From c7f1f49b10c8be145fc59204f7eab0b1e85183b8 Mon Sep 17 00:00:00 2001 From: Rebecca Franks Date: Fri, 18 Jul 2025 14:28:15 +0100 Subject: [PATCH 01/11] Add initial Sticker option for the export. Still todo: Move segmentation to background thread to improve initial feedback. --- .../theme/components/Backgrounds.kt | 22 ---- feature/results/build.gradle.kts | 1 + .../androidify/customize/AspectRatioTool.kt | 51 ++++++--- .../customize/CustomizeExportScreen.kt | 20 ++-- .../customize/CustomizeExportViewModel.kt | 104 ++++++++++++------ .../androidify/customize/CustomizeState.kt | 21 +++- .../androidify/customize/CustomizeTool.kt | 4 +- .../androidify/customize/ImageRenderer.kt | 18 ++- .../main/res/drawable-nodpi/sticker_size.png | Bin 0 -> 7922 bytes gradle/libs.versions.toml | 2 + 10 files changed, 155 insertions(+), 88 deletions(-) create mode 100644 feature/results/src/main/res/drawable-nodpi/sticker_size.png diff --git a/core/theme/src/main/java/com/android/developers/androidify/theme/components/Backgrounds.kt b/core/theme/src/main/java/com/android/developers/androidify/theme/components/Backgrounds.kt index 525ffe43..89810242 100644 --- a/core/theme/src/main/java/com/android/developers/androidify/theme/components/Backgrounds.kt +++ b/core/theme/src/main/java/com/android/developers/androidify/theme/components/Backgrounds.kt @@ -79,28 +79,6 @@ fun SquiggleBackground( } } -@Composable -fun ScallopBackground(modifier: Modifier = Modifier) { - val vectorBackground = - rememberVectorPainter(ImageVector.vectorResource(R.drawable.shape_home_bg)) - val backgroundWidth = 300.dp - BoxWithConstraints( - modifier = modifier - .fillMaxSize() - .background(MaterialTheme.colorScheme.secondary), - ) { - val maxHeight = this@BoxWithConstraints.maxHeight.dpToPx() - Box( - modifier = Modifier - .fillMaxSize() - .offset { - IntOffset(0, y = (maxHeight * 0.6f).toInt()) - } - .backgroundRepeatX(vectorBackground, backgroundWidth.dpToPx()), - ) - } -} - @LargeScreensPreview @Composable private fun SquiggleBackgroundLargePreview() { diff --git a/feature/results/build.gradle.kts b/feature/results/build.gradle.kts index cdf6d306..14472d6f 100644 --- a/feature/results/build.gradle.kts +++ b/feature/results/build.gradle.kts @@ -58,6 +58,7 @@ dependencies { implementation(libs.coil.compose) implementation(libs.accompanist.permissions) implementation(libs.androidx.lifecycle.process) + implementation(libs.mlkit.segmentation) ksp(libs.hilt.compiler) implementation(libs.androidx.ui.tooling) diff --git a/feature/results/src/main/java/com/android/developers/androidify/customize/AspectRatioTool.kt b/feature/results/src/main/java/com/android/developers/androidify/customize/AspectRatioTool.kt index 150b65cc..634ba945 100644 --- a/feature/results/src/main/java/com/android/developers/androidify/customize/AspectRatioTool.kt +++ b/feature/results/src/main/java/com/android/developers/androidify/customize/AspectRatioTool.kt @@ -15,6 +15,7 @@ */ package com.android.developers.androidify.customize +import androidx.compose.foundation.Image import androidx.compose.foundation.background import androidx.compose.foundation.border import androidx.compose.foundation.layout.Box @@ -28,8 +29,10 @@ import androidx.compose.runtime.Composable import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip +import androidx.compose.ui.res.painterResource import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp +import com.android.developers.androidify.results.R import com.android.developers.androidify.theme.AndroidifyTheme @Composable @@ -54,23 +57,36 @@ fun AspectRatioTool( .size(70.dp), contentAlignment = Alignment.Center, ) { - Box( - modifier = Modifier - .aspectRatio(tool.aspectRatio) - .border( - 2.dp, - color = MaterialTheme.colorScheme.primary, - shape = MaterialTheme.shapes.medium, - ) - .background( - MaterialTheme.colorScheme.background, - shape = MaterialTheme.shapes.medium, - ) - .padding(6.dp) - .fillMaxSize() - .clip(MaterialTheme.shapes.small) - .background(MaterialTheme.colorScheme.surfaceBright), - ) + if (tool == SizeOption.Sticker) { + Box( + modifier = Modifier + .aspectRatio(tool.aspectRatio) + .padding(6.dp) + .fillMaxSize(), + contentAlignment = Alignment.Center + ) { + Image(painterResource(R.drawable.sticker_size), contentDescription = null) + } + } else { + Box( + modifier = Modifier + .aspectRatio(tool.aspectRatio) + .border( + 2.dp, + color = MaterialTheme.colorScheme.primary, + shape = MaterialTheme.shapes.medium, + ) + .background( + MaterialTheme.colorScheme.background, + shape = MaterialTheme.shapes.medium, + ) + .padding(6.dp) + .fillMaxSize() + .clip(MaterialTheme.shapes.small) + .background(MaterialTheme.colorScheme.surfaceBright), + ) + } + } }, ) @@ -87,6 +103,7 @@ private fun AspectRatioToolPreview() { SizeOption.SocialHeader, SizeOption.Wallpaper, SizeOption.WallpaperTablet, + SizeOption.Sticker ), selectedOption = SizeOption.Square, onSizeOptionSelected = {}, 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 5f8483af..142739ad 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 @@ -162,22 +162,22 @@ private fun CustomizeExportContents( }, containerColor = MaterialTheme.colorScheme.surface, ) { paddingValues -> + val imageResult = remember { movableContentWithReceiverOf { + val chromeModifier = if (this.showSticker) Modifier else Modifier.dropShadow( + RoundedCornerShape(6), + shadow = Shadow( + radius = 26.dp, + spread = 10.dp, + color = MaterialTheme.colorScheme.inverseSurface.copy(alpha = 0.2f), + ), + ).clip(RoundedCornerShape(6)) ImageResult( this, modifier = Modifier .padding(16.dp), - outerChromeModifier = Modifier - .dropShadow( - RoundedCornerShape(6), - shadow = Shadow( - radius = 26.dp, - spread = 10.dp, - color = MaterialTheme.colorScheme.inverseSurface.copy(alpha = 0.2f), - ), - ) - .clip(RoundedCornerShape(6)), + outerChromeModifier = chromeModifier, ) } } 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 1907e18e..51c30dea 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 @@ -24,6 +24,7 @@ import androidx.compose.ui.Modifier import androidx.lifecycle.AndroidViewModel import androidx.lifecycle.viewModelScope import com.android.developers.androidify.data.ImageGenerationRepository +import com.android.developers.androidify.segmentation.SubjectSegmentationHelper import dagger.hilt.android.lifecycle.HiltViewModel import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow @@ -36,12 +37,14 @@ import javax.inject.Inject class CustomizeExportViewModel @Inject constructor( val imageGenerationRepository: ImageGenerationRepository, val composableBitmapRenderer: ComposableBitmapRenderer, + private val subjectSegmentationHelper: SubjectSegmentationHelper, application: Application, ) : AndroidViewModel(application) { private val _state = MutableStateFlow(CustomizeExportState()) val state = _state.asStateFlow() + private var _snackbarHostState = MutableStateFlow(SnackbarHostState()) val snackbarHostState: StateFlow @@ -51,6 +54,7 @@ class CustomizeExportViewModel @Inject constructor( super.onCleared() composableBitmapRenderer.dispose() } + fun setArguments( resultImageUrl: Bitmap, originalImageUrl: Uri?, @@ -66,12 +70,13 @@ class CustomizeExportViewModel @Inject constructor( fun shareClicked() { viewModelScope.launch { val exportImageCanvas = state.value.exportImageCanvas - val resultBitmap = composableBitmapRenderer.renderComposableToBitmap(exportImageCanvas.canvasSize) { - ImageResult( - exportImageCanvas = exportImageCanvas, - modifier = Modifier.fillMaxSize(), - ) - } + val resultBitmap = + composableBitmapRenderer.renderComposableToBitmap(exportImageCanvas.canvasSize) { + ImageResult( + exportImageCanvas = exportImageCanvas, + modifier = Modifier.fillMaxSize(), + ) + } if (resultBitmap != null) { val imageFileUri = imageGenerationRepository.saveImage(resultBitmap) @@ -81,45 +86,78 @@ class CustomizeExportViewModel @Inject constructor( } } } + fun onSavedUriConsumed() { _state.update { it.copy(savedUri = null) } } + fun selectedToolStateChanged(toolState: ToolState) { - _state.update { - it.copy( - toolState = it.toolState + (it.selectedTool to toolState), - exportImageCanvas = - when (toolState.selectedToolOption) { - is BackgroundOption -> { - val backgroundOption = toolState.selectedToolOption as BackgroundOption - it.exportImageCanvas.updateAspectRatioAndBackground( - backgroundOption, - it.exportImageCanvas.aspectRatioOption, - ) - } - is SizeOption -> { - it.exportImageCanvas.updateAspectRatioAndBackground( - it.exportImageCanvas.selectedBackgroundOption, - (toolState.selectedToolOption as SizeOption), - ) - } - else -> throw IllegalArgumentException("Unknown tool option") - }, - ) + viewModelScope.launch { + _state.update { + it.copy( + toolState = it.toolState + (it.selectedTool to toolState), + exportImageCanvas = + when (toolState.selectedToolOption) { + is BackgroundOption -> { + val backgroundOption = + toolState.selectedToolOption as BackgroundOption + it.exportImageCanvas.updateAspectRatioAndBackground( + backgroundOption, + it.exportImageCanvas.aspectRatioOption, + ) + } + + is SizeOption -> { + if (toolState.selectedToolOption is SizeOption.Sticker) { + // TODO kick this off to a background thread and return the + // state immediately with loading to ensure that the UI is updated + val bitmap = state.value.exportImageCanvas.imageBitmap + if (bitmap != null) { + val stickerBitmap = + if (state.value.exportImageCanvas.imageBitmapRemovedBackground == null) + subjectSegmentationHelper.removeBackground( + bitmap, + ) + else state.value.exportImageCanvas.imageBitmapRemovedBackground + + it.exportImageCanvas.copy(imageBitmapRemovedBackground = stickerBitmap) + .updateAspectRatioAndBackground( + it.exportImageCanvas.selectedBackgroundOption, + (toolState.selectedToolOption as SizeOption), + ) + } else { + it.exportImageCanvas.updateAspectRatioAndBackground( + it.exportImageCanvas.selectedBackgroundOption, + (toolState.selectedToolOption as SizeOption), + ) + } + } else { + it.exportImageCanvas.updateAspectRatioAndBackground( + it.exportImageCanvas.selectedBackgroundOption, + (toolState.selectedToolOption as SizeOption), + ) + } + } + + else -> throw IllegalArgumentException("Unknown tool option") + }, + ) + } } } fun downloadClicked() { viewModelScope.launch { val exportImageCanvas = state.value.exportImageCanvas - val resultBitmap = composableBitmapRenderer.renderComposableToBitmap(exportImageCanvas.canvasSize) { - ImageResult( - exportImageCanvas = exportImageCanvas, - modifier = Modifier.fillMaxSize(), - ) - } + val resultBitmap = + composableBitmapRenderer.renderComposableToBitmap(exportImageCanvas.canvasSize) { + ImageResult( + exportImageCanvas = exportImageCanvas, + modifier = Modifier.fillMaxSize(), + ) + } val originalImage = state.value.originalImageUrl if (originalImage != null) { val savedOriginalUri = 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 d9963976..fcf1a720 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 @@ -15,13 +15,12 @@ */ package com.android.developers.androidify.customize -import android.R.attr.rotation import android.graphics.Bitmap import android.net.Uri import androidx.annotation.DrawableRes import androidx.compose.ui.geometry.Offset -import androidx.compose.ui.geometry.R import androidx.compose.ui.geometry.Size +import androidx.compose.ui.graphics.Color data class CustomizeExportState( val originalImageUrl: Uri? = null, @@ -51,6 +50,7 @@ data class AspectRatioToolState( SizeOption.WallpaperTablet, SizeOption.Banner, SizeOption.SocialHeader, + SizeOption.Sticker, ), ) : ToolState @@ -66,6 +66,7 @@ data class BackgroundToolState( data class ExportImageCanvas( val imageBitmap: Bitmap? = null, + val imageBitmapRemovedBackground: Bitmap? = null, val aspectRatioOption: SizeOption = SizeOption.Square, val canvasSize: Size = Size(1000f, 1000f), val mainImageUri: Uri? = null, @@ -77,6 +78,8 @@ data class ExportImageCanvas( @param:DrawableRes val selectedBackgroundDrawable: Int? = com.android.developers.androidify.results.R.drawable.background_square_blocks, val includeWatermark: Boolean = true, + val backgroundColor: Color? = Color.White, + val showSticker: Boolean = false, ) { fun updateAspectRatioAndBackground( backgroundOption: BackgroundOption, @@ -88,6 +91,8 @@ data class ExportImageCanvas( var offset = Offset.Zero var image: Int? var rotation: Float + var backgroundColor: Color? = Color.White + var showSticker = false when (sizeOption) { SizeOption.Square -> { offset = Offset(newCanvasSize.width * 0.2f, newCanvasSize.height * 0.16f) @@ -171,6 +176,14 @@ data class ExportImageCanvas( BackgroundOption.Plain -> com.android.developers.androidify.results.R.drawable.background_wallpaper_tablet_light } } + SizeOption.Sticker -> { + offset = Offset(0f, 0f) + imageSize = Size(newCanvasSize.width, newCanvasSize.height) + image = null + rotation = 0f + backgroundColor = null + showSticker = true + } } return copy( selectedBackgroundDrawable = image, @@ -179,7 +192,9 @@ data class ExportImageCanvas( imageOffset = offset, canvasSize = newCanvasSize, aspectRatioOption = sizeOption, - selectedBackgroundOption = backgroundOption, + selectedBackgroundOption = if (SizeOption.Sticker == sizeOption) BackgroundOption.None else backgroundOption, + backgroundColor = backgroundColor, + showSticker = showSticker ) } } diff --git a/feature/results/src/main/java/com/android/developers/androidify/customize/CustomizeTool.kt b/feature/results/src/main/java/com/android/developers/androidify/customize/CustomizeTool.kt index d749c78c..91604f6c 100644 --- a/feature/results/src/main/java/com/android/developers/androidify/customize/CustomizeTool.kt +++ b/feature/results/src/main/java/com/android/developers/androidify/customize/CustomizeTool.kt @@ -35,11 +35,13 @@ sealed class SizeOption( override val key: String, ) : ToolOption { - object Square : SizeOption(1f, Size(1000f, 1000f), "1:1", "square") + object Square : SizeOption(1f, Size(2000f, 2000f), "1:1", "square") object Banner : SizeOption(4f, Size(4000f, 1000f), "Banner", "banner") object Wallpaper : SizeOption(9 / 16f, Size(900f, 1600f), "Wallpaper", "wallpaper") object SocialHeader : SizeOption(3f, Size(3000f, 1000f), "3:1", "social_header") object WallpaperTablet : SizeOption(1280 / 800f, Size(1280f, 800f), "Large wallpaper", "wallpaper_large") + + object Sticker: SizeOption(1f, Size(2000f, 2000f), "Sticker", "sticker") } sealed class BackgroundOption( 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 05e7f926..83ba0ca4 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 @@ -74,7 +74,16 @@ fun ImageResult( exportImageCanvas, modifier = Modifier.fillMaxSize(), ) { - if (exportImageCanvas.imageBitmap != null) { + if (exportImageCanvas.showSticker && + exportImageCanvas.imageBitmapRemovedBackground != null) { + Image( + bitmap = exportImageCanvas.imageBitmapRemovedBackground.asImageBitmap(), + modifier = Modifier + .fillMaxSize(), + contentScale = ContentScale.Crop, + contentDescription = null, + ) + } else if (exportImageCanvas.imageBitmap != null) { Image( bitmap = exportImageCanvas.imageBitmap.asImageBitmap(), modifier = Modifier @@ -94,9 +103,14 @@ fun BackgroundLayout( modifier: Modifier = Modifier, content: @Composable () -> Unit, ) { + val backgroundModifier = if (exportImageCanvas.backgroundColor != null) { + Modifier.background(exportImageCanvas.backgroundColor) + } else { + Modifier + } Box( modifier = modifier.fillMaxSize() - .background(Color.White), + .then(backgroundModifier), ) { if (exportImageCanvas.selectedBackgroundDrawable != null) { Image( diff --git a/feature/results/src/main/res/drawable-nodpi/sticker_size.png b/feature/results/src/main/res/drawable-nodpi/sticker_size.png new file mode 100644 index 0000000000000000000000000000000000000000..17824400361b33ff761230793e2336f5250e8e9c GIT binary patch literal 7922 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 Date: Fri, 8 Aug 2025 15:03:30 +0100 Subject: [PATCH 02/11] Add Sticker feature to remove background of image --- app/src/main/AndroidManifest.xml | 4 + .../BaselineProfileGenerator.kt | 4 +- core/network/build.gradle.kts | 1 + .../developers/androidify/di/NetworkModule.kt | 6 ++ .../ondevice/LocalSegmentationDataSource.kt | 54 +++++++++++ .../startup/FirebaseAppCheckInitializer.kt | 2 +- .../theme/components/Backgrounds.kt | 1 - .../data/ImageGenerationRepository.kt | 8 ++ .../developers/androidify/home/HomeScreen.kt | 81 +++++++++-------- .../androidify/customize/AspectRatioTool.kt | 5 +- .../androidify/customize/BackgroundTool.kt | 2 +- .../customize/CustomizeExportScreen.kt | 20 +++-- .../customize/CustomizeExportViewModel.kt | 89 +++++++++++++------ .../androidify/customize/CustomizeState.kt | 6 +- .../androidify/customize/CustomizeTool.kt | 38 ++++---- .../androidify/customize/GenericTool.kt | 7 +- .../androidify/customize/ImageRenderer.kt | 4 +- .../customize/CustomizeStateTest.kt | 2 +- .../customize/CustomizeViewModelTest.kt | 8 +- 19 files changed, 224 insertions(+), 118 deletions(-) create mode 100644 core/network/src/main/java/com/android/developers/androidify/ondevice/LocalSegmentationDataSource.kt diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index 800707ec..925511b3 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -87,6 +87,10 @@ + + \ No newline at end of file diff --git a/benchmark/src/main/kotlin/com/android/developers/androidify/baselineprofile/BaselineProfileGenerator.kt b/benchmark/src/main/kotlin/com/android/developers/androidify/baselineprofile/BaselineProfileGenerator.kt index c6f58951..0bb74708 100644 --- a/benchmark/src/main/kotlin/com/android/developers/androidify/baselineprofile/BaselineProfileGenerator.kt +++ b/benchmark/src/main/kotlin/com/android/developers/androidify/baselineprofile/BaselineProfileGenerator.kt @@ -46,8 +46,8 @@ class BaselineProfileGenerator { uiAutomator { startApp(packageName = packageName) onElement { textAsString() == "Let's Go" }.click() - onElement{ textAsString() == "Prompt" }.click() - onElement{ isEditable }.apply { + onElement { textAsString() == "Prompt" }.click() + onElement { isEditable }.apply { click() text = "wearing brown sneakers, a red t-shirt, " + diff --git a/core/network/build.gradle.kts b/core/network/build.gradle.kts index 12c459fa..7ff4211a 100644 --- a/core/network/build.gradle.kts +++ b/core/network/build.gradle.kts @@ -79,6 +79,7 @@ dependencies { implementation(libs.firebase.config) implementation(projects.core.util) implementation(libs.firebase.config.ktx) + implementation(libs.mlkit.segmentation) ksp(libs.hilt.compiler) androidTestImplementation(libs.androidx.ui.test.junit4) diff --git a/core/network/src/main/java/com/android/developers/androidify/di/NetworkModule.kt b/core/network/src/main/java/com/android/developers/androidify/di/NetworkModule.kt index 1777cd07..610bdeb8 100644 --- a/core/network/src/main/java/com/android/developers/androidify/di/NetworkModule.kt +++ b/core/network/src/main/java/com/android/developers/androidify/di/NetworkModule.kt @@ -24,6 +24,8 @@ import coil3.network.okhttp.OkHttpNetworkFetcherFactory import coil3.request.CachePolicy import coil3.request.crossfade import com.android.developers.androidify.network.BuildConfig +import com.android.developers.androidify.ondevice.LocalSegmentationDataSource +import com.android.developers.androidify.ondevice.LocalSegmentationDataSourceImpl import dagger.Module import dagger.Provides import dagger.hilt.InstallIn @@ -92,6 +94,10 @@ internal class NetworkModule @Inject constructor() { .crossfade(true) .build() + @Provides + fun segmentationDataSource(): LocalSegmentationDataSource { + return LocalSegmentationDataSourceImpl() + } companion object { private const val TIMEOUT_SECONDS: Long = 120 } diff --git a/core/network/src/main/java/com/android/developers/androidify/ondevice/LocalSegmentationDataSource.kt b/core/network/src/main/java/com/android/developers/androidify/ondevice/LocalSegmentationDataSource.kt new file mode 100644 index 00000000..1fbf56ef --- /dev/null +++ b/core/network/src/main/java/com/android/developers/androidify/ondevice/LocalSegmentationDataSource.kt @@ -0,0 +1,54 @@ +/* + * 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.ondevice + +import android.graphics.Bitmap +import com.google.mlkit.vision.common.InputImage +import com.google.mlkit.vision.segmentation.subject.SubjectSegmentation +import com.google.mlkit.vision.segmentation.subject.SubjectSegmenterOptions +import kotlinx.coroutines.suspendCancellableCoroutine +import kotlin.coroutines.resume +import kotlin.coroutines.resumeWithException + +interface LocalSegmentationDataSource { + suspend fun removeBackground(bitmap: Bitmap): Bitmap +} + +class LocalSegmentationDataSourceImpl : LocalSegmentationDataSource { + + override suspend fun removeBackground(bitmap: Bitmap): Bitmap { + val image = InputImage.fromBitmap(bitmap, 0) + val options = SubjectSegmenterOptions.Builder() + .enableForegroundBitmap() + .build() + + val segmenter = SubjectSegmentation.getClient(options) + + return suspendCancellableCoroutine { continuation -> + segmenter.process(image) + .addOnSuccessListener { result -> + if (result.foregroundBitmap != null) { + continuation.resume(result.foregroundBitmap!!) + } else { + continuation.resumeWithException(Exception("Subject not found")) + } + } + .addOnFailureListener { e -> + continuation.resumeWithException(e) + } + } + } +} diff --git a/core/network/src/main/java/com/android/developers/androidify/startup/FirebaseAppCheckInitializer.kt b/core/network/src/main/java/com/android/developers/androidify/startup/FirebaseAppCheckInitializer.kt index 0a43a4be..1080d715 100644 --- a/core/network/src/main/java/com/android/developers/androidify/startup/FirebaseAppCheckInitializer.kt +++ b/core/network/src/main/java/com/android/developers/androidify/startup/FirebaseAppCheckInitializer.kt @@ -18,10 +18,10 @@ package com.android.developers.androidify.startup import android.annotation.SuppressLint import android.content.Context import androidx.startup.Initializer +import com.google.firebase.Firebase import com.google.firebase.appcheck.FirebaseAppCheck import com.google.firebase.appcheck.appCheck import com.google.firebase.appcheck.playintegrity.PlayIntegrityAppCheckProviderFactory -import com.google.firebase.Firebase /** * Initialize [FirebaseAppCheck] using the App Startup Library. diff --git a/core/theme/src/main/java/com/android/developers/androidify/theme/components/Backgrounds.kt b/core/theme/src/main/java/com/android/developers/androidify/theme/components/Backgrounds.kt index 89810242..976546d2 100644 --- a/core/theme/src/main/java/com/android/developers/androidify/theme/components/Backgrounds.kt +++ b/core/theme/src/main/java/com/android/developers/androidify/theme/components/Backgrounds.kt @@ -42,7 +42,6 @@ import com.android.developers.androidify.theme.AndroidifyTheme import com.android.developers.androidify.theme.R import com.android.developers.androidify.util.LargeScreensPreview import com.android.developers.androidify.util.PhonePreview -import com.android.developers.androidify.util.backgroundRepeatX import com.android.developers.androidify.util.dpToPx import com.android.developers.androidify.util.isAtLeastMedium diff --git a/data/src/main/java/com/android/developers/androidify/data/ImageGenerationRepository.kt b/data/src/main/java/com/android/developers/androidify/data/ImageGenerationRepository.kt index 710f5d9e..c221d54c 100644 --- a/data/src/main/java/com/android/developers/androidify/data/ImageGenerationRepository.kt +++ b/data/src/main/java/com/android/developers/androidify/data/ImageGenerationRepository.kt @@ -21,6 +21,7 @@ import android.net.Uri import android.util.Log import com.android.developers.androidify.model.ValidatedDescription import com.android.developers.androidify.model.ValidatedImage +import com.android.developers.androidify.ondevice.LocalSegmentationDataSource import com.android.developers.androidify.util.LocalFileProvider import com.android.developers.androidify.vertexai.FirebaseAiDataSource import java.io.File @@ -36,6 +37,8 @@ interface ImageGenerationRepository { suspend fun saveImageToExternalStorage(imageBitmap: Bitmap): Uri suspend fun saveImageToExternalStorage(imageUri: Uri): Uri suspend fun generateImageWithEdit(image: Bitmap, editPrompt: String): Bitmap + + suspend fun removeBackground(image: Bitmap): Bitmap } @Singleton @@ -44,6 +47,7 @@ internal class ImageGenerationRepositoryImpl @Inject constructor( private val internetConnectivityManager: InternetConnectivityManager, private val geminiNanoDataSource: GeminiNanoGenerationDataSource, private val firebaseAiDataSource: FirebaseAiDataSource, + private val localSegmentationDataSource: LocalSegmentationDataSource, ) : ImageGenerationRepository { override suspend fun initialize() { @@ -129,4 +133,8 @@ internal class ImageGenerationRepositoryImpl @Inject constructor( override suspend fun generateImageWithEdit(image: Bitmap, editPrompt: String): Bitmap { return firebaseAiDataSource.generateImageWithEdit(image, editPrompt) } + + override suspend fun removeBackground(image: Bitmap): Bitmap { + return localSegmentationDataSource.removeBackground(image) + } } diff --git a/feature/home/src/main/java/com/android/developers/androidify/home/HomeScreen.kt b/feature/home/src/main/java/com/android/developers/androidify/home/HomeScreen.kt index c2764c03..b42e157e 100644 --- a/feature/home/src/main/java/com/android/developers/androidify/home/HomeScreen.kt +++ b/feature/home/src/main/java/com/android/developers/androidify/home/HomeScreen.kt @@ -461,7 +461,7 @@ private fun DancingBot( Image( painter = painterResource(id = R.drawable.dancing_droid_gif_placeholder), contentDescription = null, - modifier = modifier + modifier = modifier, ) } else { AsyncImage( @@ -554,49 +554,48 @@ private fun VideoPlayer( } } + var videoFullyOnScreen by remember { mutableStateOf(false) } + val isWindowOccluded = LocalOcclusion.current + Box( + Modifier + .background(MaterialTheme.colorScheme.surfaceContainerLowest) + .onVisibilityChanged( + minDurationMs = 100, + minFractionVisible = 1f, + ) { fullyVisible -> videoFullyOnScreen = fullyVisible } + .then(modifier), + ) { + player?.let { currentPlayer -> + LaunchedEffect(videoFullyOnScreen, LocalOcclusion.current.value) { + if (videoFullyOnScreen && !isWindowOccluded.value) currentPlayer.play() else currentPlayer.pause() + } - var videoFullyOnScreen by remember { mutableStateOf(false) } - val isWindowOccluded = LocalOcclusion.current - Box( - Modifier - .background(MaterialTheme.colorScheme.surfaceContainerLowest) - .onVisibilityChanged( - minDurationMs = 100, - minFractionVisible = 1f, - ) { fullyVisible -> videoFullyOnScreen = fullyVisible } - .then(modifier), - ) { - player?.let { currentPlayer -> - LaunchedEffect(videoFullyOnScreen, LocalOcclusion.current.value) { - if (videoFullyOnScreen && !isWindowOccluded.value) currentPlayer.play() else currentPlayer.pause() - } - - // Render the video - PlayerSurface(currentPlayer, surfaceType = SURFACE_TYPE_TEXTURE_VIEW) + // Render the video + PlayerSurface(currentPlayer, surfaceType = SURFACE_TYPE_TEXTURE_VIEW) - // Show a play / pause button - val playPauseButtonState = rememberPlayPauseButtonState(currentPlayer) - OutlinedIconButton( - onClick = playPauseButtonState::onClick, - enabled = playPauseButtonState.isEnabled, - modifier = Modifier - .align(Alignment.BottomEnd) - .padding(16.dp), - colors = IconButtonDefaults.outlinedIconButtonColors( - containerColor = MaterialTheme.colorScheme.surfaceContainerLowest, - disabledContainerColor = MaterialTheme.colorScheme.surfaceContainerLowest, - ), - ) { - val icon = - if (playPauseButtonState.showPlay) R.drawable.rounded_play_arrow_24 else R.drawable.rounded_pause_24 - val contentDescription = - if (playPauseButtonState.showPlay) R.string.play else R.string.pause - Icon( - painterResource(icon), - stringResource(contentDescription), - ) + // Show a play / pause button + val playPauseButtonState = rememberPlayPauseButtonState(currentPlayer) + OutlinedIconButton( + onClick = playPauseButtonState::onClick, + enabled = playPauseButtonState.isEnabled, + modifier = Modifier + .align(Alignment.BottomEnd) + .padding(16.dp), + colors = IconButtonDefaults.outlinedIconButtonColors( + containerColor = MaterialTheme.colorScheme.surfaceContainerLowest, + disabledContainerColor = MaterialTheme.colorScheme.surfaceContainerLowest, + ), + ) { + val icon = + if (playPauseButtonState.showPlay) R.drawable.rounded_play_arrow_24 else R.drawable.rounded_pause_24 + val contentDescription = + if (playPauseButtonState.showPlay) R.string.play else R.string.pause + Icon( + painterResource(icon), + stringResource(contentDescription), + ) + } } } } } -} diff --git a/feature/results/src/main/java/com/android/developers/androidify/customize/AspectRatioTool.kt b/feature/results/src/main/java/com/android/developers/androidify/customize/AspectRatioTool.kt index 634ba945..2cea51be 100644 --- a/feature/results/src/main/java/com/android/developers/androidify/customize/AspectRatioTool.kt +++ b/feature/results/src/main/java/com/android/developers/androidify/customize/AspectRatioTool.kt @@ -63,7 +63,7 @@ fun AspectRatioTool( .aspectRatio(tool.aspectRatio) .padding(6.dp) .fillMaxSize(), - contentAlignment = Alignment.Center + contentAlignment = Alignment.Center, ) { Image(painterResource(R.drawable.sticker_size), contentDescription = null) } @@ -86,7 +86,6 @@ fun AspectRatioTool( .background(MaterialTheme.colorScheme.surfaceBright), ) } - } }, ) @@ -103,7 +102,7 @@ private fun AspectRatioToolPreview() { SizeOption.SocialHeader, SizeOption.Wallpaper, SizeOption.WallpaperTablet, - SizeOption.Sticker + SizeOption.Sticker, ), selectedOption = SizeOption.Square, onSizeOptionSelected = {}, diff --git a/feature/results/src/main/java/com/android/developers/androidify/customize/BackgroundTool.kt b/feature/results/src/main/java/com/android/developers/androidify/customize/BackgroundTool.kt index bd50a4b8..89988fcb 100644 --- a/feature/results/src/main/java/com/android/developers/androidify/customize/BackgroundTool.kt +++ b/feature/results/src/main/java/com/android/developers/androidify/customize/BackgroundTool.kt @@ -125,7 +125,7 @@ private fun BackgroundToolPreview() { BackgroundOption.GreenThumb, BackgroundOption.Gamer, BackgroundOption.Jetsetter, - BackgroundOption.Chef + BackgroundOption.Chef, ), selectedOption = BackgroundOption.Lightspeed, onBackgroundOptionSelected = {}, 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 d5a89918..88cb8b70 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 @@ -166,14 +166,18 @@ private fun CustomizeExportContents( ) { paddingValues -> val imageResult = remember(state.showImageEditProgress) { movableContentWithReceiverOf { - val chromeModifier = if (this.showSticker) Modifier else Modifier.dropShadow( - RoundedCornerShape(6), - shadow = Shadow( - radius = 26.dp, - spread = 10.dp, - color = MaterialTheme.colorScheme.inverseSurface.copy(alpha = 0.2f), - ), - ).clip(RoundedCornerShape(6)) + val chromeModifier = if (this.showSticker) { + Modifier + } else { + Modifier.dropShadow( + RoundedCornerShape(6), + shadow = Shadow( + radius = 26.dp, + spread = 10.dp, + color = MaterialTheme.colorScheme.inverseSurface.copy(alpha = 0.2f), + ), + ).clip(RoundedCornerShape(6)) + } Box( Modifier .padding(16.dp), 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 f27885c2..4ee0ccdc 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 @@ -24,32 +24,25 @@ import androidx.compose.material3.SnackbarHostState import androidx.compose.ui.Modifier import androidx.lifecycle.AndroidViewModel import androidx.lifecycle.viewModelScope -import com.android.developers.androidify.data.DataModule_Companion_IoDispatcherFactory.ioDispatcher import com.android.developers.androidify.data.ImageGenerationRepository -import com.android.developers.androidify.segmentation.SubjectSegmentationHelper import dagger.hilt.android.lifecycle.HiltViewModel -import kotlinx.coroutines.CoroutineDispatcher -import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.update import kotlinx.coroutines.launch import javax.inject.Inject -import javax.inject.Named @HiltViewModel class CustomizeExportViewModel @Inject constructor( val imageGenerationRepository: ImageGenerationRepository, val composableBitmapRenderer: ComposableBitmapRenderer, - private val subjectSegmentationHelper: SubjectSegmentationHelper, application: Application, ) : AndroidViewModel(application) { private val _state = MutableStateFlow(CustomizeExportState()) val state = _state.asStateFlow() - private var _snackbarHostState = MutableStateFlow(SnackbarHostState()) val snackbarHostState: StateFlow @@ -98,6 +91,24 @@ class CustomizeExportViewModel @Inject constructor( } } + private fun triggerStickerBackgroundRemoval(bitmap: Bitmap) { + viewModelScope.launch { + val stickerBitmap = imageGenerationRepository.removeBackground( + bitmap, + ) + _state.update { + it.copy( + showImageEditProgress = false, + exportImageCanvas = it.exportImageCanvas.copy(imageBitmapRemovedBackground = stickerBitmap) + .updateAspectRatioAndBackground( + it.exportImageCanvas.selectedBackgroundOption, + SizeOption.Sticker, + ), + ) + } + } + } + fun selectedToolStateChanged(toolState: ToolState) { when (toolState.selectedToolOption) { is BackgroundOption -> { @@ -123,33 +134,57 @@ class CustomizeExportViewModel @Inject constructor( } is SizeOption -> { if (toolState.selectedToolOption is SizeOption.Sticker) { - // TODO kick this off to a background thread and return the - // state immediately with loading to ensure that the UI is updated val bitmap = state.value.exportImageCanvas.imageBitmap if (bitmap != null) { - val stickerBitmap = - if (state.value.exportImageCanvas.imageBitmapRemovedBackground == null) - subjectSegmentationHelper.removeBackground( - bitmap, + if (state.value.exportImageCanvas.imageBitmapRemovedBackground == null) { + _state.update { + it.copy( + toolState = it.toolState + (it.selectedTool to toolState), + showImageEditProgress = true, + exportImageCanvas = it.exportImageCanvas + .updateAspectRatioAndBackground( + it.exportImageCanvas.selectedBackgroundOption, + SizeOption.Sticker, + ), ) - else state.value.exportImageCanvas.imageBitmapRemovedBackground - - it.exportImageCanvas.copy(imageBitmapRemovedBackground = stickerBitmap) - .updateAspectRatioAndBackground( + } + triggerStickerBackgroundRemoval(bitmap) + } else { + _state.update { + it.copy( + toolState = it.toolState + (it.selectedTool to toolState), + showImageEditProgress = false, + exportImageCanvas = it.exportImageCanvas + .updateAspectRatioAndBackground( + it.exportImageCanvas.selectedBackgroundOption, + (toolState.selectedToolOption as SizeOption), + ), + ) + } + } + } else { + _state.update { + it.copy( + toolState = it.toolState + (it.selectedTool to toolState), + showImageEditProgress = false, + exportImageCanvas = it.exportImageCanvas.updateAspectRatioAndBackground( + it.exportImageCanvas.selectedBackgroundOption, + (toolState.selectedToolOption as SizeOption), + ), + ) + } + } + } else { + _state.update { + it.copy( + toolState = it.toolState + (it.selectedTool to toolState), + showImageEditProgress = false, + exportImageCanvas = it.exportImageCanvas.updateAspectRatioAndBackground( it.exportImageCanvas.selectedBackgroundOption, (toolState.selectedToolOption as SizeOption), - ) - } else { - it.exportImageCanvas.updateAspectRatioAndBackground( - it.exportImageCanvas.selectedBackgroundOption, - (toolState.selectedToolOption as SizeOption), + ), ) } - } else { - it.exportImageCanvas.updateAspectRatioAndBackground( - it.exportImageCanvas.selectedBackgroundOption, - (toolState.selectedToolOption as SizeOption), - ) } } else -> throw IllegalArgumentException("Unknown tool option") 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 191fffe3..07319489 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 @@ -70,7 +70,7 @@ data class BackgroundToolState( BackgroundOption.GreenThumb, BackgroundOption.Gamer, BackgroundOption.Jetsetter, - BackgroundOption.Chef + BackgroundOption.Chef, ), ) : ToolState @@ -220,10 +220,10 @@ data class ExportImageCanvas( SizeOption.Sticker -> { offset = Offset(0f, 0f) imageSize = Size(newCanvasSize.width, newCanvasSize.height) - image = null rotation = 0f backgroundColor = null showSticker = true + image = null } } return copy( @@ -235,7 +235,7 @@ data class ExportImageCanvas( aspectRatioOption = sizeOption, selectedBackgroundOption = if (SizeOption.Sticker == sizeOption) BackgroundOption.None else backgroundOption, backgroundColor = backgroundColor, - showSticker = showSticker + showSticker = showSticker, ) } } diff --git a/feature/results/src/main/java/com/android/developers/androidify/customize/CustomizeTool.kt b/feature/results/src/main/java/com/android/developers/androidify/customize/CustomizeTool.kt index 26b99881..4eb134ac 100644 --- a/feature/results/src/main/java/com/android/developers/androidify/customize/CustomizeTool.kt +++ b/feature/results/src/main/java/com/android/developers/androidify/customize/CustomizeTool.kt @@ -41,7 +41,7 @@ sealed class SizeOption( object SocialHeader : SizeOption(3f, Size(3000f, 1000f), "3:1", "social_header") object WallpaperTablet : SizeOption(1280 / 800f, Size(1280f, 800f), "Large wallpaper", "wallpaper_large") - object Sticker: SizeOption(1f, Size(2000f, 2000f), "Sticker", "sticker") + object Sticker : SizeOption(1f, Size(2000f, 2000f), "Sticker", "sticker") } sealed class BackgroundOption( @@ -63,7 +63,7 @@ sealed class BackgroundOption( "IO", R.drawable.background_option_io, ) - object MusicLover: BackgroundOption( + object MusicLover : BackgroundOption( "Music lover", "music", R.drawable.background_option_music_lover, @@ -78,7 +78,7 @@ sealed class BackgroundOption( Crucially, the scene is captured from a very low camera angle, almost at floor level, significantly zoomed out to showcase a much wider view of the room/setup. This low perspective emphasizes the expanse of the floor, which appears to stretch far into the distance, creating a profound sense of depth and length. The objects in the scene should appear much smaller in relation to the overall composition. Place the scene items, to the edges of the composition, so that when an object is placed in the middle of the foreground, it does not completely cover what's behind it. - """.trimIndent() + """.trimIndent(), ) object PoolMaven : BackgroundOption( "Pool maven", @@ -97,7 +97,7 @@ sealed class BackgroundOption( Crucially, the scene is captured from a very low camera angle, almost at floor level, significantly zoomed out to showcase a much wider view of the room/setup. This low perspective emphasizes the expanse of the floor, which appears to stretch far into the distance, creating a profound sense of depth and length. The objects in the scene should appear much smaller in relation to the overall composition. The foreground has the same pink tiles that surround the pool. Place the scene items, to the edges of the composition, so that when an object is placed in the middle of the foreground, it does not completely cover what's behind it. - """.trimIndent() + """.trimIndent(), ) object SoccerFanatic : BackgroundOption( @@ -121,9 +121,9 @@ sealed class BackgroundOption( Crucially, the scene is captured from a very low camera angle, almost at floor level, significantly zoomed out to showcase a much wider view of the room/setup. This low perspective emphasizes the expanse of the floor, which appears to stretch far into the distance, creating a profound sense of depth and length. The objects in the scene should appear much smaller in relation to the overall composition. Place the scene items, to the edges of the composition, so that when an object is placed in the middle of the foreground, it does not completely cover what's behind it. - """.trimIndent() + """.trimIndent(), ) - object StarGazer: BackgroundOption( + object StarGazer : BackgroundOption( "StarGazer", "star", R.drawable.background_option_stargazer, @@ -138,10 +138,10 @@ sealed class BackgroundOption( Crucially, the scene is captured from a very low camera angle, almost at floor level, significantly zoomed out to showcase a much wider view of the room/setup. This low perspective emphasizes the expanse of the floor, which appears to stretch far into the distance, creating a profound sense of depth and length. The objects in the scene should appear much smaller in relation to the overall composition. Place the scene items, to the edges of the composition, so that when an object is placed in the middle of the foreground, it does not completely cover what's behind it. - """.trimIndent() + """.trimIndent(), ) - object FitnessBuff: BackgroundOption( + object FitnessBuff : BackgroundOption( "Fitness buff", "fitness", R.drawable.background_option_fitness, @@ -156,10 +156,10 @@ sealed class BackgroundOption( Captured from a very low, zoomed-out angle, the scene emphasizes the vast aerobics stage and its contents, creating profound depth. Objects appear much smaller relative to the wide composition, subtly placed at the edges to leave the center foreground clear. Make sure that no characters appear in the scene, and that no objects are given eyes and mouths. There should be a clear horizon line close to the center of the composition where the floor meets the back wall. Crucially, the scene is captured from a very low camera angle, almost at floor level, significantly zoomed out to showcase a much wider view of the aerobics stage and its contents. This low perspective emphasizes the expanse of the stage's surface, which appears to stretch far into the distance, creating a profound sense of depth and length. Place the scene items towards the edges of the composition, ensuring that the vast middle foreground remains clear and open. - """.trimIndent() + """.trimIndent(), ) - object Fandroid: BackgroundOption( + object Fandroid : BackgroundOption( "Fandroid", "fandroid", R.drawable.background_option_fandroid, @@ -180,10 +180,10 @@ sealed class BackgroundOption( Crucially, the scene is captured from a very low camera angle, almost at floor level, significantly zoomed out to showcase a much wider view of the room/setup. This low perspective emphasizes the expanse of the floor, which appears to stretch far into the distance, creating a profound sense of depth and length. The objects in the scene should appear much smaller in relation to the overall composition. Place the scene items, to the edges of the composition, so that when an object is placed in the middle of the foreground, it does not completely cover what's behind it. - """.trimIndent() + """.trimIndent(), ) - object GreenThumb: BackgroundOption( + object GreenThumb : BackgroundOption( displayName = "Green thumb", key = "green_thumb", previewDrawableInt = R.drawable.background_option_greenthumb, @@ -198,10 +198,10 @@ sealed class BackgroundOption( Crucially, the scene is captured from a very low camera angle, almost at ground level, significantly zoomed out to showcase a much wider view of the space. This low perspective emphasizes the expanse of the ground, which appears to stretch far into the distance, creating a profound sense of depth and length. The objects in the scene should appear much smaller in relation to the overall composition. Place the scene items, to the edges of the composition, so that when an object is placed in the middle of the foreground, it does not completely cover what's behind it. - """.trimIndent() + """.trimIndent(), ) - object Gamer: BackgroundOption( + object Gamer : BackgroundOption( displayName = "Gamer", key = "gamer", previewDrawableInt = R.drawable.background_option_gamer, @@ -216,9 +216,9 @@ sealed class BackgroundOption( Crucially, the scene is captured from a very low camera angle, almost at floor level, significantly zoomed out to showcase a much wider view of the room/setup. This low perspective emphasizes the expanse of the floor, which appears to stretch far into the distance, creating a profound sense of depth and length. The objects in the scene should appear much smaller in relation to the overall composition. Place the scene items, to the edges of the composition, so that when an object is placed in the middle of the foreground, it does not completely cover what's behind it. - """.trimIndent() + """.trimIndent(), ) - object Jetsetter: BackgroundOption( + object Jetsetter : BackgroundOption( displayName = "Jetsetter", key = "jetsetter", previewDrawableInt = R.drawable.background_option_jetsetter, @@ -231,10 +231,10 @@ sealed class BackgroundOption( A collection of brightly colored, stylized luggage — including vibrant bags, deep blue cloud-shaped carry-ons, and sunny yellow star-shaped duffel bags — sit neatly arranged. The foreground is a deliberate blank space, with only the clean, subtly glowing cloud floor visible, offering an open area for a future character or object to be added, perhaps a traveler waiting for their destination. The overall atmosphere is serene and anticipatory, with the warm, ethereal light creating long, dynamic shadows that enhance the 3D rendering. Crucially, the scene is captured from a very low camera angle, almost at ground level, significantly zoomed out to showcase a much wider view of the waiting area. This low perspective emphasizes the expansive cloud station, creating a profound sense of depth and scale. The area above the horizon line is clean, open, and uncluttered, emphasizing the vastness of the sky. The items and the colorful luggage appear smaller in relation to the overall composition, positioned slightly to the edges to allow the sweeping view of the planes and sky above, ensuring that the middle foreground remains clear and open. - """.trimIndent() + """.trimIndent(), ) - object Chef: BackgroundOption( + object Chef : BackgroundOption( "Masterchef", "chef", R.drawable.background_option_chef, @@ -249,6 +249,6 @@ sealed class BackgroundOption( Captured from a very low, zoomed-out angle, the scene emphasizes the vast work surface and its contents, creating profound depth. Objects appear much smaller relative to the wide composition, subtly placed at the edges to leave the center foreground and upper area clear, creating a parting in the middle of the scene. No characters appear, and no objects are given eyes and mouths. Crucially, the scene is captured from a very low camera angle, almost at floor level, significantly zoomed out to showcase a much wider view of the work surface and its contents. This low perspective emphasizes the expanse of the surface, which appears to stretch far into the distance, creating a profound sense of depth and length. Place the scene items towards the edges of the composition, ensuring that the vast middle foreground remains clear and open. - """.trimIndent() + """.trimIndent(), ) } diff --git a/feature/results/src/main/java/com/android/developers/androidify/customize/GenericTool.kt b/feature/results/src/main/java/com/android/developers/androidify/customize/GenericTool.kt index d286c83c..1311ee6f 100644 --- a/feature/results/src/main/java/com/android/developers/androidify/customize/GenericTool.kt +++ b/feature/results/src/main/java/com/android/developers/androidify/customize/GenericTool.kt @@ -42,7 +42,6 @@ import androidx.compose.ui.layout.ContentScale 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.max import coil3.compose.rememberAsyncImagePainter import com.android.developers.androidify.theme.AndroidifyTheme @@ -117,8 +116,8 @@ fun GenericToolButton( maxLines = 1, modifier = Modifier.basicMarquee( repeatDelayMillis = 0, - iterations = 300 - ) + iterations = 300, + ), ) } } @@ -146,7 +145,7 @@ private fun GenericToolPreview() { BackgroundOption.GreenThumb, BackgroundOption.Gamer, BackgroundOption.Jetsetter, - BackgroundOption.Chef + BackgroundOption.Chef, ), singleLine = false, selectedOption = BackgroundOption.Lightspeed, 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 d11e51a5..38b18f12 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 @@ -37,7 +37,6 @@ import androidx.compose.ui.draw.clipToBounds import androidx.compose.ui.draw.rotate import androidx.compose.ui.geometry.Rect import androidx.compose.ui.geometry.Size -import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.ImageBitmap import androidx.compose.ui.graphics.asAndroidBitmap import androidx.compose.ui.graphics.asImageBitmap @@ -75,7 +74,8 @@ fun ImageResult( modifier = Modifier.fillMaxSize(), ) { if (exportImageCanvas.showSticker && - exportImageCanvas.imageBitmapRemovedBackground != null) { + exportImageCanvas.imageBitmapRemovedBackground != null + ) { Image( bitmap = exportImageCanvas.imageBitmapRemovedBackground.asImageBitmap(), modifier = Modifier 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 18428791..c26aa7a9 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 { BackgroundOption.GreenThumb, BackgroundOption.Gamer, BackgroundOption.Jetsetter, - BackgroundOption.Chef + BackgroundOption.Chef, ), state.options, ) 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 e34733ad..c08d4f77 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 @@ -23,7 +23,6 @@ import androidx.test.core.app.ApplicationProvider import com.android.developers.testing.repository.FakeImageGenerationRepository import com.android.developers.testing.util.FakeComposableBitmapRenderer import com.android.developers.testing.util.MainDispatcherRule -import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.launch import kotlinx.coroutines.test.UnconfinedTestDispatcher @@ -35,7 +34,6 @@ import org.junit.Rule import org.junit.Test import org.junit.runner.RunWith import org.robolectric.RobolectricTestRunner -import kotlin.test.assertContains import kotlin.test.assertFalse import kotlin.test.assertNotNull import kotlin.test.assertNull @@ -165,13 +163,13 @@ class CustomizeViewModelTest { options = listOf( BackgroundOption.None, BackgroundOption.IO, - BackgroundOption.Chef + BackgroundOption.Chef, ), ), ) advanceUntilIdle() assertFalse { values[values.lastIndex].showImageEditProgress } - // assertTrue(values.any { it.showImageEditProgress }) + // assertTrue(values.any { it.showImageEditProgress }) assertNotNull(values.last().exportImageCanvas.imageWithEdit) } @@ -195,7 +193,7 @@ class CustomizeViewModelTest { options = listOf( BackgroundOption.None, BackgroundOption.IO, - BackgroundOption.Chef + BackgroundOption.Chef, ), ), ) From a15102f8efad192def000a3d70bf52953ca79707 Mon Sep 17 00:00:00 2001 From: Rebecca Franks Date: Fri, 8 Aug 2025 15:11:57 +0100 Subject: [PATCH 03/11] Add Sticker feature to remove background of image --- .../developers/androidify/customize/CustomizeExportScreen.kt | 3 +-- .../androidify/customize/CustomizeExportViewModel.kt | 3 +-- 2 files changed, 2 insertions(+), 4 deletions(-) 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 88cb8b70..51f654af 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 @@ -184,8 +184,7 @@ private fun CustomizeExportContents( ) { ImageResult( this@movableContentWithReceiverOf, - modifier = Modifier - .padding(16.dp), + modifier = Modifier, outerChromeModifier = Modifier .then(chromeModifier) .loadingShimmerOverlay( 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 4ee0ccdc..b8ab4163 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 @@ -66,8 +66,7 @@ class CustomizeExportViewModel @Inject constructor( } fun shareClicked() { - viewModelScope.launch { - val exportImageCanvas = state.value.exportImageCanvas + viewModelScope.launch { val exportImageCanvas = state.value.exportImageCanvas val resultBitmap = composableBitmapRenderer.renderComposableToBitmap(exportImageCanvas.canvasSize) { ImageResult( From 7875242a9f93e054638de2e08fd116dd05f0ea74 Mon Sep 17 00:00:00 2001 From: Rebecca Franks Date: Fri, 8 Aug 2025 15:17:28 +0100 Subject: [PATCH 04/11] Fix background clipping --- .../developers/androidify/customize/ImageRenderer.kt | 8 +------- 1 file changed, 1 insertion(+), 7 deletions(-) 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 38b18f12..caba1117 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 @@ -172,15 +172,9 @@ fun BackgroundLayout( .then(safeAnimateBounds) .rotate(rotationAnimation), ) { - val clip = if (exportImageCanvas.selectedBackgroundOption == BackgroundOption.None) { - Modifier - } else { - Modifier.clip(RoundedCornerShape(6)) - } Box( modifier = Modifier - .fillMaxSize() - .then(clip), + .fillMaxSize(), contentAlignment = Alignment.Center, ) { content() From c90dbadfc5f6cf856c8ebe3ce1bcd0b8714a6560 Mon Sep 17 00:00:00 2001 From: Rebecca Franks Date: Mon, 11 Aug 2025 23:38:29 +0100 Subject: [PATCH 05/11] Add downloading of the Subject Segmentation module on demand. --- app/src/main/AndroidManifest.xml | 6 +- core/network/build.gradle.kts | 10 ++ .../developers/androidify/di/NetworkModule.kt | 5 +- .../androidify/di/OnDeviceModule.kt | 34 ++++++ .../ondevice/LocalSegmentationDataSource.kt | 88 ++++++++++++++- .../customize/CustomizeExportViewModel.kt | 105 +++++++++--------- gradle/libs.versions.toml | 8 ++ 7 files changed, 192 insertions(+), 64 deletions(-) create mode 100644 core/network/src/main/java/com/android/developers/androidify/di/OnDeviceModule.kt diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index 925511b3..512e3e6a 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -88,9 +88,9 @@ android:name="com.google.android.gms.oss.licenses.OssLicensesActivity" android:theme="@style/AppCompatAndroidify" /> - + + + \ No newline at end of file diff --git a/core/network/build.gradle.kts b/core/network/build.gradle.kts index 7ff4211a..d5bce3f4 100644 --- a/core/network/build.gradle.kts +++ b/core/network/build.gradle.kts @@ -75,13 +75,23 @@ dependencies { implementation(libs.firebase.analytics) { exclude(group = "com.google.guava") } + implementation(libs.firebase.app.check) implementation(libs.firebase.config) implementation(projects.core.util) implementation(libs.firebase.config.ktx) implementation(libs.mlkit.segmentation) + implementation(libs.mlkit.common) + implementation(libs.play.services.base) ksp(libs.hilt.compiler) + testImplementation(libs.play.services.base.testing) + testImplementation(libs.junit) + testImplementation(libs.kotlinx.coroutines.test) + testImplementation(libs.robolectric) + testImplementation(libs.androidx.core) + +// Or the latest version androidTestImplementation(libs.androidx.ui.test.junit4) androidTestImplementation(libs.hilt.android.testing) androidTestImplementation(projects.core.testing) diff --git a/core/network/src/main/java/com/android/developers/androidify/di/NetworkModule.kt b/core/network/src/main/java/com/android/developers/androidify/di/NetworkModule.kt index 610bdeb8..cce9926d 100644 --- a/core/network/src/main/java/com/android/developers/androidify/di/NetworkModule.kt +++ b/core/network/src/main/java/com/android/developers/androidify/di/NetworkModule.kt @@ -26,6 +26,7 @@ import coil3.request.crossfade import com.android.developers.androidify.network.BuildConfig import com.android.developers.androidify.ondevice.LocalSegmentationDataSource import com.android.developers.androidify.ondevice.LocalSegmentationDataSourceImpl +import com.google.android.gms.common.moduleinstall.ModuleInstallClient import dagger.Module import dagger.Provides import dagger.hilt.InstallIn @@ -95,8 +96,8 @@ internal class NetworkModule @Inject constructor() { .build() @Provides - fun segmentationDataSource(): LocalSegmentationDataSource { - return LocalSegmentationDataSourceImpl() + fun segmentationDataSource(moduleInstallClient: ModuleInstallClient): LocalSegmentationDataSource { + return LocalSegmentationDataSourceImpl(moduleInstallClient) } companion object { private const val TIMEOUT_SECONDS: Long = 120 diff --git a/core/network/src/main/java/com/android/developers/androidify/di/OnDeviceModule.kt b/core/network/src/main/java/com/android/developers/androidify/di/OnDeviceModule.kt new file mode 100644 index 00000000..8a1ba206 --- /dev/null +++ b/core/network/src/main/java/com/android/developers/androidify/di/OnDeviceModule.kt @@ -0,0 +1,34 @@ +/* + * 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.di + +import android.content.Context +import com.google.android.gms.common.moduleinstall.ModuleInstall +import com.google.android.gms.common.moduleinstall.ModuleInstallClient +import dagger.Module +import dagger.Provides +import dagger.hilt.InstallIn +import dagger.hilt.android.qualifiers.ApplicationContext +import dagger.hilt.components.SingletonComponent + +@Module +@InstallIn(SingletonComponent::class) +object OnDeviceModule { + @Provides + fun provideModuleInstallClient(@ApplicationContext context: Context): ModuleInstallClient { + return ModuleInstall.getClient(context) + } +} diff --git a/core/network/src/main/java/com/android/developers/androidify/ondevice/LocalSegmentationDataSource.kt b/core/network/src/main/java/com/android/developers/androidify/ondevice/LocalSegmentationDataSource.kt index 1fbf56ef..5dfac6ca 100644 --- a/core/network/src/main/java/com/android/developers/androidify/ondevice/LocalSegmentationDataSource.kt +++ b/core/network/src/main/java/com/android/developers/androidify/ondevice/LocalSegmentationDataSource.kt @@ -16,28 +16,105 @@ package com.android.developers.androidify.ondevice import android.graphics.Bitmap +import android.print.PrintJobInfo.STATE_COMPLETED +import android.provider.SyncStateContract.Helpers.update +import android.util.Log +import coil3.util.CoilUtils.result +import com.google.android.gms.common.moduleinstall.InstallStatusListener +import com.google.android.gms.common.moduleinstall.ModuleInstallClient +import com.google.android.gms.common.moduleinstall.ModuleInstallRequest +import com.google.android.gms.common.moduleinstall.ModuleInstallStatusUpdate +import com.google.android.gms.common.moduleinstall.ModuleInstallStatusUpdate.InstallState +import com.google.android.gms.common.moduleinstall.ModuleInstallStatusUpdate.InstallState.STATE_CANCELED +import com.google.android.gms.common.moduleinstall.ModuleInstallStatusUpdate.InstallState.STATE_FAILED import com.google.mlkit.vision.common.InputImage import com.google.mlkit.vision.segmentation.subject.SubjectSegmentation import com.google.mlkit.vision.segmentation.subject.SubjectSegmenterOptions +import kotlinx.coroutines.Job +import javax.inject.Inject import kotlinx.coroutines.suspendCancellableCoroutine import kotlin.coroutines.resume import kotlin.coroutines.resumeWithException +import kotlin.coroutines.suspendCoroutine interface LocalSegmentationDataSource { suspend fun removeBackground(bitmap: Bitmap): Bitmap } -class LocalSegmentationDataSourceImpl : LocalSegmentationDataSource { - - override suspend fun removeBackground(bitmap: Bitmap): Bitmap { - val image = InputImage.fromBitmap(bitmap, 0) +class LocalSegmentationDataSourceImpl @Inject constructor( + private val moduleInstallClient: ModuleInstallClient +) : LocalSegmentationDataSource { + private val segmenter by lazy { val options = SubjectSegmenterOptions.Builder() .enableForegroundBitmap() .build() + SubjectSegmentation.getClient(options) + } + + private suspend fun isSubjectSegmentationModuleInstalled(): Boolean { + val areModulesAvailable = + suspendCancellableCoroutine { continuation -> + moduleInstallClient.areModulesAvailable(segmenter) + .addOnSuccessListener { + continuation.resume(it.areModulesAvailable()) + } + .addOnFailureListener { + continuation.resumeWithException(it) + } + } + return areModulesAvailable + } + + private suspend fun installSubjectSegmentationModule(): Boolean { + val result = suspendCancellableCoroutine { continuation -> + val listener = InstallStatusListener { update -> + Log.d("LocalSegmentationDataSource", "Download progress: ${update.installState}") + + if (update.installState == STATE_COMPLETED) { + continuation.resume(true) + } else if (update.installState == STATE_FAILED || update.installState == STATE_CANCELED) { + continuation.resumeWithException( + Exception("Module download failed or was canceled. State: ${update.installState}") + ) + } else { + Log.d("LocalSegmentationDataSource", "State update: ${update.installState}") + } + } + val moduleInstallRequest = ModuleInstallRequest.newBuilder() + .addApi(segmenter) + .setListener(listener) + .build() + + moduleInstallClient + .installModules(moduleInstallRequest) + .addOnFailureListener { + Log.e("LocalSegmentationDataSource", "Failed to download module", it) + } + .addOnCompleteListener { + Log.d("LocalSegmentationDataSource", "Successfully triggered download - await download progress updates") + } + + } + return result + } - val segmenter = SubjectSegmentation.getClient(options) + override suspend fun removeBackground(bitmap: Bitmap): Bitmap { + val areModulesAvailable = isSubjectSegmentationModuleInstalled() + if (!areModulesAvailable) { + Log.d("LocalSegmentationDataSource", "Modules not available - downloading") + val result = installSubjectSegmentationModule() + if (!result) { + throw Exception("Failed to download module") + } + } else { + Log.d("LocalSegmentationDataSource", "Modules available") + } + val image = InputImage.fromBitmap(bitmap, 0) return suspendCancellableCoroutine { continuation -> + continuation.invokeOnCancellation { cause -> + Log.d("LocalSegmentationDataSource", "Continuation was cancelled!", cause) + } segmenter.process(image) .addOnSuccessListener { result -> if (result.foregroundBitmap != null) { @@ -47,6 +124,7 @@ class LocalSegmentationDataSourceImpl : LocalSegmentationDataSource { } } .addOnFailureListener { e -> + Log.e("LocalSegmentationDataSource", "Exception while executing background removal", e) continuation.resumeWithException(e) } } 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 b8ab4163..4d5b1702 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 @@ -26,6 +26,7 @@ import androidx.lifecycle.AndroidViewModel import androidx.lifecycle.viewModelScope import com.android.developers.androidify.data.ImageGenerationRepository import dagger.hilt.android.lifecycle.HiltViewModel +import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.asStateFlow @@ -66,7 +67,8 @@ class CustomizeExportViewModel @Inject constructor( } fun shareClicked() { - viewModelScope.launch { val exportImageCanvas = state.value.exportImageCanvas + viewModelScope.launch { + val exportImageCanvas = state.value.exportImageCanvas val resultBitmap = composableBitmapRenderer.renderComposableToBitmap(exportImageCanvas.canvasSize) { ImageResult( @@ -90,20 +92,38 @@ class CustomizeExportViewModel @Inject constructor( } } - private fun triggerStickerBackgroundRemoval(bitmap: Bitmap) { + private fun triggerStickerBackgroundRemoval(bitmap: Bitmap, previousSizeOption: SizeOption) { viewModelScope.launch { - val stickerBitmap = imageGenerationRepository.removeBackground( - bitmap, - ) - _state.update { - it.copy( - showImageEditProgress = false, - exportImageCanvas = it.exportImageCanvas.copy(imageBitmapRemovedBackground = stickerBitmap) - .updateAspectRatioAndBackground( - it.exportImageCanvas.selectedBackgroundOption, - SizeOption.Sticker, - ), + try { + val stickerBitmap = imageGenerationRepository.removeBackground( + bitmap, ) + _state.update { + it.copy( + showImageEditProgress = false, + exportImageCanvas = it.exportImageCanvas.copy(imageBitmapRemovedBackground = stickerBitmap) + .updateAspectRatioAndBackground( + it.exportImageCanvas.selectedBackgroundOption, + SizeOption.Sticker, + ), + ) + } + } catch (exception: Exception) { + Log.e("CustomizeExportViewModel", "Background removal failed", exception) + snackbarHostState.value.showSnackbar("Background removal failed") + _state.update { + val aspectRatioToolState = (it.toolState[CustomizeTool.Size] as AspectRatioToolState) + .copy(selectedToolOption = previousSizeOption) + it.copy( + toolState = it.toolState + (CustomizeTool.Size to aspectRatioToolState), + showImageEditProgress = false, + exportImageCanvas = it.exportImageCanvas.copy(imageBitmapRemovedBackground = null) + .updateAspectRatioAndBackground( + it.exportImageCanvas.selectedBackgroundOption, + previousSizeOption, + ), + ) + } } } } @@ -132,47 +152,24 @@ class CustomizeExportViewModel @Inject constructor( } } is SizeOption -> { - if (toolState.selectedToolOption is SizeOption.Sticker) { - val bitmap = state.value.exportImageCanvas.imageBitmap - if (bitmap != null) { - if (state.value.exportImageCanvas.imageBitmapRemovedBackground == null) { - _state.update { - it.copy( - toolState = it.toolState + (it.selectedTool to toolState), - showImageEditProgress = true, - exportImageCanvas = it.exportImageCanvas - .updateAspectRatioAndBackground( - it.exportImageCanvas.selectedBackgroundOption, - SizeOption.Sticker, - ), - ) - } - triggerStickerBackgroundRemoval(bitmap) - } else { - _state.update { - it.copy( - toolState = it.toolState + (it.selectedTool to toolState), - showImageEditProgress = false, - exportImageCanvas = it.exportImageCanvas - .updateAspectRatioAndBackground( - it.exportImageCanvas.selectedBackgroundOption, - (toolState.selectedToolOption as SizeOption), - ), - ) - } - } - } else { - _state.update { - it.copy( - toolState = it.toolState + (it.selectedTool to toolState), - showImageEditProgress = false, - exportImageCanvas = it.exportImageCanvas.updateAspectRatioAndBackground( - it.exportImageCanvas.selectedBackgroundOption, - (toolState.selectedToolOption as SizeOption), - ), - ) - } + val selectedSizeOption = toolState.selectedToolOption as SizeOption + val needsBackgroundRemoval = selectedSizeOption == SizeOption.Sticker && + state.value.exportImageCanvas.imageBitmapRemovedBackground == null + + val imageBitmap = state.value.exportImageCanvas.imageBitmap + if (needsBackgroundRemoval && imageBitmap != null) { + val previousSizeOption = state.value.exportImageCanvas.aspectRatioOption + _state.update { + it.copy( + toolState = it.toolState + (it.selectedTool to toolState), + showImageEditProgress = true, + exportImageCanvas = it.exportImageCanvas.updateAspectRatioAndBackground( + it.exportImageCanvas.selectedBackgroundOption, + SizeOption.Sticker, + ), + ) } + triggerStickerBackgroundRemoval(imageBitmap, previousSizeOption) } else { _state.update { it.copy( @@ -180,7 +177,7 @@ class CustomizeExportViewModel @Inject constructor( showImageEditProgress = false, exportImageCanvas = it.exportImageCanvas.updateAspectRatioAndBackground( it.exportImageCanvas.selectedBackgroundOption, - (toolState.selectedToolOption as SizeOption), + selectedSizeOption, ), ) } diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 8a9905a8..9809036b 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -2,6 +2,7 @@ # build agp = "8.11.1" compileSdk = "36" +core = "1.5.0" leakcanaryAndroid = "2.14" minSdk = "26" javaVersion = "17" @@ -48,6 +49,7 @@ material = "1.12.0" media3 = "1.7.1" navigation3 = "1.0.0-alpha05" okhttp = "4.12.0" +playServicesBaseTesting = "16.1.0" poseDetection = "18.0.0-beta5" profileinstaller = "1.4.1" retrofit = "2.11.0" @@ -61,7 +63,9 @@ uiTooling = "1.8.3" window = "1.4.0" aiEdge = "0.0.1-exp02" lifecycleProcess = "2.9.1" +mlkitCommon = "18.11.0" mlkitSegmentation = "16.0.0-beta1" +playServicesBase = "18.4.0" [libraries] accompanist-permissions = { module = "com.google.accompanist:accompanist-permissions", version.ref = "accompanist" } @@ -78,6 +82,7 @@ androidx-camera-core = { module = "androidx.camera:camera-core", version.ref = " androidx-camera-lifecycle = { group = "androidx.camera", name = "camera-lifecycle", version.ref = "camerax" } androidx-compose-bom = { group = "androidx.compose", name = "compose-bom-alpha", version.ref = "composeBom" } androidx-concurrent-futures-ktx = { module = "androidx.concurrent:concurrent-futures-ktx", version.ref = "concurrent" } +androidx-core = { module = "androidx.test:core", version.ref = "core" } androidx-core-ktx = { group = "androidx.core", name = "core-ktx", version.ref = "coreKtx" } androidx-core-splashscreen = { module = "androidx.core:core-splashscreen", version.ref = "coreSplashscreen" } androidx-espresso-core = { group = "androidx.test.espresso", name = "espresso-core", version.ref = "espressoCore" } @@ -123,9 +128,11 @@ kotlinx-coroutines-test = { group = "org.jetbrains.kotlinx", name = "kotlinx-cor kotlinx-serialization-json = { module = "org.jetbrains.kotlinx:kotlinx-serialization-json", version.ref = "kotlinxSerializationJson" } leakcanary-android = { module = "com.squareup.leakcanary:leakcanary-android", version.ref = "leakcanaryAndroid" } logging-interceptor = { group = "com.squareup.okhttp3", name = "logging-interceptor", version.ref = "loggingInterceptor" } +mlkit-common = { group = "com.google.mlkit", name = "common", version.ref = "mlkitCommon" } mlkit-pose-detection = { module = "com.google.mlkit:pose-detection", version.ref = "poseDetection" } mlkit-segmentation = { module = "com.google.android.gms:play-services-mlkit-subject-segmentation", version.ref = "mlkitSegmentation" } okhttp = { group = "com.squareup.okhttp3", name = "okhttp", version.ref = "okhttp" } +play-services-base-testing = { module = "com.google.android.gms:play-services-base-testing", version.ref = "playServicesBaseTesting" } retrofit = { group = "com.squareup.retrofit2", name = "retrofit", version.ref = "retrofit" } retrofit-kotlin-serialization = { group = "com.squareup.retrofit2", name = "converter-kotlinx-serialization", version.ref = "retrofit" } robolectric = { group = "org.robolectric", name = "robolectric", version.ref = "robolectric" } @@ -137,6 +144,7 @@ ai-edge = { group = "com.google.ai.edge.aicore", name = "aicore", version.ref = google-oss-licenses = { group = "com.google.android.gms", name = "play-services-oss-licenses", version.ref = "googleOss" } google-oss-licenses-plugin = { group = "com.google.android.gms", name = "oss-licenses-plugin", version.ref = "googleOssPlugin" } androidx-lifecycle-process = { group = "androidx.lifecycle", name = "lifecycle-process", version.ref = "lifecycleProcess" } +play-services-base = { group = "com.google.android.gms", name = "play-services-base", version.ref = "playServicesBase" } [plugins] android-application = { id = "com.android.application", version.ref = "agp" } android-library = { id = "com.android.library", version.ref = "agp" } From 3d59b15db84a23de560d6110be8d1861f7f1baaf Mon Sep 17 00:00:00 2001 From: Rebecca Franks Date: Tue, 12 Aug 2025 08:00:29 +0100 Subject: [PATCH 06/11] Switch to suspendCoroutine --- .../androidify/ondevice/LocalSegmentationDataSource.kt | 9 +++------ 1 file changed, 3 insertions(+), 6 deletions(-) diff --git a/core/network/src/main/java/com/android/developers/androidify/ondevice/LocalSegmentationDataSource.kt b/core/network/src/main/java/com/android/developers/androidify/ondevice/LocalSegmentationDataSource.kt index 5dfac6ca..bf6986bc 100644 --- a/core/network/src/main/java/com/android/developers/androidify/ondevice/LocalSegmentationDataSource.kt +++ b/core/network/src/main/java/com/android/developers/androidify/ondevice/LocalSegmentationDataSource.kt @@ -53,7 +53,7 @@ class LocalSegmentationDataSourceImpl @Inject constructor( private suspend fun isSubjectSegmentationModuleInstalled(): Boolean { val areModulesAvailable = - suspendCancellableCoroutine { continuation -> + suspendCoroutine { continuation -> moduleInstallClient.areModulesAvailable(segmenter) .addOnSuccessListener { continuation.resume(it.areModulesAvailable()) @@ -66,7 +66,7 @@ class LocalSegmentationDataSourceImpl @Inject constructor( } private suspend fun installSubjectSegmentationModule(): Boolean { - val result = suspendCancellableCoroutine { continuation -> + val result = suspendCoroutine { continuation -> val listener = InstallStatusListener { update -> Log.d("LocalSegmentationDataSource", "Download progress: ${update.installState}") @@ -111,10 +111,7 @@ class LocalSegmentationDataSourceImpl @Inject constructor( Log.d("LocalSegmentationDataSource", "Modules available") } val image = InputImage.fromBitmap(bitmap, 0) - return suspendCancellableCoroutine { continuation -> - continuation.invokeOnCancellation { cause -> - Log.d("LocalSegmentationDataSource", "Continuation was cancelled!", cause) - } + return suspendCoroutine { continuation -> segmenter.process(image) .addOnSuccessListener { result -> if (result.foregroundBitmap != null) { From 8f022dfe3654ad4cef823ddaeefff56e43a4892d Mon Sep 17 00:00:00 2001 From: Rebecca Franks Date: Tue, 12 Aug 2025 08:01:32 +0100 Subject: [PATCH 07/11] Code Cleanup --- app/src/main/AndroidManifest.xml | 10 +++++----- .../androidify/ondevice/LocalSegmentationDataSource.kt | 6 ------ 2 files changed, 5 insertions(+), 11 deletions(-) diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index 512e3e6a..7db43670 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -1,5 +1,4 @@ - - - - + \ No newline at end of file diff --git a/core/network/src/main/java/com/android/developers/androidify/ondevice/LocalSegmentationDataSource.kt b/core/network/src/main/java/com/android/developers/androidify/ondevice/LocalSegmentationDataSource.kt index bf6986bc..10b2d478 100644 --- a/core/network/src/main/java/com/android/developers/androidify/ondevice/LocalSegmentationDataSource.kt +++ b/core/network/src/main/java/com/android/developers/androidify/ondevice/LocalSegmentationDataSource.kt @@ -17,22 +17,16 @@ package com.android.developers.androidify.ondevice import android.graphics.Bitmap import android.print.PrintJobInfo.STATE_COMPLETED -import android.provider.SyncStateContract.Helpers.update import android.util.Log -import coil3.util.CoilUtils.result import com.google.android.gms.common.moduleinstall.InstallStatusListener import com.google.android.gms.common.moduleinstall.ModuleInstallClient import com.google.android.gms.common.moduleinstall.ModuleInstallRequest -import com.google.android.gms.common.moduleinstall.ModuleInstallStatusUpdate -import com.google.android.gms.common.moduleinstall.ModuleInstallStatusUpdate.InstallState import com.google.android.gms.common.moduleinstall.ModuleInstallStatusUpdate.InstallState.STATE_CANCELED import com.google.android.gms.common.moduleinstall.ModuleInstallStatusUpdate.InstallState.STATE_FAILED import com.google.mlkit.vision.common.InputImage import com.google.mlkit.vision.segmentation.subject.SubjectSegmentation import com.google.mlkit.vision.segmentation.subject.SubjectSegmenterOptions -import kotlinx.coroutines.Job import javax.inject.Inject -import kotlinx.coroutines.suspendCancellableCoroutine import kotlin.coroutines.resume import kotlin.coroutines.resumeWithException import kotlin.coroutines.suspendCoroutine From 27c1a81343b63a15712a4004f350b90cd8fc9b51 Mon Sep 17 00:00:00 2001 From: Rebecca Franks Date: Tue, 12 Aug 2025 08:11:16 +0100 Subject: [PATCH 08/11] Code Cleanup --- .../testing/repository/FakeImageGenerationRepository.kt | 4 ++++ .../developers/androidify/customize/CustomizeExportScreen.kt | 1 + 2 files changed, 5 insertions(+) diff --git a/core/testing/src/main/java/com/android/developers/testing/repository/FakeImageGenerationRepository.kt b/core/testing/src/main/java/com/android/developers/testing/repository/FakeImageGenerationRepository.kt index ab654b84..7a6e1682 100644 --- a/core/testing/src/main/java/com/android/developers/testing/repository/FakeImageGenerationRepository.kt +++ b/core/testing/src/main/java/com/android/developers/testing/repository/FakeImageGenerationRepository.kt @@ -64,4 +64,8 @@ class FakeImageGenerationRepository : ImageGenerationRepository { ): Bitmap { return createBitmap(1, 1) } + + override suspend fun removeBackground(image: Bitmap): Bitmap { + return createBitmap(1, 1) + } } 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 51f654af..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 @@ -168,6 +168,7 @@ private fun CustomizeExportContents( movableContentWithReceiverOf { val chromeModifier = if (this.showSticker) { Modifier + .clip(RoundedCornerShape(6)) } else { Modifier.dropShadow( RoundedCornerShape(6), From c5b758748f539208ecd8af4d3d1c512231402ff7 Mon Sep 17 00:00:00 2001 From: Rebecca Franks Date: Tue, 12 Aug 2025 08:36:29 +0100 Subject: [PATCH 09/11] Add Sticker test --- .../customize/CustomizeStateTest.kt | 19 +++++++++++++++++++ 1 file changed, 19 insertions(+) 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 c26aa7a9..ecaf65d0 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 @@ -53,6 +53,7 @@ class CustomizeStateTest { SizeOption.WallpaperTablet, SizeOption.Banner, SizeOption.SocialHeader, + SizeOption.Sticker ), state.options, ) @@ -226,6 +227,24 @@ class CustomizeStateTest { ) } + @Test + fun updateAspectRatioAndBackground_Sticker() { + val initialCanvas = ExportImageCanvas() + val updatedCanvas = initialCanvas.updateAspectRatioAndBackground( + backgroundOption = BackgroundOption.None, + sizeOption = SizeOption.Sticker, + ) + val newCanvasSize = SizeOption.Sticker.dimensions + + Assert.assertEquals(SizeOption.Sticker, updatedCanvas.aspectRatioOption) + Assert.assertEquals(BackgroundOption.None, updatedCanvas.selectedBackgroundOption) + Assert.assertEquals(newCanvasSize, updatedCanvas.canvasSize) + Assert.assertNull(updatedCanvas.selectedBackgroundDrawable) + Assert.assertEquals(0f, updatedCanvas.imageRotation) + Assert.assertEquals(newCanvasSize, updatedCanvas.imageSize) + Assert.assertEquals(Offset.Companion.Zero, updatedCanvas.imageOffset) + } + @Test fun updateAspectRatioAndBackground_WallpaperTablet_None() { val initialCanvas = ExportImageCanvas() From aafbba3943fa28f340ca8212bc520257688940c2 Mon Sep 17 00:00:00 2001 From: Rebecca Franks Date: Tue, 12 Aug 2025 13:49:13 +0100 Subject: [PATCH 10/11] Switch to cancellableCoroutines, move the InstallHelper listener outside of being declared anonymously. --- .../ondevice/LocalSegmentationDataSource.kt | 42 ++++++++++--------- 1 file changed, 23 insertions(+), 19 deletions(-) diff --git a/core/network/src/main/java/com/android/developers/androidify/ondevice/LocalSegmentationDataSource.kt b/core/network/src/main/java/com/android/developers/androidify/ondevice/LocalSegmentationDataSource.kt index 10b2d478..35905897 100644 --- a/core/network/src/main/java/com/android/developers/androidify/ondevice/LocalSegmentationDataSource.kt +++ b/core/network/src/main/java/com/android/developers/androidify/ondevice/LocalSegmentationDataSource.kt @@ -16,20 +16,21 @@ package com.android.developers.androidify.ondevice import android.graphics.Bitmap -import android.print.PrintJobInfo.STATE_COMPLETED import android.util.Log import com.google.android.gms.common.moduleinstall.InstallStatusListener import com.google.android.gms.common.moduleinstall.ModuleInstallClient import com.google.android.gms.common.moduleinstall.ModuleInstallRequest +import com.google.android.gms.common.moduleinstall.ModuleInstallStatusUpdate import com.google.android.gms.common.moduleinstall.ModuleInstallStatusUpdate.InstallState.STATE_CANCELED import com.google.android.gms.common.moduleinstall.ModuleInstallStatusUpdate.InstallState.STATE_FAILED import com.google.mlkit.vision.common.InputImage import com.google.mlkit.vision.segmentation.subject.SubjectSegmentation import com.google.mlkit.vision.segmentation.subject.SubjectSegmenterOptions +import kotlinx.coroutines.CancellableContinuation +import kotlinx.coroutines.suspendCancellableCoroutine import javax.inject.Inject import kotlin.coroutines.resume import kotlin.coroutines.resumeWithException -import kotlin.coroutines.suspendCoroutine interface LocalSegmentationDataSource { suspend fun removeBackground(bitmap: Bitmap): Bitmap @@ -47,7 +48,7 @@ class LocalSegmentationDataSourceImpl @Inject constructor( private suspend fun isSubjectSegmentationModuleInstalled(): Boolean { val areModulesAvailable = - suspendCoroutine { continuation -> + suspendCancellableCoroutine { continuation -> moduleInstallClient.areModulesAvailable(segmenter) .addOnSuccessListener { continuation.resume(it.areModulesAvailable()) @@ -58,22 +59,26 @@ class LocalSegmentationDataSourceImpl @Inject constructor( } return areModulesAvailable } + private class CustomInstallStatusListener( + val continuation: CancellableContinuation + ) : InstallStatusListener { - private suspend fun installSubjectSegmentationModule(): Boolean { - val result = suspendCoroutine { continuation -> - val listener = InstallStatusListener { update -> - Log.d("LocalSegmentationDataSource", "Download progress: ${update.installState}") - - if (update.installState == STATE_COMPLETED) { - continuation.resume(true) - } else if (update.installState == STATE_FAILED || update.installState == STATE_CANCELED) { - continuation.resumeWithException( - Exception("Module download failed or was canceled. State: ${update.installState}") - ) - } else { - Log.d("LocalSegmentationDataSource", "State update: ${update.installState}") - } + override fun onInstallStatusUpdated(update: ModuleInstallStatusUpdate) { + Log.d("LocalSegmentationDataSource", "Download progress: ${update.installState}.. ${continuation.hashCode()} ${continuation.isActive}") + if (update.installState == ModuleInstallStatusUpdate.InstallState.STATE_COMPLETED) { + continuation.resume(true) + } else if (update.installState == STATE_FAILED || update.installState == STATE_CANCELED) { + continuation.resumeWithException( + Exception("Module download failed or was canceled. State: ${update.installState}") + ) + } else { + Log.d("LocalSegmentationDataSource", "State update: ${update.installState}") } + } + } + private suspend fun installSubjectSegmentationModule(): Boolean { + val result = suspendCancellableCoroutine { continuation -> + val listener = CustomInstallStatusListener(continuation) val moduleInstallRequest = ModuleInstallRequest.newBuilder() .addApi(segmenter) .setListener(listener) @@ -87,7 +92,6 @@ class LocalSegmentationDataSourceImpl @Inject constructor( .addOnCompleteListener { Log.d("LocalSegmentationDataSource", "Successfully triggered download - await download progress updates") } - } return result } @@ -105,7 +109,7 @@ class LocalSegmentationDataSourceImpl @Inject constructor( Log.d("LocalSegmentationDataSource", "Modules available") } val image = InputImage.fromBitmap(bitmap, 0) - return suspendCoroutine { continuation -> + return suspendCancellableCoroutine { continuation -> segmenter.process(image) .addOnSuccessListener { result -> if (result.foregroundBitmap != null) { From 3b8f82cfccb7cdaf28ab55c31662a81ec63bb933 Mon Sep 17 00:00:00 2001 From: Rebecca Franks Date: Tue, 12 Aug 2025 16:46:16 +0100 Subject: [PATCH 11/11] PR Feedback --- core/network/build.gradle.kts | 1 - .../androidify/ondevice/LocalSegmentationDataSource.kt | 8 ++++++-- 2 files changed, 6 insertions(+), 3 deletions(-) diff --git a/core/network/build.gradle.kts b/core/network/build.gradle.kts index d5bce3f4..20a7f1e9 100644 --- a/core/network/build.gradle.kts +++ b/core/network/build.gradle.kts @@ -91,7 +91,6 @@ dependencies { testImplementation(libs.robolectric) testImplementation(libs.androidx.core) -// Or the latest version androidTestImplementation(libs.androidx.ui.test.junit4) androidTestImplementation(libs.hilt.android.testing) androidTestImplementation(projects.core.testing) diff --git a/core/network/src/main/java/com/android/developers/androidify/ondevice/LocalSegmentationDataSource.kt b/core/network/src/main/java/com/android/developers/androidify/ondevice/LocalSegmentationDataSource.kt index 35905897..d9e96128 100644 --- a/core/network/src/main/java/com/android/developers/androidify/ondevice/LocalSegmentationDataSource.kt +++ b/core/network/src/main/java/com/android/developers/androidify/ondevice/LocalSegmentationDataSource.kt @@ -65,11 +65,12 @@ class LocalSegmentationDataSourceImpl @Inject constructor( override fun onInstallStatusUpdated(update: ModuleInstallStatusUpdate) { Log.d("LocalSegmentationDataSource", "Download progress: ${update.installState}.. ${continuation.hashCode()} ${continuation.isActive}") + if (!continuation.isActive) return if (update.installState == ModuleInstallStatusUpdate.InstallState.STATE_COMPLETED) { continuation.resume(true) } else if (update.installState == STATE_FAILED || update.installState == STATE_CANCELED) { continuation.resumeWithException( - Exception("Module download failed or was canceled. State: ${update.installState}") + ImageSegmentationException("Module download failed or was canceled. State: ${update.installState}") ) } else { Log.d("LocalSegmentationDataSource", "State update: ${update.installState}") @@ -88,6 +89,7 @@ class LocalSegmentationDataSourceImpl @Inject constructor( .installModules(moduleInstallRequest) .addOnFailureListener { Log.e("LocalSegmentationDataSource", "Failed to download module", it) + continuation.resumeWithException(it) } .addOnCompleteListener { Log.d("LocalSegmentationDataSource", "Successfully triggered download - await download progress updates") @@ -115,7 +117,7 @@ class LocalSegmentationDataSourceImpl @Inject constructor( if (result.foregroundBitmap != null) { continuation.resume(result.foregroundBitmap!!) } else { - continuation.resumeWithException(Exception("Subject not found")) + continuation.resumeWithException(ImageSegmentationException("Subject not found")) } } .addOnFailureListener { e -> @@ -125,3 +127,5 @@ class LocalSegmentationDataSourceImpl @Inject constructor( } } } + +class ImageSegmentationException(message: String? = null): Exception(message) \ No newline at end of file