diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 374c7e2c..58035461 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -37,8 +37,8 @@ android { applicationId = "com.android.developers.androidify" minSdk = libs.versions.minSdk.get().toInt() targetSdk = 36 - versionCode = 4 - versionName = "1.1.2" + versionCode = 5 + versionName = "1.1.3" testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" } 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 2a8fbbc5..8eab6f36 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,6 @@ 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 @@ -40,13 +39,8 @@ 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 @@ -98,20 +92,14 @@ fun MainNavigation() { CameraPreviewScreen( onImageCaptured = { uri -> backStack.removeAll { it is Create } - backStack.add(Create(uri)) + backStack.add(Create(uri.toString())) 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) @@ -122,64 +110,6 @@ 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 8322c46f..81c8338f 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,7 +17,6 @@ package com.android.developers.androidify.navigation -import android.net.Uri import kotlinx.serialization.ExperimentalSerializationApi import kotlinx.serialization.Serializable @@ -27,39 +26,10 @@ sealed interface NavigationRoute data object Home : NavigationRoute @Serializable -data class Create( - @Serializable(with = UriSerializer::class) val fileName: Uri? = null, - val prompt: String? = null, -) : NavigationRoute +data class Create(val fileName: String? = 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 deleted file mode 100644 index 69950d33..00000000 --- a/app/src/main/java/com/android/developers/androidify/navigation/UriSerializer.kt +++ /dev/null @@ -1,36 +0,0 @@ -/* - * 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 deleted file mode 100644 index ceec4d74..00000000 --- a/core/testing/src/main/java/com/android/developers/testing/data/BitmapSample.kt +++ /dev/null @@ -1,20 +0,0 @@ -/* - * 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 75761a89..bc10d9e7 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,8 +64,4 @@ 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 4c2812d7..150fe769 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,7 +18,6 @@ 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 @@ -54,9 +53,6 @@ interface LocalFileProvider { @WorkerThread suspend fun saveUriToSharedStorage(inputUri: Uri, fileName: String, mimeType: String): Uri - - @WorkerThread - suspend fun loadBitmapFromUri(uri: Uri): Bitmap? } @Singleton @@ -124,20 +120,6 @@ 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 8cdd2681..c16c1de6 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,13 +120,18 @@ 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 @@ -154,12 +159,12 @@ import com.android.developers.androidify.creation.R as CreationR @Composable fun CreationScreen( - creationViewModel: CreationViewModel, + fileName: String? = null, + creationViewModel: CreationViewModel = hiltViewModel(), isMedium: Boolean = isAtLeastMedium(), onCameraPressed: () -> Unit = {}, onBackPressed: () -> Unit, onAboutPressed: () -> Unit, - onImageCreated: (resultImageUri: Uri, prompt: String?, originalImageUri: Uri?) -> Unit, ) { val uiState by creationViewModel.uiState.collectAsStateWithLifecycle() BackHandler( @@ -167,28 +172,19 @@ 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( @@ -216,6 +212,46 @@ 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 632300f2..b9e34558 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,6 +16,7 @@ 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 @@ -33,9 +34,6 @@ 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,10 +41,10 @@ import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.update import kotlinx.coroutines.launch +import javax.inject.Inject -@HiltViewModel(assistedFactory = CreationViewModel.Factory::class) -class CreationViewModel @AssistedInject constructor( - @Assisted("originalImageUrl") originalImageUrl: Uri?, +@HiltViewModel +class CreationViewModel @Inject constructor( val internetConnectivityManager: InternetConnectivityManager, val imageGenerationRepository: ImageGenerationRepository, val textGenerationRepository: TextGenerationRepository, @@ -56,9 +54,11 @@ class CreationViewModel @AssistedInject constructor( val context: Context, ) : ViewModel() { - @AssistedFactory - interface Factory { - fun create(@Assisted("originalImageUrl") originalImageUrl: Uri?): CreationViewModel + init { + viewModelScope.launch { + imageGenerationRepository.initialize() + textGenerationRepository.initialize() + } } private var _uiState = MutableStateFlow(CreationState()) @@ -74,14 +74,6 @@ class CreationViewModel @AssistedInject 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( @@ -161,10 +153,7 @@ class CreationViewModel @AssistedInject constructor( ) } _uiState.update { - it.copy( - resultBitmapUri = imageGenerationRepository.saveImage(bitmap), - screenState = ScreenState.EDIT, - ) + it.copy(resultBitmap = bitmap, screenState = ScreenState.RESULT) } } catch (e: Exception) { handleImageGenerationError(e) @@ -229,15 +218,27 @@ class CreationViewModel @AssistedInject 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 onResultDisplayed() { + fun customizeExportClicked() { _uiState.update { - it.copy(resultBitmapUri = null) + it.copy(screenState = ScreenState.CUSTOMIZE) } } } @@ -251,12 +252,14 @@ data class CreationState( val generatedPrompt: String? = null, val promptGenerationInProgress: Boolean = false, val screenState: ScreenState = ScreenState.EDIT, - val resultBitmapUri: Uri? = null, + val resultBitmap: Bitmap? = 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 4a98ddc5..574fda1e 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,11 @@ class CreationViewModelTest { private val internetConnectivityManager = TestInternetConnectivityManager(true) private val imageGenerationRepository = FakeImageGenerationRepository() - private val fakeUri = Uri.parse("content://test/image.jpg") + private val fakeUri = Uri.parse("test.jpeg") @Before fun setup() { viewModel = CreationViewModel( - originalImageUrl = fakeUri, internetConnectivityManager, imageGenerationRepository, TestTextGenerationRepository(), @@ -63,10 +62,12 @@ class CreationViewModelTest { FakeDropImageFactory(), context = RuntimeEnvironment.getApplication(), ) + } @Test fun stateInitialEdit_WithImage() = runTest { + viewModel.onImageSelected(fakeUri) assertEquals( ScreenState.EDIT, viewModel.uiState.value.screenState, @@ -78,7 +79,6 @@ class CreationViewModelTest { @Test fun stateInitialEdit_WithOutImage() = runTest { viewModel = CreationViewModel( - originalImageUrl = null, internetConnectivityManager, imageGenerationRepository, TestTextGenerationRepository(), @@ -136,8 +136,8 @@ class CreationViewModelTest { viewModel.onImageSelected(Uri.parse("content://test/image.jpg")) viewModel.onSelectedPromptOptionChanged(PromptType.PHOTO) viewModel.startClicked() - assertEquals(ScreenState.EDIT, viewModel.uiState.value.screenState) - assertNotNull(viewModel.uiState.value.resultBitmapUri) + assertEquals(ScreenState.RESULT, viewModel.uiState.value.screenState) + assertNotNull(viewModel.uiState.value.resultBitmap) } @Test @@ -197,8 +197,8 @@ class CreationViewModelTest { "testing input description" } viewModel.startClicked() - assertEquals(ScreenState.EDIT, viewModel.uiState.value.screenState) - assertNotNull(viewModel.uiState.value.resultBitmapUri) + assertEquals(ScreenState.RESULT, viewModel.uiState.value.screenState) + assertNotNull(viewModel.uiState.value.resultBitmap) } @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 f478f44b..cad23c8c 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,6 +15,7 @@ */ 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 @@ -40,15 +41,15 @@ class ResultsScreenTest { @get:Rule val composeTestRule = createAndroidComposeRule() - // Create a test bitmap for testing - val testUri = android.net.Uri.parse("placeholder://image") + // Create a dummy bitmap for testing + private val testBitmap: Bitmap = Bitmap.createBitmap(100, 100, Bitmap.Config.ARGB_8888) @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 = testUri, promptText = "test") + val initialState = ResultState(resultImageBitmap = testBitmap, promptText = "test") val state = mutableStateOf(initialState) composeTestRule.setContent { @@ -77,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 = testUri, promptText = "test") + val initialState = ResultState(resultImageBitmap = testBitmap, promptText = "test") val state = mutableStateOf(initialState) composeTestRule.setContent { @@ -105,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 testUri = android.net.Uri.parse("placeholder://image") + val dummyUri = android.net.Uri.parse("dummy://image") - val initialState = ResultState(resultImageUri = testUri, originalImageUrl = testUri) + val initialState = ResultState(resultImageBitmap = testBitmap, originalImageUrl = dummyUri) val state = mutableStateOf(initialState) composeTestRule.setContent { @@ -142,7 +143,7 @@ class ResultsScreenTest { val promptText = "test prompt" val promptPrefix = composeTestRule.activity.getString(R.string.my_bot_is_wearing) - val initialState = ResultState(resultImageUri = testUri, promptText = promptText) // No original image URI + val initialState = ResultState(resultImageBitmap = testBitmap, promptText = promptText) // No original image URI val state = mutableStateOf(initialState) composeTestRule.setContent { @@ -172,9 +173,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 testUri = android.net.Uri.parse("placeholder://image") + val dummyUri = android.net.Uri.parse("dummy://image") - val initialState = ResultState(resultImageUri = testUri, originalImageUrl = testUri) + val initialState = ResultState(resultImageBitmap = testBitmap, originalImageUrl = dummyUri) val state = mutableStateOf(initialState) composeTestRule.setContent { @@ -209,7 +210,7 @@ class ResultsScreenTest { var shareClicked = false // Ensure promptText is non-null when bitmap is present - val initialState = ResultState(resultImageUri = testUri, promptText = "test") + val initialState = ResultState(resultImageBitmap = testBitmap, promptText = "test") val state = mutableStateOf(initialState) composeTestRule.setContent { diff --git a/feature/results/src/main/java/com/android/developers/androidify/customize/ComposableBitmapRendererImpl.kt b/feature/results/src/main/java/com/android/developers/androidify/customize/ComposableBitmapRendererImpl.kt index 643c04fc..9b2c0d3c 100644 --- a/feature/results/src/main/java/com/android/developers/androidify/customize/ComposableBitmapRendererImpl.kt +++ b/feature/results/src/main/java/com/android/developers/androidify/customize/ComposableBitmapRendererImpl.kt @@ -22,6 +22,7 @@ import android.content.Context.DISPLAY_SERVICE import android.graphics.Bitmap import android.graphics.SurfaceTexture import android.hardware.display.DisplayManager +import android.hardware.display.VirtualDisplay import android.view.Display import android.view.Surface import android.view.ViewGroup @@ -57,10 +58,7 @@ import javax.inject.Singleton interface ComposableBitmapRenderer { - suspend fun renderComposableToBitmap( - canvasSize: Size, - composableContent: @Composable () -> Unit, - ): Bitmap? + suspend fun renderComposableToBitmap(canvasSize: Size, composableContent: @Composable () -> Unit): Bitmap? } /** @@ -76,19 +74,17 @@ interface ComposableBitmapRenderer { * } */ @Singleton -class ComposableBitmapRendererImpl @Inject constructor(private val application: Application) : - ComposableBitmapRenderer { +class ComposableBitmapRendererImpl @Inject constructor(private val application: Application) : ComposableBitmapRenderer { private suspend fun useVirtualDisplay(callback: suspend (display: Display) -> T): T? { val texture = SurfaceTexture(false) val surface = Surface(texture) - val outerContext = application.resources.displayMetrics - val virtualDisplay = - application.getSystemService(DisplayManager::class.java).createVirtualDisplay( + val virtualDisplay: VirtualDisplay? = + (application.getSystemService(DISPLAY_SERVICE) as DisplayManager).createVirtualDisplay( "virtualDisplay", - outerContext.widthPixels, - outerContext.heightPixels, - outerContext.densityDpi, + 1, + 1, + 72, surface, DisplayManager.VIRTUAL_DISPLAY_FLAG_OWN_CONTENT_ONLY, ) @@ -100,10 +96,7 @@ class ComposableBitmapRendererImpl @Inject constructor(private val application: return result } - override suspend fun renderComposableToBitmap( - canvasSize: Size, - composableContent: @Composable () -> Unit, - ): Bitmap? { + override suspend fun renderComposableToBitmap(canvasSize: Size, composableContent: @Composable () -> Unit): Bitmap? { val bitmap = useVirtualDisplay { display -> val outputDensity = Density(1f) @@ -180,7 +173,7 @@ class ComposableBitmapRendererImpl @Inject constructor(private val application: } } - val composeView = ComposeView(presentation.context).apply { + val composeView = ComposeView(context).apply { val intSize = with(density) { size.toSize().roundedToIntSize() } require(intSize.width > 0 && intSize.height > 0) { "pixel size must not have zero dimension" } 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 c7d213bb..0778e228 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,6 +18,9 @@ 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 @@ -58,14 +61,18 @@ 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 @@ -90,11 +97,16 @@ 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, + viewModel: CustomizeExportViewModel = hiltViewModel(), ) { + LaunchedEffect(resultImage, originalImageUri) { + viewModel.setArguments(resultImage, originalImageUri) + } val state = viewModel.state.collectAsStateWithLifecycle() val context = LocalContext.current LaunchedEffect(state.value.savedUri) { @@ -418,9 +430,9 @@ fun CustomizeExportPreview() { AnimatedContent(true) { targetState -> targetState CompositionLocalProvider(LocalNavAnimatedContentScope provides this@AnimatedContent) { - val imageUri = getPlaceholderBotUri() + val bitmap = ImageBitmap.imageResource(R.drawable.placeholderbot) val state = CustomizeExportState( - exportImageCanvas = ExportImageCanvas(imageUri = imageUri), + exportImageCanvas = ExportImageCanvas(imageBitmap = bitmap.asAndroidBitmap()), ) CustomizeExportContents( state = state, @@ -445,10 +457,10 @@ fun CustomizeExportPreviewLarge() { AnimatedContent(true) { targetState -> targetState CompositionLocalProvider(LocalNavAnimatedContentScope provides this@AnimatedContent) { - val imageUri = getPlaceholderBotUri() + val bitmap = ImageBitmap.imageResource(R.drawable.placeholderbot) val state = CustomizeExportState( exportImageCanvas = ExportImageCanvas( - imageUri = imageUri, + imageBitmap = bitmap.asAndroidBitmap(), 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 ec75abfd..994ee956 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 @@ -27,20 +27,16 @@ import androidx.lifecycle.viewModelScope import com.android.developers.androidify.RemoteConfigDataSource 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.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( - @Assisted("resultImageUrl") val resultImageUrl: Uri, - @Assisted("originalImageUrl") val originalImageUrl: Uri?, +@HiltViewModel +class CustomizeExportViewModel @Inject constructor( val imageGenerationRepository: ImageGenerationRepository, val composableBitmapRenderer: ComposableBitmapRenderer, val localFileProvider: LocalFileProvider, @@ -48,14 +44,6 @@ class CustomizeExportViewModel @AssistedInject 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 = _state.asStateFlow() @@ -89,9 +77,7 @@ class CustomizeExportViewModel @AssistedInject constructor( } _state.update { - it.copy( - originalImageUrl = originalImageUrl, - exportImageCanvas = it.exportImageCanvas.copy(imageUri = resultImageUrl), + CustomizeExportState( toolState = mapOf( CustomizeTool.Size to AspectRatioToolState(), CustomizeTool.Background to BackgroundToolState( @@ -100,13 +86,24 @@ class CustomizeExportViewModel @AssistedInject 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 @@ -239,6 +236,7 @@ class CustomizeExportViewModel @AssistedInject constructor( } return@launch } + val image = state.value.exportImageCanvas.imageBitmap if (image == null) { return@launch @@ -296,19 +294,4 @@ 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 68fe8d87..5137227c 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 @@ -65,7 +65,6 @@ data class BackgroundToolState( ) : ToolState data class ExportImageCanvas( - val imageUri: Uri? = null, val imageBitmap: Bitmap? = null, val imageBitmapRemovedBackground: Bitmap? = null, val aspectRatioOption: SizeOption = SizeOption.Square, 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 dc26cac7..18cd1bef 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 coil3.compose.AsyncImage +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) @@ -90,9 +91,9 @@ fun ImageResult( contentScale = ContentScale.Crop, contentDescription = null, ) - } else if (exportImageCanvas.imageUri != null) { - AsyncImage( - model = exportImageCanvas.imageUri, + } else if (exportImageCanvas.imageBitmap != null) { + Image( + bitmap = exportImageCanvas.imageBitmap.asImageBitmap(), modifier = Modifier .fillMaxSize(), contentScale = ContentScale.Crop, @@ -208,12 +209,12 @@ private fun Modifier.safeAnimateBounds(): Modifier { @Preview @Composable private fun ImageRendererPreviewSquare() { - val imageUri = getPlaceholderBotUri() + val bitmap = ImageBitmap.imageResource(R.drawable.placeholderbot) AndroidifyTheme { ImageResult( ExportImageCanvas( - imageUri = imageUri, + imageBitmap = bitmap.asAndroidBitmap(), canvasSize = Size(1000f, 1000f), aspectRatioOption = SizeOption.Square, selectedBackgroundOption = BackgroundOption.IO, @@ -231,11 +232,11 @@ private fun ImageRendererPreviewSquare() { @Preview @Composable private fun ImageRendererPreviewBanner() { - val imageUri = getPlaceholderBotUri() + val bitmap = ImageBitmap.imageResource(R.drawable.placeholderbot) AndroidifyTheme { ImageResult( ExportImageCanvas( - imageUri = imageUri, + imageBitmap = bitmap.asAndroidBitmap(), canvasSize = Size(1000f, 1000f), aspectRatioOption = SizeOption.Banner, selectedBackgroundOption = BackgroundOption.Lightspeed, @@ -253,11 +254,11 @@ private fun ImageRendererPreviewBanner() { @Preview @Composable private fun ImageRendererPreviewWallpaper() { - val imageUri = getPlaceholderBotUri() + val bitmap = ImageBitmap.imageResource(R.drawable.placeholderbot) AndroidifyTheme { ImageResult( ExportImageCanvas( - imageUri = imageUri, + imageBitmap = bitmap.asAndroidBitmap(), 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 imageUri = getPlaceholderBotUri() + val bitmap = ImageBitmap.imageResource(R.drawable.placeholderbot) AndroidifyTheme { ImageResult( ExportImageCanvas( - imageUri = imageUri, + imageBitmap = bitmap.asAndroidBitmap(), canvasSize = Size(1280f, 800f), aspectRatioOption = SizeOption.WallpaperTablet, selectedBackgroundOption = BackgroundOption.Lightspeed, @@ -297,11 +298,11 @@ private fun ImageRendererPreviewWallpaperTablet() { @Preview @Composable private fun ImageRendererPreviewWallpaperSocial() { - val imageUri = getPlaceholderBotUri() + val bitmap = ImageBitmap.imageResource(R.drawable.placeholderbot) AndroidifyTheme { ImageResult( ExportImageCanvas( - imageUri = imageUri, + imageBitmap = bitmap.asAndroidBitmap(), canvasSize = Size(1600f, 900f), aspectRatioOption = SizeOption.SocialHeader, selectedBackgroundOption = BackgroundOption.Lightspeed, @@ -319,11 +320,11 @@ private fun ImageRendererPreviewWallpaperSocial() { @Preview @Composable fun ImageRendererPreviewWallpaperIO() { - val imageUri = getPlaceholderBotUri() + val bitmap = ImageBitmap.imageResource(R.drawable.placeholderbot) AndroidifyTheme { ImageResult( ExportImageCanvas( - imageUri = imageUri, + imageBitmap = bitmap.asAndroidBitmap(), 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 deleted file mode 100644 index 40886c77..00000000 --- a/feature/results/src/main/java/com/android/developers/androidify/customize/Utils.kt +++ /dev/null @@ -1,27 +0,0 @@ -/* - * 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.net.Uri -import androidx.compose.runtime.Composable -import androidx.compose.ui.platform.LocalContext -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() 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 3f4139be..0934b485 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,6 +15,7 @@ */ 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 @@ -49,7 +50,7 @@ import coil3.compose.AsyncImage @Composable fun BotResultCard( - resultImageUri: Uri, + resultImage: Bitmap, originalImageUrl: Uri?, promptText: String?, flippableState: FlippableState, @@ -65,7 +66,7 @@ fun BotResultCard( flippableState = flippableState, onFlipStateChanged = onFlipStateChanged, front = { - FrontCard(resultImageUri) + FrontCard(resultImage) }, back = { if (originalImageUrl != null) { @@ -78,9 +79,9 @@ fun BotResultCard( } @Composable -private fun FrontCard(resultImageUri: Uri) { +private fun FrontCard(bitmap: Bitmap) { AsyncImage( - model = resultImageUri, + model = bitmap, 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 589ce799..a6c2036c 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.graphics.Bitmap import android.net.Uri import androidx.compose.animation.AnimatedVisibility import androidx.compose.animation.core.EaseOutBack @@ -43,6 +44,7 @@ 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 @@ -50,8 +52,11 @@ 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 @@ -59,8 +64,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.AndroidifyTopAppBar import com.android.developers.androidify.theme.components.PrimaryButton @@ -73,14 +78,20 @@ 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: (resultImageUri: Uri, originalImageUri: Uri?) -> Unit, - viewModel: ResultsViewModel, + onNextPress: () -> Unit, + viewModel: ResultsViewModel = hiltViewModel(), ) { val state = viewModel.state.collectAsStateWithLifecycle() + LaunchedEffect(resultImage, originalImageUri, promptText) { + viewModel.setArguments(resultImage, originalImageUri, promptText) + } val snackbarHostState by viewModel.snackbarHostState.collectAsStateWithLifecycle() Scaffold( snackbarHost = { @@ -110,14 +121,7 @@ fun ResultsScreen( contentPadding, state, verboseLayout = verboseLayout, - onCustomizeShareClicked = { - viewModel.state.value.resultImageUri?.let { - onNextPress( - it, - viewModel.state.value.originalImageUrl, - ) - } - }, + onCustomizeShareClicked = onNextPress, ) } } @@ -128,11 +132,11 @@ fun ResultsScreen( @Composable private fun ResultsScreenPreview() { AndroidifyTheme { - val imageUri = getPlaceholderBotUri() + val bitmap = ImageBitmap.imageResource(R.drawable.placeholderbot) val state = remember { mutableStateOf( ResultState( - resultImageUri = imageUri, + resultImageBitmap = bitmap.asAndroidBitmap(), promptText = "wearing a hat with straw hair", ), ) @@ -150,11 +154,11 @@ private fun ResultsScreenPreview() { @Composable private fun ResultsScreenPreviewSmall() { AndroidifyTheme { - val imageUri = getPlaceholderBotUri() + val bitmap = ImageBitmap.imageResource(R.drawable.placeholderbot) val state = remember { mutableStateOf( ResultState( - resultImageUri = imageUri, + resultImageBitmap = bitmap.asAndroidBitmap(), promptText = "wearing a hat with straw hair", ), ) @@ -178,7 +182,7 @@ fun ResultsScreenContents( defaultSelectedResult: ResultOption = ResultOption.ResultImage, ) { ResultsBackground() - val showResult = state.value.resultImageUri != null + val showResult = state.value.resultImageBitmap != null var selectedResultOption by remember { mutableStateOf(defaultSelectedResult) } @@ -206,7 +210,7 @@ fun ResultsScreenContents( .fillMaxSize(), ) { BotResultCard( - state.value.resultImageUri!!, + state.value.resultImageBitmap!!, 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 5005ecc4..a54af8ea 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,33 +15,20 @@ */ 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(assistedFactory = ResultsViewModel.Factory::class) -class ResultsViewModel @AssistedInject constructor( - @Assisted("resultImageUrl") val resultImageUrl: Uri?, - @Assisted("originalImageUrl") val originalImageUrl: Uri?, - @Assisted("promptText") val promptText: String?, -) : ViewModel() { +@HiltViewModel +class ResultsViewModel @Inject constructor() : 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() @@ -50,7 +37,11 @@ class ResultsViewModel @AssistedInject constructor( val snackbarHostState: StateFlow get() = _snackbarHostState - init { + fun setArguments( + resultImageUrl: Bitmap, + originalImageUrl: Uri?, + promptText: String?, + ) { _state.update { ResultState(resultImageUrl, originalImageUrl, promptText = promptText) } @@ -58,7 +49,7 @@ class ResultsViewModel @AssistedInject constructor( } data class ResultState( - val resultImageUri: Uri? = null, + val resultImageBitmap: Bitmap? = 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 56e6b586..f03cfbd3 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,7 +29,6 @@ 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 @@ -44,7 +43,7 @@ class ResultsScreenScreenshotTest { val state = remember { mutableStateOf( ResultState( - resultImageUri = "test://mockbitmap/${mockBitmap.hashCode()}".toUri(), + resultImageBitmap = mockBitmap, promptText = "wearing a hat with straw hair", ), ) @@ -69,7 +68,7 @@ class ResultsScreenScreenshotTest { val state = remember { mutableStateOf( ResultState( - resultImageUri = "test://mockbitmap/${mockBitmap.hashCode()}".toUri(), + resultImageBitmap = mockBitmap, promptText = "wearing a hat with straw hair", ), ) @@ -93,7 +92,7 @@ class ResultsScreenScreenshotTest { val state = remember { mutableStateOf( ResultState( - resultImageUri = "test://mockbitmap/${mockBitmap.hashCode()}".toUri(), + resultImageBitmap = mockBitmap, 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 8f4214ae..fee29d60 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 @@ -77,7 +77,7 @@ class CustomizeStateTest { @Test fun exportImageCanvas_defaultValues() { val canvas = ExportImageCanvas() - Assert.assertNull(canvas.imageUri) + Assert.assertNull(canvas.imageBitmap) Assert.assertEquals(SizeOption.Square, canvas.aspectRatioOption) Assert.assertEquals(Size(1000f, 1000f), canvas.canvasSize) Assert.assertNull(canvas.mainImageUri) 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 a77c6cad..7a5df4b9 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.util.FakeComposableBitmapRenderer @@ -47,17 +47,15 @@ class CustomizeViewModelTest { val mainDispatcherRule = MainDispatcherRule() private lateinit var viewModel: CustomizeExportViewModel - private val originalFakeUri = Uri.parse("content://com.example.app/images/original.jpg") - private val fakeUri = Uri.parse("content://com.example.app/images/original.jpg") + private val fakeBitmap = Bitmap.createBitmap(1, 1, Bitmap.Config.ARGB_8888) + private val originalFakeUri = 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(), application = ApplicationProvider.getApplicationContext(), @@ -67,17 +65,22 @@ class CustomizeViewModelTest { } @Test - fun stateResultUri_NotNull() = runTest { - assertNotNull( - viewModel.state.value.exportImageCanvas.imageUri, + fun stateInitialEmpty() = runTest { + assertEquals( + CustomizeExportState(), + viewModel.state.value, ) } @Test fun setArgumentsWithOriginalImage() = runTest { + viewModel.setArguments( + fakeBitmap, + originalFakeUri, + ) assertEquals( CustomizeExportState( - exportImageCanvas = ExportImageCanvas(imageUri = fakeUri, imageBitmap = bitmapSample), + exportImageCanvas = ExportImageCanvas(imageBitmap = fakeBitmap), originalImageUrl = originalFakeUri, ), viewModel.state.value, @@ -89,17 +92,19 @@ class CustomizeViewModelTest { val remoteConfigDataSource = TestRemoteConfigDataSource(true) remoteConfigDataSource.backgroundVibeEnabled = false val viewModel = CustomizeExportViewModel( - fakeUri, - null, FakeImageGenerationRepository(), composableBitmapRenderer = FakeComposableBitmapRenderer(), application = ApplicationProvider.getApplicationContext(), localFileProvider = TestFileProvider(), remoteConfigDataSource = remoteConfigDataSource, ) + viewModel.setArguments( + fakeBitmap, + null, + ) assertEquals( CustomizeExportState( - exportImageCanvas = ExportImageCanvas(imageUri = fakeUri, imageBitmap = bitmapSample), + exportImageCanvas = ExportImageCanvas(imageBitmap = fakeBitmap), originalImageUrl = null, ), viewModel.state.value, @@ -115,6 +120,11 @@ class CustomizeViewModelTest { } } + viewModel.setArguments( + fakeBitmap, + originalFakeUri, + ) + viewModel.downloadClicked() assertNotNull(values.last().externalOriginalSavedUri) assertEquals( @@ -132,6 +142,10 @@ class CustomizeViewModelTest { values.add(it) } } + viewModel.setArguments( + fakeBitmap, + originalFakeUri, + ) advanceUntilIdle() viewModel.shareClicked() // Ensure all coroutines on the test scheduler complete @@ -142,8 +156,6 @@ class CustomizeViewModelTest { @Test fun changeBackground_NotNull() = runTest { val viewModel = CustomizeExportViewModel( - fakeUri, - null, FakeImageGenerationRepository(), composableBitmapRenderer = FakeComposableBitmapRenderer(), application = ApplicationProvider.getApplicationContext(), @@ -157,6 +169,10 @@ class CustomizeViewModelTest { values.add(it) } } + viewModel.setArguments( + fakeBitmap, + originalFakeUri, + ) advanceUntilIdle() viewModel.selectedToolStateChanged( BackgroundToolState( @@ -183,6 +199,10 @@ class CustomizeViewModelTest { values.add(it) } } + viewModel.setArguments( + fakeBitmap, + originalFakeUri, + ) advanceUntilIdle() viewModel.selectedToolStateChanged( BackgroundToolState( @@ -204,14 +224,16 @@ class CustomizeViewModelTest { val remoteConfigDataSource = TestRemoteConfigDataSource(true) remoteConfigDataSource.backgroundVibeEnabled = true val viewModel = CustomizeExportViewModel( - fakeUri, - null, FakeImageGenerationRepository(), composableBitmapRenderer = FakeComposableBitmapRenderer(), application = ApplicationProvider.getApplicationContext(), localFileProvider = TestFileProvider(), remoteConfigDataSource = remoteConfigDataSource, ) + viewModel.setArguments( + fakeBitmap, + null, + ) val state = viewModel.state.value.toolState[CustomizeTool.Background] as BackgroundToolState assertTrue(state.options.size > 5) 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 43bf653c..5f425658 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,11 +17,13 @@ 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 @@ -32,14 +34,19 @@ 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") - private val fakeUri = Uri.parse("content://test/image.jpg") + @Before + fun setup() { + viewModel = ResultsViewModel() + } @Test fun stateInitialEmpty() = runTest { - val viewModel = ResultsViewModel(null, null, null) assertEquals( ResultState(), viewModel.state.value, @@ -47,11 +54,15 @@ class ResultsViewModelTest { } @Test - fun setArgumentsWithOriginalImage_isCorrect() = runTest { - val viewModel = ResultsViewModel(fakeUri, originalFakeUri, null) + fun setArgumentsWithOriginalImage() = runTest { + viewModel.setArguments( + fakeBitmap, + originalFakeUri, + promptText = null, + ) assertEquals( ResultState( - resultImageUri = fakeUri, + resultImageBitmap = fakeBitmap, originalImageUrl = originalFakeUri, ), viewModel.state.value, @@ -59,11 +70,15 @@ class ResultsViewModelTest { } @Test - fun initialState_withPrompt_isCorrect() = runTest { - val viewModel = ResultsViewModel(fakeUri, null, fakePromptText) + fun setArgumentsWithPrompt() = runTest { + viewModel.setArguments( + fakeBitmap, + null, + promptText = fakePromptText, + ) assertEquals( ResultState( - resultImageUri = fakeUri, + resultImageBitmap = fakeBitmap, originalImageUrl = null, promptText = fakePromptText, ),