diff --git a/.gitignore b/.gitignore
new file mode 100644
index 0000000..e43b0f9
--- /dev/null
+++ b/.gitignore
@@ -0,0 +1 @@
+.DS_Store
diff --git a/Android/.gitignore b/Android/.gitignore
new file mode 100644
index 0000000..1ce5acb
--- /dev/null
+++ b/Android/.gitignore
@@ -0,0 +1,35 @@
+# Gradle files
+.gradle/
+build/
+
+# Local configuration file (sdk path, etc)
+local.properties
+
+# Log/OS Files
+*.log
+
+# Android Studio generated files and folders
+captures/
+.externalNativeBuild/
+.cxx/
+*.apk
+output.json
+
+# IntelliJ
+*.iml
+.idea/
+misc.xml
+deploymentTargetDropDown.xml
+render.experimental.xml
+
+# Keystore files
+*.jks
+*.keystore
+
+# Google Services (e.g. APIs or Firebase)
+google-services.json
+
+# Android Profiling
+*.hprof
+
+.DS_Store
diff --git a/Android/README.md b/Android/README.md
new file mode 100644
index 0000000..30c24d9
--- /dev/null
+++ b/Android/README.md
@@ -0,0 +1 @@
+# AI Edge Gallery (Android)
diff --git a/Android/src/.gitignore b/Android/src/.gitignore
new file mode 100644
index 0000000..aa724b7
--- /dev/null
+++ b/Android/src/.gitignore
@@ -0,0 +1,15 @@
+*.iml
+.gradle
+/local.properties
+/.idea/caches
+/.idea/libraries
+/.idea/modules.xml
+/.idea/workspace.xml
+/.idea/navEditor.xml
+/.idea/assetWizardSettings.xml
+.DS_Store
+/build
+/captures
+.externalNativeBuild
+.cxx
+local.properties
diff --git a/Android/src/app/.gitignore b/Android/src/app/.gitignore
new file mode 100644
index 0000000..42afabf
--- /dev/null
+++ b/Android/src/app/.gitignore
@@ -0,0 +1 @@
+/build
\ No newline at end of file
diff --git a/Android/src/app/build.gradle.kts b/Android/src/app/build.gradle.kts
new file mode 100644
index 0000000..d06b8d7
--- /dev/null
+++ b/Android/src/app/build.gradle.kts
@@ -0,0 +1,101 @@
+/*
+ * 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.
+ */
+
+plugins {
+ alias(libs.plugins.android.application)
+ alias(libs.plugins.kotlin.android)
+ alias(libs.plugins.kotlin.compose)
+ alias(libs.plugins.kotlin.serialization)
+}
+
+android {
+ namespace = "com.google.aiedge.gallery"
+ compileSdk = 35
+
+ defaultConfig {
+ applicationId = "com.google.aiedge.gallery"
+ minSdk = 24
+ targetSdk = 35
+ versionCode = 1
+ versionName = "1.0"
+
+ // Needed for HuggingFace auth workflows.
+ manifestPlaceholders["appAuthRedirectScheme"] = "com.google.aiedge.gallery.oauth"
+
+ testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
+ }
+
+ buildTypes {
+ release {
+ isMinifyEnabled = false
+ proguardFiles(
+ getDefaultProguardFile("proguard-android-optimize.txt"),
+ "proguard-rules.pro"
+ )
+ signingConfig = signingConfigs.getByName("debug")
+ }
+ }
+ compileOptions {
+ sourceCompatibility = JavaVersion.VERSION_11
+ targetCompatibility = JavaVersion.VERSION_11
+ }
+ kotlinOptions {
+ jvmTarget = "11"
+ freeCompilerArgs += "-Xcontext-receivers"
+ }
+ buildFeatures {
+ compose = true
+ }
+}
+
+dependencies {
+ implementation(libs.androidx.core.ktx)
+ implementation(libs.androidx.lifecycle.runtime.ktx)
+ implementation(libs.androidx.activity.compose)
+ implementation(platform(libs.androidx.compose.bom))
+ implementation(libs.androidx.ui)
+ implementation(libs.androidx.ui.graphics)
+ implementation(libs.androidx.ui.tooling.preview)
+ implementation(libs.androidx.material3)
+ implementation(libs.androidx.compose.navigation)
+ implementation(libs.kotlinx.serialization.json)
+ implementation(libs.material.icon.extended)
+ implementation(libs.androidx.work.runtime)
+ implementation(libs.androidx.datastore.preferences)
+ implementation(libs.com.google.code.gson)
+ implementation(libs.androidx.lifecycle.process)
+ implementation(libs.mediapipe.tasks.text)
+ implementation(libs.mediapipe.tasks.genai)
+ implementation(libs.mediapipe.tasks.imagegen)
+ implementation(libs.commonmark)
+ implementation(libs.richtext)
+ implementation(libs.tflite)
+ implementation(libs.tflite.gpu)
+ implementation(libs.tflite.support)
+ implementation(libs.camerax.core)
+ implementation(libs.camerax.camera2)
+ implementation(libs.camerax.lifecycle)
+ implementation(libs.camerax.view)
+ implementation(libs.openid.appauth)
+ implementation(libs.androidx.splashscreen)
+ testImplementation(libs.junit)
+ androidTestImplementation(libs.androidx.junit)
+ androidTestImplementation(libs.androidx.espresso.core)
+ androidTestImplementation(platform(libs.androidx.compose.bom))
+ androidTestImplementation(libs.androidx.ui.test.junit4)
+ debugImplementation(libs.androidx.ui.tooling)
+ debugImplementation(libs.androidx.ui.test.manifest)
+}
\ No newline at end of file
diff --git a/Android/src/app/proguard-rules.pro b/Android/src/app/proguard-rules.pro
new file mode 100644
index 0000000..481bb43
--- /dev/null
+++ b/Android/src/app/proguard-rules.pro
@@ -0,0 +1,21 @@
+# 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/androidTest/java/com/google/aiedge/gallery/ExampleInstrumentedTest.kt b/Android/src/app/src/androidTest/java/com/google/aiedge/gallery/ExampleInstrumentedTest.kt
new file mode 100644
index 0000000..f26352a
--- /dev/null
+++ b/Android/src/app/src/androidTest/java/com/google/aiedge/gallery/ExampleInstrumentedTest.kt
@@ -0,0 +1,40 @@
+/*
+ * 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.aiedge.gallery
+
+import androidx.test.platform.app.InstrumentationRegistry
+import androidx.test.ext.junit.runners.AndroidJUnit4
+
+import org.junit.Test
+import org.junit.runner.RunWith
+
+import org.junit.Assert.*
+
+/**
+ * Instrumented test, which will execute on an Android device.
+ *
+ * See [testing documentation](http://d.android.com/tools/testing).
+ */
+@RunWith(AndroidJUnit4::class)
+class ExampleInstrumentedTest {
+ @Test
+ fun useAppContext() {
+ // Context of the app under test.
+ val appContext = InstrumentationRegistry.getInstrumentation().targetContext
+ assertEquals("com.google.aiedge.gallery", appContext.packageName)
+ }
+}
\ No newline at end of file
diff --git a/Android/src/app/src/main/AndroidManifest.xml b/Android/src/app/src/main/AndroidManifest.xml
new file mode 100644
index 0000000..6c366fb
--- /dev/null
+++ b/Android/src/app/src/main/AndroidManifest.xml
@@ -0,0 +1,83 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/Android/src/app/src/main/java/com/google/aiedge/gallery/GalleryApp.kt b/Android/src/app/src/main/java/com/google/aiedge/gallery/GalleryApp.kt
new file mode 100644
index 0000000..9c01f75
--- /dev/null
+++ b/Android/src/app/src/main/java/com/google/aiedge/gallery/GalleryApp.kt
@@ -0,0 +1,192 @@
+/*
+ * 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.aiedge.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.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.navigation.NavHostController
+import androidx.navigation.compose.rememberNavController
+import com.google.aiedge.gallery.data.AppBarAction
+import com.google.aiedge.gallery.data.AppBarActionType
+import com.google.aiedge.gallery.ui.navigation.GalleryNavHost
+
+/**
+ * Top level composable representing the main screen of the application.
+ */
+@Composable
+fun GalleryApp(navController: NavHostController = rememberNavController()) {
+ GalleryNavHost(navController = navController)
+}
+
+/**
+ * The top app bar.
+ */
+@Composable
+fun GalleryTopAppBar(
+ title: String,
+ modifier: Modifier = Modifier,
+ leftAction: AppBarAction? = null,
+ rightAction: AppBarAction? = null,
+ scrollBehavior: TopAppBarScrollBehavior? = null,
+ loadingHfModels: Boolean = false,
+ subtitle: String = "",
+) {
+ 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,
+ )
+ }
+ Text(
+ title,
+ style = MaterialTheme.typography.titleLarge.copy(fontWeight = FontWeight.SemiBold)
+ )
+ }
+ 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
+ )
+ }
+ }
+
+ // Click an icon to open "download manager".
+ AppBarActionType.DOWNLOAD_MANAGER -> {
+ if (loadingHfModels) {
+ CircularProgressIndicator(
+ trackColor = MaterialTheme.colorScheme.surfaceContainerHighest,
+ strokeWidth = 3.dp,
+ modifier = Modifier
+ .padding(end = 12.dp)
+ .size(20.dp)
+ )
+ }
+// else {
+// IconButton(onClick = rightAction.actionFn) {
+// Icon(
+// imageVector = Deployed_code,
+// 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/aiedge/gallery/GalleryApplication.kt b/Android/src/app/src/main/java/com/google/aiedge/gallery/GalleryApplication.kt
new file mode 100644
index 0000000..f460d80
--- /dev/null
+++ b/Android/src/app/src/main/java/com/google/aiedge/gallery/GalleryApplication.kt
@@ -0,0 +1,51 @@
+/*
+ * 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.aiedge.gallery
+
+import android.app.Application
+import android.content.Context
+import androidx.datastore.core.DataStore
+import androidx.datastore.preferences.core.Preferences
+import androidx.datastore.preferences.preferencesDataStore
+import com.google.aiedge.gallery.data.AppContainer
+import com.google.aiedge.gallery.data.DefaultAppContainer
+import com.google.aiedge.gallery.data.TASKS
+import com.google.aiedge.gallery.ui.theme.ThemeSettings
+
+private val Context.dataStore: DataStore by preferencesDataStore(name = "app_gallery_preferences")
+
+class GalleryApplication : Application() {
+ /** AppContainer instance used by the rest of classes to obtain dependencies */
+ lateinit var container: AppContainer
+
+ override fun onCreate() {
+ super.onCreate()
+
+ // Process tasks.
+ for ((index, task) in TASKS.withIndex()) {
+ task.index = index
+ for (model in task.models) {
+ model.preProcess(task = task)
+ }
+ }
+
+ container = DefaultAppContainer(this, dataStore)
+
+ // Load theme.
+ ThemeSettings.themeOverride.value = container.dataStoreRepository.readThemeOverride()
+ }
+}
\ No newline at end of file
diff --git a/Android/src/app/src/main/java/com/google/aiedge/gallery/GalleryLifecycleProvider.kt b/Android/src/app/src/main/java/com/google/aiedge/gallery/GalleryLifecycleProvider.kt
new file mode 100644
index 0000000..fde845b
--- /dev/null
+++ b/Android/src/app/src/main/java/com/google/aiedge/gallery/GalleryLifecycleProvider.kt
@@ -0,0 +1,44 @@
+/*
+ * 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.aiedge.gallery
+
+import androidx.lifecycle.DefaultLifecycleObserver
+import androidx.lifecycle.LifecycleOwner
+import androidx.lifecycle.ProcessLifecycleOwner
+
+interface AppLifecycleProvider {
+ val isAppInForeground: Boolean
+}
+
+class GalleryLifecycleProvider : AppLifecycleProvider, DefaultLifecycleObserver {
+ private var _isAppInForeground = false
+
+ init {
+ ProcessLifecycleOwner.get().lifecycle.addObserver(this)
+ }
+
+ override val isAppInForeground: Boolean
+ get() = _isAppInForeground
+
+ override fun onResume(owner: LifecycleOwner) {
+ _isAppInForeground = true
+ }
+
+ override fun onPause(owner: LifecycleOwner) {
+ _isAppInForeground = false
+ }
+}
diff --git a/Android/src/app/src/main/java/com/google/aiedge/gallery/MainActivity.kt b/Android/src/app/src/main/java/com/google/aiedge/gallery/MainActivity.kt
new file mode 100644
index 0000000..5512a8e
--- /dev/null
+++ b/Android/src/app/src/main/java/com/google/aiedge/gallery/MainActivity.kt
@@ -0,0 +1,45 @@
+/*
+ * 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.aiedge.gallery
+
+import android.os.Bundle
+import androidx.activity.ComponentActivity
+import androidx.activity.compose.setContent
+import androidx.activity.enableEdgeToEdge
+import androidx.compose.foundation.layout.fillMaxSize
+import androidx.compose.material3.Surface
+import androidx.compose.ui.Modifier
+import androidx.core.splashscreen.SplashScreen.Companion.installSplashScreen
+import com.google.aiedge.gallery.ui.theme.GalleryTheme
+
+class MainActivity : ComponentActivity() {
+ override fun onCreate(savedInstanceState: Bundle?) {
+ installSplashScreen()
+
+ super.onCreate(savedInstanceState)
+ enableEdgeToEdge()
+ setContent {
+ GalleryTheme {
+ Surface(
+ modifier = Modifier.fillMaxSize()
+ ) {
+ GalleryApp()
+ }
+ }
+ }
+ }
+}
diff --git a/Android/src/app/src/main/java/com/google/aiedge/gallery/Version.kt b/Android/src/app/src/main/java/com/google/aiedge/gallery/Version.kt
new file mode 100644
index 0000000..7924e7a
--- /dev/null
+++ b/Android/src/app/src/main/java/com/google/aiedge/gallery/Version.kt
@@ -0,0 +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.aiedge.gallery
+
+const val VERSION = "20250413"
\ No newline at end of file
diff --git a/Android/src/app/src/main/java/com/google/aiedge/gallery/data/AppBarAction.kt b/Android/src/app/src/main/java/com/google/aiedge/gallery/data/AppBarAction.kt
new file mode 100644
index 0000000..6a6dbcf
--- /dev/null
+++ b/Android/src/app/src/main/java/com/google/aiedge/gallery/data/AppBarAction.kt
@@ -0,0 +1,30 @@
+/*
+ * 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.aiedge.gallery.data
+
+/** Possible action for app bar. */
+enum class AppBarActionType {
+ NO_ACTION,
+ APP_SETTING,
+ DOWNLOAD_MANAGER,
+ MODEL_SELECTOR,
+ NAVIGATE_UP,
+ REFRESH_MODELS,
+ REFRESHING_MODELS,
+}
+
+class AppBarAction(val actionType: AppBarActionType, val actionFn: () -> Unit)
\ No newline at end of file
diff --git a/Android/src/app/src/main/java/com/google/aiedge/gallery/data/AppContainer.kt b/Android/src/app/src/main/java/com/google/aiedge/gallery/data/AppContainer.kt
new file mode 100644
index 0000000..c95d079
--- /dev/null
+++ b/Android/src/app/src/main/java/com/google/aiedge/gallery/data/AppContainer.kt
@@ -0,0 +1,47 @@
+/*
+ * 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.aiedge.gallery.data
+
+import android.content.Context
+import androidx.datastore.core.DataStore
+import androidx.datastore.preferences.core.Preferences
+import com.google.aiedge.gallery.GalleryLifecycleProvider
+import com.google.aiedge.gallery.AppLifecycleProvider
+
+/**
+ * App container for Dependency injection.
+ *
+ * This interface defines the dependencies required by the application.
+ */
+interface AppContainer {
+ val context: Context
+ val lifecycleProvider: AppLifecycleProvider
+ val dataStoreRepository: DataStoreRepository
+ val downloadRepository: DownloadRepository
+}
+
+/**
+ * Default implementation of the AppContainer interface.
+ *
+ * This class provides concrete implementations for the application's dependencies,
+ */
+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/aiedge/gallery/data/Config.kt b/Android/src/app/src/main/java/com/google/aiedge/gallery/data/Config.kt
new file mode 100644
index 0000000..73814e2
--- /dev/null
+++ b/Android/src/app/src/main/java/com/google/aiedge/gallery/data/Config.kt
@@ -0,0 +1,107 @@
+/*
+ * 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.aiedge.gallery.data
+
+/**
+ * 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.
+ */
+enum class ConfigEditorType {
+ NUMBER_SLIDER,
+ BOOLEAN_SWITCH,
+ DROPDOWN,
+}
+
+/**
+ * The data types of configuration values.
+ */
+enum class ValueType {
+ INT,
+ FLOAT,
+ DOUBLE,
+ STRING,
+ BOOLEAN,
+}
+
+/**
+ * Base class for configuration settings.
+ *
+ * @param type The type of configuration editor.
+ * @param key The unique key for the configuration setting.
+ * @param defaultValue The default value for the configuration setting.
+ * @param valueType The data type of the configuration value.
+ * @param needReinitialization Indicates whether the model needs to be reinitialized after changing
+ * this config.
+ */
+open class Config(
+ val type: ConfigEditorType,
+ open val key: ConfigKey,
+ open val defaultValue: Any,
+ open val valueType: ValueType,
+ open val needReinitialization: Boolean = true,
+)
+
+/**
+ * Configuration setting for a number slider.
+ *
+ * @param sliderMin The minimum value of the slider.
+ * @param sliderMax The maximum value of the slider.
+ */
+class NumberSliderConfig(
+ override val key: ConfigKey,
+ val sliderMin: Float,
+ val sliderMax: Float,
+ override val defaultValue: Float,
+ override val valueType: ValueType,
+ override val needReinitialization: Boolean = true,
+) :
+ Config(
+ type = ConfigEditorType.NUMBER_SLIDER,
+ key = key,
+ defaultValue = defaultValue,
+ valueType = valueType
+ )
+
+/**
+ * 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,
+)
+
+/**
+ * Configuration setting for a dropdown.
+ */
+class SegmentedButtonConfig(
+ override val key: ConfigKey,
+ override val defaultValue: String,
+ val options: List,
+) : Config(
+ type = ConfigEditorType.DROPDOWN,
+ key = key,
+ defaultValue = defaultValue,
+ valueType = ValueType.STRING,
+)
\ No newline at end of file
diff --git a/Android/src/app/src/main/java/com/google/aiedge/gallery/data/Consts.kt b/Android/src/app/src/main/java/com/google/aiedge/gallery/data/Consts.kt
new file mode 100644
index 0000000..ed666e7
--- /dev/null
+++ b/Android/src/app/src/main/java/com/google/aiedge/gallery/data/Consts.kt
@@ -0,0 +1,32 @@
+/*
+ * 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.aiedge.gallery.data
+
+// Keys used to send/receive data to Work.
+const val KEY_MODEL_URL = "KEY_MODEL_URL"
+const val KEY_MODEL_DOWNLOAD_FILE_NAME = "KEY_MODEL_DOWNLOAD_FILE_NAME"
+const val KEY_MODEL_TOTAL_BYTES = "KEY_MODEL_TOTAL_BYTES"
+const val KEY_MODEL_DOWNLOAD_RECEIVED_BYTES = "KEY_MODEL_DOWNLOAD_RECEIVED_BYTES"
+const val KEY_MODEL_DOWNLOAD_RATE = "KEY_MODEL_DOWNLOAD_RATE"
+const val KEY_MODEL_DOWNLOAD_REMAINING_MS = "KEY_MODEL_DOWNLOAD_REMAINING_SECONDS"
+const val KEY_MODEL_DOWNLOAD_ERROR_MESSAGE = "KEY_MODEL_DOWNLOAD_ERROR_MESSAGE"
+const val KEY_MODEL_DOWNLOAD_ACCESS_TOKEN = "KEY_MODEL_DOWNLOAD_ACCESS_TOKEN"
+const val KEY_MODEL_EXTRA_DATA_URLS = "KEY_MODEL_EXTRA_DATA_URLS"
+const val KEY_MODEL_EXTRA_DATA_DOWNLOAD_FILE_NAMES = "KEY_MODEL_EXTRA_DATA_DOWNLOAD_FILE_NAMES"
+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"
diff --git a/Android/src/app/src/main/java/com/google/aiedge/gallery/data/DataStoreRepository.kt b/Android/src/app/src/main/java/com/google/aiedge/gallery/data/DataStoreRepository.kt
new file mode 100644
index 0000000..0a61e1d
--- /dev/null
+++ b/Android/src/app/src/main/java/com/google/aiedge/gallery/data/DataStoreRepository.kt
@@ -0,0 +1,208 @@
+/*
+ * 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.aiedge.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.aiedge.gallery.ui.theme.THEME_AUTO
+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 expiresAtSeconds: Long
+)
+
+interface DataStoreRepository {
+ fun saveTextInputHistory(history: List)
+ fun readTextInputHistory(): List
+ fun saveThemeOverride(theme: String)
+ fun readThemeOverride(): String
+ fun saveAccessTokenData(accessToken: String, refreshToken: String, expiresAt: Long)
+ fun readAccessTokenData(): AccessTokenData?
+}
+
+/**
+ * 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")
+ }
+
+ private val keystoreAlias: String = "com_google_aiedge_gallery_access_token_key"
+ private val keyStore: KeyStore = KeyStore.getInstance("AndroidKeyStore").apply { load(null) }
+
+ override fun saveTextInputHistory(history: List) {
+ runBlocking {
+ dataStore.edit { preferences ->
+ val gson = Gson()
+ val jsonString = gson.toJson(history)
+ preferences[PreferencesKeys.TEXT_INPUT_HISTORY] = jsonString
+ }
+ }
+ }
+
+ override fun readTextInputHistory(): List {
+ return runBlocking {
+ val preferences = dataStore.data.first()
+ getTextInputHistory(preferences)
+ }
+ }
+
+ override fun saveThemeOverride(theme: String) {
+ runBlocking {
+ dataStore.edit { preferences ->
+ preferences[PreferencesKeys.THEME_OVERRIDE] = theme
+ }
+ }
+ }
+
+ override fun readThemeOverride(): String {
+ return runBlocking {
+ val preferences = dataStore.data.first()
+ preferences[PreferencesKeys.THEME_OVERRIDE] ?: THEME_AUTO
+ }
+ }
+
+ 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
+ }
+ }
+ }
+
+ 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
+ }
+ }
+ }
+
+ 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
+ }
+ }
+}
diff --git a/Android/src/app/src/main/java/com/google/aiedge/gallery/data/DownloadRepository.kt b/Android/src/app/src/main/java/com/google/aiedge/gallery/data/DownloadRepository.kt
new file mode 100644
index 0000000..eea9306
--- /dev/null
+++ b/Android/src/app/src/main/java/com/google/aiedge/gallery/data/DownloadRepository.kt
@@ -0,0 +1,312 @@
+/*
+ * 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.aiedge.gallery.data
+
+import android.Manifest
+import android.app.NotificationChannel
+import android.app.NotificationManager
+import android.app.PendingIntent
+import android.content.Context
+import android.content.Intent
+import android.content.pm.PackageManager
+import android.net.Uri
+import android.os.Build
+import android.util.Log
+import androidx.core.app.ActivityCompat
+import androidx.core.app.NotificationCompat
+import androidx.core.app.NotificationManagerCompat
+import androidx.work.Data
+import androidx.work.ExistingWorkPolicy
+import androidx.work.OneTimeWorkRequestBuilder
+import androidx.work.Operation
+import androidx.work.OutOfQuotaPolicy
+import androidx.work.WorkInfo
+import androidx.work.WorkManager
+import androidx.work.WorkQuery
+import com.google.common.util.concurrent.FutureCallback
+import com.google.common.util.concurrent.Futures
+import com.google.common.util.concurrent.ListenableFuture
+import com.google.common.util.concurrent.MoreExecutors
+import com.google.aiedge.gallery.AppLifecycleProvider
+import com.google.aiedge.gallery.R
+import com.google.aiedge.gallery.worker.DownloadWorker
+import java.util.UUID
+
+private const val TAG = "AGDownloadRepository"
+private const val MODEL_NAME_TAG = "modelName"
+
+data class AGWorkInfo(val modelName: String, val workId: String)
+
+interface DownloadRepository {
+ fun downloadModel(
+ model: Model, onStatusUpdated: (model: Model, status: ModelDownloadStatus) -> Unit
+ )
+
+ fun cancelDownloadModel(model: Model)
+
+ fun cancelAll(models: List, onComplete: () -> Unit)
+
+ fun observerWorkerProgress(
+ workerId: UUID,
+ model: Model,
+ onStatusUpdated: (model: Model, status: ModelDownloadStatus) -> Unit,
+ )
+
+ fun getEnqueuedOrRunningWorkInfos(): List
+}
+
+/**
+ * Repository for managing model downloads using WorkManager.
+ *
+ * This class provides methods to initiate model downloads, cancel downloads, observe download
+ * progress, and retrieve information about enqueued or running download tasks. It utilizes
+ * WorkManager to handle background download operations.
+ */
+class DefaultDownloadRepository(
+ private val context: Context,
+ private val lifecycleProvider: AppLifecycleProvider,
+) : DownloadRepository {
+ private val workManager = WorkManager.getInstance(context)
+
+ override fun downloadModel(
+ model: Model, onStatusUpdated: (model: Model, status: ModelDownloadStatus) -> Unit
+ ) {
+ // Create input data.
+ val builder = Data.Builder()
+ val totalBytes = model.totalBytes + model.extraDataFiles.sumOf { it.sizeInBytes }
+ val inputDataBuilder = builder.putString(KEY_MODEL_URL, model.url)
+ .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
+ )
+ 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 }
+ )
+ }
+ if (model.accessToken != null) {
+ inputDataBuilder.putString(KEY_MODEL_DOWNLOAD_ACCESS_TOKEN, model.accessToken)
+ }
+ val inputData = inputDataBuilder.build()
+
+ // Create worker request.
+ val downloadWorkRequest =
+ 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
+ )
+
+ // Observe progress.
+ observerWorkerProgress(
+ workerId = workerId, model = model, onStatusUpdated = onStatusUpdated
+ )
+ }
+
+ override fun cancelDownloadModel(model: Model) {
+ workManager.cancelAllWorkByTag("${MODEL_NAME_TAG}:${model.name}")
+ }
+
+ override fun cancelAll(models: List, onComplete: () -> Unit) {
+ if (models.isEmpty()) {
+ onComplete()
+ return
+ }
+
+ val futures = mutableListOf>()
+ for (tag in models.map { "${MODEL_NAME_TAG}:${it.name}" }) {
+ futures.add(workManager.cancelAllWorkByTag(tag).result)
+ }
+ val combinedFuture: ListenableFuture> = Futures.allAsList(futures)
+ Futures.addCallback(
+ combinedFuture, object : FutureCallback> {
+ override fun onSuccess(result: List?) {
+ // All cancellations are complete
+ onComplete()
+ }
+
+ override fun onFailure(t: Throwable) {
+ // At least one cancellation failed
+ t.printStackTrace()
+ onComplete()
+ }
+ }, MoreExecutors.directExecutor()
+ )
+ }
+
+ override fun observerWorkerProgress(
+ workerId: UUID,
+ model: Model,
+ onStatusUpdated: (model: Model, status: ModelDownloadStatus) -> Unit,
+ ) {
+ workManager.getWorkInfoByIdLiveData(workerId).observeForever { workInfo ->
+ if (workInfo != null) {
+ when (workInfo.state) {
+ WorkInfo.State.RUNNING -> {
+ val receivedBytes = workInfo.progress.getLong(KEY_MODEL_DOWNLOAD_RECEIVED_BYTES, 0L)
+ val downloadRate = workInfo.progress.getLong(KEY_MODEL_DOWNLOAD_RATE, 0L)
+ val remainingSeconds = workInfo.progress.getLong(KEY_MODEL_DOWNLOAD_REMAINING_MS, 0L)
+ val startUnzipping = workInfo.progress.getBoolean(KEY_MODEL_START_UNZIPPING, false)
+
+ if (!startUnzipping) {
+ if (receivedBytes != 0L) {
+ onStatusUpdated(
+ model, ModelDownloadStatus(
+ status = ModelDownloadStatusType.IN_PROGRESS,
+ totalBytes = model.totalBytes,
+ receivedBytes = receivedBytes,
+ bytesPerSecond = downloadRate,
+ remainingMs = remainingSeconds,
+ )
+ )
+ }
+ } else {
+ onStatusUpdated(
+ model, ModelDownloadStatus(
+ status = ModelDownloadStatusType.UNZIPPING,
+ )
+ )
+ }
+ }
+
+ WorkInfo.State.SUCCEEDED -> {
+ Log.d("repo", "worker %s success".format(workerId.toString()))
+ onStatusUpdated(
+ model, ModelDownloadStatus(
+ status = ModelDownloadStatusType.SUCCEEDED,
+ )
+ )
+ sendNotification(
+ 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 -> {
+ 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)
+ )
+ if (workInfo.state == WorkInfo.State.CANCELLED) {
+ status = ModelDownloadStatusType.NOT_DOWNLOADED
+ } else {
+ sendNotification(
+ title = context.getString(R.string.notification_title_fail),
+ text = context.getString(R.string.notification_content_success).format(model.name),
+ modelName = "",
+ )
+ }
+ onStatusUpdated(
+ model, ModelDownloadStatus(status = status, errorMessage = errorMessage)
+ )
+ }
+
+ else -> {}
+ }
+ }
+ }
+ }
+
+ /**
+ * Retrieves a list of AGWorkInfo objects representing WorkManager work items that are either
+ * enqueued or currently running.
+ */
+ override fun getEnqueuedOrRunningWorkInfos(): List {
+ val workQuery =
+ WorkQuery.Builder.fromStates(listOf(WorkInfo.State.ENQUEUED, WorkInfo.State.RUNNING)).build()
+
+ return workManager.getWorkInfos(workQuery).get().map { info ->
+ val tags = info.tags
+ var modelName = ""
+ Log.d(TAG, "work: ${info.id}, tags: $tags")
+ for (tag in tags) {
+ if (tag.startsWith("${MODEL_NAME_TAG}:")) {
+ val index = tag.indexOf(':')
+ if (index >= 0) {
+ modelName = tag.substring(index + 1)
+ break
+ }
+ }
+ }
+ return@map AGWorkInfo(modelName = modelName, workId = info.id.toString())
+ }
+ }
+
+ private fun sendNotification(title: String, text: String, modelName: String) {
+ // Don't send notification if app is in foreground.
+ if (lifecycleProvider.isAppInForeground) {
+ return
+ }
+
+ val channelId = "download_notification"
+ val channelName = "AI Edge Gallery download notification"
+
+ // Create the NotificationChannel, but only on API 26+ because
+ // the NotificationChannel class is new and not in the support library
+ if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
+ val importance = NotificationManager.IMPORTANCE_HIGH
+ val channel = NotificationChannel(channelId, channelName, importance)
+ val notificationManager: NotificationManager =
+ context.getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager
+ notificationManager.createNotificationChannel(channel)
+ }
+
+ // Create an Intent to open your app with a deep link.
+ val intent = Intent(
+ Intent.ACTION_VIEW,
+ Uri.parse("com.google.aiedge.gallery://model/${modelName}")
+ ).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 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
+ ) {
+ // Permission not granted, return or handle accordingly. In real app, request permission.
+ return
+ }
+ notify(1, builder.build())
+ }
+ }
+}
diff --git a/Android/src/app/src/main/java/com/google/aiedge/gallery/data/HuggingFace.kt b/Android/src/app/src/main/java/com/google/aiedge/gallery/data/HuggingFace.kt
new file mode 100644
index 0000000..b1fd347
--- /dev/null
+++ b/Android/src/app/src/main/java/com/google/aiedge/gallery/data/HuggingFace.kt
@@ -0,0 +1,175 @@
+/*
+ * 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.aiedge.gallery.data
+
+import com.google.aiedge.gallery.ui.llmchat.createLLmChatConfig
+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
+data class HfModelSummary(val modelId: String)
+
+@Serializable
+data class HfModelDetails(val id: String, val siblings: List)
+
+@Serializable
+data class HfModelFile(val rfilename: String)
+
+@Serializable(with = ConfigValueSerializer::class)
+sealed class ConfigValue {
+ @Serializable
+ data class IntValue(val value: Int) : ConfigValue()
+
+ @Serializable
+ data class FloatValue(val value: Float) : ConfigValue()
+
+ @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")
+
+ 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())
+ }
+ }
+
+ else -> throw SerializationException("Expected JsonPrimitive")
+ }
+ }
+}
+
+@Serializable
+data class HfModel(
+ var id: String = "",
+ val task: String,
+ val name: String,
+ val url: String = "",
+ val file: String = "",
+ val sizeInBytes: Long,
+ val configs: Map,
+) {
+ fun toModel(): Model {
+ val parts = if (url.isNotEmpty()) {
+ url.split('/')
+ } else if (file.isNotEmpty()) {
+ listOf(file)
+ } else {
+ listOf("")
+ }
+ val fileName = "${id}_${(parts.lastOrNull() ?: "")}".replace(Regex("[^a-zA-Z0-9._-]"), "_")
+
+ // Generate configs based on the given default values.
+ val configs: List = when (task) {
+ TASK_LLM_CHAT.type.label -> createLLmChatConfig(defaults = configs)
+ // todo: add configs for other types.
+ else -> listOf()
+ }
+
+ // Construct url.
+ var modelUrl = url
+ if (modelUrl.isEmpty() && file.isNotEmpty()) {
+ modelUrl = "https://huggingface.co/${id}/resolve/main/${file}?download=true"
+ }
+
+ // Other parameters.
+ val showBenchmarkButton = when (task) {
+ TASK_LLM_CHAT.type.label -> false
+ else -> true
+ }
+ val showRunAgainButton = when (task) {
+ TASK_LLM_CHAT.type.label -> false
+ else -> true
+ }
+
+ return Model(
+ hfModelId = id,
+ name = name,
+ url = modelUrl,
+ sizeInBytes = sizeInBytes,
+ downloadFileName = fileName,
+ configs = configs,
+ showBenchmarkButton = showBenchmarkButton,
+ showRunAgainButton = showRunAgainButton,
+ )
+ }
+}
+
+fun getIntConfigValue(configValue: ConfigValue?, default: Int): Int {
+ if (configValue == null) {
+ return default
+ }
+ return when (configValue) {
+ is ConfigValue.IntValue -> configValue.value
+ is ConfigValue.FloatValue -> configValue.value.toInt()
+ is ConfigValue.StringValue -> 0
+ }
+}
+
+fun getFloatConfigValue(configValue: ConfigValue?, default: Float): Float {
+ if (configValue == null) {
+ return default
+ }
+ return when (configValue) {
+ is ConfigValue.IntValue -> configValue.value.toFloat()
+ is ConfigValue.FloatValue -> configValue.value
+ is ConfigValue.StringValue -> 0f
+ }
+}
+
+fun getStringConfigValue(configValue: ConfigValue?, default: String): String {
+ if (configValue == null) {
+ return default
+ }
+ return when (configValue) {
+ is ConfigValue.IntValue -> "${configValue.value}"
+ is ConfigValue.FloatValue -> "${configValue.value}"
+ is ConfigValue.StringValue -> configValue.value
+ }
+}
diff --git a/Android/src/app/src/main/java/com/google/aiedge/gallery/data/Model.kt b/Android/src/app/src/main/java/com/google/aiedge/gallery/data/Model.kt
new file mode 100644
index 0000000..a99ccb8
--- /dev/null
+++ b/Android/src/app/src/main/java/com/google/aiedge/gallery/data/Model.kt
@@ -0,0 +1,376 @@
+/*
+ * 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.aiedge.gallery.data
+
+import android.content.Context
+import com.google.aiedge.gallery.ui.common.chat.PromptTemplate
+import com.google.aiedge.gallery.ui.common.convertValueToTargetType
+import com.google.aiedge.gallery.ui.llmchat.createLlmChatConfigs
+
+data class ModelDataFile(
+ val name: String,
+ val url: String,
+ val downloadFileName: String,
+ val sizeInBytes: Long,
+)
+
+enum class LlmBackend {
+ CPU, GPU
+}
+
+/** A model for a task */
+data class Model(
+ /** The Hugging Face model ID (if applicable). */
+ val hfModelId: String = "",
+
+ /** The name (for display purpose) of the model. */
+ val name: String,
+
+ /** The name of the downloaded model file. */
+ val downloadFileName: String,
+
+ /** The URL to download the model from. */
+ val url: String,
+
+ /** The size of the model file in bytes. */
+ val sizeInBytes: Long,
+
+ /** A list of additional data files required by the model. */
+ val extraDataFiles: List = listOf(),
+
+ /**
+ * A description or information about the model.
+ *
+ * Will be shown at the start of the chat session and in the expanded model item.
+ */
+ val info: String = "",
+
+ /**
+ * The url to jump to when clicking "learn more" in expanded model item.
+ */
+ val learnMoreUrl: String = "",
+
+ /** A list of configurable parameters for the model. */
+ val configs: List = listOf(),
+
+ /** Whether to show the "run again" button in the UI. */
+ val showRunAgainButton: Boolean = true,
+
+ /** Whether to show the "benchmark" button in the UI. */
+ val showBenchmarkButton: Boolean = true,
+
+ /** Indicates whether the model is a zip file. */
+ val isZip: Boolean = false,
+
+ /** The name of the directory to unzip the model to (if it's a zip file). */
+ val unzipDir: String = "",
+
+ /** The preferred backend of the model (only for LLM). */
+ val llmBackend: LlmBackend = LlmBackend.GPU,
+
+ /** The prompt templates for the model (only for LLM). */
+ val llmPromptTemplates: List = listOf(),
+
+ // The following fields are managed by the app. Don't need to set manually.
+ var taskType: TaskType? = null,
+ var instance: Any? = null,
+ var initializing: Boolean = false,
+ var configValues: Map = mapOf(),
+ var totalBytes: Long = 0L,
+ var accessToken: String? = null,
+) {
+ fun preProcess(task: Task) {
+ this.taskType = task.type
+ val configValues: MutableMap = mutableMapOf()
+ for (config in this.configs) {
+ configValues[config.key.label] = config.defaultValue
+ }
+ this.configValues = configValues
+ this.totalBytes = this.sizeInBytes + this.extraDataFiles.sumOf { it.sizeInBytes }
+ }
+
+ fun getPath(context: Context, fileName: String = downloadFileName): String {
+ return if (this.isZip && this.unzipDir.isNotEmpty()) {
+ "${context.getExternalFilesDir(null)}/${this.unzipDir}"
+ } else {
+ "${context.getExternalFilesDir(null)}/${fileName}"
+ }
+ }
+
+ fun getIntConfigValue(key: ConfigKey, defaultValue: Int = 0): 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
+ }
+
+ fun getBooleanConfigValue(key: ConfigKey, defaultValue: Boolean = false): Boolean {
+ return getTypedConfigValue(
+ key = key, valueType = ValueType.BOOLEAN, defaultValue = defaultValue
+ ) as Boolean
+ }
+
+ fun getExtraDataFile(name: String): ModelDataFile? {
+ return extraDataFiles.find { it.name == name }
+ }
+
+ private fun getTypedConfigValue(key: ConfigKey, valueType: ValueType, defaultValue: Any): Any {
+ return convertValueToTargetType(
+ value = configValues.getOrDefault(key.label, defaultValue), valueType = valueType
+ )
+ }
+}
+
+enum class ModelDownloadStatusType {
+ NOT_DOWNLOADED, PARTIALLY_DOWNLOADED, IN_PROGRESS, UNZIPPING, SUCCEEDED, FAILED,
+}
+
+data class ModelDownloadStatus(
+ val status: ModelDownloadStatusType,
+ val totalBytes: Long = 0,
+ val receivedBytes: Long = 0,
+ val errorMessage: String = "",
+ val bytesPerSecond: Long = 0,
+ val remainingMs: Long = 0,
+)
+
+////////////////////////////////////////////////////////////////////////////////////////////////////
+// Configs.
+
+enum class ConfigKey(val label: String, val id: String) {
+ MAX_TOKENS("Max tokens", id = "max_token"),
+ TOPK("TopK", id = "topk"),
+ TOPP(
+ "TopP",
+ id = "topp"
+ ),
+ TEMPERATURE("Temperature", id = "temperature"),
+ MAX_RESULT_COUNT(
+ "Max result count",
+ id = "max_result_count"
+ ),
+ USE_GPU("Use GPU", id = "use_gpu"),
+ WARM_UP_ITERATIONS(
+ "Warm up iterations",
+ id = "warm_up_iterations"
+ ),
+ BENCHMARK_ITERATIONS(
+ "Benchmark iterations",
+ id = "benchmark_iterations"
+ ),
+ ITERATIONS("Iterations", id = "iterations"),
+ THEME("Theme", id = "theme"),
+}
+
+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,
+ )
+)
+
+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."
+
+const val TEXT_CLASSIFICATION_LEARN_MORE_URL =
+ "https://ai.google.dev/edge/mediapipe/solutions/text/text_classifier"
+
+const val IMAGE_CLASSIFICATION_INFO = ""
+
+const val IMAGE_CLASSIFICATION_LEARN_MORE_URL = "https://ai.google.dev/edge/litert/android"
+
+const val LLM_CHAT_INFO =
+ "Some description about this large language model. A community org for developers to discover models that are ready for deployment to edge platforms"
+
+const val LLM_CHAT_LEARN_MORE_URL =
+ "https://ai.google.dev/edge/mediapipe/solutions/genai/llm_inference/android"
+
+const val IMAGE_GENERATION_INFO =
+ "Powered by [MediaPipe Image Generation API](https://ai.google.dev/edge/mediapipe/solutions/vision/image_generator/android)"
+
+////////////////////////////////////////////////////////////////////////////////////////////////////
+// Model spec.
+
+val MODEL_LLM_GEMMA_2B_GPU_INT4: Model = Model(
+ name = "Gemma 2B (GPU int4)",
+ downloadFileName = "gemma-2b-it-gpu-int4.bin",
+ url = "https://storage.googleapis.com/tfweb/app_gallery_models/gemma-2b-it-gpu-int4.bin",
+ sizeInBytes = 1354301440L,
+ configs = createLlmChatConfigs(),
+ info = LLM_CHAT_INFO,
+ learnMoreUrl = LLM_CHAT_LEARN_MORE_URL,
+)
+
+val MODEL_LLM_GEMMA_2_2B_GPU_INT8: Model = Model(
+ name = "Gemma 2 2B (GPU int8)",
+ downloadFileName = "gemma2-2b-it-gpu-int8.bin",
+ url = "https://storage.googleapis.com/tfweb/app_gallery_models/gemma2-2b-it-gpu-int8.bin",
+ sizeInBytes = 2627141632L,
+ configs = createLlmChatConfigs(),
+ info = LLM_CHAT_INFO,
+ learnMoreUrl = LLM_CHAT_LEARN_MORE_URL,
+)
+
+val MODEL_LLM_GEMMA_3_1B_INT4: Model = Model(
+ name = "Gemma 3 1B (int4)",
+ downloadFileName = "gemma3-1b-it-int4.task",
+ url = "https://huggingface.co/litert-community/Gemma3-1B-IT/resolve/main/gemma3-1b-it-int4.task?download=true",
+ sizeInBytes = 554661243L,
+ configs = createLlmChatConfigs(defaultTopK = 64, defaultTopP = 0.95f),
+ info = LLM_CHAT_INFO,
+ learnMoreUrl = LLM_CHAT_LEARN_MORE_URL,
+ llmPromptTemplates = listOf(
+ PromptTemplate(
+ title = "Emoji Fun",
+ description = "Generate emojis by emotions",
+ prompt = "Show me emojis grouped by emotions"
+ ),
+ PromptTemplate(
+ title = "Trip Planner",
+ description = "Plan a trip to a destination",
+ prompt = "Plan a two-day trip to San Francisco"
+ ),
+ )
+)
+
+val MODEL_LLM_DEEPSEEK: Model = Model(
+ name = "Deepseek",
+ downloadFileName = "deepseek.task",
+ url = "https://huggingface.co/litert-community/DeepSeek-R1-Distill-Qwen-1.5B/resolve/main/deepseek_q8_ekv1280.task?download=true",
+ sizeInBytes = 1860686856L,
+ llmBackend = LlmBackend.CPU,
+ configs = createLlmChatConfigs(defaultTemperature = 0.6f, defaultTopK = 40, defaultTopP = 0.7f),
+ info = LLM_CHAT_INFO,
+ learnMoreUrl = LLM_CHAT_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_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_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,
+)
+
+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_IMAGE_CLASSIFICATION: MutableList = mutableListOf(
+ MODEL_IMAGE_CLASSIFICATION_MOBILENET_V1,
+ MODEL_IMAGE_CLASSIFICATION_MOBILENET_V2,
+)
+
+val MODELS_LLM_CHAT: MutableList = mutableListOf(
+ MODEL_LLM_GEMMA_2B_GPU_INT4,
+ MODEL_LLM_GEMMA_2_2B_GPU_INT8,
+ MODEL_LLM_GEMMA_3_1B_INT4,
+ MODEL_LLM_DEEPSEEK,
+)
+
+val MODELS_IMAGE_GENERATION: MutableList =
+ mutableListOf(MODEL_IMAGE_GENERATION_STABLE_DIFFUSION)
diff --git a/Android/src/app/src/main/java/com/google/aiedge/gallery/data/Tasks.kt b/Android/src/app/src/main/java/com/google/aiedge/gallery/data/Tasks.kt
new file mode 100644
index 0000000..f96dd8a
--- /dev/null
+++ b/Android/src/app/src/main/java/com/google/aiedge/gallery/data/Tasks.kt
@@ -0,0 +1,111 @@
+/*
+ * 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.aiedge.gallery.data
+
+import androidx.annotation.StringRes
+import androidx.compose.material.icons.Icons
+import androidx.compose.material.icons.rounded.ImageSearch
+import androidx.compose.ui.graphics.vector.ImageVector
+import com.google.aiedge.gallery.R
+
+/** Type of task. */
+enum class TaskType(val label: String) {
+ TEXT_CLASSIFICATION("Text Classification"),
+ IMAGE_CLASSIFICATION("Image Classification"),
+ IMAGE_GENERATION("Image Generation"),
+ LLM_CHAT("LLM Chat"),
+
+ TEST_TASK_1("Test task 1"),
+ TEST_TASK_2("Test task 2")
+}
+
+/** Data class for a task listed in home screen. */
+data class Task(
+ /** Type of the task. */
+ val type: TaskType,
+
+ /** Icon to be shown in the task tile. */
+ val icon: ImageVector? = null,
+
+ /** Vector resource id for the icon. This precedes the icon if both are set. */
+ val iconVectorResourceId: Int? = null,
+
+ /** List of models for the task. */
+ val models: MutableList,
+
+ /** Description of the task. */
+ val description: String,
+
+ /** Placeholder text for the name of the agent shown above chat messages. */
+ @StringRes val agentNameRes: Int = R.string.chat_generic_agent_name,
+
+ /** Placeholder text for the text input field. */
+ @StringRes val textInputPlaceHolderRes: Int = R.string.chat_textinput_placeholder,
+
+ // The following fields are managed by the app. Don't need to set manually.
+ var index: Int = -1
+)
+
+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_IMAGE_CLASSIFICATION = Task(
+ type = TaskType.IMAGE_CLASSIFICATION,
+ icon = Icons.Rounded.ImageSearch,
+ description = "Classify images into different categories",
+ models = MODELS_IMAGE_CLASSIFICATION
+)
+
+val TASK_LLM_CHAT = Task(
+ type = TaskType.LLM_CHAT,
+ iconVectorResourceId = R.drawable.chat_spark,
+ models = MODELS_LLM_CHAT,
+ description = "Chat? with a on-device large language model",
+ 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",
+ textInputPlaceHolderRes = R.string.text_image_generation_text_field_placeholder
+)
+
+/** All tasks. */
+val TASKS: List = listOf(
+ TASK_TEXT_CLASSIFICATION,
+ TASK_IMAGE_CLASSIFICATION,
+ TASK_IMAGE_GENERATION,
+ TASK_LLM_CHAT,
+)
+
+fun getModelByName(name: String): Model? {
+ for (task in TASKS) {
+ for (model in task.models) {
+ if (model.name == name) {
+ return model
+ }
+ }
+ }
+ return null
+}
diff --git a/Android/src/app/src/main/java/com/google/aiedge/gallery/ui/ViewModelProvider.kt b/Android/src/app/src/main/java/com/google/aiedge/gallery/ui/ViewModelProvider.kt
new file mode 100644
index 0000000..6f25663
--- /dev/null
+++ b/Android/src/app/src/main/java/com/google/aiedge/gallery/ui/ViewModelProvider.kt
@@ -0,0 +1,70 @@
+/*
+ * 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.aiedge.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.aiedge.gallery.GalleryApplication
+import com.google.aiedge.gallery.ui.imageclassification.ImageClassificationViewModel
+import com.google.aiedge.gallery.ui.imagegeneration.ImageGenerationViewModel
+import com.google.aiedge.gallery.ui.llmchat.LlmChatViewModel
+import com.google.aiedge.gallery.ui.modelmanager.ModelManagerViewModel
+import com.google.aiedge.gallery.ui.textclassification.TextClassificationViewModel
+
+object ViewModelProvider {
+ val Factory = viewModelFactory {
+ // Initializer for ModelManagerViewModel.
+ initializer {
+ val downloadRepository = galleryApplication().container.downloadRepository
+ val dataStoreRepository = galleryApplication().container.dataStoreRepository
+ ModelManagerViewModel(
+ downloadRepository = downloadRepository,
+ dataStoreRepository = dataStoreRepository,
+ context = galleryApplication().container.context,
+ )
+ }
+
+ // Initializer for TextClassificationViewModel
+ initializer {
+ TextClassificationViewModel()
+ }
+
+ // Initializer for ImageClassificationViewModel
+ initializer {
+ ImageClassificationViewModel()
+ }
+
+ // Initializer for LlmChatViewModel.
+ initializer {
+ LlmChatViewModel()
+ }
+
+ initializer {
+ ImageGenerationViewModel()
+ }
+ }
+}
+
+/**
+ * Extension function to queries for [Application] object and returns an instance of
+ * [GalleryApplication].
+ */
+fun CreationExtras.galleryApplication(): GalleryApplication =
+ (this[AndroidViewModelFactory.APPLICATION_KEY] as GalleryApplication)
diff --git a/Android/src/app/src/main/java/com/google/aiedge/gallery/ui/common/AuthConfig.kt b/Android/src/app/src/main/java/com/google/aiedge/gallery/ui/common/AuthConfig.kt
new file mode 100644
index 0000000..737bcbb
--- /dev/null
+++ b/Android/src/app/src/main/java/com/google/aiedge/gallery/ui/common/AuthConfig.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.aiedge.gallery.ui.common
+
+import android.net.Uri
+import net.openid.appauth.AuthorizationServiceConfiguration
+
+object AuthConfig {
+ // Hugging Face Client ID.
+ const val clientId = "88a0ac25-fcf4-467b-b8cd-ebcc2aec9bd0"
+ // Registered redirect URI.
+ //
+ // The scheme needs to match the
+ // "android.defaultConfig.manifestPlaceholders["appAuthRedirectScheme"]" field in
+ // "build.gradle.kts".
+ const val redirectUri = "com.google.aiedge.gallery.oauth://oauthredirect"
+
+ // OAuth 2.0 Endpoints (Authorization + Token Exchange)
+ private const val authEndpoint = "https://huggingface.co/oauth/authorize"
+ 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
diff --git a/Android/src/app/src/main/java/com/google/aiedge/gallery/ui/common/DownloadAndTryButton.kt b/Android/src/app/src/main/java/com/google/aiedge/gallery/ui/common/DownloadAndTryButton.kt
new file mode 100644
index 0000000..ef2d334
--- /dev/null
+++ b/Android/src/app/src/main/java/com/google/aiedge/gallery/ui/common/DownloadAndTryButton.kt
@@ -0,0 +1,334 @@
+/*
+ * 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.aiedge.gallery.ui.common
+
+import android.content.Intent
+import android.net.Uri
+import android.util.Log
+import androidx.activity.compose.rememberLauncherForActivityResult
+import androidx.activity.result.ActivityResultLauncher
+import androidx.activity.result.contract.ActivityResultContracts
+import androidx.browser.customtabs.CustomTabsIntent
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.layout.wrapContentHeight
+import androidx.compose.material.icons.Icons
+import androidx.compose.material.icons.automirrored.rounded.ArrowForward
+import androidx.compose.material3.Button
+import androidx.compose.material3.ExperimentalMaterial3Api
+import androidx.compose.material3.Icon
+import androidx.compose.material3.MaterialTheme
+import androidx.compose.material3.ModalBottomSheet
+import androidx.compose.material3.Text
+import androidx.compose.material3.rememberModalBottomSheetState
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.getValue
+import androidx.compose.runtime.mutableStateOf
+import androidx.compose.runtime.remember
+import androidx.compose.runtime.rememberCoroutineScope
+import androidx.compose.runtime.setValue
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.platform.LocalContext
+import androidx.compose.ui.unit.dp
+import com.google.aiedge.gallery.data.Model
+import com.google.aiedge.gallery.ui.modelmanager.ModelManagerViewModel
+import com.google.aiedge.gallery.ui.modelmanager.TokenRequestResultType
+import com.google.aiedge.gallery.ui.modelmanager.TokenStatus
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.launch
+import kotlinx.coroutines.withContext
+import java.net.HttpURLConnection
+
+
+private const val TAG = "AGDownloadAndTryButton"
+
+// TODO:
+// - replace the download button in chat view page with this one, and add a flag to not "onclick"
+// just download
+
+/**
+ * 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.
+ *
+ * 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.
+ *
+ * If the model doesn't need to be downloaded first, the provided `onClicked` callback is executed.
+ *
+ * Additionally, for gated HuggingFace models, if accessing the model after token exchange results
+ * in a forbidden error, a modal bottom sheet is displayed, prompting the user to acknowledge the
+ * user agreement by opening it in a custom tab. Upon closing the tab, the download process is
+ * retried.
+ *
+ * The composable also manages UI states for indicating token checking and displaying the agreement
+ * acknowledgement sheet, and it handles requesting notification permissions before initiating the
+ * actual download.
+ */
+@OptIn(ExperimentalMaterial3Api::class)
+@Composable
+fun DownloadAndTryButton(
+ model: Model,
+ enabled: Boolean,
+ needToDownloadFirst: Boolean,
+ modelManagerViewModel: ModelManagerViewModel,
+ onClicked: () -> Unit
+) {
+ val scope = rememberCoroutineScope()
+ val context = LocalContext.current
+ var checkingToken by remember { mutableStateOf(false) }
+ var showAgreementAckSheet by remember { mutableStateOf(false) }
+ val sheetState = rememberModalBottomSheetState()
+
+ // A launcher for requesting notification permission.
+ val permissionLauncher = rememberLauncherForActivityResult(
+ ActivityResultContracts.RequestPermission()
+ ) {
+ modelManagerViewModel.downloadModel(model)
+ }
+
+ // Function to kick off download.
+ val startDownload: (accessToken: String?) -> Unit = { accessToken ->
+ model.accessToken = accessToken
+ onClicked()
+ checkNotificationPermissonAndStartDownload(
+ context = context,
+ launcher = permissionLauncher,
+ modelManagerViewModel = modelManagerViewModel,
+ 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)
+ }
+
+ // 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)
+ }
+ }
+ }
+ }
+
+ 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 = {
+ val authRequest = modelManagerViewModel.getAuthorizationRequest()
+ val authIntent = modelManagerViewModel.authService.getAuthorizationRequestIntent(authRequest)
+ authResultLauncher.launch(authIntent)
+ }
+
+ Button(
+ onClick = {
+ if (!enabled || checkingToken) {
+ return@Button
+ }
+
+ // Launches a coroutine to handle the initial check and potential authentication flow
+ // before downloading the model. It checks if the model needs to be downloaded first,
+ // handles HuggingFace URLs by verifying the need for authentication, and initiates
+ // the token exchange process if required or proceeds with the download if no auth is needed
+ // or a valid token is available.
+ scope.launch(Dispatchers.IO) {
+ if (needToDownloadFirst) {
+ // For HuggingFace urls
+ if (model.url.startsWith("https://huggingface.co")) {
+ checkingToken = true
+
+ // Check if the url needs auth.
+ Log.d(
+ TAG,
+ "Model '${model.name}' is from HuggingFace. Checking if the url needs auth to download"
+ )
+ if (modelManagerViewModel.getModelUrlResponse(model = model) == HttpURLConnection.HTTP_OK) {
+ Log.d(TAG, "Model '${model.name}' doesn't need auth. Start downloading the model...")
+ withContext(Dispatchers.Main) {
+ startDownload(null)
+ }
+ return@launch
+ }
+ Log.d(TAG, "Model '${model.name}' needs auth. Start token exchange process...")
+
+ // Get current token status
+ val tokenStatusAndData = modelManagerViewModel.getTokenStatusAndData()
+
+ 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()
+ }
+ }
+
+ // 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
+ )
+ 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)
+ }
+ }
+ // 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."
+ )
+
+ withContext(Dispatchers.Main) {
+ startTokenExchange()
+ }
+ }
+ }
+ }
+ }
+ // For other urls, just download the model.
+ else {
+ Log.d(
+ TAG,
+ "Model '${model.name}' is not from huggingface. Start downloading the model..."
+ )
+ withContext(Dispatchers.Main) {
+ startDownload(null)
+ }
+ }
+ } else {
+ withContext(Dispatchers.Main) {
+ onClicked()
+ }
+ }
+ }
+ },
+ ) {
+ Icon(
+ Icons.AutoMirrored.Rounded.ArrowForward,
+ contentDescription = "",
+ modifier = Modifier.padding(end = 4.dp)
+ )
+
+ if (checkingToken) {
+ Text("Checking access...")
+ } else {
+ if (needToDownloadFirst) {
+ Text("Download & Try it", maxLines = 1)
+ } else {
+ Text("Try it", maxLines = 1)
+ }
+ }
+ }
+
+ // A ModalBottomSheet composable that displays information about the user agreement
+ // for a gated model and provides a button to open the agreement in a custom tab.
+ // Upon clicking the button, it constructs the agreement URL, launches it using a
+ // custom tab, and then dismisses the bottom sheet.
+ if (showAgreementAckSheet) {
+ ModalBottomSheet(
+ onDismissRequest = {
+ showAgreementAckSheet = false
+ checkingToken = false
+ },
+ sheetState = sheetState,
+ modifier = Modifier.wrapContentHeight(),
+ ) {
+ Column(
+ horizontalAlignment = Alignment.CenterHorizontally,
+ 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)
+ )
+ 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)
+ }
+ // Dismiss the sheet.
+ showAgreementAckSheet = false
+ }) {
+ Text("Open user agreement")
+ }
+ }
+ }
+ }
+}
diff --git a/Android/src/app/src/main/java/com/google/aiedge/gallery/ui/common/TaskIcon.kt b/Android/src/app/src/main/java/com/google/aiedge/gallery/ui/common/TaskIcon.kt
new file mode 100644
index 0000000..02a5854
--- /dev/null
+++ b/Android/src/app/src/main/java/com/google/aiedge/gallery/ui/common/TaskIcon.kt
@@ -0,0 +1,105 @@
+/*
+ * 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.aiedge.gallery.ui.common
+
+import androidx.compose.foundation.Image
+import androidx.compose.foundation.background
+import androidx.compose.foundation.layout.Box
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.aspectRatio
+import androidx.compose.foundation.layout.fillMaxSize
+import androidx.compose.foundation.layout.size
+import androidx.compose.foundation.layout.width
+import androidx.compose.material3.Icon
+import androidx.compose.material3.MaterialTheme
+import androidx.compose.runtime.Composable
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.draw.alpha
+import androidx.compose.ui.graphics.BlendMode
+import androidx.compose.ui.graphics.Color
+import androidx.compose.ui.graphics.ColorFilter
+import androidx.compose.ui.graphics.painter.Painter
+import androidx.compose.ui.graphics.vector.ImageVector
+import androidx.compose.ui.layout.ContentScale
+import androidx.compose.ui.res.painterResource
+import androidx.compose.ui.res.vectorResource
+import androidx.compose.ui.tooling.preview.Preview
+import androidx.compose.ui.unit.Dp
+import androidx.compose.ui.unit.dp
+import com.google.aiedge.gallery.R
+import com.google.aiedge.gallery.data.TASKS
+import com.google.aiedge.gallery.data.TASK_LLM_CHAT
+import com.google.aiedge.gallery.data.Task
+import com.google.aiedge.gallery.ui.theme.GalleryTheme
+import com.google.aiedge.gallery.ui.theme.customColors
+
+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
+fun TaskIcon(task: Task, modifier: Modifier = Modifier, width: Dp = 56.dp) {
+ Box(
+ modifier = modifier
+ .width(width)
+ .aspectRatio(1f),
+ contentAlignment = Alignment.Center,
+ ) {
+ Image(
+ painter = getTaskIconBgShape(task = task),
+ contentDescription = "",
+ modifier = Modifier
+ .fillMaxSize()
+ .alpha(0.6f),
+ contentScale = ContentScale.Fit,
+ colorFilter = ColorFilter.tint(
+ MaterialTheme.customColors.taskIconShapeBgColor,
+ blendMode = BlendMode.SrcIn
+ )
+ )
+ Icon(
+ task.icon ?: ImageVector.vectorResource(task.iconVectorResourceId!!),
+ tint = getTaskIconColor(task = task),
+ modifier = Modifier.size(width * 0.6f),
+ contentDescription = "",
+ )
+ }
+}
+
+@Composable
+private fun getTaskIconBgShape(task: Task): Painter {
+ val colorIndex: Int = task.index % SHAPES.size
+ return painterResource(SHAPES[colorIndex])
+}
+
+@Preview(showBackground = true)
+@Composable
+fun TaskIconPreview() {
+ for ((index, task) in TASKS.withIndex()) {
+ task.index = index
+ }
+
+ GalleryTheme {
+ Column(modifier = Modifier.background(Color.Gray)) {
+ 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/aiedge/gallery/ui/common/Utils.kt b/Android/src/app/src/main/java/com/google/aiedge/gallery/ui/common/Utils.kt
new file mode 100644
index 0000000..5059527
--- /dev/null
+++ b/Android/src/app/src/main/java/com/google/aiedge/gallery/ui/common/Utils.kt
@@ -0,0 +1,442 @@
+/*
+ * 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.aiedge.gallery.ui.common
+
+import android.Manifest
+import android.content.Context
+import android.content.pm.PackageManager
+import android.net.Uri
+import android.os.Build
+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.aiedge.gallery.data.Config
+import com.google.aiedge.gallery.data.Model
+import com.google.aiedge.gallery.data.Task
+import com.google.aiedge.gallery.data.ValueType
+import com.google.aiedge.gallery.ui.common.chat.ChatMessageBenchmarkResult
+import com.google.aiedge.gallery.ui.common.chat.ChatMessageType
+import com.google.aiedge.gallery.ui.common.chat.ChatViewModel
+import com.google.aiedge.gallery.ui.common.chat.Histogram
+import com.google.aiedge.gallery.ui.common.chat.Stat
+import com.google.aiedge.gallery.ui.modelmanager.ModelManagerViewModel
+import com.google.aiedge.gallery.ui.theme.customColors
+import java.io.File
+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 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
+}
+
+/** Format the bytes into a human-readable format. */
+fun Long.humanReadableSize(si: Boolean = true, extraDecimalForGbAndAbove: Boolean = false): String {
+ val bytes = this
+
+ val unit = if (si) 1000 else 1024
+ if (bytes < unit) return "$bytes B"
+ val exp = (ln(bytes.toDouble()) / ln(unit.toDouble())).toInt()
+ val pre = (if (si) "kMGTPE" else "KMGTPE")[exp - 1] + if (si) "" else "i"
+ var formatString = "%.1f %sB"
+ if (extraDecimalForGbAndAbove && pre.lowercase() != "k" && pre != "M") {
+ formatString = "%.2f %sB"
+ }
+ return formatString.format(bytes / unit.toDouble().pow(exp.toDouble()), pre)
+}
+
+fun Float.humanReadableDuration(): String {
+ val milliseconds = this
+ if (milliseconds < 1000) {
+ return "$milliseconds ms"
+ }
+ val seconds = milliseconds / 1000f
+ if (seconds < 60) {
+ return "%.1f s".format(seconds)
+ }
+
+ val minutes = seconds / 60f
+ if (minutes < 60) {
+ return "%.1f min".format(minutes)
+ }
+
+ val hours = minutes / 60f
+ return "%.1f h".format(hours)
+}
+
+fun Long.formatToHourMinSecond(): String {
+ val ms = this
+ if (ms < 0) {
+ return "-"
+ }
+
+ val seconds = ms / 1000
+ val hours = seconds / 3600
+ val minutes = (seconds % 3600) / 60
+ val remainingSeconds = seconds % 60
+
+ val parts = mutableListOf()
+
+ if (hours > 0) {
+ parts.add("$hours h")
+ }
+ if (minutes > 0) {
+ parts.add("$minutes min")
+ }
+ if (remainingSeconds > 0 || (hours == 0L && minutes == 0L)) {
+ parts.add("$remainingSeconds sec")
+ }
+
+ 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)
+ )
+ return colors[index % colors.size]
+}
+
+fun Context.createTempPictureUri(
+ fileName: String = "picture_${System.currentTimeMillis()}", fileExtension: String = ".png"
+): Uri {
+ val tempFile = File.createTempFile(
+ fileName, fileExtension, cacheDir
+ ).apply {
+ createNewFile()
+ }
+
+ return FileProvider.getUriForFile(
+ applicationContext, "com.google.aiedge.gallery.provider", 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 checkNotificationPermissonAndStartDownload(
+ context: Context,
+ launcher: ManagedActivityResultLauncher,
+ modelManagerViewModel: ModelManagerViewModel,
+ model: Model
+) {
+ // Check permission
+ when (PackageManager.PERMISSION_GRANTED) {
+ // Already got permission. Call the lambda.
+ ContextCompat.checkSelfPermission(
+ context, Manifest.permission.POST_NOTIFICATIONS
+ ) -> {
+ modelManagerViewModel.downloadModel(model)
+ }
+
+ // Otherwise, ask for permission
+ else -> {
+ if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
+ launcher.launch(Manifest.permission.POST_NOTIFICATIONS)
+ }
+ }
+ }
+}
+
diff --git a/Android/src/app/src/main/java/com/google/aiedge/gallery/ui/common/chat/BenchmarkConfigDialog.kt b/Android/src/app/src/main/java/com/google/aiedge/gallery/ui/common/chat/BenchmarkConfigDialog.kt
new file mode 100644
index 0000000..79931ad
--- /dev/null
+++ b/Android/src/app/src/main/java/com/google/aiedge/gallery/ui/common/chat/BenchmarkConfigDialog.kt
@@ -0,0 +1,102 @@
+/*
+ * 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.aiedge.gallery.ui.common.chat
+
+import androidx.compose.runtime.Composable
+import androidx.compose.ui.tooling.preview.Preview
+import com.google.aiedge.gallery.data.Config
+import com.google.aiedge.gallery.data.ConfigKey
+import com.google.aiedge.gallery.data.NumberSliderConfig
+import com.google.aiedge.gallery.data.ValueType
+import com.google.aiedge.gallery.ui.common.convertValueToTargetType
+import com.google.aiedge.gallery.ui.theme.GalleryTheme
+
+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_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.
+ */
+@Composable
+fun BenchmarkConfigDialog(
+ onDismissed: () -> Unit,
+ messageToBenchmark: ChatMessage?,
+ onBenchmarkClicked: (ChatMessage, warmUpIterations: Int, benchmarkIterations: Int) -> Unit
+) {
+ ConfigDialog(
+ title = "Benchmark configs",
+ okBtnLabel = "Start",
+ configs = BENCHMARK_CONFIGS,
+ initialValues = BENCHMARK_CONFIGS_INITIAL_VALUES,
+ onDismissed = onDismissed,
+ onOk = { curConfigValues ->
+ // Hide config dialog.
+ onDismissed()
+
+ // 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
+ onBenchmarkClicked(message, warmUpIterations, benchmarkIterations)
+ }
+ },
+ )
+}
+
+@Preview(showBackground = true)
+@Composable
+fun BenchmarkConfigDialogPreview() {
+ GalleryTheme {
+ BenchmarkConfigDialog(
+ onDismissed = {},
+ messageToBenchmark = null,
+ onBenchmarkClicked = { _, _, _ -> }
+ )
+ }
+}
\ No newline at end of file
diff --git a/Android/src/app/src/main/java/com/google/aiedge/gallery/ui/common/chat/ChatMessage.kt b/Android/src/app/src/main/java/com/google/aiedge/gallery/ui/common/chat/ChatMessage.kt
new file mode 100644
index 0000000..c47b7ab
--- /dev/null
+++ b/Android/src/app/src/main/java/com/google/aiedge/gallery/ui/common/chat/ChatMessage.kt
@@ -0,0 +1,180 @@
+/*
+ * 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.aiedge.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.aiedge.gallery.data.Model
+
+enum class ChatMessageType {
+ INFO,
+ TEXT,
+ IMAGE,
+ IMAGE_WITH_HISTORY,
+ LOADING,
+ CLASSIFICATION,
+ CONFIG_VALUES_CHANGE,
+ BENCHMARK_RESULT,
+ BENCHMARK_LLM_RESULT,
+ PROMPT_TEMPLATES
+}
+
+enum class ChatSide {
+ 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, open val side: ChatSide, open val latencyMs: Float = -1f
+) {
+ open fun clone(): ChatMessage {
+ return ChatMessage(type = type, side = side, latencyMs = latencyMs)
+ }
+}
+
+/** Chat message for showing loading status. */
+class ChatMessageLoading : ChatMessage(type = ChatMessageType.LOADING, side = ChatSide.AGENT)
+
+/** Chat message for info (help). */
+class ChatMessageInfo(val content: String) :
+ ChatMessage(type = ChatMessageType.INFO, side = ChatSide.SYSTEM)
+
+/** Chat message for config values change. */
+class ChatMessageConfigValuesChange(
+ val model: Model,
+ val oldValues: Map,
+ val newValues: Map
+) : ChatMessage(type = ChatMessageType.CONFIG_VALUES_CHANGE, side = ChatSide.SYSTEM)
+
+/** Chat message for plain text. */
+open class ChatMessageText(
+ val content: String,
+ override val side: ChatSide,
+ // Negative numbers will hide the latency display.
+ override val latencyMs: Float = 0f,
+ val isMarkdown: Boolean = true,
+) : ChatMessage(type = ChatMessageType.TEXT, side = side, latencyMs = latencyMs) {
+ override fun clone(): ChatMessageText {
+ return ChatMessageText(
+ content = content,
+ side = side,
+ latencyMs = latencyMs,
+ isMarkdown = isMarkdown
+ )
+ }
+}
+
+/** Chat message for images. */
+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 fun clone(): ChatMessageImage {
+ return ChatMessageImage(
+ bitmap = bitmap,
+ imageBitMap = imageBitMap,
+ side = side,
+ latencyMs = latencyMs
+ )
+ }
+}
+
+/** Chat message for images with history. */
+class ChatMessageImageWithHistory(
+ val bitmaps: List,
+ val imageBitMaps: List,
+ val totalIterations: Int,
+ 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) {
+ fun isRunning(): Boolean {
+ return curIteration < totalIterations - 1
+ }
+}
+
+/** Chat message for showing classification result. */
+class ChatMessageClassification(
+ val classifications: List,
+ override val latencyMs: Float = 0f,
+ // Typical android phone width is > 320dp
+ val maxBarWidth: Dp? = null,
+) : 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)
+
+/** Chat message for showing benchmark result. */
+class ChatMessageBenchmarkResult(
+ val orderedStats: List,
+ val statValues: MutableMap,
+ val values: List,
+ val histogram: Histogram,
+ val warmupCurrent: Int,
+ val warmupTotal: Int,
+ val iterationCurrent: Int,
+ val iterationTotal: Int,
+ override val latencyMs: Float = 0f,
+ val highlightStat: String = "",
+) :
+ ChatMessage(
+ type = ChatMessageType.BENCHMARK_RESULT,
+ side = ChatSide.AGENT,
+ latencyMs = latencyMs
+ ) {
+ fun isWarmingUp(): Boolean {
+ return warmupCurrent < warmupTotal
+ }
+
+ fun isRunning(): Boolean {
+ return iterationCurrent < iterationTotal
+ }
+}
+
+/** Chat message for showing LLM benchmark result. */
+class ChatMessageBenchmarkLlmResult(
+ val orderedStats: List,
+ val statValues: MutableMap,
+ val running: Boolean,
+ override val latencyMs: Float = 0f,
+) : ChatMessage(
+ type = ChatMessageType.BENCHMARK_LLM_RESULT,
+ side = ChatSide.AGENT,
+ latencyMs = latencyMs
+)
+
+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/aiedge/gallery/ui/common/chat/ChatPanel.kt b/Android/src/app/src/main/java/com/google/aiedge/gallery/ui/common/chat/ChatPanel.kt
new file mode 100644
index 0000000..8211b7c
--- /dev/null
+++ b/Android/src/app/src/main/java/com/google/aiedge/gallery/ui/common/chat/ChatPanel.kt
@@ -0,0 +1,491 @@
+/*
+ * 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.aiedge.gallery.ui.common.chat
+
+import androidx.compose.animation.AnimatedVisibility
+import androidx.compose.animation.fadeIn
+import androidx.compose.animation.fadeOut
+import androidx.compose.animation.scaleIn
+import androidx.compose.animation.scaleOut
+import androidx.compose.foundation.background
+import androidx.compose.foundation.clickable
+import androidx.compose.foundation.gestures.detectTapGestures
+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.WindowInsets
+import androidx.compose.foundation.layout.fillMaxSize
+import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.foundation.layout.ime
+import androidx.compose.foundation.layout.imePadding
+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.layout.wrapContentHeight
+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.Timer
+import androidx.compose.material.icons.rounded.ContentCopy
+import androidx.compose.material.icons.rounded.Refresh
+import androidx.compose.material3.ExperimentalMaterial3Api
+import androidx.compose.material3.Icon
+import androidx.compose.material3.MaterialTheme
+import androidx.compose.material3.ModalBottomSheet
+import androidx.compose.material3.SnackbarHost
+import androidx.compose.material3.SnackbarHostState
+import androidx.compose.material3.Text
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.LaunchedEffect
+import androidx.compose.runtime.MutableState
+import androidx.compose.runtime.collectAsState
+import androidx.compose.runtime.getValue
+import androidx.compose.runtime.mutableIntStateOf
+import androidx.compose.runtime.mutableStateOf
+import androidx.compose.runtime.remember
+import androidx.compose.runtime.rememberCoroutineScope
+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.geometry.Offset
+import androidx.compose.ui.graphics.Color
+import androidx.compose.ui.graphics.asImageBitmap
+import androidx.compose.ui.hapticfeedback.HapticFeedbackType
+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.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.aiedge.gallery.R
+import com.google.aiedge.gallery.data.Model
+import com.google.aiedge.gallery.data.Task
+import com.google.aiedge.gallery.data.TaskType
+import com.google.aiedge.gallery.ui.modelmanager.ModelInitializationStatus
+import com.google.aiedge.gallery.ui.modelmanager.ModelManagerViewModel
+import com.google.aiedge.gallery.ui.preview.PreviewChatModel
+import com.google.aiedge.gallery.ui.preview.PreviewModelManagerViewModel
+import com.google.aiedge.gallery.ui.preview.TASK_TEST1
+import com.google.aiedge.gallery.ui.theme.GalleryTheme
+import com.google.aiedge.gallery.ui.theme.customColors
+import kotlinx.coroutines.launch
+
+enum class ChatInputType {
+ TEXT, IMAGE,
+}
+
+/**
+ * Composable function for the main chat panel, displaying messages and handling user input.
+ */
+@OptIn(ExperimentalMaterial3Api::class)
+@Composable
+fun ChatPanel(
+ modelManagerViewModel: ModelManagerViewModel,
+ task: Task,
+ selectedModel: Model,
+ viewModel: ChatViewModel,
+ onSendMessage: (Model, ChatMessage) -> Unit,
+ onRunAgainClicked: (Model, ChatMessage) -> Unit,
+ onBenchmarkClicked: (Model, ChatMessage, warmUpIterations: Int, benchmarkIterations: Int) -> Unit,
+ modifier: Modifier = Modifier,
+ onStreamImageMessage: (Model, ChatMessageImage) -> Unit = { _, _ -> },
+ onStreamEnd: (Int) -> Unit = {},
+ onStopButtonClicked: () -> Unit = {},
+ chatInputType: ChatInputType = ChatInputType.TEXT,
+ showStopButtonInInputWhenInProgress: Boolean = false,
+) {
+ val uiState by viewModel.uiState.collectAsState()
+ val modelManagerUiState by modelManagerViewModel.uiState.collectAsState()
+ val messages = uiState.messagesByModel[selectedModel.name] ?: listOf()
+ val streamingMessage = uiState.streamingMessagesByModel[selectedModel.name]
+ val snackbarHostState = remember { SnackbarHostState() }
+ val scope = rememberCoroutineScope()
+ val haptic = LocalHapticFeedback.current
+
+ var curMessage by remember { mutableStateOf("") } // Correct state
+ val focusManager = LocalFocusManager.current
+
+ // Remember the LazyListState to control scrolling
+ val listState = rememberLazyListState()
+ val density = LocalDensity.current
+ var showBenchmarkConfigsDialog by remember { mutableStateOf(false) }
+ val benchmarkMessage: MutableState = remember { mutableStateOf(null) }
+
+ var showMessageLongPressedSheet by remember { mutableStateOf(false) }
+ val longPressedMessage: MutableState = remember { mutableStateOf(null) }
+
+ // Keep track of the last message and last message content.
+ val lastMessage: MutableState = remember { mutableStateOf(null) }
+ val lastMessageContent: MutableState = remember { mutableStateOf("") }
+ if (messages.isNotEmpty()) {
+ val tmpLastMessage = messages.last()
+ lastMessage.value = tmpLastMessage
+ if (tmpLastMessage is ChatMessageText) {
+ lastMessageContent.value = tmpLastMessage.content
+ }
+ }
+
+ // Scroll the content to the bottom when any of these changes.
+ LaunchedEffect(
+ messages.size,
+ lastMessage.value,
+ lastMessageContent.value,
+ WindowInsets.ime.getBottom(density),
+ ) {
+ if (messages.isNotEmpty()) {
+ listState.animateScrollToItem(messages.lastIndex, scrollOffset = 10000)
+ }
+ }
+
+ val nestedScrollConnection = remember {
+ object : NestedScrollConnection {
+ override fun onPreScroll(available: Offset, source: NestedScrollSource): Offset {
+ // If downward scroll, clear the focus from any currently focused composable.
+ // This is useful for dismissing software keyboards or hiding text input fields
+ // when the user starts scrolling down a list.
+ if (available.y > 0) {
+ focusManager.clearFocus()
+ }
+ // Let LazyColumn handle the scroll
+ return Offset.Zero
+ }
+ }
+ }
+
+ val modelInitializationStatus =
+ modelManagerUiState.modelInitializationStatus[selectedModel.name]
+
+ Column(
+ modifier = modifier.imePadding()
+ ) {
+ Box(contentAlignment = Alignment.BottomCenter, modifier = Modifier.weight(1f)) {
+ LazyColumn(
+ modifier = Modifier
+ .fillMaxSize()
+ .nestedScroll(nestedScrollConnection),
+ state = listState, verticalArrangement = Arrangement.Top,
+ ) {
+ items(messages) { message ->
+ val imageHistoryCurIndex = remember { mutableIntStateOf(0) }
+ var hAlign: Alignment.Horizontal = Alignment.End
+ var backgroundColor: Color = MaterialTheme.customColors.userBubbleBgColor
+ var hardCornerAtLeftOrRight = false
+ var extraPaddingStart = 48.dp
+ var extraPaddingEnd = 0.dp
+ if (message.side == ChatSide.AGENT) {
+ hAlign = Alignment.Start
+ backgroundColor = MaterialTheme.customColors.agentBubbleBgColor
+ hardCornerAtLeftOrRight = true
+ extraPaddingStart = 0.dp
+ extraPaddingEnd = 48.dp
+ } else if (message.side == ChatSide.SYSTEM) {
+ extraPaddingStart = 24.dp
+ extraPaddingEnd = 24.dp
+ if (message.type == ChatMessageType.PROMPT_TEMPLATES) {
+ extraPaddingStart = 12.dp
+ extraPaddingEnd = 12.dp
+ }
+ }
+ if (message.type == ChatMessageType.IMAGE) {
+ backgroundColor = Color.Transparent
+ }
+ 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,
+ ),
+ horizontalAlignment = hAlign,
+ ) {
+ // Sender row.
+ MessageSender(
+ message = message,
+ agentNameRes = task.agentNameRes,
+ imageHistoryCurIndex = imageHistoryCurIndex.intValue
+ )
+
+ // Message body.
+ when (message) {
+ // Loading.
+ is ChatMessageLoading -> MessageBodyLoading()
+
+ // Info.
+ is ChatMessageInfo -> MessageBodyInfo(message = message)
+
+ // Config values change.
+ is ChatMessageConfigValuesChange -> MessageBodyConfigUpdate(message = message)
+
+ // Prompt templates.
+ is ChatMessagePromptTemplates -> MessageBodyPromptTemplates(message = message,
+ task = task,
+ onPromptClicked = { template ->
+ onSendMessage(
+ selectedModel, 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
+ )
+ )
+ .background(backgroundColor)
+ if (message is ChatMessageText) {
+ messageBubbleModifier = messageBubbleModifier
+ .pointerInput(Unit) {
+ detectTapGestures(
+ onLongPress = {
+ haptic.performHapticFeedback(HapticFeedbackType.LongPress)
+ longPressedMessage.value = message
+ showMessageLongPressedSheet = true
+ },
+ )
+ }
+ }
+ Box(
+ modifier = messageBubbleModifier,
+ ) {
+ when (message) {
+ // Text
+ is ChatMessageText -> MessageBodyText(message = message)
+
+ // Image
+ is ChatMessageImage -> MessageBodyImage(message = message)
+
+ // Image with history (for image gen)
+ is ChatMessageImageWithHistory -> MessageBodyImageWithHistory(
+ message = message, imageHistoryCurIndex = imageHistoryCurIndex
+ )
+
+ // Classification result
+ 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)
+
+ else -> {}
+ }
+ }
+ if (message.side == ChatSide.AGENT) {
+ LatencyText(message = message)
+ } else if (message.side == ChatSide.USER) {
+ Row(
+ verticalAlignment = Alignment.CenterVertically,
+ 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
+ )
+ }
+
+ // Benchmark button
+ if (selectedModel.showBenchmarkButton) {
+ MessageActionButton(
+ label = stringResource(R.string.benchmark),
+ icon = Icons.Outlined.Timer,
+ onClick = {
+ if (selectedModel.taskType == TaskType.LLM_CHAT) {
+ onBenchmarkClicked(selectedModel, message, 0, 0)
+ } else {
+ showBenchmarkConfigsDialog = true
+ benchmarkMessage.value = message
+ }
+ },
+ enabled = !uiState.inProgress
+ )
+ }
+ }
+ }
+ }
+ }
+ }
+ }
+ }
+
+ // Model initialization in-progress message.
+ this@Column.AnimatedVisibility(
+ visible = modelInitializationStatus == ModelInitializationStatus.INITIALIZING,
+ enter = scaleIn() + fadeIn(),
+ exit = scaleOut() + fadeOut(),
+ modifier = Modifier.offset(y = 12.dp)
+ ) {
+ ModelInitializationStatusChip()
+ }
+
+ SnackbarHost(hostState = snackbarHostState, modifier = Modifier.padding(vertical = 4.dp))
+ }
+
+
+ // Chat input
+ when (chatInputType) {
+ ChatInputType.TEXT -> {
+ val isLlmTask = selectedModel.taskType == TaskType.LLM_CHAT
+ val notLlmStartScreen = !(messages.size == 1 && messages[0] is ChatMessagePromptTemplates)
+ MessageInputText(
+ modelManagerViewModel = modelManagerViewModel,
+ curMessage = curMessage,
+ inProgress = uiState.inProgress,
+ textFieldPlaceHolderRes = task.textInputPlaceHolderRes,
+ onValueChanged = { curMessage = it },
+ onSendMessage = {
+ onSendMessage(selectedModel, it)
+ curMessage = ""
+ },
+ onOpenPromptTemplatesClicked = {
+ onSendMessage(
+ selectedModel, ChatMessagePromptTemplates(
+ templates = selectedModel.llmPromptTemplates, showMakeYourOwn = false
+ )
+ )
+ },
+ onStopButtonClicked = onStopButtonClicked,
+ showPromptTemplatesInMenu = isLlmTask && notLlmStartScreen,
+ showStopButtonWhenInProgress = showStopButtonInInputWhenInProgress,
+ )
+ }
+
+ ChatInputType.IMAGE -> MessageInputImage(
+ disableButtons = uiState.inProgress,
+ streamingMessage = streamingMessage,
+ onImageSelected = { bitmap ->
+ onSendMessage(
+ selectedModel, ChatMessageImage(
+ bitmap = bitmap, imageBitMap = bitmap.asImageBitmap(), side = ChatSide.USER
+ )
+ )
+ },
+ onStreamImage = { bitmap ->
+ onStreamImageMessage(
+ selectedModel, ChatMessageImage(
+ bitmap = bitmap, imageBitMap = bitmap.asImageBitmap(), side = ChatSide.USER
+ )
+ )
+ },
+ onStreamEnd = onStreamEnd,
+ )
+ }
+ }
+
+ // Benchmark config dialog.
+ if (showBenchmarkConfigsDialog) {
+ 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
+
+ ModalBottomSheet(
+ onDismissRequest = { showMessageLongPressedSheet = false },
+ modifier = Modifier.wrapContentHeight(),
+ ) {
+ Column {
+ // Copy text.
+ Box(modifier = Modifier
+ .fillMaxWidth()
+ .clickable {
+ // Copy text.
+ val clipData = AnnotatedString(message.content)
+ clipboardManager.setText(clipData)
+
+ // Hide sheet.
+ showMessageLongPressedSheet = false
+
+ // 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)
+ ) {
+ Icon(
+ Icons.Rounded.ContentCopy,
+ contentDescription = "",
+ modifier = Modifier.size(18.dp)
+ )
+ Text("Copy text")
+ }
+ }
+ }
+ }
+ }
+
+ }
+}
+
+@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),
+ onSendMessage = { _, _ -> },
+ onRunAgainClicked = { _, _ -> },
+ onBenchmarkClicked = { _, _, _, _ -> },
+ )
+ }
+}
diff --git a/Android/src/app/src/main/java/com/google/aiedge/gallery/ui/common/chat/ChatView.kt b/Android/src/app/src/main/java/com/google/aiedge/gallery/ui/common/chat/ChatView.kt
new file mode 100644
index 0000000..fe7e5e0
--- /dev/null
+++ b/Android/src/app/src/main/java/com/google/aiedge/gallery/ui/common/chat/ChatView.kt
@@ -0,0 +1,306 @@
+/*
+ * 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.aiedge.gallery.ui.common.chat
+
+import android.util.Log
+import androidx.activity.compose.BackHandler
+import androidx.activity.compose.rememberLauncherForActivityResult
+import androidx.activity.result.contract.ActivityResultContracts
+import androidx.compose.animation.AnimatedVisibility
+import androidx.compose.animation.fadeIn
+import androidx.compose.animation.fadeOut
+import androidx.compose.animation.scaleIn
+import androidx.compose.animation.scaleOut
+import androidx.compose.foundation.background
+import androidx.compose.foundation.layout.Box
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.fillMaxSize
+import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.pager.HorizontalPager
+import androidx.compose.foundation.pager.rememberPagerState
+import androidx.compose.material3.ExperimentalMaterial3Api
+import androidx.compose.material3.MaterialTheme
+import androidx.compose.material3.Scaffold
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.LaunchedEffect
+import androidx.compose.runtime.collectAsState
+import androidx.compose.runtime.getValue
+import androidx.compose.runtime.mutableStateOf
+import androidx.compose.runtime.remember
+import androidx.compose.runtime.rememberCoroutineScope
+import androidx.compose.runtime.setValue
+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 com.google.aiedge.gallery.GalleryTopAppBar
+import com.google.aiedge.gallery.data.AppBarAction
+import com.google.aiedge.gallery.data.AppBarActionType
+import com.google.aiedge.gallery.data.Model
+import com.google.aiedge.gallery.data.ModelDownloadStatusType
+import com.google.aiedge.gallery.data.Task
+import com.google.aiedge.gallery.ui.common.checkNotificationPermissonAndStartDownload
+import com.google.aiedge.gallery.ui.modelmanager.ModelManagerViewModel
+import com.google.aiedge.gallery.ui.preview.PreviewChatModel
+import com.google.aiedge.gallery.ui.preview.PreviewModelManagerViewModel
+import com.google.aiedge.gallery.ui.preview.TASK_TEST1
+import com.google.aiedge.gallery.ui.theme.GalleryTheme
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.delay
+import kotlinx.coroutines.launch
+import kotlin.math.absoluteValue
+
+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
+ * manages model initialization, cleanup, and download status, and handles navigation and system
+ * back gestures.
+ */
+@OptIn(ExperimentalMaterial3Api::class)
+@Composable
+fun ChatView(
+ task: Task,
+ viewModel: ChatViewModel,
+ modelManagerViewModel: ModelManagerViewModel,
+ onSendMessage: (Model, ChatMessage) -> Unit,
+ onRunAgainClicked: (Model, ChatMessage) -> Unit,
+ onBenchmarkClicked: (Model, ChatMessage, Int, Int) -> Unit,
+ navigateUp: () -> Unit,
+ modifier: Modifier = Modifier,
+ onStreamImageMessage: (Model, ChatMessageImage) -> Unit = { _, _ -> },
+ onStopButtonClicked: (Model) -> Unit = {},
+ chatInputType: ChatInputType = ChatInputType.TEXT,
+ showStopButtonInInputWhenInProgress: Boolean = false,
+) {
+ val modelManagerUiState by modelManagerViewModel.uiState.collectAsState()
+ val selectedModel = modelManagerUiState.selectedModel
+
+ val pagerState = rememberPagerState(initialPage = task.models.indexOf(selectedModel),
+ pageCount = { task.models.size })
+ val context = LocalContext.current
+ val scope = rememberCoroutineScope()
+
+ val launcher = rememberLauncherForActivityResult(
+ ActivityResultContracts.RequestPermission()
+ ) {
+ modelManagerViewModel.downloadModel(selectedModel)
+ }
+
+ val handleNavigateUp = {
+ navigateUp()
+
+ // clean up all models.
+ scope.launch(Dispatchers.Default) {
+ for (model in task.models) {
+ modelManagerViewModel.cleanupModel(model = model)
+ }
+ }
+ }
+
+ // Initialize model when model/download state changes.
+ val status = modelManagerUiState.modelDownloadStatus[selectedModel.name]
+ LaunchedEffect(status, selectedModel.name) {
+ if (status?.status == ModelDownloadStatusType.SUCCEEDED) {
+ Log.d(TAG, "Initializing model '${selectedModel.name}' from ChatView launched effect")
+ modelManagerViewModel.initializeModel(context, model = selectedModel)
+ }
+ }
+
+ // 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 '${selectedModel.name}'. Updating selected model."
+ )
+ if (curSelectedModel.name != selectedModel.name) {
+ modelManagerViewModel.cleanupModel(model = selectedModel)
+ }
+ modelManagerViewModel.selectModel(curSelectedModel)
+ }
+
+ // Handle system's edge swipe.
+ BackHandler {
+ handleNavigateUp()
+ }
+
+ Scaffold(modifier = modifier, topBar = {
+ GalleryTopAppBar(
+ title = task.type.label,
+ leftAction = AppBarAction(actionType = AppBarActionType.NAVIGATE_UP, actionFn = {
+ handleNavigateUp()
+ }),
+ rightAction = AppBarAction(actionType = AppBarActionType.NO_ACTION, actionFn = {}),
+ )
+ }) { innerPadding ->
+ Box {
+ // A horizontal scrollable pager to switch between models.
+ HorizontalPager(state = pagerState) { pageIndex ->
+ val curSelectedModel = task.models[pageIndex]
+
+ // Calculate the alpha of the current page based on how far they are from the center.
+ val pageOffset = (
+ (pagerState.currentPage - pageIndex) + pagerState
+ .currentPageOffsetFraction
+ ).absoluteValue
+ val curAlpha = 1f - pageOffset.coerceIn(0f, 1f)
+
+ Column(
+ modifier = Modifier
+ .padding(innerPadding)
+ .fillMaxSize()
+ .background(MaterialTheme.colorScheme.surface)
+ ) {
+ // Model selector at the top.
+ ModelSelector(
+ model = curSelectedModel,
+ task = task,
+ modelManagerViewModel = modelManagerViewModel,
+ onConfigChanged = { old, new ->
+ viewModel.addConfigChangedMessage(
+ oldConfigValues = old,
+ newConfigValues = new,
+ model = curSelectedModel
+ )
+ },
+ modifier = Modifier.fillMaxWidth(),
+ contentAlpha = curAlpha,
+ )
+
+ // Manages the conditional display of UI elements (download model button and downloading
+ // animation) based on the corresponding download status.
+ //
+ // It uses delayed visibility ensuring they are shown only after a short delay if their
+ // respective conditions remain true. This prevents UI flickering and provides a smoother
+ // user experience.
+ val curStatus = modelManagerUiState.modelDownloadStatus[curSelectedModel.name]
+ var shouldShowDownloadingAnimation by remember { mutableStateOf(false) }
+ var downloadingAnimationConditionMet by remember { mutableStateOf(false) }
+ var shouldShowDownloadModelButton by remember { mutableStateOf(false) }
+ var downloadModelButtonConditionMet by remember { mutableStateOf(false) }
+
+ downloadingAnimationConditionMet =
+ curStatus?.status == ModelDownloadStatusType.IN_PROGRESS ||
+ curStatus?.status == ModelDownloadStatusType.PARTIALLY_DOWNLOADED ||
+ curStatus?.status == ModelDownloadStatusType.UNZIPPING
+ downloadModelButtonConditionMet =
+ curStatus?.status == ModelDownloadStatusType.FAILED ||
+ curStatus?.status == ModelDownloadStatusType.NOT_DOWNLOADED
+
+ LaunchedEffect(downloadingAnimationConditionMet) {
+ if (downloadingAnimationConditionMet) {
+ delay(100)
+ shouldShowDownloadingAnimation = true
+ } else {
+ shouldShowDownloadingAnimation = false
+ }
+ }
+
+ LaunchedEffect(downloadModelButtonConditionMet) {
+ if (downloadModelButtonConditionMet) {
+ delay(700)
+ shouldShowDownloadModelButton = true
+ } else {
+ shouldShowDownloadModelButton = false
+ }
+ }
+
+ AnimatedVisibility(
+ visible = shouldShowDownloadingAnimation,
+ enter = scaleIn(initialScale = 0.9f) + fadeIn(),
+ exit = scaleOut(targetScale = 0.9f) + fadeOut()
+ ) {
+ Box(
+ modifier = Modifier.fillMaxSize(),
+ contentAlignment = Alignment.Center
+ ) {
+ ModelDownloadingAnimation()
+ }
+ }
+
+ AnimatedVisibility(
+ visible = shouldShowDownloadModelButton,
+ enter = fadeIn(),
+ exit = fadeOut()
+ ) {
+ ModelNotDownloaded(modifier = Modifier.weight(1f), onClicked = {
+ checkNotificationPermissonAndStartDownload(
+ context = context,
+ launcher = launcher,
+ modelManagerViewModel = modelManagerViewModel,
+ model = curSelectedModel
+ )
+ })
+ }
+
+ // The main messages panel.
+ if (curStatus?.status == ModelDownloadStatusType.SUCCEEDED) {
+ ChatPanel(
+ modelManagerViewModel = modelManagerViewModel,
+ task = task,
+ selectedModel = curSelectedModel,
+ viewModel = viewModel,
+ onSendMessage = onSendMessage,
+ onRunAgainClicked = onRunAgainClicked,
+ onBenchmarkClicked = onBenchmarkClicked,
+ onStreamImageMessage = onStreamImageMessage,
+ onStreamEnd = { averageFps ->
+ viewModel.addMessage(
+ model = curSelectedModel,
+ message = ChatMessageInfo(content = "Live camera session ended. Average FPS: $averageFps")
+ )
+ },
+ onStopButtonClicked = {
+ onStopButtonClicked(curSelectedModel)
+ },
+ modifier = Modifier
+ .weight(1f)
+ .graphicsLayer { alpha = curAlpha },
+ chatInputType = chatInputType,
+ showStopButtonInInputWhenInProgress = showStopButtonInInputWhenInProgress,
+ )
+ }
+ }
+ }
+ }
+ }
+}
+
+@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/aiedge/gallery/ui/common/chat/ChatViewModel.kt b/Android/src/app/src/main/java/com/google/aiedge/gallery/ui/common/chat/ChatViewModel.kt
new file mode 100644
index 0000000..0d3fde7
--- /dev/null
+++ b/Android/src/app/src/main/java/com/google/aiedge/gallery/ui/common/chat/ChatViewModel.kt
@@ -0,0 +1,189 @@
+/*
+ * 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.aiedge.gallery.ui.common.chat
+
+import android.util.Log
+import androidx.lifecycle.ViewModel
+import com.google.aiedge.gallery.data.Model
+import com.google.aiedge.gallery.data.Task
+import kotlinx.coroutines.flow.MutableStateFlow
+import kotlinx.coroutines.flow.asStateFlow
+import kotlinx.coroutines.flow.update
+
+private const val TAG = "AGChatViewModel"
+private const val START_THINKING = "***Thinking...***"
+private const val DONE_THINKING = "***Done thinking***"
+
+data class ChatUiState(
+ /**
+ * Indicates whether the runtime is currently processing a message.
+ */
+ val inProgress: Boolean = false,
+
+ /**
+ * 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.
+ */
+ val streamingMessagesByModel: Map = mapOf(),
+)
+
+/**
+ * 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()
+
+ fun addMessage(model: Model, message: ChatMessage) {
+ val newMessagesByModel = _uiState.value.messagesByModel.toMutableMap()
+ val newMessages = newMessagesByModel[model.name]?.toMutableList()
+ if (newMessages != null) {
+ newMessagesByModel[model.name] = newMessages
+ // Remove prompt template message if it is the current last message.
+ if (newMessages.size > 0 && newMessages.last().type == ChatMessageType.PROMPT_TEMPLATES) {
+ newMessages.removeAt(newMessages.size - 1)
+ }
+ newMessages.add(message)
+ }
+ _uiState.update { _uiState.value.copy(messagesByModel = newMessagesByModel) }
+ }
+
+ fun removeLastMessage(model: Model) {
+ val newMessagesByModel = _uiState.value.messagesByModel.toMutableMap()
+ val newMessages = newMessagesByModel[model.name]?.toMutableList() ?: mutableListOf()
+ if (newMessages.size > 0) {
+ newMessages.removeAt(newMessages.size - 1)
+ }
+ newMessagesByModel[model.name] = newMessages
+ _uiState.update { _uiState.value.copy(messagesByModel = newMessagesByModel) }
+ }
+
+ fun getLastMessage(model: Model): ChatMessage? {
+ return (_uiState.value.messagesByModel[model.name] ?: listOf()).lastOrNull()
+ }
+
+ fun updateLastMessageContentIncrementally(
+ model: Model,
+ partialContent: String,
+ latencyMs: Float,
+ ) {
+ val newMessagesByModel = _uiState.value.messagesByModel.toMutableMap()
+ val newMessages = newMessagesByModel[model.name]?.toMutableList() ?: mutableListOf()
+ if (newMessages.size > 0) {
+ val lastMessage = newMessages.last()
+ if (lastMessage is ChatMessageText) {
+ var newContent = "${lastMessage.content}${partialContent}"
+ // TODO: special handling for deepseek to remove the tag.
+
+ // Add "thinking" and "done thinking" around the thinking content.
+ newContent = newContent
+ .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)
+ }
+ }
+
+ val newLastMessage = ChatMessageText(
+ content = newContent,
+ side = lastMessage.side,
+ latencyMs = latencyMs,
+ )
+ newMessages.removeAt(newMessages.size - 1)
+ newMessages.add(newLastMessage)
+ }
+ }
+ newMessagesByModel[model.name] = newMessages
+ val newUiState = _uiState.value.copy(messagesByModel = newMessagesByModel)
+ _uiState.update { newUiState }
+ }
+
+ fun replaceLastMessage(model: Model, message: ChatMessage, type: ChatMessageType) {
+ val newMessagesByModel = _uiState.value.messagesByModel.toMutableMap()
+ val newMessages = newMessagesByModel[model.name]?.toMutableList() ?: mutableListOf()
+ if (newMessages.size > 0) {
+ val index = newMessages.indexOfLast { it.type == type }
+ if (index >= 0) {
+ newMessages[index] = message
+ }
+ }
+ newMessagesByModel[model.name] = newMessages
+ val newUiState = _uiState.value.copy(messagesByModel = newMessagesByModel)
+ _uiState.update { newUiState }
+ }
+
+ fun replaceMessage(model: Model, index: Int, message: ChatMessage) {
+ val newMessagesByModel = _uiState.value.messagesByModel.toMutableMap()
+ val newMessages = newMessagesByModel[model.name]?.toMutableList() ?: mutableListOf()
+ if (newMessages.size > 0) {
+ newMessages[index] = message
+ }
+ newMessagesByModel[model.name] = newMessages
+ val newUiState = _uiState.value.copy(messagesByModel = newMessagesByModel)
+ _uiState.update { newUiState }
+ }
+
+ fun updateStreamingMessage(model: Model, message: ChatMessage) {
+ val newStreamingMessagesByModel = _uiState.value.streamingMessagesByModel.toMutableMap()
+ newStreamingMessagesByModel[model.name] = message
+ _uiState.update { _uiState.value.copy(streamingMessagesByModel = newStreamingMessagesByModel) }
+ }
+
+ fun setInProgress(inProgress: Boolean) {
+ _uiState.update { _uiState.value.copy(inProgress = inProgress) }
+ }
+
+ fun isInProgress(): Boolean {
+ return _uiState.value.inProgress
+ }
+
+ fun addConfigChangedMessage(
+ 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
+ )
+ addMessage(message = message, model = model)
+ }
+
+ private fun createUiState(task: Task): ChatUiState {
+ val messagesByModel: MutableMap> = mutableMapOf()
+ for (model in task.models) {
+ val messages: MutableList = mutableListOf()
+ if (model.llmPromptTemplates.isNotEmpty()) {
+ messages.add(ChatMessagePromptTemplates(templates = model.llmPromptTemplates))
+ }
+ messagesByModel[model.name] = messages
+ }
+ return ChatUiState(
+ messagesByModel = messagesByModel
+ )
+ }
+}
diff --git a/Android/src/app/src/main/java/com/google/aiedge/gallery/ui/common/chat/ConfigDialog.kt b/Android/src/app/src/main/java/com/google/aiedge/gallery/ui/common/chat/ConfigDialog.kt
new file mode 100644
index 0000000..84990b3
--- /dev/null
+++ b/Android/src/app/src/main/java/com/google/aiedge/gallery/ui/common/chat/ConfigDialog.kt
@@ -0,0 +1,313 @@
+/*
+ * 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.aiedge.gallery.ui.common.chat
+
+import android.util.Log
+import androidx.compose.foundation.border
+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.fillMaxWidth
+import androidx.compose.foundation.layout.height
+import androidx.compose.foundation.layout.offset
+import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.layout.width
+import androidx.compose.foundation.shape.RoundedCornerShape
+import androidx.compose.foundation.text.BasicTextField
+import androidx.compose.foundation.text.KeyboardOptions
+import androidx.compose.material3.Button
+import androidx.compose.material3.Card
+import androidx.compose.material3.MaterialTheme
+import androidx.compose.material3.SegmentedButton
+import androidx.compose.material3.SegmentedButtonDefaults
+import androidx.compose.material3.SingleChoiceSegmentedButtonRow
+import androidx.compose.material3.Slider
+import androidx.compose.material3.Switch
+import androidx.compose.material3.Text
+import androidx.compose.material3.TextButton
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.getValue
+import androidx.compose.runtime.mutableIntStateOf
+import androidx.compose.runtime.mutableStateMapOf
+import androidx.compose.runtime.mutableStateOf
+import androidx.compose.runtime.remember
+import androidx.compose.runtime.setValue
+import androidx.compose.runtime.snapshots.SnapshotStateMap
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.focus.FocusRequester
+import androidx.compose.ui.focus.focusRequester
+import androidx.compose.ui.focus.onFocusChanged
+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.aiedge.gallery.data.BooleanSwitchConfig
+import com.google.aiedge.gallery.data.Config
+import com.google.aiedge.gallery.data.NumberSliderConfig
+import com.google.aiedge.gallery.data.SegmentedButtonConfig
+import com.google.aiedge.gallery.data.ValueType
+import com.google.aiedge.gallery.ui.preview.MODEL_TEST1
+import com.google.aiedge.gallery.ui.theme.GalleryTheme
+import com.google.aiedge.gallery.ui.theme.labelSmallNarrow
+import kotlin.Double.Companion.NaN
+
+private const val TAG = "AGConfigDialog"
+
+/**
+ * Displays a configuration dialog allowing users to modify settings through various input controls.
+ */
+@Composable
+fun ConfigDialog(
+ title: String,
+ configs: List,
+ initialValues: Map,
+ onDismissed: () -> Unit,
+ onOk: (Map) -> Unit,
+ okBtnLabel: String = "OK",
+ subtitle: String = "",
+ showCancel: Boolean = true,
+) {
+ val values: SnapshotStateMap = remember {
+ mutableStateMapOf().apply {
+ putAll(initialValues)
+ }
+ }
+
+ Dialog(onDismissRequest = onDismissed) {
+ Card(modifier = Modifier.fillMaxWidth(), shape = RoundedCornerShape(16.dp)) {
+ Column(
+ 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)
+ )
+ // Subtitle.
+ if (subtitle.isNotEmpty()) {
+ Text(
+ subtitle,
+ style = labelSmallNarrow,
+ color = MaterialTheme.colorScheme.onSurfaceVariant,
+ modifier = Modifier.offset(y = (-6).dp)
+ )
+ }
+ }
+
+
+ // List of config rows.
+ for (config in configs) {
+ when (config) {
+ // Number slider.
+ is NumberSliderConfig -> {
+ NumberSliderRow(config = config, values = values)
+ }
+
+ // Boolean switch.
+ is BooleanSwitchConfig -> {
+ BooleanSwitchRow(config = config, values = values)
+ }
+
+ is SegmentedButtonConfig -> {
+ SegmentedButtonRow(config = config, values = values)
+ }
+
+ else -> {}
+ }
+ }
+ Row(
+ modifier = Modifier
+ .fillMaxWidth()
+ .padding(top = 8.dp),
+ horizontalArrangement = Arrangement.End,
+ ) {
+ // Cancel button.
+ if (showCancel) {
+ TextButton(
+ onClick = { onDismissed() },
+ ) {
+ Text("Cancel")
+ }
+ }
+
+ // Ok button
+ Button(
+ onClick = {
+ Log.d(TAG, "Values from dialog: $values")
+ onOk(values.toMap())
+ },
+ ) {
+ Text(okBtnLabel)
+ }
+ }
+ }
+ }
+ }
+}
+
+/**
+ * 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.
+ */
+@Composable
+fun NumberSliderRow(config: NumberSliderConfig, values: SnapshotStateMap) {
+ Column(modifier = Modifier.fillMaxWidth()) {
+ // Field label.
+ Text(config.key.label, style = MaterialTheme.typography.titleSmall)
+
+ // Controls row.
+ Row(
+ modifier = Modifier.fillMaxWidth(), verticalAlignment = Alignment.CenterVertically
+ ) {
+ var isFocused by remember { mutableStateOf(false) }
+ val focusRequester = remember { FocusRequester() }
+
+ // Number slider.
+ val sliderValue = try {
+ values[config.key.label] as Float
+ } catch (e: Exception) {
+ 0f
+ }
+ Slider(modifier = Modifier
+ .height(24.dp)
+ .weight(1f),
+ value = sliderValue,
+ valueRange = config.sliderMin..config.sliderMax,
+ onValueChange = { values[config.key.label] = it })
+
+ Spacer(modifier = Modifier.width(8.dp))
+
+ // Text field.
+ 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()}"
+ }
+
+ else -> {
+ ""
+ }
+ }
+ } catch (e: Exception) {
+ ""
+ }
+ // A smaller text field.
+ BasicTextField(
+ value = textFieldValue,
+ modifier = Modifier
+ .width(80.dp)
+ .focusRequester(focusRequester)
+ .onFocusChanged {
+ isFocused = it.isFocused
+ },
+ keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number),
+ onValueChange = {
+ if (it.isNotEmpty()) {
+ values[config.key.label] = it.toFloatOrNull() ?: NaN
+ } else {
+ values[config.key.label] = NaN
+ }
+ },
+ ) { innerTextField ->
+ 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)
+ )
+ ) {
+ Box(modifier = Modifier.padding(8.dp)) {
+ innerTextField()
+ }
+ }
+ }
+ }
+ }
+}
+
+/**
+ * Composable function to display a row with a boolean switch.
+ *
+ * This function renders a row containing a label and a switch, allowing users to toggle
+ * a boolean value.
+ */
+@Composable
+fun BooleanSwitchRow(config: BooleanSwitchConfig, values: SnapshotStateMap) {
+ 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 })
+ }
+}
+
+@Composable
+fun SegmentedButtonRow(config: SegmentedButtonConfig, values: SnapshotStateMap) {
+ var selectedIndex by remember { mutableIntStateOf(config.options.indexOf(values[config.key.label])) }
+
+ Column(modifier = Modifier.fillMaxWidth()) {
+ Text(config.key.label, style = MaterialTheme.typography.titleSmall)
+ SingleChoiceSegmentedButtonRow {
+ config.options.forEachIndexed { index, label ->
+ SegmentedButton(shape = SegmentedButtonDefaults.itemShape(
+ index = index, count = config.options.size
+ ), onClick = {
+ selectedIndex = index
+ values[config.key.label] = label
+ }, selected = index == selectedIndex, 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
+ }
+
+ 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/aiedge/gallery/ui/common/chat/DataCard.kt b/Android/src/app/src/main/java/com/google/aiedge/gallery/ui/common/chat/DataCard.kt
new file mode 100644
index 0000000..57e6c4e
--- /dev/null
+++ b/Android/src/app/src/main/java/com/google/aiedge/gallery/ui/common/chat/DataCard.kt
@@ -0,0 +1,93 @@
+/*
+ * 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.aiedge.gallery.ui.common.chat
+
+import androidx.compose.foundation.layout.Arrangement
+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.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.aiedge.gallery.ui.theme.GalleryTheme
+import com.google.aiedge.gallery.ui.theme.bodySmallMediumNarrow
+import com.google.aiedge.gallery.ui.theme.bodySmallMediumNarrowBold
+import com.google.aiedge.gallery.ui.theme.labelSmallNarrow
+import com.google.aiedge.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.
+ */
+@Composable
+fun DataCard(
+ label: String,
+ value: Float?,
+ unit: String,
+ highlight: Boolean = false,
+ showPlaceholder: Boolean = false
+) {
+ var strValue = "-"
+ Column {
+ Text(label, style = labelSmallNarrowMedium)
+ if (showPlaceholder) {
+ Text("-", style = bodySmallMediumNarrow)
+ } else {
+ strValue = if (value == null) "-" else "%.2f".format(value)
+ if (highlight) {
+ 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)
+ )
+ }
+ }
+}
+
+@Preview(showBackground = true)
+@Composable
+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
+ )
+ DataCard(
+ label = "average", value = 12.3f, unit = "ms", highlight = false, showPlaceholder = false
+ )
+ DataCard(
+ 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/aiedge/gallery/ui/common/chat/LiveCameraDialog.kt b/Android/src/app/src/main/java/com/google/aiedge/gallery/ui/common/chat/LiveCameraDialog.kt
new file mode 100644
index 0000000..8099e45
--- /dev/null
+++ b/Android/src/app/src/main/java/com/google/aiedge/gallery/ui/common/chat/LiveCameraDialog.kt
@@ -0,0 +1,226 @@
+/*
+ * 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.aiedge.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/aiedge/gallery/ui/common/chat/MarkdownText.kt b/Android/src/app/src/main/java/com/google/aiedge/gallery/ui/common/chat/MarkdownText.kt
new file mode 100644
index 0000000..b0aca49
--- /dev/null
+++ b/Android/src/app/src/main/java/com/google/aiedge/gallery/ui/common/chat/MarkdownText.kt
@@ -0,0 +1,76 @@
+/*
+ * 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.aiedge.gallery.ui.common.chat
+
+import androidx.compose.material3.MaterialTheme
+import androidx.compose.material3.ProvideTextStyle
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.CompositionLocalProvider
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.text.TextStyle
+import androidx.compose.ui.text.font.FontFamily
+import androidx.compose.ui.tooling.preview.Preview
+import com.google.aiedge.gallery.ui.theme.GalleryTheme
+import com.halilibo.richtext.commonmark.Markdown
+import com.halilibo.richtext.ui.CodeBlockStyle
+import com.halilibo.richtext.ui.RichTextStyle
+import com.halilibo.richtext.ui.material3.RichText
+
+/**
+ * Composable function to display Markdown-formatted text.
+ */
+@Composable
+fun MarkdownText(
+ text: String,
+ modifier: Modifier = Modifier,
+ smallFontSize: Boolean = false
+) {
+ val fontSize =
+ if (smallFontSize) MaterialTheme.typography.bodySmall.fontSize else MaterialTheme.typography.bodyMedium.fontSize
+ CompositionLocalProvider {
+ ProvideTextStyle(
+ value = TextStyle(
+ fontSize = fontSize,
+ lineHeight = fontSize * 1.2,
+ )
+ ) {
+ RichText(
+ modifier = modifier,
+ style = RichTextStyle(
+ codeBlockStyle = CodeBlockStyle(
+ textStyle = TextStyle(
+ fontSize = MaterialTheme.typography.bodySmall.fontSize,
+ fontFamily = FontFamily.Monospace,
+ )
+ )
+ ),
+ ) {
+ Markdown(
+ content = text
+ )
+ }
+ }
+ }
+}
+
+@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/aiedge/gallery/ui/common/chat/MessageActionButton.kt b/Android/src/app/src/main/java/com/google/aiedge/gallery/ui/common/chat/MessageActionButton.kt
new file mode 100644
index 0000000..ddf3467
--- /dev/null
+++ b/Android/src/app/src/main/java/com/google/aiedge/gallery/ui/common/chat/MessageActionButton.kt
@@ -0,0 +1,95 @@
+/*
+ * 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.aiedge.gallery.ui.common.chat
+
+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
+import androidx.compose.runtime.Composable
+import androidx.compose.ui.Alignment
+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.aiedge.gallery.ui.theme.GalleryTheme
+import com.google.aiedge.gallery.ui.theme.bodySmallNarrow
+
+/**
+ * Composable function to display an action button below a chat message.
+ */
+@Composable
+fun MessageActionButton(
+ label: String,
+ icon: ImageVector,
+ onClick: () -> Unit,
+ enabled: Boolean = true
+) {
+ val modifier = 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) modifier.clickable { onClick() } else modifier,
+ verticalAlignment = Alignment.CenterVertically,
+ ) {
+ Icon(
+ 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)
+ )
+ }
+}
+
+@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/aiedge/gallery/ui/common/chat/MessageBodyBenchmark.kt b/Android/src/app/src/main/java/com/google/aiedge/gallery/ui/common/chat/MessageBodyBenchmark.kt
new file mode 100644
index 0000000..b21fb4c
--- /dev/null
+++ b/Android/src/app/src/main/java/com/google/aiedge/gallery/ui/common/chat/MessageBodyBenchmark.kt
@@ -0,0 +1,140 @@
+/*
+ * 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.aiedge.gallery.ui.common.chat
+
+import androidx.compose.foundation.background
+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.fillMaxWidth
+import androidx.compose.foundation.layout.height
+import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.layout.width
+import androidx.compose.foundation.shape.RoundedCornerShape
+import androidx.compose.material3.MaterialTheme
+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.aiedge.gallery.ui.theme.GalleryTheme
+import kotlin.math.max
+
+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.
+ */
+@Composable
+fun MessageBodyBenchmark(message: ChatMessageBenchmarkResult) {
+ Column(
+ modifier = Modifier
+ .padding(12.dp)
+ .fillMaxWidth(),
+ verticalArrangement = Arrangement.spacedBy(8.dp)
+ ) {
+ // Data cards.
+ 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()
+ )
+ }
+ }
+
+ // Histogram
+ if (message.histogram.buckets.isNotEmpty()) {
+ Row(
+ horizontalArrangement = Arrangement.spacedBy(2.dp)
+ ) {
+ for ((index, count) in message.histogram.buckets.withIndex()) {
+ var barBgColor = MaterialTheme.colorScheme.onSurfaceVariant
+ var alpha = 0.3f
+ if (count != 0) {
+ alpha = 0.5f
+ }
+ if (index == message.histogram.highlightBucketIndex) {
+ barBgColor = MaterialTheme.colorScheme.primary
+ alpha = 0.8f
+ }
+ // Bar container.
+ Column(
+ 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)
+ )
+ }
+ }
+ }
+ }
+ }
+}
+
+@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
diff --git a/Android/src/app/src/main/java/com/google/aiedge/gallery/ui/common/chat/MessageBodyBenchmarkLlm.kt b/Android/src/app/src/main/java/com/google/aiedge/gallery/ui/common/chat/MessageBodyBenchmarkLlm.kt
new file mode 100644
index 0000000..3b25de1
--- /dev/null
+++ b/Android/src/app/src/main/java/com/google/aiedge/gallery/ui/common/chat/MessageBodyBenchmarkLlm.kt
@@ -0,0 +1,76 @@
+/*
+ * 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.aiedge.gallery.ui.common.chat
+
+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.wrapContentWidth
+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.aiedge.gallery.ui.theme.GalleryTheme
+
+/**
+ * Composable function to display benchmark LLM results within a chat message.
+ *
+ * This function renders benchmark statistics (e.g., various token speed) in data cards
+ */
+@Composable
+fun MessageBodyBenchmarkLlm(message: ChatMessageBenchmarkLlmResult) {
+ Column(
+ modifier = Modifier
+ .padding(12.dp)
+ .wrapContentWidth(),
+ verticalArrangement = Arrangement.spacedBy(8.dp)
+ ) {
+ // Data cards.
+ Row(
+ modifier = Modifier.wrapContentWidth(), horizontalArrangement = Arrangement.spacedBy(16.dp)
+ ) {
+ for (stat in message.orderedStats) {
+ 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,
+ )
+ )
+ }
+}
diff --git a/Android/src/app/src/main/java/com/google/aiedge/gallery/ui/common/chat/MessageBodyClassification.kt b/Android/src/app/src/main/java/com/google/aiedge/gallery/ui/common/chat/MessageBodyClassification.kt
new file mode 100644
index 0000000..6508a45
--- /dev/null
+++ b/Android/src/app/src/main/java/com/google/aiedge/gallery/ui/common/chat/MessageBodyClassification.kt
@@ -0,0 +1,115 @@
+/*
+ * 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.aiedge.gallery.ui.common.chat
+
+import androidx.compose.foundation.background
+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.fillMaxWidth
+import androidx.compose.foundation.layout.height
+import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.shape.CircleShape
+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.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.aiedge.gallery.ui.theme.GalleryTheme
+
+val CLASSIFICATION_BAR_HEIGHT = 8.dp
+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.
+ */
+@Composable
+fun MessageBodyClassification(
+ message: ChatMessageClassification,
+ modifier: Modifier = Modifier,
+ oneLineLabel: Boolean = false,
+) {
+ Column(
+ modifier = modifier.padding(12.dp)
+ ) {
+ for (classification in message.classifications) {
+ 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)
+ )
+ // Classification score.
+ Text(
+ "%.2f".format(classification.score),
+ style = MaterialTheme.typography.bodySmall,
+ 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)
+ )
+ Box(
+ modifier = Modifier
+ .fillMaxWidth(classification.score)
+ .height(CLASSIFICATION_BAR_HEIGHT)
+ .clip(CircleShape)
+ .background(classification.color)
+ )
+ }
+ Spacer(modifier = Modifier.height(6.dp))
+ }
+ }
+}
+
+@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
diff --git a/Android/src/app/src/main/java/com/google/aiedge/gallery/ui/common/chat/MessageBodyConfigUpdate.kt b/Android/src/app/src/main/java/com/google/aiedge/gallery/ui/common/chat/MessageBodyConfigUpdate.kt
new file mode 100644
index 0000000..b05af41
--- /dev/null
+++ b/Android/src/app/src/main/java/com/google/aiedge/gallery/ui/common/chat/MessageBodyConfigUpdate.kt
@@ -0,0 +1,144 @@
+/*
+ * 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.aiedge.gallery.ui.common.chat
+
+import androidx.compose.foundation.background
+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.fillMaxWidth
+import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.layout.width
+import androidx.compose.foundation.shape.RoundedCornerShape
+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.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.aiedge.gallery.data.ConfigKey
+import com.google.aiedge.gallery.ui.common.convertValueToTargetType
+import com.google.aiedge.gallery.ui.common.getConfigValueString
+import com.google.aiedge.gallery.ui.preview.MODEL_TEST1
+import com.google.aiedge.gallery.ui.theme.GalleryTheme
+import com.google.aiedge.gallery.ui.theme.bodySmallNarrow
+import com.google.aiedge.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.
+ */
+@Composable
+fun MessageBodyConfigUpdate(message: ChatMessageConfigValuesChange) {
+ Row(
+ modifier = Modifier.fillMaxWidth(),
+ horizontalArrangement = Arrangement.Center,
+ ) {
+ Box(
+ modifier = Modifier
+ .clip(RoundedCornerShape(4.dp))
+ .background(MaterialTheme.colorScheme.tertiaryContainer)
+ ) {
+ Column(modifier = Modifier.padding(8.dp)) {
+ // Title.
+ Text(
+ "Configs updated",
+ color = MaterialTheme.colorScheme.onTertiaryContainer,
+ style = titleSmaller,
+ )
+
+ Row(modifier = Modifier.padding(top = 8.dp)) {
+ // Keys
+ Column {
+ for (config in message.model.configs) {
+ Text(
+ "${config.key.label}:",
+ style = bodySmallNarrow,
+ modifier = Modifier.alpha(0.6f),
+ )
+ }
+ }
+
+ Spacer(modifier = Modifier.width(4.dp))
+
+ // Values
+ 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
+ )
+ if (oldValue == newValue) {
+ Text("$newValue", style = bodySmallNarrow)
+ } else {
+ Row(verticalAlignment = Alignment.CenterVertically) {
+ Text(
+ getConfigValueString(oldValue, config), style = bodySmallNarrow
+ )
+ Text(
+ "▸",
+ style = bodySmallNarrow.copy(fontSize = 12.sp),
+ modifier = Modifier.padding(start = 4.dp, end = 4.dp)
+ )
+ Text(
+ getConfigValueString(newValue, config),
+ style = bodySmallNarrow.copy(fontWeight = FontWeight.Bold),
+ color = MaterialTheme.colorScheme.primary,
+ )
+ }
+ }
+ }
+ }
+ }
+ }
+ }
+ }
+}
+
+@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
diff --git a/Android/src/app/src/main/java/com/google/aiedge/gallery/ui/common/chat/MessageBodyImage.kt b/Android/src/app/src/main/java/com/google/aiedge/gallery/ui/common/chat/MessageBodyImage.kt
new file mode 100644
index 0000000..b5a380f
--- /dev/null
+++ b/Android/src/app/src/main/java/com/google/aiedge/gallery/ui/common/chat/MessageBodyImage.kt
@@ -0,0 +1,43 @@
+/*
+ * 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.aiedge.gallery.ui.common.chat
+
+import androidx.compose.foundation.Image
+import androidx.compose.foundation.layout.height
+import androidx.compose.foundation.layout.width
+import androidx.compose.runtime.Composable
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.layout.ContentScale
+import androidx.compose.ui.unit.dp
+
+@Composable
+fun MessageBodyImage(message: ChatMessageImage) {
+ val bitmapWidth = message.bitmap.width
+ val bitmapHeight = message.bitmap.height
+ val imageWidth =
+ if (bitmapWidth >= bitmapHeight) 200 else (200f / bitmapHeight * bitmapWidth).toInt()
+ val imageHeight =
+ if (bitmapHeight >= bitmapWidth) 200 else (200f / bitmapWidth * bitmapHeight).toInt()
+ Image(
+ bitmap = message.imageBitMap,
+ contentDescription = "",
+ 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/aiedge/gallery/ui/common/chat/MessageBodyImageWithHistory.kt b/Android/src/app/src/main/java/com/google/aiedge/gallery/ui/common/chat/MessageBodyImageWithHistory.kt
new file mode 100644
index 0000000..9a6c162
--- /dev/null
+++ b/Android/src/app/src/main/java/com/google/aiedge/gallery/ui/common/chat/MessageBodyImageWithHistory.kt
@@ -0,0 +1,91 @@
+/*
+ * 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.aiedge.gallery.ui.common.chat
+
+import androidx.compose.foundation.Image
+import androidx.compose.foundation.gestures.detectHorizontalDragGestures
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.height
+import androidx.compose.foundation.layout.width
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.LaunchedEffect
+import androidx.compose.runtime.MutableIntState
+import androidx.compose.runtime.MutableState
+import androidx.compose.runtime.getValue
+import androidx.compose.runtime.mutableFloatStateOf
+import androidx.compose.runtime.mutableIntStateOf
+import androidx.compose.runtime.mutableStateOf
+import androidx.compose.runtime.remember
+import androidx.compose.runtime.setValue
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.input.pointer.pointerInput
+import androidx.compose.ui.layout.ContentScale
+import androidx.compose.ui.unit.dp
+
+/**
+ * Composable function to display an image message with history, allowing users to navigate through
+ * different versions by sliding on the image.
+ */
+@Composable
+fun MessageBodyImageWithHistory(
+ message: ChatMessageImageWithHistory,
+ imageHistoryCurIndex: MutableIntState
+) {
+ val prevMessage: MutableState = remember { mutableStateOf(null) }
+
+ LaunchedEffect(message) {
+ imageHistoryCurIndex.intValue = message.bitmaps.size - 1
+ prevMessage.value = message
+ }
+
+ Column {
+ val curImage = message.bitmaps[imageHistoryCurIndex.intValue]
+ val curImageBitmap = message.imageBitMaps[imageHistoryCurIndex.intValue]
+
+ val bitmapWidth = curImage.width
+ val bitmapHeight = curImage.height
+ val imageWidth =
+ if (bitmapWidth >= bitmapHeight) 200 else (200f / bitmapHeight * bitmapWidth).toInt()
+ val imageHeight =
+ if (bitmapHeight >= bitmapWidth) 200 else (200f / bitmapWidth * bitmapHeight).toInt()
+
+ var value by remember { mutableFloatStateOf(0f) }
+ var savedIndex by remember { mutableIntStateOf(0) }
+ 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
+ imageHistoryCurIndex.intValue = (savedIndex + value).toInt()
+ if (imageHistoryCurIndex.intValue < 0) {
+ imageHistoryCurIndex.intValue = 0
+ } else if (imageHistoryCurIndex.intValue > message.bitmaps.size - 1) {
+ imageHistoryCurIndex.intValue = message.bitmaps.size - 1
+ }
+ }
+ },
+ contentScale = ContentScale.Fit,
+ )
+ }
+}
\ No newline at end of file
diff --git a/Android/src/app/src/main/java/com/google/aiedge/gallery/ui/common/chat/MessageBodyInfo.kt b/Android/src/app/src/main/java/com/google/aiedge/gallery/ui/common/chat/MessageBodyInfo.kt
new file mode 100644
index 0000000..03c8e3e
--- /dev/null
+++ b/Android/src/app/src/main/java/com/google/aiedge/gallery/ui/common/chat/MessageBodyInfo.kt
@@ -0,0 +1,63 @@
+/*
+ * 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.aiedge.gallery.ui.common.chat
+
+import androidx.compose.foundation.background
+import androidx.compose.foundation.layout.Arrangement
+import androidx.compose.foundation.layout.Box
+import androidx.compose.foundation.layout.Row
+import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.shape.RoundedCornerShape
+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.aiedge.gallery.ui.theme.GalleryTheme
+import com.google.aiedge.gallery.ui.theme.customColors
+
+/**
+ * Composable function to display informational message content within a chat.
+ *
+ * Supports markdown.
+ */
+@Composable
+fun MessageBodyInfo(message: ChatMessageInfo) {
+ Row(
+ modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.Center
+ ) {
+ Box(
+ modifier = Modifier
+ .clip(RoundedCornerShape(16.dp))
+ .background(MaterialTheme.customColors.agentBubbleBgColor)
+ ) {
+ MarkdownText(text = message.content, modifier = Modifier.padding(12.dp), smallFontSize = true)
+ }
+ }
+}
+
+@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
diff --git a/Android/src/app/src/main/java/com/google/aiedge/gallery/ui/common/chat/MessageBodyLoading.kt b/Android/src/app/src/main/java/com/google/aiedge/gallery/ui/common/chat/MessageBodyLoading.kt
new file mode 100644
index 0000000..705625f
--- /dev/null
+++ b/Android/src/app/src/main/java/com/google/aiedge/gallery/ui/common/chat/MessageBodyLoading.kt
@@ -0,0 +1,142 @@
+/*
+ * 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.aiedge.gallery.ui.common.chat
+
+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
+import androidx.compose.runtime.mutableIntStateOf
+import androidx.compose.runtime.remember
+import androidx.compose.ui.Alignment
+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.res.painterResource
+import androidx.compose.ui.tooling.preview.Preview
+import androidx.compose.ui.unit.dp
+import com.google.aiedge.gallery.R
+import com.google.aiedge.gallery.ui.common.getTaskIconColor
+import com.google.aiedge.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 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
+fun MessageBodyLoading() {
+ val progress = remember { Animatable(0f) }
+ val alphaAnim = remember { Animatable(0f) }
+ val activeImageIndex = remember { mutableIntStateOf(0) }
+
+ LaunchedEffect(Unit) { // Run this once
+ while (true) {
+ var progressJob = launch {
+ progress.animateTo(
+ targetValue = 1f,
+ 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,
+ )
+ )
+ }
+ progressJob.join()
+ alphaJob.join()
+ delay((PAUSE_DURATION).toLong())
+
+ progressJob = launch {
+ progress.animateTo(
+ targetValue = 0f,
+ animationSpec = tween(
+ durationMillis = ANIMATION_DURATION2,
+ easing = multiBounceEasing(bounces = 3, decay = 0.02f)
+ )
+ )
+ }
+ alphaJob = launch {
+ alphaAnim.animateTo(
+ targetValue = 0f,
+ animationSpec = tween(
+ durationMillis = ANIMATION_DURATION2 / 2,
+ )
+ )
+ }
+
+ progressJob.join()
+ alphaJob.join()
+ delay(PAUSE_DURATION2.toLong())
+
+ activeImageIndex.intValue = (activeImageIndex.intValue + 1) % IMAGE_RESOURCES.size
+ }
+ }
+
+ Box(contentAlignment = Alignment.Center) {
+ for ((index, imageResource) in IMAGE_RESOURCES.withIndex()) {
+ Image(
+ painter = painterResource(id = imageResource),
+ 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)
+ )
+ }
+ }
+}
+
+@Preview(showBackground = true)
+@Composable
+fun MessageBodyLoadingPreview() {
+ GalleryTheme {
+ Row(modifier = Modifier.padding(16.dp)) {
+ MessageBodyLoading()
+ }
+ }
+}
\ No newline at end of file
diff --git a/Android/src/app/src/main/java/com/google/aiedge/gallery/ui/common/chat/MessageBodyPromptTemplates.kt b/Android/src/app/src/main/java/com/google/aiedge/gallery/ui/common/chat/MessageBodyPromptTemplates.kt
new file mode 100644
index 0000000..0547ea4
--- /dev/null
+++ b/Android/src/app/src/main/java/com/google/aiedge/gallery/ui/common/chat/MessageBodyPromptTemplates.kt
@@ -0,0 +1,168 @@
+/*
+ * 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.aiedge.gallery.ui.common.chat
+
+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
+import androidx.compose.foundation.layout.height
+import androidx.compose.foundation.layout.offset
+import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.lazy.LazyColumn
+import androidx.compose.foundation.lazy.items
+import androidx.compose.foundation.shape.RoundedCornerShape
+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.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.aiedge.gallery.data.Task
+import com.google.aiedge.gallery.ui.common.getTaskIconColor
+import com.google.aiedge.gallery.ui.preview.ALL_PREVIEW_TASKS
+import com.google.aiedge.gallery.ui.preview.TASK_TEST1
+import com.google.aiedge.gallery.ui.theme.GalleryTheme
+
+private const val CARD_HEIGHT = 100
+
+@Composable
+fun MessageBodyPromptTemplates(
+ message: ChatMessagePromptTemplates,
+ task: Task,
+ onPromptClicked: (PromptTemplate) -> Unit = {},
+) {
+ val rowCount = message.templates.size.toFloat()
+ val color = getTaskIconColor(task)
+ val gradientColors = listOf(color.copy(alpha = 0.5f), color)
+
+ Column(
+ modifier = Modifier.padding(top = 12.dp),
+ verticalArrangement = Arrangement.spacedBy(8.dp)
+ ) {
+ Text(
+ "Try an example prompt",
+ style = MaterialTheme.typography.titleLarge.copy(
+ fontWeight = FontWeight.Bold,
+ brush = Brush.linearGradient(
+ colors = gradientColors,
+ )
+ ),
+ modifier = Modifier.fillMaxWidth(),
+ textAlign = TextAlign.Center,
+ )
+ if (message.showMakeYourOwn) {
+ Text(
+ "Or make your own",
+ style = MaterialTheme.typography.titleSmall,
+ modifier = Modifier
+ .fillMaxWidth()
+ .offset(y = -4.dp),
+ textAlign = TextAlign.Center,
+ )
+ }
+ LazyColumn(
+ 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)
+ }
+ ) {
+ Column(
+ modifier = Modifier
+ .padding(horizontal = 12.dp, vertical = 20.dp)
+ .fillMaxSize(),
+ horizontalAlignment = Alignment.CenterHorizontally,
+ ) {
+ Text(
+ template.title,
+ style = MaterialTheme.typography.titleMedium.copy(fontWeight = FontWeight.Bold),
+ )
+ Spacer(modifier = Modifier.weight(1f))
+ Text(
+ template.description,
+ style = MaterialTheme.typography.bodyMedium,
+ textAlign = TextAlign.Center,
+ )
+ }
+ }
+ }
+ }
+ }
+}
+
+@Preview(showBackground = true)
+@Composable
+fun MessageBodyPromptTemplatesPreview() {
+ for ((index, task) in ALL_PREVIEW_TASKS.withIndex()) {
+ task.index = index
+ for (model in task.models) {
+ model.preProcess(task = task)
+ }
+ }
+
+ 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/aiedge/gallery/ui/common/chat/MessageBodyText.kt b/Android/src/app/src/main/java/com/google/aiedge/gallery/ui/common/chat/MessageBodyText.kt
new file mode 100644
index 0000000..ed4c58f
--- /dev/null
+++ b/Android/src/app/src/main/java/com/google/aiedge/gallery/ui/common/chat/MessageBodyText.kt
@@ -0,0 +1,80 @@
+/*
+ * 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.aiedge.gallery.ui.common.chat
+
+import androidx.compose.foundation.background
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.Row
+import androidx.compose.foundation.layout.padding
+import androidx.compose.material3.MaterialTheme
+import androidx.compose.material3.Text
+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.aiedge.gallery.ui.theme.GalleryTheme
+
+/**
+ * Composable function to display the text content of a ChatMessageText.
+ */
+@Composable
+fun MessageBodyText(message: ChatMessageText) {
+ if (message.side == ChatSide.USER) {
+ Text(
+ message.content,
+ style = MaterialTheme.typography.bodyMedium.copy(fontWeight = FontWeight.Medium),
+ color = Color.White,
+ modifier = Modifier.padding(12.dp)
+ )
+ } else if (message.side == ChatSide.AGENT) {
+ if (message.isMarkdown) {
+ MarkdownText(text = message.content, modifier = Modifier.padding(12.dp))
+ } else {
+ Text(
+ message.content,
+ style = MaterialTheme.typography.bodyMedium.copy(fontWeight = FontWeight.Medium),
+ color = MaterialTheme.colorScheme.onSurface,
+ 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
diff --git a/Android/src/app/src/main/java/com/google/aiedge/gallery/ui/common/chat/MessageBubbleShape.kt b/Android/src/app/src/main/java/com/google/aiedge/gallery/ui/common/chat/MessageBubbleShape.kt
new file mode 100644
index 0000000..26d523c
--- /dev/null
+++ b/Android/src/app/src/main/java/com/google/aiedge/gallery/ui/common/chat/MessageBubbleShape.kt
@@ -0,0 +1,69 @@
+/*
+ * 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.aiedge.gallery.ui.common.chat
+
+import androidx.compose.ui.geometry.CornerRadius
+import androidx.compose.ui.geometry.RoundRect
+import androidx.compose.ui.geometry.Size
+import androidx.compose.ui.graphics.Outline
+import androidx.compose.ui.graphics.Path
+import androidx.compose.ui.graphics.Shape
+import androidx.compose.ui.unit.Density
+import androidx.compose.ui.unit.Dp
+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.
+ */
+class MessageBubbleShape(
+ private val radius: Dp,
+ private val hardCornerAtLeftOrRight: Boolean = false
+) : Shape {
+ override fun createOutline(
+ size: Size,
+ layoutDirection: LayoutDirection,
+ 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)
+ )
+ )
+ }
+ return Outline.Generic(path)
+ }
+}
diff --git a/Android/src/app/src/main/java/com/google/aiedge/gallery/ui/common/chat/MessageInputImage.kt b/Android/src/app/src/main/java/com/google/aiedge/gallery/ui/common/chat/MessageInputImage.kt
new file mode 100644
index 0000000..29a95fd
--- /dev/null
+++ b/Android/src/app/src/main/java/com/google/aiedge/gallery/ui/common/chat/MessageInputImage.kt
@@ -0,0 +1,269 @@
+/*
+ * 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.aiedge.gallery.ui.common.chat
+
+import android.Manifest
+import android.content.Context
+import android.content.pm.PackageManager
+import android.graphics.Bitmap
+import android.graphics.BitmapFactory
+import android.graphics.Matrix
+import android.net.Uri
+import android.util.Log
+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
+import androidx.compose.material.icons.Icons
+import androidx.compose.material.icons.rounded.Photo
+import androidx.compose.material.icons.rounded.PhotoCamera
+import androidx.compose.material.icons.rounded.Videocam
+import androidx.compose.material3.Icon
+import androidx.compose.material3.IconButton
+import androidx.compose.material3.IconButtonDefaults
+import androidx.compose.material3.MaterialTheme
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.getValue
+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.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.aiedge.gallery.ui.common.createTempPictureUri
+import com.google.aiedge.gallery.ui.theme.GalleryTheme
+
+private const val TAG = "AGMessageInputImage"
+
+/**
+ * Composable function to display image input options for chat messages.
+ *
+ * This function renders a row containing buttons that allow the user to select images from albums,
+ * take photos using the camera, or initiate a live camera stream. It handles permission requests,
+ * image selection, and launching camera activities.
+ */
+@Composable
+fun MessageInputImage(
+ onImageSelected: (Bitmap) -> Unit,
+ streamingMessage: ChatMessage? = null,
+ onStreamImage: (Bitmap) -> Unit = {},
+ onStreamEnd: (Int) -> Unit = {},
+ disableButtons: Boolean = false,
+) {
+ val context = LocalContext.current
+ var tempPhotoUri by remember { mutableStateOf(value = Uri.EMPTY) }
+ var showLiveCameraDialog by remember { mutableStateOf(false) }
+
+ // 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
+ // photo picker.
+ if (uri != null) {
+ handleImageSelected(context = context, uri = uri, onImageSelected = onImageSelected)
+ } else {
+ Log.d(TAG, "No media selected")
+ }
+ }
+
+ // launches camera
+ val cameraLauncher =
+ rememberLauncherForActivityResult(ActivityResultContracts.TakePicture()) { isImageSaved ->
+ if (isImageSaved) {
+ handleImageSelected(
+ context = context,
+ uri = tempPhotoUri,
+ onImageSelected = onImageSelected,
+ rotateForPortrait = true,
+ )
+ }
+ }
+
+ // Permission request when taking picture.
+ 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 buttonAlpha = if (disableButtons) 0.3f else 1f
+
+ Row(
+ modifier = Modifier
+ .fillMaxWidth()
+ .padding(12.dp),
+ verticalAlignment = Alignment.CenterVertically,
+ horizontalArrangement = Arrangement.End,
+ ) {
+ // Pick from albums.
+ IconButton(
+ onClick = {
+ if (disableButtons) {
+ return@IconButton
+ }
+
+ // Launch the photo picker and let the user choose only images.
+ pickMedia.launch(PickVisualMediaRequest(ActivityResultContracts.PickVisualMedia.ImageOnly))
+ },
+ colors = IconButtonDefaults.iconButtonColors(
+ containerColor = MaterialTheme.colorScheme.primary,
+ ),
+ modifier = Modifier.alpha(buttonAlpha),
+ ) {
+ Icon(Icons.Rounded.Photo, contentDescription = "", tint = MaterialTheme.colorScheme.onPrimary)
+ }
+
+ // Take picture
+ IconButton(
+ onClick = {
+ if (disableButtons) {
+ return@IconButton
+ }
+
+ // Check permission
+ when (PackageManager.PERMISSION_GRANTED) {
+ // Already got permission. Call the lambda.
+ ContextCompat.checkSelfPermission(
+ context, Manifest.permission.CAMERA
+ ) -> {
+ tempPhotoUri = context.createTempPictureUri()
+ cameraLauncher.launch(tempPhotoUri)
+ }
+
+ // Otherwise, ask for permission
+ else -> {
+ takePicturePermissionLauncher.launch(Manifest.permission.CAMERA)
+ }
+ }
+ },
+ colors = IconButtonDefaults.iconButtonColors(
+ containerColor = MaterialTheme.colorScheme.primary,
+ ),
+ modifier = Modifier.alpha(buttonAlpha),
+ ) {
+ Icon(
+ Icons.Rounded.PhotoCamera,
+ contentDescription = "",
+ tint = MaterialTheme.colorScheme.onPrimary
+ )
+ }
+
+ // Video stream.
+ IconButton(
+ onClick = {
+ if (disableButtons) {
+ return@IconButton
+ }
+
+ // Check permission
+ when (PackageManager.PERMISSION_GRANTED) {
+ // Already got permission. Call the lambda.
+ ContextCompat.checkSelfPermission(
+ context, Manifest.permission.CAMERA
+ ) -> {
+ showLiveCameraDialog = true
+ }
+
+ // Otherwise, ask for permission
+ else -> {
+ liveCameraPermissionLauncher.launch(Manifest.permission.CAMERA)
+ }
+ }
+ },
+ colors = IconButtonDefaults.iconButtonColors(
+ containerColor = MaterialTheme.colorScheme.primary,
+ ),
+ modifier = Modifier.alpha(buttonAlpha),
+ ) {
+ Icon(
+ 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
+ )
+ }
+}
+
+private fun handleImageSelected(
+ context: Context,
+ uri: Uri,
+ onImageSelected: (Bitmap) -> 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,
+) {
+ 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
+ }
+ } 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 = {})
+ }
+ }
+}
+
diff --git a/Android/src/app/src/main/java/com/google/aiedge/gallery/ui/common/chat/MessageInputText.kt b/Android/src/app/src/main/java/com/google/aiedge/gallery/ui/common/chat/MessageInputText.kt
new file mode 100644
index 0000000..2be0ecc
--- /dev/null
+++ b/Android/src/app/src/main/java/com/google/aiedge/gallery/ui/common/chat/MessageInputText.kt
@@ -0,0 +1,268 @@
+/*
+ * 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.aiedge.gallery.ui.common.chat
+
+import androidx.annotation.StringRes
+import androidx.compose.foundation.border
+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.fillMaxWidth
+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.shape.RoundedCornerShape
+import androidx.compose.material.icons.Icons
+import androidx.compose.material.icons.automirrored.rounded.Send
+import androidx.compose.material.icons.rounded.Add
+import androidx.compose.material.icons.rounded.History
+import androidx.compose.material.icons.rounded.PostAdd
+import androidx.compose.material.icons.rounded.Stop
+import androidx.compose.material3.DropdownMenu
+import androidx.compose.material3.DropdownMenuItem
+import androidx.compose.material3.ExperimentalMaterial3Api
+import androidx.compose.material3.Icon
+import androidx.compose.material3.IconButton
+import androidx.compose.material3.IconButtonDefaults
+import androidx.compose.material3.MaterialTheme
+import androidx.compose.material3.Text
+import androidx.compose.material3.TextField
+import androidx.compose.material3.TextFieldDefaults
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.collectAsState
+import androidx.compose.runtime.getValue
+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.alpha
+import androidx.compose.ui.graphics.Color
+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 com.google.aiedge.gallery.R
+import com.google.aiedge.gallery.ui.modelmanager.ModelManagerViewModel
+import com.google.aiedge.gallery.ui.preview.PreviewModelManagerViewModel
+import com.google.aiedge.gallery.ui.theme.GalleryTheme
+
+/**
+ * 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.
+ */
+@OptIn(ExperimentalMaterial3Api::class)
+@Composable
+fun MessageInputText(
+ modelManagerViewModel: ModelManagerViewModel,
+ curMessage: String,
+ inProgress: Boolean,
+ @StringRes textFieldPlaceHolderRes: Int,
+ onValueChanged: (String) -> Unit,
+ onSendMessage: (ChatMessage) -> Unit,
+ onOpenPromptTemplatesClicked: () -> Unit = {},
+ onStopButtonClicked: () -> Unit = {},
+ showPromptTemplatesInMenu: Boolean = true,
+ showStopButtonWhenInProgress: Boolean = false,
+) {
+ val modelManagerUiState by modelManagerViewModel.uiState.collectAsState()
+ var showAddContentMenu by remember { mutableStateOf(false) }
+ var showTextInputHistorySheet by remember { mutableStateOf(false) }
+
+ Box(contentAlignment = Alignment.CenterStart) {
+ // A plus button to show a popup menu to add stuff to the chat.
+ IconButton(
+ enabled = !inProgress,
+ onClick = { showAddContentMenu = true },
+ modifier = Modifier
+ .offset(x = 16.dp)
+ .alpha(0.8f)
+ ) {
+ 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)),
+ verticalAlignment = Alignment.CenterVertically,
+ ) {
+ DropdownMenu(
+ expanded = showAddContentMenu,
+ onDismissRequest = { showAddContentMenu = false }) {
+ 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.History, contentDescription = "")
+ Text("Input history")
+ }
+ }, onClick = {
+ showAddContentMenu = false
+ showTextInputHistorySheet = true
+ })
+ }
+
+ // Text field.
+ 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,
+ ),
+ textStyle = MaterialTheme.typography.bodyMedium,
+ modifier = Modifier
+ .weight(1f)
+ .padding(start = 36.dp),
+ placeholder = { Text(stringResource(textFieldPlaceHolderRes)) })
+
+ Spacer(modifier = Modifier.width(8.dp))
+
+ if (inProgress && showStopButtonWhenInProgress) {
+ IconButton(
+ onClick = onStopButtonClicked,
+ colors = IconButtonDefaults.iconButtonColors(
+ containerColor = MaterialTheme.colorScheme.secondaryContainer,
+ ),
+ ) {
+ Icon(
+ Icons.Rounded.Stop,
+ contentDescription = "",
+ tint = MaterialTheme.colorScheme.primary
+ )
+ }
+ } // Send button. Only shown when text is not empty.
+ else if (curMessage.isNotEmpty()) {
+ IconButton(
+ enabled = !inProgress,
+ onClick = {
+ onSendMessage(ChatMessageText(content = curMessage.trim(), side = ChatSide.USER))
+ },
+ colors = IconButtonDefaults.iconButtonColors(
+ containerColor = MaterialTheme.colorScheme.secondaryContainer,
+ ),
+ ) {
+ Icon(
+ Icons.AutoMirrored.Rounded.Send,
+ contentDescription = "",
+ modifier = Modifier.offset(x = 2.dp),
+ tint = if (inProgress) MaterialTheme.colorScheme.surfaceContainerHigh else MaterialTheme.colorScheme.primary
+ )
+ }
+ }
+ Spacer(modifier = Modifier.width(4.dp))
+ }
+ }
+
+
+ // A bottom sheet to show the text input history to pick from.
+ if (showTextInputHistorySheet) {
+ TextInputHistorySheet(
+ history = modelManagerUiState.textInputHistory,
+ onDismissed = {
+ showTextInputHistorySheet = false
+ },
+ onHistoryItemClicked = { item ->
+ onSendMessage(ChatMessageText(content = item, side = ChatSide.USER))
+ modelManagerViewModel.promoteTextInputHistoryItem(item)
+ },
+ onHistoryItemDeleted = { item ->
+ modelManagerViewModel.deleteTextInputHistory(item)
+ },
+ onHistoryItemsDeleteAll = {
+ modelManagerViewModel.clearTextInputHistory()
+ }
+ )
+ }
+}
+
+@Preview(showBackground = true)
+@Composable
+fun MessageInputTextPreview() {
+ val context = LocalContext.current
+
+ GalleryTheme {
+ Column {
+ MessageInputText(
+ modelManagerViewModel = PreviewModelManagerViewModel(context = context),
+ curMessage = "hello",
+ inProgress = false,
+ textFieldPlaceHolderRes = R.string.chat_textinput_placeholder,
+ onValueChanged = {},
+ onSendMessage = {},
+ showStopButtonWhenInProgress = true,
+ )
+ MessageInputText(
+ modelManagerViewModel = PreviewModelManagerViewModel(context = context),
+ curMessage = "hello",
+ inProgress = true,
+ textFieldPlaceHolderRes = R.string.chat_textinput_placeholder,
+ onValueChanged = {},
+ onSendMessage = {},
+ )
+ MessageInputText(
+ modelManagerViewModel = PreviewModelManagerViewModel(context = context),
+ curMessage = "",
+ inProgress = false,
+ textFieldPlaceHolderRes = R.string.chat_textinput_placeholder,
+ onValueChanged = {},
+ onSendMessage = {},
+ )
+ MessageInputText(
+ modelManagerViewModel = PreviewModelManagerViewModel(context = context),
+ curMessage = "",
+ inProgress = true,
+ textFieldPlaceHolderRes = R.string.chat_textinput_placeholder,
+ onValueChanged = {},
+ onSendMessage = {},
+ showStopButtonWhenInProgress = true,
+ )
+ }
+ }
+}
+
+
diff --git a/Android/src/app/src/main/java/com/google/aiedge/gallery/ui/common/chat/MessageLatency.kt b/Android/src/app/src/main/java/com/google/aiedge/gallery/ui/common/chat/MessageLatency.kt
new file mode 100644
index 0000000..c387a77
--- /dev/null
+++ b/Android/src/app/src/main/java/com/google/aiedge/gallery/ui/common/chat/MessageLatency.kt
@@ -0,0 +1,63 @@
+/*
+ * 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.aiedge.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.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.aiedge.gallery.ui.common.humanReadableDuration
+import com.google.aiedge.gallery.ui.theme.GalleryTheme
+
+/**
+ * Composable function to display the latency of a chat message, if available.
+ */
+@Composable
+fun LatencyText(message: ChatMessage) {
+ if (message.latencyMs >= 0) {
+ Text(
+ message.latencyMs.humanReadableDuration(),
+ modifier = Modifier.alpha(0.5f),
+ style = MaterialTheme.typography.labelSmall,
+ )
+ }
+}
+
+
+@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
diff --git a/Android/src/app/src/main/java/com/google/aiedge/gallery/ui/common/chat/MessageSender.kt b/Android/src/app/src/main/java/com/google/aiedge/gallery/ui/common/chat/MessageSender.kt
new file mode 100644
index 0000000..56cb84f
--- /dev/null
+++ b/Android/src/app/src/main/java/com/google/aiedge/gallery/ui/common/chat/MessageSender.kt
@@ -0,0 +1,256 @@
+/*
+ * 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.aiedge.gallery.ui.common.chat
+
+import android.graphics.Bitmap
+import androidx.annotation.StringRes
+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
+import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.layout.size
+import androidx.compose.foundation.layout.width
+import androidx.compose.material3.CircularProgressIndicator
+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.res.stringResource
+import androidx.compose.ui.tooling.preview.Preview
+import androidx.compose.ui.unit.dp
+import com.google.aiedge.gallery.R
+import com.google.aiedge.gallery.ui.theme.GalleryTheme
+import com.google.aiedge.gallery.ui.theme.bodySmallNarrow
+import com.google.aiedge.gallery.ui.theme.bodySmallSemiBold
+
+data class MessageLayoutConfig(
+ val horizontalArrangement: Arrangement.Horizontal,
+ val modifier: Modifier,
+ val userLabel: 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.
+ */
+@Composable
+fun MessageSender(
+ message: ChatMessage, @StringRes agentNameRes: Int, imageHistoryCurIndex: Int = 0
+) {
+ // No user label for system messages.
+ if (message.side == ChatSide.SYSTEM) {
+ return
+ }
+
+ val (horizontalArrangement, modifier, userLabel, rightSideLabel) = getMessageLayoutConfig(
+ message = message, agentNameRes = agentNameRes, imageHistoryCurIndex = imageHistoryCurIndex
+ )
+
+ Row(
+ modifier = modifier,
+ verticalAlignment = Alignment.CenterVertically,
+ horizontalArrangement = horizontalArrangement,
+ ) {
+ Row(verticalAlignment = Alignment.CenterVertically) {
+ // Sender label.
+ Text(
+ userLabel,
+ style = bodySmallSemiBold,
+ )
+
+ when (message) {
+ // Benchmark running status.
+ is ChatMessageBenchmarkResult -> {
+ if (message.isRunning()) {
+ Spacer(modifier = Modifier.width(8.dp))
+ CircularProgressIndicator(
+ modifier = Modifier.size(10.dp),
+ strokeWidth = 1.5.dp,
+ 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 ""
+ if (statusLabel.isNotEmpty()) {
+ Text(
+ statusLabel,
+ color = MaterialTheme.colorScheme.secondary,
+ style = bodySmallNarrow,
+ )
+ }
+ }
+
+ // Benchmark LLM running status.
+ is ChatMessageBenchmarkLlmResult -> {
+ if (message.running) {
+ Spacer(modifier = Modifier.width(8.dp))
+ CircularProgressIndicator(
+ modifier = Modifier.size(10.dp),
+ strokeWidth = 1.5.dp,
+ color = MaterialTheme.colorScheme.secondary
+ )
+ }
+ }
+
+ // Image generation running status.
+ is ChatMessageImageWithHistory -> {
+ if (message.isRunning()) {
+ Spacer(modifier = Modifier.width(8.dp))
+ CircularProgressIndicator(
+ modifier = Modifier.size(10.dp),
+ strokeWidth = 1.5.dp,
+ color = MaterialTheme.colorScheme.secondary
+ )
+ Spacer(modifier = Modifier.width(4.dp))
+ Text(
+ stringResource(R.string.running),
+ color = MaterialTheme.colorScheme.secondary,
+ style = bodySmallNarrow,
+ )
+ }
+ }
+ }
+ }
+
+ // Right-side text.
+ when (message) {
+ is ChatMessageBenchmarkResult,
+ is ChatMessageImageWithHistory,
+ is ChatMessageBenchmarkLlmResult,
+ -> {
+ Text(rightSideLabel, style = MaterialTheme.typography.bodySmall)
+ }
+ }
+ }
+}
+
+@Composable
+private fun getMessageLayoutConfig(
+ message: ChatMessage,
+ @StringRes agentNameRes: Int,
+ imageHistoryCurIndex: Int,
+): MessageLayoutConfig {
+ var userLabel = stringResource(R.string.chat_you)
+ var rightSideLabel = ""
+ var horizontalArrangement = Arrangement.End
+ var modifier = Modifier.padding(bottom = 2.dp)
+
+ if (message.side == ChatSide.AGENT) {
+ userLabel = stringResource(agentNameRes)
+ }
+
+ when (message) {
+ is ChatMessageBenchmarkResult -> {
+ horizontalArrangement = Arrangement.SpaceBetween
+ modifier = modifier.fillMaxWidth()
+ userLabel = "Benchmark"
+ rightSideLabel = if (message.isWarmingUp()) {
+ "${message.warmupCurrent}/${message.warmupTotal}"
+ } else {
+ "${message.iterationCurrent}/${message.iterationTotal}"
+ }
+ }
+
+ is ChatMessageBenchmarkLlmResult -> {
+ horizontalArrangement = Arrangement.SpaceBetween
+ modifier = modifier.fillMaxWidth()
+ userLabel = "Benchmark"
+ }
+
+ is ChatMessageImageWithHistory -> {
+ horizontalArrangement = Arrangement.SpaceBetween
+ if (message.bitmaps.isNotEmpty()) {
+ modifier = modifier.width(200.dp)
+ }
+ rightSideLabel = "${imageHistoryCurIndex + 1}/${message.totalIterations}"
+ }
+ }
+
+ return MessageLayoutConfig(
+ horizontalArrangement = horizontalArrangement,
+ modifier = modifier,
+ userLabel = userLabel,
+ 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),
+ agentNameRes = R.string.chat_generic_agent_name
+ )
+ // User message.
+ MessageSender(
+ message = ChatMessageText(content = "hello world", side = ChatSide.USER),
+ agentNameRes = 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
+ ), agentNameRes = 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
+ ), agentNameRes = 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
+ ),
+ agentNameRes = R.string.chat_generic_agent_name,
+ imageHistoryCurIndex = 4,
+ )
+ }
+ }
+}
diff --git a/Android/src/app/src/main/java/com/google/aiedge/gallery/ui/common/chat/ModelDownloadingAnimation.kt b/Android/src/app/src/main/java/com/google/aiedge/gallery/ui/common/chat/ModelDownloadingAnimation.kt
new file mode 100644
index 0000000..cdfbda6
--- /dev/null
+++ b/Android/src/app/src/main/java/com/google/aiedge/gallery/ui/common/chat/ModelDownloadingAnimation.kt
@@ -0,0 +1,176 @@
+/*
+ * 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.aiedge.gallery.ui.common.chat
+
+import androidx.compose.animation.core.Animatable
+import androidx.compose.animation.core.Easing
+import androidx.compose.animation.core.tween
+import androidx.compose.foundation.Image
+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.height
+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.lazy.grid.GridCells
+import androidx.compose.foundation.lazy.grid.LazyVerticalGrid
+import androidx.compose.foundation.lazy.grid.itemsIndexed
+import androidx.compose.material3.MaterialTheme
+import androidx.compose.material3.Text
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.LaunchedEffect
+import androidx.compose.runtime.remember
+import androidx.compose.ui.Alignment
+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.res.painterResource
+import androidx.compose.ui.text.style.TextAlign
+import androidx.compose.ui.tooling.preview.Preview
+import androidx.compose.ui.unit.dp
+import com.google.aiedge.gallery.R
+import com.google.aiedge.gallery.ui.common.getTaskIconColor
+import com.google.aiedge.gallery.ui.theme.GalleryTheme
+import kotlinx.coroutines.delay
+import kotlin.math.cos
+import kotlin.math.pow
+
+private val GRID_SIZE = 240.dp
+private val GRID_SPACING = 0.dp
+private const val PAUSE_DURATION = 200
+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.
+ */
+@Composable
+fun ModelDownloadingAnimation() {
+ val scale = remember { Animatable(END_SCALE) }
+
+ LaunchedEffect(Unit) { // Run this once
+ while (true) {
+ // Phase 1: Scale up
+ scale.animateTo(
+ targetValue = START_SCALE,
+ 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)
+ )
+ )
+ delay(PAUSE_DURATION.toLong())
+ }
+ }
+
+ Column(
+ horizontalAlignment = Alignment.CenterHorizontally,
+ 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)
+ ) {
+ itemsIndexed(
+ listOf(
+ R.drawable.pantegon,
+ R.drawable.double_circle,
+ R.drawable.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
+ }
+ ) {
+ 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)
+ )
+ }
+ }
+ }
+
+ 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.",
+ style = MaterialTheme.typography.bodyMedium,
+ textAlign = TextAlign.Center
+ )
+ }
+}
+
+// Custom Easing function for a multi-bounce effect
+fun multiBounceEasing(bounces: Int, decay: Float): Easing = Easing { x ->
+ if (x == 1f) {
+ 1f
+ } else {
+ -decay.pow(x) * cos((x * (bounces + 0.9f) * Math.PI / 1.3f)).toFloat() + 1f
+ }
+}
+
+@Preview(showBackground = true)
+@Composable
+fun ModelDownloadingAnimationPreview() {
+ GalleryTheme {
+ Row(modifier = Modifier.padding(16.dp)) {
+ ModelDownloadingAnimation()
+ }
+ }
+}
\ No newline at end of file
diff --git a/Android/src/app/src/main/java/com/google/aiedge/gallery/ui/common/chat/ModelInitializationStatus.kt b/Android/src/app/src/main/java/com/google/aiedge/gallery/ui/common/chat/ModelInitializationStatus.kt
new file mode 100644
index 0000000..b4eaa34
--- /dev/null
+++ b/Android/src/app/src/main/java/com/google/aiedge/gallery/ui/common/chat/ModelInitializationStatus.kt
@@ -0,0 +1,89 @@
+/*
+ * 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.aiedge.gallery.ui.common.chat
+
+import androidx.compose.foundation.background
+import androidx.compose.foundation.layout.Arrangement
+import androidx.compose.foundation.layout.Box
+import androidx.compose.foundation.layout.Row
+import androidx.compose.foundation.layout.Spacer
+import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.layout.size
+import androidx.compose.foundation.layout.width
+import androidx.compose.foundation.shape.CircleShape
+import androidx.compose.material3.CircularProgressIndicator
+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.draw.clip
+import androidx.compose.ui.res.stringResource
+import androidx.compose.ui.tooling.preview.Preview
+import androidx.compose.ui.unit.dp
+import com.google.aiedge.gallery.R
+import com.google.aiedge.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.
+ */
+@Composable
+fun ModelInitializationStatusChip() {
+ Row(modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.Center) {
+ Box(
+ 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
+ ) {
+ // Circular progress indicator.
+ CircularProgressIndicator(
+ modifier = Modifier.size(14.dp),
+ strokeWidth = 2.dp,
+ color = MaterialTheme.colorScheme.onSecondaryContainer
+ )
+
+ Spacer(modifier = Modifier.width(8.dp))
+
+ // Text message.
+ Text(
+ stringResource(R.string.model_is_initializing_msg),
+ style = MaterialTheme.typography.bodySmall,
+ color = MaterialTheme.colorScheme.onSecondaryContainer,
+ )
+ }
+ }
+ }
+}
+
+@Preview(showBackground = true)
+@Composable
+fun ModelInitializationStatusPreview() {
+ GalleryTheme {
+ ModelInitializationStatusChip()
+ }
+}
diff --git a/Android/src/app/src/main/java/com/google/aiedge/gallery/ui/common/chat/ModelNotDownloaded.kt b/Android/src/app/src/main/java/com/google/aiedge/gallery/ui/common/chat/ModelNotDownloaded.kt
new file mode 100644
index 0000000..eea3cdf
--- /dev/null
+++ b/Android/src/app/src/main/java/com/google/aiedge/gallery/ui/common/chat/ModelNotDownloaded.kt
@@ -0,0 +1,54 @@
+/*
+ * 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.aiedge.gallery.ui.common.chat
+
+import androidx.compose.foundation.layout.Arrangement
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.fillMaxSize
+import androidx.compose.material3.Button
+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.aiedge.gallery.ui.theme.GalleryTheme
+
+/**
+ * Composable function to display a button to download model if the model has not been downloaded.
+ */
+@Composable
+fun ModelNotDownloaded(modifier: Modifier = Modifier, onClicked: () -> Unit) {
+ Column(
+ modifier = modifier.fillMaxSize(),
+ verticalArrangement = Arrangement.Center,
+ horizontalAlignment = Alignment.CenterHorizontally
+ ) {
+ Button(
+ onClick = onClicked,
+ ) {
+ Text("Download & Try it", maxLines = 1)
+ }
+ }
+}
+
+@Preview(showBackground = true)
+@Composable
+fun Preview() {
+ GalleryTheme {
+ ModelNotDownloaded(onClicked = {})
+ }
+}
\ No newline at end of file
diff --git a/Android/src/app/src/main/java/com/google/aiedge/gallery/ui/common/chat/ModelSelector.kt b/Android/src/app/src/main/java/com/google/aiedge/gallery/ui/common/chat/ModelSelector.kt
new file mode 100644
index 0000000..b8853ff
--- /dev/null
+++ b/Android/src/app/src/main/java/com/google/aiedge/gallery/ui/common/chat/ModelSelector.kt
@@ -0,0 +1,170 @@
+/*
+ * 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.aiedge.gallery.ui.common.chat
+
+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.fillMaxWidth
+import androidx.compose.foundation.layout.padding
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.getValue
+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.graphics.graphicsLayer
+import androidx.compose.ui.platform.LocalContext
+import androidx.compose.ui.tooling.preview.Preview
+import androidx.compose.ui.unit.dp
+import com.google.aiedge.gallery.data.Model
+import com.google.aiedge.gallery.data.Task
+import com.google.aiedge.gallery.ui.common.convertValueToTargetType
+import com.google.aiedge.gallery.ui.common.modelitem.ModelItem
+import com.google.aiedge.gallery.ui.modelmanager.ModelManagerViewModel
+import com.google.aiedge.gallery.ui.preview.PreviewModelManagerViewModel
+import com.google.aiedge.gallery.ui.preview.TASK_TEST1
+import com.google.aiedge.gallery.ui.preview.TASK_TEST2
+import com.google.aiedge.gallery.ui.theme.GalleryTheme
+
+/**
+ * Composable function to display a selectable model item with an option to configure its settings.
+ */
+@Composable
+fun ModelSelector(
+ model: Model,
+ task: Task,
+ modelManagerViewModel: ModelManagerViewModel,
+ modifier: Modifier = Modifier,
+ contentAlpha: Float = 1f,
+ onConfigChanged: (oldConfigValues: Map, newConfigValues: Map) -> Unit = { _, _ -> },
+) {
+ var showConfigDialog by remember { mutableStateOf(false) }
+ val context = LocalContext.current
+
+ Column(
+ modifier = modifier
+ ) {
+ Box(
+ modifier = Modifier
+ .fillMaxWidth().padding(bottom = 8.dp),
+ contentAlignment = Alignment.Center
+ ) {
+ // Model row.
+ Row(
+ modifier = Modifier
+ .fillMaxWidth()
+ .graphicsLayer { alpha = contentAlpha },
+ verticalAlignment = Alignment.CenterVertically
+ ) {
+ ModelItem(
+ model = model,
+ task = task,
+ modelManagerViewModel = modelManagerViewModel,
+ onModelClicked = {},
+ onConfigClicked = {
+ showConfigDialog = true
+ },
+ verticalSpacing = 10.dp,
+ modifier = Modifier
+ .weight(1f)
+ .padding(horizontal = 16.dp),
+ showDeleteButton = false,
+ showConfigButtonIfExisted = true,
+ canExpand = false,
+ )
+ }
+ }
+ }
+
+ // Config dialog.
+ if (showConfigDialog) {
+ ConfigDialog(
+ title = "Model configs",
+ configs = model.configs,
+ initialValues = model.configValues,
+ onDismissed = { showConfigDialog = false },
+ onOk = { curConfigValues ->
+ // Hide config dialog.
+ showConfigDialog = false
+
+ // Check if the configs are changed or not. Also check if the model needs to be
+ // re-initialized.
+ var same = true
+ 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
+ )
+ if (oldValue != newValue) {
+ same = false
+ if (config.needReinitialization) {
+ needReinitialization = true
+ }
+ break
+ }
+ }
+ if (same) {
+ return@ConfigDialog
+ }
+
+ // Save the config values to Model.
+ val oldConfigValues = model.configValues
+ model.configValues = curConfigValues
+
+ // Force to re-initialize the model with the new configs.
+ if (needReinitialization) {
+ modelManagerViewModel.initializeModel(context = context, model = model, force = true)
+ }
+
+ // Notify.
+ onConfigChanged(oldConfigValues, model.configValues)
+ },
+ )
+ }
+}
+
+@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/aiedge/gallery/ui/common/chat/TextInputHistorySheet.kt b/Android/src/app/src/main/java/com/google/aiedge/gallery/ui/common/chat/TextInputHistorySheet.kt
new file mode 100644
index 0000000..67363a5
--- /dev/null
+++ b/Android/src/app/src/main/java/com/google/aiedge/gallery/ui/common/chat/TextInputHistorySheet.kt
@@ -0,0 +1,211 @@
+/*
+ * 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.aiedge.gallery.ui.common.chat
+
+import androidx.compose.foundation.background
+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.fillMaxWidth
+import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.layout.wrapContentHeight
+import androidx.compose.foundation.lazy.LazyColumn
+import androidx.compose.foundation.lazy.items
+import androidx.compose.foundation.shape.RoundedCornerShape
+import androidx.compose.material.icons.Icons
+import androidx.compose.material.icons.rounded.Delete
+import androidx.compose.material.icons.rounded.DeleteSweep
+import androidx.compose.material3.AlertDialog
+import androidx.compose.material3.Button
+import androidx.compose.material3.ExperimentalMaterial3Api
+import androidx.compose.material3.Icon
+import androidx.compose.material3.IconButton
+import androidx.compose.material3.MaterialTheme
+import androidx.compose.material3.ModalBottomSheet
+import androidx.compose.material3.Text
+import androidx.compose.material3.TextButton
+import androidx.compose.material3.rememberModalBottomSheetState
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.getValue
+import androidx.compose.runtime.mutableStateOf
+import androidx.compose.runtime.remember
+import androidx.compose.runtime.rememberCoroutineScope
+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.res.stringResource
+import androidx.compose.ui.text.style.TextAlign
+import androidx.compose.ui.tooling.preview.Preview
+import androidx.compose.ui.unit.dp
+import com.google.aiedge.gallery.R
+import com.google.aiedge.gallery.ui.theme.GalleryTheme
+import com.google.aiedge.gallery.ui.theme.customColors
+import kotlinx.coroutines.delay
+import kotlinx.coroutines.launch
+
+@OptIn(ExperimentalMaterial3Api::class)
+@Composable
+fun TextInputHistorySheet(
+ history: List,
+ onHistoryItemClicked: (String) -> Unit,
+ onHistoryItemDeleted: (String) -> Unit,
+ onHistoryItemsDeleteAll: () -> Unit,
+ onDismissed: () -> Unit
+) {
+ val sheetState = rememberModalBottomSheetState()
+ val scope = rememberCoroutineScope()
+
+ ModalBottomSheet(
+ onDismissRequest = onDismissed,
+ sheetState = sheetState,
+ modifier = Modifier.wrapContentHeight(),
+ ) {
+ SheetContent(
+ history = history,
+ onHistoryItemClicked = { item ->
+ scope.launch {
+ sheetState.hide()
+ delay(100)
+ onHistoryItemClicked(item)
+ onDismissed()
+ }
+ },
+ onHistoryItemDeleted = onHistoryItemDeleted,
+ onHistoryItemsDeleteAll = {
+ scope.launch {
+ sheetState.hide()
+ onDismissed()
+ onHistoryItemsDeleteAll()
+ }
+ },
+ onDismissed = {
+ scope.launch {
+ sheetState.hide()
+ onDismissed()
+ }
+ }
+ )
+ }
+}
+
+@Composable
+private fun SheetContent(
+ history: List,
+ onHistoryItemClicked: (String) -> Unit,
+ onHistoryItemDeleted: (String) -> Unit,
+ onHistoryItemsDeleteAll: () -> Unit,
+ onDismissed: () -> Unit
+) {
+ val scope = rememberCoroutineScope()
+ var showConfirmDeleteDialog by remember { mutableStateOf(false) }
+
+ Column {
+ Box(contentAlignment = Alignment.CenterEnd) {
+ Text(
+ "Text input history",
+ style = MaterialTheme.typography.titleLarge,
+ modifier = Modifier
+ .fillMaxWidth()
+ .padding(8.dp),
+ textAlign = TextAlign.Center
+ )
+ 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)
+ },
+ verticalAlignment = Alignment.CenterVertically,
+ horizontalArrangement = Arrangement.spacedBy(8.dp)
+ ) {
+ Text(
+ item,
+ style = MaterialTheme.typography.bodyMedium,
+ 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)
+ }
+ }) {
+ Icon(Icons.Rounded.Delete, contentDescription = "")
+ }
+ }
+ }
+ }
+ }
+
+ if (showConfirmDeleteDialog) {
+ AlertDialog(onDismissRequest = { showConfirmDeleteDialog = false },
+ title = { Text("Clear history?") },
+ text = {
+ Text(
+ "Are you sure you want to clear the history? This action cannot be undone."
+ )
+ },
+ confirmButton = {
+ Button(onClick = {
+ showConfirmDeleteDialog = false
+ onHistoryItemsDeleteAll()
+ }) {
+ Text(stringResource(R.string.ok))
+ }
+ },
+ dismissButton = {
+ 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
diff --git a/Android/src/app/src/main/java/com/google/aiedge/gallery/ui/common/modelitem/AnimatedLayoutModifier.kt b/Android/src/app/src/main/java/com/google/aiedge/gallery/ui/common/modelitem/AnimatedLayoutModifier.kt
new file mode 100644
index 0000000..7212c57
--- /dev/null
+++ b/Android/src/app/src/main/java/com/google/aiedge/gallery/ui/common/modelitem/AnimatedLayoutModifier.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.aiedge.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/aiedge/gallery/ui/common/modelitem/ConfirmDeleteModelDialog.kt b/Android/src/app/src/main/java/com/google/aiedge/gallery/ui/common/modelitem/ConfirmDeleteModelDialog.kt
new file mode 100644
index 0000000..9286131
--- /dev/null
+++ b/Android/src/app/src/main/java/com/google/aiedge/gallery/ui/common/modelitem/ConfirmDeleteModelDialog.kt
@@ -0,0 +1,52 @@
+/*
+ * 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.aiedge.gallery.ui.common.modelitem
+
+import androidx.compose.material3.AlertDialog
+import androidx.compose.material3.Button
+import androidx.compose.material3.Text
+import androidx.compose.material3.TextButton
+import androidx.compose.runtime.Composable
+import androidx.compose.ui.res.stringResource
+import com.google.aiedge.gallery.R
+import com.google.aiedge.gallery.data.Model
+
+/**
+ * Composable function to display a confirmation dialog for deleting a model.
+ */
+@Composable
+fun ConfirmDeleteModelDialog(model: Model, onConfirm: () -> Unit, onDismiss: () -> Unit) {
+ 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
+ )
+ )
+ },
+ 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/aiedge/gallery/ui/common/modelitem/ModelItem.kt b/Android/src/app/src/main/java/com/google/aiedge/gallery/ui/common/modelitem/ModelItem.kt
new file mode 100644
index 0000000..97491fd
--- /dev/null
+++ b/Android/src/app/src/main/java/com/google/aiedge/gallery/ui/common/modelitem/ModelItem.kt
@@ -0,0 +1,405 @@
+/*
+ * 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.aiedge.gallery.ui.common.modelitem
+
+import android.content.Intent
+import android.net.Uri
+import androidx.activity.compose.rememberLauncherForActivityResult
+import androidx.activity.result.contract.ActivityResultContracts
+import androidx.compose.animation.core.animateFloatAsState
+import androidx.compose.animation.core.tween
+import androidx.compose.foundation.background
+import androidx.compose.foundation.clickable
+import androidx.compose.foundation.interaction.MutableInteractionSource
+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.fillMaxWidth
+import androidx.compose.foundation.layout.heightIn
+import androidx.compose.foundation.layout.offset
+import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.shape.RoundedCornerShape
+import androidx.compose.material.icons.Icons
+import androidx.compose.material.icons.rounded.Settings
+import androidx.compose.material.icons.rounded.UnfoldLess
+import androidx.compose.material.icons.rounded.UnfoldMore
+import androidx.compose.material3.Icon
+import androidx.compose.material3.IconButton
+import androidx.compose.material3.OutlinedButton
+import androidx.compose.material3.Text
+import androidx.compose.material3.ripple
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.collectAsState
+import androidx.compose.runtime.derivedStateOf
+import androidx.compose.runtime.getValue
+import androidx.compose.runtime.movableContentOf
+import androidx.compose.runtime.movableContentWithReceiverOf
+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.alpha
+import androidx.compose.ui.draw.clip
+import androidx.compose.ui.layout.LookaheadScope
+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 com.google.aiedge.gallery.data.Model
+import com.google.aiedge.gallery.data.ModelDownloadStatusType
+import com.google.aiedge.gallery.data.Task
+import com.google.aiedge.gallery.ui.common.DownloadAndTryButton
+import com.google.aiedge.gallery.ui.common.TaskIcon
+import com.google.aiedge.gallery.ui.common.chat.MarkdownText
+import com.google.aiedge.gallery.ui.common.checkNotificationPermissonAndStartDownload
+import com.google.aiedge.gallery.ui.common.getTaskBgColor
+import com.google.aiedge.gallery.ui.common.getTaskIconColor
+import com.google.aiedge.gallery.ui.modelmanager.ModelManagerViewModel
+import com.google.aiedge.gallery.ui.preview.MODEL_TEST1
+import com.google.aiedge.gallery.ui.preview.MODEL_TEST2
+import com.google.aiedge.gallery.ui.preview.MODEL_TEST3
+import com.google.aiedge.gallery.ui.preview.MODEL_TEST4
+import com.google.aiedge.gallery.ui.preview.PreviewModelManagerViewModel
+import com.google.aiedge.gallery.ui.preview.TASK_TEST1
+import com.google.aiedge.gallery.ui.preview.TASK_TEST2
+import com.google.aiedge.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.
+ */
+@Composable
+fun ModelItem(
+ model: Model,
+ task: Task,
+ modelManagerViewModel: ModelManagerViewModel,
+ onModelClicked: (Model) -> Unit,
+ modifier: Modifier = Modifier,
+ onConfigClicked: () -> Unit = {},
+ verticalSpacing: Dp = DEFAULT_VERTICAL_PADDING,
+ showDeleteButton: Boolean = true,
+ showConfigButtonIfExisted: Boolean = false,
+ canExpand: Boolean = true,
+) {
+ val context = LocalContext.current
+ val modelManagerUiState by modelManagerViewModel.uiState.collectAsState()
+ val downloadStatus by remember {
+ derivedStateOf { modelManagerUiState.modelDownloadStatus[model.name] }
+ }
+ val launcher = rememberLauncherForActivityResult(
+ ActivityResultContracts.RequestPermission()
+ ) {
+ modelManagerViewModel.downloadModel(model)
+ }
+
+ var isExpanded by remember { mutableStateOf(false) }
+
+ // Animate alpha for model description and button rows when switching between layouts.
+ val alphaAnimation by animateFloatAsState(
+ targetValue = if (isExpanded) 1f else 0f,
+ animationSpec = tween(durationMillis = LAYOUT_ANIMATION_DURATION - 50)
+ )
+
+ LookaheadScope {
+ // Task icon.
+ val taskIcon = remember {
+ movableContentOf {
+ TaskIcon(
+ task = task, modifier = Modifier.animateLayout()
+ )
+ }
+ }
+
+ // Model name and status.
+ val modelNameAndStatus = remember {
+ movableContentOf {
+ ModelNameAndStatus(
+ model = model,
+ task = task,
+ downloadStatus = downloadStatus,
+ isExpanded = isExpanded,
+ modifier = Modifier.animateLayout()
+ )
+ }
+ }
+
+ val actionButton = remember {
+ movableContentOf {
+ ModelItemActionButton(
+ context = context,
+ model = model,
+ task = task,
+ modelManagerViewModel = modelManagerViewModel,
+ downloadStatus = downloadStatus,
+ onDownloadClicked = { model ->
+ checkNotificationPermissonAndStartDownload(
+ context = context,
+ launcher = launcher,
+ modelManagerViewModel = modelManagerViewModel,
+ model = model
+ )
+ },
+ showDeleteButton = showDeleteButton,
+ showDownloadButton = false,
+ )
+ }
+ }
+
+ // Expand/collapse icon, or the config icon.
+ val expandButton = remember {
+ movableContentOf {
+ if (showConfigButtonIfExisted) {
+ if (downloadStatus?.status === ModelDownloadStatusType.SUCCEEDED) {
+ if (model.configs.isNotEmpty()) {
+ IconButton(onClick = onConfigClicked) {
+ Icon(
+ Icons.Rounded.Settings,
+ contentDescription = "",
+ tint = getTaskIconColor(task)
+ )
+ }
+ }
+ }
+ } else {
+ Icon(
+ if (isExpanded) Icons.Rounded.UnfoldLess else Icons.Rounded.UnfoldMore,
+ contentDescription = "",
+ tint = getTaskIconColor(task),
+ )
+ }
+ }
+ }
+
+ // Model description shown in expanded layout.
+ val modelDescription = remember {
+ movableContentOf { m: Modifier ->
+ if (model.info.isNotEmpty()) {
+ MarkdownText(
+ model.info,
+ modifier = Modifier
+ .heightIn(min = 0.dp, max = if (isExpanded) 1000.dp else 0.dp)
+ .animateLayout()
+ .then(m)
+ )
+ }
+ }
+ }
+
+ // Button rows shown in expanded layout.
+ val buttonRows = remember {
+ movableContentOf { m: Modifier ->
+ Row(
+ modifier = Modifier
+ .heightIn(min = 0.dp, max = if (isExpanded) 1000.dp else 0.dp)
+ .animateLayout()
+ .then(m),
+ horizontalArrangement = Arrangement.spacedBy(12.dp),
+ ) {
+ // The "learn more" button. Click to show url in default browser.
+ 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(
+ model = model,
+ enabled = isExpanded,
+ needToDownloadFirst = needToDownloadFirst,
+ modelManagerViewModel = modelManagerViewModel,
+ onClicked = { onModelClicked(model) }
+ )
+// Button(
+// onClick = {
+// if (isExpanded) {
+// onModelClicked(model)
+// if (needToDownloadFirst) {
+// scope.launch {
+// delay(80)
+// checkNotificationPermissonAndStartDownload(
+// context = context,
+// launcher = launcher,
+// modelManagerViewModel = modelManagerViewModel,
+// model = model
+// )
+// }
+// }
+// }
+// },
+// ) {
+// Icon(
+// Icons.AutoMirrored.Rounded.ArrowForward,
+// contentDescription = "",
+// modifier = Modifier.padding(end = 4.dp)
+// )
+// if (needToDownloadFirst) {
+// Text("Download & Try it", maxLines = 1)
+// } else {
+// Text("Try it", maxLines = 1)
+// }
+// }
+ }
+ }
+ }
+
+ val container = remember {
+ movableContentWithReceiverOf Unit> { content ->
+ Box(
+ modifier = Modifier.animateLayout(),
+ contentAlignment = Alignment.TopEnd,
+ ) {
+ content()
+ }
+ }
+ }
+
+ var boxModifier = modifier
+ .fillMaxWidth()
+ .clip(RoundedCornerShape(size = 42.dp))
+ .background(
+ getTaskBgColor(task)
+ )
+ boxModifier = if (canExpand) {
+ boxModifier.clickable(
+ onClick = { isExpanded = !isExpanded },
+ interactionSource = remember { MutableInteractionSource() },
+ indication = ripple(
+ bounded = true,
+ radius = 500.dp,
+ )
+ )
+ } else {
+ boxModifier
+ }
+ Box(
+ modifier = boxModifier,
+ contentAlignment = Alignment.Center
+ ) {
+ if (isExpanded) {
+ container {
+ // The main part (icon, model name, status, etc)
+ Column(
+ verticalArrangement = Arrangement.spacedBy(14.dp),
+ horizontalAlignment = Alignment.CenterHorizontally,
+ modifier = Modifier
+ .fillMaxWidth()
+ .padding(vertical = verticalSpacing, horizontal = 18.dp)
+ ) {
+ Box(contentAlignment = Alignment.Center) {
+ taskIcon()
+ Row(
+ modifier = Modifier.fillMaxWidth(),
+ verticalAlignment = Alignment.CenterVertically,
+ horizontalArrangement = Arrangement.End
+ ) {
+ actionButton()
+ expandButton()
+ }
+ }
+ modelNameAndStatus()
+ modelDescription(Modifier.alpha(alphaAnimation))
+ buttonRows(Modifier.alpha(alphaAnimation)) // Apply alpha here
+ }
+ }
+ } else {
+ container {
+ Column(horizontalAlignment = Alignment.CenterHorizontally) {
+ // The main part (icon, model name, status, etc)
+ Row(
+ verticalAlignment = Alignment.CenterVertically,
+ horizontalArrangement = Arrangement.spacedBy(12.dp),
+ modifier = Modifier
+ .fillMaxWidth()
+ .padding(start = 18.dp, end = 18.dp)
+ .padding(vertical = verticalSpacing)
+ ) {
+ taskIcon()
+ Row(modifier = Modifier.weight(1f)) {
+ modelNameAndStatus()
+ }
+ Row(verticalAlignment = Alignment.CenterVertically) {
+ actionButton()
+ expandButton()
+ }
+ }
+ Column(
+ modifier = Modifier.offset(y = 30.dp),
+ horizontalAlignment = Alignment.CenterHorizontally
+ ) {
+ modelDescription(Modifier.alpha(alphaAnimation))
+ buttonRows(Modifier.alpha(alphaAnimation))
+ }
+ }
+ }
+ }
+ }
+ }
+
+}
+
+@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/aiedge/gallery/ui/common/modelitem/ModelItemActionButton.kt b/Android/src/app/src/main/java/com/google/aiedge/gallery/ui/common/modelitem/ModelItemActionButton.kt
new file mode 100644
index 0000000..4ec8295
--- /dev/null
+++ b/Android/src/app/src/main/java/com/google/aiedge/gallery/ui/common/modelitem/ModelItemActionButton.kt
@@ -0,0 +1,133 @@
+/*
+ * 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.aiedge.gallery.ui.common.modelitem
+
+import android.content.Context
+import androidx.compose.foundation.layout.Row
+import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.layout.size
+import androidx.compose.material.icons.Icons
+import androidx.compose.material.icons.rounded.Cancel
+import androidx.compose.material.icons.rounded.Delete
+import androidx.compose.material.icons.rounded.FileDownload
+import androidx.compose.material3.CircularProgressIndicator
+import androidx.compose.material3.Icon
+import androidx.compose.material3.IconButton
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.getValue
+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.unit.dp
+import com.google.aiedge.gallery.data.Model
+import com.google.aiedge.gallery.data.ModelDownloadStatus
+import com.google.aiedge.gallery.data.ModelDownloadStatusType
+import com.google.aiedge.gallery.data.Task
+import com.google.aiedge.gallery.ui.common.getTaskIconColor
+import com.google.aiedge.gallery.ui.modelmanager.ModelManagerViewModel
+
+/**
+ * Composable function to display action buttons for a model item, based on its download status.
+ *
+ * This function renders the appropriate action button (download, delete, cancel) based on the
+ * provided ModelDownloadStatus. It also handles notification permission requests for downloading
+ * and displays a confirmation dialog for deleting models.
+ */
+@Composable
+fun ModelItemActionButton(
+ context: Context,
+ model: Model,
+ task: Task,
+ modelManagerViewModel: ModelManagerViewModel,
+ downloadStatus: ModelDownloadStatus?,
+ onDownloadClicked: (Model) -> Unit,
+ modifier: Modifier = Modifier,
+ showDeleteButton: Boolean = true,
+ showDownloadButton: Boolean = true,
+) {
+ var showConfirmDeleteDialog by remember { mutableStateOf(false) }
+
+ Row(verticalAlignment = Alignment.CenterVertically, modifier = modifier) {
+ when (downloadStatus?.status) {
+ // Button to start the download.
+ ModelDownloadStatusType.NOT_DOWNLOADED, ModelDownloadStatusType.FAILED ->
+ if (showDownloadButton) {
+ 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),
+ )
+ }
+
+ }
+ }
+
+ // 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)
+ )
+ }
+
+ // Button to cancel the download when it is in progress.
+ ModelDownloadStatusType.IN_PROGRESS, ModelDownloadStatusType.UNZIPPING -> IconButton(onClick = {
+ modelManagerViewModel.cancelDownloadModel(
+ model
+ )
+ }) {
+ Icon(
+ Icons.Rounded.Cancel,
+ contentDescription = "",
+ tint = getTaskIconColor(task),
+ )
+ }
+
+ else -> {}
+ }
+ }
+
+ if (showConfirmDeleteDialog) {
+ ConfirmDeleteModelDialog(model = model, onConfirm = {
+ modelManagerViewModel.deleteModel(model)
+ showConfirmDeleteDialog = false
+ }, onDismiss = {
+ showConfirmDeleteDialog = false
+ })
+ }
+}
\ No newline at end of file
diff --git a/Android/src/app/src/main/java/com/google/aiedge/gallery/ui/common/modelitem/ModelNameAndStatus.kt b/Android/src/app/src/main/java/com/google/aiedge/gallery/ui/common/modelitem/ModelNameAndStatus.kt
new file mode 100644
index 0000000..07cc6a0
--- /dev/null
+++ b/Android/src/app/src/main/java/com/google/aiedge/gallery/ui/common/modelitem/ModelNameAndStatus.kt
@@ -0,0 +1,187 @@
+/*
+ * 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.aiedge.gallery.ui.common.modelitem
+
+import androidx.compose.animation.core.Animatable
+import androidx.compose.animation.core.tween
+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.material3.LinearProgressIndicator
+import androidx.compose.material3.MaterialTheme
+import androidx.compose.material3.Text
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.LaunchedEffect
+import androidx.compose.runtime.remember
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.text.style.TextAlign
+import androidx.compose.ui.text.style.TextOverflow
+import androidx.compose.ui.unit.dp
+import androidx.compose.ui.unit.sp
+import com.google.aiedge.gallery.data.Model
+import com.google.aiedge.gallery.data.ModelDownloadStatus
+import com.google.aiedge.gallery.data.ModelDownloadStatusType
+import com.google.aiedge.gallery.data.Task
+import com.google.aiedge.gallery.ui.common.formatToHourMinSecond
+import com.google.aiedge.gallery.ui.common.getTaskIconColor
+import com.google.aiedge.gallery.ui.common.humanReadableSize
+import com.google.aiedge.gallery.ui.theme.labelSmallNarrow
+
+/**
+ * Composable function to display the model name and its download status information.
+ *
+ * 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.
+ * - "Unzipping..." status for unzipping processes.
+ * - Model size for successful downloads.
+ */
+@Composable
+fun ModelNameAndStatus(
+ model: Model,
+ task: Task,
+ downloadStatus: ModelDownloadStatus?,
+ isExpanded: Boolean,
+ modifier: Modifier = Modifier
+) {
+ val inProgress = downloadStatus?.status == ModelDownloadStatusType.IN_PROGRESS
+ val isPartiallyDownloaded = downloadStatus?.status == ModelDownloadStatusType.PARTIALLY_DOWNLOADED
+ var curDownloadProgress = 0f
+
+ Column(
+ horizontalAlignment = if (isExpanded) Alignment.CenterHorizontally else Alignment.Start
+ ) {
+ // Model name.
+ Row(
+ verticalAlignment = Alignment.CenterVertically,
+ ) {
+ Text(
+ model.name,
+ style = MaterialTheme.typography.titleMedium,
+ modifier = modifier,
+ )
+ }
+
+ Row(verticalAlignment = Alignment.CenterVertically) {
+ // Status icon.
+ if (!inProgress && !isPartiallyDownloaded) {
+ StatusIcon(
+ downloadStatus = downloadStatus,
+ modifier = modifier.padding(end = 4.dp)
+ )
+ }
+
+ // Failure message.
+ if (downloadStatus != null && downloadStatus.status == ModelDownloadStatusType.FAILED) {
+ Row(verticalAlignment = Alignment.CenterVertically) {
+ Text(
+ downloadStatus.errorMessage,
+ color = MaterialTheme.colorScheme.error,
+ style = labelSmallNarrow,
+ overflow = TextOverflow.Ellipsis,
+ modifier = modifier,
+ )
+ }
+ }
+
+ // Status label
+ else {
+ var sizeLabel = model.totalBytes.humanReadableSize()
+ var fontSize = 11.sp
+
+ // Populate the status label.
+ if (downloadStatus != null) {
+ // For in-progress model, show {receivedSize} / {totalSize} - {rate} - {remainingTime}
+ if (inProgress || isPartiallyDownloaded) {
+ var totalSize = downloadStatus.totalBytes
+ if (totalSize == 0L) {
+ totalSize = model.totalBytes
+ }
+ sizeLabel =
+ "${downloadStatus.receivedBytes.humanReadableSize(extraDecimalForGbAndAbove = true)} of ${totalSize.humanReadableSize()}"
+ if (downloadStatus.bytesPerSecond > 0) {
+ sizeLabel =
+ "$sizeLabel · ${downloadStatus.bytesPerSecond.humanReadableSize()} / s"
+ if (downloadStatus.remainingMs >= 0) {
+ sizeLabel =
+ "$sizeLabel\n${downloadStatus.remainingMs.formatToHourMinSecond()} left"
+ }
+ }
+ if (isPartiallyDownloaded) {
+ sizeLabel = "$sizeLabel (resuming...)"
+ }
+ curDownloadProgress =
+ downloadStatus.receivedBytes.toFloat() / downloadStatus.totalBytes.toFloat()
+ if (curDownloadProgress.isNaN()) {
+ curDownloadProgress = 0f
+ }
+ fontSize = 9.sp
+ }
+ // Status for unzipping.
+ else if (downloadStatus.status == ModelDownloadStatusType.UNZIPPING) {
+ sizeLabel = "Unzipping..."
+ }
+ }
+
+ Column(
+ horizontalAlignment = if (isExpanded) Alignment.CenterHorizontally else Alignment.Start,
+ ) {
+ for ((index, line) in sizeLabel.split("\n").withIndex()) {
+ Text(
+ line,
+ color = MaterialTheme.colorScheme.secondary,
+ maxLines = 1,
+ 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)
+ )
+ }
+ }
+ }
+ }
+
+ // Download progress bar.
+ if (inProgress || isPartiallyDownloaded) {
+ val animatedProgress = remember { Animatable(0f) }
+ LinearProgressIndicator(
+ progress = { animatedProgress.value },
+ color = getTaskIconColor(task = task),
+ trackColor = MaterialTheme.colorScheme.surfaceContainerHighest,
+ modifier = modifier.padding(top = 2.dp)
+ )
+ LaunchedEffect(curDownloadProgress) {
+ animatedProgress.animateTo(curDownloadProgress, animationSpec = tween(150))
+ }
+ }
+ // Unzipping progress.
+ else if (downloadStatus?.status == ModelDownloadStatusType.UNZIPPING) {
+ LinearProgressIndicator(
+ color = getTaskIconColor(task = task),
+ trackColor = MaterialTheme.colorScheme.surfaceContainerHighest,
+ modifier = Modifier
+ .padding(top = 2.dp),
+ )
+ }
+
+
+ }
+}
diff --git a/Android/src/app/src/main/java/com/google/aiedge/gallery/ui/common/modelitem/StatusIcon.kt b/Android/src/app/src/main/java/com/google/aiedge/gallery/ui/common/modelitem/StatusIcon.kt
new file mode 100644
index 0000000..cb94e8b
--- /dev/null
+++ b/Android/src/app/src/main/java/com/google/aiedge/gallery/ui/common/modelitem/StatusIcon.kt
@@ -0,0 +1,94 @@
+/*
+ * 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.aiedge.gallery.ui.common.modelitem
+
+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
+import androidx.compose.material.icons.automirrored.outlined.HelpOutline
+import androidx.compose.material.icons.filled.DownloadForOffline
+import androidx.compose.material.icons.rounded.Error
+import androidx.compose.material3.Icon
+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.aiedge.gallery.data.ModelDownloadStatus
+import com.google.aiedge.gallery.data.ModelDownloadStatusType
+import com.google.aiedge.gallery.ui.theme.GalleryTheme
+
+/**
+ * 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
+ ) {
+ when (downloadStatus?.status) {
+ ModelDownloadStatusType.NOT_DOWNLOADED -> Icon(
+ Icons.AutoMirrored.Outlined.HelpOutline,
+ tint = Color(0xFFCCCCCC),
+ contentDescription = "",
+ modifier = Modifier.size(14.dp)
+ )
+
+ ModelDownloadStatusType.SUCCEEDED -> {
+ Icon(
+ Icons.Filled.DownloadForOffline,
+ tint = Color(0xff3d860b),
+ contentDescription = "",
+ modifier = Modifier.size(14.dp)
+ )
+ }
+
+ ModelDownloadStatusType.FAILED -> Icon(
+ Icons.Rounded.Error,
+ tint = Color(0xFFAA0000),
+ contentDescription = "",
+ modifier = Modifier.size(14.dp)
+ )
+
+ 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)
+ }
+ }
+ }
+}
diff --git a/Android/src/app/src/main/java/com/google/aiedge/gallery/ui/home/HomeScreen.kt b/Android/src/app/src/main/java/com/google/aiedge/gallery/ui/home/HomeScreen.kt
new file mode 100644
index 0000000..78bf841
--- /dev/null
+++ b/Android/src/app/src/main/java/com/google/aiedge/gallery/ui/home/HomeScreen.kt
@@ -0,0 +1,273 @@
+/*
+ * 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.aiedge.gallery.ui.home
+
+import androidx.annotation.StringRes
+import androidx.compose.foundation.background
+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.PaddingValues
+import androidx.compose.foundation.layout.Spacer
+import androidx.compose.foundation.layout.aspectRatio
+import androidx.compose.foundation.layout.fillMaxSize
+import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.foundation.layout.height
+import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.lazy.grid.GridCells
+import androidx.compose.foundation.lazy.grid.GridItemSpan
+import androidx.compose.foundation.lazy.grid.LazyVerticalGrid
+import androidx.compose.foundation.lazy.grid.items
+import androidx.compose.foundation.shape.RoundedCornerShape
+import androidx.compose.material3.Card
+import androidx.compose.material3.CardDefaults
+import androidx.compose.material3.ExperimentalMaterial3Api
+import androidx.compose.material3.MaterialTheme
+import androidx.compose.material3.Scaffold
+import androidx.compose.material3.Text
+import androidx.compose.material3.TopAppBarDefaults
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.collectAsState
+import androidx.compose.runtime.getValue
+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.Brush
+import androidx.compose.ui.input.nestedscroll.nestedScroll
+import androidx.compose.ui.layout.layout
+import androidx.compose.ui.platform.LocalConfiguration
+import androidx.compose.ui.platform.LocalContext
+import androidx.compose.ui.res.stringResource
+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 androidx.compose.ui.unit.sp
+import com.google.aiedge.gallery.GalleryTopAppBar
+import com.google.aiedge.gallery.R
+import com.google.aiedge.gallery.data.AppBarAction
+import com.google.aiedge.gallery.data.AppBarActionType
+import com.google.aiedge.gallery.data.ConfigKey
+import com.google.aiedge.gallery.data.Task
+import com.google.aiedge.gallery.ui.common.TaskIcon
+import com.google.aiedge.gallery.ui.common.getTaskBgColor
+import com.google.aiedge.gallery.ui.modelmanager.ModelManagerViewModel
+import com.google.aiedge.gallery.ui.preview.PreviewModelManagerViewModel
+import com.google.aiedge.gallery.ui.theme.GalleryTheme
+import com.google.aiedge.gallery.ui.theme.ThemeSettings
+import com.google.aiedge.gallery.ui.theme.customColors
+import com.google.aiedge.gallery.ui.theme.titleMediumNarrow
+
+/** Navigation destination data */
+object HomeScreenDestination {
+ @StringRes
+ val titleRes = R.string.app_name
+}
+
+@OptIn(ExperimentalMaterial3Api::class)
+@Composable
+fun HomeScreen(
+ modelManagerViewModel: ModelManagerViewModel,
+ navigateToTaskScreen: (Task) -> Unit,
+ modifier: Modifier = Modifier
+) {
+ val scrollBehavior = TopAppBarDefaults.pinnedScrollBehavior()
+ val uiState by modelManagerViewModel.uiState.collectAsState()
+ var showSettingsDialog by remember { mutableStateOf(false) }
+
+ val tasks = uiState.tasks
+ val loadingHfModels = uiState.loadingHfModels
+
+ Scaffold(modifier = modifier.nestedScroll(scrollBehavior.nestedScrollConnection), topBar = {
+ GalleryTopAppBar(
+ title = stringResource(HomeScreenDestination.titleRes),
+ rightAction = AppBarAction(
+ actionType = AppBarActionType.APP_SETTING, actionFn = {
+ showSettingsDialog = true
+ }
+ ),
+ loadingHfModels = loadingHfModels,
+ scrollBehavior = scrollBehavior,
+ )
+ }) { innerPadding ->
+ TaskList(
+ tasks = tasks,
+ navigateToTaskScreen = navigateToTaskScreen,
+ modifier = Modifier.fillMaxSize(),
+ contentPadding = innerPadding,
+ )
+ }
+
+ // Settings dialog.
+ if (showSettingsDialog) {
+ SettingsDialog(
+ curThemeOverride = modelManagerViewModel.readThemeOverride(),
+ onDismissed = { showSettingsDialog = false },
+ onOk = { curConfigValues ->
+ // Update theme settings.
+ // This will update app's theme.
+ val themeOverride = curConfigValues[ConfigKey.THEME.label] as String
+ ThemeSettings.themeOverride.value = themeOverride
+
+ // Save to data store.
+ modelManagerViewModel.saveThemeOverride(themeOverride)
+ },
+ )
+ }
+}
+
+@Composable
+private fun TaskList(
+ tasks: List,
+ navigateToTaskScreen: (Task) -> Unit,
+ modifier: Modifier = Modifier,
+ contentPadding: PaddingValues = PaddingValues(0.dp),
+) {
+ Box(modifier = modifier.fillMaxSize()) {
+ LazyVerticalGrid(
+ columns = GridCells.Fixed(count = 2),
+ contentPadding = contentPadding,
+ modifier = modifier.padding(12.dp),
+ horizontalArrangement = Arrangement.spacedBy(8.dp),
+ verticalArrangement = Arrangement.spacedBy(8.dp),
+ ) {
+ // Headline.
+ item(span = { GridItemSpan(2) }) {
+ Text(
+ "Welcome to AI Edge Gallery! Explore a world of \namazing on-device models from LiteRT community",
+ textAlign = TextAlign.Center,
+ style = MaterialTheme.typography.bodyMedium.copy(fontWeight = FontWeight.SemiBold),
+ modifier = Modifier.padding(bottom = 20.dp)
+ )
+ }
+
+ // Cards.
+ items(tasks) { task ->
+ TaskCard(
+ task = task,
+ onClick = {
+ navigateToTaskScreen(task)
+ },
+ modifier = Modifier
+ .fillMaxWidth()
+ .aspectRatio(1f)
+ )
+ }
+ }
+
+ // Gradient overlay at the bottom.
+ Box(
+ modifier = Modifier
+ .fillMaxWidth()
+ .height(LocalConfiguration.current.screenHeightDp.dp * 0.25f)
+ .background(
+ Brush.verticalGradient(
+ colors = MaterialTheme.customColors.homeBottomGradient,
+ )
+ )
+ .align(Alignment.BottomCenter)
+ )
+ }
+}
+
+@Composable
+private fun TaskCard(task: Task, onClick: () -> Unit, modifier: Modifier = Modifier) {
+ Card(
+ modifier = modifier
+ .clip(RoundedCornerShape(43.5.dp))
+ .clickable(
+ onClick = onClick,
+ ),
+ colors = CardDefaults.cardColors(
+ containerColor = getTaskBgColor(task = task)
+ ),
+ ) {
+ Column(
+ modifier = Modifier
+ .fillMaxSize()
+ .padding(24.dp),
+ ) {
+ // Icon.
+ TaskIcon(task = task)
+
+ Spacer(modifier = Modifier.weight(1f))
+
+ // Title.
+ val pair = task.type.label.splitByFirstSpace()
+ Text(
+ pair.first,
+ color = MaterialTheme.colorScheme.primary,
+ style = titleMediumNarrow.copy(
+ fontSize = 20.sp,
+ fontWeight = FontWeight.Bold,
+ ),
+ )
+ if (pair.second.isNotEmpty()) {
+ Text(
+ pair.second,
+ color = MaterialTheme.colorScheme.primary,
+ style = titleMediumNarrow.copy(
+ fontSize = 18.sp,
+ fontWeight = FontWeight.Bold,
+ ),
+ modifier = Modifier.layout { measurable, constraints ->
+ val placeable = measurable.measure(constraints)
+ layout(placeable.width, placeable.height) {
+ placeable.placeRelative(0, -4.dp.roundToPx())
+ }
+ }
+ )
+ }
+
+ // Model count.
+ val modelCountLabel = when (task.models.size) {
+ 1 -> "1 Model"
+ else -> "%d Models".format(task.models.size)
+ }
+ Text(
+ modelCountLabel,
+ color = MaterialTheme.colorScheme.secondary,
+ style = MaterialTheme.typography.bodyMedium
+ )
+ }
+ }
+}
+
+private fun String.splitByFirstSpace(): Pair {
+ val spaceIndex = this.indexOf(' ')
+ if (spaceIndex == -1) {
+ return Pair(this, "")
+ }
+ return Pair(this.substring(0, spaceIndex), this.substring(spaceIndex + 1))
+}
+
+@Preview
+@Composable
+fun HomeScreenPreview(
+) {
+ GalleryTheme {
+ HomeScreen(
+ modelManagerViewModel = PreviewModelManagerViewModel(context = LocalContext.current),
+ navigateToTaskScreen = {},
+ )
+ }
+}
+
diff --git a/Android/src/app/src/main/java/com/google/aiedge/gallery/ui/home/SettingsDialog.kt b/Android/src/app/src/main/java/com/google/aiedge/gallery/ui/home/SettingsDialog.kt
new file mode 100644
index 0000000..1852d71
--- /dev/null
+++ b/Android/src/app/src/main/java/com/google/aiedge/gallery/ui/home/SettingsDialog.kt
@@ -0,0 +1,60 @@
+/*
+ * 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.aiedge.gallery.ui.home
+
+import androidx.compose.runtime.Composable
+import com.google.aiedge.gallery.VERSION
+import com.google.aiedge.gallery.data.Config
+import com.google.aiedge.gallery.data.ConfigKey
+import com.google.aiedge.gallery.data.SegmentedButtonConfig
+import com.google.aiedge.gallery.ui.common.chat.ConfigDialog
+import com.google.aiedge.gallery.ui.theme.THEME_AUTO
+import com.google.aiedge.gallery.ui.theme.THEME_DARK
+import com.google.aiedge.gallery.ui.theme.THEME_LIGHT
+
+private val CONFIGS: List = listOf(
+ SegmentedButtonConfig(
+ key = ConfigKey.THEME,
+ defaultValue = THEME_AUTO,
+ options = listOf(THEME_AUTO, THEME_LIGHT, THEME_DARK)
+ )
+)
+
+@Composable
+fun SettingsDialog(
+ curThemeOverride: String,
+ onDismissed: () -> Unit,
+ onOk: (Map) -> Unit,
+) {
+ val initialValues = mapOf(
+ ConfigKey.THEME.label to curThemeOverride
+ )
+ ConfigDialog(
+ title = "Settings",
+ subtitle = "App version: $VERSION",
+ okBtnLabel = "OK",
+ configs = CONFIGS,
+ initialValues = initialValues,
+ onDismissed = onDismissed,
+ onOk = { curConfigValues ->
+ onOk(curConfigValues)
+
+ // Hide config dialog.
+ onDismissed()
+ },
+ )
+}
diff --git a/Android/src/app/src/main/java/com/google/aiedge/gallery/ui/icon/Deploy.kt b/Android/src/app/src/main/java/com/google/aiedge/gallery/ui/icon/Deploy.kt
new file mode 100644
index 0000000..85b391f
--- /dev/null
+++ b/Android/src/app/src/main/java/com/google/aiedge/gallery/ui/icon/Deploy.kt
@@ -0,0 +1,91 @@
+/*
+ * 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.aiedge.gallery.ui.icon
+
+import androidx.compose.ui.graphics.Color
+import androidx.compose.ui.graphics.PathFillType
+import androidx.compose.ui.graphics.SolidColor
+import androidx.compose.ui.graphics.StrokeCap
+import androidx.compose.ui.graphics.StrokeJoin
+import androidx.compose.ui.graphics.vector.ImageVector
+import androidx.compose.ui.graphics.vector.path
+import androidx.compose.ui.unit.dp
+
+val Deployed_code: ImageVector
+ get() {
+ 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()
+ return internal_Deployed_code!!
+ }
+
+private var internal_Deployed_code: ImageVector? = null
diff --git a/Android/src/app/src/main/java/com/google/aiedge/gallery/ui/imageclassification/ImageClassificationModelHelper.kt b/Android/src/app/src/main/java/com/google/aiedge/gallery/ui/imageclassification/ImageClassificationModelHelper.kt
new file mode 100644
index 0000000..b9415b2
--- /dev/null
+++ b/Android/src/app/src/main/java/com/google/aiedge/gallery/ui/imageclassification/ImageClassificationModelHelper.kt
@@ -0,0 +1,154 @@
+/*
+ * 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.aiedge.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.aiedge.gallery.ui.common.chat.Classification
+import com.google.aiedge.gallery.data.ConfigKey
+import com.google.aiedge.gallery.data.Model
+import com.google.aiedge.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: () -> 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/aiedge/gallery/ui/imageclassification/ImageClassificationScreen.kt b/Android/src/app/src/main/java/com/google/aiedge/gallery/ui/imageclassification/ImageClassificationScreen.kt
new file mode 100644
index 0000000..9dc2bea
--- /dev/null
+++ b/Android/src/app/src/main/java/com/google/aiedge/gallery/ui/imageclassification/ImageClassificationScreen.kt
@@ -0,0 +1,97 @@
+/*
+ * 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.aiedge.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.aiedge.gallery.ui.ViewModelProvider
+import com.google.aiedge.gallery.ui.common.chat.ChatInputType
+import com.google.aiedge.gallery.ui.common.chat.ChatMessageImage
+import com.google.aiedge.gallery.ui.common.chat.ChatView
+import com.google.aiedge.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, message ->
+ 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/aiedge/gallery/ui/imageclassification/ImageClassificationViewModel.kt b/Android/src/app/src/main/java/com/google/aiedge/gallery/ui/imageclassification/ImageClassificationViewModel.kt
new file mode 100644
index 0000000..769f549
--- /dev/null
+++ b/Android/src/app/src/main/java/com/google/aiedge/gallery/ui/imageclassification/ImageClassificationViewModel.kt
@@ -0,0 +1,165 @@
+/*
+ * 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.aiedge.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.aiedge.gallery.ui.common.chat.ChatMessage
+import com.google.aiedge.gallery.ui.common.chat.ChatMessageClassification
+import com.google.aiedge.gallery.ui.common.chat.ChatMessageImage
+import com.google.aiedge.gallery.ui.common.chat.ChatMessageType
+import com.google.aiedge.gallery.data.Model
+import com.google.aiedge.gallery.data.TASK_IMAGE_CLASSIFICATION
+import com.google.aiedge.gallery.ui.common.chat.ChatViewModel
+import com.google.aiedge.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/aiedge/gallery/ui/imagegeneration/ImageGenerationModelHelper.kt b/Android/src/app/src/main/java/com/google/aiedge/gallery/ui/imagegeneration/ImageGenerationModelHelper.kt
new file mode 100644
index 0000000..dfd4d83
--- /dev/null
+++ b/Android/src/app/src/main/java/com/google/aiedge/gallery/ui/imagegeneration/ImageGenerationModelHelper.kt
@@ -0,0 +1,77 @@
+/*
+ * 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.aiedge.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.aiedge.gallery.data.ConfigKey
+import com.google.aiedge.gallery.data.Model
+import com.google.aiedge.gallery.ui.common.LatencyProvider
+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: () -> Unit) {
+ val options = ImageGenerator.ImageGeneratorOptions.builder()
+ .setImageGeneratorModelDirectory(model.getPath(context = context))
+ .build()
+ model.instance = ImageGenerator.createFromOptions(context, options)
+ 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, message ->
+ 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/aiedge/gallery/ui/imagegeneration/ImageGenerationViewModel.kt b/Android/src/app/src/main/java/com/google/aiedge/gallery/ui/imagegeneration/ImageGenerationViewModel.kt
new file mode 100644
index 0000000..1715dc4
--- /dev/null
+++ b/Android/src/app/src/main/java/com/google/aiedge/gallery/ui/imagegeneration/ImageGenerationViewModel.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.aiedge.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.aiedge.gallery.data.Model
+import com.google.aiedge.gallery.data.TASK_IMAGE_GENERATION
+import com.google.aiedge.gallery.ui.common.chat.ChatMessageImageWithHistory
+import com.google.aiedge.gallery.ui.common.chat.ChatMessageLoading
+import com.google.aiedge.gallery.ui.common.chat.ChatMessageType
+import com.google.aiedge.gallery.ui.common.chat.ChatSide
+import com.google.aiedge.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/aiedge/gallery/ui/llmchat/LlmChatConfigs.kt b/Android/src/app/src/main/java/com/google/aiedge/gallery/ui/llmchat/LlmChatConfigs.kt
new file mode 100644
index 0000000..788a5c6
--- /dev/null
+++ b/Android/src/app/src/main/java/com/google/aiedge/gallery/ui/llmchat/LlmChatConfigs.kt
@@ -0,0 +1,84 @@
+/*
+ * 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.aiedge.gallery.ui.llmchat
+
+import com.google.aiedge.gallery.data.Config
+import com.google.aiedge.gallery.data.ConfigKey
+import com.google.aiedge.gallery.data.ConfigValue
+import com.google.aiedge.gallery.data.NumberSliderConfig
+import com.google.aiedge.gallery.data.ValueType
+import com.google.aiedge.gallery.data.getFloatConfigValue
+import com.google.aiedge.gallery.data.getIntConfigValue
+
+private const val DEFAULT_MAX_TOKEN = 1024
+private const val DEFAULT_TOPK = 40
+private const val DEFAULT_TOPP = 0.9f
+private const val DEFAULT_TEMPERATURE = 1.0f
+
+fun createLlmChatConfigs(
+ defaultMaxToken: Int = DEFAULT_MAX_TOKEN,
+ defaultTopK: Int = DEFAULT_TOPK,
+ defaultTopP: Float = DEFAULT_TOPP,
+ defaultTemperature: Float = DEFAULT_TEMPERATURE
+): List {
+ return listOf(
+ NumberSliderConfig(
+ key = ConfigKey.MAX_TOKENS,
+ sliderMin = 100f,
+ sliderMax = 1024f,
+ defaultValue = defaultMaxToken.toFloat(),
+ valueType = ValueType.INT
+ ),
+ 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
+ ),
+ )
+}
+
+fun createLLmChatConfig(defaults: Map): List {
+ val defaultMaxToken =
+ getIntConfigValue(defaults[ConfigKey.MAX_TOKENS.id], default = DEFAULT_MAX_TOKEN)
+ val defaultTopK = getIntConfigValue(defaults[ConfigKey.TOPK.id], default = DEFAULT_TOPK)
+ val defaultTopP = getFloatConfigValue(defaults[ConfigKey.TOPP.id], default = DEFAULT_TOPP)
+ val defaultTemperature =
+ getFloatConfigValue(defaults[ConfigKey.TEMPERATURE.id], default = DEFAULT_TEMPERATURE)
+
+ return createLlmChatConfigs(
+ defaultMaxToken = defaultMaxToken,
+ defaultTopK = defaultTopK,
+ defaultTopP = defaultTopP,
+ defaultTemperature = defaultTemperature
+ )
+}
diff --git a/Android/src/app/src/main/java/com/google/aiedge/gallery/ui/llmchat/LlmChatModelHelper.kt b/Android/src/app/src/main/java/com/google/aiedge/gallery/ui/llmchat/LlmChatModelHelper.kt
new file mode 100644
index 0000000..9d3b4f7
--- /dev/null
+++ b/Android/src/app/src/main/java/com/google/aiedge/gallery/ui/llmchat/LlmChatModelHelper.kt
@@ -0,0 +1,135 @@
+/*
+ * 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.aiedge.gallery.ui.llmchat
+
+import android.content.Context
+import android.util.Log
+import com.google.common.util.concurrent.ListenableFuture
+import com.google.mediapipe.tasks.genai.llminference.LlmInference
+import com.google.mediapipe.tasks.genai.llminference.LlmInferenceSession
+import com.google.aiedge.gallery.data.ConfigKey
+import com.google.aiedge.gallery.data.LlmBackend
+import com.google.aiedge.gallery.data.Model
+
+private const val TAG = "AGLlmChatModelHelper"
+private const val DEFAULT_MAX_TOKEN = 1024
+private const val DEFAULT_TOPK = 40
+private const val DEFAULT_TOPP = 0.9f
+private const val DEFAULT_TEMPERATURE = 1.0f
+
+typealias ResultListener = (partialResult: String, done: Boolean) -> Unit
+typealias CleanUpListener = () -> Unit
+
+data class LlmModelInstance(val engine: LlmInference, val session: LlmInferenceSession)
+
+object LlmChatModelHelper {
+ // Indexed by model name.
+ private val cleanUpListeners: MutableMap = mutableMapOf()
+ private val generateResponseListenableFutures: MutableMap> =
+ mutableMapOf()
+
+ fun initialize(
+ context: Context, model: Model, onDone: () -> Unit
+ ) {
+ val maxTokens =
+ model.getIntConfigValue(key = ConfigKey.MAX_TOKENS, defaultValue = DEFAULT_MAX_TOKEN)
+ val topK = model.getIntConfigValue(key = ConfigKey.TOPK, defaultValue = DEFAULT_TOPK)
+ val topP = model.getFloatConfigValue(key = ConfigKey.TOPP, defaultValue = DEFAULT_TOPP)
+ val temperature =
+ model.getFloatConfigValue(key = ConfigKey.TEMPERATURE, defaultValue = DEFAULT_TEMPERATURE)
+ Log.d(TAG, "Initializing...")
+ val preferredBackend = when (model.llmBackend) {
+ LlmBackend.CPU -> LlmInference.Backend.CPU
+ LlmBackend.GPU -> LlmInference.Backend.GPU
+ }
+ val options =
+ LlmInference.LlmInferenceOptions.builder().setModelPath(model.getPath(context = context))
+ .setMaxTokens(maxTokens).setPreferredBackend(preferredBackend).build()
+
+ // Create an instance of the LLM Inference task
+ try {
+ val llmInference = LlmInference.createFromOptions(context, options)
+
+// val session = LlmInferenceSession.createFromOptions(
+// llmInference,
+// LlmInferenceSession.LlmInferenceSessionOptions.builder().setTopK(topK).setTopP(topP)
+// .setTemperature(temperature).build()
+// )
+ model.instance = llmInference
+// LlmModelInstance(engine = llmInference, session = session)
+ } catch (e: Exception) {
+ e.printStackTrace()
+ }
+ onDone()
+ }
+
+ fun cleanUp(model: Model) {
+ if (model.instance == null) {
+ return
+ }
+
+ val instance = model.instance as LlmInference
+ try {
+ instance.close()
+// instance.session.close()
+// instance.engine.close()
+ } catch (e: Exception) {
+ // ignore
+ }
+ val onCleanUp = cleanUpListeners.remove(model.name)
+ if (onCleanUp != null) {
+ onCleanUp()
+ }
+ model.instance = null
+ Log.d(TAG, "Clean up done.")
+ }
+
+ fun runInference(
+ model: Model,
+ input: String,
+ resultListener: ResultListener,
+ cleanUpListener: CleanUpListener,
+ ) {
+ val instance = model.instance as LlmInference
+
+ // Set listener.
+ if (!cleanUpListeners.containsKey(model.name)) {
+ cleanUpListeners[model.name] = cleanUpListener
+ }
+
+ // Start async inference.
+ val future = instance.generateResponseAsync(input, resultListener)
+ generateResponseListenableFutures[model.name] = future
+
+// val session = instance.session
+// TODO: need to count token and reset session.
+// session.addQueryChunk(input)
+// session.generateResponseAsync(resultListener)
+ }
+
+ fun stopInference(model: Model) {
+ val instance = model.instance as LlmInference
+ if (instance != null) {
+ instance.close()
+ }
+// val future = generateResponseListenableFutures[model.name]
+// if (future != null) {
+// future.cancel(true)
+// generateResponseListenableFutures.remove(model.name)
+// }
+ }
+}
diff --git a/Android/src/app/src/main/java/com/google/aiedge/gallery/ui/llmchat/LlmChatScreen.kt b/Android/src/app/src/main/java/com/google/aiedge/gallery/ui/llmchat/LlmChatScreen.kt
new file mode 100644
index 0000000..dbc54e5
--- /dev/null
+++ b/Android/src/app/src/main/java/com/google/aiedge/gallery/ui/llmchat/LlmChatScreen.kt
@@ -0,0 +1,77 @@
+/*
+ * 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.aiedge.gallery.ui.llmchat
+
+import androidx.compose.runtime.Composable
+import androidx.compose.ui.Modifier
+import androidx.lifecycle.viewmodel.compose.viewModel
+import com.google.aiedge.gallery.ui.ViewModelProvider
+import com.google.aiedge.gallery.ui.common.chat.ChatMessageText
+import com.google.aiedge.gallery.ui.common.chat.ChatView
+import com.google.aiedge.gallery.ui.modelmanager.ModelManagerViewModel
+import kotlinx.serialization.Serializable
+
+/** Navigation destination data */
+object LlmChatDestination {
+ @Serializable
+ val route = "LlmChatRoute"
+}
+
+@Composable
+fun LlmChatScreen(
+ modelManagerViewModel: ModelManagerViewModel,
+ navigateUp: () -> Unit,
+ modifier: Modifier = Modifier,
+ viewModel: LlmChatViewModel = viewModel(
+ factory = ViewModelProvider.Factory
+ ),
+) {
+ ChatView(
+ task = viewModel.task,
+ viewModel = viewModel,
+ modelManagerViewModel = modelManagerViewModel,
+ onSendMessage = { model, message ->
+ viewModel.addMessage(
+ model = model,
+ message = message,
+ )
+ if (message is ChatMessageText) {
+ modelManagerViewModel.addTextInputHistory(message.content)
+ viewModel.generateResponse(
+ model = model,
+ input = message.content,
+ )
+ }
+ },
+ onRunAgainClicked = { model, message ->
+ if (message is ChatMessageText) {
+ viewModel.runAgain(model = model, message = message)
+ }
+ },
+ onBenchmarkClicked = { model, message, warmUpIterations, benchmarkIterations ->
+ if (message is ChatMessageText) {
+ viewModel.benchmark(
+ model = model,
+ message = message
+ )
+ }
+ },
+ navigateUp = navigateUp,
+ modifier = modifier,
+ )
+}
+
diff --git a/Android/src/app/src/main/java/com/google/aiedge/gallery/ui/llmchat/LlmChatViewModel.kt b/Android/src/app/src/main/java/com/google/aiedge/gallery/ui/llmchat/LlmChatViewModel.kt
new file mode 100644
index 0000000..324c254
--- /dev/null
+++ b/Android/src/app/src/main/java/com/google/aiedge/gallery/ui/llmchat/LlmChatViewModel.kt
@@ -0,0 +1,209 @@
+/*
+ * 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.aiedge.gallery.ui.llmchat
+
+import androidx.lifecycle.viewModelScope
+import com.google.mediapipe.tasks.genai.llminference.LlmInference
+import com.google.aiedge.gallery.data.Model
+import com.google.aiedge.gallery.data.TASK_LLM_CHAT
+import com.google.aiedge.gallery.ui.common.chat.ChatMessageBenchmarkLlmResult
+import com.google.aiedge.gallery.ui.common.chat.ChatMessageLoading
+import com.google.aiedge.gallery.ui.common.chat.ChatMessageText
+import com.google.aiedge.gallery.ui.common.chat.ChatMessageType
+import com.google.aiedge.gallery.ui.common.chat.ChatSide
+import com.google.aiedge.gallery.ui.common.chat.ChatViewModel
+import com.google.aiedge.gallery.ui.common.chat.Stat
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.delay
+import kotlinx.coroutines.launch
+
+private const val TAG = "AGLlmChatViewModel"
+private val STATS = listOf(
+ Stat(id = "time_to_first_token", label = "Time to 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")
+)
+
+class LlmChatViewModel : ChatViewModel(task = TASK_LLM_CHAT) {
+ fun generateResponse(model: Model, input: String) {
+ viewModelScope.launch(Dispatchers.Default) {
+ setInProgress(true)
+
+ // Loading.
+ addMessage(
+ model = model,
+ message = ChatMessageLoading(),
+ )
+
+ // Wait for instance to be initialized.
+ while (model.instance == null) {
+ delay(100)
+ }
+
+ // Run inference.
+ val start = System.currentTimeMillis()
+ LlmChatModelHelper.runInference(
+ model = model,
+ input = input,
+ resultListener = { partialResult, done ->
+ // Remove the last message if it is a "loading" message.
+ // This will only be done once.
+ val lastMessage = getLastMessage(model = model)
+ if (lastMessage?.type == ChatMessageType.LOADING) {
+ removeLastMessage(model = model)
+
+ // Add an empty message that will receive streaming results.
+ addMessage(
+ model = model,
+ message = ChatMessageText(content = "", side = ChatSide.AGENT)
+ )
+ }
+
+ // Incrementally update the streamed partial results.
+ val latencyMs: Long = if (done) System.currentTimeMillis() - start else -1
+ updateLastMessageContentIncrementally(
+ model = model,
+ partialContent = partialResult,
+ latencyMs = latencyMs.toFloat()
+ )
+
+ if (done) {
+ setInProgress(false)
+ }
+ }, cleanUpListener = {
+ setInProgress(false)
+ })
+ }
+ }
+
+ fun runAgain(model: Model, message: ChatMessageText) {
+ viewModelScope.launch(Dispatchers.Default) {
+ // Wait for model to be initialized.
+ while (model.instance == null) {
+ delay(100)
+ }
+
+ // Clone the clicked message and add it.
+ addMessage(model = model, message = message.clone())
+
+ // Run inference.
+ generateResponse(
+ model = model,
+ input = message.content,
+ )
+ }
+ }
+
+ fun benchmark(model: Model, message: ChatMessageText) {
+ viewModelScope.launch(Dispatchers.Default) {
+ setInProgress(true)
+
+ // Wait for model to be initialized.
+ while (model.instance == null) {
+ delay(100)
+ }
+ val instance = model.instance as LlmInference
+ val prefillTokens = instance.sizeInTokens(message.content)
+
+ // Add the message to show benchmark results.
+ val benchmarkLlmResult = ChatMessageBenchmarkLlmResult(
+ orderedStats = STATS,
+ statValues = mutableMapOf(),
+ running = true,
+ latencyMs = -1f,
+ )
+ addMessage(model = model, message = benchmarkLlmResult)
+
+ // Run inference.
+ val result = StringBuilder()
+ var firstRun = true
+ var timeToFirstToken = 0f
+ var firstTokenTs = 0L
+ var decodeTokens = 0
+ var prefillSpeed = 0f
+ var decodeSpeed: Float
+ val start = System.currentTimeMillis()
+ var lastUpdateTime = 0L
+ LlmChatModelHelper.runInference(
+ model = model,
+ input = message.content,
+ resultListener = { partialResult, done ->
+ val curTs = System.currentTimeMillis()
+
+ if (firstRun) {
+ firstTokenTs = System.currentTimeMillis()
+ timeToFirstToken = (firstTokenTs - start) / 1000f
+ prefillSpeed = prefillTokens / timeToFirstToken
+ firstRun = false
+
+ // Update message to show prefill speed.
+ replaceLastMessage(
+ model = model,
+ message = ChatMessageBenchmarkLlmResult(
+ orderedStats = STATS,
+ statValues = mutableMapOf(
+ "prefill_speed" to prefillSpeed,
+ "time_to_first_token" to timeToFirstToken,
+ "latency" to (curTs - start).toFloat() / 1000f,
+ ),
+ running = false,
+ latencyMs = -1f,
+ ),
+ type = ChatMessageType.BENCHMARK_LLM_RESULT,
+ )
+ } else {
+ decodeTokens++
+ }
+ result.append(partialResult)
+
+ if (curTs - lastUpdateTime > 500 || done) {
+ decodeSpeed =
+ decodeTokens / ((curTs - firstTokenTs) / 1000f)
+ if (decodeSpeed.isNaN()) {
+ decodeSpeed = 0f
+ }
+ replaceLastMessage(
+ model = model,
+ message = 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,
+ ),
+ type = ChatMessageType.BENCHMARK_LLM_RESULT
+ )
+ lastUpdateTime = curTs
+
+ if (done) {
+ setInProgress(false)
+ }
+ }
+ },
+ cleanUpListener = {
+ setInProgress(false)
+ }
+ )
+ }
+ }
+}
+
diff --git a/Android/src/app/src/main/java/com/google/aiedge/gallery/ui/modelmanager/ModelList.kt b/Android/src/app/src/main/java/com/google/aiedge/gallery/ui/modelmanager/ModelList.kt
new file mode 100644
index 0000000..59ed91c
--- /dev/null
+++ b/Android/src/app/src/main/java/com/google/aiedge/gallery/ui/modelmanager/ModelList.kt
@@ -0,0 +1,98 @@
+/*
+ * 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.aiedge.gallery.ui.modelmanager
+
+import androidx.compose.foundation.ExperimentalFoundationApi
+import androidx.compose.foundation.layout.Arrangement
+import androidx.compose.foundation.layout.Box
+import androidx.compose.foundation.layout.PaddingValues
+import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.lazy.LazyColumn
+import androidx.compose.foundation.lazy.items
+import androidx.compose.material3.MaterialTheme
+import androidx.compose.material3.Text
+import androidx.compose.runtime.Composable
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.platform.LocalContext
+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.aiedge.gallery.data.Model
+import com.google.aiedge.gallery.data.Task
+import com.google.aiedge.gallery.ui.common.modelitem.ModelItem
+import com.google.aiedge.gallery.ui.preview.PreviewModelManagerViewModel
+import com.google.aiedge.gallery.ui.preview.TASK_TEST1
+import com.google.aiedge.gallery.ui.theme.GalleryTheme
+
+/** The list of models in the model manager. */
+@OptIn(ExperimentalFoundationApi::class)
+@Composable
+fun ModelList(
+ task: Task,
+ modelManagerViewModel: ModelManagerViewModel,
+ contentPadding: PaddingValues,
+ onModelClicked: (Model) -> Unit,
+ modifier: Modifier = Modifier,
+) {
+ LazyColumn(
+ modifier = modifier.padding(top = 8.dp),
+ contentPadding = contentPadding,
+ verticalArrangement = Arrangement.spacedBy(8.dp),
+ ) {
+ // Headline.
+ item(key = "headline") {
+ Text(
+ task.description,
+ textAlign = TextAlign.Center,
+ style = MaterialTheme.typography.bodyMedium.copy(fontWeight = FontWeight.SemiBold),
+ modifier = Modifier
+ .padding(bottom = 20.dp)
+ .fillMaxWidth()
+ )
+ }
+
+ // List of models within a task.
+ items(items = task.models) { model ->
+ Box {
+ ModelItem(
+ model = model,
+ task = task,
+ modelManagerViewModel = modelManagerViewModel,
+ onModelClicked = onModelClicked,
+ modifier = Modifier.padding(start = 12.dp, end = 12.dp)
+ )
+ }
+ }
+ }
+}
+
+@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),
+ )
+ }
+}
diff --git a/Android/src/app/src/main/java/com/google/aiedge/gallery/ui/modelmanager/ModelManager.kt b/Android/src/app/src/main/java/com/google/aiedge/gallery/ui/modelmanager/ModelManager.kt
new file mode 100644
index 0000000..88eaabe
--- /dev/null
+++ b/Android/src/app/src/main/java/com/google/aiedge/gallery/ui/modelmanager/ModelManager.kt
@@ -0,0 +1,130 @@
+/*
+ * 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.aiedge.gallery.ui.modelmanager
+
+import androidx.activity.compose.BackHandler
+import androidx.compose.foundation.layout.fillMaxSize
+import androidx.compose.material3.ExperimentalMaterial3Api
+import androidx.compose.material3.Scaffold
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.collectAsState
+import androidx.compose.runtime.getValue
+import androidx.compose.runtime.rememberCoroutineScope
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.platform.LocalContext
+import androidx.compose.ui.tooling.preview.Preview
+import com.google.aiedge.gallery.GalleryTopAppBar
+import com.google.aiedge.gallery.data.AppBarAction
+import com.google.aiedge.gallery.data.AppBarActionType
+import com.google.aiedge.gallery.data.Model
+import com.google.aiedge.gallery.data.ModelDownloadStatusType
+import com.google.aiedge.gallery.data.Task
+import com.google.aiedge.gallery.data.getModelByName
+import com.google.aiedge.gallery.ui.preview.PreviewModelManagerViewModel
+import com.google.aiedge.gallery.ui.preview.TASK_TEST1
+import com.google.aiedge.gallery.ui.theme.GalleryTheme
+
+/** A screen to manage models. */
+@OptIn(ExperimentalMaterial3Api::class)
+@Composable
+fun ModelManager(
+ task: Task,
+ viewModel: ModelManagerViewModel,
+ navigateUp: () -> Unit,
+ onModelClicked: (Model) -> Unit,
+ modifier: Modifier = Modifier,
+) {
+ val uiState by viewModel.uiState.collectAsState()
+ val coroutineScope = rememberCoroutineScope()
+
+ // Set title based on the task.
+ var title = "${task.type.label} model"
+ if (task.models.size != 1) {
+ title += "s"
+ }
+
+ // Handle system's edge swipe.
+ BackHandler {
+ navigateUp()
+ }
+
+ Scaffold(
+ modifier = modifier,
+ topBar = {
+ GalleryTopAppBar(
+ title = title,
+// subtitle = String.format(
+// stringResource(R.string.downloaded_size),
+// totalSizeInBytes.humanReadableSize()
+// ),
+
+ // Refresh model list button at the left side of the app bar.
+// leftAction = AppBarAction(actionType = if (uiState.loadingHfModels) {
+// AppBarActionType.REFRESHING_MODELS
+// } else {
+// AppBarActionType.REFRESH_MODELS
+// }, actionFn = {
+// coroutineScope.launch(Dispatchers.IO) {
+// viewModel.loadHfModels()
+// }
+// }),
+ leftAction = AppBarAction(actionType = AppBarActionType.NAVIGATE_UP, actionFn = navigateUp)
+
+ // "Done" button at the right side of the app bar to navigate up.
+// rightAction = AppBarAction(
+// actionType = AppBarActionType.NAVIGATE_UP, actionFn = navigateUp
+// ),
+ )
+ },
+ ) { innerPadding ->
+ ModelList(
+ task = task,
+ modelManagerViewModel = viewModel,
+ contentPadding = innerPadding,
+ onModelClicked = onModelClicked,
+ modifier = Modifier.fillMaxSize()
+ )
+ }
+}
+
+private fun getTotalDownloadedFileSize(uiState: ModelManagerUiState): Long {
+ var totalSizeInBytes = 0L
+ for ((name, status) in uiState.modelDownloadStatus.entries) {
+ if (status.status == ModelDownloadStatusType.SUCCEEDED) {
+ totalSizeInBytes += getModelByName(name)?.totalBytes ?: 0L
+ } else if (status.status == ModelDownloadStatusType.IN_PROGRESS) {
+ totalSizeInBytes += status.receivedBytes
+ }
+ }
+ return totalSizeInBytes
+}
+
+
+@Preview
+@Composable
+fun ModelManagerPreview() {
+ val context = LocalContext.current
+
+ GalleryTheme {
+ ModelManager(
+ viewModel = PreviewModelManagerViewModel(context = context),
+ onModelClicked = {},
+ task = TASK_TEST1,
+ navigateUp = {},
+ )
+ }
+}
diff --git a/Android/src/app/src/main/java/com/google/aiedge/gallery/ui/modelmanager/ModelManagerViewModel.kt b/Android/src/app/src/main/java/com/google/aiedge/gallery/ui/modelmanager/ModelManagerViewModel.kt
new file mode 100644
index 0000000..6ed644c
--- /dev/null
+++ b/Android/src/app/src/main/java/com/google/aiedge/gallery/ui/modelmanager/ModelManagerViewModel.kt
@@ -0,0 +1,697 @@
+/*
+ * 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.aiedge.gallery.ui.modelmanager
+
+import android.content.Context
+import android.net.Uri
+import android.util.Log
+import androidx.activity.result.ActivityResult
+import androidx.lifecycle.ViewModel
+import androidx.lifecycle.viewModelScope
+import com.google.aiedge.gallery.data.AGWorkInfo
+import com.google.aiedge.gallery.data.AccessTokenData
+import com.google.aiedge.gallery.data.DataStoreRepository
+import com.google.aiedge.gallery.data.DownloadRepository
+import com.google.aiedge.gallery.data.EMPTY_MODEL
+import com.google.aiedge.gallery.data.HfModel
+import com.google.aiedge.gallery.data.HfModelDetails
+import com.google.aiedge.gallery.data.HfModelSummary
+import com.google.aiedge.gallery.data.Model
+import com.google.aiedge.gallery.data.ModelDownloadStatus
+import com.google.aiedge.gallery.data.ModelDownloadStatusType
+import com.google.aiedge.gallery.data.TASKS
+import com.google.aiedge.gallery.data.Task
+import com.google.aiedge.gallery.data.TaskType
+import com.google.aiedge.gallery.data.getModelByName
+import com.google.aiedge.gallery.ui.common.AuthConfig
+import com.google.aiedge.gallery.ui.imageclassification.ImageClassificationModelHelper
+import com.google.aiedge.gallery.ui.imagegeneration.ImageGenerationModelHelper
+import com.google.aiedge.gallery.ui.llmchat.LlmChatModelHelper
+import com.google.aiedge.gallery.ui.textclassification.TextClassificationModelHelper
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.async
+import kotlinx.coroutines.awaitAll
+import kotlinx.coroutines.coroutineScope
+import kotlinx.coroutines.delay
+import kotlinx.coroutines.flow.MutableStateFlow
+import kotlinx.coroutines.flow.asStateFlow
+import kotlinx.coroutines.flow.update
+import kotlinx.coroutines.launch
+import kotlinx.coroutines.withContext
+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 HG_COMMUNITY = "jinjingforevercommunity"
+private const val TEXT_INPUT_HISTORY_MAX_SIZE = 50
+
+enum class ModelInitializationStatus {
+ NOT_INITIALIZED, INITIALIZING, INITIALIZED,
+}
+
+enum class TokenStatus {
+ NOT_STORED, EXPIRED, NOT_EXPIRED,
+}
+
+enum class TokenRequestResultType {
+ FAILED, SUCCEEDED, USER_CANCELLED
+}
+
+data class TokenStatusAndData(
+ val status: TokenStatus,
+ val data: AccessTokenData?,
+)
+
+data class TokenRequestResult(
+ val status: TokenRequestResultType,
+ val errorMessage: String? = null
+)
+
+data class ModelManagerUiState(
+ /**
+ * A list of tasks available in the application.
+ */
+ val tasks: List,
+
+ /**
+ * A map that stores lists of models indexed by task name.
+ */
+ val modelsByTaskName: Map>,
+
+ /**
+ * 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.
+ */
+ val modelInitializationStatus: Map,
+
+ /**
+ * Whether Hugging Face models from the given community are currently being loaded.
+ */
+ val loadingHfModels: Boolean = false,
+
+ /**
+ * The currently selected model.
+ */
+ val selectedModel: Model = EMPTY_MODEL,
+
+ /**
+ * The history of text inputs entered by the user.
+ */
+ val textInputHistory: List = listOf(),
+)
+
+/**
+ * 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.
+ */
+open class ModelManagerViewModel(
+ private val downloadRepository: DownloadRepository,
+ private val dataStoreRepository: DataStoreRepository,
+ context: Context,
+) : ViewModel() {
+ private val externalFilesDir = context.getExternalFilesDir(null)
+ private val inProgressWorkInfos: List =
+ downloadRepository.getEnqueuedOrRunningWorkInfos()
+ protected val _uiState = MutableStateFlow(createUiState())
+ val uiState = _uiState.asStateFlow()
+ val authService = AuthorizationService(context)
+ var curAccessToken: String = ""
+
+ init {
+ Log.d(TAG, "In-progress worker infos: $inProgressWorkInfos")
+
+ // Iterate through the inProgressWorkInfos and retrieve the corresponding modes.
+ // 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)
+ }
+ }
+
+ // Cancel all pending downloads for the retrieved models.
+ downloadRepository.cancelAll(models) {
+ Log.d(TAG, "All pending work is cancelled")
+
+ viewModelScope.launch(Dispatchers.IO) {
+ // Load models from hg community.
+ loadHfModels()
+ Log.d(TAG, "Done loading HF models")
+
+ // Kick off downloads for these models .
+ withContext(Dispatchers.Main) {
+ for (info in inProgressWorkInfos) {
+ val model: Model? = getModelByName(info.modelName)
+ if (model != null) {
+ Log.d(TAG, "Sending a new download request for '${model.name}'")
+ downloadRepository.downloadModel(
+ model, onStatusUpdated = this@ModelManagerViewModel::setDownloadStatus
+ )
+ }
+ }
+ }
+ }
+ }
+ }
+
+ override fun onCleared() {
+ super.onCleared()
+ authService.dispose()
+ }
+
+ fun selectModel(model: Model) {
+ _uiState.update { _uiState.value.copy(selectedModel = model) }
+ }
+
+ fun downloadModel(model: Model) {
+ // Update status.
+ setDownloadStatus(
+ curModel = model, status = ModelDownloadStatus(status = ModelDownloadStatusType.IN_PROGRESS)
+ )
+
+ // Delete the model files first.
+ deleteModel(model = model)
+
+ // Start to send download request.
+ downloadRepository.downloadModel(
+ model, onStatusUpdated = this::setDownloadStatus
+ )
+ }
+
+ fun cancelDownloadModel(model: Model) {
+ downloadRepository.cancelDownloadModel(model)
+ }
+
+ fun deleteModel(model: Model) {
+ deleteFileFromExternalFilesDir(model.downloadFileName)
+ for (file in model.extraDataFiles) {
+ deleteFileFromExternalFilesDir(file.downloadFileName)
+ }
+ if (model.isZip && model.unzipDir.isNotEmpty()) {
+ deleteDirFromExternalFilesDir(model.unzipDir)
+ }
+
+ // Update model download status to NotDownloaded.
+ val curModelDownloadStatus = uiState.value.modelDownloadStatus.toMutableMap()
+ curModelDownloadStatus[model.name] =
+ ModelDownloadStatus(status = ModelDownloadStatusType.NOT_DOWNLOADED)
+ val newUiState = uiState.value.copy(modelDownloadStatus = curModelDownloadStatus)
+ _uiState.update { newUiState }
+ }
+
+ fun initializeModel(context: Context, model: Model, force: Boolean = false) {
+ viewModelScope.launch(Dispatchers.Default) {
+ // Skip if initialized already.
+ if (!force && uiState.value.modelInitializationStatus[model.name] == ModelInitializationStatus.INITIALIZED) {
+ Log.d(TAG, "Model '${model.name}' has been initialized. Skipping.")
+ return@launch
+ }
+
+ // Skip if initialization is in progress.
+ if (model.initializing) {
+ Log.d(TAG, "Model '${model.name}' is being initialized. Skipping.")
+ return@launch
+ }
+
+ // Clean up.
+ cleanupModel(model = model)
+
+ // Start initialization.
+ Log.d(TAG, "Initializing model '${model.name}'...")
+ model.initializing = true
+
+ // Show initializing status after a delay. When the delay expires, check if the model has
+ // been initialized or not. If so, skip.
+ launch {
+ delay(500)
+ if (model.instance == null) {
+ updateModelInitializationStatus(
+ model = model, status = ModelInitializationStatus.INITIALIZING
+ )
+ }
+ }
+
+ val onDone: () -> Unit = {
+ if (model.instance != null) {
+ Log.d(TAG, "Model '${model.name}' initialized successfully")
+ model.initializing = false
+ updateModelInitializationStatus(
+ model = model,
+ status = ModelInitializationStatus.INITIALIZED,
+ )
+ }
+ }
+ when (model.taskType) {
+ TaskType.TEXT_CLASSIFICATION -> TextClassificationModelHelper.initialize(
+ context = context,
+ model = model,
+ onDone = onDone,
+ )
+
+ TaskType.IMAGE_CLASSIFICATION -> ImageClassificationModelHelper.initialize(
+ context = context,
+ model = model,
+ onDone = onDone,
+ )
+
+ TaskType.LLM_CHAT -> LlmChatModelHelper.initialize(
+ context = context,
+ model = model,
+ onDone = onDone,
+ )
+
+ TaskType.IMAGE_GENERATION -> ImageGenerationModelHelper.initialize(
+ context = context, model = model, onDone = onDone
+ )
+
+ else -> {}
+ }
+ }
+ }
+
+ fun cleanupModel(model: Model) {
+ if (model.instance != null) {
+ Log.d(TAG, "Cleaning up model '${model.name}'...")
+ when (model.taskType) {
+ TaskType.TEXT_CLASSIFICATION -> TextClassificationModelHelper.cleanUp(model = model)
+ TaskType.IMAGE_CLASSIFICATION -> ImageClassificationModelHelper.cleanUp(model = model)
+ TaskType.LLM_CHAT -> LlmChatModelHelper.cleanUp(model = model)
+ TaskType.IMAGE_GENERATION -> ImageGenerationModelHelper.cleanUp(model = model)
+ else -> {}
+ }
+ model.instance = null
+ model.initializing = false
+ updateModelInitializationStatus(
+ model = model, status = ModelInitializationStatus.NOT_INITIALIZED
+ )
+ }
+ }
+
+ fun setDownloadStatus(curModel: Model, status: ModelDownloadStatus) {
+ // Update model download progress.
+ val curModelDownloadStatus = uiState.value.modelDownloadStatus.toMutableMap()
+ curModelDownloadStatus[curModel.name] = status
+ 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) {
+ deleteFileFromExternalFilesDir(curModel.downloadFileName)
+ }
+
+ _uiState.update { newUiState }
+ }
+
+ fun addTextInputHistory(text: String) {
+ if (uiState.value.textInputHistory.indexOf(text) < 0) {
+ val newHistory = uiState.value.textInputHistory.toMutableList()
+ newHistory.add(0, text)
+ if (newHistory.size > TEXT_INPUT_HISTORY_MAX_SIZE) {
+ newHistory.removeAt(newHistory.size - 1)
+ }
+ _uiState.update { _uiState.value.copy(textInputHistory = newHistory) }
+ dataStoreRepository.saveTextInputHistory(_uiState.value.textInputHistory)
+ }
+ }
+
+ fun promoteTextInputHistoryItem(text: String) {
+ val index = uiState.value.textInputHistory.indexOf(text)
+ if (index >= 0) {
+ val newHistory = uiState.value.textInputHistory.toMutableList()
+ newHistory.removeAt(index)
+ newHistory.add(0, text)
+ _uiState.update { _uiState.value.copy(textInputHistory = newHistory) }
+ dataStoreRepository.saveTextInputHistory(_uiState.value.textInputHistory)
+ }
+ }
+
+ fun deleteTextInputHistory(text: String) {
+ val index = uiState.value.textInputHistory.indexOf(text)
+ if (index >= 0) {
+ val newHistory = uiState.value.textInputHistory.toMutableList()
+ newHistory.removeAt(index)
+ _uiState.update { _uiState.value.copy(textInputHistory = newHistory) }
+ dataStoreRepository.saveTextInputHistory(_uiState.value.textInputHistory)
+ }
+ }
+
+ fun clearTextInputHistory() {
+ _uiState.update { _uiState.value.copy(textInputHistory = mutableListOf()) }
+ dataStoreRepository.saveTextInputHistory(_uiState.value.textInputHistory)
+ }
+
+ fun readThemeOverride(): String {
+ return dataStoreRepository.readThemeOverride()
+ }
+
+ fun saveThemeOverride(theme: String) {
+ dataStoreRepository.saveThemeOverride(theme = theme)
+ }
+
+ fun getModelUrlResponse(model: Model, accessToken: String? = null): Int {
+ val url = URL(model.url)
+ val connection = url.openConnection() as HttpURLConnection
+ if (accessToken != null) {
+ connection.setRequestProperty(
+ "Authorization",
+ "Bearer $accessToken"
+ )
+ }
+ connection.connect()
+
+ // Report the result.
+ return connection.responseCode
+ }
+
+ fun getTokenStatusAndData(): TokenStatusAndData {
+ // Try to load token data from DataStore.
+ var tokenStatus = TokenStatus.NOT_STORED
+ Log.d(TAG, "Reading token data from data store...")
+ val tokenData = dataStoreRepository.readAccessTokenData()
+
+ // Token exists.
+ if (tokenData != null) {
+ Log.d(TAG, "Token exists and loaded.")
+
+ // Check expiration (with 5-minute buffer).
+ val curTs = System.currentTimeMillis()
+ val expirationTs = tokenData.expiresAtSeconds - 5 * 60
+ Log.d(
+ TAG,
+ "Checking whether token has expired or not. Current ts: $curTs, expires at: $expirationTs"
+ )
+ if (curTs >= expirationTs) {
+ Log.d(TAG, "Token expired!")
+ tokenStatus = TokenStatus.EXPIRED
+ } else {
+ Log.d(TAG, "Token not expired.")
+ tokenStatus = TokenStatus.NOT_EXPIRED
+ curAccessToken = tokenData.accessToken
+ }
+ } else {
+ Log.d(TAG, "Token doesn't exists.")
+ }
+
+ return TokenStatusAndData(status = tokenStatus, data = tokenData)
+ }
+
+ fun getAuthorizationRequest(): AuthorizationRequest {
+ return AuthorizationRequest.Builder(
+ AuthConfig.authServiceConfig,
+ AuthConfig.clientId,
+ ResponseTypeValues.CODE,
+ Uri.parse(AuthConfig.redirectUri)
+ ).setScope("read-repos").build()
+ }
+
+ fun handleAuthResult(result: ActivityResult, onTokenRequested: (TokenRequestResult) -> Unit) {
+ val dataIntent = result.data
+ if (dataIntent == null) {
+ onTokenRequested(
+ TokenRequestResult(
+ status = TokenRequestResultType.FAILED,
+ errorMessage = "Empty auth result"
+ )
+ )
+ return
+ }
+
+ val response = AuthorizationResponse.fromIntent(dataIntent)
+ val exception = AuthorizationException.fromIntent(dataIntent)
+
+ when {
+ response?.authorizationCode != null -> {
+ // Authorization successful, exchange the code for tokens
+ var errorMessage: String? = null
+ authService.performTokenRequest(
+ response.createTokenExchangeRequest()
+ ) { tokenResponse, tokenEx ->
+ if (tokenResponse != null) {
+ if (tokenResponse.accessToken == null) {
+ errorMessage = "Empty access token"
+ } else if (tokenResponse.refreshToken == null) {
+ errorMessage = "Empty refresh token"
+ } else if (tokenResponse.accessTokenExpirationTime == null) {
+ errorMessage = "Empty expiration time"
+ } else {
+ // Token exchange successful. Store the tokens securely
+ Log.d(TAG, "Token exchange successful. Storing tokens...")
+ dataStoreRepository.saveAccessTokenData(
+ accessToken = tokenResponse.accessToken!!,
+ refreshToken = tokenResponse.refreshToken!!,
+ expiresAt = tokenResponse.accessTokenExpirationTime!!
+ )
+ curAccessToken = tokenResponse.accessToken!!
+ Log.d(TAG, "Token successfully saved.")
+ }
+ } else if (tokenEx != null) {
+ errorMessage = "Token exchange failed: ${tokenEx.message}"
+ } else {
+ errorMessage = "Token exchange failed"
+ }
+ if (errorMessage == null) {
+ onTokenRequested(TokenRequestResult(status = TokenRequestResultType.SUCCEEDED))
+ } else {
+ onTokenRequested(
+ TokenRequestResult(
+ status = TokenRequestResultType.FAILED,
+ errorMessage = errorMessage
+ )
+ )
+ }
+ }
+ }
+
+ exception != null -> {
+ onTokenRequested(
+ TokenRequestResult(
+ status = if (exception.message == "User cancelled flow") TokenRequestResultType.USER_CANCELLED else TokenRequestResultType.FAILED,
+ errorMessage = "${exception.message}"
+ )
+ )
+ }
+
+ else -> {
+ onTokenRequested(
+ TokenRequestResult(
+ status = TokenRequestResultType.USER_CANCELLED,
+ )
+ )
+ }
+ }
+ }
+
+ private fun isModelPartiallyDownloaded(model: Model): Boolean {
+ return inProgressWorkInfos.find { it.modelName == model.name } != null
+ }
+
+ private fun createUiState(): ModelManagerUiState {
+ val modelsByTaskName: Map> =
+ TASKS.associate { task -> task.type.label to task.models }
+ val modelDownloadStatus: MutableMap = mutableMapOf()
+ val modelInstances: MutableMap = mutableMapOf()
+ for ((_, models) in modelsByTaskName.entries) {
+ for (model in models) {
+ modelDownloadStatus[model.name] = getModelDownloadStatus(model = model)
+ modelInstances[model.name] = ModelInitializationStatus.NOT_INITIALIZED
+ }
+ }
+
+ val textInputHistory = dataStoreRepository.readTextInputHistory()
+ Log.d(TAG, "text input history: $textInputHistory")
+
+ return ModelManagerUiState(
+ tasks = TASKS,
+ modelsByTaskName = modelsByTaskName,
+ modelDownloadStatus = modelDownloadStatus,
+ modelInitializationStatus = modelInstances,
+ textInputHistory = textInputHistory,
+ )
+ }
+
+ /**
+ * Retrieves the download status of a model.
+ *
+ * This function determines the download status of a given model by checking if it's fully
+ * downloaded, partially downloaded, or not downloaded at all. It also retrieves the received and
+ * total bytes for partially downloaded models.
+ */
+ private fun getModelDownloadStatus(model: Model): ModelDownloadStatus {
+ var status = ModelDownloadStatusType.NOT_DOWNLOADED
+ var receivedBytes = 0L
+ var totalBytes = 0L
+ if (isModelDownloaded(model = model)) {
+ if (isModelPartiallyDownloaded(model = model)) {
+ status = ModelDownloadStatusType.PARTIALLY_DOWNLOADED
+ val file = File(externalFilesDir, model.downloadFileName)
+ receivedBytes = file.length()
+ totalBytes = model.totalBytes
+ } else {
+ status = ModelDownloadStatusType.SUCCEEDED
+ }
+ }
+ return ModelDownloadStatus(
+ status = status, receivedBytes = receivedBytes, totalBytes = totalBytes
+ )
+ }
+
+ suspend fun loadHfModels() {
+ // Update loading state shown in ui.
+ _uiState.update {
+ uiState.value.copy(
+ loadingHfModels = true,
+ )
+ }
+
+ val modelDownloadStatus = uiState.value.modelDownloadStatus.toMutableMap()
+ val modelInstances = uiState.value.modelInitializationStatus.toMutableMap()
+ try {
+ // Load model summaries.
+ val modelSummaries =
+ getJsonResponse>(url = "https://huggingface.co/api/models?search=$HG_COMMUNITY")
+ Log.d(TAG, "HF model summaries: $modelSummaries")
+
+ // Load individual models in parallel.
+ if (modelSummaries != null) {
+ coroutineScope {
+ val hfModels = modelSummaries.map { summary ->
+ async {
+ val details =
+ getJsonResponse(url = "https://huggingface.co/api/models/${summary.modelId}")
+ if (details != null && details.siblings.find { it.rfilename == "app.json" } != null) {
+ val hfModel =
+ getJsonResponse(url = "https://huggingface.co/${summary.modelId}/resolve/main/app.json")
+ if (hfModel != null) {
+ hfModel.id = details.id
+ }
+ return@async hfModel
+ }
+ return@async null
+ }
+ }
+
+ // Process loaded app.json
+ for (hfModel in hfModels.awaitAll()) {
+ if (hfModel != null) {
+ Log.d(TAG, "HF model: $hfModel")
+ val task = TASKS.find { it.type.label == hfModel.task }
+ val model = hfModel.toModel()
+ if (task != null && task.models.find { it.hfModelId == model.hfModelId } == null) {
+ model.preProcess(task = task)
+ Log.d(TAG, "AG model: $model")
+ task.models.add(model)
+
+ // Add initial status and states.
+ modelDownloadStatus[model.name] = getModelDownloadStatus(model = model)
+ modelInstances[model.name] = ModelInitializationStatus.NOT_INITIALIZED
+ }
+ }
+ }
+ }
+ }
+
+ _uiState.update {
+ uiState.value.copy(
+ loadingHfModels = false,
+ modelDownloadStatus = modelDownloadStatus,
+ modelInitializationStatus = modelInstances
+ )
+ }
+ } catch (e: Exception) {
+ e.printStackTrace()
+ }
+ }
+
+ private inline fun getJsonResponse(url: String): T? {
+ 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 { ignoreUnknownKeys = true } // Handle potential extra fields
+ val jsonObj = json.decodeFromString(response)
+ return jsonObj
+ } else {
+ println("HTTP error: $responseCode")
+ }
+ } catch (e: Exception) {
+ e.printStackTrace()
+ }
+
+ return null
+ }
+
+ private fun isFileInExternalFilesDir(fileName: String): Boolean {
+ if (externalFilesDir != null) {
+ val file = File(externalFilesDir, fileName)
+ return file.exists()
+ } else {
+ return false
+ }
+ }
+
+ private fun deleteFileFromExternalFilesDir(fileName: String) {
+ if (isFileInExternalFilesDir(fileName)) {
+ val file = File(externalFilesDir, fileName)
+ file.delete()
+ }
+ }
+
+ private fun deleteDirFromExternalFilesDir(dir: String) {
+ if (isFileInExternalFilesDir(dir)) {
+ val file = File(externalFilesDir, dir)
+ file.deleteRecursively()
+ }
+ }
+
+ private fun updateModelInitializationStatus(model: Model, status: ModelInitializationStatus) {
+ val curModelInstance = uiState.value.modelInitializationStatus.toMutableMap()
+ curModelInstance[model.name] = status
+ val newUiState = uiState.value.copy(modelInitializationStatus = curModelInstance)
+ _uiState.update { newUiState }
+ }
+
+ private fun isModelDownloaded(model: Model): Boolean {
+ val downloadedFileExists =
+ model.downloadFileName.isNotEmpty() && isFileInExternalFilesDir(model.downloadFileName)
+
+ val unzippedDirectoryExists =
+ model.isZip && model.unzipDir.isNotEmpty() && isFileInExternalFilesDir(model.unzipDir)
+
+ // Will also return true if model is partially downloaded.
+ return downloadedFileExists || unzippedDirectoryExists
+ }
+}
diff --git a/Android/src/app/src/main/java/com/google/aiedge/gallery/ui/navigation/GalleryNavGraph.kt b/Android/src/app/src/main/java/com/google/aiedge/gallery/ui/navigation/GalleryNavGraph.kt
new file mode 100644
index 0000000..8b7741a
--- /dev/null
+++ b/Android/src/app/src/main/java/com/google/aiedge/gallery/ui/navigation/GalleryNavGraph.kt
@@ -0,0 +1,265 @@
+/*
+ * 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.aiedge.gallery.ui.navigation
+
+import android.util.Log
+import androidx.compose.animation.AnimatedContentTransitionScope
+import androidx.compose.animation.AnimatedVisibility
+import androidx.compose.animation.EnterTransition
+import androidx.compose.animation.ExitTransition
+import androidx.compose.animation.core.EaseOutExpo
+import androidx.compose.animation.core.FiniteAnimationSpec
+import androidx.compose.animation.core.tween
+import androidx.compose.animation.slideInHorizontally
+import androidx.compose.animation.slideOutHorizontally
+import androidx.compose.material3.Text
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.getValue
+import androidx.compose.runtime.mutableStateOf
+import androidx.compose.runtime.remember
+import androidx.compose.runtime.setValue
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.unit.IntOffset
+import androidx.compose.ui.zIndex
+import androidx.lifecycle.viewmodel.compose.viewModel
+import androidx.navigation.NavBackStackEntry
+import androidx.navigation.NavHostController
+import androidx.navigation.NavType
+import androidx.navigation.compose.NavHost
+import androidx.navigation.compose.composable
+import androidx.navigation.navArgument
+import com.google.aiedge.gallery.data.Model
+import com.google.aiedge.gallery.data.TASK_IMAGE_CLASSIFICATION
+import com.google.aiedge.gallery.data.TASK_IMAGE_GENERATION
+import com.google.aiedge.gallery.data.TASK_LLM_CHAT
+import com.google.aiedge.gallery.data.TASK_TEXT_CLASSIFICATION
+import com.google.aiedge.gallery.data.Task
+import com.google.aiedge.gallery.data.TaskType
+import com.google.aiedge.gallery.data.getModelByName
+import com.google.aiedge.gallery.ui.ViewModelProvider
+import com.google.aiedge.gallery.ui.home.HomeScreen
+import com.google.aiedge.gallery.ui.imageclassification.ImageClassificationDestination
+import com.google.aiedge.gallery.ui.imageclassification.ImageClassificationScreen
+import com.google.aiedge.gallery.ui.imagegeneration.ImageGenerationDestination
+import com.google.aiedge.gallery.ui.imagegeneration.ImageGenerationScreen
+import com.google.aiedge.gallery.ui.llmchat.LlmChatDestination
+import com.google.aiedge.gallery.ui.llmchat.LlmChatScreen
+import com.google.aiedge.gallery.ui.modelmanager.ModelManager
+import com.google.aiedge.gallery.ui.modelmanager.ModelManagerViewModel
+import com.google.aiedge.gallery.ui.textclassification.TextClassificationDestination
+import com.google.aiedge.gallery.ui.textclassification.TextClassificationScreen
+
+private const val TAG = "AGGalleryNavGraph"
+private const val ROUTE_PLACEHOLDER = "placeholder"
+private const val ENTER_ANIMATION_DURATION_MS = 500
+private val ENTER_ANIMATION_EASING = EaseOutExpo
+private const val ENTER_ANIMATION_DELAY_MS = 100
+
+private const val EXIT_ANIMATION_DURATION_MS = 500
+private val EXIT_ANIMATION_EASING = EaseOutExpo
+
+private fun enterTween(): FiniteAnimationSpec {
+ return tween(
+ ENTER_ANIMATION_DURATION_MS,
+ easing = ENTER_ANIMATION_EASING,
+ delayMillis = ENTER_ANIMATION_DELAY_MS
+ )
+}
+
+private fun exitTween(): FiniteAnimationSpec {
+ return tween(EXIT_ANIMATION_DURATION_MS, easing = EXIT_ANIMATION_EASING)
+}
+
+private fun AnimatedContentTransitionScope<*>.slideEnter(): EnterTransition {
+ return slideIntoContainer(
+ animationSpec = enterTween(),
+ towards = AnimatedContentTransitionScope.SlideDirection.Left,
+ )
+}
+
+private fun AnimatedContentTransitionScope<*>.slideExit(): ExitTransition {
+ return slideOutOfContainer(
+ animationSpec = exitTween(),
+ towards = AnimatedContentTransitionScope.SlideDirection.Right,
+ )
+}
+
+/**
+ * Navigation routes.
+ */
+@Composable
+fun GalleryNavHost(
+ navController: NavHostController,
+ modifier: Modifier = Modifier,
+ modelManagerViewModel: ModelManagerViewModel = viewModel(factory = ViewModelProvider.Factory)
+) {
+ var showModelManager by remember { mutableStateOf(false) }
+ var pickedTask by remember { mutableStateOf(null) }
+
+ HomeScreen(
+ modelManagerViewModel = modelManagerViewModel,
+ navigateToTaskScreen = { task ->
+ pickedTask = task
+ showModelManager = true
+ },
+ )
+
+ // Model manager.
+ AnimatedVisibility(
+ visible = showModelManager,
+ enter = slideInHorizontally(initialOffsetX = { it }),
+ exit = slideOutHorizontally(targetOffsetX = { it }),
+ ) {
+ val curPickedTask = pickedTask
+ if (curPickedTask != null) {
+ ModelManager(
+ viewModel = modelManagerViewModel,
+ task = curPickedTask,
+ onModelClicked = { model ->
+ navigateToTaskScreen(
+ navController = navController, taskType = model.taskType!!, model = model
+ )
+ },
+ navigateUp = { showModelManager = false })
+ }
+ }
+
+ NavHost(
+ navController = navController,
+ // Default to open home screen.
+ startDestination = ROUTE_PLACEHOLDER,
+ enterTransition = { EnterTransition.None },
+ exitTransition = { ExitTransition.None },
+ 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() },
+ )
+ }
+ }
+
+ // LLMm chat demos.
+ composable(
+ route = "${LlmChatDestination.route}/{modelName}",
+ arguments = listOf(navArgument("modelName") { type = NavType.StringType }),
+ enterTransition = { slideEnter() },
+ exitTransition = { slideExit() },
+ ) {
+ getModelFromNavigationParam(it, TASK_LLM_CHAT)?.let { defaultModel ->
+ modelManagerViewModel.selectModel(defaultModel)
+
+ LlmChatScreen(
+ modelManagerViewModel = modelManagerViewModel,
+ navigateUp = { navController.navigateUp() },
+ )
+ }
+ }
+ }
+
+ // Handle incoming intents for deep links
+ val intent = androidx.activity.compose.LocalActivity.current?.intent
+ val data = intent?.data
+ if (data != null) {
+ intent.data = null
+ Log.d(TAG, "navigation link clicked: $data")
+ if (data.toString().startsWith("com.google.aiedge.gallery://model/")) {
+ val modelName = data.pathSegments.last()
+ getModelByName(modelName)?.let { model ->
+ navigateToTaskScreen(
+ navController = navController,
+ taskType = model.taskType!!,
+ model = model
+ )
+ }
+ }
+ }
+}
+
+fun navigateToTaskScreen(
+ 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.IMAGE_GENERATION -> navController.navigate("${ImageGenerationDestination.route}/${modelName}")
+ TaskType.TEST_TASK_1 -> {}
+ TaskType.TEST_TASK_2 -> {}
+ }
+}
+
+fun getModelFromNavigationParam(entry: NavBackStackEntry, task: Task): Model? {
+ var modelName = entry.arguments?.getString("modelName") ?: ""
+ if (modelName.isEmpty()) {
+ modelName = task.models[0].name
+ }
+ val model = getModelByName(modelName)
+ return model
+}
diff --git a/Android/src/app/src/main/java/com/google/aiedge/gallery/ui/preview/PreviewChatModel.kt b/Android/src/app/src/main/java/com/google/aiedge/gallery/ui/preview/PreviewChatModel.kt
new file mode 100644
index 0000000..cdc6ec7
--- /dev/null
+++ b/Android/src/app/src/main/java/com/google/aiedge/gallery/ui/preview/PreviewChatModel.kt
@@ -0,0 +1,90 @@
+/*
+ * 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.aiedge.gallery.ui.preview
+
+import android.content.Context
+import android.graphics.Bitmap
+import android.graphics.Canvas
+import android.graphics.drawable.Drawable
+import androidx.compose.ui.graphics.Color
+import androidx.compose.ui.graphics.asImageBitmap
+import androidx.core.content.ContextCompat
+import com.google.aiedge.gallery.R
+import com.google.aiedge.gallery.ui.common.chat.ChatMessageClassification
+import com.google.aiedge.gallery.ui.common.chat.ChatMessageImage
+import com.google.aiedge.gallery.ui.common.chat.ChatMessageText
+import com.google.aiedge.gallery.ui.common.chat.ChatSide
+import com.google.aiedge.gallery.ui.common.chat.ChatViewModel
+import com.google.aiedge.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)
+ ),
+ latencyMs = 12345f,
+ ),
+ )
+ val bitmap =
+ getBitmapFromVectorDrawable(
+ context = context,
+ drawableId = R.drawable.ic_launcher_background
+ )!!
+ addMessage(
+ model = model,
+ 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 bitmap = Bitmap.createBitmap(
+ drawable.intrinsicWidth,
+ drawable.intrinsicHeight,
+ Bitmap.Config.ARGB_8888
+ )
+ 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/aiedge/gallery/ui/preview/PreviewDataStoreRepository.kt b/Android/src/app/src/main/java/com/google/aiedge/gallery/ui/preview/PreviewDataStoreRepository.kt
new file mode 100644
index 0000000..1e41c38
--- /dev/null
+++ b/Android/src/app/src/main/java/com/google/aiedge/gallery/ui/preview/PreviewDataStoreRepository.kt
@@ -0,0 +1,43 @@
+/*
+ * 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.aiedge.gallery.ui.preview
+
+import com.google.aiedge.gallery.data.AccessTokenData
+import com.google.aiedge.gallery.data.DataStoreRepository
+
+class PreviewDataStoreRepository : DataStoreRepository {
+ override fun saveTextInputHistory(history: List) {
+ }
+
+ override fun readTextInputHistory(): List {
+ return listOf()
+ }
+
+ override fun saveThemeOverride(theme: String) {
+ }
+
+ override fun readThemeOverride(): String {
+ return ""
+ }
+
+ override fun saveAccessTokenData(accessToken: String, refreshToken: String, expiresAt: Long) {
+ }
+
+ override fun readAccessTokenData(): AccessTokenData? {
+ return null
+ }
+}
\ No newline at end of file
diff --git a/Android/src/app/src/main/java/com/google/aiedge/gallery/ui/preview/PreviewDownloadRepository.kt b/Android/src/app/src/main/java/com/google/aiedge/gallery/ui/preview/PreviewDownloadRepository.kt
new file mode 100644
index 0000000..a37fe20
--- /dev/null
+++ b/Android/src/app/src/main/java/com/google/aiedge/gallery/ui/preview/PreviewDownloadRepository.kt
@@ -0,0 +1,47 @@
+/*
+ * 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.aiedge.gallery.ui.preview
+
+import com.google.aiedge.gallery.data.AGWorkInfo
+import com.google.aiedge.gallery.data.DownloadRepository
+import com.google.aiedge.gallery.data.Model
+import com.google.aiedge.gallery.data.ModelDownloadStatus
+import java.util.UUID
+
+class PreviewDownloadRepository : DownloadRepository {
+ override fun downloadModel(
+ model: Model, onStatusUpdated: (model: Model, status: ModelDownloadStatus) -> Unit
+ ) {
+ }
+
+ override fun cancelDownloadModel(model: Model) {
+ }
+
+ override fun cancelAll(models: List, onComplete: () -> Unit) {
+ }
+
+ override fun observerWorkerProgress(
+ workerId: UUID,
+ model: Model,
+ onStatusUpdated: (model: Model, status: ModelDownloadStatus) -> Unit
+ ) {
+ }
+
+ override fun getEnqueuedOrRunningWorkInfos(): List {
+ return listOf()
+ }
+}
diff --git a/Android/src/app/src/main/java/com/google/aiedge/gallery/ui/preview/PreviewModelManagerViewModel.kt b/Android/src/app/src/main/java/com/google/aiedge/gallery/ui/preview/PreviewModelManagerViewModel.kt
new file mode 100644
index 0000000..31db9ee
--- /dev/null
+++ b/Android/src/app/src/main/java/com/google/aiedge/gallery/ui/preview/PreviewModelManagerViewModel.kt
@@ -0,0 +1,71 @@
+/*
+ * 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.aiedge.gallery.ui.preview
+
+import android.content.Context
+import com.google.aiedge.gallery.data.Model
+import com.google.aiedge.gallery.data.ModelDownloadStatus
+import com.google.aiedge.gallery.data.ModelDownloadStatusType
+import com.google.aiedge.gallery.ui.modelmanager.ModelManagerUiState
+import com.google.aiedge.gallery.ui.modelmanager.ModelManagerViewModel
+import kotlinx.coroutines.flow.update
+
+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(task = task)
+ }
+ }
+
+ val modelsByTaskName: Map> =
+ ALL_PREVIEW_TASKS.associate { task -> task.type.label to task.models }
+ 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,
+ modelsByTaskName = modelsByTaskName,
+ modelDownloadStatus = modelDownloadStatus,
+ modelInitializationStatus = mapOf(),
+ selectedModel = MODEL_TEST2,
+ )
+ _uiState.update { newUiState }
+ }
+}
diff --git a/Android/src/app/src/main/java/com/google/aiedge/gallery/ui/preview/PreviewTasks.kt b/Android/src/app/src/main/java/com/google/aiedge/gallery/ui/preview/PreviewTasks.kt
new file mode 100644
index 0000000..eec2aa2
--- /dev/null
+++ b/Android/src/app/src/main/java/com/google/aiedge/gallery/ui/preview/PreviewTasks.kt
@@ -0,0 +1,96 @@
+/*
+ * 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.aiedge.gallery.ui.preview
+
+import androidx.compose.material.icons.Icons
+import androidx.compose.material.icons.rounded.AccountBox
+import androidx.compose.material.icons.rounded.AutoAwesome
+import com.google.aiedge.gallery.data.BooleanSwitchConfig
+import com.google.aiedge.gallery.data.Config
+import com.google.aiedge.gallery.data.ConfigKey
+import com.google.aiedge.gallery.data.SegmentedButtonConfig
+import com.google.aiedge.gallery.data.Model
+import com.google.aiedge.gallery.data.NumberSliderConfig
+import com.google.aiedge.gallery.data.Task
+import com.google.aiedge.gallery.data.TaskType
+import com.google.aiedge.gallery.data.ValueType
+
+val TEST_CONFIGS1: List = listOf(
+ 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_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_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_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
diff --git a/Android/src/app/src/main/java/com/google/aiedge/gallery/ui/textclassification/TextClassificationModelHelper.kt b/Android/src/app/src/main/java/com/google/aiedge/gallery/ui/textclassification/TextClassificationModelHelper.kt
new file mode 100644
index 0000000..91d5a81
--- /dev/null
+++ b/Android/src/app/src/main/java/com/google/aiedge/gallery/ui/textclassification/TextClassificationModelHelper.kt
@@ -0,0 +1,95 @@
+/*
+ * 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.aiedge.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.aiedge.gallery.data.Model
+import com.google.aiedge.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: () -> 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/aiedge/gallery/ui/textclassification/TextClassificationScreen.kt b/Android/src/app/src/main/java/com/google/aiedge/gallery/ui/textclassification/TextClassificationScreen.kt
new file mode 100644
index 0000000..7dab53c
--- /dev/null
+++ b/Android/src/app/src/main/java/com/google/aiedge/gallery/ui/textclassification/TextClassificationScreen.kt
@@ -0,0 +1,74 @@
+/*
+ * 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.aiedge.gallery.ui.textclassification
+
+import androidx.compose.runtime.Composable
+import androidx.compose.ui.Modifier
+import androidx.lifecycle.viewmodel.compose.viewModel
+import com.google.aiedge.gallery.ui.ViewModelProvider
+import com.google.aiedge.gallery.ui.common.chat.ChatMessageText
+import com.google.aiedge.gallery.ui.common.chat.ChatView
+import com.google.aiedge.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, message ->
+ 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/aiedge/gallery/ui/textclassification/TextClassificationViewModel.kt b/Android/src/app/src/main/java/com/google/aiedge/gallery/ui/textclassification/TextClassificationViewModel.kt
new file mode 100644
index 0000000..41684bf
--- /dev/null
+++ b/Android/src/app/src/main/java/com/google/aiedge/gallery/ui/textclassification/TextClassificationViewModel.kt
@@ -0,0 +1,128 @@
+/*
+ * 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.aiedge.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.aiedge.gallery.data.Model
+import com.google.aiedge.gallery.data.TASK_TEXT_CLASSIFICATION
+import com.google.aiedge.gallery.ui.common.chat.ChatMessage
+import com.google.aiedge.gallery.ui.common.chat.ChatMessageClassification
+import com.google.aiedge.gallery.ui.common.chat.ChatMessageText
+import com.google.aiedge.gallery.ui.common.chat.ChatMessageType
+import com.google.aiedge.gallery.ui.common.chat.ChatViewModel
+import com.google.aiedge.gallery.ui.common.chat.Classification
+import com.google.aiedge.gallery.ui.common.getDistinctiveColor
+import com.google.aiedge.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/aiedge/gallery/ui/theme/Color.kt b/Android/src/app/src/main/java/com/google/aiedge/gallery/ui/theme/Color.kt
new file mode 100644
index 0000000..4652713
--- /dev/null
+++ b/Android/src/app/src/main/java/com/google/aiedge/gallery/ui/theme/Color.kt
@@ -0,0 +1,92 @@
+/*
+ * 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.aiedge.gallery.ui.theme
+
+import androidx.compose.ui.graphics.Color
+
+//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 onSecondaryContainerLight = Color(0xFF3B4857)
+val tertiaryLight = Color(0xFF775A0B)
+val onTertiaryLight = Color(0xFFFFFFFF)
+val tertiaryContainerLight = Color(0xFFFFDF9B)
+val onTertiaryContainerLight = Color(0xFF5B4300)
+val errorLight = Color(0xFF904A43)
+val onErrorLight = Color(0xFFFFFFFF)
+val errorContainerLight = Color(0xFFFFDAD5)
+val onErrorContainerLight = Color(0xFF73342D)
+val backgroundLight = Color(0xFFF8F9FF)
+val onBackgroundLight = Color(0xFF191C20)
+val surfaceLight = Color(0xFFF8F9FF)
+val onSurfaceLight = Color(0xFF191C20)
+val surfaceVariantLight = Color(0xFFDEE3EB)
+val onSurfaceVariantLight = Color(0xFF42474E)
+val outlineLight = Color(0xFF73777F)
+val outlineVariantLight = Color(0xFFC2C7CF)
+val scrimLight = Color(0xFF000000)
+val inverseSurfaceLight = Color(0xFF2D3135)
+val inverseOnSurfaceLight = Color(0xFFEFF1F6)
+val inversePrimaryLight = Color(0xFF9DCAFC)
+val surfaceDimLight = Color(0xFFD8DAE0)
+val surfaceBrightLight = Color(0xFFF8F9FF)
+val surfaceContainerLowestLight = Color(0xFFFFFFFF)
+val surfaceContainerLowLight = Color(0xFFF2F3F9)
+val surfaceContainerLight = Color(0xFFECEEF4)
+val surfaceContainerHighLight = Color(0xFFE6E8EE)
+val surfaceContainerHighestLight = Color(0xFFE0E2E8)
+
+val primaryDark = Color(0xFF9DCAFC)
+val onPrimaryDark = Color(0xFF003355)
+val primaryContainerDark = Color(0xFF144A74)
+val onPrimaryContainerDark = Color(0xFFD0E4FF)
+val secondaryDark = Color(0xFFBAC8DA)
+val onSecondaryDark = Color(0xFF243240)
+val secondaryContainerDark = Color(0xFF3B4857)
+val onSecondaryContainerDark = Color(0xFFD6E4F7)
+val tertiaryDark = Color(0xFFE8C26C)
+val onTertiaryDark = Color(0xFF3F2E00)
+val tertiaryContainerDark = Color(0xFF5B4300)
+val onTertiaryContainerDark = Color(0xFFFFDF9B)
+val errorDark = Color(0xFFFFB4AB)
+val onErrorDark = Color(0xFF561E19)
+val errorContainerDark = Color(0xFF73342D)
+val onErrorContainerDark = Color(0xFFFFDAD5)
+val backgroundDark = Color(0xFF101418)
+val onBackgroundDark = Color(0xFFE0E2E8)
+val surfaceDark = Color(0xFF101418)
+val onSurfaceDark = Color(0xFFE0E2E8)
+val surfaceVariantDark = Color(0xFF42474E)
+val onSurfaceVariantDark = Color(0xFFC2C7CF)
+val outlineDark = Color(0xFF8C9199)
+val outlineVariantDark = Color(0xFF42474E)
+val scrimDark = Color(0xFF000000)
+val inverseSurfaceDark = Color(0xFFE0E2E8)
+val inverseOnSurfaceDark = Color(0xFF2D3135)
+val inversePrimaryDark = Color(0xFF32628D)
+val surfaceDimDark = Color(0xFF101418)
+val surfaceBrightDark = Color(0xFF36393E)
+val surfaceContainerLowestDark = Color(0xFF0B0E12)
+val surfaceContainerLowDark = Color(0xFF191C20)
+val surfaceContainerDark = Color(0xFF1D2024)
+val surfaceContainerHighDark = Color(0xFF272A2F)
+val surfaceContainerHighestDark = Color(0xFF32353A)
diff --git a/Android/src/app/src/main/java/com/google/aiedge/gallery/ui/theme/Theme.kt b/Android/src/app/src/main/java/com/google/aiedge/gallery/ui/theme/Theme.kt
new file mode 100644
index 0000000..d223a8f
--- /dev/null
+++ b/Android/src/app/src/main/java/com/google/aiedge/gallery/ui/theme/Theme.kt
@@ -0,0 +1,223 @@
+/*
+ * 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.aiedge.gallery.ui.theme
+
+import android.app.Activity
+import androidx.compose.foundation.isSystemInDarkTheme
+import androidx.compose.material3.MaterialTheme
+import androidx.compose.material3.darkColorScheme
+import androidx.compose.material3.lightColorScheme
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.CompositionLocalProvider
+import androidx.compose.runtime.Immutable
+import androidx.compose.runtime.ReadOnlyComposable
+import androidx.compose.runtime.SideEffect
+import androidx.compose.runtime.staticCompositionLocalOf
+import androidx.compose.ui.graphics.Color
+import androidx.compose.ui.platform.LocalView
+import androidx.core.view.WindowCompat
+
+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,
+)
+
+@Immutable
+data class CustomColors(
+ val taskBgColors: List = listOf(),
+ val taskIconColors: List = listOf(),
+ val taskIconShapeBgColor: Color = Color.Transparent,
+ val homeBottomGradient: List = listOf(),
+ val userBubbleBgColor: Color = Color.Transparent,
+ val agentBubbleBgColor: Color = Color.Transparent,
+)
+
+val LocalCustomColors = staticCompositionLocalOf { CustomColors() }
+
+val lightCustomColors = CustomColors(
+ taskBgColors = listOf(
+ // yellow
+ Color(0xFFFFEFC9),
+ // red
+ Color(0xFFFFEDE6),
+ // green
+ Color(0xFFE1F6DE),
+ // blue
+ Color(0xFFEDF0FF)
+ ),
+ taskIconColors = listOf(
+ Color(0xFFE37400),
+ Color(0xFFD93025),
+ Color(0xFF34A853),
+ Color(0xFF1967D2),
+ ),
+ taskIconShapeBgColor = Color.White,
+ homeBottomGradient = listOf(
+ Color(0x00F8F9FF),
+ Color(0xffFFEFC9)
+ ),
+ agentBubbleBgColor = Color(0xFFe9eef6),
+ userBubbleBgColor = Color(0xFF32628D),
+)
+
+val darkCustomColors = CustomColors(
+ taskBgColors = listOf(
+ // yellow
+ Color(0xFF33302A),
+ // red
+ Color(0xFF362F2D),
+ // green
+ Color(0xFF2E312D),
+ // blue
+ Color(0xFF303033)
+ ),
+ taskIconColors = listOf(
+ Color(0xFFFFB955),
+ Color(0xFFFFB4AB),
+ Color(0xFF6DD58C),
+ Color(0xFFAAC7FF),
+ ),
+ taskIconShapeBgColor = Color(0xFF202124),
+ homeBottomGradient = listOf(
+ Color(0x00F8F9FF),
+ Color(0x1AF6AD01)
+ ),
+ agentBubbleBgColor = Color(0xFF1b1c1d),
+ userBubbleBgColor = Color(0xFF1f3760),
+)
+
+val MaterialTheme.customColors: CustomColors
+ @Composable
+ @ReadOnlyComposable
+ get() = LocalCustomColors.current
+
+/**
+ * Controls the color of the phone's status bar icons based on whether the app is using a dark
+ * theme.
+ */
+@Composable
+fun StatusBarColorController(useDarkTheme: Boolean) {
+ val view = LocalView.current
+ val currentWindow = (view.context as? Activity)?.window
+
+ if (currentWindow != null) {
+ SideEffect {
+ WindowCompat.setDecorFitsSystemWindows(currentWindow, false)
+ val controller = WindowCompat.getInsetsController(currentWindow, view)
+ controller.isAppearanceLightStatusBars = !useDarkTheme // Set to true for light icons
+ }
+ }
+}
+
+@Composable
+fun GalleryTheme(
+ content: @Composable () -> Unit
+) {
+ val themeOverride = ThemeSettings.themeOverride
+ val darkTheme: Boolean = isSystemInDarkTheme() || themeOverride.value == THEME_DARK
+
+ StatusBarColorController(useDarkTheme = darkTheme)
+
+ 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
+ )
+ }
+}
+
diff --git a/Android/src/app/src/main/java/com/google/aiedge/gallery/ui/theme/ThemeSettings.kt b/Android/src/app/src/main/java/com/google/aiedge/gallery/ui/theme/ThemeSettings.kt
new file mode 100644
index 0000000..333ce41
--- /dev/null
+++ b/Android/src/app/src/main/java/com/google/aiedge/gallery/ui/theme/ThemeSettings.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.aiedge.gallery.ui.theme
+
+import androidx.compose.runtime.mutableStateOf
+
+const val THEME_AUTO = "Auto"
+const val THEME_LIGHT = "Light"
+const val THEME_DARK = "Dark"
+
+object ThemeSettings {
+ val themeOverride = mutableStateOf("")
+}
diff --git a/Android/src/app/src/main/java/com/google/aiedge/gallery/ui/theme/Type.kt b/Android/src/app/src/main/java/com/google/aiedge/gallery/ui/theme/Type.kt
new file mode 100644
index 0000000..8630f05
--- /dev/null
+++ b/Android/src/app/src/main/java/com/google/aiedge/gallery/ui/theme/Type.kt
@@ -0,0 +1,91 @@
+/*
+ * 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.aiedge.gallery.ui.theme
+
+import androidx.compose.material3.Typography
+import androidx.compose.ui.text.font.Font
+import androidx.compose.ui.text.font.FontFamily
+import androidx.compose.ui.text.font.FontWeight
+import androidx.compose.ui.unit.sp
+import com.google.aiedge.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 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 titleMediumNarrow =
+ baseline.titleMedium.copy(fontFamily = nunitoFontFamily, letterSpacing = 0.0.sp)
+
+val titleSmaller = baseline.titleSmall.copy(
+ fontFamily = nunitoFontFamily,
+ fontSize = 12.sp,
+ fontWeight = FontWeight.Bold
+)
+
+val labelSmallNarrow =
+ baseline.labelSmall.copy(fontFamily = nunitoFontFamily, letterSpacing = 0.0.sp)
+
+val labelSmallNarrowMedium =
+ baseline.labelSmall.copy(
+ fontFamily = nunitoFontFamily,
+ fontWeight = FontWeight.Medium,
+ 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)
+
+val bodySmallMediumNarrow =
+ baseline.bodySmall.copy(fontFamily = nunitoFontFamily, letterSpacing = 0.0.sp, fontSize = 14.sp)
+
+val bodySmallMediumNarrowBold =
+ baseline.bodySmall.copy(
+ fontFamily = nunitoFontFamily,
+ letterSpacing = 0.0.sp,
+ fontSize = 14.sp,
+ fontWeight = FontWeight.Bold
+ )
diff --git a/Android/src/app/src/main/java/com/google/aiedge/gallery/worker/DownloadWorker.kt b/Android/src/app/src/main/java/com/google/aiedge/gallery/worker/DownloadWorker.kt
new file mode 100644
index 0000000..355c41e
--- /dev/null
+++ b/Android/src/app/src/main/java/com/google/aiedge/gallery/worker/DownloadWorker.kt
@@ -0,0 +1,243 @@
+/*
+ * 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.aiedge.gallery.worker
+
+import android.content.Context
+import android.util.Log
+import androidx.work.CoroutineWorker
+import androidx.work.Data
+import androidx.work.WorkerParameters
+import com.google.aiedge.gallery.data.KEY_MODEL_DOWNLOAD_ACCESS_TOKEN
+import com.google.aiedge.gallery.data.KEY_MODEL_DOWNLOAD_ERROR_MESSAGE
+import com.google.aiedge.gallery.data.KEY_MODEL_DOWNLOAD_FILE_NAME
+import com.google.aiedge.gallery.data.KEY_MODEL_DOWNLOAD_RATE
+import com.google.aiedge.gallery.data.KEY_MODEL_DOWNLOAD_RECEIVED_BYTES
+import com.google.aiedge.gallery.data.KEY_MODEL_DOWNLOAD_REMAINING_MS
+import com.google.aiedge.gallery.data.KEY_MODEL_EXTRA_DATA_DOWNLOAD_FILE_NAMES
+import com.google.aiedge.gallery.data.KEY_MODEL_EXTRA_DATA_URLS
+import com.google.aiedge.gallery.data.KEY_MODEL_IS_ZIP
+import com.google.aiedge.gallery.data.KEY_MODEL_START_UNZIPPING
+import com.google.aiedge.gallery.data.KEY_MODEL_TOTAL_BYTES
+import com.google.aiedge.gallery.data.KEY_MODEL_UNZIPPED_DIR
+import com.google.aiedge.gallery.data.KEY_MODEL_URL
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.withContext
+import java.io.BufferedInputStream
+import java.io.File
+import java.io.FileInputStream
+import java.io.FileOutputStream
+import java.io.IOException
+import java.net.HttpURLConnection
+import java.net.URL
+import java.util.zip.ZipEntry
+import java.util.zip.ZipInputStream
+
+private const val TAG = "AGDownloadWorker"
+
+data class UrlAndFileName(val url: String, val fileName: String)
+
+class DownloadWorker(context: Context, params: WorkerParameters) :
+ CoroutineWorker(context, params) {
+ private val externalFilesDir = context.getExternalFilesDir(null)
+
+ override suspend fun doWork(): Result {
+ val fileUrl = inputData.getString(KEY_MODEL_URL)
+ val fileName = inputData.getString(KEY_MODEL_DOWNLOAD_FILE_NAME)
+ val isZip = inputData.getBoolean(KEY_MODEL_IS_ZIP, false)
+ val unzippedDir = inputData.getString(KEY_MODEL_UNZIPPED_DIR)
+ val extraDataFileUrls = inputData.getString(KEY_MODEL_EXTRA_DATA_URLS)?.split(",") ?: listOf()
+ val extraDataFileNames =
+ inputData.getString(KEY_MODEL_EXTRA_DATA_DOWNLOAD_FILE_NAMES)?.split(",") ?: listOf()
+ val totalBytes = inputData.getLong(KEY_MODEL_TOTAL_BYTES, 0L)
+ val accessToken = inputData.getString(KEY_MODEL_DOWNLOAD_ACCESS_TOKEN)
+
+ return withContext(Dispatchers.IO) {
+ if (fileUrl == null || fileName == null) {
+ Result.failure()
+ } else {
+ return@withContext try {
+ // Collect data for all files.
+ val allFiles: MutableList = mutableListOf()
+ allFiles.add(UrlAndFileName(url = fileUrl, fileName = fileName))
+ for (index in extraDataFileUrls.indices) {
+ allFiles.add(
+ UrlAndFileName(
+ url = extraDataFileUrls[index], fileName = extraDataFileNames[index]
+ )
+ )
+ }
+ Log.d(TAG, "About to download: $allFiles")
+
+ // Download them in sequence.
+ // TODO: maybe consider downloading them in parallel.
+ var downloadedBytes = 0L
+ val bytesReadSizeBuffer: MutableList = mutableListOf()
+ val bytesReadLatencyBuffer: MutableList = mutableListOf()
+ for (file in allFiles) {
+ val url = URL(file.url)
+
+ val connection = url.openConnection() as HttpURLConnection
+ if (accessToken != null) {
+ connection.setRequestProperty("Authorization", "Bearer $accessToken")
+ }
+
+ // Read the file and see if it is partially downloaded.
+ val outputFile = File(applicationContext.getExternalFilesDir(null), file.fileName)
+ 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}-"
+ )
+ }
+ connection.connect()
+ Log.d(TAG, "response code: ${connection.responseCode}")
+
+ if (connection.responseCode == HttpURLConnection.HTTP_OK || connection.responseCode == HttpURLConnection.HTTP_PARTIAL) {
+ val contentRange = connection.getHeaderField("Content-Range")
+
+ if (contentRange != null) {
+ // Parse the Content-Range header
+ val rangeParts = contentRange.substringAfter("bytes ").split("/")
+ val byteRange = rangeParts[0].split("-")
+ val startByte = byteRange[0].toLong()
+ val endByte = byteRange[1].toLong()
+
+ Log.d(
+ TAG,
+ "Content-Range: $contentRange. Start bytes: ${startByte}, end bytes: $endByte"
+ )
+
+ downloadedBytes += startByte
+ } else {
+ Log.d(TAG, "Download starts from beginning.")
+ }
+ } else {
+ throw IOException("HTTP error code: ${connection.responseCode}")
+ }
+
+ val inputStream = connection.inputStream
+ val outputStream = FileOutputStream(outputFile, true /* append */)
+
+ val buffer = ByteArray(DEFAULT_BUFFER_SIZE)
+ var bytesRead: Int
+ var lastSetProgressTs: Long = 0
+ var deltaBytes = 0L
+ while (inputStream.read(buffer).also { bytesRead = it } != -1) {
+ outputStream.write(buffer, 0, bytesRead)
+ downloadedBytes += bytesRead
+ deltaBytes += bytesRead
+
+ // Report progress every 200 ms.
+ val curTs = System.currentTimeMillis()
+ if (curTs - lastSetProgressTs > 200) {
+ // Calculate download rate.
+ var bytesPerMs = 0f
+ if (lastSetProgressTs != 0L) {
+ if (bytesReadSizeBuffer.size == 5) {
+ bytesReadSizeBuffer.removeAt(bytesReadLatencyBuffer.lastIndex)
+ }
+ bytesReadSizeBuffer.add(deltaBytes)
+ if (bytesReadLatencyBuffer.size == 5) {
+ bytesReadLatencyBuffer.removeAt(bytesReadLatencyBuffer.lastIndex)
+ }
+ bytesReadLatencyBuffer.add(curTs - lastSetProgressTs)
+ deltaBytes = 0L
+ bytesPerMs = bytesReadSizeBuffer.sum().toFloat() / bytesReadLatencyBuffer.sum()
+ }
+
+ // Calculate remaining seconds
+ var remainingMs = 0f
+ if (bytesPerMs > 0f && totalBytes > 0L) {
+ remainingMs = (totalBytes - downloadedBytes) / bytesPerMs
+ }
+
+ 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()
+ )
+ lastSetProgressTs = curTs
+ }
+ }
+
+ outputStream.close()
+ inputStream.close()
+
+ Log.d(TAG, "Download done")
+
+ // Unzip if the downloaded file is a zip.
+ if (isZip && unzippedDir != null) {
+ setProgress(Data.Builder().putBoolean(KEY_MODEL_START_UNZIPPING, true).build())
+
+ // Prepare target dir.
+ val destDir = File("${externalFilesDir}${File.separator}${unzippedDir}")
+ if (!destDir.exists()) {
+ destDir.mkdirs()
+ }
+
+ // Unzip.
+ val unzipBuffer = ByteArray(4096)
+ val zipFilePath = "${externalFilesDir}${File.separator}${fileName}"
+ val zipIn = ZipInputStream(BufferedInputStream(FileInputStream(zipFilePath)))
+ var zipEntry: ZipEntry? = zipIn.nextEntry
+
+ while (zipEntry != null) {
+ val filePath = destDir.absolutePath + File.separator + zipEntry.name
+
+ // Extract files.
+ if (!zipEntry.isDirectory) {
+ // extract file
+ val bos = FileOutputStream(filePath)
+ bos.use { curBos ->
+ var len: Int
+ while (zipIn.read(unzipBuffer).also { len = it } > 0) {
+ curBos.write(unzipBuffer, 0, len)
+ }
+ }
+ }
+ // Create dir.
+ else {
+ val dir = File(filePath)
+ dir.mkdirs()
+ }
+
+ zipIn.closeEntry()
+ zipEntry = zipIn.nextEntry
+ }
+ zipIn.close()
+
+ // Delete the original file.
+ val zipFile = File(zipFilePath)
+ zipFile.delete()
+ }
+ }
+ Result.success()
+ } catch (e: IOException) {
+ Result.failure(
+ Data.Builder().putString(KEY_MODEL_DOWNLOAD_ERROR_MESSAGE, e.message).build()
+ )
+ }
+ }
+ }
+ }
+}
\ No newline at end of file
diff --git a/Android/src/app/src/main/res/drawable/chat_spark.xml b/Android/src/app/src/main/res/drawable/chat_spark.xml
new file mode 100644
index 0000000..7489732
--- /dev/null
+++ b/Android/src/app/src/main/res/drawable/chat_spark.xml
@@ -0,0 +1,27 @@
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/Android/src/app/src/main/res/drawable/circle.xml b/Android/src/app/src/main/res/drawable/circle.xml
new file mode 100644
index 0000000..dfa350b
--- /dev/null
+++ b/Android/src/app/src/main/res/drawable/circle.xml
@@ -0,0 +1,26 @@
+
+
+
+
+
\ No newline at end of file
diff --git a/Android/src/app/src/main/res/drawable/double_circle.xml b/Android/src/app/src/main/res/drawable/double_circle.xml
new file mode 100644
index 0000000..226248f
--- /dev/null
+++ b/Android/src/app/src/main/res/drawable/double_circle.xml
@@ -0,0 +1,26 @@
+
+
+
+
+
\ No newline at end of file
diff --git a/Android/src/app/src/main/res/drawable/four_circle.xml b/Android/src/app/src/main/res/drawable/four_circle.xml
new file mode 100644
index 0000000..1a2401b
--- /dev/null
+++ b/Android/src/app/src/main/res/drawable/four_circle.xml
@@ -0,0 +1,26 @@
+
+
+
+
+
\ No newline at end of file
diff --git a/Android/src/app/src/main/res/drawable/ic_launcher_background.xml b/Android/src/app/src/main/res/drawable/ic_launcher_background.xml
new file mode 100644
index 0000000..265c4b8
--- /dev/null
+++ b/Android/src/app/src/main/res/drawable/ic_launcher_background.xml
@@ -0,0 +1,186 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/Android/src/app/src/main/res/drawable/ic_launcher_foreground.xml b/Android/src/app/src/main/res/drawable/ic_launcher_foreground.xml
new file mode 100644
index 0000000..0187e5c
--- /dev/null
+++ b/Android/src/app/src/main/res/drawable/ic_launcher_foreground.xml
@@ -0,0 +1,46 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/Android/src/app/src/main/res/drawable/image_spark.xml b/Android/src/app/src/main/res/drawable/image_spark.xml
new file mode 100644
index 0000000..287913a
--- /dev/null
+++ b/Android/src/app/src/main/res/drawable/image_spark.xml
@@ -0,0 +1,27 @@
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/Android/src/app/src/main/res/drawable/logo.xml b/Android/src/app/src/main/res/drawable/logo.xml
new file mode 100644
index 0000000..8a04415
--- /dev/null
+++ b/Android/src/app/src/main/res/drawable/logo.xml
@@ -0,0 +1,34 @@
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/Android/src/app/src/main/res/drawable/pantegon.xml b/Android/src/app/src/main/res/drawable/pantegon.xml
new file mode 100644
index 0000000..b53cbc6
--- /dev/null
+++ b/Android/src/app/src/main/res/drawable/pantegon.xml
@@ -0,0 +1,26 @@
+
+
+
+
+
\ No newline at end of file
diff --git a/Android/src/app/src/main/res/drawable/text_spark.xml b/Android/src/app/src/main/res/drawable/text_spark.xml
new file mode 100644
index 0000000..cb0ce7d
--- /dev/null
+++ b/Android/src/app/src/main/res/drawable/text_spark.xml
@@ -0,0 +1,27 @@
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/Android/src/app/src/main/res/font/nunito_black.ttf b/Android/src/app/src/main/res/font/nunito_black.ttf
new file mode 100644
index 0000000..81d557c
Binary files /dev/null and b/Android/src/app/src/main/res/font/nunito_black.ttf differ
diff --git a/Android/src/app/src/main/res/font/nunito_bold.ttf b/Android/src/app/src/main/res/font/nunito_bold.ttf
new file mode 100644
index 0000000..886134d
Binary files /dev/null and b/Android/src/app/src/main/res/font/nunito_bold.ttf differ
diff --git a/Android/src/app/src/main/res/font/nunito_extrabold.ttf b/Android/src/app/src/main/res/font/nunito_extrabold.ttf
new file mode 100644
index 0000000..711765e
Binary files /dev/null and b/Android/src/app/src/main/res/font/nunito_extrabold.ttf differ
diff --git a/Android/src/app/src/main/res/font/nunito_extralight.ttf b/Android/src/app/src/main/res/font/nunito_extralight.ttf
new file mode 100644
index 0000000..d9eabf9
Binary files /dev/null and b/Android/src/app/src/main/res/font/nunito_extralight.ttf differ
diff --git a/Android/src/app/src/main/res/font/nunito_light.ttf b/Android/src/app/src/main/res/font/nunito_light.ttf
new file mode 100644
index 0000000..e64c0fe
Binary files /dev/null and b/Android/src/app/src/main/res/font/nunito_light.ttf differ
diff --git a/Android/src/app/src/main/res/font/nunito_medium.ttf b/Android/src/app/src/main/res/font/nunito_medium.ttf
new file mode 100644
index 0000000..e24c1d6
Binary files /dev/null and b/Android/src/app/src/main/res/font/nunito_medium.ttf differ
diff --git a/Android/src/app/src/main/res/font/nunito_regular.ttf b/Android/src/app/src/main/res/font/nunito_regular.ttf
new file mode 100644
index 0000000..9411bfb
Binary files /dev/null and b/Android/src/app/src/main/res/font/nunito_regular.ttf differ
diff --git a/Android/src/app/src/main/res/font/nunito_semibold.ttf b/Android/src/app/src/main/res/font/nunito_semibold.ttf
new file mode 100644
index 0000000..1326a7d
Binary files /dev/null and b/Android/src/app/src/main/res/font/nunito_semibold.ttf differ
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
new file mode 100644
index 0000000..6942fb7
--- /dev/null
+++ b/Android/src/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml
@@ -0,0 +1,22 @@
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/Android/src/app/src/main/res/mipmap-hdpi/ic_launcher.png b/Android/src/app/src/main/res/mipmap-hdpi/ic_launcher.png
new file mode 100644
index 0000000..8f6b3fb
Binary files /dev/null and b/Android/src/app/src/main/res/mipmap-hdpi/ic_launcher.png differ
diff --git a/Android/src/app/src/main/res/mipmap-hdpi/ic_launcher_background.png b/Android/src/app/src/main/res/mipmap-hdpi/ic_launcher_background.png
new file mode 100644
index 0000000..13f178c
Binary files /dev/null and b/Android/src/app/src/main/res/mipmap-hdpi/ic_launcher_background.png differ
diff --git a/Android/src/app/src/main/res/mipmap-hdpi/ic_launcher_foreground.png b/Android/src/app/src/main/res/mipmap-hdpi/ic_launcher_foreground.png
new file mode 100644
index 0000000..ec172b7
Binary files /dev/null and b/Android/src/app/src/main/res/mipmap-hdpi/ic_launcher_foreground.png differ
diff --git a/Android/src/app/src/main/res/mipmap-hdpi/ic_launcher_monochrome.png b/Android/src/app/src/main/res/mipmap-hdpi/ic_launcher_monochrome.png
new file mode 100644
index 0000000..ec172b7
Binary files /dev/null and b/Android/src/app/src/main/res/mipmap-hdpi/ic_launcher_monochrome.png differ
diff --git a/Android/src/app/src/main/res/mipmap-mdpi/ic_launcher.png b/Android/src/app/src/main/res/mipmap-mdpi/ic_launcher.png
new file mode 100644
index 0000000..b860434
Binary files /dev/null and b/Android/src/app/src/main/res/mipmap-mdpi/ic_launcher.png differ
diff --git a/Android/src/app/src/main/res/mipmap-mdpi/ic_launcher_background.png b/Android/src/app/src/main/res/mipmap-mdpi/ic_launcher_background.png
new file mode 100644
index 0000000..46e114d
Binary files /dev/null and b/Android/src/app/src/main/res/mipmap-mdpi/ic_launcher_background.png differ
diff --git a/Android/src/app/src/main/res/mipmap-mdpi/ic_launcher_foreground.png b/Android/src/app/src/main/res/mipmap-mdpi/ic_launcher_foreground.png
new file mode 100644
index 0000000..8b41ec7
Binary files /dev/null and b/Android/src/app/src/main/res/mipmap-mdpi/ic_launcher_foreground.png differ
diff --git a/Android/src/app/src/main/res/mipmap-mdpi/ic_launcher_monochrome.png b/Android/src/app/src/main/res/mipmap-mdpi/ic_launcher_monochrome.png
new file mode 100644
index 0000000..8b41ec7
Binary files /dev/null and b/Android/src/app/src/main/res/mipmap-mdpi/ic_launcher_monochrome.png differ
diff --git a/Android/src/app/src/main/res/mipmap-xhdpi/ic_launcher.png b/Android/src/app/src/main/res/mipmap-xhdpi/ic_launcher.png
new file mode 100644
index 0000000..9981e9a
Binary files /dev/null and b/Android/src/app/src/main/res/mipmap-xhdpi/ic_launcher.png differ
diff --git a/Android/src/app/src/main/res/mipmap-xhdpi/ic_launcher_background.png b/Android/src/app/src/main/res/mipmap-xhdpi/ic_launcher_background.png
new file mode 100644
index 0000000..bf6bc62
Binary files /dev/null and b/Android/src/app/src/main/res/mipmap-xhdpi/ic_launcher_background.png differ
diff --git a/Android/src/app/src/main/res/mipmap-xhdpi/ic_launcher_foreground.png b/Android/src/app/src/main/res/mipmap-xhdpi/ic_launcher_foreground.png
new file mode 100644
index 0000000..5606e5f
Binary files /dev/null and b/Android/src/app/src/main/res/mipmap-xhdpi/ic_launcher_foreground.png differ
diff --git a/Android/src/app/src/main/res/mipmap-xhdpi/ic_launcher_monochrome.png b/Android/src/app/src/main/res/mipmap-xhdpi/ic_launcher_monochrome.png
new file mode 100644
index 0000000..5606e5f
Binary files /dev/null and b/Android/src/app/src/main/res/mipmap-xhdpi/ic_launcher_monochrome.png differ
diff --git a/Android/src/app/src/main/res/mipmap-xxhdpi/ic_launcher.png b/Android/src/app/src/main/res/mipmap-xxhdpi/ic_launcher.png
new file mode 100644
index 0000000..d16a726
Binary files /dev/null and b/Android/src/app/src/main/res/mipmap-xxhdpi/ic_launcher.png differ
diff --git a/Android/src/app/src/main/res/mipmap-xxhdpi/ic_launcher_background.png b/Android/src/app/src/main/res/mipmap-xxhdpi/ic_launcher_background.png
new file mode 100644
index 0000000..be8ef1d
Binary files /dev/null and b/Android/src/app/src/main/res/mipmap-xxhdpi/ic_launcher_background.png differ
diff --git a/Android/src/app/src/main/res/mipmap-xxhdpi/ic_launcher_foreground.png b/Android/src/app/src/main/res/mipmap-xxhdpi/ic_launcher_foreground.png
new file mode 100644
index 0000000..760ef15
Binary files /dev/null and b/Android/src/app/src/main/res/mipmap-xxhdpi/ic_launcher_foreground.png differ
diff --git a/Android/src/app/src/main/res/mipmap-xxhdpi/ic_launcher_monochrome.png b/Android/src/app/src/main/res/mipmap-xxhdpi/ic_launcher_monochrome.png
new file mode 100644
index 0000000..760ef15
Binary files /dev/null and b/Android/src/app/src/main/res/mipmap-xxhdpi/ic_launcher_monochrome.png differ
diff --git a/Android/src/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png b/Android/src/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png
new file mode 100644
index 0000000..92a5411
Binary files /dev/null and b/Android/src/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png differ
diff --git a/Android/src/app/src/main/res/mipmap-xxxhdpi/ic_launcher_background.png b/Android/src/app/src/main/res/mipmap-xxxhdpi/ic_launcher_background.png
new file mode 100644
index 0000000..e165902
Binary files /dev/null and b/Android/src/app/src/main/res/mipmap-xxxhdpi/ic_launcher_background.png differ
diff --git a/Android/src/app/src/main/res/mipmap-xxxhdpi/ic_launcher_foreground.png b/Android/src/app/src/main/res/mipmap-xxxhdpi/ic_launcher_foreground.png
new file mode 100644
index 0000000..0a45db8
Binary files /dev/null and b/Android/src/app/src/main/res/mipmap-xxxhdpi/ic_launcher_foreground.png differ
diff --git a/Android/src/app/src/main/res/mipmap-xxxhdpi/ic_launcher_monochrome.png b/Android/src/app/src/main/res/mipmap-xxxhdpi/ic_launcher_monochrome.png
new file mode 100644
index 0000000..0a45db8
Binary files /dev/null and b/Android/src/app/src/main/res/mipmap-xxxhdpi/ic_launcher_monochrome.png differ
diff --git a/Android/src/app/src/main/res/values/dimens.xml b/Android/src/app/src/main/res/values/dimens.xml
new file mode 100644
index 0000000..565201f
--- /dev/null
+++ b/Android/src/app/src/main/res/values/dimens.xml
@@ -0,0 +1,22 @@
+
+
+
+
+
+ 54dp
+ 24dp
+
\ No newline at end of file
diff --git a/Android/src/app/src/main/res/values/ic_launcher_background.xml b/Android/src/app/src/main/res/values/ic_launcher_background.xml
new file mode 100644
index 0000000..fcdcf1c
--- /dev/null
+++ b/Android/src/app/src/main/res/values/ic_launcher_background.xml
@@ -0,0 +1,20 @@
+
+
+
+
+ #ffffff
+
\ No newline at end of file
diff --git a/Android/src/app/src/main/res/values/strings.xml b/Android/src/app/src/main/res/values/strings.xml
new file mode 100644
index 0000000..bba336b
--- /dev/null
+++ b/Android/src/app/src/main/res/values/strings.xml
@@ -0,0 +1,43 @@
+
+
+
+ AI Edge Gallery
+ Model Manager
+ %1$s downloaded
+ Cancel
+ OK
+ Delete download
+ Are you sure you want to delete the downloaded model \"%s\"?
+ Model download succeeded
+ Model \"%s\" has been downloaded
+ Model download failed
+ Failed to download model \"%s\"
+ Type message…
+ You
+ LLM
+ Model
+ Result
+ Model not downloaded yet
+ Initializing model…
+ Type movie review to classify…
+ Type prompt…
+ Type prompt (one shot)…
+ Run again
+ Run benchmark
+ warming up…
+ running
+
\ No newline at end of file
diff --git a/Android/src/app/src/main/res/values/themes.xml b/Android/src/app/src/main/res/values/themes.xml
new file mode 100644
index 0000000..beeda9c
--- /dev/null
+++ b/Android/src/app/src/main/res/values/themes.xml
@@ -0,0 +1,24 @@
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/Android/src/app/src/main/res/xml/backup_rules.xml b/Android/src/app/src/main/res/xml/backup_rules.xml
new file mode 100644
index 0000000..12c79a2
--- /dev/null
+++ b/Android/src/app/src/main/res/xml/backup_rules.xml
@@ -0,0 +1,27 @@
+
+
+
+
\ No newline at end of file
diff --git a/Android/src/app/src/main/res/xml/data_extraction_rules.xml b/Android/src/app/src/main/res/xml/data_extraction_rules.xml
new file mode 100644
index 0000000..a9d401a
--- /dev/null
+++ b/Android/src/app/src/main/res/xml/data_extraction_rules.xml
@@ -0,0 +1,33 @@
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/Android/src/app/src/main/res/xml/file_paths.xml b/Android/src/app/src/main/res/xml/file_paths.xml
new file mode 100644
index 0000000..5d5dbdd
--- /dev/null
+++ b/Android/src/app/src/main/res/xml/file_paths.xml
@@ -0,0 +1,22 @@
+
+
+
+
+
+
\ No newline at end of file
diff --git a/Android/src/app/src/test/java/com/google/aiedge/gallery/ExampleUnitTest.kt b/Android/src/app/src/test/java/com/google/aiedge/gallery/ExampleUnitTest.kt
new file mode 100644
index 0000000..1e8d62b
--- /dev/null
+++ b/Android/src/app/src/test/java/com/google/aiedge/gallery/ExampleUnitTest.kt
@@ -0,0 +1,33 @@
+/*
+ * 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.aiedge.gallery
+
+import org.junit.Test
+
+import org.junit.Assert.*
+
+/**
+ * Example local unit test, which will execute on the development machine (host).
+ *
+ * See [testing documentation](http://d.android.com/tools/testing).
+ */
+class ExampleUnitTest {
+ @Test
+ fun addition_isCorrect() {
+ assertEquals(4, 2 + 2)
+ }
+}
\ No newline at end of file
diff --git a/Android/src/build.gradle.kts b/Android/src/build.gradle.kts
new file mode 100644
index 0000000..c2b0d4d
--- /dev/null
+++ b/Android/src/build.gradle.kts
@@ -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.
+ */
+
+// 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
diff --git a/Android/src/gradle.properties b/Android/src/gradle.properties
new file mode 100644
index 0000000..20e2a01
--- /dev/null
+++ b/Android/src/gradle.properties
@@ -0,0 +1,23 @@
+# Project-wide Gradle settings.
+# IDE (e.g. Android Studio) users:
+# Gradle settings configured through the IDE *will override*
+# any settings specified in this file.
+# For more details on how to configure your build environment visit
+# http://www.gradle.org/docs/current/userguide/build_environment.html
+# Specifies the JVM arguments used for the daemon process.
+# The setting is particularly useful for tweaking memory settings.
+org.gradle.jvmargs=-Xmx2048m -Dfile.encoding=UTF-8
+# When configured, Gradle will run in incubating parallel mode.
+# This option should only be used with decoupled projects. For more details, visit
+# https://developer.android.com/r/tools/gradle-multi-project-decoupled-projects
+# org.gradle.parallel=true
+# AndroidX package structure to make it clearer which packages are bundled with the
+# Android operating system, and which are packaged with your app's APK
+# https://developer.android.com/topic/libraries/support-library/androidx-rn
+android.useAndroidX=true
+# Kotlin code style for this project: "official" or "obsolete":
+kotlin.code.style=official
+# Enables namespacing of each library's R class so that its R class includes only the
+# resources declared in the library itself and none from the library's dependencies,
+# thereby reducing the size of the R class for that library
+android.nonTransitiveRClass=true
\ No newline at end of file
diff --git a/Android/src/gradle/libs.versions.toml b/Android/src/gradle/libs.versions.toml
new file mode 100644
index 0000000..4b056d6
--- /dev/null
+++ b/Android/src/gradle/libs.versions.toml
@@ -0,0 +1,73 @@
+[versions]
+agp = "8.8.2"
+kotlin = "2.1.0"
+coreKtx = "1.15.0"
+junit = "4.13.2"
+junitVersion = "1.2.1"
+espressoCore = "3.6.1"
+lifecycleRuntimeKtx = "2.8.7"
+activityCompose = "1.10.1"
+composeBom = "2025.03.01"
+navigation = "2.8.9"
+serializationPlugin = "2.0.21"
+serializationJson = "1.7.3"
+materialIconExtended = "1.7.8"
+workRuntime = "2.10.0"
+dataStore = "1.1.4"
+gson = "2.12.1"
+lifecycleProcess = "2.8.7"
+#noinspection GradleDependency
+mediapipeTasksText = "0.10.21"
+mediapipeTasksGenai = "0.10.22"
+mediapipeTasksImageGenerator = "0.10.21"
+commonmark = "1.0.0-alpha02"
+richtext = "1.0.0-alpha02"
+playServicesTfliteJava = "16.4.0"
+playServicesTfliteGpu= "16.4.0"
+cameraX = "1.4.2"
+netOpenidAppauth = "0.11.1"
+splashscreen = "1.2.0-beta01"
+
+[libraries]
+androidx-core-ktx = { group = "androidx.core", name = "core-ktx", version.ref = "coreKtx" }
+junit = { group = "junit", name = "junit", version.ref = "junit" }
+androidx-junit = { group = "androidx.test.ext", name = "junit", version.ref = "junitVersion" }
+androidx-espresso-core = { group = "androidx.test.espresso", name = "espresso-core", version.ref = "espressoCore" }
+androidx-lifecycle-runtime-ktx = { group = "androidx.lifecycle", name = "lifecycle-runtime-ktx", version.ref = "lifecycleRuntimeKtx" }
+androidx-activity-compose = { group = "androidx.activity", name = "activity-compose", version.ref = "activityCompose" }
+androidx-compose-bom = { group = "androidx.compose", name = "compose-bom", version.ref = "composeBom" }
+androidx-ui = { group = "androidx.compose.ui", name = "ui" }
+androidx-ui-graphics = { group = "androidx.compose.ui", name = "ui-graphics" }
+androidx-ui-tooling = { group = "androidx.compose.ui", name = "ui-tooling" }
+androidx-ui-tooling-preview = { group = "androidx.compose.ui", name = "ui-tooling-preview" }
+androidx-ui-test-manifest = { group = "androidx.compose.ui", name = "ui-test-manifest" }
+androidx-ui-test-junit4 = { group = "androidx.compose.ui", name = "ui-test-junit4" }
+androidx-material3 = { group = "androidx.compose.material3", name = "material3" }
+androidx-compose-navigation = { group = "androidx.navigation", name = "navigation-compose", version.ref = "navigation" }
+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" }
+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" }
+mediapipe-tasks-genai = { group = "com.google.mediapipe", name = "tasks-genai", version.ref = "mediapipeTasksGenai" }
+mediapipe-tasks-imagegen = { group = "com.google.mediapipe", name = "tasks-vision-image-generator", version.ref = "mediapipeTasksImageGenerator" }
+commonmark = { group = "com.halilibo.compose-richtext", name = "richtext-commonmark", version.ref = "commonmark" }
+richtext = { group = "com.halilibo.compose-richtext", name = "richtext-ui-material3", version.ref = "richtext" }
+tflite = { group = "com.google.android.gms", name = "play-services-tflite-java", version.ref = "playServicesTfliteJava" }
+tflite-gpu = { group = "com.google.android.gms", name = "play-services-tflite-gpu", version.ref = "playServicesTfliteGpu" }
+tflite-support = { group = "com.google.android.gms", name = "play-services-tflite-support", version.ref = "playServicesTfliteJava" }
+camerax-core = { group = "androidx.camera", name = "camera-core", version.ref = "cameraX"}
+camerax-camera2 = { group = "androidx.camera", name = "camera-camera2", version.ref = "cameraX"}
+camerax-lifecycle = { group = "androidx.camera", name = "camera-lifecycle", version.ref = "cameraX"}
+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" }
+
+[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" }
+
diff --git a/Android/src/gradle/wrapper/gradle-wrapper.jar b/Android/src/gradle/wrapper/gradle-wrapper.jar
new file mode 100644
index 0000000..e708b1c
Binary files /dev/null and b/Android/src/gradle/wrapper/gradle-wrapper.jar differ
diff --git a/Android/src/gradle/wrapper/gradle-wrapper.properties b/Android/src/gradle/wrapper/gradle-wrapper.properties
new file mode 100644
index 0000000..a1d407e
--- /dev/null
+++ b/Android/src/gradle/wrapper/gradle-wrapper.properties
@@ -0,0 +1,6 @@
+#Sun Mar 02 09:29:13 PST 2025
+distributionBase=GRADLE_USER_HOME
+distributionPath=wrapper/dists
+distributionUrl=https\://services.gradle.org/distributions/gradle-8.10.2-bin.zip
+zipStoreBase=GRADLE_USER_HOME
+zipStorePath=wrapper/dists
diff --git a/Android/src/gradlew b/Android/src/gradlew
new file mode 100755
index 0000000..4f906e0
--- /dev/null
+++ b/Android/src/gradlew
@@ -0,0 +1,185 @@
+#!/usr/bin/env sh
+
+#
+# Copyright 2015 the original author or authors.
+#
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+#
+# https://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+#
+
+##############################################################################
+##
+## Gradle start up script for UN*X
+##
+##############################################################################
+
+# Attempt to set APP_HOME
+# Resolve links: $0 may be a link
+PRG="$0"
+# Need this for relative symlinks.
+while [ -h "$PRG" ] ; do
+ ls=`ls -ld "$PRG"`
+ link=`expr "$ls" : '.*-> \(.*\)$'`
+ if expr "$link" : '/.*' > /dev/null; then
+ PRG="$link"
+ else
+ PRG=`dirname "$PRG"`"/$link"
+ fi
+done
+SAVED="`pwd`"
+cd "`dirname \"$PRG\"`/" >/dev/null
+APP_HOME="`pwd -P`"
+cd "$SAVED" >/dev/null
+
+APP_NAME="Gradle"
+APP_BASE_NAME=`basename "$0"`
+
+# Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.
+DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"'
+
+# Use the maximum available, or set MAX_FD != -1 to use that value.
+MAX_FD="maximum"
+
+warn () {
+ echo "$*"
+}
+
+die () {
+ echo
+ echo "$*"
+ echo
+ exit 1
+}
+
+# OS specific support (must be 'true' or 'false').
+cygwin=false
+msys=false
+darwin=false
+nonstop=false
+case "`uname`" in
+ CYGWIN* )
+ cygwin=true
+ ;;
+ Darwin* )
+ darwin=true
+ ;;
+ MINGW* )
+ msys=true
+ ;;
+ NONSTOP* )
+ nonstop=true
+ ;;
+esac
+
+CLASSPATH=$APP_HOME/gradle/wrapper/gradle-wrapper.jar
+
+
+# Determine the Java command to use to start the JVM.
+if [ -n "$JAVA_HOME" ] ; then
+ if [ -x "$JAVA_HOME/jre/sh/java" ] ; then
+ # IBM's JDK on AIX uses strange locations for the executables
+ JAVACMD="$JAVA_HOME/jre/sh/java"
+ else
+ JAVACMD="$JAVA_HOME/bin/java"
+ fi
+ if [ ! -x "$JAVACMD" ] ; then
+ die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME
+
+Please set the JAVA_HOME variable in your environment to match the
+location of your Java installation."
+ fi
+else
+ JAVACMD="java"
+ which java >/dev/null 2>&1 || die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH.
+
+Please set the JAVA_HOME variable in your environment to match the
+location of your Java installation."
+fi
+
+# Increase the maximum file descriptors if we can.
+if [ "$cygwin" = "false" -a "$darwin" = "false" -a "$nonstop" = "false" ] ; then
+ MAX_FD_LIMIT=`ulimit -H -n`
+ if [ $? -eq 0 ] ; then
+ if [ "$MAX_FD" = "maximum" -o "$MAX_FD" = "max" ] ; then
+ MAX_FD="$MAX_FD_LIMIT"
+ fi
+ ulimit -n $MAX_FD
+ if [ $? -ne 0 ] ; then
+ warn "Could not set maximum file descriptor limit: $MAX_FD"
+ fi
+ else
+ warn "Could not query maximum file descriptor limit: $MAX_FD_LIMIT"
+ fi
+fi
+
+# For Darwin, add options to specify how the application appears in the dock
+if $darwin; then
+ GRADLE_OPTS="$GRADLE_OPTS \"-Xdock:name=$APP_NAME\" \"-Xdock:icon=$APP_HOME/media/gradle.icns\""
+fi
+
+# For Cygwin or MSYS, switch paths to Windows format before running java
+if [ "$cygwin" = "true" -o "$msys" = "true" ] ; then
+ APP_HOME=`cygpath --path --mixed "$APP_HOME"`
+ CLASSPATH=`cygpath --path --mixed "$CLASSPATH"`
+
+ JAVACMD=`cygpath --unix "$JAVACMD"`
+
+ # We build the pattern for arguments to be converted via cygpath
+ ROOTDIRSRAW=`find -L / -maxdepth 1 -mindepth 1 -type d 2>/dev/null`
+ SEP=""
+ for dir in $ROOTDIRSRAW ; do
+ ROOTDIRS="$ROOTDIRS$SEP$dir"
+ SEP="|"
+ done
+ OURCYGPATTERN="(^($ROOTDIRS))"
+ # Add a user-defined pattern to the cygpath arguments
+ if [ "$GRADLE_CYGPATTERN" != "" ] ; then
+ OURCYGPATTERN="$OURCYGPATTERN|($GRADLE_CYGPATTERN)"
+ fi
+ # Now convert the arguments - kludge to limit ourselves to /bin/sh
+ i=0
+ for arg in "$@" ; do
+ CHECK=`echo "$arg"|egrep -c "$OURCYGPATTERN" -`
+ CHECK2=`echo "$arg"|egrep -c "^-"` ### Determine if an option
+
+ if [ $CHECK -ne 0 ] && [ $CHECK2 -eq 0 ] ; then ### Added a condition
+ eval `echo args$i`=`cygpath --path --ignore --mixed "$arg"`
+ else
+ eval `echo args$i`="\"$arg\""
+ fi
+ i=`expr $i + 1`
+ done
+ case $i in
+ 0) set -- ;;
+ 1) set -- "$args0" ;;
+ 2) set -- "$args0" "$args1" ;;
+ 3) set -- "$args0" "$args1" "$args2" ;;
+ 4) set -- "$args0" "$args1" "$args2" "$args3" ;;
+ 5) set -- "$args0" "$args1" "$args2" "$args3" "$args4" ;;
+ 6) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" ;;
+ 7) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" ;;
+ 8) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" "$args7" ;;
+ 9) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" "$args7" "$args8" ;;
+ esac
+fi
+
+# Escape application args
+save () {
+ for i do printf %s\\n "$i" | sed "s/'/'\\\\''/g;1s/^/'/;\$s/\$/' \\\\/" ; done
+ echo " "
+}
+APP_ARGS=`save "$@"`
+
+# Collect all arguments for the java command, following the shell quoting and substitution rules
+eval set -- $DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS "\"-Dorg.gradle.appname=$APP_BASE_NAME\"" -classpath "\"$CLASSPATH\"" org.gradle.wrapper.GradleWrapperMain "$APP_ARGS"
+
+exec "$JAVACMD" "$@"
diff --git a/Android/src/gradlew.bat b/Android/src/gradlew.bat
new file mode 100644
index 0000000..107acd3
--- /dev/null
+++ b/Android/src/gradlew.bat
@@ -0,0 +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
diff --git a/Android/src/settings.gradle.kts b/Android/src/settings.gradle.kts
new file mode 100644
index 0000000..e574fc2
--- /dev/null
+++ b/Android/src/settings.gradle.kts
@@ -0,0 +1,40 @@
+/*
+ * 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.
+ */
+
+pluginManagement {
+ repositories {
+ google {
+ content {
+ includeGroupByRegex("com\\.android.*")
+ includeGroupByRegex("com\\.google.*")
+ includeGroupByRegex("androidx.*")
+ }
+ }
+ mavenCentral()
+ gradlePluginPortal()
+ }
+}
+dependencyResolutionManagement {
+ repositoriesMode.set(RepositoriesMode.FAIL_ON_PROJECT_REPOS)
+ repositories {
+ google()
+ mavenCentral()
+ }
+}
+
+rootProject.name = "AI Edge Gallery"
+include(":app")
+
\ No newline at end of file
diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md
new file mode 100644
index 0000000..f708db4
--- /dev/null
+++ b/CONTRIBUTING.md
@@ -0,0 +1,4 @@
+The repository is not currently ready for code contributions. We will
+make a separate announcement when we are ready for OSS users to make
+contributions to it.
+
diff --git a/LICENSE b/LICENSE
new file mode 100644
index 0000000..261eeb9
--- /dev/null
+++ b/LICENSE
@@ -0,0 +1,201 @@
+ Apache License
+ Version 2.0, January 2004
+ http://www.apache.org/licenses/
+
+ TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
+
+ 1. Definitions.
+
+ "License" shall mean the terms and conditions for use, reproduction,
+ and distribution as defined by Sections 1 through 9 of this document.
+
+ "Licensor" shall mean the copyright owner or entity authorized by
+ the copyright owner that is granting the License.
+
+ "Legal Entity" shall mean the union of the acting entity and all
+ other entities that control, are controlled by, or are under common
+ control with that entity. For the purposes of this definition,
+ "control" means (i) the power, direct or indirect, to cause the
+ direction or management of such entity, whether by contract or
+ otherwise, or (ii) ownership of fifty percent (50%) or more of the
+ outstanding shares, or (iii) beneficial ownership of such entity.
+
+ "You" (or "Your") shall mean an individual or Legal Entity
+ exercising permissions granted by this License.
+
+ "Source" form shall mean the preferred form for making modifications,
+ including but not limited to software source code, documentation
+ source, and configuration files.
+
+ "Object" form shall mean any form resulting from mechanical
+ transformation or translation of a Source form, including but
+ not limited to compiled object code, generated documentation,
+ and conversions to other media types.
+
+ "Work" shall mean the work of authorship, whether in Source or
+ Object form, made available under the License, as indicated by a
+ copyright notice that is included in or attached to the work
+ (an example is provided in the Appendix below).
+
+ "Derivative Works" shall mean any work, whether in Source or Object
+ form, that is based on (or derived from) the Work and for which the
+ editorial revisions, annotations, elaborations, or other modifications
+ represent, as a whole, an original work of authorship. For the purposes
+ of this License, Derivative Works shall not include works that remain
+ separable from, or merely link (or bind by name) to the interfaces of,
+ the Work and Derivative Works thereof.
+
+ "Contribution" shall mean any work of authorship, including
+ the original version of the Work and any modifications or additions
+ to that Work or Derivative Works thereof, that is intentionally
+ submitted to Licensor for inclusion in the Work by the copyright owner
+ or by an individual or Legal Entity authorized to submit on behalf of
+ the copyright owner. For the purposes of this definition, "submitted"
+ means any form of electronic, verbal, or written communication sent
+ to the Licensor or its representatives, including but not limited to
+ communication on electronic mailing lists, source code control systems,
+ and issue tracking systems that are managed by, or on behalf of, the
+ Licensor for the purpose of discussing and improving the Work, but
+ excluding communication that is conspicuously marked or otherwise
+ designated in writing by the copyright owner as "Not a Contribution."
+
+ "Contributor" shall mean Licensor and any individual or Legal Entity
+ on behalf of whom a Contribution has been received by Licensor and
+ subsequently incorporated within the Work.
+
+ 2. Grant of Copyright License. Subject to the terms and conditions of
+ this License, each Contributor hereby grants to You a perpetual,
+ worldwide, non-exclusive, no-charge, royalty-free, irrevocable
+ copyright license to reproduce, prepare Derivative Works of,
+ publicly display, publicly perform, sublicense, and distribute the
+ Work and such Derivative Works in Source or Object form.
+
+ 3. Grant of Patent License. Subject to the terms and conditions of
+ this License, each Contributor hereby grants to You a perpetual,
+ worldwide, non-exclusive, no-charge, royalty-free, irrevocable
+ (except as stated in this section) patent license to make, have made,
+ use, offer to sell, sell, import, and otherwise transfer the Work,
+ where such license applies only to those patent claims licensable
+ by such Contributor that are necessarily infringed by their
+ Contribution(s) alone or by combination of their Contribution(s)
+ with the Work to which such Contribution(s) was submitted. If You
+ institute patent litigation against any entity (including a
+ cross-claim or counterclaim in a lawsuit) alleging that the Work
+ or a Contribution incorporated within the Work constitutes direct
+ or contributory patent infringement, then any patent licenses
+ granted to You under this License for that Work shall terminate
+ as of the date such litigation is filed.
+
+ 4. Redistribution. You may reproduce and distribute copies of the
+ Work or Derivative Works thereof in any medium, with or without
+ modifications, and in Source or Object form, provided that You
+ meet the following conditions:
+
+ (a) You must give any other recipients of the Work or
+ Derivative Works a copy of this License; and
+
+ (b) You must cause any modified files to carry prominent notices
+ stating that You changed the files; and
+
+ (c) You must retain, in the Source form of any Derivative Works
+ that You distribute, all copyright, patent, trademark, and
+ attribution notices from the Source form of the Work,
+ excluding those notices that do not pertain to any part of
+ the Derivative Works; and
+
+ (d) If the Work includes a "NOTICE" text file as part of its
+ distribution, then any Derivative Works that You distribute must
+ include a readable copy of the attribution notices contained
+ within such NOTICE file, excluding those notices that do not
+ pertain to any part of the Derivative Works, in at least one
+ of the following places: within a NOTICE text file distributed
+ as part of the Derivative Works; within the Source form or
+ documentation, if provided along with the Derivative Works; or,
+ within a display generated by the Derivative Works, if and
+ wherever such third-party notices normally appear. The contents
+ of the NOTICE file are for informational purposes only and
+ do not modify the License. You may add Your own attribution
+ notices within Derivative Works that You distribute, alongside
+ or as an addendum to the NOTICE text from the Work, provided
+ that such additional attribution notices cannot be construed
+ as modifying the License.
+
+ You may add Your own copyright statement to Your modifications and
+ may provide additional or different license terms and conditions
+ for use, reproduction, or distribution of Your modifications, or
+ for any such Derivative Works as a whole, provided Your use,
+ reproduction, and distribution of the Work otherwise complies with
+ the conditions stated in this License.
+
+ 5. Submission of Contributions. Unless You explicitly state otherwise,
+ any Contribution intentionally submitted for inclusion in the Work
+ by You to the Licensor shall be under the terms and conditions of
+ this License, without any additional terms or conditions.
+ Notwithstanding the above, nothing herein shall supersede or modify
+ the terms of any separate license agreement you may have executed
+ with Licensor regarding such Contributions.
+
+ 6. Trademarks. This License does not grant permission to use the trade
+ names, trademarks, service marks, or product names of the Licensor,
+ except as required for reasonable and customary use in describing the
+ origin of the Work and reproducing the content of the NOTICE file.
+
+ 7. Disclaimer of Warranty. Unless required by applicable law or
+ agreed to in writing, Licensor provides the Work (and each
+ Contributor provides its Contributions) on an "AS IS" BASIS,
+ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
+ implied, including, without limitation, any warranties or conditions
+ of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
+ PARTICULAR PURPOSE. You are solely responsible for determining the
+ appropriateness of using or redistributing the Work and assume any
+ risks associated with Your exercise of permissions under this License.
+
+ 8. Limitation of Liability. In no event and under no legal theory,
+ whether in tort (including negligence), contract, or otherwise,
+ unless required by applicable law (such as deliberate and grossly
+ negligent acts) or agreed to in writing, shall any Contributor be
+ liable to You for damages, including any direct, indirect, special,
+ incidental, or consequential damages of any character arising as a
+ result of this License or out of the use or inability to use the
+ Work (including but not limited to damages for loss of goodwill,
+ work stoppage, computer failure or malfunction, or any and all
+ other commercial damages or losses), even if such Contributor
+ has been advised of the possibility of such damages.
+
+ 9. Accepting Warranty or Additional Liability. While redistributing
+ the Work or Derivative Works thereof, You may choose to offer,
+ and charge a fee for, acceptance of support, warranty, indemnity,
+ or other liability obligations and/or rights consistent with this
+ License. However, in accepting such obligations, You may act only
+ on Your own behalf and on Your sole responsibility, not on behalf
+ of any other Contributor, and only if You agree to indemnify,
+ defend, and hold each Contributor harmless for any liability
+ incurred by, or claims asserted against, such Contributor by reason
+ of your accepting any such warranty or additional liability.
+
+ END OF TERMS AND CONDITIONS
+
+ APPENDIX: How to apply the Apache License to your work.
+
+ To apply the Apache License to your work, attach the following
+ boilerplate notice, with the fields enclosed by brackets "[]"
+ replaced with your own identifying information. (Don't include
+ the brackets!) The text should be enclosed in the appropriate
+ comment syntax for the file format. We also recommend that a
+ file or class name and description of purpose be included on the
+ same "printed page" as the copyright notice for easier
+ identification within third-party archives.
+
+ Copyright [yyyy] [name of copyright owner]
+
+ 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.
diff --git a/README.md b/README.md
index 1955fed..5fadf33 100644
--- a/README.md
+++ b/README.md
@@ -1 +1,7 @@
-# gallery
+# AI Edge Gallery
+
+The AI Edge Gallery aims to provide developers and consumers with an easy-to-use
+Android and iOS application to explore, download, and interactively experience
+various Google AI Edge on-device machine learning (ODML) capabilities
+directly on their devices.
+