From 79c1fc9c4c806bcfe9d8b6459fa0a29ea051b70f Mon Sep 17 00:00:00 2001 From: elelanv Date: Thu, 28 Aug 2025 14:01:12 +0530 Subject: [PATCH 1/3] feature: implement custom camera with CameraX and smart permission handling - Add custom camera implementation using CameraX with Compose UI - Support both photo and video capture with configurable options - Implement preview screen with video playback using ExoPlayer - Add smart permission handling that distinguishes between first-time requests, "ask every time", and permanent denials - Integrate custom camera across MainActivity, PreviewActivity, and HomeActivity - Add proper edge-to-edge display support for Android 15+ with display cutout handling - Enhance MIME type detection with fallback to file extension matching - Add dynamic permission state refresh when returning from app settings --- app/build.gradle.kts | 12 + app/src/main/AndroidManifest.xml | 15 + .../openarchive/features/main/HomeActivity.kt | 14 +- .../openarchive/features/main/MainActivity.kt | 16 +- .../features/media/MediaLaunchers.kt | 3 +- .../openarchive/features/media/Picker.kt | 92 ++++- .../features/media/PreviewActivity.kt | 29 +- .../features/media/camera/CameraActivity.kt | 262 +++++++++++++ .../media/camera/CameraBottomControls.kt | 273 +++++++++++++ .../features/media/camera/CameraConfig.kt | 19 + .../media/camera/CameraGridOverlay.kt | 74 ++++ .../media/camera/CameraPermissionScreen.kt | 186 +++++++++ .../media/camera/CameraPreviewScreen.kt | 354 +++++++++++++++++ .../features/media/camera/CameraScreen.kt | 361 ++++++++++++++++++ .../features/media/camera/CameraState.kt | 22 ++ .../features/media/camera/CameraViewModel.kt | 233 +++++++++++ gradle/libs.versions.toml | 16 +- 17 files changed, 1965 insertions(+), 16 deletions(-) create mode 100644 app/src/main/java/net/opendasharchive/openarchive/features/media/camera/CameraActivity.kt create mode 100644 app/src/main/java/net/opendasharchive/openarchive/features/media/camera/CameraBottomControls.kt create mode 100644 app/src/main/java/net/opendasharchive/openarchive/features/media/camera/CameraConfig.kt create mode 100644 app/src/main/java/net/opendasharchive/openarchive/features/media/camera/CameraGridOverlay.kt create mode 100644 app/src/main/java/net/opendasharchive/openarchive/features/media/camera/CameraPermissionScreen.kt create mode 100644 app/src/main/java/net/opendasharchive/openarchive/features/media/camera/CameraPreviewScreen.kt create mode 100644 app/src/main/java/net/opendasharchive/openarchive/features/media/camera/CameraScreen.kt create mode 100644 app/src/main/java/net/opendasharchive/openarchive/features/media/camera/CameraState.kt create mode 100644 app/src/main/java/net/opendasharchive/openarchive/features/media/camera/CameraViewModel.kt diff --git a/app/build.gradle.kts b/app/build.gradle.kts index aec1166c..8600bb66 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -214,6 +214,18 @@ dependencies { implementation(libs.coil.video) implementation(libs.coil.network) + // CameraX + implementation(libs.androidx.camera.core) + implementation(libs.androidx.camera.camera2) + implementation(libs.androidx.camera.lifecycle) + implementation(libs.androidx.camera.video) + implementation(libs.androidx.camera.view) + implementation(libs.androidx.camera.extensions) + + // Media3 ExoPlayer for video playback in camera preview + implementation(libs.androidx.media3.exoplayer) + implementation(libs.androidx.media3.ui) + // Networking and Data // Networking implementation(libs.retrofit) diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index 24733a95..8e879665 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -3,6 +3,10 @@ xmlns:tools="http://schemas.android.com/tools" android:installLocation="auto"> + + + + + @@ -214,6 +222,13 @@ android:theme="@style/SaveAppTheme.NoActionBar" android:windowSoftInputMode="stateHidden" /> + + { - // Launch the camera using Picker. - Picker.takePhotoModern(this, mediaLaunchers.modernCameraLauncher) + // Use custom camera with photo and video support + val cameraConfig = CameraConfig( + allowVideoCapture = true, + allowPhotoCapture = true, + allowMultipleCapture = false, // Single capture for main screen + enablePreview = true, + showFlashToggle = true, + showGridToggle = true, + showCameraSwitch = true + ) + Picker.launchCustomCamera(this, mediaLaunchers.customCameraLauncher, cameraConfig) } AddMediaType.GALLERY -> { // Launch the gallery/image picker. diff --git a/app/src/main/java/net/opendasharchive/openarchive/features/main/MainActivity.kt b/app/src/main/java/net/opendasharchive/openarchive/features/main/MainActivity.kt index 8d28e7e5..8829ac97 100644 --- a/app/src/main/java/net/opendasharchive/openarchive/features/main/MainActivity.kt +++ b/app/src/main/java/net/opendasharchive/openarchive/features/main/MainActivity.kt @@ -62,6 +62,7 @@ import net.opendasharchive.openarchive.features.media.ContentPickerFragment import net.opendasharchive.openarchive.features.media.MediaLaunchers import net.opendasharchive.openarchive.features.media.Picker import net.opendasharchive.openarchive.features.media.PreviewActivity +import net.opendasharchive.openarchive.features.media.camera.CameraConfig import net.opendasharchive.openarchive.features.onboarding.Onboarding23Activity import net.opendasharchive.openarchive.features.onboarding.SpaceSetupActivity import net.opendasharchive.openarchive.features.onboarding.StartDestination @@ -840,10 +841,17 @@ class MainActivity : BaseActivity(), SpaceDrawerAdapterListener, FolderDrawerAda if (Prefs.addMediaHint) { when (mediaType) { AddMediaType.CAMERA -> { - //permissionManager.checkCameraPermission { - //Picker.takePhoto(this@MainActivity, mediaLaunchers.cameraLauncher) - Picker.takePhotoModern(this@MainActivity, mediaLaunchers.modernCameraLauncher) - //} + // Use custom camera instead of system camera + val cameraConfig = CameraConfig( + allowVideoCapture = true, + allowPhotoCapture = true, + allowMultipleCapture = false, + enablePreview = true, + showFlashToggle = true, + showGridToggle = true, + showCameraSwitch = true + ) + Picker.launchCustomCamera(this@MainActivity, mediaLaunchers.customCameraLauncher, cameraConfig) } AddMediaType.GALLERY -> { diff --git a/app/src/main/java/net/opendasharchive/openarchive/features/media/MediaLaunchers.kt b/app/src/main/java/net/opendasharchive/openarchive/features/media/MediaLaunchers.kt index 871e93d5..58452d91 100644 --- a/app/src/main/java/net/opendasharchive/openarchive/features/media/MediaLaunchers.kt +++ b/app/src/main/java/net/opendasharchive/openarchive/features/media/MediaLaunchers.kt @@ -9,5 +9,6 @@ data class MediaLaunchers( val galleryLauncher: ActivityResultLauncher, // Changed val filePickerLauncher: ActivityResultLauncher, val cameraLauncher: ActivityResultLauncher, - val modernCameraLauncher: ActivityResultLauncher + val modernCameraLauncher: ActivityResultLauncher, + val customCameraLauncher: ActivityResultLauncher ) \ No newline at end of file diff --git a/app/src/main/java/net/opendasharchive/openarchive/features/media/Picker.kt b/app/src/main/java/net/opendasharchive/openarchive/features/media/Picker.kt index f7c10ee2..3490bf46 100644 --- a/app/src/main/java/net/opendasharchive/openarchive/features/media/Picker.kt +++ b/app/src/main/java/net/opendasharchive/openarchive/features/media/Picker.kt @@ -17,6 +17,8 @@ import androidx.appcompat.app.AppCompatActivity import androidx.core.content.FileProvider import androidx.lifecycle.lifecycleScope import com.google.android.material.snackbar.Snackbar +import net.opendasharchive.openarchive.features.media.camera.CameraActivity +import net.opendasharchive.openarchive.features.media.camera.CameraConfig import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.launch import net.opendasharchive.openarchive.R @@ -129,11 +131,48 @@ object Picker { } } + // Custom camera launcher + val customCameraLauncher = activity.registerForActivityResult( + ActivityResultContracts.StartActivityForResult() + ) { result -> + if (result.resultCode == Activity.RESULT_OK) { + val capturedUris = result.data?.getStringArrayListExtra(CameraActivity.EXTRA_CAPTURED_URIS) + if (!capturedUris.isNullOrEmpty()) { + val uris = capturedUris.map { Uri.parse(it) } + val snackbar = showProgressSnackBar(activity, root, activity.getString(R.string.importing_media)) + + activity.lifecycleScope.launch(Dispatchers.IO) { + try { + // Import the captured media with proof generation enabled + // This ensures proper mimetype detection and Media object setup + val media = import(activity, project(), uris, true) + + activity.lifecycleScope.launch(Dispatchers.Main) { + snackbar.dismiss() + completed(media) + } + } catch (e: Exception) { + AppLogger.e("Error processing camera captures", e) + activity.lifecycleScope.launch(Dispatchers.Main) { + snackbar.dismiss() + Toast.makeText(activity, "Failed to process captures", Toast.LENGTH_SHORT).show() + } + } + } + } else { + AppLogger.w("No captures returned from custom camera") + } + } else { + AppLogger.w("Custom camera capture cancelled or failed") + } + } + return MediaLaunchers( galleryLauncher = galleryLauncher, filePickerLauncher = filePickerLauncher, cameraLauncher = legacyCameraLauncher, - modernCameraLauncher = modernCameraLauncher + modernCameraLauncher = modernCameraLauncher, + customCameraLauncher = customCameraLauncher ) } @@ -155,6 +194,15 @@ object Picker { type = "application/*" } + /** + * Launch custom camera with configuration options. + * Supports both photo and video capture with preview functionality. + */ + fun launchCustomCamera(activity: Activity, launcher: ActivityResultLauncher, config: CameraConfig = CameraConfig()) { + val intent = CameraActivity.createIntent(activity, config) + launcher.launch(intent) + } + /** * Modern camera photo capture using TakePicture contract. * This is the recommended approach for new implementations. @@ -257,7 +305,8 @@ object Picker { } media.originalFilePath = Uri.fromFile(file).toString() - media.mimeType = Utility.getMimeType(context, uri) ?: "" + // Enhanced mime type detection for file URIs + media.mimeType = getMimeTypeWithFallback(context, uri, file?.path) media.createDate = createDate media.updateDate = media.createDate media.sStatus = Media.Status.Local @@ -315,4 +364,43 @@ object Picker { bar.show() return bar } + + /** + * Enhanced mime type detection that falls back to file extension detection + * for file URIs where ContentResolver might not have mime type info. + */ + private fun getMimeTypeWithFallback(context: Context, uri: Uri, filePath: String?): String { + // First try the standard way + val standardMimeType = Utility.getMimeType(context, uri) + if (!standardMimeType.isNullOrEmpty()) { + return standardMimeType + } + + // Fallback to file extension detection + val extension = when { + filePath != null -> File(filePath).extension + uri.path != null -> File(uri.path!!).extension + else -> null + } + + return when (extension?.lowercase()) { + "jpg", "jpeg" -> "image/jpeg" + "png" -> "image/png" + "gif" -> "image/gif" + "webp" -> "image/webp" + "mp4" -> "video/mp4" + "mov" -> "video/quicktime" + "avi" -> "video/x-msvideo" + "mkv" -> "video/x-matroska" + "webm" -> "video/webm" + "mp3" -> "audio/mpeg" + "wav" -> "audio/wav" + "ogg" -> "audio/ogg" + "m4a" -> "audio/mp4" + else -> { + AppLogger.w("Unknown file extension '$extension' for URI: $uri") + "application/octet-stream" // Generic binary type + } + } + } } \ No newline at end of file diff --git a/app/src/main/java/net/opendasharchive/openarchive/features/media/PreviewActivity.kt b/app/src/main/java/net/opendasharchive/openarchive/features/media/PreviewActivity.kt index 17d13e92..82b4a177 100644 --- a/app/src/main/java/net/opendasharchive/openarchive/features/media/PreviewActivity.kt +++ b/app/src/main/java/net/opendasharchive/openarchive/features/media/PreviewActivity.kt @@ -25,6 +25,7 @@ import net.opendasharchive.openarchive.util.extensions.applyEdgeToEdgeInsets import net.opendasharchive.openarchive.util.extensions.hide import net.opendasharchive.openarchive.util.extensions.show import net.opendasharchive.openarchive.util.extensions.toggle +import net.opendasharchive.openarchive.features.media.camera.CameraConfig class PreviewActivity : BaseActivity(), View.OnClickListener, PreviewAdapter.Listener { @@ -120,8 +121,17 @@ class PreviewActivity : BaseActivity(), View.OnClickListener, PreviewAdapter.Lis } R.id.action_upload_camera -> { - //Picker.takePhoto(this@PreviewActivity, mediaLaunchers.cameraLauncher) - Picker.takePhotoModern(this@PreviewActivity, mediaLaunchers.modernCameraLauncher) + // Use custom camera with photo and video support + val cameraConfig = CameraConfig( + allowVideoCapture = true, + allowPhotoCapture = true, + allowMultipleCapture = false, // Allow adding multiple items + enablePreview = true, + showFlashToggle = true, + showGridToggle = true, + showCameraSwitch = true + ) + Picker.launchCustomCamera(this@PreviewActivity, mediaLaunchers.customCameraLauncher, cameraConfig) } R.id.action_upload_files -> { @@ -145,10 +155,17 @@ class PreviewActivity : BaseActivity(), View.OnClickListener, PreviewAdapter.Lis val modalBottomSheet = ContentPickerFragment { action -> when (action) { AddMediaType.CAMERA -> { -// permissionManager.checkCameraPermission { - Picker.takePhotoModern(this@PreviewActivity, mediaLaunchers.modernCameraLauncher) -// } - + // Use custom camera with photo and video support + val cameraConfig = CameraConfig( + allowVideoCapture = true, + allowPhotoCapture = true, + allowMultipleCapture = true, // Allow adding multiple items in preview + enablePreview = true, + showFlashToggle = true, + showGridToggle = true, + showCameraSwitch = true + ) + Picker.launchCustomCamera(this@PreviewActivity, mediaLaunchers.customCameraLauncher, cameraConfig) } AddMediaType.FILES -> Picker.pickFiles(mediaLaunchers.filePickerLauncher) AddMediaType.GALLERY -> onClick(mBinding.btAddMore) diff --git a/app/src/main/java/net/opendasharchive/openarchive/features/media/camera/CameraActivity.kt b/app/src/main/java/net/opendasharchive/openarchive/features/media/camera/CameraActivity.kt new file mode 100644 index 00000000..7fed5b52 --- /dev/null +++ b/app/src/main/java/net/opendasharchive/openarchive/features/media/camera/CameraActivity.kt @@ -0,0 +1,262 @@ +package net.opendasharchive.openarchive.features.media.camera + +import android.Manifest +import android.app.Activity +import android.content.Intent +import android.content.pm.PackageManager +import android.net.Uri +import android.os.Bundle +import androidx.activity.ComponentActivity +import androidx.activity.compose.setContent +import androidx.activity.result.contract.ActivityResultContracts +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.padding +import androidx.compose.material3.Surface +import androidx.compose.runtime.* +import androidx.compose.ui.Modifier +import androidx.core.content.ContextCompat +import androidx.core.view.WindowCompat +import androidx.core.view.WindowInsetsCompat +import androidx.core.view.WindowInsetsControllerCompat +import android.os.Build +import net.opendasharchive.openarchive.core.logger.AppLogger +import net.opendasharchive.openarchive.features.core.BaseComposeActivity +import net.opendasharchive.openarchive.core.presentation.theme.SaveAppTheme + +class CameraActivity : BaseComposeActivity() { + + companion object { + const val EXTRA_CAMERA_CONFIG = "camera_config" + const val EXTRA_CAPTURED_URIS = "captured_uris" + const val REQUEST_CODE_CAMERA = 1001 + + fun createIntent( + activity: Activity, + config: CameraConfig = CameraConfig() + ): Intent { + return Intent(activity, CameraActivity::class.java).apply { + putExtra(EXTRA_CAMERA_CONFIG, config) + } + } + } + + private var cameraConfig: CameraConfig? = null + private var showPermissionScreen by mutableStateOf(false) + private var isCameraPermissionPermanentlyDenied by mutableStateOf(false) + private var isAudioPermissionPermanentlyDenied by mutableStateOf(false) + private var hasCameraPermissionBeenRequested by mutableStateOf(false) + private var hasAudioPermissionBeenRequested by mutableStateOf(false) + + private val cameraPermissionLauncher = registerForActivityResult( + ActivityResultContracts.RequestPermission() + ) { granted -> + AppLogger.d("Camera permission result: $granted") + hasCameraPermissionBeenRequested = true + + if (granted) { + showPermissionScreen = false + isCameraPermissionPermanentlyDenied = false + // If camera permission is granted, request audio permission for video if needed + requestAudioPermissionIfNeeded() + } else { + // Check if permission was permanently denied (only after we've requested it) + isCameraPermissionPermanentlyDenied = !shouldShowRequestPermissionRationale(Manifest.permission.CAMERA) + showPermissionScreen = true + } + } + + private val audioPermissionLauncher = registerForActivityResult( + ActivityResultContracts.RequestPermission() + ) { granted -> + AppLogger.d("Audio permission result: $granted") + hasAudioPermissionBeenRequested = true + + if (!granted) { + // Check if audio permission was permanently denied (only after we've requested it) + isAudioPermissionPermanentlyDenied = !shouldShowRequestPermissionRationale(Manifest.permission.RECORD_AUDIO) + } else { + isAudioPermissionPermanentlyDenied = false + } + // Audio permission result doesn't affect UI state for now + // Video recording will work without audio if needed + } + + private fun checkCameraPermission(): Boolean { + return ContextCompat.checkSelfPermission( + this, Manifest.permission.CAMERA + ) == PackageManager.PERMISSION_GRANTED + } + + private fun requestCameraPermission() { + cameraPermissionLauncher.launch(Manifest.permission.CAMERA) + } + + private fun requestAudioPermissionIfNeeded() { + if (cameraConfig?.allowVideoCapture == true) { + val audioGranted = ContextCompat.checkSelfPermission( + this, Manifest.permission.RECORD_AUDIO + ) == PackageManager.PERMISSION_GRANTED + + if (!audioGranted) { + audioPermissionLauncher.launch(Manifest.permission.RECORD_AUDIO) + } + } + } + + private fun setupEdgeToEdge() { + // Enable edge-to-edge display + WindowCompat.setDecorFitsSystemWindows(window, false) + + val windowInsetsController = WindowCompat.getInsetsController(window, window.decorView) + + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) { + // Android 11+ (API 30+) - Enhanced for Android 15 + windowInsetsController.systemBarsBehavior = WindowInsetsControllerCompat.BEHAVIOR_SHOW_TRANSIENT_BARS_BY_SWIPE + + // Hide system bars but keep them accessible with gestures + windowInsetsController.hide( + WindowInsetsCompat.Type.statusBars() or + WindowInsetsCompat.Type.navigationBars() + ) + + // For Android 15+, ensure proper handling of display cutouts and camera cutouts + if (Build.VERSION.SDK_INT >= 35) { + // Android 15 (API 35) specific enhancements + // The display cutout padding in Compose will handle camera notches + AppLogger.d("Android 15+ detected - using enhanced edge-to-edge with cutout support") + } + } else { + // Legacy approach for older Android versions + windowInsetsController.systemBarsBehavior = WindowInsetsControllerCompat.BEHAVIOR_SHOW_TRANSIENT_BARS_BY_SWIPE + windowInsetsController.hide(WindowInsetsCompat.Type.systemBars()) + } + + // Make status bar and navigation bar transparent + window.statusBarColor = android.graphics.Color.TRANSPARENT + window.navigationBarColor = android.graphics.Color.TRANSPARENT + + // For Android 15+, also handle the navigation bar appearance + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) { + window.isNavigationBarContrastEnforced = false + } + } + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + + // Enhanced edge-to-edge setup for Android 15+ and camera cutouts + setupEdgeToEdge() + + // Get camera config from intent + cameraConfig = intent.getSerializableExtra(EXTRA_CAMERA_CONFIG) as? CameraConfig + ?: CameraConfig() + + // Check camera permission and request if needed + if (checkCameraPermission()) { + showPermissionScreen = false + // If camera permission is granted, request audio permission for video if needed + requestAudioPermissionIfNeeded() + } else { + // For first launch, we don't know if it's permanently denied yet + // Just show permission screen and let user try to grant + isCameraPermissionPermanentlyDenied = false + isAudioPermissionPermanentlyDenied = false + + // Show permission screen immediately + showPermissionScreen = true + } + + setContent { + SaveAppTheme { + Surface( + modifier = Modifier.fillMaxSize() + ) { + if (showPermissionScreen) { + CameraPermissionScreen( + isCameraPermissionPermanentlyDenied = isCameraPermissionPermanentlyDenied, + isAudioPermissionPermanentlyDenied = isAudioPermissionPermanentlyDenied, + needsAudioPermission = cameraConfig?.allowVideoCapture == true, + onRequestPermissions = { requestCameraPermission() }, + onOpenSettings = { + // Open app settings + val intent = android.content.Intent(android.provider.Settings.ACTION_APPLICATION_DETAILS_SETTINGS).apply { + data = android.net.Uri.fromParts("package", packageName, null) + } + startActivity(intent) + }, + onCancel = { finishWithResult(Activity.RESULT_CANCELED, emptyList()) } + ) + } else { + CameraScreen( + config = cameraConfig ?: CameraConfig(), + onCaptureComplete = { uris -> + finishWithResult(Activity.RESULT_OK, uris) + }, + onCancel = { + finishWithResult(Activity.RESULT_CANCELED, emptyList()) + } + ) + } + } + } + } + } + + override fun onResume() { + super.onResume() + // Re-apply immersive mode when returning to the activity + setupEdgeToEdge() + + // Re-check permissions when returning from settings + checkAndUpdatePermissionStates() + } + + private fun checkAndUpdatePermissionStates() { + val wasCameraPermissionGranted = checkCameraPermission() + + if (wasCameraPermissionGranted && showPermissionScreen) { + // Camera permission was granted while we were showing permission screen + showPermissionScreen = false + isCameraPermissionPermanentlyDenied = false + + // If camera permission is now granted, request audio permission for video if needed + requestAudioPermissionIfNeeded() + } else if (!wasCameraPermissionGranted && !showPermissionScreen) { + // Camera permission was revoked while we were showing camera + // Only consider it permanently denied if we've already requested it before + isCameraPermissionPermanentlyDenied = hasCameraPermissionBeenRequested && + !shouldShowRequestPermissionRationale(Manifest.permission.CAMERA) + showPermissionScreen = true + } + + // Update audio permission state if video capture is enabled + if (cameraConfig?.allowVideoCapture == true) { + val isAudioGranted = ContextCompat.checkSelfPermission( + this, Manifest.permission.RECORD_AUDIO + ) == PackageManager.PERMISSION_GRANTED + + if (!isAudioGranted && hasAudioPermissionBeenRequested) { + // Only consider it permanently denied if we've already requested it before + isAudioPermissionPermanentlyDenied = !shouldShowRequestPermissionRationale(Manifest.permission.RECORD_AUDIO) + } else if (isAudioGranted) { + isAudioPermissionPermanentlyDenied = false + } + } + } + + override fun onWindowFocusChanged(hasFocus: Boolean) { + super.onWindowFocusChanged(hasFocus) + if (hasFocus) { + // Re-apply immersive mode when the window regains focus + setupEdgeToEdge() + } + } + + private fun finishWithResult(resultCode: Int, uris: List) { + val resultIntent = Intent().apply { + putExtra(EXTRA_CAPTURED_URIS, ArrayList(uris.map { it.toString() })) + } + setResult(resultCode, resultIntent) + finish() + } +} \ No newline at end of file diff --git a/app/src/main/java/net/opendasharchive/openarchive/features/media/camera/CameraBottomControls.kt b/app/src/main/java/net/opendasharchive/openarchive/features/media/camera/CameraBottomControls.kt new file mode 100644 index 00000000..1c6a646b --- /dev/null +++ b/app/src/main/java/net/opendasharchive/openarchive/features/media/camera/CameraBottomControls.kt @@ -0,0 +1,273 @@ +package net.opendasharchive.openarchive.features.media.camera + +import androidx.compose.animation.core.* +import androidx.compose.foundation.Canvas +import androidx.compose.foundation.background +import androidx.compose.foundation.border +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.* +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.* +import androidx.compose.material3.* +import androidx.compose.runtime.* +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.draw.scale +import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.StrokeCap +import androidx.compose.ui.graphics.drawscope.Stroke +import androidx.compose.ui.platform.LocalDensity +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp + +@Composable +fun CameraBottomControls( + config: CameraConfig, + cameraState: CameraState, + onCaptureModeChange: (CameraCaptureMode) -> Unit, + onPhotoCapture: () -> Unit, + onVideoStart: () -> Unit, + onVideoStop: () -> Unit, + onDone: () -> Unit, + modifier: Modifier = Modifier +) { + Column( + modifier = modifier + .fillMaxWidth() + .padding(horizontal = 16.dp, vertical = 32.dp), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.spacedBy(24.dp) + ) { + // Mode selector (Photo/Video) + if (config.allowPhotoCapture && config.allowVideoCapture) { + CameraModeSelector( + currentMode = cameraState.captureMode, + onModeChange = onCaptureModeChange + ) + } + + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically + ) { + // Captured items count (left side) + if (config.allowMultipleCapture) { + CapturedItemsIndicator( + count = cameraState.capturedItems.size, + onDone = onDone + ) + } else { + Spacer(modifier = Modifier.width(80.dp)) + } + + // Main capture button (center) + CameraCaptureButton( + captureMode = cameraState.captureMode, + isRecording = cameraState.isRecording, + onPhotoCapture = onPhotoCapture, + onVideoStart = onVideoStart, + onVideoStop = onVideoStop + ) + + // Right side spacer or gallery thumbnail + Spacer(modifier = Modifier.width(80.dp)) + } + } +} + +@Composable +private fun CameraModeSelector( + currentMode: CameraCaptureMode, + onModeChange: (CameraCaptureMode) -> Unit, + modifier: Modifier = Modifier +) { + Row( + modifier = modifier + .background( + Color.Black.copy(alpha = 0.3f), + RoundedCornerShape(20.dp) + ) + .padding(4.dp), + horizontalArrangement = Arrangement.spacedBy(4.dp) + ) { + CameraCaptureMode.entries.forEach { mode -> + val isSelected = currentMode == mode + Text( + text = when (mode) { + CameraCaptureMode.PHOTO -> "PHOTO" + CameraCaptureMode.VIDEO -> "VIDEO" + }, + modifier = Modifier + .clip(RoundedCornerShape(16.dp)) + .background( + if (isSelected) Color.White else Color.Transparent + ) + .clickable { onModeChange(mode) } + .padding(horizontal = 16.dp, vertical = 8.dp), + color = if (isSelected) Color.Black else Color.White, + fontSize = 14.sp, + fontWeight = if (isSelected) FontWeight.Bold else FontWeight.Normal + ) + } + } +} + +@Composable +private fun CameraCaptureButton( + captureMode: CameraCaptureMode, + isRecording: Boolean, + onPhotoCapture: () -> Unit, + onVideoStart: () -> Unit, + onVideoStop: () -> Unit, + modifier: Modifier = Modifier +) { + val density = LocalDensity.current + + Box( + modifier = modifier.size(80.dp), + contentAlignment = Alignment.Center + ) { + when (captureMode) { + CameraCaptureMode.PHOTO -> { + // Photo capture button + val scale by animateFloatAsState( + targetValue = 1f, + animationSpec = spring(stiffness = Spring.StiffnessLow), + label = "photo_button_scale" + ) + + Box( + modifier = Modifier + .size(70.dp) + .scale(scale) + .clip(CircleShape) + .background(Color.White) + .border(4.dp, Color.Gray, CircleShape) + .clickable { onPhotoCapture() }, + contentAlignment = Alignment.Center + ) { + Icon( + imageVector = Icons.Default.CameraAlt, + contentDescription = "Capture Photo", + tint = Color.Black, + modifier = Modifier.size(32.dp) + ) + } + } + + CameraCaptureMode.VIDEO -> { + // Video capture button with recording animation + if (isRecording) { + val infiniteTransition = rememberInfiniteTransition(label = "recording_animation") + val pulseScale by infiniteTransition.animateFloat( + initialValue = 1f, + targetValue = 1.1f, + animationSpec = infiniteRepeatable( + animation = tween(1000, easing = EaseInOut), + repeatMode = RepeatMode.Reverse + ), + label = "pulse_scale" + ) + + // Recording indicator with pulsing red circle + Box( + modifier = Modifier + .size(70.dp) + .scale(pulseScale) + .clip(CircleShape) + .background(Color.Red) + .clickable { onVideoStop() }, + contentAlignment = Alignment.Center + ) { + // Stop icon (square) + Box( + modifier = Modifier + .size(24.dp) + .background(Color.White, RoundedCornerShape(4.dp)) + ) + } + + // Recording pulse effect + Canvas( + modifier = Modifier.size(80.dp) + ) { + val center = Offset(size.width / 2, size.height / 2) + val radius = size.minDimension / 2 + + // Outer pulsing circle + drawCircle( + color = Color.Red.copy(alpha = 0.3f), + radius = radius * pulseScale, + center = center, + style = Stroke( + width = with(density) { 2.dp.toPx() }, + cap = StrokeCap.Round + ) + ) + } + } else { + // Start recording button + Box( + modifier = Modifier + .size(70.dp) + .clip(CircleShape) + .background(Color.Red) + .border(4.dp, Color.White, CircleShape) + .clickable { onVideoStart() }, + contentAlignment = Alignment.Center + ) { + Icon( + imageVector = Icons.Default.Videocam, + contentDescription = "Start Recording", + tint = Color.White, + modifier = Modifier.size(32.dp) + ) + } + } + } + } + } +} + +@Composable +private fun CapturedItemsIndicator( + count: Int, + onDone: () -> Unit, + modifier: Modifier = Modifier +) { + if (count > 0) { + Button( + onClick = onDone, + modifier = modifier, + colors = ButtonDefaults.buttonColors( + containerColor = Color.Blue, + contentColor = Color.White + ), + shape = RoundedCornerShape(20.dp) + ) { + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(4.dp) + ) { + Icon( + imageVector = Icons.Default.Check, + contentDescription = "Done", + modifier = Modifier.size(16.dp) + ) + Text( + text = "Done ($count)", + fontSize = 14.sp, + fontWeight = FontWeight.Medium + ) + } + } + } else { + Box(modifier = modifier.size(80.dp, 40.dp)) + } +} \ No newline at end of file diff --git a/app/src/main/java/net/opendasharchive/openarchive/features/media/camera/CameraConfig.kt b/app/src/main/java/net/opendasharchive/openarchive/features/media/camera/CameraConfig.kt new file mode 100644 index 00000000..73390678 --- /dev/null +++ b/app/src/main/java/net/opendasharchive/openarchive/features/media/camera/CameraConfig.kt @@ -0,0 +1,19 @@ +package net.opendasharchive.openarchive.features.media.camera + +import java.io.Serializable + +data class CameraConfig( + val allowVideoCapture: Boolean = true, + val allowPhotoCapture: Boolean = true, + val allowMultipleCapture: Boolean = false, + val enablePreview: Boolean = true, + val showFlashToggle: Boolean = true, + val showGridToggle: Boolean = true, + val showCameraSwitch: Boolean = true, + val initialMode: CameraCaptureMode = CameraCaptureMode.PHOTO +) : Serializable + +enum class CameraCaptureMode : Serializable { + PHOTO, + VIDEO +} \ No newline at end of file diff --git a/app/src/main/java/net/opendasharchive/openarchive/features/media/camera/CameraGridOverlay.kt b/app/src/main/java/net/opendasharchive/openarchive/features/media/camera/CameraGridOverlay.kt new file mode 100644 index 00000000..b5560b18 --- /dev/null +++ b/app/src/main/java/net/opendasharchive/openarchive/features/media/camera/CameraGridOverlay.kt @@ -0,0 +1,74 @@ +package net.opendasharchive.openarchive.features.media.camera + +import androidx.compose.foundation.Canvas +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.PathEffect +import androidx.compose.ui.graphics.StrokeCap +import androidx.compose.ui.platform.LocalDensity +import androidx.compose.ui.unit.dp + +@Composable +fun CameraGridOverlay( + modifier: Modifier = Modifier +) { + val density = LocalDensity.current + val strokeWidth = with(density) { 1.dp.toPx() } + + Canvas(modifier = modifier.fillMaxSize()) { + val width = size.width + val height = size.height + + // Vertical lines (rule of thirds) + val verticalLine1X = width / 3f + val verticalLine2X = (width * 2f) / 3f + + // Horizontal lines (rule of thirds) + val horizontalLine1Y = height / 3f + val horizontalLine2Y = (height * 2f) / 3f + + val gridColor = Color.White.copy(alpha = 0.5f) + val pathEffect = PathEffect.dashPathEffect(floatArrayOf(10f, 10f), 0f) + + // Draw vertical lines + drawLine( + color = gridColor, + start = Offset(verticalLine1X, 0f), + end = Offset(verticalLine1X, height), + strokeWidth = strokeWidth, + cap = StrokeCap.Round, + pathEffect = pathEffect + ) + + drawLine( + color = gridColor, + start = Offset(verticalLine2X, 0f), + end = Offset(verticalLine2X, height), + strokeWidth = strokeWidth, + cap = StrokeCap.Round, + pathEffect = pathEffect + ) + + // Draw horizontal lines + drawLine( + color = gridColor, + start = Offset(0f, horizontalLine1Y), + end = Offset(width, horizontalLine1Y), + strokeWidth = strokeWidth, + cap = StrokeCap.Round, + pathEffect = pathEffect + ) + + drawLine( + color = gridColor, + start = Offset(0f, horizontalLine2Y), + end = Offset(width, horizontalLine2Y), + strokeWidth = strokeWidth, + cap = StrokeCap.Round, + pathEffect = pathEffect + ) + } +} \ No newline at end of file diff --git a/app/src/main/java/net/opendasharchive/openarchive/features/media/camera/CameraPermissionScreen.kt b/app/src/main/java/net/opendasharchive/openarchive/features/media/camera/CameraPermissionScreen.kt new file mode 100644 index 00000000..c4bd2b7d --- /dev/null +++ b/app/src/main/java/net/opendasharchive/openarchive/features/media/camera/CameraPermissionScreen.kt @@ -0,0 +1,186 @@ +package net.opendasharchive.openarchive.features.media.camera + +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.* +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.* +import androidx.compose.material3.* +import androidx.compose.runtime.* +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import android.content.Intent +import android.net.Uri +import android.provider.Settings + +@Composable +fun CameraPermissionScreen( + modifier: Modifier = Modifier, + isCameraPermissionPermanentlyDenied: Boolean = false, + isAudioPermissionPermanentlyDenied: Boolean = false, + needsAudioPermission: Boolean = false, + onRequestPermissions: () -> Unit, + onOpenSettings: () -> Unit = {}, + onCancel: () -> Unit +) { + val context = LocalContext.current + + Box( + modifier = modifier + .fillMaxSize() + .background(Color.Black), + contentAlignment = Alignment.Center + ) { + Column( + modifier = Modifier + .fillMaxWidth() + .padding(32.dp), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.Center + ) { + // Header + Icon( + imageVector = Icons.Default.CameraAlt, + contentDescription = null, + modifier = Modifier.size(80.dp), + tint = Color.White + ) + + Spacer(modifier = Modifier.height(24.dp)) + + Text( + text = if (isCameraPermissionPermanentlyDenied) + "Camera Access Blocked" + else + "Camera Permission Required", + style = MaterialTheme.typography.headlineMedium, + fontWeight = FontWeight.Bold, + textAlign = TextAlign.Center, + color = Color.White + ) + + Spacer(modifier = Modifier.height(16.dp)) + + Text( + text = when { + isCameraPermissionPermanentlyDenied -> { + val audioText = if (needsAudioPermission && isAudioPermissionPermanentlyDenied) + " and microphone" else "" + "Camera$audioText access has been permanently denied. Please enable it in Settings to use this feature." + } + needsAudioPermission -> { + "This app needs access to your camera and microphone to capture photos and videos." + } + else -> { + "This app needs access to your camera to capture photos." + } + }, + style = MaterialTheme.typography.bodyLarge, + textAlign = TextAlign.Center, + color = Color.White.copy(alpha = 0.8f) + ) + + // Action buttons + if (isCameraPermissionPermanentlyDenied) { + // If permanently denied, show only Open Settings and Cancel + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.spacedBy(16.dp) + ) { + OutlinedButton( + onClick = onCancel, + modifier = Modifier.weight(1f), + colors = ButtonDefaults.outlinedButtonColors( + contentColor = Color.White + ), + border = ButtonDefaults.outlinedButtonBorder(enabled = true).copy( + brush = androidx.compose.ui.graphics.SolidColor(Color.White) + ) + ) { + Text("Cancel", color = Color.White) + } + + Button( + onClick = onOpenSettings, + modifier = Modifier.weight(1f), + colors = ButtonDefaults.buttonColors( + containerColor = MaterialTheme.colorScheme.primary + ) + ) { + Row( + horizontalArrangement = Arrangement.spacedBy(8.dp), + verticalAlignment = Alignment.CenterVertically + ) { + Icon( + imageVector = Icons.Default.Settings, + contentDescription = null, + modifier = Modifier.size(18.dp) + ) + Text("Open Settings") + } + } + } + } else { + // Normal permission request flow + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.spacedBy(16.dp) + ) { + OutlinedButton( + onClick = onCancel, + modifier = Modifier.weight(1f), + colors = ButtonDefaults.outlinedButtonColors( + contentColor = Color.White + ), + border = ButtonDefaults.outlinedButtonBorder(enabled = true).copy( + brush = androidx.compose.ui.graphics.SolidColor(Color.White) + ) + ) { + Text("Cancel", color = Color.White) + } + + Button( + onClick = onRequestPermissions, + modifier = Modifier.weight(1f), + colors = ButtonDefaults.buttonColors( + containerColor = MaterialTheme.colorScheme.primary + ) + ) { + Text("Grant Permission") + } + } + } + + // Show additional settings link only if not permanently denied + if (!isCameraPermissionPermanentlyDenied) { + Spacer(modifier = Modifier.height(24.dp)) + + TextButton( + onClick = onOpenSettings + ) { + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(8.dp) + ) { + Icon( + imageVector = Icons.Default.Settings, + contentDescription = null, + modifier = Modifier.size(18.dp), + tint = Color.White.copy(alpha = 0.7f) + ) + Text( + text = "Open App Settings", + color = Color.White.copy(alpha = 0.7f), + fontSize = 14.sp + ) + } + } + } + } + } +} \ No newline at end of file diff --git a/app/src/main/java/net/opendasharchive/openarchive/features/media/camera/CameraPreviewScreen.kt b/app/src/main/java/net/opendasharchive/openarchive/features/media/camera/CameraPreviewScreen.kt new file mode 100644 index 00000000..62f1a1f9 --- /dev/null +++ b/app/src/main/java/net/opendasharchive/openarchive/features/media/camera/CameraPreviewScreen.kt @@ -0,0 +1,354 @@ +package net.opendasharchive.openarchive.features.media.camera + +import android.media.MediaMetadataRetriever +import android.net.Uri +import androidx.compose.foundation.background +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.* +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.* +import androidx.compose.material3.* +import androidx.compose.runtime.* +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.layout.ContentScale +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import androidx.compose.ui.viewinterop.AndroidView +import androidx.media3.common.MediaItem +import androidx.media3.common.Player +import androidx.media3.exoplayer.ExoPlayer +import androidx.media3.ui.PlayerView +import coil3.compose.AsyncImage +import coil3.request.ImageRequest +import coil3.video.videoFramePercent +import net.opendasharchive.openarchive.core.logger.AppLogger + +@Composable +fun CameraPreviewScreen( + item: CapturedItem, + config: CameraConfig, + onConfirm: (CapturedItem) -> Unit, + onRetake: (CapturedItem) -> Unit, + onCancel: () -> Unit, + modifier: Modifier = Modifier +) { + val context = LocalContext.current + + Box( + modifier = modifier + .fillMaxSize() + .background(Color.Black) + ) { + // Preview content + when (item.type) { + CameraCaptureMode.PHOTO -> { + // Photo preview + AsyncImage( + model = ImageRequest.Builder(context) + .data(item.uri) + .build(), + contentDescription = "Captured Photo", + modifier = Modifier.fillMaxSize(), + contentScale = ContentScale.Fit + ) + } + CameraCaptureMode.VIDEO -> { + // Video preview with playback capability + VideoPreviewPlayer( + uri = item.uri, + modifier = Modifier.fillMaxSize() + ) + + // Video duration overlay + VideoDurationOverlay( + uri = item.uri, + modifier = Modifier.align(Alignment.TopEnd) + ) + } + } + + // Top controls + Row( + modifier = Modifier + .align(Alignment.TopCenter) + .fillMaxWidth() + .statusBarsPadding() + .displayCutoutPadding() + .padding(16.dp), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically + ) { + // Back/Cancel button + IconButton( + onClick = onCancel, + modifier = Modifier + .size(48.dp) + .background(Color.Black.copy(alpha = 0.6f), CircleShape) + ) { + Icon( + imageVector = Icons.Default.ArrowBack, + contentDescription = "Back", + tint = Color.White + ) + } + + Spacer(modifier = Modifier.weight(1f)) + + // Media type indicator + Row( + modifier = Modifier + .background(Color.Black.copy(alpha = 0.6f), RoundedCornerShape(16.dp)) + .padding(horizontal = 12.dp, vertical = 6.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(4.dp) + ) { + Icon( + imageVector = when (item.type) { + CameraCaptureMode.PHOTO -> Icons.Default.Photo + CameraCaptureMode.VIDEO -> Icons.Default.Videocam + }, + contentDescription = item.type.name, + tint = Color.White, + modifier = Modifier.size(16.dp) + ) + Text( + text = when (item.type) { + CameraCaptureMode.PHOTO -> "PHOTO" + CameraCaptureMode.VIDEO -> "VIDEO" + }, + color = Color.White, + fontSize = 12.sp, + fontWeight = FontWeight.Medium + ) + } + } + + // Bottom controls + Row( + modifier = Modifier + .align(Alignment.BottomCenter) + .fillMaxWidth() + .navigationBarsPadding() + .padding(32.dp), + horizontalArrangement = Arrangement.SpaceEvenly, + verticalAlignment = Alignment.CenterVertically + ) { + // Retake button + Button( + onClick = { onRetake(item) }, + modifier = Modifier.height(56.dp), + colors = ButtonDefaults.buttonColors( + containerColor = Color.Transparent, + contentColor = Color.White + ), + border = ButtonDefaults.outlinedButtonBorder.copy( + brush = androidx.compose.ui.graphics.SolidColor(Color.White) + ), + shape = RoundedCornerShape(28.dp) + ) { + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(8.dp) + ) { + Icon( + imageVector = Icons.Default.Refresh, + contentDescription = "Retake", + modifier = Modifier.size(20.dp) + ) + Text( + text = "Retake", + fontSize = 16.sp, + fontWeight = FontWeight.Medium + ) + } + } + + // Confirm/Use button + Button( + onClick = { onConfirm(item) }, + modifier = Modifier.height(56.dp), + colors = ButtonDefaults.buttonColors( + containerColor = Color.Blue, + contentColor = Color.White + ), + shape = RoundedCornerShape(28.dp) + ) { + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(8.dp) + ) { + Icon( + imageVector = Icons.Default.Check, + contentDescription = "Use", + modifier = Modifier.size(20.dp) + ) + Text( + text = if (config.allowMultipleCapture) "Use" else "Done", + fontSize = 16.sp, + fontWeight = FontWeight.Medium + ) + } + } + } + } +} + +@Composable +private fun VideoDurationOverlay( + uri: Uri, + modifier: Modifier = Modifier +) { + val context = LocalContext.current + var duration by remember { mutableLongStateOf(0L) } + + LaunchedEffect(uri) { + try { + val retriever = MediaMetadataRetriever() + retriever.setDataSource(context, uri) + duration = retriever.extractMetadata(MediaMetadataRetriever.METADATA_KEY_DURATION)?.toLongOrNull() ?: 0L + retriever.release() + } catch (e: Exception) { + AppLogger.e("Error getting video duration", e) + duration = 0L + } + } + + if (duration > 0) { + val seconds = duration / 1000 + val minutes = seconds / 60 + val remainingSeconds = seconds % 60 + + Text( + text = String.format("%02d:%02d", minutes, remainingSeconds), + modifier = modifier + .padding(16.dp) + .background(Color.Black.copy(alpha = 0.6f), RoundedCornerShape(8.dp)) + .padding(horizontal = 8.dp, vertical = 4.dp), + color = Color.White, + fontSize = 14.sp, + fontWeight = FontWeight.Medium + ) + } +} + +@Composable +private fun VideoPreviewPlayer( + uri: Uri, + modifier: Modifier = Modifier +) { + val context = LocalContext.current + var isPlaying by remember { mutableStateOf(false) } + var showControls by remember { mutableStateOf(true) } + + // Create ExoPlayer + val exoPlayer = remember { + ExoPlayer.Builder(context) + .build() + .apply { + setMediaItem(MediaItem.fromUri(uri)) + prepare() + playWhenReady = false + repeatMode = Player.REPEAT_MODE_ONE // Loop the video for preview + } + } + + // Update playing state based on player state + LaunchedEffect(exoPlayer) { + val listener = object : Player.Listener { + override fun onIsPlayingChanged(playing: Boolean) { + isPlaying = playing + } + } + exoPlayer.addListener(listener) + } + + // Cleanup player when composable is disposed + DisposableEffect(exoPlayer) { + onDispose { + exoPlayer.release() + } + } + + Box(modifier = modifier) { + // Video player view + AndroidView( + factory = { ctx -> + PlayerView(ctx).apply { + player = exoPlayer + setShowBuffering(PlayerView.SHOW_BUFFERING_WHEN_PLAYING) + controllerAutoShow = false + hideController() + } + }, + modifier = Modifier + .fillMaxSize() + .clickable { + showControls = !showControls + } + ) + + // Custom play/pause overlay + if (showControls) { + Box( + modifier = Modifier + .align(Alignment.Center) + .size(80.dp) + .background(Color.Black.copy(alpha = 0.6f), CircleShape) + .clickable { + if (isPlaying) { + exoPlayer.pause() + } else { + exoPlayer.play() + } + }, + contentAlignment = Alignment.Center + ) { + Icon( + imageVector = if (isPlaying) Icons.Default.Pause else Icons.Default.PlayArrow, + contentDescription = if (isPlaying) "Pause" else "Play", + tint = Color.White, + modifier = Modifier.size(40.dp) + ) + } + + // Hide controls after a delay when playing + LaunchedEffect(isPlaying, showControls) { + if (isPlaying && showControls) { + kotlinx.coroutines.delay(3000) // Hide after 3 seconds + showControls = false + } + } + } + + // Video indicator in top-left corner + Row( + modifier = Modifier + .align(Alignment.TopStart) + .padding(16.dp) + .background(Color.Black.copy(alpha = 0.6f), RoundedCornerShape(12.dp)) + .padding(horizontal = 8.dp, vertical = 4.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(4.dp) + ) { + Icon( + imageVector = Icons.Default.Videocam, + contentDescription = "Video", + tint = Color.White, + modifier = Modifier.size(14.dp) + ) + Text( + text = "VIDEO", + color = Color.White, + fontSize = 12.sp, + fontWeight = FontWeight.Medium + ) + } + } +} \ No newline at end of file diff --git a/app/src/main/java/net/opendasharchive/openarchive/features/media/camera/CameraScreen.kt b/app/src/main/java/net/opendasharchive/openarchive/features/media/camera/CameraScreen.kt new file mode 100644 index 00000000..9c42237c --- /dev/null +++ b/app/src/main/java/net/opendasharchive/openarchive/features/media/camera/CameraScreen.kt @@ -0,0 +1,361 @@ +package net.opendasharchive.openarchive.features.media.camera + +import android.content.Context +import android.net.Uri +import androidx.camera.core.CameraSelector +import androidx.camera.core.ImageCapture +import androidx.camera.core.Preview +import androidx.camera.lifecycle.ProcessCameraProvider +import androidx.camera.video.Quality +import androidx.camera.video.QualitySelector +import androidx.camera.video.Recorder +import androidx.camera.video.VideoCapture +import androidx.camera.view.PreviewView +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.* +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.* +import androidx.compose.material3.* +import androidx.compose.runtime.* +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.platform.LocalLifecycleOwner +import androidx.compose.ui.unit.dp +import androidx.compose.ui.viewinterop.AndroidView +import androidx.core.content.ContextCompat +import androidx.lifecycle.viewmodel.compose.viewModel +import com.google.common.util.concurrent.ListenableFuture +import net.opendasharchive.openarchive.core.logger.AppLogger +import java.util.concurrent.ExecutorService +import java.util.concurrent.Executors + +@Composable +fun CameraScreen( + modifier: Modifier = Modifier, + config: CameraConfig = CameraConfig(), + onCaptureComplete: (List) -> Unit, + onCancel: () -> Unit, + viewModel: CameraViewModel = viewModel() +) { + val context = LocalContext.current + val lifecycleOwner = LocalLifecycleOwner.current + val cameraState by viewModel.state.collectAsState() + + var imageCapture by remember { mutableStateOf(null) } + var videoCapture by remember { mutableStateOf?>(null) } + var cameraProvider by remember { mutableStateOf(null) } + var cameraExecutor by remember { mutableStateOf(null) } + + // Initialize camera executor + LaunchedEffect(Unit) { + cameraExecutor = Executors.newSingleThreadExecutor() + } + + // Cleanup on disposal + DisposableEffect(Unit) { + onDispose { + cameraExecutor?.shutdown() + } + } + + // Show preview screen when item is captured + if (cameraState.showPreview && cameraState.currentPreviewItem != null) { + CameraPreviewScreen( + item = cameraState.currentPreviewItem!!, + config = config, + onConfirm = { item -> + val uris = viewModel.confirmCapture(item) + if (config.allowMultipleCapture) { + viewModel.hidePreview() + } else { + onCaptureComplete(uris) + } + }, + onRetake = { item -> + viewModel.retakeCapture(item) + }, + onCancel = { + viewModel.hidePreview() + } + ) + } else { + // Main camera interface + Box( + modifier = modifier + .fillMaxSize() + .background(Color.Black) + ) { + // Camera preview + var previewView by remember { mutableStateOf(null) } + + // Setup camera when preview view is ready or camera settings change + LaunchedEffect(previewView, cameraState.isFrontCamera) { + previewView?.let { preview -> + setupCamera( + context = context, + previewView = preview, + lifecycleOwner = lifecycleOwner, + cameraState = cameraState, + onCameraReady = { provider, imgCapture, vidCapture -> + cameraProvider = provider + imageCapture = imgCapture + videoCapture = vidCapture + }, + onFlashSupportChanged = { isSupported -> + viewModel.updateFlashSupport(isSupported) + } + ) + } + } + + AndroidView( + factory = { ctx -> + PreviewView(ctx).apply { + scaleType = PreviewView.ScaleType.FILL_CENTER + implementationMode = PreviewView.ImplementationMode.COMPATIBLE + }.also { preview -> + previewView = preview + } + }, + modifier = Modifier.fillMaxSize() + ) + + // Grid overlay + if (cameraState.showGrid && config.showGridToggle) { + CameraGridOverlay(modifier = Modifier.fillMaxSize()) + } + + // Top controls with system bars and display cutout padding + CameraTopControls( + config = config, + cameraState = cameraState, + onFlashToggle = { viewModel.toggleFlashMode() }, + onGridToggle = { viewModel.toggleGrid() }, + onCameraSwitch = { + viewModel.toggleCamera() + }, + onCancel = onCancel, + modifier = Modifier + .align(Alignment.TopCenter) + .statusBarsPadding() + .displayCutoutPadding() + ) + + // Bottom controls with navigation bars padding + CameraBottomControls( + config = config, + cameraState = cameraState, + onCaptureModeChange = { mode -> + viewModel.updateCaptureMode(mode) + }, + onPhotoCapture = { + imageCapture?.let { capture -> + viewModel.capturePhoto( + context = context, + imageCapture = capture, + onSuccess = { uri -> + AppLogger.d("Photo captured: $uri") + }, + onError = { error -> + AppLogger.e("Photo capture failed", error) + } + ) + } + }, + onVideoStart = { + videoCapture?.let { capture -> + viewModel.startVideoRecording( + context = context, + videoCapture = capture, + onSuccess = { uri -> + AppLogger.d("Video captured: $uri") + }, + onError = { error -> + AppLogger.e("Video capture failed", error) + } + ) + } + }, + onVideoStop = { + viewModel.stopVideoRecording() + }, + onDone = { + val allUris = viewModel.getAllCapturedUris() + if (allUris.isNotEmpty()) { + onCaptureComplete(allUris) + } + }, + modifier = Modifier + .align(Alignment.BottomCenter) + .navigationBarsPadding() + ) + } + } +} + +@Composable +private fun CameraTopControls( + config: CameraConfig, + cameraState: CameraState, + onFlashToggle: () -> Unit, + onGridToggle: () -> Unit, + onCameraSwitch: () -> Unit, + onCancel: () -> Unit, + modifier: Modifier = Modifier +) { + Row( + modifier = modifier + .fillMaxWidth() + .padding(16.dp), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically + ) { + // Cancel button + IconButton( + onClick = onCancel, + modifier = Modifier + .size(48.dp) + .background(Color.Black.copy(alpha = 0.3f), CircleShape) + ) { + Icon( + imageVector = Icons.Default.Close, + contentDescription = "Cancel", + tint = Color.White + ) + } + + // Center controls (flash, grid) + Row( + horizontalArrangement = Arrangement.spacedBy(16.dp) + ) { + // Flash toggle + if (config.showFlashToggle && cameraState.isFlashSupported) { + IconButton( + onClick = onFlashToggle, + modifier = Modifier + .size(48.dp) + .background(Color.Black.copy(alpha = 0.3f), CircleShape) + ) { + val flashIcon = when (cameraState.flashMode) { + ImageCapture.FLASH_MODE_ON -> Icons.Default.FlashOn + ImageCapture.FLASH_MODE_AUTO -> Icons.Default.FlashAuto + else -> Icons.Default.FlashOff + } + Icon( + imageVector = flashIcon, + contentDescription = "Flash", + tint = Color.White + ) + } + } + + // Grid toggle + if (config.showGridToggle) { + IconButton( + onClick = onGridToggle, + modifier = Modifier + .size(48.dp) + .background(Color.Black.copy(alpha = 0.3f), CircleShape) + ) { + Icon( + imageVector = if (cameraState.showGrid) Icons.Default.GridOn else Icons.Default.GridOff, + contentDescription = "Grid", + tint = if (cameraState.showGrid) Color.Yellow else Color.White + ) + } + } + } + + // Camera switch button + if (config.showCameraSwitch) { + IconButton( + onClick = onCameraSwitch, + modifier = Modifier + .size(48.dp) + .background(Color.Black.copy(alpha = 0.3f), CircleShape) + ) { + Icon( + imageVector = Icons.Default.CameraFront, + contentDescription = "Switch Camera", + tint = Color.White + ) + } + } else { + Spacer(modifier = Modifier.size(48.dp)) + } + } +} + +private fun setupCamera( + context: Context, + previewView: PreviewView?, + lifecycleOwner: androidx.lifecycle.LifecycleOwner, + cameraState: CameraState, + onCameraReady: (ProcessCameraProvider, ImageCapture, VideoCapture) -> Unit, + onFlashSupportChanged: (Boolean) -> Unit +) { + val cameraProviderFuture: ListenableFuture = ProcessCameraProvider.getInstance(context) + cameraProviderFuture.addListener({ + try { + val cameraProvider = cameraProviderFuture.get() + bindCamera(cameraProvider, previewView, lifecycleOwner, cameraState, onCameraReady, onFlashSupportChanged) + } catch (e: Exception) { + AppLogger.e("Failed to get camera provider", e) + } + }, ContextCompat.getMainExecutor(context)) +} + +private fun bindCamera( + cameraProvider: ProcessCameraProvider, + previewView: PreviewView?, + lifecycleOwner: androidx.lifecycle.LifecycleOwner, + cameraState: CameraState, + onCameraReady: (ProcessCameraProvider, ImageCapture, VideoCapture) -> Unit, + onFlashSupportChanged: (Boolean) -> Unit +) { + try { + cameraProvider.unbindAll() + + val cameraSelector = if (cameraState.isFrontCamera) { + CameraSelector.DEFAULT_FRONT_CAMERA + } else { + CameraSelector.DEFAULT_BACK_CAMERA + } + + val preview = Preview.Builder().build().also { + previewView?.let { pv -> + it.setSurfaceProvider(pv.surfaceProvider) + } + } + + val imageCapture = ImageCapture.Builder() + .setFlashMode(cameraState.flashMode) + .build() + + val recorder = Recorder.Builder() + .setQualitySelector(QualitySelector.from(Quality.HD)) + .build() + val videoCapture = VideoCapture.withOutput(recorder) + + val camera = cameraProvider.bindToLifecycle( + lifecycleOwner, + cameraSelector, + preview, + imageCapture, + videoCapture + ) + + // Check flash support + val flashSupported = camera.cameraInfo.hasFlashUnit() + onFlashSupportChanged(flashSupported) + + onCameraReady(cameraProvider, imageCapture, videoCapture) + + } catch (e: Exception) { + AppLogger.e("Failed to bind camera", e) + } +} \ No newline at end of file diff --git a/app/src/main/java/net/opendasharchive/openarchive/features/media/camera/CameraState.kt b/app/src/main/java/net/opendasharchive/openarchive/features/media/camera/CameraState.kt new file mode 100644 index 00000000..66e2a21a --- /dev/null +++ b/app/src/main/java/net/opendasharchive/openarchive/features/media/camera/CameraState.kt @@ -0,0 +1,22 @@ +package net.opendasharchive.openarchive.features.media.camera + +import android.net.Uri +import androidx.camera.core.ImageCapture + +data class CameraState( + val captureMode: CameraCaptureMode = CameraCaptureMode.PHOTO, + val flashMode: Int = ImageCapture.FLASH_MODE_OFF, + val isFlashSupported: Boolean = false, + val isFrontCamera: Boolean = false, + val showGrid: Boolean = false, + val isRecording: Boolean = false, + val capturedItems: List = emptyList(), + val showPreview: Boolean = false, + val currentPreviewItem: CapturedItem? = null +) + +data class CapturedItem( + val uri: Uri, + val type: CameraCaptureMode, + val timestamp: Long = System.currentTimeMillis() +) \ No newline at end of file diff --git a/app/src/main/java/net/opendasharchive/openarchive/features/media/camera/CameraViewModel.kt b/app/src/main/java/net/opendasharchive/openarchive/features/media/camera/CameraViewModel.kt new file mode 100644 index 00000000..70e788fc --- /dev/null +++ b/app/src/main/java/net/opendasharchive/openarchive/features/media/camera/CameraViewModel.kt @@ -0,0 +1,233 @@ +package net.opendasharchive.openarchive.features.media.camera + +import android.content.Context +import android.net.Uri +import android.webkit.MimeTypeMap +import androidx.camera.core.ImageCapture +import androidx.camera.core.ImageCaptureException +import androidx.camera.video.FileOutputOptions +import androidx.camera.video.Recording +import androidx.camera.video.VideoRecordEvent +import androidx.core.content.ContextCompat +import androidx.core.content.FileProvider +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.launch +import net.opendasharchive.openarchive.core.logger.AppLogger +import net.opendasharchive.openarchive.util.Utility +import java.io.File + +class CameraViewModel : ViewModel() { + + private val _state = MutableStateFlow(CameraState()) + val state: StateFlow = _state.asStateFlow() + + private var currentRecording: Recording? = null + + fun updateCaptureMode(mode: CameraCaptureMode) { + _state.value = _state.value.copy(captureMode = mode) + } + + fun toggleFlashMode() { + val currentFlashMode = _state.value.flashMode + val newFlashMode = when (currentFlashMode) { + ImageCapture.FLASH_MODE_OFF -> ImageCapture.FLASH_MODE_ON + ImageCapture.FLASH_MODE_ON -> ImageCapture.FLASH_MODE_AUTO + else -> ImageCapture.FLASH_MODE_OFF + } + _state.value = _state.value.copy(flashMode = newFlashMode) + } + + fun updateFlashSupport(isSupported: Boolean) { + _state.value = _state.value.copy(isFlashSupported = isSupported) + } + + fun toggleCamera() { + _state.value = _state.value.copy(isFrontCamera = !_state.value.isFrontCamera) + } + + fun toggleGrid() { + _state.value = _state.value.copy(showGrid = !_state.value.showGrid) + } + + fun capturePhoto( + context: Context, + imageCapture: ImageCapture, + onSuccess: (Uri) -> Unit, + onError: (Exception) -> Unit + ) { + viewModelScope.launch { + try { + val filename = "IMG_${System.currentTimeMillis()}.jpg" + val outputFile = Utility.getOutputMediaFileByCache(context, filename) + + if (outputFile == null) { + onError(Exception("Failed to create output file")) + return@launch + } + + val outputOptions = ImageCapture.OutputFileOptions.Builder(outputFile).build() + + imageCapture.takePicture( + outputOptions, + ContextCompat.getMainExecutor(context), + object : ImageCapture.OnImageSavedCallback { + override fun onImageSaved(output: ImageCapture.OutputFileResults) { + // Use FileProvider URI like other camera implementations + val uri = FileProvider.getUriForFile( + context, + "${context.packageName}.provider", + outputFile + ) + val capturedItem = CapturedItem(uri, CameraCaptureMode.PHOTO) + + val updatedItems = _state.value.capturedItems + capturedItem + _state.value = _state.value.copy( + capturedItems = updatedItems, + showPreview = true, + currentPreviewItem = capturedItem + ) + + AppLogger.d("Photo captured successfully: $uri") + onSuccess(uri) + } + + override fun onError(exception: ImageCaptureException) { + AppLogger.e("Photo capture failed", exception) + onError(exception) + } + } + ) + } catch (e: Exception) { + AppLogger.e("Error setting up photo capture", e) + onError(e) + } + } + } + + fun startVideoRecording( + context: Context, + videoCapture: androidx.camera.video.VideoCapture, + onSuccess: (Uri) -> Unit, + onError: (Exception) -> Unit + ) { + if (_state.value.isRecording) { + AppLogger.w("Video recording already in progress") + return + } + + try { + val filename = "VID_${System.currentTimeMillis()}.mp4" + val outputFile = Utility.getOutputMediaFileByCache(context, filename) + + if (outputFile == null) { + onError(Exception("Failed to create output file")) + return + } + + val fileOutputOptions = FileOutputOptions.Builder(outputFile).build() + + currentRecording = videoCapture.output + .prepareRecording(context, fileOutputOptions) + .start(ContextCompat.getMainExecutor(context)) { recordEvent -> + when (recordEvent) { + is VideoRecordEvent.Start -> { + _state.value = _state.value.copy(isRecording = true) + AppLogger.d("Video recording started") + } + is VideoRecordEvent.Finalize -> { + _state.value = _state.value.copy(isRecording = false) + + if (!recordEvent.hasError()) { + // Use FileProvider URI like other camera implementations + val uri = FileProvider.getUriForFile( + context, + "${context.packageName}.provider", + outputFile + ) + val capturedItem = CapturedItem(uri, CameraCaptureMode.VIDEO) + + val updatedItems = _state.value.capturedItems + capturedItem + _state.value = _state.value.copy( + capturedItems = updatedItems, + showPreview = true, + currentPreviewItem = capturedItem + ) + + AppLogger.d("Video captured successfully: $uri") + onSuccess(uri) + } else { + val error = Exception("Video recording failed: ${recordEvent.error}") + AppLogger.e("Video recording failed", error) + onError(error) + } + } + } + } + } catch (e: Exception) { + AppLogger.e("Error starting video recording", e) + onError(e) + } + } + + fun stopVideoRecording() { + if (_state.value.isRecording) { + currentRecording?.stop() + currentRecording = null + } + } + + fun showPreview(item: CapturedItem) { + _state.value = _state.value.copy( + showPreview = true, + currentPreviewItem = item + ) + } + + fun hidePreview() { + _state.value = _state.value.copy( + showPreview = false, + currentPreviewItem = null + ) + } + + fun confirmCapture(item: CapturedItem): List { + return if (_state.value.capturedItems.contains(item)) { + listOf(item.uri) + } else { + emptyList() + } + } + + fun retakeCapture(item: CapturedItem) { + val updatedItems = _state.value.capturedItems.filter { it != item } + _state.value = _state.value.copy( + capturedItems = updatedItems, + showPreview = false, + currentPreviewItem = null + ) + + // For FileProvider URIs, we'll need to track the actual file paths differently + // For now, rely on cache cleanup mechanisms + AppLogger.d("Marked file for removal: ${item.uri}") + } + + fun getAllCapturedUris(): List { + return _state.value.capturedItems.map { it.uri } + } + + fun clearCaptures() { + // Clear state - files in cache will be cleaned up by system + AppLogger.d("Clearing ${_state.value.capturedItems.size} captured items") + _state.value = CameraState() + } + + override fun onCleared() { + super.onCleared() + stopVideoRecording() + clearCaptures() + } +} \ No newline at end of file diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index d973bf5d..ca230a81 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -27,7 +27,7 @@ androidx-test-runner = "1.6.2" koin = "4.1.0" kotlin = "2.2.10" ksp = "2.2.10-2.0.2" -lifecycle = "2.9.2" +lifecycle = "2.9.3" material = "1.12.0" material3 = "1.4.0-beta02" mixpanel = "8.2.0" @@ -44,6 +44,8 @@ serialization = "1.9.0" timber = "5.0.1" viewpager2 = "1.1.0" work = "2.10.3" +camerax = "1.4.2" +media3 = "1.8.0" audio-waveform = "1.0.1" bitcoinj-core = "0.16.2" @@ -141,6 +143,18 @@ coil-compose = { group = "io.coil-kt.coil3", name = "coil-compose", version .ref coil-video = { group = "io.coil-kt.coil3", name = "coil-video", version.ref = "coil" } coil-network = { group = "io.coil-kt.coil3", name = "coil-network-okhttp", version.ref = "coil" } +# CameraX +androidx-camera-core = { group = "androidx.camera", name = "camera-core", version.ref = "camerax" } +androidx-camera-camera2 = { group = "androidx.camera", name = "camera-camera2", version.ref = "camerax" } +androidx-camera-lifecycle = { group = "androidx.camera", name = "camera-lifecycle", version.ref = "camerax" } +androidx-camera-video = { group = "androidx.camera", name = "camera-video", version.ref = "camerax" } +androidx-camera-view = { group = "androidx.camera", name = "camera-view", version.ref = "camerax" } +androidx-camera-extensions = { group = "androidx.camera", name = "camera-extensions", version.ref = "camerax" } + +# Media3 ExoPlayer +androidx-media3-exoplayer = { group = "androidx.media3", name = "media3-exoplayer", version.ref = "media3" } +androidx-media3-ui = { group = "androidx.media3", name = "media3-ui", version.ref = "media3" } + # Utilities kotlinx-coroutines-android = { group = "org.jetbrains.kotlinx", name = "kotlinx-coroutines-android", version.ref = "coroutines" } kotlinx-serialization-json = { group = "org.jetbrains.kotlinx", name = "kotlinx-serialization-json", version.ref = "serialization" } From e9bcf49fc568182188fea99db84e1e79a9a15e76 Mon Sep 17 00:00:00 2001 From: elelanv Date: Tue, 2 Sep 2025 17:05:18 +0530 Subject: [PATCH 2/3] Bump versionCode to 30017 --- app/build.gradle.kts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 8600bb66..4761e4fa 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -59,7 +59,7 @@ android { applicationId = "net.opendasharchive.openarchive" minSdk = 29 targetSdk = 36 - versionCode = 30016 + versionCode = 30017 versionName = "4.0.2" multiDexEnabled = true vectorDrawables.useSupportLibrary = true From 538b844729a5999a3f9b1b667477985caaa22824 Mon Sep 17 00:00:00 2001 From: elelanv Date: Thu, 11 Sep 2025 12:34:58 +0530 Subject: [PATCH 3/3] updated camera lib --- .../net/opendasharchive/openarchive/core/di/PasscodeModule.kt | 1 + gradle/libs.versions.toml | 2 +- 2 files changed, 2 insertions(+), 1 deletion(-) diff --git a/app/src/main/java/net/opendasharchive/openarchive/core/di/PasscodeModule.kt b/app/src/main/java/net/opendasharchive/openarchive/core/di/PasscodeModule.kt index 1282bc25..fab182c5 100644 --- a/app/src/main/java/net/opendasharchive/openarchive/core/di/PasscodeModule.kt +++ b/app/src/main/java/net/opendasharchive/openarchive/core/di/PasscodeModule.kt @@ -12,6 +12,7 @@ import org.koin.core.module.dsl.viewModel import org.koin.dsl.module val passcodeModule = module { + single { AppConfig( passcodeLength = 6, diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 19c499be..7fb966a6 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -45,7 +45,7 @@ serialization = "1.9.0" timber = "5.0.1" viewpager2 = "1.1.0" work = "2.10.3" -camerax = "1.4.2" +camerax = "1.5.0" media3 = "1.8.0" audio-waveform = "1.0.1"