Repository: google-ai-edge/gallery Branch: main Commit: 906ee4345e40 Files: 208 Total size: 1.4 MB Directory structure: gitextract_bdntjjtq/ ├── .github/ │ ├── ISSUE_TEMPLATE/ │ │ ├── bug_report.md │ │ ├── feature_request.md │ │ └── support_request.md │ └── workflows/ │ └── build_android.yaml ├── .gitignore ├── Android/ │ ├── .gitignore │ ├── README.md │ └── src/ │ ├── .gitignore │ ├── app/ │ │ ├── .gitignore │ │ ├── build.gradle.kts │ │ └── src/ │ │ └── main/ │ │ ├── AndroidManifest.xml │ │ ├── assets/ │ │ │ └── tinygarden/ │ │ │ ├── index.html │ │ │ ├── main-K5DSW5YL.js │ │ │ └── styles-63IRQW2E.css │ │ ├── bundle_config.pb.json │ │ ├── java/ │ │ │ └── com/ │ │ │ └── google/ │ │ │ └── ai/ │ │ │ └── edge/ │ │ │ └── gallery/ │ │ │ ├── Analytics.kt │ │ │ ├── BenchmarkResultsSerializer.kt │ │ │ ├── CutoutsSerializer.kt │ │ │ ├── FcmMessagingService.kt │ │ │ ├── GalleryApp.kt │ │ │ ├── GalleryAppTopBar.kt │ │ │ ├── GalleryApplication.kt │ │ │ ├── GalleryLifecycleProvider.kt │ │ │ ├── MainActivity.kt │ │ │ ├── SettingsSerializer.kt │ │ │ ├── UserDataSerializer.kt │ │ │ ├── common/ │ │ │ │ ├── ProjectConfig.kt │ │ │ │ ├── Types.kt │ │ │ │ └── Utils.kt │ │ │ ├── customtasks/ │ │ │ │ ├── common/ │ │ │ │ │ ├── CustomTask.kt │ │ │ │ │ └── CustomTaskData.kt │ │ │ │ ├── examplecustomtask/ │ │ │ │ │ ├── ExampleCustomTask.kt │ │ │ │ │ ├── ExampleCustomTaskModule.kt │ │ │ │ │ ├── ExampleCustomTaskScreen.kt │ │ │ │ │ └── ExampleCustomTaskViewModel.kt │ │ │ │ ├── mobileactions/ │ │ │ │ │ ├── Actions.kt │ │ │ │ │ ├── MobileActionsModule.kt │ │ │ │ │ ├── MobileActionsScreen.kt │ │ │ │ │ ├── MobileActionsTask.kt │ │ │ │ │ ├── MobileActionsTools.kt │ │ │ │ │ └── MobileActionsViewModel.kt │ │ │ │ └── tinygarden/ │ │ │ │ ├── ConversationHistoryPanel.kt │ │ │ │ ├── TinyGardenScreen.kt │ │ │ │ ├── TinyGardenTask.kt │ │ │ │ ├── TinyGardenTaskModule.kt │ │ │ │ ├── TinyGardenTools.kt │ │ │ │ └── TinyGardenViewModel.kt │ │ │ ├── data/ │ │ │ │ ├── AppBarAction.kt │ │ │ │ ├── Categories.kt │ │ │ │ ├── Config.kt │ │ │ │ ├── ConfigValue.kt │ │ │ │ ├── Consts.kt │ │ │ │ ├── DataStoreRepository.kt │ │ │ │ ├── DownloadRepository.kt │ │ │ │ ├── Model.kt │ │ │ │ ├── ModelAllowlist.kt │ │ │ │ ├── Tasks.kt │ │ │ │ └── Types.kt │ │ │ ├── di/ │ │ │ │ └── AppModule.kt │ │ │ ├── runtime/ │ │ │ │ ├── LlmModelHelper.kt │ │ │ │ └── ModelHelperExt.kt │ │ │ ├── ui/ │ │ │ │ ├── benchmark/ │ │ │ │ │ ├── BenchmarkModelPicker.kt │ │ │ │ │ ├── BenchmarkResultsViewer.kt │ │ │ │ │ ├── BenchmarkScreen.kt │ │ │ │ │ ├── BenchmarkValueSeriesViewer.kt │ │ │ │ │ └── BenchmarkViewModel.kt │ │ │ │ ├── common/ │ │ │ │ │ ├── Accordions.kt │ │ │ │ │ ├── AudioAnimation.kt │ │ │ │ │ ├── ClickableLink.kt │ │ │ │ │ ├── ColorUtils.kt │ │ │ │ │ ├── ConfigDialog.kt │ │ │ │ │ ├── DownloadAndTryButton.kt │ │ │ │ │ ├── EmptyState.kt │ │ │ │ │ ├── ErrorDialog.kt │ │ │ │ │ ├── GalleryWebView.kt │ │ │ │ │ ├── GlitteringShapesLoader.kt │ │ │ │ │ ├── LiveCameraView.kt │ │ │ │ │ ├── MarkdownText.kt │ │ │ │ │ ├── MemoryWarning.kt │ │ │ │ │ ├── ModelPageAppBar.kt │ │ │ │ │ ├── ModelPicker.kt │ │ │ │ │ ├── ModelPickerChip.kt │ │ │ │ │ ├── RotationalLoader.kt │ │ │ │ │ ├── TaskIcon.kt │ │ │ │ │ ├── Utils.kt │ │ │ │ │ ├── chat/ │ │ │ │ │ │ ├── AudioPlaybackPanel.kt │ │ │ │ │ │ ├── AudioRecorderPanel.kt │ │ │ │ │ │ ├── BenchmarkConfigDialog.kt │ │ │ │ │ │ ├── ChatMessage.kt │ │ │ │ │ │ ├── ChatPanel.kt │ │ │ │ │ │ ├── ChatView.kt │ │ │ │ │ │ ├── ChatViewModel.kt │ │ │ │ │ │ ├── DataCard.kt │ │ │ │ │ │ ├── MessageActionButton.kt │ │ │ │ │ │ ├── MessageBodyAudioClip.kt │ │ │ │ │ │ ├── MessageBodyBenchmark.kt │ │ │ │ │ │ ├── MessageBodyBenchmarkLlm.kt │ │ │ │ │ │ ├── MessageBodyClassification.kt │ │ │ │ │ │ ├── MessageBodyCollapsableProgressPanel.kt │ │ │ │ │ │ ├── MessageBodyConfigUpdate.kt │ │ │ │ │ │ ├── MessageBodyError.kt │ │ │ │ │ │ ├── MessageBodyImage.kt │ │ │ │ │ │ ├── MessageBodyImageWithHistory.kt │ │ │ │ │ │ ├── MessageBodyInfo.kt │ │ │ │ │ │ ├── MessageBodyLoading.kt │ │ │ │ │ │ ├── MessageBodyPromptTemplates.kt │ │ │ │ │ │ ├── MessageBodyText.kt │ │ │ │ │ │ ├── MessageBodyWarning.kt │ │ │ │ │ │ ├── MessageBodyWebview.kt │ │ │ │ │ │ ├── MessageBubbleShape.kt │ │ │ │ │ │ ├── MessageInputText.kt │ │ │ │ │ │ ├── MessageLatency.kt │ │ │ │ │ │ ├── MessageSender.kt │ │ │ │ │ │ ├── ModelDownloadStatusInfoPanel.kt │ │ │ │ │ │ ├── ModelDownloadingAnimation.kt │ │ │ │ │ │ ├── ModelInitializationStatus.kt │ │ │ │ │ │ ├── ModelNotDownloaded.kt │ │ │ │ │ │ ├── TextInputHistorySheet.kt │ │ │ │ │ │ └── ZoomableImage.kt │ │ │ │ │ ├── modelitem/ │ │ │ │ │ │ ├── ConfirmDeleteModelDialog.kt │ │ │ │ │ │ ├── DeleteModelButton.kt │ │ │ │ │ │ ├── DownloadModelPanel.kt │ │ │ │ │ │ ├── ModelItem.kt │ │ │ │ │ │ ├── ModelNameAndStatus.kt │ │ │ │ │ │ └── StatusIcon.kt │ │ │ │ │ ├── textandvoiceinput/ │ │ │ │ │ │ ├── HoldToDictate.kt │ │ │ │ │ │ ├── HoldToDictateViewModel.kt │ │ │ │ │ │ ├── TextAndVoiceInput.kt │ │ │ │ │ │ └── VoiceRecognizerOverlay.kt │ │ │ │ │ └── tos/ │ │ │ │ │ ├── AppTosDialog.kt │ │ │ │ │ ├── GemmaTermsOfUseDialog.kt │ │ │ │ │ └── TosViewModel.kt │ │ │ │ ├── home/ │ │ │ │ │ ├── HomeScreen.kt │ │ │ │ │ ├── MobileActionsChallengeDialog.kt │ │ │ │ │ ├── NewReleaseNotification.kt │ │ │ │ │ ├── SettingsDialog.kt │ │ │ │ │ └── SquareDrawerItem.kt │ │ │ │ ├── icon/ │ │ │ │ │ └── Deploy.kt │ │ │ │ ├── llmchat/ │ │ │ │ │ ├── LlmChatModelHelper.kt │ │ │ │ │ ├── LlmChatScreen.kt │ │ │ │ │ ├── LlmChatTaskModule.kt │ │ │ │ │ └── LlmChatViewModel.kt │ │ │ │ ├── llmsingleturn/ │ │ │ │ │ ├── LlmSingleTurnScreen.kt │ │ │ │ │ ├── LlmSingleTurnTaskModule.kt │ │ │ │ │ ├── LlmSingleTurnViewModel.kt │ │ │ │ │ ├── PromptTemplateConfigs.kt │ │ │ │ │ ├── PromptTemplatesPanel.kt │ │ │ │ │ ├── ResponsePanel.kt │ │ │ │ │ ├── SingleSelectButton.kt │ │ │ │ │ └── VerticalSplitView.kt │ │ │ │ ├── modelmanager/ │ │ │ │ │ ├── GlobalModelManager.kt │ │ │ │ │ ├── ModelImportDialog.kt │ │ │ │ │ ├── ModelList.kt │ │ │ │ │ ├── ModelManager.kt │ │ │ │ │ └── ModelManagerViewModel.kt │ │ │ │ ├── navigation/ │ │ │ │ │ └── GalleryNavGraph.kt │ │ │ │ └── theme/ │ │ │ │ ├── Color.kt │ │ │ │ ├── Theme.kt │ │ │ │ ├── ThemeSettings.kt │ │ │ │ └── Type.kt │ │ │ └── worker/ │ │ │ ├── AndroidManifest.xml │ │ │ └── DownloadWorker.kt │ │ ├── proto/ │ │ │ ├── benchmark.proto │ │ │ └── settings.proto │ │ └── res/ │ │ ├── drawable/ │ │ │ ├── chat_spark.xml │ │ │ ├── circle.xml │ │ │ ├── double_circle.xml │ │ │ ├── four_circle.xml │ │ │ ├── ic_experiment.xml │ │ │ ├── ic_launcher_background.xml │ │ │ ├── ic_launcher_foreground.xml │ │ │ ├── image_spark.xml │ │ │ ├── logo.xml │ │ │ ├── pantegon.xml │ │ │ ├── splash_screen_animated_icon.xml │ │ │ └── text_spark.xml │ │ ├── mipmap-anydpi-v26/ │ │ │ └── ic_launcher.xml │ │ ├── values/ │ │ │ ├── dimens.xml │ │ │ ├── ic_launcher_background.xml │ │ │ ├── strings.xml │ │ │ └── themes.xml │ │ ├── values-night/ │ │ │ └── themes.xml │ │ └── xml/ │ │ ├── backup_rules.xml │ │ ├── data_extraction_rules.xml │ │ └── file_paths.xml │ ├── build.gradle.kts │ ├── gradle/ │ │ ├── libs.versions.toml │ │ └── wrapper/ │ │ ├── gradle-wrapper.jar │ │ └── gradle-wrapper.properties │ ├── gradle.properties │ ├── gradlew │ ├── gradlew.bat │ └── settings.gradle.kts ├── Bug_Reporting_Guide.md ├── CONTRIBUTING.md ├── DEVELOPMENT.md ├── Function_Calling_Guide.md ├── LICENSE ├── README.md ├── model_allowlist.json └── model_allowlists/ ├── 1_0_10.json ├── 1_0_4.json ├── 1_0_5.json ├── 1_0_6.json ├── 1_0_7.json ├── 1_0_8.json ├── 1_0_9.json └── ios_1_0_0.json ================================================ FILE CONTENTS ================================================ ================================================ FILE: .github/ISSUE_TEMPLATE/bug_report.md ================================================ --- name: 🐛 Bug report about: Create a report to help us improve title: '' labels: '' assignees: '' --- **Describe the bug:** A clear and concise description of what the bug is. **To Reproduce:** Steps to reproduce the behavior: 1. Go to '...' 2. Click on '....' 3. Scroll down to '....' 4. See error **Expected behavior:** A clear and concise description of what you expected to happen. **Screenshots:** If applicable, add screenshots to help explain your problem. **Device & App Information (Please complete the following):** - Device: [e.g., Samsung Galaxy S23, Google Pixel 7] - Android Version: [e.g., Android 12, Android 13] - App Version: [e.g., 1.0.1, v1.0.2] **Additional context:** Add any other context about the problem here. ================================================ FILE: .github/ISSUE_TEMPLATE/feature_request.md ================================================ --- name: ✨ Feature request about: Suggest an idea for this project title: '' labels: '' assignees: '' --- **Is your feature request related to a problem? Please describe.** A clear and concise description of what the problem is. Ex. I'm always frustrated when [...] **Describe the solution you'd like** A clear and concise description of what you want to happen. **Describe alternatives you've considered** A clear and concise description of any alternative solutions or features you've considered. **Additional context** Add any other context or screenshots about the feature request here. ================================================ FILE: .github/ISSUE_TEMPLATE/support_request.md ================================================ --- name: 🆘 Support Request about: Ask a question or get help with usage. title: "[Support]: " labels: ["support", "question"] assignees: [] --- **What do you need help with?** Is this a question about how to do something, a configuration problem, or a general issue you can't solve? **Describe the issue/question:** Clearly describe what you are trying to achieve, what problem you are facing, or what question you have. **What have you tried so far? (Optional):** List any steps you've already taken to troubleshoot, find information, or attempt a solution. **Expected outcome (Optional):** If applicable, what did you hope would happen, or what solution are you looking for? **Screenshots/Videos (Optional):** If applicable, add screenshots or a short video that might help explain your situation. **Environment & Details:** Please provide details about your operating environment, relevant URLs, or any messages you see. - **Operating System:** - **Browser & Version (if applicable):** - **Any relevant messages (e.g., from UI, console):** ``` PASTE_ANY_MESSAGES_HERE ``` **Any additional context?:** Is there anything else that might be useful for us to know? ================================================ FILE: .github/workflows/build_android.yaml ================================================ name: Build Android APK on: workflow_dispatch: push: branches: [ "main" ] paths: - 'Android/**' pull_request: branches: [ "main" ] paths: - 'Android/**' jobs: build_apk: name: Build Android APK runs-on: ubuntu-latest defaults: run: working-directory: ./Android/src steps: - name: Checkout the source code uses: actions/checkout@v3 - uses: actions/setup-java@v4 with: distribution: 'temurin' java-version: '21' - name: Build run: ./gradlew assembleRelease ================================================ FILE: .gitignore ================================================ .DS_Store ================================================ FILE: Android/.gitignore ================================================ # @license # Copyright 2025 Google LLC # # Licensed under the Apache License, Version 2.0 (the "License"); # you may not use this file except in compliance with the License. # You may obtain a copy of the License at # # http://www.apache.org/licenses/LICENSE-2.0 # # Unless required by applicable law or agreed to in writing, software # distributed under the License is distributed on an "AS IS" BASIS, # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. # See the License for the specific language governing permissions and # limitations under the License. # ============================================================================== # Gradle files .gradle/ build/ # 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 ================================================ FILE: Android/README.md ================================================ # Google AI Edge Gallery (Android) ================================================ FILE: Android/src/.gitignore ================================================ # @license # Copyright 2025 Google LLC # # Licensed under the Apache License, Version 2.0 (the "License"); # you may not use this file except in compliance with the License. # You may obtain a copy of the License at # # http://www.apache.org/licenses/LICENSE-2.0 # # Unless required by applicable law or agreed to in writing, software # distributed under the License is distributed on an "AS IS" BASIS, # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. # See the License for the specific language governing permissions and # limitations under the License. # ============================================================================== *.iml .gradle /local.properties /.idea/caches /.idea/libraries /.idea/modules.xml /.idea/workspace.xml /.idea/navEditor.xml /.idea/assetWizardSettings.xml .DS_Store /build /captures .externalNativeBuild .cxx local.properties ================================================ FILE: Android/src/app/.gitignore ================================================ # @license # Copyright 2025 Google LLC # # Licensed under the Apache License, Version 2.0 (the "License"); # you may not use this file except in compliance with the License. # You may obtain a copy of the License at # # http://www.apache.org/licenses/LICENSE-2.0 # # Unless required by applicable law or agreed to in writing, software # distributed under the License is distributed on an "AS IS" BASIS, # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. # See the License for the specific language governing permissions and # limitations under the License. # ============================================================================== /build /release ================================================ FILE: Android/src/app/build.gradle.kts ================================================ /* * 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) // Note: set apply to true to enable google-services (requires google-services.json). alias(libs.plugins.google.services) apply false alias(libs.plugins.kotlin.android) alias(libs.plugins.kotlin.compose) alias(libs.plugins.kotlin.serialization) alias(libs.plugins.protobuf) alias(libs.plugins.hilt.application) alias(libs.plugins.oss.licenses) alias(libs.plugins.ksp) kotlin("kapt") } android { namespace = "com.google.ai.edge.gallery" compileSdk = 35 defaultConfig { applicationId = "com.google.aiedge.gallery" minSdk = 31 targetSdk = 35 versionCode = 20 versionName = "1.0.11" // Needed for HuggingFace auth workflows. // Use the scheme of the "Redirect URLs" in HuggingFace app. manifestPlaceholders["appAuthRedirectScheme"] = "REPLACE_WITH_YOUR_REDIRECT_SCHEME_IN_HUGGINGFACE_APP" manifestPlaceholders["applicationName"] = "com.google.ai.edge.gallery.GalleryApplication" 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 buildConfig = 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.kotlin.reflect) implementation(libs.material.icon.extended) implementation(libs.androidx.work.runtime) implementation(libs.androidx.datastore) implementation(libs.com.google.code.gson) implementation(libs.androidx.lifecycle.process) implementation(libs.androidx.security.crypto) implementation(libs.androidx.webkit) implementation(libs.litertlm) 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) implementation(libs.protobuf.javalite) implementation(libs.hilt.android) implementation(libs.hilt.navigation.compose) implementation(libs.play.services.oss.licenses) implementation(platform(libs.firebase.bom)) implementation(libs.firebase.analytics) implementation(libs.firebase.messaging) implementation(libs.androidx.exifinterface) implementation(libs.moshi.kotlin) kapt(libs.hilt.android.compiler) testImplementation(libs.junit) androidTestImplementation(libs.androidx.junit) androidTestImplementation(libs.androidx.espresso.core) androidTestImplementation(platform(libs.androidx.compose.bom)) androidTestImplementation(libs.androidx.ui.test.junit4) androidTestImplementation(libs.hilt.android.testing) debugImplementation(libs.androidx.ui.tooling) debugImplementation(libs.androidx.ui.test.manifest) ksp(libs.moshi.kotlin.codegen) } protobuf { protoc { artifact = "com.google.protobuf:protoc:4.26.1" } generateProtoTasks { all().forEach { it.plugins { create("java") { option("lite") } } } } } ================================================ FILE: Android/src/app/src/main/AndroidManifest.xml ================================================ ================================================ FILE: Android/src/app/src/main/assets/tinygarden/index.html ================================================ Tiny Garden ================================================ FILE: Android/src/app/src/main/assets/tinygarden/main-K5DSW5YL.js ================================================ /* * 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. */ var qg=Object.defineProperty,Yg=Object.defineProperties;var Zg=Object.getOwnPropertyDescriptors;var wu=Object.getOwnPropertySymbols;var Kg=Object.prototype.hasOwnProperty,Qg=Object.prototype.propertyIsEnumerable;var Tu=(e,t,n)=>t in e?qg(e,t,{enumerable:!0,configurable:!0,writable:!0,value:n}):e[t]=n,y=(e,t)=>{for(var n in t||={})Kg.call(t,n)&&Tu(e,n,t[n]);if(wu)for(var n of wu(t))Qg.call(t,n)&&Tu(e,n,t[n]);return e},P=(e,t)=>Yg(e,Zg(t));var Qs;function Oo(){return Qs}function qe(e){let t=Qs;return Qs=e,t}var Mu=Symbol("NotFound");function Sn(e){return e===Mu||e?.name==="\u0275NotFound"}function jo(e,t){return Object.is(e,t)}var de=null,Po=!1,Xs=1,Xg=null,oe=Symbol("SIGNAL");function _(e){let t=de;return de=e,t}function Ho(){return de}var $t={version:0,lastCleanEpoch:0,dirty:!1,producers:void 0,producersTail:void 0,consumers:void 0,consumersTail:void 0,recomputing:!1,consumerAllowSignalWrites:!1,consumerIsAlwaysLive:!1,kind:"unknown",producerMustRecompute:()=>!1,producerRecomputeValue:()=>{},consumerMarkedDirty:()=>{},consumerOnSignalRead:()=>{}};function bn(e){if(Po)throw new Error("");if(de===null)return;de.consumerOnSignalRead(e);let t=de.producersTail;if(t!==void 0&&t.producer===e)return;let n,r=de.recomputing;if(r&&(n=t!==void 0?t.nextProducer:de.producers,n!==void 0&&n.producer===e)){de.producersTail=n,n.lastReadVersion=e.version;return}let o=e.consumersTail;if(o!==void 0&&o.consumer===de&&(!r||em(o,de)))return;let i=Tn(de),s={producer:e,consumer:de,nextProducer:n,prevConsumer:o,lastReadVersion:e.version,nextConsumer:void 0};de.producersTail=s,t!==void 0?t.nextProducer=s:de.producers=s,i&&Ru(e,s)}function xu(){Xs++}function Bo(e){if(!(Tn(e)&&!e.dirty)&&!(!e.dirty&&e.lastCleanEpoch===Xs)){if(!e.producerMustRecompute(e)&&!wn(e)){Fo(e);return}e.producerRecomputeValue(e),Fo(e)}}function Js(e){if(e.consumers===void 0)return;let t=Po;Po=!0;try{for(let n=e.consumers;n!==void 0;n=n.nextConsumer){let r=n.consumer;r.dirty||Jg(r)}}finally{Po=t}}function ea(){return de?.consumerAllowSignalWrites!==!1}function Jg(e){e.dirty=!0,Js(e),e.consumerMarkedDirty?.(e)}function Fo(e){e.dirty=!1,e.lastCleanEpoch=Xs}function Gt(e){return e&&Nu(e),_(e)}function Nu(e){e.producersTail=void 0,e.recomputing=!0}function _n(e,t){_(t),e&&Au(e)}function Au(e){e.recomputing=!1;let t=e.producersTail,n=t!==void 0?t.nextProducer:e.producers;if(n!==void 0){if(Tn(e))do n=ta(n);while(n!==void 0);t!==void 0?t.nextProducer=void 0:e.producers=void 0}}function wn(e){for(let t=e.producers;t!==void 0;t=t.nextProducer){let n=t.producer,r=t.lastReadVersion;if(r!==n.version||(Bo(n),r!==n.version))return!0}return!1}function zt(e){if(Tn(e)){let t=e.producers;for(;t!==void 0;)t=ta(t)}e.producers=void 0,e.producersTail=void 0,e.consumers=void 0,e.consumersTail=void 0}function Ru(e,t){let n=e.consumersTail,r=Tn(e);if(n!==void 0?(t.nextConsumer=n.nextConsumer,n.nextConsumer=t):(t.nextConsumer=void 0,e.consumers=t),t.prevConsumer=n,e.consumersTail=t,!r)for(let o=e.producers;o!==void 0;o=o.nextProducer)Ru(o.producer,o)}function ta(e){let t=e.producer,n=e.nextProducer,r=e.nextConsumer,o=e.prevConsumer;if(e.nextConsumer=void 0,e.prevConsumer=void 0,r!==void 0?r.prevConsumer=o:t.consumersTail=o,o!==void 0)o.nextConsumer=r;else if(t.consumers=r,!Tn(t)){let i=t.producers;for(;i!==void 0;)i=ta(i)}return n}function Tn(e){return e.consumerIsAlwaysLive||e.consumers!==void 0}function Vo(e){Xg?.(e)}function em(e,t){let n=t.producersTail;if(n!==void 0){let r=t.producers;do{if(r===e)return!0;if(r===n)break;r=r.nextProducer}while(r!==void 0)}return!1}function Tr(e,t){let n=Object.create(tm);n.computation=e,t!==void 0&&(n.equal=t);let r=()=>{if(Bo(n),bn(n),n.value===wr)throw n.error;return n.value};return r[oe]=n,Vo(n),r}var ko=Symbol("UNSET"),Lo=Symbol("COMPUTING"),wr=Symbol("ERRORED"),tm=P(y({},$t),{value:ko,dirty:!0,error:null,equal:jo,kind:"computed",producerMustRecompute(e){return e.value===ko||e.value===Lo},producerRecomputeValue(e){if(e.value===Lo)throw new Error("");let t=e.value;e.value=Lo;let n=Gt(e),r,o=!1;try{r=e.computation(),_(null),o=t!==ko&&t!==wr&&r!==wr&&e.equal(t,r)}catch(i){r=wr,e.error=i}finally{_n(e,n)}if(o){e.value=t;return}e.value=r,e.version++}});function nm(){throw new Error}var Ou=nm;function Pu(e){Ou(e)}function na(e){Ou=e}var rm=null;function ra(e,t){let n=Object.create(Uo);n.value=e,t!==void 0&&(n.equal=t);let r=()=>ku(n);return r[oe]=n,Vo(n),[r,s=>Mn(n,s),s=>oa(n,s)]}function ku(e){return bn(e),e.value}function Mn(e,t){ea()||Pu(e),e.equal(e.value,t)||(e.value=t,om(e))}function oa(e,t){ea()||Pu(e),Mn(e,t(e.value))}var Uo=P(y({},$t),{equal:jo,value:void 0,kind:"signal"});function om(e){e.version++,xu(),Js(e),rm?.(e)}function x(e){return typeof e=="function"}function xn(e){let n=e(r=>{Error.call(r),r.stack=new Error().stack});return n.prototype=Object.create(Error.prototype),n.prototype.constructor=n,n}var $o=xn(e=>function(n){e(this),this.message=n?`${n.length} errors occurred during unsubscription: ${n.map((r,o)=>`${o+1}) ${r.toString()}`).join(` `)}`:"",this.name="UnsubscriptionError",this.errors=n});function Mr(e,t){if(e){let n=e.indexOf(t);0<=n&&e.splice(n,1)}}var q=class e{constructor(t){this.initialTeardown=t,this.closed=!1,this._parentage=null,this._finalizers=null}unsubscribe(){let t;if(!this.closed){this.closed=!0;let{_parentage:n}=this;if(n)if(this._parentage=null,Array.isArray(n))for(let i of n)i.remove(this);else n.remove(this);let{initialTeardown:r}=this;if(x(r))try{r()}catch(i){t=i instanceof $o?i.errors:[i]}let{_finalizers:o}=this;if(o){this._finalizers=null;for(let i of o)try{Lu(i)}catch(s){t=t??[],s instanceof $o?t=[...t,...s.errors]:t.push(s)}}if(t)throw new $o(t)}}add(t){var n;if(t&&t!==this)if(this.closed)Lu(t);else{if(t instanceof e){if(t.closed||t._hasParent(this))return;t._addParent(this)}(this._finalizers=(n=this._finalizers)!==null&&n!==void 0?n:[]).push(t)}}_hasParent(t){let{_parentage:n}=this;return n===t||Array.isArray(n)&&n.includes(t)}_addParent(t){let{_parentage:n}=this;this._parentage=Array.isArray(n)?(n.push(t),n):n?[n,t]:t}_removeParent(t){let{_parentage:n}=this;n===t?this._parentage=null:Array.isArray(n)&&Mr(n,t)}remove(t){let{_finalizers:n}=this;n&&Mr(n,t),t instanceof e&&t._removeParent(this)}};q.EMPTY=(()=>{let e=new q;return e.closed=!0,e})();var ia=q.EMPTY;function Go(e){return e instanceof q||e&&"closed"in e&&x(e.remove)&&x(e.add)&&x(e.unsubscribe)}function Lu(e){x(e)?e():e.unsubscribe()}var He={onUnhandledError:null,onStoppedNotification:null,Promise:void 0,useDeprecatedSynchronousErrorHandling:!1,useDeprecatedNextContext:!1};var Nn={setTimeout(e,t,...n){let{delegate:r}=Nn;return r?.setTimeout?r.setTimeout(e,t,...n):setTimeout(e,t,...n)},clearTimeout(e){let{delegate:t}=Nn;return(t?.clearTimeout||clearTimeout)(e)},delegate:void 0};function zo(e){Nn.setTimeout(()=>{let{onUnhandledError:t}=He;if(t)t(e);else throw e})}function xr(){}var Fu=sa("C",void 0,void 0);function ju(e){return sa("E",void 0,e)}function Hu(e){return sa("N",e,void 0)}function sa(e,t,n){return{kind:e,value:t,error:n}}var Wt=null;function An(e){if(He.useDeprecatedSynchronousErrorHandling){let t=!Wt;if(t&&(Wt={errorThrown:!1,error:null}),e(),t){let{errorThrown:n,error:r}=Wt;if(Wt=null,n)throw r}}else e()}function Bu(e){He.useDeprecatedSynchronousErrorHandling&&Wt&&(Wt.errorThrown=!0,Wt.error=e)}var qt=class extends q{constructor(t){super(),this.isStopped=!1,t?(this.destination=t,Go(t)&&t.add(this)):this.destination=am}static create(t,n,r){return new Rn(t,n,r)}next(t){this.isStopped?ca(Hu(t),this):this._next(t)}error(t){this.isStopped?ca(ju(t),this):(this.isStopped=!0,this._error(t))}complete(){this.isStopped?ca(Fu,this):(this.isStopped=!0,this._complete())}unsubscribe(){this.closed||(this.isStopped=!0,super.unsubscribe(),this.destination=null)}_next(t){this.destination.next(t)}_error(t){try{this.destination.error(t)}finally{this.unsubscribe()}}_complete(){try{this.destination.complete()}finally{this.unsubscribe()}}},im=Function.prototype.bind;function aa(e,t){return im.call(e,t)}var la=class{constructor(t){this.partialObserver=t}next(t){let{partialObserver:n}=this;if(n.next)try{n.next(t)}catch(r){Wo(r)}}error(t){let{partialObserver:n}=this;if(n.error)try{n.error(t)}catch(r){Wo(r)}else Wo(t)}complete(){let{partialObserver:t}=this;if(t.complete)try{t.complete()}catch(n){Wo(n)}}},Rn=class extends qt{constructor(t,n,r){super();let o;if(x(t)||!t)o={next:t??void 0,error:n??void 0,complete:r??void 0};else{let i;this&&He.useDeprecatedNextContext?(i=Object.create(t),i.unsubscribe=()=>this.unsubscribe(),o={next:t.next&&aa(t.next,i),error:t.error&&aa(t.error,i),complete:t.complete&&aa(t.complete,i)}):o=t}this.destination=new la(o)}};function Wo(e){He.useDeprecatedSynchronousErrorHandling?Bu(e):zo(e)}function sm(e){throw e}function ca(e,t){let{onStoppedNotification:n}=He;n&&Nn.setTimeout(()=>n(e,t))}var am={closed:!0,next:xr,error:sm,complete:xr};var On=typeof Symbol=="function"&&Symbol.observable||"@@observable";function be(e){return e}function ua(...e){return da(e)}function da(e){return e.length===0?be:e.length===1?e[0]:function(n){return e.reduce((r,o)=>o(r),n)}}var V=(()=>{class e{constructor(n){n&&(this._subscribe=n)}lift(n){let r=new e;return r.source=this,r.operator=n,r}subscribe(n,r,o){let i=lm(n)?n:new Rn(n,r,o);return An(()=>{let{operator:s,source:a}=this;i.add(s?s.call(i,a):a?this._subscribe(i):this._trySubscribe(i))}),i}_trySubscribe(n){try{return this._subscribe(n)}catch(r){n.error(r)}}forEach(n,r){return r=Vu(r),new r((o,i)=>{let s=new Rn({next:a=>{try{n(a)}catch(c){i(c),s.unsubscribe()}},error:i,complete:o});this.subscribe(s)})}_subscribe(n){var r;return(r=this.source)===null||r===void 0?void 0:r.subscribe(n)}[On](){return this}pipe(...n){return da(n)(this)}toPromise(n){return n=Vu(n),new n((r,o)=>{let i;this.subscribe(s=>i=s,s=>o(s),()=>r(i))})}}return e.create=t=>new e(t),e})();function Vu(e){var t;return(t=e??He.Promise)!==null&&t!==void 0?t:Promise}function cm(e){return e&&x(e.next)&&x(e.error)&&x(e.complete)}function lm(e){return e&&e instanceof qt||cm(e)&&Go(e)}function fa(e){return x(e?.lift)}function j(e){return t=>{if(fa(t))return t.lift(function(n){try{return e(n,this)}catch(r){this.error(r)}});throw new TypeError("Unable to lift unknown Observable type")}}function H(e,t,n,r,o){return new pa(e,t,n,r,o)}var pa=class extends qt{constructor(t,n,r,o,i,s){super(t),this.onFinalize=i,this.shouldUnsubscribe=s,this._next=n?function(a){try{n(a)}catch(c){t.error(c)}}:super._next,this._error=o?function(a){try{o(a)}catch(c){t.error(c)}finally{this.unsubscribe()}}:super._error,this._complete=r?function(){try{r()}catch(a){t.error(a)}finally{this.unsubscribe()}}:super._complete}unsubscribe(){var t;if(!this.shouldUnsubscribe||this.shouldUnsubscribe()){let{closed:n}=this;super.unsubscribe(),!n&&((t=this.onFinalize)===null||t===void 0||t.call(this))}}};function Pn(){return j((e,t)=>{let n=null;e._refCount++;let r=H(t,void 0,void 0,void 0,()=>{if(!e||e._refCount<=0||0<--e._refCount){n=null;return}let o=e._connection,i=n;n=null,o&&(!i||o===i)&&o.unsubscribe(),t.unsubscribe()});e.subscribe(r),r.closed||(n=e.connect())})}var kn=class extends V{constructor(t,n){super(),this.source=t,this.subjectFactory=n,this._subject=null,this._refCount=0,this._connection=null,fa(t)&&(this.lift=t.lift)}_subscribe(t){return this.getSubject().subscribe(t)}getSubject(){let t=this._subject;return(!t||t.isStopped)&&(this._subject=this.subjectFactory()),this._subject}_teardown(){this._refCount=0;let{_connection:t}=this;this._subject=this._connection=null,t?.unsubscribe()}connect(){let t=this._connection;if(!t){t=this._connection=new q;let n=this.getSubject();t.add(this.source.subscribe(H(n,void 0,()=>{this._teardown(),n.complete()},r=>{this._teardown(),n.error(r)},()=>this._teardown()))),t.closed&&(this._connection=null,t=q.EMPTY)}return t}refCount(){return Pn()(this)}};var Uu=xn(e=>function(){e(this),this.name="ObjectUnsubscribedError",this.message="object unsubscribed"});var X=(()=>{class e extends V{constructor(){super(),this.closed=!1,this.currentObservers=null,this.observers=[],this.isStopped=!1,this.hasError=!1,this.thrownError=null}lift(n){let r=new qo(this,this);return r.operator=n,r}_throwIfClosed(){if(this.closed)throw new Uu}next(n){An(()=>{if(this._throwIfClosed(),!this.isStopped){this.currentObservers||(this.currentObservers=Array.from(this.observers));for(let r of this.currentObservers)r.next(n)}})}error(n){An(()=>{if(this._throwIfClosed(),!this.isStopped){this.hasError=this.isStopped=!0,this.thrownError=n;let{observers:r}=this;for(;r.length;)r.shift().error(n)}})}complete(){An(()=>{if(this._throwIfClosed(),!this.isStopped){this.isStopped=!0;let{observers:n}=this;for(;n.length;)n.shift().complete()}})}unsubscribe(){this.isStopped=this.closed=!0,this.observers=this.currentObservers=null}get observed(){var n;return((n=this.observers)===null||n===void 0?void 0:n.length)>0}_trySubscribe(n){return this._throwIfClosed(),super._trySubscribe(n)}_subscribe(n){return this._throwIfClosed(),this._checkFinalizedStatuses(n),this._innerSubscribe(n)}_innerSubscribe(n){let{hasError:r,isStopped:o,observers:i}=this;return r||o?ia:(this.currentObservers=null,i.push(n),new q(()=>{this.currentObservers=null,Mr(i,n)}))}_checkFinalizedStatuses(n){let{hasError:r,thrownError:o,isStopped:i}=this;r?n.error(o):i&&n.complete()}asObservable(){let n=new V;return n.source=this,n}}return e.create=(t,n)=>new qo(t,n),e})(),qo=class extends X{constructor(t,n){super(),this.destination=t,this.source=n}next(t){var n,r;(r=(n=this.destination)===null||n===void 0?void 0:n.next)===null||r===void 0||r.call(n,t)}error(t){var n,r;(r=(n=this.destination)===null||n===void 0?void 0:n.error)===null||r===void 0||r.call(n,t)}complete(){var t,n;(n=(t=this.destination)===null||t===void 0?void 0:t.complete)===null||n===void 0||n.call(t)}_subscribe(t){var n,r;return(r=(n=this.source)===null||n===void 0?void 0:n.subscribe(t))!==null&&r!==void 0?r:ia}};var ie=class extends X{constructor(t){super(),this._value=t}get value(){return this.getValue()}_subscribe(t){let n=super._subscribe(t);return!n.closed&&t.next(this._value),n}getValue(){let{hasError:t,thrownError:n,_value:r}=this;if(t)throw n;return this._throwIfClosed(),r}next(t){super.next(this._value=t)}};var ve=new V(e=>e.complete());function $u(e){return e&&x(e.schedule)}function Gu(e){return e[e.length-1]}function zu(e){return x(Gu(e))?e.pop():void 0}function Mt(e){return $u(Gu(e))?e.pop():void 0}function qu(e,t,n,r){function o(i){return i instanceof n?i:new n(function(s){s(i)})}return new(n||(n=Promise))(function(i,s){function a(u){try{l(r.next(u))}catch(f){s(f)}}function c(u){try{l(r.throw(u))}catch(f){s(f)}}function l(u){u.done?i(u.value):o(u.value).then(a,c)}l((r=r.apply(e,t||[])).next())})}function Wu(e){var t=typeof Symbol=="function"&&Symbol.iterator,n=t&&e[t],r=0;if(n)return n.call(e);if(e&&typeof e.length=="number")return{next:function(){return e&&r>=e.length&&(e=void 0),{value:e&&e[r++],done:!e}}};throw new TypeError(t?"Object is not iterable.":"Symbol.iterator is not defined.")}function Yt(e){return this instanceof Yt?(this.v=e,this):new Yt(e)}function Yu(e,t,n){if(!Symbol.asyncIterator)throw new TypeError("Symbol.asyncIterator is not defined.");var r=n.apply(e,t||[]),o,i=[];return o=Object.create((typeof AsyncIterator=="function"?AsyncIterator:Object).prototype),a("next"),a("throw"),a("return",s),o[Symbol.asyncIterator]=function(){return this},o;function s(h){return function(E){return Promise.resolve(E).then(h,f)}}function a(h,E){r[h]&&(o[h]=function(S){return new Promise(function(O,F){i.push([h,S,O,F])>1||c(h,S)})},E&&(o[h]=E(o[h])))}function c(h,E){try{l(r[h](E))}catch(S){m(i[0][3],S)}}function l(h){h.value instanceof Yt?Promise.resolve(h.value.v).then(u,f):m(i[0][2],h)}function u(h){c("next",h)}function f(h){c("throw",h)}function m(h,E){h(E),i.shift(),i.length&&c(i[0][0],i[0][1])}}function Zu(e){if(!Symbol.asyncIterator)throw new TypeError("Symbol.asyncIterator is not defined.");var t=e[Symbol.asyncIterator],n;return t?t.call(e):(e=typeof Wu=="function"?Wu(e):e[Symbol.iterator](),n={},r("next"),r("throw"),r("return"),n[Symbol.asyncIterator]=function(){return this},n);function r(i){n[i]=e[i]&&function(s){return new Promise(function(a,c){s=e[i](s),o(a,c,s.done,s.value)})}}function o(i,s,a,c){Promise.resolve(c).then(function(l){i({value:l,done:a})},s)}}var Yo=e=>e&&typeof e.length=="number"&&typeof e!="function";function Zo(e){return x(e?.then)}function Ko(e){return x(e[On])}function Qo(e){return Symbol.asyncIterator&&x(e?.[Symbol.asyncIterator])}function Xo(e){return new TypeError(`You provided ${e!==null&&typeof e=="object"?"an invalid object":`'${e}'`} where a stream was expected. You can provide an Observable, Promise, ReadableStream, Array, AsyncIterable, or Iterable.`)}function um(){return typeof Symbol!="function"||!Symbol.iterator?"@@iterator":Symbol.iterator}var Jo=um();function ei(e){return x(e?.[Jo])}function ti(e){return Yu(this,arguments,function*(){let n=e.getReader();try{for(;;){let{value:r,done:o}=yield Yt(n.read());if(o)return yield Yt(void 0);yield yield Yt(r)}}finally{n.releaseLock()}})}function ni(e){return x(e?.getReader)}function J(e){if(e instanceof V)return e;if(e!=null){if(Ko(e))return dm(e);if(Yo(e))return fm(e);if(Zo(e))return pm(e);if(Qo(e))return Ku(e);if(ei(e))return hm(e);if(ni(e))return gm(e)}throw Xo(e)}function dm(e){return new V(t=>{let n=e[On]();if(x(n.subscribe))return n.subscribe(t);throw new TypeError("Provided object does not correctly implement Symbol.observable")})}function fm(e){return new V(t=>{for(let n=0;n{e.then(n=>{t.closed||(t.next(n),t.complete())},n=>t.error(n)).then(null,zo)})}function hm(e){return new V(t=>{for(let n of e)if(t.next(n),t.closed)return;t.complete()})}function Ku(e){return new V(t=>{mm(e,t).catch(n=>t.error(n))})}function gm(e){return Ku(ti(e))}function mm(e,t){var n,r,o,i;return qu(this,void 0,void 0,function*(){try{for(n=Zu(e);r=yield n.next(),!r.done;){let s=r.value;if(t.next(s),t.closed)return}}catch(s){o={error:s}}finally{try{r&&!r.done&&(i=n.return)&&(yield i.call(n))}finally{if(o)throw o.error}}t.complete()})}function ye(e,t,n,r=0,o=!1){let i=t.schedule(function(){n(),o?e.add(this.schedule(null,r)):this.unsubscribe()},r);if(e.add(i),!o)return i}function ri(e,t=0){return j((n,r)=>{n.subscribe(H(r,o=>ye(r,e,()=>r.next(o),t),()=>ye(r,e,()=>r.complete(),t),o=>ye(r,e,()=>r.error(o),t)))})}function oi(e,t=0){return j((n,r)=>{r.add(e.schedule(()=>n.subscribe(r),t))})}function Qu(e,t){return J(e).pipe(oi(t),ri(t))}function Xu(e,t){return J(e).pipe(oi(t),ri(t))}function Ju(e,t){return new V(n=>{let r=0;return t.schedule(function(){r===e.length?n.complete():(n.next(e[r++]),n.closed||this.schedule())})})}function ed(e,t){return new V(n=>{let r;return ye(n,t,()=>{r=e[Jo](),ye(n,t,()=>{let o,i;try{({value:o,done:i}=r.next())}catch(s){n.error(s);return}i?n.complete():n.next(o)},0,!0)}),()=>x(r?.return)&&r.return()})}function ii(e,t){if(!e)throw new Error("Iterable cannot be null");return new V(n=>{ye(n,t,()=>{let r=e[Symbol.asyncIterator]();ye(n,t,()=>{r.next().then(o=>{o.done?n.complete():n.next(o.value)})},0,!0)})})}function td(e,t){return ii(ti(e),t)}function nd(e,t){if(e!=null){if(Ko(e))return Qu(e,t);if(Yo(e))return Ju(e,t);if(Zo(e))return Xu(e,t);if(Qo(e))return ii(e,t);if(ei(e))return ed(e,t);if(ni(e))return td(e,t)}throw Xo(e)}function Y(e,t){return t?nd(e,t):J(e)}function w(...e){let t=Mt(e);return Y(e,t)}function Ln(e,t){let n=x(e)?e:()=>e,r=o=>o.error(n());return new V(t?o=>t.schedule(r,0,o):r)}function ha(e){return!!e&&(e instanceof V||x(e.lift)&&x(e.subscribe))}var ot=xn(e=>function(){e(this),this.name="EmptyError",this.message="no elements in sequence"});function B(e,t){return j((n,r)=>{let o=0;n.subscribe(H(r,i=>{r.next(e.call(t,i,o++))}))})}var{isArray:vm}=Array;function ym(e,t){return vm(t)?e(...t):e(t)}function rd(e){return B(t=>ym(e,t))}var{isArray:Em}=Array,{getPrototypeOf:Dm,prototype:Cm,keys:Im}=Object;function od(e){if(e.length===1){let t=e[0];if(Em(t))return{args:t,keys:null};if(Sm(t)){let n=Im(t);return{args:n.map(r=>t[r]),keys:n}}}return{args:e,keys:null}}function Sm(e){return e&&typeof e=="object"&&Dm(e)===Cm}function id(e,t){return e.reduce((n,r,o)=>(n[r]=t[o],n),{})}function si(...e){let t=Mt(e),n=zu(e),{args:r,keys:o}=od(e);if(r.length===0)return Y([],t);let i=new V(bm(r,t,o?s=>id(o,s):be));return n?i.pipe(rd(n)):i}function bm(e,t,n=be){return r=>{sd(t,()=>{let{length:o}=e,i=new Array(o),s=o,a=o;for(let c=0;c{let l=Y(e[c],t),u=!1;l.subscribe(H(r,f=>{i[c]=f,u||(u=!0,a--),a||r.next(n(i.slice()))},()=>{--s||r.complete()}))},r)},r)}}function sd(e,t,n){e?ye(n,e,t):t()}function ad(e,t,n,r,o,i,s,a){let c=[],l=0,u=0,f=!1,m=()=>{f&&!c.length&&!l&&t.complete()},h=S=>l{i&&t.next(S),l++;let O=!1;J(n(S,u++)).subscribe(H(t,F=>{o?.(F),i?h(F):t.next(F)},()=>{O=!0},void 0,()=>{if(O)try{for(l--;c.length&&lE(F)):E(F)}m()}catch(F){t.error(F)}}))};return e.subscribe(H(t,h,()=>{f=!0,m()})),()=>{a?.()}}function Z(e,t,n=1/0){return x(t)?Z((r,o)=>B((i,s)=>t(r,i,o,s))(J(e(r,o))),n):(typeof t=="number"&&(n=t),j((r,o)=>ad(r,o,e,n)))}function cd(e=1/0){return Z(be,e)}function ld(){return cd(1)}function Fn(...e){return ld()(Y(e,Mt(e)))}function Nr(e){return new V(t=>{J(e()).subscribe(t)})}function Ae(e,t){return j((n,r)=>{let o=0;n.subscribe(H(r,i=>e.call(t,i,o++)&&r.next(i)))})}function xt(e){return j((t,n)=>{let r=null,o=!1,i;r=t.subscribe(H(n,void 0,void 0,s=>{i=J(e(s,xt(e)(t))),r?(r.unsubscribe(),r=null,i.subscribe(n)):o=!0})),o&&(r.unsubscribe(),r=null,i.subscribe(n))})}function ud(e,t,n,r,o){return(i,s)=>{let a=n,c=t,l=0;i.subscribe(H(s,u=>{let f=l++;c=a?e(c,u,f):(a=!0,u),r&&s.next(c)},o&&(()=>{a&&s.next(c),s.complete()})))}}function jn(e,t){return x(t)?Z(e,t,1):Z(e,1)}function Nt(e){return j((t,n)=>{let r=!1;t.subscribe(H(n,o=>{r=!0,n.next(o)},()=>{r||n.next(e),n.complete()}))})}function it(e){return e<=0?()=>ve:j((t,n)=>{let r=0;t.subscribe(H(n,o=>{++r<=e&&(n.next(o),e<=r&&n.complete())}))})}function ai(e=_m){return j((t,n)=>{let r=!1;t.subscribe(H(n,o=>{r=!0,n.next(o)},()=>r?n.complete():n.error(e())))})}function _m(){return new ot}function Ar(e){return j((t,n)=>{try{t.subscribe(n)}finally{n.add(e)}})}function st(e,t){let n=arguments.length>=2;return r=>r.pipe(e?Ae((o,i)=>e(o,i,r)):be,it(1),n?Nt(t):ai(()=>new ot))}function Hn(e){return e<=0?()=>ve:j((t,n)=>{let r=[];t.subscribe(H(n,o=>{r.push(o),e{for(let o of r)n.next(o);n.complete()},void 0,()=>{r=null}))})}function ga(e,t){let n=arguments.length>=2;return r=>r.pipe(e?Ae((o,i)=>e(o,i,r)):be,Hn(1),n?Nt(t):ai(()=>new ot))}function ma(e,t){return j(ud(e,t,arguments.length>=2,!0))}function va(...e){let t=Mt(e);return j((n,r)=>{(t?Fn(e,n,t):Fn(e,n)).subscribe(r)})}function Ee(e,t){return j((n,r)=>{let o=null,i=0,s=!1,a=()=>s&&!o&&r.complete();n.subscribe(H(r,c=>{o?.unsubscribe();let l=0,u=i++;J(e(c,u)).subscribe(o=H(r,f=>r.next(t?t(c,f,u,l++):f),()=>{o=null,a()}))},()=>{s=!0,a()}))})}function ci(e){return j((t,n)=>{J(e).subscribe(H(n,()=>n.complete(),xr)),!n.closed&&t.subscribe(n)})}function ee(e,t,n){let r=x(e)||t||n?{next:e,error:t,complete:n}:e;return r?j((o,i)=>{var s;(s=r.subscribe)===null||s===void 0||s.call(r);let a=!0;o.subscribe(H(i,c=>{var l;(l=r.next)===null||l===void 0||l.call(r,c),i.next(c)},()=>{var c;a=!1,(c=r.complete)===null||c===void 0||c.call(r),i.complete()},c=>{var l;a=!1,(l=r.error)===null||l===void 0||l.call(r,c),i.error(c)},()=>{var c,l;a&&((c=r.unsubscribe)===null||c===void 0||c.call(r)),(l=r.finalize)===null||l===void 0||l.call(r)}))}):be}function dd(e){let t=_(null);try{return e()}finally{_(t)}}var fd=P(y({},$t),{consumerIsAlwaysLive:!0,consumerAllowSignalWrites:!0,dirty:!0,hasRun:!1,kind:"effect"});function pd(e){if(e.dirty=!1,e.hasRun&&!wn(e))return;e.hasRun=!0;let t=Gt(e);try{e.cleanup(),e.fn()}finally{_n(e,t)}}var Ma="https://angular.dev/best-practices/security#preventing-cross-site-scripting-xss",C=class extends Error{code;constructor(t,n){super(pi(t,n)),this.code=t}};function wm(e){return`NG0${Math.abs(e)}`}function pi(e,t){return`${wm(e)}${t?": "+t:""}`}function U(e){for(let t in e)if(e[t]===U)return t;throw Error("")}function ct(e){if(typeof e=="string")return e;if(Array.isArray(e))return`[${e.map(ct).join(", ")}]`;if(e==null)return""+e;let t=e.overriddenName||e.name;if(t)return`${t}`;let n=e.toString();if(n==null)return""+n;let r=n.indexOf(` `);return r>=0?n.slice(0,r):n}function xa(e,t){return e?t?`${e} ${t}`:e:t||""}var Tm=U({__forward_ref__:U});function hi(e){return e.__forward_ref__=hi,e.toString=function(){return ct(this())},e}function De(e){return Na(e)?e():e}function Na(e){return typeof e=="function"&&e.hasOwnProperty(Tm)&&e.__forward_ref__===hi}function I(e){return{token:e.token,providedIn:e.providedIn||null,factory:e.factory,value:void 0}}function Vn(e){return{providers:e.providers||[],imports:e.imports||[]}}function Lr(e){return Mm(e,gi)}function Aa(e){return Lr(e)!==null}function Mm(e,t){return e.hasOwnProperty(t)&&e[t]||null}function xm(e){let t=e?.[gi]??null;return t||null}function Ea(e){return e&&e.hasOwnProperty(ui)?e[ui]:null}var gi=U({\u0275prov:U}),ui=U({\u0275inj:U}),b=class{_desc;ngMetadataName="InjectionToken";\u0275prov;constructor(t,n){this._desc=t,this.\u0275prov=void 0,typeof n=="number"?this.__NG_ELEMENT_ID__=n:n!==void 0&&(this.\u0275prov=I({token:this,providedIn:n.providedIn||"root",factory:n.factory}))}get multi(){return this}toString(){return`InjectionToken ${this._desc}`}};function Ra(e){return e&&!!e.\u0275providers}var Oa=U({\u0275cmp:U}),Pa=U({\u0275dir:U}),ka=U({\u0275pipe:U}),La=U({\u0275mod:U}),Or=U({\u0275fac:U}),Jt=U({__NG_ELEMENT_ID__:U}),hd=U({__NG_ENV_ID__:U});function mi(e){return typeof e=="string"?e:e==null?"":String(e)}function md(e){return typeof e=="function"?e.name||e.toString():typeof e=="object"&&e!=null&&typeof e.type=="function"?e.type.name||e.type.toString():mi(e)}var vd=U({ngErrorCode:U}),Nm=U({ngErrorMessage:U}),Am=U({ngTokenPath:U});function Fa(e,t){return yd("",-200,t)}function vi(e,t){throw new C(-201,!1)}function yd(e,t,n){let r=new C(t,e);return r[vd]=t,r[Nm]=e,n&&(r[Am]=n),r}function Rm(e){return e[vd]}var Da;function Ed(){return Da}function _e(e){let t=Da;return Da=e,t}function ja(e,t,n){let r=Lr(e);if(r&&r.providedIn=="root")return r.value===void 0?r.value=r.factory():r.value;if(n&8)return null;if(t!==void 0)return t;vi(e,"Injector")}var Om={},Zt=Om,Pm="__NG_DI_FLAG__",Ca=class{injector;constructor(t){this.injector=t}retrieve(t,n){let r=Kt(n)||0;try{return this.injector.get(t,r&8?null:Zt,r)}catch(o){if(Sn(o))return o;throw o}}};function km(e,t=0){let n=Oo();if(n===void 0)throw new C(-203,!1);if(n===null)return ja(e,void 0,t);{let r=Lm(t),o=n.retrieve(e,r);if(Sn(o)){if(r.optional)return null;throw o}return o}}function N(e,t=0){return(Ed()||km)(De(e),t)}function v(e,t){return N(e,Kt(t))}function Kt(e){return typeof e>"u"||typeof e=="number"?e:0|(e.optional&&8)|(e.host&&1)|(e.self&&2)|(e.skipSelf&&4)}function Lm(e){return{optional:!!(e&8),host:!!(e&1),self:!!(e&2),skipSelf:!!(e&4)}}function Ia(e){let t=[];for(let n=0;nArray.isArray(n)?yi(n,t):t(n))}function Ha(e,t,n){t>=e.length?e.push(n):e.splice(t,0,n)}function Fr(e,t){return t>=e.length-1?e.pop():e.splice(t,1)[0]}function Id(e,t,n,r){let o=e.length;if(o==t)e.push(n,r);else if(o===1)e.push(r,e[0]),e[0]=n;else{for(o--,e.push(e[o-1],e[o]);o>t;){let i=o-2;e[o]=e[i],o--}e[t]=n,e[t+1]=r}}function Sd(e,t,n){let r=Un(e,t);return r>=0?e[r|1]=n:(r=~r,Id(e,r,t,n)),r}function Ei(e,t){let n=Un(e,t);if(n>=0)return e[n|1]}function Un(e,t){return jm(e,t,1)}function jm(e,t,n){let r=0,o=e.length>>n;for(;o!==r;){let i=r+(o-r>>1),s=e[i<t?o=i:r=i+1}return~(o<{n.push(s)};return yi(t,s=>{let a=s;di(a,i,[],r)&&(o||=[],o.push(a))}),o!==void 0&&Td(o,i),n}function Td(e,t){for(let n=0;n{t(i,r)})}}function di(e,t,n,r){if(e=De(e),!e)return!1;let o=null,i=Ea(e),s=!i&&Rt(e);if(!i&&!s){let c=e.ngModule;if(i=Ea(c),i)o=c;else return!1}else{if(s&&!s.standalone)return!1;o=e}let a=r.has(o);if(s){if(a)return!1;if(r.add(o),s.dependencies){let c=typeof s.dependencies=="function"?s.dependencies():s.dependencies;for(let l of c)di(l,t,n,r)}}else if(i){if(i.imports!=null&&!a){r.add(o);let l;try{yi(i.imports,u=>{di(u,t,n,r)&&(l||=[],l.push(u))})}finally{}l!==void 0&&Td(l,t)}if(!a){let l=Qt(o)||(()=>new o);t({provide:o,useFactory:l,deps:we},o),t({provide:Va,useValue:o,multi:!0},o),t({provide:lt,useValue:()=>N(o),multi:!0},o)}let c=i.providers;if(c!=null&&!a){let l=e;za(c,u=>{t(u,l)})}}else return!1;return o!==e&&e.providers!==void 0}function za(e,t){for(let n of e)Ra(n)&&(n=n.\u0275providers),Array.isArray(n)?za(n,t):t(n)}var Hm=U({provide:String,useValue:U});function Md(e){return e!==null&&typeof e=="object"&&Hm in e}function Bm(e){return!!(e&&e.useExisting)}function Vm(e){return!!(e&&e.useFactory)}function fi(e){return typeof e=="function"}var jr=new b(""),li={},gd={},ya;function Hr(){return ya===void 0&&(ya=new Pr),ya}var K=class{},Xt=class extends K{parent;source;scopes;records=new Map;_ngOnDestroyHooks=new Set;_onDestroyHooks=[];get destroyed(){return this._destroyed}_destroyed=!1;injectorDefTypes;constructor(t,n,r,o){super(),this.parent=n,this.source=r,this.scopes=o,ba(t,s=>this.processProvider(s)),this.records.set(Ba,Bn(void 0,this)),o.has("environment")&&this.records.set(K,Bn(void 0,this));let i=this.records.get(jr);i!=null&&typeof i.value=="string"&&this.scopes.add(i.value),this.injectorDefTypes=new Set(this.get(Va,we,{self:!0}))}retrieve(t,n){let r=Kt(n)||0;try{return this.get(t,Zt,r)}catch(o){if(Sn(o))return o;throw o}}destroy(){Rr(this),this._destroyed=!0;let t=_(null);try{for(let r of this._ngOnDestroyHooks)r.ngOnDestroy();let n=this._onDestroyHooks;this._onDestroyHooks=[];for(let r of n)r()}finally{this.records.clear(),this._ngOnDestroyHooks.clear(),this.injectorDefTypes.clear(),_(t)}}onDestroy(t){return Rr(this),this._onDestroyHooks.push(t),()=>this.removeOnDestroy(t)}runInContext(t){Rr(this);let n=qe(this),r=_e(void 0),o;try{return t()}finally{qe(n),_e(r)}}get(t,n=Zt,r){if(Rr(this),t.hasOwnProperty(hd))return t[hd](this);let o=Kt(r),i,s=qe(this),a=_e(void 0);try{if(!(o&4)){let l=this.records.get(t);if(l===void 0){let u=Wm(t)&&Lr(t);u&&this.injectableDefInScope(u)?l=Bn(Sa(t),li):l=null,this.records.set(t,l)}if(l!=null)return this.hydrate(t,l,o)}let c=o&2?Hr():this.parent;return n=o&8&&n===Zt?null:n,c.get(t,n)}catch(c){let l=Rm(c);throw l===-200||l===-201?new C(l,null):c}finally{_e(a),qe(s)}}resolveInjectorInitializers(){let t=_(null),n=qe(this),r=_e(void 0),o;try{let i=this.get(lt,we,{self:!0});for(let s of i)s()}finally{qe(n),_e(r),_(t)}}toString(){let t=[],n=this.records;for(let r of n.keys())t.push(ct(r));return`R3Injector[${t.join(", ")}]`}processProvider(t){t=De(t);let n=fi(t)?t:De(t&&t.provide),r=$m(t);if(!fi(t)&&t.multi===!0){let o=this.records.get(n);o||(o=Bn(void 0,li,!0),o.factory=()=>Ia(o.multi),this.records.set(n,o)),n=t,o.multi.push(t)}this.records.set(n,r)}hydrate(t,n,r){let o=_(null);try{if(n.value===gd)throw Fa(ct(t));return n.value===li&&(n.value=gd,n.value=n.factory(void 0,r)),typeof n.value=="object"&&n.value&&zm(n.value)&&this._ngOnDestroyHooks.add(n.value),n.value}finally{_(o)}}injectableDefInScope(t){if(!t.providedIn)return!1;let n=De(t.providedIn);return typeof n=="string"?n==="any"||this.scopes.has(n):this.injectorDefTypes.has(n)}removeOnDestroy(t){let n=this._onDestroyHooks.indexOf(t);n!==-1&&this._onDestroyHooks.splice(n,1)}};function Sa(e){let t=Lr(e),n=t!==null?t.factory:Qt(e);if(n!==null)return n;if(e instanceof b)throw new C(204,!1);if(e instanceof Function)return Um(e);throw new C(204,!1)}function Um(e){if(e.length>0)throw new C(204,!1);let n=xm(e);return n!==null?()=>n.factory(e):()=>new e}function $m(e){if(Md(e))return Bn(void 0,e.useValue);{let t=xd(e);return Bn(t,li)}}function xd(e,t,n){let r;if(fi(e)){let o=De(e);return Qt(o)||Sa(o)}else if(Md(e))r=()=>De(e.useValue);else if(Vm(e))r=()=>e.useFactory(...Ia(e.deps||[]));else if(Bm(e))r=(o,i)=>N(De(e.useExisting),i!==void 0&&i&8?8:void 0);else{let o=De(e&&(e.useClass||e.provide));if(Gm(e))r=()=>new o(...Ia(e.deps));else return Qt(o)||Sa(o)}return r}function Rr(e){if(e.destroyed)throw new C(205,!1)}function Bn(e,t,n=!1){return{factory:e,value:t,multi:n?[]:void 0}}function Gm(e){return!!e.deps}function zm(e){return e!==null&&typeof e=="object"&&typeof e.ngOnDestroy=="function"}function Wm(e){return typeof e=="function"||typeof e=="object"&&e.ngMetadataName==="InjectionToken"}function ba(e,t){for(let n of e)Array.isArray(n)?ba(n,t):n&&Ra(n)?ba(n.\u0275providers,t):t(n)}function G(e,t){let n;e instanceof Xt?(Rr(e),n=e):n=new Ca(e);let r,o=qe(n),i=_e(void 0);try{return t()}finally{qe(o),_e(i)}}function Nd(){return Ed()!==void 0||Oo()!=null}var Ve=0,T=1,M=2,te=3,Oe=4,Pe=5,Br=6,$n=7,se=8,Gn=9,ut=10,ne=11,zn=12,Wa=13,nn=14,ke=15,Ot=16,rn=17,Ze=18,Vr=19,qa=20,at=21,Di=22,dt=23,Te=24,Ci=25,he=26,ae=27,Ad=1;var Pt=7,Ur=8,on=9,fe=10;function ft(e){return Array.isArray(e)&&typeof e[Ad]=="object"}function Ue(e){return Array.isArray(e)&&e[Ad]===!0}function Ya(e){return(e.flags&4)!==0}function sn(e){return e.componentOffset>-1}function Ii(e){return(e.flags&1)===1}function an(e){return!!e.template}function Wn(e){return(e[M]&512)!==0}function cn(e){return(e[M]&256)===256}var Rd="svg",Od="math";function Le(e){for(;Array.isArray(e);)e=e[Ve];return e}function Za(e,t){return Le(t[e])}function Ke(e,t){return Le(t[e.index])}function $r(e,t){return e.data[t]}function Pd(e,t){return e[t]}function Qe(e,t){let n=t[e];return ft(n)?n:n[Ve]}function Si(e){return(e[M]&128)===128}function kd(e){return Ue(e[te])}function kt(e,t){return t==null?null:e[t]}function Ka(e){e[rn]=0}function Qa(e){e[M]&1024||(e[M]|=1024,Si(e)&&ln(e))}function Ld(e,t){for(;e>0;)t=t[nn],e--;return t}function Gr(e){return!!(e[M]&9216||e[Te]?.dirty)}function bi(e){e[ut].changeDetectionScheduler?.notify(8),e[M]&64&&(e[M]|=1024),Gr(e)&&ln(e)}function ln(e){e[ut].changeDetectionScheduler?.notify(0);let t=At(e);for(;t!==null&&!(t[M]&8192||(t[M]|=8192,!Si(t)));)t=At(t)}function Xa(e,t){if(cn(e))throw new C(911,!1);e[at]===null&&(e[at]=[]),e[at].push(t)}function Fd(e,t){if(e[at]===null)return;let n=e[at].indexOf(t);n!==-1&&e[at].splice(n,1)}function At(e){let t=e[te];return Ue(t)?t[te]:t}function Ja(e){return e[$n]??=[]}function ec(e){return e.cleanup??=[]}function jd(e,t,n,r){let o=Ja(t);o.push(n),e.firstCreatePass&&ec(e).push(r,o.length-1)}var R={lFrame:ef(null),bindingsEnabled:!0,skipHydrationRootTNode:null};var _a=!1;function Hd(){return R.lFrame.elementDepthCount}function Bd(){R.lFrame.elementDepthCount++}function tc(){R.lFrame.elementDepthCount--}function Vd(){return R.bindingsEnabled}function Ud(){return R.skipHydrationRootTNode!==null}function nc(e){return R.skipHydrationRootTNode===e}function rc(){R.skipHydrationRootTNode=null}function W(){return R.lFrame.lView}function Xe(){return R.lFrame.tView}function pt(e){return R.lFrame.contextLView=e,e[se]}function ht(e){return R.lFrame.contextLView=null,e}function Me(){let e=oc();for(;e!==null&&e.type===64;)e=e.parent;return e}function oc(){return R.lFrame.currentTNode}function $d(){let e=R.lFrame,t=e.currentTNode;return e.isParent?t:t.parent}function qn(e,t){let n=R.lFrame;n.currentTNode=e,n.isParent=t}function ic(){return R.lFrame.isParent}function Gd(){R.lFrame.isParent=!1}function zd(){return R.lFrame.contextLView}function sc(){return _a}function Yn(e){let t=_a;return _a=e,t}function Wd(e){return R.lFrame.bindingIndex=e}function _i(){return R.lFrame.bindingIndex++}function qd(e){let t=R.lFrame,n=t.bindingIndex;return t.bindingIndex=t.bindingIndex+e,n}function Yd(){return R.lFrame.inI18n}function Zd(e,t){let n=R.lFrame;n.bindingIndex=n.bindingRootIndex=e,wi(t)}function Kd(){return R.lFrame.currentDirectiveIndex}function wi(e){R.lFrame.currentDirectiveIndex=e}function Qd(e){let t=R.lFrame.currentDirectiveIndex;return t===-1?null:e[t]}function Xd(){return R.lFrame.currentQueryIndex}function Ti(e){R.lFrame.currentQueryIndex=e}function qm(e){let t=e[T];return t.type===2?t.declTNode:t.type===1?e[Pe]:null}function ac(e,t,n){if(n&4){let o=t,i=e;for(;o=o.parent,o===null&&!(n&1);)if(o=qm(i),o===null||(i=i[nn],o.type&10))break;if(o===null)return!1;t=o,e=i}let r=R.lFrame=Jd();return r.currentTNode=t,r.lView=e,!0}function Mi(e){let t=Jd(),n=e[T];R.lFrame=t,t.currentTNode=n.firstChild,t.lView=e,t.tView=n,t.contextLView=e,t.bindingIndex=n.bindingStartIndex,t.inI18n=!1}function Jd(){let e=R.lFrame,t=e===null?null:e.child;return t===null?ef(e):t}function ef(e){let t={currentTNode:null,isParent:!0,lView:null,tView:null,selectedIndex:-1,contextLView:null,elementDepthCount:0,currentNamespace:null,currentDirectiveIndex:-1,bindingRootIndex:-1,bindingIndex:-1,currentQueryIndex:0,parent:e,child:null,inI18n:!1};return e!==null&&(e.child=t),t}function tf(){let e=R.lFrame;return R.lFrame=e.parent,e.currentTNode=null,e.lView=null,e}var cc=tf;function xi(){let e=tf();e.isParent=!0,e.tView=null,e.selectedIndex=-1,e.contextLView=null,e.elementDepthCount=0,e.currentDirectiveIndex=-1,e.currentNamespace=null,e.bindingRootIndex=-1,e.bindingIndex=-1,e.currentQueryIndex=0}function nf(e){return(R.lFrame.contextLView=Ld(e,R.lFrame.contextLView))[se]}function un(){return R.lFrame.selectedIndex}function Lt(e){R.lFrame.selectedIndex=e}function rf(){let e=R.lFrame;return $r(e.tView,e.selectedIndex)}function of(){return R.lFrame.currentNamespace}var sf=!0;function Ni(){return sf}function Ai(e){sf=e}function wa(e,t=null,n=null,r){let o=lc(e,t,n,r);return o.resolveInjectorInitializers(),o}function lc(e,t=null,n=null,r,o=new Set){let i=[n||we,wd(e)];return r=r||(typeof e=="object"?void 0:ct(e)),new Xt(i,t||Hr(),r||null,o)}var Re=class e{static THROW_IF_NOT_FOUND=Zt;static NULL=new Pr;static create(t,n){if(Array.isArray(t))return wa({name:""},n,t,"");{let r=t.name??"";return wa({name:r},t.parent,t.providers,r)}}static \u0275prov=I({token:e,providedIn:"any",factory:()=>N(Ba)});static __NG_ELEMENT_ID__=-1},re=new b(""),gt=(()=>{class e{static __NG_ELEMENT_ID__=Ym;static __NG_ENV_ID__=n=>n}return e})(),kr=class extends gt{_lView;constructor(t){super(),this._lView=t}get destroyed(){return cn(this._lView)}onDestroy(t){let n=this._lView;return Xa(n,t),()=>Fd(n,t)}};function Ym(){return new kr(W())}var Be=class{_console=console;handleError(t){this._console.error("ERROR",t)}},Fe=new b("",{providedIn:"root",factory:()=>{let e=v(K),t;return n=>{e.destroyed&&!t?setTimeout(()=>{throw n}):(t??=e.get(Be),t.handleError(n))}}}),af={provide:lt,useValue:()=>void v(Be),multi:!0},Zm=new b("",{providedIn:"root",factory:()=>{let e=v(re).defaultView;if(!e)return;let t=v(Fe),n=i=>{t(i.reason),i.preventDefault()},r=i=>{i.error?t(i.error):t(new Error(i.message,{cause:i})),i.preventDefault()},o=()=>{e.addEventListener("unhandledrejection",n),e.addEventListener("error",r)};typeof Zone<"u"?Zone.root.run(o):o(),v(gt).onDestroy(()=>{e.removeEventListener("error",r),e.removeEventListener("unhandledrejection",n)})}});function uc(){return tn([_d(()=>void v(Zm))])}function k(e,t){let[n,r,o]=ra(e,t?.equal),i=n,s=i[oe];return i.set=r,i.update=o,i.asReadonly=dc.bind(i),i}function dc(){let e=this[oe];if(e.readonlyFn===void 0){let t=()=>this();t[oe]=e,e.readonlyFn=t}return e.readonlyFn}var Ye=class{},zr=new b("",{providedIn:"root",factory:()=>!1});var fc=new b(""),Ri=new b("");var Wr=(()=>{class e{view;node;constructor(n,r){this.view=n,this.node=r}static __NG_ELEMENT_ID__=Km}return e})();function Km(){return new Wr(W(),Me())}var mt=(()=>{class e{taskId=0;pendingTasks=new Set;destroyed=!1;pendingTask=new ie(!1);get hasPendingTasks(){return this.destroyed?!1:this.pendingTask.value}get hasPendingTasksObservable(){return this.destroyed?new V(n=>{n.next(!1),n.complete()}):this.pendingTask}add(){!this.hasPendingTasks&&!this.destroyed&&this.pendingTask.next(!0);let n=this.taskId++;return this.pendingTasks.add(n),n}has(n){return this.pendingTasks.has(n)}remove(n){this.pendingTasks.delete(n),this.pendingTasks.size===0&&this.hasPendingTasks&&this.pendingTask.next(!1)}ngOnDestroy(){this.pendingTasks.clear(),this.hasPendingTasks&&this.pendingTask.next(!1),this.destroyed=!0,this.pendingTask.unsubscribe()}static \u0275prov=I({token:e,providedIn:"root",factory:()=>new e})}return e})();function dn(...e){}var qr=(()=>{class e{static \u0275prov=I({token:e,providedIn:"root",factory:()=>new Ta})}return e})(),Ta=class{dirtyEffectCount=0;queues=new Map;add(t){this.enqueue(t),this.schedule(t)}schedule(t){t.dirty&&this.dirtyEffectCount++}remove(t){let n=t.zone,r=this.queues.get(n);r.has(t)&&(r.delete(t),t.dirty&&this.dirtyEffectCount--)}enqueue(t){let n=t.zone;this.queues.has(n)||this.queues.set(n,new Set);let r=this.queues.get(n);r.has(t)||r.add(t)}flush(){for(;this.dirtyEffectCount>0;){let t=!1;for(let[n,r]of this.queues)n===null?t||=this.flushQueue(r):t||=n.run(()=>this.flushQueue(r));t||(this.dirtyEffectCount=0)}}flushQueue(t){let n=!1;for(let r of t)r.dirty&&(this.dirtyEffectCount--,n=!0,r.run());return n}};function to(e){return{toString:e}.toString()}function sv(e){return typeof e=="function"}var ji=class{previousValue;currentValue;firstChange;constructor(t,n,r){this.previousValue=t,this.currentValue=n,this.firstChange=r}isFirstChange(){return this.firstChange}};function jf(e,t,n,r){t!==null?t.applyValueToInputSignal(t,r):e[n]=r}var Ji=(()=>{let e=()=>Hf;return e.ngInherit=!0,e})();function Hf(e){return e.type.prototype.ngOnChanges&&(e.setInput=cv),av}function av(){let e=Vf(this),t=e?.current;if(t){let n=e.previous;if(n===en)e.previous=t;else for(let r in t)n[r]=t[r];e.current=null,this.ngOnChanges(t)}}function cv(e,t,n,r,o){let i=this.declaredInputs[r],s=Vf(e)||lv(e,{previous:en,current:null}),a=s.current||(s.current={}),c=s.previous,l=c[i];a[i]=new ji(l&&l.currentValue,n,c===en),jf(e,t,o,n)}var Bf="__ngSimpleChanges__";function Vf(e){return e[Bf]||null}function lv(e,t){return e[Bf]=t}var cf=[];var z=function(e,t=null,n){for(let r=0;r=r)break}else t[c]<0&&(e[rn]+=65536),(a>14>16&&(e[M]&3)===t&&(e[M]+=16384,lf(a,i)):lf(a,i)}var Kn=-1,Kr=class{factory;name;injectImpl;resolving=!1;canSeeViewProviders;multi;componentProviders;index;providerFactory;constructor(t,n,r,o){this.factory=t,this.name=o,this.canSeeViewProviders=n,this.injectImpl=r}};function pv(e){return(e.flags&8)!==0}function hv(e){return(e.flags&16)!==0}function gv(e,t,n){let r=0;for(;rt){s=i-1;break}}}for(;i>16}function Bi(e,t){let n=yv(e),r=t;for(;n>0;)r=r[nn],n--;return r}var Ec=!0;function df(e){let t=Ec;return Ec=e,t}var Ev=256,Gf=Ev-1,zf=5,Dv=0,Je={};function Cv(e,t,n){let r;typeof n=="string"?r=n.charCodeAt(0)||0:n.hasOwnProperty(Jt)&&(r=n[Jt]),r==null&&(r=n[Jt]=Dv++);let o=r&Gf,i=1<>zf)]|=i}function Wf(e,t){let n=qf(e,t);if(n!==-1)return n;let r=t[T];r.firstCreatePass&&(e.injectorIndex=t.length,hc(r.data,e),hc(t,null),hc(r.blueprint,null));let o=Uc(e,t),i=e.injectorIndex;if($f(o)){let s=Hi(o),a=Bi(o,t),c=a[T].data;for(let l=0;l<8;l++)t[i+l]=a[s+l]|c[s+l]}return t[i+8]=o,i}function hc(e,t){e.push(0,0,0,0,0,0,0,0,t)}function qf(e,t){return e.injectorIndex===-1||e.parent&&e.parent.injectorIndex===e.injectorIndex||t[e.injectorIndex+8]===null?-1:e.injectorIndex}function Uc(e,t){if(e.parent&&e.parent.injectorIndex!==-1)return e.parent.injectorIndex;let n=0,r=null,o=t;for(;o!==null;){if(r=Xf(o),r===null)return Kn;if(n++,o=o[nn],r.injectorIndex!==-1)return r.injectorIndex|n<<16}return Kn}function Iv(e,t,n){Cv(e,t,n)}function Yf(e,t,n){if(n&8||e!==void 0)return e;vi(t,"NodeInjector")}function Zf(e,t,n,r){if(n&8&&r===void 0&&(r=null),(n&3)===0){let o=e[Gn],i=_e(void 0);try{return o?o.get(t,r,n&8):ja(t,r,n&8)}finally{_e(i)}}return Yf(r,t,n)}function Kf(e,t,n,r=0,o){if(e!==null){if(t[M]&2048&&!(r&2)){let s=wv(e,t,n,r,Je);if(s!==Je)return s}let i=Qf(e,t,n,r,Je);if(i!==Je)return i}return Zf(t,n,r,o)}function Qf(e,t,n,r,o){let i=bv(n);if(typeof i=="function"){if(!ac(t,e,r))return r&1?Yf(o,n,r):Zf(t,n,r,o);try{let s;if(s=i(r),s==null&&!(r&8))vi(n);else return s}finally{cc()}}else if(typeof i=="number"){let s=null,a=qf(e,t),c=Kn,l=r&1?t[ke][Pe]:null;for((a===-1||r&4)&&(c=a===-1?Uc(e,t):t[a+8],c===Kn||!pf(r,!1)?a=-1:(s=t[T],a=Hi(c),t=Bi(c,t)));a!==-1;){let u=t[T];if(ff(i,a,u.data)){let f=Sv(a,t,n,s,r,l);if(f!==Je)return f}c=t[a+8],c!==Kn&&pf(r,t[T].data[a+8]===l)&&ff(i,a,t)?(s=u,a=Hi(c),t=Bi(c,t)):a=-1}}return o}function Sv(e,t,n,r,o,i){let s=t[T],a=s.data[e+8],c=r==null?sn(a)&&Ec:r!=s&&(a.type&3)!==0,l=o&1&&i===a,u=Li(a,s,n,c,l);return u!==null?Vi(t,s,u,a,o):Je}function Li(e,t,n,r,o){let i=e.providerIndexes,s=t.data,a=i&1048575,c=e.directiveStart,l=e.directiveEnd,u=i>>20,f=r?a:a+u,m=o?a+u:l;for(let h=f;h=c&&E.type===n)return h}if(o){let h=s[c];if(h&&an(h)&&h.type===n)return c}return null}function Vi(e,t,n,r,o){let i=e[n],s=t.data;if(i instanceof Kr){let a=i;if(a.resolving){let h=md(s[n]);throw Fa(h)}let c=df(a.canSeeViewProviders);a.resolving=!0;let l=s[n].type||s[n],u,f=a.injectImpl?_e(a.injectImpl):null,m=ac(e,r,0);try{i=e[n]=a.factory(void 0,o,s,e,r),t.firstCreatePass&&n>=r.directiveStart&&uv(n,s[n],t)}finally{f!==null&&_e(f),df(c),a.resolving=!1,cc()}}return i}function bv(e){if(typeof e=="string")return e.charCodeAt(0)||0;let t=e.hasOwnProperty(Jt)?e[Jt]:void 0;return typeof t=="number"?t>=0?t&Gf:_v:t}function ff(e,t,n){let r=1<>zf)]&r)}function pf(e,t){return!(e&2)&&!(e&1&&t)}var fn=class{_tNode;_lView;constructor(t,n){this._tNode=t,this._lView=n}get(t,n,r){return Kf(this._tNode,this._lView,t,Kt(r),n)}};function _v(){return new fn(Me(),W())}function ts(e){return to(()=>{let t=e.prototype.constructor,n=t[Or]||Dc(t),r=Object.prototype,o=Object.getPrototypeOf(e.prototype).constructor;for(;o&&o!==r;){let i=o[Or]||Dc(o);if(i&&i!==n)return i;o=Object.getPrototypeOf(o)}return i=>new i})}function Dc(e){return Na(e)?()=>{let t=Dc(De(e));return t&&t()}:Qt(e)}function wv(e,t,n,r,o){let i=e,s=t;for(;i!==null&&s!==null&&s[M]&2048&&!Wn(s);){let a=Qf(i,s,n,r|2,Je);if(a!==Je)return a;let c=i.parent;if(!c){let l=s[qa];if(l){let u=l.get(n,Je,r);if(u!==Je)return u}c=Xf(s),s=s[nn]}i=c}return o}function Xf(e){let t=e[T],n=t.type;return n===2?t.declTNode:n===1?e[Pe]:null}function Tv(){return nr(Me(),W())}function nr(e,t){return new rr(Ke(e,t))}var rr=(()=>{class e{nativeElement;constructor(n){this.nativeElement=n}static __NG_ELEMENT_ID__=Tv}return e})();function Mv(e){return e instanceof rr?e.nativeElement:e}function xv(){return this._results[Symbol.iterator]()}var Ui=class{_emitDistinctChangesOnly;dirty=!0;_onDirty=void 0;_results=[];_changesDetected=!1;_changes=void 0;length=0;first=void 0;last=void 0;get changes(){return this._changes??=new X}constructor(t=!1){this._emitDistinctChangesOnly=t}get(t){return this._results[t]}map(t){return this._results.map(t)}filter(t){return this._results.filter(t)}find(t){return this._results.find(t)}reduce(t,n){return this._results.reduce(t,n)}forEach(t){this._results.forEach(t)}some(t){return this._results.some(t)}toArray(){return this._results.slice()}toString(){return this._results.toString()}reset(t,n){this.dirty=!1;let r=Cd(t);(this._changesDetected=!Dd(this._results,r,n))&&(this._results=r,this.length=r.length,this.last=r[this.length-1],this.first=r[0])}notifyOnChanges(){this._changes!==void 0&&(this._changesDetected||!this._emitDistinctChangesOnly)&&this._changes.next(this)}onDirty(t){this._onDirty=t}setDirty(){this.dirty=!0,this._onDirty?.()}destroy(){this._changes!==void 0&&(this._changes.complete(),this._changes.unsubscribe())}[Symbol.iterator]=xv};function Jf(e){return(e.flags&128)===128}var $c=(function(e){return e[e.OnPush=0]="OnPush",e[e.Default=1]="Default",e})($c||{}),ep=new Map,Nv=0;function Av(){return Nv++}function Rv(e){ep.set(e[Vr],e)}function Cc(e){ep.delete(e[Vr])}var hf="__ngContext__";function Qn(e,t){ft(t)?(e[hf]=t[Vr],Rv(t)):e[hf]=t}function tp(e){return rp(e[zn])}function np(e){return rp(e[Oe])}function rp(e){for(;e!==null&&!Ue(e);)e=e[Oe];return e}var Ic;function Gc(e){Ic=e}function op(){if(Ic!==void 0)return Ic;if(typeof document<"u")return document;throw new C(210,!1)}var ns=new b("",{providedIn:"root",factory:()=>Ov}),Ov="ng",rs=new b(""),or=new b("",{providedIn:"platform",factory:()=>"unknown"});var os=new b("",{providedIn:"root",factory:()=>op().body?.querySelector("[ngCspNonce]")?.getAttribute("ngCspNonce")||null});var Pv="h",kv="b";var ip=!1,sp=new b("",{providedIn:"root",factory:()=>ip});var Lv=(e,t,n,r)=>{};function Fv(e,t,n,r){Lv(e,t,n,r)}function zc(e){return(e.flags&32)===32}var jv=()=>null;function ap(e,t,n=!1){return jv(e,t,n)}function cp(e,t){let n=e.contentQueries;if(n!==null){let r=_(null);try{for(let o=0;o-1){let i;for(;++oi?f="":f=o[u+1].toLowerCase(),r&2&&l!==f){if($e(r))return!1;s=!0}}}}return $e(r)||s}function $e(e){return(e&1)===0}function $v(e,t,n,r){if(t===null)return-1;let o=0;if(r||!n){let i=!1;for(;o-1)for(n++;n0?'="'+a+'"':"")+"]"}else r&8?o+="."+s:r&4&&(o+=" "+s);else o!==""&&!$e(s)&&(t+=gf(i,o),o=""),r=s,i=i||!$e(r);n++}return o!==""&&(t+=gf(i,o)),t}function Yv(e){return e.map(qv).join(",")}function Zv(e){let t=[],n=[],r=1,o=2;for(;r{Xv(t,c,a)}):e===3&&vf(i,()=>{t.destroyNode(c)}),s!=null&&vy(t,e,s,n,o)}}function iy(e,t){Ep(e,t),t[Ve]=null,t[Pe]=null}function sy(e,t,n,r,o,i){r[Ve]=o,r[Pe]=t,as(e,r,n,1,o,i)}function Ep(e,t){t[ut].changeDetectionScheduler?.notify(9),as(e,t,t[ne],2,null,null)}function ay(e){let t=e[zn];if(!t)return gc(e[T],e);for(;t;){let n=null;if(ft(t))n=t[zn];else{let r=t[fe];r&&(n=r)}if(!n){for(;t&&!t[Oe]&&t!==e;)ft(t)&&gc(t[T],t),t=t[te];t===null&&(t=e),ft(t)&&gc(t[T],t),n=t&&t[Oe]}t=n}}function Qc(e,t){let n=e[on],r=n.indexOf(t);n.splice(r,1)}function Xc(e,t){if(cn(t))return;let n=t[ne];n.destroyNode&&as(e,t,n,3,null,null),ay(t)}function gc(e,t){if(cn(t))return;let n=_(null);try{t[M]&=-129,t[M]|=256,t[Te]&&zt(t[Te]),uy(e,t),ly(e,t),t[T].type===1&&t[ne].destroy();let r=t[Ot];if(r!==null&&Ue(t[te])){r!==t[te]&&Qc(r,t);let o=t[Ze];o!==null&&o.detachView(e)}Cc(t)}finally{_(n)}}function vf(e,t){if(e&&e[he]&&e[he].leave)if(e[he].skipLeaveAnimations)e[he].skipLeaveAnimations=!1;else{let n=e[he].leave,r=[];for(let o=0;o{e[he]&&e[he].running&&(e[he].running=void 0),ss.delete(e),t()});return}t()}function ly(e,t){let n=e.cleanup,r=t[$n];if(n!==null)for(let s=0;s=0?r[a]():r[-a].unsubscribe(),s+=2}else{let a=r[n[s+1]];n[s].call(a)}r!==null&&(t[$n]=null);let o=t[at];if(o!==null){t[at]=null;for(let s=0;sae&&yp(e,t,ae,!1),z(s?2:0,o,n),n(r,o)}finally{Lt(i),z(s?3:1,o,n)}}function Ip(e,t,n){Iy(e,t,n),(n.flags&64)===64&&Sy(e,t,n)}function tl(e,t,n=Ke){let r=t.localNames;if(r!==null){let o=t.index+1;for(let i=0;inull;function Iy(e,t,n){let r=n.directiveStart,o=n.directiveEnd;sn(n)&&ry(t,n,e.data[r+n.componentOffset]),e.firstCreatePass||Wf(n,t);let i=n.initialInputs;for(let s=r;s{ln(e.lView)},consumerOnSignalRead(){this.lView[Te]=this}});function Fy(e){let t=e[Te]??Object.create(jy);return t.lView=e,t}var jy=P(y({},$t),{consumerIsAlwaysLive:!0,kind:"template",consumerMarkedDirty:e=>{let t=At(e.lView);for(;t&&!Np(t[T]);)t=At(t);t&&Qa(t)},consumerOnSignalRead(){this.lView[Te]=this}});function Np(e){return e.type!==2}function Ap(e){if(e[dt]===null)return;let t=!0;for(;t;){let n=!1;for(let r of e[dt])r.dirty&&(n=!0,r.zone===null||Zone.current===r.zone?r.run():r.zone.run(()=>r.run()));t=n&&!!(e[M]&8192)}}var Hy=100;function Rp(e,t=0){let r=e[ut].rendererFactory,o=!1;o||r.begin?.();try{By(e,t)}finally{o||r.end?.()}}function By(e,t){let n=sc();try{Yn(!0),Mc(e,t);let r=0;for(;Gr(e);){if(r===Hy)throw new C(103,!1);r++,Mc(e,1)}}finally{Yn(n)}}function Vy(e,t,n,r){if(cn(t))return;let o=t[M],i=!1,s=!1;Mi(t);let a=!0,c=null,l=null;i||(Np(e)?(l=Oy(t),c=Gt(l)):Ho()===null?(a=!1,l=Fy(t),c=Gt(l)):t[Te]&&(zt(t[Te]),t[Te]=null));try{Ka(t),Wd(e.bindingStartIndex),n!==null&&Cp(e,t,n,2,r),Uy(t);let u=(o&3)===3;if(!i)if(u){let h=e.preOrderCheckHooks;h!==null&&Pi(t,h,null)}else{let h=e.preOrderHooks;h!==null&&ki(t,h,0,null),pc(t,0)}if(s||$y(t),Ap(t),Op(t,0),e.contentQueries!==null&&cp(e,t),!i)if(u){let h=e.contentCheckHooks;h!==null&&Pi(t,h)}else{let h=e.contentHooks;h!==null&&ki(t,h,1),pc(t,1)}zy(e,t);let f=e.components;f!==null&&kp(t,f,0);let m=e.viewQuery;if(m!==null&&Sc(2,m,r),!i)if(u){let h=e.viewCheckHooks;h!==null&&Pi(t,h)}else{let h=e.viewHooks;h!==null&&ki(t,h,2),pc(t,2)}if(e.firstUpdatePass===!0&&(e.firstUpdatePass=!1),t[Di]){for(let h of t[Di])h();t[Di]=null}i||(Mp(t),t[M]&=-73)}catch(u){throw i||ln(t),u}finally{l!==null&&(_n(l,c),a&&ky(l)),xi()}}function Uy(e){let t=e[he];if(t?.enter){for(let n of t.enter)n();t.enter=void 0}}function Op(e,t){for(let n=tp(e);n!==null;n=np(n))for(let r=fe;r0&&(e[n-1][Oe]=r[Oe]);let i=Fr(e,fe+t);iy(r[T],r);let s=i[Ze];s!==null&&s.detachView(i[T]),r[te]=null,r[Oe]=null,r[M]&=-129}return r}function Yy(e,t,n,r){let o=fe+r,i=n.length;r>0&&(n[o-1][Oe]=t),r-1&&(Gi(t,r),Fr(n,r))}this._attachedToViewContainer=!1}Xc(this._lView[T],this._lView)}onDestroy(t){Xa(this._lView,t)}markForCheck(){rl(this._cdRefInjectingView||this._lView,4)}detach(){this._lView[M]&=-129}reattach(){bi(this._lView),this._lView[M]|=128}detectChanges(){this._lView[M]|=1024,Rp(this._lView)}checkNoChanges(){}attachToViewContainerRef(){if(this._appRef)throw new C(902,!1);this._attachedToViewContainer=!0}detachFromAppRef(){this._appRef=null;let t=Wn(this._lView),n=this._lView[Ot];n!==null&&!t&&Qc(n,this._lView),Ep(this._lView[T],this._lView)}attachToAppRef(t){if(this._attachedToViewContainer)throw new C(902,!1);this._appRef=t;let n=Wn(this._lView),r=this._lView[Ot];r!==null&&!n&&jp(r,this._lView),bi(this._lView)}};var Xn=(()=>{class e{_declarationLView;_declarationTContainer;elementRef;static __NG_ELEMENT_ID__=Zy;constructor(n,r,o){this._declarationLView=n,this._declarationTContainer=r,this.elementRef=o}get ssrId(){return this._declarationTContainer.tView?.ssrId||null}createEmbeddedView(n,r){return this.createEmbeddedViewImpl(n,r)}createEmbeddedViewImpl(n,r,o){let i=wp(this._declarationLView,this._declarationTContainer,n,{embeddedViewInjector:r,dehydratedView:o});return new Ft(i)}}return e})();function Zy(){return ol(Me(),W())}function ol(e,t){return e.type&4?new Xn(t,e,nr(e,t)):null}function cs(e,t,n,r,o){let i=e.data[t];if(i===null)i=Ky(e,t,n,r,o),Yd()&&(i.flags|=32);else if(i.type&64){i.type=n,i.value=r,i.attrs=o;let s=$d();i.injectorIndex=s===null?-1:s.injectorIndex}return qn(i,!0),i}function Ky(e,t,n,r,o){let i=oc(),s=ic(),a=s?i:i&&i.parent,c=e.data[t]=Xy(e,a,n,t,r,o);return Qy(e,c,i,s),c}function Qy(e,t,n,r){e.firstChild===null&&(e.firstChild=t),n!==null&&(r?n.child==null&&t.parent!==null&&(n.child=t):n.next===null&&(n.next=t,t.prev=n))}function Xy(e,t,n,r,o,i){let s=t?t.injectorIndex:-1,a=0;return Ud()&&(a|=128),{type:n,index:r,insertBeforeIndex:null,injectorIndex:s,directiveStart:-1,directiveEnd:-1,directiveStylingLast:-1,componentOffset:-1,propertyBindings:null,flags:a,providerIndexes:0,value:o,attrs:i,mergedAttrs:null,localNames:null,initialInputs:null,inputs:null,hostDirectiveInputs:null,outputs:null,hostDirectiveOutputs:null,directiveToIndex:null,tView:null,next:null,prev:null,projectionNext:null,child:null,parent:t,projection:null,styles:null,stylesWithoutHost:null,residualStyles:void 0,classes:null,classesWithoutHost:null,residualClasses:void 0,classBindings:0,styleBindings:0}}var pN=new RegExp(`^(\\d+)*(${kv}|${Pv})*(.*)`);var Jy=()=>null,eE=()=>null;function Ef(e,t){return Jy(e,t)}function tE(e,t,n){return eE(e,t,n)}var Hp=class{},ls=class{},xc=class{resolveComponentFactory(t){throw new C(917,!1)}},no=class{static NULL=new xc},pn=class{};var Bp=(()=>{class e{static \u0275prov=I({token:e,providedIn:"root",factory:()=>null})}return e})();var Fi={},Nc=class{injector;parentInjector;constructor(t,n){this.injector=t,this.parentInjector=n}get(t,n,r){let o=this.injector.get(t,Fi,r);return o!==Fi||n===Fi?o:this.parentInjector.get(t,n,r)}};function zi(e,t,n){let r=n?e.styles:null,o=n?e.classes:null,i=0;if(t!==null)for(let s=0;s0&&(n.directiveToIndex=new Map);for(let m=0;m0;){let n=e[--t];if(typeof n=="number"&&n<0)return n}return 0}function uE(e,t,n){if(n){if(t.exportAs)for(let r=0;rr(Le(S[e.index])):e.index;vE(E,t,n,i,a,h,!1)}}return l}function gE(e){return e.startsWith("animation")||e.startsWith("transition")}function mE(e,t,n,r){let o=e.cleanup;if(o!=null)for(let i=0;ic?a[c]:null}typeof s=="string"&&(i+=2)}return null}function vE(e,t,n,r,o,i,s){let a=t.firstCreatePass?ec(t):null,c=Ja(n),l=c.length;c.push(o,i),a&&a.push(r,e,l,(l+1)*(s?-1:1))}var Ac=Symbol("BINDING");var Wi=class extends no{ngModule;constructor(t){super(),this.ngModule=t}resolveComponentFactory(t){let n=Rt(t);return new Jn(n,this.ngModule)}};function yE(e){return Object.keys(e).map(t=>{let[n,r,o]=e[t],i={propName:n,templateName:t,isSignal:(r&is.SignalBased)!==0};return o&&(i.transform=o),i})}function EE(e){return Object.keys(e).map(t=>({propName:e[t],templateName:t}))}function DE(e,t,n){let r=t instanceof K?t:t?.injector;return r&&e.getStandaloneInjector!==null&&(r=e.getStandaloneInjector(r)||r),r?new Nc(n,r):n}function CE(e){let t=e.get(pn,null);if(t===null)throw new C(407,!1);let n=e.get(Bp,null),r=e.get(Ye,null);return{rendererFactory:t,sanitizer:n,changeDetectionScheduler:r,ngReflect:!1}}function IE(e,t){let n=Gp(e);return pp(t,n,n==="svg"?Rd:n==="math"?Od:null)}function Gp(e){return(e.selectors[0][0]||"div").toLowerCase()}var Jn=class extends ls{componentDef;ngModule;selector;componentType;ngContentSelectors;isBoundToModule;cachedInputs=null;cachedOutputs=null;get inputs(){return this.cachedInputs??=yE(this.componentDef.inputs),this.cachedInputs}get outputs(){return this.cachedOutputs??=EE(this.componentDef.outputs),this.cachedOutputs}constructor(t,n){super(),this.componentDef=t,this.ngModule=n,this.componentType=t.type,this.selector=Yv(t.selectors),this.ngContentSelectors=t.ngContentSelectors??[],this.isBoundToModule=!!n}create(t,n,r,o,i,s){z(22);let a=_(null);try{let c=this.componentDef,l=SE(r,c,s,i),u=DE(c,o||this.ngModule,t),f=CE(u),m=f.rendererFactory.createRenderer(null,c),h=r?Ey(m,r,c.encapsulation,u):IE(c,m),E=s?.some(bf)||i?.some(F=>typeof F!="function"&&F.bindings.some(bf)),S=Yc(null,l,null,512|mp(c),null,null,f,m,u,null,ap(h,u,!0));S[ae]=h,Mi(S);let O=null;try{let F=Up(ae,S,2,"#host",()=>l.directiveRegistry,!0,0);h&&(gp(m,h,F),Qn(h,S)),Ip(l,S,F),lp(l,F,S),$p(l,F),n!==void 0&&_E(F,this.ngContentSelectors,n),O=Qe(F.index,S),S[se]=O[se],nl(l,S,null)}catch(F){throw O!==null&&Cc(O),Cc(S),F}finally{z(23),xi()}return new qi(this.componentType,S,!!E)}finally{_(a)}}};function SE(e,t,n,r){let o=e?["ng-version","20.3.1"]:Zv(t.selectors[0]),i=null,s=null,a=0;if(n)for(let u of n)a+=u[Ac].requiredVars,u.create&&(u.targetIdx=0,(i??=[]).push(u)),u.update&&(u.targetIdx=0,(s??=[]).push(u));if(r)for(let u=0;u{if(n&1&&e)for(let r of e)r.create();if(n&2&&t)for(let r of t)r.update()}}function bf(e){let t=e[Ac].kind;return t==="input"||t==="twoWay"}var qi=class extends Hp{_rootLView;_hasInputBindings;instance;hostView;changeDetectorRef;componentType;location;previousInputValues=null;_tNode;constructor(t,n,r){super(),this._rootLView=n,this._hasInputBindings=r,this._tNode=$r(n[T],ae),this.location=nr(this._tNode,n),this.instance=Qe(this._tNode.index,n)[se],this.hostView=this.changeDetectorRef=new Ft(n,void 0),this.componentType=t}setInput(t,n){this._hasInputBindings;let r=this._tNode;if(this.previousInputValues??=new Map,this.previousInputValues.has(t)&&Object.is(this.previousInputValues.get(t),n))return;let o=this._rootLView,i=_p(r,o[T],o,t,n);this.previousInputValues.set(t,n);let s=Qe(r.index,o);rl(s,1)}get injector(){return new fn(this._tNode,this._rootLView)}destroy(){this.hostView.destroy()}onDestroy(t){this.hostView.onDestroy(t)}};function _E(e,t,n){let r=e.projection=[];for(let o=0;o{class e{static __NG_ELEMENT_ID__=wE}return e})();function wE(){let e=Me();return Wp(e,W())}var TE=gn,zp=class extends TE{_lContainer;_hostTNode;_hostLView;constructor(t,n,r){super(),this._lContainer=t,this._hostTNode=n,this._hostLView=r}get element(){return nr(this._hostTNode,this._hostLView)}get injector(){return new fn(this._hostTNode,this._hostLView)}get parentInjector(){let t=Uc(this._hostTNode,this._hostLView);if($f(t)){let n=Bi(t,this._hostLView),r=Hi(t),o=n[T].data[r+8];return new fn(o,n)}else return new fn(null,this._hostLView)}clear(){for(;this.length>0;)this.remove(this.length-1)}get(t){let n=_f(this._lContainer);return n!==null&&n[t]||null}get length(){return this._lContainer.length-fe}createEmbeddedView(t,n,r){let o,i;typeof r=="number"?o=r:r!=null&&(o=r.index,i=r.injector);let s=Ef(this._lContainer,t.ssrId),a=t.createEmbeddedViewImpl(n||{},i,s);return this.insertImpl(a,o,Tc(this._hostTNode,s)),a}createComponent(t,n,r,o,i,s,a){let c=t&&!sv(t),l;if(c)l=n;else{let O=n||{};l=O.index,r=O.injector,o=O.projectableNodes,i=O.environmentInjector||O.ngModuleRef,s=O.directives,a=O.bindings}let u=c?t:new Jn(Rt(t)),f=r||this.parentInjector;if(!i&&u.ngModule==null){let F=(c?f:this.parentInjector).get(K,null);F&&(i=F)}let m=Rt(u.componentType??{}),h=Ef(this._lContainer,m?.id??null),E=h?.firstChild??null,S=u.create(f,o,E,i,s,a);return this.insertImpl(S.hostView,l,Tc(this._hostTNode,h)),S}insert(t,n){return this.insertImpl(t,n,!0)}insertImpl(t,n,r){let o=t._lView;if(kd(o)){let a=this.indexOf(t);if(a!==-1)this.detach(a);else{let c=o[te],l=new zp(c,c[Pe],c[te]);l.detach(l.indexOf(t))}}let i=this._adjustIndex(n),s=this._lContainer;return Fp(s,o,i,r),t.attachToViewContainerRef(),Ha(mc(s),i,t),t}move(t,n){return this.insert(t,n)}indexOf(t){let n=_f(this._lContainer);return n!==null?n.indexOf(t):-1}remove(t){let n=this._adjustIndex(t,-1),r=Gi(this._lContainer,n);r&&(Fr(mc(this._lContainer),n),Xc(r[T],r))}detach(t){let n=this._adjustIndex(t,-1),r=Gi(this._lContainer,n);return r&&Fr(mc(this._lContainer),n)!=null?new Ft(r):null}_adjustIndex(t,n=0){return t??this.length+n}};function _f(e){return e[Ur]}function mc(e){return e[Ur]||(e[Ur]=[])}function Wp(e,t){let n,r=t[e.index];return Ue(r)?n=r:(n=Lp(r,t,null,e),t[e.index]=n,Zc(t,n)),xE(n,t,e,r),new zp(n,e,t)}function ME(e,t){let n=e[ne],r=n.createComment(""),o=Ke(t,e),i=n.parentNode(o);return $i(n,i,r,n.nextSibling(o),!1),r}var xE=RE,NE=()=>!1;function AE(e,t,n){return NE(e,t,n)}function RE(e,t,n,r){if(e[Pt])return;let o;n.type&8?o=Le(r):o=ME(t,n),e[Pt]=o}var Rc=class e{queryList;matches=null;constructor(t){this.queryList=t}clone(){return new e(this.queryList)}setDirty(){this.queryList.setDirty()}},Oc=class e{queries;constructor(t=[]){this.queries=t}createEmbeddedView(t){let n=t.queries;if(n!==null){let r=t.contentQueries!==null?t.contentQueries[0]:n.length,o=[];for(let i=0;i0)r.push(s[a/2]);else{let l=i[a+1],u=t[-c];for(let f=fe;ft.trim())}function BE(e,t,n){e.queries===null&&(e.queries=new kc),e.queries.track(new Lc(t,n))}function Zp(e,t){return e.queries.getByIndex(t)}function VE(e,t){let n=e[T],r=Zp(n,t);return r.crossesNgTemplate?Fc(n,e,t,[]):qp(n,e,r,t)}function sl(e,t,n){let r,o=Tr(()=>{r._dirtyCounter();let i=$E(r,e);if(t&&i===void 0)throw new C(-951,!1);return i});return r=o[oe],r._dirtyCounter=k(0),r._flatValue=void 0,o}function Kp(e){return sl(!0,!1,e)}function Qp(e){return sl(!0,!0,e)}function Xp(e){return sl(!1,!1,e)}function UE(e,t){let n=e[oe];n._lView=W(),n._queryIndex=t,n._queryList=Yp(n._lView,t),n._queryList.onDirty(()=>n._dirtyCounter.update(r=>r+1))}function $E(e,t){let n=e._lView,r=e._queryIndex;if(n===void 0||r===void 0||n[M]&4)return t?void 0:we;let o=Yp(n,r),i=VE(n,r);return o.reset(i,Mv),t?o.first:o._changesDetected||e._flatValue===void 0?e._flatValue=o.toArray():e._flatValue}var wf=new Set;function mn(e){wf.has(e)||(wf.add(e),performance?.mark?.("mark_feature_usage",{detail:{feature:e}}))}var er=class{},ds=class{};var Yi=class extends er{ngModuleType;_parent;_bootstrapComponents=[];_r3Injector;instance;destroyCbs=[];componentFactoryResolver=new Wi(this);constructor(t,n,r,o=!0){super(),this.ngModuleType=t,this._parent=n;let i=Ua(t);this._bootstrapComponents=dp(i.bootstrap),this._r3Injector=lc(t,n,[{provide:er,useValue:this},{provide:no,useValue:this.componentFactoryResolver},...r],ct(t),new Set(["environment"])),o&&this.resolveInjectorInitializers()}resolveInjectorInitializers(){this._r3Injector.resolveInjectorInitializers(),this.instance=this._r3Injector.get(this.ngModuleType)}get injector(){return this._r3Injector}destroy(){let t=this._r3Injector;!t.destroyed&&t.destroy(),this.destroyCbs.forEach(n=>n()),this.destroyCbs=null}onDestroy(t){this.destroyCbs.push(t)}},Zi=class extends ds{moduleType;constructor(t){super(),this.moduleType=t}create(t){return new Yi(this.moduleType,t,[])}};var Xr=class extends er{injector;componentFactoryResolver=new Wi(this);instance=null;constructor(t){super();let n=new Xt([...t.providers,{provide:er,useValue:this},{provide:no,useValue:this.componentFactoryResolver}],t.parent||Hr(),t.debugName,new Set(["environment"]));this.injector=n,t.runEnvironmentInitializers&&n.resolveInjectorInitializers()}destroy(){this.injector.destroy()}onDestroy(t){this.injector.onDestroy(t)}};function ro(e,t,n=null){return new Xr({providers:e,parent:t,debugName:n,runEnvironmentInitializers:!0}).injector}var GE=(()=>{class e{_injector;cachedInjectors=new Map;constructor(n){this._injector=n}getOrCreateStandaloneInjector(n){if(!n.standalone)return null;if(!this.cachedInjectors.has(n)){let r=Ga(!1,n.type),o=r.length>0?ro([r],this._injector,`Standalone[${n.type.name}]`):null;this.cachedInjectors.set(n,o)}return this.cachedInjectors.get(n)}ngOnDestroy(){try{for(let n of this.cachedInjectors.values())n!==null&&n.destroy()}finally{this.cachedInjectors.clear()}}static \u0275prov=I({token:e,providedIn:"environment",factory:()=>new e(N(K))})}return e})();function vn(e){return to(()=>{let t=Jp(e),n=P(y({},t),{decls:e.decls,vars:e.vars,template:e.template,consts:e.consts||null,ngContentSelectors:e.ngContentSelectors,onPush:e.changeDetection===$c.OnPush,directiveDefs:null,pipeDefs:null,dependencies:t.standalone&&e.dependencies||null,getStandaloneInjector:t.standalone?o=>o.get(GE).getOrCreateStandaloneInjector(n):null,getExternalStyles:null,signals:e.signals??!1,data:e.data||{},encapsulation:e.encapsulation||vt.Emulated,styles:e.styles||we,_:null,schemas:e.schemas||null,tView:null,id:""});t.standalone&&mn("NgStandalone"),eh(n);let r=e.dependencies;return n.directiveDefs=Tf(r,zE),n.pipeDefs=Tf(r,bd),n.id=YE(n),n})}function zE(e){return Rt(e)||$a(e)}function oo(e){return to(()=>({type:e.type,bootstrap:e.bootstrap||we,declarations:e.declarations||we,imports:e.imports||we,exports:e.exports||we,transitiveCompileScopes:null,schemas:e.schemas||null,id:e.id||null}))}function WE(e,t){if(e==null)return en;let n={};for(let r in e)if(e.hasOwnProperty(r)){let o=e[r],i,s,a,c;Array.isArray(o)?(a=o[0],i=o[1],s=o[2]??i,c=o[3]||null):(i=o,s=o,a=is.None,c=null),n[i]=[r,a,c],t[i]=s}return n}function qE(e){if(e==null)return en;let t={};for(let n in e)e.hasOwnProperty(n)&&(t[e[n]]=n);return t}function fs(e){return to(()=>{let t=Jp(e);return eh(t),t})}function Jp(e){let t={};return{type:e.type,providersResolver:null,factory:null,hostBindings:e.hostBindings||null,hostVars:e.hostVars||0,hostAttrs:e.hostAttrs||null,contentQueries:e.contentQueries||null,declaredInputs:t,inputConfig:e.inputs||en,exportAs:e.exportAs||null,standalone:e.standalone??!0,signals:e.signals===!0,selectors:e.selectors||we,viewQuery:e.viewQuery||null,features:e.features||null,setInput:null,resolveHostDirectives:null,hostDirectives:null,inputs:WE(e.inputs,t),outputs:qE(e.outputs),debugInfo:null}}function eh(e){e.features?.forEach(t=>t(e))}function Tf(e,t){return e?()=>{let n=typeof e=="function"?e():e,r=[];for(let o of n){let i=t(o);i!==null&&r.push(i)}return r}:null}function YE(e){let t=0,n=typeof e.consts=="function"?"":e.consts,r=[e.selectors,e.ngContentSelectors,e.hostVars,e.hostAttrs,n,e.vars,e.decls,e.encapsulation,e.standalone,e.signals,e.exportAs,JSON.stringify(e.inputs),JSON.stringify(e.outputs),Object.getOwnPropertyNames(e.type.prototype),!!e.contentQueries,!!e.viewQuery];for(let i of r.join("|"))t=Math.imul(31,t)+i.charCodeAt(0)<<0;return t+=2147483648,"c"+t}function ZE(e,t,n,r,o,i,s,a){if(n.firstCreatePass){e.mergedAttrs=es(e.mergedAttrs,e.attrs);let u=e.tView=qc(2,e,o,i,s,n.directiveRegistry,n.pipeRegistry,null,n.schemas,n.consts,null);n.queries!==null&&(n.queries.template(n,e),u.queries=n.queries.embeddedTView(e))}a&&(e.flags|=a),qn(e,!1);let c=KE(n,t,e,r);Ni()&&Jc(n,t,c,e),Qn(c,t);let l=Lp(c,t,c,e);t[r+ae]=l,Zc(t,l),AE(l,e,t)}function th(e,t,n,r,o,i,s,a,c,l,u){let f=n+ae,m;if(t.firstCreatePass){if(m=cs(t,f,4,s||null,a||null),l!=null){let h=kt(t.consts,l);m.localNames=[];for(let E=0;Enull),s=r;if(t&&typeof t=="object"){let c=t;o=c.next?.bind(c),i=c.error?.bind(c),s=c.complete?.bind(c)}this.__isAsync&&(i=this.wrapInTimeout(i),o&&(o=this.wrapInTimeout(o)),s&&(s=this.wrapInTimeout(s)));let a=super.subscribe({next:o,error:i,complete:s});return t instanceof q&&t.add(a),a}wrapInTimeout(t){return n=>{let r=this.pendingTasks?.add();setTimeout(()=>{try{t(n)}finally{r!==void 0&&this.pendingTasks?.remove(r)}})}}},ge=jc;function rh(e){let t,n;function r(){e=dn;try{n!==void 0&&typeof cancelAnimationFrame=="function"&&cancelAnimationFrame(n),t!==void 0&&clearTimeout(t)}catch{}}return t=setTimeout(()=>{e(),r()}),typeof requestAnimationFrame=="function"&&(n=requestAnimationFrame(()=>{e(),r()})),()=>r()}function Mf(e){return queueMicrotask(()=>e()),()=>{e=dn}}var cl="isAngularZone",Ki=cl+"_ID",XE=0,Q=class e{hasPendingMacrotasks=!1;hasPendingMicrotasks=!1;isStable=!0;onUnstable=new ge(!1);onMicrotaskEmpty=new ge(!1);onStable=new ge(!1);onError=new ge(!1);constructor(t){let{enableLongStackTrace:n=!1,shouldCoalesceEventChangeDetection:r=!1,shouldCoalesceRunChangeDetection:o=!1,scheduleInRootZone:i=nh}=t;if(typeof Zone>"u")throw new C(908,!1);Zone.assertZonePatched();let s=this;s._nesting=0,s._outer=s._inner=Zone.current,Zone.TaskTrackingZoneSpec&&(s._inner=s._inner.fork(new Zone.TaskTrackingZoneSpec)),n&&Zone.longStackTraceZoneSpec&&(s._inner=s._inner.fork(Zone.longStackTraceZoneSpec)),s.shouldCoalesceEventChangeDetection=!o&&r,s.shouldCoalesceRunChangeDetection=o,s.callbackScheduled=!1,s.scheduleInRootZone=i,tD(s)}static isInAngularZone(){return typeof Zone<"u"&&Zone.current.get(cl)===!0}static assertInAngularZone(){if(!e.isInAngularZone())throw new C(909,!1)}static assertNotInAngularZone(){if(e.isInAngularZone())throw new C(909,!1)}run(t,n,r){return this._inner.run(t,n,r)}runTask(t,n,r,o){let i=this._inner,s=i.scheduleEventTask("NgZoneEvent: "+o,t,JE,dn,dn);try{return i.runTask(s,n,r)}finally{i.cancelTask(s)}}runGuarded(t,n,r){return this._inner.runGuarded(t,n,r)}runOutsideAngular(t){return this._outer.run(t)}},JE={};function ll(e){if(e._nesting==0&&!e.hasPendingMicrotasks&&!e.isStable)try{e._nesting++,e.onMicrotaskEmpty.emit(null)}finally{if(e._nesting--,!e.hasPendingMicrotasks)try{e.runOutsideAngular(()=>e.onStable.emit(null))}finally{e.isStable=!0}}}function eD(e){if(e.isCheckStableRunning||e.callbackScheduled)return;e.callbackScheduled=!0;function t(){rh(()=>{e.callbackScheduled=!1,Hc(e),e.isCheckStableRunning=!0,ll(e),e.isCheckStableRunning=!1})}e.scheduleInRootZone?Zone.root.run(()=>{t()}):e._outer.run(()=>{t()}),Hc(e)}function tD(e){let t=()=>{eD(e)},n=XE++;e._inner=e._inner.fork({name:"angular",properties:{[cl]:!0,[Ki]:n,[Ki+n]:!0},onInvokeTask:(r,o,i,s,a,c)=>{if(nD(c))return r.invokeTask(i,s,a,c);try{return xf(e),r.invokeTask(i,s,a,c)}finally{(e.shouldCoalesceEventChangeDetection&&s.type==="eventTask"||e.shouldCoalesceRunChangeDetection)&&t(),Nf(e)}},onInvoke:(r,o,i,s,a,c,l)=>{try{return xf(e),r.invoke(i,s,a,c,l)}finally{e.shouldCoalesceRunChangeDetection&&!e.callbackScheduled&&!rD(c)&&t(),Nf(e)}},onHasTask:(r,o,i,s)=>{r.hasTask(i,s),o===i&&(s.change=="microTask"?(e._hasPendingMicrotasks=s.microTask,Hc(e),ll(e)):s.change=="macroTask"&&(e.hasPendingMacrotasks=s.macroTask))},onHandleError:(r,o,i,s)=>(r.handleError(i,s),e.runOutsideAngular(()=>e.onError.emit(s)),!1)})}function Hc(e){e._hasPendingMicrotasks||(e.shouldCoalesceEventChangeDetection||e.shouldCoalesceRunChangeDetection)&&e.callbackScheduled===!0?e.hasPendingMicrotasks=!0:e.hasPendingMicrotasks=!1}function xf(e){e._nesting++,e.isStable&&(e.isStable=!1,e.onUnstable.emit(null))}function Nf(e){e._nesting--,ll(e)}var Jr=class{hasPendingMicrotasks=!1;hasPendingMacrotasks=!1;isStable=!0;onUnstable=new ge;onMicrotaskEmpty=new ge;onStable=new ge;onError=new ge;run(t,n,r){return t.apply(n,r)}runGuarded(t,n,r){return t.apply(n,r)}runOutsideAngular(t){return t()}runTask(t,n,r,o){return t.apply(n,r)}};function nD(e){return oh(e,"__ignore_ng_zone__")}function rD(e){return oh(e,"__scheduler_tick__")}function oh(e,t){return!Array.isArray(e)||e.length!==1?!1:e[0]?.data?.[t]===!0}var ih=(()=>{class e{impl=null;execute(){this.impl?.execute()}static \u0275prov=I({token:e,providedIn:"root",factory:()=>new e})}return e})();var ul=(()=>{class e{log(n){console.log(n)}warn(n){console.warn(n)}static \u0275fac=function(r){return new(r||e)};static \u0275prov=I({token:e,factory:e.\u0275fac,providedIn:"platform"})}return e})();var dl=new b("");function so(e){return!!e&&typeof e.then=="function"}function sh(e){return!!e&&typeof e.subscribe=="function"}var ah=new b("");var fl=(()=>{class e{resolve;reject;initialized=!1;done=!1;donePromise=new Promise((n,r)=>{this.resolve=n,this.reject=r});appInits=v(ah,{optional:!0})??[];injector=v(Re);constructor(){}runInitializers(){if(this.initialized)return;let n=[];for(let o of this.appInits){let i=G(this.injector,o);if(so(i))n.push(i);else if(sh(i)){let s=new Promise((a,c)=>{i.subscribe({complete:a,error:c})});n.push(s)}}let r=()=>{this.done=!0,this.resolve()};Promise.all(n).then(()=>{r()}).catch(o=>{this.reject(o)}),n.length===0&&r(),this.initialized=!0}static \u0275fac=function(r){return new(r||e)};static \u0275prov=I({token:e,factory:e.\u0275fac,providedIn:"root"})}return e})(),ps=new b("");function ch(){na(()=>{let e="";throw new C(600,e)})}function lh(e){return e.isBoundToModule}var oD=10;var yn=(()=>{class e{_runningTick=!1;_destroyed=!1;_destroyListeners=[];_views=[];internalErrorHandler=v(Fe);afterRenderManager=v(ih);zonelessEnabled=v(zr);rootEffectScheduler=v(qr);dirtyFlags=0;tracingSnapshot=null;allTestViews=new Set;autoDetectTestViews=new Set;includeAllTestViews=!1;afterTick=new X;get allViews(){return[...(this.includeAllTestViews?this.allTestViews:this.autoDetectTestViews).keys(),...this._views]}get destroyed(){return this._destroyed}componentTypes=[];components=[];internalPendingTask=v(mt);get isStable(){return this.internalPendingTask.hasPendingTasksObservable.pipe(B(n=>!n))}constructor(){v(io,{optional:!0})}whenStable(){let n;return new Promise(r=>{n=this.isStable.subscribe({next:o=>{o&&r()}})}).finally(()=>{n.unsubscribe()})}_injector=v(K);_rendererFactory=null;get injector(){return this._injector}bootstrap(n,r){return this.bootstrapImpl(n,r)}bootstrapImpl(n,r,o=Re.NULL){return this._injector.get(Q).run(()=>{z(10);let s=n instanceof ls;if(!this._injector.get(fl).done){let E="";throw new C(405,E)}let c;s?c=n:c=this._injector.get(no).resolveComponentFactory(n),this.componentTypes.push(c.componentType);let l=lh(c)?void 0:this._injector.get(er),u=r||c.selector,f=c.create(o,[],u,l),m=f.location.nativeElement,h=f.injector.get(dl,null);return h?.registerApplication(m),f.onDestroy(()=>{this.detachView(f.hostView),Zr(this.components,f),h?.unregisterApplication(m)}),this._loadComponent(f),z(11,f),f})}tick(){this.zonelessEnabled||(this.dirtyFlags|=1),this._tick()}_tick(){z(12),this.tracingSnapshot!==null?this.tracingSnapshot.run(al.CHANGE_DETECTION,this.tickImpl):this.tickImpl()}tickImpl=()=>{if(this._runningTick)throw new C(101,!1);let n=_(null);try{this._runningTick=!0,this.synchronize()}finally{this._runningTick=!1,this.tracingSnapshot?.dispose(),this.tracingSnapshot=null,_(n),this.afterTick.next(),z(13)}};synchronize(){this._rendererFactory===null&&!this._injector.destroyed&&(this._rendererFactory=this._injector.get(pn,null,{optional:!0}));let n=0;for(;this.dirtyFlags!==0&&n++Gr(n))){this.dirtyFlags|=2;return}else this.dirtyFlags&=-8}attachView(n){let r=n;this._views.push(r),r.attachToAppRef(this)}detachView(n){let r=n;Zr(this._views,r),r.detachFromAppRef()}_loadComponent(n){this.attachView(n.hostView);try{this.tick()}catch(o){this.internalErrorHandler(o)}this.components.push(n),this._injector.get(ps,[]).forEach(o=>o(n))}ngOnDestroy(){if(!this._destroyed)try{this._destroyListeners.forEach(n=>n()),this._views.slice().forEach(n=>n.destroy())}finally{this._destroyed=!0,this._views=[],this._destroyListeners=[]}}onDestroy(n){return this._destroyListeners.push(n),()=>Zr(this._destroyListeners,n)}destroy(){if(this._destroyed)throw new C(406,!1);let n=this._injector;n.destroy&&!n.destroyed&&n.destroy()}get viewCount(){return this._views.length}static \u0275fac=function(r){return new(r||e)};static \u0275prov=I({token:e,factory:e.\u0275fac,providedIn:"root"})}return e})();function Zr(e,t){let n=e.indexOf(t);n>-1&&e.splice(n,1)}function hs(e,t,n,r){let o=W(),i=_i();if(us(o,i,t)){let s=Xe(),a=rf();wy(a,o,e,t,n,r)}return hs}var DN=typeof document<"u"&&typeof document?.documentElement?.getAnimations=="function";function ir(e,t,n,r,o,i,s,a){mn("NgControlFlow");let c=W(),l=Xe(),u=kt(l.consts,i);return th(c,l,e,t,n,r,o,u,256,s,a),pl}function pl(e,t,n,r,o,i,s,a){mn("NgControlFlow");let c=W(),l=Xe(),u=kt(l.consts,i);return th(c,l,e,t,n,r,o,u,512,s,a),pl}function sr(e,t){mn("NgControlFlow");let n=W(),r=_i(),o=n[r]!==Et?n[r]:-1,i=o!==-1?Af(n,ae+o):void 0,s=0;if(us(n,r,e)){let a=_(null);try{if(i!==void 0&&qy(i,s),e!==-1){let c=ae+e,l=Af(n,c),u=iD(n[T],c),f=tE(l,u,n),m=wp(n,u,t,{dehydratedView:f});Fp(l,m,s,Tc(u,f))}}finally{_(a)}}else if(i!==void 0){let a=Wy(i,s);a!==void 0&&(a[se]=t)}}function Af(e,t){return e[t]}function iD(e,t){return $r(e,t)}function Rf(e,t,n,r,o){_p(t,e,n,o?"class":"style",r)}function hl(e,t,n,r){let o=W(),i=o[T],s=e+ae,a=i.firstCreatePass?Up(s,o,2,t,_y,Vd(),n,r):i.data[s];if(Sp(a,o,e,t,uh),Ii(a)){let c=o[T];Ip(c,o,a),lp(c,a,o)}return r!=null&&tl(o,a),hl}function gl(){let e=Xe(),t=Me(),n=bp(t);return e.firstCreatePass&&$p(e,n),nc(n)&&rc(),tc(),n.classesWithoutHost!=null&&pv(n)&&Rf(e,n,W(),n.classesWithoutHost,!0),n.stylesWithoutHost!=null&&hv(n)&&Rf(e,n,W(),n.stylesWithoutHost,!1),gl}function ar(e,t,n,r){return hl(e,t,n,r),gl(),ar}function g(e,t,n,r){let o=W(),i=o[T],s=e+ae,a=i.firstCreatePass?fE(s,i,2,t,n,r):i.data[s];return Sp(a,o,e,t,uh),r!=null&&tl(o,a),g}function p(){let e=Me(),t=bp(e);return nc(t)&&rc(),tc(),p}function L(e,t,n,r){return g(e,t,n,r),p(),L}var uh=(e,t,n,r,o)=>(Ai(!0),pp(t[ne],r,of()));function ml(){return W()}var ao="en-US";var sD=ao;function dh(e){typeof e=="string"&&(sD=e.toLowerCase().replace(/_/g,"-"))}function Ct(e,t,n){let r=W(),o=Xe(),i=Me();return(i.type&3||n)&&hE(i,o,r,n,r[ne],e,t,pE(i,r,t)),Ct}function Ge(e=1){return nf(e)}function ce(e,t,n,r){UE(e,jE(t,n,r))}function vl(e=1){Ti(Xd()+e)}function gs(e){let t=zd();return Pd(t,ae+e)}function Oi(e,t){return e<<17|t<<2}function hn(e){return e>>17&32767}function aD(e){return(e&2)==2}function cD(e,t){return e&131071|t<<17}function Bc(e){return e|2}function tr(e){return(e&131068)>>2}function vc(e,t){return e&-131069|t<<2}function lD(e){return(e&1)===1}function Vc(e){return e|1}function uD(e,t,n,r,o,i){let s=i?t.classBindings:t.styleBindings,a=hn(s),c=tr(s);e[r]=n;let l=!1,u;if(Array.isArray(n)){let f=n;u=f[1],(u===null||Un(f,u)>0)&&(l=!0)}else u=n;if(o)if(c!==0){let m=hn(e[a+1]);e[r+1]=Oi(m,a),m!==0&&(e[m+1]=vc(e[m+1],r)),e[a+1]=cD(e[a+1],r)}else e[r+1]=Oi(a,0),a!==0&&(e[a+1]=vc(e[a+1],r)),a=r;else e[r+1]=Oi(c,0),a===0?a=r:e[c+1]=vc(e[c+1],r),c=r;l&&(e[r+1]=Bc(e[r+1])),Of(e,u,r,!0),Of(e,u,r,!1),dD(t,u,e,r,i),s=Oi(a,c),i?t.classBindings=s:t.styleBindings=s}function dD(e,t,n,r,o){let i=o?e.residualClasses:e.residualStyles;i!=null&&typeof t=="string"&&Un(i,t)>=0&&(n[r+1]=Vc(n[r+1]))}function Of(e,t,n,r){let o=e[n+1],i=t===null,s=r?hn(o):tr(o),a=!1;for(;s!==0&&(a===!1||i);){let c=e[s],l=e[s+1];fD(c,t)&&(a=!0,e[s+1]=r?Vc(l):Bc(l)),s=r?hn(l):tr(l)}a&&(e[n+1]=r?Bc(o):Vc(o))}function fD(e,t){return e===null||t==null||(Array.isArray(e)?e[1]:e)===t?!0:Array.isArray(e)&&typeof t=="string"?Un(e,t)>=0:!1}function co(e,t){return pD(e,t,null,!0),co}function pD(e,t,n,r){let o=W(),i=Xe(),s=qd(2);if(i.firstUpdatePass&&gD(i,e,s,r),t!==Et&&us(o,s,t)){let a=i.data[un()];DD(i,a,o,o[ne],e,o[s+1]=CD(t,n),r,s)}}function hD(e,t){return t>=e.expandoStartIndex}function gD(e,t,n,r){let o=e.data;if(o[n+1]===null){let i=o[un()],s=hD(e,n);ID(i,r)&&t===null&&!s&&(t=!1),t=mD(o,i,t,r),uD(o,i,t,n,s,r)}}function mD(e,t,n,r){let o=Qd(e),i=r?t.residualClasses:t.residualStyles;if(o===null)(r?t.classBindings:t.styleBindings)===0&&(n=yc(null,e,t,n,r),n=eo(n,t.attrs,r),i=null);else{let s=t.directiveStylingLast;if(s===-1||e[s]!==o)if(n=yc(o,e,t,n,r),i===null){let c=vD(e,t,r);c!==void 0&&Array.isArray(c)&&(c=yc(null,e,t,c[1],r),c=eo(c,t.attrs,r),yD(e,t,r,c))}else i=ED(e,t,r)}return i!==void 0&&(r?t.residualClasses=i:t.residualStyles=i),n}function vD(e,t,n){let r=n?t.classBindings:t.styleBindings;if(tr(r)!==0)return e[hn(r)]}function yD(e,t,n,r){let o=n?t.classBindings:t.styleBindings;e[hn(o)]=r}function ED(e,t,n){let r,o=t.directiveEnd;for(let i=1+t.directiveStylingLast;i0;){let c=e[o],l=Array.isArray(c),u=l?c[1]:c,f=u===null,m=n[o+1];m===Et&&(m=f?we:void 0);let h=f?Ei(m,r):u===r?m:void 0;if(l&&!Qi(h)&&(h=Ei(c,r)),Qi(h)&&(a=h,s))return a;let E=e[o+1];o=s?hn(E):tr(E)}if(t!==null){let c=i?t.residualClasses:t.residualStyles;c!=null&&(a=Ei(c,r))}return a}function Qi(e){return e!==void 0}function CD(e,t){return e==null||e===""||(typeof t=="string"?e=e+t:typeof e=="object"&&(e=ct(up(e)))),e}function ID(e,t){return(e.flags&(t?8:16))!==0}function d(e,t=""){let n=W(),r=Xe(),o=e+ae,i=r.firstCreatePass?cs(r,o,1,t,null):r.data[o],s=SD(r,n,i,t,e);n[o]=s,Ni()&&Jc(r,n,s,i),qn(i,!1)}var SD=(e,t,n,r,o)=>(Ai(!0),Kv(t[ne],r));function bD(e,t,n,r=""){return us(e,_i(),n)?t+mi(n)+r:Et}function cr(e){return yl("",e),cr}function yl(e,t,n){let r=W(),o=bD(r,e,t,n);return o!==Et&&_D(r,un(),o),yl}function _D(e,t,n){let r=Za(t,e);Qv(e[ne],r,n)}var Xi=class{ngModuleFactory;componentFactories;constructor(t,n){this.ngModuleFactory=t,this.componentFactories=n}},El=(()=>{class e{compileModuleSync(n){return new Zi(n)}compileModuleAsync(n){return Promise.resolve(this.compileModuleSync(n))}compileModuleAndAllComponentsSync(n){let r=this.compileModuleSync(n),o=Ua(n),i=dp(o.declarations).reduce((s,a)=>{let c=Rt(a);return c&&s.push(new Jn(c)),s},[]);return new Xi(r,i)}compileModuleAndAllComponentsAsync(n){return Promise.resolve(this.compileModuleAndAllComponentsSync(n))}clearCache(){}clearCacheFor(n){}getModuleId(n){}static \u0275fac=function(r){return new(r||e)};static \u0275prov=I({token:e,factory:e.\u0275fac,providedIn:"root"})}return e})();var wD=(()=>{class e{zone=v(Q);changeDetectionScheduler=v(Ye);applicationRef=v(yn);applicationErrorHandler=v(Fe);_onMicrotaskEmptySubscription;initialize(){this._onMicrotaskEmptySubscription||(this._onMicrotaskEmptySubscription=this.zone.onMicrotaskEmpty.subscribe({next:()=>{this.changeDetectionScheduler.runningTick||this.zone.run(()=>{try{this.applicationRef.dirtyFlags|=1,this.applicationRef._tick()}catch(n){this.applicationErrorHandler(n)}})}}))}ngOnDestroy(){this._onMicrotaskEmptySubscription?.unsubscribe()}static \u0275fac=function(r){return new(r||e)};static \u0275prov=I({token:e,factory:e.\u0275fac,providedIn:"root"})}return e})();function fh({ngZoneFactory:e,ignoreChangesOutsideZone:t,scheduleInRootZone:n}){return e??=()=>new Q(P(y({},ph()),{scheduleInRootZone:n})),[{provide:Q,useFactory:e},{provide:lt,multi:!0,useFactory:()=>{let r=v(wD,{optional:!0});return()=>r.initialize()}},{provide:lt,multi:!0,useFactory:()=>{let r=v(TD);return()=>{r.initialize()}}},t===!0?{provide:fc,useValue:!0}:[],{provide:Ri,useValue:n??nh},{provide:Fe,useFactory:()=>{let r=v(Q),o=v(K),i;return s=>{r.runOutsideAngular(()=>{o.destroyed&&!i?setTimeout(()=>{throw s}):(i??=o.get(Be),i.handleError(s))})}}}]}function ph(e){return{enableLongStackTrace:!1,shouldCoalesceEventChangeDetection:e?.eventCoalescing??!1,shouldCoalesceRunChangeDetection:e?.runCoalescing??!1}}var TD=(()=>{class e{subscription=new q;initialized=!1;zone=v(Q);pendingTasks=v(mt);initialize(){if(this.initialized)return;this.initialized=!0;let n=null;!this.zone.isStable&&!this.zone.hasPendingMacrotasks&&!this.zone.hasPendingMicrotasks&&(n=this.pendingTasks.add()),this.zone.runOutsideAngular(()=>{this.subscription.add(this.zone.onStable.subscribe(()=>{Q.assertNotInAngularZone(),queueMicrotask(()=>{n!==null&&!this.zone.hasPendingMacrotasks&&!this.zone.hasPendingMicrotasks&&(this.pendingTasks.remove(n),n=null)})}))}),this.subscription.add(this.zone.onUnstable.subscribe(()=>{Q.assertInAngularZone(),n??=this.pendingTasks.add()}))}ngOnDestroy(){this.subscription.unsubscribe()}static \u0275fac=function(r){return new(r||e)};static \u0275prov=I({token:e,factory:e.\u0275fac,providedIn:"root"})}return e})();var Dl=(()=>{class e{applicationErrorHandler=v(Fe);appRef=v(yn);taskService=v(mt);ngZone=v(Q);zonelessEnabled=v(zr);tracing=v(io,{optional:!0});disableScheduling=v(fc,{optional:!0})??!1;zoneIsDefined=typeof Zone<"u"&&!!Zone.root.run;schedulerTickApplyArgs=[{data:{__scheduler_tick__:!0}}];subscriptions=new q;angularZoneId=this.zoneIsDefined?this.ngZone._inner?.get(Ki):null;scheduleInRootZone=!this.zonelessEnabled&&this.zoneIsDefined&&(v(Ri,{optional:!0})??!1);cancelScheduledCallback=null;useMicrotaskScheduler=!1;runningTick=!1;pendingRenderTaskId=null;constructor(){this.subscriptions.add(this.appRef.afterTick.subscribe(()=>{this.runningTick||this.cleanup()})),this.subscriptions.add(this.ngZone.onUnstable.subscribe(()=>{this.runningTick||this.cleanup()})),this.disableScheduling||=!this.zonelessEnabled&&(this.ngZone instanceof Jr||!this.zoneIsDefined)}notify(n){if(!this.zonelessEnabled&&n===5)return;let r=!1;switch(n){case 0:{this.appRef.dirtyFlags|=2;break}case 3:case 2:case 4:case 5:case 1:{this.appRef.dirtyFlags|=4;break}case 6:{this.appRef.dirtyFlags|=2,r=!0;break}case 12:{this.appRef.dirtyFlags|=16,r=!0;break}case 13:{this.appRef.dirtyFlags|=2,r=!0;break}case 11:{r=!0;break}case 9:case 8:case 7:case 10:default:this.appRef.dirtyFlags|=8}if(this.appRef.tracingSnapshot=this.tracing?.snapshot(this.appRef.tracingSnapshot)??null,!this.shouldScheduleTick(r))return;let o=this.useMicrotaskScheduler?Mf:rh;this.pendingRenderTaskId=this.taskService.add(),this.scheduleInRootZone?this.cancelScheduledCallback=Zone.root.run(()=>o(()=>this.tick())):this.cancelScheduledCallback=this.ngZone.runOutsideAngular(()=>o(()=>this.tick()))}shouldScheduleTick(n){return!(this.disableScheduling&&!n||this.appRef.destroyed||this.pendingRenderTaskId!==null||this.runningTick||this.appRef._runningTick||!this.zonelessEnabled&&this.zoneIsDefined&&Zone.current.get(Ki+this.angularZoneId))}tick(){if(this.runningTick||this.appRef.destroyed)return;if(this.appRef.dirtyFlags===0){this.cleanup();return}!this.zonelessEnabled&&this.appRef.dirtyFlags&7&&(this.appRef.dirtyFlags|=1);let n=this.taskService.add();try{this.ngZone.run(()=>{this.runningTick=!0,this.appRef._tick()},void 0,this.schedulerTickApplyArgs)}catch(r){this.taskService.remove(n),this.applicationErrorHandler(r)}finally{this.cleanup()}this.useMicrotaskScheduler=!0,Mf(()=>{this.useMicrotaskScheduler=!1,this.taskService.remove(n)})}ngOnDestroy(){this.subscriptions.unsubscribe(),this.cleanup()}cleanup(){if(this.runningTick=!1,this.cancelScheduledCallback?.(),this.cancelScheduledCallback=null,this.pendingRenderTaskId!==null){let n=this.pendingRenderTaskId;this.pendingRenderTaskId=null,this.taskService.remove(n)}}static \u0275fac=function(r){return new(r||e)};static \u0275prov=I({token:e,factory:e.\u0275fac,providedIn:"root"})}return e})();function Cl(){return mn("NgZoneless"),tn([{provide:Ye,useExisting:Dl},{provide:Q,useClass:Jr},{provide:zr,useValue:!0},{provide:Ri,useValue:!1},[]])}function MD(){return typeof $localize<"u"&&$localize.locale||ao}var Il=new b("",{providedIn:"root",factory:()=>v(Il,{optional:!0,skipSelf:!0})||MD()});function Ce(e){return dd(e)}function lo(e,t){return Tr(e,t?.equal)}var Sl=class{[oe];constructor(t){this[oe]=t}destroy(){this[oe].destroy()}};function It(e,t){let n=t?.injector??v(Re),r=t?.manualCleanup!==!0?n.get(gt):null,o,i=n.get(Wr,null,{optional:!0}),s=n.get(Ye);return i!==null?(o=AD(i.view,s,e),r instanceof kr&&r._lView===i.view&&(r=null)):o=RD(e,n.get(qr),s),o.injector=n,r!==null&&(o.onDestroyFn=r.onDestroy(()=>o.destroy())),new Sl(o)}var hh=P(y({},fd),{cleanupFns:void 0,zone:null,onDestroyFn:dn,run(){let e=Yn(!1);try{pd(this)}finally{Yn(e)}},cleanup(){if(!this.cleanupFns?.length)return;let e=_(null);try{for(;this.cleanupFns.length;)this.cleanupFns.pop()()}finally{this.cleanupFns=[],_(e)}}}),xD=P(y({},hh),{consumerMarkedDirty(){this.scheduler.schedule(this),this.notifier.notify(12)},destroy(){zt(this),this.onDestroyFn(),this.cleanup(),this.scheduler.remove(this)}}),ND=P(y({},hh),{consumerMarkedDirty(){this.view[M]|=8192,ln(this.view),this.notifier.notify(13)},destroy(){zt(this),this.onDestroyFn(),this.cleanup(),this.view[dt]?.delete(this)}});function AD(e,t,n){let r=Object.create(ND);return r.view=e,r.zone=typeof Zone<"u"?Zone.current:null,r.notifier=t,r.fn=gh(r,n),e[dt]??=new Set,e[dt].add(r),r.consumerMarkedDirty(r),r}function RD(e,t,n){let r=Object.create(xD);return r.fn=gh(r,e),r.scheduler=t,r.notifier=n,r.zone=typeof Zone<"u"?Zone.current:null,r.scheduler.add(r),r.notifier.notify(12),r}function gh(e,t){return()=>{t(n=>(e.cleanupFns??=[]).push(n))}}var Eh=Symbol("InputSignalNode#UNSET"),zD=P(y({},Uo),{transformFn:void 0,applyValueToInputSignal(e,t){Mn(e,t)}});function Dh(e,t){let n=Object.create(zD);n.value=e,n.transformFn=t?.transform;function r(){if(bn(n),n.value===Eh){let o=null;throw new C(-950,o)}return n.value}return r[oe]=n,r}var WD=new b("");WD.__NG_ELEMENT_ID__=e=>{let t=Me();if(t===null)throw new C(204,!1);if(t.type&2)return t.value;if(e&8)return null;throw new C(204,!1)};function mh(e,t){return Dh(e,t)}function qD(e){return Dh(Eh,e)}var Ch=(mh.required=qD,mh);function vh(e,t){return Kp(t)}function YD(e,t){return Qp(t)}var me=(vh.required=YD,vh);function Ih(e,t){return Xp(t)}var bl=new b(""),ZD=new b("");function uo(e){return!e.moduleRef}function KD(e){let t=uo(e)?e.r3Injector:e.moduleRef.injector,n=t.get(Q);return n.run(()=>{uo(e)?e.r3Injector.resolveInjectorInitializers():e.moduleRef.resolveInjectorInitializers();let r=t.get(Fe),o;if(n.runOutsideAngular(()=>{o=n.onError.subscribe({next:r})}),uo(e)){let i=()=>t.destroy(),s=e.platformInjector.get(bl);s.add(i),t.onDestroy(()=>{o.unsubscribe(),s.delete(i)})}else{let i=()=>e.moduleRef.destroy(),s=e.platformInjector.get(bl);s.add(i),e.moduleRef.onDestroy(()=>{Zr(e.allPlatformModules,e.moduleRef),o.unsubscribe(),s.delete(i)})}return XD(r,n,()=>{let i=t.get(mt),s=i.add(),a=t.get(fl);return a.runInitializers(),a.donePromise.then(()=>{let c=t.get(Il,ao);if(dh(c||ao),!t.get(ZD,!0))return uo(e)?t.get(yn):(e.allPlatformModules.push(e.moduleRef),e.moduleRef);if(uo(e)){let u=t.get(yn);return e.rootComponent!==void 0&&u.bootstrap(e.rootComponent),u}else return QD?.(e.moduleRef,e.allPlatformModules),e.moduleRef}).finally(()=>void i.remove(s))})})}var QD;function XD(e,t,n){try{let r=n();return so(r)?r.catch(o=>{throw t.runOutsideAngular(()=>e(o)),o}):r}catch(r){throw t.runOutsideAngular(()=>e(r)),r}}var ms=null;function JD(e=[],t){return Re.create({name:t,providers:[{provide:jr,useValue:"platform"},{provide:bl,useValue:new Set([()=>ms=null])},...e]})}function eC(e=[]){if(ms)return ms;let t=JD(e);return ms=t,ch(),tC(t),t}function tC(e){let t=e.get(rs,null);G(e,()=>{t?.forEach(n=>n())})}var _l=(()=>{class e{static __NG_ELEMENT_ID__=nC}return e})();function nC(e){return rC(Me(),W(),(e&16)===16)}function rC(e,t,n){if(sn(e)&&!n){let r=Qe(e.index,t);return new Ft(r,r)}else if(e.type&175){let r=t[ke];return new Ft(r,t)}return null}function Sh(e){let{rootComponent:t,appProviders:n,platformProviders:r,platformRef:o}=e;z(8);try{let i=o?.injector??eC(r),s=[fh({}),{provide:Ye,useExisting:Dl},af,...n||[]],a=new Xr({providers:s,parent:i,debugName:"",runEnvironmentInitializers:!1});return KD({r3Injector:a.injector,platformInjector:i,rootComponent:t})}catch(i){return Promise.reject(i)}finally{z(9)}}var wh=null;function St(){return wh}function wl(e){wh??=e}var fo=class{},Tl=(()=>{class e{historyGo(n){throw new Error("")}static \u0275fac=function(r){return new(r||e)};static \u0275prov=I({token:e,factory:()=>v(Th),providedIn:"platform"})}return e})();var Th=(()=>{class e extends Tl{_location;_history;_doc=v(re);constructor(){super(),this._location=window.location,this._history=window.history}getBaseHrefFromDOM(){return St().getBaseHref(this._doc)}onPopState(n){let r=St().getGlobalEventTarget(this._doc,"window");return r.addEventListener("popstate",n,!1),()=>r.removeEventListener("popstate",n)}onHashChange(n){let r=St().getGlobalEventTarget(this._doc,"window");return r.addEventListener("hashchange",n,!1),()=>r.removeEventListener("hashchange",n)}get href(){return this._location.href}get protocol(){return this._location.protocol}get hostname(){return this._location.hostname}get port(){return this._location.port}get pathname(){return this._location.pathname}get search(){return this._location.search}get hash(){return this._location.hash}set pathname(n){this._location.pathname=n}pushState(n,r,o){this._history.pushState(n,r,o)}replaceState(n,r,o){this._history.replaceState(n,r,o)}forward(){this._history.forward()}back(){this._history.back()}historyGo(n=0){this._history.go(n)}getState(){return this._history.state}static \u0275fac=function(r){return new(r||e)};static \u0275prov=I({token:e,factory:()=>new e,providedIn:"platform"})}return e})();function Mh(e,t){return e?t?e.endsWith("/")?t.startsWith("/")?e+t.slice(1):e+t:t.startsWith("/")?e+t:`${e}/${t}`:e:t}function bh(e){let t=e.search(/#|\?|$/);return e[t-1]==="/"?e.slice(0,t-1)+e.slice(t):e}function jt(e){return e&&e[0]!=="?"?`?${e}`:e}var vs=(()=>{class e{historyGo(n){throw new Error("")}static \u0275fac=function(r){return new(r||e)};static \u0275prov=I({token:e,factory:()=>v(Nh),providedIn:"root"})}return e})(),xh=new b(""),Nh=(()=>{class e extends vs{_platformLocation;_baseHref;_removeListenerFns=[];constructor(n,r){super(),this._platformLocation=n,this._baseHref=r??this._platformLocation.getBaseHrefFromDOM()??v(re).location?.origin??""}ngOnDestroy(){for(;this._removeListenerFns.length;)this._removeListenerFns.pop()()}onPopState(n){this._removeListenerFns.push(this._platformLocation.onPopState(n),this._platformLocation.onHashChange(n))}getBaseHref(){return this._baseHref}prepareExternalUrl(n){return Mh(this._baseHref,n)}path(n=!1){let r=this._platformLocation.pathname+jt(this._platformLocation.search),o=this._platformLocation.hash;return o&&n?`${r}${o}`:r}pushState(n,r,o,i){let s=this.prepareExternalUrl(o+jt(i));this._platformLocation.pushState(n,r,s)}replaceState(n,r,o,i){let s=this.prepareExternalUrl(o+jt(i));this._platformLocation.replaceState(n,r,s)}forward(){this._platformLocation.forward()}back(){this._platformLocation.back()}getState(){return this._platformLocation.getState()}historyGo(n=0){this._platformLocation.historyGo?.(n)}static \u0275fac=function(r){return new(r||e)(N(Tl),N(xh,8))};static \u0275prov=I({token:e,factory:e.\u0275fac,providedIn:"root"})}return e})(),lr=(()=>{class e{_subject=new X;_basePath;_locationStrategy;_urlChangeListeners=[];_urlChangeSubscription=null;constructor(n){this._locationStrategy=n;let r=this._locationStrategy.getBaseHref();this._basePath=sC(bh(_h(r))),this._locationStrategy.onPopState(o=>{this._subject.next({url:this.path(!0),pop:!0,state:o.state,type:o.type})})}ngOnDestroy(){this._urlChangeSubscription?.unsubscribe(),this._urlChangeListeners=[]}path(n=!1){return this.normalize(this._locationStrategy.path(n))}getState(){return this._locationStrategy.getState()}isCurrentPathEqualTo(n,r=""){return this.path()==this.normalize(n+jt(r))}normalize(n){return e.stripTrailingSlash(iC(this._basePath,_h(n)))}prepareExternalUrl(n){return n&&n[0]!=="/"&&(n="/"+n),this._locationStrategy.prepareExternalUrl(n)}go(n,r="",o=null){this._locationStrategy.pushState(o,"",n,r),this._notifyUrlChangeListeners(this.prepareExternalUrl(n+jt(r)),o)}replaceState(n,r="",o=null){this._locationStrategy.replaceState(o,"",n,r),this._notifyUrlChangeListeners(this.prepareExternalUrl(n+jt(r)),o)}forward(){this._locationStrategy.forward()}back(){this._locationStrategy.back()}historyGo(n=0){this._locationStrategy.historyGo?.(n)}onUrlChange(n){return this._urlChangeListeners.push(n),this._urlChangeSubscription??=this.subscribe(r=>{this._notifyUrlChangeListeners(r.url,r.state)}),()=>{let r=this._urlChangeListeners.indexOf(n);this._urlChangeListeners.splice(r,1),this._urlChangeListeners.length===0&&(this._urlChangeSubscription?.unsubscribe(),this._urlChangeSubscription=null)}}_notifyUrlChangeListeners(n="",r){this._urlChangeListeners.forEach(o=>o(n,r))}subscribe(n,r,o){return this._subject.subscribe({next:n,error:r??void 0,complete:o??void 0})}static normalizeQueryParams=jt;static joinWithSlash=Mh;static stripTrailingSlash=bh;static \u0275fac=function(r){return new(r||e)(N(vs))};static \u0275prov=I({token:e,factory:()=>oC(),providedIn:"root"})}return e})();function oC(){return new lr(N(vs))}function iC(e,t){if(!e||!t.startsWith(e))return t;let n=t.substring(e.length);return n===""||["/",";","?","#"].includes(n[0])?n:t}function _h(e){return e.replace(/\/index.html$/,"")}function sC(e){if(new RegExp("^(https?:)?//").test(e)){let[,n]=e.split(/\/\/[^\/]+/);return n}return e}var ys=(()=>{class e{static \u0275fac=function(r){return new(r||e)};static \u0275mod=oo({type:e});static \u0275inj=Vn({})}return e})();function Ml(e,t){t=encodeURIComponent(t);for(let n of e.split(";")){let r=n.indexOf("="),[o,i]=r==-1?[n,""]:[n.slice(0,r),n.slice(r+1)];if(o.trim()===t)return decodeURIComponent(i)}return null}var po=class{};var Ah="browser";var Ds=new b(""),Ol=(()=>{class e{_zone;_plugins;_eventNameToPlugin=new Map;constructor(n,r){this._zone=r,n.forEach(o=>{o.manager=this}),this._plugins=n.slice().reverse()}addEventListener(n,r,o,i){return this._findPluginFor(r).addEventListener(n,r,o,i)}getZone(){return this._zone}_findPluginFor(n){let r=this._eventNameToPlugin.get(n);if(r)return r;if(r=this._plugins.find(i=>i.supports(n)),!r)throw new C(5101,!1);return this._eventNameToPlugin.set(n,r),r}static \u0275fac=function(r){return new(r||e)(N(Ds),N(Q))};static \u0275prov=I({token:e,factory:e.\u0275fac})}return e})(),ho=class{_doc;constructor(t){this._doc=t}manager},xl="ng-app-id";function Rh(e){for(let t of e)t.remove()}function Oh(e,t){let n=t.createElement("style");return n.textContent=e,n}function cC(e,t,n,r){let o=e.head?.querySelectorAll(`style[${xl}="${t}"],link[${xl}="${t}"]`);if(o)for(let i of o)i.removeAttribute(xl),i instanceof HTMLLinkElement?r.set(i.href.slice(i.href.lastIndexOf("/")+1),{usage:0,elements:[i]}):i.textContent&&n.set(i.textContent,{usage:0,elements:[i]})}function Al(e,t){let n=t.createElement("link");return n.setAttribute("rel","stylesheet"),n.setAttribute("href",e),n}var Pl=(()=>{class e{doc;appId;nonce;inline=new Map;external=new Map;hosts=new Set;constructor(n,r,o,i={}){this.doc=n,this.appId=r,this.nonce=o,cC(n,r,this.inline,this.external),this.hosts.add(n.head)}addStyles(n,r){for(let o of n)this.addUsage(o,this.inline,Oh);r?.forEach(o=>this.addUsage(o,this.external,Al))}removeStyles(n,r){for(let o of n)this.removeUsage(o,this.inline);r?.forEach(o=>this.removeUsage(o,this.external))}addUsage(n,r,o){let i=r.get(n);i?i.usage++:r.set(n,{usage:1,elements:[...this.hosts].map(s=>this.addElement(s,o(n,this.doc)))})}removeUsage(n,r){let o=r.get(n);o&&(o.usage--,o.usage<=0&&(Rh(o.elements),r.delete(n)))}ngOnDestroy(){for(let[,{elements:n}]of[...this.inline,...this.external])Rh(n);this.hosts.clear()}addHost(n){this.hosts.add(n);for(let[r,{elements:o}]of this.inline)o.push(this.addElement(n,Oh(r,this.doc)));for(let[r,{elements:o}]of this.external)o.push(this.addElement(n,Al(r,this.doc)))}removeHost(n){this.hosts.delete(n)}addElement(n,r){return this.nonce&&r.setAttribute("nonce",this.nonce),n.appendChild(r)}static \u0275fac=function(r){return new(r||e)(N(re),N(ns),N(os,8),N(or))};static \u0275prov=I({token:e,factory:e.\u0275fac})}return e})(),Nl={svg:"http://www.w3.org/2000/svg",xhtml:"http://www.w3.org/1999/xhtml",xlink:"http://www.w3.org/1999/xlink",xml:"http://www.w3.org/XML/1998/namespace",xmlns:"http://www.w3.org/2000/xmlns/",math:"http://www.w3.org/1998/Math/MathML"},kl=/%COMP%/g;var kh="%COMP%",lC=`_nghost-${kh}`,uC=`_ngcontent-${kh}`,dC=!0,fC=new b("",{providedIn:"root",factory:()=>dC});function pC(e){return uC.replace(kl,e)}function hC(e){return lC.replace(kl,e)}function Lh(e,t){return t.map(n=>n.replace(kl,e))}var Ll=(()=>{class e{eventManager;sharedStylesHost;appId;removeStylesOnCompDestroy;doc;platformId;ngZone;nonce;tracingService;rendererByCompId=new Map;defaultRenderer;platformIsServer;constructor(n,r,o,i,s,a,c,l=null,u=null){this.eventManager=n,this.sharedStylesHost=r,this.appId=o,this.removeStylesOnCompDestroy=i,this.doc=s,this.platformId=a,this.ngZone=c,this.nonce=l,this.tracingService=u,this.platformIsServer=!1,this.defaultRenderer=new go(n,s,c,this.platformIsServer,this.tracingService)}createRenderer(n,r){if(!n||!r)return this.defaultRenderer;let o=this.getOrCreateRenderer(n,r);return o instanceof Es?o.applyToHost(n):o instanceof mo&&o.applyStyles(),o}getOrCreateRenderer(n,r){let o=this.rendererByCompId,i=o.get(r.id);if(!i){let s=this.doc,a=this.ngZone,c=this.eventManager,l=this.sharedStylesHost,u=this.removeStylesOnCompDestroy,f=this.platformIsServer,m=this.tracingService;switch(r.encapsulation){case vt.Emulated:i=new Es(c,l,r,this.appId,u,s,a,f,m);break;case vt.ShadowDom:return new Rl(c,l,n,r,s,a,this.nonce,f,m);default:i=new mo(c,l,r,u,s,a,f,m);break}o.set(r.id,i)}return i}ngOnDestroy(){this.rendererByCompId.clear()}componentReplaced(n){this.rendererByCompId.delete(n)}static \u0275fac=function(r){return new(r||e)(N(Ol),N(Pl),N(ns),N(fC),N(re),N(or),N(Q),N(os),N(io,8))};static \u0275prov=I({token:e,factory:e.\u0275fac})}return e})(),go=class{eventManager;doc;ngZone;platformIsServer;tracingService;data=Object.create(null);throwOnSyntheticProps=!0;constructor(t,n,r,o,i){this.eventManager=t,this.doc=n,this.ngZone=r,this.platformIsServer=o,this.tracingService=i}destroy(){}destroyNode=null;createElement(t,n){return n?this.doc.createElementNS(Nl[n]||n,t):this.doc.createElement(t)}createComment(t){return this.doc.createComment(t)}createText(t){return this.doc.createTextNode(t)}appendChild(t,n){(Ph(t)?t.content:t).appendChild(n)}insertBefore(t,n,r){t&&(Ph(t)?t.content:t).insertBefore(n,r)}removeChild(t,n){n.remove()}selectRootElement(t,n){let r=typeof t=="string"?this.doc.querySelector(t):t;if(!r)throw new C(-5104,!1);return n||(r.textContent=""),r}parentNode(t){return t.parentNode}nextSibling(t){return t.nextSibling}setAttribute(t,n,r,o){if(o){n=o+":"+n;let i=Nl[o];i?t.setAttributeNS(i,n,r):t.setAttribute(n,r)}else t.setAttribute(n,r)}removeAttribute(t,n,r){if(r){let o=Nl[r];o?t.removeAttributeNS(o,n):t.removeAttribute(`${r}:${n}`)}else t.removeAttribute(n)}addClass(t,n){t.classList.add(n)}removeClass(t,n){t.classList.remove(n)}setStyle(t,n,r,o){o&(yt.DashCase|yt.Important)?t.style.setProperty(n,r,o&yt.Important?"important":""):t.style[n]=r}removeStyle(t,n,r){r&yt.DashCase?t.style.removeProperty(n):t.style[n]=""}setProperty(t,n,r){t!=null&&(t[n]=r)}setValue(t,n){t.nodeValue=n}listen(t,n,r,o){if(typeof t=="string"&&(t=St().getGlobalEventTarget(this.doc,t),!t))throw new C(5102,!1);let i=this.decoratePreventDefault(r);return this.tracingService?.wrapEventListener&&(i=this.tracingService.wrapEventListener(t,n,i)),this.eventManager.addEventListener(t,n,i,o)}decoratePreventDefault(t){return n=>{if(n==="__ngUnwrap__")return t;t(n)===!1&&n.preventDefault()}}};function Ph(e){return e.tagName==="TEMPLATE"&&e.content!==void 0}var Rl=class extends go{sharedStylesHost;hostEl;shadowRoot;constructor(t,n,r,o,i,s,a,c,l){super(t,i,s,c,l),this.sharedStylesHost=n,this.hostEl=r,this.shadowRoot=r.attachShadow({mode:"open"}),this.sharedStylesHost.addHost(this.shadowRoot);let u=o.styles;u=Lh(o.id,u);for(let m of u){let h=document.createElement("style");a&&h.setAttribute("nonce",a),h.textContent=m,this.shadowRoot.appendChild(h)}let f=o.getExternalStyles?.();if(f)for(let m of f){let h=Al(m,i);a&&h.setAttribute("nonce",a),this.shadowRoot.appendChild(h)}}nodeOrShadowRoot(t){return t===this.hostEl?this.shadowRoot:t}appendChild(t,n){return super.appendChild(this.nodeOrShadowRoot(t),n)}insertBefore(t,n,r){return super.insertBefore(this.nodeOrShadowRoot(t),n,r)}removeChild(t,n){return super.removeChild(null,n)}parentNode(t){return this.nodeOrShadowRoot(super.parentNode(this.nodeOrShadowRoot(t)))}destroy(){this.sharedStylesHost.removeHost(this.shadowRoot)}},mo=class extends go{sharedStylesHost;removeStylesOnCompDestroy;styles;styleUrls;constructor(t,n,r,o,i,s,a,c,l){super(t,i,s,a,c),this.sharedStylesHost=n,this.removeStylesOnCompDestroy=o;let u=r.styles;this.styles=l?Lh(l,u):u,this.styleUrls=r.getExternalStyles?.(l)}applyStyles(){this.sharedStylesHost.addStyles(this.styles,this.styleUrls)}destroy(){this.removeStylesOnCompDestroy&&ss.size===0&&this.sharedStylesHost.removeStyles(this.styles,this.styleUrls)}},Es=class extends mo{contentAttr;hostAttr;constructor(t,n,r,o,i,s,a,c,l){let u=o+"-"+r.id;super(t,n,r,i,s,a,c,l,u),this.contentAttr=pC(u),this.hostAttr=hC(u)}applyToHost(t){this.applyStyles(),this.setAttribute(t,this.hostAttr,"")}createElement(t,n){let r=super.createElement(t,n);return super.setAttribute(r,this.contentAttr,""),r}};var Cs=class e extends fo{supportsDOMEvents=!0;static makeCurrent(){wl(new e)}onAndCancel(t,n,r,o){return t.addEventListener(n,r,o),()=>{t.removeEventListener(n,r,o)}}dispatchEvent(t,n){t.dispatchEvent(n)}remove(t){t.remove()}createElement(t,n){return n=n||this.getDefaultDocument(),n.createElement(t)}createHtmlDocument(){return document.implementation.createHTMLDocument("fakeTitle")}getDefaultDocument(){return document}isElementNode(t){return t.nodeType===Node.ELEMENT_NODE}isShadowRoot(t){return t instanceof DocumentFragment}getGlobalEventTarget(t,n){return n==="window"?window:n==="document"?t:n==="body"?t.body:null}getBaseHref(t){let n=gC();return n==null?null:mC(n)}resetBaseElement(){vo=null}getUserAgent(){return window.navigator.userAgent}getCookie(t){return Ml(document.cookie,t)}},vo=null;function gC(){return vo=vo||document.head.querySelector("base"),vo?vo.getAttribute("href"):null}function mC(e){return new URL(e,document.baseURI).pathname}var vC=(()=>{class e{build(){return new XMLHttpRequest}static \u0275fac=function(r){return new(r||e)};static \u0275prov=I({token:e,factory:e.\u0275fac})}return e})(),jh=(()=>{class e extends ho{constructor(n){super(n)}supports(n){return!0}addEventListener(n,r,o,i){return n.addEventListener(r,o,i),()=>this.removeEventListener(n,r,o,i)}removeEventListener(n,r,o,i){return n.removeEventListener(r,o,i)}static \u0275fac=function(r){return new(r||e)(N(re))};static \u0275prov=I({token:e,factory:e.\u0275fac})}return e})(),Fh=["alt","control","meta","shift"],yC={"\b":"Backspace"," ":"Tab","\x7F":"Delete","\x1B":"Escape",Del:"Delete",Esc:"Escape",Left:"ArrowLeft",Right:"ArrowRight",Up:"ArrowUp",Down:"ArrowDown",Menu:"ContextMenu",Scroll:"ScrollLock",Win:"OS"},EC={alt:e=>e.altKey,control:e=>e.ctrlKey,meta:e=>e.metaKey,shift:e=>e.shiftKey},Hh=(()=>{class e extends ho{constructor(n){super(n)}supports(n){return e.parseEventName(n)!=null}addEventListener(n,r,o,i){let s=e.parseEventName(r),a=e.eventCallback(s.fullKey,o,this.manager.getZone());return this.manager.getZone().runOutsideAngular(()=>St().onAndCancel(n,s.domEventName,a,i))}static parseEventName(n){let r=n.toLowerCase().split("."),o=r.shift();if(r.length===0||!(o==="keydown"||o==="keyup"))return null;let i=e._normalizeKey(r.pop()),s="",a=r.indexOf("code");if(a>-1&&(r.splice(a,1),s="code."),Fh.forEach(l=>{let u=r.indexOf(l);u>-1&&(r.splice(u,1),s+=l+".")}),s+=i,r.length!=0||i.length===0)return null;let c={};return c.domEventName=o,c.fullKey=s,c}static matchEventFullKeyCode(n,r){let o=yC[n.key]||n.key,i="";return r.indexOf("code.")>-1&&(o=n.code,i="code."),o==null||!o?!1:(o=o.toLowerCase(),o===" "?o="space":o==="."&&(o="dot"),Fh.forEach(s=>{if(s!==o){let a=EC[s];a(n)&&(i+=s+".")}}),i+=o,i===r)}static eventCallback(n,r,o){return i=>{e.matchEventFullKeyCode(i,n)&&o.runGuarded(()=>r(i))}}static _normalizeKey(n){return n==="esc"?"escape":n}static \u0275fac=function(r){return new(r||e)(N(re))};static \u0275prov=I({token:e,factory:e.\u0275fac})}return e})();function Fl(e,t,n){let r=y({rootComponent:e,platformRef:n?.platformRef},DC(t));return Sh(r)}function DC(e){return{appProviders:[..._C,...e?.providers??[]],platformProviders:bC}}function CC(){Cs.makeCurrent()}function IC(){return new Be}function SC(){return Gc(document),document}var bC=[{provide:or,useValue:Ah},{provide:rs,useValue:CC,multi:!0},{provide:re,useFactory:SC}];var _C=[{provide:jr,useValue:"root"},{provide:Be,useFactory:IC},{provide:Ds,useClass:jh,multi:!0,deps:[re]},{provide:Ds,useClass:Hh,multi:!0,deps:[re]},Ll,Pl,Ol,{provide:pn,useExisting:Ll},{provide:po,useClass:vC},[]];var Bh=(()=>{class e{_doc;constructor(n){this._doc=n}getTitle(){return this._doc.title}setTitle(n){this._doc.title=n||""}static \u0275fac=function(r){return new(r||e)(N(re))};static \u0275prov=I({token:e,factory:e.\u0275fac,providedIn:"root"})}return e})();var A="primary",No=Symbol("RouteTitle"),Ul=class{params;constructor(t){this.params=t||{}}has(t){return Object.prototype.hasOwnProperty.call(this.params,t)}get(t){if(this.has(t)){let n=this.params[t];return Array.isArray(n)?n[0]:n}return null}getAll(t){if(this.has(t)){let n=this.params[t];return Array.isArray(n)?n:[n]}return[]}get keys(){return Object.keys(this.params)}};function gr(e){return new Ul(e)}function TC(e,t,n){let r=n.path.split("/");if(r.length>e.length||n.pathMatch==="full"&&(t.hasChildren()||r.lengthr[i]===o)}else return e===t}function Zh(e){return e.length>0?e[e.length-1]:null}function wt(e){return ha(e)?e:so(e)?Y(Promise.resolve(e)):w(e)}var xC={exact:Qh,subset:Xh},Kh={exact:NC,subset:AC,ignored:()=>!0};function Vh(e,t,n){return xC[n.paths](e.root,t.root,n.matrixParams)&&Kh[n.queryParams](e.queryParams,t.queryParams)&&!(n.fragment==="exact"&&e.fragment!==t.fragment)}function NC(e,t){return tt(e,t)}function Qh(e,t,n){if(!Dn(e.segments,t.segments)||!bs(e.segments,t.segments,n)||e.numberOfChildren!==t.numberOfChildren)return!1;for(let r in t.children)if(!e.children[r]||!Qh(e.children[r],t.children[r],n))return!1;return!0}function AC(e,t){return Object.keys(t).length<=Object.keys(e).length&&Object.keys(t).every(n=>Yh(e[n],t[n]))}function Xh(e,t,n){return Jh(e,t,t.segments,n)}function Jh(e,t,n,r){if(e.segments.length>n.length){let o=e.segments.slice(0,n.length);return!(!Dn(o,n)||t.hasChildren()||!bs(o,n,r))}else if(e.segments.length===n.length){if(!Dn(e.segments,n)||!bs(e.segments,n,r))return!1;for(let o in t.children)if(!e.children[o]||!Xh(e.children[o],t.children[o],r))return!1;return!0}else{let o=n.slice(0,e.segments.length),i=n.slice(e.segments.length);return!Dn(e.segments,o)||!bs(e.segments,o,r)||!e.children[A]?!1:Jh(e.children[A],t,i,r)}}function bs(e,t,n){return t.every((r,o)=>Kh[n](e[o].parameters,r.parameters))}var _t=class{root;queryParams;fragment;_queryParamMap;constructor(t=new $([],{}),n={},r=null){this.root=t,this.queryParams=n,this.fragment=r}get queryParamMap(){return this._queryParamMap??=gr(this.queryParams),this._queryParamMap}toString(){return PC.serialize(this)}},$=class{segments;children;parent=null;constructor(t,n){this.segments=t,this.children=n,Object.values(n).forEach(r=>r.parent=this)}hasChildren(){return this.numberOfChildren>0}get numberOfChildren(){return Object.keys(this.children).length}toString(){return _s(this)}},En=class{path;parameters;_parameterMap;constructor(t,n){this.path=t,this.parameters=n}get parameterMap(){return this._parameterMap??=gr(this.parameters),this._parameterMap}toString(){return tg(this)}};function RC(e,t){return Dn(e,t)&&e.every((n,r)=>tt(n.parameters,t[r].parameters))}function Dn(e,t){return e.length!==t.length?!1:e.every((n,r)=>n.path===t[r].path)}function OC(e,t){let n=[];return Object.entries(e.children).forEach(([r,o])=>{r===A&&(n=n.concat(t(o,r)))}),Object.entries(e.children).forEach(([r,o])=>{r!==A&&(n=n.concat(t(o,r)))}),n}var Fs=(()=>{class e{static \u0275fac=function(r){return new(r||e)};static \u0275prov=I({token:e,factory:()=>new mr,providedIn:"root"})}return e})(),mr=class{parse(t){let n=new zl(t);return new _t(n.parseRootSegment(),n.parseQueryParams(),n.parseFragment())}serialize(t){let n=`/${yo(t.root,!0)}`,r=FC(t.queryParams),o=typeof t.fragment=="string"?`#${kC(t.fragment)}`:"";return`${n}${r}${o}`}},PC=new mr;function _s(e){return e.segments.map(t=>tg(t)).join("/")}function yo(e,t){if(!e.hasChildren())return _s(e);if(t){let n=e.children[A]?yo(e.children[A],!1):"",r=[];return Object.entries(e.children).forEach(([o,i])=>{o!==A&&r.push(`${o}:${yo(i,!1)}`)}),r.length>0?`${n}(${r.join("//")})`:n}else{let n=OC(e,(r,o)=>o===A?[yo(e.children[A],!1)]:[`${o}:${yo(r,!1)}`]);return Object.keys(e.children).length===1&&e.children[A]!=null?`${_s(e)}/${n[0]}`:`${_s(e)}/(${n.join("//")})`}}function eg(e){return encodeURIComponent(e).replace(/%40/g,"@").replace(/%3A/gi,":").replace(/%24/g,"$").replace(/%2C/gi,",")}function Is(e){return eg(e).replace(/%3B/gi,";")}function kC(e){return encodeURI(e)}function Gl(e){return eg(e).replace(/\(/g,"%28").replace(/\)/g,"%29").replace(/%26/gi,"&")}function ws(e){return decodeURIComponent(e)}function Uh(e){return ws(e.replace(/\+/g,"%20"))}function tg(e){return`${Gl(e.path)}${LC(e.parameters)}`}function LC(e){return Object.entries(e).map(([t,n])=>`;${Gl(t)}=${Gl(n)}`).join("")}function FC(e){let t=Object.entries(e).map(([n,r])=>Array.isArray(r)?r.map(o=>`${Is(n)}=${Is(o)}`).join("&"):`${Is(n)}=${Is(r)}`).filter(n=>n);return t.length?`?${t.join("&")}`:""}var jC=/^[^\/()?;#]+/;function jl(e){let t=e.match(jC);return t?t[0]:""}var HC=/^[^\/()?;=#]+/;function BC(e){let t=e.match(HC);return t?t[0]:""}var VC=/^[^=?&#]+/;function UC(e){let t=e.match(VC);return t?t[0]:""}var $C=/^[^&#]+/;function GC(e){let t=e.match($C);return t?t[0]:""}var zl=class{url;remaining;constructor(t){this.url=t,this.remaining=t}parseRootSegment(){return this.consumeOptional("/"),this.remaining===""||this.peekStartsWith("?")||this.peekStartsWith("#")?new $([],{}):new $([],this.parseChildren())}parseQueryParams(){let t={};if(this.consumeOptional("?"))do this.parseQueryParam(t);while(this.consumeOptional("&"));return t}parseFragment(){return this.consumeOptional("#")?decodeURIComponent(this.remaining):null}parseChildren(){if(this.remaining==="")return{};this.consumeOptional("/");let t=[];for(this.peekStartsWith("(")||t.push(this.parseSegment());this.peekStartsWith("/")&&!this.peekStartsWith("//")&&!this.peekStartsWith("/(");)this.capture("/"),t.push(this.parseSegment());let n={};this.peekStartsWith("/(")&&(this.capture("/"),n=this.parseParens(!0));let r={};return this.peekStartsWith("(")&&(r=this.parseParens(!1)),(t.length>0||Object.keys(n).length>0)&&(r[A]=new $(t,n)),r}parseSegment(){let t=jl(this.remaining);if(t===""&&this.peekStartsWith(";"))throw new C(4009,!1);return this.capture(t),new En(ws(t),this.parseMatrixParams())}parseMatrixParams(){let t={};for(;this.consumeOptional(";");)this.parseParam(t);return t}parseParam(t){let n=BC(this.remaining);if(!n)return;this.capture(n);let r="";if(this.consumeOptional("=")){let o=jl(this.remaining);o&&(r=o,this.capture(r))}t[ws(n)]=ws(r)}parseQueryParam(t){let n=UC(this.remaining);if(!n)return;this.capture(n);let r="";if(this.consumeOptional("=")){let s=GC(this.remaining);s&&(r=s,this.capture(r))}let o=Uh(n),i=Uh(r);if(t.hasOwnProperty(o)){let s=t[o];Array.isArray(s)||(s=[s],t[o]=s),s.push(i)}else t[o]=i}parseParens(t){let n={};for(this.capture("(");!this.consumeOptional(")")&&this.remaining.length>0;){let r=jl(this.remaining),o=this.remaining[r.length];if(o!=="/"&&o!==")"&&o!==";")throw new C(4010,!1);let i;r.indexOf(":")>-1?(i=r.slice(0,r.indexOf(":")),this.capture(i),this.capture(":")):t&&(i=A);let s=this.parseChildren();n[i]=Object.keys(s).length===1?s[A]:new $([],s),this.consumeOptional("//")}return n}peekStartsWith(t){return this.remaining.startsWith(t)}consumeOptional(t){return this.peekStartsWith(t)?(this.remaining=this.remaining.substring(t.length),!0):!1}capture(t){if(!this.consumeOptional(t))throw new C(4011,!1)}};function ng(e){return e.segments.length>0?new $([],{[A]:e}):e}function rg(e){let t={};for(let[r,o]of Object.entries(e.children)){let i=rg(o);if(r===A&&i.segments.length===0&&i.hasChildren())for(let[s,a]of Object.entries(i.children))t[s]=a;else(i.segments.length>0||i.hasChildren())&&(t[r]=i)}let n=new $(e.segments,t);return zC(n)}function zC(e){if(e.numberOfChildren===1&&e.children[A]){let t=e.children[A];return new $(e.segments.concat(t.segments),t.children)}return e}function vr(e){return e instanceof _t}function WC(e,t,n=null,r=null){let o=og(e);return ig(o,t,n,r)}function og(e){let t;function n(i){let s={};for(let c of i.children){let l=n(c);s[c.outlet]=l}let a=new $(i.url,s);return i===e&&(t=a),a}let r=n(e.root),o=ng(r);return t??o}function ig(e,t,n,r){let o=e;for(;o.parent;)o=o.parent;if(t.length===0)return Hl(o,o,o,n,r);let i=qC(t);if(i.toRoot())return Hl(o,o,new $([],{}),n,r);let s=YC(i,o,e),a=s.processChildren?Do(s.segmentGroup,s.index,i.commands):ag(s.segmentGroup,s.index,i.commands);return Hl(o,s.segmentGroup,a,n,r)}function Ts(e){return typeof e=="object"&&e!=null&&!e.outlets&&!e.segmentPath}function So(e){return typeof e=="object"&&e!=null&&e.outlets}function Hl(e,t,n,r,o){let i={};r&&Object.entries(r).forEach(([c,l])=>{i[c]=Array.isArray(l)?l.map(u=>`${u}`):`${l}`});let s;e===t?s=n:s=sg(e,t,n);let a=ng(rg(s));return new _t(a,i,o)}function sg(e,t,n){let r={};return Object.entries(e.children).forEach(([o,i])=>{i===t?r[o]=n:r[o]=sg(i,t,n)}),new $(e.segments,r)}var Ms=class{isAbsolute;numberOfDoubleDots;commands;constructor(t,n,r){if(this.isAbsolute=t,this.numberOfDoubleDots=n,this.commands=r,t&&r.length>0&&Ts(r[0]))throw new C(4003,!1);let o=r.find(So);if(o&&o!==Zh(r))throw new C(4004,!1)}toRoot(){return this.isAbsolute&&this.commands.length===1&&this.commands[0]=="/"}};function qC(e){if(typeof e[0]=="string"&&e.length===1&&e[0]==="/")return new Ms(!0,0,e);let t=0,n=!1,r=e.reduce((o,i,s)=>{if(typeof i=="object"&&i!=null){if(i.outlets){let a={};return Object.entries(i.outlets).forEach(([c,l])=>{a[c]=typeof l=="string"?l.split("/"):l}),[...o,{outlets:a}]}if(i.segmentPath)return[...o,i.segmentPath]}return typeof i!="string"?[...o,i]:s===0?(i.split("/").forEach((a,c)=>{c==0&&a==="."||(c==0&&a===""?n=!0:a===".."?t++:a!=""&&o.push(a))}),o):[...o,i]},[]);return new Ms(n,t,r)}var fr=class{segmentGroup;processChildren;index;constructor(t,n,r){this.segmentGroup=t,this.processChildren=n,this.index=r}};function YC(e,t,n){if(e.isAbsolute)return new fr(t,!0,0);if(!n)return new fr(t,!1,NaN);if(n.parent===null)return new fr(n,!0,0);let r=Ts(e.commands[0])?0:1,o=n.segments.length-1+r;return ZC(n,o,e.numberOfDoubleDots)}function ZC(e,t,n){let r=e,o=t,i=n;for(;i>o;){if(i-=o,r=r.parent,!r)throw new C(4005,!1);o=r.segments.length}return new fr(r,!1,o-i)}function KC(e){return So(e[0])?e[0].outlets:{[A]:e}}function ag(e,t,n){if(e??=new $([],{}),e.segments.length===0&&e.hasChildren())return Do(e,t,n);let r=QC(e,t,n),o=n.slice(r.commandIndex);if(r.match&&r.pathIndexi!==A)&&e.children[A]&&e.numberOfChildren===1&&e.children[A].segments.length===0){let i=Do(e.children[A],t,n);return new $(e.segments,i.children)}return Object.entries(r).forEach(([i,s])=>{typeof s=="string"&&(s=[s]),s!==null&&(o[i]=ag(e.children[i],t,s))}),Object.entries(e.children).forEach(([i,s])=>{r[i]===void 0&&(o[i]=s)}),new $(e.segments,o)}}function QC(e,t,n){let r=0,o=t,i={match:!1,pathIndex:0,commandIndex:0};for(;o=n.length)return i;let s=e.segments[o],a=n[r];if(So(a))break;let c=`${a}`,l=r0&&c===void 0)break;if(c&&l&&typeof l=="object"&&l.outlets===void 0){if(!Gh(c,l,s))return i;r+=2}else{if(!Gh(c,{},s))return i;r++}o++}return{match:!0,pathIndex:o,commandIndex:r}}function Wl(e,t,n){let r=e.segments.slice(0,t),o=0;for(;o{typeof r=="string"&&(r=[r]),r!==null&&(t[n]=Wl(new $([],{}),0,r))}),t}function $h(e){let t={};return Object.entries(e).forEach(([n,r])=>t[n]=`${r}`),t}function Gh(e,t,n){return e==n.path&&tt(t,n.parameters)}var Co="imperative",le=(function(e){return e[e.NavigationStart=0]="NavigationStart",e[e.NavigationEnd=1]="NavigationEnd",e[e.NavigationCancel=2]="NavigationCancel",e[e.NavigationError=3]="NavigationError",e[e.RoutesRecognized=4]="RoutesRecognized",e[e.ResolveStart=5]="ResolveStart",e[e.ResolveEnd=6]="ResolveEnd",e[e.GuardsCheckStart=7]="GuardsCheckStart",e[e.GuardsCheckEnd=8]="GuardsCheckEnd",e[e.RouteConfigLoadStart=9]="RouteConfigLoadStart",e[e.RouteConfigLoadEnd=10]="RouteConfigLoadEnd",e[e.ChildActivationStart=11]="ChildActivationStart",e[e.ChildActivationEnd=12]="ChildActivationEnd",e[e.ActivationStart=13]="ActivationStart",e[e.ActivationEnd=14]="ActivationEnd",e[e.Scroll=15]="Scroll",e[e.NavigationSkipped=16]="NavigationSkipped",e})(le||{}),je=class{id;url;constructor(t,n){this.id=t,this.url=n}},yr=class extends je{type=le.NavigationStart;navigationTrigger;restoredState;constructor(t,n,r="imperative",o=null){super(t,n),this.navigationTrigger=r,this.restoredState=o}toString(){return`NavigationStart(id: ${this.id}, url: '${this.url}')`}},Ht=class extends je{urlAfterRedirects;type=le.NavigationEnd;constructor(t,n,r){super(t,n),this.urlAfterRedirects=r}toString(){return`NavigationEnd(id: ${this.id}, url: '${this.url}', urlAfterRedirects: '${this.urlAfterRedirects}')`}},Ie=(function(e){return e[e.Redirect=0]="Redirect",e[e.SupersededByNewNavigation=1]="SupersededByNewNavigation",e[e.NoDataFromResolver=2]="NoDataFromResolver",e[e.GuardRejected=3]="GuardRejected",e[e.Aborted=4]="Aborted",e})(Ie||{}),xs=(function(e){return e[e.IgnoredSameUrlNavigation=0]="IgnoredSameUrlNavigation",e[e.IgnoredByUrlHandlingStrategy=1]="IgnoredByUrlHandlingStrategy",e})(xs||{}),bt=class extends je{reason;code;type=le.NavigationCancel;constructor(t,n,r,o){super(t,n),this.reason=r,this.code=o}toString(){return`NavigationCancel(id: ${this.id}, url: '${this.url}')`}},Bt=class extends je{reason;code;type=le.NavigationSkipped;constructor(t,n,r,o){super(t,n),this.reason=r,this.code=o}},bo=class extends je{error;target;type=le.NavigationError;constructor(t,n,r,o){super(t,n),this.error=r,this.target=o}toString(){return`NavigationError(id: ${this.id}, url: '${this.url}', error: ${this.error})`}},Ns=class extends je{urlAfterRedirects;state;type=le.RoutesRecognized;constructor(t,n,r,o){super(t,n),this.urlAfterRedirects=r,this.state=o}toString(){return`RoutesRecognized(id: ${this.id}, url: '${this.url}', urlAfterRedirects: '${this.urlAfterRedirects}', state: ${this.state})`}},ql=class extends je{urlAfterRedirects;state;type=le.GuardsCheckStart;constructor(t,n,r,o){super(t,n),this.urlAfterRedirects=r,this.state=o}toString(){return`GuardsCheckStart(id: ${this.id}, url: '${this.url}', urlAfterRedirects: '${this.urlAfterRedirects}', state: ${this.state})`}},Yl=class extends je{urlAfterRedirects;state;shouldActivate;type=le.GuardsCheckEnd;constructor(t,n,r,o,i){super(t,n),this.urlAfterRedirects=r,this.state=o,this.shouldActivate=i}toString(){return`GuardsCheckEnd(id: ${this.id}, url: '${this.url}', urlAfterRedirects: '${this.urlAfterRedirects}', state: ${this.state}, shouldActivate: ${this.shouldActivate})`}},Zl=class extends je{urlAfterRedirects;state;type=le.ResolveStart;constructor(t,n,r,o){super(t,n),this.urlAfterRedirects=r,this.state=o}toString(){return`ResolveStart(id: ${this.id}, url: '${this.url}', urlAfterRedirects: '${this.urlAfterRedirects}', state: ${this.state})`}},Kl=class extends je{urlAfterRedirects;state;type=le.ResolveEnd;constructor(t,n,r,o){super(t,n),this.urlAfterRedirects=r,this.state=o}toString(){return`ResolveEnd(id: ${this.id}, url: '${this.url}', urlAfterRedirects: '${this.urlAfterRedirects}', state: ${this.state})`}},Ql=class{route;type=le.RouteConfigLoadStart;constructor(t){this.route=t}toString(){return`RouteConfigLoadStart(path: ${this.route.path})`}},Xl=class{route;type=le.RouteConfigLoadEnd;constructor(t){this.route=t}toString(){return`RouteConfigLoadEnd(path: ${this.route.path})`}},Jl=class{snapshot;type=le.ChildActivationStart;constructor(t){this.snapshot=t}toString(){return`ChildActivationStart(path: '${this.snapshot.routeConfig&&this.snapshot.routeConfig.path||""}')`}},eu=class{snapshot;type=le.ChildActivationEnd;constructor(t){this.snapshot=t}toString(){return`ChildActivationEnd(path: '${this.snapshot.routeConfig&&this.snapshot.routeConfig.path||""}')`}},tu=class{snapshot;type=le.ActivationStart;constructor(t){this.snapshot=t}toString(){return`ActivationStart(path: '${this.snapshot.routeConfig&&this.snapshot.routeConfig.path||""}')`}},nu=class{snapshot;type=le.ActivationEnd;constructor(t){this.snapshot=t}toString(){return`ActivationEnd(path: '${this.snapshot.routeConfig&&this.snapshot.routeConfig.path||""}')`}};var _o=class{},Er=class{url;navigationBehaviorOptions;constructor(t,n){this.url=t,this.navigationBehaviorOptions=n}};function JC(e){return!(e instanceof _o)&&!(e instanceof Er)}function eI(e,t){return e.providers&&!e._injector&&(e._injector=ro(e.providers,t,`Route: ${e.path}`)),e._injector??t}function ze(e){return e.outlet||A}function tI(e,t){let n=e.filter(r=>ze(r)===t);return n.push(...e.filter(r=>ze(r)!==t)),n}function Cr(e){if(!e)return null;if(e.routeConfig?._injector)return e.routeConfig._injector;for(let t=e.parent;t;t=t.parent){let n=t.routeConfig;if(n?._loadedInjector)return n._loadedInjector;if(n?._injector)return n._injector}return null}var ru=class{rootInjector;outlet=null;route=null;children;attachRef=null;get injector(){return Cr(this.route?.snapshot)??this.rootInjector}constructor(t){this.rootInjector=t,this.children=new Ao(this.rootInjector)}},Ao=(()=>{class e{rootInjector;contexts=new Map;constructor(n){this.rootInjector=n}onChildOutletCreated(n,r){let o=this.getOrCreateContext(n);o.outlet=r,this.contexts.set(n,o)}onChildOutletDestroyed(n){let r=this.getContext(n);r&&(r.outlet=null,r.attachRef=null)}onOutletDeactivated(){let n=this.contexts;return this.contexts=new Map,n}onOutletReAttached(n){this.contexts=n}getOrCreateContext(n){let r=this.getContext(n);return r||(r=new ru(this.rootInjector),this.contexts.set(n,r)),r}getContext(n){return this.contexts.get(n)||null}static \u0275fac=function(r){return new(r||e)(N(K))};static \u0275prov=I({token:e,factory:e.\u0275fac,providedIn:"root"})}return e})(),As=class{_root;constructor(t){this._root=t}get root(){return this._root.value}parent(t){let n=this.pathFromRoot(t);return n.length>1?n[n.length-2]:null}children(t){let n=ou(t,this._root);return n?n.children.map(r=>r.value):[]}firstChild(t){let n=ou(t,this._root);return n&&n.children.length>0?n.children[0].value:null}siblings(t){let n=iu(t,this._root);return n.length<2?[]:n[n.length-2].children.map(o=>o.value).filter(o=>o!==t)}pathFromRoot(t){return iu(t,this._root).map(n=>n.value)}};function ou(e,t){if(e===t.value)return t;for(let n of t.children){let r=ou(e,n);if(r)return r}return null}function iu(e,t){if(e===t.value)return[t];for(let n of t.children){let r=iu(e,n);if(r.length)return r.unshift(t),r}return[]}var xe=class{value;children;constructor(t,n){this.value=t,this.children=n}toString(){return`TreeNode(${this.value})`}};function dr(e){let t={};return e&&e.children.forEach(n=>t[n.value.outlet]=n),t}var Rs=class extends As{snapshot;constructor(t,n){super(t),this.snapshot=n,hu(this,t)}toString(){return this.snapshot.toString()}};function cg(e){let t=nI(e),n=new ie([new En("",{})]),r=new ie({}),o=new ie({}),i=new ie({}),s=new ie(""),a=new Cn(n,r,i,s,o,A,e,t.root);return a.snapshot=t.root,new Rs(new xe(a,[]),t)}function nI(e){let t={},n={},r={},i=new pr([],t,r,"",n,A,e,null,{});return new Ps("",new xe(i,[]))}var Cn=class{urlSubject;paramsSubject;queryParamsSubject;fragmentSubject;dataSubject;outlet;component;snapshot;_futureSnapshot;_routerState;_paramMap;_queryParamMap;title;url;params;queryParams;fragment;data;constructor(t,n,r,o,i,s,a,c){this.urlSubject=t,this.paramsSubject=n,this.queryParamsSubject=r,this.fragmentSubject=o,this.dataSubject=i,this.outlet=s,this.component=a,this._futureSnapshot=c,this.title=this.dataSubject?.pipe(B(l=>l[No]))??w(void 0),this.url=t,this.params=n,this.queryParams=r,this.fragment=o,this.data=i}get routeConfig(){return this._futureSnapshot.routeConfig}get root(){return this._routerState.root}get parent(){return this._routerState.parent(this)}get firstChild(){return this._routerState.firstChild(this)}get children(){return this._routerState.children(this)}get pathFromRoot(){return this._routerState.pathFromRoot(this)}get paramMap(){return this._paramMap??=this.params.pipe(B(t=>gr(t))),this._paramMap}get queryParamMap(){return this._queryParamMap??=this.queryParams.pipe(B(t=>gr(t))),this._queryParamMap}toString(){return this.snapshot?this.snapshot.toString():`Future(${this._futureSnapshot})`}};function Os(e,t,n="emptyOnly"){let r,{routeConfig:o}=e;return t!==null&&(n==="always"||o?.path===""||!t.component&&!t.routeConfig?.loadComponent)?r={params:y(y({},t.params),e.params),data:y(y({},t.data),e.data),resolve:y(y(y(y({},e.data),t.data),o?.data),e._resolvedData)}:r={params:y({},e.params),data:y({},e.data),resolve:y(y({},e.data),e._resolvedData??{})},o&&ug(o)&&(r.resolve[No]=o.title),r}var pr=class{url;params;queryParams;fragment;data;outlet;component;routeConfig;_resolve;_resolvedData;_routerState;_paramMap;_queryParamMap;get title(){return this.data?.[No]}constructor(t,n,r,o,i,s,a,c,l){this.url=t,this.params=n,this.queryParams=r,this.fragment=o,this.data=i,this.outlet=s,this.component=a,this.routeConfig=c,this._resolve=l}get root(){return this._routerState.root}get parent(){return this._routerState.parent(this)}get firstChild(){return this._routerState.firstChild(this)}get children(){return this._routerState.children(this)}get pathFromRoot(){return this._routerState.pathFromRoot(this)}get paramMap(){return this._paramMap??=gr(this.params),this._paramMap}get queryParamMap(){return this._queryParamMap??=gr(this.queryParams),this._queryParamMap}toString(){let t=this.url.map(r=>r.toString()).join("/"),n=this.routeConfig?this.routeConfig.path:"";return`Route(url:'${t}', path:'${n}')`}},Ps=class extends As{url;constructor(t,n){super(n),this.url=t,hu(this,n)}toString(){return lg(this._root)}};function hu(e,t){t.value._routerState=e,t.children.forEach(n=>hu(e,n))}function lg(e){let t=e.children.length>0?` { ${e.children.map(lg).join(", ")} } `:"";return`${e.value}${t}`}function Bl(e){if(e.snapshot){let t=e.snapshot,n=e._futureSnapshot;e.snapshot=n,tt(t.queryParams,n.queryParams)||e.queryParamsSubject.next(n.queryParams),t.fragment!==n.fragment&&e.fragmentSubject.next(n.fragment),tt(t.params,n.params)||e.paramsSubject.next(n.params),MC(t.url,n.url)||e.urlSubject.next(n.url),tt(t.data,n.data)||e.dataSubject.next(n.data)}else e.snapshot=e._futureSnapshot,e.dataSubject.next(e._futureSnapshot.data)}function su(e,t){let n=tt(e.params,t.params)&&RC(e.url,t.url),r=!e.parent!=!t.parent;return n&&!r&&(!e.parent||su(e.parent,t.parent))}function ug(e){return typeof e.title=="string"||e.title===null}var rI=new b(""),dg=(()=>{class e{activated=null;get activatedComponentRef(){return this.activated}_activatedRoute=null;name=A;activateEvents=new ge;deactivateEvents=new ge;attachEvents=new ge;detachEvents=new ge;routerOutletData=Ch(void 0);parentContexts=v(Ao);location=v(gn);changeDetector=v(_l);inputBinder=v(js,{optional:!0});supportsBindingToComponentInputs=!0;ngOnChanges(n){if(n.name){let{firstChange:r,previousValue:o}=n.name;if(r)return;this.isTrackedInParentContexts(o)&&(this.deactivate(),this.parentContexts.onChildOutletDestroyed(o)),this.initializeOutletWithName()}}ngOnDestroy(){this.isTrackedInParentContexts(this.name)&&this.parentContexts.onChildOutletDestroyed(this.name),this.inputBinder?.unsubscribeFromRouteData(this)}isTrackedInParentContexts(n){return this.parentContexts.getContext(n)?.outlet===this}ngOnInit(){this.initializeOutletWithName()}initializeOutletWithName(){if(this.parentContexts.onChildOutletCreated(this.name,this),this.activated)return;let n=this.parentContexts.getContext(this.name);n?.route&&(n.attachRef?this.attach(n.attachRef,n.route):this.activateWith(n.route,n.injector))}get isActivated(){return!!this.activated}get component(){if(!this.activated)throw new C(4012,!1);return this.activated.instance}get activatedRoute(){if(!this.activated)throw new C(4012,!1);return this._activatedRoute}get activatedRouteData(){return this._activatedRoute?this._activatedRoute.snapshot.data:{}}detach(){if(!this.activated)throw new C(4012,!1);this.location.detach();let n=this.activated;return this.activated=null,this._activatedRoute=null,this.detachEvents.emit(n.instance),n}attach(n,r){this.activated=n,this._activatedRoute=r,this.location.insert(n.hostView),this.inputBinder?.bindActivatedRouteToOutletComponent(this),this.attachEvents.emit(n.instance)}deactivate(){if(this.activated){let n=this.component;this.activated.destroy(),this.activated=null,this._activatedRoute=null,this.deactivateEvents.emit(n)}}activateWith(n,r){if(this.isActivated)throw new C(4013,!1);this._activatedRoute=n;let o=this.location,s=n.snapshot.component,a=this.parentContexts.getOrCreateContext(this.name).children,c=new au(n,a,o.injector,this.routerOutletData);this.activated=o.createComponent(s,{index:o.length,injector:c,environmentInjector:r}),this.changeDetector.markForCheck(),this.inputBinder?.bindActivatedRouteToOutletComponent(this),this.activateEvents.emit(this.activated.instance)}static \u0275fac=function(r){return new(r||e)};static \u0275dir=fs({type:e,selectors:[["router-outlet"]],inputs:{name:"name",routerOutletData:[1,"routerOutletData"]},outputs:{activateEvents:"activate",deactivateEvents:"deactivate",attachEvents:"attach",detachEvents:"detach"},exportAs:["outlet"],features:[Ji]})}return e})(),au=class{route;childContexts;parent;outletData;constructor(t,n,r,o){this.route=t,this.childContexts=n,this.parent=r,this.outletData=o}get(t,n){return t===Cn?this.route:t===Ao?this.childContexts:t===rI?this.outletData:this.parent.get(t,n)}},js=new b("");var fg=(()=>{class e{static \u0275fac=function(r){return new(r||e)};static \u0275cmp=vn({type:e,selectors:[["ng-component"]],exportAs:["emptyRouterOutlet"],decls:1,vars:0,template:function(r,o){r&1&&ar(0,"router-outlet")},dependencies:[dg],encapsulation:2})}return e})();function gu(e){let t=e.children&&e.children.map(gu),n=t?P(y({},e),{children:t}):y({},e);return!n.component&&!n.loadComponent&&(t||n.loadChildren)&&n.outlet&&n.outlet!==A&&(n.component=fg),n}function oI(e,t,n){let r=wo(e,t._root,n?n._root:void 0);return new Rs(r,t)}function wo(e,t,n){if(n&&e.shouldReuseRoute(t.value,n.value.snapshot)){let r=n.value;r._futureSnapshot=t.value;let o=iI(e,t,n);return new xe(r,o)}else{if(e.shouldAttach(t.value)){let i=e.retrieve(t.value);if(i!==null){let s=i.route;return s.value._futureSnapshot=t.value,s.children=t.children.map(a=>wo(e,a)),s}}let r=sI(t.value),o=t.children.map(i=>wo(e,i));return new xe(r,o)}}function iI(e,t,n){return t.children.map(r=>{for(let o of n.children)if(e.shouldReuseRoute(r.value,o.value.snapshot))return wo(e,r,o);return wo(e,r)})}function sI(e){return new Cn(new ie(e.url),new ie(e.params),new ie(e.queryParams),new ie(e.fragment),new ie(e.data),e.outlet,e.component,e)}var To=class{redirectTo;navigationBehaviorOptions;constructor(t,n){this.redirectTo=t,this.navigationBehaviorOptions=n}},pg="ngNavigationCancelingError";function ks(e,t){let{redirectTo:n,navigationBehaviorOptions:r}=vr(t)?{redirectTo:t,navigationBehaviorOptions:void 0}:t,o=hg(!1,Ie.Redirect);return o.url=n,o.navigationBehaviorOptions=r,o}function hg(e,t){let n=new Error(`NavigationCancelingError: ${e||""}`);return n[pg]=!0,n.cancellationCode=t,n}function aI(e){return gg(e)&&vr(e.url)}function gg(e){return!!e&&e[pg]}var cI=(e,t,n,r)=>B(o=>(new cu(t,o.targetRouterState,o.currentRouterState,n,r).activate(e),o)),cu=class{routeReuseStrategy;futureState;currState;forwardEvent;inputBindingEnabled;constructor(t,n,r,o,i){this.routeReuseStrategy=t,this.futureState=n,this.currState=r,this.forwardEvent=o,this.inputBindingEnabled=i}activate(t){let n=this.futureState._root,r=this.currState?this.currState._root:null;this.deactivateChildRoutes(n,r,t),Bl(this.futureState.root),this.activateChildRoutes(n,r,t)}deactivateChildRoutes(t,n,r){let o=dr(n);t.children.forEach(i=>{let s=i.value.outlet;this.deactivateRoutes(i,o[s],r),delete o[s]}),Object.values(o).forEach(i=>{this.deactivateRouteAndItsChildren(i,r)})}deactivateRoutes(t,n,r){let o=t.value,i=n?n.value:null;if(o===i)if(o.component){let s=r.getContext(o.outlet);s&&this.deactivateChildRoutes(t,n,s.children)}else this.deactivateChildRoutes(t,n,r);else i&&this.deactivateRouteAndItsChildren(n,r)}deactivateRouteAndItsChildren(t,n){t.value.component&&this.routeReuseStrategy.shouldDetach(t.value.snapshot)?this.detachAndStoreRouteSubtree(t,n):this.deactivateRouteAndOutlet(t,n)}detachAndStoreRouteSubtree(t,n){let r=n.getContext(t.value.outlet),o=r&&t.value.component?r.children:n,i=dr(t);for(let s of Object.values(i))this.deactivateRouteAndItsChildren(s,o);if(r&&r.outlet){let s=r.outlet.detach(),a=r.children.onOutletDeactivated();this.routeReuseStrategy.store(t.value.snapshot,{componentRef:s,route:t,contexts:a})}}deactivateRouteAndOutlet(t,n){let r=n.getContext(t.value.outlet),o=r&&t.value.component?r.children:n,i=dr(t);for(let s of Object.values(i))this.deactivateRouteAndItsChildren(s,o);r&&(r.outlet&&(r.outlet.deactivate(),r.children.onOutletDeactivated()),r.attachRef=null,r.route=null)}activateChildRoutes(t,n,r){let o=dr(n);t.children.forEach(i=>{this.activateRoutes(i,o[i.value.outlet],r),this.forwardEvent(new nu(i.value.snapshot))}),t.children.length&&this.forwardEvent(new eu(t.value.snapshot))}activateRoutes(t,n,r){let o=t.value,i=n?n.value:null;if(Bl(o),o===i)if(o.component){let s=r.getOrCreateContext(o.outlet);this.activateChildRoutes(t,n,s.children)}else this.activateChildRoutes(t,n,r);else if(o.component){let s=r.getOrCreateContext(o.outlet);if(this.routeReuseStrategy.shouldAttach(o.snapshot)){let a=this.routeReuseStrategy.retrieve(o.snapshot);this.routeReuseStrategy.store(o.snapshot,null),s.children.onOutletReAttached(a.contexts),s.attachRef=a.componentRef,s.route=a.route.value,s.outlet&&s.outlet.attach(a.componentRef,a.route.value),Bl(a.route.value),this.activateChildRoutes(t,null,s.children)}else s.attachRef=null,s.route=o,s.outlet&&s.outlet.activateWith(o,s.injector),this.activateChildRoutes(t,null,s.children)}else this.activateChildRoutes(t,null,r)}},Ls=class{path;route;constructor(t){this.path=t,this.route=this.path[this.path.length-1]}},hr=class{component;route;constructor(t,n){this.component=t,this.route=n}};function lI(e,t,n){let r=e._root,o=t?t._root:null;return Eo(r,o,n,[r.value])}function uI(e){let t=e.routeConfig?e.routeConfig.canActivateChild:null;return!t||t.length===0?null:{node:e,guards:t}}function Ir(e,t){let n=Symbol(),r=t.get(e,n);return r===n?typeof e=="function"&&!Aa(e)?e:t.get(e):r}function Eo(e,t,n,r,o={canDeactivateChecks:[],canActivateChecks:[]}){let i=dr(t);return e.children.forEach(s=>{dI(s,i[s.value.outlet],n,r.concat([s.value]),o),delete i[s.value.outlet]}),Object.entries(i).forEach(([s,a])=>Io(a,n.getContext(s),o)),o}function dI(e,t,n,r,o={canDeactivateChecks:[],canActivateChecks:[]}){let i=e.value,s=t?t.value:null,a=n?n.getContext(e.value.outlet):null;if(s&&i.routeConfig===s.routeConfig){let c=fI(s,i,i.routeConfig.runGuardsAndResolvers);c?o.canActivateChecks.push(new Ls(r)):(i.data=s.data,i._resolvedData=s._resolvedData),i.component?Eo(e,t,a?a.children:null,r,o):Eo(e,t,n,r,o),c&&a&&a.outlet&&a.outlet.isActivated&&o.canDeactivateChecks.push(new hr(a.outlet.component,s))}else s&&Io(t,a,o),o.canActivateChecks.push(new Ls(r)),i.component?Eo(e,null,a?a.children:null,r,o):Eo(e,null,n,r,o);return o}function fI(e,t,n){if(typeof n=="function")return n(e,t);switch(n){case"pathParamsChange":return!Dn(e.url,t.url);case"pathParamsOrQueryParamsChange":return!Dn(e.url,t.url)||!tt(e.queryParams,t.queryParams);case"always":return!0;case"paramsOrQueryParamsChange":return!su(e,t)||!tt(e.queryParams,t.queryParams);case"paramsChange":default:return!su(e,t)}}function Io(e,t,n){let r=dr(e),o=e.value;Object.entries(r).forEach(([i,s])=>{o.component?t?Io(s,t.children.getContext(i),n):Io(s,null,n):Io(s,t,n)}),o.component?t&&t.outlet&&t.outlet.isActivated?n.canDeactivateChecks.push(new hr(t.outlet.component,o)):n.canDeactivateChecks.push(new hr(null,o)):n.canDeactivateChecks.push(new hr(null,o))}function Ro(e){return typeof e=="function"}function pI(e){return typeof e=="boolean"}function hI(e){return e&&Ro(e.canLoad)}function gI(e){return e&&Ro(e.canActivate)}function mI(e){return e&&Ro(e.canActivateChild)}function vI(e){return e&&Ro(e.canDeactivate)}function yI(e){return e&&Ro(e.canMatch)}function mg(e){return e instanceof ot||e?.name==="EmptyError"}var Ss=Symbol("INITIAL_VALUE");function Dr(){return Ee(e=>si(e.map(t=>t.pipe(it(1),va(Ss)))).pipe(B(t=>{for(let n of t)if(n!==!0){if(n===Ss)return Ss;if(n===!1||EI(n))return n}return!0}),Ae(t=>t!==Ss),it(1)))}function EI(e){return vr(e)||e instanceof To}function DI(e,t){return Z(n=>{let{targetSnapshot:r,currentSnapshot:o,guards:{canActivateChecks:i,canDeactivateChecks:s}}=n;return s.length===0&&i.length===0?w(P(y({},n),{guardsResult:!0})):CI(s,r,o,e).pipe(Z(a=>a&&pI(a)?II(r,i,e,t):w(a)),B(a=>P(y({},n),{guardsResult:a})))})}function CI(e,t,n,r){return Y(e).pipe(Z(o=>TI(o.component,o.route,n,t,r)),st(o=>o!==!0,!0))}function II(e,t,n,r){return Y(t).pipe(jn(o=>Fn(bI(o.route.parent,r),SI(o.route,r),wI(e,o.path,n),_I(e,o.route,n))),st(o=>o!==!0,!0))}function SI(e,t){return e!==null&&t&&t(new tu(e)),w(!0)}function bI(e,t){return e!==null&&t&&t(new Jl(e)),w(!0)}function _I(e,t,n){let r=t.routeConfig?t.routeConfig.canActivate:null;if(!r||r.length===0)return w(!0);let o=r.map(i=>Nr(()=>{let s=Cr(t)??n,a=Ir(i,s),c=gI(a)?a.canActivate(t,e):G(s,()=>a(t,e));return wt(c).pipe(st())}));return w(o).pipe(Dr())}function wI(e,t,n){let r=t[t.length-1],i=t.slice(0,t.length-1).reverse().map(s=>uI(s)).filter(s=>s!==null).map(s=>Nr(()=>{let a=s.guards.map(c=>{let l=Cr(s.node)??n,u=Ir(c,l),f=mI(u)?u.canActivateChild(r,e):G(l,()=>u(r,e));return wt(f).pipe(st())});return w(a).pipe(Dr())}));return w(i).pipe(Dr())}function TI(e,t,n,r,o){let i=t&&t.routeConfig?t.routeConfig.canDeactivate:null;if(!i||i.length===0)return w(!0);let s=i.map(a=>{let c=Cr(t)??o,l=Ir(a,c),u=vI(l)?l.canDeactivate(e,t,n,r):G(c,()=>l(e,t,n,r));return wt(u).pipe(st())});return w(s).pipe(Dr())}function MI(e,t,n,r){let o=t.canLoad;if(o===void 0||o.length===0)return w(!0);let i=o.map(s=>{let a=Ir(s,e),c=hI(a)?a.canLoad(t,n):G(e,()=>a(t,n));return wt(c)});return w(i).pipe(Dr(),vg(r))}function vg(e){return ua(ee(t=>{if(typeof t!="boolean")throw ks(e,t)}),B(t=>t===!0))}function xI(e,t,n,r){let o=t.canMatch;if(!o||o.length===0)return w(!0);let i=o.map(s=>{let a=Ir(s,e),c=yI(a)?a.canMatch(t,n):G(e,()=>a(t,n));return wt(c)});return w(i).pipe(Dr(),vg(r))}var Mo=class{segmentGroup;constructor(t){this.segmentGroup=t||null}},xo=class extends Error{urlTree;constructor(t){super(),this.urlTree=t}};function ur(e){return Ln(new Mo(e))}function NI(e){return Ln(new C(4e3,!1))}function AI(e){return Ln(hg(!1,Ie.GuardRejected))}var lu=class{urlSerializer;urlTree;constructor(t,n){this.urlSerializer=t,this.urlTree=n}lineralizeSegments(t,n){let r=[],o=n.root;for(;;){if(r=r.concat(o.segments),o.numberOfChildren===0)return w(r);if(o.numberOfChildren>1||!o.children[A])return NI(`${t.redirectTo}`);o=o.children[A]}}applyRedirectCommands(t,n,r,o,i){return RI(n,o,i).pipe(B(s=>{if(s instanceof _t)throw new xo(s);let a=this.applyRedirectCreateUrlTree(s,this.urlSerializer.parse(s),t,r);if(s[0]==="/")throw new xo(a);return a}))}applyRedirectCreateUrlTree(t,n,r,o){let i=this.createSegmentGroup(t,n.root,r,o);return new _t(i,this.createQueryParams(n.queryParams,this.urlTree.queryParams),n.fragment)}createQueryParams(t,n){let r={};return Object.entries(t).forEach(([o,i])=>{if(typeof i=="string"&&i[0]===":"){let a=i.substring(1);r[o]=n[a]}else r[o]=i}),r}createSegmentGroup(t,n,r,o){let i=this.createSegments(t,n.segments,r,o),s={};return Object.entries(n.children).forEach(([a,c])=>{s[a]=this.createSegmentGroup(t,c,r,o)}),new $(i,s)}createSegments(t,n,r,o){return n.map(i=>i.path[0]===":"?this.findPosParam(t,i,o):this.findOrReturn(i,r))}findPosParam(t,n,r){let o=r[n.path.substring(1)];if(!o)throw new C(4001,!1);return o}findOrReturn(t,n){let r=0;for(let o of n){if(o.path===t.path)return n.splice(r),o;r++}return t}};function RI(e,t,n){if(typeof e=="string")return w(e);let r=e,{queryParams:o,fragment:i,routeConfig:s,url:a,outlet:c,params:l,data:u,title:f}=t;return wt(G(n,()=>r({params:l,data:u,queryParams:o,fragment:i,routeConfig:s,url:a,outlet:c,title:f})))}var uu={matched:!1,consumedSegments:[],remainingSegments:[],parameters:{},positionalParamSegments:{}};function OI(e,t,n,r,o){let i=yg(e,t,n);return i.matched?(r=eI(t,r),xI(r,t,n,o).pipe(B(s=>s===!0?i:y({},uu)))):w(i)}function yg(e,t,n){if(t.path==="**")return PI(n);if(t.path==="")return t.pathMatch==="full"&&(e.hasChildren()||n.length>0)?y({},uu):{matched:!0,consumedSegments:[],remainingSegments:n,parameters:{},positionalParamSegments:{}};let o=(t.matcher||TC)(n,e,t);if(!o)return y({},uu);let i={};Object.entries(o.posParams??{}).forEach(([a,c])=>{i[a]=c.path});let s=o.consumed.length>0?y(y({},i),o.consumed[o.consumed.length-1].parameters):i;return{matched:!0,consumedSegments:o.consumed,remainingSegments:n.slice(o.consumed.length),parameters:s,positionalParamSegments:o.posParams??{}}}function PI(e){return{matched:!0,parameters:e.length>0?Zh(e).parameters:{},consumedSegments:e,remainingSegments:[],positionalParamSegments:{}}}function zh(e,t,n,r){return n.length>0&&FI(e,n,r)?{segmentGroup:new $(t,LI(r,new $(n,e.children))),slicedSegments:[]}:n.length===0&&jI(e,n,r)?{segmentGroup:new $(e.segments,kI(e,n,r,e.children)),slicedSegments:n}:{segmentGroup:new $(e.segments,e.children),slicedSegments:n}}function kI(e,t,n,r){let o={};for(let i of n)if(Hs(e,t,i)&&!r[ze(i)]){let s=new $([],{});o[ze(i)]=s}return y(y({},r),o)}function LI(e,t){let n={};n[A]=t;for(let r of e)if(r.path===""&&ze(r)!==A){let o=new $([],{});n[ze(r)]=o}return n}function FI(e,t,n){return n.some(r=>Hs(e,t,r)&&ze(r)!==A)}function jI(e,t,n){return n.some(r=>Hs(e,t,r))}function Hs(e,t,n){return(e.hasChildren()||t.length>0)&&n.pathMatch==="full"?!1:n.path===""}function HI(e,t,n){return t.length===0&&!e.children[n]}var du=class{};function BI(e,t,n,r,o,i,s="emptyOnly"){return new fu(e,t,n,r,o,s,i).recognize()}var VI=31,fu=class{injector;configLoader;rootComponentType;config;urlTree;paramsInheritanceStrategy;urlSerializer;applyRedirects;absoluteRedirectCount=0;allowRedirects=!0;constructor(t,n,r,o,i,s,a){this.injector=t,this.configLoader=n,this.rootComponentType=r,this.config=o,this.urlTree=i,this.paramsInheritanceStrategy=s,this.urlSerializer=a,this.applyRedirects=new lu(this.urlSerializer,this.urlTree)}noMatchError(t){return new C(4002,`'${t.segmentGroup}'`)}recognize(){let t=zh(this.urlTree.root,[],[],this.config).segmentGroup;return this.match(t).pipe(B(({children:n,rootSnapshot:r})=>{let o=new xe(r,n),i=new Ps("",o),s=WC(r,[],this.urlTree.queryParams,this.urlTree.fragment);return s.queryParams=this.urlTree.queryParams,i.url=this.urlSerializer.serialize(s),{state:i,tree:s}}))}match(t){let n=new pr([],Object.freeze({}),Object.freeze(y({},this.urlTree.queryParams)),this.urlTree.fragment,Object.freeze({}),A,this.rootComponentType,null,{});return this.processSegmentGroup(this.injector,this.config,t,A,n).pipe(B(r=>({children:r,rootSnapshot:n})),xt(r=>{if(r instanceof xo)return this.urlTree=r.urlTree,this.match(r.urlTree.root);throw r instanceof Mo?this.noMatchError(r):r}))}processSegmentGroup(t,n,r,o,i){return r.segments.length===0&&r.hasChildren()?this.processChildren(t,n,r,i):this.processSegment(t,n,r,r.segments,o,!0,i).pipe(B(s=>s instanceof xe?[s]:[]))}processChildren(t,n,r,o){let i=[];for(let s of Object.keys(r.children))s==="primary"?i.unshift(s):i.push(s);return Y(i).pipe(jn(s=>{let a=r.children[s],c=tI(n,s);return this.processSegmentGroup(t,c,a,s,o)}),ma((s,a)=>(s.push(...a),s)),Nt(null),ga(),Z(s=>{if(s===null)return ur(r);let a=Eg(s);return UI(a),w(a)}))}processSegment(t,n,r,o,i,s,a){return Y(n).pipe(jn(c=>this.processSegmentAgainstRoute(c._injector??t,n,c,r,o,i,s,a).pipe(xt(l=>{if(l instanceof Mo)return w(null);throw l}))),st(c=>!!c),xt(c=>{if(mg(c))return HI(r,o,i)?w(new du):ur(r);throw c}))}processSegmentAgainstRoute(t,n,r,o,i,s,a,c){return ze(r)!==s&&(s===A||!Hs(o,i,r))?ur(o):r.redirectTo===void 0?this.matchSegmentAgainstRoute(t,o,r,i,s,c):this.allowRedirects&&a?this.expandSegmentAgainstRouteUsingRedirect(t,o,n,r,i,s,c):ur(o)}expandSegmentAgainstRouteUsingRedirect(t,n,r,o,i,s,a){let{matched:c,parameters:l,consumedSegments:u,positionalParamSegments:f,remainingSegments:m}=yg(n,o,i);if(!c)return ur(n);typeof o.redirectTo=="string"&&o.redirectTo[0]==="/"&&(this.absoluteRedirectCount++,this.absoluteRedirectCount>VI&&(this.allowRedirects=!1));let h=new pr(i,l,Object.freeze(y({},this.urlTree.queryParams)),this.urlTree.fragment,Wh(o),ze(o),o.component??o._loadedComponent??null,o,qh(o)),E=Os(h,a,this.paramsInheritanceStrategy);return h.params=Object.freeze(E.params),h.data=Object.freeze(E.data),this.applyRedirects.applyRedirectCommands(u,o.redirectTo,f,h,t).pipe(Ee(O=>this.applyRedirects.lineralizeSegments(o,O)),Z(O=>this.processSegment(t,r,n,O.concat(m),s,!1,a)))}matchSegmentAgainstRoute(t,n,r,o,i,s){let a=OI(n,r,o,t,this.urlSerializer);return r.path==="**"&&(n.children={}),a.pipe(Ee(c=>c.matched?(t=r._injector??t,this.getChildConfig(t,r,o).pipe(Ee(({routes:l})=>{let u=r._loadedInjector??t,{parameters:f,consumedSegments:m,remainingSegments:h}=c,E=new pr(m,f,Object.freeze(y({},this.urlTree.queryParams)),this.urlTree.fragment,Wh(r),ze(r),r.component??r._loadedComponent??null,r,qh(r)),S=Os(E,s,this.paramsInheritanceStrategy);E.params=Object.freeze(S.params),E.data=Object.freeze(S.data);let{segmentGroup:O,slicedSegments:F}=zh(n,m,h,l);if(F.length===0&&O.hasChildren())return this.processChildren(u,l,O,E).pipe(B(We=>new xe(E,We)));if(l.length===0&&F.length===0)return w(new xe(E,[]));let In=ze(r)===i;return this.processSegment(u,l,O,F,In?A:i,!0,E).pipe(B(We=>new xe(E,We instanceof xe?[We]:[])))}))):ur(n)))}getChildConfig(t,n,r){return n.children?w({routes:n.children,injector:t}):n.loadChildren?n._loadedRoutes!==void 0?w({routes:n._loadedRoutes,injector:n._loadedInjector}):MI(t,n,r,this.urlSerializer).pipe(Z(o=>o?this.configLoader.loadChildren(t,n).pipe(ee(i=>{n._loadedRoutes=i.routes,n._loadedInjector=i.injector})):AI(n))):w({routes:[],injector:t})}};function UI(e){e.sort((t,n)=>t.value.outlet===A?-1:n.value.outlet===A?1:t.value.outlet.localeCompare(n.value.outlet))}function $I(e){let t=e.value.routeConfig;return t&&t.path===""}function Eg(e){let t=[],n=new Set;for(let r of e){if(!$I(r)){t.push(r);continue}let o=t.find(i=>r.value.routeConfig===i.value.routeConfig);o!==void 0?(o.children.push(...r.children),n.add(o)):t.push(r)}for(let r of n){let o=Eg(r.children);t.push(new xe(r.value,o))}return t.filter(r=>!n.has(r))}function Wh(e){return e.data||{}}function qh(e){return e.resolve||{}}function GI(e,t,n,r,o,i){return Z(s=>BI(e,t,n,r,s.extractedUrl,o,i).pipe(B(({state:a,tree:c})=>P(y({},s),{targetSnapshot:a,urlAfterRedirects:c}))))}function zI(e,t){return Z(n=>{let{targetSnapshot:r,guards:{canActivateChecks:o}}=n;if(!o.length)return w(n);let i=new Set(o.map(c=>c.route)),s=new Set;for(let c of i)if(!s.has(c))for(let l of Dg(c))s.add(l);let a=0;return Y(s).pipe(jn(c=>i.has(c)?WI(c,r,e,t):(c.data=Os(c,c.parent,e).resolve,w(void 0))),ee(()=>a++),Hn(1),Z(c=>a===s.size?w(n):ve))})}function Dg(e){let t=e.children.map(n=>Dg(n)).flat();return[e,...t]}function WI(e,t,n,r){let o=e.routeConfig,i=e._resolve;return o?.title!==void 0&&!ug(o)&&(i[No]=o.title),Nr(()=>(e.data=Os(e,e.parent,n).resolve,qI(i,e,t,r).pipe(B(s=>(e._resolvedData=s,e.data=y(y({},e.data),s),null)))))}function qI(e,t,n,r){let o=$l(e);if(o.length===0)return w({});let i={};return Y(o).pipe(Z(s=>YI(e[s],t,n,r).pipe(st(),ee(a=>{if(a instanceof To)throw ks(new mr,a);i[s]=a}))),Hn(1),B(()=>i),xt(s=>mg(s)?ve:Ln(s)))}function YI(e,t,n,r){let o=Cr(t)??r,i=Ir(e,o),s=i.resolve?i.resolve(t,n):G(o,()=>i(t,n));return wt(s)}function Vl(e){return Ee(t=>{let n=e(t);return n?Y(n).pipe(B(()=>t)):w(t)})}var Cg=(()=>{class e{buildTitle(n){let r,o=n.root;for(;o!==void 0;)r=this.getResolvedTitleForRoute(o)??r,o=o.children.find(i=>i.outlet===A);return r}getResolvedTitleForRoute(n){return n.data[No]}static \u0275fac=function(r){return new(r||e)};static \u0275prov=I({token:e,factory:()=>v(ZI),providedIn:"root"})}return e})(),ZI=(()=>{class e extends Cg{title;constructor(n){super(),this.title=n}updateTitle(n){let r=this.buildTitle(n);r!==void 0&&this.title.setTitle(r)}static \u0275fac=function(r){return new(r||e)(N(Bh))};static \u0275prov=I({token:e,factory:e.\u0275fac,providedIn:"root"})}return e})(),Bs=new b("",{providedIn:"root",factory:()=>({})}),Vs=new b(""),Ig=(()=>{class e{componentLoaders=new WeakMap;childrenLoaders=new WeakMap;onLoadStartListener;onLoadEndListener;compiler=v(El);loadComponent(n,r){if(this.componentLoaders.get(r))return this.componentLoaders.get(r);if(r._loadedComponent)return w(r._loadedComponent);this.onLoadStartListener&&this.onLoadStartListener(r);let o=wt(G(n,()=>r.loadComponent())).pipe(B(Sg),Ee(bg),ee(s=>{this.onLoadEndListener&&this.onLoadEndListener(r),r._loadedComponent=s}),Ar(()=>{this.componentLoaders.delete(r)})),i=new kn(o,()=>new X).pipe(Pn());return this.componentLoaders.set(r,i),i}loadChildren(n,r){if(this.childrenLoaders.get(r))return this.childrenLoaders.get(r);if(r._loadedRoutes)return w({routes:r._loadedRoutes,injector:r._loadedInjector});this.onLoadStartListener&&this.onLoadStartListener(r);let i=KI(r,this.compiler,n,this.onLoadEndListener).pipe(Ar(()=>{this.childrenLoaders.delete(r)})),s=new kn(i,()=>new X).pipe(Pn());return this.childrenLoaders.set(r,s),s}static \u0275fac=function(r){return new(r||e)};static \u0275prov=I({token:e,factory:e.\u0275fac,providedIn:"root"})}return e})();function KI(e,t,n,r){return wt(G(n,()=>e.loadChildren())).pipe(B(Sg),Ee(bg),Z(o=>o instanceof ds||Array.isArray(o)?w(o):Y(t.compileModuleAsync(o))),B(o=>{r&&r(e);let i,s,a=!1;return Array.isArray(o)?(s=o,a=!0):(i=o.create(n).injector,s=i.get(Vs,[],{optional:!0,self:!0}).flat()),{routes:s.map(gu),injector:i}}))}function QI(e){return e&&typeof e=="object"&&"default"in e}function Sg(e){return QI(e)?e.default:e}function bg(e){return w(e)}var mu=(()=>{class e{static \u0275fac=function(r){return new(r||e)};static \u0275prov=I({token:e,factory:()=>v(XI),providedIn:"root"})}return e})(),XI=(()=>{class e{shouldProcessUrl(n){return!0}extract(n){return n}merge(n,r){return n}static \u0275fac=function(r){return new(r||e)};static \u0275prov=I({token:e,factory:e.\u0275fac,providedIn:"root"})}return e})(),_g=new b("");var wg=new b(""),Tg=(()=>{class e{currentNavigation=k(null,{equal:()=>!1});currentTransition=null;lastSuccessfulNavigation=null;events=new X;transitionAbortWithErrorSubject=new X;configLoader=v(Ig);environmentInjector=v(K);destroyRef=v(gt);urlSerializer=v(Fs);rootContexts=v(Ao);location=v(lr);inputBindingEnabled=v(js,{optional:!0})!==null;titleStrategy=v(Cg);options=v(Bs,{optional:!0})||{};paramsInheritanceStrategy=this.options.paramsInheritanceStrategy||"emptyOnly";urlHandlingStrategy=v(mu);createViewTransition=v(_g,{optional:!0});navigationErrorHandler=v(wg,{optional:!0});navigationId=0;get hasRequestedNavigation(){return this.navigationId!==0}transitions;afterPreactivation=()=>w(void 0);rootComponentType=null;destroyed=!1;constructor(){let n=o=>this.events.next(new Ql(o)),r=o=>this.events.next(new Xl(o));this.configLoader.onLoadEndListener=r,this.configLoader.onLoadStartListener=n,this.destroyRef.onDestroy(()=>{this.destroyed=!0})}complete(){this.transitions?.complete()}handleNavigationRequest(n){let r=++this.navigationId;Ce(()=>{this.transitions?.next(P(y({},n),{extractedUrl:this.urlHandlingStrategy.extract(n.rawUrl),targetSnapshot:null,targetRouterState:null,guards:{canActivateChecks:[],canDeactivateChecks:[]},guardsResult:null,abortController:new AbortController,id:r}))})}setupNavigations(n){return this.transitions=new ie(null),this.transitions.pipe(Ae(r=>r!==null),Ee(r=>{let o=!1;return w(r).pipe(Ee(i=>{if(this.navigationId>r.id)return this.cancelNavigationTransition(r,"",Ie.SupersededByNewNavigation),ve;this.currentTransition=r,this.currentNavigation.set({id:i.id,initialUrl:i.rawUrl,extractedUrl:i.extractedUrl,targetBrowserUrl:typeof i.extras.browserUrl=="string"?this.urlSerializer.parse(i.extras.browserUrl):i.extras.browserUrl,trigger:i.source,extras:i.extras,previousNavigation:this.lastSuccessfulNavigation?P(y({},this.lastSuccessfulNavigation),{previousNavigation:null}):null,abort:()=>i.abortController.abort()});let s=!n.navigated||this.isUpdatingInternalState()||this.isUpdatedBrowserUrl(),a=i.extras.onSameUrlNavigation??n.onSameUrlNavigation;if(!s&&a!=="reload")return this.events.next(new Bt(i.id,this.urlSerializer.serialize(i.rawUrl),"",xs.IgnoredSameUrlNavigation)),i.resolve(!1),ve;if(this.urlHandlingStrategy.shouldProcessUrl(i.rawUrl))return w(i).pipe(Ee(c=>(this.events.next(new yr(c.id,this.urlSerializer.serialize(c.extractedUrl),c.source,c.restoredState)),c.id!==this.navigationId?ve:Promise.resolve(c))),GI(this.environmentInjector,this.configLoader,this.rootComponentType,n.config,this.urlSerializer,this.paramsInheritanceStrategy),ee(c=>{r.targetSnapshot=c.targetSnapshot,r.urlAfterRedirects=c.urlAfterRedirects,this.currentNavigation.update(u=>(u.finalUrl=c.urlAfterRedirects,u));let l=new Ns(c.id,this.urlSerializer.serialize(c.extractedUrl),this.urlSerializer.serialize(c.urlAfterRedirects),c.targetSnapshot);this.events.next(l)}));if(s&&this.urlHandlingStrategy.shouldProcessUrl(i.currentRawUrl)){let{id:c,extractedUrl:l,source:u,restoredState:f,extras:m}=i,h=new yr(c,this.urlSerializer.serialize(l),u,f);this.events.next(h);let E=cg(this.rootComponentType).snapshot;return this.currentTransition=r=P(y({},i),{targetSnapshot:E,urlAfterRedirects:l,extras:P(y({},m),{skipLocationChange:!1,replaceUrl:!1})}),this.currentNavigation.update(S=>(S.finalUrl=l,S)),w(r)}else return this.events.next(new Bt(i.id,this.urlSerializer.serialize(i.extractedUrl),"",xs.IgnoredByUrlHandlingStrategy)),i.resolve(!1),ve}),ee(i=>{let s=new ql(i.id,this.urlSerializer.serialize(i.extractedUrl),this.urlSerializer.serialize(i.urlAfterRedirects),i.targetSnapshot);this.events.next(s)}),B(i=>(this.currentTransition=r=P(y({},i),{guards:lI(i.targetSnapshot,i.currentSnapshot,this.rootContexts)}),r)),DI(this.environmentInjector,i=>this.events.next(i)),ee(i=>{if(r.guardsResult=i.guardsResult,i.guardsResult&&typeof i.guardsResult!="boolean")throw ks(this.urlSerializer,i.guardsResult);let s=new Yl(i.id,this.urlSerializer.serialize(i.extractedUrl),this.urlSerializer.serialize(i.urlAfterRedirects),i.targetSnapshot,!!i.guardsResult);this.events.next(s)}),Ae(i=>i.guardsResult?!0:(this.cancelNavigationTransition(i,"",Ie.GuardRejected),!1)),Vl(i=>{if(i.guards.canActivateChecks.length!==0)return w(i).pipe(ee(s=>{let a=new Zl(s.id,this.urlSerializer.serialize(s.extractedUrl),this.urlSerializer.serialize(s.urlAfterRedirects),s.targetSnapshot);this.events.next(a)}),Ee(s=>{let a=!1;return w(s).pipe(zI(this.paramsInheritanceStrategy,this.environmentInjector),ee({next:()=>a=!0,complete:()=>{a||this.cancelNavigationTransition(s,"",Ie.NoDataFromResolver)}}))}),ee(s=>{let a=new Kl(s.id,this.urlSerializer.serialize(s.extractedUrl),this.urlSerializer.serialize(s.urlAfterRedirects),s.targetSnapshot);this.events.next(a)}))}),Vl(i=>{let s=a=>{let c=[];if(a.routeConfig?.loadComponent){let l=Cr(a)??this.environmentInjector;c.push(this.configLoader.loadComponent(l,a.routeConfig).pipe(ee(u=>{a.component=u}),B(()=>{})))}for(let l of a.children)c.push(...s(l));return c};return si(s(i.targetSnapshot.root)).pipe(Nt(null),it(1))}),Vl(()=>this.afterPreactivation()),Ee(()=>{let{currentSnapshot:i,targetSnapshot:s}=r,a=this.createViewTransition?.(this.environmentInjector,i.root,s.root);return a?Y(a).pipe(B(()=>r)):w(r)}),B(i=>{let s=oI(n.routeReuseStrategy,i.targetSnapshot,i.currentRouterState);return this.currentTransition=r=P(y({},i),{targetRouterState:s}),this.currentNavigation.update(a=>(a.targetRouterState=s,a)),r}),ee(()=>{this.events.next(new _o)}),cI(this.rootContexts,n.routeReuseStrategy,i=>this.events.next(i),this.inputBindingEnabled),it(1),ci(new V(i=>{let s=r.abortController.signal,a=()=>i.next();return s.addEventListener("abort",a),()=>s.removeEventListener("abort",a)}).pipe(Ae(()=>!o&&!r.targetRouterState),ee(()=>{this.cancelNavigationTransition(r,r.abortController.signal.reason+"",Ie.Aborted)}))),ee({next:i=>{o=!0,this.lastSuccessfulNavigation=Ce(this.currentNavigation),this.events.next(new Ht(i.id,this.urlSerializer.serialize(i.extractedUrl),this.urlSerializer.serialize(i.urlAfterRedirects))),this.titleStrategy?.updateTitle(i.targetRouterState.snapshot),i.resolve(!0)},complete:()=>{o=!0}}),ci(this.transitionAbortWithErrorSubject.pipe(ee(i=>{throw i}))),Ar(()=>{o||this.cancelNavigationTransition(r,"",Ie.SupersededByNewNavigation),this.currentTransition?.id===r.id&&(this.currentNavigation.set(null),this.currentTransition=null)}),xt(i=>{if(this.destroyed)return r.resolve(!1),ve;if(o=!0,gg(i))this.events.next(new bt(r.id,this.urlSerializer.serialize(r.extractedUrl),i.message,i.cancellationCode)),aI(i)?this.events.next(new Er(i.url,i.navigationBehaviorOptions)):r.resolve(!1);else{let s=new bo(r.id,this.urlSerializer.serialize(r.extractedUrl),i,r.targetSnapshot??void 0);try{let a=G(this.environmentInjector,()=>this.navigationErrorHandler?.(s));if(a instanceof To){let{message:c,cancellationCode:l}=ks(this.urlSerializer,a);this.events.next(new bt(r.id,this.urlSerializer.serialize(r.extractedUrl),c,l)),this.events.next(new Er(a.redirectTo,a.navigationBehaviorOptions))}else throw this.events.next(s),i}catch(a){this.options.resolveNavigationPromiseOnError?r.resolve(!1):r.reject(a)}}return ve}))}))}cancelNavigationTransition(n,r,o){let i=new bt(n.id,this.urlSerializer.serialize(n.extractedUrl),r,o);this.events.next(i),n.resolve(!1)}isUpdatingInternalState(){return this.currentTransition?.extractedUrl.toString()!==this.currentTransition?.currentUrlTree.toString()}isUpdatedBrowserUrl(){let n=this.urlHandlingStrategy.extract(this.urlSerializer.parse(this.location.path(!0))),r=Ce(this.currentNavigation),o=r?.targetBrowserUrl??r?.extractedUrl;return n.toString()!==o?.toString()&&!r?.extras.skipLocationChange}static \u0275fac=function(r){return new(r||e)};static \u0275prov=I({token:e,factory:e.\u0275fac,providedIn:"root"})}return e})();function JI(e){return e!==Co}var eS=(()=>{class e{static \u0275fac=function(r){return new(r||e)};static \u0275prov=I({token:e,factory:()=>v(tS),providedIn:"root"})}return e})(),pu=class{shouldDetach(t){return!1}store(t,n){}shouldAttach(t){return!1}retrieve(t){return null}shouldReuseRoute(t,n){return t.routeConfig===n.routeConfig}},tS=(()=>{class e extends pu{static \u0275fac=(()=>{let n;return function(o){return(n||(n=ts(e)))(o||e)}})();static \u0275prov=I({token:e,factory:e.\u0275fac,providedIn:"root"})}return e})(),Mg=(()=>{class e{urlSerializer=v(Fs);options=v(Bs,{optional:!0})||{};canceledNavigationResolution=this.options.canceledNavigationResolution||"replace";location=v(lr);urlHandlingStrategy=v(mu);urlUpdateStrategy=this.options.urlUpdateStrategy||"deferred";currentUrlTree=new _t;getCurrentUrlTree(){return this.currentUrlTree}rawUrlTree=this.currentUrlTree;getRawUrlTree(){return this.rawUrlTree}createBrowserPath({finalUrl:n,initialUrl:r,targetBrowserUrl:o}){let i=n!==void 0?this.urlHandlingStrategy.merge(n,r):r,s=o??i;return s instanceof _t?this.urlSerializer.serialize(s):s}commitTransition({targetRouterState:n,finalUrl:r,initialUrl:o}){r&&n?(this.currentUrlTree=r,this.rawUrlTree=this.urlHandlingStrategy.merge(r,o),this.routerState=n):this.rawUrlTree=o}routerState=cg(null);getRouterState(){return this.routerState}stateMemento=this.createStateMemento();updateStateMemento(){this.stateMemento=this.createStateMemento()}createStateMemento(){return{rawUrlTree:this.rawUrlTree,currentUrlTree:this.currentUrlTree,routerState:this.routerState}}resetInternalState({finalUrl:n}){this.routerState=this.stateMemento.routerState,this.currentUrlTree=this.stateMemento.currentUrlTree,this.rawUrlTree=this.urlHandlingStrategy.merge(this.currentUrlTree,n??this.rawUrlTree)}static \u0275fac=function(r){return new(r||e)};static \u0275prov=I({token:e,factory:()=>v(nS),providedIn:"root"})}return e})(),nS=(()=>{class e extends Mg{currentPageId=0;lastSuccessfulId=-1;restoredState(){return this.location.getState()}get browserPageId(){return this.canceledNavigationResolution!=="computed"?this.currentPageId:this.restoredState()?.\u0275routerPageId??this.currentPageId}registerNonRouterCurrentEntryChangeListener(n){return this.location.subscribe(r=>{r.type==="popstate"&&setTimeout(()=>{n(r.url,r.state,"popstate")})})}handleRouterEvent(n,r){n instanceof yr?this.updateStateMemento():n instanceof Bt?this.commitTransition(r):n instanceof Ns?this.urlUpdateStrategy==="eager"&&(r.extras.skipLocationChange||this.setBrowserUrl(this.createBrowserPath(r),r)):n instanceof _o?(this.commitTransition(r),this.urlUpdateStrategy==="deferred"&&!r.extras.skipLocationChange&&this.setBrowserUrl(this.createBrowserPath(r),r)):n instanceof bt&&n.code!==Ie.SupersededByNewNavigation&&n.code!==Ie.Redirect?this.restoreHistory(r):n instanceof bo?this.restoreHistory(r,!0):n instanceof Ht&&(this.lastSuccessfulId=n.id,this.currentPageId=this.browserPageId)}setBrowserUrl(n,{extras:r,id:o}){let{replaceUrl:i,state:s}=r;if(this.location.isCurrentPathEqualTo(n)||i){let a=this.browserPageId,c=y(y({},s),this.generateNgRouterState(o,a));this.location.replaceState(n,"",c)}else{let a=y(y({},s),this.generateNgRouterState(o,this.browserPageId+1));this.location.go(n,"",a)}}restoreHistory(n,r=!1){if(this.canceledNavigationResolution==="computed"){let o=this.browserPageId,i=this.currentPageId-o;i!==0?this.location.historyGo(i):this.getCurrentUrlTree()===n.finalUrl&&i===0&&(this.resetInternalState(n),this.resetUrlToCurrentUrlTree())}else this.canceledNavigationResolution==="replace"&&(r&&this.resetInternalState(n),this.resetUrlToCurrentUrlTree())}resetUrlToCurrentUrlTree(){this.location.replaceState(this.urlSerializer.serialize(this.getRawUrlTree()),"",this.generateNgRouterState(this.lastSuccessfulId,this.currentPageId))}generateNgRouterState(n,r){return this.canceledNavigationResolution==="computed"?{navigationId:n,\u0275routerPageId:r}:{navigationId:n}}static \u0275fac=(()=>{let n;return function(o){return(n||(n=ts(e)))(o||e)}})();static \u0275prov=I({token:e,factory:e.\u0275fac,providedIn:"root"})}return e})();function xg(e,t){e.events.pipe(Ae(n=>n instanceof Ht||n instanceof bt||n instanceof bo||n instanceof Bt),B(n=>n instanceof Ht||n instanceof Bt?0:(n instanceof bt?n.code===Ie.Redirect||n.code===Ie.SupersededByNewNavigation:!1)?2:1),Ae(n=>n!==2),it(1)).subscribe(()=>{t()})}var rS={paths:"exact",fragment:"ignored",matrixParams:"ignored",queryParams:"exact"},oS={paths:"subset",fragment:"ignored",matrixParams:"ignored",queryParams:"subset"},vu=(()=>{class e{get currentUrlTree(){return this.stateManager.getCurrentUrlTree()}get rawUrlTree(){return this.stateManager.getRawUrlTree()}disposed=!1;nonRouterCurrentEntryChangeSubscription;console=v(ul);stateManager=v(Mg);options=v(Bs,{optional:!0})||{};pendingTasks=v(mt);urlUpdateStrategy=this.options.urlUpdateStrategy||"deferred";navigationTransitions=v(Tg);urlSerializer=v(Fs);location=v(lr);urlHandlingStrategy=v(mu);injector=v(K);_events=new X;get events(){return this._events}get routerState(){return this.stateManager.getRouterState()}navigated=!1;routeReuseStrategy=v(eS);onSameUrlNavigation=this.options.onSameUrlNavigation||"ignore";config=v(Vs,{optional:!0})?.flat()??[];componentInputBindingEnabled=!!v(js,{optional:!0});currentNavigation=this.navigationTransitions.currentNavigation.asReadonly();constructor(){this.resetConfig(this.config),this.navigationTransitions.setupNavigations(this).subscribe({error:n=>{this.console.warn(n)}}),this.subscribeToNavigationEvents()}eventsSubscription=new q;subscribeToNavigationEvents(){let n=this.navigationTransitions.events.subscribe(r=>{try{let o=this.navigationTransitions.currentTransition,i=Ce(this.navigationTransitions.currentNavigation);if(o!==null&&i!==null){if(this.stateManager.handleRouterEvent(r,i),r instanceof bt&&r.code!==Ie.Redirect&&r.code!==Ie.SupersededByNewNavigation)this.navigated=!0;else if(r instanceof Ht)this.navigated=!0;else if(r instanceof Er){let s=r.navigationBehaviorOptions,a=this.urlHandlingStrategy.merge(r.url,o.currentRawUrl),c=y({browserUrl:o.extras.browserUrl,info:o.extras.info,skipLocationChange:o.extras.skipLocationChange,replaceUrl:o.extras.replaceUrl||this.urlUpdateStrategy==="eager"||JI(o.source)},s);this.scheduleNavigation(a,Co,null,c,{resolve:o.resolve,reject:o.reject,promise:o.promise})}}JC(r)&&this._events.next(r)}catch(o){this.navigationTransitions.transitionAbortWithErrorSubject.next(o)}});this.eventsSubscription.add(n)}resetRootComponentType(n){this.routerState.root.component=n,this.navigationTransitions.rootComponentType=n}initialNavigation(){this.setUpLocationChangeListener(),this.navigationTransitions.hasRequestedNavigation||this.navigateToSyncWithBrowser(this.location.path(!0),Co,this.stateManager.restoredState())}setUpLocationChangeListener(){this.nonRouterCurrentEntryChangeSubscription??=this.stateManager.registerNonRouterCurrentEntryChangeListener((n,r,o)=>{this.navigateToSyncWithBrowser(n,o,r)})}navigateToSyncWithBrowser(n,r,o){let i={replaceUrl:!0},s=o?.navigationId?o:null;if(o){let c=y({},o);delete c.navigationId,delete c.\u0275routerPageId,Object.keys(c).length!==0&&(i.state=c)}let a=this.parseUrl(n);this.scheduleNavigation(a,r,s,i).catch(c=>{this.disposed||this.injector.get(Fe)(c)})}get url(){return this.serializeUrl(this.currentUrlTree)}getCurrentNavigation(){return Ce(this.navigationTransitions.currentNavigation)}get lastSuccessfulNavigation(){return this.navigationTransitions.lastSuccessfulNavigation}resetConfig(n){this.config=n.map(gu),this.navigated=!1}ngOnDestroy(){this.dispose()}dispose(){this._events.unsubscribe(),this.navigationTransitions.complete(),this.nonRouterCurrentEntryChangeSubscription&&(this.nonRouterCurrentEntryChangeSubscription.unsubscribe(),this.nonRouterCurrentEntryChangeSubscription=void 0),this.disposed=!0,this.eventsSubscription.unsubscribe()}createUrlTree(n,r={}){let{relativeTo:o,queryParams:i,fragment:s,queryParamsHandling:a,preserveFragment:c}=r,l=c?this.currentUrlTree.fragment:s,u=null;switch(a??this.options.defaultQueryParamsHandling){case"merge":u=y(y({},this.currentUrlTree.queryParams),i);break;case"preserve":u=this.currentUrlTree.queryParams;break;default:u=i||null}u!==null&&(u=this.removeEmptyProps(u));let f;try{let m=o?o.snapshot:this.routerState.snapshot.root;f=og(m)}catch{(typeof n[0]!="string"||n[0][0]!=="/")&&(n=[]),f=this.currentUrlTree.root}return ig(f,n,u,l??null)}navigateByUrl(n,r={skipLocationChange:!1}){let o=vr(n)?n:this.parseUrl(n),i=this.urlHandlingStrategy.merge(o,this.rawUrlTree);return this.scheduleNavigation(i,Co,null,r)}navigate(n,r={skipLocationChange:!1}){return iS(n),this.navigateByUrl(this.createUrlTree(n,r),r)}serializeUrl(n){return this.urlSerializer.serialize(n)}parseUrl(n){try{return this.urlSerializer.parse(n)}catch{return this.urlSerializer.parse("/")}}isActive(n,r){let o;if(r===!0?o=y({},rS):r===!1?o=y({},oS):o=r,vr(n))return Vh(this.currentUrlTree,n,o);let i=this.parseUrl(n);return Vh(this.currentUrlTree,i,o)}removeEmptyProps(n){return Object.entries(n).reduce((r,[o,i])=>(i!=null&&(r[o]=i),r),{})}scheduleNavigation(n,r,o,i,s){if(this.disposed)return Promise.resolve(!1);let a,c,l;s?(a=s.resolve,c=s.reject,l=s.promise):l=new Promise((f,m)=>{a=f,c=m});let u=this.pendingTasks.add();return xg(this,()=>{queueMicrotask(()=>this.pendingTasks.remove(u))}),this.navigationTransitions.handleNavigationRequest({source:r,restoredState:o,currentUrlTree:this.currentUrlTree,currentRawUrl:this.currentUrlTree,rawUrl:n,extras:i,resolve:a,reject:c,promise:l,currentSnapshot:this.routerState.snapshot,currentRouterState:this.routerState}),l.catch(f=>Promise.reject(f))}static \u0275fac=function(r){return new(r||e)};static \u0275prov=I({token:e,factory:e.\u0275fac,providedIn:"root"})}return e})();function iS(e){for(let t=0;tn.\u0275providers)])}function aS(e){return e.routerState.root}function cS(){let e=v(Re);return t=>{let n=e.get(yn);if(t!==n.components[0])return;let r=e.get(vu),o=e.get(lS);e.get(uS)===1&&r.initialNavigation(),e.get(dS,null,{optional:!0})?.setUpPreloading(),e.get(sS,null,{optional:!0})?.init(),r.resetRootComponentType(n.componentTypes[0]),o.closed||(o.next(),o.complete(),o.unsubscribe())}}var lS=new b("",{factory:()=>new X}),uS=new b("",{providedIn:"root",factory:()=>1});var dS=new b("");var Ng=[];var Ag={providers:[uc(),Cl(),yu(Ng)]};function Rg(e){switch(e){case"SEED_SUNFLOWSER":return"SUNFLOWER";case"SEED_DAISY":return"DAISY";case"SEED_ROSE":return"ROSE";case"SEED_GALLERY":return"GALLERY";default:return"SUNFLOWER"}}var D=class e{static FARM_TOP_LEFT={x:0,y:0};static FARM_TOP_MIDDLE={x:1,y:0};static FARM_TOP_RIGHT={x:2,y:0};static FARM_MID_LEFT={x:0,y:1};static FARM_SOIL={x:1,y:1};static FARM_MID_RIGHT={x:2,y:1};static FARM_BOTTOM_LEFT={x:0,y:2};static FARM_BOTTOM_MIDDLE={x:1,y:2};static FARM_BOTTOM_RIGHT={x:2,y:2};static FARM_SLOT_DRY={x:3,y:1};static FARM_SLOT_WET={x:3,y:2};static INVENTORY_SLOT={x:8,y:0,width:17,height:17};static BUSH={x:4,y:1};static MUSHROOM={x:4,y:2};static LOCK={x:5,y:1};static KEY={x:6,y:1};static POTS={x:4,y:2};static SEED_SUNFLOWSER={x:0,y:3};static SEED_DAISY={x:1,y:3};static SEED_ROSE={x:2,y:3};static SEED_GALLERY={x:3,y:3};static SEED_DAISY_LOCKED={x:1,y:4};static SEED_ROSE_LOCKED={x:2,y:4};static SEED_GALLERY_LOCKED={x:3,y:4};static DAISY_LOCK={x:5,y:0};static ROSE_LOCK={x:6,y:0};static GALLERY_LOCK={x:7,y:0};static HELP_ICON={x:7,y:1};static FOUNTAIN_ANIM=[{x:0,y:4},{x:2,y:4},{x:4,y:4},{x:6,y:4},{x:8,y:4},{x:10,y:4}];static CHICKEN_PECK_RIGHT_ANIM=[{x:0,y:6},{x:1,y:6},{x:2,y:6},{x:3,y:6},{x:4,y:6},{x:5,y:6},{x:6,y:6},{x:7,y:6}];static CHICKEN_PECK_DOWN_ANIM=[{x:0,y:8},{x:1,y:8},{x:2,y:8},{x:3,y:8},{x:4,y:8},{x:5,y:8},{x:6,y:8},{x:7,y:8}];static CHICKEN_PECK_LEFT_ANIM=[{x:0,y:9},{x:1,y:9},{x:2,y:9},{x:3,y:9},{x:4,y:9},{x:5,y:9},{x:6,y:9},{x:7,y:9}];static CHICKEN_PECK_TOP_ANIM=[{x:0,y:10},{x:1,y:10},{x:2,y:10},{x:3,y:10},{x:4,y:10},{x:5,y:10},{x:6,y:10},{x:7,y:10}];static CHICKEN_IDLE_ANIM=[{x:0,y:22},{x:1,y:22},{x:2,y:22},{x:3,y:22},{x:4,y:22},{x:5,y:22},{x:6,y:22},{x:7,y:22}];static CHICKEN_DANCE_ANIM=[{x:0,y:23},{x:1,y:23},{x:2,y:23},{x:3,y:23},{x:4,y:23},{x:5,y:23},{x:6,y:23},{x:7,y:23}];static WATER_CAN_ANIM=[{x:0,y:7},{x:1,y:7},{x:2,y:7},{x:3,y:7},{x:4,y:7},{x:5,y:7},{x:6,y:7},{x:7,y:7},{x:8,y:7}];static SCYTHE_ANIM=[{x:4,y:3},{x:4,y:3},{x:5,y:3,offsetX:-1,offsetY:1},{x:6,y:3,offsetX:-2,offsetY:3},{x:7,y:3,offsetX:-2,offsetY:3}];static SMALL_BUSHES=[{x:0,y:5},{x:1,y:5},{x:2,y:5},{x:3,y:5},{x:4,y:5},{x:5,y:5},{x:6,y:5},{x:7,y:5}];static HIGHLIGHTER_ANIM=[{x:4,y:4},{x:5,y:4},{x:6,y:4},{x:7,y:4},{x:8,y:4}];static PLANT_SUNFLOWER_PHASES=[{x:0,y:11},{x:0,y:12},{x:0,y:13,offsetY:-2},{x:0,y:14,offsetY:-4},{x:0,y:15,height:32,offsetY:-20}];static PLANT_DAISY_PHASES=[{x:1,y:11},{x:1,y:12},{x:1,y:13,offsetY:-2},{x:1,y:14,offsetY:-4},{x:1,y:15,height:32,offsetY:-20}];static PLANT_ROSE_PHASES=[{x:2,y:11},{x:2,y:12},{x:2,y:13,offsetY:-2},{x:2,y:14,offsetY:-4},{x:2,y:15,height:32,offsetY:-20}];static PLANT_GALLERY_PHASES=[{x:3,y:11},{x:3,y:12},{x:3,y:13,offsetY:-2},{x:3,y:14,offsetY:-4},{x:3,y:15,height:32,offsetY:-20}];static HARVEST_SUNFLOWER={x:0,y:17};static HARVEST_DAISY={x:1,y:17};static HARVEST_ROSE={x:2,y:17};static BUBBLE_TILES=[{x:0,y:18},{x:1,y:18},{x:2,y:18},{x:0,y:19},{x:1,y:19},{x:2,y:19},{x:0,y:20},{x:1,y:20},{x:2,y:20}];static DIGITS=[{x:0,y:21},{x:1,y:21},{x:2,y:21},{x:3,y:21},{x:4,y:21},{x:5,y:21},{x:6,y:21},{x:7,y:21},{x:8,y:21}];static GALLERY_FRUITS=[{x:0,y:24},{x:1,y:24},{x:2,y:24},{x:3,y:24}];static getAtlasTileFromObj(t){switch(t.type){case"SEED_SUNFLOWSER":return e.SEED_SUNFLOWSER;case"SEED_DAISY":return t.locked?e.SEED_DAISY_LOCKED:e.SEED_DAISY;case"SEED_ROSE":return t.locked?e.SEED_ROSE_LOCKED:e.SEED_ROSE;case"SEED_GALLERY":return t.locked?e.SEED_GALLERY_LOCKED:e.SEED_GALLERY;case"WATER_CAN":return e.WATER_CAN_ANIM[0];case"HARVEST":return e.SCYTHE_ANIM[0]}}static getLockAtlasTileFromObj(t){switch(t.type){case"SEED_DAISY":return e.DAISY_LOCK;case"SEED_ROSE":return e.ROSE_LOCK;case"SEED_GALLERY":return e.GALLERY_LOCK;default:return{x:-100,y:-100}}}static getPlantTilesFromPlant(t){switch(t.type){case"SUNFLOWER":return e.PLANT_SUNFLOWER_PHASES[t.phase];case"DAISY":return e.PLANT_DAISY_PHASES[t.phase];case"ROSE":return e.PLANT_ROSE_PHASES[t.phase];case"GALLERY":return e.PLANT_GALLERY_PHASES[t.phase]}}static getHarvestTileFromPlant(t){switch(t.type){case"SUNFLOWER":return e.HARVEST_SUNFLOWER;case"DAISY":return e.HARVEST_DAISY;case"ROSE":return e.HARVEST_ROSE}return{x:-100,y:-100}}};var pe=class{baseTiles=k([]);pos=k({x:0,y:0});size=k({width:0,height:0});visible=k(!0);tiles=lo(()=>this.genTiles());constructor(t,n,r,o){this.pos.set({x:t,y:n}),this.size.set({width:r,height:o}),this.init()}init(){}setVisible(t){this.visible.set(t)}moveTo(t,n){this.pos.set({x:t,y:n})}setWidth(t){this.size.update(n=>P(y({},n),{width:t}))}setHeight(t){this.size.update(n=>P(y({},n),{height:t}))}get x(){return this.pos().x}get y(){return this.pos().y}get width(){return this.size().width}get height(){return this.size().height}};var Ne=150,Vt=class e{timeTick=k(0);countTick=k(0);lastTs=0;constructor(){this.tick()}tick(){let t=Date.now();t-this.lastTs>Ne&&(this.timeTick.set(t),this.countTick.update(n=>n+1),this.lastTs=t),requestAnimationFrame(()=>this.tick())}static \u0275fac=function(n){return new(n||e)};static \u0275prov=I({token:e,factory:e.\u0275fac,providedIn:"root"})};function kg(e){for(let t=e.length-1;t>0;t--){let n=Math.floor(Math.random()*(t+1));[e[t],e[n]]=[e[n],e[t]]}return e}var Lg=5,Us=class extends pe{slotStates=k({0:0,1:0,2:0,3:0,4:0,5:0,6:0,7:0,8:0});plants=k({});slotPos={};shuffledGalleryFruits={};showHelp=k(!0);tickService=v(Vt);constructor(t,n,r,o){super(t,n,r,o),this.initFarm();for(let i=0;i<9;i++){let s=kg([0,1,2,3]);this.shuffledGalleryFruits[i]=s}It(()=>{let i=this.tickService.countTick();Ce(()=>{let s=this.plants();for(let a of Object.keys(s)){let c=s[Number(a)];if(c.phase>=0&&c.phase<=2&&c.growStartTick2==null){if(c.growStartTick1==null)continue;this.setPlantPhase(Number(a),Math.min(2,Math.floor((i-c.growStartTick1)/Lg)))}else if(c.phase>=2&&c.phase<=4&&c.growStartTick1!=null){if(c.growStartTick2==null)continue;this.setPlantPhase(Number(a),2+Math.min(2,Math.floor((i-c.growStartTick2)/Lg)))}(c.phase===2&&c.growStartTick2==null||c.phase===4&&!c.grown)&&(this.setSlotWatered(Number(a),!1),c.phase===4&&(c.grown=!0))}})})}initFarm(){let t=this.x,n=this.y,r=this.width,o=this.height,i=[];var s=0;for(let l=0;l{let o=y({},r);return o[t]=n?1:0,o}),n&&this.plants()[t]){let r=this.plants()[t];r.phase===0?this.plants.update(o=>{let i=y({},o);return i[t]&&(i[t].growStartTick1=this.tickService.countTick()),i}):r.phase===2&&this.plants.update(o=>{let i=y({},o);return i[t]&&(i[t].growStartTick2=this.tickService.countTick()),i})}}isSlotWatered(t){return this.slotStates()[t]===1}addPlant(t,n){let r={type:n,phase:0};this.isSlotWatered(t)&&(r.growStartTick1=this.tickService.countTick()),this.plants.update(o=>{let i=y({},o);return i[t]=r,i})}removePlant(t){this.plants.update(n=>{let r=y({},n);return delete r[t],r})}setPlantPhase(t,n){this.plants.update(r=>{if(r[t].phase===n)return r;let o=y({},r);return o[t].phase=n,o})}setHelpVisible(t){this.showHelp.set(t)}};var Eu={INVENTORY:"inventory.mp3",ERROR:"error.mp3",CLOSE:"close.mp3",WATERING:"watering.mp3",WHIP:"whip.mp3",DIRT:"dirt.mp3",CHECK_STATUS:"check_status.mp3",PATTERN_GOAL:"goal.mp3",GALLERY_GOAL:"goal_gallery.mp3",PLOP:"plop.mp3",PLOP2:"plop2.mp3",PLOP3:"plop3.mp3"},Se=class e{game;audios={};constructor(){}async preloadSounds(){let t=Object.keys(Eu).map(n=>new Promise((r,o)=>{let i=Eu[n],s=new Audio(i);s.volume=0,s.play().then(()=>{s.pause(),s.currentTime=0,s.volume=1,this.audios[n]=s,r(null)}).catch(a=>{console.error(`Error loading or pre-playing sound '${i}':`,a),o(a)})}));try{await Promise.all(t),console.log("All sounds loaded successfully.")}catch(n){console.error("Error loading sounds:",`${n}`)}}playAudio(t){let n=Eu[t];if(!n)return;new Audio(n).play().catch(o=>{console.error(`Failed to play sound '${n}':`,`${o}`)})}static \u0275fac=function(n){return new(n||e)};static \u0275prov=I({token:e,factory:e.\u0275fac,providedIn:"root"})};var $s=class extends pe{constructor(n,r,o){super(n,r,o*16,16);this.slotsCount=o;this.initInventory()}inventory=k({SEED_SUNFLOWSER:{type:"SEED_SUNFLOWSER",count:0,slotIndex:0},SEED_DAISY:{type:"SEED_DAISY",count:0,slotIndex:1,locked:!0},SEED_ROSE:{type:"SEED_ROSE",count:0,slotIndex:2,locked:!0},SEED_GALLERY:{type:"SEED_GALLERY",count:0,slotIndex:3,locked:!0},WATER_CAN:{type:"WATER_CAN",count:0,slotIndex:4,offsetX:-1,offsetY:0},HARVEST:{type:"HARVEST",count:0,slotIndex:5,offsetX:0,offsetY:1}});selectedSlot=k(0);hiddenSeeds=k(new Set);tickService=v(Vt);gameService=v(Se);initInventory(){let n=[];for(let r=0;ro.slotIndex===n);return r?r.locked===!0:!1}setSelectedSlot(n){n<0||n>=this.slotsCount||this.selectedSlot.set(n)}clearHiddenSeeds(){this.hiddenSeeds.set(new Set)}removeHiddenSeed(n){this.hiddenSeeds.update(r=>{let o=new Set(Array.from(r));return o.delete(n),o})}addHiddenSeeds(n){this.hiddenSeeds.update(r=>{let o=new Set(Array.from(r));for(let i of n)o.add(i);return o})}getSelectedSlotObjectType(){return Object.values(this.inventory()).find(r=>r.slotIndex===this.selectedSlot())?.type}unlockSlot(n){this.inventory.update(r=>{let o=y({},r);return Object.values(o).find(i=>i.slotIndex===n).locked=!1,o})}};var Fg=[D.CHICKEN_PECK_RIGHT_ANIM,D.CHICKEN_PECK_DOWN_ANIM,D.CHICKEN_PECK_LEFT_ANIM,D.CHICKEN_PECK_TOP_ANIM],jg=4,hS=20;var Gs=class extends pe{tickService=v(Vt);state=k(0);animIndex=0;baseTick=0;frozenTile;constructor(t,n){super(t,n,16,16),this.baseTick=this.tickService.countTick()}genTiles(){if(this.tickService.countTick()this.interval&&(this.tick.update(n=>n+1),this.lastTs=t,this.curStep++,this.curStep>this.steps)){this.raf>=0&&(cancelAnimationFrame(this.raf),this.raf=-1),this.onDone();return}this.raf=requestAnimationFrame(()=>{this.animate()})}};var zs=class extends pe{animator;gamService=v(Se);constructor(t,n){super(t,n,16,16),this.animator=new nt(Ne,D.WATER_CAN_ANIM.length,()=>{this.gamService.game.removeObject(this)}),this.animator.animate()}genTiles(){let t=this.animator.tick(),n=D.WATER_CAN_ANIM[t];return[[{x:this.x,y:this.y,atlasX:n.x*16,atlasY:n.y*16}]]}};var gS=177,mS=56,vS=7e3,Sr=class extends pe{constructor(n,r){let o=r?.anchorX??gS,i=r?.anchorY??mS,s=r?.delay??vS;super(o-32,i+5,32,32);this.config=r;this.animator=new nt(50,3,()=>{this.gameService.game.showOverlayText(this.x,this.y,this.width,this.height,n),setTimeout(()=>{this.removed||this.gameService.game.removeBubble(this)},s)}),this.animator.animate()}gameService=v(Se);animator;removed=!1;genTiles(){let n=this.width,r=this.animator.tick()*16+32,o=Math.min(64,this.animator.tick()*16+32),i=this.x+(n-r);Ce(()=>{this.setWidth(r),this.setHeight(o),this.moveTo(i,this.y)});let s=this.genBorderTiles();return this.config?.decorationAtlas&&s.push({x:this.x+2,y:this.y+2,atlasX:this.config.decorationAtlas.x*16,atlasY:this.config.decorationAtlas.y*16}),[s]}dispose(){this.removed=!0}genBorderTiles(){let n=[];for(let o=0;o=16&&o{this.gamService.game.removeObject(this)}),this.animator.animate()}genTiles(){let t=this.animator.tick(),n=D.SCYTHE_ANIM[t];return[[{x:this.x+(n.offsetX??0),y:this.y+(n.offsetY??0),atlasX:n.x*16,atlasY:n.y*16}]]}};var qs=class extends pe{constructor(n,r,o,i=0){super(n,r,16,16);this.harvestAtlas=o;this.animator=new nt(150,3,()=>{this.gamService.game.removeObject(this)},i),this.animator.animate()}animator;totalOffset=0;gamService=v(Se);genTiles(){let n=this.animator.tick();return n<0?[]:(this.totalOffset+=Math.pow(2,2-n),[[{x:this.x+(this.harvestAtlas.offsetX??0),y:this.y+(this.harvestAtlas.offsetY??0)+Math.floor(this.totalOffset),atlasX:this.harvestAtlas.x*16,atlasY:this.harvestAtlas.y*16}]])}};var yS=["canvas"],ES=["container"],DS=["overlay"],CS=["overlayText"],IS=["pot"],SS=["unlockGoal"],bS=["gallerySeed"],_S=["galleryDecoration"],wS=["help"],TS=["helpMenu"],MS=["helpContent"],xS=["creditContent"],NS=["msgBubble"],AS=["exitTutorial"],RS=["chickenOverlay"];function OS(e,t){e&1&&(d(0," Welcome to Tiny Garden! Let's plant a SUNFLOWER together! "),L(1,"br")(2,"br"),d(3," Use the controls at the "),g(4,"span",46),d(5,"bottom of the screen"),p(),d(6," to Type or Say: "),L(7,"br")(8,"div",47),g(9,"span",48),d(10," Plant the sunflower seed on plot 8 "),p())}function PS(e,t){e&1&&(d(0," Nice! Let's "),g(1,"span",46),d(2,"WATER"),p(),d(3," it now. "),L(4,"br")(5,"br"),d(6," Type or Say: "),L(7,"br")(8,"div",47),g(9,"span",48),d(10," Water plot 8 "),p())}function kS(e,t){e&1&&(d(0," A seed needs to be watered "),g(1,"span",46),d(2,"TWICE"),p(),d(3," to fully grow. Let's water it again. "),L(4,"br")(5,"br"),d(6," Type or Say: "),L(7,"br")(8,"div",47),g(9,"span",48),d(10," Water plot 8 "),p())}function LS(e,t){e&1&&(d(0," What a beautiful sunflower! Let's "),g(1,"span",46),d(2,"HARVEST"),p(),d(3," it to fill the "),g(4,"span",46),d(5,"CHEST"),p(),d(6," up top. "),L(7,"br")(8,"br"),d(9," Type or Say: "),L(10,"br")(11,"div",47),g(12,"span",48),d(13," Harvest plot 8 "),p())}function FS(e,t){e&1&&(d(0," You did it! Great job! "),L(1,"br")(2,"br"),d(3," I've added "),g(4,"span",25),d(5,"DAISY"),p(),d(6,", "),g(7,"span",26),d(8,"ROSE"),p(),d(9,", and "),g(10,"span",49),d(11,"S"),p(),g(12,"span",25),d(13,"E"),p(),g(14,"span",24),d(15,"C"),p(),g(16,"span",26),d(17,"R"),p(),g(18,"span",25),d(19,"E"),p(),g(20,"span",49),d(21,"T"),p(),d(22," seed to your inventory. Can you unlock them? I heard the SECRET seed is pretty cool. "),L(23,"br")(24,"br"),d(25," Feel free to "),g(26,"span",46),d(27,"tap me for hints"),p(),d(28,". Good luck! "),g(29,"div",50),d(30," Tap to close "),p())}function jS(e,t){if(e&1&&ir(0,OS,11,0)(1,PS,11,0)(2,kS,11,0)(3,LS,14,0)(4,FS,31,0),e&2){let n=Ge(2);sr(n.curTutorialStep===n.TutorialStep.PLANT||n.curTutorialStep===n.TutorialStep.PLANT_DONE?0:n.curTutorialStep===n.TutorialStep.WATER_1||n.curTutorialStep===n.TutorialStep.WATER_1_DONE?1:n.curTutorialStep===n.TutorialStep.WATER_2||n.curTutorialStep===n.TutorialStep.WATER_2_DONE?2:n.curTutorialStep===n.TutorialStep.HARVEST||n.curTutorialStep===n.TutorialStep.HARVEST_DONE?3:n.curTutorialStep===n.TutorialStep.FINAL?4:-1)}}function HS(e,t){e&1&&(d(0," To unlock the "),g(1,"span",25),d(2,"DAISY"),p(),d(3," seed, plant "),g(4,"span",24),d(5,"SUNFLOWERS"),p(),d(6," into the pattern shown above the seed. "))}function BS(e,t){e&1&&(d(0," To unlock the "),g(1,"span",26),d(2,"ROSE"),p(),d(3," seed, plant "),g(4,"span",24),d(5,"SUNFLOWERS"),p(),d(6," and "),g(7,"span",25),d(8,"DAISIES"),p(),d(9," into the pattern shown above the seed. "))}function VS(e,t){e&1&&(d(0," To unlock the "),g(1,"span",49),d(2,"S"),p(),g(3,"span",25),d(4,"E"),p(),g(5,"span",24),d(6,"C"),p(),g(7,"span",26),d(8,"R"),p(),g(9,"span",25),d(10,"E"),p(),g(11,"span",49),d(12,"T"),p(),d(13," seed, harvest the minimum numbers of flowers shown above the seed. "))}function US(e,t){e&1&&(g(0,"div",51),L(1,"img",52),g(2,"div",53),d(3," You've unlocked the "),g(4,"span",25),d(5,"DAISY"),p(),d(6," seed! "),p()())}function $S(e,t){e&1&&(g(0,"div",51),L(1,"img",54),g(2,"div",53),d(3," You've unlocked the "),g(4,"span",26),d(5,"ROSE"),p(),d(6," seed! "),p()())}function GS(e,t){e&1&&(g(0,"div",51),L(1,"img",55),g(2,"div",53),d(3," You've unlocked the "),g(4,"span",49),d(5,"S"),p(),g(6,"span",25),d(7,"E"),p(),g(8,"span",24),d(9,"C"),p(),g(10,"span",26),d(11,"R"),p(),g(12,"span",25),d(13,"E"),p(),g(14,"span",49),d(15,"T"),p(),d(16," seed! "),p()())}function zS(e,t){e&1&&(g(0,"span",46),d(1,"The seed is locked."),p(),L(2,"br")(3,"br"),d(4," Plant the pattern shown above the seed to unlock. "))}function WS(e,t){e&1&&(g(0,"span",46),d(1,"The seed is locked."),p(),L(2,"br")(3,"br"),d(4," Harvest the numbers of plants shown above the seed to unlock. "))}function qS(e,t){if(e&1&&(ir(0,HS,7,0)(1,BS,10,0)(2,VS,14,0)(3,US,7,0,"div",51)(4,$S,7,0,"div",51)(5,GS,17,0,"div",51)(6,zS,5,0)(7,WS,5,0),g(8,"div",50),d(9," Tap to close "),p()),e&2){let n,r=Ge(2);sr((n=r.curMsgType())===r.MessageType.HOW_TO_UNLOCK_DAISY?0:n===r.MessageType.HOW_TO_UNLOCK_ROSE?1:n===r.MessageType.HOW_TO_UNLOCK_SECRET?2:n===r.MessageType.DAISY_UNLOCKED?3:n===r.MessageType.ROSE_UNLOCKED?4:n===r.MessageType.SECRET_UNLOCKED?5:n===r.MessageType.WARNING_DAISY_ROSE_LOCKED?6:n===r.MessageType.WARNING_SECRET_LOCKED?7:-1),Dt(8),co("center",r.centerTapToClose())}}function YS(e,t){if(e&1){let n=ml();L(0,"canvas",null,1),g(2,"div",15,2),Ct("click",function(){pt(n);let o=Ge();return ht(o.handleTapOverlay())}),L(4,"div",16,3),g(6,"div",17,4),L(8,"img",18),p(),L(9,"div",19,5)(11,"div",20,5)(13,"div",21,5),g(15,"div",22,6),Ct("click",function(o){pt(n);let i=Ge();return ht(i.handleClickHelp(o))}),p(),g(17,"div",23,7)(19,"div",24),d(20),p(),g(21,"div",25),d(22),p(),g(23,"div",26),d(24),p()(),g(25,"div",27,8),L(27,"img",18),p(),g(28,"div",28,9),ir(30,jS,5,1)(31,qS,10,3),p(),g(32,"div",29,10),Ct("click",function(o){pt(n);let i=Ge();return ht(i.handleClickExitTutorial(o))}),g(34,"div"),d(35,"Exit"),p(),g(36,"div"),d(37,"Tutorial"),p()(),g(38,"div",30,11),Ct("click",function(o){pt(n);let i=Ge();return ht(i.handleClickChicken(o))}),p()(),g(40,"div",31,12)(42,"div",32)(43,"h2",33),d(44,"How To Play"),p(),g(45,"button",34),Ct("click",function(o){pt(n);let i=gs(41),s=Ge();return ht(s.handleCloseContentPanel(o,i))}),g(46,"span",35),d(47,"\u2715"),p(),d(48," Close "),p()(),g(49,"div",36)(50,"p"),d(51,"Welcome, gardener! This guide will help you use the magic of the on-device Function Gemma model to grow a tiny beautiful garden. Your voice (or text) is your main tool!"),p(),g(52,"p"),d(53,`Your goal is to plant seeds, grow them into flowers, and harvest them. As you do, you'll unlock new seeds, including a very mysterious "Secret Seed."`),p(),g(54,"section")(55,"h1"),d(56,"Your Tools (Inventory)"),p(),g(57,"p"),d(58,"You have 6 slots in your inventory:"),p(),g(59,"ul")(60,"li")(61,"strong"),d(62,"Slot 1: Sunflower Seed"),p(),d(63," (Your starting seed!)"),p(),g(64,"li")(65,"strong"),d(66,"Slot 2: Daisy Seed"),p(),d(67," (Locked)"),p(),g(68,"li")(69,"strong"),d(70,"Slot 3: Rose Seed"),p(),d(71," (Locked)"),p(),g(72,"li")(73,"strong"),d(74,"Slot 4: Secret Seed"),p(),d(75," (Locked)"),p(),g(76,"li")(77,"strong"),d(78,"Slot 5: Water Can"),p(),d(79," (For making 'em grow)"),p(),g(80,"li")(81,"strong"),d(82,"Slot 6: Scythe"),p(),d(83," (For harvesting your flowers)"),p()()(),g(84,"section")(85,"h1"),d(86,"Your Garden"),p(),g(87,"p"),d(88,"Your garden is a 3x3 grid of plots, ready for planting. When you harvest flowers, they'll be stored in the chests above the plots."),p()(),g(89,"section")(90,"h1"),d(91,"Your Commands"),p(),g(92,"p"),d(93,"Your voice and text are your magic keys here! You can speak or type naturally to give commands."),p(),g(94,"p"),d(95,"Here\u2019s the basic flow:"),p(),g(96,"div")(97,"div")(98,"h2"),d(99,"1. Plant a Seed"),p(),g(100,"p"),d(101,"Tell the game "),g(102,"em"),d(103,"what"),p(),d(104," seed to plant and "),g(105,"em"),d(106,"where"),p(),d(107,". A seed needs an empty plot."),p(),g(108,"ul")(109,"li")(110,"strong"),d(111,"Try:"),p(),d(112,' "Plant sunflower seed on plot 1, 2, and 3."'),p(),g(113,"li")(114,"strong"),d(115,"Or:"),p(),d(116,' "Plant daisy on the top row."'),p()()(),g(117,"div")(118,"h2"),d(119,"2. Water Your Seeds"),p(),g(120,"p"),d(121,"A seed needs to be watered "),g(122,"strong"),d(123,"twice"),p(),d(124," to grow into a full flower."),p(),g(125,"ul")(126,"li")(127,"strong"),d(128,"Try:"),p(),d(129,' "Water plot 1 and 3."'),p()()(),g(130,"div")(131,"h2"),d(132,"3. Harvest Your Flowers"),p(),g(133,"p"),d(134,"Once fully grown, tell the game to harvest them with the scythe."),p(),g(135,"ul")(136,"li")(137,"strong"),d(138,"Try:"),p(),d(139,' "Harvest plot 1."'),p(),g(140,"li")(141,"strong"),d(142,"Or:"),p(),d(143,' "Harvest all."'),p()()()()(),g(144,"section")(145,"h1"),d(146,"Unlocking New Seeds"),p(),g(147,"p"),d(148,'You start with sunflowers, but you can unlock more! Look for the "cue" shown above each locked seed in your inventory.'),p(),g(149,"ul")(150,"li")(151,"strong"),d(152,"Unlock the Daisy Seed:"),p(),L(153,"br"),g(154,"div"),d(155,"Plant your "),g(156,"strong"),d(157,"sunflowers"),p(),d(158," to match the pattern shown above the daisy seed."),p()(),g(159,"li")(160,"strong"),d(161,"Unlock the Rose Seed:"),p(),L(162,"br"),g(163,"div"),d(164,"Plant your "),g(165,"strong"),d(166,"sunflowers and daisies"),p(),d(167," to match the pattern shown above the rose seed."),p()(),g(168,"li")(169,"strong"),d(170,"Unlock the Secret Seed:"),p(),L(171,"br"),g(172,"div"),d(173,"This one is different! Harvest the required number of sunflowers, daisies, and roses. Check the numbers listed above the secret seed to see your goal!"),p()()()(),g(174,"section")(175,"h1"),d(176,"The Final Mystery!"),p(),g(177,"p"),d(178,"Once you've unlocked the legendary Secret Seed, plant it, water it, and harvest it to discover what you've grown!"),p(),g(179,"p"),d(180,"Good luck, gardener!"),p()(),g(181,"h1",37),d(182,"Credits"),p(),g(183,"section")(184,"h1"),d(185,"Graphics"),p(),g(186,"ul")(187,"li")(188,"a",38)(189,"strong"),d(190,"Little Dreamyland"),p(),d(191," by "),g(192,"strong"),d(193,"Starmixu and Utaskuas"),p()(),L(194,"br"),d(195," (Paid license) "),p()()(),g(196,"section")(197,"h1"),d(198,"Sounds"),p(),g(199,"ul")(200,"li")(201,"a",39)(202,"strong"),d(203,"Water splash.wav"),p(),d(204," by "),g(205,"strong"),d(206,"speedygonzo"),p()(),L(207,"br"),d(208," (Creative Commons 0) "),p(),g(209,"li")(210,"a",40)(211,"strong"),d(212,"Throwing / Whip Effect"),p(),d(213," by "),g(214,"strong"),d(215,"denao270"),p()(),L(216,"br"),d(217," (Creative Commons 0) "),p(),g(218,"li")(219,"a",41)(220,"strong"),d(221,"Completed.wav"),p(),d(222," by "),g(223,"strong"),d(224,"Kenneth_Cooney"),p()(),L(225,"br"),d(226," (Creative Commons 0) "),p(),g(227,"li")(228,"a",42)(229,"strong"),d(230,"Plop_1.wav"),p(),d(231," by "),g(232,"strong"),d(233,"MiSchy"),p()(),L(234,"br"),d(235," (Creative Commons 0) "),p(),g(236,"li")(237,"a",43)(238,"strong"),d(239,'"Alert" Video Game Sound'),p(),d(240," by "),g(241,"strong"),d(242,"EVRetro"),p()(),L(243,"br"),d(244," (Creative Commons 0) "),p(),g(245,"li")(246,"a",44)(247,"strong"),d(248,"Plop!"),p(),d(249," by "),g(250,"strong"),d(251,"Breviceps"),p()(),L(252,"br"),d(253," (Creative Commons 0) "),p()()(),g(254,"section")(255,"h1"),d(256,"Font"),p(),g(257,"ul")(258,"li")(259,"a",45)(260,"strong"),d(261,"04b03"),p(),d(262," by "),g(263,"strong"),d(264,"Yuji Oshimoto"),p()(),L(265,"br"),d(266," (Freeware) "),p()()()()(),g(267,"div",31,13)(269,"div",32)(270,"h2",33),d(271,"Credits"),p(),g(272,"button",34),Ct("click",function(o){pt(n);let i=gs(268),s=Ge();return ht(s.handleCloseContentPanel(o,i))}),g(273,"span",35),d(274,"\u2715"),p(),d(275," Close "),p()(),L(276,"div",36),p()}if(e&2){let n=Ge();hs("width",n.canvasWidth)("height",n.canvasHeight),Dt(20),cr(n.harvestGoalSunflower),Dt(2),cr(n.harvestGoalDaisy),Dt(2),cr(n.harvestGoalRose),Dt(6),sr(n.inTutorial?30:31),Dt(2),co("hide",!n.inTutorial)}}var Ys=.13,ZS=7,KS=12,QS=2*Ne,XS=2*Ne,JS=.5*Ne,Du=80,Cu=178,Iu=171,Su=54,Vg=88,Ug=68,zg=(l=>(l[l.OFF=0]="OFF",l[l.HOW_TO_UNLOCK_DAISY=1]="HOW_TO_UNLOCK_DAISY",l[l.HOW_TO_UNLOCK_ROSE=2]="HOW_TO_UNLOCK_ROSE",l[l.HOW_TO_UNLOCK_SECRET=3]="HOW_TO_UNLOCK_SECRET",l[l.DAISY_UNLOCKED=4]="DAISY_UNLOCKED",l[l.ROSE_UNLOCKED=5]="ROSE_UNLOCKED",l[l.SECRET_UNLOCKED=6]="SECRET_UNLOCKED",l[l.WARNING_DAISY_ROSE_LOCKED=7]="WARNING_DAISY_ROSE_LOCKED",l[l.WARNING_SECRET_LOCKED=8]="WARNING_SECRET_LOCKED",l))(zg||{}),bu=[{slots:[{slotIndex:0},{slotIndex:1,plantType:"SUNFLOWER"},{slotIndex:2},{slotIndex:3,plantType:"SUNFLOWER"},{slotIndex:4,plantType:"SUNFLOWER"},{slotIndex:5,plantType:"SUNFLOWER"},{slotIndex:6,plantType:"SUNFLOWER"},{slotIndex:7},{slotIndex:8,plantType:"SUNFLOWER"}],unlockMessageType:4,decorationAtlas:D.HARVEST_DAISY},{slots:[{slotIndex:0,plantType:"DAISY"},{slotIndex:1,plantType:"SUNFLOWER"},{slotIndex:2,plantType:"DAISY"},{slotIndex:3},{slotIndex:4,plantType:"SUNFLOWER"},{slotIndex:5},{slotIndex:6,plantType:"SUNFLOWER"},{slotIndex:7,plantType:"DAISY"},{slotIndex:8,plantType:"SUNFLOWER"}],unlockMessageType:5,decorationAtlas:D.HARVEST_ROSE}],Wg=(u=>(u[u.OFF=0]="OFF",u[u.PLANT=1]="PLANT",u[u.PLANT_DONE=2]="PLANT_DONE",u[u.WATER_1=3]="WATER_1",u[u.WATER_1_DONE=4]="WATER_1_DONE",u[u.WATER_2=5]="WATER_2",u[u.WATER_2_DONE=6]="WATER_2_DONE",u[u.HARVEST=7]="HARVEST",u[u.HARVEST_DONE=8]="HARVEST_DONE",u[u.FINAL=9]="FINAL",u))(Wg||{}),$g=["yellow_svg.svg","red_svg.svg","green_svg.svg","blue_svg.svg"],Gg=["PLOP","PLOP2","PLOP3"],Zs=class e{canvas=me("canvas");container=me("container");overlay=me("overlay");overlayText=me("overlayText");pots=Ih("pot");unlockGoal=me("unlockGoal");gallerySeed=me("gallerySeed");galleryDecoration=me("galleryDecoration");help=me("help");helpMenu=me("helpMenu");helpContent=me("helpContent");creditContent=me("creditContent");msgBubble=me("msgBubble");exitTutorial=me("exitTutorial");chickenOverlay=me("chickenOverlay");canvasWidth=256;canvasHeight=256;harvestGoalSunflower=5;harvestGoalDaisy=6;harvestGoalRose=8;imgLoaded=k(!1);TutorialStep=Wg;tutorial=k({curStep:0});atlas;offscreenCanvas=new OffscreenCanvas(this.canvasWidth,this.canvasHeight);offscreenCtx=this.offscreenCanvas.getContext("2d");ctx;resizeObserver;farm=new Us(Vg,Ug,80,80);inventory=new $s(Du,Cu,6);chicken=new Gs(Iu,Su);objects=k([this.farm,this.inventory,this.chicken]);objectsOnTop=k([]);flowerCounts=k([0,0,0]);injector=v(K);gameService=v(Se);shouldStartTutorial=k(!1);MessageType=zg;curMsgType=k(0);centerTapToClose=lo(()=>this.curMsgType()===4||this.curMsgType()===5||this.curMsgType()===6);divsPositioned=!1;constructor(){this.gameService.game=this,this.atlas=new Image,this.atlas.onload=()=>{this.imgLoaded.set(!0)},this.atlas.src="atlas.png",It(()=>{this.shouldStartTutorial()&&(this.inventory.addHiddenSeeds(["SEED_DAISY","SEED_ROSE","SEED_GALLERY"]),this.setUnlockGoalVisible(!1),this.farm.setHelpVisible(!1),setTimeout(()=>{this.startTutorial()},1e3))}),It(()=>{let t=this.canvas()?.nativeElement?.getContext("2d");if(!t)return;if(this.ctx=t,this.render(),this.overlay()?.nativeElement!=null&&!this.divsPositioned){for(let m=0;m{let l=i.offsetWidth,u=i.offsetHeight,f=l/u,m=123/160,h=1;m>f?h=l/123:h=u/160;let E=this.canvasWidth*h,S=this.canvasHeight*h;s.style.width=`${E}px`,s.style.height=`${S}px`,s.style.left=`${(l-E)/2}px`,s.style.top=`${(u-S)/2}px`,a.style.width=`${E}px`,a.style.height=`${S}px`,a.style.left=`${(l-E)/2}px`,a.style.top=`${(u-S)/2}px`,c.style.fontSize=`${c.offsetWidth/a.offsetWidth*c.offsetWidth*Ys}px`;for(let We=0;We{if(this.pots().length!==3)return;let t=this.flowerCounts();for(let n=0;n{o.style.transform=""},200))}if(!this.inTutorial&&this.inventory.isSlotLocked(3)&&t[0]>=this.harvestGoalSunflower&&t[1]>=this.harvestGoalDaisy&&t[2]>=this.harvestGoalRose){this.gameService.playAudio("GALLERY_GOAL"),this.inventory.unlockSlot(3);let n=this.unlockGoal()?.nativeElement;n&&(n.style.visibility="hidden"),this.showMsgBubble(6)}}),It(()=>{if(!this.inTutorial){let t=-1;for(let n=0;n=0){this.gameService.playAudio("PATTERN_GOAL"),this.inventory.unlockSlot(t+1);let n=bu[t];this.showMsgBubble(n.unlockMessageType)}}}),It(()=>{let t=this.tutorial(),n=this.farm.getPlant(7);Ce(async()=>{switch(n!=null?t.curStep===1?n.type=="SUNFLOWER"&&n.phase===0&&this.setTutorialStep(2):t.curStep===3?n.type=="SUNFLOWER"&&n.phase===2&&this.setTutorialStep(4):t.curStep===5&&n.type=="SUNFLOWER"&&n.phase===4&&this.setTutorialStep(6):t.curStep===7&&this.setTutorialStep(8),t.curStep){case 1:this.showMsgBubble();break;case 2:this.hideMsgBubble(),await this.wait(150),this.setTutorialStep(3);break;case 3:this.showMsgBubble();break;case 4:this.hideMsgBubble(),await this.wait(150),this.setTutorialStep(5);break;case 5:this.showMsgBubble();break;case 6:this.hideMsgBubble(),await this.wait(150),this.setTutorialStep(7);break;case 7:this.showMsgBubble();break;case 8:this.hideMsgBubble(),await this.wait(150),this.setTutorialStep(9);break;case 9:await this.wait(1e3);for(let r of["SEED_DAISY","SEED_ROSE","SEED_GALLERY"])this.gameService.playAudio("PLOP3"),this.inventory.removeHiddenSeed(r),r==="SEED_GALLERY"&&this.setUnlockGoalVisible(!0),await this.wait(500);await this.wait(500),this.showMsgBubble();break;default:break}})}),window.tinyGarden={},window.tinyGarden.use=t=>{G(this.injector,()=>{this.useOnGarden(t)})},window.tinyGarden.selectInventory=t=>{G(this.injector,()=>{this.selectInventory(t)})},window.tinyGarden.runCommands=t=>{G(this.injector,()=>{this.runCommands(t)})},window.tinyGarden.showTutorial=()=>{G(this.injector,()=>{this.startTutorial()})},window.tinyGarden.unlockAll=()=>{G(this.injector,()=>{this.unlockAll()})},window.tinyGarden.startTutorial=()=>{G(this.injector,()=>{this.startTutorial()})}}ngAfterViewInit(){let t=window.location.href,n=new URL(t);new URLSearchParams(n.search).get("tutorial")==="1"&&this.shouldStartTutorial.set(!0)}ngOnDestroy(){this.resizeObserver?.disconnect()}handleTapOverlay(){this.inTutorial&&this.curTutorialStep===9?(this.hideMsgBubble(),this.stopTutorial()):this.inTutorial||this.hideMsgBubble()}handleClickPot(t){this.gameService.playAudio("PLOP"),this.flowerCounts.update(n=>{let r=[...n];return r[t]+=5,r})}handleClickHelp(t){t.stopPropagation(),!this.inTutorial&&this.helpContent()?.nativeElement.classList.toggle("hide")}handleCloseContentPanel(t,n){t.stopPropagation(),n.classList.add("hide")}handleClickExitTutorial(t){t.stopPropagation(),this.hideMsgBubble(),this.stopTutorial()}handleClickChicken(t){t.stopPropagation(),!this.inTutorial&&(this.isMsgBubbleVisible()?this.hideMsgBubble():this.inventory.isSlotLocked(1)?this.showMsgBubble(1):this.inventory.isSlotLocked(2)?this.showMsgBubble(2):this.inventory.isSlotLocked(3)&&this.showMsgBubble(3))}showOverlayText(t,n,r,o,i){let s=this.overlayText()?.nativeElement,a=this.galleryDecoration()?.nativeElement;if(!s||!a)return;let c=(r-16)/this.canvasWidth;this.setDivRect(t,n+16,r-16,o-32,s),s.style.fontSize=`${c*s.offsetWidth*Ys}px`,s.innerHTML=i,i.toLowerCase().includes("secret")&&(a.style.visibility="visible")}removeObject(t){t instanceof Sr?this.objectsOnTop.update(n=>n.filter(r=>r!==t)):this.objects.update(n=>n.filter(r=>r!==t))}removeBubble(){let t=this.objectsOnTop().find(o=>o instanceof Sr);if(!t)return;t.dispose(),this.removeObject(t),this.chicken.setState(0);let n=this.overlayText()?.nativeElement;n&&(n.innerText="");let r=this.galleryDecoration()?.nativeElement;r&&(r.style.visibility="hidden")}unlockAll(){this.inventory.unlockSlot(1),this.inventory.unlockSlot(2),this.inventory.unlockSlot(3)}startTutorial(){this.inventory.addHiddenSeeds(["SEED_DAISY","SEED_ROSE","SEED_GALLERY"]),this.setUnlockGoalVisible(!1),this.farm.setHelpVisible(!1),this.setTutorialStep(1)}stopTutorial(){this.setTutorialStep(0),this.inventory.clearHiddenSeeds(),this.setUnlockGoalVisible(!0),this.farm.setHelpVisible(!0)}get curTutorialStep(){return this.tutorial().curStep}get inTutorial(){return this.curTutorialStep!==0}setUnlockGoalVisible(t){let n=this.unlockGoal()?.nativeElement;n&&(t?n.classList.remove("hide"):n.classList.add("hide"))}render(){let t=this.canvas()?.nativeElement,n=this.container()?.nativeElement;if(!t||!n)return;let r=this.offscreenCtx,o=this.ctx;r.clearRect(0,0,this.canvasWidth,this.canvasHeight);for(let i of[...this.objects(),...this.objectsOnTop()]){if(!i.visible())continue;let s=i.tiles();for(let a of s)for(let c of a)r.drawImage(this.atlas,c.atlasX,c.atlasY,c.atlasWidth??16,c.atlasHeight??16,c.x,c.y,c.atlasWidth??16,c.atlasHeight??16)}o.clearRect(0,0,this.canvasWidth,this.canvasHeight),o.drawImage(this.offscreenCanvas,0,0)}useOnGarden(t,n=()=>{}){this.inTutorial||this.hideMsgBubble();let r=this.inventory.getSelectedSlotObjectType();if(!r){n();return}switch(r){case"WATER_CAN":for(let o=0;o9||setTimeout(()=>{G(this.injector,()=>{this.waterGarden(i),i===t[t.length-1]&&setTimeout(()=>{n()},Ne*14)})},QS*o)}break;case"SEED_SUNFLOWSER":case"SEED_DAISY":case"SEED_ROSE":case"SEED_GALLERY":for(let o=0;o9||this.farm.getPlant(i)==null&&setTimeout(()=>{G(this.injector,()=>{this.setPlant(i,Rg(r)),i===t[t.length-1]&&n()})},JS*o)}break;case"HARVEST":for(let o=0;o9||setTimeout(()=>{G(this.injector,()=>{this.harvestGarden(i),i===t[t.length-1]&&n()})},XS*o)}break}}selectInventory(t){return this.inTutorial||this.hideMsgBubble(),t<0||t>5?(console.warn("Invalid slot"),!1):this.inventory.isSlotLocked(t)?(this.inTutorial||(t===1||t===2?this.showMsgBubble(7):t===3&&this.showMsgBubble(8)),!1):(this.gameService.playAudio("INVENTORY"),this.inventory.setSelectedSlot(t),!0)}waterGarden(t){let n=this.farm.getSlotPos(t);if(!n)return;let r=new zs(n.x+5,n.y-6);this.addObject(r),setTimeout(()=>{this.farm.setSlotWatered(t,!0)},Ne*8),setTimeout(()=>{this.gameService.playAudio("WATERING")},Ne*2)}harvestGarden(t){let n=this.farm.getSlotPos(t);if(!n)return;let r=new Ws(n.x+6,n.y-3);this.addObject(r),this.gameService.playAudio("WHIP");let o=this.farm.getPlant(t);o&&(setTimeout(()=>{this.farm.removePlant(t),this.farm.setSlotWatered(t,!1)},Ne*4),o.grown&&setTimeout(()=>{o.type==="GALLERY"?this.startGalleryFruitsFirework(t):G(this.injector,()=>{let i=Math.floor(-4+8*Math.random()),s=Math.floor(-3+6*Math.random()),a=0;switch(o.type){case"SUNFLOWER":a=0;break;case"DAISY":a=1;break;case"ROSE":a=2;break;default:break}this.addObject(new qs(Vg+16*(a+1)+s,Ug-32+i,D.getHarvestTileFromPlant(o))),setTimeout(()=>{this.gameService.playAudio("PLOP"),this.flowerCounts.update(c=>{let l=[...c];switch(o.type){case"SUNFLOWER":l[0]++;break;case"DAISY":l[1]++;break;case"ROSE":l[2]++}return l})},Ne*2)})},Ne*3.5))}async runCommands(t){try{let n=JSON.parse(t);for(let r of n){if(!this.selectInventory(r.item-1))break;await new Promise(i=>{this.useOnGarden(r.plot.map(s=>s-1).filter(s=>s>=0&&s<=8),()=>{i()})})}}catch(n){console.error(`Failed to parse json string for commands: ${t}`,n)}}setPlant(t,n){this.gameService.playAudio("DIRT"),this.farm.addPlant(t,n)}addObject(t){t instanceof Sr?this.objectsOnTop.update(n=>[...n,t]):this.objects.update(n=>[...n,t])}setDivRect(t,n,r,o,i){let s=t/this.canvasHeight,a=n/this.canvasHeight,c=r/this.canvasWidth,l=o/this.canvasHeight;i.style.left=`${s*100}%`,i.style.top=`${a*100}%`,i.style.width=`${c*100}%`,o>=0&&(i.style.height=`${l*100}%`)}setDivFontSize(t,n){let r=this.overlay()?.nativeElement;r&&(t.style.fontSize=`${t.offsetWidth/r.offsetWidth*t.offsetWidth*n}px`)}startGalleryFruitsFirework(t){let n=this.overlay()?.nativeElement;if(!n)return;let r=n.offsetWidth*.001087,o=32*r,i=32*r,s=-300*r,a=600*r,c=-600*r,l=100*r,u=-400*r,f=800*r,m=this.farm.getSlotPos(t),h=m.x/this.canvasWidth*n.offsetWidth,E=m.y/this.canvasHeight*n.offsetHeight,S=(rt,Ut,ue)=>{let Tt=document.createElement("div");Tt.style.position="absolute",Tt.style.width=`${o}px`,Tt.style.height=`${i}px`,Tt.style.left=`${rt}px`,Tt.style.top=`${Ut}px`;let _r=document.createElement("img");return _r.src=$g[ue],_r.style.width="100%",_r.style.height="100%",_r.style.objectFit="contain",Tt.appendChild(_r),n.appendChild(Tt),Tt},O=[];for(let rt=0;rt{this.gameService.playAudio(Gg[Math.floor(Math.random()*Gg.length)]),O.push({ele:S(h,E,rt%$g.length),vx:s+a*Math.random(),vy:c+l*Math.random(),posX:h,posY:E,gravityOffset:u+f*Math.random(),angle:Math.random()*180,rotateSpeed:-800+Math.random()*1600})},rt*40);let F=2500,In=2e3*r,We=Date.now(),br=performance.now(),_u=rt=>{let Ut=(rt-br)/1e3;if(Ut>0)for(let ue of O)ue.vy+=(In+ue.gravityOffset)*Ut,ue.posX+=ue.vx*Ut,ue.posY+=ue.vy*Ut,ue.ele.style.left=`${ue.posX}px`,ue.ele.style.top=`${ue.posY}px`,ue.angle+=ue.rotateSpeed*Ut,ue.ele.style.transform=`rotate(${ue.angle}deg)`;br=rt,!(Date.now()-We>F)&&requestAnimationFrame(_u)};requestAnimationFrame(_u)}setTutorialStep(t){this.tutorial.update(n=>n.curStep===t?n:P(y({},n),{curStep:t}))}showMsgBubble(t){G(this.injector,async()=>{t!=null&&(this.isMsgBubbleVisible()&&(this.hideMsgBubble(),await this.wait(100)),this.curMsgType.set(t),t===5||t===4||t===6?this.chicken.setState(2):this.chicken.setState(1));let n=this.msgBubble()?.nativeElement;n&&n.classList.add("show")})}hideMsgBubble(){G(this.injector,()=>{let t=this.msgBubble()?.nativeElement;t&&t.classList.remove("show"),this.curMsgType.set(0),this.chicken.setState(0)})}isMsgBubbleVisible(){return this.msgBubble()?.nativeElement?.classList.contains("show")??!1}async wait(t){await new Promise(n=>{setTimeout(()=>{n()},t)})}static \u0275fac=function(n){return new(n||e)};static \u0275cmp=vn({type:e,selectors:[["garden"]],viewQuery:function(n,r){n&1&&(ce(r.canvas,yS,5),ce(r.container,ES,5),ce(r.overlay,DS,5),ce(r.overlayText,CS,5),ce(r.pots,IS,5),ce(r.unlockGoal,SS,5),ce(r.gallerySeed,bS,5),ce(r.galleryDecoration,_S,5),ce(r.help,wS,5),ce(r.helpMenu,TS,5),ce(r.helpContent,MS,5),ce(r.creditContent,xS,5),ce(r.msgBubble,NS,5),ce(r.exitTutorial,AS,5),ce(r.chickenOverlay,RS,5)),n&2&&vl(15)},decls:3,vars:1,consts:[["container",""],["canvas",""],["overlay",""],["overlayText",""],["galleryDecoration",""],["pot",""],["help",""],["unlockGoal",""],["gallerySeed",""],["msgBubble",""],["exitTutorial",""],["chickenOverlay",""],["helpContent",""],["creditContent",""],[1,"container"],[1,"overlay",3,"click"],[1,"overlay-text"],[1,"gallery-decoration"],["src","gallery_with_border.svg"],[1,"pot","yellow"],[1,"pot","blue"],[1,"pot","red"],[1,"help",3,"click"],[1,"unlock-goal"],[1,"yellow"],[1,"blue"],[1,"red"],[1,"gallery-seed"],[1,"bubble"],[1,"btn-exit-tutorial",3,"click"],[1,"chicken-overlay",3,"click"],[1,"help-content","hide"],[1,"title-bar"],[1,"title"],["type","button",1,"btn-close",3,"click"],["aria-hidden","true"],[1,"content"],[1,"main"],["href","https://starmixu.itch.io/little-dreamyland-asset-pack","target","_blank"],["href","https://freesound.org/s/235725/","target","_blank"],["href","https://freesound.org/s/346373/","target","_blank"],["href","https://freesound.org/s/609336/","target","_blank"],["href","https://freesound.org/s/369952/","target","_blank"],["href","https://freesound.org/s/495004/","target","_blank"],["href","https://freesound.org/s/447910/","target","_blank"],["href","https://www.dafont.com/04b-03.font","target","_blank"],[1,"emphasize"],[1,"vertical-spacer"],[1,"highlight"],[1,"green"],[1,"tap-to-close"],[1,"unlock-msg"],["src","daisy.png",1,"decoration"],[1,"msg-content"],["src","rose.png",1,"decoration"],["src","gallery.svg",1,"decoration","rotate"]],template:function(n,r){n&1&&(g(0,"div",14,0),ir(2,YS,277,8),p()),n&2&&(Dt(2),sr(r.imgLoaded()?2:-1))},dependencies:[ys],styles:['.highlight[_ngcontent-%COMP%]{color:#4f82ce}.emphasize[_ngcontent-%COMP%]{color:#a85d5d}.vertical-spacer[_ngcontent-%COMP%]{height:8px}.blue[_ngcontent-%COMP%]{color:#3e9be7}.red[_ngcontent-%COMP%]{color:#ff5454}.yellow[_ngcontent-%COMP%]{color:#e7b616}.green[_ngcontent-%COMP%]{color:#2da158}.container[_ngcontent-%COMP%]{background-color:#b4c160;width:100%;height:100%;position:relative;overflow:hidden}.container[_ngcontent-%COMP%] canvas[_ngcontent-%COMP%]{image-rendering:pixelated;box-sizing:border-box;position:absolute}.container[_ngcontent-%COMP%] .overlay[_ngcontent-%COMP%]{position:absolute;font-family:"04b03",Courier New,Courier,monospace}.container[_ngcontent-%COMP%] .overlay[_ngcontent-%COMP%] .overlay-text[_ngcontent-%COMP%]{color:#533a38;position:absolute}@keyframes _ngcontent-%COMP%_rotate{0%{transform:rotate(0)}to{transform:rotate(360deg)}}.container[_ngcontent-%COMP%] .overlay[_ngcontent-%COMP%] .gallery-decoration[_ngcontent-%COMP%]{position:absolute;visibility:hidden}.container[_ngcontent-%COMP%] .overlay[_ngcontent-%COMP%] .gallery-decoration[_ngcontent-%COMP%] img[_ngcontent-%COMP%]{object-fit:fill;width:120%;height:120%;animation:_ngcontent-%COMP%_rotate 5s linear infinite}.container[_ngcontent-%COMP%] .overlay[_ngcontent-%COMP%] .pot[_ngcontent-%COMP%]{position:absolute;display:flex;align-items:flex-end;justify-content:flex-end}.container[_ngcontent-%COMP%] .overlay[_ngcontent-%COMP%] .help[_ngcontent-%COMP%]{position:absolute;overflow:visible}.container[_ngcontent-%COMP%] .overlay[_ngcontent-%COMP%] .help[_ngcontent-%COMP%] .help-menu[_ngcontent-%COMP%]{border-radius:3px;font-size:16px;margin-top:calc(100% + 4px);border:2px solid #9a2a2a;width:80px;box-sizing:border-box;background-color:#ecddc9;color:#533a38;opacity:1;transform:translateY(0);transition:transform .15s ease-out,opacity .15s ease-out}.container[_ngcontent-%COMP%] .overlay[_ngcontent-%COMP%] .help[_ngcontent-%COMP%] .help-menu.hide[_ngcontent-%COMP%]{transform:translateY(-5px);opacity:0;pointer-events:none}.container[_ngcontent-%COMP%] .overlay[_ngcontent-%COMP%] .help[_ngcontent-%COMP%] .help-menu[_ngcontent-%COMP%] .help-menu-item[_ngcontent-%COMP%]{padding:4px 6px}.container[_ngcontent-%COMP%] .overlay[_ngcontent-%COMP%] .help[_ngcontent-%COMP%] .help-menu[_ngcontent-%COMP%] .divider[_ngcontent-%COMP%]{height:1px;background-color:#9a2a2a;opacity:.3}.container[_ngcontent-%COMP%] .overlay[_ngcontent-%COMP%] .gallery-seed[_ngcontent-%COMP%]{position:absolute;overflow:hidden;padding:3px;box-sizing:border-box;display:flex;align-items:center;justify-content:center}.container[_ngcontent-%COMP%] .overlay[_ngcontent-%COMP%] .gallery-seed[_ngcontent-%COMP%] img[_ngcontent-%COMP%]{object-fit:fill;width:100%;height:100%}.container[_ngcontent-%COMP%] .overlay[_ngcontent-%COMP%] .blue[_ngcontent-%COMP%]{color:#3e9be7}.container[_ngcontent-%COMP%] .overlay[_ngcontent-%COMP%] .red[_ngcontent-%COMP%]{color:#ff5454}.container[_ngcontent-%COMP%] .overlay[_ngcontent-%COMP%] .unlock-goal[_ngcontent-%COMP%]{position:absolute;display:flex;align-items:center;justify-content:space-between;text-shadow:0px 0px 1px #111}.container[_ngcontent-%COMP%] .overlay[_ngcontent-%COMP%] .unlock-goal[_ngcontent-%COMP%] .blue[_ngcontent-%COMP%]{color:#2183d2}.container[_ngcontent-%COMP%] .overlay[_ngcontent-%COMP%] .unlock-goal[_ngcontent-%COMP%] .red[_ngcontent-%COMP%]{color:#c53b3b!important}.container[_ngcontent-%COMP%] .overlay[_ngcontent-%COMP%] .unlock-goal[_ngcontent-%COMP%] .yellow[_ngcontent-%COMP%]{color:#ffd200!important}.container[_ngcontent-%COMP%] .overlay[_ngcontent-%COMP%] .unlock-goal.hide[_ngcontent-%COMP%]{opacity:0}.container[_ngcontent-%COMP%] .overlay[_ngcontent-%COMP%] .bubble[_ngcontent-%COMP%]{padding:1em;border-radius:var(--r)/var(--r) min(var(--r),var(--p) - var(--h) * tan(var(--a) / 2)) min(var(--r),100% - var(--p) - var(--h) * tan(var(--a) / 2)) var(--r);clip-path:polygon(100% 0,0 0,0 100%,100% 100%,100% min(100%,var(--p) + var(--h) * tan(var(--a) / 2)),calc(100% + var(--h)) var(--p),100% max(0%,var(--p) - var(--h) * tan(var(--a) / 2)));background:var(--c1);border-image:conic-gradient(var(--c1) 0 0) fill 0/max(0%,var(--p) - var(--h) * tan(var(--a) / 2)) 0 max(0%,100% - var(--p) - var(--h) * tan(var(--a) / 2)) var(--r)/0 var(--h) 0 0;position:absolute;--a: 90deg;--h: .9em;--p: 0%;--r: 8px;--b: 4px;--c1: #a85d5d;--c2: #e8dcc8;opacity:0;transform:translate(20px) scale(.9);transform-origin:100% 0%;transition:transform .2s cubic-bezier(.77,2.15,.45,.71),opacity .2s ease-out;box-shadow:0 20px 25px -5px #0000001a,0 8px 10px -6px #0000001a}.container[_ngcontent-%COMP%] .overlay[_ngcontent-%COMP%] .bubble.show[_ngcontent-%COMP%]{transform:none;opacity:1}.container[_ngcontent-%COMP%] .overlay[_ngcontent-%COMP%] .bubble[_ngcontent-%COMP%] .tap-to-close[_ngcontent-%COMP%]{margin-top:12px;color:#533a38;width:100%;display:flex;justify-content:flex-end}.container[_ngcontent-%COMP%] .overlay[_ngcontent-%COMP%] .bubble[_ngcontent-%COMP%] .tap-to-close.center[_ngcontent-%COMP%]{justify-content:center}.container[_ngcontent-%COMP%] .overlay[_ngcontent-%COMP%] .bubble[_ngcontent-%COMP%]:before{content:"";position:absolute;z-index:-1;inset:0;padding:var(--b);border-radius:inherit;clip-path:polygon(100% 0,0 0,0 100%,100% 100%,calc(100% - var(--b)) min(100% - var(--b),var(--p) + var(--h) * tan(var(--a) / 2) - var(--b) * tan(45deg - var(--a) / 4)),calc(100% + var(--h) - var(--b) / sin(var(--a) / 2)) var(--p),calc(100% - var(--b)) max(var(--b),var(--p) - var(--h) * tan(var(--a) / 2) + var(--b) * tan(45deg - var(--a) / 4)));background:var(--c2) content-box;border-image:conic-gradient(var(--c2) 0 0) fill 0/max(var(--b),var(--p) - var(--h) * tan(var(--a) / 2)) 0 max(var(--b),100% - var(--p) - var(--h) * tan(var(--a) / 2)) var(--r)/0 var(--h) 0 0}.container[_ngcontent-%COMP%] .overlay[_ngcontent-%COMP%] .btn-exit-tutorial[_ngcontent-%COMP%]{position:absolute;display:flex;flex-direction:column;align-items:center;justify-content:center;border-radius:8px;color:#fff;background-color:#a85d5d}.container[_ngcontent-%COMP%] .overlay[_ngcontent-%COMP%] .btn-exit-tutorial.hide[_ngcontent-%COMP%]{opacity:0;pointer-events:none}.container[_ngcontent-%COMP%] .overlay[_ngcontent-%COMP%] .chicken-overlay[_ngcontent-%COMP%]{position:absolute}.container[_ngcontent-%COMP%] .overlay[_ngcontent-%COMP%] .unlock-msg[_ngcontent-%COMP%]{display:flex;flex-direction:column;align-items:center;justify-content:center;gap:1em}.container[_ngcontent-%COMP%] .overlay[_ngcontent-%COMP%] .unlock-msg[_ngcontent-%COMP%] img[_ngcontent-%COMP%]{width:2em;aspect-ratio:1;object-fit:contain;image-rendering:pixelated}.container[_ngcontent-%COMP%] .overlay[_ngcontent-%COMP%] .unlock-msg[_ngcontent-%COMP%] img.rotate[_ngcontent-%COMP%]{animation:_ngcontent-%COMP%_rotate 2s linear infinite;image-rendering:optimizeQuality}.container[_ngcontent-%COMP%] .overlay[_ngcontent-%COMP%] .test[_ngcontent-%COMP%]{position:absolute;bottom:.7em;left:1em}.container[_ngcontent-%COMP%] .help-content[_ngcontent-%COMP%]{font-family:Courier New,Courier,monospace;position:absolute;border:3px solid #533A38;box-sizing:border-box;background-color:#f1b093;width:100%;height:100%;opacity:1;transform:scale(1);transition:transform .15s ease-out,opacity .15s ease-out;font-size:14px;display:flex;flex-direction:column}.container[_ngcontent-%COMP%] .help-content.hide[_ngcontent-%COMP%]{transform:scale(.95);opacity:0;pointer-events:none}.container[_ngcontent-%COMP%] .help-content[_ngcontent-%COMP%] .title-bar[_ngcontent-%COMP%]{font-family:"04b03",Courier New,Courier,monospace;display:flex;align-items:center;justify-content:space-between;background-color:#533a38;color:#ecddc9;padding:12px 16px;flex-shrink:0}.container[_ngcontent-%COMP%] .help-content[_ngcontent-%COMP%] .title-bar[_ngcontent-%COMP%] h2[_ngcontent-%COMP%]{margin:0}.container[_ngcontent-%COMP%] .help-content[_ngcontent-%COMP%] .title-bar[_ngcontent-%COMP%] button[_ngcontent-%COMP%]{font-family:"04b03",Courier New,Courier,monospace;color:#ecddc9;background:none;outline:none;border:none;font-size:15px}.container[_ngcontent-%COMP%] .help-content[_ngcontent-%COMP%] .content[_ngcontent-%COMP%]{font-family:Roboto,Helvetica,sans-serif;flex-grow:1;overflow-x:hidden;overflow-y:auto;padding:4px 16px}.container[_ngcontent-%COMP%] .help-content[_ngcontent-%COMP%] h1[_ngcontent-%COMP%]{color:#146039;font-size:16px;font-weight:700;margin-top:28px}.container[_ngcontent-%COMP%] .help-content[_ngcontent-%COMP%] h1.main[_ngcontent-%COMP%]{color:#a85d5d;font-size:20px}.container[_ngcontent-%COMP%] .help-content[_ngcontent-%COMP%] h2[_ngcontent-%COMP%]{font-size:15px;font-weight:500}.container[_ngcontent-%COMP%] .help-content[_ngcontent-%COMP%] ul[_ngcontent-%COMP%] > li[_ngcontent-%COMP%]:not(:first-child){margin-top:8px}.container[_ngcontent-%COMP%] .help-content[_ngcontent-%COMP%] li[_ngcontent-%COMP%] > div[_ngcontent-%COMP%]{margin-top:4px}.container[_ngcontent-%COMP%] .help-content[_ngcontent-%COMP%] a[_ngcontent-%COMP%]{color:#533a38}']})};var Ks=class e{static \u0275fac=function(n){return new(n||e)};static \u0275cmp=vn({type:e,selectors:[["app-root"]],decls:1,vars:0,template:function(n,r){n&1&&ar(0,"garden")},dependencies:[Zs],encapsulation:2})};Fl(Ks,Ag).catch(e=>console.error(e)); ================================================ FILE: Android/src/app/src/main/assets/tinygarden/styles-63IRQW2E.css ================================================ /* * 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. */ body,html{margin:0;padding:0;width:100%;height:100%;overflow:hidden}@font-face{font-family:"04b03";src:url("./media/04B_03-VT65MRZF.ttf")}.yellow{color:#b59018}.blue{color:#3e9be7}.red{color:#d53d3d}.green{color:#07b449} ================================================ FILE: Android/src/app/src/main/bundle_config.pb.json ================================================ // Bundle config for the Google AI Edge Gallery app. // See: https://developer.android.com/studio/build/building-cmdline#bundleconfig { "optimizations": { "splitsConfig": { "splitDimension": [ { "value": "ABI" }, { "value": "SCREEN_DENSITY" }, { "value": "LANGUAGE" } ] } } } ================================================ FILE: Android/src/app/src/main/java/com/google/ai/edge/gallery/Analytics.kt ================================================ /* * Copyright 2025 Google LLC * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * http://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. */ package com.google.ai.edge.gallery import android.util.Log import com.google.firebase.Firebase import com.google.firebase.analytics.FirebaseAnalytics import com.google.firebase.analytics.analytics private var hasLoggedAnalyticsWarning = false val firebaseAnalytics: FirebaseAnalytics? get() = runCatching { Firebase.analytics } .onFailure { exception -> // Firebase.analytics can throw an exception if goolgle-services is not set up, e.g., // missing google-services.json. if (!hasLoggedAnalyticsWarning) { Log.w("AGAnalyticsFirebase", "Firebase Analytics is not available", exception) } } .getOrNull() enum class GalleryEvent(val id: String) { CAPABILITY_SELECT(id = "capability_select"), MODEL_DOWNLOAD(id = "model_download"), GENERATE_ACTION(id = "generate_action"), BUTTON_CLICKED(id = "button_clicked"), } ================================================ FILE: Android/src/app/src/main/java/com/google/ai/edge/gallery/BenchmarkResultsSerializer.kt ================================================ /* * Copyright 2026 Google LLC * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * http://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. */ package com.google.ai.edge.gallery import androidx.datastore.core.CorruptionException import androidx.datastore.core.Serializer import com.google.ai.edge.gallery.proto.BenchmarkResults import com.google.protobuf.InvalidProtocolBufferException import java.io.InputStream import java.io.OutputStream object BenchmarkResultsSerializer : Serializer { override val defaultValue: BenchmarkResults = BenchmarkResults.getDefaultInstance() override suspend fun readFrom(input: InputStream): BenchmarkResults { try { return BenchmarkResults.parseFrom(input) } catch (exception: InvalidProtocolBufferException) { throw CorruptionException("Cannot read proto.", exception) } } override suspend fun writeTo(t: BenchmarkResults, output: OutputStream) = t.writeTo(output) } ================================================ FILE: Android/src/app/src/main/java/com/google/ai/edge/gallery/CutoutsSerializer.kt ================================================ /* * Copyright 2025 Google LLC * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * http://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. */ package com.google.ai.edge.gallery import androidx.datastore.core.CorruptionException import androidx.datastore.core.Serializer import com.google.ai.edge.gallery.proto.CutoutCollection import com.google.protobuf.InvalidProtocolBufferException import java.io.InputStream import java.io.OutputStream object CutoutsSerializer : Serializer { override val defaultValue: CutoutCollection = CutoutCollection.getDefaultInstance() override suspend fun readFrom(input: InputStream): CutoutCollection { try { return CutoutCollection.parseFrom(input) } catch (exception: InvalidProtocolBufferException) { throw CorruptionException("Cannot read proto.", exception) } } override suspend fun writeTo(t: CutoutCollection, output: OutputStream) = t.writeTo(output) } ================================================ FILE: Android/src/app/src/main/java/com/google/ai/edge/gallery/FcmMessagingService.kt ================================================ /* * Copyright 2026 Google LLC * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * http://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. */ package com.google.ai.edge.gallery import android.app.NotificationChannel import android.app.NotificationManager import android.app.PendingIntent import android.content.Context import android.content.Intent import android.media.RingtoneManager import android.os.Build import android.util.Log import androidx.core.app.NotificationCompat import com.google.firebase.messaging.FirebaseMessagingService import com.google.firebase.messaging.RemoteMessage class GalleryFcmMessagingService : FirebaseMessagingService() { override fun onMessageReceived(remoteMessage: RemoteMessage) { // TODO(developer): Handle FCM messages here. // Not getting messages here? See why this may be: https://goo.gl/39bRNJ Log.d(TAG, "From: ${remoteMessage.from}") // Check if message contains a data payload. if (remoteMessage.data.isNotEmpty()) { Log.d(TAG, "Message data payload: ${remoteMessage.data}") // Handle message within 10 seconds handleNow() } // Check if message contains a notification payload. remoteMessage.notification?.let { notification -> Log.d(TAG, "Message Notification Body: ${notification.body}") notification.body?.let { body -> sendNotification(notification.title, body, notification.imageUrl) } } // Also if you intend on generating your own notificatisons as a result of a received FCM // message, here is where that should be initiated. See sendNotification method below. } private fun handleNow() { Log.d(TAG, "Short lived task is done.") } private fun sendNotification(title: String?, messageBody: String, imageUrl: android.net.Uri?) { val intent = Intent(this, MainActivity::class.java) intent.addFlags(Intent.FLAG_ACTIVITY_CLEAR_TOP) val requestCode = 0 val pendingIntent = PendingIntent.getActivity(this, requestCode, intent, PendingIntent.FLAG_IMMUTABLE) val channelId = "gallery_high_priority_push_channel" val defaultSoundUri = RingtoneManager.getDefaultUri(RingtoneManager.TYPE_NOTIFICATION) val notificationBuilder = NotificationCompat.Builder(this, channelId) .setSmallIcon(R.mipmap.ic_launcher) .setContentTitle(title ?: getString(R.string.gallery_news_notification_title)) .setContentText(messageBody) .setAutoCancel(true) .setSound(defaultSoundUri) .setContentIntent(pendingIntent) .setPriority(NotificationCompat.PRIORITY_HIGH) if (imageUrl != null) { try { val url = java.net.URL(imageUrl.toString()) val connection = url.openConnection() connection.connectTimeout = 5000 connection.readTimeout = 5000 val bitmap = android.graphics.BitmapFactory.decodeStream(connection.getInputStream()) if (bitmap != null) { notificationBuilder.setLargeIcon(bitmap) notificationBuilder.setStyle( NotificationCompat.BigPictureStyle() .bigPicture(bitmap) .bigLargeIcon(null as android.graphics.Bitmap?) ) } } catch (e: Exception) { Log.w(TAG, "Failed to download image", e) } } val notificationManager = getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager // Since android Oreo notification channel is needed. if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { val channel = NotificationChannel( channelId, getString(R.string.gallery_news_notification_title), NotificationManager.IMPORTANCE_HIGH, ) notificationManager.createNotificationChannel(channel) } val notificationId = 0 notificationManager.notify(notificationId, notificationBuilder.build()) } companion object { private const val TAG = "AGFcmMessagingService" } } ================================================ FILE: Android/src/app/src/main/java/com/google/ai/edge/gallery/GalleryApp.kt ================================================ /* * Copyright 2025 Google LLC * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * http://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. */ package com.google.ai.edge.gallery import androidx.compose.runtime.Composable import androidx.navigation.NavHostController import androidx.navigation.compose.rememberNavController import com.google.ai.edge.gallery.ui.modelmanager.ModelManagerViewModel import com.google.ai.edge.gallery.ui.navigation.GalleryNavHost /** Top level composable representing the main screen of the application. */ @Composable fun GalleryApp( navController: NavHostController = rememberNavController(), modelManagerViewModel: ModelManagerViewModel, ) { GalleryNavHost(navController = navController, modelManagerViewModel = modelManagerViewModel) } ================================================ FILE: Android/src/app/src/main/java/com/google/ai/edge/gallery/GalleryAppTopBar.kt ================================================ /* * Copyright 2025 Google LLC * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * http://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. */ @file:OptIn(ExperimentalMaterial3Api::class) package com.google.ai.edge.gallery import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.size import androidx.compose.foundation.text.BasicText import androidx.compose.foundation.text.TextAutoSize import androidx.compose.material.icons.Icons import androidx.compose.material.icons.automirrored.rounded.ArrowBack import androidx.compose.material.icons.rounded.Menu import androidx.compose.material.icons.rounded.Settings import androidx.compose.material3.CenterAlignedTopAppBar 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.unit.dp import androidx.compose.ui.unit.sp import com.google.ai.edge.gallery.data.AppBarAction import com.google.ai.edge.gallery.data.AppBarActionType /** The top app bar. */ @OptIn(ExperimentalMaterial3Api::class) @Composable fun GalleryTopAppBar( title: String, modifier: Modifier = Modifier, leftAction: AppBarAction? = null, rightAction: AppBarAction? = null, scrollBehavior: TopAppBarScrollBehavior? = null, subtitle: String = "", ) { val titleColor = MaterialTheme.colorScheme.onSurface 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 = null, tint = Color.Unspecified, ) } BasicText( text = title, maxLines = 1, color = { titleColor }, style = MaterialTheme.typography.titleMedium, autoSize = TextAutoSize.StepBased(minFontSize = 14.sp, maxFontSize = 16.sp, stepSize = 1.sp), ) } if (subtitle.isNotEmpty()) { Text( subtitle, style = MaterialTheme.typography.labelSmall, color = MaterialTheme.colorScheme.secondary, ) } } }, modifier = modifier, scrollBehavior = scrollBehavior, // The button at the left. navigationIcon = { when (leftAction?.actionType) { AppBarActionType.NAVIGATE_UP -> { IconButton(onClick = leftAction.actionFn) { Icon( imageVector = Icons.AutoMirrored.Rounded.ArrowBack, contentDescription = stringResource(R.string.cd_navigate_back_icon), ) } } AppBarActionType.MENU -> { IconButton(onClick = leftAction.actionFn) { Icon( imageVector = Icons.Rounded.Menu, contentDescription = stringResource(R.string.cd_menu), ) } } 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 = stringResource(R.string.cd_app_settings_icon), tint = MaterialTheme.colorScheme.onSurface, ) } } // Click a button to navigate up. AppBarActionType.NAVIGATE_UP -> { TextButton(onClick = rightAction.actionFn) { Text("Done") } } else -> {} } }, ) } ================================================ FILE: Android/src/app/src/main/java/com/google/ai/edge/gallery/GalleryApplication.kt ================================================ /* * Copyright 2025 Google LLC * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * http://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. */ package com.google.ai.edge.gallery import android.app.Application import com.google.ai.edge.gallery.data.DataStoreRepository import com.google.ai.edge.gallery.ui.theme.ThemeSettings import com.google.firebase.FirebaseApp import dagger.hilt.android.HiltAndroidApp import javax.inject.Inject @HiltAndroidApp class GalleryApplication : Application() { @Inject lateinit var dataStoreRepository: DataStoreRepository override fun onCreate() { super.onCreate() // Load saved theme. ThemeSettings.themeOverride.value = dataStoreRepository.readTheme() FirebaseApp.initializeApp(this) } } ================================================ FILE: Android/src/app/src/main/java/com/google/ai/edge/gallery/GalleryLifecycleProvider.kt ================================================ /* * Copyright 2025 Google LLC * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * http://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. */ package com.google.ai.edge.gallery interface AppLifecycleProvider { var isAppInForeground: Boolean } class GalleryLifecycleProvider : AppLifecycleProvider { private var _isAppInForeground = false override var isAppInForeground: Boolean get() = _isAppInForeground set(value) { _isAppInForeground = value } } ================================================ FILE: Android/src/app/src/main/java/com/google/ai/edge/gallery/MainActivity.kt ================================================ /* * Copyright 2025 Google LLC * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * http://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. */ package com.google.ai.edge.gallery import android.animation.ObjectAnimator import android.os.Build import android.os.Bundle import android.view.View import android.view.WindowManager import android.view.animation.DecelerateInterpolator import androidx.activity.ComponentActivity import androidx.activity.compose.setContent import androidx.activity.enableEdgeToEdge import androidx.activity.viewModels import androidx.compose.animation.AnimatedVisibility import androidx.compose.animation.core.FastOutSlowInEasing import androidx.compose.animation.core.snap import androidx.compose.animation.core.tween import androidx.compose.animation.fadeIn import androidx.compose.animation.fadeOut import androidx.compose.foundation.background import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Surface import androidx.compose.runtime.LaunchedEffect 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.core.animation.doOnEnd import androidx.core.os.bundleOf import androidx.core.splashscreen.SplashScreen.Companion.installSplashScreen import androidx.lifecycle.lifecycleScope import com.google.ai.edge.gallery.ui.modelmanager.ModelManagerViewModel import com.google.ai.edge.gallery.ui.theme.GalleryTheme import com.google.ai.edge.litertlm.ExperimentalApi import com.google.ai.edge.litertlm.ExperimentalFlags import com.google.firebase.analytics.FirebaseAnalytics import dagger.hilt.android.AndroidEntryPoint import kotlinx.coroutines.delay import kotlinx.coroutines.launch @AndroidEntryPoint class MainActivity : ComponentActivity() { private val modelManagerViewModel: ModelManagerViewModel by viewModels() private var splashScreenAboutToExit: Boolean = false private var contentSet: Boolean = false override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) fun setContent() { if (contentSet) { return } setContent { GalleryTheme { Surface(modifier = Modifier.fillMaxSize()) { GalleryApp(modelManagerViewModel = modelManagerViewModel) // Fade out a "mask" that has the same color as the background of the splash screen // to reveal the actual app content. var startMaskFadeout by remember { mutableStateOf(false) } LaunchedEffect(Unit) { startMaskFadeout = true } AnimatedVisibility( !startMaskFadeout, enter = fadeIn(animationSpec = snap(0)), exit = fadeOut(animationSpec = tween(durationMillis = 400, easing = FastOutSlowInEasing)), ) { Box( modifier = Modifier.fillMaxSize().background(MaterialTheme.colorScheme.background) ) } } } } @OptIn(ExperimentalApi::class) ExperimentalFlags.enableBenchmark = false contentSet = true } modelManagerViewModel.loadModelAllowlist() // Show splash screen. val splashScreen = installSplashScreen() // Set the content when the system-provided splash screen is not shown. // // This is necessary on some Android versions where the splash screen is optimized away (e.g., // after a force-quit) to ensure the main content is displayed immediately and correctly. lifecycleScope.launch { delay(1000) if (!splashScreenAboutToExit) { setContent() } } // Cross-fade transition from the splash screen to the main content. // // The logic performs the following key actions: // 1. Synchronizes Timing: It calculates the remaining duration of the default icon // animation. It then delays its own animations to ensure the custom fade-out begins just // before the original icon animation would have finished. // 2. Initiates a cross-fade: // - Fade out the splash screen. // - Fade in the main content. // 3. Cleans up: An `onEnd` listener on the fade-out animator calls // `splashScreenView.remove()` to properly remove the splash screen from the view hierarchy // once it's fully transparent. splashScreen.setOnExitAnimationListener { splashScreenView -> splashScreenAboutToExit = true val now = System.currentTimeMillis() val iconAnimationStartMs = splashScreenView.iconAnimationStartMillis val duration = splashScreenView.iconAnimationDurationMillis val fadeOut = ObjectAnimator.ofFloat(splashScreenView.view, View.ALPHA, 1f, 0f) fadeOut.interpolator = DecelerateInterpolator() fadeOut.duration = 300L fadeOut.doOnEnd { splashScreenView.remove() } lifecycleScope.launch { val setContentDelay = duration - (now - iconAnimationStartMs) - 300 if (setContentDelay > 0) { delay(setContentDelay) } setContent() fadeOut.start() } } enableEdgeToEdge() if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) { // Fix for three-button nav not properly going edge-to-edge. // See: https://issuetracker.google.com/issues/298296168 window.isNavigationBarContrastEnforced = false } // Keep the screen on while the app is running for better demo experience. window.addFlags(WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON) } override fun onResume() { super.onResume() firebaseAnalytics?.logEvent( FirebaseAnalytics.Event.APP_OPEN, bundleOf( "app_version" to BuildConfig.VERSION_NAME, "os_version" to Build.VERSION.SDK_INT.toString(), "device_model" to Build.MODEL, ), ) } companion object { private const val TAG = "AGMainActivity" } } ================================================ FILE: Android/src/app/src/main/java/com/google/ai/edge/gallery/SettingsSerializer.kt ================================================ /* * Copyright 2025 Google LLC * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * http://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. */ package com.google.ai.edge.gallery import androidx.datastore.core.CorruptionException import androidx.datastore.core.Serializer import com.google.ai.edge.gallery.proto.Settings import com.google.protobuf.InvalidProtocolBufferException import java.io.InputStream import java.io.OutputStream object SettingsSerializer : Serializer { override val defaultValue: Settings = Settings.getDefaultInstance() override suspend fun readFrom(input: InputStream): Settings { try { return Settings.parseFrom(input) } catch (exception: InvalidProtocolBufferException) { throw CorruptionException("Cannot read proto.", exception) } } override suspend fun writeTo(t: Settings, output: OutputStream) = t.writeTo(output) } ================================================ FILE: Android/src/app/src/main/java/com/google/ai/edge/gallery/UserDataSerializer.kt ================================================ /* * Copyright 2025 Google LLC * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * http://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. */ package com.google.ai.edge.gallery import androidx.datastore.core.CorruptionException import androidx.datastore.core.Serializer import com.google.ai.edge.gallery.proto.UserData import com.google.protobuf.InvalidProtocolBufferException import java.io.InputStream import java.io.OutputStream object UserDataSerializer : Serializer { override val defaultValue: UserData = UserData.getDefaultInstance() override suspend fun readFrom(input: InputStream): UserData { try { return UserData.parseFrom(input) } catch (exception: InvalidProtocolBufferException) { throw CorruptionException("Cannot read proto.", exception) } } override suspend fun writeTo(t: UserData, output: OutputStream) = t.writeTo(output) } ================================================ FILE: Android/src/app/src/main/java/com/google/ai/edge/gallery/common/ProjectConfig.kt ================================================ /* * Copyright 2025 Google LLC * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * http://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. */ package com.google.ai.edge.gallery.common import androidx.core.net.toUri import net.openid.appauth.AuthorizationServiceConfiguration object ProjectConfig { // Hugging Face Client ID. // const val clientId = "REPLACE_WITH_YOUR_CLIENT_ID_IN_HUGGINGFACE_APP" // Registered redirect URI. // // The scheme needs to match the // "android.defaultConfig.manifestPlaceholders["appAuthRedirectScheme"]" field in // "build.gradle.kts". const val redirectUri = "REPLACE_WITH_YOUR_REDIRECT_URI_IN_HUGGINGFACE_APP" // 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( authEndpoint.toUri(), // Authorization endpoint tokenEndpoint.toUri(), // Token exchange endpoint ) } ================================================ FILE: Android/src/app/src/main/java/com/google/ai/edge/gallery/common/Types.kt ================================================ /* * Copyright 2025 Google LLC * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * http://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. */ package com.google.ai.edge.gallery.common import androidx.compose.ui.graphics.Color import com.squareup.moshi.JsonClass import kotlinx.coroutines.CompletableDeferred interface LatencyProvider { val latencyMs: Float } data class Classification(val label: String, val score: Float, val color: Color) data class JsonObjAndTextContent(val jsonObj: T, val textContent: String) class AudioClip(val audioData: ByteArray, val sampleRate: Int) ================================================ FILE: Android/src/app/src/main/java/com/google/ai/edge/gallery/common/Utils.kt ================================================ /* * Copyright 2025 Google LLC * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * http://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. */ package com.google.ai.edge.gallery.common import android.content.Context import android.graphics.Bitmap import android.graphics.BitmapFactory import android.graphics.Matrix import android.net.Uri import android.os.Build import android.util.Log import androidx.exifinterface.media.ExifInterface import com.google.ai.edge.gallery.data.SAMPLE_RATE import com.google.gson.Gson import java.io.File import java.io.FileInputStream import java.net.HttpURLConnection import java.net.URL import java.nio.ByteBuffer import java.nio.ByteOrder import java.nio.channels.FileChannel import kotlin.math.abs import kotlin.math.floor import kotlin.math.max import kotlin.math.roundToInt private const val TAG = "AGUtils" const val LOCAL_URL_BASE = "https://appassets.androidplatform.net" fun cleanUpMediapipeTaskErrorMessage(message: String): String { val index = message.indexOf("=== Source Location Trace") if (index >= 0) { return message.substring(0, index) } return message } fun processLlmResponse(response: String): String { return response.replace("\\n", "\n") } inline fun getJsonResponse(url: String): JsonObjAndTextContent? { try { val connection = URL(url).openConnection() as HttpURLConnection connection.requestMethod = "GET" connection.connect() val responseCode = connection.responseCode if (responseCode == HttpURLConnection.HTTP_OK) { val inputStream = connection.inputStream val response = inputStream.bufferedReader().use { it.readText() } val jsonObj = parseJson(response) return if (jsonObj != null) { JsonObjAndTextContent(jsonObj = jsonObj, textContent = response) } else { null } } else { Log.e("AGUtils", "HTTP error: $responseCode") } } catch (e: Exception) { Log.e("AGUtils", "Error when getting or parsing json response", e) } return null } /** Parses a JSON string into an object of type [T] using Gson. */ inline fun parseJson(response: String): T? { return try { val gson = Gson() gson.fromJson(response, T::class.java) } catch (e: Exception) { Log.e("AGUtils", "Error parsing JSON string", e) null } } fun convertWavToMonoWithMaxSeconds( context: Context, stereoUri: Uri, maxSeconds: Int = 30, ): AudioClip? { Log.d(TAG, "Start to convert wav file to mono channel") try { val inputStream = (if (stereoUri.scheme == null || stereoUri.scheme == "file") { FileInputStream(stereoUri.path ?: "") } else { context.contentResolver.openInputStream(stereoUri) }) ?: return null val originalBytes = inputStream.readBytes() inputStream.close() // Read WAV header if (originalBytes.size < 44) { // Not a valid WAV file Log.e(TAG, "Not a valid wav file") return null } val headerBuffer = ByteBuffer.wrap(originalBytes, 0, 44).order(ByteOrder.LITTLE_ENDIAN) val channels = headerBuffer.getShort(22) var sampleRate = headerBuffer.getInt(24) val bitDepth = headerBuffer.getShort(34) Log.d(TAG, "File metadata: channels: $channels, sampleRate: $sampleRate, bitDepth: $bitDepth") // Normalize audio to 16-bit. val audioDataBytes = originalBytes.copyOfRange(fromIndex = 44, toIndex = originalBytes.size) var sixteenBitBytes: ByteArray = if (bitDepth.toInt() == 8) { Log.d(TAG, "Converting 8-bit audio to 16-bit.") convert8BitTo16Bit(audioDataBytes) } else { // Assume 16-bit or other format that can be handled directly audioDataBytes } // Convert byte array to short array for processing val shortBuffer = ByteBuffer.wrap(sixteenBitBytes).order(ByteOrder.LITTLE_ENDIAN).asShortBuffer() var pcmSamples = ShortArray(shortBuffer.remaining()) shortBuffer.get(pcmSamples) // Resample if sample rate is less than 16000 Hz --- if (sampleRate < SAMPLE_RATE) { Log.d(TAG, "Resampling from $sampleRate Hz to $SAMPLE_RATE Hz.") pcmSamples = resample(pcmSamples, sampleRate, SAMPLE_RATE, channels.toInt()) sampleRate = SAMPLE_RATE Log.d(TAG, "Resampling complete. New sample count: ${pcmSamples.size}") } // Convert stereo to mono if necessary var monoSamples = if (channels.toInt() == 2) { Log.d(TAG, "Converting stereo to mono.") val mono = ShortArray(pcmSamples.size / 2) for (i in mono.indices) { val left = pcmSamples[i * 2] val right = pcmSamples[i * 2 + 1] mono[i] = ((left + right) / 2).toShort() } mono } else { Log.d(TAG, "Audio is already mono. No channel conversion needed.") pcmSamples } // Trim the audio to maxSeconds --- val maxSamples = maxSeconds * sampleRate if (monoSamples.size > maxSamples) { Log.d(TAG, "Trimming clip from ${monoSamples.size} samples to $maxSamples samples.") monoSamples = monoSamples.copyOfRange(0, maxSamples) } val monoByteBuffer = ByteBuffer.allocate(monoSamples.size * 2).order(ByteOrder.LITTLE_ENDIAN) monoByteBuffer.asShortBuffer().put(monoSamples) return AudioClip(audioData = monoByteBuffer.array(), sampleRate = sampleRate) } catch (e: Exception) { Log.e(TAG, "Failed to convert wav to mono", e) return null } } /** Converts 8-bit unsigned PCM audio data to 16-bit signed PCM. */ private fun convert8BitTo16Bit(eightBitData: ByteArray): ByteArray { // The new 16-bit data will be twice the size val sixteenBitData = ByteArray(eightBitData.size * 2) val buffer = ByteBuffer.wrap(sixteenBitData).order(ByteOrder.LITTLE_ENDIAN) for (byte in eightBitData) { // Convert the unsigned 8-bit byte (0-255) to a signed 16-bit short (-32768 to 32767) // 1. Get the unsigned value by masking with 0xFF // 2. Subtract 128 to center the waveform around 0 (range becomes -128 to 127) // 3. Scale by 256 to expand to the 16-bit range val unsignedByte = byte.toInt() and 0xFF val sixteenBitSample = ((unsignedByte - 128) * 256).toShort() buffer.putShort(sixteenBitSample) } return sixteenBitData } /** Resamples PCM audio data from an original sample rate to a target sample rate. */ private fun resample( inputSamples: ShortArray, originalSampleRate: Int, targetSampleRate: Int, channels: Int, ): ShortArray { if (originalSampleRate == targetSampleRate) { return inputSamples } val ratio = targetSampleRate.toDouble() / originalSampleRate val outputLength = (inputSamples.size * ratio).toInt() val resampledData = ShortArray(outputLength) if (channels == 1) { // Mono for (i in resampledData.indices) { val position = i / ratio val index1 = floor(position).toInt() val index2 = index1 + 1 val fraction = position - index1 val sample1 = if (index1 < inputSamples.size) inputSamples[index1].toDouble() else 0.0 val sample2 = if (index2 < inputSamples.size) inputSamples[index2].toDouble() else 0.0 resampledData[i] = (sample1 * (1 - fraction) + sample2 * fraction).toInt().toShort() } } return resampledData } fun calculatePeakAmplitude(buffer: ByteArray, bytesRead: Int): Int { // Wrap the byte array in a ByteBuffer and set the order to little-endian val shortBuffer = ByteBuffer.wrap(buffer, 0, bytesRead).order(ByteOrder.LITTLE_ENDIAN).asShortBuffer() var maxAmplitude = 0 // Iterate through the short buffer to find the maximum absolute value while (shortBuffer.hasRemaining()) { val currentSample = abs(shortBuffer.get().toInt()) if (currentSample > maxAmplitude) { maxAmplitude = currentSample } } return maxAmplitude } fun decodeSampledBitmapFromUri(context: Context, uri: Uri, reqWidth: Int, reqHeight: Int): Bitmap? { // First, decode with inJustDecodeBounds=true to check dimensions val options = BitmapFactory.Options().apply { inJustDecodeBounds = true (if (uri.scheme == null || uri.scheme == "file") { FileInputStream(uri.path ?: "") } else { context.contentResolver.openInputStream(uri) }) ?.use { BitmapFactory.decodeStream(it, null, this) } // Calculate inSampleSize inSampleSize = calculateInSampleSize(this, reqWidth, reqHeight) // Decode bitmap with inSampleSize set inJustDecodeBounds = false } return (if (uri.scheme == null || uri.scheme == "file") { FileInputStream(uri.path ?: "") } else { context.contentResolver.openInputStream(uri) }) ?.use { BitmapFactory.decodeStream(it, null, options) } } fun rotateBitmap(bitmap: Bitmap, orientation: Int): Bitmap { val matrix = Matrix() when (orientation) { ExifInterface.ORIENTATION_ROTATE_90 -> matrix.postRotate(90f) ExifInterface.ORIENTATION_ROTATE_180 -> matrix.postRotate(180f) ExifInterface.ORIENTATION_ROTATE_270 -> matrix.postRotate(270f) ExifInterface.ORIENTATION_FLIP_HORIZONTAL -> matrix.preScale(-1.0f, 1.0f) ExifInterface.ORIENTATION_FLIP_VERTICAL -> matrix.preScale(1.0f, -1.0f) ExifInterface.ORIENTATION_TRANSPOSE -> { matrix.postRotate(90f) matrix.preScale(-1.0f, 1.0f) } ExifInterface.ORIENTATION_TRANSVERSE -> { matrix.postRotate(270f) matrix.preScale(-1.0f, 1.0f) } ExifInterface.ORIENTATION_NORMAL -> return bitmap else -> return bitmap } return Bitmap.createBitmap(bitmap, 0, 0, bitmap.width, bitmap.height, matrix, true) } private fun calculateInSampleSize( options: BitmapFactory.Options, reqWidth: Int, reqHeight: Int, ): Int { // Raw height and width of image val height: Int = options.outHeight val width: Int = options.outWidth var inSampleSize = 1 if (height > reqHeight || width > reqWidth) { // Calculate the ratio of height and width to the requested height and width val heightRatio = (height.toFloat() / reqHeight.toFloat()).roundToInt() val widthRatio = (width.toFloat() / reqWidth.toFloat()).roundToInt() // Choose the largest ratio as inSampleSize value to ensure // that both dimensions are smaller than or equal to the requested dimensions. inSampleSize = max(heightRatio, widthRatio) } return inSampleSize } 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 } } fun isPixel10(): Boolean { return Build.MODEL != null && Build.MODEL.lowercase().contains("pixel 10") } ================================================ FILE: Android/src/app/src/main/java/com/google/ai/edge/gallery/customtasks/common/CustomTask.kt ================================================ /* * Copyright 2025 Google LLC * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * http://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. */ package com.google.ai.edge.gallery.customtasks.common import android.content.Context import androidx.compose.runtime.Composable import com.google.ai.edge.gallery.data.Model import com.google.ai.edge.gallery.data.Task import kotlinx.coroutines.CoroutineScope /** * A CustomTask is a user-defined task that can be dynamically added to the app. * * The user journey for a custom task begins on the home screen, which is organized into categories. * These categories correspond to the tabs in the main navigation. Within each category, a list of * tasks is displayed. A task represents a specific functionality or use case, and it includes * metadata like a label, description, and icon, which are all defined in the `task` property of * your `CustomTask` implementation. * * When a user selects a task on the home screen, they are taken to the task's detail screen. This * screen displays the task's description and presents a list of associated models. A single task * can have multiple models, allowing the user to choose between different implementations or * versions (e.g., different LLM models for a "Chat" task). The user can then select and run a * specific model for the task. * * To create your own custom task, follow these steps: * 1. Create a class that implements this `CustomTask` interface. * 2. Define the metadata for your task in the `task` property, including its label, description, * and associated models. * 3. Implement the `initializeModelFn` and `cleanUpModelFn` functions to handle the setup and * teardown logic for your task's models. * 4. Implement the `MainScreen` composable to define the UI for your task's model detail screen. * This is where the user will interact with the model within your task. It's important to note * that this UI will be placed inside a pre-configured `Scaffold` that already handles the app * bar, *which includes the model name, a model selector, and a configuration button. Your focus * here should be on building the main content area of the screen. * 5. Create a Hilt module and use `@Provides` and `@IntoSet` to bind your custom task * implementation into a set of `CustomTask`s. This makes your task automatically discoverable by * the app's home screen. * * For a concrete example of how to implement these steps, see the * [com.google.ai.edge.gallery.customtasks.examplecustomtask.ExampleCustomTask] class. This example * implements a "Model Viewer" task that displays the text content of a model file for demonstration * purpose. See comments there for more details. * */ interface CustomTask { /** * The metadata for your task and the models within the task. * * See comments of [Task] for details. */ val task: Task /** * Called to initialize and prepare a model for use. * * This function will be called from a coroutine with Dispatchers.Default dispatcher. * * @param context The application context. * @param coroutineScope The coroutine scope for asynchronous operations. * @param model The `Model` object containing information about the model to be initialized. * @param onDone A callback function to be invoked when initialization is complete. Pass an empty * string on success, or an error message on failure. */ fun initializeModelFn( context: Context, coroutineScope: CoroutineScope, model: Model, onDone: (error: String) -> Unit, ) /** * Called to clean up resources associated with a model. * * @param context The application context. * @param coroutineScope The coroutine scope for asynchronous operations. * @param model The `Model` object to be cleaned up. * @param onDone A callback function to be invoked when cleanup is complete. */ fun cleanUpModelFn( context: Context, coroutineScope: CoroutineScope, model: Model, onDone: () -> Unit, ) /** * The main Composable UI for your custom task's detail screen. * * @param data The data sent from the app. It will typically be a [CustomTaskData]. */ @Composable fun MainScreen(data: Any) } ================================================ FILE: Android/src/app/src/main/java/com/google/ai/edge/gallery/customtasks/common/CustomTaskData.kt ================================================ /* * Copyright 2025 Google LLC * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * http://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. */ package com.google.ai.edge.gallery.customtasks.common import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.dp import com.google.ai.edge.gallery.ui.modelmanager.ModelManagerViewModel /** * Data class to hold information passed to the `MainScreen` composable of a custom task. * * @param modelManagerViewModel The ViewModel providing access to the state of models and their * management. * @param bottomPadding The bottom padding of the Scaffold's `innerPadding`. By default, your * `MainScreen` will extend to the bottom edge. Use this value if you need to apply padding to the * bottom of your screen's content to account for elements like a bottom navigation bar. * @param setAppBarControlsDisabled A callback function that the custom task screen can call to * enable and disable controls (e.g. back button, configs, etc) in the app bar. * @param setTopBarVisible A callback function that the custom task screen can call to show and hide * the top bar. */ data class CustomTaskData( val modelManagerViewModel: ModelManagerViewModel, val bottomPadding: Dp = 0.dp, val setAppBarControlsDisabled: (Boolean) -> Unit = {}, val setTopBarVisible: (Boolean) -> Unit = {}, val setCustomNavigateUpCallback: ((() -> Unit)?) -> Unit = {}, ) data class CustomTaskDataForBuiltinTask( val modelManagerViewModel: ModelManagerViewModel, val onNavUp: () -> Unit, ) ================================================ FILE: Android/src/app/src/main/java/com/google/ai/edge/gallery/customtasks/examplecustomtask/ExampleCustomTask.kt ================================================ /* * Copyright 2025 Google LLC * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * http://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. */ package com.google.ai.edge.gallery.customtasks.examplecustomtask import android.content.Context import androidx.compose.material.icons.Icons import androidx.compose.material.icons.outlined.TextFields import androidx.compose.runtime.Composable import com.google.ai.edge.gallery.customtasks.common.CustomTask import com.google.ai.edge.gallery.customtasks.common.CustomTaskData import com.google.ai.edge.gallery.data.CategoryInfo import com.google.ai.edge.gallery.data.Model import com.google.ai.edge.gallery.data.Task import com.google.ai.edge.gallery.ui.modelmanager.ModelManagerViewModel import java.io.File import javax.inject.Inject import kotlin.math.min import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.delay import kotlinx.coroutines.launch /** * An example implementation of a `CustomTask` that demonstrates how to display the content of a * text-based model file. * * This class provides two primary examples of how to configure models: * 1. A "Local model" that expects a file (`model.txt`) to be manually pushed to the device. The * `localFileRelativeDirPathOverride` field is used to specify this behavior. * 2. A "Remote model" that downloads a file (`README.md`) from a URL. The `url` and * `downloadFileName` fields are used for this configuration. * * It showcases the following key functionalities: * - Task Definition: The `task` property defines the task's metadata, including its name ("Model * Viewer"), category, description, and the list of models it supports. * - Model Initialization: The `initializeModelFn` function shows how to read the content of the * model file (either local or downloaded) and store it in a custom * `ExampleCustomTaskModelInstance`. It also demonstrates how to access model-specific * configurations, such as `maxCharCount`, which can be updated by the user. * - Model Cleanup: The `cleanUpModelFn` function is a simple example of how to release resources by * nullifying the model instance. * - UI Integration: The `MainScreen` composable provides the UI for the task, displaying the * model's content. It uses a `ViewModel` to manage UI state, such as text color, and reacts to * changes in model configurations, like font size. */ class ExampleCustomTask @Inject constructor() : CustomTask { override val task: Task = Task( id = "example_custom_task", label = "Model Viewer", category = CategoryInfo(id = "example", label = "Example"), icon = Icons.Outlined.TextFields, description = "This example task demonstrates a custom task that reads and displays the content of a " + "model file (with text content for demonstration purpose). The \"models\" listed " + "below are configured in different ways in terms of how the model file is provided " + "(pushed to device manually, vs downloaded from internet).", docUrl = "https://github.com/google-ai-edge/gallery/Android/src/app/src/main/java/com/google/ai/edge/gallery/customtasks/common/CustomTask.kt", sourceCodeUrl = "https://github.com/google-ai-edge/gallery/Android/src/app/src/main/java/com/google/ai/edge/gallery/customtasks/examplecustomtask/ExampleCustomTask.kt", models = mutableListOf( Model( name = "Local model", info = "Expects to read the model file `model.txt` manually pushed to `{ext_files_dir}/example_task/`.", localFileRelativeDirPathOverride = "example_task/", bestForTaskIds = listOf("example_custom_task"), configs = EXAMPLE_CUSTOM_TASK_CONFIGS, ), Model( name = "Remote model", info = "Downloads the model file (a README.md file for demonstration purpose) from internet.", url = "https://raw.githubusercontent.com/google-ai-edge/gallery/refs/heads/main/README.md", sizeInBytes = 3798L, downloadFileName = "README.md", configs = EXAMPLE_CUSTOM_TASK_CONFIGS, ), ), ) override fun initializeModelFn( context: Context, coroutineScope: CoroutineScope, model: Model, onDone: (String) -> Unit, ) { coroutineScope.launch(Dispatchers.IO) { model.instance = null try { // Read model file content. val file = // Remote model if (model.localFileRelativeDirPathOverride.isEmpty()) File(model.getPath(context = context)) // Local model else File(model.getPath(context = context, fileName = "model.txt")) var content = file.readText() // Use the value from model's configuration to cap the max number of characters for the // content. val maxCharCount = model.getIntConfigValue(key = EXAMPLE_CUSTOM_TASK_CONFIG_KEY_MAX_CHAR_COUNT) content = content.substring(0, min(content.length, maxCharCount)) // Set model instance. // // For this example, we're just storing the text content as a data class instance. // In a real application, this instance would be an object that provides // inference capabilities, such as a TFLite interpreter or a pointer to a model // loaded in memory. model.instance = ExampleCustomTaskModelInstance(content = content) // Simulate long initialization time. delay(1500) // Notify the initialization is done. onDone("") } catch (e: Exception) { // Handle errors. onDone(e.message ?: "Failed to read model file") } } } override fun cleanUpModelFn( context: Context, coroutineScope: CoroutineScope, model: Model, onDone: () -> Unit, ) { // In a real application, this is where you would release resources // associated with the model, such as closing a TFLite interpreter // or freeing up model memory. For this example, we simply set the // instance to null. model.instance = null // Notify the cleanup is done. onDone() } @Composable override fun MainScreen(data: Any) { // The ModelManagerViewModel is essential for accessing the state of the currently // selected model, its initialization status, etc. // This allows the UI to react to changes, such as displaying the model's content // only after it has been successfully initialized. val myData = data as CustomTaskData val modelManagerViewModel: ModelManagerViewModel = myData.modelManagerViewModel ExampleCustomTaskScreen(modelManagerViewModel = modelManagerViewModel) } } ================================================ FILE: Android/src/app/src/main/java/com/google/ai/edge/gallery/customtasks/examplecustomtask/ExampleCustomTaskModule.kt ================================================ /* * Copyright 2025 Google LLC * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * http://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. */ package com.google.ai.edge.gallery.customtasks.examplecustomtask import com.google.ai.edge.gallery.customtasks.common.CustomTask import dagger.Module import dagger.Provides import dagger.hilt.InstallIn import dagger.hilt.components.SingletonComponent import dagger.multibindings.IntoSet /** * A Hilt module that provides the `ExampleCustomTask` implementation. * * This module is crucial for integrating your custom task into the application's plugin system. By * using `@Provides` and `@IntoSet`, you are telling Hilt to add an instance of `ExampleCustomTask` * to a `Set`, which the main app will use to discover all available custom tasks * without needing to know about each one individually. */ @Module @InstallIn(SingletonComponent::class) // Or another component that fits your scope internal object ExampleCustomTaskModule { /* Remove comment to enable the function to see this example custom task in action in the app. @Provides @IntoSet fun provideExampleCustomTask(): CustomTask { return ExampleCustomTask() } */ } ================================================ FILE: Android/src/app/src/main/java/com/google/ai/edge/gallery/customtasks/examplecustomtask/ExampleCustomTaskScreen.kt ================================================ /* * Copyright 2025 Google LLC * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * http://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. */ package com.google.ai.edge.gallery.customtasks.examplecustomtask import androidx.hilt.navigation.compose.hiltViewModel 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.fillMaxSize import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.shape.CircleShape import androidx.compose.foundation.verticalScroll import androidx.compose.material.icons.Icons import androidx.compose.material.icons.outlined.Check import androidx.compose.material3.CircularProgressIndicator import androidx.compose.material3.HorizontalDivider import androidx.compose.material3.Icon import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableIntStateOf 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.Color import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp import com.google.ai.edge.gallery.data.ConfigKey import com.google.ai.edge.gallery.data.NumberSliderConfig import com.google.ai.edge.gallery.data.ValueType import com.google.ai.edge.gallery.ui.modelmanager.ModelManagerViewModel data class ExampleCustomTaskModelInstance(val content: String) /** * Configuration keys for the `ExampleCustomTask`. * * These keys are used to uniquely identify and retrieve values for configurable parameters within a * model. */ val EXAMPLE_CUSTOM_TASK_CONFIG_KEY_FONT_SIZE = ConfigKey(id = "font_size", label = "Font size") val EXAMPLE_CUSTOM_TASK_CONFIG_KEY_MAX_CHAR_COUNT = ConfigKey(id = "max_char_count", label = "Max character count") /** * A list of configurable parameters for the `ExampleCustomTask`'s models. * * This list defines two user-adjustable settings that appear in the model configuration dialog: * 1. Font size: A `NumberSliderConfig` that allows the user to change the text font size. * `needReinitialization = false` indicates that changing this value **does not** require the * model to be reloaded, as it's a simple UI change. * 2. Max character count: A `NumberSliderConfig` to cap the amount of text displayed. * `needReinitialization = true` indicates that changing this value **does** require the * `initializeModelFn` to be called again to re-read and truncate the model file content. */ val EXAMPLE_CUSTOM_TASK_CONFIGS = listOf( NumberSliderConfig( key = EXAMPLE_CUSTOM_TASK_CONFIG_KEY_FONT_SIZE, sliderMin = 8f, sliderMax = 24f, defaultValue = 14f, valueType = ValueType.INT, needReinitialization = false, ), NumberSliderConfig( key = EXAMPLE_CUSTOM_TASK_CONFIG_KEY_MAX_CHAR_COUNT, sliderMin = 100f, sliderMax = 2000f, defaultValue = 2000f, valueType = ValueType.INT, needReinitialization = true, ), ) /** The main screen of the example custom task. */ @Composable fun ExampleCustomTaskScreen( modelManagerViewModel: ModelManagerViewModel, viewModel: ExampleCustomTaskViewModel = hiltViewModel(), ) { val colors = listOf(MaterialTheme.colorScheme.onSurface, Color.Red, Color.Green, Color.Blue) val modelManagerUiState by modelManagerViewModel.uiState.collectAsState() val model = modelManagerUiState.selectedModel val uiState by viewModel.uiState.collectAsState() val textColor = uiState.textColor // Get the current font size value from config. // // `modelManagerUiState.configValuesUpdateTrigger` will be updated and trigger a recomposition // when a config value is updated. Use it as the key here to read the font size from the config // whenever it is changed. var fontSize by remember(modelManagerUiState.configValuesUpdateTrigger) { mutableIntStateOf(model.getIntConfigValue(EXAMPLE_CUSTOM_TASK_CONFIG_KEY_FONT_SIZE)) } // Set initial text color. LaunchedEffect(Unit) { viewModel.updateTextColor(color = colors[0]) } if (modelManagerUiState.isModelInitialized(model = model)) { val instance = model.instance as ExampleCustomTaskModelInstance Column { // A list of colors user can click to set the text color. Row( horizontalArrangement = Arrangement.spacedBy(8.dp), verticalAlignment = Alignment.CenterVertically, modifier = Modifier.padding(16.dp), ) { Text("Text color: ") for (color in colors) { Box( modifier = Modifier.size(16.dp).clip(CircleShape).background(color = color).clickable { viewModel.updateTextColor(color = color) }, contentAlignment = Alignment.Center, ) { if (color == textColor) { Icon( Icons.Outlined.Check, tint = MaterialTheme.colorScheme.onPrimary, contentDescription = null, modifier = Modifier.size(12.dp), ) } } } } HorizontalDivider() // Content. Column(modifier = Modifier.weight(1f).verticalScroll(rememberScrollState())) { Text( instance.content, color = textColor, modifier = Modifier.padding(16.dp), style = MaterialTheme.typography.bodyMedium.copy( fontSize = fontSize.sp, lineHeight = (fontSize * 1.3).sp, ), ) } } } // Loading spinner. else { Box(contentAlignment = Alignment.Center, modifier = Modifier.fillMaxSize()) { CircularProgressIndicator( modifier = Modifier.size(24.dp), trackColor = MaterialTheme.colorScheme.surfaceVariant, strokeWidth = 3.dp, ) } } } ================================================ FILE: Android/src/app/src/main/java/com/google/ai/edge/gallery/customtasks/examplecustomtask/ExampleCustomTaskViewModel.kt ================================================ /* * Copyright 2025 Google LLC * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * http://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. */ package com.google.ai.edge.gallery.customtasks.examplecustomtask import androidx.compose.ui.graphics.Color import androidx.lifecycle.ViewModel import dagger.hilt.android.lifecycle.HiltViewModel import javax.inject.Inject import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.update /** * The UI state of the example custom task screen. * * It tracks the current text color. */ data class ExampleCustomTaskUiState(val textColor: Color) /** The ViewModel of the example custom task screen. */ @HiltViewModel class ExampleCustomTaskViewModel @Inject constructor() : ViewModel() { protected val _uiState = MutableStateFlow(ExampleCustomTaskUiState(textColor = Color.Black)) val uiState = _uiState.asStateFlow() fun updateTextColor(color: Color) { val newUiState = uiState.value.copy(textColor = color) _uiState.update { newUiState } } } ================================================ FILE: Android/src/app/src/main/java/com/google/ai/edge/gallery/customtasks/mobileactions/Actions.kt ================================================ /* * Copyright 2025 Google LLC * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * http://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. */ package com.google.ai.edge.gallery.customtasks.mobileactions import androidx.compose.material.icons.Icons import androidx.compose.material.icons.outlined.CalendarMonth import androidx.compose.material.icons.outlined.Email import androidx.compose.material.icons.outlined.FlashOff import androidx.compose.material.icons.outlined.FlashlightOn import androidx.compose.material.icons.outlined.Map import androidx.compose.material.icons.outlined.PersonAdd import androidx.compose.material.icons.outlined.Wifi import androidx.compose.ui.graphics.vector.ImageVector // Supported action types. enum class ActionType { ACTION_FLASHLIGHT_ON, ACTION_FLASHLIGHT_OFF, ACTION_CREATE_CONTACT, ACTION_SEND_EMAIL, ACTION_SHOW_LOCATION_ON_MAP, ACTION_OPEN_WIFI_SETTINGS, ACTION_CREATE_CALENDAR_EVENT, } data class FunctionCallDetails( val functionName: String, val parameters: List>, val ts: Long = System.currentTimeMillis(), ) // Base action class. abstract class Action( // The type of the action. val type: ActionType, // The icon to be displayed next to the model response bubble. val icon: ImageVector, // The function call details to be displayed in the model response. val functionCallDetails: FunctionCallDetails, ) // Action to turn on flashlight. class FlashlightOnAction() : Action( type = ActionType.ACTION_FLASHLIGHT_ON, icon = Icons.Outlined.FlashlightOn, functionCallDetails = FunctionCallDetails(functionName = "turnOnFlashlight", parameters = listOf()), ) // Action to turn off flashlight. class FlashlightOffAction() : Action( type = ActionType.ACTION_FLASHLIGHT_OFF, icon = Icons.Outlined.FlashOff, functionCallDetails = FunctionCallDetails(functionName = "turnOffFlashlight", parameters = listOf()), ) // Action to create contact. class CreateContactAction( val firstName: String, val lastName: String, val phoneNumber: String, val email: String, ) : Action( type = ActionType.ACTION_CREATE_CONTACT, icon = Icons.Outlined.PersonAdd, functionCallDetails = FunctionCallDetails( functionName = "createContact", parameters = listOf( Pair("firstName", firstName), Pair("lastName", lastName), Pair("phoneNumber", phoneNumber), Pair("email", email), ), ), ) // Action to send email. class SendEmailAction(val to: String, val subject: String, val body: String) : Action( type = ActionType.ACTION_SEND_EMAIL, icon = Icons.Outlined.Email, functionCallDetails = FunctionCallDetails( functionName = "sendEmail", parameters = listOf(Pair("to", to), Pair("subject", subject), Pair("body", body)), ), ) // Action to show a location on map. class ShowLocationOnMap(val location: String) : Action( type = ActionType.ACTION_SHOW_LOCATION_ON_MAP, icon = Icons.Outlined.Map, functionCallDetails = FunctionCallDetails( functionName = "showLocationOnMap", parameters = listOf(Pair("location", location)), ), ) // Action to open wifi settings. class OpenWifiSettingsAction() : Action( type = ActionType.ACTION_OPEN_WIFI_SETTINGS, icon = Icons.Outlined.Wifi, functionCallDetails = FunctionCallDetails(functionName = "openWifiSettings", parameters = listOf()), ) // Action to create calendar event. class CreateCalendarEventAction(val datetime: String, val title: String) : Action( type = ActionType.ACTION_CREATE_CALENDAR_EVENT, icon = Icons.Outlined.CalendarMonth, functionCallDetails = FunctionCallDetails( functionName = "createCalendarEvent", parameters = listOf(Pair("datetime", datetime), Pair("title", title)), ), ) ================================================ FILE: Android/src/app/src/main/java/com/google/ai/edge/gallery/customtasks/mobileactions/MobileActionsModule.kt ================================================ /* * Copyright 2025 Google LLC * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * http://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. */ package com.google.ai.edge.gallery.customtasks.mobileactions import com.google.ai.edge.gallery.customtasks.common.CustomTask import dagger.Module import dagger.Provides import dagger.hilt.InstallIn import dagger.hilt.components.SingletonComponent import dagger.multibindings.IntoSet @Module @InstallIn(SingletonComponent::class) internal object MobileActionsModule { @Provides @IntoSet fun provideTask(): CustomTask { return MobileActionsTask() } } ================================================ FILE: Android/src/app/src/main/java/com/google/ai/edge/gallery/customtasks/mobileactions/MobileActionsScreen.kt ================================================ /* * Copyright 2025 Google LLC * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * http://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. */ package com.google.ai.edge.gallery.customtasks.mobileactions import android.Manifest import android.content.pm.PackageManager import android.content.res.Resources import android.os.Bundle import android.util.Log import androidx.activity.compose.rememberLauncherForActivityResult import androidx.activity.result.contract.ActivityResultContracts import androidx.annotation.StringRes import androidx.compose.animation.AnimatedContent import androidx.compose.animation.AnimatedVisibility import androidx.compose.animation.core.FastOutSlowInEasing import androidx.compose.animation.core.tween import androidx.compose.animation.fadeIn import androidx.compose.animation.fadeOut import androidx.compose.animation.slideInHorizontally import androidx.compose.animation.slideOutHorizontally import androidx.compose.animation.togetherWith import androidx.compose.foundation.background import androidx.compose.foundation.border import androidx.compose.foundation.clickable import androidx.compose.foundation.horizontalScroll import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.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.padding import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.width import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.text.BasicText import androidx.compose.foundation.text.TextAutoSize import androidx.compose.foundation.verticalScroll import androidx.compose.material.icons.Icons import androidx.compose.material.icons.automirrored.rounded.Article import androidx.compose.material.icons.outlined.CalendarMonth import androidx.compose.material.icons.outlined.Email import androidx.compose.material.icons.outlined.FlashlightOn import androidx.compose.material.icons.outlined.Map import androidx.compose.material.icons.outlined.PersonAdd import androidx.compose.material.icons.outlined.Wifi import androidx.compose.material.icons.rounded.Functions import androidx.compose.material3.AlertDialog import androidx.compose.material3.Button import androidx.compose.material3.ButtonDefaults import androidx.compose.material3.CircularProgressIndicator import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.HorizontalDivider import androidx.compose.material3.Icon import androidx.compose.material3.MaterialTheme import androidx.compose.material3.PrimaryTabRow import androidx.compose.material3.SnackbarDuration import androidx.compose.material3.SnackbarHost import androidx.compose.material3.SnackbarHostState import androidx.compose.material3.Tab import androidx.compose.material3.TabRowDefaults import androidx.compose.material3.Text import androidx.compose.material3.TextButton import androidx.compose.runtime.Composable import androidx.compose.runtime.DisposableEffect import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableIntStateOf import androidx.compose.runtime.mutableLongStateOf import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.runtime.setValue import androidx.compose.runtime.snapshots.SnapshotStateList 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.Color import androidx.compose.ui.graphics.graphicsLayer import androidx.compose.ui.graphics.vector.ImageVector import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalDensity import androidx.compose.ui.platform.LocalFocusManager import androidx.compose.ui.platform.LocalResources import androidx.compose.ui.res.stringResource import androidx.compose.ui.semantics.LiveRegionMode import androidx.compose.ui.semantics.contentDescription import androidx.compose.ui.semantics.liveRegion import androidx.compose.ui.semantics.semantics import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp import androidx.core.content.ContextCompat import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel import com.google.ai.edge.gallery.GalleryEvent import com.google.ai.edge.gallery.R import com.google.ai.edge.gallery.data.ModelDownloadStatusType import com.google.ai.edge.gallery.data.Task import com.google.ai.edge.gallery.firebaseAnalytics import com.google.ai.edge.gallery.ui.common.MarkdownText import com.google.ai.edge.gallery.ui.common.chat.ChatMessageWarning import com.google.ai.edge.gallery.ui.common.chat.MessageBodyLoading import com.google.ai.edge.gallery.ui.common.chat.MessageBodyWarning import com.google.ai.edge.gallery.ui.common.getTaskBgGradientColors import com.google.ai.edge.gallery.ui.common.getTaskIconColor import com.google.ai.edge.gallery.ui.common.textandvoiceinput.HoldToDictateViewModel import com.google.ai.edge.gallery.ui.common.textandvoiceinput.TextAndVoiceInput import com.google.ai.edge.gallery.ui.common.textandvoiceinput.VoiceRecognizerOverlay import com.google.ai.edge.gallery.ui.modelmanager.ModelInitializationStatus import com.google.ai.edge.gallery.ui.modelmanager.ModelInitializationStatusType import com.google.ai.edge.gallery.ui.modelmanager.ModelManagerViewModel import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.launch private const val TAG = "AGMAScreen" data class PromptTemplate(@StringRes val labelResId: Int, val prompt: String) private val PROMPT_TEMPLATES = listOf( PromptTemplate( labelResId = R.string.prompt_template_label_flash_on, prompt = "Turn on flashlight", ), PromptTemplate( labelResId = R.string.prompt_template_label_flash_off, prompt = "Turn off flashlight", ), PromptTemplate( labelResId = R.string.prompt_template_label_create_contact, prompt = "Create contact John Smith with email address js@example.com and phone number 123 456 7890.", ), PromptTemplate( labelResId = R.string.prompt_template_label_send_email, prompt = "Send an email to js@example.com with subject \"Meeting\" and body \"Hi John, let's meet at 3pm tomorrow.\"", ), PromptTemplate( labelResId = R.string.prompt_template_label_create_calendar_event, prompt = "Create a calendar event at 2:30pm tomorrow for \"team meeting\"", ), PromptTemplate( labelResId = R.string.prompt_template_label_show_location_on_map, prompt = "Show Googleplex on map", ), PromptTemplate( labelResId = R.string.prompt_template_label_open_wifi_settings, prompt = "Open WIFI settings", ), ) private data class SampleActionItem(@StringRes val labelResId: Int, val icon: ImageVector) private val SAMPLE_ACTION_ITEMS = listOf( SampleActionItem( labelResId = R.string.prompt_template_label_flash_on_off, icon = Icons.Outlined.FlashlightOn, ), SampleActionItem( labelResId = R.string.prompt_template_label_create_contact, icon = Icons.Outlined.PersonAdd, ), SampleActionItem( labelResId = R.string.prompt_template_label_send_email, icon = Icons.Outlined.Email, ), SampleActionItem( labelResId = R.string.prompt_template_label_create_calendar_event, icon = Icons.Outlined.CalendarMonth, ), SampleActionItem( labelResId = R.string.prompt_template_label_show_location_on_map, icon = Icons.Outlined.Map, ), SampleActionItem( labelResId = R.string.prompt_template_label_open_wifi_settings, icon = Icons.Outlined.Wifi, ), ) private data class Tab(@StringRes val labelResId: Int, val icon: ImageVector) private val TABS = listOf( Tab( labelResId = R.string.mobile_actions_tab_model_response, icon = Icons.AutoMirrored.Rounded.Article, ), Tab(labelResId = R.string.mobile_actions_tab_function_called, icon = Icons.Rounded.Functions), ) /** * A Composable function that displays the MobileActions screen. * * This screen allows users to interact with an AI model using voice or text input to perform * various actions on their device. */ @Composable fun MobileActionsScreen( task: Task, modelManagerViewModel: ModelManagerViewModel, mobileActionsViewModel: MobileActionsViewModel = hiltViewModel(), bottomPadding: Dp, setAppBarControlsDisabled: (Boolean) -> Unit, curActions: SnapshotStateList, tools: List, onProcessingStarted: () -> Unit, ) { var recordAudioPermissionGranted by remember { mutableStateOf(false) } val context = LocalContext.current // Permission request when recording audio clips. val recordAudioClipsPermissionLauncher = rememberLauncherForActivityResult(ActivityResultContracts.RequestPermission()) { permissionGranted -> if (permissionGranted) { recordAudioPermissionGranted = true } } // Ask for audio recording permission. LaunchedEffect(Unit) { // Check permission. when (PackageManager.PERMISSION_GRANTED) { // Already got permission. Call the lambda. ContextCompat.checkSelfPermission(context, Manifest.permission.RECORD_AUDIO) -> { recordAudioPermissionGranted = true } // Otherwise, ask for permission else -> { recordAudioClipsPermissionLauncher.launch(Manifest.permission.RECORD_AUDIO) } } } if (recordAudioPermissionGranted) { Column( modifier = Modifier.fillMaxSize().background(MaterialTheme.colorScheme.surface).imePadding() ) { MainUi( task = task, modelManagerViewModel = modelManagerViewModel, tools = tools, bottomPadding = bottomPadding, viewModel = mobileActionsViewModel, curActions = curActions, setAppBarControlsDisabled = setAppBarControlsDisabled, onProcessingStarted = onProcessingStarted, ) } } } @OptIn(ExperimentalMaterial3Api::class) @Composable fun MainUi( task: Task, modelManagerViewModel: ModelManagerViewModel, tools: List, bottomPadding: Dp, viewModel: MobileActionsViewModel, setAppBarControlsDisabled: (Boolean) -> Unit, curActions: SnapshotStateList, holdToDictateViewModel: HoldToDictateViewModel = hiltViewModel(), onProcessingStarted: () -> Unit, ) { val modelManagerUiState by modelManagerViewModel.uiState.collectAsState() val model = modelManagerUiState.selectedModel val initialModelConfigValues = remember { model.configValues } val holdToDictateUiState by holdToDictateViewModel.uiState.collectAsState() val uiState by viewModel.uiState.collectAsState() var curAmplitude by remember { mutableIntStateOf(0) } var clearInputTextTrigger by remember { mutableLongStateOf(0L) } var selectedTabIndex by remember { mutableIntStateOf(0) } var doneGeneratingResponse by remember { mutableStateOf(false) } var showErrorDialog by remember { mutableStateOf(false) } var errorDialogContent by remember { mutableStateOf("") } val context = LocalContext.current val scope = rememberCoroutineScope() val snackbarHostState = remember { SnackbarHostState() } val focusManager = LocalFocusManager.current val resources = LocalResources.current val taskColor = getTaskBgGradientColors(task = task)[1] val curDownloadStatus = modelManagerUiState.modelDownloadStatus[model.name]?.status setAppBarControlsDisabled( curDownloadStatus == ModelDownloadStatusType.SUCCEEDED && (!modelManagerUiState.isModelInitialized(model = model) || uiState.processing) ) // Reset states on config changes. LaunchedEffect(model.configValues) { if (model.configValues != initialModelConfigValues) { Log.d(TAG, "model config values changed.") modelManagerViewModel.setInitializationStatus( model = model, status = ModelInitializationStatus(status = ModelInitializationStatusType.NOT_INITIALIZED), ) viewModel.reset() } } DisposableEffect(Unit) { onDispose { viewModel.cleanUp() } } // Show a loading indicator before the model is initialized. if (!modelManagerUiState.isModelInitialized(model = model)) { Row( modifier = Modifier.fillMaxSize(), verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.Center, ) { CircularProgressIndicator( trackColor = MaterialTheme.colorScheme.surfaceVariant, strokeWidth = 3.dp, modifier = Modifier.size(24.dp), ) } } // Main UI. else { val noFunctionCallSnackbarMessage = stringResource(R.string.snackbar_no_function_call) val send: (String) -> Unit = { text -> scope.launch(Dispatchers.Main) { selectedTabIndex = 0 clearInputTextTrigger = System.currentTimeMillis() focusManager.clearFocus() } onProcessingStarted() // Figure out the correct action from user prompt. doneGeneratingResponse = false viewModel.processUserPrompt( model = model, userPrompt = text, tools = tools, onProcessDone = { doneGeneratingResponse = true Log.d(TAG, "Actions count: ${curActions.size}") // Execute functions. if (curActions.isNotEmpty()) { val errors = mutableListOf() for (action in curActions) { val curError = viewModel.performAction(action = action, context = context) if (curError.isEmpty()) { viewModel.addFunctionCallDetails( details = genFormattedFunctionCall(action = action, resources = resources) ) } else { errors.add(curError) } } if (errors.isNotEmpty()) { scope.launch { snackbarHostState.showSnackbar( errors.joinToString(separator = "; "), withDismissAction = true, duration = SnackbarDuration.Long, ) } } } // No function recognized. else { viewModel.setNoFunctionRecognized(value = true) // Show a snack bar for unrecognized command. scope.launch { snackbarHostState.showSnackbar( noFunctionCallSnackbarMessage, withDismissAction = true, duration = SnackbarDuration.Long, ) } } }, onError = { error -> doneGeneratingResponse = true // Show error dialog for users to reset the engine. errorDialogContent = error showErrorDialog = true }, ) firebaseAnalytics?.logEvent( GalleryEvent.GENERATE_ACTION.id, Bundle().apply { putString("capability_name", task.id) putString("model_id", model.name) }, ) } Box(modifier = Modifier.fillMaxSize()) { Column( modifier = Modifier.fillMaxSize() .padding( bottom = if (WindowInsets.ime.getBottom(LocalDensity.current) == 0) bottomPadding else 8.dp ) .imePadding() ) { // Message shown when no prompt has been processed yet. if (uiState.showWelcomeMessage) { Box(modifier = Modifier.fillMaxWidth().weight(1f), contentAlignment = Alignment.Center) { Column( horizontalAlignment = Alignment.CenterHorizontally, modifier = Modifier.fillMaxWidth(), ) { Text( stringResource(R.string.mobile_actions_title), style = MaterialTheme.typography.headlineLarge, color = getTaskIconColor(task = task), ) Text( stringResource(R.string.mobile_actions_description), style = MaterialTheme.typography.bodyMedium, color = getTaskIconColor(task = task), ) Column { Text( stringResource(R.string.mobile_actions_supported_actions), style = MaterialTheme.typography.labelLarge, modifier = Modifier.padding(top = 64.dp, bottom = 8.dp).graphicsLayer { alpha = 0.7f }, color = MaterialTheme.colorScheme.onSurfaceVariant, ) for (item in SAMPLE_ACTION_ITEMS) { Row(verticalAlignment = Alignment.CenterVertically) { Icon( item.icon, contentDescription = null, modifier = Modifier.size(24.dp).padding(end = 8.dp), tint = MaterialTheme.colorScheme.onSurfaceVariant, ) Text( stringResource(item.labelResId), style = MaterialTheme.typography.labelLarge, color = MaterialTheme.colorScheme.onSurfaceVariant, ) } } } } } } // Current user prompt and model response. else { // The current user prompt. Box( modifier = Modifier.fillMaxWidth().background(MaterialTheme.colorScheme.surfaceContainer), contentAlignment = Alignment.CenterStart, ) { Text( uiState.userPrompt, style = MaterialTheme.typography.bodyLarge, color = MaterialTheme.colorScheme.onSurfaceVariant, modifier = Modifier.fillMaxWidth().padding(16.dp), ) } // Loader when processing. if (uiState.processing) { Box( modifier = Modifier.weight(1f).fillMaxWidth().padding(16.dp), contentAlignment = Alignment.TopStart, ) { MessageBodyLoading() } } // Response. else { // Tab bar. Row(modifier = Modifier.fillMaxWidth()) { PrimaryTabRow( selectedTabIndex = selectedTabIndex, containerColor = Color.Transparent, indicator = { TabRowDefaults.PrimaryIndicator( modifier = Modifier.tabIndicatorOffset(selectedTabIndex, matchContentSize = true), color = taskColor, width = Dp.Unspecified, ) }, ) { for ((index, tab) in TABS.withIndex()) { val enabled = index == 0 || (index == 1 && !uiState.noFunctionRecognized) Tab( selected = selectedTabIndex == index, enabled = enabled, onClick = { selectedTabIndex = index }, modifier = Modifier.graphicsLayer { alpha = if (enabled) 1f else 0.3f }, text = { Row( verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(4.dp), ) { val titleColor = if (selectedTabIndex == index) taskColor else MaterialTheme.colorScheme.onSurfaceVariant Icon( tab.icon, contentDescription = null, modifier = Modifier.size(16.dp).alpha(0.7f), tint = titleColor, ) BasicText( text = stringResource(tab.labelResId), maxLines = 1, color = { titleColor }, style = MaterialTheme.typography.bodyMedium.copy( fontWeight = FontWeight.Medium ), autoSize = TextAutoSize.StepBased( minFontSize = 9.sp, maxFontSize = 14.sp, stepSize = 1.sp, ), ) } }, ) } } } // Content. Column( modifier = Modifier.weight(1f).fillMaxWidth().verticalScroll(rememberScrollState()) ) { AnimatedContent( selectedTabIndex, transitionSpec = { if (targetState > initialState) { slideInHorizontally { 40 } + fadeIn() togetherWith slideOutHorizontally { -40 } + fadeOut(animationSpec = tween(50)) } else { slideInHorizontally { -40 } + fadeIn() togetherWith slideOutHorizontally { 40 } + fadeOut(animationSpec = tween(50)) } }, modifier = Modifier.weight(1f), ) { // Model response. if (selectedTabIndex == 0) { Column(modifier = Modifier.fillMaxWidth()) { val cdResponse = stringResource(R.string.cd_model_response_text) MarkdownText( text = uiState.modelResponse, modifier = Modifier.semantics(mergeDescendants = true) { contentDescription = cdResponse // Only announce when message is complete. if (doneGeneratingResponse) { liveRegion = LiveRegionMode.Polite } } .padding(16.dp), ) if (uiState.noFunctionRecognized) { MessageBodyWarning( ChatMessageWarning( content = stringResource(R.string.warning_no_function_call) ) ) } } } // Function called. else if (selectedTabIndex == 1) { Column( modifier = Modifier.fillMaxWidth(), verticalArrangement = Arrangement.spacedBy(8.dp), ) { for ((index, details) in uiState.functionCallDetails.withIndex()) { MarkdownText(text = details, modifier = Modifier.padding(16.dp)) if (index != uiState.functionCallDetails.size - 1) { HorizontalDivider(modifier = Modifier.padding(horizontal = 16.dp)) } } } } } } } } Column( modifier = Modifier.fillMaxWidth().padding(top = 8.dp), verticalArrangement = Arrangement.spacedBy(8.dp), ) { // A list of prompt templates. Row( modifier = Modifier.fillMaxWidth().horizontalScroll(rememberScrollState()).graphicsLayer { alpha = if (uiState.processing) 0.5f else 1f }, horizontalArrangement = Arrangement.spacedBy(4.dp), ) { Spacer(modifier = Modifier.width(12.dp)) for (item in PROMPT_TEMPLATES) { Text( stringResource(item.labelResId), color = MaterialTheme.colorScheme.onSurfaceVariant, style = MaterialTheme.typography.labelLarge, modifier = Modifier.clip(RoundedCornerShape(12.dp)) .clickable(enabled = !uiState.processing) { send(item.prompt) } .background(color = MaterialTheme.colorScheme.surfaceContainerLow) .border( width = 1.dp, color = MaterialTheme.colorScheme.outlineVariant, shape = RoundedCornerShape(12.dp), ) .padding(all = 12.dp), ) } Spacer(modifier = Modifier.width(12.dp)) } // Text and voice Input. Row( modifier = Modifier.padding(horizontal = 16.dp), verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(8.dp), ) { TextAndVoiceInput( task = task, processing = uiState.processing, holdToDictateViewModel = holdToDictateViewModel, onDone = { text -> send(text) }, onAmplitudeChanged = { curAmplitude = it }, clearTextTrigger = clearInputTextTrigger, modifier = Modifier.fillMaxWidth(), ) } } } // Show an overlay during speech recognition. AnimatedVisibility( holdToDictateUiState.recognizing, enter = fadeIn(animationSpec = tween(durationMillis = 150, easing = FastOutSlowInEasing)), exit = fadeOut( animationSpec = tween(durationMillis = 100, easing = FastOutSlowInEasing, delayMillis = 300) ), ) { VoiceRecognizerOverlay( task = task, viewModel = holdToDictateViewModel, curAmplitude = curAmplitude, bottomPadding = bottomPadding, ) } SnackbarHost( hostState = snackbarHostState, modifier = Modifier.padding(bottom = bottomPadding + 100.dp).align(Alignment.BottomCenter), ) } } if (showErrorDialog) { AlertDialog( title = { Text(stringResource(R.string.error)) }, text = { Text(errorDialogContent, style = MaterialTheme.typography.bodyMedium) }, onDismissRequest = { showErrorDialog = false errorDialogContent = "" }, dismissButton = { TextButton( onClick = { showErrorDialog = false errorDialogContent = "" } ) { Text(stringResource(R.string.cancel)) } }, confirmButton = { Button( onClick = { showErrorDialog = false errorDialogContent = "" viewModel.resetEngine( context = context, model = model, tools = tools, modelManagerViewModel = modelManagerViewModel, onError = { errorDialogContent = it showErrorDialog = true }, ) }, colors = ButtonDefaults.buttonColors(containerColor = taskColor), ) { Text(stringResource(R.string.reset), color = Color.White) } }, ) } } private fun genFormattedFunctionCall(action: Action, resources: Resources): String { val strFunctionName = action.functionCallDetails.functionName val functionNameLabel = resources.getString(R.string.function_name) var content = "**$functionNameLabel**:\n- $strFunctionName" if (action.functionCallDetails.parameters.isNotEmpty()) { val parametersLabel = resources.getQuantityString(R.plurals.parameter, action.functionCallDetails.parameters.size) val strParameters = action.functionCallDetails.parameters.joinToString("\n") { "- ${it.first}: \"${it.second}\"" } content += "\n\n**$parametersLabel**:\n$strParameters" } return content } ================================================ FILE: Android/src/app/src/main/java/com/google/ai/edge/gallery/customtasks/mobileactions/MobileActionsTask.kt ================================================ /* * Copyright 2025 Google LLC * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * http://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. */ package com.google.ai.edge.gallery.customtasks.mobileactions import android.content.Context import androidx.compose.material.icons.Icons import androidx.compose.material.icons.outlined.Functions import androidx.compose.runtime.Composable import androidx.compose.runtime.mutableStateListOf import com.google.ai.edge.gallery.R import com.google.ai.edge.gallery.customtasks.common.CustomTask import com.google.ai.edge.gallery.customtasks.common.CustomTaskData import com.google.ai.edge.gallery.data.BuiltInTaskId import com.google.ai.edge.gallery.data.Category import com.google.ai.edge.gallery.data.Model import com.google.ai.edge.gallery.data.Task import com.google.ai.edge.gallery.ui.llmchat.LlmChatModelHelper import com.google.ai.edge.litertlm.Content import com.google.ai.edge.litertlm.Contents import java.time.LocalDateTime import java.time.format.DateTimeFormatter import javax.inject.Inject import kotlinx.coroutines.CoroutineScope private const val TAG = "AGMATask" /** * A custom task that demonstrates how to use function calling to control various device * functionalities. */ class MobileActionsTask @Inject constructor() : CustomTask { private var curActions = mutableStateListOf() private val tools = listOf(MobileActionsTools(onFunctionCalled = { curActions.add(it) })) override val task = Task( id = BuiltInTaskId.LLM_MOBILE_ACTIONS, label = "Mobile Actions", description = "Perform various device actions through Function Gemma", docUrl = "https://github.com/google-ai-edge/LiteRT-LM/blob/main/kotlin/README.md", sourceCodeUrl = "https://github.com/google-ai-edge/gallery/blob/main/Android/src/app/src/main/java/com/google/ai/edge/gallery/customtasks/mobileactions", category = Category.LLM, icon = Icons.Outlined.Functions, agentNameRes = R.string.chat_agent_agent_name, models = mutableListOf(), experimental = true, ) override fun initializeModelFn( context: Context, coroutineScope: CoroutineScope, model: Model, onDone: (String) -> Unit, ) { curActions.clear() // Expected to get the current time on user's device. LlmChatModelHelper.initialize( context = context, model = model, supportImage = false, supportAudio = false, onDone = onDone, systemInstruction = getSystemPrompt(), tools = tools, ) } override fun cleanUpModelFn( context: Context, coroutineScope: CoroutineScope, model: Model, onDone: () -> Unit, ) { curActions.clear() LlmChatModelHelper.cleanUp(model = model, onDone = onDone) } @Composable override fun MainScreen(data: Any) { val customTaskData = data as CustomTaskData MobileActionsScreen( task = task, modelManagerViewModel = customTaskData.modelManagerViewModel, bottomPadding = customTaskData.bottomPadding, setAppBarControlsDisabled = customTaskData.setAppBarControlsDisabled, curActions = curActions, tools = tools, onProcessingStarted = { curActions.clear() }, ) } } fun getSystemPrompt(): Contents { @SuppressWarnings("JavaTimeDefaultTimeZone") val now = LocalDateTime.now() val curDateTimeString = now.format(DateTimeFormatter.ofPattern("yyyy-MM-dd'T'HH:mm:ss")) val dayOfWeekString = now.format(DateTimeFormatter.ofPattern("EEEE")) return Contents.of( listOf( "You are a model that can do function calling with the following functions", "Current date and time given in YYYY-MM-DDTHH:MM:SS format: ${curDateTimeString}\nDay of week is $dayOfWeekString", ) .map { Content.Text(it) } ) } ================================================ FILE: Android/src/app/src/main/java/com/google/ai/edge/gallery/customtasks/mobileactions/MobileActionsTools.kt ================================================ /* * Copyright 2025 Google LLC * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * http://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. */ package com.google.ai.edge.gallery.customtasks.mobileactions import android.util.Log import com.google.ai.edge.litertlm.Tool import com.google.ai.edge.litertlm.ToolParam private const val TAG = "AGMATools" class MobileActionsTools(val onFunctionCalled: (Action) -> Unit) { /** Turns on flashlight. */ @Tool(description = "Turns the flashlight on") fun turnOnFlashlight(): Map { Log.d(TAG, "turn on flashlight") // Call the callback with the recognized action. onFunctionCalled(FlashlightOnAction()) // Return a response object to the model confirming the action. return mapOf("result" to "success") } /** Turns off flashlight. */ @Tool(description = "Turns the flashlight off") fun turnOffFlashlight(): Map { Log.d(TAG, "turn off flashlight") // Call the callback with the recognized action. onFunctionCalled(FlashlightOffAction()) // Return a response object to the model confirming the action. return mapOf("result" to "success") } /** Creates contact. */ @Tool(description = "Creates a contact in the phone's contact list.") fun createContact( @ToolParam(description = "The first name of the contact.") firstName: String, @ToolParam(description = "The last name of the contact.") lastName: String, @ToolParam(description = "The phone number of the contact.") phoneNumber: String, @ToolParam(description = "The email address of the contact.") email: String, ): Map { Log.d( TAG, "create contact. First name: '$firstName', last name: '$lastName', phone number: '$phoneNumber', email: '$email'", ) onFunctionCalled( CreateContactAction( firstName = firstName, lastName = lastName, phoneNumber = phoneNumber, email = email, ) ) return mapOf( "result" to "success", "first_name" to firstName, "last_name" to lastName, "phone_number" to phoneNumber, "email" to email, ) } /** Sends email. */ @Tool(description = "Sends an email.") fun sendEmail( @ToolParam(description = "The email address of the recipient.") to: String, @ToolParam(description = "The subject of the email.") subject: String, @ToolParam(description = "The body of the email.") body: String, ): Map { Log.d(TAG, "send email. To: '$to', subject: '$subject', body: '$body'") onFunctionCalled(SendEmailAction(to = to, subject = subject, body = body)) return mapOf("result" to "success", "to" to to, "subject" to subject, "body" to body) } /** Shows location on map. */ @Tool(description = "Shows a location on the map.") fun showLocationOnMap( @ToolParam( description = "The location to search for. May be the name of a place, a business, or an address." ) location: String ): Map { Log.d(TAG, "Show location on map. Location: '$location'") onFunctionCalled(ShowLocationOnMap(location = location)) return mapOf("result" to "success", "location" to location) } /** Opens wifi settings. */ @Tool(description = "Opens the WiFi settings.") fun openWifiSettings(): Map { Log.d(TAG, "Open wifi settings") onFunctionCalled(OpenWifiSettingsAction()) return mapOf("result" to "success") } /** Creates calendar events. */ @Tool(description = "Creates a new calendar event.") fun createCalendarEvent( @ToolParam(description = "The date and time of the event in the format YYYY-MM-DDTHH:MM:SS.") datetime: String, @ToolParam(description = "The title of the event.") title: String, ): Map { Log.d(TAG, "Create calendar event. Datetime: '$datetime', title: '$title'") onFunctionCalled(CreateCalendarEventAction(datetime = datetime, title = title)) return mapOf("result" to "success", "datetime" to datetime, "title" to title) } } ================================================ FILE: Android/src/app/src/main/java/com/google/ai/edge/gallery/customtasks/mobileactions/MobileActionsViewModel.kt ================================================ /* * Copyright 2025 Google LLC * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * http://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. */ package com.google.ai.edge.gallery.customtasks.mobileactions import android.content.Context import android.content.Intent import android.hardware.camera2.CameraCharacteristics import android.hardware.camera2.CameraManager import android.provider.CalendarContract import android.provider.ContactsContract import android.provider.Settings import android.util.Log import androidx.core.net.toUri import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import com.google.ai.edge.gallery.R import com.google.ai.edge.gallery.data.Model import com.google.ai.edge.gallery.ui.llmchat.LlmChatModelHelper import com.google.ai.edge.gallery.ui.llmchat.LlmModelInstance import com.google.ai.edge.gallery.ui.modelmanager.ModelInitializationStatus import com.google.ai.edge.gallery.ui.modelmanager.ModelInitializationStatusType import com.google.ai.edge.gallery.ui.modelmanager.ModelManagerViewModel import com.google.ai.edge.litertlm.Content import com.google.ai.edge.litertlm.Contents import dagger.hilt.android.lifecycle.HiltViewModel import dagger.hilt.android.qualifiers.ApplicationContext import java.net.URLEncoder import java.nio.charset.StandardCharsets import java.time.LocalDateTime import java.time.ZoneId import javax.inject.Inject import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.catch import kotlinx.coroutines.flow.first import kotlinx.coroutines.flow.onCompletion import kotlinx.coroutines.flow.update import kotlinx.coroutines.launch private const val TAG = "AGMAViewModel" /** The UI state of the MobileActionsViewModel. */ data class MobileActionsUiState( val showWelcomeMessage: Boolean = true, val processing: Boolean = false, val userPrompt: String = "", val modelResponse: String = "", val functionCallDetails: List = listOf(), val noFunctionRecognized: Boolean = false, ) @HiltViewModel class MobileActionsViewModel @Inject constructor(@ApplicationContext private val appContext: Context) : ViewModel() { protected val _uiState = MutableStateFlow(MobileActionsUiState()) val uiState = _uiState.asStateFlow() private val _isResettingConversation = MutableStateFlow(false) private val isResettingConversation = _isResettingConversation.asStateFlow() fun reset() { val unused = setFlashlight(context = appContext, isEnabled = false) setShowWelcomeMessage(showWelcomeMessage = true) setUserPrompt(prompt = "'") setModelResponse(response = "") setNoFunctionRecognized(value = false) clearFunctionCallDetails() } fun cleanUp() { val unused = setFlashlight(context = appContext, isEnabled = false) } fun setShowWelcomeMessage(showWelcomeMessage: Boolean) { _uiState.update { _uiState.value.copy(showWelcomeMessage = showWelcomeMessage) } } fun setProcessing(processing: Boolean) { _uiState.update { _uiState.value.copy(processing = processing) } } fun setUserPrompt(prompt: String) { _uiState.update { _uiState.value.copy(userPrompt = prompt) } } fun setModelResponse(response: String) { _uiState.update { _uiState.value.copy(modelResponse = response) } } fun appendModelResponse(partialResponse: String) { _uiState.update { _uiState.value.copy(modelResponse = _uiState.value.modelResponse + partialResponse) } } fun addFunctionCallDetails(details: String) { val newDetails = _uiState.value.functionCallDetails.toMutableList() newDetails.add(details) _uiState.update { _uiState.value.copy(functionCallDetails = newDetails) } } fun clearFunctionCallDetails() { _uiState.update { _uiState.value.copy(functionCallDetails = listOf()) } } fun setNoFunctionRecognized(value: Boolean) { _uiState.update { _uiState.value.copy(noFunctionRecognized = value) } } fun processUserPrompt( model: Model, userPrompt: String, tools: List, onProcessDone: () -> Unit, onError: (error: String) -> Unit, ) { if (model.instance == null) { setProcessing(processing = false) return } viewModelScope.launch(Dispatchers.Default) { Log.d(TAG, "Start processing user prompt: $userPrompt") setProcessing(processing = true) setShowWelcomeMessage(showWelcomeMessage = false) // Clean up. setModelResponse(response = "") setNoFunctionRecognized(value = false) clearFunctionCallDetails() // Set user prompt. setUserPrompt(prompt = userPrompt) // Wait until the conversation is NOT resetting. Log.d(TAG, "Waiting for any ongoing conversation reset to be done...") isResettingConversation.first { !it } Log.d(TAG, "Done waiting. Start inference.") // Run inference. val instance = model.instance as LlmModelInstance val conversation = instance.conversation val contents = mutableListOf() if (userPrompt.trim().isNotEmpty()) { contents.add(Content.Text(userPrompt)) } conversation .sendMessageAsync(Contents.of(contents)) .catch { Log.e(TAG, "Failed to run inference", it) onError(it.message ?: "Unknown error") } .onCompletion { setProcessing(processing = false) onProcessDone() resetConversation(model = model, tools = tools) } .collect { setProcessing(processing = false) appendModelResponse(partialResponse = it.toString()) } } } fun resetConversation(model: Model, tools: List) { _isResettingConversation.value = true LlmChatModelHelper.resetConversation( model = model, supportImage = false, supportAudio = false, systemInstruction = getSystemPrompt(), tools = tools, ) _isResettingConversation.value = false } fun resetEngine( context: Context, model: Model, tools: List, modelManagerViewModel: ModelManagerViewModel, onError: (error: String) -> Unit, ) { reset() viewModelScope.launch(Dispatchers.Default) { modelManagerViewModel.setInitializationStatus( model = model, status = ModelInitializationStatus(status = ModelInitializationStatusType.NOT_INITIALIZED), ) LlmChatModelHelper.cleanUp( model = model, onDone = { LlmChatModelHelper.initialize( context = context, model = model, supportImage = false, supportAudio = false, onDone = { error -> modelManagerViewModel.setInitializationStatus( model = model, status = ModelInitializationStatus(status = ModelInitializationStatusType.INITIALIZED), ) if (error.isNotEmpty()) { onError(error) } }, systemInstruction = getSystemPrompt(), tools = tools, ) }, ) } } fun performAction(action: Action, context: Context): String { return when (action) { // Flashlight on. is FlashlightOnAction -> setFlashlight(context = context, isEnabled = true) // Flashlight off. is FlashlightOffAction -> setFlashlight(context = context, isEnabled = false) // Create contact. is CreateContactAction -> createContact( context = context, firstName = action.firstName, lastName = action.lastName, phoneNumber = action.phoneNumber, email = action.email, ) // Send email. is SendEmailAction -> sendEmail(context = context, to = action.to, subject = action.subject, body = action.body) // Show location on map. is ShowLocationOnMap -> showLocationOnMap(context = context, location = action.location) // Open wifi settings. is OpenWifiSettingsAction -> openWifiSettings(context = context) // Create calendar events. is CreateCalendarEventAction -> createCalendarEvent(context = context, datetime = action.datetime, title = action.title) else -> "" } } private fun setFlashlight(context: Context, isEnabled: Boolean): String { val cameraManager: CameraManager = context.getSystemService(Context.CAMERA_SERVICE) as CameraManager // Assuming the device has a rear camera with a flash unit (usually camera ID '0') var cameraId: String? = null try { // Find the ID of the camera that supports the flash unit for (id in cameraManager.cameraIdList) { val characteristics = cameraManager.getCameraCharacteristics(id) val isFlashAvailable = characteristics.get(CameraCharacteristics.FLASH_INFO_AVAILABLE) ?: false if (isFlashAvailable) { cameraId = id break } } } catch (e: Exception) { Log.e(TAG, "Failed to set flashlight", e) return e.message ?: context.getString(R.string.unknown_error) } cameraId?.let { id -> try { cameraManager.setTorchMode(id, isEnabled) } catch (e: Exception) { Log.e(TAG, "Failed to set flashlight", e) return e.message ?: context.getString(R.string.unknown_error) } } return "" } private fun createContact( context: Context, firstName: String, lastName: String, phoneNumber: String, email: String, ): String { val intent = Intent(ContactsContract.Intents.Insert.ACTION) .apply { type = ContactsContract.RawContacts.CONTENT_TYPE } .apply { // Name putExtra(ContactsContract.Intents.Insert.NAME, "$firstName $lastName") // Inserts an email address putExtra(ContactsContract.Intents.Insert.EMAIL, email) putExtra( ContactsContract.Intents.Insert.EMAIL_TYPE, ContactsContract.CommonDataKinds.Email.TYPE_WORK, ) // Inserts a phone number putExtra(ContactsContract.Intents.Insert.PHONE, phoneNumber) putExtra( ContactsContract.Intents.Insert.PHONE_TYPE, ContactsContract.CommonDataKinds.Phone.TYPE_WORK, ) } try { context.startActivity(intent) } catch (e: Exception) { Log.e(TAG, "Failed to create contact", e) return e.message ?: context.getString(R.string.unknown_error) } return "" } private fun sendEmail(context: Context, to: String, subject: String, body: String): String { val intent = Intent(Intent.ACTION_SEND).apply { data = "mailto:".toUri() type = "text/plain" putExtra(Intent.EXTRA_EMAIL, arrayOf(to)) putExtra(Intent.EXTRA_SUBJECT, subject) putExtra(Intent.EXTRA_TEXT, body) } try { context.startActivity(intent) } catch (e: Exception) { Log.e(TAG, "Failed to send email", e) return e.message ?: context.getString(R.string.unknown_error) } return "" } private fun showLocationOnMap(context: Context, location: String): String { val encodedLocation = URLEncoder.encode(location, StandardCharsets.UTF_8.toString()) val intent = Intent(Intent.ACTION_VIEW).apply { data = "geo:0,0?q=$encodedLocation".toUri() } try { context.startActivity(intent) } catch (e: Exception) { Log.e(TAG, "Failed to show location on map", e) return e.message ?: context.getString(R.string.unknown_error) } return "" } private fun openWifiSettings(context: Context): String { val intent = Intent(Settings.ACTION_WIFI_SETTINGS) try { context.startActivity(intent) } catch (e: Exception) { Log.e(TAG, "Failed to open wifi settings", e) return e.message ?: context.getString(R.string.unknown_error) } return "" } private fun createCalendarEvent(context: Context, datetime: String, title: String): String { // Convert datetime string to ms. var ms = System.currentTimeMillis() try { val localDateTime = LocalDateTime.parse(datetime) val systemDefaultZone = ZoneId.systemDefault() val zonedDateTime = localDateTime.atZone(systemDefaultZone) ms = zonedDateTime.toInstant().toEpochMilli() } catch (e: Exception) { // Ignore parsing error. Log.w(TAG, "Failed to parse date time: '$datetime'", e) } val intent = Intent(Intent.ACTION_INSERT).apply { data = CalendarContract.Events.CONTENT_URI putExtra(CalendarContract.Events.TITLE, title) putExtra(CalendarContract.EXTRA_EVENT_BEGIN_TIME, ms) putExtra(CalendarContract.EXTRA_EVENT_END_TIME, ms + 3600000) } try { context.startActivity(intent) } catch (e: Exception) { Log.e(TAG, "Failed to create calendar event", e) return e.message ?: context.getString(R.string.unknown_error) } return "" } } ================================================ FILE: Android/src/app/src/main/java/com/google/ai/edge/gallery/customtasks/tinygarden/ConversationHistoryPanel.kt ================================================ /* * Copyright 2025 Google LLC * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * http://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. */ package com.google.ai.edge.gallery.customtasks.tinygarden 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.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.padding import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.verticalScroll import androidx.compose.material.icons.Icons import androidx.compose.material.icons.rounded.Close import androidx.compose.material3.Icon import androidx.compose.material3.IconButton import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue 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.res.dimensionResource import androidx.compose.ui.res.stringResource import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.dp import com.google.ai.edge.gallery.R import com.google.ai.edge.gallery.data.Task import com.google.ai.edge.gallery.ui.common.chat.ChatMessageError import com.google.ai.edge.gallery.ui.common.chat.ChatMessageText import com.google.ai.edge.gallery.ui.common.chat.ChatMessageWarning import com.google.ai.edge.gallery.ui.common.chat.ChatSide import com.google.ai.edge.gallery.ui.common.chat.MessageBodyError import com.google.ai.edge.gallery.ui.common.chat.MessageBodyText import com.google.ai.edge.gallery.ui.common.chat.MessageBodyWarning import com.google.ai.edge.gallery.ui.common.chat.MessageBubbleShape import com.google.ai.edge.gallery.ui.common.chat.MessageSender import com.google.ai.edge.gallery.ui.theme.customColors /** A panel to show the conversation history. */ @Composable fun ConversationHistoryPanel( task: Task, bottomPadding: Dp, viewModel: TinyGardenViewModel, onDismiss: () -> Unit, ) { val uiState by viewModel.uiState.collectAsState() val listState = rememberScrollState() Column( modifier = Modifier.background(color = MaterialTheme.colorScheme.surface) .fillMaxSize() .padding(bottom = bottomPadding) ) { // Scroll to bottom when adding a new message. LaunchedEffect(uiState.messages.size) { if (uiState.messages.isNotEmpty()) { listState.animateScrollTo(1000000) } } // Title and button to dismiss. Row( modifier = Modifier.background(color = MaterialTheme.colorScheme.surfaceContainerHighest) .fillMaxWidth() .padding(start = 12.dp), horizontalArrangement = Arrangement.SpaceBetween, verticalAlignment = Alignment.CenterVertically, ) { Text( stringResource(R.string.conversation_history), style = MaterialTheme.typography.titleMedium, ) IconButton(onClick = { onDismiss() }) { Icon( imageVector = Icons.Rounded.Close, contentDescription = stringResource(R.string.cd_close_icon), ) } } // Message list. Column( modifier = Modifier.weight(1f).padding(horizontal = 16.dp).verticalScroll(state = listState) ) { for (message in uiState.messages) { 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 } val bubbleBorderRadius = dimensionResource(R.dimen.chat_bubble_corner_radius) Column( modifier = Modifier.fillMaxWidth() .padding(start = extraPaddingStart, end = extraPaddingEnd, top = 6.dp, bottom = 6.dp), horizontalAlignment = hAlign, ) messageColumn@{ // Sender row. var agentName = stringResource(task.agentNameRes) if (message.accelerator.isNotEmpty()) { agentName = "$agentName on ${message.accelerator}" } MessageSender(message = message, agentName = agentName) when (message) { // Warning. is ChatMessageWarning -> MessageBodyWarning(message = message) // Error. is ChatMessageError -> MessageBodyError(message = message) else -> { // Message body. when (message) { // Text is ChatMessageText -> { Row( verticalAlignment = Alignment.Top, horizontalArrangement = Arrangement.spacedBy(8.dp), ) { Box( modifier = Modifier.clip( MessageBubbleShape( radius = bubbleBorderRadius, hardCornerAtLeftOrRight = hardCornerAtLeftOrRight, ) ) .background(backgroundColor) ) { MessageBodyText(message = message, inProgress = false) } } } else -> {} } } } } } } } } ================================================ FILE: Android/src/app/src/main/java/com/google/ai/edge/gallery/customtasks/tinygarden/TinyGardenScreen.kt ================================================ /* * Copyright 2025 Google LLC * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * http://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. */ package com.google.ai.edge.gallery.customtasks.tinygarden import android.Manifest import android.annotation.SuppressLint import android.content.Intent import android.content.pm.PackageManager import android.os.Bundle import android.util.Log import android.view.ViewGroup import android.webkit.ConsoleMessage import android.webkit.WebChromeClient import android.webkit.WebResourceRequest import android.webkit.WebResourceResponse import android.webkit.WebView import android.webkit.WebViewClient 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.core.FastOutSlowInEasing import androidx.compose.animation.core.tween import androidx.compose.animation.fadeIn import androidx.compose.animation.fadeOut import androidx.compose.animation.scaleIn import androidx.compose.animation.scaleOut import androidx.compose.animation.slideInVertically import androidx.compose.animation.slideOutVertically 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.WindowInsets import androidx.compose.foundation.layout.fillMaxHeight 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.padding import androidx.compose.foundation.layout.size import androidx.compose.material.icons.Icons import androidx.compose.material.icons.outlined.History import androidx.compose.material3.AlertDialog import androidx.compose.material3.Button import androidx.compose.material3.ButtonDefaults 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.SnackbarHost import androidx.compose.material3.SnackbarHostState import androidx.compose.material3.Text import androidx.compose.material3.TextButton import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableIntStateOf import androidx.compose.runtime.mutableLongStateOf 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.Color import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalDensity import androidx.compose.ui.platform.LocalResources import androidx.compose.ui.res.stringResource import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.dp import androidx.compose.ui.viewinterop.AndroidView import androidx.core.content.ContextCompat import androidx.core.net.toUri import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel import androidx.webkit.WebViewAssetLoader import com.google.ai.edge.gallery.GalleryEvent import com.google.ai.edge.gallery.R import com.google.ai.edge.gallery.data.ConfigKeys import com.google.ai.edge.gallery.data.ModelDownloadStatusType import com.google.ai.edge.gallery.data.Task import com.google.ai.edge.gallery.data.ValueType import com.google.ai.edge.gallery.data.convertValueToTargetType import com.google.ai.edge.gallery.firebaseAnalytics import com.google.ai.edge.gallery.ui.common.chat.ChatMessageText import com.google.ai.edge.gallery.ui.common.chat.ChatMessageWarning import com.google.ai.edge.gallery.ui.common.chat.ChatSide import com.google.ai.edge.gallery.ui.common.getTaskBgGradientColors import com.google.ai.edge.gallery.ui.common.textandvoiceinput.HoldToDictateViewModel import com.google.ai.edge.gallery.ui.common.textandvoiceinput.TextAndVoiceInput import com.google.ai.edge.gallery.ui.common.textandvoiceinput.VoiceRecognizerOverlay import com.google.ai.edge.gallery.ui.modelmanager.ModelManagerViewModel import com.google.ai.edge.gallery.ui.theme.customColors import com.google.common.io.BaseEncoding import java.security.MessageDigest import kotlinx.coroutines.delay import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.launch private const val TAG = "AGTinyGarden" private const val ASSETS_BASE_URL = "https://appassets.androidplatform.net" /** The main screen for the Tiny Garden game. */ @Composable fun TinyGardenScreen( task: Task, modelManagerViewModel: ModelManagerViewModel, tools: List, bottomPadding: Dp, setAppBarControlsDisabled: (Boolean) -> Unit, setTopBarVisible: (Boolean) -> Unit, commandFlow: Flow, viewModel: TinyGardenViewModel = hiltViewModel(), ) { val uiState by viewModel.uiState.collectAsState() var recordAudioPermissionGranted by remember { mutableStateOf(false) } val context = LocalContext.current // Permission request when recording audio clips. val recordAudioClipsPermissionLauncher = rememberLauncherForActivityResult(ActivityResultContracts.RequestPermission()) { permissionGranted -> if (permissionGranted) { recordAudioPermissionGranted = true } } LaunchedEffect(Unit) { // Check permission when (PackageManager.PERMISSION_GRANTED) { // Already got permission. Call the lambda. ContextCompat.checkSelfPermission(context, Manifest.permission.RECORD_AUDIO) -> { recordAudioPermissionGranted = true } // Otherwise, ask for permission else -> { recordAudioClipsPermissionLauncher.launch(Manifest.permission.RECORD_AUDIO) } } } if (recordAudioPermissionGranted) { Column( modifier = Modifier.fillMaxSize().background(MaterialTheme.colorScheme.surface).imePadding() ) { Box(modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center) { MainUi( task = task, modelManagerViewModel = modelManagerViewModel, tools = tools, bottomPadding = bottomPadding, commandFlow = commandFlow, viewModel = viewModel, setAppBarControlsDisabled = setAppBarControlsDisabled, setTopBarVisible = setTopBarVisible, ) // Resetting engine spinner. Column() { AnimatedVisibility( uiState.resettingEngine, enter = fadeIn() + scaleIn(initialScale = 0.9f), exit = fadeOut() + scaleOut(targetScale = 0.9f), ) { Box( modifier = Modifier.fillMaxSize().background(MaterialTheme.colorScheme.surface), contentAlignment = Alignment.Center, ) { Column( verticalArrangement = Arrangement.spacedBy(8.dp), horizontalAlignment = Alignment.CenterHorizontally, ) { CircularProgressIndicator( trackColor = MaterialTheme.colorScheme.surfaceVariant, strokeWidth = 3.dp, modifier = Modifier.size(24.dp), ) Text( stringResource(R.string.resetting_engine), color = MaterialTheme.colorScheme.onSurfaceVariant, ) Text( stringResource(R.string.reinitializing_description), color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.7f), style = MaterialTheme.typography.bodyMedium, modifier = Modifier.padding(top = 8.dp), ) } } } } } } } } @OptIn(ExperimentalMaterial3Api::class) @SuppressLint("SetJavaScriptEnabled") @Composable fun MainUi( task: Task, modelManagerViewModel: ModelManagerViewModel, tools: List, bottomPadding: Dp, viewModel: TinyGardenViewModel, setAppBarControlsDisabled: (Boolean) -> Unit, setTopBarVisible: (Boolean) -> Unit, commandFlow: Flow, holdToDictateViewModel: HoldToDictateViewModel = hiltViewModel(), ) { val modelManagerUiState by modelManagerViewModel.uiState.collectAsState() val model = modelManagerUiState.selectedModel val initialModelConfigValues = remember(model) { model.configValues } var webViewRef: WebView? by remember { mutableStateOf(null) } val scope = rememberCoroutineScope() val uiState by viewModel.uiState.collectAsState() var clearTextTrigger by remember { mutableLongStateOf(0L) } var curAmplitude by remember { mutableIntStateOf(0) } val holdToDictateUiState by holdToDictateViewModel.uiState.collectAsState() var showConversationHistoryPanel by remember { mutableStateOf(false) } var showErrorDialog by remember { mutableStateOf(false) } var errorDialogContent by remember { mutableStateOf("") } val snackbarHostState = remember { SnackbarHostState() } var prevSeed by remember { mutableStateOf("") } var prevPlots by remember { mutableStateOf("") } var prevAction by remember { mutableStateOf("") } val resources = LocalResources.current val context = LocalContext.current val taskColor = getTaskBgGradientColors(task = task)[1] val curDownloadStatus = modelManagerUiState.modelDownloadStatus[model.name]?.status setAppBarControlsDisabled( curDownloadStatus == ModelDownloadStatusType.SUCCEEDED && (!modelManagerUiState.isModelInitialized(model = model) || uiState.processing) ) // Close conversation history panel when pressing back button. BackHandler(enabled = showConversationHistoryPanel) { showConversationHistoryPanel = false } LaunchedEffect(showConversationHistoryPanel) { setTopBarVisible(!showConversationHistoryPanel) } LaunchedEffect(Unit) { // Run commands/functions generated by TinyGardenTools. commandFlow.collect { command -> // Format command and add to "chat" history. val functionName = when (command.item) { TinyGardenItem.SUNFLOWER.ordinal + 1, TinyGardenItem.DAISY.ordinal + 1, TinyGardenItem.ROSE.ordinal + 1, TinyGardenItem.SPECIAL.ordinal + 1 -> "plantSeed" TinyGardenItem.WATERING_CAN.ordinal + 1 -> "waterPlots" TinyGardenItem.SCYTHE.ordinal + 1 -> "harvestPlots" else -> "" } val strPlots = "[${command.plots.joinToString(",")}]" val functionParameter = when (command.item) { TinyGardenItem.SUNFLOWER.ordinal + 1 -> "- seed: \"sunflower\"\n- plots: $strPlots" TinyGardenItem.DAISY.ordinal + 1 -> "- seed: \"daisy\"\n- plots: $strPlots" TinyGardenItem.ROSE.ordinal + 1 -> "- seed: \"rose\"\n- plots: $strPlots" TinyGardenItem.SPECIAL.ordinal + 1 -> "- seed: \"special\"\n- plots: $strPlots" TinyGardenItem.WATERING_CAN.ordinal + 1 -> "- plots: $strPlots" TinyGardenItem.SCYTHE.ordinal + 1 -> "- plots: $strPlots" else -> "" } val numParameters = when (command.item) { TinyGardenItem.WATERING_CAN.ordinal + 1, TinyGardenItem.SCYTHE.ordinal + 1 -> 1 else -> 2 } val functionNameLabel = resources.getString(R.string.function_name) val parametersLabel = resources.getQuantityString(R.plurals.parameter, numParameters) viewModel.addMessage( message = ChatMessageText( content = "**$functionNameLabel**:\n- $functionName\n\n**$parametersLabel**:\n$functionParameter", side = ChatSide.AGENT, ) ) // Convert command into json that can be consumed by the game. val commandJson = """[{"item": ${command.item}, "plot":[${command.plots.joinToString(",")}]}]""" Log.d(TAG, "commandJson: $commandJson") // Call into the game webview. val jsScript = "tinyGarden.runCommands('$commandJson')" webViewRef ?.runCatching { evaluateJavascript(jsScript, null) } ?.onFailure { e -> Log.e(TAG, "$e") } // Save seed, plots, and action so that we can add them to system prompt when resetting // conversation. prevSeed = when (command.item) { TinyGardenItem.SUNFLOWER.ordinal + 1 -> TinyGardenItem.SUNFLOWER.label TinyGardenItem.DAISY.ordinal + 1 -> TinyGardenItem.DAISY.label TinyGardenItem.ROSE.ordinal + 1 -> TinyGardenItem.ROSE.label TinyGardenItem.SPECIAL.ordinal + 1 -> TinyGardenItem.SPECIAL.label else -> "" } prevPlots = command.plots.joinToString(",") prevAction = when (command.item) { TinyGardenItem.WATERING_CAN.ordinal + 1 -> TinyGardenItem.WATERING_CAN.label TinyGardenItem.SCYTHE.ordinal + 1 -> TinyGardenItem.SCYTHE.label else -> "" } Log.d(TAG, "prevSeed: '$prevSeed', prevPlots: '$prevPlots', prevAction: '$prevAction'") } } val noFunctionCallWarningMessage = stringResource(R.string.warning_no_function_call) val noFunctionCallSnackbarMessage = stringResource(R.string.snackbar_no_function_call) // A function to process the input from the user. fun processInstructionText(text: String) { clearTextTrigger = System.currentTimeMillis() if (text.trim().isNotEmpty()) { // A special input to unlock all :) if (text.trim().sha256() == "XtNztQDSDvVpMRPOK+q9tZs43x/VD1teVs3CvWp7zkc=") { webViewRef ?.runCatching { evaluateJavascript("tinyGarden.unlockAll()", null) } ?.onFailure { e -> Log.e(TAG, "$e") } } else { // Run inference to get response command in json. viewModel.getCommand( model = model, instructionText = text, onDone = { response -> // Add a warning message if no function was recognized. if (uiState.messages.last().side != ChatSide.AGENT) { viewModel.addMessage( message = ChatMessageWarning(content = noFunctionCallWarningMessage) ) // Show a snack bar for unrecognized command. scope.launch { snackbarHostState.showSnackbar( noFunctionCallSnackbarMessage, withDismissAction = true, ) } } // Add the final response from the model. // viewModel.addMessage( // message = ChatMessageText(content = response, side = ChatSide.AGENT) // ) // Reset conversation every {numTurns} turns. val numTurnsToReset = convertValueToTargetType( value = model.configValues.getValue(ConfigKeys.RESET_CONVERSATION_TURN_COUNT.label), valueType = ValueType.INT, ) as Int Log.d(TAG, "Target turn to reset: $numTurnsToReset") if (uiState.numTurns == numTurnsToReset) { Log.d(TAG, "!! This is the turn to reset conversation") viewModel.resetConversation( model = model, tools = tools, prevSeed = prevSeed, prevPlots = prevPlots, prevAction = prevAction, ) } }, onError = { error -> // Show error dialog for users to reset the engine. errorDialogContent = error showErrorDialog = true }, ) } firebaseAnalytics?.logEvent( GalleryEvent.GENERATE_ACTION.id, Bundle().apply { putString("capability_name", task.id) putString("model_id", model.name) }, ) } } // Reset states on config changes. LaunchedEffect(model.configValues) { if (model.configValues != initialModelConfigValues) { var same = true var nonNumTurnsConfigChanged = false for (config in model.configs) { val key = config.key.label val oldValue = if (model.prevConfigValues.containsKey(key)) { convertValueToTargetType( value = model.prevConfigValues.getValue(key), valueType = config.valueType, ) } else { null } val newValue = convertValueToTargetType( value = model.configValues.getValue(key), valueType = config.valueType, ) if (oldValue != newValue) { same = false if (config.key != ConfigKeys.RESET_CONVERSATION_TURN_COUNT) { nonNumTurnsConfigChanged = true } } } if (!same) { Log.d(TAG, "model config values changed.") if (nonNumTurnsConfigChanged) { Log.d(TAG, "need to reset engine") viewModel.resetEngine( context = context, model = model, tools = tools, onError = { errorDialogContent = it showErrorDialog = true }, ) } else { Log.d(TAG, "need to reset conversation") viewModel.resetConversation( model = model, tools = tools, prevSeed = "", prevPlots = "", prevAction = "", ) } } } } // Show a loading indicator before the model is initialized. if (!modelManagerUiState.isModelInitialized(model = model)) { Row( modifier = Modifier.fillMaxSize(), verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.Center, ) { CircularProgressIndicator( trackColor = MaterialTheme.colorScheme.surfaceVariant, strokeWidth = 3.dp, modifier = Modifier.size(24.dp), ) } } // Main UI. else { Box(modifier = Modifier.fillMaxSize()) { Column( modifier = Modifier.padding( bottom = if (WindowInsets.ime.getBottom(LocalDensity.current) == 0) bottomPadding else 12.dp ) ) { // A webview to load the game which is written in javascript. Box(contentAlignment = Alignment.BottomCenter, modifier = Modifier.weight(1f)) { AndroidView( modifier = Modifier.fillMaxHeight(), factory = { context -> // WebViewAssetLoader is used to load local assets (like HTML, CSS, JS) // from the application's assets directory into the WebView. val assetLoader = WebViewAssetLoader.Builder() .addPathHandler("/assets/", WebViewAssetLoader.AssetsPathHandler(context)) .build() WebView(context).apply { webViewRef = this // Needed to make "height:100%" work in body/html style. layoutParams = ViewGroup.LayoutParams( ViewGroup.LayoutParams.MATCH_PARENT, ViewGroup.LayoutParams.MATCH_PARENT, ) settings.apply { javaScriptEnabled = true domStorageEnabled = true allowFileAccess = true // Needed to play the audio in game without user interaction. mediaPlaybackRequiresUserGesture = false } webViewClient = object : WebViewClient() { override fun shouldInterceptRequest( view: WebView?, request: WebResourceRequest, ): WebResourceResponse? { // Check if the URL should be handled by the asset loader return assetLoader.shouldInterceptRequest(request.url) } override fun onPageFinished(view: WebView?, url: String?) { super.onPageFinished(view, url) Log.d(TAG, "webview finished loading") // Show help on first launch. if (!viewModel.dataStoreRepository.getHasRunTinyGarden()) { Log.d(TAG, "First time running Tiny Garden. Showing help screen...") viewModel.dataStoreRepository.setHasRunTinyGarden(true) scope.launch { delay(1000) webViewRef ?.runCatching { evaluateJavascript("tinyGarden.showHelp()", null) } ?.onFailure { e -> Log.e(TAG, "$e") } } } } override fun shouldOverrideUrlLoading( view: WebView?, request: WebResourceRequest?, ): Boolean { if (request == null) { return false } val url = request.url.toString() // Check if the URL should be loaded internally (e.g., local assets) if (url.startsWith(ASSETS_BASE_URL)) { // Return false to let the WebView load the URL internally return false } // If it's an external URL, launch an Android Intent to open it // in the system's default browser. try { val intent = Intent(Intent.ACTION_VIEW, url.toUri()) view?.context?.startActivity(intent) } catch (e: Exception) { Log.e(TAG, "Could not open external URL: $url", e) } // Return true to signal that we have handled the URL loading and // the WebView should NOT load it internally. return true } } webChromeClient = object : WebChromeClient() { // Log console messages. override fun onConsoleMessage(consoleMessage: ConsoleMessage?): Boolean { Log.d( TAG, "${consoleMessage?.message()} -- From line ${consoleMessage?.lineNumber()} of ${consoleMessage?.sourceId()}", ) return super.onConsoleMessage(consoleMessage) } } // Load page. // // http://appassets.androidplatform.net' is the recommended, reserved domain. var url = "$ASSETS_BASE_URL/assets/tinygarden/index.html" if (!viewModel.dataStoreRepository.getHasRunTinyGarden()) { Log.d(TAG, "First time running Tiny Garden. Showing tutorial screen...") viewModel.dataStoreRepository.setHasRunTinyGarden(true) url = "$url?tutorial=1" } loadUrl(url) } }, ) SnackbarHost(hostState = snackbarHostState, modifier = Modifier.padding(bottom = 12.dp)) } // Text and voice input. Row( modifier = Modifier.fillMaxWidth().padding(top = 12.dp), verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(4.dp), ) { TextAndVoiceInput( task = task, processing = uiState.processing, holdToDictateViewModel = holdToDictateViewModel, modifier = Modifier.padding(start = 16.dp).weight(1f), onDone = { text -> processInstructionText(text = text) }, onAmplitudeChanged = { curAmplitude = it }, clearTextTrigger = clearTextTrigger, defaultTextInputMode = true, ) Box(modifier = Modifier.size(48.dp), contentAlignment = Alignment.Center) { if (uiState.processing) { CircularProgressIndicator( trackColor = MaterialTheme.colorScheme.surfaceVariant, strokeWidth = 3.dp, modifier = Modifier.padding(end = 8.dp).size(24.dp), ) } else { IconButton( onClick = { showConversationHistoryPanel = true }, modifier = Modifier.padding(end = 8.dp), ) { Icon( imageVector = Icons.Outlined.History, contentDescription = stringResource(R.string.cd_more_options), ) } } } } } // Show an overlay during speech recognition. AnimatedVisibility( holdToDictateUiState.recognizing, enter = fadeIn(animationSpec = tween(durationMillis = 150, easing = FastOutSlowInEasing)), exit = fadeOut( animationSpec = tween(durationMillis = 100, easing = FastOutSlowInEasing, delayMillis = 300) ), ) { VoiceRecognizerOverlay( task = task, viewModel = holdToDictateViewModel, curAmplitude = curAmplitude, bottomPadding = bottomPadding, ) } // Conversation history panel. AnimatedVisibility( showConversationHistoryPanel, enter = slideInVertically { fullHeight -> fullHeight }, exit = slideOutVertically { fullHeight -> fullHeight }, ) { ConversationHistoryPanel( task = task, bottomPadding = bottomPadding, viewModel = viewModel, onDismiss = { showConversationHistoryPanel = false }, ) } } } if (showErrorDialog) { AlertDialog( title = { Text(stringResource(R.string.error)) }, text = { Column(verticalArrangement = Arrangement.spacedBy(8.dp)) { Text(errorDialogContent, style = MaterialTheme.typography.bodyMedium) Text( stringResource(R.string.reset_note), style = MaterialTheme.typography.labelMedium, color = MaterialTheme.customColors.warningTextColor, ) } }, onDismissRequest = { showErrorDialog = false errorDialogContent = "" }, dismissButton = { TextButton( onClick = { showErrorDialog = false errorDialogContent = "" } ) { Text(stringResource(R.string.cancel)) } }, confirmButton = { Button( onClick = { showErrorDialog = false errorDialogContent = "" viewModel.resetEngine( context = context, model = model, tools = tools, onError = { errorDialogContent = it showErrorDialog = true }, ) }, colors = ButtonDefaults.buttonColors(containerColor = taskColor), ) { Text(stringResource(R.string.reset), color = Color.White) } }, ) } } /** Returns the SHA-256 hash of the given string as a base64 encoded string. */ private fun String.sha256(): String { val inputBytes = this.toByteArray() return try { val sha256 = MessageDigest.getInstance("SHA-256") val digest = sha256.digest(inputBytes) BaseEncoding.base64().encode(digest) } catch (e: Exception) { e.printStackTrace() "" } } ================================================ FILE: Android/src/app/src/main/java/com/google/ai/edge/gallery/customtasks/tinygarden/TinyGardenTask.kt ================================================ /* * Copyright 2025 Google LLC * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * http://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. */ package com.google.ai.edge.gallery.customtasks.tinygarden import android.content.Context import androidx.compose.material.icons.Icons import androidx.compose.material.icons.outlined.LocalFlorist import androidx.compose.runtime.Composable import com.google.ai.edge.gallery.R import com.google.ai.edge.gallery.customtasks.common.CustomTask import com.google.ai.edge.gallery.customtasks.common.CustomTaskData import com.google.ai.edge.gallery.data.BuiltInTaskId import com.google.ai.edge.gallery.data.Category import com.google.ai.edge.gallery.data.Model import com.google.ai.edge.gallery.data.Task import com.google.ai.edge.gallery.ui.llmchat.LlmChatModelHelper import com.google.ai.edge.litertlm.Contents import javax.inject.Inject import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.channels.Channel import kotlinx.coroutines.flow.receiveAsFlow private const val SYSTEM_PROMPT = """You are an assistant helping the user play a game about gardening. The environment is a 3x3 grid of garden plots. The plots are numbered 1 through 9. **Garden Plot Layout**: - Row 1: Plots 1, 2, 3 (top row) - Row 2: Plots 4, 5, 6 (middle row) - Row 3: Plots 7, 8, 9 (bottom row) Help the user plant seeds, water plots, and harvest flowers. There are 4 kinds of seeds you can plant: 1. sunflower 2. daisy 3. rose 4. special (edge gallery, special, secret) Plot Array: For each action, identify all individual plot numbers (1-9) or implied plots (e.g., 'top row' -> 1, 2, 3) and collect them into the `plots` list. Tips: - ""top row"" has plots 1, 2, 3. - ""middle row"" has plots 4, 5, 6. - ""bottom row"" has plots 7, 8, 9. - ""left column"" has plots 1, 4, 7. - ""middle column"" has plots 2, 5, 8. - ""right column"" has plots 3, 6, 9. """ /** A custom task that demonstrates how to use FunctionGemma to play a simple gardening game. */ class TinyGardenTask @Inject constructor() : CustomTask { private val _updateChannel = Channel(Channel.BUFFERED) private val commandFlow = _updateChannel.receiveAsFlow() private val tools = listOf( TinyGardenTools( onFunctionCalled = { val unused = _updateChannel.trySend(it) } ) ) override val task = Task( id = BuiltInTaskId.LLM_TINY_GARDEN, label = "Tiny Garden", description = "Use natural language to plant, water, and harvest in this fully offline mini-game.\n\nNote: This is powered by the experimental FunctionGemma model optimized for latency. Due to its compact size (270M), it works well on simple instructions but responses may vary to more complex interactions.", docUrl = "https://github.com/google-ai-edge/LiteRT-LM/blob/main/kotlin/README.md", sourceCodeUrl = "https://github.com/google-ai-edge/gallery/blob/main/Android/src/app/src/main/java/com/google/ai/edge/gallery/customtasks/tinygarden", category = Category.LLM, icon = Icons.Outlined.LocalFlorist, agentNameRes = R.string.chat_agent_agent_name, models = mutableListOf(), handleModelConfigChangesInTask = true, experimental = true, ) override fun initializeModelFn( context: Context, coroutineScope: CoroutineScope, model: Model, onDone: (String) -> Unit, ) { clearQueue() LlmChatModelHelper.initialize( context = context, model = model, supportImage = false, supportAudio = false, onDone = onDone, systemInstruction = Contents.of(getTinyGardenSystemPrompt()), tools = tools, enableConversationConstrainedDecoding = true, ) } override fun cleanUpModelFn( context: Context, coroutineScope: CoroutineScope, model: Model, onDone: () -> Unit, ) { clearQueue() LlmChatModelHelper.cleanUp(model = model, onDone = onDone) } @Composable override fun MainScreen(data: Any) { val customTaskData = data as CustomTaskData TinyGardenScreen( task = task, modelManagerViewModel = customTaskData.modelManagerViewModel, tools = tools, bottomPadding = customTaskData.bottomPadding, commandFlow = commandFlow, setAppBarControlsDisabled = customTaskData.setAppBarControlsDisabled, setTopBarVisible = customTaskData.setTopBarVisible, ) } private fun clearQueue() { while (_updateChannel.tryReceive().isSuccess) {} } } fun getTinyGardenSystemPrompt( prevSeed: String = "", prevPlots: String = "", prevAction: String = "", ): String { val parts = mutableListOf(SYSTEM_PROMPT) if (prevSeed.isNotEmpty() || prevPlots.isNotEmpty() || prevAction.isNotEmpty()) { parts.add("Here is the info about user's last action:") } if (prevSeed.isNotEmpty()) { parts.add("- seed: $prevSeed") } if (prevPlots.isNotEmpty()) { parts.add("- plots: $prevPlots") } if (prevAction.isNotEmpty()) { parts.add("- action: $prevAction") } return parts.joinToString(separator = "\n") } ================================================ FILE: Android/src/app/src/main/java/com/google/ai/edge/gallery/customtasks/tinygarden/TinyGardenTaskModule.kt ================================================ /* * Copyright 2025 Google LLC * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * http://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. */ package com.google.ai.edge.gallery.customtasks.tinygarden import com.google.ai.edge.gallery.customtasks.common.CustomTask import dagger.Module import dagger.Provides import dagger.hilt.InstallIn import dagger.hilt.components.SingletonComponent import dagger.multibindings.IntoSet @Module @InstallIn(SingletonComponent::class) internal object TinyGardenTaskModule { @Provides @IntoSet fun provideTask(): CustomTask { return TinyGardenTask() } } ================================================ FILE: Android/src/app/src/main/java/com/google/ai/edge/gallery/customtasks/tinygarden/TinyGardenTools.kt ================================================ /* * Copyright 2025 Google LLC * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * http://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. */ package com.google.ai.edge.gallery.customtasks.tinygarden import android.util.Log import com.google.ai.edge.litertlm.Tool import com.google.ai.edge.litertlm.ToolParam private const val TAG = "AGTGTools" /** The items that can be used in the Tiny Garden game. */ enum class TinyGardenItem(val label: String) { SUNFLOWER(label = "sunflower"), DAISY(label = "daisy"), ROSE(label = "rose"), SPECIAL(label = "secret"), WATERING_CAN(label = "water"), SCYTHE(label = "harvest"), } /** A command to be sent to the Tiny Garden game. */ data class TinyGardenCommand( // This is 1-based. val item: Int, val plots: List, val ts: Long = System.currentTimeMillis(), ) /** * A class that defines the tools available to the Tiny Garden game. * * Instructions: * https://github.com/google-ai-edge/LiteRT-LM/blob/main/kotlin/README.md#6-defining-and-using-tools */ class TinyGardenTools(val onFunctionCalled: (command: TinyGardenCommand) -> Unit) { /** Waters one or more garden plots. */ @Tool(description = "Water one or more garden plots.") fun waterPlots( @ToolParam(description = "The IDs of the plots to water.") plots: List ): Map { Log.d(TAG, "waterPlots. Plots=$plots") onFunctionCalled( TinyGardenCommand(item = TinyGardenItem.WATERING_CAN.ordinal + 1, plots = plots) ) // Return a response object to the model confirming the action. return mapOf("result" to "success", "plots" to plots) } /** Plants a seed in one or more garden plots. */ @Tool(description = "Plant a seed in one or more garden plots.") fun plantSeed( @ToolParam(description = "The name of the seed to plant.") seed: String, @ToolParam(description = "The IDs of the plots to plant a seed in.") plots: List, ): Map { Log.d(TAG, "plantSeed. seed: $seed, plots; $plots") val itemId = when (seed.lowercase()) { "sunflower" -> TinyGardenItem.SUNFLOWER.ordinal "daisy" -> TinyGardenItem.DAISY.ordinal "rose" -> TinyGardenItem.ROSE.ordinal "special", "edge gallery", "secret" -> TinyGardenItem.SPECIAL.ordinal else -> -1 } + 1 if (itemId > 0) { onFunctionCalled(TinyGardenCommand(item = itemId, plots = plots)) } // Return a response object to the model confirming the action return mapOf("result" to "success", "seed" to seed, "plots" to plots) } /** Harvests one or more garden plots. */ @Tool(description = "Harvest one or more garden plots.") fun harvestPlots( @ToolParam(description = "The IDs of the plots to harvest.") plots: List ): Map { Log.d(TAG, "harvestPlots. Plots=$plots") onFunctionCalled(TinyGardenCommand(item = TinyGardenItem.SCYTHE.ordinal + 1, plots = plots)) // Return a response object to the model confirming the action. return mapOf("result" to "success", "plots" to plots) } } ================================================ FILE: Android/src/app/src/main/java/com/google/ai/edge/gallery/customtasks/tinygarden/TinyGardenViewModel.kt ================================================ /* * Copyright 2025 Google LLC * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * http://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. */ package com.google.ai.edge.gallery.customtasks.tinygarden import android.content.Context import android.util.Log import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import com.google.ai.edge.gallery.R import com.google.ai.edge.gallery.data.DataStoreRepository import com.google.ai.edge.gallery.data.Model import com.google.ai.edge.gallery.ui.common.chat.ChatMessage import com.google.ai.edge.gallery.ui.common.chat.ChatMessageText import com.google.ai.edge.gallery.ui.common.chat.ChatMessageWarning import com.google.ai.edge.gallery.ui.common.chat.ChatSide import com.google.ai.edge.gallery.ui.llmchat.LlmChatModelHelper import com.google.ai.edge.gallery.ui.llmchat.LlmModelInstance import com.google.ai.edge.litertlm.Content import com.google.ai.edge.litertlm.Contents import dagger.hilt.android.lifecycle.HiltViewModel import dagger.hilt.android.qualifiers.ApplicationContext import javax.inject.Inject import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.first import kotlinx.coroutines.flow.update import kotlinx.coroutines.launch private const val TAG = "AGTGViewModel" /** The UI state of the task. */ data class TinyGardenUiState( // Whether the app is processing the user input. val processing: Boolean = false, // Whether the app is resetting the engine (without resetting the game). val resettingEngine: Boolean = false, // The messages in the conversation history. val messages: List = listOf(), // The number of turns. val numTurns: Int = 0, ) /** The ViewModel of the task screen. */ @HiltViewModel class TinyGardenViewModel @Inject constructor( @ApplicationContext private val context: Context, val dataStoreRepository: DataStoreRepository, ) : ViewModel() { protected val _uiState = MutableStateFlow(TinyGardenUiState()) val uiState = _uiState.asStateFlow() private val _isResettingConversation = MutableStateFlow(false) private val isResettingConversation = _isResettingConversation.asStateFlow() /** * Sends the user instruction to the model and processes the response. * * The tools defined in [TinyGardenTools] will be invoked during the process. */ fun getCommand( model: Model, instructionText: String, onDone: (String) -> Unit, onError: (String) -> Unit, ) { if (model.instance == null) { setProcessing(processing = false) return } // Count turn. incrementNumTurns() Log.d(TAG, "Turn #: ${uiState.value.numTurns}") // Add user prompt to history. this.addMessage(message = ChatMessageText(content = instructionText, side = ChatSide.USER)) viewModelScope.launch(Dispatchers.Default) { Log.d(TAG, "Start processing user instruction: '$instructionText'") setProcessing(processing = true) // Wait until the conversation is NOT resetting. Log.d(TAG, "Waiting for any ongoing conversation reset to be done...") isResettingConversation.first { !it } Log.d(TAG, "Done waiting. Start inference.") val instance = model.instance as LlmModelInstance val conversation = instance.conversation val contents = mutableListOf() if (instructionText.trim().isNotEmpty()) { contents.add(Content.Text(instructionText)) } try { val responseMessage = conversation.sendMessage(Contents.of(contents)) val response = responseMessage.toString() Log.d(TAG, "Done processing user instruction. Response: $response") onDone(response) } catch (e: Exception) { Log.e(TAG, "Failed to run inference", e) onError(e.message ?: context.getString(R.string.unknown_error)) } finally { setProcessing(processing = false) } } } fun addMessage(message: ChatMessage) { val newMessages = _uiState.value.messages.toMutableList() newMessages.add(message) _uiState.update { _uiState.value.copy(messages = newMessages) } } fun clearMessages() { _uiState.update { _uiState.value.copy(messages = listOf()) } } fun setProcessing(processing: Boolean) { _uiState.update { uiState.value.copy(processing = processing) } } fun setResettingEngine(resetting: Boolean) { _uiState.update { uiState.value.copy(resettingEngine = resetting) } } fun incrementNumTurns() { _uiState.update { uiState.value.copy(numTurns = uiState.value.numTurns + 1) } } fun resetNumTurns() { _uiState.update { uiState.value.copy(numTurns = 0) } } fun resetEngine( context: Context, model: Model, tools: List, onError: (error: String) -> Unit, ) { resetNumTurns() viewModelScope.launch(Dispatchers.Default) { setResettingEngine(resetting = true) LlmChatModelHelper.cleanUp( model = model, onDone = { LlmChatModelHelper.initialize( context = context, model = model, supportImage = false, supportAudio = false, onDone = { error -> setResettingEngine(resetting = false) if (error.isNotEmpty()) { onError(error) } addMessage( message = ChatMessageWarning(content = context.getString(R.string.engin_reset_message)) ) }, systemInstruction = Contents.of(getTinyGardenSystemPrompt()), tools = tools, enableConversationConstrainedDecoding = true, ) }, ) } } fun resetConversation( model: Model, tools: List, prevSeed: String, prevPlots: String, prevAction: String, ) { resetNumTurns() viewModelScope.launch(Dispatchers.Default) { _isResettingConversation.value = true val curSystemPrompt = getTinyGardenSystemPrompt( prevSeed = prevSeed, prevPlots = prevPlots, prevAction = prevAction, ) Log.d(TAG, "Current system prompt:\n$curSystemPrompt") LlmChatModelHelper.resetConversation( model = model, supportImage = false, supportAudio = false, systemInstruction = Contents.of(curSystemPrompt), tools = tools, enableConversationConstrainedDecoding = true, ) _isResettingConversation.value = false addMessage( message = ChatMessageWarning(content = context.getString(R.string.conversation_reset_message)) ) } } } ================================================ FILE: Android/src/app/src/main/java/com/google/ai/edge/gallery/data/AppBarAction.kt ================================================ /* * Copyright 2025 Google LLC * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * http://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. */ package com.google.ai.edge.gallery.data /** Possible action for app bar. */ enum class AppBarActionType { NO_ACTION, APP_SETTING, DOWNLOAD_MANAGER, NAVIGATE_UP, MENU, } class AppBarAction(val actionType: AppBarActionType, val actionFn: () -> Unit) ================================================ FILE: Android/src/app/src/main/java/com/google/ai/edge/gallery/data/Categories.kt ================================================ /* * Copyright 2025 Google LLC * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * http://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. */ package com.google.ai.edge.gallery.data import androidx.annotation.StringRes import com.google.ai.edge.gallery.R /** * Stores basic info about a Category * * A category is a tab on the home page which contains a list of tasks. Category is set through * Task. */ data class CategoryInfo( // The id of the category. val id: String, // The string resource id of the label of the resource, for display purpose. @StringRes val labelStringRes: Int? = null, // The string label. It takes precedence over labelStringRes above. val label: String? = null, ) /** Pre-defined categories. */ object Category { val LLM = CategoryInfo(id = "llm", labelStringRes = R.string.category_llm) val CLASSICAL_ML = CategoryInfo(id = "classical_ml", labelStringRes = R.string.category_llm) val EXPERIMENTAL = CategoryInfo(id = "experimental", labelStringRes = R.string.category_experimental) } ================================================ FILE: Android/src/app/src/main/java/com/google/ai/edge/gallery/data/Config.kt ================================================ /* * Copyright 2025 Google LLC * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * http://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. */ package com.google.ai.edge.gallery.data import androidx.annotation.StringRes import kotlin.math.abs /** * The types of configuration editors available. * * This enum defines the different UI components used to edit configuration values. Each type * corresponds to a specific editor widget, such as a slider or a switch. */ enum class ConfigEditorType { LABEL, NUMBER_SLIDER, BOOLEAN_SWITCH, SEGMENTED_BUTTON, BOTTOMSHEET_SELECTOR, } /** The data types of configuration values. */ enum class ValueType { INT, FLOAT, DOUBLE, STRING, BOOLEAN, } data class ConfigKey(val id: String, val label: String) object ConfigKeys { val MAX_TOKENS = ConfigKey("max_tokens", "Max tokens") val TOPK = ConfigKey("topk", "TopK") val TOPP = ConfigKey("topp", "TopP") val TEMPERATURE = ConfigKey("temperature", "Temperature") val DEFAULT_MAX_TOKENS = ConfigKey("default_max_tokens", "Default max tokens") val DEFAULT_TOPK = ConfigKey("default_topk", "Default TopK") val DEFAULT_TOPP = ConfigKey("default_topp", "Default TopP") val DEFAULT_TEMPERATURE = ConfigKey("default_temperature", "Default temperature") val SUPPORT_IMAGE = ConfigKey("support_image", "Support image") val SUPPORT_AUDIO = ConfigKey("support_audio", "Support audio") val SUPPORT_TINY_GARDEN = ConfigKey("support_tiny_garden", "Support tiny garden") val SUPPORT_MOBILE_ACTIONS = ConfigKey("support_mobile_actions", "Support mobile actions") val MAX_RESULT_COUNT = ConfigKey("max_result_count", "Max result count") val USE_GPU = ConfigKey("use_gpu", "Use GPU") val ACCELERATOR = ConfigKey("accelerator", "Accelerator") val VISION_ACCELERATOR = ConfigKey("vision_accelerator", "Vision accelerator") val COMPATIBLE_ACCELERATORS = ConfigKey("compatible_accelerators", "Compatible accelerators") val WARM_UP_ITERATIONS = ConfigKey("warm_up_iterations", "Warm up iterations") val BENCHMARK_ITERATIONS = ConfigKey("benchmark_iterations", "Benchmark iterations") val ITERATIONS = ConfigKey("iterations", "Iterations") val THEME = ConfigKey("theme", "Theme") val NAME = ConfigKey("name", "Name") val MODEL_TYPE = ConfigKey("model_type", "Model type") val MODEL = ConfigKey("model", "Model") val RESET_CONVERSATION_TURN_COUNT = ConfigKey("reset_conversation_turn_count", "Number of turns before the conversation resets") val PREFILL_TOKENS = ConfigKey("prefill_tokens", "Prefill tokens") val DECODE_TOKENS = ConfigKey("decode_tokens", "Decode tokens") val NUMBER_OF_RUNS = ConfigKey("number_of_runs", "Number of runs") } /** * 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, // Changes on any configs with this field set to true will automatically trigger a model // re-initialization. open val needReinitialization: Boolean = true, ) /** Configuration setting for a label. */ class LabelConfig(override val key: ConfigKey, override val defaultValue: String = "") : Config( type = ConfigEditorType.LABEL, key = key, defaultValue = defaultValue, valueType = ValueType.STRING, ) /** * Configuration setting for a 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 segmented button. */ class SegmentedButtonConfig( override val key: ConfigKey, override val defaultValue: String, val options: List, val allowMultiple: Boolean = false, ) : Config( type = ConfigEditorType.SEGMENTED_BUTTON, key = key, defaultValue = defaultValue, // The emitted value will be comma-separated labels when allowMultiple=true. valueType = ValueType.STRING, ) /** Configuration setting for a bottom sheet selector. */ class BottomSheetSelectorConfig( override val key: ConfigKey, override val defaultValue: String, val options: List, @StringRes val bottomSheetTitleResId: Int? = null, ) : Config( type = ConfigEditorType.BOTTOMSHEET_SELECTOR, key = key, defaultValue = defaultValue, valueType = ValueType.STRING, ) data class BottomSheetSelectorItem(val label: String) fun convertValueToTargetType(value: Any, valueType: ValueType): Any { return when (valueType) { ValueType.INT -> when (value) { is Int -> value is Float -> value.toInt() is Double -> value.toInt() is String -> value.toIntOrNull() ?: "" is Boolean -> if (value) 1 else 0 else -> "" } ValueType.FLOAT -> when (value) { is Int -> value.toFloat() is Float -> value is Double -> value.toFloat() is String -> value.toFloatOrNull() ?: "" is Boolean -> if (value) 1f else 0f else -> "" } ValueType.DOUBLE -> when (value) { is Int -> value.toDouble() is Float -> value.toDouble() is Double -> value is String -> value.toDoubleOrNull() ?: "" is Boolean -> if (value) 1.0 else 0.0 else -> "" } ValueType.BOOLEAN -> when (value) { is Int -> value == 0 is Boolean -> value is Float -> abs(value) > 1e-6 is Double -> abs(value) > 1e-6 is String -> value.isNotEmpty() else -> false } ValueType.STRING -> value.toString() } } fun createLlmChatConfigs( defaultMaxToken: Int = DEFAULT_MAX_TOKEN, defaultTopK: Int = DEFAULT_TOPK, defaultTopP: Float = DEFAULT_TOPP, defaultTemperature: Float = DEFAULT_TEMPERATURE, accelerators: List = DEFAULT_ACCELERATORS, ): List { return listOf( LabelConfig(key = ConfigKeys.MAX_TOKENS, defaultValue = "$defaultMaxToken"), NumberSliderConfig( key = ConfigKeys.TOPK, sliderMin = 5f, sliderMax = 100f, defaultValue = defaultTopK.toFloat(), valueType = ValueType.INT, ), NumberSliderConfig( key = ConfigKeys.TOPP, sliderMin = 0.0f, sliderMax = 1.0f, defaultValue = defaultTopP, valueType = ValueType.FLOAT, ), NumberSliderConfig( key = ConfigKeys.TEMPERATURE, sliderMin = 0.0f, sliderMax = 2.0f, defaultValue = defaultTemperature, valueType = ValueType.FLOAT, ), SegmentedButtonConfig( key = ConfigKeys.ACCELERATOR, defaultValue = accelerators[0].label, options = accelerators.map { it.label }, ), ) } /** * Creates the configuration settings for an LLM model that only supports NPU. * * For now NPU models don't support setting topK, topP, and temperature. */ fun createLlmChatConfigsForNpuModel( defaultMaxToken: Int = DEFAULT_MAX_TOKEN, accelerators: List = DEFAULT_ACCELERATORS, ): List { return listOf( LabelConfig(key = ConfigKeys.MAX_TOKENS, defaultValue = "$defaultMaxToken"), SegmentedButtonConfig( key = ConfigKeys.ACCELERATOR, defaultValue = accelerators[0].label, options = accelerators.map { it.label }, ), ) } fun getConfigValueString(value: Any, config: Config): String { var strNewValue = "$value" if (config.valueType == ValueType.FLOAT) { strNewValue = "%.2f".format(value) } return strNewValue } ================================================ FILE: Android/src/app/src/main/java/com/google/ai/edge/gallery/data/ConfigValue.kt ================================================ /* * Copyright 2025 Google LLC * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * http://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. */ package com.google.ai.edge.gallery.data // @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") // } // } // } 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 } } ================================================ FILE: Android/src/app/src/main/java/com/google/ai/edge/gallery/data/Consts.kt ================================================ /* * Copyright 2025 Google LLC * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * http://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. */ package com.google.ai.edge.gallery.data import android.os.Build import androidx.compose.ui.unit.dp // Keys used to send/receive data to Work. const val KEY_MODEL_URL = "KEY_MODEL_URL" const val KEY_MODEL_NAME = "KEY_MODEL_NAME" const val KEY_MODEL_COMMIT_HASH = "KEY_MODEL_COMMIT_HASH" const val KEY_MODEL_DOWNLOAD_MODEL_DIR = "KEY_MODEL_DOWNLOAD_MODEL_DIR" 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" // Default values for LLM models. const val DEFAULT_MAX_TOKEN = 1024 const val DEFAULT_TOPK = 64 const val DEFAULT_TOPP = 0.95f const val DEFAULT_TEMPERATURE = 1.0f val DEFAULT_ACCELERATORS = listOf(Accelerator.GPU) val DEFAULT_VISION_ACCELERATOR = Accelerator.GPU // Max number of images allowed in a "ask image" session. const val MAX_IMAGE_COUNT = 10 // Max number of audio clip in an "ask audio" session. const val MAX_AUDIO_CLIP_COUNT = 1 // Max audio clip duration in seconds. const val MAX_AUDIO_CLIP_DURATION_SEC = 30 // Audio-recording related consts. const val SAMPLE_RATE = 16000 // The size the icon shown under each of the model names in the model list screen. val MODEL_INFO_ICON_SIZE = 18.dp // The extension of the tmp download files. const val TMP_FILE_EXT = "gallerytmp" // Current device's SOC in lowercase. val SOC = (if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) { Build.SOC_MODEL ?: "" } else { "" }) .lowercase() ================================================ FILE: Android/src/app/src/main/java/com/google/ai/edge/gallery/data/DataStoreRepository.kt ================================================ /* * Copyright 2025 Google LLC * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * http://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. */ package com.google.ai.edge.gallery.data import androidx.datastore.core.DataStore import com.google.ai.edge.gallery.proto.AccessTokenData import com.google.ai.edge.gallery.proto.BenchmarkResult import com.google.ai.edge.gallery.proto.BenchmarkResults import com.google.ai.edge.gallery.proto.Cutout import com.google.ai.edge.gallery.proto.CutoutCollection import com.google.ai.edge.gallery.proto.ImportedModel import com.google.ai.edge.gallery.proto.Settings import com.google.ai.edge.gallery.proto.Theme import com.google.ai.edge.gallery.proto.UserData import kotlinx.coroutines.flow.first import kotlinx.coroutines.runBlocking // TODO(b/423700720): Change to async (suspend) functions interface DataStoreRepository { fun saveTextInputHistory(history: List) fun readTextInputHistory(): List fun saveTheme(theme: Theme) fun readTheme(): Theme fun saveSecret(key: String, value: String) fun readSecret(key: String): String? fun deleteSecret(key: String) fun saveAccessTokenData(accessToken: String, refreshToken: String, expiresAt: Long) fun clearAccessTokenData() fun readAccessTokenData(): AccessTokenData? fun saveImportedModels(importedModels: List) fun readImportedModels(): List fun isTosAccepted(): Boolean fun acceptTos() fun isGemmaTermsOfUseAccepted(): Boolean fun acceptGemmaTermsOfUse() fun getHasRunTinyGarden(): Boolean fun setHasRunTinyGarden(hasRun: Boolean) fun addCutout(cutout: Cutout) fun getAllCutouts(): List fun setCutout(newCutout: Cutout) fun setCutouts(cutouts: List) fun setHasSeenBenchmarkComparisonHelp(seen: Boolean) fun getHasSeenBenchmarkComparisonHelp(): Boolean fun addBenchmarkResult(result: BenchmarkResult) fun getAllBenchmarkResults(): List fun deleteBenchmarkResult(index: Int) } /** Repository for managing data using Proto DataStore. */ class DefaultDataStoreRepository( private val dataStore: DataStore, private val userDataDataStore: DataStore, private val cutoutDataStore: DataStore, private val benchmarkResultsDataStore: DataStore, ) : DataStoreRepository { override fun saveTextInputHistory(history: List) { runBlocking { dataStore.updateData { settings -> settings.toBuilder().clearTextInputHistory().addAllTextInputHistory(history).build() } } } override fun readTextInputHistory(): List { return runBlocking { val settings = dataStore.data.first() settings.textInputHistoryList } } override fun saveTheme(theme: Theme) { runBlocking { dataStore.updateData { settings -> settings.toBuilder().setTheme(theme).build() } } } override fun readTheme(): Theme { return runBlocking { val settings = dataStore.data.first() val curTheme = settings.theme // Use "auto" as the default theme. if (curTheme == Theme.THEME_UNSPECIFIED) Theme.THEME_AUTO else curTheme } } override fun saveSecret(key: String, value: String) { runBlocking { userDataDataStore.updateData { userData -> userData.toBuilder().putSecrets(key, value).build() } } } override fun readSecret(key: String): String? { return runBlocking { userDataDataStore.data.first().secretsMap[key] } } override fun deleteSecret(key: String) { runBlocking { userDataDataStore.updateData { userData -> userData.toBuilder().removeSecrets(key).build() } } } override fun saveAccessTokenData(accessToken: String, refreshToken: String, expiresAt: Long) { runBlocking { // Clear the entry in old data store. dataStore.updateData { settings -> settings.toBuilder().setAccessTokenData(AccessTokenData.getDefaultInstance()).build() } userDataDataStore.updateData { userData -> userData .toBuilder() .setAccessTokenData( AccessTokenData.newBuilder() .setAccessToken(accessToken) .setRefreshToken(refreshToken) .setExpiresAtMs(expiresAt) .build() ) .build() } } } override fun clearAccessTokenData() { runBlocking { dataStore.updateData { settings -> settings.toBuilder().clearAccessTokenData().build() } userDataDataStore.updateData { userData -> userData.toBuilder().clearAccessTokenData().build() } } } override fun readAccessTokenData(): AccessTokenData? { return runBlocking { val userData = userDataDataStore.data.first() userData.accessTokenData } } override fun saveImportedModels(importedModels: List) { runBlocking { dataStore.updateData { settings -> settings.toBuilder().clearImportedModel().addAllImportedModel(importedModels).build() } } } override fun readImportedModels(): List { return runBlocking { val settings = dataStore.data.first() settings.importedModelList } } override fun isTosAccepted(): Boolean { return runBlocking { val settings = dataStore.data.first() settings.isTosAccepted } } override fun acceptTos() { runBlocking { dataStore.updateData { settings -> settings.toBuilder().setIsTosAccepted(true).build() } } } override fun isGemmaTermsOfUseAccepted(): Boolean { return runBlocking { val settings = dataStore.data.first() settings.isGemmaTermsAccepted } } override fun acceptGemmaTermsOfUse() { runBlocking { dataStore.updateData { settings -> settings.toBuilder().setIsGemmaTermsAccepted(true).build() } } } override fun getHasRunTinyGarden(): Boolean { return runBlocking { val settings = dataStore.data.first() settings.hasRunTinyGarden } } override fun setHasRunTinyGarden(hasRun: Boolean) { runBlocking { dataStore.updateData { settings -> settings.toBuilder().setHasRunTinyGarden(hasRun).build() } } } override fun addCutout(cutout: Cutout) { runBlocking { cutoutDataStore.updateData { cutouts -> cutouts.toBuilder().addCutout(cutout).build() } } } override fun getAllCutouts(): List { return runBlocking { cutoutDataStore.data.first().cutoutList } } override fun setCutout(newCutout: Cutout) { runBlocking { cutoutDataStore.updateData { cutouts -> var index = -1 for (i in 0..= 0) { cutouts.toBuilder().setCutout(index, newCutout).build() } else { cutouts } } } } override fun setCutouts(cutouts: List) { runBlocking { cutoutDataStore.updateData { CutoutCollection.newBuilder().addAllCutout(cutouts).build() } } } override fun setHasSeenBenchmarkComparisonHelp(seen: Boolean) { runBlocking { dataStore.updateData { settings -> settings.toBuilder().setHasSeenBenchmarkComparisonHelp(seen).build() } } } override fun getHasSeenBenchmarkComparisonHelp(): Boolean { return runBlocking { val settings = dataStore.data.first() settings.hasSeenBenchmarkComparisonHelp } } override fun addBenchmarkResult(result: BenchmarkResult) { runBlocking { benchmarkResultsDataStore.updateData { results -> results.toBuilder().addResult(0, result).build() } } } override fun getAllBenchmarkResults(): List { return runBlocking { benchmarkResultsDataStore.data.first().resultList } } override fun deleteBenchmarkResult(index: Int) { runBlocking { benchmarkResultsDataStore.updateData { results -> val newResults = results.toBuilder().removeResult(index).build() newResults } } } } ================================================ FILE: Android/src/app/src/main/java/com/google/ai/edge/gallery/data/DownloadRepository.kt ================================================ /* * Copyright 2025 Google LLC * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * http://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. */ package com.google.ai.edge.gallery.data 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.util.Log import androidx.core.app.ActivityCompat import androidx.core.app.NotificationCompat import androidx.core.app.NotificationManagerCompat import androidx.core.content.edit import androidx.core.net.toUri import androidx.core.os.bundleOf import androidx.work.Data import androidx.work.ExistingWorkPolicy import androidx.work.OneTimeWorkRequestBuilder import androidx.work.OutOfQuotaPolicy import androidx.work.WorkInfo import androidx.work.WorkManager import com.google.ai.edge.gallery.AppLifecycleProvider import com.google.ai.edge.gallery.GalleryEvent import com.google.ai.edge.gallery.R import com.google.ai.edge.gallery.firebaseAnalytics import com.google.ai.edge.gallery.worker.DownloadWorker import java.util.UUID import java.util.concurrent.Executors private const val TAG = "AGDownloadRepository" private const val MODEL_NAME_TAG = "modelName" private const val TASK_ID_TAG = "taskId" data class AGWorkInfo(val taskId: String, val modelName: String, val workId: String) interface DownloadRepository { fun downloadModel( task: Task?, model: Model, onStatusUpdated: (model: Model, status: ModelDownloadStatus) -> Unit, ) fun cancelDownloadModel(model: Model) fun cancelAll(onComplete: () -> Unit) fun observerWorkerProgress( workerId: UUID, task: Task?, model: Model, onStatusUpdated: (model: Model, status: ModelDownloadStatus) -> Unit, ) } private const val DOWNLOAD_FROM_GLOBAL_MODEL_MANAGER_TASK_ID = "___" /** * 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) /** * Stores the start time of a model download. * * We use SharedPreferences to persist the download start times. This ensures that the data is * still available after the app restarts. The key is the model name and the value is the download * start time in milliseconds. */ private val downloadStartTimeSharedPreferences = context.getSharedPreferences("download_start_time_ms", Context.MODE_PRIVATE) override fun downloadModel( task: Task?, 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_NAME, model.name) .putString(KEY_MODEL_URL, model.url) .putString(KEY_MODEL_COMMIT_HASH, model.version) .putString(KEY_MODEL_DOWNLOAD_MODEL_DIR, model.normalizedName) .putString(KEY_MODEL_DOWNLOAD_FILE_NAME, model.downloadFileName) .putBoolean(KEY_MODEL_IS_ZIP, model.isZip) .putString(KEY_MODEL_UNZIPPED_DIR, model.unzipDir) .putLong(KEY_MODEL_TOTAL_BYTES, totalBytes) 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}") .addTag("$TASK_ID_TAG:${task?.id ?: ""}") .build() val workerId = downloadWorkRequest.id // Start! workManager.enqueueUniqueWork(model.name, ExistingWorkPolicy.REPLACE, downloadWorkRequest) // Observe progress. observerWorkerProgress( workerId = workerId, task = task, model = model, onStatusUpdated = onStatusUpdated, ) } override fun cancelDownloadModel(model: Model) { workManager.cancelAllWorkByTag("$MODEL_NAME_TAG:${model.name}") } override fun cancelAll(onComplete: () -> Unit) { workManager .cancelAllWork() .result .addListener({ onComplete() }, Executors.newSingleThreadExecutor()) } override fun observerWorkerProgress( workerId: UUID, task: Task?, model: Model, onStatusUpdated: (model: Model, status: ModelDownloadStatus) -> Unit, ) { workManager.getWorkInfoByIdLiveData(workerId).observeForever { workInfo -> if (workInfo != null) { when (workInfo.state) { WorkInfo.State.ENQUEUED -> { downloadStartTimeSharedPreferences.edit { putLong(model.name, System.currentTimeMillis()) } firebaseAnalytics?.logEvent( GalleryEvent.MODEL_DOWNLOAD.id, bundleOf("event_type" to "start", "model_id" to model.name), ) } 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), taskId = task?.id ?: DOWNLOAD_FROM_GLOBAL_MODEL_MANAGER_TASK_ID, modelName = model.name, ) val startTime = downloadStartTimeSharedPreferences.getLong(model.name, 0L) val duration = System.currentTimeMillis() - startTime firebaseAnalytics?.logEvent( GalleryEvent.MODEL_DOWNLOAD.id, bundleOf( "event_type" to "success", "model_id" to model.name, "duration_ms" to duration, ), ) downloadStartTimeSharedPreferences.edit { remove(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), taskId = "", modelName = "", ) } onStatusUpdated( model, ModelDownloadStatus(status = status, errorMessage = errorMessage), ) val startTime = downloadStartTimeSharedPreferences.getLong(model.name, 0L) val duration = System.currentTimeMillis() - startTime // TODO: Add failure reasons firebaseAnalytics?.logEvent( GalleryEvent.MODEL_DOWNLOAD.id, bundleOf( "event_type" to "failure", "model_id" to model.name, "duration_ms" to duration, ), ) downloadStartTimeSharedPreferences.edit { remove(model.name) } } else -> {} } } } } private fun sendNotification(title: String, text: String, taskId: 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 val importance = NotificationManager.IMPORTANCE_HIGH val channel = NotificationChannel(channelId, channelName, importance) val notificationManager: NotificationManager = context.getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager notificationManager.createNotificationChannel(channel) val intent: Intent if (taskId.isEmpty()) { // If taskId is empty, it's a failed download. Just open the app's main screen. intent = context.packageManager.getLaunchIntentForPackage(context.packageName)!! } // Download from global model manager. Open the global model manager screen. else if (taskId == DOWNLOAD_FROM_GLOBAL_MODEL_MANAGER_TASK_ID) { intent = Intent(Intent.ACTION_VIEW, "com.google.ai.edge.gallery://global_model_manager".toUri()) .apply { flags = Intent.FLAG_ACTIVITY_NEW_TASK } } else { // Otherwise, create the deep link as before. intent = Intent( Intent.ACTION_VIEW, "com.google.ai.edge.gallery://model/$taskId/${modelName}".toUri(), ) .apply { flags = Intent.FLAG_ACTIVITY_NEW_TASK } } // Create a PendingIntent val pendingIntent: PendingIntent = PendingIntent.getActivity( context, 0, intent, PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE, ) val 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()) } } } ================================================ FILE: Android/src/app/src/main/java/com/google/ai/edge/gallery/data/Model.kt ================================================ /* * Copyright 2025 Google LLC * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * http://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. */ package com.google.ai.edge.gallery.data import android.content.Context import com.google.gson.annotations.SerializedName import java.io.File data class ModelDataFile( val name: String, val url: String, val downloadFileName: String, val sizeInBytes: Long, ) const val IMPORTS_DIR = "__imports" private val NORMALIZE_NAME_REGEX = Regex("[^a-zA-Z0-9]") data class PromptTemplate(val title: String, val description: String, val prompt: String) enum class RuntimeType { @SerializedName("unknown") UNKNOWN, @SerializedName("litert_lm") LITERT_LM, } /** * A model for a task (see [Task]). * * A task can have multiple models. For example, a task might be "LLM Chat", and it might have * models such as Gemma2, Gemma3, etc. */ data class Model( /** * The name of the model. * * This field is used to uniquely identify this model among all the tasks. * * IMPORTANT: it shouldn't contain "/" character. */ val name: String, /** * The display name of the model, for display purpose. * * If this field is not set, the `name` field above will be used as the default display name. */ val displayName: String = "", /** * (optional) * * A description or information about the model (Markdown supported). * * Displayed in the expanded model info card. */ val info: String = "", /** * (optional) * * A list of configurable parameters for the model. * * If set, a gear icon appears on the right side of the model main screen's app bar. When * selected, a dialog pops up, allowing users to update the model's configurations. * * See [Config] for more details */ var configs: List = listOf(), /** * (optional) * * The url to jump to when clicking "learn more" in model's info card. */ val learnMoreUrl: String = "", /** * (optional) * * The task type ids that this model is best for. * * When set, the model's info card is pinned to the top of the model list when the corresponding * task is selected, expanded by default, and displays a "best overall" banner. * * Each task should only have one such model. */ val bestForTaskIds: List = listOf(), /** * (optional) * * The minimum device memory in GB to run the model. * * If set, a warning dialog will be shown when user trying to download the model or enter the * model screen. */ val minDeviceMemoryInGb: Int? = null, ////////////////////////////////////////////////////////////////////////////////////////////////// // Fill in the following fields if the model file needs to be downloaded from internet. // // If you want to manually manage model files without downloading them from internet, set the // `localFilePathOverride` field below. /** * The URL to download the model from. * * If the url is from HuggingFace, we will automatically prompt users to fetch access token if the * model is gated. */ val url: String = "", /** * The size of the model file in bytes. * * This will be used to calculate download progress. */ val sizeInBytes: Long = 0L, /** * The name of the downloaded model file. * * It will be used to define the file path on local device to store the downloaded model. * {context.getExternalFilesDir}/{normalizedName}/{version}/{downloadFileName} */ val downloadFileName: String = "_", /** * (optional) * * The version of the model. * * It will be used to define the file path on local device to store the downloaded model. * {context.getExternalFilesDir}/{normalizedName}/{version}/{downloadFileName} */ val version: String = "_", /** * (optional, experimental) * * A list of additional data files required by the model. */ val extraDataFiles: List = listOf(), /** Whether the model is LLM or not. */ val isLlm: Boolean = false, // End of model download related fields. ////////////////////////////////////////////////////////////////////////////////////////////////// /** The type of local runtime environment to use for running the model. */ val runtimeType: RuntimeType = RuntimeType.UNKNOWN, /** * Set this to a relative path pointing to a dir (e.g., my_model/local_dir/) if you want to * manually manage model files instead of downloading them. This dir is relative to the app's * "External Files Directory", which is: /storage/emulated/0/Android/data//files/. * * The depends on how the app was built: * - `com.google.aiedge.gallery` for builds from the GitHub source. * - `com.google.ai.edge.gallery` for other builds (Play store, internal, etc). * * For example, if this field is set to "my_model/local_dir/", then the location you should push * files to is (assuming non-github builds): * * /storage/emulated/0/Android/data/com.google.ai.edge.gallery/files/my_model/local_dir/ * * You can get the full path to a specific file within your code using `Model.getPath(Context, * fileNameToGet)`. * * Using this field is recommended when: * - Your model files are not publicly accessible on the internet (e.g. private models). * - Your "model" or experience requires multiple files. Manually pushing these files to the * device and using Model.getPath() for each one is often simpler than downloading them, * especially for demos. */ val localFileRelativeDirPathOverride: String = "", /** * When set, the app will try to use this path to find the model file. * * For testing purpose only. */ val localModelFilePathOverride: String = "", // The following fields are only used for built-in tasks. Can ignore if you are creating your own // custom tasks. // /** 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 prompt templates for the model (only for LLM). */ val llmPromptTemplates: List = listOf(), /** Whether the LLM model supports image input. */ val llmSupportImage: Boolean = false, /** Whether the LLM model supports audio input. */ val llmSupportAudio: Boolean = false, /** Whether the LLM model supports tiny garden. */ val llmSupportTinyGarden: Boolean = false, /** Whether the LLM model supports mobile actions. */ val llmSupportMobileActions: Boolean = false, /** The max token for llm model. */ val llmMaxToken: Int = 0, /** Compatible accelerators. */ val accelerators: List = listOf(), /** Accelerator for running vision encoder. */ val visionAccelerator: Accelerator = Accelerator.GPU, /** Whether the model is imported or not. */ val imported: Boolean = false, // The following fields are managed by the app. Don't need to set manually. // var normalizedName: String = "", var instance: Any? = null, var initializing: Boolean = false, // TODO(jingjin): use a "queue" system to manage model init and cleanup. var cleanUpAfterInit: Boolean = false, var configValues: Map = mapOf(), var prevConfigValues: Map = mapOf(), var totalBytes: Long = 0L, var accessToken: String? = null, ) { init { normalizedName = NORMALIZE_NAME_REGEX.replace(name, "_") } fun preProcess() { 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 { if (imported) { return listOf(context.getExternalFilesDir(null)?.absolutePath ?: "", fileName) .joinToString(File.separator) } if (localModelFilePathOverride.isNotEmpty()) { return localModelFilePathOverride } if (localFileRelativeDirPathOverride.isNotEmpty()) { return listOf( context.getExternalFilesDir(null)?.absolutePath ?: "", localFileRelativeDirPathOverride, fileName, ) .joinToString(File.separator) } val baseDir = listOf(context.getExternalFilesDir(null)?.absolutePath ?: "", normalizedName, version) .joinToString(File.separator) return if (this.isZip && this.unzipDir.isNotEmpty()) { listOf(baseDir, this.unzipDir).joinToString(File.separator) } else { listOf(baseDir, fileName).joinToString(File.separator) } } 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 getStringConfigValue(key: ConfigKey, defaultValue: String = ""): String { return getTypedConfigValue(key = key, valueType = ValueType.STRING, defaultValue = defaultValue) as String } 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. val EMPTY_MODEL: Model = Model(name = "empty", downloadFileName = "empty.tflite", url = "", sizeInBytes = 0L) ================================================ FILE: Android/src/app/src/main/java/com/google/ai/edge/gallery/data/ModelAllowlist.kt ================================================ /* * Copyright 2025 Google LLC * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * http://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. */ package com.google.ai.edge.gallery.data import android.os.Build import android.util.Log import com.google.ai.edge.gallery.common.isPixel10 import com.google.gson.annotations.SerializedName private const val TAG = "AGModelAllowlist" data class DefaultConfig( @SerializedName("topK") val topK: Int?, @SerializedName("topP") val topP: Float?, @SerializedName("temperature") val temperature: Float?, @SerializedName("accelerators") val accelerators: String?, @SerializedName("visionAccelerator") val visionAccelerator: String?, @SerializedName("maxTokens") val maxTokens: Int?, ) /** A model file on HF for a specific SOC. */ data class SocModelFile( @SerializedName("modelFile") val modelFile: String?, @SerializedName("url") val url: String?, @SerializedName("commitHash") val commitHash: String?, @SerializedName("sizeInBytes") val sizeInBytes: Long?, ) /** A model in the model allowlist. */ data class AllowedModel( val name: String, val modelId: String, val modelFile: String, val commitHash: String, val description: String, val sizeInBytes: Long, val defaultConfig: DefaultConfig, val taskTypes: List, val disabled: Boolean? = null, val llmSupportImage: Boolean? = null, val llmSupportAudio: Boolean? = null, val llmSupportTinyGarden: Boolean? = null, val llmSupportMobileActions: Boolean? = null, val minDeviceMemoryInGb: Int? = null, val bestForTaskTypes: List? = null, val localModelFilePathOverride: String? = null, val url: String? = null, val socToModelFiles: Map? = null, val runtimeType: RuntimeType? = null, ) { fun toModel(): Model { // Construct HF download url. var version = commitHash var downloadedFileName = modelFile var downloadUrl = url ?: "https://huggingface.co/$modelId/resolve/$commitHash/$modelFile?download=true" var sizeInBytes = sizeInBytes // Handle per-soc model files. if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) { if (socToModelFiles?.isNotEmpty() == true) { socToModelFiles.get(SOC)?.let { info -> Log.d(TAG, "Found soc-specific model files for model $name: $info") version = info.commitHash ?: "-" downloadedFileName = info.modelFile ?: "-" downloadUrl = info.url ?: "https://huggingface.co/$modelId/resolve/${info.commitHash}/${info.modelFile}?download=true" sizeInBytes = info.sizeInBytes ?: -1 } } } // Config. val isLlmModel = taskTypes.contains(BuiltInTaskId.LLM_CHAT) || taskTypes.contains(BuiltInTaskId.LLM_PROMPT_LAB) || taskTypes.contains(BuiltInTaskId.LLM_ASK_AUDIO) || taskTypes.contains(BuiltInTaskId.LLM_ASK_IMAGE) || taskTypes.contains(BuiltInTaskId.LLM_MOBILE_ACTIONS) || taskTypes.contains(BuiltInTaskId.LLM_TINY_GARDEN) var configs: MutableList = mutableListOf() var llmMaxToken = 1024 var accelerators: List = DEFAULT_ACCELERATORS var visionAccelerator: Accelerator = DEFAULT_VISION_ACCELERATOR if (isLlmModel) { val defaultTopK: Int = defaultConfig.topK ?: DEFAULT_TOPK val defaultTopP: Float = defaultConfig.topP ?: DEFAULT_TOPP val defaultTemperature: Float = defaultConfig.temperature ?: DEFAULT_TEMPERATURE llmMaxToken = defaultConfig.maxTokens ?: 1024 if (defaultConfig.accelerators != null) { val items = defaultConfig.accelerators.split(",") accelerators = mutableListOf() for (item in items) { if (item == "cpu") { accelerators.add(Accelerator.CPU) } else if (item == "gpu") { accelerators.add(Accelerator.GPU) } else if (item == "npu") { accelerators.add(Accelerator.NPU) } } // Remove GPU from pixel 10 devices. if (isPixel10()) { accelerators.remove(Accelerator.GPU) } } if (defaultConfig.visionAccelerator != null) { val accelerator = defaultConfig.visionAccelerator if (accelerator == "cpu") { visionAccelerator = Accelerator.CPU } else if (accelerator == "gpu") { visionAccelerator = Accelerator.GPU } else if (accelerator == "npu") { visionAccelerator = Accelerator.NPU } } val npuOnly = accelerators.size == 1 && accelerators[0] == Accelerator.NPU configs = ( if (npuOnly) { createLlmChatConfigsForNpuModel( defaultMaxToken = llmMaxToken, accelerators = accelerators, ) } else { createLlmChatConfigs( defaultTopK = defaultTopK, defaultTopP = defaultTopP, defaultTemperature = defaultTemperature, defaultMaxToken = llmMaxToken, accelerators = accelerators, ) }) .toMutableList() } var learnMoreUrl = "https://huggingface.co/${modelId}" // Misc. var showBenchmarkButton = true var showRunAgainButton = true if (isLlmModel) { showBenchmarkButton = false showRunAgainButton = false } return Model( name = name, version = version, info = description, url = downloadUrl, sizeInBytes = sizeInBytes, minDeviceMemoryInGb = minDeviceMemoryInGb, configs = configs, downloadFileName = downloadedFileName, showBenchmarkButton = showBenchmarkButton, showRunAgainButton = showRunAgainButton, learnMoreUrl = learnMoreUrl, llmSupportImage = llmSupportImage == true, llmSupportAudio = llmSupportAudio == true, llmSupportTinyGarden = llmSupportTinyGarden == true, llmSupportMobileActions = llmSupportMobileActions == true, llmMaxToken = llmMaxToken, accelerators = accelerators, visionAccelerator = visionAccelerator, bestForTaskIds = bestForTaskTypes ?: listOf(), localModelFilePathOverride = localModelFilePathOverride ?: "", isLlm = isLlmModel, runtimeType = runtimeType ?: RuntimeType.LITERT_LM, ) } override fun toString(): String { return "$modelId/$modelFile" } } /** The model allowlist. */ data class ModelAllowlist(val models: List) ================================================ FILE: Android/src/app/src/main/java/com/google/ai/edge/gallery/data/Tasks.kt ================================================ /* * Copyright 2025 Google LLC * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * http://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. */ package com.google.ai.edge.gallery.data import androidx.annotation.StringRes import androidx.compose.runtime.MutableState import androidx.compose.runtime.mutableLongStateOf import androidx.compose.ui.graphics.vector.ImageVector import com.google.ai.edge.gallery.R /** * Data class for a task displayed on the home screen * * Tasks are grouped into categories (see [category] field), which correspond to the tabs on the * home screen. The tab bar is hidden if only one category exists. Each task can have a list of * associated models (see [Model]], which are shown when the task is selected. * * To register a custom task, see [com.google.ai.edge.gallery.customtasks.common.CustomTask]. */ data class Task( /** * The id of the task. * * The ids in [BuiltInTaskId] are reserved for built-in tasks. */ val id: String, /** The label of the task, for display purpose. */ val label: String, /** * The category of the task. * * We've pre-defined several categories in [Category]. Feel free to create your own category. */ val category: CategoryInfo, /** 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, /** * Description of the task. * * Will be shown at the top of the task screen. */ val description: String, /** * (optional) * * Documentation url for the task. * * Will be shown below the description on the task screen. */ val docUrl: String = "", /** * (optional) * * Source code url for the model-related functions. * * Will be shown below the description on the task screen. */ val sourceCodeUrl: String = "", /** List of models for the task. */ val models: MutableList, /** * List of model names for the task. * * If this field is non-empty, the task will try to find the models with the matching names from * the allowlist */ val modelNames: List = listOf(), /** * Whether to handel model config changes in task's screen itself. The default behavior is to * automatically re-initialize the model. */ val handleModelConfigChangesInTask: Boolean = false, /** Whether the task is experimental. */ val experimental: Boolean = false, /** Whether to use theme color instead of the task tint color. */ val useThemeColor: Boolean = false, /** The default system prompt for this task. */ val defaultSystemPrompt: String = "", // The following fields are only used for built-in tasks. Can ignore if you are creating your own // custom tasks. // /** 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 updateTrigger: MutableState = mutableLongStateOf(0), ) object BuiltInTaskId { const val LLM_CHAT = "llm_chat" const val LLM_PROMPT_LAB = "llm_prompt_lab" const val LLM_ASK_IMAGE = "llm_ask_image" const val LLM_ASK_AUDIO = "llm_ask_audio" const val LLM_MOBILE_ACTIONS = "llm_mobile_actions" const val LLM_TINY_GARDEN = "llm_tiny_garden" const val MP_SCRAPBOOK = "mp_scrapbook" } private val allLegacyTaskIds: MutableSet = mutableSetOf( BuiltInTaskId.LLM_CHAT, BuiltInTaskId.LLM_PROMPT_LAB, BuiltInTaskId.LLM_ASK_IMAGE, BuiltInTaskId.LLM_ASK_AUDIO, ) fun isLegacyTasks(id: String): Boolean { return allLegacyTaskIds.contains(id) } ================================================ FILE: Android/src/app/src/main/java/com/google/ai/edge/gallery/data/Types.kt ================================================ /* * Copyright 2025 Google LLC * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * http://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. */ package com.google.ai.edge.gallery.data enum class Accelerator(val label: String) { CPU(label = "CPU"), GPU(label = "GPU"), NPU(label = "NPU"), } ================================================ FILE: Android/src/app/src/main/java/com/google/ai/edge/gallery/di/AppModule.kt ================================================ /* * Copyright 2025 Google LLC * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * http://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. */ package com.google.ai.edge.gallery.di import android.content.Context import androidx.datastore.core.DataStore import androidx.datastore.core.DataStoreFactory import androidx.datastore.core.Serializer import androidx.datastore.dataStoreFile import com.google.ai.edge.gallery.AppLifecycleProvider import com.google.ai.edge.gallery.BenchmarkResultsSerializer import com.google.ai.edge.gallery.CutoutsSerializer import com.google.ai.edge.gallery.GalleryLifecycleProvider import com.google.ai.edge.gallery.SettingsSerializer import com.google.ai.edge.gallery.UserDataSerializer import com.google.ai.edge.gallery.data.DataStoreRepository import com.google.ai.edge.gallery.data.DefaultDataStoreRepository import com.google.ai.edge.gallery.data.DefaultDownloadRepository import com.google.ai.edge.gallery.data.DownloadRepository import com.google.ai.edge.gallery.proto.BenchmarkResults import com.google.ai.edge.gallery.proto.CutoutCollection import com.google.ai.edge.gallery.proto.Settings import com.google.ai.edge.gallery.proto.UserData import dagger.Module import dagger.Provides import dagger.hilt.InstallIn import dagger.hilt.android.qualifiers.ApplicationContext import dagger.hilt.components.SingletonComponent import javax.inject.Singleton @Module @InstallIn(SingletonComponent::class) internal object AppModule { // Provides the SettingsSerializer @Provides @Singleton fun provideSettingsSerializer(): Serializer { return SettingsSerializer } // Provides the CutoutSerializer @Provides @Singleton fun provideCutoutSerializer(): Serializer { return CutoutsSerializer } // Provides the UserDataSerializer @Provides @Singleton fun provideUserDataSerializer(): Serializer { return UserDataSerializer } // Provides the BenchmarkResultsSerializer @Provides @Singleton fun provideBenchmarkResultsSerializer(): Serializer { return BenchmarkResultsSerializer } // Provides DataStore @Provides @Singleton fun provideSettingsDataStore( @ApplicationContext context: Context, settingsSerializer: Serializer, ): DataStore { return DataStoreFactory.create( serializer = settingsSerializer, produceFile = { context.dataStoreFile("settings.pb") }, ) } // Provides DataStore @Provides @Singleton fun provideCutoutsDataStore( @ApplicationContext context: Context, cutoutsSerializer: Serializer, ): DataStore { return DataStoreFactory.create( serializer = cutoutsSerializer, produceFile = { context.dataStoreFile("cutouts.pb") }, ) } // Provides DataStore @Provides @Singleton fun provideUserDataDataStore( @ApplicationContext context: Context, userDataSerializer: Serializer, ): DataStore { return DataStoreFactory.create( serializer = userDataSerializer, produceFile = { context.dataStoreFile("user_data.pb") }, ) } // Provides DataStore @Provides @Singleton fun provideBenchmarkResultsDataStore( @ApplicationContext context: Context, benchmarkResultsSerializer: Serializer, ): DataStore { return DataStoreFactory.create( serializer = benchmarkResultsSerializer, produceFile = { context.dataStoreFile("benchmark_results.pb") }, ) } // Provides AppLifecycleProvider @Provides @Singleton fun provideAppLifecycleProvider(): AppLifecycleProvider { return GalleryLifecycleProvider() } // Provides DataStoreRepository @Provides @Singleton fun provideDataStoreRepository( dataStore: DataStore, userDataDataStore: DataStore, cutoutsDataStore: DataStore, benchmarkResultsStore: DataStore, ): DataStoreRepository { return DefaultDataStoreRepository( dataStore, userDataDataStore, cutoutsDataStore, benchmarkResultsStore, ) } // Provides DownloadRepository @Provides @Singleton fun provideDownloadRepository( @ApplicationContext context: Context, lifecycleProvider: AppLifecycleProvider, ): DownloadRepository { return DefaultDownloadRepository(context, lifecycleProvider) } } ================================================ FILE: Android/src/app/src/main/java/com/google/ai/edge/gallery/runtime/LlmModelHelper.kt ================================================ /* * Copyright 2026 Google LLC * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * http://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. */ package com.google.ai.edge.gallery.runtime import android.content.Context import android.graphics.Bitmap import com.google.ai.edge.gallery.data.Model import com.google.ai.edge.litertlm.Contents import kotlinx.coroutines.CoroutineScope typealias ResultListener = (partialResult: String, done: Boolean) -> Unit typealias CleanUpListener = () -> Unit /** * Base interface for all LLM runtimes. It defines the foundational operations needed to initialize, * manage conversations, execute inferences, and clean up resources for different Large Language * Model backends. */ interface LlmModelHelper { /** * Initializes the LLM runtime with the specified configuration. * * @param context the application context. * @param model the model to be initialized. * @param supportImage whether to support image input. * @param supportAudio whether to support audio input. * @param onDone callback invoked when initialization is completed successfully. * @param systemInstruction instruction provided to guide the model's behavior. * @param tools tools available for the model to use. * @param enableConversationConstrainedDecoding whether to enable constrained decoding for * conversations. * @param coroutineScope optional coroutine scope for async execution. */ fun initialize( context: Context, model: Model, supportImage: Boolean, supportAudio: Boolean, onDone: (String) -> Unit, systemInstruction: Contents? = null, tools: List = listOf(), enableConversationConstrainedDecoding: Boolean = false, coroutineScope: CoroutineScope? = null, ) /** * Resets the conversation context for the specified model. * * @param model the model whose conversation context needs to be reset. * @param supportImage whether to preserve support for image input. * @param supportAudio whether to preserve support for audio input. * @param systemInstruction new system instruction to guide the model's behavior after reset. * @param tools new or updated tools available for the model. * @param enableConversationConstrainedDecoding whether to enable constrained decoding. */ fun resetConversation( model: Model, supportImage: Boolean = false, supportAudio: Boolean = false, systemInstruction: Contents? = null, tools: List = listOf(), enableConversationConstrainedDecoding: Boolean = false, ) /** * Cleans up resources occupied by the model. * * @param model the model whose resources should be cleaned up. * @param onDone callback invoked when clean up completes. */ fun cleanUp(model: Model, onDone: () -> Unit) /** * Runs an inference pass on the specified model. * * @param model the model to run inference on. * @param input the input text for inference. * @param resultListener callback invoked with partial inference results. * @param cleanUpListener callback invoked to trigger necessary cleanup. * @param onError callback invoked if an error occurs during inference. * @param images optional list of images provided as input context. * @param audioClips optional list of audio clips provided as input context. * @param coroutineScope optional coroutine scope for async inference execution. */ fun runInference( model: Model, input: String, resultListener: ResultListener, cleanUpListener: CleanUpListener, onError: (message: String) -> Unit = {}, images: List = listOf(), audioClips: List = listOf(), coroutineScope: CoroutineScope? = null, ) /** * Stops the ongoing response generation for the model. * * @param model the ongoing model response to be stopped. */ fun stopResponse(model: Model) } ================================================ FILE: Android/src/app/src/main/java/com/google/ai/edge/gallery/runtime/ModelHelperExt.kt ================================================ /* * Copyright 2026 Google LLC * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * http://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. */ package com.google.ai.edge.gallery.runtime import com.google.ai.edge.gallery.data.Model import com.google.ai.edge.gallery.data.RuntimeType import com.google.ai.edge.gallery.ui.llmchat.LlmChatModelHelper val Model.runtimeHelper: LlmModelHelper get() { return LlmChatModelHelper } ================================================ FILE: Android/src/app/src/main/java/com/google/ai/edge/gallery/ui/benchmark/BenchmarkModelPicker.kt ================================================ /* * Copyright 2026 Google LLC * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * http://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. */ package com.google.ai.edge.gallery.ui.benchmark 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.Column import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.sizeIn 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.ArrowDropDown import androidx.compose.material.icons.rounded.CheckCircle 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.draw.alpha import androidx.compose.ui.draw.clip import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp import kotlinx.coroutines.delay import kotlinx.coroutines.launch @OptIn(ExperimentalMaterial3Api::class) @Composable fun BenchmarkModelPicker( selectedModelName: String, modelNames: List, @StringRes titleResId: Int, onSelected: (String) -> Unit, ) { val scope = rememberCoroutineScope() var showBottomSheet by remember { mutableStateOf(false) } val sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true) Row( verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(4.dp), modifier = Modifier.clip(RoundedCornerShape(8.dp)) .clickable { showBottomSheet = true } .background(MaterialTheme.colorScheme.secondaryContainer) .padding(4.dp) .padding(start = 8.dp), ) { Text( selectedModelName, style = MaterialTheme.typography.labelLarge, maxLines = 1, overflow = TextOverflow.MiddleEllipsis, modifier = Modifier.weight(1f, fill = false), ) Icon( Icons.Rounded.ArrowDropDown, modifier = Modifier.size(20.dp).sizeIn(minWidth = 20.dp), contentDescription = null, ) } // Model picker. if (showBottomSheet) { ModalBottomSheet( onDismissRequest = { showBottomSheet = false }, sheetState = sheetState, containerColor = MaterialTheme.colorScheme.surface, ) { Column(modifier = Modifier.fillMaxWidth().padding(bottom = 16.dp)) { Text( stringResource(titleResId), style = MaterialTheme.typography.titleLarge, color = MaterialTheme.colorScheme.onSurface, modifier = Modifier.padding(16.dp), ) LazyColumn { items(modelNames) { modelName -> Row( modifier = Modifier.clickable { onSelected(modelName) scope.launch { delay(200) sheetState.hide() showBottomSheet = false } } .padding(horizontal = 16.dp, vertical = 6.dp) .fillMaxWidth(), verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(16.dp), ) { Icon( Icons.Rounded.CheckCircle, contentDescription = null, tint = MaterialTheme.colorScheme.secondary, modifier = Modifier.alpha(if (modelName == selectedModelName) 1f else 0f), ) Text( modelName, color = MaterialTheme.colorScheme.onSurface, style = MaterialTheme.typography.labelLarge, ) } } } } } } } ================================================ FILE: Android/src/app/src/main/java/com/google/ai/edge/gallery/ui/benchmark/BenchmarkResultsViewer.kt ================================================ /* * Copyright 2026 Google LLC * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * http://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. */ package com.google.ai.edge.gallery.ui.benchmark import android.content.ClipData import androidx.activity.compose.BackHandler import androidx.compose.animation.AnimatedContent import androidx.compose.animation.fadeIn import androidx.compose.animation.fadeOut import androidx.compose.animation.scaleIn import androidx.compose.animation.scaleOut import androidx.compose.animation.slideInVertically import androidx.compose.animation.slideOutVertically import androidx.compose.animation.togetherWith 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.PaddingValues 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.layout.size import androidx.compose.foundation.layout.width import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.itemsIndexed import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material.icons.Icons import androidx.compose.material.icons.automirrored.outlined.HelpOutline import androidx.compose.material.icons.rounded.ArrowDropDown import androidx.compose.material.icons.rounded.Check import androidx.compose.material.icons.rounded.Close import androidx.compose.material.icons.rounded.ContentCopy import androidx.compose.material.icons.rounded.DeleteOutline import androidx.compose.material.icons.rounded.UnfoldLessDouble import androidx.compose.material.icons.rounded.UnfoldMoreDouble import androidx.compose.material3.AlertDialog import androidx.compose.material3.Button import androidx.compose.material3.ButtonDefaults import androidx.compose.material3.CenterAlignedTopAppBar import androidx.compose.material3.CircularProgressIndicator import androidx.compose.material3.DropdownMenu import androidx.compose.material3.DropdownMenuItem import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.FilterChip import androidx.compose.material3.Icon import androidx.compose.material3.IconButton import androidx.compose.material3.MaterialTheme import androidx.compose.material3.ModalBottomSheet import androidx.compose.material3.OutlinedButton import androidx.compose.material3.Scaffold import androidx.compose.material3.Text import androidx.compose.material3.rememberModalBottomSheetState import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateListOf 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.draw.drawBehind import androidx.compose.ui.geometry.Offset import androidx.compose.ui.graphics.Brush import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.PathEffect import androidx.compose.ui.platform.ClipEntry import androidx.compose.ui.platform.LocalClipboard import androidx.compose.ui.platform.LocalResources import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp import com.google.ai.edge.gallery.R import com.google.ai.edge.gallery.proto.LlmBenchmarkResult import com.google.ai.edge.gallery.proto.ValueSeries import com.google.ai.edge.gallery.ui.common.Accordions import com.google.ai.edge.gallery.ui.common.MarkdownText import com.google.ai.edge.gallery.ui.common.SMALL_BUTTON_CONTENT_PADDING import com.google.ai.edge.gallery.ui.modelmanager.ModelManagerViewModel import com.google.ai.edge.gallery.ui.theme.customColors import java.text.SimpleDateFormat import java.util.Date import java.util.Locale import kotlin.math.abs import kotlinx.coroutines.delay import kotlinx.coroutines.launch @OptIn(ExperimentalMaterial3Api::class) @Composable fun BenchmarkResultsViewer( initialModelName: String, modelManagerViewModel: ModelManagerViewModel, viewModel: BenchmarkViewModel, onClose: () -> Unit, ) { val scope = rememberCoroutineScope() val uiState by viewModel.uiState.collectAsState() var showConfirmDeleteDialog by remember { mutableStateOf(false) } var showLazyListPlacementAnimation by remember { mutableStateOf(false) } var showBenchmarkComparisonHelpBottomSheet by remember { mutableStateOf(false) } var benchmarkResultIdToDelete by remember { mutableStateOf("") } val filterableModelNames = remember { mutableStateListOf() } var selectedModelName by remember { mutableStateOf(initialModelName) } val filteredResults = remember { mutableStateListOf() } val strAll = stringResource(R.string.all) val sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true) // Update filterable model names. LaunchedEffect(uiState.results) { filterableModelNames.clear() filterableModelNames.add(strAll) filterableModelNames.addAll( uiState.results.mapNotNull { it.benchmarkResult.llmResult?.baiscInfo?.modelName }.distinct() ) } // Update filteredResults when selected model is changed. LaunchedEffect(selectedModelName, uiState.results) { filteredResults.clear() filteredResults.addAll( uiState.results.filter { selectedModelName == strAll || it.benchmarkResult.llmResult?.baiscInfo?.modelName == selectedModelName } ) } // Reset baseline when model selection is changed. LaunchedEffect(selectedModelName) { viewModel.clearBaseline() } // Show "benchmark comparison help" bottom sheet when there are multiple results available. LaunchedEffect(filteredResults.size) { if ( filteredResults.size > 1 && !viewModel.dataStoreRepository.getHasSeenBenchmarkComparisonHelp() ) { delay(500) showBenchmarkComparisonHelpBottomSheet = true viewModel.dataStoreRepository.setHasSeenBenchmarkComparisonHelp(true) } } // Close it when back button is clicked. BackHandler { if (!uiState.running) { onClose() } } Scaffold( topBar = { CenterAlignedTopAppBar( // Title label. title = { if (!uiState.running) { Column(horizontalAlignment = Alignment.CenterHorizontally) { Text( stringResource(R.string.benchmark_results), style = MaterialTheme.typography.titleMedium, color = MaterialTheme.colorScheme.onSurface, ) BenchmarkModelPicker( selectedModelName = selectedModelName, modelNames = filterableModelNames, titleResId = R.string.select_model, onSelected = { showLazyListPlacementAnimation = true selectedModelName = it scope.launch { delay(500) showLazyListPlacementAnimation = false } }, ) } } }, navigationIcon = { if (filteredResults.size > 1) { IconButton(onClick = { showBenchmarkComparisonHelpBottomSheet = true }) { Icon( Icons.AutoMirrored.Outlined.HelpOutline, contentDescription = stringResource(R.string.cd_help), ) } } else { Spacer(modifier = Modifier.size(48.dp)) } }, // The close button. actions = { if (!uiState.running) { IconButton(onClick = onClose) { Icon(Icons.Rounded.Close, contentDescription = stringResource(R.string.close)) } } }, ) }, modifier = Modifier.fillMaxSize(), ) { innerPadding -> Box(modifier = Modifier.fillMaxSize()) { AnimatedContent( targetState = uiState.running, transitionSpec = { // Running. if (targetState) { scaleIn(initialScale = 0.8f) + fadeIn() togetherWith scaleOut(targetScale = 0.8f) + fadeOut() } // Results. else { slideInVertically { 40 } + fadeIn() togetherWith slideOutVertically { 40 } + fadeOut() } }, ) { running -> // Running in progress. if (running) { Box( modifier = Modifier.fillMaxSize().padding(top = innerPadding.calculateTopPadding()), contentAlignment = Alignment.Center, ) { Column( horizontalAlignment = Alignment.CenterHorizontally, verticalArrangement = Arrangement.Center, modifier = Modifier.fillMaxSize().padding(bottom = innerPadding.calculateBottomPadding()), ) { Column( verticalArrangement = Arrangement.spacedBy(24.dp), horizontalAlignment = Alignment.CenterHorizontally, ) { // Progress spinner. CircularProgressIndicator(strokeWidth = 4.dp, modifier = Modifier.size(36.dp)) // Info text. Text( stringResource(R.string.running_benchmark_msg), style = MaterialTheme.typography.titleMedium, color = MaterialTheme.colorScheme.onSurface, ) // Progress text. Text( "${uiState.completedRunCount} / ${uiState.totalRunCount}", color = MaterialTheme.colorScheme.onSurfaceVariant, style = MaterialTheme.typography.labelLarge, ) } } } } else { Box( modifier = Modifier.fillMaxSize() .padding(top = innerPadding.calculateTopPadding()) .background(MaterialTheme.colorScheme.surfaceContainer), contentAlignment = Alignment.TopCenter, ) { Column(modifier = Modifier.fillMaxWidth()) { // Results. // // Empty state. if (filteredResults.isEmpty()) { Column( verticalArrangement = Arrangement.Center, horizontalAlignment = Alignment.CenterHorizontally, modifier = Modifier.fillMaxSize(), ) { Text( stringResource(R.string.benchmark_no_results), color = MaterialTheme.colorScheme.onSurfaceVariant, style = MaterialTheme.typography.titleMedium, modifier = Modifier.padding(horizontal = 32.dp), textAlign = TextAlign.Center, ) } } else { // List. LazyColumn(modifier = Modifier.fillMaxWidth().padding(horizontal = 16.dp)) { item { Spacer(modifier = Modifier.height(16.dp)) } if (filteredResults.size > 1) { item { Row( verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(8.dp), modifier = Modifier.padding(bottom = 16.dp), ) { OutlinedButton( onClick = { viewModel.expandAll() }, contentPadding = SMALL_BUTTON_CONTENT_PADDING, ) { Icon( Icons.Rounded.UnfoldMoreDouble, contentDescription = null, modifier = Modifier.padding(end = 4.dp).size(16.dp), ) Text(stringResource(R.string.expand_all)) } OutlinedButton( onClick = { viewModel.collapseAll() }, contentPadding = SMALL_BUTTON_CONTENT_PADDING, ) { Icon( Icons.Rounded.UnfoldLessDouble, contentDescription = null, modifier = Modifier.padding(end = 4.dp).size(16.dp), ) Text(stringResource(R.string.collapse_all)) } } } } itemsIndexed(items = filteredResults, key = { index, item -> item.id }) { index, result -> // Result card. var cardModifier = Modifier.clip(RoundedCornerShape(20.dp)).fillMaxWidth() if (showLazyListPlacementAnimation) { cardModifier = cardModifier.animateItem() } result.benchmarkResult.llmResult?.let { llmResult -> val modelName = llmResult.baiscInfo.modelName Accordions( title = "$modelName · ${llmResult.baiscInfo.accelerator}", subtitle = SimpleDateFormat("yyyy-MM-dd HH:mm:ss", Locale.getDefault()) .format(Date(llmResult.baiscInfo.startMs)), boldTitle = true, expanded = result.expanded, onExpandedChange = { viewModel.setExpanded(id = result.id, expanded = it) }, modifier = cardModifier, titleRowAction = { // A chip to toggle on/off baseline, used for set the comparison base. // Only visible when there are >2 results. if (filteredResults.size > 1) { FilterChip( onClick = { viewModel.setBaseline(id = result.id) }, label = { Text( stringResource(R.string.baseline), style = MaterialTheme.typography.labelSmall, ) }, selected = result.id == uiState.baselineResult?.id, leadingIcon = if (result.id == uiState.baselineResult?.id) { { Icon( Icons.Rounded.Check, contentDescription = null, modifier = Modifier.size(16.dp).offset(x = 2.dp), ) } } else { null }, modifier = Modifier.height(24.dp), ) } }, ) { Column( verticalArrangement = Arrangement.spacedBy(8.dp), modifier = Modifier.padding(bottom = 2.dp), ) { // Basic info. Accordions( title = stringResource(R.string.basic_info), bgColor = MaterialTheme.colorScheme.surfaceContainerLow, expanded = result.basicInfoExpanded, onExpandedChange = { viewModel.setBasicInfoExpanded(id = result.id, expanded = it) }, modifier = Modifier.clip(RoundedCornerShape(12.dp)), ) { Column( verticalArrangement = Arrangement.spacedBy(8.dp), modifier = Modifier.padding(start = 6.dp, top = 6.dp, bottom = 4.dp), ) { StatRow(label = "Model", value = llmResult.baiscInfo.modelName) StatRow( label = "Accelerator", value = llmResult.baiscInfo.accelerator, ) StatRow( label = "Prefill tokens", value = "${llmResult.baiscInfo.prefillTokens}", ) StatRow( label = "Decode tokens", value = "${llmResult.baiscInfo.decodeTokens}", ) StatRow( label = "Number of runs", value = "${llmResult.baiscInfo.numberOfRuns}", ) StatRow(label = "App version", value = llmResult.baiscInfo.appVersion) } } // Stats val resources = LocalResources.current Accordions( title = "${stringResource(R.string.results)} (${resources.getQuantityString( R.plurals.runs , llmResult.baiscInfo.numberOfRuns, llmResult.baiscInfo.numberOfRuns, )})", bgColor = MaterialTheme.colorScheme.surfaceContainerLow, expanded = result.statsExpanded, onExpandedChange = { viewModel.setStatsExpanded(id = result.id, expanded = it) }, modifier = Modifier.clip(RoundedCornerShape(12.dp)), titleRowAction = { if ( (result.benchmarkResult.llmResult?.baiscInfo?.numberOfRuns ?: 0) > 1 ) { var showAggregationDropdown by remember { mutableStateOf(false) } // Aggregation method. Box { Row( verticalAlignment = Alignment.CenterVertically, modifier = Modifier.clip(RoundedCornerShape(8.dp)) .clickable { showAggregationDropdown = true } .background( MaterialTheme.colorScheme.surfaceContainerLowest ) .border( width = 1.dp, color = MaterialTheme.colorScheme.outlineVariant, shape = RoundedCornerShape(8.dp), ) .padding(start = 8.dp, end = 0.dp) .height(24.dp), ) { Text( result.aggregation.label, color = MaterialTheme.colorScheme.onSurfaceVariant, style = MaterialTheme.typography.labelMedium, ) Icon( Icons.Rounded.ArrowDropDown, modifier = Modifier.size(20.dp), contentDescription = null, ) } DropdownMenu( expanded = showAggregationDropdown, onDismissRequest = { showAggregationDropdown = false }, ) { for (aggregation in Aggregation.entries) { DropdownMenuItem( text = { Text(aggregation.label) }, onClick = { showAggregationDropdown = false viewModel.setAggregation( id = result.id, aggregation = aggregation, ) }, ) } } } } }, hideTitleRowActionOnCollapse = true, ) { Column( verticalArrangement = Arrangement.spacedBy(8.dp), modifier = Modifier.padding(start = 6.dp, top = 6.dp), ) { val baselineStats = uiState.baselineResult?.benchmarkResult?.llmResult?.stats ValueSeriesRow( label = "Prefill speed", valueSeries = llmResult.stats.prefillSpeed, aggregation = result.aggregation, unit = "tokens/sec", baselineValueSeries = if (result.id != uiState.baselineResult?.id) { baselineStats?.prefillSpeed } else { null }, baselineAggregation = if (result.id != uiState.baselineResult?.id) { uiState.baselineResult?.aggregation } else { null }, ) ValueSeriesRow( label = "Decode speed", valueSeries = llmResult.stats.decodeSpeed, aggregation = result.aggregation, unit = "tokens/sec", baselineValueSeries = if (result.id != uiState.baselineResult?.id) { baselineStats?.decodeSpeed } else { null }, baselineAggregation = if (result.id != uiState.baselineResult?.id) { uiState.baselineResult?.aggregation } else { null }, ) ValueSeriesRow( label = "Time to first token", valueSeries = llmResult.stats.timeToFirstToken, aggregation = result.aggregation, unit = "sec", baselineValueSeries = if (result.id != uiState.baselineResult?.id) { baselineStats?.timeToFirstToken } else { null }, baselineAggregation = if (result.id != uiState.baselineResult?.id) { uiState.baselineResult?.aggregation } else { null }, lessIsBetter = true, ) StatRow( label = "First init time", value = String.format( Locale.getDefault(), "%.2f", llmResult.stats.firstInitTimeMs, ), unit = "ms", baselineValue = if (result.id != uiState.baselineResult?.id) { baselineStats?.firstInitTimeMs } else { null }, lessIsBetter = true, ) if (llmResult.stats.nonFirstInitTimeMs.valueCount > 1) { ValueSeriesRow( label = "Steady init time", valueSeries = llmResult.stats.nonFirstInitTimeMs, aggregation = result.aggregation, unit = "ms", baselineValueSeries = if (result.id != uiState.baselineResult?.id) { baselineStats?.nonFirstInitTimeMs } else { null }, baselineAggregation = if (result.id != uiState.baselineResult?.id) { uiState.baselineResult?.aggregation } else { null }, lessIsBetter = true, ) } } } // Buttons. Row( verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.End, modifier = Modifier.fillMaxWidth(), ) { // Delete. OutlinedButton( onClick = { benchmarkResultIdToDelete = result.id showConfirmDeleteDialog = true }, contentPadding = SMALL_BUTTON_CONTENT_PADDING, ) { Row( verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(4.dp), ) { Icon( Icons.Rounded.DeleteOutline, contentDescription = null, modifier = Modifier.size(20.dp), ) Text(stringResource(R.string.delete)) } } Spacer(modifier = Modifier.width(8.dp)) // Copy val clipboard = LocalClipboard.current Button( onClick = { scope.launch { // Copy csv to clipboard. val csv = getBenchmarkResultCsv( llmResult = llmResult, aggregation = result.aggregation, ) val clipData = ClipData.newPlainText("benchmark results for ${modelName}", csv) val clipEntry = ClipEntry(clipData = clipData) clipboard.setClipEntry(clipEntry = clipEntry) } }, colors = ButtonDefaults.buttonColors( containerColor = MaterialTheme.colorScheme.secondaryContainer ), contentPadding = SMALL_BUTTON_CONTENT_PADDING, ) { Row( verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(4.dp), ) { Icon( Icons.Rounded.ContentCopy, contentDescription = null, modifier = Modifier.size(20.dp), tint = MaterialTheme.colorScheme.onSecondaryContainer, ) Text( stringResource(R.string.copy), color = MaterialTheme.colorScheme.onSecondaryContainer, ) } } } } } } if (index != filteredResults.size - 1) { Spacer(modifier = Modifier.height(12.dp).animateItem(placementSpec = null)) } } item { Spacer(modifier = Modifier.height(innerPadding.calculateBottomPadding())) } } } } // Gradient overlay at the bottom. Box( modifier = Modifier.fillMaxWidth() .height(innerPadding.calculateBottomPadding()) .background( Brush.verticalGradient( colors = listOf(Color.Transparent, MaterialTheme.colorScheme.surfaceContainer) ) ) .align(Alignment.BottomCenter) ) } } } } } if (showConfirmDeleteDialog) { AlertDialog( onDismissRequest = { showConfirmDeleteDialog = false }, title = { Text(stringResource(R.string.delete_benchmark_result_dialog_title)) }, text = { Text(stringResource(R.string.delete_benchmark_result_dialog_content)) }, confirmButton = { Button( onClick = { showLazyListPlacementAnimation = true showConfirmDeleteDialog = false viewModel.deleteBenchmarkResult(id = benchmarkResultIdToDelete) scope.launch { delay(500) showLazyListPlacementAnimation = false } }, contentPadding = SMALL_BUTTON_CONTENT_PADDING, ) { Text(stringResource(R.string.delete)) } }, dismissButton = { OutlinedButton( onClick = { showConfirmDeleteDialog = false }, contentPadding = SMALL_BUTTON_CONTENT_PADDING, ) { Text(stringResource(R.string.cancel)) } }, ) } if (showBenchmarkComparisonHelpBottomSheet) { ModalBottomSheet( onDismissRequest = { showBenchmarkComparisonHelpBottomSheet = false }, sheetState = sheetState, ) { Column( modifier = Modifier.padding(horizontal = 16.dp).padding(bottom = 16.dp), verticalArrangement = Arrangement.spacedBy(16.dp), ) { Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) { Icon(Icons.AutoMirrored.Outlined.HelpOutline, contentDescription = null) Text( stringResource(R.string.benchmark_comparison_help_title), style = MaterialTheme.typography.titleMedium, ) } MarkdownText( text = stringResource(R.string.benchmark_comparison_help_content), smallFontSize = true, ) OutlinedButton( onClick = { scope.launch { sheetState.hide() showBenchmarkComparisonHelpBottomSheet = false } }, contentPadding = SMALL_BUTTON_CONTENT_PADDING, modifier = Modifier.align(alignment = Alignment.End), ) { Text(stringResource(R.string.dismiss)) } } } } } @Composable private fun StatRow( label: String, value: String, modifier: Modifier = Modifier, unit: String = "", baselineValue: Double? = null, lessIsBetter: Boolean = false, ) { Row(modifier = modifier.fillMaxWidth(), verticalAlignment = Alignment.Top) { // label. Text( label, style = MaterialTheme.typography.labelMedium, color = MaterialTheme.colorScheme.onSurface, modifier = Modifier.weight(0.6f), maxLines = 1, overflow = TextOverflow.MiddleEllipsis, ) // Value Column( verticalArrangement = Arrangement.Top, horizontalAlignment = Alignment.Start, modifier = Modifier.weight(0.4f), ) { Row( modifier = Modifier.fillMaxWidth(), verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.SpaceBetween, ) { Text( value, style = MaterialTheme.typography.labelMedium, color = MaterialTheme.colorScheme.onSurface, maxLines = 1, overflow = TextOverflow.MiddleEllipsis, ) AnimatedContent( baselineValue, contentAlignment = Alignment.CenterStart, transitionSpec = { fadeIn() togetherWith fadeOut() }, ) { curBaselineValue -> if (curBaselineValue != null) { val doubleValue = value.toDouble() val pct = (doubleValue - curBaselineValue) / curBaselineValue * 100 val strPct = String.format(Locale.getDefault(), "%.1f", abs(pct)) val sign = if (pct >= 0.0) "+" else "-" val betterSign = if (lessIsBetter) "-" else "+" val color = if (sign == betterSign) { MaterialTheme.customColors.successColor } else { MaterialTheme.customColors.errorTextColor } Text("$sign$strPct%", style = MaterialTheme.typography.labelMedium, color = color) } } } if (unit.isNotEmpty()) { Text( unit, style = MaterialTheme.typography.labelMedium, color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.7f), ) } } } } @Composable private fun ValueSeriesRow( label: String, valueSeries: ValueSeries, aggregation: Aggregation, modifier: Modifier = Modifier, unit: String = "", baselineValueSeries: ValueSeries? = null, baselineAggregation: Aggregation? = null, lessIsBetter: Boolean = false, ) { val value = getAggregationValue(valueSeries = valueSeries, aggregation = aggregation) var baselineValue: Double? = null if (baselineValueSeries != null && baselineAggregation != null) { baselineValue = getAggregationValue(valueSeries = baselineValueSeries, aggregation = baselineAggregation) } var showValueSeriesBottomSheet by remember { mutableStateOf(false) } Row(modifier = modifier.fillMaxWidth(), verticalAlignment = Alignment.Top) { // label. Text( label, style = MaterialTheme.typography.labelMedium, color = MaterialTheme.colorScheme.onSurface, modifier = Modifier.weight(0.6f), maxLines = 1, overflow = TextOverflow.MiddleEllipsis, ) // Value Column( verticalArrangement = Arrangement.Top, horizontalAlignment = Alignment.Start, modifier = Modifier.weight(0.4f), ) { Row( modifier = Modifier.fillMaxWidth(), verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.SpaceBetween, ) { val linkColor = MaterialTheme.customColors.linkColor val isMultipleRuns = valueSeries.valueCount > 1 val textColor = if (isMultipleRuns) linkColor else MaterialTheme.colorScheme.onSurface val textModifier = if (isMultipleRuns) { Modifier.drawBehind { val strokeWidth = 2f val y = size.height - strokeWidth // Define the dash pattern: 8px line, 8px gap val dashPath = PathEffect.dashPathEffect(floatArrayOf(8f, 8f), 0f) drawLine( color = linkColor, start = Offset(0f, y), end = Offset(size.width, y), strokeWidth = strokeWidth, pathEffect = dashPath, ) } .clickable { showValueSeriesBottomSheet = true } } else { Modifier } AnimatedContent(value) { curValue -> Text( String.format(Locale.getDefault(), "%.2f", curValue), style = MaterialTheme.typography.labelMedium, color = textColor, maxLines = 1, overflow = TextOverflow.MiddleEllipsis, modifier = textModifier, ) } AnimatedContent( baselineValue, contentAlignment = Alignment.CenterStart, transitionSpec = { fadeIn() togetherWith fadeOut() }, ) { curBaselineValue -> if (curBaselineValue != null && abs(curBaselineValue) > 1e-6) { val pct = (value - curBaselineValue) / curBaselineValue * 100 val strPct = String.format(Locale.getDefault(), "%.1f", abs(pct)) val sign = if (pct >= 0.0) "+" else "-" val betterSign = if (lessIsBetter) "-" else "+" val color = if (sign == betterSign) { MaterialTheme.customColors.successColor } else { MaterialTheme.customColors.errorTextColor } Text("$sign$strPct%", style = MaterialTheme.typography.labelMedium, color = color) } } } if (unit.isNotEmpty()) { Text( unit, style = MaterialTheme.typography.labelMedium, color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.7f), ) } } } if (showValueSeriesBottomSheet) { BenchmarkValueSeriesViewer( title = "$label ($unit)", valueSeries = valueSeries, onDismiss = { showValueSeriesBottomSheet = false }, ) } } private fun getBenchmarkResultCsv(llmResult: LlmBenchmarkResult, aggregation: Aggregation): String { val basicInfo = llmResult.baiscInfo val stats = llmResult.stats val header = listOf( "start time (ms)", "end time (ms)", "model name", "accelerator", "prefill tokens count", "decode tokens count", "runs count", "app version", "prefill speed (tokens/sec)", "decode speed (tokens/sec)", "time to first token (sec)", "first init time (ms)", "steady init time (ms)", ) .joinToString(",") val data = listOf( basicInfo.startMs, basicInfo.endMs, basicInfo.modelName, basicInfo.accelerator, basicInfo.prefillTokens, basicInfo.decodeTokens, basicInfo.numberOfRuns, basicInfo.appVersion, getAggregationValue(stats.prefillSpeed, aggregation), getAggregationValue(stats.decodeSpeed, aggregation), getAggregationValue(stats.timeToFirstToken, aggregation), stats.firstInitTimeMs, getAggregationValue(stats.nonFirstInitTimeMs, aggregation), ) .joinToString(",") return "$header\n$data" } private fun getAggregationValue(valueSeries: ValueSeries, aggregation: Aggregation): Double { return when (aggregation) { Aggregation.AVG -> valueSeries.avg Aggregation.MEDIAN -> valueSeries.medium // Aggregation.P25 -> valueSeries.pct25 // Aggregation.P75 -> valueSeries.pct75 Aggregation.MIN -> valueSeries.min Aggregation.MAX -> valueSeries.max } } ================================================ FILE: Android/src/app/src/main/java/com/google/ai/edge/gallery/ui/benchmark/BenchmarkScreen.kt ================================================ /* * Copyright 2026 Google LLC * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * http://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. */ package com.google.ai.edge.gallery.ui.benchmark import android.os.Bundle import androidx.compose.animation.AnimatedVisibility import androidx.compose.animation.fadeIn import androidx.compose.animation.fadeOut import androidx.compose.animation.slideInVertically import androidx.compose.animation.slideOutVertically 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.imePadding import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.width import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.verticalScroll import androidx.compose.material.icons.Icons import androidx.compose.material.icons.automirrored.rounded.ArrowBack import androidx.compose.material.icons.automirrored.rounded.List import androidx.compose.material.icons.rounded.BarChart import androidx.compose.material3.AlertDialog import androidx.compose.material3.Button import androidx.compose.material3.CenterAlignedTopAppBar import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.Icon import androidx.compose.material3.IconButton import androidx.compose.material3.MaterialTheme import androidx.compose.material3.OutlinedButton import androidx.compose.material3.Scaffold import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateListOf 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.res.stringResource import androidx.compose.ui.unit.dp import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel import com.google.ai.edge.gallery.GalleryEvent import com.google.ai.edge.gallery.R import com.google.ai.edge.gallery.data.Accelerator import com.google.ai.edge.gallery.data.Config import com.google.ai.edge.gallery.data.ConfigKey import com.google.ai.edge.gallery.data.ConfigKeys import com.google.ai.edge.gallery.data.Model import com.google.ai.edge.gallery.data.NumberSliderConfig import com.google.ai.edge.gallery.data.SegmentedButtonConfig import com.google.ai.edge.gallery.data.ValueType import com.google.ai.edge.gallery.data.convertValueToTargetType import com.google.ai.edge.gallery.firebaseAnalytics import com.google.ai.edge.gallery.ui.common.ConfigEditorsPanel import com.google.ai.edge.gallery.ui.common.SMALL_BUTTON_CONTENT_PADDING import com.google.ai.edge.gallery.ui.modelmanager.ModelManagerViewModel import com.google.ai.edge.gallery.ui.theme.customColors @OptIn(ExperimentalMaterial3Api::class) @Composable fun BenchmarkScreen( initialModel: Model, modelManagerViewModel: ModelManagerViewModel, modifier: Modifier = Modifier, viewModel: BenchmarkViewModel = hiltViewModel(), onBackClicked: () -> Unit, ) { val uiState by viewModel.uiState.collectAsState() var enableBackButton by remember { mutableStateOf(true) } var showRunBenchmarkConfirmationDialog by remember { mutableStateOf(false) } val downloadedLlmModelNames = remember { modelManagerViewModel.getAllDownloadedModels().filter { it.isLlm }.map { it.name } } var selectedModelName by remember { mutableStateOf(initialModel.name) } var selectedModel by remember(selectedModelName) { mutableStateOf(modelManagerViewModel.getModelByName(name = selectedModelName)!!) } val filteredResults = remember { mutableStateListOf() } val configs = remember(selectedModel) { mutableStateListOf().apply { add( SegmentedButtonConfig( key = ConfigKeys.ACCELERATOR, defaultValue = selectedModel.accelerators.getOrNull(0)?.label ?: Accelerator.CPU.label, options = selectedModel.accelerators.map { it.label }, allowMultiple = false, ) ) add( NumberSliderConfig( key = ConfigKeys.PREFILL_TOKENS, sliderMin = 16f, sliderMax = 1024f, defaultValue = 256f, valueType = ValueType.INT, ) ) add( NumberSliderConfig( key = ConfigKeys.DECODE_TOKENS, sliderMin = 16f, sliderMax = 1024f, defaultValue = 256f, valueType = ValueType.INT, ) ) add( NumberSliderConfig( key = ConfigKeys.NUMBER_OF_RUNS, sliderMin = 1f, sliderMax = 10f, defaultValue = 3f, valueType = ValueType.INT, ) ) } } val values: SnapshotStateMap = remember(configs) { mutableStateMapOf().apply { for (config in configs) { put(config.key.label, config.defaultValue) } } } val sumOfPrefillAndDecodeTokens = getIntConfigValue(values = values, key = ConfigKeys.PREFILL_TOKENS) + getIntConfigValue(values = values, key = ConfigKeys.DECODE_TOKENS) val maxToken = selectedModel.llmMaxToken // Update filteredResults when selected model is changed. LaunchedEffect(selectedModelName, uiState.results) { filteredResults.clear() filteredResults.addAll( uiState.results.filter { it.benchmarkResult.llmResult?.baiscInfo?.modelName == selectedModelName } ) } Box(modifier = Modifier.fillMaxSize()) { // Benchmark configs. Scaffold( topBar = { CenterAlignedTopAppBar( // Title icon and label. title = { Column(horizontalAlignment = Alignment.CenterHorizontally) { Text( stringResource(R.string.benchmark_model), style = MaterialTheme.typography.titleMedium, color = MaterialTheme.colorScheme.onSurface, ) BenchmarkModelPicker( selectedModelName = selectedModelName, modelNames = downloadedLlmModelNames, titleResId = R.string.select_downloaded_model, onSelected = { selectedModelName = it }, ) } }, // The back button. navigationIcon = { IconButton(onClick = onBackClicked, enabled = enableBackButton) { Icon( imageVector = Icons.AutoMirrored.Rounded.ArrowBack, contentDescription = stringResource(R.string.cd_navigate_back_icon), ) } }, actions = { Spacer(modifier = Modifier.size(48.dp)) }, ) }, modifier = Modifier.imePadding(), ) { innerPadding -> Box( modifier = Modifier.padding(innerPadding).fillMaxSize(), contentAlignment = Alignment.TopCenter, ) { Column(modifier = Modifier.padding(16.dp).fillMaxSize()) { // Config items. Column( modifier = Modifier.weight(1f).fillMaxWidth().verticalScroll(rememberScrollState()), verticalArrangement = Arrangement.spacedBy(24.dp), ) { ConfigEditorsPanel(configs = configs, values = values) // Info text on the limit of the sum of prefill and decode tokens. Text( stringResource( R.string.benchmark_tokens_limit_message, sumOfPrefillAndDecodeTokens, maxToken, ), style = MaterialTheme.typography.bodyMedium, color = if (sumOfPrefillAndDecodeTokens > maxToken) MaterialTheme.customColors.warningTextColor else MaterialTheme.colorScheme.onSurfaceVariant, ) } // Buttons. Row( verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(8.dp), modifier = Modifier.padding(top = 8.dp).fillMaxWidth(), ) { // View results. OutlinedButton( enabled = filteredResults.isNotEmpty(), onClick = { viewModel.setShowResultsViewer(showResultsViewer = true) firebaseAnalytics?.logEvent( GalleryEvent.BUTTON_CLICKED.id, Bundle().apply { putString("event_type", "view_benchmark_results") putString("model_id", selectedModelName) }, ) }, modifier = Modifier.weight(1f), ) { Icon(Icons.AutoMirrored.Rounded.List, contentDescription = null) Spacer(modifier = Modifier.width(4.dp)) Text(stringResource(R.string.view_results)) } // Run benchmark. Button( enabled = sumOfPrefillAndDecodeTokens <= maxToken, onClick = { modelManagerViewModel.getModelByName(name = selectedModelName)?.let { model -> showRunBenchmarkConfirmationDialog = true } }, modifier = Modifier.weight(1f), ) { Icon(Icons.Rounded.BarChart, contentDescription = null) Spacer(modifier = Modifier.width(4.dp)) Text(stringResource(R.string.benchmark)) } } } } } // Results viewer. AnimatedVisibility( visible = uiState.showResultsViewer, enter = slideInVertically(initialOffsetY = { fullHeight -> fullHeight }) + fadeIn(), exit = slideOutVertically(targetOffsetY = { fullHeight -> fullHeight }) + fadeOut(), ) { BenchmarkResultsViewer( initialModelName = selectedModelName, modelManagerViewModel = modelManagerViewModel, viewModel = viewModel, onClose = { viewModel.setShowResultsViewer(showResultsViewer = false) }, ) } } // Confirmation dialog for running benchmark. if (showRunBenchmarkConfirmationDialog) { AlertDialog( title = { Text(stringResource(R.string.run_benchmark)) }, text = { Text(stringResource(R.string.run_benchmark_confirmation_msg)) }, onDismissRequest = { showRunBenchmarkConfirmationDialog = false }, dismissButton = { OutlinedButton( onClick = { showRunBenchmarkConfirmationDialog = false }, contentPadding = SMALL_BUTTON_CONTENT_PADDING, ) { Text(stringResource(R.string.cancel)) } }, confirmButton = { Button( onClick = { viewModel.runBenchmark( model = selectedModel, accelerator = getStringConfigValue(values = values, key = ConfigKeys.ACCELERATOR), prefillTokens = getIntConfigValue(values = values, key = ConfigKeys.PREFILL_TOKENS), decodeTokens = getIntConfigValue(values = values, key = ConfigKeys.DECODE_TOKENS), runCount = getIntConfigValue(values = values, key = ConfigKeys.NUMBER_OF_RUNS), ) firebaseAnalytics?.logEvent( GalleryEvent.BUTTON_CLICKED.id, Bundle().apply { putString("event_type", "run_benchmark") putString("model_id", selectedModelName) }, ) showRunBenchmarkConfirmationDialog = false }, contentPadding = SMALL_BUTTON_CONTENT_PADDING, ) { Text(stringResource(R.string.continue_button_label)) } }, ) } } private fun getStringConfigValue(values: Map, key: ConfigKey): String { return convertValueToTargetType(value = values.get(key.label) ?: "", valueType = ValueType.STRING) as String } private fun getIntConfigValue(values: Map, key: ConfigKey): Int { return convertValueToTargetType(value = values.get(key.label) ?: 0, valueType = ValueType.INT) as Int } ================================================ FILE: Android/src/app/src/main/java/com/google/ai/edge/gallery/ui/benchmark/BenchmarkValueSeriesViewer.kt ================================================ /* * Copyright 2026 Google LLC * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * http://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. */ package com.google.ai.edge.gallery.ui.benchmark import androidx.compose.foundation.Canvas import androidx.compose.foundation.background 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.height import androidx.compose.foundation.layout.padding import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.text.TextAutoSize import androidx.compose.material3.ExperimentalMaterial3Api 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.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.PathEffect.Companion.dashPathEffect import androidx.compose.ui.graphics.drawscope.Stroke import androidx.compose.ui.input.pointer.PointerEventType import androidx.compose.ui.input.pointer.pointerInput import androidx.compose.ui.res.stringResource import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp import com.google.ai.edge.gallery.R import com.google.ai.edge.gallery.proto.ValueSeries import com.google.ai.edge.gallery.ui.theme.customColors import java.util.Locale @OptIn(ExperimentalMaterial3Api::class) @Composable fun BenchmarkValueSeriesViewer(title: String, valueSeries: ValueSeries, onDismiss: () -> Unit) { val sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true) ModalBottomSheet( onDismissRequest = onDismiss, sheetState = sheetState, containerColor = MaterialTheme.colorScheme.surface, ) { Column( modifier = Modifier.padding(horizontal = 16.dp).padding(bottom = 16.dp), verticalArrangement = Arrangement.spacedBy(12.dp), ) { // Title. Text( title, style = MaterialTheme.typography.titleMedium, color = MaterialTheme.colorScheme.onSurface, ) val values = valueSeries.valueList if (values.isNotEmpty()) { Column(verticalArrangement = Arrangement.spacedBy(4.dp)) { val lineColor = MaterialTheme.colorScheme.outline val dotBgColor = MaterialTheme.colorScheme.surface val dotBorderColor = MaterialTheme.colorScheme.outline val tappedLineColor = MaterialTheme.customColors.linkColor var tappedValue by remember { mutableStateOf(null) } // Value for tapped point. Text( if (tappedValue == null) { stringResource(R.string.tap_to_see_value) } else { "Value: ${String.format(Locale.getDefault(), "%.2f", tappedValue)}" }, style = MaterialTheme.typography.labelMedium, color = MaterialTheme.colorScheme.onSurfaceVariant, ) // Sparkline. val verticalPaddingFactor = 0.2f val min = valueSeries.min val max = valueSeries.max val range = max - min val effectiveMin = min - (range * verticalPaddingFactor) val effectiveMax = max + (range * verticalPaddingFactor) val scaledYRange = effectiveMax - effectiveMin Canvas( modifier = Modifier.clip(RoundedCornerShape(8.dp)) .background(MaterialTheme.colorScheme.surfaceContainer) .fillMaxWidth() .height(80.dp) .pointerInput(values) { awaitPointerEventScope { while (true) { val event = awaitPointerEvent() val position = event.changes.firstOrNull()?.position if (position != null) { // Update on Press and Move events if ( event.type == PointerEventType.Press || event.type == PointerEventType.Move ) { val tappedY = position.y val value = effectiveMin + (1f - (tappedY / size.height)) * scaledYRange tappedValue = value // Consume the event to prevent it from being propagated to parent event.changes.forEach { it.consume() } } } } } } ) { val horizontalPaddingDp = 12.dp val horizontalPaddingPx = horizontalPaddingDp.toPx() val width = size.width - horizontalPaddingPx * 2 val height = size.height val xStep = if (values.size > 1) width / (values.size - 1) else 0f val points = values.mapIndexed { index, value -> val x = index * xStep + horizontalPaddingPx val y = height - ((value - effectiveMin) / scaledYRange) * height Offset(x, y.toFloat()) } // Draw lines connecting the points for (i in 0 until points.size - 1) { drawLine( color = lineColor, start = points[i], end = points[i + 1], strokeWidth = 2.dp.toPx(), ) } // Draw dots for each value val dotRadius = 4.dp.toPx() val dotBorderWidth = 2.dp.toPx() for (offset in points) { // background drawCircle(color = dotBgColor, radius = dotRadius, center = offset) // border drawCircle( color = dotBorderColor, radius = dotRadius, center = offset, style = Stroke(width = dotBorderWidth), ) } // Draw dashed line for tapped value if (tappedValue != null) { val y = size.height - ((tappedValue!! - effectiveMin) / scaledYRange) * size.height val start = Offset(0f, y.toFloat()) val end = Offset(size.width, y.toFloat()) val dashIntervals = floatArrayOf(10f, 10f) // 10px on, 10px off drawLine( color = tappedLineColor, start = start, end = end, strokeWidth = 1.dp.toPx(), pathEffect = dashPathEffect(dashIntervals, 0f), ) } } } } // Stats. Row( verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.SpaceBetween, modifier = Modifier.fillMaxWidth(), ) { StatCell(key = "avg", value = valueSeries.avg) StatCell(key = "median", value = valueSeries.medium) StatCell(key = "min", value = valueSeries.min) StatCell(key = "max", value = valueSeries.max) } } } } @Composable private fun StatCell(key: String, value: Double) { Column() { Text( String.format(Locale.getDefault(), "%.2f", value), style = MaterialTheme.typography.labelMedium, color = MaterialTheme.colorScheme.onSurfaceVariant, maxLines = 1, autoSize = TextAutoSize.StepBased(minFontSize = 8.sp, maxFontSize = 12.sp, stepSize = 1.sp), ) Text( key, style = MaterialTheme.typography.labelSmall, color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.7f), ) } } ================================================ FILE: Android/src/app/src/main/java/com/google/ai/edge/gallery/ui/benchmark/BenchmarkViewModel.kt ================================================ /* * Copyright 2026 Google LLC * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * http://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. */ package com.google.ai.edge.gallery.ui.benchmark import android.content.Context import android.util.Log import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import com.google.ai.edge.gallery.BuildConfig import com.google.ai.edge.gallery.data.DataStoreRepository import com.google.ai.edge.gallery.data.Model import com.google.ai.edge.gallery.proto.BenchmarkResult import com.google.ai.edge.gallery.proto.LlmBenchmarkBasicInfo import com.google.ai.edge.gallery.proto.LlmBenchmarkResult import com.google.ai.edge.gallery.proto.LlmBenchmarkStats import com.google.ai.edge.gallery.proto.ValueSeries import com.google.ai.edge.litertlm.Backend import com.google.ai.edge.litertlm.ExperimentalApi import com.google.ai.edge.litertlm.ExperimentalFlags import com.google.ai.edge.litertlm.benchmark import dagger.hilt.android.lifecycle.HiltViewModel import dagger.hilt.android.qualifiers.ApplicationContext import java.io.File import javax.inject.Inject import kotlin.math.ceil import kotlin.math.floor import kotlin.random.Random import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.update import kotlinx.coroutines.launch private const val TAG = "AGBenchmarkVM" enum class Aggregation(val label: String) { AVG(label = "avg"), MEDIAN(label = "median"), MIN(label = "min"), MAX(label = "max"), // P25(label = "p25"), // P75(label = "p75"), } data class BenchmarkResultInfo( val id: String, val benchmarkResult: BenchmarkResult, val expanded: Boolean = false, val basicInfoExpanded: Boolean = true, val statsExpanded: Boolean = true, val aggregation: Aggregation = Aggregation.AVG, ) data class BenchmarkUiState( val results: List = listOf(), val baselineResult: BenchmarkResultInfo? = null, val showResultsViewer: Boolean = false, val running: Boolean = false, val totalRunCount: Int = 0, val completedRunCount: Int = 0, ) @HiltViewModel class BenchmarkViewModel @Inject constructor( @ApplicationContext private val appContext: Context, val dataStoreRepository: DataStoreRepository, ) : ViewModel() { protected val _uiState = MutableStateFlow(BenchmarkUiState()) val uiState = _uiState.asStateFlow() init { // Load results from storage. val storedResults = dataStoreRepository.getAllBenchmarkResults() Log.d(TAG, "Loaded ${storedResults.size} benchmark results") setBenchmarkResults(results = storedResults) collapseAll() } @OptIn(ExperimentalApi::class) fun runBenchmark( model: Model, accelerator: String, prefillTokens: Int, decodeTokens: Int, runCount: Int, ) { viewModelScope.launch(Dispatchers.Default) { setRunning(running = true) setRunProgress(completedRunCount = 0) setTotalRunCount(totalRunCount = runCount) setShowResultsViewer(showResultsViewer = true) val parts: List = listOf( "- model: ${model.name}", "- accelerator: $accelerator", "- prefill tokens: $prefillTokens", "- decode tokens: $decodeTokens", "- runs: $runCount", ) Log.d(TAG, "Running benchmark: ${parts.joinToString("\n")}") // TODO: handle error. val startMs = System.currentTimeMillis() val prefillSpeeds = mutableListOf() val decodeSpeeds = mutableListOf() val timesToFirstToken = mutableListOf() var firstInitTime = 0.0 val nonFirstInitTimes = mutableListOf() // Create a temporary cache dir to run benchmark in. val timestamp = System.currentTimeMillis() var needCleanUpCacheDir = true val benchmarkCacheDir = File(appContext.cacheDir, "benchmark_$timestamp") var cacheDirPath = benchmarkCacheDir.absolutePath if (!benchmarkCacheDir.mkdirs()) { Log.e(TAG, "Failed to create benchmark cache directory: ${benchmarkCacheDir.absolutePath}") cacheDirPath = appContext.cacheDir.absolutePath needCleanUpCacheDir = false } Log.d(TAG, "Using benchmark cache dir: $cacheDirPath") val backend: Backend = when (accelerator.lowercase()) { "gpu" -> Backend.GPU() "npu" -> Backend.NPU() else -> Backend.CPU() } if (backend is Backend.NPU) { ExperimentalFlags.npuLibrariesDir = appContext.applicationInfo.nativeLibraryDir } val modelPath = model.getPath(context = appContext) for (i in 0 until runCount) { Log.d(TAG, "Start running #$i...") val benchmarkInfo = benchmark( modelPath = modelPath, backend = backend, prefillTokens = prefillTokens, decodeTokens = decodeTokens, cacheDir = cacheDirPath, ) Log.d(TAG, "Done #$i") val initTimeMs = benchmarkInfo.initTimeInSecond * 1000.0 if (i == 0) { firstInitTime = initTimeMs } else { nonFirstInitTimes.add(initTimeMs) } prefillSpeeds.add(benchmarkInfo.lastPrefillTokensPerSecond) decodeSpeeds.add(benchmarkInfo.lastDecodeTokensPerSecond) timesToFirstToken.add(benchmarkInfo.timeToFirstTokenInSecond) // Mark finish for this run. setRunProgress(completedRunCount = i + 1) } val endMs = System.currentTimeMillis() if (needCleanUpCacheDir) { benchmarkCacheDir.deleteRecursively() Log.d(TAG, "Cleaned up benchmark cache dir: ${benchmarkCacheDir.absolutePath}") } // Create and add benchmark result. val basicInfo = LlmBenchmarkBasicInfo.newBuilder() .setStartMs(startMs) .setEndMs(endMs) .setModelName(model.name) .setAccelerator(accelerator) .setPrefillTokens(prefillTokens) .setDecodeTokens(decodeTokens) .setNumberOfRuns(runCount) .setAppVersion(BuildConfig.VERSION_NAME) .build() val stats = LlmBenchmarkStats.newBuilder() .setPrefillSpeed(calculateValueSeries(prefillSpeeds)) .setDecodeSpeed(calculateValueSeries(decodeSpeeds)) .setTimeToFirstToken(calculateValueSeries(timesToFirstToken)) .setFirstInitTimeMs(firstInitTime) .setNonFirstInitTimeMs(calculateValueSeries(nonFirstInitTimes)) .build() val result = BenchmarkResult.newBuilder() .setLlmResult( LlmBenchmarkResult.newBuilder().setBaiscInfo(basicInfo).setStats(stats).build() ) .build() val newId = addBenchmarkResult(result = result) collapseAll() setExpanded(id = newId, expanded = true) setRunning(running = false) } } fun setShowResultsViewer(showResultsViewer: Boolean) { _uiState.update { _uiState.value.copy(showResultsViewer = showResultsViewer) } } fun setRunning(running: Boolean) { _uiState.update { _uiState.value.copy(running = running) } } fun setTotalRunCount(totalRunCount: Int) { _uiState.update { _uiState.value.copy(totalRunCount = totalRunCount) } } fun setRunProgress(completedRunCount: Int) { _uiState.update { _uiState.value.copy(completedRunCount = completedRunCount) } } fun addBenchmarkResult(result: BenchmarkResult): String { val newResults = _uiState.value.results.toMutableList() // Add the new result to the beginning of the list. val newId = "${Random.nextDouble()}" newResults.add( 0, BenchmarkResultInfo( benchmarkResult = result, id = newId, basicInfoExpanded = true, statsExpanded = true, ), ) _uiState.update { _uiState.value.copy(results = newResults) } // Save to storage. dataStoreRepository.addBenchmarkResult(result) return newId } fun setBenchmarkResults(results: List) { _uiState.update { _uiState.value.copy( results = results.map { result -> BenchmarkResultInfo( benchmarkResult = result, expanded = false, id = "${Random.nextDouble()}", basicInfoExpanded = false, statsExpanded = true, ) } ) } } fun deleteBenchmarkResult(id: String) { val newResults = _uiState.value.results.toMutableList() val index = newResults.indexOfFirst { it.id == id } if (index != -1) { val deletedResult = newResults.removeAt(index) _uiState.update { _uiState.value.copy(results = newResults) } if (deletedResult.id == uiState.value.baselineResult?.id) { _uiState.update { _uiState.value.copy(baselineResult = null) } } // Update storage. dataStoreRepository.deleteBenchmarkResult(index = index) } else { Log.w(TAG, "Benchmark result with id $id not found.") } } fun setBaseline(id: String) { if (id == uiState.value.baselineResult?.id) { clearBaseline() } else { val result = _uiState.value.results.firstOrNull { it.id == id } if (result == null) { Log.w(TAG, "Benchmark result with id $id not found.") return } _uiState.update { _uiState.value.copy(baselineResult = result) } } } fun clearBaseline() { _uiState.update { _uiState.value.copy(baselineResult = null) } } fun setExpanded(id: String, expanded: Boolean) { val newResults = _uiState.value.results.toMutableList() val index = newResults.indexOfFirst { it.id == id } if (index != -1) { newResults[index] = newResults[index].copy( expanded = expanded, basicInfoExpanded = expanded, statsExpanded = expanded, ) _uiState.update { _uiState.value.copy(results = newResults) } } else { Log.w(TAG, "Benchmark result with id $id not found.") } } fun setBasicInfoExpanded(id: String, expanded: Boolean) { val newResults = _uiState.value.results.toMutableList() val index = newResults.indexOfFirst { it.id == id } if (index != -1) { newResults[index] = newResults[index].copy(basicInfoExpanded = expanded) _uiState.update { _uiState.value.copy(results = newResults) } } else { Log.w(TAG, "Benchmark result with id $id not found.") } } fun setStatsExpanded(id: String, expanded: Boolean) { val newResults = _uiState.value.results.toMutableList() val index = newResults.indexOfFirst { it.id == id } if (index != -1) { newResults[index] = newResults[index].copy(statsExpanded = expanded) _uiState.update { _uiState.value.copy(results = newResults) } } else { Log.w(TAG, "Benchmark result with id $id not found.") } } fun expandAll() { val newResults = _uiState.value.results.toMutableList() for (i in newResults.indices) { newResults[i] = newResults[i].copy(expanded = true, statsExpanded = true, basicInfoExpanded = true) } _uiState.update { _uiState.value.copy(results = newResults) } } fun collapseAll() { val newResults = _uiState.value.results.toMutableList() for (i in newResults.indices) { newResults[i] = newResults[i].copy(expanded = false, statsExpanded = false, basicInfoExpanded = false) } _uiState.update { _uiState.value.copy(results = newResults) } } fun setAggregation(id: String, aggregation: Aggregation) { val newResults = _uiState.value.results.toMutableList() val index = newResults.indexOfFirst { it.id == id } if (index >= 0) { newResults[index] = newResults[index].copy(aggregation = aggregation) if (uiState.value.baselineResult?.id == newResults[index].id) { _uiState.update { _uiState.value.copy(baselineResult = newResults[index]) } } } _uiState.update { _uiState.value.copy(results = newResults) } } private fun calculateValueSeries(values: List): ValueSeries { if (values.isEmpty()) { return ValueSeries.getDefaultInstance() } val sortedValues = values.sorted() val size = sortedValues.size val min = sortedValues.first() val max = sortedValues.last() val avg = values.average() // Helper function to get the value at a specific percentile (0.0 to 1.0) fun getPercentile(p: Double): Double { if (size == 1) return sortedValues[0] val index = p * (size - 1) val lower = floor(index).toInt() val upper = ceil(index).toInt() if (lower == upper) { return sortedValues[lower] } val weight = index - lower return sortedValues[lower] * (1 - weight) + sortedValues[upper] * weight } val median = getPercentile(0.5) val pct25 = getPercentile(0.25) val pct75 = getPercentile(0.75) return ValueSeries.newBuilder() .addAllValue(values) .setMin(min) .setMax(max) .setAvg(avg) .setMedium(median) // Proto field is named 'medium' .setPct25(pct25) .setPct75(pct75) .build() } } ================================================ FILE: Android/src/app/src/main/java/com/google/ai/edge/gallery/ui/common/Accordions.kt ================================================ /* * Copyright 2026 Google LLC * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * http://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. */ package com.google.ai.edge.gallery.ui.common import androidx.compose.animation.AnimatedVisibility import androidx.compose.animation.expandVertically import androidx.compose.animation.fadeIn import androidx.compose.animation.fadeOut import androidx.compose.animation.shrinkVertically 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.shape.RoundedCornerShape import androidx.compose.material.icons.Icons import androidx.compose.material.icons.automirrored.rounded.ArrowRight import androidx.compose.material.icons.rounded.ArrowDropDown 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.clip import androidx.compose.ui.graphics.Color import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp @Composable fun Accordions( title: String, expanded: Boolean, onExpandedChange: (Boolean) -> Unit, modifier: Modifier = Modifier, subtitle: String = "", boldTitle: Boolean = false, bgColor: Color = MaterialTheme.colorScheme.surface, titleRowAction: @Composable () -> Unit = {}, hideTitleRowActionOnCollapse: Boolean = false, content: @Composable () -> Unit, ) { Column(modifier = modifier.background(bgColor).padding(8.dp)) { // Title. Row( verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(6.dp), modifier = Modifier.clip(RoundedCornerShape(8.dp)) .clickable { onExpandedChange(!expanded) } .fillMaxWidth(), ) { Icon( if (expanded) Icons.Rounded.ArrowDropDown else Icons.AutoMirrored.Rounded.ArrowRight, contentDescription = null, ) Column(modifier = Modifier.weight(1f)) { Text( title, style = MaterialTheme.typography.bodyMedium.copy( fontWeight = if (boldTitle) FontWeight.SemiBold else FontWeight.Normal ), color = MaterialTheme.colorScheme.onSurface, maxLines = 1, overflow = TextOverflow.MiddleEllipsis, ) if (subtitle.isNotEmpty()) { Text( subtitle, style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant, ) } } if (hideTitleRowActionOnCollapse) { AnimatedVisibility(expanded, enter = fadeIn(), exit = fadeOut()) { if (expanded) { titleRowAction() } } } else { titleRowAction() } } // Content. AnimatedVisibility(visible = expanded, enter = expandVertically(), exit = shrinkVertically()) { Box( modifier = Modifier.padding(start = 4.dp).padding(top = 8.dp), contentAlignment = Alignment.TopStart, ) { content() } } } } ================================================ FILE: Android/src/app/src/main/java/com/google/ai/edge/gallery/ui/common/AudioAnimation.kt ================================================ /* * Copyright 2025 Google LLC * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * http://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. */ package com.google.ai.edge.gallery.ui.common import android.graphics.RuntimeShader import android.os.Build import androidx.compose.animation.core.Animatable import androidx.compose.animation.core.tween import androidx.compose.foundation.Canvas import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableDoubleStateOf import androidx.compose.runtime.mutableFloatStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.setValue import androidx.compose.runtime.withFrameMillis import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.ShaderBrush import kotlin.math.pow import kotlin.random.Random private const val SHADER = """ // The size of the render area. uniform float2 iResolution; // The color of the background to render the wave on. uniform vec4 bgColor; // Current timestamp in seconds. uniform float iTime; // The amplitude of the sound to be visualized. // From 0 to 1. uniform float amplitude; // The extra offset for 1d perlin noise. uniform float pOffset; // Creates a gradient that blends four different colors based on a uv coordinate and animated // over time. vec3 mix4(vec3 color1, vec3 color2, vec3 color3, vec3 color4, vec2 uv){ float sinTime1 = sin(iTime / 1.6); float sinTime2 = sin(iTime / 1.8); return mix( mix(color1, color2, smoothstep(0.0 + sinTime1 * 0.1, 0.24 + sinTime1 * 0.1, uv.y)), mix(color3, color4, smoothstep(-0.16 - sinTime2 * 0.1, 0.24 - sinTime2 * 0.1, uv.y)), smoothstep(0.0, 0.7 + sinTime1 * 0.1, uv.x)); } float hash(float i) { float h = i * 127.1; float p = -1. + 2. * fract(sin(h) * 43758.1453123); return p; } float perlin_noise_1d(float d) { float i = floor(d); float f = d - i; float y = f*f*f* (6. * f*f - 15. * f + 10.); float slope1 = hash(i); float slope2 = hash(i + 1.0); float v1 = f; float v2 = f - 1.0; float r = mix(slope1 * v1, slope2 * v2, y); r = r * 0.5 + 0.5; return r; } half4 main(float2 fragCoord) { float2 uv = fragCoord/iResolution.xy; uv.y = 1.0 - uv.y; // Add a wavy distortion to the y-coordinate of the uv. // // Control the amplitude of the wave float wave_strength = 0.036; // Control the speed of the wave float wave_speed = 1.2; // Control the frequency of the wave float wave_frequency = 4.0; // Idle. if (amplitude == 0.) { uv.y += sin(uv.x * wave_frequency + -iTime * wave_speed) * wave_strength; } // Visualizing amplitude by sampling the 1d perlin noise at the given offset. else { uv.y -= perlin_noise_1d(pOffset + uv.x * 3.) * amplitude / 2.0; } vec3 col = mix4( vec3(0.992, 0.875, 0.522), // yellow vec3(0.627, 0.816, 0.686), // green vec3(0.886, 0.372, 0.341), // red vec3(0.522, 0.694, 0.973), // blue uv); // Define the fade parameters float fade_start = 0.24; float fade_end = 0.34; // Calculate the blend factor using smoothstep for a smooth transition float fade_factor = smoothstep(fade_start, fade_end, uv.y); // Blend the base color with background color using the fade factor vec4 final_color = mix(vec4(col, 1.0), bgColor, fade_factor); return vec4(half3(final_color.xyz) * (1 + amplitude * 0.2), final_color.a); } """ /** * This composable function displays a shader-based audio animation. * * It uses a `RuntimeShader` to create a dynamically animated visual effect that responds to an * audio amplitude. The shader renders a gradient with a wavy distortion. It moves slowly when * waiting for recording to start (amplitude is 0), and reacts to amplitude changes by rendering * random "bumps" from 1d perlin noise. */ @Composable fun AudioAnimation(bgColor: Color, amplitude: Int, modifier: Modifier = Modifier) { if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { val shader = remember { RuntimeShader(SHADER) } val shaderBrush = remember { ShaderBrush(shader) } var iTime by remember { mutableFloatStateOf(0f) } var curPOffset by remember { mutableFloatStateOf(0f) } var prevNormalizedAmplitude by remember { mutableDoubleStateOf(0.0) } // Use pow(x, 0.5) to make low amplitude levels more significant. val normalizedAmplitude = (amplitude / 32767.0).pow(0.5) var animatedAmplitude by remember { mutableFloatStateOf(normalizedAmplitude.toFloat()) } // Animate the amplitude value whenever amplitude changes. // This will drive the animation from the current value to the new target value. LaunchedEffect(amplitude) { val animatable = Animatable(initialValue = animatedAmplitude) animatable.animateTo( targetValue = normalizedAmplitude.toFloat(), animationSpec = tween(durationMillis = 100), ) { animatedAmplitude = this.value } } // Updates the iTime uniform for the shader. LaunchedEffect(Unit) { while (true) { withFrameMillis { frameTimeMs -> iTime = frameTimeMs / 1000f } } } // Shader rending. Canvas(modifier = modifier.fillMaxSize()) { // Add a random offset to the Perlin noise whenever the audio amplitude drops from a high // level (0.2 or greater) to a low level (less than 0.2). This makes the noise-driven visual // effect appear to "jump" or reset to a new, random state when the audio becomes quiet, // preventing the visual from settling into a repetitive or static pattern. if (normalizedAmplitude < 0.2 && prevNormalizedAmplitude >= 0.2) { curPOffset = Random.nextFloat() * 1000f } prevNormalizedAmplitude = normalizedAmplitude shader.setFloatUniform("iTime", iTime) shader.setFloatUniform("iResolution", size.width, size.height) shader.setFloatUniform("bgColor", bgColor.red, bgColor.green, bgColor.blue, bgColor.alpha) shader.setFloatUniform("amplitude", animatedAmplitude) shader.setFloatUniform("pOffset", curPOffset) drawRect(brush = shaderBrush) } } } ================================================ FILE: Android/src/app/src/main/java/com/google/ai/edge/gallery/ui/common/ClickableLink.kt ================================================ /* * Copyright 2025 Google LLC * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * http://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. */ package com.google.ai.edge.gallery.ui.common import android.os.Bundle import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size import androidx.compose.material3.Icon import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.vector.ImageVector import androidx.compose.ui.platform.LocalUriHandler import androidx.compose.ui.text.AnnotatedString import androidx.compose.ui.text.LinkAnnotation import androidx.compose.ui.text.SpanStyle import androidx.compose.ui.text.TextLinkStyles import androidx.compose.ui.text.buildAnnotatedString import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.text.style.TextDecoration import androidx.compose.ui.text.withLink import androidx.compose.ui.unit.dp import com.google.ai.edge.gallery.firebaseAnalytics import com.google.ai.edge.gallery.ui.theme.customColors @Composable fun buildTrackableUrlAnnotatedString(url: String, linkText: String): AnnotatedString { val uriHandler = LocalUriHandler.current return buildAnnotatedString { withLink( link = LinkAnnotation.Url( url = url, styles = TextLinkStyles( style = SpanStyle( color = MaterialTheme.customColors.linkColor, textDecoration = TextDecoration.Underline, ) ), linkInteractionListener = { uriHandler.openUri(url) firebaseAnalytics?.logEvent( "resource_link_click", Bundle().apply { putString("link_destination", url) }, ) }, ) ) { append(linkText) } } } @Composable fun ClickableLink( url: String, linkText: String, modifier: Modifier = Modifier, icon: ImageVector? = null, ) { val annotatedText = buildTrackableUrlAnnotatedString(url, linkText) Row( verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.Center, modifier = modifier, ) { if (icon != null) { Icon(icon, contentDescription = null, modifier = Modifier.size(16.dp)) } Text( text = annotatedText, textAlign = TextAlign.Center, style = MaterialTheme.typography.bodyMedium, modifier = Modifier.padding(start = 6.dp), ) } } ================================================ FILE: Android/src/app/src/main/java/com/google/ai/edge/gallery/ui/common/ColorUtils.kt ================================================ /* * Copyright 2025 Google LLC * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * http://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. */ package com.google.ai.edge.gallery.ui.common import androidx.compose.material3.MaterialTheme import androidx.compose.runtime.Composable import androidx.compose.ui.graphics.Color import com.google.ai.edge.gallery.data.Task import com.google.ai.edge.gallery.ui.theme.customColors @Composable fun getTaskBgColor(task: Task): Color { val colorIndex: Int = (task.index.coerceAtLeast(0)) % MaterialTheme.customColors.taskBgColors.size return MaterialTheme.customColors.taskBgColors[colorIndex] } @Composable fun getTaskBgGradientColors(task: Task): List { val colorIndex: Int = (task.index.coerceAtLeast(0)) % MaterialTheme.customColors.taskBgColors.size return MaterialTheme.customColors.taskBgGradientColors[colorIndex] } @Composable fun getTaskIconColor(task: Task): Color { val colorIndex: Int = (task.index.coerceAtLeast(0)) % MaterialTheme.customColors.taskIconColors.size return MaterialTheme.customColors.taskIconColors[colorIndex] } @Composable fun getTaskIconColor(index: Int): Color { val colorIndex: Int = (index.coerceAtLeast(0)) % MaterialTheme.customColors.taskIconColors.size return MaterialTheme.customColors.taskIconColors[colorIndex] } ================================================ FILE: Android/src/app/src/main/java/com/google/ai/edge/gallery/ui/common/ConfigDialog.kt ================================================ /* * Copyright 2025 Google LLC * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * http://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. */ package com.google.ai.edge.gallery.ui.common // import androidx.compose.ui.tooling.preview.Preview // import com.google.ai.edge.gallery.ui.preview.MODEL_TEST1 // import com.google.ai.edge.gallery.ui.theme.GalleryTheme import android.util.Log import androidx.annotation.StringRes import androidx.compose.foundation.border 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.Spacer import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.imePadding import androidx.compose.foundation.layout.offset import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.width import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.items import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.shape.CircleShape import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.text.BasicTextField import androidx.compose.foundation.text.KeyboardActions import androidx.compose.foundation.text.KeyboardOptions import androidx.compose.foundation.verticalScroll import androidx.compose.material.icons.Icons import androidx.compose.material.icons.rounded.ArrowDropDown import androidx.compose.material.icons.rounded.CheckCircle import androidx.compose.material3.Button import androidx.compose.material3.Card import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.Icon import androidx.compose.material3.MaterialTheme import androidx.compose.material3.ModalBottomSheet import androidx.compose.material3.MultiChoiceSegmentedButtonRow import androidx.compose.material3.OutlinedButton import androidx.compose.material3.OutlinedTextField import androidx.compose.material3.PrimaryTabRow import androidx.compose.material3.SegmentedButton import androidx.compose.material3.SegmentedButtonDefaults import androidx.compose.material3.Slider import androidx.compose.material3.Switch import androidx.compose.material3.Tab 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.mutableIntStateOf import androidx.compose.runtime.mutableStateMapOf import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.rememberCoroutineScope 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.draw.alpha import androidx.compose.ui.draw.clip import androidx.compose.ui.focus.FocusRequester import androidx.compose.ui.focus.focusRequester import androidx.compose.ui.focus.onFocusChanged import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.SolidColor import androidx.compose.ui.platform.LocalFocusManager import androidx.compose.ui.res.stringResource import androidx.compose.ui.semantics.semantics import androidx.compose.ui.text.TextStyle import androidx.compose.ui.text.input.KeyboardType import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp import androidx.compose.ui.window.Dialog import com.google.ai.edge.gallery.R import com.google.ai.edge.gallery.data.BooleanSwitchConfig import com.google.ai.edge.gallery.data.BottomSheetSelectorConfig import com.google.ai.edge.gallery.data.BottomSheetSelectorItem import com.google.ai.edge.gallery.data.Config import com.google.ai.edge.gallery.data.LabelConfig import com.google.ai.edge.gallery.data.NumberSliderConfig import com.google.ai.edge.gallery.data.SegmentedButtonConfig import com.google.ai.edge.gallery.data.ValueType import com.google.ai.edge.gallery.ui.theme.labelSmallNarrow import kotlinx.coroutines.delay import kotlinx.coroutines.launch private const val TAG = "AGConfigDialog" private data class Tab(@StringRes val labelResId: Int) private val TABS = listOf( Tab(labelResId = R.string.config_dialog_tab_model_configs), Tab(labelResId = R.string.config_dialog_tab_system_prompt), ) /** * 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: (values: Map, oldSystemPrompt: String, newSystemPrompt: String) -> Unit, okBtnLabel: String = "OK", subtitle: String = "", showCancel: Boolean = true, showSystemPromptEditorTab: Boolean = false, defaultSystemPrompt: String = "", curSystemPrompt: String = "", ) { val values: SnapshotStateMap = remember { mutableStateMapOf().apply { putAll(initialValues) } } val interactionSource = remember { MutableInteractionSource() } var selectedTabIndex by remember { mutableIntStateOf(0) } val savedSystemPrompt = remember { curSystemPrompt } var systemPrompt by remember { mutableStateOf(curSystemPrompt) } Dialog(onDismissRequest = onDismissed) { val focusManager = LocalFocusManager.current Card( modifier = Modifier.fillMaxWidth() .clickable( interactionSource = interactionSource, indication = null, // Disable the ripple effect ) { focusManager.clearFocus() } .imePadding(), 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), ) } } // Tab. if (showSystemPromptEditorTab) { PrimaryTabRow(selectedTabIndex = selectedTabIndex, containerColor = Color.Transparent) { TABS.forEachIndexed { index, tab -> Tab( selected = selectedTabIndex == index, onClick = { selectedTabIndex = index }, text = { Row( verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(4.dp), ) { val titleColor = if (selectedTabIndex == index) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.onSurfaceVariant Text(stringResource(tab.labelResId), color = titleColor) } }, ) } } } if (selectedTabIndex == 0) { // List of config rows. Column( modifier = Modifier.verticalScroll(rememberScrollState()).weight(1f, fill = false), verticalArrangement = Arrangement.spacedBy(16.dp), ) { ConfigEditorsPanel(configs = configs, values = values) } } else if (selectedTabIndex == 1) { OutlinedTextField( value = systemPrompt, modifier = Modifier.weight(1f, fill = false), textStyle = MaterialTheme.typography.bodySmall, onValueChange = { systemPrompt = it }, ) } // Button row. Row( horizontalArrangement = if (showSystemPromptEditorTab && selectedTabIndex == 1) { Arrangement.SpaceBetween } else { Arrangement.End }, verticalAlignment = Alignment.CenterVertically, modifier = Modifier.padding(top = 8.dp), ) { // Restore default button to restore system prompt. if (showSystemPromptEditorTab && selectedTabIndex == 1) { OutlinedButton( onClick = { systemPrompt = defaultSystemPrompt }, contentPadding = SMALL_BUTTON_CONTENT_PADDING, ) { Text(stringResource(R.string.restore_default)) } } Row( modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.End, verticalAlignment = Alignment.CenterVertically, ) { // Cancel button. if (showCancel) { TextButton(onClick = { onDismissed() }) { Text("Cancel") } } // Ok button Button( onClick = { Log.d(TAG, "Values from dialog: $values") onOk(values.toMap(), savedSystemPrompt, systemPrompt) } ) { Text(okBtnLabel) } } } } } } } /** Composable function to display a list of config editor rows. */ @Composable fun ConfigEditorsPanel(configs: List, values: SnapshotStateMap) { for (config in configs) { when (config) { // Label. is LabelConfig -> { LabelRow(config = config, values = values) } // Number slider. is NumberSliderConfig -> { NumberSliderRow(config = config, values = values) } // Boolean switch. is BooleanSwitchConfig -> { BooleanSwitchRow(config = config, values = values) } // Segmented button. is SegmentedButtonConfig -> { SegmentedButtonRow(config = config, values = values) } // Bottom sheet selector. is BottomSheetSelectorConfig -> { BottomSheetSelectorRow(config = config, values = values) } else -> {} } } } @Composable fun LabelRow(config: LabelConfig, values: SnapshotStateMap) { Column(modifier = Modifier.fillMaxWidth()) { // Field label. Text(config.key.label, style = MaterialTheme.typography.titleSmall) // Content label. val label = try { values[config.key.label] as String } catch (e: Exception) { "" } Text(label, style = MaterialTheme.typography.bodyMedium) } } fun getTextFieldDisplayValue(valueType: ValueType, value: Float): String { return try { when (valueType) { ValueType.FLOAT -> { "%.2f".format(value) } ValueType.INT -> { "${value.toInt()}" } else -> { "" } } } catch (e: Exception) { "" } } /** * 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) { val focusManager = LocalFocusManager.current Column(modifier = Modifier.fillMaxWidth().semantics(mergeDescendants = true) {}) { // 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() } // The displaying value for the Text field. It allows hold invalid values that is not a proper // value or out of the slider range, temporary while user is still editing the text. var textFieldDisplayValue by remember { mutableStateOf( getTextFieldDisplayValue(config.valueType, values[config.key.label] as Float) ) } // 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 textFieldDisplayValue = getTextFieldDisplayValue(config.valueType, it) }, ) Spacer(modifier = Modifier.width(8.dp)) // A smaller text field. BasicTextField( value = textFieldDisplayValue, modifier = Modifier.width(80.dp).focusRequester(focusRequester).onFocusChanged { isFocused = it.isFocused // When leaving focus, display the internal value so that any invalid value is cleared. if (!isFocused) { textFieldDisplayValue = getTextFieldDisplayValue(config.valueType, values[config.key.label] as Float) } }, keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number), keyboardActions = KeyboardActions(onDone = { focusManager.clearFocus() }), singleLine = true, onValueChange = { // Always update the display value to reflect the update on the UI. textFieldDisplayValue = it // Only if the new value could be converted to a float, then update the internal value, // bounded by the slider range. It prevents invalid values like NaN from crashing the app. it.toFloatOrNull()?.let { floatValue -> values[config.key.label] = minOf(maxOf(floatValue, config.sliderMin), config.sliderMax) } }, textStyle = TextStyle(color = MaterialTheme.colorScheme.onSurface), cursorBrush = SolidColor(MaterialTheme.colorScheme.onSurface), ) { innerTextField -> Box( modifier = Modifier.border( width = if (isFocused) 2.dp else 1.dp, color = if (isFocused) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.outline, shape = 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().semantics(mergeDescendants = true) {}) { Text(config.key.label, style = MaterialTheme.typography.titleSmall) Switch(checked = switchValue, onCheckedChange = { values[config.key.label] = it }) } } /** * Composable function to display a row with a segmented button. * * This function renders a row containing a label and a segmented button, allowing users to select * one or more options from a list. */ @Composable fun SegmentedButtonRow(config: SegmentedButtonConfig, values: SnapshotStateMap) { val selectedOptions: List = remember { (values[config.key.label] as String).split(",") } var selectionStates: List by remember { mutableStateOf( List(config.options.size) { index -> selectedOptions.contains(config.options[index]) } ) } Column(modifier = Modifier.fillMaxWidth().semantics(mergeDescendants = true) {}) { Text(config.key.label, style = MaterialTheme.typography.titleSmall) MultiChoiceSegmentedButtonRow { config.options.forEachIndexed { index, label -> SegmentedButton( shape = SegmentedButtonDefaults.itemShape(index = index, count = config.options.size), onCheckedChange = { var newSelectionStates = selectionStates.toMutableList() val selectedCount = newSelectionStates.count { it } // Single select. if (!config.allowMultiple) { if (!newSelectionStates[index]) { newSelectionStates = MutableList(config.options.size) { it == index } } } // Multiple select. else { if (!(selectedCount == 1 && newSelectionStates[index])) { newSelectionStates[index] = !newSelectionStates[index] } } selectionStates = newSelectionStates values[config.key.label] = config.options .filterIndexed { index, option -> selectionStates[index] } .joinToString(",") }, checked = selectionStates[index], label = { Text(label) }, ) } } } } /** * Composable function to display a row with a bottom sheet selector. * * This function renders a row containing a label and a button, allowing users to select an option * from a bottom sheet. */ @OptIn(ExperimentalMaterial3Api::class) @Composable fun BottomSheetSelectorRow( config: BottomSheetSelectorConfig, values: SnapshotStateMap, showLabel: Boolean = true, onSelected: (BottomSheetSelectorItem) -> Unit = {}, ) { var selectedOption by remember { mutableStateOf( if (config.options.isEmpty()) { null } else { config.options.find { it.label == config.defaultValue } } ) } var showBottomSheet by remember { mutableStateOf(false) } val sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true) val scope = rememberCoroutineScope() Column( modifier = Modifier.fillMaxWidth().semantics(mergeDescendants = true) {}, verticalArrangement = Arrangement.spacedBy(4.dp), ) { if (showLabel) { Text(config.key.label, style = MaterialTheme.typography.titleSmall) } Row( horizontalArrangement = Arrangement.SpaceBetween, verticalAlignment = Alignment.CenterVertically, modifier = Modifier.height(40.dp) .clip(CircleShape) .clickable { showBottomSheet = true } .border(1.dp, MaterialTheme.colorScheme.outline, CircleShape) .padding(start = 12.dp, end = 8.dp), ) { Text( selectedOption?.label ?: "-", style = MaterialTheme.typography.labelLarge, color = MaterialTheme.colorScheme.onSurface, modifier = Modifier.weight(1f), maxLines = 1, overflow = TextOverflow.MiddleEllipsis, ) Icon( Icons.Rounded.ArrowDropDown, contentDescription = null, tint = MaterialTheme.colorScheme.onSurface, ) } } if (showBottomSheet) { ModalBottomSheet( onDismissRequest = { showBottomSheet = false }, sheetState = sheetState, containerColor = MaterialTheme.colorScheme.surface, ) { Column(modifier = Modifier.fillMaxWidth()) { val titleResId = config.bottomSheetTitleResId if (titleResId != null) { Text( stringResource(titleResId), style = MaterialTheme.typography.titleLarge, color = MaterialTheme.colorScheme.onSurface, modifier = Modifier.padding(16.dp), ) } LazyColumn { items(config.options) { option -> Row( modifier = Modifier.clickable { selectedOption = option values[config.key.label] = option.label onSelected(option) scope.launch { delay(200) sheetState.hide() showBottomSheet = false } } .padding(horizontal = 16.dp, vertical = 12.dp) .fillMaxWidth(), verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(16.dp), ) { Icon( Icons.Rounded.CheckCircle, contentDescription = null, tint = MaterialTheme.colorScheme.secondary, modifier = Modifier.alpha(if (option == selectedOption) 1f else 0f), ) Text( option.label, color = MaterialTheme.colorScheme.onSurface, style = MaterialTheme.typography.labelLarge, ) } } } } } } } ================================================ FILE: Android/src/app/src/main/java/com/google/ai/edge/gallery/ui/common/DownloadAndTryButton.kt ================================================ /* * Copyright 2025 Google LLC * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * http://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. */ package com.google.ai.edge.gallery.ui.common import android.content.Intent 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.animation.core.Animatable import androidx.compose.animation.core.tween import androidx.compose.foundation.background import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.PaddingValues 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.layout.wrapContentHeight import androidx.compose.foundation.shape.CircleShape import androidx.compose.foundation.text.TextAutoSize import androidx.compose.material.icons.Icons import androidx.compose.material.icons.automirrored.rounded.ArrowForward import androidx.compose.material.icons.outlined.Close import androidx.compose.material.icons.outlined.FileDownload import androidx.compose.material.icons.rounded.Error import androidx.compose.material3.AlertDialog import androidx.compose.material3.Button import androidx.compose.material3.ButtonDefaults import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.Icon import androidx.compose.material3.IconButton import androidx.compose.material3.IconButtonDefaults import androidx.compose.material3.LinearProgressIndicator 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.LaunchedEffect 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.graphics.Color import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.res.stringResource import androidx.compose.ui.semantics.contentDescription import androidx.compose.ui.semantics.semantics import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp import androidx.core.net.toUri import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel import com.google.ai.edge.gallery.R import com.google.ai.edge.gallery.data.Model import com.google.ai.edge.gallery.data.ModelDownloadStatus import com.google.ai.edge.gallery.data.ModelDownloadStatusType import com.google.ai.edge.gallery.data.Task import com.google.ai.edge.gallery.ui.common.tos.GemmaTermsOfUseDialog import com.google.ai.edge.gallery.ui.common.tos.TosViewModel import com.google.ai.edge.gallery.ui.modelmanager.ModelManagerViewModel import com.google.ai.edge.gallery.ui.modelmanager.TokenRequestResultType import com.google.ai.edge.gallery.ui.modelmanager.TokenStatus import java.net.HttpURLConnection import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.launch import kotlinx.coroutines.withContext private const val TAG = "AGDownloadAndTryButton" private const val SYSTEM_RESERVED_MEMORY_IN_BYTES = 3 * (1L shl 30) /** * 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( task: Task?, model: Model, enabled: Boolean, downloadStatus: ModelDownloadStatus?, modelManagerViewModel: ModelManagerViewModel, onClicked: () -> Unit, modifier: Modifier = Modifier, tosViewModel: TosViewModel = hiltViewModel(), modifierWhenExpanded: Modifier = Modifier, compact: Boolean = false, canShowTryIt: Boolean = true, ) { val scope = rememberCoroutineScope() val context = LocalContext.current var checkingToken by remember { mutableStateOf(false) } var showAgreementAckSheet by remember { mutableStateOf(false) } var showErrorDialog by remember { mutableStateOf(false) } var showMemoryWarning by remember { mutableStateOf(false) } var showGemmaTermsOfUseDialog by remember { mutableStateOf(false) } var downloadStarted by remember { mutableStateOf(false) } val sheetState = rememberModalBottomSheetState() val needToDownloadFirst = (downloadStatus?.status == ModelDownloadStatusType.NOT_DOWNLOADED || downloadStatus?.status == ModelDownloadStatusType.FAILED) && model.localFileRelativeDirPathOverride.isEmpty() val inProgress = downloadStatus?.status == ModelDownloadStatusType.IN_PROGRESS val downloadSucceeded = downloadStatus?.status == ModelDownloadStatusType.SUCCEEDED val isPartiallyDownloaded = downloadStatus?.status == ModelDownloadStatusType.PARTIALLY_DOWNLOADED val showDownloadProgress = !downloadSucceeded && (downloadStarted || checkingToken || inProgress || isPartiallyDownloaded) var curDownloadProgress: Float // A launcher for requesting notification permission. val permissionLauncher = rememberLauncherForActivityResult(ActivityResultContracts.RequestPermission()) { modelManagerViewModel.downloadModel(task = task, model = model) } // Function to kick off download. val startDownload: (accessToken: String?) -> Unit = { accessToken -> model.accessToken = accessToken checkNotificationPermissionAndStartDownload( context = context, launcher = permissionLauncher, modelManagerViewModel = modelManagerViewModel, task = task, 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 downloadStarted = false } TokenRequestResultType.USER_CANCELLED -> { Log.d(TAG, "User cancelled. Do nothing") checkingToken = false downloadStarted = 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) } // 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. val handleClickButton = { scope.launch(Dispatchers.IO) { if (needToDownloadFirst) { downloadStarted = true // 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", ) val firstResponseCode = modelManagerViewModel.getModelUrlResponse(model = model) if (firstResponseCode == HttpURLConnection.HTTP_OK) { Log.d(TAG, "Model '${model.name}' doesn't need auth. Start downloading the model...") withContext(Dispatchers.Main) { startDownload(null) } return@launch } else if (firstResponseCode < 0) { checkingToken = false downloadStarted = false Log.e(TAG, "Unknown network error") showErrorDialog = true 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) } } } // No need to download. Directly open the model. else { withContext(Dispatchers.Main) { onClicked() } } } } val checkMemoryAndClickDownloadButton = { if (isMemoryLow(context = context, model = model)) { showMemoryWarning = true } else { handleClickButton() } } if (!showDownloadProgress) { var buttonModifier: Modifier = modifier.height(42.dp) if (!compact) { buttonModifier = buttonModifier.then(modifierWhenExpanded) } Button( modifier = buttonModifier, colors = ButtonDefaults.buttonColors( containerColor = if ( (!downloadSucceeded || !canShowTryIt) && model.localFileRelativeDirPathOverride.isEmpty() ) { MaterialTheme.colorScheme.surfaceContainer } else if (task != null) { getTaskBgGradientColors(task = task)[1] } else { MaterialTheme.colorScheme.primary } ), contentPadding = PaddingValues(horizontal = 12.dp), onClick = { if (!enabled || checkingToken) { return@Button } // Check TOS before downloading. if ( model.url.startsWith("https://dl.google.com/google-ai-edge-gallery/") && !tosViewModel.getIsGemmaTermsOfUseAccepted() ) { showGemmaTermsOfUseDialog = true } else { checkMemoryAndClickDownloadButton() } }, ) { val textColor = if (!downloadSucceeded && model.localFileRelativeDirPathOverride.isEmpty()) { MaterialTheme.colorScheme.onSurface } else if (task != null) { Color.White } else { MaterialTheme.colorScheme.onPrimary } Row( verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(8.dp), ) { Icon( if (needToDownloadFirst) Icons.Outlined.FileDownload else Icons.AutoMirrored.Rounded.ArrowForward, contentDescription = null, tint = textColor, ) if (!compact) { if (needToDownloadFirst) { Text( stringResource(R.string.download), color = textColor, style = MaterialTheme.typography.titleMedium, ) } else if (canShowTryIt) { Text( stringResource(R.string.try_it), color = textColor, style = MaterialTheme.typography.titleMedium, maxLines = 1, autoSize = TextAutoSize.StepBased(minFontSize = 8.sp, maxFontSize = 16.sp, stepSize = 1.sp), ) } } } } } // Download progress. else { curDownloadProgress = downloadStatus!!.receivedBytes.toFloat() / downloadStatus.totalBytes.toFloat() if (curDownloadProgress.isNaN()) { curDownloadProgress = 0f } val animatedProgress = remember { Animatable(0f) } var downloadProgressModifier: Modifier = modifier if (!compact) { downloadProgressModifier = downloadProgressModifier.fillMaxWidth() } downloadProgressModifier = downloadProgressModifier .clip(CircleShape) .background(MaterialTheme.colorScheme.surfaceContainer) .padding(horizontal = 8.dp) .height(42.dp) Row(modifier = downloadProgressModifier, verticalAlignment = Alignment.CenterVertically) { if (checkingToken) { Text( stringResource(R.string.checking_access), style = MaterialTheme.typography.bodyMedium, color = MaterialTheme.colorScheme.onSurface, textAlign = TextAlign.Center, modifier = if (!compact) Modifier.fillMaxWidth() else Modifier.padding(horizontal = 4.dp), ) } else { Text( "${(curDownloadProgress * 100).toInt()}%", style = MaterialTheme.typography.bodyMedium, color = MaterialTheme.colorScheme.onSurface, modifier = Modifier.padding(start = 12.dp).width(if (compact) 32.dp else 44.dp), ) if (!compact) { val color = if (task != null) getTaskBgGradientColors(task = task)[1] else MaterialTheme.colorScheme.primary LinearProgressIndicator( modifier = Modifier.weight(1f).padding(horizontal = 4.dp), progress = { animatedProgress.value }, color = color, trackColor = MaterialTheme.colorScheme.surfaceContainerHighest, ) } val cbStop = stringResource(R.string.cd_stop_icon) IconButton( onClick = { downloadStarted = false modelManagerViewModel.cancelDownloadModel(model = model) }, colors = IconButtonDefaults.iconButtonColors( containerColor = MaterialTheme.colorScheme.surfaceContainer ), modifier = Modifier.semantics { contentDescription = cbStop }, ) { Icon( Icons.Outlined.Close, contentDescription = null, tint = MaterialTheme.colorScheme.onSurface, ) } } } LaunchedEffect(curDownloadProgress) { animatedProgress.animateTo(curDownloadProgress, animationSpec = tween(150)) } } // 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(agreementUrl.toUri()) agreementAckLauncher.launch(customTabsIntent.intent) } // Dismiss the sheet. showAgreementAckSheet = false } ) { Text("Open user agreement") } } } } if (showErrorDialog) { AlertDialog( icon = { Icon( Icons.Rounded.Error, contentDescription = stringResource(R.string.cd_error), tint = MaterialTheme.colorScheme.error, ) }, title = { Text("Unknown network error") }, text = { Text("Please check your internet connection.") }, onDismissRequest = { showErrorDialog = false }, confirmButton = { TextButton(onClick = { showErrorDialog = false }) { Text("Close") } }, ) } if (showMemoryWarning) { MemoryWarningAlert( onProceeded = { handleClickButton() showMemoryWarning = false }, onDismissed = { showMemoryWarning = false }, ) } if (showGemmaTermsOfUseDialog) { GemmaTermsOfUseDialog( onTosAccepted = { showGemmaTermsOfUseDialog = false tosViewModel.acceptGemmaTermsOfUse() checkMemoryAndClickDownloadButton() }, onCancel = { showGemmaTermsOfUseDialog = false }, ) } } ================================================ FILE: Android/src/app/src/main/java/com/google/ai/edge/gallery/ui/common/EmptyState.kt ================================================ /* * Copyright 2025 Google LLC * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * http://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. */ package com.google.ai.edge.gallery.ui.common import androidx.annotation.StringRes import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size import androidx.compose.material3.Button import androidx.compose.material3.Icon import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.vector.ImageVector import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.unit.dp data class EmptyStateButtonConfig( @StringRes val buttonLabelResId: Int, val buttonIcon: ImageVector? = null, val onButtonClick: () -> Unit = {}, val extraContent: @Composable () -> Unit = {}, ) /** * A composable function to display an empty state with an icon, title, description, and an optional * button. */ @Composable fun EmptyState( icon: ImageVector, @StringRes titleResId: Int, @StringRes descriptionResId: Int, buttonConfig: EmptyStateButtonConfig? = null, ) { Column( horizontalAlignment = Alignment.CenterHorizontally, verticalArrangement = Arrangement.spacedBy(16.dp), modifier = Modifier.padding(horizontal = 48.dp), ) { Icon( icon, contentDescription = null, modifier = Modifier.size(56.dp), tint = MaterialTheme.colorScheme.onSurfaceVariant, ) Text( stringResource(titleResId), style = MaterialTheme.typography.headlineMedium, color = MaterialTheme.colorScheme.onSurface, textAlign = TextAlign.Center, ) Text( stringResource(descriptionResId), style = MaterialTheme.typography.bodyLarge, color = MaterialTheme.colorScheme.onSurfaceVariant, textAlign = TextAlign.Center, ) if (buttonConfig != null) { Box { Button( contentPadding = SMALL_BUTTON_CONTENT_PADDING, onClick = buttonConfig.onButtonClick, ) { if (buttonConfig.buttonIcon != null) { Icon( buttonConfig.buttonIcon, contentDescription = null, modifier = Modifier.padding(end = 8.dp).size(20.dp), ) } Text(stringResource(buttonConfig.buttonLabelResId)) } buttonConfig.extraContent() } } } } ================================================ FILE: Android/src/app/src/main/java/com/google/ai/edge/gallery/ui/common/ErrorDialog.kt ================================================ /* * Copyright 2025 Google LLC * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * http://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. */ package com.google.ai.edge.gallery.ui.common import androidx.compose.foundation.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.foundation.shape.RoundedCornerShape import androidx.compose.material3.Button import androidx.compose.material3.Card import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.ui.Modifier import androidx.compose.ui.unit.dp import androidx.compose.ui.window.Dialog @Composable fun ErrorDialog(error: String, onDismiss: () -> Unit) { Dialog(onDismissRequest = onDismiss) { Card(modifier = Modifier.fillMaxWidth(), shape = RoundedCornerShape(16.dp)) { Column( modifier = Modifier.padding(20.dp), verticalArrangement = Arrangement.spacedBy(16.dp), ) { // Title Text( "Error", style = MaterialTheme.typography.titleLarge, modifier = Modifier.padding(bottom = 8.dp), ) // Error Text( error, style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.error, ) Row(modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.End) { Button(onClick = onDismiss) { Text("Close") } } } } } } ================================================ FILE: Android/src/app/src/main/java/com/google/ai/edge/gallery/ui/common/GalleryWebView.kt ================================================ /* * Copyright 2025 Google LLC * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * http://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. */ package com.google.ai.edge.gallery.ui.common import android.Manifest import android.content.Context import android.util.Log import android.view.ViewGroup import android.webkit.ConsoleMessage import android.webkit.PermissionRequest import android.webkit.WebChromeClient import android.webkit.WebResourceRequest import android.webkit.WebResourceResponse import android.webkit.WebView import android.webkit.WebViewClient import androidx.activity.compose.rememberLauncherForActivityResult import androidx.activity.result.contract.ActivityResultContracts 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.platform.LocalContext import androidx.compose.ui.viewinterop.AndroidView import androidx.webkit.WebViewAssetLoader import com.google.ai.edge.gallery.common.LOCAL_URL_BASE import java.io.File private const val TAG = "AGGalleryWebView" private val iframeWrapper = """ """ .trimIndent() /** * A base [WebViewClient] for [GalleryWebView] that handles local asset loading and logs page * finishing. */ open class BaseGalleryWebViewClient(private val context: Context) : WebViewClient() { private val localFileAssetsLoader = WebViewAssetLoader.Builder() .addPathHandler("/", WebViewAssetLoader.InternalStoragePathHandler(context, context.filesDir)) .build() override fun shouldInterceptRequest( view: WebView?, request: WebResourceRequest?, ): WebResourceResponse? { if (request?.url != null && request.url.toString().startsWith(LOCAL_URL_BASE)) { // Returns 404 if file not exist. val path = request.url.path ?: "" val localFile = File(context.filesDir, path) if (!localFile.exists() || localFile.isDirectory) { return WebResourceResponse("text/plain", "UTF-8", null) } return localFileAssetsLoader.shouldInterceptRequest(request.url) } return super.shouldInterceptRequest(view, request) } } /** * A reusable Composable that wraps an Android WebView, providing common configurations and handling * for permissions, local asset loading, and JavaScript interfaces. */ @Composable fun GalleryWebView( modifier: Modifier = Modifier, initialUrl: String? = null, useIframeWrapper: Boolean = false, preventParentScrolling: Boolean = false, allowRequestPermission: Boolean = false, onWebViewCreated: ((WebView) -> Unit)? = null, onConsoleMessage: ((ConsoleMessage?) -> Boolean)? = null, onPermissionRequest: ((PermissionRequest?) -> Unit)? = null, customWebViewClient: WebViewClient? = null, ) { val context = LocalContext.current val curWebViewClient = remember { customWebViewClient ?: BaseGalleryWebViewClient(context = context) } var pendingCameraPermissionRequest by remember { mutableStateOf(null) } var pendingAudioPermissionRequest by remember { mutableStateOf(null) } val cameraPermissionLauncher = rememberLauncherForActivityResult(contract = ActivityResultContracts.RequestPermission()) { isGranted: Boolean -> pendingCameraPermissionRequest?.let { request -> if (isGranted) { request.grant(arrayOf(PermissionRequest.RESOURCE_VIDEO_CAPTURE)) } else { // If camera is denied, we don't call request.deny() on the whole request, // as it might contain other resources. The WebView will handle the denial // of the specific camera resource. } pendingCameraPermissionRequest = null } } val audioPermissionLauncher = rememberLauncherForActivityResult(contract = ActivityResultContracts.RequestPermission()) { isGranted: Boolean -> pendingAudioPermissionRequest?.let { request -> if (isGranted) { request.grant(arrayOf(PermissionRequest.RESOURCE_AUDIO_CAPTURE)) } else { // Similar to camera, don't call request.deny() on the whole request. } pendingAudioPermissionRequest = null } } AndroidView( modifier = modifier, factory = { ctx -> WebView(ctx).apply { layoutParams = ViewGroup.LayoutParams( ViewGroup.LayoutParams.MATCH_PARENT, ViewGroup.LayoutParams.MATCH_PARENT, ) settings.apply { javaScriptEnabled = true domStorageEnabled = true allowFileAccess = true mediaPlaybackRequiresUserGesture = false } if (preventParentScrolling) { setOnTouchListener { v, event -> v.parent.requestDisallowInterceptTouchEvent(true) false } } webChromeClient = object : WebChromeClient() { override fun onConsoleMessage(consoleMessage: ConsoleMessage?): Boolean { onConsoleMessage?.invoke(consoleMessage) ?: Log.d( TAG, "${consoleMessage?.message()} -- From line ${consoleMessage?.lineNumber()} of ${consoleMessage?.sourceId()}", ) return super.onConsoleMessage(consoleMessage) } override fun onPermissionRequest(request: PermissionRequest?) { if (!allowRequestPermission) { request?.deny() return } if (request == null) return onPermissionRequest?.invoke(request) ?: run { val resources = request.resources val isCameraRequest = resources.any { it == PermissionRequest.RESOURCE_VIDEO_CAPTURE } val isAudioRequest = resources.any { it == PermissionRequest.RESOURCE_AUDIO_CAPTURE } if (isCameraRequest) { pendingCameraPermissionRequest = request cameraPermissionLauncher.launch(Manifest.permission.CAMERA) } if (isAudioRequest) { pendingAudioPermissionRequest = request audioPermissionLauncher.launch(Manifest.permission.RECORD_AUDIO) } val otherResources = resources .filter { it != PermissionRequest.RESOURCE_VIDEO_CAPTURE && it != PermissionRequest.RESOURCE_AUDIO_CAPTURE } .toTypedArray() if (otherResources.isNotEmpty()) { request.grant(otherResources) } } } } webViewClient = curWebViewClient initialUrl?.let { url -> if (useIframeWrapper) { loadDataWithBaseURL(null, iframeWrapper.replace("___", url), "text/html", "UTF-8", null) } else { loadUrl(url) } } onWebViewCreated?.invoke(this) } }, onRelease = { webView -> webView.stopLoading() webView.destroy() }, ) } ================================================ FILE: Android/src/app/src/main/java/com/google/ai/edge/gallery/ui/common/GlitteringShapesLoader.kt ================================================ /* * Copyright 2025 Google LLC * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * http://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. */ package com.google.ai.edge.gallery.ui.common import androidx.compose.animation.core.LinearEasing import androidx.compose.animation.core.animateFloatAsState import androidx.compose.animation.core.tween import androidx.compose.foundation.Image import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.size import androidx.compose.material3.MaterialTheme import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue import androidx.compose.runtime.key 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.Color import androidx.compose.ui.graphics.ColorFilter import androidx.compose.ui.graphics.graphicsLayer import androidx.compose.ui.graphics.lerp import androidx.compose.ui.layout.ContentScale import androidx.compose.ui.layout.onSizeChanged import androidx.compose.ui.res.painterResource import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.IntSize import androidx.compose.ui.unit.dp import com.google.ai.edge.gallery.R import com.google.ai.edge.gallery.ui.theme.customColors import kotlin.random.Random import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.delay import kotlinx.coroutines.withContext private val SHAPES: List = listOf(R.drawable.circle, R.drawable.double_circle, R.drawable.pantegon, R.drawable.four_circle) data class Shape( val id: Long, val shape: Int, val relativeX: Float, val relativeY: Float, val size: Dp, val color: Color, val addedTs: Long, ) private const val PARTICLE_ANIMATION_DURATION = 300 private const val PARTICLE_ALIVE_MS = 600 private const val PARTICLE_BASE_SIZE = 6 private const val BATCH_SIZE = 5 private const val BATCH_INTERVAL_MS = 300 var curId = 0L @Composable fun GlitteringShapesLoader() { var shapes by remember { mutableStateOf(listOf()) } var boxSize by remember { mutableStateOf(IntSize.Zero) } val taskIconColors = MaterialTheme.customColors.taskIconColors // Use LaunchedEffect to manage the list of shapes LaunchedEffect(Unit) { withContext(Dispatchers.Default) { while (true) { val newShapes = mutableListOf() for (i in 1..BATCH_SIZE) { val shape = Shape( id = curId++, shape = SHAPES[Random.nextInt(SHAPES.size)], relativeX = Random.nextFloat(), relativeY = Random.nextFloat(), size = (PARTICLE_BASE_SIZE + Random.nextInt(-2, 2)).dp, color = taskIconColors[Random.nextInt(taskIconColors.size)], addedTs = System.currentTimeMillis(), ) newShapes.add(shape) } val curTs = System.currentTimeMillis() for (shape in shapes) { if (curTs - shape.addedTs > PARTICLE_ANIMATION_DURATION * 2 + PARTICLE_ALIVE_MS + 100) { continue } newShapes.add(shape) } shapes = newShapes delay(BATCH_INTERVAL_MS.toLong()) } } } Box( modifier = Modifier.fillMaxSize().onSizeChanged { boxSize = it }, contentAlignment = Alignment.TopStart, ) { for (shape in shapes) { key(shape.id) { Particle(shape = shape, boxSize = boxSize) } } } } @Composable private fun Particle(shape: Shape, boxSize: IntSize) { var enterAnimation by remember { mutableStateOf(false) } val enterProgress: Float by animateFloatAsState( if (enterAnimation) 1f else 0f, animationSpec = tween(durationMillis = PARTICLE_ANIMATION_DURATION, easing = LinearEasing), ) val initialDelay = remember { Random.nextLong(50) } LaunchedEffect(Unit) { delay(initialDelay) enterAnimation = true } var exitAnimation by remember { mutableStateOf(false) } val exitProgress: Float by animateFloatAsState( if (exitAnimation) 1f else 0f, animationSpec = tween(durationMillis = PARTICLE_ANIMATION_DURATION, easing = LinearEasing), ) LaunchedEffect(Unit) { delay(initialDelay + PARTICLE_ALIVE_MS.toLong()) exitAnimation = true } val progress = if (exitProgress > 0) (1 - exitProgress) else enterProgress Image( painter = painterResource(shape.shape), contentDescription = null, modifier = Modifier.size(shape.size).graphicsLayer { translationX = boxSize.width * shape.relativeX translationY = boxSize.height * shape.relativeY scaleX = progress scaleY = progress }, colorFilter = ColorFilter.tint(lerp(shape.color, Color.White, 0.95f)), contentScale = ContentScale.Fit, ) } ================================================ FILE: Android/src/app/src/main/java/com/google/ai/edge/gallery/ui/common/LiveCameraView.kt ================================================ /* * Copyright 2025 Google LLC * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * http://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. */ package com.google.ai.edge.gallery.ui.common import android.Manifest import android.content.Context import android.content.pm.PackageManager import android.graphics.Bitmap import android.graphics.Matrix import android.util.Size import androidx.activity.compose.rememberLauncherForActivityResult import androidx.activity.result.contract.ActivityResultContracts import androidx.camera.core.CameraSelector import androidx.camera.core.ImageAnalysis import androidx.camera.core.ImageProxy import androidx.camera.core.resolutionselector.ResolutionSelector import androidx.camera.core.resolutionselector.ResolutionStrategy import androidx.camera.lifecycle.ProcessCameraProvider import androidx.camera.lifecycle.awaitInstance import androidx.compose.foundation.Canvas import androidx.compose.foundation.background import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.runtime.Composable import androidx.compose.runtime.DisposableEffect import androidx.compose.runtime.LaunchedEffect 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.Modifier import androidx.compose.ui.geometry.Rect import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.ImageBitmap import androidx.compose.ui.graphics.asImageBitmap import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.unit.IntOffset import androidx.compose.ui.unit.IntSize import androidx.core.content.ContextCompat import androidx.lifecycle.LifecycleOwner import androidx.lifecycle.compose.LocalLifecycleOwner import java.util.concurrent.Executors import kotlinx.coroutines.launch @Composable fun LiveCameraView( onBitmap: (Bitmap, ImageProxy) -> Unit, modifier: Modifier = Modifier, preferredSize: Int = 500, @ImageAnalysis.OutputImageFormat outputImageFormat: Int = ImageAnalysis.OUTPUT_IMAGE_FORMAT_RGBA_8888, renderPreview: Boolean = true, cameraSelector: CameraSelector = CameraSelector.DEFAULT_FRONT_CAMERA, ) { val context = LocalContext.current val scope = rememberCoroutineScope() val lifecycleOwner = LocalLifecycleOwner.current var imageBitmap by remember { mutableStateOf(null) } var cameraProvider: ProcessCameraProvider? by remember { mutableStateOf(null) } val onBitmapFn: (Bitmap, ImageProxy) -> Unit = { bitmap, imageProxy -> imageBitmap = bitmap.asImageBitmap() onBitmap(bitmap, imageProxy) } val liveCameraPermissionLauncher = rememberLauncherForActivityResult(ActivityResultContracts.RequestPermission()) { permissionGranted -> if (permissionGranted) { scope.launch { cameraProvider = startCamera( context = context, lifecycleOwner = lifecycleOwner, onBitmap = onBitmapFn, preferredSize = preferredSize, outputImageFormat = outputImageFormat, cameraSelector = cameraSelector, ) } } } LaunchedEffect(Unit) { // Check permission. when (PackageManager.PERMISSION_GRANTED) { // Already got permission. Call the lambda. ContextCompat.checkSelfPermission(context, Manifest.permission.CAMERA) -> { cameraProvider = startCamera( context = context, lifecycleOwner = lifecycleOwner, onBitmap = onBitmapFn, preferredSize = preferredSize, outputImageFormat = outputImageFormat, cameraSelector = cameraSelector, ) } // Otherwise, ask for permission else -> { liveCameraPermissionLauncher.launch(Manifest.permission.CAMERA) } } } DisposableEffect(Unit) { onDispose { cameraProvider?.unbindAll() } } // Camera live view. if (renderPreview) { Row(modifier = modifier.background(Color.Red), horizontalArrangement = Arrangement.Center) { val ib = imageBitmap if (ib != null) { Canvas(modifier = Modifier.fillMaxSize()) { val bitmapWidth = ib.width.toFloat() val bitmapHeight = ib.height.toFloat() val canvasWidth = size.width val canvasHeight = size.height // Calculate the scale to fill the canvas while maintaining aspect ratio val scale: Float = if (bitmapWidth / bitmapHeight > canvasWidth / canvasHeight) { canvasHeight / bitmapHeight } else { canvasWidth / bitmapWidth } // Calculate the source rectangle (what to draw from the bitmap) val srcLeft = (bitmapWidth - canvasWidth / scale) / 2 val srcTop = (bitmapHeight - canvasHeight / scale) / 2 val srcRight = srcLeft + canvasWidth / scale val srcBottom = srcTop + canvasHeight / scale val srcRect = Rect(srcLeft, srcTop, srcRight, srcBottom) // The destination rectangle is the entire canvas val dstRect = Rect(0f, 0f, canvasWidth, canvasHeight) // Draw the bitmap with the calculated source and destination rectangles drawImage( image = ib, srcOffset = IntOffset(srcRect.topLeft.x.toInt(), srcRect.topLeft.y.toInt()), srcSize = IntSize(srcRect.width.toInt(), srcRect.height.toInt()), dstOffset = IntOffset(dstRect.topLeft.x.toInt(), dstRect.topLeft.y.toInt()), dstSize = IntSize(dstRect.width.toInt(), dstRect.height.toInt()), ) } } } } } /** Asynchronously initializes and starts the camera for image capture and analysis. */ private suspend fun startCamera( context: Context, lifecycleOwner: LifecycleOwner, onBitmap: (Bitmap, ImageProxy) -> Unit, preferredSize: Int, @ImageAnalysis.OutputImageFormat outputImageFormat: Int, cameraSelector: CameraSelector, ): ProcessCameraProvider { val cameraProvider = ProcessCameraProvider.awaitInstance(context) val resolutionSelector = ResolutionSelector.Builder() .setResolutionStrategy( ResolutionStrategy( Size(preferredSize, preferredSize), ResolutionStrategy.FALLBACK_RULE_CLOSEST_LOWER_THEN_HIGHER, ) ) .build() val imageAnalysis = ImageAnalysis.Builder() .setResolutionSelector(resolutionSelector) .setOutputImageFormat(outputImageFormat) .setBackpressureStrategy(ImageAnalysis.STRATEGY_KEEP_ONLY_LATEST) .build() .also { it.setAnalyzer(Executors.newSingleThreadExecutor()) { imageProxy -> var bitmap = imageProxy.toBitmap() val rotation = imageProxy.imageInfo.rotationDegrees val matrix = Matrix() if (rotation != 0) { matrix.postRotate(rotation.toFloat()) } if (cameraSelector == CameraSelector.DEFAULT_FRONT_CAMERA) { matrix.postScale(-1f, 1f) } bitmap = Bitmap.createBitmap(bitmap, 0, 0, bitmap.width, bitmap.height, matrix, true) // The caller is responsible of calling `.close` on imageProxy to mark that the // processing of the current frame is done. onBitmap(bitmap, imageProxy) } } try { cameraProvider.unbindAll() cameraProvider.bindToLifecycle(lifecycleOwner, cameraSelector, imageAnalysis) } catch (exc: Exception) { // todo: Handle exceptions (e.g., camera initialization failure) } return cameraProvider } ================================================ FILE: Android/src/app/src/main/java/com/google/ai/edge/gallery/ui/common/MarkdownText.kt ================================================ /* * Copyright 2025 Google LLC * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * http://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. */ package com.google.ai.edge.gallery.ui.common import androidx.compose.material3.MaterialTheme import androidx.compose.material3.ProvideTextStyle import androidx.compose.runtime.Composable import androidx.compose.runtime.CompositionLocalProvider import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color import androidx.compose.ui.text.SpanStyle import androidx.compose.ui.text.TextLinkStyles import androidx.compose.ui.text.TextStyle import androidx.compose.ui.text.font.FontFamily import androidx.compose.ui.unit.sp import com.google.ai.edge.gallery.ui.theme.customColors 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 import com.halilibo.richtext.ui.string.RichTextStringStyle /** Composable function to display Markdown-formatted text. */ @Composable fun MarkdownText( text: String, modifier: Modifier = Modifier, smallFontSize: Boolean = false, textColor: Color = MaterialTheme.colorScheme.onSurface, linkColor: Color = MaterialTheme.customColors.linkColor, ) { val fontSize = if (smallFontSize) MaterialTheme.typography.bodyMedium.fontSize else MaterialTheme.typography.bodyLarge.fontSize CompositionLocalProvider { ProvideTextStyle( value = TextStyle( fontSize = fontSize, lineHeight = fontSize * if (smallFontSize) 1.4f else 1.5f, color = textColor, letterSpacing = 0.2.sp, ) ) { RichText( modifier = modifier, style = RichTextStyle( codeBlockStyle = CodeBlockStyle( textStyle = TextStyle( fontSize = MaterialTheme.typography.bodySmall.fontSize, fontFamily = FontFamily.Monospace, lineHeight = MaterialTheme.typography.bodySmall.fontSize * 1.4f, ) ), stringStyle = RichTextStringStyle(linkStyle = TextLinkStyles(style = SpanStyle(color = linkColor))), ), ) { Markdown(content = text) } } } } // @Preview(showBackground = true) // @Composable // fun MarkdownTextPreview() { // GalleryTheme { // MarkdownText(text = "*Hello World*\n**Good morning!!**") // } // } ================================================ FILE: Android/src/app/src/main/java/com/google/ai/edge/gallery/ui/common/MemoryWarning.kt ================================================ /* * Copyright 2025 Google LLC * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * http://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. */ package com.google.ai.edge.gallery.ui.common import android.app.ActivityManager import android.content.Context import android.os.Build import android.util.Log import androidx.compose.material3.AlertDialog import androidx.compose.material3.Text import androidx.compose.material3.TextButton import androidx.compose.runtime.Composable import androidx.compose.ui.res.stringResource import com.google.ai.edge.gallery.R import com.google.ai.edge.gallery.data.Model private const val TAG = "AGMemoryWarning" private const val BYTES_IN_GB = 1024f * 1024 * 1024 /** Composable function to display a memory warning alert dialog. */ @Composable fun MemoryWarningAlert(onProceeded: () -> Unit, onDismissed: () -> Unit) { AlertDialog( title = { Text(stringResource(R.string.memory_warning_title)) }, text = { Text(stringResource(R.string.memory_warning_content)) }, onDismissRequest = onDismissed, confirmButton = { TextButton(onClick = onProceeded) { Text(stringResource(R.string.memory_warning_proceed_anyway)) } }, dismissButton = { TextButton(onClick = onDismissed) { Text(stringResource(R.string.cancel)) } }, ) } /** Checks if the device's memory is lower than the required minimum for the given model. */ fun isMemoryLow(context: Context, model: Model): Boolean { val activityManager = context.getSystemService(android.app.Activity.ACTIVITY_SERVICE) as? ActivityManager val minDeviceMemoryInGb = model.minDeviceMemoryInGb return if (activityManager != null && minDeviceMemoryInGb != null) { val memoryInfo = ActivityManager.MemoryInfo() activityManager.getMemoryInfo(memoryInfo) var deviceMemInGb = memoryInfo.totalMem / BYTES_IN_GB // API 34+ uses advertisedMem instead of totalMem for better accuracy. if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.UPSIDE_DOWN_CAKE) { deviceMemInGb = memoryInfo.advertisedMem / BYTES_IN_GB } Log.d( TAG, "Device memory (GB): $deviceMemInGb. " + "Model's required min device memory (GB): $minDeviceMemoryInGb.", ) deviceMemInGb < minDeviceMemoryInGb } else { false } } ================================================ FILE: Android/src/app/src/main/java/com/google/ai/edge/gallery/ui/common/ModelPageAppBar.kt ================================================ /* * Copyright 2025 Google LLC * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * http://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. */ package com.google.ai.edge.gallery.ui.common import androidx.compose.foundation.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.offset import androidx.compose.foundation.layout.size import androidx.compose.foundation.shape.CircleShape import androidx.compose.material.icons.Icons import androidx.compose.material.icons.automirrored.rounded.ArrowBack import androidx.compose.material.icons.rounded.MapsUgc import androidx.compose.material.icons.rounded.Tune 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.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.draw.clip import androidx.compose.ui.graphics.vector.ImageVector import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.res.stringResource import androidx.compose.ui.res.vectorResource import androidx.compose.ui.unit.dp import com.google.ai.edge.gallery.R import com.google.ai.edge.gallery.data.BuiltInTaskId import com.google.ai.edge.gallery.data.ConfigKeys import com.google.ai.edge.gallery.data.Model import com.google.ai.edge.gallery.data.ModelDownloadStatusType import com.google.ai.edge.gallery.data.Task import com.google.ai.edge.gallery.data.convertValueToTargetType import com.google.ai.edge.gallery.ui.modelmanager.ModelInitializationStatusType import com.google.ai.edge.gallery.ui.modelmanager.ModelManagerViewModel @OptIn(ExperimentalMaterial3Api::class) @Composable fun ModelPageAppBar( task: Task, model: Model, modelManagerViewModel: ModelManagerViewModel, onBackClicked: () -> Unit, onModelSelected: (prev: Model, cur: Model) -> Unit, inProgress: Boolean, modelPreparing: Boolean, modifier: Modifier = Modifier, isResettingSession: Boolean = false, onResetSessionClicked: (Model) -> Unit = {}, canShowResetSessionButton: Boolean = false, hideModelSelector: Boolean = false, useThemeColor: Boolean = false, onConfigChanged: (oldConfigValues: Map, newConfigValues: Map) -> Unit = { _, _ -> }, allowEditingSystemPrompt: Boolean = false, curSystemPrompt: String = "", onSystemPromptChanged: (String) -> Unit = {}, ) { var showConfigDialog by remember { mutableStateOf(false) } val modelManagerUiState by modelManagerViewModel.uiState.collectAsState() val context = LocalContext.current val curDownloadStatus = modelManagerUiState.modelDownloadStatus[model.name] val modelInitializationStatus = modelManagerUiState.modelInitializationStatus[model.name] val isModelInitializing = modelInitializationStatus?.status == ModelInitializationStatusType.INITIALIZING val isModelInitialized = modelInitializationStatus?.status == ModelInitializationStatusType.INITIALIZED CenterAlignedTopAppBar( title = { Column( horizontalAlignment = Alignment.CenterHorizontally, verticalArrangement = Arrangement.spacedBy(4.dp), ) { // Task type. Row( verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(10.dp), ) { val tintColor = if (useThemeColor) MaterialTheme.colorScheme.onSurface else getTaskIconColor(task = task) Icon( task.icon ?: ImageVector.vectorResource(task.iconVectorResourceId!!), tint = tintColor, modifier = Modifier.size(24.dp), contentDescription = null, ) Text(task.label, style = MaterialTheme.typography.titleMedium, color = tintColor) } // Model chips pager. if (!hideModelSelector) { val enableModelPickerChip = !isModelInitializing && !inProgress ModelPickerChip( enabled = enableModelPickerChip, task = task, initialModel = model, modelManagerViewModel = modelManagerViewModel, onModelSelected = onModelSelected, ) } } }, modifier = modifier, // The back button. navigationIcon = { val enableBackButton = !isModelInitializing && !inProgress IconButton(onClick = onBackClicked, enabled = enableBackButton) { Icon( imageVector = Icons.AutoMirrored.Rounded.ArrowBack, contentDescription = stringResource(R.string.cd_navigate_back_icon), ) } }, // The config button for the model (if existed). actions = { val downloadSucceeded = curDownloadStatus?.status == ModelDownloadStatusType.SUCCEEDED val showConfigButton = model.configs.isNotEmpty() && downloadSucceeded val showResetSessionButton = canShowResetSessionButton && downloadSucceeded Box(modifier = Modifier.size(42.dp), contentAlignment = Alignment.Center) { var configButtonOffset = 0.dp if (showConfigButton && canShowResetSessionButton) { configButtonOffset = (-40).dp } if (showConfigButton) { val enableConfigButton = !isModelInitializing && !inProgress && isModelInitialized IconButton( onClick = { showConfigDialog = true }, enabled = enableConfigButton, modifier = Modifier.offset(x = configButtonOffset).alpha(if (!enableConfigButton) 0.5f else 1f), ) { Icon( imageVector = Icons.Rounded.Tune, contentDescription = stringResource(R.string.cd_model_settings_icon), tint = MaterialTheme.colorScheme.onSurface, modifier = Modifier.size(20.dp), ) } } if (showResetSessionButton) { if (isResettingSession) { CircularProgressIndicator( trackColor = MaterialTheme.colorScheme.surfaceVariant, strokeWidth = 2.dp, modifier = Modifier.size(16.dp), ) } else { val enableResetButton = !isModelInitializing && !modelPreparing && !inProgress && isModelInitialized IconButton( onClick = { onResetSessionClicked(model) }, enabled = enableResetButton, modifier = Modifier.alpha(if (!enableResetButton) 0.5f else 1f), ) { Box( modifier = Modifier.size(32.dp) .clip(CircleShape) .background(MaterialTheme.colorScheme.surfaceContainer), contentAlignment = Alignment.Center, ) { Icon( imageVector = Icons.Rounded.MapsUgc, contentDescription = stringResource(R.string.cd_reset_session_icon), tint = MaterialTheme.colorScheme.onSurface, modifier = Modifier.size(20.dp), ) } } } } } }, ) // Config dialog. if (showConfigDialog) { // Remove the reset conversation turn count config for non-tiny-garden tasks. // // This may happen when user imports a model with "enable tiny garden" turned on and use the // model in another non-tiny-garden task. val modelConfigs = model.configs.toMutableList() if (task.id != BuiltInTaskId.LLM_TINY_GARDEN) { modelConfigs.removeIf { it.key == ConfigKeys.RESET_CONVERSATION_TURN_COUNT } } ConfigDialog( title = "Configurations", configs = modelConfigs, initialValues = model.configValues, onDismissed = { showConfigDialog = false }, onOk = { curConfigValues, oldSystemPrompt, newSystemPrompt -> // 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 modelConfigs) { 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) { if (newSystemPrompt != oldSystemPrompt) { onSystemPromptChanged(newSystemPrompt) } return@ConfigDialog } // Save the config values to Model. val oldConfigValues = model.configValues model.prevConfigValues = oldConfigValues model.configValues = curConfigValues modelManagerViewModel.updateConfigValuesUpdateTrigger() if (!task.handleModelConfigChangesInTask) { // Force to re-initialize the model with the new configs. if (needReinitialization) { modelManagerViewModel.initializeModel( context = context, task = task, model = model, force = true, onDone = { if (oldSystemPrompt != newSystemPrompt) { onSystemPromptChanged(newSystemPrompt) } }, ) } // Notify. onConfigChanged(oldConfigValues, model.configValues) } }, showSystemPromptEditorTab = allowEditingSystemPrompt, defaultSystemPrompt = task.defaultSystemPrompt, curSystemPrompt = curSystemPrompt, ) } } ================================================ FILE: Android/src/app/src/main/java/com/google/ai/edge/gallery/ui/common/ModelPicker.kt ================================================ /* * Copyright 2025 Google LLC * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * http://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. */ package com.google.ai.edge.gallery.ui.common // import androidx.compose.ui.tooling.preview.Preview // import com.google.ai.edge.gallery.ui.preview.PreviewModelManagerViewModel // import com.google.ai.edge.gallery.ui.preview.TASK_TEST1 // import com.google.ai.edge.gallery.ui.theme.GalleryTheme import androidx.compose.foundation.background import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.Arrangement 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.material.icons.Icons import androidx.compose.material.icons.filled.CheckCircle import androidx.compose.material3.Icon import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Text 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.graphics.Color import androidx.compose.ui.graphics.vector.ImageVector import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.res.stringResource import androidx.compose.ui.res.vectorResource import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp import com.google.ai.edge.gallery.R import com.google.ai.edge.gallery.data.Model import com.google.ai.edge.gallery.data.Task import com.google.ai.edge.gallery.ui.common.modelitem.StatusIcon import com.google.ai.edge.gallery.ui.modelmanager.ModelManagerViewModel import com.google.ai.edge.gallery.ui.theme.labelSmallNarrow @Composable fun ModelPicker( task: Task, modelManagerViewModel: ModelManagerViewModel, onModelSelected: (Model) -> Unit, ) { val modelManagerUiState by modelManagerViewModel.uiState.collectAsState() var showMemoryWarning by remember { mutableStateOf(false) } var modelToPick by remember { mutableStateOf(null) } val context = LocalContext.current Column(modifier = Modifier.padding(bottom = 8.dp)) { // Title Row( modifier = Modifier.padding(horizontal = 16.dp).padding(top = 4.dp, bottom = 4.dp), verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(8.dp), ) { Icon( task.icon ?: ImageVector.vectorResource(task.iconVectorResourceId!!), tint = getTaskIconColor(task = task), modifier = Modifier.size(16.dp), contentDescription = null, ) Text( "${task.label} models", modifier = Modifier.fillMaxWidth(), style = MaterialTheme.typography.titleMedium, color = getTaskIconColor(task = task), ) } // Model list. for (model in task.models) { val selected = model.name == modelManagerUiState.selectedModel.name Row( verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.SpaceBetween, modifier = Modifier.fillMaxWidth() .clickable { // Show memory warning before proceeding. if (isMemoryLow(context = context, model = model)) { modelToPick = model showMemoryWarning = true } else { onModelSelected(model) } } .background( if (selected) MaterialTheme.colorScheme.surfaceContainer else Color.Transparent ) .padding(horizontal = 16.dp, vertical = 8.dp), ) { Spacer(modifier = Modifier.width(24.dp)) Column(modifier = Modifier.weight(1f)) { Text( model.displayName.ifEmpty { model.name }, style = MaterialTheme.typography.bodyMedium, ) Row( horizontalArrangement = Arrangement.spacedBy(4.dp), verticalAlignment = Alignment.CenterVertically, ) { StatusIcon( task = task, model = model, downloadStatus = modelManagerUiState.modelDownloadStatus[model.name], ) Text( if (model.localFileRelativeDirPathOverride.isEmpty()) model.sizeInBytes.humanReadableSize() else "{ext_file_dir}/${model.localFileRelativeDirPathOverride}", color = MaterialTheme.colorScheme.onSurfaceVariant, style = labelSmallNarrow.copy(lineHeight = 10.sp), ) } } if (selected) { Icon( Icons.Filled.CheckCircle, modifier = Modifier.size(16.dp), contentDescription = stringResource(R.string.cd_selected_icon), ) } } } } if (showMemoryWarning) { MemoryWarningAlert( onProceeded = { val curModelToPick = modelToPick if (curModelToPick != null) { onModelSelected(curModelToPick) } showMemoryWarning = false }, onDismissed = { showMemoryWarning = false }, ) } } // @Preview(showBackground = true) // @Composable // fun ModelPickerPreview() { // val context = LocalContext.current // GalleryTheme { // ModelPicker( // task = TASK_TEST1, // modelManagerViewModel = PreviewModelManagerViewModel(context = context), // onModelSelected = {}, // ) // } // } ================================================ FILE: Android/src/app/src/main/java/com/google/ai/edge/gallery/ui/common/ModelPickerChip.kt ================================================ /* * Copyright 2025 Google LLC * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * http://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. */ package com.google.ai.edge.gallery.ui.common import androidx.compose.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.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.layout.size import androidx.compose.foundation.layout.widthIn import androidx.compose.foundation.shape.CircleShape import androidx.compose.material.icons.Icons import androidx.compose.material.icons.rounded.ArrowDropDown import androidx.compose.material3.CircularProgressIndicator 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.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.draw.clip import androidx.compose.ui.graphics.graphicsLayer import androidx.compose.ui.platform.LocalDensity import androidx.compose.ui.platform.LocalWindowInfo import androidx.compose.ui.res.stringResource import androidx.compose.ui.semantics.clearAndSetSemantics import androidx.compose.ui.semantics.contentDescription import androidx.compose.ui.semantics.semantics import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp import com.google.ai.edge.gallery.R import com.google.ai.edge.gallery.data.Model import com.google.ai.edge.gallery.data.Task import com.google.ai.edge.gallery.ui.common.modelitem.StatusIcon import com.google.ai.edge.gallery.ui.modelmanager.ModelInitializationStatusType import com.google.ai.edge.gallery.ui.modelmanager.ModelManagerViewModel @OptIn(ExperimentalMaterial3Api::class) @Composable fun ModelPickerChip( enabled: Boolean, task: Task, initialModel: Model, modelManagerViewModel: ModelManagerViewModel, onModelSelected: (prev: Model, cur: Model) -> Unit, ) { var showModelPicker by remember { mutableStateOf(false) } var modelPickerModel by remember { mutableStateOf(null) } val modelManagerUiState by modelManagerViewModel.uiState.collectAsState() val sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true) val density = LocalDensity.current val windowInfo = LocalWindowInfo.current val screenWidthDp = remember { with(density) { windowInfo.containerSize.width.toDp() } } val modelInitializationStatus = modelManagerUiState.modelInitializationStatus[initialModel.name] Box(modifier = Modifier.fillMaxWidth(), contentAlignment = Alignment.Center) { Row( verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(2.dp), ) { val modelName = initialModel.displayName.ifEmpty { initialModel.name } val cdChangeModel = stringResource(R.string.cd_change_model, modelName) Row( verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(2.dp), modifier = Modifier.clip(CircleShape) .background(MaterialTheme.colorScheme.surfaceContainerHigh) .clickable(enabled = enabled) { modelPickerModel = initialModel showModelPicker = true } .padding(start = 8.dp, end = 2.dp) .padding(vertical = 4.dp) .graphicsLayer { alpha = if (enabled) 1f else 0.6f } .semantics { contentDescription = cdChangeModel }, ) Inner@{ Box(contentAlignment = Alignment.Center, modifier = Modifier.size(21.dp)) { StatusIcon( task = task, model = initialModel, downloadStatus = modelManagerUiState.modelDownloadStatus[initialModel.name], ) this@Inner.AnimatedVisibility( visible = modelInitializationStatus?.status == ModelInitializationStatusType.INITIALIZING, enter = scaleIn() + fadeIn(), exit = scaleOut() + fadeOut(), ) { // Circular progress indicator. CircularProgressIndicator( modifier = Modifier.size(24.dp).alpha(0.5f), strokeWidth = 2.dp, color = MaterialTheme.colorScheme.onSurfaceVariant, ) } } Text( modelName, style = MaterialTheme.typography.labelLarge, modifier = Modifier.padding(start = 4.dp) .widthIn(0.dp, screenWidthDp - 250.dp) .clearAndSetSemantics {}, maxLines = 1, overflow = TextOverflow.MiddleEllipsis, ) Icon( Icons.Rounded.ArrowDropDown, modifier = Modifier.size(20.dp), contentDescription = null, ) } } } // Model picker. val curModelPickerModel = modelPickerModel if (showModelPicker && curModelPickerModel != null) { ModalBottomSheet(onDismissRequest = { showModelPicker = false }, sheetState = sheetState) { ModelPicker( task = task, modelManagerViewModel = modelManagerViewModel, onModelSelected = { selectedModel -> showModelPicker = false val prevSelectedModel = modelManagerUiState.selectedModel onModelSelected(prevSelectedModel, selectedModel) }, ) } } } ================================================ FILE: Android/src/app/src/main/java/com/google/ai/edge/gallery/ui/common/RotationalLoader.kt ================================================ /* * Copyright 2025 Google LLC * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * http://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. */ package com.google.ai.edge.gallery.ui.common import androidx.compose.animation.core.CubicBezierEasing import androidx.compose.animation.core.EaseInOut import androidx.compose.animation.core.RepeatMode import androidx.compose.animation.core.animateFloat import androidx.compose.animation.core.infiniteRepeatable import androidx.compose.animation.core.rememberInfiniteTransition 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.size 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.runtime.Composable import androidx.compose.runtime.getValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.drawWithContent import androidx.compose.ui.graphics.BlendMode import androidx.compose.ui.graphics.Brush.Companion.linearGradient import androidx.compose.ui.graphics.graphicsLayer import androidx.compose.ui.layout.ContentScale import androidx.compose.ui.res.painterResource import androidx.compose.ui.semantics.clearAndSetSemantics import androidx.compose.ui.unit.Dp import com.google.ai.edge.gallery.R import com.google.ai.edge.gallery.ui.theme.customColors private const val GRID_SPACING_FACTOR = 0.1f private const val ICON_SIZE_FACTOR = 0.3f /** * A composable that displays a rotational and scaling animated loader, structured as a 2x2 grid. * * This loader uses two concurrent infinite animations: * 1. **Outer Rotation (rotationZ):** Continuously rotates the entire [LazyVerticalGrid] container * using a custom [CubicBezierEasing] for a distinct non-linear rotation speed. * 2. **Inner Scale (scaleX, scaleY):** Cycles the scale of the individual grid items between 1.0 * and 0.4 using [EaseInOut] easing for a smooth pulsing/breathing effect. */ @Composable fun RotationalLoader(size: Dp) { val infiniteTransition = rememberInfiniteTransition(label = "infinite") val rotationProgress by infiniteTransition.animateFloat( initialValue = 0f, targetValue = 1f, animationSpec = infiniteRepeatable( animation = tween(2000, easing = CubicBezierEasing(0.5f, 0.16f, 0f, 0.71f)), repeatMode = RepeatMode.Restart, ), ) val scaleProgress by infiniteTransition.animateFloat( initialValue = 1f, targetValue = 0.4f, animationSpec = infiniteRepeatable( animation = tween(1000, easing = EaseInOut), repeatMode = RepeatMode.Reverse, ), ) val curRotationZ = 45f + rotationProgress * 360f val curScale = scaleProgress val gridSpacing = size * GRID_SPACING_FACTOR LazyVerticalGrid( columns = GridCells.Fixed(2), horizontalArrangement = Arrangement.spacedBy(gridSpacing), verticalArrangement = Arrangement.spacedBy(gridSpacing), modifier = Modifier.size(size).graphicsLayer { rotationZ = curRotationZ }.clearAndSetSemantics {}, ) { itemsIndexed( listOf( R.drawable.four_circle, R.drawable.circle, R.drawable.double_circle, R.drawable.pantegon, ) ) { index, imageResource -> Box( modifier = Modifier.size((size - gridSpacing) / 2), contentAlignment = when (index) { 0 -> Alignment.BottomEnd 1 -> Alignment.BottomStart 2 -> Alignment.TopEnd 3 -> Alignment.TopStart else -> Alignment.Center }, ) { val colorIndex = when (index) { 0 -> 2 1 -> 1 2 -> 0 else -> 3 } val brush = linearGradient(colors = MaterialTheme.customColors.taskBgGradientColors[colorIndex]) Image( painter = painterResource(id = imageResource), contentDescription = null, modifier = Modifier.size(size * ICON_SIZE_FACTOR) .graphicsLayer { // This is important to make blending mode work. alpha = 0.99f rotationZ = -curRotationZ scaleX = curScale scaleY = curScale } .drawWithContent { drawContent() drawRect(brush = brush, blendMode = BlendMode.SrcIn) }, contentScale = ContentScale.Fit, ) } } } } ================================================ FILE: Android/src/app/src/main/java/com/google/ai/edge/gallery/ui/common/TaskIcon.kt ================================================ /* * Copyright 2025 Google LLC * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * http://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. */ package com.google.ai.edge.gallery.ui.common import androidx.compose.foundation.Image import androidx.compose.foundation.layout.Box 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.runtime.Composable import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.drawWithContent import androidx.compose.ui.draw.scale import androidx.compose.ui.graphics.BlendMode import androidx.compose.ui.graphics.Brush.Companion.linearGradient import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.CompositingStrategy import androidx.compose.ui.graphics.graphicsLayer 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.unit.Dp import androidx.compose.ui.unit.dp import com.google.ai.edge.gallery.R import com.google.ai.edge.gallery.data.Task private val SHAPES: List = listOf( // Ask image. R.drawable.circle, // Audio scribe R.drawable.double_circle, // Prompt lab R.drawable.pantegon, // AI chat, 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, animationProgress: Float = 1f, ) { val revealingBrush = linearGradient( colorStops = arrayOf( (1f + 0.2f) * (1 - animationProgress) - 0.2f to Color.Red, (1f + 0.2f) * (1 - animationProgress) to Color.Transparent, ) ) Box(modifier = modifier.width(width).aspectRatio(1f), contentAlignment = Alignment.Center) { val brush = linearGradient(colors = getTaskBgGradientColors(task = task)) Image( painter = getTaskIconBgShape(task = task), contentDescription = null, modifier = Modifier.fillMaxSize() .graphicsLayer( // This is important to make blending mode work. alpha = 0.99f, compositingStrategy = CompositingStrategy.Offscreen, translationX = 80 * (1 - animationProgress), rotationZ = -180 * (1 - animationProgress), ) .drawWithContent { drawContent() drawRect(brush = brush, blendMode = BlendMode.SrcIn) drawRect(brush = revealingBrush, blendMode = BlendMode.DstOut) }, contentScale = ContentScale.FillHeight, ) var iconAnimationProgress = 0f if (animationProgress >= 0.8) { iconAnimationProgress = (animationProgress - 0.8f) / 0.2f } Icon( task.icon ?: ImageVector.vectorResource(task.iconVectorResourceId!!), tint = Color.White, modifier = Modifier.size(width * 0.55f) .graphicsLayer { alpha = iconAnimationProgress } .scale(iconAnimationProgress), contentDescription = null, ) } } @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) // } // } // } ================================================ FILE: Android/src/app/src/main/java/com/google/ai/edge/gallery/ui/common/Utils.kt ================================================ /* * Copyright 2025 Google LLC * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * http://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. */ package com.google.ai.edge.gallery.ui.common import 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.animation.core.Easing import androidx.compose.animation.core.FastOutSlowInEasing import androidx.compose.animation.core.LinearEasing import androidx.compose.animation.core.animateFloatAsState import androidx.compose.animation.core.tween import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.padding import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect 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.drawWithContent import androidx.compose.ui.graphics.BlendMode import androidx.compose.ui.graphics.Brush.Companion.linearGradient import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.CompositingStrategy import androidx.compose.ui.graphics.graphicsLayer import androidx.compose.ui.text.TextStyle import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.dp import androidx.core.content.ContextCompat import androidx.core.content.FileProvider import com.google.ai.edge.gallery.data.Model import com.google.ai.edge.gallery.data.Task import com.google.ai.edge.gallery.ui.modelmanager.ModelManagerViewModel import java.io.File import kotlin.math.ln import kotlin.math.pow import kotlinx.coroutines.delay private const val TAG = "AGUtils" val SMALL_BUTTON_CONTENT_PADDING = PaddingValues(start = 16.dp, top = 8.dp, end = 16.dp, bottom = 8.dp) /** 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 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.ai.edge.gallery.provider" /* {applicationId}.provider */, tempFile, ) } fun checkNotificationPermissionAndStartDownload( context: Context, launcher: ManagedActivityResultLauncher, modelManagerViewModel: ModelManagerViewModel, task: Task?, model: Model, ) { // Check permission when (PackageManager.PERMISSION_GRANTED) { // Already got permission. Call the lambda. ContextCompat.checkSelfPermission(context, Manifest.permission.POST_NOTIFICATIONS) -> { modelManagerViewModel.downloadModel(task = task, model = model) } // Otherwise, ask for permission else -> { if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { launcher.launch(Manifest.permission.POST_NOTIFICATIONS) } } } } fun ensureValidFileName(fileName: String): String { return fileName.replace(Regex("[^a-zA-Z0-9._-]"), "_") } /** * A composable that animates text appearing to "swipe" into view from left to right. * * This effect is created by animating a linear gradient brush that colors the text, combined with * an alpha animation for fading. The text gradually becomes visible as the gradient moves across * it, revealing the full text by the end of the animation. */ @Composable fun SwipingText( text: String, style: TextStyle, color: Color, modifier: Modifier = Modifier, animationDelay: Long = 0, animationDurationMs: Int = 300, edgeGradientRelativeSize: Float = 1.0f, ) { val progress = rememberDelayedAnimationProgress( initialDelay = animationDelay, animationDurationMs = animationDurationMs, animationLabel = "swiping text", easing = LinearEasing, ) Text( text, style = style.copy( brush = linearGradient( colorStops = arrayOf( (1f + edgeGradientRelativeSize) * progress - edgeGradientRelativeSize to color, (1f + edgeGradientRelativeSize) * progress to Color.Transparent, ) ) ), modifier = modifier.graphicsLayer { alpha = progress }, ) } /** * A composable that animates the revelation of text using a linear gradient mask. * * The text appears to "wipe" into view from left to right, controlled by an animation progress. * This is achieved by drawing a gradient mask over the text that moves horizontally, revealing the * content as the animation progresses. * * The core of the revelation effect relies on `BlendMode.DstOut`. First, the text content * (`drawContent()`) is rendered as the "destination." Then, a rectangle filled with a `maskBrush` * (our linear gradient) is drawn as the "source." `DstOut` works by taking the destination (the * text) and making transparent any parts that overlap with the opaque (non-transparent) regions of * the source (the red part of our mask). As the `maskBrush` animates and slides across the text, * the transparent portion of the mask "reveals" the text, creating the wipe-in effect. */ @Composable fun RevealingText( text: String, style: TextStyle, modifier: Modifier = Modifier, animationDelay: Long = 0, animationDurationMs: Int = 300, edgeGradientRelativeSize: Float = 0.5f, extraTextPadding: Dp = 16.dp, ) { val progress = rememberDelayedAnimationProgress( initialDelay = animationDelay, animationDurationMs = animationDurationMs, animationLabel = "revealing text", ) val maskBrush = linearGradient( colorStops = arrayOf( (1f + edgeGradientRelativeSize) * progress - edgeGradientRelativeSize to Color.Transparent, (1f + edgeGradientRelativeSize) * progress to Color.Red, ) ) Box( modifier = modifier .graphicsLayer(alpha = 0.99f, compositingStrategy = CompositingStrategy.Offscreen) .drawWithContent { drawContent() drawRect(brush = maskBrush, blendMode = BlendMode.DstOut) }, contentAlignment = Alignment.Center, ) { Text(text, style = style, modifier = Modifier.padding(horizontal = extraTextPadding)) } } /** Another version of RevealingText with animationProgress passed in. */ @Composable fun RevealingText( text: String, style: TextStyle, animationProgress: Float, modifier: Modifier = Modifier, textAlign: TextAlign? = null, edgeGradientRelativeSize: Float = 0.5f, ) { val maskBrush = linearGradient( colorStops = arrayOf( (1f + edgeGradientRelativeSize) * animationProgress - edgeGradientRelativeSize to Color.Transparent, (1f + edgeGradientRelativeSize) * animationProgress to Color.Red, ) ) Box( modifier = modifier .graphicsLayer(alpha = 0.99f, compositingStrategy = CompositingStrategy.Offscreen) .drawWithContent { drawContent() drawRect(brush = maskBrush, blendMode = BlendMode.DstOut) }, contentAlignment = Alignment.Center, ) { Text( text, style = style, modifier = modifier.padding(horizontal = 16.dp), textAlign = textAlign, ) } } /** * A reusable Composable function that provides an animated float progress value after an initial * delay. * * This function is ideal for creating "enter" animations that start after a specified pause, * allowing for staggered or timed visual effects. It uses `animateFloatAsState` to smoothly * transition the progress from 0f to 1f. */ @Composable fun rememberDelayedAnimationProgress( initialDelay: Long = 0, animationDurationMs: Int, animationLabel: String, easing: Easing = FastOutSlowInEasing, ): Float { var startAnimation by remember { mutableStateOf(false) } val progress: Float by animateFloatAsState( if (startAnimation) 1f else 0f, label = animationLabel, animationSpec = tween(durationMillis = animationDurationMs, easing = easing), ) LaunchedEffect(Unit) { delay(initialDelay) startAnimation = true } return progress } ================================================ FILE: Android/src/app/src/main/java/com/google/ai/edge/gallery/ui/common/chat/AudioPlaybackPanel.kt ================================================ /* * Copyright 2025 Google LLC * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * http://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. */ package com.google.ai.edge.gallery.ui.common.chat import android.media.AudioAttributes import android.media.AudioFormat import android.media.AudioTrack import android.util.Log import androidx.compose.foundation.Canvas import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.width import androidx.compose.material.icons.Icons import androidx.compose.material.icons.rounded.PlayArrow import androidx.compose.material.icons.rounded.Stop import androidx.compose.material3.Icon import androidx.compose.material3.IconButton import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.DisposableEffect import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.MutableState import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableFloatStateOf 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.geometry.CornerRadius import androidx.compose.ui.geometry.Offset import androidx.compose.ui.geometry.Size import androidx.compose.ui.geometry.toRect import androidx.compose.ui.graphics.BlendMode import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.drawscope.drawIntoCanvas import androidx.compose.ui.res.stringResource import androidx.compose.ui.unit.dp import com.google.ai.edge.gallery.R import com.google.ai.edge.gallery.data.MAX_AUDIO_CLIP_DURATION_SEC import com.google.ai.edge.gallery.ui.theme.customColors import java.nio.ByteBuffer import java.nio.ByteOrder import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.isActive import kotlinx.coroutines.launch import kotlinx.coroutines.withContext private const val TAG = "AGAudioPlaybackPanel" private const val BAR_SPACE = 2 private const val BAR_WIDTH = 2 private const val MIN_BAR_COUNT = 16 private const val MAX_BAR_COUNT = 48 /** * A Composable that displays an audio playback panel, including play/stop controls, a waveform * visualization, and the duration of the audio clip. */ @Composable fun AudioPlaybackPanel( audioData: ByteArray, sampleRate: Int, isRecording: Boolean, modifier: Modifier = Modifier, onDarkBg: Boolean = false, ) { val coroutineScope = rememberCoroutineScope() var isPlaying by remember { mutableStateOf(false) } val audioTrackState = remember { mutableStateOf(null) } val durationInSeconds = remember(audioData) { // PCM 16-bit val bytesPerSample = 2 val bytesPerFrame = bytesPerSample * 1 // mono val totalFrames = audioData.size.toDouble() / bytesPerFrame totalFrames / sampleRate } val barCount = remember(durationInSeconds) { val f = durationInSeconds / MAX_AUDIO_CLIP_DURATION_SEC ((MAX_BAR_COUNT - MIN_BAR_COUNT) * f + MIN_BAR_COUNT).toInt() } val amplitudeLevels = remember(audioData) { generateAmplitudeLevels(audioData = audioData, barCount = barCount) } var playbackProgress by remember { mutableFloatStateOf(0f) } // Reset when a new recording is started. LaunchedEffect(isRecording) { if (isRecording) { val audioTrack = audioTrackState.value audioTrack?.stop() isPlaying = false playbackProgress = 0f } } // Cleanup on Composable Disposal. DisposableEffect(Unit) { onDispose { val audioTrack = audioTrackState.value audioTrack?.stop() audioTrack?.release() } } Row(verticalAlignment = Alignment.CenterVertically, modifier = modifier) { // Button to play/stop the clip. IconButton( onClick = { coroutineScope.launch { if (!isPlaying) { isPlaying = true playAudio( audioTrackState = audioTrackState, audioData = audioData, sampleRate = sampleRate, onProgress = { playbackProgress = it }, onCompletion = { playbackProgress = 0f isPlaying = false }, ) } else { stopPlayAudio(audioTrackState = audioTrackState) playbackProgress = 0f isPlaying = false } } } ) { Icon( if (isPlaying) Icons.Rounded.Stop else Icons.Rounded.PlayArrow, contentDescription = stringResource( if (isPlaying) R.string.cd_stop_playback_icon else R.string.cd_play_audio_icon ), tint = if (onDarkBg) Color.White else MaterialTheme.colorScheme.primary, ) } // Visualization AmplitudeBarGraph( amplitudeLevels = amplitudeLevels, progress = playbackProgress, modifier = Modifier.width((barCount * BAR_WIDTH + (barCount - 1) * BAR_SPACE).dp).height(24.dp), onDarkBg = onDarkBg, ) // Duration Text( "${"%.1f".format(durationInSeconds)}s", style = MaterialTheme.typography.labelLarge, color = if (onDarkBg) Color.White else MaterialTheme.colorScheme.onSurfaceVariant, modifier = Modifier.padding(start = 12.dp), ) } } @Composable private fun AmplitudeBarGraph( amplitudeLevels: List, progress: Float, modifier: Modifier = Modifier, onDarkBg: Boolean = false, ) { val barColor = MaterialTheme.customColors.waveFormBgColor val progressColor = if (onDarkBg) Color.White else MaterialTheme.colorScheme.primary Canvas(modifier = modifier) { val barCount = amplitudeLevels.size val barWidth = (size.width - BAR_SPACE.dp.toPx() * (barCount - 1)) / barCount val cornerRadius = CornerRadius(x = barWidth, y = barWidth) // Use drawIntoCanvas for advanced blend mode operations drawIntoCanvas { canvas -> // 1. Save the current state of the canvas onto a temporary, offscreen layer canvas.saveLayer(size.toRect(), androidx.compose.ui.graphics.Paint()) // 2. Draw the bars in grey. amplitudeLevels.forEachIndexed { index, level -> val barHeight = (level * size.height).coerceAtLeast(1.5f) val left = index * (barWidth + BAR_SPACE.dp.toPx()) drawRoundRect( color = barColor, topLeft = Offset(x = left, y = size.height / 2 - barHeight / 2), size = Size(barWidth, barHeight), cornerRadius = cornerRadius, ) } // 3. Draw the progress rectangle using BlendMode.SrcIn to only draw where the bars already // exists. val progressWidth = size.width * progress drawRect( color = progressColor, topLeft = Offset.Zero, size = Size(progressWidth, size.height), blendMode = BlendMode.SrcIn, ) // 4. Restore the layer, merging it onto the main canvas canvas.restore() } } } private suspend fun playAudio( audioTrackState: MutableState, audioData: ByteArray, sampleRate: Int, onProgress: (Float) -> Unit, onCompletion: () -> Unit, ) { Log.d(TAG, "Start playing audio...") try { withContext(Dispatchers.IO) { var lastProgressUpdateMs = 0L audioTrackState.value?.release() val audioTrack = AudioTrack.Builder() .setAudioAttributes( AudioAttributes.Builder() .setContentType(AudioAttributes.CONTENT_TYPE_SPEECH) .setUsage(AudioAttributes.USAGE_MEDIA) .build() ) .setAudioFormat( AudioFormat.Builder() .setEncoding(AudioFormat.ENCODING_PCM_16BIT) .setSampleRate(sampleRate) .setChannelMask(AudioFormat.CHANNEL_OUT_MONO) .build() ) .setTransferMode(AudioTrack.MODE_STATIC) .setBufferSizeInBytes(audioData.size) .build() val bytesPerFrame = 2 // For PCM 16-bit Mono val totalFrames = audioData.size / bytesPerFrame audioTrackState.value = audioTrack audioTrack.write(audioData, 0, audioData.size) audioTrack.play() // Coroutine to monitor progress while (isActive && audioTrack.playState == AudioTrack.PLAYSTATE_PLAYING) { val currentFrames = audioTrack.playbackHeadPosition if (currentFrames >= totalFrames) { break // Exit loop when playback is done } val progress = currentFrames.toFloat() / totalFrames val curMs = System.currentTimeMillis() if (curMs - lastProgressUpdateMs > 30) { onProgress(progress) lastProgressUpdateMs = curMs } } if (isActive) { audioTrackState.value?.stop() } } } catch (e: Exception) { // Ignore } finally { onProgress(1f) onCompletion() } } private fun stopPlayAudio(audioTrackState: MutableState) { Log.d(TAG, "Stopping playing audio...") val audioTrack = audioTrackState.value audioTrack?.stop() audioTrack?.release() audioTrackState.value = null } /** * Processes a raw PCM 16-bit audio byte array to generate a list of normalized amplitude levels for * visualization. */ private fun generateAmplitudeLevels(audioData: ByteArray, barCount: Int): List { if (audioData.isEmpty()) { return List(barCount) { 0f } } // 1. Parse bytes into 16-bit short samples (PCM 16-bit) val shortBuffer = ByteBuffer.wrap(audioData).order(ByteOrder.LITTLE_ENDIAN).asShortBuffer() val samples = ShortArray(shortBuffer.remaining()) shortBuffer.get(samples) if (samples.isEmpty()) { return List(barCount) { 0f } } // 2. Determine the size of each chunk val chunkSize = samples.size / barCount val amplitudeLevels = mutableListOf() // 3. Get the max value for each chunk for (i in 0 until barCount) { val chunkStart = i * chunkSize val chunkEnd = (chunkStart + chunkSize).coerceAtMost(samples.size) var maxAmplitudeInChunk = 0.0 for (j in chunkStart until chunkEnd) { val sampleAbs = kotlin.math.abs(samples[j].toDouble()) if (sampleAbs > maxAmplitudeInChunk) { maxAmplitudeInChunk = sampleAbs } } // 4. Normalize the value (0 to 1) // Short.MAX_VALUE is 32767.0, a good reference for max amplitude val normalizedRms = (maxAmplitudeInChunk / Short.MAX_VALUE).toFloat().coerceIn(0f, 1f) amplitudeLevels.add(normalizedRms) } // Normalize the resulting levels so that the max value becomes 0.9. val maxVal = amplitudeLevels.max() if (maxVal == 0f) { return amplitudeLevels } val scaleFactor = 0.9f / maxVal return amplitudeLevels.map { it * scaleFactor } } ================================================ FILE: Android/src/app/src/main/java/com/google/ai/edge/gallery/ui/common/chat/AudioRecorderPanel.kt ================================================ /* * Copyright 2025 Google LLC * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * http://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. */ package com.google.ai.edge.gallery.ui.common.chat import android.annotation.SuppressLint import android.content.Context import android.media.AudioFormat import android.media.AudioRecord import android.media.MediaRecorder import android.util.Log 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.layout.size import androidx.compose.foundation.shape.CircleShape import androidx.compose.material.icons.Icons import androidx.compose.material.icons.rounded.ArrowUpward import androidx.compose.material.icons.rounded.Close import androidx.compose.material.icons.rounded.Mic 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.runtime.Composable import androidx.compose.runtime.DisposableEffect import androidx.compose.runtime.MutableLongState import androidx.compose.runtime.MutableState import androidx.compose.runtime.derivedStateOf import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableLongStateOf 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.graphics.Color import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.res.stringResource import androidx.compose.ui.semantics.LiveRegionMode import androidx.compose.ui.semantics.liveRegion import androidx.compose.ui.semantics.semantics import androidx.compose.ui.unit.dp import com.google.ai.edge.gallery.R import com.google.ai.edge.gallery.common.calculatePeakAmplitude import com.google.ai.edge.gallery.data.MAX_AUDIO_CLIP_DURATION_SEC import com.google.ai.edge.gallery.data.SAMPLE_RATE import com.google.ai.edge.gallery.data.Task import com.google.ai.edge.gallery.ui.common.getTaskIconColor import com.google.ai.edge.gallery.ui.theme.customColors import java.io.ByteArrayOutputStream import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.coroutineScope import kotlinx.coroutines.launch private const val TAG = "AGAudioRecorderPanel" private const val CHANNEL_CONFIG = AudioFormat.CHANNEL_IN_MONO private const val AUDIO_FORMAT = AudioFormat.ENCODING_PCM_16BIT private const val PANEL_ALPHA = 0.7f /** * This composable function creates a UI panel for audio recording. It handles the UI state (e.g., * recording vs. idle) and manages the audio recording lifecycle. * * The panel displays different content based on the recording state: * - When idle, it shows a "Tap to record" message and a microphone icon. * - When recording, it shows a red indicator, elapsed time, and an "up arrow" icon button to send * the clip. * * Tapping the record button starts a coroutine to handle audio capture on a background thread. * Tapping the "up arrow" button stops the recording, passes the audio data via the onSendAudioClip * callback, and resets the state. * * A DisposableEffect is used to ensure the AudioRecord resource is properly released when the * composable is removed from the UI hierarchy. */ @Composable fun AudioRecorderPanel( task: Task, onAmplitudeChanged: (Int /* 0-32767 */) -> Unit, onSendAudioClip: (ByteArray) -> Unit, onClose: () -> Unit, modifier: Modifier = Modifier, ) { val context = LocalContext.current val coroutineScope = rememberCoroutineScope() var isRecording by remember { mutableStateOf(false) } val elapsedMs = remember { mutableLongStateOf(0L) } val audioRecordState = remember { mutableStateOf(null) } val audioStream = remember { ByteArrayOutputStream() } val elapsedSeconds by remember { derivedStateOf { "%.1f".format(elapsedMs.longValue.toFloat() / 1000f) } } // Cleanup on Composable Disposal. DisposableEffect(Unit) { onDispose { audioRecordState.value?.release() } } Row( modifier = modifier.fillMaxWidth().padding(horizontal = 8.dp), horizontalArrangement = Arrangement.spacedBy(4.dp), verticalAlignment = Alignment.CenterVertically, ) { // Close button. IconButton( onClick = { if (isRecording) { val unused = stopRecording(audioRecordState = audioRecordState, audioStream = audioStream) isRecording = false } onClose() }, colors = IconButtonDefaults.iconButtonColors( containerColor = MaterialTheme.colorScheme.surfaceContainer.copy(alpha = PANEL_ALPHA) ), ) { Icon( Icons.Rounded.Close, contentDescription = stringResource(R.string.close), tint = MaterialTheme.colorScheme.onSurface, ) } // Controls. Row( modifier = Modifier.clip(CircleShape) .weight(1f) .background(MaterialTheme.colorScheme.surfaceContainer.copy(alpha = PANEL_ALPHA)) .padding(start = 12.dp), verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.SpaceBetween, ) { // Info message when there is no recorded clip and the recording has not started yet. if (!isRecording) { Text( "Tap the record button to start", style = MaterialTheme.typography.labelMedium, color = MaterialTheme.colorScheme.onSurfaceVariant, ) } // Elapsed seconds when recording in progress. else { Row( horizontalArrangement = Arrangement.spacedBy(12.dp), verticalAlignment = Alignment.CenterVertically, ) { Box( modifier = Modifier.size(8.dp) .background(MaterialTheme.customColors.recordButtonBgColor, CircleShape) ) Text("$elapsedSeconds s") } } // Record/send button. IconButton( modifier = Modifier.semantics { liveRegion = LiveRegionMode.Assertive }, onClick = { coroutineScope.launch { if (!isRecording) { isRecording = true startRecording( context = context, audioRecordState = audioRecordState, audioStream = audioStream, elapsedMs = elapsedMs, onAmplitudeChanged = onAmplitudeChanged, onMaxDurationReached = { val curRecordedBytes = stopRecording(audioRecordState = audioRecordState, audioStream = audioStream) onSendAudioClip(curRecordedBytes) isRecording = false }, ) } else { val curRecordedBytes = stopRecording(audioRecordState = audioRecordState, audioStream = audioStream) onSendAudioClip(curRecordedBytes) isRecording = false } } }, colors = IconButtonDefaults.iconButtonColors(containerColor = getTaskIconColor(task = task)), ) { Icon( if (isRecording) Icons.Rounded.ArrowUpward else Icons.Rounded.Mic, contentDescription = stringResource( if (isRecording) R.string.cd_send_audio_clip_icon else R.string.cd_start_recording ), tint = Color.White, ) } } } } // Permission is checked in parent composable. @SuppressLint("MissingPermission") private suspend fun startRecording( context: Context, audioRecordState: MutableState, audioStream: ByteArrayOutputStream, elapsedMs: MutableLongState, onAmplitudeChanged: (Int) -> Unit, onMaxDurationReached: () -> Unit, ) { Log.d(TAG, "Start recording...") val minBufferSize = AudioRecord.getMinBufferSize(SAMPLE_RATE, CHANNEL_CONFIG, AUDIO_FORMAT) audioRecordState.value?.release() val recorder = AudioRecord( MediaRecorder.AudioSource.MIC, SAMPLE_RATE, CHANNEL_CONFIG, AUDIO_FORMAT, minBufferSize, ) audioRecordState.value = recorder val buffer = ByteArray(minBufferSize) // The function will only return when the recording is done (when stopRecording is called). coroutineScope { launch(Dispatchers.IO) { recorder.startRecording() val startMs = System.currentTimeMillis() elapsedMs.longValue = 0L while (audioRecordState.value?.recordingState == AudioRecord.RECORDSTATE_RECORDING) { val bytesRead = recorder.read(buffer, 0, buffer.size) if (bytesRead > 0) { val currentAmplitude = calculatePeakAmplitude(buffer = buffer, bytesRead = bytesRead) onAmplitudeChanged(currentAmplitude) audioStream.write(buffer, 0, bytesRead) } elapsedMs.longValue = System.currentTimeMillis() - startMs if (elapsedMs.longValue >= MAX_AUDIO_CLIP_DURATION_SEC * 1000) { onMaxDurationReached() break } } } } } private fun stopRecording( audioRecordState: MutableState, audioStream: ByteArrayOutputStream, ): ByteArray { Log.d(TAG, "Stopping recording...") val recorder = audioRecordState.value if (recorder?.recordingState == AudioRecord.RECORDSTATE_RECORDING) { recorder.stop() } recorder?.release() audioRecordState.value = null val recordedBytes = audioStream.toByteArray() audioStream.reset() Log.d(TAG, "Stopped. Recorded ${recordedBytes.size} bytes.") return recordedBytes } ================================================ FILE: Android/src/app/src/main/java/com/google/ai/edge/gallery/ui/common/chat/BenchmarkConfigDialog.kt ================================================ /* * Copyright 2025 Google LLC * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * http://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. */ package com.google.ai.edge.gallery.ui.common.chat // import androidx.compose.ui.tooling.preview.Preview // import com.google.ai.edge.gallery.ui.theme.GalleryTheme import androidx.compose.runtime.Composable import com.google.ai.edge.gallery.data.Config import com.google.ai.edge.gallery.data.ConfigKeys import com.google.ai.edge.gallery.data.NumberSliderConfig import com.google.ai.edge.gallery.data.ValueType import com.google.ai.edge.gallery.data.convertValueToTargetType import com.google.ai.edge.gallery.ui.common.ConfigDialog private const val DEFAULT_BENCHMARK_WARM_UP_ITERATIONS = 50f private const val DEFAULT_BENCHMARK_ITERATIONS = 200f private val BENCHMARK_CONFIGS: List = listOf( NumberSliderConfig( key = ConfigKeys.WARM_UP_ITERATIONS, sliderMin = 10f, sliderMax = 200f, defaultValue = DEFAULT_BENCHMARK_WARM_UP_ITERATIONS, valueType = ValueType.INT, ), NumberSliderConfig( key = ConfigKeys.BENCHMARK_ITERATIONS, sliderMin = 50f, sliderMax = 500f, defaultValue = DEFAULT_BENCHMARK_ITERATIONS, valueType = ValueType.INT, ), ) private val BENCHMARK_CONFIGS_INITIAL_VALUES = mapOf( ConfigKeys.WARM_UP_ITERATIONS.label to DEFAULT_BENCHMARK_WARM_UP_ITERATIONS, ConfigKeys.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(ConfigKeys.WARM_UP_ITERATIONS.label), valueType = ValueType.INT, ) as Int val benchmarkIterations = convertValueToTargetType( value = curConfigValues.getValue(ConfigKeys.BENCHMARK_ITERATIONS.label), valueType = ValueType.INT, ) as Int onBenchmarkClicked(message, warmUpIterations, benchmarkIterations) } }, ) } // @Preview(showBackground = true) // @Composable // fun BenchmarkConfigDialogPreview() { // GalleryTheme { // BenchmarkConfigDialog( // onDismissed = {}, // messageToBenchmark = null, // onBenchmarkClicked = { _, _, _ -> }, // ) // } // } ================================================ FILE: Android/src/app/src/main/java/com/google/ai/edge/gallery/ui/common/chat/ChatMessage.kt ================================================ /* * Copyright 2025 Google LLC * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * http://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. */ package com.google.ai.edge.gallery.ui.common.chat import android.graphics.Bitmap import android.util.Log import androidx.compose.material.icons.Icons import androidx.compose.material.icons.rounded.Check import androidx.compose.ui.graphics.ImageBitmap import androidx.compose.ui.graphics.vector.ImageVector import androidx.compose.ui.unit.Dp import com.google.ai.edge.gallery.common.Classification import com.google.ai.edge.gallery.data.Model import com.google.ai.edge.gallery.data.PromptTemplate private const val TAG = "AGChatMessage" enum class ChatMessageType { INFO, WARNING, ERROR, TEXT, IMAGE, IMAGE_WITH_HISTORY, AUDIO_CLIP, LOADING, CLASSIFICATION, CONFIG_VALUES_CHANGE, BENCHMARK_RESULT, BENCHMARK_LLM_RESULT, PROMPT_TEMPLATES, WEBVIEW, COLLAPSABLE_PROGRESS_PANEL, } enum class ChatSide { USER, AGENT, SYSTEM, } /** Base class for a chat message. */ open class ChatMessage( open val type: ChatMessageType, open val side: ChatSide, open val latencyMs: Float = -1f, open val accelerator: String = "", open val hideSenderLabel: Boolean = false, open val disableBubbleShape: Boolean = false, ) { open fun clone(): ChatMessage { return ChatMessage( type = type, side = side, latencyMs = latencyMs, accelerator = accelerator, hideSenderLabel = hideSenderLabel, disableBubbleShape = disableBubbleShape, ) } } /** Chat message for showing loading status. */ class ChatMessageLoading( var extraProgressLabel: String = "", override val accelerator: String = "", ) : ChatMessage(type = ChatMessageType.LOADING, side = ChatSide.AGENT, accelerator = accelerator) { override fun clone(): ChatMessageLoading { return ChatMessageLoading(extraProgressLabel = extraProgressLabel, accelerator = accelerator) } } /** Chat message for info (help). */ class ChatMessageInfo(val content: String) : ChatMessage(type = ChatMessageType.INFO, side = ChatSide.SYSTEM) /** Chat message for warning message. */ class ChatMessageWarning(val content: String) : ChatMessage(type = ChatMessageType.WARNING, side = ChatSide.SYSTEM) /** Chat message for error message. */ class ChatMessageError(val content: String) : ChatMessage(type = ChatMessageType.ERROR, 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, // Benchmark result for LLM response. var llmBenchmarkResult: ChatMessageBenchmarkLlmResult? = null, override val accelerator: String = "", override val hideSenderLabel: Boolean = false, var data: Any? = null, ) : ChatMessage( type = ChatMessageType.TEXT, side = side, latencyMs = latencyMs, accelerator = accelerator, hideSenderLabel = hideSenderLabel, ) { override fun clone(): ChatMessageText { return ChatMessageText( content = content, side = side, latencyMs = latencyMs, accelerator = accelerator, isMarkdown = isMarkdown, llmBenchmarkResult = llmBenchmarkResult, hideSenderLabel = hideSenderLabel, data = data, ) } } /** Chat message for images. */ class ChatMessageImage( val bitmaps: List, val imageBitMaps: List, val maxSize: Int = 200, override val side: ChatSide, override val latencyMs: Float = 0f, override val accelerator: String = "", override val hideSenderLabel: Boolean = false, ) : ChatMessage( type = ChatMessageType.IMAGE, side = side, latencyMs = latencyMs, accelerator = accelerator, hideSenderLabel = hideSenderLabel, ) { override fun clone(): ChatMessageImage { return ChatMessageImage( bitmaps = bitmaps.toList(), imageBitMaps = imageBitMaps.toList(), side = side, latencyMs = latencyMs, accelerator = accelerator, hideSenderLabel = hideSenderLabel, ) } } /** Chat message for audio clip. */ class ChatMessageAudioClip( val audioData: ByteArray, val sampleRate: Int, override val side: ChatSide, override val latencyMs: Float = 0f, ) : ChatMessage(type = ChatMessageType.AUDIO_CLIP, side = side, latencyMs = latencyMs) { override fun clone(): ChatMessageAudioClip { return ChatMessageAudioClip( audioData = audioData, sampleRate = sampleRate, side = side, latencyMs = latencyMs, ) } fun genByteArrayForWav(): ByteArray { val header = ByteArray(44) val pcmDataSize = audioData.size val wavFileSize = pcmDataSize + 44 // 44 bytes for the header val channels = 1 // Mono val bitsPerSample: Short = 16 val byteRate = sampleRate * channels * bitsPerSample / 8 Log.d(TAG, "Wav metadata: sampleRate: $sampleRate") // RIFF/WAVE header header[0] = 'R'.code.toByte() header[1] = 'I'.code.toByte() header[2] = 'F'.code.toByte() header[3] = 'F'.code.toByte() header[4] = (wavFileSize and 0xff).toByte() header[5] = (wavFileSize shr 8 and 0xff).toByte() header[6] = (wavFileSize shr 16 and 0xff).toByte() header[7] = (wavFileSize shr 24 and 0xff).toByte() header[8] = 'W'.code.toByte() header[9] = 'A'.code.toByte() header[10] = 'V'.code.toByte() header[11] = 'E'.code.toByte() header[12] = 'f'.code.toByte() header[13] = 'm'.code.toByte() header[14] = 't'.code.toByte() header[15] = ' '.code.toByte() header[16] = 16 header[17] = 0 header[18] = 0 header[19] = 0 // Sub-chunk size (16 for PCM) header[20] = 1 header[21] = 0 // Audio format (1 for PCM) header[22] = channels.toByte() header[23] = 0 // Number of channels header[24] = (sampleRate and 0xff).toByte() header[25] = (sampleRate shr 8 and 0xff).toByte() header[26] = (sampleRate shr 16 and 0xff).toByte() header[27] = (sampleRate shr 24 and 0xff).toByte() header[28] = (byteRate and 0xff).toByte() header[29] = (byteRate shr 8 and 0xff).toByte() header[30] = (byteRate shr 16 and 0xff).toByte() header[31] = (byteRate shr 24 and 0xff).toByte() header[32] = (channels * bitsPerSample / 8).toByte() header[33] = 0 // Block align header[34] = bitsPerSample.toByte() header[35] = (bitsPerSample.toInt() shr 8 and 0xff).toByte() // Bits per sample header[36] = 'd'.code.toByte() header[37] = 'a'.code.toByte() header[38] = 't'.code.toByte() header[39] = 'a'.code.toByte() header[40] = (pcmDataSize and 0xff).toByte() header[41] = (pcmDataSize shr 8 and 0xff).toByte() header[42] = (pcmDataSize shr 16 and 0xff).toByte() header[43] = (pcmDataSize shr 24 and 0xff).toByte() return header + audioData } fun getDurationInSeconds(): Float { // PCM 16-bit val bytesPerSample = 2 val bytesPerFrame = bytesPerSample * 1 // mono val totalFrames = audioData.size.toFloat() / bytesPerFrame return totalFrames / sampleRate } } /** 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, override val accelerator: String = "", ) : ChatMessage( type = ChatMessageType.BENCHMARK_LLM_RESULT, side = ChatSide.AGENT, latencyMs = latencyMs, accelerator = accelerator, ) data class Histogram(val buckets: List, val maxCount: Int, val highlightBucketIndex: Int = -1) /** Chat message for showing prompt templates. */ class ChatMessagePromptTemplates( val templates: List, val showMakeYourOwn: Boolean = true, ) : ChatMessage(type = ChatMessageType.PROMPT_TEMPLATES, side = ChatSide.SYSTEM) /** Chat message for showing a WebView. */ class ChatMessageWebView( val url: String, val iframe: Boolean, override val side: ChatSide = ChatSide.AGENT, override val hideSenderLabel: Boolean = false, ) : ChatMessage( type = ChatMessageType.WEBVIEW, side = side, hideSenderLabel = hideSenderLabel, disableBubbleShape = true, ) { override fun clone(): ChatMessageWebView { return ChatMessageWebView( url = url, iframe = iframe, side = side, hideSenderLabel = hideSenderLabel, ) } } data class ProgressPanelItem(val title: String, val description: String) /** Chat message for showing a collapsable progress panel. */ class ChatMessageCollapsableProgressPanel( val title: String, val inProgress: Boolean, override val accelerator: String, val doneIcon: ImageVector = Icons.Rounded.Check, val items: List = listOf(), val customData: Any? = null, ) : ChatMessage( type = ChatMessageType.COLLAPSABLE_PROGRESS_PANEL, side = ChatSide.AGENT, accelerator = accelerator, ) { override fun clone(): ChatMessageCollapsableProgressPanel { return ChatMessageCollapsableProgressPanel( title = title, inProgress = inProgress, accelerator = accelerator, doneIcon = doneIcon, items = items.toList(), customData = customData, ) } } ================================================ FILE: Android/src/app/src/main/java/com/google/ai/edge/gallery/ui/common/chat/ChatPanel.kt ================================================ /* * Copyright 2025 Google LLC * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * http://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. */ package com.google.ai.edge.gallery.ui.common.chat import android.content.ClipData import android.graphics.Bitmap import androidx.compose.animation.AnimatedVisibility import androidx.compose.animation.core.Spring import androidx.compose.animation.core.VisibilityThreshold import androidx.compose.animation.core.spring import androidx.compose.animation.fadeIn import androidx.compose.animation.fadeOut import androidx.compose.animation.slideInVertically 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.PaddingValues import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.WindowInsets import androidx.compose.foundation.layout.consumeWindowInsets 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.padding import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.width import androidx.compose.foundation.layout.wrapContentHeight import androidx.compose.foundation.layout.wrapContentWidth import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.LazyListState import androidx.compose.foundation.lazy.itemsIndexed import androidx.compose.foundation.lazy.rememberLazyListState import androidx.compose.foundation.shape.RoundedCornerShape 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.graphicsLayer 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.ClipEntry import androidx.compose.ui.platform.LocalClipboard import androidx.compose.ui.platform.LocalDensity import androidx.compose.ui.platform.LocalFocusManager import androidx.compose.ui.platform.LocalHapticFeedback import androidx.compose.ui.res.dimensionResource import androidx.compose.ui.res.stringResource import androidx.compose.ui.semantics.contentDescription import androidx.compose.ui.semantics.semantics import androidx.compose.ui.unit.IntOffset import androidx.compose.ui.unit.dp import com.google.ai.edge.gallery.R import com.google.ai.edge.gallery.data.BuiltInTaskId import com.google.ai.edge.gallery.data.Model import com.google.ai.edge.gallery.data.Task import com.google.ai.edge.gallery.ui.common.AudioAnimation import com.google.ai.edge.gallery.ui.common.ErrorDialog import com.google.ai.edge.gallery.ui.modelmanager.ModelInitializationStatusType import com.google.ai.edge.gallery.ui.modelmanager.ModelManagerViewModel import com.google.ai.edge.gallery.ui.theme.customColors import kotlinx.coroutines.launch /** 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, innerPadding: PaddingValues, onSendMessage: (Model, List) -> Unit, onRunAgainClicked: (Model, ChatMessage) -> Unit, onBenchmarkClicked: (Model, ChatMessage, warmUpIterations: Int, benchmarkIterations: Int) -> Unit, navigateUp: () -> Unit, modifier: Modifier = Modifier, onStreamImageMessage: (Model, ChatMessageImage) -> Unit = { _, _ -> }, onStreamEnd: (Int) -> Unit = {}, onStopButtonClicked: () -> Unit = {}, onImageSelected: (bitmaps: List, selectedBitmapIndex: Int) -> Unit = { _, _ -> }, showStopButtonInInputWhenInProgress: Boolean = false, emptyStateComposable: @Composable () -> Unit = {}, ) { 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 val imageCountToLastConfigChange = remember(messages) { var imageCount = 0 for (message in messages.reversed()) { if (message is ChatMessageConfigValuesChange) { break } if (message is ChatMessageImage) { imageCount += message.bitmaps.size } } imageCount } val audioClipMesssageCountToLastconfigChange = remember(messages) { var audioClipMessageCount = 0 for (message in messages.reversed()) { if (message is ChatMessageConfigValuesChange) { break } if (message is ChatMessageAudioClip) { audioClipMessageCount++ } } audioClipMessageCount } 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) } var longPressedMessageIndex by remember { mutableIntStateOf(-1) } var showErrorDialog by remember { mutableStateOf(false) } var showAudioRecorder by remember { mutableStateOf(false) } var curAmplitude by remember { mutableIntStateOf(0) } // 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 to bottom when IME is toggled. LaunchedEffect(WindowInsets.ime.getBottom(density)) { scrollToBottom(listState = listState, animate = true) } // Scroll the content to the bottom when any of these changes. LaunchedEffect( messages.size, lastMessage.value, lastMessageContent.value, lastMessage.value?.latencyMs, ) { if (messages.isNotEmpty()) { val lastVisibleItem = listState.layoutInfo.visibleItemsInfo.last() // Determines if an automatic scroll is necessary. It is true if: // 1. The last item is not yet fully visible // OR // 2. The scroll position is close to the bottom (within 90 pixels of the end offset. 90 is // slightly taller than the "show stats" chip). val canScroll = lastVisibleItem.index < messages.size - 1 || lastVisibleItem.offset + lastVisibleItem.size - listState.layoutInfo.viewportEndOffset < 90 if (canScroll) { scrollToBottom(listState = listState, animate = true) } } } 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] LaunchedEffect(modelInitializationStatus) { showErrorDialog = modelInitializationStatus?.status == ModelInitializationStatusType.ERROR } Box(modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.BottomCenter) { // Audio record animation. AnimatedVisibility( showAudioRecorder, enter = slideInVertically( animationSpec = spring( stiffness = Spring.StiffnessLow, visibilityThreshold = IntOffset.VisibilityThreshold, ) ) { it } + fadeIn(animationSpec = spring(stiffness = Spring.StiffnessLow)), exit = fadeOut(), modifier = Modifier.graphicsLayer { alpha = 0.8f }, ) { AudioAnimation(bgColor = MaterialTheme.colorScheme.surface, amplitude = curAmplitude) } Column( modifier = modifier.padding(innerPadding).consumeWindowInsets(innerPadding).imePadding() ) { Box(contentAlignment = Alignment.BottomCenter, modifier = Modifier.weight(1f)) { val cdChatPanel = stringResource(R.string.cd_chat_panel) LazyColumn( modifier = Modifier.fillMaxSize().nestedScroll(nestedScrollConnection).semantics { contentDescription = cdChatPanel }, state = listState, verticalArrangement = Arrangement.Top, ) { itemsIndexed(messages) { index, 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 if ( message.type !== ChatMessageType.LOADING && message.type !== ChatMessageType.WEBVIEW && message.type !== ChatMessageType.COLLAPSABLE_PROGRESS_PANEL ) { 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, ) messageColumn@{ // Sender row. var agentName = stringResource(task.agentNameRes) if (message.accelerator.isNotEmpty()) { agentName = "$agentName on ${message.accelerator}" } if (!message.hideSenderLabel) { MessageSender( message = message, agentName = agentName, imageHistoryCurIndex = imageHistoryCurIndex.intValue, ) } // Message body. when (message) { // Loading. is ChatMessageLoading -> MessageBodyLoading(message = message) // Info. is ChatMessageInfo -> MessageBodyInfo(message = message) // Warning is ChatMessageWarning -> MessageBodyWarning(message = message) // Error is ChatMessageError -> MessageBodyError(message = message) // Config values change. is ChatMessageConfigValuesChange -> MessageBodyConfigUpdate(message = message) // Prompt templates. is ChatMessagePromptTemplates -> MessageBodyPromptTemplates( message = message, task = task, onPromptClicked = { template -> onSendMessage( selectedModel, listOf(ChatMessageText(content = template.prompt, side = ChatSide.USER)), ) }, ) // Non-system messages. else -> { // The bubble shape around the message body. var messageBubbleModifier: Modifier = Modifier if (!message.disableBubbleShape) { // Use a rounded rectangle clip for multi-image image message. if (message is ChatMessageImage && message.bitmaps.size > 1) { messageBubbleModifier = messageBubbleModifier.clip(RoundedCornerShape(6.dp)) } // For other messages, use a bubble shape to clip. else { messageBubbleModifier = messageBubbleModifier.clip( MessageBubbleShape( radius = bubbleBorderRadius, hardCornerAtLeftOrRight = hardCornerAtLeftOrRight, ) ) } messageBubbleModifier = messageBubbleModifier.background(backgroundColor) } if (message is ChatMessageText) { messageBubbleModifier = messageBubbleModifier.pointerInput(Unit) { detectTapGestures( onLongPress = { haptic.performHapticFeedback(HapticFeedbackType.LongPress) longPressedMessageIndex = index showMessageLongPressedSheet = true } ) } } Box(modifier = messageBubbleModifier) { when (message) { // Text is ChatMessageText -> MessageBodyText(message = message, inProgress = uiState.inProgress) // Image is ChatMessageImage -> { MessageBodyImage(message = message, onImageClicked = onImageSelected) } // Image with history (for image gen) is ChatMessageImageWithHistory -> MessageBodyImageWithHistory( message = message, imageHistoryCurIndex = imageHistoryCurIndex, ) // Audio clip. is ChatMessageAudioClip -> MessageBodyAudioClip(message = message) // 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, modifier = Modifier.wrapContentWidth(), ) // Webview. is ChatMessageWebView -> MessageBodyWebview(message = message) // Collapsable progress panel. is ChatMessageCollapsableProgressPanel -> MessageBodyCollapsableProgressPanel(message = message) else -> {} } } if (message.side == ChatSide.AGENT) { Row( verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(8.dp), ) { 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.run_benchmark), icon = Icons.Outlined.Timer, onClick = { showBenchmarkConfigsDialog = true benchmarkMessage.value = message }, enabled = !uiState.inProgress, ) } } } } } } } } SnackbarHost(hostState = snackbarHostState, modifier = Modifier.padding(vertical = 4.dp)) // Show empty state. if (messages.isEmpty()) { emptyStateComposable() } } MessageInputText( task = task, modelManagerViewModel = modelManagerViewModel, curMessage = curMessage, inProgress = uiState.inProgress, isResettingSession = uiState.isResettingSession, modelPreparing = uiState.preparing, imageCount = imageCountToLastConfigChange, audioClipMessageCount = audioClipMesssageCountToLastconfigChange, modelInitializing = modelInitializationStatus?.status == ModelInitializationStatusType.INITIALIZING, textFieldPlaceHolderRes = task.textInputPlaceHolderRes, onValueChanged = { curMessage = it }, onSendMessage = { onSendMessage(selectedModel, it) curMessage = "" // Hide software keyboard. focusManager.clearFocus() }, onOpenPromptTemplatesClicked = { onSendMessage( selectedModel, listOf( ChatMessagePromptTemplates( templates = selectedModel.llmPromptTemplates, showMakeYourOwn = false, ) ), ) }, onStopButtonClicked = onStopButtonClicked, onSetAudioRecorderVisible = { start -> showAudioRecorder = start if (!showAudioRecorder) { curAmplitude = 0 } }, onAmplitudeChanged = { curAmplitude = it }, showPromptTemplatesInMenu = false, showImagePicker = selectedModel.llmSupportImage && task.id === BuiltInTaskId.LLM_ASK_IMAGE, showAudioPicker = selectedModel.llmSupportAudio && task.id === BuiltInTaskId.LLM_ASK_AUDIO, showStopButtonWhenInProgress = showStopButtonInInputWhenInProgress, ) } } // Error dialog. if (showErrorDialog) { ErrorDialog( error = modelInitializationStatus?.error ?: "", onDismiss = { showErrorDialog = false }, ) } // 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 = uiState.messagesByModel .getOrDefault(selectedModel.name, listOf()) .getOrNull(longPressedMessageIndex) if (message != null && message is ChatMessageText) { val clipboard = LocalClipboard.current ModalBottomSheet( onDismissRequest = { showMessageLongPressedSheet = false }, modifier = Modifier.wrapContentHeight(), ) { Column { // Copy text. Box( modifier = Modifier.fillMaxWidth().clickable { // Copy text. scope.launch { val clipData = ClipData.newPlainText("message content", message.content) val clipEntry = ClipEntry(clipData = clipData) clipboard.setClipEntry(clipEntry = clipEntry) } // Hide sheet. showMessageLongPressedSheet = false // 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 = stringResource(R.string.cd_copy_to_clipboard_icon), modifier = Modifier.size(18.dp), ) Text("Copy text") } } } } } } } private suspend fun scrollToBottom(listState: LazyListState, animate: Boolean = false) { val itemCount = listState.layoutInfo.totalItemsCount if (itemCount > 0) { if (animate) { listState.animateScrollToItem(itemCount - 1, scrollOffset = 1000000) } else { listState.scrollToItem(itemCount - 1, scrollOffset = 1000000) } } } ================================================ FILE: Android/src/app/src/main/java/com/google/ai/edge/gallery/ui/common/chat/ChatView.kt ================================================ /* * Copyright 2025 Google LLC * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * http://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. */ package com.google.ai.edge.gallery.ui.common.chat // import com.google.ai.edge.gallery.ui.preview.PreviewChatModel // import com.google.ai.edge.gallery.ui.preview.PreviewModelManagerViewModel // import com.google.ai.edge.gallery.ui.preview.TASK_TEST1 // import com.google.ai.edge.gallery.ui.theme.GalleryTheme import android.graphics.Bitmap import android.util.Log import androidx.activity.compose.BackHandler import androidx.compose.animation.AnimatedContent import androidx.compose.animation.AnimatedVisibility import androidx.compose.animation.fadeIn import androidx.compose.animation.fadeOut import androidx.compose.animation.slideInVertically import androidx.compose.animation.slideOutVertically 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.offset import androidx.compose.foundation.layout.padding import androidx.compose.foundation.pager.HorizontalPager import androidx.compose.foundation.pager.rememberPagerState import androidx.compose.material.icons.Icons import androidx.compose.material.icons.rounded.Close import androidx.compose.material3.Icon import androidx.compose.material3.IconButton import androidx.compose.material3.IconButtonDefaults 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.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.graphics.Color import androidx.compose.ui.graphics.asImageBitmap import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.res.stringResource import androidx.compose.ui.unit.dp import com.google.ai.edge.gallery.R import com.google.ai.edge.gallery.data.BuiltInTaskId import com.google.ai.edge.gallery.data.ConfigKeys import com.google.ai.edge.gallery.data.Model import com.google.ai.edge.gallery.data.ModelDownloadStatusType import com.google.ai.edge.gallery.data.Task import com.google.ai.edge.gallery.ui.common.ModelPageAppBar import com.google.ai.edge.gallery.ui.modelmanager.ModelInitializationStatusType import com.google.ai.edge.gallery.ui.modelmanager.ModelManagerViewModel import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.launch 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. */ @Composable fun ChatView( task: Task, viewModel: ChatViewModel, modelManagerViewModel: ModelManagerViewModel, onSendMessage: (Model, List) -> Unit, onRunAgainClicked: (Model, ChatMessage) -> Unit, onBenchmarkClicked: (Model, ChatMessage, Int, Int) -> Unit, navigateUp: () -> Unit, modifier: Modifier = Modifier, onResetSessionClicked: (Model) -> Unit = {}, onStreamImageMessage: (Model, ChatMessageImage) -> Unit = { _, _ -> }, onStopButtonClicked: (Model) -> Unit = {}, showStopButtonInInputWhenInProgress: Boolean = false, composableBelowMessageList: @Composable (Model) -> Unit = {}, emptyStateComposable: @Composable () -> Unit = {}, allowEditingSystemPrompt: Boolean = false, curSystemPrompt: String = "", onSystemPromptChanged: (String) -> Unit = {}, sendMessageTrigger: Pair>? = null, ) { val uiState by viewModel.uiState.collectAsState() val modelManagerUiState by modelManagerViewModel.uiState.collectAsState() val selectedModel = modelManagerUiState.selectedModel // Image viewer related. var selectedImageIndex by remember { mutableIntStateOf(-1) } var allImageViewerImages by remember { mutableStateOf>(listOf()) } var showImageViewer by remember { mutableStateOf(false) } val context = LocalContext.current val scope = rememberCoroutineScope() var navigatingUp by remember { mutableStateOf(false) } val handleNavigateUp = { navigatingUp = true navigateUp() // clean up all models. scope.launch(Dispatchers.Default) { for (model in task.models) { modelManagerViewModel.cleanupModel(context = context, task = task, model = model) } } } // Initialize model when model/download state changes. val curDownloadStatus = modelManagerUiState.modelDownloadStatus[selectedModel.name] LaunchedEffect(curDownloadStatus, selectedModel.name) { if (!navigatingUp) { if (curDownloadStatus?.status == ModelDownloadStatusType.SUCCEEDED) { Log.d(TAG, "Initializing model '${selectedModel.name}' from ChatView launched effect") modelManagerViewModel.initializeModel(context, task = task, model = selectedModel) } } } LaunchedEffect(sendMessageTrigger) { sendMessageTrigger?.let { trigger -> onSendMessage(trigger.first, trigger.second) } } // Handle system's edge swipe. BackHandler { val modelInitializationStatus = modelManagerUiState.modelInitializationStatus[selectedModel.name] val isModelInitializing = modelInitializationStatus?.status == ModelInitializationStatusType.INITIALIZING if (!isModelInitializing && !uiState.inProgress) { handleNavigateUp() } } Scaffold( modifier = modifier, topBar = { ModelPageAppBar( task = task, model = selectedModel, modelManagerViewModel = modelManagerViewModel, canShowResetSessionButton = true, isResettingSession = uiState.isResettingSession, inProgress = uiState.inProgress, modelPreparing = uiState.preparing, onResetSessionClicked = onResetSessionClicked, onConfigChanged = { old, new -> // Filter out config values that are not relevant to the task. // // - The "reset conversation turn count" is only valid for tiny garden task. val filteredOld = old.toMutableMap() val filteredNew = new.toMutableMap() if (task.id != BuiltInTaskId.LLM_TINY_GARDEN) { filteredOld.remove(ConfigKeys.RESET_CONVERSATION_TURN_COUNT.label) filteredNew.remove(ConfigKeys.RESET_CONVERSATION_TURN_COUNT.label) } viewModel.addConfigChangedMessage( oldConfigValues = filteredOld, newConfigValues = filteredNew, model = selectedModel, ) }, onBackClicked = { handleNavigateUp() }, onModelSelected = { prevModel, curModel -> if (prevModel.name != curModel.name) { modelManagerViewModel.cleanupModel(context = context, task = task, model = prevModel) } modelManagerViewModel.selectModel(model = curModel) }, allowEditingSystemPrompt = allowEditingSystemPrompt, curSystemPrompt = curSystemPrompt, onSystemPromptChanged = onSystemPromptChanged, ) }, ) { innerPadding -> Box { val curModelDownloadStatus = modelManagerUiState.modelDownloadStatus[selectedModel.name] composableBelowMessageList(selectedModel) Column(modifier = Modifier.fillMaxSize().background(MaterialTheme.colorScheme.surface)) { AnimatedContent( targetState = curModelDownloadStatus?.status == ModelDownloadStatusType.SUCCEEDED ) { targetState -> when (targetState) { // Main UI when model is downloaded. true -> ChatPanel( modelManagerViewModel = modelManagerViewModel, task = task, selectedModel = selectedModel, viewModel = viewModel, innerPadding = innerPadding, navigateUp = navigateUp, onSendMessage = onSendMessage, onRunAgainClicked = onRunAgainClicked, onBenchmarkClicked = onBenchmarkClicked, onStreamImageMessage = onStreamImageMessage, onStreamEnd = { averageFps -> viewModel.addMessage( model = selectedModel, message = ChatMessageInfo( content = "Live camera session ended. Average FPS: $averageFps" ), ) }, onStopButtonClicked = { onStopButtonClicked(selectedModel) }, onImageSelected = { bitmaps, selectedBitmapIndex -> selectedImageIndex = selectedBitmapIndex allImageViewerImages = bitmaps showImageViewer = true }, modifier = Modifier.weight(1f), showStopButtonInInputWhenInProgress = showStopButtonInInputWhenInProgress, emptyStateComposable = emptyStateComposable, ) // Model download false -> ModelDownloadStatusInfoPanel( model = selectedModel, task = task, modelManagerViewModel = modelManagerViewModel, ) } } } // Image viewer. AnimatedVisibility( visible = showImageViewer, enter = slideInVertically(initialOffsetY = { fullHeight -> fullHeight }) + fadeIn(), exit = slideOutVertically(targetOffsetY = { fullHeight -> fullHeight }) + fadeOut(), ) { val pagerState = rememberPagerState( pageCount = { allImageViewerImages.size }, initialPage = selectedImageIndex, ) val scrollEnabled = remember { mutableStateOf(true) } Box(modifier = Modifier.fillMaxSize().padding(top = innerPadding.calculateTopPadding())) { HorizontalPager( state = pagerState, userScrollEnabled = scrollEnabled.value, modifier = Modifier.fillMaxSize().background(Color.Black.copy(alpha = 0.95f)), ) { page -> allImageViewerImages[page].let { image -> ZoomableImage( bitmap = image.asImageBitmap(), pagerState = pagerState, modifier = Modifier.fillMaxSize(), ) } } // Close button. IconButton( onClick = { showImageViewer = false }, colors = IconButtonDefaults.iconButtonColors( containerColor = MaterialTheme.colorScheme.surfaceVariant ), modifier = Modifier.offset(x = (-8).dp, y = 8.dp).align(Alignment.TopEnd), ) { Icon( Icons.Rounded.Close, contentDescription = stringResource(R.string.cd_close_image_viewer_icon), tint = MaterialTheme.colorScheme.onSurfaceVariant, ) } } } } } } ================================================ FILE: Android/src/app/src/main/java/com/google/ai/edge/gallery/ui/common/chat/ChatViewModel.kt ================================================ /* * Copyright 2025 Google LLC * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * http://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. */ package com.google.ai.edge.gallery.ui.common.chat import android.util.Log import androidx.compose.ui.graphics.vector.ImageVector import androidx.lifecycle.ViewModel import com.google.ai.edge.gallery.common.processLlmResponse import com.google.ai.edge.gallery.data.ConfigKeys import com.google.ai.edge.gallery.data.Model import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.update private const val TAG = "AGChatViewModel" data class ChatUiState( /** Indicates whether the runtime is currently processing a message. */ val inProgress: Boolean = false, /** Indicates whether the session is being reset. */ val isResettingSession: Boolean = false, /** * Indicates whether the model is preparing (before outputting any result and after initializing). */ val preparing: 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. */ abstract class ChatViewModel() : ViewModel() { private val _uiState = MutableStateFlow(createUiState()) val uiState = _uiState.asStateFlow() fun addMessage(model: Model, message: ChatMessage) { val newMessagesByModel = _uiState.value.messagesByModel.toMutableMap() val newMessages = newMessagesByModel[model.name]?.toMutableList() ?: mutableListOf() 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 insertMessageAfter(model: Model, anchorMessage: ChatMessage, messageToAdd: ChatMessage) { val newMessagesByModel = _uiState.value.messagesByModel.toMutableMap() val newMessages = newMessagesByModel[model.name]?.toMutableList() ?: mutableListOf() newMessagesByModel[model.name] = newMessages // Find the index of the anchor message val anchorIndex = newMessages.indexOf(anchorMessage) if (anchorIndex != -1) { // Insert the new message after the anchor message newMessages.add(anchorIndex + 1, messageToAdd) } _uiState.update { _uiState.value.copy(messagesByModel = newMessagesByModel) } } fun removeMessageAt(model: Model, index: Int) { val newMessagesByModel = _uiState.value.messagesByModel.toMutableMap() val newMessages = newMessagesByModel[model.name]?.toMutableList() if (newMessages != null) { newMessagesByModel[model.name] = newMessages if (index >= 0 && index < newMessages.size) { newMessages.removeAt(index) } } _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 clearAllMessages(model: Model) { val newMessagesByModel = _uiState.value.messagesByModel.toMutableMap() newMessagesByModel[model.name] = mutableListOf() _uiState.update { _uiState.value.copy(messagesByModel = newMessagesByModel) } } fun getLastMessage(model: Model): ChatMessage? { return (_uiState.value.messagesByModel[model.name] ?: listOf()).lastOrNull() } fun getLastMessageWithType(model: Model, type: ChatMessageType): ChatMessage? { return (_uiState.value.messagesByModel[model.name] ?: listOf()).lastOrNull { it.type == type } } fun getLastMessageWithTypeAndSide( model: Model, type: ChatMessageType, side: ChatSide, ): ChatMessage? { return (_uiState.value.messagesByModel[model.name] ?: listOf()).lastOrNull { it.type == type && it.side == side } } fun updateLastTextMessageContentIncrementally( model: Model, partialContent: String, latencyMs: Float, ) { val newMessagesByModel = _uiState.value.messagesByModel.toMutableMap() val newMessages = newMessagesByModel[model.name]?.toMutableList() ?: mutableListOf() if (newMessages.isNotEmpty()) { val lastMessage = newMessages.last() if (lastMessage is ChatMessageText) { val newContent = processLlmResponse(response = "${lastMessage.content}${partialContent}") val newLastMessage = ChatMessageText( content = newContent, side = lastMessage.side, latencyMs = latencyMs, accelerator = lastMessage.accelerator, hideSenderLabel = lastMessage.hideSenderLabel, ) newMessages.removeAt(newMessages.size - 1) newMessages.add(newLastMessage) } } newMessagesByModel[model.name] = newMessages val newUiState = _uiState.value.copy(messagesByModel = newMessagesByModel) _uiState.update { newUiState } } fun updateLastTextMessageLlmBenchmarkResult( model: Model, llmBenchmarkResult: ChatMessageBenchmarkLlmResult, ) { 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) { lastMessage.llmBenchmarkResult = llmBenchmarkResult newMessages.removeAt(newMessages.size - 1) newMessages.add(lastMessage) } } 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 (index >= 0 && index < newMessages.size) { 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 updateCollapsableProgressPanelMessage( model: Model, title: String, inProgress: Boolean, doneIcon: ImageVector, addItemTitle: String, addItemDescription: String, customData: Any? = null, ) { val accelerator = model.getStringConfigValue(key = ConfigKeys.ACCELERATOR, defaultValue = "") val newMessagesByModel = _uiState.value.messagesByModel.toMutableMap() val newMessages = newMessagesByModel[model.name]?.toMutableList() ?: mutableListOf() if (newMessages.isNotEmpty()) { val lastMessage = newMessages.last() // If the last message is a loading message, replace it with a collapsable progress message. if (lastMessage is ChatMessageLoading) { newMessages.removeAt(newMessages.size - 1) val newCollapsableMessage = ChatMessageCollapsableProgressPanel( title = title, inProgress = inProgress, doneIcon = doneIcon, items = if (addItemTitle.isNotEmpty()) { listOf(ProgressPanelItem(title = addItemTitle, description = addItemDescription)) } else { listOf() }, accelerator = accelerator, customData = customData, ) newMessages.add(newCollapsableMessage) } // If the last message is not a loading message... else { val lastProgressPanelMessage = getLastMessageWithType(model = model, type = ChatMessageType.COLLAPSABLE_PROGRESS_PANEL) val lastProgressPanelMessageIndex = newMessages.indexOf(lastProgressPanelMessage) val lastUserTextMessage = getLastMessageWithTypeAndSide( model = model, type = ChatMessageType.TEXT, side = ChatSide.USER, ) val lastUserTextMessageIndex = newMessages.indexOf(lastUserTextMessage) // If the last user text message is after the last progress panel message, insert the new // collapsable message after the last user text message. if ( lastProgressPanelMessage != null && lastUserTextMessage != null && lastUserTextMessageIndex > lastProgressPanelMessageIndex ) { val newCollapsableMessage = ChatMessageCollapsableProgressPanel( title = title, inProgress = inProgress, doneIcon = doneIcon, items = if (addItemTitle.isNotEmpty()) { listOf(ProgressPanelItem(title = addItemTitle, description = addItemDescription)) } else { listOf() }, accelerator = accelerator, customData = customData, ) // Insert the new collapsable message after the last user text message. newMessages.add(lastUserTextMessageIndex + 1, newCollapsableMessage) } // If the last progress panel message is a collapsable progress panel, update it. else if ( lastProgressPanelMessage != null && lastProgressPanelMessage is ChatMessageCollapsableProgressPanel ) { val updatedMessage = ChatMessageCollapsableProgressPanel( title = title, accelerator = accelerator, inProgress = inProgress, doneIcon = doneIcon, items = lastProgressPanelMessage.items + if (addItemTitle.isNotEmpty()) { listOf( ProgressPanelItem(title = addItemTitle, description = addItemDescription) ) } else { listOf() }, customData = lastProgressPanelMessage.customData, ) newMessages[lastProgressPanelMessageIndex] = updatedMessage } } } newMessagesByModel[model.name] = newMessages _uiState.update { _uiState.value.copy(messagesByModel = newMessagesByModel) } } fun setInProgress(inProgress: Boolean) { _uiState.update { _uiState.value.copy(inProgress = inProgress) } } fun setIsResettingSession(isResettingSession: Boolean) { _uiState.update { _uiState.value.copy(isResettingSession = isResettingSession) } } fun setPreparing(preparing: Boolean) { _uiState.update { _uiState.value.copy(preparing = preparing) } } 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) } fun getMessageIndex(model: Model, message: ChatMessage): Int { return (_uiState.value.messagesByModel[model.name] ?: listOf()).indexOf(message) } private fun createUiState(): ChatUiState { return ChatUiState() } } ================================================ FILE: Android/src/app/src/main/java/com/google/ai/edge/gallery/ui/common/chat/DataCard.kt ================================================ /* * Copyright 2025 Google LLC * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * http://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. */ package com.google.ai.edge.gallery.ui.common.chat import 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.semantics.isTraversalGroup import androidx.compose.ui.semantics.semantics import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp import com.google.ai.edge.gallery.ui.theme.GalleryTheme import com.google.ai.edge.gallery.ui.theme.bodySmallMediumNarrow import com.google.ai.edge.gallery.ui.theme.bodySmallMediumNarrowBold import com.google.ai.edge.gallery.ui.theme.labelSmallNarrow import com.google.ai.edge.gallery.ui.theme.labelSmallNarrowMedium /** * Composable function to display a data card with a label and a numeric value. * * This function renders a column containing a label and a formatted numeric value. It provides * options for highlighting the value and displaying a placeholder when the value is not available. */ @Composable fun DataCard( label: String, value: Float?, unit: String, highlight: Boolean = false, showPlaceholder: Boolean = false, ) { var strValue = "-" Column(modifier = Modifier.semantics { isTraversalGroup = true }) { 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, ) } } } ================================================ FILE: Android/src/app/src/main/java/com/google/ai/edge/gallery/ui/common/chat/MessageActionButton.kt ================================================ /* * Copyright 2025 Google LLC * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * http://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. */ package com.google.ai.edge.gallery.ui.common.chat // import androidx.compose.ui.tooling.preview.Preview // import com.google.ai.edge.gallery.ui.theme.GalleryTheme import androidx.compose.foundation.background import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.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.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.unit.dp import com.google.ai.edge.gallery.ui.theme.bodySmallNarrow /** Composable function to display an action button below a chat message. */ @Composable fun MessageActionButton( label: String, icon: ImageVector, onClick: () -> Unit, modifier: Modifier = Modifier, enabled: Boolean = true, ) { val curModifier = modifier .padding(top = 4.dp) .clip(CircleShape) .background( if (enabled) MaterialTheme.colorScheme.secondaryContainer else MaterialTheme.colorScheme.surfaceContainerHigh ) val alpha: Float = if (enabled) 1.0f else 0.3f Row( modifier = if (enabled) curModifier.clickable { onClick() } else modifier, verticalAlignment = Alignment.CenterVertically, ) { Icon( icon, contentDescription = null, 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 = {}) // } // } // } ================================================ FILE: Android/src/app/src/main/java/com/google/ai/edge/gallery/ui/common/chat/MessageBodyAudioClip.kt ================================================ /* * Copyright 2025 Google LLC * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * http://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. */ package com.google.ai.edge.gallery.ui.common.chat import androidx.compose.foundation.layout.padding import androidx.compose.runtime.Composable import androidx.compose.ui.Modifier import androidx.compose.ui.unit.dp @Composable fun MessageBodyAudioClip(message: ChatMessageAudioClip, modifier: Modifier = Modifier) { AudioPlaybackPanel( audioData = message.audioData, sampleRate = message.sampleRate, isRecording = false, modifier = Modifier.padding(end = 16.dp), onDarkBg = true, ) } ================================================ FILE: Android/src/app/src/main/java/com/google/ai/edge/gallery/ui/common/chat/MessageBodyBenchmark.kt ================================================ /* * Copyright 2025 Google LLC * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * http://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. */ package com.google.ai.edge.gallery.ui.common.chat // import androidx.compose.ui.tooling.preview.Preview // import com.google.ai.edge.gallery.ui.theme.GalleryTheme import androidx.compose.foundation.background import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box 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.unit.dp 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" // ) // ) // } // } ================================================ FILE: Android/src/app/src/main/java/com/google/ai/edge/gallery/ui/common/chat/MessageBodyBenchmarkLlm.kt ================================================ /* * Copyright 2025 Google LLC * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * http://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. */ package com.google.ai.edge.gallery.ui.common.chat // import androidx.compose.ui.tooling.preview.Preview // import com.google.ai.edge.gallery.ui.theme.GalleryTheme import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.padding import androidx.compose.runtime.Composable import androidx.compose.ui.Modifier import androidx.compose.ui.unit.dp /** * 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, modifier: Modifier = Modifier) { Column(modifier = modifier.padding(12.dp), 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]) } } } } // @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, // ) // ) // } // } ================================================ FILE: Android/src/app/src/main/java/com/google/ai/edge/gallery/ui/common/chat/MessageBodyClassification.kt ================================================ /* * Copyright 2025 Google LLC * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * http://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. */ package com.google.ai.edge.gallery.ui.common.chat // import androidx.compose.ui.tooling.preview.Preview // import com.google.ai.edge.gallery.ui.theme.GalleryTheme import androidx.compose.foundation.background import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box 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.text.style.TextOverflow import androidx.compose.ui.unit.dp 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, // ) // ) // } // } ================================================ FILE: Android/src/app/src/main/java/com/google/ai/edge/gallery/ui/common/chat/MessageBodyCollapsableProgressPanel.kt ================================================ /* * Copyright 2026 Google LLC * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * http://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. */ package com.google.ai.edge.gallery.ui.common.chat import androidx.compose.animation.AnimatedContent import androidx.compose.animation.AnimatedVisibility import androidx.compose.animation.expandVertically import androidx.compose.animation.fadeIn import androidx.compose.animation.fadeOut import androidx.compose.animation.shrinkVertically import androidx.compose.animation.slideInVertically import androidx.compose.animation.slideOutVertically import androidx.compose.animation.togetherWith 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.heightIn import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.shape.CircleShape import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.verticalScroll import androidx.compose.material.icons.Icons import androidx.compose.material.icons.filled.KeyboardArrowDown import androidx.compose.material.icons.filled.KeyboardArrowUp import androidx.compose.material3.CircularProgressIndicator import androidx.compose.material3.Icon import androidx.compose.material3.MaterialTheme 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.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip import androidx.compose.ui.platform.LocalDensity import androidx.compose.ui.unit.dp private const val MAX_DESCRIPTION_LINES = 5 /** * A Composable function that displays a rounded rectangle panel with a title and a collapsable * section. */ @Composable fun MessageBodyCollapsableProgressPanel(message: ChatMessageCollapsableProgressPanel) { var isExpanded by remember { mutableStateOf(false) } Column( modifier = Modifier.background(MaterialTheme.colorScheme.surfaceContainerHigh) .clickable { isExpanded = !isExpanded } .fillMaxWidth() ) { // Header Row: Contains the title and the expand/collapse button Row( modifier = Modifier.fillMaxWidth().padding(16.dp), verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.SpaceBetween, ) { Row( modifier = Modifier.weight(1f), verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(12.dp), ) { // Spinner on the most left when loading Box(contentAlignment = Alignment.Center, modifier = Modifier.size(24.dp)) { if (message.inProgress) { CircularProgressIndicator( modifier = Modifier.size(16.dp), strokeWidth = 2.dp, color = MaterialTheme.colorScheme.onSurfaceVariant, ) } else { Icon(message.doneIcon, contentDescription = null, modifier = Modifier.size(24.dp)) } } Box(modifier = Modifier.weight(1f), contentAlignment = Alignment.CenterStart) { // Title. AnimatedContent( targetState = message.title, transitionSpec = { slideInVertically { it } + fadeIn() togetherWith slideOutVertically { -it } + fadeOut() }, ) { curTitle -> Text(text = curTitle, style = MaterialTheme.typography.labelLarge) } } } // Expand/Collapse Button on the right side Icon( imageVector = if (isExpanded) Icons.Filled.KeyboardArrowUp else Icons.Filled.KeyboardArrowDown, contentDescription = if (isExpanded) "Collapse panel" else "Expand panel", ) } // Collapsable Content: Shown only when isExpanded is true AnimatedVisibility( visible = isExpanded, enter = expandVertically(), exit = shrinkVertically(), ) { Column( modifier = Modifier.padding(horizontal = 16.dp).padding(bottom = 16.dp), verticalArrangement = Arrangement.spacedBy(12.dp), ) { for (item in message.items) { Row( modifier = Modifier.clip(shape = RoundedCornerShape(12.dp)) .background(MaterialTheme.colorScheme.surfaceContainerLow) .padding(12.dp) .fillMaxWidth(), verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(12.dp), ) { // A colored dot. Box( modifier = Modifier.size(12.dp) .clip(shape = CircleShape) .background(MaterialTheme.colorScheme.secondaryContainer) ) Column() { // Title. Text( item.title, style = MaterialTheme.typography.labelMedium, modifier = Modifier.padding(bottom = 2.dp), ) // Description. if (item.description.isNotEmpty()) { val density = LocalDensity.current val maxHeight = with(density) { (MaterialTheme.typography.labelMedium.lineHeight * MAX_DESCRIPTION_LINES).toDp() } Text( item.description, style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant, modifier = Modifier.heightIn(max = maxHeight).verticalScroll(rememberScrollState()), ) } } } } } } } } ================================================ FILE: Android/src/app/src/main/java/com/google/ai/edge/gallery/ui/common/chat/MessageBodyConfigUpdate.kt ================================================ /* * Copyright 2025 Google LLC * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * http://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. */ package com.google.ai.edge.gallery.ui.common.chat import 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.layout.widthIn import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.remember import androidx.compose.ui.Modifier import androidx.compose.ui.draw.alpha import androidx.compose.ui.draw.clip import androidx.compose.ui.platform.LocalDensity import androidx.compose.ui.platform.LocalWindowInfo import androidx.compose.ui.text.buildAnnotatedString import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.text.withStyle import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp import com.google.ai.edge.gallery.data.convertValueToTargetType import com.google.ai.edge.gallery.data.getConfigValueString import com.google.ai.edge.gallery.ui.theme.bodySmallNarrow import com.google.ai.edge.gallery.ui.theme.titleSmaller private data class ConfigRowData( val label: String, val oldValueDisplay: String, val newValueDisplay: String, val isChanged: Boolean, ) /** * 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) { val density = LocalDensity.current val windowInfo = LocalWindowInfo.current val screenWidthDp = remember { with(density) { windowInfo.containerSize.width.toDp() } } val configRows = remember(message) { val oldValues = message.oldValues val newValues = message.newValues val commonKeys = oldValues.keys.intersect(newValues.keys) commonKeys.mapNotNull { key -> val config = message.model.configs.firstOrNull { it.key.label == key } ?: return@mapNotNull null val oldValue: Any = convertValueToTargetType( value = message.oldValues.getValue(key), valueType = config.valueType, ) val newValue: Any = convertValueToTargetType( value = message.newValues.getValue(key), valueType = config.valueType, ) val isChanged = oldValue != newValue val oldValueDisplay = getConfigValueString(oldValue, config) val newValueDisplay = getConfigValueString(newValue, config) ConfigRowData(key, oldValueDisplay, newValueDisplay, isChanged) } } 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(modifier = Modifier.widthIn(max = screenWidthDp / 2)) { for (rowData in configRows) { Text( "${rowData.label}:", style = bodySmallNarrow, modifier = Modifier.alpha(0.6f), maxLines = 1, overflow = TextOverflow.MiddleEllipsis, ) } } Spacer(modifier = Modifier.width(4.dp)) // Values Column { for (rowData in configRows) { if (!rowData.isChanged) { Text(rowData.newValueDisplay, style = bodySmallNarrow, maxLines = 1) } else { val annotatedString = buildAnnotatedString { withStyle(style = bodySmallNarrow.toSpanStyle()) { append(rowData.oldValueDisplay) } withStyle(style = bodySmallNarrow.copy(fontSize = 12.sp).toSpanStyle()) { append(" ▸ ") // Added spaces for visual separation } withStyle( style = bodySmallNarrow .copy(fontWeight = FontWeight.Bold) .toSpanStyle() .copy(color = MaterialTheme.colorScheme.primary) ) { append(rowData.newValueDisplay) } } Text(annotatedString, maxLines = 1, lineHeight = 12.sp) } } } } } } } } ================================================ FILE: Android/src/app/src/main/java/com/google/ai/edge/gallery/ui/common/chat/MessageBodyError.kt ================================================ /* * Copyright 2025 Google LLC * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * http://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. */ package com.google.ai.edge.gallery.ui.common.chat // import androidx.compose.ui.tooling.preview.Preview // import com.google.ai.edge.gallery.ui.theme.GalleryTheme import androidx.compose.foundation.background import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box 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.unit.dp import com.google.ai.edge.gallery.ui.common.MarkdownText import com.google.ai.edge.gallery.ui.theme.customColors /** * Composable function to display error message content within a chat. * * Supports markdown. */ @Composable fun MessageBodyError(message: ChatMessageError) { Row(modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.Center) { Box( modifier = Modifier.clip(RoundedCornerShape(16.dp)) .background(MaterialTheme.customColors.errorContainerColor) ) { MarkdownText( text = message.content, modifier = Modifier.padding(horizontal = 16.dp, vertical = 6.dp), smallFontSize = true, textColor = MaterialTheme.customColors.errorTextColor, ) } } } ================================================ FILE: Android/src/app/src/main/java/com/google/ai/edge/gallery/ui/common/chat/MessageBodyImage.kt ================================================ /* * Copyright 2025 Google LLC * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * http://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. */ package com.google.ai.edge.gallery.ui.common.chat import android.graphics.Bitmap import androidx.compose.foundation.Image import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.width import androidx.compose.runtime.Composable import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.layout.ContentScale import androidx.compose.ui.res.stringResource import androidx.compose.ui.unit.dp import com.google.ai.edge.gallery.R import kotlin.math.ceil @Composable fun MessageBodyImage( message: ChatMessageImage, onImageClicked: (bitmaps: List, selectedBitmapIndex: Int) -> Unit, modifier: Modifier = Modifier, ) { val imageCount = message.bitmaps.size // Single image. if (imageCount == 1) { val bitmap = message.bitmaps[0] val imageBitMap = message.imageBitMaps[0] val bitmapWidth = bitmap.width val bitmapHeight = bitmap.height val maxSize = message.maxSize var imageWidth = bitmapWidth var imageHeight = bitmapHeight if (imageWidth >= maxSize || imageHeight >= maxSize) { imageWidth = if (bitmapWidth >= bitmapHeight) maxSize else (maxSize.toFloat() / bitmapHeight * bitmapWidth).toInt() imageHeight = if (bitmapHeight >= bitmapWidth) maxSize else (maxSize.toFloat() / bitmapWidth * bitmapHeight).toInt() } Image( bitmap = imageBitMap, contentDescription = stringResource(R.string.cd_user_image), modifier = modifier.height(imageHeight.dp).width(imageWidth.dp).clickable { onImageClicked(message.bitmaps, 0) }, contentScale = ContentScale.Fit, ) } // Multiple images. // // Lay them out in a grid. else { var colCount = 3 if (imageCount == 4) { colCount = 2 } val rowCount = ceil(imageCount.toFloat() / colCount).toInt() Column(horizontalAlignment = Alignment.End, verticalArrangement = Arrangement.spacedBy(2.dp)) { for (row in 0..= imageCount) { return@Row } val imageBitMap = message.imageBitMaps[imageIndex] Image( bitmap = imageBitMap, contentDescription = stringResource(R.string.cd_user_image_in_group, imageIndex + 1, imageCount), modifier = Modifier.height(100.dp).width(100.dp).clickable { onImageClicked(message.bitmaps, imageIndex) }, contentScale = ContentScale.Crop, ) } } } } } } ================================================ FILE: Android/src/app/src/main/java/com/google/ai/edge/gallery/ui/common/chat/MessageBodyImageWithHistory.kt ================================================ /* * Copyright 2025 Google LLC * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * http://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. */ package com.google.ai.edge.gallery.ui.common.chat import 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 = null, 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, ) } } ================================================ FILE: Android/src/app/src/main/java/com/google/ai/edge/gallery/ui/common/chat/MessageBodyInfo.kt ================================================ /* * Copyright 2025 Google LLC * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * http://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. */ package com.google.ai.edge.gallery.ui.common.chat // import androidx.compose.ui.tooling.preview.Preview // import com.google.ai.edge.gallery.ui.theme.GalleryTheme import androidx.compose.foundation.background import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box 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.unit.dp import com.google.ai.edge.gallery.ui.common.MarkdownText import com.google.ai.edge.gallery.ui.theme.customColors /** * Composable function to display informational message content within a chat. * * Supports markdown. */ @Composable fun MessageBodyInfo(message: ChatMessageInfo, smallFontSize: Boolean = true) { 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 = smallFontSize, ) } } } // @Preview(showBackground = true) // @Composable // fun MessageBodyInfoPreview() { // GalleryTheme { // Row(modifier = Modifier.padding(16.dp)) { // MessageBodyInfo(message = ChatMessageInfo(content = "This is a model")) // } // } // } ================================================ FILE: Android/src/app/src/main/java/com/google/ai/edge/gallery/ui/common/chat/MessageBodyLoading.kt ================================================ /* * Copyright 2025 Google LLC * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * http://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. */ package com.google.ai.edge.gallery.ui.common.chat import androidx.compose.animation.AnimatedContent import androidx.compose.animation.core.LinearEasing import androidx.compose.animation.core.RepeatMode import androidx.compose.animation.core.animateFloat import androidx.compose.animation.core.infiniteRepeatable import androidx.compose.animation.core.rememberInfiniteTransition import androidx.compose.animation.core.tween import androidx.compose.animation.fadeIn import androidx.compose.animation.fadeOut import androidx.compose.animation.togetherWith import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.width import androidx.compose.material.icons.Icons import androidx.compose.material.icons.rounded.HomeRepairService import androidx.compose.material3.Icon import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.graphicsLayer import androidx.compose.ui.unit.dp import com.google.ai.edge.gallery.ui.common.RotationalLoader /** Composable function to display a loading indicator. */ @Composable fun MessageBodyLoading(message: ChatMessageLoading? = null) { val infiniteTransition = rememberInfiniteTransition(label = "icon-flash") val iconAlpha by infiniteTransition.animateFloat( initialValue = 0.3f, targetValue = 1f, animationSpec = infiniteRepeatable( // Duration of one phase (1 second) animation = tween(1000, easing = LinearEasing), // Reverse back to start for a "breathing" effect repeatMode = RepeatMode.Reverse, ), label = "icon-alpha", ) Row( horizontalArrangement = Arrangement.SpaceBetween, verticalAlignment = Alignment.CenterVertically, modifier = Modifier.fillMaxWidth(), ) { RotationalLoader(size = 32.dp) if (message?.extraProgressLabel?.isNotEmpty() == true) { AnimatedContent( message.extraProgressLabel, transitionSpec = { fadeIn() togetherWith fadeOut() }, ) { label -> Row( verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(6.dp), ) { Icon( Icons.Rounded.HomeRepairService, contentDescription = null, modifier = Modifier.graphicsLayer { alpha = iconAlpha }.size(16.dp), tint = MaterialTheme.colorScheme.primary, ) Text( label, style = MaterialTheme.typography.labelSmall, color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.8f), ) } } } else { Spacer(modifier = Modifier.width(1.dp)) } } } ================================================ FILE: Android/src/app/src/main/java/com/google/ai/edge/gallery/ui/common/chat/MessageBodyPromptTemplates.kt ================================================ /* * Copyright 2025 Google LLC * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * http://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. */ package com.google.ai.edge.gallery.ui.common.chat // import androidx.compose.ui.tooling.preview.Preview // import com.google.ai.edge.gallery.ui.preview.ALL_PREVIEW_TASKS // import com.google.ai.edge.gallery.ui.preview.TASK_TEST1 // import com.google.ai.edge.gallery.ui.theme.GalleryTheme import androidx.compose.foundation.background import androidx.compose.foundation.border import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.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.unit.dp import com.google.ai.edge.gallery.data.PromptTemplate import com.google.ai.edge.gallery.data.Task import com.google.ai.edge.gallery.ui.common.getTaskIconColor 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() // } // } // 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, // ) // } // } // } ================================================ FILE: Android/src/app/src/main/java/com/google/ai/edge/gallery/ui/common/chat/MessageBodyText.kt ================================================ /* * Copyright 2025 Google LLC * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * http://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. */ package com.google.ai.edge.gallery.ui.common.chat // import com.google.ai.edge.gallery.ui.theme.GalleryTheme // import androidx.compose.ui.tooling.preview.Preview import androidx.compose.foundation.layout.padding import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color import androidx.compose.ui.res.stringResource import androidx.compose.ui.semantics.LiveRegionMode import androidx.compose.ui.semantics.contentDescription import androidx.compose.ui.semantics.liveRegion import androidx.compose.ui.semantics.semantics import androidx.compose.ui.unit.dp import com.google.ai.edge.gallery.R import com.google.ai.edge.gallery.ui.common.MarkdownText /** Composable function to display the text content of a ChatMessageText. */ @Composable fun MessageBodyText(message: ChatMessageText, inProgress: Boolean) { if (message.side == ChatSide.USER) { MarkdownText( text = message.content, modifier = Modifier.padding(12.dp), textColor = Color.White, linkColor = Color.White, ) } else if (message.side == ChatSide.AGENT) { val cdResponse = stringResource(R.string.cd_model_response_text) if (message.isMarkdown) { MarkdownText( text = message.content, modifier = Modifier.padding(12.dp).semantics(mergeDescendants = true) { contentDescription = cdResponse // Only announce when message is complete. if (!inProgress) { liveRegion = LiveRegionMode.Polite } }, ) } else { Text( message.content, style = MaterialTheme.typography.bodyMedium, color = MaterialTheme.colorScheme.onSurface, modifier = Modifier.padding(12.dp).semantics { contentDescription = cdResponse // Only announce when message is complete. if (!inProgress) { liveRegion = LiveRegionMode.Polite } }, ) } } } // @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)) // } // } // } // } ================================================ FILE: Android/src/app/src/main/java/com/google/ai/edge/gallery/ui/common/chat/MessageBodyWarning.kt ================================================ /* * Copyright 2025 Google LLC * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * http://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. */ package com.google.ai.edge.gallery.ui.common.chat // import androidx.compose.ui.tooling.preview.Preview // import com.google.ai.edge.gallery.ui.theme.GalleryTheme import androidx.compose.foundation.background import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box 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.unit.dp import com.google.ai.edge.gallery.ui.common.MarkdownText import com.google.ai.edge.gallery.ui.theme.customColors /** * Composable function to display warning message content within a chat. * * Supports markdown. */ @Composable fun MessageBodyWarning(message: ChatMessageWarning) { Row(modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.Center) { Box( modifier = Modifier.clip(RoundedCornerShape(16.dp)) .background(MaterialTheme.customColors.warningContainerColor) ) { MarkdownText( text = message.content, modifier = Modifier.padding(horizontal = 16.dp, vertical = 6.dp), smallFontSize = true, textColor = MaterialTheme.customColors.warningTextColor, ) } } } // @Preview(showBackground = true) // @Composable // fun MessageBodyWarningPreview() { // GalleryTheme { // Row(modifier = Modifier.padding(16.dp)) { // MessageBodyWarning(message = ChatMessageWarning(content = "This is a warning")) // } // } // } ================================================ FILE: Android/src/app/src/main/java/com/google/ai/edge/gallery/ui/common/chat/MessageBodyWebview.kt ================================================ /* * Copyright 2026 Google LLC * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * http://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. */ package com.google.ai.edge.gallery.ui.common.chat import android.util.Log import android.webkit.ConsoleMessage import androidx.compose.foundation.layout.aspectRatio import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.runtime.Composable import androidx.compose.ui.Modifier import com.google.ai.edge.gallery.ui.common.GalleryWebView private const val TAG = "AGMessageBodyWebview" /** A Composable that displays a WebView to render web content within a chat message. */ @Composable fun MessageBodyWebview(message: ChatMessageWebView, modifier: Modifier = Modifier) { GalleryWebView( modifier = modifier.fillMaxWidth().aspectRatio(4f / 3f), initialUrl = message.url, useIframeWrapper = message.iframe, preventParentScrolling = true, allowRequestPermission = true, onConsoleMessage = { consoleMessage: ConsoleMessage? -> Log.d( TAG, "${consoleMessage?.message()} -- From line ${consoleMessage?.lineNumber()} of ${consoleMessage?.sourceId()}", ) true // Return true to indicate the message was handled. }, ) } ================================================ FILE: Android/src/app/src/main/java/com/google/ai/edge/gallery/ui/common/chat/MessageBubbleShape.kt ================================================ /* * Copyright 2025 Google LLC * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * http://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. */ package com.google.ai.edge.gallery.ui.common.chat import 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) } } ================================================ FILE: Android/src/app/src/main/java/com/google/ai/edge/gallery/ui/common/chat/MessageInputText.kt ================================================ /* * Copyright 2025 Google LLC * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * http://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. */ package com.google.ai.edge.gallery.ui.common.chat import android.Manifest import android.content.Context import android.content.Intent import android.content.pm.PackageManager import android.graphics.Bitmap import android.graphics.Matrix import android.hardware.Sensor import android.hardware.SensorEvent import android.hardware.SensorEventListener import android.hardware.SensorManager import android.net.Uri import android.util.Log import android.util.Size import androidx.activity.compose.rememberLauncherForActivityResult import androidx.activity.result.PickVisualMediaRequest import androidx.activity.result.contract.ActivityResultContracts import androidx.annotation.StringRes import androidx.camera.core.CameraControl import androidx.camera.core.CameraSelector import androidx.camera.core.ImageCapture import androidx.camera.core.ImageProxy import androidx.camera.core.resolutionselector.AspectRatioStrategy import androidx.camera.core.resolutionselector.ResolutionSelector import androidx.camera.core.resolutionselector.ResolutionStrategy import androidx.camera.lifecycle.ProcessCameraProvider import androidx.camera.lifecycle.awaitInstance import androidx.camera.view.PreviewView import androidx.compose.animation.AnimatedContent import androidx.compose.foundation.Image import androidx.compose.foundation.background import androidx.compose.foundation.border import androidx.compose.foundation.clickable import androidx.compose.foundation.horizontalScroll import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.heightIn import androidx.compose.foundation.layout.offset import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.width import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.shape.CircleShape import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material.icons.Icons import androidx.compose.material.icons.automirrored.rounded.Send import androidx.compose.material.icons.outlined.AddPhotoAlternate import androidx.compose.material.icons.outlined.HomeRepairService import androidx.compose.material.icons.outlined.MusicNote import androidx.compose.material.icons.rounded.AudioFile import androidx.compose.material.icons.rounded.Close import androidx.compose.material.icons.rounded.FlipCameraAndroid import androidx.compose.material.icons.rounded.History import androidx.compose.material.icons.rounded.Mic import androidx.compose.material.icons.rounded.Photo import androidx.compose.material.icons.rounded.PhotoCamera import androidx.compose.material.icons.rounded.Stop import androidx.compose.material3.AssistChip import androidx.compose.material3.AssistChipDefaults 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.ModalBottomSheet import androidx.compose.material3.Text import androidx.compose.material3.TextField import androidx.compose.material3.TextFieldDefaults import androidx.compose.material3.rememberModalBottomSheetState import androidx.compose.runtime.Composable import androidx.compose.runtime.DisposableEffect import androidx.compose.runtime.LaunchedEffect 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.draw.shadow import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.asImageBitmap import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.res.stringResource import androidx.compose.ui.semantics.contentDescription import androidx.compose.ui.semantics.semantics import androidx.compose.ui.unit.dp import androidx.compose.ui.viewinterop.AndroidView import androidx.core.content.ContextCompat import androidx.core.graphics.scale import androidx.exifinterface.media.ExifInterface import androidx.lifecycle.DefaultLifecycleObserver import androidx.lifecycle.LifecycleOwner import androidx.lifecycle.compose.LocalLifecycleOwner import com.google.ai.edge.gallery.R import com.google.ai.edge.gallery.common.AudioClip import com.google.ai.edge.gallery.common.convertWavToMonoWithMaxSeconds import com.google.ai.edge.gallery.common.decodeSampledBitmapFromUri import com.google.ai.edge.gallery.common.rotateBitmap import com.google.ai.edge.gallery.data.MAX_AUDIO_CLIP_COUNT import com.google.ai.edge.gallery.data.MAX_IMAGE_COUNT import com.google.ai.edge.gallery.data.SAMPLE_RATE import com.google.ai.edge.gallery.data.Task import com.google.ai.edge.gallery.ui.common.getTaskIconColor import com.google.ai.edge.gallery.ui.modelmanager.ModelManagerViewModel import com.google.ai.edge.gallery.ui.theme.bodyLargeNarrow import java.io.FileInputStream import java.util.concurrent.Executors import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.launch private const val TAG = "AGMessageInputText" /** * Composable function to display a text input field for composing chat messages. * * This function renders a row containing a text field for message input and a send button. It * handles message composition, input validation, and sending messages. */ @OptIn(ExperimentalMaterial3Api::class) @Composable fun MessageInputText( task: Task, modelManagerViewModel: ModelManagerViewModel, curMessage: String, isResettingSession: Boolean, inProgress: Boolean, imageCount: Int, audioClipMessageCount: Int, modelInitializing: Boolean, @StringRes textFieldPlaceHolderRes: Int, onValueChanged: (String) -> Unit, onSendMessage: (List) -> Unit, modelPreparing: Boolean = false, onOpenPromptTemplatesClicked: () -> Unit = {}, onStopButtonClicked: () -> Unit = {}, onSetAudioRecorderVisible: (visible: Boolean) -> Unit = {}, onAmplitudeChanged: (Int) -> Unit, showPromptTemplatesInMenu: Boolean = false, showImagePicker: Boolean = false, showAudioPicker: Boolean = false, showStopButtonWhenInProgress: Boolean = false, ) { val context = LocalContext.current val lifecycleOwner = LocalLifecycleOwner.current val scope = rememberCoroutineScope() val modelManagerUiState by modelManagerViewModel.uiState.collectAsState() var showAddImageMenu by remember { mutableStateOf(false) } var showAddAudioMenu by remember { mutableStateOf(false) } var showTextInputHistorySheet by remember { mutableStateOf(false) } var showCameraCaptureBottomSheet by remember { mutableStateOf(false) } val cameraCaptureSheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true) var showAudioRecorder by remember { mutableStateOf(false) } val audioRecorderSheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true) var pickedImages by remember { mutableStateOf>(listOf()) } var pickedAudioClips by remember { mutableStateOf>(listOf()) } var hasFrontCamera by remember { mutableStateOf(false) } val sensorObserver = remember { SensorObserver(context) } val updatePickedImages: (List) -> Unit = { bitmaps -> var newPickedImages: MutableList = mutableListOf() newPickedImages.addAll(pickedImages) newPickedImages.addAll(bitmaps) if (newPickedImages.size > MAX_IMAGE_COUNT) { newPickedImages = newPickedImages.subList(fromIndex = 0, toIndex = MAX_IMAGE_COUNT) } pickedImages = newPickedImages.toList() } val updatePickedAudioClips: (List) -> Unit = { audioDataList -> var newAudioDataList: MutableList = mutableListOf() newAudioDataList.addAll(pickedAudioClips) newAudioDataList.addAll(audioDataList) if (newAudioDataList.size > MAX_AUDIO_CLIP_COUNT) { newAudioDataList = newAudioDataList.subList(fromIndex = 0, toIndex = MAX_AUDIO_CLIP_COUNT) } pickedAudioClips = newAudioDataList.toList() } LaunchedEffect(Unit) { checkFrontCamera(context = context, callback = { hasFrontCamera = it }) } // Permission request when taking picture. val takePicturePermissionLauncher = rememberLauncherForActivityResult(ActivityResultContracts.RequestPermission()) { permissionGranted -> if (permissionGranted) { showAddImageMenu = false showCameraCaptureBottomSheet = true } } val handleClickRecordAudioClip = { showAddAudioMenu = false showAudioRecorder = true onSetAudioRecorderVisible(true) } // Permission request when recording audio clips. val recordAudioClipsPermissionLauncher = rememberLauncherForActivityResult(ActivityResultContracts.RequestPermission()) { permissionGranted -> if (permissionGranted) { handleClickRecordAudioClip() } } // Registers a photo picker activity launcher in single-select mode. val pickMedia = rememberLauncherForActivityResult(ActivityResultContracts.PickMultipleVisualMedia()) { uris -> // Callback is invoked after the user selects media items or closes the // photo picker. if (uris.isNotEmpty()) { scope.launch(Dispatchers.IO) { handleImagesSelected( context = context, uris = uris, onImagesSelected = { bitmaps -> updatePickedImages(bitmaps) }, ) } } } val pickWav = rememberLauncherForActivityResult( contract = ActivityResultContracts.StartActivityForResult() ) { result -> if (result.resultCode == android.app.Activity.RESULT_OK) { result.data?.data?.let { uri -> Log.d(TAG, "Picked wav file: $uri") scope.launch(Dispatchers.IO) { handleAudioWavSelected( context = context, uri = uri, onAudioSelected = { audioClip -> updatePickedAudioClips( listOf( AudioClip(audioData = audioClip.audioData, sampleRate = audioClip.sampleRate) ) ) }, ) } } } else { Log.d(TAG, "Wav picking cancelled.") } } DisposableEffect(lifecycleOwner) { lifecycleOwner.lifecycle.addObserver(sensorObserver) onDispose { lifecycleOwner.lifecycle.removeObserver(sensorObserver) } } Column { // A preview panel for the selected images and audio clips. if (pickedImages.isNotEmpty() || pickedAudioClips.isNotEmpty()) { Row( modifier = Modifier.fillMaxWidth().horizontalScroll(rememberScrollState()), horizontalArrangement = Arrangement.spacedBy(16.dp), ) { Spacer(modifier = Modifier.width(16.dp)) for (image in pickedImages) { Box(contentAlignment = Alignment.TopEnd) { Image( bitmap = image.asImageBitmap(), contentDescription = stringResource(R.string.cd_image_thumbnail), modifier = Modifier.height(80.dp) .shadow(2.dp, shape = RoundedCornerShape(8.dp)) .clip(RoundedCornerShape(8.dp)) .border(1.dp, MaterialTheme.colorScheme.outline, RoundedCornerShape(8.dp)), ) MediaPanelCloseButton { pickedImages = pickedImages.filter { image != it } } } } for ((index, audioClip) in pickedAudioClips.withIndex()) { Box(contentAlignment = Alignment.TopEnd) { Box( modifier = Modifier.shadow(2.dp, shape = RoundedCornerShape(8.dp)) .clip(RoundedCornerShape(8.dp)) .background(MaterialTheme.colorScheme.surface) .border(1.dp, MaterialTheme.colorScheme.outline, RoundedCornerShape(8.dp)) ) { AudioPlaybackPanel( audioData = audioClip.audioData, sampleRate = audioClip.sampleRate, isRecording = false, modifier = Modifier.padding(end = 16.dp), ) } MediaPanelCloseButton { pickedAudioClips = pickedAudioClips.filterIndexed { curIndex, curAudioData -> curIndex != index } } } } Spacer(modifier = Modifier.width(16.dp)) } } Box(contentAlignment = Alignment.Center, modifier = Modifier.heightIn(min = 76.dp)) { AnimatedContent(targetState = showAudioRecorder) { curShowAudioRecorder -> when (curShowAudioRecorder) { // Input false -> Column( modifier = Modifier.padding(horizontal = 12.dp) .padding(vertical = 8.dp) .border(1.dp, MaterialTheme.colorScheme.outlineVariant, RoundedCornerShape(16.dp)) ) { // Text field. Row( modifier = Modifier.fillMaxWidth(), verticalAlignment = Alignment.CenterVertically, ) { // Text field. val cdPromptInput = stringResource(R.string.cd_prompt_input_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 = bodyLargeNarrow, modifier = Modifier.weight(1f).semantics { contentDescription = cdPromptInput }, placeholder = { Text(stringResource(textFieldPlaceHolderRes)) }, ) Spacer(modifier = Modifier.width(8.dp)) if (inProgress && showStopButtonWhenInProgress) { if (!modelInitializing && !modelPreparing) { IconButton( onClick = onStopButtonClicked, colors = IconButtonDefaults.iconButtonColors( containerColor = MaterialTheme.colorScheme.secondaryContainer ), ) { Icon( Icons.Rounded.Stop, contentDescription = stringResource(R.string.cd_stop_icon), tint = MaterialTheme.colorScheme.primary, ) } } } // Send button. Only shown when text is not empty. else if (curMessage.isNotEmpty()) { IconButton( enabled = !inProgress && !isResettingSession, onClick = { var message = curMessage.trim() onSendMessage( createMessagesToSend( pickedImages = pickedImages, audioClips = pickedAudioClips, text = message, ) ) pickedImages = listOf() pickedAudioClips = listOf() }, colors = IconButtonDefaults.iconButtonColors( containerColor = getTaskIconColor(task = task) ), ) { Icon( Icons.AutoMirrored.Rounded.Send, contentDescription = stringResource(R.string.cd_send_prompt_icon), modifier = Modifier.offset(x = 2.dp), tint = Color.White, ) } } Spacer(modifier = Modifier.width(4.dp)) } // Second row for buttons to add extra content. Row( modifier = Modifier.fillMaxWidth().padding(horizontal = 12.dp).offset(y = (-4).dp), verticalAlignment = Alignment.Top, horizontalArrangement = Arrangement.spacedBy(4.dp), ) { // Add image. if (showImagePicker) { val enableAddImageMenuItems = (imageCount + pickedImages.size) < MAX_IMAGE_COUNT Box() { AssistChip( onClick = { showAddImageMenu = true }, label = { Text(stringResource(R.string.add_image)) }, enabled = !inProgress, leadingIcon = { Icon( Icons.Outlined.AddPhotoAlternate, contentDescription = null, Modifier.size(AssistChipDefaults.IconSize), ) }, ) // Menu shown when add image chip above is clicked. DropdownMenu( expanded = showAddImageMenu, onDismissRequest = { showAddImageMenu = false }, ) { // Take a picture. DropdownMenuItem( text = { Row( verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(6.dp), ) { Icon(Icons.Rounded.PhotoCamera, contentDescription = null) Text("Take a picture") } }, enabled = enableAddImageMenuItems, onClick = { // Check permission when (PackageManager.PERMISSION_GRANTED) { // Already got permission. Call the lambda. ContextCompat.checkSelfPermission( context, Manifest.permission.CAMERA, ) -> { showAddImageMenu = false showCameraCaptureBottomSheet = true } // Otherwise, ask for permission else -> { takePicturePermissionLauncher.launch(Manifest.permission.CAMERA) } } }, ) // Pick an image from album. DropdownMenuItem( text = { Row( verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(6.dp), ) { Icon(Icons.Rounded.Photo, contentDescription = null) Text("Pick from album") } }, enabled = enableAddImageMenuItems, onClick = { // Launch the photo picker and let the user choose only images. pickMedia.launch( PickVisualMediaRequest( ActivityResultContracts.PickVisualMedia.ImageOnly ) ) showAddImageMenu = false }, ) } } } // Add audio. if (showAudioPicker) { val enableRecordAudioClipMenuItems = (audioClipMessageCount + pickedAudioClips.size) < MAX_AUDIO_CLIP_COUNT Box() { AssistChip( onClick = { showAddAudioMenu = true }, label = { Text(stringResource(R.string.add_audio)) }, enabled = !inProgress, leadingIcon = { Icon( Icons.Outlined.MusicNote, contentDescription = null, Modifier.size(AssistChipDefaults.IconSize), ) }, ) // Menu shown when add audio chip above is clicked. DropdownMenu( expanded = showAddAudioMenu, onDismissRequest = { showAddAudioMenu = false }, ) { DropdownMenuItem( text = { Row( verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(6.dp), ) { Icon(Icons.Rounded.Mic, contentDescription = null) Text("Record audio clip") } }, enabled = enableRecordAudioClipMenuItems, onClick = { // Check permission when (PackageManager.PERMISSION_GRANTED) { // Already got permission. Call the lambda. ContextCompat.checkSelfPermission( context, Manifest.permission.RECORD_AUDIO, ) -> { handleClickRecordAudioClip() } // Otherwise, ask for permission else -> { recordAudioClipsPermissionLauncher.launch( Manifest.permission.RECORD_AUDIO ) } } }, ) DropdownMenuItem( text = { Row( verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(6.dp), ) { Icon(Icons.Rounded.AudioFile, contentDescription = null) Text("Pick wav file") } }, enabled = enableRecordAudioClipMenuItems, onClick = { showAddAudioMenu = false // Show file picker. val intent = Intent(Intent.ACTION_GET_CONTENT).apply { addCategory(Intent.CATEGORY_OPENABLE) type = "audio/*" // Provide a list of more specific MIME types to filter for. val mimeTypes = arrayOf("audio/wav", "audio/x-wav") putExtra(Intent.EXTRA_MIME_TYPES, mimeTypes) // Single select. putExtra(Intent.EXTRA_ALLOW_MULTIPLE, false) .addFlags(Intent.FLAG_GRANT_PERSISTABLE_URI_PERMISSION) .addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION) } pickWav.launch(intent) }, ) } } } // Input history. AssistChip( onClick = { showTextInputHistorySheet = true }, label = { Text(stringResource(R.string.input_history)) }, enabled = !inProgress, leadingIcon = { Icon( Icons.Rounded.History, contentDescription = null, Modifier.size(AssistChipDefaults.IconSize), ) }, ) } } // Audio recorder. true -> AudioRecorderPanel( task = task, onSendAudioClip = { audioData -> scope.launch { updatePickedAudioClips( listOf(AudioClip(audioData = audioData, sampleRate = SAMPLE_RATE)) ) audioRecorderSheetState.hide() showAudioRecorder = false onSetAudioRecorderVisible(false) } }, onAmplitudeChanged = onAmplitudeChanged, onClose = { showAudioRecorder = false onSetAudioRecorderVisible(false) }, ) } } } } // A bottom sheet to show the text input history to pick from. if (showTextInputHistorySheet) { TextInputHistorySheet( history = modelManagerUiState.textInputHistory, onDismissed = { showTextInputHistorySheet = false }, onHistoryItemClicked = { item -> onSendMessage( createMessagesToSend( pickedImages = pickedImages, audioClips = pickedAudioClips, text = item, ) ) pickedImages = listOf() pickedAudioClips = listOf() modelManagerViewModel.promoteTextInputHistoryItem(item) }, onHistoryItemDeleted = { item -> modelManagerViewModel.deleteTextInputHistory(item) }, onHistoryItemsDeleteAll = { modelManagerViewModel.clearTextInputHistory() }, ) } if (showCameraCaptureBottomSheet) { ModalBottomSheet( sheetState = cameraCaptureSheetState, onDismissRequest = { showCameraCaptureBottomSheet = false }, ) { val lifecycleOwner = LocalLifecycleOwner.current val previewUseCase = remember { androidx.camera.core.Preview.Builder().build() } val imageCaptureUseCase = remember { // Try to limit the image size. val preferredSize = Size(512, 512) val resolutionStrategy = ResolutionStrategy( preferredSize, ResolutionStrategy.FALLBACK_RULE_CLOSEST_HIGHER_THEN_LOWER, ) val resolutionSelector = ResolutionSelector.Builder() .setResolutionStrategy(resolutionStrategy) .setAspectRatioStrategy(AspectRatioStrategy.RATIO_4_3_FALLBACK_AUTO_STRATEGY) .build() ImageCapture.Builder().setResolutionSelector(resolutionSelector).build() } var cameraProvider by remember { mutableStateOf(null) } var cameraControl by remember { mutableStateOf(null) } val localContext = LocalContext.current var cameraSide by remember { mutableIntStateOf(CameraSelector.LENS_FACING_BACK) } val executor = remember { Executors.newSingleThreadExecutor() } fun rebindCameraProvider() { cameraProvider?.let { cameraProvider -> val cameraSelector = CameraSelector.Builder().requireLensFacing(cameraSide).build() try { cameraProvider.unbindAll() val camera = cameraProvider.bindToLifecycle( lifecycleOwner = lifecycleOwner, cameraSelector = cameraSelector, previewUseCase, imageCaptureUseCase, ) cameraControl = camera.cameraControl } catch (e: Exception) { Log.d(TAG, "Failed to bind camera", e) } } } LaunchedEffect(Unit) { cameraProvider = ProcessCameraProvider.awaitInstance(localContext) rebindCameraProvider() } LaunchedEffect(cameraSide) { rebindCameraProvider() } DisposableEffect(Unit) { // Or key on lifecycleOwner if it makes more sense onDispose { cameraProvider?.unbindAll() // Unbind all use cases from the camera provider if (!executor.isShutdown) { executor.shutdown() // Shut down the executor service } } } Box(modifier = Modifier.fillMaxSize()) { // PreviewView for the camera feed. AndroidView( modifier = Modifier.fillMaxSize(), factory = { ctx -> PreviewView(ctx).also { previewUseCase.surfaceProvider = it.surfaceProvider rebindCameraProvider() } }, ) // Close button. IconButton( onClick = { scope.launch { cameraCaptureSheetState.hide() showCameraCaptureBottomSheet = false } }, colors = IconButtonDefaults.iconButtonColors( containerColor = MaterialTheme.colorScheme.surfaceVariant ), modifier = Modifier.offset(x = (-8).dp, y = 8.dp).align(Alignment.TopEnd), ) { Icon( Icons.Rounded.Close, contentDescription = stringResource(R.string.cd_close_icon), tint = MaterialTheme.colorScheme.primary, ) } // Button that triggers the image capture process IconButton( colors = IconButtonDefaults.iconButtonColors(containerColor = MaterialTheme.colorScheme.primary), modifier = Modifier.align(Alignment.BottomCenter) .padding(bottom = 32.dp) .size(size = 64.dp) .border(width = 2.dp, color = MaterialTheme.colorScheme.onPrimary, CircleShape), onClick = { val callback = object : ImageCapture.OnImageCapturedCallback() { override fun onCaptureSuccess(image: ImageProxy) { try { var bitmap = image.toBitmap() val rotation = sensorObserver.currentRotation + image.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 bitmap = resizeBitmap(originalBitmap = bitmap) updatePickedImages(listOf(bitmap)) } catch (e: Exception) { Log.e(TAG, "Failed to process image", e) } finally { image.close() scope.launch { cameraCaptureSheetState.hide() showCameraCaptureBottomSheet = false } } } } imageCaptureUseCase.takePicture(executor, callback) }, ) { Icon( Icons.Rounded.PhotoCamera, contentDescription = stringResource(R.string.cd_camera_shutter_icon), tint = MaterialTheme.colorScheme.onPrimary, modifier = Modifier.size(36.dp), ) } // Button that toggles the front and back camera. if (hasFrontCamera) { IconButton( colors = IconButtonDefaults.iconButtonColors( containerColor = MaterialTheme.colorScheme.secondaryContainer ), modifier = Modifier.align(Alignment.BottomEnd).padding(bottom = 40.dp, end = 32.dp).size(48.dp), onClick = { cameraSide = when (cameraSide) { CameraSelector.LENS_FACING_BACK -> CameraSelector.LENS_FACING_FRONT else -> CameraSelector.LENS_FACING_BACK } }, ) { Icon( Icons.Rounded.FlipCameraAndroid, contentDescription = stringResource(R.string.cd_toggle_front_back_camera_icon), tint = MaterialTheme.colorScheme.onSecondaryContainer, modifier = Modifier.size(24.dp), ) } } } } } } @Composable private fun MediaPanelCloseButton(onClicked: () -> Unit) { Box( modifier = Modifier.offset(x = 10.dp, y = (-10).dp) .clip(CircleShape) .background(MaterialTheme.colorScheme.surface) .border((1.5).dp, MaterialTheme.colorScheme.outline, CircleShape) .clickable { onClicked() } ) { Icon( Icons.Rounded.Close, contentDescription = stringResource(R.string.cd_delete_icon), modifier = Modifier.padding(3.dp).size(16.dp), ) } } private fun handleImagesSelected( context: Context, uris: List, onImagesSelected: (List) -> Unit, ) { val images: MutableList = mutableListOf() for (uri in uris) { val bitmap: Bitmap? = try { val inputStream = if (uri.scheme == null || uri.scheme == "file") { FileInputStream(uri.path ?: "") } else { context.contentResolver.openInputStream(uri) } if (inputStream != null) { // Read the EXIF metadata from the picture and rotate it correctly. val exif = ExifInterface(inputStream) val orientation = exif.getAttributeInt(ExifInterface.TAG_ORIENTATION, ExifInterface.ORIENTATION_NORMAL) // You MUST close the first input stream before opening another one on the same URI. inputStream.close() // The let block will now return the rotated bitmap decodeSampledBitmapFromUri(context, uri, 1024, 1024)?.let { originalBitmap -> rotateBitmap(bitmap = originalBitmap, orientation = orientation) } } else { null } } catch (e: Exception) { e.printStackTrace() null } if (bitmap != null) { images.add(bitmap) } } if (images.isNotEmpty()) { onImagesSelected(images) } } private fun handleAudioWavSelected( context: Context, uri: Uri, onAudioSelected: (AudioClip) -> Unit, ) { convertWavToMonoWithMaxSeconds(context = context, stereoUri = uri)?.let { audioClip -> onAudioSelected(audioClip) } } /** * Resizes a given Bitmap to fit within a square of a specified size, while maintaining its original * aspect ratio. */ private fun resizeBitmap(originalBitmap: Bitmap, size: Int = 1024): Bitmap { val originalWidth = originalBitmap.width val originalHeight = originalBitmap.height // Return the original bitmap if it's already within the specified size. if (originalWidth <= size && originalHeight <= size) { return originalBitmap } val aspectRatio: Float = originalWidth.toFloat() / originalHeight.toFloat() val newWidth: Int val newHeight: Int if (aspectRatio > 1) { // Landscape or square orientation newWidth = size newHeight = (size / aspectRatio).toInt() } else { // Portrait orientation newHeight = size newWidth = (size * aspectRatio).toInt() } Log.d(TAG, "Resizing image from $originalWidth x $originalHeight to $newWidth x $newHeight") // Create a new scaled bitmap using the calculated dimensions return originalBitmap.scale(newWidth, newHeight) } private fun checkFrontCamera(context: Context, callback: (Boolean) -> Unit) { val cameraProviderFuture = ProcessCameraProvider.getInstance(context) cameraProviderFuture.addListener( { val cameraProvider = cameraProviderFuture.get() try { // Attempt to select the default front camera val hasFront = cameraProvider.hasCamera(CameraSelector.DEFAULT_FRONT_CAMERA) callback(hasFront) } catch (e: Exception) { e.printStackTrace() callback(false) } }, ContextCompat.getMainExecutor(context), ) } private fun createMessagesToSend( pickedImages: List, audioClips: List, text: String, ): List { val messages: MutableList = mutableListOf() // Add image message. if (pickedImages.isNotEmpty()) { // Cap the number of image messages. var curPickedImages = pickedImages.toList() if (curPickedImages.size > MAX_IMAGE_COUNT) { curPickedImages = curPickedImages.subList(fromIndex = 0, toIndex = MAX_IMAGE_COUNT) } messages.add( ChatMessageImage( bitmaps = curPickedImages, imageBitMaps = curPickedImages.map { it.asImageBitmap() }, side = ChatSide.USER, ) ) } // Add audio messages. var audioMessages: MutableList = mutableListOf() if (audioClips.isNotEmpty()) { for (audioClip in audioClips) { audioMessages.add( ChatMessageAudioClip( audioData = audioClip.audioData, sampleRate = audioClip.sampleRate, side = ChatSide.USER, ) ) } } // Cap the number of audio messages. if (audioMessages.size > MAX_AUDIO_CLIP_COUNT) { audioMessages = audioMessages.subList(fromIndex = 0, toIndex = MAX_AUDIO_CLIP_COUNT) } messages.addAll(audioMessages) if (text.isNotEmpty()) { messages.add(ChatMessageText(content = text, side = ChatSide.USER)) } return messages } /** * A private class that acts as a LifecycleObserver to monitor sensor events for a device's * orientation, specifically using the accelerometer. * * This observer registers for accelerometer events in `onResume` and unregisters in `onPause` to * conserve battery and resources. It calculates the device's rotation (0, 90, 180, -90) by checking * if the acceleration on the X or Y axis exceeds a threshold of 7.0 m/s^2, which corresponds to * gravity's pull when the device is held in a cardinal direction. A 'dead zone' is used to prevent * the rotation from "chattering" when the device is held at an angle between the cardinal * directions. */ private class SensorObserver(context: Context) : DefaultLifecycleObserver, SensorEventListener { private val sensorManager = context.getSystemService(Context.SENSOR_SERVICE) as SensorManager private val accelerometer: Sensor? = sensorManager.getDefaultSensor(Sensor.TYPE_ACCELEROMETER) var currentRotation = 0 override fun onResume(owner: LifecycleOwner) { super.onResume(owner) accelerometer?.let { sensorManager.registerListener(this, it, SensorManager.SENSOR_DELAY_NORMAL) } } override fun onPause(owner: LifecycleOwner) { super.onPause(owner) sensorManager.unregisterListener(this) } override fun onSensorChanged(event: SensorEvent?) { if (event?.sensor?.type == Sensor.TYPE_ACCELEROMETER) { val x = event.values[0] val y = event.values[1] // When the phone is on its side, gravity acts primarily along the x-axis. // When the phone is upright, gravity acts primarily along the y-axis. val newOrientation = when { x < -7.0 -> 90 x > 7.0 -> -90 y < -7.0 -> 180 y > 7.0 -> 0 else -> currentRotation // Keep the last known orientation } if (newOrientation != currentRotation) { currentRotation = newOrientation } } } override fun onAccuracyChanged(sensor: Sensor?, accuracy: Int) {} } ================================================ FILE: Android/src/app/src/main/java/com/google/ai/edge/gallery/ui/common/chat/MessageLatency.kt ================================================ /* * Copyright 2025 Google LLC * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * http://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. */ package com.google.ai.edge.gallery.ui.common.chat // import androidx.compose.ui.tooling.preview.Preview // import com.google.ai.edge.gallery.ui.theme.GalleryTheme import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.ui.Modifier import androidx.compose.ui.draw.alpha import androidx.compose.ui.platform.testTag import com.google.ai.edge.gallery.ui.common.humanReadableDuration /** 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).testTag("latency_label"), 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) // ) // } // } // } // } ================================================ FILE: Android/src/app/src/main/java/com/google/ai/edge/gallery/ui/common/chat/MessageSender.kt ================================================ /* * Copyright 2025 Google LLC * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * http://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. */ package com.google.ai.edge.gallery.ui.common.chat // import androidx.compose.ui.tooling.preview.Preview // import com.google.ai.edge.gallery.ui.theme.GalleryTheme import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.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.unit.dp import com.google.ai.edge.gallery.R import com.google.ai.edge.gallery.ui.theme.bodySmallNarrow data class MessageLayoutConfig( val horizontalArrangement: Arrangement.Horizontal, val modifier: Modifier, val userLabel: String, val rightSideLabel: String, ) /** * 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, agentName: String = "", imageHistoryCurIndex: Int = 0) { // No user label for system messages. if (message.side == ChatSide.SYSTEM) { return } val (horizontalArrangement, modifier, userLabel, rightSideLabel) = getMessageLayoutConfig( message = message, agentName = agentName, imageHistoryCurIndex = imageHistoryCurIndex, ) Row( modifier = modifier, verticalAlignment = Alignment.CenterVertically, horizontalArrangement = horizontalArrangement, ) { Row(verticalAlignment = Alignment.CenterVertically) { // Sender label. Text(userLabel, style = MaterialTheme.typography.titleSmall) 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, agentName: String, 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 = agentName } 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 = "Stats" if (message.accelerator.isNotEmpty()) { userLabel = "${userLabel} on ${message.accelerator}" } } 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), // agentName = stringResource(R.string.chat_generic_agent_name), // ) // // User message. // MessageSender( // message = ChatMessageText(content = "hello world", side = ChatSide.USER), // agentName = stringResource(R.string.chat_generic_agent_name), // ) // // Benchmark during warmup. // MessageSender( // message = // ChatMessageBenchmarkResult( // orderedStats = listOf(), // statValues = mutableMapOf(), // values = listOf(), // histogram = Histogram(listOf(), 0), // warmupCurrent = 10, // warmupTotal = 50, // iterationCurrent = 0, // iterationTotal = 200, // ), // agentName = stringResource(R.string.chat_generic_agent_name), // ) // // Benchmark during running. // MessageSender( // message = // ChatMessageBenchmarkResult( // orderedStats = listOf(), // statValues = mutableMapOf(), // values = listOf(), // histogram = Histogram(listOf(), 0), // warmupCurrent = 50, // warmupTotal = 50, // iterationCurrent = 123, // iterationTotal = 200, // ), // agentName = stringResource(R.string.chat_generic_agent_name), // ) // // Image generation during running. // MessageSender( // message = // ChatMessageImageWithHistory( // bitmaps = listOf(Bitmap.createBitmap(100, 100, Bitmap.Config.ARGB_8888)), // imageBitMaps = listOf(), // totalIterations = 10, // ChatSide.AGENT, // ), // agentName = stringResource(R.string.chat_generic_agent_name), // imageHistoryCurIndex = 4, // ) // } // } // } ================================================ FILE: Android/src/app/src/main/java/com/google/ai/edge/gallery/ui/common/chat/ModelDownloadStatusInfoPanel.kt ================================================ /* * Copyright 2025 Google LLC * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * http://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. */ package com.google.ai.edge.gallery.ui.common.chat import 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.layout.Arrangement 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.material3.MaterialTheme import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.unit.dp import com.google.ai.edge.gallery.data.Model import com.google.ai.edge.gallery.data.ModelDownloadStatusType import com.google.ai.edge.gallery.data.Task import com.google.ai.edge.gallery.ui.common.DownloadAndTryButton import com.google.ai.edge.gallery.ui.modelmanager.ModelManagerViewModel @Composable fun ModelDownloadStatusInfoPanel( model: Model, task: Task, modelManagerViewModel: ModelManagerViewModel, ) { val modelManagerUiState by modelManagerViewModel.uiState.collectAsState() // 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[model.name] val downloading = curStatus?.status == ModelDownloadStatusType.IN_PROGRESS || curStatus?.status == ModelDownloadStatusType.PARTIALLY_DOWNLOADED || curStatus?.status == ModelDownloadStatusType.UNZIPPING Column( modifier = Modifier.fillMaxSize(), horizontalAlignment = Alignment.CenterHorizontally, verticalArrangement = Arrangement.Center, ) { // Animation. Column(verticalArrangement = Arrangement.Bottom, modifier = Modifier.weight(1f)) { AnimatedVisibility( visible = downloading, enter = scaleIn(initialScale = 0.9f) + fadeIn(), exit = scaleOut(targetScale = 0.9f) + fadeOut(), ) { ModelDownloadingAnimation( model = model, task = task, modelManagerViewModel = modelManagerViewModel, ) } } // Download button and progress. DownloadAndTryButton( task = task, model = model, enabled = true, downloadStatus = curStatus, modelManagerViewModel = modelManagerViewModel, modifier = Modifier.padding(horizontal = 32.dp).padding(top = 4.dp, bottom = 16.dp), onClicked = {}, canShowTryIt = false, ) // Info text. Column(verticalArrangement = Arrangement.Top, modifier = Modifier.weight(1f)) { AnimatedVisibility( visible = downloading, enter = scaleIn(initialScale = 0.9f) + fadeIn(), exit = scaleOut(targetScale = 0.9f) + fadeOut(), ) { 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.bodyLarge, textAlign = TextAlign.Center, modifier = Modifier.fillMaxWidth(), ) } } } } ================================================ FILE: Android/src/app/src/main/java/com/google/ai/edge/gallery/ui/common/chat/ModelDownloadingAnimation.kt ================================================ /* * Copyright 2025 Google LLC * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * http://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. */ package com.google.ai.edge.gallery.ui.common.chat import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.collectAsState import androidx.compose.runtime.derivedStateOf import androidx.compose.runtime.getValue 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 com.google.ai.edge.gallery.data.Model import com.google.ai.edge.gallery.data.ModelDownloadStatusType import com.google.ai.edge.gallery.data.Task import com.google.ai.edge.gallery.ui.common.RotationalLoader import com.google.ai.edge.gallery.ui.common.formatToHourMinSecond import com.google.ai.edge.gallery.ui.common.humanReadableSize import com.google.ai.edge.gallery.ui.modelmanager.ModelManagerViewModel import com.google.ai.edge.gallery.ui.theme.labelSmallNarrow /** * Composable function to display a loading animation using a 2x2 grid of images with a synchronized * scaling and rotation effect. */ @Composable fun ModelDownloadingAnimation( model: Model, task: Task, modelManagerViewModel: ModelManagerViewModel, ) { val modelManagerUiState by modelManagerViewModel.uiState.collectAsState() val downloadStatus by remember { derivedStateOf { modelManagerUiState.modelDownloadStatus[model.name] } } val inProgress = downloadStatus?.status == ModelDownloadStatusType.IN_PROGRESS val isPartiallyDownloaded = downloadStatus?.status == ModelDownloadStatusType.PARTIALLY_DOWNLOADED var curDownloadProgress = 0f // Failure message. val curDownloadStatus = downloadStatus if (curDownloadStatus != null && curDownloadStatus.status == ModelDownloadStatusType.FAILED) { Row(verticalAlignment = Alignment.CenterVertically) { Text( curDownloadStatus.errorMessage, color = MaterialTheme.colorScheme.error, style = labelSmallNarrow, overflow = TextOverflow.Ellipsis, ) } } // No failure else { Column( horizontalAlignment = Alignment.CenterHorizontally, modifier = Modifier.padding(top = 32.dp), ) { // Loader. RotationalLoader(size = 160.dp) Spacer(modifier = Modifier.height(32.dp)) // Download stats var sizeLabel = model.totalBytes.humanReadableSize() if (curDownloadStatus != null) { // For in-progress model, show {receivedSize} / {totalSize} - {rate} - {remainingTime} if (inProgress || isPartiallyDownloaded) { var totalSize = curDownloadStatus.totalBytes if (totalSize == 0L) { totalSize = model.totalBytes } sizeLabel = "${curDownloadStatus.receivedBytes.humanReadableSize(extraDecimalForGbAndAbove = true)} of ${totalSize.humanReadableSize()}" if (curDownloadStatus.bytesPerSecond > 0) { sizeLabel = "$sizeLabel · ${curDownloadStatus.bytesPerSecond.humanReadableSize()} / s" if (curDownloadStatus.remainingMs >= 0) { sizeLabel = "$sizeLabel · ${curDownloadStatus.remainingMs.formatToHourMinSecond()} left" } } if (isPartiallyDownloaded) { sizeLabel = "$sizeLabel (resuming...)" } curDownloadProgress = curDownloadStatus.receivedBytes.toFloat() / curDownloadStatus.totalBytes.toFloat() if (curDownloadProgress.isNaN()) { curDownloadProgress = 0f } } // Status for unzipping. else if (curDownloadStatus.status == ModelDownloadStatusType.UNZIPPING) { sizeLabel = "Unzipping..." } Text( sizeLabel, color = MaterialTheme.colorScheme.onSurfaceVariant, style = MaterialTheme.typography.labelMedium, textAlign = TextAlign.Center, overflow = TextOverflow.Visible, modifier = Modifier.padding(bottom = 4.dp), ) } } } } ================================================ FILE: Android/src/app/src/main/java/com/google/ai/edge/gallery/ui/common/chat/ModelInitializationStatus.kt ================================================ /* * Copyright 2025 Google LLC * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * http://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. */ package com.google.ai.edge.gallery.ui.common.chat // import androidx.compose.ui.tooling.preview.Preview // import com.google.ai.edge.gallery.ui.theme.GalleryTheme import androidx.compose.foundation.background import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box 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.unit.dp import com.google.ai.edge.gallery.R /** * 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() } // } ================================================ FILE: Android/src/app/src/main/java/com/google/ai/edge/gallery/ui/common/chat/ModelNotDownloaded.kt ================================================ /* * Copyright 2025 Google LLC * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * http://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. */ package com.google.ai.edge.gallery.ui.common.chat // import androidx.compose.ui.tooling.preview.Preview // import com.google.ai.edge.gallery.ui.theme.GalleryTheme import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.fillMaxSize 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 /** * 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 = {}) } // } ================================================ FILE: Android/src/app/src/main/java/com/google/ai/edge/gallery/ui/common/chat/TextInputHistorySheet.kt ================================================ /* * Copyright 2025 Google LLC * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * http://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. */ package com.google.ai.edge.gallery.ui.common.chat // import androidx.compose.ui.tooling.preview.Preview // import com.google.ai.edge.gallery.ui.theme.GalleryTheme import androidx.compose.foundation.background import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.Arrangement 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.text.style.TextOverflow import androidx.compose.ui.unit.dp import com.google.ai.edge.gallery.R import com.google.ai.edge.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 = stringResource(R.string.cd_clear_input_history_icon), ) } } 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, maxLines = 3, overflow = TextOverflow.Ellipsis, 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 = stringResource(R.string.cd_delete_input_history_entry_icon), ) } } } } } 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 = {}, // ) // } // } ================================================ FILE: Android/src/app/src/main/java/com/google/ai/edge/gallery/ui/common/chat/ZoomableImage.kt ================================================ /* * Copyright 2025 Google LLC * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * http://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. */ package com.google.ai.edge.gallery.ui.common.chat import androidx.compose.foundation.Image import androidx.compose.foundation.MutatePriority import androidx.compose.foundation.background import androidx.compose.foundation.gestures.awaitEachGesture import androidx.compose.foundation.gestures.awaitFirstDown import androidx.compose.foundation.gestures.calculatePan import androidx.compose.foundation.gestures.calculateZoom import androidx.compose.foundation.layout.Box import androidx.compose.foundation.pager.PagerState import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.mutableFloatStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.rememberCoroutineScope 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.graphics.ImageBitmap import androidx.compose.ui.graphics.graphicsLayer import androidx.compose.ui.input.pointer.pointerInput import androidx.compose.ui.layout.ContentScale import androidx.compose.ui.unit.dp import kotlin.math.max import kotlin.math.min import kotlinx.coroutines.awaitCancellation import kotlinx.coroutines.launch /** * A Composable function that displays a zoomable and pannable image. * * This function handles multi-touch gestures for zooming and panning. It's designed to be used * within a Pager to prevent the Pager from scrolling while the user is interacting with the image. */ @Composable fun ZoomableImage( bitmap: ImageBitmap, modifier: Modifier = Modifier, minScale: Float = 1f, maxScale: Float = 3f, contentScale: ContentScale = ContentScale.Fit, pagerState: PagerState? = null, resetOnImageUpdate: Boolean = true, enabled: Boolean = true, twoFingerOnly: Boolean = false, onTransformed: (offsetX: Float, offsetY: Float, scale: Float) -> Unit = { _, _, _ -> }, ) { val scale = remember { mutableFloatStateOf(1f) } val offsetX = remember { mutableFloatStateOf(0f) } val offsetY = remember { mutableFloatStateOf(0f) } val coroutineScope = rememberCoroutineScope() LaunchedEffect(bitmap) { if (resetOnImageUpdate) { scale.floatValue = 1f offsetX.floatValue = 0f offsetY.floatValue = 0f } } val gestureModifier = if (enabled) { Modifier.pointerInput(twoFingerOnly) { // Only apply if enabled is true // It uses the `pointerInput` modifier to detect gestures. // // When a user performs a pinch-to-zoom gesture, the `scale` state is updated. // Once the content is zoomed in (`scale.value > 1`), pan gestures are enabled, and the // `offsetX` and `offsetY` states are updated to move the content. // // To prevent a parent Pager component from scrolling horizontally during a pan gesture, the // `pagerState`'s scrolling is temporarily disabled and then re-enabled after the pan event. // If the content is zoomed back out to its original size, the scale and offsets are reset. awaitEachGesture { awaitFirstDown() do { val event = awaitPointerEvent() val isTwoFingerGesture = event.changes.size >= 2 if ((twoFingerOnly && isTwoFingerGesture) || !twoFingerOnly) { scale.floatValue *= event.calculateZoom() scale.floatValue = max(min(scale.floatValue, maxScale), minScale) coroutineScope.launch { pagerState?.setScrolling(false) } val offset = event.calculatePan() offsetX.floatValue += offset.x offsetY.floatValue += offset.y // Consume the event so the parent Pager does not receive it if (twoFingerOnly) { event.changes.forEach { it.consume() } } coroutineScope.launch { pagerState?.setScrolling(true) } onTransformed(offsetX.floatValue, offsetY.floatValue, scale.floatValue) } } while (event.changes.any { it.pressed }) } } } else { // Return an empty modifier if disabled, effectively disabling interaction Modifier } Box( contentAlignment = Alignment.Center, modifier = modifier.background(Color.Transparent).clip(RoundedCornerShape(0.dp)).then(gestureModifier), ) { Image( bitmap = bitmap, contentDescription = null, contentScale = contentScale, modifier = Modifier.align(Alignment.Center).graphicsLayer { scaleX = maxOf(minScale, minOf(maxScale, scale.floatValue)) scaleY = maxOf(minScale, minOf(maxScale, scale.floatValue)) translationX = offsetX.floatValue translationY = offsetY.floatValue }, ) } } /** * An extension function on [PagerState] to temporarily disable or enable scrolling. * * This function uses a [MutatePriority.PreventUserInput] scroll block to ensure that no other * scrolls (like the user swiping) can happen while this block is active. */ suspend fun PagerState.setScrolling(value: Boolean) { scroll(scrollPriority = MutatePriority.PreventUserInput) { when (value) { true -> Unit else -> awaitCancellation() } } } ================================================ FILE: Android/src/app/src/main/java/com/google/ai/edge/gallery/ui/common/modelitem/ConfirmDeleteModelDialog.kt ================================================ /* * Copyright 2025 Google LLC * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * http://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. */ package com.google.ai.edge.gallery.ui.common.modelitem import androidx.compose.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.ai.edge.gallery.R import com.google.ai.edge.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)) } }, ) } ================================================ FILE: Android/src/app/src/main/java/com/google/ai/edge/gallery/ui/common/modelitem/DeleteModelButton.kt ================================================ /* * Copyright 2025 Google LLC * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * http://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. */ package com.google.ai.edge.gallery.ui.common.modelitem import androidx.compose.foundation.layout.Row import androidx.compose.material.icons.Icons import androidx.compose.material.icons.outlined.Delete import androidx.compose.material3.Icon import androidx.compose.material3.IconButton 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.res.stringResource import com.google.ai.edge.gallery.R import com.google.ai.edge.gallery.data.Model import com.google.ai.edge.gallery.data.ModelDownloadStatus import com.google.ai.edge.gallery.data.ModelDownloadStatusType import com.google.ai.edge.gallery.ui.modelmanager.ModelManagerViewModel /** Composable function to display a button for deleting the downloaded model. */ @Composable fun DeleteModelButton( model: Model, modelManagerViewModel: ModelManagerViewModel, downloadStatus: ModelDownloadStatus?, modifier: Modifier = Modifier, showDeleteButton: Boolean = true, ) { var showConfirmDeleteDialog by remember { mutableStateOf(false) } Row(verticalAlignment = Alignment.CenterVertically, modifier = modifier) { when (downloadStatus?.status) { // Button to delete the download. ModelDownloadStatusType.SUCCEEDED -> { if (showDeleteButton) { IconButton(onClick = { showConfirmDeleteDialog = true }) { Icon( Icons.Outlined.Delete, contentDescription = stringResource(R.string.cd_delete_icon), tint = MaterialTheme.colorScheme.onSurfaceVariant, modifier = Modifier.alpha(0.6f), ) } } } else -> {} } } if (showConfirmDeleteDialog) { ConfirmDeleteModelDialog( model = model, onConfirm = { modelManagerViewModel.deleteModel(model = model) showConfirmDeleteDialog = false }, onDismiss = { showConfirmDeleteDialog = false }, ) } } ================================================ FILE: Android/src/app/src/main/java/com/google/ai/edge/gallery/ui/common/modelitem/DownloadModelPanel.kt ================================================ /* * Copyright 2025 Google LLC * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * http://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. */ package com.google.ai.edge.gallery.ui.common.modelitem import androidx.compose.animation.AnimatedVisibilityScope import androidx.compose.animation.ExperimentalSharedTransitionApi import androidx.compose.animation.SharedTransitionScope import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.PaddingValues 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.width import androidx.compose.foundation.text.TextAutoSize import androidx.compose.material.icons.Icons import androidx.compose.material.icons.rounded.BarChart import androidx.compose.material3.Button import androidx.compose.material3.ButtonDefaults 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.res.stringResource import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp import com.google.ai.edge.gallery.R import com.google.ai.edge.gallery.data.Model import com.google.ai.edge.gallery.data.ModelDownloadStatus import com.google.ai.edge.gallery.data.ModelDownloadStatusType import com.google.ai.edge.gallery.data.Task import com.google.ai.edge.gallery.ui.common.DownloadAndTryButton import com.google.ai.edge.gallery.ui.modelmanager.ModelManagerViewModel @OptIn(ExperimentalSharedTransitionApi::class) @Composable fun DownloadModelPanel( model: Model, task: Task?, modelManagerViewModel: ModelManagerViewModel, downloadStatus: ModelDownloadStatus?, isExpanded: Boolean, sharedTransitionScope: SharedTransitionScope, animatedVisibilityScope: AnimatedVisibilityScope, onTryItClicked: () -> Unit, onBenchmarkClicked: () -> Unit, modifier: Modifier = Modifier, showBenchmarkButton: Boolean = false, ) { val downloadSucceeded = downloadStatus?.status == ModelDownloadStatusType.SUCCEEDED with(sharedTransitionScope) { Row( modifier = modifier.fillMaxWidth(), horizontalArrangement = Arrangement.End, verticalAlignment = Alignment.CenterVertically, ) { if (showBenchmarkButton && downloadSucceeded) { // Benchmark button. var buttonModifier: Modifier = Modifier.height(42.dp) if (isExpanded) { buttonModifier = buttonModifier.weight(1f) } Button( modifier = Modifier.sharedElement( sharedContentState = rememberSharedContentState(key = "benchmark_button"), animatedVisibilityScope = animatedVisibilityScope, ) .then(buttonModifier), colors = ButtonDefaults.buttonColors( containerColor = MaterialTheme.colorScheme.secondaryContainer ), contentPadding = PaddingValues(horizontal = 12.dp), onClick = onBenchmarkClicked, ) { val textColor = MaterialTheme.colorScheme.onSecondaryContainer Row( verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(8.dp), ) { Icon(Icons.Rounded.BarChart, contentDescription = null, tint = textColor) if (isExpanded) { Text( stringResource(R.string.benchmark), color = textColor, style = MaterialTheme.typography.titleMedium, maxLines = 1, autoSize = TextAutoSize.StepBased(minFontSize = 8.sp, maxFontSize = 16.sp, stepSize = 1.sp), ) } } } Spacer(modifier = Modifier.width(8.dp)) } DownloadAndTryButton( task = task, model = model, downloadStatus = downloadStatus, enabled = true, modelManagerViewModel = modelManagerViewModel, onClicked = onTryItClicked, compact = !isExpanded, modifier = Modifier.sharedElement( sharedContentState = rememberSharedContentState(key = "download_button"), animatedVisibilityScope = animatedVisibilityScope, ), modifierWhenExpanded = Modifier.weight(1f), ) } } } ================================================ FILE: Android/src/app/src/main/java/com/google/ai/edge/gallery/ui/common/modelitem/ModelItem.kt ================================================ /* * Copyright 2025 Google LLC * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * http://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. */ package com.google.ai.edge.gallery.ui.common.modelitem import androidx.compose.animation.AnimatedContent import androidx.compose.animation.ExperimentalSharedTransitionApi import androidx.compose.animation.SharedTransitionLayout 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.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.UnfoldLess import androidx.compose.material.icons.rounded.UnfoldMore import androidx.compose.material3.Icon import androidx.compose.material3.MaterialTheme 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.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.res.stringResource import androidx.compose.ui.semantics.isTraversalGroup import androidx.compose.ui.semantics.semantics import androidx.compose.ui.unit.dp import com.google.ai.edge.gallery.R import com.google.ai.edge.gallery.data.Model import com.google.ai.edge.gallery.data.Task import com.google.ai.edge.gallery.ui.common.MarkdownText import com.google.ai.edge.gallery.ui.modelmanager.ModelManagerViewModel import com.google.ai.edge.gallery.ui.theme.customColors /** * 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. */ @OptIn(ExperimentalSharedTransitionApi::class) @Composable fun ModelItem( model: Model, task: Task?, modelManagerViewModel: ModelManagerViewModel, onModelClicked: (Model) -> Unit, onBenchmarkClicked: (Model) -> Unit, modifier: Modifier = Modifier, expanded: Boolean? = null, showDeleteButton: Boolean = true, canExpand: Boolean = true, showBenchmarkButton: Boolean = false, onExpanded: (Boolean) -> Unit = {}, ) { val modelManagerUiState by modelManagerViewModel.uiState.collectAsState() val downloadStatus by remember { derivedStateOf { modelManagerUiState.modelDownloadStatus[model.name] } } val isBestOverall = model.bestForTaskIds.contains(task?.id ?: "") var isExpanded by remember { mutableStateOf(expanded ?: isBestOverall) } var boxModifier = modifier .fillMaxWidth() .clip(RoundedCornerShape(size = 12.dp)) .background(color = MaterialTheme.customColors.taskCardBgColor) boxModifier = if (canExpand) { boxModifier.clickable( onClick = { if (!model.imported) { isExpanded = !isExpanded onExpanded(isExpanded) } else if (!showBenchmarkButton) { onModelClicked(model) } }, interactionSource = remember { MutableInteractionSource() }, indication = ripple(bounded = true, radius = 1000.dp), ) } else { boxModifier } Box(modifier = boxModifier) { Column(modifier = Modifier.padding(16.dp), verticalArrangement = Arrangement.spacedBy(8.dp)) { Row( horizontalArrangement = Arrangement.spacedBy(8.dp), modifier = Modifier.semantics { isTraversalGroup = true }, ) { ModelNameAndStatus( model = model, task = task, downloadStatus = downloadStatus, isExpanded = isExpanded, modifier = Modifier.weight(1f), ) // Button to delete model and expand/collapse button at the right. Row(verticalAlignment = Alignment.Top) { if (model.localFileRelativeDirPathOverride.isEmpty()) { DeleteModelButton( model = model, modelManagerViewModel = modelManagerViewModel, downloadStatus = downloadStatus, showDeleteButton = showDeleteButton, modifier = Modifier.offset(y = (-12).dp, x = if (model.imported) 12.dp else 0.dp), ) } if (!model.imported) { Icon( if (isExpanded) Icons.Rounded.UnfoldLess else Icons.Rounded.UnfoldMore, contentDescription = stringResource( if (isExpanded) R.string.cd_collapse_icon else R.string.cd_expand_icon ), tint = MaterialTheme.colorScheme.onSurfaceVariant, modifier = Modifier.alpha(0.6f), ) } } } AnimatedContent(isExpanded, label = "item_layout_transition") { targetState -> // Show description when expanded. if (targetState) { if (model.info.isNotEmpty()) { MarkdownText( model.info, smallFontSize = true, textColor = MaterialTheme.colorScheme.onSurfaceVariant, modifier = Modifier.padding(top = 12.dp), ) } } } SharedTransitionLayout { AnimatedContent(isExpanded, label = "item_layout_transition") { targetState -> DownloadModelPanel( task = task, model = model, downloadStatus = downloadStatus, animatedVisibilityScope = this@AnimatedContent, sharedTransitionScope = this@SharedTransitionLayout, modifier = Modifier.sharedElement( sharedContentState = rememberSharedContentState(key = "download_panel"), animatedVisibilityScope = this@AnimatedContent, ) .padding(top = if (targetState) 12.dp else 0.dp), modelManagerViewModel = modelManagerViewModel, isExpanded = targetState, onTryItClicked = { onModelClicked(model) }, onBenchmarkClicked = { onBenchmarkClicked(model) }, showBenchmarkButton = showBenchmarkButton, ) } } } } } ================================================ FILE: Android/src/app/src/main/java/com/google/ai/edge/gallery/ui/common/modelitem/ModelNameAndStatus.kt ================================================ /* * Copyright 2025 Google LLC * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * http://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. */ package com.google.ai.edge.gallery.ui.common.modelitem import androidx.compose.animation.ExperimentalSharedTransitionApi 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.foundation.layout.size import androidx.compose.material.icons.Icons import androidx.compose.material.icons.automirrored.outlined.OpenInNew import androidx.compose.material.icons.filled.Star 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.graphics.Color import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp import com.google.ai.edge.gallery.R import com.google.ai.edge.gallery.data.MODEL_INFO_ICON_SIZE import com.google.ai.edge.gallery.data.Model import com.google.ai.edge.gallery.data.ModelDownloadStatus import com.google.ai.edge.gallery.data.ModelDownloadStatusType import com.google.ai.edge.gallery.data.Task import com.google.ai.edge.gallery.ui.common.ClickableLink import com.google.ai.edge.gallery.ui.common.humanReadableSize import com.google.ai.edge.gallery.ui.theme.customColors import com.google.ai.edge.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). * - "Unzipping..." status for unzipping processes. * - Model size for successful downloads. */ @OptIn(ExperimentalSharedTransitionApi::class) @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(modifier = modifier) { // Show "best overall" only for the first model if it is indeed the best for this task. if (task != null && model.bestForTaskIds.contains(task.id) && task.models[0] == model) { Row( verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(8.dp), modifier = Modifier.padding(bottom = 6.dp), ) { Icon( Icons.Filled.Star, tint = Color(0xFFFCC934), contentDescription = null, modifier = Modifier.size(18.dp), ) Text( stringResource(R.string.best_overall), style = MaterialTheme.typography.labelMedium, color = MaterialTheme.colorScheme.onSurfaceVariant, modifier = Modifier.alpha(0.6f), ) } } // Model name and action buttons. Text( model.displayName.ifEmpty { model.name }, maxLines = 1, overflow = TextOverflow.MiddleEllipsis, style = MaterialTheme.typography.titleMedium, ) // Status icon + size + download progress details. Row(verticalAlignment = Alignment.CenterVertically, modifier = Modifier.padding(top = 4.dp)) { // Status icon. StatusIcon( task = task, model = model, 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, ) } } // Status label else { var sizeLabel = model.totalBytes.humanReadableSize() if (model.localFileRelativeDirPathOverride.isNotEmpty()) { sizeLabel = "{ext_files_dir}/${model.localFileRelativeDirPathOverride}" } // 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 } } // 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.onSurfaceVariant, maxLines = 1, style = MaterialTheme.typography.bodyMedium, overflow = TextOverflow.Visible, modifier = Modifier.offset(y = if (index == 0) 0.dp else (-1).dp), ) } } } } // Learn more url. if (!model.imported && model.learnMoreUrl.isNotEmpty()) { Row(verticalAlignment = Alignment.CenterVertically) { Icon( Icons.AutoMirrored.Outlined.OpenInNew, tint = MaterialTheme.customColors.modelInfoIconColor, contentDescription = null, modifier = Modifier.size(MODEL_INFO_ICON_SIZE).offset(y = 1.dp), ) ClickableLink(model.learnMoreUrl, linkText = stringResource(R.string.learn_more)) } } } } ================================================ FILE: Android/src/app/src/main/java/com/google/ai/edge/gallery/ui/common/modelitem/StatusIcon.kt ================================================ /* * Copyright 2025 Google LLC * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * http://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. */ package com.google.ai.edge.gallery.ui.common.modelitem // import androidx.compose.ui.tooling.preview.Preview // import com.google.ai.edge.gallery.ui.theme.GalleryTheme import androidx.compose.foundation.layout.Arrangement 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.Downloading import androidx.compose.material.icons.rounded.Error 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.graphics.Color import androidx.compose.ui.res.stringResource import com.google.ai.edge.gallery.R import com.google.ai.edge.gallery.data.MODEL_INFO_ICON_SIZE import com.google.ai.edge.gallery.data.Model import com.google.ai.edge.gallery.data.ModelDownloadStatus import com.google.ai.edge.gallery.data.ModelDownloadStatusType import com.google.ai.edge.gallery.data.Task import com.google.ai.edge.gallery.ui.common.getTaskBgGradientColors import com.google.ai.edge.gallery.ui.theme.customColors /** Composable function to display an icon representing the download status of a model. */ @Composable fun StatusIcon( task: Task?, model: Model, downloadStatus: ModelDownloadStatus?, modifier: Modifier = Modifier, ) { Row( verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.Center, modifier = modifier, ) { val color = if (task != null) { getTaskBgGradientColors(task = task)[1] } else { MaterialTheme.colorScheme.primary } if (model.localFileRelativeDirPathOverride.isNotEmpty()) { Icon( Icons.Filled.DownloadForOffline, tint = color, contentDescription = stringResource(R.string.cd_downloaded_icon), modifier = Modifier.size(MODEL_INFO_ICON_SIZE), ) } else { when (downloadStatus?.status) { ModelDownloadStatusType.NOT_DOWNLOADED -> Icon( Icons.AutoMirrored.Outlined.HelpOutline, tint = MaterialTheme.customColors.modelInfoIconColor, contentDescription = stringResource(R.string.cd_not_downloaded_icon), modifier = Modifier.size(MODEL_INFO_ICON_SIZE), ) ModelDownloadStatusType.SUCCEEDED -> { Icon( Icons.Filled.DownloadForOffline, tint = color, contentDescription = stringResource(R.string.cd_downloaded_icon), modifier = Modifier.size(MODEL_INFO_ICON_SIZE), ) } ModelDownloadStatusType.FAILED -> Icon( Icons.Rounded.Error, tint = Color(0xFFAA0000), contentDescription = stringResource(R.string.cd_download_failed_icon), modifier = Modifier.size(MODEL_INFO_ICON_SIZE), ) ModelDownloadStatusType.IN_PROGRESS -> Icon( Icons.Rounded.Downloading, contentDescription = stringResource(R.string.cd_downloading_icon), modifier = Modifier.size(MODEL_INFO_ICON_SIZE), ) else -> {} } } } } // @Preview(showBackground = true) // @Composable // fun StatusIconPreview() { // GalleryTheme { // Column { // for (downloadStatus in // listOf( // ModelDownloadStatus(status = ModelDownloadStatusType.NOT_DOWNLOADED), // ModelDownloadStatus(status = ModelDownloadStatusType.IN_PROGRESS), // ModelDownloadStatus(status = ModelDownloadStatusType.SUCCEEDED), // ModelDownloadStatus(status = ModelDownloadStatusType.FAILED), // ModelDownloadStatus(status = ModelDownloadStatusType.UNZIPPING), // ModelDownloadStatus(status = ModelDownloadStatusType.PARTIALLY_DOWNLOADED), // )) { // StatusIcon(downloadStatus = downloadStatus) // } // } // } // } ================================================ FILE: Android/src/app/src/main/java/com/google/ai/edge/gallery/ui/common/textandvoiceinput/HoldToDictate.kt ================================================ /* * Copyright 2025 Google LLC * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * http://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. */ package com.google.ai.edge.gallery.ui.common.textandvoiceinput import android.Manifest import android.content.pm.PackageManager import androidx.activity.compose.rememberLauncherForActivityResult import androidx.activity.result.contract.ActivityResultContracts import androidx.compose.foundation.background import androidx.compose.foundation.gestures.detectTapGestures import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.height import androidx.compose.foundation.shape.CircleShape import androidx.compose.material3.Text 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.setValue 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.graphics.graphicsLayer import androidx.compose.ui.input.pointer.pointerInput import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.res.stringResource import androidx.compose.ui.unit.dp import androidx.core.content.ContextCompat import com.google.ai.edge.gallery.R import com.google.ai.edge.gallery.data.Task import com.google.ai.edge.gallery.ui.common.getTaskBgGradientColors import kotlin.coroutines.cancellation.CancellationException private const val TAG = "AGHoldToDictate" /** * A Composable that provides a "Hold to Dictate" functionality. * * This composable requests RECORD_AUDIO permission and, once granted, displays a button. The user * can press and hold the button to start speech recognition. Releasing the button stops the * recognition. Moving the finger off the button while holding will cancel the recognition. */ @Composable fun HoldToDictate( task: Task, viewModel: HoldToDictateViewModel, onDone: (String) -> Unit, onAmplitudeChanged: (Int) -> Unit, enabled: Boolean, modifier: Modifier = Modifier, ) { val uiState by viewModel.uiState.collectAsState() var recordAudioPermissionGranted by remember { mutableStateOf(false) } val context = LocalContext.current val recordAudioPermissionLauncher = rememberLauncherForActivityResult(ActivityResultContracts.RequestPermission()) { permissionGranted -> if (permissionGranted) { recordAudioPermissionGranted = true } } LaunchedEffect(Unit) { // Check permission when (PackageManager.PERMISSION_GRANTED) { // Already got permission. ContextCompat.checkSelfPermission(context, Manifest.permission.RECORD_AUDIO) -> { recordAudioPermissionGranted = true } // Otherwise, ask for permission else -> { recordAudioPermissionLauncher.launch(Manifest.permission.RECORD_AUDIO) } } } if (recordAudioPermissionGranted) { Box( modifier = modifier .then( if (enabled) { Modifier.pointerInput(Unit) { detectTapGestures( onPress = { viewModel.startSpeechRecognition( onDone = onDone, onAmplitudeChanged = onAmplitudeChanged, ) try { awaitRelease() } catch (e: CancellationException) { // Move out of the button to cancel it. viewModel.cancelSpeechRecognition() return@detectTapGestures } // Release to stop recognition. viewModel.stopSpeechRecognition() } ) } } else { Modifier } ) .clip(CircleShape) .graphicsLayer { alpha = if (enabled) 1f else 0.5f } .background(getTaskBgGradientColors(task = task)[1]) .height(48.dp), contentAlignment = Alignment.Center, ) { Text( stringResource(if (uiState.recognizing) R.string.listening else R.string.hold_down_to_talk), color = Color.White, ) } } } ================================================ FILE: Android/src/app/src/main/java/com/google/ai/edge/gallery/ui/common/textandvoiceinput/HoldToDictateViewModel.kt ================================================ /* * Copyright 2025 Google LLC * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * http://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. */ package com.google.ai.edge.gallery.ui.common.textandvoiceinput import android.content.Context import android.content.Intent import android.os.Bundle import android.speech.RecognitionListener import android.speech.RecognizerIntent import android.speech.SpeechRecognizer import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import dagger.hilt.android.lifecycle.HiltViewModel import dagger.hilt.android.qualifiers.ApplicationContext import javax.inject.Inject import kotlinx.coroutines.delay import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.update import kotlinx.coroutines.launch private const val TAG = "AGHTD" private const val AUDIO_METER_MIN_DB = -2.0f private const val AUDIO_METER_MAX_DB = 100.0f /** The UI state of the HoldToDictateViewModel. */ data class HoldToDictateUiState(val recognizing: Boolean = false, val recognizedText: String = "") @HiltViewModel class HoldToDictateViewModel @Inject constructor(@ApplicationContext private val context: Context) : ViewModel(), RecognitionListener { protected val _uiState = MutableStateFlow(HoldToDictateUiState()) val uiState = _uiState.asStateFlow() private val speechRecognizer: SpeechRecognizer private val recognizerIntent: Intent private var onRecognitionDone: ((String) -> Unit)? = null private var onAmplitudeChanged: ((Int) -> Unit)? = null init { // Initialize SpeechRecognizer speechRecognizer = SpeechRecognizer.createSpeechRecognizer(context).apply { setRecognitionListener(this@HoldToDictateViewModel) } // Initialize Intent (used for language/model settings) recognizerIntent = Intent(RecognizerIntent.ACTION_RECOGNIZE_SPEECH).apply { putExtra(RecognizerIntent.EXTRA_LANGUAGE_MODEL, RecognizerIntent.LANGUAGE_MODEL_FREE_FORM) putExtra(RecognizerIntent.EXTRA_LANGUAGE, "en-US") putExtra(RecognizerIntent.EXTRA_MAX_RESULTS, 1) putExtra(RecognizerIntent.EXTRA_PARTIAL_RESULTS, true) } } fun startSpeechRecognition(onDone: (String) -> Unit, onAmplitudeChanged: (Int) -> Unit) { onRecognitionDone = onDone this.onAmplitudeChanged = onAmplitudeChanged speechRecognizer.startListening(recognizerIntent) setRecognizedText(text = "") setRecognizing(recognizing = true) } fun stopSpeechRecognition() { viewModelScope.launch { delay(500) speechRecognizer.stopListening() setRecognizing(recognizing = false) } } fun cancelSpeechRecognition() { setRecognizing(recognizing = false) } fun setRecognizing(recognizing: Boolean) { _uiState.update { uiState.value.copy(recognizing = recognizing) } } fun setRecognizedText(text: String) { _uiState.update { uiState.value.copy(recognizedText = text) } } override fun onReadyForSpeech(params: Bundle?) {} override fun onBeginningOfSpeech() {} override fun onRmsChanged(rmsdB: Float) { onAmplitudeChanged?.invoke(convertRmsDbToAmplitude(rmsdB = rmsdB)) } override fun onBufferReceived(buffer: ByteArray?) {} override fun onEndOfSpeech() {} override fun onError(error: Int) {} override fun onResults(results: Bundle?) { val matches = results?.getStringArrayList(SpeechRecognizer.RESULTS_RECOGNITION) if (matches != null && matches.size > 0) { setRecognizedText(matches.get(0) ?: "") } else { setRecognizedText("") } val curOnRecognitionDone = onRecognitionDone if (curOnRecognitionDone != null) { curOnRecognitionDone(uiState.value.recognizedText) } setRecognizing(recognizing = false) } override fun onPartialResults(partialResults: Bundle?) { val matches = partialResults?.getStringArrayList(SpeechRecognizer.RESULTS_RECOGNITION) if (matches != null && matches.size > 0) { setRecognizedText(matches.get(0) ?: "") } else { setRecognizedText("") } } override fun onEvent(eventType: Int, params: Bundle?) {} } private fun convertRmsDbToAmplitude(rmsdB: Float): Int { // Clamp the input value to the defined range var clampedRmsdB = Math.max(rmsdB, AUDIO_METER_MIN_DB) clampedRmsdB = Math.min(clampedRmsdB, AUDIO_METER_MAX_DB) // Linear scaling to a 0-65535 range return ((clampedRmsdB - AUDIO_METER_MIN_DB) * 65535f / (AUDIO_METER_MAX_DB - AUDIO_METER_MIN_DB)) .toInt() } ================================================ FILE: Android/src/app/src/main/java/com/google/ai/edge/gallery/ui/common/textandvoiceinput/TextAndVoiceInput.kt ================================================ /* * Copyright 2025 Google LLC * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * http://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. */ package com.google.ai.edge.gallery.ui.common.textandvoiceinput import androidx.compose.animation.AnimatedContent 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.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.layout.size import androidx.compose.foundation.shape.CircleShape import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.text.BasicTextField import androidx.compose.material.icons.Icons import androidx.compose.material.icons.automirrored.rounded.Send import androidx.compose.material.icons.outlined.KeyboardAlt import androidx.compose.material.icons.outlined.Mic import androidx.compose.material3.Icon import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect 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.Color import androidx.compose.ui.graphics.SolidColor import androidx.compose.ui.graphics.graphicsLayer import androidx.compose.ui.res.stringResource import androidx.compose.ui.semantics.contentDescription import androidx.compose.ui.semantics.semantics import androidx.compose.ui.unit.dp import com.google.ai.edge.gallery.R import com.google.ai.edge.gallery.data.Task import com.google.ai.edge.gallery.ui.common.getTaskIconColor import com.google.ai.edge.gallery.ui.theme.bodyLargeNarrow @Composable fun TextAndVoiceInput( task: Task, processing: Boolean, holdToDictateViewModel: HoldToDictateViewModel, onDone: (String) -> Unit, onAmplitudeChanged: (Int) -> Unit, modifier: Modifier = Modifier, clearTextTrigger: Long = 0L, defaultTextInputMode: Boolean = false, ) { Row( modifier = modifier, verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(8.dp), ) { var textInputMode by remember { mutableStateOf(defaultTextInputMode) } var curTextInput by remember { mutableStateOf("") } LaunchedEffect(clearTextTrigger) { curTextInput = "" } // An icon button to switch between text and voice input. Box( modifier = Modifier.clip(CircleShape) .then( if (!processing) { Modifier.clickable { curTextInput = "" textInputMode = !textInputMode } } else { Modifier } ) .graphicsLayer { alpha = if (!processing) 1f else 0.5f } .background(MaterialTheme.colorScheme.surfaceContainerLow) .border( width = 1.dp, color = MaterialTheme.colorScheme.outlineVariant, shape = CircleShape, ) .size(48.dp), contentAlignment = Alignment.Center, ) { Icon( if (textInputMode) Icons.Outlined.Mic else Icons.Outlined.KeyboardAlt, contentDescription = stringResource( if (textInputMode) R.string.cd_switch_to_voice else R.string.cd_switch_to_keyboard ), modifier = Modifier.size(24.dp), ) } AnimatedContent(targetState = textInputMode) { showTextInput -> // Text field. if (showTextInput) { val cdPromptInput = stringResource(R.string.cd_prompt_input_text_field) Row( modifier = Modifier.weight(1f) .clip(RoundedCornerShape(28.dp)) .background(MaterialTheme.colorScheme.surface) .border( width = 1.dp, color = MaterialTheme.colorScheme.outlineVariant, shape = RoundedCornerShape(28.dp), ) .heightIn(min = 48.dp), verticalAlignment = Alignment.CenterVertically, ) { BasicTextField( value = curTextInput, enabled = !processing, onValueChange = { curTextInput = it }, textStyle = bodyLargeNarrow.copy(color = MaterialTheme.colorScheme.onSurface), cursorBrush = SolidColor(MaterialTheme.colorScheme.primary), modifier = Modifier.padding(start = 16.dp, end = 8.dp).padding(vertical = 2.dp).semantics { contentDescription = cdPromptInput }, minLines = 1, maxLines = 3, decorationBox = { innerTextField -> Row( verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(4.dp), ) { Box(Modifier.weight(1f).padding(vertical = 8.dp)) { if (curTextInput.isEmpty()) { Text( text = stringResource(R.string.text_input_placeholder_llm_chat), color = MaterialTheme.colorScheme.onSurfaceVariant, ) } innerTextField() } // Send button. Box( modifier = Modifier.clip(CircleShape) .then( if (!processing) { Modifier.clickable { onDone(curTextInput) } } else { Modifier } ) .graphicsLayer { alpha = if (!processing) 1f else 0.5f } .background(getTaskIconColor(task = task)) .size(36.dp), contentAlignment = Alignment.Center, ) { Icon( Icons.AutoMirrored.Rounded.Send, contentDescription = stringResource(R.string.cd_send_prompt_icon), modifier = Modifier.offset(x = 2.dp), tint = Color.White, ) } } }, ) } } // Hold to talk. else { HoldToDictate( task = task, viewModel = holdToDictateViewModel, onDone = { text -> onDone(text) }, onAmplitudeChanged = { onAmplitudeChanged(it) }, enabled = !processing, modifier = Modifier.fillMaxWidth(), ) } } } } ================================================ FILE: Android/src/app/src/main/java/com/google/ai/edge/gallery/ui/common/textandvoiceinput/VoiceRecognizerOverlay.kt ================================================ /* * Copyright 2025 Google LLC * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * http://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. */ package com.google.ai.edge.gallery.ui.common.textandvoiceinput 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.fillMaxSize 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.runtime.collectAsState import androidx.compose.runtime.getValue 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.input.pointer.pointerInput import androidx.compose.ui.res.stringResource import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.dp import com.google.ai.edge.gallery.R import com.google.ai.edge.gallery.data.Task import com.google.ai.edge.gallery.ui.common.AudioAnimation import com.google.ai.edge.gallery.ui.common.getTaskBgGradientColors private const val TAG = "AGVROverlay" /** * A Composable that displays the UI after user holding down on the "Hold to Dictate" button. * * It shows the recognized text, an audio level animation, and instructions for the user. */ @Composable fun VoiceRecognizerOverlay( task: Task, viewModel: HoldToDictateViewModel, bottomPadding: Dp, curAmplitude: Int, modifier: Modifier = Modifier, ) { val uiState by viewModel.uiState.collectAsState() Box(modifier = modifier.fillMaxSize(), contentAlignment = Alignment.BottomCenter) { // Audio level animation. AudioAnimation(bgColor = Color.Black.copy(alpha = 0.8f), amplitude = curAmplitude) // Recognized text. Text( uiState.recognizedText.ifEmpty { stringResource(R.string.listening) }, modifier = Modifier.padding(horizontal = 16.dp) .padding(bottom = (48.dp + bottomPadding) / 2) .align(Alignment.Center), color = Color.White, ) Column( modifier = Modifier.padding(bottom = bottomPadding).padding(horizontal = 16.dp).fillMaxWidth(), horizontalAlignment = Alignment.CenterHorizontally, verticalArrangement = Arrangement.spacedBy(8.dp), ) { // Instructions Row( modifier = Modifier.fillMaxWidth(), verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.SpaceBetween, ) { Text( stringResource(R.string.release_to_send), color = Color.Black, style = MaterialTheme.typography.labelMedium, ) Text( stringResource(R.string.slide_up_to_cancel), color = Color.Black, style = MaterialTheme.typography.labelMedium, ) } // A button that covers the HoldToDictate button. Box( modifier = modifier .pointerInput(Unit) {} .clip(CircleShape) .background(getTaskBgGradientColors(task = task)[1]) .fillMaxWidth() .height(48.dp), contentAlignment = Alignment.Center, ) { Text(stringResource(R.string.listening), color = Color.White) } } } } ================================================ FILE: Android/src/app/src/main/java/com/google/ai/edge/gallery/ui/common/tos/AppTosDialog.kt ================================================ /* * Copyright 2026 Google LLC * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * http://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. */ package com.google.ai.edge.gallery.ui.common.tos import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.padding import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.text.BasicText import androidx.compose.foundation.text.TextAutoSize import androidx.compose.foundation.verticalScroll import androidx.compose.material3.Button import androidx.compose.material3.Card import androidx.compose.material3.ExperimentalMaterial3Api 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.text.font.FontWeight import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp import androidx.compose.ui.window.Dialog import androidx.compose.ui.window.DialogProperties import com.google.ai.edge.gallery.R import com.google.ai.edge.gallery.ui.common.MarkdownText /** A composable for Terms of Service dialog, shown once when app is launched. */ @OptIn(ExperimentalMaterial3Api::class) @Composable fun AppTosDialog(onTosAccepted: () -> Unit, viewingMode: Boolean = false) { Dialog( properties = DialogProperties(dismissOnBackPress = false, dismissOnClickOutside = false), onDismissRequest = { if (viewingMode) onTosAccepted() }, ) { Card(shape = RoundedCornerShape(28.dp)) { Column(modifier = Modifier.padding(horizontal = 24.dp)) { // Title. val titleColor = MaterialTheme.colorScheme.onSurface BasicText( stringResource(R.string.tos_dialog_title_app), modifier = Modifier.fillMaxWidth().padding(top = 24.dp), style = MaterialTheme.typography.headlineSmall.copy(fontWeight = FontWeight.Medium), color = { titleColor }, maxLines = 1, autoSize = TextAutoSize.StepBased(minFontSize = 16.sp, maxFontSize = 24.sp, stepSize = 1.sp), ) Column(modifier = Modifier.verticalScroll(rememberScrollState()).weight(1f, fill = false)) { // Short content. MarkdownText( "By using this app, you agree to the " + "[Google Terms of Service](https://policies.google.com/terms?hl=en-US).\n\n" + "To learn what information we collect and why, how we use it, " + "and how to review and update it, please review the " + "[Google Privacy Policy](https://policies.google.com/privacy?hl=en-US).", smallFontSize = true, textColor = MaterialTheme.colorScheme.onSurfaceVariant, modifier = Modifier.padding(top = 16.dp), ) } // Accept button. Button( onClick = onTosAccepted, modifier = Modifier.padding(top = 28.dp, bottom = 24.dp).align(Alignment.End), ) { Text( stringResource( if (viewingMode) R.string.close else R.string.tos_dialog_accept_and_continue_button_label ) ) } } } } } ================================================ FILE: Android/src/app/src/main/java/com/google/ai/edge/gallery/ui/common/tos/GemmaTermsOfUseDialog.kt ================================================ /* * Copyright 2025 Google LLC * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * http://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. */ package com.google.ai.edge.gallery.ui.common.tos 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.width import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.text.BasicText import androidx.compose.foundation.text.TextAutoSize import androidx.compose.foundation.verticalScroll import androidx.compose.material3.Button import androidx.compose.material3.Card import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Text import androidx.compose.material3.TextButton 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.text.buildAnnotatedString import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp import androidx.compose.ui.window.Dialog import com.google.ai.edge.gallery.R import com.google.ai.edge.gallery.ui.common.buildTrackableUrlAnnotatedString /** A composable for Gemma Terms of Use dialog, shown once before a Gemma model is downloaded. */ @OptIn(ExperimentalMaterial3Api::class) @Composable fun GemmaTermsOfUseDialog( onTosAccepted: () -> Unit, onCancel: () -> Unit = {}, viewingMode: Boolean = false, ) { Dialog(onDismissRequest = onCancel) { Card(shape = RoundedCornerShape(28.dp)) { Column(modifier = Modifier.padding(horizontal = 24.dp).padding(bottom = 24.dp)) { // Title. val titleColor = MaterialTheme.colorScheme.onSurface BasicText( stringResource(R.string.tos_dialog_title_gemma), modifier = Modifier.fillMaxWidth().padding(top = 24.dp), style = MaterialTheme.typography.headlineSmall.copy(fontWeight = FontWeight.Medium), color = { titleColor }, maxLines = 1, autoSize = TextAutoSize.StepBased(minFontSize = 16.sp, maxFontSize = 24.sp, stepSize = 1.sp), ) Column(modifier = Modifier.verticalScroll(rememberScrollState()).weight(1f, fill = false)) { Text( buildAnnotatedString { append("Gemma models on the Google AI Edge Gallery app are governed by the ") append( buildTrackableUrlAnnotatedString( url = "https://ai.google.dev/gemma/terms", linkText = "Gemma Terms of Service", ) ) append(". Please review these terms and ensure you agree before continuing.") }, style = MaterialTheme.typography.bodyMedium, color = MaterialTheme.colorScheme.onSurfaceVariant, modifier = Modifier.padding(top = 16.dp), ) } Row( modifier = Modifier.fillMaxWidth().padding(top = 24.dp), horizontalArrangement = Arrangement.End, verticalAlignment = Alignment.CenterVertically, ) { // Cancel button. if (!viewingMode) { TextButton(onClick = onCancel) { Text(stringResource(R.string.cancel)) } Spacer(modifier = Modifier.width(8.dp)) } // Accept button. Button(onClick = onTosAccepted) { Text( stringResource( if (viewingMode) R.string.close else R.string.tos_dialog_agree_and_continue_button_label ) ) } } } } } } ================================================ FILE: Android/src/app/src/main/java/com/google/ai/edge/gallery/ui/common/tos/TosViewModel.kt ================================================ /* * Copyright 2025 Google LLC * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * http://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. */ package com.google.ai.edge.gallery.ui.common.tos import androidx.lifecycle.ViewModel import com.google.ai.edge.gallery.data.DataStoreRepository import dagger.hilt.android.lifecycle.HiltViewModel import javax.inject.Inject /** ViewModel responsible for managing terms of services related tasks. */ @HiltViewModel open class TosViewModel @Inject constructor(private val dataStoreRepository: DataStoreRepository) : ViewModel() { fun getIsTosAccepted(): Boolean { return dataStoreRepository.isTosAccepted() } fun acceptTos() { dataStoreRepository.acceptTos() } fun getIsGemmaTermsOfUseAccepted(): Boolean { return dataStoreRepository.isGemmaTermsOfUseAccepted() } fun acceptGemmaTermsOfUse() { dataStoreRepository.acceptGemmaTermsOfUse() } } ================================================ FILE: Android/src/app/src/main/java/com/google/ai/edge/gallery/ui/home/HomeScreen.kt ================================================ /* * Copyright 2025 Google LLC * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * http://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. */ package com.google.ai.edge.gallery.ui.home // import androidx.compose.ui.tooling.preview.Preview // import com.google.ai.edge.gallery.ui.theme.GalleryTheme // import com.google.ai.edge.gallery.ui.preview.PreviewModelManagerViewModel import android.Manifest import android.content.Context import android.content.pm.PackageManager import android.os.Build import androidx.activity.compose.BackHandler import androidx.activity.compose.rememberLauncherForActivityResult import androidx.activity.result.contract.ActivityResultContracts 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.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.layout.size import androidx.compose.foundation.layout.width import androidx.compose.foundation.lazy.LazyRow import androidx.compose.foundation.lazy.itemsIndexed import androidx.compose.foundation.lazy.rememberLazyListState import androidx.compose.foundation.pager.HorizontalPager import androidx.compose.foundation.pager.PagerState import androidx.compose.foundation.pager.rememberPagerState import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.shape.CircleShape import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.verticalScroll import androidx.compose.material.icons.Icons import androidx.compose.material.icons.automirrored.rounded.ListAlt import androidx.compose.material.icons.rounded.Error import androidx.compose.material.icons.rounded.Flag import androidx.compose.material.icons.rounded.Settings import androidx.compose.material3.AlertDialog import androidx.compose.material3.Card import androidx.compose.material3.CardDefaults import androidx.compose.material3.CircularProgressIndicator import androidx.compose.material3.DrawerValue import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.Icon import androidx.compose.material3.MaterialTheme import androidx.compose.material3.ModalDrawerSheet import androidx.compose.material3.ModalNavigationDrawer import androidx.compose.material3.Scaffold import androidx.compose.material3.Text import androidx.compose.material3.TextButton import androidx.compose.material3.rememberDrawerState import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.collectAsState import androidx.compose.runtime.derivedStateOf 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.graphics.Brush import androidx.compose.ui.graphics.Brush.Companion.linearGradient import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.graphicsLayer import androidx.compose.ui.platform.LocalConfiguration import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalDensity import androidx.compose.ui.platform.LocalUriHandler import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.stringResource import androidx.compose.ui.semantics.clearAndSetSemantics import androidx.compose.ui.semantics.contentDescription import androidx.compose.ui.semantics.semantics import androidx.compose.ui.text.buildAnnotatedString import androidx.compose.ui.unit.dp import androidx.core.content.ContextCompat import com.google.ai.edge.gallery.GalleryTopAppBar import com.google.ai.edge.gallery.R import com.google.ai.edge.gallery.data.AppBarAction import com.google.ai.edge.gallery.data.AppBarActionType import com.google.ai.edge.gallery.data.Category import com.google.ai.edge.gallery.data.CategoryInfo import com.google.ai.edge.gallery.data.Task import com.google.ai.edge.gallery.ui.common.RevealingText import com.google.ai.edge.gallery.ui.common.SwipingText import com.google.ai.edge.gallery.ui.common.TaskIcon import com.google.ai.edge.gallery.ui.common.buildTrackableUrlAnnotatedString import com.google.ai.edge.gallery.ui.common.rememberDelayedAnimationProgress import com.google.ai.edge.gallery.ui.common.tos.AppTosDialog import com.google.ai.edge.gallery.ui.common.tos.TosViewModel import com.google.ai.edge.gallery.ui.modelmanager.ModelManagerViewModel import com.google.ai.edge.gallery.ui.theme.customColors import com.google.ai.edge.gallery.ui.theme.homePageTitleStyle import kotlinx.coroutines.delay import kotlinx.coroutines.launch private const val TAG = "AGHomeScreen" private const val TASK_COUNT_ANIMATION_DURATION = 250 private const val ANIMATION_INIT_DELAY = 0L private const val TOP_APP_BAR_ANIMATION_DURATION = 600 private const val TITLE_FIRST_LINE_ANIMATION_DURATION = 600 private const val TITLE_SECOND_LINE_ANIMATION_DURATION = 600 private const val TITLE_SECOND_LINE_ANIMATION_DURATION2 = 800 private const val TITLE_SECOND_LINE_ANIMATION_START = ANIMATION_INIT_DELAY + (TITLE_FIRST_LINE_ANIMATION_DURATION * 0.5).toInt() private const val TASK_LIST_ANIMATION_START = TITLE_SECOND_LINE_ANIMATION_START + 110 private const val TASK_CARD_ANIMATION_DELAY_OFFSET = 100 private const val TASK_CARD_ANIMATION_DURATION = 600 private const val CONTENT_COMPOSABLES_ANIMATION_DURATION = 1200 private const val CONTENT_COMPOSABLES_OFFSET_Y = 16 /** Navigation destination data */ object HomeScreenDestination { @StringRes val titleRes = R.string.app_name } private val PREDEFINED_CATEGORY_ORDER = listOf(Category.LLM.id, Category.EXPERIMENTAL.id) @OptIn(ExperimentalMaterial3Api::class) @Composable fun HomeScreen( modelManagerViewModel: ModelManagerViewModel, tosViewModel: TosViewModel, navigateToTaskScreen: (Task) -> Unit, onModelsClicked: () -> Unit, enableAnimation: Boolean, modifier: Modifier = Modifier, ) { val uiState by modelManagerViewModel.uiState.collectAsState() var showSettingsDialog by remember { mutableStateOf(false) } var showTosDialog by remember { mutableStateOf(!tosViewModel.getIsTosAccepted()) } val scope = rememberCoroutineScope() val context = LocalContext.current val isDevBuild = context.packageName.endsWith(".dev") var tasks = uiState.tasks val categoryMap: Map = remember(tasks) { tasks.associateBy { it.category.id }.mapValues { it.value.category } } val sortedCategories = remember(categoryMap) { categoryMap.keys .toList() .sortedWith { a, b -> val indexA = PREDEFINED_CATEGORY_ORDER.indexOf(a) val indexB = PREDEFINED_CATEGORY_ORDER.indexOf(b) // Check if both categories are in the predefined order if (indexA != -1 && indexB != -1) { indexA.compareTo(indexB) } // Check if only category 'a' is in the predefined order else if (indexA != -1) { -1 } // Check if only category 'b' is in the predefined order else if (indexB != -1) { 1 } // If neither is in the predefined order, sort by label else { val ca = categoryMap[a]!! val cb = categoryMap[b]!! val caLabel = getCategoryLabel(context = context, category = ca) val cbLabel = getCategoryLabel(context = context, category = cb) caLabel.compareTo(cbLabel) } } .map { categoryMap[it]!! } } // Show home screen content when TOS has been accepted. if (!showTosDialog) { // The code below manages the display of the model allowlist loading indicator with a debounced // delay. It ensures that a progress indicator is only shown if the loading operation // (represented by `uiState.loadingModelAllowlist`) takes longer than 200 milliseconds. // If the loading completes within 200ms, the indicator is never shown, // preventing a "flicker" and improving the perceived responsiveness of the UI. // The `loadingModelAllowlistDelayed` state is used to control the actual // visibility of the indicator based on this debounced logic. var loadingModelAllowlistDelayed by remember { mutableStateOf(false) } // This effect runs whenever uiState.loadingModelAllowlist changes LaunchedEffect(uiState.loadingModelAllowlist) { if (uiState.loadingModelAllowlist) { // If loading starts, wait for 200ms delay(200) // After 200ms, check if loadingModelAllowlist is still true if (uiState.loadingModelAllowlist) { loadingModelAllowlistDelayed = true } } else { // If loading finishes, immediately hide the indicator loadingModelAllowlistDelayed = false } } // Label and spinner to show when in the process of loading model allowlist. if (loadingModelAllowlistDelayed) { Row( modifier = Modifier.fillMaxSize(), verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.Center, ) { CircularProgressIndicator( trackColor = MaterialTheme.colorScheme.surfaceVariant, strokeWidth = 3.dp, modifier = Modifier.padding(end = 8.dp).size(20.dp), ) Text( stringResource(R.string.loading_model_list), style = MaterialTheme.typography.bodyMedium, ) } } // Main UI when allowlist is done loading. if (!loadingModelAllowlistDelayed && !uiState.loadingModelAllowlist) { val drawerState = rememberDrawerState(initialValue = DrawerValue.Closed) val requestPermissionLauncher = rememberLauncherForActivityResult(ActivityResultContracts.RequestPermission()) { isGranted: Boolean -> if (isGranted) { // FCM SDK (and your app) can post notifications. } } LaunchedEffect(Unit) { delay(2000) if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { if ( ContextCompat.checkSelfPermission(context, Manifest.permission.POST_NOTIFICATIONS) != PackageManager.PERMISSION_GRANTED ) { requestPermissionLauncher.launch(Manifest.permission.POST_NOTIFICATIONS) } } } // Close the menu when back button is pressed. BackHandler(drawerState.isOpen) { scope.launch { drawerState.close() } } ModalNavigationDrawer( drawerState = drawerState, drawerContent = { ModalDrawerSheet { Column(modifier = Modifier.padding(16.dp)) { Row(modifier = Modifier.fillMaxWidth()) { SquareDrawerItem( label = stringResource(R.string.drawer_settings_label), description = stringResource(R.string.drawer_settings_description), icon = Icons.Rounded.Settings, onClick = { showSettingsDialog = true scope.launch { drawerState.close() } }, modifier = Modifier.weight(1f), iconBrush = linearGradient( colors = listOf( MaterialTheme.customColors.taskBgGradientColors[2][0], MaterialTheme.customColors.taskBgGradientColors[2][1], ) ), ) Spacer(modifier = Modifier.width(16.dp)) SquareDrawerItem( label = stringResource(R.string.drawer_models_label), description = stringResource(R.string.drawer_models_description), icon = Icons.AutoMirrored.Rounded.ListAlt, onClick = { scope.launch { drawerState.close() } scope.launch { delay(50) onModelsClicked() } }, modifier = Modifier.weight(1f), iconBrush = linearGradient( colors = listOf( MaterialTheme.customColors.taskBgGradientColors[1][0], MaterialTheme.customColors.taskBgGradientColors[1][1], ) ), ) } } } }, gesturesEnabled = drawerState.isOpen, ) { Scaffold( containerColor = MaterialTheme.colorScheme.background, topBar = { // Top bar animation: // // Fade in and move down at the same time. val progress = if (!enableAnimation) 1f else rememberDelayedAnimationProgress( initialDelay = ANIMATION_INIT_DELAY - 50, animationDurationMs = TOP_APP_BAR_ANIMATION_DURATION, animationLabel = "top bar", ) Box( modifier = Modifier.graphicsLayer { alpha = progress translationY = ((-16).dp * (1 - progress)).toPx() } ) { GalleryTopAppBar( title = stringResource(HomeScreenDestination.titleRes), leftAction = AppBarAction( actionType = AppBarActionType.MENU, actionFn = { scope.launch { drawerState.apply { if (isClosed) open() else close() } } }, ), ) } }, ) { innerPadding -> // Outer box for coloring the background edge to edge. Box( contentAlignment = Alignment.TopCenter, modifier = Modifier.fillMaxSize().background(MaterialTheme.colorScheme.surfaceContainer), ) { // Inner box to hold content. Box( contentAlignment = Alignment.TopCenter, modifier = Modifier.fillMaxSize().padding(top = innerPadding.calculateTopPadding()), ) { Column(modifier = Modifier.fillMaxWidth().verticalScroll(rememberScrollState())) { var selectedCategoryIndex by remember { mutableIntStateOf(0) } // App title and intro text. Column( modifier = Modifier.padding(horizontal = 40.dp, vertical = 48.dp).semantics( mergeDescendants = true ) {}, verticalArrangement = Arrangement.spacedBy(8.dp), ) { AppTitle(enableAnimation = enableAnimation) IntroText(enableAnimation = enableAnimation) } // Tab header for categories. // // synchronizes the `pagerState` and the `selectedCategoryIndex` to ensure that // both the tab header and the task list always show the correct category and page. val pagerState = rememberPagerState(pageCount = { sortedCategories.size }) LaunchedEffect(pagerState.settledPage) { selectedCategoryIndex = pagerState.settledPage } if (sortedCategories.size > 1) { CategoryTabHeader( sortedCategories = sortedCategories, selectedIndex = selectedCategoryIndex, enableAnimation = enableAnimation, onCategorySelected = { index -> selectedCategoryIndex = index scope.launch { pagerState.animateScrollToPage(page = index) } }, ) } // Task list in a horizontal pager. Each page shows the list of tasks for the // category. TaskList( pagerState = pagerState, sortedCategories = sortedCategories, tasksByCategories = uiState.tasksByCategory, enableAnimation = enableAnimation, navigateToTaskScreen = navigateToTaskScreen, ) Spacer(modifier = Modifier.height(innerPadding.calculateBottomPadding() + 10.dp)) } } // Gradient overlay at the bottom. Box( modifier = Modifier.fillMaxWidth() .height(innerPadding.calculateBottomPadding()) .background( Brush.verticalGradient( colors = listOf(Color.Transparent, MaterialTheme.colorScheme.surfaceContainer) ) ) .align(Alignment.BottomCenter) ) } } } } } // Show TOS dialog for users to accept. if (showTosDialog) { AppTosDialog( onTosAccepted = { showTosDialog = false tosViewModel.acceptTos() } ) } // Settings dialog. if (showSettingsDialog) { SettingsDialog( curThemeOverride = modelManagerViewModel.readThemeOverride(), modelManagerViewModel = modelManagerViewModel, onDismissed = { showSettingsDialog = false }, ) } if (uiState.loadingModelAllowlistError.isNotEmpty()) { AlertDialog( icon = { Icon( Icons.Rounded.Error, contentDescription = stringResource(R.string.cd_error), tint = MaterialTheme.colorScheme.error, ) }, title = { Text(uiState.loadingModelAllowlistError) }, text = { Text("Please check your internet connection and try again later.") }, onDismissRequest = { modelManagerViewModel.loadModelAllowlist() }, confirmButton = { TextButton(onClick = { modelManagerViewModel.loadModelAllowlist() }) { Text("Retry") } }, dismissButton = { TextButton(onClick = { modelManagerViewModel.clearLoadModelAllowlistError() }) { Text("Cancel") } }, ) } } @Composable private fun AppTitle(enableAnimation: Boolean) { val firstLineText = stringResource(R.string.app_name_first_part) val secondLineText = stringResource(R.string.app_name_second_part) val titleColor = MaterialTheme.customColors.appTitleGradientColors[1] val screenWidthInDp = LocalConfiguration.current.screenWidthDp.dp val fontSize = with(LocalDensity.current) { (screenWidthInDp.toPx() * 0.12f).toSp() } val titleStyle = homePageTitleStyle.copy(fontSize = fontSize, lineHeight = fontSize) // First line text "Google AI" and its animation. // // The animation starts with the first line of text swiping in from left to right, progressively // revealing itself in the title color (blue). Then, after a brief delay, the exact same text, but // in the onSurface color (which is black in light mode), begins its own left-to-right swiping // animation. This second animation is positioned directly on top of the first, appearing just as // the initial reveal is finishing or has just completed, creating a layered and dynamic visual // effect. Box(modifier = Modifier.clearAndSetSemantics {}) { var delay = ANIMATION_INIT_DELAY if (enableAnimation) { SwipingText( text = firstLineText, style = titleStyle, color = titleColor, animationDelay = delay, animationDurationMs = TITLE_FIRST_LINE_ANIMATION_DURATION, ) delay += (TITLE_FIRST_LINE_ANIMATION_DURATION * 0.3).toLong() } SwipingText( text = firstLineText, style = titleStyle, color = MaterialTheme.colorScheme.onSurface, animationDelay = if (enableAnimation) delay else 0, animationDurationMs = if (enableAnimation) TITLE_FIRST_LINE_ANIMATION_DURATION else 0, ) } // Second line text "Edge Gallery" and its animation. // // The initial animation is the same as the first line text. Right before it is done, the final // text with a gradient is revealed. Box(modifier = Modifier.clearAndSetSemantics {}) { var delay = TITLE_SECOND_LINE_ANIMATION_START if (enableAnimation) { SwipingText( text = secondLineText, style = titleStyle, color = titleColor, modifier = Modifier.offset(y = (-16).dp), animationDelay = delay, animationDurationMs = TITLE_SECOND_LINE_ANIMATION_DURATION, ) delay += (TITLE_SECOND_LINE_ANIMATION_DURATION * 0.3).toInt() SwipingText( text = secondLineText, style = titleStyle, color = MaterialTheme.colorScheme.onSurface, modifier = Modifier.offset(y = (-16).dp), animationDelay = delay, animationDurationMs = TITLE_SECOND_LINE_ANIMATION_DURATION, ) delay += (TITLE_SECOND_LINE_ANIMATION_DURATION * 0.6).toInt() } RevealingText( text = secondLineText, style = titleStyle.copy( brush = linearGradient(colors = MaterialTheme.customColors.appTitleGradientColors) ), modifier = Modifier.offset(x = (-16).dp, y = (-16).dp), animationDelay = if (enableAnimation) delay else 0, animationDurationMs = if (enableAnimation) TITLE_SECOND_LINE_ANIMATION_DURATION2 else 0, ) } } @Composable private fun IntroText(enableAnimation: Boolean) { val url = "https://huggingface.co/litert-community" val linkColor = MaterialTheme.customColors.linkColor val uriHandler = LocalUriHandler.current // Intro text animation: // // fade in + slide up. val progress = if (!enableAnimation) 1f else rememberDelayedAnimationProgress( initialDelay = TITLE_SECOND_LINE_ANIMATION_START, animationDurationMs = CONTENT_COMPOSABLES_ANIMATION_DURATION, animationLabel = "intro text animation", ) val introText = buildAnnotatedString { append("${stringResource(R.string.app_intro)} ") append( buildTrackableUrlAnnotatedString( url = url, linkText = stringResource(R.string.litert_community_label), ) ) } Text( introText, style = MaterialTheme.typography.bodyMedium, modifier = Modifier.graphicsLayer { alpha = progress translationY = (CONTENT_COMPOSABLES_OFFSET_Y.dp * (1 - progress)).toPx() }, ) } @Composable private fun CategoryTabHeader( sortedCategories: List, selectedIndex: Int, enableAnimation: Boolean, onCategorySelected: (Int) -> Unit, ) { val context = LocalContext.current val scope = rememberCoroutineScope() val listState = rememberLazyListState() val progress = if (!enableAnimation) 1f else rememberDelayedAnimationProgress( initialDelay = TASK_LIST_ANIMATION_START, animationDurationMs = CONTENT_COMPOSABLES_ANIMATION_DURATION, animationLabel = "task card animation", ) LazyRow( state = listState, modifier = Modifier.fillMaxWidth().padding(bottom = 32.dp).graphicsLayer { alpha = progress translationY = (CONTENT_COMPOSABLES_OFFSET_Y.dp * (1 - progress)).toPx() }, horizontalArrangement = Arrangement.spacedBy(16.dp), ) { item(key = "spacer_start") { Spacer(modifier = Modifier.width(8.dp)) } itemsIndexed(items = sortedCategories) { index, category -> Row( modifier = Modifier.height(40.dp) .clip(CircleShape) .background( color = if (selectedIndex == index) MaterialTheme.customColors.tabHeaderBgColor else Color.Transparent ) .clickable { onCategorySelected(index) // Scroll to clicked item when the item is not fully inside view. scope.launch { val visibleItems = listState.layoutInfo.visibleItemsInfo val targetItem = visibleItems.find { // +1 because the first item is the item keyed at spacer_start. it.index == index + 1 } if ( targetItem == null || targetItem.offset < 0 || targetItem.offset + targetItem.size > listState.layoutInfo.viewportSize.width ) { listState.animateScrollToItem(index = index) } } }, verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.Center, ) { Text( getCategoryLabel(context = context, category = category), modifier = Modifier.padding(horizontal = 16.dp), style = MaterialTheme.typography.labelLarge, color = if (selectedIndex == index) Color.White else MaterialTheme.colorScheme.onSurfaceVariant, ) } } item(key = "spacer_end") { Spacer(modifier = Modifier.width(8.dp)) } } } @Composable private fun TaskList( pagerState: PagerState, sortedCategories: List, tasksByCategories: Map>, enableAnimation: Boolean, navigateToTaskScreen: (Task) -> Unit, ) { // Model list animation: // // 1. Slide Up: The entire column of task cards translates upwards, // 2. Fade in one by one: The task card fade in one by one. See TaskCard for details. val progress = if (!enableAnimation) 1f else rememberDelayedAnimationProgress( initialDelay = TASK_LIST_ANIMATION_START, animationDurationMs = CONTENT_COMPOSABLES_ANIMATION_DURATION, animationLabel = "task card animation", ) // Tracks when the initial animation is done. // var initialAnimationDone by remember { mutableStateOf(false) } LaunchedEffect(Unit) { // Use 5 iterations to make sure all visible task cards are animated. delay(((TASK_CARD_ANIMATION_DURATION + TASK_CARD_ANIMATION_DELAY_OFFSET) * 5).toLong()) initialAnimationDone = true } HorizontalPager( state = pagerState, verticalAlignment = Alignment.Top, contentPadding = PaddingValues(horizontal = 20.dp), ) { pageIndex -> val tasks = tasksByCategories[sortedCategories[pageIndex].id]!! Column( modifier = Modifier.fillMaxWidth().padding(4.dp).graphicsLayer { translationY = (CONTENT_COMPOSABLES_OFFSET_Y.dp * (1 - progress)).toPx() }, verticalArrangement = Arrangement.spacedBy(10.dp), ) { var index = 0 for (task in tasks) { TaskCard( task = task, index = index, animate = (pageIndex == 0 || pageIndex == 1) && !initialAnimationDone && enableAnimation, onClick = { navigateToTaskScreen(task) }, modifier = Modifier.fillMaxWidth(), ) index++ } } } } @Composable private fun TaskCard( task: Task, index: Int, animate: Boolean, onClick: () -> Unit, modifier: Modifier = Modifier, ) { // Observes the model count and updates the model count label with a fade-in/fade-out animation // whenever the count changes. val modelCount by remember { derivedStateOf { val trigger = task.updateTrigger.value if (trigger >= 0) { task.models.size } else { 0 } } } val modelCountLabel by remember { derivedStateOf { when (modelCount) { 1 -> "1 Model" else -> "%d Models".format(modelCount) } } } var curModelCountLabel by remember { mutableStateOf("") } var modelCountLabelVisible by remember { mutableStateOf(true) } LaunchedEffect(modelCountLabel) { if (curModelCountLabel.isEmpty()) { curModelCountLabel = modelCountLabel } else { modelCountLabelVisible = false delay(TASK_COUNT_ANIMATION_DURATION.toLong()) curModelCountLabel = modelCountLabel modelCountLabelVisible = true } } // Task card animation: // // This animation makes the task cards appear with a delayed fade-in effect. Each card will become // visible sequentially, starting after an initial delay and then with an additional offset for // subsequent cards. val progress = if (animate) rememberDelayedAnimationProgress( initialDelay = TASK_LIST_ANIMATION_START + index * TASK_CARD_ANIMATION_DELAY_OFFSET, animationDurationMs = TASK_CARD_ANIMATION_DURATION, animationLabel = "task card animation", ) else 1f val cbTask = stringResource(R.string.cd_task_card, task.label, task.models.size) Card( modifier = modifier .clip(RoundedCornerShape(24.dp)) .clickable(onClick = onClick) .graphicsLayer { alpha = progress } .semantics { contentDescription = cbTask }, colors = CardDefaults.cardColors(containerColor = MaterialTheme.customColors.taskCardBgColor), ) { Row( modifier = Modifier.fillMaxSize().padding(24.dp), verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.SpaceBetween, ) { // Title and model count Column { Row(verticalAlignment = Alignment.CenterVertically) { Text( task.label, color = MaterialTheme.colorScheme.onSurface, style = MaterialTheme.typography.titleMedium, ) if (task.experimental) { Icon( painter = painterResource(R.drawable.ic_experiment), contentDescription = "Experimental", modifier = Modifier.size(20.dp).padding(start = 4.dp), tint = MaterialTheme.colorScheme.onSurfaceVariant, ) } } Text( curModelCountLabel, color = MaterialTheme.colorScheme.onSurfaceVariant, style = MaterialTheme.typography.bodyMedium, modifier = Modifier.clearAndSetSemantics {}, ) } // Icon. TaskIcon(task = task, width = 40.dp) } } } private fun getCategoryLabel(context: Context, category: CategoryInfo): String { val stringRes = category.labelStringRes val label = category.label if (stringRes != null) { return context.getString(stringRes) } else if (label != null) { return label } return context.getString(R.string.category_unlabeled) } ================================================ FILE: Android/src/app/src/main/java/com/google/ai/edge/gallery/ui/home/MobileActionsChallengeDialog.kt ================================================ /* * Copyright 2025 Google LLC * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * http://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. */ package com.google.ai.edge.gallery.ui.home import 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.height import androidx.compose.foundation.layout.padding import androidx.compose.material3.Button import androidx.compose.material3.ExperimentalMaterial3Api 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.ui.Modifier import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.SpanStyle import androidx.compose.ui.text.buildAnnotatedString import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.text.withStyle import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp import com.google.ai.edge.gallery.R import com.google.ai.edge.gallery.ui.common.buildTrackableUrlAnnotatedString @OptIn(ExperimentalMaterial3Api::class) @Composable fun MobileActionsChallengeDialog( onDismiss: () -> Unit, onLoadModel: () -> Unit, onSendEmail: () -> Unit, ) { val sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true) val guideUrl = "https://ai.google.dev/gemma/docs/mobile-actions" ModalBottomSheet(onDismissRequest = onDismiss, sheetState = sheetState) { Column(modifier = Modifier.padding(16.dp)) { Text(text = "🏆", modifier = Modifier.fillMaxWidth(), textAlign = TextAlign.Center) Text( text = stringResource(R.string.mobile_actions_challenge_title), modifier = Modifier.fillMaxWidth(), textAlign = TextAlign.Center, fontSize = 16.sp, fontWeight = FontWeight.Bold, ) Text( text = stringResource(R.string.mobile_actions_challenge_subtitle), modifier = Modifier.fillMaxWidth(), textAlign = TextAlign.Center, ) Spacer(modifier = Modifier.height(16.dp)) Text( text = stringResource(R.string.mobile_actions_challenge_description), style = MaterialTheme.typography.bodyMedium, ) Spacer(modifier = Modifier.height(24.dp)) Text( text = stringResource(R.string.mobile_actions_challenge_instructions_title), fontWeight = FontWeight.Bold, ) val instructions = buildAnnotatedString { append("1. ") withStyle(style = SpanStyle(fontWeight = FontWeight.Bold)) { append("On your computer") } append(", open ") append(buildTrackableUrlAnnotatedString(url = guideUrl, linkText = "this guide")) append( "\n2. Follow the instructions to fine tune the model and convert it to .litertlm format." ) append("\n3. Transfer the file to this phone.") append("\n4. Tap ") withStyle(style = SpanStyle(fontWeight = FontWeight.Bold)) { append("Load Model") } append(" below to unlock the demo.") } Text( text = instructions, color = MaterialTheme.colorScheme.onSurface, style = MaterialTheme.typography.bodyMedium, ) Spacer(modifier = Modifier.height(16.dp)) Row(modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.SpaceBetween) { TextButton(onClick = onSendEmail) { Text(stringResource(R.string.mobile_actions_challenge_email_colab)) } Button(onClick = onLoadModel) { Text(stringResource(R.string.mobile_actions_challenge_load_model)) } } } } } ================================================ FILE: Android/src/app/src/main/java/com/google/ai/edge/gallery/ui/home/NewReleaseNotification.kt ================================================ /* * Copyright 2025 Google LLC * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * http://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. */ package com.google.ai.edge.gallery.ui.home import android.util.Log import androidx.compose.animation.AnimatedVisibility import androidx.compose.animation.expandVertically import androidx.compose.animation.fadeIn import androidx.compose.foundation.background import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.padding import androidx.compose.foundation.shape.CircleShape import androidx.compose.material.icons.Icons import androidx.compose.material.icons.automirrored.rounded.OpenInNew import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.DisposableEffect import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember 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.unit.dp import androidx.lifecycle.Lifecycle import androidx.lifecycle.LifecycleEventObserver import androidx.lifecycle.LifecycleOwner import androidx.lifecycle.compose.LocalLifecycleOwner import com.google.ai.edge.gallery.BuildConfig import com.google.ai.edge.gallery.common.getJsonResponse import com.google.ai.edge.gallery.ui.common.ClickableLink import kotlin.math.max import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.launch import kotlinx.coroutines.withContext private const val TAG = "AGNewReleaseNotifi" private const val REPO = "google-ai-edge/gallery" data class ReleaseInfo(val html_url: String, val tag_name: String) @Composable fun NewReleaseNotification() { var newReleaseVersion by remember { mutableStateOf("") } var newReleaseUrl by remember { mutableStateOf("") } val lifecycleOwner: LifecycleOwner = LocalLifecycleOwner.current val coroutineScope = rememberCoroutineScope() DisposableEffect(lifecycleOwner) { // Create a LifecycleEventObserver to listen for specific lifecycle events. val observer = LifecycleEventObserver { _, event -> // Log or perform actions based on the lifecycle event. when (event) { Lifecycle.Event.ON_RESUME -> { coroutineScope.launch { withContext(Dispatchers.IO) { Log.d(TAG, "Checking for new release...") val info = getJsonResponse("https://api.github.com/repos/$REPO/releases/latest") if (info != null) { val curRelease = BuildConfig.VERSION_NAME val newRelease = info.jsonObj.tag_name val isNewer = isNewerRelease(currentRelease = curRelease, newRelease = newRelease) Log.d(TAG, "curRelease: $curRelease, newRelease: $newRelease, isNewer: $isNewer") if (isNewer) { newReleaseVersion = newRelease newReleaseUrl = info.jsonObj.html_url } } } } } else -> {} } } lifecycleOwner.lifecycle.addObserver(observer) onDispose { lifecycleOwner.lifecycle.removeObserver(observer) } } AnimatedVisibility( visible = newReleaseVersion.isNotEmpty(), enter = fadeIn() + expandVertically(), ) { Row( verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.SpaceBetween, modifier = Modifier.padding(horizontal = 16.dp) .padding(bottom = 12.dp) .clip(CircleShape) .background(MaterialTheme.colorScheme.tertiaryContainer) .padding(4.dp), ) { Text( "New release $newReleaseVersion available", style = MaterialTheme.typography.bodyMedium, modifier = Modifier.padding(start = 12.dp), ) Row( modifier = Modifier.padding(end = 12.dp), verticalAlignment = Alignment.CenterVertically, ) { ClickableLink( url = newReleaseUrl, linkText = "View", icon = Icons.AutoMirrored.Rounded.OpenInNew, ) } } } } private fun isNewerRelease(currentRelease: String, newRelease: String): Boolean { // Split the version strings into their individual components (e.g., "0.9.0" -> ["0", "9", "0"]) val currentComponents = currentRelease.split('.').map { it.toIntOrNull() ?: 0 } val newComponents = newRelease.split('.').map { it.toIntOrNull() ?: 0 } // Determine the maximum number of components to iterate through val maxComponents = max(currentComponents.size, newComponents.size) // Iterate through the components from left to right (major, minor, patch, etc.) for (i in 0 until maxComponents) { val currentComponent = currentComponents.getOrElse(i) { 0 } val newComponent = newComponents.getOrElse(i) { 0 } if (newComponent > currentComponent) { return true } else if (newComponent < currentComponent) { return false } } return false } ================================================ FILE: Android/src/app/src/main/java/com/google/ai/edge/gallery/ui/home/SettingsDialog.kt ================================================ /* * Copyright 2025 Google LLC * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * http://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. */ package com.google.ai.edge.gallery.ui.home import com.google.android.gms.oss.licenses.OssLicensesMenuActivity import android.app.UiModeManager import android.content.Context import android.content.Intent import androidx.compose.foundation.border 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.height import androidx.compose.foundation.layout.offset import androidx.compose.foundation.layout.padding import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.shape.CircleShape import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.text.BasicTextField import androidx.compose.foundation.text.KeyboardActions import androidx.compose.foundation.text.KeyboardOptions import androidx.compose.foundation.verticalScroll import androidx.compose.material.icons.Icons import androidx.compose.material.icons.rounded.CheckCircle import androidx.compose.material3.Button import androidx.compose.material3.Card import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.Icon import androidx.compose.material3.IconButton import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MultiChoiceSegmentedButtonRow import androidx.compose.material3.OutlinedButton import androidx.compose.material3.SegmentedButton import androidx.compose.material3.SegmentedButtonDefaults 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.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.graphics.SolidColor import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalFocusManager import androidx.compose.ui.res.stringResource import androidx.compose.ui.semantics.semantics import androidx.compose.ui.text.TextStyle import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.input.ImeAction import androidx.compose.ui.unit.dp import androidx.compose.ui.window.Dialog import com.google.ai.edge.gallery.BuildConfig import com.google.ai.edge.gallery.R import com.google.ai.edge.gallery.proto.Theme import com.google.ai.edge.gallery.ui.common.ClickableLink import com.google.ai.edge.gallery.ui.common.tos.AppTosDialog import com.google.ai.edge.gallery.ui.modelmanager.ModelManagerViewModel import com.google.ai.edge.gallery.ui.theme.ThemeSettings import com.google.ai.edge.gallery.ui.theme.labelSmallNarrow import java.time.Instant import java.time.ZoneId import java.time.format.DateTimeFormatter import java.util.Locale import kotlin.math.min private val THEME_OPTIONS = listOf(Theme.THEME_AUTO, Theme.THEME_LIGHT, Theme.THEME_DARK) @OptIn(ExperimentalMaterial3Api::class) @Composable fun SettingsDialog( curThemeOverride: Theme, modelManagerViewModel: ModelManagerViewModel, onDismissed: () -> Unit, ) { var selectedTheme by remember { mutableStateOf(curThemeOverride) } var hfToken by remember { mutableStateOf(modelManagerViewModel.getTokenStatusAndData().data) } val dateFormatter = remember { DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss") .withZone(ZoneId.systemDefault()) .withLocale(Locale.getDefault()) } var customHfToken by remember { mutableStateOf("") } var isFocused by remember { mutableStateOf(false) } val focusRequester = remember { FocusRequester() } val interactionSource = remember { MutableInteractionSource() } var showTos by remember { mutableStateOf(false) } Dialog(onDismissRequest = onDismissed) { val focusManager = LocalFocusManager.current Card( modifier = Modifier.fillMaxWidth().clickable( interactionSource = interactionSource, indication = null, // Disable the ripple effect ) { focusManager.clearFocus() }, shape = RoundedCornerShape(16.dp), ) { Column( modifier = Modifier.padding(20.dp), verticalArrangement = Arrangement.spacedBy(16.dp), ) { // Dialog title and subtitle. Column { Text( "Settings", style = MaterialTheme.typography.titleLarge, modifier = Modifier.padding(bottom = 8.dp), ) // Subtitle. Text( "App version: ${BuildConfig.VERSION_NAME}", style = labelSmallNarrow, color = MaterialTheme.colorScheme.onSurfaceVariant, modifier = Modifier.offset(y = (-6).dp), ) } Column( modifier = Modifier.verticalScroll(rememberScrollState()).weight(1f, fill = false), verticalArrangement = Arrangement.spacedBy(16.dp), ) { val context = LocalContext.current // Theme switcher. Column(modifier = Modifier.fillMaxWidth().semantics(mergeDescendants = true) {}) { Text( "Theme", style = MaterialTheme.typography.titleSmall.copy(fontWeight = FontWeight.Medium), ) MultiChoiceSegmentedButtonRow { THEME_OPTIONS.forEachIndexed { index, theme -> SegmentedButton( shape = SegmentedButtonDefaults.itemShape(index = index, count = THEME_OPTIONS.size), onCheckedChange = { selectedTheme = theme // Update theme settings. // This will update app's theme. ThemeSettings.themeOverride.value = theme // Save to data store. modelManagerViewModel.saveThemeOverride(theme) // Update ui mode. // // This is necessary to make other Activities launched from MainActivity to have // the correct theme. val uiModeManager = context.applicationContext.getSystemService(Context.UI_MODE_SERVICE) as UiModeManager if (theme == Theme.THEME_AUTO) { uiModeManager.setApplicationNightMode(UiModeManager.MODE_NIGHT_AUTO) } else if (theme == Theme.THEME_LIGHT) { uiModeManager.setApplicationNightMode(UiModeManager.MODE_NIGHT_NO) } else { uiModeManager.setApplicationNightMode(UiModeManager.MODE_NIGHT_YES) } }, checked = theme == selectedTheme, label = { Text(themeLabel(theme)) }, ) } } } // HF Token management. Column( modifier = Modifier.fillMaxWidth().semantics(mergeDescendants = true) {}, verticalArrangement = Arrangement.spacedBy(4.dp), ) { Text( "HuggingFace access token", style = MaterialTheme.typography.titleSmall.copy(fontWeight = FontWeight.Medium), ) // Show the start of the token. val curHfToken = hfToken if (curHfToken != null && curHfToken.accessToken.isNotEmpty()) { Text( curHfToken.accessToken.substring(0, min(16, curHfToken.accessToken.length)) + "...", style = MaterialTheme.typography.bodyMedium, color = MaterialTheme.colorScheme.onSurfaceVariant, ) Text( "Expires at: ${dateFormatter.format(Instant.ofEpochMilli(curHfToken.expiresAtMs))}", style = MaterialTheme.typography.bodyMedium, color = MaterialTheme.colorScheme.onSurfaceVariant, ) } else { Text( "Not available", style = MaterialTheme.typography.bodyMedium, color = MaterialTheme.colorScheme.onSurfaceVariant, ) Text( "The token will be automatically retrieved when a gated model is downloaded", style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant, ) } Row(horizontalArrangement = Arrangement.spacedBy(4.dp)) { OutlinedButton( onClick = { modelManagerViewModel.clearAccessToken() hfToken = null }, enabled = curHfToken != null, ) { Text("Clear") } val handleSaveToken = { modelManagerViewModel.saveAccessToken( accessToken = customHfToken, refreshToken = "", expiresAt = System.currentTimeMillis() + 1000L * 60 * 60 * 24 * 365 * 10, ) hfToken = modelManagerViewModel.getTokenStatusAndData().data focusManager.clearFocus() } BasicTextField( value = customHfToken, singleLine = true, keyboardOptions = KeyboardOptions(imeAction = ImeAction.Done), keyboardActions = KeyboardActions(onDone = { handleSaveToken() }), modifier = Modifier.fillMaxWidth() .padding(top = 4.dp) .focusRequester(focusRequester) .onFocusChanged { isFocused = it.isFocused }, onValueChange = { customHfToken = it }, textStyle = TextStyle(color = MaterialTheme.colorScheme.onSurface), cursorBrush = SolidColor(MaterialTheme.colorScheme.onSurface), ) { innerTextField -> Box( modifier = Modifier.border( width = if (isFocused) 2.dp else 1.dp, color = if (isFocused) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.outline, shape = CircleShape, ) .height(40.dp), contentAlignment = Alignment.CenterStart, ) { Row(verticalAlignment = Alignment.CenterVertically) { Box(modifier = Modifier.padding(start = 16.dp).weight(1f)) { if (customHfToken.isEmpty()) { Text( "Enter token manually", color = MaterialTheme.colorScheme.onSurfaceVariant, style = MaterialTheme.typography.bodySmall, ) } innerTextField() } if (customHfToken.isNotEmpty()) { IconButton(modifier = Modifier.offset(x = 1.dp), onClick = handleSaveToken) { Icon( Icons.Rounded.CheckCircle, contentDescription = stringResource(R.string.cd_done_icon), ) } } } } } } } // Third party licenses. Column(modifier = Modifier.fillMaxWidth().semantics(mergeDescendants = true) {}) { Text( "Third-party libraries", style = MaterialTheme.typography.titleSmall.copy(fontWeight = FontWeight.Medium), ) OutlinedButton( onClick = { // Create an Intent to launch a license viewer that displays a list of // third-party library names. Clicking a name will show its license content. val intent = Intent(context, OssLicensesMenuActivity::class.java) context.startActivity(intent) } ) { Text("View licenses") } } // Tos Column(modifier = Modifier.fillMaxWidth().semantics(mergeDescendants = true) {}) { Text( stringResource(R.string.settings_dialog_tos_title), style = MaterialTheme.typography.titleSmall.copy(fontWeight = FontWeight.Medium), ) OutlinedButton(onClick = { showTos = true }) { Text(stringResource(R.string.settings_dialog_view_app_terms_of_service)) } ClickableLink( url = "https://ai.google.dev/gemma/terms", linkText = stringResource(R.string.tos_dialog_title_gemma), modifier = Modifier.padding(top = 4.dp), ) ClickableLink( url = "https://ai.google.dev/gemma/prohibited_use_policy", linkText = stringResource(R.string.settings_dialog_gemma_prohibited_use_policy), modifier = Modifier.padding(top = 8.dp), ) } } // Button row. Row( modifier = Modifier.fillMaxWidth().padding(top = 8.dp), horizontalArrangement = Arrangement.End, ) { // Close button Button(onClick = { onDismissed() }) { Text("Close") } } } } } if (showTos) { AppTosDialog(onTosAccepted = { showTos = false }, viewingMode = true) } } private fun themeLabel(theme: Theme): String { return when (theme) { Theme.THEME_AUTO -> "Auto" Theme.THEME_LIGHT -> "Light" Theme.THEME_DARK -> "Dark" else -> "Unknown" } } ================================================ FILE: Android/src/app/src/main/java/com/google/ai/edge/gallery/ui/home/SquareDrawerItem.kt ================================================ /* * Copyright 2026 Google LLC * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * http://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. */ package com.google.ai.edge.gallery.ui.home import androidx.compose.foundation.border import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.aspectRatio import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.text.TextAutoSize 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.clip import androidx.compose.ui.draw.drawWithContent import androidx.compose.ui.graphics.BlendMode import androidx.compose.ui.graphics.Brush import androidx.compose.ui.graphics.graphicsLayer import androidx.compose.ui.graphics.vector.ImageVector import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp @Composable fun SquareDrawerItem( label: String, description: String, icon: ImageVector, onClick: () -> Unit, modifier: Modifier = Modifier, iconBrush: Brush? = null, ) { Column( modifier = modifier .aspectRatio(1f) .clip(RoundedCornerShape(24.dp)) .clickable { onClick() } .border( width = 2.dp, color = MaterialTheme.colorScheme.surfaceContainerHigh, shape = RoundedCornerShape(24.dp), ) ) { Column( verticalArrangement = Arrangement.SpaceBetween, horizontalAlignment = Alignment.Start, modifier = Modifier.padding(18.dp).fillMaxSize(), ) { Icon( icon, contentDescription = null, modifier = Modifier.size(40.dp) .then( if (iconBrush != null) { Modifier.graphicsLayer( // Required for some devices to blend correctly alpha = 0.99f ) .drawWithContent { // Draws the icon first drawContent() // Masks the brush to the icon's shape drawRect(brush = iconBrush, blendMode = BlendMode.SrcIn) } } else { Modifier } ), ) Column(modifier = Modifier.fillMaxWidth(), verticalArrangement = Arrangement.spacedBy(4.dp)) { Text( label, color = MaterialTheme.colorScheme.onSurface, style = MaterialTheme.typography.bodyLarge.copy(fontWeight = FontWeight.Medium), ) Text( description, color = MaterialTheme.colorScheme.onSurfaceVariant, style = MaterialTheme.typography.bodySmall, maxLines = 2, autoSize = TextAutoSize.StepBased(minFontSize = 8.sp, maxFontSize = 12.sp, stepSize = 1.sp), ) } } } } ================================================ FILE: Android/src/app/src/main/java/com/google/ai/edge/gallery/ui/icon/Deploy.kt ================================================ /* * Copyright 2025 Google LLC * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * http://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. */ package com.google.ai.edge.gallery.ui.icon import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.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 ================================================ FILE: Android/src/app/src/main/java/com/google/ai/edge/gallery/ui/llmchat/LlmChatModelHelper.kt ================================================ /* * Copyright 2025 Google LLC * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * http://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. */ package com.google.ai.edge.gallery.ui.llmchat import android.content.Context import android.graphics.Bitmap import android.util.Log import com.google.ai.edge.gallery.common.cleanUpMediapipeTaskErrorMessage import com.google.ai.edge.gallery.data.Accelerator import com.google.ai.edge.gallery.data.ConfigKeys import com.google.ai.edge.gallery.data.DEFAULT_MAX_TOKEN import com.google.ai.edge.gallery.data.DEFAULT_TEMPERATURE import com.google.ai.edge.gallery.data.DEFAULT_TOPK import com.google.ai.edge.gallery.data.DEFAULT_TOPP import com.google.ai.edge.gallery.data.DEFAULT_VISION_ACCELERATOR import com.google.ai.edge.gallery.data.Model import com.google.ai.edge.gallery.runtime.CleanUpListener import com.google.ai.edge.gallery.runtime.LlmModelHelper import com.google.ai.edge.gallery.runtime.ResultListener import com.google.ai.edge.litertlm.Backend import com.google.ai.edge.litertlm.Content import com.google.ai.edge.litertlm.Contents import com.google.ai.edge.litertlm.Conversation import com.google.ai.edge.litertlm.ConversationConfig import com.google.ai.edge.litertlm.Engine import com.google.ai.edge.litertlm.EngineConfig import com.google.ai.edge.litertlm.ExperimentalApi import com.google.ai.edge.litertlm.ExperimentalFlags import com.google.ai.edge.litertlm.Message import com.google.ai.edge.litertlm.MessageCallback import com.google.ai.edge.litertlm.SamplerConfig import java.io.ByteArrayOutputStream import java.util.concurrent.CancellationException import kotlinx.coroutines.CoroutineScope private const val TAG = "AGLlmChatModelHelper" data class LlmModelInstance(val engine: Engine, var conversation: Conversation) object LlmChatModelHelper : LlmModelHelper { // Indexed by model name. private val cleanUpListeners: MutableMap = mutableMapOf() @OptIn(ExperimentalApi::class) // opt-in experimental flags override fun initialize( context: Context, model: Model, supportImage: Boolean, supportAudio: Boolean, onDone: (String) -> Unit, systemInstruction: Contents?, tools: List, enableConversationConstrainedDecoding: Boolean, coroutineScope: CoroutineScope?, ) { // Prepare options. val maxTokens = model.getIntConfigValue(key = ConfigKeys.MAX_TOKENS, defaultValue = DEFAULT_MAX_TOKEN) val topK = model.getIntConfigValue(key = ConfigKeys.TOPK, defaultValue = DEFAULT_TOPK) val topP = model.getFloatConfigValue(key = ConfigKeys.TOPP, defaultValue = DEFAULT_TOPP) val temperature = model.getFloatConfigValue(key = ConfigKeys.TEMPERATURE, defaultValue = DEFAULT_TEMPERATURE) val accelerator = model.getStringConfigValue(key = ConfigKeys.ACCELERATOR, defaultValue = Accelerator.GPU.label) val visionAccelerator = model.getStringConfigValue( key = ConfigKeys.VISION_ACCELERATOR, defaultValue = DEFAULT_VISION_ACCELERATOR.label, ) val visionBackend = when (visionAccelerator) { Accelerator.CPU.label -> Backend.CPU() Accelerator.GPU.label -> Backend.GPU() Accelerator.NPU.label -> Backend.NPU() else -> Backend.GPU() } val shouldEnableImage = supportImage val shouldEnableAudio = supportAudio val preferredBackend = when (accelerator) { Accelerator.CPU.label -> Backend.CPU() Accelerator.GPU.label -> Backend.GPU() Accelerator.NPU.label -> Backend.NPU() else -> Backend.CPU() } Log.d(TAG, "Preferred backend: $preferredBackend") if (preferredBackend is Backend.NPU) { ExperimentalFlags.npuLibrariesDir = context.applicationInfo.nativeLibraryDir } val modelPath = model.getPath(context = context) val engineConfig = EngineConfig( modelPath = modelPath, backend = preferredBackend, visionBackend = if (shouldEnableImage) visionBackend else null, // must be GPU for Gemma 3n audioBackend = if (shouldEnableAudio) Backend.CPU() else null, // must be CPU for Gemma 3n maxNumTokens = maxTokens, cacheDir = if (modelPath.startsWith("/data/local/tmp")) context.getExternalFilesDir(null)?.absolutePath else null, ) // Create an instance of LiteRT LM engine and conversation. try { val engine = Engine(engineConfig) engine.initialize() ExperimentalFlags.enableConversationConstrainedDecoding = enableConversationConstrainedDecoding val conversation = engine.createConversation( ConversationConfig( samplerConfig = if (preferredBackend is Backend.NPU) { null } else { SamplerConfig( topK = topK, topP = topP.toDouble(), temperature = temperature.toDouble(), ) }, systemInstruction = systemInstruction, tools = tools, ) ) ExperimentalFlags.enableConversationConstrainedDecoding = false model.instance = LlmModelInstance(engine = engine, conversation = conversation) } catch (e: Exception) { onDone(cleanUpMediapipeTaskErrorMessage(e.message ?: "Unknown error")) return } onDone("") } @OptIn(ExperimentalApi::class) // opt-in experimental flags override fun resetConversation( model: Model, supportImage: Boolean, supportAudio: Boolean, systemInstruction: Contents?, tools: List, enableConversationConstrainedDecoding: Boolean, ) { try { Log.d(TAG, "Resetting conversation for model '${model.name}'") val instance = model.instance as LlmModelInstance? ?: return instance.conversation.close() val engine = instance.engine val topK = model.getIntConfigValue(key = ConfigKeys.TOPK, defaultValue = DEFAULT_TOPK) val topP = model.getFloatConfigValue(key = ConfigKeys.TOPP, defaultValue = DEFAULT_TOPP) val temperature = model.getFloatConfigValue(key = ConfigKeys.TEMPERATURE, defaultValue = DEFAULT_TEMPERATURE) val shouldEnableImage = supportImage val shouldEnableAudio = supportAudio Log.d(TAG, "Enable image: $shouldEnableImage, enable audio: $shouldEnableAudio") val accelerator = model.getStringConfigValue( key = ConfigKeys.ACCELERATOR, defaultValue = Accelerator.GPU.label, ) ExperimentalFlags.enableConversationConstrainedDecoding = enableConversationConstrainedDecoding val newConversation = engine.createConversation( ConversationConfig( samplerConfig = if (accelerator == Accelerator.NPU.label) { null } else { SamplerConfig( topK = topK, topP = topP.toDouble(), temperature = temperature.toDouble(), ) }, systemInstruction = systemInstruction, tools = tools, ) ) ExperimentalFlags.enableConversationConstrainedDecoding = false instance.conversation = newConversation Log.d(TAG, "Resetting done") } catch (e: Exception) { Log.d(TAG, "Failed to reset conversation", e) } } override fun cleanUp(model: Model, onDone: () -> Unit) { if (model.instance == null) { return } val instance = model.instance as LlmModelInstance try { instance.conversation.close() } catch (e: Exception) { Log.e(TAG, "Failed to close the conversation: ${e.message}") } try { instance.engine.close() } catch (e: Exception) { Log.e(TAG, "Failed to close the engine: ${e.message}") } val onCleanUp = cleanUpListeners.remove(model.name) if (onCleanUp != null) { onCleanUp() } model.instance = null onDone() Log.d(TAG, "Clean up done.") } override fun stopResponse(model: Model) { val instance = model.instance as? LlmModelInstance ?: return instance.conversation.cancelProcess() } override fun runInference( model: Model, input: String, resultListener: ResultListener, cleanUpListener: CleanUpListener, onError: (message: String) -> Unit, images: List, audioClips: List, coroutineScope: CoroutineScope?, ) { val instance = model.instance as? LlmModelInstance if (instance == null) { onError("LlmModelInstance is not initialized.") return } // Set listener. if (!cleanUpListeners.containsKey(model.name)) { cleanUpListeners[model.name] = cleanUpListener } val conversation = instance.conversation val contents = mutableListOf() for (image in images) { contents.add(Content.ImageBytes(image.toPngByteArray())) } for (audioClip in audioClips) { contents.add(Content.AudioBytes(audioClip)) } // add the text after image and audio for the accurate last token if (input.trim().isNotEmpty()) { contents.add(Content.Text(input)) } conversation.sendMessageAsync( Contents.of(contents), object : MessageCallback { override fun onMessage(message: Message) { resultListener(message.toString(), false) } override fun onDone() { resultListener("", true) } override fun onError(throwable: Throwable) { if (throwable is CancellationException) { Log.i(TAG, "The inference is cancelled.") resultListener("", true) } else { Log.e(TAG, "onError", throwable) onError("Error: ${throwable.message}") } } }, ) } private fun Bitmap.toPngByteArray(): ByteArray { val stream = ByteArrayOutputStream() this.compress(Bitmap.CompressFormat.PNG, 100, stream) return stream.toByteArray() } } ================================================ FILE: Android/src/app/src/main/java/com/google/ai/edge/gallery/ui/llmchat/LlmChatScreen.kt ================================================ /* * Copyright 2025 Google LLC * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * http://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. */ package com.google.ai.edge.gallery.ui.llmchat import androidx.hilt.navigation.compose.hiltViewModel import android.graphics.Bitmap import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.padding import androidx.compose.runtime.Composable import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.semantics.LiveRegionMode import androidx.compose.ui.semantics.liveRegion import androidx.compose.ui.semantics.semantics import androidx.compose.ui.unit.dp import androidx.core.os.bundleOf import com.google.ai.edge.gallery.GalleryEvent import com.google.ai.edge.gallery.data.BuiltInTaskId import com.google.ai.edge.gallery.data.Model import com.google.ai.edge.gallery.data.Task import com.google.ai.edge.gallery.firebaseAnalytics import com.google.ai.edge.gallery.ui.common.chat.ChatMessage import com.google.ai.edge.gallery.ui.common.chat.ChatMessageAudioClip import com.google.ai.edge.gallery.ui.common.chat.ChatMessageImage import com.google.ai.edge.gallery.ui.common.chat.ChatMessageInfo import com.google.ai.edge.gallery.ui.common.chat.ChatMessageText import com.google.ai.edge.gallery.ui.common.chat.ChatView import com.google.ai.edge.gallery.ui.common.chat.MessageBodyInfo import com.google.ai.edge.gallery.ui.modelmanager.ModelManagerViewModel private const val TAG = "AGLlmChatScreen" @Composable fun LlmChatScreen( modelManagerViewModel: ModelManagerViewModel, navigateUp: () -> Unit, modifier: Modifier = Modifier, taskId: String = BuiltInTaskId.LLM_CHAT, onFirstToken: (Model) -> Unit = {}, onGenerateResponseDone: (Model) -> Unit = {}, onResetSessionClickedOverride: ((Task, Model) -> Unit)? = null, composableBelowMessageList: @Composable (Model) -> Unit = {}, viewModel: LlmChatViewModel = hiltViewModel(), allowEditingSystemPrompt: Boolean = false, curSystemPrompt: String = "", onSystemPromptChanged: (String) -> Unit = {}, emptyStateComposable: @Composable () -> Unit = {}, sendMessageTrigger: Pair>? = null, ) { ChatViewWrapper( viewModel = viewModel, modelManagerViewModel = modelManagerViewModel, taskId = taskId, navigateUp = navigateUp, modifier = modifier, onFirstToken = onFirstToken, onGenerateResponseDone = onGenerateResponseDone, onResetSessionClickedOverride = onResetSessionClickedOverride, composableBelowMessageList = composableBelowMessageList, allowEditingSystemPrompt = allowEditingSystemPrompt, curSystemPrompt = curSystemPrompt, onSystemPromptChanged = onSystemPromptChanged, emptyStateComposable = emptyStateComposable, sendMessageTrigger = sendMessageTrigger, ) } @Composable fun LlmAskImageScreen( modelManagerViewModel: ModelManagerViewModel, navigateUp: () -> Unit, modifier: Modifier = Modifier, viewModel: LlmAskImageViewModel = hiltViewModel(), ) { ChatViewWrapper( viewModel = viewModel, modelManagerViewModel = modelManagerViewModel, taskId = BuiltInTaskId.LLM_ASK_IMAGE, navigateUp = navigateUp, modifier = modifier, emptyStateComposable = { Column( modifier = Modifier.padding(horizontal = 16.dp).fillMaxSize().semantics(mergeDescendants = true) { liveRegion = LiveRegionMode.Polite }, horizontalAlignment = Alignment.CenterHorizontally, verticalArrangement = Arrangement.Center, ) { MessageBodyInfo( ChatMessageInfo( content = "To get started, tap the Add image button below to add images (up to 10 in a single session) and type a prompt to ask a question about it." ), smallFontSize = false, ) } }, ) } @Composable fun LlmAskAudioScreen( modelManagerViewModel: ModelManagerViewModel, navigateUp: () -> Unit, modifier: Modifier = Modifier, viewModel: LlmAskAudioViewModel = hiltViewModel(), ) { ChatViewWrapper( viewModel = viewModel, modelManagerViewModel = modelManagerViewModel, taskId = BuiltInTaskId.LLM_ASK_AUDIO, navigateUp = navigateUp, modifier = modifier, emptyStateComposable = { Column( modifier = Modifier.padding(horizontal = 16.dp).fillMaxSize().semantics(mergeDescendants = true) { liveRegion = LiveRegionMode.Polite }, horizontalAlignment = Alignment.CenterHorizontally, verticalArrangement = Arrangement.Center, ) { MessageBodyInfo( ChatMessageInfo( content = "To get started, tap the Add audio button below to add your audio clip. Limited to 1 clip up to 30 seconds long." ), smallFontSize = false, ) } }, ) } @Composable fun ChatViewWrapper( viewModel: LlmChatViewModelBase, modelManagerViewModel: ModelManagerViewModel, taskId: String, navigateUp: () -> Unit, modifier: Modifier = Modifier, onFirstToken: (Model) -> Unit = {}, onGenerateResponseDone: (Model) -> Unit = {}, onResetSessionClickedOverride: ((Task, Model) -> Unit)? = null, composableBelowMessageList: @Composable (Model) -> Unit = {}, emptyStateComposable: @Composable () -> Unit = {}, allowEditingSystemPrompt: Boolean = false, curSystemPrompt: String = "", onSystemPromptChanged: (String) -> Unit = {}, sendMessageTrigger: Pair>? = null, ) { val context = LocalContext.current val task = modelManagerViewModel.getTaskById(id = taskId)!! ChatView( task = task, viewModel = viewModel, modelManagerViewModel = modelManagerViewModel, onSendMessage = { model, messages -> for (message in messages) { viewModel.addMessage(model = model, message = message) } var text = "" val images: MutableList = mutableListOf() val audioMessages: MutableList = mutableListOf() var chatMessageText: ChatMessageText? = null for (message in messages) { if (message is ChatMessageText) { chatMessageText = message text = message.content } else if (message is ChatMessageImage) { images.addAll(message.bitmaps) } else if (message is ChatMessageAudioClip) { audioMessages.add(message) } } if ((text.isNotEmpty() && chatMessageText != null) || audioMessages.isNotEmpty()) { modelManagerViewModel.addTextInputHistory(text) viewModel.generateResponse( model = model, input = text, images = images, audioMessages = audioMessages, onFirstToken = onFirstToken, onDone = { onGenerateResponseDone(model) }, onError = { errorMessage -> viewModel.handleError( context = context, task = task, model = model, errorMessage = errorMessage, modelManagerViewModel = modelManagerViewModel, ) }, ) firebaseAnalytics?.logEvent( GalleryEvent.GENERATE_ACTION.id, bundleOf("capability_name" to task.id, "model_id" to model.name), ) } }, onRunAgainClicked = { model, message -> if (message is ChatMessageText) { viewModel.runAgain( model = model, message = message, onError = { errorMessage -> viewModel.handleError( context = context, task = task, model = model, errorMessage = errorMessage, modelManagerViewModel = modelManagerViewModel, ) }, ) } }, onBenchmarkClicked = { _, _, _, _ -> }, onResetSessionClicked = { model -> if (onResetSessionClickedOverride != null) { onResetSessionClickedOverride(task, model) } else { viewModel.resetSession(task = task, model = model) } }, showStopButtonInInputWhenInProgress = true, onStopButtonClicked = { model -> viewModel.stopResponse(model = model) }, navigateUp = navigateUp, modifier = modifier, composableBelowMessageList = composableBelowMessageList, emptyStateComposable = emptyStateComposable, allowEditingSystemPrompt = allowEditingSystemPrompt, curSystemPrompt = curSystemPrompt, onSystemPromptChanged = onSystemPromptChanged, sendMessageTrigger = sendMessageTrigger, ) } ================================================ FILE: Android/src/app/src/main/java/com/google/ai/edge/gallery/ui/llmchat/LlmChatTaskModule.kt ================================================ /* * Copyright 2025 Google LLC * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * http://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. */ package com.google.ai.edge.gallery.ui.llmchat import android.content.Context import androidx.compose.material.icons.Icons import androidx.compose.material.icons.outlined.Forum import androidx.compose.material.icons.outlined.Mic import androidx.compose.material.icons.outlined.Mms import androidx.compose.runtime.Composable import com.google.ai.edge.gallery.R import com.google.ai.edge.gallery.customtasks.common.CustomTask import com.google.ai.edge.gallery.customtasks.common.CustomTaskDataForBuiltinTask import com.google.ai.edge.gallery.data.BuiltInTaskId import com.google.ai.edge.gallery.data.Category import com.google.ai.edge.gallery.data.Model import com.google.ai.edge.gallery.data.Task import com.google.ai.edge.gallery.runtime.runtimeHelper import dagger.Module import dagger.Provides import dagger.hilt.InstallIn import dagger.hilt.components.SingletonComponent import dagger.multibindings.IntoSet import javax.inject.Inject import kotlinx.coroutines.CoroutineScope //////////////////////////////////////////////////////////////////////////////////////////////////// // AI Chat. class LlmChatTask @Inject constructor() : CustomTask { override val task: Task = Task( id = BuiltInTaskId.LLM_CHAT, label = "AI Chat", category = Category.LLM, icon = Icons.Outlined.Forum, models = mutableListOf(), description = "Chat with on-device large language models", docUrl = "https://github.com/google-ai-edge/LiteRT-LM/blob/main/kotlin/README.md", sourceCodeUrl = "https://github.com/google-ai-edge/gallery/blob/main/Android/src/app/src/main/java/com/google/ai/edge/gallery/ui/llmchat/LlmChatModelHelper.kt", textInputPlaceHolderRes = R.string.text_input_placeholder_llm_chat, ) override fun initializeModelFn( context: Context, coroutineScope: CoroutineScope, model: Model, onDone: (String) -> Unit, ) { model.runtimeHelper.initialize( context = context, model = model, supportImage = false, supportAudio = false, onDone = onDone, coroutineScope = coroutineScope, ) } override fun cleanUpModelFn( context: Context, coroutineScope: CoroutineScope, model: Model, onDone: () -> Unit, ) { model.runtimeHelper.cleanUp(model = model, onDone = onDone) } @Composable override fun MainScreen(data: Any) { val myData = data as CustomTaskDataForBuiltinTask LlmChatScreen(modelManagerViewModel = myData.modelManagerViewModel, navigateUp = myData.onNavUp) } } @Module @InstallIn(SingletonComponent::class) // Or another component that fits your scope internal object LlmChatTaskModule { @Provides @IntoSet fun provideTask(): CustomTask { return LlmChatTask() } } //////////////////////////////////////////////////////////////////////////////////////////////////// // Ask image. class LlmAskImageTask @Inject constructor() : CustomTask { override val task: Task = Task( id = BuiltInTaskId.LLM_ASK_IMAGE, label = "Ask Image", category = Category.LLM, icon = Icons.Outlined.Mms, models = mutableListOf(), description = "Ask questions about images with on-device large language models", docUrl = "https://github.com/google-ai-edge/LiteRT-LM/blob/main/kotlin/README.md", sourceCodeUrl = "https://github.com/google-ai-edge/gallery/blob/main/Android/src/app/src/main/java/com/google/ai/edge/gallery/ui/llmchat/LlmChatModelHelper.kt", textInputPlaceHolderRes = R.string.text_input_placeholder_llm_chat, ) override fun initializeModelFn( context: Context, coroutineScope: CoroutineScope, model: Model, onDone: (String) -> Unit, ) { model.runtimeHelper.initialize( context = context, model = model, supportImage = true, supportAudio = false, onDone = onDone, coroutineScope = coroutineScope, ) } override fun cleanUpModelFn( context: Context, coroutineScope: CoroutineScope, model: Model, onDone: () -> Unit, ) { model.runtimeHelper.cleanUp(model = model, onDone = onDone) } @Composable override fun MainScreen(data: Any) { val myData = data as CustomTaskDataForBuiltinTask LlmAskImageScreen( modelManagerViewModel = myData.modelManagerViewModel, navigateUp = myData.onNavUp, ) } } @Module @InstallIn(SingletonComponent::class) // Or another component that fits your scope internal object LlmAskImageModule { @Provides @IntoSet fun provideTask(): CustomTask { return LlmAskImageTask() } } //////////////////////////////////////////////////////////////////////////////////////////////////// // Ask audio. class LlmAskAudioTask @Inject constructor() : CustomTask { override val task: Task = Task( id = BuiltInTaskId.LLM_ASK_AUDIO, label = "Audio Scribe", category = Category.LLM, icon = Icons.Outlined.Mic, models = mutableListOf(), description = "Instantly transcribe and/or translate audio clips using on-device large language models", docUrl = "https://github.com/google-ai-edge/LiteRT-LM/blob/main/kotlin/README.md", sourceCodeUrl = "https://github.com/google-ai-edge/gallery/blob/main/Android/src/app/src/main/java/com/google/ai/edge/gallery/ui/llmchat/LlmChatModelHelper.kt", textInputPlaceHolderRes = R.string.text_input_placeholder_llm_chat, ) override fun initializeModelFn( context: Context, coroutineScope: CoroutineScope, model: Model, onDone: (String) -> Unit, ) { model.runtimeHelper.initialize( context = context, model = model, supportImage = false, supportAudio = true, onDone = onDone, coroutineScope = coroutineScope, ) } override fun cleanUpModelFn( context: Context, coroutineScope: CoroutineScope, model: Model, onDone: () -> Unit, ) { model.runtimeHelper.cleanUp(model = model, onDone = onDone) } @Composable override fun MainScreen(data: Any) { val myData = data as CustomTaskDataForBuiltinTask LlmAskAudioScreen( modelManagerViewModel = myData.modelManagerViewModel, navigateUp = myData.onNavUp, ) } } @Module @InstallIn(SingletonComponent::class) // Or another component that fits your scope internal object LlmAskAudioModule { @Provides @IntoSet fun provideTask(): CustomTask { return LlmAskAudioTask() } } ================================================ FILE: Android/src/app/src/main/java/com/google/ai/edge/gallery/ui/llmchat/LlmChatViewModel.kt ================================================ /* * Copyright 2025 Google LLC * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * http://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. */ package com.google.ai.edge.gallery.ui.llmchat import android.content.Context import android.graphics.Bitmap import android.util.Log import androidx.lifecycle.viewModelScope import com.google.ai.edge.gallery.data.ConfigKeys import com.google.ai.edge.gallery.data.Model import com.google.ai.edge.gallery.data.Task import com.google.ai.edge.gallery.runtime.runtimeHelper import com.google.ai.edge.gallery.ui.common.chat.ChatMessageAudioClip import com.google.ai.edge.gallery.ui.common.chat.ChatMessageError import com.google.ai.edge.gallery.ui.common.chat.ChatMessageLoading import com.google.ai.edge.gallery.ui.common.chat.ChatMessageText import com.google.ai.edge.gallery.ui.common.chat.ChatMessageType import com.google.ai.edge.gallery.ui.common.chat.ChatMessageWarning import com.google.ai.edge.gallery.ui.common.chat.ChatSide import com.google.ai.edge.gallery.ui.common.chat.ChatViewModel import com.google.ai.edge.gallery.ui.modelmanager.ModelManagerViewModel import com.google.ai.edge.litertlm.Contents import com.google.ai.edge.litertlm.ExperimentalApi import dagger.hilt.android.lifecycle.HiltViewModel import javax.inject.Inject import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.delay import kotlinx.coroutines.launch private const val TAG = "AGLlmChatViewModel" @OptIn(ExperimentalApi::class) open class LlmChatViewModelBase() : ChatViewModel() { fun generateResponse( model: Model, input: String, images: List = listOf(), audioMessages: List = listOf(), onFirstToken: (Model) -> Unit = {}, onDone: () -> Unit = {}, onError: (String) -> Unit, ) { val accelerator = model.getStringConfigValue(key = ConfigKeys.ACCELERATOR, defaultValue = "") viewModelScope.launch(Dispatchers.Default) { setInProgress(true) setPreparing(true) // Loading. addMessage(model = model, message = ChatMessageLoading(accelerator = accelerator)) // Wait for instance to be initialized. while (model.instance == null) { delay(100) } delay(500) // Run inference. val audioClips: MutableList = mutableListOf() for (audioMessage in audioMessages) { audioClips.add(audioMessage.genByteArrayForWav()) } var firstRun = true val start = System.currentTimeMillis() try { val resultListener: (String, Boolean) -> Unit = { partialResult, done -> if (partialResult.startsWith(" Unit = { setInProgress(false) setPreparing(false) } val errorListener: (String) -> Unit = { message -> Log.e(TAG, "Error occurred while running inference") setInProgress(false) setPreparing(false) onError(message) } model.runtimeHelper.runInference( model = model, input = input, images = images, audioClips = audioClips, resultListener = resultListener, cleanUpListener = cleanUpListener, onError = errorListener, coroutineScope = viewModelScope, ) } catch (e: Exception) { Log.e(TAG, "Error occurred while running inference", e) setInProgress(false) setPreparing(false) onError(e.message ?: "") } } } fun stopResponse(model: Model) { Log.d(TAG, "Stopping response for model ${model.name}...") if (getLastMessage(model = model) is ChatMessageLoading) { removeLastMessage(model = model) } setInProgress(false) model.runtimeHelper.stopResponse(model) Log.d(TAG, "Done stopping response") } fun resetSession( task: Task, model: Model, systemInstruction: Contents? = null, tools: List = listOf(), onDone: () -> Unit = {}, ) { viewModelScope.launch(Dispatchers.Default) { setIsResettingSession(true) clearAllMessages(model = model) stopResponse(model = model) while (true) { try { val supportImage = model.llmSupportImage && task.id == com.google.ai.edge.gallery.data.BuiltInTaskId.LLM_ASK_IMAGE val supportAudio = model.llmSupportAudio && task.id == com.google.ai.edge.gallery.data.BuiltInTaskId.LLM_ASK_AUDIO model.runtimeHelper.resetConversation( model = model, supportImage = supportImage, supportAudio = supportAudio, systemInstruction = systemInstruction, tools = tools, ) break } catch (e: Exception) { Log.d(TAG, "Failed to reset session. Trying again") } delay(200) } setIsResettingSession(false) onDone() } } fun runAgain(model: Model, message: ChatMessageText, onError: (String) -> Unit) { 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, onError = onError) } } fun handleError( context: Context, task: Task, model: Model, modelManagerViewModel: ModelManagerViewModel, errorMessage: String, ) { // Remove the "loading" message. if (getLastMessage(model = model) is ChatMessageLoading) { removeLastMessage(model = model) } // Show error message. addMessage(model = model, message = ChatMessageError(content = errorMessage)) // Clean up and re-initialize. viewModelScope.launch(Dispatchers.Default) { modelManagerViewModel.cleanupModel( context = context, task = task, model = model, onDone = { modelManagerViewModel.initializeModel(context = context, task = task, model = model) // Add a warning message for re-initializing the session. addMessage( model = model, message = ChatMessageWarning(content = "Session re-initialized"), ) }, ) } } } @HiltViewModel class LlmChatViewModel @Inject constructor() : LlmChatViewModelBase() @HiltViewModel class LlmAskImageViewModel @Inject constructor() : LlmChatViewModelBase() @HiltViewModel class LlmAskAudioViewModel @Inject constructor() : LlmChatViewModelBase() ================================================ FILE: Android/src/app/src/main/java/com/google/ai/edge/gallery/ui/llmsingleturn/LlmSingleTurnScreen.kt ================================================ /* * Copyright 2025 Google LLC * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * http://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. */ package com.google.ai.edge.gallery.ui.llmsingleturn import androidx.hilt.navigation.compose.hiltViewModel // import androidx.compose.ui.tooling.preview.Preview // import com.google.ai.edge.gallery.ui.preview.PreviewLlmSingleTurnViewModel // import com.google.ai.edge.gallery.ui.preview.PreviewModelManagerViewModel // import com.google.ai.edge.gallery.ui.theme.GalleryTheme import android.util.Log import androidx.activity.compose.BackHandler import androidx.compose.animation.AnimatedVisibility import androidx.compose.animation.core.animateFloatAsState 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.calculateStartPadding import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.padding 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.platform.LocalLayoutDirection import androidx.core.os.bundleOf import com.google.ai.edge.gallery.GalleryEvent import com.google.ai.edge.gallery.data.BuiltInTaskId import com.google.ai.edge.gallery.data.ModelDownloadStatusType import com.google.ai.edge.gallery.firebaseAnalytics import com.google.ai.edge.gallery.ui.common.ErrorDialog import com.google.ai.edge.gallery.ui.common.ModelPageAppBar import com.google.ai.edge.gallery.ui.common.chat.ModelDownloadStatusInfoPanel import com.google.ai.edge.gallery.ui.modelmanager.ModelInitializationStatusType import com.google.ai.edge.gallery.ui.modelmanager.ModelManagerViewModel import com.google.ai.edge.gallery.ui.theme.customColors import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.launch private const val TAG = "AGLlmSingleTurnScreen" @Composable fun LlmSingleTurnScreen( modelManagerViewModel: ModelManagerViewModel, navigateUp: () -> Unit, modifier: Modifier = Modifier, viewModel: LlmSingleTurnViewModel = hiltViewModel(), ) { val task = modelManagerViewModel.getTaskById(id = BuiltInTaskId.LLM_PROMPT_LAB)!! val modelManagerUiState by modelManagerViewModel.uiState.collectAsState() val uiState by viewModel.uiState.collectAsState() val selectedModel = modelManagerUiState.selectedModel val scope = rememberCoroutineScope() val context = LocalContext.current var navigatingUp by remember { mutableStateOf(false) } var showErrorDialog by remember { mutableStateOf(false) } val handleNavigateUp = { navigatingUp = true navigateUp() // clean up all models. scope.launch(Dispatchers.Default) { for (model in task.models) { modelManagerViewModel.cleanupModel(context = context, task = task, model = model) } } } // Handle system's edge swipe. BackHandler { val modelInitializationStatus = modelManagerUiState.modelInitializationStatus[selectedModel.name] val isModelInitializing = modelInitializationStatus?.status == ModelInitializationStatusType.INITIALIZING if (!isModelInitializing && !uiState.inProgress) { handleNavigateUp() } } // Initialize model when model/download state changes. val curDownloadStatus = modelManagerUiState.modelDownloadStatus[selectedModel.name] LaunchedEffect(curDownloadStatus, selectedModel.name) { if (!navigatingUp) { if (curDownloadStatus?.status == ModelDownloadStatusType.SUCCEEDED) { Log.d( TAG, "Initializing model '${selectedModel.name}' from LlmsingleTurnScreen launched effect", ) modelManagerViewModel.initializeModel(context, task = task, model = selectedModel) } } } val modelInitializationStatus = modelManagerUiState.modelInitializationStatus[selectedModel.name] LaunchedEffect(modelInitializationStatus) { showErrorDialog = modelInitializationStatus?.status == ModelInitializationStatusType.ERROR } Scaffold( modifier = modifier, topBar = { ModelPageAppBar( task = task, model = selectedModel, modelManagerViewModel = modelManagerViewModel, inProgress = uiState.inProgress, modelPreparing = uiState.preparing, onConfigChanged = { _, _ -> }, onBackClicked = { handleNavigateUp() }, onModelSelected = { prevModel, newSelectedModel -> scope.launch(Dispatchers.Default) { if (prevModel.name != newSelectedModel.name) { // Clean up prev model. modelManagerViewModel.cleanupModel(context = context, task = task, model = prevModel) } // Update selected model. modelManagerViewModel.selectModel(model = newSelectedModel) } }, ) }, ) { innerPadding -> Box( modifier = Modifier.padding( top = innerPadding.calculateTopPadding(), start = innerPadding.calculateStartPadding(LocalLayoutDirection.current), end = innerPadding.calculateStartPadding(LocalLayoutDirection.current), ) ) { val modelDownloaded = curDownloadStatus?.status == ModelDownloadStatusType.SUCCEEDED AnimatedVisibility( visible = !modelDownloaded, enter = scaleIn(initialScale = 0.9f) + fadeIn(), exit = scaleOut(targetScale = 0.9f) + fadeOut(), ) { ModelDownloadStatusInfoPanel( model = selectedModel, task = task, modelManagerViewModel = modelManagerViewModel, ) } // Main UI after model is downloaded. var mainUiVisible by remember { mutableStateOf(modelDownloaded) } LaunchedEffect(modelDownloaded) { mainUiVisible = modelDownloaded } val animatedAlpha by animateFloatAsState(targetValue = if (mainUiVisible) 1.0f else 0f) Box( contentAlignment = Alignment.BottomCenter, modifier = Modifier.fillMaxSize() // Just hide the UI without removing it from the screen so that the scroll syncing // from ResponsePanel still works. .graphicsLayer { alpha = animatedAlpha }, ) { VerticalSplitView( modifier = Modifier.fillMaxSize(), topView = { PromptTemplatesPanel( model = selectedModel, viewModel = viewModel, modelManagerViewModel = modelManagerViewModel, onSend = { fullPrompt -> viewModel.generateResponse(task = task, model = selectedModel, input = fullPrompt) firebaseAnalytics?.logEvent( GalleryEvent.GENERATE_ACTION.id, bundleOf("capability_name" to task.id, "model_id" to selectedModel.name), ) }, onStopButtonClicked = { model -> viewModel.stopResponse(model = model) }, modifier = Modifier.fillMaxSize(), ) }, bottomView = { Box( contentAlignment = Alignment.BottomCenter, modifier = Modifier.fillMaxSize().background(MaterialTheme.customColors.agentBubbleBgColor), ) { if (task.models.indexOf(selectedModel) >= 0) { ResponsePanel( task = task, model = selectedModel, viewModel = viewModel, modelManagerViewModel = modelManagerViewModel, modifier = Modifier.fillMaxSize().padding(bottom = innerPadding.calculateBottomPadding()), ) } } }, ) } if (showErrorDialog) { ErrorDialog( error = modelInitializationStatus?.error ?: "", onDismiss = { showErrorDialog = false }, ) } } } } // @Preview(showBackground = true) // @Composable // fun LlmSingleTurnScreenPreview() { // val context = LocalContext.current // GalleryTheme { // LlmSingleTurnScreen( // modelManagerViewModel = PreviewModelManagerViewModel(context = context), // viewModel = PreviewLlmSingleTurnViewModel(), // navigateUp = {}, // ) // } // } ================================================ FILE: Android/src/app/src/main/java/com/google/ai/edge/gallery/ui/llmsingleturn/LlmSingleTurnTaskModule.kt ================================================ /* * Copyright 2025 Google LLC * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * http://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. */ package com.google.ai.edge.gallery.ui.llmsingleturn import android.content.Context import androidx.compose.material.icons.Icons import androidx.compose.material.icons.outlined.Widgets import androidx.compose.runtime.Composable import com.google.ai.edge.gallery.R import com.google.ai.edge.gallery.customtasks.common.CustomTask import com.google.ai.edge.gallery.customtasks.common.CustomTaskDataForBuiltinTask import com.google.ai.edge.gallery.data.BuiltInTaskId import com.google.ai.edge.gallery.data.Category import com.google.ai.edge.gallery.data.Model import com.google.ai.edge.gallery.data.Task import com.google.ai.edge.gallery.ui.llmchat.LlmChatModelHelper import dagger.Module import dagger.Provides import dagger.hilt.InstallIn import dagger.hilt.components.SingletonComponent import dagger.multibindings.IntoSet import javax.inject.Inject import kotlinx.coroutines.CoroutineScope class LlmSingleTurnTask @Inject constructor() : CustomTask { override val task: Task = Task( id = BuiltInTaskId.LLM_PROMPT_LAB, label = "Prompt Lab", category = Category.LLM, icon = Icons.Outlined.Widgets, models = mutableListOf(), description = "Single turn use cases with on-device large language models", docUrl = "https://github.com/google-ai-edge/LiteRT-LM/blob/main/kotlin/README.md", sourceCodeUrl = "https://github.com/google-ai-edge/gallery/blob/main/Android/src/app/src/main/java/com/google/ai/edge/gallery/ui/llmchat/LlmChatModelHelper.kt", textInputPlaceHolderRes = R.string.text_input_placeholder_llm_chat, ) override fun initializeModelFn( context: Context, coroutineScope: CoroutineScope, model: Model, onDone: (String) -> Unit, ) { LlmChatModelHelper.initialize( context = context, model = model, supportImage = false, supportAudio = false, onDone = onDone, ) } override fun cleanUpModelFn( context: Context, coroutineScope: CoroutineScope, model: Model, onDone: () -> Unit, ) { LlmChatModelHelper.cleanUp(model = model, onDone = onDone) } @Composable override fun MainScreen(data: Any) { val myData = data as CustomTaskDataForBuiltinTask LlmSingleTurnScreen( modelManagerViewModel = myData.modelManagerViewModel, navigateUp = myData.onNavUp, ) } } @Module @InstallIn(SingletonComponent::class) // Or another component that fits your scope internal object LlmSingleTurnTaskModule { @Provides @IntoSet fun provideTask(): CustomTask { return LlmSingleTurnTask() } } ================================================ FILE: Android/src/app/src/main/java/com/google/ai/edge/gallery/ui/llmsingleturn/LlmSingleTurnViewModel.kt ================================================ /* * Copyright 2025 Google LLC * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * http://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. */ package com.google.ai.edge.gallery.ui.llmsingleturn import android.util.Log import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import com.google.ai.edge.gallery.common.processLlmResponse import com.google.ai.edge.gallery.data.Model import com.google.ai.edge.gallery.data.Task import com.google.ai.edge.gallery.runtime.runtimeHelper import dagger.hilt.android.lifecycle.HiltViewModel import javax.inject.Inject import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.delay import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.update import kotlinx.coroutines.launch private const val TAG = "AGLlmSingleTurnVM" data class LlmSingleTurnUiState( /** Indicates whether the runtime is currently processing a message. */ val inProgress: Boolean = false, /** * Indicates whether the model is preparing (before outputting any result and after initializing). */ val preparing: Boolean = false, // model ->