From df7c28c858d3edcf65fb435920f6badbd507f13e Mon Sep 17 00:00:00 2001 From: srikrishnasakunia Date: Mon, 11 Aug 2025 16:25:27 +0530 Subject: [PATCH 1/5] Moved ResultsScreen.kt & CustomizeExportScreen.kt to open from MainNavigation.kt using Nav3 instead of driving them from CreationScreen.kt # Conflicts: # feature/results/src/main/java/com/android/developers/androidify/customize/ImageRenderer.kt # Conflicts: # feature/results/src/main/java/com/android/developers/androidify/customize/CustomizeState.kt --- .../androidify/navigation/MainNavigation.kt | 48 +++++++++++++++++++ .../androidify/navigation/NavigationRoutes.kt | 6 +++ .../androidify/creation/CreationScreen.kt | 37 ++------------ .../androidify/creation/CreationViewModel.kt | 44 ++++++++++------- .../creation/CreationViewModelTest.kt | 4 +- .../androidify/results/ResultsScreenTest.kt | 14 +++--- .../customize/CustomizeExportScreen.kt | 16 +++---- .../customize/CustomizeExportViewModel.kt | 4 +- .../androidify/customize/CustomizeState.kt | 2 +- .../androidify/customize/ImageRenderer.kt | 33 ++++++------- .../androidify/results/BotResultCard.kt | 24 ++-------- .../androidify/results/ResultsScreen.kt | 31 +++++++----- .../androidify/results/ResultsViewModel.kt | 4 +- 13 files changed, 149 insertions(+), 118 deletions(-) diff --git a/app/src/main/java/com/android/developers/androidify/navigation/MainNavigation.kt b/app/src/main/java/com/android/developers/androidify/navigation/MainNavigation.kt index 8eab6f36..2d302a21 100644 --- a/app/src/main/java/com/android/developers/androidify/navigation/MainNavigation.kt +++ b/app/src/main/java/com/android/developers/androidify/navigation/MainNavigation.kt @@ -32,6 +32,7 @@ import androidx.compose.runtime.remember import androidx.compose.runtime.setValue import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.unit.IntOffset +import androidx.core.net.toUri import androidx.lifecycle.viewmodel.navigation3.rememberViewModelStoreNavEntryDecorator import androidx.navigation3.runtime.entry import androidx.navigation3.runtime.entryProvider @@ -39,8 +40,10 @@ import androidx.navigation3.runtime.rememberSavedStateNavEntryDecorator import androidx.navigation3.ui.NavDisplay import com.android.developers.androidify.camera.CameraPreviewScreen import com.android.developers.androidify.creation.CreationScreen +import com.android.developers.androidify.customize.CustomizeAndExportScreen import com.android.developers.androidify.home.AboutScreen import com.android.developers.androidify.home.HomeScreen +import com.android.developers.androidify.results.ResultsScreen import com.android.developers.androidify.theme.transitions.ColorSplashTransitionScreen import com.google.android.gms.oss.licenses.OssLicensesMenuActivity @@ -110,6 +113,51 @@ fun MainNavigation() { onAboutPressed = { backStack.add(About) }, + onImageCreated = { resultImageUri, prompt, originalImageUri -> + backStack.removeAll{ it is ImageResult} + backStack.add( + ImageResult( + result = resultImageUri.toString(), + prompt = prompt, + originalImageUri = originalImageUri?.toString() + ) + ) + } + ) + } + entry { resultKey -> + ResultsScreen( + resultImageUri = resultKey.result.toUri(), + originalImageUri = resultKey.originalImageUri?.toUri(), + onNextPress = { resultImageUri, originalImageUri -> + backStack.removeAll{ it is ImageResult} + backStack.add( + ShareResult( + resultUri = resultImageUri.toString(), + originalImageUri = originalImageUri?.toString() + ) + ) + }, + promptText = resultKey.prompt, + onAboutPress = { + backStack.add(About) + }, + onBackPress = { + backStack.removeLastOrNull() + backStack.add(Create(fileName = resultKey.originalImageUri, prompt = resultKey.prompt)) + } + ) + } + entry { shareKey -> + CustomizeAndExportScreen( + resultImageUri = shareKey.resultUri.toUri(), + originalImageUri = shareKey.originalImageUri?.toUri(), + onBackPress = { + backStack.removeLastOrNull() + }, + onInfoPress = { + backStack.add(About) + } ) } entry { diff --git a/app/src/main/java/com/android/developers/androidify/navigation/NavigationRoutes.kt b/app/src/main/java/com/android/developers/androidify/navigation/NavigationRoutes.kt index 81c8338f..46f5f943 100644 --- a/app/src/main/java/com/android/developers/androidify/navigation/NavigationRoutes.kt +++ b/app/src/main/java/com/android/developers/androidify/navigation/NavigationRoutes.kt @@ -33,3 +33,9 @@ object Camera : NavigationRoute @Serializable object About : NavigationRoute + +@Serializable +data class ImageResult(val originalImageUri: String? = null, val prompt: String? = null, val result: String) : NavigationRoute + +@Serializable +data class ShareResult(val resultUri: String, val originalImageUri: String?) : NavigationRoute \ No newline at end of file diff --git a/feature/creation/src/main/java/com/android/developers/androidify/creation/CreationScreen.kt b/feature/creation/src/main/java/com/android/developers/androidify/creation/CreationScreen.kt index 40767c56..8192463d 100644 --- a/feature/creation/src/main/java/com/android/developers/androidify/creation/CreationScreen.kt +++ b/feature/creation/src/main/java/com/android/developers/androidify/creation/CreationScreen.kt @@ -164,6 +164,7 @@ fun CreationScreen( onCameraPressed: () -> Unit = {}, onBackPressed: () -> Unit, onAboutPressed: () -> Unit, + onImageCreated: (resultImageUri: Uri, prompt: String?, originalImageUri: Uri?) -> Unit, ) { val uiState by creationViewModel.uiState.collectAsStateWithLifecycle() BackHandler( @@ -213,44 +214,16 @@ fun CreationScreen( } ScreenState.RESULT -> { - val prompt = uiState.descriptionText.text.toString() - val key = if (uiState.descriptionText.text.isBlank()) { - uiState.imageUri.toString() - } else { - prompt - } - ResultsScreen( - uiState.resultBitmap!!, + onImageCreated( + uiState.resultBitmapUri!!, + uiState.descriptionText.text.toString(), if (uiState.selectedPromptOption == PromptType.PHOTO) { uiState.imageUri } else { null - }, - promptText = prompt, - viewModel = hiltViewModel(key = key), - onAboutPress = onAboutPressed, - onBackPress = onBackPressed, - onNextPress = creationViewModel::customizeExportClicked, + } ) } - - ScreenState.CUSTOMIZE -> { - val prompt = uiState.descriptionText.text.toString() - val key = if (uiState.descriptionText.text.isBlank()) { - uiState.imageUri.toString() - } else { - prompt - } - uiState.resultBitmap?.let { bitmap -> - CustomizeAndExportScreen( - resultImage = bitmap, - originalImageUri = uiState.imageUri, - onBackPress = onBackPressed, - onInfoPress = onAboutPressed, - viewModel = hiltViewModel(key = key), - ) - } - } } } diff --git a/feature/creation/src/main/java/com/android/developers/androidify/creation/CreationViewModel.kt b/feature/creation/src/main/java/com/android/developers/androidify/creation/CreationViewModel.kt index b9e34558..45e0f3a2 100644 --- a/feature/creation/src/main/java/com/android/developers/androidify/creation/CreationViewModel.kt +++ b/feature/creation/src/main/java/com/android/developers/androidify/creation/CreationViewModel.kt @@ -36,11 +36,15 @@ import com.android.developers.androidify.data.TextGenerationRepository import com.android.developers.androidify.util.LocalFileProvider import dagger.hilt.android.lifecycle.HiltViewModel import dagger.hilt.android.qualifiers.ApplicationContext +import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Job import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.update import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext +import java.io.File +import java.io.FileOutputStream import javax.inject.Inject @HiltViewModel @@ -153,7 +157,7 @@ class CreationViewModel @Inject constructor( ) } _uiState.update { - it.copy(resultBitmap = bitmap, screenState = ScreenState.RESULT) + it.copy(resultBitmapUri = saveBitmapToCache(context, bitmap), screenState = ScreenState.RESULT) } } catch (e: Exception) { handleImageGenerationError(e) @@ -220,25 +224,13 @@ class CreationViewModel @Inject constructor( ScreenState.RESULT -> { _uiState.update { - it.copy(screenState = ScreenState.EDIT, resultBitmap = null) + it.copy(screenState = ScreenState.EDIT, resultBitmapUri = null) } } ScreenState.EDIT -> { // do nothing, back press handled outside } - - ScreenState.CUSTOMIZE -> { - _uiState.update { - it.copy(screenState = ScreenState.RESULT) - } - } - } - } - - fun customizeExportClicked() { - _uiState.update { - it.copy(screenState = ScreenState.CUSTOMIZE) } } } @@ -252,14 +244,13 @@ data class CreationState( val generatedPrompt: String? = null, val promptGenerationInProgress: Boolean = false, val screenState: ScreenState = ScreenState.EDIT, - val resultBitmap: Bitmap? = null, + val resultBitmapUri: Uri? = null, ) enum class ScreenState { EDIT, LOADING, RESULT, - CUSTOMIZE, } data class BotColor( @@ -301,3 +292,24 @@ enum class PromptType(val displayName: String) { PHOTO("Photo"), TEXT("Prompt"), } + +suspend fun saveBitmapToCache( + context: Context, + bitmap: Bitmap, + compressionFormat: Bitmap.CompressFormat = Bitmap.CompressFormat.JPEG, + quality: Int = 100 +): Uri? = withContext(Dispatchers.IO) { + + val cacheDir = context.cacheDir + val fileName = File(cacheDir, "temp_image_${System.currentTimeMillis()}.jpg") + try { + FileOutputStream(fileName).use { outputStream -> + bitmap.compress(compressionFormat, quality, outputStream) + } + Uri.fromFile(fileName) + } catch (e: Exception) { + e.printStackTrace() + null + } + +} \ No newline at end of file diff --git a/feature/creation/src/test/kotlin/com/android/developers/androidify/creation/CreationViewModelTest.kt b/feature/creation/src/test/kotlin/com/android/developers/androidify/creation/CreationViewModelTest.kt index 11105ce9..c9d760fd 100644 --- a/feature/creation/src/test/kotlin/com/android/developers/androidify/creation/CreationViewModelTest.kt +++ b/feature/creation/src/test/kotlin/com/android/developers/androidify/creation/CreationViewModelTest.kt @@ -117,7 +117,7 @@ class CreationViewModelTest { viewModel.onSelectedPromptOptionChanged(PromptType.PHOTO) viewModel.startClicked() assertEquals(ScreenState.RESULT, viewModel.uiState.value.screenState) - assertNotNull(viewModel.uiState.value.resultBitmap) + assertNotNull(viewModel.uiState.value.resultBitmapUri) } @Test @@ -178,7 +178,7 @@ class CreationViewModelTest { } viewModel.startClicked() assertEquals(ScreenState.RESULT, viewModel.uiState.value.screenState) - assertNotNull(viewModel.uiState.value.resultBitmap) + assertNotNull(viewModel.uiState.value.resultBitmapUri) } @Test diff --git a/feature/results/src/androidTest/java/com/android/developers/androidify/results/ResultsScreenTest.kt b/feature/results/src/androidTest/java/com/android/developers/androidify/results/ResultsScreenTest.kt index cad23c8c..5dba07c0 100644 --- a/feature/results/src/androidTest/java/com/android/developers/androidify/results/ResultsScreenTest.kt +++ b/feature/results/src/androidTest/java/com/android/developers/androidify/results/ResultsScreenTest.kt @@ -42,14 +42,14 @@ class ResultsScreenTest { val composeTestRule = createAndroidComposeRule() // Create a dummy bitmap for testing - private val testBitmap: Bitmap = Bitmap.createBitmap(100, 100, Bitmap.Config.ARGB_8888) + val dummyUri = android.net.Uri.parse("dummy://image") @Test fun resultsScreenContents_displaysActionButtons() { val shareButtonText = composeTestRule.activity.getString(R.string.customize_and_share) // Note: Download button is identified by icon, harder to test reliably without tags/desc - val initialState = ResultState(resultImageBitmap = testBitmap, promptText = "test") + val initialState = ResultState(resultImageUri = dummyUri, promptText = "test") val state = mutableStateOf(initialState) composeTestRule.setContent { @@ -78,7 +78,7 @@ class ResultsScreenTest { val frontCardDesc = composeTestRule.activity.getString(R.string.resultant_android_bot) // Ensure promptText is non-null when bitmap is present - val initialState = ResultState(resultImageBitmap = testBitmap, promptText = "test") + val initialState = ResultState(resultImageUri = dummyUri, promptText = "test") val state = mutableStateOf(initialState) composeTestRule.setContent { @@ -108,7 +108,7 @@ class ResultsScreenTest { val backCardDesc = composeTestRule.activity.getString(R.string.original_image) val dummyUri = android.net.Uri.parse("dummy://image") - val initialState = ResultState(resultImageBitmap = testBitmap, originalImageUrl = dummyUri) + val initialState = ResultState(resultImageUri = dummyUri, originalImageUrl = dummyUri) val state = mutableStateOf(initialState) composeTestRule.setContent { @@ -143,7 +143,7 @@ class ResultsScreenTest { val promptText = "test prompt" val promptPrefix = composeTestRule.activity.getString(R.string.my_bot_is_wearing) - val initialState = ResultState(resultImageBitmap = testBitmap, promptText = promptText) // No original image URI + val initialState = ResultState(resultImageUri = dummyUri, promptText = promptText) // No original image URI val state = mutableStateOf(initialState) composeTestRule.setContent { @@ -175,7 +175,7 @@ class ResultsScreenTest { val frontCardDesc = composeTestRule.activity.getString(R.string.resultant_android_bot) val dummyUri = android.net.Uri.parse("dummy://image") - val initialState = ResultState(resultImageBitmap = testBitmap, originalImageUrl = dummyUri) + val initialState = ResultState(resultImageUri = dummyUri, originalImageUrl = dummyUri) val state = mutableStateOf(initialState) composeTestRule.setContent { @@ -210,7 +210,7 @@ class ResultsScreenTest { var shareClicked = false // Ensure promptText is non-null when bitmap is present - val initialState = ResultState(resultImageBitmap = testBitmap, promptText = "test") + val initialState = ResultState(resultImageUri = dummyUri, promptText = "test") val state = mutableStateOf(initialState) composeTestRule.setContent { diff --git a/feature/results/src/main/java/com/android/developers/androidify/customize/CustomizeExportScreen.kt b/feature/results/src/main/java/com/android/developers/androidify/customize/CustomizeExportScreen.kt index 0778e228..1ba4ace7 100644 --- a/feature/results/src/main/java/com/android/developers/androidify/customize/CustomizeExportScreen.kt +++ b/feature/results/src/main/java/com/android/developers/androidify/customize/CustomizeExportScreen.kt @@ -62,7 +62,6 @@ import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip import androidx.compose.ui.draw.dropShadow import androidx.compose.ui.graphics.ImageBitmap -import androidx.compose.ui.graphics.asAndroidBitmap import androidx.compose.ui.graphics.shadow.Shadow import androidx.compose.ui.graphics.vector.ImageVector import androidx.compose.ui.layout.LookaheadScope @@ -72,6 +71,7 @@ import androidx.compose.ui.res.stringResource import androidx.compose.ui.res.vectorResource import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp +import androidx.core.net.toUri import androidx.hilt.navigation.compose.hiltViewModel import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.navigation3.ui.LocalNavAnimatedContentScope @@ -97,15 +97,15 @@ import com.android.developers.androidify.theme.R as ThemeR @OptIn(ExperimentalMaterial3Api::class) @Composable fun CustomizeAndExportScreen( - resultImage: Bitmap, + resultImageUri: Uri, originalImageUri: Uri?, onBackPress: () -> Unit, onInfoPress: () -> Unit, isMediumWindowSize: Boolean = isAtLeastMedium(), viewModel: CustomizeExportViewModel = hiltViewModel(), ) { - LaunchedEffect(resultImage, originalImageUri) { - viewModel.setArguments(resultImage, originalImageUri) + LaunchedEffect(resultImageUri, originalImageUri) { + viewModel.setArguments(resultImageUri, originalImageUri) } val state = viewModel.state.collectAsStateWithLifecycle() val context = LocalContext.current @@ -430,9 +430,9 @@ fun CustomizeExportPreview() { AnimatedContent(true) { targetState -> targetState CompositionLocalProvider(LocalNavAnimatedContentScope provides this@AnimatedContent) { - val bitmap = ImageBitmap.imageResource(R.drawable.placeholderbot) + val imageUri = ("android.resource://com.android.developers.androidify.results/" + R.drawable.placeholderbot).toUri() val state = CustomizeExportState( - exportImageCanvas = ExportImageCanvas(imageBitmap = bitmap.asAndroidBitmap()), + exportImageCanvas = ExportImageCanvas(imageUri = imageUri), ) CustomizeExportContents( state = state, @@ -457,10 +457,10 @@ fun CustomizeExportPreviewLarge() { AnimatedContent(true) { targetState -> targetState CompositionLocalProvider(LocalNavAnimatedContentScope provides this@AnimatedContent) { - val bitmap = ImageBitmap.imageResource(R.drawable.placeholderbot) + val imageUri = ("android.resource://com.android.developers.androidify.results/" + R.drawable.placeholderbot).toUri() val state = CustomizeExportState( exportImageCanvas = ExportImageCanvas( - imageBitmap = bitmap.asAndroidBitmap(), + imageUri = imageUri, aspectRatioOption = SizeOption.Square, ), selectedTool = CustomizeTool.Background, diff --git a/feature/results/src/main/java/com/android/developers/androidify/customize/CustomizeExportViewModel.kt b/feature/results/src/main/java/com/android/developers/androidify/customize/CustomizeExportViewModel.kt index 416993da..28d73b1e 100644 --- a/feature/results/src/main/java/com/android/developers/androidify/customize/CustomizeExportViewModel.kt +++ b/feature/results/src/main/java/com/android/developers/androidify/customize/CustomizeExportViewModel.kt @@ -54,13 +54,13 @@ class CustomizeExportViewModel @Inject constructor( } fun setArguments( - resultImageUrl: Bitmap, + resultImageUrl: Uri, originalImageUrl: Uri?, ) { _state.update { CustomizeExportState( originalImageUrl, - exportImageCanvas = it.exportImageCanvas.copy(imageBitmap = resultImageUrl), + exportImageCanvas = it.exportImageCanvas.copy(imageUri = resultImageUrl), ) } } diff --git a/feature/results/src/main/java/com/android/developers/androidify/customize/CustomizeState.kt b/feature/results/src/main/java/com/android/developers/androidify/customize/CustomizeState.kt index 07319489..796d722a 100644 --- a/feature/results/src/main/java/com/android/developers/androidify/customize/CustomizeState.kt +++ b/feature/results/src/main/java/com/android/developers/androidify/customize/CustomizeState.kt @@ -75,7 +75,7 @@ data class BackgroundToolState( ) : ToolState data class ExportImageCanvas( - val imageBitmap: Bitmap? = null, + val imageUri: Uri? = null, val imageBitmapRemovedBackground: Bitmap? = null, val aspectRatioOption: SizeOption = SizeOption.Square, val canvasSize: Size = Size(1000f, 1000f), diff --git a/feature/results/src/main/java/com/android/developers/androidify/customize/ImageRenderer.kt b/feature/results/src/main/java/com/android/developers/androidify/customize/ImageRenderer.kt index b65673a3..736fd090 100644 --- a/feature/results/src/main/java/com/android/developers/androidify/customize/ImageRenderer.kt +++ b/feature/results/src/main/java/com/android/developers/androidify/customize/ImageRenderer.kt @@ -38,13 +38,14 @@ import androidx.compose.ui.draw.rotate import androidx.compose.ui.geometry.Rect import androidx.compose.ui.geometry.Size import androidx.compose.ui.graphics.ImageBitmap -import androidx.compose.ui.graphics.asAndroidBitmap import androidx.compose.ui.graphics.asImageBitmap import androidx.compose.ui.layout.ContentScale import androidx.compose.ui.layout.layout import androidx.compose.ui.res.imageResource import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.util.fastRoundToInt +import androidx.core.net.toUri +import coil3.compose.AsyncImage import com.android.developers.androidify.results.R import com.android.developers.androidify.theme.AndroidifyTheme import com.android.developers.androidify.theme.LocalAnimateBoundsScope @@ -91,9 +92,9 @@ fun ImageResult( contentScale = ContentScale.Crop, contentDescription = null, ) - } else if (exportImageCanvas.imageBitmap != null) { - Image( - bitmap = exportImageCanvas.imageBitmap.asImageBitmap(), + } else if (exportImageCanvas.imageUri != null) { + AsyncImage( + model = exportImageCanvas.imageUri, modifier = Modifier .fillMaxSize(), contentScale = ContentScale.Crop, @@ -208,12 +209,12 @@ private fun Modifier.safeAnimateBounds(): Modifier { @Preview @Composable private fun ImageRendererPreviewSquare() { - val bitmap = ImageBitmap.imageResource(R.drawable.placeholderbot) + val imageUri = ("android.resource://com.android.developers.androidify.results/" + R.drawable.placeholderbot).toUri() AndroidifyTheme { ImageResult( ExportImageCanvas( - imageBitmap = bitmap.asAndroidBitmap(), + imageUri = imageUri, canvasSize = Size(1000f, 1000f), aspectRatioOption = SizeOption.Square, selectedBackgroundOption = BackgroundOption.IO, @@ -231,11 +232,11 @@ private fun ImageRendererPreviewSquare() { @Preview @Composable private fun ImageRendererPreviewBanner() { - val bitmap = ImageBitmap.imageResource(R.drawable.placeholderbot) + val imageUri = ("android.resource://com.android.developers.androidify.results/" + R.drawable.placeholderbot).toUri() AndroidifyTheme { ImageResult( ExportImageCanvas( - imageBitmap = bitmap.asAndroidBitmap(), + imageUri = imageUri, canvasSize = Size(1000f, 1000f), aspectRatioOption = SizeOption.Banner, selectedBackgroundOption = BackgroundOption.Lightspeed, @@ -253,11 +254,11 @@ private fun ImageRendererPreviewBanner() { @Preview @Composable private fun ImageRendererPreviewWallpaper() { - val bitmap = ImageBitmap.imageResource(R.drawable.placeholderbot) + val imageUri = ("android.resource://com.android.developers.androidify.results/" + R.drawable.placeholderbot).toUri() AndroidifyTheme { ImageResult( ExportImageCanvas( - imageBitmap = bitmap.asAndroidBitmap(), + imageUri = imageUri, canvasSize = Size(1000f, 1000f), aspectRatioOption = SizeOption.Wallpaper, selectedBackgroundOption = BackgroundOption.Lightspeed, @@ -275,11 +276,11 @@ private fun ImageRendererPreviewWallpaper() { @Preview(widthDp = 1280, heightDp = 800) @Composable private fun ImageRendererPreviewWallpaperTablet() { - val bitmap = ImageBitmap.imageResource(R.drawable.placeholderbot) + val imageUri = ("android.resource://com.android.developers.androidify.results/" + R.drawable.placeholderbot).toUri() AndroidifyTheme { ImageResult( ExportImageCanvas( - imageBitmap = bitmap.asAndroidBitmap(), + imageUri = imageUri, canvasSize = Size(1280f, 800f), aspectRatioOption = SizeOption.WallpaperTablet, selectedBackgroundOption = BackgroundOption.Lightspeed, @@ -297,11 +298,11 @@ private fun ImageRendererPreviewWallpaperTablet() { @Preview @Composable private fun ImageRendererPreviewWallpaperSocial() { - val bitmap = ImageBitmap.imageResource(R.drawable.placeholderbot) + val imageUri = ("android.resource://com.android.developers.androidify.results/" + R.drawable.placeholderbot).toUri() AndroidifyTheme { ImageResult( ExportImageCanvas( - imageBitmap = bitmap.asAndroidBitmap(), + imageUri = imageUri, canvasSize = Size(1600f, 900f), aspectRatioOption = SizeOption.SocialHeader, selectedBackgroundOption = BackgroundOption.Lightspeed, @@ -319,11 +320,11 @@ private fun ImageRendererPreviewWallpaperSocial() { @Preview @Composable fun ImageRendererPreviewWallpaperIO() { - val bitmap = ImageBitmap.imageResource(R.drawable.placeholderbot) + val imageUri = ("android.resource://com.android.developers.androidify.results/" + R.drawable.placeholderbot).toUri() AndroidifyTheme { ImageResult( ExportImageCanvas( - imageBitmap = bitmap.asAndroidBitmap(), + imageUri = imageUri, canvasSize = Size(1600f, 900f), aspectRatioOption = SizeOption.SocialHeader, selectedBackgroundOption = BackgroundOption.IO, diff --git a/feature/results/src/main/java/com/android/developers/androidify/results/BotResultCard.kt b/feature/results/src/main/java/com/android/developers/androidify/results/BotResultCard.kt index 0934b485..4f78fd3d 100644 --- a/feature/results/src/main/java/com/android/developers/androidify/results/BotResultCard.kt +++ b/feature/results/src/main/java/com/android/developers/androidify/results/BotResultCard.kt @@ -50,7 +50,7 @@ import coil3.compose.AsyncImage @Composable fun BotResultCard( - resultImage: Bitmap, + resultImageUri: Uri, originalImageUrl: Uri?, promptText: String?, flippableState: FlippableState, @@ -66,11 +66,11 @@ fun BotResultCard( flippableState = flippableState, onFlipStateChanged = onFlipStateChanged, front = { - FrontCard(resultImage) + ImageCard(resultImageUri, isBack = false) }, back = { if (originalImageUrl != null) { - BackCard(originalImageUrl) + ImageCard(originalImageUrl, isBack = true) } else { BackCardPrompt(promptText!!) } @@ -79,24 +79,10 @@ fun BotResultCard( } @Composable -private fun FrontCard(bitmap: Bitmap) { - AsyncImage( - model = bitmap, - contentDescription = stringResource(R.string.resultant_android_bot), - contentScale = ContentScale.Crop, - modifier = Modifier - .fillMaxSize() - .aspectRatio(BOT_ASPECT_RATIO) - .shadow(8.dp, shape = MaterialTheme.shapes.large) - .clip(MaterialTheme.shapes.large), - ) -} - -@Composable -private fun BackCard(originalImageUrl: Uri) { +private fun ImageCard(originalImageUrl: Uri, isBack: Boolean) { AsyncImage( model = originalImageUrl, - contentDescription = stringResource(R.string.original_image), + contentDescription = if (isBack)stringResource(R.string.original_image) else stringResource(R.string.resultant_android_bot), contentScale = ContentScale.Crop, modifier = Modifier .fillMaxSize() diff --git a/feature/results/src/main/java/com/android/developers/androidify/results/ResultsScreen.kt b/feature/results/src/main/java/com/android/developers/androidify/results/ResultsScreen.kt index a6c2036c..fd9df9ca 100644 --- a/feature/results/src/main/java/com/android/developers/androidify/results/ResultsScreen.kt +++ b/feature/results/src/main/java/com/android/developers/androidify/results/ResultsScreen.kt @@ -17,7 +17,6 @@ package com.android.developers.androidify.results -import android.graphics.Bitmap import android.net.Uri import androidx.compose.animation.AnimatedVisibility import androidx.compose.animation.core.EaseOutBack @@ -75,22 +74,23 @@ import com.android.developers.androidify.util.SmallPhonePreview import com.android.developers.androidify.util.allowsFullContent import com.android.developers.androidify.util.isAtLeastMedium import com.google.accompanist.permissions.ExperimentalPermissionsApi +import androidx.core.net.toUri @Composable fun ResultsScreen( - resultImage: Bitmap, + resultImageUri: Uri, originalImageUri: Uri?, promptText: String?, modifier: Modifier = Modifier, verboseLayout: Boolean = allowsFullContent(), onBackPress: () -> Unit, onAboutPress: () -> Unit, - onNextPress: () -> Unit, - viewModel: ResultsViewModel = hiltViewModel(), + onNextPress: (resultImageUri:Uri, originalImageUri:Uri?) -> Unit, + viewModel: ResultsViewModel = hiltViewModel(), ) { val state = viewModel.state.collectAsStateWithLifecycle() - LaunchedEffect(resultImage, originalImageUri, promptText) { - viewModel.setArguments(resultImage, originalImageUri, promptText) + LaunchedEffect(resultImageUri, originalImageUri, promptText) { + viewModel.setArguments(resultImageUri, originalImageUri, promptText) } val snackbarHostState by viewModel.snackbarHostState.collectAsStateWithLifecycle() Scaffold( @@ -121,7 +121,12 @@ fun ResultsScreen( contentPadding, state, verboseLayout = verboseLayout, - onCustomizeShareClicked = onNextPress, + onCustomizeShareClicked = { + onNextPress( + resultImageUri, + originalImageUri, + ) + }, ) } } @@ -132,11 +137,11 @@ fun ResultsScreen( @Composable private fun ResultsScreenPreview() { AndroidifyTheme { - val bitmap = ImageBitmap.imageResource(R.drawable.placeholderbot) + val imageUri = ("android.resource://com.android.developers.androidify.results/" + R.drawable.placeholderbot).toUri() val state = remember { mutableStateOf( ResultState( - resultImageBitmap = bitmap.asAndroidBitmap(), + resultImageUri = imageUri, promptText = "wearing a hat with straw hair", ), ) @@ -154,11 +159,11 @@ private fun ResultsScreenPreview() { @Composable private fun ResultsScreenPreviewSmall() { AndroidifyTheme { - val bitmap = ImageBitmap.imageResource(R.drawable.placeholderbot) + val imageUri = ("android.resource://com.android.developers.androidify.results/" + R.drawable.placeholderbot).toUri() val state = remember { mutableStateOf( ResultState( - resultImageBitmap = bitmap.asAndroidBitmap(), + resultImageUri = imageUri, promptText = "wearing a hat with straw hair", ), ) @@ -182,7 +187,7 @@ fun ResultsScreenContents( defaultSelectedResult: ResultOption = ResultOption.ResultImage, ) { ResultsBackground() - val showResult = state.value.resultImageBitmap != null + val showResult = state.value.resultImageUri != null var selectedResultOption by remember { mutableStateOf(defaultSelectedResult) } @@ -210,7 +215,7 @@ fun ResultsScreenContents( .fillMaxSize(), ) { BotResultCard( - state.value.resultImageBitmap!!, + state.value.resultImageUri!!, state.value.originalImageUrl, state.value.promptText, modifier = Modifier.align(Alignment.Center), diff --git a/feature/results/src/main/java/com/android/developers/androidify/results/ResultsViewModel.kt b/feature/results/src/main/java/com/android/developers/androidify/results/ResultsViewModel.kt index a54af8ea..70c6ca5b 100644 --- a/feature/results/src/main/java/com/android/developers/androidify/results/ResultsViewModel.kt +++ b/feature/results/src/main/java/com/android/developers/androidify/results/ResultsViewModel.kt @@ -38,7 +38,7 @@ class ResultsViewModel @Inject constructor() : ViewModel() { get() = _snackbarHostState fun setArguments( - resultImageUrl: Bitmap, + resultImageUrl: Uri?, originalImageUrl: Uri?, promptText: String?, ) { @@ -49,7 +49,7 @@ class ResultsViewModel @Inject constructor() : ViewModel() { } data class ResultState( - val resultImageBitmap: Bitmap? = null, + val resultImageUri: Uri? = null, val originalImageUrl: Uri? = null, val promptText: String? = null, ) From 7bb6ccd7a4daec243177760354de6aadbb337d92 Mon Sep 17 00:00:00 2001 From: srikrishnasakunia Date: Mon, 11 Aug 2025 18:45:30 +0530 Subject: [PATCH 2/5] Refactor image saving and loading in Creation and Customize features This commit refactors image handling in the `CreationViewModel` and `CustomizeExportViewModel`. **CreationViewModel:** - The `saveBitmapToCache` function has been removed. - Image saving is now delegated to `imageGenerationRepository.saveImage(bitmap)`. **CustomizeExportViewModel:** - Images are now loaded from a URI using a new `convertUriToBitmap(uri)` suspend function. This function uses the `application.contentResolver` to open an input stream from the URI and decodes it into a Bitmap. - The `export()` function was updated to use `convertUriToBitmap` to get the image for export instead of directly accessing `state.value.exportImageCanvas.imageBitmap`. --- .../androidify/creation/CreationViewModel.kt | 28 +------------------ .../customize/CustomizeExportViewModel.kt | 23 ++++++++++++++- 2 files changed, 23 insertions(+), 28 deletions(-) diff --git a/feature/creation/src/main/java/com/android/developers/androidify/creation/CreationViewModel.kt b/feature/creation/src/main/java/com/android/developers/androidify/creation/CreationViewModel.kt index 45e0f3a2..499881cb 100644 --- a/feature/creation/src/main/java/com/android/developers/androidify/creation/CreationViewModel.kt +++ b/feature/creation/src/main/java/com/android/developers/androidify/creation/CreationViewModel.kt @@ -16,7 +16,6 @@ package com.android.developers.androidify.creation import android.content.Context -import android.graphics.Bitmap import android.net.Uri import android.util.Log import androidx.compose.foundation.text.input.TextFieldState @@ -36,15 +35,11 @@ import com.android.developers.androidify.data.TextGenerationRepository import com.android.developers.androidify.util.LocalFileProvider import dagger.hilt.android.lifecycle.HiltViewModel import dagger.hilt.android.qualifiers.ApplicationContext -import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Job import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.update import kotlinx.coroutines.launch -import kotlinx.coroutines.withContext -import java.io.File -import java.io.FileOutputStream import javax.inject.Inject @HiltViewModel @@ -157,7 +152,7 @@ class CreationViewModel @Inject constructor( ) } _uiState.update { - it.copy(resultBitmapUri = saveBitmapToCache(context, bitmap), screenState = ScreenState.RESULT) + it.copy(resultBitmapUri = imageGenerationRepository.saveImage(bitmap), screenState = ScreenState.RESULT) } } catch (e: Exception) { handleImageGenerationError(e) @@ -291,25 +286,4 @@ private fun getBotColors(): List { enum class PromptType(val displayName: String) { PHOTO("Photo"), TEXT("Prompt"), -} - -suspend fun saveBitmapToCache( - context: Context, - bitmap: Bitmap, - compressionFormat: Bitmap.CompressFormat = Bitmap.CompressFormat.JPEG, - quality: Int = 100 -): Uri? = withContext(Dispatchers.IO) { - - val cacheDir = context.cacheDir - val fileName = File(cacheDir, "temp_image_${System.currentTimeMillis()}.jpg") - try { - FileOutputStream(fileName).use { outputStream -> - bitmap.compress(compressionFormat, quality, outputStream) - } - Uri.fromFile(fileName) - } catch (e: Exception) { - e.printStackTrace() - null - } - } \ No newline at end of file diff --git a/feature/results/src/main/java/com/android/developers/androidify/customize/CustomizeExportViewModel.kt b/feature/results/src/main/java/com/android/developers/androidify/customize/CustomizeExportViewModel.kt index 28d73b1e..e7a8651a 100644 --- a/feature/results/src/main/java/com/android/developers/androidify/customize/CustomizeExportViewModel.kt +++ b/feature/results/src/main/java/com/android/developers/androidify/customize/CustomizeExportViewModel.kt @@ -17,12 +17,14 @@ package com.android.developers.androidify.customize import android.app.Application import android.graphics.Bitmap +import android.graphics.BitmapFactory import android.net.Uri import android.util.Log import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.material3.SnackbarHostState import androidx.compose.ui.Modifier import androidx.lifecycle.AndroidViewModel +import androidx.lifecycle.application import androidx.lifecycle.viewModelScope import com.android.developers.androidify.data.ImageGenerationRepository import dagger.hilt.android.lifecycle.HiltViewModel @@ -32,6 +34,7 @@ import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.update import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext import javax.inject.Inject @HiltViewModel @@ -198,7 +201,7 @@ class CustomizeExportViewModel @Inject constructor( return@launch } - val image = state.value.exportImageCanvas.imageBitmap + val image = state.value.exportImageCanvas.imageUri?.let { uri -> convertUriToBitmap(uri) } if (image == null) { return@launch } @@ -254,4 +257,22 @@ class CustomizeExportViewModel @Inject constructor( it.copy(selectedTool = tool) } } + + suspend fun convertUriToBitmap(uri: Uri): Bitmap? { + return withContext(ioDispatcher()) { + try { + val inputStream = application.contentResolver.openInputStream(uri) + if (inputStream != null) { + val bitmap = BitmapFactory.decodeStream(inputStream) + inputStream.close() + bitmap + } else { + null + } + } catch (e: Exception) { + e.printStackTrace() + null + } + } + } } From 048c5021d7f0dd53c54ade22856c82d8333af48d Mon Sep 17 00:00:00 2001 From: srikrishnasakunia Date: Sun, 17 Aug 2025 23:57:43 +0530 Subject: [PATCH 3/5] Fixed the PR Comments --- .../androidify/navigation/MainNavigation.kt | 67 +++++++++++++------ .../androidify/navigation/NavigationRoutes.kt | 30 ++++++++- .../androidify/navigation/UriSerializer.kt | 21 ++++++ .../androidify/util/LocalFileProvider.kt | 18 +++++ .../androidify/creation/CreationScreen.kt | 44 ++++++------ .../androidify/creation/CreationViewModel.kt | 44 +++++++----- .../androidify/results/ResultsScreenTest.kt | 21 +++--- .../customize/CustomizeExportScreen.kt | 12 ++-- .../customize/CustomizeExportViewModel.kt | 53 ++++++++------- .../androidify/customize/ImageRenderer.kt | 14 ++-- .../androidify/results/BotResultCard.kt | 22 ++++-- .../androidify/results/ResultsScreen.kt | 33 +++++---- .../androidify/results/ResultsViewModel.kt | 25 +++++-- .../results/ResultsScreenScreenshotTest.kt | 7 +- 14 files changed, 269 insertions(+), 142 deletions(-) create mode 100644 app/src/main/java/com/android/developers/androidify/navigation/UriSerializer.kt diff --git a/app/src/main/java/com/android/developers/androidify/navigation/MainNavigation.kt b/app/src/main/java/com/android/developers/androidify/navigation/MainNavigation.kt index 2d302a21..bc076d5c 100644 --- a/app/src/main/java/com/android/developers/androidify/navigation/MainNavigation.kt +++ b/app/src/main/java/com/android/developers/androidify/navigation/MainNavigation.kt @@ -32,7 +32,7 @@ import androidx.compose.runtime.remember import androidx.compose.runtime.setValue import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.unit.IntOffset -import androidx.core.net.toUri +import androidx.hilt.navigation.compose.hiltViewModel import androidx.lifecycle.viewmodel.navigation3.rememberViewModelStoreNavEntryDecorator import androidx.navigation3.runtime.entry import androidx.navigation3.runtime.entryProvider @@ -40,10 +40,13 @@ import androidx.navigation3.runtime.rememberSavedStateNavEntryDecorator import androidx.navigation3.ui.NavDisplay import com.android.developers.androidify.camera.CameraPreviewScreen import com.android.developers.androidify.creation.CreationScreen +import com.android.developers.androidify.creation.CreationViewModel import com.android.developers.androidify.customize.CustomizeAndExportScreen +import com.android.developers.androidify.customize.CustomizeExportViewModel import com.android.developers.androidify.home.AboutScreen import com.android.developers.androidify.home.HomeScreen import com.android.developers.androidify.results.ResultsScreen +import com.android.developers.androidify.results.ResultsViewModel import com.android.developers.androidify.theme.transitions.ColorSplashTransitionScreen import com.google.android.gms.oss.licenses.OssLicensesMenuActivity @@ -95,14 +98,20 @@ fun MainNavigation() { CameraPreviewScreen( onImageCaptured = { uri -> backStack.removeAll { it is Create } - backStack.add(Create(uri.toString())) + backStack.add(Create(uri)) backStack.removeAll { it is Camera } }, ) } entry { createKey -> + val creationViewModel = hiltViewModel( + creationCallback = { factory -> + factory.create( + originalImageUrl = createKey.fileName + ) + } + ) CreationScreen( - createKey.fileName, onCameraPressed = { backStack.removeAll { it is Camera } backStack.add(Camera) @@ -114,50 +123,64 @@ fun MainNavigation() { backStack.add(About) }, onImageCreated = { resultImageUri, prompt, originalImageUri -> - backStack.removeAll{ it is ImageResult} + backStack.removeAll{ it is Result} backStack.add( - ImageResult( - result = resultImageUri.toString(), + Result( + resultImageUri = resultImageUri, prompt = prompt, - originalImageUri = originalImageUri?.toString() + originalImageUri = originalImageUri ) ) - } + }, + creationViewModel = creationViewModel ) } - entry { resultKey -> + entry { resultKey -> + val resultsViewModel = hiltViewModel( + creationCallback = { factory -> + factory.create( + resultImageUrl = resultKey.resultImageUri, + originalImageUrl = resultKey.originalImageUri, + promptText = resultKey.prompt + ) + } + ) ResultsScreen( - resultImageUri = resultKey.result.toUri(), - originalImageUri = resultKey.originalImageUri?.toUri(), onNextPress = { resultImageUri, originalImageUri -> - backStack.removeAll{ it is ImageResult} + backStack.removeAll{ it is Result} backStack.add( - ShareResult( - resultUri = resultImageUri.toString(), - originalImageUri = originalImageUri?.toString() + CustomizeExport( + resultImageUri = resultImageUri, + originalImageUri = originalImageUri ) ) }, - promptText = resultKey.prompt, onAboutPress = { backStack.add(About) }, onBackPress = { backStack.removeLastOrNull() - backStack.add(Create(fileName = resultKey.originalImageUri, prompt = resultKey.prompt)) - } + }, + viewModel = resultsViewModel ) } - entry { shareKey -> + entry { shareKey -> + val customizeExportViewModel = hiltViewModel( + creationCallback = { factory -> + factory.create( + resultImageUrl = shareKey.resultImageUri, + originalImageUrl = shareKey.originalImageUri + ) + } + ) CustomizeAndExportScreen( - resultImageUri = shareKey.resultUri.toUri(), - originalImageUri = shareKey.originalImageUri?.toUri(), onBackPress = { backStack.removeLastOrNull() }, onInfoPress = { backStack.add(About) - } + }, + viewModel = customizeExportViewModel ) } entry { diff --git a/app/src/main/java/com/android/developers/androidify/navigation/NavigationRoutes.kt b/app/src/main/java/com/android/developers/androidify/navigation/NavigationRoutes.kt index 46f5f943..50930b0c 100644 --- a/app/src/main/java/com/android/developers/androidify/navigation/NavigationRoutes.kt +++ b/app/src/main/java/com/android/developers/androidify/navigation/NavigationRoutes.kt @@ -17,6 +17,7 @@ package com.android.developers.androidify.navigation +import android.net.Uri import kotlinx.serialization.ExperimentalSerializationApi import kotlinx.serialization.Serializable @@ -26,7 +27,10 @@ sealed interface NavigationRoute data object Home : NavigationRoute @Serializable -data class Create(val fileName: String? = null, val prompt: String? = null) : NavigationRoute +data class Create( + @Serializable(with = UriSerializer::class) val fileName: Uri? = null, + val prompt: String? = null +) : NavigationRoute @Serializable object Camera : NavigationRoute @@ -34,8 +38,28 @@ object Camera : NavigationRoute @Serializable object About : NavigationRoute +/** + * Represents the result of an image generation process, used for navigation. + * + * @param resultImageUri The URI of the generated image. + * @param originalImageUri The URI of the original image used as a base for generation, if any. + * @param prompt The text prompt used to generate the image, if any. + */ @Serializable -data class ImageResult(val originalImageUri: String? = null, val prompt: String? = null, val result: String) : NavigationRoute +data class Result( + @Serializable(with = UriSerializer::class) val resultImageUri: Uri, + @Serializable(with = UriSerializer::class) val originalImageUri: Uri? = null, + val prompt: String? = null +) : NavigationRoute +/** + * Represents the navigation route to the screen for customizing and exporting a generated image. + * + * @param resultImageUri The URI of the generated image to be customized. + * @param originalImageUri The URI of the original image, passed along for context. + */ @Serializable -data class ShareResult(val resultUri: String, val originalImageUri: String?) : NavigationRoute \ No newline at end of file +data class CustomizeExport( + @Serializable(with = UriSerializer::class) val resultImageUri: Uri, + @Serializable(with = UriSerializer::class) val originalImageUri: Uri? +) : NavigationRoute \ No newline at end of file diff --git a/app/src/main/java/com/android/developers/androidify/navigation/UriSerializer.kt b/app/src/main/java/com/android/developers/androidify/navigation/UriSerializer.kt new file mode 100644 index 00000000..02733c09 --- /dev/null +++ b/app/src/main/java/com/android/developers/androidify/navigation/UriSerializer.kt @@ -0,0 +1,21 @@ +package com.android.developers.androidify.navigation + +import android.net.Uri +import kotlinx.serialization.KSerializer +import kotlinx.serialization.descriptors.PrimitiveKind +import kotlinx.serialization.descriptors.PrimitiveSerialDescriptor +import kotlinx.serialization.descriptors.SerialDescriptor +import kotlinx.serialization.encoding.Decoder +import kotlinx.serialization.encoding.Encoder +import androidx.core.net.toUri + +object UriSerializer: KSerializer { + override val descriptor: SerialDescriptor = + PrimitiveSerialDescriptor("Uri", PrimitiveKind.STRING) + + override fun serialize(encoder: Encoder, value: Uri) { + encoder.encodeString(value.toString()) + } + + override fun deserialize(decoder: Decoder): Uri = decoder.decodeString().toUri() +} \ No newline at end of file diff --git a/core/util/src/main/java/com/android/developers/androidify/util/LocalFileProvider.kt b/core/util/src/main/java/com/android/developers/androidify/util/LocalFileProvider.kt index 150fe769..4c2812d7 100644 --- a/core/util/src/main/java/com/android/developers/androidify/util/LocalFileProvider.kt +++ b/core/util/src/main/java/com/android/developers/androidify/util/LocalFileProvider.kt @@ -18,6 +18,7 @@ package com.android.developers.androidify.util import android.app.Application import android.content.ContentValues import android.graphics.Bitmap +import android.graphics.BitmapFactory import android.net.Uri import android.os.Build import android.os.Environment @@ -53,6 +54,9 @@ interface LocalFileProvider { @WorkerThread suspend fun saveUriToSharedStorage(inputUri: Uri, fileName: String, mimeType: String): Uri + + @WorkerThread + suspend fun loadBitmapFromUri(uri: Uri): Bitmap? } @Singleton @@ -120,6 +124,20 @@ class LocalFileProviderImpl @Inject constructor( return@withContext newUri } + override suspend fun loadBitmapFromUri(uri: Uri): Bitmap? { + return withContext(ioDispatcher) { + try { + application.contentResolver.openInputStream(uri)?.use { + return@withContext BitmapFactory.decodeStream(it) + } + null + } catch (e: Exception) { + e.printStackTrace() + null + } + } + } + @Throws(IOException::class) @WorkerThread private fun saveFileToUri(file: File, uri: Uri) { diff --git a/feature/creation/src/main/java/com/android/developers/androidify/creation/CreationScreen.kt b/feature/creation/src/main/java/com/android/developers/androidify/creation/CreationScreen.kt index 8192463d..097eedf7 100644 --- a/feature/creation/src/main/java/com/android/developers/androidify/creation/CreationScreen.kt +++ b/feature/creation/src/main/java/com/android/developers/androidify/creation/CreationScreen.kt @@ -92,6 +92,7 @@ import androidx.compose.material3.rememberModalBottomSheetState import androidx.compose.material3.ripple import androidx.compose.material3.toShape import androidx.compose.runtime.Composable +import androidx.compose.runtime.DisposableEffect import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf @@ -123,6 +124,11 @@ import androidx.core.net.toUri import androidx.graphics.shapes.RoundedPolygon import androidx.graphics.shapes.rectangle import androidx.hilt.navigation.compose.hiltViewModel +import androidx.lifecycle.Lifecycle +import androidx.lifecycle.LifecycleEventObserver +import androidx.lifecycle.LifecycleOwner +import androidx.lifecycle.compose.LifecycleResumeEffect +import androidx.lifecycle.compose.LocalLifecycleOwner import androidx.lifecycle.compose.collectAsStateWithLifecycle import coil3.compose.AsyncImage import coil3.request.ImageRequest @@ -158,8 +164,7 @@ import com.android.developers.androidify.creation.R as CreationR @Composable fun CreationScreen( - fileName: String? = null, - creationViewModel: CreationViewModel = hiltViewModel(), + creationViewModel: CreationViewModel, isMedium: Boolean = isAtLeastMedium(), onCameraPressed: () -> Unit = {}, onBackPressed: () -> Unit, @@ -172,19 +177,28 @@ fun CreationScreen( ) { creationViewModel.onBackPress() } - LaunchedEffect(Unit) { - if (fileName != null) { - creationViewModel.onImageSelected(fileName.toUri()) - } else { - creationViewModel.onImageSelected(null) - } - } val pickMedia = rememberLauncherForActivityResult(PickVisualMedia()) { uri -> if (uri != null) { creationViewModel.onImageSelected(uri) } } val snackbarHostState by creationViewModel.snackbarHostState.collectAsStateWithLifecycle() + + LaunchedEffect(uiState.resultBitmapUri) { + uiState.resultBitmapUri?.let { resultBitmapUri -> + onImageCreated( + resultBitmapUri, + uiState.descriptionText.text.toString(), + if (uiState.selectedPromptOption == PromptType.PHOTO) { + uiState.imageUri + } else { + null + } + ) + creationViewModel.onResultDisplayed() + } + } + when (uiState.screenState) { ScreenState.EDIT -> { EditScreen( @@ -212,18 +226,6 @@ fun CreationScreen( }, ) } - - ScreenState.RESULT -> { - onImageCreated( - uiState.resultBitmapUri!!, - uiState.descriptionText.text.toString(), - if (uiState.selectedPromptOption == PromptType.PHOTO) { - uiState.imageUri - } else { - null - } - ) - } } } diff --git a/feature/creation/src/main/java/com/android/developers/androidify/creation/CreationViewModel.kt b/feature/creation/src/main/java/com/android/developers/androidify/creation/CreationViewModel.kt index 499881cb..603e0ba9 100644 --- a/feature/creation/src/main/java/com/android/developers/androidify/creation/CreationViewModel.kt +++ b/feature/creation/src/main/java/com/android/developers/androidify/creation/CreationViewModel.kt @@ -33,6 +33,9 @@ import com.android.developers.androidify.data.InternetConnectivityManager import com.android.developers.androidify.data.NoInternetException import com.android.developers.androidify.data.TextGenerationRepository import com.android.developers.androidify.util.LocalFileProvider +import dagger.assisted.Assisted +import dagger.assisted.AssistedFactory +import dagger.assisted.AssistedInject import dagger.hilt.android.lifecycle.HiltViewModel import dagger.hilt.android.qualifiers.ApplicationContext import kotlinx.coroutines.Job @@ -42,8 +45,9 @@ import kotlinx.coroutines.flow.update import kotlinx.coroutines.launch import javax.inject.Inject -@HiltViewModel -class CreationViewModel @Inject constructor( +@HiltViewModel(assistedFactory = CreationViewModel.Factory::class) +class CreationViewModel @AssistedInject constructor( + @Assisted("originalImageUrl") originalImageUrl: Uri?, val internetConnectivityManager: InternetConnectivityManager, val imageGenerationRepository: ImageGenerationRepository, val textGenerationRepository: TextGenerationRepository, @@ -53,11 +57,9 @@ class CreationViewModel @Inject constructor( val context: Context, ) : ViewModel() { - init { - viewModelScope.launch { - imageGenerationRepository.initialize() - textGenerationRepository.initialize() - } + @AssistedFactory + interface Factory { + fun create(@Assisted("originalImageUrl") originalImageUrl: Uri?): CreationViewModel } private var _uiState = MutableStateFlow(CreationState()) @@ -73,6 +75,14 @@ class CreationViewModel @Inject constructor( private var promptGenerationJob: Job? = null private var imageGenerationJob: Job? = null + init { + onImageSelected(originalImageUrl) + viewModelScope.launch { + imageGenerationRepository.initialize() + textGenerationRepository.initialize() + } + } + fun onImageSelected(uri: Uri?) { _uiState.update { it.copy( @@ -152,7 +162,10 @@ class CreationViewModel @Inject constructor( ) } _uiState.update { - it.copy(resultBitmapUri = imageGenerationRepository.saveImage(bitmap), screenState = ScreenState.RESULT) + it.copy( + resultBitmapUri = imageGenerationRepository.saveImage(bitmap), + screenState = ScreenState.EDIT + ) } } catch (e: Exception) { handleImageGenerationError(e) @@ -217,17 +230,17 @@ class CreationViewModel @Inject constructor( cancelInProgressTask() } - ScreenState.RESULT -> { - _uiState.update { - it.copy(screenState = ScreenState.EDIT, resultBitmapUri = null) - } - } - ScreenState.EDIT -> { // do nothing, back press handled outside } } } + + fun onResultDisplayed() { + _uiState.update { + it.copy(resultBitmapUri = null) + } + } } data class CreationState( @@ -245,7 +258,6 @@ data class CreationState( enum class ScreenState { EDIT, LOADING, - RESULT, } data class BotColor( @@ -286,4 +298,4 @@ private fun getBotColors(): List { enum class PromptType(val displayName: String) { PHOTO("Photo"), TEXT("Prompt"), -} \ No newline at end of file +} diff --git a/feature/results/src/androidTest/java/com/android/developers/androidify/results/ResultsScreenTest.kt b/feature/results/src/androidTest/java/com/android/developers/androidify/results/ResultsScreenTest.kt index 5dba07c0..9f1f312a 100644 --- a/feature/results/src/androidTest/java/com/android/developers/androidify/results/ResultsScreenTest.kt +++ b/feature/results/src/androidTest/java/com/android/developers/androidify/results/ResultsScreenTest.kt @@ -41,15 +41,15 @@ class ResultsScreenTest { @get:Rule val composeTestRule = createAndroidComposeRule() - // Create a dummy bitmap for testing - val dummyUri = android.net.Uri.parse("dummy://image") + // Create a test bitmap for testing + val testUri = android.net.Uri.parse("placeholder://image") @Test fun resultsScreenContents_displaysActionButtons() { val shareButtonText = composeTestRule.activity.getString(R.string.customize_and_share) // Note: Download button is identified by icon, harder to test reliably without tags/desc - val initialState = ResultState(resultImageUri = dummyUri, promptText = "test") + val initialState = ResultState(resultImageUri = testUri, promptText = "test") val state = mutableStateOf(initialState) composeTestRule.setContent { @@ -78,7 +78,7 @@ class ResultsScreenTest { val frontCardDesc = composeTestRule.activity.getString(R.string.resultant_android_bot) // Ensure promptText is non-null when bitmap is present - val initialState = ResultState(resultImageUri = dummyUri, promptText = "test") + val initialState = ResultState(resultImageUri = testUri, promptText = "test") val state = mutableStateOf(initialState) composeTestRule.setContent { @@ -106,9 +106,9 @@ class ResultsScreenTest { val photoOptionText = composeTestRule.activity.getString(R.string.photo) val frontCardDesc = composeTestRule.activity.getString(R.string.resultant_android_bot) val backCardDesc = composeTestRule.activity.getString(R.string.original_image) - val dummyUri = android.net.Uri.parse("dummy://image") + val testUri = android.net.Uri.parse("placeholder://image") - val initialState = ResultState(resultImageUri = dummyUri, originalImageUrl = dummyUri) + val initialState = ResultState(resultImageUri = testUri, originalImageUrl = testUri) val state = mutableStateOf(initialState) composeTestRule.setContent { @@ -143,7 +143,7 @@ class ResultsScreenTest { val promptText = "test prompt" val promptPrefix = composeTestRule.activity.getString(R.string.my_bot_is_wearing) - val initialState = ResultState(resultImageUri = dummyUri, promptText = promptText) // No original image URI + val initialState = ResultState(resultImageUri = testUri, promptText = promptText) // No original image URI val state = mutableStateOf(initialState) composeTestRule.setContent { @@ -173,9 +173,10 @@ class ResultsScreenTest { val botOptionText = composeTestRule.activity.getString(R.string.bot) val photoOptionText = composeTestRule.activity.getString(R.string.photo) val frontCardDesc = composeTestRule.activity.getString(R.string.resultant_android_bot) - val dummyUri = android.net.Uri.parse("dummy://image") + val testUri = android.net.Uri.parse("placeholder://image") - val initialState = ResultState(resultImageUri = dummyUri, originalImageUrl = dummyUri) + + val initialState = ResultState(resultImageUri = testUri, originalImageUrl = testUri) val state = mutableStateOf(initialState) composeTestRule.setContent { @@ -210,7 +211,7 @@ class ResultsScreenTest { var shareClicked = false // Ensure promptText is non-null when bitmap is present - val initialState = ResultState(resultImageUri = dummyUri, promptText = "test") + val initialState = ResultState(resultImageUri = testUri, promptText = "test") val state = mutableStateOf(initialState) composeTestRule.setContent { diff --git a/feature/results/src/main/java/com/android/developers/androidify/customize/CustomizeExportScreen.kt b/feature/results/src/main/java/com/android/developers/androidify/customize/CustomizeExportScreen.kt index 1ba4ace7..9a8f5edc 100644 --- a/feature/results/src/main/java/com/android/developers/androidify/customize/CustomizeExportScreen.kt +++ b/feature/results/src/main/java/com/android/developers/androidify/customize/CustomizeExportScreen.kt @@ -93,20 +93,16 @@ import com.google.accompanist.permissions.isGranted import com.google.accompanist.permissions.rememberPermissionState import com.google.accompanist.permissions.shouldShowRationale import com.android.developers.androidify.theme.R as ThemeR +import android.content.ContentResolver @OptIn(ExperimentalMaterial3Api::class) @Composable fun CustomizeAndExportScreen( - resultImageUri: Uri, - originalImageUri: Uri?, onBackPress: () -> Unit, onInfoPress: () -> Unit, isMediumWindowSize: Boolean = isAtLeastMedium(), - viewModel: CustomizeExportViewModel = hiltViewModel(), + viewModel: CustomizeExportViewModel, ) { - LaunchedEffect(resultImageUri, originalImageUri) { - viewModel.setArguments(resultImageUri, originalImageUri) - } val state = viewModel.state.collectAsStateWithLifecycle() val context = LocalContext.current LaunchedEffect(state.value.savedUri) { @@ -430,7 +426,7 @@ fun CustomizeExportPreview() { AnimatedContent(true) { targetState -> targetState CompositionLocalProvider(LocalNavAnimatedContentScope provides this@AnimatedContent) { - val imageUri = ("android.resource://com.android.developers.androidify.results/" + R.drawable.placeholderbot).toUri() + val imageUri = ("${ContentResolver.SCHEME_ANDROID_RESOURCE}://${LocalContext.current.packageName}/${R.drawable.placeholderbot}").toUri() val state = CustomizeExportState( exportImageCanvas = ExportImageCanvas(imageUri = imageUri), ) @@ -457,7 +453,7 @@ fun CustomizeExportPreviewLarge() { AnimatedContent(true) { targetState -> targetState CompositionLocalProvider(LocalNavAnimatedContentScope provides this@AnimatedContent) { - val imageUri = ("android.resource://com.android.developers.androidify.results/" + R.drawable.placeholderbot).toUri() + val imageUri = ("${ContentResolver.SCHEME_ANDROID_RESOURCE}://${LocalContext.current.packageName}/${R.drawable.placeholderbot}").toUri() val state = CustomizeExportState( exportImageCanvas = ExportImageCanvas( imageUri = imageUri, diff --git a/feature/results/src/main/java/com/android/developers/androidify/customize/CustomizeExportViewModel.kt b/feature/results/src/main/java/com/android/developers/androidify/customize/CustomizeExportViewModel.kt index e7a8651a..2a37c671 100644 --- a/feature/results/src/main/java/com/android/developers/androidify/customize/CustomizeExportViewModel.kt +++ b/feature/results/src/main/java/com/android/developers/androidify/customize/CustomizeExportViewModel.kt @@ -16,17 +16,18 @@ package com.android.developers.androidify.customize import android.app.Application -import android.graphics.Bitmap -import android.graphics.BitmapFactory import android.net.Uri import android.util.Log import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.material3.SnackbarHostState import androidx.compose.ui.Modifier import androidx.lifecycle.AndroidViewModel -import androidx.lifecycle.application import androidx.lifecycle.viewModelScope import com.android.developers.androidify.data.ImageGenerationRepository +import com.android.developers.androidify.util.LocalFileProvider +import dagger.assisted.Assisted +import dagger.assisted.AssistedFactory +import dagger.assisted.AssistedInject import dagger.hilt.android.lifecycle.HiltViewModel import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.flow.MutableStateFlow @@ -34,16 +35,26 @@ import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.update import kotlinx.coroutines.launch -import kotlinx.coroutines.withContext import javax.inject.Inject -@HiltViewModel -class CustomizeExportViewModel @Inject constructor( +@HiltViewModel(assistedFactory = CustomizeExportViewModel.Factory::class) +class CustomizeExportViewModel @AssistedInject constructor( + @Assisted("resultImageUrl") val resultImageUrl: Uri, + @Assisted("originalImageUrl") val originalImageUrl: Uri?, val imageGenerationRepository: ImageGenerationRepository, val composableBitmapRenderer: ComposableBitmapRenderer, + val localFileProvider: LocalFileProvider, application: Application, ) : AndroidViewModel(application) { + @AssistedFactory + interface Factory{ + fun create( + @Assisted("resultImageUrl") resultImageUrl: Uri, + @Assisted("originalImageUrl")originalImageUrl: Uri? + ): CustomizeExportViewModel + } + private val _state = MutableStateFlow(CustomizeExportState()) val state = _state.asStateFlow() @@ -52,6 +63,16 @@ class CustomizeExportViewModel @Inject constructor( val snackbarHostState: StateFlow get() = _snackbarHostState + init { + _state.update { + CustomizeExportState( + originalImageUrl = originalImageUrl, + exportImageCanvas = it.exportImageCanvas.copy(imageUri = resultImageUrl), + ) + } + } + + override fun onCleared() { super.onCleared() } @@ -201,7 +222,7 @@ class CustomizeExportViewModel @Inject constructor( return@launch } - val image = state.value.exportImageCanvas.imageUri?.let { uri -> convertUriToBitmap(uri) } + val image = state.value.exportImageCanvas.imageUri?.let { uri -> localFileProvider.loadBitmapFromUri(uri) } if (image == null) { return@launch } @@ -257,22 +278,4 @@ class CustomizeExportViewModel @Inject constructor( it.copy(selectedTool = tool) } } - - suspend fun convertUriToBitmap(uri: Uri): Bitmap? { - return withContext(ioDispatcher()) { - try { - val inputStream = application.contentResolver.openInputStream(uri) - if (inputStream != null) { - val bitmap = BitmapFactory.decodeStream(inputStream) - inputStream.close() - bitmap - } else { - null - } - } catch (e: Exception) { - e.printStackTrace() - null - } - } - } } diff --git a/feature/results/src/main/java/com/android/developers/androidify/customize/ImageRenderer.kt b/feature/results/src/main/java/com/android/developers/androidify/customize/ImageRenderer.kt index 736fd090..a113d060 100644 --- a/feature/results/src/main/java/com/android/developers/androidify/customize/ImageRenderer.kt +++ b/feature/results/src/main/java/com/android/developers/androidify/customize/ImageRenderer.kt @@ -17,6 +17,7 @@ package com.android.developers.androidify.customize +import android.content.ContentResolver import androidx.compose.animation.ExperimentalSharedTransitionApi import androidx.compose.animation.animateBounds import androidx.compose.animation.core.animateFloatAsState @@ -41,6 +42,7 @@ import androidx.compose.ui.graphics.ImageBitmap import androidx.compose.ui.graphics.asImageBitmap import androidx.compose.ui.layout.ContentScale import androidx.compose.ui.layout.layout +import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.res.imageResource import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.util.fastRoundToInt @@ -209,7 +211,7 @@ private fun Modifier.safeAnimateBounds(): Modifier { @Preview @Composable private fun ImageRendererPreviewSquare() { - val imageUri = ("android.resource://com.android.developers.androidify.results/" + R.drawable.placeholderbot).toUri() + val imageUri = ("${ContentResolver.SCHEME_ANDROID_RESOURCE}://${LocalContext.current.packageName}/${R.drawable.placeholderbot}").toUri() AndroidifyTheme { ImageResult( @@ -232,7 +234,7 @@ private fun ImageRendererPreviewSquare() { @Preview @Composable private fun ImageRendererPreviewBanner() { - val imageUri = ("android.resource://com.android.developers.androidify.results/" + R.drawable.placeholderbot).toUri() + val imageUri = ("${ContentResolver.SCHEME_ANDROID_RESOURCE}://${LocalContext.current.packageName}/${R.drawable.placeholderbot}").toUri() AndroidifyTheme { ImageResult( ExportImageCanvas( @@ -254,7 +256,7 @@ private fun ImageRendererPreviewBanner() { @Preview @Composable private fun ImageRendererPreviewWallpaper() { - val imageUri = ("android.resource://com.android.developers.androidify.results/" + R.drawable.placeholderbot).toUri() + val imageUri = ("${ContentResolver.SCHEME_ANDROID_RESOURCE}://${LocalContext.current.packageName}/${R.drawable.placeholderbot}").toUri() AndroidifyTheme { ImageResult( ExportImageCanvas( @@ -276,7 +278,7 @@ private fun ImageRendererPreviewWallpaper() { @Preview(widthDp = 1280, heightDp = 800) @Composable private fun ImageRendererPreviewWallpaperTablet() { - val imageUri = ("android.resource://com.android.developers.androidify.results/" + R.drawable.placeholderbot).toUri() + val imageUri = ("${ContentResolver.SCHEME_ANDROID_RESOURCE}://${LocalContext.current.packageName}/${R.drawable.placeholderbot}").toUri() AndroidifyTheme { ImageResult( ExportImageCanvas( @@ -298,7 +300,7 @@ private fun ImageRendererPreviewWallpaperTablet() { @Preview @Composable private fun ImageRendererPreviewWallpaperSocial() { - val imageUri = ("android.resource://com.android.developers.androidify.results/" + R.drawable.placeholderbot).toUri() + val imageUri = ("${ContentResolver.SCHEME_ANDROID_RESOURCE}://${LocalContext.current.packageName}/${R.drawable.placeholderbot}").toUri() AndroidifyTheme { ImageResult( ExportImageCanvas( @@ -320,7 +322,7 @@ private fun ImageRendererPreviewWallpaperSocial() { @Preview @Composable fun ImageRendererPreviewWallpaperIO() { - val imageUri = ("android.resource://com.android.developers.androidify.results/" + R.drawable.placeholderbot).toUri() + val imageUri = ("${ContentResolver.SCHEME_ANDROID_RESOURCE}://${LocalContext.current.packageName}/${R.drawable.placeholderbot}").toUri() AndroidifyTheme { ImageResult( ExportImageCanvas( diff --git a/feature/results/src/main/java/com/android/developers/androidify/results/BotResultCard.kt b/feature/results/src/main/java/com/android/developers/androidify/results/BotResultCard.kt index 4f78fd3d..fd772d8d 100644 --- a/feature/results/src/main/java/com/android/developers/androidify/results/BotResultCard.kt +++ b/feature/results/src/main/java/com/android/developers/androidify/results/BotResultCard.kt @@ -66,11 +66,11 @@ fun BotResultCard( flippableState = flippableState, onFlipStateChanged = onFlipStateChanged, front = { - ImageCard(resultImageUri, isBack = false) + FrontCard(resultImageUri) }, back = { if (originalImageUrl != null) { - ImageCard(originalImageUrl, isBack = true) + BackCard(originalImageUrl) } else { BackCardPrompt(promptText!!) } @@ -79,10 +79,24 @@ fun BotResultCard( } @Composable -private fun ImageCard(originalImageUrl: Uri, isBack: Boolean) { +private fun FrontCard(resultImageUri: Uri) { + AsyncImage( + model = resultImageUri, + contentDescription = stringResource(R.string.resultant_android_bot), + contentScale = ContentScale.Crop, + modifier = Modifier + .fillMaxSize() + .aspectRatio(BOT_ASPECT_RATIO) + .shadow(8.dp, shape = MaterialTheme.shapes.large) + .clip(MaterialTheme.shapes.large), + ) +} + +@Composable +private fun BackCard(originalImageUrl: Uri) { AsyncImage( model = originalImageUrl, - contentDescription = if (isBack)stringResource(R.string.original_image) else stringResource(R.string.resultant_android_bot), + contentDescription = stringResource(R.string.original_image), contentScale = ContentScale.Crop, modifier = Modifier .fillMaxSize() diff --git a/feature/results/src/main/java/com/android/developers/androidify/results/ResultsScreen.kt b/feature/results/src/main/java/com/android/developers/androidify/results/ResultsScreen.kt index fd9df9ca..c8e51dab 100644 --- a/feature/results/src/main/java/com/android/developers/androidify/results/ResultsScreen.kt +++ b/feature/results/src/main/java/com/android/developers/androidify/results/ResultsScreen.kt @@ -17,6 +17,7 @@ package com.android.developers.androidify.results +import android.content.ContentResolver import android.net.Uri import androidx.compose.animation.AnimatedVisibility import androidx.compose.animation.core.EaseOutBack @@ -43,7 +44,6 @@ import androidx.compose.material3.SnackbarDefaults import androidx.compose.material3.SnackbarHost import androidx.compose.material3.Text import androidx.compose.runtime.Composable -import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.State import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf @@ -51,11 +51,9 @@ import androidx.compose.runtime.remember import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier -import androidx.compose.ui.graphics.ImageBitmap -import androidx.compose.ui.graphics.asAndroidBitmap import androidx.compose.ui.graphics.vector.ImageVector +import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalInspectionMode -import androidx.compose.ui.res.imageResource import androidx.compose.ui.res.stringArrayResource import androidx.compose.ui.res.stringResource import androidx.compose.ui.res.vectorResource @@ -63,7 +61,7 @@ import androidx.compose.ui.text.font.FontWeight.Companion.Bold import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp -import androidx.hilt.navigation.compose.hiltViewModel +import androidx.core.net.toUri import androidx.lifecycle.compose.collectAsStateWithLifecycle import com.android.developers.androidify.theme.AndroidifyTheme import com.android.developers.androidify.theme.components.AndroidifyTopAppBar @@ -74,24 +72,23 @@ import com.android.developers.androidify.util.SmallPhonePreview import com.android.developers.androidify.util.allowsFullContent import com.android.developers.androidify.util.isAtLeastMedium import com.google.accompanist.permissions.ExperimentalPermissionsApi -import androidx.core.net.toUri @Composable fun ResultsScreen( - resultImageUri: Uri, + /*resultImageUri: Uri, originalImageUri: Uri?, - promptText: String?, + promptText: String?,*/ modifier: Modifier = Modifier, verboseLayout: Boolean = allowsFullContent(), onBackPress: () -> Unit, onAboutPress: () -> Unit, onNextPress: (resultImageUri:Uri, originalImageUri:Uri?) -> Unit, - viewModel: ResultsViewModel = hiltViewModel(), + viewModel: ResultsViewModel, ) { val state = viewModel.state.collectAsStateWithLifecycle() - LaunchedEffect(resultImageUri, originalImageUri, promptText) { + /*LaunchedEffect(resultImageUri, originalImageUri, promptText) { viewModel.setArguments(resultImageUri, originalImageUri, promptText) - } + }*/ val snackbarHostState by viewModel.snackbarHostState.collectAsStateWithLifecycle() Scaffold( snackbarHost = { @@ -122,10 +119,12 @@ fun ResultsScreen( state, verboseLayout = verboseLayout, onCustomizeShareClicked = { - onNextPress( - resultImageUri, - originalImageUri, - ) + viewModel.state.value.resultImageUri?.let { + onNextPress( + it, + viewModel.state.value.originalImageUrl, + ) + } }, ) } @@ -137,7 +136,7 @@ fun ResultsScreen( @Composable private fun ResultsScreenPreview() { AndroidifyTheme { - val imageUri = ("android.resource://com.android.developers.androidify.results/" + R.drawable.placeholderbot).toUri() + val imageUri = ("${ContentResolver.SCHEME_ANDROID_RESOURCE}://${LocalContext.current.packageName}/${R.drawable.placeholderbot}").toUri() val state = remember { mutableStateOf( ResultState( @@ -159,7 +158,7 @@ private fun ResultsScreenPreview() { @Composable private fun ResultsScreenPreviewSmall() { AndroidifyTheme { - val imageUri = ("android.resource://com.android.developers.androidify.results/" + R.drawable.placeholderbot).toUri() + val imageUri = ("${ContentResolver.SCHEME_ANDROID_RESOURCE}://${LocalContext.current.packageName}/${R.drawable.placeholderbot}").toUri() val state = remember { mutableStateOf( ResultState( diff --git a/feature/results/src/main/java/com/android/developers/androidify/results/ResultsViewModel.kt b/feature/results/src/main/java/com/android/developers/androidify/results/ResultsViewModel.kt index 70c6ca5b..2b226d61 100644 --- a/feature/results/src/main/java/com/android/developers/androidify/results/ResultsViewModel.kt +++ b/feature/results/src/main/java/com/android/developers/androidify/results/ResultsViewModel.kt @@ -19,6 +19,9 @@ import android.graphics.Bitmap import android.net.Uri import androidx.compose.material3.SnackbarHostState import androidx.lifecycle.ViewModel +import dagger.assisted.Assisted +import dagger.assisted.AssistedFactory +import dagger.assisted.AssistedInject import dagger.hilt.android.lifecycle.HiltViewModel import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow @@ -26,9 +29,21 @@ import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.update import javax.inject.Inject -@HiltViewModel -class ResultsViewModel @Inject constructor() : ViewModel() { +@HiltViewModel(assistedFactory = ResultsViewModel.Factory::class) +class ResultsViewModel @AssistedInject constructor( + @Assisted("resultImageUrl") val resultImageUrl: Uri?, + @Assisted("originalImageUrl") val originalImageUrl: Uri?, + @Assisted("promptText") val promptText: String? +) : ViewModel() { + @AssistedFactory + interface Factory { + fun create( + @Assisted("resultImageUrl") resultImageUrl: Uri?, + @Assisted("originalImageUrl") originalImageUrl: Uri?, + @Assisted("promptText") promptText: String?, + ): ResultsViewModel + } private val _state = MutableStateFlow(ResultState()) val state = _state.asStateFlow() @@ -37,11 +52,7 @@ class ResultsViewModel @Inject constructor() : ViewModel() { val snackbarHostState: StateFlow get() = _snackbarHostState - fun setArguments( - resultImageUrl: Uri?, - originalImageUrl: Uri?, - promptText: String?, - ) { + init{ _state.update { ResultState(resultImageUrl, originalImageUrl, promptText = promptText) } diff --git a/feature/results/src/screenshotTest/java/com/android/developers/androidify/results/ResultsScreenScreenshotTest.kt b/feature/results/src/screenshotTest/java/com/android/developers/androidify/results/ResultsScreenScreenshotTest.kt index f03cfbd3..ff33b851 100644 --- a/feature/results/src/screenshotTest/java/com/android/developers/androidify/results/ResultsScreenScreenshotTest.kt +++ b/feature/results/src/screenshotTest/java/com/android/developers/androidify/results/ResultsScreenScreenshotTest.kt @@ -32,6 +32,7 @@ import androidx.compose.ui.unit.dp import com.android.developers.androidify.theme.AndroidifyTheme import com.android.developers.androidify.util.AdaptivePreview import com.android.developers.androidify.util.SmallPhonePreview +import androidx.core.net.toUri class ResultsScreenScreenshotTest { @@ -43,7 +44,7 @@ class ResultsScreenScreenshotTest { val state = remember { mutableStateOf( ResultState( - resultImageBitmap = mockBitmap, + resultImageUri = "test://mockbitmap/${mockBitmap.hashCode()}".toUri(), promptText = "wearing a hat with straw hair", ), ) @@ -68,7 +69,7 @@ class ResultsScreenScreenshotTest { val state = remember { mutableStateOf( ResultState( - resultImageBitmap = mockBitmap, + resultImageUri = "test://mockbitmap/${mockBitmap.hashCode()}".toUri(), promptText = "wearing a hat with straw hair", ), ) @@ -92,7 +93,7 @@ class ResultsScreenScreenshotTest { val state = remember { mutableStateOf( ResultState( - resultImageBitmap = mockBitmap, + resultImageUri = "test://mockbitmap/${mockBitmap.hashCode()}".toUri(), promptText = "wearing a hat with straw hair", ), ) From 500f5dc1939d70d12d5c158c9a7eaeb15e69a472 Mon Sep 17 00:00:00 2001 From: srikrishnasakunia Date: Mon, 18 Aug 2025 12:24:08 +0530 Subject: [PATCH 4/5] Handling the process death scenario for Bitmap --- .../customize/CustomizeExportViewModel.kt | 24 +++++++++++++++---- .../androidify/customize/CustomizeState.kt | 1 + 2 files changed, 20 insertions(+), 5 deletions(-) diff --git a/feature/results/src/main/java/com/android/developers/androidify/customize/CustomizeExportViewModel.kt b/feature/results/src/main/java/com/android/developers/androidify/customize/CustomizeExportViewModel.kt index 2a37c671..5a44f0de 100644 --- a/feature/results/src/main/java/com/android/developers/androidify/customize/CustomizeExportViewModel.kt +++ b/feature/results/src/main/java/com/android/developers/androidify/customize/CustomizeExportViewModel.kt @@ -16,6 +16,7 @@ package com.android.developers.androidify.customize import android.app.Application +import android.graphics.Bitmap import android.net.Uri import android.util.Log import androidx.compose.foundation.layout.fillMaxSize @@ -29,13 +30,11 @@ import dagger.assisted.Assisted import dagger.assisted.AssistedFactory import dagger.assisted.AssistedInject import dagger.hilt.android.lifecycle.HiltViewModel -import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.update import kotlinx.coroutines.launch -import javax.inject.Inject @HiltViewModel(assistedFactory = CustomizeExportViewModel.Factory::class) class CustomizeExportViewModel @AssistedInject constructor( @@ -65,11 +64,12 @@ class CustomizeExportViewModel @AssistedInject constructor( init { _state.update { - CustomizeExportState( + it.copy( originalImageUrl = originalImageUrl, exportImageCanvas = it.exportImageCanvas.copy(imageUri = resultImageUrl), ) } + loadInitialBitmap(resultImageUrl) } @@ -221,8 +221,7 @@ class CustomizeExportViewModel @AssistedInject constructor( } return@launch } - - val image = state.value.exportImageCanvas.imageUri?.let { uri -> localFileProvider.loadBitmapFromUri(uri) } + val image = state.value.exportImageCanvas.imageBitmap if (image == null) { return@launch } @@ -278,4 +277,19 @@ class CustomizeExportViewModel @AssistedInject constructor( it.copy(selectedTool = tool) } } + + private fun loadInitialBitmap(uri: Uri) { + viewModelScope.launch { + try { + val bitmap = localFileProvider.loadBitmapFromUri(uri) + _state.update { + it.copy( + exportImageCanvas = it.exportImageCanvas.copy(imageBitmap = bitmap) + ) + } + } catch (e: Exception) { + _snackbarHostState.value.showSnackbar("Could not load image.") + } + } + } } diff --git a/feature/results/src/main/java/com/android/developers/androidify/customize/CustomizeState.kt b/feature/results/src/main/java/com/android/developers/androidify/customize/CustomizeState.kt index 796d722a..588c33e6 100644 --- a/feature/results/src/main/java/com/android/developers/androidify/customize/CustomizeState.kt +++ b/feature/results/src/main/java/com/android/developers/androidify/customize/CustomizeState.kt @@ -76,6 +76,7 @@ data class BackgroundToolState( data class ExportImageCanvas( val imageUri: Uri? = null, + val imageBitmap: Bitmap? = null, val imageBitmapRemovedBackground: Bitmap? = null, val aspectRatioOption: SizeOption = SizeOption.Square, val canvasSize: Size = Size(1000f, 1000f), From 50e83950fe3b28ed5971e939213d63e2051ac4b7 Mon Sep 17 00:00:00 2001 From: srikrishnasakunia Date: Mon, 18 Aug 2025 14:39:04 +0530 Subject: [PATCH 5/5] Test Cases handling --- .../testing/data/TestFileProvider.kt | 4 ++++ .../creation/CreationViewModelTest.kt | 6 +++-- .../customize/CustomizeViewModelTest.kt | 21 +++++++++++++----- .../results/ResultsViewModelTest.kt | 22 ++++++++----------- 4 files changed, 32 insertions(+), 21 deletions(-) diff --git a/core/testing/src/main/java/com/android/developers/testing/data/TestFileProvider.kt b/core/testing/src/main/java/com/android/developers/testing/data/TestFileProvider.kt index bc10d9e7..5a9ca1a7 100644 --- a/core/testing/src/main/java/com/android/developers/testing/data/TestFileProvider.kt +++ b/core/testing/src/main/java/com/android/developers/testing/data/TestFileProvider.kt @@ -64,4 +64,8 @@ class TestFileProvider : LocalFileProvider { ): Uri { TODO("Not yet implemented") } + + override suspend fun loadBitmapFromUri(uri: Uri): Bitmap? { + TODO("Not yet implemented") + } } diff --git a/feature/creation/src/test/kotlin/com/android/developers/androidify/creation/CreationViewModelTest.kt b/feature/creation/src/test/kotlin/com/android/developers/androidify/creation/CreationViewModelTest.kt index c9d760fd..28e6b316 100644 --- a/feature/creation/src/test/kotlin/com/android/developers/androidify/creation/CreationViewModelTest.kt +++ b/feature/creation/src/test/kotlin/com/android/developers/androidify/creation/CreationViewModelTest.kt @@ -53,7 +53,9 @@ class CreationViewModelTest { @Before fun setup() { + val fakeUri = Uri.parse("content://test/image.jpg") viewModel = CreationViewModel( + originalImageUrl = fakeUri, internetConnectivityManager, imageGenerationRepository, TestTextGenerationRepository(), @@ -116,7 +118,7 @@ class CreationViewModelTest { viewModel.onImageSelected(Uri.parse("content://test/image.jpg")) viewModel.onSelectedPromptOptionChanged(PromptType.PHOTO) viewModel.startClicked() - assertEquals(ScreenState.RESULT, viewModel.uiState.value.screenState) + assertEquals(ScreenState.EDIT, viewModel.uiState.value.screenState) assertNotNull(viewModel.uiState.value.resultBitmapUri) } @@ -177,7 +179,7 @@ class CreationViewModelTest { "testing input description" } viewModel.startClicked() - assertEquals(ScreenState.RESULT, viewModel.uiState.value.screenState) + assertEquals(ScreenState.EDIT, viewModel.uiState.value.screenState) assertNotNull(viewModel.uiState.value.resultBitmapUri) } diff --git a/feature/results/src/test/kotlin/com/android/developers/androidify/customize/CustomizeViewModelTest.kt b/feature/results/src/test/kotlin/com/android/developers/androidify/customize/CustomizeViewModelTest.kt index c08d4f77..c8853d72 100644 --- a/feature/results/src/test/kotlin/com/android/developers/androidify/customize/CustomizeViewModelTest.kt +++ b/feature/results/src/test/kotlin/com/android/developers/androidify/customize/CustomizeViewModelTest.kt @@ -20,6 +20,7 @@ package com.android.developers.androidify.customize import android.graphics.Bitmap import android.net.Uri import androidx.test.core.app.ApplicationProvider +import com.android.developers.testing.data.TestFileProvider import com.android.developers.testing.repository.FakeImageGenerationRepository import com.android.developers.testing.util.FakeComposableBitmapRenderer import com.android.developers.testing.util.MainDispatcherRule @@ -49,12 +50,17 @@ class CustomizeViewModelTest { private val fakeBitmap = Bitmap.createBitmap(1, 1, Bitmap.Config.ARGB_8888) private val originalFakeUri = Uri.parse("content://com.example.app/images/original.jpg") + val fakeUri = Uri.parse("content://test/image.jpg") + @Before fun setup() { viewModel = CustomizeExportViewModel( + fakeUri, + originalFakeUri, FakeImageGenerationRepository(), composableBitmapRenderer = FakeComposableBitmapRenderer(), application = ApplicationProvider.getApplicationContext(), + localFileProvider = TestFileProvider() ) } @@ -69,7 +75,7 @@ class CustomizeViewModelTest { @Test fun setArgumentsWithOriginalImage() = runTest { viewModel.setArguments( - fakeBitmap, + fakeUri, originalFakeUri, ) assertEquals( @@ -84,7 +90,7 @@ class CustomizeViewModelTest { @Test fun setArgumentsWithPrompt() = runTest { viewModel.setArguments( - fakeBitmap, + fakeUri, null, ) assertEquals( @@ -106,7 +112,7 @@ class CustomizeViewModelTest { } viewModel.setArguments( - fakeBitmap, + fakeUri, originalFakeUri, ) @@ -128,7 +134,7 @@ class CustomizeViewModelTest { } } viewModel.setArguments( - fakeBitmap, + fakeUri, originalFakeUri, ) advanceUntilIdle() @@ -141,9 +147,12 @@ class CustomizeViewModelTest { @Test fun changeBackground_NotNull() = runTest { val viewModel = CustomizeExportViewModel( + fakeUri, + originalFakeUri, FakeImageGenerationRepository(), composableBitmapRenderer = FakeComposableBitmapRenderer(), application = ApplicationProvider.getApplicationContext(), + localFileProvider = TestFileProvider() ) val values = mutableListOf() // Launch collector on the backgroundScope directly to use runTest's scheduler @@ -153,7 +162,7 @@ class CustomizeViewModelTest { } } viewModel.setArguments( - fakeBitmap, + fakeUri, originalFakeUri, ) advanceUntilIdle() @@ -183,7 +192,7 @@ class CustomizeViewModelTest { } } viewModel.setArguments( - fakeBitmap, + fakeUri, originalFakeUri, ) advanceUntilIdle() diff --git a/feature/results/src/test/kotlin/com/android/developers/androidify/results/ResultsViewModelTest.kt b/feature/results/src/test/kotlin/com/android/developers/androidify/results/ResultsViewModelTest.kt index 5f425658..e8828609 100644 --- a/feature/results/src/test/kotlin/com/android/developers/androidify/results/ResultsViewModelTest.kt +++ b/feature/results/src/test/kotlin/com/android/developers/androidify/results/ResultsViewModelTest.kt @@ -40,9 +40,15 @@ class ResultsViewModelTest { private val fakePromptText = "Pink Hair, plaid shirt, jeans" private val originalFakeUri = Uri.parse("content://com.example.app/images/original.jpg") + private val fakeUri = Uri.parse("content://test/image.jpg") + @Before fun setup() { - viewModel = ResultsViewModel() + viewModel = ResultsViewModel( + fakeUri, + originalFakeUri, + fakePromptText + ) } @Test @@ -55,14 +61,9 @@ class ResultsViewModelTest { @Test fun setArgumentsWithOriginalImage() = runTest { - viewModel.setArguments( - fakeBitmap, - originalFakeUri, - promptText = null, - ) assertEquals( ResultState( - resultImageBitmap = fakeBitmap, + resultImageUri = fakeUri, originalImageUrl = originalFakeUri, ), viewModel.state.value, @@ -71,14 +72,9 @@ class ResultsViewModelTest { @Test fun setArgumentsWithPrompt() = runTest { - viewModel.setArguments( - fakeBitmap, - null, - promptText = fakePromptText, - ) assertEquals( ResultState( - resultImageBitmap = fakeBitmap, + resultImageUri = fakeUri, originalImageUrl = null, promptText = fakePromptText, ),