From 9982903ec203eb3c78d8963508acec37ccfc7a27 Mon Sep 17 00:00:00 2001 From: Yasith Vidanaarachchi Date: Mon, 3 Mar 2025 11:41:16 -0500 Subject: [PATCH 01/22] Create data:media module, and a MediaRepository --- data/media/.gitignore | 1 + data/media/build.gradle.kts | 74 +++++++++++++++++++ data/media/consumer-rules.pro | 0 data/media/proguard-rules.pro | 21 ++++++ data/media/src/main/AndroidManifest.xml | 19 +++++ .../data/media/LocalMediaRepository.kt | 19 +++++ .../jetpackcamera/data/media/MediaModule.kt | 35 +++++++++ .../data/media/MediaRepository.kt | 23 ++++++ feature/preview/build.gradle.kts | 3 +- settings.gradle.kts | 1 + 10 files changed, 195 insertions(+), 1 deletion(-) create mode 100644 data/media/.gitignore create mode 100644 data/media/build.gradle.kts create mode 100644 data/media/consumer-rules.pro create mode 100644 data/media/proguard-rules.pro create mode 100644 data/media/src/main/AndroidManifest.xml create mode 100644 data/media/src/main/kotlin/com/google/jetpackcamera/data/media/LocalMediaRepository.kt create mode 100644 data/media/src/main/kotlin/com/google/jetpackcamera/data/media/MediaModule.kt create mode 100644 data/media/src/main/kotlin/com/google/jetpackcamera/data/media/MediaRepository.kt 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..e25d4a607 --- /dev/null +++ b/data/media/build.gradle.kts @@ -0,0 +1,74 @@ +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) +} + +// 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/LocalMediaRepository.kt b/data/media/src/main/kotlin/com/google/jetpackcamera/data/media/LocalMediaRepository.kt new file mode 100644 index 000000000..d6f57ca23 --- /dev/null +++ b/data/media/src/main/kotlin/com/google/jetpackcamera/data/media/LocalMediaRepository.kt @@ -0,0 +1,19 @@ +/* + * 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 + +class LocalMediaRepository : MediaRepository \ No newline at end of file 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..da053ffd4 --- /dev/null +++ b/data/media/src/main/kotlin/com/google/jetpackcamera/data/media/MediaModule.kt @@ -0,0 +1,35 @@ +/* + * 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.Module +import dagger.Provides +import dagger.hilt.InstallIn +import dagger.hilt.components.SingletonComponent +import javax.inject.Singleton + +/** + * Dagger [Module] for Media dependencies. + */ +@Module +@InstallIn(SingletonComponent::class) +class MediaModule { + + @Provides + @Singleton + fun provideMediaRepository() : MediaRepository = LocalMediaRepository() +} \ No newline at end of file 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..b830ce966 --- /dev/null +++ b/data/media/src/main/kotlin/com/google/jetpackcamera/data/media/MediaRepository.kt @@ -0,0 +1,23 @@ +/* + * 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 + +/** + * Data layer for Media. + */ +interface MediaRepository { +} \ 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/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") From fc1fd40b695ea6562adea60c1239329473a56227 Mon Sep 17 00:00:00 2001 From: Yasith Vidanaarachchi Date: Mon, 3 Mar 2025 12:40:55 -0500 Subject: [PATCH 02/22] Apply spotless Fill out the MediaRepository interface --- data/media/build.gradle.kts | 15 ++++++++++ .../data/media/LocalMediaRepository.kt | 3 +- .../jetpackcamera/data/media/MediaModule.kt | 5 ++-- .../data/media/MediaRepository.kt | 28 +++++++++++++++++-- 4 files changed, 44 insertions(+), 7 deletions(-) diff --git a/data/media/build.gradle.kts b/data/media/build.gradle.kts index e25d4a607..57fd0d041 100644 --- a/data/media/build.gradle.kts +++ b/data/media/build.gradle.kts @@ -1,3 +1,18 @@ +/* + * 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) 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 index d6f57ca23..751b308cb 100644 --- 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 @@ -13,7 +13,6 @@ * See the License for the specific language governing permissions and * limitations under the License. */ - package com.google.jetpackcamera.data.media -class LocalMediaRepository : MediaRepository \ No newline at end of file +class LocalMediaRepository : MediaRepository 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 index da053ffd4..b7e2328c9 100644 --- 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 @@ -13,7 +13,6 @@ * See the License for the specific language governing permissions and * limitations under the License. */ - package com.google.jetpackcamera.data.media import dagger.Module @@ -31,5 +30,5 @@ class MediaModule { @Provides @Singleton - fun provideMediaRepository() : MediaRepository = LocalMediaRepository() -} \ No newline at end of file + fun provideMediaRepository(): MediaRepository = LocalMediaRepository() +} 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 index b830ce966..11c632117 100644 --- 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 @@ -13,11 +13,35 @@ * 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 { -} \ No newline at end of file + suspend fun getLastCapturedMedia() : MediaDescriptor + suspend fun load(mediaDescriptor: MediaDescriptor) : Media +} + +/** + * Descriptors used for [Media]. + */ +sealed class 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]. + */ +sealed class Media { + data object None : Media() + class Image(val bitmap: Bitmap?) : Media() + class Video(val uri: Uri) : Media() +} + + From a9868ed9941a087b56d61337c0b0d25c021bcaf0 Mon Sep 17 00:00:00 2001 From: Yasith Vidanaarachchi Date: Wed, 12 Mar 2025 17:10:04 -0400 Subject: [PATCH 03/22] Apply Spotless --- .../com/google/jetpackcamera/data/media/MediaRepository.kt | 6 ++---- 1 file changed, 2 insertions(+), 4 deletions(-) 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 index 11c632117..dce410200 100644 --- 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 @@ -22,8 +22,8 @@ import android.net.Uri * Data layer for Media. */ interface MediaRepository { - suspend fun getLastCapturedMedia() : MediaDescriptor - suspend fun load(mediaDescriptor: MediaDescriptor) : Media + suspend fun getLastCapturedMedia(): MediaDescriptor + suspend fun load(mediaDescriptor: MediaDescriptor): Media } /** @@ -43,5 +43,3 @@ sealed class Media { class Image(val bitmap: Bitmap?) : Media() class Video(val uri: Uri) : Media() } - - From 3cfa6ba99b2e3535156dd9fbd310342a6069826b Mon Sep 17 00:00:00 2001 From: Yasith Vidanaarachchi Date: Wed, 12 Mar 2025 17:23:56 -0400 Subject: [PATCH 04/22] Add stubs to LocalMediaRepository --- .../jetpackcamera/data/media/LocalMediaRepository.kt | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) 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 index 751b308cb..df7c5a0f3 100644 --- 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 @@ -15,4 +15,12 @@ */ package com.google.jetpackcamera.data.media -class LocalMediaRepository : MediaRepository +class LocalMediaRepository : MediaRepository { + override suspend fun getLastCapturedMedia(): MediaDescriptor { + TODO("Not yet implemented") + } + + override suspend fun load(mediaDescriptor: MediaDescriptor): Media { + TODO("Not yet implemented") + } +} From 1a16a3c53f4f03b3bba49704ca84e69994d9b565 Mon Sep 17 00:00:00 2001 From: Yasith Vidanaarachchi Date: Tue, 18 Mar 2025 17:12:03 -0400 Subject: [PATCH 05/22] Address PR feedback. Change Media and MediaDescriptor into interfaces Add more KDoc Add object Error to Media --- .../data/media/MediaRepository.kt | 24 ++++++++++++------- 1 file changed, 16 insertions(+), 8 deletions(-) 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 index dce410200..d7705bf0a 100644 --- 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 @@ -28,18 +28,26 @@ interface MediaRepository { /** * Descriptors used for [Media]. + * + * Media descriptors contain a reference to a [Media] item that's not yet loaded. */ -sealed class MediaDescriptor { - data object None : MediaDescriptor() - class Image(val uri: Uri, val thumbnail: Bitmap?) : MediaDescriptor() - class Video(val uri: Uri, val thumbnail: Bitmap?) : MediaDescriptor() +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 class Media { - data object None : Media() - class Image(val bitmap: Bitmap?) : Media() - class Video(val uri: Uri) : Media() +sealed interface Media { + data object None : Media + data object Error : Media + class Image(val bitmap: Bitmap) : Media + class Video(val uri: Uri) : Media } From a6c659d629b4e8354be3c9ab39bd0d0b7c9356a9 Mon Sep 17 00:00:00 2001 From: Yasith Vidanaarachchi Date: Mon, 3 Mar 2025 12:40:55 -0500 Subject: [PATCH 06/22] Fill out the MediaRepository interface Apply Spotless From ad8ad8d85bae67f5783f1ada139fdb0c3c213897 Mon Sep 17 00:00:00 2001 From: Yasith Vidanaarachchi Date: Mon, 3 Mar 2025 12:40:55 -0500 Subject: [PATCH 07/22] Implement functions in LocalMediaRepository --- data/media/build.gradle.kts | 4 + .../data/media/LocalMediaRepository.kt | 149 +++++++++++++++++- .../jetpackcamera/data/media/MediaModule.kt | 9 +- 3 files changed, 157 insertions(+), 5 deletions(-) diff --git a/data/media/build.gradle.kts b/data/media/build.gradle.kts index 57fd0d041..2f382a2f4 100644 --- a/data/media/build.gradle.kts +++ b/data/media/build.gradle.kts @@ -81,6 +81,10 @@ dependencies { androidTestImplementation(libs.androidx.junit) androidTestImplementation(libs.truth) androidTestImplementation(libs.kotlinx.coroutines.test) + + + // Project dependencies + implementation(project(":core:common")) } // Allow references to generated code 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 index df7c5a0f3..e47f0dce7 100644 --- 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 @@ -15,12 +15,153 @@ */ package com.google.jetpackcamera.data.media -class LocalMediaRepository : MediaRepository { +import android.content.ContentUris +import android.content.Context +import android.graphics.Bitmap +import android.graphics.BitmapFactory +import android.net.Uri +import android.os.Build +import android.provider.MediaStore +import android.util.Size +import com.google.jetpackcamera.core.common.IODispatcher +import javax.inject.Inject +import kotlinx.coroutines.CoroutineDispatcher +import kotlinx.coroutines.withContext + +class LocalMediaRepository +@Inject constructor( + 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 { - TODO("Not yet implemented") + 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 + } } - override suspend fun load(mediaDescriptor: MediaDescriptor): Media { - TODO("Not yet implemented") + 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 + context.contentResolver.openInputStream(uri)?.use { inputStream -> + BitmapFactory.decodeStream(inputStream) + } + } 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 index b7e2328c9..dbad2dda3 100644 --- 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 @@ -15,11 +15,15 @@ */ package com.google.jetpackcamera.data.media +import android.content.Context +import com.google.jetpackcamera.core.common.IODispatcher import dagger.Module import dagger.Provides import dagger.hilt.InstallIn +import dagger.hilt.android.qualifiers.ApplicationContext import dagger.hilt.components.SingletonComponent import javax.inject.Singleton +import kotlinx.coroutines.CoroutineDispatcher /** * Dagger [Module] for Media dependencies. @@ -30,5 +34,8 @@ class MediaModule { @Provides @Singleton - fun provideMediaRepository(): MediaRepository = LocalMediaRepository() + fun provideMediaRepository( + @ApplicationContext context: Context, + @IODispatcher ioDispatcher: CoroutineDispatcher + ): MediaRepository = LocalMediaRepository(context, ioDispatcher) } From 2e8735382d9fdc6daed96cc72df7ba6c93403ea4 Mon Sep 17 00:00:00 2001 From: Yasith Vidanaarachchi Date: Wed, 12 Mar 2025 16:56:10 -0400 Subject: [PATCH 08/22] Fix dependency injection for MediaRepository --- .../data/media/LocalMediaRepository.kt | 3 ++- .../google/jetpackcamera/data/media/MediaModule.kt | 12 ++++++------ feature/postcapture/build.gradle.kts | 14 +++++++++++++- 3 files changed, 21 insertions(+), 8 deletions(-) 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 index e47f0dce7..4dd9e2f61 100644 --- 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 @@ -24,13 +24,14 @@ 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( - private val context: Context, + @ApplicationContext private val context: Context, @IODispatcher private val iODispatcher: CoroutineDispatcher ) : MediaRepository { 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 index dbad2dda3..e8a840af5 100644 --- 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 @@ -17,6 +17,7 @@ package com.google.jetpackcamera.data.media import android.content.Context import com.google.jetpackcamera.core.common.IODispatcher +import dagger.Binds import dagger.Module import dagger.Provides import dagger.hilt.InstallIn @@ -30,12 +31,11 @@ import kotlinx.coroutines.CoroutineDispatcher */ @Module @InstallIn(SingletonComponent::class) -class MediaModule { +interface MediaModule { - @Provides + @Binds @Singleton - fun provideMediaRepository( - @ApplicationContext context: Context, - @IODispatcher ioDispatcher: CoroutineDispatcher - ): MediaRepository = LocalMediaRepository(context, ioDispatcher) + fun bindsMediaRepository( + localMediaRepository: LocalMediaRepository + ): MediaRepository } diff --git a/feature/postcapture/build.gradle.kts b/feature/postcapture/build.gradle.kts index f42133426..b83395287 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,10 @@ dependencies { testImplementation(libs.compose.test.manifest) testImplementation(libs.compose.junit) + // Hilt + implementation(libs.dagger.hilt.android) + kapt(libs.dagger.hilt.compiler) + // Testing testImplementation(libs.junit) testImplementation(libs.truth) @@ -129,5 +135,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 +} From b7e7f5cc7b0e313a8ed074fc381dd5d2c1b223d4 Mon Sep 17 00:00:00 2001 From: Yasith Vidanaarachchi Date: Thu, 13 Mar 2025 14:49:00 -0400 Subject: [PATCH 09/22] Apply Spotless --- .../jetpackcamera/data/media/LocalMediaRepository.kt | 7 +++---- .../com/google/jetpackcamera/data/media/MediaModule.kt | 9 +-------- 2 files changed, 4 insertions(+), 12 deletions(-) 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 index 4dd9e2f61..93814f891 100644 --- 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 @@ -79,12 +79,11 @@ class LocalMediaRepository } } - return@withContext if(loadedBitmap != null) { - Media.Image(loadedBitmap) + return@withContext if (loadedBitmap != null) { + Media.Image(loadedBitmap) } else { - Media.Error + Media.Error } - } catch (e: Exception) { e.printStackTrace() return@withContext Media.Error 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 index e8a840af5..a4ec2ddfc 100644 --- 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 @@ -15,16 +15,11 @@ */ package com.google.jetpackcamera.data.media -import android.content.Context -import com.google.jetpackcamera.core.common.IODispatcher import dagger.Binds import dagger.Module -import dagger.Provides import dagger.hilt.InstallIn -import dagger.hilt.android.qualifiers.ApplicationContext import dagger.hilt.components.SingletonComponent import javax.inject.Singleton -import kotlinx.coroutines.CoroutineDispatcher /** * Dagger [Module] for Media dependencies. @@ -35,7 +30,5 @@ interface MediaModule { @Binds @Singleton - fun bindsMediaRepository( - localMediaRepository: LocalMediaRepository - ): MediaRepository + fun bindsMediaRepository(localMediaRepository: LocalMediaRepository): MediaRepository } From 31120b12d058cd8e0ad9b7c44215fe30fdda1929 Mon Sep 17 00:00:00 2001 From: Yasith Vidanaarachchi Date: Thu, 13 Mar 2025 15:31:36 -0400 Subject: [PATCH 10/22] Fix dependency injection for PostCaptureViewModel --- .../jetpackcamera/feature/postcapture/PostCaptureViewModel.kt | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) 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..d9ff2c1ed 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 @@ -19,12 +19,13 @@ import android.content.ContentResolver import android.net.Uri import androidx.lifecycle.ViewModel import dagger.hilt.android.lifecycle.HiltViewModel +import javax.inject.Inject import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.update @HiltViewModel -class PostCaptureViewModel : ViewModel() { +class PostCaptureViewModel @Inject constructor() : ViewModel() { private val _uiState = MutableStateFlow(PostCaptureUiState()) val uiState: StateFlow = _uiState From 46c712e15d8ebd944479f7b446000beec5b60fef Mon Sep 17 00:00:00 2001 From: Yasith Vidanaarachchi Date: Mon, 17 Mar 2025 17:31:39 -0400 Subject: [PATCH 11/22] Use ImageDecoder for API 29 and above --- .../google/jetpackcamera/data/media/LocalMediaRepository.kt | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) 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 index 93814f891..b38903fc7 100644 --- 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 @@ -19,6 +19,7 @@ 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 @@ -69,9 +70,8 @@ class LocalMediaRepository try { val loadedBitmap = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) { // Android 10 (API 29) and above: Use ImageDecoder - context.contentResolver.openInputStream(uri)?.use { inputStream -> - BitmapFactory.decodeStream(inputStream) - } + 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 -> From 1c787ab1df61de7b97c86cb98777126432ac3d59 Mon Sep 17 00:00:00 2001 From: Yasith Vidanaarachchi Date: Mon, 3 Mar 2025 12:40:55 -0500 Subject: [PATCH 12/22] Fill out the MediaRepository interface Apply Spotless From aacd4056a3aed96ce1bb3a52a855ce5e6aca6e62 Mon Sep 17 00:00:00 2001 From: Yasith Vidanaarachchi Date: Mon, 3 Mar 2025 12:40:55 -0500 Subject: [PATCH 13/22] Fill out the MediaRepository interface Apply Spotless From b6eb8561431a81ca2ee1414fe490a99d1049c80c Mon Sep 17 00:00:00 2001 From: Yasith Vidanaarachchi Date: Wed, 12 Mar 2025 16:54:55 -0400 Subject: [PATCH 14/22] Use MediaRepository in ImageWell and PostCapture --- .../com/google/jetpackcamera/ui/JcaApp.kt | 29 +---- .../core/common/MediaStoreUtils.kt | 100 ------------------ .../feature/postcapture/PostCaptureScreen.kt | 54 ++++++---- .../postcapture/PostCaptureViewModel.kt | 39 +++++-- .../feature/preview/PreviewScreen.kt | 13 +-- .../feature/preview/PreviewViewModel.kt | 18 ++-- .../preview/ui/CameraControlsOverlay.kt | 4 +- .../feature/preview/ui/ImageWell.kt | 18 ++-- 8 files changed, 95 insertions(+), 180 deletions(-) delete mode 100644 core/common/src/main/java/com/google/jetpackcamera/core/common/MediaStoreUtils.kt 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..4815da65a 100644 --- a/app/src/main/java/com/google/jetpackcamera/ui/JcaApp.kt +++ b/app/src/main/java/com/google/jetpackcamera/ui/JcaApp.kt @@ -118,11 +118,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,26 +152,9 @@ 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 - } - PostCaptureScreen( - imageUri = imageUri - ) + POST_CAPTURE_ROUTE, + ) { + PostCaptureScreen() } } } 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/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..32efdde06 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 @@ -40,7 +40,6 @@ 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 @@ -50,25 +49,29 @@ import androidx.compose.ui.graphics.nativeCanvas import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.unit.dp import androidx.hilt.navigation.compose.hiltViewModel -import com.google.jetpackcamera.core.common.loadAndRotateBitmap +import com.google.jetpackcamera.data.media.Media +import android.util.Log +import com.google.jetpackcamera.data.media.MediaDescriptor + +private const val TAG = "PostCaptureScreen" @Composable -fun PostCaptureScreen(viewModel: PostCaptureViewModel = hiltViewModel(), imageUri: Uri?) { +fun PostCaptureScreen( + viewModel: PostCaptureViewModel = hiltViewModel() +) { + Log.d(TAG, "PostCaptureScreen") + val uiState: PostCaptureUiState by viewModel.uiState.collectAsState() val context = LocalContext.current - LaunchedEffect(imageUri) { - viewModel.setLastCapturedImageUri(imageUri) + LaunchedEffect(Unit) { + viewModel.getLastCapture() } 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 +93,17 @@ fun PostCaptureScreen(viewModel: PostCaptureViewModel = hiltViewModel(), imageUr } } } - } ?: Text( - text = "No Image Captured", - modifier = Modifier.align(Alignment.Center) - ) + is Media.Video -> { + Text(text = "Video support pending", + modifier = Modifier.align(Alignment.Center)) + } + Media.None -> { + Text(text = "No Media Captured", modifier = Modifier.align(Alignment.Center)) + } + Media.Error -> { + Text(text = "Error loading media", modifier = Modifier.align(Alignment.Center)) + } + } Row( modifier = Modifier @@ -104,7 +114,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 +134,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 d9ff2c1ed..ce4aa73b6 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,31 +16,50 @@ package com.google.jetpackcamera.feature.postcapture import android.content.ContentResolver -import android.net.Uri import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +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 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 @Inject constructor() : ViewModel() { +class PostCaptureViewModel @Inject constructor( + private val mediaRepository: MediaRepository, +) : ViewModel() { + + private val _uiState = MutableStateFlow(PostCaptureUiState( + mediaDescriptor = MediaDescriptor.None, + media = Media.None + )) - 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 deleteImage(contentResolver: ContentResolver) { - contentResolver.delete(uiState.value.imageUri!!, null, null) - _uiState.update { it.copy(imageUri = null, isImageDeleted = true) } + 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) } } } data class PostCaptureUiState( - val imageUri: Uri? = null, - val isImageDeleted: Boolean = false + val mediaDescriptor: MediaDescriptor, + val media : Media ) 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/PreviewViewModel.kt b/feature/preview/src/main/java/com/google/jetpackcamera/feature/preview/PreviewViewModel.kt index 12deedf40..7e057f87e 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 -> 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..1a3604e2e 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 @@ -31,22 +31,24 @@ 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 + 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 +57,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 @@ -105,5 +107,5 @@ fun ImageWell( sealed interface ImageWellUiState { data object NoPreviousCapture : ImageWellUiState - data class LastCapture(val uri: Uri) : ImageWellUiState + data class LastCapture(val mediaDescriptor: MediaDescriptor) : ImageWellUiState } From 44c864507f72da4e677765d9e1469bd94fbabd5e Mon Sep 17 00:00:00 2001 From: Yasith Vidanaarachchi Date: Thu, 13 Mar 2025 11:26:21 -0400 Subject: [PATCH 15/22] Make sure video thumbnails are updated in ImageWell --- .../com/google/jetpackcamera/feature/preview/PreviewViewModel.kt | 1 + 1 file changed, 1 insertion(+) 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 7e057f87e..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 @@ -870,6 +870,7 @@ class PreviewViewModel @AssistedInject constructor( withDismissAction = true, testTag = VIDEO_CAPTURE_SUCCESS_TAG ) + updateLastCapturedMedia() } is CameraUseCase.OnVideoRecordEvent.OnVideoRecordError -> { From 71e894ac224d13c0201008d475893d25e437d5ef Mon Sep 17 00:00:00 2001 From: Yasith Vidanaarachchi Date: Thu, 13 Mar 2025 15:07:14 -0400 Subject: [PATCH 16/22] Apply Spotless --- .../java/com/google/jetpackcamera/ui/JcaApp.kt | 5 +---- .../feature/postcapture/PostCaptureScreen.kt | 16 ++++++++-------- .../feature/postcapture/PostCaptureViewModel.kt | 16 +++++++++------- .../feature/preview/ui/ImageWell.kt | 6 ++---- 4 files changed, 20 insertions(+), 23 deletions(-) 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 4815da65a..70f0f75b1 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 @@ -152,7 +149,7 @@ private fun JetpackCameraNavHost( } composable( - POST_CAPTURE_ROUTE, + POST_CAPTURE_ROUTE ) { PostCaptureScreen() } 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 32efdde06..f8470a9f7 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 @@ -18,6 +18,7 @@ package com.google.jetpackcamera.feature.postcapture import android.content.Context import android.content.Intent import android.net.Uri +import android.util.Log import androidx.compose.foundation.Canvas import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box @@ -50,15 +51,12 @@ import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.unit.dp import androidx.hilt.navigation.compose.hiltViewModel import com.google.jetpackcamera.data.media.Media -import android.util.Log import com.google.jetpackcamera.data.media.MediaDescriptor private const val TAG = "PostCaptureScreen" @Composable -fun PostCaptureScreen( - viewModel: PostCaptureViewModel = hiltViewModel() -) { +fun PostCaptureScreen(viewModel: PostCaptureViewModel = hiltViewModel()) { Log.d(TAG, "PostCaptureScreen") val uiState: PostCaptureUiState by viewModel.uiState.collectAsState() @@ -69,7 +67,7 @@ fun PostCaptureScreen( } Box(modifier = Modifier.fillMaxSize()) { - when(val media = uiState.media) { + when (val media = uiState.media) { is Media.Image -> { val bitmap = media.bitmap Canvas(modifier = Modifier.fillMaxSize()) { @@ -94,8 +92,10 @@ fun PostCaptureScreen( } } is Media.Video -> { - Text(text = "Video support pending", - modifier = Modifier.align(Alignment.Center)) + Text( + text = "Video support pending", + modifier = Modifier.align(Alignment.Center) + ) } Media.None -> { Text(text = "No Media Captured", modifier = Modifier.align(Alignment.Center)) @@ -140,7 +140,7 @@ fun PostCaptureScreen( shareImage(context, mediaDescriptor.uri) } - if(mediaDescriptor is MediaDescriptor.Video) { + if (mediaDescriptor is MediaDescriptor.Video) { shareImage(context, mediaDescriptor.uri) } }, 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 ce4aa73b6..ddb4597fd 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 @@ -30,13 +30,15 @@ import kotlinx.coroutines.launch @HiltViewModel class PostCaptureViewModel @Inject constructor( - private val mediaRepository: MediaRepository, + private val mediaRepository: MediaRepository ) : ViewModel() { - private val _uiState = MutableStateFlow(PostCaptureUiState( - mediaDescriptor = MediaDescriptor.None, - media = Media.None - )) + private val _uiState = MutableStateFlow( + PostCaptureUiState( + mediaDescriptor = MediaDescriptor.None, + media = Media.None + ) + ) val uiState: StateFlow = _uiState @@ -50,7 +52,7 @@ class PostCaptureViewModel @Inject constructor( } fun deleteMedia(contentResolver: ContentResolver) { - when(val mediaDescriptor = uiState.value.mediaDescriptor) { + 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 -> {} @@ -61,5 +63,5 @@ class PostCaptureViewModel @Inject constructor( data class PostCaptureUiState( val mediaDescriptor: MediaDescriptor, - val media : Media + val media: Media ) 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 1a3604e2e..10f42bbdf 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 @@ -43,10 +42,9 @@ fun ImageWell( ) { when (imageWellUiState) { is ImageWellUiState.LastCapture -> { - - val bitmap = when(imageWellUiState.mediaDescriptor) { + val bitmap = when (imageWellUiState.mediaDescriptor) { is MediaDescriptor.Image -> imageWellUiState.mediaDescriptor.thumbnail - is MediaDescriptor.Video -> imageWellUiState.mediaDescriptor.thumbnail + is MediaDescriptor.Video -> imageWellUiState.mediaDescriptor.thumbnail is MediaDescriptor.None -> null } From 9a8be2612eb0e5ebe739fa7b9971813dcb4fe365 Mon Sep 17 00:00:00 2001 From: Yasith Vidanaarachchi Date: Thu, 13 Mar 2025 15:44:34 -0400 Subject: [PATCH 17/22] Add a FakeMediaRepository for PreviewViewModelTest --- .../data/media/FakeMediaRepository.kt | 26 +++++++++++++++++++ .../feature/preview/PreviewViewModelTest.kt | 4 ++- 2 files changed, 29 insertions(+), 1 deletion(-) create mode 100644 data/media/src/main/kotlin/com/google/jetpackcamera/data/media/FakeMediaRepository.kt 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/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() } From 80bf58e40003d2bc3869b82c7ed8855728b90e7a Mon Sep 17 00:00:00 2001 From: Yasith Vidanaarachchi Date: Wed, 19 Mar 2025 11:29:52 -0400 Subject: [PATCH 18/22] Address PR comments --- .../feature/postcapture/PostCaptureScreen.kt | 16 ++++++++------- .../postcapture/PostCaptureViewModel.kt | 4 ++++ .../src/main/res/values/strings.xml | 20 +++++++++++++++++++ .../feature/preview/PreviewUiState.kt | 2 +- .../feature/preview/ui/ImageWell.kt | 6 +++--- 5 files changed, 37 insertions(+), 11 deletions(-) create mode 100644 feature/postcapture/src/main/res/values/strings.xml 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 f8470a9f7..906180461 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 @@ -38,7 +38,6 @@ 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.ui.Alignment @@ -48,6 +47,7 @@ import androidx.compose.ui.geometry.Size import androidx.compose.ui.graphics.drawscope.drawIntoCanvas import androidx.compose.ui.graphics.nativeCanvas 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.data.media.Media @@ -62,10 +62,6 @@ fun PostCaptureScreen(viewModel: PostCaptureViewModel = hiltViewModel()) { val uiState: PostCaptureUiState by viewModel.uiState.collectAsState() val context = LocalContext.current - LaunchedEffect(Unit) { - viewModel.getLastCapture() - } - Box(modifier = Modifier.fillMaxSize()) { when (val media = uiState.media) { is Media.Image -> { @@ -98,10 +94,16 @@ fun PostCaptureScreen(viewModel: PostCaptureViewModel = hiltViewModel()) { ) } Media.None -> { - Text(text = "No Media Captured", modifier = Modifier.align(Alignment.Center)) + Text( + text = stringResource(R.string.no_media_available), + modifier = Modifier.align(Alignment.Center) + ) } Media.Error -> { - Text(text = "Error loading media", modifier = Modifier.align(Alignment.Center)) + Text( + text = stringResource(R.string.error_loading_media), + modifier = Modifier.align(Alignment.Center) + ) } } 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 ddb4597fd..a95763604 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 @@ -33,6 +33,10 @@ class PostCaptureViewModel @Inject constructor( private val mediaRepository: MediaRepository ) : ViewModel() { + init { + getLastCapture() + } + private val _uiState = MutableStateFlow( PostCaptureUiState( mediaDescriptor = MediaDescriptor.None, 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/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/ui/ImageWell.kt b/feature/preview/src/main/java/com/google/jetpackcamera/feature/preview/ui/ImageWell.kt index 10f42bbdf..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 @@ -37,7 +37,7 @@ import kotlin.math.min @Composable fun ImageWell( modifier: Modifier = Modifier, - imageWellUiState: ImageWellUiState = ImageWellUiState.NoPreviousCapture, + imageWellUiState: ImageWellUiState = ImageWellUiState.Unavailable, onClick: () -> Unit ) { when (imageWellUiState) { @@ -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 mediaDescriptor: MediaDescriptor) : ImageWellUiState } From 66d4bdcbedacbdb730b6c19669aab19f9f62b5e0 Mon Sep 17 00:00:00 2001 From: Yasith Vidanaarachchi Date: Thu, 13 Mar 2025 10:50:44 -0400 Subject: [PATCH 19/22] Add Video Playback to PostCaptureScreen --- feature/postcapture/build.gradle.kts | 4 ++++ .../feature/postcapture/PostCaptureScreen.kt | 12 +++++++++--- .../postcapture/PostCaptureViewModel.kt | 19 ++++++++++++++++++- gradle/libs.versions.toml | 3 +++ 4 files changed, 34 insertions(+), 4 deletions(-) diff --git a/feature/postcapture/build.gradle.kts b/feature/postcapture/build.gradle.kts index b83395287..88f1bf042 100644 --- a/feature/postcapture/build.gradle.kts +++ b/feature/postcapture/build.gradle.kts @@ -117,6 +117,10 @@ dependencies { 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) 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 906180461..a83e3009b 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 @@ -19,6 +19,7 @@ import android.content.Context import android.content.Intent import android.net.Uri import android.util.Log +import androidx.annotation.OptIn import androidx.compose.foundation.Canvas import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box @@ -50,11 +51,15 @@ 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 androidx.media3.common.util.UnstableApi +import androidx.media3.ui.compose.PlayerSurface +import androidx.media3.ui.compose.SURFACE_TYPE_SURFACE_VIEW 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()) { Log.d(TAG, "PostCaptureScreen") @@ -88,10 +93,11 @@ fun PostCaptureScreen(viewModel: PostCaptureViewModel = hiltViewModel()) { } } is Media.Video -> { - Text( - text = "Video support pending", - modifier = Modifier.align(Alignment.Center) + PlayerSurface( + player = viewModel.player, + surfaceType = SURFACE_TYPE_SURFACE_VIEW ) + viewModel.playVideo() } Media.None -> { Text( 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 a95763604..141e8f60c 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,12 +16,16 @@ package com.google.jetpackcamera.feature.postcapture import android.content.ContentResolver +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 @@ -30,7 +34,8 @@ import kotlinx.coroutines.launch @HiltViewModel class PostCaptureViewModel @Inject constructor( - private val mediaRepository: MediaRepository + private val mediaRepository: MediaRepository, + @ApplicationContext private val context: Context ) : ViewModel() { init { @@ -44,6 +49,8 @@ class PostCaptureViewModel @Inject constructor( ) ) + val player = ExoPlayer.Builder(context).build() + val uiState: StateFlow = _uiState fun getLastCapture() { @@ -63,6 +70,16 @@ class PostCaptureViewModel @Inject constructor( } _uiState.update { it.copy(mediaDescriptor = MediaDescriptor.None, media = Media.None) } } + + fun playVideo() { + val media = uiState.value.media + if (media is Media.Video) { + val mediaItem = MediaItem.fromUri(media.uri) + player.setMediaItem(mediaItem) + player.prepare() + player.play() + } + } } data class PostCaptureUiState( diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 97ecfb251..25097f802 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-beta01" 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" } From 38e36bd63fe8f4e809de18fbe539ba353e3f4bf6 Mon Sep 17 00:00:00 2001 From: Yasith Vidanaarachchi Date: Thu, 13 Mar 2025 17:01:47 -0400 Subject: [PATCH 20/22] Fix content scale, and upgrade media3 version to 1.6 rc1 --- .../feature/postcapture/PostCaptureScreen.kt | 10 ++++++++-- gradle/libs.versions.toml | 2 +- 2 files changed, 9 insertions(+), 3 deletions(-) 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 a83e3009b..45282eba1 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 @@ -47,13 +47,15 @@ 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 androidx.media3.common.util.UnstableApi import androidx.media3.ui.compose.PlayerSurface -import androidx.media3.ui.compose.SURFACE_TYPE_SURFACE_VIEW +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 @@ -93,9 +95,13 @@ fun PostCaptureScreen(viewModel: PostCaptureViewModel = hiltViewModel()) { } } is Media.Video -> { + val presentationState = rememberPresentationState(viewModel.player) PlayerSurface( player = viewModel.player, - surfaceType = SURFACE_TYPE_SURFACE_VIEW + modifier = Modifier.resizeWithContentScale( + ContentScale.Fit, + presentationState.videoSizeDp + ) ) viewModel.playVideo() } diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 25097f802..4aee2b787 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -27,7 +27,7 @@ androidxDatastore = "1.1.1" androidxGraphicsCore = "1.0.2" androidxHiltNavigationCompose = "1.2.0" androidxLifecycle = "2.8.7" -androidxMedia3 = "1.6.0-beta01" +androidxMedia3 = "1.6.0-rc01" androidxNavigationCompose = "2.8.4" androidxProfileinstaller = "1.4.1" androidxTestEspresso = "3.6.1" From e612b8e3b6252677279f0e94b2b6e8a11f7d7808 Mon Sep 17 00:00:00 2001 From: Yasith Vidanaarachchi Date: Wed, 19 Mar 2025 13:27:14 -0400 Subject: [PATCH 21/22] Make the video loop --- .../jetpackcamera/feature/postcapture/PostCaptureViewModel.kt | 1 + 1 file changed, 1 insertion(+) 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 141e8f60c..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 @@ -77,6 +77,7 @@ class PostCaptureViewModel @Inject constructor( val mediaItem = MediaItem.fromUri(media.uri) player.setMediaItem(mediaItem) player.prepare() + player.setRepeatMode(ExoPlayer.REPEAT_MODE_ONE) player.play() } } From a845d1b85d315d92804517f5306f49173a95254b Mon Sep 17 00:00:00 2001 From: Yasith Vidanaarachchi Date: Mon, 17 Mar 2025 16:29:21 -0400 Subject: [PATCH 22/22] Implement support for HDR playback in PostCaptureScreen --- .../com/google/jetpackcamera/ui/JcaApp.kt | 4 +++- .../feature/postcapture/PostCaptureScreen.kt | 22 ++++++++++++++++++- 2 files changed, 24 insertions(+), 2 deletions(-) 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 70f0f75b1..889771b14 100644 --- a/app/src/main/java/com/google/jetpackcamera/ui/JcaApp.kt +++ b/app/src/main/java/com/google/jetpackcamera/ui/JcaApp.kt @@ -151,7 +151,9 @@ private fun JetpackCameraNavHost( composable( POST_CAPTURE_ROUTE ) { - PostCaptureScreen() + PostCaptureScreen( + onRequestWindowColorMode = onRequestWindowColorMode + ) } } } 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 45282eba1..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,8 +17,11 @@ 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 @@ -63,7 +66,10 @@ private const val TAG = "PostCaptureScreen" @OptIn(UnstableApi::class) @Composable -fun PostCaptureScreen(viewModel: PostCaptureViewModel = hiltViewModel()) { +fun PostCaptureScreen( + onRequestWindowColorMode: (Int) -> Unit = {}, + viewModel: PostCaptureViewModel = hiltViewModel() +) { Log.d(TAG, "PostCaptureScreen") val uiState: PostCaptureUiState by viewModel.uiState.collectAsState() @@ -103,6 +109,20 @@ fun PostCaptureScreen(viewModel: PostCaptureViewModel = hiltViewModel()) { 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 -> {