diff --git a/ai-catalog/app/src/main/java/com/android/ai/catalog/ui/domain/SampleCatalog.kt b/ai-catalog/app/src/main/java/com/android/ai/catalog/ui/domain/SampleCatalog.kt index d7c0aec2..525c2b1b 100644 --- a/ai-catalog/app/src/main/java/com/android/ai/catalog/ui/domain/SampleCatalog.kt +++ b/ai-catalog/app/src/main/java/com/android/ai/catalog/ui/domain/SampleCatalog.kt @@ -26,7 +26,7 @@ import com.android.ai.samples.genai_image_description.GenAIImageDescriptionScree import com.android.ai.samples.genai_summarization.GenAISummarizationScreen import com.android.ai.samples.genai_writing_assistance.GenAIWritingAssistanceScreen import com.android.ai.samples.imagen.ui.ImagenScreen -import com.android.ai.samples.magicselfie.MagicSelfieScreen +import com.android.ai.samples.magicselfie.ui.MagicSelfieScreen val sampleCatalog = listOf( SampleCatalogItem( diff --git a/ai-catalog/samples/magic-selfie/src/main/java/com/android/ai/samples/magicselfie/MagicSelfieViewModel.kt b/ai-catalog/samples/magic-selfie/src/main/java/com/android/ai/samples/magicselfie/data/MagicSelfieRepository.kt similarity index 65% rename from ai-catalog/samples/magic-selfie/src/main/java/com/android/ai/samples/magicselfie/MagicSelfieViewModel.kt rename to ai-catalog/samples/magic-selfie/src/main/java/com/android/ai/samples/magicselfie/data/MagicSelfieRepository.kt index 3dcbc8eb..2e293777 100644 --- a/ai-catalog/samples/magic-selfie/src/main/java/com/android/ai/samples/magicselfie/MagicSelfieViewModel.kt +++ b/ai-catalog/samples/magic-selfie/src/main/java/com/android/ai/samples/magicselfie/data/MagicSelfieRepository.kt @@ -13,15 +13,11 @@ * See the License for the specific language governing permissions and * limitations under the License. */ -package com.android.ai.samples.magicselfie +package com.android.ai.samples.magicselfie.data import android.graphics.Bitmap import android.graphics.Canvas import android.graphics.Paint -import androidx.lifecycle.LiveData -import androidx.lifecycle.MutableLiveData -import androidx.lifecycle.ViewModel -import androidx.lifecycle.viewModelScope import com.google.firebase.Firebase import com.google.firebase.ai.ai import com.google.firebase.ai.type.GenerativeBackend @@ -33,19 +29,13 @@ import com.google.mlkit.vision.common.InputImage import com.google.mlkit.vision.segmentation.subject.SubjectSegmentation import com.google.mlkit.vision.segmentation.subject.SubjectSegmenterOptions import javax.inject.Inject +import javax.inject.Singleton +import kotlin.coroutines.suspendCoroutine import kotlin.math.roundToInt -import kotlinx.coroutines.flow.MutableStateFlow -import kotlinx.coroutines.launch - -@OptIn(PublicPreviewAPI::class) -class MagicSelfieViewModel @Inject constructor() : ViewModel() { - - private val _foregroundBitmap = MutableStateFlow(null) - val foregroundBitmap: MutableStateFlow = _foregroundBitmap - - private val _progress = MutableLiveData(null) - val progress: LiveData = _progress +@Singleton +class MagicSelfieRepository @Inject constructor() { + @OptIn(PublicPreviewAPI::class) private val imagenModel = Firebase.ai(backend = GenerativeBackend.vertexAI()).imagenModel( modelName = "imagen-4.0-generate-preview-06-06", generationConfig = ImagenGenerationConfig( @@ -61,36 +51,28 @@ class MagicSelfieViewModel @Inject constructor() : ViewModel() { .build(), ) - fun createMagicSelfie(bitmap: Bitmap, prompt: String) { + suspend fun generateForegroundBitmap(bitmap: Bitmap): Bitmap { val image = InputImage.fromBitmap(bitmap, 0) - - _progress.value = "Removing selfie background..." - - subjectSegmenter.process(image) - .addOnSuccessListener { - it.foregroundBitmap?.let { - _foregroundBitmap.value = it - generateBackground(prompt) + return suspendCoroutine { continuation -> + subjectSegmenter.process(image) + .addOnSuccessListener { + it.foregroundBitmap?.let { foregroundBitmap -> + continuation.resumeWith(Result.success(foregroundBitmap)) + } + } + .addOnFailureListener { + continuation.resumeWith(Result.failure(it)) } - }.addOnFailureListener { - _progress.postValue("Something went wrong :(") - } + } } - private fun generateBackground(prompt: String) { - _progress.value = "Generating new background..." - - viewModelScope.launch { - val imageResponse = imagenModel.generateImages( - prompt = prompt, - ) - val image = imageResponse.images.first() - - val bitmapImage = image.asBitmap() - - _foregroundBitmap.value = combineBitmaps(_foregroundBitmap.value!!, bitmapImage) - _progress.postValue(null) - } + @OptIn(PublicPreviewAPI::class) + suspend fun generateBackground(prompt: String): Bitmap { + val imageResponse = imagenModel.generateImages( + prompt = prompt, + ) + val image = imageResponse.images.first() + return image.asBitmap() } fun combineBitmaps(foreground: Bitmap, background: Bitmap): Bitmap { diff --git a/ai-catalog/samples/magic-selfie/src/main/java/com/android/ai/samples/magicselfie/ui/ImageUtils.kt b/ai-catalog/samples/magic-selfie/src/main/java/com/android/ai/samples/magicselfie/ui/ImageUtils.kt new file mode 100644 index 00000000..99d21dc4 --- /dev/null +++ b/ai-catalog/samples/magic-selfie/src/main/java/com/android/ai/samples/magicselfie/ui/ImageUtils.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.ai.samples.magicselfie.ui + +import android.graphics.Bitmap +import android.graphics.Matrix +import android.media.ExifInterface +import java.io.File + +fun rotateImageIfRequired(imageFile: File, bitmap: Bitmap): Bitmap { + val ei = ExifInterface(imageFile.absolutePath) + val orientation = ei.getAttributeInt( + ExifInterface.TAG_ORIENTATION, + ExifInterface.ORIENTATION_NORMAL, + ) + + return when (orientation) { + ExifInterface.ORIENTATION_ROTATE_90 -> rotateImage(bitmap, 90f) + ExifInterface.ORIENTATION_ROTATE_180 -> rotateImage(bitmap, 180f) + ExifInterface.ORIENTATION_ROTATE_270 -> rotateImage(bitmap, 270f) + ExifInterface.ORIENTATION_FLIP_HORIZONTAL -> flipImage(bitmap, true, false) + ExifInterface.ORIENTATION_FLIP_VERTICAL -> flipImage(bitmap, false, true) + ExifInterface.ORIENTATION_TRANSPOSE -> flipImage(rotateImage(bitmap, 90f), true, false) + ExifInterface.ORIENTATION_TRANSVERSE -> flipImage(rotateImage(bitmap, 270f), true, false) + else -> bitmap + } +} + +fun rotateImage(bitmap: Bitmap, degrees: Float): Bitmap { + val matrix = Matrix() + matrix.postRotate(degrees) + return Bitmap.createBitmap(bitmap, 0, 0, bitmap.width, bitmap.height, matrix, true) +} + +fun flipImage(bitmap: Bitmap, horizontal: Boolean, vertical: Boolean): Bitmap { + val matrix = Matrix() + val scaleX = if (horizontal) -1f else 1f + val scaleY = if (vertical) -1f else 1f + matrix.setScale(scaleX, scaleY) + return Bitmap.createBitmap(bitmap, 0, 0, bitmap.width, bitmap.height, matrix, true) +} diff --git a/ai-catalog/samples/magic-selfie/src/main/java/com/android/ai/samples/magicselfie/MagicSelfieScreen.kt b/ai-catalog/samples/magic-selfie/src/main/java/com/android/ai/samples/magicselfie/ui/MagicSelfieScreen.kt similarity index 73% rename from ai-catalog/samples/magic-selfie/src/main/java/com/android/ai/samples/magicselfie/MagicSelfieScreen.kt rename to ai-catalog/samples/magic-selfie/src/main/java/com/android/ai/samples/magicselfie/ui/MagicSelfieScreen.kt index 9c5224f8..c7ae316a 100644 --- a/ai-catalog/samples/magic-selfie/src/main/java/com/android/ai/samples/magicselfie/MagicSelfieScreen.kt +++ b/ai-catalog/samples/magic-selfie/src/main/java/com/android/ai/samples/magicselfie/ui/MagicSelfieScreen.kt @@ -13,16 +13,12 @@ * See the License for the specific language governing permissions and * limitations under the License. */ -package com.android.ai.samples.magicselfie +package com.android.ai.samples.magicselfie.ui import android.annotation.SuppressLint import android.app.Activity -import android.content.Context import android.content.Intent import android.graphics.Bitmap -import android.graphics.Matrix -import android.media.ExifInterface -import android.net.Uri import android.provider.MediaStore import androidx.activity.compose.rememberLauncherForActivityResult import androidx.activity.result.ActivityResult @@ -35,13 +31,13 @@ import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.imePadding import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.verticalScroll import androidx.compose.material.icons.Icons import androidx.compose.material.icons.filled.CameraAlt -import androidx.compose.material.icons.filled.Code import androidx.compose.material.icons.filled.SmartToy import androidx.compose.material3.Button import androidx.compose.material3.Card @@ -58,7 +54,6 @@ import androidx.compose.material3.rememberTopAppBarState import androidx.compose.runtime.Composable import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue -import androidx.compose.runtime.livedata.observeAsState import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.setValue @@ -69,9 +64,9 @@ import androidx.compose.ui.layout.ContentScale import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.res.stringResource import androidx.compose.ui.unit.dp -import androidx.compose.ui.unit.sp import androidx.core.content.FileProvider import androidx.hilt.navigation.compose.hiltViewModel +import com.android.ai.samples.magicselfie.R import java.io.File @OptIn(ExperimentalMaterial3Api::class) @@ -79,6 +74,7 @@ import java.io.File @Composable fun MagicSelfieScreen(viewModel: MagicSelfieViewModel = hiltViewModel()) { val context = LocalContext.current + val uiState by viewModel.uiState.collectAsState() val topAppBarState = rememberTopAppBarState() val scrollBehavior = TopAppBarDefaults.pinnedScrollBehavior(topAppBarState) @@ -95,8 +91,6 @@ fun MagicSelfieScreen(viewModel: MagicSelfieViewModel = hiltViewModel()) { cameraIntent.addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION) var selfieBitmap by remember { mutableStateOf(null) } - val progress by viewModel.progress.observeAsState(null) - val generatedBitmap by viewModel.foregroundBitmap.collectAsState() var editTextValue by remember { mutableStateOf("A very scenic view from the edge of the grand canyon") } val resultLauncher = @@ -132,6 +126,7 @@ fun MagicSelfieScreen(viewModel: MagicSelfieViewModel = hiltViewModel()) { Modifier .padding(12.dp) .padding(innerPadding) + .imePadding() .verticalScroll(rememberScrollState()), ) { Card( @@ -141,12 +136,12 @@ fun MagicSelfieScreen(viewModel: MagicSelfieViewModel = hiltViewModel()) { height = 450.dp, ), ) { - - if (generatedBitmap != null) { + if (uiState is MagicSelfieUiState.Success) { + val successState = uiState as MagicSelfieUiState.Success Image( - bitmap = generatedBitmap!!.asImageBitmap(), + bitmap = successState.bitmap.asImageBitmap(), contentDescription = "Picture", - contentScale = ContentScale.Fit, + contentScale = ContentScale.Crop, modifier = Modifier.fillMaxSize(), ) } else if (selfieBitmap != null) { @@ -183,74 +178,43 @@ fun MagicSelfieScreen(viewModel: MagicSelfieViewModel = hiltViewModel()) { viewModel.createMagicSelfie(selfieBitmap!!, editTextValue) } }, - enabled = progress == null, + enabled = (uiState !is MagicSelfieUiState.RemovingBackground) && + (uiState !is MagicSelfieUiState.GeneratingBackground), ) { Icon(Icons.Default.SmartToy, contentDescription = "Robot") Text(modifier = Modifier.padding(start = 8.dp), text = "Generate") } - if (progress != null) { + if (uiState is MagicSelfieUiState.RemovingBackground) { Spacer( modifier = Modifier .height(30.dp) .padding(12.dp), ) Text( - text = progress!!, + text = stringResource(R.string.removing_background), + ) + } else if (uiState is MagicSelfieUiState.GeneratingBackground) { + Spacer( + modifier = Modifier + .height(30.dp) + .padding(12.dp), + ) + Text( + text = stringResource(R.string.generating_new_background), + ) + } else if (uiState is MagicSelfieUiState.Error) { + val errorState = uiState as MagicSelfieUiState.Error + Spacer( + modifier = Modifier + .height(30.dp) + .padding(12.dp), + ) + Text( + text = errorState.message ?: stringResource(R.string.unknown_error), + color = MaterialTheme.colorScheme.error, ) } } } } - -fun rotateImageIfRequired(imageFile: File, bitmap: Bitmap): Bitmap { - val ei = ExifInterface(imageFile.absolutePath) - val orientation = ei.getAttributeInt( - ExifInterface.TAG_ORIENTATION, - ExifInterface.ORIENTATION_NORMAL, - ) - - return when (orientation) { - ExifInterface.ORIENTATION_ROTATE_90 -> rotateImage(bitmap, 90f) - ExifInterface.ORIENTATION_ROTATE_180 -> rotateImage(bitmap, 180f) - ExifInterface.ORIENTATION_ROTATE_270 -> rotateImage(bitmap, 270f) - ExifInterface.ORIENTATION_FLIP_HORIZONTAL -> flipImage(bitmap, true, false) - ExifInterface.ORIENTATION_FLIP_VERTICAL -> flipImage(bitmap, false, true) - ExifInterface.ORIENTATION_TRANSPOSE -> flipImage(rotateImage(bitmap, 90f), true, false) - ExifInterface.ORIENTATION_TRANSVERSE -> flipImage(rotateImage(bitmap, 270f), true, false) - else -> bitmap - } -} - -fun rotateImage(bitmap: Bitmap, degrees: Float): Bitmap { - val matrix = Matrix() - matrix.postRotate(degrees) - return Bitmap.createBitmap(bitmap, 0, 0, bitmap.width, bitmap.height, matrix, true) -} - -fun flipImage(bitmap: Bitmap, horizontal: Boolean, vertical: Boolean): Bitmap { - val matrix = Matrix() - val scaleX = if (horizontal) -1f else 1f - val scaleY = if (vertical) -1f else 1f - matrix.setScale(scaleX, scaleY) - return Bitmap.createBitmap(bitmap, 0, 0, bitmap.width, bitmap.height, matrix, true) -} - -@Composable -fun SeeCodeButton(context: Context) { - val githubLink = "https://github.com/android/ai-samples/tree/main/ai-catalog/samples/magic-selfie" - Button( - onClick = { - val intent = Intent(Intent.ACTION_VIEW, Uri.parse(githubLink)) - context.startActivity(intent) - }, - modifier = Modifier.padding(end = 8.dp), - ) { - Icon(Icons.Filled.Code, contentDescription = "See code") - Text( - modifier = Modifier.padding(start = 8.dp), - fontSize = 12.sp, - text = stringResource(R.string.see_code), - ) - } -} diff --git a/ai-catalog/samples/magic-selfie/src/main/java/com/android/ai/samples/magicselfie/ui/MagicSelfieUiState.kt b/ai-catalog/samples/magic-selfie/src/main/java/com/android/ai/samples/magicselfie/ui/MagicSelfieUiState.kt new file mode 100644 index 00000000..2ada49bf --- /dev/null +++ b/ai-catalog/samples/magic-selfie/src/main/java/com/android/ai/samples/magicselfie/ui/MagicSelfieUiState.kt @@ -0,0 +1,26 @@ +/* + * 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.ai.samples.magicselfie.ui + +import android.graphics.Bitmap + +sealed interface MagicSelfieUiState { + data object Initial : MagicSelfieUiState + data object RemovingBackground : MagicSelfieUiState + data object GeneratingBackground : MagicSelfieUiState + data class Success(val bitmap: Bitmap) : MagicSelfieUiState + data class Error(val message: String?) : MagicSelfieUiState +} diff --git a/ai-catalog/samples/magic-selfie/src/main/java/com/android/ai/samples/magicselfie/ui/MagicSelfieViewModel.kt b/ai-catalog/samples/magic-selfie/src/main/java/com/android/ai/samples/magicselfie/ui/MagicSelfieViewModel.kt new file mode 100644 index 00000000..4ff3a59c --- /dev/null +++ b/ai-catalog/samples/magic-selfie/src/main/java/com/android/ai/samples/magicselfie/ui/MagicSelfieViewModel.kt @@ -0,0 +1,48 @@ +/* + * 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.ai.samples.magicselfie.ui + +import android.graphics.Bitmap +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import com.android.ai.samples.magicselfie.data.MagicSelfieRepository +import dagger.hilt.android.lifecycle.HiltViewModel +import javax.inject.Inject +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.launch + +@HiltViewModel +class MagicSelfieViewModel @Inject constructor(private val magicSelfieRepository: MagicSelfieRepository) : ViewModel() { + + private val _uiState = MutableStateFlow(MagicSelfieUiState.Initial) + val uiState: StateFlow = _uiState + + fun createMagicSelfie(bitmap: Bitmap, prompt: String) { + viewModelScope.launch { + try { + _uiState.value = MagicSelfieUiState.RemovingBackground + val foregroundBitmap = magicSelfieRepository.generateForegroundBitmap(bitmap) + _uiState.value = MagicSelfieUiState.GeneratingBackground + val backgroundBitmap = magicSelfieRepository.generateBackground(prompt) + val resultBitmap = magicSelfieRepository.combineBitmaps(foregroundBitmap, backgroundBitmap) + _uiState.value = MagicSelfieUiState.Success(resultBitmap) + } catch (e: Exception) { + _uiState.value = MagicSelfieUiState.Error(e.message) + } + } + } +} diff --git a/ai-catalog/samples/magic-selfie/src/main/java/com/android/ai/samples/magicselfie/ui/SeeCodeButton.kt b/ai-catalog/samples/magic-selfie/src/main/java/com/android/ai/samples/magicselfie/ui/SeeCodeButton.kt new file mode 100644 index 00000000..4c664bf0 --- /dev/null +++ b/ai-catalog/samples/magic-selfie/src/main/java/com/android/ai/samples/magicselfie/ui/SeeCodeButton.kt @@ -0,0 +1,51 @@ +/* + * 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.ai.samples.magicselfie.ui + +import android.content.Context +import android.content.Intent +import android.net.Uri +import androidx.compose.foundation.layout.padding +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Code +import androidx.compose.material3.Button +import androidx.compose.material3.Icon +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import com.android.ai.samples.magicselfie.R + +@Composable +fun SeeCodeButton(context: Context) { + val githubLink = "https://github.com/android/ai-samples/tree/main/ai-catalog/samples/magic-selfie" + Button( + onClick = { + val intent = Intent(Intent.ACTION_VIEW, Uri.parse(githubLink)) + context.startActivity(intent) + }, + modifier = Modifier.padding(end = 8.dp), + ) { + Icon(Icons.Filled.Code, contentDescription = stringResource(R.string.see_code)) + Text( + modifier = Modifier.padding(start = 8.dp), + fontSize = 12.sp, + text = stringResource(R.string.see_code), + ) + } +} diff --git a/ai-catalog/samples/magic-selfie/src/main/res/values/strings.xml b/ai-catalog/samples/magic-selfie/src/main/res/values/strings.xml index 7977ad67..9f82fb6b 100644 --- a/ai-catalog/samples/magic-selfie/src/main/res/values/strings.xml +++ b/ai-catalog/samples/magic-selfie/src/main/res/values/strings.xml @@ -3,4 +3,7 @@ Magic Selfie See code Share image + Unknown error + Removing background... + Generating new background... \ No newline at end of file