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