Skip to content

Commit e869e79

Browse files
Refactor GenAI Image Description sample to use UI state (#68)
Co-authored-by: google-labs-jules[bot] <161369871+google-labs-jules[bot]@users.noreply.github.com> Co-authored-by: calren <[email protected]>
1 parent ae70dc0 commit e869e79

File tree

4 files changed

+124
-78
lines changed

4 files changed

+124
-78
lines changed

ai-catalog/samples/genai-image-description/build.gradle.kts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -72,5 +72,6 @@ dependencies {
7272
implementation(libs.genai.image.description)
7373
implementation(libs.coil.compose)
7474
implementation(libs.kotlinx.coroutines.guava)
75+
implementation(libs.androidx.lifecycle.runtime.compose)
7576
ksp(libs.hilt.compiler)
7677
}

ai-catalog/samples/genai-image-description/src/main/java/com/android/ai/samples/genai_image_description/GenAIImageDescriptionScreen.kt

Lines changed: 34 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,7 @@ import androidx.activity.result.PickVisualMediaRequest
2222
import androidx.activity.result.contract.ActivityResultContracts.PickVisualMedia
2323
import androidx.compose.foundation.layout.Column
2424
import androidx.compose.foundation.layout.fillMaxSize
25+
import androidx.compose.foundation.layout.fillMaxWidth
2526
import androidx.compose.foundation.layout.padding
2627
import androidx.compose.foundation.layout.size
2728
import androidx.compose.material.icons.Icons
@@ -31,14 +32,11 @@ import androidx.compose.material3.Card
3132
import androidx.compose.material3.ExperimentalMaterial3Api
3233
import androidx.compose.material3.Icon
3334
import androidx.compose.material3.MaterialTheme
34-
import androidx.compose.material3.ModalBottomSheet
3535
import androidx.compose.material3.Scaffold
3636
import androidx.compose.material3.Text
3737
import androidx.compose.material3.TopAppBar
3838
import androidx.compose.material3.TopAppBarDefaults.topAppBarColors
39-
import androidx.compose.material3.rememberModalBottomSheetState
4039
import androidx.compose.runtime.Composable
41-
import androidx.compose.runtime.collectAsState
4240
import androidx.compose.runtime.getValue
4341
import androidx.compose.runtime.mutableStateOf
4442
import androidx.compose.runtime.remember
@@ -52,19 +50,14 @@ import androidx.compose.ui.unit.dp
5250
import androidx.compose.ui.unit.sp
5351
import androidx.core.net.toUri
5452
import androidx.hilt.navigation.compose.hiltViewModel
53+
import androidx.lifecycle.compose.collectAsStateWithLifecycle
5554
import coil3.compose.AsyncImage
5655
import com.android.ai.samples.geminimultimodal.R
5756

5857
@OptIn(ExperimentalMaterial3Api::class)
5958
@Composable
6059
fun GenAIImageDescriptionScreen(viewModel: GenAIImageDescriptionViewModel = hiltViewModel()) {
61-
62-
val sheetState = rememberModalBottomSheetState()
63-
var showBottomSheet by remember { mutableStateOf(false) }
64-
65-
val context = LocalContext.current
66-
67-
val imageDescriptionResult = viewModel.resultGenerated.collectAsState()
60+
val uiState by viewModel.uiState.collectAsStateWithLifecycle()
6861

6962
var imageUri by remember { mutableStateOf<Uri?>(null) }
7063
val photoPickerLauncher = rememberLauncherForActivityResult(PickVisualMedia()) { uri ->
@@ -74,7 +67,8 @@ fun GenAIImageDescriptionScreen(viewModel: GenAIImageDescriptionViewModel = hilt
7467
}
7568

7669
Scaffold(
77-
modifier = Modifier.fillMaxSize(), topBar = {
70+
modifier = Modifier.fillMaxSize(),
71+
topBar = {
7872
TopAppBar(
7973
colors = topAppBarColors(
8074
containerColor = MaterialTheme.colorScheme.primaryContainer,
@@ -111,7 +105,9 @@ fun GenAIImageDescriptionScreen(viewModel: GenAIImageDescriptionViewModel = hilt
111105
onClick = {
112106
photoPickerLauncher.launch(PickVisualMediaRequest(PickVisualMedia.ImageOnly))
113107
},
114-
modifier = Modifier.padding(10.dp).align(Alignment.CenterHorizontally),
108+
modifier = Modifier
109+
.padding(10.dp)
110+
.align(Alignment.CenterHorizontally),
115111
) {
116112
Text(
117113
text = stringResource(id = R.string.genai_image_description_add_image),
@@ -121,26 +117,40 @@ fun GenAIImageDescriptionScreen(viewModel: GenAIImageDescriptionViewModel = hilt
121117
// Generate image description button
122118
Button(
123119
onClick = {
124-
showBottomSheet = true
125-
viewModel.getImageDescription(imageUri, context)
126-
}, modifier = Modifier.padding(10.dp).align(Alignment.CenterHorizontally),
120+
viewModel.getImageDescription(imageUri)
121+
},
122+
modifier = Modifier
123+
.padding(10.dp)
124+
.align(Alignment.CenterHorizontally),
127125
) {
128126
Text(
129127
text = stringResource(id = R.string.genai_image_description_run_inference),
130128
)
131129
}
132-
}
133130

134-
if (showBottomSheet) {
135-
ModalBottomSheet(
136-
onDismissRequest = {
137-
showBottomSheet = false
138-
viewModel.clearGeneratedText()
139-
}, sheetState = sheetState,
131+
val outputText = when (val state = uiState) {
132+
is GenAIImageDescriptionUiState.DownloadingFeature -> stringResource(
133+
id = R.string.image_desc_downloading,
134+
state.bytesDownloaded,
135+
state.bytesToDownload,
136+
)
137+
138+
is GenAIImageDescriptionUiState.Error -> stringResource(state.errorMessageStringRes)
139+
is GenAIImageDescriptionUiState.Generating -> state.partialOutput
140+
is GenAIImageDescriptionUiState.Success -> state.generatedOutput
141+
GenAIImageDescriptionUiState.CheckingFeatureStatus -> stringResource(id = R.string.image_desc_checking_feature_status)
142+
else -> "" // Show nothing for the Initial state
143+
}
144+
145+
Card(
146+
modifier = Modifier
147+
.fillMaxWidth()
148+
.weight(1f)
149+
.padding(horizontal = 16.dp, vertical = 8.dp),
140150
) {
141151
Text(
142-
text = imageDescriptionResult.value,
143-
modifier = Modifier.padding(top = 8.dp, bottom = 24.dp, start = 24.dp, end = 24.dp),
152+
text = outputText,
153+
modifier = Modifier.padding(16.dp),
144154
)
145155
}
146156
}

ai-catalog/samples/genai-image-description/src/main/java/com/android/ai/samples/genai_image_description/GenAIImageDescriptionViewModel.kt

Lines changed: 84 additions & 53 deletions
Original file line numberDiff line numberDiff line change
@@ -15,89 +15,120 @@
1515
*/
1616
package com.android.ai.samples.genai_image_description
1717

18-
import android.content.Context
18+
import android.app.Application
1919
import android.net.Uri
2020
import android.provider.MediaStore
2121
import android.util.Log
22-
import androidx.lifecycle.ViewModel
22+
import androidx.annotation.StringRes
23+
import androidx.lifecycle.AndroidViewModel
2324
import androidx.lifecycle.viewModelScope
2425
import com.android.ai.samples.geminimultimodal.R
26+
import com.google.mlkit.genai.common.DownloadCallback
2527
import com.google.mlkit.genai.common.FeatureStatus
28+
import com.google.mlkit.genai.common.GenAiException
2629
import com.google.mlkit.genai.imagedescription.ImageDescriber
2730
import com.google.mlkit.genai.imagedescription.ImageDescriberOptions
2831
import com.google.mlkit.genai.imagedescription.ImageDescription
2932
import com.google.mlkit.genai.imagedescription.ImageDescriptionRequest
3033
import javax.inject.Inject
3134
import kotlinx.coroutines.flow.MutableStateFlow
3235
import kotlinx.coroutines.flow.StateFlow
36+
import kotlinx.coroutines.flow.asStateFlow
37+
import kotlinx.coroutines.flow.update
3338
import kotlinx.coroutines.guava.await
3439
import kotlinx.coroutines.launch
3540

36-
class GenAIImageDescriptionViewModel @Inject constructor() : ViewModel() {
37-
private val _resultGenerated = MutableStateFlow("")
38-
val resultGenerated: StateFlow<String> = _resultGenerated
41+
sealed class GenAIImageDescriptionUiState {
42+
data object Initial : GenAIImageDescriptionUiState()
43+
data object CheckingFeatureStatus : GenAIImageDescriptionUiState()
44+
data class DownloadingFeature(
45+
val bytesToDownload: Long,
46+
val bytesDownloaded: Long,
47+
) : GenAIImageDescriptionUiState()
3948

40-
private var imageDescriber: ImageDescriber? = null
49+
data class Generating(val partialOutput: String) : GenAIImageDescriptionUiState()
50+
data class Success(val generatedOutput: String) : GenAIImageDescriptionUiState()
51+
data class Error(@StringRes val errorMessageStringRes: Int) : GenAIImageDescriptionUiState()
52+
}
53+
54+
class GenAIImageDescriptionViewModel @Inject constructor(val context: Application) : AndroidViewModel(context) {
55+
private val _uiState = MutableStateFlow<GenAIImageDescriptionUiState>(GenAIImageDescriptionUiState.Initial)
56+
val uiState: StateFlow<GenAIImageDescriptionUiState> = _uiState.asStateFlow()
4157

42-
fun getImageDescription(imageUri: Uri?, context: Context) {
58+
private var imageDescriber: ImageDescriber = ImageDescription.getClient(
59+
ImageDescriberOptions.builder(context).build(),
60+
)
61+
62+
fun getImageDescription(imageUri: Uri?) {
4363
if (imageUri == null) {
44-
_resultGenerated.value =
45-
context.getString(R.string.genai_image_description_no_image_selected)
64+
_uiState.value = GenAIImageDescriptionUiState.Error(R.string.genai_image_description_no_image_selected)
4665
return
4766
}
4867

49-
val imageDescriberOptions = ImageDescriberOptions.builder(context).build()
50-
imageDescriber = ImageDescription.getClient(imageDescriberOptions)
51-
5268
viewModelScope.launch {
53-
imageDescriber?.let { imageDescriber ->
54-
var featureStatus = FeatureStatus.UNAVAILABLE
55-
56-
try {
57-
featureStatus = imageDescriber.checkFeatureStatus().await()
58-
} catch (error: Exception) {
59-
Log.e("GenAIImageDesc", "Error checking feature status", error)
60-
}
61-
62-
if (featureStatus == FeatureStatus.UNAVAILABLE) {
63-
_resultGenerated.value =
64-
context.getString(R.string.genai_image_description_not_available)
65-
return@launch
66-
}
67-
68-
// If feature is downloadable, making an inference call will automatically start
69-
// the downloading process.
70-
// If feature is downloading, the inference request will automatically execute after
71-
// the feature has been downloaded.
72-
// Alternatively, you can call imageDescriber.downloadFeature() to monitor the
73-
// progress of the download.
74-
if (featureStatus == FeatureStatus.DOWNLOADABLE ||
75-
featureStatus == FeatureStatus.DOWNLOADING
76-
) {
77-
_resultGenerated.value =
78-
context.getString(R.string.genai_image_description_downloading)
79-
}
80-
81-
val bitmap = MediaStore.Images.Media.getBitmap(context.contentResolver, imageUri)
82-
val request = ImageDescriptionRequest.builder(bitmap).build()
83-
imageDescriber.runInference(request) { newText ->
84-
if (_resultGenerated.value ==
85-
context.getString(R.string.genai_image_description_downloading)
86-
) {
87-
clearGeneratedText()
88-
}
89-
_resultGenerated.value += newText
90-
}
69+
var featureStatus = FeatureStatus.UNAVAILABLE
70+
71+
try {
72+
_uiState.value = GenAIImageDescriptionUiState.CheckingFeatureStatus
73+
featureStatus = imageDescriber.checkFeatureStatus().await()
74+
} catch (error: Exception) {
75+
_uiState.value = GenAIImageDescriptionUiState.Error(R.string.image_desc_feature_check_fail)
76+
Log.e("GenAIImageDesc", "Error checking feature status", error)
77+
}
78+
79+
if (featureStatus == FeatureStatus.UNAVAILABLE) {
80+
_uiState.value = GenAIImageDescriptionUiState.Error(R.string.genai_image_description_not_available)
9181
return@launch
9282
}
83+
84+
if (featureStatus == FeatureStatus.DOWNLOADABLE || featureStatus == FeatureStatus.DOWNLOADING) {
85+
imageDescriber.downloadFeature(
86+
object : DownloadCallback {
87+
override fun onDownloadStarted(bytesToDownload: Long) {
88+
_uiState.value = GenAIImageDescriptionUiState.DownloadingFeature(bytesToDownload, 0)
89+
}
90+
91+
override fun onDownloadProgress(bytesDownloaded: Long) {
92+
_uiState.update {
93+
(it as? GenAIImageDescriptionUiState.DownloadingFeature)?.copy(bytesDownloaded = bytesDownloaded) ?: it
94+
}
95+
}
96+
97+
override fun onDownloadCompleted() {
98+
viewModelScope.launch {
99+
generateImageDescription(imageUri)
100+
}
101+
}
102+
103+
override fun onDownloadFailed(exception: GenAiException) {
104+
Log.e("GenAIImageDesc", "Download failed", exception)
105+
_uiState.value = GenAIImageDescriptionUiState.Error(R.string.image_desc_download_failed)
106+
}
107+
},
108+
)
109+
} else {
110+
generateImageDescription(imageUri)
111+
}
93112
}
94113
}
95114

96-
fun clearGeneratedText() {
97-
_resultGenerated.value = ""
115+
private suspend fun generateImageDescription(imageUri: Uri) {
116+
_uiState.value = GenAIImageDescriptionUiState.Generating("")
117+
val bitmap = MediaStore.Images.Media.getBitmap(context.contentResolver, imageUri)
118+
val request = ImageDescriptionRequest.builder(bitmap).build()
119+
120+
imageDescriber.runInference(request) { newText ->
121+
_uiState.update {
122+
(it as? GenAIImageDescriptionUiState.Generating)?.copy(partialOutput = it.partialOutput + newText) ?: it
123+
}
124+
}.await()
125+
126+
(_uiState.value as? GenAIImageDescriptionUiState.Generating)?.partialOutput?.let { generatedOutput ->
127+
_uiState.value = GenAIImageDescriptionUiState.Success(generatedOutput)
128+
}
98129
}
99130

100131
override fun onCleared() {
101-
imageDescriber?.close()
132+
imageDescriber.close()
102133
}
103134
}

ai-catalog/samples/genai-image-description/src/main/res/values/strings.xml

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,10 @@
55
<string name="genai_image_description_run_inference">Generate image description</string>
66
<string name="genai_image_description_no_image_selected">No image selected</string>
77
<string name="genai_image_description_not_available">Feature is not available on this device</string>
8-
<string name="genai_image_description_downloading">Downloading feature</string>
98
<string name="genai_image_see_code">See code</string>
9+
<string name="image_desc_feature_check_fail">Error checking feature status</string>
10+
<string name="image_desc_download_failed">Feature download failed</string>
11+
<string name="image_desc_downloading">Downloading feature: %1$d / %2$d bytes</string>
12+
<string name="image_desc_checking_feature_status">Checking feature status</string>
13+
<string name="image_desc_generation_error">Error generating summary</string>
1014
</resources>

0 commit comments

Comments
 (0)