From f4006b35b0a67ebaa3acc5ba0dfc976895aa8364 Mon Sep 17 00:00:00 2001 From: Google AI Edge Gallery Date: Thu, 12 Jun 2025 17:32:43 -0700 Subject: [PATCH] No public description PiperOrigin-RevId: 770859221 --- Android/.gitignore | 16 + Android/README.md | 2 +- Android/src/.gitignore | 16 + Android/src/app/.gitignore | 16 + Android/src/app/build.gradle.kts | 17 +- Android/src/app/proguard-rules.pro | 21 - Android/src/app/src/main/AndroidManifest.xml | 10 +- .../com/google/ai/edge/gallery/GalleryApp.kt | 154 +---- .../ai/edge/gallery/GalleryAppTopBar.kt | 157 +++++ .../ai/edge/gallery/GalleryApplication.kt | 35 +- .../edge/gallery/GalleryLifecycleProvider.kt | 25 +- .../google/ai/edge/gallery/GalleryService.kt | 12 - .../google/ai/edge/gallery/MainActivity.kt | 10 +- .../google/ai/edge/gallery/common/Types.kt | 27 + .../google/ai/edge/gallery/common/Utils.kt | 114 ++++ .../ai/edge/gallery/data/AppBarAction.kt | 2 +- .../ai/edge/gallery/data/AppContainer.kt | 8 +- .../com/google/ai/edge/gallery/data/Config.kt | 180 ++++- .../ai/edge/gallery/data/ConfigValue.kt | 85 ++- .../com/google/ai/edge/gallery/data/Consts.kt | 10 + .../edge/gallery/data/DataStoreRepository.kt | 224 ++----- .../edge/gallery/data/DownloadRepository.kt | 120 ++-- .../com/google/ai/edge/gallery/data/Model.kt | 285 ++++---- .../ai/edge/gallery/data/ModelAllowlist.kt | 65 +- .../com/google/ai/edge/gallery/data/Tasks.kt | 117 ++-- .../com/google/ai/edge/gallery/data/Types.kt | 22 + .../ai/edge/gallery/ui/ViewModelProvider.kt | 35 +- .../ai/edge/gallery/ui/common/AuthConfig.kt | 13 +- .../edge/gallery/ui/common/ClickableLink.kt | 68 ++ .../ai/edge/gallery/ui/common/ColorUtils.kt | 41 ++ .../ui/common/{chat => }/ConfigDialog.kt | 247 ++++--- .../gallery/ui/common/DownloadAndTryButton.kt | 272 ++++---- .../ai/edge/gallery/ui/common/ErrorDialog.kt | 15 +- .../ui/common/{chat => }/MarkdownText.kt | 69 +- .../edge/gallery/ui/common/ModelPageAppBar.kt | 134 ++-- .../ai/edge/gallery/ui/common/ModelPicker.kt | 65 +- .../ui/common/ModelPickerChipsPager.kt | 73 +- .../ai/edge/gallery/ui/common/TaskIcon.kt | 26 +- .../google/ai/edge/gallery/ui/common/Utils.kt | 462 +------------ .../ui/common/chat/BenchmarkConfigDialog.kt | 99 +-- .../gallery/ui/common/chat/ChatMessage.kt | 62 +- .../edge/gallery/ui/common/chat/ChatPanel.kt | 363 +++++----- .../edge/gallery/ui/common/chat/ChatView.kt | 197 +++--- .../gallery/ui/common/chat/ChatViewModel.kt | 54 +- .../edge/gallery/ui/common/chat/DataCard.kt | 37 +- .../ui/common/chat/LiveCameraDialog.kt | 226 ------- .../ui/common/chat/MessageActionButton.kt | 67 +- .../ui/common/chat/MessageBodyBenchmark.kt | 108 ++- .../ui/common/chat/MessageBodyBenchmarkLlm.kt | 57 +- .../common/chat/MessageBodyClassification.kt | 73 +- .../ui/common/chat/MessageBodyConfigUpdate.kt | 96 ++- .../ui/common/chat/MessageBodyImage.kt | 6 +- .../chat/MessageBodyImageWithHistory.kt | 22 +- .../gallery/ui/common/chat/MessageBodyInfo.kt | 35 +- .../ui/common/chat/MessageBodyLoading.kt | 74 +- .../common/chat/MessageBodyPromptTemplates.kt | 125 ++-- .../gallery/ui/common/chat/MessageBodyText.kt | 55 +- .../ui/common/chat/MessageBodyWarning.kt | 35 +- .../ui/common/chat/MessageBubbleShape.kt | 48 +- .../ui/common/chat/MessageInputImage.kt | 129 ++-- .../ui/common/chat/MessageInputText.kt | 634 +++++++++--------- .../gallery/ui/common/chat/MessageLatency.kt | 47 +- .../gallery/ui/common/chat/MessageSender.kt | 192 +++--- .../chat/ModelDownloadStatusInfoPanel.kt | 29 +- .../common/chat/ModelDownloadingAnimation.kt | 134 ++-- .../common/chat/ModelInitializationStatus.kt | 35 +- .../ui/common/chat/ModelNotDownloaded.kt | 25 +- .../gallery/ui/common/chat/ModelSelector.kt | 108 ++- .../ui/common/chat/TextInputHistorySheet.kt | 120 ++-- .../gallery/ui/common/chat/ZoomableBox.kt | 35 +- .../modelitem/AnimatedLayoutModifier.kt | 73 -- .../modelitem/ConfirmDeleteModelDialog.kt | 26 +- .../gallery/ui/common/modelitem/ModelItem.kt | 395 +++++------ .../common/modelitem/ModelItemActionButton.kt | 65 +- .../ui/common/modelitem/ModelNameAndStatus.kt | 82 +-- .../gallery/ui/common/modelitem/StatusIcon.kt | 83 ++- .../ai/edge/gallery/ui/home/HomeScreen.kt | 323 ++++----- .../edge/gallery/ui/home/ModelImportDialog.kt | 266 ++++---- .../gallery/ui/home/NewReleaseNotification.kt | 55 +- .../ai/edge/gallery/ui/home/SettingsDialog.kt | 149 ++-- .../google/ai/edge/gallery/ui/icon/Deploy.kt | 113 ++-- .../google/ai/edge/gallery/ui/icon/Forum.kt | 78 +++ .../com/google/ai/edge/gallery/ui/icon/Mms.kt | 73 ++ .../ai/edge/gallery/ui/icon/Widgets.kt.kt | 87 +++ .../ImageClassificationModelHelper.kt | 154 ----- .../ImageClassificationScreen.kt | 98 --- .../ImageClassificationViewModel.kt | 165 ----- .../ImageGenerationModelHelper.kt | 83 --- .../imagegeneration/ImageGenerationScreen.kt | 65 -- .../ImageGenerationViewModel.kt | 87 --- .../edge/gallery/ui/llmchat/LlmChatConfigs.kt | 72 -- .../gallery/ui/llmchat/LlmChatModelHelper.kt | 88 ++- .../edge/gallery/ui/llmchat/LlmChatScreen.kt | 76 +-- .../gallery/ui/llmchat/LlmChatViewModel.kt | 87 +-- .../ui/llmsingleturn/LlmSingleTurnScreen.kt | 137 ++-- .../llmsingleturn/LlmSingleTurnViewModel.kt | 65 +- .../ui/llmsingleturn/PromptTemplateConfigs.kt | 162 +++-- .../ui/llmsingleturn/PromptTemplatesPanel.kt | 312 +++++---- .../gallery/ui/llmsingleturn/ResponsePanel.kt | 140 ++-- .../ui/llmsingleturn/SingleSelectButton.kt | 29 +- .../ui/llmsingleturn/VerticalSplitView.kt | 107 ++- .../edge/gallery/ui/modelmanager/ModelList.kt | 139 ++-- .../gallery/ui/modelmanager/ModelManager.kt | 44 +- .../ui/modelmanager/ModelManagerViewModel.kt | 387 +++++------ .../gallery/ui/navigation/GalleryNavGraph.kt | 134 ++-- .../gallery/ui/preview/PreviewChatModel.kt | 69 +- .../ui/preview/PreviewDataStoreRepository.kt | 57 +- .../ui/preview/PreviewDownloadRepository.kt | 17 +- .../preview/PreviewLlmSingleTurnViewModel.kt | 2 +- .../preview/PreviewModelManagerViewModel.kt | 88 ++- .../edge/gallery/ui/preview/PreviewTasks.kt | 126 ++-- .../TextClassificationModelHelper.kt | 95 --- .../TextClassificationScreen.kt | 75 --- .../TextClassificationViewModel.kt | 128 ---- .../google/ai/edge/gallery/ui/theme/Color.kt | 4 +- .../google/ai/edge/gallery/ui/theme/Theme.kt | 280 ++++---- .../ai/edge/gallery/ui/theme/ThemeSettings.kt | 7 +- .../google/ai/edge/gallery/ui/theme/Type.kt | 74 +- .../ai/edge/gallery/worker/DownloadWorker.kt | 106 ++- Android/src/app/src/main/proto/settings.proto | 65 ++ .../res/mipmap-anydpi-v26/ic_launcher.xml | 15 + Android/src/build.gradle.kts | 8 +- Android/src/gradle/libs.versions.toml | 9 +- Android/src/gradle/wrapper/gradle-wrapper.jar | Bin 59203 -> 43462 bytes Android/src/gradlew.bat | 178 ++--- Android/src/settings.gradle.kts | 35 +- 126 files changed, 5557 insertions(+), 6977 deletions(-) delete mode 100644 Android/src/app/proguard-rules.pro create mode 100644 Android/src/app/src/main/java/com/google/ai/edge/gallery/GalleryAppTopBar.kt delete mode 100644 Android/src/app/src/main/java/com/google/ai/edge/gallery/GalleryService.kt create mode 100644 Android/src/app/src/main/java/com/google/ai/edge/gallery/common/Types.kt create mode 100644 Android/src/app/src/main/java/com/google/ai/edge/gallery/common/Utils.kt create mode 100644 Android/src/app/src/main/java/com/google/ai/edge/gallery/data/Types.kt create mode 100644 Android/src/app/src/main/java/com/google/ai/edge/gallery/ui/common/ClickableLink.kt create mode 100644 Android/src/app/src/main/java/com/google/ai/edge/gallery/ui/common/ColorUtils.kt rename Android/src/app/src/main/java/com/google/ai/edge/gallery/ui/common/{chat => }/ConfigDialog.kt (65%) rename Android/src/app/src/main/java/com/google/ai/edge/gallery/ui/common/{chat => }/MarkdownText.kt (56%) delete mode 100644 Android/src/app/src/main/java/com/google/ai/edge/gallery/ui/common/chat/LiveCameraDialog.kt delete mode 100644 Android/src/app/src/main/java/com/google/ai/edge/gallery/ui/common/modelitem/AnimatedLayoutModifier.kt create mode 100644 Android/src/app/src/main/java/com/google/ai/edge/gallery/ui/icon/Forum.kt create mode 100644 Android/src/app/src/main/java/com/google/ai/edge/gallery/ui/icon/Mms.kt create mode 100644 Android/src/app/src/main/java/com/google/ai/edge/gallery/ui/icon/Widgets.kt.kt delete mode 100644 Android/src/app/src/main/java/com/google/ai/edge/gallery/ui/imageclassification/ImageClassificationModelHelper.kt delete mode 100644 Android/src/app/src/main/java/com/google/ai/edge/gallery/ui/imageclassification/ImageClassificationScreen.kt delete mode 100644 Android/src/app/src/main/java/com/google/ai/edge/gallery/ui/imageclassification/ImageClassificationViewModel.kt delete mode 100644 Android/src/app/src/main/java/com/google/ai/edge/gallery/ui/imagegeneration/ImageGenerationModelHelper.kt delete mode 100644 Android/src/app/src/main/java/com/google/ai/edge/gallery/ui/imagegeneration/ImageGenerationScreen.kt delete mode 100644 Android/src/app/src/main/java/com/google/ai/edge/gallery/ui/imagegeneration/ImageGenerationViewModel.kt delete mode 100644 Android/src/app/src/main/java/com/google/ai/edge/gallery/ui/llmchat/LlmChatConfigs.kt delete mode 100644 Android/src/app/src/main/java/com/google/ai/edge/gallery/ui/textclassification/TextClassificationModelHelper.kt delete mode 100644 Android/src/app/src/main/java/com/google/ai/edge/gallery/ui/textclassification/TextClassificationScreen.kt delete mode 100644 Android/src/app/src/main/java/com/google/ai/edge/gallery/ui/textclassification/TextClassificationViewModel.kt create mode 100644 Android/src/app/src/main/proto/settings.proto diff --git a/Android/.gitignore b/Android/.gitignore index 1ce5acb..3c5bc8e 100644 --- a/Android/.gitignore +++ b/Android/.gitignore @@ -1,3 +1,19 @@ +# @license +# Copyright 2025 Google LLC +# +# 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 +# +# http://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. +# ============================================================================== + # Gradle files .gradle/ build/ diff --git a/Android/README.md b/Android/README.md index 30c24d9..64790d0 100644 --- a/Android/README.md +++ b/Android/README.md @@ -1 +1 @@ -# AI Edge Gallery (Android) +# Google AI Edge Gallery (Android) diff --git a/Android/src/.gitignore b/Android/src/.gitignore index aa724b7..8b6602c 100644 --- a/Android/src/.gitignore +++ b/Android/src/.gitignore @@ -1,4 +1,20 @@ +# @license +# Copyright 2025 Google LLC +# +# 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 +# +# http://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. +# ============================================================================== *.iml + .gradle /local.properties /.idea/caches diff --git a/Android/src/app/.gitignore b/Android/src/app/.gitignore index 956c004..61b3747 100644 --- a/Android/src/app/.gitignore +++ b/Android/src/app/.gitignore @@ -1,2 +1,18 @@ +# @license +# Copyright 2025 Google LLC +# +# 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 +# +# http://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. +# ============================================================================== + /build /release \ No newline at end of file diff --git a/Android/src/app/build.gradle.kts b/Android/src/app/build.gradle.kts index 1a22a5c..a49a150 100644 --- a/Android/src/app/build.gradle.kts +++ b/Android/src/app/build.gradle.kts @@ -19,6 +19,7 @@ plugins { alias(libs.plugins.kotlin.android) alias(libs.plugins.kotlin.compose) alias(libs.plugins.kotlin.serialization) + alias(libs.plugins.protobuf) } android { @@ -26,7 +27,6 @@ android { compileSdk = 35 defaultConfig { - // Don't change to com.google.ai.edge.gallery yet. applicationId = "com.google.aiedge.gallery" minSdk = 26 targetSdk = 35 @@ -42,10 +42,7 @@ android { buildTypes { release { isMinifyEnabled = false - proguardFiles( - getDefaultProguardFile("proguard-android-optimize.txt"), - "proguard-rules.pro" - ) + proguardFiles(getDefaultProguardFile("proguard-android-optimize.txt"), "proguard-rules.pro") signingConfig = signingConfigs.getByName("debug") } } @@ -76,7 +73,7 @@ dependencies { implementation(libs.kotlinx.serialization.json) implementation(libs.material.icon.extended) implementation(libs.androidx.work.runtime) - implementation(libs.androidx.datastore.preferences) + implementation(libs.androidx.datastore) implementation(libs.com.google.code.gson) implementation(libs.androidx.lifecycle.process) implementation(libs.mediapipe.tasks.text) @@ -93,6 +90,7 @@ dependencies { implementation(libs.camerax.view) implementation(libs.openid.appauth) implementation(libs.androidx.splashscreen) + implementation(libs.protobuf.javalite) testImplementation(libs.junit) androidTestImplementation(libs.androidx.junit) androidTestImplementation(libs.androidx.espresso.core) @@ -100,4 +98,9 @@ dependencies { androidTestImplementation(libs.androidx.ui.test.junit4) debugImplementation(libs.androidx.ui.tooling) debugImplementation(libs.androidx.ui.test.manifest) -} \ No newline at end of file +} + +protobuf { + protoc { artifact = "com.google.protobuf:protoc:4.26.1" } + generateProtoTasks { all().forEach { it.plugins { create("java") { option("lite") } } } } +} diff --git a/Android/src/app/proguard-rules.pro b/Android/src/app/proguard-rules.pro deleted file mode 100644 index 481bb43..0000000 --- a/Android/src/app/proguard-rules.pro +++ /dev/null @@ -1,21 +0,0 @@ -# Add project specific ProGuard rules here. -# You can control the set of applied configuration files using the -# proguardFiles setting in build.gradle. -# -# For more details, see -# http://developer.android.com/guide/developing/tools/proguard.html - -# If your project uses WebView with JS, uncomment the following -# and specify the fully qualified class name to the JavaScript interface -# class: -#-keepclassmembers class fqcn.of.javascript.interface.for.webview { -# public *; -#} - -# Uncomment this to preserve the line number information for -# debugging stack traces. -#-keepattributes SourceFile,LineNumberTable - -# If you keep the line number information, uncomment this to -# hide the original source file name. -#-renamesourcefileattribute SourceFile \ No newline at end of file diff --git a/Android/src/app/src/main/AndroidManifest.xml b/Android/src/app/src/main/AndroidManifest.xml index 3d71053..a87aa98 100644 --- a/Android/src/app/src/main/AndroidManifest.xml +++ b/Android/src/app/src/main/AndroidManifest.xml @@ -16,13 +16,20 @@ --> + + + - + + diff --git a/Android/src/app/src/main/java/com/google/ai/edge/gallery/GalleryApp.kt b/Android/src/app/src/main/java/com/google/ai/edge/gallery/GalleryApp.kt index 19f53a0..9a5cdd2 100644 --- a/Android/src/app/src/main/java/com/google/ai/edge/gallery/GalleryApp.kt +++ b/Android/src/app/src/main/java/com/google/ai/edge/gallery/GalleryApp.kt @@ -14,167 +14,15 @@ * limitations under the License. */ -@file:OptIn(ExperimentalMaterial3Api::class) - package com.google.ai.edge.gallery -import androidx.compose.foundation.layout.Arrangement -import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.Row -import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.layout.size -import androidx.compose.foundation.text.BasicText -import androidx.compose.foundation.text.TextAutoSize -import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.automirrored.rounded.ArrowBack -import androidx.compose.material.icons.rounded.Refresh -import androidx.compose.material.icons.rounded.Settings -import androidx.compose.material3.CenterAlignedTopAppBar -import androidx.compose.material3.CircularProgressIndicator -import androidx.compose.material3.ExperimentalMaterial3Api -import androidx.compose.material3.Icon -import androidx.compose.material3.IconButton -import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.Text -import androidx.compose.material3.TextButton -import androidx.compose.material3.TopAppBarScrollBehavior import androidx.compose.runtime.Composable -import androidx.compose.ui.Alignment -import androidx.compose.ui.Modifier -import androidx.compose.ui.graphics.Color -import androidx.compose.ui.res.painterResource -import androidx.compose.ui.res.stringResource -import androidx.compose.ui.text.font.FontWeight -import androidx.compose.ui.unit.dp -import androidx.compose.ui.unit.sp import androidx.navigation.NavHostController import androidx.navigation.compose.rememberNavController -import com.google.ai.edge.gallery.data.AppBarAction -import com.google.ai.edge.gallery.data.AppBarActionType import com.google.ai.edge.gallery.ui.navigation.GalleryNavHost -/** - * Top level composable representing the main screen of the application. - */ +/** Top level composable representing the main screen of the application. */ @Composable fun GalleryApp(navController: NavHostController = rememberNavController()) { GalleryNavHost(navController = navController) } - -/** - * The top app bar. - */ -@OptIn(ExperimentalMaterial3Api::class) -@Composable -fun GalleryTopAppBar( - title: String, - modifier: Modifier = Modifier, - leftAction: AppBarAction? = null, - rightAction: AppBarAction? = null, - scrollBehavior: TopAppBarScrollBehavior? = null, - subtitle: String = "", -) { - val titleColor = MaterialTheme.colorScheme.primary - CenterAlignedTopAppBar( - title = { - Column(horizontalAlignment = Alignment.CenterHorizontally) { - Row( - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.spacedBy(12.dp) - ) { - if (title == stringResource(R.string.app_name)) { - Icon( - painterResource(R.drawable.logo), - modifier = Modifier.size(20.dp), - contentDescription = "", - tint = Color.Unspecified, - ) - } - BasicText( - text = title, - maxLines = 1, - color = { titleColor }, - style = MaterialTheme.typography.titleLarge.copy(fontWeight = FontWeight.SemiBold), - autoSize = TextAutoSize.StepBased( - minFontSize = 14.sp, - maxFontSize = 22.sp, - stepSize = 1.sp - ) - ) - } - if (subtitle.isNotEmpty()) { - Text( - subtitle, - style = MaterialTheme.typography.labelSmall, - color = MaterialTheme.colorScheme.secondary - ) - } - } - }, - modifier = modifier, - scrollBehavior = scrollBehavior, - // The button at the left. - navigationIcon = { - when (leftAction?.actionType) { - AppBarActionType.NAVIGATE_UP -> { - IconButton(onClick = leftAction.actionFn) { - Icon( - imageVector = Icons.AutoMirrored.Rounded.ArrowBack, - contentDescription = "", - ) - } - } - - AppBarActionType.REFRESH_MODELS -> { - IconButton(onClick = leftAction.actionFn) { - Icon( - imageVector = Icons.Rounded.Refresh, - contentDescription = "", - tint = MaterialTheme.colorScheme.secondary - ) - } - } - - AppBarActionType.REFRESHING_MODELS -> { - CircularProgressIndicator( - trackColor = MaterialTheme.colorScheme.surfaceContainerHighest, - strokeWidth = 3.dp, - modifier = Modifier - .padding(start = 16.dp) - .size(20.dp) - ) - } - - else -> {} - } - }, - // The "action" component at the right. - actions = { - when (rightAction?.actionType) { - // Click an icon to open "app setting". - AppBarActionType.APP_SETTING -> { - IconButton(onClick = rightAction.actionFn) { - Icon( - imageVector = Icons.Rounded.Settings, - contentDescription = "", - tint = MaterialTheme.colorScheme.primary - ) - } - } - - AppBarActionType.MODEL_SELECTOR -> { - Text("ms") - } - - // Click a button to navigate up. - AppBarActionType.NAVIGATE_UP -> { - TextButton(onClick = rightAction.actionFn) { - Text("Done") - } - } - - else -> {} - } - } - ) -} \ No newline at end of file diff --git a/Android/src/app/src/main/java/com/google/ai/edge/gallery/GalleryAppTopBar.kt b/Android/src/app/src/main/java/com/google/ai/edge/gallery/GalleryAppTopBar.kt new file mode 100644 index 0000000..0b1ffa1 --- /dev/null +++ b/Android/src/app/src/main/java/com/google/ai/edge/gallery/GalleryAppTopBar.kt @@ -0,0 +1,157 @@ +/* + * Copyright 2025 Google LLC + * + * 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 + * + * http://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. + */ + +@file:OptIn(ExperimentalMaterial3Api::class) + +package com.google.ai.edge.gallery + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.text.BasicText +import androidx.compose.foundation.text.TextAutoSize +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.rounded.ArrowBack +import androidx.compose.material.icons.rounded.Refresh +import androidx.compose.material.icons.rounded.Settings +import androidx.compose.material3.CenterAlignedTopAppBar +import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.material3.TopAppBarScrollBehavior +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import com.google.ai.edge.gallery.data.AppBarAction +import com.google.ai.edge.gallery.data.AppBarActionType + +/** The top app bar. */ +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun GalleryTopAppBar( + title: String, + modifier: Modifier = Modifier, + leftAction: AppBarAction? = null, + rightAction: AppBarAction? = null, + scrollBehavior: TopAppBarScrollBehavior? = null, + subtitle: String = "", +) { + val titleColor = MaterialTheme.colorScheme.primary + CenterAlignedTopAppBar( + title = { + Column(horizontalAlignment = Alignment.CenterHorizontally) { + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(12.dp), + ) { + if (title == stringResource(R.string.app_name)) { + Icon( + painterResource(R.drawable.logo), + modifier = Modifier.size(20.dp), + contentDescription = "", + tint = Color.Unspecified, + ) + } + BasicText( + text = title, + maxLines = 1, + color = { titleColor }, + style = MaterialTheme.typography.titleLarge.copy(fontWeight = FontWeight.SemiBold), + autoSize = + TextAutoSize.StepBased(minFontSize = 14.sp, maxFontSize = 22.sp, stepSize = 1.sp), + ) + } + if (subtitle.isNotEmpty()) { + Text( + subtitle, + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.secondary, + ) + } + } + }, + modifier = modifier, + scrollBehavior = scrollBehavior, + // The button at the left. + navigationIcon = { + when (leftAction?.actionType) { + AppBarActionType.NAVIGATE_UP -> { + IconButton(onClick = leftAction.actionFn) { + Icon(imageVector = Icons.AutoMirrored.Rounded.ArrowBack, contentDescription = "") + } + } + + AppBarActionType.REFRESH_MODELS -> { + IconButton(onClick = leftAction.actionFn) { + Icon( + imageVector = Icons.Rounded.Refresh, + contentDescription = "", + tint = MaterialTheme.colorScheme.secondary, + ) + } + } + + AppBarActionType.REFRESHING_MODELS -> { + CircularProgressIndicator( + trackColor = MaterialTheme.colorScheme.surfaceContainerHighest, + strokeWidth = 3.dp, + modifier = Modifier.padding(start = 16.dp).size(20.dp), + ) + } + + else -> {} + } + }, + // The "action" component at the right. + actions = { + when (rightAction?.actionType) { + // Click an icon to open "app setting". + AppBarActionType.APP_SETTING -> { + IconButton(onClick = rightAction.actionFn) { + Icon( + imageVector = Icons.Rounded.Settings, + contentDescription = "", + tint = MaterialTheme.colorScheme.primary, + ) + } + } + + AppBarActionType.MODEL_SELECTOR -> { + Text("ms") + } + + // Click a button to navigate up. + AppBarActionType.NAVIGATE_UP -> { + TextButton(onClick = rightAction.actionFn) { Text("Done") } + } + + else -> {} + } + }, + ) +} diff --git a/Android/src/app/src/main/java/com/google/ai/edge/gallery/GalleryApplication.kt b/Android/src/app/src/main/java/com/google/ai/edge/gallery/GalleryApplication.kt index d6c02a3..4572311 100644 --- a/Android/src/app/src/main/java/com/google/ai/edge/gallery/GalleryApplication.kt +++ b/Android/src/app/src/main/java/com/google/ai/edge/gallery/GalleryApplication.kt @@ -18,15 +18,35 @@ package com.google.ai.edge.gallery import android.app.Application import android.content.Context +import androidx.datastore.core.CorruptionException import androidx.datastore.core.DataStore -import androidx.datastore.preferences.core.Preferences -import androidx.datastore.preferences.preferencesDataStore +import androidx.datastore.core.Serializer +import androidx.datastore.dataStore +import com.google.ai.edge.gallery.common.writeLaunchInfo import com.google.ai.edge.gallery.data.AppContainer import com.google.ai.edge.gallery.data.DefaultAppContainer -import com.google.ai.edge.gallery.ui.common.writeLaunchInfo +import com.google.ai.edge.gallery.proto.Settings import com.google.ai.edge.gallery.ui.theme.ThemeSettings +import com.google.protobuf.InvalidProtocolBufferException +import java.io.InputStream +import java.io.OutputStream -private val Context.dataStore: DataStore by preferencesDataStore(name = "app_gallery_preferences") +object SettingsSerializer : Serializer { + override val defaultValue: Settings = Settings.getDefaultInstance() + + override suspend fun readFrom(input: InputStream): Settings { + try { + return Settings.parseFrom(input) + } catch (exception: InvalidProtocolBufferException) { + throw CorruptionException("Cannot read proto.", exception) + } + } + + override suspend fun writeTo(t: Settings, output: OutputStream) = t.writeTo(output) +} + +private val Context.dataStore: DataStore by + dataStore(fileName = "settings.pb", serializer = SettingsSerializer) class GalleryApplication : Application() { /** AppContainer instance used by the rest of classes to obtain dependencies */ @@ -35,11 +55,10 @@ class GalleryApplication : Application() { override fun onCreate() { super.onCreate() - writeLaunchInfo(context = this) container = DefaultAppContainer(this, dataStore) - // Load theme. - ThemeSettings.themeOverride.value = container.dataStoreRepository.readThemeOverride() + // Load saved theme. + ThemeSettings.themeOverride.value = container.dataStoreRepository.readTheme() } -} \ No newline at end of file +} diff --git a/Android/src/app/src/main/java/com/google/ai/edge/gallery/GalleryLifecycleProvider.kt b/Android/src/app/src/main/java/com/google/ai/edge/gallery/GalleryLifecycleProvider.kt index 3d76eb8..ebf56a6 100644 --- a/Android/src/app/src/main/java/com/google/ai/edge/gallery/GalleryLifecycleProvider.kt +++ b/Android/src/app/src/main/java/com/google/ai/edge/gallery/GalleryLifecycleProvider.kt @@ -16,29 +16,16 @@ package com.google.ai.edge.gallery -import androidx.lifecycle.DefaultLifecycleObserver -import androidx.lifecycle.LifecycleOwner -import androidx.lifecycle.ProcessLifecycleOwner - interface AppLifecycleProvider { - val isAppInForeground: Boolean + var isAppInForeground: Boolean } -class GalleryLifecycleProvider : AppLifecycleProvider, DefaultLifecycleObserver { +class GalleryLifecycleProvider : AppLifecycleProvider { private var _isAppInForeground = false - init { - ProcessLifecycleOwner.get().lifecycle.addObserver(this) - } - - override val isAppInForeground: Boolean + override var isAppInForeground: Boolean get() = _isAppInForeground - - override fun onResume(owner: LifecycleOwner) { - _isAppInForeground = true - } - - override fun onPause(owner: LifecycleOwner) { - _isAppInForeground = false - } + set(value) { + _isAppInForeground = value + } } diff --git a/Android/src/app/src/main/java/com/google/ai/edge/gallery/GalleryService.kt b/Android/src/app/src/main/java/com/google/ai/edge/gallery/GalleryService.kt deleted file mode 100644 index f245943..0000000 --- a/Android/src/app/src/main/java/com/google/ai/edge/gallery/GalleryService.kt +++ /dev/null @@ -1,12 +0,0 @@ -package com.google.ai.edge.gallery - -import android.app.Service -import android.content.Intent -import android.os.IBinder - -// TODO(jingjin): implement foreground service. -class GalleryService : Service() { - override fun onBind(p0: Intent?): IBinder? { - return null - } -} \ No newline at end of file diff --git a/Android/src/app/src/main/java/com/google/ai/edge/gallery/MainActivity.kt b/Android/src/app/src/main/java/com/google/ai/edge/gallery/MainActivity.kt index 1356d12..61e3add 100644 --- a/Android/src/app/src/main/java/com/google/ai/edge/gallery/MainActivity.kt +++ b/Android/src/app/src/main/java/com/google/ai/edge/gallery/MainActivity.kt @@ -32,14 +32,6 @@ class MainActivity : ComponentActivity() { super.onCreate(savedInstanceState) enableEdgeToEdge() - setContent { - GalleryTheme { - Surface( - modifier = Modifier.fillMaxSize() - ) { - GalleryApp() - } - } - } + setContent { GalleryTheme { Surface(modifier = Modifier.fillMaxSize()) { GalleryApp() } } } } } diff --git a/Android/src/app/src/main/java/com/google/ai/edge/gallery/common/Types.kt b/Android/src/app/src/main/java/com/google/ai/edge/gallery/common/Types.kt new file mode 100644 index 0000000..f9e9a9f --- /dev/null +++ b/Android/src/app/src/main/java/com/google/ai/edge/gallery/common/Types.kt @@ -0,0 +1,27 @@ +/* + * Copyright 2025 Google LLC + * + * 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 + * + * http://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.google.ai.edge.gallery.common + +import androidx.compose.ui.graphics.Color + +interface LatencyProvider { + val latencyMs: Float +} + +data class Classification(val label: String, val score: Float, val color: Color) + +data class JsonObjAndTextContent(val jsonObj: T, val textContent: String) diff --git a/Android/src/app/src/main/java/com/google/ai/edge/gallery/common/Utils.kt b/Android/src/app/src/main/java/com/google/ai/edge/gallery/common/Utils.kt new file mode 100644 index 0000000..6d37f9d --- /dev/null +++ b/Android/src/app/src/main/java/com/google/ai/edge/gallery/common/Utils.kt @@ -0,0 +1,114 @@ +/* + * Copyright 2025 Google LLC + * + * 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 + * + * http://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.google.ai.edge.gallery.common + +import android.content.Context +import android.util.Log +import com.google.gson.Gson +import com.google.gson.reflect.TypeToken +import java.io.File +import java.net.HttpURLConnection +import java.net.URL + +data class LaunchInfo(val ts: Long) + +private const val TAG = "AGUtils" +private const val LAUNCH_INFO_FILE_NAME = "launch_info" +private const val START_THINKING = "***Thinking...***" +private const val DONE_THINKING = "***Done thinking***" + +fun readLaunchInfo(context: Context): LaunchInfo? { + try { + val gson = Gson() + val type = object : TypeToken() {}.type + val file = File(context.getExternalFilesDir(null), LAUNCH_INFO_FILE_NAME) + val content = file.readText() + return gson.fromJson(content, type) + } catch (e: Exception) { + Log.e(TAG, "Failed to read launch info", e) + return null + } +} + +fun cleanUpMediapipeTaskErrorMessage(message: String): String { + val index = message.indexOf("=== Source Location Trace") + if (index >= 0) { + return message.substring(0, index) + } + return message +} + +fun processLlmResponse(response: String): String { + // Add "thinking" and "done thinking" around the thinking content. + var newContent = + response.replace("", "$START_THINKING\n").replace("", "\n$DONE_THINKING") + + // Remove empty thinking content. + val endThinkingIndex = newContent.indexOf(DONE_THINKING) + if (endThinkingIndex >= 0) { + val thinkingContent = + newContent + .substring(0, endThinkingIndex + DONE_THINKING.length) + .replace(START_THINKING, "") + .replace(DONE_THINKING, "") + if (thinkingContent.isBlank()) { + newContent = newContent.substring(endThinkingIndex + DONE_THINKING.length) + } + } + + newContent = newContent.replace("\\n", "\n") + + return newContent +} + +fun writeLaunchInfo(context: Context) { + try { + val gson = Gson() + val launchInfo = LaunchInfo(ts = System.currentTimeMillis()) + val jsonString = gson.toJson(launchInfo) + val file = File(context.getExternalFilesDir(null), LAUNCH_INFO_FILE_NAME) + file.writeText(jsonString) + } catch (e: Exception) { + Log.e(TAG, "Failed to write launch info", e) + } +} + +inline fun getJsonResponse(url: String): JsonObjAndTextContent? { + try { + val connection = URL(url).openConnection() as HttpURLConnection + connection.requestMethod = "GET" + connection.connect() + + val responseCode = connection.responseCode + if (responseCode == HttpURLConnection.HTTP_OK) { + val inputStream = connection.inputStream + val response = inputStream.bufferedReader().use { it.readText() } + + val gson = Gson() + val type = object : TypeToken() {}.type + val jsonObj = gson.fromJson(response, type) + return JsonObjAndTextContent(jsonObj = jsonObj, textContent = response) + } else { + Log.e("AGUtils", "HTTP error: $responseCode") + } + } catch (e: Exception) { + Log.e("AGUtils", "Error when getting json response: ${e.message}") + e.printStackTrace() + } + + return null +} diff --git a/Android/src/app/src/main/java/com/google/ai/edge/gallery/data/AppBarAction.kt b/Android/src/app/src/main/java/com/google/ai/edge/gallery/data/AppBarAction.kt index f139896..754d704 100644 --- a/Android/src/app/src/main/java/com/google/ai/edge/gallery/data/AppBarAction.kt +++ b/Android/src/app/src/main/java/com/google/ai/edge/gallery/data/AppBarAction.kt @@ -27,4 +27,4 @@ enum class AppBarActionType { REFRESHING_MODELS, } -class AppBarAction(val actionType: AppBarActionType, val actionFn: () -> Unit) \ No newline at end of file +class AppBarAction(val actionType: AppBarActionType, val actionFn: () -> Unit) diff --git a/Android/src/app/src/main/java/com/google/ai/edge/gallery/data/AppContainer.kt b/Android/src/app/src/main/java/com/google/ai/edge/gallery/data/AppContainer.kt index 57a905d..75ad89d 100644 --- a/Android/src/app/src/main/java/com/google/ai/edge/gallery/data/AppContainer.kt +++ b/Android/src/app/src/main/java/com/google/ai/edge/gallery/data/AppContainer.kt @@ -18,9 +18,9 @@ package com.google.ai.edge.gallery.data import android.content.Context import androidx.datastore.core.DataStore -import androidx.datastore.preferences.core.Preferences -import com.google.ai.edge.gallery.GalleryLifecycleProvider import com.google.ai.edge.gallery.AppLifecycleProvider +import com.google.ai.edge.gallery.GalleryLifecycleProvider +import com.google.ai.edge.gallery.proto.Settings /** * App container for Dependency injection. @@ -39,9 +39,9 @@ interface AppContainer { * * This class provides concrete implementations for the application's dependencies, */ -class DefaultAppContainer(ctx: Context, dataStore: DataStore) : AppContainer { +class DefaultAppContainer(ctx: Context, dataStore: DataStore) : AppContainer { override val context = ctx override val lifecycleProvider = GalleryLifecycleProvider() override val dataStoreRepository = DefaultDataStoreRepository(dataStore) override val downloadRepository = DefaultDownloadRepository(ctx, lifecycleProvider) -} \ No newline at end of file +} diff --git a/Android/src/app/src/main/java/com/google/ai/edge/gallery/data/Config.kt b/Android/src/app/src/main/java/com/google/ai/edge/gallery/data/Config.kt index 1c85088..d961ecc 100644 --- a/Android/src/app/src/main/java/com/google/ai/edge/gallery/data/Config.kt +++ b/Android/src/app/src/main/java/com/google/ai/edge/gallery/data/Config.kt @@ -16,11 +16,13 @@ package com.google.ai.edge.gallery.data +import kotlin.math.abs + /** * The types of configuration editors available. * - * This enum defines the different UI components used to edit configuration values. - * Each type corresponds to a specific editor widget, such as a slider or a switch. + * This enum defines the different UI components used to edit configuration values. Each type + * corresponds to a specific editor widget, such as a slider or a switch. */ enum class ConfigEditorType { LABEL, @@ -29,9 +31,7 @@ enum class ConfigEditorType { DROPDOWN, } -/** - * The data types of configuration values. - */ +/** The data types of configuration values. */ enum class ValueType { INT, FLOAT, @@ -40,6 +40,28 @@ enum class ValueType { BOOLEAN, } +enum class ConfigKey(val label: String) { + MAX_TOKENS("Max tokens"), + TOPK("TopK"), + TOPP("TopP"), + TEMPERATURE("Temperature"), + DEFAULT_MAX_TOKENS("Default max tokens"), + DEFAULT_TOPK("Default TopK"), + DEFAULT_TOPP("Default TopP"), + DEFAULT_TEMPERATURE("Default temperature"), + SUPPORT_IMAGE("Support image"), + MAX_RESULT_COUNT("Max result count"), + USE_GPU("Use GPU"), + ACCELERATOR("Choose accelerator"), + COMPATIBLE_ACCELERATORS("Compatible accelerators"), + WARM_UP_ITERATIONS("Warm up iterations"), + BENCHMARK_ITERATIONS("Benchmark iterations"), + ITERATIONS("Iterations"), + THEME("Theme"), + NAME("Name"), + MODEL_TYPE("Model type"), +} + /** * Base class for configuration settings. * @@ -58,18 +80,14 @@ open class Config( open val needReinitialization: Boolean = true, ) -/** - * Configuration setting for a label. - */ -class LabelConfig( - override val key: ConfigKey, - override val defaultValue: String = "", -) : Config( - type = ConfigEditorType.LABEL, - key = key, - defaultValue = defaultValue, - valueType = ValueType.STRING -) +/** Configuration setting for a label. */ +class LabelConfig(override val key: ConfigKey, override val defaultValue: String = "") : + Config( + type = ConfigEditorType.LABEL, + key = key, + defaultValue = defaultValue, + valueType = ValueType.STRING, + ) /** * Configuration setting for a number slider. @@ -92,32 +110,122 @@ class NumberSliderConfig( valueType = valueType, ) -/** - * Configuration setting for a boolean switch. - */ +/** Configuration setting for a boolean switch. */ class BooleanSwitchConfig( override val key: ConfigKey, override val defaultValue: Boolean, override val needReinitialization: Boolean = true, -) : Config( - type = ConfigEditorType.BOOLEAN_SWITCH, - key = key, - defaultValue = defaultValue, - valueType = ValueType.BOOLEAN, -) +) : + Config( + type = ConfigEditorType.BOOLEAN_SWITCH, + key = key, + defaultValue = defaultValue, + valueType = ValueType.BOOLEAN, + ) -/** - * Configuration setting for a dropdown. - */ +/** Configuration setting for a dropdown. */ class SegmentedButtonConfig( override val key: ConfigKey, override val defaultValue: String, val options: List, val allowMultiple: Boolean = false, -) : Config( - type = ConfigEditorType.DROPDOWN, - key = key, - defaultValue = defaultValue, - // The emitted value will be comma-separated labels when allowMultiple=true. - valueType = ValueType.STRING, -) \ No newline at end of file +) : + Config( + type = ConfigEditorType.DROPDOWN, + key = key, + defaultValue = defaultValue, + // The emitted value will be comma-separated labels when allowMultiple=true. + valueType = ValueType.STRING, + ) + +fun convertValueToTargetType(value: Any, valueType: ValueType): Any { + return when (valueType) { + ValueType.INT -> + when (value) { + is Int -> value + is Float -> value.toInt() + is Double -> value.toInt() + is String -> value.toIntOrNull() ?: "" + is Boolean -> if (value) 1 else 0 + else -> "" + } + + ValueType.FLOAT -> + when (value) { + is Int -> value.toFloat() + is Float -> value + is Double -> value.toFloat() + is String -> value.toFloatOrNull() ?: "" + is Boolean -> if (value) 1f else 0f + else -> "" + } + + ValueType.DOUBLE -> + when (value) { + is Int -> value.toDouble() + is Float -> value.toDouble() + is Double -> value + is String -> value.toDoubleOrNull() ?: "" + is Boolean -> if (value) 1.0 else 0.0 + else -> "" + } + + ValueType.BOOLEAN -> + when (value) { + is Int -> value == 0 + is Boolean -> value + is Float -> abs(value) > 1e-6 + is Double -> abs(value) > 1e-6 + is String -> value.isNotEmpty() + else -> false + } + + ValueType.STRING -> value.toString() + } +} + +fun createLlmChatConfigs( + defaultMaxToken: Int = DEFAULT_MAX_TOKEN, + defaultTopK: Int = DEFAULT_TOPK, + defaultTopP: Float = DEFAULT_TOPP, + defaultTemperature: Float = DEFAULT_TEMPERATURE, + accelerators: List = DEFAULT_ACCELERATORS, +): List { + return listOf( + LabelConfig(key = ConfigKey.MAX_TOKENS, defaultValue = "$defaultMaxToken"), + NumberSliderConfig( + key = ConfigKey.TOPK, + sliderMin = 5f, + sliderMax = 40f, + defaultValue = defaultTopK.toFloat(), + valueType = ValueType.INT, + ), + NumberSliderConfig( + key = ConfigKey.TOPP, + sliderMin = 0.0f, + sliderMax = 1.0f, + defaultValue = defaultTopP, + valueType = ValueType.FLOAT, + ), + NumberSliderConfig( + key = ConfigKey.TEMPERATURE, + sliderMin = 0.0f, + sliderMax = 2.0f, + defaultValue = defaultTemperature, + valueType = ValueType.FLOAT, + ), + SegmentedButtonConfig( + key = ConfigKey.ACCELERATOR, + defaultValue = accelerators[0].label, + options = accelerators.map { it.label }, + ), + ) +} + +fun getConfigValueString(value: Any, config: Config): String { + var strNewValue = "$value" + if (config.valueType == ValueType.FLOAT) { + strNewValue = "%.2f".format(value) + } + return strNewValue +} diff --git a/Android/src/app/src/main/java/com/google/ai/edge/gallery/data/ConfigValue.kt b/Android/src/app/src/main/java/com/google/ai/edge/gallery/data/ConfigValue.kt index ef3e5ae..27da5de 100644 --- a/Android/src/app/src/main/java/com/google/ai/edge/gallery/data/ConfigValue.kt +++ b/Android/src/app/src/main/java/com/google/ai/edge/gallery/data/ConfigValue.kt @@ -16,64 +16,55 @@ package com.google.ai.edge.gallery.data -import kotlinx.serialization.KSerializer -import kotlinx.serialization.Serializable -import kotlinx.serialization.SerializationException -import kotlinx.serialization.descriptors.SerialDescriptor -import kotlinx.serialization.descriptors.buildClassSerialDescriptor -import kotlinx.serialization.encoding.Decoder -import kotlinx.serialization.encoding.Encoder -import kotlinx.serialization.json.JsonDecoder -import kotlinx.serialization.json.JsonPrimitive - -@Serializable(with = ConfigValueSerializer::class) +// @Serializable(with = ConfigValueSerializer::class) sealed class ConfigValue { - @Serializable + // @Serializable data class IntValue(val value: Int) : ConfigValue() - @Serializable + // @Serializable data class FloatValue(val value: Float) : ConfigValue() - @Serializable + // @Serializable data class StringValue(val value: String) : ConfigValue() } -/** - * Custom serializer for the ConfigValue class. - * - * This object implements the KSerializer interface to provide custom serialization and - * deserialization logic for the ConfigValue class. It handles different types of ConfigValue - * (IntValue, FloatValue, StringValue) and supports JSON format. - */ -object ConfigValueSerializer : KSerializer { - override val descriptor: SerialDescriptor = buildClassSerialDescriptor("ConfigValue") +// /** +// * Custom serializer for the ConfigValue class. +// * +// * This object implements the KSerializer interface to provide custom serialization and +// * deserialization logic for the ConfigValue class. It handles different types of ConfigValue +// * (IntValue, FloatValue, StringValue) and supports JSON format. +// */ +// object ConfigValueSerializer : KSerializer { +// override val descriptor: SerialDescriptor = buildClassSerialDescriptor("ConfigValue") - override fun serialize(encoder: Encoder, value: ConfigValue) { - when (value) { - is ConfigValue.IntValue -> encoder.encodeInt(value.value) - is ConfigValue.FloatValue -> encoder.encodeFloat(value.value) - is ConfigValue.StringValue -> encoder.encodeString(value.value) - } - } +// override fun serialize(encoder: Encoder, value: ConfigValue) { +// when (value) { +// is ConfigValue.IntValue -> encoder.encodeInt(value.value) +// is ConfigValue.FloatValue -> encoder.encodeFloat(value.value) +// is ConfigValue.StringValue -> encoder.encodeString(value.value) +// } +// } - override fun deserialize(decoder: Decoder): ConfigValue { - val input = decoder as? JsonDecoder - ?: throw SerializationException("This serializer only works with Json") - return when (val element = input.decodeJsonElement()) { - is JsonPrimitive -> { - if (element.isString) { - ConfigValue.StringValue(element.content) - } else if (element.content.contains('.')) { - ConfigValue.FloatValue(element.content.toFloat()) - } else { - ConfigValue.IntValue(element.content.toInt()) - } - } +// override fun deserialize(decoder: Decoder): ConfigValue { +// val input = +// decoder as? JsonDecoder +// ?: throw SerializationException("This serializer only works with Json") +// return when (val element = input.decodeJsonElement()) { +// is JsonPrimitive -> { +// if (element.isString) { +// ConfigValue.StringValue(element.content) +// } else if (element.content.contains('.')) { +// ConfigValue.FloatValue(element.content.toFloat()) +// } else { +// ConfigValue.IntValue(element.content.toInt()) +// } +// } - else -> throw SerializationException("Expected JsonPrimitive") - } - } -} +// else -> throw SerializationException("Expected JsonPrimitive") +// } +// } +// } fun getIntConfigValue(configValue: ConfigValue?, default: Int): Int { if (configValue == null) { diff --git a/Android/src/app/src/main/java/com/google/ai/edge/gallery/data/Consts.kt b/Android/src/app/src/main/java/com/google/ai/edge/gallery/data/Consts.kt index 4f6bb97..a2209af 100644 --- a/Android/src/app/src/main/java/com/google/ai/edge/gallery/data/Consts.kt +++ b/Android/src/app/src/main/java/com/google/ai/edge/gallery/data/Consts.kt @@ -34,3 +34,13 @@ const val KEY_MODEL_EXTRA_DATA_DOWNLOAD_FILE_NAMES = "KEY_MODEL_EXTRA_DATA_DOWNL const val KEY_MODEL_IS_ZIP = "KEY_MODEL_IS_ZIP" const val KEY_MODEL_UNZIPPED_DIR = "KEY_MODEL_UNZIPPED_DIR" const val KEY_MODEL_START_UNZIPPING = "KEY_MODEL_START_UNZIPPING" + +// Default values for LLM models. +const val DEFAULT_MAX_TOKEN = 1024 +const val DEFAULT_TOPK = 40 +const val DEFAULT_TOPP = 0.9f +const val DEFAULT_TEMPERATURE = 1.0f +val DEFAULT_ACCELERATORS = listOf(Accelerator.GPU) + +// Max number of images allowed in a "ask image" session. +const val MAX_IMAGE_COUNT = 10 diff --git a/Android/src/app/src/main/java/com/google/ai/edge/gallery/data/DataStoreRepository.kt b/Android/src/app/src/main/java/com/google/ai/edge/gallery/data/DataStoreRepository.kt index 82749e9..c4d65de 100644 --- a/Android/src/app/src/main/java/com/google/ai/edge/gallery/data/DataStoreRepository.kt +++ b/Android/src/app/src/main/java/com/google/ai/edge/gallery/data/DataStoreRepository.kt @@ -16,231 +16,109 @@ package com.google.ai.edge.gallery.data -import android.security.keystore.KeyGenParameterSpec -import android.security.keystore.KeyProperties -import android.util.Base64 import androidx.datastore.core.DataStore -import androidx.datastore.preferences.core.Preferences -import androidx.datastore.preferences.core.edit -import androidx.datastore.preferences.core.longPreferencesKey -import androidx.datastore.preferences.core.stringPreferencesKey -import com.google.gson.Gson -import com.google.gson.reflect.TypeToken -import com.google.ai.edge.gallery.ui.theme.THEME_AUTO +import com.google.ai.edge.gallery.proto.AccessTokenData +import com.google.ai.edge.gallery.proto.ImportedModel +import com.google.ai.edge.gallery.proto.Settings +import com.google.ai.edge.gallery.proto.Theme import kotlinx.coroutines.flow.first import kotlinx.coroutines.runBlocking -import java.security.KeyStore -import javax.crypto.Cipher -import javax.crypto.KeyGenerator -import javax.crypto.SecretKey - -data class AccessTokenData( - val accessToken: String, - val refreshToken: String, - val expiresAtMs: Long -) +// TODO(b/423700720): Change to async (suspend) functions interface DataStoreRepository { fun saveTextInputHistory(history: List) + fun readTextInputHistory(): List - fun saveThemeOverride(theme: String) - fun readThemeOverride(): String + + fun saveTheme(theme: Theme) + + fun readTheme(): Theme + fun saveAccessTokenData(accessToken: String, refreshToken: String, expiresAt: Long) + fun clearAccessTokenData() + fun readAccessTokenData(): AccessTokenData? - fun saveImportedModels(importedModels: List) - fun readImportedModels(): List + + fun saveImportedModels(importedModels: List) + + fun readImportedModels(): List } -/** - * Repository for managing data using DataStore, with JSON serialization. - * - * This class provides methods to read, add, remove, and clear data stored in DataStore, - * using JSON serialization for complex objects. It uses Gson for serializing and deserializing - * lists of objects to/from JSON strings. - * - * DataStore is used to persist data as JSON strings under specified keys. - */ -class DefaultDataStoreRepository( - private val dataStore: DataStore -) : - DataStoreRepository { - - private object PreferencesKeys { - val TEXT_INPUT_HISTORY = stringPreferencesKey("text_input_history") - - val THEME_OVERRIDE = stringPreferencesKey("theme_override") - - val ENCRYPTED_ACCESS_TOKEN = stringPreferencesKey("encrypted_access_token") - - // Store Initialization Vector - val ACCESS_TOKEN_IV = stringPreferencesKey("access_token_iv") - - val ENCRYPTED_REFRESH_TOKEN = stringPreferencesKey("encrypted_refresh_token") - - // Store Initialization Vector - val REFRESH_TOKEN_IV = stringPreferencesKey("refresh_token_iv") - - val ACCESS_TOKEN_EXPIRES_AT = longPreferencesKey("access_token_expires_at") - - // Data for all imported models. - val IMPORTED_MODELS = stringPreferencesKey("imported_models") - } - - private val keystoreAlias: String = "com_google_aiedge_gallery_access_token_key" - private val keyStore: KeyStore = KeyStore.getInstance("AndroidKeyStore").apply { load(null) } - +/** Repository for managing data using Proto DataStore. */ +class DefaultDataStoreRepository(private val dataStore: DataStore) : DataStoreRepository { override fun saveTextInputHistory(history: List) { runBlocking { - dataStore.edit { preferences -> - val gson = Gson() - val jsonString = gson.toJson(history) - preferences[PreferencesKeys.TEXT_INPUT_HISTORY] = jsonString + dataStore.updateData { settings -> + settings.toBuilder().clearTextInputHistory().addAllTextInputHistory(history).build() } } } override fun readTextInputHistory(): List { return runBlocking { - val preferences = dataStore.data.first() - getTextInputHistory(preferences) + val settings = dataStore.data.first() + settings.textInputHistoryList } } - override fun saveThemeOverride(theme: String) { + override fun saveTheme(theme: Theme) { runBlocking { - dataStore.edit { preferences -> - preferences[PreferencesKeys.THEME_OVERRIDE] = theme - } + dataStore.updateData { settings -> settings.toBuilder().setTheme(theme).build() } } } - override fun readThemeOverride(): String { + override fun readTheme(): Theme { return runBlocking { - val preferences = dataStore.data.first() - preferences[PreferencesKeys.THEME_OVERRIDE] ?: THEME_AUTO + val settings = dataStore.data.first() + val curTheme = settings.theme + // Use "auto" as the default theme. + if (curTheme == Theme.THEME_UNSPECIFIED) Theme.THEME_AUTO else curTheme } } override fun saveAccessTokenData(accessToken: String, refreshToken: String, expiresAt: Long) { runBlocking { - val (encryptedAccessToken, accessTokenIv) = encrypt(accessToken) - val (encryptedRefreshToken, refreshTokenIv) = encrypt(refreshToken) - dataStore.edit { preferences -> - preferences[PreferencesKeys.ENCRYPTED_ACCESS_TOKEN] = encryptedAccessToken - preferences[PreferencesKeys.ACCESS_TOKEN_IV] = accessTokenIv - preferences[PreferencesKeys.ENCRYPTED_REFRESH_TOKEN] = encryptedRefreshToken - preferences[PreferencesKeys.REFRESH_TOKEN_IV] = refreshTokenIv - preferences[PreferencesKeys.ACCESS_TOKEN_EXPIRES_AT] = expiresAt + dataStore.updateData { settings -> + settings + .toBuilder() + .setAccessTokenData( + AccessTokenData.newBuilder() + .setAccessToken(accessToken) + .setRefreshToken(refreshToken) + .setExpiresAtMs(expiresAt) + .build() + ) + .build() } } } override fun clearAccessTokenData() { - return runBlocking { - dataStore.edit { preferences -> - preferences.remove(PreferencesKeys.ENCRYPTED_ACCESS_TOKEN) - preferences.remove(PreferencesKeys.ACCESS_TOKEN_IV) - preferences.remove(PreferencesKeys.ENCRYPTED_REFRESH_TOKEN) - preferences.remove(PreferencesKeys.REFRESH_TOKEN_IV) - preferences.remove(PreferencesKeys.ACCESS_TOKEN_EXPIRES_AT) - } + runBlocking { + dataStore.updateData { settings -> settings.toBuilder().clearAccessTokenData().build() } } } override fun readAccessTokenData(): AccessTokenData? { return runBlocking { - val preferences = dataStore.data.first() - val encryptedAccessToken = preferences[PreferencesKeys.ENCRYPTED_ACCESS_TOKEN] - val encryptedRefreshToken = preferences[PreferencesKeys.ENCRYPTED_REFRESH_TOKEN] - val accessTokenIv = preferences[PreferencesKeys.ACCESS_TOKEN_IV] - val refreshTokenIv = preferences[PreferencesKeys.REFRESH_TOKEN_IV] - val expiresAt = preferences[PreferencesKeys.ACCESS_TOKEN_EXPIRES_AT] - - var decryptedAccessToken: String? = null - var decryptedRefreshToken: String? = null - if (encryptedAccessToken != null && accessTokenIv != null) { - decryptedAccessToken = decrypt(encryptedAccessToken, accessTokenIv) - } - if (encryptedRefreshToken != null && refreshTokenIv != null) { - decryptedRefreshToken = decrypt(encryptedRefreshToken, refreshTokenIv) - } - if (decryptedAccessToken != null && decryptedRefreshToken != null && expiresAt != null) { - AccessTokenData(decryptedAccessToken, decryptedRefreshToken, expiresAt) - } else { - null - } + val settings = dataStore.data.first() + settings.accessTokenData } } - override fun saveImportedModels(importedModels: List) { + override fun saveImportedModels(importedModels: List) { runBlocking { - dataStore.edit { preferences -> - val gson = Gson() - val jsonString = gson.toJson(importedModels) - preferences[PreferencesKeys.IMPORTED_MODELS] = jsonString + dataStore.updateData { settings -> + settings.toBuilder().clearImportedModel().addAllImportedModel(importedModels).build() } } } - override fun readImportedModels(): List { + override fun readImportedModels(): List { return runBlocking { - val preferences = dataStore.data.first() - val infosStr = preferences[PreferencesKeys.IMPORTED_MODELS] ?: "[]" - val gson = Gson() - val listType = object : TypeToken>() {}.type - gson.fromJson(infosStr, listType) - } - } - - private fun getTextInputHistory(preferences: Preferences): List { - val infosStr = preferences[PreferencesKeys.TEXT_INPUT_HISTORY] ?: "[]" - val gson = Gson() - val listType = object : TypeToken>() {}.type - return gson.fromJson(infosStr, listType) - } - - private fun getOrCreateSecretKey(): SecretKey { - return (keyStore.getKey(keystoreAlias, null) as? SecretKey) ?: run { - val keyGenerator = - KeyGenerator.getInstance(KeyProperties.KEY_ALGORITHM_AES, "AndroidKeyStore") - val keyGenParameterSpec = KeyGenParameterSpec.Builder( - keystoreAlias, - KeyProperties.PURPOSE_ENCRYPT or KeyProperties.PURPOSE_DECRYPT - ) - .setBlockModes(KeyProperties.BLOCK_MODE_GCM) - .setEncryptionPaddings(KeyProperties.ENCRYPTION_PADDING_NONE) - .setUserAuthenticationRequired(false) // Consider setting to true for added security - .build() - keyGenerator.init(keyGenParameterSpec) - keyGenerator.generateKey() - } - } - - private fun encrypt(plainText: String): Pair { - val secretKey = getOrCreateSecretKey() - val cipher = Cipher.getInstance("AES/GCM/NoPadding") - cipher.init(Cipher.ENCRYPT_MODE, secretKey) - val iv = cipher.iv - val encryptedBytes = cipher.doFinal(plainText.toByteArray(Charsets.UTF_8)) - return Base64.encodeToString(encryptedBytes, Base64.DEFAULT) to Base64.encodeToString( - iv, - Base64.DEFAULT - ) - } - - private fun decrypt(encryptedText: String, ivText: String): String? { - val secretKey = getOrCreateSecretKey() - val cipher = Cipher.getInstance("AES/GCM/NoPadding") - val ivBytes = Base64.decode(ivText, Base64.DEFAULT) - val spec = javax.crypto.spec.GCMParameterSpec(128, ivBytes) // 128 bit tag length - cipher.init(Cipher.DECRYPT_MODE, secretKey, spec) - val encryptedBytes = Base64.decode(encryptedText, Base64.DEFAULT) - return try { - String(cipher.doFinal(encryptedBytes), Charsets.UTF_8) - } catch (e: Exception) { - // Handle decryption errors (e.g., key not found) - null + val settings = dataStore.data.first() + settings.importedModelList } } } diff --git a/Android/src/app/src/main/java/com/google/ai/edge/gallery/data/DownloadRepository.kt b/Android/src/app/src/main/java/com/google/ai/edge/gallery/data/DownloadRepository.kt index 065c472..2294fca 100644 --- a/Android/src/app/src/main/java/com/google/ai/edge/gallery/data/DownloadRepository.kt +++ b/Android/src/app/src/main/java/com/google/ai/edge/gallery/data/DownloadRepository.kt @@ -23,11 +23,11 @@ import android.app.PendingIntent import android.content.Context import android.content.Intent import android.content.pm.PackageManager -import android.net.Uri import android.util.Log import androidx.core.app.ActivityCompat import androidx.core.app.NotificationCompat import androidx.core.app.NotificationManagerCompat +import androidx.core.net.toUri import androidx.work.Data import androidx.work.ExistingWorkPolicy import androidx.work.OneTimeWorkRequestBuilder @@ -38,7 +38,7 @@ import androidx.work.WorkManager import androidx.work.WorkQuery import com.google.ai.edge.gallery.AppLifecycleProvider import com.google.ai.edge.gallery.R -import com.google.ai.edge.gallery.ui.common.readLaunchInfo +import com.google.ai.edge.gallery.common.readLaunchInfo import com.google.ai.edge.gallery.worker.DownloadWorker import com.google.common.util.concurrent.FutureCallback import com.google.common.util.concurrent.Futures @@ -53,7 +53,8 @@ data class AGWorkInfo(val modelName: String, val workId: String) interface DownloadRepository { fun downloadModel( - model: Model, onStatusUpdated: (model: Model, status: ModelDownloadStatus) -> Unit + model: Model, + onStatusUpdated: (model: Model, status: ModelDownloadStatus) -> Unit, ) fun cancelDownloadModel(model: Model) @@ -83,7 +84,8 @@ class DefaultDownloadRepository( private val workManager = WorkManager.getInstance(context) override fun downloadModel( - model: Model, onStatusUpdated: (model: Model, status: ModelDownloadStatus) -> Unit + model: Model, + onStatusUpdated: (model: Model, status: ModelDownloadStatus) -> Unit, ) { val appTs = readLaunchInfo(context = context)?.ts ?: 0 @@ -91,18 +93,24 @@ class DefaultDownloadRepository( val builder = Data.Builder() val totalBytes = model.totalBytes + model.extraDataFiles.sumOf { it.sizeInBytes } val inputDataBuilder = - builder.putString(KEY_MODEL_NAME, model.name).putString(KEY_MODEL_URL, model.url) + builder + .putString(KEY_MODEL_NAME, model.name) + .putString(KEY_MODEL_URL, model.url) .putString(KEY_MODEL_VERSION, model.version) .putString(KEY_MODEL_DOWNLOAD_MODEL_DIR, model.normalizedName) .putString(KEY_MODEL_DOWNLOAD_FILE_NAME, model.downloadFileName) - .putBoolean(KEY_MODEL_IS_ZIP, model.isZip).putString(KEY_MODEL_UNZIPPED_DIR, model.unzipDir) - .putLong(KEY_MODEL_TOTAL_BYTES, totalBytes).putLong(KEY_MODEL_DOWNLOAD_APP_TS, appTs) + .putBoolean(KEY_MODEL_IS_ZIP, model.isZip) + .putString(KEY_MODEL_UNZIPPED_DIR, model.unzipDir) + .putLong(KEY_MODEL_TOTAL_BYTES, totalBytes) + .putLong(KEY_MODEL_DOWNLOAD_APP_TS, appTs) if (model.extraDataFiles.isNotEmpty()) { - inputDataBuilder.putString(KEY_MODEL_EXTRA_DATA_URLS, - model.extraDataFiles.joinToString(",") { it.url }).putString( - KEY_MODEL_EXTRA_DATA_DOWNLOAD_FILE_NAMES, - model.extraDataFiles.joinToString(",") { it.downloadFileName }) + inputDataBuilder + .putString(KEY_MODEL_EXTRA_DATA_URLS, model.extraDataFiles.joinToString(",") { it.url }) + .putString( + KEY_MODEL_EXTRA_DATA_DOWNLOAD_FILE_NAMES, + model.extraDataFiles.joinToString(",") { it.downloadFileName }, + ) } if (model.accessToken != null) { inputDataBuilder.putString(KEY_MODEL_DOWNLOAD_ACCESS_TOKEN, model.accessToken) @@ -111,20 +119,19 @@ class DefaultDownloadRepository( // Create worker request. val downloadWorkRequest = - OneTimeWorkRequestBuilder().setExpedited(OutOfQuotaPolicy.RUN_AS_NON_EXPEDITED_WORK_REQUEST) - .setInputData(inputData).addTag("$MODEL_NAME_TAG:${model.name}").build() + OneTimeWorkRequestBuilder() + .setExpedited(OutOfQuotaPolicy.RUN_AS_NON_EXPEDITED_WORK_REQUEST) + .setInputData(inputData) + .addTag("$MODEL_NAME_TAG:${model.name}") + .build() val workerId = downloadWorkRequest.id // Start! - workManager.enqueueUniqueWork( - model.name, ExistingWorkPolicy.REPLACE, downloadWorkRequest - ) + workManager.enqueueUniqueWork(model.name, ExistingWorkPolicy.REPLACE, downloadWorkRequest) // Observe progress. - observerWorkerProgress( - workerId = workerId, model = model, onStatusUpdated = onStatusUpdated - ) + observerWorkerProgress(workerId = workerId, model = model, onStatusUpdated = onStatusUpdated) } override fun cancelDownloadModel(model: Model) { @@ -143,7 +150,8 @@ class DefaultDownloadRepository( } val combinedFuture: ListenableFuture> = Futures.allAsList(futures) Futures.addCallback( - combinedFuture, object : FutureCallback> { + combinedFuture, + object : FutureCallback> { override fun onSuccess(result: List?) { // All cancellations are complete onComplete() @@ -154,7 +162,8 @@ class DefaultDownloadRepository( t.printStackTrace() onComplete() } - }, MoreExecutors.directExecutor() + }, + MoreExecutors.directExecutor(), ) } @@ -175,45 +184,41 @@ class DefaultDownloadRepository( if (!startUnzipping) { if (receivedBytes != 0L) { onStatusUpdated( - model, ModelDownloadStatus( + model, + ModelDownloadStatus( status = ModelDownloadStatusType.IN_PROGRESS, totalBytes = model.totalBytes, receivedBytes = receivedBytes, bytesPerSecond = downloadRate, remainingMs = remainingSeconds, - ) + ), ) } } else { onStatusUpdated( - model, ModelDownloadStatus( - status = ModelDownloadStatusType.UNZIPPING, - ) + model, + ModelDownloadStatus(status = ModelDownloadStatusType.UNZIPPING), ) } } WorkInfo.State.SUCCEEDED -> { Log.d("repo", "worker %s success".format(workerId.toString())) - onStatusUpdated( - model, ModelDownloadStatus( - status = ModelDownloadStatusType.SUCCEEDED, - ) - ) + onStatusUpdated(model, ModelDownloadStatus(status = ModelDownloadStatusType.SUCCEEDED)) sendNotification( - title = context.getString( - R.string.notification_title_success - ), + title = context.getString(R.string.notification_title_success), text = context.getString(R.string.notification_content_success).format(model.name), modelName = model.name, ) } - WorkInfo.State.FAILED, WorkInfo.State.CANCELLED -> { + WorkInfo.State.FAILED, + WorkInfo.State.CANCELLED -> { var status = ModelDownloadStatusType.FAILED val errorMessage = workInfo.outputData.getString(KEY_MODEL_DOWNLOAD_ERROR_MESSAGE) ?: "" Log.d( - "repo", "worker %s FAILED or CANCELLED: %s".format(workerId.toString(), errorMessage) + "repo", + "worker %s FAILED or CANCELLED: %s".format(workerId.toString(), errorMessage), ) if (workInfo.state == WorkInfo.State.CANCELLED) { status = ModelDownloadStatusType.NOT_DOWNLOADED @@ -225,7 +230,8 @@ class DefaultDownloadRepository( ) } onStatusUpdated( - model, ModelDownloadStatus(status = status, errorMessage = errorMessage) + model, + ModelDownloadStatus(status = status, errorMessage = errorMessage), ) } @@ -278,29 +284,35 @@ class DefaultDownloadRepository( notificationManager.createNotificationChannel(channel) // Create an Intent to open your app with a deep link. - val intent = Intent( - Intent.ACTION_VIEW, Uri.parse("com.google.ai.edge.gallery://model/${modelName}") - ).apply { - flags = Intent.FLAG_ACTIVITY_NEW_TASK - } + val intent = + Intent(Intent.ACTION_VIEW, "com.google.ai.edge.gallery://model/${modelName}".toUri()).apply { + flags = Intent.FLAG_ACTIVITY_NEW_TASK + } // Create a PendingIntent - val pendingIntent: PendingIntent = PendingIntent.getActivity( - context, 0, intent, PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE - ) + val pendingIntent: PendingIntent = + PendingIntent.getActivity( + context, + 0, + intent, + PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE, + ) - - val builder = NotificationCompat.Builder(context, channelId) - // TODO: replace icon. - .setSmallIcon(android.R.drawable.ic_dialog_info).setContentTitle(title).setContentText(text) - .setPriority(NotificationCompat.PRIORITY_HIGH).setContentIntent(pendingIntent) - .setAutoCancel(true) + val builder = + NotificationCompat.Builder(context, channelId) + // TODO: replace icon. + .setSmallIcon(android.R.drawable.ic_dialog_info) + .setContentTitle(title) + .setContentText(text) + .setPriority(NotificationCompat.PRIORITY_HIGH) + .setContentIntent(pendingIntent) + .setAutoCancel(true) with(NotificationManagerCompat.from(context)) { // notificationId is a unique int for each notification that you must define - if (ActivityCompat.checkSelfPermission( - context, Manifest.permission.POST_NOTIFICATIONS - ) != PackageManager.PERMISSION_GRANTED + if ( + ActivityCompat.checkSelfPermission(context, Manifest.permission.POST_NOTIFICATIONS) != + PackageManager.PERMISSION_GRANTED ) { // Permission not granted, return or handle accordingly. In real app, request permission. return diff --git a/Android/src/app/src/main/java/com/google/ai/edge/gallery/data/Model.kt b/Android/src/app/src/main/java/com/google/ai/edge/gallery/data/Model.kt index e79613a..580bf2b 100644 --- a/Android/src/app/src/main/java/com/google/ai/edge/gallery/data/Model.kt +++ b/Android/src/app/src/main/java/com/google/ai/edge/gallery/data/Model.kt @@ -17,8 +17,6 @@ package com.google.ai.edge.gallery.data import android.content.Context -import com.google.ai.edge.gallery.ui.common.chat.PromptTemplate -import com.google.ai.edge.gallery.ui.common.convertValueToTargetType import java.io.File data class ModelDataFile( @@ -28,13 +26,11 @@ data class ModelDataFile( val sizeInBytes: Long, ) -enum class Accelerator(val label: String) { - CPU(label = "CPU"), GPU(label = "GPU") -} - const val IMPORTS_DIR = "__imports" private val NORMALIZE_NAME_REGEX = Regex("[^a-zA-Z0-9]") +data class PromptTemplate(val title: String, val description: String, val prompt: String) + /** A model for a task */ data class Model( /** The name (for display purpose) of the model. */ @@ -67,9 +63,7 @@ data class Model( */ val info: String = "", - /** - * The url to jump to when clicking "learn more" in expanded model item. - */ + /** The url to jump to when clicking "learn more" in expanded model item. */ val learnMoreUrl: String = "", /** A list of configurable parameters for the model. */ @@ -105,6 +99,9 @@ data class Model( var configValues: Map = mapOf(), var totalBytes: Long = 0L, var accessToken: String? = null, + + /** The estimated peak memory in byte to run the model. */ + val estimatedPeakMemoryInBytes: Long? = null, ) { init { normalizedName = NORMALIZE_NAME_REGEX.replace(name, "_") @@ -121,17 +118,13 @@ data class Model( fun getPath(context: Context, fileName: String = downloadFileName): String { if (imported) { - return listOf(context.getExternalFilesDir(null)?.absolutePath ?: "", fileName).joinToString( - File.separator - ) + return listOf(context.getExternalFilesDir(null)?.absolutePath ?: "", fileName) + .joinToString(File.separator) } val baseDir = - listOf( - context.getExternalFilesDir(null)?.absolutePath ?: "", - normalizedName, - version - ).joinToString(File.separator) + listOf(context.getExternalFilesDir(null)?.absolutePath ?: "", normalizedName, version) + .joinToString(File.separator) return if (this.isZip && this.unzipDir.isNotEmpty()) { "$baseDir/${this.unzipDir}" } else { @@ -140,27 +133,27 @@ data class Model( } fun getIntConfigValue(key: ConfigKey, defaultValue: Int = 0): Int { - return getTypedConfigValue( - key = key, valueType = ValueType.INT, defaultValue = defaultValue - ) as Int + return getTypedConfigValue(key = key, valueType = ValueType.INT, defaultValue = defaultValue) + as Int } fun getFloatConfigValue(key: ConfigKey, defaultValue: Float = 0.0f): Float { - return getTypedConfigValue( - key = key, valueType = ValueType.FLOAT, defaultValue = defaultValue - ) as Float + return getTypedConfigValue(key = key, valueType = ValueType.FLOAT, defaultValue = defaultValue) + as Float } fun getBooleanConfigValue(key: ConfigKey, defaultValue: Boolean = false): Boolean { return getTypedConfigValue( - key = key, valueType = ValueType.BOOLEAN, defaultValue = defaultValue - ) as Boolean + key = key, + valueType = ValueType.BOOLEAN, + defaultValue = defaultValue, + ) + as Boolean } fun getStringConfigValue(key: ConfigKey, defaultValue: String = ""): String { - return getTypedConfigValue( - key = key, valueType = ValueType.STRING, defaultValue = defaultValue - ) as String + return getTypedConfigValue(key = key, valueType = ValueType.STRING, defaultValue = defaultValue) + as String } fun getExtraDataFile(name: String): ModelDataFile? { @@ -169,20 +162,19 @@ data class Model( private fun getTypedConfigValue(key: ConfigKey, valueType: ValueType, defaultValue: Any): Any { return convertValueToTargetType( - value = configValues.getOrDefault(key.label, defaultValue), valueType = valueType + value = configValues.getOrDefault(key.label, defaultValue), + valueType = valueType, ) } } -/** Data for a imported local model. */ -data class ImportedModelInfo( - val fileName: String, - val fileSize: Long, - val defaultValues: Map -) - enum class ModelDownloadStatusType { - NOT_DOWNLOADED, PARTIALLY_DOWNLOADED, IN_PROGRESS, UNZIPPING, SUCCEEDED, FAILED, + NOT_DOWNLOADED, + PARTIALLY_DOWNLOADED, + IN_PROGRESS, + UNZIPPING, + SUCCEEDED, + FAILED, } data class ModelDownloadStatus( @@ -197,51 +189,29 @@ data class ModelDownloadStatus( //////////////////////////////////////////////////////////////////////////////////////////////////// // Configs. -enum class ConfigKey(val label: String) { - MAX_TOKENS("Max tokens"), - TOPK("TopK"), - TOPP("TopP"), - TEMPERATURE("Temperature"), - DEFAULT_MAX_TOKENS("Default max tokens"), - DEFAULT_TOPK("Default TopK"), - DEFAULT_TOPP("Default TopP"), - DEFAULT_TEMPERATURE("Default temperature"), - SUPPORT_IMAGE("Support image"), - MAX_RESULT_COUNT("Max result count"), - USE_GPU("Use GPU"), - ACCELERATOR("Choose accelerator"), - COMPATIBLE_ACCELERATORS("Compatible accelerators"), - WARM_UP_ITERATIONS("Warm up iterations"), - BENCHMARK_ITERATIONS("Benchmark iterations"), - ITERATIONS("Iterations"), - THEME("Theme"), - NAME("Name"), - MODEL_TYPE("Model type") -} - -val MOBILENET_CONFIGS: List = listOf( - NumberSliderConfig( - key = ConfigKey.MAX_RESULT_COUNT, - sliderMin = 1f, - sliderMax = 5f, - defaultValue = 3f, - valueType = ValueType.INT - ), BooleanSwitchConfig( - key = ConfigKey.USE_GPU, - defaultValue = false, +val MOBILENET_CONFIGS: List = + listOf( + NumberSliderConfig( + key = ConfigKey.MAX_RESULT_COUNT, + sliderMin = 1f, + sliderMax = 5f, + defaultValue = 3f, + valueType = ValueType.INT, + ), + BooleanSwitchConfig(key = ConfigKey.USE_GPU, defaultValue = false), ) -) -val IMAGE_GENERATION_CONFIGS: List = listOf( - NumberSliderConfig( - key = ConfigKey.ITERATIONS, - sliderMin = 5f, - sliderMax = 50f, - defaultValue = 10f, - valueType = ValueType.INT, - needReinitialization = false, +val IMAGE_GENERATION_CONFIGS: List = + listOf( + NumberSliderConfig( + key = ConfigKey.ITERATIONS, + sliderMin = 5f, + sliderMax = 50f, + defaultValue = 10f, + valueType = ValueType.INT, + needReinitialization = false, + ) ) -) const val TEXT_CLASSIFICATION_INFO = "Model is trained on movie reviews dataset. Type a movie review below and see the scores of positive or negative sentiment." @@ -256,92 +226,97 @@ const val IMAGE_CLASSIFICATION_LEARN_MORE_URL = "https://ai.google.dev/edge/lite const val IMAGE_GENERATION_INFO = "Powered by [MediaPipe Image Generation API](https://ai.google.dev/edge/mediapipe/solutions/vision/image_generator/android)" +val MODEL_TEXT_CLASSIFICATION_MOBILEBERT: Model = + Model( + name = "MobileBert", + downloadFileName = "bert_classifier.tflite", + url = + "https://storage.googleapis.com/mediapipe-models/text_classifier/bert_classifier/float32/latest/bert_classifier.tflite", + sizeInBytes = 25707538L, + info = TEXT_CLASSIFICATION_INFO, + learnMoreUrl = TEXT_CLASSIFICATION_LEARN_MORE_URL, + ) -val MODEL_TEXT_CLASSIFICATION_MOBILEBERT: Model = Model( - name = "MobileBert", - downloadFileName = "bert_classifier.tflite", - url = "https://storage.googleapis.com/mediapipe-models/text_classifier/bert_classifier/float32/latest/bert_classifier.tflite", - sizeInBytes = 25707538L, - info = TEXT_CLASSIFICATION_INFO, - learnMoreUrl = TEXT_CLASSIFICATION_LEARN_MORE_URL, -) +val MODEL_TEXT_CLASSIFICATION_AVERAGE_WORD_EMBEDDING: Model = + Model( + name = "Average word embedding", + downloadFileName = "average_word_classifier.tflite", + url = + "https://storage.googleapis.com/mediapipe-models/text_classifier/average_word_classifier/float32/latest/average_word_classifier.tflite", + sizeInBytes = 775708L, + info = TEXT_CLASSIFICATION_INFO, + ) -val MODEL_TEXT_CLASSIFICATION_AVERAGE_WORD_EMBEDDING: Model = Model( - name = "Average word embedding", - downloadFileName = "average_word_classifier.tflite", - url = "https://storage.googleapis.com/mediapipe-models/text_classifier/average_word_classifier/float32/latest/average_word_classifier.tflite", - sizeInBytes = 775708L, - info = TEXT_CLASSIFICATION_INFO, -) +val MODEL_IMAGE_CLASSIFICATION_MOBILENET_V1: Model = + Model( + name = "Mobilenet V1", + downloadFileName = "mobilenet_v1.tflite", + url = "https://storage.googleapis.com/tfweb/app_gallery_models/mobilenet_v1.tflite", + sizeInBytes = 16900760L, + extraDataFiles = + listOf( + ModelDataFile( + name = "labels", + url = + "https://raw.githubusercontent.com/leferrad/tensorflow-mobilenet/refs/heads/master/imagenet/labels.txt", + downloadFileName = "mobilenet_labels_v1.txt", + sizeInBytes = 21685L, + ) + ), + configs = MOBILENET_CONFIGS, + info = IMAGE_CLASSIFICATION_INFO, + learnMoreUrl = IMAGE_CLASSIFICATION_LEARN_MORE_URL, + ) -val MODEL_IMAGE_CLASSIFICATION_MOBILENET_V1: Model = Model( - name = "Mobilenet V1", - downloadFileName = "mobilenet_v1.tflite", - url = "https://storage.googleapis.com/tfweb/app_gallery_models/mobilenet_v1.tflite", - sizeInBytes = 16900760L, - extraDataFiles = listOf( - ModelDataFile( - name = "labels", - url = "https://raw.githubusercontent.com/leferrad/tensorflow-mobilenet/refs/heads/master/imagenet/labels.txt", - downloadFileName = "mobilenet_labels_v1.txt", - sizeInBytes = 21685L - ), - ), - configs = MOBILENET_CONFIGS, - info = IMAGE_CLASSIFICATION_INFO, - learnMoreUrl = IMAGE_CLASSIFICATION_LEARN_MORE_URL, -) +val MODEL_IMAGE_CLASSIFICATION_MOBILENET_V2: Model = + Model( + name = "Mobilenet V2", + downloadFileName = "mobilenet_v2.tflite", + url = "https://storage.googleapis.com/tfweb/app_gallery_models/mobilenet_v2.tflite", + sizeInBytes = 13978596L, + extraDataFiles = + listOf( + ModelDataFile( + name = "labels", + url = + "https://raw.githubusercontent.com/leferrad/tensorflow-mobilenet/refs/heads/master/imagenet/labels.txt", + downloadFileName = "mobilenet_labels_v2.txt", + sizeInBytes = 21685L, + ) + ), + configs = MOBILENET_CONFIGS, + info = IMAGE_CLASSIFICATION_INFO, + ) -val MODEL_IMAGE_CLASSIFICATION_MOBILENET_V2: Model = Model( - name = "Mobilenet V2", - downloadFileName = "mobilenet_v2.tflite", - url = "https://storage.googleapis.com/tfweb/app_gallery_models/mobilenet_v2.tflite", - sizeInBytes = 13978596L, - extraDataFiles = listOf( - ModelDataFile( - name = "labels", - url = "https://raw.githubusercontent.com/leferrad/tensorflow-mobilenet/refs/heads/master/imagenet/labels.txt", - downloadFileName = "mobilenet_labels_v2.txt", - sizeInBytes = 21685L - ), - ), - configs = MOBILENET_CONFIGS, - info = IMAGE_CLASSIFICATION_INFO, -) +val MODEL_IMAGE_GENERATION_STABLE_DIFFUSION: Model = + Model( + name = "Stable diffusion", + downloadFileName = "sd15.zip", + isZip = true, + unzipDir = "sd15", + url = "https://storage.googleapis.com/tfweb/app_gallery_models/sd15.zip", + sizeInBytes = 1906219565L, + showRunAgainButton = false, + showBenchmarkButton = false, + info = IMAGE_GENERATION_INFO, + configs = IMAGE_GENERATION_CONFIGS, + learnMoreUrl = "https://huggingface.co/litert-community", + ) -val MODEL_IMAGE_GENERATION_STABLE_DIFFUSION: Model = Model( - name = "Stable diffusion", - downloadFileName = "sd15.zip", - isZip = true, - unzipDir = "sd15", - url = "https://storage.googleapis.com/tfweb/app_gallery_models/sd15.zip", - sizeInBytes = 1906219565L, - showRunAgainButton = false, - showBenchmarkButton = false, - info = IMAGE_GENERATION_INFO, - configs = IMAGE_GENERATION_CONFIGS, - learnMoreUrl = "https://huggingface.co/litert-community", -) - -val EMPTY_MODEL: Model = Model( - name = "empty", - downloadFileName = "empty.tflite", - url = "", - sizeInBytes = 0L, -) +val EMPTY_MODEL: Model = + Model(name = "empty", downloadFileName = "empty.tflite", url = "", sizeInBytes = 0L) //////////////////////////////////////////////////////////////////////////////////////////////////// // Model collections for different tasks. -val MODELS_TEXT_CLASSIFICATION: MutableList = mutableListOf( - MODEL_TEXT_CLASSIFICATION_MOBILEBERT, - MODEL_TEXT_CLASSIFICATION_AVERAGE_WORD_EMBEDDING, -) +val MODELS_TEXT_CLASSIFICATION: MutableList = + mutableListOf( + MODEL_TEXT_CLASSIFICATION_MOBILEBERT, + MODEL_TEXT_CLASSIFICATION_AVERAGE_WORD_EMBEDDING, + ) -val MODELS_IMAGE_CLASSIFICATION: MutableList = mutableListOf( - MODEL_IMAGE_CLASSIFICATION_MOBILENET_V1, - MODEL_IMAGE_CLASSIFICATION_MOBILENET_V2, -) +val MODELS_IMAGE_CLASSIFICATION: MutableList = + mutableListOf(MODEL_IMAGE_CLASSIFICATION_MOBILENET_V1, MODEL_IMAGE_CLASSIFICATION_MOBILENET_V2) val MODELS_IMAGE_GENERATION: MutableList = mutableListOf(MODEL_IMAGE_GENERATION_STABLE_DIFFUSION) diff --git a/Android/src/app/src/main/java/com/google/ai/edge/gallery/data/ModelAllowlist.kt b/Android/src/app/src/main/java/com/google/ai/edge/gallery/data/ModelAllowlist.kt index ffb8211..f336638 100644 --- a/Android/src/app/src/main/java/com/google/ai/edge/gallery/data/ModelAllowlist.kt +++ b/Android/src/app/src/main/java/com/google/ai/edge/gallery/data/ModelAllowlist.kt @@ -16,15 +16,17 @@ package com.google.ai.edge.gallery.data -import com.google.ai.edge.gallery.ui.llmchat.DEFAULT_ACCELERATORS -import com.google.ai.edge.gallery.ui.llmchat.DEFAULT_TEMPERATURE -import com.google.ai.edge.gallery.ui.llmchat.DEFAULT_TOPK -import com.google.ai.edge.gallery.ui.llmchat.DEFAULT_TOPP -import com.google.ai.edge.gallery.ui.llmchat.createLlmChatConfigs -import kotlinx.serialization.Serializable +import com.google.gson.annotations.SerializedName + +data class DefaultConfig( + @SerializedName("topK") val topK: Int?, + @SerializedName("topP") val topP: Float?, + @SerializedName("temperature") val temperature: Float?, + @SerializedName("accelerators") val accelerators: String?, + @SerializedName("maxTokens") val maxTokens: Int?, +) /** A model in the model allowlist. */ -@Serializable data class AllowedModel( val name: String, val modelId: String, @@ -32,10 +34,11 @@ data class AllowedModel( val description: String, val sizeInBytes: Long, val version: String, - val defaultConfig: Map, + val defaultConfig: DefaultConfig, val taskTypes: List, val disabled: Boolean? = null, val llmSupportImage: Boolean? = null, + val estimatedPeakMemoryInBytes: Long? = null, ) { fun toModel(): Model { // Construct HF download url. @@ -46,25 +49,13 @@ data class AllowedModel( taskTypes.contains(TASK_LLM_CHAT.type.id) || taskTypes.contains(TASK_LLM_PROMPT_LAB.type.id) var configs: List = listOf() if (isLlmModel) { - var defaultTopK: Int = DEFAULT_TOPK - var defaultTopP: Float = DEFAULT_TOPP - var defaultTemperature: Float = DEFAULT_TEMPERATURE - var defaultMaxToken = 1024 + var defaultTopK: Int = defaultConfig.topK ?: DEFAULT_TOPK + var defaultTopP: Float = defaultConfig.topP ?: DEFAULT_TOPP + var defaultTemperature: Float = defaultConfig.temperature ?: DEFAULT_TEMPERATURE + var defaultMaxToken = defaultConfig.maxTokens ?: 1024 var accelerators: List = DEFAULT_ACCELERATORS - if (defaultConfig.containsKey("topK")) { - defaultTopK = getIntConfigValue(defaultConfig["topK"], defaultTopK) - } - if (defaultConfig.containsKey("topP")) { - defaultTopP = getFloatConfigValue(defaultConfig["topP"], defaultTopP) - } - if (defaultConfig.containsKey("temperature")) { - defaultTemperature = getFloatConfigValue(defaultConfig["temperature"], defaultTemperature) - } - if (defaultConfig.containsKey("maxTokens")) { - defaultMaxToken = getIntConfigValue(defaultConfig["maxTokens"], defaultMaxToken) - } - if (defaultConfig.containsKey("accelerators")) { - val items = getStringConfigValue(defaultConfig["accelerators"], "gpu").split(",") + if (defaultConfig.accelerators != null) { + val items = defaultConfig.accelerators.split(",") accelerators = mutableListOf() for (item in items) { if (item == "cpu") { @@ -74,13 +65,14 @@ data class AllowedModel( } } } - configs = createLlmChatConfigs( - defaultTopK = defaultTopK, - defaultTopP = defaultTopP, - defaultTemperature = defaultTemperature, - defaultMaxToken = defaultMaxToken, - accelerators = accelerators, - ) + configs = + createLlmChatConfigs( + defaultTopK = defaultTopK, + defaultTopP = defaultTopP, + defaultTemperature = defaultTemperature, + defaultMaxToken = defaultMaxToken, + accelerators = accelerators, + ) } // Misc. @@ -97,6 +89,7 @@ data class AllowedModel( info = description, url = downloadUrl, sizeInBytes = sizeInBytes, + estimatedPeakMemoryInBytes = estimatedPeakMemoryInBytes, configs = configs, downloadFileName = modelFile, showBenchmarkButton = showBenchmarkButton, @@ -112,8 +105,4 @@ data class AllowedModel( } /** The model allowlist. */ -@Serializable -data class ModelAllowlist( - val models: List, -) - +data class ModelAllowlist(val models: List) diff --git a/Android/src/app/src/main/java/com/google/ai/edge/gallery/data/Tasks.kt b/Android/src/app/src/main/java/com/google/ai/edge/gallery/data/Tasks.kt index 41a2314..e95feab 100644 --- a/Android/src/app/src/main/java/com/google/ai/edge/gallery/data/Tasks.kt +++ b/Android/src/app/src/main/java/com/google/ai/edge/gallery/data/Tasks.kt @@ -17,27 +17,21 @@ package com.google.ai.edge.gallery.data import androidx.annotation.StringRes -import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.outlined.Forum -import androidx.compose.material.icons.outlined.Mms -import androidx.compose.material.icons.outlined.Widgets -import androidx.compose.material.icons.rounded.ImageSearch import androidx.compose.runtime.MutableState import androidx.compose.runtime.mutableLongStateOf import androidx.compose.ui.graphics.vector.ImageVector import com.google.ai.edge.gallery.R +import com.google.ai.edge.gallery.ui.icon.Forum +import com.google.ai.edge.gallery.ui.icon.Mms +import com.google.ai.edge.gallery.ui.icon.Widgets /** Type of task. */ enum class TaskType(val label: String, val id: String) { - TEXT_CLASSIFICATION(label = "Text Classification", id = "text_classification"), - IMAGE_CLASSIFICATION(label = "Image Classification", id = "image_classification"), - IMAGE_GENERATION(label = "Image Generation", id = "image_generation"), LLM_CHAT(label = "AI Chat", id = "llm_chat"), LLM_PROMPT_LAB(label = "Prompt Lab", id = "llm_prompt_lab"), LLM_ASK_IMAGE(label = "Ask Image", id = "llm_ask_image"), - TEST_TASK_1(label = "Test task 1", id = "test_task_1"), - TEST_TASK_2(label = "Test task 2", id = "test_task_2") + TEST_TASK_2(label = "Test task 2", id = "test_task_2"), } /** Data class for a task listed in home screen. */ @@ -71,71 +65,47 @@ data class Task( // The following fields are managed by the app. Don't need to set manually. var index: Int = -1, - - val updateTrigger: MutableState = mutableLongStateOf(0) + val updateTrigger: MutableState = mutableLongStateOf(0), ) -val TASK_TEXT_CLASSIFICATION = Task( - type = TaskType.TEXT_CLASSIFICATION, - iconVectorResourceId = R.drawable.text_spark, - models = MODELS_TEXT_CLASSIFICATION, - description = "Classify text into different categories", - textInputPlaceHolderRes = R.string.text_input_placeholder_text_classification -) +val TASK_LLM_CHAT = + Task( + type = TaskType.LLM_CHAT, + icon = Forum, + models = mutableListOf(), + description = "Chat with on-device large language models", + docUrl = "https://ai.google.dev/edge/mediapipe/solutions/genai/llm_inference/android", + sourceCodeUrl = + "https://github.com/google-ai-edge/gallery/blob/main/Android/src/app/src/main/java/com/google/ai/edge/gallery/ui/llmchat/LlmChatModelHelper.kt", + textInputPlaceHolderRes = R.string.text_input_placeholder_llm_chat, + ) -val TASK_IMAGE_CLASSIFICATION = Task( - type = TaskType.IMAGE_CLASSIFICATION, - icon = Icons.Rounded.ImageSearch, - description = "Classify images into different categories", - models = MODELS_IMAGE_CLASSIFICATION -) +val TASK_LLM_PROMPT_LAB = + Task( + type = TaskType.LLM_PROMPT_LAB, + icon = Widgets, + models = mutableListOf(), + description = "Single turn use cases with on-device large language model", + docUrl = "https://ai.google.dev/edge/mediapipe/solutions/genai/llm_inference/android", + sourceCodeUrl = + "https://github.com/google-ai-edge/gallery/blob/main/Android/src/app/src/main/java/com/google/ai/edge/gallery/ui/llmchat/LlmChatModelHelper.kt", + textInputPlaceHolderRes = R.string.text_input_placeholder_llm_chat, + ) -val TASK_LLM_CHAT = Task( - type = TaskType.LLM_CHAT, - icon = Icons.Outlined.Forum, - models = mutableListOf(), - description = "Chat with on-device large language models", - docUrl = "https://ai.google.dev/edge/mediapipe/solutions/genai/llm_inference/android", - sourceCodeUrl = "https://github.com/google-ai-edge/gallery/blob/main/Android/src/app/src/main/java/com/google/ai/edge/gallery/ui/llmchat/LlmChatModelHelper.kt", - textInputPlaceHolderRes = R.string.text_input_placeholder_llm_chat -) - -val TASK_LLM_PROMPT_LAB = Task( - type = TaskType.LLM_PROMPT_LAB, - icon = Icons.Outlined.Widgets, - models = mutableListOf(), - description = "Single turn use cases with on-device large language model", - docUrl = "https://ai.google.dev/edge/mediapipe/solutions/genai/llm_inference/android", - sourceCodeUrl = "https://github.com/google-ai-edge/gallery/blob/main/Android/src/app/src/main/java/com/google/ai/edge/gallery/ui/llmchat/LlmChatModelHelper.kt", - textInputPlaceHolderRes = R.string.text_input_placeholder_llm_chat -) - -val TASK_LLM_ASK_IMAGE = Task( - type = TaskType.LLM_ASK_IMAGE, - icon = Icons.Outlined.Mms, - models = mutableListOf(), - description = "Ask questions about images with on-device large language models", - docUrl = "https://ai.google.dev/edge/mediapipe/solutions/genai/llm_inference/android", - sourceCodeUrl = "https://github.com/google-ai-edge/gallery/blob/main/Android/src/app/src/main/java/com/google/ai/edge/gallery/ui/llmchat/LlmChatModelHelper.kt", - textInputPlaceHolderRes = R.string.text_input_placeholder_llm_chat -) - -val TASK_IMAGE_GENERATION = Task( - type = TaskType.IMAGE_GENERATION, - iconVectorResourceId = R.drawable.image_spark, - models = MODELS_IMAGE_GENERATION, - description = "Generate images from text", - docUrl = "https://ai.google.dev/edge/mediapipe/solutions/vision/image_generator/android", - sourceCodeUrl = "https://github.com/google-ai-edge/gallery/blob/main/Android/src/app/src/main/java/com/google/ai/edge/gallery/ui/imagegeneration/ImageGenerationModelHelper.kt", - textInputPlaceHolderRes = R.string.text_image_generation_text_field_placeholder -) +val TASK_LLM_ASK_IMAGE = + Task( + type = TaskType.LLM_ASK_IMAGE, + icon = Mms, + models = mutableListOf(), + description = "Ask questions about images with on-device large language models", + docUrl = "https://ai.google.dev/edge/mediapipe/solutions/genai/llm_inference/android", + sourceCodeUrl = + "https://github.com/google-ai-edge/gallery/blob/main/Android/src/app/src/main/java/com/google/ai/edge/gallery/ui/llmchat/LlmChatModelHelper.kt", + textInputPlaceHolderRes = R.string.text_input_placeholder_llm_chat, + ) /** All tasks. */ -val TASKS: List = listOf( - TASK_LLM_ASK_IMAGE, - TASK_LLM_PROMPT_LAB, - TASK_LLM_CHAT, -) +val TASKS: List = listOf(TASK_LLM_ASK_IMAGE, TASK_LLM_PROMPT_LAB, TASK_LLM_CHAT) fun getModelByName(name: String): Model? { for (task in TASKS) { @@ -147,3 +117,12 @@ fun getModelByName(name: String): Model? { } return null } + +fun processTasks() { + for ((index, task) in TASKS.withIndex()) { + task.index = index + for (model in task.models) { + model.preProcess() + } + } +} diff --git a/Android/src/app/src/main/java/com/google/ai/edge/gallery/data/Types.kt b/Android/src/app/src/main/java/com/google/ai/edge/gallery/data/Types.kt new file mode 100644 index 0000000..d7c3da6 --- /dev/null +++ b/Android/src/app/src/main/java/com/google/ai/edge/gallery/data/Types.kt @@ -0,0 +1,22 @@ +/* + * Copyright 2025 Google LLC + * + * 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 + * + * http://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.google.ai.edge.gallery.data + +enum class Accelerator(val label: String) { + CPU(label = "CPU"), + GPU(label = "GPU"), +} diff --git a/Android/src/app/src/main/java/com/google/ai/edge/gallery/ui/ViewModelProvider.kt b/Android/src/app/src/main/java/com/google/ai/edge/gallery/ui/ViewModelProvider.kt index 8a257dd..fdded53 100644 --- a/Android/src/app/src/main/java/com/google/ai/edge/gallery/ui/ViewModelProvider.kt +++ b/Android/src/app/src/main/java/com/google/ai/edge/gallery/ui/ViewModelProvider.kt @@ -16,19 +16,15 @@ package com.google.ai.edge.gallery.ui -import android.app.Application import androidx.lifecycle.ViewModelProvider.AndroidViewModelFactory import androidx.lifecycle.viewmodel.CreationExtras import androidx.lifecycle.viewmodel.initializer import androidx.lifecycle.viewmodel.viewModelFactory import com.google.ai.edge.gallery.GalleryApplication -import com.google.ai.edge.gallery.ui.imageclassification.ImageClassificationViewModel -import com.google.ai.edge.gallery.ui.imagegeneration.ImageGenerationViewModel -import com.google.ai.edge.gallery.ui.llmchat.LlmChatViewModel import com.google.ai.edge.gallery.ui.llmchat.LlmAskImageViewModel +import com.google.ai.edge.gallery.ui.llmchat.LlmChatViewModel import com.google.ai.edge.gallery.ui.llmsingleturn.LlmSingleTurnViewModel import com.google.ai.edge.gallery.ui.modelmanager.ModelManagerViewModel -import com.google.ai.edge.gallery.ui.textclassification.TextClassificationViewModel object ViewModelProvider { val Factory = viewModelFactory { @@ -36,42 +32,23 @@ object ViewModelProvider { initializer { val downloadRepository = galleryApplication().container.downloadRepository val dataStoreRepository = galleryApplication().container.dataStoreRepository + val lifecycleProvider = galleryApplication().container.lifecycleProvider ModelManagerViewModel( downloadRepository = downloadRepository, dataStoreRepository = dataStoreRepository, + lifecycleProvider = lifecycleProvider, context = galleryApplication().container.context, ) } - // Initializer for TextClassificationViewModel - initializer { - TextClassificationViewModel() - } - - // Initializer for ImageClassificationViewModel - initializer { - ImageClassificationViewModel() - } - // Initializer for LlmChatViewModel. - initializer { - LlmChatViewModel() - } + initializer { LlmChatViewModel() } // Initializer for LlmSingleTurnViewModel.. - initializer { - LlmSingleTurnViewModel() - } + initializer { LlmSingleTurnViewModel() } // Initializer for LlmAskImageViewModel. - initializer { - LlmAskImageViewModel() - } - - // Initializer for ImageGenerationViewModel. - initializer { - ImageGenerationViewModel() - } + initializer { LlmAskImageViewModel() } } } diff --git a/Android/src/app/src/main/java/com/google/ai/edge/gallery/ui/common/AuthConfig.kt b/Android/src/app/src/main/java/com/google/ai/edge/gallery/ui/common/AuthConfig.kt index 11e30e0..beccfe4 100644 --- a/Android/src/app/src/main/java/com/google/ai/edge/gallery/ui/common/AuthConfig.kt +++ b/Android/src/app/src/main/java/com/google/ai/edge/gallery/ui/common/AuthConfig.kt @@ -16,7 +16,7 @@ package com.google.ai.edge.gallery.ui.common -import android.net.Uri +import androidx.core.net.toUri import net.openid.appauth.AuthorizationServiceConfiguration object AuthConfig { @@ -34,8 +34,9 @@ object AuthConfig { private const val tokenEndpoint = "https://huggingface.co/oauth/token" // OAuth service configuration (AppAuth library requires this) - val authServiceConfig = AuthorizationServiceConfiguration( - Uri.parse(authEndpoint), // Authorization endpoint - Uri.parse(tokenEndpoint) // Token exchange endpoint - ) -} \ No newline at end of file + val authServiceConfig = + AuthorizationServiceConfiguration( + authEndpoint.toUri(), // Authorization endpoint + tokenEndpoint.toUri(), // Token exchange endpoint + ) +} diff --git a/Android/src/app/src/main/java/com/google/ai/edge/gallery/ui/common/ClickableLink.kt b/Android/src/app/src/main/java/com/google/ai/edge/gallery/ui/common/ClickableLink.kt new file mode 100644 index 0000000..7d4abdb --- /dev/null +++ b/Android/src/app/src/main/java/com/google/ai/edge/gallery/ui/common/ClickableLink.kt @@ -0,0 +1,68 @@ +/* + * Copyright 2025 Google LLC + * + * 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 + * + * http://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.google.ai.edge.gallery.ui.common + +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.vector.ImageVector +import androidx.compose.ui.platform.LocalUriHandler +import androidx.compose.ui.text.AnnotatedString +import androidx.compose.ui.text.SpanStyle +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.text.style.TextDecoration +import androidx.compose.ui.unit.dp +import com.google.ai.edge.gallery.ui.theme.customColors + +@Composable +fun ClickableLink(url: String, linkText: String, icon: ImageVector) { + val uriHandler = LocalUriHandler.current + val annotatedText = + AnnotatedString( + text = linkText, + spanStyles = + listOf( + AnnotatedString.Range( + item = + SpanStyle( + color = MaterialTheme.customColors.linkColor, + textDecoration = TextDecoration.Underline, + ), + start = 0, + end = linkText.length, + ) + ), + ) + + Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.Center) { + Icon(icon, contentDescription = "", modifier = Modifier.size(16.dp)) + Text( + text = annotatedText, + textAlign = TextAlign.Center, + style = MaterialTheme.typography.bodyLarge, + modifier = Modifier.padding(start = 6.dp).clickable { uriHandler.openUri(url) }, + ) + } +} diff --git a/Android/src/app/src/main/java/com/google/ai/edge/gallery/ui/common/ColorUtils.kt b/Android/src/app/src/main/java/com/google/ai/edge/gallery/ui/common/ColorUtils.kt new file mode 100644 index 0000000..2b3925f --- /dev/null +++ b/Android/src/app/src/main/java/com/google/ai/edge/gallery/ui/common/ColorUtils.kt @@ -0,0 +1,41 @@ +/* + * Copyright 2025 Google LLC + * + * 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 + * + * http://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.google.ai.edge.gallery.ui.common + +import androidx.compose.material3.MaterialTheme +import androidx.compose.runtime.Composable +import androidx.compose.ui.graphics.Color +import com.google.ai.edge.gallery.data.Task +import com.google.ai.edge.gallery.ui.theme.customColors + +@Composable +fun getTaskBgColor(task: Task): Color { + val colorIndex: Int = task.index % MaterialTheme.customColors.taskBgColors.size + return MaterialTheme.customColors.taskBgColors[colorIndex] +} + +@Composable +fun getTaskIconColor(task: Task): Color { + val colorIndex: Int = task.index % MaterialTheme.customColors.taskIconColors.size + return MaterialTheme.customColors.taskIconColors[colorIndex] +} + +@Composable +fun getTaskIconColor(index: Int): Color { + val colorIndex: Int = index % MaterialTheme.customColors.taskIconColors.size + return MaterialTheme.customColors.taskIconColors[colorIndex] +} diff --git a/Android/src/app/src/main/java/com/google/ai/edge/gallery/ui/common/chat/ConfigDialog.kt b/Android/src/app/src/main/java/com/google/ai/edge/gallery/ui/common/ConfigDialog.kt similarity index 65% rename from Android/src/app/src/main/java/com/google/ai/edge/gallery/ui/common/chat/ConfigDialog.kt rename to Android/src/app/src/main/java/com/google/ai/edge/gallery/ui/common/ConfigDialog.kt index 34952c5..c265a31 100644 --- a/Android/src/app/src/main/java/com/google/ai/edge/gallery/ui/common/chat/ConfigDialog.kt +++ b/Android/src/app/src/main/java/com/google/ai/edge/gallery/ui/common/ConfigDialog.kt @@ -14,8 +14,11 @@ * limitations under the License. */ -package com.google.ai.edge.gallery.ui.common.chat +package com.google.ai.edge.gallery.ui.common +// import androidx.compose.ui.tooling.preview.Preview +// import com.google.ai.edge.gallery.ui.preview.MODEL_TEST1 +// import com.google.ai.edge.gallery.ui.theme.GalleryTheme import android.util.Log import androidx.compose.foundation.border import androidx.compose.foundation.clickable @@ -61,7 +64,6 @@ import androidx.compose.ui.graphics.SolidColor import androidx.compose.ui.platform.LocalFocusManager import androidx.compose.ui.text.TextStyle import androidx.compose.ui.text.input.KeyboardType -import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp import androidx.compose.ui.window.Dialog import com.google.ai.edge.gallery.data.BooleanSwitchConfig @@ -70,8 +72,6 @@ import com.google.ai.edge.gallery.data.LabelConfig import com.google.ai.edge.gallery.data.NumberSliderConfig import com.google.ai.edge.gallery.data.SegmentedButtonConfig import com.google.ai.edge.gallery.data.ValueType -import com.google.ai.edge.gallery.ui.preview.MODEL_TEST1 -import com.google.ai.edge.gallery.ui.theme.GalleryTheme import com.google.ai.edge.gallery.ui.theme.labelSmallNarrow import kotlin.Double.Companion.NaN @@ -92,33 +92,32 @@ fun ConfigDialog( showCancel: Boolean = true, ) { val values: SnapshotStateMap = remember { - mutableStateMapOf().apply { - putAll(initialValues) - } + mutableStateMapOf().apply { putAll(initialValues) } } val interactionSource = remember { MutableInteractionSource() } Dialog(onDismissRequest = onDismissed) { val focusManager = LocalFocusManager.current Card( - modifier = Modifier - .fillMaxWidth() - .clickable( - interactionSource = interactionSource, indication = null // Disable the ripple effect + modifier = + Modifier.fillMaxWidth().clickable( + interactionSource = interactionSource, + indication = null, // Disable the ripple effect ) { focusManager.clearFocus() }, - shape = RoundedCornerShape(16.dp) + shape = RoundedCornerShape(16.dp), ) { Column( - modifier = Modifier.padding(20.dp), verticalArrangement = Arrangement.spacedBy(16.dp) + modifier = Modifier.padding(20.dp), + verticalArrangement = Arrangement.spacedBy(16.dp), ) { // Dialog title and subtitle. Column { Text( title, style = MaterialTheme.typography.titleLarge, - modifier = Modifier.padding(bottom = 8.dp) + modifier = Modifier.padding(bottom = 8.dp), ) // Subtitle. if (subtitle.isNotEmpty()) { @@ -126,35 +125,27 @@ fun ConfigDialog( subtitle, style = labelSmallNarrow, color = MaterialTheme.colorScheme.onSurfaceVariant, - modifier = Modifier.offset(y = (-6).dp) + modifier = Modifier.offset(y = (-6).dp), ) } } // List of config rows. Column( - modifier = Modifier - .verticalScroll(rememberScrollState()) - .weight(1f, fill = false), - verticalArrangement = Arrangement.spacedBy(16.dp) + modifier = Modifier.verticalScroll(rememberScrollState()).weight(1f, fill = false), + verticalArrangement = Arrangement.spacedBy(16.dp), ) { ConfigEditorsPanel(configs = configs, values = values) } // Button row. Row( - modifier = Modifier - .fillMaxWidth() - .padding(top = 8.dp), + modifier = Modifier.fillMaxWidth().padding(top = 8.dp), horizontalArrangement = Arrangement.End, ) { // Cancel button. if (showCancel) { - TextButton( - onClick = { onDismissed() }, - ) { - Text("Cancel") - } + TextButton(onClick = { onDismissed() }) { Text("Cancel") } } // Ok button @@ -162,7 +153,7 @@ fun ConfigDialog( onClick = { Log.d(TAG, "Values from dialog: $values") onOk(values.toMap()) - }, + } ) { Text(okBtnLabel) } @@ -172,9 +163,7 @@ fun ConfigDialog( } } -/** - * Composable function to display a list of config editor rows. - */ +/** Composable function to display a list of config editor rows. */ @Composable fun ConfigEditorsPanel(configs: List, values: SnapshotStateMap) { for (config in configs) { @@ -210,11 +199,12 @@ fun LabelRow(config: LabelConfig, values: SnapshotStateMap) { // Field label. Text(config.key.label, style = MaterialTheme.typography.titleSmall) // Content label. - val label = try { - values[config.key.label] as String - } catch (e: Exception) { - "" - } + val label = + try { + values[config.key.label] as String + } catch (e: Exception) { + "" + } Text(label, style = MaterialTheme.typography.bodyMedium) } } @@ -222,9 +212,9 @@ fun LabelRow(config: LabelConfig, values: SnapshotStateMap) { /** * Composable function to display a number slider with an associated text input field. * - * This function renders a row containing a slider and a text field, both used to modify - * a numeric value. The slider allows users to visually adjust the value within a specified range, - * while the text field provides precise numeric input. + * This function renders a row containing a slider and a text field, both used to modify a numeric + * value. The slider allows users to visually adjust the value within a specified range, while the + * text field provides precise numeric input. */ @Composable fun NumberSliderRow(config: NumberSliderConfig, values: SnapshotStateMap) { @@ -233,52 +223,50 @@ fun NumberSliderRow(config: NumberSliderConfig, values: SnapshotStateMap { - "%.2f".format(values[config.key.label] as Float) - } + val textFieldValue = + try { + when (config.valueType) { + ValueType.FLOAT -> { + "%.2f".format(values[config.key.label] as Float) + } - ValueType.INT -> { - "${(values[config.key.label] as Float).toInt()}" - } + ValueType.INT -> { + "${(values[config.key.label] as Float).toInt()}" + } - else -> { - "" + else -> { + "" + } } + } catch (e: Exception) { + "" } - } catch (e: Exception) { - "" - } // A smaller text field. BasicTextField( value = textFieldValue, - modifier = Modifier - .width(80.dp) - .focusRequester(focusRequester) - .onFocusChanged { + modifier = + Modifier.width(80.dp).focusRequester(focusRequester).onFocusChanged { isFocused = it.isFocused }, keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number), @@ -293,15 +281,16 @@ fun NumberSliderRow(config: NumberSliderConfig, values: SnapshotStateMap Box( - modifier = Modifier.border( - width = if (isFocused) 2.dp else 1.dp, - color = if (isFocused) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.outline, - shape = RoundedCornerShape(4.dp) - ) + modifier = + Modifier.border( + width = if (isFocused) 2.dp else 1.dp, + color = + if (isFocused) MaterialTheme.colorScheme.primary + else MaterialTheme.colorScheme.outline, + shape = RoundedCornerShape(4.dp), + ) ) { - Box(modifier = Modifier.padding(8.dp)) { - innerTextField() - } + Box(modifier = Modifier.padding(8.dp)) { innerTextField() } } } } @@ -311,16 +300,17 @@ fun NumberSliderRow(config: NumberSliderConfig, values: SnapshotStateMap) { - val switchValue = try { - values[config.key.label] as Boolean - } catch (e: Exception) { - false - } + val switchValue = + try { + values[config.key.label] as Boolean + } catch (e: Exception) { + false + } Column(modifier = Modifier.fillMaxWidth()) { Text(config.key.label, style = MaterialTheme.typography.titleSmall) Switch(checked = switchValue, onCheckedChange = { values[config.key.label] = it }) @@ -331,63 +321,66 @@ fun BooleanSwitchRow(config: BooleanSwitchConfig, values: SnapshotStateMap) { val selectedOptions: List = remember { (values[config.key.label] as String).split(",") } var selectionStates: List by remember { - mutableStateOf(List(config.options.size) { index -> - selectedOptions.contains(config.options[index]) - }) + mutableStateOf( + List(config.options.size) { index -> selectedOptions.contains(config.options[index]) } + ) } Column(modifier = Modifier.fillMaxWidth()) { Text(config.key.label, style = MaterialTheme.typography.titleSmall) MultiChoiceSegmentedButtonRow { config.options.forEachIndexed { index, label -> - SegmentedButton(shape = SegmentedButtonDefaults.itemShape( - index = index, count = config.options.size - ), onCheckedChange = { - var newSelectionStates = selectionStates.toMutableList() - val selectedCount = newSelectionStates.count { it } + SegmentedButton( + shape = SegmentedButtonDefaults.itemShape(index = index, count = config.options.size), + onCheckedChange = { + var newSelectionStates = selectionStates.toMutableList() + val selectedCount = newSelectionStates.count { it } - // Single select. - if (!config.allowMultiple) { - if (!newSelectionStates[index]) { - newSelectionStates = MutableList(config.options.size) { it == index } + // Single select. + if (!config.allowMultiple) { + if (!newSelectionStates[index]) { + newSelectionStates = MutableList(config.options.size) { it == index } + } } - } - // Multiple select. - else { - if (!(selectedCount == 1 && newSelectionStates[index])) { - newSelectionStates[index] = !newSelectionStates[index] + // Multiple select. + else { + if (!(selectedCount == 1 && newSelectionStates[index])) { + newSelectionStates[index] = !newSelectionStates[index] + } } - } - selectionStates = newSelectionStates + selectionStates = newSelectionStates - values[config.key.label] = - config.options.filterIndexed { index, option -> selectionStates[index] } - .joinToString(",") - }, checked = selectionStates[index], label = { Text(label) }) + values[config.key.label] = + config.options + .filterIndexed { index, option -> selectionStates[index] } + .joinToString(",") + }, + checked = selectionStates[index], + label = { Text(label) }, + ) } } - } } -@Composable -@Preview(showBackground = true) -fun ConfigDialogPreview() { - GalleryTheme { - val defaultValues: MutableMap = mutableMapOf() - for (config in MODEL_TEST1.configs) { - defaultValues[config.key.label] = config.defaultValue - } +// @Composable +// @Preview(showBackground = true) +// fun ConfigDialogPreview() { +// GalleryTheme { +// val defaultValues: MutableMap = mutableMapOf() +// for (config in MODEL_TEST1.configs) { +// defaultValues[config.key.label] = config.defaultValue +// } - Column { - ConfigDialog( - title = "Dialog title", - subtitle = "20250413", - configs = MODEL_TEST1.configs, - initialValues = defaultValues, - onDismissed = {}, - onOk = {}, - ) - } - } -} +// Column { +// ConfigDialog( +// title = "Dialog title", +// subtitle = "20250413", +// configs = MODEL_TEST1.configs, +// initialValues = defaultValues, +// onDismissed = {}, +// onOk = {}, +// ) +// } +// } +// } diff --git a/Android/src/app/src/main/java/com/google/ai/edge/gallery/ui/common/DownloadAndTryButton.kt b/Android/src/app/src/main/java/com/google/ai/edge/gallery/ui/common/DownloadAndTryButton.kt index 9924da1..8511c6e 100644 --- a/Android/src/app/src/main/java/com/google/ai/edge/gallery/ui/common/DownloadAndTryButton.kt +++ b/Android/src/app/src/main/java/com/google/ai/edge/gallery/ui/common/DownloadAndTryButton.kt @@ -16,8 +16,8 @@ package com.google.ai.edge.gallery.ui.common +import android.app.ActivityManager import android.content.Intent -import android.net.Uri import android.util.Log import androidx.activity.compose.rememberLauncherForActivityResult import androidx.activity.result.ActivityResultLauncher @@ -51,16 +51,17 @@ import androidx.compose.ui.Modifier import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp +import androidx.core.net.toUri import com.google.ai.edge.gallery.data.Model import com.google.ai.edge.gallery.data.Task import com.google.ai.edge.gallery.ui.modelmanager.ModelManagerViewModel import com.google.ai.edge.gallery.ui.modelmanager.TokenRequestResultType import com.google.ai.edge.gallery.ui.modelmanager.TokenStatus +import java.net.HttpURLConnection +import kotlin.math.max import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.launch import kotlinx.coroutines.withContext -import java.net.HttpURLConnection - private const val TAG = "AGDownloadAndTryButton" @@ -72,14 +73,14 @@ private const val TAG = "AGDownloadAndTryButton" * Handles the "Download & Try it" button click, managing the model download process based on * various conditions. * - * If the button is enabled and not currently checking the token, it initiates a coroutine to - * handle the download logic. + * If the button is enabled and not currently checking the token, it initiates a coroutine to handle + * the download logic. * - * For models requiring download first, it specifically addresses HuggingFace URLs by first - * checking if authentication is necessary. If no authentication is needed, the download starts - * directly. Otherwise, it checks the current token status; if the token is invalid or expired, - * a token exchange flow is initiated. If a valid token exists, it attempts to access the - * download URL. If access is granted, the download begins; if not, a new token is requested. + * For models requiring download first, it specifically addresses HuggingFace URLs by first checking + * if authentication is necessary. If no authentication is needed, the download starts directly. + * Otherwise, it checks the current token status; if the token is invalid or expired, a token + * exchange flow is initiated. If a valid token exists, it attempts to access the download URL. If + * access is granted, the download begins; if not, a new token is requested. * * For non-HuggingFace URLs that need downloading, the download starts directly. * @@ -102,21 +103,21 @@ fun DownloadAndTryButton( enabled: Boolean, needToDownloadFirst: Boolean, modelManagerViewModel: ModelManagerViewModel, - onClicked: () -> Unit + onClicked: () -> Unit, ) { val scope = rememberCoroutineScope() val context = LocalContext.current var checkingToken by remember { mutableStateOf(false) } var showAgreementAckSheet by remember { mutableStateOf(false) } var showErrorDialog by remember { mutableStateOf(false) } + var showMemoryWarning by remember { mutableStateOf(false) } val sheetState = rememberModalBottomSheetState() // A launcher for requesting notification permission. - val permissionLauncher = rememberLauncherForActivityResult( - ActivityResultContracts.RequestPermission() - ) { - modelManagerViewModel.downloadModel(task = task, model = model) - } + val permissionLauncher = + rememberLauncherForActivityResult(ActivityResultContracts.RequestPermission()) { + modelManagerViewModel.downloadModel(task = task, model = model) + } // Function to kick off download. val startDownload: (accessToken: String?) -> Unit = { accessToken -> @@ -127,64 +128,73 @@ fun DownloadAndTryButton( launcher = permissionLauncher, modelManagerViewModel = modelManagerViewModel, task = task, - model = model + model = model, ) checkingToken = false } // A launcher for opening the custom tabs intent for requesting user agreement ack. // Once the tab is closed, try starting the download process. - val agreementAckLauncher: ActivityResultLauncher = rememberLauncherForActivityResult( - contract = ActivityResultContracts.StartActivityForResult() - ) { result -> - Log.d(TAG, "User closes the browser tab. Try to start downloading.") - startDownload(modelManagerViewModel.curAccessToken) - } + val agreementAckLauncher: ActivityResultLauncher = + rememberLauncherForActivityResult( + contract = ActivityResultContracts.StartActivityForResult() + ) { result -> + Log.d(TAG, "User closes the browser tab. Try to start downloading.") + startDownload(modelManagerViewModel.curAccessToken) + } // A launcher for handling the authentication flow. // It processes the result of the authentication activity and then checks if a user agreement // acknowledgement is needed before proceeding with the model download. - val authResultLauncher = rememberLauncherForActivityResult( - contract = ActivityResultContracts.StartActivityForResult() - ) { result -> - modelManagerViewModel.handleAuthResult(result, onTokenRequested = { tokenRequestResult -> - when (tokenRequestResult.status) { - TokenRequestResultType.SUCCEEDED -> { - Log.d(TAG, "Token request succeeded. Checking if we need user to ack user agreement") - scope.launch(Dispatchers.IO) { - // Check if we can use the current token to access model. If not, we might need to - // acknowledge the user agreement. - if (modelManagerViewModel.getModelUrlResponse( - model = model, - accessToken = modelManagerViewModel.curAccessToken - ) == HttpURLConnection.HTTP_FORBIDDEN - ) { - Log.d(TAG, "Model '${model.name}' needs user agreement ack.") - showAgreementAckSheet = true - } else { - Log.d( - TAG, - "Model '${model.name}' does NOT need user agreement ack. Start downloading..." - ) - withContext(Dispatchers.Main) { - startDownload(modelManagerViewModel.curAccessToken) + val authResultLauncher = + rememberLauncherForActivityResult( + contract = ActivityResultContracts.StartActivityForResult() + ) { result -> + modelManagerViewModel.handleAuthResult( + result, + onTokenRequested = { tokenRequestResult -> + when (tokenRequestResult.status) { + TokenRequestResultType.SUCCEEDED -> { + Log.d(TAG, "Token request succeeded. Checking if we need user to ack user agreement") + scope.launch(Dispatchers.IO) { + // Check if we can use the current token to access model. If not, we might need to + // acknowledge the user agreement. + if ( + modelManagerViewModel.getModelUrlResponse( + model = model, + accessToken = modelManagerViewModel.curAccessToken, + ) == HttpURLConnection.HTTP_FORBIDDEN + ) { + Log.d(TAG, "Model '${model.name}' needs user agreement ack.") + showAgreementAckSheet = true + } else { + Log.d( + TAG, + "Model '${model.name}' does NOT need user agreement ack. Start downloading...", + ) + withContext(Dispatchers.Main) { + startDownload(modelManagerViewModel.curAccessToken) + } + } } } + + TokenRequestResultType.FAILED -> { + Log.d( + TAG, + "Token request done. Error message: ${tokenRequestResult.errorMessage ?: ""}", + ) + checkingToken = false + } + + TokenRequestResultType.USER_CANCELLED -> { + Log.d(TAG, "User cancelled. Do nothing") + checkingToken = false + } } - } - - TokenRequestResultType.FAILED -> { - Log.d(TAG, "Token request done. Error message: ${tokenRequestResult.errorMessage ?: ""}") - checkingToken = false - } - - TokenRequestResultType.USER_CANCELLED -> { - Log.d(TAG, "User cancelled. Do nothing") - checkingToken = false - } - } - }) - } + }, + ) + } // Function to kick off the authentication and token exchange flow. val startTokenExchange = { @@ -213,14 +223,12 @@ fun DownloadAndTryButton( // Check if the url needs auth. Log.d( TAG, - "Model '${model.name}' is from HuggingFace. Checking if the url needs auth to download" + "Model '${model.name}' is from HuggingFace. Checking if the url needs auth to download", ) val firstResponseCode = modelManagerViewModel.getModelUrlResponse(model = model) if (firstResponseCode == HttpURLConnection.HTTP_OK) { Log.d(TAG, "Model '${model.name}' doesn't need auth. Start downloading the model...") - withContext(Dispatchers.Main) { - startDownload(null) - } + withContext(Dispatchers.Main) { startDownload(null) } return@launch } else if (firstResponseCode < 0) { checkingToken = false @@ -235,37 +243,36 @@ fun DownloadAndTryButton( when (tokenStatusAndData.status) { // If token is not stored or expired, log in and request a new token. - TokenStatus.NOT_STORED, TokenStatus.EXPIRED -> { - withContext(Dispatchers.Main) { - startTokenExchange() - } + TokenStatus.NOT_STORED, + TokenStatus.EXPIRED -> { + withContext(Dispatchers.Main) { startTokenExchange() } } // If token is still valid... TokenStatus.NOT_EXPIRED -> { // Use the current token to check the download url. Log.d(TAG, "Checking the download url '${model.url}' with the current token...") - val responseCode = modelManagerViewModel.getModelUrlResponse( - model = model, accessToken = tokenStatusAndData.data!!.accessToken - ) + val responseCode = + modelManagerViewModel.getModelUrlResponse( + model = model, + accessToken = tokenStatusAndData.data!!.accessToken, + ) if (responseCode == HttpURLConnection.HTTP_OK) { // Download url is accessible. Download the model. Log.d(TAG, "Download url is accessible with the current token.") withContext(Dispatchers.Main) { - startDownload(tokenStatusAndData.data.accessToken) + startDownload(tokenStatusAndData.data!!.accessToken) } } // Download url is NOT accessible. Request a new token. else { Log.d( TAG, - "Download url is NOT accessible. Response code: ${responseCode}. Trying to request a new token." + "Download url is NOT accessible. Response code: ${responseCode}. Trying to request a new token.", ) - withContext(Dispatchers.Main) { - startTokenExchange() - } + withContext(Dispatchers.Main) { startTokenExchange() } } } } @@ -274,24 +281,50 @@ fun DownloadAndTryButton( else { Log.d( TAG, - "Model '${model.name}' is not from huggingface. Start downloading the model..." + "Model '${model.name}' is not from huggingface. Start downloading the model...", ) - withContext(Dispatchers.Main) { - startDownload(null) - } + withContext(Dispatchers.Main) { startDownload(null) } } } else { withContext(Dispatchers.Main) { - onClicked() + val activityManager = + context.getSystemService(android.app.Activity.ACTIVITY_SERVICE) as? ActivityManager + val estimatedPeakMemoryInBytes = model.estimatedPeakMemoryInBytes + + val isMemoryLow = + if (activityManager != null && estimatedPeakMemoryInBytes != null) { + val memoryInfo = ActivityManager.MemoryInfo() + activityManager.getMemoryInfo(memoryInfo) + Log.d( + TAG, + "AvailMem: ${memoryInfo.availMem}. TotalMem: ${memoryInfo.totalMem}. Estimated peak memory: ${estimatedPeakMemoryInBytes}.", + ) + + // The device should be able to run the model if `availMem` is larger than the + // estimated peak memory. Android also has a mechanism to kill background apps to + // free up memory for the foreground app. We believe that if half of the total + // memory on the device is larger than the estimated peak memory, it can run the + // model fine with this mechanism. For example, a phone with 12GB memory can have + // very few `availMem` but will have no problem running most models. + max(memoryInfo.availMem, memoryInfo.totalMem / 2) < estimatedPeakMemoryInBytes + } else { + false + } + + if (isMemoryLow) { + showMemoryWarning = true + } else { + onClicked() + } } } } - }, + } ) { Icon( Icons.AutoMirrored.Rounded.ArrowForward, contentDescription = "", - modifier = Modifier.padding(end = 4.dp) + modifier = Modifier.padding(end = 4.dp), ) val textColor = MaterialTheme.colorScheme.onPrimary @@ -301,11 +334,7 @@ fun DownloadAndTryButton( maxLines = 1, color = { textColor }, style = MaterialTheme.typography.bodyMedium, - autoSize = TextAutoSize.StepBased( - minFontSize = 8.sp, - maxFontSize = 14.sp, - stepSize = 1.sp - ) + autoSize = TextAutoSize.StepBased(minFontSize = 8.sp, maxFontSize = 14.sp, stepSize = 1.sp), ) } else { if (needToDownloadFirst) { @@ -314,11 +343,8 @@ fun DownloadAndTryButton( maxLines = 1, color = { textColor }, style = MaterialTheme.typography.bodyMedium, - autoSize = TextAutoSize.StepBased( - minFontSize = 8.sp, - maxFontSize = 14.sp, - stepSize = 1.sp - ) + autoSize = + TextAutoSize.StepBased(minFontSize = 8.sp, maxFontSize = 14.sp, stepSize = 1.sp), ) } else { Text("Try it", maxLines = 1) @@ -341,28 +367,30 @@ fun DownloadAndTryButton( ) { Column( horizontalAlignment = Alignment.CenterHorizontally, - modifier = Modifier.padding(horizontal = 16.dp) + modifier = Modifier.padding(horizontal = 16.dp), ) { Text("Acknowledge user agreement", style = MaterialTheme.typography.titleLarge) Text( "This is a gated model. Please click the button below to view and agree to the user agreement. After accepting, simply close that tab to proceed with the model download.", style = MaterialTheme.typography.bodyMedium, - modifier = Modifier.padding(vertical = 16.dp) + modifier = Modifier.padding(vertical = 16.dp), ) - Button(onClick = { - // Get agreement url from model url. - val index = model.url.indexOf("/resolve/") - // Show it in a tab. - if (index >= 0) { - val agreementUrl = model.url.substring(0, index) + Button( + onClick = { + // Get agreement url from model url. + val index = model.url.indexOf("/resolve/") + // Show it in a tab. + if (index >= 0) { + val agreementUrl = model.url.substring(0, index) - val customTabsIntent = CustomTabsIntent.Builder().build() - customTabsIntent.intent.setData(Uri.parse(agreementUrl)) - agreementAckLauncher.launch(customTabsIntent.intent) + val customTabsIntent = CustomTabsIntent.Builder().build() + customTabsIntent.intent.setData(agreementUrl.toUri()) + agreementAckLauncher.launch(customTabsIntent.intent) + } + // Dismiss the sheet. + showAgreementAckSheet = false } - // Dismiss the sheet. - showAgreementAckSheet = false - }) { + ) { Text("Open user agreement") } } @@ -374,24 +402,34 @@ fun DownloadAndTryButton( icon = { Icon(Icons.Rounded.Error, contentDescription = "", tint = MaterialTheme.colorScheme.error) }, - title = { - Text("Unknown network error") - }, + title = { Text("Unknown network error") }, + text = { Text("Please check your internet connection.") }, + onDismissRequest = { showErrorDialog = false }, + confirmButton = { TextButton(onClick = { showErrorDialog = false }) { Text("Close") } }, + ) + } + + if (showMemoryWarning) { + AlertDialog( + title = { Text("Memory Warning") }, text = { - Text("Please check your internet connection.") - }, - onDismissRequest = { - showErrorDialog = false + Text( + "This model might need more memory than your device has available. " + + "Running it could cause the app to crash." + ) }, + onDismissRequest = { showMemoryWarning = false }, confirmButton = { TextButton( onClick = { - showErrorDialog = false + onClicked() + showMemoryWarning = false } ) { - Text("Close") + Text("Continue") } }, + dismissButton = { TextButton(onClick = { showMemoryWarning = false }) { Text("Cancel") } }, ) } } diff --git a/Android/src/app/src/main/java/com/google/ai/edge/gallery/ui/common/ErrorDialog.kt b/Android/src/app/src/main/java/com/google/ai/edge/gallery/ui/common/ErrorDialog.kt index ee5641e..430d219 100644 --- a/Android/src/app/src/main/java/com/google/ai/edge/gallery/ui/common/ErrorDialog.kt +++ b/Android/src/app/src/main/java/com/google/ai/edge/gallery/ui/common/ErrorDialog.kt @@ -33,18 +33,17 @@ import androidx.compose.ui.window.Dialog @Composable fun ErrorDialog(error: String, onDismiss: () -> Unit) { - Dialog( - onDismissRequest = onDismiss - ) { + Dialog(onDismissRequest = onDismiss) { Card(modifier = Modifier.fillMaxWidth(), shape = RoundedCornerShape(16.dp)) { Column( - modifier = Modifier.padding(20.dp), verticalArrangement = Arrangement.spacedBy(16.dp) + modifier = Modifier.padding(20.dp), + verticalArrangement = Arrangement.spacedBy(16.dp), ) { // Title Text( "Error", style = MaterialTheme.typography.titleLarge, - modifier = Modifier.padding(bottom = 8.dp) + modifier = Modifier.padding(bottom = 8.dp), ) // Error @@ -55,11 +54,7 @@ fun ErrorDialog(error: String, onDismiss: () -> Unit) { ) Row(modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.End) { - Button( - onClick = onDismiss - ) { - Text("Close") - } + Button(onClick = onDismiss) { Text("Close") } } } } diff --git a/Android/src/app/src/main/java/com/google/ai/edge/gallery/ui/common/chat/MarkdownText.kt b/Android/src/app/src/main/java/com/google/ai/edge/gallery/ui/common/MarkdownText.kt similarity index 56% rename from Android/src/app/src/main/java/com/google/ai/edge/gallery/ui/common/chat/MarkdownText.kt rename to Android/src/app/src/main/java/com/google/ai/edge/gallery/ui/common/MarkdownText.kt index 3c07e92..d0fc51b 100644 --- a/Android/src/app/src/main/java/com/google/ai/edge/gallery/ui/common/chat/MarkdownText.kt +++ b/Android/src/app/src/main/java/com/google/ai/edge/gallery/ui/common/MarkdownText.kt @@ -14,7 +14,7 @@ * limitations under the License. */ -package com.google.ai.edge.gallery.ui.common.chat +package com.google.ai.edge.gallery.ui.common import androidx.compose.material3.MaterialTheme import androidx.compose.material3.ProvideTextStyle @@ -25,8 +25,6 @@ import androidx.compose.ui.text.SpanStyle import androidx.compose.ui.text.TextLinkStyles import androidx.compose.ui.text.TextStyle import androidx.compose.ui.text.font.FontFamily -import androidx.compose.ui.tooling.preview.Preview -import com.google.ai.edge.gallery.ui.theme.GalleryTheme import com.google.ai.edge.gallery.ui.theme.customColors import com.halilibo.richtext.commonmark.Markdown import com.halilibo.richtext.ui.CodeBlockStyle @@ -34,52 +32,43 @@ import com.halilibo.richtext.ui.RichTextStyle import com.halilibo.richtext.ui.material3.RichText import com.halilibo.richtext.ui.string.RichTextStringStyle -/** - * Composable function to display Markdown-formatted text. - */ +/** Composable function to display Markdown-formatted text. */ @Composable -fun MarkdownText( - text: String, - modifier: Modifier = Modifier, - smallFontSize: Boolean = false -) { +fun MarkdownText(text: String, modifier: Modifier = Modifier, smallFontSize: Boolean = false) { val fontSize = - if (smallFontSize) MaterialTheme.typography.bodyMedium.fontSize else MaterialTheme.typography.bodyLarge.fontSize + if (smallFontSize) MaterialTheme.typography.bodyMedium.fontSize + else MaterialTheme.typography.bodyLarge.fontSize CompositionLocalProvider { - ProvideTextStyle( - value = TextStyle( - fontSize = fontSize, - lineHeight = fontSize * 1.3, - ) - ) { + ProvideTextStyle(value = TextStyle(fontSize = fontSize, lineHeight = fontSize * 1.3)) { RichText( modifier = modifier, - style = RichTextStyle( - codeBlockStyle = CodeBlockStyle( - textStyle = TextStyle( - fontSize = MaterialTheme.typography.bodySmall.fontSize, - fontFamily = FontFamily.Monospace, - ) + style = + RichTextStyle( + codeBlockStyle = + CodeBlockStyle( + textStyle = + TextStyle( + fontSize = MaterialTheme.typography.bodySmall.fontSize, + fontFamily = FontFamily.Monospace, + ) + ), + stringStyle = + RichTextStringStyle( + linkStyle = + TextLinkStyles(style = SpanStyle(color = MaterialTheme.customColors.linkColor)) + ), ), - stringStyle = RichTextStringStyle( - linkStyle = TextLinkStyles( - style = SpanStyle(color = MaterialTheme.customColors.linkColor) - ) - ) - ), ) { - Markdown( - content = text - ) + Markdown(content = text) } } } } -@Preview(showBackground = true) -@Composable -fun MarkdownTextPreview() { - GalleryTheme { - MarkdownText(text = "*Hello World*\n**Good morning!!**") - } -} +// @Preview(showBackground = true) +// @Composable +// fun MarkdownTextPreview() { +// GalleryTheme { +// MarkdownText(text = "*Hello World*\n**Good morning!!**") +// } +// } diff --git a/Android/src/app/src/main/java/com/google/ai/edge/gallery/ui/common/ModelPageAppBar.kt b/Android/src/app/src/main/java/com/google/ai/edge/gallery/ui/common/ModelPageAppBar.kt index e900f5d..7623e2c 100644 --- a/Android/src/app/src/main/java/com/google/ai/edge/gallery/ui/common/ModelPageAppBar.kt +++ b/Android/src/app/src/main/java/com/google/ai/edge/gallery/ui/common/ModelPageAppBar.kt @@ -53,7 +53,7 @@ import androidx.compose.ui.unit.dp import com.google.ai.edge.gallery.data.Model import com.google.ai.edge.gallery.data.ModelDownloadStatusType import com.google.ai.edge.gallery.data.Task -import com.google.ai.edge.gallery.ui.common.chat.ConfigDialog +import com.google.ai.edge.gallery.data.convertValueToTargetType import com.google.ai.edge.gallery.ui.modelmanager.ModelInitializationStatusType import com.google.ai.edge.gallery.ui.modelmanager.ModelManagerViewModel @@ -71,54 +71,54 @@ fun ModelPageAppBar( isResettingSession: Boolean = false, onResetSessionClicked: (Model) -> Unit = {}, canShowResetSessionButton: Boolean = false, - onConfigChanged: (oldConfigValues: Map, newConfigValues: Map) -> Unit = { _, _ -> }, + onConfigChanged: (oldConfigValues: Map, newConfigValues: Map) -> Unit = + { _, _ -> + }, ) { var showConfigDialog by remember { mutableStateOf(false) } val modelManagerUiState by modelManagerViewModel.uiState.collectAsState() val context = LocalContext.current val curDownloadStatus = modelManagerUiState.modelDownloadStatus[model.name] - val modelInitializationStatus = - modelManagerUiState.modelInitializationStatus[model.name] + val modelInitializationStatus = modelManagerUiState.modelInitializationStatus[model.name] - CenterAlignedTopAppBar(title = { - Column( - horizontalAlignment = Alignment.CenterHorizontally, - verticalArrangement = Arrangement.spacedBy(4.dp) - ) { - // Task type. - Row( - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.spacedBy(6.dp) + CenterAlignedTopAppBar( + title = { + Column( + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.spacedBy(4.dp), ) { - Icon( - task.icon ?: ImageVector.vectorResource(task.iconVectorResourceId!!), - tint = getTaskIconColor(task = task), - modifier = Modifier.size(16.dp), - contentDescription = "", - ) - Text( - task.type.label, - style = MaterialTheme.typography.titleMedium.copy(fontWeight = FontWeight.SemiBold), - color = getTaskIconColor(task = task) + // Task type. + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(6.dp), + ) { + Icon( + task.icon ?: ImageVector.vectorResource(task.iconVectorResourceId!!), + tint = getTaskIconColor(task = task), + modifier = Modifier.size(16.dp), + contentDescription = "", + ) + Text( + task.type.label, + style = MaterialTheme.typography.titleMedium.copy(fontWeight = FontWeight.SemiBold), + color = getTaskIconColor(task = task), + ) + } + + // Model chips pager. + ModelPickerChipsPager( + task = task, + initialModel = model, + modelManagerViewModel = modelManagerViewModel, + onModelSelected = onModelSelected, ) } - - // Model chips pager. - ModelPickerChipsPager( - task = task, - initialModel = model, - modelManagerViewModel = modelManagerViewModel, - onModelSelected = onModelSelected, - ) - } - }, modifier = modifier, + }, + modifier = modifier, // The back button. navigationIcon = { IconButton(onClick = onBackClicked) { - Icon( - imageVector = Icons.AutoMirrored.Rounded.ArrowBack, - contentDescription = "", - ) + Icon(imageVector = Icons.AutoMirrored.Rounded.ArrowBack, contentDescription = "") } }, // The config button for the model (if existed). @@ -136,19 +136,16 @@ fun ModelPageAppBar( if (showConfigButton) { val enableConfigButton = !isModelInitializing && !inProgress IconButton( - onClick = { - showConfigDialog = true - }, + onClick = { showConfigDialog = true }, enabled = enableConfigButton, - modifier = Modifier - .offset(x = configButtonOffset) - .alpha(if (!enableConfigButton) 0.5f else 1f) + modifier = + Modifier.offset(x = configButtonOffset).alpha(if (!enableConfigButton) 0.5f else 1f), ) { Icon( imageVector = Icons.Rounded.Tune, contentDescription = "", tint = MaterialTheme.colorScheme.primary, - modifier = Modifier.size(20.dp) + modifier = Modifier.size(20.dp), ) } } @@ -157,39 +154,35 @@ fun ModelPageAppBar( CircularProgressIndicator( trackColor = MaterialTheme.colorScheme.surfaceVariant, strokeWidth = 2.dp, - modifier = Modifier.size(16.dp) + modifier = Modifier.size(16.dp), ) } else { val enableResetButton = !isModelInitializing && !modelPreparing IconButton( - onClick = { - onResetSessionClicked(model) - }, + onClick = { onResetSessionClicked(model) }, enabled = enableResetButton, - modifier = Modifier - .alpha(if (!enableResetButton) 0.5f else 1f) + modifier = Modifier.alpha(if (!enableResetButton) 0.5f else 1f), ) { Box( - modifier = Modifier - .size(32.dp) - .clip(CircleShape) - .background(MaterialTheme.colorScheme.surfaceContainer), - contentAlignment = Alignment.Center + modifier = + Modifier.size(32.dp) + .clip(CircleShape) + .background(MaterialTheme.colorScheme.surfaceContainer), + contentAlignment = Alignment.Center, ) { Icon( imageVector = Icons.Rounded.MapsUgc, contentDescription = "", tint = MaterialTheme.colorScheme.primary, - modifier = Modifier - .size(20.dp) + modifier = Modifier.size(20.dp), ) } } } } } - - }) + }, + ) // Config dialog. if (showConfigDialog) { @@ -208,12 +201,16 @@ fun ModelPageAppBar( var needReinitialization = false for (config in model.configs) { val key = config.key.label - val oldValue = convertValueToTargetType( - value = model.configValues.getValue(key), valueType = config.valueType - ) - val newValue = convertValueToTargetType( - value = curConfigValues.getValue(key), valueType = config.valueType - ) + val oldValue = + convertValueToTargetType( + value = model.configValues.getValue(key), + valueType = config.valueType, + ) + val newValue = + convertValueToTargetType( + value = curConfigValues.getValue(key), + valueType = config.valueType, + ) if (oldValue != newValue) { same = false if (config.needReinitialization) { @@ -233,7 +230,10 @@ fun ModelPageAppBar( // Force to re-initialize the model with the new configs. if (needReinitialization) { modelManagerViewModel.initializeModel( - context = context, task = task, model = model, force = true + context = context, + task = task, + model = model, + force = true, ) } @@ -242,4 +242,4 @@ fun ModelPageAppBar( }, ) } -} \ No newline at end of file +} diff --git a/Android/src/app/src/main/java/com/google/ai/edge/gallery/ui/common/ModelPicker.kt b/Android/src/app/src/main/java/com/google/ai/edge/gallery/ui/common/ModelPicker.kt index 94090ed..b8d9c7b 100644 --- a/Android/src/app/src/main/java/com/google/ai/edge/gallery/ui/common/ModelPicker.kt +++ b/Android/src/app/src/main/java/com/google/ai/edge/gallery/ui/common/ModelPicker.kt @@ -16,6 +16,11 @@ package com.google.ai.edge.gallery.ui.common +// import androidx.compose.ui.tooling.preview.Preview +// import com.google.ai.edge.gallery.ui.preview.PreviewModelManagerViewModel +// import com.google.ai.edge.gallery.ui.preview.TASK_TEST1 +// import com.google.ai.edge.gallery.ui.theme.GalleryTheme + import androidx.compose.foundation.background import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.Arrangement @@ -28,7 +33,6 @@ import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.width import androidx.compose.material.icons.Icons import androidx.compose.material.icons.filled.CheckCircle -import androidx.compose.material.icons.outlined.CheckCircle import androidx.compose.material3.Icon import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Text @@ -39,34 +43,27 @@ import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.vector.ImageVector -import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.res.vectorResource -import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp import com.google.ai.edge.gallery.data.Model import com.google.ai.edge.gallery.data.Task import com.google.ai.edge.gallery.ui.common.modelitem.StatusIcon import com.google.ai.edge.gallery.ui.modelmanager.ModelManagerViewModel -import com.google.ai.edge.gallery.ui.preview.PreviewModelManagerViewModel -import com.google.ai.edge.gallery.ui.preview.TASK_TEST1 -import com.google.ai.edge.gallery.ui.theme.GalleryTheme import com.google.ai.edge.gallery.ui.theme.labelSmallNarrow @Composable fun ModelPicker( task: Task, modelManagerViewModel: ModelManagerViewModel, - onModelSelected: (Model) -> Unit + onModelSelected: (Model) -> Unit, ) { val modelManagerUiState by modelManagerViewModel.uiState.collectAsState() Column(modifier = Modifier.padding(bottom = 8.dp)) { // Title Row( - modifier = Modifier - .padding(horizontal = 16.dp) - .padding(top = 4.dp, bottom = 4.dp), + modifier = Modifier.padding(horizontal = 16.dp).padding(top = 4.dp, bottom = 4.dp), verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(8.dp), ) { @@ -90,51 +87,47 @@ fun ModelPicker( Row( verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.SpaceBetween, - modifier = Modifier - .fillMaxWidth() - .clickable { - onModelSelected(model) - } - .background(if (selected) MaterialTheme.colorScheme.surfaceContainer else Color.Transparent) - .padding(horizontal = 16.dp, vertical = 8.dp), + modifier = + Modifier.fillMaxWidth() + .clickable { onModelSelected(model) } + .background( + if (selected) MaterialTheme.colorScheme.surfaceContainer else Color.Transparent + ) + .padding(horizontal = 16.dp, vertical = 8.dp), ) { Spacer(modifier = Modifier.width(24.dp)) Column(modifier = Modifier.weight(1f)) { Text(model.name, style = MaterialTheme.typography.bodyMedium) Row( horizontalArrangement = Arrangement.spacedBy(4.dp), - verticalAlignment = Alignment.CenterVertically + verticalAlignment = Alignment.CenterVertically, ) { StatusIcon(downloadStatus = modelManagerUiState.modelDownloadStatus[model.name]) Text( model.sizeInBytes.humanReadableSize(), color = MaterialTheme.colorScheme.secondary, - style = labelSmallNarrow.copy(lineHeight = 10.sp) + style = labelSmallNarrow.copy(lineHeight = 10.sp), ) } } if (selected) { - Icon( - Icons.Filled.CheckCircle, - modifier = Modifier.size(16.dp), - contentDescription = "" - ) + Icon(Icons.Filled.CheckCircle, modifier = Modifier.size(16.dp), contentDescription = "") } } } } } -@Preview(showBackground = true) -@Composable -fun ModelPickerPreview() { - val context = LocalContext.current +// @Preview(showBackground = true) +// @Composable +// fun ModelPickerPreview() { +// val context = LocalContext.current - GalleryTheme { - ModelPicker( - task = TASK_TEST1, - modelManagerViewModel = PreviewModelManagerViewModel(context = context), - onModelSelected = {}, - ) - } -} \ No newline at end of file +// GalleryTheme { +// ModelPicker( +// task = TASK_TEST1, +// modelManagerViewModel = PreviewModelManagerViewModel(context = context), +// onModelSelected = {}, +// ) +// } +// } diff --git a/Android/src/app/src/main/java/com/google/ai/edge/gallery/ui/common/ModelPickerChipsPager.kt b/Android/src/app/src/main/java/com/google/ai/edge/gallery/ui/common/ModelPickerChipsPager.kt index 1fe9655..6d095da 100644 --- a/Android/src/app/src/main/java/com/google/ai/edge/gallery/ui/common/ModelPickerChipsPager.kt +++ b/Android/src/app/src/main/java/com/google/ai/edge/gallery/ui/common/ModelPickerChipsPager.kt @@ -64,9 +64,9 @@ import com.google.ai.edge.gallery.data.Task import com.google.ai.edge.gallery.ui.common.modelitem.StatusIcon import com.google.ai.edge.gallery.ui.modelmanager.ModelInitializationStatusType import com.google.ai.edge.gallery.ui.modelmanager.ModelManagerViewModel +import kotlin.math.absoluteValue import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.launch -import kotlin.math.absoluteValue @OptIn(ExperimentalMaterial3Api::class) @Composable @@ -83,14 +83,13 @@ fun ModelPickerChipsPager( val scope = rememberCoroutineScope() val density = LocalDensity.current val windowInfo = LocalWindowInfo.current - val screenWidthDp = remember { - with(density) { - windowInfo.containerSize.width.toDp() - } - } + val screenWidthDp = remember { with(density) { windowInfo.containerSize.width.toDp() } } - val pagerState = rememberPagerState(initialPage = task.models.indexOf(initialModel), - pageCount = { task.models.size }) + val pagerState = + rememberPagerState( + initialPage = task.models.indexOf(initialModel), + pageCount = { task.models.size }, + ) // Sync scrolling. LaunchedEffect(modelManagerViewModel.pagerScrollState) { @@ -107,56 +106,51 @@ fun ModelPickerChipsPager( ((pagerState.currentPage - pageIndex) + pagerState.currentPageOffsetFraction).absoluteValue val curAlpha = 1f - (pageOffset * 1.5f).coerceIn(0f, 1f) - val modelInitializationStatus = - modelManagerUiState.modelInitializationStatus[model.name] + val modelInitializationStatus = modelManagerUiState.modelInitializationStatus[model.name] Box( - modifier = Modifier - .fillMaxWidth() - .graphicsLayer { alpha = curAlpha }, - contentAlignment = Alignment.Center + modifier = Modifier.fillMaxWidth().graphicsLayer { alpha = curAlpha }, + contentAlignment = Alignment.Center, ) { Row( verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.spacedBy(2.dp) + horizontalArrangement = Arrangement.spacedBy(2.dp), ) { - Row(verticalAlignment = Alignment.CenterVertically, + Row( + verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(2.dp), - modifier = Modifier - .clip(CircleShape) - .background(MaterialTheme.colorScheme.surfaceContainerHigh) - .clickable { - modelPickerModel = model - showModelPicker = true - } - .padding(start = 8.dp, end = 2.dp) - .padding(vertical = 4.dp)) Inner@{ + modifier = + Modifier.clip(CircleShape) + .background(MaterialTheme.colorScheme.surfaceContainerHigh) + .clickable { + modelPickerModel = model + showModelPicker = true + } + .padding(start = 8.dp, end = 2.dp) + .padding(vertical = 4.dp), + ) Inner@{ Box(contentAlignment = Alignment.Center, modifier = Modifier.size(21.dp)) { StatusIcon(downloadStatus = modelManagerUiState.modelDownloadStatus[model.name]) this@Inner.AnimatedVisibility( - visible = modelInitializationStatus?.status == ModelInitializationStatusType.INITIALIZING, + visible = + modelInitializationStatus?.status == ModelInitializationStatusType.INITIALIZING, enter = scaleIn() + fadeIn(), exit = scaleOut() + fadeOut(), ) { // Circular progress indicator. CircularProgressIndicator( - modifier = Modifier - .size(24.dp) - .alpha(0.5f), + modifier = Modifier.size(24.dp).alpha(0.5f), strokeWidth = 2.dp, - color = MaterialTheme.colorScheme.onSurfaceVariant + color = MaterialTheme.colorScheme.onSurfaceVariant, ) } } Text( model.name, style = MaterialTheme.typography.labelLarge, - modifier = Modifier - .padding(start = 4.dp) - .widthIn(0.dp, screenWidthDp - 250.dp), + modifier = Modifier.padding(start = 4.dp).widthIn(0.dp, screenWidthDp - 250.dp), maxLines = 1, - overflow = TextOverflow.MiddleEllipsis - + overflow = TextOverflow.MiddleEllipsis, ) Icon( Icons.Rounded.ArrowDropDown, @@ -171,10 +165,7 @@ fun ModelPickerChipsPager( // Model picker. val curModelPickerModel = modelPickerModel if (showModelPicker && curModelPickerModel != null) { - ModalBottomSheet( - onDismissRequest = { showModelPicker = false }, - sheetState = sheetState, - ) { + ModalBottomSheet(onDismissRequest = { showModelPicker = false }, sheetState = sheetState) { ModelPicker( task = task, modelManagerViewModel = modelManagerViewModel, @@ -187,8 +178,8 @@ fun ModelPickerChipsPager( } onModelSelected(selectedModel) - } + }, ) } } -} \ No newline at end of file +} diff --git a/Android/src/app/src/main/java/com/google/ai/edge/gallery/ui/common/TaskIcon.kt b/Android/src/app/src/main/java/com/google/ai/edge/gallery/ui/common/TaskIcon.kt index 0ae662e..d4da147 100644 --- a/Android/src/app/src/main/java/com/google/ai/edge/gallery/ui/common/TaskIcon.kt +++ b/Android/src/app/src/main/java/com/google/ai/edge/gallery/ui/common/TaskIcon.kt @@ -52,28 +52,22 @@ private val SHAPES: List = listOf(R.drawable.pantegon, R.drawable.double_circle, R.drawable.circle, R.drawable.four_circle) /** - * Composable that displays an icon representing a task. It consists of a background - * image and a foreground icon, both centered within a square box. + * Composable that displays an icon representing a task. It consists of a background image and a + * foreground icon, both centered within a square box. */ @Composable fun TaskIcon(task: Task, modifier: Modifier = Modifier, width: Dp = 56.dp) { - Box( - modifier = modifier - .width(width) - .aspectRatio(1f), - contentAlignment = Alignment.Center, - ) { + Box(modifier = modifier.width(width).aspectRatio(1f), contentAlignment = Alignment.Center) { Image( painter = getTaskIconBgShape(task = task), contentDescription = "", - modifier = Modifier - .fillMaxSize() - .alpha(0.6f), + modifier = Modifier.fillMaxSize().alpha(0.6f), contentScale = ContentScale.Fit, - colorFilter = ColorFilter.tint( - MaterialTheme.customColors.taskIconShapeBgColor, - blendMode = BlendMode.SrcIn - ) + colorFilter = + ColorFilter.tint( + MaterialTheme.customColors.taskIconShapeBgColor, + blendMode = BlendMode.SrcIn, + ), ) Icon( task.icon ?: ImageVector.vectorResource(task.iconVectorResourceId!!), @@ -102,4 +96,4 @@ fun TaskIconPreview() { TaskIcon(task = TASK_LLM_CHAT, width = 80.dp) } } -} \ No newline at end of file +} diff --git a/Android/src/app/src/main/java/com/google/ai/edge/gallery/ui/common/Utils.kt b/Android/src/app/src/main/java/com/google/ai/edge/gallery/ui/common/Utils.kt index 5b72d17..97b184d 100644 --- a/Android/src/app/src/main/java/com/google/ai/edge/gallery/ui/common/Utils.kt +++ b/Android/src/app/src/main/java/com/google/ai/edge/gallery/ui/common/Utils.kt @@ -21,63 +21,18 @@ import android.content.Context import android.content.pm.PackageManager import android.net.Uri import android.os.Build -import android.util.Log import androidx.activity.compose.ManagedActivityResultLauncher -import androidx.compose.material3.MaterialTheme -import androidx.compose.runtime.Composable import androidx.compose.ui.graphics.Color import androidx.core.content.ContextCompat import androidx.core.content.FileProvider -import com.google.ai.edge.gallery.data.Config import com.google.ai.edge.gallery.data.Model -import com.google.ai.edge.gallery.data.TASKS import com.google.ai.edge.gallery.data.Task -import com.google.ai.edge.gallery.data.ValueType -import com.google.ai.edge.gallery.ui.common.chat.ChatMessageBenchmarkResult -import com.google.ai.edge.gallery.ui.common.chat.ChatMessageType -import com.google.ai.edge.gallery.ui.common.chat.ChatViewModel -import com.google.ai.edge.gallery.ui.common.chat.Histogram -import com.google.ai.edge.gallery.ui.common.chat.Stat import com.google.ai.edge.gallery.ui.modelmanager.ModelManagerViewModel -import com.google.ai.edge.gallery.ui.theme.customColors -import com.google.gson.Gson -import com.google.gson.reflect.TypeToken -import kotlinx.serialization.ExperimentalSerializationApi -import kotlinx.serialization.json.Json import java.io.File -import java.net.HttpURLConnection -import java.net.URL -import kotlin.math.abs import kotlin.math.ln -import kotlin.math.max -import kotlin.math.min import kotlin.math.pow -import kotlin.math.sqrt private const val TAG = "AGUtils" -private const val LAUNCH_INFO_FILE_NAME = "launch_info" - -private val STATS = listOf( - Stat(id = "min", label = "Min", unit = "ms"), - Stat(id = "max", label = "Max", unit = "ms"), - Stat(id = "avg", label = "Avg", unit = "ms"), - Stat(id = "stddev", label = "Stddev", unit = "ms") -) - -interface LatencyProvider { - val latencyMs: Float -} - -private const val START_THINKING = "***Thinking...***" -private const val DONE_THINKING = "***Done thinking***" - -data class JsonObjAndTextContent( - val jsonObj: T, val textContent: String, -) - -data class LaunchInfo( - val ts: Long -) /** Format the bytes into a human-readable format. */ fun Long.humanReadableSize(si: Boolean = true, extraDecimalForGbAndAbove: Boolean = false): String { @@ -139,320 +94,56 @@ fun Long.formatToHourMinSecond(): String { return parts.joinToString(" ") } -fun convertValueToTargetType(value: Any, valueType: ValueType): Any { - return when (valueType) { - ValueType.INT -> when (value) { - is Int -> value - is Float -> value.toInt() - is Double -> value.toInt() - is String -> value.toIntOrNull() ?: "" - is Boolean -> if (value) 1 else 0 - else -> "" - } - - ValueType.FLOAT -> when (value) { - is Int -> value.toFloat() - is Float -> value - is Double -> value.toFloat() - is String -> value.toFloatOrNull() ?: "" - is Boolean -> if (value) 1f else 0f - else -> "" - } - - ValueType.DOUBLE -> when (value) { - is Int -> value.toDouble() - is Float -> value.toDouble() - is Double -> value - is String -> value.toDoubleOrNull() ?: "" - is Boolean -> if (value) 1.0 else 0.0 - else -> "" - } - - ValueType.BOOLEAN -> when (value) { - is Int -> value == 0 - is Boolean -> value - is Float -> abs(value) > 1e-6 - is Double -> abs(value) > 1e-6 - is String -> value.isNotEmpty() - else -> false - } - - ValueType.STRING -> value.toString() - } -} - fun getDistinctiveColor(index: Int): Color { - val colors = listOf( -// Color(0xffe6194b), - Color(0xff3cb44b), - Color(0xffffe119), - Color(0xff4363d8), - Color(0xfff58231), - Color(0xff911eb4), - Color(0xff46f0f0), - Color(0xfff032e6), - Color(0xffbcf60c), - Color(0xfffabebe), - Color(0xff008080), - Color(0xffe6beff), - Color(0xff9a6324), - Color(0xfffffac8), - Color(0xff800000), - Color(0xffaaffc3), - Color(0xff808000), - Color(0xffffd8b1), - Color(0xff000075) - ) + val colors = + listOf( + // Color(0xffe6194b), + Color(0xff3cb44b), + Color(0xffffe119), + Color(0xff4363d8), + Color(0xfff58231), + Color(0xff911eb4), + Color(0xff46f0f0), + Color(0xfff032e6), + Color(0xffbcf60c), + Color(0xfffabebe), + Color(0xff008080), + Color(0xffe6beff), + Color(0xff9a6324), + Color(0xfffffac8), + Color(0xff800000), + Color(0xffaaffc3), + Color(0xff808000), + Color(0xffffd8b1), + Color(0xff000075), + ) return colors[index % colors.size] } fun Context.createTempPictureUri( - fileName: String = "picture_${System.currentTimeMillis()}", fileExtension: String = ".png" + fileName: String = "picture_${System.currentTimeMillis()}", + fileExtension: String = ".png", ): Uri { - val tempFile = File.createTempFile( - fileName, fileExtension, cacheDir - ).apply { - createNewFile() - } + val tempFile = File.createTempFile(fileName, fileExtension, cacheDir).apply { createNewFile() } return FileProvider.getUriForFile( applicationContext, "com.google.aiedge.gallery.provider" /* {applicationId}.provider */, - tempFile + tempFile, ) } -fun runBasicBenchmark( - model: Model, - warmupCount: Int, - iterations: Int, - chatViewModel: ChatViewModel, - inferenceFn: () -> LatencyProvider, - chatMessageType: ChatMessageType, -) { - val start = System.currentTimeMillis() - var lastUpdateTs = 0L - val update: (ChatMessageBenchmarkResult) -> Unit = { message -> - if (lastUpdateTs == 0L) { - chatViewModel.addMessage( - model = model, - message = message, - ) - lastUpdateTs = System.currentTimeMillis() - } else { - val curTs = System.currentTimeMillis() - if (curTs - lastUpdateTs > 500) { - chatViewModel.replaceLastMessage(model = model, message = message, type = chatMessageType) - lastUpdateTs = curTs - } - } - } - - // Warmup. - val latencies: MutableList = mutableListOf() - for (count in 1..warmupCount) { - inferenceFn() - update( - ChatMessageBenchmarkResult( - orderedStats = STATS, - statValues = calculateStats(min = 0f, max = 0f, sum = 0f, latencies = latencies), - histogram = calculateLatencyHistogram( - latencies = latencies, min = 0f, max = 0f, avg = 0f - ), - values = latencies, - warmupCurrent = count, - warmupTotal = warmupCount, - iterationCurrent = 0, - iterationTotal = iterations, - latencyMs = (System.currentTimeMillis() - start).toFloat(), - highlightStat = "avg" - ) - ) - } - - // Benchmark iterations. - var min = Float.MAX_VALUE - var max = 0f - var sum = 0f - for (count in 1..iterations) { - val result = inferenceFn() - val latency = result.latencyMs - min = min(min, latency) - max = max(max, latency) - sum += latency - latencies.add(latency) - - val curTs = System.currentTimeMillis() - if (curTs - lastUpdateTs > 500 || count == iterations) { - lastUpdateTs = curTs - - val stats = calculateStats(min = min, max = max, sum = sum, latencies = latencies) - chatViewModel.replaceLastMessage( - model = model, - message = ChatMessageBenchmarkResult( - orderedStats = STATS, - statValues = stats, - histogram = calculateLatencyHistogram( - latencies = latencies, - min = min, - max = max, - avg = stats["avg"] ?: 0f, - ), - values = latencies, - warmupCurrent = warmupCount, - warmupTotal = warmupCount, - iterationCurrent = count, - iterationTotal = iterations, - latencyMs = (System.currentTimeMillis() - start).toFloat(), - highlightStat = "avg" - ), - type = chatMessageType, - ) - } - - // Go through other benchmark messages and update their buckets for the common min/max values. - var allMin = Float.MAX_VALUE - var allMax = 0f - val allMessages = chatViewModel.uiState.value.messagesByModel[model.name] ?: listOf() - for (message in allMessages) { - if (message is ChatMessageBenchmarkResult) { - val curMin = message.statValues["min"] ?: 0f - val curMax = message.statValues["max"] ?: 0f - allMin = min(allMin, curMin) - allMax = max(allMax, curMax) - } - } - - for ((index, message) in allMessages.withIndex()) { - if (message === allMessages.last()) { - break - } - if (message is ChatMessageBenchmarkResult) { - val updatedMessage = ChatMessageBenchmarkResult( - orderedStats = STATS, - statValues = message.statValues, - histogram = calculateLatencyHistogram( - latencies = message.values, - min = allMin, - max = allMax, - avg = message.statValues["avg"] ?: 0f, - ), - values = message.values, - warmupCurrent = message.warmupCurrent, - warmupTotal = message.warmupTotal, - iterationCurrent = message.iterationCurrent, - iterationTotal = message.iterationTotal, - latencyMs = message.latencyMs, - highlightStat = "avg" - ) - chatViewModel.replaceMessage(model = model, index = index, message = updatedMessage) - } - } - } -} - -private fun calculateStats( - min: Float, max: Float, sum: Float, latencies: MutableList -): MutableMap { - val avg = if (latencies.size == 0) 0f else sum / latencies.size - val squaredDifferences = latencies.map { (it - avg).pow(2) } - val variance = squaredDifferences.average() - val stddev = if (latencies.size == 0) 0f else sqrt(variance).toFloat() - var medium = 0f - if (latencies.size == 1) { - medium = latencies[0] - } else if (latencies.size > 1) { - latencies.sort() - val middle = latencies.size / 2 - medium = - if (latencies.size % 2 == 0) (latencies[middle - 1] + latencies[middle]) / 2.0f else latencies[middle] - } - return mutableMapOf( - "min" to min, "max" to max, "avg" to avg, "stddev" to stddev, "medium" to medium - ) -} - -fun calculateLatencyHistogram( - latencies: List, min: Float, max: Float, avg: Float, numBuckets: Int = 20 -): Histogram { - if (latencies.isEmpty() || numBuckets <= 0) { - return Histogram( - buckets = List(numBuckets) { 0 }, maxCount = 0 - ) - } - - if (min == max) { - // All latencies are the same. - val result = MutableList(numBuckets) { 0 } - result[0] = latencies.size - return Histogram(buckets = result, maxCount = result[0], highlightBucketIndex = 0) - } - - val bucketSize = (max - min) / numBuckets - - val histogram = MutableList(numBuckets) { 0 } - - val getBucketIndex: (value: Float) -> Int = { - var bucketIndex = ((it - min) / bucketSize).toInt() - // Handle the case where latency equals maxLatency - if (bucketIndex == numBuckets) { - bucketIndex = numBuckets - 1 - } - bucketIndex - } - - for (latency in latencies) { - val bucketIndex = getBucketIndex(latency) - histogram[bucketIndex]++ - } - - val avgBucketIndex = getBucketIndex(avg) - return Histogram( - buckets = histogram, - maxCount = histogram.maxOrNull() ?: 0, - highlightBucketIndex = avgBucketIndex - ) -} - -fun getConfigValueString(value: Any, config: Config): String { - var strNewValue = "$value" - if (config.valueType == ValueType.FLOAT) { - strNewValue = "%.2f".format(value) - } - return strNewValue -} - -@Composable -fun getTaskBgColor(task: Task): Color { - val colorIndex: Int = task.index % MaterialTheme.customColors.taskBgColors.size - return MaterialTheme.customColors.taskBgColors[colorIndex] -} - -@Composable -fun getTaskIconColor(task: Task): Color { - val colorIndex: Int = task.index % MaterialTheme.customColors.taskIconColors.size - return MaterialTheme.customColors.taskIconColors[colorIndex] -} - -@Composable -fun getTaskIconColor(index: Int): Color { - val colorIndex: Int = index % MaterialTheme.customColors.taskIconColors.size - return MaterialTheme.customColors.taskIconColors[colorIndex] -} - fun checkNotificationPermissionAndStartDownload( context: Context, launcher: ManagedActivityResultLauncher, modelManagerViewModel: ModelManagerViewModel, task: Task, - model: Model + model: Model, ) { // Check permission when (PackageManager.PERMISSION_GRANTED) { // Already got permission. Call the lambda. - ContextCompat.checkSelfPermission( - context, Manifest.permission.POST_NOTIFICATIONS - ) -> { + ContextCompat.checkSelfPermission(context, Manifest.permission.POST_NOTIFICATIONS) -> { modelManagerViewModel.downloadModel(task = task, model = model) } @@ -468,100 +159,3 @@ fun checkNotificationPermissionAndStartDownload( fun ensureValidFileName(fileName: String): String { return fileName.replace(Regex("[^a-zA-Z0-9._-]"), "_") } - -fun cleanUpMediapipeTaskErrorMessage(message: String): String { - val index = message.indexOf("=== Source Location Trace") - if (index >= 0) { - return message.substring(0, index) - } - return message -} - -fun processTasks() { - for ((index, task) in TASKS.withIndex()) { - task.index = index - for (model in task.models) { - model.preProcess() - } - } -} - -fun processLlmResponse(response: String): String { - // Add "thinking" and "done thinking" around the thinking content. - var newContent = - response.replace("", "$START_THINKING\n").replace("", "\n$DONE_THINKING") - - // Remove empty thinking content. - val endThinkingIndex = newContent.indexOf(DONE_THINKING) - if (endThinkingIndex >= 0) { - val thinkingContent = - newContent.substring(0, endThinkingIndex + DONE_THINKING.length).replace(START_THINKING, "") - .replace(DONE_THINKING, "") - if (thinkingContent.isBlank()) { - newContent = newContent.substring(endThinkingIndex + DONE_THINKING.length) - } - } - - newContent = newContent.replace("\\n", "\n") - - return newContent -} - -@OptIn(ExperimentalSerializationApi::class) -inline fun getJsonResponse(url: String): JsonObjAndTextContent? { - try { - val connection = URL(url).openConnection() as HttpURLConnection - connection.requestMethod = "GET" - connection.connect() - - val responseCode = connection.responseCode - if (responseCode == HttpURLConnection.HTTP_OK) { - val inputStream = connection.inputStream - val response = inputStream.bufferedReader().use { it.readText() } - - // Parse JSON using kotlinx.serialization - val json = Json { - // Handle potential extra fields - ignoreUnknownKeys = true - allowComments = true - allowTrailingComma = true - } - val jsonObj = json.decodeFromString(response) - return JsonObjAndTextContent(jsonObj = jsonObj, textContent = response) - } else { - Log.e("AGUtils", "HTTP error: $responseCode") - } - } catch (e: Exception) { - Log.e( - "AGUtils", "Error when getting json response: ${e.message}" - ) - e.printStackTrace() - } - - return null -} - -fun writeLaunchInfo(context: Context) { - try { - val gson = Gson() - val launchInfo = LaunchInfo(ts = System.currentTimeMillis()) - val jsonString = gson.toJson(launchInfo) - val file = File(context.getExternalFilesDir(null), LAUNCH_INFO_FILE_NAME) - file.writeText(jsonString) - } catch (e: Exception) { - Log.e(TAG, "Failed to write launch info", e) - } -} - -fun readLaunchInfo(context: Context): LaunchInfo? { - try { - val gson = Gson() - val type = object : TypeToken() {}.type - val file = File(context.getExternalFilesDir(null), LAUNCH_INFO_FILE_NAME) - val content = file.readText() - return gson.fromJson(content, type) - } catch (e: Exception) { - Log.e(TAG, "Failed to read launch info", e) - return null - } -} \ No newline at end of file diff --git a/Android/src/app/src/main/java/com/google/ai/edge/gallery/ui/common/chat/BenchmarkConfigDialog.kt b/Android/src/app/src/main/java/com/google/ai/edge/gallery/ui/common/chat/BenchmarkConfigDialog.kt index 14ebc6f..7f51da0 100644 --- a/Android/src/app/src/main/java/com/google/ai/edge/gallery/ui/common/chat/BenchmarkConfigDialog.kt +++ b/Android/src/app/src/main/java/com/google/ai/edge/gallery/ui/common/chat/BenchmarkConfigDialog.kt @@ -16,52 +16,55 @@ package com.google.ai.edge.gallery.ui.common.chat +// import androidx.compose.ui.tooling.preview.Preview +// import com.google.ai.edge.gallery.ui.theme.GalleryTheme import androidx.compose.runtime.Composable -import androidx.compose.ui.tooling.preview.Preview import com.google.ai.edge.gallery.data.Config import com.google.ai.edge.gallery.data.ConfigKey import com.google.ai.edge.gallery.data.NumberSliderConfig import com.google.ai.edge.gallery.data.ValueType -import com.google.ai.edge.gallery.ui.common.convertValueToTargetType -import com.google.ai.edge.gallery.ui.theme.GalleryTheme +import com.google.ai.edge.gallery.data.convertValueToTargetType +import com.google.ai.edge.gallery.ui.common.ConfigDialog private const val DEFAULT_BENCHMARK_WARM_UP_ITERATIONS = 50f private const val DEFAULT_BENCHMARK_ITERATIONS = 200f -private val BENCHMARK_CONFIGS: List = listOf( - NumberSliderConfig( - key = ConfigKey.WARM_UP_ITERATIONS, - sliderMin = 10f, - sliderMax = 200f, - defaultValue = DEFAULT_BENCHMARK_WARM_UP_ITERATIONS, - valueType = ValueType.INT - ), - NumberSliderConfig( - key = ConfigKey.BENCHMARK_ITERATIONS, - sliderMin = 50f, - sliderMax = 500f, - defaultValue = DEFAULT_BENCHMARK_ITERATIONS, - valueType = ValueType.INT - ), -) +private val BENCHMARK_CONFIGS: List = + listOf( + NumberSliderConfig( + key = ConfigKey.WARM_UP_ITERATIONS, + sliderMin = 10f, + sliderMax = 200f, + defaultValue = DEFAULT_BENCHMARK_WARM_UP_ITERATIONS, + valueType = ValueType.INT, + ), + NumberSliderConfig( + key = ConfigKey.BENCHMARK_ITERATIONS, + sliderMin = 50f, + sliderMax = 500f, + defaultValue = DEFAULT_BENCHMARK_ITERATIONS, + valueType = ValueType.INT, + ), + ) -private val BENCHMARK_CONFIGS_INITIAL_VALUES = mapOf( - ConfigKey.WARM_UP_ITERATIONS.label to DEFAULT_BENCHMARK_WARM_UP_ITERATIONS, - ConfigKey.BENCHMARK_ITERATIONS.label to DEFAULT_BENCHMARK_ITERATIONS -) +private val BENCHMARK_CONFIGS_INITIAL_VALUES = + mapOf( + ConfigKey.WARM_UP_ITERATIONS.label to DEFAULT_BENCHMARK_WARM_UP_ITERATIONS, + ConfigKey.BENCHMARK_ITERATIONS.label to DEFAULT_BENCHMARK_ITERATIONS, + ) /** * Composable function to display a configuration dialog for benchmarking a chat message. * - * This function renders a configuration dialog specifically tailored for setting up - * benchmark parameters. It allows users to specify warm-up and benchmark iterations - * before running a benchmark test on a given chat message. + * This function renders a configuration dialog specifically tailored for setting up benchmark + * parameters. It allows users to specify warm-up and benchmark iterations before running a + * benchmark test on a given chat message. */ @Composable fun BenchmarkConfigDialog( onDismissed: () -> Unit, messageToBenchmark: ChatMessage?, - onBenchmarkClicked: (ChatMessage, warmUpIterations: Int, benchmarkIterations: Int) -> Unit + onBenchmarkClicked: (ChatMessage, warmUpIterations: Int, benchmarkIterations: Int) -> Unit, ) { ConfigDialog( title = "Benchmark configs", @@ -75,28 +78,32 @@ fun BenchmarkConfigDialog( // Start benchmark. messageToBenchmark?.let { message -> - val warmUpIterations = convertValueToTargetType( - value = curConfigValues.getValue(ConfigKey.WARM_UP_ITERATIONS.label), - valueType = ValueType.INT - ) as Int - val benchmarkIterations = convertValueToTargetType( - value = curConfigValues.getValue(ConfigKey.BENCHMARK_ITERATIONS.label), - valueType = ValueType.INT - ) as Int + val warmUpIterations = + convertValueToTargetType( + value = curConfigValues.getValue(ConfigKey.WARM_UP_ITERATIONS.label), + valueType = ValueType.INT, + ) + as Int + val benchmarkIterations = + convertValueToTargetType( + value = curConfigValues.getValue(ConfigKey.BENCHMARK_ITERATIONS.label), + valueType = ValueType.INT, + ) + as Int onBenchmarkClicked(message, warmUpIterations, benchmarkIterations) } }, ) } -@Preview(showBackground = true) -@Composable -fun BenchmarkConfigDialogPreview() { - GalleryTheme { - BenchmarkConfigDialog( - onDismissed = {}, - messageToBenchmark = null, - onBenchmarkClicked = { _, _, _ -> } - ) - } -} \ No newline at end of file +// @Preview(showBackground = true) +// @Composable +// fun BenchmarkConfigDialogPreview() { +// GalleryTheme { +// BenchmarkConfigDialog( +// onDismissed = {}, +// messageToBenchmark = null, +// onBenchmarkClicked = { _, _, _ -> }, +// ) +// } +// } diff --git a/Android/src/app/src/main/java/com/google/ai/edge/gallery/ui/common/chat/ChatMessage.kt b/Android/src/app/src/main/java/com/google/ai/edge/gallery/ui/common/chat/ChatMessage.kt index 50cae09..7eba194 100644 --- a/Android/src/app/src/main/java/com/google/ai/edge/gallery/ui/common/chat/ChatMessage.kt +++ b/Android/src/app/src/main/java/com/google/ai/edge/gallery/ui/common/chat/ChatMessage.kt @@ -17,10 +17,11 @@ package com.google.ai.edge.gallery.ui.common.chat import android.graphics.Bitmap -import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.ImageBitmap import androidx.compose.ui.unit.Dp +import com.google.ai.edge.gallery.common.Classification import com.google.ai.edge.gallery.data.Model +import com.google.ai.edge.gallery.data.PromptTemplate enum class ChatMessageType { INFO, @@ -33,15 +34,15 @@ enum class ChatMessageType { CONFIG_VALUES_CHANGE, BENCHMARK_RESULT, BENCHMARK_LLM_RESULT, - PROMPT_TEMPLATES + PROMPT_TEMPLATES, } enum class ChatSide { - USER, AGENT, SYSTEM + USER, + AGENT, + SYSTEM, } -data class Classification(val label: String, val score: Float, val color: Color) - /** Base class for a chat message. */ open class ChatMessage( open val type: ChatMessageType, @@ -70,7 +71,7 @@ class ChatMessageWarning(val content: String) : class ChatMessageConfigValuesChange( val model: Model, val oldValues: Map, - val newValues: Map + val newValues: Map, ) : ChatMessage(type = ChatMessageType.CONFIG_VALUES_CHANGE, side = ChatSide.SYSTEM) /** Chat message for plain text. */ @@ -84,12 +85,13 @@ open class ChatMessageText( // Benchmark result for LLM response. var llmBenchmarkResult: ChatMessageBenchmarkLlmResult? = null, override val accelerator: String = "", -) : ChatMessage( - type = ChatMessageType.TEXT, - side = side, - latencyMs = latencyMs, - accelerator = accelerator -) { +) : + ChatMessage( + type = ChatMessageType.TEXT, + side = side, + latencyMs = latencyMs, + accelerator = accelerator, + ) { override fun clone(): ChatMessageText { return ChatMessageText( content = content, @@ -107,15 +109,14 @@ class ChatMessageImage( val bitmap: Bitmap, val imageBitMap: ImageBitmap, override val side: ChatSide, - override val latencyMs: Float = 0f -) : - ChatMessage(type = ChatMessageType.IMAGE, side = side, latencyMs = latencyMs) { + override val latencyMs: Float = 0f, +) : ChatMessage(type = ChatMessageType.IMAGE, side = side, latencyMs = latencyMs) { override fun clone(): ChatMessageImage { return ChatMessageImage( bitmap = bitmap, imageBitMap = imageBitMap, side = side, - latencyMs = latencyMs + latencyMs = latencyMs, ) } } @@ -128,8 +129,7 @@ class ChatMessageImageWithHistory( override val side: ChatSide, override val latencyMs: Float = 0f, var curIteration: Int = 0, // 0-based -) : - ChatMessage(type = ChatMessageType.IMAGE_WITH_HISTORY, side = side, latencyMs = latencyMs) { +) : ChatMessage(type = ChatMessageType.IMAGE_WITH_HISTORY, side = side, latencyMs = latencyMs) { fun isRunning(): Boolean { return curIteration < totalIterations - 1 } @@ -141,7 +141,8 @@ class ChatMessageClassification( override val latencyMs: Float = 0f, // Typical android phone width is > 320dp val maxBarWidth: Dp? = null, -) : ChatMessage(type = ChatMessageType.CLASSIFICATION, side = ChatSide.AGENT, latencyMs = latencyMs) +) : + ChatMessage(type = ChatMessageType.CLASSIFICATION, side = ChatSide.AGENT, latencyMs = latencyMs) /** A stat used in benchmark result. */ data class Stat(val id: String, val label: String, val unit: String) @@ -162,7 +163,7 @@ class ChatMessageBenchmarkResult( ChatMessage( type = ChatMessageType.BENCHMARK_RESULT, side = ChatSide.AGENT, - latencyMs = latencyMs + latencyMs = latencyMs, ) { fun isWarmingUp(): Boolean { return warmupCurrent < warmupTotal @@ -180,23 +181,18 @@ class ChatMessageBenchmarkLlmResult( val running: Boolean, override val latencyMs: Float = 0f, override val accelerator: String = "", -) : ChatMessage( - type = ChatMessageType.BENCHMARK_LLM_RESULT, - side = ChatSide.AGENT, - latencyMs = latencyMs, - accelerator = accelerator, -) +) : + ChatMessage( + type = ChatMessageType.BENCHMARK_LLM_RESULT, + side = ChatSide.AGENT, + latencyMs = latencyMs, + accelerator = accelerator, + ) -data class Histogram( - val buckets: List, - val maxCount: Int, - val highlightBucketIndex: Int = -1 -) +data class Histogram(val buckets: List, val maxCount: Int, val highlightBucketIndex: Int = -1) /** Chat message for showing prompt templates. */ class ChatMessagePromptTemplates( val templates: List, val showMakeYourOwn: Boolean = true, ) : ChatMessage(type = ChatMessageType.PROMPT_TEMPLATES, side = ChatSide.SYSTEM) - -data class PromptTemplate(val title: String, val description: String, val prompt: String) \ No newline at end of file diff --git a/Android/src/app/src/main/java/com/google/ai/edge/gallery/ui/common/chat/ChatPanel.kt b/Android/src/app/src/main/java/com/google/ai/edge/gallery/ui/common/chat/ChatPanel.kt index 1e035eb..2b8b168 100644 --- a/Android/src/app/src/main/java/com/google/ai/edge/gallery/ui/common/chat/ChatPanel.kt +++ b/Android/src/app/src/main/java/com/google/ai/edge/gallery/ui/common/chat/ChatPanel.kt @@ -16,6 +16,11 @@ package com.google.ai.edge.gallery.ui.common.chat +// import com.google.ai.edge.gallery.ui.preview.PreviewChatModel +// import com.google.ai.edge.gallery.ui.preview.PreviewModelManagerViewModel +// import com.google.ai.edge.gallery.ui.preview.TASK_TEST1 +// import com.google.ai.edge.gallery.ui.theme.GalleryTheme +import android.content.ClipData import android.graphics.Bitmap import androidx.compose.foundation.background import androidx.compose.foundation.clickable @@ -69,15 +74,13 @@ import androidx.compose.ui.input.nestedscroll.NestedScrollConnection import androidx.compose.ui.input.nestedscroll.NestedScrollSource import androidx.compose.ui.input.nestedscroll.nestedScroll import androidx.compose.ui.input.pointer.pointerInput -import androidx.compose.ui.platform.LocalClipboardManager -import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.platform.ClipEntry +import androidx.compose.ui.platform.LocalClipboard import androidx.compose.ui.platform.LocalDensity import androidx.compose.ui.platform.LocalFocusManager import androidx.compose.ui.platform.LocalHapticFeedback import androidx.compose.ui.res.dimensionResource import androidx.compose.ui.res.stringResource -import androidx.compose.ui.text.AnnotatedString -import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp import com.google.ai.edge.gallery.R import com.google.ai.edge.gallery.data.Model @@ -86,20 +89,15 @@ import com.google.ai.edge.gallery.data.TaskType import com.google.ai.edge.gallery.ui.common.ErrorDialog import com.google.ai.edge.gallery.ui.modelmanager.ModelInitializationStatusType import com.google.ai.edge.gallery.ui.modelmanager.ModelManagerViewModel -import com.google.ai.edge.gallery.ui.preview.PreviewChatModel -import com.google.ai.edge.gallery.ui.preview.PreviewModelManagerViewModel -import com.google.ai.edge.gallery.ui.preview.TASK_TEST1 -import com.google.ai.edge.gallery.ui.theme.GalleryTheme import com.google.ai.edge.gallery.ui.theme.customColors import kotlinx.coroutines.launch enum class ChatInputType { - TEXT, IMAGE, + TEXT, + IMAGE, } -/** - * Composable function for the main chat panel, displaying messages and handling user input. - */ +/** Composable function for the main chat panel, displaying messages and handling user input. */ @OptIn(ExperimentalMaterial3Api::class) @Composable fun ChatPanel( @@ -126,18 +124,19 @@ fun ChatPanel( val snackbarHostState = remember { SnackbarHostState() } val scope = rememberCoroutineScope() val haptic = LocalHapticFeedback.current - val hasImageMessageToLastConfigChange = remember(messages) { - var foundImageMessage = false - for (message in messages.reversed()) { - if (message is ChatMessageConfigValuesChange) { - break - } - if (message is ChatMessageImage) { - foundImageMessage = true + val imageMessageCountToLastConfigChange = + remember(messages) { + var imageMessageCount = 0 + for (message in messages.reversed()) { + if (message is ChatMessageConfigValuesChange) { + break + } + if (message is ChatMessageImage) { + imageMessageCount++ + } } + imageMessageCount } - foundImageMessage - } var curMessage by remember { mutableStateOf("") } // Correct state val focusManager = LocalFocusManager.current @@ -163,8 +162,9 @@ fun ChatPanel( lastMessageContent.value = tmpLastMessage.content } } - val lastShowingStatsByModel: MutableState>> = - remember { mutableStateOf(mapOf()) } + val lastShowingStatsByModel: MutableState>> = remember { + mutableStateOf(mapOf()) + } // Scroll the content to the bottom when any of these changes. LaunchedEffect( @@ -217,15 +217,12 @@ fun ChatPanel( showErrorDialog = modelInitializationStatus?.status == ModelInitializationStatusType.ERROR } - Column( - modifier = modifier.imePadding() - ) { + Column(modifier = modifier.imePadding()) { Box(contentAlignment = Alignment.BottomCenter, modifier = Modifier.weight(1f)) { LazyColumn( - modifier = Modifier - .fillMaxSize() - .nestedScroll(nestedScrollConnection), - state = listState, verticalArrangement = Arrangement.Top, + modifier = Modifier.fillMaxSize().nestedScroll(nestedScrollConnection), + state = listState, + verticalArrangement = Arrangement.Top, ) { items(messages) { message -> val imageHistoryCurIndex = remember { mutableIntStateOf(0) } @@ -254,14 +251,14 @@ fun ChatPanel( val bubbleBorderRadius = dimensionResource(R.dimen.chat_bubble_corner_radius) Column( - modifier = Modifier - .fillMaxWidth() - .padding( - start = 12.dp + extraPaddingStart, - end = 12.dp + extraPaddingEnd, - top = 6.dp, - bottom = 6.dp, - ), + modifier = + Modifier.fillMaxWidth() + .padding( + start = 12.dp + extraPaddingStart, + end = 12.dp + extraPaddingEnd, + top = 6.dp, + bottom = 6.dp, + ), horizontalAlignment = hAlign, ) messageColumn@{ // Sender row. @@ -272,7 +269,7 @@ fun ChatPanel( MessageSender( message = message, agentName = agentName, - imageHistoryCurIndex = imageHistoryCurIndex.intValue + imageHistoryCurIndex = imageHistoryCurIndex.intValue, ) // Message body. @@ -290,40 +287,42 @@ fun ChatPanel( is ChatMessageConfigValuesChange -> MessageBodyConfigUpdate(message = message) // Prompt templates. - is ChatMessagePromptTemplates -> MessageBodyPromptTemplates(message = message, - task = task, - onPromptClicked = { template -> - onSendMessage( - selectedModel, - listOf(ChatMessageText(content = template.prompt, side = ChatSide.USER)) - ) - }) + is ChatMessagePromptTemplates -> + MessageBodyPromptTemplates( + message = message, + task = task, + onPromptClicked = { template -> + onSendMessage( + selectedModel, + listOf(ChatMessageText(content = template.prompt, side = ChatSide.USER)), + ) + }, + ) // Non-system messages. else -> { // The bubble shape around the message body. - var messageBubbleModifier = Modifier - .clip( - MessageBubbleShape( - radius = bubbleBorderRadius, - hardCornerAtLeftOrRight = hardCornerAtLeftOrRight + var messageBubbleModifier = + Modifier.clip( + MessageBubbleShape( + radius = bubbleBorderRadius, + hardCornerAtLeftOrRight = hardCornerAtLeftOrRight, + ) ) - ) - .background(backgroundColor) + .background(backgroundColor) if (message is ChatMessageText) { - messageBubbleModifier = messageBubbleModifier.pointerInput(Unit) { - detectTapGestures( - onLongPress = { - haptic.performHapticFeedback(HapticFeedbackType.LongPress) - longPressedMessage.value = message - showMessageLongPressedSheet = true - }, - ) - } + messageBubbleModifier = + messageBubbleModifier.pointerInput(Unit) { + detectTapGestures( + onLongPress = { + haptic.performHapticFeedback(HapticFeedbackType.LongPress) + longPressedMessage.value = message + showMessageLongPressedSheet = true + } + ) + } } - Box( - modifier = messageBubbleModifier, - ) { + Box(modifier = messageBubbleModifier) { when (message) { // Text is ChatMessageText -> MessageBodyText(message = message) @@ -331,32 +330,35 @@ fun ChatPanel( // Image is ChatMessageImage -> { MessageBodyImage( - message = message, modifier = Modifier - .clickable { - onImageSelected(message.bitmap) - } + message = message, + modifier = Modifier.clickable { onImageSelected(message.bitmap) }, ) } // Image with history (for image gen) - is ChatMessageImageWithHistory -> MessageBodyImageWithHistory( - message = message, imageHistoryCurIndex = imageHistoryCurIndex - ) + is ChatMessageImageWithHistory -> + MessageBodyImageWithHistory( + message = message, + imageHistoryCurIndex = imageHistoryCurIndex, + ) // Classification result - is ChatMessageClassification -> MessageBodyClassification( - message = message, modifier = Modifier.width( - message.maxBarWidth ?: CLASSIFICATION_BAR_MAX_WIDTH + is ChatMessageClassification -> + MessageBodyClassification( + message = message, + modifier = + Modifier.width(message.maxBarWidth ?: CLASSIFICATION_BAR_MAX_WIDTH), ) - ) // Benchmark result. is ChatMessageBenchmarkResult -> MessageBodyBenchmark(message = message) // Benchmark LLM result. - is ChatMessageBenchmarkLlmResult -> MessageBodyBenchmarkLlm( - message = message, modifier = Modifier.wrapContentWidth() - ) + is ChatMessageBenchmarkLlmResult -> + MessageBodyBenchmarkLlm( + message = message, + modifier = Modifier.wrapContentWidth(), + ) else -> {} } @@ -369,10 +371,14 @@ fun ChatPanel( ) { LatencyText(message = message) // A button to show stats for the LLM message. - if (task.type.id.startsWith("llm_") && message is ChatMessageText - // This means we only want to show the action button when the message is done - // generating, at which point the latency will be set. - && message.latencyMs >= 0 + if ( + task.type.id.startsWith("llm_") && + message is ChatMessageText + // This means we only want to show the action button when the message is + // done + // generating, at which point the latency will be set. + && + message.latencyMs >= 0 ) { val showingStats = viewModel.isShowingStats(model = selectedModel, message = message) @@ -384,10 +390,7 @@ fun ChatPanel( viewModel.toggleShowingStats(selectedModel, message) // Add the stats message after the LLM message. - if (viewModel.isShowingStats( - model = selectedModel, message = message - ) - ) { + if (viewModel.isShowingStats(model = selectedModel, message = message)) { val llmBenchmarkResult = message.llmBenchmarkResult if (llmBenchmarkResult != null) { viewModel.insertMessageAfter( @@ -399,32 +402,30 @@ fun ChatPanel( } // Remove the stats message. else { - val curMessageIndex = viewModel.getMessageIndex( - model = selectedModel, message = message - ) + val curMessageIndex = + viewModel.getMessageIndex(model = selectedModel, message = message) viewModel.removeMessageAt( - model = selectedModel, index = curMessageIndex + 1 + model = selectedModel, + index = curMessageIndex + 1, ) } }, - enabled = !uiState.inProgress + enabled = !uiState.inProgress, ) } } } else if (message.side == ChatSide.USER) { Row( verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.spacedBy(4.dp) + horizontalArrangement = Arrangement.spacedBy(4.dp), ) { // Run again button. if (selectedModel.showRunAgainButton) { MessageActionButton( label = stringResource(R.string.run_again), icon = Icons.Rounded.Refresh, - onClick = { - onRunAgainClicked(selectedModel, message) - }, - enabled = !uiState.inProgress + onClick = { onRunAgainClicked(selectedModel, message) }, + enabled = !uiState.inProgress, ) } @@ -437,7 +438,7 @@ fun ChatPanel( showBenchmarkConfigsDialog = true benchmarkMessage.value = message }, - enabled = !uiState.inProgress + enabled = !uiState.inProgress, ) } } @@ -453,15 +454,16 @@ fun ChatPanel( // Show an info message for ask image task to get users started. if (task.type == TaskType.LLM_ASK_IMAGE && messages.isEmpty()) { Column( - modifier = Modifier - .padding(horizontal = 16.dp) - .fillMaxSize(), + modifier = Modifier.padding(horizontal = 16.dp).fillMaxSize(), horizontalAlignment = Alignment.CenterHorizontally, - verticalArrangement = Arrangement.Center + verticalArrangement = Arrangement.Center, ) { MessageBodyInfo( - ChatMessageInfo(content = "To get started, click + below to add an image and type a prompt to ask a question about it."), - smallFontSize = false + ChatMessageInfo( + content = + "To get started, click + below to add images (up to 10 in a single session) and type a prompt to ask a question about it." + ), + smallFontSize = false, ) } } @@ -470,16 +472,18 @@ fun ChatPanel( // Chat input when (chatInputType) { ChatInputType.TEXT -> { -// val isLlmTask = task.type == TaskType.LLM_CHAT -// val notLlmStartScreen = !(messages.size == 1 && messages[0] is ChatMessagePromptTemplates) + // val isLlmTask = task.type == TaskType.LLM_CHAT + // val notLlmStartScreen = !(messages.size == 1 && messages[0] is + // ChatMessagePromptTemplates) MessageInputText( modelManagerViewModel = modelManagerViewModel, curMessage = curMessage, inProgress = uiState.inProgress, isResettingSession = uiState.isResettingSession, modelPreparing = uiState.preparing, - hasImageMessage = hasImageMessageToLastConfigChange, - modelInitializing = modelInitializationStatus?.status == ModelInitializationStatusType.INITIALIZING, + imageMessageCount = imageMessageCountToLastConfigChange, + modelInitializing = + modelInitializationStatus?.status == ModelInitializationStatusType.INITIALIZING, textFieldPlaceHolderRes = task.textInputPlaceHolderRes, onValueChanged = { curMessage = it }, onSendMessage = { @@ -488,66 +492,78 @@ fun ChatPanel( }, onOpenPromptTemplatesClicked = { onSendMessage( - selectedModel, listOf( + selectedModel, + listOf( ChatMessagePromptTemplates( - templates = selectedModel.llmPromptTemplates, showMakeYourOwn = false + templates = selectedModel.llmPromptTemplates, + showMakeYourOwn = false, ) - ) + ), ) }, onStopButtonClicked = onStopButtonClicked, -// showPromptTemplatesInMenu = isLlmTask && notLlmStartScreen, + // showPromptTemplatesInMenu = isLlmTask && notLlmStartScreen, showPromptTemplatesInMenu = false, showImagePickerInMenu = selectedModel.llmSupportImage, showStopButtonWhenInProgress = showStopButtonInInputWhenInProgress, ) } - ChatInputType.IMAGE -> MessageInputImage( - disableButtons = uiState.inProgress, - streamingMessage = streamingMessage, - onImageSelected = { bitmap -> - onSendMessage( - selectedModel, listOf( + ChatInputType.IMAGE -> + MessageInputImage( + disableButtons = uiState.inProgress, + streamingMessage = streamingMessage, + onImageSelected = { bitmap -> + onSendMessage( + selectedModel, + listOf( + ChatMessageImage( + bitmap = bitmap, + imageBitMap = bitmap.asImageBitmap(), + side = ChatSide.USER, + ) + ), + ) + }, + onStreamImage = { bitmap -> + onStreamImageMessage( + selectedModel, ChatMessageImage( - bitmap = bitmap, imageBitMap = bitmap.asImageBitmap(), side = ChatSide.USER - ) + bitmap = bitmap, + imageBitMap = bitmap.asImageBitmap(), + side = ChatSide.USER, + ), ) - ) - }, - onStreamImage = { bitmap -> - onStreamImageMessage( - selectedModel, ChatMessageImage( - bitmap = bitmap, imageBitMap = bitmap.asImageBitmap(), side = ChatSide.USER - ) - ) - }, - onStreamEnd = onStreamEnd, - ) + }, + onStreamEnd = onStreamEnd, + ) } } // Error dialog. if (showErrorDialog) { - ErrorDialog(error = modelInitializationStatus?.error ?: "", onDismiss = { - showErrorDialog = false - }) + ErrorDialog( + error = modelInitializationStatus?.error ?: "", + onDismiss = { showErrorDialog = false }, + ) } // Benchmark config dialog. if (showBenchmarkConfigsDialog) { - BenchmarkConfigDialog(onDismissed = { showBenchmarkConfigsDialog = false }, + BenchmarkConfigDialog( + onDismissed = { showBenchmarkConfigsDialog = false }, messageToBenchmark = benchmarkMessage.value, onBenchmarkClicked = { message, warmUpIterations, benchmarkIterations -> onBenchmarkClicked(selectedModel, message, warmUpIterations, benchmarkIterations) - }) + }, + ) } // Sheet to show when a message is long-pressed. if (showMessageLongPressedSheet) { val message = longPressedMessage.value if (message != null && message is ChatMessageText) { - val clipboardManager = LocalClipboardManager.current + val clipboard = LocalClipboard.current ModalBottomSheet( onDismissRequest = { showMessageLongPressedSheet = false }, @@ -555,28 +571,32 @@ fun ChatPanel( ) { Column { // Copy text. - Box(modifier = Modifier - .fillMaxWidth() - .clickable { - // Copy text. - val clipData = AnnotatedString(message.content) - clipboardManager.setText(clipData) + Box( + modifier = + Modifier.fillMaxWidth().clickable { + // Copy text. + scope.launch { + val clipData = ClipData.newPlainText("message content", message.content) + val clipEntry = ClipEntry(clipData = clipData) + clipboard.setClipEntry(clipEntry = clipEntry) + } - // Hide sheet. - showMessageLongPressedSheet = false + // Hide sheet. + showMessageLongPressedSheet = false - // Show a snack bar. - scope.launch { - snackbarHostState.showSnackbar("Text copied to clipboard") + // Show a snack bar. + scope.launch { snackbarHostState.showSnackbar("Text copied to clipboard") } } - }) { + ) { Row( verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(6.dp), - modifier = Modifier.padding(vertical = 8.dp, horizontal = 16.dp) + modifier = Modifier.padding(vertical = 8.dp, horizontal = 16.dp), ) { Icon( - Icons.Rounded.ContentCopy, contentDescription = "", modifier = Modifier.size(18.dp) + Icons.Rounded.ContentCopy, + contentDescription = "", + modifier = Modifier.size(18.dp), ) Text("Copy text") } @@ -584,25 +604,24 @@ fun ChatPanel( } } } - } } -@Preview(showBackground = true) -@Composable -fun ChatPanelPreview() { - GalleryTheme { - val context = LocalContext.current - val task = TASK_TEST1 - ChatPanel( - modelManagerViewModel = PreviewModelManagerViewModel(context = LocalContext.current), - task = task, - selectedModel = TASK_TEST1.models[1], - viewModel = PreviewChatModel(context = context), - navigateUp = {}, - onSendMessage = { _, _ -> }, - onRunAgainClicked = { _, _ -> }, - onBenchmarkClicked = { _, _, _, _ -> }, - ) - } -} +// @Preview(showBackground = true) +// @Composable +// fun ChatPanelPreview() { +// GalleryTheme { +// val context = LocalContext.current +// val task = TASK_TEST1 +// ChatPanel( +// modelManagerViewModel = PreviewModelManagerViewModel(context = LocalContext.current), +// task = task, +// selectedModel = TASK_TEST1.models[1], +// viewModel = PreviewChatModel(context = context), +// navigateUp = {}, +// onSendMessage = { _, _ -> }, +// onRunAgainClicked = { _, _ -> }, +// onBenchmarkClicked = { _, _, _, _ -> }, +// ) +// } +// } diff --git a/Android/src/app/src/main/java/com/google/ai/edge/gallery/ui/common/chat/ChatView.kt b/Android/src/app/src/main/java/com/google/ai/edge/gallery/ui/common/chat/ChatView.kt index f6c75ea..a57ae7d 100644 --- a/Android/src/app/src/main/java/com/google/ai/edge/gallery/ui/common/chat/ChatView.kt +++ b/Android/src/app/src/main/java/com/google/ai/edge/gallery/ui/common/chat/ChatView.kt @@ -16,6 +16,10 @@ package com.google.ai.edge.gallery.ui.common.chat +// import com.google.ai.edge.gallery.ui.preview.PreviewChatModel +// import com.google.ai.edge.gallery.ui.preview.PreviewModelManagerViewModel +// import com.google.ai.edge.gallery.ui.preview.TASK_TEST1 +// import com.google.ai.edge.gallery.ui.theme.GalleryTheme import android.graphics.Bitmap import android.util.Log import androidx.activity.compose.BackHandler @@ -55,7 +59,6 @@ import androidx.compose.ui.graphics.asImageBitmap import androidx.compose.ui.graphics.graphicsLayer import androidx.compose.ui.layout.ContentScale import androidx.compose.ui.platform.LocalContext -import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp import com.google.ai.edge.gallery.data.Model import com.google.ai.edge.gallery.data.ModelDownloadStatusType @@ -63,13 +66,9 @@ import com.google.ai.edge.gallery.data.Task import com.google.ai.edge.gallery.ui.common.ModelPageAppBar import com.google.ai.edge.gallery.ui.modelmanager.ModelManagerViewModel import com.google.ai.edge.gallery.ui.modelmanager.PagerScrollState -import com.google.ai.edge.gallery.ui.preview.PreviewChatModel -import com.google.ai.edge.gallery.ui.preview.PreviewModelManagerViewModel -import com.google.ai.edge.gallery.ui.preview.TASK_TEST1 -import com.google.ai.edge.gallery.ui.theme.GalleryTheme +import kotlin.math.absoluteValue import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.launch -import kotlin.math.absoluteValue private const val TAG = "AGChatView" @@ -77,8 +76,8 @@ private const val TAG = "AGChatView" * A composable that displays a chat interface, allowing users to interact with different models * associated with a given task. * - * This composable provides a horizontal pager for switching between models, a model selector - * for configuring the selected model, and a chat panel for sending and receiving messages. It also + * This composable provides a horizontal pager for switching between models, a model selector for + * configuring the selected model, and a chat panel for sending and receiving messages. It also * manages model initialization, cleanup, and download status, and handles navigation and system * back gestures. */ @@ -104,8 +103,11 @@ fun ChatView( var selectedImage by remember { mutableStateOf(null) } var showImageViewer by remember { mutableStateOf(false) } - val pagerState = rememberPagerState(initialPage = task.models.indexOf(selectedModel), - pageCount = { task.models.size }) + val pagerState = + rememberPagerState( + initialPage = task.models.indexOf(selectedModel), + pageCount = { task.models.size }, + ) val context = LocalContext.current val scope = rememberCoroutineScope() var navigatingUp by remember { mutableStateOf(false) } @@ -138,7 +140,7 @@ fun ChatView( val curSelectedModel = task.models[pagerState.settledPage] Log.d( TAG, - "Pager settled on model '${curSelectedModel.name}' from '${selectedModel.name}'. Updating selected model." + "Pager settled on model '${curSelectedModel.name}' from '${selectedModel.name}'. Updating selected model.", ) if (curSelectedModel.name != selectedModel.name) { modelManagerViewModel.cleanupModel(task = task, model = selectedModel) @@ -148,52 +150,49 @@ fun ChatView( LaunchedEffect(pagerState) { // Collect from the a snapshotFlow reading the currentPage - snapshotFlow { pagerState.currentPage }.collect { page -> - Log.d(TAG, "Page changed to $page") - } + snapshotFlow { pagerState.currentPage }.collect { page -> Log.d(TAG, "Page changed to $page") } } // Trigger scroll sync. LaunchedEffect(pagerState) { snapshotFlow { - PagerScrollState( - page = pagerState.currentPage, offset = pagerState.currentPageOffsetFraction - ) - }.collect { scrollState -> - modelManagerViewModel.pagerScrollState.value = scrollState - } + PagerScrollState( + page = pagerState.currentPage, + offset = pagerState.currentPageOffsetFraction, + ) + } + .collect { scrollState -> modelManagerViewModel.pagerScrollState.value = scrollState } } // Handle system's edge swipe. - BackHandler { - handleNavigateUp() - } + BackHandler { handleNavigateUp() } - Scaffold(modifier = modifier, topBar = { - ModelPageAppBar( - task = task, - model = selectedModel, - modelManagerViewModel = modelManagerViewModel, - canShowResetSessionButton = true, - isResettingSession = uiState.isResettingSession, - inProgress = uiState.inProgress, - modelPreparing = uiState.preparing, - onResetSessionClicked = onResetSessionClicked, - onConfigChanged = { old, new -> - viewModel.addConfigChangedMessage( - oldConfigValues = old, newConfigValues = new, model = selectedModel - ) - }, - onBackClicked = { - handleNavigateUp() - }, - onModelSelected = { model -> - scope.launch { - pagerState.animateScrollToPage(task.models.indexOf(model)) - } - }, - ) - }) { innerPadding -> + Scaffold( + modifier = modifier, + topBar = { + ModelPageAppBar( + task = task, + model = selectedModel, + modelManagerViewModel = modelManagerViewModel, + canShowResetSessionButton = true, + isResettingSession = uiState.isResettingSession, + inProgress = uiState.inProgress, + modelPreparing = uiState.preparing, + onResetSessionClicked = onResetSessionClicked, + onConfigChanged = { old, new -> + viewModel.addConfigChangedMessage( + oldConfigValues = old, + newConfigValues = new, + model = selectedModel, + ) + }, + onBackClicked = { handleNavigateUp() }, + onModelSelected = { model -> + scope.launch { pagerState.animateScrollToPage(task.models.indexOf(model)) } + }, + ) + }, + ) { innerPadding -> Box { // A horizontal scrollable pager to switch between models. HorizontalPager(state = pagerState) { pageIndex -> @@ -202,17 +201,20 @@ fun ChatView( // Calculate the alpha of the current page based on how far they are from the center. val pageOffset = - ((pagerState.currentPage - pageIndex) + pagerState.currentPageOffsetFraction).absoluteValue + ((pagerState.currentPage - pageIndex) + pagerState.currentPageOffsetFraction) + .absoluteValue val curAlpha = 1f - pageOffset.coerceIn(0f, 1f) Column( - modifier = Modifier - .padding(innerPadding) - .fillMaxSize() - .background(MaterialTheme.colorScheme.surface) + modifier = + Modifier.padding(innerPadding) + .fillMaxSize() + .background(MaterialTheme.colorScheme.surface) ) { ModelDownloadStatusInfoPanel( - model = curSelectedModel, task = task, modelManagerViewModel = modelManagerViewModel + model = curSelectedModel, + task = task, + modelManagerViewModel = modelManagerViewModel, ) // The main messages panel. @@ -230,19 +232,16 @@ fun ChatView( onStreamEnd = { averageFps -> viewModel.addMessage( model = curSelectedModel, - message = ChatMessageInfo(content = "Live camera session ended. Average FPS: $averageFps") + message = + ChatMessageInfo(content = "Live camera session ended. Average FPS: $averageFps"), ) }, - onStopButtonClicked = { - onStopButtonClicked(curSelectedModel) - }, + onStopButtonClicked = { onStopButtonClicked(curSelectedModel) }, onImageSelected = { bitmap -> selectedImage = bitmap showImageViewer = true }, - modifier = Modifier - .weight(1f) - .graphicsLayer { alpha = curAlpha }, + modifier = Modifier.weight(1f).graphicsLayer { alpha = curAlpha }, chatInputType = chatInputType, showStopButtonInInputWhenInProgress = showStopButtonInInputWhenInProgress, ) @@ -254,39 +253,43 @@ fun ChatView( AnimatedVisibility( visible = showImageViewer, enter = slideInVertically(initialOffsetY = { fullHeight -> fullHeight }) + fadeIn(), - exit = slideOutVertically( - targetOffsetY = { fullHeight -> fullHeight }, - ) + fadeOut() + exit = slideOutVertically(targetOffsetY = { fullHeight -> fullHeight }) + fadeOut(), ) { selectedImage?.let { image -> ZoomableBox( - modifier = Modifier - .fillMaxSize() - .padding(top = innerPadding.calculateTopPadding()) - .background(Color.Black.copy(alpha = 0.95f)), + modifier = + Modifier.fillMaxSize() + .padding(top = innerPadding.calculateTopPadding()) + .background(Color.Black.copy(alpha = 0.95f)) ) { Image( - bitmap = image.asImageBitmap(), contentDescription = "", - modifier = modifier - .fillMaxSize() - .graphicsLayer( - scaleX = scale, scaleY = scale, translationX = offsetX, translationY = offsetY - ), + bitmap = image.asImageBitmap(), + contentDescription = "", + modifier = + modifier + .fillMaxSize() + .graphicsLayer( + scaleX = scale, + scaleY = scale, + translationX = offsetX, + translationY = offsetY, + ), contentScale = ContentScale.Fit, ) // Close button. IconButton( - onClick = { - showImageViewer = false - }, colors = IconButtonDefaults.iconButtonColors( - containerColor = MaterialTheme.colorScheme.surfaceVariant, - ), modifier = Modifier.offset(x = (-8).dp, y = 8.dp) + onClick = { showImageViewer = false }, + colors = + IconButtonDefaults.iconButtonColors( + containerColor = MaterialTheme.colorScheme.surfaceVariant + ), + modifier = Modifier.offset(x = (-8).dp, y = 8.dp), ) { Icon( Icons.Rounded.Close, contentDescription = "", - tint = MaterialTheme.colorScheme.primary + tint = MaterialTheme.colorScheme.primary, ) } } @@ -296,20 +299,20 @@ fun ChatView( } } -@Preview -@Composable -fun ChatScreenPreview() { - GalleryTheme { - val context = LocalContext.current - val task = TASK_TEST1 - ChatView( - task = task, - viewModel = PreviewChatModel(context = context), - modelManagerViewModel = PreviewModelManagerViewModel(context = context), - onSendMessage = { _, _ -> }, - onRunAgainClicked = { _, _ -> }, - onBenchmarkClicked = { _, _, _, _ -> }, - navigateUp = {}, - ) - } -} +// @Preview +// @Composable +// fun ChatScreenPreview() { +// GalleryTheme { +// val context = LocalContext.current +// val task = TASK_TEST1 +// ChatView( +// task = task, +// viewModel = PreviewChatModel(context = context), +// modelManagerViewModel = PreviewModelManagerViewModel(context = context), +// onSendMessage = { _, _ -> }, +// onRunAgainClicked = { _, _ -> }, +// onBenchmarkClicked = { _, _, _, _ -> }, +// navigateUp = {}, +// ) +// } +// } diff --git a/Android/src/app/src/main/java/com/google/ai/edge/gallery/ui/common/chat/ChatViewModel.kt b/Android/src/app/src/main/java/com/google/ai/edge/gallery/ui/common/chat/ChatViewModel.kt index 7efdeef..95785aa 100644 --- a/Android/src/app/src/main/java/com/google/ai/edge/gallery/ui/common/chat/ChatViewModel.kt +++ b/Android/src/app/src/main/java/com/google/ai/edge/gallery/ui/common/chat/ChatViewModel.kt @@ -18,9 +18,9 @@ package com.google.ai.edge.gallery.ui.common.chat import android.util.Log import androidx.lifecycle.ViewModel +import com.google.ai.edge.gallery.common.processLlmResponse import com.google.ai.edge.gallery.data.Model import com.google.ai.edge.gallery.data.Task -import com.google.ai.edge.gallery.ui.common.processLlmResponse import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.update @@ -28,14 +28,10 @@ import kotlinx.coroutines.flow.update private const val TAG = "AGChatViewModel" data class ChatUiState( - /** - * Indicates whether the runtime is currently processing a message. - */ + /** Indicates whether the runtime is currently processing a message. */ val inProgress: Boolean = false, - /** - * Indicates whether the session is being reset. - */ + /** Indicates whether the session is being reset. */ val isResettingSession: Boolean = false, /** @@ -43,14 +39,10 @@ data class ChatUiState( */ val preparing: Boolean = false, - /** - * A map of model names to lists of chat messages. - */ + /** A map of model names to lists of chat messages. */ val messagesByModel: Map> = mapOf(), - /** - * A map of model names to the currently streaming chat message. - */ + /** A map of model names to the currently streaming chat message. */ val streamingMessagesByModel: Map = mapOf(), /* @@ -60,9 +52,7 @@ data class ChatUiState( val showingStatsByModel: Map> = mapOf(), ) -/** - * ViewModel responsible for managing the chat UI state and handling chat-related operations. - */ +/** ViewModel responsible for managing the chat UI state and handling chat-related operations. */ open class ChatViewModel(val task: Task) : ViewModel() { private val _uiState = MutableStateFlow(createUiState(task = task)) val uiState = _uiState.asStateFlow() @@ -137,12 +127,13 @@ open class ChatViewModel(val task: Task) : ViewModel() { val lastMessage = newMessages.last() if (lastMessage is ChatMessageText) { val newContent = processLlmResponse(response = "${lastMessage.content}${partialContent}") - val newLastMessage = ChatMessageText( - content = newContent, - side = lastMessage.side, - latencyMs = latencyMs, - accelerator = lastMessage.accelerator, - ) + val newLastMessage = + ChatMessageText( + content = newContent, + side = lastMessage.side, + latencyMs = latencyMs, + accelerator = lastMessage.accelerator, + ) newMessages.removeAt(newMessages.size - 1) newMessages.add(newLastMessage) } @@ -154,7 +145,7 @@ open class ChatViewModel(val task: Task) : ViewModel() { fun updateLastTextMessageLlmBenchmarkResult( model: Model, - llmBenchmarkResult: ChatMessageBenchmarkLlmResult + llmBenchmarkResult: ChatMessageBenchmarkLlmResult, ) { val newMessagesByModel = _uiState.value.messagesByModel.toMutableMap() val newMessages = newMessagesByModel[model.name]?.toMutableList() ?: mutableListOf() @@ -215,12 +206,17 @@ open class ChatViewModel(val task: Task) : ViewModel() { } fun addConfigChangedMessage( - oldConfigValues: Map, newConfigValues: Map, model: Model + oldConfigValues: Map, + newConfigValues: Map, + model: Model, ) { Log.d(TAG, "Adding config changed message. Old: ${oldConfigValues}, new: $newConfigValues") - val message = ChatMessageConfigValuesChange( - model = model, oldValues = oldConfigValues, newValues = newConfigValues - ) + val message = + ChatMessageConfigValuesChange( + model = model, + oldValues = oldConfigValues, + newValues = newConfigValues, + ) addMessage(message = message, model = model) } @@ -253,8 +249,6 @@ open class ChatViewModel(val task: Task) : ViewModel() { } messagesByModel[model.name] = messages } - return ChatUiState( - messagesByModel = messagesByModel - ) + return ChatUiState(messagesByModel = messagesByModel) } } diff --git a/Android/src/app/src/main/java/com/google/ai/edge/gallery/ui/common/chat/DataCard.kt b/Android/src/app/src/main/java/com/google/ai/edge/gallery/ui/common/chat/DataCard.kt index bf1163f..c37a0b3 100644 --- a/Android/src/app/src/main/java/com/google/ai/edge/gallery/ui/common/chat/DataCard.kt +++ b/Android/src/app/src/main/java/com/google/ai/edge/gallery/ui/common/chat/DataCard.kt @@ -37,9 +37,8 @@ import com.google.ai.edge.gallery.ui.theme.labelSmallNarrowMedium /** * Composable function to display a data card with a label and a numeric value. * - * This function renders a column containing a label and a formatted numeric value. - * It provides options for highlighting the value and displaying a placeholder when the value is not - * available. + * This function renders a column containing a label and a formatted numeric value. It provides + * options for highlighting the value and displaying a placeholder when the value is not available. */ @Composable fun DataCard( @@ -47,7 +46,7 @@ fun DataCard( value: Float?, unit: String, highlight: Boolean = false, - showPlaceholder: Boolean = false + showPlaceholder: Boolean = false, ) { var strValue = "-" Column { @@ -57,19 +56,13 @@ fun DataCard( } else { strValue = if (value == null) "-" else "%.2f".format(value) if (highlight) { - Text( - strValue, style = bodySmallMediumNarrowBold, color = MaterialTheme.colorScheme.primary - ) + Text(strValue, style = bodySmallMediumNarrowBold, color = MaterialTheme.colorScheme.primary) } else { Text(strValue, style = bodySmallMediumNarrow) } } if (strValue != "-") { - Text( - unit, style = labelSmallNarrow, modifier = Modifier - .alpha(0.5f) - .offset(y = (-1).dp) - ) + Text(unit, style = labelSmallNarrow, modifier = Modifier.alpha(0.5f).offset(y = (-1).dp)) } } } @@ -80,14 +73,26 @@ fun DataCardPreview() { GalleryTheme { Row(modifier = Modifier.padding(16.dp), horizontalArrangement = Arrangement.spacedBy(16.dp)) { DataCard( - label = "sum", value = 123.45f, unit = "ms", highlight = true, showPlaceholder = false + label = "sum", + value = 123.45f, + unit = "ms", + highlight = true, + showPlaceholder = false, ) DataCard( - label = "average", value = 12.3f, unit = "ms", highlight = false, showPlaceholder = false + label = "average", + value = 12.3f, + unit = "ms", + highlight = false, + showPlaceholder = false, ) DataCard( - label = "test", value = null, unit = "ms", highlight = false, showPlaceholder = false + label = "test", + value = null, + unit = "ms", + highlight = false, + showPlaceholder = false, ) } } -} \ No newline at end of file +} diff --git a/Android/src/app/src/main/java/com/google/ai/edge/gallery/ui/common/chat/LiveCameraDialog.kt b/Android/src/app/src/main/java/com/google/ai/edge/gallery/ui/common/chat/LiveCameraDialog.kt deleted file mode 100644 index 3d31836..0000000 --- a/Android/src/app/src/main/java/com/google/ai/edge/gallery/ui/common/chat/LiveCameraDialog.kt +++ /dev/null @@ -1,226 +0,0 @@ -/* - * Copyright 2025 Google LLC - * - * 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 - * - * http://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.google.ai.edge.gallery.ui.common.chat - -import android.graphics.Bitmap -import android.graphics.Matrix -import android.util.Size -import androidx.camera.core.CameraSelector -import androidx.camera.core.ImageAnalysis -import androidx.camera.core.resolutionselector.ResolutionSelector -import androidx.camera.core.resolutionselector.ResolutionStrategy -import androidx.camera.lifecycle.ProcessCameraProvider -import androidx.compose.foundation.Image -import androidx.compose.foundation.layout.Arrangement -import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.Row -import androidx.compose.foundation.layout.aspectRatio -import androidx.compose.foundation.layout.fillMaxHeight -import androidx.compose.foundation.layout.fillMaxWidth -import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.shape.RoundedCornerShape -import androidx.compose.material3.Card -import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.Text -import androidx.compose.material3.TextButton -import androidx.compose.runtime.Composable -import androidx.compose.runtime.LaunchedEffect -import androidx.compose.runtime.getValue -import androidx.compose.runtime.mutableLongStateOf -import androidx.compose.runtime.mutableStateOf -import androidx.compose.runtime.remember -import androidx.compose.runtime.setValue -import androidx.compose.ui.Alignment -import androidx.compose.ui.Modifier -import androidx.compose.ui.draw.clip -import androidx.compose.ui.graphics.ImageBitmap -import androidx.compose.ui.graphics.asImageBitmap -import androidx.compose.ui.layout.ContentScale -import androidx.compose.ui.platform.LocalContext -import androidx.compose.ui.unit.dp -import androidx.compose.ui.window.Dialog -import androidx.core.content.ContextCompat -import androidx.lifecycle.compose.LocalLifecycleOwner -import java.util.concurrent.Executors -import kotlin.coroutines.resume -import kotlin.coroutines.suspendCoroutine - -/** - * Composable function to display a live camera feed in a dialog. - * - * This function renders a dialog that displays a live camera preview, along with optional - * classification results and FPS information. It manages camera initialization, frame capture, - * and dialog dismissal. - */ -@Composable -fun LiveCameraDialog( - onDismissed: (averageFps: Int) -> Unit, - onBitmap: (Bitmap) -> Unit, - streamingMessage: ChatMessage? = null, -) { - val context = LocalContext.current - val lifecycleOwner = LocalLifecycleOwner.current - var imageBitmap by remember { mutableStateOf(null) } - var cameraProvider: ProcessCameraProvider? by remember { mutableStateOf(null) } - var sumFps by remember { mutableLongStateOf(0L) } - var fpsCount by remember { mutableLongStateOf(0L) } - - LaunchedEffect(key1 = true) { - cameraProvider = startCamera( - context, - lifecycleOwner, - onBitmap = onBitmap, - onImageBitmap = { b -> imageBitmap = b }) - } - - Dialog(onDismissRequest = { - cameraProvider?.unbindAll() - onDismissed((sumFps / fpsCount).toInt()) - }) { - Card(modifier = Modifier.fillMaxWidth(), shape = RoundedCornerShape(16.dp)) { - Column( - modifier = Modifier.padding(20.dp), verticalArrangement = Arrangement.spacedBy(16.dp) - ) { - // Title - Row( - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.SpaceBetween, - modifier = Modifier - .fillMaxWidth() - .padding(bottom = 8.dp) - ) { - Text( - "Live camera", - style = MaterialTheme.typography.titleLarge, - ) - if (streamingMessage != null) { - val fps = (1000f / streamingMessage.latencyMs).toInt() - sumFps += fps.toLong() - fpsCount += 1 - - Text( - "%d FPS".format(fps), - style = MaterialTheme.typography.titleLarge, - ) - } - } - - // Camera live view. - Row( - modifier = Modifier - .fillMaxWidth() - .aspectRatio(1f), - horizontalArrangement = Arrangement.Center - ) { - val ib = imageBitmap - if (ib != null) { - Image( - bitmap = ib, - contentDescription = "", - modifier = Modifier - .fillMaxHeight() - .clip(RoundedCornerShape(8.dp)), - contentScale = ContentScale.Inside - ) - } - } - - // Result. - if (streamingMessage != null && streamingMessage is ChatMessageClassification) { - MessageBodyClassification( - message = streamingMessage, - modifier = Modifier.fillMaxWidth(), - oneLineLabel = true - ) - } - - // Button. - Row( - modifier = Modifier - .fillMaxWidth() - .padding(top = 8.dp), - horizontalArrangement = Arrangement.End, - ) { - TextButton( - onClick = { - cameraProvider?.unbindAll() - onDismissed((sumFps / fpsCount).toInt()) - }, - ) { - Text("OK") - } - } - } - } - } -} - -/** - * Asynchronously initializes and starts the camera for image capture and analysis. - * - * This function sets up the camera using CameraX, configures image analysis, and binds - * the camera lifecycle to the provided LifecycleOwner. It captures frames from the camera, - * converts them to Bitmaps and ImageBitmaps, and invokes the provided callbacks. - */ -private suspend fun startCamera( - context: android.content.Context, - lifecycleOwner: androidx.lifecycle.LifecycleOwner, - onBitmap: (Bitmap) -> Unit, - onImageBitmap: (ImageBitmap) -> Unit -): ProcessCameraProvider? = suspendCoroutine { continuation -> - val cameraProviderFuture = ProcessCameraProvider.getInstance(context) - - cameraProviderFuture.addListener({ - val cameraProvider = cameraProviderFuture.get() - - val resolutionSelector = ResolutionSelector.Builder().setResolutionStrategy( - ResolutionStrategy( - Size(1080, 1080), - ResolutionStrategy.FALLBACK_RULE_CLOSEST_LOWER_THEN_HIGHER - ) - ).build() - val imageAnalysis = - ImageAnalysis.Builder().setResolutionSelector(resolutionSelector) - .setBackpressureStrategy(ImageAnalysis.STRATEGY_KEEP_ONLY_LATEST).build().also { - it.setAnalyzer(Executors.newSingleThreadExecutor()) { imageProxy -> - var bitmap = imageProxy.toBitmap() - val rotation = imageProxy.imageInfo.rotationDegrees - bitmap = if (rotation != 0) { - val matrix = Matrix().apply { - postRotate(rotation.toFloat()) - } - Bitmap.createBitmap(bitmap, 0, 0, bitmap.width, bitmap.height, matrix, true) - } else bitmap - onBitmap(bitmap) - onImageBitmap(bitmap.asImageBitmap()) - imageProxy.close() - } - } - - val cameraSelector = CameraSelector.DEFAULT_BACK_CAMERA - try { - cameraProvider?.unbindAll() - cameraProvider?.bindToLifecycle( - lifecycleOwner, cameraSelector, imageAnalysis - ) - // Resume with the provider - continuation.resume(cameraProvider) - } catch (exc: Exception) { - // todo: Handle exceptions (e.g., camera initialization failure) - } - }, ContextCompat.getMainExecutor(context)) -} diff --git a/Android/src/app/src/main/java/com/google/ai/edge/gallery/ui/common/chat/MessageActionButton.kt b/Android/src/app/src/main/java/com/google/ai/edge/gallery/ui/common/chat/MessageActionButton.kt index 1bf59fc..3a5327f 100644 --- a/Android/src/app/src/main/java/com/google/ai/edge/gallery/ui/common/chat/MessageActionButton.kt +++ b/Android/src/app/src/main/java/com/google/ai/edge/gallery/ui/common/chat/MessageActionButton.kt @@ -16,16 +16,15 @@ package com.google.ai.edge.gallery.ui.common.chat +// import androidx.compose.ui.tooling.preview.Preview +// import com.google.ai.edge.gallery.ui.theme.GalleryTheme import androidx.compose.foundation.background import androidx.compose.foundation.clickable -import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.offset import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size import androidx.compose.foundation.shape.CircleShape -import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.filled.PlayArrow import androidx.compose.material3.Icon import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Text @@ -35,62 +34,56 @@ import androidx.compose.ui.Modifier import androidx.compose.ui.draw.alpha import androidx.compose.ui.draw.clip import androidx.compose.ui.graphics.vector.ImageVector -import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp -import com.google.ai.edge.gallery.ui.theme.GalleryTheme import com.google.ai.edge.gallery.ui.theme.bodySmallNarrow -/** - * Composable function to display an action button below a chat message. - */ +/** Composable function to display an action button below a chat message. */ @Composable fun MessageActionButton( label: String, icon: ImageVector, onClick: () -> Unit, modifier: Modifier = Modifier, - enabled: Boolean = true + enabled: Boolean = true, ) { - val curModifier = modifier - .padding(top = 4.dp) - .clip(CircleShape) - .background(if (enabled) MaterialTheme.colorScheme.secondaryContainer else MaterialTheme.colorScheme.surfaceContainerHigh) + val curModifier = + modifier + .padding(top = 4.dp) + .clip(CircleShape) + .background( + if (enabled) MaterialTheme.colorScheme.secondaryContainer + else MaterialTheme.colorScheme.surfaceContainerHigh + ) val alpha: Float = if (enabled) 1.0f else 0.3f Row( modifier = if (enabled) curModifier.clickable { onClick() } else modifier, verticalAlignment = Alignment.CenterVertically, ) { Icon( - icon, contentDescription = "", modifier = Modifier - .size(16.dp) - .offset(x = 6.dp) - .alpha(alpha) + icon, + contentDescription = "", + modifier = Modifier.size(16.dp).offset(x = 6.dp).alpha(alpha), ) Text( label, color = MaterialTheme.colorScheme.onSecondaryContainer, style = bodySmallNarrow, - modifier = Modifier - .padding( - start = 10.dp, end = 8.dp, top = 4.dp, bottom = 4.dp - ) - .alpha(alpha) + modifier = Modifier.padding(start = 10.dp, end = 8.dp, top = 4.dp, bottom = 4.dp).alpha(alpha), ) } } -@Preview(showBackground = true) -@Composable -fun MessageActionButtonPreview() { - GalleryTheme { - Column { - MessageActionButton(label = "run", icon = Icons.Default.PlayArrow, onClick = {}) - MessageActionButton( - label = "run", - icon = Icons.Default.PlayArrow, - enabled = false, - onClick = {}) - } - } -} - +// @Preview(showBackground = true) +// @Composable +// fun MessageActionButtonPreview() { +// GalleryTheme { +// Column { +// MessageActionButton(label = "run", icon = Icons.Default.PlayArrow, onClick = {}) +// MessageActionButton( +// label = "run", +// icon = Icons.Default.PlayArrow, +// enabled = false, +// onClick = {}) +// } +// } +// } diff --git a/Android/src/app/src/main/java/com/google/ai/edge/gallery/ui/common/chat/MessageBodyBenchmark.kt b/Android/src/app/src/main/java/com/google/ai/edge/gallery/ui/common/chat/MessageBodyBenchmark.kt index 7c8f568..b6f1775 100644 --- a/Android/src/app/src/main/java/com/google/ai/edge/gallery/ui/common/chat/MessageBodyBenchmark.kt +++ b/Android/src/app/src/main/java/com/google/ai/edge/gallery/ui/common/chat/MessageBodyBenchmark.kt @@ -16,6 +16,8 @@ package com.google.ai.edge.gallery.ui.common.chat +// import androidx.compose.ui.tooling.preview.Preview +// import com.google.ai.edge.gallery.ui.theme.GalleryTheme import androidx.compose.foundation.background import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box @@ -31,9 +33,7 @@ import androidx.compose.runtime.Composable import androidx.compose.ui.Modifier import androidx.compose.ui.draw.alpha import androidx.compose.ui.draw.clip -import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp -import com.google.ai.edge.gallery.ui.theme.GalleryTheme import kotlin.math.max private const val DEFAULT_HISTOGRAM_BAR_HEIGHT = 50f @@ -41,37 +41,31 @@ private const val DEFAULT_HISTOGRAM_BAR_HEIGHT = 50f /** * Composable function to display benchmark results within a chat message. * - * This function renders benchmark statistics (e.g., average latency) in data cards and - * visualizes the latency distribution using a histogram. + * This function renders benchmark statistics (e.g., average latency) in data cards and visualizes + * the latency distribution using a histogram. */ @Composable fun MessageBodyBenchmark(message: ChatMessageBenchmarkResult) { Column( - modifier = Modifier - .padding(12.dp) - .fillMaxWidth(), - verticalArrangement = Arrangement.spacedBy(8.dp) + modifier = Modifier.padding(12.dp).fillMaxWidth(), + verticalArrangement = Arrangement.spacedBy(8.dp), ) { // Data cards. - Row( - modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.SpaceBetween - ) { + Row(modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.SpaceBetween) { for (stat in message.orderedStats) { DataCard( label = stat.label, unit = stat.unit, value = message.statValues[stat.id], highlight = stat.id == message.highlightStat, - showPlaceholder = message.isWarmingUp() + showPlaceholder = message.isWarmingUp(), ) } } // Histogram if (message.histogram.buckets.isNotEmpty()) { - Row( - horizontalArrangement = Arrangement.spacedBy(2.dp) - ) { + Row(horizontalArrangement = Arrangement.spacedBy(2.dp)) { for ((index, count) in message.histogram.buckets.withIndex()) { var barBgColor = MaterialTheme.colorScheme.onSurfaceVariant var alpha = 0.3f @@ -84,24 +78,24 @@ fun MessageBodyBenchmark(message: ChatMessageBenchmarkResult) { } // Bar container. Column( - modifier = Modifier - .height(DEFAULT_HISTOGRAM_BAR_HEIGHT.dp) - .width(4.dp), + modifier = Modifier.height(DEFAULT_HISTOGRAM_BAR_HEIGHT.dp).width(4.dp), verticalArrangement = Arrangement.Bottom, ) { // Bar content. Box( - modifier = Modifier - .height( - max( - 1f, - count.toFloat() / message.histogram.maxCount.toFloat() * DEFAULT_HISTOGRAM_BAR_HEIGHT - ).dp - ) - .fillMaxWidth() - .clip(RoundedCornerShape(20.dp, 20.dp, 0.dp, 0.dp)) - .alpha(alpha) - .background(barBgColor) + modifier = + Modifier.height( + max( + 1f, + count.toFloat() / message.histogram.maxCount.toFloat() * + DEFAULT_HISTOGRAM_BAR_HEIGHT, + ) + .dp + ) + .fillMaxWidth() + .clip(RoundedCornerShape(20.dp, 20.dp, 0.dp, 0.dp)) + .alpha(alpha) + .background(barBgColor) ) } } @@ -110,31 +104,31 @@ fun MessageBodyBenchmark(message: ChatMessageBenchmarkResult) { } } -@Preview(showBackground = true) -@Composable -fun MessageBodyBenchmarkPreview() { - GalleryTheme { - MessageBodyBenchmark( - message = ChatMessageBenchmarkResult( - orderedStats = listOf( - Stat(id = "stat1", label = "Stat1", unit = "ms"), - Stat(id = "stat2", label = "Stat2", unit = "ms"), - Stat(id = "stat3", label = "Stat3", unit = "ms"), - Stat(id = "stat4", label = "Stat4", unit = "ms") - ), - statValues = mutableMapOf( - "stat1" to 0.3f, - "stat2" to 0.4f, - "stat3" to 0.5f, - ), - values = listOf(), - histogram = Histogram(listOf(), 0), - warmupCurrent = 0, - warmupTotal = 0, - iterationCurrent = 0, - iterationTotal = 0, - highlightStat = "stat2" - ) - ) - } -} \ No newline at end of file +// @Preview(showBackground = true) +// @Composable +// fun MessageBodyBenchmarkPreview() { +// GalleryTheme { +// MessageBodyBenchmark( +// message = ChatMessageBenchmarkResult( +// orderedStats = listOf( +// Stat(id = "stat1", label = "Stat1", unit = "ms"), +// Stat(id = "stat2", label = "Stat2", unit = "ms"), +// Stat(id = "stat3", label = "Stat3", unit = "ms"), +// Stat(id = "stat4", label = "Stat4", unit = "ms") +// ), +// statValues = mutableMapOf( +// "stat1" to 0.3f, +// "stat2" to 0.4f, +// "stat3" to 0.5f, +// ), +// values = listOf(), +// histogram = Histogram(listOf(), 0), +// warmupCurrent = 0, +// warmupTotal = 0, +// iterationCurrent = 0, +// iterationTotal = 0, +// highlightStat = "stat2" +// ) +// ) +// } +// } diff --git a/Android/src/app/src/main/java/com/google/ai/edge/gallery/ui/common/chat/MessageBodyBenchmarkLlm.kt b/Android/src/app/src/main/java/com/google/ai/edge/gallery/ui/common/chat/MessageBodyBenchmarkLlm.kt index b26b2cd..961c8f2 100644 --- a/Android/src/app/src/main/java/com/google/ai/edge/gallery/ui/common/chat/MessageBodyBenchmarkLlm.kt +++ b/Android/src/app/src/main/java/com/google/ai/edge/gallery/ui/common/chat/MessageBodyBenchmarkLlm.kt @@ -16,6 +16,8 @@ package com.google.ai.edge.gallery.ui.common.chat +// import androidx.compose.ui.tooling.preview.Preview +// import com.google.ai.edge.gallery.ui.theme.GalleryTheme import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row @@ -23,9 +25,7 @@ import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.padding import androidx.compose.runtime.Composable import androidx.compose.ui.Modifier -import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp -import com.google.ai.edge.gallery.ui.theme.GalleryTheme /** * Composable function to display benchmark LLM results within a chat message. @@ -34,41 +34,32 @@ import com.google.ai.edge.gallery.ui.theme.GalleryTheme */ @Composable fun MessageBodyBenchmarkLlm(message: ChatMessageBenchmarkLlmResult, modifier: Modifier = Modifier) { - Column( - modifier = modifier.padding(12.dp), - verticalArrangement = Arrangement.spacedBy(8.dp) - ) { + Column(modifier = modifier.padding(12.dp), verticalArrangement = Arrangement.spacedBy(8.dp)) { // Data cards. - Row( - modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.SpaceBetween - ) { + Row(modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.SpaceBetween) { for (stat in message.orderedStats) { - DataCard( - label = stat.label, - unit = stat.unit, - value = message.statValues[stat.id], - ) + DataCard(label = stat.label, unit = stat.unit, value = message.statValues[stat.id]) } } } } -@Preview(showBackground = true) -@Composable -fun MessageBodyBenchmarkLlmPreview() { - GalleryTheme { - MessageBodyBenchmarkLlm( - message = ChatMessageBenchmarkLlmResult( - orderedStats = listOf( - Stat(id = "stat1", label = "Stat1", unit = "tokens/s"), - Stat(id = "stat2", label = "Stat2", unit = "tokens/s") - ), - statValues = mutableMapOf( - "stat1" to 0.3f, - "stat2" to 0.4f, - ), - running = false, - ) - ) - } -} +// @Preview(showBackground = true) +// @Composable +// fun MessageBodyBenchmarkLlmPreview() { +// GalleryTheme { +// MessageBodyBenchmarkLlm( +// message = ChatMessageBenchmarkLlmResult( +// orderedStats = listOf( +// Stat(id = "stat1", label = "Stat1", unit = "tokens/s"), +// Stat(id = "stat2", label = "Stat2", unit = "tokens/s") +// ), +// statValues = mutableMapOf( +// "stat1" to 0.3f, +// "stat2" to 0.4f, +// ), +// running = false, +// ) +// ) +// } +// } diff --git a/Android/src/app/src/main/java/com/google/ai/edge/gallery/ui/common/chat/MessageBodyClassification.kt b/Android/src/app/src/main/java/com/google/ai/edge/gallery/ui/common/chat/MessageBodyClassification.kt index 52999fc..d333f70 100644 --- a/Android/src/app/src/main/java/com/google/ai/edge/gallery/ui/common/chat/MessageBodyClassification.kt +++ b/Android/src/app/src/main/java/com/google/ai/edge/gallery/ui/common/chat/MessageBodyClassification.kt @@ -16,6 +16,8 @@ package com.google.ai.edge.gallery.ui.common.chat +// import androidx.compose.ui.tooling.preview.Preview +// import com.google.ai.edge.gallery.ui.theme.GalleryTheme import androidx.compose.foundation.background import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box @@ -32,11 +34,8 @@ 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.graphics.Color import androidx.compose.ui.text.style.TextOverflow -import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp -import com.google.ai.edge.gallery.ui.theme.GalleryTheme val CLASSIFICATION_BAR_HEIGHT = 8.dp val CLASSIFICATION_BAR_MAX_WIDTH = 200.dp @@ -44,7 +43,8 @@ val CLASSIFICATION_BAR_MAX_WIDTH = 200.dp /** * Composable function to display classification results. * - * This function renders a list of classifications, each with its label, score, and a visual score bar. + * This function renders a list of classifications, each with its label, score, and a visual score + * bar. */ @Composable fun MessageBodyClassification( @@ -52,45 +52,40 @@ fun MessageBodyClassification( modifier: Modifier = Modifier, oneLineLabel: Boolean = false, ) { - Column( - modifier = modifier.padding(12.dp) - ) { + Column(modifier = modifier.padding(12.dp)) { for (classification in message.classifications) { - Row( - modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.SpaceBetween - ) { + Row(modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.SpaceBetween) { // Classification label. Text( classification.label, maxLines = if (oneLineLabel) 1 else Int.MAX_VALUE, overflow = TextOverflow.Ellipsis, style = MaterialTheme.typography.bodySmall, - modifier = Modifier.weight(1f) + modifier = Modifier.weight(1f), ) // Classification score. Text( "%.2f".format(classification.score), style = MaterialTheme.typography.bodySmall, - modifier = Modifier - .align(Alignment.Bottom), + modifier = Modifier.align(Alignment.Bottom), ) } Spacer(modifier = Modifier.height(2.dp)) // Score bar. Box { Box( - modifier = Modifier - .fillMaxWidth() - .height(CLASSIFICATION_BAR_HEIGHT) - .clip(CircleShape) - .background(MaterialTheme.colorScheme.surfaceDim) + modifier = + Modifier.fillMaxWidth() + .height(CLASSIFICATION_BAR_HEIGHT) + .clip(CircleShape) + .background(MaterialTheme.colorScheme.surfaceDim) ) Box( - modifier = Modifier - .fillMaxWidth(classification.score) - .height(CLASSIFICATION_BAR_HEIGHT) - .clip(CircleShape) - .background(classification.color) + modifier = + Modifier.fillMaxWidth(classification.score) + .height(CLASSIFICATION_BAR_HEIGHT) + .clip(CircleShape) + .background(classification.color) ) } Spacer(modifier = Modifier.height(6.dp)) @@ -98,18 +93,20 @@ fun MessageBodyClassification( } } -@Preview(showBackground = true) -@Composable -fun MessageBodyClassificationPreview() { - GalleryTheme { - MessageBodyClassification( - message = ChatMessageClassification( - classifications = listOf( - Classification(label = "label1", score = 0.3f, color = Color.Red), - Classification(label = "label2", score = 0.7f, color = Color.Blue) - ), - latencyMs = 12345f, - ), - ) - } -} \ No newline at end of file +// @Preview(showBackground = true) +// @Composable +// fun MessageBodyClassificationPreview() { +// GalleryTheme { +// MessageBodyClassification( +// message = +// ChatMessageClassification( +// classifications = +// listOf( +// Classification(label = "label1", score = 0.3f, color = Color.Red), +// Classification(label = "label2", score = 0.7f, color = Color.Blue), +// ), +// latencyMs = 12345f, +// ) +// ) +// } +// } diff --git a/Android/src/app/src/main/java/com/google/ai/edge/gallery/ui/common/chat/MessageBodyConfigUpdate.kt b/Android/src/app/src/main/java/com/google/ai/edge/gallery/ui/common/chat/MessageBodyConfigUpdate.kt index c51b15d..62b2e6d 100644 --- a/Android/src/app/src/main/java/com/google/ai/edge/gallery/ui/common/chat/MessageBodyConfigUpdate.kt +++ b/Android/src/app/src/main/java/com/google/ai/edge/gallery/ui/common/chat/MessageBodyConfigUpdate.kt @@ -16,6 +16,9 @@ package com.google.ai.edge.gallery.ui.common.chat +// import androidx.compose.ui.tooling.preview.Preview +// import com.google.ai.edge.gallery.ui.theme.GalleryTheme +// import com.google.ai.edge.gallery.ui.preview.MODEL_TEST1 import androidx.compose.foundation.background import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box @@ -34,33 +37,26 @@ import androidx.compose.ui.Modifier import androidx.compose.ui.draw.alpha import androidx.compose.ui.draw.clip import androidx.compose.ui.text.font.FontWeight -import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp -import com.google.ai.edge.gallery.data.ConfigKey -import com.google.ai.edge.gallery.ui.common.convertValueToTargetType -import com.google.ai.edge.gallery.ui.common.getConfigValueString -import com.google.ai.edge.gallery.ui.preview.MODEL_TEST1 -import com.google.ai.edge.gallery.ui.theme.GalleryTheme +import com.google.ai.edge.gallery.data.convertValueToTargetType +import com.google.ai.edge.gallery.data.getConfigValueString import com.google.ai.edge.gallery.ui.theme.bodySmallNarrow import com.google.ai.edge.gallery.ui.theme.titleSmaller /** * Composable function to display a message indicating configuration value changes. * - * This function renders a centered row containing a box that displays the old and new - * values of configuration settings that have been updated. + * This function renders a centered row containing a box that displays the old and new values of + * configuration settings that have been updated. */ @Composable fun MessageBodyConfigUpdate(message: ChatMessageConfigValuesChange) { - Row( - modifier = Modifier.fillMaxWidth(), - horizontalArrangement = Arrangement.Center, - ) { + Row(modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.Center) { Box( - modifier = Modifier - .clip(RoundedCornerShape(4.dp)) - .background(MaterialTheme.colorScheme.tertiaryContainer) + modifier = + Modifier.clip(RoundedCornerShape(4.dp)) + .background(MaterialTheme.colorScheme.tertiaryContainer) ) { Column(modifier = Modifier.padding(8.dp)) { // Title. @@ -74,11 +70,7 @@ fun MessageBodyConfigUpdate(message: ChatMessageConfigValuesChange) { // Keys Column { for (config in message.model.configs) { - Text( - "${config.key.label}:", - style = bodySmallNarrow, - modifier = Modifier.alpha(0.6f), - ) + Text("${config.key.label}:", style = bodySmallNarrow, modifier = Modifier.alpha(0.6f)) } } @@ -88,23 +80,25 @@ fun MessageBodyConfigUpdate(message: ChatMessageConfigValuesChange) { Column { for (config in message.model.configs) { val key = config.key.label - val oldValue: Any = convertValueToTargetType( - value = message.oldValues.getValue(key), valueType = config.valueType - ) - val newValue: Any = convertValueToTargetType( - value = message.newValues.getValue(key), valueType = config.valueType - ) + val oldValue: Any = + convertValueToTargetType( + value = message.oldValues.getValue(key), + valueType = config.valueType, + ) + val newValue: Any = + convertValueToTargetType( + value = message.newValues.getValue(key), + valueType = config.valueType, + ) if (oldValue == newValue) { Text("$newValue", style = bodySmallNarrow) } else { Row(verticalAlignment = Alignment.CenterVertically) { - Text( - getConfigValueString(oldValue, config), style = bodySmallNarrow - ) + Text(getConfigValueString(oldValue, config), style = bodySmallNarrow) Text( "▸", style = bodySmallNarrow.copy(fontSize = 12.sp), - modifier = Modifier.padding(start = 4.dp, end = 4.dp) + modifier = Modifier.padding(start = 4.dp, end = 4.dp), ) Text( getConfigValueString(newValue, config), @@ -121,24 +115,24 @@ fun MessageBodyConfigUpdate(message: ChatMessageConfigValuesChange) { } } -@Preview(showBackground = true) -@Composable -fun MessageBodyConfigUpdatePreview() { - GalleryTheme { - Row(modifier = Modifier.padding(16.dp)) { - MessageBodyConfigUpdate( - message = ChatMessageConfigValuesChange( - model = MODEL_TEST1, - oldValues = mapOf( - ConfigKey.MAX_RESULT_COUNT.label to 100, - ConfigKey.USE_GPU.label to false - ), - newValues = mapOf( - ConfigKey.MAX_RESULT_COUNT.label to 200, - ConfigKey.USE_GPU.label to true - ) - ) - ) - } - } -} \ No newline at end of file +// @Preview(showBackground = true) +// @Composable +// fun MessageBodyConfigUpdatePreview() { +// GalleryTheme { +// Row(modifier = Modifier.padding(16.dp)) { +// MessageBodyConfigUpdate( +// message = ChatMessageConfigValuesChange( +// model = MODEL_TEST1, +// oldValues = mapOf( +// ConfigKey.MAX_RESULT_COUNT.label to 100, +// ConfigKey.USE_GPU.label to false +// ), +// newValues = mapOf( +// ConfigKey.MAX_RESULT_COUNT.label to 200, +// ConfigKey.USE_GPU.label to true +// ) +// ) +// ) +// } +// } +// } diff --git a/Android/src/app/src/main/java/com/google/ai/edge/gallery/ui/common/chat/MessageBodyImage.kt b/Android/src/app/src/main/java/com/google/ai/edge/gallery/ui/common/chat/MessageBodyImage.kt index a4d7d4d..5c2f5cf 100644 --- a/Android/src/app/src/main/java/com/google/ai/edge/gallery/ui/common/chat/MessageBodyImage.kt +++ b/Android/src/app/src/main/java/com/google/ai/edge/gallery/ui/common/chat/MessageBodyImage.kt @@ -36,9 +36,7 @@ fun MessageBodyImage(message: ChatMessageImage, modifier: Modifier = Modifier) { Image( bitmap = message.imageBitMap, contentDescription = "", - modifier = modifier - .height(imageHeight.dp) - .width(imageWidth.dp), + modifier = modifier.height(imageHeight.dp).width(imageWidth.dp), contentScale = ContentScale.Fit, ) -} \ No newline at end of file +} diff --git a/Android/src/app/src/main/java/com/google/ai/edge/gallery/ui/common/chat/MessageBodyImageWithHistory.kt b/Android/src/app/src/main/java/com/google/ai/edge/gallery/ui/common/chat/MessageBodyImageWithHistory.kt index 2200943..d136ce0 100644 --- a/Android/src/app/src/main/java/com/google/ai/edge/gallery/ui/common/chat/MessageBodyImageWithHistory.kt +++ b/Android/src/app/src/main/java/com/google/ai/edge/gallery/ui/common/chat/MessageBodyImageWithHistory.kt @@ -43,7 +43,7 @@ import androidx.compose.ui.unit.dp @Composable fun MessageBodyImageWithHistory( message: ChatMessageImageWithHistory, - imageHistoryCurIndex: MutableIntState + imageHistoryCurIndex: MutableIntState, ) { val prevMessage: MutableState = remember { mutableStateOf(null) } @@ -68,15 +68,15 @@ fun MessageBodyImageWithHistory( Image( bitmap = curImageBitmap, contentDescription = "", - modifier = Modifier - .height(imageHeight.dp) - .width(imageWidth.dp) - .pointerInput(Unit) { - detectHorizontalDragGestures(onDragStart = { - value = 0f - savedIndex = imageHistoryCurIndex.intValue - }) { _, dragAmount -> - value += (dragAmount / 20f)// Adjust sensitivity here + modifier = + Modifier.height(imageHeight.dp).width(imageWidth.dp).pointerInput(Unit) { + detectHorizontalDragGestures( + onDragStart = { + value = 0f + savedIndex = imageHistoryCurIndex.intValue + } + ) { _, dragAmount -> + value += (dragAmount / 20f) // Adjust sensitivity here imageHistoryCurIndex.intValue = (savedIndex + value).toInt() if (imageHistoryCurIndex.intValue < 0) { imageHistoryCurIndex.intValue = 0 @@ -88,4 +88,4 @@ fun MessageBodyImageWithHistory( contentScale = ContentScale.Fit, ) } -} \ No newline at end of file +} diff --git a/Android/src/app/src/main/java/com/google/ai/edge/gallery/ui/common/chat/MessageBodyInfo.kt b/Android/src/app/src/main/java/com/google/ai/edge/gallery/ui/common/chat/MessageBodyInfo.kt index 1092993..2f228c4 100644 --- a/Android/src/app/src/main/java/com/google/ai/edge/gallery/ui/common/chat/MessageBodyInfo.kt +++ b/Android/src/app/src/main/java/com/google/ai/edge/gallery/ui/common/chat/MessageBodyInfo.kt @@ -16,6 +16,8 @@ package com.google.ai.edge.gallery.ui.common.chat +// import androidx.compose.ui.tooling.preview.Preview +// import com.google.ai.edge.gallery.ui.theme.GalleryTheme import androidx.compose.foundation.background import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box @@ -27,9 +29,8 @@ import androidx.compose.material3.MaterialTheme import androidx.compose.runtime.Composable import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip -import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp -import com.google.ai.edge.gallery.ui.theme.GalleryTheme +import com.google.ai.edge.gallery.ui.common.MarkdownText import com.google.ai.edge.gallery.ui.theme.customColors /** @@ -39,29 +40,27 @@ import com.google.ai.edge.gallery.ui.theme.customColors */ @Composable fun MessageBodyInfo(message: ChatMessageInfo, smallFontSize: Boolean = true) { - Row( - modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.Center - ) { + Row(modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.Center) { Box( - modifier = Modifier - .clip(RoundedCornerShape(16.dp)) - .background(MaterialTheme.customColors.agentBubbleBgColor) + modifier = + Modifier.clip(RoundedCornerShape(16.dp)) + .background(MaterialTheme.customColors.agentBubbleBgColor) ) { MarkdownText( text = message.content, modifier = Modifier.padding(12.dp), - smallFontSize = smallFontSize + smallFontSize = smallFontSize, ) } } } -@Preview(showBackground = true) -@Composable -fun MessageBodyInfoPreview() { - GalleryTheme { - Row(modifier = Modifier.padding(16.dp)) { - MessageBodyInfo(message = ChatMessageInfo(content = "This is a model")) - } - } -} \ No newline at end of file +// @Preview(showBackground = true) +// @Composable +// fun MessageBodyInfoPreview() { +// GalleryTheme { +// Row(modifier = Modifier.padding(16.dp)) { +// MessageBodyInfo(message = ChatMessageInfo(content = "This is a model")) +// } +// } +// } diff --git a/Android/src/app/src/main/java/com/google/ai/edge/gallery/ui/common/chat/MessageBodyLoading.kt b/Android/src/app/src/main/java/com/google/ai/edge/gallery/ui/common/chat/MessageBodyLoading.kt index d89fb55..aee92d4 100644 --- a/Android/src/app/src/main/java/com/google/ai/edge/gallery/ui/common/chat/MessageBodyLoading.kt +++ b/Android/src/app/src/main/java/com/google/ai/edge/gallery/ui/common/chat/MessageBodyLoading.kt @@ -16,12 +16,12 @@ package com.google.ai.edge.gallery.ui.common.chat +// import androidx.compose.ui.tooling.preview.Preview +// import com.google.ai.edge.gallery.ui.theme.GalleryTheme import androidx.compose.animation.core.Animatable import androidx.compose.animation.core.tween import androidx.compose.foundation.Image import androidx.compose.foundation.layout.Box -import androidx.compose.foundation.layout.Row -import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect @@ -33,29 +33,21 @@ import androidx.compose.ui.graphics.ColorFilter import androidx.compose.ui.graphics.graphicsLayer import androidx.compose.ui.layout.ContentScale import androidx.compose.ui.res.painterResource -import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp import com.google.ai.edge.gallery.R import com.google.ai.edge.gallery.ui.common.getTaskIconColor -import com.google.ai.edge.gallery.ui.theme.GalleryTheme import kotlinx.coroutines.delay import kotlinx.coroutines.launch -private val IMAGE_RESOURCES = listOf( - R.drawable.pantegon, - R.drawable.double_circle, - R.drawable.circle, - R.drawable.four_circle -) +private val IMAGE_RESOURCES = + listOf(R.drawable.pantegon, R.drawable.double_circle, R.drawable.circle, R.drawable.four_circle) private const val ANIMATION_DURATION = 300 private const val ANIMATION_DURATION2 = 300 private const val PAUSE_DURATION = 200 private const val PAUSE_DURATION2 = 0 -/** - * Composable function to display a loading indicator. - */ +/** Composable function to display a loading indicator. */ @Composable fun MessageBodyLoading() { val progress = remember { Animatable(0f) } @@ -67,18 +59,17 @@ fun MessageBodyLoading() { var progressJob = launch { progress.animateTo( targetValue = 1f, - animationSpec = tween( - durationMillis = ANIMATION_DURATION, - easing = multiBounceEasing(bounces = 3, decay = 0.02f) - ) + animationSpec = + tween( + durationMillis = ANIMATION_DURATION, + easing = multiBounceEasing(bounces = 3, decay = 0.02f), + ), ) } var alphaJob = launch { alphaAnim.animateTo( targetValue = 1f, - animationSpec = tween( - durationMillis = ANIMATION_DURATION / 2, - ) + animationSpec = tween(durationMillis = ANIMATION_DURATION / 2), ) } progressJob.join() @@ -88,18 +79,17 @@ fun MessageBodyLoading() { progressJob = launch { progress.animateTo( targetValue = 0f, - animationSpec = tween( - durationMillis = ANIMATION_DURATION2, - easing = multiBounceEasing(bounces = 3, decay = 0.02f) - ) + animationSpec = + tween( + durationMillis = ANIMATION_DURATION2, + easing = multiBounceEasing(bounces = 3, decay = 0.02f), + ), ) } alphaJob = launch { alphaAnim.animateTo( targetValue = 0f, - animationSpec = tween( - durationMillis = ANIMATION_DURATION2 / 2, - ) + animationSpec = tween(durationMillis = ANIMATION_DURATION2 / 2), ) } @@ -118,25 +108,21 @@ fun MessageBodyLoading() { contentDescription = "", contentScale = ContentScale.Fit, colorFilter = ColorFilter.tint(getTaskIconColor(index = index)), - modifier = Modifier - .graphicsLayer { - scaleX = progress.value * 0.2f + 0.8f - scaleY = progress.value * 0.2f + 0.8f - rotationZ = progress.value * 100 - alpha = if (index != activeImageIndex.intValue) 0f else alphaAnim.value - } - .size(24.dp) + modifier = + Modifier.graphicsLayer { + scaleX = progress.value * 0.2f + 0.8f + scaleY = progress.value * 0.2f + 0.8f + rotationZ = progress.value * 100 + alpha = if (index != activeImageIndex.intValue) 0f else alphaAnim.value + } + .size(24.dp), ) } } } -@Preview(showBackground = true) -@Composable -fun MessageBodyLoadingPreview() { - GalleryTheme { - Row(modifier = Modifier.padding(16.dp)) { - MessageBodyLoading() - } - } -} \ No newline at end of file +// @Preview(showBackground = true) +// @Composable +// fun MessageBodyLoadingPreview() { +// GalleryTheme { Row(modifier = Modifier.padding(16.dp)) { MessageBodyLoading() } } +// } diff --git a/Android/src/app/src/main/java/com/google/ai/edge/gallery/ui/common/chat/MessageBodyPromptTemplates.kt b/Android/src/app/src/main/java/com/google/ai/edge/gallery/ui/common/chat/MessageBodyPromptTemplates.kt index 777d6a4..a845ae8 100644 --- a/Android/src/app/src/main/java/com/google/ai/edge/gallery/ui/common/chat/MessageBodyPromptTemplates.kt +++ b/Android/src/app/src/main/java/com/google/ai/edge/gallery/ui/common/chat/MessageBodyPromptTemplates.kt @@ -16,13 +16,17 @@ package com.google.ai.edge.gallery.ui.common.chat +// import androidx.compose.ui.tooling.preview.Preview +// import com.google.ai.edge.gallery.ui.preview.ALL_PREVIEW_TASKS +// import com.google.ai.edge.gallery.ui.preview.TASK_TEST1 +// import com.google.ai.edge.gallery.ui.theme.GalleryTheme + import androidx.compose.foundation.background import androidx.compose.foundation.border import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth @@ -41,13 +45,10 @@ import androidx.compose.ui.draw.shadow import androidx.compose.ui.graphics.Brush import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.style.TextAlign -import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp +import com.google.ai.edge.gallery.data.PromptTemplate import com.google.ai.edge.gallery.data.Task import com.google.ai.edge.gallery.ui.common.getTaskIconColor -import com.google.ai.edge.gallery.ui.preview.ALL_PREVIEW_TASKS -import com.google.ai.edge.gallery.ui.preview.TASK_TEST1 -import com.google.ai.edge.gallery.ui.theme.GalleryTheme private const val CARD_HEIGHT = 100 @@ -63,16 +64,15 @@ fun MessageBodyPromptTemplates( Column( modifier = Modifier.padding(top = 12.dp), - verticalArrangement = Arrangement.spacedBy(8.dp) + verticalArrangement = Arrangement.spacedBy(8.dp), ) { Text( "Try an example prompt", - style = MaterialTheme.typography.titleLarge.copy( - fontWeight = FontWeight.Bold, - brush = Brush.linearGradient( - colors = gradientColors, - ) - ), + style = + MaterialTheme.typography.titleLarge.copy( + fontWeight = FontWeight.Bold, + brush = Brush.linearGradient(colors = gradientColors), + ), modifier = Modifier.fillMaxWidth(), textAlign = TextAlign.Center, ) @@ -80,41 +80,30 @@ fun MessageBodyPromptTemplates( Text( "Or make your own", style = MaterialTheme.typography.titleSmall, - modifier = Modifier - .fillMaxWidth() - .offset(y = (-4).dp), + modifier = Modifier.fillMaxWidth().offset(y = (-4).dp), textAlign = TextAlign.Center, ) } LazyColumn( - modifier = Modifier - .height((rowCount * (CARD_HEIGHT + 8)).dp), + modifier = Modifier.height((rowCount * (CARD_HEIGHT + 8)).dp), verticalArrangement = Arrangement.spacedBy(8.dp), ) { // Cards. items(message.templates) { template -> Box( - modifier = Modifier - .border( - width = 1.dp, - color = color.copy(alpha = 0.3f), - shape = RoundedCornerShape(24.dp) - ) - .height(CARD_HEIGHT.dp) - .shadow( - elevation = 2.dp, - shape = RoundedCornerShape(24.dp), - spotColor = color - ) - .background(MaterialTheme.colorScheme.surface) - .clickable { - onPromptClicked(template) - } + modifier = + Modifier.border( + width = 1.dp, + color = color.copy(alpha = 0.3f), + shape = RoundedCornerShape(24.dp), + ) + .height(CARD_HEIGHT.dp) + .shadow(elevation = 2.dp, shape = RoundedCornerShape(24.dp), spotColor = color) + .background(MaterialTheme.colorScheme.surface) + .clickable { onPromptClicked(template) } ) { Column( - modifier = Modifier - .padding(horizontal = 12.dp, vertical = 20.dp) - .fillMaxSize(), + modifier = Modifier.padding(horizontal = 12.dp, vertical = 20.dp).fillMaxSize(), horizontalAlignment = Alignment.CenterHorizontally, ) { Text( @@ -134,35 +123,37 @@ fun MessageBodyPromptTemplates( } } -@Preview(showBackground = true) -@Composable -fun MessageBodyPromptTemplatesPreview() { - for ((index, task) in ALL_PREVIEW_TASKS.withIndex()) { - task.index = index - for (model in task.models) { - model.preProcess() - } - } +// @Preview(showBackground = true) +// @Composable +// fun MessageBodyPromptTemplatesPreview() { +// for ((index, task) in ALL_PREVIEW_TASKS.withIndex()) { +// task.index = index +// for (model in task.models) { +// model.preProcess() +// } +// } - GalleryTheme { - Row(modifier = Modifier.padding(16.dp)) { - MessageBodyPromptTemplates( - message = ChatMessagePromptTemplates( - templates = listOf( - PromptTemplate( - title = "Math Worksheets", - description = "Create a set of math worksheets for parents", - prompt = "" - ), - PromptTemplate( - title = "Shape Sequencer", - description = "Find the next shape in a sequence", - prompt = "" - ) - ) - ), - task = TASK_TEST1, - ) - } - } -} +// GalleryTheme { +// Row(modifier = Modifier.padding(16.dp)) { +// MessageBodyPromptTemplates( +// message = +// ChatMessagePromptTemplates( +// templates = +// listOf( +// PromptTemplate( +// title = "Math Worksheets", +// description = "Create a set of math worksheets for parents", +// prompt = "", +// ), +// PromptTemplate( +// title = "Shape Sequencer", +// description = "Find the next shape in a sequence", +// prompt = "", +// ), +// ) +// ), +// task = TASK_TEST1, +// ) +// } +// } +// } diff --git a/Android/src/app/src/main/java/com/google/ai/edge/gallery/ui/common/chat/MessageBodyText.kt b/Android/src/app/src/main/java/com/google/ai/edge/gallery/ui/common/chat/MessageBodyText.kt index 8304c87..883f254 100644 --- a/Android/src/app/src/main/java/com/google/ai/edge/gallery/ui/common/chat/MessageBodyText.kt +++ b/Android/src/app/src/main/java/com/google/ai/edge/gallery/ui/common/chat/MessageBodyText.kt @@ -16,9 +16,9 @@ package com.google.ai.edge.gallery.ui.common.chat -import androidx.compose.foundation.background -import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.Row +// import com.google.ai.edge.gallery.ui.theme.GalleryTheme +// import androidx.compose.ui.tooling.preview.Preview + import androidx.compose.foundation.layout.padding import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Text @@ -26,13 +26,10 @@ import androidx.compose.runtime.Composable import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color import androidx.compose.ui.text.font.FontWeight -import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp -import com.google.ai.edge.gallery.ui.theme.GalleryTheme +import com.google.ai.edge.gallery.ui.common.MarkdownText -/** - * Composable function to display the text content of a ChatMessageText. - */ +/** Composable function to display the text content of a ChatMessageText. */ @Composable fun MessageBodyText(message: ChatMessageText) { if (message.side == ChatSide.USER) { @@ -40,7 +37,7 @@ fun MessageBodyText(message: ChatMessageText) { message.content, style = MaterialTheme.typography.bodyLarge.copy(fontWeight = FontWeight.Medium), color = Color.White, - modifier = Modifier.padding(12.dp) + modifier = Modifier.padding(12.dp), ) } else if (message.side == ChatSide.AGENT) { if (message.isMarkdown) { @@ -50,31 +47,25 @@ fun MessageBodyText(message: ChatMessageText) { message.content, style = MaterialTheme.typography.bodyMedium.copy(fontWeight = FontWeight.Medium), color = MaterialTheme.colorScheme.onSurface, - modifier = Modifier.padding(12.dp) + modifier = Modifier.padding(12.dp), ) } } } -@Preview(showBackground = true) -@Composable -fun MessageBodyTextPreview() { - GalleryTheme { - Column { - Row( - modifier = Modifier - .padding(16.dp) - .background(MaterialTheme.colorScheme.primary), - ) { - MessageBodyText(ChatMessageText(content = "Hello world", side = ChatSide.USER)) - } - Row( - modifier = Modifier - .padding(16.dp) - .background(MaterialTheme.colorScheme.surfaceContainer), - ) { - MessageBodyText(ChatMessageText(content = "yes hello world", side = ChatSide.AGENT)) - } - } - } -} \ No newline at end of file +// @Preview(showBackground = true) +// @Composable +// fun MessageBodyTextPreview() { +// GalleryTheme { +// Column { +// Row(modifier = Modifier.padding(16.dp).background(MaterialTheme.colorScheme.primary)) { +// MessageBodyText(ChatMessageText(content = "Hello world", side = ChatSide.USER)) +// } +// Row( +// modifier = Modifier.padding(16.dp).background(MaterialTheme.colorScheme.surfaceContainer) +// ) { +// MessageBodyText(ChatMessageText(content = "yes hello world", side = ChatSide.AGENT)) +// } +// } +// } +// } diff --git a/Android/src/app/src/main/java/com/google/ai/edge/gallery/ui/common/chat/MessageBodyWarning.kt b/Android/src/app/src/main/java/com/google/ai/edge/gallery/ui/common/chat/MessageBodyWarning.kt index af29e40..89b8c8e 100644 --- a/Android/src/app/src/main/java/com/google/ai/edge/gallery/ui/common/chat/MessageBodyWarning.kt +++ b/Android/src/app/src/main/java/com/google/ai/edge/gallery/ui/common/chat/MessageBodyWarning.kt @@ -16,6 +16,8 @@ package com.google.ai.edge.gallery.ui.common.chat +// import androidx.compose.ui.tooling.preview.Preview +// import com.google.ai.edge.gallery.ui.theme.GalleryTheme import androidx.compose.foundation.background import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box @@ -27,9 +29,8 @@ import androidx.compose.material3.MaterialTheme import androidx.compose.runtime.Composable import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip -import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp -import com.google.ai.edge.gallery.ui.theme.GalleryTheme +import com.google.ai.edge.gallery.ui.common.MarkdownText /** * Composable function to display warning message content within a chat. @@ -38,29 +39,27 @@ import com.google.ai.edge.gallery.ui.theme.GalleryTheme */ @Composable fun MessageBodyWarning(message: ChatMessageWarning) { - Row( - modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.Center - ) { + Row(modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.Center) { Box( - modifier = Modifier - .clip(RoundedCornerShape(16.dp)) - .background(MaterialTheme.colorScheme.tertiaryContainer) + modifier = + Modifier.clip(RoundedCornerShape(16.dp)) + .background(MaterialTheme.colorScheme.tertiaryContainer) ) { MarkdownText( text = message.content, modifier = Modifier.padding(horizontal = 16.dp, vertical = 6.dp), - smallFontSize = true + smallFontSize = true, ) } } } -@Preview(showBackground = true) -@Composable -fun MessageBodyWarningPreview() { - GalleryTheme { - Row(modifier = Modifier.padding(16.dp)) { - MessageBodyWarning(message = ChatMessageWarning(content = "This is a warning")) - } - } -} \ No newline at end of file +// @Preview(showBackground = true) +// @Composable +// fun MessageBodyWarningPreview() { +// GalleryTheme { +// Row(modifier = Modifier.padding(16.dp)) { +// MessageBodyWarning(message = ChatMessageWarning(content = "This is a warning")) +// } +// } +// } diff --git a/Android/src/app/src/main/java/com/google/ai/edge/gallery/ui/common/chat/MessageBubbleShape.kt b/Android/src/app/src/main/java/com/google/ai/edge/gallery/ui/common/chat/MessageBubbleShape.kt index 85ca175..66b4616 100644 --- a/Android/src/app/src/main/java/com/google/ai/edge/gallery/ui/common/chat/MessageBubbleShape.kt +++ b/Android/src/app/src/main/java/com/google/ai/edge/gallery/ui/common/chat/MessageBubbleShape.kt @@ -29,41 +29,39 @@ import androidx.compose.ui.unit.LayoutDirection /** * Custom Shape for creating message bubble outlines with configurable corner radii. * - * This class defines a custom Shape that generates a rounded rectangle outline, - * suitable for message bubbles. It allows specifying a uniform corner radius for - * most corners, but also provides the option to have a hard (non-rounded) corner - * on either the left or right side. + * This class defines a custom Shape that generates a rounded rectangle outline, suitable for + * message bubbles. It allows specifying a uniform corner radius for most corners, but also provides + * the option to have a hard (non-rounded) corner on either the left or right side. */ class MessageBubbleShape( private val radius: Dp, - private val hardCornerAtLeftOrRight: Boolean = false + private val hardCornerAtLeftOrRight: Boolean = false, ) : Shape { override fun createOutline( size: Size, layoutDirection: LayoutDirection, - density: Density + density: Density, ): Outline { val radiusPx = with(density) { radius.toPx() } - val path = Path().apply { - addRoundRect( - RoundRect( - left = 0f, - top = 0f, - right = size.width, - bottom = size.height, - topLeftCornerRadius = if (hardCornerAtLeftOrRight) CornerRadius(0f, 0f) else CornerRadius( - radiusPx, - radiusPx - ), - topRightCornerRadius = if (hardCornerAtLeftOrRight) CornerRadius( - radiusPx, - radiusPx - ) else CornerRadius(0f, 0f), // No rounding here - bottomLeftCornerRadius = CornerRadius(radiusPx, radiusPx), - bottomRightCornerRadius = CornerRadius(radiusPx, radiusPx) + val path = + Path().apply { + addRoundRect( + RoundRect( + left = 0f, + top = 0f, + right = size.width, + bottom = size.height, + topLeftCornerRadius = + if (hardCornerAtLeftOrRight) CornerRadius(0f, 0f) + else CornerRadius(radiusPx, radiusPx), + topRightCornerRadius = + if (hardCornerAtLeftOrRight) CornerRadius(radiusPx, radiusPx) + else CornerRadius(0f, 0f), // No rounding here + bottomLeftCornerRadius = CornerRadius(radiusPx, radiusPx), + bottomRightCornerRadius = CornerRadius(radiusPx, radiusPx), + ) ) - ) - } + } return Outline.Generic(path) } } diff --git a/Android/src/app/src/main/java/com/google/ai/edge/gallery/ui/common/chat/MessageInputImage.kt b/Android/src/app/src/main/java/com/google/ai/edge/gallery/ui/common/chat/MessageInputImage.kt index 8e2a99f..93798e1 100644 --- a/Android/src/app/src/main/java/com/google/ai/edge/gallery/ui/common/chat/MessageInputImage.kt +++ b/Android/src/app/src/main/java/com/google/ai/edge/gallery/ui/common/chat/MessageInputImage.kt @@ -16,6 +16,9 @@ package com.google.ai.edge.gallery.ui.common.chat +// import androidx.compose.ui.tooling.preview.Preview +// import com.google.ai.edge.gallery.ui.theme.GalleryTheme + import android.Manifest import android.content.Context import android.content.pm.PackageManager @@ -28,7 +31,6 @@ import androidx.activity.compose.rememberLauncherForActivityResult import androidx.activity.result.PickVisualMediaRequest import androidx.activity.result.contract.ActivityResultContracts import androidx.compose.foundation.layout.Arrangement -import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.padding @@ -49,11 +51,9 @@ import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.alpha import androidx.compose.ui.platform.LocalContext -import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp import androidx.core.content.ContextCompat import com.google.ai.edge.gallery.ui.common.createTempPictureUri -import com.google.ai.edge.gallery.ui.theme.GalleryTheme private const val TAG = "AGMessageInputImage" @@ -102,30 +102,28 @@ fun MessageInputImage( } // Permission request when taking picture. - val takePicturePermissionLauncher = rememberLauncherForActivityResult( - ActivityResultContracts.RequestPermission() - ) { permissionGranted -> - if (permissionGranted) { - tempPhotoUri = context.createTempPictureUri() - cameraLauncher.launch(tempPhotoUri) + val takePicturePermissionLauncher = + rememberLauncherForActivityResult(ActivityResultContracts.RequestPermission()) { + permissionGranted -> + if (permissionGranted) { + tempPhotoUri = context.createTempPictureUri() + cameraLauncher.launch(tempPhotoUri) + } } - } // Permission request when using live camera. - val liveCameraPermissionLauncher = rememberLauncherForActivityResult( - ActivityResultContracts.RequestPermission() - ) { permissionGranted -> - if (permissionGranted) { - showLiveCameraDialog = true + val liveCameraPermissionLauncher = + rememberLauncherForActivityResult(ActivityResultContracts.RequestPermission()) { + permissionGranted -> + if (permissionGranted) { + showLiveCameraDialog = true + } } - } val buttonAlpha = if (disableButtons) 0.3f else 1f Row( - modifier = Modifier - .fillMaxWidth() - .padding(12.dp), + modifier = Modifier.fillMaxWidth().padding(12.dp), verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.End, ) { @@ -139,9 +137,8 @@ fun MessageInputImage( // Launch the photo picker and let the user choose only images. pickMedia.launch(PickVisualMediaRequest(ActivityResultContracts.PickVisualMedia.ImageOnly)) }, - colors = IconButtonDefaults.iconButtonColors( - containerColor = MaterialTheme.colorScheme.primary, - ), + colors = + IconButtonDefaults.iconButtonColors(containerColor = MaterialTheme.colorScheme.primary), modifier = Modifier.alpha(buttonAlpha), ) { Icon(Icons.Rounded.Photo, contentDescription = "", tint = MaterialTheme.colorScheme.onPrimary) @@ -157,9 +154,7 @@ fun MessageInputImage( // Check permission when (PackageManager.PERMISSION_GRANTED) { // Already got permission. Call the lambda. - ContextCompat.checkSelfPermission( - context, Manifest.permission.CAMERA - ) -> { + ContextCompat.checkSelfPermission(context, Manifest.permission.CAMERA) -> { tempPhotoUri = context.createTempPictureUri() cameraLauncher.launch(tempPhotoUri) } @@ -170,15 +165,14 @@ fun MessageInputImage( } } }, - colors = IconButtonDefaults.iconButtonColors( - containerColor = MaterialTheme.colorScheme.primary, - ), + colors = + IconButtonDefaults.iconButtonColors(containerColor = MaterialTheme.colorScheme.primary), modifier = Modifier.alpha(buttonAlpha), ) { Icon( Icons.Rounded.PhotoCamera, contentDescription = "", - tint = MaterialTheme.colorScheme.onPrimary + tint = MaterialTheme.colorScheme.onPrimary, ) } @@ -192,9 +186,7 @@ fun MessageInputImage( // Check permission when (PackageManager.PERMISSION_GRANTED) { // Already got permission. Call the lambda. - ContextCompat.checkSelfPermission( - context, Manifest.permission.CAMERA - ) -> { + ContextCompat.checkSelfPermission(context, Manifest.permission.CAMERA) -> { showLiveCameraDialog = true } @@ -204,25 +196,30 @@ fun MessageInputImage( } } }, - colors = IconButtonDefaults.iconButtonColors( - containerColor = MaterialTheme.colorScheme.primary, - ), + colors = + IconButtonDefaults.iconButtonColors(containerColor = MaterialTheme.colorScheme.primary), modifier = Modifier.alpha(buttonAlpha), ) { Icon( - Icons.Rounded.Videocam, contentDescription = "", tint = MaterialTheme.colorScheme.onPrimary + Icons.Rounded.Videocam, + contentDescription = "", + tint = MaterialTheme.colorScheme.onPrimary, ) } } // Live camera stream dialog. if (showLiveCameraDialog) { - LiveCameraDialog( - streamingMessage = streamingMessage, onDismissed = { averageFps -> - onStreamEnd(averageFps) - showLiveCameraDialog = false - }, onBitmap = onStreamImage - ) + // TODO(migration) + // + // LiveCameraDialog( + // streamingMessage = streamingMessage, + // onDismissed = { averageFps -> + // onStreamEnd(averageFps) + // showLiveCameraDialog = false + // }, + // onBitmap = onStreamImage, + // ) } } @@ -237,33 +234,33 @@ private fun handleImageSelected( ) { Log.d(TAG, "Selected URI: $uri") - val bitmap: Bitmap? = try { - val inputStream = context.contentResolver.openInputStream(uri) - val tmpBitmap = BitmapFactory.decodeStream(inputStream) - if (rotateForPortrait && tmpBitmap.width > tmpBitmap.height) { - val matrix = Matrix() - matrix.postRotate(90f) - Bitmap.createBitmap(tmpBitmap, 0, 0, tmpBitmap.width, tmpBitmap.height, matrix, true) - } else { - tmpBitmap + val bitmap: Bitmap? = + try { + val inputStream = context.contentResolver.openInputStream(uri) + val tmpBitmap = BitmapFactory.decodeStream(inputStream) + if (rotateForPortrait && tmpBitmap.width > tmpBitmap.height) { + val matrix = Matrix() + matrix.postRotate(90f) + Bitmap.createBitmap(tmpBitmap, 0, 0, tmpBitmap.width, tmpBitmap.height, matrix, true) + } else { + tmpBitmap + } + } catch (e: Exception) { + e.printStackTrace() + null } - } catch (e: Exception) { - e.printStackTrace() - null - } if (bitmap != null) { onImageSelected(bitmap) } } -@Preview(showBackground = true) -@Composable -fun MessageInputImagePreview() { - GalleryTheme { - Column { - MessageInputImage(onImageSelected = {}) - MessageInputImage(disableButtons = true, onImageSelected = {}) - } - } -} - +// @Preview(showBackground = true) +// @Composable +// fun MessageInputImagePreview() { +// GalleryTheme { +// Column { +// MessageInputImage(onImageSelected = {}) +// MessageInputImage(disableButtons = true, onImageSelected = {}) +// } +// } +// } diff --git a/Android/src/app/src/main/java/com/google/ai/edge/gallery/ui/common/chat/MessageInputText.kt b/Android/src/app/src/main/java/com/google/ai/edge/gallery/ui/common/chat/MessageInputText.kt index 49afffd..24f0e76 100644 --- a/Android/src/app/src/main/java/com/google/ai/edge/gallery/ui/common/chat/MessageInputText.kt +++ b/Android/src/app/src/main/java/com/google/ai/edge/gallery/ui/common/chat/MessageInputText.kt @@ -16,6 +16,9 @@ package com.google.ai.edge.gallery.ui.common.chat +// import androidx.compose.ui.tooling.preview.Preview +// import com.google.ai.edge.gallery.ui.preview.PreviewModelManagerViewModel +// import com.google.ai.edge.gallery.ui.theme.GalleryTheme import android.Manifest import android.content.Context import android.content.pm.PackageManager @@ -43,9 +46,9 @@ import androidx.compose.foundation.Image import androidx.compose.foundation.background import androidx.compose.foundation.border import androidx.compose.foundation.clickable +import androidx.compose.foundation.horizontalScroll import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box -import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxSize @@ -55,6 +58,7 @@ import androidx.compose.foundation.layout.offset import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.width +import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.shape.CircleShape import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material.icons.Icons @@ -98,26 +102,22 @@ import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.asImageBitmap import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.res.stringResource -import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp import androidx.compose.ui.viewinterop.AndroidView import androidx.core.content.ContextCompat import androidx.lifecycle.compose.LocalLifecycleOwner -import com.google.ai.edge.gallery.R -import com.google.ai.edge.gallery.ui.common.createTempPictureUri +import com.google.ai.edge.gallery.data.MAX_IMAGE_COUNT import com.google.ai.edge.gallery.ui.modelmanager.ModelManagerViewModel -import com.google.ai.edge.gallery.ui.preview.PreviewModelManagerViewModel -import com.google.ai.edge.gallery.ui.theme.GalleryTheme -import kotlinx.coroutines.launch import java.util.concurrent.Executors +import kotlinx.coroutines.launch private const val TAG = "AGMessageInputText" /** * Composable function to display a text input field for composing chat messages. * - * This function renders a row containing a text field for message input and a send button. - * It handles message composition, input validation, and sending messages. + * This function renders a row containing a text field for message input and a send button. It + * handles message composition, input validation, and sending messages. */ @OptIn(ExperimentalMaterial3Api::class) @Composable @@ -126,7 +126,7 @@ fun MessageInputText( curMessage: String, isResettingSession: Boolean, inProgress: Boolean, - hasImageMessage: Boolean, + imageMessageCount: Int, modelInitializing: Boolean, @StringRes textFieldPlaceHolderRes: Int, onValueChanged: (String) -> Unit, @@ -145,73 +145,80 @@ fun MessageInputText( var showTextInputHistorySheet by remember { mutableStateOf(false) } var showCameraCaptureBottomSheet by remember { mutableStateOf(false) } val cameraCaptureSheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true) - var tempPhotoUri by remember { mutableStateOf(value = Uri.EMPTY) } var pickedImages by remember { mutableStateOf>(listOf()) } - val updatePickedImages: (Bitmap) -> Unit = { bitmap -> - val newPickedImages: MutableList = mutableListOf() + val updatePickedImages: (List) -> Unit = { bitmaps -> + var newPickedImages: MutableList = mutableListOf() newPickedImages.addAll(pickedImages) - newPickedImages.add(bitmap) + newPickedImages.addAll(bitmaps) + if (newPickedImages.size > MAX_IMAGE_COUNT) { + newPickedImages = newPickedImages.subList(fromIndex = 0, toIndex = MAX_IMAGE_COUNT) + } pickedImages = newPickedImages.toList() } var hasFrontCamera by remember { mutableStateOf(false) } - LaunchedEffect(Unit) { - checkFrontCamera(context = context, callback = { hasFrontCamera = it }) - } + LaunchedEffect(Unit) { checkFrontCamera(context = context, callback = { hasFrontCamera = it }) } // Permission request when taking picture. - val takePicturePermissionLauncher = rememberLauncherForActivityResult( - ActivityResultContracts.RequestPermission() - ) { permissionGranted -> - if (permissionGranted) { - showAddContentMenu = false - tempPhotoUri = context.createTempPictureUri() - showCameraCaptureBottomSheet = true + val takePicturePermissionLauncher = + rememberLauncherForActivityResult(ActivityResultContracts.RequestPermission()) { + permissionGranted -> + if (permissionGranted) { + showAddContentMenu = false + showCameraCaptureBottomSheet = true + } } - } // Registers a photo picker activity launcher in single-select mode. val pickMedia = - rememberLauncherForActivityResult(ActivityResultContracts.PickVisualMedia()) { uri -> - // Callback is invoked after the user selects a media item or closes the + rememberLauncherForActivityResult(ActivityResultContracts.PickMultipleVisualMedia()) { uris -> + // Callback is invoked after the user selects media items or closes the // photo picker. - if (uri != null) { - handleImageSelected(context = context, uri = uri, onImageSelected = { bitmap -> - updatePickedImages(bitmap) - }) + if (uris.isNotEmpty()) { + handleImagesSelected( + context = context, + uris = uris, + onImagesSelected = { bitmaps -> updatePickedImages(bitmaps) }, + ) } } Box(contentAlignment = Alignment.CenterStart) { // A preview panel for the selected image. if (pickedImages.isNotEmpty()) { - Box( - contentAlignment = Alignment.TopEnd, modifier = Modifier.offset(x = 16.dp, y = (-80).dp) + Row( + modifier = + Modifier.offset(x = 16.dp, y = (-80).dp) + .fillMaxWidth() + .horizontalScroll(rememberScrollState()), + horizontalArrangement = Arrangement.spacedBy(16.dp), ) { - Image( - bitmap = pickedImages.last().asImageBitmap(), - contentDescription = "", - modifier = Modifier - .height(80.dp) - .shadow(2.dp, shape = RoundedCornerShape(8.dp)) - .clip(RoundedCornerShape(8.dp)) - .border(1.dp, MaterialTheme.colorScheme.outline, RoundedCornerShape(8.dp)), - ) - Box(modifier = Modifier - .offset(x = 10.dp, y = (-10).dp) - .clip(CircleShape) - .background(MaterialTheme.colorScheme.surface) - .border((1.5).dp, MaterialTheme.colorScheme.outline, CircleShape) - .clickable { - pickedImages = listOf() - }) { - Icon( - Icons.Rounded.Close, - contentDescription = "", - modifier = Modifier - .padding(3.dp) - .size(16.dp) - ) + for (image in pickedImages) { + Box(contentAlignment = Alignment.TopEnd) { + Image( + bitmap = image.asImageBitmap(), + contentDescription = "", + modifier = + Modifier.height(80.dp) + .shadow(2.dp, shape = RoundedCornerShape(8.dp)) + .clip(RoundedCornerShape(8.dp)) + .border(1.dp, MaterialTheme.colorScheme.outline, RoundedCornerShape(8.dp)), + ) + Box( + modifier = + Modifier.offset(x = 10.dp, y = (-10).dp) + .clip(CircleShape) + .background(MaterialTheme.colorScheme.surface) + .border((1.5).dp, MaterialTheme.colorScheme.outline, CircleShape) + .clickable { pickedImages = pickedImages.filter { image != it } } + ) { + Icon( + Icons.Rounded.Close, + contentDescription = "", + modifier = Modifier.padding(3.dp).size(16.dp), + ) + } + } } } } @@ -220,48 +227,41 @@ fun MessageInputText( IconButton( enabled = !inProgress && !isResettingSession, onClick = { showAddContentMenu = true }, - modifier = Modifier - .offset(x = 16.dp) - .alpha(0.8f) + modifier = Modifier.offset(x = 16.dp).alpha(0.8f), ) { - Icon( - Icons.Rounded.Add, - contentDescription = "", - modifier = Modifier.size(28.dp), - ) + Icon(Icons.Rounded.Add, contentDescription = "", modifier = Modifier.size(28.dp)) } Row( - modifier = Modifier - .fillMaxWidth() - .padding(12.dp) - .border(1.dp, MaterialTheme.colorScheme.outlineVariant, RoundedCornerShape(28.dp)), + modifier = + Modifier.fillMaxWidth() + .padding(12.dp) + .border(1.dp, MaterialTheme.colorScheme.outlineVariant, RoundedCornerShape(28.dp)), verticalAlignment = Alignment.CenterVertically, ) { + val enableAddImageMenuItems = (imageMessageCount + pickedImages.size) < MAX_IMAGE_COUNT DropdownMenu( expanded = showAddContentMenu, - onDismissRequest = { showAddContentMenu = false }) { + onDismissRequest = { showAddContentMenu = false }, + ) { if (showImagePickerInMenu) { // Take a picture. DropdownMenuItem( text = { Row( verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.spacedBy(6.dp) + horizontalArrangement = Arrangement.spacedBy(6.dp), ) { Icon(Icons.Rounded.PhotoCamera, contentDescription = "") Text("Take a picture") } }, - enabled = pickedImages.isEmpty() && !hasImageMessage, + enabled = enableAddImageMenuItems, onClick = { // Check permission when (PackageManager.PERMISSION_GRANTED) { // Already got permission. Call the lambda. - ContextCompat.checkSelfPermission( - context, Manifest.permission.CAMERA - ) -> { + ContextCompat.checkSelfPermission(context, Manifest.permission.CAMERA) -> { showAddContentMenu = false - tempPhotoUri = context.createTempPictureUri() showCameraCaptureBottomSheet = true } @@ -270,75 +270,86 @@ fun MessageInputText( takePicturePermissionLauncher.launch(Manifest.permission.CAMERA) } } - }) + }, + ) // Pick an image from album. DropdownMenuItem( text = { Row( verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.spacedBy(6.dp) + horizontalArrangement = Arrangement.spacedBy(6.dp), ) { Icon(Icons.Rounded.Photo, contentDescription = "") Text("Pick from album") } }, - enabled = pickedImages.isEmpty() && !hasImageMessage, + enabled = enableAddImageMenuItems, onClick = { // Launch the photo picker and let the user choose only images. - pickMedia.launch(PickVisualMediaRequest(ActivityResultContracts.PickVisualMedia.ImageOnly)) + pickMedia.launch( + PickVisualMediaRequest(ActivityResultContracts.PickVisualMedia.ImageOnly) + ) showAddContentMenu = false - }) + }, + ) } // Prompt templates. if (showPromptTemplatesInMenu) { - DropdownMenuItem(text = { - Row( - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.spacedBy(6.dp) - ) { - Icon(Icons.Rounded.PostAdd, contentDescription = "") - Text("Prompt templates") - } - }, onClick = { - onOpenPromptTemplatesClicked() - showAddContentMenu = false - }) + DropdownMenuItem( + text = { + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(6.dp), + ) { + Icon(Icons.Rounded.PostAdd, contentDescription = "") + Text("Prompt templates") + } + }, + onClick = { + onOpenPromptTemplatesClicked() + showAddContentMenu = false + }, + ) } // Prompt history. - DropdownMenuItem(text = { - Row( - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.spacedBy(6.dp) - ) { - Icon(Icons.Rounded.History, contentDescription = "") - Text("Input history") - } - }, onClick = { - showAddContentMenu = false - showTextInputHistorySheet = true - }) + DropdownMenuItem( + text = { + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(6.dp), + ) { + Icon(Icons.Rounded.History, contentDescription = "") + Text("Input history") + } + }, + onClick = { + showAddContentMenu = false + showTextInputHistorySheet = true + }, + ) } // Text field. - TextField(value = curMessage, + TextField( + value = curMessage, minLines = 1, maxLines = 3, onValueChange = onValueChanged, - colors = TextFieldDefaults.colors( - unfocusedContainerColor = Color.Transparent, - focusedContainerColor = Color.Transparent, - focusedIndicatorColor = Color.Transparent, - unfocusedIndicatorColor = Color.Transparent, - disabledIndicatorColor = Color.Transparent, - disabledContainerColor = Color.Transparent, - ), + colors = + TextFieldDefaults.colors( + unfocusedContainerColor = Color.Transparent, + focusedContainerColor = Color.Transparent, + focusedIndicatorColor = Color.Transparent, + unfocusedIndicatorColor = Color.Transparent, + disabledIndicatorColor = Color.Transparent, + disabledContainerColor = Color.Transparent, + ), textStyle = MaterialTheme.typography.bodyLarge, - modifier = Modifier - .weight(1f) - .padding(start = 36.dp), - placeholder = { Text(stringResource(textFieldPlaceHolderRes)) }) + modifier = Modifier.weight(1f).padding(start = 36.dp), + placeholder = { Text(stringResource(textFieldPlaceHolderRes)) }, + ) Spacer(modifier = Modifier.width(8.dp)) @@ -346,12 +357,15 @@ fun MessageInputText( if (!modelInitializing && !modelPreparing) { IconButton( onClick = onStopButtonClicked, - colors = IconButtonDefaults.iconButtonColors( - containerColor = MaterialTheme.colorScheme.secondaryContainer, - ), + colors = + IconButtonDefaults.iconButtonColors( + containerColor = MaterialTheme.colorScheme.secondaryContainer + ), ) { Icon( - Icons.Rounded.Stop, contentDescription = "", tint = MaterialTheme.colorScheme.primary + Icons.Rounded.Stop, + contentDescription = "", + tint = MaterialTheme.colorScheme.primary, ) } } @@ -365,15 +379,16 @@ fun MessageInputText( ) pickedImages = listOf() }, - colors = IconButtonDefaults.iconButtonColors( - containerColor = MaterialTheme.colorScheme.secondaryContainer, - ), + colors = + IconButtonDefaults.iconButtonColors( + containerColor = MaterialTheme.colorScheme.secondaryContainer + ), ) { Icon( Icons.AutoMirrored.Rounded.Send, contentDescription = "", modifier = Modifier.offset(x = 2.dp), - tint = MaterialTheme.colorScheme.primary + tint = MaterialTheme.colorScheme.primary, ) } } @@ -385,40 +400,37 @@ fun MessageInputText( if (showTextInputHistorySheet) { TextInputHistorySheet( history = modelManagerUiState.textInputHistory, - onDismissed = { - showTextInputHistorySheet = false - }, + onDismissed = { showTextInputHistorySheet = false }, onHistoryItemClicked = { item -> onSendMessage(createMessagesToSend(pickedImages = pickedImages, text = item)) pickedImages = listOf() modelManagerViewModel.promoteTextInputHistoryItem(item) }, - onHistoryItemDeleted = { item -> - modelManagerViewModel.deleteTextInputHistory(item) - }, - onHistoryItemsDeleteAll = { - modelManagerViewModel.clearTextInputHistory() - }) + onHistoryItemDeleted = { item -> modelManagerViewModel.deleteTextInputHistory(item) }, + onHistoryItemsDeleteAll = { modelManagerViewModel.clearTextInputHistory() }, + ) } if (showCameraCaptureBottomSheet) { ModalBottomSheet( sheetState = cameraCaptureSheetState, - onDismissRequest = { showCameraCaptureBottomSheet = false }) { - + onDismissRequest = { showCameraCaptureBottomSheet = false }, + ) { val lifecycleOwner = LocalLifecycleOwner.current val previewUseCase = remember { androidx.camera.core.Preview.Builder().build() } val imageCaptureUseCase = remember { // Try to limit the image size. val preferredSize = Size(512, 512) - val resolutionStrategy = ResolutionStrategy( - preferredSize, - ResolutionStrategy.FALLBACK_RULE_CLOSEST_HIGHER_THEN_LOWER - ) - val resolutionSelector = ResolutionSelector.Builder() - .setResolutionStrategy(resolutionStrategy) - .setAspectRatioStrategy(AspectRatioStrategy.RATIO_4_3_FALLBACK_AUTO_STRATEGY) - .build() + val resolutionStrategy = + ResolutionStrategy( + preferredSize, + ResolutionStrategy.FALLBACK_RULE_CLOSEST_HIGHER_THEN_LOWER, + ) + val resolutionSelector = + ResolutionSelector.Builder() + .setResolutionStrategy(resolutionStrategy) + .setAspectRatioStrategy(AspectRatioStrategy.RATIO_4_3_FALLBACK_AUTO_STRATEGY) + .build() ImageCapture.Builder().setResolutionSelector(resolutionSelector).build() } @@ -430,17 +442,16 @@ fun MessageInputText( fun rebindCameraProvider() { cameraProvider?.let { cameraProvider -> - val cameraSelector = CameraSelector.Builder() - .requireLensFacing(cameraSide) - .build() + val cameraSelector = CameraSelector.Builder().requireLensFacing(cameraSide).build() try { cameraProvider.unbindAll() - val camera = cameraProvider.bindToLifecycle( - lifecycleOwner = lifecycleOwner, - cameraSelector = cameraSelector, - previewUseCase, - imageCaptureUseCase - ) + val camera = + cameraProvider.bindToLifecycle( + lifecycleOwner = lifecycleOwner, + cameraSelector = cameraSelector, + previewUseCase, + imageCaptureUseCase, + ) cameraControl = camera.cameraControl } catch (e: Exception) { Log.d(TAG, "Failed to bind camera", e) @@ -453,15 +464,13 @@ fun MessageInputText( rebindCameraProvider() } - LaunchedEffect(cameraSide) { - rebindCameraProvider() - } + LaunchedEffect(cameraSide) { rebindCameraProvider() } DisposableEffect(Unit) { // Or key on lifecycleOwner if it makes more sense onDispose { cameraProvider?.unbindAll() // Unbind all use cases from the camera provider if (!executor.isShutdown) { - executor.shutdown() // Shut down the executor service + executor.shutdown() // Shut down the executor service } } } @@ -485,54 +494,54 @@ fun MessageInputText( cameraCaptureSheetState.hide() showCameraCaptureBottomSheet = false } - }, colors = IconButtonDefaults.iconButtonColors( - containerColor = MaterialTheme.colorScheme.surfaceVariant, - ), modifier = Modifier - .offset(x = (-8).dp, y = 8.dp) - .align(Alignment.TopEnd) + }, + colors = + IconButtonDefaults.iconButtonColors( + containerColor = MaterialTheme.colorScheme.surfaceVariant + ), + modifier = Modifier.offset(x = (-8).dp, y = 8.dp).align(Alignment.TopEnd), ) { Icon( Icons.Rounded.Close, contentDescription = "", - tint = MaterialTheme.colorScheme.primary + tint = MaterialTheme.colorScheme.primary, ) } // Button that triggers the image capture process IconButton( - colors = IconButtonDefaults.iconButtonColors( - containerColor = MaterialTheme.colorScheme.primary, - ), - modifier = Modifier - .align(Alignment.BottomCenter) - .padding(bottom = 32.dp) - .size(64.dp) - .border(2.dp, MaterialTheme.colorScheme.onPrimary, CircleShape), + colors = + IconButtonDefaults.iconButtonColors(containerColor = MaterialTheme.colorScheme.primary), + modifier = + Modifier.align(Alignment.BottomCenter) + .padding(bottom = 32.dp) + .size(64.dp) + .border(2.dp, MaterialTheme.colorScheme.onPrimary, CircleShape), onClick = { - val callback = object : ImageCapture.OnImageCapturedCallback() { - override fun onCaptureSuccess(image: ImageProxy) { - try { - var bitmap = image.toBitmap() - val rotation = image.imageInfo.rotationDegrees - bitmap = if (rotation != 0) { - val matrix = Matrix().apply { - postRotate(rotation.toFloat()) + val callback = + object : ImageCapture.OnImageCapturedCallback() { + override fun onCaptureSuccess(image: ImageProxy) { + try { + var bitmap = image.toBitmap() + val rotation = image.imageInfo.rotationDegrees + bitmap = + if (rotation != 0) { + val matrix = Matrix().apply { postRotate(rotation.toFloat()) } + Log.d(TAG, "image size: ${bitmap.width}, ${bitmap.height}") + Bitmap.createBitmap(bitmap, 0, 0, bitmap.width, bitmap.height, matrix, true) + } else bitmap + updatePickedImages(listOf(bitmap)) + } catch (e: Exception) { + Log.e(TAG, "Failed to process image", e) + } finally { + image.close() + scope.launch { + cameraCaptureSheetState.hide() + showCameraCaptureBottomSheet = false } - Log.d(TAG, "image size: ${bitmap.width}, ${bitmap.height}") - Bitmap.createBitmap(bitmap, 0, 0, bitmap.width, bitmap.height, matrix, true) - } else bitmap - updatePickedImages(bitmap) - } catch (e: Exception) { - Log.e(TAG, "Failed to process image", e) - } finally { - image.close() - scope.launch { - cameraCaptureSheetState.hide() - showCameraCaptureBottomSheet = false } } } - } imageCaptureUseCase.takePicture(executor, callback) }, ) { @@ -540,32 +549,32 @@ fun MessageInputText( Icons.Rounded.PhotoCamera, contentDescription = "", tint = MaterialTheme.colorScheme.onPrimary, - modifier = Modifier.size(36.dp) + modifier = Modifier.size(36.dp), ) } // Button that toggles the front and back camera. if (hasFrontCamera) { IconButton( - colors = IconButtonDefaults.iconButtonColors( - containerColor = MaterialTheme.colorScheme.secondaryContainer, - ), - modifier = Modifier - .align(Alignment.BottomEnd) - .padding(bottom = 40.dp, end = 32.dp) - .size(48.dp), + colors = + IconButtonDefaults.iconButtonColors( + containerColor = MaterialTheme.colorScheme.secondaryContainer + ), + modifier = + Modifier.align(Alignment.BottomEnd).padding(bottom = 40.dp, end = 32.dp).size(48.dp), onClick = { - cameraSide = when (cameraSide) { - CameraSelector.LENS_FACING_BACK -> CameraSelector.LENS_FACING_FRONT - else -> CameraSelector.LENS_FACING_BACK - } + cameraSide = + when (cameraSide) { + CameraSelector.LENS_FACING_BACK -> CameraSelector.LENS_FACING_FRONT + else -> CameraSelector.LENS_FACING_BACK + } }, ) { Icon( Icons.Rounded.FlipCameraAndroid, contentDescription = "", tint = MaterialTheme.colorScheme.onSecondaryContainer, - modifier = Modifier.size(24.dp) + modifier = Modifier.size(24.dp), ) } } @@ -574,25 +583,32 @@ fun MessageInputText( } } -private fun handleImageSelected( +private fun handleImagesSelected( context: Context, - uri: Uri, - onImageSelected: (Bitmap) -> Unit, + uris: List, + onImagesSelected: (List) -> Unit, // For some reason, some Android phone would store the picture taken by the camera rotated // horizontally. Use this flag to rotate the image back to portrait if the picture's width // is bigger than height. rotateForPortrait: Boolean = false, ) { - val bitmap: Bitmap? = try { - val inputStream = context.contentResolver.openInputStream(uri) - val tmpBitmap = BitmapFactory.decodeStream(inputStream) - rotateImageIfNecessary(bitmap = tmpBitmap, rotateForPortrait = rotateForPortrait) - } catch (e: Exception) { - e.printStackTrace() - null + val images: MutableList = mutableListOf() + for (uri in uris) { + val bitmap: Bitmap? = + try { + val inputStream = context.contentResolver.openInputStream(uri) + val tmpBitmap = BitmapFactory.decodeStream(inputStream) + rotateImageIfNecessary(bitmap = tmpBitmap, rotateForPortrait = rotateForPortrait) + } catch (e: Exception) { + e.printStackTrace() + null + } + if (bitmap != null) { + images.add(bitmap) + } } - if (bitmap != null) { - onImageSelected(bitmap) + if (images.isNotEmpty()) { + onImagesSelected(images) } } @@ -608,106 +624,106 @@ private fun rotateImageIfNecessary(bitmap: Bitmap, rotateForPortrait: Boolean = private fun checkFrontCamera(context: Context, callback: (Boolean) -> Unit) { val cameraProviderFuture = ProcessCameraProvider.getInstance(context) - cameraProviderFuture.addListener({ - val cameraProvider = cameraProviderFuture.get() - try { - // Attempt to select the default front camera - val hasFront = cameraProvider.hasCamera(CameraSelector.DEFAULT_FRONT_CAMERA) - callback(hasFront) - } catch (e: Exception) { - e.printStackTrace() - callback(false) - } - }, ContextCompat.getMainExecutor(context)) + cameraProviderFuture.addListener( + { + val cameraProvider = cameraProviderFuture.get() + try { + // Attempt to select the default front camera + val hasFront = cameraProvider.hasCamera(CameraSelector.DEFAULT_FRONT_CAMERA) + callback(hasFront) + } catch (e: Exception) { + e.printStackTrace() + callback(false) + } + }, + ContextCompat.getMainExecutor(context), + ) } private fun createMessagesToSend(pickedImages: List, text: String): List { - val messages: MutableList = mutableListOf() + var messages: MutableList = mutableListOf() if (pickedImages.isNotEmpty()) { - val lastImage = pickedImages.last() - messages.add( - ChatMessageImage( - bitmap = lastImage, imageBitMap = lastImage.asImageBitmap(), side = ChatSide.USER + for (image in pickedImages) { + messages.add( + ChatMessageImage(bitmap = image, imageBitMap = image.asImageBitmap(), side = ChatSide.USER) ) - ) + } } - messages.add( - ChatMessageText( - content = text, side = ChatSide.USER - ) - ) + // Cap the number of image messages. + if (messages.size > MAX_IMAGE_COUNT) { + messages = messages.subList(fromIndex = 0, toIndex = MAX_IMAGE_COUNT) + } + messages.add(ChatMessageText(content = text, side = ChatSide.USER)) return messages } -@Preview(showBackground = true) -@Composable -fun MessageInputTextPreview() { - val context = LocalContext.current - - GalleryTheme { - Column { - MessageInputText( - modelManagerViewModel = PreviewModelManagerViewModel(context = context), - curMessage = "hello", - inProgress = false, - isResettingSession = false, - modelInitializing = false, - hasImageMessage = false, - textFieldPlaceHolderRes = R.string.chat_textinput_placeholder, - onValueChanged = {}, - onSendMessage = {}, - showStopButtonWhenInProgress = true, - showImagePickerInMenu = true, - ) - MessageInputText( - modelManagerViewModel = PreviewModelManagerViewModel(context = context), - curMessage = "hello", - inProgress = false, - isResettingSession = false, - hasImageMessage = false, - modelInitializing = false, - textFieldPlaceHolderRes = R.string.chat_textinput_placeholder, - onValueChanged = {}, - onSendMessage = {}, - showStopButtonWhenInProgress = true, - ) - MessageInputText( - modelManagerViewModel = PreviewModelManagerViewModel(context = context), - curMessage = "hello", - inProgress = true, - isResettingSession = false, - hasImageMessage = false, - modelInitializing = false, - textFieldPlaceHolderRes = R.string.chat_textinput_placeholder, - onValueChanged = {}, - onSendMessage = {}, - ) - MessageInputText( - modelManagerViewModel = PreviewModelManagerViewModel(context = context), - curMessage = "", - inProgress = false, - isResettingSession = false, - hasImageMessage = false, - modelInitializing = false, - textFieldPlaceHolderRes = R.string.chat_textinput_placeholder, - onValueChanged = {}, - onSendMessage = {}, - ) - MessageInputText( - modelManagerViewModel = PreviewModelManagerViewModel(context = context), - curMessage = "", - inProgress = true, - isResettingSession = false, - hasImageMessage = false, - modelInitializing = false, - textFieldPlaceHolderRes = R.string.chat_textinput_placeholder, - onValueChanged = {}, - onSendMessage = {}, - showStopButtonWhenInProgress = true, - ) - } - } -} - +// @Preview(showBackground = true) +// @Composable +// fun MessageInputTextPreview() { +// val context = LocalContext.current +// GalleryTheme { +// Column { +// MessageInputText( +// modelManagerViewModel = PreviewModelManagerViewModel(context = context), +// curMessage = "hello", +// inProgress = false, +// isResettingSession = false, +// modelInitializing = false, +// hasImageMessage = false, +// textFieldPlaceHolderRes = R.string.chat_textinput_placeholder, +// onValueChanged = {}, +// onSendMessage = {}, +// showStopButtonWhenInProgress = true, +// showImagePickerInMenu = true, +// ) +// MessageInputText( +// modelManagerViewModel = PreviewModelManagerViewModel(context = context), +// curMessage = "hello", +// inProgress = false, +// isResettingSession = false, +// hasImageMessage = false, +// modelInitializing = false, +// textFieldPlaceHolderRes = R.string.chat_textinput_placeholder, +// onValueChanged = {}, +// onSendMessage = {}, +// showStopButtonWhenInProgress = true, +// ) +// MessageInputText( +// modelManagerViewModel = PreviewModelManagerViewModel(context = context), +// curMessage = "hello", +// inProgress = true, +// isResettingSession = false, +// hasImageMessage = false, +// modelInitializing = false, +// textFieldPlaceHolderRes = R.string.chat_textinput_placeholder, +// onValueChanged = {}, +// onSendMessage = {}, +// ) +// MessageInputText( +// modelManagerViewModel = PreviewModelManagerViewModel(context = context), +// curMessage = "", +// inProgress = false, +// isResettingSession = false, +// hasImageMessage = false, +// modelInitializing = false, +// textFieldPlaceHolderRes = R.string.chat_textinput_placeholder, +// onValueChanged = {}, +// onSendMessage = {}, +// ) +// MessageInputText( +// modelManagerViewModel = PreviewModelManagerViewModel(context = context), +// curMessage = "", +// inProgress = true, +// isResettingSession = false, +// hasImageMessage = false, +// modelInitializing = false, +// textFieldPlaceHolderRes = R.string.chat_textinput_placeholder, +// onValueChanged = {}, +// onSendMessage = {}, +// showStopButtonWhenInProgress = true, +// ) +// } +// } +// } diff --git a/Android/src/app/src/main/java/com/google/ai/edge/gallery/ui/common/chat/MessageLatency.kt b/Android/src/app/src/main/java/com/google/ai/edge/gallery/ui/common/chat/MessageLatency.kt index 9253765..6104752 100644 --- a/Android/src/app/src/main/java/com/google/ai/edge/gallery/ui/common/chat/MessageLatency.kt +++ b/Android/src/app/src/main/java/com/google/ai/edge/gallery/ui/common/chat/MessageLatency.kt @@ -16,22 +16,17 @@ package com.google.ai.edge.gallery.ui.common.chat -import androidx.compose.foundation.layout.Arrangement -import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.padding +// import androidx.compose.ui.tooling.preview.Preview +// import com.google.ai.edge.gallery.ui.theme.GalleryTheme + import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.ui.Modifier import androidx.compose.ui.draw.alpha -import androidx.compose.ui.tooling.preview.Preview -import androidx.compose.ui.unit.dp import com.google.ai.edge.gallery.ui.common.humanReadableDuration -import com.google.ai.edge.gallery.ui.theme.GalleryTheme -/** - * Composable function to display the latency of a chat message, if available. - */ +/** Composable function to display the latency of a chat message, if available. */ @Composable fun LatencyText(message: ChatMessage) { if (message.latencyMs >= 0) { @@ -43,21 +38,19 @@ fun LatencyText(message: ChatMessage) { } } - -@Preview(showBackground = true) -@Composable -fun LatencyTextPreview() { - GalleryTheme { - Column(modifier = Modifier.padding(16.dp), verticalArrangement = Arrangement.spacedBy(16.dp)) { - for (latencyMs in listOf(123f, 1234f, 123456f, 7234567f)) { - LatencyText( - message = ChatMessage( - latencyMs = latencyMs, - type = ChatMessageType.TEXT, - side = ChatSide.AGENT - ) - ) - } - } - } -} \ No newline at end of file +// @Preview(showBackground = true) +// @Composable +// fun LatencyTextPreview() { +// GalleryTheme { +// Column(modifier = Modifier.padding(16.dp), verticalArrangement = Arrangement.spacedBy(16.dp)) +// { +// for (latencyMs in listOf(123f, 1234f, 123456f, 7234567f)) { +// LatencyText( +// message = +// ChatMessage(latencyMs = latencyMs, type = ChatMessageType.TEXT, side = +// ChatSide.AGENT) +// ) +// } +// } +// } +// } diff --git a/Android/src/app/src/main/java/com/google/ai/edge/gallery/ui/common/chat/MessageSender.kt b/Android/src/app/src/main/java/com/google/ai/edge/gallery/ui/common/chat/MessageSender.kt index ac1375c..474fece 100644 --- a/Android/src/app/src/main/java/com/google/ai/edge/gallery/ui/common/chat/MessageSender.kt +++ b/Android/src/app/src/main/java/com/google/ai/edge/gallery/ui/common/chat/MessageSender.kt @@ -16,9 +16,10 @@ package com.google.ai.edge.gallery.ui.common.chat -import android.graphics.Bitmap +// import androidx.compose.ui.tooling.preview.Preview +// import com.google.ai.edge.gallery.ui.theme.GalleryTheme + import androidx.compose.foundation.layout.Arrangement -import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxWidth @@ -32,40 +33,37 @@ import androidx.compose.runtime.Composable import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.res.stringResource -import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp import com.google.ai.edge.gallery.R -import com.google.ai.edge.gallery.ui.theme.GalleryTheme import com.google.ai.edge.gallery.ui.theme.bodySmallNarrow data class MessageLayoutConfig( val horizontalArrangement: Arrangement.Horizontal, val modifier: Modifier, val userLabel: String, - val rightSideLabel: String + val rightSideLabel: String, ) /** * Composable function to display the sender information for a chat message. * - * This function handles different types of chat messages, including system messages, - * benchmark results, and image generation results, and displays the appropriate sender label - * and status information. + * This function handles different types of chat messages, including system messages, benchmark + * results, and image generation results, and displays the appropriate sender label and status + * information. */ @Composable -fun MessageSender( - message: ChatMessage, - agentName: String = "", - imageHistoryCurIndex: Int = 0 -) { +fun MessageSender(message: ChatMessage, agentName: String = "", imageHistoryCurIndex: Int = 0) { // No user label for system messages. if (message.side == ChatSide.SYSTEM) { return } - val (horizontalArrangement, modifier, userLabel, rightSideLabel) = getMessageLayoutConfig( - message = message, agentName = agentName, imageHistoryCurIndex = imageHistoryCurIndex - ) + val (horizontalArrangement, modifier, userLabel, rightSideLabel) = + getMessageLayoutConfig( + message = message, + agentName = agentName, + imageHistoryCurIndex = imageHistoryCurIndex, + ) Row( modifier = modifier, @@ -74,10 +72,7 @@ fun MessageSender( ) { Row(verticalAlignment = Alignment.CenterVertically) { // Sender label. - Text( - userLabel, - style = MaterialTheme.typography.titleSmall, - ) + Text(userLabel, style = MaterialTheme.typography.titleSmall) when (message) { // Benchmark running status. @@ -87,21 +82,18 @@ fun MessageSender( CircularProgressIndicator( modifier = Modifier.size(10.dp), strokeWidth = 1.5.dp, - color = MaterialTheme.colorScheme.secondary + color = MaterialTheme.colorScheme.secondary, ) Spacer(modifier = Modifier.width(4.dp)) } - val statusLabel = if (message.isWarmingUp()) { - stringResource(R.string.warming_up) - } else if (message.isRunning()) { - stringResource(R.string.running) - } else "" + val statusLabel = + if (message.isWarmingUp()) { + stringResource(R.string.warming_up) + } else if (message.isRunning()) { + stringResource(R.string.running) + } else "" if (statusLabel.isNotEmpty()) { - Text( - statusLabel, - color = MaterialTheme.colorScheme.secondary, - style = bodySmallNarrow, - ) + Text(statusLabel, color = MaterialTheme.colorScheme.secondary, style = bodySmallNarrow) } } @@ -112,7 +104,7 @@ fun MessageSender( CircularProgressIndicator( modifier = Modifier.size(10.dp), strokeWidth = 1.5.dp, - color = MaterialTheme.colorScheme.secondary + color = MaterialTheme.colorScheme.secondary, ) } } @@ -124,7 +116,7 @@ fun MessageSender( CircularProgressIndicator( modifier = Modifier.size(10.dp), strokeWidth = 1.5.dp, - color = MaterialTheme.colorScheme.secondary + color = MaterialTheme.colorScheme.secondary, ) Spacer(modifier = Modifier.width(4.dp)) Text( @@ -141,8 +133,7 @@ fun MessageSender( when (message) { is ChatMessageBenchmarkResult, is ChatMessageImageWithHistory, - is ChatMessageBenchmarkLlmResult, - -> { + is ChatMessageBenchmarkLlmResult -> { Text(rightSideLabel, style = MaterialTheme.typography.bodySmall) } } @@ -169,11 +160,12 @@ private fun getMessageLayoutConfig( horizontalArrangement = Arrangement.SpaceBetween modifier = modifier.fillMaxWidth() userLabel = "Benchmark" - rightSideLabel = if (message.isWarmingUp()) { - "${message.warmupCurrent}/${message.warmupTotal}" - } else { - "${message.iterationCurrent}/${message.iterationTotal}" - } + rightSideLabel = + if (message.isWarmingUp()) { + "${message.warmupCurrent}/${message.warmupTotal}" + } else { + "${message.iterationCurrent}/${message.iterationTotal}" + } } is ChatMessageBenchmarkLlmResult -> { @@ -198,64 +190,68 @@ private fun getMessageLayoutConfig( horizontalArrangement = horizontalArrangement, modifier = modifier, userLabel = userLabel, - rightSideLabel = rightSideLabel + rightSideLabel = rightSideLabel, ) } -@Preview(showBackground = true) -@Composable -fun MessageSenderPreview() { - GalleryTheme { - Column(modifier = Modifier.padding(16.dp), verticalArrangement = Arrangement.spacedBy(16.dp)) { - // Agent message. - MessageSender( - message = ChatMessageText(content = "hello world", side = ChatSide.AGENT), - agentName = stringResource(R.string.chat_generic_agent_name) - ) - // User message. - MessageSender( - message = ChatMessageText(content = "hello world", side = ChatSide.USER), - agentName = stringResource(R.string.chat_generic_agent_name) - ) - // Benchmark during warmup. - MessageSender( - message = ChatMessageBenchmarkResult( - orderedStats = listOf(), - statValues = mutableMapOf(), - values = listOf(), - histogram = Histogram(listOf(), 0), - warmupCurrent = 10, - warmupTotal = 50, - iterationCurrent = 0, - iterationTotal = 200 - ), - agentName = stringResource(R.string.chat_generic_agent_name) - ) - // Benchmark during running. - MessageSender( - message = ChatMessageBenchmarkResult( - orderedStats = listOf(), - statValues = mutableMapOf(), - values = listOf(), - histogram = Histogram(listOf(), 0), - warmupCurrent = 50, - warmupTotal = 50, - iterationCurrent = 123, - iterationTotal = 200 - ), - agentName = stringResource(R.string.chat_generic_agent_name) - ) - // Image generation during running. - MessageSender( - message = ChatMessageImageWithHistory( - bitmaps = listOf(Bitmap.createBitmap(100, 100, Bitmap.Config.ARGB_8888)), - imageBitMaps = listOf(), - totalIterations = 10, - ChatSide.AGENT - ), - agentName = stringResource(R.string.chat_generic_agent_name), - imageHistoryCurIndex = 4, - ) - } - } -} +// @Preview(showBackground = true) +// @Composable +// fun MessageSenderPreview() { +// GalleryTheme { +// Column(modifier = Modifier.padding(16.dp), verticalArrangement = Arrangement.spacedBy(16.dp)) +// { +// // Agent message. +// MessageSender( +// message = ChatMessageText(content = "hello world", side = ChatSide.AGENT), +// agentName = stringResource(R.string.chat_generic_agent_name), +// ) +// // User message. +// MessageSender( +// message = ChatMessageText(content = "hello world", side = ChatSide.USER), +// agentName = stringResource(R.string.chat_generic_agent_name), +// ) +// // Benchmark during warmup. +// MessageSender( +// message = +// ChatMessageBenchmarkResult( +// orderedStats = listOf(), +// statValues = mutableMapOf(), +// values = listOf(), +// histogram = Histogram(listOf(), 0), +// warmupCurrent = 10, +// warmupTotal = 50, +// iterationCurrent = 0, +// iterationTotal = 200, +// ), +// agentName = stringResource(R.string.chat_generic_agent_name), +// ) +// // Benchmark during running. +// MessageSender( +// message = +// ChatMessageBenchmarkResult( +// orderedStats = listOf(), +// statValues = mutableMapOf(), +// values = listOf(), +// histogram = Histogram(listOf(), 0), +// warmupCurrent = 50, +// warmupTotal = 50, +// iterationCurrent = 123, +// iterationTotal = 200, +// ), +// agentName = stringResource(R.string.chat_generic_agent_name), +// ) +// // Image generation during running. +// MessageSender( +// message = +// ChatMessageImageWithHistory( +// bitmaps = listOf(Bitmap.createBitmap(100, 100, Bitmap.Config.ARGB_8888)), +// imageBitMaps = listOf(), +// totalIterations = 10, +// ChatSide.AGENT, +// ), +// agentName = stringResource(R.string.chat_generic_agent_name), +// imageHistoryCurIndex = 4, +// ) +// } +// } +// } diff --git a/Android/src/app/src/main/java/com/google/ai/edge/gallery/ui/common/chat/ModelDownloadStatusInfoPanel.kt b/Android/src/app/src/main/java/com/google/ai/edge/gallery/ui/common/chat/ModelDownloadStatusInfoPanel.kt index 6105ebd..bda8d7a 100644 --- a/Android/src/app/src/main/java/com/google/ai/edge/gallery/ui/common/chat/ModelDownloadStatusInfoPanel.kt +++ b/Android/src/app/src/main/java/com/google/ai/edge/gallery/ui/common/chat/ModelDownloadStatusInfoPanel.kt @@ -45,7 +45,7 @@ import kotlinx.coroutines.delay fun ModelDownloadStatusInfoPanel( model: Model, task: Task, - modelManagerViewModel: ModelManagerViewModel + modelManagerViewModel: ModelManagerViewModel, ) { val modelManagerUiState by modelManagerViewModel.uiState.collectAsState() @@ -62,9 +62,12 @@ fun ModelDownloadStatusInfoPanel( var downloadModelButtonConditionMet by remember { mutableStateOf(false) } downloadingAnimationConditionMet = - curStatus?.status == ModelDownloadStatusType.IN_PROGRESS || curStatus?.status == ModelDownloadStatusType.PARTIALLY_DOWNLOADED || curStatus?.status == ModelDownloadStatusType.UNZIPPING + curStatus?.status == ModelDownloadStatusType.IN_PROGRESS || + curStatus?.status == ModelDownloadStatusType.PARTIALLY_DOWNLOADED || + curStatus?.status == ModelDownloadStatusType.UNZIPPING downloadModelButtonConditionMet = - curStatus?.status == ModelDownloadStatusType.FAILED || curStatus?.status == ModelDownloadStatusType.NOT_DOWNLOADED + curStatus?.status == ModelDownloadStatusType.FAILED || + curStatus?.status == ModelDownloadStatusType.NOT_DOWNLOADED LaunchedEffect(downloadingAnimationConditionMet) { if (downloadingAnimationConditionMet) { @@ -87,24 +90,22 @@ fun ModelDownloadStatusInfoPanel( AnimatedVisibility( visible = shouldShowDownloadingAnimation, enter = scaleIn(initialScale = 0.9f) + fadeIn(), - exit = scaleOut(targetScale = 0.9f) + fadeOut() + exit = scaleOut(targetScale = 0.9f) + fadeOut(), ) { - Box( - modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center - ) { + Box(modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center) { ModelDownloadingAnimation( - model = model, task = task, modelManagerViewModel = modelManagerViewModel + model = model, + task = task, + modelManagerViewModel = modelManagerViewModel, ) } } - AnimatedVisibility( - visible = shouldShowDownloadModelButton, enter = fadeIn(), exit = fadeOut() - ) { + AnimatedVisibility(visible = shouldShowDownloadModelButton, enter = fadeIn(), exit = fadeOut()) { Column( modifier = Modifier.fillMaxSize(), verticalArrangement = Arrangement.Center, - horizontalAlignment = Alignment.CenterHorizontally + horizontalAlignment = Alignment.CenterHorizontally, ) { DownloadAndTryButton( task = task, @@ -112,8 +113,8 @@ fun ModelDownloadStatusInfoPanel( enabled = true, needToDownloadFirst = true, modelManagerViewModel = modelManagerViewModel, - onClicked = {} + onClicked = {}, ) } } -} \ No newline at end of file +} diff --git a/Android/src/app/src/main/java/com/google/ai/edge/gallery/ui/common/chat/ModelDownloadingAnimation.kt b/Android/src/app/src/main/java/com/google/ai/edge/gallery/ui/common/chat/ModelDownloadingAnimation.kt index df6be3e..728f6ef 100644 --- a/Android/src/app/src/main/java/com/google/ai/edge/gallery/ui/common/chat/ModelDownloadingAnimation.kt +++ b/Android/src/app/src/main/java/com/google/ai/edge/gallery/ui/common/chat/ModelDownloadingAnimation.kt @@ -16,6 +16,11 @@ package com.google.ai.edge.gallery.ui.common.chat +// import com.google.ai.edge.gallery.ui.preview.MODEL_TEST1 +// import com.google.ai.edge.gallery.ui.preview.PreviewModelManagerViewModel +// import com.google.ai.edge.gallery.ui.preview.TASK_TEST1 +// import com.google.ai.edge.gallery.ui.theme.GalleryTheme + import androidx.compose.animation.core.Animatable import androidx.compose.animation.core.Easing import androidx.compose.animation.core.tween @@ -47,13 +52,10 @@ import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.ColorFilter import androidx.compose.ui.graphics.graphicsLayer import androidx.compose.ui.layout.ContentScale -import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.res.painterResource import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.text.style.TextOverflow -import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp -import androidx.compose.ui.unit.sp import com.google.ai.edge.gallery.R import com.google.ai.edge.gallery.data.Model import com.google.ai.edge.gallery.data.ModelDownloadStatusType @@ -62,14 +64,10 @@ import com.google.ai.edge.gallery.ui.common.formatToHourMinSecond import com.google.ai.edge.gallery.ui.common.getTaskIconColor import com.google.ai.edge.gallery.ui.common.humanReadableSize import com.google.ai.edge.gallery.ui.modelmanager.ModelManagerViewModel -import com.google.ai.edge.gallery.ui.preview.MODEL_TEST1 -import com.google.ai.edge.gallery.ui.preview.PreviewModelManagerViewModel -import com.google.ai.edge.gallery.ui.preview.TASK_TEST1 -import com.google.ai.edge.gallery.ui.theme.GalleryTheme import com.google.ai.edge.gallery.ui.theme.labelSmallNarrow -import kotlinx.coroutines.delay import kotlin.math.cos import kotlin.math.pow +import kotlinx.coroutines.delay private val GRID_SIZE = 240.dp private val GRID_SPACING = 0.dp @@ -78,7 +76,6 @@ private const val ANIMATION_DURATION = 500 private const val START_SCALE = 0.9f private const val END_SCALE = 0.6f - /** * Composable function to display a loading animation using a 2x2 grid of images with a synchronized * scaling and rotation effect. @@ -87,7 +84,7 @@ private const val END_SCALE = 0.6f fun ModelDownloadingAnimation( model: Model, task: Task, - modelManagerViewModel: ModelManagerViewModel + modelManagerViewModel: ModelManagerViewModel, ) { val scale = remember { Animatable(END_SCALE) } val modelManagerUiState by modelManagerViewModel.uiState.collectAsState() @@ -103,26 +100,27 @@ fun ModelDownloadingAnimation( // Phase 1: Scale up scale.animateTo( targetValue = START_SCALE, - animationSpec = tween( - durationMillis = ANIMATION_DURATION, - easing = multiBounceEasing(bounces = 3, decay = 0.02f) - ) + animationSpec = + tween( + durationMillis = ANIMATION_DURATION, + easing = multiBounceEasing(bounces = 3, decay = 0.02f), + ), ) delay(PAUSE_DURATION.toLong()) // Phase 2: Scale down scale.animateTo( targetValue = END_SCALE, - animationSpec = tween( - durationMillis = ANIMATION_DURATION, - easing = multiBounceEasing(bounces = 3, decay = 0.02f) - ) + animationSpec = + tween( + durationMillis = ANIMATION_DURATION, + easing = multiBounceEasing(bounces = 3, decay = 0.02f), + ), ) delay(PAUSE_DURATION.toLong()) } } - // Failure message. val curDownloadStatus = downloadStatus if (curDownloadStatus != null && curDownloadStatus.status == ModelDownloadStatusType.FAILED) { @@ -139,58 +137,55 @@ fun ModelDownloadingAnimation( else { Column( horizontalAlignment = Alignment.CenterHorizontally, - modifier = Modifier.offset(y = -GRID_SIZE / 8) + modifier = Modifier.offset(y = -GRID_SIZE / 8), ) { LazyVerticalGrid( columns = GridCells.Fixed(2), horizontalArrangement = Arrangement.spacedBy(GRID_SPACING), verticalArrangement = Arrangement.spacedBy(GRID_SPACING), - modifier = Modifier - .width(GRID_SIZE) - .height(GRID_SIZE) + modifier = Modifier.width(GRID_SIZE).height(GRID_SIZE), ) { itemsIndexed( listOf( R.drawable.pantegon, R.drawable.double_circle, R.drawable.circle, - R.drawable.four_circle + R.drawable.four_circle, ) ) { index, imageResource -> val currentScale = if (index == 0 || index == 3) scale.value else START_SCALE + END_SCALE - scale.value Box( - modifier = Modifier - .width((GRID_SIZE - GRID_SPACING) / 2) - .height((GRID_SIZE - GRID_SPACING) / 2), - contentAlignment = when (index) { - 0 -> Alignment.BottomEnd - 1 -> Alignment.BottomStart - 2 -> Alignment.TopEnd - 3 -> Alignment.TopStart - else -> Alignment.Center - } + modifier = + Modifier.width((GRID_SIZE - GRID_SPACING) / 2).height((GRID_SIZE - GRID_SPACING) / 2), + contentAlignment = + when (index) { + 0 -> Alignment.BottomEnd + 1 -> Alignment.BottomStart + 2 -> Alignment.TopEnd + 3 -> Alignment.TopStart + else -> Alignment.Center + }, ) { Image( painter = painterResource(id = imageResource), contentDescription = "", contentScale = ContentScale.Fit, colorFilter = ColorFilter.tint(getTaskIconColor(index = index)), - modifier = Modifier - .graphicsLayer { - scaleX = currentScale - scaleY = currentScale - rotationZ = currentScale * 120 - alpha = 0.8f - } - .size(70.dp) + modifier = + Modifier.graphicsLayer { + scaleX = currentScale + scaleY = currentScale + rotationZ = currentScale * 120 + alpha = 0.8f + } + .size(70.dp), ) } } } - // Download stats var sizeLabel = model.totalBytes.humanReadableSize() if (curDownloadStatus != null) { @@ -203,8 +198,7 @@ fun ModelDownloadingAnimation( sizeLabel = "${curDownloadStatus.receivedBytes.humanReadableSize(extraDecimalForGbAndAbove = true)} of ${totalSize.humanReadableSize()}" if (curDownloadStatus.bytesPerSecond > 0) { - sizeLabel = - "$sizeLabel · ${curDownloadStatus.bytesPerSecond.humanReadableSize()} / s" + sizeLabel = "$sizeLabel · ${curDownloadStatus.bytesPerSecond.humanReadableSize()} / s" if (curDownloadStatus.remainingMs >= 0) { sizeLabel = "$sizeLabel · ${curDownloadStatus.remainingMs.formatToHourMinSecond()} left" @@ -229,8 +223,7 @@ fun ModelDownloadingAnimation( style = MaterialTheme.typography.labelMedium, textAlign = TextAlign.Center, overflow = TextOverflow.Visible, - modifier = Modifier - .padding(bottom = 4.dp) + modifier = Modifier.padding(bottom = 4.dp), ) } @@ -241,10 +234,7 @@ fun ModelDownloadingAnimation( progress = { animatedProgress.value }, color = getTaskIconColor(task = task), trackColor = MaterialTheme.colorScheme.surfaceContainerHighest, - modifier = Modifier - .fillMaxWidth() - .padding(bottom = 36.dp) - .padding(horizontal = 36.dp) + modifier = Modifier.fillMaxWidth().padding(bottom = 36.dp).padding(horizontal = 36.dp), ) LaunchedEffect(curDownloadProgress) { animatedProgress.animateTo(curDownloadProgress, animationSpec = tween(150)) @@ -255,23 +245,19 @@ fun ModelDownloadingAnimation( LinearProgressIndicator( color = getTaskIconColor(task = task), trackColor = MaterialTheme.colorScheme.surfaceContainerHighest, - modifier = Modifier - .fillMaxWidth() - .padding(bottom = 36.dp) - .padding(horizontal = 36.dp) + modifier = Modifier.fillMaxWidth().padding(bottom = 36.dp).padding(horizontal = 36.dp), ) } Text( - "Feel free to switch apps or lock your device.\n" - + "The download will continue in the background.\n" - + "We'll send a notification when it's done.", + "Feel free to switch apps or lock your device.\n" + + "The download will continue in the background.\n" + + "We'll send a notification when it's done.", style = MaterialTheme.typography.bodyLarge, - textAlign = TextAlign.Center + textAlign = TextAlign.Center, ) } } - } // Custom Easing function for a multi-bounce effect @@ -283,18 +269,18 @@ fun multiBounceEasing(bounces: Int, decay: Float): Easing = Easing { x -> } } -@Preview(showBackground = true) -@Composable -fun ModelDownloadingAnimationPreview() { - val context = LocalContext.current +// @Preview(showBackground = true) +// @Composable +// fun ModelDownloadingAnimationPreview() { +// val context = LocalContext.current - GalleryTheme { - Row(modifier = Modifier.padding(16.dp)) { - ModelDownloadingAnimation( - model = MODEL_TEST1, - task = TASK_TEST1, - modelManagerViewModel = PreviewModelManagerViewModel(context = context) - ) - } - } -} \ No newline at end of file +// GalleryTheme { +// Row(modifier = Modifier.padding(16.dp)) { +// ModelDownloadingAnimation( +// model = MODEL_TEST1, +// task = TASK_TEST1, +// modelManagerViewModel = PreviewModelManagerViewModel(context = context), +// ) +// } +// } +// } diff --git a/Android/src/app/src/main/java/com/google/ai/edge/gallery/ui/common/chat/ModelInitializationStatus.kt b/Android/src/app/src/main/java/com/google/ai/edge/gallery/ui/common/chat/ModelInitializationStatus.kt index 4b5439f..ad1e855 100644 --- a/Android/src/app/src/main/java/com/google/ai/edge/gallery/ui/common/chat/ModelInitializationStatus.kt +++ b/Android/src/app/src/main/java/com/google/ai/edge/gallery/ui/common/chat/ModelInitializationStatus.kt @@ -16,6 +16,9 @@ package com.google.ai.edge.gallery.ui.common.chat +// import androidx.compose.ui.tooling.preview.Preview +// import com.google.ai.edge.gallery.ui.theme.GalleryTheme + import androidx.compose.foundation.background import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box @@ -34,37 +37,35 @@ import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip import androidx.compose.ui.res.stringResource -import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp import com.google.ai.edge.gallery.R -import com.google.ai.edge.gallery.ui.theme.GalleryTheme /** * Composable function to display a visual indicator for model initialization status. * - * This function renders a row containing a circular progress indicator and a message - * indicating that the model is currently initializing. It provides a visual cue to the - * user that the model is in a loading state. + * This function renders a row containing a circular progress indicator and a message indicating + * that the model is currently initializing. It provides a visual cue to the user that the model is + * in a loading state. */ @Composable fun ModelInitializationStatusChip() { Row(modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.Center) { Box( - modifier = Modifier - .padding(8.dp) - .clip(CircleShape) - .background(MaterialTheme.colorScheme.secondaryContainer) + modifier = + Modifier.padding(8.dp) + .clip(CircleShape) + .background(MaterialTheme.colorScheme.secondaryContainer) ) { Row( modifier = Modifier.padding(top = 4.dp, bottom = 4.dp, start = 8.dp, end = 8.dp), horizontalArrangement = Arrangement.Center, - verticalAlignment = Alignment.CenterVertically + verticalAlignment = Alignment.CenterVertically, ) { // Circular progress indicator. CircularProgressIndicator( modifier = Modifier.size(14.dp), strokeWidth = 2.dp, - color = MaterialTheme.colorScheme.onSecondaryContainer + color = MaterialTheme.colorScheme.onSecondaryContainer, ) Spacer(modifier = Modifier.width(8.dp)) @@ -80,10 +81,8 @@ fun ModelInitializationStatusChip() { } } -@Preview(showBackground = true) -@Composable -fun ModelInitializationStatusPreview() { - GalleryTheme { - ModelInitializationStatusChip() - } -} +// @Preview(showBackground = true) +// @Composable +// fun ModelInitializationStatusPreview() { +// GalleryTheme { ModelInitializationStatusChip() } +// } diff --git a/Android/src/app/src/main/java/com/google/ai/edge/gallery/ui/common/chat/ModelNotDownloaded.kt b/Android/src/app/src/main/java/com/google/ai/edge/gallery/ui/common/chat/ModelNotDownloaded.kt index d6e3ad7..4b445fd 100644 --- a/Android/src/app/src/main/java/com/google/ai/edge/gallery/ui/common/chat/ModelNotDownloaded.kt +++ b/Android/src/app/src/main/java/com/google/ai/edge/gallery/ui/common/chat/ModelNotDownloaded.kt @@ -16,6 +16,9 @@ package com.google.ai.edge.gallery.ui.common.chat +// import androidx.compose.ui.tooling.preview.Preview +// import com.google.ai.edge.gallery.ui.theme.GalleryTheme + import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.fillMaxSize @@ -24,8 +27,6 @@ import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier -import androidx.compose.ui.tooling.preview.Preview -import com.google.ai.edge.gallery.ui.theme.GalleryTheme /** * Composable function to display a button to download model if the model has not been downloaded. @@ -35,20 +36,14 @@ fun ModelNotDownloaded(modifier: Modifier = Modifier, onClicked: () -> Unit) { Column( modifier = modifier.fillMaxSize(), verticalArrangement = Arrangement.Center, - horizontalAlignment = Alignment.CenterHorizontally + horizontalAlignment = Alignment.CenterHorizontally, ) { - Button( - onClick = onClicked, - ) { - Text("Download & Try it", maxLines = 1) - } + Button(onClick = onClicked) { Text("Download & Try it", maxLines = 1) } } } -@Preview(showBackground = true) -@Composable -fun Preview() { - GalleryTheme { - ModelNotDownloaded(onClicked = {}) - } -} \ No newline at end of file +// @Preview(showBackground = true) +// @Composable +// fun Preview() { +// GalleryTheme { ModelNotDownloaded(onClicked = {}) } +// } diff --git a/Android/src/app/src/main/java/com/google/ai/edge/gallery/ui/common/chat/ModelSelector.kt b/Android/src/app/src/main/java/com/google/ai/edge/gallery/ui/common/chat/ModelSelector.kt index 060b4c2..9ea5974 100644 --- a/Android/src/app/src/main/java/com/google/ai/edge/gallery/ui/common/chat/ModelSelector.kt +++ b/Android/src/app/src/main/java/com/google/ai/edge/gallery/ui/common/chat/ModelSelector.kt @@ -16,7 +16,12 @@ package com.google.ai.edge.gallery.ui.common.chat -import androidx.compose.foundation.layout.Arrangement +// import androidx.compose.ui.tooling.preview.Preview +// import com.google.ai.edge.gallery.ui.preview.PreviewModelManagerViewModel +// import com.google.ai.edge.gallery.ui.preview.TASK_TEST1 +// import com.google.ai.edge.gallery.ui.preview.TASK_TEST2 +// import com.google.ai.edge.gallery.ui.theme.GalleryTheme + import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row @@ -31,17 +36,13 @@ import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.graphicsLayer import androidx.compose.ui.platform.LocalContext -import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp import com.google.ai.edge.gallery.data.Model import com.google.ai.edge.gallery.data.Task -import com.google.ai.edge.gallery.ui.common.convertValueToTargetType +import com.google.ai.edge.gallery.data.convertValueToTargetType +import com.google.ai.edge.gallery.ui.common.ConfigDialog import com.google.ai.edge.gallery.ui.common.modelitem.ModelItem import com.google.ai.edge.gallery.ui.modelmanager.ModelManagerViewModel -import com.google.ai.edge.gallery.ui.preview.PreviewModelManagerViewModel -import com.google.ai.edge.gallery.ui.preview.TASK_TEST1 -import com.google.ai.edge.gallery.ui.preview.TASK_TEST2 -import com.google.ai.edge.gallery.ui.theme.GalleryTheme /** * Composable function to display a selectable model item with an option to configure its settings. @@ -53,39 +54,31 @@ fun ModelSelector( modelManagerViewModel: ModelManagerViewModel, modifier: Modifier = Modifier, contentAlpha: Float = 1f, - onConfigChanged: (oldConfigValues: Map, newConfigValues: Map) -> Unit = { _, _ -> }, + onConfigChanged: (oldConfigValues: Map, newConfigValues: Map) -> Unit = + { _, _ -> + }, ) { var showConfigDialog by remember { mutableStateOf(false) } val context = LocalContext.current - Column( - modifier = modifier - ) { + Column(modifier = modifier) { Box( - modifier = Modifier - .fillMaxWidth() - .padding(bottom = 8.dp), - contentAlignment = Alignment.Center + modifier = Modifier.fillMaxWidth().padding(bottom = 8.dp), + contentAlignment = Alignment.Center, ) { // Model row. Row( - modifier = Modifier - .fillMaxWidth() - .graphicsLayer { alpha = contentAlpha }, - verticalAlignment = Alignment.CenterVertically + modifier = Modifier.fillMaxWidth().graphicsLayer { alpha = contentAlpha }, + verticalAlignment = Alignment.CenterVertically, ) { ModelItem( model = model, task = task, modelManagerViewModel = modelManagerViewModel, onModelClicked = {}, - onConfigClicked = { - showConfigDialog = true - }, + onConfigClicked = { showConfigDialog = true }, verticalSpacing = 10.dp, - modifier = Modifier - .weight(1f) - .padding(horizontal = 16.dp), + modifier = Modifier.weight(1f).padding(horizontal = 16.dp), showDeleteButton = false, showConfigButtonIfExisted = true, canExpand = false, @@ -111,12 +104,16 @@ fun ModelSelector( var needReinitialization = false for (config in model.configs) { val key = config.key.label - val oldValue = convertValueToTargetType( - value = model.configValues.getValue(key), valueType = config.valueType - ) - val newValue = convertValueToTargetType( - value = curConfigValues.getValue(key), valueType = config.valueType - ) + val oldValue = + convertValueToTargetType( + value = model.configValues.getValue(key), + valueType = config.valueType, + ) + val newValue = + convertValueToTargetType( + value = curConfigValues.getValue(key), + valueType = config.valueType, + ) if (oldValue != newValue) { same = false if (config.needReinitialization) { @@ -139,7 +136,7 @@ fun ModelSelector( context = context, task = task, model = model, - force = true + force = true, ) } @@ -150,27 +147,26 @@ fun ModelSelector( } } -@Preview(showBackground = true) -@Composable -fun ModelSelectorPreview( -) { - GalleryTheme { - Column(verticalArrangement = Arrangement.spacedBy(16.dp)) { - ModelSelector( - model = TASK_TEST1.models[0], - task = TASK_TEST1, - modelManagerViewModel = PreviewModelManagerViewModel(context = LocalContext.current), - ) - ModelSelector( - model = TASK_TEST1.models[1], - task = TASK_TEST1, - modelManagerViewModel = PreviewModelManagerViewModel(context = LocalContext.current), - ) - ModelSelector( - model = TASK_TEST2.models[1], - task = TASK_TEST2, - modelManagerViewModel = PreviewModelManagerViewModel(context = LocalContext.current), - ) - } - } -} +// @Preview(showBackground = true) +// @Composable +// fun ModelSelectorPreview() { +// GalleryTheme { +// Column(verticalArrangement = Arrangement.spacedBy(16.dp)) { +// ModelSelector( +// model = TASK_TEST1.models[0], +// task = TASK_TEST1, +// modelManagerViewModel = PreviewModelManagerViewModel(context = LocalContext.current), +// ) +// ModelSelector( +// model = TASK_TEST1.models[1], +// task = TASK_TEST1, +// modelManagerViewModel = PreviewModelManagerViewModel(context = LocalContext.current), +// ) +// ModelSelector( +// model = TASK_TEST2.models[1], +// task = TASK_TEST2, +// modelManagerViewModel = PreviewModelManagerViewModel(context = LocalContext.current), +// ) +// } +// } +// } diff --git a/Android/src/app/src/main/java/com/google/ai/edge/gallery/ui/common/chat/TextInputHistorySheet.kt b/Android/src/app/src/main/java/com/google/ai/edge/gallery/ui/common/chat/TextInputHistorySheet.kt index c864ff1..cf6d240 100644 --- a/Android/src/app/src/main/java/com/google/ai/edge/gallery/ui/common/chat/TextInputHistorySheet.kt +++ b/Android/src/app/src/main/java/com/google/ai/edge/gallery/ui/common/chat/TextInputHistorySheet.kt @@ -16,6 +16,8 @@ package com.google.ai.edge.gallery.ui.common.chat +// import androidx.compose.ui.tooling.preview.Preview +// import com.google.ai.edge.gallery.ui.theme.GalleryTheme import androidx.compose.foundation.background import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.Arrangement @@ -53,10 +55,8 @@ import androidx.compose.ui.draw.clip import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.text.style.TextOverflow -import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp import com.google.ai.edge.gallery.R -import com.google.ai.edge.gallery.ui.theme.GalleryTheme import com.google.ai.edge.gallery.ui.theme.customColors import kotlinx.coroutines.delay import kotlinx.coroutines.launch @@ -68,7 +68,7 @@ fun TextInputHistorySheet( onHistoryItemClicked: (String) -> Unit, onHistoryItemDeleted: (String) -> Unit, onHistoryItemsDeleteAll: () -> Unit, - onDismissed: () -> Unit + onDismissed: () -> Unit, ) { val sheetState = rememberModalBottomSheetState() val scope = rememberCoroutineScope() @@ -101,7 +101,7 @@ fun TextInputHistorySheet( sheetState.hide() onDismissed() } - } + }, ) } } @@ -112,7 +112,7 @@ private fun SheetContent( onHistoryItemClicked: (String) -> Unit, onHistoryItemDeleted: (String) -> Unit, onHistoryItemsDeleteAll: () -> Unit, - onDismissed: () -> Unit + onDismissed: () -> Unit, ) { val scope = rememberCoroutineScope() var showConfirmDeleteDialog by remember { mutableStateOf(false) } @@ -122,47 +122,44 @@ private fun SheetContent( Text( "Text input history", style = MaterialTheme.typography.titleLarge, - modifier = Modifier - .fillMaxWidth() - .padding(8.dp), - textAlign = TextAlign.Center + modifier = Modifier.fillMaxWidth().padding(8.dp), + textAlign = TextAlign.Center, ) - IconButton(modifier = Modifier.padding(end = 12.dp), onClick = { - showConfirmDeleteDialog = true - }) { + IconButton( + modifier = Modifier.padding(end = 12.dp), + onClick = { showConfirmDeleteDialog = true }, + ) { Icon(Icons.Rounded.DeleteSweep, contentDescription = "") } } LazyColumn(modifier = Modifier.weight(1f)) { items(history, key = { it }) { item -> Row( - modifier = Modifier - .fillMaxWidth() - .padding(horizontal = 8.dp, vertical = 2.dp) - .clip(RoundedCornerShape(24.dp)) - .background(MaterialTheme.customColors.agentBubbleBgColor) - .clickable { - onHistoryItemClicked(item) - }, + modifier = + Modifier.fillMaxWidth() + .padding(horizontal = 8.dp, vertical = 2.dp) + .clip(RoundedCornerShape(24.dp)) + .background(MaterialTheme.customColors.agentBubbleBgColor) + .clickable { onHistoryItemClicked(item) }, verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.spacedBy(8.dp) + horizontalArrangement = Arrangement.spacedBy(8.dp), ) { Text( item, style = MaterialTheme.typography.bodyMedium, maxLines = 3, overflow = TextOverflow.Ellipsis, - modifier = Modifier - .padding(vertical = 16.dp) - .padding(start = 16.dp) - .weight(1f) + modifier = Modifier.padding(vertical = 16.dp).padding(start = 16.dp).weight(1f), ) - IconButton(modifier = Modifier.padding(end = 8.dp), onClick = { - scope.launch { - delay(400) - onHistoryItemDeleted(item) - } - }) { + IconButton( + modifier = Modifier.padding(end = 8.dp), + onClick = { + scope.launch { + delay(400) + onHistoryItemDeleted(item) + } + }, + ) { Icon(Icons.Rounded.Delete, contentDescription = "") } } @@ -171,18 +168,17 @@ private fun SheetContent( } if (showConfirmDeleteDialog) { - AlertDialog(onDismissRequest = { showConfirmDeleteDialog = false }, + AlertDialog( + onDismissRequest = { showConfirmDeleteDialog = false }, title = { Text("Clear history?") }, - text = { - Text( - "Are you sure you want to clear the history? This action cannot be undone." - ) - }, + text = { Text("Are you sure you want to clear the history? This action cannot be undone.") }, confirmButton = { - Button(onClick = { - showConfirmDeleteDialog = false - onHistoryItemsDeleteAll() - }) { + Button( + onClick = { + showConfirmDeleteDialog = false + onHistoryItemsDeleteAll() + } + ) { Text(stringResource(R.string.ok)) } }, @@ -190,25 +186,29 @@ private fun SheetContent( TextButton(onClick = { showConfirmDeleteDialog = false }) { Text(stringResource(R.string.cancel)) } - }) + }, + ) } } -@Preview(showBackground = true) -@Composable -fun TextInputHistorySheetContentPreview() { - GalleryTheme { - SheetContent( - history = listOf( - "Analyze the sentiment of the following Tweets and classify them as POSITIVE, NEGATIVE, or NEUTRAL. \"It's so beautiful today!\"", - "I have the ingredients above. Not sure what to cook for lunch. Show me a list of foods with the recipes.", - "You are Santa Claus, write a letter back for this kid.", - "Generate a list of cookie recipes. Make the outputs in JSON format." - ), - onHistoryItemClicked = {}, - onHistoryItemDeleted = {}, - onHistoryItemsDeleteAll = {}, - onDismissed = {}, - ) - } -} \ No newline at end of file +// @Preview(showBackground = true) +// @Composable +// fun TextInputHistorySheetContentPreview() { +// GalleryTheme { +// SheetContent( +// history = +// listOf( +// "Analyze the sentiment of the following Tweets and classify them as POSITIVE, NEGATIVE, +// or NEUTRAL. \"It's so beautiful today!\"", +// "I have the ingredients above. Not sure what to cook for lunch. Show me a list of foods +// with the recipes.", +// "You are Santa Claus, write a letter back for this kid.", +// "Generate a list of cookie recipes. Make the outputs in JSON format.", +// ), +// onHistoryItemClicked = {}, +// onHistoryItemDeleted = {}, +// onHistoryItemsDeleteAll = {}, +// onDismissed = {}, +// ) +// } +// } diff --git a/Android/src/app/src/main/java/com/google/ai/edge/gallery/ui/common/chat/ZoomableBox.kt b/Android/src/app/src/main/java/com/google/ai/edge/gallery/ui/common/chat/ZoomableBox.kt index 81b2a1a..ac9a192 100644 --- a/Android/src/app/src/main/java/com/google/ai/edge/gallery/ui/common/chat/ZoomableBox.kt +++ b/Android/src/app/src/main/java/com/google/ai/edge/gallery/ui/common/chat/ZoomableBox.kt @@ -35,25 +35,28 @@ fun ZoomableBox( modifier: Modifier = Modifier, minScale: Float = 1f, maxScale: Float = 5f, - content: @Composable ZoomableBoxScope.() -> Unit + content: @Composable ZoomableBoxScope.() -> Unit, ) { var scale by remember { mutableFloatStateOf(1f) } var offsetX by remember { mutableFloatStateOf(0f) } var offsetY by remember { mutableFloatStateOf(0f) } var size by remember { mutableStateOf(IntSize.Zero) } - Box(modifier = modifier - .onSizeChanged { size = it } - .pointerInput(Unit) { - detectTransformGestures { _, pan, zoom, _ -> - scale = maxOf(minScale, minOf(scale * zoom, maxScale)) - val maxX = (size.width * (scale - 1)) / 2 - val minX = -maxX - offsetX = maxOf(minX, minOf(maxX, offsetX + pan.x)) - val maxY = (size.height * (scale - 1)) / 2 - val minY = -maxY - offsetY = maxOf(minY, minOf(maxY, offsetY + pan.y)) - } - }, contentAlignment = Alignment.TopEnd + Box( + modifier = + modifier + .onSizeChanged { size = it } + .pointerInput(Unit) { + detectTransformGestures { _, pan, zoom, _ -> + scale = maxOf(minScale, minOf(scale * zoom, maxScale)) + val maxX = (size.width * (scale - 1)) / 2 + val minX = -maxX + offsetX = maxOf(minX, minOf(maxX, offsetX + pan.x)) + val maxY = (size.height * (scale - 1)) / 2 + val minY = -maxY + offsetY = maxOf(minY, minOf(maxY, offsetY + pan.y)) + } + }, + contentAlignment = Alignment.TopEnd, ) { val scope = ZoomableBoxScopeImpl(scale, offsetX, offsetY) scope.content() @@ -67,5 +70,7 @@ interface ZoomableBoxScope { } private data class ZoomableBoxScopeImpl( - override val scale: Float, override val offsetX: Float, override val offsetY: Float + override val scale: Float, + override val offsetX: Float, + override val offsetY: Float, ) : ZoomableBoxScope diff --git a/Android/src/app/src/main/java/com/google/ai/edge/gallery/ui/common/modelitem/AnimatedLayoutModifier.kt b/Android/src/app/src/main/java/com/google/ai/edge/gallery/ui/common/modelitem/AnimatedLayoutModifier.kt deleted file mode 100644 index c3d05f6..0000000 --- a/Android/src/app/src/main/java/com/google/ai/edge/gallery/ui/common/modelitem/AnimatedLayoutModifier.kt +++ /dev/null @@ -1,73 +0,0 @@ -/* - * Copyright 2025 Google LLC - * - * 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 - * - * http://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.google.ai.edge.gallery.ui.common.modelitem - -import androidx.compose.animation.core.DeferredTargetAnimation -import androidx.compose.animation.core.ExperimentalAnimatableApi -import androidx.compose.animation.core.VectorConverter -import androidx.compose.animation.core.tween -import androidx.compose.runtime.remember -import androidx.compose.runtime.rememberCoroutineScope -import androidx.compose.ui.Modifier -import androidx.compose.ui.composed -import androidx.compose.ui.geometry.Offset -import androidx.compose.ui.layout.LookaheadScope -import androidx.compose.ui.layout.approachLayout -import androidx.compose.ui.unit.Constraints -import androidx.compose.ui.unit.IntOffset -import androidx.compose.ui.unit.IntSize -import androidx.compose.ui.unit.round - -const val LAYOUT_ANIMATION_DURATION = 250 - -context(LookaheadScope) -@OptIn(ExperimentalAnimatableApi::class) -fun Modifier.animateLayout(): Modifier = composed { - val sizeAnim = remember { DeferredTargetAnimation(IntSize.VectorConverter) } - val offsetAnim = remember { DeferredTargetAnimation(IntOffset.VectorConverter) } - val scope = rememberCoroutineScope() - - this.approachLayout( - isMeasurementApproachInProgress = { lookaheadSize -> - sizeAnim.updateTarget(lookaheadSize, scope, tween(LAYOUT_ANIMATION_DURATION)) - !sizeAnim.isIdle - }, - isPlacementApproachInProgress = { lookaheadCoordinates -> - val target = lookaheadScopeCoordinates.localLookaheadPositionOf(lookaheadCoordinates) - offsetAnim.updateTarget(target.round(), scope, tween(LAYOUT_ANIMATION_DURATION)) - !offsetAnim.isIdle - } - ) { measurable, _ -> - val (animWidth, animHeight) = sizeAnim.updateTarget( - lookaheadSize, - scope, - tween(LAYOUT_ANIMATION_DURATION) - ) - measurable.measure(Constraints.fixed(animWidth, animHeight)) - .run { - layout(width, height) { - coordinates?.let { - val target = lookaheadScopeCoordinates.localLookaheadPositionOf(it).round() - val animOffset = offsetAnim.updateTarget(target, scope, tween(LAYOUT_ANIMATION_DURATION)) - val current = lookaheadScopeCoordinates.localPositionOf(it, Offset.Zero).round() - val (x, y) = animOffset - current - place(x, y) - } ?: place(0, 0) - } - } - } -} \ No newline at end of file diff --git a/Android/src/app/src/main/java/com/google/ai/edge/gallery/ui/common/modelitem/ConfirmDeleteModelDialog.kt b/Android/src/app/src/main/java/com/google/ai/edge/gallery/ui/common/modelitem/ConfirmDeleteModelDialog.kt index 5143896..4a47003 100644 --- a/Android/src/app/src/main/java/com/google/ai/edge/gallery/ui/common/modelitem/ConfirmDeleteModelDialog.kt +++ b/Android/src/app/src/main/java/com/google/ai/edge/gallery/ui/common/modelitem/ConfirmDeleteModelDialog.kt @@ -25,28 +25,16 @@ import androidx.compose.ui.res.stringResource import com.google.ai.edge.gallery.R import com.google.ai.edge.gallery.data.Model -/** - * Composable function to display a confirmation dialog for deleting a model. - */ +/** Composable function to display a confirmation dialog for deleting a model. */ @Composable fun ConfirmDeleteModelDialog(model: Model, onConfirm: () -> Unit, onDismiss: () -> Unit) { - AlertDialog(onDismissRequest = onDismiss, + AlertDialog( + onDismissRequest = onDismiss, title = { Text(stringResource(R.string.confirm_delete_model_dialog_title)) }, text = { - Text( - stringResource(R.string.confirm_delete_model_dialog_content).format( - model.name - ) - ) + Text(stringResource(R.string.confirm_delete_model_dialog_content).format(model.name)) }, - confirmButton = { - Button(onClick = onConfirm) { - Text(stringResource(R.string.ok)) - } - }, - dismissButton = { - TextButton(onClick = onDismiss) { - Text(stringResource(R.string.cancel)) - } - }) + confirmButton = { Button(onClick = onConfirm) { Text(stringResource(R.string.ok)) } }, + dismissButton = { TextButton(onClick = onDismiss) { Text(stringResource(R.string.cancel)) } }, + ) } diff --git a/Android/src/app/src/main/java/com/google/ai/edge/gallery/ui/common/modelitem/ModelItem.kt b/Android/src/app/src/main/java/com/google/ai/edge/gallery/ui/common/modelitem/ModelItem.kt index fe5ed42..9ad68e1 100644 --- a/Android/src/app/src/main/java/com/google/ai/edge/gallery/ui/common/modelitem/ModelItem.kt +++ b/Android/src/app/src/main/java/com/google/ai/edge/gallery/ui/common/modelitem/ModelItem.kt @@ -16,8 +16,17 @@ package com.google.ai.edge.gallery.ui.common.modelitem +// import androidx.compose.ui.tooling.preview.Preview +// import com.google.ai.edge.gallery.ui.preview.MODEL_TEST1 +// import com.google.ai.edge.gallery.ui.preview.MODEL_TEST2 +// import com.google.ai.edge.gallery.ui.preview.MODEL_TEST3 +// import com.google.ai.edge.gallery.ui.preview.MODEL_TEST4 +// import com.google.ai.edge.gallery.ui.preview.PreviewModelManagerViewModel +// import com.google.ai.edge.gallery.ui.preview.TASK_TEST1 +// import com.google.ai.edge.gallery.ui.preview.TASK_TEST2 +// import com.google.ai.edge.gallery.ui.theme.GalleryTheme + import android.content.Intent -import android.net.Uri import androidx.activity.compose.rememberLauncherForActivityResult import androidx.activity.result.contract.ActivityResultContracts import androidx.compose.animation.AnimatedContent @@ -52,37 +61,28 @@ import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip import androidx.compose.ui.platform.LocalContext -import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.dp +import androidx.core.net.toUri import com.google.ai.edge.gallery.data.Model import com.google.ai.edge.gallery.data.ModelDownloadStatusType import com.google.ai.edge.gallery.data.Task import com.google.ai.edge.gallery.ui.common.DownloadAndTryButton +import com.google.ai.edge.gallery.ui.common.MarkdownText import com.google.ai.edge.gallery.ui.common.TaskIcon -import com.google.ai.edge.gallery.ui.common.chat.MarkdownText import com.google.ai.edge.gallery.ui.common.checkNotificationPermissionAndStartDownload import com.google.ai.edge.gallery.ui.common.getTaskBgColor import com.google.ai.edge.gallery.ui.common.getTaskIconColor import com.google.ai.edge.gallery.ui.modelmanager.ModelManagerViewModel -import com.google.ai.edge.gallery.ui.preview.MODEL_TEST1 -import com.google.ai.edge.gallery.ui.preview.MODEL_TEST2 -import com.google.ai.edge.gallery.ui.preview.MODEL_TEST3 -import com.google.ai.edge.gallery.ui.preview.MODEL_TEST4 -import com.google.ai.edge.gallery.ui.preview.PreviewModelManagerViewModel -import com.google.ai.edge.gallery.ui.preview.TASK_TEST1 -import com.google.ai.edge.gallery.ui.preview.TASK_TEST2 -import com.google.ai.edge.gallery.ui.theme.GalleryTheme private val DEFAULT_VERTICAL_PADDING = 16.dp /** * Composable function to display a model item in the model manager list. * - * This function renders a card representing a model, displaying its task icon, name, - * download status, and providing action buttons. It supports expanding to show a - * model description and buttons for learning more (opening a URL) and downloading/trying - * the model. + * This function renders a card representing a model, displaying its task icon, name, download + * status, and providing action buttons. It supports expanding to show a model description and + * buttons for learning more (opening a URL) and downloading/trying the model. */ @OptIn(ExperimentalSharedTransitionApi::class) @Composable @@ -103,170 +103,174 @@ fun ModelItem( val downloadStatus by remember { derivedStateOf { modelManagerUiState.modelDownloadStatus[model.name] } } - val launcher = rememberLauncherForActivityResult( - ActivityResultContracts.RequestPermission() - ) { - modelManagerViewModel.downloadModel(task = task, model = model) - } + val launcher = + rememberLauncherForActivityResult(ActivityResultContracts.RequestPermission()) { + modelManagerViewModel.downloadModel(task = task, model = model) + } var isExpanded by remember { mutableStateOf(false) } - var boxModifier = modifier - .fillMaxWidth() - .clip(RoundedCornerShape(size = 42.dp)) - .background( - getTaskBgColor(task) - ) - boxModifier = if (canExpand) { - boxModifier.clickable(onClick = { - if (!model.imported) { - isExpanded = !isExpanded - } else { - onModelClicked(model) - } - }, interactionSource = remember { MutableInteractionSource() }, indication = ripple( - bounded = true, - radius = 1000.dp, - ) - ) - } else { - boxModifier - } + var boxModifier = + modifier.fillMaxWidth().clip(RoundedCornerShape(size = 42.dp)).background(getTaskBgColor(task)) + boxModifier = + if (canExpand) { + boxModifier.clickable( + onClick = { + if (!model.imported) { + isExpanded = !isExpanded + } else { + onModelClicked(model) + } + }, + interactionSource = remember { MutableInteractionSource() }, + indication = ripple(bounded = true, radius = 1000.dp), + ) + } else { + boxModifier + } - Box( - modifier = boxModifier, - contentAlignment = Alignment.Center, - ) { + Box(modifier = boxModifier, contentAlignment = Alignment.Center) { SharedTransitionLayout { - AnimatedContent( - isExpanded, label = "item_layout_transition", - ) { targetState -> - val taskIcon = @Composable { - TaskIcon( - task = task, modifier = Modifier.sharedElement( - sharedContentState = rememberSharedContentState(key = "task_icon"), - animatedVisibilityScope = this@AnimatedContent, - ) - ) - } - - val modelNameAndStatus = @Composable { - ModelNameAndStatus( - model = model, - task = task, - downloadStatus = downloadStatus, - isExpanded = isExpanded, - animatedVisibilityScope = this@AnimatedContent, - sharedTransitionScope = this@SharedTransitionLayout - ) - } - - val actionButton = @Composable { - ModelItemActionButton( - context = context, - model = model, - task = task, - modelManagerViewModel = modelManagerViewModel, - downloadStatus = downloadStatus, - onDownloadClicked = { model -> - checkNotificationPermissionAndStartDownload( - context = context, - launcher = launcher, - modelManagerViewModel = modelManagerViewModel, - task = task, - model = model - ) - }, - showDeleteButton = showDeleteButton, - showDownloadButton = false, - modifier = Modifier.sharedElement( - sharedContentState = rememberSharedContentState(key = "action_button"), - animatedVisibilityScope = this@AnimatedContent, - ) - ) - } - - val expandButton = @Composable { - Icon( - // For imported model, show ">" directly indicating users can just tap the model item to - // go into it without needing to expand it first. - if (model.imported) Icons.Rounded.ChevronRight else if (isExpanded) Icons.Rounded.UnfoldLess else Icons.Rounded.UnfoldMore, - contentDescription = "", - tint = getTaskIconColor(task), - modifier = Modifier.sharedElement( - sharedContentState = rememberSharedContentState(key = "expand_button"), - animatedVisibilityScope = this@AnimatedContent, - ) - ) - } - - val description = @Composable { - if (model.info.isNotEmpty()) { - MarkdownText( - model.info, modifier = Modifier - .sharedElement( - sharedContentState = rememberSharedContentState(key = "description"), + AnimatedContent(isExpanded, label = "item_layout_transition") { targetState -> + val taskIcon = + @Composable { + TaskIcon( + task = task, + modifier = + Modifier.sharedElement( + sharedContentState = rememberSharedContentState(key = "task_icon"), animatedVisibilityScope = this@AnimatedContent, - ) - .skipToLookaheadSize() + ), ) } - } - val buttonsRow = @Composable { - Row( - horizontalArrangement = Arrangement.spacedBy(12.dp), modifier = Modifier - .sharedElement( - sharedContentState = rememberSharedContentState(key = "buttons_row"), - animatedVisibilityScope = this@AnimatedContent, - ) - .skipToLookaheadSize() - ) { - // The "learn more" button. Click to show related urls in a bottom sheet. - if (model.learnMoreUrl.isNotEmpty()) { - OutlinedButton( - onClick = { - if (isExpanded) { - val intent = Intent(Intent.ACTION_VIEW, Uri.parse(model.learnMoreUrl)) - context.startActivity(intent) - } - }, - ) { - Text("Learn More", maxLines = 1) - } - } - - // Button to start the download and start the chat session with the model. - val needToDownloadFirst = - downloadStatus?.status == ModelDownloadStatusType.NOT_DOWNLOADED || downloadStatus?.status == ModelDownloadStatusType.FAILED - DownloadAndTryButton(task = task, + val modelNameAndStatus = + @Composable { + ModelNameAndStatus( model = model, - enabled = isExpanded, - needToDownloadFirst = needToDownloadFirst, - modelManagerViewModel = modelManagerViewModel, - onClicked = { onModelClicked(model) }) + task = task, + downloadStatus = downloadStatus, + isExpanded = isExpanded, + animatedVisibilityScope = this@AnimatedContent, + sharedTransitionScope = this@SharedTransitionLayout, + ) + } + + val actionButton = + @Composable { + ModelItemActionButton( + context = context, + model = model, + task = task, + modelManagerViewModel = modelManagerViewModel, + downloadStatus = downloadStatus, + onDownloadClicked = { model -> + checkNotificationPermissionAndStartDownload( + context = context, + launcher = launcher, + modelManagerViewModel = modelManagerViewModel, + task = task, + model = model, + ) + }, + showDeleteButton = showDeleteButton, + showDownloadButton = false, + modifier = + Modifier.sharedElement( + sharedContentState = rememberSharedContentState(key = "action_button"), + animatedVisibilityScope = this@AnimatedContent, + ), + ) + } + + val expandButton = + @Composable { + Icon( + // For imported model, show ">" directly indicating users can just tap the model item + // to + // go into it without needing to expand it first. + if (model.imported) Icons.Rounded.ChevronRight + else if (isExpanded) Icons.Rounded.UnfoldLess else Icons.Rounded.UnfoldMore, + contentDescription = "", + tint = getTaskIconColor(task), + modifier = + Modifier.sharedElement( + sharedContentState = rememberSharedContentState(key = "expand_button"), + animatedVisibilityScope = this@AnimatedContent, + ), + ) + } + + val description = + @Composable { + if (model.info.isNotEmpty()) { + MarkdownText( + model.info, + modifier = + Modifier.sharedElement( + sharedContentState = rememberSharedContentState(key = "description"), + animatedVisibilityScope = this@AnimatedContent, + ) + .skipToLookaheadSize(), + ) + } + } + + val buttonsRow = + @Composable { + Row( + horizontalArrangement = Arrangement.spacedBy(12.dp), + modifier = + Modifier.sharedElement( + sharedContentState = rememberSharedContentState(key = "buttons_row"), + animatedVisibilityScope = this@AnimatedContent, + ) + .skipToLookaheadSize(), + ) { + // The "learn more" button. Click to show related urls in a bottom sheet. + if (model.learnMoreUrl.isNotEmpty()) { + OutlinedButton( + onClick = { + if (isExpanded) { + val intent = Intent(Intent.ACTION_VIEW, model.learnMoreUrl.toUri()) + context.startActivity(intent) + } + } + ) { + Text("Learn More", maxLines = 1) + } + } + + // Button to start the download and start the chat session with the model. + val needToDownloadFirst = + downloadStatus?.status == ModelDownloadStatusType.NOT_DOWNLOADED || + downloadStatus?.status == ModelDownloadStatusType.FAILED + DownloadAndTryButton( + task = task, + model = model, + enabled = isExpanded, + needToDownloadFirst = needToDownloadFirst, + modelManagerViewModel = modelManagerViewModel, + onClicked = { onModelClicked(model) }, + ) + } } - } // Collapsed state. if (!targetState) { - Column( - horizontalAlignment = Alignment.CenterHorizontally, - ) { + Column(horizontalAlignment = Alignment.CenterHorizontally) { Row( verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(12.dp), - modifier = Modifier - .fillMaxWidth() - .padding(start = 18.dp, end = 18.dp) - .padding(vertical = verticalSpacing) + modifier = + Modifier.fillMaxWidth() + .padding(start = 18.dp, end = 18.dp) + .padding(vertical = verticalSpacing), ) { // Icon at the left. taskIcon() // Model name and status at the center. - Row(modifier = Modifier.weight(1f)) { - modelNameAndStatus() - } + Row(modifier = Modifier.weight(1f)) { modelNameAndStatus() } // Action button and expand/collapse button at the right. Row(verticalAlignment = Alignment.CenterVertically) { actionButton() @@ -278,9 +282,8 @@ fun ModelItem( Column( verticalArrangement = Arrangement.spacedBy(14.dp), horizontalAlignment = Alignment.CenterHorizontally, - modifier = Modifier - .fillMaxWidth() - .padding(vertical = verticalSpacing, horizontal = 18.dp) + modifier = + Modifier.fillMaxWidth().padding(vertical = verticalSpacing, horizontal = 18.dp), ) { Box(contentAlignment = Alignment.Center) { // Icon at the top-center. @@ -289,7 +292,7 @@ fun ModelItem( Row( modifier = Modifier.fillMaxWidth(), verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.End + horizontalArrangement = Arrangement.End, ) { actionButton() expandButton() @@ -308,37 +311,37 @@ fun ModelItem( } } -@Preview(showBackground = true) -@Composable -fun PreviewModelItem() { - GalleryTheme { - Column( - verticalArrangement = Arrangement.spacedBy(16.dp), modifier = Modifier.padding(16.dp) - ) { - ModelItem( - model = MODEL_TEST1, - task = TASK_TEST1, - onModelClicked = { }, - modelManagerViewModel = PreviewModelManagerViewModel(context = LocalContext.current), - ) - ModelItem( - model = MODEL_TEST2, - task = TASK_TEST1, - onModelClicked = { }, - modelManagerViewModel = PreviewModelManagerViewModel(context = LocalContext.current), - ) - ModelItem( - model = MODEL_TEST3, - task = TASK_TEST2, - onModelClicked = { }, - modelManagerViewModel = PreviewModelManagerViewModel(context = LocalContext.current), - ) - ModelItem( - model = MODEL_TEST4, - task = TASK_TEST2, - onModelClicked = { }, - modelManagerViewModel = PreviewModelManagerViewModel(context = LocalContext.current), - ) - } - } -} +// @Preview(showBackground = true) +// @Composable +// fun PreviewModelItem() { +// GalleryTheme { +// Column( +// verticalArrangement = Arrangement.spacedBy(16.dp), modifier = Modifier.padding(16.dp) +// ) { +// ModelItem( +// model = MODEL_TEST1, +// task = TASK_TEST1, +// onModelClicked = { }, +// modelManagerViewModel = PreviewModelManagerViewModel(context = LocalContext.current), +// ) +// ModelItem( +// model = MODEL_TEST2, +// task = TASK_TEST1, +// onModelClicked = { }, +// modelManagerViewModel = PreviewModelManagerViewModel(context = LocalContext.current), +// ) +// ModelItem( +// model = MODEL_TEST3, +// task = TASK_TEST2, +// onModelClicked = { }, +// modelManagerViewModel = PreviewModelManagerViewModel(context = LocalContext.current), +// ) +// ModelItem( +// model = MODEL_TEST4, +// task = TASK_TEST2, +// onModelClicked = { }, +// modelManagerViewModel = PreviewModelManagerViewModel(context = LocalContext.current), +// ) +// } +// } +// } diff --git a/Android/src/app/src/main/java/com/google/ai/edge/gallery/ui/common/modelitem/ModelItemActionButton.kt b/Android/src/app/src/main/java/com/google/ai/edge/gallery/ui/common/modelitem/ModelItemActionButton.kt index cf14bd5..cb1ad14 100644 --- a/Android/src/app/src/main/java/com/google/ai/edge/gallery/ui/common/modelitem/ModelItemActionButton.kt +++ b/Android/src/app/src/main/java/com/google/ai/edge/gallery/ui/common/modelitem/ModelItemActionButton.kt @@ -66,69 +66,50 @@ fun ModelItemActionButton( Row(verticalAlignment = Alignment.CenterVertically, modifier = modifier) { when (downloadStatus?.status) { // Button to start the download. - ModelDownloadStatusType.NOT_DOWNLOADED, ModelDownloadStatusType.FAILED -> + ModelDownloadStatusType.NOT_DOWNLOADED, + ModelDownloadStatusType.FAILED -> if (showDownloadButton) { - IconButton(onClick = { - onDownloadClicked(model) - }) { - Icon( - Icons.Rounded.FileDownload, - contentDescription = "", - tint = getTaskIconColor(task), - ) + IconButton(onClick = { onDownloadClicked(model) }) { + Icon(Icons.Rounded.FileDownload, contentDescription = "", tint = getTaskIconColor(task)) } } // Button to delete the download. ModelDownloadStatusType.SUCCEEDED -> { if (showDeleteButton) { - IconButton(onClick = { - showConfirmDeleteDialog = true - }) { - Icon( - Icons.Rounded.Delete, - contentDescription = "", - tint = getTaskIconColor(task), - ) + IconButton(onClick = { showConfirmDeleteDialog = true }) { + Icon(Icons.Rounded.Delete, contentDescription = "", tint = getTaskIconColor(task)) } - } } // Show spinner when the model is partially downloaded because it might some time for // background task to be started by Android. ModelDownloadStatusType.PARTIALLY_DOWNLOADED -> { - CircularProgressIndicator( - modifier = Modifier - .padding(end = 12.dp) - .size(24.dp) - ) + CircularProgressIndicator(modifier = Modifier.padding(end = 12.dp).size(24.dp)) } // Button to cancel the download when it is in progress. - ModelDownloadStatusType.IN_PROGRESS, ModelDownloadStatusType.UNZIPPING -> IconButton(onClick = { - modelManagerViewModel.cancelDownloadModel( - task = task, - model = model - ) - }) { - Icon( - Icons.Rounded.Cancel, - contentDescription = "", - tint = getTaskIconColor(task), - ) - } + ModelDownloadStatusType.IN_PROGRESS, + ModelDownloadStatusType.UNZIPPING -> + IconButton( + onClick = { modelManagerViewModel.cancelDownloadModel(task = task, model = model) } + ) { + Icon(Icons.Rounded.Cancel, contentDescription = "", tint = getTaskIconColor(task)) + } else -> {} } } if (showConfirmDeleteDialog) { - ConfirmDeleteModelDialog(model = model, onConfirm = { - modelManagerViewModel.deleteModel(task = task, model = model) - showConfirmDeleteDialog = false - }, onDismiss = { - showConfirmDeleteDialog = false - }) + ConfirmDeleteModelDialog( + model = model, + onConfirm = { + modelManagerViewModel.deleteModel(task = task, model = model) + showConfirmDeleteDialog = false + }, + onDismiss = { showConfirmDeleteDialog = false }, + ) } -} \ No newline at end of file +} diff --git a/Android/src/app/src/main/java/com/google/ai/edge/gallery/ui/common/modelitem/ModelNameAndStatus.kt b/Android/src/app/src/main/java/com/google/ai/edge/gallery/ui/common/modelitem/ModelNameAndStatus.kt index 7735f87..4ad8098 100644 --- a/Android/src/app/src/main/java/com/google/ai/edge/gallery/ui/common/modelitem/ModelNameAndStatus.kt +++ b/Android/src/app/src/main/java/com/google/ai/edge/gallery/ui/common/modelitem/ModelNameAndStatus.kt @@ -52,8 +52,8 @@ import com.google.ai.edge.gallery.ui.theme.labelSmallNarrow * This function renders the model's name and its current download status, including: * - Model name. * - Failure message (if download failed). - * - Download progress (received size, total size, download rate, remaining time) for - * in-progress downloads. + * - Download progress (received size, total size, download rate, remaining time) for in-progress + * downloads. * - "Unzipping..." status for unzipping processes. * - Model size for successful downloads. */ @@ -66,7 +66,7 @@ fun ModelNameAndStatus( isExpanded: Boolean, sharedTransitionScope: SharedTransitionScope, animatedVisibilityScope: AnimatedVisibilityScope, - modifier: Modifier = Modifier + modifier: Modifier = Modifier, ) { val inProgress = downloadStatus?.status == ModelDownloadStatusType.IN_PROGRESS val isPartiallyDownloaded = downloadStatus?.status == ModelDownloadStatusType.PARTIALLY_DOWNLOADED @@ -77,18 +77,17 @@ fun ModelNameAndStatus( horizontalAlignment = if (isExpanded) Alignment.CenterHorizontally else Alignment.Start ) { // Model name. - Row( - verticalAlignment = Alignment.CenterVertically, - ) { + Row(verticalAlignment = Alignment.CenterVertically) { Text( model.name, maxLines = 1, overflow = TextOverflow.MiddleEllipsis, style = MaterialTheme.typography.titleMedium, - modifier = Modifier.sharedElement( - rememberSharedContentState(key = "model_name"), - animatedVisibilityScope = animatedVisibilityScope - ) + modifier = + Modifier.sharedElement( + rememberSharedContentState(key = "model_name"), + animatedVisibilityScope = animatedVisibilityScope, + ), ) } @@ -97,12 +96,13 @@ fun ModelNameAndStatus( if (!inProgress && !isPartiallyDownloaded) { StatusIcon( downloadStatus = downloadStatus, - modifier = modifier - .padding(end = 4.dp) - .sharedElement( - rememberSharedContentState(key = "download_status_icon"), - animatedVisibilityScope = animatedVisibilityScope - ) + modifier = + modifier + .padding(end = 4.dp) + .sharedElement( + rememberSharedContentState(key = "download_status_icon"), + animatedVisibilityScope = animatedVisibilityScope, + ), ) } @@ -114,10 +114,11 @@ fun ModelNameAndStatus( color = MaterialTheme.colorScheme.error, style = labelSmallNarrow, overflow = TextOverflow.Ellipsis, - modifier = Modifier.sharedElement( - rememberSharedContentState(key = "failure_messsage"), - animatedVisibilityScope = animatedVisibilityScope - ) + modifier = + Modifier.sharedElement( + rememberSharedContentState(key = "failure_messsage"), + animatedVisibilityScope = animatedVisibilityScope, + ), ) } } @@ -138,8 +139,7 @@ fun ModelNameAndStatus( sizeLabel = "${downloadStatus.receivedBytes.humanReadableSize(extraDecimalForGbAndAbove = true)} of ${totalSize.humanReadableSize()}" if (downloadStatus.bytesPerSecond > 0) { - sizeLabel = - "$sizeLabel · ${downloadStatus.bytesPerSecond.humanReadableSize()} / s" + sizeLabel = "$sizeLabel · ${downloadStatus.bytesPerSecond.humanReadableSize()} / s" if (downloadStatus.remainingMs >= 0) { sizeLabel = "$sizeLabel\n${downloadStatus.remainingMs.formatToHourMinSecond()} left" @@ -162,7 +162,7 @@ fun ModelNameAndStatus( } Column( - horizontalAlignment = if (isExpanded) Alignment.CenterHorizontally else Alignment.Start, + horizontalAlignment = if (isExpanded) Alignment.CenterHorizontally else Alignment.Start ) { for ((index, line) in sizeLabel.split("\n").withIndex()) { Text( @@ -172,12 +172,12 @@ fun ModelNameAndStatus( style = labelSmallNarrow.copy(fontSize = fontSize, lineHeight = 10.sp), textAlign = if (isExpanded) TextAlign.Center else TextAlign.Start, overflow = TextOverflow.Visible, - modifier = Modifier - .offset(y = if (index == 0) 0.dp else (-1).dp) - .sharedElement( - rememberSharedContentState(key = "status_label_${index}"), - animatedVisibilityScope = animatedVisibilityScope - ) + modifier = + Modifier.offset(y = if (index == 0) 0.dp else (-1).dp) + .sharedElement( + rememberSharedContentState(key = "status_label_${index}"), + animatedVisibilityScope = animatedVisibilityScope, + ), ) } } @@ -191,12 +191,12 @@ fun ModelNameAndStatus( progress = { animatedProgress.value }, color = getTaskIconColor(task = task), trackColor = MaterialTheme.colorScheme.surfaceContainerHighest, - modifier = Modifier - .padding(top = 2.dp) - .sharedElement( - rememberSharedContentState(key = "download_progress_bar"), - animatedVisibilityScope = animatedVisibilityScope - ) + modifier = + Modifier.padding(top = 2.dp) + .sharedElement( + rememberSharedContentState(key = "download_progress_bar"), + animatedVisibilityScope = animatedVisibilityScope, + ), ) LaunchedEffect(curDownloadProgress) { animatedProgress.animateTo(curDownloadProgress, animationSpec = tween(150)) @@ -207,12 +207,12 @@ fun ModelNameAndStatus( LinearProgressIndicator( color = getTaskIconColor(task = task), trackColor = MaterialTheme.colorScheme.surfaceContainerHighest, - modifier = Modifier - .padding(top = 2.dp) - .sharedElement( - rememberSharedContentState(key = "unzip_progress_bar"), - animatedVisibilityScope = animatedVisibilityScope - ) + modifier = + Modifier.padding(top = 2.dp) + .sharedElement( + rememberSharedContentState(key = "unzip_progress_bar"), + animatedVisibilityScope = animatedVisibilityScope, + ), ) } } diff --git a/Android/src/app/src/main/java/com/google/ai/edge/gallery/ui/common/modelitem/StatusIcon.kt b/Android/src/app/src/main/java/com/google/ai/edge/gallery/ui/common/modelitem/StatusIcon.kt index 042a8ed..612aa0c 100644 --- a/Android/src/app/src/main/java/com/google/ai/edge/gallery/ui/common/modelitem/StatusIcon.kt +++ b/Android/src/app/src/main/java/com/google/ai/edge/gallery/ui/common/modelitem/StatusIcon.kt @@ -16,8 +16,9 @@ package com.google.ai.edge.gallery.ui.common.modelitem +// import androidx.compose.ui.tooling.preview.Preview +// import com.google.ai.edge.gallery.ui.theme.GalleryTheme import androidx.compose.foundation.layout.Arrangement -import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.size import androidx.compose.material.icons.Icons @@ -31,75 +32,71 @@ import androidx.compose.runtime.Composable import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color -import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp import com.google.ai.edge.gallery.data.ModelDownloadStatus import com.google.ai.edge.gallery.data.ModelDownloadStatusType -import com.google.ai.edge.gallery.ui.theme.GalleryTheme import com.google.ai.edge.gallery.ui.theme.customColors private val SIZE = 18.dp -/** - * Composable function to display an icon representing the download status of a model. - */ +/** Composable function to display an icon representing the download status of a model. */ @Composable fun StatusIcon(downloadStatus: ModelDownloadStatus?, modifier: Modifier = Modifier) { Row( verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.Center, - modifier = modifier + modifier = modifier, ) { when (downloadStatus?.status) { - ModelDownloadStatusType.NOT_DOWNLOADED -> Icon( - Icons.AutoMirrored.Outlined.HelpOutline, - tint = Color(0xFFCCCCCC), - contentDescription = "", - modifier = Modifier.size(SIZE) - ) + ModelDownloadStatusType.NOT_DOWNLOADED -> + Icon( + Icons.AutoMirrored.Outlined.HelpOutline, + tint = Color(0xFFCCCCCC), + contentDescription = "", + modifier = Modifier.size(SIZE), + ) ModelDownloadStatusType.SUCCEEDED -> { Icon( Icons.Filled.DownloadForOffline, tint = MaterialTheme.customColors.successColor, contentDescription = "", - modifier = Modifier.size(SIZE) + modifier = Modifier.size(SIZE), ) } - ModelDownloadStatusType.FAILED -> Icon( - Icons.Rounded.Error, - tint = Color(0xFFAA0000), - contentDescription = "", - modifier = Modifier.size(SIZE) - ) + ModelDownloadStatusType.FAILED -> + Icon( + Icons.Rounded.Error, + tint = Color(0xFFAA0000), + contentDescription = "", + modifier = Modifier.size(SIZE), + ) - ModelDownloadStatusType.IN_PROGRESS -> Icon( - Icons.Rounded.Downloading, - contentDescription = "", - modifier = Modifier.size(SIZE) - ) + ModelDownloadStatusType.IN_PROGRESS -> + Icon(Icons.Rounded.Downloading, contentDescription = "", modifier = Modifier.size(SIZE)) else -> {} } } } -@Preview(showBackground = true) -@Composable -fun StatusIconPreview() { - GalleryTheme { - Column { - for (downloadStatus in listOf( - ModelDownloadStatus(status = ModelDownloadStatusType.NOT_DOWNLOADED), - ModelDownloadStatus(status = ModelDownloadStatusType.IN_PROGRESS), - ModelDownloadStatus(status = ModelDownloadStatusType.SUCCEEDED), - ModelDownloadStatus(status = ModelDownloadStatusType.FAILED), - ModelDownloadStatus(status = ModelDownloadStatusType.UNZIPPING), - ModelDownloadStatus(status = ModelDownloadStatusType.PARTIALLY_DOWNLOADED), - )) { - StatusIcon(downloadStatus = downloadStatus) - } - } - } -} +// @Preview(showBackground = true) +// @Composable +// fun StatusIconPreview() { +// GalleryTheme { +// Column { +// for (downloadStatus in +// listOf( +// ModelDownloadStatus(status = ModelDownloadStatusType.NOT_DOWNLOADED), +// ModelDownloadStatus(status = ModelDownloadStatusType.IN_PROGRESS), +// ModelDownloadStatus(status = ModelDownloadStatusType.SUCCEEDED), +// ModelDownloadStatus(status = ModelDownloadStatusType.FAILED), +// ModelDownloadStatus(status = ModelDownloadStatusType.UNZIPPING), +// ModelDownloadStatus(status = ModelDownloadStatusType.PARTIALLY_DOWNLOADED), +// )) { +// StatusIcon(downloadStatus = downloadStatus) +// } +// } +// } +// } diff --git a/Android/src/app/src/main/java/com/google/ai/edge/gallery/ui/home/HomeScreen.kt b/Android/src/app/src/main/java/com/google/ai/edge/gallery/ui/home/HomeScreen.kt index 5b124c1..58427ab 100644 --- a/Android/src/app/src/main/java/com/google/ai/edge/gallery/ui/home/HomeScreen.kt +++ b/Android/src/app/src/main/java/com/google/ai/edge/gallery/ui/home/HomeScreen.kt @@ -16,6 +16,9 @@ package com.google.ai.edge.gallery.ui.home +// import androidx.compose.ui.tooling.preview.Preview +// import com.google.ai.edge.gallery.ui.theme.GalleryTheme +// import com.google.ai.edge.gallery.ui.preview.PreviewModelManagerViewModel import android.content.Context import android.content.Intent import android.net.Uri @@ -95,20 +98,17 @@ import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.text.style.TextDecoration import androidx.compose.ui.text.withLink -import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp import com.google.ai.edge.gallery.GalleryTopAppBar import com.google.ai.edge.gallery.R import com.google.ai.edge.gallery.data.AppBarAction import com.google.ai.edge.gallery.data.AppBarActionType -import com.google.ai.edge.gallery.data.ImportedModelInfo import com.google.ai.edge.gallery.data.Task +import com.google.ai.edge.gallery.proto.ImportedModel import com.google.ai.edge.gallery.ui.common.TaskIcon import com.google.ai.edge.gallery.ui.common.getTaskBgColor import com.google.ai.edge.gallery.ui.modelmanager.ModelManagerViewModel -import com.google.ai.edge.gallery.ui.preview.PreviewModelManagerViewModel -import com.google.ai.edge.gallery.ui.theme.GalleryTheme import com.google.ai.edge.gallery.ui.theme.customColors import com.google.ai.edge.gallery.ui.theme.titleMediumNarrow import kotlinx.coroutines.delay @@ -125,8 +125,7 @@ private const val MIN_TASK_CARD_ICON_SIZE = 50 /** Navigation destination data */ object HomeScreenDestination { - @StringRes - val titleRes = R.string.app_name + @StringRes val titleRes = R.string.app_name } @OptIn(ExperimentalMaterial3Api::class) @@ -134,7 +133,7 @@ object HomeScreenDestination { fun HomeScreen( modelManagerViewModel: ModelManagerViewModel, navigateToTaskScreen: (Task) -> Unit, - modifier: Modifier = Modifier + modifier: Modifier = Modifier, ) { val scrollBehavior = TopAppBarDefaults.pinnedScrollBehavior() val uiState by modelManagerViewModel.uiState.collectAsState() @@ -145,53 +144,56 @@ fun HomeScreen( var showImportDialog by remember { mutableStateOf(false) } var showImportingDialog by remember { mutableStateOf(false) } val selectedLocalModelFileUri = remember { mutableStateOf(null) } - val selectedImportedModelInfo = remember { mutableStateOf(null) } + val selectedImportedModelInfo = remember { mutableStateOf(null) } val coroutineScope = rememberCoroutineScope() val snackbarHostState = remember { SnackbarHostState() } val scope = rememberCoroutineScope() val context = LocalContext.current - val filePickerLauncher: ActivityResultLauncher = rememberLauncherForActivityResult( - contract = ActivityResultContracts.StartActivityForResult() - ) { result -> - if (result.resultCode == android.app.Activity.RESULT_OK) { - result.data?.data?.let { uri -> - val fileName = getFileName(context = context, uri = uri) - Log.d(TAG, "Selected file: $fileName") - if (fileName != null && !fileName.endsWith(".task")) { - showUnsupportedFileTypeDialog = true - } else { - selectedLocalModelFileUri.value = uri - showImportDialog = true - } - } ?: run { - Log.d(TAG, "No file selected or URI is null.") + val filePickerLauncher: ActivityResultLauncher = + rememberLauncherForActivityResult( + contract = ActivityResultContracts.StartActivityForResult() + ) { result -> + if (result.resultCode == android.app.Activity.RESULT_OK) { + result.data?.data?.let { uri -> + val fileName = getFileName(context = context, uri = uri) + Log.d(TAG, "Selected file: $fileName") + if (fileName != null && !fileName.endsWith(".task")) { + showUnsupportedFileTypeDialog = true + } else { + selectedLocalModelFileUri.value = uri + showImportDialog = true + } + } ?: run { Log.d(TAG, "No file selected or URI is null.") } + } else { + Log.d(TAG, "File picking cancelled.") } - } else { - Log.d(TAG, "File picking cancelled.") } - } - Scaffold(modifier = modifier.nestedScroll(scrollBehavior.nestedScrollConnection), topBar = { - GalleryTopAppBar( - title = stringResource(HomeScreenDestination.titleRes), - rightAction = AppBarAction(actionType = AppBarActionType.APP_SETTING, actionFn = { - showSettingsDialog = true - }), - scrollBehavior = scrollBehavior, - ) - }, floatingActionButton = { - // A floating action button to show "import model" bottom sheet. - SmallFloatingActionButton( - onClick = { - showImportModelSheet = true - }, - containerColor = MaterialTheme.colorScheme.secondaryContainer, - contentColor = MaterialTheme.colorScheme.secondary, - ) { - Icon(Icons.Filled.Add, "") - } - }) { innerPadding -> + Scaffold( + modifier = modifier.nestedScroll(scrollBehavior.nestedScrollConnection), + topBar = { + GalleryTopAppBar( + title = stringResource(HomeScreenDestination.titleRes), + rightAction = + AppBarAction( + actionType = AppBarActionType.APP_SETTING, + actionFn = { showSettingsDialog = true }, + ), + scrollBehavior = scrollBehavior, + ) + }, + floatingActionButton = { + // A floating action button to show "import model" bottom sheet. + SmallFloatingActionButton( + onClick = { showImportModelSheet = true }, + containerColor = MaterialTheme.colorScheme.secondaryContainer, + contentColor = MaterialTheme.colorScheme.secondary, + ) { + Icon(Icons.Filled.Add, "") + } + }, + ) { innerPadding -> Box(contentAlignment = Alignment.BottomCenter, modifier = Modifier.fillMaxSize()) { TaskList( tasks = uiState.tasks, @@ -216,37 +218,36 @@ fun HomeScreen( // Import model bottom sheet. if (showImportModelSheet) { - ModalBottomSheet( - onDismissRequest = { showImportModelSheet = false }, - sheetState = sheetState, - ) { + ModalBottomSheet(onDismissRequest = { showImportModelSheet = false }, sheetState = sheetState) { Text( "Import model", style = MaterialTheme.typography.titleLarge, - modifier = Modifier.padding(vertical = 4.dp, horizontal = 16.dp) + modifier = Modifier.padding(vertical = 4.dp, horizontal = 16.dp), ) - Box(modifier = Modifier.clickable { - coroutineScope.launch { - // Give it sometime to show the click effect. - delay(200) - showImportModelSheet = false + Box( + modifier = + Modifier.clickable { + coroutineScope.launch { + // Give it sometime to show the click effect. + delay(200) + showImportModelSheet = false - // Show file picker. - val intent = Intent(Intent.ACTION_OPEN_DOCUMENT).apply { - addCategory(Intent.CATEGORY_OPENABLE) - type = "*/*" - // Single select. - putExtra(Intent.EXTRA_ALLOW_MULTIPLE, false) + // Show file picker. + val intent = + Intent(Intent.ACTION_OPEN_DOCUMENT).apply { + addCategory(Intent.CATEGORY_OPENABLE) + type = "*/*" + // Single select. + putExtra(Intent.EXTRA_ALLOW_MULTIPLE, false) + } + filePickerLauncher.launch(intent) + } } - filePickerLauncher.launch(intent) - } - }) { + ) { Row( verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(6.dp), - modifier = Modifier - .fillMaxWidth() - .padding(16.dp) + modifier = Modifier.fillMaxWidth().padding(16.dp), ) { Icon(Icons.AutoMirrored.Outlined.NoteAdd, contentDescription = "") Text("From local model file") @@ -258,11 +259,15 @@ fun HomeScreen( // Import dialog if (showImportDialog) { selectedLocalModelFileUri.value?.let { uri -> - ModelImportDialog(uri = uri, onDismiss = { showImportDialog = false }, onDone = { info -> - selectedImportedModelInfo.value = info - showImportDialog = false - showImportingDialog = true - }) + ModelImportDialog( + uri = uri, + onDismiss = { showImportDialog = false }, + onDone = { info -> + selectedImportedModelInfo.value = info + showImportDialog = false + showImportingDialog = true + }, + ) } } @@ -270,20 +275,18 @@ fun HomeScreen( if (showImportingDialog) { selectedLocalModelFileUri.value?.let { uri -> selectedImportedModelInfo.value?.let { info -> - ModelImportingDialog(uri = uri, + ModelImportingDialog( + uri = uri, info = info, onDismiss = { showImportingDialog = false }, onDone = { - modelManagerViewModel.addImportedLlmModel( - info = it, - ) + modelManagerViewModel.addImportedLlmModel(info = it) showImportingDialog = false // Show a snack bar for successful import. - scope.launch { - snackbarHostState.showSnackbar("Model imported successfully") - } - }) + scope.launch { snackbarHostState.showSnackbar("Model imported successfully") } + }, + ) } } } @@ -293,9 +296,7 @@ fun HomeScreen( AlertDialog( onDismissRequest = { showUnsupportedFileTypeDialog = false }, title = { Text("Unsupported file type") }, - text = { - Text("Only \".task\" file type is supported.") - }, + text = { Text("Only \".task\" file type is supported.") }, confirmButton = { Button(onClick = { showUnsupportedFileTypeDialog = false }) { Text(stringResource(R.string.ok)) @@ -309,21 +310,11 @@ fun HomeScreen( icon = { Icon(Icons.Rounded.Error, contentDescription = "", tint = MaterialTheme.colorScheme.error) }, - title = { - Text(uiState.loadingModelAllowlistError) - }, - text = { - Text("Please check your internet connection and try again later.") - }, - onDismissRequest = { - modelManagerViewModel.loadModelAllowlist() - }, + title = { Text(uiState.loadingModelAllowlistError) }, + text = { Text("Please check your internet connection and try again later.") }, + onDismissRequest = { modelManagerViewModel.loadModelAllowlist() }, confirmButton = { - TextButton(onClick = { - modelManagerViewModel.loadModelAllowlist() - }) { - Text("Retry") - } + TextButton(onClick = { modelManagerViewModel.loadModelAllowlist() }) { Text("Retry") } }, ) } @@ -339,31 +330,22 @@ private fun TaskList( ) { val density = LocalDensity.current val windowInfo = LocalWindowInfo.current - val screenWidthDp = remember { - with(density) { - windowInfo.containerSize.width.toDp() - } - } - val screenHeightDp = remember { - with(density) { - windowInfo.containerSize.height.toDp() - } - } + val screenWidthDp = remember { with(density) { windowInfo.containerSize.width.toDp() } } + val screenHeightDp = remember { with(density) { windowInfo.containerSize.height.toDp() } } val sizeFraction = remember { ((screenWidthDp - 360.dp) / (410.dp - 360.dp)).coerceIn(0f, 1f) } val linkColor = MaterialTheme.customColors.linkColor val introText = buildAnnotatedString { append("Welcome to Google AI Edge Gallery! Explore a world of amazing on-device models from ") withLink( - link = LinkAnnotation.Url( - url = "https://huggingface.co/litert-community", // Replace with the actual URL - styles = TextLinkStyles( - style = SpanStyle( - color = linkColor, - textDecoration = TextDecoration.Underline, - ) + link = + LinkAnnotation.Url( + url = "https://huggingface.co/litert-community", // Replace with the actual URL + styles = + TextLinkStyles( + style = SpanStyle(color = linkColor, textDecoration = TextDecoration.Underline) + ), ) - ) ) { append("LiteRT community") } @@ -378,9 +360,7 @@ private fun TaskList( verticalArrangement = Arrangement.spacedBy(8.dp), ) { // New rel - item(key = "newReleaseNotification", span = { GridItemSpan(2) }) { - NewReleaseNotification() - } + item(key = "newReleaseNotification", span = { GridItemSpan(2) }) { NewReleaseNotification() } // Headline. item(key = "headline", span = { GridItemSpan(2) }) { @@ -388,7 +368,7 @@ private fun TaskList( introText, textAlign = TextAlign.Center, style = MaterialTheme.typography.bodyLarge.copy(fontWeight = FontWeight.SemiBold), - modifier = Modifier.padding(bottom = 20.dp).padding(horizontal = 16.dp) + modifier = Modifier.padding(bottom = 20.dp).padding(horizontal = 16.dp), ) } @@ -396,16 +376,12 @@ private fun TaskList( item(key = "loading", span = { GridItemSpan(2) }) { Row( horizontalArrangement = Arrangement.Center, - modifier = Modifier - .fillMaxWidth() - .padding(top = 32.dp) + modifier = Modifier.fillMaxWidth().padding(top = 32.dp), ) { CircularProgressIndicator( trackColor = MaterialTheme.colorScheme.surfaceVariant, strokeWidth = 3.dp, - modifier = Modifier - .padding(end = 8.dp) - .size(20.dp) + modifier = Modifier.padding(end = 8.dp).size(20.dp), ) Text("Loading model list...", style = MaterialTheme.typography.bodyMedium) } @@ -417,17 +393,16 @@ private fun TaskList( "Example LLM Use Cases", style = MaterialTheme.typography.titleSmall.copy(fontWeight = FontWeight.SemiBold), color = MaterialTheme.colorScheme.onSurfaceVariant, - modifier = Modifier.padding(bottom = 4.dp) + modifier = Modifier.padding(bottom = 4.dp), ) } items(tasks) { task -> TaskCard( - sizeFraction = sizeFraction, task = task, onClick = { - navigateToTaskScreen(task) - }, modifier = Modifier - .fillMaxWidth() - .aspectRatio(1f) + sizeFraction = sizeFraction, + task = task, + onClick = { navigateToTaskScreen(task) }, + modifier = Modifier.fillMaxWidth().aspectRatio(1f), ) } } @@ -440,22 +415,23 @@ private fun TaskList( // Gradient overlay at the bottom. Box( - modifier = Modifier - .fillMaxWidth() - .height(screenHeightDp * 0.25f) - .background( - Brush.verticalGradient( - colors = MaterialTheme.customColors.homeBottomGradient, + modifier = + Modifier.fillMaxWidth() + .height(screenHeightDp * 0.25f) + .background( + Brush.verticalGradient(colors = MaterialTheme.customColors.homeBottomGradient) ) - ) - .align(Alignment.BottomCenter) + .align(Alignment.BottomCenter) ) } } @Composable private fun TaskCard( - task: Task, onClick: () -> Unit, sizeFraction: Float, modifier: Modifier = Modifier + task: Task, + onClick: () -> Unit, + sizeFraction: Float, + modifier: Modifier = Modifier, ) { val padding = (MAX_TASK_CARD_PADDING - MIN_TASK_CARD_PADDING) * sizeFraction + MIN_TASK_CARD_PADDING @@ -485,14 +461,16 @@ private fun TaskCard( } var curModelCountLabel by remember { mutableStateOf("") } var modelCountLabelVisible by remember { mutableStateOf(true) } - val modelCountAlpha: Float by animateFloatAsState( - targetValue = if (modelCountLabelVisible) 1f else 0f, - animationSpec = tween(durationMillis = TASK_COUNT_ANIMATION_DURATION) - ) - val modelCountScale: Float by animateFloatAsState( - targetValue = if (modelCountLabelVisible) 1f else 0.7f, - animationSpec = tween(durationMillis = TASK_COUNT_ANIMATION_DURATION) - ) + val modelCountAlpha: Float by + animateFloatAsState( + targetValue = if (modelCountLabelVisible) 1f else 0f, + animationSpec = tween(durationMillis = TASK_COUNT_ANIMATION_DURATION), + ) + val modelCountScale: Float by + animateFloatAsState( + targetValue = if (modelCountLabelVisible) 1f else 0.7f, + animationSpec = tween(durationMillis = TASK_COUNT_ANIMATION_DURATION), + ) LaunchedEffect(modelCountLabel) { if (curModelCountLabel.isEmpty()) { @@ -506,20 +484,10 @@ private fun TaskCard( } Card( - modifier = modifier - .clip(RoundedCornerShape(radius.dp)) - .clickable( - onClick = onClick, - ), - colors = CardDefaults.cardColors( - containerColor = getTaskBgColor(task = task) - ), + modifier = modifier.clip(RoundedCornerShape(radius.dp)).clickable(onClick = onClick), + colors = CardDefaults.cardColors(containerColor = getTaskBgColor(task = task)), ) { - Column( - modifier = Modifier - .fillMaxSize() - .padding(padding.dp), - ) { + Column(modifier = Modifier.fillMaxSize().padding(padding.dp)) { // Icon. TaskIcon(task = task, width = iconSize.dp) @@ -529,10 +497,7 @@ private fun TaskCard( Text( task.type.label, color = MaterialTheme.colorScheme.primary, - style = titleMediumNarrow.copy( - fontSize = 20.sp, - fontWeight = FontWeight.Bold, - ), + style = titleMediumNarrow.copy(fontSize = 20.sp, fontWeight = FontWeight.Bold), ) Spacer(modifier = Modifier.weight(1f)) @@ -542,9 +507,7 @@ private fun TaskCard( curModelCountLabel, color = MaterialTheme.colorScheme.secondary, style = MaterialTheme.typography.bodyMedium, - modifier = Modifier - .alpha(modelCountAlpha) - .scale(modelCountScale), + modifier = Modifier.alpha(modelCountAlpha).scale(modelCountScale), ) } } @@ -567,15 +530,13 @@ fun getFileName(context: Context, uri: Uri): String? { return null } -@Preview -@Composable -fun HomeScreenPreview( -) { - GalleryTheme { - HomeScreen( - modelManagerViewModel = PreviewModelManagerViewModel(context = LocalContext.current), - navigateToTaskScreen = {}, - ) - } -} - +// @Preview +// @Composable +// fun HomeScreenPreview() { +// GalleryTheme { +// HomeScreen( +// modelManagerViewModel = PreviewModelManagerViewModel(context = LocalContext.current), +// navigateToTaskScreen = {}, +// ) +// } +// } diff --git a/Android/src/app/src/main/java/com/google/ai/edge/gallery/ui/home/ModelImportDialog.kt b/Android/src/app/src/main/java/com/google/ai/edge/gallery/ui/home/ModelImportDialog.kt index a1fd263..1f0cd00 100644 --- a/Android/src/app/src/main/java/com/google/ai/edge/gallery/ui/home/ModelImportDialog.kt +++ b/Android/src/app/src/main/java/com/google/ai/edge/gallery/ui/home/ModelImportDialog.kt @@ -63,69 +63,74 @@ import com.google.ai.edge.gallery.data.Accelerator import com.google.ai.edge.gallery.data.BooleanSwitchConfig import com.google.ai.edge.gallery.data.Config import com.google.ai.edge.gallery.data.ConfigKey +import com.google.ai.edge.gallery.data.DEFAULT_MAX_TOKEN +import com.google.ai.edge.gallery.data.DEFAULT_TEMPERATURE +import com.google.ai.edge.gallery.data.DEFAULT_TOPK +import com.google.ai.edge.gallery.data.DEFAULT_TOPP import com.google.ai.edge.gallery.data.IMPORTS_DIR import com.google.ai.edge.gallery.data.LabelConfig -import com.google.ai.edge.gallery.data.ImportedModelInfo import com.google.ai.edge.gallery.data.NumberSliderConfig import com.google.ai.edge.gallery.data.SegmentedButtonConfig import com.google.ai.edge.gallery.data.ValueType -import com.google.ai.edge.gallery.ui.common.chat.ConfigEditorsPanel +import com.google.ai.edge.gallery.data.convertValueToTargetType +import com.google.ai.edge.gallery.proto.ImportedModel +import com.google.ai.edge.gallery.proto.LlmConfig +import com.google.ai.edge.gallery.ui.common.ConfigEditorsPanel import com.google.ai.edge.gallery.ui.common.ensureValidFileName import com.google.ai.edge.gallery.ui.common.humanReadableSize -import com.google.ai.edge.gallery.ui.llmchat.DEFAULT_MAX_TOKEN -import com.google.ai.edge.gallery.ui.llmchat.DEFAULT_TEMPERATURE -import com.google.ai.edge.gallery.ui.llmchat.DEFAULT_TOPK -import com.google.ai.edge.gallery.ui.llmchat.DEFAULT_TOPP -import kotlinx.coroutines.CoroutineScope -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.launch import java.io.File import java.io.FileOutputStream import java.net.URLDecoder import java.nio.charset.StandardCharsets +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.launch private const val TAG = "AGModelImportDialog" -private val IMPORT_CONFIGS_LLM: List = listOf( - LabelConfig(key = ConfigKey.NAME), LabelConfig(key = ConfigKey.MODEL_TYPE), NumberSliderConfig( - key = ConfigKey.DEFAULT_MAX_TOKENS, - sliderMin = 100f, - sliderMax = 1024f, - defaultValue = DEFAULT_MAX_TOKEN.toFloat(), - valueType = ValueType.INT - ), NumberSliderConfig( - key = ConfigKey.DEFAULT_TOPK, - sliderMin = 5f, - sliderMax = 40f, - defaultValue = DEFAULT_TOPK.toFloat(), - valueType = ValueType.INT - ), NumberSliderConfig( - key = ConfigKey.DEFAULT_TOPP, - sliderMin = 0.0f, - sliderMax = 1.0f, - defaultValue = DEFAULT_TOPP, - valueType = ValueType.FLOAT - ), NumberSliderConfig( - key = ConfigKey.DEFAULT_TEMPERATURE, - sliderMin = 0.0f, - sliderMax = 2.0f, - defaultValue = DEFAULT_TEMPERATURE, - valueType = ValueType.FLOAT - ), BooleanSwitchConfig( - key = ConfigKey.SUPPORT_IMAGE, - defaultValue = false, - ), SegmentedButtonConfig( - key = ConfigKey.COMPATIBLE_ACCELERATORS, - defaultValue = Accelerator.CPU.label, - options = listOf(Accelerator.CPU.label, Accelerator.GPU.label), - allowMultiple = true, +private val IMPORT_CONFIGS_LLM: List = + listOf( + LabelConfig(key = ConfigKey.NAME), + LabelConfig(key = ConfigKey.MODEL_TYPE), + NumberSliderConfig( + key = ConfigKey.DEFAULT_MAX_TOKENS, + sliderMin = 100f, + sliderMax = 1024f, + defaultValue = DEFAULT_MAX_TOKEN.toFloat(), + valueType = ValueType.INT, + ), + NumberSliderConfig( + key = ConfigKey.DEFAULT_TOPK, + sliderMin = 5f, + sliderMax = 40f, + defaultValue = DEFAULT_TOPK.toFloat(), + valueType = ValueType.INT, + ), + NumberSliderConfig( + key = ConfigKey.DEFAULT_TOPP, + sliderMin = 0.0f, + sliderMax = 1.0f, + defaultValue = DEFAULT_TOPP, + valueType = ValueType.FLOAT, + ), + NumberSliderConfig( + key = ConfigKey.DEFAULT_TEMPERATURE, + sliderMin = 0.0f, + sliderMax = 2.0f, + defaultValue = DEFAULT_TEMPERATURE, + valueType = ValueType.FLOAT, + ), + BooleanSwitchConfig(key = ConfigKey.SUPPORT_IMAGE, defaultValue = false), + SegmentedButtonConfig( + key = ConfigKey.COMPATIBLE_ACCELERATORS, + defaultValue = Accelerator.CPU.label, + options = listOf(Accelerator.CPU.label, Accelerator.GPU.label), + allowMultiple = true, + ), ) -) @Composable -fun ModelImportDialog( - uri: Uri, onDismiss: () -> Unit, onDone: (ImportedModelInfo) -> Unit -) { +fun ModelImportDialog(uri: Uri, onDismiss: () -> Unit, onDone: (ImportedModel) -> Unit) { val context = LocalContext.current val info = remember { getFileSizeAndDisplayNameFromUri(context = context, uri = uri) } val fileSize by remember { mutableLongStateOf(info.first) } @@ -142,78 +147,110 @@ fun ModelImportDialog( } } val values: SnapshotStateMap = remember { - mutableStateMapOf().apply { - putAll(initialValues) - } + mutableStateMapOf().apply { putAll(initialValues) } } val interactionSource = remember { MutableInteractionSource() } - Dialog( - onDismissRequest = onDismiss, - ) { + Dialog(onDismissRequest = onDismiss) { val focusManager = LocalFocusManager.current Card( - modifier = Modifier - .fillMaxWidth() - .clickable( - interactionSource = interactionSource, indication = null // Disable the ripple effect + modifier = + Modifier.fillMaxWidth().clickable( + interactionSource = interactionSource, + indication = null, // Disable the ripple effect ) { focusManager.clearFocus() - }, shape = RoundedCornerShape(16.dp) + }, + shape = RoundedCornerShape(16.dp), ) { Column( - modifier = Modifier.padding(20.dp), verticalArrangement = Arrangement.spacedBy(16.dp) + modifier = Modifier.padding(20.dp), + verticalArrangement = Arrangement.spacedBy(16.dp), ) { // Title. Text( "Import Model", style = MaterialTheme.typography.titleLarge, - modifier = Modifier.padding(bottom = 8.dp) + modifier = Modifier.padding(bottom = 8.dp), ) Column( - modifier = Modifier - .verticalScroll(rememberScrollState()) - .weight(1f, fill = false), - verticalArrangement = Arrangement.spacedBy(16.dp) + modifier = Modifier.verticalScroll(rememberScrollState()).weight(1f, fill = false), + verticalArrangement = Arrangement.spacedBy(16.dp), ) { // Default configs for users to set. - ConfigEditorsPanel( - configs = IMPORT_CONFIGS_LLM, - values = values, - ) + ConfigEditorsPanel(configs = IMPORT_CONFIGS_LLM, values = values) } // Button row. Row( - modifier = Modifier - .fillMaxWidth() - .padding(top = 8.dp), + modifier = Modifier.fillMaxWidth().padding(top = 8.dp), horizontalArrangement = Arrangement.End, ) { // Cancel button. - TextButton( - onClick = { onDismiss() }, - ) { - Text("Cancel") - } + TextButton(onClick = { onDismiss() }) { Text("Cancel") } // Import button Button( onClick = { - onDone( - ImportedModelInfo( - fileName = fileName, - fileSize = fileSize, - defaultValues = values, + val supportedAccelerators = + (convertValueToTargetType( + value = values.get(ConfigKey.COMPATIBLE_ACCELERATORS.label)!!, + valueType = ValueType.STRING, + ) + as String) + .split(",") + val defaultMaxTokens = + convertValueToTargetType( + value = values.get(ConfigKey.DEFAULT_MAX_TOKENS.label)!!, + valueType = ValueType.INT, ) - ) - }, + as Int + val defaultTopk = + convertValueToTargetType( + value = values.get(ConfigKey.DEFAULT_TOPK.label)!!, + valueType = ValueType.INT, + ) + as Int + val defaultTopp = + convertValueToTargetType( + value = values.get(ConfigKey.DEFAULT_TOPP.label)!!, + valueType = ValueType.FLOAT, + ) + as Float + val defaultTemperature = + convertValueToTargetType( + value = values.get(ConfigKey.DEFAULT_TEMPERATURE.label)!!, + valueType = ValueType.FLOAT, + ) + as Float + val supportImage = + convertValueToTargetType( + value = values.get(ConfigKey.SUPPORT_IMAGE.label)!!, + valueType = ValueType.BOOLEAN, + ) + as Boolean + val importedModel: ImportedModel = + ImportedModel.newBuilder() + .setFileName(fileName) + .setFileSize(fileSize) + .setLlmConfig( + LlmConfig.newBuilder() + .addAllCompatibleAccelerators(supportedAccelerators) + .setDefaultMaxTokens(defaultMaxTokens) + .setDefaultTopk(defaultTopk) + .setDefaultTopp(defaultTopp) + .setDefaultTemperature(defaultTemperature) + .setSupportImage(supportImage) + .build() + ) + .build() + onDone(importedModel) + } ) { Text("Import") } } - } } } @@ -221,7 +258,10 @@ fun ModelImportDialog( @Composable fun ModelImportingDialog( - uri: Uri, info: ImportedModelInfo, onDismiss: () -> Unit, onDone: (ImportedModelInfo) -> Unit + uri: Uri, + info: ImportedModel, + onDismiss: () -> Unit, + onDone: (ImportedModel) -> Unit, ) { var error by remember { mutableStateOf("") } val context = LocalContext.current @@ -230,20 +270,16 @@ fun ModelImportingDialog( LaunchedEffect(Unit) { // Import. - importModel(context = context, + importModel( + context = context, coroutineScope = coroutineScope, fileName = info.fileName, fileSize = info.fileSize, uri = uri, - onDone = { - onDone(info) - }, - onProgress = { - progress = it - }, - onError = { - error = it - }) + onDone = { onDone(info) }, + onProgress = { progress = it }, + onError = { error = it }, + ) } Dialog( @@ -252,13 +288,14 @@ fun ModelImportingDialog( ) { Card(modifier = Modifier.fillMaxWidth(), shape = RoundedCornerShape(16.dp)) { Column( - modifier = Modifier.padding(20.dp), verticalArrangement = Arrangement.spacedBy(16.dp) + modifier = Modifier.padding(20.dp), + verticalArrangement = Arrangement.spacedBy(16.dp), ) { // Title. Text( "Import Model", style = MaterialTheme.typography.titleLarge, - modifier = Modifier.padding(bottom = 8.dp) + modifier = Modifier.padding(bottom = 8.dp), ) // No error. @@ -272,9 +309,7 @@ fun ModelImportingDialog( val animatedProgress = remember { Animatable(0f) } LinearProgressIndicator( progress = { animatedProgress.value }, - modifier = Modifier - .fillMaxWidth() - .padding(bottom = 8.dp), + modifier = Modifier.fillMaxWidth().padding(bottom = 8.dp), ) LaunchedEffect(progress) { animatedProgress.animateTo(progress, animationSpec = tween(150)) @@ -284,24 +319,23 @@ fun ModelImportingDialog( // Has error. else { Row( - verticalAlignment = Alignment.Top, horizontalArrangement = Arrangement.spacedBy(6.dp) + verticalAlignment = Alignment.Top, + horizontalArrangement = Arrangement.spacedBy(6.dp), ) { Icon( - Icons.Rounded.Error, contentDescription = "", tint = MaterialTheme.colorScheme.error + Icons.Rounded.Error, + contentDescription = "", + tint = MaterialTheme.colorScheme.error, ) Text( error, style = MaterialTheme.typography.labelSmall, color = MaterialTheme.colorScheme.error, - modifier = Modifier.padding(top = 4.dp) + modifier = Modifier.padding(top = 4.dp), ) } Row(modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.End) { - Button(onClick = { - onDismiss() - }) { - Text("Close") - } + Button(onClick = { onDismiss() }) { Text("Close") } } } } @@ -376,17 +410,17 @@ private fun getFileSizeAndDisplayNameFromUri(context: Context, uri: Uri): Pair - if (cursor.moveToFirst()) { - val sizeIndex = cursor.getColumnIndexOrThrow(OpenableColumns.SIZE) - fileSize = cursor.getLong(sizeIndex) + contentResolver + .query(uri, arrayOf(OpenableColumns.SIZE, OpenableColumns.DISPLAY_NAME), null, null, null) + ?.use { cursor -> + if (cursor.moveToFirst()) { + val sizeIndex = cursor.getColumnIndexOrThrow(OpenableColumns.SIZE) + fileSize = cursor.getLong(sizeIndex) - val nameIndex = cursor.getColumnIndexOrThrow(OpenableColumns.DISPLAY_NAME) - displayName = cursor.getString(nameIndex) + val nameIndex = cursor.getColumnIndexOrThrow(OpenableColumns.DISPLAY_NAME) + displayName = cursor.getString(nameIndex) + } } - } } catch (e: Exception) { e.printStackTrace() return Pair(0L, "") diff --git a/Android/src/app/src/main/java/com/google/ai/edge/gallery/ui/home/NewReleaseNotification.kt b/Android/src/app/src/main/java/com/google/ai/edge/gallery/ui/home/NewReleaseNotification.kt index b4a61d8..2886875 100644 --- a/Android/src/app/src/main/java/com/google/ai/edge/gallery/ui/home/NewReleaseNotification.kt +++ b/Android/src/app/src/main/java/com/google/ai/edge/gallery/ui/home/NewReleaseNotification.kt @@ -1,3 +1,19 @@ +/* + * Copyright 2025 Google LLC + * + * 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 + * + * http://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.google.ai.edge.gallery.ui.home import android.util.Log @@ -29,22 +45,17 @@ import androidx.lifecycle.LifecycleEventObserver import androidx.lifecycle.LifecycleOwner import androidx.lifecycle.compose.LocalLifecycleOwner import com.google.ai.edge.gallery.BuildConfig -import com.google.ai.edge.gallery.ui.common.getJsonResponse -import com.google.ai.edge.gallery.ui.modelmanager.ClickableLink +import com.google.ai.edge.gallery.common.getJsonResponse +import com.google.ai.edge.gallery.ui.common.ClickableLink +import kotlin.math.max import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.launch import kotlinx.coroutines.withContext -import kotlinx.serialization.Serializable -import kotlin.math.max -private const val TAG = "AGNewReleaseNotification" +private const val TAG = "AGNewReleaseNotifi" private const val REPO = "google-ai-edge/gallery" -@Serializable -data class ReleaseInfo( - val html_url: String, - val tag_name: String, -) +data class ReleaseInfo(val html_url: String, val tag_name: String) @Composable fun NewReleaseNotification() { @@ -84,35 +95,31 @@ fun NewReleaseNotification() { lifecycleOwner.lifecycle.addObserver(observer) - onDispose { - lifecycleOwner.lifecycle.removeObserver(observer) - } + onDispose { lifecycleOwner.lifecycle.removeObserver(observer) } } AnimatedVisibility( visible = newReleaseVersion.isNotEmpty(), - enter = fadeIn() + expandVertically() + enter = fadeIn() + expandVertically(), ) { Row( verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.SpaceBetween, - modifier = Modifier - .padding(horizontal = 16.dp) - .padding(bottom = 12.dp) - .clip( - CircleShape - ) - .background(MaterialTheme.colorScheme.tertiaryContainer) - .padding(4.dp) + modifier = + Modifier.padding(horizontal = 16.dp) + .padding(bottom = 12.dp) + .clip(CircleShape) + .background(MaterialTheme.colorScheme.tertiaryContainer) + .padding(4.dp), ) { Text( "New release $newReleaseVersion available", style = MaterialTheme.typography.bodyMedium, - modifier = Modifier.padding(start = 12.dp) + modifier = Modifier.padding(start = 12.dp), ) Row( modifier = Modifier.padding(end = 12.dp), - verticalAlignment = Alignment.CenterVertically + verticalAlignment = Alignment.CenterVertically, ) { ClickableLink( url = newReleaseUrl, diff --git a/Android/src/app/src/main/java/com/google/ai/edge/gallery/ui/home/SettingsDialog.kt b/Android/src/app/src/main/java/com/google/ai/edge/gallery/ui/home/SettingsDialog.kt index c0cae8f..ee418bb 100644 --- a/Android/src/app/src/main/java/com/google/ai/edge/gallery/ui/home/SettingsDialog.kt +++ b/Android/src/app/src/main/java/com/google/ai/edge/gallery/ui/home/SettingsDialog.kt @@ -61,10 +61,8 @@ import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.unit.dp import androidx.compose.ui.window.Dialog import com.google.ai.edge.gallery.BuildConfig +import com.google.ai.edge.gallery.proto.Theme import com.google.ai.edge.gallery.ui.modelmanager.ModelManagerViewModel -import com.google.ai.edge.gallery.ui.theme.THEME_AUTO -import com.google.ai.edge.gallery.ui.theme.THEME_DARK -import com.google.ai.edge.gallery.ui.theme.THEME_LIGHT import com.google.ai.edge.gallery.ui.theme.ThemeSettings import com.google.ai.edge.gallery.ui.theme.labelSmallNarrow import java.time.Instant @@ -73,18 +71,19 @@ import java.time.format.DateTimeFormatter import java.util.Locale import kotlin.math.min -private val THEME_OPTIONS = listOf(THEME_AUTO, THEME_LIGHT, THEME_DARK) +private val THEME_OPTIONS = listOf(Theme.THEME_AUTO, Theme.THEME_LIGHT, Theme.THEME_DARK) @Composable fun SettingsDialog( - curThemeOverride: String, + curThemeOverride: Theme, modelManagerViewModel: ModelManagerViewModel, onDismissed: () -> Unit, ) { var selectedTheme by remember { mutableStateOf(curThemeOverride) } var hfToken by remember { mutableStateOf(modelManagerViewModel.getTokenStatusAndData().data) } val dateFormatter = remember { - DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss").withZone(ZoneId.systemDefault()) + DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss") + .withZone(ZoneId.systemDefault()) .withLocale(Locale.getDefault()) } var customHfToken by remember { mutableStateOf("") } @@ -95,72 +94,75 @@ fun SettingsDialog( Dialog(onDismissRequest = onDismissed) { val focusManager = LocalFocusManager.current Card( - modifier = Modifier - .fillMaxWidth() - .clickable( - interactionSource = interactionSource, indication = null // Disable the ripple effect + modifier = + Modifier.fillMaxWidth().clickable( + interactionSource = interactionSource, + indication = null, // Disable the ripple effect ) { focusManager.clearFocus() - }, shape = RoundedCornerShape(16.dp) + }, + shape = RoundedCornerShape(16.dp), ) { Column( - modifier = Modifier.padding(20.dp), verticalArrangement = Arrangement.spacedBy(16.dp) + modifier = Modifier.padding(20.dp), + verticalArrangement = Arrangement.spacedBy(16.dp), ) { // Dialog title and subtitle. Column { Text( "Settings", style = MaterialTheme.typography.titleLarge, - modifier = Modifier.padding(bottom = 8.dp) + modifier = Modifier.padding(bottom = 8.dp), ) // Subtitle. Text( "App version: ${BuildConfig.VERSION_NAME}", style = labelSmallNarrow, color = MaterialTheme.colorScheme.onSurfaceVariant, - modifier = Modifier.offset(y = (-6).dp) + modifier = Modifier.offset(y = (-6).dp), ) } Column( - modifier = Modifier - .verticalScroll(rememberScrollState()) - .weight(1f, fill = false), - verticalArrangement = Arrangement.spacedBy(16.dp) + modifier = Modifier.verticalScroll(rememberScrollState()).weight(1f, fill = false), + verticalArrangement = Arrangement.spacedBy(16.dp), ) { // Theme switcher. - Column( - modifier = Modifier.fillMaxWidth() - ) { + Column(modifier = Modifier.fillMaxWidth()) { Text( "Theme", - style = MaterialTheme.typography.titleSmall.copy(fontWeight = FontWeight.Bold) + style = MaterialTheme.typography.titleSmall.copy(fontWeight = FontWeight.Bold), ) MultiChoiceSegmentedButtonRow { - THEME_OPTIONS.forEachIndexed { index, label -> - SegmentedButton(shape = SegmentedButtonDefaults.itemShape( - index = index, count = THEME_OPTIONS.size - ), onCheckedChange = { - selectedTheme = label + THEME_OPTIONS.forEachIndexed { index, theme -> + SegmentedButton( + shape = + SegmentedButtonDefaults.itemShape(index = index, count = THEME_OPTIONS.size), + onCheckedChange = { + selectedTheme = theme - // Update theme settings. - // This will update app's theme. - ThemeSettings.themeOverride.value = label + // Update theme settings. + // This will update app's theme. + ThemeSettings.themeOverride.value = theme - // Save to data store. - modelManagerViewModel.saveThemeOverride(label) - }, checked = label == selectedTheme, label = { Text(label) }) + // Save to data store. + modelManagerViewModel.saveThemeOverride(theme) + }, + checked = theme == selectedTheme, + label = { Text(themeLabel(theme)) }, + ) } } } // HF Token management. Column( - modifier = Modifier.fillMaxWidth(), verticalArrangement = Arrangement.spacedBy(4.dp) + modifier = Modifier.fillMaxWidth(), + verticalArrangement = Arrangement.spacedBy(4.dp), ) { Text( "HuggingFace access token", - style = MaterialTheme.typography.titleSmall.copy(fontWeight = FontWeight.Bold) + style = MaterialTheme.typography.titleSmall.copy(fontWeight = FontWeight.Bold), ) // Show the start of the token. val curHfToken = hfToken @@ -168,23 +170,23 @@ fun SettingsDialog( Text( curHfToken.accessToken.substring(0, min(16, curHfToken.accessToken.length)) + "...", style = MaterialTheme.typography.bodyMedium, - color = MaterialTheme.colorScheme.onSurfaceVariant + color = MaterialTheme.colorScheme.onSurfaceVariant, ) Text( "Expired at: ${dateFormatter.format(Instant.ofEpochMilli(curHfToken.expiresAtMs))}", style = MaterialTheme.typography.bodyMedium, - color = MaterialTheme.colorScheme.onSurfaceVariant + color = MaterialTheme.colorScheme.onSurfaceVariant, ) } else { Text( "Not available", style = MaterialTheme.typography.bodyMedium, - color = MaterialTheme.colorScheme.onSurfaceVariant + color = MaterialTheme.colorScheme.onSurfaceVariant, ) Text( "The token will be automatically retrieved when a gated model is downloaded", style = MaterialTheme.typography.bodySmall, - color = MaterialTheme.colorScheme.onSurfaceVariant + color = MaterialTheme.colorScheme.onSurfaceVariant, ) } Row(horizontalArrangement = Arrangement.spacedBy(4.dp)) { @@ -192,46 +194,42 @@ fun SettingsDialog( onClick = { modelManagerViewModel.clearAccessToken() hfToken = null - }, enabled = curHfToken != null + }, + enabled = curHfToken != null, ) { Text("Clear") } BasicTextField( value = customHfToken, singleLine = true, - modifier = Modifier - .fillMaxWidth() - .padding(top = 4.dp) - .focusRequester(focusRequester) - .onFocusChanged { - isFocused = it.isFocused - }, - onValueChange = { - customHfToken = it - }, + modifier = + Modifier.fillMaxWidth() + .padding(top = 4.dp) + .focusRequester(focusRequester) + .onFocusChanged { isFocused = it.isFocused }, + onValueChange = { customHfToken = it }, textStyle = TextStyle(color = MaterialTheme.colorScheme.onSurface), cursorBrush = SolidColor(MaterialTheme.colorScheme.onSurface), ) { innerTextField -> Box( - modifier = Modifier - .border( - width = if (isFocused) 2.dp else 1.dp, - color = if (isFocused) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.outline, - shape = CircleShape, - ) - .height(40.dp), contentAlignment = Alignment.CenterStart + modifier = + Modifier.border( + width = if (isFocused) 2.dp else 1.dp, + color = + if (isFocused) MaterialTheme.colorScheme.primary + else MaterialTheme.colorScheme.outline, + shape = CircleShape, + ) + .height(40.dp), + contentAlignment = Alignment.CenterStart, ) { Row(verticalAlignment = Alignment.CenterVertically) { - Box( - modifier = Modifier - .padding(start = 16.dp) - .weight(1f) - ) { + Box(modifier = Modifier.padding(start = 16.dp).weight(1f)) { if (customHfToken.isEmpty()) { Text( "Enter token manually", color = MaterialTheme.colorScheme.onSurfaceVariant, - style = MaterialTheme.typography.bodySmall + style = MaterialTheme.typography.bodySmall, ) } innerTextField() @@ -246,7 +244,8 @@ fun SettingsDialog( expiresAt = System.currentTimeMillis() + 1000L * 60 * 60 * 24 * 365 * 10, ) hfToken = modelManagerViewModel.getTokenStatusAndData().data - }) { + }, + ) { Icon(Icons.Rounded.CheckCircle, contentDescription = "") } } @@ -257,24 +256,24 @@ fun SettingsDialog( } } - // Button row. Row( - modifier = Modifier - .fillMaxWidth() - .padding(top = 8.dp), + modifier = Modifier.fillMaxWidth().padding(top = 8.dp), horizontalArrangement = Arrangement.End, ) { // Close button - Button( - onClick = { - onDismissed() - }, - ) { - Text("Close") - } + Button(onClick = { onDismissed() }) { Text("Close") } } } } } } + +private fun themeLabel(theme: Theme): String { + return when (theme) { + Theme.THEME_AUTO -> "Auto" + Theme.THEME_LIGHT -> "Light" + Theme.THEME_DARK -> "Dark" + else -> "Unknown" + } +} diff --git a/Android/src/app/src/main/java/com/google/ai/edge/gallery/ui/icon/Deploy.kt b/Android/src/app/src/main/java/com/google/ai/edge/gallery/ui/icon/Deploy.kt index 3908611..914633b 100644 --- a/Android/src/app/src/main/java/com/google/ai/edge/gallery/ui/icon/Deploy.kt +++ b/Android/src/app/src/main/java/com/google/ai/edge/gallery/ui/icon/Deploy.kt @@ -30,61 +30,64 @@ val Deployed_code: ImageVector if (internal_Deployed_code != null) { return internal_Deployed_code!! } - internal_Deployed_code = ImageVector.Builder( - name = "Deployed_code", - defaultWidth = 24.dp, - defaultHeight = 24.dp, - viewportWidth = 960f, - viewportHeight = 960f - ).apply { - path( - fill = SolidColor(Color.Black), - fillAlpha = 1.0f, - stroke = null, - strokeAlpha = 1.0f, - strokeLineWidth = 1.0f, - strokeLineCap = StrokeCap.Butt, - strokeLineJoin = StrokeJoin.Miter, - strokeLineMiter = 1.0f, - pathFillType = PathFillType.NonZero - ) { - moveTo(440f, 777f) - verticalLineToRelative(-274f) - lineTo(200f, 364f) - verticalLineToRelative(274f) - close() - moveToRelative(80f, 0f) - lineToRelative(240f, -139f) - verticalLineToRelative(-274f) - lineTo(520f, 503f) - close() - moveToRelative(-40f, -343f) - lineToRelative(237f, -137f) - lineToRelative(-237f, -137f) - lineToRelative(-237f, 137f) - close() - moveTo(160f, 708f) - quadToRelative(-19f, -11f, -29.5f, -29f) - reflectiveQuadTo(120f, 639f) - verticalLineToRelative(-318f) - quadToRelative(0f, -22f, 10.5f, -40f) - reflectiveQuadToRelative(29.5f, -29f) - lineToRelative(280f, -161f) - quadToRelative(19f, -11f, 40f, -11f) - reflectiveQuadToRelative(40f, 11f) - lineToRelative(280f, 161f) - quadToRelative(19f, 11f, 29.5f, 29f) - reflectiveQuadToRelative(10.5f, 40f) - verticalLineToRelative(318f) - quadToRelative(0f, 22f, -10.5f, 40f) - reflectiveQuadTo(800f, 708f) - lineTo(520f, 869f) - quadToRelative(-19f, 11f, -40f, 11f) - reflectiveQuadToRelative(-40f, -11f) - close() - moveToRelative(320f, -228f) - } - }.build() + internal_Deployed_code = + ImageVector.Builder( + name = "Deployed_code", + defaultWidth = 24.dp, + defaultHeight = 24.dp, + viewportWidth = 960f, + viewportHeight = 960f, + ) + .apply { + path( + fill = SolidColor(Color.Black), + fillAlpha = 1.0f, + stroke = null, + strokeAlpha = 1.0f, + strokeLineWidth = 1.0f, + strokeLineCap = StrokeCap.Butt, + strokeLineJoin = StrokeJoin.Miter, + strokeLineMiter = 1.0f, + pathFillType = PathFillType.NonZero, + ) { + moveTo(440f, 777f) + verticalLineToRelative(-274f) + lineTo(200f, 364f) + verticalLineToRelative(274f) + close() + moveToRelative(80f, 0f) + lineToRelative(240f, -139f) + verticalLineToRelative(-274f) + lineTo(520f, 503f) + close() + moveToRelative(-40f, -343f) + lineToRelative(237f, -137f) + lineToRelative(-237f, -137f) + lineToRelative(-237f, 137f) + close() + moveTo(160f, 708f) + quadToRelative(-19f, -11f, -29.5f, -29f) + reflectiveQuadTo(120f, 639f) + verticalLineToRelative(-318f) + quadToRelative(0f, -22f, 10.5f, -40f) + reflectiveQuadToRelative(29.5f, -29f) + lineToRelative(280f, -161f) + quadToRelative(19f, -11f, 40f, -11f) + reflectiveQuadToRelative(40f, 11f) + lineToRelative(280f, 161f) + quadToRelative(19f, 11f, 29.5f, 29f) + reflectiveQuadToRelative(10.5f, 40f) + verticalLineToRelative(318f) + quadToRelative(0f, 22f, -10.5f, 40f) + reflectiveQuadTo(800f, 708f) + lineTo(520f, 869f) + quadToRelative(-19f, 11f, -40f, 11f) + reflectiveQuadToRelative(-40f, -11f) + close() + moveToRelative(320f, -228f) + } + } + .build() return internal_Deployed_code!! } diff --git a/Android/src/app/src/main/java/com/google/ai/edge/gallery/ui/icon/Forum.kt b/Android/src/app/src/main/java/com/google/ai/edge/gallery/ui/icon/Forum.kt new file mode 100644 index 0000000..173ca4f --- /dev/null +++ b/Android/src/app/src/main/java/com/google/ai/edge/gallery/ui/icon/Forum.kt @@ -0,0 +1,78 @@ +/* + * Copyright 2025 Google LLC + * + * 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 + * + * http://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.google.ai.edge.gallery.ui.icon + +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.SolidColor +import androidx.compose.ui.graphics.vector.ImageVector +import androidx.compose.ui.graphics.vector.path +import androidx.compose.ui.unit.dp + +val Forum: ImageVector + get() { + if (_Forum != null) return _Forum!! + + _Forum = + ImageVector.Builder( + name = "Forum", + defaultWidth = 24.dp, + defaultHeight = 24.dp, + viewportWidth = 960f, + viewportHeight = 960f, + ) + .apply { + path(fill = SolidColor(Color(0xFF000000))) { + moveTo(280f, 720f) + quadToRelative(-17f, 0f, -28.5f, -11.5f) + reflectiveQuadTo(240f, 680f) + verticalLineToRelative(-80f) + horizontalLineToRelative(520f) + verticalLineToRelative(-360f) + horizontalLineToRelative(80f) + quadToRelative(17f, 0f, 28.5f, 11.5f) + reflectiveQuadTo(880f, 280f) + verticalLineToRelative(600f) + lineTo(720f, 720f) + close() + moveTo(80f, 680f) + verticalLineToRelative(-560f) + quadToRelative(0f, -17f, 11.5f, -28.5f) + reflectiveQuadTo(120f, 80f) + horizontalLineToRelative(520f) + quadToRelative(17f, 0f, 28.5f, 11.5f) + reflectiveQuadTo(680f, 120f) + verticalLineToRelative(360f) + quadToRelative(0f, 17f, -11.5f, 28.5f) + reflectiveQuadTo(640f, 520f) + horizontalLineTo(240f) + close() + moveToRelative(520f, -240f) + verticalLineToRelative(-280f) + horizontalLineTo(160f) + verticalLineToRelative(280f) + close() + moveToRelative(-440f, 0f) + verticalLineToRelative(-280f) + close() + } + } + .build() + + return _Forum!! + } + +private var _Forum: ImageVector? = null diff --git a/Android/src/app/src/main/java/com/google/ai/edge/gallery/ui/icon/Mms.kt b/Android/src/app/src/main/java/com/google/ai/edge/gallery/ui/icon/Mms.kt new file mode 100644 index 0000000..56f9990 --- /dev/null +++ b/Android/src/app/src/main/java/com/google/ai/edge/gallery/ui/icon/Mms.kt @@ -0,0 +1,73 @@ +/* + * Copyright 2025 Google LLC + * + * 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 + * + * http://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.google.ai.edge.gallery.ui.icon + +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.SolidColor +import androidx.compose.ui.graphics.vector.ImageVector +import androidx.compose.ui.graphics.vector.path +import androidx.compose.ui.unit.dp + +val Mms: ImageVector + get() { + if (_Mms != null) return _Mms!! + + _Mms = + ImageVector.Builder( + name = "Mms", + defaultWidth = 24.dp, + defaultHeight = 24.dp, + viewportWidth = 960f, + viewportHeight = 960f, + ) + .apply { + path(fill = SolidColor(Color(0xFF000000))) { + moveTo(240f, 560f) + horizontalLineToRelative(480f) + lineTo(570f, 360f) + lineTo(450f, 520f) + lineToRelative(-90f, -120f) + close() + moveTo(80f, 880f) + verticalLineToRelative(-720f) + quadToRelative(0f, -33f, 23.5f, -56.5f) + reflectiveQuadTo(160f, 80f) + horizontalLineToRelative(640f) + quadToRelative(33f, 0f, 56.5f, 23.5f) + reflectiveQuadTo(880f, 160f) + verticalLineToRelative(480f) + quadToRelative(0f, 33f, -23.5f, 56.5f) + reflectiveQuadTo(800f, 720f) + horizontalLineTo(240f) + close() + moveToRelative(126f, -240f) + horizontalLineToRelative(594f) + verticalLineToRelative(-480f) + horizontalLineTo(160f) + verticalLineToRelative(525f) + close() + moveToRelative(-46f, 0f) + verticalLineToRelative(-480f) + close() + } + } + .build() + + return _Mms!! + } + +private var _Mms: ImageVector? = null diff --git a/Android/src/app/src/main/java/com/google/ai/edge/gallery/ui/icon/Widgets.kt.kt b/Android/src/app/src/main/java/com/google/ai/edge/gallery/ui/icon/Widgets.kt.kt new file mode 100644 index 0000000..c727a7b --- /dev/null +++ b/Android/src/app/src/main/java/com/google/ai/edge/gallery/ui/icon/Widgets.kt.kt @@ -0,0 +1,87 @@ +/* + * Copyright 2025 Google LLC + * + * 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 + * + * http://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.google.ai.edge.gallery.ui.icon + +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.SolidColor +import androidx.compose.ui.graphics.vector.ImageVector +import androidx.compose.ui.graphics.vector.path +import androidx.compose.ui.unit.dp + +val Widgets: ImageVector + get() { + if (_Widgets != null) return _Widgets!! + + _Widgets = + ImageVector.Builder( + name = "Widgets", + defaultWidth = 24.dp, + defaultHeight = 24.dp, + viewportWidth = 960f, + viewportHeight = 960f, + ) + .apply { + path(fill = SolidColor(Color(0xFF000000))) { + moveTo(666f, 520f) + lineTo(440f, 294f) + lineToRelative(226f, -226f) + lineToRelative(226f, 226f) + close() + moveToRelative(-546f, -80f) + verticalLineToRelative(-320f) + horizontalLineToRelative(320f) + verticalLineToRelative(320f) + close() + moveToRelative(400f, 400f) + verticalLineToRelative(-320f) + horizontalLineToRelative(320f) + verticalLineToRelative(320f) + close() + moveToRelative(-400f, 0f) + verticalLineToRelative(-320f) + horizontalLineToRelative(320f) + verticalLineToRelative(320f) + close() + moveToRelative(80f, -480f) + horizontalLineToRelative(160f) + verticalLineToRelative(-160f) + horizontalLineTo(200f) + close() + moveToRelative(467f, 48f) + lineToRelative(113f, -113f) + lineToRelative(-113f, -113f) + lineToRelative(-113f, 113f) + close() + moveToRelative(-67f, 352f) + horizontalLineToRelative(160f) + verticalLineToRelative(-160f) + horizontalLineTo(600f) + close() + moveToRelative(-400f, 0f) + horizontalLineToRelative(160f) + verticalLineToRelative(-160f) + horizontalLineTo(200f) + close() + moveToRelative(400f, -160f) + } + } + .build() + + return _Widgets!! + } + +private var _Widgets: ImageVector? = null diff --git a/Android/src/app/src/main/java/com/google/ai/edge/gallery/ui/imageclassification/ImageClassificationModelHelper.kt b/Android/src/app/src/main/java/com/google/ai/edge/gallery/ui/imageclassification/ImageClassificationModelHelper.kt deleted file mode 100644 index 6d13d56..0000000 --- a/Android/src/app/src/main/java/com/google/ai/edge/gallery/ui/imageclassification/ImageClassificationModelHelper.kt +++ /dev/null @@ -1,154 +0,0 @@ -/* - * Copyright 2025 Google LLC - * - * 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 - * - * http://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.google.ai.edge.gallery.ui.imageclassification - -import android.content.Context -import android.graphics.Bitmap -import android.util.Log -import androidx.compose.ui.graphics.Color -import com.google.android.gms.tflite.client.TfLiteInitializationOptions -import com.google.android.gms.tflite.gpu.support.TfLiteGpu -import com.google.android.gms.tflite.java.TfLite -import com.google.ai.edge.gallery.ui.common.chat.Classification -import com.google.ai.edge.gallery.data.ConfigKey -import com.google.ai.edge.gallery.data.Model -import com.google.ai.edge.gallery.ui.common.LatencyProvider -import org.tensorflow.lite.DataType -import org.tensorflow.lite.InterpreterApi -import org.tensorflow.lite.gpu.GpuDelegateFactory -import org.tensorflow.lite.support.common.FileUtil -import org.tensorflow.lite.support.common.ops.NormalizeOp -import org.tensorflow.lite.support.image.ImageProcessor -import org.tensorflow.lite.support.image.TensorImage -import org.tensorflow.lite.support.image.ops.ResizeOp -import org.tensorflow.lite.support.tensorbuffer.TensorBuffer -import java.io.File -import java.io.FileInputStream - -private const val TAG = "AGImageClassificationModelHelper" - -class ImageClassificationInferenceResult( - val categories: List, override val latencyMs: Float -) : LatencyProvider - -//TODO: handle error. - -object ImageClassificationModelHelper { - fun initialize(context: Context, model: Model, onDone: (String) -> Unit) { - val useGpu = model.getBooleanConfigValue(key = ConfigKey.USE_GPU) - TfLiteGpu.isGpuDelegateAvailable(context).continueWith { gpuTask -> - val optionsBuilder = TfLiteInitializationOptions.builder() - if (gpuTask.result) { - optionsBuilder.setEnableGpuDelegateSupport(true) - } - val task = TfLite.initialize( - context, - optionsBuilder.build() - ) - task.addOnSuccessListener { - val interpreterOption = - InterpreterApi.Options().setRuntime(InterpreterApi.Options.TfLiteRuntime.FROM_SYSTEM_ONLY) - if (useGpu) { - interpreterOption.addDelegateFactory(GpuDelegateFactory()) - } - val interpreter = InterpreterApi.create( - File(model.getPath(context = context)), interpreterOption - ) - model.instance = interpreter - onDone("") - } - } - } - - fun cleanUp(model: Model) { - if (model.instance == null) { - return - } - val instance = model.instance as InterpreterApi - instance.close() - } - - fun runInference( - context: Context, - model: Model, - input: Bitmap, - primaryColor: Color, - ): ImageClassificationInferenceResult { - val instance = model.instance - if (instance == null) { - Log.d( - TAG, "Model '${model.name}' has not been initialized" - ) - return ImageClassificationInferenceResult(categories = listOf(), latencyMs = 0f) - } - - // Pre-process image. - val start = System.currentTimeMillis() - val interpreter = model.instance as InterpreterApi - val inputShape = interpreter.getInputTensor(0).shape() - val imageProcessor = ImageProcessor.Builder() - .add(ResizeOp(inputShape[1], inputShape[2], ResizeOp.ResizeMethod.BILINEAR)) - .add(NormalizeOp(127.5f, 127.5f)) // Normalize pixel values - .build() - val tensorImage = TensorImage(DataType.FLOAT32) - tensorImage.load(input) - val inputTensorBuffer = imageProcessor.process(tensorImage).tensorBuffer - - // Output buffer - val outputBuffer = - TensorBuffer.createFixedSize(interpreter.getOutputTensor(0).shape(), DataType.FLOAT32) - - // Run inference - interpreter.run(inputTensorBuffer.buffer, outputBuffer.buffer) - - // Post-process result. - val output = outputBuffer.floatArray - val labelsFilePath = model.getPath( - context = context, - fileName = model.getExtraDataFile(name = "labels")?.downloadFileName ?: "_" - ) - val labelsFileInputStream = FileInputStream(File(labelsFilePath)) - val labels = FileUtil.loadLabels(labelsFileInputStream) - labelsFileInputStream.close() - val topIndices = - getTopKMaxIndices(output = output, k = model.getIntConfigValue(ConfigKey.MAX_RESULT_COUNT)) - val categories: List = - topIndices.map { i -> - Classification( - label = labels[i], - score = output[i], - color = primaryColor - ) - } - return ImageClassificationInferenceResult( - categories = categories, - latencyMs = (System.currentTimeMillis() - start).toFloat() - ) - } - - private fun getTopKMaxIndices(output: FloatArray, k: Int): List { - if (k <= 0 || output.isEmpty()) { - return emptyList() - } - - val indexedValues = output.withIndex().toList() - val sortedIndexedValues = - indexedValues.sortedByDescending { it.value } - return sortedIndexedValues.take(k).map { it.index } // Take the top k and extract indices - } - -} diff --git a/Android/src/app/src/main/java/com/google/ai/edge/gallery/ui/imageclassification/ImageClassificationScreen.kt b/Android/src/app/src/main/java/com/google/ai/edge/gallery/ui/imageclassification/ImageClassificationScreen.kt deleted file mode 100644 index 0b616d6..0000000 --- a/Android/src/app/src/main/java/com/google/ai/edge/gallery/ui/imageclassification/ImageClassificationScreen.kt +++ /dev/null @@ -1,98 +0,0 @@ -/* - * Copyright 2025 Google LLC - * - * 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 - * - * http://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.google.ai.edge.gallery.ui.imageclassification - -import androidx.compose.material3.MaterialTheme -import androidx.compose.runtime.Composable -import androidx.compose.ui.Modifier -import androidx.compose.ui.platform.LocalContext -import androidx.lifecycle.viewmodel.compose.viewModel -import com.google.ai.edge.gallery.ui.ViewModelProvider -import com.google.ai.edge.gallery.ui.common.chat.ChatInputType -import com.google.ai.edge.gallery.ui.common.chat.ChatMessageImage -import com.google.ai.edge.gallery.ui.common.chat.ChatView -import com.google.ai.edge.gallery.ui.modelmanager.ModelManagerViewModel -import kotlinx.serialization.Serializable - -/** Navigation destination data */ -object ImageClassificationDestination { - @Serializable - val route = "ImageClassificationRoute" -} - -@Composable -fun ImageClassificationScreen( - modelManagerViewModel: ModelManagerViewModel, - navigateUp: () -> Unit, - modifier: Modifier = Modifier, - viewModel: ImageClassificationViewModel = viewModel( - factory = ViewModelProvider.Factory - ), -) { - val context = LocalContext.current - val primaryColor = MaterialTheme.colorScheme.primary - - ChatView( - task = viewModel.task, - viewModel = viewModel, - modelManagerViewModel = modelManagerViewModel, - onSendMessage = { model, messages -> - val message = messages[0] - viewModel.addMessage( - model = model, - message = message, - ) - if (message is ChatMessageImage) { - viewModel.generateResponse( - context = context, - model = model, - input = message.bitmap, - primaryColor = primaryColor, - ) - } - }, - onStreamImageMessage = { model, message -> - viewModel.generateStreamingResponse( - context = context, - model = model, - input = message.bitmap, - primaryColor = primaryColor, - ) - }, - onRunAgainClicked = { model, message -> - viewModel.runAgain( - context = context, - model = model, - message = message, - primaryColor = primaryColor, - ) - }, - onBenchmarkClicked = { model, message, warmUpIterations, benchmarkIterations -> - viewModel.benchmark( - context = context, - model = model, - message = message, - warmupCount = warmUpIterations, - iterations = benchmarkIterations, - primaryColor = primaryColor, - ) - }, - navigateUp = navigateUp, - modifier = modifier, - chatInputType = ChatInputType.IMAGE, - ) -} diff --git a/Android/src/app/src/main/java/com/google/ai/edge/gallery/ui/imageclassification/ImageClassificationViewModel.kt b/Android/src/app/src/main/java/com/google/ai/edge/gallery/ui/imageclassification/ImageClassificationViewModel.kt deleted file mode 100644 index e00839d..0000000 --- a/Android/src/app/src/main/java/com/google/ai/edge/gallery/ui/imageclassification/ImageClassificationViewModel.kt +++ /dev/null @@ -1,165 +0,0 @@ -/* - * Copyright 2025 Google LLC - * - * 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 - * - * http://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.google.ai.edge.gallery.ui.imageclassification - -import android.content.Context -import android.graphics.Bitmap -import androidx.compose.ui.graphics.Color -import androidx.compose.ui.unit.dp -import androidx.lifecycle.viewModelScope -import com.google.ai.edge.gallery.ui.common.chat.ChatMessage -import com.google.ai.edge.gallery.ui.common.chat.ChatMessageClassification -import com.google.ai.edge.gallery.ui.common.chat.ChatMessageImage -import com.google.ai.edge.gallery.ui.common.chat.ChatMessageType -import com.google.ai.edge.gallery.data.Model -import com.google.ai.edge.gallery.data.TASK_IMAGE_CLASSIFICATION -import com.google.ai.edge.gallery.ui.common.chat.ChatViewModel -import com.google.ai.edge.gallery.ui.common.runBasicBenchmark -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.delay -import kotlinx.coroutines.launch -import kotlinx.coroutines.sync.Mutex - -class ImageClassificationViewModel : ChatViewModel(task = TASK_IMAGE_CLASSIFICATION) { - private val mutex = Mutex() - - fun generateResponse(context: Context, model: Model, input: Bitmap, primaryColor: Color) { - viewModelScope.launch(Dispatchers.Default) { - // Wait for model to be initialized. - while (model.instance == null) { - delay(100) - } - - val result = ImageClassificationModelHelper.runInference( - context = context, model = model, input = input, primaryColor = primaryColor - ) - - super.addMessage( - model = model, - message = ChatMessageClassification( - classifications = result.categories, - latencyMs = result.latencyMs, - maxBarWidth = 300.dp, - ), - ) - } - } - - fun generateStreamingResponse( - context: Context, - model: Model, - input: Bitmap, - primaryColor: Color - ) { - viewModelScope.launch(Dispatchers.Default) { - // Wait for model to be initialized. - while (model.instance == null) { - delay(100) - } - - if (mutex.tryLock()) { - try { - val result = ImageClassificationModelHelper.runInference( - context = context, model = model, input = input, primaryColor = primaryColor - ) - updateStreamingMessage( - model = model, - message = ChatMessageClassification( - classifications = result.categories, - latencyMs = result.latencyMs - ) - ) - } finally { - mutex.unlock() - } - } else { - // skip call if the previous call has not been finished (mutex is still locked). - } - } - } - - fun benchmark( - context: Context, - model: Model, - message: ChatMessage, - warmupCount: Int, - iterations: Int, - primaryColor: Color - ) { - viewModelScope.launch(Dispatchers.Default) { - // Wait for model to be initialized. - while (model.instance == null) { - delay(100) - } - - if (message is ChatMessageImage) { - setInProgress(true) - runBasicBenchmark( - model = model, - warmupCount = warmupCount, - iterations = iterations, - chatViewModel = this@ImageClassificationViewModel, - inferenceFn = { - ImageClassificationModelHelper.runInference( - context = context, - model = model, - input = message.bitmap, - primaryColor = primaryColor - ) - }, - chatMessageType = ChatMessageType.BENCHMARK_RESULT, - ) - setInProgress(false) - } - } - } - - fun runAgain(context: Context, model: Model, message: ChatMessage, primaryColor: Color) { - viewModelScope.launch(Dispatchers.Default) { - // Wait for model to be initialized. - while (model.instance == null) { - delay(100) - } - - if (message is ChatMessageImage) { - // Clone the clicked message and add it. - addMessage(model = model, message = message.clone()) - - // Run inference. - val result = - ImageClassificationModelHelper.runInference( - context = context, - model = model, - input = message.bitmap, - primaryColor = primaryColor - ) - - // Add response message. - val newMessage = generateClassificationMessage(result = result) - addMessage(model = model, message = newMessage) - } - } - } - - private fun generateClassificationMessage(result: ImageClassificationInferenceResult): ChatMessageClassification { - return ChatMessageClassification( - classifications = result.categories, - latencyMs = result.latencyMs, - maxBarWidth = 300.dp, - ) - } -} diff --git a/Android/src/app/src/main/java/com/google/ai/edge/gallery/ui/imagegeneration/ImageGenerationModelHelper.kt b/Android/src/app/src/main/java/com/google/ai/edge/gallery/ui/imagegeneration/ImageGenerationModelHelper.kt deleted file mode 100644 index f8284bb..0000000 --- a/Android/src/app/src/main/java/com/google/ai/edge/gallery/ui/imagegeneration/ImageGenerationModelHelper.kt +++ /dev/null @@ -1,83 +0,0 @@ -/* - * Copyright 2025 Google LLC - * - * 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 - * - * http://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.google.ai.edge.gallery.ui.imagegeneration - -import android.content.Context -import android.graphics.Bitmap -import android.util.Log -import com.google.mediapipe.framework.image.BitmapExtractor -import com.google.mediapipe.tasks.vision.imagegenerator.ImageGenerator -import com.google.ai.edge.gallery.data.ConfigKey -import com.google.ai.edge.gallery.data.Model -import com.google.ai.edge.gallery.ui.common.LatencyProvider -import com.google.ai.edge.gallery.ui.common.cleanUpMediapipeTaskErrorMessage -import kotlin.random.Random - -private const val TAG = "AGImageGenerationModelHelper" - -class ImageGenerationInferenceResult( - val bitmap: Bitmap, override val latencyMs: Float -) : LatencyProvider - -object ImageGenerationModelHelper { - fun initialize(context: Context, model: Model, onDone: (String) -> Unit) { - try { - val options = ImageGenerator.ImageGeneratorOptions.builder() - .setImageGeneratorModelDirectory(model.getPath(context = context)) - .build() - model.instance = ImageGenerator.createFromOptions(context, options) - } catch (e: Exception) { - onDone(cleanUpMediapipeTaskErrorMessage(e.message ?: "Unknown error")) - return - } - onDone("") - } - - fun cleanUp(model: Model) { - if (model.instance == null) { - return - } - val instance = model.instance as ImageGenerator - try { - instance.close() - } catch (e: Exception) { - // ignore - } - model.instance = null - Log.d(TAG, "Clean up done.") - } - - fun runInference( - model: Model, - input: String, - onStep: (curIteration: Int, totalIterations: Int, ImageGenerationInferenceResult, isLast: Boolean) -> Unit - ) { - val start = System.currentTimeMillis() - val instance = model.instance as ImageGenerator - val iterations = model.getIntConfigValue(ConfigKey.ITERATIONS) - instance.setInputs(input, iterations, Random.nextInt()) - for (i in 0.. Unit, - modifier: Modifier = Modifier, - viewModel: ImageGenerationViewModel = viewModel( - factory = ViewModelProvider.Factory - ), -) { - ChatView( - task = viewModel.task, - viewModel = viewModel, - modelManagerViewModel = modelManagerViewModel, - onSendMessage = { model, messages -> - val message = messages[0] - viewModel.addMessage( - model = model, - message = message, - ) - if (message is ChatMessageText) { - viewModel.generateResponse( - model = model, - input = message.content, - ) - } - }, - onRunAgainClicked = { _, _ -> }, - onBenchmarkClicked = { _, _, _, _ -> }, - navigateUp = navigateUp, - modifier = modifier, - ) -} \ No newline at end of file diff --git a/Android/src/app/src/main/java/com/google/ai/edge/gallery/ui/imagegeneration/ImageGenerationViewModel.kt b/Android/src/app/src/main/java/com/google/ai/edge/gallery/ui/imagegeneration/ImageGenerationViewModel.kt deleted file mode 100644 index efa4312..0000000 --- a/Android/src/app/src/main/java/com/google/ai/edge/gallery/ui/imagegeneration/ImageGenerationViewModel.kt +++ /dev/null @@ -1,87 +0,0 @@ -/* - * Copyright 2025 Google LLC - * - * 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 - * - * http://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.google.ai.edge.gallery.ui.imagegeneration - -import android.graphics.Bitmap -import androidx.compose.ui.graphics.ImageBitmap -import androidx.compose.ui.graphics.asImageBitmap -import androidx.lifecycle.viewModelScope -import com.google.ai.edge.gallery.data.Model -import com.google.ai.edge.gallery.data.TASK_IMAGE_GENERATION -import com.google.ai.edge.gallery.ui.common.chat.ChatMessageImageWithHistory -import com.google.ai.edge.gallery.ui.common.chat.ChatMessageLoading -import com.google.ai.edge.gallery.ui.common.chat.ChatMessageType -import com.google.ai.edge.gallery.ui.common.chat.ChatSide -import com.google.ai.edge.gallery.ui.common.chat.ChatViewModel -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.delay -import kotlinx.coroutines.launch - -class ImageGenerationViewModel : ChatViewModel(task = TASK_IMAGE_GENERATION) { - fun generateResponse(model: Model, input: String) { - viewModelScope.launch(Dispatchers.Default) { - setInProgress(true) - - // Loading. - addMessage( - model = model, - message = ChatMessageLoading(), - ) - - // Wait for model to be initialized. - while (model.instance == null) { - delay(100) - } - - // Run inference. - val bitmaps: MutableList = mutableListOf() - val imageBitmaps: MutableList = mutableListOf() - ImageGenerationModelHelper.runInference( - model = model, input = input - ) { step, totalIterations, result, isLast -> - bitmaps.add(result.bitmap) - imageBitmaps.add(result.bitmap.asImageBitmap()) - val message = ChatMessageImageWithHistory( - bitmaps = bitmaps, - imageBitMaps = imageBitmaps, - totalIterations = totalIterations, - side = ChatSide.AGENT, - latencyMs = result.latencyMs, - curIteration = step, - ) - if (step == 0) { - removeLastMessage(model = model) - - super.addMessage( - model = model, - message = message, - ) - } else { - super.replaceLastMessage( - model = model, - message = message, - type = ChatMessageType.IMAGE_WITH_HISTORY - ) - } - - if (isLast) { - setInProgress(false) - } - } - } - } -} diff --git a/Android/src/app/src/main/java/com/google/ai/edge/gallery/ui/llmchat/LlmChatConfigs.kt b/Android/src/app/src/main/java/com/google/ai/edge/gallery/ui/llmchat/LlmChatConfigs.kt deleted file mode 100644 index 3fcc5fa..0000000 --- a/Android/src/app/src/main/java/com/google/ai/edge/gallery/ui/llmchat/LlmChatConfigs.kt +++ /dev/null @@ -1,72 +0,0 @@ -/* - * Copyright 2025 Google LLC - * - * 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 - * - * http://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.google.ai.edge.gallery.ui.llmchat - -import com.google.ai.edge.gallery.data.Accelerator -import com.google.ai.edge.gallery.data.Config -import com.google.ai.edge.gallery.data.ConfigKey -import com.google.ai.edge.gallery.data.LabelConfig -import com.google.ai.edge.gallery.data.NumberSliderConfig -import com.google.ai.edge.gallery.data.SegmentedButtonConfig -import com.google.ai.edge.gallery.data.ValueType - -const val DEFAULT_MAX_TOKEN = 1024 -const val DEFAULT_TOPK = 40 -const val DEFAULT_TOPP = 0.9f -const val DEFAULT_TEMPERATURE = 1.0f -val DEFAULT_ACCELERATORS = listOf(Accelerator.GPU) - -fun createLlmChatConfigs( - defaultMaxToken: Int = DEFAULT_MAX_TOKEN, - defaultTopK: Int = DEFAULT_TOPK, - defaultTopP: Float = DEFAULT_TOPP, - defaultTemperature: Float = DEFAULT_TEMPERATURE, - accelerators: List = DEFAULT_ACCELERATORS, -): List { - return listOf( - LabelConfig( - key = ConfigKey.MAX_TOKENS, - defaultValue = "$defaultMaxToken", - ), - NumberSliderConfig( - key = ConfigKey.TOPK, - sliderMin = 5f, - sliderMax = 40f, - defaultValue = defaultTopK.toFloat(), - valueType = ValueType.INT - ), - NumberSliderConfig( - key = ConfigKey.TOPP, - sliderMin = 0.0f, - sliderMax = 1.0f, - defaultValue = defaultTopP, - valueType = ValueType.FLOAT - ), - NumberSliderConfig( - key = ConfigKey.TEMPERATURE, - sliderMin = 0.0f, - sliderMax = 2.0f, - defaultValue = defaultTemperature, - valueType = ValueType.FLOAT - ), - SegmentedButtonConfig( - key = ConfigKey.ACCELERATOR, - defaultValue = accelerators[0].label, - options = accelerators.map { it.label } - ) - ) -} diff --git a/Android/src/app/src/main/java/com/google/ai/edge/gallery/ui/llmchat/LlmChatModelHelper.kt b/Android/src/app/src/main/java/com/google/ai/edge/gallery/ui/llmchat/LlmChatModelHelper.kt index ce2094e..128baa8 100644 --- a/Android/src/app/src/main/java/com/google/ai/edge/gallery/ui/llmchat/LlmChatModelHelper.kt +++ b/Android/src/app/src/main/java/com/google/ai/edge/gallery/ui/llmchat/LlmChatModelHelper.kt @@ -19,10 +19,15 @@ package com.google.ai.edge.gallery.ui.llmchat import android.content.Context import android.graphics.Bitmap import android.util.Log +import com.google.ai.edge.gallery.common.cleanUpMediapipeTaskErrorMessage import com.google.ai.edge.gallery.data.Accelerator import com.google.ai.edge.gallery.data.ConfigKey +import com.google.ai.edge.gallery.data.DEFAULT_MAX_TOKEN +import com.google.ai.edge.gallery.data.DEFAULT_TEMPERATURE +import com.google.ai.edge.gallery.data.DEFAULT_TOPK +import com.google.ai.edge.gallery.data.DEFAULT_TOPP +import com.google.ai.edge.gallery.data.MAX_IMAGE_COUNT import com.google.ai.edge.gallery.data.Model -import com.google.ai.edge.gallery.ui.common.cleanUpMediapipeTaskErrorMessage import com.google.mediapipe.framework.image.BitmapImageBuilder import com.google.mediapipe.tasks.genai.llminference.GraphOptions import com.google.mediapipe.tasks.genai.llminference.LlmInference @@ -31,6 +36,7 @@ import com.google.mediapipe.tasks.genai.llminference.LlmInferenceSession private const val TAG = "AGLlmChatModelHelper" typealias ResultListener = (partialResult: String, done: Boolean) -> Unit + typealias CleanUpListener = () -> Unit data class LlmModelInstance(val engine: LlmInference, var session: LlmInferenceSession) @@ -39,9 +45,7 @@ object LlmChatModelHelper { // Indexed by model name. private val cleanUpListeners: MutableMap = mutableMapOf() - fun initialize( - context: Context, model: Model, onDone: (String) -> Unit - ) { + fun initialize(context: Context, model: Model, onDone: (String) -> Unit) { // Prepare options. val maxTokens = model.getIntConfigValue(key = ConfigKey.MAX_TOKENS, defaultValue = DEFAULT_MAX_TOKEN) @@ -52,29 +56,36 @@ object LlmChatModelHelper { val accelerator = model.getStringConfigValue(key = ConfigKey.ACCELERATOR, defaultValue = Accelerator.GPU.label) Log.d(TAG, "Initializing...") - val preferredBackend = when (accelerator) { - Accelerator.CPU.label -> LlmInference.Backend.CPU - Accelerator.GPU.label -> LlmInference.Backend.GPU - else -> LlmInference.Backend.GPU - } + val preferredBackend = + when (accelerator) { + Accelerator.CPU.label -> LlmInference.Backend.CPU + Accelerator.GPU.label -> LlmInference.Backend.GPU + else -> LlmInference.Backend.GPU + } val options = - LlmInference.LlmInferenceOptions.builder().setModelPath(model.getPath(context = context)) - .setMaxTokens(maxTokens).setPreferredBackend(preferredBackend) - .setMaxNumImages(if (model.llmSupportImage) 1 else 0) + LlmInference.LlmInferenceOptions.builder() + .setModelPath(model.getPath(context = context)) + .setMaxTokens(maxTokens) + .setPreferredBackend(preferredBackend) + .setMaxNumImages(if (model.llmSupportImage) MAX_IMAGE_COUNT else 0) .build() // Create an instance of the LLM Inference task and session. try { val llmInference = LlmInference.createFromOptions(context, options) - val session = LlmInferenceSession.createFromOptions( - llmInference, - LlmInferenceSession.LlmInferenceSessionOptions.builder().setTopK(topK).setTopP(topP) - .setTemperature(temperature) - .setGraphOptions( - GraphOptions.builder().setEnableVisionModality(model.llmSupportImage).build() - ).build() - ) + val session = + LlmInferenceSession.createFromOptions( + llmInference, + LlmInferenceSession.LlmInferenceSessionOptions.builder() + .setTopK(topK) + .setTopP(topP) + .setTemperature(temperature) + .setGraphOptions( + GraphOptions.builder().setEnableVisionModality(model.llmSupportImage).build() + ) + .build(), + ) model.instance = LlmModelInstance(engine = llmInference, session = session) } catch (e: Exception) { onDone(cleanUpMediapipeTaskErrorMessage(e.message ?: "Unknown error")) @@ -96,14 +107,18 @@ object LlmChatModelHelper { val topP = model.getFloatConfigValue(key = ConfigKey.TOPP, defaultValue = DEFAULT_TOPP) val temperature = model.getFloatConfigValue(key = ConfigKey.TEMPERATURE, defaultValue = DEFAULT_TEMPERATURE) - val newSession = LlmInferenceSession.createFromOptions( - inference, - LlmInferenceSession.LlmInferenceSessionOptions.builder().setTopK(topK).setTopP(topP) - .setTemperature(temperature) - .setGraphOptions( - GraphOptions.builder().setEnableVisionModality(model.llmSupportImage).build() - ).build() - ) + val newSession = + LlmInferenceSession.createFromOptions( + inference, + LlmInferenceSession.LlmInferenceSessionOptions.builder() + .setTopK(topK) + .setTopP(topP) + .setTemperature(temperature) + .setGraphOptions( + GraphOptions.builder().setEnableVisionModality(model.llmSupportImage).build() + ) + .build(), + ) instance.session = newSession Log.d(TAG, "Resetting done") } catch (e: Exception) { @@ -117,12 +132,19 @@ object LlmChatModelHelper { } val instance = model.instance as LlmModelInstance + + try { + instance.session.close() + } catch (e: Exception) { + Log.e(TAG, "Failed to close the LLM Inference session: ${e.message}") + } + try { - // This will also close the session. Do not call session.close manually. instance.engine.close() } catch (e: Exception) { - // ignore + Log.e(TAG, "Failed to close the LLM Inference engine: ${e.message}") } + val onCleanUp = cleanUpListeners.remove(model.name) if (onCleanUp != null) { onCleanUp() @@ -136,7 +158,7 @@ object LlmChatModelHelper { input: String, resultListener: ResultListener, cleanUpListener: CleanUpListener, - image: Bitmap? = null, + images: List = listOf(), ) { val instance = model.instance as LlmModelInstance @@ -151,9 +173,9 @@ object LlmChatModelHelper { // image. val session = instance.session session.addQueryChunk(input) - if (image != null) { + for (image in images) { session.addImage(BitmapImageBuilder(image).build()) } - session.generateResponseAsync(resultListener) + val unused = session.generateResponseAsync(resultListener) } } diff --git a/Android/src/app/src/main/java/com/google/ai/edge/gallery/ui/llmchat/LlmChatScreen.kt b/Android/src/app/src/main/java/com/google/ai/edge/gallery/ui/llmchat/LlmChatScreen.kt index 6cebc53..0a97f3f 100644 --- a/Android/src/app/src/main/java/com/google/ai/edge/gallery/ui/llmchat/LlmChatScreen.kt +++ b/Android/src/app/src/main/java/com/google/ai/edge/gallery/ui/llmchat/LlmChatScreen.kt @@ -26,16 +26,13 @@ import com.google.ai.edge.gallery.ui.common.chat.ChatMessageImage import com.google.ai.edge.gallery.ui.common.chat.ChatMessageText import com.google.ai.edge.gallery.ui.common.chat.ChatView import com.google.ai.edge.gallery.ui.modelmanager.ModelManagerViewModel -import kotlinx.serialization.Serializable /** Navigation destination data */ object LlmChatDestination { - @Serializable val route = "LlmChatRoute" } object LlmAskImageDestination { - @Serializable val route = "LlmAskImageRoute" } @@ -44,9 +41,7 @@ fun LlmChatScreen( modelManagerViewModel: ModelManagerViewModel, navigateUp: () -> Unit, modifier: Modifier = Modifier, - viewModel: LlmChatViewModel = viewModel( - factory = ViewModelProvider.Factory - ), + viewModel: LlmChatViewModel = viewModel(factory = ViewModelProvider.Factory), ) { ChatViewWrapper( viewModel = viewModel, @@ -61,9 +56,7 @@ fun LlmAskImageScreen( modelManagerViewModel: ModelManagerViewModel, navigateUp: () -> Unit, modifier: Modifier = Modifier, - viewModel: LlmAskImageViewModel = viewModel( - factory = ViewModelProvider.Factory - ), + viewModel: LlmAskImageViewModel = viewModel(factory = ViewModelProvider.Factory), ) { ChatViewWrapper( viewModel = viewModel, @@ -78,7 +71,7 @@ fun ChatViewWrapper( viewModel: LlmChatViewModel, modelManagerViewModel: ModelManagerViewModel, navigateUp: () -> Unit, - modifier: Modifier = Modifier + modifier: Modifier = Modifier, ) { val context = LocalContext.current @@ -88,57 +81,58 @@ fun ChatViewWrapper( modelManagerViewModel = modelManagerViewModel, onSendMessage = { model, messages -> for (message in messages) { - viewModel.addMessage( - model = model, - message = message, - ) + viewModel.addMessage(model = model, message = message) } var text = "" - var image: Bitmap? = null + val images: MutableList = mutableListOf() var chatMessageText: ChatMessageText? = null for (message in messages) { if (message is ChatMessageText) { chatMessageText = message text = message.content } else if (message is ChatMessageImage) { - image = message.bitmap + images.add(message.bitmap) } } if (text.isNotEmpty() && chatMessageText != null) { modelManagerViewModel.addTextInputHistory(text) - viewModel.generateResponse(model = model, input = text, image = image, onError = { - viewModel.handleError( - context = context, - model = model, - modelManagerViewModel = modelManagerViewModel, - triggeredMessage = chatMessageText, - ) - }) + viewModel.generateResponse( + model = model, + input = text, + images = images, + onError = { + viewModel.handleError( + context = context, + model = model, + modelManagerViewModel = modelManagerViewModel, + triggeredMessage = chatMessageText, + ) + }, + ) } }, onRunAgainClicked = { model, message -> if (message is ChatMessageText) { - viewModel.runAgain(model = model, message = message, onError = { - viewModel.handleError( - context = context, - model = model, - modelManagerViewModel = modelManagerViewModel, - triggeredMessage = message, - ) - }) + viewModel.runAgain( + model = model, + message = message, + onError = { + viewModel.handleError( + context = context, + model = model, + modelManagerViewModel = modelManagerViewModel, + triggeredMessage = message, + ) + }, + ) } }, - onBenchmarkClicked = { _, _, _, _ -> - }, - onResetSessionClicked = { model -> - viewModel.resetSession(model = model) - }, + onBenchmarkClicked = { _, _, _, _ -> }, + onResetSessionClicked = { model -> viewModel.resetSession(model = model) }, showStopButtonInInputWhenInProgress = true, - onStopButtonClicked = { model -> - viewModel.stopResponse(model = model) - }, + onStopButtonClicked = { model -> viewModel.stopResponse(model = model) }, navigateUp = navigateUp, modifier = modifier, ) -} \ No newline at end of file +} diff --git a/Android/src/app/src/main/java/com/google/ai/edge/gallery/ui/llmchat/LlmChatViewModel.kt b/Android/src/app/src/main/java/com/google/ai/edge/gallery/ui/llmchat/LlmChatViewModel.kt index b99cdc8..7b0a083 100644 --- a/Android/src/app/src/main/java/com/google/ai/edge/gallery/ui/llmchat/LlmChatViewModel.kt +++ b/Android/src/app/src/main/java/com/google/ai/edge/gallery/ui/llmchat/LlmChatViewModel.kt @@ -22,8 +22,8 @@ import android.util.Log import androidx.lifecycle.viewModelScope import com.google.ai.edge.gallery.data.ConfigKey import com.google.ai.edge.gallery.data.Model -import com.google.ai.edge.gallery.data.TASK_LLM_CHAT import com.google.ai.edge.gallery.data.TASK_LLM_ASK_IMAGE +import com.google.ai.edge.gallery.data.TASK_LLM_CHAT import com.google.ai.edge.gallery.data.Task import com.google.ai.edge.gallery.ui.common.chat.ChatMessageBenchmarkLlmResult import com.google.ai.edge.gallery.ui.common.chat.ChatMessageLoading @@ -39,25 +39,28 @@ import kotlinx.coroutines.delay import kotlinx.coroutines.launch private const val TAG = "AGLlmChatViewModel" -private val STATS = listOf( - Stat(id = "time_to_first_token", label = "1st token", unit = "sec"), - Stat(id = "prefill_speed", label = "Prefill speed", unit = "tokens/s"), - Stat(id = "decode_speed", label = "Decode speed", unit = "tokens/s"), - Stat(id = "latency", label = "Latency", unit = "sec") -) +private val STATS = + listOf( + Stat(id = "time_to_first_token", label = "1st token", unit = "sec"), + Stat(id = "prefill_speed", label = "Prefill speed", unit = "tokens/s"), + Stat(id = "decode_speed", label = "Decode speed", unit = "tokens/s"), + Stat(id = "latency", label = "Latency", unit = "sec"), + ) open class LlmChatViewModel(curTask: Task = TASK_LLM_CHAT) : ChatViewModel(task = curTask) { - fun generateResponse(model: Model, input: String, image: Bitmap? = null, onError: () -> Unit) { + fun generateResponse( + model: Model, + input: String, + images: List = listOf(), + onError: () -> Unit, + ) { val accelerator = model.getStringConfigValue(key = ConfigKey.ACCELERATOR, defaultValue = "") viewModelScope.launch(Dispatchers.Default) { setInProgress(true) setPreparing(true) // Loading. - addMessage( - model = model, - message = ChatMessageLoading(accelerator = accelerator), - ) + addMessage(model = model, message = ChatMessageLoading(accelerator = accelerator)) // Wait for instance to be initialized. while (model.instance == null) { @@ -68,9 +71,7 @@ open class LlmChatViewModel(curTask: Task = TASK_LLM_CHAT) : ChatViewModel(task // Run inference. val instance = model.instance as LlmModelInstance var prefillTokens = instance.session.sizeInTokens(input) - if (image != null) { - prefillTokens += 257 - } + prefillTokens += images.size * 257 var firstRun = true var timeToFirstToken = 0f @@ -81,9 +82,10 @@ open class LlmChatViewModel(curTask: Task = TASK_LLM_CHAT) : ChatViewModel(task val start = System.currentTimeMillis() try { - LlmChatModelHelper.runInference(model = model, + LlmChatModelHelper.runInference( + model = model, input = input, - image = image, + images = images, resultListener = { partialResult, done -> val curTs = System.currentTimeMillis() @@ -106,18 +108,17 @@ open class LlmChatViewModel(curTask: Task = TASK_LLM_CHAT) : ChatViewModel(task // Add an empty message that will receive streaming results. addMessage( model = model, - message = ChatMessageText( - content = "", - side = ChatSide.AGENT, - accelerator = accelerator - ) + message = + ChatMessageText(content = "", side = ChatSide.AGENT, accelerator = accelerator), ) } // Incrementally update the streamed partial results. val latencyMs: Long = if (done) System.currentTimeMillis() - start else -1 updateLastTextMessageContentIncrementally( - model = model, partialContent = partialResult, latencyMs = latencyMs.toFloat() + model = model, + partialContent = partialResult, + latencyMs = latencyMs.toFloat(), ) if (done) { @@ -130,18 +131,21 @@ open class LlmChatViewModel(curTask: Task = TASK_LLM_CHAT) : ChatViewModel(task if (lastMessage is ChatMessageText) { updateLastTextMessageLlmBenchmarkResult( - model = model, llmBenchmarkResult = ChatMessageBenchmarkLlmResult( - orderedStats = STATS, - statValues = mutableMapOf( - "prefill_speed" to prefillSpeed, - "decode_speed" to decodeSpeed, - "time_to_first_token" to timeToFirstToken, - "latency" to (curTs - start).toFloat() / 1000f, + model = model, + llmBenchmarkResult = + ChatMessageBenchmarkLlmResult( + orderedStats = STATS, + statValues = + mutableMapOf( + "prefill_speed" to prefillSpeed, + "decode_speed" to decodeSpeed, + "time_to_first_token" to timeToFirstToken, + "latency" to (curTs - start).toFloat() / 1000f, + ), + running = false, + latencyMs = -1f, + accelerator = accelerator, ), - running = false, - latencyMs = -1f, - accelerator = accelerator, - ) ) } } @@ -149,7 +153,8 @@ open class LlmChatViewModel(curTask: Task = TASK_LLM_CHAT) : ChatViewModel(task cleanUpListener = { setInProgress(false) setPreparing(false) - }) + }, + ) } catch (e: Exception) { Log.e(TAG, "Error occurred while running inference", e) setInProgress(false) @@ -201,9 +206,7 @@ open class LlmChatViewModel(curTask: Task = TASK_LLM_CHAT) : ChatViewModel(task addMessage(model = model, message = message.clone()) // Run inference. - generateResponse( - model = model, input = message.content, onError = onError - ) + generateResponse(model = model, input = message.content, onError = onError) } } @@ -229,20 +232,18 @@ open class LlmChatViewModel(curTask: Task = TASK_LLM_CHAT) : ChatViewModel(task // Add a warning message for re-initializing the session. addMessage( model = model, - message = ChatMessageWarning(content = "Error occurred. Re-initializing the session.") + message = ChatMessageWarning(content = "Error occurred. Re-initializing the session."), ) // Add the triggered message back. addMessage(model = model, message = triggeredMessage) // Re-initialize the session/engine. - modelManagerViewModel.initializeModel( - context = context, task = task, model = model - ) + modelManagerViewModel.initializeModel(context = context, task = task, model = model) // Re-generate the response automatically. generateResponse(model = model, input = triggeredMessage.content, onError = {}) } } -class LlmAskImageViewModel : LlmChatViewModel(curTask = TASK_LLM_ASK_IMAGE) \ No newline at end of file +class LlmAskImageViewModel : LlmChatViewModel(curTask = TASK_LLM_ASK_IMAGE) diff --git a/Android/src/app/src/main/java/com/google/ai/edge/gallery/ui/llmsingleturn/LlmSingleTurnScreen.kt b/Android/src/app/src/main/java/com/google/ai/edge/gallery/ui/llmsingleturn/LlmSingleTurnScreen.kt index 94d1c66..2759ba4 100644 --- a/Android/src/app/src/main/java/com/google/ai/edge/gallery/ui/llmsingleturn/LlmSingleTurnScreen.kt +++ b/Android/src/app/src/main/java/com/google/ai/edge/gallery/ui/llmsingleturn/LlmSingleTurnScreen.kt @@ -16,6 +16,10 @@ package com.google.ai.edge.gallery.ui.llmsingleturn +// import androidx.compose.ui.tooling.preview.Preview +// import com.google.ai.edge.gallery.ui.preview.PreviewLlmSingleTurnViewModel +// import com.google.ai.edge.gallery.ui.preview.PreviewModelManagerViewModel +// import com.google.ai.edge.gallery.ui.theme.GalleryTheme import android.util.Log import androidx.activity.compose.BackHandler import androidx.compose.foundation.background @@ -39,7 +43,6 @@ import androidx.compose.ui.Modifier import androidx.compose.ui.draw.alpha import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalLayoutDirection -import androidx.compose.ui.tooling.preview.Preview import androidx.lifecycle.viewmodel.compose.viewModel import com.google.ai.edge.gallery.data.ModelDownloadStatusType import com.google.ai.edge.gallery.ui.ViewModelProvider @@ -48,17 +51,12 @@ import com.google.ai.edge.gallery.ui.common.ModelPageAppBar import com.google.ai.edge.gallery.ui.common.chat.ModelDownloadStatusInfoPanel import com.google.ai.edge.gallery.ui.modelmanager.ModelInitializationStatusType import com.google.ai.edge.gallery.ui.modelmanager.ModelManagerViewModel -import com.google.ai.edge.gallery.ui.preview.PreviewLlmSingleTurnViewModel -import com.google.ai.edge.gallery.ui.preview.PreviewModelManagerViewModel -import com.google.ai.edge.gallery.ui.theme.GalleryTheme import com.google.ai.edge.gallery.ui.theme.customColors import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.launch -import kotlinx.serialization.Serializable /** Navigation destination data */ object LlmSingleTurnDestination { - @Serializable val route = "LlmSingleTurnRoute" } @@ -69,9 +67,7 @@ fun LlmSingleTurnScreen( modelManagerViewModel: ModelManagerViewModel, navigateUp: () -> Unit, modifier: Modifier = Modifier, - viewModel: LlmSingleTurnViewModel = viewModel( - factory = ViewModelProvider.Factory - ), + viewModel: LlmSingleTurnViewModel = viewModel(factory = ViewModelProvider.Factory), ) { val task = viewModel.task val modelManagerUiState by modelManagerViewModel.uiState.collectAsState() @@ -95,9 +91,7 @@ fun LlmSingleTurnScreen( } // Handle system's edge swipe. - BackHandler { - handleNavigateUp() - } + BackHandler { handleNavigateUp() } // Initialize model when model/download state changes. val curDownloadStatus = modelManagerUiState.modelDownloadStatus[selectedModel.name] @@ -106,7 +100,7 @@ fun LlmSingleTurnScreen( if (curDownloadStatus?.status == ModelDownloadStatusType.SUCCEEDED) { Log.d( TAG, - "Initializing model '${selectedModel.name}' from LlmsingleTurnScreen launched effect" + "Initializing model '${selectedModel.name}' from LlmsingleTurnScreen launched effect", ) modelManagerViewModel.initializeModel(context, task = task, model = selectedModel) } @@ -118,50 +112,55 @@ fun LlmSingleTurnScreen( showErrorDialog = modelInitializationStatus?.status == ModelInitializationStatusType.ERROR } - Scaffold(modifier = modifier, topBar = { - ModelPageAppBar( - task = task, - model = selectedModel, - modelManagerViewModel = modelManagerViewModel, - inProgress = uiState.inProgress, - modelPreparing = uiState.preparing, - onConfigChanged = { _, _ -> }, - onBackClicked = { handleNavigateUp() }, - onModelSelected = { newSelectedModel -> - scope.launch(Dispatchers.Default) { - // Clean up current model. - modelManagerViewModel.cleanupModel(task = task, model = selectedModel) + Scaffold( + modifier = modifier, + topBar = { + ModelPageAppBar( + task = task, + model = selectedModel, + modelManagerViewModel = modelManagerViewModel, + inProgress = uiState.inProgress, + modelPreparing = uiState.preparing, + onConfigChanged = { _, _ -> }, + onBackClicked = { handleNavigateUp() }, + onModelSelected = { newSelectedModel -> + scope.launch(Dispatchers.Default) { + // Clean up current model. + modelManagerViewModel.cleanupModel(task = task, model = selectedModel) - // Update selected model. - modelManagerViewModel.selectModel(model = newSelectedModel) - } - } - ) - }) { innerPadding -> - Column( - modifier = Modifier.padding( - top = innerPadding.calculateTopPadding(), - start = innerPadding.calculateStartPadding(LocalLayoutDirection.current), - end = innerPadding.calculateStartPadding(LocalLayoutDirection.current), + // Update selected model. + modelManagerViewModel.selectModel(model = newSelectedModel) + } + }, ) + }, + ) { innerPadding -> + Column( + modifier = + Modifier.padding( + top = innerPadding.calculateTopPadding(), + start = innerPadding.calculateStartPadding(LocalLayoutDirection.current), + end = innerPadding.calculateStartPadding(LocalLayoutDirection.current), + ) ) { ModelDownloadStatusInfoPanel( model = selectedModel, task = task, - modelManagerViewModel = modelManagerViewModel + modelManagerViewModel = modelManagerViewModel, ) // Main UI after model is downloaded. val modelDownloaded = curDownloadStatus?.status == ModelDownloadStatusType.SUCCEEDED Box( contentAlignment = Alignment.BottomCenter, - modifier = Modifier - .weight(1f) - // Just hide the UI without removing it from the screen so that the scroll syncing - // from ResponsePanel still works. - .alpha(if (modelDownloaded) 1.0f else 0.0f) + modifier = + Modifier.weight(1f) + // Just hide the UI without removing it from the screen so that the scroll syncing + // from ResponsePanel still works. + .alpha(if (modelDownloaded) 1.0f else 0.0f), ) { - VerticalSplitView(modifier = Modifier.fillMaxSize(), + VerticalSplitView( + modifier = Modifier.fillMaxSize(), topView = { PromptTemplatesPanel( model = selectedModel, @@ -170,49 +169,47 @@ fun LlmSingleTurnScreen( onSend = { fullPrompt -> viewModel.generateResponse(model = selectedModel, input = fullPrompt) }, - onStopButtonClicked = { model -> - viewModel.stopResponse(model = model) - }, - modifier = Modifier.fillMaxSize() + onStopButtonClicked = { model -> viewModel.stopResponse(model = model) }, + modifier = Modifier.fillMaxSize(), ) }, bottomView = { Box( contentAlignment = Alignment.BottomCenter, - modifier = Modifier - .fillMaxSize() - .background(MaterialTheme.customColors.agentBubbleBgColor) + modifier = + Modifier.fillMaxSize().background(MaterialTheme.customColors.agentBubbleBgColor), ) { ResponsePanel( model = selectedModel, viewModel = viewModel, modelManagerViewModel = modelManagerViewModel, - modifier = Modifier - .fillMaxSize() - .padding(bottom = innerPadding.calculateBottomPadding()) + modifier = + Modifier.fillMaxSize().padding(bottom = innerPadding.calculateBottomPadding()), ) } - }) + }, + ) } if (showErrorDialog) { - ErrorDialog(error = modelInitializationStatus?.error ?: "", onDismiss = { - showErrorDialog = false - }) + ErrorDialog( + error = modelInitializationStatus?.error ?: "", + onDismiss = { showErrorDialog = false }, + ) } } } } -@Preview(showBackground = true) -@Composable -fun LlmSingleTurnScreenPreview() { - val context = LocalContext.current - GalleryTheme { - LlmSingleTurnScreen( - modelManagerViewModel = PreviewModelManagerViewModel(context = context), - viewModel = PreviewLlmSingleTurnViewModel(), - navigateUp = {}, - ) - } -} +// @Preview(showBackground = true) +// @Composable +// fun LlmSingleTurnScreenPreview() { +// val context = LocalContext.current +// GalleryTheme { +// LlmSingleTurnScreen( +// modelManagerViewModel = PreviewModelManagerViewModel(context = context), +// viewModel = PreviewLlmSingleTurnViewModel(), +// navigateUp = {}, +// ) +// } +// } diff --git a/Android/src/app/src/main/java/com/google/ai/edge/gallery/ui/llmsingleturn/LlmSingleTurnViewModel.kt b/Android/src/app/src/main/java/com/google/ai/edge/gallery/ui/llmsingleturn/LlmSingleTurnViewModel.kt index 64ce612..88414d6 100644 --- a/Android/src/app/src/main/java/com/google/ai/edge/gallery/ui/llmsingleturn/LlmSingleTurnViewModel.kt +++ b/Android/src/app/src/main/java/com/google/ai/edge/gallery/ui/llmsingleturn/LlmSingleTurnViewModel.kt @@ -19,12 +19,12 @@ package com.google.ai.edge.gallery.ui.llmsingleturn import android.util.Log import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope +import com.google.ai.edge.gallery.common.processLlmResponse import com.google.ai.edge.gallery.data.Model import com.google.ai.edge.gallery.data.TASK_LLM_PROMPT_LAB import com.google.ai.edge.gallery.data.Task import com.google.ai.edge.gallery.ui.common.chat.ChatMessageBenchmarkLlmResult import com.google.ai.edge.gallery.ui.common.chat.Stat -import com.google.ai.edge.gallery.ui.common.processLlmResponse import com.google.ai.edge.gallery.ui.llmchat.LlmChatModelHelper import com.google.ai.edge.gallery.ui.llmchat.LlmModelInstance import kotlinx.coroutines.Dispatchers @@ -34,12 +34,10 @@ import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.update import kotlinx.coroutines.launch -private const val TAG = "AGLlmSingleTurnViewModel" +private const val TAG = "AGLlmSingleTurnVM" data class LlmSingleTurnUiState( - /** - * Indicates whether the runtime is currently processing a message. - */ + /** Indicates whether the runtime is currently processing a message. */ val inProgress: Boolean = false, /** @@ -57,12 +55,13 @@ data class LlmSingleTurnUiState( val selectedPromptTemplateType: PromptTemplateType = PromptTemplateType.entries[0], ) -private val STATS = listOf( - Stat(id = "time_to_first_token", label = "1st token", unit = "sec"), - Stat(id = "prefill_speed", label = "Prefill speed", unit = "tokens/s"), - Stat(id = "decode_speed", label = "Decode speed", unit = "tokens/s"), - Stat(id = "latency", label = "Latency", unit = "sec") -) +private val STATS = + listOf( + Stat(id = "time_to_first_token", label = "1st token", unit = "sec"), + Stat(id = "prefill_speed", label = "Prefill speed", unit = "tokens/s"), + Stat(id = "decode_speed", label = "Decode speed", unit = "tokens/s"), + Stat(id = "latency", label = "Latency", unit = "sec"), + ) open class LlmSingleTurnViewModel(val task: Task = TASK_LLM_PROMPT_LAB) : ViewModel() { private val _uiState = MutableStateFlow(createUiState(task = task)) @@ -94,7 +93,8 @@ open class LlmSingleTurnViewModel(val task: Task = TASK_LLM_PROMPT_LAB) : ViewMo val start = System.currentTimeMillis() var response = "" var lastBenchmarkUpdateTs = 0L - LlmChatModelHelper.runInference(model = model, + LlmChatModelHelper.runInference( + model = model, input = input, resultListener = { partialResult, done -> val curTs = System.currentTimeMillis() @@ -116,7 +116,7 @@ open class LlmSingleTurnViewModel(val task: Task = TASK_LLM_PROMPT_LAB) : ViewMo updateResponse( model = model, promptTemplateType = uiState.value.selectedPromptTemplateType, - response = response + response = response, ) // Update benchmark (with throttling). @@ -125,21 +125,23 @@ open class LlmSingleTurnViewModel(val task: Task = TASK_LLM_PROMPT_LAB) : ViewMo if (decodeSpeed.isNaN()) { decodeSpeed = 0f } - val benchmark = ChatMessageBenchmarkLlmResult( - orderedStats = STATS, - statValues = mutableMapOf( - "prefill_speed" to prefillSpeed, - "decode_speed" to decodeSpeed, - "time_to_first_token" to timeToFirstToken, - "latency" to (curTs - start).toFloat() / 1000f, - ), - running = !done, - latencyMs = -1f, - ) + val benchmark = + ChatMessageBenchmarkLlmResult( + orderedStats = STATS, + statValues = + mutableMapOf( + "prefill_speed" to prefillSpeed, + "decode_speed" to decodeSpeed, + "time_to_first_token" to timeToFirstToken, + "latency" to (curTs - start).toFloat() / 1000f, + ), + running = !done, + latencyMs = -1f, + ) updateBenchmark( model = model, promptTemplateType = uiState.value.selectedPromptTemplateType, - benchmark = benchmark + benchmark = benchmark, ) lastBenchmarkUpdateTs = curTs } @@ -151,7 +153,8 @@ open class LlmSingleTurnViewModel(val task: Task = TASK_LLM_PROMPT_LAB) : ViewMo cleanUpListener = { setPreparing(false) setInProgress(false) - }) + }, + ) } } @@ -161,7 +164,9 @@ open class LlmSingleTurnViewModel(val task: Task = TASK_LLM_PROMPT_LAB) : ViewMo // Clear response. updateResponse(model = model, promptTemplateType = promptTemplateType, response = "") - this._uiState.update { this.uiState.value.copy(selectedPromptTemplateType = promptTemplateType) } + this._uiState.update { + this.uiState.value.copy(selectedPromptTemplateType = promptTemplateType) + } } fun setInProgress(inProgress: Boolean) { @@ -184,7 +189,9 @@ open class LlmSingleTurnViewModel(val task: Task = TASK_LLM_PROMPT_LAB) : ViewMo } fun updateBenchmark( - model: Model, promptTemplateType: PromptTemplateType, benchmark: ChatMessageBenchmarkLlmResult + model: Model, + promptTemplateType: PromptTemplateType, + benchmark: ChatMessageBenchmarkLlmResult, ) { _uiState.update { currentState -> val currentBenchmark = currentState.benchmarkByModel @@ -218,4 +225,4 @@ open class LlmSingleTurnViewModel(val task: Task = TASK_LLM_PROMPT_LAB) : ViewMo benchmarkByModel = benchmarkByModel, ) } -} \ No newline at end of file +} diff --git a/Android/src/app/src/main/java/com/google/ai/edge/gallery/ui/llmsingleturn/PromptTemplateConfigs.kt b/Android/src/app/src/main/java/com/google/ai/edge/gallery/ui/llmsingleturn/PromptTemplateConfigs.kt index 3b4e815..e60b84d 100644 --- a/Android/src/app/src/main/java/com/google/ai/edge/gallery/ui/llmsingleturn/PromptTemplateConfigs.kt +++ b/Android/src/app/src/main/java/com/google/ai/edge/gallery/ui/llmsingleturn/PromptTemplateConfigs.kt @@ -16,21 +16,23 @@ package com.google.ai.edge.gallery.ui.llmsingleturn +import androidx.compose.ui.graphics.Brush.Companion.linearGradient import androidx.compose.ui.graphics.Color import androidx.compose.ui.text.AnnotatedString +import androidx.compose.ui.text.SpanStyle import androidx.compose.ui.text.buildAnnotatedString import androidx.compose.ui.text.withStyle -import androidx.compose.ui.text.SpanStyle -import androidx.compose.ui.graphics.Brush.Companion.linearGradient enum class PromptTemplateInputEditorType { SINGLE_SELECT } enum class RewriteToneType(val label: String) { - FORMAL(label = "Formal"), CASUAL(label = "Casual"), FRIENDLY(label = "Friendly"), POLITE(label = "Polite"), ENTHUSIASTIC( - label = "Enthusiastic" - ), + FORMAL(label = "Formal"), + CASUAL(label = "Casual"), + FRIENDLY(label = "Friendly"), + POLITE(label = "Polite"), + ENTHUSIASTIC(label = "Enthusiastic"), CONCISE(label = "Concise"), } @@ -69,51 +71,60 @@ class PromptTemplateSingleSelectInputEditor( override val label: String, val options: List = listOf(), override val defaultOption: String = "", -) : PromptTemplateInputEditor( - label = label, type = PromptTemplateInputEditorType.SINGLE_SELECT, defaultOption = defaultOption -) +) : + PromptTemplateInputEditor( + label = label, + type = PromptTemplateInputEditorType.SINGLE_SELECT, + defaultOption = defaultOption, + ) data class PromptTemplateConfig(val inputEditors: List = listOf()) -private val GEMINI_GRADIENT_STYLE = SpanStyle( - brush = linearGradient( - colors = listOf(Color(0xFF4285f4), Color(0xFF9b72cb), Color(0xFFd96570)) +private val GEMINI_GRADIENT_STYLE = + SpanStyle( + brush = linearGradient(colors = listOf(Color(0xFF4285f4), Color(0xFF9b72cb), Color(0xFFd96570))) ) -) +@Suppress("ImmutableEnum") enum class PromptTemplateType( val label: String, val config: PromptTemplateConfig, - val genFullPrompt: (userInput: String, inputEditorValues: Map) -> AnnotatedString = { _, _ -> - AnnotatedString("") - }, + val genFullPrompt: (userInput: String, inputEditorValues: Map) -> AnnotatedString = + { _, _ -> + AnnotatedString("") + }, val examplePrompts: List = listOf(), ) { FREE_FORM( label = "Free form", config = PromptTemplateConfig(), genFullPrompt = { userInput, _ -> AnnotatedString(userInput) }, - examplePrompts = listOf( - "Suggest 3 topics for a podcast about \"Friendships in your 20s\".", - "Outline the key sections needed in a basic logo design brief.", - "List 3 pros and 3 cons to consider before buying a smart watch.", - "Write a short, optimistic quote about the future of technology.", - "Generate 3 potential names for a mobile app that helps users identify plants.", - "Explain the difference between AI and machine learning in 2 sentences.", - "Create a simple haiku about a cat sleeping in the sun.", - "List 3 ways to make instant noodles taste better using common kitchen ingredients." - ) + examplePrompts = + listOf( + "Suggest 3 topics for a podcast about \"Friendships in your 20s\".", + "Outline the key sections needed in a basic logo design brief.", + "List 3 pros and 3 cons to consider before buying a smart watch.", + "Write a short, optimistic quote about the future of technology.", + "Generate 3 potential names for a mobile app that helps users identify plants.", + "Explain the difference between AI and machine learning in 2 sentences.", + "Create a simple haiku about a cat sleeping in the sun.", + "List 3 ways to make instant noodles taste better using common kitchen ingredients.", + ), ), REWRITE_TONE( - label = "Rewrite tone", config = PromptTemplateConfig( - inputEditors = listOf( - PromptTemplateSingleSelectInputEditor( - label = InputEditorLabel.TONE.label, - options = RewriteToneType.entries.map { it.label }, - defaultOption = RewriteToneType.FORMAL.label - ) - ) - ), genFullPrompt = { userInput, inputEditorValues -> + label = "Rewrite tone", + config = + PromptTemplateConfig( + inputEditors = + listOf( + PromptTemplateSingleSelectInputEditor( + label = InputEditorLabel.TONE.label, + options = RewriteToneType.entries.map { it.label }, + defaultOption = RewriteToneType.FORMAL.label, + ) + ) + ), + genFullPrompt = { userInput, inputEditorValues -> val tone = inputEditorValues[InputEditorLabel.TONE.label] as String buildAnnotatedString { withStyle(GEMINI_GRADIENT_STYLE) { @@ -121,25 +132,29 @@ enum class PromptTemplateType( } append(userInput) } - }, examplePrompts = listOf( - "Hey team, just wanted to remind everyone about the meeting tomorrow @ 10. Be there!", - "Our new software update includes several bug fixes and performance improvements.", - "Due to the fact that the weather was bad, we decided to postpone the event.", - "Please find attached the requested documentation for your perusal.", - "Welcome to the team. Review the onboarding materials.", - ) + }, + examplePrompts = + listOf( + "Hey team, just wanted to remind everyone about the meeting tomorrow @ 10. Be there!", + "Our new software update includes several bug fixes and performance improvements.", + "Due to the fact that the weather was bad, we decided to postpone the event.", + "Please find attached the requested documentation for your perusal.", + "Welcome to the team. Review the onboarding materials.", + ), ), SUMMARIZE_TEXT( label = "Summarize text", - config = PromptTemplateConfig( - inputEditors = listOf( - PromptTemplateSingleSelectInputEditor( - label = InputEditorLabel.STYLE.label, - options = SummarizationType.entries.map { it.label }, - defaultOption = SummarizationType.KEY_BULLET_POINT.label - ) - ) - ), + config = + PromptTemplateConfig( + inputEditors = + listOf( + PromptTemplateSingleSelectInputEditor( + label = InputEditorLabel.STYLE.label, + options = SummarizationType.entries.map { it.label }, + defaultOption = SummarizationType.KEY_BULLET_POINT.label, + ) + ) + ), genFullPrompt = { userInput, inputEditorValues -> val style = inputEditorValues[InputEditorLabel.STYLE.label] as String buildAnnotatedString { @@ -149,37 +164,38 @@ enum class PromptTemplateType( append(userInput) } }, - examplePrompts = listOf( - "The new Pixel phone features an advanced camera system with improved low-light performance and AI-powered editing tools. The display is brighter and more energy-efficient. It runs on the latest Tensor chip, offering faster processing and enhanced security features. Battery life has also been extended, providing all-day power for most users.", - "Beginning this Friday, January 24, giant pandas Bao Li and Qing Bao are officially on view to the public at the Smithsonian’s National Zoo and Conservation Biology Institute (NZCBI). The 3-year-old bears arrived in Washington this past October, undergoing a quarantine period before making their debut. Under NZCBI’s new agreement with the CWCA, Qing Bao and Bao Li will remain in the United States for ten years, until April 2034, in exchange for an annual fee of \$1 million. The pair are still too young to breed, as pandas only reach sexual maturity between ages 4 and 7. “Kind of picture them as like awkward teenagers right now,” Lally told WUSA9. “We still have about two years before we would probably even see signs that they’re ready to start mating.”", - ), + examplePrompts = + listOf( + "The new Pixel phone features an advanced camera system with improved low-light performance and AI-powered editing tools. The display is brighter and more energy-efficient. It runs on the latest Tensor chip, offering faster processing and enhanced security features. Battery life has also been extended, providing all-day power for most users.", + "Beginning this Friday, January 24, giant pandas Bao Li and Qing Bao are officially on view to the public at the Smithsonian’s National Zoo and Conservation Biology Institute (NZCBI). The 3-year-old bears arrived in Washington this past October, undergoing a quarantine period before making their debut. Under NZCBI’s new agreement with the CWCA, Qing Bao and Bao Li will remain in the United States for ten years, until April 2034, in exchange for an annual fee of \$1 million. The pair are still too young to breed, as pandas only reach sexual maturity between ages 4 and 7. “Kind of picture them as like awkward teenagers right now,” Lally told WUSA9. “We still have about two years before we would probably even see signs that they’re ready to start mating.”", + ), ), CODE_SNIPPET( label = "Code snippet", - config = PromptTemplateConfig( - inputEditors = listOf( - PromptTemplateSingleSelectInputEditor( - label = InputEditorLabel.LANGUAGE.label, - options = LanguageType.entries.map { it.label }, - defaultOption = LanguageType.JAVASCRIPT.label - ) - ) - ), + config = + PromptTemplateConfig( + inputEditors = + listOf( + PromptTemplateSingleSelectInputEditor( + label = InputEditorLabel.LANGUAGE.label, + options = LanguageType.entries.map { it.label }, + defaultOption = LanguageType.JAVASCRIPT.label, + ) + ) + ), genFullPrompt = { userInput, inputEditorValues -> val language = inputEditorValues[InputEditorLabel.LANGUAGE.label] as String buildAnnotatedString { - withStyle(GEMINI_GRADIENT_STYLE) { - append("Write a $language code snippet to ") - } + withStyle(GEMINI_GRADIENT_STYLE) { append("Write a $language code snippet to ") } append(userInput) } }, - examplePrompts = listOf( - "Create an alert box that says \"Hello, World!\"", - "Declare an immutable variable named 'appName' with the value \"AI Gallery\"", - "Print the numbers from 1 to 5 using a for loop.", - "Write a function that returns the square of an integer input.", - ), + examplePrompts = + listOf( + "Create an alert box that says \"Hello, World!\"", + "Declare an immutable variable named 'appName' with the value \"AI Gallery\"", + "Print the numbers from 1 to 5 using a for loop.", + "Write a function that returns the square of an integer input.", + ), ), } - diff --git a/Android/src/app/src/main/java/com/google/ai/edge/gallery/ui/llmsingleturn/PromptTemplatesPanel.kt b/Android/src/app/src/main/java/com/google/ai/edge/gallery/ui/llmsingleturn/PromptTemplatesPanel.kt index 59bdc0e..8250023 100644 --- a/Android/src/app/src/main/java/com/google/ai/edge/gallery/ui/llmsingleturn/PromptTemplatesPanel.kt +++ b/Android/src/app/src/main/java/com/google/ai/edge/gallery/ui/llmsingleturn/PromptTemplatesPanel.kt @@ -16,6 +16,7 @@ package com.google.ai.edge.gallery.ui.llmsingleturn +import android.content.ClipData import androidx.compose.foundation.BorderStroke import androidx.compose.foundation.background import androidx.compose.foundation.border @@ -79,7 +80,8 @@ import androidx.compose.ui.draw.clip import androidx.compose.ui.focus.FocusRequester import androidx.compose.ui.focus.focusRequester import androidx.compose.ui.graphics.Color -import androidx.compose.ui.platform.LocalClipboardManager +import androidx.compose.ui.platform.ClipEntry +import androidx.compose.ui.platform.LocalClipboard import androidx.compose.ui.platform.LocalFocusManager import androidx.compose.ui.res.dimensionResource import androidx.compose.ui.text.TextLayoutResult @@ -108,7 +110,7 @@ fun PromptTemplatesPanel( modelManagerViewModel: ModelManagerViewModel, onSend: (fullPrompt: String) -> Unit, onStopButtonClicked: (Model) -> Unit, - modifier: Modifier = Modifier + modifier: Modifier = Modifier, ) { val scope = rememberCoroutineScope() val uiState by viewModel.uiState.collectAsState() @@ -125,13 +127,12 @@ fun PromptTemplatesPanel( uiState.selectedPromptTemplateType.genFullPrompt(curTextInputContent, inputEditorValues) } } - val clipboardManager = LocalClipboardManager.current + val clipboard = LocalClipboard.current val focusRequester = remember { FocusRequester() } val focusManager = LocalFocusManager.current val interactionSource = remember { MutableInteractionSource() } val expandedStates = remember { mutableStateMapOf() } - val modelInitializationStatus = - modelManagerUiState.modelInitializationStatus[model.name] + val modelInitializationStatus = modelManagerUiState.modelInitializationStatus[model.name] // Update input editor values when prompt template changes. LaunchedEffect(selectedPromptTemplateType) { @@ -147,11 +148,10 @@ fun PromptTemplatesPanel( Column(modifier = modifier) { // Scrollable tab row for all prompt templates. - PrimaryScrollableTabRow( - selectedTabIndex = selectedTabIndex - ) { + PrimaryScrollableTabRow(selectedTabIndex = selectedTabIndex) { TAB_TITLES.forEachIndexed { index, title -> - Tab(selected = selectedTabIndex == index, + Tab( + selected = selectedTabIndex == index, enabled = !inProgress, onClick = { // Clear input when tab changes. @@ -162,41 +162,34 @@ fun PromptTemplatesPanel( selectedTabIndex = index viewModel.selectPromptTemplate( model = model, - promptTemplateType = promptTemplateTypes[index] + promptTemplateType = promptTemplateTypes[index], ) }, - text = { - Text( - text = title, - modifier = Modifier.alpha(if (inProgress) 0.5f else 1f) - ) - }) + text = { Text(text = title, modifier = Modifier.alpha(if (inProgress) 0.5f else 1f)) }, + ) } } // Content. - Column( - modifier = Modifier - .weight(1f) - .fillMaxWidth() - ) { + Column(modifier = Modifier.weight(1f).fillMaxWidth()) { // Input editor row. if (selectedPromptTemplateType.config.inputEditors.isNotEmpty()) { Row( verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(8.dp), - modifier = Modifier - .fillMaxWidth() - .background(MaterialTheme.colorScheme.surfaceContainerLow) - .padding(horizontal = 16.dp, vertical = 10.dp) + modifier = + Modifier.fillMaxWidth() + .background(MaterialTheme.colorScheme.surfaceContainerLow) + .padding(horizontal = 16.dp, vertical = 10.dp), ) { // Input editors. for (inputEditor in selectedPromptTemplateType.config.inputEditors) { when (inputEditor.type) { - PromptTemplateInputEditorType.SINGLE_SELECT -> SingleSelectButton(config = inputEditor as PromptTemplateSingleSelectInputEditor, - onSelected = { option -> - inputEditorValues[inputEditor.label] = option - }) + PromptTemplateInputEditorType.SINGLE_SELECT -> + SingleSelectButton( + config = inputEditor as PromptTemplateSingleSelectInputEditor, + onSelected = { option -> inputEditorValues[inputEditor.label] = option }, + ) } } } @@ -205,12 +198,10 @@ fun PromptTemplatesPanel( // Text input box. Box(contentAlignment = Alignment.BottomCenter, modifier = Modifier.weight(1f)) { Column( - modifier = Modifier - .fillMaxSize() - .verticalScroll(rememberScrollState()) - .clickable( + modifier = + Modifier.fillMaxSize().verticalScroll(rememberScrollState()).clickable( interactionSource = interactionSource, - indication = null // Disable the ripple effect + indication = null, // Disable the ripple effect ) { // Request focus on the TextField when the Column is clicked focusRequester.requestFocus() @@ -220,32 +211,31 @@ fun PromptTemplatesPanel( Text( fullPrompt, style = MaterialTheme.typography.bodyMedium, - modifier = Modifier - .fillMaxWidth() - .padding(16.dp) - .padding(bottom = 40.dp) - .clip(MessageBubbleShape(radius = bubbleBorderRadius)) - .background(MaterialTheme.customColors.agentBubbleBgColor) - .padding(16.dp) - .focusRequester(focusRequester) + modifier = + Modifier.fillMaxWidth() + .padding(16.dp) + .padding(bottom = 40.dp) + .clip(MessageBubbleShape(radius = bubbleBorderRadius)) + .background(MaterialTheme.customColors.agentBubbleBgColor) + .padding(16.dp) + .focusRequester(focusRequester), ) } else { TextField( value = curTextInputContent, onValueChange = { curTextInputContent = it }, - colors = TextFieldDefaults.colors( - unfocusedContainerColor = Color.Transparent, - focusedContainerColor = Color.Transparent, - focusedIndicatorColor = Color.Transparent, - unfocusedIndicatorColor = Color.Transparent, - disabledIndicatorColor = Color.Transparent, - disabledContainerColor = Color.Transparent, - ), + colors = + TextFieldDefaults.colors( + unfocusedContainerColor = Color.Transparent, + focusedContainerColor = Color.Transparent, + focusedIndicatorColor = Color.Transparent, + unfocusedIndicatorColor = Color.Transparent, + disabledIndicatorColor = Color.Transparent, + disabledContainerColor = Color.Transparent, + ), textStyle = MaterialTheme.typography.bodyLarge, placeholder = { Text("Enter content") }, - modifier = Modifier - .padding(bottom = 40.dp) - .focusRequester(focusRequester) + modifier = Modifier.padding(bottom = 40.dp).focusRequester(focusRequester), ) } } @@ -254,26 +244,35 @@ fun PromptTemplatesPanel( Row( verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(4.dp), - modifier = Modifier - .fillMaxWidth() - .padding(vertical = 4.dp, horizontal = 16.dp) + modifier = Modifier.fillMaxWidth().padding(vertical = 4.dp, horizontal = 16.dp), ) { // Full prompt switch. - if (selectedPromptTemplateType != PromptTemplateType.FREE_FORM && curTextInputContent.isNotEmpty()) { - Row(verticalAlignment = Alignment.CenterVertically, + if ( + selectedPromptTemplateType != PromptTemplateType.FREE_FORM && + curTextInputContent.isNotEmpty() + ) { + Row( + verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(4.dp), - modifier = Modifier - .clip(CircleShape) - .background(if (inputEditorValues[FULL_PROMPT_SWITCH_KEY] as Boolean) MaterialTheme.colorScheme.secondaryContainer else MaterialTheme.customColors.agentBubbleBgColor) - .clickable { - inputEditorValues[FULL_PROMPT_SWITCH_KEY] = - !(inputEditorValues[FULL_PROMPT_SWITCH_KEY] as Boolean) - } - .height(40.dp) - .border( - width = 1.dp, color = MaterialTheme.colorScheme.surface, shape = CircleShape - ) - .padding(horizontal = 12.dp)) { + modifier = + Modifier.clip(CircleShape) + .background( + if (inputEditorValues[FULL_PROMPT_SWITCH_KEY] as Boolean) + MaterialTheme.colorScheme.secondaryContainer + else MaterialTheme.customColors.agentBubbleBgColor + ) + .clickable { + inputEditorValues[FULL_PROMPT_SWITCH_KEY] = + !(inputEditorValues[FULL_PROMPT_SWITCH_KEY] as Boolean) + } + .height(40.dp) + .border( + width = 1.dp, + color = MaterialTheme.colorScheme.surface, + shape = CircleShape, + ) + .padding(horizontal = 12.dp), + ) { if (inputEditorValues[FULL_PROMPT_SWITCH_KEY] as Boolean) { Icon( imageVector = Icons.Rounded.Visibility, @@ -284,9 +283,7 @@ fun PromptTemplatesPanel( Icon( imageVector = Icons.Rounded.VisibilityOff, contentDescription = "", - modifier = Modifier - .size(FilterChipDefaults.IconSize) - .alpha(0.3f), + modifier = Modifier.size(FilterChipDefaults.IconSize).alpha(0.3f), ) } Text("Preview prompt", style = MaterialTheme.typography.labelMedium) @@ -299,20 +296,27 @@ fun PromptTemplatesPanel( if (curTextInputContent.isNotEmpty()) { OutlinedIconButton( onClick = { - val clipData = fullPrompt - clipboardManager.setText(clipData) + scope.launch { + val clipData = ClipData.newPlainText("prompt", fullPrompt) + val clipEntry = ClipEntry(clipData = clipData) + clipboard.setClipEntry(clipEntry = clipEntry) + } }, - colors = IconButtonDefaults.iconButtonColors( - containerColor = MaterialTheme.customColors.agentBubbleBgColor, - disabledContainerColor = MaterialTheme.customColors.agentBubbleBgColor.copy(alpha = 0.4f), - contentColor = MaterialTheme.colorScheme.primary, - disabledContentColor = MaterialTheme.colorScheme.primary.copy(alpha = 0.2f), - ), + colors = + IconButtonDefaults.iconButtonColors( + containerColor = MaterialTheme.customColors.agentBubbleBgColor, + disabledContainerColor = + MaterialTheme.customColors.agentBubbleBgColor.copy(alpha = 0.4f), + contentColor = MaterialTheme.colorScheme.primary, + disabledContentColor = MaterialTheme.colorScheme.primary.copy(alpha = 0.2f), + ), border = BorderStroke(width = 1.dp, color = MaterialTheme.colorScheme.surface), - modifier = Modifier.size(ICON_BUTTON_SIZE) + modifier = Modifier.size(ICON_BUTTON_SIZE), ) { Icon( - Icons.Outlined.ContentCopy, contentDescription = "", modifier = Modifier.size(20.dp) + Icons.Outlined.ContentCopy, + contentDescription = "", + modifier = Modifier.size(20.dp), ) } } @@ -321,38 +325,35 @@ fun PromptTemplatesPanel( OutlinedIconButton( enabled = !inProgress, onClick = { showExamplePromptBottomSheet = true }, - colors = IconButtonDefaults.iconButtonColors( - containerColor = MaterialTheme.customColors.agentBubbleBgColor, - disabledContainerColor = MaterialTheme.customColors.agentBubbleBgColor.copy(alpha = 0.4f), - contentColor = MaterialTheme.colorScheme.primary, - disabledContentColor = MaterialTheme.colorScheme.primary.copy(alpha = 0.2f), - ), + colors = + IconButtonDefaults.iconButtonColors( + containerColor = MaterialTheme.customColors.agentBubbleBgColor, + disabledContainerColor = + MaterialTheme.customColors.agentBubbleBgColor.copy(alpha = 0.4f), + contentColor = MaterialTheme.colorScheme.primary, + disabledContentColor = MaterialTheme.colorScheme.primary.copy(alpha = 0.2f), + ), border = BorderStroke(width = 1.dp, color = MaterialTheme.colorScheme.surface), - modifier = Modifier.size(ICON_BUTTON_SIZE) + modifier = Modifier.size(ICON_BUTTON_SIZE), ) { - Icon( - Icons.Rounded.Add, - contentDescription = "", - modifier = Modifier.size(20.dp), - ) + Icon(Icons.Rounded.Add, contentDescription = "", modifier = Modifier.size(20.dp)) } val modelInitializing = modelInitializationStatus?.status == ModelInitializationStatusType.INITIALIZING if (inProgress && !modelInitializing && !uiState.preparing) { IconButton( - onClick = { - onStopButtonClicked(model) - }, - colors = IconButtonDefaults.iconButtonColors( - containerColor = MaterialTheme.colorScheme.secondaryContainer, - ), - modifier = Modifier.size(ICON_BUTTON_SIZE) + onClick = { onStopButtonClicked(model) }, + colors = + IconButtonDefaults.iconButtonColors( + containerColor = MaterialTheme.colorScheme.secondaryContainer + ), + modifier = Modifier.size(ICON_BUTTON_SIZE), ) { Icon( Icons.Rounded.Stop, contentDescription = "", - tint = MaterialTheme.colorScheme.primary + tint = MaterialTheme.colorScheme.primary, ) } } else { @@ -363,21 +364,21 @@ fun PromptTemplatesPanel( focusManager.clearFocus() onSend(fullPrompt.text) }, - colors = IconButtonDefaults.iconButtonColors( - containerColor = MaterialTheme.colorScheme.secondaryContainer, - disabledContainerColor = MaterialTheme.colorScheme.secondaryContainer.copy(alpha = 0.3f), - contentColor = MaterialTheme.colorScheme.primary, - disabledContentColor = MaterialTheme.colorScheme.primary.copy(alpha = 0.1f), - ), + colors = + IconButtonDefaults.iconButtonColors( + containerColor = MaterialTheme.colorScheme.secondaryContainer, + disabledContainerColor = + MaterialTheme.colorScheme.secondaryContainer.copy(alpha = 0.3f), + contentColor = MaterialTheme.colorScheme.primary, + disabledContentColor = MaterialTheme.colorScheme.primary.copy(alpha = 0.1f), + ), border = BorderStroke(width = 1.dp, color = MaterialTheme.colorScheme.surface), - modifier = Modifier.size(ICON_BUTTON_SIZE) + modifier = Modifier.size(ICON_BUTTON_SIZE), ) { Icon( Icons.AutoMirrored.Rounded.Send, contentDescription = "", - modifier = Modifier - .size(20.dp) - .offset(x = 2.dp), + modifier = Modifier.size(20.dp).offset(x = 2.dp), ) } } @@ -396,89 +397,82 @@ fun PromptTemplatesPanel( // Title Text( "Select an example", - modifier = Modifier - .fillMaxWidth() - .padding(16.dp), - style = MaterialTheme.typography.titleLarge + modifier = Modifier.fillMaxWidth().padding(16.dp), + style = MaterialTheme.typography.titleLarge, ) // Examples for (prompt in selectedPromptTemplateType.examplePrompts) { var textLayoutResultState by remember { mutableStateOf(null) } - val hasOverflow = remember(textLayoutResultState) { - textLayoutResultState?.hasVisualOverflow ?: false - } + val hasOverflow = + remember(textLayoutResultState) { textLayoutResultState?.hasVisualOverflow ?: false } val isExpanded = expandedStates[prompt] ?: false Column( - modifier = Modifier - .fillMaxWidth() - .clickable { - curTextInputContent = prompt - scope.launch { - // Give it sometime to show the click effect. - delay(200) - showExamplePromptBottomSheet = false + modifier = + Modifier.fillMaxWidth() + .clickable { + curTextInputContent = prompt + scope.launch { + // Give it sometime to show the click effect. + delay(200) + showExamplePromptBottomSheet = false + } } - } - .padding(horizontal = 16.dp, vertical = 8.dp), + .padding(horizontal = 16.dp, vertical = 8.dp) ) { Row( verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(8.dp), ) { Icon(Icons.Outlined.Description, contentDescription = "") - Text(prompt, + Text( + prompt, maxLines = if (isExpanded) Int.MAX_VALUE else 3, overflow = TextOverflow.Ellipsis, style = MaterialTheme.typography.bodySmall, modifier = Modifier.weight(1f), - onTextLayout = { textLayoutResultState = it } - + onTextLayout = { textLayoutResultState = it }, ) } if (hasOverflow && !isExpanded) { Row( - modifier = Modifier - .fillMaxWidth() - .padding(top = 2.dp), - horizontalArrangement = Arrangement.End + modifier = Modifier.fillMaxWidth().padding(top = 2.dp), + horizontalArrangement = Arrangement.End, ) { - Box(modifier = Modifier - .padding(end = 16.dp) - .clip(CircleShape) - .background(MaterialTheme.colorScheme.surfaceContainerHighest) - .clickable { - expandedStates[prompt] = true - } - .padding(vertical = 1.dp, horizontal = 6.dp)) { + Box( + modifier = + Modifier.padding(end = 16.dp) + .clip(CircleShape) + .background(MaterialTheme.colorScheme.surfaceContainerHighest) + .clickable { expandedStates[prompt] = true } + .padding(vertical = 1.dp, horizontal = 6.dp) + ) { Icon( Icons.Outlined.ExpandMore, contentDescription = "", - modifier = Modifier.size(12.dp) + modifier = Modifier.size(12.dp), ) } } } else if (isExpanded) { Row( - modifier = Modifier - .fillMaxWidth() - .padding(top = 2.dp), - horizontalArrangement = Arrangement.End + modifier = Modifier.fillMaxWidth().padding(top = 2.dp), + horizontalArrangement = Arrangement.End, ) { - Box(modifier = Modifier - .padding(end = 16.dp) - .clip(CircleShape) - .background(MaterialTheme.colorScheme.surfaceContainerHighest) - .clickable { - expandedStates[prompt] = false - } - .padding(vertical = 1.dp, horizontal = 6.dp)) { + Box( + modifier = + Modifier.padding(end = 16.dp) + .clip(CircleShape) + .background(MaterialTheme.colorScheme.surfaceContainerHighest) + .clickable { expandedStates[prompt] = false } + .padding(vertical = 1.dp, horizontal = 6.dp) + ) { Icon( Icons.Outlined.ExpandLess, contentDescription = "", - modifier = Modifier.size(12.dp) + modifier = Modifier.size(12.dp), ) } } diff --git a/Android/src/app/src/main/java/com/google/ai/edge/gallery/ui/llmsingleturn/ResponsePanel.kt b/Android/src/app/src/main/java/com/google/ai/edge/gallery/ui/llmsingleturn/ResponsePanel.kt index 28116a9..fcae08c 100644 --- a/Android/src/app/src/main/java/com/google/ai/edge/gallery/ui/llmsingleturn/ResponsePanel.kt +++ b/Android/src/app/src/main/java/com/google/ai/edge/gallery/ui/llmsingleturn/ResponsePanel.kt @@ -16,6 +16,7 @@ package com.google.ai.edge.gallery.ui.llmsingleturn +import android.content.ClipData import android.util.Log import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box @@ -49,24 +50,26 @@ import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableIntStateOf import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.runtime.setValue import androidx.compose.runtime.snapshotFlow import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.alpha import androidx.compose.ui.graphics.Color -import androidx.compose.ui.platform.LocalClipboardManager -import androidx.compose.ui.text.AnnotatedString +import androidx.compose.ui.platform.ClipEntry +import androidx.compose.ui.platform.LocalClipboard import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp import com.google.ai.edge.gallery.data.ConfigKey import com.google.ai.edge.gallery.data.Model import com.google.ai.edge.gallery.data.TASK_LLM_PROMPT_LAB -import com.google.ai.edge.gallery.ui.common.chat.MarkdownText +import com.google.ai.edge.gallery.ui.common.MarkdownText import com.google.ai.edge.gallery.ui.common.chat.MessageBodyBenchmarkLlm import com.google.ai.edge.gallery.ui.common.chat.MessageBodyLoading import com.google.ai.edge.gallery.ui.modelmanager.ModelManagerViewModel import com.google.ai.edge.gallery.ui.modelmanager.PagerScrollState +import kotlinx.coroutines.launch private val OPTIONS = listOf("Response", "Benchmark") private val ICONS = listOf(Icons.Outlined.AutoAwesome, Icons.Outlined.Timer) @@ -88,23 +91,21 @@ fun ResponsePanel( val selectedPromptTemplateType = uiState.selectedPromptTemplateType val responseScrollState = rememberScrollState() var selectedOptionIndex by remember { mutableIntStateOf(0) } - val clipboardManager = LocalClipboardManager.current - val pagerState = rememberPagerState( - initialPage = task.models.indexOf(model), - pageCount = { task.models.size }) + val clipboard = LocalClipboard.current + val scope = rememberCoroutineScope() + val pagerState = + rememberPagerState(initialPage = task.models.indexOf(model), pageCount = { task.models.size }) val accelerator = model.getStringConfigValue(key = ConfigKey.ACCELERATOR, defaultValue = "") // Select the "response" tab when prompt template changes. - LaunchedEffect(selectedPromptTemplateType) { - selectedOptionIndex = 0 - } + LaunchedEffect(selectedPromptTemplateType) { selectedOptionIndex = 0 } // Update selected model and clean up previous model when page is settled on a model page. LaunchedEffect(pagerState.settledPage) { val curSelectedModel = task.models[pagerState.settledPage] Log.d( TAG, - "Pager settled on model '${curSelectedModel.name}' from '${model.name}'. Updating selected model." + "Pager settled on model '${curSelectedModel.name}' from '${model.name}'. Updating selected model.", ) if (curSelectedModel.name != model.name) { modelManagerViewModel.cleanupModel(task = task, model = model) @@ -115,13 +116,12 @@ fun ResponsePanel( // Trigger scroll sync. LaunchedEffect(pagerState) { snapshotFlow { - PagerScrollState( - page = pagerState.currentPage, - offset = pagerState.currentPageOffsetFraction - ) - }.collect { scrollState -> - modelManagerViewModel.pagerScrollState.value = scrollState - } + PagerScrollState( + page = pagerState.currentPage, + offset = pagerState.currentPageOffsetFraction, + ) + } + .collect { scrollState -> modelManagerViewModel.pagerScrollState.value = scrollState } } // Scroll pager when selected model changes. @@ -147,9 +147,7 @@ fun ResponsePanel( if (initializing) { Box( contentAlignment = Alignment.TopStart, - modifier = modifier - .fillMaxSize() - .padding(horizontal = 16.dp) + modifier = modifier.fillMaxSize().padding(horizontal = 16.dp), ) { MessageBodyLoading() } @@ -159,7 +157,7 @@ fun ResponsePanel( Row( modifier = Modifier.fillMaxSize(), horizontalArrangement = Arrangement.Center, - verticalAlignment = Alignment.CenterVertically + verticalAlignment = Alignment.CenterVertically, ) { Text( "Response will appear here", @@ -170,11 +168,7 @@ fun ResponsePanel( } // Response markdown. else { - Column( - modifier = modifier - .padding(horizontal = 16.dp) - .padding(bottom = 4.dp) - ) { + Column(modifier = modifier.padding(horizontal = 16.dp).padding(bottom = 4.dp)) { // Response/benchmark switch. Row(modifier = Modifier.fillMaxWidth()) { PrimaryTabRow( @@ -182,66 +176,64 @@ fun ResponsePanel( containerColor = Color.Transparent, ) { OPTIONS.forEachIndexed { index, title -> - Tab(selected = selectedOptionIndex == index, onClick = { - selectedOptionIndex = index - }, text = { - Row( - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.spacedBy(4.dp) - ) { - Icon( - ICONS[index], - contentDescription = "", - modifier = Modifier - .size(16.dp) - .alpha(0.7f) - ) - var curTitle = title - if (accelerator.isNotEmpty()) { - curTitle = "$curTitle on $accelerator" - } - val titleColor = MaterialTheme.colorScheme.primary - BasicText( - text = curTitle, - maxLines = 1, - color = { titleColor }, - style = MaterialTheme.typography.bodyMedium, - autoSize = TextAutoSize.StepBased( - minFontSize = 9.sp, - maxFontSize = 14.sp, - stepSize = 1.sp + Tab( + selected = selectedOptionIndex == index, + onClick = { selectedOptionIndex = index }, + text = { + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(4.dp), + ) { + Icon( + ICONS[index], + contentDescription = "", + modifier = Modifier.size(16.dp).alpha(0.7f), ) - ) - } - }) + var curTitle = title + if (accelerator.isNotEmpty()) { + curTitle = "$curTitle on $accelerator" + } + val titleColor = MaterialTheme.colorScheme.primary + BasicText( + text = curTitle, + maxLines = 1, + color = { titleColor }, + style = MaterialTheme.typography.bodyMedium, + autoSize = + TextAutoSize.StepBased( + minFontSize = 9.sp, + maxFontSize = 14.sp, + stepSize = 1.sp, + ), + ) + } + }, + ) } } } if (selectedOptionIndex == 0) { - Box( - contentAlignment = Alignment.BottomEnd, - modifier = Modifier.weight(1f) - ) { - Column( - modifier = Modifier - .fillMaxSize() - .verticalScroll(responseScrollState) - ) { + Box(contentAlignment = Alignment.BottomEnd, modifier = Modifier.weight(1f)) { + Column(modifier = Modifier.fillMaxSize().verticalScroll(responseScrollState)) { MarkdownText( text = response, - modifier = Modifier.padding(top = 8.dp, bottom = 40.dp) + modifier = Modifier.padding(top = 8.dp, bottom = 40.dp), ) } // Copy button. IconButton( onClick = { - val clipData = AnnotatedString(response) - clipboardManager.setText(clipData) + scope.launch { + val clipData = ClipData.newPlainText("response", response) + val clipEntry = ClipEntry(clipData = clipData) + clipboard.setClipEntry(clipEntry = clipEntry) + } }, - colors = IconButtonDefaults.iconButtonColors( - containerColor = MaterialTheme.colorScheme.surfaceContainerHighest, - contentColor = MaterialTheme.colorScheme.primary, - ), + colors = + IconButtonDefaults.iconButtonColors( + containerColor = MaterialTheme.colorScheme.surfaceContainerHighest, + contentColor = MaterialTheme.colorScheme.primary, + ), ) { Icon( Icons.Outlined.ContentCopy, diff --git a/Android/src/app/src/main/java/com/google/ai/edge/gallery/ui/llmsingleturn/SingleSelectButton.kt b/Android/src/app/src/main/java/com/google/ai/edge/gallery/ui/llmsingleturn/SingleSelectButton.kt index 76d3747..0969d8c 100644 --- a/Android/src/app/src/main/java/com/google/ai/edge/gallery/ui/llmsingleturn/SingleSelectButton.kt +++ b/Android/src/app/src/main/java/com/google/ai/edge/gallery/ui/llmsingleturn/SingleSelectButton.kt @@ -44,36 +44,29 @@ import androidx.compose.ui.unit.dp @Composable fun SingleSelectButton( config: PromptTemplateSingleSelectInputEditor, - onSelected: (String) -> Unit + onSelected: (String) -> Unit, ) { var showMenu by remember { mutableStateOf(false) } var selectedOption by remember { mutableStateOf(config.defaultOption) } - LaunchedEffect(config) { - selectedOption = config.defaultOption - } + LaunchedEffect(config) { selectedOption = config.defaultOption } Box { Row( verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(2.dp), - modifier = Modifier - .clip(RoundedCornerShape(8.dp)) - .background(MaterialTheme.colorScheme.secondaryContainer) - .clickable { - showMenu = true - } - .padding(vertical = 4.dp, horizontal = 6.dp) - .padding(start = 8.dp) + modifier = + Modifier.clip(RoundedCornerShape(8.dp)) + .background(MaterialTheme.colorScheme.secondaryContainer) + .clickable { showMenu = true } + .padding(vertical = 4.dp, horizontal = 6.dp) + .padding(start = 8.dp), ) { Text("${config.label}: $selectedOption", style = MaterialTheme.typography.labelLarge) Icon(Icons.Rounded.ArrowDropDown, contentDescription = "") } - DropdownMenu( - expanded = showMenu, - onDismissRequest = { showMenu = false } - ) { + DropdownMenu(expanded = showMenu, onDismissRequest = { showMenu = false }) { // Options for (option in config.options) { DropdownMenuItem( @@ -82,9 +75,9 @@ fun SingleSelectButton( selectedOption = option showMenu = false onSelected(option) - } + }, ) } } } -} \ No newline at end of file +} diff --git a/Android/src/app/src/main/java/com/google/ai/edge/gallery/ui/llmsingleturn/VerticalSplitView.kt b/Android/src/app/src/main/java/com/google/ai/edge/gallery/ui/llmsingleturn/VerticalSplitView.kt index c91bcb5..f2ac6af 100644 --- a/Android/src/app/src/main/java/com/google/ai/edge/gallery/ui/llmsingleturn/VerticalSplitView.kt +++ b/Android/src/app/src/main/java/com/google/ai/edge/gallery/ui/llmsingleturn/VerticalSplitView.kt @@ -16,6 +16,8 @@ package com.google.ai.edge.gallery.ui.llmsingleturn +// import androidx.compose.ui.tooling.preview.Preview +// import com.google.ai.edge.gallery.ui.theme.GalleryTheme import androidx.compose.foundation.background import androidx.compose.foundation.gestures.detectDragGestures import androidx.compose.foundation.layout.Box @@ -26,7 +28,6 @@ import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.width import androidx.compose.foundation.shape.CircleShape import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableFloatStateOf @@ -39,10 +40,8 @@ import androidx.compose.ui.draw.clip import androidx.compose.ui.input.pointer.pointerInput import androidx.compose.ui.layout.onGloballyPositioned import androidx.compose.ui.platform.LocalDensity -import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.dp -import com.google.ai.edge.gallery.ui.theme.GalleryTheme import com.google.ai.edge.gallery.ui.theme.customColors @Composable @@ -53,81 +52,59 @@ fun VerticalSplitView( initialRatio: Float = 0.5f, minTopHeight: Dp = 250.dp, minBottomHeight: Dp = 200.dp, - handleThickness: Dp = 20.dp + handleThickness: Dp = 20.dp, ) { var splitRatio by remember { mutableFloatStateOf(initialRatio) } - var columnHeightPx by remember { - mutableFloatStateOf(0f) - } - var columnHeightDp by remember { - mutableStateOf(0.dp) - } + var columnHeightPx by remember { mutableFloatStateOf(0f) } + var columnHeightDp by remember { mutableStateOf(0.dp) } val localDensity = LocalDensity.current - Column(modifier = modifier - .fillMaxSize() - .onGloballyPositioned { coordinates -> - // Set column height using the LayoutCoordinates - columnHeightPx = coordinates.size.height.toFloat() - columnHeightDp = with(localDensity) { coordinates.size.height.toDp() } - } + Column( + modifier = + modifier.fillMaxSize().onGloballyPositioned { coordinates -> + // Set column height using the LayoutCoordinates + columnHeightPx = coordinates.size.height.toFloat() + columnHeightDp = with(localDensity) { coordinates.size.height.toDp() } + } ) { - Box( - modifier = Modifier - .fillMaxWidth() - .weight(splitRatio) - ) { - topView() - } + Box(modifier = Modifier.fillMaxWidth().weight(splitRatio)) { topView() } Box( - modifier = Modifier - .fillMaxWidth() - .height(handleThickness) - .background(MaterialTheme.customColors.agentBubbleBgColor) - .pointerInput(Unit) { - detectDragGestures { change, dragAmount -> - val newTopHeightPx = columnHeightPx * splitRatio + dragAmount.y - var newTopHeightDp = with(localDensity) { newTopHeightPx.toDp() } - if (newTopHeightDp < minTopHeight) { - newTopHeightDp = minTopHeight + modifier = + Modifier.fillMaxWidth() + .height(handleThickness) + .background(MaterialTheme.customColors.agentBubbleBgColor) + .pointerInput(Unit) { + detectDragGestures { change, dragAmount -> + val newTopHeightPx = columnHeightPx * splitRatio + dragAmount.y + var newTopHeightDp = with(localDensity) { newTopHeightPx.toDp() } + if (newTopHeightDp < minTopHeight) { + newTopHeightDp = minTopHeight + } + if (columnHeightDp - newTopHeightDp < minBottomHeight) { + newTopHeightDp = columnHeightDp - minBottomHeight + } + splitRatio = newTopHeightDp / columnHeightDp + change.consume() } - if (columnHeightDp - newTopHeightDp < minBottomHeight) { - newTopHeightDp = columnHeightDp - minBottomHeight - } - splitRatio = newTopHeightDp / columnHeightDp - change.consume() - } - }, - contentAlignment = Alignment.Center + }, + contentAlignment = Alignment.Center, ) { Box( - modifier = Modifier - .width(32.dp) - .height(4.dp) - .clip(CircleShape) - .background(MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.4f)) + modifier = + Modifier.width(32.dp) + .height(4.dp) + .clip(CircleShape) + .background(MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.4f)) ) } - Box( - modifier = Modifier - .fillMaxWidth() - .weight(1f - splitRatio) - ) { - bottomView() - } + Box(modifier = Modifier.fillMaxWidth().weight(1f - splitRatio)) { bottomView() } } } -@Preview(showBackground = true) -@Composable -fun VerticalSplitViewPreview() { - GalleryTheme { - VerticalSplitView(topView = { - Text("top") - }, bottomView = { - Text("bottom") - }) - } -} \ No newline at end of file +// @Preview(showBackground = true) +// @Composable +// fun VerticalSplitViewPreview() { +// GalleryTheme { VerticalSplitView(topView = { Text("top") }, bottomView = { Text("bottom") }) } +// } diff --git a/Android/src/app/src/main/java/com/google/ai/edge/gallery/ui/modelmanager/ModelList.kt b/Android/src/app/src/main/java/com/google/ai/edge/gallery/ui/modelmanager/ModelList.kt index 806a6e6..ffe8ff8 100644 --- a/Android/src/app/src/main/java/com/google/ai/edge/gallery/ui/modelmanager/ModelList.kt +++ b/Android/src/app/src/main/java/com/google/ai/edge/gallery/ui/modelmanager/ModelList.kt @@ -16,7 +16,11 @@ package com.google.ai.edge.gallery.ui.modelmanager -import androidx.compose.foundation.clickable +// import androidx.compose.ui.tooling.preview.Preview +// import com.google.ai.edge.gallery.ui.preview.PreviewModelManagerViewModel +// import com.google.ai.edge.gallery.ui.preview.TASK_TEST1 +// import com.google.ai.edge.gallery.ui.theme.GalleryTheme + import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column @@ -24,14 +28,12 @@ import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.layout.size import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.items import androidx.compose.foundation.lazy.rememberLazyListState import androidx.compose.material.icons.Icons import androidx.compose.material.icons.outlined.Code import androidx.compose.material.icons.outlined.Description -import androidx.compose.material3.Icon import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Text import androidx.compose.runtime.Composable @@ -40,23 +42,13 @@ import androidx.compose.runtime.getValue import androidx.compose.runtime.remember import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier -import androidx.compose.ui.graphics.vector.ImageVector -import androidx.compose.ui.platform.LocalContext -import androidx.compose.ui.platform.LocalUriHandler -import androidx.compose.ui.text.AnnotatedString -import androidx.compose.ui.text.SpanStyle import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.style.TextAlign -import androidx.compose.ui.text.style.TextDecoration -import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp import com.google.ai.edge.gallery.data.Model import com.google.ai.edge.gallery.data.Task +import com.google.ai.edge.gallery.ui.common.ClickableLink import com.google.ai.edge.gallery.ui.common.modelitem.ModelItem -import com.google.ai.edge.gallery.ui.preview.PreviewModelManagerViewModel -import com.google.ai.edge.gallery.ui.preview.TASK_TEST1 -import com.google.ai.edge.gallery.ui.theme.GalleryTheme -import com.google.ai.edge.gallery.ui.theme.customColors private const val TAG = "AGModelList" @@ -71,26 +63,28 @@ fun ModelList( ) { // This is just to update "models" list when task.updateTrigger is updated so that the UI can // be properly updated. - val models by remember(task) { - derivedStateOf { - val trigger = task.updateTrigger.value - if (trigger >= 0) { - task.models.toList().filter { !it.imported } - } else { - listOf() + val models by + remember(task) { + derivedStateOf { + val trigger = task.updateTrigger.value + if (trigger >= 0) { + task.models.toList().filter { !it.imported } + } else { + listOf() + } } } - } - val importedModels by remember(task) { - derivedStateOf { - val trigger = task.updateTrigger.value - if (trigger >= 0) { - task.models.toList().filter { it.imported } - } else { - listOf() + val importedModels by + remember(task) { + derivedStateOf { + val trigger = task.updateTrigger.value + if (trigger >= 0) { + task.models.toList().filter { it.imported } + } else { + listOf() + } } } - } val listState = rememberLazyListState() @@ -107,9 +101,7 @@ fun ModelList( task.description, textAlign = TextAlign.Center, style = MaterialTheme.typography.bodyLarge.copy(fontWeight = FontWeight.SemiBold), - modifier = Modifier - .fillMaxWidth() - .padding(horizontal = 16.dp), + modifier = Modifier.fillMaxWidth().padding(horizontal = 16.dp), ) } @@ -117,9 +109,7 @@ fun ModelList( item(key = "urls") { Row( horizontalArrangement = Arrangement.Center, - modifier = Modifier - .fillMaxWidth() - .padding(top = 12.dp, bottom = 16.dp), + modifier = Modifier.fillMaxWidth().padding(top = 12.dp, bottom = 16.dp), ) { Column( horizontalAlignment = Alignment.Start, @@ -127,12 +117,16 @@ fun ModelList( ) { if (task.docUrl.isNotEmpty()) { ClickableLink( - url = task.docUrl, linkText = "API Documentation", icon = Icons.Outlined.Description + url = task.docUrl, + linkText = "API Documentation", + icon = Icons.Outlined.Description, ) } if (task.sourceCodeUrl.isNotEmpty()) { ClickableLink( - url = task.sourceCodeUrl, linkText = "Example code", icon = Icons.Outlined.Code + url = task.sourceCodeUrl, + linkText = "Example code", + icon = Icons.Outlined.Code, ) } } @@ -147,7 +141,7 @@ fun ModelList( task = task, modelManagerViewModel = modelManagerViewModel, onModelClicked = onModelClicked, - modifier = Modifier.padding(horizontal = 12.dp) + modifier = Modifier.padding(horizontal = 12.dp), ) } } @@ -158,9 +152,7 @@ fun ModelList( Text( "Imported models", style = MaterialTheme.typography.bodyMedium.copy(fontWeight = FontWeight.SemiBold), - modifier = Modifier - .padding(horizontal = 16.dp) - .padding(top = 24.dp) + modifier = Modifier.padding(horizontal = 16.dp).padding(top = 24.dp), ) } } @@ -173,7 +165,7 @@ fun ModelList( task = task, modelManagerViewModel = modelManagerViewModel, onModelClicked = onModelClicked, - modifier = Modifier.padding(horizontal = 12.dp) + modifier = Modifier.padding(horizontal = 12.dp), ) } } @@ -181,52 +173,17 @@ fun ModelList( } } -@Composable -fun ClickableLink( - url: String, - linkText: String, - icon: ImageVector, -) { - val uriHandler = LocalUriHandler.current - val annotatedText = AnnotatedString( - text = linkText, spanStyles = listOf( - AnnotatedString.Range( - item = SpanStyle( - color = MaterialTheme.customColors.linkColor, textDecoration = TextDecoration.Underline - ), start = 0, end = linkText.length - ) - ) - ) +// @Preview(showBackground = true) +// @Composable +// fun ModelListPreview() { +// val context = LocalContext.current - Row( - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.Center, - ) { - Icon(icon, contentDescription = "", modifier = Modifier.size(16.dp)) - Text( - text = annotatedText, - textAlign = TextAlign.Center, - style = MaterialTheme.typography.bodyLarge, - modifier = Modifier - .padding(start = 6.dp) - .clickable { - uriHandler.openUri(url) - }, - ) - } -} - -@Preview(showBackground = true) -@Composable -fun ModelListPreview() { - val context = LocalContext.current - - GalleryTheme { - ModelList( - task = TASK_TEST1, - modelManagerViewModel = PreviewModelManagerViewModel(context = context), - onModelClicked = {}, - contentPadding = PaddingValues(all = 16.dp), - ) - } -} +// GalleryTheme { +// ModelList( +// task = TASK_TEST1, +// modelManagerViewModel = PreviewModelManagerViewModel(context = context), +// onModelClicked = {}, +// contentPadding = PaddingValues(all = 16.dp), +// ) +// } +// } diff --git a/Android/src/app/src/main/java/com/google/ai/edge/gallery/ui/modelmanager/ModelManager.kt b/Android/src/app/src/main/java/com/google/ai/edge/gallery/ui/modelmanager/ModelManager.kt index f6eb4f0..3d75277 100644 --- a/Android/src/app/src/main/java/com/google/ai/edge/gallery/ui/modelmanager/ModelManager.kt +++ b/Android/src/app/src/main/java/com/google/ai/edge/gallery/ui/modelmanager/ModelManager.kt @@ -16,6 +16,11 @@ package com.google.ai.edge.gallery.ui.modelmanager +// import androidx.compose.ui.tooling.preview.Preview +// import com.google.ai.edge.gallery.ui.preview.PreviewModelManagerViewModel +// import com.google.ai.edge.gallery.ui.preview.TASK_TEST1 +// import com.google.ai.edge.gallery.ui.theme.GalleryTheme + import androidx.activity.compose.BackHandler import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.material3.ExperimentalMaterial3Api @@ -26,16 +31,11 @@ import androidx.compose.runtime.derivedStateOf import androidx.compose.runtime.getValue import androidx.compose.runtime.remember import androidx.compose.ui.Modifier -import androidx.compose.ui.platform.LocalContext -import androidx.compose.ui.tooling.preview.Preview import com.google.ai.edge.gallery.GalleryTopAppBar import com.google.ai.edge.gallery.data.AppBarAction import com.google.ai.edge.gallery.data.AppBarActionType import com.google.ai.edge.gallery.data.Model import com.google.ai.edge.gallery.data.Task -import com.google.ai.edge.gallery.ui.preview.PreviewModelManagerViewModel -import com.google.ai.edge.gallery.ui.preview.TASK_TEST1 -import com.google.ai.edge.gallery.ui.theme.GalleryTheme /** A screen to manage models. */ @OptIn(ExperimentalMaterial3Api::class) @@ -72,16 +72,14 @@ fun ModelManager( } // Handle system's edge swipe. - BackHandler { - navigateUp() - } + BackHandler { navigateUp() } Scaffold( modifier = modifier, topBar = { GalleryTopAppBar( title = title, - leftAction = AppBarAction(actionType = AppBarActionType.NAVIGATE_UP, actionFn = navigateUp) + leftAction = AppBarAction(actionType = AppBarActionType.NAVIGATE_UP, actionFn = navigateUp), ) }, ) { innerPadding -> @@ -90,22 +88,22 @@ fun ModelManager( modelManagerViewModel = viewModel, contentPadding = innerPadding, onModelClicked = onModelClicked, - modifier = Modifier.fillMaxSize() + modifier = Modifier.fillMaxSize(), ) } } -@Preview -@Composable -fun ModelManagerPreview() { - val context = LocalContext.current +// @Preview +// @Composable +// fun ModelManagerPreview() { +// val context = LocalContext.current - GalleryTheme { - ModelManager( - viewModel = PreviewModelManagerViewModel(context = context), - onModelClicked = {}, - task = TASK_TEST1, - navigateUp = {}, - ) - } -} +// GalleryTheme { +// ModelManager( +// viewModel = PreviewModelManagerViewModel(context = context), +// onModelClicked = {}, +// task = TASK_TEST1, +// navigateUp = {}, +// ) +// } +// } diff --git a/Android/src/app/src/main/java/com/google/ai/edge/gallery/ui/modelmanager/ModelManagerViewModel.kt b/Android/src/app/src/main/java/com/google/ai/edge/gallery/ui/modelmanager/ModelManagerViewModel.kt index 63b5db7..f73b893 100644 --- a/Android/src/app/src/main/java/com/google/ai/edge/gallery/ui/modelmanager/ModelManagerViewModel.kt +++ b/Android/src/app/src/main/java/com/google/ai/edge/gallery/ui/modelmanager/ModelManagerViewModel.kt @@ -17,21 +17,20 @@ package com.google.ai.edge.gallery.ui.modelmanager import android.content.Context -import android.net.Uri import android.util.Log import androidx.activity.result.ActivityResult +import androidx.core.net.toUri import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope +import com.google.ai.edge.gallery.AppLifecycleProvider +import com.google.ai.edge.gallery.common.getJsonResponse import com.google.ai.edge.gallery.data.AGWorkInfo import com.google.ai.edge.gallery.data.Accelerator -import com.google.ai.edge.gallery.data.AccessTokenData import com.google.ai.edge.gallery.data.Config -import com.google.ai.edge.gallery.data.ConfigKey import com.google.ai.edge.gallery.data.DataStoreRepository import com.google.ai.edge.gallery.data.DownloadRepository import com.google.ai.edge.gallery.data.EMPTY_MODEL import com.google.ai.edge.gallery.data.IMPORTS_DIR -import com.google.ai.edge.gallery.data.ImportedModelInfo import com.google.ai.edge.gallery.data.Model import com.google.ai.edge.gallery.data.ModelAllowlist import com.google.ai.edge.gallery.data.ModelDownloadStatus @@ -42,17 +41,19 @@ import com.google.ai.edge.gallery.data.TASK_LLM_CHAT import com.google.ai.edge.gallery.data.TASK_LLM_PROMPT_LAB import com.google.ai.edge.gallery.data.Task import com.google.ai.edge.gallery.data.TaskType -import com.google.ai.edge.gallery.data.ValueType +import com.google.ai.edge.gallery.data.createLlmChatConfigs import com.google.ai.edge.gallery.data.getModelByName +import com.google.ai.edge.gallery.data.processTasks +import com.google.ai.edge.gallery.proto.AccessTokenData +import com.google.ai.edge.gallery.proto.ImportedModel +import com.google.ai.edge.gallery.proto.Theme import com.google.ai.edge.gallery.ui.common.AuthConfig -import com.google.ai.edge.gallery.ui.common.convertValueToTargetType -import com.google.ai.edge.gallery.ui.common.getJsonResponse -import com.google.ai.edge.gallery.ui.common.processTasks -import com.google.ai.edge.gallery.ui.imageclassification.ImageClassificationModelHelper -import com.google.ai.edge.gallery.ui.imagegeneration.ImageGenerationModelHelper import com.google.ai.edge.gallery.ui.llmchat.LlmChatModelHelper -import com.google.ai.edge.gallery.ui.llmchat.createLlmChatConfigs -import com.google.ai.edge.gallery.ui.textclassification.TextClassificationModelHelper +import com.google.gson.Gson +import com.google.gson.reflect.TypeToken +import java.io.File +import java.net.HttpURLConnection +import java.net.URL import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.delay import kotlinx.coroutines.flow.MutableStateFlow @@ -60,16 +61,11 @@ import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.update import kotlinx.coroutines.launch import kotlinx.coroutines.withContext -import kotlinx.serialization.ExperimentalSerializationApi -import kotlinx.serialization.json.Json import net.openid.appauth.AuthorizationException import net.openid.appauth.AuthorizationRequest import net.openid.appauth.AuthorizationResponse import net.openid.appauth.AuthorizationService import net.openid.appauth.ResponseTypeValues -import java.io.File -import java.net.HttpURLConnection -import java.net.URL private const val TAG = "AGModelManagerViewModel" private const val TEXT_INPUT_HISTORY_MAX_SIZE = 50 @@ -78,81 +74,69 @@ private const val MODEL_ALLOWLIST_URL = private const val MODEL_ALLOWLIST_FILENAME = "model_allowlist.json" data class ModelInitializationStatus( - val status: ModelInitializationStatusType, var error: String = "" + val status: ModelInitializationStatusType, + var error: String = "", ) enum class ModelInitializationStatusType { - NOT_INITIALIZED, INITIALIZING, INITIALIZED, ERROR + NOT_INITIALIZED, + INITIALIZING, + INITIALIZED, + ERROR, } enum class TokenStatus { - NOT_STORED, EXPIRED, NOT_EXPIRED, + NOT_STORED, + EXPIRED, + NOT_EXPIRED, } enum class TokenRequestResultType { - FAILED, SUCCEEDED, USER_CANCELLED + FAILED, + SUCCEEDED, + USER_CANCELLED, } -data class TokenStatusAndData( - val status: TokenStatus, - val data: AccessTokenData?, -) +data class TokenStatusAndData(val status: TokenStatus, val data: AccessTokenData?) -data class TokenRequestResult( - val status: TokenRequestResultType, val errorMessage: String? = null -) +data class TokenRequestResult(val status: TokenRequestResultType, val errorMessage: String? = null) data class ModelManagerUiState( - /** - * A list of tasks available in the application. - */ + /** A list of tasks available in the application. */ val tasks: List, - /** - * A map that tracks the download status of each model, indexed by model name. - */ + /** A map that tracks the download status of each model, indexed by model name. */ val modelDownloadStatus: Map, - /** - * A map that tracks the initialization status of each model, indexed by model name. - */ + /** A map that tracks the initialization status of each model, indexed by model name. */ val modelInitializationStatus: Map, - /** - * Whether the app is loading and processing the model allowlist. - */ + /** Whether the app is loading and processing the model allowlist. */ val loadingModelAllowlist: Boolean = true, /** The error message when loading the model allowlist. */ val loadingModelAllowlistError: String = "", - /** - * The currently selected model. - */ + /** The currently selected model. */ val selectedModel: Model = EMPTY_MODEL, - /** - * The history of text inputs entered by the user. - */ + /** The history of text inputs entered by the user. */ val textInputHistory: List = listOf(), ) -data class PagerScrollState( - val page: Int = 0, - val offset: Float = 0f, -) +data class PagerScrollState(val page: Int = 0, val offset: Float = 0f) /** * ViewModel responsible for managing models, their download status, and initialization. * - * This ViewModel handles model-related operations such as downloading, deleting, initializing, - * and cleaning up models. It also manages the UI state for model management, including the - * list of tasks, models, download statuses, and initialization statuses. + * This ViewModel handles model-related operations such as downloading, deleting, initializing, and + * cleaning up models. It also manages the UI state for model management, including the list of + * tasks, models, download statuses, and initialization statuses. */ -@OptIn(ExperimentalSerializationApi::class) open class ModelManagerViewModel( private val downloadRepository: DownloadRepository, private val dataStoreRepository: DataStoreRepository, + private val lifecycleProvider: AppLifecycleProvider, context: Context, ) : ViewModel() { private val externalFilesDir = context.getExternalFilesDir(null) @@ -182,16 +166,15 @@ open class ModelManagerViewModel( fun downloadModel(task: Task, model: Model) { // Update status. setDownloadStatus( - curModel = model, status = ModelDownloadStatus(status = ModelDownloadStatusType.IN_PROGRESS) + curModel = model, + status = ModelDownloadStatus(status = ModelDownloadStatusType.IN_PROGRESS), ) // Delete the model files first. deleteModel(task = task, model = model) // Start to send download request. - downloadRepository.downloadModel( - model, onStatusUpdated = this::setDownloadStatus - ) + downloadRepository.downloadModel(model, onStatusUpdated = this::setDownloadStatus) } fun cancelDownloadModel(task: Task, model: Model) { @@ -222,7 +205,7 @@ open class ModelManagerViewModel( } curModelDownloadStatus.remove(model.name) - // Update preference. + // Update data store. val importedModels = dataStoreRepository.readImportedModels().toMutableList() val importedModelIndex = importedModels.indexOfFirst { it.fileName == model.name } if (importedModelIndex >= 0) { @@ -230,16 +213,22 @@ open class ModelManagerViewModel( } dataStoreRepository.saveImportedModels(importedModels = importedModels) } - val newUiState = uiState.value.copy( - modelDownloadStatus = curModelDownloadStatus, tasks = uiState.value.tasks.toList() - ) + val newUiState = + uiState.value.copy( + modelDownloadStatus = curModelDownloadStatus, + tasks = uiState.value.tasks.toList(), + ) _uiState.update { newUiState } } fun initializeModel(context: Context, task: Task, model: Model, force: Boolean = false) { viewModelScope.launch(Dispatchers.Default) { // Skip if initialized already. - if (!force && uiState.value.modelInitializationStatus[model.name]?.status == ModelInitializationStatusType.INITIALIZED) { + if ( + !force && + uiState.value.modelInitializationStatus[model.name]?.status == + ModelInitializationStatusType.INITIALIZED + ) { Log.d(TAG, "Model '${model.name}' has been initialized. Skipping.") return@launch } @@ -264,7 +253,8 @@ open class ModelManagerViewModel( delay(500) if (model.instance == null && model.initializing) { updateModelInitializationStatus( - model = model, status = ModelInitializationStatusType.INITIALIZING + model = model, + status = ModelInitializationStatusType.INITIALIZING, ) } } @@ -291,39 +281,14 @@ open class ModelManagerViewModel( } } when (task.type) { - TaskType.TEXT_CLASSIFICATION -> TextClassificationModelHelper.initialize( - context = context, - model = model, - onDone = onDone, - ) + TaskType.LLM_CHAT -> + LlmChatModelHelper.initialize(context = context, model = model, onDone = onDone) - TaskType.IMAGE_CLASSIFICATION -> ImageClassificationModelHelper.initialize( - context = context, - model = model, - onDone = onDone, - ) + TaskType.LLM_PROMPT_LAB -> + LlmChatModelHelper.initialize(context = context, model = model, onDone = onDone) - TaskType.LLM_CHAT -> LlmChatModelHelper.initialize( - context = context, - model = model, - onDone = onDone, - ) - - TaskType.LLM_PROMPT_LAB -> LlmChatModelHelper.initialize( - context = context, - model = model, - onDone = onDone, - ) - - TaskType.LLM_ASK_IMAGE -> LlmChatModelHelper.initialize( - context = context, - model = model, - onDone = onDone, - ) - - TaskType.IMAGE_GENERATION -> ImageGenerationModelHelper.initialize( - context = context, model = model, onDone = onDone - ) + TaskType.LLM_ASK_IMAGE -> + LlmChatModelHelper.initialize(context = context, model = model, onDone = onDone) TaskType.TEST_TASK_1 -> {} TaskType.TEST_TASK_2 -> {} @@ -336,19 +301,17 @@ open class ModelManagerViewModel( model.cleanUpAfterInit = false Log.d(TAG, "Cleaning up model '${model.name}'...") when (task.type) { - TaskType.TEXT_CLASSIFICATION -> TextClassificationModelHelper.cleanUp(model = model) - TaskType.IMAGE_CLASSIFICATION -> ImageClassificationModelHelper.cleanUp(model = model) TaskType.LLM_CHAT -> LlmChatModelHelper.cleanUp(model = model) TaskType.LLM_PROMPT_LAB -> LlmChatModelHelper.cleanUp(model = model) TaskType.LLM_ASK_IMAGE -> LlmChatModelHelper.cleanUp(model = model) - TaskType.IMAGE_GENERATION -> ImageGenerationModelHelper.cleanUp(model = model) TaskType.TEST_TASK_1 -> {} TaskType.TEST_TASK_2 -> {} } model.instance = null model.initializing = false updateModelInitializationStatus( - model = model, status = ModelInitializationStatusType.NOT_INITIALIZED + model = model, + status = ModelInitializationStatusType.NOT_INITIALIZED, ) } else { // When model is being initialized and we are trying to clean it up at same time, we mark it @@ -366,7 +329,10 @@ open class ModelManagerViewModel( val newUiState = uiState.value.copy(modelDownloadStatus = curModelDownloadStatus) // Delete downloaded file if status is failed or not_downloaded. - if (status.status == ModelDownloadStatusType.FAILED || status.status == ModelDownloadStatusType.NOT_DOWNLOADED) { + if ( + status.status == ModelDownloadStatusType.FAILED || + status.status == ModelDownloadStatusType.NOT_DOWNLOADED + ) { deleteFileFromExternalFilesDir(curModel.downloadFileName) } @@ -413,12 +379,12 @@ open class ModelManagerViewModel( dataStoreRepository.saveTextInputHistory(_uiState.value.textInputHistory) } - fun readThemeOverride(): String { - return dataStoreRepository.readThemeOverride() + fun readThemeOverride(): Theme { + return dataStoreRepository.readTheme() } - fun saveThemeOverride(theme: String) { - dataStoreRepository.saveThemeOverride(theme = theme) + fun saveThemeOverride(theme: Theme) { + dataStoreRepository.saveTheme(theme = theme) } fun getModelUrlResponse(model: Model, accessToken: String? = null): Int { @@ -426,9 +392,7 @@ open class ModelManagerViewModel( val url = URL(model.url) val connection = url.openConnection() as HttpURLConnection if (accessToken != null) { - connection.setRequestProperty( - "Authorization", "Bearer $accessToken" - ) + connection.setRequestProperty("Authorization", "Bearer $accessToken") } connection.connect() @@ -440,7 +404,7 @@ open class ModelManagerViewModel( } } - fun addImportedLlmModel(info: ImportedModelInfo) { + fun addImportedLlmModel(info: ImportedModel) { Log.d(TAG, "adding imported llm model: $info") // Create model. @@ -453,7 +417,7 @@ open class ModelManagerViewModel( Log.d(TAG, "duplicated imported model found in task. Removing it first") task.models.removeAt(modelIndex) } - if (task == TASK_LLM_ASK_IMAGE && model.llmSupportImage || task != TASK_LLM_ASK_IMAGE) { + if ((task == TASK_LLM_ASK_IMAGE && model.llmSupportImage) || task != TASK_LLM_ASK_IMAGE) { task.models.add(model) } task.updateTrigger.value = System.currentTimeMillis() @@ -462,11 +426,12 @@ open class ModelManagerViewModel( // Add initial status and states. val modelDownloadStatus = uiState.value.modelDownloadStatus.toMutableMap() val modelInstances = uiState.value.modelInitializationStatus.toMutableMap() - modelDownloadStatus[model.name] = ModelDownloadStatus( - status = ModelDownloadStatusType.SUCCEEDED, - receivedBytes = info.fileSize, - totalBytes = info.fileSize - ) + modelDownloadStatus[model.name] = + ModelDownloadStatus( + status = ModelDownloadStatusType.SUCCEEDED, + receivedBytes = info.fileSize, + totalBytes = info.fileSize, + ) modelInstances[model.name] = ModelInitializationStatus(status = ModelInitializationStatusType.NOT_INITIALIZED) @@ -475,15 +440,15 @@ open class ModelManagerViewModel( uiState.value.copy( tasks = uiState.value.tasks.toList(), modelDownloadStatus = modelDownloadStatus, - modelInitializationStatus = modelInstances + modelInitializationStatus = modelInstances, ) } - // Add to preference storage. + // Add to data store. val importedModels = dataStoreRepository.readImportedModels().toMutableList() val importedModelIndex = importedModels.indexOfFirst { info.fileName == it.fileName } if (importedModelIndex >= 0) { - Log.d(TAG, "duplicated imported model found in preference storage. Removing it first") + Log.d(TAG, "duplicated imported model found in data store. Removing it first") importedModels.removeAt(importedModelIndex) } importedModels.add(info) @@ -505,7 +470,7 @@ open class ModelManagerViewModel( val expirationTs = tokenData.expiresAtMs - 5 * 60 Log.d( TAG, - "Checking whether token has expired or not. Current ts: $curTs, expires at: $expirationTs" + "Checking whether token has expired or not. Current ts: $curTs, expires at: $expirationTs", ) if (curTs >= expirationTs) { Log.d(TAG, "Token expired!") @@ -524,11 +489,13 @@ open class ModelManagerViewModel( fun getAuthorizationRequest(): AuthorizationRequest { return AuthorizationRequest.Builder( - AuthConfig.authServiceConfig, - AuthConfig.clientId, - ResponseTypeValues.CODE, - Uri.parse(AuthConfig.redirectUri) - ).setScope("read-repos").build() + AuthConfig.authServiceConfig, + AuthConfig.clientId, + ResponseTypeValues.CODE, + AuthConfig.redirectUri.toUri(), + ) + .setScope("read-repos") + .build() } fun handleAuthResult(result: ActivityResult, onTokenRequested: (TokenRequestResult) -> Unit) { @@ -536,7 +503,8 @@ open class ModelManagerViewModel( if (dataIntent == null) { onTokenRequested( TokenRequestResult( - status = TokenRequestResultType.FAILED, errorMessage = "Empty auth result" + status = TokenRequestResultType.FAILED, + errorMessage = "Empty auth result", ) ) return @@ -549,9 +517,9 @@ open class ModelManagerViewModel( response?.authorizationCode != null -> { // Authorization successful, exchange the code for tokens var errorMessage: String? = null - authService.performTokenRequest( - response.createTokenExchangeRequest() - ) { tokenResponse, tokenEx -> + authService.performTokenRequest(response.createTokenExchangeRequest()) { + tokenResponse, + tokenEx -> if (tokenResponse != null) { if (tokenResponse.accessToken == null) { errorMessage = "Empty access token" @@ -565,7 +533,7 @@ open class ModelManagerViewModel( saveAccessToken( accessToken = tokenResponse.accessToken!!, refreshToken = tokenResponse.refreshToken!!, - expiresAt = tokenResponse.accessTokenExpirationTime!! + expiresAt = tokenResponse.accessTokenExpirationTime!!, ) curAccessToken = tokenResponse.accessToken!! Log.d(TAG, "Token successfully saved.") @@ -580,7 +548,8 @@ open class ModelManagerViewModel( } else { onTokenRequested( TokenRequestResult( - status = TokenRequestResultType.FAILED, errorMessage = errorMessage + status = TokenRequestResultType.FAILED, + errorMessage = errorMessage, ) ) } @@ -590,18 +559,16 @@ open class ModelManagerViewModel( exception != null -> { onTokenRequested( TokenRequestResult( - status = if (exception.message == "User cancelled flow") TokenRequestResultType.USER_CANCELLED else TokenRequestResultType.FAILED, - errorMessage = "${exception.message}" + status = + if (exception.message == "User cancelled flow") TokenRequestResultType.USER_CANCELLED + else TokenRequestResultType.FAILED, + errorMessage = exception.message, ) ) } else -> { - onTokenRequested( - TokenRequestResult( - status = TokenRequestResultType.USER_CANCELLED, - ) - ) + onTokenRequested(TokenRequestResult(status = TokenRequestResultType.USER_CANCELLED)) } } } @@ -625,9 +592,7 @@ open class ModelManagerViewModel( // Those models are the ones that have not finished downloading. val models: MutableList = mutableListOf() for (info in inProgressWorkInfos) { - getModelByName(info.modelName)?.let { model -> - models.add(model) - } + getModelByName(info.modelName)?.let { model -> models.add(model) } } // Cancel all pending downloads for the retrieved models. @@ -641,12 +606,16 @@ open class ModelManagerViewModel( for (info in inProgressWorkInfos) { val model: Model? = getModelByName(info.modelName) if (model != null) { - if (tokenStatusAndData.status == TokenStatus.NOT_EXPIRED && tokenStatusAndData.data != null) { + if ( + tokenStatusAndData.status == TokenStatus.NOT_EXPIRED && + tokenStatusAndData.data != null + ) { model.accessToken = tokenStatusAndData.data.accessToken } Log.d(TAG, "Sending a new download request for '${model.name}'") downloadRepository.downloadModel( - model, onStatusUpdated = this@ModelManagerViewModel::setDownloadStatus + model, + onStatusUpdated = this@ModelManagerViewModel::setDownloadStatus, ) } } @@ -657,9 +626,7 @@ open class ModelManagerViewModel( fun loadModelAllowlist() { _uiState.update { - uiState.value.copy( - loadingModelAllowlist = true, loadingModelAllowlistError = "" - ) + uiState.value.copy(loadingModelAllowlist = true, loadingModelAllowlistError = "") } viewModelScope.launch(Dispatchers.IO) { @@ -678,7 +645,9 @@ open class ModelManagerViewModel( } if (modelAllowlist == null) { - _uiState.update { uiState.value.copy(loadingModelAllowlistError = "Failed to load model list") } + _uiState.update { + uiState.value.copy(loadingModelAllowlistError = "Failed to load model list") + } return@launch } @@ -710,11 +679,7 @@ open class ModelManagerViewModel( // Update UI state. val newUiState = createUiState() - _uiState.update { - newUiState.copy( - loadingModelAllowlist = false, - ) - } + _uiState.update { newUiState.copy(loadingModelAllowlist = false) } // Process pending downloads. processPendingDownloads() @@ -724,6 +689,10 @@ open class ModelManagerViewModel( } } + fun setAppInForeground(foreground: Boolean) { + lifecycleProvider.isAppInForeground = foreground + } + private fun saveModelAllowlistToDisk(modelAllowlistContent: String) { try { Log.d(TAG, "Saving model allowlist to disk...") @@ -742,13 +711,10 @@ open class ModelManagerViewModel( if (file.exists()) { val content = file.readText() Log.d(TAG, "Model allowlist content from local file: $content") - val json = Json { - // Handle potential extra fields - ignoreUnknownKeys = true - allowComments = true - allowTrailingComma = true - } - return json.decodeFromString(content) + + val gson = Gson() + val type = object : TypeToken() {}.type + return gson.fromJson(content, type) } } catch (e: Exception) { Log.e(TAG, "failed to read model allowlist from disk", e) @@ -796,11 +762,12 @@ open class ModelManagerViewModel( } // Update status. - modelDownloadStatus[model.name] = ModelDownloadStatus( - status = ModelDownloadStatusType.SUCCEEDED, - receivedBytes = importedModel.fileSize, - totalBytes = importedModel.fileSize - ) + modelDownloadStatus[model.name] = + ModelDownloadStatus( + status = ModelDownloadStatusType.SUCCEEDED, + receivedBytes = importedModel.fileSize, + totalBytes = importedModel.fileSize, + ) } val textInputHistory = dataStoreRepository.readTextInputHistory() @@ -815,45 +782,36 @@ open class ModelManagerViewModel( ) } - private fun createModelFromImportedModelInfo(info: ImportedModelInfo): Model { - val accelerators: List = (convertValueToTargetType( - info.defaultValues[ConfigKey.COMPATIBLE_ACCELERATORS.label]!!, ValueType.STRING - ) as String).split(",").mapNotNull { acceleratorLabel -> - when (acceleratorLabel.trim()) { - Accelerator.GPU.label -> Accelerator.GPU - Accelerator.CPU.label -> Accelerator.CPU - else -> null // Ignore unknown accelerator labels + private fun createModelFromImportedModelInfo(info: ImportedModel): Model { + val accelerators: List = + info.llmConfig.compatibleAcceleratorsList.mapNotNull { acceleratorLabel -> + when (acceleratorLabel.trim()) { + Accelerator.GPU.label -> Accelerator.GPU + Accelerator.CPU.label -> Accelerator.CPU + else -> null // Ignore unknown accelerator labels + } } - } - val configs: List = createLlmChatConfigs( - defaultMaxToken = convertValueToTargetType( - info.defaultValues[ConfigKey.DEFAULT_MAX_TOKENS.label]!!, ValueType.INT - ) as Int, - defaultTopK = convertValueToTargetType( - info.defaultValues[ConfigKey.DEFAULT_TOPK.label]!!, ValueType.INT - ) as Int, - defaultTopP = convertValueToTargetType( - info.defaultValues[ConfigKey.DEFAULT_TOPP.label]!!, ValueType.FLOAT - ) as Float, - defaultTemperature = convertValueToTargetType( - info.defaultValues[ConfigKey.DEFAULT_TEMPERATURE.label]!!, ValueType.FLOAT - ) as Float, - accelerators = accelerators, - ) - val llmSupportImage = convertValueToTargetType( - info.defaultValues[ConfigKey.SUPPORT_IMAGE.label] ?: false, ValueType.BOOLEAN - ) as Boolean - val model = Model( - name = info.fileName, - url = "", - configs = configs, - sizeInBytes = info.fileSize, - downloadFileName = "$IMPORTS_DIR/${info.fileName}", - showBenchmarkButton = false, - showRunAgainButton = false, - imported = true, - llmSupportImage = llmSupportImage, - ) + val configs: List = + createLlmChatConfigs( + defaultMaxToken = info.llmConfig.defaultMaxTokens, + defaultTopK = info.llmConfig.defaultTopk, + defaultTopP = info.llmConfig.defaultTopp, + defaultTemperature = info.llmConfig.defaultTemperature, + accelerators = accelerators, + ) + val llmSupportImage = info.llmConfig.supportImage + val model = + Model( + name = info.fileName, + url = "", + configs = configs, + sizeInBytes = info.fileSize, + downloadFileName = "$IMPORTS_DIR/${info.fileName}", + showBenchmarkButton = false, + showRunAgainButton = false, + imported = true, + llmSupportImage = llmSupportImage, + ) model.preProcess() return model @@ -881,7 +839,9 @@ open class ModelManagerViewModel( } } return ModelDownloadStatus( - status = status, receivedBytes = receivedBytes, totalBytes = totalBytes + status = status, + receivedBytes = receivedBytes, + totalBytes = totalBytes, ) } @@ -909,7 +869,9 @@ open class ModelManagerViewModel( } private fun updateModelInitializationStatus( - model: Model, status: ModelInitializationStatusType, error: String = "" + model: Model, + status: ModelInitializationStatusType, + error: String = "", ) { val curModelInstance = uiState.value.modelInitializationStatus.toMutableMap() curModelInstance[model.name] = ModelInitializationStatus(status = status, error = error) @@ -918,18 +880,19 @@ open class ModelManagerViewModel( } private fun isModelDownloaded(model: Model): Boolean { - val downloadedFileExists = model.downloadFileName.isNotEmpty() && isFileInExternalFilesDir( - listOf( - model.normalizedName, model.version, model.downloadFileName - ).joinToString(File.separator) - ) + val downloadedFileExists = + model.downloadFileName.isNotEmpty() && + isFileInExternalFilesDir( + listOf(model.normalizedName, model.version, model.downloadFileName) + .joinToString(File.separator) + ) val unzippedDirectoryExists = - model.isZip && model.unzipDir.isNotEmpty() && isFileInExternalFilesDir( - listOf( - model.normalizedName, model.version, model.unzipDir - ).joinToString(File.separator) - ) + model.isZip && + model.unzipDir.isNotEmpty() && + isFileInExternalFilesDir( + listOf(model.normalizedName, model.version, model.unzipDir).joinToString(File.separator) + ) // Will also return true if model is partially downloaded. return downloadedFileExists || unzippedDirectoryExists diff --git a/Android/src/app/src/main/java/com/google/ai/edge/gallery/ui/navigation/GalleryNavGraph.kt b/Android/src/app/src/main/java/com/google/ai/edge/gallery/ui/navigation/GalleryNavGraph.kt index fa58868..5e8672d 100644 --- a/Android/src/app/src/main/java/com/google/ai/edge/gallery/ui/navigation/GalleryNavGraph.kt +++ b/Android/src/app/src/main/java/com/google/ai/edge/gallery/ui/navigation/GalleryNavGraph.kt @@ -28,6 +28,7 @@ import androidx.compose.animation.slideInHorizontally import androidx.compose.animation.slideOutHorizontally import androidx.compose.material3.Text import androidx.compose.runtime.Composable +import androidx.compose.runtime.DisposableEffect import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember @@ -35,6 +36,9 @@ import androidx.compose.runtime.setValue import androidx.compose.ui.Modifier import androidx.compose.ui.unit.IntOffset import androidx.compose.ui.zIndex +import androidx.lifecycle.Lifecycle +import androidx.lifecycle.LifecycleEventObserver +import androidx.lifecycle.compose.LocalLifecycleOwner import androidx.lifecycle.viewmodel.compose.viewModel import androidx.navigation.NavBackStackEntry import androidx.navigation.NavHostController @@ -43,31 +47,22 @@ import androidx.navigation.compose.NavHost import androidx.navigation.compose.composable import androidx.navigation.navArgument import com.google.ai.edge.gallery.data.Model -import com.google.ai.edge.gallery.data.TASK_IMAGE_CLASSIFICATION -import com.google.ai.edge.gallery.data.TASK_IMAGE_GENERATION -import com.google.ai.edge.gallery.data.TASK_LLM_CHAT import com.google.ai.edge.gallery.data.TASK_LLM_ASK_IMAGE +import com.google.ai.edge.gallery.data.TASK_LLM_CHAT import com.google.ai.edge.gallery.data.TASK_LLM_PROMPT_LAB -import com.google.ai.edge.gallery.data.TASK_TEXT_CLASSIFICATION import com.google.ai.edge.gallery.data.Task import com.google.ai.edge.gallery.data.TaskType import com.google.ai.edge.gallery.data.getModelByName import com.google.ai.edge.gallery.ui.ViewModelProvider import com.google.ai.edge.gallery.ui.home.HomeScreen -import com.google.ai.edge.gallery.ui.imageclassification.ImageClassificationDestination -import com.google.ai.edge.gallery.ui.imageclassification.ImageClassificationScreen -import com.google.ai.edge.gallery.ui.imagegeneration.ImageGenerationDestination -import com.google.ai.edge.gallery.ui.imagegeneration.ImageGenerationScreen -import com.google.ai.edge.gallery.ui.llmchat.LlmChatDestination -import com.google.ai.edge.gallery.ui.llmchat.LlmChatScreen import com.google.ai.edge.gallery.ui.llmchat.LlmAskImageDestination import com.google.ai.edge.gallery.ui.llmchat.LlmAskImageScreen +import com.google.ai.edge.gallery.ui.llmchat.LlmChatDestination +import com.google.ai.edge.gallery.ui.llmchat.LlmChatScreen import com.google.ai.edge.gallery.ui.llmsingleturn.LlmSingleTurnDestination import com.google.ai.edge.gallery.ui.llmsingleturn.LlmSingleTurnScreen import com.google.ai.edge.gallery.ui.modelmanager.ModelManager import com.google.ai.edge.gallery.ui.modelmanager.ModelManagerViewModel -import com.google.ai.edge.gallery.ui.textclassification.TextClassificationDestination -import com.google.ai.edge.gallery.ui.textclassification.TextClassificationScreen private const val TAG = "AGGalleryNavGraph" private const val ROUTE_PLACEHOLDER = "placeholder" @@ -82,7 +77,7 @@ private fun enterTween(): FiniteAnimationSpec { return tween( ENTER_ANIMATION_DURATION_MS, easing = ENTER_ANIMATION_EASING, - delayMillis = ENTER_ANIMATION_DELAY_MS + delayMillis = ENTER_ANIMATION_DELAY_MS, ) } @@ -104,18 +99,40 @@ private fun AnimatedContentTransitionScope<*>.slideExit(): ExitTransition { ) } -/** - * Navigation routes. - */ +/** Navigation routes. */ @Composable fun GalleryNavHost( navController: NavHostController, modifier: Modifier = Modifier, - modelManagerViewModel: ModelManagerViewModel = viewModel(factory = ViewModelProvider.Factory) + modelManagerViewModel: ModelManagerViewModel = viewModel(factory = ViewModelProvider.Factory), ) { + val lifecycleOwner = LocalLifecycleOwner.current var showModelManager by remember { mutableStateOf(false) } var pickedTask by remember { mutableStateOf(null) } + // Track whether app is in foreground. + DisposableEffect(lifecycleOwner) { + val observer = LifecycleEventObserver { _, event -> + when (event) { + Lifecycle.Event.ON_START, + Lifecycle.Event.ON_RESUME -> { + modelManagerViewModel.setAppInForeground(foreground = true) + } + Lifecycle.Event.ON_STOP, + Lifecycle.Event.ON_PAUSE -> { + modelManagerViewModel.setAppInForeground(foreground = false) + } + else -> { + /* Do nothing for other events */ + } + } + } + + lifecycleOwner.lifecycle.addObserver(observer) + + onDispose { lifecycleOwner.lifecycle.removeObserver(observer) } + } + HomeScreen( modelManagerViewModel = modelManagerViewModel, navigateToTaskScreen = { task -> @@ -132,14 +149,18 @@ fun GalleryNavHost( ) { val curPickedTask = pickedTask if (curPickedTask != null) { - ModelManager(viewModel = modelManagerViewModel, + ModelManager( + viewModel = modelManagerViewModel, task = curPickedTask, onModelClicked = { model -> navigateToTaskScreen( - navController = navController, taskType = curPickedTask.type, model = model + navController = navController, + taskType = curPickedTask.type, + model = model, ) }, - navigateUp = { showModelManager = false }) + navigateUp = { showModelManager = false }, + ) } } @@ -149,65 +170,10 @@ fun GalleryNavHost( startDestination = ROUTE_PLACEHOLDER, enterTransition = { EnterTransition.None }, exitTransition = { ExitTransition.None }, - modifier = modifier.zIndex(1f) + modifier = modifier.zIndex(1f), ) { // Placeholder root screen - composable( - route = ROUTE_PLACEHOLDER, - ) { - Text("") - } - - // Text classification. - composable( - route = "${TextClassificationDestination.route}/{modelName}", - arguments = listOf(navArgument("modelName") { type = NavType.StringType }), - enterTransition = { slideEnter() }, - exitTransition = { slideExit() }, - ) { - getModelFromNavigationParam(it, TASK_TEXT_CLASSIFICATION)?.let { defaultModel -> - modelManagerViewModel.selectModel(defaultModel) - - TextClassificationScreen( - modelManagerViewModel = modelManagerViewModel, - navigateUp = { navController.navigateUp() }, - ) - } - } - - // Image classification. - composable( - route = "${ImageClassificationDestination.route}/{modelName}", - arguments = listOf(navArgument("modelName") { type = NavType.StringType }), - enterTransition = { slideEnter() }, - exitTransition = { slideExit() }, - ) { - getModelFromNavigationParam(it, TASK_IMAGE_CLASSIFICATION)?.let { defaultModel -> - modelManagerViewModel.selectModel(defaultModel) - - ImageClassificationScreen( - modelManagerViewModel = modelManagerViewModel, - navigateUp = { navController.navigateUp() }, - ) - } - } - - // Image generation. - composable( - route = "${ImageGenerationDestination.route}/{modelName}", - arguments = listOf(navArgument("modelName") { type = NavType.StringType }), - enterTransition = { slideEnter() }, - exitTransition = { slideExit() }, - ) { - getModelFromNavigationParam(it, TASK_IMAGE_GENERATION)?.let { defaultModel -> - modelManagerViewModel.selectModel(defaultModel) - - ImageGenerationScreen( - modelManagerViewModel = modelManagerViewModel, - navigateUp = { navController.navigateUp() }, - ) - } - } + composable(route = ROUTE_PLACEHOLDER) { Text("") } // LLM chat demos. composable( @@ -272,7 +238,9 @@ fun GalleryNavHost( getModelByName(modelName)?.let { model -> // TODO(jingjin): need to show a list of possible tasks for this model. navigateToTaskScreen( - navController = navController, taskType = TaskType.LLM_CHAT, model = model + navController = navController, + taskType = TaskType.LLM_CHAT, + model = model, ) } } @@ -280,16 +248,16 @@ fun GalleryNavHost( } fun navigateToTaskScreen( - navController: NavHostController, taskType: TaskType, model: Model? = null + navController: NavHostController, + taskType: TaskType, + model: Model? = null, ) { val modelName = model?.name ?: "" when (taskType) { - TaskType.TEXT_CLASSIFICATION -> navController.navigate("${TextClassificationDestination.route}/${modelName}") - TaskType.IMAGE_CLASSIFICATION -> navController.navigate("${ImageClassificationDestination.route}/${modelName}") TaskType.LLM_CHAT -> navController.navigate("${LlmChatDestination.route}/${modelName}") TaskType.LLM_ASK_IMAGE -> navController.navigate("${LlmAskImageDestination.route}/${modelName}") - TaskType.LLM_PROMPT_LAB -> navController.navigate("${LlmSingleTurnDestination.route}/${modelName}") - TaskType.IMAGE_GENERATION -> navController.navigate("${ImageGenerationDestination.route}/${modelName}") + TaskType.LLM_PROMPT_LAB -> + navController.navigate("${LlmSingleTurnDestination.route}/${modelName}") TaskType.TEST_TASK_1 -> {} TaskType.TEST_TASK_2 -> {} } diff --git a/Android/src/app/src/main/java/com/google/ai/edge/gallery/ui/preview/PreviewChatModel.kt b/Android/src/app/src/main/java/com/google/ai/edge/gallery/ui/preview/PreviewChatModel.kt index 6db68e7..bab07cb 100644 --- a/Android/src/app/src/main/java/com/google/ai/edge/gallery/ui/preview/PreviewChatModel.kt +++ b/Android/src/app/src/main/java/com/google/ai/edge/gallery/ui/preview/PreviewChatModel.kt @@ -23,68 +23,69 @@ import android.graphics.drawable.Drawable import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.asImageBitmap import androidx.core.content.ContextCompat +import androidx.core.graphics.createBitmap import com.google.ai.edge.gallery.R +import com.google.ai.edge.gallery.common.Classification import com.google.ai.edge.gallery.ui.common.chat.ChatMessageClassification import com.google.ai.edge.gallery.ui.common.chat.ChatMessageImage import com.google.ai.edge.gallery.ui.common.chat.ChatMessageText import com.google.ai.edge.gallery.ui.common.chat.ChatSide import com.google.ai.edge.gallery.ui.common.chat.ChatViewModel -import com.google.ai.edge.gallery.ui.common.chat.Classification class PreviewChatModel(context: Context) : ChatViewModel(task = TASK_TEST1) { init { val model = task.models[1] addMessage( model = model, - message = ChatMessageText( - content = "Thanks everyone for your enthusiasm on the team lunch, but people who can sign on the cheque is OOO next week \uD83D\uDE02,", - side = ChatSide.USER - ), - ) - addMessage( - model = model, - message = ChatMessageText( - content = "Today is Wednesday!", side = ChatSide.AGENT, latencyMs = 1232f - ), - ) - addMessage( - model = model, - message = ChatMessageClassification( - classifications = listOf( - Classification(label = "label1", score = 0.3f, color = Color.Red), - Classification(label = "label2", score = 0.7f, color = Color.Blue) + message = + ChatMessageText( + content = + "Thanks everyone for your enthusiasm on the team lunch, but people who can sign on the cheque is OOO next week \uD83D\uDE02,", + side = ChatSide.USER, + ), + ) + addMessage( + model = model, + message = + ChatMessageText(content = "Today is Wednesday!", side = ChatSide.AGENT, latencyMs = 1232f), + ) + addMessage( + model = model, + message = + ChatMessageClassification( + classifications = + listOf( + Classification(label = "label1", score = 0.3f, color = Color.Red), + Classification(label = "label2", score = 0.7f, color = Color.Blue), + ), + latencyMs = 12345f, ), - latencyMs = 12345f, - ), ) val bitmap = getBitmapFromVectorDrawable( context = context, - drawableId = R.drawable.ic_launcher_background + drawableId = R.drawable.ic_launcher_background, )!! addMessage( model = model, - message = ChatMessageImage( - bitmap = bitmap, - imageBitMap = bitmap.asImageBitmap(), - side = ChatSide.USER, - ), + message = + ChatMessageImage( + bitmap = bitmap, + imageBitMap = bitmap.asImageBitmap(), + side = ChatSide.USER, + ), ) } private fun getBitmapFromVectorDrawable(context: Context, drawableId: Int): Bitmap? { - val drawable: Drawable = ContextCompat.getDrawable(context, drawableId) - ?: return null // Drawable not found + val drawable: Drawable = + ContextCompat.getDrawable(context, drawableId) ?: return null // Drawable not found - val bitmap = Bitmap.createBitmap( - drawable.intrinsicWidth, - drawable.intrinsicHeight, - Bitmap.Config.ARGB_8888 - ) + val bitmap = createBitmap(drawable.intrinsicWidth, drawable.intrinsicHeight) val canvas = Canvas(bitmap) drawable.setBounds(0, 0, canvas.width, canvas.height) drawable.draw(canvas) return bitmap } -} \ No newline at end of file +} diff --git a/Android/src/app/src/main/java/com/google/ai/edge/gallery/ui/preview/PreviewDataStoreRepository.kt b/Android/src/app/src/main/java/com/google/ai/edge/gallery/ui/preview/PreviewDataStoreRepository.kt index 8a5455a..b5e46c9 100644 --- a/Android/src/app/src/main/java/com/google/ai/edge/gallery/ui/preview/PreviewDataStoreRepository.kt +++ b/Android/src/app/src/main/java/com/google/ai/edge/gallery/ui/preview/PreviewDataStoreRepository.kt @@ -16,39 +16,42 @@ package com.google.ai.edge.gallery.ui.preview -import com.google.ai.edge.gallery.data.AccessTokenData -import com.google.ai.edge.gallery.data.DataStoreRepository -import com.google.ai.edge.gallery.data.ImportedModelInfo +// TODO(migration) +// +// import com.google.ai.edge.gallery.data.AccessTokenData +// import com.google.ai.edge.gallery.data.DataStoreRepository +// import com.google.ai.edge.gallery.data.ImportedModelInfo -class PreviewDataStoreRepository : DataStoreRepository { - override fun saveTextInputHistory(history: List) { - } +// class PreviewDataStoreRepository : DataStoreRepository +class PreviewDataStoreRepository { + // override fun saveTextInputHistory(history: List) { + // } - override fun readTextInputHistory(): List { - return listOf() - } + // override fun readTextInputHistory(): List { + // return listOf() + // } - override fun saveThemeOverride(theme: String) { - } + // override fun saveThemeOverride(theme: String) { + // } - override fun readThemeOverride(): String { - return "" - } + // override fun readThemeOverride(): String { + // return "" + // } - override fun saveAccessTokenData(accessToken: String, refreshToken: String, expiresAt: Long) { - } + // override fun saveAccessTokenData(accessToken: String, refreshToken: String, expiresAt: Long) { + // } - override fun readAccessTokenData(): AccessTokenData? { - return null - } + // override fun readAccessTokenData(): AccessTokenData? { + // return null + // } - override fun clearAccessTokenData() { - } + // override fun clearAccessTokenData() { + // } - override fun saveImportedModels(importedModels: List) { - } + // override fun saveImportedModels(importedModels: List) { + // } - override fun readImportedModels(): List { - return listOf() - } -} \ No newline at end of file + // override fun readImportedModels(): List { + // return listOf() + // } +} diff --git a/Android/src/app/src/main/java/com/google/ai/edge/gallery/ui/preview/PreviewDownloadRepository.kt b/Android/src/app/src/main/java/com/google/ai/edge/gallery/ui/preview/PreviewDownloadRepository.kt index 072ab98..88e7dfa 100644 --- a/Android/src/app/src/main/java/com/google/ai/edge/gallery/ui/preview/PreviewDownloadRepository.kt +++ b/Android/src/app/src/main/java/com/google/ai/edge/gallery/ui/preview/PreviewDownloadRepository.kt @@ -24,22 +24,19 @@ import java.util.UUID class PreviewDownloadRepository : DownloadRepository { override fun downloadModel( - model: Model, onStatusUpdated: (model: Model, status: ModelDownloadStatus) -> Unit - ) { - } + model: Model, + onStatusUpdated: (model: Model, status: ModelDownloadStatus) -> Unit, + ) {} - override fun cancelDownloadModel(model: Model) { - } + override fun cancelDownloadModel(model: Model) {} - override fun cancelAll(models: List, onComplete: () -> Unit) { - } + override fun cancelAll(models: List, onComplete: () -> Unit) {} override fun observerWorkerProgress( workerId: UUID, model: Model, - onStatusUpdated: (model: Model, status: ModelDownloadStatus) -> Unit - ) { - } + onStatusUpdated: (model: Model, status: ModelDownloadStatus) -> Unit, + ) {} override fun getEnqueuedOrRunningWorkInfos(): List { return listOf() diff --git a/Android/src/app/src/main/java/com/google/ai/edge/gallery/ui/preview/PreviewLlmSingleTurnViewModel.kt b/Android/src/app/src/main/java/com/google/ai/edge/gallery/ui/preview/PreviewLlmSingleTurnViewModel.kt index d3a7e57..0c8d9dc 100644 --- a/Android/src/app/src/main/java/com/google/ai/edge/gallery/ui/preview/PreviewLlmSingleTurnViewModel.kt +++ b/Android/src/app/src/main/java/com/google/ai/edge/gallery/ui/preview/PreviewLlmSingleTurnViewModel.kt @@ -18,4 +18,4 @@ package com.google.ai.edge.gallery.ui.preview import com.google.ai.edge.gallery.ui.llmsingleturn.LlmSingleTurnViewModel -class PreviewLlmSingleTurnViewModel : LlmSingleTurnViewModel(task = TASK_TEST1) \ No newline at end of file +class PreviewLlmSingleTurnViewModel : LlmSingleTurnViewModel(task = TASK_TEST1) diff --git a/Android/src/app/src/main/java/com/google/ai/edge/gallery/ui/preview/PreviewModelManagerViewModel.kt b/Android/src/app/src/main/java/com/google/ai/edge/gallery/ui/preview/PreviewModelManagerViewModel.kt index 63da529..612cbe6 100644 --- a/Android/src/app/src/main/java/com/google/ai/edge/gallery/ui/preview/PreviewModelManagerViewModel.kt +++ b/Android/src/app/src/main/java/com/google/ai/edge/gallery/ui/preview/PreviewModelManagerViewModel.kt @@ -16,52 +16,48 @@ package com.google.ai.edge.gallery.ui.preview -import android.content.Context -import com.google.ai.edge.gallery.data.ModelDownloadStatus -import com.google.ai.edge.gallery.data.ModelDownloadStatusType -import com.google.ai.edge.gallery.ui.modelmanager.ModelManagerUiState -import com.google.ai.edge.gallery.ui.modelmanager.ModelManagerViewModel -import kotlinx.coroutines.flow.update +class PreviewModelManagerViewModel {} -class PreviewModelManagerViewModel(context: Context) : - ModelManagerViewModel( - downloadRepository = PreviewDownloadRepository(), - dataStoreRepository = PreviewDataStoreRepository(), - context = context - ) { +// class PreviewModelManagerViewModel(context: Context) : +// ModelManagerViewModel( +// downloadRepository = PreviewDownloadRepository(), +// // dataStoreRepository = PreviewDataStoreRepository(), +// context = context, +// ) { - init { - for ((index, task) in ALL_PREVIEW_TASKS.withIndex()) { - task.index = index - for (model in task.models) { - model.preProcess() - } - } +// init { +// for ((index, task) in ALL_PREVIEW_TASKS.withIndex()) { +// task.index = index +// for (model in task.models) { +// model.preProcess() +// } +// } - val modelDownloadStatus = mapOf( - MODEL_TEST1.name to ModelDownloadStatus( - status = ModelDownloadStatusType.IN_PROGRESS, - receivedBytes = 1234, - totalBytes = 3456, - bytesPerSecond = 2333, - remainingMs = 324, - ), - MODEL_TEST2.name to ModelDownloadStatus( - status = ModelDownloadStatusType.SUCCEEDED - ), - MODEL_TEST3.name to ModelDownloadStatus( - status = ModelDownloadStatusType.FAILED, errorMessage = "Http code 404" - ), - MODEL_TEST4.name to ModelDownloadStatus( - status = ModelDownloadStatusType.NOT_DOWNLOADED - ), - ) - val newUiState = ModelManagerUiState( - tasks = ALL_PREVIEW_TASKS, - modelDownloadStatus = modelDownloadStatus, - modelInitializationStatus = mapOf(), - selectedModel = MODEL_TEST2, - ) - _uiState.update { newUiState } - } -} +// val modelDownloadStatus = +// mapOf( +// MODEL_TEST1.name to +// ModelDownloadStatus( +// status = ModelDownloadStatusType.IN_PROGRESS, +// receivedBytes = 1234, +// totalBytes = 3456, +// bytesPerSecond = 2333, +// remainingMs = 324, +// ), +// MODEL_TEST2.name to ModelDownloadStatus(status = ModelDownloadStatusType.SUCCEEDED), +// MODEL_TEST3.name to +// ModelDownloadStatus( +// status = ModelDownloadStatusType.FAILED, +// errorMessage = "Http code 404", +// ), +// MODEL_TEST4.name to ModelDownloadStatus(status = ModelDownloadStatusType.NOT_DOWNLOADED), +// ) +// val newUiState = +// ModelManagerUiState( +// tasks = ALL_PREVIEW_TASKS, +// modelDownloadStatus = modelDownloadStatus, +// modelInitializationStatus = mapOf(), +// selectedModel = MODEL_TEST2, +// ) +// _uiState.update { newUiState } +// } +// } diff --git a/Android/src/app/src/main/java/com/google/ai/edge/gallery/ui/preview/PreviewTasks.kt b/Android/src/app/src/main/java/com/google/ai/edge/gallery/ui/preview/PreviewTasks.kt index 6555d7e..44e2e3d 100644 --- a/Android/src/app/src/main/java/com/google/ai/edge/gallery/ui/preview/PreviewTasks.kt +++ b/Android/src/app/src/main/java/com/google/ai/edge/gallery/ui/preview/PreviewTasks.kt @@ -23,79 +23,81 @@ import com.google.ai.edge.gallery.data.BooleanSwitchConfig import com.google.ai.edge.gallery.data.Config import com.google.ai.edge.gallery.data.ConfigKey import com.google.ai.edge.gallery.data.LabelConfig -import com.google.ai.edge.gallery.data.SegmentedButtonConfig import com.google.ai.edge.gallery.data.Model import com.google.ai.edge.gallery.data.NumberSliderConfig +import com.google.ai.edge.gallery.data.SegmentedButtonConfig import com.google.ai.edge.gallery.data.Task import com.google.ai.edge.gallery.data.TaskType import com.google.ai.edge.gallery.data.ValueType -val TEST_CONFIGS1: List = listOf( - LabelConfig( - key = ConfigKey.NAME, - defaultValue = "Test name", - ), - NumberSliderConfig( - key = ConfigKey.MAX_RESULT_COUNT, - sliderMin = 1f, - sliderMax = 5f, - defaultValue = 3f, - valueType = ValueType.INT - ), BooleanSwitchConfig( - key = ConfigKey.USE_GPU, - defaultValue = false, - ), SegmentedButtonConfig( - key = ConfigKey.THEME, - defaultValue = "Auto", - options = listOf("Auto", "Light", "Dark") +val TEST_CONFIGS1: List = + listOf( + LabelConfig(key = ConfigKey.NAME, defaultValue = "Test name"), + NumberSliderConfig( + key = ConfigKey.MAX_RESULT_COUNT, + sliderMin = 1f, + sliderMax = 5f, + defaultValue = 3f, + valueType = ValueType.INT, + ), + BooleanSwitchConfig(key = ConfigKey.USE_GPU, defaultValue = false), + SegmentedButtonConfig( + key = ConfigKey.THEME, + defaultValue = "Auto", + options = listOf("Auto", "Light", "Dark"), + ), ) -) -val MODEL_TEST1: Model = Model( - name = "deterministic3", - downloadFileName = "deterministric3.json", - url = "https://storage.googleapis.com/tfweb/model-graph-vis-v2-test-models/deterministic3.json", - sizeInBytes = 40146048L, - configs = TEST_CONFIGS1, -) +val MODEL_TEST1: Model = + Model( + name = "deterministic3", + downloadFileName = "deterministric3.json", + url = "https://storage.googleapis.com/tfweb/model-graph-vis-v2-test-models/deterministic3.json", + sizeInBytes = 40146048L, + configs = TEST_CONFIGS1, + ) -val MODEL_TEST2: Model = Model( - name = "isnet", - downloadFileName = "isnet.tflite", - url = "https://storage.googleapis.com/tfweb/model-graph-vis-v2-test-models/isnet-general-use-int8.tflite", - sizeInBytes = 44366296L, - configs = TEST_CONFIGS1, -) +val MODEL_TEST2: Model = + Model( + name = "isnet", + downloadFileName = "isnet.tflite", + url = + "https://storage.googleapis.com/tfweb/model-graph-vis-v2-test-models/isnet-general-use-int8.tflite", + sizeInBytes = 44366296L, + configs = TEST_CONFIGS1, + ) -val MODEL_TEST3: Model = Model( - name = "yolo", - downloadFileName = "yolo.json", - url = "https://storage.googleapis.com/tfweb/model-graph-vis-v2-test-models/yolo.json", - sizeInBytes = 40641364L -) +val MODEL_TEST3: Model = + Model( + name = "yolo", + downloadFileName = "yolo.json", + url = "https://storage.googleapis.com/tfweb/model-graph-vis-v2-test-models/yolo.json", + sizeInBytes = 40641364L, + ) -val MODEL_TEST4: Model = Model( - name = "mobilenet v3", - downloadFileName = "mobilenet_v3_large.pt2", - url = "https://storage.googleapis.com/tfweb/model-graph-vis-v2-test-models/mobilenet_v3_large.pt2", - sizeInBytes = 277135998L -) +val MODEL_TEST4: Model = + Model( + name = "mobilenet v3", + downloadFileName = "mobilenet_v3_large.pt2", + url = + "https://storage.googleapis.com/tfweb/model-graph-vis-v2-test-models/mobilenet_v3_large.pt2", + sizeInBytes = 277135998L, + ) -val TASK_TEST1 = Task( - type = TaskType.TEST_TASK_1, - icon = Icons.Rounded.AutoAwesome, - models = mutableListOf(MODEL_TEST1, MODEL_TEST2), - description = "This is a test task (1)" -) +val TASK_TEST1 = + Task( + type = TaskType.TEST_TASK_1, + icon = Icons.Rounded.AutoAwesome, + models = mutableListOf(MODEL_TEST1, MODEL_TEST2), + description = "This is a test task (1)", + ) -val TASK_TEST2 = Task( - type = TaskType.TEST_TASK_2, - icon = Icons.Rounded.AccountBox, - models = mutableListOf(MODEL_TEST3, MODEL_TEST4), - description = "This is a test task (2)" -) +val TASK_TEST2 = + Task( + type = TaskType.TEST_TASK_2, + icon = Icons.Rounded.AccountBox, + models = mutableListOf(MODEL_TEST3, MODEL_TEST4), + description = "This is a test task (2)", + ) -val ALL_PREVIEW_TASKS: List = listOf( - TASK_TEST1, - TASK_TEST2, -) \ No newline at end of file +val ALL_PREVIEW_TASKS: List = listOf(TASK_TEST1, TASK_TEST2) diff --git a/Android/src/app/src/main/java/com/google/ai/edge/gallery/ui/textclassification/TextClassificationModelHelper.kt b/Android/src/app/src/main/java/com/google/ai/edge/gallery/ui/textclassification/TextClassificationModelHelper.kt deleted file mode 100644 index 358d734..0000000 --- a/Android/src/app/src/main/java/com/google/ai/edge/gallery/ui/textclassification/TextClassificationModelHelper.kt +++ /dev/null @@ -1,95 +0,0 @@ -/* - * Copyright 2025 Google LLC - * - * 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 - * - * http://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.google.ai.edge.gallery.ui.textclassification - -import android.content.Context -import android.util.Log -import com.google.mediapipe.tasks.components.containers.Category -import com.google.mediapipe.tasks.core.BaseOptions -import com.google.mediapipe.tasks.text.textclassifier.TextClassifier -import com.google.ai.edge.gallery.data.Model -import com.google.ai.edge.gallery.ui.common.LatencyProvider -import java.io.File -import java.io.FileInputStream -import java.nio.ByteBuffer -import java.nio.channels.FileChannel - -private const val TAG = "AGTextClassificationModelHelper" - -class TextClassificationInferenceResult( - val categories: List, override val latencyMs: Float -) : LatencyProvider - -// TODO: handle error. - -/** - * Helper object for managing text classification models. - */ -object TextClassificationModelHelper { - fun initialize(context: Context, model: Model, onDone: (String) -> Unit) { - val modelByteBuffer = readFileToByteBuffer(File(model.getPath(context = context))) - if (modelByteBuffer != null) { - val options = TextClassifier.TextClassifierOptions.builder().setBaseOptions( - BaseOptions.builder().setModelAssetBuffer(modelByteBuffer).build() - ).build() - model.instance = TextClassifier.createFromOptions(context, options) - onDone("") - } - } - - fun runInference(model: Model, input: String): TextClassificationInferenceResult { - val instance = model.instance - val start = System.currentTimeMillis() - val classifier: TextClassifier = instance as TextClassifier - val result = classifier.classify(input) - val categories = result.classificationResult().classifications().first().categories() - val latencyMs = (System.currentTimeMillis() - start).toFloat() - return TextClassificationInferenceResult(categories = categories, latencyMs = latencyMs) - } - - fun cleanUp(model: Model) { - if (model.instance == null) { - return - } - val instance = model.instance as TextClassifier - - try { - instance.close() - } catch (e: Exception) { - // ignore - } - - model.instance = null - Log.d(TAG, "Clean up done.") - } - - - private fun readFileToByteBuffer(file: File): ByteBuffer? { - return try { - val fileInputStream = FileInputStream(file) - val fileChannel: FileChannel = fileInputStream.channel - val byteBuffer = ByteBuffer.allocateDirect(fileChannel.size().toInt()) - fileChannel.read(byteBuffer) - byteBuffer.rewind() - fileInputStream.close() - byteBuffer - } catch (e: Exception) { - e.printStackTrace() - null - } - } -} \ No newline at end of file diff --git a/Android/src/app/src/main/java/com/google/ai/edge/gallery/ui/textclassification/TextClassificationScreen.kt b/Android/src/app/src/main/java/com/google/ai/edge/gallery/ui/textclassification/TextClassificationScreen.kt deleted file mode 100644 index 38e3a00..0000000 --- a/Android/src/app/src/main/java/com/google/ai/edge/gallery/ui/textclassification/TextClassificationScreen.kt +++ /dev/null @@ -1,75 +0,0 @@ -/* - * Copyright 2025 Google LLC - * - * 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 - * - * http://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.google.ai.edge.gallery.ui.textclassification - -import androidx.compose.runtime.Composable -import androidx.compose.ui.Modifier -import androidx.lifecycle.viewmodel.compose.viewModel -import com.google.ai.edge.gallery.ui.ViewModelProvider -import com.google.ai.edge.gallery.ui.common.chat.ChatMessageText -import com.google.ai.edge.gallery.ui.common.chat.ChatView -import com.google.ai.edge.gallery.ui.modelmanager.ModelManagerViewModel -import kotlinx.serialization.Serializable - -/** Navigation destination data */ -object TextClassificationDestination { - @Serializable - val route = "TextClassificationRoute" -} - -@Composable -fun TextClassificationScreen( - modelManagerViewModel: ModelManagerViewModel, - navigateUp: () -> Unit, - modifier: Modifier = Modifier, - viewModel: TextClassificationViewModel = viewModel( - factory = ViewModelProvider.Factory - ), -) { - ChatView( - task = viewModel.task, - viewModel = viewModel, - modelManagerViewModel = modelManagerViewModel, - onSendMessage = { model, messages -> - val message = messages[0] - viewModel.addMessage( - model = model, - message = message, - ) - if (message is ChatMessageText) { - modelManagerViewModel.addTextInputHistory(message.content) - viewModel.generateResponse( - model = model, - input = message.content, - ) - } - }, - onRunAgainClicked = { model, message -> - viewModel.runAgain(model = model, message = message) - }, - onBenchmarkClicked = { model, message, warmUpIterations, benchmarkIterations -> - viewModel.benchmark( - model = model, - message = message, - warmupCount = warmUpIterations, - itertations = benchmarkIterations, - ) - }, - navigateUp = navigateUp, - modifier = modifier - ) -} diff --git a/Android/src/app/src/main/java/com/google/ai/edge/gallery/ui/textclassification/TextClassificationViewModel.kt b/Android/src/app/src/main/java/com/google/ai/edge/gallery/ui/textclassification/TextClassificationViewModel.kt deleted file mode 100644 index b2326cb..0000000 --- a/Android/src/app/src/main/java/com/google/ai/edge/gallery/ui/textclassification/TextClassificationViewModel.kt +++ /dev/null @@ -1,128 +0,0 @@ -/* - * Copyright 2025 Google LLC - * - * 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 - * - * http://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.google.ai.edge.gallery.ui.textclassification - -import android.util.Log -import androidx.compose.ui.graphics.Color -import androidx.lifecycle.viewModelScope -import com.google.mediapipe.tasks.components.containers.Category -import com.google.ai.edge.gallery.data.Model -import com.google.ai.edge.gallery.data.TASK_TEXT_CLASSIFICATION -import com.google.ai.edge.gallery.ui.common.chat.ChatMessage -import com.google.ai.edge.gallery.ui.common.chat.ChatMessageClassification -import com.google.ai.edge.gallery.ui.common.chat.ChatMessageText -import com.google.ai.edge.gallery.ui.common.chat.ChatMessageType -import com.google.ai.edge.gallery.ui.common.chat.ChatViewModel -import com.google.ai.edge.gallery.ui.common.chat.Classification -import com.google.ai.edge.gallery.ui.common.getDistinctiveColor -import com.google.ai.edge.gallery.ui.common.runBasicBenchmark -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.delay -import kotlinx.coroutines.launch - -private const val TAG = "AGTextClassificationViewModel" - -class TextClassificationViewModel : ChatViewModel(task = TASK_TEXT_CLASSIFICATION) { - fun generateResponse(model: Model, input: String) { - viewModelScope.launch(Dispatchers.Default) { - // Wait for model to be initialized. - while (model.instance == null) { - delay(100) - } - - val result = TextClassificationModelHelper.runInference(model = model, input = input) - Log.d(TAG, "$result") - - addMessage( - model = model, - message = generateClassificationMessage(result = result), - ) - } - } - - fun runAgain(model: Model, message: ChatMessage) { - viewModelScope.launch(Dispatchers.Default) { - // Wait for model to be initialized. - while (model.instance == null) { - delay(100) - } - - if (message is ChatMessageText) { - // Clone the clicked message and add it. - addMessage(model = model, message = message.clone()) - - // Run inference. - val result = - TextClassificationModelHelper.runInference(model = model, input = message.content) - - // Add response message. - val newMessage = generateClassificationMessage(result = result) - addMessage( - model = model, - message = newMessage, - ) - } - } - } - - fun benchmark( - model: Model, message: ChatMessage, warmupCount: Int, itertations: Int - ) { - viewModelScope.launch(Dispatchers.Default) { - // Wait for model to be initialized. - while (model.instance == null) { - delay(100) - } - - if (message is ChatMessageText) { - setInProgress(true) - runBasicBenchmark( - model = model, - warmupCount = warmupCount, - iterations = itertations, - chatViewModel = this@TextClassificationViewModel, - inferenceFn = { - TextClassificationModelHelper.runInference(model = model, input = message.content) - }, - chatMessageType = ChatMessageType.BENCHMARK_RESULT, - ) - setInProgress(false) - } - } - } - - private fun generateClassificationMessage(result: TextClassificationInferenceResult): ChatMessageClassification { - return ChatMessageClassification(classifications = result.categories.mapIndexed { index, category -> - val color = when (category.categoryName().lowercase()) { - "negative", "0" -> Color(0xffe6194B) - "positive", "1" -> Color(0xff3cb44b) - else -> getDistinctiveColor(index) - } - category.toClassification(color = color) - }.sortedBy { it.label }, latencyMs = result.latencyMs) - } -} - -fun Category.toClassification(color: Color): Classification { - var categoryName = this.categoryName() - if (categoryName == "0") { - categoryName = "negative" - } else if (categoryName == "1") { - categoryName = "positive" - } - return Classification(label = categoryName, score = this.score(), color = color) -} diff --git a/Android/src/app/src/main/java/com/google/ai/edge/gallery/ui/theme/Color.kt b/Android/src/app/src/main/java/com/google/ai/edge/gallery/ui/theme/Color.kt index a952079..3dd7fb5 100644 --- a/Android/src/app/src/main/java/com/google/ai/edge/gallery/ui/theme/Color.kt +++ b/Android/src/app/src/main/java/com/google/ai/edge/gallery/ui/theme/Color.kt @@ -18,14 +18,14 @@ package com.google.ai.edge.gallery.ui.theme import androidx.compose.ui.graphics.Color -//val primaryLight = Color(0xFF32628D) +// val primaryLight = Color(0xFF32628D) val primaryLight = Color(0xFF1F1F1F) val onPrimaryLight = Color(0xFFFFFFFF) val primaryContainerLight = Color(0xFFD0E4FF) val onPrimaryContainerLight = Color(0xFF144A74) val secondaryLight = Color(0xFF526070) val onSecondaryLight = Color(0xFFFFFFFF) -//val secondaryContainerLight = Color(0xFFD6E4F7) +// val secondaryContainerLight = Color(0xFFD6E4F7) val secondaryContainerLight = Color(0xFFC2E7FF) val onSecondaryContainerLight = Color(0xFF3B4857) val tertiaryLight = Color(0xFF775A0B) diff --git a/Android/src/app/src/main/java/com/google/ai/edge/gallery/ui/theme/Theme.kt b/Android/src/app/src/main/java/com/google/ai/edge/gallery/ui/theme/Theme.kt index 5d8cd0e..72ad1d5 100644 --- a/Android/src/app/src/main/java/com/google/ai/edge/gallery/ui/theme/Theme.kt +++ b/Android/src/app/src/main/java/com/google/ai/edge/gallery/ui/theme/Theme.kt @@ -30,82 +30,85 @@ import androidx.compose.runtime.staticCompositionLocalOf import androidx.compose.ui.graphics.Color import androidx.compose.ui.platform.LocalView import androidx.core.view.WindowCompat +import com.google.ai.edge.gallery.proto.Theme -private val lightScheme = lightColorScheme( - primary = primaryLight, - onPrimary = onPrimaryLight, - primaryContainer = primaryContainerLight, - onPrimaryContainer = onPrimaryContainerLight, - secondary = secondaryLight, - onSecondary = onSecondaryLight, - secondaryContainer = secondaryContainerLight, - onSecondaryContainer = onSecondaryContainerLight, - tertiary = tertiaryLight, - onTertiary = onTertiaryLight, - tertiaryContainer = tertiaryContainerLight, - onTertiaryContainer = onTertiaryContainerLight, - error = errorLight, - onError = onErrorLight, - errorContainer = errorContainerLight, - onErrorContainer = onErrorContainerLight, - background = backgroundLight, - onBackground = onBackgroundLight, - surface = surfaceLight, - onSurface = onSurfaceLight, - surfaceVariant = surfaceVariantLight, - onSurfaceVariant = onSurfaceVariantLight, - outline = outlineLight, - outlineVariant = outlineVariantLight, - scrim = scrimLight, - inverseSurface = inverseSurfaceLight, - inverseOnSurface = inverseOnSurfaceLight, - inversePrimary = inversePrimaryLight, - surfaceDim = surfaceDimLight, - surfaceBright = surfaceBrightLight, - surfaceContainerLowest = surfaceContainerLowestLight, - surfaceContainerLow = surfaceContainerLowLight, - surfaceContainer = surfaceContainerLight, - surfaceContainerHigh = surfaceContainerHighLight, - surfaceContainerHighest = surfaceContainerHighestLight, -) +private val lightScheme = + lightColorScheme( + primary = primaryLight, + onPrimary = onPrimaryLight, + primaryContainer = primaryContainerLight, + onPrimaryContainer = onPrimaryContainerLight, + secondary = secondaryLight, + onSecondary = onSecondaryLight, + secondaryContainer = secondaryContainerLight, + onSecondaryContainer = onSecondaryContainerLight, + tertiary = tertiaryLight, + onTertiary = onTertiaryLight, + tertiaryContainer = tertiaryContainerLight, + onTertiaryContainer = onTertiaryContainerLight, + error = errorLight, + onError = onErrorLight, + errorContainer = errorContainerLight, + onErrorContainer = onErrorContainerLight, + background = backgroundLight, + onBackground = onBackgroundLight, + surface = surfaceLight, + onSurface = onSurfaceLight, + surfaceVariant = surfaceVariantLight, + onSurfaceVariant = onSurfaceVariantLight, + outline = outlineLight, + outlineVariant = outlineVariantLight, + scrim = scrimLight, + inverseSurface = inverseSurfaceLight, + inverseOnSurface = inverseOnSurfaceLight, + inversePrimary = inversePrimaryLight, + surfaceDim = surfaceDimLight, + surfaceBright = surfaceBrightLight, + surfaceContainerLowest = surfaceContainerLowestLight, + surfaceContainerLow = surfaceContainerLowLight, + surfaceContainer = surfaceContainerLight, + surfaceContainerHigh = surfaceContainerHighLight, + surfaceContainerHighest = surfaceContainerHighestLight, + ) -private val darkScheme = darkColorScheme( - primary = primaryDark, - onPrimary = onPrimaryDark, - primaryContainer = primaryContainerDark, - onPrimaryContainer = onPrimaryContainerDark, - secondary = secondaryDark, - onSecondary = onSecondaryDark, - secondaryContainer = secondaryContainerDark, - onSecondaryContainer = onSecondaryContainerDark, - tertiary = tertiaryDark, - onTertiary = onTertiaryDark, - tertiaryContainer = tertiaryContainerDark, - onTertiaryContainer = onTertiaryContainerDark, - error = errorDark, - onError = onErrorDark, - errorContainer = errorContainerDark, - onErrorContainer = onErrorContainerDark, - background = backgroundDark, - onBackground = onBackgroundDark, - surface = surfaceDark, - onSurface = onSurfaceDark, - surfaceVariant = surfaceVariantDark, - onSurfaceVariant = onSurfaceVariantDark, - outline = outlineDark, - outlineVariant = outlineVariantDark, - scrim = scrimDark, - inverseSurface = inverseSurfaceDark, - inverseOnSurface = inverseOnSurfaceDark, - inversePrimary = inversePrimaryDark, - surfaceDim = surfaceDimDark, - surfaceBright = surfaceBrightDark, - surfaceContainerLowest = surfaceContainerLowestDark, - surfaceContainerLow = surfaceContainerLowDark, - surfaceContainer = surfaceContainerDark, - surfaceContainerHigh = surfaceContainerHighDark, - surfaceContainerHighest = surfaceContainerHighestDark, -) +private val darkScheme = + darkColorScheme( + primary = primaryDark, + onPrimary = onPrimaryDark, + primaryContainer = primaryContainerDark, + onPrimaryContainer = onPrimaryContainerDark, + secondary = secondaryDark, + onSecondary = onSecondaryDark, + secondaryContainer = secondaryContainerDark, + onSecondaryContainer = onSecondaryContainerDark, + tertiary = tertiaryDark, + onTertiary = onTertiaryDark, + tertiaryContainer = tertiaryContainerDark, + onTertiaryContainer = onTertiaryContainerDark, + error = errorDark, + onError = onErrorDark, + errorContainer = errorContainerDark, + onErrorContainer = onErrorContainerDark, + background = backgroundDark, + onBackground = onBackgroundDark, + surface = surfaceDark, + onSurface = onSurfaceDark, + surfaceVariant = surfaceVariantDark, + onSurfaceVariant = onSurfaceVariantDark, + outline = outlineDark, + outlineVariant = outlineVariantDark, + scrim = scrimDark, + inverseSurface = inverseSurfaceDark, + inverseOnSurface = inverseOnSurfaceDark, + inversePrimary = inversePrimaryDark, + surfaceDim = surfaceDimDark, + surfaceBright = surfaceBrightDark, + surfaceContainerLowest = surfaceContainerLowestDark, + surfaceContainerLow = surfaceContainerLowDark, + surfaceContainer = surfaceContainerDark, + surfaceContainerHigh = surfaceContainerHighDark, + surfaceContainerHighest = surfaceContainerHighestDark, + ) @Immutable data class CustomColors( @@ -121,66 +124,54 @@ data class CustomColors( val LocalCustomColors = staticCompositionLocalOf { CustomColors() } -val lightCustomColors = CustomColors( - taskBgColors = listOf( - // green - Color(0xFFE1F6DE), - // blue - Color(0xFFEDF0FF), - // yellow - Color(0xFFFFEFC9), - // red - Color(0xFFFFEDE6), - ), - taskIconColors = listOf( - Color(0xFF34A853), - Color(0xFF1967D2), - Color(0xFFE37400), - Color(0xFFD93025), - ), - taskIconShapeBgColor = Color.White, - homeBottomGradient = listOf( - Color(0x00F8F9FF), - Color(0xffFFEFC9) - ), - agentBubbleBgColor = Color(0xFFe9eef6), - userBubbleBgColor = Color(0xFF32628D), - linkColor = Color(0xFF32628D), - successColor = Color(0xff3d860b), -) +val lightCustomColors = + CustomColors( + taskBgColors = + listOf( + // green + Color(0xFFE1F6DE), + // blue + Color(0xFFEDF0FF), + // yellow + Color(0xFFFFEFC9), + // red + Color(0xFFFFEDE6), + ), + taskIconColors = + listOf(Color(0xFF34A853), Color(0xFF1967D2), Color(0xFFE37400), Color(0xFFD93025)), + taskIconShapeBgColor = Color.White, + homeBottomGradient = listOf(Color(0x00F8F9FF), Color(0xffFFEFC9)), + agentBubbleBgColor = Color(0xFFe9eef6), + userBubbleBgColor = Color(0xFF32628D), + linkColor = Color(0xFF32628D), + successColor = Color(0xff3d860b), + ) -val darkCustomColors = CustomColors( - taskBgColors = listOf( - // green - Color(0xFF2E312D), - // blue - Color(0xFF303033), - // yellow - Color(0xFF33302A), - // red - Color(0xFF362F2D), - ), - taskIconColors = listOf( - Color(0xFF6DD58C), - Color(0xFFAAC7FF), - Color(0xFFFFB955), - Color(0xFFFFB4AB), - ), - taskIconShapeBgColor = Color(0xFF202124), - homeBottomGradient = listOf( - Color(0x00F8F9FF), - Color(0x1AF6AD01) - ), - agentBubbleBgColor = Color(0xFF1b1c1d), - userBubbleBgColor = Color(0xFF1f3760), - linkColor = Color(0xFF9DCAFC), - successColor = Color(0xFFA1CE83), -) +val darkCustomColors = + CustomColors( + taskBgColors = + listOf( + // green + Color(0xFF2E312D), + // blue + Color(0xFF303033), + // yellow + Color(0xFF33302A), + // red + Color(0xFF362F2D), + ), + taskIconColors = + listOf(Color(0xFF6DD58C), Color(0xFFAAC7FF), Color(0xFFFFB955), Color(0xFFFFB4AB)), + taskIconShapeBgColor = Color(0xFF202124), + homeBottomGradient = listOf(Color(0x00F8F9FF), Color(0x1AF6AD01)), + agentBubbleBgColor = Color(0xFF1b1c1d), + userBubbleBgColor = Color(0xFF1f3760), + linkColor = Color(0xFF9DCAFC), + successColor = Color(0xFFA1CE83), + ) val MaterialTheme.customColors: CustomColors - @Composable - @ReadOnlyComposable - get() = LocalCustomColors.current + @Composable @ReadOnlyComposable get() = LocalCustomColors.current /** * Controls the color of the phone's status bar icons based on whether the app is using a dark @@ -201,30 +192,23 @@ fun StatusBarColorController(useDarkTheme: Boolean) { } @Composable -fun GalleryTheme( - content: @Composable () -> Unit -) { +fun GalleryTheme(content: @Composable () -> Unit) { val themeOverride = ThemeSettings.themeOverride val darkTheme: Boolean = - (isSystemInDarkTheme() || themeOverride.value == THEME_DARK) && themeOverride.value != THEME_LIGHT + (isSystemInDarkTheme() || themeOverride.value == Theme.THEME_DARK) && + themeOverride.value != Theme.THEME_LIGHT StatusBarColorController(useDarkTheme = darkTheme) - val colorScheme = when { - darkTheme -> darkScheme - else -> lightScheme - } + val colorScheme = + when { + darkTheme -> darkScheme + else -> lightScheme + } val customColorsPalette = if (darkTheme) darkCustomColors else lightCustomColors - CompositionLocalProvider( - LocalCustomColors provides customColorsPalette - ) { - MaterialTheme( - colorScheme = colorScheme, - typography = AppTypography, - content = content - ) + CompositionLocalProvider(LocalCustomColors provides customColorsPalette) { + MaterialTheme(colorScheme = colorScheme, typography = AppTypography, content = content) } } - diff --git a/Android/src/app/src/main/java/com/google/ai/edge/gallery/ui/theme/ThemeSettings.kt b/Android/src/app/src/main/java/com/google/ai/edge/gallery/ui/theme/ThemeSettings.kt index 2978016..27ad41e 100644 --- a/Android/src/app/src/main/java/com/google/ai/edge/gallery/ui/theme/ThemeSettings.kt +++ b/Android/src/app/src/main/java/com/google/ai/edge/gallery/ui/theme/ThemeSettings.kt @@ -17,11 +17,8 @@ package com.google.ai.edge.gallery.ui.theme import androidx.compose.runtime.mutableStateOf - -const val THEME_AUTO = "Auto" -const val THEME_LIGHT = "Light" -const val THEME_DARK = "Dark" +import com.google.ai.edge.gallery.proto.Theme object ThemeSettings { - val themeOverride = mutableStateOf("") + val themeOverride = mutableStateOf(Theme.THEME_AUTO) } diff --git a/Android/src/app/src/main/java/com/google/ai/edge/gallery/ui/theme/Type.kt b/Android/src/app/src/main/java/com/google/ai/edge/gallery/ui/theme/Type.kt index d786a04..7bd9a53 100644 --- a/Android/src/app/src/main/java/com/google/ai/edge/gallery/ui/theme/Type.kt +++ b/Android/src/app/src/main/java/com/google/ai/edge/gallery/ui/theme/Type.kt @@ -23,45 +23,48 @@ import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.unit.sp import com.google.ai.edge.gallery.R -val nunitoFontFamily = FontFamily( - Font(R.font.nunito_regular, FontWeight.Normal), - Font(R.font.nunito_extralight, FontWeight.ExtraLight), - Font(R.font.nunito_light, FontWeight.Light), - Font(R.font.nunito_medium, FontWeight.Medium), - Font(R.font.nunito_semibold, FontWeight.SemiBold), - Font(R.font.nunito_bold, FontWeight.Bold), - Font(R.font.nunito_extrabold, FontWeight.ExtraBold), - Font(R.font.nunito_black, FontWeight.Black), -) +val nunitoFontFamily = + FontFamily( + Font(R.font.nunito_regular, FontWeight.Normal), + Font(R.font.nunito_extralight, FontWeight.ExtraLight), + Font(R.font.nunito_light, FontWeight.Light), + Font(R.font.nunito_medium, FontWeight.Medium), + Font(R.font.nunito_semibold, FontWeight.SemiBold), + Font(R.font.nunito_bold, FontWeight.Bold), + Font(R.font.nunito_extrabold, FontWeight.ExtraBold), + Font(R.font.nunito_black, FontWeight.Black), + ) val baseline = Typography() -val AppTypography = Typography( - displayLarge = baseline.displayLarge.copy(fontFamily = nunitoFontFamily), - displayMedium = baseline.displayMedium.copy(fontFamily = nunitoFontFamily), - displaySmall = baseline.displaySmall.copy(fontFamily = nunitoFontFamily), - headlineLarge = baseline.headlineLarge.copy(fontFamily = nunitoFontFamily), - headlineMedium = baseline.headlineMedium.copy(fontFamily = nunitoFontFamily), - headlineSmall = baseline.headlineSmall.copy(fontFamily = nunitoFontFamily), - titleLarge = baseline.titleLarge.copy(fontFamily = nunitoFontFamily), - titleMedium = baseline.titleMedium.copy(fontFamily = nunitoFontFamily), - titleSmall = baseline.titleSmall.copy(fontFamily = nunitoFontFamily), - bodyLarge = baseline.bodyLarge.copy(fontFamily = nunitoFontFamily), - bodyMedium = baseline.bodyMedium.copy(fontFamily = nunitoFontFamily), - bodySmall = baseline.bodySmall.copy(fontFamily = nunitoFontFamily), - labelLarge = baseline.labelLarge.copy(fontFamily = nunitoFontFamily), - labelMedium = baseline.labelMedium.copy(fontFamily = nunitoFontFamily), - labelSmall = baseline.labelSmall.copy(fontFamily = nunitoFontFamily), -) +val AppTypography = + Typography( + displayLarge = baseline.displayLarge.copy(fontFamily = nunitoFontFamily), + displayMedium = baseline.displayMedium.copy(fontFamily = nunitoFontFamily), + displaySmall = baseline.displaySmall.copy(fontFamily = nunitoFontFamily), + headlineLarge = baseline.headlineLarge.copy(fontFamily = nunitoFontFamily), + headlineMedium = baseline.headlineMedium.copy(fontFamily = nunitoFontFamily), + headlineSmall = baseline.headlineSmall.copy(fontFamily = nunitoFontFamily), + titleLarge = baseline.titleLarge.copy(fontFamily = nunitoFontFamily), + titleMedium = baseline.titleMedium.copy(fontFamily = nunitoFontFamily), + titleSmall = baseline.titleSmall.copy(fontFamily = nunitoFontFamily), + bodyLarge = baseline.bodyLarge.copy(fontFamily = nunitoFontFamily), + bodyMedium = baseline.bodyMedium.copy(fontFamily = nunitoFontFamily), + bodySmall = baseline.bodySmall.copy(fontFamily = nunitoFontFamily), + labelLarge = baseline.labelLarge.copy(fontFamily = nunitoFontFamily), + labelMedium = baseline.labelMedium.copy(fontFamily = nunitoFontFamily), + labelSmall = baseline.labelSmall.copy(fontFamily = nunitoFontFamily), + ) val titleMediumNarrow = baseline.titleMedium.copy(fontFamily = nunitoFontFamily, letterSpacing = 0.0.sp) -val titleSmaller = baseline.titleSmall.copy( - fontFamily = nunitoFontFamily, - fontSize = 12.sp, - fontWeight = FontWeight.Bold -) +val titleSmaller = + baseline.titleSmall.copy( + fontFamily = nunitoFontFamily, + fontSize = 12.sp, + fontWeight = FontWeight.Bold, + ) val labelSmallNarrow = baseline.labelSmall.copy(fontFamily = nunitoFontFamily, letterSpacing = 0.0.sp) @@ -70,11 +73,10 @@ val labelSmallNarrowMedium = baseline.labelSmall.copy( fontFamily = nunitoFontFamily, fontWeight = FontWeight.Medium, - letterSpacing = 0.0.sp + letterSpacing = 0.0.sp, ) -val bodySmallNarrow = - baseline.bodySmall.copy(fontFamily = nunitoFontFamily, letterSpacing = 0.0.sp) +val bodySmallNarrow = baseline.bodySmall.copy(fontFamily = nunitoFontFamily, letterSpacing = 0.0.sp) val bodySmallSemiBold = baseline.bodySmall.copy(fontFamily = nunitoFontFamily, fontWeight = FontWeight.SemiBold) @@ -90,5 +92,5 @@ val bodySmallMediumNarrowBold = fontFamily = nunitoFontFamily, letterSpacing = 0.0.sp, fontSize = 14.sp, - fontWeight = FontWeight.Bold + fontWeight = FontWeight.Bold, ) diff --git a/Android/src/app/src/main/java/com/google/ai/edge/gallery/worker/DownloadWorker.kt b/Android/src/app/src/main/java/com/google/ai/edge/gallery/worker/DownloadWorker.kt index ed1a2d2..2fb1315 100644 --- a/Android/src/app/src/main/java/com/google/ai/edge/gallery/worker/DownloadWorker.kt +++ b/Android/src/app/src/main/java/com/google/ai/edge/gallery/worker/DownloadWorker.kt @@ -22,11 +22,13 @@ import android.content.Context import android.content.pm.ServiceInfo import android.os.Build import android.util.Log +import androidx.annotation.RequiresApi import androidx.core.app.NotificationCompat import androidx.work.CoroutineWorker import androidx.work.Data import androidx.work.ForegroundInfo import androidx.work.WorkerParameters +import com.google.ai.edge.gallery.common.readLaunchInfo import com.google.ai.edge.gallery.data.KEY_MODEL_DOWNLOAD_ACCESS_TOKEN import com.google.ai.edge.gallery.data.KEY_MODEL_DOWNLOAD_APP_TS import com.google.ai.edge.gallery.data.KEY_MODEL_DOWNLOAD_ERROR_MESSAGE @@ -44,9 +46,6 @@ import com.google.ai.edge.gallery.data.KEY_MODEL_TOTAL_BYTES import com.google.ai.edge.gallery.data.KEY_MODEL_UNZIPPED_DIR import com.google.ai.edge.gallery.data.KEY_MODEL_URL import com.google.ai.edge.gallery.data.KEY_MODEL_VERSION -import com.google.ai.edge.gallery.ui.common.readLaunchInfo -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.withContext import java.io.BufferedInputStream import java.io.File import java.io.FileInputStream @@ -56,17 +55,17 @@ import java.net.HttpURLConnection import java.net.URL import java.util.zip.ZipEntry import java.util.zip.ZipInputStream +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext private const val TAG = "AGDownloadWorker" -data class UrlAndFileName( - val url: String, - val fileName: String, -) +data class UrlAndFileName(val url: String, val fileName: String) private const val FOREGROUND_NOTIFICATION_CHANNEL_ID = "model_download_channel_foreground" private var channelCreated = false +@RequiresApi(Build.VERSION_CODES.O) class DownloadWorker(context: Context, params: WorkerParameters) : CoroutineWorker(context, params) { private val externalFilesDir = context.getExternalFilesDir(null) @@ -80,14 +79,14 @@ class DownloadWorker(context: Context, params: WorkerParameters) : init { if (!channelCreated) { // Create a notification channel for showing notifications for model downloading progress. - val channel = NotificationChannel( - FOREGROUND_NOTIFICATION_CHANNEL_ID, - "Model Downloading", - // Make it silent. - NotificationManager.IMPORTANCE_LOW - ).apply { - description = "Notifications for model downloading" - } + val channel = + NotificationChannel( + FOREGROUND_NOTIFICATION_CHANNEL_ID, + "Model Downloading", + // Make it silent. + NotificationManager.IMPORTANCE_LOW, + ) + .apply { description = "Notifications for model downloading" } notificationManager.createNotificationChannel(channel) channelCreated = true } @@ -128,9 +127,7 @@ class DownloadWorker(context: Context, params: WorkerParameters) : allFiles.add(UrlAndFileName(url = fileUrl, fileName = fileName)) for (index in extraDataFileUrls.indices) { allFiles.add( - UrlAndFileName( - url = extraDataFileUrls[index], fileName = extraDataFileNames[index] - ) + UrlAndFileName(url = extraDataFileUrls[index], fileName = extraDataFileNames[index]) ) } Log.d(TAG, "About to download: $allFiles") @@ -150,33 +147,36 @@ class DownloadWorker(context: Context, params: WorkerParameters) : } // Prepare output file's dir. - val outputDir = File( - applicationContext.getExternalFilesDir(null), - listOf(modelDir, version).joinToString(separator = File.separator) - ) + val outputDir = + File( + applicationContext.getExternalFilesDir(null), + listOf(modelDir, version).joinToString(separator = File.separator), + ) if (!outputDir.exists()) { outputDir.mkdirs() } // Read the file and see if it is partially downloaded. - val outputFile = File( - applicationContext.getExternalFilesDir(null), - listOf(modelDir, version, file.fileName).joinToString(separator = File.separator) - ) + val outputFile = + File( + applicationContext.getExternalFilesDir(null), + listOf(modelDir, version, file.fileName).joinToString(separator = File.separator), + ) val outputFileBytes = outputFile.length() if (outputFileBytes > 0) { Log.d( TAG, - "File '${file.fileName}' partial size: ${outputFileBytes}. Trying to resume download" - ) - connection.setRequestProperty( - "Range", "bytes=${outputFileBytes}-" + "File '${file.fileName}' partial size: ${outputFileBytes}. Trying to resume download", ) + connection.setRequestProperty("Range", "bytes=${outputFileBytes}-") } connection.connect() Log.d(TAG, "response code: ${connection.responseCode}") - if (connection.responseCode == HttpURLConnection.HTTP_OK || connection.responseCode == HttpURLConnection.HTTP_PARTIAL) { + if ( + connection.responseCode == HttpURLConnection.HTTP_OK || + connection.responseCode == HttpURLConnection.HTTP_PARTIAL + ) { val contentRange = connection.getHeaderField("Content-Range") if (contentRange != null) { @@ -188,7 +188,7 @@ class DownloadWorker(context: Context, params: WorkerParameters) : Log.d( TAG, - "Content-Range: $contentRange. Start bytes: ${startByte}, end bytes: $endByte" + "Content-Range: $contentRange. Start bytes: ${startByte}, end bytes: $endByte", ) downloadedBytes += startByte @@ -236,15 +236,16 @@ class DownloadWorker(context: Context, params: WorkerParameters) : } setProgress( - Data.Builder().putLong( - KEY_MODEL_DOWNLOAD_RECEIVED_BYTES, downloadedBytes - ).putLong(KEY_MODEL_DOWNLOAD_RATE, (bytesPerMs * 1000).toLong()).putLong( - KEY_MODEL_DOWNLOAD_REMAINING_MS, remainingMs.toLong() - ).build() + Data.Builder() + .putLong(KEY_MODEL_DOWNLOAD_RECEIVED_BYTES, downloadedBytes) + .putLong(KEY_MODEL_DOWNLOAD_RATE, (bytesPerMs * 1000).toLong()) + .putLong(KEY_MODEL_DOWNLOAD_REMAINING_MS, remainingMs.toLong()) + .build() ) setForeground( createForegroundInfo( - progress = (downloadedBytes * 100 / totalBytes).toInt(), modelName = modelName + progress = (downloadedBytes * 100 / totalBytes).toInt(), + modelName = modelName, ) ) Log.d(TAG, "downloadedBytes: $downloadedBytes") @@ -262,10 +263,11 @@ class DownloadWorker(context: Context, params: WorkerParameters) : setProgress(Data.Builder().putBoolean(KEY_MODEL_START_UNZIPPING, true).build()) // Prepare target dir. - val destDir = File( - externalFilesDir, - listOf(modelDir, version, unzippedDir).joinToString(File.separator) - ) + val destDir = + File( + externalFilesDir, + listOf(modelDir, version, unzippedDir).joinToString(File.separator), + ) if (!destDir.exists()) { destDir.mkdirs() } @@ -323,9 +325,9 @@ class DownloadWorker(context: Context, params: WorkerParameters) : } /** - * Creates a [ForegroundInfo] object for the download worker's ongoing notification. - * This notification is used to keep the worker running in the foreground, indicating - * to the user that an active download is in progress. + * Creates a [ForegroundInfo] object for the download worker's ongoing notification. This + * notification is used to keep the worker running in the foreground, indicating to the user that + * an active download is in progress. */ private fun createForegroundInfo(progress: Int, modelName: String? = null): ForegroundInfo { // Create a notification for the foreground service @@ -337,21 +339,17 @@ class DownloadWorker(context: Context, params: WorkerParameters) : val notification = NotificationCompat.Builder(applicationContext, FOREGROUND_NOTIFICATION_CHANNEL_ID) - .setContentTitle(title).setContentText(content) + .setContentTitle(title) + .setContentText(content) .setSmallIcon(android.R.drawable.ic_dialog_info) .setOngoing(true) // Makes the notification non-dismissable .setProgress(100, progress, false) // Show progress .build() return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) { - ForegroundInfo( - notificationId, notification, ServiceInfo.FOREGROUND_SERVICE_TYPE_DATA_SYNC - ) + ForegroundInfo(notificationId, notification, ServiceInfo.FOREGROUND_SERVICE_TYPE_DATA_SYNC) } else { - ForegroundInfo( - notificationId, - notification, - ) + ForegroundInfo(notificationId, notification) } } -} \ No newline at end of file +} diff --git a/Android/src/app/src/main/proto/settings.proto b/Android/src/app/src/main/proto/settings.proto new file mode 100644 index 0000000..5540eaf --- /dev/null +++ b/Android/src/app/src/main/proto/settings.proto @@ -0,0 +1,65 @@ +/* Copyright 2025 Google LLC + +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 + + http://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. +==============================================================================*/ + +syntax = "proto3"; + +package com.google.ai.edge.gallery.proto; + +option java_package = "com.google.ai.edge.gallery.proto"; +option java_multiple_files = true; + +enum Theme { + THEME_UNSPECIFIED = 0; + + // Force to use light theme. + THEME_LIGHT = 1; + + // Force to use dark theme. + THEME_DARK = 2; + + // Use the system them setting on user's phone. + THEME_AUTO = 3; +} + +message AccessTokenData { + string access_token = 1; + string refresh_token = 2; + int64 expires_at_ms = 3; +} + +message ImportedModel { + string file_name = 1; + int64 file_size = 2; + + oneof config { + LlmConfig llm_config = 3; + } +} + +message LlmConfig { + repeated string compatible_accelerators = 1; + int32 default_max_tokens = 2; + int32 default_topk = 3; + float default_topp = 4; + float default_temperature = 5; + bool support_image = 6; +} + +message Settings { + Theme theme = 1; + AccessTokenData access_token_data = 2; + repeated string text_input_history = 3; + repeated ImportedModel imported_model = 4; +} diff --git a/Android/src/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml b/Android/src/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml index 345888d..934c46c 100644 --- a/Android/src/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml +++ b/Android/src/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml @@ -1,4 +1,19 @@ + diff --git a/Android/src/build.gradle.kts b/Android/src/build.gradle.kts index c2b0d4d..da93723 100644 --- a/Android/src/build.gradle.kts +++ b/Android/src/build.gradle.kts @@ -16,7 +16,7 @@ // Top-level build file where you can add configuration options common to all sub-projects/modules. plugins { - alias(libs.plugins.android.application) apply false - alias(libs.plugins.kotlin.android) apply false - alias(libs.plugins.kotlin.compose) apply false -} \ No newline at end of file + alias(libs.plugins.android.application) apply false + alias(libs.plugins.kotlin.android) apply false + alias(libs.plugins.kotlin.compose) apply false +} diff --git a/Android/src/gradle/libs.versions.toml b/Android/src/gradle/libs.versions.toml index 7eca9d2..31e5f49 100644 --- a/Android/src/gradle/libs.versions.toml +++ b/Android/src/gradle/libs.versions.toml @@ -13,9 +13,11 @@ serializationPlugin = "2.0.21" serializationJson = "1.7.3" materialIconExtended = "1.7.8" workRuntime = "2.10.0" -dataStore = "1.1.4" +dataStore = "1.1.7" gson = "2.12.1" lifecycleProcess = "2.8.7" +protobuf = "0.9.5" +protobufJavaLite = "4.26.1" #noinspection GradleDependency mediapipeTasksText = "0.10.21" mediapipeTasksGenai = "0.10.24" @@ -47,7 +49,7 @@ androidx-compose-navigation = { group = "androidx.navigation", name = "navigatio kotlinx-serialization-json = { group = "org.jetbrains.kotlinx", name = "kotlinx-serialization-json", version.ref = "serializationJson" } material-icon-extended = { group = "androidx.compose.material", name = "material-icons-extended", version.ref = "materialIconExtended" } androidx-work-runtime = { group = "androidx.work", name = "work-runtime-ktx", version.ref = "workRuntime" } -androidx-datastore-preferences = { group = "androidx.datastore", name = "datastore-preferences", version.ref = "dataStore" } +androidx-datastore = { group = "androidx.datastore", name = "datastore", version.ref = "dataStore" } com-google-code-gson = { group = "com.google.code.gson", name = "gson", version.ref = "gson" } androidx-lifecycle-process = { group = "androidx.lifecycle", name = "lifecycle-process", version.ref = "lifecycleProcess" } mediapipe-tasks-text = { group = "com.google.mediapipe", name = "tasks-text", version.ref = "mediapipeTasksText" } @@ -64,10 +66,11 @@ camerax-lifecycle = { group = "androidx.camera", name = "camera-lifecycle", vers camerax-view = { group = "androidx.camera", name = "camera-view", version.ref = "cameraX"} openid-appauth = { group = "net.openid", name = "appauth", version.ref = "netOpenidAppauth" } androidx-splashscreen = { group = "androidx.core", name = "core-splashscreen", version.ref = "splashscreen" } +protobuf-javalite = { group = "com.google.protobuf", name = "protobuf-javalite", version.ref = "protobufJavaLite" } [plugins] android-application = { id = "com.android.application", version.ref = "agp" } kotlin-android = { id = "org.jetbrains.kotlin.android", version.ref = "kotlin" } kotlin-compose = { id = "org.jetbrains.kotlin.plugin.compose", version.ref = "kotlin" } kotlin-serialization = { id = "org.jetbrains.kotlin.plugin.serialization", version.ref = "serializationPlugin" } - +protobuf = {id = "com.google.protobuf", version.ref = "protobuf"} diff --git a/Android/src/gradle/wrapper/gradle-wrapper.jar b/Android/src/gradle/wrapper/gradle-wrapper.jar index e708b1c023ec8b20f512888fe07c5bd3ff77bb8f..d64cd4917707c1f8861d8cb53dd15194d4248596 100644 GIT binary patch literal 43462 zcma&NWl&^owk(X(xVyW%ySuwf;qI=D6|RlDJ2cR^yEKh!@I- zp9QeisK*rlxC>+~7Dk4IxIRsKBHqdR9b3+fyL=ynHmIDe&|>O*VlvO+%z5;9Z$|DJ zb4dO}-R=MKr^6EKJiOrJdLnCJn>np?~vU-1sSFgPu;pthGwf}bG z(1db%xwr#x)r+`4AGu$j7~u2MpVs3VpLp|mx&;>`0p0vH6kF+D2CY0fVdQOZ@h;A` z{infNyvmFUiu*XG}RNMNwXrbec_*a3N=2zJ|Wh5z* z5rAX$JJR{#zP>KY**>xHTuw?|-Rg|o24V)74HcfVT;WtQHXlE+_4iPE8QE#DUm%x0 zEKr75ur~W%w#-My3Tj`hH6EuEW+8K-^5P62$7Sc5OK+22qj&Pd1;)1#4tKihi=~8C zHiQSst0cpri6%OeaR`PY>HH_;CPaRNty%WTm4{wDK8V6gCZlG@U3$~JQZ;HPvDJcT1V{ z?>H@13MJcCNe#5z+MecYNi@VT5|&UiN1D4ATT+%M+h4c$t;C#UAs3O_q=GxK0}8%8 z8J(_M9bayxN}69ex4dzM_P3oh@ZGREjVvn%%r7=xjkqxJP4kj}5tlf;QosR=%4L5y zWhgejO=vao5oX%mOHbhJ8V+SG&K5dABn6!WiKl{|oPkq(9z8l&Mm%(=qGcFzI=eLu zWc_oCLyf;hVlB@dnwY98?75B20=n$>u3b|NB28H0u-6Rpl((%KWEBOfElVWJx+5yg z#SGqwza7f}$z;n~g%4HDU{;V{gXIhft*q2=4zSezGK~nBgu9-Q*rZ#2f=Q}i2|qOp z!!y4p)4o=LVUNhlkp#JL{tfkhXNbB=Ox>M=n6soptJw-IDI|_$is2w}(XY>a=H52d z3zE$tjPUhWWS+5h=KVH&uqQS=$v3nRs&p$%11b%5qtF}S2#Pc`IiyBIF4%A!;AVoI zXU8-Rpv!DQNcF~(qQnyyMy=-AN~U>#&X1j5BLDP{?K!%h!;hfJI>$mdLSvktEr*89 zdJHvby^$xEX0^l9g$xW-d?J;L0#(`UT~zpL&*cEh$L|HPAu=P8`OQZV!-}l`noSp_ zQ-1$q$R-gDL)?6YaM!=8H=QGW$NT2SeZlb8PKJdc=F-cT@j7Xags+Pr*jPtlHFnf- zh?q<6;)27IdPc^Wdy-mX%2s84C1xZq9Xms+==F4);O`VUASmu3(RlgE#0+#giLh-& zcxm3_e}n4{%|X zJp{G_j+%`j_q5}k{eW&TlP}J2wtZ2^<^E(O)4OQX8FDp6RJq!F{(6eHWSD3=f~(h} zJXCf7=r<16X{pHkm%yzYI_=VDP&9bmI1*)YXZeB}F? z(%QsB5fo*FUZxK$oX~X^69;x~j7ms8xlzpt-T15e9}$4T-pC z6PFg@;B-j|Ywajpe4~bk#S6(fO^|mm1hKOPfA%8-_iGCfICE|=P_~e;Wz6my&)h_~ zkv&_xSAw7AZ%ThYF(4jADW4vg=oEdJGVOs>FqamoL3Np8>?!W#!R-0%2Bg4h?kz5I zKV-rKN2n(vUL%D<4oj@|`eJ>0i#TmYBtYmfla;c!ATW%;xGQ0*TW@PTlGG><@dxUI zg>+3SiGdZ%?5N=8uoLA|$4isK$aJ%i{hECP$bK{J#0W2gQ3YEa zZQ50Stn6hqdfxJ*9#NuSLwKFCUGk@c=(igyVL;;2^wi4o30YXSIb2g_ud$ zgpCr@H0qWtk2hK8Q|&wx)}4+hTYlf;$a4#oUM=V@Cw#!$(nOFFpZ;0lc!qd=c$S}Z zGGI-0jg~S~cgVT=4Vo)b)|4phjStD49*EqC)IPwyeKBLcN;Wu@Aeph;emROAwJ-0< z_#>wVm$)ygH|qyxZaet&(Vf%pVdnvKWJn9`%DAxj3ot;v>S$I}jJ$FLBF*~iZ!ZXE zkvui&p}fI0Y=IDX)mm0@tAd|fEHl~J&K}ZX(Mm3cm1UAuwJ42+AO5@HwYfDH7ipIc zmI;1J;J@+aCNG1M`Btf>YT>~c&3j~Qi@Py5JT6;zjx$cvOQW@3oQ>|}GH?TW-E z1R;q^QFjm5W~7f}c3Ww|awg1BAJ^slEV~Pk`Kd`PS$7;SqJZNj->it4DW2l15}xP6 zoCl$kyEF%yJni0(L!Z&14m!1urXh6Btj_5JYt1{#+H8w?5QI%% zo-$KYWNMJVH?Hh@1n7OSu~QhSswL8x0=$<8QG_zepi_`y_79=nK=_ZP_`Em2UI*tyQoB+r{1QYZCpb?2OrgUw#oRH$?^Tj!Req>XiE#~B|~ z+%HB;=ic+R@px4Ld8mwpY;W^A%8%l8$@B@1m5n`TlKI6bz2mp*^^^1mK$COW$HOfp zUGTz-cN9?BGEp}5A!mDFjaiWa2_J2Iq8qj0mXzk; z66JBKRP{p%wN7XobR0YjhAuW9T1Gw3FDvR5dWJ8ElNYF94eF3ebu+QwKjtvVu4L zI9ip#mQ@4uqVdkl-TUQMb^XBJVLW(-$s;Nq;@5gr4`UfLgF$adIhd?rHOa%D);whv z=;krPp~@I+-Z|r#s3yCH+c1US?dnm+C*)r{m+86sTJusLdNu^sqLrfWed^ndHXH`m zd3#cOe3>w-ga(Dus_^ppG9AC>Iq{y%%CK+Cro_sqLCs{VLuK=dev>OL1dis4(PQ5R zcz)>DjEkfV+MO;~>VUlYF00SgfUo~@(&9$Iy2|G0T9BSP?&T22>K46D zL*~j#yJ?)^*%J3!16f)@Y2Z^kS*BzwfAQ7K96rFRIh>#$*$_Io;z>ux@}G98!fWR@ zGTFxv4r~v)Gsd|pF91*-eaZ3Qw1MH$K^7JhWIdX%o$2kCbvGDXy)a?@8T&1dY4`;L z4Kn+f%SSFWE_rpEpL9bnlmYq`D!6F%di<&Hh=+!VI~j)2mfil03T#jJ_s?}VV0_hp z7T9bWxc>Jm2Z0WMU?`Z$xE74Gu~%s{mW!d4uvKCx@WD+gPUQ zV0vQS(Ig++z=EHN)BR44*EDSWIyT~R4$FcF*VEY*8@l=218Q05D2$|fXKFhRgBIEE zdDFB}1dKkoO^7}{5crKX!p?dZWNz$m>1icsXG2N+((x0OIST9Zo^DW_tytvlwXGpn zs8?pJXjEG;T@qrZi%#h93?FP$!&P4JA(&H61tqQi=opRzNpm zkrG}$^t9&XduK*Qa1?355wd8G2CI6QEh@Ua>AsD;7oRUNLPb76m4HG3K?)wF~IyS3`fXuNM>${?wmB zpVz;?6_(Fiadfd{vUCBM*_kt$+F3J+IojI;9L(gc9n3{sEZyzR9o!_mOwFC#tQ{Q~ zP3-`#uK#tP3Q7~Q;4H|wjZHO8h7e4IuBxl&vz2w~D8)w=Wtg31zpZhz%+kzSzL*dV zwp@{WU4i;hJ7c2f1O;7Mz6qRKeASoIv0_bV=i@NMG*l<#+;INk-^`5w@}Dj~;k=|}qM1vq_P z|GpBGe_IKq|LNy9SJhKOQ$c=5L{Dv|Q_lZl=-ky*BFBJLW9&y_C|!vyM~rQx=!vun z?rZJQB5t}Dctmui5i31C_;_}CEn}_W%>oSXtt>@kE1=JW*4*v4tPp;O6 zmAk{)m!)}34pTWg8{i>($%NQ(Tl;QC@J@FfBoc%Gr&m560^kgSfodAFrIjF}aIw)X zoXZ`@IsMkc8_=w%-7`D6Y4e*CG8k%Ud=GXhsTR50jUnm+R*0A(O3UKFg0`K;qp1bl z7``HN=?39ic_kR|^R^~w-*pa?Vj#7|e9F1iRx{GN2?wK!xR1GW!qa=~pjJb-#u1K8 zeR?Y2i-pt}yJq;SCiVHODIvQJX|ZJaT8nO+(?HXbLefulKKgM^B(UIO1r+S=7;kLJ zcH}1J=Px2jsh3Tec&v8Jcbng8;V-`#*UHt?hB(pmOipKwf3Lz8rG$heEB30Sg*2rx zV<|KN86$soN(I!BwO`1n^^uF2*x&vJ$2d$>+`(romzHP|)K_KkO6Hc>_dwMW-M(#S zK(~SiXT1@fvc#U+?|?PniDRm01)f^#55;nhM|wi?oG>yBsa?~?^xTU|fX-R(sTA+5 zaq}-8Tx7zrOy#3*JLIIVsBmHYLdD}!0NP!+ITW+Thn0)8SS!$@)HXwB3tY!fMxc#1 zMp3H?q3eD?u&Njx4;KQ5G>32+GRp1Ee5qMO0lZjaRRu&{W<&~DoJNGkcYF<5(Ab+J zgO>VhBl{okDPn78<%&e2mR{jwVCz5Og;*Z;;3%VvoGo_;HaGLWYF7q#jDX=Z#Ml`H z858YVV$%J|e<1n`%6Vsvq7GmnAV0wW4$5qQ3uR@1i>tW{xrl|ExywIc?fNgYlA?C5 zh$ezAFb5{rQu6i7BSS5*J-|9DQ{6^BVQ{b*lq`xS@RyrsJN?-t=MTMPY;WYeKBCNg z^2|pN!Q^WPJuuO4!|P@jzt&tY1Y8d%FNK5xK(!@`jO2aEA*4 zkO6b|UVBipci?){-Ke=+1;mGlND8)6+P;8sq}UXw2hn;fc7nM>g}GSMWu&v&fqh

iViYT=fZ(|3Ox^$aWPp4a8h24tD<|8-!aK0lHgL$N7Efw}J zVIB!7=T$U`ao1?upi5V4Et*-lTG0XvExbf!ya{cua==$WJyVG(CmA6Of*8E@DSE%L z`V^$qz&RU$7G5mg;8;=#`@rRG`-uS18$0WPN@!v2d{H2sOqP|!(cQ@ zUHo!d>>yFArLPf1q`uBvY32miqShLT1B@gDL4XoVTK&@owOoD)OIHXrYK-a1d$B{v zF^}8D3Y^g%^cnvScOSJR5QNH+BI%d|;J;wWM3~l>${fb8DNPg)wrf|GBP8p%LNGN# z3EaIiItgwtGgT&iYCFy9-LG}bMI|4LdmmJt@V@% zb6B)1kc=T)(|L@0;wr<>=?r04N;E&ef+7C^`wPWtyQe(*pD1pI_&XHy|0gIGHMekd zF_*M4yi6J&Z4LQj65)S zXwdM{SwUo%3SbPwFsHgqF@V|6afT|R6?&S;lw=8% z3}@9B=#JI3@B*#4s!O))~z zc>2_4Q_#&+5V`GFd?88^;c1i7;Vv_I*qt!_Yx*n=;rj!82rrR2rQ8u5(Ejlo{15P% zs~!{%XJ>FmJ})H^I9bn^Re&38H{xA!0l3^89k(oU;bZWXM@kn$#aoS&Y4l^-WEn-fH39Jb9lA%s*WsKJQl?n9B7_~P z-XM&WL7Z!PcoF6_D>V@$CvUIEy=+Z&0kt{szMk=f1|M+r*a43^$$B^MidrT0J;RI` z(?f!O<8UZkm$_Ny$Hth1J#^4ni+im8M9mr&k|3cIgwvjAgjH z8`N&h25xV#v*d$qBX5jkI|xOhQn!>IYZK7l5#^P4M&twe9&Ey@@GxYMxBZq2e7?`q z$~Szs0!g{2fGcp9PZEt|rdQ6bhAgpcLHPz?f-vB?$dc*!9OL?Q8mn7->bFD2Si60* z!O%y)fCdMSV|lkF9w%x~J*A&srMyYY3{=&$}H zGQ4VG_?$2X(0|vT0{=;W$~icCI{b6W{B!Q8xdGhF|D{25G_5_+%s(46lhvNLkik~R z>nr(&C#5wwOzJZQo9m|U<;&Wk!_#q|V>fsmj1g<6%hB{jGoNUPjgJslld>xmODzGjYc?7JSuA?A_QzjDw5AsRgi@Y|Z0{F{!1=!NES-#*f^s4l0Hu zz468))2IY5dmD9pa*(yT5{EyP^G>@ZWumealS-*WeRcZ}B%gxq{MiJ|RyX-^C1V=0 z@iKdrGi1jTe8Ya^x7yyH$kBNvM4R~`fbPq$BzHum-3Zo8C6=KW@||>zsA8-Y9uV5V z#oq-f5L5}V<&wF4@X@<3^C%ptp6+Ce)~hGl`kwj)bsAjmo_GU^r940Z-|`<)oGnh7 zFF0Tde3>ui?8Yj{sF-Z@)yQd~CGZ*w-6p2U<8}JO-sRsVI5dBji`01W8A&3$?}lxBaC&vn0E$c5tW* zX>5(zzZ=qn&!J~KdsPl;P@bmA-Pr8T*)eh_+Dv5=Ma|XSle6t(k8qcgNyar{*ReQ8 zTXwi=8vr>!3Ywr+BhggHDw8ke==NTQVMCK`$69fhzEFB*4+H9LIvdt-#IbhZvpS}} zO3lz;P?zr0*0$%-Rq_y^k(?I{Mk}h@w}cZpMUp|ucs55bcloL2)($u%mXQw({Wzc~ z;6nu5MkjP)0C(@%6Q_I_vsWrfhl7Zpoxw#WoE~r&GOSCz;_ro6i(^hM>I$8y>`!wW z*U^@?B!MMmb89I}2(hcE4zN2G^kwyWCZp5JG>$Ez7zP~D=J^LMjSM)27_0B_X^C(M z`fFT+%DcKlu?^)FCK>QzSnV%IsXVcUFhFdBP!6~se&xxrIxsvySAWu++IrH;FbcY$ z2DWTvSBRfLwdhr0nMx+URA$j3i7_*6BWv#DXfym?ZRDcX9C?cY9sD3q)uBDR3uWg= z(lUIzB)G$Hr!){>E{s4Dew+tb9kvToZp-1&c?y2wn@Z~(VBhqz`cB;{E4(P3N2*nJ z_>~g@;UF2iG{Kt(<1PyePTKahF8<)pozZ*xH~U-kfoAayCwJViIrnqwqO}7{0pHw$ zs2Kx?s#vQr7XZ264>5RNKSL8|Ty^=PsIx^}QqOOcfpGUU4tRkUc|kc7-!Ae6!+B{o~7nFpm3|G5^=0#Bnm6`V}oSQlrX(u%OWnC zoLPy&Q;1Jui&7ST0~#+}I^&?vcE*t47~Xq#YwvA^6^} z`WkC)$AkNub|t@S!$8CBlwbV~?yp&@9h{D|3z-vJXgzRC5^nYm+PyPcgRzAnEi6Q^gslXYRv4nycsy-SJu?lMps-? zV`U*#WnFsdPLL)Q$AmD|0`UaC4ND07+&UmOu!eHruzV|OUox<+Jl|Mr@6~C`T@P%s zW7sgXLF2SSe9Fl^O(I*{9wsFSYb2l%-;&Pi^dpv!{)C3d0AlNY6!4fgmSgj_wQ*7Am7&$z;Jg&wgR-Ih;lUvWS|KTSg!&s_E9_bXBkZvGiC6bFKDWZxsD$*NZ#_8bl zG1P-#@?OQzED7@jlMJTH@V!6k;W>auvft)}g zhoV{7$q=*;=l{O>Q4a@ ziMjf_u*o^PsO)#BjC%0^h>Xp@;5$p{JSYDt)zbb}s{Kbt!T*I@Pk@X0zds6wsefuU zW$XY%yyRGC94=6mf?x+bbA5CDQ2AgW1T-jVAJbm7K(gp+;v6E0WI#kuACgV$r}6L? zd|Tj?^%^*N&b>Dd{Wr$FS2qI#Ucs1yd4N+RBUQiSZGujH`#I)mG&VKoDh=KKFl4=G z&MagXl6*<)$6P}*Tiebpz5L=oMaPrN+caUXRJ`D?=K9!e0f{@D&cZLKN?iNP@X0aF zE(^pl+;*T5qt?1jRC=5PMgV!XNITRLS_=9{CJExaQj;lt!&pdzpK?8p>%Mb+D z?yO*uSung=-`QQ@yX@Hyd4@CI^r{2oiu`%^bNkz+Nkk!IunjwNC|WcqvX~k=><-I3 zDQdbdb|!v+Iz01$w@aMl!R)koD77Xp;eZwzSl-AT zr@Vu{=xvgfq9akRrrM)}=!=xcs+U1JO}{t(avgz`6RqiiX<|hGG1pmop8k6Q+G_mv zJv|RfDheUp2L3=^C=4aCBMBn0aRCU(DQwX-W(RkRwmLeuJYF<0urcaf(=7)JPg<3P zQs!~G)9CT18o!J4{zX{_e}4eS)U-E)0FAt}wEI(c0%HkxgggW;(1E=>J17_hsH^sP z%lT0LGgbUXHx-K*CI-MCrP66UP0PvGqM$MkeLyqHdbgP|_Cm!7te~b8p+e6sQ_3k| zVcwTh6d83ltdnR>D^)BYQpDKlLk3g0Hdcgz2}%qUs9~~Rie)A-BV1mS&naYai#xcZ z(d{8=-LVpTp}2*y)|gR~;qc7fp26}lPcLZ#=JpYcn3AT9(UIdOyg+d(P5T7D&*P}# zQCYplZO5|7+r19%9e`v^vfSS1sbX1c%=w1;oyruXB%Kl$ACgKQ6=qNWLsc=28xJjg zwvsI5-%SGU|3p>&zXVl^vVtQT3o-#$UT9LI@Npz~6=4!>mc431VRNN8od&Ul^+G_kHC`G=6WVWM z%9eWNyy(FTO|A+@x}Ou3CH)oi;t#7rAxdIXfNFwOj_@Y&TGz6P_sqiB`Q6Lxy|Q{`|fgmRG(k+!#b*M+Z9zFce)f-7;?Km5O=LHV9f9_87; zF7%R2B+$?@sH&&-$@tzaPYkw0;=i|;vWdI|Wl3q_Zu>l;XdIw2FjV=;Mq5t1Q0|f< zs08j54Bp`3RzqE=2enlkZxmX6OF+@|2<)A^RNQpBd6o@OXl+i)zO%D4iGiQNuXd+zIR{_lb96{lc~bxsBveIw6umhShTX+3@ZJ=YHh@ zWY3(d0azg;7oHn>H<>?4@*RQbi>SmM=JrHvIG(~BrvI)#W(EAeO6fS+}mxxcc+X~W6&YVl86W9WFSS}Vz-f9vS?XUDBk)3TcF z8V?$4Q)`uKFq>xT=)Y9mMFVTUk*NIA!0$?RP6Ig0TBmUFrq*Q-Agq~DzxjStQyJ({ zBeZ;o5qUUKg=4Hypm|}>>L=XKsZ!F$yNTDO)jt4H0gdQ5$f|d&bnVCMMXhNh)~mN z@_UV6D7MVlsWz+zM+inZZp&P4fj=tm6fX)SG5H>OsQf_I8c~uGCig$GzuwViK54bcgL;VN|FnyQl>Ed7(@>=8$a_UKIz|V6CeVSd2(P z0Uu>A8A+muM%HLFJQ9UZ5c)BSAv_zH#1f02x?h9C}@pN@6{>UiAp>({Fn(T9Q8B z^`zB;kJ5b`>%dLm+Ol}ty!3;8f1XDSVX0AUe5P#@I+FQ-`$(a;zNgz)4x5hz$Hfbg z!Q(z26wHLXko(1`;(BAOg_wShpX0ixfWq3ponndY+u%1gyX)_h=v1zR#V}#q{au6; z!3K=7fQwnRfg6FXtNQmP>`<;!N137paFS%y?;lb1@BEdbvQHYC{976l`cLqn;b8lp zIDY>~m{gDj(wfnK!lpW6pli)HyLEiUrNc%eXTil|F2s(AY+LW5hkKb>TQ3|Q4S9rr zpDs4uK_co6XPsn_z$LeS{K4jFF`2>U`tbgKdyDne`xmR<@6AA+_hPNKCOR-Zqv;xk zu5!HsBUb^!4uJ7v0RuH-7?l?}b=w5lzzXJ~gZcxRKOovSk@|#V+MuX%Y+=;14i*%{)_gSW9(#4%)AV#3__kac1|qUy!uyP{>?U#5wYNq}y$S9pCc zFc~4mgSC*G~j0u#qqp9 z${>3HV~@->GqEhr_Xwoxq?Hjn#=s2;i~g^&Hn|aDKpA>Oc%HlW(KA1?BXqpxB;Ydx)w;2z^MpjJ(Qi(X!$5RC z*P{~%JGDQqojV>2JbEeCE*OEu!$XJ>bWA9Oa_Hd;y)F%MhBRi*LPcdqR8X`NQ&1L# z5#9L*@qxrx8n}LfeB^J{%-?SU{FCwiWyHp682F+|pa+CQa3ZLzBqN1{)h4d6+vBbV zC#NEbQLC;}me3eeYnOG*nXOJZEU$xLZ1<1Y=7r0(-U0P6-AqwMAM`a(Ed#7vJkn6plb4eI4?2y3yOTGmmDQ!z9`wzbf z_OY#0@5=bnep;MV0X_;;SJJWEf^E6Bd^tVJ9znWx&Ks8t*B>AM@?;D4oWUGc z!H*`6d7Cxo6VuyS4Eye&L1ZRhrRmN6Lr`{NL(wDbif|y&z)JN>Fl5#Wi&mMIr5i;x zBx}3YfF>>8EC(fYnmpu~)CYHuHCyr5*`ECap%t@y=jD>!_%3iiE|LN$mK9>- zHdtpy8fGZtkZF?%TW~29JIAfi2jZT8>OA7=h;8T{{k?c2`nCEx9$r zS+*&vt~2o^^J+}RDG@+9&M^K*z4p{5#IEVbz`1%`m5c2};aGt=V?~vIM}ZdPECDI)47|CWBCfDWUbxBCnmYivQ*0Nu_xb*C>~C9(VjHM zxe<*D<#dQ8TlpMX2c@M<9$w!RP$hpG4cs%AI){jp*Sj|*`m)5(Bw*A0$*i-(CA5#%>a)$+jI2C9r6|(>J8InryENI z$NohnxDUB;wAYDwrb*!N3noBTKPpPN}~09SEL18tkG zxgz(RYU_;DPT{l?Q$+eaZaxnsWCA^ds^0PVRkIM%bOd|G2IEBBiz{&^JtNsODs;5z zICt_Zj8wo^KT$7Bg4H+y!Df#3mbl%%?|EXe!&(Vmac1DJ*y~3+kRKAD=Ovde4^^%~ zw<9av18HLyrf*_>Slp;^i`Uy~`mvBjZ|?Ad63yQa#YK`4+c6;pW4?XIY9G1(Xh9WO8{F-Aju+nS9Vmv=$Ac0ienZ+p9*O%NG zMZKy5?%Z6TAJTE?o5vEr0r>f>hb#2w2U3DL64*au_@P!J!TL`oH2r*{>ffu6|A7tv zL4juf$DZ1MW5ZPsG!5)`k8d8c$J$o;%EIL0va9&GzWvkS%ZsGb#S(?{!UFOZ9<$a| zY|a+5kmD5N&{vRqkgY>aHsBT&`rg|&kezoD)gP0fsNYHsO#TRc_$n6Lf1Z{?+DLziXlHrq4sf(!>O{?Tj;Eh@%)+nRE_2VxbN&&%%caU#JDU%vL3}Cb zsb4AazPI{>8H&d=jUaZDS$-0^AxE@utGs;-Ez_F(qC9T=UZX=>ok2k2 ziTn{K?y~a5reD2A)P${NoI^>JXn>`IeArow(41c-Wm~)wiryEP(OS{YXWi7;%dG9v zI?mwu1MxD{yp_rrk!j^cKM)dc4@p4Ezyo%lRN|XyD}}>v=Xoib0gOcdXrQ^*61HNj z=NP|pd>@yfvr-=m{8$3A8TQGMTE7g=z!%yt`8`Bk-0MMwW~h^++;qyUP!J~ykh1GO z(FZ59xuFR$(WE;F@UUyE@Sp>`aVNjyj=Ty>_Vo}xf`e7`F;j-IgL5`1~-#70$9_=uBMq!2&1l zomRgpD58@)YYfvLtPW}{C5B35R;ZVvB<<#)x%srmc_S=A7F@DW8>QOEGwD6suhwCg z>Pa+YyULhmw%BA*4yjDp|2{!T98~<6Yfd(wo1mQ!KWwq0eg+6)o1>W~f~kL<-S+P@$wx*zeI|1t7z#Sxr5 zt6w+;YblPQNplq4Z#T$GLX#j6yldXAqj>4gAnnWtBICUnA&-dtnlh=t0Ho_vEKwV` z)DlJi#!@nkYV#$!)@>udAU*hF?V`2$Hf=V&6PP_|r#Iv*J$9)pF@X3`k;5})9^o4y z&)~?EjX5yX12O(BsFy-l6}nYeuKkiq`u9145&3Ssg^y{5G3Pse z9w(YVa0)N-fLaBq1`P!_#>SS(8fh_5!f{UrgZ~uEdeMJIz7DzI5!NHHqQtm~#CPij z?=N|J>nPR6_sL7!f4hD_|KH`vf8(Wpnj-(gPWH+ZvID}%?~68SwhPTC3u1_cB`otq z)U?6qo!ZLi5b>*KnYHWW=3F!p%h1;h{L&(Q&{qY6)_qxNfbP6E3yYpW!EO+IW3?@J z);4>g4gnl^8klu7uA>eGF6rIGSynacogr)KUwE_R4E5Xzi*Qir@b-jy55-JPC8c~( zo!W8y9OGZ&`xmc8;=4-U9=h{vCqfCNzYirONmGbRQlR`WWlgnY+1wCXbMz&NT~9*| z6@FrzP!LX&{no2!Ln_3|I==_4`@}V?4a;YZKTdw;vT<+K+z=uWbW(&bXEaWJ^W8Td z-3&1bY^Z*oM<=M}LVt>_j+p=2Iu7pZmbXrhQ_k)ysE9yXKygFNw$5hwDn(M>H+e1&9BM5!|81vd%r%vEm zqxY3?F@fb6O#5UunwgAHR9jp_W2zZ}NGp2%mTW@(hz7$^+a`A?mb8|_G*GNMJ) zjqegXQio=i@AINre&%ofexAr95aop5C+0MZ0m-l=MeO8m3epm7U%vZB8+I+C*iNFM z#T3l`gknX;D$-`2XT^Cg*vrv=RH+P;_dfF++cP?B_msQI4j+lt&rX2)3GaJx%W*Nn zkML%D{z5tpHH=dksQ*gzc|}gzW;lwAbxoR07VNgS*-c3d&8J|;@3t^ zVUz*J*&r7DFRuFVDCJDK8V9NN5hvpgGjwx+5n)qa;YCKe8TKtdnh{I7NU9BCN!0dq zczrBk8pE{{@vJa9ywR@mq*J=v+PG;?fwqlJVhijG!3VmIKs>9T6r7MJpC)m!Tc#>g zMtVsU>wbwFJEfwZ{vB|ZlttNe83)$iz`~#8UJ^r)lJ@HA&G#}W&ZH*;k{=TavpjWE z7hdyLZPf*X%Gm}i`Y{OGeeu^~nB8=`{r#TUrM-`;1cBvEd#d!kPqIgYySYhN-*1;L z^byj%Yi}Gx)Wnkosi337BKs}+5H5dth1JA{Ir-JKN$7zC)*}hqeoD(WfaUDPT>0`- z(6sa0AoIqASwF`>hP}^|)a_j2s^PQn*qVC{Q}htR z5-)duBFXT_V56-+UohKXlq~^6uf!6sA#ttk1o~*QEy_Y-S$gAvq47J9Vtk$5oA$Ct zYhYJ@8{hsC^98${!#Ho?4y5MCa7iGnfz}b9jE~h%EAAv~Qxu)_rAV;^cygV~5r_~?l=B`zObj7S=H=~$W zPtI_m%g$`kL_fVUk9J@>EiBH zOO&jtn~&`hIFMS5S`g8w94R4H40mdNUH4W@@XQk1sr17b{@y|JB*G9z1|CrQjd+GX z6+KyURG3;!*BQrentw{B2R&@2&`2}n(z-2&X7#r!{yg@Soy}cRD~j zj9@UBW+N|4HW4AWapy4wfUI- zZ`gSL6DUlgj*f1hSOGXG0IVH8HxK?o2|3HZ;KW{K+yPAlxtb)NV_2AwJm|E)FRs&& z=c^e7bvUsztY|+f^k7NXs$o1EUq>cR7C0$UKi6IooHWlK_#?IWDkvywnzg&ThWo^? z2O_N{5X39#?eV9l)xI(>@!vSB{DLt*oY!K1R8}_?%+0^C{d9a%N4 zoxHVT1&Lm|uDX%$QrBun5e-F`HJ^T$ zmzv)p@4ZHd_w9!%Hf9UYNvGCw2TTTbrj9pl+T9%-_-}L(tES>Or-}Z4F*{##n3~L~TuxjirGuIY#H7{%$E${?p{Q01 zi6T`n;rbK1yIB9jmQNycD~yZq&mbIsFWHo|ZAChSFPQa<(%d8mGw*V3fh|yFoxOOiWJd(qvVb!Z$b88cg->N=qO*4k~6;R==|9ihg&riu#P~s4Oap9O7f%crSr^rljeIfXDEg>wi)&v*a%7zpz<9w z*r!3q9J|390x`Zk;g$&OeN&ctp)VKRpDSV@kU2Q>jtok($Y-*x8_$2piTxun81@vt z!Vj?COa0fg2RPXMSIo26T=~0d`{oGP*eV+$!0I<(4azk&Vj3SiG=Q!6mX0p$z7I}; z9BJUFgT-K9MQQ-0@Z=^7R<{bn2Fm48endsSs`V7_@%8?Bxkqv>BDoVcj?K#dV#uUP zL1ND~?D-|VGKe3Rw_7-Idpht>H6XRLh*U7epS6byiGvJpr%d}XwfusjH9g;Z98H`x zyde%%5mhGOiL4wljCaWCk-&uE4_OOccb9c!ZaWt4B(wYl!?vyzl%7n~QepN&eFUrw zFIOl9c({``6~QD+43*_tzP{f2x41h(?b43^y6=iwyB)2os5hBE!@YUS5?N_tXd=h( z)WE286Fbd>R4M^P{!G)f;h<3Q>Fipuy+d2q-)!RyTgt;wr$(?9ox3;q+{E*ZQHhOn;lM`cjnu9 zXa48ks-v(~b*;MAI<>YZH(^NV8vjb34beE<_cwKlJoR;k6lJNSP6v}uiyRD?|0w+X@o1ONrH8a$fCxXpf? z?$DL0)7|X}Oc%h^zrMKWc-NS9I0Utu@>*j}b@tJ=ixQSJ={4@854wzW@E>VSL+Y{i z#0b=WpbCZS>kUCO_iQz)LoE>P5LIG-hv9E+oG}DtlIDF>$tJ1aw9^LuhLEHt?BCj& z(O4I8v1s#HUi5A>nIS-JK{v!7dJx)^Yg%XjNmlkWAq2*cv#tHgz`Y(bETc6CuO1VkN^L-L3j_x<4NqYb5rzrLC-7uOv z!5e`GZt%B782C5-fGnn*GhDF$%(qP<74Z}3xx+{$4cYKy2ikxI7B2N+2r07DN;|-T->nU&!=Cm#rZt%O_5c&1Z%nlWq3TKAW0w zQqemZw_ue--2uKQsx+niCUou?HjD`xhEjjQd3%rrBi82crq*~#uA4+>vR<_S{~5ce z-2EIl?~s z1=GVL{NxP1N3%=AOaC}j_Fv=ur&THz zyO!d9kHq|c73kpq`$+t+8Bw7MgeR5~`d7ChYyGCBWSteTB>8WAU(NPYt2Dk`@#+}= zI4SvLlyk#pBgVigEe`?NG*vl7V6m+<}%FwPV=~PvvA)=#ths==DRTDEYh4V5}Cf$z@#;< zyWfLY_5sP$gc3LLl2x+Ii)#b2nhNXJ{R~vk`s5U7Nyu^3yFg&D%Txwj6QezMX`V(x z=C`{76*mNb!qHHs)#GgGZ_7|vkt9izl_&PBrsu@}L`X{95-2jf99K)0=*N)VxBX2q z((vkpP2RneSIiIUEnGb?VqbMb=Zia+rF~+iqslydE34cSLJ&BJW^3knX@M;t*b=EA zNvGzv41Ld_T+WT#XjDB840vovUU^FtN_)G}7v)1lPetgpEK9YS^OWFkPoE{ovj^=@ zO9N$S=G$1ecndT_=5ehth2Lmd1II-PuT~C9`XVePw$y8J#dpZ?Tss<6wtVglm(Ok7 z3?^oi@pPio6l&!z8JY(pJvG=*pI?GIOu}e^EB6QYk$#FJQ%^AIK$I4epJ+9t?KjqA+bkj&PQ*|vLttme+`9G=L% ziadyMw_7-M)hS(3E$QGNCu|o23|%O+VN7;Qggp?PB3K-iSeBa2b}V4_wY`G1Jsfz4 z9|SdB^;|I8E8gWqHKx!vj_@SMY^hLEIbSMCuE?WKq=c2mJK z8LoG-pnY!uhqFv&L?yEuxo{dpMTsmCn)95xanqBrNPTgXP((H$9N${Ow~Is-FBg%h z53;|Y5$MUN)9W2HBe2TD`ct^LHI<(xWrw}$qSoei?}s)&w$;&!14w6B6>Yr6Y8b)S z0r71`WmAvJJ`1h&poLftLUS6Ir zC$bG9!Im_4Zjse)#K=oJM9mHW1{%l8sz$1o?ltdKlLTxWWPB>Vk22czVt|1%^wnN@*!l)}?EgtvhC>vlHm^t+ogpgHI1_$1ox9e;>0!+b(tBrmXRB`PY1vp-R**8N7 zGP|QqI$m(Rdu#=(?!(N}G9QhQ%o!aXE=aN{&wtGP8|_qh+7a_j_sU5|J^)vxq;# zjvzLn%_QPHZZIWu1&mRAj;Sa_97p_lLq_{~j!M9N^1yp3U_SxRqK&JnR%6VI#^E12 z>CdOVI^_9aPK2eZ4h&^{pQs}xsijXgFYRIxJ~N7&BB9jUR1fm!(xl)mvy|3e6-B3j zJn#ajL;bFTYJ2+Q)tDjx=3IklO@Q+FFM}6UJr6km7hj7th9n_&JR7fnqC!hTZoM~T zBeaVFp%)0cbPhejX<8pf5HyRUj2>aXnXBqDJe73~J%P(2C?-RT{c3NjE`)om! zl$uewSgWkE66$Kb34+QZZvRn`fob~Cl9=cRk@Es}KQm=?E~CE%spXaMO6YmrMl%9Q zlA3Q$3|L1QJ4?->UjT&CBd!~ru{Ih^in&JXO=|<6J!&qp zRe*OZ*cj5bHYlz!!~iEKcuE|;U4vN1rk$xq6>bUWD*u(V@8sG^7>kVuo(QL@Ki;yL zWC!FT(q{E8#on>%1iAS0HMZDJg{Z{^!De(vSIq&;1$+b)oRMwA3nc3mdTSG#3uYO_ z>+x;7p4I;uHz?ZB>dA-BKl+t-3IB!jBRgdvAbW!aJ(Q{aT>+iz?91`C-xbe)IBoND z9_Xth{6?(y3rddwY$GD65IT#f3<(0o#`di{sh2gm{dw*#-Vnc3r=4==&PU^hCv$qd zjw;>i&?L*Wq#TxG$mFIUf>eK+170KG;~+o&1;Tom9}}mKo23KwdEM6UonXgc z!6N(@k8q@HPw{O8O!lAyi{rZv|DpgfU{py+j(X_cwpKqcalcqKIr0kM^%Br3SdeD> zHSKV94Yxw;pjzDHo!Q?8^0bb%L|wC;4U^9I#pd5O&eexX+Im{ z?jKnCcsE|H?{uGMqVie_C~w7GX)kYGWAg%-?8|N_1#W-|4F)3YTDC+QSq1s!DnOML3@d`mG%o2YbYd#jww|jD$gotpa)kntakp#K;+yo-_ZF9qrNZw<%#C zuPE@#3RocLgPyiBZ+R_-FJ_$xP!RzWm|aN)S+{$LY9vvN+IW~Kf3TsEIvP+B9Mtm! zpfNNxObWQpLoaO&cJh5>%slZnHl_Q~(-Tfh!DMz(dTWld@LG1VRF`9`DYKhyNv z2pU|UZ$#_yUx_B_|MxUq^glT}O5Xt(Vm4Mr02><%C)@v;vPb@pT$*yzJ4aPc_FZ3z z3}PLoMBIM>q_9U2rl^sGhk1VUJ89=*?7|v`{!Z{6bqFMq(mYiA?%KbsI~JwuqVA9$H5vDE+VocjX+G^%bieqx->s;XWlKcuv(s%y%D5Xbc9+ zc(_2nYS1&^yL*ey664&4`IoOeDIig}y-E~_GS?m;D!xv5-xwz+G`5l6V+}CpeJDi^ z%4ed$qowm88=iYG+(`ld5Uh&>Dgs4uPHSJ^TngXP_V6fPyl~>2bhi20QB%lSd#yYn zO05?KT1z@?^-bqO8Cg`;ft>ilejsw@2%RR7;`$Vs;FmO(Yr3Fp`pHGr@P2hC%QcA|X&N2Dn zYf`MqXdHi%cGR@%y7Rg7?d3?an){s$zA{!H;Ie5exE#c~@NhQUFG8V=SQh%UxUeiV zd7#UcYqD=lk-}sEwlpu&H^T_V0{#G?lZMxL7ih_&{(g)MWBnCZxtXg znr#}>U^6!jA%e}@Gj49LWG@*&t0V>Cxc3?oO7LSG%~)Y5}f7vqUUnQ;STjdDU}P9IF9d9<$;=QaXc zL1^X7>fa^jHBu_}9}J~#-oz3Oq^JmGR#?GO7b9a(=R@fw@}Q{{@`Wy1vIQ#Bw?>@X z-_RGG@wt|%u`XUc%W{J z>iSeiz8C3H7@St3mOr_mU+&bL#Uif;+Xw-aZdNYUpdf>Rvu0i0t6k*}vwU`XNO2he z%miH|1tQ8~ZK!zmL&wa3E;l?!!XzgV#%PMVU!0xrDsNNZUWKlbiOjzH-1Uoxm8E#r`#2Sz;-o&qcqB zC-O_R{QGuynW14@)7&@yw1U}uP(1cov)twxeLus0s|7ayrtT8c#`&2~Fiu2=R;1_4bCaD=*E@cYI>7YSnt)nQc zohw5CsK%m?8Ack)qNx`W0_v$5S}nO|(V|RZKBD+btO?JXe|~^Qqur%@eO~<8-L^9d z=GA3-V14ng9L29~XJ>a5k~xT2152zLhM*@zlp2P5Eu}bywkcqR;ISbas&#T#;HZSf z2m69qTV(V@EkY(1Dk3`}j)JMo%ZVJ*5eB zYOjIisi+igK0#yW*gBGj?@I{~mUOvRFQR^pJbEbzFxTubnrw(Muk%}jI+vXmJ;{Q6 zrSobKD>T%}jV4Ub?L1+MGOD~0Ir%-`iTnWZN^~YPrcP5y3VMAzQ+&en^VzKEb$K!Q z<7Dbg&DNXuow*eD5yMr+#08nF!;%4vGrJI++5HdCFcGLfMW!KS*Oi@=7hFwDG!h2< zPunUEAF+HncQkbfFj&pbzp|MU*~60Z(|Ik%Tn{BXMN!hZOosNIseT?R;A`W?=d?5X zK(FB=9mZusYahp|K-wyb={rOpdn=@;4YI2W0EcbMKyo~-#^?h`BA9~o285%oY zfifCh5Lk$SY@|2A@a!T2V+{^!psQkx4?x0HSV`(w9{l75QxMk!)U52Lbhn{8ol?S) zCKo*7R(z!uk<6*qO=wh!Pul{(qq6g6xW;X68GI_CXp`XwO zxuSgPRAtM8K7}5E#-GM!*ydOOG_{A{)hkCII<|2=ma*71ci_-}VPARm3crFQjLYV! z9zbz82$|l01mv`$WahE2$=fAGWkd^X2kY(J7iz}WGS z@%MyBEO=A?HB9=^?nX`@nh;7;laAjs+fbo!|K^mE!tOB>$2a_O0y-*uaIn8k^6Y zSbuv;5~##*4Y~+y7Z5O*3w4qgI5V^17u*ZeupVGH^nM&$qmAk|anf*>r zWc5CV;-JY-Z@Uq1Irpb^O`L_7AGiqd*YpGUShb==os$uN3yYvb`wm6d=?T*it&pDk zo`vhw)RZX|91^^Wa_ti2zBFyWy4cJu#g)_S6~jT}CC{DJ_kKpT`$oAL%b^!2M;JgT zM3ZNbUB?}kP(*YYvXDIH8^7LUxz5oE%kMhF!rnPqv!GiY0o}NR$OD=ITDo9r%4E>E0Y^R(rS^~XjWyVI6 zMOR5rPXhTp*G*M&X#NTL`Hu*R+u*QNoiOKg4CtNPrjgH>c?Hi4MUG#I917fx**+pJfOo!zFM&*da&G_x)L(`k&TPI*t3e^{crd zX<4I$5nBQ8Ax_lmNRa~E*zS-R0sxkz`|>7q_?*e%7bxqNm3_eRG#1ae3gtV9!fQpY z+!^a38o4ZGy9!J5sylDxZTx$JmG!wg7;>&5H1)>f4dXj;B+@6tMlL=)cLl={jLMxY zbbf1ax3S4>bwB9-$;SN2?+GULu;UA-35;VY*^9Blx)Jwyb$=U!D>HhB&=jSsd^6yw zL)?a|>GxU!W}ocTC(?-%z3!IUhw^uzc`Vz_g>-tv)(XA#JK^)ZnC|l1`@CdX1@|!| z_9gQ)7uOf?cR@KDp97*>6X|;t@Y`k_N@)aH7gY27)COv^P3ya9I{4z~vUjLR9~z1Z z5=G{mVtKH*&$*t0@}-i_v|3B$AHHYale7>E+jP`ClqG%L{u;*ff_h@)al?RuL7tOO z->;I}>%WI{;vbLP3VIQ^iA$4wl6@0sDj|~112Y4OFjMs`13!$JGkp%b&E8QzJw_L5 zOnw9joc0^;O%OpF$Qp)W1HI!$4BaXX84`%@#^dk^hFp^pQ@rx4g(8Xjy#!X%+X5Jd@fs3amGT`}mhq#L97R>OwT5-m|h#yT_-v@(k$q7P*9X~T*3)LTdzP!*B} z+SldbVWrrwQo9wX*%FyK+sRXTa@O?WM^FGWOE?S`R(0P{<6p#f?0NJvnBia?k^fX2 zNQs7K-?EijgHJY}&zsr;qJ<*PCZUd*x|dD=IQPUK_nn)@X4KWtqoJNHkT?ZWL_hF? zS8lp2(q>;RXR|F;1O}EE#}gCrY~#n^O`_I&?&z5~7N;zL0)3Tup`%)oHMK-^r$NT% zbFg|o?b9w(q@)6w5V%si<$!U<#}s#x@0aX-hP>zwS#9*75VXA4K*%gUc>+yzupTDBOKH8WR4V0pM(HrfbQ&eJ79>HdCvE=F z|J>s;;iDLB^3(9}?biKbxf1$lI!*Z%*0&8UUq}wMyPs_hclyQQi4;NUY+x2qy|0J; zhn8;5)4ED1oHwg+VZF|80<4MrL97tGGXc5Sw$wAI#|2*cvQ=jB5+{AjMiDHmhUC*a zlmiZ`LAuAn_}hftXh;`Kq0zblDk8?O-`tnilIh|;3lZp@F_osJUV9`*R29M?7H{Fy z`nfVEIDIWXmU&YW;NjU8)EJpXhxe5t+scf|VXM!^bBlwNh)~7|3?fWwo_~ZFk(22% zTMesYw+LNx3J-_|DM~`v93yXe=jPD{q;li;5PD?Dyk+b? zo21|XpT@)$BM$%F=P9J19Vi&1#{jM3!^Y&fr&_`toi`XB1!n>sbL%U9I5<7!@?t)~ z;&H%z>bAaQ4f$wIzkjH70;<8tpUoxzKrPhn#IQfS%9l5=Iu))^XC<58D!-O z{B+o5R^Z21H0T9JQ5gNJnqh#qH^na|z92=hONIM~@_iuOi|F>jBh-?aA20}Qx~EpDGElELNn~|7WRXRFnw+Wdo`|# zBpU=Cz3z%cUJ0mx_1($X<40XEIYz(`noWeO+x#yb_pwj6)R(__%@_Cf>txOQ74wSJ z0#F3(zWWaR-jMEY$7C*3HJrohc79>MCUu26mfYN)f4M~4gD`}EX4e}A!U}QV8!S47 z6y-U-%+h`1n`*pQuKE%Av0@)+wBZr9mH}@vH@i{v(m-6QK7Ncf17x_D=)32`FOjjo zg|^VPf5c6-!FxN{25dvVh#fog=NNpXz zfB$o+0jbRkHH{!TKhE709f+jI^$3#v1Nmf80w`@7-5$1Iv_`)W^px8P-({xwb;D0y z7LKDAHgX<84?l!I*Dvi2#D@oAE^J|g$3!)x1Ua;_;<@#l1fD}lqU2_tS^6Ht$1Wl} zBESo7o^)9-Tjuz$8YQSGhfs{BQV6zW7dA?0b(Dbt=UnQs&4zHfe_sj{RJ4uS-vQpC zX;Bbsuju4%!o8?&m4UZU@~ZZjeFF6ex2ss5_60_JS_|iNc+R0GIjH1@Z z=rLT9%B|WWgOrR7IiIwr2=T;Ne?30M!@{%Qf8o`!>=s<2CBpCK_TWc(DX51>e^xh8 z&@$^b6CgOd7KXQV&Y4%}_#uN*mbanXq(2=Nj`L7H7*k(6F8s6{FOw@(DzU`4-*77{ zF+dxpv}%mFpYK?>N_2*#Y?oB*qEKB}VoQ@bzm>ptmVS_EC(#}Lxxx730trt0G)#$b zE=wVvtqOct1%*9}U{q<)2?{+0TzZzP0jgf9*)arV)*e!f`|jgT{7_9iS@e)recI#z zbzolURQ+TOzE!ymqvBY7+5NnAbWxvMLsLTwEbFqW=CPyCsmJ}P1^V30|D5E|p3BC5 z)3|qgw@ra7aXb-wsa|l^in~1_fm{7bS9jhVRkYVO#U{qMp z)Wce+|DJ}4<2gp8r0_xfZpMo#{Hl2MfjLcZdRB9(B(A(f;+4s*FxV{1F|4d`*sRNd zp4#@sEY|?^FIJ;tmH{@keZ$P(sLh5IdOk@k^0uB^BWr@pk6mHy$qf&~rI>P*a;h0C{%oA*i!VjWn&D~O#MxN&f@1Po# zKN+ zrGrkSjcr?^R#nGl<#Q722^wbYcgW@{+6CBS<1@%dPA8HC!~a`jTz<`g_l5N1M@9wn9GOAZ>nqNgq!yOCbZ@1z`U_N`Z>}+1HIZxk*5RDc&rd5{3qjRh8QmT$VyS;jK z;AF+r6XnnCp=wQYoG|rT2@8&IvKq*IB_WvS%nt%e{MCFm`&W*#LXc|HrD?nVBo=(8*=Aq?u$sDA_sC_RPDUiQ+wnIJET8vx$&fxkW~kP9qXKt zozR)@xGC!P)CTkjeWvXW5&@2?)qt)jiYWWBU?AUtzAN}{JE1I)dfz~7$;}~BmQF`k zpn11qmObXwRB8&rnEG*#4Xax3XBkKlw(;tb?Np^i+H8m(Wyz9k{~ogba@laiEk;2! zV*QV^6g6(QG%vX5Um#^sT&_e`B1pBW5yVth~xUs#0}nv?~C#l?W+9Lsb_5)!71rirGvY zTIJ$OPOY516Y|_014sNv+Z8cc5t_V=i>lWV=vNu#!58y9Zl&GsMEW#pPYPYGHQ|;vFvd*9eM==$_=vc7xnyz0~ zY}r??$<`wAO?JQk@?RGvkWVJlq2dk9vB(yV^vm{=NVI8dhsX<)O(#nr9YD?I?(VmQ z^r7VfUBn<~p3()8yOBjm$#KWx!5hRW)5Jl7wY@ky9lNM^jaT##8QGVsYeaVywmpv>X|Xj7gWE1Ezai&wVLt3p)k4w~yrskT-!PR!kiyQlaxl(( zXhF%Q9x}1TMt3~u@|#wWm-Vq?ZerK={8@~&@9r5JW}r#45#rWii};t`{5#&3$W)|@ zbAf2yDNe0q}NEUvq_Quq3cTjcw z@H_;$hu&xllCI9CFDLuScEMg|x{S7GdV8<&Mq=ezDnRZAyX-8gv97YTm0bg=d)(>N z+B2FcqvI9>jGtnK%eO%y zoBPkJTk%y`8TLf4)IXPBn`U|9>O~WL2C~C$z~9|0m*YH<-vg2CD^SX#&)B4ngOSG$ zV^wmy_iQk>dfN@Pv(ckfy&#ak@MLC7&Q6Ro#!ezM*VEh`+b3Jt%m(^T&p&WJ2Oqvj zs-4nq0TW6cv~(YI$n0UkfwN}kg3_fp?(ijSV#tR9L0}l2qjc7W?i*q01=St0eZ=4h zyGQbEw`9OEH>NMuIe)hVwYHsGERWOD;JxEiO7cQv%pFCeR+IyhwQ|y@&^24k+|8fD zLiOWFNJ2&vu2&`Jv96_z-Cd5RLgmeY3*4rDOQo?Jm`;I_(+ejsPM03!ly!*Cu}Cco zrQSrEDHNyzT(D5s1rZq!8#?f6@v6dB7a-aWs(Qk>N?UGAo{gytlh$%_IhyL7h?DLXDGx zgxGEBQoCAWo-$LRvM=F5MTle`M})t3vVv;2j0HZY&G z22^iGhV@uaJh(XyyY%} zd4iH_UfdV#T=3n}(Lj^|n;O4|$;xhu*8T3hR1mc_A}fK}jfZ7LX~*n5+`8N2q#rI$ z@<_2VANlYF$vIH$ zl<)+*tIWW78IIINA7Rr7i{<;#^yzxoLNkXL)eSs=%|P>$YQIh+ea_3k z_s7r4%j7%&*NHSl?R4k%1>Z=M9o#zxY!n8sL5>BO-ZP;T3Gut>iLS@U%IBrX6BA3k z)&@q}V8a{X<5B}K5s(c(LQ=%v1ocr`t$EqqY0EqVjr65usa=0bkf|O#ky{j3)WBR(((L^wmyHRzoWuL2~WTC=`yZ zn%VX`L=|Ok0v7?s>IHg?yArBcync5rG#^+u)>a%qjES%dRZoIyA8gQ;StH z1Ao7{<&}6U=5}4v<)1T7t!J_CL%U}CKNs-0xWoTTeqj{5{?Be$L0_tk>M9o8 zo371}S#30rKZFM{`H_(L`EM9DGp+Mifk&IP|C2Zu_)Ghr4Qtpmkm1osCf@%Z$%t+7 zYH$Cr)Ro@3-QDeQJ8m+x6%;?YYT;k6Z0E-?kr>x33`H%*ueBD7Zx~3&HtWn0?2Wt} zTG}*|v?{$ajzt}xPzV%lL1t-URi8*Zn)YljXNGDb>;!905Td|mpa@mHjIH%VIiGx- zd@MqhpYFu4_?y5N4xiHn3vX&|e6r~Xt> zZG`aGq|yTNjv;9E+Txuoa@A(9V7g?1_T5FzRI;!=NP1Kqou1z5?%X~Wwb{trRfd>i z8&y^H)8YnKyA_Fyx>}RNmQIczT?w2J4SNvI{5J&}Wto|8FR(W;Qw#b1G<1%#tmYzQ zQ2mZA-PAdi%RQOhkHy9Ea#TPSw?WxwL@H@cbkZwIq0B!@ns}niALidmn&W?!Vd4Gj zO7FiuV4*6Mr^2xlFSvM;Cp_#r8UaqIzHJQg_z^rEJw&OMm_8NGAY2)rKvki|o1bH~ z$2IbfVeY2L(^*rMRU1lM5Y_sgrDS`Z??nR2lX;zyR=c%UyGb*%TC-Dil?SihkjrQy~TMv6;BMs7P8il`H7DmpVm@rJ;b)hW)BL)GjS154b*xq-NXq2cwE z^;VP7ua2pxvCmxrnqUYQMH%a%nHmwmI33nJM(>4LznvY*k&C0{8f*%?zggpDgkuz&JBx{9mfb@wegEl2v!=}Sq2Gaty0<)UrOT0{MZtZ~j5y&w zXlYa_jY)I_+VA-^#mEox#+G>UgvM!Ac8zI<%JRXM_73Q!#i3O|)lOP*qBeJG#BST0 zqohi)O!|$|2SeJQo(w6w7%*92S})XfnhrH_Z8qe!G5>CglP=nI7JAOW?(Z29;pXJ9 zR9`KzQ=WEhy*)WH>$;7Cdz|>*i>=##0bB)oU0OR>>N<21e4rMCHDemNi2LD>Nc$;& zQRFthpWniC1J6@Zh~iJCoLOxN`oCKD5Q4r%ynwgUKPlIEd#?QViIqovY|czyK8>6B zSP%{2-<;%;1`#0mG^B(8KbtXF;Nf>K#Di72UWE4gQ%(_26Koiad)q$xRL~?pN71ZZ zujaaCx~jXjygw;rI!WB=xrOJO6HJ!!w}7eiivtCg5K|F6$EXa)=xUC za^JXSX98W`7g-tm@uo|BKj39Dl;sg5ta;4qjo^pCh~{-HdLl6qI9Ix6f$+qiZ$}s= zNguKrU;u+T@ko(Vr1>)Q%h$?UKXCY>3se%&;h2osl2D zE4A9bd7_|^njDd)6cI*FupHpE3){4NQ*$k*cOWZ_?CZ>Z4_fl@n(mMnYK62Q1d@+I zr&O))G4hMihgBqRIAJkLdk(p(D~X{-oBUA+If@B}j& zsHbeJ3RzTq96lB7d($h$xTeZ^gP0c{t!Y0c)aQE;$FY2!mACg!GDEMKXFOPI^)nHZ z`aSPJpvV0|bbrzhWWkuPURlDeN%VT8tndV8?d)eN*i4I@u zVKl^6{?}A?P)Fsy?3oi#clf}L18t;TjNI2>eI&(ezDK7RyqFxcv%>?oxUlonv(px) z$vnPzRH`y5A(x!yOIfL0bmgeMQB$H5wenx~!ujQK*nUBW;@Em&6Xv2%s(~H5WcU2R z;%Nw<$tI)a`Ve!>x+qegJnQsN2N7HaKzrFqM>`6R*gvh%O*-%THt zrB$Nk;lE;z{s{r^PPm5qz(&lM{sO*g+W{sK+m3M_z=4=&CC>T`{X}1Vg2PEfSj2x_ zmT*(x;ov%3F?qoEeeM>dUn$a*?SIGyO8m806J1W1o+4HRhc2`9$s6hM#qAm zChQ87b~GEw{ADfs+5}FJ8+|bIlIv(jT$Ap#hSHoXdd9#w<#cA<1Rkq^*EEkknUd4& zoIWIY)sAswy6fSERVm&!SO~#iN$OgOX*{9@_BWFyJTvC%S++ilSfCrO(?u=Dc?CXZ zzCG&0yVR{Z`|ZF0eEApWEo#s9osV>F{uK{QA@BES#&;#KsScf>y zvs?vIbI>VrT<*!;XmQS=bhq%46-aambZ(8KU-wOO2=en~D}MCToB_u;Yz{)1ySrPZ z@=$}EvjTdzTWU7c0ZI6L8=yP+YRD_eMMos}b5vY^S*~VZysrkq<`cK3>>v%uy7jgq z0ilW9KjVDHLv0b<1K_`1IkbTOINs0=m-22c%M~l=^S}%hbli-3?BnNq?b`hx^HX2J zIe6ECljRL0uBWb`%{EA=%!i^4sMcj+U_TaTZRb+~GOk z^ZW!nky0n*Wb*r+Q|9H@ml@Z5gU&W`(z4-j!OzC1wOke`TRAYGZVl$PmQ16{3196( zO*?`--I}Qf(2HIwb2&1FB^!faPA2=sLg(@6P4mN)>Dc3i(B0;@O-y2;lM4akD>@^v z=u>*|!s&9zem70g7zfw9FXl1bpJW(C#5w#uy5!V?Q(U35A~$dR%LDVnq@}kQm13{} zd53q3N(s$Eu{R}k2esbftfjfOITCL;jWa$}(mmm}d(&7JZ6d3%IABCapFFYjdEjdK z&4Edqf$G^MNAtL=uCDRs&Fu@FXRgX{*0<(@c3|PNHa>L%zvxWS={L8%qw`STm+=Rd zA}FLspESSIpE_^41~#5yI2bJ=9`oc;GIL!JuW&7YetZ?0H}$$%8rW@*J37L-~Rsx!)8($nI4 zZhcZ2^=Y+p4YPl%j!nFJA|*M^gc(0o$i3nlphe+~-_m}jVkRN{spFs(o0ajW@f3K{ zDV!#BwL322CET$}Y}^0ixYj2w>&Xh12|R8&yEw|wLDvF!lZ#dOTHM9pK6@Nm-@9Lnng4ZHBgBSrr7KI8YCC9DX5Kg|`HsiwJHg2(7#nS;A{b3tVO?Z% za{m5b3rFV6EpX;=;n#wltDv1LE*|g5pQ+OY&*6qCJZc5oDS6Z6JD#6F)bWxZSF@q% z+1WV;m!lRB!n^PC>RgQCI#D1br_o^#iPk>;K2hB~0^<~)?p}LG%kigm@moD#q3PE+ zA^Qca)(xnqw6x>XFhV6ku9r$E>bWNrVH9fum0?4s?Rn2LG{Vm_+QJHse6xa%nzQ?k zKug4PW~#Gtb;#5+9!QBgyB@q=sk9=$S{4T>wjFICStOM?__fr+Kei1 z3j~xPqW;W@YkiUM;HngG!;>@AITg}vAE`M2Pj9Irl4w1fo4w<|Bu!%rh%a(Ai^Zhi zs92>v5;@Y(Zi#RI*ua*h`d_7;byQSa*v9E{2x$<-_=5Z<7{%)}4XExANcz@rK69T0x3%H<@frW>RA8^swA+^a(FxK| zFl3LD*ImHN=XDUkrRhp6RY5$rQ{bRgSO*(vEHYV)3Mo6Jy3puiLmU&g82p{qr0F?ohmbz)f2r{X2|T2 z$4fdQ=>0BeKbiVM!e-lIIs8wVTuC_m7}y4A_%ikI;Wm5$9j(^Y z(cD%U%k)X>_>9~t8;pGzL6L-fmQO@K; zo&vQzMlgY95;1BSkngY)e{`n0!NfVgf}2mB3t}D9@*N;FQ{HZ3Pb%BK6;5#-O|WI( zb6h@qTLU~AbVW#_6?c!?Dj65Now7*pU{h!1+eCV^KCuPAGs28~3k@ueL5+u|Z-7}t z9|lskE`4B7W8wMs@xJa{#bsCGDFoRSNSnmNYB&U7 zVGKWe%+kFB6kb)e;TyHfqtU6~fRg)f|>=5(N36)0+C z`hv65J<$B}WUc!wFAb^QtY31yNleq4dzmG`1wHTj=c*=hay9iD071Hc?oYoUk|M*_ zU1GihAMBsM@5rUJ(qS?9ZYJ6@{bNqJ`2Mr+5#hKf?doa?F|+^IR!8lq9)wS3tF_9n zW_?hm)G(M+MYb?V9YoX^_mu5h-LP^TL^!Q9Z7|@sO(rg_4+@=PdI)WL(B7`!K^ND- z-uIuVDCVEdH_C@c71YGYT^_Scf_dhB8Z2Xy6vGtBSlYud9vggOqv^L~F{BraSE_t} zIkP+Hp2&nH^-MNEs}^`oMLy11`PQW$T|K(`Bu*(f@)mv1-qY(_YG&J2M2<7k;;RK~ zL{Fqj9yCz8(S{}@c)S!65aF<=&eLI{hAMErCx&>i7OeDN>okvegO87OaG{Jmi<|}D zaT@b|0X{d@OIJ7zvT>r+eTzgLq~|Dpu)Z&db-P4z*`M$UL51lf>FLlq6rfG)%doyp z)3kk_YIM!03eQ8Vu_2fg{+osaEJPtJ-s36R+5_AEG12`NG)IQ#TF9c@$99%0iye+ zUzZ57=m2)$D(5Nx!n)=5Au&O0BBgwxIBaeI(mro$#&UGCr<;C{UjJVAbVi%|+WP(a zL$U@TYCxJ=1{Z~}rnW;7UVb7+ZnzgmrogDxhjLGo>c~MiJAWs&&;AGg@%U?Y^0JhL ze(x6Z74JG6FlOFK(T}SXQfhr}RIFl@QXKnIcXYF)5|V~e-}suHILKT-k|<*~Ij|VF zC;t@=uj=hot~*!C68G8hTA%8SzOfETOXQ|3FSaIEjvBJp(A)7SWUi5!Eu#yWgY+;n zlm<$+UDou*V+246_o#V4kMdto8hF%%Lki#zPh}KYXmMf?hrN0;>Mv%`@{0Qn`Ujp) z=lZe+13>^Q!9zT);H<(#bIeRWz%#*}sgUX9P|9($kexOyKIOc`dLux}c$7It4u|Rl z6SSkY*V~g_B-hMPo_ak>>z@AVQ(_N)VY2kB3IZ0G(iDUYw+2d7W^~(Jq}KY=JnWS( z#rzEa&0uNhJ>QE8iiyz;n2H|SV#Og+wEZv=f2%1ELX!SX-(d3tEj$5$1}70Mp<&eI zCkfbByL7af=qQE@5vDVxx1}FSGt_a1DoE3SDI+G)mBAna)KBG4p8Epxl9QZ4BfdAN zFnF|Y(umr;gRgG6NLQ$?ZWgllEeeq~z^ZS7L?<(~O&$5|y)Al^iMKy}&W+eMm1W z7EMU)u^ke(A1#XCV>CZ71}P}0x)4wtHO8#JRG3MA-6g=`ZM!FcICCZ{IEw8Dm2&LQ z1|r)BUG^0GzI6f946RrBlfB1Vs)~8toZf~7)+G;pv&XiUO(%5bm)pl=p>nV^o*;&T z;}@oZSibzto$arQgfkp|z4Z($P>dTXE{4O=vY0!)kDO* zGF8a4wq#VaFpLfK!iELy@?-SeRrdz%F*}hjKcA*y@mj~VD3!it9lhRhX}5YOaR9$} z3mS%$2Be7{l(+MVx3 z(4?h;P!jnRmX9J9sYN#7i=iyj_5q7n#X(!cdqI2lnr8T$IfOW<_v`eB!d9xY1P=2q&WtOXY=D9QYteP)De?S4}FK6#6Ma z=E*V+#s8>L;8aVroK^6iKo=MH{4yEZ_>N-N z`(|;aOATba1^asjxlILk<4}f~`39dBFlxj>Dw(hMYKPO3EEt1@S`1lxFNM+J@uB7T zZ8WKjz7HF1-5&2=l=fqF-*@>n5J}jIxdDwpT?oKM3s8Nr`x8JnN-kCE?~aM1H!hAE z%%w(3kHfGwMnMmNj(SU(w42OrC-euI>Dsjk&jz3ts}WHqmMpzQ3vZrsXrZ|}+MHA7 z068obeXZTsO*6RS@o3x80E4ok``rV^Y3hr&C1;|ZZ0|*EKO`$lECUYG2gVFtUTw)R z4Um<0ZzlON`zTdvVdL#KFoMFQX*a5wM0Czp%wTtfK4Sjs)P**RW&?lP$(<}q%r68Z zS53Y!d@&~ne9O)A^tNrXHhXBkj~$8j%pT1%%mypa9AW5E&s9)rjF4@O3ytH{0z6riz|@< zB~UPh*wRFg2^7EbQrHf0y?E~dHlkOxof_a?M{LqQ^C!i2dawHTPYUE=X@2(3<=OOxs8qn_(y>pU>u^}3y&df{JarR0@VJn0f+U%UiF=$Wyq zQvnVHESil@d|8&R<%}uidGh7@u^(%?$#|&J$pvFC-n8&A>utA=n3#)yMkz+qnG3wd zP7xCnF|$9Dif@N~L)Vde3hW8W!UY0BgT2v(wzp;tlLmyk2%N|0jfG$%<;A&IVrOI< z!L)o>j>;dFaqA3pL}b-Je(bB@VJ4%!JeX@3x!i{yIeIso^=n?fDX`3bU=eG7sTc%g%ye8$v8P@yKE^XD=NYxTb zbf!Mk=h|otpqjFaA-vs5YOF-*GwWPc7VbaOW&stlANnCN8iftFMMrUdYNJ_Bnn5Vt zxfz@Ah|+4&P;reZxp;MmEI7C|FOv8NKUm8njF7Wb6Gi7DeODLl&G~}G4be&*Hi0Qw z5}77vL0P+7-B%UL@3n1&JPxW^d@vVwp?u#gVcJqY9#@-3X{ok#UfW3<1fb%FT`|)V~ggq z(3AUoUS-;7)^hCjdT0Kf{i}h)mBg4qhtHHBti=~h^n^OTH5U*XMgDLIR@sre`AaB$ zg)IGBET_4??m@cx&c~bA80O7B8CHR7(LX7%HThkeC*@vi{-pL%e)yXp!B2InafbDF zjPXf1mko3h59{lT6EEbxKO1Z5GF71)WwowO6kY|6tjSVSWdQ}NsK2x{>i|MKZK8%Q zfu&_0D;CO-Jg0#YmyfctyJ!mRJp)e#@O0mYdp|8x;G1%OZQ3Q847YWTyy|%^cpA;m zze0(5p{tMu^lDkpe?HynyO?a1$_LJl2L&mpeKu%8YvgRNr=%2z${%WThHG=vrWY@4 zsA`OP#O&)TetZ>s%h!=+CE15lOOls&nvC~$Qz0Ph7tHiP;O$i|eDwpT{cp>+)0-|; zY$|bB+Gbel>5aRN3>c0x)4U=|X+z+{ zn*_p*EQoquRL+=+p;=lm`d71&1NqBz&_ph)MXu(Nv6&XE7(RsS)^MGj5Q?Fwude-(sq zjJ>aOq!7!EN>@(fK7EE#;i_BGvli`5U;r!YA{JRodLBc6-`n8K+Fjgwb%sX;j=qHQ z7&Tr!)!{HXoO<2BQrV9Sw?JRaLXV8HrsNevvnf>Y-6|{T!pYLl7jp$-nEE z#X!4G4L#K0qG_4Z;Cj6=;b|Be$hi4JvMH!-voxqx^@8cXp`B??eFBz2lLD8RRaRGh zn7kUfy!YV~p(R|p7iC1Rdgt$_24i0cd-S8HpG|`@my70g^y`gu%#Tf_L21-k?sRRZHK&at(*ED0P8iw{7?R$9~OF$Ko;Iu5)ur5<->x!m93Eb zFYpIx60s=Wxxw=`$aS-O&dCO_9?b1yKiPCQmSQb>T)963`*U+Ydj5kI(B(B?HNP8r z*bfSBpSu)w(Z3j7HQoRjUG(+d=IaE~tv}y14zHHs|0UcN52fT8V_<@2ep_ee{QgZG zmgp8iv4V{k;~8@I%M3<#B;2R>Ef(Gg_cQM7%}0s*^)SK6!Ym+~P^58*wnwV1BW@eG z4sZLqsUvBbFsr#8u7S1r4teQ;t)Y@jnn_m5jS$CsW1um!p&PqAcc8!zyiXHVta9QC zY~wCwCF0U%xiQPD_INKtTb;A|Zf29(mu9NI;E zc-e>*1%(LSXB`g}kd`#}O;veb<(sk~RWL|f3ljxCnEZDdNSTDV6#Td({6l&y4IjKF z^}lIUq*ZUqgTPumD)RrCN{M^jhY>E~1pn|KOZ5((%F)G|*ZQ|r4zIbrEiV%42hJV8 z3xS)=!X1+=olbdGJ=yZil?oXLct8FM{(6ikLL3E%=q#O6(H$p~gQu6T8N!plf!96| z&Q3=`L~>U0zZh;z(pGR2^S^{#PrPxTRHD1RQOON&f)Siaf`GLj#UOk&(|@0?zm;Sx ztsGt8=29-MZs5CSf1l1jNFtNt5rFNZxJPvkNu~2}7*9468TWm>nN9TP&^!;J{-h)_ z7WsHH9|F%I`Pb!>KAS3jQWKfGivTVkMJLO-HUGM_a4UQ_%RgL6WZvrW+Z4ujZn;y@ zz9$=oO!7qVTaQAA^BhX&ZxS*|5dj803M=k&2%QrXda`-Q#IoZL6E(g+tN!6CA!CP* zCpWtCujIea)ENl0liwVfj)Nc<9mV%+e@=d`haoZ*`B7+PNjEbXBkv=B+Pi^~L#EO$D$ZqTiD8f<5$eyb54-(=3 zh)6i8i|jp(@OnRrY5B8t|LFXFQVQ895n*P16cEKTrT*~yLH6Z4e*bZ5otpRDri&+A zfNbK1D5@O=sm`fN=WzWyse!za5n%^+6dHPGX#8DyIK>?9qyX}2XvBWVqbP%%D)7$= z=#$WulZlZR<{m#gU7lwqK4WS1Ne$#_P{b17qe$~UOXCl>5b|6WVh;5vVnR<%d+Lnp z$uEmML38}U4vaW8>shm6CzB(Wei3s#NAWE3)a2)z@i{4jTn;;aQS)O@l{rUM`J@K& l00vQ5JBs~;vo!vr%%-k{2_Fq1Mn4QF81S)AQ99zk{{c4yR+0b! literal 59203 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

K7UpUK$>l@ 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 E2O(~oYXATM diff --git a/Android/src/gradlew.bat b/Android/src/gradlew.bat index 107acd3..ac1b06f 100644 --- a/Android/src/gradlew.bat +++ b/Android/src/gradlew.bat @@ -1,89 +1,89 @@ -@rem -@rem Copyright 2015 the original author or authors. -@rem -@rem Licensed under the Apache License, Version 2.0 (the "License"); -@rem you may not use this file except in compliance with the License. -@rem You may obtain a copy of the License at -@rem -@rem https://www.apache.org/licenses/LICENSE-2.0 -@rem -@rem Unless required by applicable law or agreed to in writing, software -@rem distributed under the License is distributed on an "AS IS" BASIS, -@rem WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -@rem See the License for the specific language governing permissions and -@rem limitations under the License. -@rem - -@if "%DEBUG%" == "" @echo off -@rem ########################################################################## -@rem -@rem Gradle startup script for Windows -@rem -@rem ########################################################################## - -@rem Set local scope for the variables with windows NT shell -if "%OS%"=="Windows_NT" setlocal - -set DIRNAME=%~dp0 -if "%DIRNAME%" == "" set DIRNAME=. -set APP_BASE_NAME=%~n0 -set APP_HOME=%DIRNAME% - -@rem Resolve any "." and ".." in APP_HOME to make it shorter. -for %%i in ("%APP_HOME%") do set APP_HOME=%%~fi - -@rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. -set DEFAULT_JVM_OPTS="-Xmx64m" "-Xms64m" - -@rem Find java.exe -if defined JAVA_HOME goto findJavaFromJavaHome - -set JAVA_EXE=java.exe -%JAVA_EXE% -version >NUL 2>&1 -if "%ERRORLEVEL%" == "0" goto execute - -echo. -echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. -echo. -echo Please set the JAVA_HOME variable in your environment to match the -echo location of your Java installation. - -goto fail - -:findJavaFromJavaHome -set JAVA_HOME=%JAVA_HOME:"=% -set JAVA_EXE=%JAVA_HOME%/bin/java.exe - -if exist "%JAVA_EXE%" goto execute - -echo. -echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% -echo. -echo Please set the JAVA_HOME variable in your environment to match the -echo location of your Java installation. - -goto fail - -:execute -@rem Setup the command line - -set CLASSPATH=%APP_HOME%\gradle\wrapper\gradle-wrapper.jar - - -@rem Execute Gradle -"%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" org.gradle.wrapper.GradleWrapperMain %* - -:end -@rem End local scope for the variables with windows NT shell -if "%ERRORLEVEL%"=="0" goto mainEnd - -:fail -rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of -rem the _cmd.exe /c_ return code! -if not "" == "%GRADLE_EXIT_CONSOLE%" exit 1 -exit /b 1 - -:mainEnd -if "%OS%"=="Windows_NT" endlocal - -:omega +@rem +@rem Copyright 2015 the original author or authors. +@rem +@rem Licensed under the Apache License, Version 2.0 (the "License"); +@rem you may not use this file except in compliance with the License. +@rem You may obtain a copy of the License at +@rem +@rem https://www.apache.org/licenses/LICENSE-2.0 +@rem +@rem Unless required by applicable law or agreed to in writing, software +@rem distributed under the License is distributed on an "AS IS" BASIS, +@rem WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +@rem See the License for the specific language governing permissions and +@rem limitations under the License. +@rem + +@if "%DEBUG%" == "" @echo off +@rem ########################################################################## +@rem +@rem Gradle startup script for Windows +@rem +@rem ########################################################################## + +@rem Set local scope for the variables with windows NT shell +if "%OS%"=="Windows_NT" setlocal + +set DIRNAME=%~dp0 +if "%DIRNAME%" == "" set DIRNAME=. +set APP_BASE_NAME=%~n0 +set APP_HOME=%DIRNAME% + +@rem Resolve any "." and ".." in APP_HOME to make it shorter. +for %%i in ("%APP_HOME%") do set APP_HOME=%%~fi + +@rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. +set DEFAULT_JVM_OPTS="-Xmx64m" "-Xms64m" + +@rem Find java.exe +if defined JAVA_HOME goto findJavaFromJavaHome + +set JAVA_EXE=java.exe +%JAVA_EXE% -version >NUL 2>&1 +if "%ERRORLEVEL%" == "0" goto execute + +echo. +echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. +echo. +echo Please set the JAVA_HOME variable in your environment to match the +echo location of your Java installation. + +goto fail + +:findJavaFromJavaHome +set JAVA_HOME=%JAVA_HOME:"=% +set JAVA_EXE=%JAVA_HOME%/bin/java.exe + +if exist "%JAVA_EXE%" goto execute + +echo. +echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% +echo. +echo Please set the JAVA_HOME variable in your environment to match the +echo location of your Java installation. + +goto fail + +:execute +@rem Setup the command line + +set CLASSPATH=%APP_HOME%\gradle\wrapper\gradle-wrapper.jar + + +@rem Execute Gradle +"%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" org.gradle.wrapper.GradleWrapperMain %* + +:end +@rem End local scope for the variables with windows NT shell +if "%ERRORLEVEL%"=="0" goto mainEnd + +:fail +rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of +rem the _cmd.exe /c_ return code! +if not "" == "%GRADLE_EXIT_CONSOLE%" exit 1 +exit /b 1 + +:mainEnd +if "%OS%"=="Windows_NT" endlocal + +:omega diff --git a/Android/src/settings.gradle.kts b/Android/src/settings.gradle.kts index de7c1d0..fa3d79f 100644 --- a/Android/src/settings.gradle.kts +++ b/Android/src/settings.gradle.kts @@ -15,27 +15,28 @@ */ pluginManagement { - repositories { - google { - content { - includeGroupByRegex("com\\.android.*") - includeGroupByRegex("com\\.google.*") - includeGroupByRegex("androidx.*") - } - } - mavenCentral() - gradlePluginPortal() + repositories { + google { + content { + includeGroupByRegex("com\\.android.*") + includeGroupByRegex("com\\.google.*") + includeGroupByRegex("androidx.*") + } } + mavenCentral() + gradlePluginPortal() + } } + dependencyResolutionManagement { - repositoriesMode.set(RepositoriesMode.FAIL_ON_PROJECT_REPOS) - repositories { -// mavenLocal() - google() - mavenCentral() - } + repositoriesMode.set(RepositoriesMode.FAIL_ON_PROJECT_REPOS) + repositories { + // mavenLocal() + google() + mavenCentral() + } } rootProject.name = "AI Edge Gallery" + include(":app") - \ No newline at end of file