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..2a8fbbc5 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.hilt.navigation.compose.hiltViewModel import androidx.lifecycle.viewmodel.navigation3.rememberViewModelStoreNavEntryDecorator import androidx.navigation3.runtime.entry import androidx.navigation3.runtime.entryProvider @@ -39,8 +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 @@ -92,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) @@ -110,6 +122,64 @@ fun MainNavigation() { onAboutPressed = { backStack.add(About) }, + onImageCreated = { resultImageUri, prompt, originalImageUri -> + backStack.removeAll { it is Result } + backStack.add( + Result( + resultImageUri = resultImageUri, + prompt = prompt, + originalImageUri = originalImageUri, + ), + ) + }, + creationViewModel = creationViewModel, + ) + } + entry { resultKey -> + val resultsViewModel = hiltViewModel( + creationCallback = { factory -> + factory.create( + resultImageUrl = resultKey.resultImageUri, + originalImageUrl = resultKey.originalImageUri, + promptText = resultKey.prompt, + ) + }, + ) + ResultsScreen( + onNextPress = { resultImageUri, originalImageUri -> + backStack.add( + CustomizeExport( + resultImageUri = resultImageUri, + originalImageUri = originalImageUri, + ), + ) + }, + onAboutPress = { + backStack.add(About) + }, + onBackPress = { + backStack.removeLastOrNull() + }, + viewModel = resultsViewModel, + ) + } + entry { shareKey -> + val customizeExportViewModel = hiltViewModel( + creationCallback = { factory -> + factory.create( + resultImageUrl = shareKey.resultImageUri, + originalImageUrl = shareKey.originalImageUri, + ) + }, + ) + CustomizeAndExportScreen( + 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 81c8338f..8322c46f 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,10 +27,39 @@ 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 @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 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 CustomizeExport( + @Serializable(with = UriSerializer::class) val resultImageUri: Uri, + @Serializable(with = UriSerializer::class) val originalImageUri: Uri?, +) : NavigationRoute 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..69950d33 --- /dev/null +++ b/app/src/main/java/com/android/developers/androidify/navigation/UriSerializer.kt @@ -0,0 +1,36 @@ +/* + * Copyright 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 + * + * https://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.android.developers.androidify.navigation + +import android.net.Uri +import androidx.core.net.toUri +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 + +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() +} diff --git a/core/testing/src/main/java/com/android/developers/testing/data/BitmapSample.kt b/core/testing/src/main/java/com/android/developers/testing/data/BitmapSample.kt new file mode 100644 index 00000000..ceec4d74 --- /dev/null +++ b/core/testing/src/main/java/com/android/developers/testing/data/BitmapSample.kt @@ -0,0 +1,20 @@ +/* + * Copyright 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 + * + * https://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.android.developers.testing.data + +import android.graphics.Bitmap + +val bitmapSample = Bitmap.createBitmap(1, 1, Bitmap.Config.ARGB_8888) 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..75761a89 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? { + return bitmapSample + } } 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 db0043e9..41fd50ff 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 @@ -120,18 +120,13 @@ import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp -import androidx.core.net.toUri import androidx.graphics.shapes.RoundedPolygon import androidx.graphics.shapes.rectangle -import androidx.hilt.navigation.compose.hiltViewModel import androidx.lifecycle.compose.collectAsStateWithLifecycle import coil3.compose.AsyncImage import coil3.request.ImageRequest import coil3.request.crossfade -import com.android.developers.androidify.customize.CustomizeAndExportScreen -import com.android.developers.androidify.customize.CustomizeExportViewModel import com.android.developers.androidify.data.DropBehaviourFactory -import com.android.developers.androidify.results.ResultsScreen import com.android.developers.androidify.theme.AndroidifyTheme import com.android.developers.androidify.theme.LimeGreen import com.android.developers.androidify.theme.LocalSharedTransitionScope @@ -160,12 +155,12 @@ 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, onAboutPressed: () -> Unit, + onImageCreated: (resultImageUri: Uri, prompt: String?, originalImageUri: Uri?) -> Unit, ) { val uiState by creationViewModel.uiState.collectAsStateWithLifecycle() BackHandler( @@ -173,19 +168,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( @@ -213,46 +217,6 @@ 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!!, - 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 04c0d90e..a18faa74 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 androidx.compose.foundation.text.input.TextFieldState import androidx.compose.material3.SnackbarHostState @@ -33,6 +32,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 @@ -43,8 +45,9 @@ import kotlinx.coroutines.launch import timber.log.Timber 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, @@ -54,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()) @@ -74,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( @@ -153,7 +162,10 @@ class CreationViewModel @Inject constructor( ) } _uiState.update { - it.copy(resultBitmap = bitmap, screenState = ScreenState.RESULT) + it.copy( + resultBitmapUri = imageGenerationRepository.saveImage(bitmap), + screenState = ScreenState.EDIT, + ) } } catch (e: Exception) { handleImageGenerationError(e) @@ -218,27 +230,15 @@ class CreationViewModel @Inject constructor( cancelInProgressTask() } - ScreenState.RESULT -> { - _uiState.update { - it.copy(screenState = ScreenState.EDIT, resultBitmap = null) - } - } - ScreenState.EDIT -> { // do nothing, back press handled outside } - - ScreenState.CUSTOMIZE -> { - _uiState.update { - it.copy(screenState = ScreenState.RESULT) - } - } } } - fun customizeExportClicked() { + fun onResultDisplayed() { _uiState.update { - it.copy(screenState = ScreenState.CUSTOMIZE) + it.copy(resultBitmapUri = null) } } } @@ -252,14 +252,12 @@ 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( 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 f8a4d478..5f2a4c7e 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 @@ -50,12 +50,12 @@ class CreationViewModelTest { private val internetConnectivityManager = TestInternetConnectivityManager(true) private val imageGenerationRepository = FakeImageGenerationRepository() - - private val fakeUri = Uri.parse("test.jpeg") + private val fakeUri = Uri.parse("content://test/image.jpg") @Before fun setup() { viewModel = CreationViewModel( + originalImageUrl = fakeUri, internetConnectivityManager, imageGenerationRepository, TestTextGenerationRepository(), @@ -79,6 +79,7 @@ class CreationViewModelTest { @Test fun stateInitialEdit_WithOutImage() = runTest { viewModel = CreationViewModel( + null, internetConnectivityManager, imageGenerationRepository, TestTextGenerationRepository(), @@ -91,7 +92,7 @@ class CreationViewModelTest { viewModel.uiState.value.screenState, ) assertEquals(false, viewModel.uiState.value.promptGenerationInProgress) - assertEquals(null, viewModel.uiState.value.imageUri) + assertEquals( null, viewModel.uiState.value.imageUri) } @Test @@ -136,8 +137,8 @@ class CreationViewModelTest { viewModel.onImageSelected(Uri.parse("content://test/image.jpg")) viewModel.onSelectedPromptOptionChanged(PromptType.PHOTO) viewModel.startClicked() - assertEquals(ScreenState.RESULT, viewModel.uiState.value.screenState) - assertNotNull(viewModel.uiState.value.resultBitmap) + assertEquals(ScreenState.EDIT, viewModel.uiState.value.screenState) + assertNotNull(viewModel.uiState.value.resultBitmapUri) } @Test @@ -197,8 +198,8 @@ class CreationViewModelTest { "testing input description" } viewModel.startClicked() - assertEquals(ScreenState.RESULT, viewModel.uiState.value.screenState) - assertNotNull(viewModel.uiState.value.resultBitmap) + assertEquals(ScreenState.EDIT, viewModel.uiState.value.screenState) + 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..f478f44b 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 @@ -15,7 +15,6 @@ */ package com.android.developers.androidify.results -import android.graphics.Bitmap import androidx.activity.ComponentActivity import androidx.compose.foundation.layout.PaddingValues import androidx.compose.runtime.CompositionLocalProvider @@ -41,15 +40,15 @@ class ResultsScreenTest { @get:Rule val composeTestRule = createAndroidComposeRule() - // Create a dummy bitmap for testing - private val testBitmap: Bitmap = Bitmap.createBitmap(100, 100, Bitmap.Config.ARGB_8888) + // 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(resultImageBitmap = testBitmap, promptText = "test") + val initialState = ResultState(resultImageUri = testUri, promptText = "test") val state = mutableStateOf(initialState) composeTestRule.setContent { @@ -78,7 +77,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 = testUri, promptText = "test") val state = mutableStateOf(initialState) composeTestRule.setContent { @@ -106,9 +105,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(resultImageBitmap = testBitmap, originalImageUrl = dummyUri) + val initialState = ResultState(resultImageUri = testUri, originalImageUrl = testUri) val state = mutableStateOf(initialState) composeTestRule.setContent { @@ -143,7 +142,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 = testUri, promptText = promptText) // No original image URI val state = mutableStateOf(initialState) composeTestRule.setContent { @@ -173,9 +172,9 @@ 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(resultImageBitmap = testBitmap, originalImageUrl = dummyUri) + val initialState = ResultState(resultImageUri = testUri, originalImageUrl = testUri) val state = mutableStateOf(initialState) composeTestRule.setContent { @@ -210,7 +209,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 = 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 3eaffa62..995022ee 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 @@ -18,9 +18,6 @@ package com.android.developers.androidify.customize import android.Manifest -import android.R.attr.visible -import android.graphics.Bitmap -import android.net.Uri import android.os.Build import androidx.compose.animation.AnimatedContent import androidx.compose.animation.ExperimentalSharedTransitionApi @@ -62,18 +59,14 @@ import androidx.compose.ui.Alignment 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 import androidx.compose.ui.platform.LocalContext -import androidx.compose.ui.res.imageResource 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.hilt.navigation.compose.hiltViewModel import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.navigation3.ui.LocalNavAnimatedContentScope import com.android.developers.androidify.results.PermissionRationaleDialog @@ -101,16 +94,11 @@ import com.android.developers.androidify.theme.R as ThemeR @OptIn(ExperimentalMaterial3Api::class) @Composable fun CustomizeAndExportScreen( - resultImage: Bitmap, - originalImageUri: Uri?, onBackPress: () -> Unit, onInfoPress: () -> Unit, isMediumWindowSize: Boolean = isAtLeastMedium(), - viewModel: CustomizeExportViewModel = hiltViewModel(), + viewModel: CustomizeExportViewModel, ) { - LaunchedEffect(resultImage, originalImageUri) { - viewModel.setArguments(resultImage, originalImageUri) - } val state = viewModel.state.collectAsStateWithLifecycle() val context = LocalContext.current @@ -497,9 +485,9 @@ fun CustomizeExportPreview() { displayName = "Pixel Watch 3", hasAndroidify = true, ) - val bitmap = ImageBitmap.imageResource(R.drawable.placeholderbot) + val bitmap = getPlaceholderBotBitmap() val state = CustomizeExportState( - exportImageCanvas = ExportImageCanvas(imageBitmap = bitmap.asAndroidBitmap()), + exportImageCanvas = ExportImageCanvas(imageBitmap = bitmap), connectedWatch = connectedWatch, ) CustomizeExportContents( @@ -529,7 +517,7 @@ fun CustomizeExportPreviewLarge() { AnimatedContent(true) { targetState -> targetState CompositionLocalProvider(LocalNavAnimatedContentScope provides this@AnimatedContent) { - val bitmap = ImageBitmap.imageResource(R.drawable.placeholderbot) + val bitmap = getPlaceholderBotBitmap() val connectedWatch = ConnectedWatch( nodeId = "1234", displayName = "Pixel Watch 3", @@ -537,7 +525,7 @@ fun CustomizeExportPreviewLarge() { ) val state = CustomizeExportState( exportImageCanvas = ExportImageCanvas( - imageBitmap = bitmap.asAndroidBitmap(), + imageBitmap = bitmap, 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 9fe834f2..c8444670 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 @@ -30,6 +30,9 @@ import com.android.developers.androidify.watchface.WatchFaceAsset import com.android.developers.androidify.watchface.transfer.WatchFaceInstallationRepository import com.android.developers.androidify.wear.common.WatchFaceInstallError import com.android.developers.androidify.wear.common.WatchFaceInstallationStatus +import dagger.assisted.Assisted +import dagger.assisted.AssistedFactory +import dagger.assisted.AssistedInject import dagger.hilt.android.lifecycle.HiltViewModel import kotlinx.coroutines.Job import kotlinx.coroutines.flow.MutableStateFlow @@ -40,11 +43,12 @@ import kotlinx.coroutines.flow.stateIn import kotlinx.coroutines.flow.update import kotlinx.coroutines.launch import timber.log.Timber -import javax.inject.Inject import kotlin.collections.isNotEmpty -@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 watchfaceInstallationRepository: WatchFaceInstallationRepository, @@ -53,6 +57,14 @@ class CustomizeExportViewModel @Inject constructor( 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: StateFlow = combine( _state, @@ -102,7 +114,8 @@ class CustomizeExportViewModel @Inject constructor( } _state.update { - CustomizeExportState( + it.copy( + originalImageUrl = originalImageUrl, toolState = mapOf( CustomizeTool.Size to AspectRatioToolState(), CustomizeTool.Background to BackgroundToolState( @@ -111,24 +124,13 @@ class CustomizeExportViewModel @Inject constructor( ), ) } + loadInitialBitmap(resultImageUrl) } override fun onCleared() { super.onCleared() } - fun setArguments( - resultImageUrl: Bitmap, - originalImageUrl: Uri?, - ) { - _state.update { - _state.value.copy( - originalImageUrl, - exportImageCanvas = it.exportImageCanvas.copy(imageBitmap = resultImageUrl), - ) - } - } - fun shareClicked() { viewModelScope.launch { val exportImageCanvas = state.value.exportImageCanvas @@ -261,7 +263,6 @@ class CustomizeExportViewModel @Inject constructor( } return@launch } - val image = state.value.exportImageCanvas.imageBitmap if (image == null) { return@launch @@ -396,4 +397,20 @@ class CustomizeExportViewModel @Inject constructor( watchfaceInstallationRepository.resetInstallationStatus() } } + + 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) { + Timber.e(e, "Could not load Bitmap from the URI due to ${e.message}") + _snackbarHostState.value.showSnackbar("Could not load image.") + } + } + } } 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 18cd1bef..761599f0 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,14 +38,12 @@ 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 com.android.developers.androidify.results.R import com.android.developers.androidify.theme.AndroidifyTheme import com.android.developers.androidify.theme.LocalAnimateBoundsScope @OptIn(ExperimentalMaterial3ExpressiveApi::class, ExperimentalSharedTransitionApi::class) @@ -209,12 +207,12 @@ private fun Modifier.safeAnimateBounds(): Modifier { @Preview @Composable private fun ImageRendererPreviewSquare() { - val bitmap = ImageBitmap.imageResource(R.drawable.placeholderbot) + val imageBitmap = getPlaceholderBotBitmap() AndroidifyTheme { ImageResult( ExportImageCanvas( - imageBitmap = bitmap.asAndroidBitmap(), + imageBitmap = imageBitmap, canvasSize = Size(1000f, 1000f), aspectRatioOption = SizeOption.Square, selectedBackgroundOption = BackgroundOption.IO, @@ -232,11 +230,11 @@ private fun ImageRendererPreviewSquare() { @Preview @Composable private fun ImageRendererPreviewBanner() { - val bitmap = ImageBitmap.imageResource(R.drawable.placeholderbot) + val imageBitmap = getPlaceholderBotBitmap() AndroidifyTheme { ImageResult( ExportImageCanvas( - imageBitmap = bitmap.asAndroidBitmap(), + imageBitmap = imageBitmap, canvasSize = Size(1000f, 1000f), aspectRatioOption = SizeOption.Banner, selectedBackgroundOption = BackgroundOption.Lightspeed, @@ -254,11 +252,11 @@ private fun ImageRendererPreviewBanner() { @Preview @Composable private fun ImageRendererPreviewWallpaper() { - val bitmap = ImageBitmap.imageResource(R.drawable.placeholderbot) + val imageBitmap = getPlaceholderBotBitmap() AndroidifyTheme { ImageResult( ExportImageCanvas( - imageBitmap = bitmap.asAndroidBitmap(), + imageBitmap = imageBitmap, canvasSize = Size(1000f, 1000f), aspectRatioOption = SizeOption.Wallpaper, selectedBackgroundOption = BackgroundOption.Lightspeed, @@ -276,11 +274,11 @@ private fun ImageRendererPreviewWallpaper() { @Preview(widthDp = 1280, heightDp = 800) @Composable private fun ImageRendererPreviewWallpaperTablet() { - val bitmap = ImageBitmap.imageResource(R.drawable.placeholderbot) + val imageBitmap = getPlaceholderBotBitmap() AndroidifyTheme { ImageResult( ExportImageCanvas( - imageBitmap = bitmap.asAndroidBitmap(), + imageBitmap = imageBitmap, canvasSize = Size(1280f, 800f), aspectRatioOption = SizeOption.WallpaperTablet, selectedBackgroundOption = BackgroundOption.Lightspeed, @@ -298,11 +296,11 @@ private fun ImageRendererPreviewWallpaperTablet() { @Preview @Composable private fun ImageRendererPreviewWallpaperSocial() { - val bitmap = ImageBitmap.imageResource(R.drawable.placeholderbot) + val imageBitmap = getPlaceholderBotBitmap() AndroidifyTheme { ImageResult( ExportImageCanvas( - imageBitmap = bitmap.asAndroidBitmap(), + imageBitmap = imageBitmap, canvasSize = Size(1600f, 900f), aspectRatioOption = SizeOption.SocialHeader, selectedBackgroundOption = BackgroundOption.Lightspeed, @@ -320,11 +318,11 @@ private fun ImageRendererPreviewWallpaperSocial() { @Preview @Composable fun ImageRendererPreviewWallpaperIO() { - val bitmap = ImageBitmap.imageResource(R.drawable.placeholderbot) + val imageBitmap = getPlaceholderBotBitmap() AndroidifyTheme { ImageResult( ExportImageCanvas( - imageBitmap = bitmap.asAndroidBitmap(), + imageBitmap = imageBitmap, canvasSize = Size(1600f, 900f), aspectRatioOption = SizeOption.SocialHeader, selectedBackgroundOption = BackgroundOption.IO, diff --git a/feature/results/src/main/java/com/android/developers/androidify/customize/Utils.kt b/feature/results/src/main/java/com/android/developers/androidify/customize/Utils.kt new file mode 100644 index 00000000..4e662541 --- /dev/null +++ b/feature/results/src/main/java/com/android/developers/androidify/customize/Utils.kt @@ -0,0 +1,35 @@ +/* + * Copyright 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 + * + * https://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.android.developers.androidify.customize + +import android.content.ContentResolver +import android.graphics.Bitmap +import android.net.Uri +import androidx.compose.runtime.Composable +import androidx.compose.ui.graphics.ImageBitmap +import androidx.compose.ui.graphics.asAndroidBitmap +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.res.imageResource +import androidx.core.net.toUri +import com.android.developers.androidify.results.R + +@Composable +fun getPlaceholderBotUri(): Uri = + ("${ContentResolver.SCHEME_ANDROID_RESOURCE}://${LocalContext.current.packageName}/${R.drawable.placeholderbot}").toUri() + +@Composable +fun getPlaceholderBotBitmap(): Bitmap = + ImageBitmap.imageResource(id = R.drawable.placeholderbot).asAndroidBitmap() \ No newline at end of file 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..3f4139be 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 @@ -15,7 +15,6 @@ */ package com.android.developers.androidify.results -import android.graphics.Bitmap import android.net.Uri import androidx.compose.foundation.background import androidx.compose.foundation.layout.Arrangement @@ -50,7 +49,7 @@ import coil3.compose.AsyncImage @Composable fun BotResultCard( - resultImage: Bitmap, + resultImageUri: Uri, originalImageUrl: Uri?, promptText: String?, flippableState: FlippableState, @@ -66,7 +65,7 @@ fun BotResultCard( flippableState = flippableState, onFlipStateChanged = onFlipStateChanged, front = { - FrontCard(resultImage) + FrontCard(resultImageUri) }, back = { if (originalImageUrl != null) { @@ -79,9 +78,9 @@ fun BotResultCard( } @Composable -private fun FrontCard(bitmap: Bitmap) { +private fun FrontCard(resultImageUri: Uri) { AsyncImage( - model = bitmap, + model = resultImageUri, contentDescription = stringResource(R.string.resultant_android_bot), contentScale = ContentScale.Crop, modifier = Modifier 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 96a685b6..5d377f9c 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 @@ -52,11 +51,8 @@ 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.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 @@ -64,8 +60,8 @@ 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.lifecycle.compose.collectAsStateWithLifecycle +import com.android.developers.androidify.customize.getPlaceholderBotUri import com.android.developers.androidify.theme.AndroidifyTheme import com.android.developers.androidify.theme.components.AboutButton import com.android.developers.androidify.theme.components.AndroidifyTopAppBar @@ -79,20 +75,14 @@ import com.google.accompanist.permissions.ExperimentalPermissionsApi @Composable fun ResultsScreen( - resultImage: Bitmap, - 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, ) { val state = viewModel.state.collectAsStateWithLifecycle() - LaunchedEffect(resultImage, originalImageUri, promptText) { - viewModel.setArguments(resultImage, originalImageUri, promptText) - } val snackbarHostState by viewModel.snackbarHostState.collectAsStateWithLifecycle() Scaffold( snackbarHost = { @@ -124,7 +114,14 @@ fun ResultsScreen( contentPadding, state, verboseLayout = verboseLayout, - onCustomizeShareClicked = onNextPress, + onCustomizeShareClicked = { + viewModel.state.value.resultImageUri?.let { + onNextPress( + it, + viewModel.state.value.originalImageUrl, + ) + } + }, ) } } @@ -135,11 +132,11 @@ fun ResultsScreen( @Composable private fun ResultsScreenPreview() { AndroidifyTheme { - val bitmap = ImageBitmap.imageResource(R.drawable.placeholderbot) + val imageUri = getPlaceholderBotUri() val state = remember { mutableStateOf( ResultState( - resultImageBitmap = bitmap.asAndroidBitmap(), + resultImageUri = imageUri, promptText = "wearing a hat with straw hair", ), ) @@ -157,11 +154,11 @@ private fun ResultsScreenPreview() { @Composable private fun ResultsScreenPreviewSmall() { AndroidifyTheme { - val bitmap = ImageBitmap.imageResource(R.drawable.placeholderbot) + val imageUri = getPlaceholderBotUri() val state = remember { mutableStateOf( ResultState( - resultImageBitmap = bitmap.asAndroidBitmap(), + resultImageUri = imageUri, promptText = "wearing a hat with straw hair", ), ) @@ -185,7 +182,10 @@ fun ResultsScreenContents( defaultSelectedResult: ResultOption = ResultOption.ResultImage, ) { ResultsBackground() - val showResult = state.value.resultImageBitmap != null + var showResult by remember { mutableStateOf(false) } + LaunchedEffect(state.value.resultImageUri) { + showResult = state.value.resultImageUri != null + } var selectedResultOption by remember { mutableStateOf(defaultSelectedResult) } @@ -213,7 +213,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..5005ecc4 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 @@ -15,20 +15,33 @@ */ package com.android.developers.androidify.results -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 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 +50,7 @@ class ResultsViewModel @Inject constructor() : ViewModel() { val snackbarHostState: StateFlow get() = _snackbarHostState - fun setArguments( - resultImageUrl: Bitmap, - originalImageUrl: Uri?, - promptText: String?, - ) { + init { _state.update { ResultState(resultImageUrl, originalImageUrl, promptText = promptText) } @@ -49,7 +58,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, ) 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..56e6b586 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 @@ -29,6 +29,7 @@ import androidx.compose.runtime.remember import androidx.compose.ui.platform.LocalInspectionMode import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp +import androidx.core.net.toUri import com.android.developers.androidify.theme.AndroidifyTheme import com.android.developers.androidify.util.AdaptivePreview import com.android.developers.androidify.util.SmallPhonePreview @@ -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", ), ) diff --git a/feature/results/src/test/kotlin/com/android/developers/androidify/customize/CustomizeStateTest.kt b/feature/results/src/test/kotlin/com/android/developers/androidify/customize/CustomizeStateTest.kt index fee29d60..14717a48 100644 --- a/feature/results/src/test/kotlin/com/android/developers/androidify/customize/CustomizeStateTest.kt +++ b/feature/results/src/test/kotlin/com/android/developers/androidify/customize/CustomizeStateTest.kt @@ -86,7 +86,7 @@ class CustomizeStateTest { Assert.assertEquals(0f, canvas.imageRotation) Assert.assertEquals(Size(1024f, 1024f), canvas.imageOriginalBitmapSize) Assert.assertEquals(BackgroundOption.IO, canvas.selectedBackgroundOption) - Assert.assertEquals(com.android.developers.androidify.results.R.drawable.background_square_blocks, canvas.selectedBackgroundDrawable) + Assert.assertEquals(R.drawable.background_square_blocks, canvas.selectedBackgroundDrawable) Assert.assertTrue(canvas.includeWatermark) } 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 f15b6af0..d096119b 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 @@ -17,10 +17,10 @@ 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.data.bitmapSample import com.android.developers.testing.network.TestRemoteConfigDataSource import com.android.developers.testing.repository.FakeImageGenerationRepository import com.android.developers.testing.repository.FakeWatchFaceInstallationRepository @@ -49,15 +49,17 @@ class CustomizeViewModelTest { val mainDispatcherRule = MainDispatcherRule() private lateinit var viewModel: CustomizeExportViewModel - - private val fakeBitmap = Bitmap.createBitmap(1, 1, Bitmap.Config.ARGB_8888) private val originalFakeUri = Uri.parse("content://com.example.app/images/original.jpg") + private val fakeUri = Uri.parse("content://com.example.app/images/original.jpg") + @Before fun setup() { val remoteConfigDataSource = TestRemoteConfigDataSource(true) remoteConfigDataSource.backgroundVibeEnabled = false viewModel = CustomizeExportViewModel( + fakeUri, + originalFakeUri, FakeImageGenerationRepository(), composableBitmapRenderer = FakeComposableBitmapRenderer(), watchfaceInstallationRepository = FakeWatchFaceInstallationRepository(), @@ -68,28 +70,21 @@ class CustomizeViewModelTest { } @Test - fun stateInitialEmpty() = runTest { - assertEquals( - CustomizeExportState(), - viewModel.state.value, - ) + fun stateResultUri_NotNull() = runTest { + val firstState = viewModel.state.first() + assertNotNull(firstState.exportImageCanvas.imageBitmap) + assertEquals(bitmapSample, firstState.exportImageCanvas.imageBitmap) } @Test fun setArgumentsWithOriginalImage() = runTest { val initialState = viewModel.state.value - - viewModel.setArguments( - fakeBitmap, - originalFakeUri, - ) - // Ensure state has changed - view model uses combine to combine state flows so state // update is not immediate val newState = viewModel.state.first { it != initialState } assertEquals( CustomizeExportState( - exportImageCanvas = ExportImageCanvas(imageBitmap = fakeBitmap), + exportImageCanvas = ExportImageCanvas(imageBitmap = bitmapSample), originalImageUrl = originalFakeUri, ), newState, @@ -103,6 +98,8 @@ class CustomizeViewModelTest { val initialState = viewModel.state.value val viewModel = CustomizeExportViewModel( + fakeUri, + null, FakeImageGenerationRepository(), composableBitmapRenderer = FakeComposableBitmapRenderer(), application = ApplicationProvider.getApplicationContext(), @@ -110,19 +107,13 @@ class CustomizeViewModelTest { watchfaceInstallationRepository = FakeWatchFaceInstallationRepository(), remoteConfigDataSource = remoteConfigDataSource, ) - - viewModel.setArguments( - fakeBitmap, - null, - ) - // Ensure state has changed - view model uses combine to combine state flows so state // update is not immediate val newState = viewModel.state.first { it != initialState } assertEquals( CustomizeExportState( - exportImageCanvas = ExportImageCanvas(imageBitmap = fakeBitmap), + exportImageCanvas = ExportImageCanvas(imageBitmap = bitmapSample), originalImageUrl = null, ), newState, @@ -138,11 +129,6 @@ class CustomizeViewModelTest { } } - viewModel.setArguments( - fakeBitmap, - originalFakeUri, - ) - viewModel.downloadClicked() assertNotNull(values.last().externalOriginalSavedUri) assertEquals( @@ -160,10 +146,6 @@ class CustomizeViewModelTest { values.add(it) } } - viewModel.setArguments( - fakeBitmap, - originalFakeUri, - ) advanceUntilIdle() viewModel.shareClicked() // Ensure all coroutines on the test scheduler complete @@ -174,6 +156,8 @@ class CustomizeViewModelTest { @Test fun changeBackground_NotNull() = runTest { val viewModel = CustomizeExportViewModel( + fakeUri, + null, FakeImageGenerationRepository(), composableBitmapRenderer = FakeComposableBitmapRenderer(), watchfaceInstallationRepository = FakeWatchFaceInstallationRepository(), @@ -188,10 +172,6 @@ class CustomizeViewModelTest { values.add(it) } } - viewModel.setArguments( - fakeBitmap, - originalFakeUri, - ) advanceUntilIdle() viewModel.selectedToolStateChanged( BackgroundToolState( @@ -205,7 +185,6 @@ class CustomizeViewModelTest { ) advanceUntilIdle() assertFalse { values[values.lastIndex].showImageEditProgress } - // assertTrue(values.any { it.showImageEditProgress }) assertNotNull(values.last().exportImageCanvas.imageWithEdit) } @@ -218,10 +197,6 @@ class CustomizeViewModelTest { values.add(it) } } - viewModel.setArguments( - fakeBitmap, - originalFakeUri, - ) advanceUntilIdle() viewModel.selectedToolStateChanged( BackgroundToolState( @@ -243,6 +218,8 @@ class CustomizeViewModelTest { val remoteConfigDataSource = TestRemoteConfigDataSource(true) remoteConfigDataSource.backgroundVibeEnabled = true val viewModel = CustomizeExportViewModel( + fakeUri, + originalFakeUri, FakeImageGenerationRepository(), composableBitmapRenderer = FakeComposableBitmapRenderer(), application = ApplicationProvider.getApplicationContext(), @@ -252,12 +229,6 @@ class CustomizeViewModelTest { ) val initialState = viewModel.state.value - - viewModel.setArguments( - fakeBitmap, - null, - ) - val newState = viewModel.state.first { it != initialState } val toolState = newState.toolState[CustomizeTool.Background] as BackgroundToolState 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..43bf653c 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 @@ -17,13 +17,11 @@ package com.android.developers.androidify.results -import android.graphics.Bitmap import android.net.Uri import com.android.developers.testing.util.MainDispatcherRule import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.test.runTest import org.junit.Assert.assertEquals -import org.junit.Before import org.junit.Rule import org.junit.Test import org.junit.runner.RunWith @@ -34,19 +32,14 @@ class ResultsViewModelTest { @get:Rule val mainDispatcherRule = MainDispatcherRule() - private lateinit var viewModel: ResultsViewModel - - private val fakeBitmap = Bitmap.createBitmap(1, 1, Bitmap.Config.ARGB_8888) private val fakePromptText = "Pink Hair, plaid shirt, jeans" private val originalFakeUri = Uri.parse("content://com.example.app/images/original.jpg") - @Before - fun setup() { - viewModel = ResultsViewModel() - } + private val fakeUri = Uri.parse("content://test/image.jpg") @Test fun stateInitialEmpty() = runTest { + val viewModel = ResultsViewModel(null, null, null) assertEquals( ResultState(), viewModel.state.value, @@ -54,15 +47,11 @@ class ResultsViewModelTest { } @Test - fun setArgumentsWithOriginalImage() = runTest { - viewModel.setArguments( - fakeBitmap, - originalFakeUri, - promptText = null, - ) + fun setArgumentsWithOriginalImage_isCorrect() = runTest { + val viewModel = ResultsViewModel(fakeUri, originalFakeUri, null) assertEquals( ResultState( - resultImageBitmap = fakeBitmap, + resultImageUri = fakeUri, originalImageUrl = originalFakeUri, ), viewModel.state.value, @@ -70,15 +59,11 @@ class ResultsViewModelTest { } @Test - fun setArgumentsWithPrompt() = runTest { - viewModel.setArguments( - fakeBitmap, - null, - promptText = fakePromptText, - ) + fun initialState_withPrompt_isCorrect() = runTest { + val viewModel = ResultsViewModel(fakeUri, null, fakePromptText) assertEquals( ResultState( - resultImageBitmap = fakeBitmap, + resultImageUri = fakeUri, originalImageUrl = null, promptText = fakePromptText, ),