diff --git a/app/src/main/java/com/google/jetpackcamera/ui/JcaApp.kt b/app/src/main/java/com/google/jetpackcamera/ui/JcaApp.kt index e66f51055..889771b14 100644 --- a/app/src/main/java/com/google/jetpackcamera/ui/JcaApp.kt +++ b/app/src/main/java/com/google/jetpackcamera/ui/JcaApp.kt @@ -16,7 +16,6 @@ package com.google.jetpackcamera.ui import android.Manifest -import android.net.Uri import androidx.compose.animation.AnimatedContentTransitionScope import androidx.compose.animation.core.EaseIn import androidx.compose.animation.core.EaseOut @@ -27,11 +26,9 @@ import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.ui.Modifier import androidx.navigation.NavHostController -import androidx.navigation.NavType import androidx.navigation.compose.NavHost import androidx.navigation.compose.composable import androidx.navigation.compose.rememberNavController -import androidx.navigation.navArgument import com.google.accompanist.permissions.ExperimentalPermissionsApi import com.google.accompanist.permissions.isGranted import com.google.accompanist.permissions.rememberMultiplePermissionsState @@ -118,11 +115,7 @@ private fun JetpackCameraNavHost( } PreviewScreen( onNavigateToSettings = { navController.navigate(SETTINGS_ROUTE) }, - onNavigateToPostCapture = { imageUri -> - navController.navigate( - "$POST_CAPTURE_ROUTE?imageUri=${Uri.encode(imageUri.toString())}" - ) - }, + onNavigateToPostCapture = { navController.navigate(POST_CAPTURE_ROUTE) }, onRequestWindowColorMode = onRequestWindowColorMode, onFirstFrameCaptureCompleted = onFirstFrameCaptureCompleted, previewMode = previewMode, @@ -156,25 +149,10 @@ private fun JetpackCameraNavHost( } composable( - "$POST_CAPTURE_ROUTE?imageUri={imageUri}", - arguments = listOf( - navArgument("imageUri") { - type = NavType.StringType - defaultValue = "" - } - ) - ) { backStackEntry -> - val imageUriString = backStackEntry.arguments?.getString("imageUri") - - val imageUri = if (!imageUriString.isNullOrEmpty()) { - Uri.parse( - imageUriString - ) - } else { - null - } + POST_CAPTURE_ROUTE + ) { PostCaptureScreen( - imageUri = imageUri + onRequestWindowColorMode = onRequestWindowColorMode ) } } diff --git a/core/common/src/main/java/com/google/jetpackcamera/core/common/MediaStoreUtils.kt b/core/common/src/main/java/com/google/jetpackcamera/core/common/MediaStoreUtils.kt deleted file mode 100644 index f7604522f..000000000 --- a/core/common/src/main/java/com/google/jetpackcamera/core/common/MediaStoreUtils.kt +++ /dev/null @@ -1,100 +0,0 @@ -/* - * Copyright (C) 2025 The Android Open Source Project - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ -package com.google.jetpackcamera.core.common - -import android.content.ContentUris -import android.content.Context -import android.graphics.Bitmap -import android.graphics.ImageDecoder -import android.graphics.Matrix -import android.net.Uri -import android.provider.MediaStore -import java.io.File -import java.io.FileNotFoundException - -/** - * Retrieves the URI for the most recently added image whose filename starts with "JCA". - * - * @param context The application context. - * @return The content URI of the matching image, or null if none is found. - */ -fun getLastImageUri(context: Context): Uri? { - val projection = arrayOf( - MediaStore.Images.Media._ID, - MediaStore.Images.Media.DATE_ADDED - ) - - // Filter by filenames starting with "JCA" - val selection = "${MediaStore.Images.Media.DISPLAY_NAME} LIKE ?" - val selectionArgs = arrayOf("JCA%") - - // Sort the results so that the most recently added image appears first. - val sortOrder = "${MediaStore.Images.Media.DATE_ADDED} DESC" - - // Perform the query on the MediaStore. - context.contentResolver.query( - MediaStore.Images.Media.EXTERNAL_CONTENT_URI, - projection, - selection, - selectionArgs, - sortOrder - )?.use { cursor -> - if (cursor.moveToFirst()) { - val idColumn = cursor.getColumnIndexOrThrow(MediaStore.Images.Media._ID) - val id = cursor.getLong(idColumn) - - return ContentUris.withAppendedId( - MediaStore.Images.Media.EXTERNAL_CONTENT_URI, - id - ) - } - } - return null -} - -/** - * Loads a Bitmap from a given URI and rotates it by the specified degrees. - * - * @param context The application context. - * @param uri The URI of the image to load. - * @param degrees The number of degrees to rotate the image by. - */ -fun loadAndRotateBitmap(context: Context, uri: Uri?, degrees: Float): Bitmap? { - uri ?: return null - - if (uri.scheme == "file") { - val file = File(uri.path ?: "") - if (!file.exists()) { - return null - } - } - - return try { - val bitmap = if (android.os.Build.VERSION.SDK_INT < android.os.Build.VERSION_CODES.P) { - MediaStore.Images.Media.getBitmap(context.contentResolver, uri) - } else { - val imageDecoderSource = ImageDecoder.createSource(context.contentResolver, uri) - ImageDecoder.decodeBitmap(imageDecoderSource) - } - - bitmap?.let { - val matrix = Matrix().apply { postRotate(degrees) } - Bitmap.createBitmap(it, 0, 0, it.width, it.height, matrix, true) - } - } catch (e: FileNotFoundException) { - null - } -} diff --git a/data/media/.gitignore b/data/media/.gitignore new file mode 100644 index 000000000..42afabfd2 --- /dev/null +++ b/data/media/.gitignore @@ -0,0 +1 @@ +/build \ No newline at end of file diff --git a/data/media/build.gradle.kts b/data/media/build.gradle.kts new file mode 100644 index 000000000..2f382a2f4 --- /dev/null +++ b/data/media/build.gradle.kts @@ -0,0 +1,93 @@ +/* + * Copyright (C) 2025 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +plugins { + alias(libs.plugins.android.library) + alias(libs.plugins.kotlin.android) + alias(libs.plugins.kotlin.kapt) + alias(libs.plugins.dagger.hilt.android) +} + +android { + namespace = "com.google.jetpackcamera.data.media" + compileSdk = libs.versions.compileSdk.get().toInt() + + defaultConfig { + minSdk = libs.versions.minSdk.get().toInt() + testOptions.targetSdk = libs.versions.targetSdk.get().toInt() + lint.targetSdk = libs.versions.targetSdk.get().toInt() + + testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" + consumerProguardFiles("consumer-rules.pro") + } + + flavorDimensions += "flavor" + productFlavors { + create("stable") { + dimension = "flavor" + isDefault = true + } + } + + compileOptions { + sourceCompatibility = JavaVersion.VERSION_17 + targetCompatibility = JavaVersion.VERSION_17 + } + kotlin { + jvmToolchain(17) + } + + @Suppress("UnstableApiUsage") + testOptions { + managedDevices { + localDevices { + create("pixel2Api28") { + device = "Pixel 2" + apiLevel = 28 + } + create("pixel8Api34") { + device = "Pixel 8" + apiLevel = 34 + systemImageSource = "aosp_atd" + } + } + } + } +} + +dependencies { + implementation(libs.kotlinx.coroutines.core) + + // Hilt + implementation(libs.dagger.hilt.android) + kapt(libs.dagger.hilt.compiler) + + // Testing + testImplementation(libs.junit) + testImplementation(libs.truth) + androidTestImplementation(libs.androidx.espresso.core) + androidTestImplementation(libs.androidx.junit) + androidTestImplementation(libs.truth) + androidTestImplementation(libs.kotlinx.coroutines.test) + + + // Project dependencies + implementation(project(":core:common")) +} + +// Allow references to generated code +kapt { + correctErrorTypes = true +} diff --git a/data/media/consumer-rules.pro b/data/media/consumer-rules.pro new file mode 100644 index 000000000..e69de29bb diff --git a/data/media/proguard-rules.pro b/data/media/proguard-rules.pro new file mode 100644 index 000000000..481bb4348 --- /dev/null +++ b/data/media/proguard-rules.pro @@ -0,0 +1,21 @@ +# Add project specific ProGuard rules here. +# You can control the set of applied configuration files using the +# proguardFiles setting in build.gradle. +# +# For more details, see +# http://developer.android.com/guide/developing/tools/proguard.html + +# If your project uses WebView with JS, uncomment the following +# and specify the fully qualified class name to the JavaScript interface +# class: +#-keepclassmembers class fqcn.of.javascript.interface.for.webview { +# public *; +#} + +# Uncomment this to preserve the line number information for +# debugging stack traces. +#-keepattributes SourceFile,LineNumberTable + +# If you keep the line number information, uncomment this to +# hide the original source file name. +#-renamesourcefileattribute SourceFile \ No newline at end of file diff --git a/data/media/src/main/AndroidManifest.xml b/data/media/src/main/AndroidManifest.xml new file mode 100644 index 000000000..1e7c244bc --- /dev/null +++ b/data/media/src/main/AndroidManifest.xml @@ -0,0 +1,19 @@ + + + + + \ No newline at end of file diff --git a/data/media/src/main/kotlin/com/google/jetpackcamera/data/media/FakeMediaRepository.kt b/data/media/src/main/kotlin/com/google/jetpackcamera/data/media/FakeMediaRepository.kt new file mode 100644 index 000000000..590d10ca6 --- /dev/null +++ b/data/media/src/main/kotlin/com/google/jetpackcamera/data/media/FakeMediaRepository.kt @@ -0,0 +1,26 @@ +/* + * Copyright (C) 2025 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.google.jetpackcamera.data.media + +object FakeMediaRepository : MediaRepository { + override suspend fun getLastCapturedMedia(): MediaDescriptor { + return MediaDescriptor.None + } + + override suspend fun load(mediaDescriptor: MediaDescriptor): Media { + return Media.None + } +} diff --git a/data/media/src/main/kotlin/com/google/jetpackcamera/data/media/LocalMediaRepository.kt b/data/media/src/main/kotlin/com/google/jetpackcamera/data/media/LocalMediaRepository.kt new file mode 100644 index 000000000..b38903fc7 --- /dev/null +++ b/data/media/src/main/kotlin/com/google/jetpackcamera/data/media/LocalMediaRepository.kt @@ -0,0 +1,167 @@ +/* + * Copyright (C) 2025 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.google.jetpackcamera.data.media + +import android.content.ContentUris +import android.content.Context +import android.graphics.Bitmap +import android.graphics.BitmapFactory +import android.graphics.ImageDecoder +import android.net.Uri +import android.os.Build +import android.provider.MediaStore +import android.util.Size +import com.google.jetpackcamera.core.common.IODispatcher +import dagger.hilt.android.qualifiers.ApplicationContext +import javax.inject.Inject +import kotlinx.coroutines.CoroutineDispatcher +import kotlinx.coroutines.withContext + +class LocalMediaRepository +@Inject constructor( + @ApplicationContext private val context: Context, + @IODispatcher private val iODispatcher: CoroutineDispatcher +) : MediaRepository { + + override suspend fun load(mediaDescriptor: MediaDescriptor): Media { + return when (mediaDescriptor) { + is MediaDescriptor.Image -> loadImage(mediaDescriptor.uri) + MediaDescriptor.None -> Media.None + is MediaDescriptor.Video -> Media.Video(mediaDescriptor.uri) + } + } + + override suspend fun getLastCapturedMedia(): MediaDescriptor { + val imagePair = + getLastMediaUriWithDate(context, MediaStore.Images.Media.EXTERNAL_CONTENT_URI) + val videoPair = + getLastMediaUriWithDate(context, MediaStore.Video.Media.EXTERNAL_CONTENT_URI) + + return when { + imagePair == null && videoPair == null -> MediaDescriptor.None + imagePair == null && videoPair != null -> getVideoMediaDescriptor(videoPair.first) + videoPair == null && imagePair != null -> getImageMediaDescriptor(imagePair.first) + imagePair != null && videoPair != null -> { + if (imagePair.second > videoPair.second) { + getImageMediaDescriptor(imagePair.first) + } else { + getVideoMediaDescriptor(videoPair.first) + } + } + + else -> MediaDescriptor.None // Should not happen + } + } + + private suspend fun loadImage(uri: Uri): Media = withContext(iODispatcher) { + try { + val loadedBitmap = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) { + // Android 10 (API 29) and above: Use ImageDecoder + val source = ImageDecoder.createSource(context.contentResolver, uri) + ImageDecoder.decodeBitmap(source) + } else { + // Android 9 (API 28) and below: Use BitmapFactory + context.contentResolver.openInputStream(uri)?.use { inputStream -> + BitmapFactory.decodeStream(inputStream) + } + } + + return@withContext if (loadedBitmap != null) { + Media.Image(loadedBitmap) + } else { + Media.Error + } + } catch (e: Exception) { + e.printStackTrace() + return@withContext Media.Error + } + } + + private suspend fun getVideoMediaDescriptor(uri: Uri): MediaDescriptor { + val thumbnail = getThumbnail(uri, MediaStore.Video.Media.EXTERNAL_CONTENT_URI) + return MediaDescriptor.Video(uri, thumbnail) + } + + private suspend fun getImageMediaDescriptor(uri: Uri): MediaDescriptor { + val thumbnail = getThumbnail(uri, MediaStore.Images.Media.EXTERNAL_CONTENT_URI) + return MediaDescriptor.Image(uri, thumbnail) + } + + private suspend fun getThumbnail(uri: Uri, collectionUri: Uri): Bitmap? = + withContext(iODispatcher) { + return@withContext try { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) { + context.contentResolver.loadThumbnail(uri, Size(640, 480), null) + } else { + if (collectionUri == MediaStore.Images.Media.EXTERNAL_CONTENT_URI) { + MediaStore.Images.Thumbnails.getThumbnail( + context.contentResolver, + ContentUris.parseId(uri), + MediaStore.Images.Thumbnails.MINI_KIND, + null + ) + } else { // Video + MediaStore.Video.Thumbnails.getThumbnail( + context.contentResolver, + ContentUris.parseId(uri), + MediaStore.Video.Thumbnails.MINI_KIND, + null + ) + } + } + } catch (e: Exception) { + e.printStackTrace() + null + } + } + + private fun getLastMediaUriWithDate(context: Context, collectionUri: Uri): Pair? { + val projection = arrayOf( + MediaStore.MediaColumns._ID, + MediaStore.MediaColumns.DATE_ADDED + ) + + // Filter by filenames starting with "JCA" + val selection = "${MediaStore.MediaColumns.DISPLAY_NAME} LIKE ?" + val selectionArgs = arrayOf("JCA%") + + // Sort the results so that the most recently added media appears first. + val sortOrder = "${MediaStore.MediaColumns.DATE_ADDED} DESC" + + // Perform the query on the MediaStore. + context.contentResolver.query( + collectionUri, + projection, + selection, + selectionArgs, + sortOrder + )?.use { cursor -> + if (cursor.moveToFirst()) { + val idColumn = cursor.getColumnIndexOrThrow(MediaStore.MediaColumns._ID) + val dateAddedColumn = cursor.getColumnIndexOrThrow( + MediaStore.MediaColumns.DATE_ADDED + ) + + val id = cursor.getLong(idColumn) + val dateAdded = cursor.getLong(dateAddedColumn) + + val uri = ContentUris.withAppendedId(collectionUri, id) + return Pair(uri, dateAdded) + } + } + return null + } +} diff --git a/data/media/src/main/kotlin/com/google/jetpackcamera/data/media/MediaModule.kt b/data/media/src/main/kotlin/com/google/jetpackcamera/data/media/MediaModule.kt new file mode 100644 index 000000000..a4ec2ddfc --- /dev/null +++ b/data/media/src/main/kotlin/com/google/jetpackcamera/data/media/MediaModule.kt @@ -0,0 +1,34 @@ +/* + * Copyright (C) 2025 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.google.jetpackcamera.data.media + +import dagger.Binds +import dagger.Module +import dagger.hilt.InstallIn +import dagger.hilt.components.SingletonComponent +import javax.inject.Singleton + +/** + * Dagger [Module] for Media dependencies. + */ +@Module +@InstallIn(SingletonComponent::class) +interface MediaModule { + + @Binds + @Singleton + fun bindsMediaRepository(localMediaRepository: LocalMediaRepository): MediaRepository +} diff --git a/data/media/src/main/kotlin/com/google/jetpackcamera/data/media/MediaRepository.kt b/data/media/src/main/kotlin/com/google/jetpackcamera/data/media/MediaRepository.kt new file mode 100644 index 000000000..d7705bf0a --- /dev/null +++ b/data/media/src/main/kotlin/com/google/jetpackcamera/data/media/MediaRepository.kt @@ -0,0 +1,53 @@ +/* + * Copyright (C) 2025 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.google.jetpackcamera.data.media + +import android.graphics.Bitmap +import android.net.Uri + +/** + * Data layer for Media. + */ +interface MediaRepository { + suspend fun getLastCapturedMedia(): MediaDescriptor + suspend fun load(mediaDescriptor: MediaDescriptor): Media +} + +/** + * Descriptors used for [Media]. + * + * Media descriptors contain a reference to a [Media] item that's not yet loaded. + */ +sealed interface MediaDescriptor { + data object None : MediaDescriptor + class Image(val uri: Uri, val thumbnail: Bitmap?) : MediaDescriptor + class Video(val uri: Uri, val thumbnail: Bitmap?) : MediaDescriptor +} + +/** + * Media items that are supported by [MediaRepository]. + * + * [Image] will have the bitmap data loaded. + * [Video] is still a reference to the video file, will switch to a loaded version later on. + * + * TODO(yasith): Load the video data to the Video object. + */ +sealed interface Media { + data object None : Media + data object Error : Media + class Image(val bitmap: Bitmap) : Media + class Video(val uri: Uri) : Media +} diff --git a/feature/postcapture/build.gradle.kts b/feature/postcapture/build.gradle.kts index f42133426..88f1bf042 100644 --- a/feature/postcapture/build.gradle.kts +++ b/feature/postcapture/build.gradle.kts @@ -16,6 +16,8 @@ plugins { alias(libs.plugins.android.library) alias(libs.plugins.kotlin.android) + alias(libs.plugins.kotlin.kapt) + alias(libs.plugins.dagger.hilt.android) } android { @@ -111,6 +113,14 @@ dependencies { testImplementation(libs.compose.test.manifest) testImplementation(libs.compose.junit) + // Hilt + implementation(libs.dagger.hilt.android) + kapt(libs.dagger.hilt.compiler) + + // Media3 + implementation(libs.androidx.media3.exoplayer) + implementation(libs.androidx.media3.ui.compose) + // Testing testImplementation(libs.junit) testImplementation(libs.truth) @@ -129,5 +139,11 @@ dependencies { // Project dependencies implementation(project(":core:common")) + implementation(project(":data:media")) testImplementation(project(":core:common")) -} \ No newline at end of file +} + +// Allow references to generated code +kapt { + correctErrorTypes = true +} diff --git a/feature/postcapture/src/main/java/com/google/jetpackcamera/feature/postcapture/PostCaptureScreen.kt b/feature/postcapture/src/main/java/com/google/jetpackcamera/feature/postcapture/PostCaptureScreen.kt index 854f64fb5..8cda26d5f 100644 --- a/feature/postcapture/src/main/java/com/google/jetpackcamera/feature/postcapture/PostCaptureScreen.kt +++ b/feature/postcapture/src/main/java/com/google/jetpackcamera/feature/postcapture/PostCaptureScreen.kt @@ -17,7 +17,12 @@ package com.google.jetpackcamera.feature.postcapture import android.content.Context import android.content.Intent +import android.content.pm.ActivityInfo import android.net.Uri +import android.os.Build +import android.util.Log +import android.view.Display.HdrCapabilities.HDR_TYPE_HLG +import androidx.annotation.OptIn import androidx.compose.foundation.Canvas import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box @@ -37,38 +42,43 @@ import androidx.compose.material3.IconButtonDefaults import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Text import androidx.compose.runtime.Composable -import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue -import androidx.compose.runtime.remember import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.shadow import androidx.compose.ui.geometry.Size import androidx.compose.ui.graphics.drawscope.drawIntoCanvas import androidx.compose.ui.graphics.nativeCanvas +import androidx.compose.ui.layout.ContentScale import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.res.stringResource import androidx.compose.ui.unit.dp import androidx.hilt.navigation.compose.hiltViewModel -import com.google.jetpackcamera.core.common.loadAndRotateBitmap +import androidx.media3.common.util.UnstableApi +import androidx.media3.ui.compose.PlayerSurface +import androidx.media3.ui.compose.modifiers.resizeWithContentScale +import androidx.media3.ui.compose.state.rememberPresentationState +import com.google.jetpackcamera.data.media.Media +import com.google.jetpackcamera.data.media.MediaDescriptor +private const val TAG = "PostCaptureScreen" + +@OptIn(UnstableApi::class) @Composable -fun PostCaptureScreen(viewModel: PostCaptureViewModel = hiltViewModel(), imageUri: Uri?) { +fun PostCaptureScreen( + onRequestWindowColorMode: (Int) -> Unit = {}, + viewModel: PostCaptureViewModel = hiltViewModel() +) { + Log.d(TAG, "PostCaptureScreen") + val uiState: PostCaptureUiState by viewModel.uiState.collectAsState() val context = LocalContext.current - LaunchedEffect(imageUri) { - viewModel.setLastCapturedImageUri(imageUri) - } - Box(modifier = Modifier.fillMaxSize()) { - uiState.imageUri?.let { uri -> - val bitmap = remember(uri) { - // TODO(yasith): Get the image rotation from the image - loadAndRotateBitmap(context, uri, 270f) - } - - if (bitmap != null) { + when (val media = uiState.media) { + is Media.Image -> { + val bitmap = media.bitmap Canvas(modifier = Modifier.fillMaxSize()) { drawIntoCanvas { canvas -> val scale = maxOf( @@ -90,10 +100,44 @@ fun PostCaptureScreen(viewModel: PostCaptureViewModel = hiltViewModel(), imageUr } } } - } ?: Text( - text = "No Image Captured", - modifier = Modifier.align(Alignment.Center) - ) + is Media.Video -> { + val presentationState = rememberPresentationState(viewModel.player) + PlayerSurface( + player = viewModel.player, + modifier = Modifier.resizeWithContentScale( + ContentScale.Fit, + presentationState.videoSizeDp + ) + ) + + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) { + val capabilities = + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.VANILLA_ICE_CREAM) { + context.display.mode.supportedHdrTypes + } else { + context.display.hdrCapabilities?.supportedHdrTypes ?: intArrayOf() + } + + if (capabilities.contains(HDR_TYPE_HLG)) { + Log.d(TAG, "HLG is supported, enabling") + onRequestWindowColorMode(ActivityInfo.COLOR_MODE_HDR) + } + } + viewModel.playVideo() + } + Media.None -> { + Text( + text = stringResource(R.string.no_media_available), + modifier = Modifier.align(Alignment.Center) + ) + } + Media.Error -> { + Text( + text = stringResource(R.string.error_loading_media), + modifier = Modifier.align(Alignment.Center) + ) + } + } Row( modifier = Modifier @@ -104,7 +148,7 @@ fun PostCaptureScreen(viewModel: PostCaptureViewModel = hiltViewModel(), imageUr ) { // Delete Image Button IconButton( - onClick = { viewModel.deleteImage(context.contentResolver) }, + onClick = { viewModel.deleteMedia(context.contentResolver) }, modifier = Modifier .size(56.dp) .shadow(10.dp, CircleShape), @@ -124,8 +168,14 @@ fun PostCaptureScreen(viewModel: PostCaptureViewModel = hiltViewModel(), imageUr // Share Image Button IconButton( onClick = { - imageUri?.let { - shareImage(context, it) + val mediaDescriptor = uiState.mediaDescriptor + + if (mediaDescriptor is MediaDescriptor.Image) { + shareImage(context, mediaDescriptor.uri) + } + + if (mediaDescriptor is MediaDescriptor.Video) { + shareImage(context, mediaDescriptor.uri) } }, modifier = Modifier diff --git a/feature/postcapture/src/main/java/com/google/jetpackcamera/feature/postcapture/PostCaptureViewModel.kt b/feature/postcapture/src/main/java/com/google/jetpackcamera/feature/postcapture/PostCaptureViewModel.kt index a9ed0b890..0a6a95b98 100644 --- a/feature/postcapture/src/main/java/com/google/jetpackcamera/feature/postcapture/PostCaptureViewModel.kt +++ b/feature/postcapture/src/main/java/com/google/jetpackcamera/feature/postcapture/PostCaptureViewModel.kt @@ -16,30 +16,74 @@ package com.google.jetpackcamera.feature.postcapture import android.content.ContentResolver -import android.net.Uri +import android.content.Context import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import androidx.media3.common.MediaItem +import androidx.media3.exoplayer.ExoPlayer +import com.google.jetpackcamera.data.media.Media +import com.google.jetpackcamera.data.media.MediaDescriptor +import com.google.jetpackcamera.data.media.MediaRepository import dagger.hilt.android.lifecycle.HiltViewModel +import dagger.hilt.android.qualifiers.ApplicationContext +import javax.inject.Inject import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.update +import kotlinx.coroutines.launch @HiltViewModel -class PostCaptureViewModel : ViewModel() { +class PostCaptureViewModel @Inject constructor( + private val mediaRepository: MediaRepository, + @ApplicationContext private val context: Context +) : ViewModel() { + + init { + getLastCapture() + } + + private val _uiState = MutableStateFlow( + PostCaptureUiState( + mediaDescriptor = MediaDescriptor.None, + media = Media.None + ) + ) + + val player = ExoPlayer.Builder(context).build() - private val _uiState = MutableStateFlow(PostCaptureUiState()) val uiState: StateFlow = _uiState - fun setLastCapturedImageUri(imageUri: Uri?) { - _uiState.update { it.copy(imageUri = imageUri, isImageDeleted = false) } + fun getLastCapture() { + viewModelScope.launch { + val mediaDescriptor = mediaRepository.getLastCapturedMedia() + val media = mediaRepository.load(mediaDescriptor) + + _uiState.update { it.copy(mediaDescriptor = mediaDescriptor, media = media) } + } + } + + fun deleteMedia(contentResolver: ContentResolver) { + when (val mediaDescriptor = uiState.value.mediaDescriptor) { + is MediaDescriptor.Image -> contentResolver.delete(mediaDescriptor.uri, null, null) + is MediaDescriptor.Video -> contentResolver.delete(mediaDescriptor.uri, null, null) + MediaDescriptor.None -> {} + } + _uiState.update { it.copy(mediaDescriptor = MediaDescriptor.None, media = Media.None) } } - fun deleteImage(contentResolver: ContentResolver) { - contentResolver.delete(uiState.value.imageUri!!, null, null) - _uiState.update { it.copy(imageUri = null, isImageDeleted = true) } + fun playVideo() { + val media = uiState.value.media + if (media is Media.Video) { + val mediaItem = MediaItem.fromUri(media.uri) + player.setMediaItem(mediaItem) + player.prepare() + player.setRepeatMode(ExoPlayer.REPEAT_MODE_ONE) + player.play() + } } } data class PostCaptureUiState( - val imageUri: Uri? = null, - val isImageDeleted: Boolean = false + val mediaDescriptor: MediaDescriptor, + val media: Media ) diff --git a/feature/postcapture/src/main/res/values/strings.xml b/feature/postcapture/src/main/res/values/strings.xml new file mode 100644 index 000000000..8248ecc55 --- /dev/null +++ b/feature/postcapture/src/main/res/values/strings.xml @@ -0,0 +1,20 @@ + + + + No Media Available + Error loading media + \ No newline at end of file diff --git a/feature/preview/build.gradle.kts b/feature/preview/build.gradle.kts index abe853bfd..552f52459 100644 --- a/feature/preview/build.gradle.kts +++ b/feature/preview/build.gradle.kts @@ -140,9 +140,10 @@ dependencies { implementation(libs.kotlinx.atomicfu) // Project dependencies - implementation(project(":data:settings")) implementation(project(":core:camera")) implementation(project(":core:common")) + implementation(project(":data:media")) + implementation(project(":data:settings")) testImplementation(project(":core:common")) } diff --git a/feature/preview/src/main/java/com/google/jetpackcamera/feature/preview/PreviewScreen.kt b/feature/preview/src/main/java/com/google/jetpackcamera/feature/preview/PreviewScreen.kt index 11140fa2d..39e12838a 100644 --- a/feature/preview/src/main/java/com/google/jetpackcamera/feature/preview/PreviewScreen.kt +++ b/feature/preview/src/main/java/com/google/jetpackcamera/feature/preview/PreviewScreen.kt @@ -52,7 +52,6 @@ import androidx.hilt.navigation.compose.hiltViewModel import androidx.lifecycle.compose.LifecycleStartEffect import androidx.tracing.Trace import com.google.jetpackcamera.core.camera.VideoRecordingState -import com.google.jetpackcamera.core.common.getLastImageUri import com.google.jetpackcamera.feature.preview.quicksettings.QuickSettingsScreenOverlay import com.google.jetpackcamera.feature.preview.ui.CameraControlsOverlay import com.google.jetpackcamera.feature.preview.ui.PreviewDisplay @@ -82,7 +81,7 @@ private const val TAG = "PreviewScreen" @Composable fun PreviewScreen( onNavigateToSettings: () -> Unit, - onNavigateToPostCapture: (uri: Uri?) -> Unit, + onNavigateToPostCapture: () -> Unit, previewMode: PreviewMode, isDebugMode: Boolean, modifier: Modifier = Modifier, @@ -163,15 +162,11 @@ fun PreviewScreen( onRequestWindowColorMode = onRequestWindowColorMode, onSnackBarResult = viewModel::onSnackBarResult, isDebugMode = isDebugMode, - onImageWellClick = { uri -> onNavigateToPostCapture(uri) } + onImageWellClick = onNavigateToPostCapture ) - // TODO(yasith): Remove and use ImageRepository after implementing LaunchedEffect(Unit) { - val lastCapturedImageUri = getLastImageUri(context) - lastCapturedImageUri?.let { uri -> - viewModel.updateLastCapturedImageUri(uri) - } + viewModel.updateLastCapturedMedia() } } } @@ -217,7 +212,7 @@ private fun ContentScreen( onRequestWindowColorMode: (Int) -> Unit = {}, onSnackBarResult: (String) -> Unit = {}, isDebugMode: Boolean = false, - onImageWellClick: (uri: Uri?) -> Unit = {} + onImageWellClick: () -> Unit = {} ) { val snackbarHostState = remember { SnackbarHostState() } Scaffold( diff --git a/feature/preview/src/main/java/com/google/jetpackcamera/feature/preview/PreviewUiState.kt b/feature/preview/src/main/java/com/google/jetpackcamera/feature/preview/PreviewUiState.kt index 9b097e669..558f04a03 100644 --- a/feature/preview/src/main/java/com/google/jetpackcamera/feature/preview/PreviewUiState.kt +++ b/feature/preview/src/main/java/com/google/jetpackcamera/feature/preview/PreviewUiState.kt @@ -57,7 +57,7 @@ sealed interface PreviewUiState { val audioUiState: AudioUiState = AudioUiState.Disabled, val elapsedTimeUiState: ElapsedTimeUiState = ElapsedTimeUiState.Unavailable, val captureButtonUiState: CaptureButtonUiState = CaptureButtonUiState.Unavailable, - val imageWellUiState: ImageWellUiState = ImageWellUiState.NoPreviousCapture + val imageWellUiState: ImageWellUiState = ImageWellUiState.Unavailable ) : PreviewUiState } diff --git a/feature/preview/src/main/java/com/google/jetpackcamera/feature/preview/PreviewViewModel.kt b/feature/preview/src/main/java/com/google/jetpackcamera/feature/preview/PreviewViewModel.kt index 12deedf40..3aadcfd71 100644 --- a/feature/preview/src/main/java/com/google/jetpackcamera/feature/preview/PreviewViewModel.kt +++ b/feature/preview/src/main/java/com/google/jetpackcamera/feature/preview/PreviewViewModel.kt @@ -29,6 +29,7 @@ import com.google.jetpackcamera.core.camera.CameraState import com.google.jetpackcamera.core.camera.CameraUseCase import com.google.jetpackcamera.core.camera.VideoRecordingState import com.google.jetpackcamera.core.common.traceFirstFramePreview +import com.google.jetpackcamera.data.media.MediaRepository import com.google.jetpackcamera.feature.preview.ui.IMAGE_CAPTURE_EXTERNAL_UNSUPPORTED_TAG import com.google.jetpackcamera.feature.preview.ui.IMAGE_CAPTURE_FAILURE_TAG import com.google.jetpackcamera.feature.preview.ui.IMAGE_CAPTURE_SUCCESS_TAG @@ -92,7 +93,8 @@ class PreviewViewModel @AssistedInject constructor( @Assisted val isDebugMode: Boolean, private val cameraUseCase: CameraUseCase, private val settingsRepository: SettingsRepository, - private val constraintsRepository: ConstraintsRepository + private val constraintsRepository: ConstraintsRepository, + private val mediaRepository: MediaRepository ) : ViewModel() { private val _previewUiState: MutableStateFlow = MutableStateFlow(PreviewUiState.NotReady) @@ -237,11 +239,15 @@ class PreviewViewModel @AssistedInject constructor( } } - fun updateLastCapturedImageUri(uri: Uri) { + fun updateLastCapturedMedia() { viewModelScope.launch { + val lastCapturedMediaDescriptor = mediaRepository.getLastCapturedMedia() _previewUiState.update { old -> - (old as PreviewUiState.Ready) - .copy(imageWellUiState = ImageWellUiState.LastCapture(uri)) + (old as PreviewUiState.Ready).copy( + imageWellUiState = ImageWellUiState.LastCapture( + mediaDescriptor = lastCapturedMediaDescriptor + ) + ) ?: old } } } @@ -745,9 +751,7 @@ class PreviewViewModel @AssistedInject constructor( }, contentResolver, finalImageUri, ignoreUri).savedUri }, onSuccess = { savedUri -> - savedUri?.let { - updateLastCapturedImageUri(it) - } + updateLastCapturedMedia() onImageCapture(ImageCaptureEvent.ImageSaved(savedUri), uriIndex) }, onFailure = { exception -> @@ -866,6 +870,7 @@ class PreviewViewModel @AssistedInject constructor( withDismissAction = true, testTag = VIDEO_CAPTURE_SUCCESS_TAG ) + updateLastCapturedMedia() } is CameraUseCase.OnVideoRecordEvent.OnVideoRecordError -> { diff --git a/feature/preview/src/main/java/com/google/jetpackcamera/feature/preview/ui/CameraControlsOverlay.kt b/feature/preview/src/main/java/com/google/jetpackcamera/feature/preview/ui/CameraControlsOverlay.kt index f9516d286..a2e1e811d 100644 --- a/feature/preview/src/main/java/com/google/jetpackcamera/feature/preview/ui/CameraControlsOverlay.kt +++ b/feature/preview/src/main/java/com/google/jetpackcamera/feature/preview/ui/CameraControlsOverlay.kt @@ -118,7 +118,7 @@ fun CameraControlsOverlay( (PreviewViewModel.VideoCaptureEvent) -> Unit ) -> Unit = { _, _, _ -> }, onStopVideoRecording: () -> Unit = {}, - onImageWellClick: (uri: Uri?) -> Unit = {}, + onImageWellClick: () -> Unit = {}, onLockVideoRecording: (Boolean) -> Unit ) { // Show the current zoom level for a short period of time, only when the level changes. @@ -284,7 +284,7 @@ private fun ControlsBottom( (PreviewViewModel.VideoCaptureEvent) -> Unit ) -> Unit = { _, _, _ -> }, onStopVideoRecording: () -> Unit = {}, - onImageWellClick: (uri: Uri?) -> Unit = {}, + onImageWellClick: () -> Unit = {}, onLockVideoRecording: (Boolean) -> Unit = {} ) { Column( diff --git a/feature/preview/src/main/java/com/google/jetpackcamera/feature/preview/ui/ImageWell.kt b/feature/preview/src/main/java/com/google/jetpackcamera/feature/preview/ui/ImageWell.kt index 504fec2df..56995c6cb 100644 --- a/feature/preview/src/main/java/com/google/jetpackcamera/feature/preview/ui/ImageWell.kt +++ b/feature/preview/src/main/java/com/google/jetpackcamera/feature/preview/ui/ImageWell.kt @@ -16,7 +16,6 @@ package com.google.jetpackcamera.feature.preview.ui import android.graphics.RectF -import android.net.Uri import androidx.compose.animation.AnimatedContent import androidx.compose.foundation.Canvas import androidx.compose.foundation.border @@ -31,22 +30,23 @@ import androidx.compose.ui.draw.clip import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.drawscope.drawIntoCanvas import androidx.compose.ui.graphics.nativeCanvas -import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.unit.dp -import com.google.jetpackcamera.core.common.loadAndRotateBitmap +import com.google.jetpackcamera.data.media.MediaDescriptor import kotlin.math.min @Composable fun ImageWell( modifier: Modifier = Modifier, - imageWellUiState: ImageWellUiState = ImageWellUiState.NoPreviousCapture, - onClick: (uri: Uri?) -> Unit + imageWellUiState: ImageWellUiState = ImageWellUiState.Unavailable, + onClick: () -> Unit ) { - val context = LocalContext.current - when (imageWellUiState) { is ImageWellUiState.LastCapture -> { - val bitmap = loadAndRotateBitmap(context, imageWellUiState.uri, 270f) + val bitmap = when (imageWellUiState.mediaDescriptor) { + is MediaDescriptor.Image -> imageWellUiState.mediaDescriptor.thumbnail + is MediaDescriptor.Video -> imageWellUiState.mediaDescriptor.thumbnail + is MediaDescriptor.None -> null + } bitmap?.let { Box( @@ -55,7 +55,7 @@ fun ImageWell( .padding(18.dp) .border(2.dp, Color.White, RoundedCornerShape(16.dp)) .clip(RoundedCornerShape(16.dp)) - .clickable(onClick = { onClick(imageWellUiState.uri) }) + .clickable(onClick = onClick) ) { AnimatedContent( targetState = bitmap @@ -96,14 +96,14 @@ fun ImageWell( } } - is ImageWellUiState.NoPreviousCapture -> { + is ImageWellUiState.Unavailable -> { } } } // TODO(yasith): Add support for Video sealed interface ImageWellUiState { - data object NoPreviousCapture : ImageWellUiState + data object Unavailable : ImageWellUiState - data class LastCapture(val uri: Uri) : ImageWellUiState + data class LastCapture(val mediaDescriptor: MediaDescriptor) : ImageWellUiState } diff --git a/feature/preview/src/test/java/com/google/jetpackcamera/feature/preview/PreviewViewModelTest.kt b/feature/preview/src/test/java/com/google/jetpackcamera/feature/preview/PreviewViewModelTest.kt index a6528964e..fa8dc1aba 100644 --- a/feature/preview/src/test/java/com/google/jetpackcamera/feature/preview/PreviewViewModelTest.kt +++ b/feature/preview/src/test/java/com/google/jetpackcamera/feature/preview/PreviewViewModelTest.kt @@ -18,6 +18,7 @@ package com.google.jetpackcamera.feature.preview import android.content.ContentResolver import com.google.common.truth.Truth.assertThat import com.google.jetpackcamera.core.camera.test.FakeCameraUseCase +import com.google.jetpackcamera.data.media.FakeMediaRepository import com.google.jetpackcamera.settings.SettableConstraintsRepositoryImpl import com.google.jetpackcamera.settings.model.FlashMode import com.google.jetpackcamera.settings.model.LensFacing @@ -54,7 +55,8 @@ class PreviewViewModelTest { false, cameraUseCase = cameraUseCase, constraintsRepository = constraintsRepository, - settingsRepository = FakeSettingsRepository + settingsRepository = FakeSettingsRepository, + mediaRepository = FakeMediaRepository ) advanceUntilIdle() } diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 97ecfb251..4aee2b787 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -27,6 +27,7 @@ androidxDatastore = "1.1.1" androidxGraphicsCore = "1.0.2" androidxHiltNavigationCompose = "1.2.0" androidxLifecycle = "2.8.7" +androidxMedia3 = "1.6.0-rc01" androidxNavigationCompose = "2.8.4" androidxProfileinstaller = "1.4.1" androidxTestEspresso = "3.6.1" @@ -58,6 +59,8 @@ androidx-junit = { module = "androidx.test.ext:junit", version.ref = "androidxTe androidx-lifecycle-livedata = { module = "androidx.lifecycle:lifecycle-livedata-ktx", version.ref = "androidxLifecycle" } androidx-lifecycle-viewmodel-compose = { module = "androidx.lifecycle:lifecycle-viewmodel-compose", version.ref = "androidxLifecycle" } androidx-lifecycle-runtime-compose = { module = "androidx.lifecycle:lifecycle-runtime-compose", version.ref = "androidxLifecycle" } +androidx-media3-exoplayer = { module = "androidx.media3:media3-exoplayer", version.ref = "androidxMedia3" } +androidx-media3-ui-compose = { module = "androidx.media3:media3-ui-compose", version.ref = "androidxMedia3" } androidx-navigation-compose = { module = "androidx.navigation:navigation-compose", version.ref = "androidxNavigationCompose" } androidx-orchestrator = { module = "androidx.test:orchestrator", version.ref = "orchestrator" } androidx-profileinstaller = { module = "androidx.profileinstaller:profileinstaller", version.ref = "androidxProfileinstaller" } diff --git a/settings.gradle.kts b/settings.gradle.kts index 96740cfec..cb095531e 100644 --- a/settings.gradle.kts +++ b/settings.gradle.kts @@ -38,6 +38,7 @@ include(":feature:preview") include(":core:camera") include(":feature:settings") include(":data:settings") +include(":data:media") include(":core:common") include(":benchmark") include(":feature:permissions")