diff --git a/app/build.gradle.kts b/app/build.gradle.kts
index 904641e0..9db60eb5 100644
--- a/app/build.gradle.kts
+++ b/app/build.gradle.kts
@@ -233,6 +233,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 9e309b1c..cc816ccc 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">
+
+
+
+
+
@@ -200,6 +208,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 0f1e1f93..7fb966a6 100644
--- a/gradle/libs.versions.toml
+++ b/gradle/libs.versions.toml
@@ -45,6 +45,8 @@ serialization = "1.9.0"
timber = "5.0.1"
viewpager2 = "1.1.0"
work = "2.10.3"
+camerax = "1.5.0"
+media3 = "1.8.0"
audio-waveform = "1.0.1"
bitcoinj-core = "0.16.2"
@@ -142,6 +144,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" }