Skip to content

Add an option to create a Sticker of the Android #93

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 13 commits into from
Aug 12, 2025
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 6 additions & 2 deletions app/src/main/AndroidManifest.xml
Original file line number Diff line number Diff line change
@@ -1,5 +1,4 @@
<?xml version="1.0" encoding="utf-8"?>
<!--
<?xml version="1.0" encoding="utf-8"?><!--
Copyright 2025 The Android Open Source Project

Licensed under the Apache License, Version 2.0 (the "License");
Expand Down Expand Up @@ -66,6 +65,7 @@
android:name="com.android.developers.androidify.startup.FirebaseRemoteConfigInitializer"
android:value="@string/androidx_startup" />
</provider>

<activity
android:name=".MainActivity"
android:configChanges="keyboard|keyboardHidden|screenSize|screenLayout"
Expand All @@ -87,6 +87,10 @@
<activity
android:name="com.google.android.gms.oss.licenses.OssLicensesActivity"
android:theme="@style/AppCompatAndroidify" />

<meta-data
android:name="com.google.mlkit.vision.DEPENDENCIES"
android:value="subject_segmentation" />
</application>

</manifest>
Original file line number Diff line number Diff line change
Expand Up @@ -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, " +
Expand Down
10 changes: 10 additions & 0 deletions core/network/build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -75,12 +75,22 @@ 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)

androidTestImplementation(libs.androidx.ui.test.junit4)
androidTestImplementation(libs.hilt.android.testing)
androidTestImplementation(projects.core.testing)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,9 @@ 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 com.google.android.gms.common.moduleinstall.ModuleInstallClient
import dagger.Module
import dagger.Provides
import dagger.hilt.InstallIn
Expand Down Expand Up @@ -92,6 +95,10 @@ internal class NetworkModule @Inject constructor() {
.crossfade(true)
.build()

@Provides
fun segmentationDataSource(moduleInstallClient: ModuleInstallClient): LocalSegmentationDataSource {
return LocalSegmentationDataSourceImpl(moduleInstallClient)
}
companion object {
private const val TIMEOUT_SECONDS: Long = 120
}
Expand Down
Original file line number Diff line number Diff line change
@@ -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)
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,131 @@
/*
* 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 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

interface LocalSegmentationDataSource {
suspend fun removeBackground(bitmap: Bitmap): Bitmap
}

class LocalSegmentationDataSourceImpl @Inject constructor(
private val moduleInstallClient: ModuleInstallClient
) : LocalSegmentationDataSource {
private val segmenter by lazy {
Copy link
Member

@bentrengrove bentrengrove Aug 12, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This class is used as a singleton right? segmenter has a close function if not that should probably be called if you are creating and destroying this class

Copy link
Collaborator Author

@riggaroo riggaroo Aug 12, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Its not closed off in the official sample for it - so i dont think we need to call it , also haven't seen any leaks from LeakCanary for it. https://github.com/googlesamples/mlkit/blob/80d6bc64a0b88e18666f26feee0219995f1a86b2/android/vision-quickstart/app/src/main/java/com/google/mlkit/vision/demo/kotlin/subjectsegmenter/SubjectSegmenterProcessor.kt

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 class CustomInstallStatusListener(
val continuation: CancellableContinuation<Boolean>
) : InstallStatusListener {

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(
ImageSegmentationException("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)
.build()

moduleInstallClient
.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")
}
}
return result
}

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 ->
segmenter.process(image)
.addOnSuccessListener { result ->
if (result.foregroundBitmap != null) {
continuation.resume(result.foregroundBitmap!!)
} else {
continuation.resumeWithException(ImageSegmentationException("Subject not found"))
}
}
.addOnFailureListener { e ->
Log.e("LocalSegmentationDataSource", "Exception while executing background removal", e)
continuation.resumeWithException(e)
}
}
}
}

class ImageSegmentationException(message: String? = null): Exception(message)
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -64,4 +64,8 @@ class FakeImageGenerationRepository : ImageGenerationRepository {
): Bitmap {
return createBitmap(1, 1)
}

override suspend fun removeBackground(image: Bitmap): Bitmap {
return createBitmap(1, 1)
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -79,28 +78,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() {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@ import android.util.Log
import com.android.developers.androidify.RemoteConfigDataSource
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
Expand All @@ -38,6 +39,7 @@ interface ImageGenerationRepository {
suspend fun saveImageToExternalStorage(imageUri: Uri): Uri

suspend fun addBackgroundToBot(image: Bitmap, backgroundPrompt: String) : Bitmap
suspend fun removeBackground(image: Bitmap): Bitmap
}

@Singleton
Expand All @@ -46,7 +48,8 @@ internal class ImageGenerationRepositoryImpl @Inject constructor(
private val internetConnectivityManager: InternetConnectivityManager,
private val geminiNanoDataSource: GeminiNanoGenerationDataSource,
private val firebaseAiDataSource: FirebaseAiDataSource,
private val remoteConfigDataSource: RemoteConfigDataSource
private val remoteConfigDataSource: RemoteConfigDataSource,
private val localSegmentationDataSource: LocalSegmentationDataSource,
) : ImageGenerationRepository {

override suspend fun initialize() {
Expand Down Expand Up @@ -134,4 +137,8 @@ internal class ImageGenerationRepositoryImpl @Inject constructor(
"\"" + backgroundPrompt + "\""
return firebaseAiDataSource.generateImageWithEdit(image, backgroundBotInstructions)
}

override suspend fun removeBackground(image: Bitmap): Bitmap {
return localSegmentationDataSource.removeBackground(image)
}
}
Loading