[
  {
    "path": ".github/ISSUE_TEMPLATE/bug_report.md",
    "content": "---\nname: 🐛 Bug report\nabout: Create a report to help us improve\ntitle: ''\nlabels: ''\nassignees: ''\n\n---\n\n**Describe the bug:**    \nA clear and concise description of what the bug is.\n\n**To Reproduce:**      \nSteps to reproduce the behavior:\n1. Go to '...'\n2. Click on '....'\n3. Scroll down to '....'\n4. See error\n\n**Expected behavior:**        \nA clear and concise description of what you expected to happen.\n\n**Screenshots:**    \nIf applicable, add screenshots to help explain your problem.\n\n**Device & App Information (Please complete the following):**\n- Device: [e.g., Samsung Galaxy S23, Google Pixel 7]\n- Android Version: [e.g., Android 12, Android 13]\n- App Version: [e.g., 1.0.1, v1.0.2]\n\n**Additional context:**    \nAdd any other context about the problem here.\n"
  },
  {
    "path": ".github/ISSUE_TEMPLATE/feature_request.md",
    "content": "---\nname: ✨ Feature request\nabout: Suggest an idea for this project\ntitle: ''\nlabels: ''\nassignees: ''\n\n---\n\n**Is your feature request related to a problem? Please describe.**    \nA clear and concise description of what the problem is. Ex. I'm always frustrated when [...]\n\n**Describe the solution you'd like**       \nA clear and concise description of what you want to happen.\n\n**Describe alternatives you've considered**     \nA clear and concise description of any alternative solutions or features you've considered.\n\n**Additional context**     \nAdd any other context or screenshots about the feature request here.\n"
  },
  {
    "path": ".github/ISSUE_TEMPLATE/support_request.md",
    "content": "---\nname: 🆘 Support Request\nabout: Ask a question or get help with usage.\ntitle: \"[Support]: \"\nlabels: [\"support\", \"question\"]\nassignees: []\n---\n\n<!--\nThanks for reaching out for help! To assist you efficiently, please provide as much detail as possible.\n-->\n\n**What do you need help with?**\n\nIs this a question about how to do something, a configuration problem, or a general issue you can't solve?\n\n**Describe the issue/question:**\n\nClearly describe what you are trying to achieve, what problem you are facing, or what question you have.\n\n**What have you tried so far? (Optional):**\n\nList any steps you've already taken to troubleshoot, find information, or attempt a solution.\n\n**Expected outcome (Optional):**\n\nIf applicable, what did you hope would happen, or what solution are you looking for?\n\n**Screenshots/Videos (Optional):**\n\nIf applicable, add screenshots or a short video that might help explain your situation.\n\n**Environment & Details:**\n\nPlease provide details about your operating environment, relevant URLs, or any messages you see.\n\n- **Operating System:**\n- **Browser & Version (if applicable):**\n- **Any relevant messages (e.g., from UI, console):**\n    ```\n    PASTE_ANY_MESSAGES_HERE\n    ```\n\n**Any additional context?:**\n\nIs there anything else that might be useful for us to know?\n"
  },
  {
    "path": ".github/workflows/build_android.yaml",
    "content": "name: Build Android APK\n\non:\n  workflow_dispatch:\n  push:\n    branches: [ \"main\" ]\n    paths:\n      - 'Android/**'\n  pull_request:\n    branches: [ \"main\" ]\n    paths:\n      - 'Android/**'\n\njobs:\n  build_apk:\n    name: Build Android APK\n    runs-on: ubuntu-latest\n    defaults:\n      run:\n        working-directory: ./Android/src\n    steps:\n      - name: Checkout the source code\n        uses: actions/checkout@v3\n      - uses: actions/setup-java@v4\n        with:\n          distribution: 'temurin'\n          java-version: '21'\n      - name: Build\n        run: ./gradlew assembleRelease\n"
  },
  {
    "path": ".gitignore",
    "content": ".DS_Store\n"
  },
  {
    "path": "Android/.gitignore",
    "content": "# @license\n# Copyright 2025 Google LLC\n#\n# Licensed under the Apache License, Version 2.0 (the \"License\");\n# you may not use this file except in compliance with the License.\n# You may obtain a copy of the License at\n#\n#     http://www.apache.org/licenses/LICENSE-2.0\n#\n# Unless required by applicable law or agreed to in writing, software\n# distributed under the License is distributed on an \"AS IS\" BASIS,\n# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n# See the License for the specific language governing permissions and\n# limitations under the License.\n# ==============================================================================\n\n# Gradle files\n.gradle/\nbuild/\n\n# Local configuration file (sdk path, etc)\nlocal.properties\n\n# Log/OS Files\n*.log\n\n# Android Studio generated files and folders\ncaptures/\n.externalNativeBuild/\n.cxx/\n*.apk\noutput.json\n\n# IntelliJ\n*.iml\n.idea/\nmisc.xml\ndeploymentTargetDropDown.xml\nrender.experimental.xml\n\n# Keystore files\n*.jks\n*.keystore\n\n# Google Services (e.g. APIs or Firebase)\ngoogle-services.json\n\n# Android Profiling\n*.hprof\n\n.DS_Store\n"
  },
  {
    "path": "Android/README.md",
    "content": "# Google AI Edge Gallery (Android)\n"
  },
  {
    "path": "Android/src/.gitignore",
    "content": "# @license\n# Copyright 2025 Google LLC\n#\n# Licensed under the Apache License, Version 2.0 (the \"License\");\n# you may not use this file except in compliance with the License.\n# You may obtain a copy of the License at\n#\n#     http://www.apache.org/licenses/LICENSE-2.0\n#\n# Unless required by applicable law or agreed to in writing, software\n# distributed under the License is distributed on an \"AS IS\" BASIS,\n# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n# See the License for the specific language governing permissions and\n# limitations under the License.\n# ==============================================================================\n*.iml\n\n.gradle\n/local.properties\n/.idea/caches\n/.idea/libraries\n/.idea/modules.xml\n/.idea/workspace.xml\n/.idea/navEditor.xml\n/.idea/assetWizardSettings.xml\n.DS_Store\n/build\n/captures\n.externalNativeBuild\n.cxx\nlocal.properties\n"
  },
  {
    "path": "Android/src/app/.gitignore",
    "content": "# @license\n# Copyright 2025 Google LLC\n#\n# Licensed under the Apache License, Version 2.0 (the \"License\");\n# you may not use this file except in compliance with the License.\n# You may obtain a copy of the License at\n#\n#     http://www.apache.org/licenses/LICENSE-2.0\n#\n# Unless required by applicable law or agreed to in writing, software\n# distributed under the License is distributed on an \"AS IS\" BASIS,\n# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n# See the License for the specific language governing permissions and\n# limitations under the License.\n# ==============================================================================\n\n/build\n/release"
  },
  {
    "path": "Android/src/app/build.gradle.kts",
    "content": "/*\n * Copyright 2025 Google LLC\n *\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n *     http://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\n\nplugins {\n  alias(libs.plugins.android.application)\n  // Note: set apply to true to enable google-services (requires google-services.json).\n  alias(libs.plugins.google.services) apply false\n  alias(libs.plugins.kotlin.android)\n  alias(libs.plugins.kotlin.compose)\n  alias(libs.plugins.kotlin.serialization)\n  alias(libs.plugins.protobuf)\n  alias(libs.plugins.hilt.application)\n  alias(libs.plugins.oss.licenses)\n  alias(libs.plugins.ksp)\n  kotlin(\"kapt\")\n}\n\nandroid {\n  namespace = \"com.google.ai.edge.gallery\"\n  compileSdk = 35\n\n  defaultConfig {\n    applicationId = \"com.google.aiedge.gallery\"\n    minSdk = 31\n    targetSdk = 35\n    versionCode = 20\n    versionName = \"1.0.11\"\n\n    // Needed for HuggingFace auth workflows.\n    // Use the scheme of the \"Redirect URLs\" in HuggingFace app.\n    manifestPlaceholders[\"appAuthRedirectScheme\"] =\n        \"REPLACE_WITH_YOUR_REDIRECT_SCHEME_IN_HUGGINGFACE_APP\"\n    manifestPlaceholders[\"applicationName\"] = \"com.google.ai.edge.gallery.GalleryApplication\"\n\n    testInstrumentationRunner = \"androidx.test.runner.AndroidJUnitRunner\"\n  }\n\n  buildTypes {\n    release {\n      isMinifyEnabled = false\n      proguardFiles(getDefaultProguardFile(\"proguard-android-optimize.txt\"), \"proguard-rules.pro\")\n      signingConfig = signingConfigs.getByName(\"debug\")\n    }\n  }\n  compileOptions {\n    sourceCompatibility = JavaVersion.VERSION_11\n    targetCompatibility = JavaVersion.VERSION_11\n  }\n  kotlinOptions {\n    jvmTarget = \"11\"\n    freeCompilerArgs += \"-Xcontext-receivers\"\n  }\n  buildFeatures {\n    compose = true\n    buildConfig = true\n  }\n}\n\ndependencies {\n  implementation(libs.androidx.core.ktx)\n  implementation(libs.androidx.lifecycle.runtime.ktx)\n  implementation(libs.androidx.activity.compose)\n  implementation(platform(libs.androidx.compose.bom))\n  implementation(libs.androidx.ui)\n  implementation(libs.androidx.ui.graphics)\n  implementation(libs.androidx.ui.tooling.preview)\n  implementation(libs.androidx.material3)\n  implementation(libs.androidx.compose.navigation)\n  implementation(libs.kotlinx.serialization.json)\n  implementation(libs.kotlin.reflect)\n  implementation(libs.material.icon.extended)\n  implementation(libs.androidx.work.runtime)\n  implementation(libs.androidx.datastore)\n  implementation(libs.com.google.code.gson)\n  implementation(libs.androidx.lifecycle.process)\n  implementation(libs.androidx.security.crypto)\n  implementation(libs.androidx.webkit)\n  implementation(libs.litertlm)\n  implementation(libs.commonmark)\n  implementation(libs.richtext)\n  implementation(libs.tflite)\n  implementation(libs.tflite.gpu)\n  implementation(libs.tflite.support)\n  implementation(libs.camerax.core)\n  implementation(libs.camerax.camera2)\n  implementation(libs.camerax.lifecycle)\n  implementation(libs.camerax.view)\n  implementation(libs.openid.appauth)\n  implementation(libs.androidx.splashscreen)\n  implementation(libs.protobuf.javalite)\n  implementation(libs.hilt.android)\n  implementation(libs.hilt.navigation.compose)\n  implementation(libs.play.services.oss.licenses)\n  implementation(platform(libs.firebase.bom))\n  implementation(libs.firebase.analytics)\n  implementation(libs.firebase.messaging)\n  implementation(libs.androidx.exifinterface)\n  implementation(libs.moshi.kotlin)\n  kapt(libs.hilt.android.compiler)\n  testImplementation(libs.junit)\n  androidTestImplementation(libs.androidx.junit)\n  androidTestImplementation(libs.androidx.espresso.core)\n  androidTestImplementation(platform(libs.androidx.compose.bom))\n  androidTestImplementation(libs.androidx.ui.test.junit4)\n  androidTestImplementation(libs.hilt.android.testing)\n  debugImplementation(libs.androidx.ui.tooling)\n  debugImplementation(libs.androidx.ui.test.manifest)\n  ksp(libs.moshi.kotlin.codegen)\n}\n\nprotobuf {\n  protoc { artifact = \"com.google.protobuf:protoc:4.26.1\" }\n  generateProtoTasks { all().forEach { it.plugins { create(\"java\") { option(\"lite\") } } } }\n}\n"
  },
  {
    "path": "Android/src/app/src/main/AndroidManifest.xml",
    "content": "<?xml version=\"1.0\" encoding=\"utf-8\"?>\n<!--\n Copyright 2025 Google LLC\n\n Licensed under the Apache License, Version 2.0 (the \"License\");\n you may not use this file except in compliance with the License.\n You may obtain a copy of the License at\n\n     http://www.apache.org/licenses/LICENSE-2.0\n\n Unless required by applicable law or agreed to in writing, software\n distributed under the License is distributed on an \"AS IS\" BASIS,\n WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n See the License for the specific language governing permissions and\n limitations under the License.\n-->\n\n<manifest xmlns:android=\"http://schemas.android.com/apk/res/android\"\n    package=\"com.google.ai.edge.gallery\"\n    xmlns:tools=\"http://schemas.android.com/tools\">\n\n    <uses-sdk\n        android:minSdkVersion=\"31\"\n        android:compileSdkVersion =\"35\"\n        android:targetSdkVersion=\"35\" />\n\n    <uses-permission android:name=\"android.permission.CAMERA\" />\n    <uses-permission android:name=\"android.permission.FOREGROUND_SERVICE\"/>\n    <uses-permission android:name=\"android.permission.FOREGROUND_SERVICE_DATA_SYNC\"/>\n    <uses-permission android:name=\"android.permission.INTERNET\" />\n    <uses-permission android:name=\"android.permission.POST_NOTIFICATIONS\" />\n    <uses-permission android:name=\"android.permission.RECORD_AUDIO\" />\n    <uses-permission android:name=\"android.permission.WAKE_LOCK\"/>\n    <uses-permission android:name=\"android.permission.ACCESS_NETWORK_STATE\" />\n\n    <!-- Additional permission required for push notification handling -->\n    <!-- Required for resurfacing notifications after boot -->\n    <uses-permission android:name=\"android.permission.RECEIVE_BOOT_COMPLETED\" />\n    <!-- Required for GCM message handling -->\n    <uses-permission android:name=\"com.google.android.c2dm.permission.RECEIVE\" />\n    <!-- Required for registering with Chime properly -->\n    <uses-permission android:name=\"com.google.android.providers.gsf.permission.READ_GSERVICES\" />\n    <!-- Required before JellyBean MR1, optional for newer versions of Android. -->\n    <uses-permission android:name=\"android.permission.GET_ACCOUNTS\" />\n\n    <uses-feature\n        android:name=\"android.hardware.camera\"\n        android:required=\"false\" />\n    <uses-feature android:name=\"android.hardware.camera.flash\"\n        android:required=\"false\" />\n\n    <application\n        android:name=\"${applicationName}\"\n        android:allowBackup=\"true\"\n        android:dataExtractionRules=\"@xml/data_extraction_rules\"\n        android:fullBackupContent=\"@xml/backup_rules\"\n        android:icon=\"@mipmap/ic_launcher\"\n        android:label=\"Edge Gallery\"\n        android:roundIcon=\"@mipmap/ic_launcher\"\n        android:supportsRtl=\"true\"\n        android:theme=\"@style/Theme.Gallery\"\n        tools:targetApi=\"31\">\n        <!--\n          android:configChanges=\"uiMode\" tells the system don't destroy and\n          recreate the activity when UI mode changes (e.g. setting dark mode).\n          Instead, just recompose the view.\n         -->\n        <activity\n            android:name=\"com.google.ai.edge.gallery.MainActivity\"\n            android:exported=\"true\"\n            android:theme=\"@style/Theme.Gallery.SplashScreen\"\n            android:screenOrientation=\"portrait\"\n            android:windowSoftInputMode=\"adjustResize\"\n            android:configChanges=\"uiMode\"\n            tools:ignore=\"DiscouragedApi,LockedOrientationActivity\">\n            <!-- This is for putting the app into launcher -->\n            <intent-filter>\n                <action android:name=\"android.intent.action.MAIN\" />\n                <category android:name=\"android.intent.category.LAUNCHER\" />\n            </intent-filter>\n\n            <!-- This is for deep linking -->\n            <intent-filter>\n                <action android:name=\"android.intent.action.VIEW\" />\n\n                <category android:name=\"android.intent.category.DEFAULT\" />\n                <category android:name=\"android.intent.category.BROWSABLE\" />\n\n                <data android:scheme=\"com.google.ai.edge.gallery\" />\n            </intent-filter>\n        </activity>\n\n        <!-- Set themes for activities that are used for viewing open source licenses -->\n        <activity\n            android:name=\"com.google.android.gms.oss.licenses.OssLicensesMenuActivity\"\n            android:exported=\"true\"\n            android:theme=\"@style/Theme.Gallery.OssLicenses\" />\n        <activity\n            android:name=\"com.google.android.gms.oss.licenses.OssLicensesActivity\"\n            android:exported=\"true\"\n            android:theme=\"@style/Theme.Gallery.OssLicenses\" />\n\n        <provider\n            android:name=\"androidx.core.content.FileProvider\"\n            android:authorities=\"${applicationId}.provider\"\n            android:exported=\"false\"\n            android:grantUriPermissions=\"true\">\n            <meta-data\n                android:name=\"android.support.FILE_PROVIDER_PATHS\"\n                android:resource=\"@xml/file_paths\" />\n        </provider>\n\n        <service\n            android:name=\"androidx.work.impl.foreground.SystemForegroundService\"\n            android:foregroundServiceType=\"dataSync\"\n            android:exported=\"false\"\n            tools:node=\"merge\">\n        </service>\n\n        <!-- For Firebase Analytics. -->\n        <receiver\n            android:name=\"com.google.android.gms.measurement.AppMeasurementReceiver\"\n            android:enabled=\"true\"\n            android:exported=\"false\" />\n        <service android:name=\"com.google.android.gms.measurement.AppMeasurementService\"\n            android:enabled=\"true\"\n            android:exported=\"false\" />\n        <service\n            android:name=\"com.google.android.gms.measurement.AppMeasurementJobService\"\n            android:enabled=\"true\"\n            android:exported=\"false\"\n            android:permission=\"android.permission.BIND_JOB_SERVICE\" />\n\n        <service\n            android:name=\".GalleryFcmMessagingService\"\n            android:exported=\"false\">\n            <intent-filter>\n                <action android:name=\"com.google.firebase.MESSAGING_EVENT\" />\n            </intent-filter>\n        </service>\n\n        <meta-data\n            android:name=\"com.google.firebase.messaging.default_notification_channel_id\"\n            android:value=\"gallery_high_priority_push_channel\" />\n\n        <uses-native-library android:name=\"libvndksupport.so\" android:required=\"false\"/>\n        <uses-native-library android:name=\"libOpenCL.so\" android:required=\"false\"/>\n        <uses-native-library android:name=\"libcdsprpc.so\" android:required=\"false\" />\n    </application>\n\n</manifest>\n"
  },
  {
    "path": "Android/src/app/src/main/assets/tinygarden/index.html",
    "content": "<!--\n@license\nCopyright 2025 Google LLC\n\nLicensed under the Apache License, Version 2.0 (the \"License\");\nyou may not use this file except in compliance with the License.\nYou may obtain a copy of the License at\n\n    http://www.apache.org/licenses/LICENSE-2.0\n\nUnless required by applicable law or agreed to in writing, software\ndistributed under the License is distributed on an \"AS IS\" BASIS,\nWITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\nSee the License for the specific language governing permissions and\nlimitations under the License.\n==============================================================================\n-->\n\n<!doctype html>\n<html lang=\"en\" data-beasties-container>\n<head>\n  <meta charset=\"utf-8\">\n  <title>Tiny Garden</title>\n  <base href=\"/assets/tinygarden/\">\n  <meta name=\"viewport\" content=\"width=device-width, initial-scale=1\">\n<style>body,html{margin:0;padding:0;width:100%;height:100%;overflow:hidden}</style><link rel=\"stylesheet\" href=\"styles-63IRQW2E.css\" media=\"print\" onload=\"this.media='all'\"><noscript><link rel=\"stylesheet\" href=\"styles-63IRQW2E.css\"></noscript></head>\n<body>\n  <app-root></app-root>\n<script src=\"main-K5DSW5YL.js\" type=\"module\"></script></body>\n</html>\n"
  },
  {
    "path": "Android/src/app/src/main/assets/tinygarden/main-K5DSW5YL.js",
    "content": "/*\n * Copyright 2025 Google LLC\n *\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n *     http://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\nvar 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${n.map((r,o)=>`${o+1}) ${r.toString()}`).join(`\n  `)}`:\"\",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.length&&!t.closed;n++)t.next(e[n]);t.complete()})}function pm(e){return new V(t=>{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<o;c++)sd(t,()=>{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<r?E(S):c.push(S),E=S=>{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&&l<r;){let F=c.shift();s?ye(t,s,()=>E(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<r.length&&r.shift()},()=>{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(`\n`);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;n<e.length;n++){let r=De(e[n]);if(Array.isArray(r)){if(r.length===0)throw new C(900,!1);let o,i=0;for(let s=0;s<r.length;s++){let a=r[s],c=Fm(a);typeof c==\"number\"?c===-1?o=a.token:i|=c:o=a}t.push(N(o,i))}else t.push(N(r))}return t}function Fm(e){return e[Pm]}function Qt(e,t){let n=e.hasOwnProperty(Or);return n?e[Or]:null}function Dd(e,t,n){if(e.length!==t.length)return!1;for(let r=0;r<e.length;r++){let o=e[r],i=t[r];if(n&&(o=n(o),i=n(i)),i!==o)return!1}return!0}function Cd(e){return e.flat(Number.POSITIVE_INFINITY)}function yi(e,t){e.forEach(n=>Array.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<<n];if(t===s)return i<<n;s>t?o=i:r=i+1}return~(o<<n)}var en={},we=[],lt=new b(\"\"),Ba=new b(\"\",-1),Va=new b(\"\"),Pr=class{get(t,n=Zt){if(n===Zt){let o=yd(\"\",-201);throw o.name=\"\\u0275NotFound\",o}return n}};function Ua(e){return e[La]||null}function Rt(e){return e[Oa]||null}function $a(e){return e[Pa]||null}function bd(e){return e[ka]||null}function tn(e){return{\\u0275providers:e}}function _d(e){return tn([{provide:lt,multi:!0,useValue:e}])}function wd(...e){return{\\u0275providers:Ga(!0,e),\\u0275fromNgModule:!0}}function Ga(e,...t){let n=[],r=new Set,o,i=s=>{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<e.length;n++){let{ngModule:r,providers:o}=e[n];za(o,i=>{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<cf.length;r++){let o=cf[r];o(e,t,n)}};function uv(e,t,n){let{ngOnChanges:r,ngOnInit:o,ngDoCheck:i}=t.type.prototype;if(r){let s=Hf(t);(n.preOrderHooks??=[]).push(e,s),(n.preOrderCheckHooks??=[]).push(e,s)}o&&(n.preOrderHooks??=[]).push(0-e,o),i&&((n.preOrderHooks??=[]).push(e,i),(n.preOrderCheckHooks??=[]).push(e,i))}function dv(e,t){for(let n=t.directiveStart,r=t.directiveEnd;n<r;n++){let i=e.data[n].type.prototype,{ngAfterContentInit:s,ngAfterContentChecked:a,ngAfterViewInit:c,ngAfterViewChecked:l,ngOnDestroy:u}=i;s&&(e.contentHooks??=[]).push(-n,s),a&&((e.contentHooks??=[]).push(n,a),(e.contentCheckHooks??=[]).push(n,a)),c&&(e.viewHooks??=[]).push(-n,c),l&&((e.viewHooks??=[]).push(n,l),(e.viewCheckHooks??=[]).push(n,l)),u!=null&&(e.destroyHooks??=[]).push(n,u)}}function Pi(e,t,n){Uf(e,t,3,n)}function ki(e,t,n,r){(e[M]&3)===n&&Uf(e,t,n,r)}function pc(e,t){let n=e[M];(n&3)===t&&(n&=16383,n+=1,e[M]=n)}function Uf(e,t,n,r){let o=r!==void 0?e[rn]&65535:0,i=r??-1,s=t.length-1,a=0;for(let c=o;c<s;c++)if(typeof t[c+1]==\"number\"){if(a=t[c],r!=null&&a>=r)break}else t[c]<0&&(e[rn]+=65536),(a<i||i==-1)&&(fv(e,n,t,c),e[rn]=(e[rn]&4294901760)+c+2),c++}function lf(e,t){z(4,e,t);let n=_(null);try{t.call(e)}finally{_(n),z(5,e,t)}}function fv(e,t,n,r){let o=n[r]<0,i=n[r+1],s=o?-n[r]:n[r],a=e[s];o?e[M]>>14<e[rn]>>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(;r<n.length;){let o=n[r];if(typeof o==\"number\"){if(o!==0)break;r++;let i=n[r++],s=n[r++],a=n[r++];e.setAttribute(t,s,a,i)}else{let i=o,s=n[++r];vv(i)?e.setProperty(t,i,s):e.setAttribute(t,i,s),r++}}return r}function mv(e){return e===3||e===4||e===6}function vv(e){return e.charCodeAt(0)===64}function es(e,t){if(!(t===null||t.length===0))if(e===null||e.length===0)e=t.slice();else{let n=-1;for(let r=0;r<t.length;r++){let o=t[r];typeof o==\"number\"?n=o:n===0||(n===-1||n===2?uf(e,n,o,null,t[++r]):uf(e,n,o,null,null))}}return e}function uf(e,t,n,r,o){let i=0,s=e.length;if(t===-1)s=-1;else for(;i<e.length;){let a=e[i++];if(typeof a==\"number\"){if(a===t){s=-1;break}else if(a>t){s=i-1;break}}}for(;i<e.length;){let a=e[i];if(typeof a==\"number\")break;if(a===n){o!==null&&(e[i+1]=o);return}i++,o!==null&&i++}s!==-1&&(e.splice(s,0,t),i=s+1),e.splice(i++,0,n),o!==null&&e.splice(i++,0,o)}function $f(e){return e!==Kn}function Hi(e){return e&32767}function yv(e){return e>>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<<o;t.data[e+(o>>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<m;h++){let E=s[h];if(h<c&&n===E||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<<e;return!!(n[t+(e>>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<n.length;o+=2){let i=n[o],s=n[o+1];if(s!==-1){let a=e.data[s];Ti(i),a.contentQueries(2,t[s],s)}}}finally{_(r)}}}function Sc(e,t,n){Ti(0);let r=_(null);try{t(e,n)}finally{_(r)}}function lp(e,t,n){if(Ya(t)){let r=_(null);try{let o=t.directiveStart,i=t.directiveEnd;for(let s=o;s<i;s++){let a=e.data[s];if(a.contentQueries){let c=n[s];a.contentQueries(1,c,s)}}}finally{_(r)}}}var vt=(function(e){return e[e.Emulated=0]=\"Emulated\",e[e.None=2]=\"None\",e[e.ShadowDom=3]=\"ShadowDom\",e})(vt||{});var bc=class{changingThisBreaksApplicationSecurity;constructor(t){this.changingThisBreaksApplicationSecurity=t}toString(){return`SafeValue must use [property]=binding: ${this.changingThisBreaksApplicationSecurity} (see ${Ma})`}};function up(e){return e instanceof bc?e.changingThisBreaksApplicationSecurity:e}function dp(e){return e instanceof Function?e():e}function Hv(e,t,n){let r=e.length;for(;;){let o=e.indexOf(t,n);if(o===-1)return o;if(o===0||e.charCodeAt(o-1)<=32){let i=t.length;if(o+i===r||e.charCodeAt(o+i)<=32)return o}n=o+1}}var fp=\"ng-template\";function Bv(e,t,n,r){let o=0;if(r){for(;o<t.length&&typeof t[o]==\"string\";o+=2)if(t[o]===\"class\"&&Hv(t[o+1].toLowerCase(),n,0)!==-1)return!0}else if(Wc(e))return!1;if(o=t.indexOf(1,o),o>-1){let i;for(;++o<t.length&&typeof(i=t[o])==\"string\";)if(i.toLowerCase()===n)return!0}return!1}function Wc(e){return e.type===4&&e.value!==fp}function Vv(e,t,n){let r=e.type===4&&!n?fp:e.value;return t===r}function Uv(e,t,n){let r=4,o=e.attrs,i=o!==null?zv(o):0,s=!1;for(let a=0;a<t.length;a++){let c=t[a];if(typeof c==\"number\"){if(!s&&!$e(r)&&!$e(c))return!1;if(s&&$e(c))continue;s=!1,r=c|r&1;continue}if(!s)if(r&4){if(r=2|r&1,c!==\"\"&&!Vv(e,c,n)||c===\"\"&&t.length===1){if($e(r))return!1;s=!0}}else if(r&8){if(o===null||!Bv(e,o,c,n)){if($e(r))return!1;s=!0}}else{let l=t[++a],u=$v(c,o,Wc(e),n);if(u===-1){if($e(r))return!1;s=!0;continue}if(l!==\"\"){let f;if(u>i?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<t.length;){let s=t[o];if(s===e)return o;if(s===3||s===6)i=!0;else if(s===1||s===2){let a=t[++o];for(;typeof a==\"string\";)a=t[++o];continue}else{if(s===4)break;if(s===0){o+=4;continue}}o+=i?1:2}return-1}else return Wv(t,e)}function Gv(e,t,n=!1){for(let r=0;r<t.length;r++)if(Uv(e,t[r],n))return!0;return!1}function zv(e){for(let t=0;t<e.length;t++){let n=e[t];if(mv(n))return t}return e.length}function Wv(e,t){let n=e.indexOf(4);if(n>-1)for(n++;n<e.length;){let r=e[n];if(typeof r==\"number\")return-1;if(r===t)return n;n++}return-1}function gf(e,t){return e?\":not(\"+t.trim()+\")\":t}function qv(e){let t=e[0],n=1,r=2,o=\"\",i=!1;for(;n<e.length;){let s=e[n];if(typeof s==\"string\")if(r&2){let a=e[++n];o+=\"[\"+s+(a.length>0?'=\"'+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<e.length;){let i=e[r];if(typeof i==\"string\")o===2?i!==\"\"&&t.push(i,e[++r]):o===8&&n.push(i);else{if(!$e(o))break;o=i}r++}return n.length&&t.push(1,...n),t}var Et={};function Kv(e,t){return e.createText(t)}function Qv(e,t,n){e.setValue(t,n)}function pp(e,t,n){return e.createElement(t,n)}function $i(e,t,n,r,o){e.insertBefore(t,n,r,o)}function hp(e,t,n){e.appendChild(t,n)}function mf(e,t,n,r,o){r!==null?$i(e,t,n,r,o):hp(e,t,n)}function Xv(e,t,n){e.removeChild(null,t,n)}function Jv(e,t,n){e.setAttribute(t,\"style\",n)}function ey(e,t,n){n===\"\"?e.removeAttribute(t,\"class\"):e.setAttribute(t,\"class\",n)}function gp(e,t,n){let{mergedAttrs:r,classes:o,styles:i}=n;r!==null&&gv(e,t,r),o!==null&&ey(e,t,o),i!==null&&Jv(e,t,i)}function qc(e,t,n,r,o,i,s,a,c,l,u){let f=ae+r,m=f+o,h=ty(f,m),E=typeof l==\"function\"?l():l;return h[T]={type:e,blueprint:h,template:n,queries:null,viewQuery:a,declTNode:t,data:h.slice().fill(null,f),bindingStartIndex:f,expandoStartIndex:m,hostBindingOpCodes:null,firstCreatePass:!0,firstUpdatePass:!0,staticViewQueries:!1,staticContentQueries:!1,preOrderHooks:null,preOrderCheckHooks:null,contentHooks:null,contentCheckHooks:null,viewHooks:null,viewCheckHooks:null,destroyHooks:null,cleanup:null,contentQueries:null,components:null,directiveRegistry:typeof i==\"function\"?i():i,pipeRegistry:typeof s==\"function\"?s():s,firstChild:null,schemas:c,consts:E,incompleteFirstPass:!1,ssrId:u}}function ty(e,t){let n=[];for(let r=0;r<t;r++)n.push(r<e?null:Et);return n}function ny(e){let t=e.tView;return t===null||t.incompleteFirstPass?e.tView=qc(1,null,e.template,e.decls,e.vars,e.directiveDefs,e.pipeDefs,e.viewQuery,e.schemas,e.consts,e.id):t}function Yc(e,t,n,r,o,i,s,a,c,l,u){let f=t.blueprint.slice();return f[Ve]=o,f[M]=r|4|128|8|64|1024,(l!==null||e&&e[M]&2048)&&(f[M]|=2048),Ka(f),f[te]=f[nn]=e,f[se]=n,f[ut]=s||e&&e[ut],f[ne]=a||e&&e[ne],f[Gn]=c||e&&e[Gn]||null,f[Pe]=i,f[Vr]=Av(),f[Br]=u,f[qa]=l,f[ke]=t.type==2?e[ke]:f,f}function ry(e,t,n){let r=Ke(t,e),o=ny(n),i=e[ut].rendererFactory,s=Zc(e,Yc(e,o,null,mp(n),r,t,null,i.createRenderer(r,n),null,null,null));return e[t.index]=s}function mp(e){let t=16;return e.signals?t=4096:e.onPush&&(t=64),t}function vp(e,t,n,r){if(n===0)return-1;let o=t.length;for(let i=0;i<n;i++)t.push(r),e.blueprint.push(r),e.data.push(null);return o}function Zc(e,t){return e[zn]?e[Wa][Oe]=t:e[zn]=t,e[Wa]=t,t}function Dt(e=1){yp(Xe(),W(),un()+e,!1)}function yp(e,t,n,r){if(!r)if((t[M]&3)===3){let i=e.preOrderCheckHooks;i!==null&&Pi(t,i,n)}else{let i=e.preOrderHooks;i!==null&&ki(t,i,0,n)}Lt(n)}var is=(function(e){return e[e.None=0]=\"None\",e[e.SignalBased=1]=\"SignalBased\",e[e.HasDecoratorInputTransform=2]=\"HasDecoratorInputTransform\",e})(is||{});function _c(e,t,n,r){let o=_(null);try{let[i,s,a]=e.inputs[n],c=null;(s&is.SignalBased)!==0&&(c=t[i][oe]),c!==null&&c.transformFn!==void 0?r=c.transformFn(r):a!==null&&(r=a.call(t,r)),e.setInput!==null?e.setInput(t,c,r,n,i):jf(t,c,i,r)}finally{_(o)}}var yt=(function(e){return e[e.Important=1]=\"Important\",e[e.DashCase=2]=\"DashCase\",e})(yt||{}),oy;function Kc(e,t){return oy(e,t)}var ss=new Set;function Zn(e,t,n,r,o,i){if(r!=null){let s,a=!1;Ue(r)?s=r:ft(r)&&(a=!0,r=r[Ve]);let c=Le(r);e===0&&n!==null?o==null?hp(t,n,c):$i(t,n,c,o||null,!0):e===1&&n!==null?$i(t,n,c,o||null,!0):e===2?vf(i,()=>{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<n.length;o++){let i=n[o];r.push(i())}e[he].running=Promise.allSettled(r),e[he].leave=void 0}cy(e,t)}function cy(e,t){if(e&&e[he]&&e[he].running){e[he].running.then(()=>{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<n.length-1;s+=2)if(typeof n[s]==\"string\"){let a=n[s+3];a>=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;s<o.length;s++){let a=o[s];a()}}let i=t[dt];if(i!==null){t[dt]=null;for(let s of i)s.destroy()}}function uy(e,t){let n;if(e!=null&&(n=e.destroyHooks)!=null)for(let r=0;r<n.length;r+=2){let o=t[n[r]];if(!(o instanceof Kr)){let i=n[r+1];if(Array.isArray(i))for(let s=0;s<i.length;s+=2){let a=o[i[s]],c=i[s+1];z(4,a,c);try{c.call(a)}finally{z(5,a,c)}}else{z(4,o,i);try{i.call(o)}finally{z(5,o,i)}}}}}function dy(e,t,n){return fy(e,t.parent,n)}function fy(e,t,n){let r=t;for(;r!==null&&r.type&168;)t=r,r=t.parent;if(r===null)return n[Ve];if(sn(r)){let{encapsulation:o}=e.data[r.directiveStart+r.componentOffset];if(o===vt.None||o===vt.Emulated)return null}return Ke(r,n)}function py(e,t,n){return gy(e,t,n)}function hy(e,t,n){return e.type&40?Ke(e,n):null}var gy=hy,yf;function Jc(e,t,n,r){let o=dy(e,r,t),i=t[ne],s=r.parent||t[Pe],a=py(s,r,t);if(o!=null)if(Array.isArray(n))for(let c=0;c<n.length;c++)mf(i,o,n[c],a,!1);else mf(i,o,n,a,!1);yf!==void 0&&yf(i,r,t,n,o)}function Yr(e,t){if(t!==null){let n=t.type;if(n&3)return Ke(t,e);if(n&4)return wc(-1,e[t.index]);if(n&8){let r=t.child;if(r!==null)return Yr(e,r);{let o=e[t.index];return Ue(o)?wc(-1,o):Le(o)}}else{if(n&128)return Yr(e,t.next);if(n&32)return Kc(t,e)()||Le(e[t.index]);{let r=Dp(e,t);if(r!==null){if(Array.isArray(r))return r[0];let o=At(e[ke]);return Yr(o,r)}else return Yr(e,t.next)}}}return null}function Dp(e,t){if(t!==null){let r=e[ke][Pe],o=t.projection;return r.projection[o]}return null}function wc(e,t){let n=fe+e+1;if(n<t.length){let r=t[n],o=r[T].firstChild;if(o!==null)return Yr(r,o)}return t[Pt]}function el(e,t,n,r,o,i,s){for(;n!=null;){if(n.type===128){n=n.next;continue}let a=r[n.index],c=n.type;if(s&&t===0&&(a&&Qn(Le(a),r),n.flags|=2),!zc(n))if(c&8)el(e,t,n.child,r,o,i,!1),Zn(t,e,o,a,i,r);else if(c&32){let l=Kc(n,r),u;for(;u=l();)Zn(t,e,o,u,i,r);Zn(t,e,o,a,i,r)}else c&16?my(e,t,r,n,o,i):Zn(t,e,o,a,i,r);n=s?n.projectionNext:n.next}}function as(e,t,n,r,o,i){el(n,r,e.firstChild,t,o,i,!1)}function my(e,t,n,r,o,i){let s=n[ke],c=s[Pe].projection[r.projection];if(Array.isArray(c))for(let l=0;l<c.length;l++){let u=c[l];Zn(t,e,o,u,i,n)}else{let l=c,u=s[te];Jf(r)&&(l.flags|=128),el(e,t,l,u,o,i,!0)}}function vy(e,t,n,r,o){let i=n[Pt],s=Le(n);i!==s&&Zn(t,e,r,i,o);for(let a=fe;a<n.length;a++){let c=n[a];as(c[T],c,e,t,r,i)}}function yy(e,t,n,r,o){if(t)o?e.addClass(n,r):e.removeClass(n,r);else{let i=r.indexOf(\"-\")===-1?void 0:yt.DashCase;o==null?e.removeStyle(n,r,i):(typeof o==\"string\"&&o.endsWith(\"!important\")&&(o=o.slice(0,-10),i|=yt.Important),e.setStyle(n,r,o,i))}}function Cp(e,t,n,r,o){let i=un(),s=r&2;try{Lt(-1),s&&t.length>ae&&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;i<r.length;i+=2){let s=r[i+1],a=s===-1?n(t,e):e[s];e[o++]=a}}}function Ey(e,t,n,r){let i=r.get(sp,ip)||n===vt.ShadowDom,s=e.selectRootElement(t,i);return Dy(s),s}function Dy(e){Cy(e)}var Cy=()=>null;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<o;s++){let a=e.data[s],c=Vi(t,e,s,n);if(Qn(c,t),i!==null&&My(t,s-r,c,a,n,i),an(a)){let l=Qe(n.index,t);l[se]=Vi(t,e,s,n)}}}function Sy(e,t,n){let r=n.directiveStart,o=n.directiveEnd,i=n.index,s=Kd();try{Lt(i);for(let a=r;a<o;a++){let c=e.data[a],l=t[a];wi(a),(c.hostBindings!==null||c.hostVars!==0||c.hostAttrs!==null)&&by(c,l)}}finally{Lt(-1),wi(s)}}function by(e,t){e.hostBindings!==null&&e.hostBindings(1,t)}function _y(e,t){let n=e.directiveRegistry,r=null;if(n)for(let o=0;o<n.length;o++){let i=n[o];Gv(t,i.selectors,!1)&&(r??=[],an(i)?r.unshift(i):r.push(i))}return r}function wy(e,t,n,r,o,i){let s=Ke(e,t);Ty(t[ne],s,i,e.value,n,r,o)}function Ty(e,t,n,r,o,i,s){if(i==null)e.removeAttribute(t,o,n);else{let a=s==null?mi(i):s(i,r||\"\",o);e.setAttribute(t,o,a,n)}}function My(e,t,n,r,o,i){let s=i[t];if(s!==null)for(let a=0;a<s.length;a+=2){let c=s[a],l=s[a+1];_c(r,n,c,l)}}function Sp(e,t,n,r,o){let i=ae+n,s=t[T],a=o(s,t,e,r,n);t[i]=a,qn(e,!0);let c=e.type===2;return c?(gp(t[ne],a,e),(Hd()===0||Ii(e))&&Qn(a,t),Bd()):Qn(a,t),Ni()&&(!c||!zc(e))&&Jc(s,t,a,e),e}function bp(e){let t=e;return ic()?Gd():(t=t.parent,qn(t,!1)),t}function xy(e,t){let n=e[Gn];if(!n)return;let r;try{r=n.get(Fe,null)}catch{r=null}r?.(t)}function _p(e,t,n,r,o){let i=e.inputs?.[r],s=e.hostDirectiveInputs?.[r],a=!1;if(s)for(let c=0;c<s.length;c+=2){let l=s[c],u=s[c+1],f=t.data[l];_c(f,n[l],u,o),a=!0}if(i)for(let c of i){let l=n[c],u=t.data[c];_c(u,l,r,o),a=!0}return a}function Ny(e,t){let n=Qe(t,e),r=n[T];Ay(r,n);let o=n[Ve];o!==null&&n[Br]===null&&(n[Br]=ap(o,n[Gn])),z(18),nl(r,n,n[se]),z(19,n[se])}function Ay(e,t){for(let n=t.length;n<e.blueprint.length;n++)t.push(e.blueprint[n])}function nl(e,t,n){Mi(t);try{let r=e.viewQuery;r!==null&&Sc(1,r,n);let o=e.template;o!==null&&Cp(e,t,o,1,n),e.firstCreatePass&&(e.firstCreatePass=!1),t[Ze]?.finishViewCreation(e),e.staticContentQueries&&cp(e,t),e.staticViewQueries&&Sc(2,e.viewQuery,n);let i=e.components;i!==null&&Ry(t,i)}catch(r){throw e.firstCreatePass&&(e.incompleteFirstPass=!0,e.firstCreatePass=!1),r}finally{t[M]&=-5,xi()}}function Ry(e,t){for(let n=0;n<t.length;n++)Ny(e,t[n])}function wp(e,t,n,r){let o=_(null);try{let i=t.tView,a=e[M]&4096?4096:16,c=Yc(e,i,n,a,null,t,null,null,r?.injector??null,r?.embeddedViewInjector??null,r?.dehydratedView??null),l=e[t.index];c[Ot]=l;let u=e[Ze];return u!==null&&(c[Ze]=u.createEmbeddedView(i)),nl(i,c,n),c}finally{_(o)}}function Tc(e,t){return!t||t.firstChild===null||Jf(e)}function Qr(e,t,n,r,o=!1){for(;n!==null;){if(n.type===128){n=o?n.projectionNext:n.next;continue}let i=t[n.index];i!==null&&r.push(Le(i)),Ue(i)&&Tp(i,r);let s=n.type;if(s&8)Qr(e,t,n.child,r);else if(s&32){let a=Kc(n,t),c;for(;c=a();)r.push(c)}else if(s&16){let a=Dp(t,n);if(Array.isArray(a))r.push(...a);else{let c=At(t[ke]);Qr(c[T],c,a,r,!0)}}n=o?n.projectionNext:n.next}return r}function Tp(e,t){for(let n=fe;n<e.length;n++){let r=e[n],o=r[T].firstChild;o!==null&&Qr(r[T],r,o,t)}e[Pt]!==e[Ve]&&t.push(e[Pt])}function Mp(e){if(e[Ci]!==null){for(let t of e[Ci])t.impl.addSequence(t);e[Ci].length=0}}var xp=[];function Oy(e){return e[Te]??Py(e)}function Py(e){let t=xp.pop()??Object.create(Ly);return t.lView=e,t}function ky(e){e.lView[Te]!==e&&(e.lView=null,xp.push(e))}var Ly=P(y({},$t),{consumerIsAlwaysLive:!0,kind:\"template\",consumerMarkedDirty:e=>{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;r<n.length;r++){let o=n[r];Pp(o,t)}}function $y(e){for(let t=tp(e);t!==null;t=np(t)){if(!(t[M]&2))continue;let n=t[on];for(let r=0;r<n.length;r++){let o=n[r];Qa(o)}}}function Gy(e,t,n){z(18);let r=Qe(t,e);Pp(r,n),z(19,r[se])}function Pp(e,t){Si(e)&&Mc(e,t)}function Mc(e,t){let r=e[T],o=e[M],i=e[Te],s=!!(t===0&&o&16);if(s||=!!(o&64&&t===0),s||=!!(o&1024),s||=!!(i?.dirty&&wn(i)),s||=!1,i&&(i.dirty=!1),e[M]&=-9217,s)Vy(r,e,r.template,e[se]);else if(o&8192){let a=_(null);try{Ap(e),Op(e,1);let c=r.components;c!==null&&kp(e,c,1),Mp(e)}finally{_(a)}}}function kp(e,t,n){for(let r=0;r<t.length;r++)Gy(e,t[r],n)}function zy(e,t){let n=e.hostBindingOpCodes;if(n!==null)try{for(let r=0;r<n.length;r++){let o=n[r];if(o<0)Lt(~o);else{let i=o,s=n[++r],a=n[++r];Zd(s,i);let c=t[i];z(24,c),a(2,c),z(25,c)}}}finally{Lt(-1)}}function rl(e,t){let n=sc()?64:1088;for(e[ut].changeDetectionScheduler?.notify(t);e;){e[M]|=n;let r=At(e);if(Wn(e)&&!r)return e;e=r}return null}function Lp(e,t,n,r){return[e,!0,0,t,null,r,null,n,null,null]}function Wy(e,t){let n=fe+t;if(n<e.length)return e[n]}function Fp(e,t,n,r=!0){let o=t[T];if(Yy(o,t,e,n),r){let s=wc(n,e),a=t[ne],c=a.parentNode(e[Pt]);c!==null&&sy(o,e[Pe],a,t,c,s)}let i=t[Br];i!==null&&i.firstChild!==null&&(i.firstChild=null)}function qy(e,t){let n=Gi(e,t);return n!==void 0&&Xc(n[T],n),n}function Gi(e,t){if(e.length<=fe)return;let n=fe+t,r=e[n];if(r){let o=r[Ot];o!==null&&o!==e&&Qc(o,r),t>0&&(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<i-fe?(t[Oe]=n[o],Ha(n,fe+r,t)):(n.push(t),t[Oe]=null),t[te]=n;let s=t[Ot];s!==null&&n!==s&&jp(s,t);let a=t[Ze];a!==null&&a.insertView(e),bi(t),t[M]|=128}function jp(e,t){let n=e[on],r=t[te];if(ft(r))e[M]|=2;else{let o=r[te][ke];t[ke]!==o&&(e[M]|=2)}n===null?e[on]=[t]:n.push(t)}var Ft=class{_lView;_cdRefInjectingView;_appRef=null;_attachedToViewContainer=!1;exhaustive;get rootNodes(){let t=this._lView,n=t[T];return Qr(n,t,n.firstChild,[])}constructor(t,n){this._lView=t,this._cdRefInjectingView=n}get context(){return this._lView[se]}set context(t){this._lView[se]=t}get destroyed(){return cn(this._lView)}destroy(){if(this._appRef)this._appRef.detachView(this);else if(this._attachedToViewContainer){let t=this._lView[te];if(Ue(t)){let n=t[Ur],r=n?n.indexOf(this):-1;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;s<t.length;s++){let a=t[s];if(typeof a==\"number\")i=a;else if(i==1)o=xa(o,a);else if(i==2){let c=a,l=t[++s];r=xa(r,c+\": \"+l+\";\")}}n?e.styles=r:e.stylesWithoutHost=r,n?e.classes=o:e.classesWithoutHost=o}function il(e,t=0){let n=W();if(n===null)return N(e,t);let r=Me();return Kf(r,n,De(e),t)}function nE(e,t,n,r,o){let i=r===null?null:{\"\":-1},s=o(e,n);if(s!==null){let a=s,c=null,l=null;for(let u of s)if(u.resolveHostDirectives!==null){[a,c,l]=u.resolveHostDirectives(s);break}iE(e,t,n,a,i,c,l)}i!==null&&r!==null&&rE(n,r,i)}function rE(e,t,n){let r=e.localNames=[];for(let o=0;o<t.length;o+=2){let i=n[t[o+1]];if(i==null)throw new C(-301,!1);r.push(t[o],i)}}function oE(e,t,n){t.componentOffset=n,(e.components??=[]).push(t.index)}function iE(e,t,n,r,o,i,s){let a=r.length,c=!1;for(let m=0;m<a;m++){let h=r[m];!c&&an(h)&&(c=!0,oE(e,n,m)),Iv(Wf(n,t),e,h.type)}dE(n,e.data.length,a);for(let m=0;m<a;m++){let h=r[m];h.providersResolver&&h.providersResolver(h)}let l=!1,u=!1,f=vp(e,t,a,null);a>0&&(n.directiveToIndex=new Map);for(let m=0;m<a;m++){let h=r[m];if(n.mergedAttrs=es(n.mergedAttrs,h.hostAttrs),aE(e,n,t,f,h),uE(f,h,o),s!==null&&s.has(h)){let[S,O]=s.get(h);n.directiveToIndex.set(h.type,[f,S+n.directiveStart,O+n.directiveStart])}else(i===null||!i.has(h))&&n.directiveToIndex.set(h.type,f);h.contentQueries!==null&&(n.flags|=4),(h.hostBindings!==null||h.hostAttrs!==null||h.hostVars!==0)&&(n.flags|=64);let E=h.type.prototype;!l&&(E.ngOnChanges||E.ngOnInit||E.ngDoCheck)&&((e.preOrderHooks??=[]).push(n.index),l=!0),!u&&(E.ngOnChanges||E.ngDoCheck)&&((e.preOrderCheckHooks??=[]).push(n.index),u=!0),f++}sE(e,n,i)}function sE(e,t,n){for(let r=t.directiveStart;r<t.directiveEnd;r++){let o=e.data[r];if(n===null||!n.has(o))Df(0,t,o,r),Df(1,t,o,r),If(t,r,!1);else{let i=n.get(o);Cf(0,t,i,r),Cf(1,t,i,r),If(t,r,!0)}}}function Df(e,t,n,r){let o=e===0?n.inputs:n.outputs;for(let i in o)if(o.hasOwnProperty(i)){let s;e===0?s=t.inputs??={}:s=t.outputs??={},s[i]??=[],s[i].push(r),Vp(t,i)}}function Cf(e,t,n,r){let o=e===0?n.inputs:n.outputs;for(let i in o)if(o.hasOwnProperty(i)){let s=o[i],a;e===0?a=t.hostDirectiveInputs??={}:a=t.hostDirectiveOutputs??={},a[s]??=[],a[s].push(r,i),Vp(t,s)}}function Vp(e,t){t===\"class\"?e.flags|=8:t===\"style\"&&(e.flags|=16)}function If(e,t,n){let{attrs:r,inputs:o,hostDirectiveInputs:i}=e;if(r===null||!n&&o===null||n&&i===null||Wc(e)){e.initialInputs??=[],e.initialInputs.push(null);return}let s=null,a=0;for(;a<r.length;){let c=r[a];if(c===0){a+=4;continue}else if(c===5){a+=2;continue}else if(typeof c==\"number\")break;if(!n&&o.hasOwnProperty(c)){let l=o[c];for(let u of l)if(u===t){s??=[],s.push(c,r[a+1]);break}}else if(n&&i.hasOwnProperty(c)){let l=i[c];for(let u=0;u<l.length;u+=2)if(l[u]===t){s??=[],s.push(l[u+1],r[a+1]);break}}a+=2}e.initialInputs??=[],e.initialInputs.push(s)}function aE(e,t,n,r,o){e.data[r]=o;let i=o.factory||(o.factory=Qt(o.type,!0)),s=new Kr(i,an(o),il,null);e.blueprint[r]=s,n[r]=s,cE(e,t,r,vp(e,n,o.hostVars,Et),o)}function cE(e,t,n,r,o){let i=o.hostBindings;if(i){let s=e.hostBindingOpCodes;s===null&&(s=e.hostBindingOpCodes=[]);let a=~t.index;lE(s)!=a&&s.push(a),s.push(n,r,i)}}function lE(e){let t=e.length;for(;t>0;){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;r<t.exportAs.length;r++)n[t.exportAs[r]]=e;an(t)&&(n[\"\"]=e)}}function dE(e,t,n){e.flags|=1,e.directiveStart=t,e.directiveEnd=t+n,e.providerIndexes=t}function Up(e,t,n,r,o,i,s,a){let c=t[T],l=c.consts,u=kt(l,s),f=cs(c,e,n,r,u);return i&&nE(c,t,f,kt(l,a),o),f.mergedAttrs=es(f.mergedAttrs,f.attrs),f.attrs!==null&&zi(f,f.attrs,!1),f.mergedAttrs!==null&&zi(f,f.mergedAttrs,!0),c.queries!==null&&c.queries.elementStart(c,f),f}function $p(e,t){dv(e,t),Ya(t)&&e.queries.elementEnd(t)}function fE(e,t,n,r,o,i){let s=t.consts,a=kt(s,o),c=cs(t,e,n,r,a);if(c.mergedAttrs=es(c.mergedAttrs,c.attrs),i!=null){let l=kt(s,i);c.localNames=[];for(let u=0;u<l.length;u+=2)c.localNames.push(l[u],-1)}return c.attrs!==null&&zi(c,c.attrs,!1),c.mergedAttrs!==null&&zi(c,c.mergedAttrs,!0),t.queries!==null&&t.queries.elementStart(t,c),c}function us(e,t,n){if(n===Et)return!1;let r=e[t];return Object.is(r,n)?!1:(e[t]=n,!0)}function pE(e,t,n){return function r(o){let i=sn(e)?Qe(e.index,t):t;rl(i,5);let s=t[se],a=Sf(t,s,n,o),c=r.__ngNextListenerFn__;for(;c;)a=Sf(t,s,c,o)&&a,c=c.__ngNextListenerFn__;return a}}function Sf(e,t,n,r){let o=_(null);try{return z(6,t,n),n(r)!==!1}catch(i){return xy(e,i),!1}finally{z(7,t,n),_(o)}}function hE(e,t,n,r,o,i,s,a){let c=Ii(e),l=!1,u=null;if(!r&&c&&(u=mE(t,n,i,e.index)),u!==null){let f=u.__ngLastListenerFn__||u;f.__ngNextListenerFn__=s,u.__ngLastListenerFn__=s,l=!0}else{let f=Ke(e,n),m=r?r(f):f;Fv(n,m,i,a);let h=o.listen(m,i,a);if(!gE(i)){let E=r?S=>r(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;i<o.length-1;i+=2){let s=o[i];if(s===n&&o[i+1]===r){let a=t[$n],c=o[i+2];return a&&a.length>c?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<r.length;u++){let f=r[u];if(typeof f!=\"function\")for(let m of f.bindings){a+=m[Ac].requiredVars;let h=u+1;m.create&&(m.targetIdx=h,(i??=[]).push(m)),m.update&&(m.targetIdx=h,(s??=[]).push(m))}}let c=[t];if(r)for(let u of r){let f=typeof u==\"function\"?u:u.type,m=$a(f);c.push(m)}return qc(0,null,bE(i,s),1,a,c,null,null,null,[o],null)}function bE(e,t){return!e&&!t?null:n=>{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<t.length;o++){let i=n[o];r.push(i!=null&&i.length?Array.from(i):null)}}var gn=(()=>{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;i<r;i++){let s=n.getByIndex(i),a=this.queries[s.indexInDeclarationView];o.push(a.clone())}return new e(o)}return null}insertView(t){this.dirtyQueriesWithMatches(t)}detachView(t){this.dirtyQueriesWithMatches(t)}finishViewCreation(t){this.dirtyQueriesWithMatches(t)}dirtyQueriesWithMatches(t){for(let n=0;n<this.queries.length;n++)Zp(t,n).matches!==null&&this.queries[n].setDirty()}},Pc=class{flags;read;predicate;constructor(t,n,r=null){this.flags=n,this.read=r,typeof t==\"string\"?this.predicate=HE(t):this.predicate=t}},kc=class e{queries;constructor(t=[]){this.queries=t}elementStart(t,n){for(let r=0;r<this.queries.length;r++)this.queries[r].elementStart(t,n)}elementEnd(t){for(let n=0;n<this.queries.length;n++)this.queries[n].elementEnd(t)}embeddedTView(t){let n=null;for(let r=0;r<this.length;r++){let o=n!==null?n.length:0,i=this.getByIndex(r).embeddedTView(t,o);i&&(i.indexInDeclarationView=r,n!==null?n.push(i):n=[i])}return n!==null?new e(n):null}template(t,n){for(let r=0;r<this.queries.length;r++)this.queries[r].template(t,n)}getByIndex(t){return this.queries[t]}get length(){return this.queries.length}track(t){this.queries.push(t)}},Lc=class e{metadata;matches=null;indexInDeclarationView=-1;crossesNgTemplate=!1;_declarationNodeIndex;_appliesToNextNode=!0;constructor(t,n=-1){this.metadata=t,this._declarationNodeIndex=n}elementStart(t,n){this.isApplyingToNode(n)&&this.matchTNode(t,n)}elementEnd(t){this._declarationNodeIndex===t.index&&(this._appliesToNextNode=!1)}template(t,n){this.elementStart(t,n)}embeddedTView(t,n){return this.isApplyingToNode(t)?(this.crossesNgTemplate=!0,this.addMatch(-t.index,n),new e(this.metadata)):null}isApplyingToNode(t){if(this._appliesToNextNode&&(this.metadata.flags&1)!==1){let n=this._declarationNodeIndex,r=t.parent;for(;r!==null&&r.type&8&&r.index!==n;)r=r.parent;return n===(r!==null?r.index:-1)}return this._appliesToNextNode}matchTNode(t,n){let r=this.metadata.predicate;if(Array.isArray(r))for(let o=0;o<r.length;o++){let i=r[o];this.matchTNodeWithReadOption(t,n,OE(n,i)),this.matchTNodeWithReadOption(t,n,Li(n,t,i,!1,!1))}else r===Xn?n.type&4&&this.matchTNodeWithReadOption(t,n,-1):this.matchTNodeWithReadOption(t,n,Li(n,t,r,!1,!1))}matchTNodeWithReadOption(t,n,r){if(r!==null){let o=this.metadata.read;if(o!==null)if(o===rr||o===gn||o===Xn&&n.type&4)this.addMatch(n.index,-2);else{let i=Li(n,t,o,!1,!1);i!==null&&this.addMatch(n.index,i)}else this.addMatch(n.index,r)}}addMatch(t,n){this.matches===null?this.matches=[t,n]:this.matches.push(t,n)}};function OE(e,t){let n=e.localNames;if(n!==null){for(let r=0;r<n.length;r+=2)if(n[r]===t)return n[r+1]}return null}function PE(e,t){return e.type&11?nr(e,t):e.type&4?ol(e,t):null}function kE(e,t,n,r){return n===-1?PE(t,e):n===-2?LE(e,t,r):Vi(e,e[T],n,t)}function LE(e,t,n){if(n===rr)return nr(t,e);if(n===Xn)return ol(t,e);if(n===gn)return Wp(t,e)}function qp(e,t,n,r){let o=t[Ze].queries[r];if(o.matches===null){let i=e.data,s=n.matches,a=[];for(let c=0;s!==null&&c<s.length;c+=2){let l=s[c];if(l<0)a.push(null);else{let u=i[l];a.push(kE(t,u,s[c+1],n.metadata.read))}}o.matches=a}return o.matches}function Fc(e,t,n,r){let o=e.queries.getByIndex(n),i=o.matches;if(i!==null){let s=qp(e,t,o,n);for(let a=0;a<i.length;a+=2){let c=i[a];if(c>0)r.push(s[a/2]);else{let l=i[a+1],u=t[-c];for(let f=fe;f<u.length;f++){let m=u[f];m[Ot]===m[te]&&Fc(m[T],m,l,r)}if(u[on]!==null){let f=u[on];for(let m=0;m<f.length;m++){let h=f[m];Fc(h[T],h,l,r)}}}}}return r}function Yp(e,t){return e[Ze].queries[t].queryList}function FE(e,t,n){let r=new Ui((n&4)===4);return jd(e,t,r,r.destroy),(t[Ze]??=new Oc).queries.push(new Rc(r))-1}function jE(e,t,n){let r=Xe();return r.firstCreatePass&&(BE(r,new Pc(e,t,n),-1),(t&2)===2&&(r.staticViewQueries=!0)),FE(r,W(),t)}function HE(e){return e.split(\",\").map(t=>t.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;E<h.length;E+=2)m.localNames.push(h[E],-1)}}else m=t.data[f];return ZE(m,e,t,n,r,o,i,c),l!=null&&tl(e,m,u),m}var KE=QE;function QE(e,t,n,r){return Ai(!0),t[ne].createComment(\"\")}var al=(function(e){return e[e.CHANGE_DETECTION=0]=\"CHANGE_DETECTION\",e[e.AFTER_NEXT_RENDER=1]=\"AFTER_NEXT_RENDER\",e})(al||{}),io=new b(\"\"),nh=!1,jc=class extends X{__isAsync;destroyRef=void 0;pendingTasks=void 0;constructor(t=!1){super(),this.__isAsync=t,Nd()&&(this.destroyRef=v(gt,{optional:!0})??void 0,this.pendingTasks=v(mt,{optional:!0})??void 0)}emit(t){let n=_(null);try{super.next(t)}finally{_(n)}}subscribe(t,n,r){let o=t,i=n||(()=>null),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++<oD;)z(14),this.synchronizeOnce(),z(15)}synchronizeOnce(){this.dirtyFlags&16&&(this.dirtyFlags&=-17,this.rootEffectScheduler.flush());let n=!1;if(this.dirtyFlags&7){let r=!!(this.dirtyFlags&1);this.dirtyFlags&=-8,this.dirtyFlags|=8;for(let{_lView:o}of this.allViews){if(!r&&!Gr(o))continue;let i=r&&!this.zonelessEnabled?0:1;Rp(o,i),n=!0}if(this.dirtyFlags&=-5,this.syncDirtyFlagsWithViews(),this.dirtyFlags&23)return}n||(this._rendererFactory?.begin?.(),this._rendererFactory?.end?.()),this.dirtyFlags&8&&(this.dirtyFlags&=-9,this.afterRenderManager.execute()),this.syncDirtyFlagsWithViews()}syncDirtyFlagsWithViews(){if(this.allViews.some(({_lView: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;i<o;i++){let s=e[i].hostAttrs;r=eo(r,s,n)}return eo(r,t.attrs,n)}function yc(e,t,n,r,o){let i=null,s=n.directiveEnd,a=n.directiveStylingLast;for(a===-1?a=n.directiveStart:a++;a<s&&(i=t[a],r=eo(r,i.hostAttrs,o),i!==e);)a++;return e!==null&&(n.directiveStylingLast=a),r}function eo(e,t,n){let r=n?1:2,o=-1;if(t!==null)for(let i=0;i<t.length;i++){let s=t[i];typeof s==\"number\"?o=s:o===r&&(Array.isArray(e)||(e=e===void 0?[]:[\"\",e]),Sd(e,s,n?!0:t[++i]))}return e===void 0?null:e}function DD(e,t,n,r,o,i,s,a){if(!(t.type&3))return;let c=e.data,l=c[a+1],u=lD(l)?Pf(c,t,n,o,tr(l),s):void 0;if(!Qi(u)){Qi(i)||aD(l)&&(i=Pf(c,null,n,o,a,s));let f=Za(un(),n);yy(r,s,f,o,i)}}function Pf(e,t,n,r,o,i){let s=t===null,a;for(;o>0;){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\",\"\t\":\"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.length<e.length))return null;let o={};for(let i=0;i<r.length;i++){let s=r[i],a=e[i];if(s[0]===\":\")o[s.substring(1)]=a;else if(s!==a.path)return null}return{consumed:e.slice(0,r.length),posParams:o}}function MC(e,t){if(e.length!==t.length)return!1;for(let n=0;n<e.length;++n)if(!tt(e[n],t[n]))return!1;return!0}function tt(e,t){let n=e?$l(e):void 0,r=t?$l(t):void 0;if(!n||!r||n.length!=r.length)return!1;let o;for(let i=0;i<n.length;i++)if(o=n[i],!Yh(e[o],t[o]))return!1;return!0}function $l(e){return[...Object.keys(e),...Object.getOwnPropertySymbols(e)]}function Yh(e,t){if(Array.isArray(e)&&Array.isArray(t)){if(e.length!==t.length)return!1;let n=[...e].sort(),r=[...t].sort();return n.every((o,i)=>r[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.pathIndex<e.segments.length){let i=new $(e.segments.slice(0,r.pathIndex),{});return i.children[A]=new $(e.segments.slice(r.pathIndex),e.children),Do(i,0,o)}else return r.match&&o.length===0?new $(e.segments,{}):r.match&&!e.hasChildren()?Wl(e,t,n):r.match?Do(e,0,o):Wl(e,t,n)}function Do(e,t,n){if(n.length===0)return new $(e.segments,{});{let r=KC(n),o={};if(Object.keys(r).some(i=>i!==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<e.segments.length;){if(r>=n.length)return i;let s=e.segments[o],a=n[r];if(So(a))break;let c=`${a}`,l=r<n.length-1?n[r+1]:null;if(o>0&&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<n.length;){let i=n[o];if(So(i)){let c=XC(i.outlets);return new $(r,c)}if(o===0&&Ts(n[0])){let c=e.segments[t];r.push(new En(c.path,$h(n[0]))),o++;continue}let s=So(i)?i.outlets[A]:`${i}`,a=o<n.length-1?n[o+1]:null;s&&a&&Ts(a)?(r.push(new En(s,$h(a))),o+=2):(r.push(new En(s,{})),o++)}return new $(r,{})}function XC(e){let t={};return Object.entries(e).forEach(([n,r])=>{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;t<e.length;t++)if(e[t]==null)throw new C(4008,!1)}var sS=new b(\"\");function yu(e,...t){return tn([{provide:Vs,multi:!0,useValue:e},[],{provide:Cn,useFactory:aS,deps:[vu]},{provide:ps,multi:!0,useFactory:cS},t.map(n=>n.\\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<r;l+=16)for(let u=0;u<o;u+=16){var a=D.FARM_SOIL.x,c=D.FARM_SOIL.y;u===0?l==0?(a=D.FARM_TOP_LEFT.x,c=D.FARM_TOP_LEFT.y):l===r-16?(a=D.FARM_TOP_RIGHT.x,c=D.FARM_TOP_RIGHT.y):(a=D.FARM_TOP_MIDDLE.x,c=D.FARM_TOP_MIDDLE.y):u===o-16?l==0?(a=D.FARM_BOTTOM_LEFT.x,c=D.FARM_BOTTOM_LEFT.y):l===r-16?(a=D.FARM_BOTTOM_RIGHT.x,c=D.FARM_BOTTOM_RIGHT.y):(a=D.FARM_BOTTOM_MIDDLE.x,c=D.FARM_BOTTOM_MIDDLE.y):l==0?(a=D.FARM_MID_LEFT.x,c=D.FARM_MID_LEFT.y):l===r-16?(a=D.FARM_MID_RIGHT.x,c=D.FARM_MID_RIGHT.y):(this.slotPos[s]={x:this.x+u,y:this.y+l},s++),i.push({x:t+l,y:n+u,atlasX:a*16,atlasY:c*16})}i.push({x:this.x+16,y:this.y-17,atlasX:D.POTS.x*16,atlasY:D.POTS.y*16,atlasWidth:48,atlasHeight:16}),this.baseTiles.set([i])}genTiles(){let t=[];var n=0;let r=this.slotStates();for(let a=0;a<this.height;a+=16)for(let c=0;c<this.width;c+=16)if(c!==0&&c!==this.height-16&&a!==0&&a!==this.width-16){var o=D.FARM_SLOT_DRY;r[n]==1&&(o=D.FARM_SLOT_WET),t.push({x:this.x+c,y:this.y+a,atlasX:o.x*16,atlasY:o.y*16}),this.plants()[n]||t.push({x:this.x+c,y:this.y+a+1,atlasX:D.DIGITS[n].x*16,atlasY:D.DIGITS[n].y*16}),n++}let i=[];for(let a of Object.keys(this.plants())){let c=Number(a),l=this.plants()[c],u=D.getPlantTilesFromPlant(l);if(i.push({x:this.slotPos[c].x+(u.offsetX??0),y:this.slotPos[c].y+(u.offsetY??0),atlasX:u.x*16,atlasY:u.y*16,atlasWidth:u.width??16,atlasHeight:u.height??16}),l.type===\"GALLERY\"&&l.grown){let f=this.shuffledGalleryFruits[c];for(let m=0;m<f.length;m++){let h=0,E=0;switch(m){case 0:h=-3,E=-8;break;case 1:h=1,E=-8;break;case 2:h=-3,E=-3;break;case 3:h=2,E=-3;break;default:break}let S=f[m],O=D.GALLERY_FRUITS[S];i.push({x:this.slotPos[c].x+h,y:this.slotPos[c].y+E,atlasX:O.x*16,atlasY:O.y*16})}}}let s=[this.baseTiles()[0],t,i];return this.showHelp()&&s.push([{x:this.x-16,y:this.y-16,atlasX:D.HELP_ICON.x*16,atlasY:D.HELP_ICON.y*16,atlasWidth:16,atlasHeight:16}]),s}getSlotPos(t){return this.slotPos[t]}getPlant(t){return this.plants()[t]}setSlotWatered(t,n){if(this.slotStates.update(r=>{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;r<this.slotsCount;r++)n.push({x:this.x+r*16,y:this.y,atlasX:D.INVENTORY_SLOT.x*16,atlasY:D.INVENTORY_SLOT.y*16,atlasWidth:D.INVENTORY_SLOT.width??16,atlasHeight:D.INVENTORY_SLOT.height??16});this.baseTiles.set([n])}genTiles(){let n=[],r=this.tickService.countTick()%D.HIGHLIGHTER_ANIM.length;n.push({x:this.x+this.selectedSlot()*16+1,y:this.y+14,atlasX:D.HIGHLIGHTER_ANIM[r].x*16,atlasY:D.HIGHLIGHTER_ANIM[r].y*16});for(let o of Object.keys(this.inventory())){if(this.hiddenSeeds().has(o))continue;let i=this.inventory()[o];i.slotIndex<0||(n.push({x:this.x+i.slotIndex*16+(i.offsetX??0),y:this.y+(i.offsetY??0),atlasX:D.getAtlasTileFromObj(i).x*16,atlasY:D.getAtlasTileFromObj(i).y*16}),i.locked&&(n.push({x:this.x+i.slotIndex*16,y:this.y-24,atlasX:D.getLockAtlasTileFromObj(i).x*16,atlasY:D.getLockAtlasTileFromObj(i).y*16}),n.push({x:this.x+i.slotIndex*16+1,y:this.y+14,atlasX:D.LOCK.x*16,atlasY:D.LOCK.y*16}),n.push({x:this.x+i.slotIndex*16,y:this.y-13,atlasX:D.KEY.x*16,atlasY:D.KEY.y*16})))}return[this.baseTiles()[0],n]}isSlotLocked(n){let r=Object.values(this.inventory()).find(o=>o.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.baseTick)return this.frozenTile?[[this.frozenTile]]:[];let t=Fg[this.animIndex];this.state()===2?t=D.CHICKEN_DANCE_ANIM:this.state()===1&&(t=D.CHICKEN_IDLE_ANIM);let n=this.tickService.countTick()-this.baseTick,r=t[n];if(n===t.length){this.animIndex=Math.floor(Math.random()*Fg.length),this.state()!==2?this.baseTick=this.tickService.countTick()+Math.floor(Math.random()*(hS-jg)+jg):this.baseTick=this.tickService.countTick()+1;let o=t[t.length-1];return this.frozenTile={x:this.x,y:this.y,atlasX:o.x*16,atlasY:o.y*16},[[this.frozenTile]]}return[[{x:this.x,y:this.y,atlasX:r.x*16,atlasY:r.y*16}]]}setState(t){this.baseTick=this.tickService.countTick(),this.state.set(t)}};var nt=class{constructor(t,n,r,o=0){this.interval=t;this.steps=n;this.onDone=r;this.delayMs=o;o!==0&&(this.lastTs=Date.now()+o)}curStep=0;raf=-1;lastTs=0;tick=k(-1);animate(){let t=Date.now();if(t-this.lastTs>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<this.width;o+=16)for(let i=0;i<this.height;i+=16){var r=0;o===0?i===0?r=0:i===this.height-16?r=6:r=3:o===this.width-16?i===0?r=2:i===this.height-16?r=8:r=5:o>=16&&o<this.width-16&&(i===0?r=1:i===this.height-16?r=7:r=4),n.push({x:this.x+o,y:this.y+i,atlasX:D.BUBBLE_TILES[r].x*16,atlasY:D.BUBBLE_TILES[r].y*16})}return n}};var Ws=class extends pe{animator;gamService=v(Se);constructor(t,n){super(t,n,16,16),this.animator=new nt(100,D.SCYTHE_ANIM.length,()=>{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<this.pots().length;m++){let h=this.pots()[m]?.nativeElement;h&&this.setDivRect(105+16*m,53.5,13,11,h)}let i=this.help()?.nativeElement;i&&this.setDivRect(72,51.5,16,16,i);let s=this.unlockGoal()?.nativeElement;s&&this.setDivRect(Du+45+6.5,Cu-17.2,11,10,s);let a=this.gallerySeed()?.nativeElement;a&&this.setDivRect(Du+45+7,Cu+5,9,9,a);let c=this.galleryDecoration()?.nativeElement;c&&this.setDivRect(Iu-71,Su+10,9,9,c);let l=this.msgBubble()?.nativeElement;l&&this.setDivRect(78,66,81,-1,l);let u=this.exitTutorial()?.nativeElement;u&&this.setDivRect(78,54,18,11,u);let f=this.chickenOverlay()?.nativeElement;f&&this.setDivRect(Iu-2,Su-2,20,20,f),this.divsPositioned=!0}let r=this.unlockGoal()?.nativeElement;r&&!this.inventory.isSlotLocked(3)&&(r.style.visibility=\"hidden\");let o=this.gallerySeed()?.nativeElement;if(o&&(this.inventory.isSlotLocked(3)?o.style.visibility=\"hidden\":o.style.visibility=\"visible\"),!this.resizeObserver){let i=this.container()?.nativeElement,s=this.canvas()?.nativeElement,a=this.overlay()?.nativeElement,c=this.overlayText()?.nativeElement;if(!i||!s||!a||!c)return;this.resizeObserver=new ResizeObserver(()=>{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<this.pots().length;We++){let br=this.pots()[We]?.nativeElement;br&&this.setDivFontSize(br,ZS)}let O=this.unlockGoal()?.nativeElement;O&&this.setDivFontSize(O,KS);let F=this.msgBubble()?.nativeElement;F&&this.setDivFontSize(F,Ys);let In=this.exitTutorial()?.nativeElement;In&&this.setDivFontSize(In,Ys*20)}),this.resizeObserver.observe(i)}}),It(()=>{if(this.pots().length!==3)return;let t=this.flowerCounts();for(let n=0;n<t.length;n++){let r=t[n],o=this.pots()[n]?.nativeElement;if(!o)continue;let i=o.innerText;o.innerText=r.toString(),i!==\"\"&&o.innerText!==i&&(o.style.transform=\"translate(-25%, -5%) scale(1.5)\",setTimeout(()=>{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<bu.length;n++){let r=bu[n];if(r.unlocked)continue;let o=!0;for(let i of r.slots){let s=this.farm.getPlant(i.slotIndex);if(i.plantType==null&&s!=null||i.plantType!=null&&(s?.type!==i.plantType||s.phase!==4)){o=!1;break}}if(o){r.unlocked=!0,t=n;break}}if(t>=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;o<t.length;o++){let i=t[o];i<0||i>9||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;o<t.length;o++){let i=t[o];i<0||i>9||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;o<t.length;o++){let i=t[o];i<0||i>9||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<Math.floor(4+Math.random()*8);rt++)setTimeout(()=>{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));\n"
  },
  {
    "path": "Android/src/app/src/main/assets/tinygarden/styles-63IRQW2E.css",
    "content": "/*\n * Copyright 2025 Google LLC\n *\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n *     http://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\nbody,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}\n"
  },
  {
    "path": "Android/src/app/src/main/bundle_config.pb.json",
    "content": "// Bundle config for the Google AI Edge Gallery app.\n// See: https://developer.android.com/studio/build/building-cmdline#bundleconfig\n{\n  \"optimizations\": {\n    \"splitsConfig\": {\n      \"splitDimension\": [\n        {\n          \"value\": \"ABI\"\n        },\n        {\n          \"value\": \"SCREEN_DENSITY\"\n        },\n        {\n          \"value\": \"LANGUAGE\"\n        }\n      ]\n    }\n  }\n}"
  },
  {
    "path": "Android/src/app/src/main/java/com/google/ai/edge/gallery/Analytics.kt",
    "content": "/*\n * Copyright 2025 Google LLC\n *\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n *     http://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\n\npackage com.google.ai.edge.gallery\n\nimport android.util.Log\nimport com.google.firebase.Firebase\nimport com.google.firebase.analytics.FirebaseAnalytics\nimport com.google.firebase.analytics.analytics\n\nprivate var hasLoggedAnalyticsWarning = false\n\nval firebaseAnalytics: FirebaseAnalytics?\n  get() =\n    runCatching { Firebase.analytics }\n      .onFailure { exception ->\n        // Firebase.analytics can throw an exception if goolgle-services is not set up, e.g.,\n        // missing google-services.json.\n        if (!hasLoggedAnalyticsWarning) {\n          Log.w(\"AGAnalyticsFirebase\", \"Firebase Analytics is not available\", exception)\n        }\n      }\n      .getOrNull()\n\nenum class GalleryEvent(val id: String) {\n  CAPABILITY_SELECT(id = \"capability_select\"),\n  MODEL_DOWNLOAD(id = \"model_download\"),\n  GENERATE_ACTION(id = \"generate_action\"),\n  BUTTON_CLICKED(id = \"button_clicked\"),\n}\n"
  },
  {
    "path": "Android/src/app/src/main/java/com/google/ai/edge/gallery/BenchmarkResultsSerializer.kt",
    "content": "/*\n * Copyright 2026 Google LLC\n *\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n *     http://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\n\npackage com.google.ai.edge.gallery\n\nimport androidx.datastore.core.CorruptionException\nimport androidx.datastore.core.Serializer\nimport com.google.ai.edge.gallery.proto.BenchmarkResults\nimport com.google.protobuf.InvalidProtocolBufferException\nimport java.io.InputStream\nimport java.io.OutputStream\n\nobject BenchmarkResultsSerializer : Serializer<BenchmarkResults> {\n  override val defaultValue: BenchmarkResults = BenchmarkResults.getDefaultInstance()\n\n  override suspend fun readFrom(input: InputStream): BenchmarkResults {\n    try {\n      return BenchmarkResults.parseFrom(input)\n    } catch (exception: InvalidProtocolBufferException) {\n      throw CorruptionException(\"Cannot read proto.\", exception)\n    }\n  }\n\n  override suspend fun writeTo(t: BenchmarkResults, output: OutputStream) = t.writeTo(output)\n}\n"
  },
  {
    "path": "Android/src/app/src/main/java/com/google/ai/edge/gallery/CutoutsSerializer.kt",
    "content": "/*\n * Copyright 2025 Google LLC\n *\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n *     http://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\n\npackage com.google.ai.edge.gallery\n\nimport androidx.datastore.core.CorruptionException\nimport androidx.datastore.core.Serializer\nimport com.google.ai.edge.gallery.proto.CutoutCollection\nimport com.google.protobuf.InvalidProtocolBufferException\nimport java.io.InputStream\nimport java.io.OutputStream\n\nobject CutoutsSerializer : Serializer<CutoutCollection> {\n  override val defaultValue: CutoutCollection = CutoutCollection.getDefaultInstance()\n\n  override suspend fun readFrom(input: InputStream): CutoutCollection {\n    try {\n      return CutoutCollection.parseFrom(input)\n    } catch (exception: InvalidProtocolBufferException) {\n      throw CorruptionException(\"Cannot read proto.\", exception)\n    }\n  }\n\n  override suspend fun writeTo(t: CutoutCollection, output: OutputStream) = t.writeTo(output)\n}\n"
  },
  {
    "path": "Android/src/app/src/main/java/com/google/ai/edge/gallery/FcmMessagingService.kt",
    "content": "/*\n * Copyright 2026 Google LLC\n *\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n *     http://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\n\npackage com.google.ai.edge.gallery\n\nimport android.app.NotificationChannel\nimport android.app.NotificationManager\nimport android.app.PendingIntent\nimport android.content.Context\nimport android.content.Intent\nimport android.media.RingtoneManager\nimport android.os.Build\nimport android.util.Log\nimport androidx.core.app.NotificationCompat\nimport com.google.firebase.messaging.FirebaseMessagingService\nimport com.google.firebase.messaging.RemoteMessage\n\nclass GalleryFcmMessagingService : FirebaseMessagingService() {\n  override fun onMessageReceived(remoteMessage: RemoteMessage) {\n    // TODO(developer): Handle FCM messages here.\n    // Not getting messages here? See why this may be: https://goo.gl/39bRNJ\n    Log.d(TAG, \"From: ${remoteMessage.from}\")\n\n    // Check if message contains a data payload.\n    if (remoteMessage.data.isNotEmpty()) {\n      Log.d(TAG, \"Message data payload: ${remoteMessage.data}\")\n\n      // Handle message within 10 seconds\n      handleNow()\n    }\n\n    // Check if message contains a notification payload.\n    remoteMessage.notification?.let { notification ->\n      Log.d(TAG, \"Message Notification Body: ${notification.body}\")\n      notification.body?.let { body ->\n        sendNotification(notification.title, body, notification.imageUrl)\n      }\n    }\n\n    // Also if you intend on generating your own notificatisons as a result of a received FCM\n    // message, here is where that should be initiated. See sendNotification method below.\n\n  }\n\n  private fun handleNow() {\n    Log.d(TAG, \"Short lived task is done.\")\n  }\n\n  private fun sendNotification(title: String?, messageBody: String, imageUrl: android.net.Uri?) {\n    val intent = Intent(this, MainActivity::class.java)\n    intent.addFlags(Intent.FLAG_ACTIVITY_CLEAR_TOP)\n    val requestCode = 0\n    val pendingIntent =\n      PendingIntent.getActivity(this, requestCode, intent, PendingIntent.FLAG_IMMUTABLE)\n\n    val channelId = \"gallery_high_priority_push_channel\"\n    val defaultSoundUri = RingtoneManager.getDefaultUri(RingtoneManager.TYPE_NOTIFICATION)\n    val notificationBuilder =\n      NotificationCompat.Builder(this, channelId)\n        .setSmallIcon(R.mipmap.ic_launcher)\n        .setContentTitle(title ?: getString(R.string.gallery_news_notification_title))\n        .setContentText(messageBody)\n        .setAutoCancel(true)\n        .setSound(defaultSoundUri)\n        .setContentIntent(pendingIntent)\n        .setPriority(NotificationCompat.PRIORITY_HIGH)\n\n    if (imageUrl != null) {\n      try {\n        val url = java.net.URL(imageUrl.toString())\n        val connection = url.openConnection()\n        connection.connectTimeout = 5000\n        connection.readTimeout = 5000\n        val bitmap = android.graphics.BitmapFactory.decodeStream(connection.getInputStream())\n        if (bitmap != null) {\n          notificationBuilder.setLargeIcon(bitmap)\n          notificationBuilder.setStyle(\n            NotificationCompat.BigPictureStyle()\n              .bigPicture(bitmap)\n              .bigLargeIcon(null as android.graphics.Bitmap?)\n          )\n        }\n      } catch (e: Exception) {\n        Log.w(TAG, \"Failed to download image\", e)\n      }\n    }\n\n    val notificationManager = getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager\n\n    // Since android Oreo notification channel is needed.\n    if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {\n      val channel =\n        NotificationChannel(\n          channelId,\n          getString(R.string.gallery_news_notification_title),\n          NotificationManager.IMPORTANCE_HIGH,\n        )\n      notificationManager.createNotificationChannel(channel)\n    }\n\n    val notificationId = 0\n    notificationManager.notify(notificationId, notificationBuilder.build())\n  }\n\n  companion object {\n    private const val TAG = \"AGFcmMessagingService\"\n  }\n}\n"
  },
  {
    "path": "Android/src/app/src/main/java/com/google/ai/edge/gallery/GalleryApp.kt",
    "content": "/*\n * Copyright 2025 Google LLC\n *\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n *     http://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\n\npackage com.google.ai.edge.gallery\n\nimport androidx.compose.runtime.Composable\nimport androidx.navigation.NavHostController\nimport androidx.navigation.compose.rememberNavController\nimport com.google.ai.edge.gallery.ui.modelmanager.ModelManagerViewModel\nimport com.google.ai.edge.gallery.ui.navigation.GalleryNavHost\n\n/** Top level composable representing the main screen of the application. */\n@Composable\nfun GalleryApp(\n  navController: NavHostController = rememberNavController(),\n  modelManagerViewModel: ModelManagerViewModel,\n) {\n  GalleryNavHost(navController = navController, modelManagerViewModel = modelManagerViewModel)\n}\n"
  },
  {
    "path": "Android/src/app/src/main/java/com/google/ai/edge/gallery/GalleryAppTopBar.kt",
    "content": "/*\n * Copyright 2025 Google LLC\n *\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n *     http://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\n\n@file:OptIn(ExperimentalMaterial3Api::class)\n\npackage com.google.ai.edge.gallery\n\nimport androidx.compose.foundation.layout.Arrangement\nimport androidx.compose.foundation.layout.Column\nimport androidx.compose.foundation.layout.Row\nimport androidx.compose.foundation.layout.size\nimport androidx.compose.foundation.text.BasicText\nimport androidx.compose.foundation.text.TextAutoSize\nimport androidx.compose.material.icons.Icons\nimport androidx.compose.material.icons.automirrored.rounded.ArrowBack\nimport androidx.compose.material.icons.rounded.Menu\nimport androidx.compose.material.icons.rounded.Settings\nimport androidx.compose.material3.CenterAlignedTopAppBar\nimport androidx.compose.material3.ExperimentalMaterial3Api\nimport androidx.compose.material3.Icon\nimport androidx.compose.material3.IconButton\nimport androidx.compose.material3.MaterialTheme\nimport androidx.compose.material3.Text\nimport androidx.compose.material3.TextButton\nimport androidx.compose.material3.TopAppBarScrollBehavior\nimport androidx.compose.runtime.Composable\nimport androidx.compose.ui.Alignment\nimport androidx.compose.ui.Modifier\nimport androidx.compose.ui.graphics.Color\nimport androidx.compose.ui.res.painterResource\nimport androidx.compose.ui.res.stringResource\nimport androidx.compose.ui.unit.dp\nimport androidx.compose.ui.unit.sp\nimport com.google.ai.edge.gallery.data.AppBarAction\nimport com.google.ai.edge.gallery.data.AppBarActionType\n\n/** The top app bar. */\n@OptIn(ExperimentalMaterial3Api::class)\n@Composable\nfun GalleryTopAppBar(\n  title: String,\n  modifier: Modifier = Modifier,\n  leftAction: AppBarAction? = null,\n  rightAction: AppBarAction? = null,\n  scrollBehavior: TopAppBarScrollBehavior? = null,\n  subtitle: String = \"\",\n) {\n  val titleColor = MaterialTheme.colorScheme.onSurface\n  CenterAlignedTopAppBar(\n    title = {\n      Column(horizontalAlignment = Alignment.CenterHorizontally) {\n        Row(\n          verticalAlignment = Alignment.CenterVertically,\n          horizontalArrangement = Arrangement.spacedBy(12.dp),\n        ) {\n          if (title == stringResource(R.string.app_name)) {\n            Icon(\n              painterResource(R.drawable.logo),\n              modifier = Modifier.size(20.dp),\n              contentDescription = null,\n              tint = Color.Unspecified,\n            )\n          }\n          BasicText(\n            text = title,\n            maxLines = 1,\n            color = { titleColor },\n            style = MaterialTheme.typography.titleMedium,\n            autoSize =\n              TextAutoSize.StepBased(minFontSize = 14.sp, maxFontSize = 16.sp, stepSize = 1.sp),\n          )\n        }\n        if (subtitle.isNotEmpty()) {\n          Text(\n            subtitle,\n            style = MaterialTheme.typography.labelSmall,\n            color = MaterialTheme.colorScheme.secondary,\n          )\n        }\n      }\n    },\n    modifier = modifier,\n    scrollBehavior = scrollBehavior,\n    // The button at the left.\n    navigationIcon = {\n      when (leftAction?.actionType) {\n        AppBarActionType.NAVIGATE_UP -> {\n          IconButton(onClick = leftAction.actionFn) {\n            Icon(\n              imageVector = Icons.AutoMirrored.Rounded.ArrowBack,\n              contentDescription = stringResource(R.string.cd_navigate_back_icon),\n            )\n          }\n        }\n        AppBarActionType.MENU -> {\n          IconButton(onClick = leftAction.actionFn) {\n            Icon(\n              imageVector = Icons.Rounded.Menu,\n              contentDescription = stringResource(R.string.cd_menu),\n            )\n          }\n        }\n\n        else -> {}\n      }\n    },\n    // The \"action\" component at the right.\n    actions = {\n      when (rightAction?.actionType) {\n        // Click an icon to open \"app setting\".\n        AppBarActionType.APP_SETTING -> {\n          IconButton(onClick = rightAction.actionFn) {\n            Icon(\n              imageVector = Icons.Rounded.Settings,\n              contentDescription = stringResource(R.string.cd_app_settings_icon),\n              tint = MaterialTheme.colorScheme.onSurface,\n            )\n          }\n        }\n\n        // Click a button to navigate up.\n        AppBarActionType.NAVIGATE_UP -> {\n          TextButton(onClick = rightAction.actionFn) { Text(\"Done\") }\n        }\n\n        else -> {}\n      }\n    },\n  )\n}\n"
  },
  {
    "path": "Android/src/app/src/main/java/com/google/ai/edge/gallery/GalleryApplication.kt",
    "content": "/*\n * Copyright 2025 Google LLC\n *\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n *     http://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\n\npackage com.google.ai.edge.gallery\n\nimport android.app.Application\nimport com.google.ai.edge.gallery.data.DataStoreRepository\nimport com.google.ai.edge.gallery.ui.theme.ThemeSettings\nimport com.google.firebase.FirebaseApp\nimport dagger.hilt.android.HiltAndroidApp\nimport javax.inject.Inject\n\n@HiltAndroidApp\nclass GalleryApplication : Application() {\n\n  @Inject lateinit var dataStoreRepository: DataStoreRepository\n\n  override fun onCreate() {\n    super.onCreate()\n\n    // Load saved theme.\n    ThemeSettings.themeOverride.value = dataStoreRepository.readTheme()\n\n    FirebaseApp.initializeApp(this)\n  }\n}\n"
  },
  {
    "path": "Android/src/app/src/main/java/com/google/ai/edge/gallery/GalleryLifecycleProvider.kt",
    "content": "/*\n * Copyright 2025 Google LLC\n *\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n *     http://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\n\npackage com.google.ai.edge.gallery\n\ninterface AppLifecycleProvider {\n  var isAppInForeground: Boolean\n}\n\nclass GalleryLifecycleProvider : AppLifecycleProvider {\n  private var _isAppInForeground = false\n\n  override var isAppInForeground: Boolean\n    get() = _isAppInForeground\n    set(value) {\n      _isAppInForeground = value\n    }\n}\n"
  },
  {
    "path": "Android/src/app/src/main/java/com/google/ai/edge/gallery/MainActivity.kt",
    "content": "/*\n * Copyright 2025 Google LLC\n *\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n *     http://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\n\npackage com.google.ai.edge.gallery\n\nimport android.animation.ObjectAnimator\nimport android.os.Build\nimport android.os.Bundle\nimport android.view.View\nimport android.view.WindowManager\nimport android.view.animation.DecelerateInterpolator\nimport androidx.activity.ComponentActivity\nimport androidx.activity.compose.setContent\nimport androidx.activity.enableEdgeToEdge\nimport androidx.activity.viewModels\nimport androidx.compose.animation.AnimatedVisibility\nimport androidx.compose.animation.core.FastOutSlowInEasing\nimport androidx.compose.animation.core.snap\nimport androidx.compose.animation.core.tween\nimport androidx.compose.animation.fadeIn\nimport androidx.compose.animation.fadeOut\nimport androidx.compose.foundation.background\nimport androidx.compose.foundation.layout.Box\nimport androidx.compose.foundation.layout.fillMaxSize\nimport androidx.compose.material3.MaterialTheme\nimport androidx.compose.material3.Surface\nimport androidx.compose.runtime.LaunchedEffect\nimport androidx.compose.runtime.getValue\nimport androidx.compose.runtime.mutableStateOf\nimport androidx.compose.runtime.remember\nimport androidx.compose.runtime.setValue\nimport androidx.compose.ui.Modifier\nimport androidx.core.animation.doOnEnd\nimport androidx.core.os.bundleOf\nimport androidx.core.splashscreen.SplashScreen.Companion.installSplashScreen\nimport androidx.lifecycle.lifecycleScope\nimport com.google.ai.edge.gallery.ui.modelmanager.ModelManagerViewModel\nimport com.google.ai.edge.gallery.ui.theme.GalleryTheme\nimport com.google.ai.edge.litertlm.ExperimentalApi\nimport com.google.ai.edge.litertlm.ExperimentalFlags\nimport com.google.firebase.analytics.FirebaseAnalytics\nimport dagger.hilt.android.AndroidEntryPoint\nimport kotlinx.coroutines.delay\nimport kotlinx.coroutines.launch\n\n@AndroidEntryPoint\nclass MainActivity : ComponentActivity() {\n\n  private val modelManagerViewModel: ModelManagerViewModel by viewModels()\n  private var splashScreenAboutToExit: Boolean = false\n  private var contentSet: Boolean = false\n\n  override fun onCreate(savedInstanceState: Bundle?) {\n    super.onCreate(savedInstanceState)\n\n    fun setContent() {\n      if (contentSet) {\n        return\n      }\n\n      setContent {\n        GalleryTheme {\n          Surface(modifier = Modifier.fillMaxSize()) {\n            GalleryApp(modelManagerViewModel = modelManagerViewModel)\n\n            // Fade out a \"mask\" that has the same color as the background of the splash screen\n            // to reveal the actual app content.\n            var startMaskFadeout by remember { mutableStateOf(false) }\n            LaunchedEffect(Unit) { startMaskFadeout = true }\n            AnimatedVisibility(\n              !startMaskFadeout,\n              enter = fadeIn(animationSpec = snap(0)),\n              exit =\n                fadeOut(animationSpec = tween(durationMillis = 400, easing = FastOutSlowInEasing)),\n            ) {\n              Box(\n                modifier = Modifier.fillMaxSize().background(MaterialTheme.colorScheme.background)\n              )\n            }\n          }\n        }\n      }\n\n      @OptIn(ExperimentalApi::class)\n      ExperimentalFlags.enableBenchmark = false\n\n      contentSet = true\n    }\n\n    modelManagerViewModel.loadModelAllowlist()\n\n    // Show splash screen.\n    val splashScreen = installSplashScreen()\n\n    // Set the content when the system-provided splash screen is not shown.\n    //\n    // This is necessary on some Android versions where the splash screen is optimized away (e.g.,\n    // after a force-quit) to ensure the main content is displayed immediately and correctly.\n    lifecycleScope.launch {\n      delay(1000)\n      if (!splashScreenAboutToExit) {\n        setContent()\n      }\n    }\n\n    // Cross-fade transition from the splash screen to the main content.\n    //\n    // The logic performs the following key actions:\n    // 1. Synchronizes Timing: It calculates the remaining duration of the default icon\n    //    animation. It then delays its own animations to ensure the custom fade-out begins just\n    //    before the original icon animation would have finished.\n    // 2. Initiates a cross-fade:\n    //    - Fade out the splash screen.\n    //    - Fade in the main content.\n    // 3. Cleans up: An `onEnd` listener on the fade-out animator calls\n    //    `splashScreenView.remove()` to properly remove the splash screen from the view hierarchy\n    //    once it's fully transparent.\n    splashScreen.setOnExitAnimationListener { splashScreenView ->\n      splashScreenAboutToExit = true\n\n      val now = System.currentTimeMillis()\n      val iconAnimationStartMs = splashScreenView.iconAnimationStartMillis\n      val duration = splashScreenView.iconAnimationDurationMillis\n      val fadeOut = ObjectAnimator.ofFloat(splashScreenView.view, View.ALPHA, 1f, 0f)\n      fadeOut.interpolator = DecelerateInterpolator()\n      fadeOut.duration = 300L\n      fadeOut.doOnEnd { splashScreenView.remove() }\n      lifecycleScope.launch {\n        val setContentDelay = duration - (now - iconAnimationStartMs) - 300\n        if (setContentDelay > 0) {\n          delay(setContentDelay)\n        }\n        setContent()\n        fadeOut.start()\n      }\n    }\n\n    enableEdgeToEdge()\n    if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {\n      // Fix for three-button nav not properly going edge-to-edge.\n      // See: https://issuetracker.google.com/issues/298296168\n      window.isNavigationBarContrastEnforced = false\n    }\n    // Keep the screen on while the app is running for better demo experience.\n    window.addFlags(WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON)\n  }\n\n  override fun onResume() {\n    super.onResume()\n\n    firebaseAnalytics?.logEvent(\n      FirebaseAnalytics.Event.APP_OPEN,\n      bundleOf(\n        \"app_version\" to BuildConfig.VERSION_NAME,\n        \"os_version\" to Build.VERSION.SDK_INT.toString(),\n        \"device_model\" to Build.MODEL,\n      ),\n    )\n  }\n\n  companion object {\n    private const val TAG = \"AGMainActivity\"\n  }\n}\n"
  },
  {
    "path": "Android/src/app/src/main/java/com/google/ai/edge/gallery/SettingsSerializer.kt",
    "content": "/*\n * Copyright 2025 Google LLC\n *\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n *     http://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\n\npackage com.google.ai.edge.gallery\n\nimport androidx.datastore.core.CorruptionException\nimport androidx.datastore.core.Serializer\nimport com.google.ai.edge.gallery.proto.Settings\nimport com.google.protobuf.InvalidProtocolBufferException\nimport java.io.InputStream\nimport java.io.OutputStream\n\nobject SettingsSerializer : Serializer<Settings> {\n  override val defaultValue: Settings = Settings.getDefaultInstance()\n\n  override suspend fun readFrom(input: InputStream): Settings {\n    try {\n      return Settings.parseFrom(input)\n    } catch (exception: InvalidProtocolBufferException) {\n      throw CorruptionException(\"Cannot read proto.\", exception)\n    }\n  }\n\n  override suspend fun writeTo(t: Settings, output: OutputStream) = t.writeTo(output)\n}\n"
  },
  {
    "path": "Android/src/app/src/main/java/com/google/ai/edge/gallery/UserDataSerializer.kt",
    "content": "/*\n * Copyright 2025 Google LLC\n *\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n *     http://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\n\npackage com.google.ai.edge.gallery\n\nimport androidx.datastore.core.CorruptionException\nimport androidx.datastore.core.Serializer\nimport com.google.ai.edge.gallery.proto.UserData\nimport com.google.protobuf.InvalidProtocolBufferException\nimport java.io.InputStream\nimport java.io.OutputStream\n\nobject UserDataSerializer : Serializer<UserData> {\n  override val defaultValue: UserData = UserData.getDefaultInstance()\n\n  override suspend fun readFrom(input: InputStream): UserData {\n    try {\n      return UserData.parseFrom(input)\n    } catch (exception: InvalidProtocolBufferException) {\n      throw CorruptionException(\"Cannot read proto.\", exception)\n    }\n  }\n\n  override suspend fun writeTo(t: UserData, output: OutputStream) = t.writeTo(output)\n}\n"
  },
  {
    "path": "Android/src/app/src/main/java/com/google/ai/edge/gallery/common/ProjectConfig.kt",
    "content": "/*\n * Copyright 2025 Google LLC\n *\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n *     http://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\n\npackage com.google.ai.edge.gallery.common\n\nimport androidx.core.net.toUri\nimport net.openid.appauth.AuthorizationServiceConfiguration\n\nobject ProjectConfig {\n  // Hugging Face Client ID.\n  //\n  const val clientId = \"REPLACE_WITH_YOUR_CLIENT_ID_IN_HUGGINGFACE_APP\"\n\n  // Registered redirect URI.\n  //\n  // The scheme needs to match the\n  // \"android.defaultConfig.manifestPlaceholders[\"appAuthRedirectScheme\"]\" field in\n  // \"build.gradle.kts\".\n  const val redirectUri = \"REPLACE_WITH_YOUR_REDIRECT_URI_IN_HUGGINGFACE_APP\"\n\n  // OAuth 2.0 Endpoints (Authorization + Token Exchange)\n  private const val authEndpoint = \"https://huggingface.co/oauth/authorize\"\n  private const val tokenEndpoint = \"https://huggingface.co/oauth/token\"\n\n  // OAuth service configuration (AppAuth library requires this)\n  val authServiceConfig =\n    AuthorizationServiceConfiguration(\n      authEndpoint.toUri(), // Authorization endpoint\n      tokenEndpoint.toUri(), // Token exchange endpoint\n    )\n}\n"
  },
  {
    "path": "Android/src/app/src/main/java/com/google/ai/edge/gallery/common/Types.kt",
    "content": "/*\n * Copyright 2025 Google LLC\n *\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n *     http://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\n\npackage com.google.ai.edge.gallery.common\n\nimport androidx.compose.ui.graphics.Color\nimport com.squareup.moshi.JsonClass\nimport kotlinx.coroutines.CompletableDeferred\n\ninterface LatencyProvider {\n  val latencyMs: Float\n}\n\ndata class Classification(val label: String, val score: Float, val color: Color)\n\ndata class JsonObjAndTextContent<T>(val jsonObj: T, val textContent: String)\n\nclass AudioClip(val audioData: ByteArray, val sampleRate: Int)\n"
  },
  {
    "path": "Android/src/app/src/main/java/com/google/ai/edge/gallery/common/Utils.kt",
    "content": "/*\n * Copyright 2025 Google LLC\n *\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n *     http://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\n\npackage com.google.ai.edge.gallery.common\n\nimport android.content.Context\nimport android.graphics.Bitmap\nimport android.graphics.BitmapFactory\nimport android.graphics.Matrix\nimport android.net.Uri\nimport android.os.Build\nimport android.util.Log\nimport androidx.exifinterface.media.ExifInterface\nimport com.google.ai.edge.gallery.data.SAMPLE_RATE\nimport com.google.gson.Gson\nimport java.io.File\nimport java.io.FileInputStream\nimport java.net.HttpURLConnection\nimport java.net.URL\nimport java.nio.ByteBuffer\nimport java.nio.ByteOrder\nimport java.nio.channels.FileChannel\nimport kotlin.math.abs\nimport kotlin.math.floor\nimport kotlin.math.max\nimport kotlin.math.roundToInt\n\nprivate const val TAG = \"AGUtils\"\n\nconst val LOCAL_URL_BASE = \"https://appassets.androidplatform.net\"\n\nfun cleanUpMediapipeTaskErrorMessage(message: String): String {\n  val index = message.indexOf(\"=== Source Location Trace\")\n  if (index >= 0) {\n    return message.substring(0, index)\n  }\n  return message\n}\n\nfun processLlmResponse(response: String): String {\n  return response.replace(\"\\\\n\", \"\\n\")\n}\n\ninline fun <reified T> getJsonResponse(url: String): JsonObjAndTextContent<T>? {\n  try {\n    val connection = URL(url).openConnection() as HttpURLConnection\n    connection.requestMethod = \"GET\"\n    connection.connect()\n\n    val responseCode = connection.responseCode\n    if (responseCode == HttpURLConnection.HTTP_OK) {\n      val inputStream = connection.inputStream\n      val response = inputStream.bufferedReader().use { it.readText() }\n\n      val jsonObj = parseJson<T>(response)\n      return if (jsonObj != null) {\n        JsonObjAndTextContent(jsonObj = jsonObj, textContent = response)\n      } else {\n        null\n      }\n    } else {\n      Log.e(\"AGUtils\", \"HTTP error: $responseCode\")\n    }\n  } catch (e: Exception) {\n    Log.e(\"AGUtils\", \"Error when getting or parsing json response\", e)\n  }\n\n  return null\n}\n\n/** Parses a JSON string into an object of type [T] using Gson. */\ninline fun <reified T> parseJson(response: String): T? {\n  return try {\n    val gson = Gson()\n    gson.fromJson(response, T::class.java)\n  } catch (e: Exception) {\n    Log.e(\"AGUtils\", \"Error parsing JSON string\", e)\n    null\n  }\n}\n\nfun convertWavToMonoWithMaxSeconds(\n  context: Context,\n  stereoUri: Uri,\n  maxSeconds: Int = 30,\n): AudioClip? {\n  Log.d(TAG, \"Start to convert wav file to mono channel\")\n\n  try {\n    val inputStream =\n      (if (stereoUri.scheme == null || stereoUri.scheme == \"file\") {\n        FileInputStream(stereoUri.path ?: \"\")\n      } else {\n        context.contentResolver.openInputStream(stereoUri)\n      }) ?: return null\n    val originalBytes = inputStream.readBytes()\n    inputStream.close()\n\n    // Read WAV header\n    if (originalBytes.size < 44) {\n      // Not a valid WAV file\n      Log.e(TAG, \"Not a valid wav file\")\n      return null\n    }\n\n    val headerBuffer = ByteBuffer.wrap(originalBytes, 0, 44).order(ByteOrder.LITTLE_ENDIAN)\n    val channels = headerBuffer.getShort(22)\n    var sampleRate = headerBuffer.getInt(24)\n    val bitDepth = headerBuffer.getShort(34)\n    Log.d(TAG, \"File metadata: channels: $channels, sampleRate: $sampleRate, bitDepth: $bitDepth\")\n\n    // Normalize audio to 16-bit.\n    val audioDataBytes = originalBytes.copyOfRange(fromIndex = 44, toIndex = originalBytes.size)\n    var sixteenBitBytes: ByteArray =\n      if (bitDepth.toInt() == 8) {\n        Log.d(TAG, \"Converting 8-bit audio to 16-bit.\")\n        convert8BitTo16Bit(audioDataBytes)\n      } else {\n        // Assume 16-bit or other format that can be handled directly\n        audioDataBytes\n      }\n\n    // Convert byte array to short array for processing\n    val shortBuffer =\n      ByteBuffer.wrap(sixteenBitBytes).order(ByteOrder.LITTLE_ENDIAN).asShortBuffer()\n    var pcmSamples = ShortArray(shortBuffer.remaining())\n    shortBuffer.get(pcmSamples)\n\n    // Resample if sample rate is less than 16000 Hz ---\n    if (sampleRate < SAMPLE_RATE) {\n      Log.d(TAG, \"Resampling from $sampleRate Hz to $SAMPLE_RATE Hz.\")\n      pcmSamples = resample(pcmSamples, sampleRate, SAMPLE_RATE, channels.toInt())\n      sampleRate = SAMPLE_RATE\n      Log.d(TAG, \"Resampling complete. New sample count: ${pcmSamples.size}\")\n    }\n\n    // Convert stereo to mono if necessary\n    var monoSamples =\n      if (channels.toInt() == 2) {\n        Log.d(TAG, \"Converting stereo to mono.\")\n        val mono = ShortArray(pcmSamples.size / 2)\n        for (i in mono.indices) {\n          val left = pcmSamples[i * 2]\n          val right = pcmSamples[i * 2 + 1]\n          mono[i] = ((left + right) / 2).toShort()\n        }\n        mono\n      } else {\n        Log.d(TAG, \"Audio is already mono. No channel conversion needed.\")\n        pcmSamples\n      }\n\n    // Trim the audio to maxSeconds ---\n    val maxSamples = maxSeconds * sampleRate\n    if (monoSamples.size > maxSamples) {\n      Log.d(TAG, \"Trimming clip from ${monoSamples.size} samples to $maxSamples samples.\")\n      monoSamples = monoSamples.copyOfRange(0, maxSamples)\n    }\n\n    val monoByteBuffer = ByteBuffer.allocate(monoSamples.size * 2).order(ByteOrder.LITTLE_ENDIAN)\n    monoByteBuffer.asShortBuffer().put(monoSamples)\n    return AudioClip(audioData = monoByteBuffer.array(), sampleRate = sampleRate)\n  } catch (e: Exception) {\n    Log.e(TAG, \"Failed to convert wav to mono\", e)\n    return null\n  }\n}\n\n/** Converts 8-bit unsigned PCM audio data to 16-bit signed PCM. */\nprivate fun convert8BitTo16Bit(eightBitData: ByteArray): ByteArray {\n  // The new 16-bit data will be twice the size\n  val sixteenBitData = ByteArray(eightBitData.size * 2)\n  val buffer = ByteBuffer.wrap(sixteenBitData).order(ByteOrder.LITTLE_ENDIAN)\n\n  for (byte in eightBitData) {\n    // Convert the unsigned 8-bit byte (0-255) to a signed 16-bit short (-32768 to 32767)\n    // 1. Get the unsigned value by masking with 0xFF\n    // 2. Subtract 128 to center the waveform around 0 (range becomes -128 to 127)\n    // 3. Scale by 256 to expand to the 16-bit range\n    val unsignedByte = byte.toInt() and 0xFF\n    val sixteenBitSample = ((unsignedByte - 128) * 256).toShort()\n    buffer.putShort(sixteenBitSample)\n  }\n  return sixteenBitData\n}\n\n/** Resamples PCM audio data from an original sample rate to a target sample rate. */\nprivate fun resample(\n  inputSamples: ShortArray,\n  originalSampleRate: Int,\n  targetSampleRate: Int,\n  channels: Int,\n): ShortArray {\n  if (originalSampleRate == targetSampleRate) {\n    return inputSamples\n  }\n\n  val ratio = targetSampleRate.toDouble() / originalSampleRate\n  val outputLength = (inputSamples.size * ratio).toInt()\n  val resampledData = ShortArray(outputLength)\n\n  if (channels == 1) { // Mono\n    for (i in resampledData.indices) {\n      val position = i / ratio\n      val index1 = floor(position).toInt()\n      val index2 = index1 + 1\n      val fraction = position - index1\n\n      val sample1 = if (index1 < inputSamples.size) inputSamples[index1].toDouble() else 0.0\n      val sample2 = if (index2 < inputSamples.size) inputSamples[index2].toDouble() else 0.0\n\n      resampledData[i] = (sample1 * (1 - fraction) + sample2 * fraction).toInt().toShort()\n    }\n  }\n\n  return resampledData\n}\n\nfun calculatePeakAmplitude(buffer: ByteArray, bytesRead: Int): Int {\n  // Wrap the byte array in a ByteBuffer and set the order to little-endian\n  val shortBuffer =\n    ByteBuffer.wrap(buffer, 0, bytesRead).order(ByteOrder.LITTLE_ENDIAN).asShortBuffer()\n\n  var maxAmplitude = 0\n  // Iterate through the short buffer to find the maximum absolute value\n  while (shortBuffer.hasRemaining()) {\n    val currentSample = abs(shortBuffer.get().toInt())\n    if (currentSample > maxAmplitude) {\n      maxAmplitude = currentSample\n    }\n  }\n  return maxAmplitude\n}\n\nfun decodeSampledBitmapFromUri(context: Context, uri: Uri, reqWidth: Int, reqHeight: Int): Bitmap? {\n  // First, decode with inJustDecodeBounds=true to check dimensions\n  val options =\n    BitmapFactory.Options().apply {\n      inJustDecodeBounds = true\n      (if (uri.scheme == null || uri.scheme == \"file\") {\n          FileInputStream(uri.path ?: \"\")\n        } else {\n          context.contentResolver.openInputStream(uri)\n        })\n        ?.use { BitmapFactory.decodeStream(it, null, this) }\n\n      // Calculate inSampleSize\n      inSampleSize = calculateInSampleSize(this, reqWidth, reqHeight)\n\n      // Decode bitmap with inSampleSize set\n      inJustDecodeBounds = false\n    }\n\n  return (if (uri.scheme == null || uri.scheme == \"file\") {\n      FileInputStream(uri.path ?: \"\")\n    } else {\n      context.contentResolver.openInputStream(uri)\n    })\n    ?.use { BitmapFactory.decodeStream(it, null, options) }\n}\n\nfun rotateBitmap(bitmap: Bitmap, orientation: Int): Bitmap {\n  val matrix = Matrix()\n  when (orientation) {\n    ExifInterface.ORIENTATION_ROTATE_90 -> matrix.postRotate(90f)\n    ExifInterface.ORIENTATION_ROTATE_180 -> matrix.postRotate(180f)\n    ExifInterface.ORIENTATION_ROTATE_270 -> matrix.postRotate(270f)\n    ExifInterface.ORIENTATION_FLIP_HORIZONTAL -> matrix.preScale(-1.0f, 1.0f)\n    ExifInterface.ORIENTATION_FLIP_VERTICAL -> matrix.preScale(1.0f, -1.0f)\n    ExifInterface.ORIENTATION_TRANSPOSE -> {\n      matrix.postRotate(90f)\n      matrix.preScale(-1.0f, 1.0f)\n    }\n    ExifInterface.ORIENTATION_TRANSVERSE -> {\n      matrix.postRotate(270f)\n      matrix.preScale(-1.0f, 1.0f)\n    }\n    ExifInterface.ORIENTATION_NORMAL -> return bitmap\n    else -> return bitmap\n  }\n  return Bitmap.createBitmap(bitmap, 0, 0, bitmap.width, bitmap.height, matrix, true)\n}\n\nprivate fun calculateInSampleSize(\n  options: BitmapFactory.Options,\n  reqWidth: Int,\n  reqHeight: Int,\n): Int {\n  // Raw height and width of image\n  val height: Int = options.outHeight\n  val width: Int = options.outWidth\n  var inSampleSize = 1\n\n  if (height > reqHeight || width > reqWidth) {\n    // Calculate the ratio of height and width to the requested height and width\n    val heightRatio = (height.toFloat() / reqHeight.toFloat()).roundToInt()\n    val widthRatio = (width.toFloat() / reqWidth.toFloat()).roundToInt()\n\n    // Choose the largest ratio as inSampleSize value to ensure\n    // that both dimensions are smaller than or equal to the requested dimensions.\n    inSampleSize = max(heightRatio, widthRatio)\n  }\n\n  return inSampleSize\n}\n\nfun readFileToByteBuffer(file: File): ByteBuffer? {\n  return try {\n    val fileInputStream = FileInputStream(file)\n    val fileChannel: FileChannel = fileInputStream.channel\n    val byteBuffer = ByteBuffer.allocateDirect(fileChannel.size().toInt())\n    fileChannel.read(byteBuffer)\n    byteBuffer.rewind()\n    fileInputStream.close()\n    byteBuffer\n  } catch (e: Exception) {\n    e.printStackTrace()\n    null\n  }\n}\n\nfun isPixel10(): Boolean {\n  return Build.MODEL != null && Build.MODEL.lowercase().contains(\"pixel 10\")\n}\n"
  },
  {
    "path": "Android/src/app/src/main/java/com/google/ai/edge/gallery/customtasks/common/CustomTask.kt",
    "content": "/*\n * Copyright 2025 Google LLC\n *\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n *     http://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\n\npackage com.google.ai.edge.gallery.customtasks.common\n\nimport android.content.Context\nimport androidx.compose.runtime.Composable\nimport com.google.ai.edge.gallery.data.Model\nimport com.google.ai.edge.gallery.data.Task\nimport kotlinx.coroutines.CoroutineScope\n\n/**\n * A CustomTask is a user-defined task that can be dynamically added to the app.\n *\n * The user journey for a custom task begins on the home screen, which is organized into categories.\n * These categories correspond to the tabs in the main navigation. Within each category, a list of\n * tasks is displayed. A task represents a specific functionality or use case, and it includes\n * metadata like a label, description, and icon, which are all defined in the `task` property of\n * your `CustomTask` implementation.\n *\n * When a user selects a task on the home screen, they are taken to the task's detail screen. This\n * screen displays the task's description and presents a list of associated models. A single task\n * can have multiple models, allowing the user to choose between different implementations or\n * versions (e.g., different LLM models for a \"Chat\" task). The user can then select and run a\n * specific model for the task.\n *\n * To create your own custom task, follow these steps:\n * 1. Create a class that implements this `CustomTask` interface.\n * 2. Define the metadata for your task in the `task` property, including its label, description,\n *    and associated models.\n * 3. Implement the `initializeModelFn` and `cleanUpModelFn` functions to handle the setup and\n *    teardown logic for your task's models.\n * 4. Implement the `MainScreen` composable to define the UI for your task's model detail screen.\n *    This is where the user will interact with the model within your task. It's important to note\n *    that this UI will be placed inside a pre-configured `Scaffold` that already handles the app\n *    bar, *which includes the model name, a model selector, and a configuration button. Your focus\n *    here should be on building the main content area of the screen.\n * 5. Create a Hilt module and use `@Provides` and `@IntoSet` to bind your custom task\n *    implementation into a set of `CustomTask`s. This makes your task automatically discoverable by\n *    the app's home screen.\n *\n * For a concrete example of how to implement these steps, see the\n * [com.google.ai.edge.gallery.customtasks.examplecustomtask.ExampleCustomTask] class. This example\n * implements a \"Model Viewer\" task that displays the text content of a model file for demonstration\n * purpose. See comments there for more details.\n *\n */\ninterface CustomTask {\n  /**\n   * The metadata for your task and the models within the task.\n   *\n   * See comments of [Task] for details.\n   */\n  val task: Task\n\n  /**\n   * Called to initialize and prepare a model for use.\n   *\n   * This function will be called from a coroutine with Dispatchers.Default dispatcher.\n   *\n   * @param context The application context.\n   * @param coroutineScope The coroutine scope for asynchronous operations.\n   * @param model The `Model` object containing information about the model to be initialized.\n   * @param onDone A callback function to be invoked when initialization is complete. Pass an empty\n   *   string on success, or an error message on failure.\n   */\n  fun initializeModelFn(\n    context: Context,\n    coroutineScope: CoroutineScope,\n    model: Model,\n    onDone: (error: String) -> Unit,\n  )\n\n  /**\n   * Called to clean up resources associated with a model.\n   *\n   * @param context The application context.\n   * @param coroutineScope The coroutine scope for asynchronous operations.\n   * @param model The `Model` object to be cleaned up.\n   * @param onDone A callback function to be invoked when cleanup is complete.\n   */\n  fun cleanUpModelFn(\n    context: Context,\n    coroutineScope: CoroutineScope,\n    model: Model,\n    onDone: () -> Unit,\n  )\n\n  /**\n   * The main Composable UI for your custom task's detail screen.\n   *\n   * @param data The data sent from the app. It will typically be a [CustomTaskData].\n   */\n  @Composable fun MainScreen(data: Any)\n}\n"
  },
  {
    "path": "Android/src/app/src/main/java/com/google/ai/edge/gallery/customtasks/common/CustomTaskData.kt",
    "content": "/*\n * Copyright 2025 Google LLC\n *\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n *     http://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\n\npackage com.google.ai.edge.gallery.customtasks.common\n\nimport androidx.compose.ui.unit.Dp\nimport androidx.compose.ui.unit.dp\nimport com.google.ai.edge.gallery.ui.modelmanager.ModelManagerViewModel\n\n/**\n * Data class to hold information passed to the `MainScreen` composable of a custom task.\n *\n * @param modelManagerViewModel The ViewModel providing access to the state of models and their\n *   management.\n * @param bottomPadding The bottom padding of the Scaffold's `innerPadding`. By default, your\n *   `MainScreen` will extend to the bottom edge. Use this value if you need to apply padding to the\n *   bottom of your screen's content to account for elements like a bottom navigation bar.\n * @param setAppBarControlsDisabled A callback function that the custom task screen can call to\n *   enable and disable controls (e.g. back button, configs, etc) in the app bar.\n * @param setTopBarVisible A callback function that the custom task screen can call to show and hide\n *   the top bar.\n */\ndata class CustomTaskData(\n  val modelManagerViewModel: ModelManagerViewModel,\n  val bottomPadding: Dp = 0.dp,\n  val setAppBarControlsDisabled: (Boolean) -> Unit = {},\n  val setTopBarVisible: (Boolean) -> Unit = {},\n  val setCustomNavigateUpCallback: ((() -> Unit)?) -> Unit = {},\n)\n\ndata class CustomTaskDataForBuiltinTask(\n  val modelManagerViewModel: ModelManagerViewModel,\n  val onNavUp: () -> Unit,\n)\n"
  },
  {
    "path": "Android/src/app/src/main/java/com/google/ai/edge/gallery/customtasks/examplecustomtask/ExampleCustomTask.kt",
    "content": "/*\n * Copyright 2025 Google LLC\n *\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n *     http://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\n\npackage com.google.ai.edge.gallery.customtasks.examplecustomtask\n\nimport android.content.Context\nimport androidx.compose.material.icons.Icons\nimport androidx.compose.material.icons.outlined.TextFields\nimport androidx.compose.runtime.Composable\nimport com.google.ai.edge.gallery.customtasks.common.CustomTask\nimport com.google.ai.edge.gallery.customtasks.common.CustomTaskData\nimport com.google.ai.edge.gallery.data.CategoryInfo\nimport com.google.ai.edge.gallery.data.Model\nimport com.google.ai.edge.gallery.data.Task\nimport com.google.ai.edge.gallery.ui.modelmanager.ModelManagerViewModel\nimport java.io.File\nimport javax.inject.Inject\nimport kotlin.math.min\nimport kotlinx.coroutines.CoroutineScope\nimport kotlinx.coroutines.Dispatchers\nimport kotlinx.coroutines.delay\nimport kotlinx.coroutines.launch\n\n/**\n * An example implementation of a `CustomTask` that demonstrates how to display the content of a\n * text-based model file.\n *\n * This class provides two primary examples of how to configure models:\n * 1. A \"Local model\" that expects a file (`model.txt`) to be manually pushed to the device. The\n *    `localFileRelativeDirPathOverride` field is used to specify this behavior.\n * 2. A \"Remote model\" that downloads a file (`README.md`) from a URL. The `url` and\n *    `downloadFileName` fields are used for this configuration.\n *\n * It showcases the following key functionalities:\n * - Task Definition: The `task` property defines the task's metadata, including its name (\"Model\n *   Viewer\"), category, description, and the list of models it supports.\n * - Model Initialization: The `initializeModelFn` function shows how to read the content of the\n *   model file (either local or downloaded) and store it in a custom\n *   `ExampleCustomTaskModelInstance`. It also demonstrates how to access model-specific\n *   configurations, such as `maxCharCount`, which can be updated by the user.\n * - Model Cleanup: The `cleanUpModelFn` function is a simple example of how to release resources by\n *   nullifying the model instance.\n * - UI Integration: The `MainScreen` composable provides the UI for the task, displaying the\n *   model's content. It uses a `ViewModel` to manage UI state, such as text color, and reacts to\n *   changes in model configurations, like font size.\n */\nclass ExampleCustomTask @Inject constructor() : CustomTask {\n  override val task: Task =\n    Task(\n      id = \"example_custom_task\",\n      label = \"Model Viewer\",\n      category = CategoryInfo(id = \"example\", label = \"Example\"),\n      icon = Icons.Outlined.TextFields,\n      description =\n        \"This example task demonstrates a custom task that reads and displays the content of a \" +\n          \"model file (with text content for demonstration purpose). The \\\"models\\\" listed \" +\n          \"below are configured in different ways in terms of how the model file is provided \" +\n          \"(pushed to device manually, vs downloaded from internet).\",\n      docUrl =\n        \"https://github.com/google-ai-edge/gallery/Android/src/app/src/main/java/com/google/ai/edge/gallery/customtasks/common/CustomTask.kt\",\n      sourceCodeUrl =\n        \"https://github.com/google-ai-edge/gallery/Android/src/app/src/main/java/com/google/ai/edge/gallery/customtasks/examplecustomtask/ExampleCustomTask.kt\",\n      models =\n        mutableListOf(\n          Model(\n            name = \"Local model\",\n            info =\n              \"Expects to read the model file `model.txt` manually pushed to `{ext_files_dir}/example_task/`.\",\n            localFileRelativeDirPathOverride = \"example_task/\",\n            bestForTaskIds = listOf(\"example_custom_task\"),\n            configs = EXAMPLE_CUSTOM_TASK_CONFIGS,\n          ),\n          Model(\n            name = \"Remote model\",\n            info =\n              \"Downloads the model file (a README.md file for demonstration purpose) from internet.\",\n            url =\n              \"https://raw.githubusercontent.com/google-ai-edge/gallery/refs/heads/main/README.md\",\n            sizeInBytes = 3798L,\n            downloadFileName = \"README.md\",\n            configs = EXAMPLE_CUSTOM_TASK_CONFIGS,\n          ),\n        ),\n    )\n\n  override fun initializeModelFn(\n    context: Context,\n    coroutineScope: CoroutineScope,\n    model: Model,\n    onDone: (String) -> Unit,\n  ) {\n    coroutineScope.launch(Dispatchers.IO) {\n      model.instance = null\n      try {\n        // Read model file content.\n        val file =\n          // Remote model\n          if (model.localFileRelativeDirPathOverride.isEmpty())\n            File(model.getPath(context = context))\n          // Local model\n          else File(model.getPath(context = context, fileName = \"model.txt\"))\n        var content = file.readText()\n\n        // Use the value from model's configuration to cap the max number of characters for the\n        // content.\n        val maxCharCount =\n          model.getIntConfigValue(key = EXAMPLE_CUSTOM_TASK_CONFIG_KEY_MAX_CHAR_COUNT)\n        content = content.substring(0, min(content.length, maxCharCount))\n\n        // Set model instance.\n        //\n        // For this example, we're just storing the text content as a data class instance.\n        // In a real application, this instance would be an object that provides\n        // inference capabilities, such as a TFLite interpreter or a pointer to a model\n        // loaded in memory.\n        model.instance = ExampleCustomTaskModelInstance(content = content)\n\n        // Simulate long initialization time.\n        delay(1500)\n\n        // Notify the initialization is done.\n        onDone(\"\")\n      } catch (e: Exception) {\n        // Handle errors.\n        onDone(e.message ?: \"Failed to read model file\")\n      }\n    }\n  }\n\n  override fun cleanUpModelFn(\n    context: Context,\n    coroutineScope: CoroutineScope,\n    model: Model,\n    onDone: () -> Unit,\n  ) {\n    // In a real application, this is where you would release resources\n    // associated with the model, such as closing a TFLite interpreter\n    // or freeing up model memory. For this example, we simply set the\n    // instance to null.\n    model.instance = null\n\n    // Notify the cleanup is done.\n    onDone()\n  }\n\n  @Composable\n  override fun MainScreen(data: Any) {\n    // The ModelManagerViewModel is essential for accessing the state of the currently\n    // selected model, its initialization status, etc.\n    // This allows the UI to react to changes, such as displaying the model's content\n    // only after it has been successfully initialized.\n    val myData = data as CustomTaskData\n    val modelManagerViewModel: ModelManagerViewModel = myData.modelManagerViewModel\n\n    ExampleCustomTaskScreen(modelManagerViewModel = modelManagerViewModel)\n  }\n}\n"
  },
  {
    "path": "Android/src/app/src/main/java/com/google/ai/edge/gallery/customtasks/examplecustomtask/ExampleCustomTaskModule.kt",
    "content": "/*\n * Copyright 2025 Google LLC\n *\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n *     http://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\n\npackage com.google.ai.edge.gallery.customtasks.examplecustomtask\n\nimport com.google.ai.edge.gallery.customtasks.common.CustomTask\nimport dagger.Module\nimport dagger.Provides\nimport dagger.hilt.InstallIn\nimport dagger.hilt.components.SingletonComponent\nimport dagger.multibindings.IntoSet\n\n/**\n * A Hilt module that provides the `ExampleCustomTask` implementation.\n *\n * This module is crucial for integrating your custom task into the application's plugin system. By\n * using `@Provides` and `@IntoSet`, you are telling Hilt to add an instance of `ExampleCustomTask`\n * to a `Set<CustomTask>`, which the main app will use to discover all available custom tasks\n * without needing to know about each one individually.\n */\n@Module\n@InstallIn(SingletonComponent::class) // Or another component that fits your scope\ninternal object ExampleCustomTaskModule {\n  /* Remove comment to enable the function to see this example custom task in action in the app.\n  @Provides\n  @IntoSet\n  fun provideExampleCustomTask(): CustomTask {\n    return ExampleCustomTask()\n  }\n  */\n}\n"
  },
  {
    "path": "Android/src/app/src/main/java/com/google/ai/edge/gallery/customtasks/examplecustomtask/ExampleCustomTaskScreen.kt",
    "content": "/*\n * Copyright 2025 Google LLC\n *\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n *     http://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\n\npackage com.google.ai.edge.gallery.customtasks.examplecustomtask\n\nimport androidx.hilt.navigation.compose.hiltViewModel\n\nimport androidx.compose.foundation.background\nimport androidx.compose.foundation.clickable\nimport androidx.compose.foundation.layout.Arrangement\nimport androidx.compose.foundation.layout.Box\nimport androidx.compose.foundation.layout.Column\nimport androidx.compose.foundation.layout.Row\nimport androidx.compose.foundation.layout.fillMaxSize\nimport androidx.compose.foundation.layout.padding\nimport androidx.compose.foundation.layout.size\nimport androidx.compose.foundation.rememberScrollState\nimport androidx.compose.foundation.shape.CircleShape\nimport androidx.compose.foundation.verticalScroll\nimport androidx.compose.material.icons.Icons\nimport androidx.compose.material.icons.outlined.Check\nimport androidx.compose.material3.CircularProgressIndicator\nimport androidx.compose.material3.HorizontalDivider\nimport androidx.compose.material3.Icon\nimport androidx.compose.material3.MaterialTheme\nimport androidx.compose.material3.Text\nimport androidx.compose.runtime.Composable\nimport androidx.compose.runtime.LaunchedEffect\nimport androidx.compose.runtime.collectAsState\nimport androidx.compose.runtime.getValue\nimport androidx.compose.runtime.mutableIntStateOf\nimport androidx.compose.runtime.remember\nimport androidx.compose.runtime.setValue\nimport androidx.compose.ui.Alignment\nimport androidx.compose.ui.Modifier\nimport androidx.compose.ui.draw.clip\nimport androidx.compose.ui.graphics.Color\nimport androidx.compose.ui.unit.dp\nimport androidx.compose.ui.unit.sp\nimport com.google.ai.edge.gallery.data.ConfigKey\nimport com.google.ai.edge.gallery.data.NumberSliderConfig\nimport com.google.ai.edge.gallery.data.ValueType\nimport com.google.ai.edge.gallery.ui.modelmanager.ModelManagerViewModel\n\ndata class ExampleCustomTaskModelInstance(val content: String)\n\n/**\n * Configuration keys for the `ExampleCustomTask`.\n *\n * These keys are used to uniquely identify and retrieve values for configurable parameters within a\n * model.\n */\nval EXAMPLE_CUSTOM_TASK_CONFIG_KEY_FONT_SIZE = ConfigKey(id = \"font_size\", label = \"Font size\")\nval EXAMPLE_CUSTOM_TASK_CONFIG_KEY_MAX_CHAR_COUNT =\n  ConfigKey(id = \"max_char_count\", label = \"Max character count\")\n\n/**\n * A list of configurable parameters for the `ExampleCustomTask`'s models.\n *\n * This list defines two user-adjustable settings that appear in the model configuration dialog:\n * 1. Font size: A `NumberSliderConfig` that allows the user to change the text font size.\n *    `needReinitialization = false` indicates that changing this value **does not** require the\n *    model to be reloaded, as it's a simple UI change.\n * 2. Max character count: A `NumberSliderConfig` to cap the amount of text displayed.\n *    `needReinitialization = true` indicates that changing this value **does** require the\n *    `initializeModelFn` to be called again to re-read and truncate the model file content.\n */\nval EXAMPLE_CUSTOM_TASK_CONFIGS =\n  listOf(\n    NumberSliderConfig(\n      key = EXAMPLE_CUSTOM_TASK_CONFIG_KEY_FONT_SIZE,\n      sliderMin = 8f,\n      sliderMax = 24f,\n      defaultValue = 14f,\n      valueType = ValueType.INT,\n      needReinitialization = false,\n    ),\n    NumberSliderConfig(\n      key = EXAMPLE_CUSTOM_TASK_CONFIG_KEY_MAX_CHAR_COUNT,\n      sliderMin = 100f,\n      sliderMax = 2000f,\n      defaultValue = 2000f,\n      valueType = ValueType.INT,\n      needReinitialization = true,\n    ),\n  )\n\n/** The main screen of the example custom task. */\n@Composable\nfun ExampleCustomTaskScreen(\n  modelManagerViewModel: ModelManagerViewModel,\n  viewModel: ExampleCustomTaskViewModel = hiltViewModel(),\n) {\n  val colors = listOf(MaterialTheme.colorScheme.onSurface, Color.Red, Color.Green, Color.Blue)\n  val modelManagerUiState by modelManagerViewModel.uiState.collectAsState()\n  val model = modelManagerUiState.selectedModel\n  val uiState by viewModel.uiState.collectAsState()\n  val textColor = uiState.textColor\n\n  // Get the current font size value from config.\n  //\n  // `modelManagerUiState.configValuesUpdateTrigger` will be updated and trigger a recomposition\n  // when a config value is updated. Use it as the key here to read the font size from the config\n  // whenever it is changed.\n  var fontSize by\n    remember(modelManagerUiState.configValuesUpdateTrigger) {\n      mutableIntStateOf(model.getIntConfigValue(EXAMPLE_CUSTOM_TASK_CONFIG_KEY_FONT_SIZE))\n    }\n\n  // Set initial text color.\n  LaunchedEffect(Unit) { viewModel.updateTextColor(color = colors[0]) }\n\n  if (modelManagerUiState.isModelInitialized(model = model)) {\n    val instance = model.instance as ExampleCustomTaskModelInstance\n    Column {\n      // A list of colors user can click to set the text color.\n      Row(\n        horizontalArrangement = Arrangement.spacedBy(8.dp),\n        verticalAlignment = Alignment.CenterVertically,\n        modifier = Modifier.padding(16.dp),\n      ) {\n        Text(\"Text color: \")\n        for (color in colors) {\n          Box(\n            modifier =\n              Modifier.size(16.dp).clip(CircleShape).background(color = color).clickable {\n                viewModel.updateTextColor(color = color)\n              },\n            contentAlignment = Alignment.Center,\n          ) {\n            if (color == textColor) {\n              Icon(\n                Icons.Outlined.Check,\n                tint = MaterialTheme.colorScheme.onPrimary,\n                contentDescription = null,\n                modifier = Modifier.size(12.dp),\n              )\n            }\n          }\n        }\n      }\n\n      HorizontalDivider()\n\n      // Content.\n      Column(modifier = Modifier.weight(1f).verticalScroll(rememberScrollState())) {\n        Text(\n          instance.content,\n          color = textColor,\n          modifier = Modifier.padding(16.dp),\n          style =\n            MaterialTheme.typography.bodyMedium.copy(\n              fontSize = fontSize.sp,\n              lineHeight = (fontSize * 1.3).sp,\n            ),\n        )\n      }\n    }\n  }\n  // Loading spinner.\n  else {\n    Box(contentAlignment = Alignment.Center, modifier = Modifier.fillMaxSize()) {\n      CircularProgressIndicator(\n        modifier = Modifier.size(24.dp),\n        trackColor = MaterialTheme.colorScheme.surfaceVariant,\n        strokeWidth = 3.dp,\n      )\n    }\n  }\n}\n"
  },
  {
    "path": "Android/src/app/src/main/java/com/google/ai/edge/gallery/customtasks/examplecustomtask/ExampleCustomTaskViewModel.kt",
    "content": "/*\n * Copyright 2025 Google LLC\n *\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n *     http://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\n\npackage com.google.ai.edge.gallery.customtasks.examplecustomtask\n\nimport androidx.compose.ui.graphics.Color\nimport androidx.lifecycle.ViewModel\nimport dagger.hilt.android.lifecycle.HiltViewModel\nimport javax.inject.Inject\nimport kotlinx.coroutines.flow.MutableStateFlow\nimport kotlinx.coroutines.flow.asStateFlow\nimport kotlinx.coroutines.flow.update\n\n/**\n * The UI state of the example custom task screen.\n *\n * It tracks the current text color.\n */\ndata class ExampleCustomTaskUiState(val textColor: Color)\n\n/** The ViewModel of the example custom task screen. */\n@HiltViewModel\nclass ExampleCustomTaskViewModel @Inject constructor() : ViewModel() {\n  protected val _uiState = MutableStateFlow(ExampleCustomTaskUiState(textColor = Color.Black))\n  val uiState = _uiState.asStateFlow()\n\n  fun updateTextColor(color: Color) {\n    val newUiState = uiState.value.copy(textColor = color)\n    _uiState.update { newUiState }\n  }\n}\n"
  },
  {
    "path": "Android/src/app/src/main/java/com/google/ai/edge/gallery/customtasks/mobileactions/Actions.kt",
    "content": "/*\n * Copyright 2025 Google LLC\n *\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n *     http://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\npackage com.google.ai.edge.gallery.customtasks.mobileactions\n\nimport androidx.compose.material.icons.Icons\nimport androidx.compose.material.icons.outlined.CalendarMonth\nimport androidx.compose.material.icons.outlined.Email\nimport androidx.compose.material.icons.outlined.FlashOff\nimport androidx.compose.material.icons.outlined.FlashlightOn\nimport androidx.compose.material.icons.outlined.Map\nimport androidx.compose.material.icons.outlined.PersonAdd\nimport androidx.compose.material.icons.outlined.Wifi\nimport androidx.compose.ui.graphics.vector.ImageVector\n\n// Supported action types.\nenum class ActionType {\n  ACTION_FLASHLIGHT_ON,\n  ACTION_FLASHLIGHT_OFF,\n  ACTION_CREATE_CONTACT,\n  ACTION_SEND_EMAIL,\n  ACTION_SHOW_LOCATION_ON_MAP,\n  ACTION_OPEN_WIFI_SETTINGS,\n  ACTION_CREATE_CALENDAR_EVENT,\n}\n\ndata class FunctionCallDetails(\n  val functionName: String,\n  val parameters: List<Pair<String, String>>,\n  val ts: Long = System.currentTimeMillis(),\n)\n\n// Base action class.\nabstract class Action(\n  // The type of the action.\n  val type: ActionType,\n  // The icon to be displayed next to the model response bubble.\n  val icon: ImageVector,\n  // The function call details to be displayed in the model response.\n  val functionCallDetails: FunctionCallDetails,\n)\n\n// Action to turn on flashlight.\nclass FlashlightOnAction() :\n  Action(\n    type = ActionType.ACTION_FLASHLIGHT_ON,\n    icon = Icons.Outlined.FlashlightOn,\n    functionCallDetails =\n      FunctionCallDetails(functionName = \"turnOnFlashlight\", parameters = listOf()),\n  )\n\n// Action to turn off flashlight.\nclass FlashlightOffAction() :\n  Action(\n    type = ActionType.ACTION_FLASHLIGHT_OFF,\n    icon = Icons.Outlined.FlashOff,\n    functionCallDetails =\n      FunctionCallDetails(functionName = \"turnOffFlashlight\", parameters = listOf()),\n  )\n\n// Action to create contact.\nclass CreateContactAction(\n  val firstName: String,\n  val lastName: String,\n  val phoneNumber: String,\n  val email: String,\n) :\n  Action(\n    type = ActionType.ACTION_CREATE_CONTACT,\n    icon = Icons.Outlined.PersonAdd,\n    functionCallDetails =\n      FunctionCallDetails(\n        functionName = \"createContact\",\n        parameters =\n          listOf(\n            Pair(\"firstName\", firstName),\n            Pair(\"lastName\", lastName),\n            Pair(\"phoneNumber\", phoneNumber),\n            Pair(\"email\", email),\n          ),\n      ),\n  )\n\n// Action to send email.\nclass SendEmailAction(val to: String, val subject: String, val body: String) :\n  Action(\n    type = ActionType.ACTION_SEND_EMAIL,\n    icon = Icons.Outlined.Email,\n    functionCallDetails =\n      FunctionCallDetails(\n        functionName = \"sendEmail\",\n        parameters = listOf(Pair(\"to\", to), Pair(\"subject\", subject), Pair(\"body\", body)),\n      ),\n  )\n\n// Action to show a location on map.\nclass ShowLocationOnMap(val location: String) :\n  Action(\n    type = ActionType.ACTION_SHOW_LOCATION_ON_MAP,\n    icon = Icons.Outlined.Map,\n    functionCallDetails =\n      FunctionCallDetails(\n        functionName = \"showLocationOnMap\",\n        parameters = listOf(Pair(\"location\", location)),\n      ),\n  )\n\n// Action to open wifi settings.\nclass OpenWifiSettingsAction() :\n  Action(\n    type = ActionType.ACTION_OPEN_WIFI_SETTINGS,\n    icon = Icons.Outlined.Wifi,\n    functionCallDetails =\n      FunctionCallDetails(functionName = \"openWifiSettings\", parameters = listOf()),\n  )\n\n// Action to create calendar event.\nclass CreateCalendarEventAction(val datetime: String, val title: String) :\n  Action(\n    type = ActionType.ACTION_CREATE_CALENDAR_EVENT,\n    icon = Icons.Outlined.CalendarMonth,\n    functionCallDetails =\n      FunctionCallDetails(\n        functionName = \"createCalendarEvent\",\n        parameters = listOf(Pair(\"datetime\", datetime), Pair(\"title\", title)),\n      ),\n  )\n"
  },
  {
    "path": "Android/src/app/src/main/java/com/google/ai/edge/gallery/customtasks/mobileactions/MobileActionsModule.kt",
    "content": "/*\n * Copyright 2025 Google LLC\n *\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n *     http://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\npackage com.google.ai.edge.gallery.customtasks.mobileactions\n\nimport com.google.ai.edge.gallery.customtasks.common.CustomTask\nimport dagger.Module\nimport dagger.Provides\nimport dagger.hilt.InstallIn\nimport dagger.hilt.components.SingletonComponent\nimport dagger.multibindings.IntoSet\n\n@Module\n@InstallIn(SingletonComponent::class)\ninternal object MobileActionsModule {\n  @Provides\n  @IntoSet\n  fun provideTask(): CustomTask {\n    return MobileActionsTask()\n  }\n}\n"
  },
  {
    "path": "Android/src/app/src/main/java/com/google/ai/edge/gallery/customtasks/mobileactions/MobileActionsScreen.kt",
    "content": "/*\n * Copyright 2025 Google LLC\n *\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n *     http://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\npackage com.google.ai.edge.gallery.customtasks.mobileactions\n\nimport android.Manifest\nimport android.content.pm.PackageManager\nimport android.content.res.Resources\nimport android.os.Bundle\nimport android.util.Log\nimport androidx.activity.compose.rememberLauncherForActivityResult\nimport androidx.activity.result.contract.ActivityResultContracts\nimport androidx.annotation.StringRes\nimport androidx.compose.animation.AnimatedContent\nimport androidx.compose.animation.AnimatedVisibility\nimport androidx.compose.animation.core.FastOutSlowInEasing\nimport androidx.compose.animation.core.tween\nimport androidx.compose.animation.fadeIn\nimport androidx.compose.animation.fadeOut\nimport androidx.compose.animation.slideInHorizontally\nimport androidx.compose.animation.slideOutHorizontally\nimport androidx.compose.animation.togetherWith\nimport androidx.compose.foundation.background\nimport androidx.compose.foundation.border\nimport androidx.compose.foundation.clickable\nimport androidx.compose.foundation.horizontalScroll\nimport androidx.compose.foundation.layout.Arrangement\nimport androidx.compose.foundation.layout.Box\nimport androidx.compose.foundation.layout.Column\nimport androidx.compose.foundation.layout.Row\nimport androidx.compose.foundation.layout.Spacer\nimport androidx.compose.foundation.layout.WindowInsets\nimport androidx.compose.foundation.layout.fillMaxSize\nimport androidx.compose.foundation.layout.fillMaxWidth\nimport androidx.compose.foundation.layout.ime\nimport androidx.compose.foundation.layout.imePadding\nimport androidx.compose.foundation.layout.padding\nimport androidx.compose.foundation.layout.size\nimport androidx.compose.foundation.layout.width\nimport androidx.compose.foundation.rememberScrollState\nimport androidx.compose.foundation.shape.RoundedCornerShape\nimport androidx.compose.foundation.text.BasicText\nimport androidx.compose.foundation.text.TextAutoSize\nimport androidx.compose.foundation.verticalScroll\nimport androidx.compose.material.icons.Icons\nimport androidx.compose.material.icons.automirrored.rounded.Article\nimport androidx.compose.material.icons.outlined.CalendarMonth\nimport androidx.compose.material.icons.outlined.Email\nimport androidx.compose.material.icons.outlined.FlashlightOn\nimport androidx.compose.material.icons.outlined.Map\nimport androidx.compose.material.icons.outlined.PersonAdd\nimport androidx.compose.material.icons.outlined.Wifi\nimport androidx.compose.material.icons.rounded.Functions\nimport androidx.compose.material3.AlertDialog\nimport androidx.compose.material3.Button\nimport androidx.compose.material3.ButtonDefaults\nimport androidx.compose.material3.CircularProgressIndicator\nimport androidx.compose.material3.ExperimentalMaterial3Api\nimport androidx.compose.material3.HorizontalDivider\nimport androidx.compose.material3.Icon\nimport androidx.compose.material3.MaterialTheme\nimport androidx.compose.material3.PrimaryTabRow\nimport androidx.compose.material3.SnackbarDuration\nimport androidx.compose.material3.SnackbarHost\nimport androidx.compose.material3.SnackbarHostState\nimport androidx.compose.material3.Tab\nimport androidx.compose.material3.TabRowDefaults\nimport androidx.compose.material3.Text\nimport androidx.compose.material3.TextButton\nimport androidx.compose.runtime.Composable\nimport androidx.compose.runtime.DisposableEffect\nimport androidx.compose.runtime.LaunchedEffect\nimport androidx.compose.runtime.collectAsState\nimport androidx.compose.runtime.getValue\nimport androidx.compose.runtime.mutableIntStateOf\nimport androidx.compose.runtime.mutableLongStateOf\nimport androidx.compose.runtime.mutableStateOf\nimport androidx.compose.runtime.remember\nimport androidx.compose.runtime.rememberCoroutineScope\nimport androidx.compose.runtime.setValue\nimport androidx.compose.runtime.snapshots.SnapshotStateList\nimport androidx.compose.ui.Alignment\nimport androidx.compose.ui.Modifier\nimport androidx.compose.ui.draw.alpha\nimport androidx.compose.ui.draw.clip\nimport androidx.compose.ui.graphics.Color\nimport androidx.compose.ui.graphics.graphicsLayer\nimport androidx.compose.ui.graphics.vector.ImageVector\nimport androidx.compose.ui.platform.LocalContext\nimport androidx.compose.ui.platform.LocalDensity\nimport androidx.compose.ui.platform.LocalFocusManager\nimport androidx.compose.ui.platform.LocalResources\nimport androidx.compose.ui.res.stringResource\nimport androidx.compose.ui.semantics.LiveRegionMode\nimport androidx.compose.ui.semantics.contentDescription\nimport androidx.compose.ui.semantics.liveRegion\nimport androidx.compose.ui.semantics.semantics\nimport androidx.compose.ui.text.font.FontWeight\nimport androidx.compose.ui.unit.Dp\nimport androidx.compose.ui.unit.dp\nimport androidx.compose.ui.unit.sp\nimport androidx.core.content.ContextCompat\nimport androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel\nimport com.google.ai.edge.gallery.GalleryEvent\nimport com.google.ai.edge.gallery.R\nimport com.google.ai.edge.gallery.data.ModelDownloadStatusType\nimport com.google.ai.edge.gallery.data.Task\nimport com.google.ai.edge.gallery.firebaseAnalytics\nimport com.google.ai.edge.gallery.ui.common.MarkdownText\nimport com.google.ai.edge.gallery.ui.common.chat.ChatMessageWarning\nimport com.google.ai.edge.gallery.ui.common.chat.MessageBodyLoading\nimport com.google.ai.edge.gallery.ui.common.chat.MessageBodyWarning\nimport com.google.ai.edge.gallery.ui.common.getTaskBgGradientColors\nimport com.google.ai.edge.gallery.ui.common.getTaskIconColor\nimport com.google.ai.edge.gallery.ui.common.textandvoiceinput.HoldToDictateViewModel\nimport com.google.ai.edge.gallery.ui.common.textandvoiceinput.TextAndVoiceInput\nimport com.google.ai.edge.gallery.ui.common.textandvoiceinput.VoiceRecognizerOverlay\nimport com.google.ai.edge.gallery.ui.modelmanager.ModelInitializationStatus\nimport com.google.ai.edge.gallery.ui.modelmanager.ModelInitializationStatusType\nimport com.google.ai.edge.gallery.ui.modelmanager.ModelManagerViewModel\nimport kotlinx.coroutines.Dispatchers\nimport kotlinx.coroutines.launch\n\nprivate const val TAG = \"AGMAScreen\"\n\ndata class PromptTemplate(@StringRes val labelResId: Int, val prompt: String)\n\nprivate val PROMPT_TEMPLATES =\n  listOf(\n    PromptTemplate(\n      labelResId = R.string.prompt_template_label_flash_on,\n      prompt = \"Turn on flashlight\",\n    ),\n    PromptTemplate(\n      labelResId = R.string.prompt_template_label_flash_off,\n      prompt = \"Turn off flashlight\",\n    ),\n    PromptTemplate(\n      labelResId = R.string.prompt_template_label_create_contact,\n      prompt =\n        \"Create contact John Smith with email address js@example.com and phone number 123 456 7890.\",\n    ),\n    PromptTemplate(\n      labelResId = R.string.prompt_template_label_send_email,\n      prompt =\n        \"Send an email to js@example.com with subject \\\"Meeting\\\" and body \\\"Hi John, let's meet at 3pm tomorrow.\\\"\",\n    ),\n    PromptTemplate(\n      labelResId = R.string.prompt_template_label_create_calendar_event,\n      prompt = \"Create a calendar event at 2:30pm tomorrow for \\\"team meeting\\\"\",\n    ),\n    PromptTemplate(\n      labelResId = R.string.prompt_template_label_show_location_on_map,\n      prompt = \"Show Googleplex on map\",\n    ),\n    PromptTemplate(\n      labelResId = R.string.prompt_template_label_open_wifi_settings,\n      prompt = \"Open WIFI settings\",\n    ),\n  )\n\nprivate data class SampleActionItem(@StringRes val labelResId: Int, val icon: ImageVector)\n\nprivate val SAMPLE_ACTION_ITEMS =\n  listOf(\n    SampleActionItem(\n      labelResId = R.string.prompt_template_label_flash_on_off,\n      icon = Icons.Outlined.FlashlightOn,\n    ),\n    SampleActionItem(\n      labelResId = R.string.prompt_template_label_create_contact,\n      icon = Icons.Outlined.PersonAdd,\n    ),\n    SampleActionItem(\n      labelResId = R.string.prompt_template_label_send_email,\n      icon = Icons.Outlined.Email,\n    ),\n    SampleActionItem(\n      labelResId = R.string.prompt_template_label_create_calendar_event,\n      icon = Icons.Outlined.CalendarMonth,\n    ),\n    SampleActionItem(\n      labelResId = R.string.prompt_template_label_show_location_on_map,\n      icon = Icons.Outlined.Map,\n    ),\n    SampleActionItem(\n      labelResId = R.string.prompt_template_label_open_wifi_settings,\n      icon = Icons.Outlined.Wifi,\n    ),\n  )\n\nprivate data class Tab(@StringRes val labelResId: Int, val icon: ImageVector)\n\nprivate val TABS =\n  listOf(\n    Tab(\n      labelResId = R.string.mobile_actions_tab_model_response,\n      icon = Icons.AutoMirrored.Rounded.Article,\n    ),\n    Tab(labelResId = R.string.mobile_actions_tab_function_called, icon = Icons.Rounded.Functions),\n  )\n\n/**\n * A Composable function that displays the MobileActions screen.\n *\n * This screen allows users to interact with an AI model using voice or text input to perform\n * various actions on their device.\n */\n@Composable\nfun MobileActionsScreen(\n  task: Task,\n  modelManagerViewModel: ModelManagerViewModel,\n  mobileActionsViewModel: MobileActionsViewModel = hiltViewModel(),\n  bottomPadding: Dp,\n  setAppBarControlsDisabled: (Boolean) -> Unit,\n  curActions: SnapshotStateList<Action>,\n  tools: List<MobileActionsTools>,\n  onProcessingStarted: () -> Unit,\n) {\n  var recordAudioPermissionGranted by remember { mutableStateOf(false) }\n  val context = LocalContext.current\n\n  // Permission request when recording audio clips.\n  val recordAudioClipsPermissionLauncher =\n    rememberLauncherForActivityResult(ActivityResultContracts.RequestPermission()) {\n      permissionGranted ->\n      if (permissionGranted) {\n        recordAudioPermissionGranted = true\n      }\n    }\n\n  // Ask for audio recording permission.\n  LaunchedEffect(Unit) {\n    // Check permission.\n    when (PackageManager.PERMISSION_GRANTED) {\n      // Already got permission. Call the lambda.\n      ContextCompat.checkSelfPermission(context, Manifest.permission.RECORD_AUDIO) -> {\n        recordAudioPermissionGranted = true\n      }\n\n      // Otherwise, ask for permission\n      else -> {\n        recordAudioClipsPermissionLauncher.launch(Manifest.permission.RECORD_AUDIO)\n      }\n    }\n  }\n\n  if (recordAudioPermissionGranted) {\n    Column(\n      modifier = Modifier.fillMaxSize().background(MaterialTheme.colorScheme.surface).imePadding()\n    ) {\n      MainUi(\n        task = task,\n        modelManagerViewModel = modelManagerViewModel,\n        tools = tools,\n        bottomPadding = bottomPadding,\n        viewModel = mobileActionsViewModel,\n        curActions = curActions,\n        setAppBarControlsDisabled = setAppBarControlsDisabled,\n        onProcessingStarted = onProcessingStarted,\n      )\n    }\n  }\n}\n\n@OptIn(ExperimentalMaterial3Api::class)\n@Composable\nfun MainUi(\n  task: Task,\n  modelManagerViewModel: ModelManagerViewModel,\n  tools: List<MobileActionsTools>,\n  bottomPadding: Dp,\n  viewModel: MobileActionsViewModel,\n  setAppBarControlsDisabled: (Boolean) -> Unit,\n  curActions: SnapshotStateList<Action>,\n  holdToDictateViewModel: HoldToDictateViewModel = hiltViewModel(),\n  onProcessingStarted: () -> Unit,\n) {\n  val modelManagerUiState by modelManagerViewModel.uiState.collectAsState()\n  val model = modelManagerUiState.selectedModel\n  val initialModelConfigValues = remember { model.configValues }\n  val holdToDictateUiState by holdToDictateViewModel.uiState.collectAsState()\n  val uiState by viewModel.uiState.collectAsState()\n  var curAmplitude by remember { mutableIntStateOf(0) }\n  var clearInputTextTrigger by remember { mutableLongStateOf(0L) }\n  var selectedTabIndex by remember { mutableIntStateOf(0) }\n  var doneGeneratingResponse by remember { mutableStateOf(false) }\n  var showErrorDialog by remember { mutableStateOf(false) }\n  var errorDialogContent by remember { mutableStateOf(\"\") }\n  val context = LocalContext.current\n  val scope = rememberCoroutineScope()\n  val snackbarHostState = remember { SnackbarHostState() }\n  val focusManager = LocalFocusManager.current\n  val resources = LocalResources.current\n  val taskColor = getTaskBgGradientColors(task = task)[1]\n\n  val curDownloadStatus = modelManagerUiState.modelDownloadStatus[model.name]?.status\n  setAppBarControlsDisabled(\n    curDownloadStatus == ModelDownloadStatusType.SUCCEEDED &&\n      (!modelManagerUiState.isModelInitialized(model = model) || uiState.processing)\n  )\n\n  // Reset states on config changes.\n  LaunchedEffect(model.configValues) {\n    if (model.configValues != initialModelConfigValues) {\n      Log.d(TAG, \"model config values changed.\")\n      modelManagerViewModel.setInitializationStatus(\n        model = model,\n        status = ModelInitializationStatus(status = ModelInitializationStatusType.NOT_INITIALIZED),\n      )\n      viewModel.reset()\n    }\n  }\n\n  DisposableEffect(Unit) { onDispose { viewModel.cleanUp() } }\n\n  // Show a loading indicator before the model is initialized.\n  if (!modelManagerUiState.isModelInitialized(model = model)) {\n    Row(\n      modifier = Modifier.fillMaxSize(),\n      verticalAlignment = Alignment.CenterVertically,\n      horizontalArrangement = Arrangement.Center,\n    ) {\n      CircularProgressIndicator(\n        trackColor = MaterialTheme.colorScheme.surfaceVariant,\n        strokeWidth = 3.dp,\n        modifier = Modifier.size(24.dp),\n      )\n    }\n  }\n  // Main UI.\n  else {\n    val noFunctionCallSnackbarMessage = stringResource(R.string.snackbar_no_function_call)\n\n    val send: (String) -> Unit = { text ->\n      scope.launch(Dispatchers.Main) {\n        selectedTabIndex = 0\n        clearInputTextTrigger = System.currentTimeMillis()\n        focusManager.clearFocus()\n      }\n\n      onProcessingStarted()\n\n      // Figure out the correct action from user prompt.\n      doneGeneratingResponse = false\n      viewModel.processUserPrompt(\n        model = model,\n        userPrompt = text,\n        tools = tools,\n        onProcessDone = {\n          doneGeneratingResponse = true\n          Log.d(TAG, \"Actions count: ${curActions.size}\")\n\n          // Execute functions.\n          if (curActions.isNotEmpty()) {\n            val errors = mutableListOf<String>()\n            for (action in curActions) {\n              val curError = viewModel.performAction(action = action, context = context)\n              if (curError.isEmpty()) {\n                viewModel.addFunctionCallDetails(\n                  details = genFormattedFunctionCall(action = action, resources = resources)\n                )\n              } else {\n                errors.add(curError)\n              }\n            }\n            if (errors.isNotEmpty()) {\n              scope.launch {\n                snackbarHostState.showSnackbar(\n                  errors.joinToString(separator = \"; \"),\n                  withDismissAction = true,\n                  duration = SnackbarDuration.Long,\n                )\n              }\n            }\n          }\n          // No function recognized.\n          else {\n            viewModel.setNoFunctionRecognized(value = true)\n\n            // Show a snack bar for unrecognized command.\n            scope.launch {\n              snackbarHostState.showSnackbar(\n                noFunctionCallSnackbarMessage,\n                withDismissAction = true,\n                duration = SnackbarDuration.Long,\n              )\n            }\n          }\n        },\n        onError = { error ->\n          doneGeneratingResponse = true\n\n          // Show error dialog for users to reset the engine.\n          errorDialogContent = error\n          showErrorDialog = true\n        },\n      )\n\n      firebaseAnalytics?.logEvent(\n        GalleryEvent.GENERATE_ACTION.id,\n        Bundle().apply {\n          putString(\"capability_name\", task.id)\n          putString(\"model_id\", model.name)\n        },\n      )\n    }\n\n    Box(modifier = Modifier.fillMaxSize()) {\n      Column(\n        modifier =\n          Modifier.fillMaxSize()\n            .padding(\n              bottom =\n                if (WindowInsets.ime.getBottom(LocalDensity.current) == 0) bottomPadding else 8.dp\n            )\n            .imePadding()\n      ) {\n        // Message shown when no prompt has been processed yet.\n        if (uiState.showWelcomeMessage) {\n          Box(modifier = Modifier.fillMaxWidth().weight(1f), contentAlignment = Alignment.Center) {\n            Column(\n              horizontalAlignment = Alignment.CenterHorizontally,\n              modifier = Modifier.fillMaxWidth(),\n            ) {\n              Text(\n                stringResource(R.string.mobile_actions_title),\n                style = MaterialTheme.typography.headlineLarge,\n                color = getTaskIconColor(task = task),\n              )\n              Text(\n                stringResource(R.string.mobile_actions_description),\n                style = MaterialTheme.typography.bodyMedium,\n                color = getTaskIconColor(task = task),\n              )\n              Column {\n                Text(\n                  stringResource(R.string.mobile_actions_supported_actions),\n                  style = MaterialTheme.typography.labelLarge,\n                  modifier =\n                    Modifier.padding(top = 64.dp, bottom = 8.dp).graphicsLayer { alpha = 0.7f },\n                  color = MaterialTheme.colorScheme.onSurfaceVariant,\n                )\n                for (item in SAMPLE_ACTION_ITEMS) {\n                  Row(verticalAlignment = Alignment.CenterVertically) {\n                    Icon(\n                      item.icon,\n                      contentDescription = null,\n                      modifier = Modifier.size(24.dp).padding(end = 8.dp),\n                      tint = MaterialTheme.colorScheme.onSurfaceVariant,\n                    )\n                    Text(\n                      stringResource(item.labelResId),\n                      style = MaterialTheme.typography.labelLarge,\n                      color = MaterialTheme.colorScheme.onSurfaceVariant,\n                    )\n                  }\n                }\n              }\n            }\n          }\n        }\n        // Current user prompt and model response.\n        else {\n          // The current user prompt.\n          Box(\n            modifier =\n              Modifier.fillMaxWidth().background(MaterialTheme.colorScheme.surfaceContainer),\n            contentAlignment = Alignment.CenterStart,\n          ) {\n            Text(\n              uiState.userPrompt,\n              style = MaterialTheme.typography.bodyLarge,\n              color = MaterialTheme.colorScheme.onSurfaceVariant,\n              modifier = Modifier.fillMaxWidth().padding(16.dp),\n            )\n          }\n\n          // Loader when processing.\n          if (uiState.processing) {\n            Box(\n              modifier = Modifier.weight(1f).fillMaxWidth().padding(16.dp),\n              contentAlignment = Alignment.TopStart,\n            ) {\n              MessageBodyLoading()\n            }\n          }\n          // Response.\n          else {\n            // Tab bar.\n            Row(modifier = Modifier.fillMaxWidth()) {\n              PrimaryTabRow(\n                selectedTabIndex = selectedTabIndex,\n                containerColor = Color.Transparent,\n                indicator = {\n                  TabRowDefaults.PrimaryIndicator(\n                    modifier =\n                      Modifier.tabIndicatorOffset(selectedTabIndex, matchContentSize = true),\n                    color = taskColor,\n                    width = Dp.Unspecified,\n                  )\n                },\n              ) {\n                for ((index, tab) in TABS.withIndex()) {\n                  val enabled = index == 0 || (index == 1 && !uiState.noFunctionRecognized)\n                  Tab(\n                    selected = selectedTabIndex == index,\n                    enabled = enabled,\n                    onClick = { selectedTabIndex = index },\n                    modifier = Modifier.graphicsLayer { alpha = if (enabled) 1f else 0.3f },\n                    text = {\n                      Row(\n                        verticalAlignment = Alignment.CenterVertically,\n                        horizontalArrangement = Arrangement.spacedBy(4.dp),\n                      ) {\n                        val titleColor =\n                          if (selectedTabIndex == index) taskColor\n                          else MaterialTheme.colorScheme.onSurfaceVariant\n                        Icon(\n                          tab.icon,\n                          contentDescription = null,\n                          modifier = Modifier.size(16.dp).alpha(0.7f),\n                          tint = titleColor,\n                        )\n                        BasicText(\n                          text = stringResource(tab.labelResId),\n                          maxLines = 1,\n                          color = { titleColor },\n                          style =\n                            MaterialTheme.typography.bodyMedium.copy(\n                              fontWeight = FontWeight.Medium\n                            ),\n                          autoSize =\n                            TextAutoSize.StepBased(\n                              minFontSize = 9.sp,\n                              maxFontSize = 14.sp,\n                              stepSize = 1.sp,\n                            ),\n                        )\n                      }\n                    },\n                  )\n                }\n              }\n            }\n\n            // Content.\n            Column(\n              modifier = Modifier.weight(1f).fillMaxWidth().verticalScroll(rememberScrollState())\n            ) {\n              AnimatedContent(\n                selectedTabIndex,\n                transitionSpec = {\n                  if (targetState > initialState) {\n                    slideInHorizontally { 40 } + fadeIn() togetherWith\n                      slideOutHorizontally { -40 } + fadeOut(animationSpec = tween(50))\n                  } else {\n                    slideInHorizontally { -40 } + fadeIn() togetherWith\n                      slideOutHorizontally { 40 } + fadeOut(animationSpec = tween(50))\n                  }\n                },\n                modifier = Modifier.weight(1f),\n              ) {\n                // Model response.\n                if (selectedTabIndex == 0) {\n                  Column(modifier = Modifier.fillMaxWidth()) {\n                    val cdResponse = stringResource(R.string.cd_model_response_text)\n                    MarkdownText(\n                      text = uiState.modelResponse,\n                      modifier =\n                        Modifier.semantics(mergeDescendants = true) {\n                            contentDescription = cdResponse\n                            // Only announce when message is complete.\n                            if (doneGeneratingResponse) {\n                              liveRegion = LiveRegionMode.Polite\n                            }\n                          }\n                          .padding(16.dp),\n                    )\n\n                    if (uiState.noFunctionRecognized) {\n                      MessageBodyWarning(\n                        ChatMessageWarning(\n                          content = stringResource(R.string.warning_no_function_call)\n                        )\n                      )\n                    }\n                  }\n                }\n                // Function called.\n                else if (selectedTabIndex == 1) {\n                  Column(\n                    modifier = Modifier.fillMaxWidth(),\n                    verticalArrangement = Arrangement.spacedBy(8.dp),\n                  ) {\n                    for ((index, details) in uiState.functionCallDetails.withIndex()) {\n                      MarkdownText(text = details, modifier = Modifier.padding(16.dp))\n\n                      if (index != uiState.functionCallDetails.size - 1) {\n                        HorizontalDivider(modifier = Modifier.padding(horizontal = 16.dp))\n                      }\n                    }\n                  }\n                }\n              }\n            }\n          }\n        }\n\n        Column(\n          modifier = Modifier.fillMaxWidth().padding(top = 8.dp),\n          verticalArrangement = Arrangement.spacedBy(8.dp),\n        ) {\n          // A list of prompt templates.\n          Row(\n            modifier =\n              Modifier.fillMaxWidth().horizontalScroll(rememberScrollState()).graphicsLayer {\n                alpha = if (uiState.processing) 0.5f else 1f\n              },\n            horizontalArrangement = Arrangement.spacedBy(4.dp),\n          ) {\n            Spacer(modifier = Modifier.width(12.dp))\n            for (item in PROMPT_TEMPLATES) {\n              Text(\n                stringResource(item.labelResId),\n                color = MaterialTheme.colorScheme.onSurfaceVariant,\n                style = MaterialTheme.typography.labelLarge,\n                modifier =\n                  Modifier.clip(RoundedCornerShape(12.dp))\n                    .clickable(enabled = !uiState.processing) { send(item.prompt) }\n                    .background(color = MaterialTheme.colorScheme.surfaceContainerLow)\n                    .border(\n                      width = 1.dp,\n                      color = MaterialTheme.colorScheme.outlineVariant,\n                      shape = RoundedCornerShape(12.dp),\n                    )\n                    .padding(all = 12.dp),\n              )\n            }\n            Spacer(modifier = Modifier.width(12.dp))\n          }\n\n          // Text and voice Input.\n          Row(\n            modifier = Modifier.padding(horizontal = 16.dp),\n            verticalAlignment = Alignment.CenterVertically,\n            horizontalArrangement = Arrangement.spacedBy(8.dp),\n          ) {\n            TextAndVoiceInput(\n              task = task,\n              processing = uiState.processing,\n              holdToDictateViewModel = holdToDictateViewModel,\n              onDone = { text -> send(text) },\n              onAmplitudeChanged = { curAmplitude = it },\n              clearTextTrigger = clearInputTextTrigger,\n              modifier = Modifier.fillMaxWidth(),\n            )\n          }\n        }\n      }\n\n      // Show an overlay during speech recognition.\n      AnimatedVisibility(\n        holdToDictateUiState.recognizing,\n        enter = fadeIn(animationSpec = tween(durationMillis = 150, easing = FastOutSlowInEasing)),\n        exit =\n          fadeOut(\n            animationSpec =\n              tween(durationMillis = 100, easing = FastOutSlowInEasing, delayMillis = 300)\n          ),\n      ) {\n        VoiceRecognizerOverlay(\n          task = task,\n          viewModel = holdToDictateViewModel,\n          curAmplitude = curAmplitude,\n          bottomPadding = bottomPadding,\n        )\n      }\n\n      SnackbarHost(\n        hostState = snackbarHostState,\n        modifier = Modifier.padding(bottom = bottomPadding + 100.dp).align(Alignment.BottomCenter),\n      )\n    }\n  }\n\n  if (showErrorDialog) {\n    AlertDialog(\n      title = { Text(stringResource(R.string.error)) },\n      text = { Text(errorDialogContent, style = MaterialTheme.typography.bodyMedium) },\n      onDismissRequest = {\n        showErrorDialog = false\n        errorDialogContent = \"\"\n      },\n      dismissButton = {\n        TextButton(\n          onClick = {\n            showErrorDialog = false\n            errorDialogContent = \"\"\n          }\n        ) {\n          Text(stringResource(R.string.cancel))\n        }\n      },\n      confirmButton = {\n        Button(\n          onClick = {\n            showErrorDialog = false\n            errorDialogContent = \"\"\n\n            viewModel.resetEngine(\n              context = context,\n              model = model,\n              tools = tools,\n              modelManagerViewModel = modelManagerViewModel,\n              onError = {\n                errorDialogContent = it\n                showErrorDialog = true\n              },\n            )\n          },\n          colors = ButtonDefaults.buttonColors(containerColor = taskColor),\n        ) {\n          Text(stringResource(R.string.reset), color = Color.White)\n        }\n      },\n    )\n  }\n}\n\nprivate fun genFormattedFunctionCall(action: Action, resources: Resources): String {\n  val strFunctionName = action.functionCallDetails.functionName\n  val functionNameLabel = resources.getString(R.string.function_name)\n  var content = \"**$functionNameLabel**:\\n- $strFunctionName\"\n  if (action.functionCallDetails.parameters.isNotEmpty()) {\n    val parametersLabel =\n      resources.getQuantityString(R.plurals.parameter, action.functionCallDetails.parameters.size)\n    val strParameters =\n      action.functionCallDetails.parameters.joinToString(\"\\n\") { \"- ${it.first}: \\\"${it.second}\\\"\" }\n    content += \"\\n\\n**$parametersLabel**:\\n$strParameters\"\n  }\n  return content\n}\n"
  },
  {
    "path": "Android/src/app/src/main/java/com/google/ai/edge/gallery/customtasks/mobileactions/MobileActionsTask.kt",
    "content": "/*\n * Copyright 2025 Google LLC\n *\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n *     http://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\npackage com.google.ai.edge.gallery.customtasks.mobileactions\n\nimport android.content.Context\nimport androidx.compose.material.icons.Icons\nimport androidx.compose.material.icons.outlined.Functions\nimport androidx.compose.runtime.Composable\nimport androidx.compose.runtime.mutableStateListOf\nimport com.google.ai.edge.gallery.R\nimport com.google.ai.edge.gallery.customtasks.common.CustomTask\nimport com.google.ai.edge.gallery.customtasks.common.CustomTaskData\nimport com.google.ai.edge.gallery.data.BuiltInTaskId\nimport com.google.ai.edge.gallery.data.Category\nimport com.google.ai.edge.gallery.data.Model\nimport com.google.ai.edge.gallery.data.Task\nimport com.google.ai.edge.gallery.ui.llmchat.LlmChatModelHelper\nimport com.google.ai.edge.litertlm.Content\nimport com.google.ai.edge.litertlm.Contents\nimport java.time.LocalDateTime\nimport java.time.format.DateTimeFormatter\nimport javax.inject.Inject\nimport kotlinx.coroutines.CoroutineScope\n\nprivate const val TAG = \"AGMATask\"\n\n/**\n * A custom task that demonstrates how to use function calling to control various device\n * functionalities.\n */\nclass MobileActionsTask @Inject constructor() : CustomTask {\n  private var curActions = mutableStateListOf<Action>()\n  private val tools = listOf(MobileActionsTools(onFunctionCalled = { curActions.add(it) }))\n\n  override val task =\n    Task(\n      id = BuiltInTaskId.LLM_MOBILE_ACTIONS,\n      label = \"Mobile Actions\",\n      description = \"Perform various device actions through Function Gemma\",\n      docUrl = \"https://github.com/google-ai-edge/LiteRT-LM/blob/main/kotlin/README.md\",\n      sourceCodeUrl =\n        \"https://github.com/google-ai-edge/gallery/blob/main/Android/src/app/src/main/java/com/google/ai/edge/gallery/customtasks/mobileactions\",\n      category = Category.LLM,\n      icon = Icons.Outlined.Functions,\n      agentNameRes = R.string.chat_agent_agent_name,\n      models = mutableListOf(),\n      experimental = true,\n    )\n\n  override fun initializeModelFn(\n    context: Context,\n    coroutineScope: CoroutineScope,\n    model: Model,\n    onDone: (String) -> Unit,\n  ) {\n    curActions.clear()\n\n    // Expected to get the current time on user's device.\n    LlmChatModelHelper.initialize(\n      context = context,\n      model = model,\n      supportImage = false,\n      supportAudio = false,\n      onDone = onDone,\n      systemInstruction = getSystemPrompt(),\n      tools = tools,\n    )\n  }\n\n  override fun cleanUpModelFn(\n    context: Context,\n    coroutineScope: CoroutineScope,\n    model: Model,\n    onDone: () -> Unit,\n  ) {\n    curActions.clear()\n    LlmChatModelHelper.cleanUp(model = model, onDone = onDone)\n  }\n\n  @Composable\n  override fun MainScreen(data: Any) {\n    val customTaskData = data as CustomTaskData\n    MobileActionsScreen(\n      task = task,\n      modelManagerViewModel = customTaskData.modelManagerViewModel,\n      bottomPadding = customTaskData.bottomPadding,\n      setAppBarControlsDisabled = customTaskData.setAppBarControlsDisabled,\n      curActions = curActions,\n      tools = tools,\n      onProcessingStarted = { curActions.clear() },\n    )\n  }\n}\n\nfun getSystemPrompt(): Contents {\n  @SuppressWarnings(\"JavaTimeDefaultTimeZone\") val now = LocalDateTime.now()\n  val curDateTimeString = now.format(DateTimeFormatter.ofPattern(\"yyyy-MM-dd'T'HH:mm:ss\"))\n  val dayOfWeekString = now.format(DateTimeFormatter.ofPattern(\"EEEE\"))\n  return Contents.of(\n    listOf(\n        \"You are a model that can do function calling with the following functions\",\n        \"Current date and time given in YYYY-MM-DDTHH:MM:SS format: ${curDateTimeString}\\nDay of week is $dayOfWeekString\",\n      )\n      .map { Content.Text(it) }\n  )\n}\n"
  },
  {
    "path": "Android/src/app/src/main/java/com/google/ai/edge/gallery/customtasks/mobileactions/MobileActionsTools.kt",
    "content": "/*\n * Copyright 2025 Google LLC\n *\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n *     http://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\npackage com.google.ai.edge.gallery.customtasks.mobileactions\n\nimport android.util.Log\nimport com.google.ai.edge.litertlm.Tool\nimport com.google.ai.edge.litertlm.ToolParam\n\nprivate const val TAG = \"AGMATools\"\n\nclass MobileActionsTools(val onFunctionCalled: (Action) -> Unit) {\n  /** Turns on flashlight. */\n  @Tool(description = \"Turns the flashlight on\")\n  fun turnOnFlashlight(): Map<String, String> {\n    Log.d(TAG, \"turn on flashlight\")\n\n    // Call the callback with the recognized action.\n    onFunctionCalled(FlashlightOnAction())\n\n    // Return a response object to the model confirming the action.\n    return mapOf(\"result\" to \"success\")\n  }\n\n  /** Turns off flashlight. */\n  @Tool(description = \"Turns the flashlight off\")\n  fun turnOffFlashlight(): Map<String, String> {\n    Log.d(TAG, \"turn off flashlight\")\n\n    // Call the callback with the recognized action.\n    onFunctionCalled(FlashlightOffAction())\n\n    // Return a response object to the model confirming the action.\n    return mapOf(\"result\" to \"success\")\n  }\n\n  /** Creates contact. */\n  @Tool(description = \"Creates a contact in the phone's contact list.\")\n  fun createContact(\n    @ToolParam(description = \"The first name of the contact.\") firstName: String,\n    @ToolParam(description = \"The last name of the contact.\") lastName: String,\n    @ToolParam(description = \"The phone number of the contact.\") phoneNumber: String,\n    @ToolParam(description = \"The email address of the contact.\") email: String,\n  ): Map<String, String> {\n    Log.d(\n      TAG,\n      \"create contact. First name: '$firstName', last name: '$lastName', phone number: '$phoneNumber', email: '$email'\",\n    )\n\n    onFunctionCalled(\n      CreateContactAction(\n        firstName = firstName,\n        lastName = lastName,\n        phoneNumber = phoneNumber,\n        email = email,\n      )\n    )\n\n    return mapOf(\n      \"result\" to \"success\",\n      \"first_name\" to firstName,\n      \"last_name\" to lastName,\n      \"phone_number\" to phoneNumber,\n      \"email\" to email,\n    )\n  }\n\n  /** Sends email. */\n  @Tool(description = \"Sends an email.\")\n  fun sendEmail(\n    @ToolParam(description = \"The email address of the recipient.\") to: String,\n    @ToolParam(description = \"The subject of the email.\") subject: String,\n    @ToolParam(description = \"The body of the email.\") body: String,\n  ): Map<String, String> {\n    Log.d(TAG, \"send email. To: '$to', subject: '$subject', body: '$body'\")\n\n    onFunctionCalled(SendEmailAction(to = to, subject = subject, body = body))\n\n    return mapOf(\"result\" to \"success\", \"to\" to to, \"subject\" to subject, \"body\" to body)\n  }\n\n  /** Shows location on map. */\n  @Tool(description = \"Shows a location on the map.\")\n  fun showLocationOnMap(\n    @ToolParam(\n      description =\n        \"The location to search for. May be the name of a place, a business, or an address.\"\n    )\n    location: String\n  ): Map<String, String> {\n    Log.d(TAG, \"Show location on map. Location: '$location'\")\n\n    onFunctionCalled(ShowLocationOnMap(location = location))\n\n    return mapOf(\"result\" to \"success\", \"location\" to location)\n  }\n\n  /** Opens wifi settings. */\n  @Tool(description = \"Opens the WiFi settings.\")\n  fun openWifiSettings(): Map<String, String> {\n    Log.d(TAG, \"Open wifi settings\")\n\n    onFunctionCalled(OpenWifiSettingsAction())\n\n    return mapOf(\"result\" to \"success\")\n  }\n\n  /** Creates calendar events. */\n  @Tool(description = \"Creates a new calendar event.\")\n  fun createCalendarEvent(\n    @ToolParam(description = \"The date and time of the event in the format YYYY-MM-DDTHH:MM:SS.\")\n    datetime: String,\n    @ToolParam(description = \"The title of the event.\") title: String,\n  ): Map<String, String> {\n    Log.d(TAG, \"Create calendar event. Datetime: '$datetime', title: '$title'\")\n\n    onFunctionCalled(CreateCalendarEventAction(datetime = datetime, title = title))\n\n    return mapOf(\"result\" to \"success\", \"datetime\" to datetime, \"title\" to title)\n  }\n}\n"
  },
  {
    "path": "Android/src/app/src/main/java/com/google/ai/edge/gallery/customtasks/mobileactions/MobileActionsViewModel.kt",
    "content": "/*\n * Copyright 2025 Google LLC\n *\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n *     http://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\npackage com.google.ai.edge.gallery.customtasks.mobileactions\n\nimport android.content.Context\nimport android.content.Intent\nimport android.hardware.camera2.CameraCharacteristics\nimport android.hardware.camera2.CameraManager\nimport android.provider.CalendarContract\nimport android.provider.ContactsContract\nimport android.provider.Settings\nimport android.util.Log\nimport androidx.core.net.toUri\nimport androidx.lifecycle.ViewModel\nimport androidx.lifecycle.viewModelScope\nimport com.google.ai.edge.gallery.R\nimport com.google.ai.edge.gallery.data.Model\nimport com.google.ai.edge.gallery.ui.llmchat.LlmChatModelHelper\nimport com.google.ai.edge.gallery.ui.llmchat.LlmModelInstance\nimport com.google.ai.edge.gallery.ui.modelmanager.ModelInitializationStatus\nimport com.google.ai.edge.gallery.ui.modelmanager.ModelInitializationStatusType\nimport com.google.ai.edge.gallery.ui.modelmanager.ModelManagerViewModel\nimport com.google.ai.edge.litertlm.Content\nimport com.google.ai.edge.litertlm.Contents\nimport dagger.hilt.android.lifecycle.HiltViewModel\nimport dagger.hilt.android.qualifiers.ApplicationContext\nimport java.net.URLEncoder\nimport java.nio.charset.StandardCharsets\nimport java.time.LocalDateTime\nimport java.time.ZoneId\nimport javax.inject.Inject\nimport kotlinx.coroutines.Dispatchers\nimport kotlinx.coroutines.flow.MutableStateFlow\nimport kotlinx.coroutines.flow.asStateFlow\nimport kotlinx.coroutines.flow.catch\nimport kotlinx.coroutines.flow.first\nimport kotlinx.coroutines.flow.onCompletion\nimport kotlinx.coroutines.flow.update\nimport kotlinx.coroutines.launch\n\nprivate const val TAG = \"AGMAViewModel\"\n\n/** The UI state of the MobileActionsViewModel. */\ndata class MobileActionsUiState(\n  val showWelcomeMessage: Boolean = true,\n  val processing: Boolean = false,\n  val userPrompt: String = \"\",\n  val modelResponse: String = \"\",\n  val functionCallDetails: List<String> = listOf(),\n  val noFunctionRecognized: Boolean = false,\n)\n\n@HiltViewModel\nclass MobileActionsViewModel\n@Inject\nconstructor(@ApplicationContext private val appContext: Context) : ViewModel() {\n  protected val _uiState = MutableStateFlow(MobileActionsUiState())\n  val uiState = _uiState.asStateFlow()\n\n  private val _isResettingConversation = MutableStateFlow(false)\n  private val isResettingConversation = _isResettingConversation.asStateFlow()\n\n  fun reset() {\n    val unused = setFlashlight(context = appContext, isEnabled = false)\n    setShowWelcomeMessage(showWelcomeMessage = true)\n    setUserPrompt(prompt = \"'\")\n    setModelResponse(response = \"\")\n    setNoFunctionRecognized(value = false)\n    clearFunctionCallDetails()\n  }\n\n  fun cleanUp() {\n    val unused = setFlashlight(context = appContext, isEnabled = false)\n  }\n\n  fun setShowWelcomeMessage(showWelcomeMessage: Boolean) {\n    _uiState.update { _uiState.value.copy(showWelcomeMessage = showWelcomeMessage) }\n  }\n\n  fun setProcessing(processing: Boolean) {\n    _uiState.update { _uiState.value.copy(processing = processing) }\n  }\n\n  fun setUserPrompt(prompt: String) {\n    _uiState.update { _uiState.value.copy(userPrompt = prompt) }\n  }\n\n  fun setModelResponse(response: String) {\n    _uiState.update { _uiState.value.copy(modelResponse = response) }\n  }\n\n  fun appendModelResponse(partialResponse: String) {\n    _uiState.update {\n      _uiState.value.copy(modelResponse = _uiState.value.modelResponse + partialResponse)\n    }\n  }\n\n  fun addFunctionCallDetails(details: String) {\n    val newDetails = _uiState.value.functionCallDetails.toMutableList()\n    newDetails.add(details)\n    _uiState.update { _uiState.value.copy(functionCallDetails = newDetails) }\n  }\n\n  fun clearFunctionCallDetails() {\n    _uiState.update { _uiState.value.copy(functionCallDetails = listOf()) }\n  }\n\n  fun setNoFunctionRecognized(value: Boolean) {\n    _uiState.update { _uiState.value.copy(noFunctionRecognized = value) }\n  }\n\n  fun processUserPrompt(\n    model: Model,\n    userPrompt: String,\n    tools: List<MobileActionsTools>,\n    onProcessDone: () -> Unit,\n    onError: (error: String) -> Unit,\n  ) {\n    if (model.instance == null) {\n      setProcessing(processing = false)\n      return\n    }\n\n    viewModelScope.launch(Dispatchers.Default) {\n      Log.d(TAG, \"Start processing user prompt: $userPrompt\")\n      setProcessing(processing = true)\n      setShowWelcomeMessage(showWelcomeMessage = false)\n\n      // Clean up.\n      setModelResponse(response = \"\")\n      setNoFunctionRecognized(value = false)\n      clearFunctionCallDetails()\n\n      // Set user prompt.\n      setUserPrompt(prompt = userPrompt)\n\n      // Wait until the conversation is NOT resetting.\n      Log.d(TAG, \"Waiting for any ongoing conversation reset to be done...\")\n      isResettingConversation.first { !it }\n      Log.d(TAG, \"Done waiting. Start inference.\")\n\n      // Run inference.\n      val instance = model.instance as LlmModelInstance\n      val conversation = instance.conversation\n      val contents = mutableListOf<Content>()\n      if (userPrompt.trim().isNotEmpty()) {\n        contents.add(Content.Text(userPrompt))\n      }\n\n      conversation\n        .sendMessageAsync(Contents.of(contents))\n        .catch {\n          Log.e(TAG, \"Failed to run inference\", it)\n          onError(it.message ?: \"Unknown error\")\n        }\n        .onCompletion {\n          setProcessing(processing = false)\n          onProcessDone()\n          resetConversation(model = model, tools = tools)\n        }\n        .collect {\n          setProcessing(processing = false)\n          appendModelResponse(partialResponse = it.toString())\n        }\n    }\n  }\n\n  fun resetConversation(model: Model, tools: List<MobileActionsTools>) {\n    _isResettingConversation.value = true\n    LlmChatModelHelper.resetConversation(\n      model = model,\n      supportImage = false,\n      supportAudio = false,\n      systemInstruction = getSystemPrompt(),\n      tools = tools,\n    )\n    _isResettingConversation.value = false\n  }\n\n  fun resetEngine(\n    context: Context,\n    model: Model,\n    tools: List<MobileActionsTools>,\n    modelManagerViewModel: ModelManagerViewModel,\n    onError: (error: String) -> Unit,\n  ) {\n    reset()\n\n    viewModelScope.launch(Dispatchers.Default) {\n      modelManagerViewModel.setInitializationStatus(\n        model = model,\n        status = ModelInitializationStatus(status = ModelInitializationStatusType.NOT_INITIALIZED),\n      )\n      LlmChatModelHelper.cleanUp(\n        model = model,\n        onDone = {\n          LlmChatModelHelper.initialize(\n            context = context,\n            model = model,\n            supportImage = false,\n            supportAudio = false,\n            onDone = { error ->\n              modelManagerViewModel.setInitializationStatus(\n                model = model,\n                status =\n                  ModelInitializationStatus(status = ModelInitializationStatusType.INITIALIZED),\n              )\n              if (error.isNotEmpty()) {\n                onError(error)\n              }\n            },\n            systemInstruction = getSystemPrompt(),\n            tools = tools,\n          )\n        },\n      )\n    }\n  }\n\n  fun performAction(action: Action, context: Context): String {\n    return when (action) {\n      // Flashlight on.\n      is FlashlightOnAction -> setFlashlight(context = context, isEnabled = true)\n\n      // Flashlight off.\n      is FlashlightOffAction -> setFlashlight(context = context, isEnabled = false)\n\n      // Create contact.\n      is CreateContactAction ->\n        createContact(\n          context = context,\n          firstName = action.firstName,\n          lastName = action.lastName,\n          phoneNumber = action.phoneNumber,\n          email = action.email,\n        )\n\n      // Send email.\n      is SendEmailAction ->\n        sendEmail(context = context, to = action.to, subject = action.subject, body = action.body)\n\n      // Show location on map.\n      is ShowLocationOnMap -> showLocationOnMap(context = context, location = action.location)\n\n      // Open wifi settings.\n      is OpenWifiSettingsAction -> openWifiSettings(context = context)\n\n      // Create calendar events.\n      is CreateCalendarEventAction ->\n        createCalendarEvent(context = context, datetime = action.datetime, title = action.title)\n\n      else -> \"\"\n    }\n  }\n\n  private fun setFlashlight(context: Context, isEnabled: Boolean): String {\n    val cameraManager: CameraManager =\n      context.getSystemService(Context.CAMERA_SERVICE) as CameraManager\n\n    // Assuming the device has a rear camera with a flash unit (usually camera ID '0')\n    var cameraId: String? = null\n\n    try {\n      // Find the ID of the camera that supports the flash unit\n      for (id in cameraManager.cameraIdList) {\n        val characteristics = cameraManager.getCameraCharacteristics(id)\n        val isFlashAvailable =\n          characteristics.get(CameraCharacteristics.FLASH_INFO_AVAILABLE) ?: false\n        if (isFlashAvailable) {\n          cameraId = id\n          break\n        }\n      }\n    } catch (e: Exception) {\n      Log.e(TAG, \"Failed to set flashlight\", e)\n      return e.message ?: context.getString(R.string.unknown_error)\n    }\n\n    cameraId?.let { id ->\n      try {\n        cameraManager.setTorchMode(id, isEnabled)\n      } catch (e: Exception) {\n        Log.e(TAG, \"Failed to set flashlight\", e)\n        return e.message ?: context.getString(R.string.unknown_error)\n      }\n    }\n\n    return \"\"\n  }\n\n  private fun createContact(\n    context: Context,\n    firstName: String,\n    lastName: String,\n    phoneNumber: String,\n    email: String,\n  ): String {\n    val intent =\n      Intent(ContactsContract.Intents.Insert.ACTION)\n        .apply { type = ContactsContract.RawContacts.CONTENT_TYPE }\n        .apply {\n          // Name\n          putExtra(ContactsContract.Intents.Insert.NAME, \"$firstName $lastName\")\n          // Inserts an email address\n          putExtra(ContactsContract.Intents.Insert.EMAIL, email)\n          putExtra(\n            ContactsContract.Intents.Insert.EMAIL_TYPE,\n            ContactsContract.CommonDataKinds.Email.TYPE_WORK,\n          )\n          // Inserts a phone number\n          putExtra(ContactsContract.Intents.Insert.PHONE, phoneNumber)\n          putExtra(\n            ContactsContract.Intents.Insert.PHONE_TYPE,\n            ContactsContract.CommonDataKinds.Phone.TYPE_WORK,\n          )\n        }\n\n    try {\n      context.startActivity(intent)\n    } catch (e: Exception) {\n      Log.e(TAG, \"Failed to create contact\", e)\n      return e.message ?: context.getString(R.string.unknown_error)\n    }\n\n    return \"\"\n  }\n\n  private fun sendEmail(context: Context, to: String, subject: String, body: String): String {\n    val intent =\n      Intent(Intent.ACTION_SEND).apply {\n        data = \"mailto:\".toUri()\n        type = \"text/plain\"\n        putExtra(Intent.EXTRA_EMAIL, arrayOf(to))\n        putExtra(Intent.EXTRA_SUBJECT, subject)\n        putExtra(Intent.EXTRA_TEXT, body)\n      }\n\n    try {\n      context.startActivity(intent)\n    } catch (e: Exception) {\n      Log.e(TAG, \"Failed to send email\", e)\n      return e.message ?: context.getString(R.string.unknown_error)\n    }\n\n    return \"\"\n  }\n\n  private fun showLocationOnMap(context: Context, location: String): String {\n    val encodedLocation = URLEncoder.encode(location, StandardCharsets.UTF_8.toString())\n    val intent = Intent(Intent.ACTION_VIEW).apply { data = \"geo:0,0?q=$encodedLocation\".toUri() }\n\n    try {\n      context.startActivity(intent)\n    } catch (e: Exception) {\n      Log.e(TAG, \"Failed to show location on map\", e)\n      return e.message ?: context.getString(R.string.unknown_error)\n    }\n\n    return \"\"\n  }\n\n  private fun openWifiSettings(context: Context): String {\n    val intent = Intent(Settings.ACTION_WIFI_SETTINGS)\n    try {\n      context.startActivity(intent)\n    } catch (e: Exception) {\n      Log.e(TAG, \"Failed to open wifi settings\", e)\n      return e.message ?: context.getString(R.string.unknown_error)\n    }\n\n    return \"\"\n  }\n\n  private fun createCalendarEvent(context: Context, datetime: String, title: String): String {\n    // Convert datetime string to ms.\n    var ms = System.currentTimeMillis()\n    try {\n      val localDateTime = LocalDateTime.parse(datetime)\n      val systemDefaultZone = ZoneId.systemDefault()\n      val zonedDateTime = localDateTime.atZone(systemDefaultZone)\n      ms = zonedDateTime.toInstant().toEpochMilli()\n    } catch (e: Exception) {\n      // Ignore parsing error.\n      Log.w(TAG, \"Failed to parse date time: '$datetime'\", e)\n    }\n\n    val intent =\n      Intent(Intent.ACTION_INSERT).apply {\n        data = CalendarContract.Events.CONTENT_URI\n        putExtra(CalendarContract.Events.TITLE, title)\n        putExtra(CalendarContract.EXTRA_EVENT_BEGIN_TIME, ms)\n        putExtra(CalendarContract.EXTRA_EVENT_END_TIME, ms + 3600000)\n      }\n    try {\n      context.startActivity(intent)\n    } catch (e: Exception) {\n      Log.e(TAG, \"Failed to create calendar event\", e)\n      return e.message ?: context.getString(R.string.unknown_error)\n    }\n\n    return \"\"\n  }\n}\n"
  },
  {
    "path": "Android/src/app/src/main/java/com/google/ai/edge/gallery/customtasks/tinygarden/ConversationHistoryPanel.kt",
    "content": "/*\n * Copyright 2025 Google LLC\n *\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n *     http://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\npackage com.google.ai.edge.gallery.customtasks.tinygarden\n\nimport androidx.compose.foundation.background\nimport androidx.compose.foundation.layout.Arrangement\nimport androidx.compose.foundation.layout.Box\nimport androidx.compose.foundation.layout.Column\nimport androidx.compose.foundation.layout.Row\nimport androidx.compose.foundation.layout.fillMaxSize\nimport androidx.compose.foundation.layout.fillMaxWidth\nimport androidx.compose.foundation.layout.padding\nimport androidx.compose.foundation.rememberScrollState\nimport androidx.compose.foundation.verticalScroll\nimport androidx.compose.material.icons.Icons\nimport androidx.compose.material.icons.rounded.Close\nimport androidx.compose.material3.Icon\nimport androidx.compose.material3.IconButton\nimport androidx.compose.material3.MaterialTheme\nimport androidx.compose.material3.Text\nimport androidx.compose.runtime.Composable\nimport androidx.compose.runtime.LaunchedEffect\nimport androidx.compose.runtime.collectAsState\nimport androidx.compose.runtime.getValue\nimport androidx.compose.ui.Alignment\nimport androidx.compose.ui.Modifier\nimport androidx.compose.ui.draw.clip\nimport androidx.compose.ui.graphics.Color\nimport androidx.compose.ui.res.dimensionResource\nimport androidx.compose.ui.res.stringResource\nimport androidx.compose.ui.unit.Dp\nimport androidx.compose.ui.unit.dp\nimport com.google.ai.edge.gallery.R\nimport com.google.ai.edge.gallery.data.Task\nimport com.google.ai.edge.gallery.ui.common.chat.ChatMessageError\nimport com.google.ai.edge.gallery.ui.common.chat.ChatMessageText\nimport com.google.ai.edge.gallery.ui.common.chat.ChatMessageWarning\nimport com.google.ai.edge.gallery.ui.common.chat.ChatSide\nimport com.google.ai.edge.gallery.ui.common.chat.MessageBodyError\nimport com.google.ai.edge.gallery.ui.common.chat.MessageBodyText\nimport com.google.ai.edge.gallery.ui.common.chat.MessageBodyWarning\nimport com.google.ai.edge.gallery.ui.common.chat.MessageBubbleShape\nimport com.google.ai.edge.gallery.ui.common.chat.MessageSender\nimport com.google.ai.edge.gallery.ui.theme.customColors\n\n/** A panel to show the conversation history. */\n@Composable\nfun ConversationHistoryPanel(\n  task: Task,\n  bottomPadding: Dp,\n  viewModel: TinyGardenViewModel,\n  onDismiss: () -> Unit,\n) {\n  val uiState by viewModel.uiState.collectAsState()\n  val listState = rememberScrollState()\n\n  Column(\n    modifier =\n      Modifier.background(color = MaterialTheme.colorScheme.surface)\n        .fillMaxSize()\n        .padding(bottom = bottomPadding)\n  ) {\n    // Scroll to bottom when adding a new message.\n    LaunchedEffect(uiState.messages.size) {\n      if (uiState.messages.isNotEmpty()) {\n        listState.animateScrollTo(1000000)\n      }\n    }\n\n    // Title and button to dismiss.\n    Row(\n      modifier =\n        Modifier.background(color = MaterialTheme.colorScheme.surfaceContainerHighest)\n          .fillMaxWidth()\n          .padding(start = 12.dp),\n      horizontalArrangement = Arrangement.SpaceBetween,\n      verticalAlignment = Alignment.CenterVertically,\n    ) {\n      Text(\n        stringResource(R.string.conversation_history),\n        style = MaterialTheme.typography.titleMedium,\n      )\n      IconButton(onClick = { onDismiss() }) {\n        Icon(\n          imageVector = Icons.Rounded.Close,\n          contentDescription = stringResource(R.string.cd_close_icon),\n        )\n      }\n    }\n\n    // Message list.\n    Column(\n      modifier = Modifier.weight(1f).padding(horizontal = 16.dp).verticalScroll(state = listState)\n    ) {\n      for (message in uiState.messages) {\n        var hAlign: Alignment.Horizontal = Alignment.End\n        var backgroundColor: Color = MaterialTheme.customColors.userBubbleBgColor\n        var hardCornerAtLeftOrRight = false\n        var extraPaddingStart = 48.dp\n        var extraPaddingEnd = 0.dp\n        if (message.side == ChatSide.AGENT) {\n          hAlign = Alignment.Start\n          backgroundColor = MaterialTheme.customColors.agentBubbleBgColor\n          hardCornerAtLeftOrRight = true\n          extraPaddingStart = 0.dp\n          extraPaddingEnd = 48.dp\n        } else if (message.side == ChatSide.SYSTEM) {\n          extraPaddingStart = 24.dp\n          extraPaddingEnd = 24.dp\n        }\n        val bubbleBorderRadius = dimensionResource(R.dimen.chat_bubble_corner_radius)\n\n        Column(\n          modifier =\n            Modifier.fillMaxWidth()\n              .padding(start = extraPaddingStart, end = extraPaddingEnd, top = 6.dp, bottom = 6.dp),\n          horizontalAlignment = hAlign,\n        ) messageColumn@{\n          // Sender row.\n          var agentName = stringResource(task.agentNameRes)\n          if (message.accelerator.isNotEmpty()) {\n            agentName = \"$agentName on ${message.accelerator}\"\n          }\n          MessageSender(message = message, agentName = agentName)\n\n          when (message) {\n            // Warning.\n            is ChatMessageWarning -> MessageBodyWarning(message = message)\n\n            // Error.\n            is ChatMessageError -> MessageBodyError(message = message)\n\n            else -> {\n              // Message body.\n              when (message) {\n                // Text\n                is ChatMessageText -> {\n                  Row(\n                    verticalAlignment = Alignment.Top,\n                    horizontalArrangement = Arrangement.spacedBy(8.dp),\n                  ) {\n                    Box(\n                      modifier =\n                        Modifier.clip(\n                            MessageBubbleShape(\n                              radius = bubbleBorderRadius,\n                              hardCornerAtLeftOrRight = hardCornerAtLeftOrRight,\n                            )\n                          )\n                          .background(backgroundColor)\n                    ) {\n                      MessageBodyText(message = message, inProgress = false)\n                    }\n                  }\n                }\n                else -> {}\n              }\n            }\n          }\n        }\n      }\n    }\n  }\n}\n"
  },
  {
    "path": "Android/src/app/src/main/java/com/google/ai/edge/gallery/customtasks/tinygarden/TinyGardenScreen.kt",
    "content": "/*\n * Copyright 2025 Google LLC\n *\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n *     http://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\npackage com.google.ai.edge.gallery.customtasks.tinygarden\n\nimport android.Manifest\nimport android.annotation.SuppressLint\nimport android.content.Intent\nimport android.content.pm.PackageManager\nimport android.os.Bundle\nimport android.util.Log\nimport android.view.ViewGroup\nimport android.webkit.ConsoleMessage\nimport android.webkit.WebChromeClient\nimport android.webkit.WebResourceRequest\nimport android.webkit.WebResourceResponse\nimport android.webkit.WebView\nimport android.webkit.WebViewClient\nimport androidx.activity.compose.BackHandler\nimport androidx.activity.compose.rememberLauncherForActivityResult\nimport androidx.activity.result.contract.ActivityResultContracts\nimport androidx.compose.animation.AnimatedVisibility\nimport androidx.compose.animation.core.FastOutSlowInEasing\nimport androidx.compose.animation.core.tween\nimport androidx.compose.animation.fadeIn\nimport androidx.compose.animation.fadeOut\nimport androidx.compose.animation.scaleIn\nimport androidx.compose.animation.scaleOut\nimport androidx.compose.animation.slideInVertically\nimport androidx.compose.animation.slideOutVertically\nimport androidx.compose.foundation.background\nimport androidx.compose.foundation.layout.Arrangement\nimport androidx.compose.foundation.layout.Box\nimport androidx.compose.foundation.layout.Column\nimport androidx.compose.foundation.layout.Row\nimport androidx.compose.foundation.layout.WindowInsets\nimport androidx.compose.foundation.layout.fillMaxHeight\nimport androidx.compose.foundation.layout.fillMaxSize\nimport androidx.compose.foundation.layout.fillMaxWidth\nimport androidx.compose.foundation.layout.ime\nimport androidx.compose.foundation.layout.imePadding\nimport androidx.compose.foundation.layout.padding\nimport androidx.compose.foundation.layout.size\nimport androidx.compose.material.icons.Icons\nimport androidx.compose.material.icons.outlined.History\nimport androidx.compose.material3.AlertDialog\nimport androidx.compose.material3.Button\nimport androidx.compose.material3.ButtonDefaults\nimport androidx.compose.material3.CircularProgressIndicator\nimport androidx.compose.material3.ExperimentalMaterial3Api\nimport androidx.compose.material3.Icon\nimport androidx.compose.material3.IconButton\nimport androidx.compose.material3.MaterialTheme\nimport androidx.compose.material3.SnackbarHost\nimport androidx.compose.material3.SnackbarHostState\nimport androidx.compose.material3.Text\nimport androidx.compose.material3.TextButton\nimport androidx.compose.runtime.Composable\nimport androidx.compose.runtime.LaunchedEffect\nimport androidx.compose.runtime.collectAsState\nimport androidx.compose.runtime.getValue\nimport androidx.compose.runtime.mutableIntStateOf\nimport androidx.compose.runtime.mutableLongStateOf\nimport androidx.compose.runtime.mutableStateOf\nimport androidx.compose.runtime.remember\nimport androidx.compose.runtime.rememberCoroutineScope\nimport androidx.compose.runtime.setValue\nimport androidx.compose.ui.Alignment\nimport androidx.compose.ui.Modifier\nimport androidx.compose.ui.graphics.Color\nimport androidx.compose.ui.platform.LocalContext\nimport androidx.compose.ui.platform.LocalDensity\nimport androidx.compose.ui.platform.LocalResources\nimport androidx.compose.ui.res.stringResource\nimport androidx.compose.ui.unit.Dp\nimport androidx.compose.ui.unit.dp\nimport androidx.compose.ui.viewinterop.AndroidView\nimport androidx.core.content.ContextCompat\nimport androidx.core.net.toUri\nimport androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel\nimport androidx.webkit.WebViewAssetLoader\nimport com.google.ai.edge.gallery.GalleryEvent\nimport com.google.ai.edge.gallery.R\nimport com.google.ai.edge.gallery.data.ConfigKeys\nimport com.google.ai.edge.gallery.data.ModelDownloadStatusType\nimport com.google.ai.edge.gallery.data.Task\nimport com.google.ai.edge.gallery.data.ValueType\nimport com.google.ai.edge.gallery.data.convertValueToTargetType\nimport com.google.ai.edge.gallery.firebaseAnalytics\nimport com.google.ai.edge.gallery.ui.common.chat.ChatMessageText\nimport com.google.ai.edge.gallery.ui.common.chat.ChatMessageWarning\nimport com.google.ai.edge.gallery.ui.common.chat.ChatSide\nimport com.google.ai.edge.gallery.ui.common.getTaskBgGradientColors\nimport com.google.ai.edge.gallery.ui.common.textandvoiceinput.HoldToDictateViewModel\nimport com.google.ai.edge.gallery.ui.common.textandvoiceinput.TextAndVoiceInput\nimport com.google.ai.edge.gallery.ui.common.textandvoiceinput.VoiceRecognizerOverlay\nimport com.google.ai.edge.gallery.ui.modelmanager.ModelManagerViewModel\nimport com.google.ai.edge.gallery.ui.theme.customColors\nimport com.google.common.io.BaseEncoding\nimport java.security.MessageDigest\nimport kotlinx.coroutines.delay\nimport kotlinx.coroutines.flow.Flow\nimport kotlinx.coroutines.launch\n\nprivate const val TAG = \"AGTinyGarden\"\nprivate const val ASSETS_BASE_URL = \"https://appassets.androidplatform.net\"\n\n/** The main screen for the Tiny Garden game. */\n@Composable\nfun TinyGardenScreen(\n  task: Task,\n  modelManagerViewModel: ModelManagerViewModel,\n  tools: List<TinyGardenTools>,\n  bottomPadding: Dp,\n  setAppBarControlsDisabled: (Boolean) -> Unit,\n  setTopBarVisible: (Boolean) -> Unit,\n  commandFlow: Flow<TinyGardenCommand>,\n  viewModel: TinyGardenViewModel = hiltViewModel(),\n) {\n  val uiState by viewModel.uiState.collectAsState()\n  var recordAudioPermissionGranted by remember { mutableStateOf(false) }\n  val context = LocalContext.current\n\n  // Permission request when recording audio clips.\n  val recordAudioClipsPermissionLauncher =\n    rememberLauncherForActivityResult(ActivityResultContracts.RequestPermission()) {\n      permissionGranted ->\n      if (permissionGranted) {\n        recordAudioPermissionGranted = true\n      }\n    }\n\n  LaunchedEffect(Unit) {\n    // Check permission\n    when (PackageManager.PERMISSION_GRANTED) {\n      // Already got permission. Call the lambda.\n      ContextCompat.checkSelfPermission(context, Manifest.permission.RECORD_AUDIO) -> {\n        recordAudioPermissionGranted = true\n      }\n\n      // Otherwise, ask for permission\n      else -> {\n        recordAudioClipsPermissionLauncher.launch(Manifest.permission.RECORD_AUDIO)\n      }\n    }\n  }\n\n  if (recordAudioPermissionGranted) {\n    Column(\n      modifier = Modifier.fillMaxSize().background(MaterialTheme.colorScheme.surface).imePadding()\n    ) {\n      Box(modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {\n        MainUi(\n          task = task,\n          modelManagerViewModel = modelManagerViewModel,\n          tools = tools,\n          bottomPadding = bottomPadding,\n          commandFlow = commandFlow,\n          viewModel = viewModel,\n          setAppBarControlsDisabled = setAppBarControlsDisabled,\n          setTopBarVisible = setTopBarVisible,\n        )\n\n        // Resetting engine spinner.\n        Column() {\n          AnimatedVisibility(\n            uiState.resettingEngine,\n            enter = fadeIn() + scaleIn(initialScale = 0.9f),\n            exit = fadeOut() + scaleOut(targetScale = 0.9f),\n          ) {\n            Box(\n              modifier = Modifier.fillMaxSize().background(MaterialTheme.colorScheme.surface),\n              contentAlignment = Alignment.Center,\n            ) {\n              Column(\n                verticalArrangement = Arrangement.spacedBy(8.dp),\n                horizontalAlignment = Alignment.CenterHorizontally,\n              ) {\n                CircularProgressIndicator(\n                  trackColor = MaterialTheme.colorScheme.surfaceVariant,\n                  strokeWidth = 3.dp,\n                  modifier = Modifier.size(24.dp),\n                )\n                Text(\n                  stringResource(R.string.resetting_engine),\n                  color = MaterialTheme.colorScheme.onSurfaceVariant,\n                )\n                Text(\n                  stringResource(R.string.reinitializing_description),\n                  color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.7f),\n                  style = MaterialTheme.typography.bodyMedium,\n                  modifier = Modifier.padding(top = 8.dp),\n                )\n              }\n            }\n          }\n        }\n      }\n    }\n  }\n}\n\n@OptIn(ExperimentalMaterial3Api::class)\n@SuppressLint(\"SetJavaScriptEnabled\")\n@Composable\nfun MainUi(\n  task: Task,\n  modelManagerViewModel: ModelManagerViewModel,\n  tools: List<TinyGardenTools>,\n  bottomPadding: Dp,\n  viewModel: TinyGardenViewModel,\n  setAppBarControlsDisabled: (Boolean) -> Unit,\n  setTopBarVisible: (Boolean) -> Unit,\n  commandFlow: Flow<TinyGardenCommand>,\n  holdToDictateViewModel: HoldToDictateViewModel = hiltViewModel(),\n) {\n  val modelManagerUiState by modelManagerViewModel.uiState.collectAsState()\n  val model = modelManagerUiState.selectedModel\n  val initialModelConfigValues = remember(model) { model.configValues }\n  var webViewRef: WebView? by remember { mutableStateOf(null) }\n  val scope = rememberCoroutineScope()\n  val uiState by viewModel.uiState.collectAsState()\n  var clearTextTrigger by remember { mutableLongStateOf(0L) }\n  var curAmplitude by remember { mutableIntStateOf(0) }\n  val holdToDictateUiState by holdToDictateViewModel.uiState.collectAsState()\n  var showConversationHistoryPanel by remember { mutableStateOf(false) }\n  var showErrorDialog by remember { mutableStateOf(false) }\n  var errorDialogContent by remember { mutableStateOf(\"\") }\n  val snackbarHostState = remember { SnackbarHostState() }\n  var prevSeed by remember { mutableStateOf(\"\") }\n  var prevPlots by remember { mutableStateOf(\"\") }\n  var prevAction by remember { mutableStateOf(\"\") }\n  val resources = LocalResources.current\n  val context = LocalContext.current\n\n  val taskColor = getTaskBgGradientColors(task = task)[1]\n  val curDownloadStatus = modelManagerUiState.modelDownloadStatus[model.name]?.status\n  setAppBarControlsDisabled(\n    curDownloadStatus == ModelDownloadStatusType.SUCCEEDED &&\n      (!modelManagerUiState.isModelInitialized(model = model) || uiState.processing)\n  )\n\n  // Close conversation history panel when pressing back button.\n  BackHandler(enabled = showConversationHistoryPanel) { showConversationHistoryPanel = false }\n\n  LaunchedEffect(showConversationHistoryPanel) { setTopBarVisible(!showConversationHistoryPanel) }\n\n  LaunchedEffect(Unit) {\n    // Run commands/functions generated by TinyGardenTools.\n    commandFlow.collect { command ->\n      // Format command and add to \"chat\" history.\n      val functionName =\n        when (command.item) {\n          TinyGardenItem.SUNFLOWER.ordinal + 1,\n          TinyGardenItem.DAISY.ordinal + 1,\n          TinyGardenItem.ROSE.ordinal + 1,\n          TinyGardenItem.SPECIAL.ordinal + 1 -> \"plantSeed\"\n\n          TinyGardenItem.WATERING_CAN.ordinal + 1 -> \"waterPlots\"\n          TinyGardenItem.SCYTHE.ordinal + 1 -> \"harvestPlots\"\n          else -> \"\"\n        }\n      val strPlots = \"[${command.plots.joinToString(\",\")}]\"\n      val functionParameter =\n        when (command.item) {\n          TinyGardenItem.SUNFLOWER.ordinal + 1 -> \"- seed: \\\"sunflower\\\"\\n- plots: $strPlots\"\n          TinyGardenItem.DAISY.ordinal + 1 -> \"- seed: \\\"daisy\\\"\\n- plots: $strPlots\"\n          TinyGardenItem.ROSE.ordinal + 1 -> \"- seed: \\\"rose\\\"\\n- plots: $strPlots\"\n          TinyGardenItem.SPECIAL.ordinal + 1 -> \"- seed: \\\"special\\\"\\n- plots: $strPlots\"\n          TinyGardenItem.WATERING_CAN.ordinal + 1 -> \"- plots: $strPlots\"\n          TinyGardenItem.SCYTHE.ordinal + 1 -> \"- plots: $strPlots\"\n          else -> \"\"\n        }\n      val numParameters =\n        when (command.item) {\n          TinyGardenItem.WATERING_CAN.ordinal + 1,\n          TinyGardenItem.SCYTHE.ordinal + 1 -> 1\n          else -> 2\n        }\n      val functionNameLabel = resources.getString(R.string.function_name)\n      val parametersLabel = resources.getQuantityString(R.plurals.parameter, numParameters)\n      viewModel.addMessage(\n        message =\n          ChatMessageText(\n            content =\n              \"**$functionNameLabel**:\\n- $functionName\\n\\n**$parametersLabel**:\\n$functionParameter\",\n            side = ChatSide.AGENT,\n          )\n      )\n\n      // Convert command into json that can be consumed by the game.\n      val commandJson =\n        \"\"\"[{\"item\": ${command.item}, \"plot\":[${command.plots.joinToString(\",\")}]}]\"\"\"\n      Log.d(TAG, \"commandJson: $commandJson\")\n\n      // Call into the game webview.\n      val jsScript = \"tinyGarden.runCommands('$commandJson')\"\n      webViewRef\n        ?.runCatching { evaluateJavascript(jsScript, null) }\n        ?.onFailure { e -> Log.e(TAG, \"$e\") }\n\n      // Save seed, plots, and action so that we can add them to system prompt when resetting\n      // conversation.\n      prevSeed =\n        when (command.item) {\n          TinyGardenItem.SUNFLOWER.ordinal + 1 -> TinyGardenItem.SUNFLOWER.label\n          TinyGardenItem.DAISY.ordinal + 1 -> TinyGardenItem.DAISY.label\n          TinyGardenItem.ROSE.ordinal + 1 -> TinyGardenItem.ROSE.label\n          TinyGardenItem.SPECIAL.ordinal + 1 -> TinyGardenItem.SPECIAL.label\n          else -> \"\"\n        }\n      prevPlots = command.plots.joinToString(\",\")\n      prevAction =\n        when (command.item) {\n          TinyGardenItem.WATERING_CAN.ordinal + 1 -> TinyGardenItem.WATERING_CAN.label\n          TinyGardenItem.SCYTHE.ordinal + 1 -> TinyGardenItem.SCYTHE.label\n          else -> \"\"\n        }\n      Log.d(TAG, \"prevSeed: '$prevSeed', prevPlots: '$prevPlots', prevAction: '$prevAction'\")\n    }\n  }\n\n  val noFunctionCallWarningMessage = stringResource(R.string.warning_no_function_call)\n  val noFunctionCallSnackbarMessage = stringResource(R.string.snackbar_no_function_call)\n\n  // A function to process the input from the user.\n  fun processInstructionText(text: String) {\n    clearTextTrigger = System.currentTimeMillis()\n\n    if (text.trim().isNotEmpty()) {\n      // A special input to unlock all :)\n      if (text.trim().sha256() == \"XtNztQDSDvVpMRPOK+q9tZs43x/VD1teVs3CvWp7zkc=\") {\n        webViewRef\n          ?.runCatching { evaluateJavascript(\"tinyGarden.unlockAll()\", null) }\n          ?.onFailure { e -> Log.e(TAG, \"$e\") }\n      } else {\n        // Run inference to get response command in json.\n        viewModel.getCommand(\n          model = model,\n          instructionText = text,\n          onDone = { response ->\n            // Add a warning message if no function was recognized.\n            if (uiState.messages.last().side != ChatSide.AGENT) {\n              viewModel.addMessage(\n                message = ChatMessageWarning(content = noFunctionCallWarningMessage)\n              )\n              // Show a snack bar for unrecognized command.\n              scope.launch {\n                snackbarHostState.showSnackbar(\n                  noFunctionCallSnackbarMessage,\n                  withDismissAction = true,\n                )\n              }\n            }\n\n            // Add the final response from the model.\n            // viewModel.addMessage(\n            //   message = ChatMessageText(content = response, side = ChatSide.AGENT)\n            // )\n\n            // Reset conversation every {numTurns} turns.\n            val numTurnsToReset =\n              convertValueToTargetType(\n                value = model.configValues.getValue(ConfigKeys.RESET_CONVERSATION_TURN_COUNT.label),\n                valueType = ValueType.INT,\n              )\n                as Int\n            Log.d(TAG, \"Target turn to reset: $numTurnsToReset\")\n            if (uiState.numTurns == numTurnsToReset) {\n              Log.d(TAG, \"!! This is the turn to reset conversation\")\n              viewModel.resetConversation(\n                model = model,\n                tools = tools,\n                prevSeed = prevSeed,\n                prevPlots = prevPlots,\n                prevAction = prevAction,\n              )\n            }\n          },\n          onError = { error ->\n            // Show error dialog for users to reset the engine.\n            errorDialogContent = error\n            showErrorDialog = true\n          },\n        )\n      }\n\n      firebaseAnalytics?.logEvent(\n        GalleryEvent.GENERATE_ACTION.id,\n        Bundle().apply {\n          putString(\"capability_name\", task.id)\n          putString(\"model_id\", model.name)\n        },\n      )\n    }\n  }\n\n  // Reset states on config changes.\n  LaunchedEffect(model.configValues) {\n    if (model.configValues != initialModelConfigValues) {\n      var same = true\n      var nonNumTurnsConfigChanged = false\n      for (config in model.configs) {\n        val key = config.key.label\n        val oldValue =\n          if (model.prevConfigValues.containsKey(key)) {\n            convertValueToTargetType(\n              value = model.prevConfigValues.getValue(key),\n              valueType = config.valueType,\n            )\n          } else {\n            null\n          }\n        val newValue =\n          convertValueToTargetType(\n            value = model.configValues.getValue(key),\n            valueType = config.valueType,\n          )\n        if (oldValue != newValue) {\n          same = false\n          if (config.key != ConfigKeys.RESET_CONVERSATION_TURN_COUNT) {\n            nonNumTurnsConfigChanged = true\n          }\n        }\n      }\n\n      if (!same) {\n        Log.d(TAG, \"model config values changed.\")\n        if (nonNumTurnsConfigChanged) {\n          Log.d(TAG, \"need to reset engine\")\n          viewModel.resetEngine(\n            context = context,\n            model = model,\n            tools = tools,\n            onError = {\n              errorDialogContent = it\n              showErrorDialog = true\n            },\n          )\n        } else {\n          Log.d(TAG, \"need to reset conversation\")\n          viewModel.resetConversation(\n            model = model,\n            tools = tools,\n            prevSeed = \"\",\n            prevPlots = \"\",\n            prevAction = \"\",\n          )\n        }\n      }\n    }\n  }\n\n  // Show a loading indicator before the model is initialized.\n  if (!modelManagerUiState.isModelInitialized(model = model)) {\n    Row(\n      modifier = Modifier.fillMaxSize(),\n      verticalAlignment = Alignment.CenterVertically,\n      horizontalArrangement = Arrangement.Center,\n    ) {\n      CircularProgressIndicator(\n        trackColor = MaterialTheme.colorScheme.surfaceVariant,\n        strokeWidth = 3.dp,\n        modifier = Modifier.size(24.dp),\n      )\n    }\n  }\n  // Main UI.\n  else {\n    Box(modifier = Modifier.fillMaxSize()) {\n      Column(\n        modifier =\n          Modifier.padding(\n            bottom =\n              if (WindowInsets.ime.getBottom(LocalDensity.current) == 0) bottomPadding else 12.dp\n          )\n      ) {\n        // A webview to load the game which is written in javascript.\n        Box(contentAlignment = Alignment.BottomCenter, modifier = Modifier.weight(1f)) {\n          AndroidView(\n            modifier = Modifier.fillMaxHeight(),\n            factory = { context ->\n              // WebViewAssetLoader is used to load local assets (like HTML, CSS, JS)\n              // from the application's assets directory into the WebView.\n              val assetLoader =\n                WebViewAssetLoader.Builder()\n                  .addPathHandler(\"/assets/\", WebViewAssetLoader.AssetsPathHandler(context))\n                  .build()\n\n              WebView(context).apply {\n                webViewRef = this\n\n                // Needed to make \"height:100%\" work in body/html style.\n                layoutParams =\n                  ViewGroup.LayoutParams(\n                    ViewGroup.LayoutParams.MATCH_PARENT,\n                    ViewGroup.LayoutParams.MATCH_PARENT,\n                  )\n\n                settings.apply {\n                  javaScriptEnabled = true\n                  domStorageEnabled = true\n                  allowFileAccess = true\n                  // Needed to play the audio in game without user interaction.\n                  mediaPlaybackRequiresUserGesture = false\n                }\n\n                webViewClient =\n                  object : WebViewClient() {\n                    override fun shouldInterceptRequest(\n                      view: WebView?,\n                      request: WebResourceRequest,\n                    ): WebResourceResponse? {\n                      // Check if the URL should be handled by the asset loader\n                      return assetLoader.shouldInterceptRequest(request.url)\n                    }\n\n                    override fun onPageFinished(view: WebView?, url: String?) {\n                      super.onPageFinished(view, url)\n                      Log.d(TAG, \"webview finished loading\")\n\n                      // Show help on first launch.\n                      if (!viewModel.dataStoreRepository.getHasRunTinyGarden()) {\n                        Log.d(TAG, \"First time running Tiny Garden. Showing help screen...\")\n                        viewModel.dataStoreRepository.setHasRunTinyGarden(true)\n                        scope.launch {\n                          delay(1000)\n                          webViewRef\n                            ?.runCatching { evaluateJavascript(\"tinyGarden.showHelp()\", null) }\n                            ?.onFailure { e -> Log.e(TAG, \"$e\") }\n                        }\n                      }\n                    }\n\n                    override fun shouldOverrideUrlLoading(\n                      view: WebView?,\n                      request: WebResourceRequest?,\n                    ): Boolean {\n                      if (request == null) {\n                        return false\n                      }\n\n                      val url = request.url.toString()\n\n                      // Check if the URL should be loaded internally (e.g., local assets)\n                      if (url.startsWith(ASSETS_BASE_URL)) {\n                        // Return false to let the WebView load the URL internally\n                        return false\n                      }\n\n                      // If it's an external URL, launch an Android Intent to open it\n                      // in the system's default browser.\n                      try {\n                        val intent = Intent(Intent.ACTION_VIEW, url.toUri())\n                        view?.context?.startActivity(intent)\n                      } catch (e: Exception) {\n                        Log.e(TAG, \"Could not open external URL: $url\", e)\n                      }\n\n                      // Return true to signal that we have handled the URL loading and\n                      // the WebView should NOT load it internally.\n                      return true\n                    }\n                  }\n\n                webChromeClient =\n                  object : WebChromeClient() {\n                    // Log console messages.\n                    override fun onConsoleMessage(consoleMessage: ConsoleMessage?): Boolean {\n                      Log.d(\n                        TAG,\n                        \"${consoleMessage?.message()} -- From line ${consoleMessage?.lineNumber()} of ${consoleMessage?.sourceId()}\",\n                      )\n                      return super.onConsoleMessage(consoleMessage)\n                    }\n                  }\n\n                // Load page.\n                //\n                // http://appassets.androidplatform.net' is the recommended, reserved domain.\n                var url = \"$ASSETS_BASE_URL/assets/tinygarden/index.html\"\n                if (!viewModel.dataStoreRepository.getHasRunTinyGarden()) {\n                  Log.d(TAG, \"First time running Tiny Garden. Showing tutorial screen...\")\n                  viewModel.dataStoreRepository.setHasRunTinyGarden(true)\n                  url = \"$url?tutorial=1\"\n                }\n                loadUrl(url)\n              }\n            },\n          )\n\n          SnackbarHost(hostState = snackbarHostState, modifier = Modifier.padding(bottom = 12.dp))\n        }\n\n        // Text and voice input.\n        Row(\n          modifier = Modifier.fillMaxWidth().padding(top = 12.dp),\n          verticalAlignment = Alignment.CenterVertically,\n          horizontalArrangement = Arrangement.spacedBy(4.dp),\n        ) {\n          TextAndVoiceInput(\n            task = task,\n            processing = uiState.processing,\n            holdToDictateViewModel = holdToDictateViewModel,\n            modifier = Modifier.padding(start = 16.dp).weight(1f),\n            onDone = { text -> processInstructionText(text = text) },\n            onAmplitudeChanged = { curAmplitude = it },\n            clearTextTrigger = clearTextTrigger,\n            defaultTextInputMode = true,\n          )\n          Box(modifier = Modifier.size(48.dp), contentAlignment = Alignment.Center) {\n            if (uiState.processing) {\n              CircularProgressIndicator(\n                trackColor = MaterialTheme.colorScheme.surfaceVariant,\n                strokeWidth = 3.dp,\n                modifier = Modifier.padding(end = 8.dp).size(24.dp),\n              )\n            } else {\n              IconButton(\n                onClick = { showConversationHistoryPanel = true },\n                modifier = Modifier.padding(end = 8.dp),\n              ) {\n                Icon(\n                  imageVector = Icons.Outlined.History,\n                  contentDescription = stringResource(R.string.cd_more_options),\n                )\n              }\n            }\n          }\n        }\n      }\n\n      // Show an overlay during speech recognition.\n      AnimatedVisibility(\n        holdToDictateUiState.recognizing,\n        enter = fadeIn(animationSpec = tween(durationMillis = 150, easing = FastOutSlowInEasing)),\n        exit =\n          fadeOut(\n            animationSpec =\n              tween(durationMillis = 100, easing = FastOutSlowInEasing, delayMillis = 300)\n          ),\n      ) {\n        VoiceRecognizerOverlay(\n          task = task,\n          viewModel = holdToDictateViewModel,\n          curAmplitude = curAmplitude,\n          bottomPadding = bottomPadding,\n        )\n      }\n\n      // Conversation history panel.\n      AnimatedVisibility(\n        showConversationHistoryPanel,\n        enter = slideInVertically { fullHeight -> fullHeight },\n        exit = slideOutVertically { fullHeight -> fullHeight },\n      ) {\n        ConversationHistoryPanel(\n          task = task,\n          bottomPadding = bottomPadding,\n          viewModel = viewModel,\n          onDismiss = { showConversationHistoryPanel = false },\n        )\n      }\n    }\n  }\n\n  if (showErrorDialog) {\n    AlertDialog(\n      title = { Text(stringResource(R.string.error)) },\n      text = {\n        Column(verticalArrangement = Arrangement.spacedBy(8.dp)) {\n          Text(errorDialogContent, style = MaterialTheme.typography.bodyMedium)\n          Text(\n            stringResource(R.string.reset_note),\n            style = MaterialTheme.typography.labelMedium,\n            color = MaterialTheme.customColors.warningTextColor,\n          )\n        }\n      },\n      onDismissRequest = {\n        showErrorDialog = false\n        errorDialogContent = \"\"\n      },\n      dismissButton = {\n        TextButton(\n          onClick = {\n            showErrorDialog = false\n            errorDialogContent = \"\"\n          }\n        ) {\n          Text(stringResource(R.string.cancel))\n        }\n      },\n      confirmButton = {\n        Button(\n          onClick = {\n            showErrorDialog = false\n            errorDialogContent = \"\"\n\n            viewModel.resetEngine(\n              context = context,\n              model = model,\n              tools = tools,\n              onError = {\n                errorDialogContent = it\n                showErrorDialog = true\n              },\n            )\n          },\n          colors = ButtonDefaults.buttonColors(containerColor = taskColor),\n        ) {\n          Text(stringResource(R.string.reset), color = Color.White)\n        }\n      },\n    )\n  }\n}\n\n/** Returns the SHA-256 hash of the given string as a base64 encoded string. */\nprivate fun String.sha256(): String {\n  val inputBytes = this.toByteArray()\n  return try {\n    val sha256 = MessageDigest.getInstance(\"SHA-256\")\n    val digest = sha256.digest(inputBytes)\n    BaseEncoding.base64().encode(digest)\n  } catch (e: Exception) {\n    e.printStackTrace()\n    \"\"\n  }\n}\n"
  },
  {
    "path": "Android/src/app/src/main/java/com/google/ai/edge/gallery/customtasks/tinygarden/TinyGardenTask.kt",
    "content": "/*\n * Copyright 2025 Google LLC\n *\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n *     http://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\npackage com.google.ai.edge.gallery.customtasks.tinygarden\n\nimport android.content.Context\nimport androidx.compose.material.icons.Icons\nimport androidx.compose.material.icons.outlined.LocalFlorist\nimport androidx.compose.runtime.Composable\nimport com.google.ai.edge.gallery.R\nimport com.google.ai.edge.gallery.customtasks.common.CustomTask\nimport com.google.ai.edge.gallery.customtasks.common.CustomTaskData\nimport com.google.ai.edge.gallery.data.BuiltInTaskId\nimport com.google.ai.edge.gallery.data.Category\nimport com.google.ai.edge.gallery.data.Model\nimport com.google.ai.edge.gallery.data.Task\nimport com.google.ai.edge.gallery.ui.llmchat.LlmChatModelHelper\nimport com.google.ai.edge.litertlm.Contents\nimport javax.inject.Inject\nimport kotlinx.coroutines.CoroutineScope\nimport kotlinx.coroutines.channels.Channel\nimport kotlinx.coroutines.flow.receiveAsFlow\n\nprivate const val SYSTEM_PROMPT =\n  \"\"\"You are an assistant helping the user play a game about gardening.\n\nThe environment is a 3x3 grid of garden plots. The plots are numbered 1 through 9.\n\n**Garden Plot Layout**:\n\n- Row 1: Plots 1, 2, 3 (top row)\n- Row 2: Plots 4, 5, 6 (middle row)\n- Row 3: Plots 7, 8, 9 (bottom row)\n\nHelp the user plant seeds, water plots, and harvest flowers.\n\nThere are 4 kinds of seeds you can plant:\n\n1. sunflower\n2. daisy\n3. rose\n4. special (edge gallery, special, secret)\n\nPlot 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.\n\nTips:\n\n- \"\"top row\"\" has plots 1, 2, 3.\n- \"\"middle row\"\" has plots 4, 5, 6.\n- \"\"bottom row\"\" has plots 7, 8, 9.\n- \"\"left column\"\" has plots 1, 4, 7.\n- \"\"middle column\"\" has plots 2, 5, 8.\n- \"\"right column\"\" has plots 3, 6, 9.\n\"\"\"\n\n/** A custom task that demonstrates how to use FunctionGemma to play a simple gardening game. */\nclass TinyGardenTask @Inject constructor() : CustomTask {\n  private val _updateChannel = Channel<TinyGardenCommand>(Channel.BUFFERED)\n  private val commandFlow = _updateChannel.receiveAsFlow()\n  private val tools =\n    listOf(\n      TinyGardenTools(\n        onFunctionCalled = {\n          val unused = _updateChannel.trySend(it)\n        }\n      )\n    )\n\n  override val task =\n    Task(\n      id = BuiltInTaskId.LLM_TINY_GARDEN,\n      label = \"Tiny Garden\",\n      description =\n        \"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.\",\n      docUrl = \"https://github.com/google-ai-edge/LiteRT-LM/blob/main/kotlin/README.md\",\n      sourceCodeUrl =\n        \"https://github.com/google-ai-edge/gallery/blob/main/Android/src/app/src/main/java/com/google/ai/edge/gallery/customtasks/tinygarden\",\n      category = Category.LLM,\n      icon = Icons.Outlined.LocalFlorist,\n      agentNameRes = R.string.chat_agent_agent_name,\n      models = mutableListOf(),\n      handleModelConfigChangesInTask = true,\n      experimental = true,\n    )\n\n  override fun initializeModelFn(\n    context: Context,\n    coroutineScope: CoroutineScope,\n    model: Model,\n    onDone: (String) -> Unit,\n  ) {\n    clearQueue()\n    LlmChatModelHelper.initialize(\n      context = context,\n      model = model,\n      supportImage = false,\n      supportAudio = false,\n      onDone = onDone,\n      systemInstruction = Contents.of(getTinyGardenSystemPrompt()),\n      tools = tools,\n      enableConversationConstrainedDecoding = true,\n    )\n  }\n\n  override fun cleanUpModelFn(\n    context: Context,\n    coroutineScope: CoroutineScope,\n    model: Model,\n    onDone: () -> Unit,\n  ) {\n    clearQueue()\n    LlmChatModelHelper.cleanUp(model = model, onDone = onDone)\n  }\n\n  @Composable\n  override fun MainScreen(data: Any) {\n    val customTaskData = data as CustomTaskData\n    TinyGardenScreen(\n      task = task,\n      modelManagerViewModel = customTaskData.modelManagerViewModel,\n      tools = tools,\n      bottomPadding = customTaskData.bottomPadding,\n      commandFlow = commandFlow,\n      setAppBarControlsDisabled = customTaskData.setAppBarControlsDisabled,\n      setTopBarVisible = customTaskData.setTopBarVisible,\n    )\n  }\n\n  private fun clearQueue() {\n    while (_updateChannel.tryReceive().isSuccess) {}\n  }\n}\n\nfun getTinyGardenSystemPrompt(\n  prevSeed: String = \"\",\n  prevPlots: String = \"\",\n  prevAction: String = \"\",\n): String {\n  val parts = mutableListOf(SYSTEM_PROMPT)\n  if (prevSeed.isNotEmpty() || prevPlots.isNotEmpty() || prevAction.isNotEmpty()) {\n    parts.add(\"Here is the info about user's last action:\")\n  }\n  if (prevSeed.isNotEmpty()) {\n    parts.add(\"- seed: $prevSeed\")\n  }\n  if (prevPlots.isNotEmpty()) {\n    parts.add(\"- plots: $prevPlots\")\n  }\n  if (prevAction.isNotEmpty()) {\n    parts.add(\"- action: $prevAction\")\n  }\n  return parts.joinToString(separator = \"\\n\")\n}\n"
  },
  {
    "path": "Android/src/app/src/main/java/com/google/ai/edge/gallery/customtasks/tinygarden/TinyGardenTaskModule.kt",
    "content": "/*\n * Copyright 2025 Google LLC\n *\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n *     http://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\npackage com.google.ai.edge.gallery.customtasks.tinygarden\n\nimport com.google.ai.edge.gallery.customtasks.common.CustomTask\nimport dagger.Module\nimport dagger.Provides\nimport dagger.hilt.InstallIn\nimport dagger.hilt.components.SingletonComponent\nimport dagger.multibindings.IntoSet\n\n@Module\n@InstallIn(SingletonComponent::class)\ninternal object TinyGardenTaskModule {\n  @Provides\n  @IntoSet\n  fun provideTask(): CustomTask {\n    return TinyGardenTask()\n  }\n}\n"
  },
  {
    "path": "Android/src/app/src/main/java/com/google/ai/edge/gallery/customtasks/tinygarden/TinyGardenTools.kt",
    "content": "/*\n * Copyright 2025 Google LLC\n *\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n *     http://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\npackage com.google.ai.edge.gallery.customtasks.tinygarden\n\nimport android.util.Log\nimport com.google.ai.edge.litertlm.Tool\nimport com.google.ai.edge.litertlm.ToolParam\n\nprivate const val TAG = \"AGTGTools\"\n\n/** The items that can be used in the Tiny Garden game. */\nenum class TinyGardenItem(val label: String) {\n  SUNFLOWER(label = \"sunflower\"),\n  DAISY(label = \"daisy\"),\n  ROSE(label = \"rose\"),\n  SPECIAL(label = \"secret\"),\n  WATERING_CAN(label = \"water\"),\n  SCYTHE(label = \"harvest\"),\n}\n\n/** A command to be sent to the Tiny Garden game. */\ndata class TinyGardenCommand(\n  // This is 1-based.\n  val item: Int,\n  val plots: List<Int>,\n  val ts: Long = System.currentTimeMillis(),\n)\n\n/**\n * A class that defines the tools available to the Tiny Garden game.\n *\n * Instructions:\n * https://github.com/google-ai-edge/LiteRT-LM/blob/main/kotlin/README.md#6-defining-and-using-tools\n */\nclass TinyGardenTools(val onFunctionCalled: (command: TinyGardenCommand) -> Unit) {\n\n  /** Waters one or more garden plots. */\n  @Tool(description = \"Water one or more garden plots.\")\n  fun waterPlots(\n    @ToolParam(description = \"The IDs of the plots to water.\") plots: List<Int>\n  ): Map<String, Any> {\n    Log.d(TAG, \"waterPlots. Plots=$plots\")\n\n    onFunctionCalled(\n      TinyGardenCommand(item = TinyGardenItem.WATERING_CAN.ordinal + 1, plots = plots)\n    )\n\n    // Return a response object to the model confirming the action.\n    return mapOf(\"result\" to \"success\", \"plots\" to plots)\n  }\n\n  /** Plants a seed in one or more garden plots. */\n  @Tool(description = \"Plant a seed in one or more garden plots.\")\n  fun plantSeed(\n    @ToolParam(description = \"The name of the seed to plant.\") seed: String,\n    @ToolParam(description = \"The IDs of the plots to plant a seed in.\") plots: List<Int>,\n  ): Map<String, Any> {\n    Log.d(TAG, \"plantSeed. seed: $seed, plots; $plots\")\n\n    val itemId =\n      when (seed.lowercase()) {\n        \"sunflower\" -> TinyGardenItem.SUNFLOWER.ordinal\n        \"daisy\" -> TinyGardenItem.DAISY.ordinal\n        \"rose\" -> TinyGardenItem.ROSE.ordinal\n        \"special\",\n        \"edge gallery\",\n        \"secret\" -> TinyGardenItem.SPECIAL.ordinal\n        else -> -1\n      } + 1\n    if (itemId > 0) {\n      onFunctionCalled(TinyGardenCommand(item = itemId, plots = plots))\n    }\n\n    // Return a response object to the model confirming the action\n    return mapOf(\"result\" to \"success\", \"seed\" to seed, \"plots\" to plots)\n  }\n\n  /** Harvests one or more garden plots. */\n  @Tool(description = \"Harvest one or more garden plots.\")\n  fun harvestPlots(\n    @ToolParam(description = \"The IDs of the plots to harvest.\") plots: List<Int>\n  ): Map<String, Any> {\n    Log.d(TAG, \"harvestPlots. Plots=$plots\")\n\n    onFunctionCalled(TinyGardenCommand(item = TinyGardenItem.SCYTHE.ordinal + 1, plots = plots))\n\n    // Return a response object to the model confirming the action.\n    return mapOf(\"result\" to \"success\", \"plots\" to plots)\n  }\n}\n"
  },
  {
    "path": "Android/src/app/src/main/java/com/google/ai/edge/gallery/customtasks/tinygarden/TinyGardenViewModel.kt",
    "content": "/*\n * Copyright 2025 Google LLC\n *\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n *     http://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\n\npackage com.google.ai.edge.gallery.customtasks.tinygarden\n\nimport android.content.Context\nimport android.util.Log\nimport androidx.lifecycle.ViewModel\nimport androidx.lifecycle.viewModelScope\nimport com.google.ai.edge.gallery.R\nimport com.google.ai.edge.gallery.data.DataStoreRepository\nimport com.google.ai.edge.gallery.data.Model\nimport com.google.ai.edge.gallery.ui.common.chat.ChatMessage\nimport com.google.ai.edge.gallery.ui.common.chat.ChatMessageText\nimport com.google.ai.edge.gallery.ui.common.chat.ChatMessageWarning\nimport com.google.ai.edge.gallery.ui.common.chat.ChatSide\nimport com.google.ai.edge.gallery.ui.llmchat.LlmChatModelHelper\nimport com.google.ai.edge.gallery.ui.llmchat.LlmModelInstance\nimport com.google.ai.edge.litertlm.Content\nimport com.google.ai.edge.litertlm.Contents\nimport dagger.hilt.android.lifecycle.HiltViewModel\nimport dagger.hilt.android.qualifiers.ApplicationContext\nimport javax.inject.Inject\nimport kotlinx.coroutines.Dispatchers\nimport kotlinx.coroutines.flow.MutableStateFlow\nimport kotlinx.coroutines.flow.asStateFlow\nimport kotlinx.coroutines.flow.first\nimport kotlinx.coroutines.flow.update\nimport kotlinx.coroutines.launch\n\nprivate const val TAG = \"AGTGViewModel\"\n\n/** The UI state of the task. */\ndata class TinyGardenUiState(\n  // Whether the app is processing the user input.\n  val processing: Boolean = false,\n\n  // Whether the app is resetting the engine (without resetting the game).\n  val resettingEngine: Boolean = false,\n\n  // The messages in the conversation history.\n  val messages: List<ChatMessage> = listOf(),\n\n  // The number of turns.\n  val numTurns: Int = 0,\n)\n\n/** The ViewModel of the task screen. */\n@HiltViewModel\nclass TinyGardenViewModel\n@Inject\nconstructor(\n  @ApplicationContext private val context: Context,\n  val dataStoreRepository: DataStoreRepository,\n) : ViewModel() {\n  protected val _uiState = MutableStateFlow(TinyGardenUiState())\n  val uiState = _uiState.asStateFlow()\n\n  private val _isResettingConversation = MutableStateFlow(false)\n  private val isResettingConversation = _isResettingConversation.asStateFlow()\n\n  /**\n   * Sends the user instruction to the model and processes the response.\n   *\n   * The tools defined in [TinyGardenTools] will be invoked during the process.\n   */\n  fun getCommand(\n    model: Model,\n    instructionText: String,\n    onDone: (String) -> Unit,\n    onError: (String) -> Unit,\n  ) {\n    if (model.instance == null) {\n      setProcessing(processing = false)\n      return\n    }\n\n    // Count turn.\n    incrementNumTurns()\n    Log.d(TAG, \"Turn #: ${uiState.value.numTurns}\")\n\n    // Add user prompt to history.\n    this.addMessage(message = ChatMessageText(content = instructionText, side = ChatSide.USER))\n\n    viewModelScope.launch(Dispatchers.Default) {\n      Log.d(TAG, \"Start processing user instruction: '$instructionText'\")\n      setProcessing(processing = true)\n\n      // Wait until the conversation is NOT resetting.\n      Log.d(TAG, \"Waiting for any ongoing conversation reset to be done...\")\n      isResettingConversation.first { !it }\n      Log.d(TAG, \"Done waiting. Start inference.\")\n\n      val instance = model.instance as LlmModelInstance\n      val conversation = instance.conversation\n      val contents = mutableListOf<Content>()\n      if (instructionText.trim().isNotEmpty()) {\n        contents.add(Content.Text(instructionText))\n      }\n\n      try {\n        val responseMessage = conversation.sendMessage(Contents.of(contents))\n        val response = responseMessage.toString()\n        Log.d(TAG, \"Done processing user instruction. Response: $response\")\n        onDone(response)\n      } catch (e: Exception) {\n        Log.e(TAG, \"Failed to run inference\", e)\n        onError(e.message ?: context.getString(R.string.unknown_error))\n      } finally {\n        setProcessing(processing = false)\n      }\n    }\n  }\n\n  fun addMessage(message: ChatMessage) {\n    val newMessages = _uiState.value.messages.toMutableList()\n    newMessages.add(message)\n    _uiState.update { _uiState.value.copy(messages = newMessages) }\n  }\n\n  fun clearMessages() {\n    _uiState.update { _uiState.value.copy(messages = listOf()) }\n  }\n\n  fun setProcessing(processing: Boolean) {\n    _uiState.update { uiState.value.copy(processing = processing) }\n  }\n\n  fun setResettingEngine(resetting: Boolean) {\n    _uiState.update { uiState.value.copy(resettingEngine = resetting) }\n  }\n\n  fun incrementNumTurns() {\n    _uiState.update { uiState.value.copy(numTurns = uiState.value.numTurns + 1) }\n  }\n\n  fun resetNumTurns() {\n    _uiState.update { uiState.value.copy(numTurns = 0) }\n  }\n\n  fun resetEngine(\n    context: Context,\n    model: Model,\n    tools: List<TinyGardenTools>,\n    onError: (error: String) -> Unit,\n  ) {\n    resetNumTurns()\n\n    viewModelScope.launch(Dispatchers.Default) {\n      setResettingEngine(resetting = true)\n      LlmChatModelHelper.cleanUp(\n        model = model,\n        onDone = {\n          LlmChatModelHelper.initialize(\n            context = context,\n            model = model,\n            supportImage = false,\n            supportAudio = false,\n            onDone = { error ->\n              setResettingEngine(resetting = false)\n              if (error.isNotEmpty()) {\n                onError(error)\n              }\n              addMessage(\n                message =\n                  ChatMessageWarning(content = context.getString(R.string.engin_reset_message))\n              )\n            },\n            systemInstruction = Contents.of(getTinyGardenSystemPrompt()),\n            tools = tools,\n            enableConversationConstrainedDecoding = true,\n          )\n        },\n      )\n    }\n  }\n\n  fun resetConversation(\n    model: Model,\n    tools: List<TinyGardenTools>,\n    prevSeed: String,\n    prevPlots: String,\n    prevAction: String,\n  ) {\n    resetNumTurns()\n\n    viewModelScope.launch(Dispatchers.Default) {\n      _isResettingConversation.value = true\n      val curSystemPrompt =\n        getTinyGardenSystemPrompt(\n          prevSeed = prevSeed,\n          prevPlots = prevPlots,\n          prevAction = prevAction,\n        )\n      Log.d(TAG, \"Current system prompt:\\n$curSystemPrompt\")\n      LlmChatModelHelper.resetConversation(\n        model = model,\n        supportImage = false,\n        supportAudio = false,\n        systemInstruction = Contents.of(curSystemPrompt),\n        tools = tools,\n        enableConversationConstrainedDecoding = true,\n      )\n      _isResettingConversation.value = false\n      addMessage(\n        message =\n          ChatMessageWarning(content = context.getString(R.string.conversation_reset_message))\n      )\n    }\n  }\n}\n"
  },
  {
    "path": "Android/src/app/src/main/java/com/google/ai/edge/gallery/data/AppBarAction.kt",
    "content": "/*\n * Copyright 2025 Google LLC\n *\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n *     http://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\n\npackage com.google.ai.edge.gallery.data\n\n/** Possible action for app bar. */\nenum class AppBarActionType {\n  NO_ACTION,\n  APP_SETTING,\n  DOWNLOAD_MANAGER,\n  NAVIGATE_UP,\n  MENU,\n}\n\nclass AppBarAction(val actionType: AppBarActionType, val actionFn: () -> Unit)\n"
  },
  {
    "path": "Android/src/app/src/main/java/com/google/ai/edge/gallery/data/Categories.kt",
    "content": "/*\n * Copyright 2025 Google LLC\n *\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n *     http://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\n\npackage com.google.ai.edge.gallery.data\n\nimport androidx.annotation.StringRes\nimport com.google.ai.edge.gallery.R\n\n/**\n * Stores basic info about a Category\n *\n * A category is a tab on the home page which contains a list of tasks. Category is set through\n * Task.\n */\ndata class CategoryInfo(\n  // The id of the category.\n  val id: String,\n\n  // The string resource id of the label of the resource, for display purpose.\n  @StringRes val labelStringRes: Int? = null,\n\n  // The string label. It takes precedence over labelStringRes above.\n  val label: String? = null,\n)\n\n/** Pre-defined categories. */\nobject Category {\n  val LLM = CategoryInfo(id = \"llm\", labelStringRes = R.string.category_llm)\n  val CLASSICAL_ML = CategoryInfo(id = \"classical_ml\", labelStringRes = R.string.category_llm)\n  val EXPERIMENTAL =\n    CategoryInfo(id = \"experimental\", labelStringRes = R.string.category_experimental)\n}\n"
  },
  {
    "path": "Android/src/app/src/main/java/com/google/ai/edge/gallery/data/Config.kt",
    "content": "/*\n * Copyright 2025 Google LLC\n *\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n *     http://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\n\npackage com.google.ai.edge.gallery.data\n\nimport androidx.annotation.StringRes\nimport kotlin.math.abs\n\n/**\n * The types of configuration editors available.\n *\n * This enum defines the different UI components used to edit configuration values. Each type\n * corresponds to a specific editor widget, such as a slider or a switch.\n */\nenum class ConfigEditorType {\n  LABEL,\n  NUMBER_SLIDER,\n  BOOLEAN_SWITCH,\n  SEGMENTED_BUTTON,\n  BOTTOMSHEET_SELECTOR,\n}\n\n/** The data types of configuration values. */\nenum class ValueType {\n  INT,\n  FLOAT,\n  DOUBLE,\n  STRING,\n  BOOLEAN,\n}\n\ndata class ConfigKey(val id: String, val label: String)\n\nobject ConfigKeys {\n  val MAX_TOKENS = ConfigKey(\"max_tokens\", \"Max tokens\")\n  val TOPK = ConfigKey(\"topk\", \"TopK\")\n  val TOPP = ConfigKey(\"topp\", \"TopP\")\n  val TEMPERATURE = ConfigKey(\"temperature\", \"Temperature\")\n  val DEFAULT_MAX_TOKENS = ConfigKey(\"default_max_tokens\", \"Default max tokens\")\n  val DEFAULT_TOPK = ConfigKey(\"default_topk\", \"Default TopK\")\n  val DEFAULT_TOPP = ConfigKey(\"default_topp\", \"Default TopP\")\n  val DEFAULT_TEMPERATURE = ConfigKey(\"default_temperature\", \"Default temperature\")\n  val SUPPORT_IMAGE = ConfigKey(\"support_image\", \"Support image\")\n  val SUPPORT_AUDIO = ConfigKey(\"support_audio\", \"Support audio\")\n  val SUPPORT_TINY_GARDEN = ConfigKey(\"support_tiny_garden\", \"Support tiny garden\")\n  val SUPPORT_MOBILE_ACTIONS = ConfigKey(\"support_mobile_actions\", \"Support mobile actions\")\n  val MAX_RESULT_COUNT = ConfigKey(\"max_result_count\", \"Max result count\")\n  val USE_GPU = ConfigKey(\"use_gpu\", \"Use GPU\")\n  val ACCELERATOR = ConfigKey(\"accelerator\", \"Accelerator\")\n  val VISION_ACCELERATOR = ConfigKey(\"vision_accelerator\", \"Vision accelerator\")\n  val COMPATIBLE_ACCELERATORS = ConfigKey(\"compatible_accelerators\", \"Compatible accelerators\")\n  val WARM_UP_ITERATIONS = ConfigKey(\"warm_up_iterations\", \"Warm up iterations\")\n  val BENCHMARK_ITERATIONS = ConfigKey(\"benchmark_iterations\", \"Benchmark iterations\")\n  val ITERATIONS = ConfigKey(\"iterations\", \"Iterations\")\n  val THEME = ConfigKey(\"theme\", \"Theme\")\n  val NAME = ConfigKey(\"name\", \"Name\")\n  val MODEL_TYPE = ConfigKey(\"model_type\", \"Model type\")\n  val MODEL = ConfigKey(\"model\", \"Model\")\n  val RESET_CONVERSATION_TURN_COUNT =\n    ConfigKey(\"reset_conversation_turn_count\", \"Number of turns before the conversation resets\")\n  val PREFILL_TOKENS = ConfigKey(\"prefill_tokens\", \"Prefill tokens\")\n  val DECODE_TOKENS = ConfigKey(\"decode_tokens\", \"Decode tokens\")\n  val NUMBER_OF_RUNS = ConfigKey(\"number_of_runs\", \"Number of runs\")\n}\n\n/**\n * Base class for configuration settings.\n *\n * @param type The type of configuration editor.\n * @param key The unique key for the configuration setting.\n * @param defaultValue The default value for the configuration setting.\n * @param valueType The data type of the configuration value.\n * @param needReinitialization Indicates whether the model needs to be reinitialized after changing\n *   this config.\n */\nopen class Config(\n  val type: ConfigEditorType,\n  open val key: ConfigKey,\n  open val defaultValue: Any,\n  open val valueType: ValueType,\n  // Changes on any configs with this field set to true will automatically trigger a model\n  // re-initialization.\n  open val needReinitialization: Boolean = true,\n)\n\n/** Configuration setting for a label. */\nclass LabelConfig(override val key: ConfigKey, override val defaultValue: String = \"\") :\n  Config(\n    type = ConfigEditorType.LABEL,\n    key = key,\n    defaultValue = defaultValue,\n    valueType = ValueType.STRING,\n  )\n\n/**\n * Configuration setting for a number slider.\n *\n * @param sliderMin The minimum value of the slider.\n * @param sliderMax The maximum value of the slider.\n */\nclass NumberSliderConfig(\n  override val key: ConfigKey,\n  val sliderMin: Float,\n  val sliderMax: Float,\n  override val defaultValue: Float,\n  override val valueType: ValueType,\n  override val needReinitialization: Boolean = true,\n) :\n  Config(\n    type = ConfigEditorType.NUMBER_SLIDER,\n    key = key,\n    defaultValue = defaultValue,\n    valueType = valueType,\n  )\n\n/** Configuration setting for a boolean switch. */\nclass BooleanSwitchConfig(\n  override val key: ConfigKey,\n  override val defaultValue: Boolean,\n  override val needReinitialization: Boolean = true,\n) :\n  Config(\n    type = ConfigEditorType.BOOLEAN_SWITCH,\n    key = key,\n    defaultValue = defaultValue,\n    valueType = ValueType.BOOLEAN,\n  )\n\n/** Configuration setting for a segmented button. */\nclass SegmentedButtonConfig(\n  override val key: ConfigKey,\n  override val defaultValue: String,\n  val options: List<String>,\n  val allowMultiple: Boolean = false,\n) :\n  Config(\n    type = ConfigEditorType.SEGMENTED_BUTTON,\n    key = key,\n    defaultValue = defaultValue,\n    // The emitted value will be comma-separated labels when allowMultiple=true.\n    valueType = ValueType.STRING,\n  )\n\n/** Configuration setting for a bottom sheet selector. */\nclass BottomSheetSelectorConfig(\n  override val key: ConfigKey,\n  override val defaultValue: String,\n  val options: List<BottomSheetSelectorItem>,\n  @StringRes val bottomSheetTitleResId: Int? = null,\n) :\n  Config(\n    type = ConfigEditorType.BOTTOMSHEET_SELECTOR,\n    key = key,\n    defaultValue = defaultValue,\n    valueType = ValueType.STRING,\n  )\n\ndata class BottomSheetSelectorItem(val label: String)\n\nfun convertValueToTargetType(value: Any, valueType: ValueType): Any {\n  return when (valueType) {\n    ValueType.INT ->\n      when (value) {\n        is Int -> value\n        is Float -> value.toInt()\n        is Double -> value.toInt()\n        is String -> value.toIntOrNull() ?: \"\"\n        is Boolean -> if (value) 1 else 0\n        else -> \"\"\n      }\n\n    ValueType.FLOAT ->\n      when (value) {\n        is Int -> value.toFloat()\n        is Float -> value\n        is Double -> value.toFloat()\n        is String -> value.toFloatOrNull() ?: \"\"\n        is Boolean -> if (value) 1f else 0f\n        else -> \"\"\n      }\n\n    ValueType.DOUBLE ->\n      when (value) {\n        is Int -> value.toDouble()\n        is Float -> value.toDouble()\n        is Double -> value\n        is String -> value.toDoubleOrNull() ?: \"\"\n        is Boolean -> if (value) 1.0 else 0.0\n        else -> \"\"\n      }\n\n    ValueType.BOOLEAN ->\n      when (value) {\n        is Int -> value == 0\n        is Boolean -> value\n        is Float -> abs(value) > 1e-6\n        is Double -> abs(value) > 1e-6\n        is String -> value.isNotEmpty()\n        else -> false\n      }\n\n    ValueType.STRING -> value.toString()\n  }\n}\n\nfun createLlmChatConfigs(\n  defaultMaxToken: Int = DEFAULT_MAX_TOKEN,\n  defaultTopK: Int = DEFAULT_TOPK,\n  defaultTopP: Float = DEFAULT_TOPP,\n  defaultTemperature: Float = DEFAULT_TEMPERATURE,\n  accelerators: List<Accelerator> = DEFAULT_ACCELERATORS,\n): List<Config> {\n  return listOf(\n    LabelConfig(key = ConfigKeys.MAX_TOKENS, defaultValue = \"$defaultMaxToken\"),\n    NumberSliderConfig(\n      key = ConfigKeys.TOPK,\n      sliderMin = 5f,\n      sliderMax = 100f,\n      defaultValue = defaultTopK.toFloat(),\n      valueType = ValueType.INT,\n    ),\n    NumberSliderConfig(\n      key = ConfigKeys.TOPP,\n      sliderMin = 0.0f,\n      sliderMax = 1.0f,\n      defaultValue = defaultTopP,\n      valueType = ValueType.FLOAT,\n    ),\n    NumberSliderConfig(\n      key = ConfigKeys.TEMPERATURE,\n      sliderMin = 0.0f,\n      sliderMax = 2.0f,\n      defaultValue = defaultTemperature,\n      valueType = ValueType.FLOAT,\n    ),\n    SegmentedButtonConfig(\n      key = ConfigKeys.ACCELERATOR,\n      defaultValue = accelerators[0].label,\n      options = accelerators.map { it.label },\n    ),\n  )\n}\n\n/**\n * Creates the configuration settings for an LLM model that only supports NPU.\n *\n * For now NPU models don't support setting topK, topP, and temperature.\n */\nfun createLlmChatConfigsForNpuModel(\n  defaultMaxToken: Int = DEFAULT_MAX_TOKEN,\n  accelerators: List<Accelerator> = DEFAULT_ACCELERATORS,\n): List<Config> {\n  return listOf(\n    LabelConfig(key = ConfigKeys.MAX_TOKENS, defaultValue = \"$defaultMaxToken\"),\n    SegmentedButtonConfig(\n      key = ConfigKeys.ACCELERATOR,\n      defaultValue = accelerators[0].label,\n      options = accelerators.map { it.label },\n    ),\n  )\n}\n\nfun getConfigValueString(value: Any, config: Config): String {\n  var strNewValue = \"$value\"\n  if (config.valueType == ValueType.FLOAT) {\n    strNewValue = \"%.2f\".format(value)\n  }\n  return strNewValue\n}\n"
  },
  {
    "path": "Android/src/app/src/main/java/com/google/ai/edge/gallery/data/ConfigValue.kt",
    "content": "/*\n * Copyright 2025 Google LLC\n *\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n *     http://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\n\npackage com.google.ai.edge.gallery.data\n\n// @Serializable(with = ConfigValueSerializer::class)\nsealed class ConfigValue {\n  // @Serializable\n  data class IntValue(val value: Int) : ConfigValue()\n\n  // @Serializable\n  data class FloatValue(val value: Float) : ConfigValue()\n\n  // @Serializable\n  data class StringValue(val value: String) : ConfigValue()\n}\n\n// /**\n//  * Custom serializer for the ConfigValue class.\n//  *\n//  * This object implements the KSerializer interface to provide custom serialization and\n//  * deserialization logic for the ConfigValue class. It handles different types of ConfigValue\n//  * (IntValue, FloatValue, StringValue) and supports JSON format.\n//  */\n// object ConfigValueSerializer : KSerializer<ConfigValue> {\n//   override val descriptor: SerialDescriptor = buildClassSerialDescriptor(\"ConfigValue\")\n\n//   override fun serialize(encoder: Encoder, value: ConfigValue) {\n//     when (value) {\n//       is ConfigValue.IntValue -> encoder.encodeInt(value.value)\n//       is ConfigValue.FloatValue -> encoder.encodeFloat(value.value)\n//       is ConfigValue.StringValue -> encoder.encodeString(value.value)\n//     }\n//   }\n\n//   override fun deserialize(decoder: Decoder): ConfigValue {\n//     val input =\n//       decoder as? JsonDecoder\n//         ?: throw SerializationException(\"This serializer only works with Json\")\n//     return when (val element = input.decodeJsonElement()) {\n//       is JsonPrimitive -> {\n//         if (element.isString) {\n//           ConfigValue.StringValue(element.content)\n//         } else if (element.content.contains('.')) {\n//           ConfigValue.FloatValue(element.content.toFloat())\n//         } else {\n//           ConfigValue.IntValue(element.content.toInt())\n//         }\n//       }\n\n//       else -> throw SerializationException(\"Expected JsonPrimitive\")\n//     }\n//   }\n// }\n\nfun getIntConfigValue(configValue: ConfigValue?, default: Int): Int {\n  if (configValue == null) {\n    return default\n  }\n  return when (configValue) {\n    is ConfigValue.IntValue -> configValue.value\n    is ConfigValue.FloatValue -> configValue.value.toInt()\n    is ConfigValue.StringValue -> 0\n  }\n}\n\nfun getFloatConfigValue(configValue: ConfigValue?, default: Float): Float {\n  if (configValue == null) {\n    return default\n  }\n  return when (configValue) {\n    is ConfigValue.IntValue -> configValue.value.toFloat()\n    is ConfigValue.FloatValue -> configValue.value\n    is ConfigValue.StringValue -> 0f\n  }\n}\n\nfun getStringConfigValue(configValue: ConfigValue?, default: String): String {\n  if (configValue == null) {\n    return default\n  }\n  return when (configValue) {\n    is ConfigValue.IntValue -> \"${configValue.value}\"\n    is ConfigValue.FloatValue -> \"${configValue.value}\"\n    is ConfigValue.StringValue -> configValue.value\n  }\n}\n"
  },
  {
    "path": "Android/src/app/src/main/java/com/google/ai/edge/gallery/data/Consts.kt",
    "content": "/*\n * Copyright 2025 Google LLC\n *\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n *     http://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\n\npackage com.google.ai.edge.gallery.data\n\nimport android.os.Build\nimport androidx.compose.ui.unit.dp\n\n// Keys used to send/receive data to Work.\nconst val KEY_MODEL_URL = \"KEY_MODEL_URL\"\nconst val KEY_MODEL_NAME = \"KEY_MODEL_NAME\"\nconst val KEY_MODEL_COMMIT_HASH = \"KEY_MODEL_COMMIT_HASH\"\nconst val KEY_MODEL_DOWNLOAD_MODEL_DIR = \"KEY_MODEL_DOWNLOAD_MODEL_DIR\"\nconst val KEY_MODEL_DOWNLOAD_FILE_NAME = \"KEY_MODEL_DOWNLOAD_FILE_NAME\"\nconst val KEY_MODEL_TOTAL_BYTES = \"KEY_MODEL_TOTAL_BYTES\"\nconst val KEY_MODEL_DOWNLOAD_RECEIVED_BYTES = \"KEY_MODEL_DOWNLOAD_RECEIVED_BYTES\"\nconst val KEY_MODEL_DOWNLOAD_RATE = \"KEY_MODEL_DOWNLOAD_RATE\"\nconst val KEY_MODEL_DOWNLOAD_REMAINING_MS = \"KEY_MODEL_DOWNLOAD_REMAINING_SECONDS\"\nconst val KEY_MODEL_DOWNLOAD_ERROR_MESSAGE = \"KEY_MODEL_DOWNLOAD_ERROR_MESSAGE\"\nconst val KEY_MODEL_DOWNLOAD_ACCESS_TOKEN = \"KEY_MODEL_DOWNLOAD_ACCESS_TOKEN\"\nconst val KEY_MODEL_EXTRA_DATA_URLS = \"KEY_MODEL_EXTRA_DATA_URLS\"\nconst val KEY_MODEL_EXTRA_DATA_DOWNLOAD_FILE_NAMES = \"KEY_MODEL_EXTRA_DATA_DOWNLOAD_FILE_NAMES\"\nconst val KEY_MODEL_IS_ZIP = \"KEY_MODEL_IS_ZIP\"\nconst val KEY_MODEL_UNZIPPED_DIR = \"KEY_MODEL_UNZIPPED_DIR\"\nconst val KEY_MODEL_START_UNZIPPING = \"KEY_MODEL_START_UNZIPPING\"\n\n// Default values for LLM models.\nconst val DEFAULT_MAX_TOKEN = 1024\nconst val DEFAULT_TOPK = 64\nconst val DEFAULT_TOPP = 0.95f\nconst val DEFAULT_TEMPERATURE = 1.0f\nval DEFAULT_ACCELERATORS = listOf(Accelerator.GPU)\nval DEFAULT_VISION_ACCELERATOR = Accelerator.GPU\n\n// Max number of images allowed in a \"ask image\" session.\nconst val MAX_IMAGE_COUNT = 10\n\n// Max number of audio clip in an \"ask audio\" session.\nconst val MAX_AUDIO_CLIP_COUNT = 1\n\n// Max audio clip duration in seconds.\nconst val MAX_AUDIO_CLIP_DURATION_SEC = 30\n\n// Audio-recording related consts.\nconst val SAMPLE_RATE = 16000\n\n// The size the icon shown under each of the model names in the model list screen.\nval MODEL_INFO_ICON_SIZE = 18.dp\n\n// The extension of the tmp download files.\nconst val TMP_FILE_EXT = \"gallerytmp\"\n\n// Current device's SOC in lowercase.\nval SOC =\n  (if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {\n      Build.SOC_MODEL ?: \"\"\n    } else {\n      \"\"\n    })\n    .lowercase()\n"
  },
  {
    "path": "Android/src/app/src/main/java/com/google/ai/edge/gallery/data/DataStoreRepository.kt",
    "content": "/*\n * Copyright 2025 Google LLC\n *\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n *     http://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\n\npackage com.google.ai.edge.gallery.data\n\nimport androidx.datastore.core.DataStore\nimport com.google.ai.edge.gallery.proto.AccessTokenData\nimport com.google.ai.edge.gallery.proto.BenchmarkResult\nimport com.google.ai.edge.gallery.proto.BenchmarkResults\nimport com.google.ai.edge.gallery.proto.Cutout\nimport com.google.ai.edge.gallery.proto.CutoutCollection\nimport com.google.ai.edge.gallery.proto.ImportedModel\nimport com.google.ai.edge.gallery.proto.Settings\nimport com.google.ai.edge.gallery.proto.Theme\nimport com.google.ai.edge.gallery.proto.UserData\nimport kotlinx.coroutines.flow.first\nimport kotlinx.coroutines.runBlocking\n\n// TODO(b/423700720): Change to async (suspend) functions\ninterface DataStoreRepository {\n  fun saveTextInputHistory(history: List<String>)\n\n  fun readTextInputHistory(): List<String>\n\n  fun saveTheme(theme: Theme)\n\n  fun readTheme(): Theme\n\n  fun saveSecret(key: String, value: String)\n\n  fun readSecret(key: String): String?\n\n  fun deleteSecret(key: String)\n\n  fun saveAccessTokenData(accessToken: String, refreshToken: String, expiresAt: Long)\n\n  fun clearAccessTokenData()\n\n  fun readAccessTokenData(): AccessTokenData?\n\n  fun saveImportedModels(importedModels: List<ImportedModel>)\n\n  fun readImportedModels(): List<ImportedModel>\n\n  fun isTosAccepted(): Boolean\n\n  fun acceptTos()\n\n  fun isGemmaTermsOfUseAccepted(): Boolean\n\n  fun acceptGemmaTermsOfUse()\n\n  fun getHasRunTinyGarden(): Boolean\n\n  fun setHasRunTinyGarden(hasRun: Boolean)\n\n  fun addCutout(cutout: Cutout)\n\n  fun getAllCutouts(): List<Cutout>\n\n  fun setCutout(newCutout: Cutout)\n\n  fun setCutouts(cutouts: List<Cutout>)\n\n  fun setHasSeenBenchmarkComparisonHelp(seen: Boolean)\n\n  fun getHasSeenBenchmarkComparisonHelp(): Boolean\n\n  fun addBenchmarkResult(result: BenchmarkResult)\n\n  fun getAllBenchmarkResults(): List<BenchmarkResult>\n\n  fun deleteBenchmarkResult(index: Int)\n}\n\n/** Repository for managing data using Proto DataStore. */\nclass DefaultDataStoreRepository(\n  private val dataStore: DataStore<Settings>,\n  private val userDataDataStore: DataStore<UserData>,\n  private val cutoutDataStore: DataStore<CutoutCollection>,\n  private val benchmarkResultsDataStore: DataStore<BenchmarkResults>,\n) : DataStoreRepository {\n  override fun saveTextInputHistory(history: List<String>) {\n    runBlocking {\n      dataStore.updateData { settings ->\n        settings.toBuilder().clearTextInputHistory().addAllTextInputHistory(history).build()\n      }\n    }\n  }\n\n  override fun readTextInputHistory(): List<String> {\n    return runBlocking {\n      val settings = dataStore.data.first()\n      settings.textInputHistoryList\n    }\n  }\n\n  override fun saveTheme(theme: Theme) {\n    runBlocking {\n      dataStore.updateData { settings -> settings.toBuilder().setTheme(theme).build() }\n    }\n  }\n\n  override fun readTheme(): Theme {\n    return runBlocking {\n      val settings = dataStore.data.first()\n      val curTheme = settings.theme\n      // Use \"auto\" as the default theme.\n      if (curTheme == Theme.THEME_UNSPECIFIED) Theme.THEME_AUTO else curTheme\n    }\n  }\n\n  override fun saveSecret(key: String, value: String) {\n    runBlocking {\n      userDataDataStore.updateData { userData ->\n        userData.toBuilder().putSecrets(key, value).build()\n      }\n    }\n  }\n\n  override fun readSecret(key: String): String? {\n    return runBlocking { userDataDataStore.data.first().secretsMap[key] }\n  }\n\n  override fun deleteSecret(key: String) {\n    runBlocking {\n      userDataDataStore.updateData { userData -> userData.toBuilder().removeSecrets(key).build() }\n    }\n  }\n\n  override fun saveAccessTokenData(accessToken: String, refreshToken: String, expiresAt: Long) {\n    runBlocking {\n      // Clear the entry in old data store.\n      dataStore.updateData { settings ->\n        settings.toBuilder().setAccessTokenData(AccessTokenData.getDefaultInstance()).build()\n      }\n\n      userDataDataStore.updateData { userData ->\n        userData\n          .toBuilder()\n          .setAccessTokenData(\n            AccessTokenData.newBuilder()\n              .setAccessToken(accessToken)\n              .setRefreshToken(refreshToken)\n              .setExpiresAtMs(expiresAt)\n              .build()\n          )\n          .build()\n      }\n    }\n  }\n\n  override fun clearAccessTokenData() {\n    runBlocking {\n      dataStore.updateData { settings -> settings.toBuilder().clearAccessTokenData().build() }\n      userDataDataStore.updateData { userData ->\n        userData.toBuilder().clearAccessTokenData().build()\n      }\n    }\n  }\n\n  override fun readAccessTokenData(): AccessTokenData? {\n    return runBlocking {\n      val userData = userDataDataStore.data.first()\n      userData.accessTokenData\n    }\n  }\n\n  override fun saveImportedModels(importedModels: List<ImportedModel>) {\n    runBlocking {\n      dataStore.updateData { settings ->\n        settings.toBuilder().clearImportedModel().addAllImportedModel(importedModels).build()\n      }\n    }\n  }\n\n  override fun readImportedModels(): List<ImportedModel> {\n    return runBlocking {\n      val settings = dataStore.data.first()\n      settings.importedModelList\n    }\n  }\n\n  override fun isTosAccepted(): Boolean {\n    return runBlocking {\n      val settings = dataStore.data.first()\n      settings.isTosAccepted\n    }\n  }\n\n  override fun acceptTos() {\n    runBlocking {\n      dataStore.updateData { settings -> settings.toBuilder().setIsTosAccepted(true).build() }\n    }\n  }\n\n  override fun isGemmaTermsOfUseAccepted(): Boolean {\n    return runBlocking {\n      val settings = dataStore.data.first()\n      settings.isGemmaTermsAccepted\n    }\n  }\n\n  override fun acceptGemmaTermsOfUse() {\n    runBlocking {\n      dataStore.updateData { settings ->\n        settings.toBuilder().setIsGemmaTermsAccepted(true).build()\n      }\n    }\n  }\n\n  override fun getHasRunTinyGarden(): Boolean {\n    return runBlocking {\n      val settings = dataStore.data.first()\n      settings.hasRunTinyGarden\n    }\n  }\n\n  override fun setHasRunTinyGarden(hasRun: Boolean) {\n    runBlocking {\n      dataStore.updateData { settings -> settings.toBuilder().setHasRunTinyGarden(hasRun).build() }\n    }\n  }\n\n  override fun addCutout(cutout: Cutout) {\n    runBlocking {\n      cutoutDataStore.updateData { cutouts -> cutouts.toBuilder().addCutout(cutout).build() }\n    }\n  }\n\n  override fun getAllCutouts(): List<Cutout> {\n    return runBlocking { cutoutDataStore.data.first().cutoutList }\n  }\n\n  override fun setCutout(newCutout: Cutout) {\n    runBlocking {\n      cutoutDataStore.updateData { cutouts ->\n        var index = -1\n        for (i in 0..<cutouts.cutoutCount) {\n          val cutout = cutouts.cutoutList.get(i)\n          if (cutout.id == newCutout.id) {\n            index = i\n            break\n          }\n        }\n        if (index >= 0) {\n          cutouts.toBuilder().setCutout(index, newCutout).build()\n        } else {\n          cutouts\n        }\n      }\n    }\n  }\n\n  override fun setCutouts(cutouts: List<Cutout>) {\n    runBlocking {\n      cutoutDataStore.updateData { CutoutCollection.newBuilder().addAllCutout(cutouts).build() }\n    }\n  }\n\n  override fun setHasSeenBenchmarkComparisonHelp(seen: Boolean) {\n    runBlocking {\n      dataStore.updateData { settings ->\n        settings.toBuilder().setHasSeenBenchmarkComparisonHelp(seen).build()\n      }\n    }\n  }\n\n  override fun getHasSeenBenchmarkComparisonHelp(): Boolean {\n    return runBlocking {\n      val settings = dataStore.data.first()\n      settings.hasSeenBenchmarkComparisonHelp\n    }\n  }\n\n  override fun addBenchmarkResult(result: BenchmarkResult) {\n    runBlocking {\n      benchmarkResultsDataStore.updateData { results ->\n        results.toBuilder().addResult(0, result).build()\n      }\n    }\n  }\n\n  override fun getAllBenchmarkResults(): List<BenchmarkResult> {\n    return runBlocking { benchmarkResultsDataStore.data.first().resultList }\n  }\n\n  override fun deleteBenchmarkResult(index: Int) {\n    runBlocking {\n      benchmarkResultsDataStore.updateData { results ->\n        val newResults = results.toBuilder().removeResult(index).build()\n        newResults\n      }\n    }\n  }\n}\n"
  },
  {
    "path": "Android/src/app/src/main/java/com/google/ai/edge/gallery/data/DownloadRepository.kt",
    "content": "/*\n * Copyright 2025 Google LLC\n *\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n *     http://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\n\npackage com.google.ai.edge.gallery.data\n\nimport android.Manifest\nimport android.app.NotificationChannel\nimport android.app.NotificationManager\nimport android.app.PendingIntent\nimport android.content.Context\nimport android.content.Intent\nimport android.content.pm.PackageManager\nimport android.util.Log\nimport androidx.core.app.ActivityCompat\nimport androidx.core.app.NotificationCompat\nimport androidx.core.app.NotificationManagerCompat\nimport androidx.core.content.edit\nimport androidx.core.net.toUri\nimport androidx.core.os.bundleOf\nimport androidx.work.Data\nimport androidx.work.ExistingWorkPolicy\nimport androidx.work.OneTimeWorkRequestBuilder\nimport androidx.work.OutOfQuotaPolicy\nimport androidx.work.WorkInfo\nimport androidx.work.WorkManager\nimport com.google.ai.edge.gallery.AppLifecycleProvider\nimport com.google.ai.edge.gallery.GalleryEvent\nimport com.google.ai.edge.gallery.R\nimport com.google.ai.edge.gallery.firebaseAnalytics\nimport com.google.ai.edge.gallery.worker.DownloadWorker\nimport java.util.UUID\nimport java.util.concurrent.Executors\n\nprivate const val TAG = \"AGDownloadRepository\"\nprivate const val MODEL_NAME_TAG = \"modelName\"\nprivate const val TASK_ID_TAG = \"taskId\"\n\ndata class AGWorkInfo(val taskId: String, val modelName: String, val workId: String)\n\ninterface DownloadRepository {\n  fun downloadModel(\n    task: Task?,\n    model: Model,\n    onStatusUpdated: (model: Model, status: ModelDownloadStatus) -> Unit,\n  )\n\n  fun cancelDownloadModel(model: Model)\n\n  fun cancelAll(onComplete: () -> Unit)\n\n  fun observerWorkerProgress(\n    workerId: UUID,\n    task: Task?,\n    model: Model,\n    onStatusUpdated: (model: Model, status: ModelDownloadStatus) -> Unit,\n  )\n}\n\nprivate const val DOWNLOAD_FROM_GLOBAL_MODEL_MANAGER_TASK_ID = \"___\"\n\n/**\n * Repository for managing model downloads using WorkManager.\n *\n * This class provides methods to initiate model downloads, cancel downloads, observe download\n * progress, and retrieve information about enqueued or running download tasks. It utilizes\n * WorkManager to handle background download operations.\n */\nclass DefaultDownloadRepository(\n  private val context: Context,\n  private val lifecycleProvider: AppLifecycleProvider,\n) : DownloadRepository {\n  private val workManager = WorkManager.getInstance(context)\n  /**\n   * Stores the start time of a model download.\n   *\n   * We use SharedPreferences to persist the download start times. This ensures that the data is\n   * still available after the app restarts. The key is the model name and the value is the download\n   * start time in milliseconds.\n   */\n  private val downloadStartTimeSharedPreferences =\n    context.getSharedPreferences(\"download_start_time_ms\", Context.MODE_PRIVATE)\n\n  override fun downloadModel(\n    task: Task?,\n    model: Model,\n    onStatusUpdated: (model: Model, status: ModelDownloadStatus) -> Unit,\n  ) {\n    // Create input data.\n    val builder = Data.Builder()\n    val totalBytes = model.totalBytes + model.extraDataFiles.sumOf { it.sizeInBytes }\n    val inputDataBuilder =\n      builder\n        .putString(KEY_MODEL_NAME, model.name)\n        .putString(KEY_MODEL_URL, model.url)\n        .putString(KEY_MODEL_COMMIT_HASH, model.version)\n        .putString(KEY_MODEL_DOWNLOAD_MODEL_DIR, model.normalizedName)\n        .putString(KEY_MODEL_DOWNLOAD_FILE_NAME, model.downloadFileName)\n        .putBoolean(KEY_MODEL_IS_ZIP, model.isZip)\n        .putString(KEY_MODEL_UNZIPPED_DIR, model.unzipDir)\n        .putLong(KEY_MODEL_TOTAL_BYTES, totalBytes)\n\n    if (model.extraDataFiles.isNotEmpty()) {\n      inputDataBuilder\n        .putString(KEY_MODEL_EXTRA_DATA_URLS, model.extraDataFiles.joinToString(\",\") { it.url })\n        .putString(\n          KEY_MODEL_EXTRA_DATA_DOWNLOAD_FILE_NAMES,\n          model.extraDataFiles.joinToString(\",\") { it.downloadFileName },\n        )\n    }\n    if (model.accessToken != null) {\n      inputDataBuilder.putString(KEY_MODEL_DOWNLOAD_ACCESS_TOKEN, model.accessToken)\n    }\n    val inputData = inputDataBuilder.build()\n\n    // Create worker request.\n    val downloadWorkRequest =\n      OneTimeWorkRequestBuilder<DownloadWorker>()\n        .setExpedited(OutOfQuotaPolicy.RUN_AS_NON_EXPEDITED_WORK_REQUEST)\n        .setInputData(inputData)\n        .addTag(\"$MODEL_NAME_TAG:${model.name}\")\n        .addTag(\"$TASK_ID_TAG:${task?.id ?: \"\"}\")\n        .build()\n\n    val workerId = downloadWorkRequest.id\n\n    // Start!\n    workManager.enqueueUniqueWork(model.name, ExistingWorkPolicy.REPLACE, downloadWorkRequest)\n\n    // Observe progress.\n    observerWorkerProgress(\n      workerId = workerId,\n      task = task,\n      model = model,\n      onStatusUpdated = onStatusUpdated,\n    )\n  }\n\n  override fun cancelDownloadModel(model: Model) {\n    workManager.cancelAllWorkByTag(\"$MODEL_NAME_TAG:${model.name}\")\n  }\n\n  override fun cancelAll(onComplete: () -> Unit) {\n    workManager\n      .cancelAllWork()\n      .result\n      .addListener({ onComplete() }, Executors.newSingleThreadExecutor())\n  }\n\n  override fun observerWorkerProgress(\n    workerId: UUID,\n    task: Task?,\n    model: Model,\n    onStatusUpdated: (model: Model, status: ModelDownloadStatus) -> Unit,\n  ) {\n    workManager.getWorkInfoByIdLiveData(workerId).observeForever { workInfo ->\n      if (workInfo != null) {\n        when (workInfo.state) {\n          WorkInfo.State.ENQUEUED -> {\n            downloadStartTimeSharedPreferences.edit {\n              putLong(model.name, System.currentTimeMillis())\n            }\n            firebaseAnalytics?.logEvent(\n              GalleryEvent.MODEL_DOWNLOAD.id,\n              bundleOf(\"event_type\" to \"start\", \"model_id\" to model.name),\n            )\n          }\n\n          WorkInfo.State.RUNNING -> {\n            val receivedBytes = workInfo.progress.getLong(KEY_MODEL_DOWNLOAD_RECEIVED_BYTES, 0L)\n            val downloadRate = workInfo.progress.getLong(KEY_MODEL_DOWNLOAD_RATE, 0L)\n            val remainingSeconds = workInfo.progress.getLong(KEY_MODEL_DOWNLOAD_REMAINING_MS, 0L)\n            val startUnzipping = workInfo.progress.getBoolean(KEY_MODEL_START_UNZIPPING, false)\n\n            if (!startUnzipping) {\n              if (receivedBytes != 0L) {\n                onStatusUpdated(\n                  model,\n                  ModelDownloadStatus(\n                    status = ModelDownloadStatusType.IN_PROGRESS,\n                    totalBytes = model.totalBytes,\n                    receivedBytes = receivedBytes,\n                    bytesPerSecond = downloadRate,\n                    remainingMs = remainingSeconds,\n                  ),\n                )\n              }\n            } else {\n              onStatusUpdated(\n                model,\n                ModelDownloadStatus(status = ModelDownloadStatusType.UNZIPPING),\n              )\n            }\n          }\n\n          WorkInfo.State.SUCCEEDED -> {\n            Log.d(\"repo\", \"worker %s success\".format(workerId.toString()))\n            onStatusUpdated(model, ModelDownloadStatus(status = ModelDownloadStatusType.SUCCEEDED))\n            sendNotification(\n              title = context.getString(R.string.notification_title_success),\n              text = context.getString(R.string.notification_content_success).format(model.name),\n              taskId = task?.id ?: DOWNLOAD_FROM_GLOBAL_MODEL_MANAGER_TASK_ID,\n              modelName = model.name,\n            )\n\n            val startTime = downloadStartTimeSharedPreferences.getLong(model.name, 0L)\n            val duration = System.currentTimeMillis() - startTime\n            firebaseAnalytics?.logEvent(\n              GalleryEvent.MODEL_DOWNLOAD.id,\n              bundleOf(\n                \"event_type\" to \"success\",\n                \"model_id\" to model.name,\n                \"duration_ms\" to duration,\n              ),\n            )\n            downloadStartTimeSharedPreferences.edit { remove(model.name) }\n          }\n\n          WorkInfo.State.FAILED,\n          WorkInfo.State.CANCELLED -> {\n            var status = ModelDownloadStatusType.FAILED\n            val errorMessage = workInfo.outputData.getString(KEY_MODEL_DOWNLOAD_ERROR_MESSAGE) ?: \"\"\n            Log.d(\n              \"repo\",\n              \"worker %s FAILED or CANCELLED: %s\".format(workerId.toString(), errorMessage),\n            )\n            if (workInfo.state == WorkInfo.State.CANCELLED) {\n              status = ModelDownloadStatusType.NOT_DOWNLOADED\n            } else {\n              sendNotification(\n                title = context.getString(R.string.notification_title_fail),\n                text = context.getString(R.string.notification_content_success).format(model.name),\n                taskId = \"\",\n                modelName = \"\",\n              )\n            }\n            onStatusUpdated(\n              model,\n              ModelDownloadStatus(status = status, errorMessage = errorMessage),\n            )\n\n            val startTime = downloadStartTimeSharedPreferences.getLong(model.name, 0L)\n            val duration = System.currentTimeMillis() - startTime\n            // TODO: Add failure reasons\n            firebaseAnalytics?.logEvent(\n              GalleryEvent.MODEL_DOWNLOAD.id,\n              bundleOf(\n                \"event_type\" to \"failure\",\n                \"model_id\" to model.name,\n                \"duration_ms\" to duration,\n              ),\n            )\n            downloadStartTimeSharedPreferences.edit { remove(model.name) }\n          }\n\n          else -> {}\n        }\n      }\n    }\n  }\n\n  private fun sendNotification(title: String, text: String, taskId: String, modelName: String) {\n    // Don't send notification if app is in foreground.\n    if (lifecycleProvider.isAppInForeground) {\n      return\n    }\n\n    val channelId = \"download_notification\"\n    val channelName = \"AI Edge Gallery download notification\"\n\n    // Create the NotificationChannel, but only on API 26+ because\n    // the NotificationChannel class is new and not in the support library\n    val importance = NotificationManager.IMPORTANCE_HIGH\n    val channel = NotificationChannel(channelId, channelName, importance)\n    val notificationManager: NotificationManager =\n      context.getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager\n    notificationManager.createNotificationChannel(channel)\n\n    val intent: Intent\n    if (taskId.isEmpty()) {\n      // If taskId is empty, it's a failed download. Just open the app's main screen.\n      intent = context.packageManager.getLaunchIntentForPackage(context.packageName)!!\n    }\n    // Download from global model manager. Open the global model manager screen.\n    else if (taskId == DOWNLOAD_FROM_GLOBAL_MODEL_MANAGER_TASK_ID) {\n      intent =\n        Intent(Intent.ACTION_VIEW, \"com.google.ai.edge.gallery://global_model_manager\".toUri())\n          .apply { flags = Intent.FLAG_ACTIVITY_NEW_TASK }\n    } else {\n\n      // Otherwise, create the deep link as before.\n      intent =\n        Intent(\n            Intent.ACTION_VIEW,\n            \"com.google.ai.edge.gallery://model/$taskId/${modelName}\".toUri(),\n          )\n          .apply { flags = Intent.FLAG_ACTIVITY_NEW_TASK }\n    }\n\n    // Create a PendingIntent\n    val pendingIntent: PendingIntent =\n      PendingIntent.getActivity(\n        context,\n        0,\n        intent,\n        PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE,\n      )\n\n    val builder =\n      NotificationCompat.Builder(context, channelId)\n        // TODO: replace icon.\n        .setSmallIcon(android.R.drawable.ic_dialog_info)\n        .setContentTitle(title)\n        .setContentText(text)\n        .setPriority(NotificationCompat.PRIORITY_HIGH)\n        .setContentIntent(pendingIntent)\n        .setAutoCancel(true)\n\n    with(NotificationManagerCompat.from(context)) {\n      // notificationId is a unique int for each notification that you must define\n      if (\n        ActivityCompat.checkSelfPermission(context, Manifest.permission.POST_NOTIFICATIONS) !=\n          PackageManager.PERMISSION_GRANTED\n      ) {\n        // Permission not granted, return or handle accordingly. In real app, request permission.\n        return\n      }\n      notify(1, builder.build())\n    }\n  }\n}\n"
  },
  {
    "path": "Android/src/app/src/main/java/com/google/ai/edge/gallery/data/Model.kt",
    "content": "/*\n * Copyright 2025 Google LLC\n *\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n *     http://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\n\npackage com.google.ai.edge.gallery.data\n\nimport android.content.Context\nimport com.google.gson.annotations.SerializedName\nimport java.io.File\n\ndata class ModelDataFile(\n  val name: String,\n  val url: String,\n  val downloadFileName: String,\n  val sizeInBytes: Long,\n)\n\nconst val IMPORTS_DIR = \"__imports\"\nprivate val NORMALIZE_NAME_REGEX = Regex(\"[^a-zA-Z0-9]\")\n\ndata class PromptTemplate(val title: String, val description: String, val prompt: String)\n\nenum class RuntimeType {\n  @SerializedName(\"unknown\") UNKNOWN,\n  @SerializedName(\"litert_lm\") LITERT_LM,\n}\n\n/**\n * A model for a task (see [Task]).\n *\n * A task can have multiple models. For example, a task might be \"LLM Chat\", and it might have\n * models such as Gemma2, Gemma3, etc.\n */\ndata class Model(\n  /**\n   * The name of the model.\n   *\n   * This field is used to uniquely identify this model among all the tasks.\n   *\n   * IMPORTANT: it shouldn't contain \"/\" character.\n   */\n  val name: String,\n\n  /**\n   * The display name of the model, for display purpose.\n   *\n   * If this field is not set, the `name` field above will be used as the default display name.\n   */\n  val displayName: String = \"\",\n\n  /**\n   * (optional)\n   *\n   * A description or information about the model (Markdown supported).\n   *\n   * Displayed in the expanded model info card.\n   */\n  val info: String = \"\",\n\n  /**\n   * (optional)\n   *\n   * A list of configurable parameters for the model.\n   *\n   * If set, a gear icon appears on the right side of the model main screen's app bar. When\n   * selected, a dialog pops up, allowing users to update the model's configurations.\n   *\n   * See [Config] for more details\n   */\n  var configs: List<Config> = listOf(),\n\n  /**\n   * (optional)\n   *\n   * The url to jump to when clicking \"learn more\" in model's info card.\n   */\n  val learnMoreUrl: String = \"\",\n\n  /**\n   * (optional)\n   *\n   * The task type ids that this model is best for.\n   *\n   * When set, the model's info card is pinned to the top of the model list when the corresponding\n   * task is selected, expanded by default, and displays a \"best overall\" banner.\n   *\n   * Each task should only have one such model.\n   */\n  val bestForTaskIds: List<String> = listOf(),\n\n  /**\n   * (optional)\n   *\n   * The minimum device memory in GB to run the model.\n   *\n   * If set, a warning dialog will be shown when user trying to download the model or enter the\n   * model screen.\n   */\n  val minDeviceMemoryInGb: Int? = null,\n\n  //////////////////////////////////////////////////////////////////////////////////////////////////\n  // Fill in the following fields if the model file needs to be downloaded from internet.\n  //\n  // If you want to manually manage model files without downloading them from internet, set the\n  // `localFilePathOverride` field below.\n\n  /**\n   * The URL to download the model from.\n   *\n   * If the url is from HuggingFace, we will automatically prompt users to fetch access token if the\n   * model is gated.\n   */\n  val url: String = \"\",\n\n  /**\n   * The size of the model file in bytes.\n   *\n   * This will be used to calculate download progress.\n   */\n  val sizeInBytes: Long = 0L,\n\n  /**\n   * The name of the downloaded model file.\n   *\n   * It will be used to define the file path on local device to store the downloaded model.\n   * {context.getExternalFilesDir}/{normalizedName}/{version}/{downloadFileName}\n   */\n  val downloadFileName: String = \"_\",\n\n  /**\n   * (optional)\n   *\n   * The version of the model.\n   *\n   * It will be used to define the file path on local device to store the downloaded model.\n   * {context.getExternalFilesDir}/{normalizedName}/{version}/{downloadFileName}\n   */\n  val version: String = \"_\",\n\n  /**\n   * (optional, experimental)\n   *\n   * A list of additional data files required by the model.\n   */\n  val extraDataFiles: List<ModelDataFile> = listOf(),\n\n  /** Whether the model is LLM or not. */\n  val isLlm: Boolean = false,\n\n  // End of model download related fields.\n  //////////////////////////////////////////////////////////////////////////////////////////////////\n\n  /** The type of local runtime environment to use for running the model. */\n  val runtimeType: RuntimeType = RuntimeType.UNKNOWN,\n\n  /**\n   * Set this to a relative path pointing to a dir (e.g., my_model/local_dir/) if you want to\n   * manually manage model files instead of downloading them. This dir is relative to the app's\n   * \"External Files Directory\", which is: /storage/emulated/0/Android/data/<app_id>/files/.\n   *\n   * The <app_id> depends on how the app was built:\n   * - `com.google.aiedge.gallery` for builds from the GitHub source.\n   * - `com.google.ai.edge.gallery` for other builds (Play store, internal, etc).\n   *\n   * For example, if this field is set to \"my_model/local_dir/\", then the location you should push\n   * files to is (assuming non-github builds):\n   *\n   * /storage/emulated/0/Android/data/com.google.ai.edge.gallery/files/my_model/local_dir/\n   *\n   * You can get the full path to a specific file within your code using `Model.getPath(Context,\n   * fileNameToGet)`.\n   *\n   * Using this field is recommended when:\n   * - Your model files are not publicly accessible on the internet (e.g. private models).\n   * - Your \"model\" or experience requires multiple files. Manually pushing these files to the\n   *   device and using Model.getPath() for each one is often simpler than downloading them,\n   *   especially for demos.\n   */\n  val localFileRelativeDirPathOverride: String = \"\",\n\n  /**\n   * When set, the app will try to use this path to find the model file.\n   *\n   * For testing purpose only.\n   */\n  val localModelFilePathOverride: String = \"\",\n\n  // The following fields are only used for built-in tasks. Can ignore if you are creating your own\n  // custom tasks.\n  //\n\n  /** Whether to show the \"run again\" button in the UI. */\n  val showRunAgainButton: Boolean = true,\n\n  /** Whether to show the \"benchmark\" button in the UI. */\n  val showBenchmarkButton: Boolean = true,\n\n  /** Indicates whether the model is a zip file. */\n  val isZip: Boolean = false,\n\n  /** The name of the directory to unzip the model to (if it's a zip file). */\n  val unzipDir: String = \"\",\n\n  /** The prompt templates for the model (only for LLM). */\n  val llmPromptTemplates: List<PromptTemplate> = listOf(),\n\n  /** Whether the LLM model supports image input. */\n  val llmSupportImage: Boolean = false,\n\n  /** Whether the LLM model supports audio input. */\n  val llmSupportAudio: Boolean = false,\n\n  /** Whether the LLM model supports tiny garden. */\n  val llmSupportTinyGarden: Boolean = false,\n\n  /** Whether the LLM model supports mobile actions. */\n  val llmSupportMobileActions: Boolean = false,\n\n  /** The max token for llm model. */\n  val llmMaxToken: Int = 0,\n\n  /** Compatible accelerators. */\n  val accelerators: List<Accelerator> = listOf(),\n\n  /** Accelerator for running vision encoder. */\n  val visionAccelerator: Accelerator = Accelerator.GPU,\n\n  /** Whether the model is imported or not. */\n  val imported: Boolean = false,\n\n  // The following fields are managed by the app. Don't need to set manually.\n  //\n  var normalizedName: String = \"\",\n  var instance: Any? = null,\n  var initializing: Boolean = false,\n  // TODO(jingjin): use a \"queue\" system to manage model init and cleanup.\n  var cleanUpAfterInit: Boolean = false,\n  var configValues: Map<String, Any> = mapOf(),\n  var prevConfigValues: Map<String, Any> = mapOf(),\n  var totalBytes: Long = 0L,\n  var accessToken: String? = null,\n) {\n  init {\n    normalizedName = NORMALIZE_NAME_REGEX.replace(name, \"_\")\n  }\n\n  fun preProcess() {\n    val configValues: MutableMap<String, Any> = mutableMapOf()\n    for (config in this.configs) {\n      configValues[config.key.label] = config.defaultValue\n    }\n    this.configValues = configValues\n    this.totalBytes = this.sizeInBytes + this.extraDataFiles.sumOf { it.sizeInBytes }\n  }\n\n  fun getPath(context: Context, fileName: String = downloadFileName): String {\n    if (imported) {\n      return listOf(context.getExternalFilesDir(null)?.absolutePath ?: \"\", fileName)\n        .joinToString(File.separator)\n    }\n\n    if (localModelFilePathOverride.isNotEmpty()) {\n      return localModelFilePathOverride\n    }\n\n    if (localFileRelativeDirPathOverride.isNotEmpty()) {\n      return listOf(\n          context.getExternalFilesDir(null)?.absolutePath ?: \"\",\n          localFileRelativeDirPathOverride,\n          fileName,\n        )\n        .joinToString(File.separator)\n    }\n\n    val baseDir =\n      listOf(context.getExternalFilesDir(null)?.absolutePath ?: \"\", normalizedName, version)\n        .joinToString(File.separator)\n    return if (this.isZip && this.unzipDir.isNotEmpty()) {\n      listOf(baseDir, this.unzipDir).joinToString(File.separator)\n    } else {\n      listOf(baseDir, fileName).joinToString(File.separator)\n    }\n  }\n\n  fun getIntConfigValue(key: ConfigKey, defaultValue: Int = 0): Int {\n    return getTypedConfigValue(key = key, valueType = ValueType.INT, defaultValue = defaultValue)\n      as Int\n  }\n\n  fun getFloatConfigValue(key: ConfigKey, defaultValue: Float = 0.0f): Float {\n    return getTypedConfigValue(key = key, valueType = ValueType.FLOAT, defaultValue = defaultValue)\n      as Float\n  }\n\n  fun getBooleanConfigValue(key: ConfigKey, defaultValue: Boolean = false): Boolean {\n    return getTypedConfigValue(\n      key = key,\n      valueType = ValueType.BOOLEAN,\n      defaultValue = defaultValue,\n    )\n      as Boolean\n  }\n\n  fun getStringConfigValue(key: ConfigKey, defaultValue: String = \"\"): String {\n    return getTypedConfigValue(key = key, valueType = ValueType.STRING, defaultValue = defaultValue)\n      as String\n  }\n\n  fun getExtraDataFile(name: String): ModelDataFile? {\n    return extraDataFiles.find { it.name == name }\n  }\n\n  private fun getTypedConfigValue(key: ConfigKey, valueType: ValueType, defaultValue: Any): Any {\n    return convertValueToTargetType(\n      value = configValues.getOrDefault(key.label, defaultValue),\n      valueType = valueType,\n    )\n  }\n}\n\nenum class ModelDownloadStatusType {\n  NOT_DOWNLOADED,\n  PARTIALLY_DOWNLOADED,\n  IN_PROGRESS,\n  UNZIPPING,\n  SUCCEEDED,\n  FAILED,\n}\n\ndata class ModelDownloadStatus(\n  val status: ModelDownloadStatusType,\n  val totalBytes: Long = 0,\n  val receivedBytes: Long = 0,\n  val errorMessage: String = \"\",\n  val bytesPerSecond: Long = 0,\n  val remainingMs: Long = 0,\n)\n\n////////////////////////////////////////////////////////////////////////////////////////////////////\n// Configs.\n\nval EMPTY_MODEL: Model =\n  Model(name = \"empty\", downloadFileName = \"empty.tflite\", url = \"\", sizeInBytes = 0L)\n"
  },
  {
    "path": "Android/src/app/src/main/java/com/google/ai/edge/gallery/data/ModelAllowlist.kt",
    "content": "/*\n * Copyright 2025 Google LLC\n *\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n *     http://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\n\npackage com.google.ai.edge.gallery.data\n\nimport android.os.Build\nimport android.util.Log\nimport com.google.ai.edge.gallery.common.isPixel10\nimport com.google.gson.annotations.SerializedName\n\nprivate const val TAG = \"AGModelAllowlist\"\n\ndata class DefaultConfig(\n  @SerializedName(\"topK\") val topK: Int?,\n  @SerializedName(\"topP\") val topP: Float?,\n  @SerializedName(\"temperature\") val temperature: Float?,\n  @SerializedName(\"accelerators\") val accelerators: String?,\n  @SerializedName(\"visionAccelerator\") val visionAccelerator: String?,\n  @SerializedName(\"maxTokens\") val maxTokens: Int?,\n)\n\n/** A model file on HF for a specific SOC. */\ndata class SocModelFile(\n  @SerializedName(\"modelFile\") val modelFile: String?,\n  @SerializedName(\"url\") val url: String?,\n  @SerializedName(\"commitHash\") val commitHash: String?,\n  @SerializedName(\"sizeInBytes\") val sizeInBytes: Long?,\n)\n\n/** A model in the model allowlist. */\ndata class AllowedModel(\n  val name: String,\n  val modelId: String,\n  val modelFile: String,\n  val commitHash: String,\n  val description: String,\n  val sizeInBytes: Long,\n  val defaultConfig: DefaultConfig,\n  val taskTypes: List<String>,\n  val disabled: Boolean? = null,\n  val llmSupportImage: Boolean? = null,\n  val llmSupportAudio: Boolean? = null,\n  val llmSupportTinyGarden: Boolean? = null,\n  val llmSupportMobileActions: Boolean? = null,\n  val minDeviceMemoryInGb: Int? = null,\n  val bestForTaskTypes: List<String>? = null,\n  val localModelFilePathOverride: String? = null,\n  val url: String? = null,\n  val socToModelFiles: Map<String, SocModelFile>? = null,\n  val runtimeType: RuntimeType? = null,\n) {\n  fun toModel(): Model {\n    // Construct HF download url.\n    var version = commitHash\n    var downloadedFileName = modelFile\n    var downloadUrl =\n      url ?: \"https://huggingface.co/$modelId/resolve/$commitHash/$modelFile?download=true\"\n    var sizeInBytes = sizeInBytes\n\n    // Handle per-soc model files.\n    if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {\n      if (socToModelFiles?.isNotEmpty() == true) {\n        socToModelFiles.get(SOC)?.let { info ->\n          Log.d(TAG, \"Found soc-specific model files for model $name: $info\")\n          version = info.commitHash ?: \"-\"\n          downloadedFileName = info.modelFile ?: \"-\"\n          downloadUrl =\n            info.url\n              ?: \"https://huggingface.co/$modelId/resolve/${info.commitHash}/${info.modelFile}?download=true\"\n          sizeInBytes = info.sizeInBytes ?: -1\n        }\n      }\n    }\n\n    // Config.\n    val isLlmModel =\n      taskTypes.contains(BuiltInTaskId.LLM_CHAT) ||\n        taskTypes.contains(BuiltInTaskId.LLM_PROMPT_LAB) ||\n        taskTypes.contains(BuiltInTaskId.LLM_ASK_AUDIO) ||\n        taskTypes.contains(BuiltInTaskId.LLM_ASK_IMAGE) ||\n        taskTypes.contains(BuiltInTaskId.LLM_MOBILE_ACTIONS) ||\n        taskTypes.contains(BuiltInTaskId.LLM_TINY_GARDEN)\n    var configs: MutableList<Config> = mutableListOf()\n    var llmMaxToken = 1024\n    var accelerators: List<Accelerator> = DEFAULT_ACCELERATORS\n    var visionAccelerator: Accelerator = DEFAULT_VISION_ACCELERATOR\n    if (isLlmModel) {\n      val defaultTopK: Int = defaultConfig.topK ?: DEFAULT_TOPK\n      val defaultTopP: Float = defaultConfig.topP ?: DEFAULT_TOPP\n      val defaultTemperature: Float = defaultConfig.temperature ?: DEFAULT_TEMPERATURE\n      llmMaxToken = defaultConfig.maxTokens ?: 1024\n      if (defaultConfig.accelerators != null) {\n        val items = defaultConfig.accelerators.split(\",\")\n        accelerators = mutableListOf()\n        for (item in items) {\n          if (item == \"cpu\") {\n            accelerators.add(Accelerator.CPU)\n          } else if (item == \"gpu\") {\n            accelerators.add(Accelerator.GPU)\n          } else if (item == \"npu\") {\n            accelerators.add(Accelerator.NPU)\n          }\n        }\n        // Remove GPU from pixel 10 devices.\n        if (isPixel10()) {\n          accelerators.remove(Accelerator.GPU)\n        }\n      }\n      if (defaultConfig.visionAccelerator != null) {\n        val accelerator = defaultConfig.visionAccelerator\n        if (accelerator == \"cpu\") {\n          visionAccelerator = Accelerator.CPU\n        } else if (accelerator == \"gpu\") {\n          visionAccelerator = Accelerator.GPU\n        } else if (accelerator == \"npu\") {\n          visionAccelerator = Accelerator.NPU\n        }\n      }\n      val npuOnly = accelerators.size == 1 && accelerators[0] == Accelerator.NPU\n      configs =\n        (\n          if (npuOnly) {\n            createLlmChatConfigsForNpuModel(\n              defaultMaxToken = llmMaxToken,\n              accelerators = accelerators,\n            )\n          } else {\n            createLlmChatConfigs(\n              defaultTopK = defaultTopK,\n              defaultTopP = defaultTopP,\n              defaultTemperature = defaultTemperature,\n              defaultMaxToken = llmMaxToken,\n              accelerators = accelerators,\n            )\n          })\n          .toMutableList()\n    }\n\n    var learnMoreUrl = \"https://huggingface.co/${modelId}\"\n\n    // Misc.\n    var showBenchmarkButton = true\n    var showRunAgainButton = true\n    if (isLlmModel) {\n      showBenchmarkButton = false\n      showRunAgainButton = false\n    }\n    return Model(\n      name = name,\n      version = version,\n      info = description,\n      url = downloadUrl,\n      sizeInBytes = sizeInBytes,\n      minDeviceMemoryInGb = minDeviceMemoryInGb,\n      configs = configs,\n      downloadFileName = downloadedFileName,\n      showBenchmarkButton = showBenchmarkButton,\n      showRunAgainButton = showRunAgainButton,\n      learnMoreUrl = learnMoreUrl,\n      llmSupportImage = llmSupportImage == true,\n      llmSupportAudio = llmSupportAudio == true,\n      llmSupportTinyGarden = llmSupportTinyGarden == true,\n      llmSupportMobileActions = llmSupportMobileActions == true,\n      llmMaxToken = llmMaxToken,\n      accelerators = accelerators,\n      visionAccelerator = visionAccelerator,\n      bestForTaskIds = bestForTaskTypes ?: listOf(),\n      localModelFilePathOverride = localModelFilePathOverride ?: \"\",\n      isLlm = isLlmModel,\n      runtimeType = runtimeType ?: RuntimeType.LITERT_LM,\n    )\n  }\n\n  override fun toString(): String {\n    return \"$modelId/$modelFile\"\n  }\n}\n\n/** The model allowlist. */\ndata class ModelAllowlist(val models: List<AllowedModel>)\n"
  },
  {
    "path": "Android/src/app/src/main/java/com/google/ai/edge/gallery/data/Tasks.kt",
    "content": "/*\n * Copyright 2025 Google LLC\n *\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n *     http://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\n\npackage com.google.ai.edge.gallery.data\n\nimport androidx.annotation.StringRes\nimport androidx.compose.runtime.MutableState\nimport androidx.compose.runtime.mutableLongStateOf\nimport androidx.compose.ui.graphics.vector.ImageVector\nimport com.google.ai.edge.gallery.R\n\n/**\n * Data class for a task displayed on the home screen\n *\n * Tasks are grouped into categories (see [category] field), which correspond to the tabs on the\n * home screen. The tab bar is hidden if only one category exists. Each task can have a list of\n * associated models (see [Model]], which are shown when the task is selected.\n *\n * To register a custom task, see [com.google.ai.edge.gallery.customtasks.common.CustomTask].\n */\ndata class Task(\n  /**\n   * The id of the task.\n   *\n   * The ids in [BuiltInTaskId] are reserved for built-in tasks.\n   */\n  val id: String,\n\n  /** The label of the task, for display purpose. */\n  val label: String,\n\n  /**\n   * The category of the task.\n   *\n   * We've pre-defined several categories in [Category]. Feel free to create your own category.\n   */\n  val category: CategoryInfo,\n\n  /** Icon to be shown in the task tile. */\n  val icon: ImageVector? = null,\n\n  /** Vector resource id for the icon. This precedes the icon if both are set. */\n  val iconVectorResourceId: Int? = null,\n\n  /**\n   * Description of the task.\n   *\n   * Will be shown at the top of the task screen.\n   */\n  val description: String,\n\n  /**\n   * (optional)\n   *\n   * Documentation url for the task.\n   *\n   * Will be shown below the description on the task screen.\n   */\n  val docUrl: String = \"\",\n\n  /**\n   * (optional)\n   *\n   * Source code url for the model-related functions.\n   *\n   * Will be shown below the description on the task screen.\n   */\n  val sourceCodeUrl: String = \"\",\n\n  /** List of models for the task. */\n  val models: MutableList<Model>,\n\n  /**\n   * List of model names for the task.\n   *\n   * If this field is non-empty, the task will try to find the models with the matching names from\n   * the allowlist\n   */\n  val modelNames: List<String> = listOf(),\n\n  /**\n   * Whether to handel model config changes in task's screen itself. The default behavior is to\n   * automatically re-initialize the model.\n   */\n  val handleModelConfigChangesInTask: Boolean = false,\n\n  /** Whether the task is experimental. */\n  val experimental: Boolean = false,\n\n  /** Whether to use theme color instead of the task tint color. */\n  val useThemeColor: Boolean = false,\n\n  /** The default system prompt for this task. */\n  val defaultSystemPrompt: String = \"\",\n\n  // The following fields are only used for built-in tasks. Can ignore if you are creating your own\n  // custom tasks.\n  //\n\n  /** Placeholder text for the name of the agent shown above chat messages. */\n  @StringRes val agentNameRes: Int = R.string.chat_generic_agent_name,\n\n  /** Placeholder text for the text input field. */\n  @StringRes val textInputPlaceHolderRes: Int = R.string.chat_textinput_placeholder,\n\n  // The following fields are managed by the app. Don't need to set manually.\n  //\n\n  var index: Int = -1,\n  val updateTrigger: MutableState<Long> = mutableLongStateOf(0),\n)\n\nobject BuiltInTaskId {\n  const val LLM_CHAT = \"llm_chat\"\n  const val LLM_PROMPT_LAB = \"llm_prompt_lab\"\n  const val LLM_ASK_IMAGE = \"llm_ask_image\"\n  const val LLM_ASK_AUDIO = \"llm_ask_audio\"\n  const val LLM_MOBILE_ACTIONS = \"llm_mobile_actions\"\n  const val LLM_TINY_GARDEN = \"llm_tiny_garden\"\n  const val MP_SCRAPBOOK = \"mp_scrapbook\"\n}\n\nprivate val allLegacyTaskIds: MutableSet<String> =\n  mutableSetOf(\n    BuiltInTaskId.LLM_CHAT,\n    BuiltInTaskId.LLM_PROMPT_LAB,\n    BuiltInTaskId.LLM_ASK_IMAGE,\n    BuiltInTaskId.LLM_ASK_AUDIO,\n  )\n\nfun isLegacyTasks(id: String): Boolean {\n  return allLegacyTaskIds.contains(id)\n}\n"
  },
  {
    "path": "Android/src/app/src/main/java/com/google/ai/edge/gallery/data/Types.kt",
    "content": "/*\n * Copyright 2025 Google LLC\n *\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n *     http://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\n\npackage com.google.ai.edge.gallery.data\n\nenum class Accelerator(val label: String) {\n  CPU(label = \"CPU\"),\n  GPU(label = \"GPU\"),\n  NPU(label = \"NPU\"),\n}\n"
  },
  {
    "path": "Android/src/app/src/main/java/com/google/ai/edge/gallery/di/AppModule.kt",
    "content": "/*\n * Copyright 2025 Google LLC\n *\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n *     http://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\n\npackage com.google.ai.edge.gallery.di\n\nimport android.content.Context\nimport androidx.datastore.core.DataStore\nimport androidx.datastore.core.DataStoreFactory\nimport androidx.datastore.core.Serializer\nimport androidx.datastore.dataStoreFile\nimport com.google.ai.edge.gallery.AppLifecycleProvider\nimport com.google.ai.edge.gallery.BenchmarkResultsSerializer\nimport com.google.ai.edge.gallery.CutoutsSerializer\nimport com.google.ai.edge.gallery.GalleryLifecycleProvider\nimport com.google.ai.edge.gallery.SettingsSerializer\nimport com.google.ai.edge.gallery.UserDataSerializer\nimport com.google.ai.edge.gallery.data.DataStoreRepository\nimport com.google.ai.edge.gallery.data.DefaultDataStoreRepository\nimport com.google.ai.edge.gallery.data.DefaultDownloadRepository\nimport com.google.ai.edge.gallery.data.DownloadRepository\nimport com.google.ai.edge.gallery.proto.BenchmarkResults\nimport com.google.ai.edge.gallery.proto.CutoutCollection\nimport com.google.ai.edge.gallery.proto.Settings\nimport com.google.ai.edge.gallery.proto.UserData\nimport dagger.Module\nimport dagger.Provides\nimport dagger.hilt.InstallIn\nimport dagger.hilt.android.qualifiers.ApplicationContext\nimport dagger.hilt.components.SingletonComponent\nimport javax.inject.Singleton\n\n@Module\n@InstallIn(SingletonComponent::class)\ninternal object AppModule {\n\n  // Provides the SettingsSerializer\n  @Provides\n  @Singleton\n  fun provideSettingsSerializer(): Serializer<Settings> {\n    return SettingsSerializer\n  }\n\n  // Provides the CutoutSerializer\n  @Provides\n  @Singleton\n  fun provideCutoutSerializer(): Serializer<CutoutCollection> {\n    return CutoutsSerializer\n  }\n\n  // Provides the UserDataSerializer\n  @Provides\n  @Singleton\n  fun provideUserDataSerializer(): Serializer<UserData> {\n    return UserDataSerializer\n  }\n\n  // Provides the BenchmarkResultsSerializer\n  @Provides\n  @Singleton\n  fun provideBenchmarkResultsSerializer(): Serializer<BenchmarkResults> {\n    return BenchmarkResultsSerializer\n  }\n\n  // Provides DataStore<Settings>\n  @Provides\n  @Singleton\n  fun provideSettingsDataStore(\n    @ApplicationContext context: Context,\n    settingsSerializer: Serializer<Settings>,\n  ): DataStore<Settings> {\n    return DataStoreFactory.create(\n      serializer = settingsSerializer,\n      produceFile = { context.dataStoreFile(\"settings.pb\") },\n    )\n  }\n\n  // Provides DataStore<CutoutCollection>\n  @Provides\n  @Singleton\n  fun provideCutoutsDataStore(\n    @ApplicationContext context: Context,\n    cutoutsSerializer: Serializer<CutoutCollection>,\n  ): DataStore<CutoutCollection> {\n    return DataStoreFactory.create(\n      serializer = cutoutsSerializer,\n      produceFile = { context.dataStoreFile(\"cutouts.pb\") },\n    )\n  }\n\n  // Provides DataStore<UserData>\n  @Provides\n  @Singleton\n  fun provideUserDataDataStore(\n    @ApplicationContext context: Context,\n    userDataSerializer: Serializer<UserData>,\n  ): DataStore<UserData> {\n    return DataStoreFactory.create(\n      serializer = userDataSerializer,\n      produceFile = { context.dataStoreFile(\"user_data.pb\") },\n    )\n  }\n\n  // Provides DataStore<BenchmarkResults>\n  @Provides\n  @Singleton\n  fun provideBenchmarkResultsDataStore(\n    @ApplicationContext context: Context,\n    benchmarkResultsSerializer: Serializer<BenchmarkResults>,\n  ): DataStore<BenchmarkResults> {\n    return DataStoreFactory.create(\n      serializer = benchmarkResultsSerializer,\n      produceFile = { context.dataStoreFile(\"benchmark_results.pb\") },\n    )\n  }\n\n  // Provides AppLifecycleProvider\n  @Provides\n  @Singleton\n  fun provideAppLifecycleProvider(): AppLifecycleProvider {\n    return GalleryLifecycleProvider()\n  }\n\n  // Provides DataStoreRepository\n  @Provides\n  @Singleton\n  fun provideDataStoreRepository(\n    dataStore: DataStore<Settings>,\n    userDataDataStore: DataStore<UserData>,\n    cutoutsDataStore: DataStore<CutoutCollection>,\n    benchmarkResultsStore: DataStore<BenchmarkResults>,\n  ): DataStoreRepository {\n    return DefaultDataStoreRepository(\n      dataStore,\n      userDataDataStore,\n      cutoutsDataStore,\n      benchmarkResultsStore,\n    )\n  }\n\n  // Provides DownloadRepository\n  @Provides\n  @Singleton\n  fun provideDownloadRepository(\n    @ApplicationContext context: Context,\n    lifecycleProvider: AppLifecycleProvider,\n  ): DownloadRepository {\n    return DefaultDownloadRepository(context, lifecycleProvider)\n  }\n}\n"
  },
  {
    "path": "Android/src/app/src/main/java/com/google/ai/edge/gallery/runtime/LlmModelHelper.kt",
    "content": "/*\n * Copyright 2026 Google LLC\n *\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n *     http://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\n\npackage com.google.ai.edge.gallery.runtime\n\nimport android.content.Context\nimport android.graphics.Bitmap\nimport com.google.ai.edge.gallery.data.Model\nimport com.google.ai.edge.litertlm.Contents\nimport kotlinx.coroutines.CoroutineScope\n\ntypealias ResultListener = (partialResult: String, done: Boolean) -> Unit\n\ntypealias CleanUpListener = () -> Unit\n\n/**\n * Base interface for all LLM runtimes. It defines the foundational operations needed to initialize,\n * manage conversations, execute inferences, and clean up resources for different Large Language\n * Model backends.\n */\ninterface LlmModelHelper {\n  /**\n   * Initializes the LLM runtime with the specified configuration.\n   *\n   * @param context the application context.\n   * @param model the model to be initialized.\n   * @param supportImage whether to support image input.\n   * @param supportAudio whether to support audio input.\n   * @param onDone callback invoked when initialization is completed successfully.\n   * @param systemInstruction instruction provided to guide the model's behavior.\n   * @param tools tools available for the model to use.\n   * @param enableConversationConstrainedDecoding whether to enable constrained decoding for\n   *   conversations.\n   * @param coroutineScope optional coroutine scope for async execution.\n   */\n  fun initialize(\n    context: Context,\n    model: Model,\n    supportImage: Boolean,\n    supportAudio: Boolean,\n    onDone: (String) -> Unit,\n    systemInstruction: Contents? = null,\n    tools: List<Any> = listOf(),\n    enableConversationConstrainedDecoding: Boolean = false,\n    coroutineScope: CoroutineScope? = null,\n  )\n\n  /**\n   * Resets the conversation context for the specified model.\n   *\n   * @param model the model whose conversation context needs to be reset.\n   * @param supportImage whether to preserve support for image input.\n   * @param supportAudio whether to preserve support for audio input.\n   * @param systemInstruction new system instruction to guide the model's behavior after reset.\n   * @param tools new or updated tools available for the model.\n   * @param enableConversationConstrainedDecoding whether to enable constrained decoding.\n   */\n  fun resetConversation(\n    model: Model,\n    supportImage: Boolean = false,\n    supportAudio: Boolean = false,\n    systemInstruction: Contents? = null,\n    tools: List<Any> = listOf(),\n    enableConversationConstrainedDecoding: Boolean = false,\n  )\n\n  /**\n   * Cleans up resources occupied by the model.\n   *\n   * @param model the model whose resources should be cleaned up.\n   * @param onDone callback invoked when clean up completes.\n   */\n  fun cleanUp(model: Model, onDone: () -> Unit)\n\n  /**\n   * Runs an inference pass on the specified model.\n   *\n   * @param model the model to run inference on.\n   * @param input the input text for inference.\n   * @param resultListener callback invoked with partial inference results.\n   * @param cleanUpListener callback invoked to trigger necessary cleanup.\n   * @param onError callback invoked if an error occurs during inference.\n   * @param images optional list of images provided as input context.\n   * @param audioClips optional list of audio clips provided as input context.\n   * @param coroutineScope optional coroutine scope for async inference execution.\n   */\n  fun runInference(\n    model: Model,\n    input: String,\n    resultListener: ResultListener,\n    cleanUpListener: CleanUpListener,\n    onError: (message: String) -> Unit = {},\n    images: List<Bitmap> = listOf(),\n    audioClips: List<ByteArray> = listOf(),\n    coroutineScope: CoroutineScope? = null,\n  )\n\n  /**\n   * Stops the ongoing response generation for the model.\n   *\n   * @param model the ongoing model response to be stopped.\n   */\n  fun stopResponse(model: Model)\n}\n"
  },
  {
    "path": "Android/src/app/src/main/java/com/google/ai/edge/gallery/runtime/ModelHelperExt.kt",
    "content": "/*\n * Copyright 2026 Google LLC\n *\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n *     http://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\n\npackage com.google.ai.edge.gallery.runtime\n\nimport com.google.ai.edge.gallery.data.Model\nimport com.google.ai.edge.gallery.data.RuntimeType\nimport com.google.ai.edge.gallery.ui.llmchat.LlmChatModelHelper\n\nval Model.runtimeHelper: LlmModelHelper\n  get() {\n    return LlmChatModelHelper\n  }\n"
  },
  {
    "path": "Android/src/app/src/main/java/com/google/ai/edge/gallery/ui/benchmark/BenchmarkModelPicker.kt",
    "content": "/*\n * Copyright 2026 Google LLC\n *\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n *     http://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\n\npackage com.google.ai.edge.gallery.ui.benchmark\n\nimport androidx.annotation.StringRes\nimport androidx.compose.foundation.background\nimport androidx.compose.foundation.clickable\nimport androidx.compose.foundation.layout.Arrangement\nimport androidx.compose.foundation.layout.Column\nimport androidx.compose.foundation.layout.Row\nimport androidx.compose.foundation.layout.fillMaxWidth\nimport androidx.compose.foundation.layout.padding\nimport androidx.compose.foundation.layout.size\nimport androidx.compose.foundation.layout.sizeIn\nimport androidx.compose.foundation.lazy.LazyColumn\nimport androidx.compose.foundation.lazy.items\nimport androidx.compose.foundation.shape.RoundedCornerShape\nimport androidx.compose.material.icons.Icons\nimport androidx.compose.material.icons.rounded.ArrowDropDown\nimport androidx.compose.material.icons.rounded.CheckCircle\nimport androidx.compose.material3.ExperimentalMaterial3Api\nimport androidx.compose.material3.Icon\nimport androidx.compose.material3.MaterialTheme\nimport androidx.compose.material3.ModalBottomSheet\nimport androidx.compose.material3.Text\nimport androidx.compose.material3.rememberModalBottomSheetState\nimport androidx.compose.runtime.Composable\nimport androidx.compose.runtime.getValue\nimport androidx.compose.runtime.mutableStateOf\nimport androidx.compose.runtime.remember\nimport androidx.compose.runtime.rememberCoroutineScope\nimport androidx.compose.runtime.setValue\nimport androidx.compose.ui.Alignment\nimport androidx.compose.ui.Modifier\nimport androidx.compose.ui.draw.alpha\nimport androidx.compose.ui.draw.clip\nimport androidx.compose.ui.res.stringResource\nimport androidx.compose.ui.text.style.TextOverflow\nimport androidx.compose.ui.unit.dp\nimport kotlinx.coroutines.delay\nimport kotlinx.coroutines.launch\n\n@OptIn(ExperimentalMaterial3Api::class)\n@Composable\nfun BenchmarkModelPicker(\n  selectedModelName: String,\n  modelNames: List<String>,\n  @StringRes titleResId: Int,\n  onSelected: (String) -> Unit,\n) {\n  val scope = rememberCoroutineScope()\n  var showBottomSheet by remember { mutableStateOf(false) }\n  val sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true)\n\n  Row(\n    verticalAlignment = Alignment.CenterVertically,\n    horizontalArrangement = Arrangement.spacedBy(4.dp),\n    modifier =\n      Modifier.clip(RoundedCornerShape(8.dp))\n        .clickable { showBottomSheet = true }\n        .background(MaterialTheme.colorScheme.secondaryContainer)\n        .padding(4.dp)\n        .padding(start = 8.dp),\n  ) {\n    Text(\n      selectedModelName,\n      style = MaterialTheme.typography.labelLarge,\n      maxLines = 1,\n      overflow = TextOverflow.MiddleEllipsis,\n      modifier = Modifier.weight(1f, fill = false),\n    )\n    Icon(\n      Icons.Rounded.ArrowDropDown,\n      modifier = Modifier.size(20.dp).sizeIn(minWidth = 20.dp),\n      contentDescription = null,\n    )\n  }\n\n  // Model picker.\n  if (showBottomSheet) {\n    ModalBottomSheet(\n      onDismissRequest = { showBottomSheet = false },\n      sheetState = sheetState,\n      containerColor = MaterialTheme.colorScheme.surface,\n    ) {\n      Column(modifier = Modifier.fillMaxWidth().padding(bottom = 16.dp)) {\n        Text(\n          stringResource(titleResId),\n          style = MaterialTheme.typography.titleLarge,\n          color = MaterialTheme.colorScheme.onSurface,\n          modifier = Modifier.padding(16.dp),\n        )\n        LazyColumn {\n          items(modelNames) { modelName ->\n            Row(\n              modifier =\n                Modifier.clickable {\n                    onSelected(modelName)\n                    scope.launch {\n                      delay(200)\n                      sheetState.hide()\n                      showBottomSheet = false\n                    }\n                  }\n                  .padding(horizontal = 16.dp, vertical = 6.dp)\n                  .fillMaxWidth(),\n              verticalAlignment = Alignment.CenterVertically,\n              horizontalArrangement = Arrangement.spacedBy(16.dp),\n            ) {\n              Icon(\n                Icons.Rounded.CheckCircle,\n                contentDescription = null,\n                tint = MaterialTheme.colorScheme.secondary,\n                modifier = Modifier.alpha(if (modelName == selectedModelName) 1f else 0f),\n              )\n              Text(\n                modelName,\n                color = MaterialTheme.colorScheme.onSurface,\n                style = MaterialTheme.typography.labelLarge,\n              )\n            }\n          }\n        }\n      }\n    }\n  }\n}\n"
  },
  {
    "path": "Android/src/app/src/main/java/com/google/ai/edge/gallery/ui/benchmark/BenchmarkResultsViewer.kt",
    "content": "/*\n * Copyright 2026 Google LLC\n *\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n *     http://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\n\npackage com.google.ai.edge.gallery.ui.benchmark\n\nimport android.content.ClipData\nimport androidx.activity.compose.BackHandler\nimport androidx.compose.animation.AnimatedContent\nimport androidx.compose.animation.fadeIn\nimport androidx.compose.animation.fadeOut\nimport androidx.compose.animation.scaleIn\nimport androidx.compose.animation.scaleOut\nimport androidx.compose.animation.slideInVertically\nimport androidx.compose.animation.slideOutVertically\nimport androidx.compose.animation.togetherWith\nimport androidx.compose.foundation.background\nimport androidx.compose.foundation.border\nimport androidx.compose.foundation.clickable\nimport androidx.compose.foundation.layout.Arrangement\nimport androidx.compose.foundation.layout.Box\nimport androidx.compose.foundation.layout.Column\nimport androidx.compose.foundation.layout.PaddingValues\nimport androidx.compose.foundation.layout.Row\nimport androidx.compose.foundation.layout.Spacer\nimport androidx.compose.foundation.layout.fillMaxSize\nimport androidx.compose.foundation.layout.fillMaxWidth\nimport androidx.compose.foundation.layout.height\nimport androidx.compose.foundation.layout.offset\nimport androidx.compose.foundation.layout.padding\nimport androidx.compose.foundation.layout.size\nimport androidx.compose.foundation.layout.width\nimport androidx.compose.foundation.lazy.LazyColumn\nimport androidx.compose.foundation.lazy.itemsIndexed\nimport androidx.compose.foundation.shape.RoundedCornerShape\nimport androidx.compose.material.icons.Icons\nimport androidx.compose.material.icons.automirrored.outlined.HelpOutline\nimport androidx.compose.material.icons.rounded.ArrowDropDown\nimport androidx.compose.material.icons.rounded.Check\nimport androidx.compose.material.icons.rounded.Close\nimport androidx.compose.material.icons.rounded.ContentCopy\nimport androidx.compose.material.icons.rounded.DeleteOutline\nimport androidx.compose.material.icons.rounded.UnfoldLessDouble\nimport androidx.compose.material.icons.rounded.UnfoldMoreDouble\nimport androidx.compose.material3.AlertDialog\nimport androidx.compose.material3.Button\nimport androidx.compose.material3.ButtonDefaults\nimport androidx.compose.material3.CenterAlignedTopAppBar\nimport androidx.compose.material3.CircularProgressIndicator\nimport androidx.compose.material3.DropdownMenu\nimport androidx.compose.material3.DropdownMenuItem\nimport androidx.compose.material3.ExperimentalMaterial3Api\nimport androidx.compose.material3.FilterChip\nimport androidx.compose.material3.Icon\nimport androidx.compose.material3.IconButton\nimport androidx.compose.material3.MaterialTheme\nimport androidx.compose.material3.ModalBottomSheet\nimport androidx.compose.material3.OutlinedButton\nimport androidx.compose.material3.Scaffold\nimport androidx.compose.material3.Text\nimport androidx.compose.material3.rememberModalBottomSheetState\nimport androidx.compose.runtime.Composable\nimport androidx.compose.runtime.LaunchedEffect\nimport androidx.compose.runtime.collectAsState\nimport androidx.compose.runtime.getValue\nimport androidx.compose.runtime.mutableStateListOf\nimport androidx.compose.runtime.mutableStateOf\nimport androidx.compose.runtime.remember\nimport androidx.compose.runtime.rememberCoroutineScope\nimport androidx.compose.runtime.setValue\nimport androidx.compose.ui.Alignment\nimport androidx.compose.ui.Modifier\nimport androidx.compose.ui.draw.clip\nimport androidx.compose.ui.draw.drawBehind\nimport androidx.compose.ui.geometry.Offset\nimport androidx.compose.ui.graphics.Brush\nimport androidx.compose.ui.graphics.Color\nimport androidx.compose.ui.graphics.PathEffect\nimport androidx.compose.ui.platform.ClipEntry\nimport androidx.compose.ui.platform.LocalClipboard\nimport androidx.compose.ui.platform.LocalResources\nimport androidx.compose.ui.res.stringResource\nimport androidx.compose.ui.text.style.TextAlign\nimport androidx.compose.ui.text.style.TextOverflow\nimport androidx.compose.ui.unit.dp\nimport com.google.ai.edge.gallery.R\nimport com.google.ai.edge.gallery.proto.LlmBenchmarkResult\nimport com.google.ai.edge.gallery.proto.ValueSeries\nimport com.google.ai.edge.gallery.ui.common.Accordions\nimport com.google.ai.edge.gallery.ui.common.MarkdownText\nimport com.google.ai.edge.gallery.ui.common.SMALL_BUTTON_CONTENT_PADDING\nimport com.google.ai.edge.gallery.ui.modelmanager.ModelManagerViewModel\nimport com.google.ai.edge.gallery.ui.theme.customColors\nimport java.text.SimpleDateFormat\nimport java.util.Date\nimport java.util.Locale\nimport kotlin.math.abs\nimport kotlinx.coroutines.delay\nimport kotlinx.coroutines.launch\n\n@OptIn(ExperimentalMaterial3Api::class)\n@Composable\nfun BenchmarkResultsViewer(\n  initialModelName: String,\n  modelManagerViewModel: ModelManagerViewModel,\n  viewModel: BenchmarkViewModel,\n  onClose: () -> Unit,\n) {\n  val scope = rememberCoroutineScope()\n  val uiState by viewModel.uiState.collectAsState()\n  var showConfirmDeleteDialog by remember { mutableStateOf(false) }\n  var showLazyListPlacementAnimation by remember { mutableStateOf(false) }\n  var showBenchmarkComparisonHelpBottomSheet by remember { mutableStateOf(false) }\n  var benchmarkResultIdToDelete by remember { mutableStateOf(\"\") }\n  val filterableModelNames = remember { mutableStateListOf<String>() }\n  var selectedModelName by remember { mutableStateOf(initialModelName) }\n  val filteredResults = remember { mutableStateListOf<BenchmarkResultInfo>() }\n  val strAll = stringResource(R.string.all)\n  val sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true)\n\n  // Update filterable model names.\n  LaunchedEffect(uiState.results) {\n    filterableModelNames.clear()\n    filterableModelNames.add(strAll)\n    filterableModelNames.addAll(\n      uiState.results.mapNotNull { it.benchmarkResult.llmResult?.baiscInfo?.modelName }.distinct()\n    )\n  }\n\n  // Update filteredResults when selected model is changed.\n  LaunchedEffect(selectedModelName, uiState.results) {\n    filteredResults.clear()\n    filteredResults.addAll(\n      uiState.results.filter {\n        selectedModelName == strAll ||\n          it.benchmarkResult.llmResult?.baiscInfo?.modelName == selectedModelName\n      }\n    )\n  }\n\n  // Reset baseline when model selection is changed.\n  LaunchedEffect(selectedModelName) { viewModel.clearBaseline() }\n\n  // Show \"benchmark comparison help\" bottom sheet when there are multiple results available.\n  LaunchedEffect(filteredResults.size) {\n    if (\n      filteredResults.size > 1 && !viewModel.dataStoreRepository.getHasSeenBenchmarkComparisonHelp()\n    ) {\n      delay(500)\n      showBenchmarkComparisonHelpBottomSheet = true\n      viewModel.dataStoreRepository.setHasSeenBenchmarkComparisonHelp(true)\n    }\n  }\n\n  // Close it when back button is clicked.\n  BackHandler {\n    if (!uiState.running) {\n      onClose()\n    }\n  }\n\n  Scaffold(\n    topBar = {\n      CenterAlignedTopAppBar(\n        // Title label.\n        title = {\n          if (!uiState.running) {\n            Column(horizontalAlignment = Alignment.CenterHorizontally) {\n              Text(\n                stringResource(R.string.benchmark_results),\n                style = MaterialTheme.typography.titleMedium,\n                color = MaterialTheme.colorScheme.onSurface,\n              )\n              BenchmarkModelPicker(\n                selectedModelName = selectedModelName,\n                modelNames = filterableModelNames,\n                titleResId = R.string.select_model,\n                onSelected = {\n                  showLazyListPlacementAnimation = true\n                  selectedModelName = it\n                  scope.launch {\n                    delay(500)\n                    showLazyListPlacementAnimation = false\n                  }\n                },\n              )\n            }\n          }\n        },\n        navigationIcon = {\n          if (filteredResults.size > 1) {\n            IconButton(onClick = { showBenchmarkComparisonHelpBottomSheet = true }) {\n              Icon(\n                Icons.AutoMirrored.Outlined.HelpOutline,\n                contentDescription = stringResource(R.string.cd_help),\n              )\n            }\n          } else {\n            Spacer(modifier = Modifier.size(48.dp))\n          }\n        },\n        // The close button.\n        actions = {\n          if (!uiState.running) {\n            IconButton(onClick = onClose) {\n              Icon(Icons.Rounded.Close, contentDescription = stringResource(R.string.close))\n            }\n          }\n        },\n      )\n    },\n    modifier = Modifier.fillMaxSize(),\n  ) { innerPadding ->\n    Box(modifier = Modifier.fillMaxSize()) {\n      AnimatedContent(\n        targetState = uiState.running,\n        transitionSpec = {\n          // Running.\n          if (targetState) {\n            scaleIn(initialScale = 0.8f) + fadeIn() togetherWith\n              scaleOut(targetScale = 0.8f) + fadeOut()\n          }\n          // Results.\n          else {\n            slideInVertically { 40 } + fadeIn() togetherWith slideOutVertically { 40 } + fadeOut()\n          }\n        },\n      ) { running ->\n        // Running in progress.\n        if (running) {\n          Box(\n            modifier = Modifier.fillMaxSize().padding(top = innerPadding.calculateTopPadding()),\n            contentAlignment = Alignment.Center,\n          ) {\n            Column(\n              horizontalAlignment = Alignment.CenterHorizontally,\n              verticalArrangement = Arrangement.Center,\n              modifier =\n                Modifier.fillMaxSize().padding(bottom = innerPadding.calculateBottomPadding()),\n            ) {\n              Column(\n                verticalArrangement = Arrangement.spacedBy(24.dp),\n                horizontalAlignment = Alignment.CenterHorizontally,\n              ) {\n                // Progress spinner.\n                CircularProgressIndicator(strokeWidth = 4.dp, modifier = Modifier.size(36.dp))\n                // Info text.\n                Text(\n                  stringResource(R.string.running_benchmark_msg),\n                  style = MaterialTheme.typography.titleMedium,\n                  color = MaterialTheme.colorScheme.onSurface,\n                )\n                // Progress text.\n                Text(\n                  \"${uiState.completedRunCount} / ${uiState.totalRunCount}\",\n                  color = MaterialTheme.colorScheme.onSurfaceVariant,\n                  style = MaterialTheme.typography.labelLarge,\n                )\n              }\n            }\n          }\n        } else {\n          Box(\n            modifier =\n              Modifier.fillMaxSize()\n                .padding(top = innerPadding.calculateTopPadding())\n                .background(MaterialTheme.colorScheme.surfaceContainer),\n            contentAlignment = Alignment.TopCenter,\n          ) {\n            Column(modifier = Modifier.fillMaxWidth()) {\n              // Results.\n              //\n              // Empty state.\n              if (filteredResults.isEmpty()) {\n                Column(\n                  verticalArrangement = Arrangement.Center,\n                  horizontalAlignment = Alignment.CenterHorizontally,\n                  modifier = Modifier.fillMaxSize(),\n                ) {\n                  Text(\n                    stringResource(R.string.benchmark_no_results),\n                    color = MaterialTheme.colorScheme.onSurfaceVariant,\n                    style = MaterialTheme.typography.titleMedium,\n                    modifier = Modifier.padding(horizontal = 32.dp),\n                    textAlign = TextAlign.Center,\n                  )\n                }\n              } else {\n                // List.\n                LazyColumn(modifier = Modifier.fillMaxWidth().padding(horizontal = 16.dp)) {\n                  item { Spacer(modifier = Modifier.height(16.dp)) }\n                  if (filteredResults.size > 1) {\n                    item {\n                      Row(\n                        verticalAlignment = Alignment.CenterVertically,\n                        horizontalArrangement = Arrangement.spacedBy(8.dp),\n                        modifier = Modifier.padding(bottom = 16.dp),\n                      ) {\n                        OutlinedButton(\n                          onClick = { viewModel.expandAll() },\n                          contentPadding = SMALL_BUTTON_CONTENT_PADDING,\n                        ) {\n                          Icon(\n                            Icons.Rounded.UnfoldMoreDouble,\n                            contentDescription = null,\n                            modifier = Modifier.padding(end = 4.dp).size(16.dp),\n                          )\n                          Text(stringResource(R.string.expand_all))\n                        }\n                        OutlinedButton(\n                          onClick = { viewModel.collapseAll() },\n                          contentPadding = SMALL_BUTTON_CONTENT_PADDING,\n                        ) {\n                          Icon(\n                            Icons.Rounded.UnfoldLessDouble,\n                            contentDescription = null,\n                            modifier = Modifier.padding(end = 4.dp).size(16.dp),\n                          )\n                          Text(stringResource(R.string.collapse_all))\n                        }\n                      }\n                    }\n                  }\n                  itemsIndexed(items = filteredResults, key = { index, item -> item.id }) {\n                    index,\n                    result ->\n                    // Result card.\n                    var cardModifier = Modifier.clip(RoundedCornerShape(20.dp)).fillMaxWidth()\n                    if (showLazyListPlacementAnimation) {\n                      cardModifier = cardModifier.animateItem()\n                    }\n                    result.benchmarkResult.llmResult?.let { llmResult ->\n                      val modelName = llmResult.baiscInfo.modelName\n                      Accordions(\n                        title = \"$modelName · ${llmResult.baiscInfo.accelerator}\",\n                        subtitle =\n                          SimpleDateFormat(\"yyyy-MM-dd HH:mm:ss\", Locale.getDefault())\n                            .format(Date(llmResult.baiscInfo.startMs)),\n                        boldTitle = true,\n                        expanded = result.expanded,\n                        onExpandedChange = { viewModel.setExpanded(id = result.id, expanded = it) },\n                        modifier = cardModifier,\n                        titleRowAction = {\n                          // A chip to toggle on/off baseline, used for set the comparison base.\n                          // Only visible when there are >2 results.\n                          if (filteredResults.size > 1) {\n                            FilterChip(\n                              onClick = { viewModel.setBaseline(id = result.id) },\n                              label = {\n                                Text(\n                                  stringResource(R.string.baseline),\n                                  style = MaterialTheme.typography.labelSmall,\n                                )\n                              },\n                              selected = result.id == uiState.baselineResult?.id,\n                              leadingIcon =\n                                if (result.id == uiState.baselineResult?.id) {\n                                  {\n                                    Icon(\n                                      Icons.Rounded.Check,\n                                      contentDescription = null,\n                                      modifier = Modifier.size(16.dp).offset(x = 2.dp),\n                                    )\n                                  }\n                                } else {\n                                  null\n                                },\n                              modifier = Modifier.height(24.dp),\n                            )\n                          }\n                        },\n                      ) {\n                        Column(\n                          verticalArrangement = Arrangement.spacedBy(8.dp),\n                          modifier = Modifier.padding(bottom = 2.dp),\n                        ) {\n                          // Basic info.\n                          Accordions(\n                            title = stringResource(R.string.basic_info),\n                            bgColor = MaterialTheme.colorScheme.surfaceContainerLow,\n                            expanded = result.basicInfoExpanded,\n                            onExpandedChange = {\n                              viewModel.setBasicInfoExpanded(id = result.id, expanded = it)\n                            },\n                            modifier = Modifier.clip(RoundedCornerShape(12.dp)),\n                          ) {\n                            Column(\n                              verticalArrangement = Arrangement.spacedBy(8.dp),\n                              modifier = Modifier.padding(start = 6.dp, top = 6.dp, bottom = 4.dp),\n                            ) {\n                              StatRow(label = \"Model\", value = llmResult.baiscInfo.modelName)\n                              StatRow(\n                                label = \"Accelerator\",\n                                value = llmResult.baiscInfo.accelerator,\n                              )\n                              StatRow(\n                                label = \"Prefill tokens\",\n                                value = \"${llmResult.baiscInfo.prefillTokens}\",\n                              )\n                              StatRow(\n                                label = \"Decode tokens\",\n                                value = \"${llmResult.baiscInfo.decodeTokens}\",\n                              )\n                              StatRow(\n                                label = \"Number of runs\",\n                                value = \"${llmResult.baiscInfo.numberOfRuns}\",\n                              )\n                              StatRow(label = \"App version\", value = llmResult.baiscInfo.appVersion)\n                            }\n                          }\n\n                          // Stats\n                          val resources = LocalResources.current\n                          Accordions(\n                            title =\n                              \"${stringResource(R.string.results)} (${resources.getQuantityString(\n                                R.plurals.runs ,\n                                llmResult.baiscInfo.numberOfRuns,\n                                llmResult.baiscInfo.numberOfRuns,\n                              )})\",\n                            bgColor = MaterialTheme.colorScheme.surfaceContainerLow,\n                            expanded = result.statsExpanded,\n                            onExpandedChange = {\n                              viewModel.setStatsExpanded(id = result.id, expanded = it)\n                            },\n                            modifier = Modifier.clip(RoundedCornerShape(12.dp)),\n                            titleRowAction = {\n                              if (\n                                (result.benchmarkResult.llmResult?.baiscInfo?.numberOfRuns ?: 0) > 1\n                              ) {\n                                var showAggregationDropdown by remember { mutableStateOf(false) }\n                                // Aggregation method.\n                                Box {\n                                  Row(\n                                    verticalAlignment = Alignment.CenterVertically,\n                                    modifier =\n                                      Modifier.clip(RoundedCornerShape(8.dp))\n                                        .clickable { showAggregationDropdown = true }\n                                        .background(\n                                          MaterialTheme.colorScheme.surfaceContainerLowest\n                                        )\n                                        .border(\n                                          width = 1.dp,\n                                          color = MaterialTheme.colorScheme.outlineVariant,\n                                          shape = RoundedCornerShape(8.dp),\n                                        )\n                                        .padding(start = 8.dp, end = 0.dp)\n                                        .height(24.dp),\n                                  ) {\n                                    Text(\n                                      result.aggregation.label,\n                                      color = MaterialTheme.colorScheme.onSurfaceVariant,\n                                      style = MaterialTheme.typography.labelMedium,\n                                    )\n                                    Icon(\n                                      Icons.Rounded.ArrowDropDown,\n                                      modifier = Modifier.size(20.dp),\n                                      contentDescription = null,\n                                    )\n                                  }\n                                  DropdownMenu(\n                                    expanded = showAggregationDropdown,\n                                    onDismissRequest = { showAggregationDropdown = false },\n                                  ) {\n                                    for (aggregation in Aggregation.entries) {\n                                      DropdownMenuItem(\n                                        text = { Text(aggregation.label) },\n                                        onClick = {\n                                          showAggregationDropdown = false\n                                          viewModel.setAggregation(\n                                            id = result.id,\n                                            aggregation = aggregation,\n                                          )\n                                        },\n                                      )\n                                    }\n                                  }\n                                }\n                              }\n                            },\n                            hideTitleRowActionOnCollapse = true,\n                          ) {\n                            Column(\n                              verticalArrangement = Arrangement.spacedBy(8.dp),\n                              modifier = Modifier.padding(start = 6.dp, top = 6.dp),\n                            ) {\n                              val baselineStats =\n                                uiState.baselineResult?.benchmarkResult?.llmResult?.stats\n                              ValueSeriesRow(\n                                label = \"Prefill speed\",\n                                valueSeries = llmResult.stats.prefillSpeed,\n                                aggregation = result.aggregation,\n                                unit = \"tokens/sec\",\n                                baselineValueSeries =\n                                  if (result.id != uiState.baselineResult?.id) {\n                                    baselineStats?.prefillSpeed\n                                  } else {\n                                    null\n                                  },\n                                baselineAggregation =\n                                  if (result.id != uiState.baselineResult?.id) {\n                                    uiState.baselineResult?.aggregation\n                                  } else {\n                                    null\n                                  },\n                              )\n                              ValueSeriesRow(\n                                label = \"Decode speed\",\n                                valueSeries = llmResult.stats.decodeSpeed,\n                                aggregation = result.aggregation,\n                                unit = \"tokens/sec\",\n                                baselineValueSeries =\n                                  if (result.id != uiState.baselineResult?.id) {\n                                    baselineStats?.decodeSpeed\n                                  } else {\n                                    null\n                                  },\n                                baselineAggregation =\n                                  if (result.id != uiState.baselineResult?.id) {\n                                    uiState.baselineResult?.aggregation\n                                  } else {\n                                    null\n                                  },\n                              )\n                              ValueSeriesRow(\n                                label = \"Time to first token\",\n                                valueSeries = llmResult.stats.timeToFirstToken,\n                                aggregation = result.aggregation,\n                                unit = \"sec\",\n                                baselineValueSeries =\n                                  if (result.id != uiState.baselineResult?.id) {\n                                    baselineStats?.timeToFirstToken\n                                  } else {\n                                    null\n                                  },\n                                baselineAggregation =\n                                  if (result.id != uiState.baselineResult?.id) {\n                                    uiState.baselineResult?.aggregation\n                                  } else {\n                                    null\n                                  },\n                                lessIsBetter = true,\n                              )\n                              StatRow(\n                                label = \"First init time\",\n                                value =\n                                  String.format(\n                                    Locale.getDefault(),\n                                    \"%.2f\",\n                                    llmResult.stats.firstInitTimeMs,\n                                  ),\n                                unit = \"ms\",\n                                baselineValue =\n                                  if (result.id != uiState.baselineResult?.id) {\n                                    baselineStats?.firstInitTimeMs\n                                  } else {\n                                    null\n                                  },\n                                lessIsBetter = true,\n                              )\n                              if (llmResult.stats.nonFirstInitTimeMs.valueCount > 1) {\n                                ValueSeriesRow(\n                                  label = \"Steady init time\",\n                                  valueSeries = llmResult.stats.nonFirstInitTimeMs,\n                                  aggregation = result.aggregation,\n                                  unit = \"ms\",\n                                  baselineValueSeries =\n                                    if (result.id != uiState.baselineResult?.id) {\n                                      baselineStats?.nonFirstInitTimeMs\n                                    } else {\n                                      null\n                                    },\n                                  baselineAggregation =\n                                    if (result.id != uiState.baselineResult?.id) {\n                                      uiState.baselineResult?.aggregation\n                                    } else {\n                                      null\n                                    },\n                                  lessIsBetter = true,\n                                )\n                              }\n                            }\n                          }\n\n                          // Buttons.\n                          Row(\n                            verticalAlignment = Alignment.CenterVertically,\n                            horizontalArrangement = Arrangement.End,\n                            modifier = Modifier.fillMaxWidth(),\n                          ) {\n                            // Delete.\n                            OutlinedButton(\n                              onClick = {\n                                benchmarkResultIdToDelete = result.id\n                                showConfirmDeleteDialog = true\n                              },\n                              contentPadding = SMALL_BUTTON_CONTENT_PADDING,\n                            ) {\n                              Row(\n                                verticalAlignment = Alignment.CenterVertically,\n                                horizontalArrangement = Arrangement.spacedBy(4.dp),\n                              ) {\n                                Icon(\n                                  Icons.Rounded.DeleteOutline,\n                                  contentDescription = null,\n                                  modifier = Modifier.size(20.dp),\n                                )\n                                Text(stringResource(R.string.delete))\n                              }\n                            }\n\n                            Spacer(modifier = Modifier.width(8.dp))\n\n                            // Copy\n                            val clipboard = LocalClipboard.current\n                            Button(\n                              onClick = {\n                                scope.launch {\n                                  // Copy csv to clipboard.\n                                  val csv =\n                                    getBenchmarkResultCsv(\n                                      llmResult = llmResult,\n                                      aggregation = result.aggregation,\n                                    )\n                                  val clipData =\n                                    ClipData.newPlainText(\"benchmark results for ${modelName}\", csv)\n                                  val clipEntry = ClipEntry(clipData = clipData)\n                                  clipboard.setClipEntry(clipEntry = clipEntry)\n                                }\n                              },\n                              colors =\n                                ButtonDefaults.buttonColors(\n                                  containerColor = MaterialTheme.colorScheme.secondaryContainer\n                                ),\n                              contentPadding = SMALL_BUTTON_CONTENT_PADDING,\n                            ) {\n                              Row(\n                                verticalAlignment = Alignment.CenterVertically,\n                                horizontalArrangement = Arrangement.spacedBy(4.dp),\n                              ) {\n                                Icon(\n                                  Icons.Rounded.ContentCopy,\n                                  contentDescription = null,\n                                  modifier = Modifier.size(20.dp),\n                                  tint = MaterialTheme.colorScheme.onSecondaryContainer,\n                                )\n                                Text(\n                                  stringResource(R.string.copy),\n                                  color = MaterialTheme.colorScheme.onSecondaryContainer,\n                                )\n                              }\n                            }\n                          }\n                        }\n                      }\n                    }\n                    if (index != filteredResults.size - 1) {\n                      Spacer(modifier = Modifier.height(12.dp).animateItem(placementSpec = null))\n                    }\n                  }\n                  item { Spacer(modifier = Modifier.height(innerPadding.calculateBottomPadding())) }\n                }\n              }\n            }\n\n            // Gradient overlay at the bottom.\n            Box(\n              modifier =\n                Modifier.fillMaxWidth()\n                  .height(innerPadding.calculateBottomPadding())\n                  .background(\n                    Brush.verticalGradient(\n                      colors = listOf(Color.Transparent, MaterialTheme.colorScheme.surfaceContainer)\n                    )\n                  )\n                  .align(Alignment.BottomCenter)\n            )\n          }\n        }\n      }\n    }\n  }\n\n  if (showConfirmDeleteDialog) {\n    AlertDialog(\n      onDismissRequest = { showConfirmDeleteDialog = false },\n      title = { Text(stringResource(R.string.delete_benchmark_result_dialog_title)) },\n      text = { Text(stringResource(R.string.delete_benchmark_result_dialog_content)) },\n      confirmButton = {\n        Button(\n          onClick = {\n            showLazyListPlacementAnimation = true\n            showConfirmDeleteDialog = false\n            viewModel.deleteBenchmarkResult(id = benchmarkResultIdToDelete)\n\n            scope.launch {\n              delay(500)\n              showLazyListPlacementAnimation = false\n            }\n          },\n          contentPadding = SMALL_BUTTON_CONTENT_PADDING,\n        ) {\n          Text(stringResource(R.string.delete))\n        }\n      },\n      dismissButton = {\n        OutlinedButton(\n          onClick = { showConfirmDeleteDialog = false },\n          contentPadding = SMALL_BUTTON_CONTENT_PADDING,\n        ) {\n          Text(stringResource(R.string.cancel))\n        }\n      },\n    )\n  }\n\n  if (showBenchmarkComparisonHelpBottomSheet) {\n    ModalBottomSheet(\n      onDismissRequest = { showBenchmarkComparisonHelpBottomSheet = false },\n      sheetState = sheetState,\n    ) {\n      Column(\n        modifier = Modifier.padding(horizontal = 16.dp).padding(bottom = 16.dp),\n        verticalArrangement = Arrangement.spacedBy(16.dp),\n      ) {\n        Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {\n          Icon(Icons.AutoMirrored.Outlined.HelpOutline, contentDescription = null)\n          Text(\n            stringResource(R.string.benchmark_comparison_help_title),\n            style = MaterialTheme.typography.titleMedium,\n          )\n        }\n        MarkdownText(\n          text = stringResource(R.string.benchmark_comparison_help_content),\n          smallFontSize = true,\n        )\n        OutlinedButton(\n          onClick = {\n            scope.launch {\n              sheetState.hide()\n              showBenchmarkComparisonHelpBottomSheet = false\n            }\n          },\n          contentPadding = SMALL_BUTTON_CONTENT_PADDING,\n          modifier = Modifier.align(alignment = Alignment.End),\n        ) {\n          Text(stringResource(R.string.dismiss))\n        }\n      }\n    }\n  }\n}\n\n@Composable\nprivate fun StatRow(\n  label: String,\n  value: String,\n  modifier: Modifier = Modifier,\n  unit: String = \"\",\n  baselineValue: Double? = null,\n  lessIsBetter: Boolean = false,\n) {\n  Row(modifier = modifier.fillMaxWidth(), verticalAlignment = Alignment.Top) {\n    // label.\n    Text(\n      label,\n      style = MaterialTheme.typography.labelMedium,\n      color = MaterialTheme.colorScheme.onSurface,\n      modifier = Modifier.weight(0.6f),\n      maxLines = 1,\n      overflow = TextOverflow.MiddleEllipsis,\n    )\n    // Value\n    Column(\n      verticalArrangement = Arrangement.Top,\n      horizontalAlignment = Alignment.Start,\n      modifier = Modifier.weight(0.4f),\n    ) {\n      Row(\n        modifier = Modifier.fillMaxWidth(),\n        verticalAlignment = Alignment.CenterVertically,\n        horizontalArrangement = Arrangement.SpaceBetween,\n      ) {\n        Text(\n          value,\n          style = MaterialTheme.typography.labelMedium,\n          color = MaterialTheme.colorScheme.onSurface,\n          maxLines = 1,\n          overflow = TextOverflow.MiddleEllipsis,\n        )\n        AnimatedContent(\n          baselineValue,\n          contentAlignment = Alignment.CenterStart,\n          transitionSpec = { fadeIn() togetherWith fadeOut() },\n        ) { curBaselineValue ->\n          if (curBaselineValue != null) {\n            val doubleValue = value.toDouble()\n            val pct = (doubleValue - curBaselineValue) / curBaselineValue * 100\n            val strPct = String.format(Locale.getDefault(), \"%.1f\", abs(pct))\n            val sign = if (pct >= 0.0) \"+\" else \"-\"\n            val betterSign = if (lessIsBetter) \"-\" else \"+\"\n            val color =\n              if (sign == betterSign) {\n                MaterialTheme.customColors.successColor\n              } else {\n                MaterialTheme.customColors.errorTextColor\n              }\n            Text(\"$sign$strPct%\", style = MaterialTheme.typography.labelMedium, color = color)\n          }\n        }\n      }\n      if (unit.isNotEmpty()) {\n        Text(\n          unit,\n          style = MaterialTheme.typography.labelMedium,\n          color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.7f),\n        )\n      }\n    }\n  }\n}\n\n@Composable\nprivate fun ValueSeriesRow(\n  label: String,\n  valueSeries: ValueSeries,\n  aggregation: Aggregation,\n  modifier: Modifier = Modifier,\n  unit: String = \"\",\n  baselineValueSeries: ValueSeries? = null,\n  baselineAggregation: Aggregation? = null,\n  lessIsBetter: Boolean = false,\n) {\n  val value = getAggregationValue(valueSeries = valueSeries, aggregation = aggregation)\n  var baselineValue: Double? = null\n  if (baselineValueSeries != null && baselineAggregation != null) {\n    baselineValue =\n      getAggregationValue(valueSeries = baselineValueSeries, aggregation = baselineAggregation)\n  }\n  var showValueSeriesBottomSheet by remember { mutableStateOf(false) }\n\n  Row(modifier = modifier.fillMaxWidth(), verticalAlignment = Alignment.Top) {\n    // label.\n    Text(\n      label,\n      style = MaterialTheme.typography.labelMedium,\n      color = MaterialTheme.colorScheme.onSurface,\n      modifier = Modifier.weight(0.6f),\n      maxLines = 1,\n      overflow = TextOverflow.MiddleEllipsis,\n    )\n    // Value\n    Column(\n      verticalArrangement = Arrangement.Top,\n      horizontalAlignment = Alignment.Start,\n      modifier = Modifier.weight(0.4f),\n    ) {\n      Row(\n        modifier = Modifier.fillMaxWidth(),\n        verticalAlignment = Alignment.CenterVertically,\n        horizontalArrangement = Arrangement.SpaceBetween,\n      ) {\n        val linkColor = MaterialTheme.customColors.linkColor\n        val isMultipleRuns = valueSeries.valueCount > 1\n        val textColor = if (isMultipleRuns) linkColor else MaterialTheme.colorScheme.onSurface\n        val textModifier =\n          if (isMultipleRuns) {\n            Modifier.drawBehind {\n                val strokeWidth = 2f\n                val y = size.height - strokeWidth\n\n                // Define the dash pattern: 8px line, 8px gap\n                val dashPath = PathEffect.dashPathEffect(floatArrayOf(8f, 8f), 0f)\n\n                drawLine(\n                  color = linkColor,\n                  start = Offset(0f, y),\n                  end = Offset(size.width, y),\n                  strokeWidth = strokeWidth,\n                  pathEffect = dashPath,\n                )\n              }\n              .clickable { showValueSeriesBottomSheet = true }\n          } else {\n            Modifier\n          }\n        AnimatedContent(value) { curValue ->\n          Text(\n            String.format(Locale.getDefault(), \"%.2f\", curValue),\n            style = MaterialTheme.typography.labelMedium,\n            color = textColor,\n            maxLines = 1,\n            overflow = TextOverflow.MiddleEllipsis,\n            modifier = textModifier,\n          )\n        }\n        AnimatedContent(\n          baselineValue,\n          contentAlignment = Alignment.CenterStart,\n          transitionSpec = { fadeIn() togetherWith fadeOut() },\n        ) { curBaselineValue ->\n          if (curBaselineValue != null && abs(curBaselineValue) > 1e-6) {\n            val pct = (value - curBaselineValue) / curBaselineValue * 100\n            val strPct = String.format(Locale.getDefault(), \"%.1f\", abs(pct))\n            val sign = if (pct >= 0.0) \"+\" else \"-\"\n            val betterSign = if (lessIsBetter) \"-\" else \"+\"\n            val color =\n              if (sign == betterSign) {\n                MaterialTheme.customColors.successColor\n              } else {\n                MaterialTheme.customColors.errorTextColor\n              }\n            Text(\"$sign$strPct%\", style = MaterialTheme.typography.labelMedium, color = color)\n          }\n        }\n      }\n      if (unit.isNotEmpty()) {\n        Text(\n          unit,\n          style = MaterialTheme.typography.labelMedium,\n          color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.7f),\n        )\n      }\n    }\n  }\n\n  if (showValueSeriesBottomSheet) {\n    BenchmarkValueSeriesViewer(\n      title = \"$label ($unit)\",\n      valueSeries = valueSeries,\n      onDismiss = { showValueSeriesBottomSheet = false },\n    )\n  }\n}\n\nprivate fun getBenchmarkResultCsv(llmResult: LlmBenchmarkResult, aggregation: Aggregation): String {\n  val basicInfo = llmResult.baiscInfo\n  val stats = llmResult.stats\n\n  val header =\n    listOf(\n        \"start time (ms)\",\n        \"end time (ms)\",\n        \"model name\",\n        \"accelerator\",\n        \"prefill tokens count\",\n        \"decode tokens count\",\n        \"runs count\",\n        \"app version\",\n        \"prefill speed (tokens/sec)\",\n        \"decode speed (tokens/sec)\",\n        \"time to first token (sec)\",\n        \"first init time (ms)\",\n        \"steady init time (ms)\",\n      )\n      .joinToString(\",\")\n\n  val data =\n    listOf(\n        basicInfo.startMs,\n        basicInfo.endMs,\n        basicInfo.modelName,\n        basicInfo.accelerator,\n        basicInfo.prefillTokens,\n        basicInfo.decodeTokens,\n        basicInfo.numberOfRuns,\n        basicInfo.appVersion,\n        getAggregationValue(stats.prefillSpeed, aggregation),\n        getAggregationValue(stats.decodeSpeed, aggregation),\n        getAggregationValue(stats.timeToFirstToken, aggregation),\n        stats.firstInitTimeMs,\n        getAggregationValue(stats.nonFirstInitTimeMs, aggregation),\n      )\n      .joinToString(\",\")\n\n  return \"$header\\n$data\"\n}\n\nprivate fun getAggregationValue(valueSeries: ValueSeries, aggregation: Aggregation): Double {\n  return when (aggregation) {\n    Aggregation.AVG -> valueSeries.avg\n    Aggregation.MEDIAN -> valueSeries.medium\n    // Aggregation.P25 -> valueSeries.pct25\n    // Aggregation.P75 -> valueSeries.pct75\n    Aggregation.MIN -> valueSeries.min\n    Aggregation.MAX -> valueSeries.max\n  }\n}\n"
  },
  {
    "path": "Android/src/app/src/main/java/com/google/ai/edge/gallery/ui/benchmark/BenchmarkScreen.kt",
    "content": "/*\n * Copyright 2026 Google LLC\n *\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n *     http://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\n\npackage com.google.ai.edge.gallery.ui.benchmark\n\nimport android.os.Bundle\nimport androidx.compose.animation.AnimatedVisibility\nimport androidx.compose.animation.fadeIn\nimport androidx.compose.animation.fadeOut\nimport androidx.compose.animation.slideInVertically\nimport androidx.compose.animation.slideOutVertically\nimport androidx.compose.foundation.layout.Arrangement\nimport androidx.compose.foundation.layout.Box\nimport androidx.compose.foundation.layout.Column\nimport androidx.compose.foundation.layout.Row\nimport androidx.compose.foundation.layout.Spacer\nimport androidx.compose.foundation.layout.fillMaxSize\nimport androidx.compose.foundation.layout.fillMaxWidth\nimport androidx.compose.foundation.layout.imePadding\nimport androidx.compose.foundation.layout.padding\nimport androidx.compose.foundation.layout.size\nimport androidx.compose.foundation.layout.width\nimport androidx.compose.foundation.rememberScrollState\nimport androidx.compose.foundation.verticalScroll\nimport androidx.compose.material.icons.Icons\nimport androidx.compose.material.icons.automirrored.rounded.ArrowBack\nimport androidx.compose.material.icons.automirrored.rounded.List\nimport androidx.compose.material.icons.rounded.BarChart\nimport androidx.compose.material3.AlertDialog\nimport androidx.compose.material3.Button\nimport androidx.compose.material3.CenterAlignedTopAppBar\nimport androidx.compose.material3.ExperimentalMaterial3Api\nimport androidx.compose.material3.Icon\nimport androidx.compose.material3.IconButton\nimport androidx.compose.material3.MaterialTheme\nimport androidx.compose.material3.OutlinedButton\nimport androidx.compose.material3.Scaffold\nimport androidx.compose.material3.Text\nimport androidx.compose.runtime.Composable\nimport androidx.compose.runtime.LaunchedEffect\nimport androidx.compose.runtime.collectAsState\nimport androidx.compose.runtime.getValue\nimport androidx.compose.runtime.mutableStateListOf\nimport androidx.compose.runtime.mutableStateMapOf\nimport androidx.compose.runtime.mutableStateOf\nimport androidx.compose.runtime.remember\nimport androidx.compose.runtime.setValue\nimport androidx.compose.runtime.snapshots.SnapshotStateMap\nimport androidx.compose.ui.Alignment\nimport androidx.compose.ui.Modifier\nimport androidx.compose.ui.res.stringResource\nimport androidx.compose.ui.unit.dp\nimport androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel\nimport com.google.ai.edge.gallery.GalleryEvent\nimport com.google.ai.edge.gallery.R\nimport com.google.ai.edge.gallery.data.Accelerator\nimport com.google.ai.edge.gallery.data.Config\nimport com.google.ai.edge.gallery.data.ConfigKey\nimport com.google.ai.edge.gallery.data.ConfigKeys\nimport com.google.ai.edge.gallery.data.Model\nimport com.google.ai.edge.gallery.data.NumberSliderConfig\nimport com.google.ai.edge.gallery.data.SegmentedButtonConfig\nimport com.google.ai.edge.gallery.data.ValueType\nimport com.google.ai.edge.gallery.data.convertValueToTargetType\nimport com.google.ai.edge.gallery.firebaseAnalytics\nimport com.google.ai.edge.gallery.ui.common.ConfigEditorsPanel\nimport com.google.ai.edge.gallery.ui.common.SMALL_BUTTON_CONTENT_PADDING\nimport com.google.ai.edge.gallery.ui.modelmanager.ModelManagerViewModel\nimport com.google.ai.edge.gallery.ui.theme.customColors\n\n@OptIn(ExperimentalMaterial3Api::class)\n@Composable\nfun BenchmarkScreen(\n  initialModel: Model,\n  modelManagerViewModel: ModelManagerViewModel,\n  modifier: Modifier = Modifier,\n  viewModel: BenchmarkViewModel = hiltViewModel(),\n  onBackClicked: () -> Unit,\n) {\n  val uiState by viewModel.uiState.collectAsState()\n  var enableBackButton by remember { mutableStateOf(true) }\n  var showRunBenchmarkConfirmationDialog by remember { mutableStateOf(false) }\n  val downloadedLlmModelNames = remember {\n    modelManagerViewModel.getAllDownloadedModels().filter { it.isLlm }.map { it.name }\n  }\n  var selectedModelName by remember { mutableStateOf(initialModel.name) }\n  var selectedModel by\n    remember(selectedModelName) {\n      mutableStateOf(modelManagerViewModel.getModelByName(name = selectedModelName)!!)\n    }\n  val filteredResults = remember { mutableStateListOf<BenchmarkResultInfo>() }\n  val configs =\n    remember(selectedModel) {\n      mutableStateListOf<Config>().apply {\n        add(\n          SegmentedButtonConfig(\n            key = ConfigKeys.ACCELERATOR,\n            defaultValue = selectedModel.accelerators.getOrNull(0)?.label ?: Accelerator.CPU.label,\n            options = selectedModel.accelerators.map { it.label },\n            allowMultiple = false,\n          )\n        )\n        add(\n          NumberSliderConfig(\n            key = ConfigKeys.PREFILL_TOKENS,\n            sliderMin = 16f,\n            sliderMax = 1024f,\n            defaultValue = 256f,\n            valueType = ValueType.INT,\n          )\n        )\n        add(\n          NumberSliderConfig(\n            key = ConfigKeys.DECODE_TOKENS,\n            sliderMin = 16f,\n            sliderMax = 1024f,\n            defaultValue = 256f,\n            valueType = ValueType.INT,\n          )\n        )\n        add(\n          NumberSliderConfig(\n            key = ConfigKeys.NUMBER_OF_RUNS,\n            sliderMin = 1f,\n            sliderMax = 10f,\n            defaultValue = 3f,\n            valueType = ValueType.INT,\n          )\n        )\n      }\n    }\n\n  val values: SnapshotStateMap<String, Any> =\n    remember(configs) {\n      mutableStateMapOf<String, Any>().apply {\n        for (config in configs) {\n          put(config.key.label, config.defaultValue)\n        }\n      }\n    }\n\n  val sumOfPrefillAndDecodeTokens =\n    getIntConfigValue(values = values, key = ConfigKeys.PREFILL_TOKENS) +\n      getIntConfigValue(values = values, key = ConfigKeys.DECODE_TOKENS)\n  val maxToken = selectedModel.llmMaxToken\n\n  // Update filteredResults when selected model is changed.\n  LaunchedEffect(selectedModelName, uiState.results) {\n    filteredResults.clear()\n    filteredResults.addAll(\n      uiState.results.filter {\n        it.benchmarkResult.llmResult?.baiscInfo?.modelName == selectedModelName\n      }\n    )\n  }\n\n  Box(modifier = Modifier.fillMaxSize()) {\n    // Benchmark configs.\n    Scaffold(\n      topBar = {\n        CenterAlignedTopAppBar(\n          // Title icon and label.\n          title = {\n            Column(horizontalAlignment = Alignment.CenterHorizontally) {\n              Text(\n                stringResource(R.string.benchmark_model),\n                style = MaterialTheme.typography.titleMedium,\n                color = MaterialTheme.colorScheme.onSurface,\n              )\n              BenchmarkModelPicker(\n                selectedModelName = selectedModelName,\n                modelNames = downloadedLlmModelNames,\n                titleResId = R.string.select_downloaded_model,\n                onSelected = { selectedModelName = it },\n              )\n            }\n          },\n          // The back button.\n          navigationIcon = {\n            IconButton(onClick = onBackClicked, enabled = enableBackButton) {\n              Icon(\n                imageVector = Icons.AutoMirrored.Rounded.ArrowBack,\n                contentDescription = stringResource(R.string.cd_navigate_back_icon),\n              )\n            }\n          },\n          actions = { Spacer(modifier = Modifier.size(48.dp)) },\n        )\n      },\n      modifier = Modifier.imePadding(),\n    ) { innerPadding ->\n      Box(\n        modifier = Modifier.padding(innerPadding).fillMaxSize(),\n        contentAlignment = Alignment.TopCenter,\n      ) {\n        Column(modifier = Modifier.padding(16.dp).fillMaxSize()) {\n          // Config items.\n          Column(\n            modifier = Modifier.weight(1f).fillMaxWidth().verticalScroll(rememberScrollState()),\n            verticalArrangement = Arrangement.spacedBy(24.dp),\n          ) {\n            ConfigEditorsPanel(configs = configs, values = values)\n\n            // Info text on the limit of the sum of prefill and decode tokens.\n            Text(\n              stringResource(\n                R.string.benchmark_tokens_limit_message,\n                sumOfPrefillAndDecodeTokens,\n                maxToken,\n              ),\n              style = MaterialTheme.typography.bodyMedium,\n              color =\n                if (sumOfPrefillAndDecodeTokens > maxToken)\n                  MaterialTheme.customColors.warningTextColor\n                else MaterialTheme.colorScheme.onSurfaceVariant,\n            )\n          }\n\n          // Buttons.\n          Row(\n            verticalAlignment = Alignment.CenterVertically,\n            horizontalArrangement = Arrangement.spacedBy(8.dp),\n            modifier = Modifier.padding(top = 8.dp).fillMaxWidth(),\n          ) {\n            // View results.\n            OutlinedButton(\n              enabled = filteredResults.isNotEmpty(),\n              onClick = {\n                viewModel.setShowResultsViewer(showResultsViewer = true)\n                firebaseAnalytics?.logEvent(\n                  GalleryEvent.BUTTON_CLICKED.id,\n                  Bundle().apply {\n                    putString(\"event_type\", \"view_benchmark_results\")\n                    putString(\"model_id\", selectedModelName)\n                  },\n                )\n              },\n              modifier = Modifier.weight(1f),\n            ) {\n              Icon(Icons.AutoMirrored.Rounded.List, contentDescription = null)\n              Spacer(modifier = Modifier.width(4.dp))\n              Text(stringResource(R.string.view_results))\n            }\n            // Run benchmark.\n            Button(\n              enabled = sumOfPrefillAndDecodeTokens <= maxToken,\n              onClick = {\n                modelManagerViewModel.getModelByName(name = selectedModelName)?.let { model ->\n                  showRunBenchmarkConfirmationDialog = true\n                }\n              },\n              modifier = Modifier.weight(1f),\n            ) {\n              Icon(Icons.Rounded.BarChart, contentDescription = null)\n              Spacer(modifier = Modifier.width(4.dp))\n              Text(stringResource(R.string.benchmark))\n            }\n          }\n        }\n      }\n    }\n\n    // Results viewer.\n    AnimatedVisibility(\n      visible = uiState.showResultsViewer,\n      enter = slideInVertically(initialOffsetY = { fullHeight -> fullHeight }) + fadeIn(),\n      exit = slideOutVertically(targetOffsetY = { fullHeight -> fullHeight }) + fadeOut(),\n    ) {\n      BenchmarkResultsViewer(\n        initialModelName = selectedModelName,\n        modelManagerViewModel = modelManagerViewModel,\n        viewModel = viewModel,\n        onClose = { viewModel.setShowResultsViewer(showResultsViewer = false) },\n      )\n    }\n  }\n\n  // Confirmation dialog for running benchmark.\n  if (showRunBenchmarkConfirmationDialog) {\n    AlertDialog(\n      title = { Text(stringResource(R.string.run_benchmark)) },\n      text = { Text(stringResource(R.string.run_benchmark_confirmation_msg)) },\n      onDismissRequest = { showRunBenchmarkConfirmationDialog = false },\n      dismissButton = {\n        OutlinedButton(\n          onClick = { showRunBenchmarkConfirmationDialog = false },\n          contentPadding = SMALL_BUTTON_CONTENT_PADDING,\n        ) {\n          Text(stringResource(R.string.cancel))\n        }\n      },\n      confirmButton = {\n        Button(\n          onClick = {\n            viewModel.runBenchmark(\n              model = selectedModel,\n              accelerator = getStringConfigValue(values = values, key = ConfigKeys.ACCELERATOR),\n              prefillTokens = getIntConfigValue(values = values, key = ConfigKeys.PREFILL_TOKENS),\n              decodeTokens = getIntConfigValue(values = values, key = ConfigKeys.DECODE_TOKENS),\n              runCount = getIntConfigValue(values = values, key = ConfigKeys.NUMBER_OF_RUNS),\n            )\n            firebaseAnalytics?.logEvent(\n              GalleryEvent.BUTTON_CLICKED.id,\n              Bundle().apply {\n                putString(\"event_type\", \"run_benchmark\")\n                putString(\"model_id\", selectedModelName)\n              },\n            )\n            showRunBenchmarkConfirmationDialog = false\n          },\n          contentPadding = SMALL_BUTTON_CONTENT_PADDING,\n        ) {\n          Text(stringResource(R.string.continue_button_label))\n        }\n      },\n    )\n  }\n}\n\nprivate fun getStringConfigValue(values: Map<String, Any>, key: ConfigKey): String {\n  return convertValueToTargetType(value = values.get(key.label) ?: \"\", valueType = ValueType.STRING)\n    as String\n}\n\nprivate fun getIntConfigValue(values: Map<String, Any>, key: ConfigKey): Int {\n  return convertValueToTargetType(value = values.get(key.label) ?: 0, valueType = ValueType.INT)\n    as Int\n}\n"
  },
  {
    "path": "Android/src/app/src/main/java/com/google/ai/edge/gallery/ui/benchmark/BenchmarkValueSeriesViewer.kt",
    "content": "/*\n * Copyright 2026 Google LLC\n *\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n *     http://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\n\npackage com.google.ai.edge.gallery.ui.benchmark\n\nimport androidx.compose.foundation.Canvas\nimport androidx.compose.foundation.background\nimport androidx.compose.foundation.layout.Arrangement\nimport androidx.compose.foundation.layout.Column\nimport androidx.compose.foundation.layout.Row\nimport androidx.compose.foundation.layout.fillMaxWidth\nimport androidx.compose.foundation.layout.height\nimport androidx.compose.foundation.layout.padding\nimport androidx.compose.foundation.shape.RoundedCornerShape\nimport androidx.compose.foundation.text.TextAutoSize\nimport androidx.compose.material3.ExperimentalMaterial3Api\nimport androidx.compose.material3.MaterialTheme\nimport androidx.compose.material3.ModalBottomSheet\nimport androidx.compose.material3.Text\nimport androidx.compose.material3.rememberModalBottomSheetState\nimport androidx.compose.runtime.Composable\nimport androidx.compose.runtime.getValue\nimport androidx.compose.runtime.mutableStateOf\nimport androidx.compose.runtime.remember\nimport androidx.compose.runtime.setValue\nimport androidx.compose.ui.Alignment\nimport androidx.compose.ui.Modifier\nimport androidx.compose.ui.draw.clip\nimport androidx.compose.ui.geometry.Offset\nimport androidx.compose.ui.graphics.PathEffect.Companion.dashPathEffect\nimport androidx.compose.ui.graphics.drawscope.Stroke\nimport androidx.compose.ui.input.pointer.PointerEventType\nimport androidx.compose.ui.input.pointer.pointerInput\nimport androidx.compose.ui.res.stringResource\nimport androidx.compose.ui.unit.dp\nimport androidx.compose.ui.unit.sp\nimport com.google.ai.edge.gallery.R\nimport com.google.ai.edge.gallery.proto.ValueSeries\nimport com.google.ai.edge.gallery.ui.theme.customColors\nimport java.util.Locale\n\n@OptIn(ExperimentalMaterial3Api::class)\n@Composable\nfun BenchmarkValueSeriesViewer(title: String, valueSeries: ValueSeries, onDismiss: () -> Unit) {\n  val sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true)\n\n  ModalBottomSheet(\n    onDismissRequest = onDismiss,\n    sheetState = sheetState,\n    containerColor = MaterialTheme.colorScheme.surface,\n  ) {\n    Column(\n      modifier = Modifier.padding(horizontal = 16.dp).padding(bottom = 16.dp),\n      verticalArrangement = Arrangement.spacedBy(12.dp),\n    ) {\n      // Title.\n      Text(\n        title,\n        style = MaterialTheme.typography.titleMedium,\n        color = MaterialTheme.colorScheme.onSurface,\n      )\n\n      val values = valueSeries.valueList\n      if (values.isNotEmpty()) {\n        Column(verticalArrangement = Arrangement.spacedBy(4.dp)) {\n          val lineColor = MaterialTheme.colorScheme.outline\n          val dotBgColor = MaterialTheme.colorScheme.surface\n          val dotBorderColor = MaterialTheme.colorScheme.outline\n          val tappedLineColor = MaterialTheme.customColors.linkColor\n          var tappedValue by remember { mutableStateOf<Double?>(null) }\n\n          // Value for tapped point.\n          Text(\n            if (tappedValue == null) {\n              stringResource(R.string.tap_to_see_value)\n            } else {\n              \"Value: ${String.format(Locale.getDefault(), \"%.2f\", tappedValue)}\"\n            },\n            style = MaterialTheme.typography.labelMedium,\n            color = MaterialTheme.colorScheme.onSurfaceVariant,\n          )\n\n          // Sparkline.\n          val verticalPaddingFactor = 0.2f\n          val min = valueSeries.min\n          val max = valueSeries.max\n          val range = max - min\n          val effectiveMin = min - (range * verticalPaddingFactor)\n          val effectiveMax = max + (range * verticalPaddingFactor)\n          val scaledYRange = effectiveMax - effectiveMin\n          Canvas(\n            modifier =\n              Modifier.clip(RoundedCornerShape(8.dp))\n                .background(MaterialTheme.colorScheme.surfaceContainer)\n                .fillMaxWidth()\n                .height(80.dp)\n                .pointerInput(values) {\n                  awaitPointerEventScope {\n                    while (true) {\n                      val event = awaitPointerEvent()\n                      val position = event.changes.firstOrNull()?.position\n                      if (position != null) {\n                        // Update on Press and Move events\n                        if (\n                          event.type == PointerEventType.Press ||\n                            event.type == PointerEventType.Move\n                        ) {\n                          val tappedY = position.y\n                          val value = effectiveMin + (1f - (tappedY / size.height)) * scaledYRange\n                          tappedValue = value\n                          // Consume the event to prevent it from being propagated to parent\n                          event.changes.forEach { it.consume() }\n                        }\n                      }\n                    }\n                  }\n                }\n          ) {\n            val horizontalPaddingDp = 12.dp\n            val horizontalPaddingPx = horizontalPaddingDp.toPx()\n            val width = size.width - horizontalPaddingPx * 2\n            val height = size.height\n\n            val xStep = if (values.size > 1) width / (values.size - 1) else 0f\n\n            val points =\n              values.mapIndexed { index, value ->\n                val x = index * xStep + horizontalPaddingPx\n                val y = height - ((value - effectiveMin) / scaledYRange) * height\n                Offset(x, y.toFloat())\n              }\n\n            // Draw lines connecting the points\n            for (i in 0 until points.size - 1) {\n              drawLine(\n                color = lineColor,\n                start = points[i],\n                end = points[i + 1],\n                strokeWidth = 2.dp.toPx(),\n              )\n            }\n\n            // Draw dots for each value\n            val dotRadius = 4.dp.toPx()\n            val dotBorderWidth = 2.dp.toPx()\n            for (offset in points) {\n              // background\n              drawCircle(color = dotBgColor, radius = dotRadius, center = offset)\n              // border\n              drawCircle(\n                color = dotBorderColor,\n                radius = dotRadius,\n                center = offset,\n                style = Stroke(width = dotBorderWidth),\n              )\n            }\n\n            // Draw dashed line for tapped value\n            if (tappedValue != null) {\n              val y = size.height - ((tappedValue!! - effectiveMin) / scaledYRange) * size.height\n              val start = Offset(0f, y.toFloat())\n              val end = Offset(size.width, y.toFloat())\n              val dashIntervals = floatArrayOf(10f, 10f) // 10px on, 10px off\n              drawLine(\n                color = tappedLineColor,\n                start = start,\n                end = end,\n                strokeWidth = 1.dp.toPx(),\n                pathEffect = dashPathEffect(dashIntervals, 0f),\n              )\n            }\n          }\n        }\n      }\n\n      // Stats.\n      Row(\n        verticalAlignment = Alignment.CenterVertically,\n        horizontalArrangement = Arrangement.SpaceBetween,\n        modifier = Modifier.fillMaxWidth(),\n      ) {\n        StatCell(key = \"avg\", value = valueSeries.avg)\n        StatCell(key = \"median\", value = valueSeries.medium)\n        StatCell(key = \"min\", value = valueSeries.min)\n        StatCell(key = \"max\", value = valueSeries.max)\n      }\n    }\n  }\n}\n\n@Composable\nprivate fun StatCell(key: String, value: Double) {\n  Column() {\n    Text(\n      String.format(Locale.getDefault(), \"%.2f\", value),\n      style = MaterialTheme.typography.labelMedium,\n      color = MaterialTheme.colorScheme.onSurfaceVariant,\n      maxLines = 1,\n      autoSize = TextAutoSize.StepBased(minFontSize = 8.sp, maxFontSize = 12.sp, stepSize = 1.sp),\n    )\n    Text(\n      key,\n      style = MaterialTheme.typography.labelSmall,\n      color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.7f),\n    )\n  }\n}\n"
  },
  {
    "path": "Android/src/app/src/main/java/com/google/ai/edge/gallery/ui/benchmark/BenchmarkViewModel.kt",
    "content": "/*\n * Copyright 2026 Google LLC\n *\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n *     http://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\npackage com.google.ai.edge.gallery.ui.benchmark\n\nimport android.content.Context\nimport android.util.Log\nimport androidx.lifecycle.ViewModel\nimport androidx.lifecycle.viewModelScope\nimport com.google.ai.edge.gallery.BuildConfig\nimport com.google.ai.edge.gallery.data.DataStoreRepository\nimport com.google.ai.edge.gallery.data.Model\nimport com.google.ai.edge.gallery.proto.BenchmarkResult\nimport com.google.ai.edge.gallery.proto.LlmBenchmarkBasicInfo\nimport com.google.ai.edge.gallery.proto.LlmBenchmarkResult\nimport com.google.ai.edge.gallery.proto.LlmBenchmarkStats\nimport com.google.ai.edge.gallery.proto.ValueSeries\nimport com.google.ai.edge.litertlm.Backend\nimport com.google.ai.edge.litertlm.ExperimentalApi\nimport com.google.ai.edge.litertlm.ExperimentalFlags\nimport com.google.ai.edge.litertlm.benchmark\nimport dagger.hilt.android.lifecycle.HiltViewModel\nimport dagger.hilt.android.qualifiers.ApplicationContext\nimport java.io.File\nimport javax.inject.Inject\nimport kotlin.math.ceil\nimport kotlin.math.floor\nimport kotlin.random.Random\nimport kotlinx.coroutines.Dispatchers\nimport kotlinx.coroutines.flow.MutableStateFlow\nimport kotlinx.coroutines.flow.asStateFlow\nimport kotlinx.coroutines.flow.update\nimport kotlinx.coroutines.launch\n\nprivate const val TAG = \"AGBenchmarkVM\"\n\nenum class Aggregation(val label: String) {\n  AVG(label = \"avg\"),\n  MEDIAN(label = \"median\"),\n  MIN(label = \"min\"),\n  MAX(label = \"max\"),\n  // P25(label = \"p25\"),\n  // P75(label = \"p75\"),\n}\n\ndata class BenchmarkResultInfo(\n  val id: String,\n  val benchmarkResult: BenchmarkResult,\n  val expanded: Boolean = false,\n  val basicInfoExpanded: Boolean = true,\n  val statsExpanded: Boolean = true,\n  val aggregation: Aggregation = Aggregation.AVG,\n)\n\ndata class BenchmarkUiState(\n  val results: List<BenchmarkResultInfo> = listOf(),\n  val baselineResult: BenchmarkResultInfo? = null,\n  val showResultsViewer: Boolean = false,\n  val running: Boolean = false,\n  val totalRunCount: Int = 0,\n  val completedRunCount: Int = 0,\n)\n\n@HiltViewModel\nclass BenchmarkViewModel\n@Inject\nconstructor(\n  @ApplicationContext private val appContext: Context,\n  val dataStoreRepository: DataStoreRepository,\n) : ViewModel() {\n  protected val _uiState = MutableStateFlow(BenchmarkUiState())\n  val uiState = _uiState.asStateFlow()\n\n  init {\n    // Load results from storage.\n    val storedResults = dataStoreRepository.getAllBenchmarkResults()\n    Log.d(TAG, \"Loaded ${storedResults.size} benchmark results\")\n    setBenchmarkResults(results = storedResults)\n    collapseAll()\n  }\n\n  @OptIn(ExperimentalApi::class)\n  fun runBenchmark(\n    model: Model,\n    accelerator: String,\n    prefillTokens: Int,\n    decodeTokens: Int,\n    runCount: Int,\n  ) {\n    viewModelScope.launch(Dispatchers.Default) {\n      setRunning(running = true)\n      setRunProgress(completedRunCount = 0)\n      setTotalRunCount(totalRunCount = runCount)\n      setShowResultsViewer(showResultsViewer = true)\n\n      val parts: List<String> =\n        listOf(\n          \"- model: ${model.name}\",\n          \"- accelerator: $accelerator\",\n          \"- prefill tokens: $prefillTokens\",\n          \"- decode tokens: $decodeTokens\",\n          \"- runs: $runCount\",\n        )\n      Log.d(TAG, \"Running benchmark: ${parts.joinToString(\"\\n\")}\")\n\n      // TODO: handle error.\n      val startMs = System.currentTimeMillis()\n      val prefillSpeeds = mutableListOf<Double>()\n      val decodeSpeeds = mutableListOf<Double>()\n      val timesToFirstToken = mutableListOf<Double>()\n      var firstInitTime = 0.0\n      val nonFirstInitTimes = mutableListOf<Double>()\n      // Create a temporary cache dir to run benchmark in.\n      val timestamp = System.currentTimeMillis()\n      var needCleanUpCacheDir = true\n      val benchmarkCacheDir = File(appContext.cacheDir, \"benchmark_$timestamp\")\n      var cacheDirPath = benchmarkCacheDir.absolutePath\n      if (!benchmarkCacheDir.mkdirs()) {\n        Log.e(TAG, \"Failed to create benchmark cache directory: ${benchmarkCacheDir.absolutePath}\")\n        cacheDirPath = appContext.cacheDir.absolutePath\n        needCleanUpCacheDir = false\n      }\n      Log.d(TAG, \"Using benchmark cache dir: $cacheDirPath\")\n      val backend: Backend =\n        when (accelerator.lowercase()) {\n          \"gpu\" -> Backend.GPU()\n          \"npu\" -> Backend.NPU()\n          else -> Backend.CPU()\n        }\n      if (backend is Backend.NPU) {\n        ExperimentalFlags.npuLibrariesDir = appContext.applicationInfo.nativeLibraryDir\n      }\n      val modelPath = model.getPath(context = appContext)\n      for (i in 0 until runCount) {\n        Log.d(TAG, \"Start running #$i...\")\n        val benchmarkInfo =\n          benchmark(\n            modelPath = modelPath,\n            backend = backend,\n            prefillTokens = prefillTokens,\n            decodeTokens = decodeTokens,\n            cacheDir = cacheDirPath,\n          )\n        Log.d(TAG, \"Done #$i\")\n\n        val initTimeMs = benchmarkInfo.initTimeInSecond * 1000.0\n        if (i == 0) {\n          firstInitTime = initTimeMs\n        } else {\n          nonFirstInitTimes.add(initTimeMs)\n        }\n        prefillSpeeds.add(benchmarkInfo.lastPrefillTokensPerSecond)\n        decodeSpeeds.add(benchmarkInfo.lastDecodeTokensPerSecond)\n        timesToFirstToken.add(benchmarkInfo.timeToFirstTokenInSecond)\n\n        // Mark finish for this run.\n        setRunProgress(completedRunCount = i + 1)\n      }\n      val endMs = System.currentTimeMillis()\n      if (needCleanUpCacheDir) {\n        benchmarkCacheDir.deleteRecursively()\n        Log.d(TAG, \"Cleaned up benchmark cache dir: ${benchmarkCacheDir.absolutePath}\")\n      }\n\n      // Create and add benchmark result.\n      val basicInfo =\n        LlmBenchmarkBasicInfo.newBuilder()\n          .setStartMs(startMs)\n          .setEndMs(endMs)\n          .setModelName(model.name)\n          .setAccelerator(accelerator)\n          .setPrefillTokens(prefillTokens)\n          .setDecodeTokens(decodeTokens)\n          .setNumberOfRuns(runCount)\n          .setAppVersion(BuildConfig.VERSION_NAME)\n          .build()\n      val stats =\n        LlmBenchmarkStats.newBuilder()\n          .setPrefillSpeed(calculateValueSeries(prefillSpeeds))\n          .setDecodeSpeed(calculateValueSeries(decodeSpeeds))\n          .setTimeToFirstToken(calculateValueSeries(timesToFirstToken))\n          .setFirstInitTimeMs(firstInitTime)\n          .setNonFirstInitTimeMs(calculateValueSeries(nonFirstInitTimes))\n          .build()\n\n      val result =\n        BenchmarkResult.newBuilder()\n          .setLlmResult(\n            LlmBenchmarkResult.newBuilder().setBaiscInfo(basicInfo).setStats(stats).build()\n          )\n          .build()\n      val newId = addBenchmarkResult(result = result)\n      collapseAll()\n      setExpanded(id = newId, expanded = true)\n\n      setRunning(running = false)\n    }\n  }\n\n  fun setShowResultsViewer(showResultsViewer: Boolean) {\n    _uiState.update { _uiState.value.copy(showResultsViewer = showResultsViewer) }\n  }\n\n  fun setRunning(running: Boolean) {\n    _uiState.update { _uiState.value.copy(running = running) }\n  }\n\n  fun setTotalRunCount(totalRunCount: Int) {\n    _uiState.update { _uiState.value.copy(totalRunCount = totalRunCount) }\n  }\n\n  fun setRunProgress(completedRunCount: Int) {\n    _uiState.update { _uiState.value.copy(completedRunCount = completedRunCount) }\n  }\n\n  fun addBenchmarkResult(result: BenchmarkResult): String {\n    val newResults = _uiState.value.results.toMutableList()\n    // Add the new result to the beginning of the list.\n    val newId = \"${Random.nextDouble()}\"\n    newResults.add(\n      0,\n      BenchmarkResultInfo(\n        benchmarkResult = result,\n        id = newId,\n        basicInfoExpanded = true,\n        statsExpanded = true,\n      ),\n    )\n    _uiState.update { _uiState.value.copy(results = newResults) }\n\n    // Save to storage.\n    dataStoreRepository.addBenchmarkResult(result)\n\n    return newId\n  }\n\n  fun setBenchmarkResults(results: List<BenchmarkResult>) {\n    _uiState.update {\n      _uiState.value.copy(\n        results =\n          results.map { result ->\n            BenchmarkResultInfo(\n              benchmarkResult = result,\n              expanded = false,\n              id = \"${Random.nextDouble()}\",\n              basicInfoExpanded = false,\n              statsExpanded = true,\n            )\n          }\n      )\n    }\n  }\n\n  fun deleteBenchmarkResult(id: String) {\n    val newResults = _uiState.value.results.toMutableList()\n    val index = newResults.indexOfFirst { it.id == id }\n    if (index != -1) {\n      val deletedResult = newResults.removeAt(index)\n      _uiState.update { _uiState.value.copy(results = newResults) }\n      if (deletedResult.id == uiState.value.baselineResult?.id) {\n        _uiState.update { _uiState.value.copy(baselineResult = null) }\n      }\n\n      // Update storage.\n      dataStoreRepository.deleteBenchmarkResult(index = index)\n    } else {\n      Log.w(TAG, \"Benchmark result with id $id not found.\")\n    }\n  }\n\n  fun setBaseline(id: String) {\n    if (id == uiState.value.baselineResult?.id) {\n      clearBaseline()\n    } else {\n      val result = _uiState.value.results.firstOrNull { it.id == id }\n      if (result == null) {\n        Log.w(TAG, \"Benchmark result with id $id not found.\")\n        return\n      }\n      _uiState.update { _uiState.value.copy(baselineResult = result) }\n    }\n  }\n\n  fun clearBaseline() {\n    _uiState.update { _uiState.value.copy(baselineResult = null) }\n  }\n\n  fun setExpanded(id: String, expanded: Boolean) {\n    val newResults = _uiState.value.results.toMutableList()\n    val index = newResults.indexOfFirst { it.id == id }\n    if (index != -1) {\n      newResults[index] =\n        newResults[index].copy(\n          expanded = expanded,\n          basicInfoExpanded = expanded,\n          statsExpanded = expanded,\n        )\n      _uiState.update { _uiState.value.copy(results = newResults) }\n    } else {\n      Log.w(TAG, \"Benchmark result with id $id not found.\")\n    }\n  }\n\n  fun setBasicInfoExpanded(id: String, expanded: Boolean) {\n    val newResults = _uiState.value.results.toMutableList()\n    val index = newResults.indexOfFirst { it.id == id }\n    if (index != -1) {\n      newResults[index] = newResults[index].copy(basicInfoExpanded = expanded)\n      _uiState.update { _uiState.value.copy(results = newResults) }\n    } else {\n      Log.w(TAG, \"Benchmark result with id $id not found.\")\n    }\n  }\n\n  fun setStatsExpanded(id: String, expanded: Boolean) {\n    val newResults = _uiState.value.results.toMutableList()\n    val index = newResults.indexOfFirst { it.id == id }\n    if (index != -1) {\n      newResults[index] = newResults[index].copy(statsExpanded = expanded)\n      _uiState.update { _uiState.value.copy(results = newResults) }\n    } else {\n      Log.w(TAG, \"Benchmark result with id $id not found.\")\n    }\n  }\n\n  fun expandAll() {\n    val newResults = _uiState.value.results.toMutableList()\n    for (i in newResults.indices) {\n      newResults[i] =\n        newResults[i].copy(expanded = true, statsExpanded = true, basicInfoExpanded = true)\n    }\n    _uiState.update { _uiState.value.copy(results = newResults) }\n  }\n\n  fun collapseAll() {\n    val newResults = _uiState.value.results.toMutableList()\n    for (i in newResults.indices) {\n      newResults[i] =\n        newResults[i].copy(expanded = false, statsExpanded = false, basicInfoExpanded = false)\n    }\n    _uiState.update { _uiState.value.copy(results = newResults) }\n  }\n\n  fun setAggregation(id: String, aggregation: Aggregation) {\n    val newResults = _uiState.value.results.toMutableList()\n    val index = newResults.indexOfFirst { it.id == id }\n    if (index >= 0) {\n      newResults[index] = newResults[index].copy(aggregation = aggregation)\n      if (uiState.value.baselineResult?.id == newResults[index].id) {\n        _uiState.update { _uiState.value.copy(baselineResult = newResults[index]) }\n      }\n    }\n    _uiState.update { _uiState.value.copy(results = newResults) }\n  }\n\n  private fun calculateValueSeries(values: List<Double>): ValueSeries {\n    if (values.isEmpty()) {\n      return ValueSeries.getDefaultInstance()\n    }\n\n    val sortedValues = values.sorted()\n    val size = sortedValues.size\n\n    val min = sortedValues.first()\n    val max = sortedValues.last()\n    val avg = values.average()\n\n    // Helper function to get the value at a specific percentile (0.0 to 1.0)\n    fun getPercentile(p: Double): Double {\n      if (size == 1) return sortedValues[0]\n      val index = p * (size - 1)\n      val lower = floor(index).toInt()\n      val upper = ceil(index).toInt()\n      if (lower == upper) {\n        return sortedValues[lower]\n      }\n      val weight = index - lower\n      return sortedValues[lower] * (1 - weight) + sortedValues[upper] * weight\n    }\n\n    val median = getPercentile(0.5)\n    val pct25 = getPercentile(0.25)\n    val pct75 = getPercentile(0.75)\n\n    return ValueSeries.newBuilder()\n      .addAllValue(values)\n      .setMin(min)\n      .setMax(max)\n      .setAvg(avg)\n      .setMedium(median) // Proto field is named 'medium'\n      .setPct25(pct25)\n      .setPct75(pct75)\n      .build()\n  }\n}\n"
  },
  {
    "path": "Android/src/app/src/main/java/com/google/ai/edge/gallery/ui/common/Accordions.kt",
    "content": "/*\n * Copyright 2026 Google LLC\n *\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n *     http://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\n\npackage com.google.ai.edge.gallery.ui.common\n\nimport androidx.compose.animation.AnimatedVisibility\nimport androidx.compose.animation.expandVertically\nimport androidx.compose.animation.fadeIn\nimport androidx.compose.animation.fadeOut\nimport androidx.compose.animation.shrinkVertically\nimport androidx.compose.foundation.background\nimport androidx.compose.foundation.clickable\nimport androidx.compose.foundation.layout.Arrangement\nimport androidx.compose.foundation.layout.Box\nimport androidx.compose.foundation.layout.Column\nimport androidx.compose.foundation.layout.Row\nimport androidx.compose.foundation.layout.fillMaxWidth\nimport androidx.compose.foundation.layout.padding\nimport androidx.compose.foundation.shape.RoundedCornerShape\nimport androidx.compose.material.icons.Icons\nimport androidx.compose.material.icons.automirrored.rounded.ArrowRight\nimport androidx.compose.material.icons.rounded.ArrowDropDown\nimport androidx.compose.material3.Icon\nimport androidx.compose.material3.MaterialTheme\nimport androidx.compose.material3.Text\nimport androidx.compose.runtime.Composable\nimport androidx.compose.ui.Alignment\nimport androidx.compose.ui.Modifier\nimport androidx.compose.ui.draw.clip\nimport androidx.compose.ui.graphics.Color\nimport androidx.compose.ui.text.font.FontWeight\nimport androidx.compose.ui.text.style.TextOverflow\nimport androidx.compose.ui.unit.dp\n\n@Composable\nfun Accordions(\n  title: String,\n  expanded: Boolean,\n  onExpandedChange: (Boolean) -> Unit,\n  modifier: Modifier = Modifier,\n  subtitle: String = \"\",\n  boldTitle: Boolean = false,\n  bgColor: Color = MaterialTheme.colorScheme.surface,\n  titleRowAction: @Composable () -> Unit = {},\n  hideTitleRowActionOnCollapse: Boolean = false,\n  content: @Composable () -> Unit,\n) {\n  Column(modifier = modifier.background(bgColor).padding(8.dp)) {\n    // Title.\n    Row(\n      verticalAlignment = Alignment.CenterVertically,\n      horizontalArrangement = Arrangement.spacedBy(6.dp),\n      modifier =\n        Modifier.clip(RoundedCornerShape(8.dp))\n          .clickable { onExpandedChange(!expanded) }\n          .fillMaxWidth(),\n    ) {\n      Icon(\n        if (expanded) Icons.Rounded.ArrowDropDown else Icons.AutoMirrored.Rounded.ArrowRight,\n        contentDescription = null,\n      )\n      Column(modifier = Modifier.weight(1f)) {\n        Text(\n          title,\n          style =\n            MaterialTheme.typography.bodyMedium.copy(\n              fontWeight = if (boldTitle) FontWeight.SemiBold else FontWeight.Normal\n            ),\n          color = MaterialTheme.colorScheme.onSurface,\n          maxLines = 1,\n          overflow = TextOverflow.MiddleEllipsis,\n        )\n        if (subtitle.isNotEmpty()) {\n          Text(\n            subtitle,\n            style = MaterialTheme.typography.bodySmall,\n            color = MaterialTheme.colorScheme.onSurfaceVariant,\n          )\n        }\n      }\n      if (hideTitleRowActionOnCollapse) {\n        AnimatedVisibility(expanded, enter = fadeIn(), exit = fadeOut()) {\n          if (expanded) {\n            titleRowAction()\n          }\n        }\n      } else {\n        titleRowAction()\n      }\n    }\n\n    // Content.\n    AnimatedVisibility(visible = expanded, enter = expandVertically(), exit = shrinkVertically()) {\n      Box(\n        modifier = Modifier.padding(start = 4.dp).padding(top = 8.dp),\n        contentAlignment = Alignment.TopStart,\n      ) {\n        content()\n      }\n    }\n  }\n}\n"
  },
  {
    "path": "Android/src/app/src/main/java/com/google/ai/edge/gallery/ui/common/AudioAnimation.kt",
    "content": "/*\n * Copyright 2025 Google LLC\n *\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n *     http://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\n\npackage com.google.ai.edge.gallery.ui.common\n\nimport android.graphics.RuntimeShader\nimport android.os.Build\nimport androidx.compose.animation.core.Animatable\nimport androidx.compose.animation.core.tween\nimport androidx.compose.foundation.Canvas\nimport androidx.compose.foundation.layout.fillMaxSize\nimport androidx.compose.runtime.Composable\nimport androidx.compose.runtime.LaunchedEffect\nimport androidx.compose.runtime.getValue\nimport androidx.compose.runtime.mutableDoubleStateOf\nimport androidx.compose.runtime.mutableFloatStateOf\nimport androidx.compose.runtime.remember\nimport androidx.compose.runtime.setValue\nimport androidx.compose.runtime.withFrameMillis\nimport androidx.compose.ui.Modifier\nimport androidx.compose.ui.graphics.Color\nimport androidx.compose.ui.graphics.ShaderBrush\nimport kotlin.math.pow\nimport kotlin.random.Random\n\nprivate const val SHADER =\n  \"\"\"\n// The size of the render area.\nuniform float2 iResolution;\n// The color of the background to render the wave on.\nuniform vec4 bgColor;\n// Current timestamp in seconds.\nuniform float iTime;\n// The amplitude of the sound to be visualized.\n// From 0 to 1.\nuniform float amplitude;\n// The extra offset for 1d perlin noise.\nuniform float pOffset;\n\n// Creates a gradient that blends four different colors based on a uv coordinate and animated\n// over time.\nvec3 mix4(vec3 color1, vec3 color2, vec3 color3, vec3 color4, vec2 uv){\n  float sinTime1 = sin(iTime / 1.6);\n  float sinTime2 = sin(iTime / 1.8);\n  return mix(\n    mix(color1, color2, smoothstep(0.0 + sinTime1 * 0.1, 0.24 + sinTime1 * 0.1, uv.y)),\n    mix(color3, color4, smoothstep(-0.16 - sinTime2 * 0.1, 0.24 - sinTime2 * 0.1, uv.y)),\n    smoothstep(0.0, 0.7 + sinTime1 * 0.1, uv.x));\n}\n\nfloat hash(float i) {\n\tfloat h = i * 127.1;\n\tfloat p = -1. + 2. * fract(sin(h) * 43758.1453123);\n  return p;\n}\n\nfloat perlin_noise_1d(float d) {\n  float i = floor(d);\n  float f = d - i;\n\n  float y = f*f*f* (6. * f*f - 15. * f + 10.);\n\n  float slope1 = hash(i);\n  float slope2 = hash(i + 1.0);\n  float v1 = f;\n  float v2 = f - 1.0;\n\n  float r = mix(slope1 * v1, slope2 * v2, y);\n  r = r * 0.5 + 0.5;\n  return r;\n}\n\nhalf4 main(float2 fragCoord) {\n  float2 uv = fragCoord/iResolution.xy;\n  uv.y = 1.0 - uv.y;\n\n  // Add a wavy distortion to the y-coordinate of the uv.\n  //\n  // Control the amplitude of the wave\n  float wave_strength = 0.036;\n  // Control the speed of the wave\n  float wave_speed = 1.2;\n  // Control the frequency of the wave\n  float wave_frequency = 4.0;\n\n  // Idle.\n  if (amplitude == 0.) {\n    uv.y += sin(uv.x * wave_frequency + -iTime * wave_speed) * wave_strength;\n  }\n  // Visualizing amplitude by sampling the 1d perlin noise at the given offset.\n  else {\n    uv.y -= perlin_noise_1d(pOffset + uv.x * 3.) * amplitude / 2.0;\n  }\n\n  vec3 col = mix4(\n    vec3(0.992, 0.875, 0.522),  // yellow\n    vec3(0.627, 0.816, 0.686),  // green\n    vec3(0.886, 0.372, 0.341),  // red\n    vec3(0.522, 0.694, 0.973),  // blue\n    uv);\n\n  // Define the fade parameters\n  float fade_start = 0.24;\n  float fade_end = 0.34;\n\n  // Calculate the blend factor using smoothstep for a smooth transition\n  float fade_factor = smoothstep(fade_start, fade_end, uv.y);\n\n  // Blend the base color with background color using the fade factor\n  vec4 final_color = mix(vec4(col, 1.0), bgColor, fade_factor);\n\n  return vec4(half3(final_color.xyz) * (1 + amplitude * 0.2), final_color.a);\n}\n\"\"\"\n\n/**\n * This composable function displays a shader-based audio animation.\n *\n * It uses a `RuntimeShader` to create a dynamically animated visual effect that responds to an\n * audio amplitude. The shader renders a gradient with a wavy distortion. It moves slowly when\n * waiting for recording to start (amplitude is 0), and reacts to amplitude changes by rendering\n * random \"bumps\" from 1d perlin noise.\n */\n@Composable\nfun AudioAnimation(bgColor: Color, amplitude: Int, modifier: Modifier = Modifier) {\n  if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {\n    val shader = remember { RuntimeShader(SHADER) }\n    val shaderBrush = remember { ShaderBrush(shader) }\n    var iTime by remember { mutableFloatStateOf(0f) }\n    var curPOffset by remember { mutableFloatStateOf(0f) }\n    var prevNormalizedAmplitude by remember { mutableDoubleStateOf(0.0) }\n    // Use pow(x, 0.5) to make low amplitude levels more significant.\n    val normalizedAmplitude = (amplitude / 32767.0).pow(0.5)\n    var animatedAmplitude by remember { mutableFloatStateOf(normalizedAmplitude.toFloat()) }\n\n    // Animate the amplitude value whenever amplitude changes.\n    // This will drive the animation from the current value to the new target value.\n    LaunchedEffect(amplitude) {\n      val animatable = Animatable(initialValue = animatedAmplitude)\n      animatable.animateTo(\n        targetValue = normalizedAmplitude.toFloat(),\n        animationSpec = tween(durationMillis = 100),\n      ) {\n        animatedAmplitude = this.value\n      }\n    }\n\n    // Updates the iTime uniform for the shader.\n    LaunchedEffect(Unit) {\n      while (true) {\n        withFrameMillis { frameTimeMs -> iTime = frameTimeMs / 1000f }\n      }\n    }\n\n    // Shader rending.\n    Canvas(modifier = modifier.fillMaxSize()) {\n      // Add a random offset to the Perlin noise whenever the audio amplitude drops from a high\n      // level (0.2 or greater) to a low level (less than 0.2). This makes the noise-driven visual\n      // effect appear to \"jump\" or reset to a new, random state when the audio becomes quiet,\n      // preventing the visual from settling into a repetitive or static pattern.\n      if (normalizedAmplitude < 0.2 && prevNormalizedAmplitude >= 0.2) {\n        curPOffset = Random.nextFloat() * 1000f\n      }\n      prevNormalizedAmplitude = normalizedAmplitude\n\n      shader.setFloatUniform(\"iTime\", iTime)\n      shader.setFloatUniform(\"iResolution\", size.width, size.height)\n      shader.setFloatUniform(\"bgColor\", bgColor.red, bgColor.green, bgColor.blue, bgColor.alpha)\n      shader.setFloatUniform(\"amplitude\", animatedAmplitude)\n      shader.setFloatUniform(\"pOffset\", curPOffset)\n\n      drawRect(brush = shaderBrush)\n    }\n  }\n}\n"
  },
  {
    "path": "Android/src/app/src/main/java/com/google/ai/edge/gallery/ui/common/ClickableLink.kt",
    "content": "/*\n * Copyright 2025 Google LLC\n *\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n *     http://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\n\npackage com.google.ai.edge.gallery.ui.common\n\nimport android.os.Bundle\nimport androidx.compose.foundation.layout.Arrangement\nimport androidx.compose.foundation.layout.Row\nimport androidx.compose.foundation.layout.padding\nimport androidx.compose.foundation.layout.size\nimport androidx.compose.material3.Icon\nimport androidx.compose.material3.MaterialTheme\nimport androidx.compose.material3.Text\nimport androidx.compose.runtime.Composable\nimport androidx.compose.ui.Alignment\nimport androidx.compose.ui.Modifier\nimport androidx.compose.ui.graphics.vector.ImageVector\nimport androidx.compose.ui.platform.LocalUriHandler\nimport androidx.compose.ui.text.AnnotatedString\nimport androidx.compose.ui.text.LinkAnnotation\nimport androidx.compose.ui.text.SpanStyle\nimport androidx.compose.ui.text.TextLinkStyles\nimport androidx.compose.ui.text.buildAnnotatedString\nimport androidx.compose.ui.text.style.TextAlign\nimport androidx.compose.ui.text.style.TextDecoration\nimport androidx.compose.ui.text.withLink\nimport androidx.compose.ui.unit.dp\nimport com.google.ai.edge.gallery.firebaseAnalytics\nimport com.google.ai.edge.gallery.ui.theme.customColors\n\n@Composable\nfun buildTrackableUrlAnnotatedString(url: String, linkText: String): AnnotatedString {\n  val uriHandler = LocalUriHandler.current\n  return buildAnnotatedString {\n    withLink(\n      link =\n        LinkAnnotation.Url(\n          url = url,\n          styles =\n            TextLinkStyles(\n              style =\n                SpanStyle(\n                  color = MaterialTheme.customColors.linkColor,\n                  textDecoration = TextDecoration.Underline,\n                )\n            ),\n          linkInteractionListener = {\n            uriHandler.openUri(url)\n            firebaseAnalytics?.logEvent(\n              \"resource_link_click\",\n              Bundle().apply { putString(\"link_destination\", url) },\n            )\n          },\n        )\n    ) {\n      append(linkText)\n    }\n  }\n}\n\n@Composable\nfun ClickableLink(\n  url: String,\n  linkText: String,\n  modifier: Modifier = Modifier,\n  icon: ImageVector? = null,\n) {\n  val annotatedText = buildTrackableUrlAnnotatedString(url, linkText)\n\n  Row(\n    verticalAlignment = Alignment.CenterVertically,\n    horizontalArrangement = Arrangement.Center,\n    modifier = modifier,\n  ) {\n    if (icon != null) {\n      Icon(icon, contentDescription = null, modifier = Modifier.size(16.dp))\n    }\n    Text(\n      text = annotatedText,\n      textAlign = TextAlign.Center,\n      style = MaterialTheme.typography.bodyMedium,\n      modifier = Modifier.padding(start = 6.dp),\n    )\n  }\n}\n"
  },
  {
    "path": "Android/src/app/src/main/java/com/google/ai/edge/gallery/ui/common/ColorUtils.kt",
    "content": "/*\n * Copyright 2025 Google LLC\n *\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n *     http://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\n\npackage com.google.ai.edge.gallery.ui.common\n\nimport androidx.compose.material3.MaterialTheme\nimport androidx.compose.runtime.Composable\nimport androidx.compose.ui.graphics.Color\nimport com.google.ai.edge.gallery.data.Task\nimport com.google.ai.edge.gallery.ui.theme.customColors\n\n@Composable\nfun getTaskBgColor(task: Task): Color {\n  val colorIndex: Int = (task.index.coerceAtLeast(0)) % MaterialTheme.customColors.taskBgColors.size\n  return MaterialTheme.customColors.taskBgColors[colorIndex]\n}\n\n@Composable\nfun getTaskBgGradientColors(task: Task): List<Color> {\n  val colorIndex: Int = (task.index.coerceAtLeast(0)) % MaterialTheme.customColors.taskBgColors.size\n  return MaterialTheme.customColors.taskBgGradientColors[colorIndex]\n}\n\n@Composable\nfun getTaskIconColor(task: Task): Color {\n  val colorIndex: Int =\n    (task.index.coerceAtLeast(0)) % MaterialTheme.customColors.taskIconColors.size\n  return MaterialTheme.customColors.taskIconColors[colorIndex]\n}\n\n@Composable\nfun getTaskIconColor(index: Int): Color {\n  val colorIndex: Int = (index.coerceAtLeast(0)) % MaterialTheme.customColors.taskIconColors.size\n  return MaterialTheme.customColors.taskIconColors[colorIndex]\n}\n"
  },
  {
    "path": "Android/src/app/src/main/java/com/google/ai/edge/gallery/ui/common/ConfigDialog.kt",
    "content": "/*\n * Copyright 2025 Google LLC\n *\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n *     http://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\n\npackage com.google.ai.edge.gallery.ui.common\n\n// import androidx.compose.ui.tooling.preview.Preview\n// import com.google.ai.edge.gallery.ui.preview.MODEL_TEST1\n// import com.google.ai.edge.gallery.ui.theme.GalleryTheme\nimport android.util.Log\nimport androidx.annotation.StringRes\nimport androidx.compose.foundation.border\nimport androidx.compose.foundation.clickable\nimport androidx.compose.foundation.interaction.MutableInteractionSource\nimport androidx.compose.foundation.layout.Arrangement\nimport androidx.compose.foundation.layout.Box\nimport androidx.compose.foundation.layout.Column\nimport androidx.compose.foundation.layout.Row\nimport androidx.compose.foundation.layout.Spacer\nimport androidx.compose.foundation.layout.fillMaxWidth\nimport androidx.compose.foundation.layout.height\nimport androidx.compose.foundation.layout.imePadding\nimport androidx.compose.foundation.layout.offset\nimport androidx.compose.foundation.layout.padding\nimport androidx.compose.foundation.layout.width\nimport androidx.compose.foundation.lazy.LazyColumn\nimport androidx.compose.foundation.lazy.items\nimport androidx.compose.foundation.rememberScrollState\nimport androidx.compose.foundation.shape.CircleShape\nimport androidx.compose.foundation.shape.RoundedCornerShape\nimport androidx.compose.foundation.text.BasicTextField\nimport androidx.compose.foundation.text.KeyboardActions\nimport androidx.compose.foundation.text.KeyboardOptions\nimport androidx.compose.foundation.verticalScroll\nimport androidx.compose.material.icons.Icons\nimport androidx.compose.material.icons.rounded.ArrowDropDown\nimport androidx.compose.material.icons.rounded.CheckCircle\nimport androidx.compose.material3.Button\nimport androidx.compose.material3.Card\nimport androidx.compose.material3.ExperimentalMaterial3Api\nimport androidx.compose.material3.Icon\nimport androidx.compose.material3.MaterialTheme\nimport androidx.compose.material3.ModalBottomSheet\nimport androidx.compose.material3.MultiChoiceSegmentedButtonRow\nimport androidx.compose.material3.OutlinedButton\nimport androidx.compose.material3.OutlinedTextField\nimport androidx.compose.material3.PrimaryTabRow\nimport androidx.compose.material3.SegmentedButton\nimport androidx.compose.material3.SegmentedButtonDefaults\nimport androidx.compose.material3.Slider\nimport androidx.compose.material3.Switch\nimport androidx.compose.material3.Tab\nimport androidx.compose.material3.Text\nimport androidx.compose.material3.TextButton\nimport androidx.compose.material3.rememberModalBottomSheetState\nimport androidx.compose.runtime.Composable\nimport androidx.compose.runtime.getValue\nimport androidx.compose.runtime.mutableIntStateOf\nimport androidx.compose.runtime.mutableStateMapOf\nimport androidx.compose.runtime.mutableStateOf\nimport androidx.compose.runtime.remember\nimport androidx.compose.runtime.rememberCoroutineScope\nimport androidx.compose.runtime.setValue\nimport androidx.compose.runtime.snapshots.SnapshotStateMap\nimport androidx.compose.ui.Alignment\nimport androidx.compose.ui.Modifier\nimport androidx.compose.ui.draw.alpha\nimport androidx.compose.ui.draw.clip\nimport androidx.compose.ui.focus.FocusRequester\nimport androidx.compose.ui.focus.focusRequester\nimport androidx.compose.ui.focus.onFocusChanged\nimport androidx.compose.ui.graphics.Color\nimport androidx.compose.ui.graphics.SolidColor\nimport androidx.compose.ui.platform.LocalFocusManager\nimport androidx.compose.ui.res.stringResource\nimport androidx.compose.ui.semantics.semantics\nimport androidx.compose.ui.text.TextStyle\nimport androidx.compose.ui.text.input.KeyboardType\nimport androidx.compose.ui.text.style.TextOverflow\nimport androidx.compose.ui.unit.dp\nimport androidx.compose.ui.window.Dialog\nimport com.google.ai.edge.gallery.R\nimport com.google.ai.edge.gallery.data.BooleanSwitchConfig\nimport com.google.ai.edge.gallery.data.BottomSheetSelectorConfig\nimport com.google.ai.edge.gallery.data.BottomSheetSelectorItem\nimport com.google.ai.edge.gallery.data.Config\nimport com.google.ai.edge.gallery.data.LabelConfig\nimport com.google.ai.edge.gallery.data.NumberSliderConfig\nimport com.google.ai.edge.gallery.data.SegmentedButtonConfig\nimport com.google.ai.edge.gallery.data.ValueType\nimport com.google.ai.edge.gallery.ui.theme.labelSmallNarrow\nimport kotlinx.coroutines.delay\nimport kotlinx.coroutines.launch\n\nprivate const val TAG = \"AGConfigDialog\"\n\nprivate data class Tab(@StringRes val labelResId: Int)\n\nprivate val TABS =\n  listOf(\n    Tab(labelResId = R.string.config_dialog_tab_model_configs),\n    Tab(labelResId = R.string.config_dialog_tab_system_prompt),\n  )\n\n/**\n * Displays a configuration dialog allowing users to modify settings through various input controls.\n */\n@Composable\nfun ConfigDialog(\n  title: String,\n  configs: List<Config>,\n  initialValues: Map<String, Any>,\n  onDismissed: () -> Unit,\n  onOk: (values: Map<String, Any>, oldSystemPrompt: String, newSystemPrompt: String) -> Unit,\n  okBtnLabel: String = \"OK\",\n  subtitle: String = \"\",\n  showCancel: Boolean = true,\n  showSystemPromptEditorTab: Boolean = false,\n  defaultSystemPrompt: String = \"\",\n  curSystemPrompt: String = \"\",\n) {\n  val values: SnapshotStateMap<String, Any> = remember {\n    mutableStateMapOf<String, Any>().apply { putAll(initialValues) }\n  }\n  val interactionSource = remember { MutableInteractionSource() }\n  var selectedTabIndex by remember { mutableIntStateOf(0) }\n  val savedSystemPrompt = remember { curSystemPrompt }\n  var systemPrompt by remember { mutableStateOf(curSystemPrompt) }\n\n  Dialog(onDismissRequest = onDismissed) {\n    val focusManager = LocalFocusManager.current\n    Card(\n      modifier =\n        Modifier.fillMaxWidth()\n          .clickable(\n            interactionSource = interactionSource,\n            indication = null, // Disable the ripple effect\n          ) {\n            focusManager.clearFocus()\n          }\n          .imePadding(),\n      shape = RoundedCornerShape(16.dp),\n    ) {\n      Column(\n        modifier = Modifier.padding(20.dp),\n        verticalArrangement = Arrangement.spacedBy(16.dp),\n      ) {\n        // Dialog title and subtitle.\n        Column {\n          Text(\n            title,\n            style = MaterialTheme.typography.titleLarge,\n            modifier = Modifier.padding(bottom = 8.dp),\n          )\n          // Subtitle.\n          if (subtitle.isNotEmpty()) {\n            Text(\n              subtitle,\n              style = labelSmallNarrow,\n              color = MaterialTheme.colorScheme.onSurfaceVariant,\n              modifier = Modifier.offset(y = (-6).dp),\n            )\n          }\n        }\n\n        // Tab.\n        if (showSystemPromptEditorTab) {\n          PrimaryTabRow(selectedTabIndex = selectedTabIndex, containerColor = Color.Transparent) {\n            TABS.forEachIndexed { index, tab ->\n              Tab(\n                selected = selectedTabIndex == index,\n                onClick = { selectedTabIndex = index },\n                text = {\n                  Row(\n                    verticalAlignment = Alignment.CenterVertically,\n                    horizontalArrangement = Arrangement.spacedBy(4.dp),\n                  ) {\n                    val titleColor =\n                      if (selectedTabIndex == index) MaterialTheme.colorScheme.primary\n                      else MaterialTheme.colorScheme.onSurfaceVariant\n                    Text(stringResource(tab.labelResId), color = titleColor)\n                  }\n                },\n              )\n            }\n          }\n        }\n\n        if (selectedTabIndex == 0) {\n          // List of config rows.\n          Column(\n            modifier = Modifier.verticalScroll(rememberScrollState()).weight(1f, fill = false),\n            verticalArrangement = Arrangement.spacedBy(16.dp),\n          ) {\n            ConfigEditorsPanel(configs = configs, values = values)\n          }\n        } else if (selectedTabIndex == 1) {\n          OutlinedTextField(\n            value = systemPrompt,\n            modifier = Modifier.weight(1f, fill = false),\n            textStyle = MaterialTheme.typography.bodySmall,\n            onValueChange = { systemPrompt = it },\n          )\n        }\n\n        // Button row.\n        Row(\n          horizontalArrangement =\n            if (showSystemPromptEditorTab && selectedTabIndex == 1) {\n              Arrangement.SpaceBetween\n            } else {\n              Arrangement.End\n            },\n          verticalAlignment = Alignment.CenterVertically,\n          modifier = Modifier.padding(top = 8.dp),\n        ) {\n          // Restore default button to restore system prompt.\n          if (showSystemPromptEditorTab && selectedTabIndex == 1) {\n            OutlinedButton(\n              onClick = { systemPrompt = defaultSystemPrompt },\n              contentPadding = SMALL_BUTTON_CONTENT_PADDING,\n            ) {\n              Text(stringResource(R.string.restore_default))\n            }\n          }\n\n          Row(\n            modifier = Modifier.fillMaxWidth(),\n            horizontalArrangement = Arrangement.End,\n            verticalAlignment = Alignment.CenterVertically,\n          ) {\n            // Cancel button.\n            if (showCancel) {\n              TextButton(onClick = { onDismissed() }) { Text(\"Cancel\") }\n            }\n\n            // Ok button\n            Button(\n              onClick = {\n                Log.d(TAG, \"Values from dialog: $values\")\n                onOk(values.toMap(), savedSystemPrompt, systemPrompt)\n              }\n            ) {\n              Text(okBtnLabel)\n            }\n          }\n        }\n      }\n    }\n  }\n}\n\n/** Composable function to display a list of config editor rows. */\n@Composable\nfun ConfigEditorsPanel(configs: List<Config>, values: SnapshotStateMap<String, Any>) {\n  for (config in configs) {\n    when (config) {\n      // Label.\n      is LabelConfig -> {\n        LabelRow(config = config, values = values)\n      }\n\n      // Number slider.\n      is NumberSliderConfig -> {\n        NumberSliderRow(config = config, values = values)\n      }\n\n      // Boolean switch.\n      is BooleanSwitchConfig -> {\n        BooleanSwitchRow(config = config, values = values)\n      }\n\n      // Segmented button.\n      is SegmentedButtonConfig -> {\n        SegmentedButtonRow(config = config, values = values)\n      }\n\n      // Bottom sheet selector.\n      is BottomSheetSelectorConfig -> {\n        BottomSheetSelectorRow(config = config, values = values)\n      }\n\n      else -> {}\n    }\n  }\n}\n\n@Composable\nfun LabelRow(config: LabelConfig, values: SnapshotStateMap<String, Any>) {\n  Column(modifier = Modifier.fillMaxWidth()) {\n    // Field label.\n    Text(config.key.label, style = MaterialTheme.typography.titleSmall)\n    // Content label.\n    val label =\n      try {\n        values[config.key.label] as String\n      } catch (e: Exception) {\n        \"\"\n      }\n    Text(label, style = MaterialTheme.typography.bodyMedium)\n  }\n}\n\nfun getTextFieldDisplayValue(valueType: ValueType, value: Float): String {\n  return try {\n    when (valueType) {\n      ValueType.FLOAT -> {\n        \"%.2f\".format(value)\n      }\n\n      ValueType.INT -> {\n        \"${value.toInt()}\"\n      }\n\n      else -> {\n        \"\"\n      }\n    }\n  } catch (e: Exception) {\n    \"\"\n  }\n}\n\n/**\n * Composable function to display a number slider with an associated text input field.\n *\n * This function renders a row containing a slider and a text field, both used to modify a numeric\n * value. The slider allows users to visually adjust the value within a specified range, while the\n * text field provides precise numeric input.\n */\n@Composable\nfun NumberSliderRow(config: NumberSliderConfig, values: SnapshotStateMap<String, Any>) {\n  val focusManager = LocalFocusManager.current\n\n  Column(modifier = Modifier.fillMaxWidth().semantics(mergeDescendants = true) {}) {\n    // Field label.\n    Text(config.key.label, style = MaterialTheme.typography.titleSmall)\n\n    // Controls row.\n    Row(modifier = Modifier.fillMaxWidth(), verticalAlignment = Alignment.CenterVertically) {\n      var isFocused by remember { mutableStateOf(false) }\n      val focusRequester = remember { FocusRequester() }\n\n      // The displaying value for the Text field. It allows hold invalid values that is not a proper\n      // value or out of the slider range, temporary while user is still editing the text.\n      var textFieldDisplayValue by remember {\n        mutableStateOf(\n          getTextFieldDisplayValue(config.valueType, values[config.key.label] as Float)\n        )\n      }\n\n      // Number slider.\n      val sliderValue =\n        try {\n          values[config.key.label] as Float\n        } catch (e: Exception) {\n          0f\n        }\n\n      Slider(\n        modifier = Modifier.height(24.dp).weight(1f),\n        value = sliderValue,\n        valueRange = config.sliderMin..config.sliderMax,\n        onValueChange = {\n          values[config.key.label] = it\n          textFieldDisplayValue = getTextFieldDisplayValue(config.valueType, it)\n        },\n      )\n\n      Spacer(modifier = Modifier.width(8.dp))\n\n      // A smaller text field.\n      BasicTextField(\n        value = textFieldDisplayValue,\n        modifier =\n          Modifier.width(80.dp).focusRequester(focusRequester).onFocusChanged {\n            isFocused = it.isFocused\n\n            // When leaving focus, display the internal value so that any invalid value is cleared.\n            if (!isFocused) {\n              textFieldDisplayValue =\n                getTextFieldDisplayValue(config.valueType, values[config.key.label] as Float)\n            }\n          },\n        keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number),\n        keyboardActions = KeyboardActions(onDone = { focusManager.clearFocus() }),\n        singleLine = true,\n        onValueChange = {\n          // Always update the display value to reflect the update on the UI.\n          textFieldDisplayValue = it\n\n          // Only if the new value could be converted to a float, then update the internal value,\n          // bounded by the slider range. It prevents invalid values like NaN from crashing the app.\n          it.toFloatOrNull()?.let { floatValue ->\n            values[config.key.label] = minOf(maxOf(floatValue, config.sliderMin), config.sliderMax)\n          }\n        },\n        textStyle = TextStyle(color = MaterialTheme.colorScheme.onSurface),\n        cursorBrush = SolidColor(MaterialTheme.colorScheme.onSurface),\n      ) { innerTextField ->\n        Box(\n          modifier =\n            Modifier.border(\n              width = if (isFocused) 2.dp else 1.dp,\n              color =\n                if (isFocused) MaterialTheme.colorScheme.primary\n                else MaterialTheme.colorScheme.outline,\n              shape = RoundedCornerShape(4.dp),\n            )\n        ) {\n          Box(modifier = Modifier.padding(8.dp)) { innerTextField() }\n        }\n      }\n    }\n  }\n}\n\n/**\n * Composable function to display a row with a boolean switch.\n *\n * This function renders a row containing a label and a switch, allowing users to toggle a boolean\n * value.\n */\n@Composable\nfun BooleanSwitchRow(config: BooleanSwitchConfig, values: SnapshotStateMap<String, Any>) {\n  val switchValue =\n    try {\n      values[config.key.label] as Boolean\n    } catch (e: Exception) {\n      false\n    }\n  Column(modifier = Modifier.fillMaxWidth().semantics(mergeDescendants = true) {}) {\n    Text(config.key.label, style = MaterialTheme.typography.titleSmall)\n    Switch(checked = switchValue, onCheckedChange = { values[config.key.label] = it })\n  }\n}\n\n/**\n * Composable function to display a row with a segmented button.\n *\n * This function renders a row containing a label and a segmented button, allowing users to select\n * one or more options from a list.\n */\n@Composable\nfun SegmentedButtonRow(config: SegmentedButtonConfig, values: SnapshotStateMap<String, Any>) {\n  val selectedOptions: List<String> = remember { (values[config.key.label] as String).split(\",\") }\n  var selectionStates: List<Boolean> by remember {\n    mutableStateOf(\n      List(config.options.size) { index -> selectedOptions.contains(config.options[index]) }\n    )\n  }\n\n  Column(modifier = Modifier.fillMaxWidth().semantics(mergeDescendants = true) {}) {\n    Text(config.key.label, style = MaterialTheme.typography.titleSmall)\n    MultiChoiceSegmentedButtonRow {\n      config.options.forEachIndexed { index, label ->\n        SegmentedButton(\n          shape = SegmentedButtonDefaults.itemShape(index = index, count = config.options.size),\n          onCheckedChange = {\n            var newSelectionStates = selectionStates.toMutableList()\n            val selectedCount = newSelectionStates.count { it }\n\n            // Single select.\n            if (!config.allowMultiple) {\n              if (!newSelectionStates[index]) {\n                newSelectionStates = MutableList(config.options.size) { it == index }\n              }\n            }\n            // Multiple select.\n            else {\n              if (!(selectedCount == 1 && newSelectionStates[index])) {\n                newSelectionStates[index] = !newSelectionStates[index]\n              }\n            }\n            selectionStates = newSelectionStates\n\n            values[config.key.label] =\n              config.options\n                .filterIndexed { index, option -> selectionStates[index] }\n                .joinToString(\",\")\n          },\n          checked = selectionStates[index],\n          label = { Text(label) },\n        )\n      }\n    }\n  }\n}\n\n/**\n * Composable function to display a row with a bottom sheet selector.\n *\n * This function renders a row containing a label and a button, allowing users to select an option\n * from a bottom sheet.\n */\n@OptIn(ExperimentalMaterial3Api::class)\n@Composable\nfun BottomSheetSelectorRow(\n  config: BottomSheetSelectorConfig,\n  values: SnapshotStateMap<String, Any>,\n  showLabel: Boolean = true,\n  onSelected: (BottomSheetSelectorItem) -> Unit = {},\n) {\n  var selectedOption by remember {\n    mutableStateOf(\n      if (config.options.isEmpty()) {\n        null\n      } else {\n        config.options.find { it.label == config.defaultValue }\n      }\n    )\n  }\n  var showBottomSheet by remember { mutableStateOf(false) }\n  val sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true)\n  val scope = rememberCoroutineScope()\n\n  Column(\n    modifier = Modifier.fillMaxWidth().semantics(mergeDescendants = true) {},\n    verticalArrangement = Arrangement.spacedBy(4.dp),\n  ) {\n    if (showLabel) {\n      Text(config.key.label, style = MaterialTheme.typography.titleSmall)\n    }\n    Row(\n      horizontalArrangement = Arrangement.SpaceBetween,\n      verticalAlignment = Alignment.CenterVertically,\n      modifier =\n        Modifier.height(40.dp)\n          .clip(CircleShape)\n          .clickable { showBottomSheet = true }\n          .border(1.dp, MaterialTheme.colorScheme.outline, CircleShape)\n          .padding(start = 12.dp, end = 8.dp),\n    ) {\n      Text(\n        selectedOption?.label ?: \"-\",\n        style = MaterialTheme.typography.labelLarge,\n        color = MaterialTheme.colorScheme.onSurface,\n        modifier = Modifier.weight(1f),\n        maxLines = 1,\n        overflow = TextOverflow.MiddleEllipsis,\n      )\n      Icon(\n        Icons.Rounded.ArrowDropDown,\n        contentDescription = null,\n        tint = MaterialTheme.colorScheme.onSurface,\n      )\n    }\n  }\n\n  if (showBottomSheet) {\n    ModalBottomSheet(\n      onDismissRequest = { showBottomSheet = false },\n      sheetState = sheetState,\n      containerColor = MaterialTheme.colorScheme.surface,\n    ) {\n      Column(modifier = Modifier.fillMaxWidth()) {\n        val titleResId = config.bottomSheetTitleResId\n        if (titleResId != null) {\n          Text(\n            stringResource(titleResId),\n            style = MaterialTheme.typography.titleLarge,\n            color = MaterialTheme.colorScheme.onSurface,\n            modifier = Modifier.padding(16.dp),\n          )\n        }\n        LazyColumn {\n          items(config.options) { option ->\n            Row(\n              modifier =\n                Modifier.clickable {\n                    selectedOption = option\n                    values[config.key.label] = option.label\n                    onSelected(option)\n                    scope.launch {\n                      delay(200)\n                      sheetState.hide()\n                      showBottomSheet = false\n                    }\n                  }\n                  .padding(horizontal = 16.dp, vertical = 12.dp)\n                  .fillMaxWidth(),\n              verticalAlignment = Alignment.CenterVertically,\n              horizontalArrangement = Arrangement.spacedBy(16.dp),\n            ) {\n              Icon(\n                Icons.Rounded.CheckCircle,\n                contentDescription = null,\n                tint = MaterialTheme.colorScheme.secondary,\n                modifier = Modifier.alpha(if (option == selectedOption) 1f else 0f),\n              )\n              Text(\n                option.label,\n                color = MaterialTheme.colorScheme.onSurface,\n                style = MaterialTheme.typography.labelLarge,\n              )\n            }\n          }\n        }\n      }\n    }\n  }\n}\n"
  },
  {
    "path": "Android/src/app/src/main/java/com/google/ai/edge/gallery/ui/common/DownloadAndTryButton.kt",
    "content": "/*\n * Copyright 2025 Google LLC\n *\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n *     http://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\n\npackage com.google.ai.edge.gallery.ui.common\n\nimport android.content.Intent\nimport android.util.Log\nimport androidx.activity.compose.rememberLauncherForActivityResult\nimport androidx.activity.result.ActivityResultLauncher\nimport androidx.activity.result.contract.ActivityResultContracts\nimport androidx.browser.customtabs.CustomTabsIntent\nimport androidx.compose.animation.core.Animatable\nimport androidx.compose.animation.core.tween\nimport androidx.compose.foundation.background\nimport androidx.compose.foundation.layout.Arrangement\nimport androidx.compose.foundation.layout.Column\nimport androidx.compose.foundation.layout.PaddingValues\nimport androidx.compose.foundation.layout.Row\nimport androidx.compose.foundation.layout.fillMaxWidth\nimport androidx.compose.foundation.layout.height\nimport androidx.compose.foundation.layout.padding\nimport androidx.compose.foundation.layout.width\nimport androidx.compose.foundation.layout.wrapContentHeight\nimport androidx.compose.foundation.shape.CircleShape\nimport androidx.compose.foundation.text.TextAutoSize\nimport androidx.compose.material.icons.Icons\nimport androidx.compose.material.icons.automirrored.rounded.ArrowForward\nimport androidx.compose.material.icons.outlined.Close\nimport androidx.compose.material.icons.outlined.FileDownload\nimport androidx.compose.material.icons.rounded.Error\nimport androidx.compose.material3.AlertDialog\nimport androidx.compose.material3.Button\nimport androidx.compose.material3.ButtonDefaults\nimport androidx.compose.material3.ExperimentalMaterial3Api\nimport androidx.compose.material3.Icon\nimport androidx.compose.material3.IconButton\nimport androidx.compose.material3.IconButtonDefaults\nimport androidx.compose.material3.LinearProgressIndicator\nimport androidx.compose.material3.MaterialTheme\nimport androidx.compose.material3.ModalBottomSheet\nimport androidx.compose.material3.Text\nimport androidx.compose.material3.TextButton\nimport androidx.compose.material3.rememberModalBottomSheetState\nimport androidx.compose.runtime.Composable\nimport androidx.compose.runtime.LaunchedEffect\nimport androidx.compose.runtime.getValue\nimport androidx.compose.runtime.mutableStateOf\nimport androidx.compose.runtime.remember\nimport androidx.compose.runtime.rememberCoroutineScope\nimport androidx.compose.runtime.setValue\nimport androidx.compose.ui.Alignment\nimport androidx.compose.ui.Modifier\nimport androidx.compose.ui.draw.clip\nimport androidx.compose.ui.graphics.Color\nimport androidx.compose.ui.platform.LocalContext\nimport androidx.compose.ui.res.stringResource\nimport androidx.compose.ui.semantics.contentDescription\nimport androidx.compose.ui.semantics.semantics\nimport androidx.compose.ui.text.style.TextAlign\nimport androidx.compose.ui.unit.dp\nimport androidx.compose.ui.unit.sp\nimport androidx.core.net.toUri\nimport androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel\nimport com.google.ai.edge.gallery.R\nimport com.google.ai.edge.gallery.data.Model\nimport com.google.ai.edge.gallery.data.ModelDownloadStatus\nimport com.google.ai.edge.gallery.data.ModelDownloadStatusType\nimport com.google.ai.edge.gallery.data.Task\nimport com.google.ai.edge.gallery.ui.common.tos.GemmaTermsOfUseDialog\nimport com.google.ai.edge.gallery.ui.common.tos.TosViewModel\nimport com.google.ai.edge.gallery.ui.modelmanager.ModelManagerViewModel\nimport com.google.ai.edge.gallery.ui.modelmanager.TokenRequestResultType\nimport com.google.ai.edge.gallery.ui.modelmanager.TokenStatus\nimport java.net.HttpURLConnection\nimport kotlinx.coroutines.Dispatchers\nimport kotlinx.coroutines.launch\nimport kotlinx.coroutines.withContext\n\nprivate const val TAG = \"AGDownloadAndTryButton\"\nprivate const val SYSTEM_RESERVED_MEMORY_IN_BYTES = 3 * (1L shl 30)\n\n/**\n * Handles the \"Download & Try it\" button click, managing the model download process based on\n * various conditions.\n *\n * If the button is enabled and not currently checking the token, it initiates a coroutine to handle\n * the download logic.\n *\n * For models requiring download first, it specifically addresses HuggingFace URLs by first checking\n * if authentication is necessary. If no authentication is needed, the download starts directly.\n * Otherwise, it checks the current token status; if the token is invalid or expired, a token\n * exchange flow is initiated. If a valid token exists, it attempts to access the download URL. If\n * access is granted, the download begins; if not, a new token is requested.\n *\n * For non-HuggingFace URLs that need downloading, the download starts directly.\n *\n * If the model doesn't need to be downloaded first, the provided `onClicked` callback is executed.\n *\n * Additionally, for gated HuggingFace models, if accessing the model after token exchange results\n * in a forbidden error, a modal bottom sheet is displayed, prompting the user to acknowledge the\n * user agreement by opening it in a custom tab. Upon closing the tab, the download process is\n * retried.\n *\n * The composable also manages UI states for indicating token checking and displaying the agreement\n * acknowledgement sheet, and it handles requesting notification permissions before initiating the\n * actual download.\n */\n@OptIn(ExperimentalMaterial3Api::class)\n@Composable\nfun DownloadAndTryButton(\n  task: Task?,\n  model: Model,\n  enabled: Boolean,\n  downloadStatus: ModelDownloadStatus?,\n  modelManagerViewModel: ModelManagerViewModel,\n  onClicked: () -> Unit,\n  modifier: Modifier = Modifier,\n  tosViewModel: TosViewModel = hiltViewModel(),\n  modifierWhenExpanded: Modifier = Modifier,\n  compact: Boolean = false,\n  canShowTryIt: Boolean = true,\n) {\n  val scope = rememberCoroutineScope()\n  val context = LocalContext.current\n  var checkingToken by remember { mutableStateOf(false) }\n  var showAgreementAckSheet by remember { mutableStateOf(false) }\n  var showErrorDialog by remember { mutableStateOf(false) }\n  var showMemoryWarning by remember { mutableStateOf(false) }\n  var showGemmaTermsOfUseDialog by remember { mutableStateOf(false) }\n  var downloadStarted by remember { mutableStateOf(false) }\n  val sheetState = rememberModalBottomSheetState()\n\n  val needToDownloadFirst =\n    (downloadStatus?.status == ModelDownloadStatusType.NOT_DOWNLOADED ||\n      downloadStatus?.status == ModelDownloadStatusType.FAILED) &&\n      model.localFileRelativeDirPathOverride.isEmpty()\n  val inProgress = downloadStatus?.status == ModelDownloadStatusType.IN_PROGRESS\n  val downloadSucceeded = downloadStatus?.status == ModelDownloadStatusType.SUCCEEDED\n  val isPartiallyDownloaded = downloadStatus?.status == ModelDownloadStatusType.PARTIALLY_DOWNLOADED\n  val showDownloadProgress =\n    !downloadSucceeded && (downloadStarted || checkingToken || inProgress || isPartiallyDownloaded)\n  var curDownloadProgress: Float\n\n  // A launcher for requesting notification permission.\n  val permissionLauncher =\n    rememberLauncherForActivityResult(ActivityResultContracts.RequestPermission()) {\n      modelManagerViewModel.downloadModel(task = task, model = model)\n    }\n\n  // Function to kick off download.\n  val startDownload: (accessToken: String?) -> Unit = { accessToken ->\n    model.accessToken = accessToken\n    checkNotificationPermissionAndStartDownload(\n      context = context,\n      launcher = permissionLauncher,\n      modelManagerViewModel = modelManagerViewModel,\n      task = task,\n      model = model,\n    )\n    checkingToken = false\n  }\n\n  // A launcher for opening the custom tabs intent for requesting user agreement ack.\n  // Once the tab is closed, try starting the download process.\n  val agreementAckLauncher: ActivityResultLauncher<Intent> =\n    rememberLauncherForActivityResult(\n      contract = ActivityResultContracts.StartActivityForResult()\n    ) { result ->\n      Log.d(TAG, \"User closes the browser tab. Try to start downloading.\")\n      startDownload(modelManagerViewModel.curAccessToken)\n    }\n\n  // A launcher for handling the authentication flow.\n  // It processes the result of the authentication activity and then checks if a user agreement\n  // acknowledgement is needed before proceeding with the model download.\n  val authResultLauncher =\n    rememberLauncherForActivityResult(\n      contract = ActivityResultContracts.StartActivityForResult()\n    ) { result ->\n      modelManagerViewModel.handleAuthResult(\n        result,\n        onTokenRequested = { tokenRequestResult ->\n          when (tokenRequestResult.status) {\n            TokenRequestResultType.SUCCEEDED -> {\n              Log.d(TAG, \"Token request succeeded. Checking if we need user to ack user agreement\")\n              scope.launch(Dispatchers.IO) {\n                // Check if we can use the current token to access model. If not, we might need to\n                // acknowledge the user agreement.\n                if (\n                  modelManagerViewModel.getModelUrlResponse(\n                    model = model,\n                    accessToken = modelManagerViewModel.curAccessToken,\n                  ) == HttpURLConnection.HTTP_FORBIDDEN\n                ) {\n                  Log.d(TAG, \"Model '${model.name}' needs user agreement ack.\")\n                  showAgreementAckSheet = true\n                } else {\n                  Log.d(\n                    TAG,\n                    \"Model '${model.name}' does NOT need user agreement ack. Start downloading...\",\n                  )\n                  withContext(Dispatchers.Main) {\n                    startDownload(modelManagerViewModel.curAccessToken)\n                  }\n                }\n              }\n            }\n\n            TokenRequestResultType.FAILED -> {\n              Log.d(\n                TAG,\n                \"Token request done. Error message: ${tokenRequestResult.errorMessage ?: \"\"}\",\n              )\n              checkingToken = false\n              downloadStarted = false\n            }\n\n            TokenRequestResultType.USER_CANCELLED -> {\n              Log.d(TAG, \"User cancelled. Do nothing\")\n              checkingToken = false\n              downloadStarted = false\n            }\n          }\n        },\n      )\n    }\n\n  // Function to kick off the authentication and token exchange flow.\n  val startTokenExchange = {\n    val authRequest = modelManagerViewModel.getAuthorizationRequest()\n    val authIntent = modelManagerViewModel.authService.getAuthorizationRequestIntent(authRequest)\n    authResultLauncher.launch(authIntent)\n  }\n\n  // Launches a coroutine to handle the initial check and potential authentication flow\n  // before downloading the model. It checks if the model needs to be downloaded first,\n  // handles HuggingFace URLs by verifying the need for authentication, and initiates\n  // the token exchange process if required or proceeds with the download if no auth is needed\n  // or a valid token is available.\n  val handleClickButton = {\n    scope.launch(Dispatchers.IO) {\n      if (needToDownloadFirst) {\n        downloadStarted = true\n        // For HuggingFace urls\n        if (model.url.startsWith(\"https://huggingface.co\")) {\n          checkingToken = true\n\n          // Check if the url needs auth.\n          Log.d(\n            TAG,\n            \"Model '${model.name}' is from HuggingFace. Checking if the url needs auth to download\",\n          )\n          val firstResponseCode = modelManagerViewModel.getModelUrlResponse(model = model)\n          if (firstResponseCode == HttpURLConnection.HTTP_OK) {\n            Log.d(TAG, \"Model '${model.name}' doesn't need auth. Start downloading the model...\")\n            withContext(Dispatchers.Main) { startDownload(null) }\n            return@launch\n          } else if (firstResponseCode < 0) {\n            checkingToken = false\n            downloadStarted = false\n            Log.e(TAG, \"Unknown network error\")\n            showErrorDialog = true\n            return@launch\n          }\n          Log.d(TAG, \"Model '${model.name}' needs auth. Start token exchange process...\")\n\n          // Get current token status\n          val tokenStatusAndData = modelManagerViewModel.getTokenStatusAndData()\n\n          when (tokenStatusAndData.status) {\n            // If token is not stored or expired, log in and request a new token.\n            TokenStatus.NOT_STORED,\n            TokenStatus.EXPIRED -> {\n              withContext(Dispatchers.Main) { startTokenExchange() }\n            }\n\n            // If token is still valid...\n            TokenStatus.NOT_EXPIRED -> {\n              // Use the current token to check the download url.\n              Log.d(TAG, \"Checking the download url '${model.url}' with the current token...\")\n              val responseCode =\n                modelManagerViewModel.getModelUrlResponse(\n                  model = model,\n                  accessToken = tokenStatusAndData.data!!.accessToken,\n                )\n              if (responseCode == HttpURLConnection.HTTP_OK) {\n                // Download url is accessible. Download the model.\n                Log.d(TAG, \"Download url is accessible with the current token.\")\n\n                withContext(Dispatchers.Main) {\n                  startDownload(tokenStatusAndData.data!!.accessToken)\n                }\n              }\n              // Download url is NOT accessible. Request a new token.\n              else {\n                Log.d(\n                  TAG,\n                  \"Download url is NOT accessible. Response code: ${responseCode}. Trying to request a new token.\",\n                )\n\n                withContext(Dispatchers.Main) { startTokenExchange() }\n              }\n            }\n          }\n        }\n        // For other urls, just download the model.\n        else {\n          Log.d(\n            TAG,\n            \"Model '${model.name}' is not from huggingface. Start downloading the model...\",\n          )\n          withContext(Dispatchers.Main) { startDownload(null) }\n        }\n      }\n      // No need to download. Directly open the model.\n      else {\n        withContext(Dispatchers.Main) { onClicked() }\n      }\n    }\n  }\n\n  val checkMemoryAndClickDownloadButton = {\n    if (isMemoryLow(context = context, model = model)) {\n      showMemoryWarning = true\n    } else {\n      handleClickButton()\n    }\n  }\n\n  if (!showDownloadProgress) {\n    var buttonModifier: Modifier = modifier.height(42.dp)\n    if (!compact) {\n      buttonModifier = buttonModifier.then(modifierWhenExpanded)\n    }\n    Button(\n      modifier = buttonModifier,\n      colors =\n        ButtonDefaults.buttonColors(\n          containerColor =\n            if (\n              (!downloadSucceeded || !canShowTryIt) &&\n                model.localFileRelativeDirPathOverride.isEmpty()\n            ) {\n\n              MaterialTheme.colorScheme.surfaceContainer\n            } else if (task != null) {\n              getTaskBgGradientColors(task = task)[1]\n            } else {\n              MaterialTheme.colorScheme.primary\n            }\n        ),\n      contentPadding = PaddingValues(horizontal = 12.dp),\n      onClick = {\n        if (!enabled || checkingToken) {\n          return@Button\n        }\n\n        // Check TOS before downloading.\n        if (\n          model.url.startsWith(\"https://dl.google.com/google-ai-edge-gallery/\") &&\n            !tosViewModel.getIsGemmaTermsOfUseAccepted()\n        ) {\n          showGemmaTermsOfUseDialog = true\n        } else {\n          checkMemoryAndClickDownloadButton()\n        }\n      },\n    ) {\n      val textColor =\n        if (!downloadSucceeded && model.localFileRelativeDirPathOverride.isEmpty()) {\n          MaterialTheme.colorScheme.onSurface\n        } else if (task != null) {\n          Color.White\n        } else {\n          MaterialTheme.colorScheme.onPrimary\n        }\n      Row(\n        verticalAlignment = Alignment.CenterVertically,\n        horizontalArrangement = Arrangement.spacedBy(8.dp),\n      ) {\n        Icon(\n          if (needToDownloadFirst) Icons.Outlined.FileDownload\n          else Icons.AutoMirrored.Rounded.ArrowForward,\n          contentDescription = null,\n          tint = textColor,\n        )\n\n        if (!compact) {\n          if (needToDownloadFirst) {\n            Text(\n              stringResource(R.string.download),\n              color = textColor,\n              style = MaterialTheme.typography.titleMedium,\n            )\n          } else if (canShowTryIt) {\n            Text(\n              stringResource(R.string.try_it),\n              color = textColor,\n              style = MaterialTheme.typography.titleMedium,\n              maxLines = 1,\n              autoSize =\n                TextAutoSize.StepBased(minFontSize = 8.sp, maxFontSize = 16.sp, stepSize = 1.sp),\n            )\n          }\n        }\n      }\n    }\n  }\n  // Download progress.\n  else {\n    curDownloadProgress =\n      downloadStatus!!.receivedBytes.toFloat() / downloadStatus.totalBytes.toFloat()\n    if (curDownloadProgress.isNaN()) {\n      curDownloadProgress = 0f\n    }\n    val animatedProgress = remember { Animatable(0f) }\n\n    var downloadProgressModifier: Modifier = modifier\n    if (!compact) {\n      downloadProgressModifier = downloadProgressModifier.fillMaxWidth()\n    }\n    downloadProgressModifier =\n      downloadProgressModifier\n        .clip(CircleShape)\n        .background(MaterialTheme.colorScheme.surfaceContainer)\n        .padding(horizontal = 8.dp)\n        .height(42.dp)\n    Row(modifier = downloadProgressModifier, verticalAlignment = Alignment.CenterVertically) {\n      if (checkingToken) {\n        Text(\n          stringResource(R.string.checking_access),\n          style = MaterialTheme.typography.bodyMedium,\n          color = MaterialTheme.colorScheme.onSurface,\n          textAlign = TextAlign.Center,\n          modifier = if (!compact) Modifier.fillMaxWidth() else Modifier.padding(horizontal = 4.dp),\n        )\n      } else {\n        Text(\n          \"${(curDownloadProgress * 100).toInt()}%\",\n          style = MaterialTheme.typography.bodyMedium,\n          color = MaterialTheme.colorScheme.onSurface,\n          modifier = Modifier.padding(start = 12.dp).width(if (compact) 32.dp else 44.dp),\n        )\n        if (!compact) {\n          val color =\n            if (task != null) getTaskBgGradientColors(task = task)[1]\n            else MaterialTheme.colorScheme.primary\n          LinearProgressIndicator(\n            modifier = Modifier.weight(1f).padding(horizontal = 4.dp),\n            progress = { animatedProgress.value },\n            color = color,\n            trackColor = MaterialTheme.colorScheme.surfaceContainerHighest,\n          )\n        }\n        val cbStop = stringResource(R.string.cd_stop_icon)\n        IconButton(\n          onClick = {\n            downloadStarted = false\n            modelManagerViewModel.cancelDownloadModel(model = model)\n          },\n          colors =\n            IconButtonDefaults.iconButtonColors(\n              containerColor = MaterialTheme.colorScheme.surfaceContainer\n            ),\n          modifier = Modifier.semantics { contentDescription = cbStop },\n        ) {\n          Icon(\n            Icons.Outlined.Close,\n            contentDescription = null,\n            tint = MaterialTheme.colorScheme.onSurface,\n          )\n        }\n      }\n    }\n    LaunchedEffect(curDownloadProgress) {\n      animatedProgress.animateTo(curDownloadProgress, animationSpec = tween(150))\n    }\n  }\n\n  // A ModalBottomSheet composable that displays information about the user agreement\n  // for a gated model and provides a button to open the agreement in a custom tab.\n  // Upon clicking the button, it constructs the agreement URL, launches it using a\n  // custom tab, and then dismisses the bottom sheet.\n  if (showAgreementAckSheet) {\n    ModalBottomSheet(\n      onDismissRequest = {\n        showAgreementAckSheet = false\n        checkingToken = false\n      },\n      sheetState = sheetState,\n      modifier = Modifier.wrapContentHeight(),\n    ) {\n      Column(\n        horizontalAlignment = Alignment.CenterHorizontally,\n        modifier = Modifier.padding(horizontal = 16.dp),\n      ) {\n        Text(\"Acknowledge user agreement\", style = MaterialTheme.typography.titleLarge)\n        Text(\n          \"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.\",\n          style = MaterialTheme.typography.bodyMedium,\n          modifier = Modifier.padding(vertical = 16.dp),\n        )\n        Button(\n          onClick = {\n            // Get agreement url from model url.\n            val index = model.url.indexOf(\"/resolve/\")\n            // Show it in a tab.\n            if (index >= 0) {\n              val agreementUrl = model.url.substring(0, index)\n\n              val customTabsIntent = CustomTabsIntent.Builder().build()\n              customTabsIntent.intent.setData(agreementUrl.toUri())\n              agreementAckLauncher.launch(customTabsIntent.intent)\n            }\n            // Dismiss the sheet.\n            showAgreementAckSheet = false\n          }\n        ) {\n          Text(\"Open user agreement\")\n        }\n      }\n    }\n  }\n\n  if (showErrorDialog) {\n    AlertDialog(\n      icon = {\n        Icon(\n          Icons.Rounded.Error,\n          contentDescription = stringResource(R.string.cd_error),\n          tint = MaterialTheme.colorScheme.error,\n        )\n      },\n      title = { Text(\"Unknown network error\") },\n      text = { Text(\"Please check your internet connection.\") },\n      onDismissRequest = { showErrorDialog = false },\n      confirmButton = { TextButton(onClick = { showErrorDialog = false }) { Text(\"Close\") } },\n    )\n  }\n\n  if (showMemoryWarning) {\n    MemoryWarningAlert(\n      onProceeded = {\n        handleClickButton()\n        showMemoryWarning = false\n      },\n      onDismissed = { showMemoryWarning = false },\n    )\n  }\n\n  if (showGemmaTermsOfUseDialog) {\n    GemmaTermsOfUseDialog(\n      onTosAccepted = {\n        showGemmaTermsOfUseDialog = false\n        tosViewModel.acceptGemmaTermsOfUse()\n        checkMemoryAndClickDownloadButton()\n      },\n      onCancel = { showGemmaTermsOfUseDialog = false },\n    )\n  }\n}\n"
  },
  {
    "path": "Android/src/app/src/main/java/com/google/ai/edge/gallery/ui/common/EmptyState.kt",
    "content": "/*\n * Copyright 2025 Google LLC\n *\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n *     http://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\n\npackage com.google.ai.edge.gallery.ui.common\n\nimport androidx.annotation.StringRes\nimport androidx.compose.foundation.layout.Arrangement\nimport androidx.compose.foundation.layout.Box\nimport androidx.compose.foundation.layout.Column\nimport androidx.compose.foundation.layout.padding\nimport androidx.compose.foundation.layout.size\nimport androidx.compose.material3.Button\nimport androidx.compose.material3.Icon\nimport androidx.compose.material3.MaterialTheme\nimport androidx.compose.material3.Text\nimport androidx.compose.runtime.Composable\nimport androidx.compose.ui.Alignment\nimport androidx.compose.ui.Modifier\nimport androidx.compose.ui.graphics.vector.ImageVector\nimport androidx.compose.ui.res.stringResource\nimport androidx.compose.ui.text.style.TextAlign\nimport androidx.compose.ui.unit.dp\n\ndata class EmptyStateButtonConfig(\n  @StringRes val buttonLabelResId: Int,\n  val buttonIcon: ImageVector? = null,\n  val onButtonClick: () -> Unit = {},\n  val extraContent: @Composable () -> Unit = {},\n)\n\n/**\n * A composable function to display an empty state with an icon, title, description, and an optional\n * button.\n */\n@Composable\nfun EmptyState(\n  icon: ImageVector,\n  @StringRes titleResId: Int,\n  @StringRes descriptionResId: Int,\n  buttonConfig: EmptyStateButtonConfig? = null,\n) {\n  Column(\n    horizontalAlignment = Alignment.CenterHorizontally,\n    verticalArrangement = Arrangement.spacedBy(16.dp),\n    modifier = Modifier.padding(horizontal = 48.dp),\n  ) {\n    Icon(\n      icon,\n      contentDescription = null,\n      modifier = Modifier.size(56.dp),\n      tint = MaterialTheme.colorScheme.onSurfaceVariant,\n    )\n    Text(\n      stringResource(titleResId),\n      style = MaterialTheme.typography.headlineMedium,\n      color = MaterialTheme.colorScheme.onSurface,\n      textAlign = TextAlign.Center,\n    )\n    Text(\n      stringResource(descriptionResId),\n      style = MaterialTheme.typography.bodyLarge,\n      color = MaterialTheme.colorScheme.onSurfaceVariant,\n      textAlign = TextAlign.Center,\n    )\n    if (buttonConfig != null) {\n      Box {\n        Button(\n          contentPadding = SMALL_BUTTON_CONTENT_PADDING,\n          onClick = buttonConfig.onButtonClick,\n        ) {\n          if (buttonConfig.buttonIcon != null) {\n            Icon(\n              buttonConfig.buttonIcon,\n              contentDescription = null,\n              modifier = Modifier.padding(end = 8.dp).size(20.dp),\n            )\n          }\n          Text(stringResource(buttonConfig.buttonLabelResId))\n        }\n        buttonConfig.extraContent()\n      }\n    }\n  }\n}\n"
  },
  {
    "path": "Android/src/app/src/main/java/com/google/ai/edge/gallery/ui/common/ErrorDialog.kt",
    "content": "/*\n * Copyright 2025 Google LLC\n *\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n *     http://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\n\npackage com.google.ai.edge.gallery.ui.common\n\nimport androidx.compose.foundation.layout.Arrangement\nimport androidx.compose.foundation.layout.Column\nimport androidx.compose.foundation.layout.Row\nimport androidx.compose.foundation.layout.fillMaxWidth\nimport androidx.compose.foundation.layout.padding\nimport androidx.compose.foundation.shape.RoundedCornerShape\nimport androidx.compose.material3.Button\nimport androidx.compose.material3.Card\nimport androidx.compose.material3.MaterialTheme\nimport androidx.compose.material3.Text\nimport androidx.compose.runtime.Composable\nimport androidx.compose.ui.Modifier\nimport androidx.compose.ui.unit.dp\nimport androidx.compose.ui.window.Dialog\n\n@Composable\nfun ErrorDialog(error: String, onDismiss: () -> Unit) {\n  Dialog(onDismissRequest = onDismiss) {\n    Card(modifier = Modifier.fillMaxWidth(), shape = RoundedCornerShape(16.dp)) {\n      Column(\n        modifier = Modifier.padding(20.dp),\n        verticalArrangement = Arrangement.spacedBy(16.dp),\n      ) {\n        // Title\n        Text(\n          \"Error\",\n          style = MaterialTheme.typography.titleLarge,\n          modifier = Modifier.padding(bottom = 8.dp),\n        )\n\n        // Error\n        Text(\n          error,\n          style = MaterialTheme.typography.bodySmall,\n          color = MaterialTheme.colorScheme.error,\n        )\n\n        Row(modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.End) {\n          Button(onClick = onDismiss) { Text(\"Close\") }\n        }\n      }\n    }\n  }\n}\n"
  },
  {
    "path": "Android/src/app/src/main/java/com/google/ai/edge/gallery/ui/common/GalleryWebView.kt",
    "content": "/*\n * Copyright 2025 Google LLC\n *\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n *     http://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\n\npackage com.google.ai.edge.gallery.ui.common\n\nimport android.Manifest\nimport android.content.Context\nimport android.util.Log\nimport android.view.ViewGroup\nimport android.webkit.ConsoleMessage\nimport android.webkit.PermissionRequest\nimport android.webkit.WebChromeClient\nimport android.webkit.WebResourceRequest\nimport android.webkit.WebResourceResponse\nimport android.webkit.WebView\nimport android.webkit.WebViewClient\nimport androidx.activity.compose.rememberLauncherForActivityResult\nimport androidx.activity.result.contract.ActivityResultContracts\nimport androidx.compose.runtime.Composable\nimport androidx.compose.runtime.getValue\nimport androidx.compose.runtime.mutableStateOf\nimport androidx.compose.runtime.remember\nimport androidx.compose.runtime.setValue\nimport androidx.compose.ui.Modifier\nimport androidx.compose.ui.platform.LocalContext\nimport androidx.compose.ui.viewinterop.AndroidView\nimport androidx.webkit.WebViewAssetLoader\nimport com.google.ai.edge.gallery.common.LOCAL_URL_BASE\nimport java.io.File\n\nprivate const val TAG = \"AGGalleryWebView\"\nprivate val iframeWrapper =\n  \"\"\"\n  <html>\n    <body style=\"margin:0;padding:0;\">\n      <iframe\n          width=\"100%\"\n          height=\"100%\"\n          src=\"___\"\n          frameborder=\"0\"\n          style=\"border:0;\">\n      </iframe>\n    </body>\n  </html>\n  \"\"\"\n    .trimIndent()\n\n/**\n * A base [WebViewClient] for [GalleryWebView] that handles local asset loading and logs page\n * finishing.\n */\nopen class BaseGalleryWebViewClient(private val context: Context) : WebViewClient() {\n  private val localFileAssetsLoader =\n    WebViewAssetLoader.Builder()\n      .addPathHandler(\"/\", WebViewAssetLoader.InternalStoragePathHandler(context, context.filesDir))\n      .build()\n\n  override fun shouldInterceptRequest(\n    view: WebView?,\n    request: WebResourceRequest?,\n  ): WebResourceResponse? {\n    if (request?.url != null && request.url.toString().startsWith(LOCAL_URL_BASE)) {\n      // Returns 404 if file not exist.\n      val path = request.url.path ?: \"\"\n      val localFile = File(context.filesDir, path)\n      if (!localFile.exists() || localFile.isDirectory) {\n        return WebResourceResponse(\"text/plain\", \"UTF-8\", null)\n      }\n      return localFileAssetsLoader.shouldInterceptRequest(request.url)\n    }\n    return super.shouldInterceptRequest(view, request)\n  }\n}\n\n/**\n * A reusable Composable that wraps an Android WebView, providing common configurations and handling\n * for permissions, local asset loading, and JavaScript interfaces.\n */\n@Composable\nfun GalleryWebView(\n  modifier: Modifier = Modifier,\n  initialUrl: String? = null,\n  useIframeWrapper: Boolean = false,\n  preventParentScrolling: Boolean = false,\n  allowRequestPermission: Boolean = false,\n  onWebViewCreated: ((WebView) -> Unit)? = null,\n  onConsoleMessage: ((ConsoleMessage?) -> Boolean)? = null,\n  onPermissionRequest: ((PermissionRequest?) -> Unit)? = null,\n  customWebViewClient: WebViewClient? = null,\n) {\n  val context = LocalContext.current\n\n  val curWebViewClient = remember {\n    customWebViewClient ?: BaseGalleryWebViewClient(context = context)\n  }\n  var pendingCameraPermissionRequest by remember { mutableStateOf<PermissionRequest?>(null) }\n  var pendingAudioPermissionRequest by remember { mutableStateOf<PermissionRequest?>(null) }\n\n  val cameraPermissionLauncher =\n    rememberLauncherForActivityResult(contract = ActivityResultContracts.RequestPermission()) {\n      isGranted: Boolean ->\n      pendingCameraPermissionRequest?.let { request ->\n        if (isGranted) {\n          request.grant(arrayOf(PermissionRequest.RESOURCE_VIDEO_CAPTURE))\n        } else {\n          // If camera is denied, we don't call request.deny() on the whole request,\n          // as it might contain other resources. The WebView will handle the denial\n          // of the specific camera resource.\n        }\n        pendingCameraPermissionRequest = null\n      }\n    }\n\n  val audioPermissionLauncher =\n    rememberLauncherForActivityResult(contract = ActivityResultContracts.RequestPermission()) {\n      isGranted: Boolean ->\n      pendingAudioPermissionRequest?.let { request ->\n        if (isGranted) {\n          request.grant(arrayOf(PermissionRequest.RESOURCE_AUDIO_CAPTURE))\n        } else {\n          // Similar to camera, don't call request.deny() on the whole request.\n        }\n        pendingAudioPermissionRequest = null\n      }\n    }\n\n  AndroidView(\n    modifier = modifier,\n    factory = { ctx ->\n      WebView(ctx).apply {\n        layoutParams =\n          ViewGroup.LayoutParams(\n            ViewGroup.LayoutParams.MATCH_PARENT,\n            ViewGroup.LayoutParams.MATCH_PARENT,\n          )\n\n        settings.apply {\n          javaScriptEnabled = true\n          domStorageEnabled = true\n          allowFileAccess = true\n          mediaPlaybackRequiresUserGesture = false\n        }\n\n        if (preventParentScrolling) {\n          setOnTouchListener { v, event ->\n            v.parent.requestDisallowInterceptTouchEvent(true)\n            false\n          }\n        }\n\n        webChromeClient =\n          object : WebChromeClient() {\n            override fun onConsoleMessage(consoleMessage: ConsoleMessage?): Boolean {\n              onConsoleMessage?.invoke(consoleMessage)\n                ?: Log.d(\n                  TAG,\n                  \"${consoleMessage?.message()} -- From line ${consoleMessage?.lineNumber()} of ${consoleMessage?.sourceId()}\",\n                )\n              return super.onConsoleMessage(consoleMessage)\n            }\n\n            override fun onPermissionRequest(request: PermissionRequest?) {\n              if (!allowRequestPermission) {\n                request?.deny()\n                return\n              }\n\n              if (request == null) return\n              onPermissionRequest?.invoke(request)\n                ?: run {\n                  val resources = request.resources\n                  val isCameraRequest =\n                    resources.any { it == PermissionRequest.RESOURCE_VIDEO_CAPTURE }\n                  val isAudioRequest =\n                    resources.any { it == PermissionRequest.RESOURCE_AUDIO_CAPTURE }\n\n                  if (isCameraRequest) {\n                    pendingCameraPermissionRequest = request\n                    cameraPermissionLauncher.launch(Manifest.permission.CAMERA)\n                  }\n\n                  if (isAudioRequest) {\n                    pendingAudioPermissionRequest = request\n                    audioPermissionLauncher.launch(Manifest.permission.RECORD_AUDIO)\n                  }\n\n                  val otherResources =\n                    resources\n                      .filter {\n                        it != PermissionRequest.RESOURCE_VIDEO_CAPTURE &&\n                          it != PermissionRequest.RESOURCE_AUDIO_CAPTURE\n                      }\n                      .toTypedArray()\n                  if (otherResources.isNotEmpty()) {\n                    request.grant(otherResources)\n                  }\n                }\n            }\n          }\n\n        webViewClient = curWebViewClient\n\n        initialUrl?.let { url ->\n          if (useIframeWrapper) {\n            loadDataWithBaseURL(null, iframeWrapper.replace(\"___\", url), \"text/html\", \"UTF-8\", null)\n          } else {\n            loadUrl(url)\n          }\n        }\n        onWebViewCreated?.invoke(this)\n      }\n    },\n    onRelease = { webView ->\n      webView.stopLoading()\n      webView.destroy()\n    },\n  )\n}\n"
  },
  {
    "path": "Android/src/app/src/main/java/com/google/ai/edge/gallery/ui/common/GlitteringShapesLoader.kt",
    "content": "/*\n * Copyright 2025 Google LLC\n *\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n *     http://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\n\npackage com.google.ai.edge.gallery.ui.common\n\nimport androidx.compose.animation.core.LinearEasing\nimport androidx.compose.animation.core.animateFloatAsState\nimport androidx.compose.animation.core.tween\nimport androidx.compose.foundation.Image\nimport androidx.compose.foundation.layout.Box\nimport androidx.compose.foundation.layout.fillMaxSize\nimport androidx.compose.foundation.layout.size\nimport androidx.compose.material3.MaterialTheme\nimport androidx.compose.runtime.Composable\nimport androidx.compose.runtime.LaunchedEffect\nimport androidx.compose.runtime.getValue\nimport androidx.compose.runtime.key\nimport androidx.compose.runtime.mutableStateOf\nimport androidx.compose.runtime.remember\nimport androidx.compose.runtime.setValue\nimport androidx.compose.ui.Alignment\nimport androidx.compose.ui.Modifier\nimport androidx.compose.ui.graphics.Color\nimport androidx.compose.ui.graphics.ColorFilter\nimport androidx.compose.ui.graphics.graphicsLayer\nimport androidx.compose.ui.graphics.lerp\nimport androidx.compose.ui.layout.ContentScale\nimport androidx.compose.ui.layout.onSizeChanged\nimport androidx.compose.ui.res.painterResource\nimport androidx.compose.ui.unit.Dp\nimport androidx.compose.ui.unit.IntSize\nimport androidx.compose.ui.unit.dp\nimport com.google.ai.edge.gallery.R\nimport com.google.ai.edge.gallery.ui.theme.customColors\nimport kotlin.random.Random\nimport kotlinx.coroutines.Dispatchers\nimport kotlinx.coroutines.delay\nimport kotlinx.coroutines.withContext\n\nprivate val SHAPES: List<Int> =\n  listOf(R.drawable.circle, R.drawable.double_circle, R.drawable.pantegon, R.drawable.four_circle)\n\ndata class Shape(\n  val id: Long,\n  val shape: Int,\n  val relativeX: Float,\n  val relativeY: Float,\n  val size: Dp,\n  val color: Color,\n  val addedTs: Long,\n)\n\nprivate const val PARTICLE_ANIMATION_DURATION = 300\nprivate const val PARTICLE_ALIVE_MS = 600\nprivate const val PARTICLE_BASE_SIZE = 6\nprivate const val BATCH_SIZE = 5\nprivate const val BATCH_INTERVAL_MS = 300\n\nvar curId = 0L\n\n@Composable\nfun GlitteringShapesLoader() {\n  var shapes by remember { mutableStateOf(listOf<Shape>()) }\n  var boxSize by remember { mutableStateOf(IntSize.Zero) }\n  val taskIconColors = MaterialTheme.customColors.taskIconColors\n\n  // Use LaunchedEffect to manage the list of shapes\n  LaunchedEffect(Unit) {\n    withContext(Dispatchers.Default) {\n      while (true) {\n        val newShapes = mutableListOf<Shape>()\n        for (i in 1..BATCH_SIZE) {\n          val shape =\n            Shape(\n              id = curId++,\n              shape = SHAPES[Random.nextInt(SHAPES.size)],\n              relativeX = Random.nextFloat(),\n              relativeY = Random.nextFloat(),\n              size = (PARTICLE_BASE_SIZE + Random.nextInt(-2, 2)).dp,\n              color = taskIconColors[Random.nextInt(taskIconColors.size)],\n              addedTs = System.currentTimeMillis(),\n            )\n          newShapes.add(shape)\n        }\n        val curTs = System.currentTimeMillis()\n        for (shape in shapes) {\n          if (curTs - shape.addedTs > PARTICLE_ANIMATION_DURATION * 2 + PARTICLE_ALIVE_MS + 100) {\n            continue\n          }\n          newShapes.add(shape)\n        }\n        shapes = newShapes\n        delay(BATCH_INTERVAL_MS.toLong())\n      }\n    }\n  }\n\n  Box(\n    modifier = Modifier.fillMaxSize().onSizeChanged { boxSize = it },\n    contentAlignment = Alignment.TopStart,\n  ) {\n    for (shape in shapes) {\n      key(shape.id) { Particle(shape = shape, boxSize = boxSize) }\n    }\n  }\n}\n\n@Composable\nprivate fun Particle(shape: Shape, boxSize: IntSize) {\n  var enterAnimation by remember { mutableStateOf(false) }\n  val enterProgress: Float by\n    animateFloatAsState(\n      if (enterAnimation) 1f else 0f,\n      animationSpec = tween(durationMillis = PARTICLE_ANIMATION_DURATION, easing = LinearEasing),\n    )\n  val initialDelay = remember { Random.nextLong(50) }\n  LaunchedEffect(Unit) {\n    delay(initialDelay)\n    enterAnimation = true\n  }\n\n  var exitAnimation by remember { mutableStateOf(false) }\n  val exitProgress: Float by\n    animateFloatAsState(\n      if (exitAnimation) 1f else 0f,\n      animationSpec = tween(durationMillis = PARTICLE_ANIMATION_DURATION, easing = LinearEasing),\n    )\n  LaunchedEffect(Unit) {\n    delay(initialDelay + PARTICLE_ALIVE_MS.toLong())\n    exitAnimation = true\n  }\n\n  val progress = if (exitProgress > 0) (1 - exitProgress) else enterProgress\n  Image(\n    painter = painterResource(shape.shape),\n    contentDescription = null,\n    modifier =\n      Modifier.size(shape.size).graphicsLayer {\n        translationX = boxSize.width * shape.relativeX\n        translationY = boxSize.height * shape.relativeY\n        scaleX = progress\n        scaleY = progress\n      },\n    colorFilter = ColorFilter.tint(lerp(shape.color, Color.White, 0.95f)),\n    contentScale = ContentScale.Fit,\n  )\n}\n"
  },
  {
    "path": "Android/src/app/src/main/java/com/google/ai/edge/gallery/ui/common/LiveCameraView.kt",
    "content": "/*\n * Copyright 2025 Google LLC\n *\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n *     http://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\n\npackage com.google.ai.edge.gallery.ui.common\n\nimport android.Manifest\nimport android.content.Context\nimport android.content.pm.PackageManager\nimport android.graphics.Bitmap\nimport android.graphics.Matrix\nimport android.util.Size\nimport androidx.activity.compose.rememberLauncherForActivityResult\nimport androidx.activity.result.contract.ActivityResultContracts\nimport androidx.camera.core.CameraSelector\nimport androidx.camera.core.ImageAnalysis\nimport androidx.camera.core.ImageProxy\nimport androidx.camera.core.resolutionselector.ResolutionSelector\nimport androidx.camera.core.resolutionselector.ResolutionStrategy\nimport androidx.camera.lifecycle.ProcessCameraProvider\nimport androidx.camera.lifecycle.awaitInstance\nimport androidx.compose.foundation.Canvas\nimport androidx.compose.foundation.background\nimport androidx.compose.foundation.layout.Arrangement\nimport androidx.compose.foundation.layout.Row\nimport androidx.compose.foundation.layout.fillMaxSize\nimport androidx.compose.runtime.Composable\nimport androidx.compose.runtime.DisposableEffect\nimport androidx.compose.runtime.LaunchedEffect\nimport androidx.compose.runtime.getValue\nimport androidx.compose.runtime.mutableStateOf\nimport androidx.compose.runtime.remember\nimport androidx.compose.runtime.rememberCoroutineScope\nimport androidx.compose.runtime.setValue\nimport androidx.compose.ui.Modifier\nimport androidx.compose.ui.geometry.Rect\nimport androidx.compose.ui.graphics.Color\nimport androidx.compose.ui.graphics.ImageBitmap\nimport androidx.compose.ui.graphics.asImageBitmap\nimport androidx.compose.ui.platform.LocalContext\nimport androidx.compose.ui.unit.IntOffset\nimport androidx.compose.ui.unit.IntSize\nimport androidx.core.content.ContextCompat\nimport androidx.lifecycle.LifecycleOwner\nimport androidx.lifecycle.compose.LocalLifecycleOwner\nimport java.util.concurrent.Executors\nimport kotlinx.coroutines.launch\n\n@Composable\nfun LiveCameraView(\n  onBitmap: (Bitmap, ImageProxy) -> Unit,\n  modifier: Modifier = Modifier,\n  preferredSize: Int = 500,\n  @ImageAnalysis.OutputImageFormat\n  outputImageFormat: Int = ImageAnalysis.OUTPUT_IMAGE_FORMAT_RGBA_8888,\n  renderPreview: Boolean = true,\n  cameraSelector: CameraSelector = CameraSelector.DEFAULT_FRONT_CAMERA,\n) {\n  val context = LocalContext.current\n  val scope = rememberCoroutineScope()\n  val lifecycleOwner = LocalLifecycleOwner.current\n  var imageBitmap by remember { mutableStateOf<ImageBitmap?>(null) }\n  var cameraProvider: ProcessCameraProvider? by remember { mutableStateOf(null) }\n\n  val onBitmapFn: (Bitmap, ImageProxy) -> Unit = { bitmap, imageProxy ->\n    imageBitmap = bitmap.asImageBitmap()\n    onBitmap(bitmap, imageProxy)\n  }\n\n  val liveCameraPermissionLauncher =\n    rememberLauncherForActivityResult(ActivityResultContracts.RequestPermission()) {\n      permissionGranted ->\n      if (permissionGranted) {\n        scope.launch {\n          cameraProvider =\n            startCamera(\n              context = context,\n              lifecycleOwner = lifecycleOwner,\n              onBitmap = onBitmapFn,\n              preferredSize = preferredSize,\n              outputImageFormat = outputImageFormat,\n              cameraSelector = cameraSelector,\n            )\n        }\n      }\n    }\n\n  LaunchedEffect(Unit) {\n    // Check permission.\n    when (PackageManager.PERMISSION_GRANTED) {\n      // Already got permission. Call the lambda.\n      ContextCompat.checkSelfPermission(context, Manifest.permission.CAMERA) -> {\n        cameraProvider =\n          startCamera(\n            context = context,\n            lifecycleOwner = lifecycleOwner,\n            onBitmap = onBitmapFn,\n            preferredSize = preferredSize,\n            outputImageFormat = outputImageFormat,\n            cameraSelector = cameraSelector,\n          )\n      }\n\n      // Otherwise, ask for permission\n      else -> {\n        liveCameraPermissionLauncher.launch(Manifest.permission.CAMERA)\n      }\n    }\n  }\n\n  DisposableEffect(Unit) { onDispose { cameraProvider?.unbindAll() } }\n\n  // Camera live view.\n  if (renderPreview) {\n    Row(modifier = modifier.background(Color.Red), horizontalArrangement = Arrangement.Center) {\n      val ib = imageBitmap\n      if (ib != null) {\n        Canvas(modifier = Modifier.fillMaxSize()) {\n          val bitmapWidth = ib.width.toFloat()\n          val bitmapHeight = ib.height.toFloat()\n          val canvasWidth = size.width\n          val canvasHeight = size.height\n\n          // Calculate the scale to fill the canvas while maintaining aspect ratio\n          val scale: Float =\n            if (bitmapWidth / bitmapHeight > canvasWidth / canvasHeight) {\n              canvasHeight / bitmapHeight\n            } else {\n              canvasWidth / bitmapWidth\n            }\n\n          // Calculate the source rectangle (what to draw from the bitmap)\n          val srcLeft = (bitmapWidth - canvasWidth / scale) / 2\n          val srcTop = (bitmapHeight - canvasHeight / scale) / 2\n          val srcRight = srcLeft + canvasWidth / scale\n          val srcBottom = srcTop + canvasHeight / scale\n          val srcRect = Rect(srcLeft, srcTop, srcRight, srcBottom)\n\n          // The destination rectangle is the entire canvas\n          val dstRect = Rect(0f, 0f, canvasWidth, canvasHeight)\n\n          // Draw the bitmap with the calculated source and destination rectangles\n          drawImage(\n            image = ib,\n            srcOffset = IntOffset(srcRect.topLeft.x.toInt(), srcRect.topLeft.y.toInt()),\n            srcSize = IntSize(srcRect.width.toInt(), srcRect.height.toInt()),\n            dstOffset = IntOffset(dstRect.topLeft.x.toInt(), dstRect.topLeft.y.toInt()),\n            dstSize = IntSize(dstRect.width.toInt(), dstRect.height.toInt()),\n          )\n        }\n      }\n    }\n  }\n}\n\n/** Asynchronously initializes and starts the camera for image capture and analysis. */\nprivate suspend fun startCamera(\n  context: Context,\n  lifecycleOwner: LifecycleOwner,\n  onBitmap: (Bitmap, ImageProxy) -> Unit,\n  preferredSize: Int,\n  @ImageAnalysis.OutputImageFormat outputImageFormat: Int,\n  cameraSelector: CameraSelector,\n): ProcessCameraProvider {\n  val cameraProvider = ProcessCameraProvider.awaitInstance(context)\n\n  val resolutionSelector =\n    ResolutionSelector.Builder()\n      .setResolutionStrategy(\n        ResolutionStrategy(\n          Size(preferredSize, preferredSize),\n          ResolutionStrategy.FALLBACK_RULE_CLOSEST_LOWER_THEN_HIGHER,\n        )\n      )\n      .build()\n  val imageAnalysis =\n    ImageAnalysis.Builder()\n      .setResolutionSelector(resolutionSelector)\n      .setOutputImageFormat(outputImageFormat)\n      .setBackpressureStrategy(ImageAnalysis.STRATEGY_KEEP_ONLY_LATEST)\n      .build()\n      .also {\n        it.setAnalyzer(Executors.newSingleThreadExecutor()) { imageProxy ->\n          var bitmap = imageProxy.toBitmap()\n          val rotation = imageProxy.imageInfo.rotationDegrees\n          val matrix = Matrix()\n          if (rotation != 0) {\n            matrix.postRotate(rotation.toFloat())\n          }\n          if (cameraSelector == CameraSelector.DEFAULT_FRONT_CAMERA) {\n            matrix.postScale(-1f, 1f)\n          }\n          bitmap = Bitmap.createBitmap(bitmap, 0, 0, bitmap.width, bitmap.height, matrix, true)\n          //  The caller is responsible of calling `.close` on imageProxy to mark that the\n          //  processing of the current frame is done.\n          onBitmap(bitmap, imageProxy)\n        }\n      }\n\n  try {\n    cameraProvider.unbindAll()\n    cameraProvider.bindToLifecycle(lifecycleOwner, cameraSelector, imageAnalysis)\n  } catch (exc: Exception) {\n    // todo: Handle exceptions (e.g., camera initialization failure)\n  }\n  return cameraProvider\n}\n"
  },
  {
    "path": "Android/src/app/src/main/java/com/google/ai/edge/gallery/ui/common/MarkdownText.kt",
    "content": "/*\n * Copyright 2025 Google LLC\n *\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n *     http://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\n\npackage com.google.ai.edge.gallery.ui.common\n\nimport androidx.compose.material3.MaterialTheme\nimport androidx.compose.material3.ProvideTextStyle\nimport androidx.compose.runtime.Composable\nimport androidx.compose.runtime.CompositionLocalProvider\nimport androidx.compose.ui.Modifier\nimport androidx.compose.ui.graphics.Color\nimport androidx.compose.ui.text.SpanStyle\nimport androidx.compose.ui.text.TextLinkStyles\nimport androidx.compose.ui.text.TextStyle\nimport androidx.compose.ui.text.font.FontFamily\nimport androidx.compose.ui.unit.sp\nimport com.google.ai.edge.gallery.ui.theme.customColors\nimport com.halilibo.richtext.commonmark.Markdown\nimport com.halilibo.richtext.ui.CodeBlockStyle\nimport com.halilibo.richtext.ui.RichTextStyle\nimport com.halilibo.richtext.ui.material3.RichText\nimport com.halilibo.richtext.ui.string.RichTextStringStyle\n\n/** Composable function to display Markdown-formatted text. */\n@Composable\nfun MarkdownText(\n  text: String,\n  modifier: Modifier = Modifier,\n  smallFontSize: Boolean = false,\n  textColor: Color = MaterialTheme.colorScheme.onSurface,\n  linkColor: Color = MaterialTheme.customColors.linkColor,\n) {\n  val fontSize =\n    if (smallFontSize) MaterialTheme.typography.bodyMedium.fontSize\n    else MaterialTheme.typography.bodyLarge.fontSize\n  CompositionLocalProvider {\n    ProvideTextStyle(\n      value =\n        TextStyle(\n          fontSize = fontSize,\n          lineHeight = fontSize * if (smallFontSize) 1.4f else 1.5f,\n          color = textColor,\n          letterSpacing = 0.2.sp,\n        )\n    ) {\n      RichText(\n        modifier = modifier,\n        style =\n          RichTextStyle(\n            codeBlockStyle =\n              CodeBlockStyle(\n                textStyle =\n                  TextStyle(\n                    fontSize = MaterialTheme.typography.bodySmall.fontSize,\n                    fontFamily = FontFamily.Monospace,\n                    lineHeight = MaterialTheme.typography.bodySmall.fontSize * 1.4f,\n                  )\n              ),\n            stringStyle =\n              RichTextStringStyle(linkStyle = TextLinkStyles(style = SpanStyle(color = linkColor))),\n          ),\n      ) {\n        Markdown(content = text)\n      }\n    }\n  }\n}\n\n// @Preview(showBackground = true)\n// @Composable\n// fun MarkdownTextPreview() {\n//   GalleryTheme {\n//     MarkdownText(text = \"*Hello World*\\n**Good morning!!**\")\n//   }\n// }\n"
  },
  {
    "path": "Android/src/app/src/main/java/com/google/ai/edge/gallery/ui/common/MemoryWarning.kt",
    "content": "/*\n * Copyright 2025 Google LLC\n *\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n *     http://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\n\npackage com.google.ai.edge.gallery.ui.common\n\nimport android.app.ActivityManager\nimport android.content.Context\nimport android.os.Build\nimport android.util.Log\nimport androidx.compose.material3.AlertDialog\nimport androidx.compose.material3.Text\nimport androidx.compose.material3.TextButton\nimport androidx.compose.runtime.Composable\nimport androidx.compose.ui.res.stringResource\nimport com.google.ai.edge.gallery.R\nimport com.google.ai.edge.gallery.data.Model\n\nprivate const val TAG = \"AGMemoryWarning\"\nprivate const val BYTES_IN_GB = 1024f * 1024 * 1024\n\n/** Composable function to display a memory warning alert dialog. */\n@Composable\nfun MemoryWarningAlert(onProceeded: () -> Unit, onDismissed: () -> Unit) {\n  AlertDialog(\n    title = { Text(stringResource(R.string.memory_warning_title)) },\n    text = { Text(stringResource(R.string.memory_warning_content)) },\n    onDismissRequest = onDismissed,\n    confirmButton = {\n      TextButton(onClick = onProceeded) {\n        Text(stringResource(R.string.memory_warning_proceed_anyway))\n      }\n    },\n    dismissButton = { TextButton(onClick = onDismissed) { Text(stringResource(R.string.cancel)) } },\n  )\n}\n\n/** Checks if the device's memory is lower than the required minimum for the given model. */\nfun isMemoryLow(context: Context, model: Model): Boolean {\n  val activityManager =\n    context.getSystemService(android.app.Activity.ACTIVITY_SERVICE) as? ActivityManager\n  val minDeviceMemoryInGb = model.minDeviceMemoryInGb\n  return if (activityManager != null && minDeviceMemoryInGb != null) {\n    val memoryInfo = ActivityManager.MemoryInfo()\n    activityManager.getMemoryInfo(memoryInfo)\n    var deviceMemInGb = memoryInfo.totalMem / BYTES_IN_GB\n    // API 34+ uses advertisedMem instead of totalMem for better accuracy.\n    if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.UPSIDE_DOWN_CAKE) {\n      deviceMemInGb = memoryInfo.advertisedMem / BYTES_IN_GB\n    }\n    Log.d(\n      TAG,\n      \"Device memory (GB): $deviceMemInGb. \" +\n        \"Model's required min device memory (GB): $minDeviceMemoryInGb.\",\n    )\n    deviceMemInGb < minDeviceMemoryInGb\n  } else {\n    false\n  }\n}\n"
  },
  {
    "path": "Android/src/app/src/main/java/com/google/ai/edge/gallery/ui/common/ModelPageAppBar.kt",
    "content": "/*\n * Copyright 2025 Google LLC\n *\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n *     http://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\n\npackage com.google.ai.edge.gallery.ui.common\n\nimport androidx.compose.foundation.background\nimport androidx.compose.foundation.layout.Arrangement\nimport androidx.compose.foundation.layout.Box\nimport androidx.compose.foundation.layout.Column\nimport androidx.compose.foundation.layout.Row\nimport androidx.compose.foundation.layout.offset\nimport androidx.compose.foundation.layout.size\nimport androidx.compose.foundation.shape.CircleShape\nimport androidx.compose.material.icons.Icons\nimport androidx.compose.material.icons.automirrored.rounded.ArrowBack\nimport androidx.compose.material.icons.rounded.MapsUgc\nimport androidx.compose.material.icons.rounded.Tune\nimport androidx.compose.material3.CenterAlignedTopAppBar\nimport androidx.compose.material3.CircularProgressIndicator\nimport androidx.compose.material3.ExperimentalMaterial3Api\nimport androidx.compose.material3.Icon\nimport androidx.compose.material3.IconButton\nimport androidx.compose.material3.MaterialTheme\nimport androidx.compose.material3.Text\nimport androidx.compose.runtime.Composable\nimport androidx.compose.runtime.collectAsState\nimport androidx.compose.runtime.getValue\nimport androidx.compose.runtime.mutableStateOf\nimport androidx.compose.runtime.remember\nimport androidx.compose.runtime.setValue\nimport androidx.compose.ui.Alignment\nimport androidx.compose.ui.Modifier\nimport androidx.compose.ui.draw.alpha\nimport androidx.compose.ui.draw.clip\nimport androidx.compose.ui.graphics.vector.ImageVector\nimport androidx.compose.ui.platform.LocalContext\nimport androidx.compose.ui.res.stringResource\nimport androidx.compose.ui.res.vectorResource\nimport androidx.compose.ui.unit.dp\nimport com.google.ai.edge.gallery.R\nimport com.google.ai.edge.gallery.data.BuiltInTaskId\nimport com.google.ai.edge.gallery.data.ConfigKeys\nimport com.google.ai.edge.gallery.data.Model\nimport com.google.ai.edge.gallery.data.ModelDownloadStatusType\nimport com.google.ai.edge.gallery.data.Task\nimport com.google.ai.edge.gallery.data.convertValueToTargetType\nimport com.google.ai.edge.gallery.ui.modelmanager.ModelInitializationStatusType\nimport com.google.ai.edge.gallery.ui.modelmanager.ModelManagerViewModel\n\n@OptIn(ExperimentalMaterial3Api::class)\n@Composable\nfun ModelPageAppBar(\n  task: Task,\n  model: Model,\n  modelManagerViewModel: ModelManagerViewModel,\n  onBackClicked: () -> Unit,\n  onModelSelected: (prev: Model, cur: Model) -> Unit,\n  inProgress: Boolean,\n  modelPreparing: Boolean,\n  modifier: Modifier = Modifier,\n  isResettingSession: Boolean = false,\n  onResetSessionClicked: (Model) -> Unit = {},\n  canShowResetSessionButton: Boolean = false,\n  hideModelSelector: Boolean = false,\n  useThemeColor: Boolean = false,\n  onConfigChanged: (oldConfigValues: Map<String, Any>, newConfigValues: Map<String, Any>) -> Unit =\n    { _, _ ->\n    },\n  allowEditingSystemPrompt: Boolean = false,\n  curSystemPrompt: String = \"\",\n  onSystemPromptChanged: (String) -> Unit = {},\n) {\n  var showConfigDialog by remember { mutableStateOf(false) }\n  val modelManagerUiState by modelManagerViewModel.uiState.collectAsState()\n  val context = LocalContext.current\n  val curDownloadStatus = modelManagerUiState.modelDownloadStatus[model.name]\n  val modelInitializationStatus = modelManagerUiState.modelInitializationStatus[model.name]\n  val isModelInitializing =\n    modelInitializationStatus?.status == ModelInitializationStatusType.INITIALIZING\n  val isModelInitialized =\n    modelInitializationStatus?.status == ModelInitializationStatusType.INITIALIZED\n\n  CenterAlignedTopAppBar(\n    title = {\n      Column(\n        horizontalAlignment = Alignment.CenterHorizontally,\n        verticalArrangement = Arrangement.spacedBy(4.dp),\n      ) {\n        // Task type.\n        Row(\n          verticalAlignment = Alignment.CenterVertically,\n          horizontalArrangement = Arrangement.spacedBy(10.dp),\n        ) {\n          val tintColor =\n            if (useThemeColor) MaterialTheme.colorScheme.onSurface\n            else getTaskIconColor(task = task)\n          Icon(\n            task.icon ?: ImageVector.vectorResource(task.iconVectorResourceId!!),\n            tint = tintColor,\n            modifier = Modifier.size(24.dp),\n            contentDescription = null,\n          )\n          Text(task.label, style = MaterialTheme.typography.titleMedium, color = tintColor)\n        }\n\n        // Model chips pager.\n        if (!hideModelSelector) {\n          val enableModelPickerChip = !isModelInitializing && !inProgress\n          ModelPickerChip(\n            enabled = enableModelPickerChip,\n            task = task,\n            initialModel = model,\n            modelManagerViewModel = modelManagerViewModel,\n            onModelSelected = onModelSelected,\n          )\n        }\n      }\n    },\n    modifier = modifier,\n    // The back button.\n    navigationIcon = {\n      val enableBackButton = !isModelInitializing && !inProgress\n      IconButton(onClick = onBackClicked, enabled = enableBackButton) {\n        Icon(\n          imageVector = Icons.AutoMirrored.Rounded.ArrowBack,\n          contentDescription = stringResource(R.string.cd_navigate_back_icon),\n        )\n      }\n    },\n    // The config button for the model (if existed).\n    actions = {\n      val downloadSucceeded = curDownloadStatus?.status == ModelDownloadStatusType.SUCCEEDED\n      val showConfigButton = model.configs.isNotEmpty() && downloadSucceeded\n      val showResetSessionButton = canShowResetSessionButton && downloadSucceeded\n      Box(modifier = Modifier.size(42.dp), contentAlignment = Alignment.Center) {\n        var configButtonOffset = 0.dp\n        if (showConfigButton && canShowResetSessionButton) {\n          configButtonOffset = (-40).dp\n        }\n        if (showConfigButton) {\n          val enableConfigButton = !isModelInitializing && !inProgress && isModelInitialized\n          IconButton(\n            onClick = { showConfigDialog = true },\n            enabled = enableConfigButton,\n            modifier =\n              Modifier.offset(x = configButtonOffset).alpha(if (!enableConfigButton) 0.5f else 1f),\n          ) {\n            Icon(\n              imageVector = Icons.Rounded.Tune,\n              contentDescription = stringResource(R.string.cd_model_settings_icon),\n              tint = MaterialTheme.colorScheme.onSurface,\n              modifier = Modifier.size(20.dp),\n            )\n          }\n        }\n        if (showResetSessionButton) {\n          if (isResettingSession) {\n            CircularProgressIndicator(\n              trackColor = MaterialTheme.colorScheme.surfaceVariant,\n              strokeWidth = 2.dp,\n              modifier = Modifier.size(16.dp),\n            )\n          } else {\n            val enableResetButton =\n              !isModelInitializing && !modelPreparing && !inProgress && isModelInitialized\n            IconButton(\n              onClick = { onResetSessionClicked(model) },\n              enabled = enableResetButton,\n              modifier = Modifier.alpha(if (!enableResetButton) 0.5f else 1f),\n            ) {\n              Box(\n                modifier =\n                  Modifier.size(32.dp)\n                    .clip(CircleShape)\n                    .background(MaterialTheme.colorScheme.surfaceContainer),\n                contentAlignment = Alignment.Center,\n              ) {\n                Icon(\n                  imageVector = Icons.Rounded.MapsUgc,\n                  contentDescription = stringResource(R.string.cd_reset_session_icon),\n                  tint = MaterialTheme.colorScheme.onSurface,\n                  modifier = Modifier.size(20.dp),\n                )\n              }\n            }\n          }\n        }\n      }\n    },\n  )\n\n  // Config dialog.\n  if (showConfigDialog) {\n    // Remove the reset conversation turn count config for non-tiny-garden tasks.\n    //\n    // This may happen when user imports a model with \"enable tiny garden\" turned on and use the\n    // model in another non-tiny-garden task.\n    val modelConfigs = model.configs.toMutableList()\n    if (task.id != BuiltInTaskId.LLM_TINY_GARDEN) {\n      modelConfigs.removeIf { it.key == ConfigKeys.RESET_CONVERSATION_TURN_COUNT }\n    }\n    ConfigDialog(\n      title = \"Configurations\",\n      configs = modelConfigs,\n      initialValues = model.configValues,\n      onDismissed = { showConfigDialog = false },\n      onOk = { curConfigValues, oldSystemPrompt, newSystemPrompt ->\n        // Hide config dialog.\n        showConfigDialog = false\n\n        // Check if the configs are changed or not. Also check if the model needs to be\n        // re-initialized.\n        var same = true\n        var needReinitialization = false\n        for (config in modelConfigs) {\n          val key = config.key.label\n          val oldValue =\n            convertValueToTargetType(\n              value = model.configValues.getValue(key),\n              valueType = config.valueType,\n            )\n          val newValue =\n            convertValueToTargetType(\n              value = curConfigValues.getValue(key),\n              valueType = config.valueType,\n            )\n          if (oldValue != newValue) {\n            same = false\n            if (config.needReinitialization) {\n              needReinitialization = true\n            }\n            break\n          }\n        }\n        if (same) {\n          if (newSystemPrompt != oldSystemPrompt) {\n            onSystemPromptChanged(newSystemPrompt)\n          }\n          return@ConfigDialog\n        }\n\n        // Save the config values to Model.\n        val oldConfigValues = model.configValues\n        model.prevConfigValues = oldConfigValues\n        model.configValues = curConfigValues\n        modelManagerViewModel.updateConfigValuesUpdateTrigger()\n\n        if (!task.handleModelConfigChangesInTask) {\n          // Force to re-initialize the model with the new configs.\n          if (needReinitialization) {\n            modelManagerViewModel.initializeModel(\n              context = context,\n              task = task,\n              model = model,\n              force = true,\n              onDone = {\n                if (oldSystemPrompt != newSystemPrompt) {\n                  onSystemPromptChanged(newSystemPrompt)\n                }\n              },\n            )\n          }\n\n          // Notify.\n          onConfigChanged(oldConfigValues, model.configValues)\n        }\n      },\n      showSystemPromptEditorTab = allowEditingSystemPrompt,\n      defaultSystemPrompt = task.defaultSystemPrompt,\n      curSystemPrompt = curSystemPrompt,\n    )\n  }\n}\n"
  },
  {
    "path": "Android/src/app/src/main/java/com/google/ai/edge/gallery/ui/common/ModelPicker.kt",
    "content": "/*\n * Copyright 2025 Google LLC\n *\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n *     http://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\n\npackage com.google.ai.edge.gallery.ui.common\n\n// import androidx.compose.ui.tooling.preview.Preview\n// import com.google.ai.edge.gallery.ui.preview.PreviewModelManagerViewModel\n// import com.google.ai.edge.gallery.ui.preview.TASK_TEST1\n// import com.google.ai.edge.gallery.ui.theme.GalleryTheme\n\nimport androidx.compose.foundation.background\nimport androidx.compose.foundation.clickable\nimport androidx.compose.foundation.layout.Arrangement\nimport androidx.compose.foundation.layout.Column\nimport androidx.compose.foundation.layout.Row\nimport androidx.compose.foundation.layout.Spacer\nimport androidx.compose.foundation.layout.fillMaxWidth\nimport androidx.compose.foundation.layout.padding\nimport androidx.compose.foundation.layout.size\nimport androidx.compose.foundation.layout.width\nimport androidx.compose.material.icons.Icons\nimport androidx.compose.material.icons.filled.CheckCircle\nimport androidx.compose.material3.Icon\nimport androidx.compose.material3.MaterialTheme\nimport androidx.compose.material3.Text\nimport androidx.compose.runtime.Composable\nimport androidx.compose.runtime.collectAsState\nimport androidx.compose.runtime.getValue\nimport androidx.compose.runtime.mutableStateOf\nimport androidx.compose.runtime.remember\nimport androidx.compose.runtime.setValue\nimport androidx.compose.ui.Alignment\nimport androidx.compose.ui.Modifier\nimport androidx.compose.ui.graphics.Color\nimport androidx.compose.ui.graphics.vector.ImageVector\nimport androidx.compose.ui.platform.LocalContext\nimport androidx.compose.ui.res.stringResource\nimport androidx.compose.ui.res.vectorResource\nimport androidx.compose.ui.unit.dp\nimport androidx.compose.ui.unit.sp\nimport com.google.ai.edge.gallery.R\nimport com.google.ai.edge.gallery.data.Model\nimport com.google.ai.edge.gallery.data.Task\nimport com.google.ai.edge.gallery.ui.common.modelitem.StatusIcon\nimport com.google.ai.edge.gallery.ui.modelmanager.ModelManagerViewModel\nimport com.google.ai.edge.gallery.ui.theme.labelSmallNarrow\n\n@Composable\nfun ModelPicker(\n  task: Task,\n  modelManagerViewModel: ModelManagerViewModel,\n  onModelSelected: (Model) -> Unit,\n) {\n  val modelManagerUiState by modelManagerViewModel.uiState.collectAsState()\n  var showMemoryWarning by remember { mutableStateOf(false) }\n  var modelToPick by remember { mutableStateOf<Model?>(null) }\n  val context = LocalContext.current\n\n  Column(modifier = Modifier.padding(bottom = 8.dp)) {\n    // Title\n    Row(\n      modifier = Modifier.padding(horizontal = 16.dp).padding(top = 4.dp, bottom = 4.dp),\n      verticalAlignment = Alignment.CenterVertically,\n      horizontalArrangement = Arrangement.spacedBy(8.dp),\n    ) {\n      Icon(\n        task.icon ?: ImageVector.vectorResource(task.iconVectorResourceId!!),\n        tint = getTaskIconColor(task = task),\n        modifier = Modifier.size(16.dp),\n        contentDescription = null,\n      )\n      Text(\n        \"${task.label} models\",\n        modifier = Modifier.fillMaxWidth(),\n        style = MaterialTheme.typography.titleMedium,\n        color = getTaskIconColor(task = task),\n      )\n    }\n\n    // Model list.\n    for (model in task.models) {\n      val selected = model.name == modelManagerUiState.selectedModel.name\n      Row(\n        verticalAlignment = Alignment.CenterVertically,\n        horizontalArrangement = Arrangement.SpaceBetween,\n        modifier =\n          Modifier.fillMaxWidth()\n            .clickable {\n              // Show memory warning before proceeding.\n              if (isMemoryLow(context = context, model = model)) {\n                modelToPick = model\n                showMemoryWarning = true\n              } else {\n                onModelSelected(model)\n              }\n            }\n            .background(\n              if (selected) MaterialTheme.colorScheme.surfaceContainer else Color.Transparent\n            )\n            .padding(horizontal = 16.dp, vertical = 8.dp),\n      ) {\n        Spacer(modifier = Modifier.width(24.dp))\n        Column(modifier = Modifier.weight(1f)) {\n          Text(\n            model.displayName.ifEmpty { model.name },\n            style = MaterialTheme.typography.bodyMedium,\n          )\n          Row(\n            horizontalArrangement = Arrangement.spacedBy(4.dp),\n            verticalAlignment = Alignment.CenterVertically,\n          ) {\n            StatusIcon(\n              task = task,\n              model = model,\n              downloadStatus = modelManagerUiState.modelDownloadStatus[model.name],\n            )\n            Text(\n              if (model.localFileRelativeDirPathOverride.isEmpty())\n                model.sizeInBytes.humanReadableSize()\n              else \"{ext_file_dir}/${model.localFileRelativeDirPathOverride}\",\n              color = MaterialTheme.colorScheme.onSurfaceVariant,\n              style = labelSmallNarrow.copy(lineHeight = 10.sp),\n            )\n          }\n        }\n        if (selected) {\n          Icon(\n            Icons.Filled.CheckCircle,\n            modifier = Modifier.size(16.dp),\n            contentDescription = stringResource(R.string.cd_selected_icon),\n          )\n        }\n      }\n    }\n  }\n\n  if (showMemoryWarning) {\n    MemoryWarningAlert(\n      onProceeded = {\n        val curModelToPick = modelToPick\n        if (curModelToPick != null) {\n          onModelSelected(curModelToPick)\n        }\n        showMemoryWarning = false\n      },\n      onDismissed = { showMemoryWarning = false },\n    )\n  }\n}\n\n// @Preview(showBackground = true)\n// @Composable\n// fun ModelPickerPreview() {\n//   val context = LocalContext.current\n\n//   GalleryTheme {\n//     ModelPicker(\n//       task = TASK_TEST1,\n//       modelManagerViewModel = PreviewModelManagerViewModel(context = context),\n//       onModelSelected = {},\n//     )\n//   }\n// }\n"
  },
  {
    "path": "Android/src/app/src/main/java/com/google/ai/edge/gallery/ui/common/ModelPickerChip.kt",
    "content": "/*\n * Copyright 2025 Google LLC\n *\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n *     http://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\n\npackage com.google.ai.edge.gallery.ui.common\n\nimport androidx.compose.animation.AnimatedVisibility\nimport androidx.compose.animation.fadeIn\nimport androidx.compose.animation.fadeOut\nimport androidx.compose.animation.scaleIn\nimport androidx.compose.animation.scaleOut\nimport androidx.compose.foundation.background\nimport androidx.compose.foundation.clickable\nimport androidx.compose.foundation.layout.Arrangement\nimport androidx.compose.foundation.layout.Box\nimport androidx.compose.foundation.layout.Row\nimport androidx.compose.foundation.layout.fillMaxWidth\nimport androidx.compose.foundation.layout.padding\nimport androidx.compose.foundation.layout.size\nimport androidx.compose.foundation.layout.widthIn\nimport androidx.compose.foundation.shape.CircleShape\nimport androidx.compose.material.icons.Icons\nimport androidx.compose.material.icons.rounded.ArrowDropDown\nimport androidx.compose.material3.CircularProgressIndicator\nimport androidx.compose.material3.ExperimentalMaterial3Api\nimport androidx.compose.material3.Icon\nimport androidx.compose.material3.MaterialTheme\nimport androidx.compose.material3.ModalBottomSheet\nimport androidx.compose.material3.Text\nimport androidx.compose.material3.rememberModalBottomSheetState\nimport androidx.compose.runtime.Composable\nimport androidx.compose.runtime.collectAsState\nimport androidx.compose.runtime.getValue\nimport androidx.compose.runtime.mutableStateOf\nimport androidx.compose.runtime.remember\nimport androidx.compose.runtime.setValue\nimport androidx.compose.ui.Alignment\nimport androidx.compose.ui.Modifier\nimport androidx.compose.ui.draw.alpha\nimport androidx.compose.ui.draw.clip\nimport androidx.compose.ui.graphics.graphicsLayer\nimport androidx.compose.ui.platform.LocalDensity\nimport androidx.compose.ui.platform.LocalWindowInfo\nimport androidx.compose.ui.res.stringResource\nimport androidx.compose.ui.semantics.clearAndSetSemantics\nimport androidx.compose.ui.semantics.contentDescription\nimport androidx.compose.ui.semantics.semantics\nimport androidx.compose.ui.text.style.TextOverflow\nimport androidx.compose.ui.unit.dp\nimport com.google.ai.edge.gallery.R\nimport com.google.ai.edge.gallery.data.Model\nimport com.google.ai.edge.gallery.data.Task\nimport com.google.ai.edge.gallery.ui.common.modelitem.StatusIcon\nimport com.google.ai.edge.gallery.ui.modelmanager.ModelInitializationStatusType\nimport com.google.ai.edge.gallery.ui.modelmanager.ModelManagerViewModel\n\n@OptIn(ExperimentalMaterial3Api::class)\n@Composable\nfun ModelPickerChip(\n  enabled: Boolean,\n  task: Task,\n  initialModel: Model,\n  modelManagerViewModel: ModelManagerViewModel,\n  onModelSelected: (prev: Model, cur: Model) -> Unit,\n) {\n  var showModelPicker by remember { mutableStateOf(false) }\n  var modelPickerModel by remember { mutableStateOf<Model?>(null) }\n  val modelManagerUiState by modelManagerViewModel.uiState.collectAsState()\n  val sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true)\n  val density = LocalDensity.current\n  val windowInfo = LocalWindowInfo.current\n  val screenWidthDp = remember { with(density) { windowInfo.containerSize.width.toDp() } }\n\n  val modelInitializationStatus = modelManagerUiState.modelInitializationStatus[initialModel.name]\n\n  Box(modifier = Modifier.fillMaxWidth(), contentAlignment = Alignment.Center) {\n    Row(\n      verticalAlignment = Alignment.CenterVertically,\n      horizontalArrangement = Arrangement.spacedBy(2.dp),\n    ) {\n      val modelName = initialModel.displayName.ifEmpty { initialModel.name }\n      val cdChangeModel = stringResource(R.string.cd_change_model, modelName)\n      Row(\n        verticalAlignment = Alignment.CenterVertically,\n        horizontalArrangement = Arrangement.spacedBy(2.dp),\n        modifier =\n          Modifier.clip(CircleShape)\n            .background(MaterialTheme.colorScheme.surfaceContainerHigh)\n            .clickable(enabled = enabled) {\n              modelPickerModel = initialModel\n              showModelPicker = true\n            }\n            .padding(start = 8.dp, end = 2.dp)\n            .padding(vertical = 4.dp)\n            .graphicsLayer { alpha = if (enabled) 1f else 0.6f }\n            .semantics { contentDescription = cdChangeModel },\n      ) Inner@{\n        Box(contentAlignment = Alignment.Center, modifier = Modifier.size(21.dp)) {\n          StatusIcon(\n            task = task,\n            model = initialModel,\n            downloadStatus = modelManagerUiState.modelDownloadStatus[initialModel.name],\n          )\n          this@Inner.AnimatedVisibility(\n            visible =\n              modelInitializationStatus?.status == ModelInitializationStatusType.INITIALIZING,\n            enter = scaleIn() + fadeIn(),\n            exit = scaleOut() + fadeOut(),\n          ) {\n            // Circular progress indicator.\n            CircularProgressIndicator(\n              modifier = Modifier.size(24.dp).alpha(0.5f),\n              strokeWidth = 2.dp,\n              color = MaterialTheme.colorScheme.onSurfaceVariant,\n            )\n          }\n        }\n        Text(\n          modelName,\n          style = MaterialTheme.typography.labelLarge,\n          modifier =\n            Modifier.padding(start = 4.dp)\n              .widthIn(0.dp, screenWidthDp - 250.dp)\n              .clearAndSetSemantics {},\n          maxLines = 1,\n          overflow = TextOverflow.MiddleEllipsis,\n        )\n        Icon(\n          Icons.Rounded.ArrowDropDown,\n          modifier = Modifier.size(20.dp),\n          contentDescription = null,\n        )\n      }\n    }\n  }\n\n  // Model picker.\n  val curModelPickerModel = modelPickerModel\n  if (showModelPicker && curModelPickerModel != null) {\n    ModalBottomSheet(onDismissRequest = { showModelPicker = false }, sheetState = sheetState) {\n      ModelPicker(\n        task = task,\n        modelManagerViewModel = modelManagerViewModel,\n        onModelSelected = { selectedModel ->\n          showModelPicker = false\n          val prevSelectedModel = modelManagerUiState.selectedModel\n          onModelSelected(prevSelectedModel, selectedModel)\n        },\n      )\n    }\n  }\n}\n"
  },
  {
    "path": "Android/src/app/src/main/java/com/google/ai/edge/gallery/ui/common/RotationalLoader.kt",
    "content": "/*\n * Copyright 2025 Google LLC\n *\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n *     http://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\n\npackage com.google.ai.edge.gallery.ui.common\n\nimport androidx.compose.animation.core.CubicBezierEasing\nimport androidx.compose.animation.core.EaseInOut\nimport androidx.compose.animation.core.RepeatMode\nimport androidx.compose.animation.core.animateFloat\nimport androidx.compose.animation.core.infiniteRepeatable\nimport androidx.compose.animation.core.rememberInfiniteTransition\nimport androidx.compose.animation.core.tween\nimport androidx.compose.foundation.Image\nimport androidx.compose.foundation.layout.Arrangement\nimport androidx.compose.foundation.layout.Box\nimport androidx.compose.foundation.layout.size\nimport androidx.compose.foundation.lazy.grid.GridCells\nimport androidx.compose.foundation.lazy.grid.LazyVerticalGrid\nimport androidx.compose.foundation.lazy.grid.itemsIndexed\nimport androidx.compose.material3.MaterialTheme\nimport androidx.compose.runtime.Composable\nimport androidx.compose.runtime.getValue\nimport androidx.compose.ui.Alignment\nimport androidx.compose.ui.Modifier\nimport androidx.compose.ui.draw.drawWithContent\nimport androidx.compose.ui.graphics.BlendMode\nimport androidx.compose.ui.graphics.Brush.Companion.linearGradient\nimport androidx.compose.ui.graphics.graphicsLayer\nimport androidx.compose.ui.layout.ContentScale\nimport androidx.compose.ui.res.painterResource\nimport androidx.compose.ui.semantics.clearAndSetSemantics\nimport androidx.compose.ui.unit.Dp\nimport com.google.ai.edge.gallery.R\nimport com.google.ai.edge.gallery.ui.theme.customColors\n\nprivate const val GRID_SPACING_FACTOR = 0.1f\nprivate const val ICON_SIZE_FACTOR = 0.3f\n\n/**\n * A composable that displays a rotational and scaling animated loader, structured as a 2x2 grid.\n *\n * This loader uses two concurrent infinite animations:\n * 1. **Outer Rotation (rotationZ):** Continuously rotates the entire [LazyVerticalGrid] container\n *    using a custom [CubicBezierEasing] for a distinct non-linear rotation speed.\n * 2. **Inner Scale (scaleX, scaleY):** Cycles the scale of the individual grid items between 1.0\n *    and 0.4 using [EaseInOut] easing for a smooth pulsing/breathing effect.\n */\n@Composable\nfun RotationalLoader(size: Dp) {\n  val infiniteTransition = rememberInfiniteTransition(label = \"infinite\")\n  val rotationProgress by\n    infiniteTransition.animateFloat(\n      initialValue = 0f,\n      targetValue = 1f,\n      animationSpec =\n        infiniteRepeatable(\n          animation = tween(2000, easing = CubicBezierEasing(0.5f, 0.16f, 0f, 0.71f)),\n          repeatMode = RepeatMode.Restart,\n        ),\n    )\n  val scaleProgress by\n    infiniteTransition.animateFloat(\n      initialValue = 1f,\n      targetValue = 0.4f,\n      animationSpec =\n        infiniteRepeatable(\n          animation = tween(1000, easing = EaseInOut),\n          repeatMode = RepeatMode.Reverse,\n        ),\n    )\n  val curRotationZ = 45f + rotationProgress * 360f\n  val curScale = scaleProgress\n\n  val gridSpacing = size * GRID_SPACING_FACTOR\n  LazyVerticalGrid(\n    columns = GridCells.Fixed(2),\n    horizontalArrangement = Arrangement.spacedBy(gridSpacing),\n    verticalArrangement = Arrangement.spacedBy(gridSpacing),\n    modifier =\n      Modifier.size(size).graphicsLayer { rotationZ = curRotationZ }.clearAndSetSemantics {},\n  ) {\n    itemsIndexed(\n      listOf(\n        R.drawable.four_circle,\n        R.drawable.circle,\n        R.drawable.double_circle,\n        R.drawable.pantegon,\n      )\n    ) { index, imageResource ->\n      Box(\n        modifier = Modifier.size((size - gridSpacing) / 2),\n        contentAlignment =\n          when (index) {\n            0 -> Alignment.BottomEnd\n            1 -> Alignment.BottomStart\n            2 -> Alignment.TopEnd\n            3 -> Alignment.TopStart\n            else -> Alignment.Center\n          },\n      ) {\n        val colorIndex =\n          when (index) {\n            0 -> 2\n            1 -> 1\n            2 -> 0\n            else -> 3\n          }\n        val brush =\n          linearGradient(colors = MaterialTheme.customColors.taskBgGradientColors[colorIndex])\n        Image(\n          painter = painterResource(id = imageResource),\n          contentDescription = null,\n          modifier =\n            Modifier.size(size * ICON_SIZE_FACTOR)\n              .graphicsLayer {\n                // This is important to make blending mode work.\n                alpha = 0.99f\n                rotationZ = -curRotationZ\n                scaleX = curScale\n                scaleY = curScale\n              }\n              .drawWithContent {\n                drawContent()\n                drawRect(brush = brush, blendMode = BlendMode.SrcIn)\n              },\n          contentScale = ContentScale.Fit,\n        )\n      }\n    }\n  }\n}\n"
  },
  {
    "path": "Android/src/app/src/main/java/com/google/ai/edge/gallery/ui/common/TaskIcon.kt",
    "content": "/*\n * Copyright 2025 Google LLC\n *\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n *     http://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\n\npackage com.google.ai.edge.gallery.ui.common\n\nimport androidx.compose.foundation.Image\nimport androidx.compose.foundation.layout.Box\nimport androidx.compose.foundation.layout.aspectRatio\nimport androidx.compose.foundation.layout.fillMaxSize\nimport androidx.compose.foundation.layout.size\nimport androidx.compose.foundation.layout.width\nimport androidx.compose.material3.Icon\nimport androidx.compose.runtime.Composable\nimport androidx.compose.ui.Alignment\nimport androidx.compose.ui.Modifier\nimport androidx.compose.ui.draw.drawWithContent\nimport androidx.compose.ui.draw.scale\nimport androidx.compose.ui.graphics.BlendMode\nimport androidx.compose.ui.graphics.Brush.Companion.linearGradient\nimport androidx.compose.ui.graphics.Color\nimport androidx.compose.ui.graphics.CompositingStrategy\nimport androidx.compose.ui.graphics.graphicsLayer\nimport androidx.compose.ui.graphics.painter.Painter\nimport androidx.compose.ui.graphics.vector.ImageVector\nimport androidx.compose.ui.layout.ContentScale\nimport androidx.compose.ui.res.painterResource\nimport androidx.compose.ui.res.vectorResource\nimport androidx.compose.ui.unit.Dp\nimport androidx.compose.ui.unit.dp\nimport com.google.ai.edge.gallery.R\nimport com.google.ai.edge.gallery.data.Task\n\nprivate val SHAPES: List<Int> =\n  listOf(\n    // Ask image.\n    R.drawable.circle,\n    // Audio scribe\n    R.drawable.double_circle,\n    // Prompt lab\n    R.drawable.pantegon,\n    // AI chat,\n    R.drawable.four_circle,\n  )\n\n/**\n * Composable that displays an icon representing a task. It consists of a background image and a\n * foreground icon, both centered within a square box.\n */\n@Composable\nfun TaskIcon(\n  task: Task,\n  modifier: Modifier = Modifier,\n  width: Dp = 56.dp,\n  animationProgress: Float = 1f,\n) {\n  val revealingBrush =\n    linearGradient(\n      colorStops =\n        arrayOf(\n          (1f + 0.2f) * (1 - animationProgress) - 0.2f to Color.Red,\n          (1f + 0.2f) * (1 - animationProgress) to Color.Transparent,\n        )\n    )\n  Box(modifier = modifier.width(width).aspectRatio(1f), contentAlignment = Alignment.Center) {\n    val brush = linearGradient(colors = getTaskBgGradientColors(task = task))\n    Image(\n      painter = getTaskIconBgShape(task = task),\n      contentDescription = null,\n      modifier =\n        Modifier.fillMaxSize()\n          .graphicsLayer(\n            // This is important to make blending mode work.\n            alpha = 0.99f,\n            compositingStrategy = CompositingStrategy.Offscreen,\n            translationX = 80 * (1 - animationProgress),\n            rotationZ = -180 * (1 - animationProgress),\n          )\n          .drawWithContent {\n            drawContent()\n            drawRect(brush = brush, blendMode = BlendMode.SrcIn)\n            drawRect(brush = revealingBrush, blendMode = BlendMode.DstOut)\n          },\n      contentScale = ContentScale.FillHeight,\n    )\n    var iconAnimationProgress = 0f\n    if (animationProgress >= 0.8) {\n      iconAnimationProgress = (animationProgress - 0.8f) / 0.2f\n    }\n    Icon(\n      task.icon ?: ImageVector.vectorResource(task.iconVectorResourceId!!),\n      tint = Color.White,\n      modifier =\n        Modifier.size(width * 0.55f)\n          .graphicsLayer { alpha = iconAnimationProgress }\n          .scale(iconAnimationProgress),\n      contentDescription = null,\n    )\n  }\n}\n\n@Composable\nprivate fun getTaskIconBgShape(task: Task): Painter {\n  val colorIndex: Int = task.index % SHAPES.size\n  return painterResource(SHAPES[colorIndex])\n}\n\n// @Preview(showBackground = true)\n// @Composable\n// fun TaskIconPreview() {\n//   for ((index, task) in TASKS.withIndex()) {\n//     task.index = index\n//   }\n//\n//   GalleryTheme {\n//     Column(modifier = Modifier.background(Color.Gray)) {\n//       TaskIcon(task = TASK_LLM_CHAT, width = 80.dp)\n//     }\n//   }\n// }\n"
  },
  {
    "path": "Android/src/app/src/main/java/com/google/ai/edge/gallery/ui/common/Utils.kt",
    "content": "/*\n * Copyright 2025 Google LLC\n *\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n *     http://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\n\npackage com.google.ai.edge.gallery.ui.common\n\nimport android.Manifest\nimport android.content.Context\nimport android.content.pm.PackageManager\nimport android.net.Uri\nimport android.os.Build\nimport androidx.activity.compose.ManagedActivityResultLauncher\nimport androidx.compose.animation.core.Easing\nimport androidx.compose.animation.core.FastOutSlowInEasing\nimport androidx.compose.animation.core.LinearEasing\nimport androidx.compose.animation.core.animateFloatAsState\nimport androidx.compose.animation.core.tween\nimport androidx.compose.foundation.layout.Box\nimport androidx.compose.foundation.layout.PaddingValues\nimport androidx.compose.foundation.layout.padding\nimport androidx.compose.material3.Text\nimport androidx.compose.runtime.Composable\nimport androidx.compose.runtime.LaunchedEffect\nimport androidx.compose.runtime.getValue\nimport androidx.compose.runtime.mutableStateOf\nimport androidx.compose.runtime.remember\nimport androidx.compose.runtime.setValue\nimport androidx.compose.ui.Alignment\nimport androidx.compose.ui.Modifier\nimport androidx.compose.ui.draw.drawWithContent\nimport androidx.compose.ui.graphics.BlendMode\nimport androidx.compose.ui.graphics.Brush.Companion.linearGradient\nimport androidx.compose.ui.graphics.Color\nimport androidx.compose.ui.graphics.CompositingStrategy\nimport androidx.compose.ui.graphics.graphicsLayer\nimport androidx.compose.ui.text.TextStyle\nimport androidx.compose.ui.text.style.TextAlign\nimport androidx.compose.ui.unit.Dp\nimport androidx.compose.ui.unit.dp\nimport androidx.core.content.ContextCompat\nimport androidx.core.content.FileProvider\nimport com.google.ai.edge.gallery.data.Model\nimport com.google.ai.edge.gallery.data.Task\nimport com.google.ai.edge.gallery.ui.modelmanager.ModelManagerViewModel\nimport java.io.File\nimport kotlin.math.ln\nimport kotlin.math.pow\nimport kotlinx.coroutines.delay\n\nprivate const val TAG = \"AGUtils\"\n\nval SMALL_BUTTON_CONTENT_PADDING =\n  PaddingValues(start = 16.dp, top = 8.dp, end = 16.dp, bottom = 8.dp)\n\n/** Format the bytes into a human-readable format. */\nfun Long.humanReadableSize(si: Boolean = true, extraDecimalForGbAndAbove: Boolean = false): String {\n  val bytes = this\n\n  val unit = if (si) 1000 else 1024\n  if (bytes < unit) return \"$bytes B\"\n  val exp = (ln(bytes.toDouble()) / ln(unit.toDouble())).toInt()\n  val pre = (if (si) \"kMGTPE\" else \"KMGTPE\")[exp - 1] + if (si) \"\" else \"i\"\n  var formatString = \"%.1f %sB\"\n  if (extraDecimalForGbAndAbove && pre.lowercase() != \"k\" && pre != \"M\") {\n    formatString = \"%.2f %sB\"\n  }\n  return formatString.format(bytes / unit.toDouble().pow(exp.toDouble()), pre)\n}\n\nfun Float.humanReadableDuration(): String {\n  val milliseconds = this\n  if (milliseconds < 1000) {\n    return \"$milliseconds ms\"\n  }\n  val seconds = milliseconds / 1000f\n  if (seconds < 60) {\n    return \"%.1f s\".format(seconds)\n  }\n\n  val minutes = seconds / 60f\n  if (minutes < 60) {\n    return \"%.1f min\".format(minutes)\n  }\n\n  val hours = minutes / 60f\n  return \"%.1f h\".format(hours)\n}\n\nfun Long.formatToHourMinSecond(): String {\n  val ms = this\n  if (ms < 0) {\n    return \"-\"\n  }\n\n  val seconds = ms / 1000\n  val hours = seconds / 3600\n  val minutes = (seconds % 3600) / 60\n  val remainingSeconds = seconds % 60\n\n  val parts = mutableListOf<String>()\n\n  if (hours > 0) {\n    parts.add(\"$hours h\")\n  }\n  if (minutes > 0) {\n    parts.add(\"$minutes min\")\n  }\n  if (remainingSeconds > 0 || (hours == 0L && minutes == 0L)) {\n    parts.add(\"$remainingSeconds sec\")\n  }\n\n  return parts.joinToString(\" \")\n}\n\nfun getDistinctiveColor(index: Int): Color {\n  val colors =\n    listOf(\n      //      Color(0xffe6194b),\n      Color(0xff3cb44b),\n      Color(0xffffe119),\n      Color(0xff4363d8),\n      Color(0xfff58231),\n      Color(0xff911eb4),\n      Color(0xff46f0f0),\n      Color(0xfff032e6),\n      Color(0xffbcf60c),\n      Color(0xfffabebe),\n      Color(0xff008080),\n      Color(0xffe6beff),\n      Color(0xff9a6324),\n      Color(0xfffffac8),\n      Color(0xff800000),\n      Color(0xffaaffc3),\n      Color(0xff808000),\n      Color(0xffffd8b1),\n      Color(0xff000075),\n    )\n  return colors[index % colors.size]\n}\n\nfun Context.createTempPictureUri(\n  fileName: String = \"picture_${System.currentTimeMillis()}\",\n  fileExtension: String = \".png\",\n): Uri {\n  val tempFile = File.createTempFile(fileName, fileExtension, cacheDir).apply { createNewFile() }\n\n  return FileProvider.getUriForFile(\n    applicationContext,\n    \"com.google.ai.edge.gallery.provider\" /* {applicationId}.provider */,\n    tempFile,\n  )\n}\n\nfun checkNotificationPermissionAndStartDownload(\n  context: Context,\n  launcher: ManagedActivityResultLauncher<String, Boolean>,\n  modelManagerViewModel: ModelManagerViewModel,\n  task: Task?,\n  model: Model,\n) {\n  // Check permission\n  when (PackageManager.PERMISSION_GRANTED) {\n    // Already got permission. Call the lambda.\n    ContextCompat.checkSelfPermission(context, Manifest.permission.POST_NOTIFICATIONS) -> {\n      modelManagerViewModel.downloadModel(task = task, model = model)\n    }\n\n    // Otherwise, ask for permission\n    else -> {\n      if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {\n        launcher.launch(Manifest.permission.POST_NOTIFICATIONS)\n      }\n    }\n  }\n}\n\nfun ensureValidFileName(fileName: String): String {\n  return fileName.replace(Regex(\"[^a-zA-Z0-9._-]\"), \"_\")\n}\n\n/**\n * A composable that animates text appearing to \"swipe\" into view from left to right.\n *\n * This effect is created by animating a linear gradient brush that colors the text, combined with\n * an alpha animation for fading. The text gradually becomes visible as the gradient moves across\n * it, revealing the full text by the end of the animation.\n */\n@Composable\nfun SwipingText(\n  text: String,\n  style: TextStyle,\n  color: Color,\n  modifier: Modifier = Modifier,\n  animationDelay: Long = 0,\n  animationDurationMs: Int = 300,\n  edgeGradientRelativeSize: Float = 1.0f,\n) {\n  val progress =\n    rememberDelayedAnimationProgress(\n      initialDelay = animationDelay,\n      animationDurationMs = animationDurationMs,\n      animationLabel = \"swiping text\",\n      easing = LinearEasing,\n    )\n  Text(\n    text,\n    style =\n      style.copy(\n        brush =\n          linearGradient(\n            colorStops =\n              arrayOf(\n                (1f + edgeGradientRelativeSize) * progress - edgeGradientRelativeSize to color,\n                (1f + edgeGradientRelativeSize) * progress to Color.Transparent,\n              )\n          )\n      ),\n    modifier = modifier.graphicsLayer { alpha = progress },\n  )\n}\n\n/**\n * A composable that animates the revelation of text using a linear gradient mask.\n *\n * The text appears to \"wipe\" into view from left to right, controlled by an animation progress.\n * This is achieved by drawing a gradient mask over the text that moves horizontally, revealing the\n * content as the animation progresses.\n *\n * The core of the revelation effect relies on `BlendMode.DstOut`. First, the text content\n * (`drawContent()`) is rendered as the \"destination.\" Then, a rectangle filled with a `maskBrush`\n * (our linear gradient) is drawn as the \"source.\" `DstOut` works by taking the destination (the\n * text) and making transparent any parts that overlap with the opaque (non-transparent) regions of\n * the source (the red part of our mask). As the `maskBrush` animates and slides across the text,\n * the transparent portion of the mask \"reveals\" the text, creating the wipe-in effect.\n */\n@Composable\nfun RevealingText(\n  text: String,\n  style: TextStyle,\n  modifier: Modifier = Modifier,\n  animationDelay: Long = 0,\n  animationDurationMs: Int = 300,\n  edgeGradientRelativeSize: Float = 0.5f,\n  extraTextPadding: Dp = 16.dp,\n) {\n  val progress =\n    rememberDelayedAnimationProgress(\n      initialDelay = animationDelay,\n      animationDurationMs = animationDurationMs,\n      animationLabel = \"revealing text\",\n    )\n  val maskBrush =\n    linearGradient(\n      colorStops =\n        arrayOf(\n          (1f + edgeGradientRelativeSize) * progress - edgeGradientRelativeSize to\n            Color.Transparent,\n          (1f + edgeGradientRelativeSize) * progress to Color.Red,\n        )\n    )\n  Box(\n    modifier =\n      modifier\n        .graphicsLayer(alpha = 0.99f, compositingStrategy = CompositingStrategy.Offscreen)\n        .drawWithContent {\n          drawContent()\n          drawRect(brush = maskBrush, blendMode = BlendMode.DstOut)\n        },\n    contentAlignment = Alignment.Center,\n  ) {\n    Text(text, style = style, modifier = Modifier.padding(horizontal = extraTextPadding))\n  }\n}\n\n/** Another version of RevealingText with animationProgress passed in. */\n@Composable\nfun RevealingText(\n  text: String,\n  style: TextStyle,\n  animationProgress: Float,\n  modifier: Modifier = Modifier,\n  textAlign: TextAlign? = null,\n  edgeGradientRelativeSize: Float = 0.5f,\n) {\n  val maskBrush =\n    linearGradient(\n      colorStops =\n        arrayOf(\n          (1f + edgeGradientRelativeSize) * animationProgress - edgeGradientRelativeSize to\n            Color.Transparent,\n          (1f + edgeGradientRelativeSize) * animationProgress to Color.Red,\n        )\n    )\n  Box(\n    modifier =\n      modifier\n        .graphicsLayer(alpha = 0.99f, compositingStrategy = CompositingStrategy.Offscreen)\n        .drawWithContent {\n          drawContent()\n          drawRect(brush = maskBrush, blendMode = BlendMode.DstOut)\n        },\n    contentAlignment = Alignment.Center,\n  ) {\n    Text(\n      text,\n      style = style,\n      modifier = modifier.padding(horizontal = 16.dp),\n      textAlign = textAlign,\n    )\n  }\n}\n\n/**\n * A reusable Composable function that provides an animated float progress value after an initial\n * delay.\n *\n * This function is ideal for creating \"enter\" animations that start after a specified pause,\n * allowing for staggered or timed visual effects. It uses `animateFloatAsState` to smoothly\n * transition the progress from 0f to 1f.\n */\n@Composable\nfun rememberDelayedAnimationProgress(\n  initialDelay: Long = 0,\n  animationDurationMs: Int,\n  animationLabel: String,\n  easing: Easing = FastOutSlowInEasing,\n): Float {\n  var startAnimation by remember { mutableStateOf(false) }\n  val progress: Float by\n    animateFloatAsState(\n      if (startAnimation) 1f else 0f,\n      label = animationLabel,\n      animationSpec = tween(durationMillis = animationDurationMs, easing = easing),\n    )\n  LaunchedEffect(Unit) {\n    delay(initialDelay)\n    startAnimation = true\n  }\n  return progress\n}\n"
  },
  {
    "path": "Android/src/app/src/main/java/com/google/ai/edge/gallery/ui/common/chat/AudioPlaybackPanel.kt",
    "content": "/*\n * Copyright 2025 Google LLC\n *\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n *     http://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\n\npackage com.google.ai.edge.gallery.ui.common.chat\n\nimport android.media.AudioAttributes\nimport android.media.AudioFormat\nimport android.media.AudioTrack\nimport android.util.Log\nimport androidx.compose.foundation.Canvas\nimport androidx.compose.foundation.layout.Row\nimport androidx.compose.foundation.layout.height\nimport androidx.compose.foundation.layout.padding\nimport androidx.compose.foundation.layout.width\nimport androidx.compose.material.icons.Icons\nimport androidx.compose.material.icons.rounded.PlayArrow\nimport androidx.compose.material.icons.rounded.Stop\nimport androidx.compose.material3.Icon\nimport androidx.compose.material3.IconButton\nimport androidx.compose.material3.MaterialTheme\nimport androidx.compose.material3.Text\nimport androidx.compose.runtime.Composable\nimport androidx.compose.runtime.DisposableEffect\nimport androidx.compose.runtime.LaunchedEffect\nimport androidx.compose.runtime.MutableState\nimport androidx.compose.runtime.getValue\nimport androidx.compose.runtime.mutableFloatStateOf\nimport androidx.compose.runtime.mutableStateOf\nimport androidx.compose.runtime.remember\nimport androidx.compose.runtime.rememberCoroutineScope\nimport androidx.compose.runtime.setValue\nimport androidx.compose.ui.Alignment\nimport androidx.compose.ui.Modifier\nimport androidx.compose.ui.geometry.CornerRadius\nimport androidx.compose.ui.geometry.Offset\nimport androidx.compose.ui.geometry.Size\nimport androidx.compose.ui.geometry.toRect\nimport androidx.compose.ui.graphics.BlendMode\nimport androidx.compose.ui.graphics.Color\nimport androidx.compose.ui.graphics.drawscope.drawIntoCanvas\nimport androidx.compose.ui.res.stringResource\nimport androidx.compose.ui.unit.dp\nimport com.google.ai.edge.gallery.R\nimport com.google.ai.edge.gallery.data.MAX_AUDIO_CLIP_DURATION_SEC\nimport com.google.ai.edge.gallery.ui.theme.customColors\nimport java.nio.ByteBuffer\nimport java.nio.ByteOrder\nimport kotlinx.coroutines.Dispatchers\nimport kotlinx.coroutines.isActive\nimport kotlinx.coroutines.launch\nimport kotlinx.coroutines.withContext\n\nprivate const val TAG = \"AGAudioPlaybackPanel\"\nprivate const val BAR_SPACE = 2\nprivate const val BAR_WIDTH = 2\nprivate const val MIN_BAR_COUNT = 16\nprivate const val MAX_BAR_COUNT = 48\n\n/**\n * A Composable that displays an audio playback panel, including play/stop controls, a waveform\n * visualization, and the duration of the audio clip.\n */\n@Composable\nfun AudioPlaybackPanel(\n  audioData: ByteArray,\n  sampleRate: Int,\n  isRecording: Boolean,\n  modifier: Modifier = Modifier,\n  onDarkBg: Boolean = false,\n) {\n  val coroutineScope = rememberCoroutineScope()\n  var isPlaying by remember { mutableStateOf(false) }\n  val audioTrackState = remember { mutableStateOf<AudioTrack?>(null) }\n  val durationInSeconds =\n    remember(audioData) {\n      // PCM 16-bit\n      val bytesPerSample = 2\n      val bytesPerFrame = bytesPerSample * 1 // mono\n      val totalFrames = audioData.size.toDouble() / bytesPerFrame\n      totalFrames / sampleRate\n    }\n  val barCount =\n    remember(durationInSeconds) {\n      val f = durationInSeconds / MAX_AUDIO_CLIP_DURATION_SEC\n      ((MAX_BAR_COUNT - MIN_BAR_COUNT) * f + MIN_BAR_COUNT).toInt()\n    }\n  val amplitudeLevels =\n    remember(audioData) { generateAmplitudeLevels(audioData = audioData, barCount = barCount) }\n  var playbackProgress by remember { mutableFloatStateOf(0f) }\n\n  // Reset when a new recording is started.\n  LaunchedEffect(isRecording) {\n    if (isRecording) {\n      val audioTrack = audioTrackState.value\n      audioTrack?.stop()\n      isPlaying = false\n      playbackProgress = 0f\n    }\n  }\n\n  // Cleanup on Composable Disposal.\n  DisposableEffect(Unit) {\n    onDispose {\n      val audioTrack = audioTrackState.value\n      audioTrack?.stop()\n      audioTrack?.release()\n    }\n  }\n\n  Row(verticalAlignment = Alignment.CenterVertically, modifier = modifier) {\n    // Button to play/stop the clip.\n    IconButton(\n      onClick = {\n        coroutineScope.launch {\n          if (!isPlaying) {\n            isPlaying = true\n            playAudio(\n              audioTrackState = audioTrackState,\n              audioData = audioData,\n              sampleRate = sampleRate,\n              onProgress = { playbackProgress = it },\n              onCompletion = {\n                playbackProgress = 0f\n                isPlaying = false\n              },\n            )\n          } else {\n            stopPlayAudio(audioTrackState = audioTrackState)\n            playbackProgress = 0f\n            isPlaying = false\n          }\n        }\n      }\n    ) {\n      Icon(\n        if (isPlaying) Icons.Rounded.Stop else Icons.Rounded.PlayArrow,\n        contentDescription =\n          stringResource(\n            if (isPlaying) R.string.cd_stop_playback_icon else R.string.cd_play_audio_icon\n          ),\n        tint = if (onDarkBg) Color.White else MaterialTheme.colorScheme.primary,\n      )\n    }\n\n    // Visualization\n    AmplitudeBarGraph(\n      amplitudeLevels = amplitudeLevels,\n      progress = playbackProgress,\n      modifier =\n        Modifier.width((barCount * BAR_WIDTH + (barCount - 1) * BAR_SPACE).dp).height(24.dp),\n      onDarkBg = onDarkBg,\n    )\n\n    // Duration\n    Text(\n      \"${\"%.1f\".format(durationInSeconds)}s\",\n      style = MaterialTheme.typography.labelLarge,\n      color = if (onDarkBg) Color.White else MaterialTheme.colorScheme.onSurfaceVariant,\n      modifier = Modifier.padding(start = 12.dp),\n    )\n  }\n}\n\n@Composable\nprivate fun AmplitudeBarGraph(\n  amplitudeLevels: List<Float>,\n  progress: Float,\n  modifier: Modifier = Modifier,\n  onDarkBg: Boolean = false,\n) {\n  val barColor = MaterialTheme.customColors.waveFormBgColor\n  val progressColor = if (onDarkBg) Color.White else MaterialTheme.colorScheme.primary\n\n  Canvas(modifier = modifier) {\n    val barCount = amplitudeLevels.size\n    val barWidth = (size.width - BAR_SPACE.dp.toPx() * (barCount - 1)) / barCount\n    val cornerRadius = CornerRadius(x = barWidth, y = barWidth)\n\n    // Use drawIntoCanvas for advanced blend mode operations\n    drawIntoCanvas { canvas ->\n\n      // 1. Save the current state of the canvas onto a temporary, offscreen layer\n      canvas.saveLayer(size.toRect(), androidx.compose.ui.graphics.Paint())\n\n      // 2. Draw the bars in grey.\n      amplitudeLevels.forEachIndexed { index, level ->\n        val barHeight = (level * size.height).coerceAtLeast(1.5f)\n        val left = index * (barWidth + BAR_SPACE.dp.toPx())\n        drawRoundRect(\n          color = barColor,\n          topLeft = Offset(x = left, y = size.height / 2 - barHeight / 2),\n          size = Size(barWidth, barHeight),\n          cornerRadius = cornerRadius,\n        )\n      }\n\n      // 3. Draw the progress rectangle using BlendMode.SrcIn to only draw where the bars already\n      // exists.\n      val progressWidth = size.width * progress\n      drawRect(\n        color = progressColor,\n        topLeft = Offset.Zero,\n        size = Size(progressWidth, size.height),\n        blendMode = BlendMode.SrcIn,\n      )\n\n      // 4. Restore the layer, merging it onto the main canvas\n      canvas.restore()\n    }\n  }\n}\n\nprivate suspend fun playAudio(\n  audioTrackState: MutableState<AudioTrack?>,\n  audioData: ByteArray,\n  sampleRate: Int,\n  onProgress: (Float) -> Unit,\n  onCompletion: () -> Unit,\n) {\n  Log.d(TAG, \"Start playing audio...\")\n\n  try {\n    withContext(Dispatchers.IO) {\n      var lastProgressUpdateMs = 0L\n      audioTrackState.value?.release()\n      val audioTrack =\n        AudioTrack.Builder()\n          .setAudioAttributes(\n            AudioAttributes.Builder()\n              .setContentType(AudioAttributes.CONTENT_TYPE_SPEECH)\n              .setUsage(AudioAttributes.USAGE_MEDIA)\n              .build()\n          )\n          .setAudioFormat(\n            AudioFormat.Builder()\n              .setEncoding(AudioFormat.ENCODING_PCM_16BIT)\n              .setSampleRate(sampleRate)\n              .setChannelMask(AudioFormat.CHANNEL_OUT_MONO)\n              .build()\n          )\n          .setTransferMode(AudioTrack.MODE_STATIC)\n          .setBufferSizeInBytes(audioData.size)\n          .build()\n\n      val bytesPerFrame = 2 // For PCM 16-bit Mono\n      val totalFrames = audioData.size / bytesPerFrame\n\n      audioTrackState.value = audioTrack\n      audioTrack.write(audioData, 0, audioData.size)\n      audioTrack.play()\n\n      // Coroutine to monitor progress\n      while (isActive && audioTrack.playState == AudioTrack.PLAYSTATE_PLAYING) {\n        val currentFrames = audioTrack.playbackHeadPosition\n        if (currentFrames >= totalFrames) {\n          break // Exit loop when playback is done\n        }\n        val progress = currentFrames.toFloat() / totalFrames\n        val curMs = System.currentTimeMillis()\n        if (curMs - lastProgressUpdateMs > 30) {\n          onProgress(progress)\n          lastProgressUpdateMs = curMs\n        }\n      }\n\n      if (isActive) {\n        audioTrackState.value?.stop()\n      }\n    }\n  } catch (e: Exception) {\n    // Ignore\n  } finally {\n    onProgress(1f)\n    onCompletion()\n  }\n}\n\nprivate fun stopPlayAudio(audioTrackState: MutableState<AudioTrack?>) {\n  Log.d(TAG, \"Stopping playing audio...\")\n\n  val audioTrack = audioTrackState.value\n  audioTrack?.stop()\n  audioTrack?.release()\n  audioTrackState.value = null\n}\n\n/**\n * Processes a raw PCM 16-bit audio byte array to generate a list of normalized amplitude levels for\n * visualization.\n */\nprivate fun generateAmplitudeLevels(audioData: ByteArray, barCount: Int): List<Float> {\n  if (audioData.isEmpty()) {\n    return List(barCount) { 0f }\n  }\n\n  // 1. Parse bytes into 16-bit short samples (PCM 16-bit)\n  val shortBuffer = ByteBuffer.wrap(audioData).order(ByteOrder.LITTLE_ENDIAN).asShortBuffer()\n  val samples = ShortArray(shortBuffer.remaining())\n  shortBuffer.get(samples)\n\n  if (samples.isEmpty()) {\n    return List(barCount) { 0f }\n  }\n\n  // 2. Determine the size of each chunk\n  val chunkSize = samples.size / barCount\n  val amplitudeLevels = mutableListOf<Float>()\n\n  // 3. Get the max value for each chunk\n  for (i in 0 until barCount) {\n    val chunkStart = i * chunkSize\n    val chunkEnd = (chunkStart + chunkSize).coerceAtMost(samples.size)\n\n    var maxAmplitudeInChunk = 0.0\n\n    for (j in chunkStart until chunkEnd) {\n      val sampleAbs = kotlin.math.abs(samples[j].toDouble())\n      if (sampleAbs > maxAmplitudeInChunk) {\n        maxAmplitudeInChunk = sampleAbs\n      }\n    }\n\n    // 4. Normalize the value (0 to 1)\n    // Short.MAX_VALUE is 32767.0, a good reference for max amplitude\n    val normalizedRms = (maxAmplitudeInChunk / Short.MAX_VALUE).toFloat().coerceIn(0f, 1f)\n    amplitudeLevels.add(normalizedRms)\n  }\n\n  // Normalize the resulting levels so that the max value becomes 0.9.\n  val maxVal = amplitudeLevels.max()\n  if (maxVal == 0f) {\n    return amplitudeLevels\n  }\n  val scaleFactor = 0.9f / maxVal\n  return amplitudeLevels.map { it * scaleFactor }\n}\n"
  },
  {
    "path": "Android/src/app/src/main/java/com/google/ai/edge/gallery/ui/common/chat/AudioRecorderPanel.kt",
    "content": "/*\n * Copyright 2025 Google LLC\n *\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n *     http://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\n\npackage com.google.ai.edge.gallery.ui.common.chat\n\nimport android.annotation.SuppressLint\nimport android.content.Context\nimport android.media.AudioFormat\nimport android.media.AudioRecord\nimport android.media.MediaRecorder\nimport android.util.Log\nimport androidx.compose.foundation.background\nimport androidx.compose.foundation.layout.Arrangement\nimport androidx.compose.foundation.layout.Box\nimport androidx.compose.foundation.layout.Row\nimport androidx.compose.foundation.layout.fillMaxWidth\nimport androidx.compose.foundation.layout.padding\nimport androidx.compose.foundation.layout.size\nimport androidx.compose.foundation.shape.CircleShape\nimport androidx.compose.material.icons.Icons\nimport androidx.compose.material.icons.rounded.ArrowUpward\nimport androidx.compose.material.icons.rounded.Close\nimport androidx.compose.material.icons.rounded.Mic\nimport androidx.compose.material3.Icon\nimport androidx.compose.material3.IconButton\nimport androidx.compose.material3.IconButtonDefaults\nimport androidx.compose.material3.MaterialTheme\nimport androidx.compose.material3.Text\nimport androidx.compose.runtime.Composable\nimport androidx.compose.runtime.DisposableEffect\nimport androidx.compose.runtime.MutableLongState\nimport androidx.compose.runtime.MutableState\nimport androidx.compose.runtime.derivedStateOf\nimport androidx.compose.runtime.getValue\nimport androidx.compose.runtime.mutableLongStateOf\nimport androidx.compose.runtime.mutableStateOf\nimport androidx.compose.runtime.remember\nimport androidx.compose.runtime.rememberCoroutineScope\nimport androidx.compose.runtime.setValue\nimport androidx.compose.ui.Alignment\nimport androidx.compose.ui.Modifier\nimport androidx.compose.ui.draw.clip\nimport androidx.compose.ui.graphics.Color\nimport androidx.compose.ui.platform.LocalContext\nimport androidx.compose.ui.res.stringResource\nimport androidx.compose.ui.semantics.LiveRegionMode\nimport androidx.compose.ui.semantics.liveRegion\nimport androidx.compose.ui.semantics.semantics\nimport androidx.compose.ui.unit.dp\nimport com.google.ai.edge.gallery.R\nimport com.google.ai.edge.gallery.common.calculatePeakAmplitude\nimport com.google.ai.edge.gallery.data.MAX_AUDIO_CLIP_DURATION_SEC\nimport com.google.ai.edge.gallery.data.SAMPLE_RATE\nimport com.google.ai.edge.gallery.data.Task\nimport com.google.ai.edge.gallery.ui.common.getTaskIconColor\nimport com.google.ai.edge.gallery.ui.theme.customColors\nimport java.io.ByteArrayOutputStream\nimport kotlinx.coroutines.Dispatchers\nimport kotlinx.coroutines.coroutineScope\nimport kotlinx.coroutines.launch\n\nprivate const val TAG = \"AGAudioRecorderPanel\"\n\nprivate const val CHANNEL_CONFIG = AudioFormat.CHANNEL_IN_MONO\nprivate const val AUDIO_FORMAT = AudioFormat.ENCODING_PCM_16BIT\nprivate const val PANEL_ALPHA = 0.7f\n\n/**\n * This composable function creates a UI panel for audio recording. It handles the UI state (e.g.,\n * recording vs. idle) and manages the audio recording lifecycle.\n *\n * The panel displays different content based on the recording state:\n * - When idle, it shows a \"Tap to record\" message and a microphone icon.\n * - When recording, it shows a red indicator, elapsed time, and an \"up arrow\" icon button to send\n *   the clip.\n *\n * Tapping the record button starts a coroutine to handle audio capture on a background thread.\n * Tapping the \"up arrow\" button stops the recording, passes the audio data via the onSendAudioClip\n * callback, and resets the state.\n *\n * A DisposableEffect is used to ensure the AudioRecord resource is properly released when the\n * composable is removed from the UI hierarchy.\n */\n@Composable\nfun AudioRecorderPanel(\n  task: Task,\n  onAmplitudeChanged: (Int /* 0-32767 */) -> Unit,\n  onSendAudioClip: (ByteArray) -> Unit,\n  onClose: () -> Unit,\n  modifier: Modifier = Modifier,\n) {\n  val context = LocalContext.current\n  val coroutineScope = rememberCoroutineScope()\n\n  var isRecording by remember { mutableStateOf(false) }\n  val elapsedMs = remember { mutableLongStateOf(0L) }\n  val audioRecordState = remember { mutableStateOf<AudioRecord?>(null) }\n  val audioStream = remember { ByteArrayOutputStream() }\n\n  val elapsedSeconds by remember {\n    derivedStateOf { \"%.1f\".format(elapsedMs.longValue.toFloat() / 1000f) }\n  }\n\n  // Cleanup on Composable Disposal.\n  DisposableEffect(Unit) { onDispose { audioRecordState.value?.release() } }\n\n  Row(\n    modifier = modifier.fillMaxWidth().padding(horizontal = 8.dp),\n    horizontalArrangement = Arrangement.spacedBy(4.dp),\n    verticalAlignment = Alignment.CenterVertically,\n  ) {\n    // Close button.\n    IconButton(\n      onClick = {\n        if (isRecording) {\n          val unused = stopRecording(audioRecordState = audioRecordState, audioStream = audioStream)\n          isRecording = false\n        }\n        onClose()\n      },\n      colors =\n        IconButtonDefaults.iconButtonColors(\n          containerColor = MaterialTheme.colorScheme.surfaceContainer.copy(alpha = PANEL_ALPHA)\n        ),\n    ) {\n      Icon(\n        Icons.Rounded.Close,\n        contentDescription = stringResource(R.string.close),\n        tint = MaterialTheme.colorScheme.onSurface,\n      )\n    }\n\n    // Controls.\n    Row(\n      modifier =\n        Modifier.clip(CircleShape)\n          .weight(1f)\n          .background(MaterialTheme.colorScheme.surfaceContainer.copy(alpha = PANEL_ALPHA))\n          .padding(start = 12.dp),\n      verticalAlignment = Alignment.CenterVertically,\n      horizontalArrangement = Arrangement.SpaceBetween,\n    ) {\n      // Info message when there is no recorded clip and the recording has not started yet.\n      if (!isRecording) {\n        Text(\n          \"Tap the record button to start\",\n          style = MaterialTheme.typography.labelMedium,\n          color = MaterialTheme.colorScheme.onSurfaceVariant,\n        )\n      }\n      // Elapsed seconds when recording in progress.\n      else {\n        Row(\n          horizontalArrangement = Arrangement.spacedBy(12.dp),\n          verticalAlignment = Alignment.CenterVertically,\n        ) {\n          Box(\n            modifier =\n              Modifier.size(8.dp)\n                .background(MaterialTheme.customColors.recordButtonBgColor, CircleShape)\n          )\n          Text(\"$elapsedSeconds s\")\n        }\n      }\n\n      // Record/send button.\n      IconButton(\n        modifier = Modifier.semantics { liveRegion = LiveRegionMode.Assertive },\n        onClick = {\n          coroutineScope.launch {\n            if (!isRecording) {\n              isRecording = true\n              startRecording(\n                context = context,\n                audioRecordState = audioRecordState,\n                audioStream = audioStream,\n                elapsedMs = elapsedMs,\n                onAmplitudeChanged = onAmplitudeChanged,\n                onMaxDurationReached = {\n                  val curRecordedBytes =\n                    stopRecording(audioRecordState = audioRecordState, audioStream = audioStream)\n                  onSendAudioClip(curRecordedBytes)\n                  isRecording = false\n                },\n              )\n            } else {\n              val curRecordedBytes =\n                stopRecording(audioRecordState = audioRecordState, audioStream = audioStream)\n              onSendAudioClip(curRecordedBytes)\n              isRecording = false\n            }\n          }\n        },\n        colors = IconButtonDefaults.iconButtonColors(containerColor = getTaskIconColor(task = task)),\n      ) {\n        Icon(\n          if (isRecording) Icons.Rounded.ArrowUpward else Icons.Rounded.Mic,\n          contentDescription =\n            stringResource(\n              if (isRecording) R.string.cd_send_audio_clip_icon else R.string.cd_start_recording\n            ),\n          tint = Color.White,\n        )\n      }\n    }\n  }\n}\n\n// Permission is checked in parent composable.\n@SuppressLint(\"MissingPermission\")\nprivate suspend fun startRecording(\n  context: Context,\n  audioRecordState: MutableState<AudioRecord?>,\n  audioStream: ByteArrayOutputStream,\n  elapsedMs: MutableLongState,\n  onAmplitudeChanged: (Int) -> Unit,\n  onMaxDurationReached: () -> Unit,\n) {\n  Log.d(TAG, \"Start recording...\")\n  val minBufferSize = AudioRecord.getMinBufferSize(SAMPLE_RATE, CHANNEL_CONFIG, AUDIO_FORMAT)\n\n  audioRecordState.value?.release()\n  val recorder =\n    AudioRecord(\n      MediaRecorder.AudioSource.MIC,\n      SAMPLE_RATE,\n      CHANNEL_CONFIG,\n      AUDIO_FORMAT,\n      minBufferSize,\n    )\n\n  audioRecordState.value = recorder\n  val buffer = ByteArray(minBufferSize)\n\n  // The function will only return when the recording is done (when stopRecording is called).\n  coroutineScope {\n    launch(Dispatchers.IO) {\n      recorder.startRecording()\n\n      val startMs = System.currentTimeMillis()\n      elapsedMs.longValue = 0L\n      while (audioRecordState.value?.recordingState == AudioRecord.RECORDSTATE_RECORDING) {\n        val bytesRead = recorder.read(buffer, 0, buffer.size)\n        if (bytesRead > 0) {\n          val currentAmplitude = calculatePeakAmplitude(buffer = buffer, bytesRead = bytesRead)\n          onAmplitudeChanged(currentAmplitude)\n          audioStream.write(buffer, 0, bytesRead)\n        }\n        elapsedMs.longValue = System.currentTimeMillis() - startMs\n        if (elapsedMs.longValue >= MAX_AUDIO_CLIP_DURATION_SEC * 1000) {\n          onMaxDurationReached()\n          break\n        }\n      }\n    }\n  }\n}\n\nprivate fun stopRecording(\n  audioRecordState: MutableState<AudioRecord?>,\n  audioStream: ByteArrayOutputStream,\n): ByteArray {\n  Log.d(TAG, \"Stopping recording...\")\n\n  val recorder = audioRecordState.value\n  if (recorder?.recordingState == AudioRecord.RECORDSTATE_RECORDING) {\n    recorder.stop()\n  }\n  recorder?.release()\n  audioRecordState.value = null\n\n  val recordedBytes = audioStream.toByteArray()\n  audioStream.reset()\n  Log.d(TAG, \"Stopped. Recorded ${recordedBytes.size} bytes.\")\n\n  return recordedBytes\n}\n"
  },
  {
    "path": "Android/src/app/src/main/java/com/google/ai/edge/gallery/ui/common/chat/BenchmarkConfigDialog.kt",
    "content": "/*\n * Copyright 2025 Google LLC\n *\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n *     http://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\n\npackage com.google.ai.edge.gallery.ui.common.chat\n\n// import androidx.compose.ui.tooling.preview.Preview\n// import com.google.ai.edge.gallery.ui.theme.GalleryTheme\nimport androidx.compose.runtime.Composable\nimport com.google.ai.edge.gallery.data.Config\nimport com.google.ai.edge.gallery.data.ConfigKeys\nimport com.google.ai.edge.gallery.data.NumberSliderConfig\nimport com.google.ai.edge.gallery.data.ValueType\nimport com.google.ai.edge.gallery.data.convertValueToTargetType\nimport com.google.ai.edge.gallery.ui.common.ConfigDialog\n\nprivate const val DEFAULT_BENCHMARK_WARM_UP_ITERATIONS = 50f\nprivate const val DEFAULT_BENCHMARK_ITERATIONS = 200f\n\nprivate val BENCHMARK_CONFIGS: List<Config> =\n  listOf(\n    NumberSliderConfig(\n      key = ConfigKeys.WARM_UP_ITERATIONS,\n      sliderMin = 10f,\n      sliderMax = 200f,\n      defaultValue = DEFAULT_BENCHMARK_WARM_UP_ITERATIONS,\n      valueType = ValueType.INT,\n    ),\n    NumberSliderConfig(\n      key = ConfigKeys.BENCHMARK_ITERATIONS,\n      sliderMin = 50f,\n      sliderMax = 500f,\n      defaultValue = DEFAULT_BENCHMARK_ITERATIONS,\n      valueType = ValueType.INT,\n    ),\n  )\n\nprivate val BENCHMARK_CONFIGS_INITIAL_VALUES =\n  mapOf(\n    ConfigKeys.WARM_UP_ITERATIONS.label to DEFAULT_BENCHMARK_WARM_UP_ITERATIONS,\n    ConfigKeys.BENCHMARK_ITERATIONS.label to DEFAULT_BENCHMARK_ITERATIONS,\n  )\n\n/**\n * Composable function to display a configuration dialog for benchmarking a chat message.\n *\n * This function renders a configuration dialog specifically tailored for setting up benchmark\n * parameters. It allows users to specify warm-up and benchmark iterations before running a\n * benchmark test on a given chat message.\n */\n@Composable\nfun BenchmarkConfigDialog(\n  onDismissed: () -> Unit,\n  messageToBenchmark: ChatMessage?,\n  onBenchmarkClicked: (ChatMessage, warmUpIterations: Int, benchmarkIterations: Int) -> Unit,\n) {\n  ConfigDialog(\n    title = \"Benchmark configs\",\n    okBtnLabel = \"Start\",\n    configs = BENCHMARK_CONFIGS,\n    initialValues = BENCHMARK_CONFIGS_INITIAL_VALUES,\n    onDismissed = onDismissed,\n    onOk = { curConfigValues, _, _ ->\n      // Hide config dialog.\n      onDismissed()\n\n      // Start benchmark.\n      messageToBenchmark?.let { message ->\n        val warmUpIterations =\n          convertValueToTargetType(\n            value = curConfigValues.getValue(ConfigKeys.WARM_UP_ITERATIONS.label),\n            valueType = ValueType.INT,\n          )\n            as Int\n        val benchmarkIterations =\n          convertValueToTargetType(\n            value = curConfigValues.getValue(ConfigKeys.BENCHMARK_ITERATIONS.label),\n            valueType = ValueType.INT,\n          )\n            as Int\n        onBenchmarkClicked(message, warmUpIterations, benchmarkIterations)\n      }\n    },\n  )\n}\n\n// @Preview(showBackground = true)\n// @Composable\n// fun BenchmarkConfigDialogPreview() {\n//   GalleryTheme {\n//     BenchmarkConfigDialog(\n//       onDismissed = {},\n//       messageToBenchmark = null,\n//       onBenchmarkClicked = { _, _, _ -> },\n//     )\n//   }\n// }\n"
  },
  {
    "path": "Android/src/app/src/main/java/com/google/ai/edge/gallery/ui/common/chat/ChatMessage.kt",
    "content": "/*\n * Copyright 2025 Google LLC\n *\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n *     http://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\n\npackage com.google.ai.edge.gallery.ui.common.chat\n\nimport android.graphics.Bitmap\nimport android.util.Log\nimport androidx.compose.material.icons.Icons\nimport androidx.compose.material.icons.rounded.Check\nimport androidx.compose.ui.graphics.ImageBitmap\nimport androidx.compose.ui.graphics.vector.ImageVector\nimport androidx.compose.ui.unit.Dp\nimport com.google.ai.edge.gallery.common.Classification\nimport com.google.ai.edge.gallery.data.Model\nimport com.google.ai.edge.gallery.data.PromptTemplate\n\nprivate const val TAG = \"AGChatMessage\"\n\nenum class ChatMessageType {\n  INFO,\n  WARNING,\n  ERROR,\n  TEXT,\n  IMAGE,\n  IMAGE_WITH_HISTORY,\n  AUDIO_CLIP,\n  LOADING,\n  CLASSIFICATION,\n  CONFIG_VALUES_CHANGE,\n  BENCHMARK_RESULT,\n  BENCHMARK_LLM_RESULT,\n  PROMPT_TEMPLATES,\n  WEBVIEW,\n  COLLAPSABLE_PROGRESS_PANEL,\n}\n\nenum class ChatSide {\n  USER,\n  AGENT,\n  SYSTEM,\n}\n\n/** Base class for a chat message. */\nopen class ChatMessage(\n  open val type: ChatMessageType,\n  open val side: ChatSide,\n  open val latencyMs: Float = -1f,\n  open val accelerator: String = \"\",\n  open val hideSenderLabel: Boolean = false,\n  open val disableBubbleShape: Boolean = false,\n) {\n  open fun clone(): ChatMessage {\n    return ChatMessage(\n      type = type,\n      side = side,\n      latencyMs = latencyMs,\n      accelerator = accelerator,\n      hideSenderLabel = hideSenderLabel,\n      disableBubbleShape = disableBubbleShape,\n    )\n  }\n}\n\n/** Chat message for showing loading status. */\nclass ChatMessageLoading(\n  var extraProgressLabel: String = \"\",\n  override val accelerator: String = \"\",\n) : ChatMessage(type = ChatMessageType.LOADING, side = ChatSide.AGENT, accelerator = accelerator) {\n  override fun clone(): ChatMessageLoading {\n    return ChatMessageLoading(extraProgressLabel = extraProgressLabel, accelerator = accelerator)\n  }\n}\n\n/** Chat message for info (help). */\nclass ChatMessageInfo(val content: String) :\n  ChatMessage(type = ChatMessageType.INFO, side = ChatSide.SYSTEM)\n\n/** Chat message for warning message. */\nclass ChatMessageWarning(val content: String) :\n  ChatMessage(type = ChatMessageType.WARNING, side = ChatSide.SYSTEM)\n\n/** Chat message for error message. */\nclass ChatMessageError(val content: String) :\n  ChatMessage(type = ChatMessageType.ERROR, side = ChatSide.SYSTEM)\n\n/** Chat message for config values change. */\nclass ChatMessageConfigValuesChange(\n  val model: Model,\n  val oldValues: Map<String, Any>,\n  val newValues: Map<String, Any>,\n) : ChatMessage(type = ChatMessageType.CONFIG_VALUES_CHANGE, side = ChatSide.SYSTEM)\n\n/** Chat message for plain text. */\nopen class ChatMessageText(\n  val content: String,\n  override val side: ChatSide,\n  // Negative numbers will hide the latency display.\n  override val latencyMs: Float = 0f,\n  val isMarkdown: Boolean = true,\n\n  // Benchmark result for LLM response.\n  var llmBenchmarkResult: ChatMessageBenchmarkLlmResult? = null,\n  override val accelerator: String = \"\",\n  override val hideSenderLabel: Boolean = false,\n  var data: Any? = null,\n) :\n  ChatMessage(\n    type = ChatMessageType.TEXT,\n    side = side,\n    latencyMs = latencyMs,\n    accelerator = accelerator,\n    hideSenderLabel = hideSenderLabel,\n  ) {\n  override fun clone(): ChatMessageText {\n    return ChatMessageText(\n      content = content,\n      side = side,\n      latencyMs = latencyMs,\n      accelerator = accelerator,\n      isMarkdown = isMarkdown,\n      llmBenchmarkResult = llmBenchmarkResult,\n      hideSenderLabel = hideSenderLabel,\n      data = data,\n    )\n  }\n}\n\n/** Chat message for images. */\nclass ChatMessageImage(\n  val bitmaps: List<Bitmap>,\n  val imageBitMaps: List<ImageBitmap>,\n  val maxSize: Int = 200,\n  override val side: ChatSide,\n  override val latencyMs: Float = 0f,\n  override val accelerator: String = \"\",\n  override val hideSenderLabel: Boolean = false,\n) :\n  ChatMessage(\n    type = ChatMessageType.IMAGE,\n    side = side,\n    latencyMs = latencyMs,\n    accelerator = accelerator,\n    hideSenderLabel = hideSenderLabel,\n  ) {\n  override fun clone(): ChatMessageImage {\n    return ChatMessageImage(\n      bitmaps = bitmaps.toList(),\n      imageBitMaps = imageBitMaps.toList(),\n      side = side,\n      latencyMs = latencyMs,\n      accelerator = accelerator,\n      hideSenderLabel = hideSenderLabel,\n    )\n  }\n}\n\n/** Chat message for audio clip. */\nclass ChatMessageAudioClip(\n  val audioData: ByteArray,\n  val sampleRate: Int,\n  override val side: ChatSide,\n  override val latencyMs: Float = 0f,\n) : ChatMessage(type = ChatMessageType.AUDIO_CLIP, side = side, latencyMs = latencyMs) {\n  override fun clone(): ChatMessageAudioClip {\n    return ChatMessageAudioClip(\n      audioData = audioData,\n      sampleRate = sampleRate,\n      side = side,\n      latencyMs = latencyMs,\n    )\n  }\n\n  fun genByteArrayForWav(): ByteArray {\n    val header = ByteArray(44)\n\n    val pcmDataSize = audioData.size\n    val wavFileSize = pcmDataSize + 44 // 44 bytes for the header\n    val channels = 1 // Mono\n    val bitsPerSample: Short = 16\n    val byteRate = sampleRate * channels * bitsPerSample / 8\n    Log.d(TAG, \"Wav metadata: sampleRate: $sampleRate\")\n\n    // RIFF/WAVE header\n    header[0] = 'R'.code.toByte()\n    header[1] = 'I'.code.toByte()\n    header[2] = 'F'.code.toByte()\n    header[3] = 'F'.code.toByte()\n    header[4] = (wavFileSize and 0xff).toByte()\n    header[5] = (wavFileSize shr 8 and 0xff).toByte()\n    header[6] = (wavFileSize shr 16 and 0xff).toByte()\n    header[7] = (wavFileSize shr 24 and 0xff).toByte()\n    header[8] = 'W'.code.toByte()\n    header[9] = 'A'.code.toByte()\n    header[10] = 'V'.code.toByte()\n    header[11] = 'E'.code.toByte()\n    header[12] = 'f'.code.toByte()\n    header[13] = 'm'.code.toByte()\n    header[14] = 't'.code.toByte()\n    header[15] = ' '.code.toByte()\n    header[16] = 16\n    header[17] = 0\n    header[18] = 0\n    header[19] = 0 // Sub-chunk size (16 for PCM)\n    header[20] = 1\n    header[21] = 0 // Audio format (1 for PCM)\n    header[22] = channels.toByte()\n    header[23] = 0 // Number of channels\n    header[24] = (sampleRate and 0xff).toByte()\n    header[25] = (sampleRate shr 8 and 0xff).toByte()\n    header[26] = (sampleRate shr 16 and 0xff).toByte()\n    header[27] = (sampleRate shr 24 and 0xff).toByte()\n    header[28] = (byteRate and 0xff).toByte()\n    header[29] = (byteRate shr 8 and 0xff).toByte()\n    header[30] = (byteRate shr 16 and 0xff).toByte()\n    header[31] = (byteRate shr 24 and 0xff).toByte()\n    header[32] = (channels * bitsPerSample / 8).toByte()\n    header[33] = 0 // Block align\n    header[34] = bitsPerSample.toByte()\n    header[35] = (bitsPerSample.toInt() shr 8 and 0xff).toByte() // Bits per sample\n    header[36] = 'd'.code.toByte()\n    header[37] = 'a'.code.toByte()\n    header[38] = 't'.code.toByte()\n    header[39] = 'a'.code.toByte()\n    header[40] = (pcmDataSize and 0xff).toByte()\n    header[41] = (pcmDataSize shr 8 and 0xff).toByte()\n    header[42] = (pcmDataSize shr 16 and 0xff).toByte()\n    header[43] = (pcmDataSize shr 24 and 0xff).toByte()\n\n    return header + audioData\n  }\n\n  fun getDurationInSeconds(): Float {\n    // PCM 16-bit\n    val bytesPerSample = 2\n    val bytesPerFrame = bytesPerSample * 1 // mono\n    val totalFrames = audioData.size.toFloat() / bytesPerFrame\n    return totalFrames / sampleRate\n  }\n}\n\n/** Chat message for images with history. */\nclass ChatMessageImageWithHistory(\n  val bitmaps: List<Bitmap>,\n  val imageBitMaps: List<ImageBitmap>,\n  val totalIterations: Int,\n  override val side: ChatSide,\n  override val latencyMs: Float = 0f,\n  var curIteration: Int = 0, // 0-based\n) : ChatMessage(type = ChatMessageType.IMAGE_WITH_HISTORY, side = side, latencyMs = latencyMs) {\n  fun isRunning(): Boolean {\n    return curIteration < totalIterations - 1\n  }\n}\n\n/** Chat message for showing classification result. */\nclass ChatMessageClassification(\n  val classifications: List<Classification>,\n  override val latencyMs: Float = 0f,\n  // Typical android phone width is > 320dp\n  val maxBarWidth: Dp? = null,\n) : ChatMessage(type = ChatMessageType.CLASSIFICATION, side = ChatSide.AGENT, latencyMs = latencyMs)\n\n/** A stat used in benchmark result. */\ndata class Stat(val id: String, val label: String, val unit: String)\n\n/** Chat message for showing benchmark result. */\nclass ChatMessageBenchmarkResult(\n  val orderedStats: List<Stat>,\n  val statValues: MutableMap<String, Float>,\n  val values: List<Float>,\n  val histogram: Histogram,\n  val warmupCurrent: Int,\n  val warmupTotal: Int,\n  val iterationCurrent: Int,\n  val iterationTotal: Int,\n  override val latencyMs: Float = 0f,\n  val highlightStat: String = \"\",\n) :\n  ChatMessage(\n    type = ChatMessageType.BENCHMARK_RESULT,\n    side = ChatSide.AGENT,\n    latencyMs = latencyMs,\n  ) {\n  fun isWarmingUp(): Boolean {\n    return warmupCurrent < warmupTotal\n  }\n\n  fun isRunning(): Boolean {\n    return iterationCurrent < iterationTotal\n  }\n}\n\n/** Chat message for showing LLM benchmark result. */\nclass ChatMessageBenchmarkLlmResult(\n  val orderedStats: List<Stat>,\n  val statValues: MutableMap<String, Float>,\n  val running: Boolean,\n  override val latencyMs: Float = 0f,\n  override val accelerator: String = \"\",\n) :\n  ChatMessage(\n    type = ChatMessageType.BENCHMARK_LLM_RESULT,\n    side = ChatSide.AGENT,\n    latencyMs = latencyMs,\n    accelerator = accelerator,\n  )\n\ndata class Histogram(val buckets: List<Int>, val maxCount: Int, val highlightBucketIndex: Int = -1)\n\n/** Chat message for showing prompt templates. */\nclass ChatMessagePromptTemplates(\n  val templates: List<PromptTemplate>,\n  val showMakeYourOwn: Boolean = true,\n) : ChatMessage(type = ChatMessageType.PROMPT_TEMPLATES, side = ChatSide.SYSTEM)\n\n/** Chat message for showing a WebView. */\nclass ChatMessageWebView(\n  val url: String,\n  val iframe: Boolean,\n  override val side: ChatSide = ChatSide.AGENT,\n  override val hideSenderLabel: Boolean = false,\n) :\n  ChatMessage(\n    type = ChatMessageType.WEBVIEW,\n    side = side,\n    hideSenderLabel = hideSenderLabel,\n    disableBubbleShape = true,\n  ) {\n  override fun clone(): ChatMessageWebView {\n    return ChatMessageWebView(\n      url = url,\n      iframe = iframe,\n      side = side,\n      hideSenderLabel = hideSenderLabel,\n    )\n  }\n}\n\ndata class ProgressPanelItem(val title: String, val description: String)\n\n/** Chat message for showing a collapsable progress panel. */\nclass ChatMessageCollapsableProgressPanel(\n  val title: String,\n  val inProgress: Boolean,\n  override val accelerator: String,\n  val doneIcon: ImageVector = Icons.Rounded.Check,\n  val items: List<ProgressPanelItem> = listOf(),\n  val customData: Any? = null,\n) :\n  ChatMessage(\n    type = ChatMessageType.COLLAPSABLE_PROGRESS_PANEL,\n    side = ChatSide.AGENT,\n    accelerator = accelerator,\n  ) {\n  override fun clone(): ChatMessageCollapsableProgressPanel {\n    return ChatMessageCollapsableProgressPanel(\n      title = title,\n      inProgress = inProgress,\n      accelerator = accelerator,\n      doneIcon = doneIcon,\n      items = items.toList(),\n      customData = customData,\n    )\n  }\n}\n"
  },
  {
    "path": "Android/src/app/src/main/java/com/google/ai/edge/gallery/ui/common/chat/ChatPanel.kt",
    "content": "/*\n * Copyright 2025 Google LLC\n *\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n *     http://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\n\npackage com.google.ai.edge.gallery.ui.common.chat\n\nimport android.content.ClipData\nimport android.graphics.Bitmap\nimport androidx.compose.animation.AnimatedVisibility\nimport androidx.compose.animation.core.Spring\nimport androidx.compose.animation.core.VisibilityThreshold\nimport androidx.compose.animation.core.spring\nimport androidx.compose.animation.fadeIn\nimport androidx.compose.animation.fadeOut\nimport androidx.compose.animation.slideInVertically\nimport androidx.compose.foundation.background\nimport androidx.compose.foundation.clickable\nimport androidx.compose.foundation.gestures.detectTapGestures\nimport androidx.compose.foundation.layout.Arrangement\nimport androidx.compose.foundation.layout.Box\nimport androidx.compose.foundation.layout.Column\nimport androidx.compose.foundation.layout.PaddingValues\nimport androidx.compose.foundation.layout.Row\nimport androidx.compose.foundation.layout.WindowInsets\nimport androidx.compose.foundation.layout.consumeWindowInsets\nimport androidx.compose.foundation.layout.fillMaxSize\nimport androidx.compose.foundation.layout.fillMaxWidth\nimport androidx.compose.foundation.layout.ime\nimport androidx.compose.foundation.layout.imePadding\nimport androidx.compose.foundation.layout.padding\nimport androidx.compose.foundation.layout.size\nimport androidx.compose.foundation.layout.width\nimport androidx.compose.foundation.layout.wrapContentHeight\nimport androidx.compose.foundation.layout.wrapContentWidth\nimport androidx.compose.foundation.lazy.LazyColumn\nimport androidx.compose.foundation.lazy.LazyListState\nimport androidx.compose.foundation.lazy.itemsIndexed\nimport androidx.compose.foundation.lazy.rememberLazyListState\nimport androidx.compose.foundation.shape.RoundedCornerShape\nimport androidx.compose.material.icons.Icons\nimport androidx.compose.material.icons.outlined.Timer\nimport androidx.compose.material.icons.rounded.ContentCopy\nimport androidx.compose.material.icons.rounded.Refresh\nimport androidx.compose.material3.ExperimentalMaterial3Api\nimport androidx.compose.material3.Icon\nimport androidx.compose.material3.MaterialTheme\nimport androidx.compose.material3.ModalBottomSheet\nimport androidx.compose.material3.SnackbarHost\nimport androidx.compose.material3.SnackbarHostState\nimport androidx.compose.material3.Text\nimport androidx.compose.runtime.Composable\nimport androidx.compose.runtime.LaunchedEffect\nimport androidx.compose.runtime.MutableState\nimport androidx.compose.runtime.collectAsState\nimport androidx.compose.runtime.getValue\nimport androidx.compose.runtime.mutableIntStateOf\nimport androidx.compose.runtime.mutableStateOf\nimport androidx.compose.runtime.remember\nimport androidx.compose.runtime.rememberCoroutineScope\nimport androidx.compose.runtime.setValue\nimport androidx.compose.ui.Alignment\nimport androidx.compose.ui.Modifier\nimport androidx.compose.ui.draw.clip\nimport androidx.compose.ui.geometry.Offset\nimport androidx.compose.ui.graphics.Color\nimport androidx.compose.ui.graphics.graphicsLayer\nimport androidx.compose.ui.hapticfeedback.HapticFeedbackType\nimport androidx.compose.ui.input.nestedscroll.NestedScrollConnection\nimport androidx.compose.ui.input.nestedscroll.NestedScrollSource\nimport androidx.compose.ui.input.nestedscroll.nestedScroll\nimport androidx.compose.ui.input.pointer.pointerInput\nimport androidx.compose.ui.platform.ClipEntry\nimport androidx.compose.ui.platform.LocalClipboard\nimport androidx.compose.ui.platform.LocalDensity\nimport androidx.compose.ui.platform.LocalFocusManager\nimport androidx.compose.ui.platform.LocalHapticFeedback\nimport androidx.compose.ui.res.dimensionResource\nimport androidx.compose.ui.res.stringResource\nimport androidx.compose.ui.semantics.contentDescription\nimport androidx.compose.ui.semantics.semantics\nimport androidx.compose.ui.unit.IntOffset\nimport androidx.compose.ui.unit.dp\nimport com.google.ai.edge.gallery.R\nimport com.google.ai.edge.gallery.data.BuiltInTaskId\nimport com.google.ai.edge.gallery.data.Model\nimport com.google.ai.edge.gallery.data.Task\nimport com.google.ai.edge.gallery.ui.common.AudioAnimation\nimport com.google.ai.edge.gallery.ui.common.ErrorDialog\nimport com.google.ai.edge.gallery.ui.modelmanager.ModelInitializationStatusType\nimport com.google.ai.edge.gallery.ui.modelmanager.ModelManagerViewModel\nimport com.google.ai.edge.gallery.ui.theme.customColors\nimport kotlinx.coroutines.launch\n\n/** Composable function for the main chat panel, displaying messages and handling user input. */\n@OptIn(ExperimentalMaterial3Api::class)\n@Composable\nfun ChatPanel(\n  modelManagerViewModel: ModelManagerViewModel,\n  task: Task,\n  selectedModel: Model,\n  viewModel: ChatViewModel,\n  innerPadding: PaddingValues,\n  onSendMessage: (Model, List<ChatMessage>) -> Unit,\n  onRunAgainClicked: (Model, ChatMessage) -> Unit,\n  onBenchmarkClicked: (Model, ChatMessage, warmUpIterations: Int, benchmarkIterations: Int) -> Unit,\n  navigateUp: () -> Unit,\n  modifier: Modifier = Modifier,\n  onStreamImageMessage: (Model, ChatMessageImage) -> Unit = { _, _ -> },\n  onStreamEnd: (Int) -> Unit = {},\n  onStopButtonClicked: () -> Unit = {},\n  onImageSelected: (bitmaps: List<Bitmap>, selectedBitmapIndex: Int) -> Unit = { _, _ -> },\n  showStopButtonInInputWhenInProgress: Boolean = false,\n  emptyStateComposable: @Composable () -> Unit = {},\n) {\n  val uiState by viewModel.uiState.collectAsState()\n  val modelManagerUiState by modelManagerViewModel.uiState.collectAsState()\n  val messages = uiState.messagesByModel[selectedModel.name] ?: listOf()\n  val streamingMessage = uiState.streamingMessagesByModel[selectedModel.name]\n  val snackbarHostState = remember { SnackbarHostState() }\n  val scope = rememberCoroutineScope()\n  val haptic = LocalHapticFeedback.current\n  val imageCountToLastConfigChange =\n    remember(messages) {\n      var imageCount = 0\n      for (message in messages.reversed()) {\n        if (message is ChatMessageConfigValuesChange) {\n          break\n        }\n        if (message is ChatMessageImage) {\n          imageCount += message.bitmaps.size\n        }\n      }\n      imageCount\n    }\n  val audioClipMesssageCountToLastconfigChange =\n    remember(messages) {\n      var audioClipMessageCount = 0\n      for (message in messages.reversed()) {\n        if (message is ChatMessageConfigValuesChange) {\n          break\n        }\n        if (message is ChatMessageAudioClip) {\n          audioClipMessageCount++\n        }\n      }\n      audioClipMessageCount\n    }\n\n  var curMessage by remember { mutableStateOf(\"\") } // Correct state\n  val focusManager = LocalFocusManager.current\n\n  // Remember the LazyListState to control scrolling\n  val listState = rememberLazyListState()\n  val density = LocalDensity.current\n  var showBenchmarkConfigsDialog by remember { mutableStateOf(false) }\n  val benchmarkMessage: MutableState<ChatMessage?> = remember { mutableStateOf(null) }\n\n  var showMessageLongPressedSheet by remember { mutableStateOf(false) }\n  var longPressedMessageIndex by remember { mutableIntStateOf(-1) }\n\n  var showErrorDialog by remember { mutableStateOf(false) }\n\n  var showAudioRecorder by remember { mutableStateOf(false) }\n  var curAmplitude by remember { mutableIntStateOf(0) }\n\n  // Keep track of the last message and last message content.\n  val lastMessage: MutableState<ChatMessage?> = remember { mutableStateOf(null) }\n  val lastMessageContent: MutableState<String> = remember { mutableStateOf(\"\") }\n  if (messages.isNotEmpty()) {\n    val tmpLastMessage = messages.last()\n    lastMessage.value = tmpLastMessage\n    if (tmpLastMessage is ChatMessageText) {\n      lastMessageContent.value = tmpLastMessage.content\n    }\n  }\n\n  // Scroll to bottom when IME is toggled.\n  LaunchedEffect(WindowInsets.ime.getBottom(density)) {\n    scrollToBottom(listState = listState, animate = true)\n  }\n\n  // Scroll the content to the bottom when any of these changes.\n  LaunchedEffect(\n    messages.size,\n    lastMessage.value,\n    lastMessageContent.value,\n    lastMessage.value?.latencyMs,\n  ) {\n    if (messages.isNotEmpty()) {\n      val lastVisibleItem = listState.layoutInfo.visibleItemsInfo.last()\n      // Determines if an automatic scroll is necessary. It is true if:\n      // 1. The last item is not yet fully visible\n      // OR\n      // 2. The scroll position is close to the bottom (within 90 pixels of the end offset. 90 is\n      //    slightly taller than the \"show stats\" chip).\n      val canScroll =\n        lastVisibleItem.index < messages.size - 1 ||\n          lastVisibleItem.offset + lastVisibleItem.size - listState.layoutInfo.viewportEndOffset <\n            90\n      if (canScroll) {\n        scrollToBottom(listState = listState, animate = true)\n      }\n    }\n  }\n\n  val nestedScrollConnection = remember {\n    object : NestedScrollConnection {\n      override fun onPreScroll(available: Offset, source: NestedScrollSource): Offset {\n        // If downward scroll, clear the focus from any currently focused composable.\n        // This is useful for dismissing software keyboards or hiding text input fields\n        // when the user starts scrolling down a list.\n        if (available.y > 0) {\n          focusManager.clearFocus()\n        }\n        // Let LazyColumn handle the scroll\n        return Offset.Zero\n      }\n    }\n  }\n\n  val modelInitializationStatus = modelManagerUiState.modelInitializationStatus[selectedModel.name]\n\n  LaunchedEffect(modelInitializationStatus) {\n    showErrorDialog = modelInitializationStatus?.status == ModelInitializationStatusType.ERROR\n  }\n\n  Box(modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.BottomCenter) {\n    // Audio record animation.\n    AnimatedVisibility(\n      showAudioRecorder,\n      enter =\n        slideInVertically(\n          animationSpec =\n            spring(\n              stiffness = Spring.StiffnessLow,\n              visibilityThreshold = IntOffset.VisibilityThreshold,\n            )\n        ) {\n          it\n        } + fadeIn(animationSpec = spring(stiffness = Spring.StiffnessLow)),\n      exit = fadeOut(),\n      modifier = Modifier.graphicsLayer { alpha = 0.8f },\n    ) {\n      AudioAnimation(bgColor = MaterialTheme.colorScheme.surface, amplitude = curAmplitude)\n    }\n\n    Column(\n      modifier = modifier.padding(innerPadding).consumeWindowInsets(innerPadding).imePadding()\n    ) {\n      Box(contentAlignment = Alignment.BottomCenter, modifier = Modifier.weight(1f)) {\n        val cdChatPanel = stringResource(R.string.cd_chat_panel)\n        LazyColumn(\n          modifier =\n            Modifier.fillMaxSize().nestedScroll(nestedScrollConnection).semantics {\n              contentDescription = cdChatPanel\n            },\n          state = listState,\n          verticalArrangement = Arrangement.Top,\n        ) {\n          itemsIndexed(messages) { index, message ->\n            val imageHistoryCurIndex = remember { mutableIntStateOf(0) }\n            var hAlign: Alignment.Horizontal = Alignment.End\n            var backgroundColor: Color = MaterialTheme.customColors.userBubbleBgColor\n            var hardCornerAtLeftOrRight = false\n            var extraPaddingStart = 48.dp\n            var extraPaddingEnd = 0.dp\n            if (message.side == ChatSide.AGENT) {\n              hAlign = Alignment.Start\n              backgroundColor = MaterialTheme.customColors.agentBubbleBgColor\n              hardCornerAtLeftOrRight = true\n              extraPaddingStart = 0.dp\n              if (\n                message.type !== ChatMessageType.LOADING &&\n                  message.type !== ChatMessageType.WEBVIEW &&\n                  message.type !== ChatMessageType.COLLAPSABLE_PROGRESS_PANEL\n              ) {\n                extraPaddingEnd = 48.dp\n              }\n            } else if (message.side == ChatSide.SYSTEM) {\n              extraPaddingStart = 24.dp\n              extraPaddingEnd = 24.dp\n              if (message.type == ChatMessageType.PROMPT_TEMPLATES) {\n                extraPaddingStart = 12.dp\n                extraPaddingEnd = 12.dp\n              }\n            }\n            if (message.type == ChatMessageType.IMAGE) {\n              backgroundColor = Color.Transparent\n            }\n            val bubbleBorderRadius = dimensionResource(R.dimen.chat_bubble_corner_radius)\n\n            Column(\n              modifier =\n                Modifier.fillMaxWidth()\n                  .padding(\n                    start = 12.dp + extraPaddingStart,\n                    end = 12.dp + extraPaddingEnd,\n                    top = 6.dp,\n                    bottom = 6.dp,\n                  ),\n              horizontalAlignment = hAlign,\n            ) messageColumn@{\n              // Sender row.\n              var agentName = stringResource(task.agentNameRes)\n              if (message.accelerator.isNotEmpty()) {\n                agentName = \"$agentName on ${message.accelerator}\"\n              }\n              if (!message.hideSenderLabel) {\n                MessageSender(\n                  message = message,\n                  agentName = agentName,\n                  imageHistoryCurIndex = imageHistoryCurIndex.intValue,\n                )\n              }\n\n              // Message body.\n              when (message) {\n                // Loading.\n                is ChatMessageLoading -> MessageBodyLoading(message = message)\n\n                // Info.\n                is ChatMessageInfo -> MessageBodyInfo(message = message)\n\n                // Warning\n                is ChatMessageWarning -> MessageBodyWarning(message = message)\n\n                // Error\n                is ChatMessageError -> MessageBodyError(message = message)\n\n                // Config values change.\n                is ChatMessageConfigValuesChange -> MessageBodyConfigUpdate(message = message)\n\n                // Prompt templates.\n                is ChatMessagePromptTemplates ->\n                  MessageBodyPromptTemplates(\n                    message = message,\n                    task = task,\n                    onPromptClicked = { template ->\n                      onSendMessage(\n                        selectedModel,\n                        listOf(ChatMessageText(content = template.prompt, side = ChatSide.USER)),\n                      )\n                    },\n                  )\n\n                // Non-system messages.\n                else -> {\n                  // The bubble shape around the message body.\n                  var messageBubbleModifier: Modifier = Modifier\n                  if (!message.disableBubbleShape) {\n                    // Use a rounded rectangle clip for multi-image image message.\n                    if (message is ChatMessageImage && message.bitmaps.size > 1) {\n                      messageBubbleModifier = messageBubbleModifier.clip(RoundedCornerShape(6.dp))\n                    }\n                    // For other messages, use a bubble shape to clip.\n                    else {\n                      messageBubbleModifier =\n                        messageBubbleModifier.clip(\n                          MessageBubbleShape(\n                            radius = bubbleBorderRadius,\n                            hardCornerAtLeftOrRight = hardCornerAtLeftOrRight,\n                          )\n                        )\n                    }\n                    messageBubbleModifier = messageBubbleModifier.background(backgroundColor)\n                  }\n                  if (message is ChatMessageText) {\n                    messageBubbleModifier =\n                      messageBubbleModifier.pointerInput(Unit) {\n                        detectTapGestures(\n                          onLongPress = {\n                            haptic.performHapticFeedback(HapticFeedbackType.LongPress)\n                            longPressedMessageIndex = index\n                            showMessageLongPressedSheet = true\n                          }\n                        )\n                      }\n                  }\n                  Box(modifier = messageBubbleModifier) {\n                    when (message) {\n                      // Text\n                      is ChatMessageText ->\n                        MessageBodyText(message = message, inProgress = uiState.inProgress)\n\n                      // Image\n                      is ChatMessageImage -> {\n                        MessageBodyImage(message = message, onImageClicked = onImageSelected)\n                      }\n\n                      // Image with history (for image gen)\n                      is ChatMessageImageWithHistory ->\n                        MessageBodyImageWithHistory(\n                          message = message,\n                          imageHistoryCurIndex = imageHistoryCurIndex,\n                        )\n\n                      // Audio clip.\n                      is ChatMessageAudioClip -> MessageBodyAudioClip(message = message)\n\n                      // Classification result\n                      is ChatMessageClassification ->\n                        MessageBodyClassification(\n                          message = message,\n                          modifier =\n                            Modifier.width(message.maxBarWidth ?: CLASSIFICATION_BAR_MAX_WIDTH),\n                        )\n\n                      // Benchmark result.\n                      is ChatMessageBenchmarkResult -> MessageBodyBenchmark(message = message)\n\n                      // Benchmark LLM result.\n                      is ChatMessageBenchmarkLlmResult ->\n                        MessageBodyBenchmarkLlm(\n                          message = message,\n                          modifier = Modifier.wrapContentWidth(),\n                        )\n\n                      // Webview.\n                      is ChatMessageWebView -> MessageBodyWebview(message = message)\n\n                      // Collapsable progress panel.\n                      is ChatMessageCollapsableProgressPanel ->\n                        MessageBodyCollapsableProgressPanel(message = message)\n\n                      else -> {}\n                    }\n                  }\n\n                  if (message.side == ChatSide.AGENT) {\n                    Row(\n                      verticalAlignment = Alignment.CenterVertically,\n                      horizontalArrangement = Arrangement.spacedBy(8.dp),\n                    ) {\n                      LatencyText(message = message)\n                    }\n                  } else if (message.side == ChatSide.USER) {\n                    Row(\n                      verticalAlignment = Alignment.CenterVertically,\n                      horizontalArrangement = Arrangement.spacedBy(4.dp),\n                    ) {\n                      // Run again button.\n                      if (selectedModel.showRunAgainButton) {\n                        MessageActionButton(\n                          label = stringResource(R.string.run_again),\n                          icon = Icons.Rounded.Refresh,\n                          onClick = { onRunAgainClicked(selectedModel, message) },\n                          enabled = !uiState.inProgress,\n                        )\n                      }\n\n                      // Benchmark button\n                      if (selectedModel.showBenchmarkButton) {\n                        MessageActionButton(\n                          label = stringResource(R.string.run_benchmark),\n                          icon = Icons.Outlined.Timer,\n                          onClick = {\n                            showBenchmarkConfigsDialog = true\n                            benchmarkMessage.value = message\n                          },\n                          enabled = !uiState.inProgress,\n                        )\n                      }\n                    }\n                  }\n                }\n              }\n            }\n          }\n        }\n\n        SnackbarHost(hostState = snackbarHostState, modifier = Modifier.padding(vertical = 4.dp))\n\n        // Show empty state.\n        if (messages.isEmpty()) {\n          emptyStateComposable()\n        }\n      }\n\n      MessageInputText(\n        task = task,\n        modelManagerViewModel = modelManagerViewModel,\n        curMessage = curMessage,\n        inProgress = uiState.inProgress,\n        isResettingSession = uiState.isResettingSession,\n        modelPreparing = uiState.preparing,\n        imageCount = imageCountToLastConfigChange,\n        audioClipMessageCount = audioClipMesssageCountToLastconfigChange,\n        modelInitializing =\n          modelInitializationStatus?.status == ModelInitializationStatusType.INITIALIZING,\n        textFieldPlaceHolderRes = task.textInputPlaceHolderRes,\n        onValueChanged = { curMessage = it },\n        onSendMessage = {\n          onSendMessage(selectedModel, it)\n          curMessage = \"\"\n          // Hide software keyboard.\n          focusManager.clearFocus()\n        },\n        onOpenPromptTemplatesClicked = {\n          onSendMessage(\n            selectedModel,\n            listOf(\n              ChatMessagePromptTemplates(\n                templates = selectedModel.llmPromptTemplates,\n                showMakeYourOwn = false,\n              )\n            ),\n          )\n        },\n        onStopButtonClicked = onStopButtonClicked,\n        onSetAudioRecorderVisible = { start ->\n          showAudioRecorder = start\n          if (!showAudioRecorder) {\n            curAmplitude = 0\n          }\n        },\n        onAmplitudeChanged = { curAmplitude = it },\n        showPromptTemplatesInMenu = false,\n        showImagePicker = selectedModel.llmSupportImage && task.id === BuiltInTaskId.LLM_ASK_IMAGE,\n        showAudioPicker = selectedModel.llmSupportAudio && task.id === BuiltInTaskId.LLM_ASK_AUDIO,\n        showStopButtonWhenInProgress = showStopButtonInInputWhenInProgress,\n      )\n    }\n  }\n\n  // Error dialog.\n  if (showErrorDialog) {\n    ErrorDialog(\n      error = modelInitializationStatus?.error ?: \"\",\n      onDismiss = { showErrorDialog = false },\n    )\n  }\n\n  // Benchmark config dialog.\n  if (showBenchmarkConfigsDialog) {\n    BenchmarkConfigDialog(\n      onDismissed = { showBenchmarkConfigsDialog = false },\n      messageToBenchmark = benchmarkMessage.value,\n      onBenchmarkClicked = { message, warmUpIterations, benchmarkIterations ->\n        onBenchmarkClicked(selectedModel, message, warmUpIterations, benchmarkIterations)\n      },\n    )\n  }\n\n  // Sheet to show when a message is long-pressed.\n  if (showMessageLongPressedSheet) {\n    val message =\n      uiState.messagesByModel\n        .getOrDefault(selectedModel.name, listOf())\n        .getOrNull(longPressedMessageIndex)\n    if (message != null && message is ChatMessageText) {\n      val clipboard = LocalClipboard.current\n\n      ModalBottomSheet(\n        onDismissRequest = { showMessageLongPressedSheet = false },\n        modifier = Modifier.wrapContentHeight(),\n      ) {\n        Column {\n          // Copy text.\n          Box(\n            modifier =\n              Modifier.fillMaxWidth().clickable {\n                // Copy text.\n                scope.launch {\n                  val clipData = ClipData.newPlainText(\"message content\", message.content)\n                  val clipEntry = ClipEntry(clipData = clipData)\n                  clipboard.setClipEntry(clipEntry = clipEntry)\n                }\n\n                // Hide sheet.\n                showMessageLongPressedSheet = false\n\n                // Show a snack bar.\n                scope.launch { snackbarHostState.showSnackbar(\"Text copied to clipboard\") }\n              }\n          ) {\n            Row(\n              verticalAlignment = Alignment.CenterVertically,\n              horizontalArrangement = Arrangement.spacedBy(6.dp),\n              modifier = Modifier.padding(vertical = 8.dp, horizontal = 16.dp),\n            ) {\n              Icon(\n                Icons.Rounded.ContentCopy,\n                contentDescription = stringResource(R.string.cd_copy_to_clipboard_icon),\n                modifier = Modifier.size(18.dp),\n              )\n              Text(\"Copy text\")\n            }\n          }\n        }\n      }\n    }\n  }\n}\n\nprivate suspend fun scrollToBottom(listState: LazyListState, animate: Boolean = false) {\n  val itemCount = listState.layoutInfo.totalItemsCount\n  if (itemCount > 0) {\n    if (animate) {\n      listState.animateScrollToItem(itemCount - 1, scrollOffset = 1000000)\n    } else {\n      listState.scrollToItem(itemCount - 1, scrollOffset = 1000000)\n    }\n  }\n}\n"
  },
  {
    "path": "Android/src/app/src/main/java/com/google/ai/edge/gallery/ui/common/chat/ChatView.kt",
    "content": "/*\n * Copyright 2025 Google LLC\n *\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n *     http://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\n\npackage com.google.ai.edge.gallery.ui.common.chat\n\n// import com.google.ai.edge.gallery.ui.preview.PreviewChatModel\n// import com.google.ai.edge.gallery.ui.preview.PreviewModelManagerViewModel\n// import com.google.ai.edge.gallery.ui.preview.TASK_TEST1\n// import com.google.ai.edge.gallery.ui.theme.GalleryTheme\n\nimport android.graphics.Bitmap\nimport android.util.Log\nimport androidx.activity.compose.BackHandler\nimport androidx.compose.animation.AnimatedContent\nimport androidx.compose.animation.AnimatedVisibility\nimport androidx.compose.animation.fadeIn\nimport androidx.compose.animation.fadeOut\nimport androidx.compose.animation.slideInVertically\nimport androidx.compose.animation.slideOutVertically\nimport androidx.compose.foundation.background\nimport androidx.compose.foundation.layout.Box\nimport androidx.compose.foundation.layout.Column\nimport androidx.compose.foundation.layout.fillMaxSize\nimport androidx.compose.foundation.layout.offset\nimport androidx.compose.foundation.layout.padding\nimport androidx.compose.foundation.pager.HorizontalPager\nimport androidx.compose.foundation.pager.rememberPagerState\nimport androidx.compose.material.icons.Icons\nimport androidx.compose.material.icons.rounded.Close\nimport androidx.compose.material3.Icon\nimport androidx.compose.material3.IconButton\nimport androidx.compose.material3.IconButtonDefaults\nimport androidx.compose.material3.MaterialTheme\nimport androidx.compose.material3.Scaffold\nimport androidx.compose.runtime.Composable\nimport androidx.compose.runtime.LaunchedEffect\nimport androidx.compose.runtime.collectAsState\nimport androidx.compose.runtime.getValue\nimport androidx.compose.runtime.mutableIntStateOf\nimport androidx.compose.runtime.mutableStateOf\nimport androidx.compose.runtime.remember\nimport androidx.compose.runtime.rememberCoroutineScope\nimport androidx.compose.runtime.setValue\nimport androidx.compose.ui.Alignment\nimport androidx.compose.ui.Modifier\nimport androidx.compose.ui.graphics.Color\nimport androidx.compose.ui.graphics.asImageBitmap\nimport androidx.compose.ui.platform.LocalContext\nimport androidx.compose.ui.res.stringResource\nimport androidx.compose.ui.unit.dp\nimport com.google.ai.edge.gallery.R\nimport com.google.ai.edge.gallery.data.BuiltInTaskId\nimport com.google.ai.edge.gallery.data.ConfigKeys\nimport com.google.ai.edge.gallery.data.Model\nimport com.google.ai.edge.gallery.data.ModelDownloadStatusType\nimport com.google.ai.edge.gallery.data.Task\nimport com.google.ai.edge.gallery.ui.common.ModelPageAppBar\nimport com.google.ai.edge.gallery.ui.modelmanager.ModelInitializationStatusType\nimport com.google.ai.edge.gallery.ui.modelmanager.ModelManagerViewModel\nimport kotlinx.coroutines.Dispatchers\nimport kotlinx.coroutines.launch\n\nprivate const val TAG = \"AGChatView\"\n\n/**\n * A composable that displays a chat interface, allowing users to interact with different models\n * associated with a given task.\n *\n * This composable provides a horizontal pager for switching between models, a model selector for\n * configuring the selected model, and a chat panel for sending and receiving messages. It also\n * manages model initialization, cleanup, and download status, and handles navigation and system\n * back gestures.\n */\n@Composable\nfun ChatView(\n  task: Task,\n  viewModel: ChatViewModel,\n  modelManagerViewModel: ModelManagerViewModel,\n  onSendMessage: (Model, List<ChatMessage>) -> Unit,\n  onRunAgainClicked: (Model, ChatMessage) -> Unit,\n  onBenchmarkClicked: (Model, ChatMessage, Int, Int) -> Unit,\n  navigateUp: () -> Unit,\n  modifier: Modifier = Modifier,\n  onResetSessionClicked: (Model) -> Unit = {},\n  onStreamImageMessage: (Model, ChatMessageImage) -> Unit = { _, _ -> },\n  onStopButtonClicked: (Model) -> Unit = {},\n  showStopButtonInInputWhenInProgress: Boolean = false,\n  composableBelowMessageList: @Composable (Model) -> Unit = {},\n  emptyStateComposable: @Composable () -> Unit = {},\n  allowEditingSystemPrompt: Boolean = false,\n  curSystemPrompt: String = \"\",\n  onSystemPromptChanged: (String) -> Unit = {},\n  sendMessageTrigger: Pair<Model, List<ChatMessage>>? = null,\n) {\n  val uiState by viewModel.uiState.collectAsState()\n  val modelManagerUiState by modelManagerViewModel.uiState.collectAsState()\n  val selectedModel = modelManagerUiState.selectedModel\n\n  // Image viewer related.\n  var selectedImageIndex by remember { mutableIntStateOf(-1) }\n  var allImageViewerImages by remember { mutableStateOf<List<Bitmap>>(listOf()) }\n  var showImageViewer by remember { mutableStateOf(false) }\n\n  val context = LocalContext.current\n  val scope = rememberCoroutineScope()\n  var navigatingUp by remember { mutableStateOf(false) }\n\n  val handleNavigateUp = {\n    navigatingUp = true\n    navigateUp()\n\n    // clean up all models.\n    scope.launch(Dispatchers.Default) {\n      for (model in task.models) {\n        modelManagerViewModel.cleanupModel(context = context, task = task, model = model)\n      }\n    }\n  }\n\n  // Initialize model when model/download state changes.\n  val curDownloadStatus = modelManagerUiState.modelDownloadStatus[selectedModel.name]\n  LaunchedEffect(curDownloadStatus, selectedModel.name) {\n    if (!navigatingUp) {\n      if (curDownloadStatus?.status == ModelDownloadStatusType.SUCCEEDED) {\n        Log.d(TAG, \"Initializing model '${selectedModel.name}' from ChatView launched effect\")\n        modelManagerViewModel.initializeModel(context, task = task, model = selectedModel)\n      }\n    }\n  }\n\n  LaunchedEffect(sendMessageTrigger) {\n    sendMessageTrigger?.let { trigger -> onSendMessage(trigger.first, trigger.second) }\n  }\n\n  // Handle system's edge swipe.\n  BackHandler {\n    val modelInitializationStatus =\n      modelManagerUiState.modelInitializationStatus[selectedModel.name]\n    val isModelInitializing =\n      modelInitializationStatus?.status == ModelInitializationStatusType.INITIALIZING\n    if (!isModelInitializing && !uiState.inProgress) {\n      handleNavigateUp()\n    }\n  }\n\n  Scaffold(\n    modifier = modifier,\n    topBar = {\n      ModelPageAppBar(\n        task = task,\n        model = selectedModel,\n        modelManagerViewModel = modelManagerViewModel,\n        canShowResetSessionButton = true,\n        isResettingSession = uiState.isResettingSession,\n        inProgress = uiState.inProgress,\n        modelPreparing = uiState.preparing,\n        onResetSessionClicked = onResetSessionClicked,\n        onConfigChanged = { old, new ->\n          // Filter out config values that are not relevant to the task.\n          //\n          // - The \"reset conversation turn count\" is only valid for tiny garden task.\n          val filteredOld = old.toMutableMap()\n          val filteredNew = new.toMutableMap()\n          if (task.id != BuiltInTaskId.LLM_TINY_GARDEN) {\n            filteredOld.remove(ConfigKeys.RESET_CONVERSATION_TURN_COUNT.label)\n            filteredNew.remove(ConfigKeys.RESET_CONVERSATION_TURN_COUNT.label)\n          }\n          viewModel.addConfigChangedMessage(\n            oldConfigValues = filteredOld,\n            newConfigValues = filteredNew,\n            model = selectedModel,\n          )\n        },\n        onBackClicked = { handleNavigateUp() },\n        onModelSelected = { prevModel, curModel ->\n          if (prevModel.name != curModel.name) {\n            modelManagerViewModel.cleanupModel(context = context, task = task, model = prevModel)\n          }\n          modelManagerViewModel.selectModel(model = curModel)\n        },\n        allowEditingSystemPrompt = allowEditingSystemPrompt,\n        curSystemPrompt = curSystemPrompt,\n        onSystemPromptChanged = onSystemPromptChanged,\n      )\n    },\n  ) { innerPadding ->\n    Box {\n      val curModelDownloadStatus = modelManagerUiState.modelDownloadStatus[selectedModel.name]\n\n      composableBelowMessageList(selectedModel)\n\n      Column(modifier = Modifier.fillMaxSize().background(MaterialTheme.colorScheme.surface)) {\n        AnimatedContent(\n          targetState = curModelDownloadStatus?.status == ModelDownloadStatusType.SUCCEEDED\n        ) { targetState ->\n          when (targetState) {\n            // Main UI when model is downloaded.\n            true ->\n              ChatPanel(\n                modelManagerViewModel = modelManagerViewModel,\n                task = task,\n                selectedModel = selectedModel,\n                viewModel = viewModel,\n                innerPadding = innerPadding,\n                navigateUp = navigateUp,\n                onSendMessage = onSendMessage,\n                onRunAgainClicked = onRunAgainClicked,\n                onBenchmarkClicked = onBenchmarkClicked,\n                onStreamImageMessage = onStreamImageMessage,\n                onStreamEnd = { averageFps ->\n                  viewModel.addMessage(\n                    model = selectedModel,\n                    message =\n                      ChatMessageInfo(\n                        content = \"Live camera session ended. Average FPS: $averageFps\"\n                      ),\n                  )\n                },\n                onStopButtonClicked = { onStopButtonClicked(selectedModel) },\n                onImageSelected = { bitmaps, selectedBitmapIndex ->\n                  selectedImageIndex = selectedBitmapIndex\n                  allImageViewerImages = bitmaps\n                  showImageViewer = true\n                },\n                modifier = Modifier.weight(1f),\n                showStopButtonInInputWhenInProgress = showStopButtonInInputWhenInProgress,\n                emptyStateComposable = emptyStateComposable,\n              )\n            // Model download\n            false ->\n              ModelDownloadStatusInfoPanel(\n                model = selectedModel,\n                task = task,\n                modelManagerViewModel = modelManagerViewModel,\n              )\n          }\n        }\n      }\n\n      // Image viewer.\n      AnimatedVisibility(\n        visible = showImageViewer,\n        enter = slideInVertically(initialOffsetY = { fullHeight -> fullHeight }) + fadeIn(),\n        exit = slideOutVertically(targetOffsetY = { fullHeight -> fullHeight }) + fadeOut(),\n      ) {\n        val pagerState =\n          rememberPagerState(\n            pageCount = { allImageViewerImages.size },\n            initialPage = selectedImageIndex,\n          )\n        val scrollEnabled = remember { mutableStateOf(true) }\n        Box(modifier = Modifier.fillMaxSize().padding(top = innerPadding.calculateTopPadding())) {\n          HorizontalPager(\n            state = pagerState,\n            userScrollEnabled = scrollEnabled.value,\n            modifier = Modifier.fillMaxSize().background(Color.Black.copy(alpha = 0.95f)),\n          ) { page ->\n            allImageViewerImages[page].let { image ->\n              ZoomableImage(\n                bitmap = image.asImageBitmap(),\n                pagerState = pagerState,\n                modifier = Modifier.fillMaxSize(),\n              )\n            }\n          }\n\n          // Close button.\n          IconButton(\n            onClick = { showImageViewer = false },\n            colors =\n              IconButtonDefaults.iconButtonColors(\n                containerColor = MaterialTheme.colorScheme.surfaceVariant\n              ),\n            modifier = Modifier.offset(x = (-8).dp, y = 8.dp).align(Alignment.TopEnd),\n          ) {\n            Icon(\n              Icons.Rounded.Close,\n              contentDescription = stringResource(R.string.cd_close_image_viewer_icon),\n              tint = MaterialTheme.colorScheme.onSurfaceVariant,\n            )\n          }\n        }\n      }\n    }\n  }\n}\n"
  },
  {
    "path": "Android/src/app/src/main/java/com/google/ai/edge/gallery/ui/common/chat/ChatViewModel.kt",
    "content": "/*\n * Copyright 2025 Google LLC\n *\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n *     http://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\n\npackage com.google.ai.edge.gallery.ui.common.chat\n\nimport android.util.Log\nimport androidx.compose.ui.graphics.vector.ImageVector\nimport androidx.lifecycle.ViewModel\nimport com.google.ai.edge.gallery.common.processLlmResponse\nimport com.google.ai.edge.gallery.data.ConfigKeys\nimport com.google.ai.edge.gallery.data.Model\nimport kotlinx.coroutines.flow.MutableStateFlow\nimport kotlinx.coroutines.flow.asStateFlow\nimport kotlinx.coroutines.flow.update\n\nprivate const val TAG = \"AGChatViewModel\"\n\ndata class ChatUiState(\n  /** Indicates whether the runtime is currently processing a message. */\n  val inProgress: Boolean = false,\n\n  /** Indicates whether the session is being reset. */\n  val isResettingSession: Boolean = false,\n\n  /**\n   * Indicates whether the model is preparing (before outputting any result and after initializing).\n   */\n  val preparing: Boolean = false,\n\n  /** A map of model names to lists of chat messages. */\n  val messagesByModel: Map<String, MutableList<ChatMessage>> = mapOf(),\n\n  /** A map of model names to the currently streaming chat message. */\n  val streamingMessagesByModel: Map<String, ChatMessage> = mapOf(),\n)\n\n/** ViewModel responsible for managing the chat UI state and handling chat-related operations. */\nabstract class ChatViewModel() : ViewModel() {\n  private val _uiState = MutableStateFlow(createUiState())\n  val uiState = _uiState.asStateFlow()\n\n  fun addMessage(model: Model, message: ChatMessage) {\n    val newMessagesByModel = _uiState.value.messagesByModel.toMutableMap()\n    val newMessages = newMessagesByModel[model.name]?.toMutableList() ?: mutableListOf()\n    newMessagesByModel[model.name] = newMessages\n    // Remove prompt template message if it is the current last message.\n    if (newMessages.size > 0 && newMessages.last().type == ChatMessageType.PROMPT_TEMPLATES) {\n      newMessages.removeAt(newMessages.size - 1)\n    }\n    newMessages.add(message)\n    _uiState.update { _uiState.value.copy(messagesByModel = newMessagesByModel) }\n  }\n\n  fun insertMessageAfter(model: Model, anchorMessage: ChatMessage, messageToAdd: ChatMessage) {\n    val newMessagesByModel = _uiState.value.messagesByModel.toMutableMap()\n    val newMessages = newMessagesByModel[model.name]?.toMutableList() ?: mutableListOf()\n    newMessagesByModel[model.name] = newMessages\n    // Find the index of the anchor message\n    val anchorIndex = newMessages.indexOf(anchorMessage)\n    if (anchorIndex != -1) {\n      // Insert the new message after the anchor message\n      newMessages.add(anchorIndex + 1, messageToAdd)\n    }\n    _uiState.update { _uiState.value.copy(messagesByModel = newMessagesByModel) }\n  }\n\n  fun removeMessageAt(model: Model, index: Int) {\n    val newMessagesByModel = _uiState.value.messagesByModel.toMutableMap()\n    val newMessages = newMessagesByModel[model.name]?.toMutableList()\n    if (newMessages != null) {\n      newMessagesByModel[model.name] = newMessages\n      if (index >= 0 && index < newMessages.size) {\n        newMessages.removeAt(index)\n      }\n    }\n    _uiState.update { _uiState.value.copy(messagesByModel = newMessagesByModel) }\n  }\n\n  fun removeLastMessage(model: Model) {\n    val newMessagesByModel = _uiState.value.messagesByModel.toMutableMap()\n    val newMessages = newMessagesByModel[model.name]?.toMutableList() ?: mutableListOf()\n    if (newMessages.size > 0) {\n      newMessages.removeAt(newMessages.size - 1)\n    }\n    newMessagesByModel[model.name] = newMessages\n    _uiState.update { _uiState.value.copy(messagesByModel = newMessagesByModel) }\n  }\n\n  fun clearAllMessages(model: Model) {\n    val newMessagesByModel = _uiState.value.messagesByModel.toMutableMap()\n    newMessagesByModel[model.name] = mutableListOf()\n    _uiState.update { _uiState.value.copy(messagesByModel = newMessagesByModel) }\n  }\n\n  fun getLastMessage(model: Model): ChatMessage? {\n    return (_uiState.value.messagesByModel[model.name] ?: listOf()).lastOrNull()\n  }\n\n  fun getLastMessageWithType(model: Model, type: ChatMessageType): ChatMessage? {\n    return (_uiState.value.messagesByModel[model.name] ?: listOf()).lastOrNull { it.type == type }\n  }\n\n  fun getLastMessageWithTypeAndSide(\n    model: Model,\n    type: ChatMessageType,\n    side: ChatSide,\n  ): ChatMessage? {\n    return (_uiState.value.messagesByModel[model.name] ?: listOf()).lastOrNull {\n      it.type == type && it.side == side\n    }\n  }\n\n  fun updateLastTextMessageContentIncrementally(\n    model: Model,\n    partialContent: String,\n    latencyMs: Float,\n  ) {\n    val newMessagesByModel = _uiState.value.messagesByModel.toMutableMap()\n    val newMessages = newMessagesByModel[model.name]?.toMutableList() ?: mutableListOf()\n    if (newMessages.isNotEmpty()) {\n      val lastMessage = newMessages.last()\n      if (lastMessage is ChatMessageText) {\n        val newContent = processLlmResponse(response = \"${lastMessage.content}${partialContent}\")\n        val newLastMessage =\n          ChatMessageText(\n            content = newContent,\n            side = lastMessage.side,\n            latencyMs = latencyMs,\n            accelerator = lastMessage.accelerator,\n            hideSenderLabel = lastMessage.hideSenderLabel,\n          )\n        newMessages.removeAt(newMessages.size - 1)\n        newMessages.add(newLastMessage)\n      }\n    }\n    newMessagesByModel[model.name] = newMessages\n    val newUiState = _uiState.value.copy(messagesByModel = newMessagesByModel)\n    _uiState.update { newUiState }\n  }\n\n  fun updateLastTextMessageLlmBenchmarkResult(\n    model: Model,\n    llmBenchmarkResult: ChatMessageBenchmarkLlmResult,\n  ) {\n    val newMessagesByModel = _uiState.value.messagesByModel.toMutableMap()\n    val newMessages = newMessagesByModel[model.name]?.toMutableList() ?: mutableListOf()\n    if (newMessages.size > 0) {\n      val lastMessage = newMessages.last()\n      if (lastMessage is ChatMessageText) {\n        lastMessage.llmBenchmarkResult = llmBenchmarkResult\n        newMessages.removeAt(newMessages.size - 1)\n        newMessages.add(lastMessage)\n      }\n    }\n    newMessagesByModel[model.name] = newMessages\n    val newUiState = _uiState.value.copy(messagesByModel = newMessagesByModel)\n    _uiState.update { newUiState }\n  }\n\n  fun replaceLastMessage(model: Model, message: ChatMessage, type: ChatMessageType) {\n    val newMessagesByModel = _uiState.value.messagesByModel.toMutableMap()\n    val newMessages = newMessagesByModel[model.name]?.toMutableList() ?: mutableListOf()\n    if (newMessages.size > 0) {\n      val index = newMessages.indexOfLast { it.type == type }\n      if (index >= 0) {\n        newMessages[index] = message\n      }\n    }\n    newMessagesByModel[model.name] = newMessages\n    val newUiState = _uiState.value.copy(messagesByModel = newMessagesByModel)\n    _uiState.update { newUiState }\n  }\n\n  fun replaceMessage(model: Model, index: Int, message: ChatMessage) {\n    val newMessagesByModel = _uiState.value.messagesByModel.toMutableMap()\n    val newMessages = newMessagesByModel[model.name]?.toMutableList() ?: mutableListOf()\n    if (index >= 0 && index < newMessages.size) {\n      newMessages[index] = message\n    }\n    newMessagesByModel[model.name] = newMessages\n    val newUiState = _uiState.value.copy(messagesByModel = newMessagesByModel)\n    _uiState.update { newUiState }\n  }\n\n  fun updateStreamingMessage(model: Model, message: ChatMessage) {\n    val newStreamingMessagesByModel = _uiState.value.streamingMessagesByModel.toMutableMap()\n    newStreamingMessagesByModel[model.name] = message\n    _uiState.update { _uiState.value.copy(streamingMessagesByModel = newStreamingMessagesByModel) }\n  }\n\n  fun updateCollapsableProgressPanelMessage(\n    model: Model,\n    title: String,\n    inProgress: Boolean,\n    doneIcon: ImageVector,\n    addItemTitle: String,\n    addItemDescription: String,\n    customData: Any? = null,\n  ) {\n    val accelerator = model.getStringConfigValue(key = ConfigKeys.ACCELERATOR, defaultValue = \"\")\n    val newMessagesByModel = _uiState.value.messagesByModel.toMutableMap()\n    val newMessages = newMessagesByModel[model.name]?.toMutableList() ?: mutableListOf()\n    if (newMessages.isNotEmpty()) {\n      val lastMessage = newMessages.last()\n      // If the last message is a loading message, replace it with a collapsable progress message.\n      if (lastMessage is ChatMessageLoading) {\n        newMessages.removeAt(newMessages.size - 1)\n        val newCollapsableMessage =\n          ChatMessageCollapsableProgressPanel(\n            title = title,\n            inProgress = inProgress,\n            doneIcon = doneIcon,\n            items =\n              if (addItemTitle.isNotEmpty()) {\n                listOf(ProgressPanelItem(title = addItemTitle, description = addItemDescription))\n              } else {\n                listOf()\n              },\n            accelerator = accelerator,\n            customData = customData,\n          )\n        newMessages.add(newCollapsableMessage)\n      }\n      // If the last message is not a loading message...\n      else {\n        val lastProgressPanelMessage =\n          getLastMessageWithType(model = model, type = ChatMessageType.COLLAPSABLE_PROGRESS_PANEL)\n        val lastProgressPanelMessageIndex = newMessages.indexOf(lastProgressPanelMessage)\n        val lastUserTextMessage =\n          getLastMessageWithTypeAndSide(\n            model = model,\n            type = ChatMessageType.TEXT,\n            side = ChatSide.USER,\n          )\n        val lastUserTextMessageIndex = newMessages.indexOf(lastUserTextMessage)\n        // If the last user text message is after the last progress panel message, insert the new\n        // collapsable message after the last user text message.\n        if (\n          lastProgressPanelMessage != null &&\n            lastUserTextMessage != null &&\n            lastUserTextMessageIndex > lastProgressPanelMessageIndex\n        ) {\n          val newCollapsableMessage =\n            ChatMessageCollapsableProgressPanel(\n              title = title,\n              inProgress = inProgress,\n              doneIcon = doneIcon,\n              items =\n                if (addItemTitle.isNotEmpty()) {\n                  listOf(ProgressPanelItem(title = addItemTitle, description = addItemDescription))\n                } else {\n                  listOf()\n                },\n              accelerator = accelerator,\n              customData = customData,\n            )\n          // Insert the new collapsable message after the last user text message.\n          newMessages.add(lastUserTextMessageIndex + 1, newCollapsableMessage)\n        }\n        // If the last progress panel message is a collapsable progress panel, update it.\n        else if (\n          lastProgressPanelMessage != null &&\n            lastProgressPanelMessage is ChatMessageCollapsableProgressPanel\n        ) {\n          val updatedMessage =\n            ChatMessageCollapsableProgressPanel(\n              title = title,\n              accelerator = accelerator,\n              inProgress = inProgress,\n              doneIcon = doneIcon,\n              items =\n                lastProgressPanelMessage.items +\n                  if (addItemTitle.isNotEmpty()) {\n                    listOf(\n                      ProgressPanelItem(title = addItemTitle, description = addItemDescription)\n                    )\n                  } else {\n                    listOf()\n                  },\n              customData = lastProgressPanelMessage.customData,\n            )\n          newMessages[lastProgressPanelMessageIndex] = updatedMessage\n        }\n      }\n    }\n    newMessagesByModel[model.name] = newMessages\n    _uiState.update { _uiState.value.copy(messagesByModel = newMessagesByModel) }\n  }\n\n  fun setInProgress(inProgress: Boolean) {\n    _uiState.update { _uiState.value.copy(inProgress = inProgress) }\n  }\n\n  fun setIsResettingSession(isResettingSession: Boolean) {\n    _uiState.update { _uiState.value.copy(isResettingSession = isResettingSession) }\n  }\n\n  fun setPreparing(preparing: Boolean) {\n    _uiState.update { _uiState.value.copy(preparing = preparing) }\n  }\n\n  fun addConfigChangedMessage(\n    oldConfigValues: Map<String, Any>,\n    newConfigValues: Map<String, Any>,\n    model: Model,\n  ) {\n    Log.d(TAG, \"Adding config changed message. Old: ${oldConfigValues}, new: $newConfigValues\")\n    val message =\n      ChatMessageConfigValuesChange(\n        model = model,\n        oldValues = oldConfigValues,\n        newValues = newConfigValues,\n      )\n    addMessage(message = message, model = model)\n  }\n\n  fun getMessageIndex(model: Model, message: ChatMessage): Int {\n    return (_uiState.value.messagesByModel[model.name] ?: listOf()).indexOf(message)\n  }\n\n  private fun createUiState(): ChatUiState {\n    return ChatUiState()\n  }\n}\n"
  },
  {
    "path": "Android/src/app/src/main/java/com/google/ai/edge/gallery/ui/common/chat/DataCard.kt",
    "content": "/*\n * Copyright 2025 Google LLC\n *\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n *     http://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\n\npackage com.google.ai.edge.gallery.ui.common.chat\n\nimport androidx.compose.foundation.layout.Arrangement\nimport androidx.compose.foundation.layout.Column\nimport androidx.compose.foundation.layout.Row\nimport androidx.compose.foundation.layout.offset\nimport androidx.compose.foundation.layout.padding\nimport androidx.compose.material3.MaterialTheme\nimport androidx.compose.material3.Text\nimport androidx.compose.runtime.Composable\nimport androidx.compose.ui.Modifier\nimport androidx.compose.ui.draw.alpha\nimport androidx.compose.ui.semantics.isTraversalGroup\nimport androidx.compose.ui.semantics.semantics\nimport androidx.compose.ui.tooling.preview.Preview\nimport androidx.compose.ui.unit.dp\nimport com.google.ai.edge.gallery.ui.theme.GalleryTheme\nimport com.google.ai.edge.gallery.ui.theme.bodySmallMediumNarrow\nimport com.google.ai.edge.gallery.ui.theme.bodySmallMediumNarrowBold\nimport com.google.ai.edge.gallery.ui.theme.labelSmallNarrow\nimport com.google.ai.edge.gallery.ui.theme.labelSmallNarrowMedium\n\n/**\n * Composable function to display a data card with a label and a numeric value.\n *\n * This function renders a column containing a label and a formatted numeric value. It provides\n * options for highlighting the value and displaying a placeholder when the value is not available.\n */\n@Composable\nfun DataCard(\n  label: String,\n  value: Float?,\n  unit: String,\n  highlight: Boolean = false,\n  showPlaceholder: Boolean = false,\n) {\n  var strValue = \"-\"\n  Column(modifier = Modifier.semantics { isTraversalGroup = true }) {\n    Text(label, style = labelSmallNarrowMedium)\n    if (showPlaceholder) {\n      Text(\"-\", style = bodySmallMediumNarrow)\n    } else {\n      strValue = if (value == null) \"-\" else \"%.2f\".format(value)\n      if (highlight) {\n        Text(strValue, style = bodySmallMediumNarrowBold, color = MaterialTheme.colorScheme.primary)\n      } else {\n        Text(strValue, style = bodySmallMediumNarrow)\n      }\n    }\n    if (strValue != \"-\") {\n      Text(unit, style = labelSmallNarrow, modifier = Modifier.alpha(0.5f).offset(y = (-1).dp))\n    }\n  }\n}\n\n@Preview(showBackground = true)\n@Composable\nfun DataCardPreview() {\n  GalleryTheme {\n    Row(modifier = Modifier.padding(16.dp), horizontalArrangement = Arrangement.spacedBy(16.dp)) {\n      DataCard(\n        label = \"sum\",\n        value = 123.45f,\n        unit = \"ms\",\n        highlight = true,\n        showPlaceholder = false,\n      )\n      DataCard(\n        label = \"average\",\n        value = 12.3f,\n        unit = \"ms\",\n        highlight = false,\n        showPlaceholder = false,\n      )\n      DataCard(\n        label = \"test\",\n        value = null,\n        unit = \"ms\",\n        highlight = false,\n        showPlaceholder = false,\n      )\n    }\n  }\n}\n"
  },
  {
    "path": "Android/src/app/src/main/java/com/google/ai/edge/gallery/ui/common/chat/MessageActionButton.kt",
    "content": "/*\n * Copyright 2025 Google LLC\n *\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n *     http://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\n\npackage com.google.ai.edge.gallery.ui.common.chat\n\n// import androidx.compose.ui.tooling.preview.Preview\n// import com.google.ai.edge.gallery.ui.theme.GalleryTheme\nimport androidx.compose.foundation.background\nimport androidx.compose.foundation.clickable\nimport androidx.compose.foundation.layout.Row\nimport androidx.compose.foundation.layout.offset\nimport androidx.compose.foundation.layout.padding\nimport androidx.compose.foundation.layout.size\nimport androidx.compose.foundation.shape.CircleShape\nimport androidx.compose.material3.Icon\nimport androidx.compose.material3.MaterialTheme\nimport androidx.compose.material3.Text\nimport androidx.compose.runtime.Composable\nimport androidx.compose.ui.Alignment\nimport androidx.compose.ui.Modifier\nimport androidx.compose.ui.draw.alpha\nimport androidx.compose.ui.draw.clip\nimport androidx.compose.ui.graphics.vector.ImageVector\nimport androidx.compose.ui.unit.dp\nimport com.google.ai.edge.gallery.ui.theme.bodySmallNarrow\n\n/** Composable function to display an action button below a chat message. */\n@Composable\nfun MessageActionButton(\n  label: String,\n  icon: ImageVector,\n  onClick: () -> Unit,\n  modifier: Modifier = Modifier,\n  enabled: Boolean = true,\n) {\n  val curModifier =\n    modifier\n      .padding(top = 4.dp)\n      .clip(CircleShape)\n      .background(\n        if (enabled) MaterialTheme.colorScheme.secondaryContainer\n        else MaterialTheme.colorScheme.surfaceContainerHigh\n      )\n  val alpha: Float = if (enabled) 1.0f else 0.3f\n  Row(\n    modifier = if (enabled) curModifier.clickable { onClick() } else modifier,\n    verticalAlignment = Alignment.CenterVertically,\n  ) {\n    Icon(\n      icon,\n      contentDescription = null,\n      modifier = Modifier.size(16.dp).offset(x = 6.dp).alpha(alpha),\n    )\n    Text(\n      label,\n      color = MaterialTheme.colorScheme.onSecondaryContainer,\n      style = bodySmallNarrow,\n      modifier = Modifier.padding(start = 10.dp, end = 8.dp, top = 4.dp, bottom = 4.dp).alpha(alpha),\n    )\n  }\n}\n\n// @Preview(showBackground = true)\n// @Composable\n// fun MessageActionButtonPreview() {\n//   GalleryTheme {\n//     Column {\n//       MessageActionButton(label = \"run\", icon = Icons.Default.PlayArrow, onClick = {})\n//       MessageActionButton(\n//         label = \"run\",\n//         icon = Icons.Default.PlayArrow,\n//         enabled = false,\n//         onClick = {})\n//     }\n//   }\n// }\n"
  },
  {
    "path": "Android/src/app/src/main/java/com/google/ai/edge/gallery/ui/common/chat/MessageBodyAudioClip.kt",
    "content": "/*\n * Copyright 2025 Google LLC\n *\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n *     http://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\n\npackage com.google.ai.edge.gallery.ui.common.chat\n\nimport androidx.compose.foundation.layout.padding\nimport androidx.compose.runtime.Composable\nimport androidx.compose.ui.Modifier\nimport androidx.compose.ui.unit.dp\n\n@Composable\nfun MessageBodyAudioClip(message: ChatMessageAudioClip, modifier: Modifier = Modifier) {\n  AudioPlaybackPanel(\n    audioData = message.audioData,\n    sampleRate = message.sampleRate,\n    isRecording = false,\n    modifier = Modifier.padding(end = 16.dp),\n    onDarkBg = true,\n  )\n}\n"
  },
  {
    "path": "Android/src/app/src/main/java/com/google/ai/edge/gallery/ui/common/chat/MessageBodyBenchmark.kt",
    "content": "/*\n * Copyright 2025 Google LLC\n *\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n *     http://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\n\npackage com.google.ai.edge.gallery.ui.common.chat\n\n// import androidx.compose.ui.tooling.preview.Preview\n// import com.google.ai.edge.gallery.ui.theme.GalleryTheme\nimport androidx.compose.foundation.background\nimport androidx.compose.foundation.layout.Arrangement\nimport androidx.compose.foundation.layout.Box\nimport androidx.compose.foundation.layout.Column\nimport androidx.compose.foundation.layout.Row\nimport androidx.compose.foundation.layout.fillMaxWidth\nimport androidx.compose.foundation.layout.height\nimport androidx.compose.foundation.layout.padding\nimport androidx.compose.foundation.layout.width\nimport androidx.compose.foundation.shape.RoundedCornerShape\nimport androidx.compose.material3.MaterialTheme\nimport androidx.compose.runtime.Composable\nimport androidx.compose.ui.Modifier\nimport androidx.compose.ui.draw.alpha\nimport androidx.compose.ui.draw.clip\nimport androidx.compose.ui.unit.dp\nimport kotlin.math.max\n\nprivate const val DEFAULT_HISTOGRAM_BAR_HEIGHT = 50f\n\n/**\n * Composable function to display benchmark results within a chat message.\n *\n * This function renders benchmark statistics (e.g., average latency) in data cards and visualizes\n * the latency distribution using a histogram.\n */\n@Composable\nfun MessageBodyBenchmark(message: ChatMessageBenchmarkResult) {\n  Column(\n    modifier = Modifier.padding(12.dp).fillMaxWidth(),\n    verticalArrangement = Arrangement.spacedBy(8.dp),\n  ) {\n    // Data cards.\n    Row(modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.SpaceBetween) {\n      for (stat in message.orderedStats) {\n        DataCard(\n          label = stat.label,\n          unit = stat.unit,\n          value = message.statValues[stat.id],\n          highlight = stat.id == message.highlightStat,\n          showPlaceholder = message.isWarmingUp(),\n        )\n      }\n    }\n\n    // Histogram\n    if (message.histogram.buckets.isNotEmpty()) {\n      Row(horizontalArrangement = Arrangement.spacedBy(2.dp)) {\n        for ((index, count) in message.histogram.buckets.withIndex()) {\n          var barBgColor = MaterialTheme.colorScheme.onSurfaceVariant\n          var alpha = 0.3f\n          if (count != 0) {\n            alpha = 0.5f\n          }\n          if (index == message.histogram.highlightBucketIndex) {\n            barBgColor = MaterialTheme.colorScheme.primary\n            alpha = 0.8f\n          }\n          // Bar container.\n          Column(\n            modifier = Modifier.height(DEFAULT_HISTOGRAM_BAR_HEIGHT.dp).width(4.dp),\n            verticalArrangement = Arrangement.Bottom,\n          ) {\n            // Bar content.\n            Box(\n              modifier =\n                Modifier.height(\n                    max(\n                        1f,\n                        count.toFloat() / message.histogram.maxCount.toFloat() *\n                          DEFAULT_HISTOGRAM_BAR_HEIGHT,\n                      )\n                      .dp\n                  )\n                  .fillMaxWidth()\n                  .clip(RoundedCornerShape(20.dp, 20.dp, 0.dp, 0.dp))\n                  .alpha(alpha)\n                  .background(barBgColor)\n            )\n          }\n        }\n      }\n    }\n  }\n}\n\n// @Preview(showBackground = true)\n// @Composable\n// fun MessageBodyBenchmarkPreview() {\n//   GalleryTheme {\n//     MessageBodyBenchmark(\n//       message = ChatMessageBenchmarkResult(\n//         orderedStats = listOf(\n//           Stat(id = \"stat1\", label = \"Stat1\", unit = \"ms\"),\n//           Stat(id = \"stat2\", label = \"Stat2\", unit = \"ms\"),\n//           Stat(id = \"stat3\", label = \"Stat3\", unit = \"ms\"),\n//           Stat(id = \"stat4\", label = \"Stat4\", unit = \"ms\")\n//         ),\n//         statValues = mutableMapOf(\n//           \"stat1\" to 0.3f,\n//           \"stat2\" to 0.4f,\n//           \"stat3\" to 0.5f,\n//         ),\n//         values = listOf(),\n//         histogram = Histogram(listOf(), 0),\n//         warmupCurrent = 0,\n//         warmupTotal = 0,\n//         iterationCurrent = 0,\n//         iterationTotal = 0,\n//         highlightStat = \"stat2\"\n//       )\n//     )\n//   }\n// }\n"
  },
  {
    "path": "Android/src/app/src/main/java/com/google/ai/edge/gallery/ui/common/chat/MessageBodyBenchmarkLlm.kt",
    "content": "/*\n * Copyright 2025 Google LLC\n *\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n *     http://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\n\npackage com.google.ai.edge.gallery.ui.common.chat\n\n// import androidx.compose.ui.tooling.preview.Preview\n// import com.google.ai.edge.gallery.ui.theme.GalleryTheme\nimport androidx.compose.foundation.layout.Arrangement\nimport androidx.compose.foundation.layout.Column\nimport androidx.compose.foundation.layout.Row\nimport androidx.compose.foundation.layout.fillMaxWidth\nimport androidx.compose.foundation.layout.padding\nimport androidx.compose.runtime.Composable\nimport androidx.compose.ui.Modifier\nimport androidx.compose.ui.unit.dp\n\n/**\n * Composable function to display benchmark LLM results within a chat message.\n *\n * This function renders benchmark statistics (e.g., various token speed) in data cards\n */\n@Composable\nfun MessageBodyBenchmarkLlm(message: ChatMessageBenchmarkLlmResult, modifier: Modifier = Modifier) {\n  Column(modifier = modifier.padding(12.dp), verticalArrangement = Arrangement.spacedBy(8.dp)) {\n    // Data cards.\n    Row(modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.SpaceBetween) {\n      for (stat in message.orderedStats) {\n        DataCard(label = stat.label, unit = stat.unit, value = message.statValues[stat.id])\n      }\n    }\n  }\n}\n\n// @Preview(showBackground = true)\n// @Composable\n// fun MessageBodyBenchmarkLlmPreview() {\n//   GalleryTheme {\n//     MessageBodyBenchmarkLlm(\n//       message = ChatMessageBenchmarkLlmResult(\n//         orderedStats = listOf(\n//           Stat(id = \"stat1\", label = \"Stat1\", unit = \"tokens/s\"),\n//           Stat(id = \"stat2\", label = \"Stat2\", unit = \"tokens/s\")\n//         ),\n//         statValues = mutableMapOf(\n//           \"stat1\" to 0.3f,\n//           \"stat2\" to 0.4f,\n//         ),\n//         running = false,\n//       )\n//     )\n//   }\n// }\n"
  },
  {
    "path": "Android/src/app/src/main/java/com/google/ai/edge/gallery/ui/common/chat/MessageBodyClassification.kt",
    "content": "/*\n * Copyright 2025 Google LLC\n *\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n *     http://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\n\npackage com.google.ai.edge.gallery.ui.common.chat\n\n// import androidx.compose.ui.tooling.preview.Preview\n// import com.google.ai.edge.gallery.ui.theme.GalleryTheme\nimport androidx.compose.foundation.background\nimport androidx.compose.foundation.layout.Arrangement\nimport androidx.compose.foundation.layout.Box\nimport androidx.compose.foundation.layout.Column\nimport androidx.compose.foundation.layout.Row\nimport androidx.compose.foundation.layout.Spacer\nimport androidx.compose.foundation.layout.fillMaxWidth\nimport androidx.compose.foundation.layout.height\nimport androidx.compose.foundation.layout.padding\nimport androidx.compose.foundation.shape.CircleShape\nimport androidx.compose.material3.MaterialTheme\nimport androidx.compose.material3.Text\nimport androidx.compose.runtime.Composable\nimport androidx.compose.ui.Alignment\nimport androidx.compose.ui.Modifier\nimport androidx.compose.ui.draw.clip\nimport androidx.compose.ui.text.style.TextOverflow\nimport androidx.compose.ui.unit.dp\n\nval CLASSIFICATION_BAR_HEIGHT = 8.dp\nval CLASSIFICATION_BAR_MAX_WIDTH = 200.dp\n\n/**\n * Composable function to display classification results.\n *\n * This function renders a list of classifications, each with its label, score, and a visual score\n * bar.\n */\n@Composable\nfun MessageBodyClassification(\n  message: ChatMessageClassification,\n  modifier: Modifier = Modifier,\n  oneLineLabel: Boolean = false,\n) {\n  Column(modifier = modifier.padding(12.dp)) {\n    for (classification in message.classifications) {\n      Row(modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.SpaceBetween) {\n        // Classification label.\n        Text(\n          classification.label,\n          maxLines = if (oneLineLabel) 1 else Int.MAX_VALUE,\n          overflow = TextOverflow.Ellipsis,\n          style = MaterialTheme.typography.bodySmall,\n          modifier = Modifier.weight(1f),\n        )\n        // Classification score.\n        Text(\n          \"%.2f\".format(classification.score),\n          style = MaterialTheme.typography.bodySmall,\n          modifier = Modifier.align(Alignment.Bottom),\n        )\n      }\n      Spacer(modifier = Modifier.height(2.dp))\n      // Score bar.\n      Box {\n        Box(\n          modifier =\n            Modifier.fillMaxWidth()\n              .height(CLASSIFICATION_BAR_HEIGHT)\n              .clip(CircleShape)\n              .background(MaterialTheme.colorScheme.surfaceDim)\n        )\n        Box(\n          modifier =\n            Modifier.fillMaxWidth(classification.score)\n              .height(CLASSIFICATION_BAR_HEIGHT)\n              .clip(CircleShape)\n              .background(classification.color)\n        )\n      }\n      Spacer(modifier = Modifier.height(6.dp))\n    }\n  }\n}\n\n// @Preview(showBackground = true)\n// @Composable\n// fun MessageBodyClassificationPreview() {\n//   GalleryTheme {\n//     MessageBodyClassification(\n//       message =\n//         ChatMessageClassification(\n//           classifications =\n//             listOf(\n//               Classification(label = \"label1\", score = 0.3f, color = Color.Red),\n//               Classification(label = \"label2\", score = 0.7f, color = Color.Blue),\n//             ),\n//           latencyMs = 12345f,\n//         )\n//     )\n//   }\n// }\n"
  },
  {
    "path": "Android/src/app/src/main/java/com/google/ai/edge/gallery/ui/common/chat/MessageBodyCollapsableProgressPanel.kt",
    "content": "/*\n * Copyright 2026 Google LLC\n *\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n *     http://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\n\npackage com.google.ai.edge.gallery.ui.common.chat\n\nimport androidx.compose.animation.AnimatedContent\nimport androidx.compose.animation.AnimatedVisibility\nimport androidx.compose.animation.expandVertically\nimport androidx.compose.animation.fadeIn\nimport androidx.compose.animation.fadeOut\nimport androidx.compose.animation.shrinkVertically\nimport androidx.compose.animation.slideInVertically\nimport androidx.compose.animation.slideOutVertically\nimport androidx.compose.animation.togetherWith\nimport androidx.compose.foundation.background\nimport androidx.compose.foundation.clickable\nimport androidx.compose.foundation.layout.Arrangement\nimport androidx.compose.foundation.layout.Box\nimport androidx.compose.foundation.layout.Column\nimport androidx.compose.foundation.layout.Row\nimport androidx.compose.foundation.layout.fillMaxWidth\nimport androidx.compose.foundation.layout.heightIn\nimport androidx.compose.foundation.layout.padding\nimport androidx.compose.foundation.layout.size\nimport androidx.compose.foundation.rememberScrollState\nimport androidx.compose.foundation.shape.CircleShape\nimport androidx.compose.foundation.shape.RoundedCornerShape\nimport androidx.compose.foundation.verticalScroll\nimport androidx.compose.material.icons.Icons\nimport androidx.compose.material.icons.filled.KeyboardArrowDown\nimport androidx.compose.material.icons.filled.KeyboardArrowUp\nimport androidx.compose.material3.CircularProgressIndicator\nimport androidx.compose.material3.Icon\nimport androidx.compose.material3.MaterialTheme\nimport androidx.compose.material3.Text\nimport androidx.compose.runtime.Composable\nimport androidx.compose.runtime.getValue\nimport androidx.compose.runtime.mutableStateOf\nimport androidx.compose.runtime.remember\nimport androidx.compose.runtime.setValue\nimport androidx.compose.ui.Alignment\nimport androidx.compose.ui.Modifier\nimport androidx.compose.ui.draw.clip\nimport androidx.compose.ui.platform.LocalDensity\nimport androidx.compose.ui.unit.dp\n\nprivate const val MAX_DESCRIPTION_LINES = 5\n\n/**\n * A Composable function that displays a rounded rectangle panel with a title and a collapsable\n * section.\n */\n@Composable\nfun MessageBodyCollapsableProgressPanel(message: ChatMessageCollapsableProgressPanel) {\n  var isExpanded by remember { mutableStateOf(false) }\n\n  Column(\n    modifier =\n      Modifier.background(MaterialTheme.colorScheme.surfaceContainerHigh)\n        .clickable { isExpanded = !isExpanded }\n        .fillMaxWidth()\n  ) {\n    // Header Row: Contains the title and the expand/collapse button\n    Row(\n      modifier = Modifier.fillMaxWidth().padding(16.dp),\n      verticalAlignment = Alignment.CenterVertically,\n      horizontalArrangement = Arrangement.SpaceBetween,\n    ) {\n      Row(\n        modifier = Modifier.weight(1f),\n        verticalAlignment = Alignment.CenterVertically,\n        horizontalArrangement = Arrangement.spacedBy(12.dp),\n      ) {\n        // Spinner on the most left when loading\n        Box(contentAlignment = Alignment.Center, modifier = Modifier.size(24.dp)) {\n          if (message.inProgress) {\n            CircularProgressIndicator(\n              modifier = Modifier.size(16.dp),\n              strokeWidth = 2.dp,\n              color = MaterialTheme.colorScheme.onSurfaceVariant,\n            )\n          } else {\n            Icon(message.doneIcon, contentDescription = null, modifier = Modifier.size(24.dp))\n          }\n        }\n\n        Box(modifier = Modifier.weight(1f), contentAlignment = Alignment.CenterStart) {\n          // Title.\n          AnimatedContent(\n            targetState = message.title,\n            transitionSpec = {\n              slideInVertically { it } + fadeIn() togetherWith\n                slideOutVertically { -it } + fadeOut()\n            },\n          ) { curTitle ->\n            Text(text = curTitle, style = MaterialTheme.typography.labelLarge)\n          }\n        }\n      }\n\n      // Expand/Collapse Button on the right side\n      Icon(\n        imageVector =\n          if (isExpanded) Icons.Filled.KeyboardArrowUp else Icons.Filled.KeyboardArrowDown,\n        contentDescription = if (isExpanded) \"Collapse panel\" else \"Expand panel\",\n      )\n    }\n\n    // Collapsable Content: Shown only when isExpanded is true\n    AnimatedVisibility(\n      visible = isExpanded,\n      enter = expandVertically(),\n      exit = shrinkVertically(),\n    ) {\n      Column(\n        modifier = Modifier.padding(horizontal = 16.dp).padding(bottom = 16.dp),\n        verticalArrangement = Arrangement.spacedBy(12.dp),\n      ) {\n        for (item in message.items) {\n          Row(\n            modifier =\n              Modifier.clip(shape = RoundedCornerShape(12.dp))\n                .background(MaterialTheme.colorScheme.surfaceContainerLow)\n                .padding(12.dp)\n                .fillMaxWidth(),\n            verticalAlignment = Alignment.CenterVertically,\n            horizontalArrangement = Arrangement.spacedBy(12.dp),\n          ) {\n            // A colored dot.\n            Box(\n              modifier =\n                Modifier.size(12.dp)\n                  .clip(shape = CircleShape)\n                  .background(MaterialTheme.colorScheme.secondaryContainer)\n            )\n            Column() {\n              // Title.\n              Text(\n                item.title,\n                style = MaterialTheme.typography.labelMedium,\n                modifier = Modifier.padding(bottom = 2.dp),\n              )\n\n              // Description.\n              if (item.description.isNotEmpty()) {\n                val density = LocalDensity.current\n                val maxHeight =\n                  with(density) {\n                    (MaterialTheme.typography.labelMedium.lineHeight * MAX_DESCRIPTION_LINES).toDp()\n                  }\n                Text(\n                  item.description,\n                  style = MaterialTheme.typography.bodySmall,\n                  color = MaterialTheme.colorScheme.onSurfaceVariant,\n                  modifier =\n                    Modifier.heightIn(max = maxHeight).verticalScroll(rememberScrollState()),\n                )\n              }\n            }\n          }\n        }\n      }\n    }\n  }\n}\n"
  },
  {
    "path": "Android/src/app/src/main/java/com/google/ai/edge/gallery/ui/common/chat/MessageBodyConfigUpdate.kt",
    "content": "/*\n * Copyright 2025 Google LLC\n *\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n *     http://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\n\npackage com.google.ai.edge.gallery.ui.common.chat\n\nimport androidx.compose.foundation.background\nimport androidx.compose.foundation.layout.Arrangement\nimport androidx.compose.foundation.layout.Box\nimport androidx.compose.foundation.layout.Column\nimport androidx.compose.foundation.layout.Row\nimport androidx.compose.foundation.layout.Spacer\nimport androidx.compose.foundation.layout.fillMaxWidth\nimport androidx.compose.foundation.layout.padding\nimport androidx.compose.foundation.layout.width\nimport androidx.compose.foundation.layout.widthIn\nimport androidx.compose.foundation.shape.RoundedCornerShape\nimport androidx.compose.material3.MaterialTheme\nimport androidx.compose.material3.Text\nimport androidx.compose.runtime.Composable\nimport androidx.compose.runtime.remember\nimport androidx.compose.ui.Modifier\nimport androidx.compose.ui.draw.alpha\nimport androidx.compose.ui.draw.clip\nimport androidx.compose.ui.platform.LocalDensity\nimport androidx.compose.ui.platform.LocalWindowInfo\nimport androidx.compose.ui.text.buildAnnotatedString\nimport androidx.compose.ui.text.font.FontWeight\nimport androidx.compose.ui.text.style.TextOverflow\nimport androidx.compose.ui.text.withStyle\nimport androidx.compose.ui.unit.dp\nimport androidx.compose.ui.unit.sp\nimport com.google.ai.edge.gallery.data.convertValueToTargetType\nimport com.google.ai.edge.gallery.data.getConfigValueString\nimport com.google.ai.edge.gallery.ui.theme.bodySmallNarrow\nimport com.google.ai.edge.gallery.ui.theme.titleSmaller\n\nprivate data class ConfigRowData(\n  val label: String,\n  val oldValueDisplay: String,\n  val newValueDisplay: String,\n  val isChanged: Boolean,\n)\n\n/**\n * Composable function to display a message indicating configuration value changes.\n *\n * This function renders a centered row containing a box that displays the old and new values of\n * configuration settings that have been updated.\n */\n@Composable\nfun MessageBodyConfigUpdate(message: ChatMessageConfigValuesChange) {\n  val density = LocalDensity.current\n  val windowInfo = LocalWindowInfo.current\n  val screenWidthDp = remember { with(density) { windowInfo.containerSize.width.toDp() } }\n\n  val configRows =\n    remember(message) {\n      val oldValues = message.oldValues\n      val newValues = message.newValues\n      val commonKeys = oldValues.keys.intersect(newValues.keys)\n      commonKeys.mapNotNull { key ->\n        val config =\n          message.model.configs.firstOrNull { it.key.label == key } ?: return@mapNotNull null\n        val oldValue: Any =\n          convertValueToTargetType(\n            value = message.oldValues.getValue(key),\n            valueType = config.valueType,\n          )\n        val newValue: Any =\n          convertValueToTargetType(\n            value = message.newValues.getValue(key),\n            valueType = config.valueType,\n          )\n\n        val isChanged = oldValue != newValue\n        val oldValueDisplay = getConfigValueString(oldValue, config)\n        val newValueDisplay = getConfigValueString(newValue, config)\n\n        ConfigRowData(key, oldValueDisplay, newValueDisplay, isChanged)\n      }\n    }\n\n  Row(modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.Center) {\n    Box(\n      modifier =\n        Modifier.clip(RoundedCornerShape(4.dp))\n          .background(MaterialTheme.colorScheme.tertiaryContainer)\n    ) {\n      Column(modifier = Modifier.padding(8.dp)) {\n        // Title.\n        Text(\n          \"Configs updated\",\n          color = MaterialTheme.colorScheme.onTertiaryContainer,\n          style = titleSmaller,\n        )\n\n        Row(modifier = Modifier.padding(top = 8.dp)) {\n          // Keys\n          Column(modifier = Modifier.widthIn(max = screenWidthDp / 2)) {\n            for (rowData in configRows) {\n              Text(\n                \"${rowData.label}:\",\n                style = bodySmallNarrow,\n                modifier = Modifier.alpha(0.6f),\n                maxLines = 1,\n                overflow = TextOverflow.MiddleEllipsis,\n              )\n            }\n          }\n\n          Spacer(modifier = Modifier.width(4.dp))\n\n          // Values\n          Column {\n            for (rowData in configRows) {\n              if (!rowData.isChanged) {\n                Text(rowData.newValueDisplay, style = bodySmallNarrow, maxLines = 1)\n              } else {\n                val annotatedString = buildAnnotatedString {\n                  withStyle(style = bodySmallNarrow.toSpanStyle()) {\n                    append(rowData.oldValueDisplay)\n                  }\n                  withStyle(style = bodySmallNarrow.copy(fontSize = 12.sp).toSpanStyle()) {\n                    append(\" ▸ \") // Added spaces for visual separation\n                  }\n                  withStyle(\n                    style =\n                      bodySmallNarrow\n                        .copy(fontWeight = FontWeight.Bold)\n                        .toSpanStyle()\n                        .copy(color = MaterialTheme.colorScheme.primary)\n                  ) {\n                    append(rowData.newValueDisplay)\n                  }\n                }\n                Text(annotatedString, maxLines = 1, lineHeight = 12.sp)\n              }\n            }\n          }\n        }\n      }\n    }\n  }\n}\n"
  },
  {
    "path": "Android/src/app/src/main/java/com/google/ai/edge/gallery/ui/common/chat/MessageBodyError.kt",
    "content": "/*\n * Copyright 2025 Google LLC\n *\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n *     http://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\n\npackage com.google.ai.edge.gallery.ui.common.chat\n\n// import androidx.compose.ui.tooling.preview.Preview\n// import com.google.ai.edge.gallery.ui.theme.GalleryTheme\nimport androidx.compose.foundation.background\nimport androidx.compose.foundation.layout.Arrangement\nimport androidx.compose.foundation.layout.Box\nimport androidx.compose.foundation.layout.Row\nimport androidx.compose.foundation.layout.fillMaxWidth\nimport androidx.compose.foundation.layout.padding\nimport androidx.compose.foundation.shape.RoundedCornerShape\nimport androidx.compose.material3.MaterialTheme\nimport androidx.compose.runtime.Composable\nimport androidx.compose.ui.Modifier\nimport androidx.compose.ui.draw.clip\nimport androidx.compose.ui.unit.dp\nimport com.google.ai.edge.gallery.ui.common.MarkdownText\nimport com.google.ai.edge.gallery.ui.theme.customColors\n\n/**\n * Composable function to display error message content within a chat.\n *\n * Supports markdown.\n */\n@Composable\nfun MessageBodyError(message: ChatMessageError) {\n  Row(modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.Center) {\n    Box(\n      modifier =\n        Modifier.clip(RoundedCornerShape(16.dp))\n          .background(MaterialTheme.customColors.errorContainerColor)\n    ) {\n      MarkdownText(\n        text = message.content,\n        modifier = Modifier.padding(horizontal = 16.dp, vertical = 6.dp),\n        smallFontSize = true,\n        textColor = MaterialTheme.customColors.errorTextColor,\n      )\n    }\n  }\n}\n"
  },
  {
    "path": "Android/src/app/src/main/java/com/google/ai/edge/gallery/ui/common/chat/MessageBodyImage.kt",
    "content": "/*\n * Copyright 2025 Google LLC\n *\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n *     http://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\n\npackage com.google.ai.edge.gallery.ui.common.chat\n\nimport android.graphics.Bitmap\nimport androidx.compose.foundation.Image\nimport androidx.compose.foundation.clickable\nimport androidx.compose.foundation.layout.Arrangement\nimport androidx.compose.foundation.layout.Column\nimport androidx.compose.foundation.layout.Row\nimport androidx.compose.foundation.layout.height\nimport androidx.compose.foundation.layout.width\nimport androidx.compose.runtime.Composable\nimport androidx.compose.ui.Alignment\nimport androidx.compose.ui.Modifier\nimport androidx.compose.ui.layout.ContentScale\nimport androidx.compose.ui.res.stringResource\nimport androidx.compose.ui.unit.dp\nimport com.google.ai.edge.gallery.R\nimport kotlin.math.ceil\n\n@Composable\nfun MessageBodyImage(\n  message: ChatMessageImage,\n  onImageClicked: (bitmaps: List<Bitmap>, selectedBitmapIndex: Int) -> Unit,\n  modifier: Modifier = Modifier,\n) {\n  val imageCount = message.bitmaps.size\n  // Single image.\n  if (imageCount == 1) {\n    val bitmap = message.bitmaps[0]\n    val imageBitMap = message.imageBitMaps[0]\n    val bitmapWidth = bitmap.width\n    val bitmapHeight = bitmap.height\n    val maxSize = message.maxSize\n    var imageWidth = bitmapWidth\n    var imageHeight = bitmapHeight\n    if (imageWidth >= maxSize || imageHeight >= maxSize) {\n      imageWidth =\n        if (bitmapWidth >= bitmapHeight) maxSize\n        else (maxSize.toFloat() / bitmapHeight * bitmapWidth).toInt()\n      imageHeight =\n        if (bitmapHeight >= bitmapWidth) maxSize\n        else (maxSize.toFloat() / bitmapWidth * bitmapHeight).toInt()\n    }\n\n    Image(\n      bitmap = imageBitMap,\n      contentDescription = stringResource(R.string.cd_user_image),\n      modifier =\n        modifier.height(imageHeight.dp).width(imageWidth.dp).clickable {\n          onImageClicked(message.bitmaps, 0)\n        },\n      contentScale = ContentScale.Fit,\n    )\n  }\n  // Multiple images.\n  //\n  // Lay them out in a grid.\n  else {\n    var colCount = 3\n    if (imageCount == 4) {\n      colCount = 2\n    }\n    val rowCount = ceil(imageCount.toFloat() / colCount).toInt()\n    Column(horizontalAlignment = Alignment.End, verticalArrangement = Arrangement.spacedBy(2.dp)) {\n      for (row in 0..<rowCount) {\n        Row(\n          verticalAlignment = Alignment.CenterVertically,\n          horizontalArrangement = Arrangement.spacedBy(2.dp),\n        ) {\n          for (col in 0..<colCount) {\n            val imageIndex = row * colCount + col\n            if (imageIndex >= imageCount) {\n              return@Row\n            }\n            val imageBitMap = message.imageBitMaps[imageIndex]\n            Image(\n              bitmap = imageBitMap,\n              contentDescription =\n                stringResource(R.string.cd_user_image_in_group, imageIndex + 1, imageCount),\n              modifier =\n                Modifier.height(100.dp).width(100.dp).clickable {\n                  onImageClicked(message.bitmaps, imageIndex)\n                },\n              contentScale = ContentScale.Crop,\n            )\n          }\n        }\n      }\n    }\n  }\n}\n"
  },
  {
    "path": "Android/src/app/src/main/java/com/google/ai/edge/gallery/ui/common/chat/MessageBodyImageWithHistory.kt",
    "content": "/*\n * Copyright 2025 Google LLC\n *\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n *     http://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\n\npackage com.google.ai.edge.gallery.ui.common.chat\n\nimport androidx.compose.foundation.Image\nimport androidx.compose.foundation.gestures.detectHorizontalDragGestures\nimport androidx.compose.foundation.layout.Column\nimport androidx.compose.foundation.layout.height\nimport androidx.compose.foundation.layout.width\nimport androidx.compose.runtime.Composable\nimport androidx.compose.runtime.LaunchedEffect\nimport androidx.compose.runtime.MutableIntState\nimport androidx.compose.runtime.MutableState\nimport androidx.compose.runtime.getValue\nimport androidx.compose.runtime.mutableFloatStateOf\nimport androidx.compose.runtime.mutableIntStateOf\nimport androidx.compose.runtime.mutableStateOf\nimport androidx.compose.runtime.remember\nimport androidx.compose.runtime.setValue\nimport androidx.compose.ui.Modifier\nimport androidx.compose.ui.input.pointer.pointerInput\nimport androidx.compose.ui.layout.ContentScale\nimport androidx.compose.ui.unit.dp\n\n/**\n * Composable function to display an image message with history, allowing users to navigate through\n * different versions by sliding on the image.\n */\n@Composable\nfun MessageBodyImageWithHistory(\n  message: ChatMessageImageWithHistory,\n  imageHistoryCurIndex: MutableIntState,\n) {\n  val prevMessage: MutableState<ChatMessageImageWithHistory?> = remember { mutableStateOf(null) }\n\n  LaunchedEffect(message) {\n    imageHistoryCurIndex.intValue = message.bitmaps.size - 1\n    prevMessage.value = message\n  }\n\n  Column {\n    val curImage = message.bitmaps[imageHistoryCurIndex.intValue]\n    val curImageBitmap = message.imageBitMaps[imageHistoryCurIndex.intValue]\n\n    val bitmapWidth = curImage.width\n    val bitmapHeight = curImage.height\n    val imageWidth =\n      if (bitmapWidth >= bitmapHeight) 200 else (200f / bitmapHeight * bitmapWidth).toInt()\n    val imageHeight =\n      if (bitmapHeight >= bitmapWidth) 200 else (200f / bitmapWidth * bitmapHeight).toInt()\n\n    var value by remember { mutableFloatStateOf(0f) }\n    var savedIndex by remember { mutableIntStateOf(0) }\n    Image(\n      bitmap = curImageBitmap,\n      contentDescription = null,\n      modifier =\n        Modifier.height(imageHeight.dp).width(imageWidth.dp).pointerInput(Unit) {\n          detectHorizontalDragGestures(\n            onDragStart = {\n              value = 0f\n              savedIndex = imageHistoryCurIndex.intValue\n            }\n          ) { _, dragAmount ->\n            value += (dragAmount / 20f) // Adjust sensitivity here\n            imageHistoryCurIndex.intValue = (savedIndex + value).toInt()\n            if (imageHistoryCurIndex.intValue < 0) {\n              imageHistoryCurIndex.intValue = 0\n            } else if (imageHistoryCurIndex.intValue > message.bitmaps.size - 1) {\n              imageHistoryCurIndex.intValue = message.bitmaps.size - 1\n            }\n          }\n        },\n      contentScale = ContentScale.Fit,\n    )\n  }\n}\n"
  },
  {
    "path": "Android/src/app/src/main/java/com/google/ai/edge/gallery/ui/common/chat/MessageBodyInfo.kt",
    "content": "/*\n * Copyright 2025 Google LLC\n *\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n *     http://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\n\npackage com.google.ai.edge.gallery.ui.common.chat\n\n// import androidx.compose.ui.tooling.preview.Preview\n// import com.google.ai.edge.gallery.ui.theme.GalleryTheme\nimport androidx.compose.foundation.background\nimport androidx.compose.foundation.layout.Arrangement\nimport androidx.compose.foundation.layout.Box\nimport androidx.compose.foundation.layout.Row\nimport androidx.compose.foundation.layout.fillMaxWidth\nimport androidx.compose.foundation.layout.padding\nimport androidx.compose.foundation.shape.RoundedCornerShape\nimport androidx.compose.material3.MaterialTheme\nimport androidx.compose.runtime.Composable\nimport androidx.compose.ui.Modifier\nimport androidx.compose.ui.draw.clip\nimport androidx.compose.ui.unit.dp\nimport com.google.ai.edge.gallery.ui.common.MarkdownText\nimport com.google.ai.edge.gallery.ui.theme.customColors\n\n/**\n * Composable function to display informational message content within a chat.\n *\n * Supports markdown.\n */\n@Composable\nfun MessageBodyInfo(message: ChatMessageInfo, smallFontSize: Boolean = true) {\n  Row(modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.Center) {\n    Box(\n      modifier =\n        Modifier.clip(RoundedCornerShape(16.dp))\n          .background(MaterialTheme.customColors.agentBubbleBgColor)\n    ) {\n      MarkdownText(\n        text = message.content,\n        modifier = Modifier.padding(12.dp),\n        smallFontSize = smallFontSize,\n      )\n    }\n  }\n}\n\n// @Preview(showBackground = true)\n// @Composable\n// fun MessageBodyInfoPreview() {\n//   GalleryTheme {\n//     Row(modifier = Modifier.padding(16.dp)) {\n//       MessageBodyInfo(message = ChatMessageInfo(content = \"This is a model\"))\n//     }\n//   }\n// }\n"
  },
  {
    "path": "Android/src/app/src/main/java/com/google/ai/edge/gallery/ui/common/chat/MessageBodyLoading.kt",
    "content": "/*\n * Copyright 2025 Google LLC\n *\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n *     http://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\n\npackage com.google.ai.edge.gallery.ui.common.chat\n\nimport androidx.compose.animation.AnimatedContent\nimport androidx.compose.animation.core.LinearEasing\nimport androidx.compose.animation.core.RepeatMode\nimport androidx.compose.animation.core.animateFloat\nimport androidx.compose.animation.core.infiniteRepeatable\nimport androidx.compose.animation.core.rememberInfiniteTransition\nimport androidx.compose.animation.core.tween\nimport androidx.compose.animation.fadeIn\nimport androidx.compose.animation.fadeOut\nimport androidx.compose.animation.togetherWith\nimport androidx.compose.foundation.layout.Arrangement\nimport androidx.compose.foundation.layout.Row\nimport androidx.compose.foundation.layout.Spacer\nimport androidx.compose.foundation.layout.fillMaxWidth\nimport androidx.compose.foundation.layout.size\nimport androidx.compose.foundation.layout.width\nimport androidx.compose.material.icons.Icons\nimport androidx.compose.material.icons.rounded.HomeRepairService\nimport androidx.compose.material3.Icon\nimport androidx.compose.material3.MaterialTheme\nimport androidx.compose.material3.Text\nimport androidx.compose.runtime.Composable\nimport androidx.compose.runtime.getValue\nimport androidx.compose.ui.Alignment\nimport androidx.compose.ui.Modifier\nimport androidx.compose.ui.graphics.graphicsLayer\nimport androidx.compose.ui.unit.dp\nimport com.google.ai.edge.gallery.ui.common.RotationalLoader\n\n/** Composable function to display a loading indicator. */\n@Composable\nfun MessageBodyLoading(message: ChatMessageLoading? = null) {\n  val infiniteTransition = rememberInfiniteTransition(label = \"icon-flash\")\n  val iconAlpha by\n    infiniteTransition.animateFloat(\n      initialValue = 0.3f,\n      targetValue = 1f,\n      animationSpec =\n        infiniteRepeatable(\n          // Duration of one phase (1 second)\n          animation = tween(1000, easing = LinearEasing),\n          // Reverse back to start for a \"breathing\" effect\n          repeatMode = RepeatMode.Reverse,\n        ),\n      label = \"icon-alpha\",\n    )\n\n  Row(\n    horizontalArrangement = Arrangement.SpaceBetween,\n    verticalAlignment = Alignment.CenterVertically,\n    modifier = Modifier.fillMaxWidth(),\n  ) {\n    RotationalLoader(size = 32.dp)\n\n    if (message?.extraProgressLabel?.isNotEmpty() == true) {\n      AnimatedContent(\n        message.extraProgressLabel,\n        transitionSpec = { fadeIn() togetherWith fadeOut() },\n      ) { label ->\n        Row(\n          verticalAlignment = Alignment.CenterVertically,\n          horizontalArrangement = Arrangement.spacedBy(6.dp),\n        ) {\n          Icon(\n            Icons.Rounded.HomeRepairService,\n            contentDescription = null,\n            modifier = Modifier.graphicsLayer { alpha = iconAlpha }.size(16.dp),\n            tint = MaterialTheme.colorScheme.primary,\n          )\n          Text(\n            label,\n            style = MaterialTheme.typography.labelSmall,\n            color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.8f),\n          )\n        }\n      }\n    } else {\n      Spacer(modifier = Modifier.width(1.dp))\n    }\n  }\n}\n"
  },
  {
    "path": "Android/src/app/src/main/java/com/google/ai/edge/gallery/ui/common/chat/MessageBodyPromptTemplates.kt",
    "content": "/*\n * Copyright 2025 Google LLC\n *\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n *     http://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\n\npackage com.google.ai.edge.gallery.ui.common.chat\n\n// import androidx.compose.ui.tooling.preview.Preview\n// import com.google.ai.edge.gallery.ui.preview.ALL_PREVIEW_TASKS\n// import com.google.ai.edge.gallery.ui.preview.TASK_TEST1\n// import com.google.ai.edge.gallery.ui.theme.GalleryTheme\n\nimport androidx.compose.foundation.background\nimport androidx.compose.foundation.border\nimport androidx.compose.foundation.clickable\nimport androidx.compose.foundation.layout.Arrangement\nimport androidx.compose.foundation.layout.Box\nimport androidx.compose.foundation.layout.Column\nimport androidx.compose.foundation.layout.Spacer\nimport androidx.compose.foundation.layout.fillMaxSize\nimport androidx.compose.foundation.layout.fillMaxWidth\nimport androidx.compose.foundation.layout.height\nimport androidx.compose.foundation.layout.offset\nimport androidx.compose.foundation.layout.padding\nimport androidx.compose.foundation.lazy.LazyColumn\nimport androidx.compose.foundation.lazy.items\nimport androidx.compose.foundation.shape.RoundedCornerShape\nimport androidx.compose.material3.MaterialTheme\nimport androidx.compose.material3.Text\nimport androidx.compose.runtime.Composable\nimport androidx.compose.ui.Alignment\nimport androidx.compose.ui.Modifier\nimport androidx.compose.ui.draw.shadow\nimport androidx.compose.ui.graphics.Brush\nimport androidx.compose.ui.text.font.FontWeight\nimport androidx.compose.ui.text.style.TextAlign\nimport androidx.compose.ui.unit.dp\nimport com.google.ai.edge.gallery.data.PromptTemplate\nimport com.google.ai.edge.gallery.data.Task\nimport com.google.ai.edge.gallery.ui.common.getTaskIconColor\n\nprivate const val CARD_HEIGHT = 100\n\n@Composable\nfun MessageBodyPromptTemplates(\n  message: ChatMessagePromptTemplates,\n  task: Task,\n  onPromptClicked: (PromptTemplate) -> Unit = {},\n) {\n  val rowCount = message.templates.size.toFloat()\n  val color = getTaskIconColor(task)\n  val gradientColors = listOf(color.copy(alpha = 0.5f), color)\n\n  Column(\n    modifier = Modifier.padding(top = 12.dp),\n    verticalArrangement = Arrangement.spacedBy(8.dp),\n  ) {\n    Text(\n      \"Try an example prompt\",\n      style =\n        MaterialTheme.typography.titleLarge.copy(\n          fontWeight = FontWeight.Bold,\n          brush = Brush.linearGradient(colors = gradientColors),\n        ),\n      modifier = Modifier.fillMaxWidth(),\n      textAlign = TextAlign.Center,\n    )\n    if (message.showMakeYourOwn) {\n      Text(\n        \"Or make your own\",\n        style = MaterialTheme.typography.titleSmall,\n        modifier = Modifier.fillMaxWidth().offset(y = (-4).dp),\n        textAlign = TextAlign.Center,\n      )\n    }\n    LazyColumn(\n      modifier = Modifier.height((rowCount * (CARD_HEIGHT + 8)).dp),\n      verticalArrangement = Arrangement.spacedBy(8.dp),\n    ) {\n      // Cards.\n      items(message.templates) { template ->\n        Box(\n          modifier =\n            Modifier.border(\n                width = 1.dp,\n                color = color.copy(alpha = 0.3f),\n                shape = RoundedCornerShape(24.dp),\n              )\n              .height(CARD_HEIGHT.dp)\n              .shadow(elevation = 2.dp, shape = RoundedCornerShape(24.dp), spotColor = color)\n              .background(MaterialTheme.colorScheme.surface)\n              .clickable { onPromptClicked(template) }\n        ) {\n          Column(\n            modifier = Modifier.padding(horizontal = 12.dp, vertical = 20.dp).fillMaxSize(),\n            horizontalAlignment = Alignment.CenterHorizontally,\n          ) {\n            Text(\n              template.title,\n              style = MaterialTheme.typography.titleMedium.copy(fontWeight = FontWeight.Bold),\n            )\n            Spacer(modifier = Modifier.weight(1f))\n            Text(\n              template.description,\n              style = MaterialTheme.typography.bodyMedium,\n              textAlign = TextAlign.Center,\n            )\n          }\n        }\n      }\n    }\n  }\n}\n\n// @Preview(showBackground = true)\n// @Composable\n// fun MessageBodyPromptTemplatesPreview() {\n//   for ((index, task) in ALL_PREVIEW_TASKS.withIndex()) {\n//     task.index = index\n//     for (model in task.models) {\n//       model.preProcess()\n//     }\n//   }\n\n//   GalleryTheme {\n//     Row(modifier = Modifier.padding(16.dp)) {\n//       MessageBodyPromptTemplates(\n//         message =\n//           ChatMessagePromptTemplates(\n//             templates =\n//               listOf(\n//                 PromptTemplate(\n//                   title = \"Math Worksheets\",\n//                   description = \"Create a set of math worksheets for parents\",\n//                   prompt = \"\",\n//                 ),\n//                 PromptTemplate(\n//                   title = \"Shape Sequencer\",\n//                   description = \"Find the next shape in a sequence\",\n//                   prompt = \"\",\n//                 ),\n//               )\n//           ),\n//         task = TASK_TEST1,\n//       )\n//     }\n//   }\n// }\n"
  },
  {
    "path": "Android/src/app/src/main/java/com/google/ai/edge/gallery/ui/common/chat/MessageBodyText.kt",
    "content": "/*\n * Copyright 2025 Google LLC\n *\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n *     http://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\n\npackage com.google.ai.edge.gallery.ui.common.chat\n\n// import com.google.ai.edge.gallery.ui.theme.GalleryTheme\n// import androidx.compose.ui.tooling.preview.Preview\n\nimport androidx.compose.foundation.layout.padding\nimport androidx.compose.material3.MaterialTheme\nimport androidx.compose.material3.Text\nimport androidx.compose.runtime.Composable\nimport androidx.compose.ui.Modifier\nimport androidx.compose.ui.graphics.Color\nimport androidx.compose.ui.res.stringResource\nimport androidx.compose.ui.semantics.LiveRegionMode\nimport androidx.compose.ui.semantics.contentDescription\nimport androidx.compose.ui.semantics.liveRegion\nimport androidx.compose.ui.semantics.semantics\nimport androidx.compose.ui.unit.dp\nimport com.google.ai.edge.gallery.R\nimport com.google.ai.edge.gallery.ui.common.MarkdownText\n\n/** Composable function to display the text content of a ChatMessageText. */\n@Composable\nfun MessageBodyText(message: ChatMessageText, inProgress: Boolean) {\n  if (message.side == ChatSide.USER) {\n    MarkdownText(\n      text = message.content,\n      modifier = Modifier.padding(12.dp),\n      textColor = Color.White,\n      linkColor = Color.White,\n    )\n  } else if (message.side == ChatSide.AGENT) {\n    val cdResponse = stringResource(R.string.cd_model_response_text)\n    if (message.isMarkdown) {\n      MarkdownText(\n        text = message.content,\n        modifier =\n          Modifier.padding(12.dp).semantics(mergeDescendants = true) {\n            contentDescription = cdResponse\n            // Only announce when message is complete.\n            if (!inProgress) {\n              liveRegion = LiveRegionMode.Polite\n            }\n          },\n      )\n    } else {\n      Text(\n        message.content,\n        style = MaterialTheme.typography.bodyMedium,\n        color = MaterialTheme.colorScheme.onSurface,\n        modifier =\n          Modifier.padding(12.dp).semantics {\n            contentDescription = cdResponse\n            // Only announce when message is complete.\n            if (!inProgress) {\n              liveRegion = LiveRegionMode.Polite\n            }\n          },\n      )\n    }\n  }\n}\n\n// @Preview(showBackground = true)\n// @Composable\n// fun MessageBodyTextPreview() {\n//   GalleryTheme {\n//     Column {\n//       Row(modifier = Modifier.padding(16.dp).background(MaterialTheme.colorScheme.primary)) {\n//         MessageBodyText(ChatMessageText(content = \"Hello world\", side = ChatSide.USER))\n//       }\n//       Row(\n//         modifier = Modifier.padding(16.dp).background(MaterialTheme.colorScheme.surfaceContainer)\n//       ) {\n//         MessageBodyText(ChatMessageText(content = \"yes hello world\", side = ChatSide.AGENT))\n//       }\n//     }\n//   }\n// }\n"
  },
  {
    "path": "Android/src/app/src/main/java/com/google/ai/edge/gallery/ui/common/chat/MessageBodyWarning.kt",
    "content": "/*\n * Copyright 2025 Google LLC\n *\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n *     http://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\n\npackage com.google.ai.edge.gallery.ui.common.chat\n\n// import androidx.compose.ui.tooling.preview.Preview\n// import com.google.ai.edge.gallery.ui.theme.GalleryTheme\nimport androidx.compose.foundation.background\nimport androidx.compose.foundation.layout.Arrangement\nimport androidx.compose.foundation.layout.Box\nimport androidx.compose.foundation.layout.Row\nimport androidx.compose.foundation.layout.fillMaxWidth\nimport androidx.compose.foundation.layout.padding\nimport androidx.compose.foundation.shape.RoundedCornerShape\nimport androidx.compose.material3.MaterialTheme\nimport androidx.compose.runtime.Composable\nimport androidx.compose.ui.Modifier\nimport androidx.compose.ui.draw.clip\nimport androidx.compose.ui.unit.dp\nimport com.google.ai.edge.gallery.ui.common.MarkdownText\nimport com.google.ai.edge.gallery.ui.theme.customColors\n\n/**\n * Composable function to display warning message content within a chat.\n *\n * Supports markdown.\n */\n@Composable\nfun MessageBodyWarning(message: ChatMessageWarning) {\n  Row(modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.Center) {\n    Box(\n      modifier =\n        Modifier.clip(RoundedCornerShape(16.dp))\n          .background(MaterialTheme.customColors.warningContainerColor)\n    ) {\n      MarkdownText(\n        text = message.content,\n        modifier = Modifier.padding(horizontal = 16.dp, vertical = 6.dp),\n        smallFontSize = true,\n        textColor = MaterialTheme.customColors.warningTextColor,\n      )\n    }\n  }\n}\n\n// @Preview(showBackground = true)\n// @Composable\n// fun MessageBodyWarningPreview() {\n//   GalleryTheme {\n//     Row(modifier = Modifier.padding(16.dp)) {\n//       MessageBodyWarning(message = ChatMessageWarning(content = \"This is a warning\"))\n//     }\n//   }\n// }\n"
  },
  {
    "path": "Android/src/app/src/main/java/com/google/ai/edge/gallery/ui/common/chat/MessageBodyWebview.kt",
    "content": "/*\n * Copyright 2026 Google LLC\n *\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n *     http://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\n\npackage com.google.ai.edge.gallery.ui.common.chat\n\nimport android.util.Log\nimport android.webkit.ConsoleMessage\nimport androidx.compose.foundation.layout.aspectRatio\nimport androidx.compose.foundation.layout.fillMaxWidth\nimport androidx.compose.runtime.Composable\nimport androidx.compose.ui.Modifier\nimport com.google.ai.edge.gallery.ui.common.GalleryWebView\n\nprivate const val TAG = \"AGMessageBodyWebview\"\n\n/** A Composable that displays a WebView to render web content within a chat message. */\n@Composable\nfun MessageBodyWebview(message: ChatMessageWebView, modifier: Modifier = Modifier) {\n  GalleryWebView(\n    modifier = modifier.fillMaxWidth().aspectRatio(4f / 3f),\n    initialUrl = message.url,\n    useIframeWrapper = message.iframe,\n    preventParentScrolling = true,\n    allowRequestPermission = true,\n    onConsoleMessage = { consoleMessage: ConsoleMessage? ->\n      Log.d(\n        TAG,\n        \"${consoleMessage?.message()} -- From line ${consoleMessage?.lineNumber()} of ${consoleMessage?.sourceId()}\",\n      )\n      true // Return true to indicate the message was handled.\n    },\n  )\n}\n"
  },
  {
    "path": "Android/src/app/src/main/java/com/google/ai/edge/gallery/ui/common/chat/MessageBubbleShape.kt",
    "content": "/*\n * Copyright 2025 Google LLC\n *\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n *     http://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\n\npackage com.google.ai.edge.gallery.ui.common.chat\n\nimport androidx.compose.ui.geometry.CornerRadius\nimport androidx.compose.ui.geometry.RoundRect\nimport androidx.compose.ui.geometry.Size\nimport androidx.compose.ui.graphics.Outline\nimport androidx.compose.ui.graphics.Path\nimport androidx.compose.ui.graphics.Shape\nimport androidx.compose.ui.unit.Density\nimport androidx.compose.ui.unit.Dp\nimport androidx.compose.ui.unit.LayoutDirection\n\n/**\n * Custom Shape for creating message bubble outlines with configurable corner radii.\n *\n * This class defines a custom Shape that generates a rounded rectangle outline, suitable for\n * message bubbles. It allows specifying a uniform corner radius for most corners, but also provides\n * the option to have a hard (non-rounded) corner on either the left or right side.\n */\nclass MessageBubbleShape(\n  private val radius: Dp,\n  private val hardCornerAtLeftOrRight: Boolean = false,\n) : Shape {\n  override fun createOutline(\n    size: Size,\n    layoutDirection: LayoutDirection,\n    density: Density,\n  ): Outline {\n    val radiusPx = with(density) { radius.toPx() }\n    val path =\n      Path().apply {\n        addRoundRect(\n          RoundRect(\n            left = 0f,\n            top = 0f,\n            right = size.width,\n            bottom = size.height,\n            topLeftCornerRadius =\n              if (hardCornerAtLeftOrRight) CornerRadius(0f, 0f)\n              else CornerRadius(radiusPx, radiusPx),\n            topRightCornerRadius =\n              if (hardCornerAtLeftOrRight) CornerRadius(radiusPx, radiusPx)\n              else CornerRadius(0f, 0f), // No rounding here\n            bottomLeftCornerRadius = CornerRadius(radiusPx, radiusPx),\n            bottomRightCornerRadius = CornerRadius(radiusPx, radiusPx),\n          )\n        )\n      }\n    return Outline.Generic(path)\n  }\n}\n"
  },
  {
    "path": "Android/src/app/src/main/java/com/google/ai/edge/gallery/ui/common/chat/MessageInputText.kt",
    "content": "/*\n * Copyright 2025 Google LLC\n *\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n *     http://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\n\npackage com.google.ai.edge.gallery.ui.common.chat\n\nimport android.Manifest\nimport android.content.Context\nimport android.content.Intent\nimport android.content.pm.PackageManager\nimport android.graphics.Bitmap\nimport android.graphics.Matrix\nimport android.hardware.Sensor\nimport android.hardware.SensorEvent\nimport android.hardware.SensorEventListener\nimport android.hardware.SensorManager\nimport android.net.Uri\nimport android.util.Log\nimport android.util.Size\nimport androidx.activity.compose.rememberLauncherForActivityResult\nimport androidx.activity.result.PickVisualMediaRequest\nimport androidx.activity.result.contract.ActivityResultContracts\nimport androidx.annotation.StringRes\nimport androidx.camera.core.CameraControl\nimport androidx.camera.core.CameraSelector\nimport androidx.camera.core.ImageCapture\nimport androidx.camera.core.ImageProxy\nimport androidx.camera.core.resolutionselector.AspectRatioStrategy\nimport androidx.camera.core.resolutionselector.ResolutionSelector\nimport androidx.camera.core.resolutionselector.ResolutionStrategy\nimport androidx.camera.lifecycle.ProcessCameraProvider\nimport androidx.camera.lifecycle.awaitInstance\nimport androidx.camera.view.PreviewView\nimport androidx.compose.animation.AnimatedContent\nimport androidx.compose.foundation.Image\nimport androidx.compose.foundation.background\nimport androidx.compose.foundation.border\nimport androidx.compose.foundation.clickable\nimport androidx.compose.foundation.horizontalScroll\nimport androidx.compose.foundation.layout.Arrangement\nimport androidx.compose.foundation.layout.Box\nimport androidx.compose.foundation.layout.Column\nimport androidx.compose.foundation.layout.Row\nimport androidx.compose.foundation.layout.Spacer\nimport androidx.compose.foundation.layout.fillMaxSize\nimport androidx.compose.foundation.layout.fillMaxWidth\nimport androidx.compose.foundation.layout.height\nimport androidx.compose.foundation.layout.heightIn\nimport androidx.compose.foundation.layout.offset\nimport androidx.compose.foundation.layout.padding\nimport androidx.compose.foundation.layout.size\nimport androidx.compose.foundation.layout.width\nimport androidx.compose.foundation.rememberScrollState\nimport androidx.compose.foundation.shape.CircleShape\nimport androidx.compose.foundation.shape.RoundedCornerShape\nimport androidx.compose.material.icons.Icons\nimport androidx.compose.material.icons.automirrored.rounded.Send\nimport androidx.compose.material.icons.outlined.AddPhotoAlternate\nimport androidx.compose.material.icons.outlined.HomeRepairService\nimport androidx.compose.material.icons.outlined.MusicNote\nimport androidx.compose.material.icons.rounded.AudioFile\nimport androidx.compose.material.icons.rounded.Close\nimport androidx.compose.material.icons.rounded.FlipCameraAndroid\nimport androidx.compose.material.icons.rounded.History\nimport androidx.compose.material.icons.rounded.Mic\nimport androidx.compose.material.icons.rounded.Photo\nimport androidx.compose.material.icons.rounded.PhotoCamera\nimport androidx.compose.material.icons.rounded.Stop\nimport androidx.compose.material3.AssistChip\nimport androidx.compose.material3.AssistChipDefaults\nimport androidx.compose.material3.DropdownMenu\nimport androidx.compose.material3.DropdownMenuItem\nimport androidx.compose.material3.ExperimentalMaterial3Api\nimport androidx.compose.material3.Icon\nimport androidx.compose.material3.IconButton\nimport androidx.compose.material3.IconButtonDefaults\nimport androidx.compose.material3.MaterialTheme\nimport androidx.compose.material3.ModalBottomSheet\nimport androidx.compose.material3.Text\nimport androidx.compose.material3.TextField\nimport androidx.compose.material3.TextFieldDefaults\nimport androidx.compose.material3.rememberModalBottomSheetState\nimport androidx.compose.runtime.Composable\nimport androidx.compose.runtime.DisposableEffect\nimport androidx.compose.runtime.LaunchedEffect\nimport androidx.compose.runtime.collectAsState\nimport androidx.compose.runtime.getValue\nimport androidx.compose.runtime.mutableIntStateOf\nimport androidx.compose.runtime.mutableStateOf\nimport androidx.compose.runtime.remember\nimport androidx.compose.runtime.rememberCoroutineScope\nimport androidx.compose.runtime.setValue\nimport androidx.compose.ui.Alignment\nimport androidx.compose.ui.Modifier\nimport androidx.compose.ui.draw.clip\nimport androidx.compose.ui.draw.shadow\nimport androidx.compose.ui.graphics.Color\nimport androidx.compose.ui.graphics.asImageBitmap\nimport androidx.compose.ui.platform.LocalContext\nimport androidx.compose.ui.res.stringResource\nimport androidx.compose.ui.semantics.contentDescription\nimport androidx.compose.ui.semantics.semantics\nimport androidx.compose.ui.unit.dp\nimport androidx.compose.ui.viewinterop.AndroidView\nimport androidx.core.content.ContextCompat\nimport androidx.core.graphics.scale\nimport androidx.exifinterface.media.ExifInterface\nimport androidx.lifecycle.DefaultLifecycleObserver\nimport androidx.lifecycle.LifecycleOwner\nimport androidx.lifecycle.compose.LocalLifecycleOwner\nimport com.google.ai.edge.gallery.R\nimport com.google.ai.edge.gallery.common.AudioClip\nimport com.google.ai.edge.gallery.common.convertWavToMonoWithMaxSeconds\nimport com.google.ai.edge.gallery.common.decodeSampledBitmapFromUri\nimport com.google.ai.edge.gallery.common.rotateBitmap\nimport com.google.ai.edge.gallery.data.MAX_AUDIO_CLIP_COUNT\nimport com.google.ai.edge.gallery.data.MAX_IMAGE_COUNT\nimport com.google.ai.edge.gallery.data.SAMPLE_RATE\nimport com.google.ai.edge.gallery.data.Task\nimport com.google.ai.edge.gallery.ui.common.getTaskIconColor\nimport com.google.ai.edge.gallery.ui.modelmanager.ModelManagerViewModel\nimport com.google.ai.edge.gallery.ui.theme.bodyLargeNarrow\nimport java.io.FileInputStream\nimport java.util.concurrent.Executors\nimport kotlinx.coroutines.Dispatchers\nimport kotlinx.coroutines.launch\n\nprivate const val TAG = \"AGMessageInputText\"\n\n/**\n * Composable function to display a text input field for composing chat messages.\n *\n * This function renders a row containing a text field for message input and a send button. It\n * handles message composition, input validation, and sending messages.\n */\n@OptIn(ExperimentalMaterial3Api::class)\n@Composable\nfun MessageInputText(\n  task: Task,\n  modelManagerViewModel: ModelManagerViewModel,\n  curMessage: String,\n  isResettingSession: Boolean,\n  inProgress: Boolean,\n  imageCount: Int,\n  audioClipMessageCount: Int,\n  modelInitializing: Boolean,\n  @StringRes textFieldPlaceHolderRes: Int,\n  onValueChanged: (String) -> Unit,\n  onSendMessage: (List<ChatMessage>) -> Unit,\n  modelPreparing: Boolean = false,\n  onOpenPromptTemplatesClicked: () -> Unit = {},\n  onStopButtonClicked: () -> Unit = {},\n  onSetAudioRecorderVisible: (visible: Boolean) -> Unit = {},\n  onAmplitudeChanged: (Int) -> Unit,\n  showPromptTemplatesInMenu: Boolean = false,\n  showImagePicker: Boolean = false,\n  showAudioPicker: Boolean = false,\n  showStopButtonWhenInProgress: Boolean = false,\n) {\n  val context = LocalContext.current\n  val lifecycleOwner = LocalLifecycleOwner.current\n  val scope = rememberCoroutineScope()\n  val modelManagerUiState by modelManagerViewModel.uiState.collectAsState()\n  var showAddImageMenu by remember { mutableStateOf(false) }\n  var showAddAudioMenu by remember { mutableStateOf(false) }\n  var showTextInputHistorySheet by remember { mutableStateOf(false) }\n  var showCameraCaptureBottomSheet by remember { mutableStateOf(false) }\n  val cameraCaptureSheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true)\n  var showAudioRecorder by remember { mutableStateOf(false) }\n  val audioRecorderSheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true)\n  var pickedImages by remember { mutableStateOf<List<Bitmap>>(listOf()) }\n  var pickedAudioClips by remember { mutableStateOf<List<AudioClip>>(listOf()) }\n  var hasFrontCamera by remember { mutableStateOf(false) }\n  val sensorObserver = remember { SensorObserver(context) }\n\n  val updatePickedImages: (List<Bitmap>) -> Unit = { bitmaps ->\n    var newPickedImages: MutableList<Bitmap> = mutableListOf()\n    newPickedImages.addAll(pickedImages)\n    newPickedImages.addAll(bitmaps)\n    if (newPickedImages.size > MAX_IMAGE_COUNT) {\n      newPickedImages = newPickedImages.subList(fromIndex = 0, toIndex = MAX_IMAGE_COUNT)\n    }\n    pickedImages = newPickedImages.toList()\n  }\n\n  val updatePickedAudioClips: (List<AudioClip>) -> Unit = { audioDataList ->\n    var newAudioDataList: MutableList<AudioClip> = mutableListOf()\n    newAudioDataList.addAll(pickedAudioClips)\n    newAudioDataList.addAll(audioDataList)\n    if (newAudioDataList.size > MAX_AUDIO_CLIP_COUNT) {\n      newAudioDataList = newAudioDataList.subList(fromIndex = 0, toIndex = MAX_AUDIO_CLIP_COUNT)\n    }\n    pickedAudioClips = newAudioDataList.toList()\n  }\n\n  LaunchedEffect(Unit) { checkFrontCamera(context = context, callback = { hasFrontCamera = it }) }\n\n  // Permission request when taking picture.\n  val takePicturePermissionLauncher =\n    rememberLauncherForActivityResult(ActivityResultContracts.RequestPermission()) {\n      permissionGranted ->\n      if (permissionGranted) {\n        showAddImageMenu = false\n        showCameraCaptureBottomSheet = true\n      }\n    }\n\n  val handleClickRecordAudioClip = {\n    showAddAudioMenu = false\n    showAudioRecorder = true\n    onSetAudioRecorderVisible(true)\n  }\n\n  // Permission request when recording audio clips.\n  val recordAudioClipsPermissionLauncher =\n    rememberLauncherForActivityResult(ActivityResultContracts.RequestPermission()) {\n      permissionGranted ->\n      if (permissionGranted) {\n        handleClickRecordAudioClip()\n      }\n    }\n\n  // Registers a photo picker activity launcher in single-select mode.\n  val pickMedia =\n    rememberLauncherForActivityResult(ActivityResultContracts.PickMultipleVisualMedia()) { uris ->\n      // Callback is invoked after the user selects media items or closes the\n      // photo picker.\n      if (uris.isNotEmpty()) {\n        scope.launch(Dispatchers.IO) {\n          handleImagesSelected(\n            context = context,\n            uris = uris,\n            onImagesSelected = { bitmaps -> updatePickedImages(bitmaps) },\n          )\n        }\n      }\n    }\n\n  val pickWav =\n    rememberLauncherForActivityResult(\n      contract = ActivityResultContracts.StartActivityForResult()\n    ) { result ->\n      if (result.resultCode == android.app.Activity.RESULT_OK) {\n        result.data?.data?.let { uri ->\n          Log.d(TAG, \"Picked wav file: $uri\")\n          scope.launch(Dispatchers.IO) {\n            handleAudioWavSelected(\n              context = context,\n              uri = uri,\n              onAudioSelected = { audioClip ->\n                updatePickedAudioClips(\n                  listOf(\n                    AudioClip(audioData = audioClip.audioData, sampleRate = audioClip.sampleRate)\n                  )\n                )\n              },\n            )\n          }\n        }\n      } else {\n        Log.d(TAG, \"Wav picking cancelled.\")\n      }\n    }\n\n  DisposableEffect(lifecycleOwner) {\n    lifecycleOwner.lifecycle.addObserver(sensorObserver)\n    onDispose { lifecycleOwner.lifecycle.removeObserver(sensorObserver) }\n  }\n\n  Column {\n    // A preview panel for the selected images and audio clips.\n    if (pickedImages.isNotEmpty() || pickedAudioClips.isNotEmpty()) {\n      Row(\n        modifier = Modifier.fillMaxWidth().horizontalScroll(rememberScrollState()),\n        horizontalArrangement = Arrangement.spacedBy(16.dp),\n      ) {\n        Spacer(modifier = Modifier.width(16.dp))\n\n        for (image in pickedImages) {\n          Box(contentAlignment = Alignment.TopEnd) {\n            Image(\n              bitmap = image.asImageBitmap(),\n              contentDescription = stringResource(R.string.cd_image_thumbnail),\n              modifier =\n                Modifier.height(80.dp)\n                  .shadow(2.dp, shape = RoundedCornerShape(8.dp))\n                  .clip(RoundedCornerShape(8.dp))\n                  .border(1.dp, MaterialTheme.colorScheme.outline, RoundedCornerShape(8.dp)),\n            )\n            MediaPanelCloseButton { pickedImages = pickedImages.filter { image != it } }\n          }\n        }\n\n        for ((index, audioClip) in pickedAudioClips.withIndex()) {\n          Box(contentAlignment = Alignment.TopEnd) {\n            Box(\n              modifier =\n                Modifier.shadow(2.dp, shape = RoundedCornerShape(8.dp))\n                  .clip(RoundedCornerShape(8.dp))\n                  .background(MaterialTheme.colorScheme.surface)\n                  .border(1.dp, MaterialTheme.colorScheme.outline, RoundedCornerShape(8.dp))\n            ) {\n              AudioPlaybackPanel(\n                audioData = audioClip.audioData,\n                sampleRate = audioClip.sampleRate,\n                isRecording = false,\n                modifier = Modifier.padding(end = 16.dp),\n              )\n            }\n            MediaPanelCloseButton {\n              pickedAudioClips =\n                pickedAudioClips.filterIndexed { curIndex, curAudioData -> curIndex != index }\n            }\n          }\n        }\n\n        Spacer(modifier = Modifier.width(16.dp))\n      }\n    }\n\n    Box(contentAlignment = Alignment.Center, modifier = Modifier.heightIn(min = 76.dp)) {\n      AnimatedContent(targetState = showAudioRecorder) { curShowAudioRecorder ->\n        when (curShowAudioRecorder) {\n          // Input\n          false ->\n            Column(\n              modifier =\n                Modifier.padding(horizontal = 12.dp)\n                  .padding(vertical = 8.dp)\n                  .border(1.dp, MaterialTheme.colorScheme.outlineVariant, RoundedCornerShape(16.dp))\n            ) {\n              // Text field.\n              Row(\n                modifier = Modifier.fillMaxWidth(),\n                verticalAlignment = Alignment.CenterVertically,\n              ) {\n                // Text field.\n                val cdPromptInput = stringResource(R.string.cd_prompt_input_text_field)\n                TextField(\n                  value = curMessage,\n                  minLines = 1,\n                  maxLines = 3,\n                  onValueChange = onValueChanged,\n                  colors =\n                    TextFieldDefaults.colors(\n                      unfocusedContainerColor = Color.Transparent,\n                      focusedContainerColor = Color.Transparent,\n                      focusedIndicatorColor = Color.Transparent,\n                      unfocusedIndicatorColor = Color.Transparent,\n                      disabledIndicatorColor = Color.Transparent,\n                      disabledContainerColor = Color.Transparent,\n                    ),\n                  textStyle = bodyLargeNarrow,\n                  modifier = Modifier.weight(1f).semantics { contentDescription = cdPromptInput },\n                  placeholder = { Text(stringResource(textFieldPlaceHolderRes)) },\n                )\n\n                Spacer(modifier = Modifier.width(8.dp))\n\n                if (inProgress && showStopButtonWhenInProgress) {\n                  if (!modelInitializing && !modelPreparing) {\n                    IconButton(\n                      onClick = onStopButtonClicked,\n                      colors =\n                        IconButtonDefaults.iconButtonColors(\n                          containerColor = MaterialTheme.colorScheme.secondaryContainer\n                        ),\n                    ) {\n                      Icon(\n                        Icons.Rounded.Stop,\n                        contentDescription = stringResource(R.string.cd_stop_icon),\n                        tint = MaterialTheme.colorScheme.primary,\n                      )\n                    }\n                  }\n                }\n                // Send button. Only shown when text is not empty.\n                else if (curMessage.isNotEmpty()) {\n                  IconButton(\n                    enabled = !inProgress && !isResettingSession,\n                    onClick = {\n                      var message = curMessage.trim()\n                      onSendMessage(\n                        createMessagesToSend(\n                          pickedImages = pickedImages,\n                          audioClips = pickedAudioClips,\n                          text = message,\n                        )\n                      )\n                      pickedImages = listOf()\n                      pickedAudioClips = listOf()\n                    },\n                    colors =\n                      IconButtonDefaults.iconButtonColors(\n                        containerColor = getTaskIconColor(task = task)\n                      ),\n                  ) {\n                    Icon(\n                      Icons.AutoMirrored.Rounded.Send,\n                      contentDescription = stringResource(R.string.cd_send_prompt_icon),\n                      modifier = Modifier.offset(x = 2.dp),\n                      tint = Color.White,\n                    )\n                  }\n                }\n                Spacer(modifier = Modifier.width(4.dp))\n              }\n\n              // Second row for buttons to add extra content.\n              Row(\n                modifier = Modifier.fillMaxWidth().padding(horizontal = 12.dp).offset(y = (-4).dp),\n                verticalAlignment = Alignment.Top,\n                horizontalArrangement = Arrangement.spacedBy(4.dp),\n              ) {\n\n                // Add image.\n                if (showImagePicker) {\n                  val enableAddImageMenuItems = (imageCount + pickedImages.size) < MAX_IMAGE_COUNT\n                  Box() {\n                    AssistChip(\n                      onClick = { showAddImageMenu = true },\n                      label = { Text(stringResource(R.string.add_image)) },\n                      enabled = !inProgress,\n                      leadingIcon = {\n                        Icon(\n                          Icons.Outlined.AddPhotoAlternate,\n                          contentDescription = null,\n                          Modifier.size(AssistChipDefaults.IconSize),\n                        )\n                      },\n                    )\n\n                    // Menu shown when add image chip above is clicked.\n                    DropdownMenu(\n                      expanded = showAddImageMenu,\n                      onDismissRequest = { showAddImageMenu = false },\n                    ) {\n                      // Take a picture.\n                      DropdownMenuItem(\n                        text = {\n                          Row(\n                            verticalAlignment = Alignment.CenterVertically,\n                            horizontalArrangement = Arrangement.spacedBy(6.dp),\n                          ) {\n                            Icon(Icons.Rounded.PhotoCamera, contentDescription = null)\n                            Text(\"Take a picture\")\n                          }\n                        },\n                        enabled = enableAddImageMenuItems,\n                        onClick = {\n                          // Check permission\n                          when (PackageManager.PERMISSION_GRANTED) {\n                            // Already got permission. Call the lambda.\n                            ContextCompat.checkSelfPermission(\n                              context,\n                              Manifest.permission.CAMERA,\n                            ) -> {\n                              showAddImageMenu = false\n                              showCameraCaptureBottomSheet = true\n                            }\n\n                            // Otherwise, ask for permission\n                            else -> {\n                              takePicturePermissionLauncher.launch(Manifest.permission.CAMERA)\n                            }\n                          }\n                        },\n                      )\n\n                      // Pick an image from album.\n                      DropdownMenuItem(\n                        text = {\n                          Row(\n                            verticalAlignment = Alignment.CenterVertically,\n                            horizontalArrangement = Arrangement.spacedBy(6.dp),\n                          ) {\n                            Icon(Icons.Rounded.Photo, contentDescription = null)\n                            Text(\"Pick from album\")\n                          }\n                        },\n                        enabled = enableAddImageMenuItems,\n                        onClick = {\n                          // Launch the photo picker and let the user choose only images.\n                          pickMedia.launch(\n                            PickVisualMediaRequest(\n                              ActivityResultContracts.PickVisualMedia.ImageOnly\n                            )\n                          )\n                          showAddImageMenu = false\n                        },\n                      )\n                    }\n                  }\n                }\n\n                // Add audio.\n                if (showAudioPicker) {\n                  val enableRecordAudioClipMenuItems =\n                    (audioClipMessageCount + pickedAudioClips.size) < MAX_AUDIO_CLIP_COUNT\n                  Box() {\n                    AssistChip(\n                      onClick = { showAddAudioMenu = true },\n                      label = { Text(stringResource(R.string.add_audio)) },\n                      enabled = !inProgress,\n                      leadingIcon = {\n                        Icon(\n                          Icons.Outlined.MusicNote,\n                          contentDescription = null,\n                          Modifier.size(AssistChipDefaults.IconSize),\n                        )\n                      },\n                    )\n\n                    // Menu shown when add audio chip above is clicked.\n                    DropdownMenu(\n                      expanded = showAddAudioMenu,\n                      onDismissRequest = { showAddAudioMenu = false },\n                    ) {\n                      DropdownMenuItem(\n                        text = {\n                          Row(\n                            verticalAlignment = Alignment.CenterVertically,\n                            horizontalArrangement = Arrangement.spacedBy(6.dp),\n                          ) {\n                            Icon(Icons.Rounded.Mic, contentDescription = null)\n                            Text(\"Record audio clip\")\n                          }\n                        },\n                        enabled = enableRecordAudioClipMenuItems,\n                        onClick = {\n                          // Check permission\n                          when (PackageManager.PERMISSION_GRANTED) {\n                            // Already got permission. Call the lambda.\n                            ContextCompat.checkSelfPermission(\n                              context,\n                              Manifest.permission.RECORD_AUDIO,\n                            ) -> {\n                              handleClickRecordAudioClip()\n                            }\n\n                            // Otherwise, ask for permission\n                            else -> {\n                              recordAudioClipsPermissionLauncher.launch(\n                                Manifest.permission.RECORD_AUDIO\n                              )\n                            }\n                          }\n                        },\n                      )\n\n                      DropdownMenuItem(\n                        text = {\n                          Row(\n                            verticalAlignment = Alignment.CenterVertically,\n                            horizontalArrangement = Arrangement.spacedBy(6.dp),\n                          ) {\n                            Icon(Icons.Rounded.AudioFile, contentDescription = null)\n                            Text(\"Pick wav file\")\n                          }\n                        },\n                        enabled = enableRecordAudioClipMenuItems,\n                        onClick = {\n                          showAddAudioMenu = false\n\n                          // Show file picker.\n                          val intent =\n                            Intent(Intent.ACTION_GET_CONTENT).apply {\n                              addCategory(Intent.CATEGORY_OPENABLE)\n                              type = \"audio/*\"\n\n                              // Provide a list of more specific MIME types to filter for.\n                              val mimeTypes = arrayOf(\"audio/wav\", \"audio/x-wav\")\n                              putExtra(Intent.EXTRA_MIME_TYPES, mimeTypes)\n\n                              // Single select.\n                              putExtra(Intent.EXTRA_ALLOW_MULTIPLE, false)\n                                .addFlags(Intent.FLAG_GRANT_PERSISTABLE_URI_PERMISSION)\n                                .addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION)\n                            }\n                          pickWav.launch(intent)\n                        },\n                      )\n                    }\n                  }\n                }\n\n                // Input history.\n                AssistChip(\n                  onClick = { showTextInputHistorySheet = true },\n                  label = { Text(stringResource(R.string.input_history)) },\n                  enabled = !inProgress,\n                  leadingIcon = {\n                    Icon(\n                      Icons.Rounded.History,\n                      contentDescription = null,\n                      Modifier.size(AssistChipDefaults.IconSize),\n                    )\n                  },\n                )\n              }\n            }\n\n          // Audio recorder.\n          true ->\n            AudioRecorderPanel(\n              task = task,\n              onSendAudioClip = { audioData ->\n                scope.launch {\n                  updatePickedAudioClips(\n                    listOf(AudioClip(audioData = audioData, sampleRate = SAMPLE_RATE))\n                  )\n                  audioRecorderSheetState.hide()\n                  showAudioRecorder = false\n                  onSetAudioRecorderVisible(false)\n                }\n              },\n              onAmplitudeChanged = onAmplitudeChanged,\n              onClose = {\n                showAudioRecorder = false\n                onSetAudioRecorderVisible(false)\n              },\n            )\n        }\n      }\n    }\n  }\n\n  // A bottom sheet to show the text input history to pick from.\n  if (showTextInputHistorySheet) {\n    TextInputHistorySheet(\n      history = modelManagerUiState.textInputHistory,\n      onDismissed = { showTextInputHistorySheet = false },\n      onHistoryItemClicked = { item ->\n        onSendMessage(\n          createMessagesToSend(\n            pickedImages = pickedImages,\n            audioClips = pickedAudioClips,\n            text = item,\n          )\n        )\n        pickedImages = listOf()\n        pickedAudioClips = listOf()\n        modelManagerViewModel.promoteTextInputHistoryItem(item)\n      },\n      onHistoryItemDeleted = { item -> modelManagerViewModel.deleteTextInputHistory(item) },\n      onHistoryItemsDeleteAll = { modelManagerViewModel.clearTextInputHistory() },\n    )\n  }\n\n  if (showCameraCaptureBottomSheet) {\n    ModalBottomSheet(\n      sheetState = cameraCaptureSheetState,\n      onDismissRequest = { showCameraCaptureBottomSheet = false },\n    ) {\n      val lifecycleOwner = LocalLifecycleOwner.current\n      val previewUseCase = remember { androidx.camera.core.Preview.Builder().build() }\n      val imageCaptureUseCase = remember {\n        // Try to limit the image size.\n        val preferredSize = Size(512, 512)\n        val resolutionStrategy =\n          ResolutionStrategy(\n            preferredSize,\n            ResolutionStrategy.FALLBACK_RULE_CLOSEST_HIGHER_THEN_LOWER,\n          )\n        val resolutionSelector =\n          ResolutionSelector.Builder()\n            .setResolutionStrategy(resolutionStrategy)\n            .setAspectRatioStrategy(AspectRatioStrategy.RATIO_4_3_FALLBACK_AUTO_STRATEGY)\n            .build()\n\n        ImageCapture.Builder().setResolutionSelector(resolutionSelector).build()\n      }\n      var cameraProvider by remember { mutableStateOf<ProcessCameraProvider?>(null) }\n      var cameraControl by remember { mutableStateOf<CameraControl?>(null) }\n      val localContext = LocalContext.current\n      var cameraSide by remember { mutableIntStateOf(CameraSelector.LENS_FACING_BACK) }\n      val executor = remember { Executors.newSingleThreadExecutor() }\n\n      fun rebindCameraProvider() {\n        cameraProvider?.let { cameraProvider ->\n          val cameraSelector = CameraSelector.Builder().requireLensFacing(cameraSide).build()\n          try {\n            cameraProvider.unbindAll()\n            val camera =\n              cameraProvider.bindToLifecycle(\n                lifecycleOwner = lifecycleOwner,\n                cameraSelector = cameraSelector,\n                previewUseCase,\n                imageCaptureUseCase,\n              )\n            cameraControl = camera.cameraControl\n          } catch (e: Exception) {\n            Log.d(TAG, \"Failed to bind camera\", e)\n          }\n        }\n      }\n\n      LaunchedEffect(Unit) {\n        cameraProvider = ProcessCameraProvider.awaitInstance(localContext)\n        rebindCameraProvider()\n      }\n\n      LaunchedEffect(cameraSide) { rebindCameraProvider() }\n\n      DisposableEffect(Unit) { // Or key on lifecycleOwner if it makes more sense\n        onDispose {\n          cameraProvider?.unbindAll() // Unbind all use cases from the camera provider\n          if (!executor.isShutdown) {\n            executor.shutdown() // Shut down the executor service\n          }\n        }\n      }\n\n      Box(modifier = Modifier.fillMaxSize()) {\n        // PreviewView for the camera feed.\n        AndroidView(\n          modifier = Modifier.fillMaxSize(),\n          factory = { ctx ->\n            PreviewView(ctx).also {\n              previewUseCase.surfaceProvider = it.surfaceProvider\n              rebindCameraProvider()\n            }\n          },\n        )\n\n        // Close button.\n        IconButton(\n          onClick = {\n            scope.launch {\n              cameraCaptureSheetState.hide()\n              showCameraCaptureBottomSheet = false\n            }\n          },\n          colors =\n            IconButtonDefaults.iconButtonColors(\n              containerColor = MaterialTheme.colorScheme.surfaceVariant\n            ),\n          modifier = Modifier.offset(x = (-8).dp, y = 8.dp).align(Alignment.TopEnd),\n        ) {\n          Icon(\n            Icons.Rounded.Close,\n            contentDescription = stringResource(R.string.cd_close_icon),\n            tint = MaterialTheme.colorScheme.primary,\n          )\n        }\n\n        // Button that triggers the image capture process\n        IconButton(\n          colors =\n            IconButtonDefaults.iconButtonColors(containerColor = MaterialTheme.colorScheme.primary),\n          modifier =\n            Modifier.align(Alignment.BottomCenter)\n              .padding(bottom = 32.dp)\n              .size(size = 64.dp)\n              .border(width = 2.dp, color = MaterialTheme.colorScheme.onPrimary, CircleShape),\n          onClick = {\n            val callback =\n              object : ImageCapture.OnImageCapturedCallback() {\n                override fun onCaptureSuccess(image: ImageProxy) {\n                  try {\n                    var bitmap = image.toBitmap()\n                    val rotation = sensorObserver.currentRotation + image.imageInfo.rotationDegrees\n                    bitmap =\n                      if (rotation != 0) {\n                        val matrix = Matrix().apply { postRotate(rotation.toFloat()) }\n                        Bitmap.createBitmap(bitmap, 0, 0, bitmap.width, bitmap.height, matrix, true)\n                      } else bitmap\n                    bitmap = resizeBitmap(originalBitmap = bitmap)\n                    updatePickedImages(listOf(bitmap))\n                  } catch (e: Exception) {\n                    Log.e(TAG, \"Failed to process image\", e)\n                  } finally {\n                    image.close()\n                    scope.launch {\n                      cameraCaptureSheetState.hide()\n                      showCameraCaptureBottomSheet = false\n                    }\n                  }\n                }\n              }\n            imageCaptureUseCase.takePicture(executor, callback)\n          },\n        ) {\n          Icon(\n            Icons.Rounded.PhotoCamera,\n            contentDescription = stringResource(R.string.cd_camera_shutter_icon),\n            tint = MaterialTheme.colorScheme.onPrimary,\n            modifier = Modifier.size(36.dp),\n          )\n        }\n\n        // Button that toggles the front and back camera.\n        if (hasFrontCamera) {\n          IconButton(\n            colors =\n              IconButtonDefaults.iconButtonColors(\n                containerColor = MaterialTheme.colorScheme.secondaryContainer\n              ),\n            modifier =\n              Modifier.align(Alignment.BottomEnd).padding(bottom = 40.dp, end = 32.dp).size(48.dp),\n            onClick = {\n              cameraSide =\n                when (cameraSide) {\n                  CameraSelector.LENS_FACING_BACK -> CameraSelector.LENS_FACING_FRONT\n                  else -> CameraSelector.LENS_FACING_BACK\n                }\n            },\n          ) {\n            Icon(\n              Icons.Rounded.FlipCameraAndroid,\n              contentDescription = stringResource(R.string.cd_toggle_front_back_camera_icon),\n              tint = MaterialTheme.colorScheme.onSecondaryContainer,\n              modifier = Modifier.size(24.dp),\n            )\n          }\n        }\n      }\n    }\n  }\n}\n\n@Composable\nprivate fun MediaPanelCloseButton(onClicked: () -> Unit) {\n  Box(\n    modifier =\n      Modifier.offset(x = 10.dp, y = (-10).dp)\n        .clip(CircleShape)\n        .background(MaterialTheme.colorScheme.surface)\n        .border((1.5).dp, MaterialTheme.colorScheme.outline, CircleShape)\n        .clickable { onClicked() }\n  ) {\n    Icon(\n      Icons.Rounded.Close,\n      contentDescription = stringResource(R.string.cd_delete_icon),\n      modifier = Modifier.padding(3.dp).size(16.dp),\n    )\n  }\n}\n\nprivate fun handleImagesSelected(\n  context: Context,\n  uris: List<Uri>,\n  onImagesSelected: (List<Bitmap>) -> Unit,\n) {\n  val images: MutableList<Bitmap> = mutableListOf()\n  for (uri in uris) {\n    val bitmap: Bitmap? =\n      try {\n        val inputStream =\n          if (uri.scheme == null || uri.scheme == \"file\") {\n            FileInputStream(uri.path ?: \"\")\n          } else {\n            context.contentResolver.openInputStream(uri)\n          }\n        if (inputStream != null) {\n          // Read the EXIF metadata from the picture and rotate it correctly.\n          val exif = ExifInterface(inputStream)\n          val orientation =\n            exif.getAttributeInt(ExifInterface.TAG_ORIENTATION, ExifInterface.ORIENTATION_NORMAL)\n          // You MUST close the first input stream before opening another one on the same URI.\n          inputStream.close()\n\n          // The let block will now return the rotated bitmap\n          decodeSampledBitmapFromUri(context, uri, 1024, 1024)?.let { originalBitmap ->\n            rotateBitmap(bitmap = originalBitmap, orientation = orientation)\n          }\n        } else {\n          null\n        }\n      } catch (e: Exception) {\n        e.printStackTrace()\n        null\n      }\n    if (bitmap != null) {\n      images.add(bitmap)\n    }\n  }\n  if (images.isNotEmpty()) {\n    onImagesSelected(images)\n  }\n}\n\nprivate fun handleAudioWavSelected(\n  context: Context,\n  uri: Uri,\n  onAudioSelected: (AudioClip) -> Unit,\n) {\n  convertWavToMonoWithMaxSeconds(context = context, stereoUri = uri)?.let { audioClip ->\n    onAudioSelected(audioClip)\n  }\n}\n\n/**\n * Resizes a given Bitmap to fit within a square of a specified size, while maintaining its original\n * aspect ratio.\n */\nprivate fun resizeBitmap(originalBitmap: Bitmap, size: Int = 1024): Bitmap {\n  val originalWidth = originalBitmap.width\n  val originalHeight = originalBitmap.height\n\n  // Return the original bitmap if it's already within the specified size.\n  if (originalWidth <= size && originalHeight <= size) {\n    return originalBitmap\n  }\n\n  val aspectRatio: Float = originalWidth.toFloat() / originalHeight.toFloat()\n  val newWidth: Int\n  val newHeight: Int\n\n  if (aspectRatio > 1) {\n    // Landscape or square orientation\n    newWidth = size\n    newHeight = (size / aspectRatio).toInt()\n  } else {\n    // Portrait orientation\n    newHeight = size\n    newWidth = (size * aspectRatio).toInt()\n  }\n\n  Log.d(TAG, \"Resizing image from $originalWidth x $originalHeight to $newWidth x $newHeight\")\n\n  // Create a new scaled bitmap using the calculated dimensions\n  return originalBitmap.scale(newWidth, newHeight)\n}\n\nprivate fun checkFrontCamera(context: Context, callback: (Boolean) -> Unit) {\n  val cameraProviderFuture = ProcessCameraProvider.getInstance(context)\n  cameraProviderFuture.addListener(\n    {\n      val cameraProvider = cameraProviderFuture.get()\n      try {\n        // Attempt to select the default front camera\n        val hasFront = cameraProvider.hasCamera(CameraSelector.DEFAULT_FRONT_CAMERA)\n        callback(hasFront)\n      } catch (e: Exception) {\n        e.printStackTrace()\n        callback(false)\n      }\n    },\n    ContextCompat.getMainExecutor(context),\n  )\n}\n\nprivate fun createMessagesToSend(\n  pickedImages: List<Bitmap>,\n  audioClips: List<AudioClip>,\n  text: String,\n): List<ChatMessage> {\n  val messages: MutableList<ChatMessage> = mutableListOf()\n\n  // Add image message.\n  if (pickedImages.isNotEmpty()) {\n    // Cap the number of image messages.\n    var curPickedImages = pickedImages.toList()\n    if (curPickedImages.size > MAX_IMAGE_COUNT) {\n      curPickedImages = curPickedImages.subList(fromIndex = 0, toIndex = MAX_IMAGE_COUNT)\n    }\n    messages.add(\n      ChatMessageImage(\n        bitmaps = curPickedImages,\n        imageBitMaps = curPickedImages.map { it.asImageBitmap() },\n        side = ChatSide.USER,\n      )\n    )\n  }\n\n  // Add audio messages.\n  var audioMessages: MutableList<ChatMessageAudioClip> = mutableListOf()\n  if (audioClips.isNotEmpty()) {\n    for (audioClip in audioClips) {\n      audioMessages.add(\n        ChatMessageAudioClip(\n          audioData = audioClip.audioData,\n          sampleRate = audioClip.sampleRate,\n          side = ChatSide.USER,\n        )\n      )\n    }\n  }\n  // Cap the number of audio messages.\n  if (audioMessages.size > MAX_AUDIO_CLIP_COUNT) {\n    audioMessages = audioMessages.subList(fromIndex = 0, toIndex = MAX_AUDIO_CLIP_COUNT)\n  }\n  messages.addAll(audioMessages)\n\n  if (text.isNotEmpty()) {\n    messages.add(ChatMessageText(content = text, side = ChatSide.USER))\n  }\n\n  return messages\n}\n\n/**\n * A private class that acts as a LifecycleObserver to monitor sensor events for a device's\n * orientation, specifically using the accelerometer.\n *\n * This observer registers for accelerometer events in `onResume` and unregisters in `onPause` to\n * conserve battery and resources. It calculates the device's rotation (0, 90, 180, -90) by checking\n * if the acceleration on the X or Y axis exceeds a threshold of 7.0 m/s^2, which corresponds to\n * gravity's pull when the device is held in a cardinal direction. A 'dead zone' is used to prevent\n * the rotation from \"chattering\" when the device is held at an angle between the cardinal\n * directions.\n */\nprivate class SensorObserver(context: Context) : DefaultLifecycleObserver, SensorEventListener {\n  private val sensorManager = context.getSystemService(Context.SENSOR_SERVICE) as SensorManager\n  private val accelerometer: Sensor? = sensorManager.getDefaultSensor(Sensor.TYPE_ACCELEROMETER)\n\n  var currentRotation = 0\n\n  override fun onResume(owner: LifecycleOwner) {\n    super.onResume(owner)\n    accelerometer?.let {\n      sensorManager.registerListener(this, it, SensorManager.SENSOR_DELAY_NORMAL)\n    }\n  }\n\n  override fun onPause(owner: LifecycleOwner) {\n    super.onPause(owner)\n    sensorManager.unregisterListener(this)\n  }\n\n  override fun onSensorChanged(event: SensorEvent?) {\n    if (event?.sensor?.type == Sensor.TYPE_ACCELEROMETER) {\n      val x = event.values[0]\n      val y = event.values[1]\n\n      // When the phone is on its side, gravity acts primarily along the x-axis.\n      // When the phone is upright, gravity acts primarily along the y-axis.\n      val newOrientation =\n        when {\n          x < -7.0 -> 90\n          x > 7.0 -> -90\n          y < -7.0 -> 180\n          y > 7.0 -> 0\n          else -> currentRotation // Keep the last known orientation\n        }\n\n      if (newOrientation != currentRotation) {\n        currentRotation = newOrientation\n      }\n    }\n  }\n\n  override fun onAccuracyChanged(sensor: Sensor?, accuracy: Int) {}\n}\n"
  },
  {
    "path": "Android/src/app/src/main/java/com/google/ai/edge/gallery/ui/common/chat/MessageLatency.kt",
    "content": "/*\n * Copyright 2025 Google LLC\n *\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n *     http://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\n\npackage com.google.ai.edge.gallery.ui.common.chat\n\n// import androidx.compose.ui.tooling.preview.Preview\n// import com.google.ai.edge.gallery.ui.theme.GalleryTheme\n\nimport androidx.compose.material3.MaterialTheme\nimport androidx.compose.material3.Text\nimport androidx.compose.runtime.Composable\nimport androidx.compose.ui.Modifier\nimport androidx.compose.ui.draw.alpha\nimport androidx.compose.ui.platform.testTag\nimport com.google.ai.edge.gallery.ui.common.humanReadableDuration\n\n/** Composable function to display the latency of a chat message, if available. */\n@Composable\nfun LatencyText(message: ChatMessage) {\n  if (message.latencyMs >= 0) {\n    Text(\n      message.latencyMs.humanReadableDuration(),\n      modifier = Modifier.alpha(0.5f).testTag(\"latency_label\"),\n      style = MaterialTheme.typography.labelSmall,\n    )\n  }\n}\n\n// @Preview(showBackground = true)\n// @Composable\n// fun LatencyTextPreview() {\n//   GalleryTheme {\n//     Column(modifier = Modifier.padding(16.dp), verticalArrangement = Arrangement.spacedBy(16.dp))\n// {\n//       for (latencyMs in listOf(123f, 1234f, 123456f, 7234567f)) {\n//         LatencyText(\n//           message =\n//             ChatMessage(latencyMs = latencyMs, type = ChatMessageType.TEXT, side =\n// ChatSide.AGENT)\n//         )\n//       }\n//     }\n//   }\n// }\n"
  },
  {
    "path": "Android/src/app/src/main/java/com/google/ai/edge/gallery/ui/common/chat/MessageSender.kt",
    "content": "/*\n * Copyright 2025 Google LLC\n *\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n *     http://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\n\npackage com.google.ai.edge.gallery.ui.common.chat\n\n// import androidx.compose.ui.tooling.preview.Preview\n// import com.google.ai.edge.gallery.ui.theme.GalleryTheme\n\nimport androidx.compose.foundation.layout.Arrangement\nimport androidx.compose.foundation.layout.Row\nimport androidx.compose.foundation.layout.Spacer\nimport androidx.compose.foundation.layout.fillMaxWidth\nimport androidx.compose.foundation.layout.padding\nimport androidx.compose.foundation.layout.size\nimport androidx.compose.foundation.layout.width\nimport androidx.compose.material3.CircularProgressIndicator\nimport androidx.compose.material3.MaterialTheme\nimport androidx.compose.material3.Text\nimport androidx.compose.runtime.Composable\nimport androidx.compose.ui.Alignment\nimport androidx.compose.ui.Modifier\nimport androidx.compose.ui.res.stringResource\nimport androidx.compose.ui.unit.dp\nimport com.google.ai.edge.gallery.R\nimport com.google.ai.edge.gallery.ui.theme.bodySmallNarrow\n\ndata class MessageLayoutConfig(\n  val horizontalArrangement: Arrangement.Horizontal,\n  val modifier: Modifier,\n  val userLabel: String,\n  val rightSideLabel: String,\n)\n\n/**\n * Composable function to display the sender information for a chat message.\n *\n * This function handles different types of chat messages, including system messages, benchmark\n * results, and image generation results, and displays the appropriate sender label and status\n * information.\n */\n@Composable\nfun MessageSender(message: ChatMessage, agentName: String = \"\", imageHistoryCurIndex: Int = 0) {\n  // No user label for system messages.\n  if (message.side == ChatSide.SYSTEM) {\n    return\n  }\n\n  val (horizontalArrangement, modifier, userLabel, rightSideLabel) =\n    getMessageLayoutConfig(\n      message = message,\n      agentName = agentName,\n      imageHistoryCurIndex = imageHistoryCurIndex,\n    )\n\n  Row(\n    modifier = modifier,\n    verticalAlignment = Alignment.CenterVertically,\n    horizontalArrangement = horizontalArrangement,\n  ) {\n    Row(verticalAlignment = Alignment.CenterVertically) {\n      // Sender label.\n      Text(userLabel, style = MaterialTheme.typography.titleSmall)\n\n      when (message) {\n        // Benchmark running status.\n        is ChatMessageBenchmarkResult -> {\n          if (message.isRunning()) {\n            Spacer(modifier = Modifier.width(8.dp))\n            CircularProgressIndicator(\n              modifier = Modifier.size(10.dp),\n              strokeWidth = 1.5.dp,\n              color = MaterialTheme.colorScheme.secondary,\n            )\n            Spacer(modifier = Modifier.width(4.dp))\n          }\n          val statusLabel =\n            if (message.isWarmingUp()) {\n              stringResource(R.string.warming_up)\n            } else if (message.isRunning()) {\n              stringResource(R.string.running)\n            } else \"\"\n          if (statusLabel.isNotEmpty()) {\n            Text(statusLabel, color = MaterialTheme.colorScheme.secondary, style = bodySmallNarrow)\n          }\n        }\n\n        // Benchmark LLM running status.\n        is ChatMessageBenchmarkLlmResult -> {\n          if (message.running) {\n            Spacer(modifier = Modifier.width(8.dp))\n            CircularProgressIndicator(\n              modifier = Modifier.size(10.dp),\n              strokeWidth = 1.5.dp,\n              color = MaterialTheme.colorScheme.secondary,\n            )\n          }\n        }\n\n        // Image generation running status.\n        is ChatMessageImageWithHistory -> {\n          if (message.isRunning()) {\n            Spacer(modifier = Modifier.width(8.dp))\n            CircularProgressIndicator(\n              modifier = Modifier.size(10.dp),\n              strokeWidth = 1.5.dp,\n              color = MaterialTheme.colorScheme.secondary,\n            )\n            Spacer(modifier = Modifier.width(4.dp))\n            Text(\n              stringResource(R.string.running),\n              color = MaterialTheme.colorScheme.secondary,\n              style = bodySmallNarrow,\n            )\n          }\n        }\n      }\n    }\n\n    // Right-side text.\n    when (message) {\n      is ChatMessageBenchmarkResult,\n      is ChatMessageImageWithHistory,\n      is ChatMessageBenchmarkLlmResult -> {\n        Text(rightSideLabel, style = MaterialTheme.typography.bodySmall)\n      }\n    }\n  }\n}\n\n@Composable\nprivate fun getMessageLayoutConfig(\n  message: ChatMessage,\n  agentName: String,\n  imageHistoryCurIndex: Int,\n): MessageLayoutConfig {\n  var userLabel = stringResource(R.string.chat_you)\n  var rightSideLabel = \"\"\n  var horizontalArrangement = Arrangement.End\n  var modifier = Modifier.padding(bottom = 2.dp)\n\n  if (message.side == ChatSide.AGENT) {\n    userLabel = agentName\n  }\n\n  when (message) {\n    is ChatMessageBenchmarkResult -> {\n      horizontalArrangement = Arrangement.SpaceBetween\n      modifier = modifier.fillMaxWidth()\n      userLabel = \"Benchmark\"\n      rightSideLabel =\n        if (message.isWarmingUp()) {\n          \"${message.warmupCurrent}/${message.warmupTotal}\"\n        } else {\n          \"${message.iterationCurrent}/${message.iterationTotal}\"\n        }\n    }\n\n    is ChatMessageBenchmarkLlmResult -> {\n      horizontalArrangement = Arrangement.SpaceBetween\n      modifier = modifier.fillMaxWidth()\n      userLabel = \"Stats\"\n      if (message.accelerator.isNotEmpty()) {\n        userLabel = \"${userLabel} on ${message.accelerator}\"\n      }\n    }\n\n    is ChatMessageImageWithHistory -> {\n      horizontalArrangement = Arrangement.SpaceBetween\n      if (message.bitmaps.isNotEmpty()) {\n        modifier = modifier.width(200.dp)\n      }\n      rightSideLabel = \"${imageHistoryCurIndex + 1}/${message.totalIterations}\"\n    }\n  }\n\n  return MessageLayoutConfig(\n    horizontalArrangement = horizontalArrangement,\n    modifier = modifier,\n    userLabel = userLabel,\n    rightSideLabel = rightSideLabel,\n  )\n}\n\n// @Preview(showBackground = true)\n// @Composable\n// fun MessageSenderPreview() {\n//   GalleryTheme {\n//     Column(modifier = Modifier.padding(16.dp), verticalArrangement = Arrangement.spacedBy(16.dp))\n// {\n//       // Agent message.\n//       MessageSender(\n//         message = ChatMessageText(content = \"hello world\", side = ChatSide.AGENT),\n//         agentName = stringResource(R.string.chat_generic_agent_name),\n//       )\n//       // User message.\n//       MessageSender(\n//         message = ChatMessageText(content = \"hello world\", side = ChatSide.USER),\n//         agentName = stringResource(R.string.chat_generic_agent_name),\n//       )\n//       // Benchmark during warmup.\n//       MessageSender(\n//         message =\n//           ChatMessageBenchmarkResult(\n//             orderedStats = listOf(),\n//             statValues = mutableMapOf(),\n//             values = listOf(),\n//             histogram = Histogram(listOf(), 0),\n//             warmupCurrent = 10,\n//             warmupTotal = 50,\n//             iterationCurrent = 0,\n//             iterationTotal = 200,\n//           ),\n//         agentName = stringResource(R.string.chat_generic_agent_name),\n//       )\n//       // Benchmark during running.\n//       MessageSender(\n//         message =\n//           ChatMessageBenchmarkResult(\n//             orderedStats = listOf(),\n//             statValues = mutableMapOf(),\n//             values = listOf(),\n//             histogram = Histogram(listOf(), 0),\n//             warmupCurrent = 50,\n//             warmupTotal = 50,\n//             iterationCurrent = 123,\n//             iterationTotal = 200,\n//           ),\n//         agentName = stringResource(R.string.chat_generic_agent_name),\n//       )\n//       // Image generation during running.\n//       MessageSender(\n//         message =\n//           ChatMessageImageWithHistory(\n//             bitmaps = listOf(Bitmap.createBitmap(100, 100, Bitmap.Config.ARGB_8888)),\n//             imageBitMaps = listOf(),\n//             totalIterations = 10,\n//             ChatSide.AGENT,\n//           ),\n//         agentName = stringResource(R.string.chat_generic_agent_name),\n//         imageHistoryCurIndex = 4,\n//       )\n//     }\n//   }\n// }\n"
  },
  {
    "path": "Android/src/app/src/main/java/com/google/ai/edge/gallery/ui/common/chat/ModelDownloadStatusInfoPanel.kt",
    "content": "/*\n * Copyright 2025 Google LLC\n *\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n *     http://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\n\npackage com.google.ai.edge.gallery.ui.common.chat\n\nimport androidx.compose.animation.AnimatedVisibility\nimport androidx.compose.animation.fadeIn\nimport androidx.compose.animation.fadeOut\nimport androidx.compose.animation.scaleIn\nimport androidx.compose.animation.scaleOut\nimport androidx.compose.foundation.layout.Arrangement\nimport androidx.compose.foundation.layout.Column\nimport androidx.compose.foundation.layout.fillMaxSize\nimport androidx.compose.foundation.layout.fillMaxWidth\nimport androidx.compose.foundation.layout.padding\nimport androidx.compose.material3.MaterialTheme\nimport androidx.compose.material3.Text\nimport androidx.compose.runtime.Composable\nimport androidx.compose.runtime.collectAsState\nimport androidx.compose.runtime.getValue\nimport androidx.compose.ui.Alignment\nimport androidx.compose.ui.Modifier\nimport androidx.compose.ui.text.style.TextAlign\nimport androidx.compose.ui.unit.dp\nimport com.google.ai.edge.gallery.data.Model\nimport com.google.ai.edge.gallery.data.ModelDownloadStatusType\nimport com.google.ai.edge.gallery.data.Task\nimport com.google.ai.edge.gallery.ui.common.DownloadAndTryButton\nimport com.google.ai.edge.gallery.ui.modelmanager.ModelManagerViewModel\n\n@Composable\nfun ModelDownloadStatusInfoPanel(\n  model: Model,\n  task: Task,\n  modelManagerViewModel: ModelManagerViewModel,\n) {\n  val modelManagerUiState by modelManagerViewModel.uiState.collectAsState()\n\n  // Manages the conditional display of UI elements (download model button and downloading\n  // animation) based on the corresponding download status.\n  //\n  // It uses delayed visibility ensuring they are shown only after a short delay if their\n  // respective conditions remain true. This prevents UI flickering and provides a smoother\n  // user experience.\n  val curStatus = modelManagerUiState.modelDownloadStatus[model.name]\n  val downloading =\n    curStatus?.status == ModelDownloadStatusType.IN_PROGRESS ||\n      curStatus?.status == ModelDownloadStatusType.PARTIALLY_DOWNLOADED ||\n      curStatus?.status == ModelDownloadStatusType.UNZIPPING\n\n  Column(\n    modifier = Modifier.fillMaxSize(),\n    horizontalAlignment = Alignment.CenterHorizontally,\n    verticalArrangement = Arrangement.Center,\n  ) {\n    // Animation.\n    Column(verticalArrangement = Arrangement.Bottom, modifier = Modifier.weight(1f)) {\n      AnimatedVisibility(\n        visible = downloading,\n        enter = scaleIn(initialScale = 0.9f) + fadeIn(),\n        exit = scaleOut(targetScale = 0.9f) + fadeOut(),\n      ) {\n        ModelDownloadingAnimation(\n          model = model,\n          task = task,\n          modelManagerViewModel = modelManagerViewModel,\n        )\n      }\n    }\n\n    // Download button and progress.\n    DownloadAndTryButton(\n      task = task,\n      model = model,\n      enabled = true,\n      downloadStatus = curStatus,\n      modelManagerViewModel = modelManagerViewModel,\n      modifier = Modifier.padding(horizontal = 32.dp).padding(top = 4.dp, bottom = 16.dp),\n      onClicked = {},\n      canShowTryIt = false,\n    )\n\n    // Info text.\n    Column(verticalArrangement = Arrangement.Top, modifier = Modifier.weight(1f)) {\n      AnimatedVisibility(\n        visible = downloading,\n        enter = scaleIn(initialScale = 0.9f) + fadeIn(),\n        exit = scaleOut(targetScale = 0.9f) + fadeOut(),\n      ) {\n        Text(\n          \"Feel free to switch apps or lock your device.\\n\" +\n            \"The download will continue in the background.\\n\" +\n            \"We'll send a notification when it's done.\",\n          style = MaterialTheme.typography.bodyLarge,\n          textAlign = TextAlign.Center,\n          modifier = Modifier.fillMaxWidth(),\n        )\n      }\n    }\n  }\n}\n"
  },
  {
    "path": "Android/src/app/src/main/java/com/google/ai/edge/gallery/ui/common/chat/ModelDownloadingAnimation.kt",
    "content": "/*\n * Copyright 2025 Google LLC\n *\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n *     http://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\n\npackage com.google.ai.edge.gallery.ui.common.chat\n\nimport androidx.compose.foundation.layout.Column\nimport androidx.compose.foundation.layout.Row\nimport androidx.compose.foundation.layout.Spacer\nimport androidx.compose.foundation.layout.height\nimport androidx.compose.foundation.layout.padding\nimport androidx.compose.material3.MaterialTheme\nimport androidx.compose.material3.Text\nimport androidx.compose.runtime.Composable\nimport androidx.compose.runtime.collectAsState\nimport androidx.compose.runtime.derivedStateOf\nimport androidx.compose.runtime.getValue\nimport androidx.compose.runtime.remember\nimport androidx.compose.ui.Alignment\nimport androidx.compose.ui.Modifier\nimport androidx.compose.ui.text.style.TextAlign\nimport androidx.compose.ui.text.style.TextOverflow\nimport androidx.compose.ui.unit.dp\nimport com.google.ai.edge.gallery.data.Model\nimport com.google.ai.edge.gallery.data.ModelDownloadStatusType\nimport com.google.ai.edge.gallery.data.Task\nimport com.google.ai.edge.gallery.ui.common.RotationalLoader\nimport com.google.ai.edge.gallery.ui.common.formatToHourMinSecond\nimport com.google.ai.edge.gallery.ui.common.humanReadableSize\nimport com.google.ai.edge.gallery.ui.modelmanager.ModelManagerViewModel\nimport com.google.ai.edge.gallery.ui.theme.labelSmallNarrow\n\n/**\n * Composable function to display a loading animation using a 2x2 grid of images with a synchronized\n * scaling and rotation effect.\n */\n@Composable\nfun ModelDownloadingAnimation(\n  model: Model,\n  task: Task,\n  modelManagerViewModel: ModelManagerViewModel,\n) {\n  val modelManagerUiState by modelManagerViewModel.uiState.collectAsState()\n  val downloadStatus by remember {\n    derivedStateOf { modelManagerUiState.modelDownloadStatus[model.name] }\n  }\n  val inProgress = downloadStatus?.status == ModelDownloadStatusType.IN_PROGRESS\n  val isPartiallyDownloaded = downloadStatus?.status == ModelDownloadStatusType.PARTIALLY_DOWNLOADED\n  var curDownloadProgress = 0f\n\n  // Failure message.\n  val curDownloadStatus = downloadStatus\n  if (curDownloadStatus != null && curDownloadStatus.status == ModelDownloadStatusType.FAILED) {\n    Row(verticalAlignment = Alignment.CenterVertically) {\n      Text(\n        curDownloadStatus.errorMessage,\n        color = MaterialTheme.colorScheme.error,\n        style = labelSmallNarrow,\n        overflow = TextOverflow.Ellipsis,\n      )\n    }\n  }\n  // No failure\n  else {\n    Column(\n      horizontalAlignment = Alignment.CenterHorizontally,\n      modifier = Modifier.padding(top = 32.dp),\n    ) {\n      // Loader.\n      RotationalLoader(size = 160.dp)\n\n      Spacer(modifier = Modifier.height(32.dp))\n\n      // Download stats\n      var sizeLabel = model.totalBytes.humanReadableSize()\n      if (curDownloadStatus != null) {\n        // For in-progress model, show {receivedSize} / {totalSize} - {rate} - {remainingTime}\n        if (inProgress || isPartiallyDownloaded) {\n          var totalSize = curDownloadStatus.totalBytes\n          if (totalSize == 0L) {\n            totalSize = model.totalBytes\n          }\n          sizeLabel =\n            \"${curDownloadStatus.receivedBytes.humanReadableSize(extraDecimalForGbAndAbove = true)} of ${totalSize.humanReadableSize()}\"\n          if (curDownloadStatus.bytesPerSecond > 0) {\n            sizeLabel = \"$sizeLabel · ${curDownloadStatus.bytesPerSecond.humanReadableSize()} / s\"\n            if (curDownloadStatus.remainingMs >= 0) {\n              sizeLabel =\n                \"$sizeLabel · ${curDownloadStatus.remainingMs.formatToHourMinSecond()} left\"\n            }\n          }\n          if (isPartiallyDownloaded) {\n            sizeLabel = \"$sizeLabel (resuming...)\"\n          }\n          curDownloadProgress =\n            curDownloadStatus.receivedBytes.toFloat() / curDownloadStatus.totalBytes.toFloat()\n          if (curDownloadProgress.isNaN()) {\n            curDownloadProgress = 0f\n          }\n        }\n        // Status for unzipping.\n        else if (curDownloadStatus.status == ModelDownloadStatusType.UNZIPPING) {\n          sizeLabel = \"Unzipping...\"\n        }\n        Text(\n          sizeLabel,\n          color = MaterialTheme.colorScheme.onSurfaceVariant,\n          style = MaterialTheme.typography.labelMedium,\n          textAlign = TextAlign.Center,\n          overflow = TextOverflow.Visible,\n          modifier = Modifier.padding(bottom = 4.dp),\n        )\n      }\n    }\n  }\n}\n"
  },
  {
    "path": "Android/src/app/src/main/java/com/google/ai/edge/gallery/ui/common/chat/ModelInitializationStatus.kt",
    "content": "/*\n * Copyright 2025 Google LLC\n *\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n *     http://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\n\npackage com.google.ai.edge.gallery.ui.common.chat\n\n// import androidx.compose.ui.tooling.preview.Preview\n// import com.google.ai.edge.gallery.ui.theme.GalleryTheme\n\nimport androidx.compose.foundation.background\nimport androidx.compose.foundation.layout.Arrangement\nimport androidx.compose.foundation.layout.Box\nimport androidx.compose.foundation.layout.Row\nimport androidx.compose.foundation.layout.Spacer\nimport androidx.compose.foundation.layout.fillMaxWidth\nimport androidx.compose.foundation.layout.padding\nimport androidx.compose.foundation.layout.size\nimport androidx.compose.foundation.layout.width\nimport androidx.compose.foundation.shape.CircleShape\nimport androidx.compose.material3.CircularProgressIndicator\nimport androidx.compose.material3.MaterialTheme\nimport androidx.compose.material3.Text\nimport androidx.compose.runtime.Composable\nimport androidx.compose.ui.Alignment\nimport androidx.compose.ui.Modifier\nimport androidx.compose.ui.draw.clip\nimport androidx.compose.ui.res.stringResource\nimport androidx.compose.ui.unit.dp\nimport com.google.ai.edge.gallery.R\n\n/**\n * Composable function to display a visual indicator for model initialization status.\n *\n * This function renders a row containing a circular progress indicator and a message indicating\n * that the model is currently initializing. It provides a visual cue to the user that the model is\n * in a loading state.\n */\n@Composable\nfun ModelInitializationStatusChip() {\n  Row(modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.Center) {\n    Box(\n      modifier =\n        Modifier.padding(8.dp)\n          .clip(CircleShape)\n          .background(MaterialTheme.colorScheme.secondaryContainer)\n    ) {\n      Row(\n        modifier = Modifier.padding(top = 4.dp, bottom = 4.dp, start = 8.dp, end = 8.dp),\n        horizontalArrangement = Arrangement.Center,\n        verticalAlignment = Alignment.CenterVertically,\n      ) {\n        // Circular progress indicator.\n        CircularProgressIndicator(\n          modifier = Modifier.size(14.dp),\n          strokeWidth = 2.dp,\n          color = MaterialTheme.colorScheme.onSecondaryContainer,\n        )\n\n        Spacer(modifier = Modifier.width(8.dp))\n\n        // Text message.\n        Text(\n          stringResource(R.string.model_is_initializing_msg),\n          style = MaterialTheme.typography.bodySmall,\n          color = MaterialTheme.colorScheme.onSecondaryContainer,\n        )\n      }\n    }\n  }\n}\n\n// @Preview(showBackground = true)\n// @Composable\n// fun ModelInitializationStatusPreview() {\n//   GalleryTheme { ModelInitializationStatusChip() }\n// }\n"
  },
  {
    "path": "Android/src/app/src/main/java/com/google/ai/edge/gallery/ui/common/chat/ModelNotDownloaded.kt",
    "content": "/*\n * Copyright 2025 Google LLC\n *\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n *     http://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\n\npackage com.google.ai.edge.gallery.ui.common.chat\n\n// import androidx.compose.ui.tooling.preview.Preview\n// import com.google.ai.edge.gallery.ui.theme.GalleryTheme\n\nimport androidx.compose.foundation.layout.Arrangement\nimport androidx.compose.foundation.layout.Column\nimport androidx.compose.foundation.layout.fillMaxSize\nimport androidx.compose.material3.Button\nimport androidx.compose.material3.Text\nimport androidx.compose.runtime.Composable\nimport androidx.compose.ui.Alignment\nimport androidx.compose.ui.Modifier\n\n/**\n * Composable function to display a button to download model if the model has not been downloaded.\n */\n@Composable\nfun ModelNotDownloaded(modifier: Modifier = Modifier, onClicked: () -> Unit) {\n  Column(\n    modifier = modifier.fillMaxSize(),\n    verticalArrangement = Arrangement.Center,\n    horizontalAlignment = Alignment.CenterHorizontally,\n  ) {\n    Button(onClick = onClicked) { Text(\"Download & Try it\", maxLines = 1) }\n  }\n}\n\n// @Preview(showBackground = true)\n// @Composable\n// fun Preview() {\n//   GalleryTheme { ModelNotDownloaded(onClicked = {}) }\n// }\n"
  },
  {
    "path": "Android/src/app/src/main/java/com/google/ai/edge/gallery/ui/common/chat/TextInputHistorySheet.kt",
    "content": "/*\n * Copyright 2025 Google LLC\n *\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n *     http://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\n\npackage com.google.ai.edge.gallery.ui.common.chat\n\n// import androidx.compose.ui.tooling.preview.Preview\n// import com.google.ai.edge.gallery.ui.theme.GalleryTheme\nimport androidx.compose.foundation.background\nimport androidx.compose.foundation.clickable\nimport androidx.compose.foundation.layout.Arrangement\nimport androidx.compose.foundation.layout.Box\nimport androidx.compose.foundation.layout.Column\nimport androidx.compose.foundation.layout.Row\nimport androidx.compose.foundation.layout.fillMaxWidth\nimport androidx.compose.foundation.layout.padding\nimport androidx.compose.foundation.layout.wrapContentHeight\nimport androidx.compose.foundation.lazy.LazyColumn\nimport androidx.compose.foundation.lazy.items\nimport androidx.compose.foundation.shape.RoundedCornerShape\nimport androidx.compose.material.icons.Icons\nimport androidx.compose.material.icons.rounded.Delete\nimport androidx.compose.material.icons.rounded.DeleteSweep\nimport androidx.compose.material3.AlertDialog\nimport androidx.compose.material3.Button\nimport androidx.compose.material3.ExperimentalMaterial3Api\nimport androidx.compose.material3.Icon\nimport androidx.compose.material3.IconButton\nimport androidx.compose.material3.MaterialTheme\nimport androidx.compose.material3.ModalBottomSheet\nimport androidx.compose.material3.Text\nimport androidx.compose.material3.TextButton\nimport androidx.compose.material3.rememberModalBottomSheetState\nimport androidx.compose.runtime.Composable\nimport androidx.compose.runtime.getValue\nimport androidx.compose.runtime.mutableStateOf\nimport androidx.compose.runtime.remember\nimport androidx.compose.runtime.rememberCoroutineScope\nimport androidx.compose.runtime.setValue\nimport androidx.compose.ui.Alignment\nimport androidx.compose.ui.Modifier\nimport androidx.compose.ui.draw.clip\nimport androidx.compose.ui.res.stringResource\nimport androidx.compose.ui.text.style.TextAlign\nimport androidx.compose.ui.text.style.TextOverflow\nimport androidx.compose.ui.unit.dp\nimport com.google.ai.edge.gallery.R\nimport com.google.ai.edge.gallery.ui.theme.customColors\nimport kotlinx.coroutines.delay\nimport kotlinx.coroutines.launch\n\n@OptIn(ExperimentalMaterial3Api::class)\n@Composable\nfun TextInputHistorySheet(\n  history: List<String>,\n  onHistoryItemClicked: (String) -> Unit,\n  onHistoryItemDeleted: (String) -> Unit,\n  onHistoryItemsDeleteAll: () -> Unit,\n  onDismissed: () -> Unit,\n) {\n  val sheetState = rememberModalBottomSheetState()\n  val scope = rememberCoroutineScope()\n\n  ModalBottomSheet(\n    onDismissRequest = onDismissed,\n    sheetState = sheetState,\n    modifier = Modifier.wrapContentHeight(),\n  ) {\n    SheetContent(\n      history = history,\n      onHistoryItemClicked = { item ->\n        scope.launch {\n          sheetState.hide()\n          delay(100)\n          onHistoryItemClicked(item)\n          onDismissed()\n        }\n      },\n      onHistoryItemDeleted = onHistoryItemDeleted,\n      onHistoryItemsDeleteAll = {\n        scope.launch {\n          sheetState.hide()\n          onDismissed()\n          onHistoryItemsDeleteAll()\n        }\n      },\n      onDismissed = {\n        scope.launch {\n          sheetState.hide()\n          onDismissed()\n        }\n      },\n    )\n  }\n}\n\n@Composable\nprivate fun SheetContent(\n  history: List<String>,\n  onHistoryItemClicked: (String) -> Unit,\n  onHistoryItemDeleted: (String) -> Unit,\n  onHistoryItemsDeleteAll: () -> Unit,\n  onDismissed: () -> Unit,\n) {\n  val scope = rememberCoroutineScope()\n  var showConfirmDeleteDialog by remember { mutableStateOf(false) }\n\n  Column {\n    Box(contentAlignment = Alignment.CenterEnd) {\n      Text(\n        \"Text input history\",\n        style = MaterialTheme.typography.titleLarge,\n        modifier = Modifier.fillMaxWidth().padding(8.dp),\n        textAlign = TextAlign.Center,\n      )\n      IconButton(\n        modifier = Modifier.padding(end = 12.dp),\n        onClick = { showConfirmDeleteDialog = true },\n      ) {\n        Icon(\n          Icons.Rounded.DeleteSweep,\n          contentDescription = stringResource(R.string.cd_clear_input_history_icon),\n        )\n      }\n    }\n    LazyColumn(modifier = Modifier.weight(1f)) {\n      items(history, key = { it }) { item ->\n        Row(\n          modifier =\n            Modifier.fillMaxWidth()\n              .padding(horizontal = 8.dp, vertical = 2.dp)\n              .clip(RoundedCornerShape(24.dp))\n              .background(MaterialTheme.customColors.agentBubbleBgColor)\n              .clickable { onHistoryItemClicked(item) },\n          verticalAlignment = Alignment.CenterVertically,\n          horizontalArrangement = Arrangement.spacedBy(8.dp),\n        ) {\n          Text(\n            item,\n            style = MaterialTheme.typography.bodyMedium,\n            maxLines = 3,\n            overflow = TextOverflow.Ellipsis,\n            modifier = Modifier.padding(vertical = 16.dp).padding(start = 16.dp).weight(1f),\n          )\n          IconButton(\n            modifier = Modifier.padding(end = 8.dp),\n            onClick = {\n              scope.launch {\n                delay(400)\n                onHistoryItemDeleted(item)\n              }\n            },\n          ) {\n            Icon(\n              Icons.Rounded.Delete,\n              contentDescription = stringResource(R.string.cd_delete_input_history_entry_icon),\n            )\n          }\n        }\n      }\n    }\n  }\n\n  if (showConfirmDeleteDialog) {\n    AlertDialog(\n      onDismissRequest = { showConfirmDeleteDialog = false },\n      title = { Text(\"Clear history?\") },\n      text = { Text(\"Are you sure you want to clear the history? This action cannot be undone.\") },\n      confirmButton = {\n        Button(\n          onClick = {\n            showConfirmDeleteDialog = false\n            onHistoryItemsDeleteAll()\n          }\n        ) {\n          Text(stringResource(R.string.ok))\n        }\n      },\n      dismissButton = {\n        TextButton(onClick = { showConfirmDeleteDialog = false }) {\n          Text(stringResource(R.string.cancel))\n        }\n      },\n    )\n  }\n}\n\n// @Preview(showBackground = true)\n// @Composable\n// fun TextInputHistorySheetContentPreview() {\n//   GalleryTheme {\n//     SheetContent(\n//       history =\n//         listOf(\n//           \"Analyze the sentiment of the following Tweets and classify them as POSITIVE, NEGATIVE,\n// or NEUTRAL. \\\"It's so beautiful today!\\\"\",\n//           \"I have the ingredients above. Not sure what to cook for lunch. Show me a list of foods\n// with the recipes.\",\n//           \"You are Santa Claus, write a letter back for this kid.\",\n//           \"Generate a list of cookie recipes. Make the outputs in JSON format.\",\n//         ),\n//       onHistoryItemClicked = {},\n//       onHistoryItemDeleted = {},\n//       onHistoryItemsDeleteAll = {},\n//       onDismissed = {},\n//     )\n//   }\n// }\n"
  },
  {
    "path": "Android/src/app/src/main/java/com/google/ai/edge/gallery/ui/common/chat/ZoomableImage.kt",
    "content": "/*\n * Copyright 2025 Google LLC\n *\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n *     http://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\n\npackage com.google.ai.edge.gallery.ui.common.chat\n\nimport androidx.compose.foundation.Image\nimport androidx.compose.foundation.MutatePriority\nimport androidx.compose.foundation.background\nimport androidx.compose.foundation.gestures.awaitEachGesture\nimport androidx.compose.foundation.gestures.awaitFirstDown\nimport androidx.compose.foundation.gestures.calculatePan\nimport androidx.compose.foundation.gestures.calculateZoom\nimport androidx.compose.foundation.layout.Box\nimport androidx.compose.foundation.pager.PagerState\nimport androidx.compose.foundation.shape.RoundedCornerShape\nimport androidx.compose.runtime.Composable\nimport androidx.compose.runtime.LaunchedEffect\nimport androidx.compose.runtime.mutableFloatStateOf\nimport androidx.compose.runtime.remember\nimport androidx.compose.runtime.rememberCoroutineScope\nimport androidx.compose.ui.Alignment\nimport androidx.compose.ui.Modifier\nimport androidx.compose.ui.draw.clip\nimport androidx.compose.ui.graphics.Color\nimport androidx.compose.ui.graphics.ImageBitmap\nimport androidx.compose.ui.graphics.graphicsLayer\nimport androidx.compose.ui.input.pointer.pointerInput\nimport androidx.compose.ui.layout.ContentScale\nimport androidx.compose.ui.unit.dp\nimport kotlin.math.max\nimport kotlin.math.min\nimport kotlinx.coroutines.awaitCancellation\nimport kotlinx.coroutines.launch\n\n/**\n * A Composable function that displays a zoomable and pannable image.\n *\n * This function handles multi-touch gestures for zooming and panning. It's designed to be used\n * within a Pager to prevent the Pager from scrolling while the user is interacting with the image.\n */\n@Composable\nfun ZoomableImage(\n  bitmap: ImageBitmap,\n  modifier: Modifier = Modifier,\n  minScale: Float = 1f,\n  maxScale: Float = 3f,\n  contentScale: ContentScale = ContentScale.Fit,\n  pagerState: PagerState? = null,\n  resetOnImageUpdate: Boolean = true,\n  enabled: Boolean = true,\n  twoFingerOnly: Boolean = false,\n  onTransformed: (offsetX: Float, offsetY: Float, scale: Float) -> Unit = { _, _, _ -> },\n) {\n  val scale = remember { mutableFloatStateOf(1f) }\n  val offsetX = remember { mutableFloatStateOf(0f) }\n  val offsetY = remember { mutableFloatStateOf(0f) }\n  val coroutineScope = rememberCoroutineScope()\n\n  LaunchedEffect(bitmap) {\n    if (resetOnImageUpdate) {\n      scale.floatValue = 1f\n      offsetX.floatValue = 0f\n      offsetY.floatValue = 0f\n    }\n  }\n\n  val gestureModifier =\n    if (enabled) {\n      Modifier.pointerInput(twoFingerOnly) { // Only apply if enabled is true\n        // It uses the `pointerInput` modifier to detect gestures.\n        //\n        // When a user performs a pinch-to-zoom gesture, the `scale` state is updated.\n        // Once the content is zoomed in (`scale.value > 1`), pan gestures are enabled, and the\n        // `offsetX` and `offsetY` states are updated to move the content.\n        //\n        // To prevent a parent Pager component from scrolling horizontally during a pan gesture, the\n        // `pagerState`'s scrolling is temporarily disabled and then re-enabled after the pan event.\n        // If the content is zoomed back out to its original size, the scale and offsets are reset.\n        awaitEachGesture {\n          awaitFirstDown()\n          do {\n            val event = awaitPointerEvent()\n            val isTwoFingerGesture = event.changes.size >= 2\n            if ((twoFingerOnly && isTwoFingerGesture) || !twoFingerOnly) {\n              scale.floatValue *= event.calculateZoom()\n              scale.floatValue = max(min(scale.floatValue, maxScale), minScale)\n              coroutineScope.launch { pagerState?.setScrolling(false) }\n              val offset = event.calculatePan()\n              offsetX.floatValue += offset.x\n              offsetY.floatValue += offset.y\n\n              // Consume the event so the parent Pager does not receive it\n              if (twoFingerOnly) {\n                event.changes.forEach { it.consume() }\n              }\n\n              coroutineScope.launch { pagerState?.setScrolling(true) }\n              onTransformed(offsetX.floatValue, offsetY.floatValue, scale.floatValue)\n            }\n          } while (event.changes.any { it.pressed })\n        }\n      }\n    } else {\n      // Return an empty modifier if disabled, effectively disabling interaction\n      Modifier\n    }\n\n  Box(\n    contentAlignment = Alignment.Center,\n    modifier =\n      modifier.background(Color.Transparent).clip(RoundedCornerShape(0.dp)).then(gestureModifier),\n  ) {\n    Image(\n      bitmap = bitmap,\n      contentDescription = null,\n      contentScale = contentScale,\n      modifier =\n        Modifier.align(Alignment.Center).graphicsLayer {\n          scaleX = maxOf(minScale, minOf(maxScale, scale.floatValue))\n          scaleY = maxOf(minScale, minOf(maxScale, scale.floatValue))\n          translationX = offsetX.floatValue\n          translationY = offsetY.floatValue\n        },\n    )\n  }\n}\n\n/**\n * An extension function on [PagerState] to temporarily disable or enable scrolling.\n *\n * This function uses a [MutatePriority.PreventUserInput] scroll block to ensure that no other\n * scrolls (like the user swiping) can happen while this block is active.\n */\nsuspend fun PagerState.setScrolling(value: Boolean) {\n  scroll(scrollPriority = MutatePriority.PreventUserInput) {\n    when (value) {\n      true -> Unit\n      else -> awaitCancellation()\n    }\n  }\n}\n"
  },
  {
    "path": "Android/src/app/src/main/java/com/google/ai/edge/gallery/ui/common/modelitem/ConfirmDeleteModelDialog.kt",
    "content": "/*\n * Copyright 2025 Google LLC\n *\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n *     http://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\n\npackage com.google.ai.edge.gallery.ui.common.modelitem\n\nimport androidx.compose.material3.AlertDialog\nimport androidx.compose.material3.Button\nimport androidx.compose.material3.Text\nimport androidx.compose.material3.TextButton\nimport androidx.compose.runtime.Composable\nimport androidx.compose.ui.res.stringResource\nimport com.google.ai.edge.gallery.R\nimport com.google.ai.edge.gallery.data.Model\n\n/** Composable function to display a confirmation dialog for deleting a model. */\n@Composable\nfun ConfirmDeleteModelDialog(model: Model, onConfirm: () -> Unit, onDismiss: () -> Unit) {\n  AlertDialog(\n    onDismissRequest = onDismiss,\n    title = { Text(stringResource(R.string.confirm_delete_model_dialog_title)) },\n    text = {\n      Text(stringResource(R.string.confirm_delete_model_dialog_content).format(model.name))\n    },\n    confirmButton = { Button(onClick = onConfirm) { Text(stringResource(R.string.ok)) } },\n    dismissButton = { TextButton(onClick = onDismiss) { Text(stringResource(R.string.cancel)) } },\n  )\n}\n"
  },
  {
    "path": "Android/src/app/src/main/java/com/google/ai/edge/gallery/ui/common/modelitem/DeleteModelButton.kt",
    "content": "/*\n * Copyright 2025 Google LLC\n *\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n *     http://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\n\npackage com.google.ai.edge.gallery.ui.common.modelitem\n\nimport androidx.compose.foundation.layout.Row\nimport androidx.compose.material.icons.Icons\nimport androidx.compose.material.icons.outlined.Delete\nimport androidx.compose.material3.Icon\nimport androidx.compose.material3.IconButton\nimport androidx.compose.material3.MaterialTheme\nimport androidx.compose.runtime.Composable\nimport androidx.compose.runtime.getValue\nimport androidx.compose.runtime.mutableStateOf\nimport androidx.compose.runtime.remember\nimport androidx.compose.runtime.setValue\nimport androidx.compose.ui.Alignment\nimport androidx.compose.ui.Modifier\nimport androidx.compose.ui.draw.alpha\nimport androidx.compose.ui.res.stringResource\nimport com.google.ai.edge.gallery.R\nimport com.google.ai.edge.gallery.data.Model\nimport com.google.ai.edge.gallery.data.ModelDownloadStatus\nimport com.google.ai.edge.gallery.data.ModelDownloadStatusType\nimport com.google.ai.edge.gallery.ui.modelmanager.ModelManagerViewModel\n\n/** Composable function to display a button for deleting the downloaded model. */\n@Composable\nfun DeleteModelButton(\n  model: Model,\n  modelManagerViewModel: ModelManagerViewModel,\n  downloadStatus: ModelDownloadStatus?,\n  modifier: Modifier = Modifier,\n  showDeleteButton: Boolean = true,\n) {\n  var showConfirmDeleteDialog by remember { mutableStateOf(false) }\n\n  Row(verticalAlignment = Alignment.CenterVertically, modifier = modifier) {\n    when (downloadStatus?.status) {\n      // Button to delete the download.\n      ModelDownloadStatusType.SUCCEEDED -> {\n        if (showDeleteButton) {\n          IconButton(onClick = { showConfirmDeleteDialog = true }) {\n            Icon(\n              Icons.Outlined.Delete,\n              contentDescription = stringResource(R.string.cd_delete_icon),\n              tint = MaterialTheme.colorScheme.onSurfaceVariant,\n              modifier = Modifier.alpha(0.6f),\n            )\n          }\n        }\n      }\n\n      else -> {}\n    }\n  }\n\n  if (showConfirmDeleteDialog) {\n    ConfirmDeleteModelDialog(\n      model = model,\n      onConfirm = {\n        modelManagerViewModel.deleteModel(model = model)\n        showConfirmDeleteDialog = false\n      },\n      onDismiss = { showConfirmDeleteDialog = false },\n    )\n  }\n}\n"
  },
  {
    "path": "Android/src/app/src/main/java/com/google/ai/edge/gallery/ui/common/modelitem/DownloadModelPanel.kt",
    "content": "/*\n * Copyright 2025 Google LLC\n *\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n *     http://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\n\npackage com.google.ai.edge.gallery.ui.common.modelitem\n\nimport androidx.compose.animation.AnimatedVisibilityScope\nimport androidx.compose.animation.ExperimentalSharedTransitionApi\nimport androidx.compose.animation.SharedTransitionScope\nimport androidx.compose.foundation.layout.Arrangement\nimport androidx.compose.foundation.layout.PaddingValues\nimport androidx.compose.foundation.layout.Row\nimport androidx.compose.foundation.layout.Spacer\nimport androidx.compose.foundation.layout.fillMaxWidth\nimport androidx.compose.foundation.layout.height\nimport androidx.compose.foundation.layout.width\nimport androidx.compose.foundation.text.TextAutoSize\nimport androidx.compose.material.icons.Icons\nimport androidx.compose.material.icons.rounded.BarChart\nimport androidx.compose.material3.Button\nimport androidx.compose.material3.ButtonDefaults\nimport androidx.compose.material3.Icon\nimport androidx.compose.material3.MaterialTheme\nimport androidx.compose.material3.Text\nimport androidx.compose.runtime.Composable\nimport androidx.compose.ui.Alignment\nimport androidx.compose.ui.Modifier\nimport androidx.compose.ui.res.stringResource\nimport androidx.compose.ui.unit.dp\nimport androidx.compose.ui.unit.sp\nimport com.google.ai.edge.gallery.R\nimport com.google.ai.edge.gallery.data.Model\nimport com.google.ai.edge.gallery.data.ModelDownloadStatus\nimport com.google.ai.edge.gallery.data.ModelDownloadStatusType\nimport com.google.ai.edge.gallery.data.Task\nimport com.google.ai.edge.gallery.ui.common.DownloadAndTryButton\nimport com.google.ai.edge.gallery.ui.modelmanager.ModelManagerViewModel\n\n@OptIn(ExperimentalSharedTransitionApi::class)\n@Composable\nfun DownloadModelPanel(\n  model: Model,\n  task: Task?,\n  modelManagerViewModel: ModelManagerViewModel,\n  downloadStatus: ModelDownloadStatus?,\n  isExpanded: Boolean,\n  sharedTransitionScope: SharedTransitionScope,\n  animatedVisibilityScope: AnimatedVisibilityScope,\n  onTryItClicked: () -> Unit,\n  onBenchmarkClicked: () -> Unit,\n  modifier: Modifier = Modifier,\n  showBenchmarkButton: Boolean = false,\n) {\n  val downloadSucceeded = downloadStatus?.status == ModelDownloadStatusType.SUCCEEDED\n  with(sharedTransitionScope) {\n    Row(\n      modifier = modifier.fillMaxWidth(),\n      horizontalArrangement = Arrangement.End,\n      verticalAlignment = Alignment.CenterVertically,\n    ) {\n      if (showBenchmarkButton && downloadSucceeded) {\n        // Benchmark button.\n        var buttonModifier: Modifier = Modifier.height(42.dp)\n        if (isExpanded) {\n          buttonModifier = buttonModifier.weight(1f)\n        }\n        Button(\n          modifier =\n            Modifier.sharedElement(\n                sharedContentState = rememberSharedContentState(key = \"benchmark_button\"),\n                animatedVisibilityScope = animatedVisibilityScope,\n              )\n              .then(buttonModifier),\n          colors =\n            ButtonDefaults.buttonColors(\n              containerColor = MaterialTheme.colorScheme.secondaryContainer\n            ),\n          contentPadding = PaddingValues(horizontal = 12.dp),\n          onClick = onBenchmarkClicked,\n        ) {\n          val textColor = MaterialTheme.colorScheme.onSecondaryContainer\n          Row(\n            verticalAlignment = Alignment.CenterVertically,\n            horizontalArrangement = Arrangement.spacedBy(8.dp),\n          ) {\n            Icon(Icons.Rounded.BarChart, contentDescription = null, tint = textColor)\n\n            if (isExpanded) {\n              Text(\n                stringResource(R.string.benchmark),\n                color = textColor,\n                style = MaterialTheme.typography.titleMedium,\n                maxLines = 1,\n                autoSize =\n                  TextAutoSize.StepBased(minFontSize = 8.sp, maxFontSize = 16.sp, stepSize = 1.sp),\n              )\n            }\n          }\n        }\n\n        Spacer(modifier = Modifier.width(8.dp))\n      }\n\n      DownloadAndTryButton(\n        task = task,\n        model = model,\n        downloadStatus = downloadStatus,\n        enabled = true,\n        modelManagerViewModel = modelManagerViewModel,\n        onClicked = onTryItClicked,\n        compact = !isExpanded,\n        modifier =\n          Modifier.sharedElement(\n            sharedContentState = rememberSharedContentState(key = \"download_button\"),\n            animatedVisibilityScope = animatedVisibilityScope,\n          ),\n        modifierWhenExpanded = Modifier.weight(1f),\n      )\n    }\n  }\n}\n"
  },
  {
    "path": "Android/src/app/src/main/java/com/google/ai/edge/gallery/ui/common/modelitem/ModelItem.kt",
    "content": "/*\n * Copyright 2025 Google LLC\n *\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n *     http://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\n\npackage com.google.ai.edge.gallery.ui.common.modelitem\n\nimport androidx.compose.animation.AnimatedContent\nimport androidx.compose.animation.ExperimentalSharedTransitionApi\nimport androidx.compose.animation.SharedTransitionLayout\nimport androidx.compose.foundation.background\nimport androidx.compose.foundation.clickable\nimport androidx.compose.foundation.interaction.MutableInteractionSource\nimport androidx.compose.foundation.layout.Arrangement\nimport androidx.compose.foundation.layout.Box\nimport androidx.compose.foundation.layout.Column\nimport androidx.compose.foundation.layout.Row\nimport androidx.compose.foundation.layout.fillMaxWidth\nimport androidx.compose.foundation.layout.offset\nimport androidx.compose.foundation.layout.padding\nimport androidx.compose.foundation.shape.RoundedCornerShape\nimport androidx.compose.material.icons.Icons\nimport androidx.compose.material.icons.rounded.UnfoldLess\nimport androidx.compose.material.icons.rounded.UnfoldMore\nimport androidx.compose.material3.Icon\nimport androidx.compose.material3.MaterialTheme\nimport androidx.compose.material3.ripple\nimport androidx.compose.runtime.Composable\nimport androidx.compose.runtime.collectAsState\nimport androidx.compose.runtime.derivedStateOf\nimport androidx.compose.runtime.getValue\nimport androidx.compose.runtime.mutableStateOf\nimport androidx.compose.runtime.remember\nimport androidx.compose.runtime.setValue\nimport androidx.compose.ui.Alignment\nimport androidx.compose.ui.Modifier\nimport androidx.compose.ui.draw.alpha\nimport androidx.compose.ui.draw.clip\nimport androidx.compose.ui.res.stringResource\nimport androidx.compose.ui.semantics.isTraversalGroup\nimport androidx.compose.ui.semantics.semantics\nimport androidx.compose.ui.unit.dp\nimport com.google.ai.edge.gallery.R\nimport com.google.ai.edge.gallery.data.Model\nimport com.google.ai.edge.gallery.data.Task\nimport com.google.ai.edge.gallery.ui.common.MarkdownText\nimport com.google.ai.edge.gallery.ui.modelmanager.ModelManagerViewModel\nimport com.google.ai.edge.gallery.ui.theme.customColors\n\n/**\n * Composable function to display a model item in the model manager list.\n *\n * This function renders a card representing a model, displaying its task icon, name, download\n * status, and providing action buttons. It supports expanding to show a model description and\n * buttons for learning more (opening a URL) and downloading/trying the model.\n */\n@OptIn(ExperimentalSharedTransitionApi::class)\n@Composable\nfun ModelItem(\n  model: Model,\n  task: Task?,\n  modelManagerViewModel: ModelManagerViewModel,\n  onModelClicked: (Model) -> Unit,\n  onBenchmarkClicked: (Model) -> Unit,\n  modifier: Modifier = Modifier,\n  expanded: Boolean? = null,\n  showDeleteButton: Boolean = true,\n  canExpand: Boolean = true,\n  showBenchmarkButton: Boolean = false,\n  onExpanded: (Boolean) -> Unit = {},\n) {\n  val modelManagerUiState by modelManagerViewModel.uiState.collectAsState()\n  val downloadStatus by remember {\n    derivedStateOf { modelManagerUiState.modelDownloadStatus[model.name] }\n  }\n\n  val isBestOverall = model.bestForTaskIds.contains(task?.id ?: \"\")\n  var isExpanded by remember { mutableStateOf(expanded ?: isBestOverall) }\n\n  var boxModifier =\n    modifier\n      .fillMaxWidth()\n      .clip(RoundedCornerShape(size = 12.dp))\n      .background(color = MaterialTheme.customColors.taskCardBgColor)\n  boxModifier =\n    if (canExpand) {\n      boxModifier.clickable(\n        onClick = {\n          if (!model.imported) {\n            isExpanded = !isExpanded\n            onExpanded(isExpanded)\n          } else if (!showBenchmarkButton) {\n            onModelClicked(model)\n          }\n        },\n        interactionSource = remember { MutableInteractionSource() },\n        indication = ripple(bounded = true, radius = 1000.dp),\n      )\n    } else {\n      boxModifier\n    }\n\n  Box(modifier = boxModifier) {\n    Column(modifier = Modifier.padding(16.dp), verticalArrangement = Arrangement.spacedBy(8.dp)) {\n      Row(\n        horizontalArrangement = Arrangement.spacedBy(8.dp),\n        modifier = Modifier.semantics { isTraversalGroup = true },\n      ) {\n        ModelNameAndStatus(\n          model = model,\n          task = task,\n          downloadStatus = downloadStatus,\n          isExpanded = isExpanded,\n          modifier = Modifier.weight(1f),\n        )\n        // Button to delete model and expand/collapse button at the right.\n        Row(verticalAlignment = Alignment.Top) {\n          if (model.localFileRelativeDirPathOverride.isEmpty()) {\n            DeleteModelButton(\n              model = model,\n              modelManagerViewModel = modelManagerViewModel,\n              downloadStatus = downloadStatus,\n              showDeleteButton = showDeleteButton,\n              modifier = Modifier.offset(y = (-12).dp, x = if (model.imported) 12.dp else 0.dp),\n            )\n          }\n          if (!model.imported) {\n            Icon(\n              if (isExpanded) Icons.Rounded.UnfoldLess else Icons.Rounded.UnfoldMore,\n              contentDescription =\n                stringResource(\n                  if (isExpanded) R.string.cd_collapse_icon else R.string.cd_expand_icon\n                ),\n              tint = MaterialTheme.colorScheme.onSurfaceVariant,\n              modifier = Modifier.alpha(0.6f),\n            )\n          }\n        }\n      }\n      AnimatedContent(isExpanded, label = \"item_layout_transition\") { targetState ->\n        // Show description when expanded.\n        if (targetState) {\n          if (model.info.isNotEmpty()) {\n            MarkdownText(\n              model.info,\n              smallFontSize = true,\n              textColor = MaterialTheme.colorScheme.onSurfaceVariant,\n              modifier = Modifier.padding(top = 12.dp),\n            )\n          }\n        }\n      }\n      SharedTransitionLayout {\n        AnimatedContent(isExpanded, label = \"item_layout_transition\") { targetState ->\n          DownloadModelPanel(\n            task = task,\n            model = model,\n            downloadStatus = downloadStatus,\n            animatedVisibilityScope = this@AnimatedContent,\n            sharedTransitionScope = this@SharedTransitionLayout,\n            modifier =\n              Modifier.sharedElement(\n                  sharedContentState = rememberSharedContentState(key = \"download_panel\"),\n                  animatedVisibilityScope = this@AnimatedContent,\n                )\n                .padding(top = if (targetState) 12.dp else 0.dp),\n            modelManagerViewModel = modelManagerViewModel,\n            isExpanded = targetState,\n            onTryItClicked = { onModelClicked(model) },\n            onBenchmarkClicked = { onBenchmarkClicked(model) },\n            showBenchmarkButton = showBenchmarkButton,\n          )\n        }\n      }\n    }\n  }\n}\n"
  },
  {
    "path": "Android/src/app/src/main/java/com/google/ai/edge/gallery/ui/common/modelitem/ModelNameAndStatus.kt",
    "content": "/*\n * Copyright 2025 Google LLC\n *\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n *     http://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\n\npackage com.google.ai.edge.gallery.ui.common.modelitem\n\nimport androidx.compose.animation.ExperimentalSharedTransitionApi\nimport androidx.compose.foundation.layout.Arrangement\nimport androidx.compose.foundation.layout.Column\nimport androidx.compose.foundation.layout.Row\nimport androidx.compose.foundation.layout.offset\nimport androidx.compose.foundation.layout.padding\nimport androidx.compose.foundation.layout.size\nimport androidx.compose.material.icons.Icons\nimport androidx.compose.material.icons.automirrored.outlined.OpenInNew\nimport androidx.compose.material.icons.filled.Star\nimport androidx.compose.material3.Icon\nimport androidx.compose.material3.MaterialTheme\nimport androidx.compose.material3.Text\nimport androidx.compose.runtime.Composable\nimport androidx.compose.ui.Alignment\nimport androidx.compose.ui.Modifier\nimport androidx.compose.ui.draw.alpha\nimport androidx.compose.ui.graphics.Color\nimport androidx.compose.ui.res.stringResource\nimport androidx.compose.ui.text.style.TextOverflow\nimport androidx.compose.ui.unit.dp\nimport com.google.ai.edge.gallery.R\nimport com.google.ai.edge.gallery.data.MODEL_INFO_ICON_SIZE\nimport com.google.ai.edge.gallery.data.Model\nimport com.google.ai.edge.gallery.data.ModelDownloadStatus\nimport com.google.ai.edge.gallery.data.ModelDownloadStatusType\nimport com.google.ai.edge.gallery.data.Task\nimport com.google.ai.edge.gallery.ui.common.ClickableLink\nimport com.google.ai.edge.gallery.ui.common.humanReadableSize\nimport com.google.ai.edge.gallery.ui.theme.customColors\nimport com.google.ai.edge.gallery.ui.theme.labelSmallNarrow\n\n/**\n * Composable function to display the model name and its download status information.\n *\n * This function renders the model's name and its current download status, including:\n * - Model name.\n * - Failure message (if download failed).\n * - \"Unzipping...\" status for unzipping processes.\n * - Model size for successful downloads.\n */\n@OptIn(ExperimentalSharedTransitionApi::class)\n@Composable\nfun ModelNameAndStatus(\n  model: Model,\n  task: Task?,\n  downloadStatus: ModelDownloadStatus?,\n  isExpanded: Boolean,\n  modifier: Modifier = Modifier,\n) {\n  val inProgress = downloadStatus?.status == ModelDownloadStatusType.IN_PROGRESS\n  val isPartiallyDownloaded = downloadStatus?.status == ModelDownloadStatusType.PARTIALLY_DOWNLOADED\n  var curDownloadProgress = 0f\n\n  Column(modifier = modifier) {\n    // Show \"best overall\" only for the first model if it is indeed the best for this task.\n    if (task != null && model.bestForTaskIds.contains(task.id) && task.models[0] == model) {\n      Row(\n        verticalAlignment = Alignment.CenterVertically,\n        horizontalArrangement = Arrangement.spacedBy(8.dp),\n        modifier = Modifier.padding(bottom = 6.dp),\n      ) {\n        Icon(\n          Icons.Filled.Star,\n          tint = Color(0xFFFCC934),\n          contentDescription = null,\n          modifier = Modifier.size(18.dp),\n        )\n        Text(\n          stringResource(R.string.best_overall),\n          style = MaterialTheme.typography.labelMedium,\n          color = MaterialTheme.colorScheme.onSurfaceVariant,\n          modifier = Modifier.alpha(0.6f),\n        )\n      }\n    }\n\n    // Model name and action buttons.\n    Text(\n      model.displayName.ifEmpty { model.name },\n      maxLines = 1,\n      overflow = TextOverflow.MiddleEllipsis,\n      style = MaterialTheme.typography.titleMedium,\n    )\n\n    // Status icon + size + download progress details.\n    Row(verticalAlignment = Alignment.CenterVertically, modifier = Modifier.padding(top = 4.dp)) {\n      // Status icon.\n      StatusIcon(\n        task = task,\n        model = model,\n        downloadStatus = downloadStatus,\n        modifier = Modifier.padding(end = 4.dp),\n      )\n\n      // Failure message.\n      if (downloadStatus != null && downloadStatus.status == ModelDownloadStatusType.FAILED) {\n        Row(verticalAlignment = Alignment.CenterVertically) {\n          Text(\n            downloadStatus.errorMessage,\n            color = MaterialTheme.colorScheme.error,\n            style = labelSmallNarrow,\n            overflow = TextOverflow.Ellipsis,\n          )\n        }\n      }\n\n      // Status label\n      else {\n        var sizeLabel = model.totalBytes.humanReadableSize()\n        if (model.localFileRelativeDirPathOverride.isNotEmpty()) {\n          sizeLabel = \"{ext_files_dir}/${model.localFileRelativeDirPathOverride}\"\n        }\n\n        // Populate the status label.\n        if (downloadStatus != null) {\n          // For in-progress model, show {receivedSize} / {totalSize} - {rate} - {remainingTime}\n          if (inProgress || isPartiallyDownloaded) {\n            var totalSize = downloadStatus.totalBytes\n            if (totalSize == 0L) {\n              totalSize = model.totalBytes\n            }\n            sizeLabel =\n              \"${downloadStatus.receivedBytes.humanReadableSize(extraDecimalForGbAndAbove = true)} of ${totalSize.humanReadableSize()}\"\n            if (downloadStatus.bytesPerSecond > 0) {\n              sizeLabel = \"$sizeLabel · ${downloadStatus.bytesPerSecond.humanReadableSize()} / s\"\n              // if (downloadStatus.remainingMs >= 0) {\n              //   sizeLabel =\n              //     \"$sizeLabel\\n${downloadStatus.remainingMs.formatToHourMinSecond()} left\"\n              // }\n            }\n            if (isPartiallyDownloaded) {\n              sizeLabel = \"$sizeLabel (resuming...)\"\n            }\n            curDownloadProgress =\n              downloadStatus.receivedBytes.toFloat() / downloadStatus.totalBytes.toFloat()\n            if (curDownloadProgress.isNaN()) {\n              curDownloadProgress = 0f\n            }\n          }\n          // Status for unzipping.\n          else if (downloadStatus.status == ModelDownloadStatusType.UNZIPPING) {\n            sizeLabel = \"Unzipping...\"\n          }\n        }\n\n        Column(\n          horizontalAlignment = if (isExpanded) Alignment.CenterHorizontally else Alignment.Start\n        ) {\n          for ((index, line) in sizeLabel.split(\"\\n\").withIndex()) {\n            Text(\n              line,\n              color = MaterialTheme.colorScheme.onSurfaceVariant,\n              maxLines = 1,\n              style = MaterialTheme.typography.bodyMedium,\n              overflow = TextOverflow.Visible,\n              modifier = Modifier.offset(y = if (index == 0) 0.dp else (-1).dp),\n            )\n          }\n        }\n      }\n    }\n\n    // Learn more url.\n    if (!model.imported && model.learnMoreUrl.isNotEmpty()) {\n      Row(verticalAlignment = Alignment.CenterVertically) {\n        Icon(\n          Icons.AutoMirrored.Outlined.OpenInNew,\n          tint = MaterialTheme.customColors.modelInfoIconColor,\n          contentDescription = null,\n          modifier = Modifier.size(MODEL_INFO_ICON_SIZE).offset(y = 1.dp),\n        )\n        ClickableLink(model.learnMoreUrl, linkText = stringResource(R.string.learn_more))\n      }\n    }\n  }\n}\n"
  },
  {
    "path": "Android/src/app/src/main/java/com/google/ai/edge/gallery/ui/common/modelitem/StatusIcon.kt",
    "content": "/*\n * Copyright 2025 Google LLC\n *\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n *     http://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\n\npackage com.google.ai.edge.gallery.ui.common.modelitem\n\n// import androidx.compose.ui.tooling.preview.Preview\n// import com.google.ai.edge.gallery.ui.theme.GalleryTheme\nimport androidx.compose.foundation.layout.Arrangement\nimport androidx.compose.foundation.layout.Row\nimport androidx.compose.foundation.layout.size\nimport androidx.compose.material.icons.Icons\nimport androidx.compose.material.icons.automirrored.outlined.HelpOutline\nimport androidx.compose.material.icons.filled.DownloadForOffline\nimport androidx.compose.material.icons.rounded.Downloading\nimport androidx.compose.material.icons.rounded.Error\nimport androidx.compose.material3.Icon\nimport androidx.compose.material3.MaterialTheme\nimport androidx.compose.runtime.Composable\nimport androidx.compose.ui.Alignment\nimport androidx.compose.ui.Modifier\nimport androidx.compose.ui.graphics.Color\nimport androidx.compose.ui.res.stringResource\nimport com.google.ai.edge.gallery.R\nimport com.google.ai.edge.gallery.data.MODEL_INFO_ICON_SIZE\nimport com.google.ai.edge.gallery.data.Model\nimport com.google.ai.edge.gallery.data.ModelDownloadStatus\nimport com.google.ai.edge.gallery.data.ModelDownloadStatusType\nimport com.google.ai.edge.gallery.data.Task\nimport com.google.ai.edge.gallery.ui.common.getTaskBgGradientColors\nimport com.google.ai.edge.gallery.ui.theme.customColors\n\n/** Composable function to display an icon representing the download status of a model. */\n@Composable\nfun StatusIcon(\n  task: Task?,\n  model: Model,\n  downloadStatus: ModelDownloadStatus?,\n  modifier: Modifier = Modifier,\n) {\n  Row(\n    verticalAlignment = Alignment.CenterVertically,\n    horizontalArrangement = Arrangement.Center,\n    modifier = modifier,\n  ) {\n    val color =\n      if (task != null) {\n        getTaskBgGradientColors(task = task)[1]\n      } else {\n        MaterialTheme.colorScheme.primary\n      }\n    if (model.localFileRelativeDirPathOverride.isNotEmpty()) {\n      Icon(\n        Icons.Filled.DownloadForOffline,\n        tint = color,\n        contentDescription = stringResource(R.string.cd_downloaded_icon),\n        modifier = Modifier.size(MODEL_INFO_ICON_SIZE),\n      )\n    } else {\n      when (downloadStatus?.status) {\n        ModelDownloadStatusType.NOT_DOWNLOADED ->\n          Icon(\n            Icons.AutoMirrored.Outlined.HelpOutline,\n            tint = MaterialTheme.customColors.modelInfoIconColor,\n            contentDescription = stringResource(R.string.cd_not_downloaded_icon),\n            modifier = Modifier.size(MODEL_INFO_ICON_SIZE),\n          )\n\n        ModelDownloadStatusType.SUCCEEDED -> {\n          Icon(\n            Icons.Filled.DownloadForOffline,\n            tint = color,\n            contentDescription = stringResource(R.string.cd_downloaded_icon),\n            modifier = Modifier.size(MODEL_INFO_ICON_SIZE),\n          )\n        }\n\n        ModelDownloadStatusType.FAILED ->\n          Icon(\n            Icons.Rounded.Error,\n            tint = Color(0xFFAA0000),\n            contentDescription = stringResource(R.string.cd_download_failed_icon),\n            modifier = Modifier.size(MODEL_INFO_ICON_SIZE),\n          )\n\n        ModelDownloadStatusType.IN_PROGRESS ->\n          Icon(\n            Icons.Rounded.Downloading,\n            contentDescription = stringResource(R.string.cd_downloading_icon),\n            modifier = Modifier.size(MODEL_INFO_ICON_SIZE),\n          )\n\n        else -> {}\n      }\n    }\n  }\n}\n\n// @Preview(showBackground = true)\n// @Composable\n// fun StatusIconPreview() {\n//   GalleryTheme {\n//     Column {\n//       for (downloadStatus in\n//         listOf(\n//           ModelDownloadStatus(status = ModelDownloadStatusType.NOT_DOWNLOADED),\n//           ModelDownloadStatus(status = ModelDownloadStatusType.IN_PROGRESS),\n//           ModelDownloadStatus(status = ModelDownloadStatusType.SUCCEEDED),\n//           ModelDownloadStatus(status = ModelDownloadStatusType.FAILED),\n//           ModelDownloadStatus(status = ModelDownloadStatusType.UNZIPPING),\n//           ModelDownloadStatus(status = ModelDownloadStatusType.PARTIALLY_DOWNLOADED),\n//         )) {\n//         StatusIcon(downloadStatus = downloadStatus)\n//       }\n//     }\n//   }\n// }\n"
  },
  {
    "path": "Android/src/app/src/main/java/com/google/ai/edge/gallery/ui/common/textandvoiceinput/HoldToDictate.kt",
    "content": "/*\n * Copyright 2025 Google LLC\n *\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n *     http://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\npackage com.google.ai.edge.gallery.ui.common.textandvoiceinput\n\nimport android.Manifest\nimport android.content.pm.PackageManager\nimport androidx.activity.compose.rememberLauncherForActivityResult\nimport androidx.activity.result.contract.ActivityResultContracts\nimport androidx.compose.foundation.background\nimport androidx.compose.foundation.gestures.detectTapGestures\nimport androidx.compose.foundation.layout.Box\nimport androidx.compose.foundation.layout.height\nimport androidx.compose.foundation.shape.CircleShape\nimport androidx.compose.material3.Text\nimport androidx.compose.runtime.Composable\nimport androidx.compose.runtime.LaunchedEffect\nimport androidx.compose.runtime.collectAsState\nimport androidx.compose.runtime.getValue\nimport androidx.compose.runtime.mutableStateOf\nimport androidx.compose.runtime.remember\nimport androidx.compose.runtime.setValue\nimport androidx.compose.ui.Alignment\nimport androidx.compose.ui.Modifier\nimport androidx.compose.ui.draw.clip\nimport androidx.compose.ui.graphics.Color\nimport androidx.compose.ui.graphics.graphicsLayer\nimport androidx.compose.ui.input.pointer.pointerInput\nimport androidx.compose.ui.platform.LocalContext\nimport androidx.compose.ui.res.stringResource\nimport androidx.compose.ui.unit.dp\nimport androidx.core.content.ContextCompat\nimport com.google.ai.edge.gallery.R\nimport com.google.ai.edge.gallery.data.Task\nimport com.google.ai.edge.gallery.ui.common.getTaskBgGradientColors\nimport kotlin.coroutines.cancellation.CancellationException\n\nprivate const val TAG = \"AGHoldToDictate\"\n\n/**\n * A Composable that provides a \"Hold to Dictate\" functionality.\n *\n * This composable requests RECORD_AUDIO permission and, once granted, displays a button. The user\n * can press and hold the button to start speech recognition. Releasing the button stops the\n * recognition. Moving the finger off the button while holding will cancel the recognition.\n */\n@Composable\nfun HoldToDictate(\n  task: Task,\n  viewModel: HoldToDictateViewModel,\n  onDone: (String) -> Unit,\n  onAmplitudeChanged: (Int) -> Unit,\n  enabled: Boolean,\n  modifier: Modifier = Modifier,\n) {\n  val uiState by viewModel.uiState.collectAsState()\n  var recordAudioPermissionGranted by remember { mutableStateOf(false) }\n  val context = LocalContext.current\n\n  val recordAudioPermissionLauncher =\n    rememberLauncherForActivityResult(ActivityResultContracts.RequestPermission()) {\n      permissionGranted ->\n      if (permissionGranted) {\n        recordAudioPermissionGranted = true\n      }\n    }\n\n  LaunchedEffect(Unit) {\n    // Check permission\n    when (PackageManager.PERMISSION_GRANTED) {\n      // Already got permission.\n      ContextCompat.checkSelfPermission(context, Manifest.permission.RECORD_AUDIO) -> {\n        recordAudioPermissionGranted = true\n      }\n\n      // Otherwise, ask for permission\n      else -> {\n        recordAudioPermissionLauncher.launch(Manifest.permission.RECORD_AUDIO)\n      }\n    }\n  }\n\n  if (recordAudioPermissionGranted) {\n    Box(\n      modifier =\n        modifier\n          .then(\n            if (enabled) {\n              Modifier.pointerInput(Unit) {\n                detectTapGestures(\n                  onPress = {\n                    viewModel.startSpeechRecognition(\n                      onDone = onDone,\n                      onAmplitudeChanged = onAmplitudeChanged,\n                    )\n                    try {\n                      awaitRelease()\n                    } catch (e: CancellationException) {\n                      // Move out of the button to cancel it.\n                      viewModel.cancelSpeechRecognition()\n                      return@detectTapGestures\n                    }\n\n                    // Release to stop recognition.\n                    viewModel.stopSpeechRecognition()\n                  }\n                )\n              }\n            } else {\n              Modifier\n            }\n          )\n          .clip(CircleShape)\n          .graphicsLayer { alpha = if (enabled) 1f else 0.5f }\n          .background(getTaskBgGradientColors(task = task)[1])\n          .height(48.dp),\n      contentAlignment = Alignment.Center,\n    ) {\n      Text(\n        stringResource(if (uiState.recognizing) R.string.listening else R.string.hold_down_to_talk),\n        color = Color.White,\n      )\n    }\n  }\n}\n"
  },
  {
    "path": "Android/src/app/src/main/java/com/google/ai/edge/gallery/ui/common/textandvoiceinput/HoldToDictateViewModel.kt",
    "content": "/*\n * Copyright 2025 Google LLC\n *\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n *     http://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\npackage com.google.ai.edge.gallery.ui.common.textandvoiceinput\n\nimport android.content.Context\nimport android.content.Intent\nimport android.os.Bundle\nimport android.speech.RecognitionListener\nimport android.speech.RecognizerIntent\nimport android.speech.SpeechRecognizer\nimport androidx.lifecycle.ViewModel\nimport androidx.lifecycle.viewModelScope\nimport dagger.hilt.android.lifecycle.HiltViewModel\nimport dagger.hilt.android.qualifiers.ApplicationContext\nimport javax.inject.Inject\nimport kotlinx.coroutines.delay\nimport kotlinx.coroutines.flow.MutableStateFlow\nimport kotlinx.coroutines.flow.asStateFlow\nimport kotlinx.coroutines.flow.update\nimport kotlinx.coroutines.launch\n\nprivate const val TAG = \"AGHTD\"\n\nprivate const val AUDIO_METER_MIN_DB = -2.0f\nprivate const val AUDIO_METER_MAX_DB = 100.0f\n\n/** The UI state of the HoldToDictateViewModel. */\ndata class HoldToDictateUiState(val recognizing: Boolean = false, val recognizedText: String = \"\")\n\n@HiltViewModel\nclass HoldToDictateViewModel @Inject constructor(@ApplicationContext private val context: Context) :\n  ViewModel(), RecognitionListener {\n  protected val _uiState = MutableStateFlow(HoldToDictateUiState())\n  val uiState = _uiState.asStateFlow()\n\n  private val speechRecognizer: SpeechRecognizer\n  private val recognizerIntent: Intent\n  private var onRecognitionDone: ((String) -> Unit)? = null\n  private var onAmplitudeChanged: ((Int) -> Unit)? = null\n\n  init {\n    // Initialize SpeechRecognizer\n    speechRecognizer =\n      SpeechRecognizer.createSpeechRecognizer(context).apply {\n        setRecognitionListener(this@HoldToDictateViewModel)\n      }\n\n    // Initialize Intent (used for language/model settings)\n    recognizerIntent =\n      Intent(RecognizerIntent.ACTION_RECOGNIZE_SPEECH).apply {\n        putExtra(RecognizerIntent.EXTRA_LANGUAGE_MODEL, RecognizerIntent.LANGUAGE_MODEL_FREE_FORM)\n        putExtra(RecognizerIntent.EXTRA_LANGUAGE, \"en-US\")\n        putExtra(RecognizerIntent.EXTRA_MAX_RESULTS, 1)\n        putExtra(RecognizerIntent.EXTRA_PARTIAL_RESULTS, true)\n      }\n  }\n\n  fun startSpeechRecognition(onDone: (String) -> Unit, onAmplitudeChanged: (Int) -> Unit) {\n    onRecognitionDone = onDone\n    this.onAmplitudeChanged = onAmplitudeChanged\n\n    speechRecognizer.startListening(recognizerIntent)\n    setRecognizedText(text = \"\")\n    setRecognizing(recognizing = true)\n  }\n\n  fun stopSpeechRecognition() {\n    viewModelScope.launch {\n      delay(500)\n      speechRecognizer.stopListening()\n      setRecognizing(recognizing = false)\n    }\n  }\n\n  fun cancelSpeechRecognition() {\n    setRecognizing(recognizing = false)\n  }\n\n  fun setRecognizing(recognizing: Boolean) {\n    _uiState.update { uiState.value.copy(recognizing = recognizing) }\n  }\n\n  fun setRecognizedText(text: String) {\n    _uiState.update { uiState.value.copy(recognizedText = text) }\n  }\n\n  override fun onReadyForSpeech(params: Bundle?) {}\n\n  override fun onBeginningOfSpeech() {}\n\n  override fun onRmsChanged(rmsdB: Float) {\n    onAmplitudeChanged?.invoke(convertRmsDbToAmplitude(rmsdB = rmsdB))\n  }\n\n  override fun onBufferReceived(buffer: ByteArray?) {}\n\n  override fun onEndOfSpeech() {}\n\n  override fun onError(error: Int) {}\n\n  override fun onResults(results: Bundle?) {\n    val matches = results?.getStringArrayList(SpeechRecognizer.RESULTS_RECOGNITION)\n    if (matches != null && matches.size > 0) {\n      setRecognizedText(matches.get(0) ?: \"\")\n    } else {\n      setRecognizedText(\"\")\n    }\n\n    val curOnRecognitionDone = onRecognitionDone\n    if (curOnRecognitionDone != null) {\n      curOnRecognitionDone(uiState.value.recognizedText)\n    }\n\n    setRecognizing(recognizing = false)\n  }\n\n  override fun onPartialResults(partialResults: Bundle?) {\n    val matches = partialResults?.getStringArrayList(SpeechRecognizer.RESULTS_RECOGNITION)\n    if (matches != null && matches.size > 0) {\n      setRecognizedText(matches.get(0) ?: \"\")\n    } else {\n      setRecognizedText(\"\")\n    }\n  }\n\n  override fun onEvent(eventType: Int, params: Bundle?) {}\n}\n\nprivate fun convertRmsDbToAmplitude(rmsdB: Float): Int {\n  // Clamp the input value to the defined range\n  var clampedRmsdB = Math.max(rmsdB, AUDIO_METER_MIN_DB)\n  clampedRmsdB = Math.min(clampedRmsdB, AUDIO_METER_MAX_DB)\n\n  // Linear scaling to a 0-65535 range\n  return ((clampedRmsdB - AUDIO_METER_MIN_DB) * 65535f / (AUDIO_METER_MAX_DB - AUDIO_METER_MIN_DB))\n    .toInt()\n}\n"
  },
  {
    "path": "Android/src/app/src/main/java/com/google/ai/edge/gallery/ui/common/textandvoiceinput/TextAndVoiceInput.kt",
    "content": "/*\n * Copyright 2025 Google LLC\n *\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n *     http://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\npackage com.google.ai.edge.gallery.ui.common.textandvoiceinput\n\nimport androidx.compose.animation.AnimatedContent\nimport androidx.compose.foundation.background\nimport androidx.compose.foundation.border\nimport androidx.compose.foundation.clickable\nimport androidx.compose.foundation.layout.Arrangement\nimport androidx.compose.foundation.layout.Box\nimport androidx.compose.foundation.layout.Row\nimport androidx.compose.foundation.layout.fillMaxWidth\nimport androidx.compose.foundation.layout.heightIn\nimport androidx.compose.foundation.layout.offset\nimport androidx.compose.foundation.layout.padding\nimport androidx.compose.foundation.layout.size\nimport androidx.compose.foundation.shape.CircleShape\nimport androidx.compose.foundation.shape.RoundedCornerShape\nimport androidx.compose.foundation.text.BasicTextField\nimport androidx.compose.material.icons.Icons\nimport androidx.compose.material.icons.automirrored.rounded.Send\nimport androidx.compose.material.icons.outlined.KeyboardAlt\nimport androidx.compose.material.icons.outlined.Mic\nimport androidx.compose.material3.Icon\nimport androidx.compose.material3.MaterialTheme\nimport androidx.compose.material3.Text\nimport androidx.compose.runtime.Composable\nimport androidx.compose.runtime.LaunchedEffect\nimport androidx.compose.runtime.getValue\nimport androidx.compose.runtime.mutableStateOf\nimport androidx.compose.runtime.remember\nimport androidx.compose.runtime.setValue\nimport androidx.compose.ui.Alignment\nimport androidx.compose.ui.Modifier\nimport androidx.compose.ui.draw.clip\nimport androidx.compose.ui.graphics.Color\nimport androidx.compose.ui.graphics.SolidColor\nimport androidx.compose.ui.graphics.graphicsLayer\nimport androidx.compose.ui.res.stringResource\nimport androidx.compose.ui.semantics.contentDescription\nimport androidx.compose.ui.semantics.semantics\nimport androidx.compose.ui.unit.dp\nimport com.google.ai.edge.gallery.R\nimport com.google.ai.edge.gallery.data.Task\nimport com.google.ai.edge.gallery.ui.common.getTaskIconColor\nimport com.google.ai.edge.gallery.ui.theme.bodyLargeNarrow\n\n@Composable\nfun TextAndVoiceInput(\n  task: Task,\n  processing: Boolean,\n  holdToDictateViewModel: HoldToDictateViewModel,\n  onDone: (String) -> Unit,\n  onAmplitudeChanged: (Int) -> Unit,\n  modifier: Modifier = Modifier,\n  clearTextTrigger: Long = 0L,\n  defaultTextInputMode: Boolean = false,\n) {\n  Row(\n    modifier = modifier,\n    verticalAlignment = Alignment.CenterVertically,\n    horizontalArrangement = Arrangement.spacedBy(8.dp),\n  ) {\n    var textInputMode by remember { mutableStateOf(defaultTextInputMode) }\n    var curTextInput by remember { mutableStateOf(\"\") }\n\n    LaunchedEffect(clearTextTrigger) { curTextInput = \"\" }\n\n    // An icon button to switch between text and voice input.\n    Box(\n      modifier =\n        Modifier.clip(CircleShape)\n          .then(\n            if (!processing) {\n              Modifier.clickable {\n                curTextInput = \"\"\n                textInputMode = !textInputMode\n              }\n            } else {\n              Modifier\n            }\n          )\n          .graphicsLayer { alpha = if (!processing) 1f else 0.5f }\n          .background(MaterialTheme.colorScheme.surfaceContainerLow)\n          .border(\n            width = 1.dp,\n            color = MaterialTheme.colorScheme.outlineVariant,\n            shape = CircleShape,\n          )\n          .size(48.dp),\n      contentAlignment = Alignment.Center,\n    ) {\n      Icon(\n        if (textInputMode) Icons.Outlined.Mic else Icons.Outlined.KeyboardAlt,\n        contentDescription =\n          stringResource(\n            if (textInputMode) R.string.cd_switch_to_voice else R.string.cd_switch_to_keyboard\n          ),\n        modifier = Modifier.size(24.dp),\n      )\n    }\n\n    AnimatedContent(targetState = textInputMode) { showTextInput ->\n      // Text field.\n      if (showTextInput) {\n        val cdPromptInput = stringResource(R.string.cd_prompt_input_text_field)\n        Row(\n          modifier =\n            Modifier.weight(1f)\n              .clip(RoundedCornerShape(28.dp))\n              .background(MaterialTheme.colorScheme.surface)\n              .border(\n                width = 1.dp,\n                color = MaterialTheme.colorScheme.outlineVariant,\n                shape = RoundedCornerShape(28.dp),\n              )\n              .heightIn(min = 48.dp),\n          verticalAlignment = Alignment.CenterVertically,\n        ) {\n          BasicTextField(\n            value = curTextInput,\n            enabled = !processing,\n            onValueChange = { curTextInput = it },\n            textStyle = bodyLargeNarrow.copy(color = MaterialTheme.colorScheme.onSurface),\n            cursorBrush = SolidColor(MaterialTheme.colorScheme.primary),\n            modifier =\n              Modifier.padding(start = 16.dp, end = 8.dp).padding(vertical = 2.dp).semantics {\n                contentDescription = cdPromptInput\n              },\n            minLines = 1,\n            maxLines = 3,\n            decorationBox = { innerTextField ->\n              Row(\n                verticalAlignment = Alignment.CenterVertically,\n                horizontalArrangement = Arrangement.spacedBy(4.dp),\n              ) {\n                Box(Modifier.weight(1f).padding(vertical = 8.dp)) {\n                  if (curTextInput.isEmpty()) {\n                    Text(\n                      text = stringResource(R.string.text_input_placeholder_llm_chat),\n                      color = MaterialTheme.colorScheme.onSurfaceVariant,\n                    )\n                  }\n                  innerTextField()\n                }\n\n                // Send button.\n                Box(\n                  modifier =\n                    Modifier.clip(CircleShape)\n                      .then(\n                        if (!processing) {\n                          Modifier.clickable { onDone(curTextInput) }\n                        } else {\n                          Modifier\n                        }\n                      )\n                      .graphicsLayer { alpha = if (!processing) 1f else 0.5f }\n                      .background(getTaskIconColor(task = task))\n                      .size(36.dp),\n                  contentAlignment = Alignment.Center,\n                ) {\n                  Icon(\n                    Icons.AutoMirrored.Rounded.Send,\n                    contentDescription = stringResource(R.string.cd_send_prompt_icon),\n                    modifier = Modifier.offset(x = 2.dp),\n                    tint = Color.White,\n                  )\n                }\n              }\n            },\n          )\n        }\n      }\n      // Hold to talk.\n      else {\n        HoldToDictate(\n          task = task,\n          viewModel = holdToDictateViewModel,\n          onDone = { text -> onDone(text) },\n          onAmplitudeChanged = { onAmplitudeChanged(it) },\n          enabled = !processing,\n          modifier = Modifier.fillMaxWidth(),\n        )\n      }\n    }\n  }\n}\n"
  },
  {
    "path": "Android/src/app/src/main/java/com/google/ai/edge/gallery/ui/common/textandvoiceinput/VoiceRecognizerOverlay.kt",
    "content": "/*\n * Copyright 2025 Google LLC\n *\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n *     http://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\npackage com.google.ai.edge.gallery.ui.common.textandvoiceinput\n\nimport androidx.compose.foundation.background\nimport androidx.compose.foundation.layout.Arrangement\nimport androidx.compose.foundation.layout.Box\nimport androidx.compose.foundation.layout.Column\nimport androidx.compose.foundation.layout.Row\nimport androidx.compose.foundation.layout.fillMaxSize\nimport androidx.compose.foundation.layout.fillMaxWidth\nimport androidx.compose.foundation.layout.height\nimport androidx.compose.foundation.layout.padding\nimport androidx.compose.foundation.shape.CircleShape\nimport androidx.compose.material3.MaterialTheme\nimport androidx.compose.material3.Text\nimport androidx.compose.runtime.Composable\nimport androidx.compose.runtime.collectAsState\nimport androidx.compose.runtime.getValue\nimport androidx.compose.ui.Alignment\nimport androidx.compose.ui.Modifier\nimport androidx.compose.ui.draw.clip\nimport androidx.compose.ui.graphics.Color\nimport androidx.compose.ui.input.pointer.pointerInput\nimport androidx.compose.ui.res.stringResource\nimport androidx.compose.ui.unit.Dp\nimport androidx.compose.ui.unit.dp\nimport com.google.ai.edge.gallery.R\nimport com.google.ai.edge.gallery.data.Task\nimport com.google.ai.edge.gallery.ui.common.AudioAnimation\nimport com.google.ai.edge.gallery.ui.common.getTaskBgGradientColors\n\nprivate const val TAG = \"AGVROverlay\"\n\n/**\n * A Composable that displays the UI after user holding down on the \"Hold to Dictate\" button.\n *\n * It shows the recognized text, an audio level animation, and instructions for the user.\n */\n@Composable\nfun VoiceRecognizerOverlay(\n  task: Task,\n  viewModel: HoldToDictateViewModel,\n  bottomPadding: Dp,\n  curAmplitude: Int,\n  modifier: Modifier = Modifier,\n) {\n  val uiState by viewModel.uiState.collectAsState()\n\n  Box(modifier = modifier.fillMaxSize(), contentAlignment = Alignment.BottomCenter) {\n    // Audio level animation.\n    AudioAnimation(bgColor = Color.Black.copy(alpha = 0.8f), amplitude = curAmplitude)\n\n    // Recognized text.\n    Text(\n      uiState.recognizedText.ifEmpty { stringResource(R.string.listening) },\n      modifier =\n        Modifier.padding(horizontal = 16.dp)\n          .padding(bottom = (48.dp + bottomPadding) / 2)\n          .align(Alignment.Center),\n      color = Color.White,\n    )\n\n    Column(\n      modifier =\n        Modifier.padding(bottom = bottomPadding).padding(horizontal = 16.dp).fillMaxWidth(),\n      horizontalAlignment = Alignment.CenterHorizontally,\n      verticalArrangement = Arrangement.spacedBy(8.dp),\n    ) {\n      // Instructions\n      Row(\n        modifier = Modifier.fillMaxWidth(),\n        verticalAlignment = Alignment.CenterVertically,\n        horizontalArrangement = Arrangement.SpaceBetween,\n      ) {\n        Text(\n          stringResource(R.string.release_to_send),\n          color = Color.Black,\n          style = MaterialTheme.typography.labelMedium,\n        )\n        Text(\n          stringResource(R.string.slide_up_to_cancel),\n          color = Color.Black,\n          style = MaterialTheme.typography.labelMedium,\n        )\n      }\n\n      // A button that covers the HoldToDictate button.\n      Box(\n        modifier =\n          modifier\n            .pointerInput(Unit) {}\n            .clip(CircleShape)\n            .background(getTaskBgGradientColors(task = task)[1])\n            .fillMaxWidth()\n            .height(48.dp),\n        contentAlignment = Alignment.Center,\n      ) {\n        Text(stringResource(R.string.listening), color = Color.White)\n      }\n    }\n  }\n}\n"
  },
  {
    "path": "Android/src/app/src/main/java/com/google/ai/edge/gallery/ui/common/tos/AppTosDialog.kt",
    "content": "/*\n * Copyright 2026 Google LLC\n *\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n *     http://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\n\npackage com.google.ai.edge.gallery.ui.common.tos\n\nimport androidx.compose.foundation.layout.Column\nimport androidx.compose.foundation.layout.fillMaxWidth\nimport androidx.compose.foundation.layout.padding\nimport androidx.compose.foundation.rememberScrollState\nimport androidx.compose.foundation.shape.RoundedCornerShape\nimport androidx.compose.foundation.text.BasicText\nimport androidx.compose.foundation.text.TextAutoSize\nimport androidx.compose.foundation.verticalScroll\nimport androidx.compose.material3.Button\nimport androidx.compose.material3.Card\nimport androidx.compose.material3.ExperimentalMaterial3Api\nimport androidx.compose.material3.MaterialTheme\nimport androidx.compose.material3.Text\nimport androidx.compose.runtime.Composable\nimport androidx.compose.ui.Alignment\nimport androidx.compose.ui.Modifier\nimport androidx.compose.ui.res.stringResource\nimport androidx.compose.ui.text.font.FontWeight\nimport androidx.compose.ui.unit.dp\nimport androidx.compose.ui.unit.sp\nimport androidx.compose.ui.window.Dialog\nimport androidx.compose.ui.window.DialogProperties\nimport com.google.ai.edge.gallery.R\nimport com.google.ai.edge.gallery.ui.common.MarkdownText\n\n/** A composable for Terms of Service dialog, shown once when app is launched. */\n@OptIn(ExperimentalMaterial3Api::class)\n@Composable\nfun AppTosDialog(onTosAccepted: () -> Unit, viewingMode: Boolean = false) {\n  Dialog(\n    properties = DialogProperties(dismissOnBackPress = false, dismissOnClickOutside = false),\n    onDismissRequest = { if (viewingMode) onTosAccepted() },\n  ) {\n    Card(shape = RoundedCornerShape(28.dp)) {\n      Column(modifier = Modifier.padding(horizontal = 24.dp)) {\n        // Title.\n        val titleColor = MaterialTheme.colorScheme.onSurface\n        BasicText(\n          stringResource(R.string.tos_dialog_title_app),\n          modifier = Modifier.fillMaxWidth().padding(top = 24.dp),\n          style = MaterialTheme.typography.headlineSmall.copy(fontWeight = FontWeight.Medium),\n          color = { titleColor },\n          maxLines = 1,\n          autoSize =\n            TextAutoSize.StepBased(minFontSize = 16.sp, maxFontSize = 24.sp, stepSize = 1.sp),\n        )\n\n        Column(modifier = Modifier.verticalScroll(rememberScrollState()).weight(1f, fill = false)) {\n          // Short content.\n          MarkdownText(\n            \"By using this app, you agree to the \" +\n              \"[Google Terms of Service](https://policies.google.com/terms?hl=en-US).\\n\\n\" +\n              \"To learn what information we collect and why, how we use it, \" +\n              \"and how to review and update it, please review the \" +\n              \"[Google Privacy Policy](https://policies.google.com/privacy?hl=en-US).\",\n            smallFontSize = true,\n            textColor = MaterialTheme.colorScheme.onSurfaceVariant,\n            modifier = Modifier.padding(top = 16.dp),\n          )\n        }\n\n        // Accept button.\n        Button(\n          onClick = onTosAccepted,\n          modifier = Modifier.padding(top = 28.dp, bottom = 24.dp).align(Alignment.End),\n        ) {\n          Text(\n            stringResource(\n              if (viewingMode) R.string.close\n              else R.string.tos_dialog_accept_and_continue_button_label\n            )\n          )\n        }\n      }\n    }\n  }\n}\n"
  },
  {
    "path": "Android/src/app/src/main/java/com/google/ai/edge/gallery/ui/common/tos/GemmaTermsOfUseDialog.kt",
    "content": "/*\n * Copyright 2025 Google LLC\n *\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n *     http://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\n\npackage com.google.ai.edge.gallery.ui.common.tos\n\nimport androidx.compose.foundation.layout.Arrangement\nimport androidx.compose.foundation.layout.Column\nimport androidx.compose.foundation.layout.Row\nimport androidx.compose.foundation.layout.Spacer\nimport androidx.compose.foundation.layout.fillMaxWidth\nimport androidx.compose.foundation.layout.padding\nimport androidx.compose.foundation.layout.width\nimport androidx.compose.foundation.rememberScrollState\nimport androidx.compose.foundation.shape.RoundedCornerShape\nimport androidx.compose.foundation.text.BasicText\nimport androidx.compose.foundation.text.TextAutoSize\nimport androidx.compose.foundation.verticalScroll\nimport androidx.compose.material3.Button\nimport androidx.compose.material3.Card\nimport androidx.compose.material3.ExperimentalMaterial3Api\nimport androidx.compose.material3.MaterialTheme\nimport androidx.compose.material3.Text\nimport androidx.compose.material3.TextButton\nimport androidx.compose.runtime.Composable\nimport androidx.compose.ui.Alignment\nimport androidx.compose.ui.Modifier\nimport androidx.compose.ui.res.stringResource\nimport androidx.compose.ui.text.buildAnnotatedString\nimport androidx.compose.ui.text.font.FontWeight\nimport androidx.compose.ui.unit.dp\nimport androidx.compose.ui.unit.sp\nimport androidx.compose.ui.window.Dialog\nimport com.google.ai.edge.gallery.R\nimport com.google.ai.edge.gallery.ui.common.buildTrackableUrlAnnotatedString\n\n/** A composable for Gemma Terms of Use dialog, shown once before a Gemma model is downloaded. */\n@OptIn(ExperimentalMaterial3Api::class)\n@Composable\nfun GemmaTermsOfUseDialog(\n  onTosAccepted: () -> Unit,\n  onCancel: () -> Unit = {},\n  viewingMode: Boolean = false,\n) {\n  Dialog(onDismissRequest = onCancel) {\n    Card(shape = RoundedCornerShape(28.dp)) {\n      Column(modifier = Modifier.padding(horizontal = 24.dp).padding(bottom = 24.dp)) {\n        // Title.\n        val titleColor = MaterialTheme.colorScheme.onSurface\n        BasicText(\n          stringResource(R.string.tos_dialog_title_gemma),\n          modifier = Modifier.fillMaxWidth().padding(top = 24.dp),\n          style = MaterialTheme.typography.headlineSmall.copy(fontWeight = FontWeight.Medium),\n          color = { titleColor },\n          maxLines = 1,\n          autoSize =\n            TextAutoSize.StepBased(minFontSize = 16.sp, maxFontSize = 24.sp, stepSize = 1.sp),\n        )\n\n        Column(modifier = Modifier.verticalScroll(rememberScrollState()).weight(1f, fill = false)) {\n          Text(\n            buildAnnotatedString {\n              append(\"Gemma models on the Google AI Edge Gallery app are governed by the \")\n              append(\n                buildTrackableUrlAnnotatedString(\n                  url = \"https://ai.google.dev/gemma/terms\",\n                  linkText = \"Gemma Terms of Service\",\n                )\n              )\n              append(\". Please review these terms and ensure you agree before continuing.\")\n            },\n            style = MaterialTheme.typography.bodyMedium,\n            color = MaterialTheme.colorScheme.onSurfaceVariant,\n            modifier = Modifier.padding(top = 16.dp),\n          )\n        }\n\n        Row(\n          modifier = Modifier.fillMaxWidth().padding(top = 24.dp),\n          horizontalArrangement = Arrangement.End,\n          verticalAlignment = Alignment.CenterVertically,\n        ) {\n          // Cancel button.\n          if (!viewingMode) {\n            TextButton(onClick = onCancel) { Text(stringResource(R.string.cancel)) }\n            Spacer(modifier = Modifier.width(8.dp))\n          }\n\n          // Accept button.\n          Button(onClick = onTosAccepted) {\n            Text(\n              stringResource(\n                if (viewingMode) R.string.close\n                else R.string.tos_dialog_agree_and_continue_button_label\n              )\n            )\n          }\n        }\n      }\n    }\n  }\n}\n"
  },
  {
    "path": "Android/src/app/src/main/java/com/google/ai/edge/gallery/ui/common/tos/TosViewModel.kt",
    "content": "/*\n * Copyright 2025 Google LLC\n *\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n *     http://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\n\npackage com.google.ai.edge.gallery.ui.common.tos\n\nimport androidx.lifecycle.ViewModel\nimport com.google.ai.edge.gallery.data.DataStoreRepository\nimport dagger.hilt.android.lifecycle.HiltViewModel\nimport javax.inject.Inject\n\n/** ViewModel responsible for managing terms of services related tasks. */\n@HiltViewModel\nopen class TosViewModel @Inject constructor(private val dataStoreRepository: DataStoreRepository) :\n  ViewModel() {\n  fun getIsTosAccepted(): Boolean {\n    return dataStoreRepository.isTosAccepted()\n  }\n\n  fun acceptTos() {\n    dataStoreRepository.acceptTos()\n  }\n\n  fun getIsGemmaTermsOfUseAccepted(): Boolean {\n    return dataStoreRepository.isGemmaTermsOfUseAccepted()\n  }\n\n  fun acceptGemmaTermsOfUse() {\n    dataStoreRepository.acceptGemmaTermsOfUse()\n  }\n}\n"
  },
  {
    "path": "Android/src/app/src/main/java/com/google/ai/edge/gallery/ui/home/HomeScreen.kt",
    "content": "/*\n * Copyright 2025 Google LLC\n *\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n *     http://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\n\npackage com.google.ai.edge.gallery.ui.home\n\n// import androidx.compose.ui.tooling.preview.Preview\n// import com.google.ai.edge.gallery.ui.theme.GalleryTheme\n// import com.google.ai.edge.gallery.ui.preview.PreviewModelManagerViewModel\nimport android.Manifest\nimport android.content.Context\nimport android.content.pm.PackageManager\nimport android.os.Build\nimport androidx.activity.compose.BackHandler\nimport androidx.activity.compose.rememberLauncherForActivityResult\nimport androidx.activity.result.contract.ActivityResultContracts\nimport androidx.annotation.StringRes\nimport androidx.compose.foundation.background\nimport androidx.compose.foundation.clickable\nimport androidx.compose.foundation.layout.Arrangement\nimport androidx.compose.foundation.layout.Box\nimport androidx.compose.foundation.layout.Column\nimport androidx.compose.foundation.layout.PaddingValues\nimport androidx.compose.foundation.layout.Row\nimport androidx.compose.foundation.layout.Spacer\nimport androidx.compose.foundation.layout.fillMaxSize\nimport androidx.compose.foundation.layout.fillMaxWidth\nimport androidx.compose.foundation.layout.height\nimport androidx.compose.foundation.layout.offset\nimport androidx.compose.foundation.layout.padding\nimport androidx.compose.foundation.layout.size\nimport androidx.compose.foundation.layout.width\nimport androidx.compose.foundation.lazy.LazyRow\nimport androidx.compose.foundation.lazy.itemsIndexed\nimport androidx.compose.foundation.lazy.rememberLazyListState\nimport androidx.compose.foundation.pager.HorizontalPager\nimport androidx.compose.foundation.pager.PagerState\nimport androidx.compose.foundation.pager.rememberPagerState\nimport androidx.compose.foundation.rememberScrollState\nimport androidx.compose.foundation.shape.CircleShape\nimport androidx.compose.foundation.shape.RoundedCornerShape\nimport androidx.compose.foundation.verticalScroll\nimport androidx.compose.material.icons.Icons\nimport androidx.compose.material.icons.automirrored.rounded.ListAlt\nimport androidx.compose.material.icons.rounded.Error\nimport androidx.compose.material.icons.rounded.Flag\nimport androidx.compose.material.icons.rounded.Settings\nimport androidx.compose.material3.AlertDialog\nimport androidx.compose.material3.Card\nimport androidx.compose.material3.CardDefaults\nimport androidx.compose.material3.CircularProgressIndicator\nimport androidx.compose.material3.DrawerValue\nimport androidx.compose.material3.ExperimentalMaterial3Api\nimport androidx.compose.material3.Icon\nimport androidx.compose.material3.MaterialTheme\nimport androidx.compose.material3.ModalDrawerSheet\nimport androidx.compose.material3.ModalNavigationDrawer\nimport androidx.compose.material3.Scaffold\nimport androidx.compose.material3.Text\nimport androidx.compose.material3.TextButton\nimport androidx.compose.material3.rememberDrawerState\nimport androidx.compose.runtime.Composable\nimport androidx.compose.runtime.LaunchedEffect\nimport androidx.compose.runtime.collectAsState\nimport androidx.compose.runtime.derivedStateOf\nimport androidx.compose.runtime.getValue\nimport androidx.compose.runtime.mutableIntStateOf\nimport androidx.compose.runtime.mutableStateOf\nimport androidx.compose.runtime.remember\nimport androidx.compose.runtime.rememberCoroutineScope\nimport androidx.compose.runtime.setValue\nimport androidx.compose.ui.Alignment\nimport androidx.compose.ui.Modifier\nimport androidx.compose.ui.draw.clip\nimport androidx.compose.ui.graphics.Brush\nimport androidx.compose.ui.graphics.Brush.Companion.linearGradient\nimport androidx.compose.ui.graphics.Color\nimport androidx.compose.ui.graphics.graphicsLayer\nimport androidx.compose.ui.platform.LocalConfiguration\nimport androidx.compose.ui.platform.LocalContext\nimport androidx.compose.ui.platform.LocalDensity\nimport androidx.compose.ui.platform.LocalUriHandler\nimport androidx.compose.ui.res.painterResource\nimport androidx.compose.ui.res.stringResource\nimport androidx.compose.ui.semantics.clearAndSetSemantics\nimport androidx.compose.ui.semantics.contentDescription\nimport androidx.compose.ui.semantics.semantics\nimport androidx.compose.ui.text.buildAnnotatedString\nimport androidx.compose.ui.unit.dp\nimport androidx.core.content.ContextCompat\nimport com.google.ai.edge.gallery.GalleryTopAppBar\nimport com.google.ai.edge.gallery.R\nimport com.google.ai.edge.gallery.data.AppBarAction\nimport com.google.ai.edge.gallery.data.AppBarActionType\nimport com.google.ai.edge.gallery.data.Category\nimport com.google.ai.edge.gallery.data.CategoryInfo\nimport com.google.ai.edge.gallery.data.Task\nimport com.google.ai.edge.gallery.ui.common.RevealingText\nimport com.google.ai.edge.gallery.ui.common.SwipingText\nimport com.google.ai.edge.gallery.ui.common.TaskIcon\nimport com.google.ai.edge.gallery.ui.common.buildTrackableUrlAnnotatedString\nimport com.google.ai.edge.gallery.ui.common.rememberDelayedAnimationProgress\nimport com.google.ai.edge.gallery.ui.common.tos.AppTosDialog\nimport com.google.ai.edge.gallery.ui.common.tos.TosViewModel\nimport com.google.ai.edge.gallery.ui.modelmanager.ModelManagerViewModel\nimport com.google.ai.edge.gallery.ui.theme.customColors\nimport com.google.ai.edge.gallery.ui.theme.homePageTitleStyle\nimport kotlinx.coroutines.delay\nimport kotlinx.coroutines.launch\n\nprivate const val TAG = \"AGHomeScreen\"\nprivate const val TASK_COUNT_ANIMATION_DURATION = 250\nprivate const val ANIMATION_INIT_DELAY = 0L\nprivate const val TOP_APP_BAR_ANIMATION_DURATION = 600\nprivate const val TITLE_FIRST_LINE_ANIMATION_DURATION = 600\nprivate const val TITLE_SECOND_LINE_ANIMATION_DURATION = 600\nprivate const val TITLE_SECOND_LINE_ANIMATION_DURATION2 = 800\nprivate const val TITLE_SECOND_LINE_ANIMATION_START =\n  ANIMATION_INIT_DELAY + (TITLE_FIRST_LINE_ANIMATION_DURATION * 0.5).toInt()\nprivate const val TASK_LIST_ANIMATION_START = TITLE_SECOND_LINE_ANIMATION_START + 110\nprivate const val TASK_CARD_ANIMATION_DELAY_OFFSET = 100\nprivate const val TASK_CARD_ANIMATION_DURATION = 600\nprivate const val CONTENT_COMPOSABLES_ANIMATION_DURATION = 1200\nprivate const val CONTENT_COMPOSABLES_OFFSET_Y = 16\n\n/** Navigation destination data */\nobject HomeScreenDestination {\n  @StringRes val titleRes = R.string.app_name\n}\n\nprivate val PREDEFINED_CATEGORY_ORDER = listOf(Category.LLM.id, Category.EXPERIMENTAL.id)\n\n@OptIn(ExperimentalMaterial3Api::class)\n@Composable\nfun HomeScreen(\n  modelManagerViewModel: ModelManagerViewModel,\n  tosViewModel: TosViewModel,\n  navigateToTaskScreen: (Task) -> Unit,\n  onModelsClicked: () -> Unit,\n  enableAnimation: Boolean,\n  modifier: Modifier = Modifier,\n) {\n  val uiState by modelManagerViewModel.uiState.collectAsState()\n  var showSettingsDialog by remember { mutableStateOf(false) }\n  var showTosDialog by remember { mutableStateOf(!tosViewModel.getIsTosAccepted()) }\n  val scope = rememberCoroutineScope()\n  val context = LocalContext.current\n  val isDevBuild = context.packageName.endsWith(\".dev\")\n\n  var tasks = uiState.tasks\n\n  val categoryMap: Map<String, CategoryInfo> =\n    remember(tasks) { tasks.associateBy { it.category.id }.mapValues { it.value.category } }\n  val sortedCategories =\n    remember(categoryMap) {\n      categoryMap.keys\n        .toList()\n        .sortedWith { a, b ->\n          val indexA = PREDEFINED_CATEGORY_ORDER.indexOf(a)\n          val indexB = PREDEFINED_CATEGORY_ORDER.indexOf(b)\n          // Check if both categories are in the predefined order\n          if (indexA != -1 && indexB != -1) {\n            indexA.compareTo(indexB)\n          }\n          // Check if only category 'a' is in the predefined order\n          else if (indexA != -1) {\n            -1\n          }\n          // Check if only category 'b' is in the predefined order\n          else if (indexB != -1) {\n            1\n          }\n          // If neither is in the predefined order, sort by label\n          else {\n            val ca = categoryMap[a]!!\n            val cb = categoryMap[b]!!\n            val caLabel = getCategoryLabel(context = context, category = ca)\n            val cbLabel = getCategoryLabel(context = context, category = cb)\n            caLabel.compareTo(cbLabel)\n          }\n        }\n        .map { categoryMap[it]!! }\n    }\n\n  // Show home screen content when TOS has been accepted.\n  if (!showTosDialog) {\n    // The code below manages the display of the model allowlist loading indicator with a debounced\n    // delay. It ensures that a progress indicator is only shown if the loading operation\n    // (represented by `uiState.loadingModelAllowlist`) takes longer than 200 milliseconds.\n    // If the loading completes within 200ms, the indicator is never shown,\n    // preventing a \"flicker\" and improving the perceived responsiveness of the UI.\n    // The `loadingModelAllowlistDelayed` state is used to control the actual\n    // visibility of the indicator based on this debounced logic.\n    var loadingModelAllowlistDelayed by remember { mutableStateOf(false) }\n    // This effect runs whenever uiState.loadingModelAllowlist changes\n    LaunchedEffect(uiState.loadingModelAllowlist) {\n      if (uiState.loadingModelAllowlist) {\n        // If loading starts, wait for 200ms\n        delay(200)\n        // After 200ms, check if loadingModelAllowlist is still true\n        if (uiState.loadingModelAllowlist) {\n          loadingModelAllowlistDelayed = true\n        }\n      } else {\n        // If loading finishes, immediately hide the indicator\n        loadingModelAllowlistDelayed = false\n      }\n    }\n\n    // Label and spinner to show when in the process of loading model allowlist.\n    if (loadingModelAllowlistDelayed) {\n      Row(\n        modifier = Modifier.fillMaxSize(),\n        verticalAlignment = Alignment.CenterVertically,\n        horizontalArrangement = Arrangement.Center,\n      ) {\n        CircularProgressIndicator(\n          trackColor = MaterialTheme.colorScheme.surfaceVariant,\n          strokeWidth = 3.dp,\n          modifier = Modifier.padding(end = 8.dp).size(20.dp),\n        )\n        Text(\n          stringResource(R.string.loading_model_list),\n          style = MaterialTheme.typography.bodyMedium,\n        )\n      }\n    }\n    // Main UI when allowlist is done loading.\n    if (!loadingModelAllowlistDelayed && !uiState.loadingModelAllowlist) {\n      val drawerState = rememberDrawerState(initialValue = DrawerValue.Closed)\n\n      val requestPermissionLauncher =\n        rememberLauncherForActivityResult(ActivityResultContracts.RequestPermission()) {\n          isGranted: Boolean ->\n          if (isGranted) {\n            // FCM SDK (and your app) can post notifications.\n          }\n        }\n\n      LaunchedEffect(Unit) {\n        delay(2000)\n        if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {\n          if (\n            ContextCompat.checkSelfPermission(context, Manifest.permission.POST_NOTIFICATIONS) !=\n              PackageManager.PERMISSION_GRANTED\n          ) {\n            requestPermissionLauncher.launch(Manifest.permission.POST_NOTIFICATIONS)\n          }\n        }\n      }\n\n      // Close the menu when back button is pressed.\n      BackHandler(drawerState.isOpen) { scope.launch { drawerState.close() } }\n\n      ModalNavigationDrawer(\n        drawerState = drawerState,\n        drawerContent = {\n          ModalDrawerSheet {\n            Column(modifier = Modifier.padding(16.dp)) {\n              Row(modifier = Modifier.fillMaxWidth()) {\n                SquareDrawerItem(\n                  label = stringResource(R.string.drawer_settings_label),\n                  description = stringResource(R.string.drawer_settings_description),\n                  icon = Icons.Rounded.Settings,\n                  onClick = {\n                    showSettingsDialog = true\n                    scope.launch { drawerState.close() }\n                  },\n                  modifier = Modifier.weight(1f),\n                  iconBrush =\n                    linearGradient(\n                      colors =\n                        listOf(\n                          MaterialTheme.customColors.taskBgGradientColors[2][0],\n                          MaterialTheme.customColors.taskBgGradientColors[2][1],\n                        )\n                    ),\n                )\n                Spacer(modifier = Modifier.width(16.dp))\n                SquareDrawerItem(\n                  label = stringResource(R.string.drawer_models_label),\n                  description = stringResource(R.string.drawer_models_description),\n                  icon = Icons.AutoMirrored.Rounded.ListAlt,\n                  onClick = {\n                    scope.launch { drawerState.close() }\n                    scope.launch {\n                      delay(50)\n                      onModelsClicked()\n                    }\n                  },\n                  modifier = Modifier.weight(1f),\n                  iconBrush =\n                    linearGradient(\n                      colors =\n                        listOf(\n                          MaterialTheme.customColors.taskBgGradientColors[1][0],\n                          MaterialTheme.customColors.taskBgGradientColors[1][1],\n                        )\n                    ),\n                )\n              }\n            }\n          }\n        },\n        gesturesEnabled = drawerState.isOpen,\n      ) {\n        Scaffold(\n          containerColor = MaterialTheme.colorScheme.background,\n          topBar = {\n            // Top bar animation:\n            //\n            // Fade in and move down at the same time.\n            val progress =\n              if (!enableAnimation) 1f\n              else\n                rememberDelayedAnimationProgress(\n                  initialDelay = ANIMATION_INIT_DELAY - 50,\n                  animationDurationMs = TOP_APP_BAR_ANIMATION_DURATION,\n                  animationLabel = \"top bar\",\n                )\n            Box(\n              modifier =\n                Modifier.graphicsLayer {\n                  alpha = progress\n                  translationY = ((-16).dp * (1 - progress)).toPx()\n                }\n            ) {\n              GalleryTopAppBar(\n                title = stringResource(HomeScreenDestination.titleRes),\n                leftAction =\n                  AppBarAction(\n                    actionType = AppBarActionType.MENU,\n                    actionFn = {\n                      scope.launch { drawerState.apply { if (isClosed) open() else close() } }\n                    },\n                  ),\n              )\n            }\n          },\n        ) { innerPadding ->\n          // Outer box for coloring the background edge to edge.\n          Box(\n            contentAlignment = Alignment.TopCenter,\n            modifier = Modifier.fillMaxSize().background(MaterialTheme.colorScheme.surfaceContainer),\n          ) {\n            // Inner box to hold content.\n            Box(\n              contentAlignment = Alignment.TopCenter,\n              modifier = Modifier.fillMaxSize().padding(top = innerPadding.calculateTopPadding()),\n            ) {\n              Column(modifier = Modifier.fillMaxWidth().verticalScroll(rememberScrollState())) {\n                var selectedCategoryIndex by remember { mutableIntStateOf(0) }\n\n                // App title and intro text.\n                Column(\n                  modifier =\n                    Modifier.padding(horizontal = 40.dp, vertical = 48.dp).semantics(\n                      mergeDescendants = true\n                    ) {},\n                  verticalArrangement = Arrangement.spacedBy(8.dp),\n                ) {\n                  AppTitle(enableAnimation = enableAnimation)\n                  IntroText(enableAnimation = enableAnimation)\n                }\n\n                // Tab header for categories.\n                //\n                // synchronizes the `pagerState` and the `selectedCategoryIndex` to ensure that\n                //  both the tab header and the task list always show the correct category and page.\n                val pagerState = rememberPagerState(pageCount = { sortedCategories.size })\n                LaunchedEffect(pagerState.settledPage) {\n                  selectedCategoryIndex = pagerState.settledPage\n                }\n                if (sortedCategories.size > 1) {\n                  CategoryTabHeader(\n                    sortedCategories = sortedCategories,\n                    selectedIndex = selectedCategoryIndex,\n                    enableAnimation = enableAnimation,\n                    onCategorySelected = { index ->\n                      selectedCategoryIndex = index\n                      scope.launch { pagerState.animateScrollToPage(page = index) }\n                    },\n                  )\n                }\n\n                // Task list in a horizontal pager. Each page shows the list of tasks for the\n                // category.\n                TaskList(\n                  pagerState = pagerState,\n                  sortedCategories = sortedCategories,\n                  tasksByCategories = uiState.tasksByCategory,\n                  enableAnimation = enableAnimation,\n                  navigateToTaskScreen = navigateToTaskScreen,\n                )\n\n                Spacer(modifier = Modifier.height(innerPadding.calculateBottomPadding() + 10.dp))\n              }\n            }\n\n            // Gradient overlay at the bottom.\n            Box(\n              modifier =\n                Modifier.fillMaxWidth()\n                  .height(innerPadding.calculateBottomPadding())\n                  .background(\n                    Brush.verticalGradient(\n                      colors = listOf(Color.Transparent, MaterialTheme.colorScheme.surfaceContainer)\n                    )\n                  )\n                  .align(Alignment.BottomCenter)\n            )\n          }\n        }\n      }\n    }\n  }\n\n  // Show TOS dialog for users to accept.\n  if (showTosDialog) {\n    AppTosDialog(\n      onTosAccepted = {\n        showTosDialog = false\n        tosViewModel.acceptTos()\n      }\n    )\n  }\n\n  // Settings dialog.\n  if (showSettingsDialog) {\n    SettingsDialog(\n      curThemeOverride = modelManagerViewModel.readThemeOverride(),\n      modelManagerViewModel = modelManagerViewModel,\n      onDismissed = { showSettingsDialog = false },\n    )\n  }\n\n  if (uiState.loadingModelAllowlistError.isNotEmpty()) {\n    AlertDialog(\n      icon = {\n        Icon(\n          Icons.Rounded.Error,\n          contentDescription = stringResource(R.string.cd_error),\n          tint = MaterialTheme.colorScheme.error,\n        )\n      },\n      title = { Text(uiState.loadingModelAllowlistError) },\n      text = { Text(\"Please check your internet connection and try again later.\") },\n      onDismissRequest = { modelManagerViewModel.loadModelAllowlist() },\n      confirmButton = {\n        TextButton(onClick = { modelManagerViewModel.loadModelAllowlist() }) { Text(\"Retry\") }\n      },\n      dismissButton = {\n        TextButton(onClick = { modelManagerViewModel.clearLoadModelAllowlistError() }) {\n          Text(\"Cancel\")\n        }\n      },\n    )\n  }\n}\n\n@Composable\nprivate fun AppTitle(enableAnimation: Boolean) {\n  val firstLineText = stringResource(R.string.app_name_first_part)\n  val secondLineText = stringResource(R.string.app_name_second_part)\n  val titleColor = MaterialTheme.customColors.appTitleGradientColors[1]\n  val screenWidthInDp = LocalConfiguration.current.screenWidthDp.dp\n  val fontSize = with(LocalDensity.current) { (screenWidthInDp.toPx() * 0.12f).toSp() }\n  val titleStyle = homePageTitleStyle.copy(fontSize = fontSize, lineHeight = fontSize)\n\n  // First line text \"Google AI\" and its animation.\n  //\n  // The animation starts with the first line of text swiping in from left to right, progressively\n  // revealing itself in the title color (blue). Then, after a brief delay, the exact same text, but\n  // in the onSurface color (which is black in light mode), begins its own left-to-right swiping\n  // animation. This second animation is positioned directly on top of the first, appearing just as\n  // the initial reveal is finishing or has just completed, creating a layered and dynamic visual\n  // effect.\n  Box(modifier = Modifier.clearAndSetSemantics {}) {\n    var delay = ANIMATION_INIT_DELAY\n    if (enableAnimation) {\n      SwipingText(\n        text = firstLineText,\n        style = titleStyle,\n        color = titleColor,\n        animationDelay = delay,\n        animationDurationMs = TITLE_FIRST_LINE_ANIMATION_DURATION,\n      )\n      delay += (TITLE_FIRST_LINE_ANIMATION_DURATION * 0.3).toLong()\n    }\n    SwipingText(\n      text = firstLineText,\n      style = titleStyle,\n      color = MaterialTheme.colorScheme.onSurface,\n      animationDelay = if (enableAnimation) delay else 0,\n      animationDurationMs = if (enableAnimation) TITLE_FIRST_LINE_ANIMATION_DURATION else 0,\n    )\n  }\n  // Second line text \"Edge Gallery\" and its animation.\n  //\n  // The initial animation is the same as the first line text. Right before it is done, the final\n  // text with a gradient is revealed.\n  Box(modifier = Modifier.clearAndSetSemantics {}) {\n    var delay = TITLE_SECOND_LINE_ANIMATION_START\n    if (enableAnimation) {\n      SwipingText(\n        text = secondLineText,\n        style = titleStyle,\n        color = titleColor,\n        modifier = Modifier.offset(y = (-16).dp),\n        animationDelay = delay,\n        animationDurationMs = TITLE_SECOND_LINE_ANIMATION_DURATION,\n      )\n      delay += (TITLE_SECOND_LINE_ANIMATION_DURATION * 0.3).toInt()\n      SwipingText(\n        text = secondLineText,\n        style = titleStyle,\n        color = MaterialTheme.colorScheme.onSurface,\n        modifier = Modifier.offset(y = (-16).dp),\n        animationDelay = delay,\n        animationDurationMs = TITLE_SECOND_LINE_ANIMATION_DURATION,\n      )\n      delay += (TITLE_SECOND_LINE_ANIMATION_DURATION * 0.6).toInt()\n    }\n    RevealingText(\n      text = secondLineText,\n      style =\n        titleStyle.copy(\n          brush = linearGradient(colors = MaterialTheme.customColors.appTitleGradientColors)\n        ),\n      modifier = Modifier.offset(x = (-16).dp, y = (-16).dp),\n      animationDelay = if (enableAnimation) delay else 0,\n      animationDurationMs = if (enableAnimation) TITLE_SECOND_LINE_ANIMATION_DURATION2 else 0,\n    )\n  }\n}\n\n@Composable\nprivate fun IntroText(enableAnimation: Boolean) {\n  val url = \"https://huggingface.co/litert-community\"\n  val linkColor = MaterialTheme.customColors.linkColor\n  val uriHandler = LocalUriHandler.current\n\n  // Intro text animation:\n  //\n  // fade in + slide up.\n  val progress =\n    if (!enableAnimation) 1f\n    else\n      rememberDelayedAnimationProgress(\n        initialDelay = TITLE_SECOND_LINE_ANIMATION_START,\n        animationDurationMs = CONTENT_COMPOSABLES_ANIMATION_DURATION,\n        animationLabel = \"intro text animation\",\n      )\n\n  val introText = buildAnnotatedString {\n    append(\"${stringResource(R.string.app_intro)} \")\n    append(\n      buildTrackableUrlAnnotatedString(\n        url = url,\n        linkText = stringResource(R.string.litert_community_label),\n      )\n    )\n  }\n  Text(\n    introText,\n    style = MaterialTheme.typography.bodyMedium,\n    modifier =\n      Modifier.graphicsLayer {\n        alpha = progress\n        translationY = (CONTENT_COMPOSABLES_OFFSET_Y.dp * (1 - progress)).toPx()\n      },\n  )\n}\n\n@Composable\nprivate fun CategoryTabHeader(\n  sortedCategories: List<CategoryInfo>,\n  selectedIndex: Int,\n  enableAnimation: Boolean,\n  onCategorySelected: (Int) -> Unit,\n) {\n  val context = LocalContext.current\n  val scope = rememberCoroutineScope()\n  val listState = rememberLazyListState()\n\n  val progress =\n    if (!enableAnimation) 1f\n    else\n      rememberDelayedAnimationProgress(\n        initialDelay = TASK_LIST_ANIMATION_START,\n        animationDurationMs = CONTENT_COMPOSABLES_ANIMATION_DURATION,\n        animationLabel = \"task card animation\",\n      )\n\n  LazyRow(\n    state = listState,\n    modifier =\n      Modifier.fillMaxWidth().padding(bottom = 32.dp).graphicsLayer {\n        alpha = progress\n        translationY = (CONTENT_COMPOSABLES_OFFSET_Y.dp * (1 - progress)).toPx()\n      },\n    horizontalArrangement = Arrangement.spacedBy(16.dp),\n  ) {\n    item(key = \"spacer_start\") { Spacer(modifier = Modifier.width(8.dp)) }\n    itemsIndexed(items = sortedCategories) { index, category ->\n      Row(\n        modifier =\n          Modifier.height(40.dp)\n            .clip(CircleShape)\n            .background(\n              color =\n                if (selectedIndex == index) MaterialTheme.customColors.tabHeaderBgColor\n                else Color.Transparent\n            )\n            .clickable {\n              onCategorySelected(index)\n\n              // Scroll to clicked item when the item is not fully inside view.\n              scope.launch {\n                val visibleItems = listState.layoutInfo.visibleItemsInfo\n                val targetItem =\n                  visibleItems.find {\n                    // +1 because the first item is the item keyed at spacer_start.\n                    it.index == index + 1\n                  }\n                if (\n                  targetItem == null ||\n                    targetItem.offset < 0 ||\n                    targetItem.offset + targetItem.size > listState.layoutInfo.viewportSize.width\n                ) {\n                  listState.animateScrollToItem(index = index)\n                }\n              }\n            },\n        verticalAlignment = Alignment.CenterVertically,\n        horizontalArrangement = Arrangement.Center,\n      ) {\n        Text(\n          getCategoryLabel(context = context, category = category),\n          modifier = Modifier.padding(horizontal = 16.dp),\n          style = MaterialTheme.typography.labelLarge,\n          color =\n            if (selectedIndex == index) Color.White else MaterialTheme.colorScheme.onSurfaceVariant,\n        )\n      }\n    }\n    item(key = \"spacer_end\") { Spacer(modifier = Modifier.width(8.dp)) }\n  }\n}\n\n@Composable\nprivate fun TaskList(\n  pagerState: PagerState,\n  sortedCategories: List<CategoryInfo>,\n  tasksByCategories: Map<String, List<Task>>,\n  enableAnimation: Boolean,\n  navigateToTaskScreen: (Task) -> Unit,\n) {\n  // Model list animation:\n  //\n  // 1.  Slide Up: The entire column of task cards translates upwards,\n  // 2.  Fade in one by one: The task card fade in one by one. See TaskCard for details.\n  val progress =\n    if (!enableAnimation) 1f\n    else\n      rememberDelayedAnimationProgress(\n        initialDelay = TASK_LIST_ANIMATION_START,\n        animationDurationMs = CONTENT_COMPOSABLES_ANIMATION_DURATION,\n        animationLabel = \"task card animation\",\n      )\n\n  // Tracks when the initial animation is done.\n  //\n  var initialAnimationDone by remember { mutableStateOf(false) }\n  LaunchedEffect(Unit) {\n    // Use 5 iterations to make sure all visible task cards are animated.\n    delay(((TASK_CARD_ANIMATION_DURATION + TASK_CARD_ANIMATION_DELAY_OFFSET) * 5).toLong())\n    initialAnimationDone = true\n  }\n\n  HorizontalPager(\n    state = pagerState,\n    verticalAlignment = Alignment.Top,\n    contentPadding = PaddingValues(horizontal = 20.dp),\n  ) { pageIndex ->\n    val tasks = tasksByCategories[sortedCategories[pageIndex].id]!!\n    Column(\n      modifier =\n        Modifier.fillMaxWidth().padding(4.dp).graphicsLayer {\n          translationY = (CONTENT_COMPOSABLES_OFFSET_Y.dp * (1 - progress)).toPx()\n        },\n      verticalArrangement = Arrangement.spacedBy(10.dp),\n    ) {\n      var index = 0\n      for (task in tasks) {\n        TaskCard(\n          task = task,\n          index = index,\n          animate = (pageIndex == 0 || pageIndex == 1) && !initialAnimationDone && enableAnimation,\n          onClick = { navigateToTaskScreen(task) },\n          modifier = Modifier.fillMaxWidth(),\n        )\n        index++\n      }\n    }\n  }\n}\n\n@Composable\nprivate fun TaskCard(\n  task: Task,\n  index: Int,\n  animate: Boolean,\n  onClick: () -> Unit,\n  modifier: Modifier = Modifier,\n) {\n  // Observes the model count and updates the model count label with a fade-in/fade-out animation\n  // whenever the count changes.\n  val modelCount by remember {\n    derivedStateOf {\n      val trigger = task.updateTrigger.value\n      if (trigger >= 0) {\n        task.models.size\n      } else {\n        0\n      }\n    }\n  }\n  val modelCountLabel by remember {\n    derivedStateOf {\n      when (modelCount) {\n        1 -> \"1 Model\"\n        else -> \"%d Models\".format(modelCount)\n      }\n    }\n  }\n  var curModelCountLabel by remember { mutableStateOf(\"\") }\n  var modelCountLabelVisible by remember { mutableStateOf(true) }\n\n  LaunchedEffect(modelCountLabel) {\n    if (curModelCountLabel.isEmpty()) {\n      curModelCountLabel = modelCountLabel\n    } else {\n      modelCountLabelVisible = false\n      delay(TASK_COUNT_ANIMATION_DURATION.toLong())\n      curModelCountLabel = modelCountLabel\n      modelCountLabelVisible = true\n    }\n  }\n\n  // Task card animation:\n  //\n  // This animation makes the task cards appear with a delayed fade-in effect. Each card will become\n  // visible sequentially, starting after an initial delay and then with an additional offset for\n  // subsequent cards.\n  val progress =\n    if (animate)\n      rememberDelayedAnimationProgress(\n        initialDelay = TASK_LIST_ANIMATION_START + index * TASK_CARD_ANIMATION_DELAY_OFFSET,\n        animationDurationMs = TASK_CARD_ANIMATION_DURATION,\n        animationLabel = \"task card animation\",\n      )\n    else 1f\n\n  val cbTask = stringResource(R.string.cd_task_card, task.label, task.models.size)\n  Card(\n    modifier =\n      modifier\n        .clip(RoundedCornerShape(24.dp))\n        .clickable(onClick = onClick)\n        .graphicsLayer { alpha = progress }\n        .semantics { contentDescription = cbTask },\n    colors = CardDefaults.cardColors(containerColor = MaterialTheme.customColors.taskCardBgColor),\n  ) {\n    Row(\n      modifier = Modifier.fillMaxSize().padding(24.dp),\n      verticalAlignment = Alignment.CenterVertically,\n      horizontalArrangement = Arrangement.SpaceBetween,\n    ) {\n      // Title and model count\n      Column {\n        Row(verticalAlignment = Alignment.CenterVertically) {\n          Text(\n            task.label,\n            color = MaterialTheme.colorScheme.onSurface,\n            style = MaterialTheme.typography.titleMedium,\n          )\n          if (task.experimental) {\n            Icon(\n              painter = painterResource(R.drawable.ic_experiment),\n              contentDescription = \"Experimental\",\n              modifier = Modifier.size(20.dp).padding(start = 4.dp),\n              tint = MaterialTheme.colorScheme.onSurfaceVariant,\n            )\n          }\n        }\n        Text(\n          curModelCountLabel,\n          color = MaterialTheme.colorScheme.onSurfaceVariant,\n          style = MaterialTheme.typography.bodyMedium,\n          modifier = Modifier.clearAndSetSemantics {},\n        )\n      }\n\n      // Icon.\n      TaskIcon(task = task, width = 40.dp)\n    }\n  }\n}\n\nprivate fun getCategoryLabel(context: Context, category: CategoryInfo): String {\n  val stringRes = category.labelStringRes\n  val label = category.label\n  if (stringRes != null) {\n    return context.getString(stringRes)\n  } else if (label != null) {\n    return label\n  }\n  return context.getString(R.string.category_unlabeled)\n}\n"
  },
  {
    "path": "Android/src/app/src/main/java/com/google/ai/edge/gallery/ui/home/MobileActionsChallengeDialog.kt",
    "content": "/*\n * Copyright 2025 Google LLC\n *\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n *     http://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\n\npackage com.google.ai.edge.gallery.ui.home\n\nimport androidx.compose.foundation.layout.Arrangement\nimport androidx.compose.foundation.layout.Column\nimport androidx.compose.foundation.layout.Row\nimport androidx.compose.foundation.layout.Spacer\nimport androidx.compose.foundation.layout.fillMaxWidth\nimport androidx.compose.foundation.layout.height\nimport androidx.compose.foundation.layout.padding\nimport androidx.compose.material3.Button\nimport androidx.compose.material3.ExperimentalMaterial3Api\nimport androidx.compose.material3.MaterialTheme\nimport androidx.compose.material3.ModalBottomSheet\nimport androidx.compose.material3.Text\nimport androidx.compose.material3.TextButton\nimport androidx.compose.material3.rememberModalBottomSheetState\nimport androidx.compose.runtime.Composable\nimport androidx.compose.ui.Modifier\nimport androidx.compose.ui.res.stringResource\nimport androidx.compose.ui.text.SpanStyle\nimport androidx.compose.ui.text.buildAnnotatedString\nimport androidx.compose.ui.text.font.FontWeight\nimport androidx.compose.ui.text.style.TextAlign\nimport androidx.compose.ui.text.withStyle\nimport androidx.compose.ui.unit.dp\nimport androidx.compose.ui.unit.sp\nimport com.google.ai.edge.gallery.R\nimport com.google.ai.edge.gallery.ui.common.buildTrackableUrlAnnotatedString\n\n@OptIn(ExperimentalMaterial3Api::class)\n@Composable\nfun MobileActionsChallengeDialog(\n  onDismiss: () -> Unit,\n  onLoadModel: () -> Unit,\n  onSendEmail: () -> Unit,\n) {\n  val sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true)\n  val guideUrl = \"https://ai.google.dev/gemma/docs/mobile-actions\"\n\n  ModalBottomSheet(onDismissRequest = onDismiss, sheetState = sheetState) {\n    Column(modifier = Modifier.padding(16.dp)) {\n      Text(text = \"🏆\", modifier = Modifier.fillMaxWidth(), textAlign = TextAlign.Center)\n      Text(\n        text = stringResource(R.string.mobile_actions_challenge_title),\n        modifier = Modifier.fillMaxWidth(),\n        textAlign = TextAlign.Center,\n        fontSize = 16.sp,\n        fontWeight = FontWeight.Bold,\n      )\n      Text(\n        text = stringResource(R.string.mobile_actions_challenge_subtitle),\n        modifier = Modifier.fillMaxWidth(),\n        textAlign = TextAlign.Center,\n      )\n      Spacer(modifier = Modifier.height(16.dp))\n      Text(\n        text = stringResource(R.string.mobile_actions_challenge_description),\n        style = MaterialTheme.typography.bodyMedium,\n      )\n      Spacer(modifier = Modifier.height(24.dp))\n      Text(\n        text = stringResource(R.string.mobile_actions_challenge_instructions_title),\n        fontWeight = FontWeight.Bold,\n      )\n      val instructions = buildAnnotatedString {\n        append(\"1. \")\n        withStyle(style = SpanStyle(fontWeight = FontWeight.Bold)) { append(\"On your computer\") }\n        append(\", open \")\n        append(buildTrackableUrlAnnotatedString(url = guideUrl, linkText = \"this guide\"))\n        append(\n          \"\\n2. Follow the instructions to fine tune the model and convert it to .litertlm format.\"\n        )\n        append(\"\\n3. Transfer the file to this phone.\")\n        append(\"\\n4. Tap \")\n        withStyle(style = SpanStyle(fontWeight = FontWeight.Bold)) { append(\"Load Model\") }\n        append(\" below to unlock the demo.\")\n      }\n      Text(\n        text = instructions,\n        color = MaterialTheme.colorScheme.onSurface,\n        style = MaterialTheme.typography.bodyMedium,\n      )\n      Spacer(modifier = Modifier.height(16.dp))\n      Row(modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.SpaceBetween) {\n        TextButton(onClick = onSendEmail) {\n          Text(stringResource(R.string.mobile_actions_challenge_email_colab))\n        }\n        Button(onClick = onLoadModel) {\n          Text(stringResource(R.string.mobile_actions_challenge_load_model))\n        }\n      }\n    }\n  }\n}\n"
  },
  {
    "path": "Android/src/app/src/main/java/com/google/ai/edge/gallery/ui/home/NewReleaseNotification.kt",
    "content": "/*\n * Copyright 2025 Google LLC\n *\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n *     http://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\n\npackage com.google.ai.edge.gallery.ui.home\n\nimport android.util.Log\nimport androidx.compose.animation.AnimatedVisibility\nimport androidx.compose.animation.expandVertically\nimport androidx.compose.animation.fadeIn\nimport androidx.compose.foundation.background\nimport androidx.compose.foundation.layout.Arrangement\nimport androidx.compose.foundation.layout.Row\nimport androidx.compose.foundation.layout.padding\nimport androidx.compose.foundation.shape.CircleShape\nimport androidx.compose.material.icons.Icons\nimport androidx.compose.material.icons.automirrored.rounded.OpenInNew\nimport androidx.compose.material3.MaterialTheme\nimport androidx.compose.material3.Text\nimport androidx.compose.runtime.Composable\nimport androidx.compose.runtime.DisposableEffect\nimport androidx.compose.runtime.getValue\nimport androidx.compose.runtime.mutableStateOf\nimport androidx.compose.runtime.remember\nimport androidx.compose.runtime.rememberCoroutineScope\nimport androidx.compose.runtime.setValue\nimport androidx.compose.ui.Alignment\nimport androidx.compose.ui.Modifier\nimport androidx.compose.ui.draw.clip\nimport androidx.compose.ui.unit.dp\nimport androidx.lifecycle.Lifecycle\nimport androidx.lifecycle.LifecycleEventObserver\nimport androidx.lifecycle.LifecycleOwner\nimport androidx.lifecycle.compose.LocalLifecycleOwner\nimport com.google.ai.edge.gallery.BuildConfig\nimport com.google.ai.edge.gallery.common.getJsonResponse\nimport com.google.ai.edge.gallery.ui.common.ClickableLink\nimport kotlin.math.max\nimport kotlinx.coroutines.Dispatchers\nimport kotlinx.coroutines.launch\nimport kotlinx.coroutines.withContext\n\nprivate const val TAG = \"AGNewReleaseNotifi\"\nprivate const val REPO = \"google-ai-edge/gallery\"\n\ndata class ReleaseInfo(val html_url: String, val tag_name: String)\n\n@Composable\nfun NewReleaseNotification() {\n  var newReleaseVersion by remember { mutableStateOf(\"\") }\n  var newReleaseUrl by remember { mutableStateOf(\"\") }\n  val lifecycleOwner: LifecycleOwner = LocalLifecycleOwner.current\n  val coroutineScope = rememberCoroutineScope()\n\n  DisposableEffect(lifecycleOwner) {\n    // Create a LifecycleEventObserver to listen for specific lifecycle events.\n    val observer = LifecycleEventObserver { _, event ->\n      // Log or perform actions based on the lifecycle event.\n      when (event) {\n        Lifecycle.Event.ON_RESUME -> {\n          coroutineScope.launch {\n            withContext(Dispatchers.IO) {\n              Log.d(TAG, \"Checking for new release...\")\n              val info =\n                getJsonResponse<ReleaseInfo>(\"https://api.github.com/repos/$REPO/releases/latest\")\n              if (info != null) {\n                val curRelease = BuildConfig.VERSION_NAME\n                val newRelease = info.jsonObj.tag_name\n                val isNewer = isNewerRelease(currentRelease = curRelease, newRelease = newRelease)\n                Log.d(TAG, \"curRelease: $curRelease, newRelease: $newRelease, isNewer: $isNewer\")\n                if (isNewer) {\n                  newReleaseVersion = newRelease\n                  newReleaseUrl = info.jsonObj.html_url\n                }\n              }\n            }\n          }\n        }\n\n        else -> {}\n      }\n    }\n\n    lifecycleOwner.lifecycle.addObserver(observer)\n\n    onDispose { lifecycleOwner.lifecycle.removeObserver(observer) }\n  }\n\n  AnimatedVisibility(\n    visible = newReleaseVersion.isNotEmpty(),\n    enter = fadeIn() + expandVertically(),\n  ) {\n    Row(\n      verticalAlignment = Alignment.CenterVertically,\n      horizontalArrangement = Arrangement.SpaceBetween,\n      modifier =\n        Modifier.padding(horizontal = 16.dp)\n          .padding(bottom = 12.dp)\n          .clip(CircleShape)\n          .background(MaterialTheme.colorScheme.tertiaryContainer)\n          .padding(4.dp),\n    ) {\n      Text(\n        \"New release $newReleaseVersion available\",\n        style = MaterialTheme.typography.bodyMedium,\n        modifier = Modifier.padding(start = 12.dp),\n      )\n      Row(\n        modifier = Modifier.padding(end = 12.dp),\n        verticalAlignment = Alignment.CenterVertically,\n      ) {\n        ClickableLink(\n          url = newReleaseUrl,\n          linkText = \"View\",\n          icon = Icons.AutoMirrored.Rounded.OpenInNew,\n        )\n      }\n    }\n  }\n}\n\nprivate fun isNewerRelease(currentRelease: String, newRelease: String): Boolean {\n  // Split the version strings into their individual components (e.g., \"0.9.0\" -> [\"0\", \"9\", \"0\"])\n  val currentComponents = currentRelease.split('.').map { it.toIntOrNull() ?: 0 }\n  val newComponents = newRelease.split('.').map { it.toIntOrNull() ?: 0 }\n\n  // Determine the maximum number of components to iterate through\n  val maxComponents = max(currentComponents.size, newComponents.size)\n\n  // Iterate through the components from left to right (major, minor, patch, etc.)\n  for (i in 0 until maxComponents) {\n    val currentComponent = currentComponents.getOrElse(i) { 0 }\n    val newComponent = newComponents.getOrElse(i) { 0 }\n\n    if (newComponent > currentComponent) {\n      return true\n    } else if (newComponent < currentComponent) {\n      return false\n    }\n  }\n\n  return false\n}\n"
  },
  {
    "path": "Android/src/app/src/main/java/com/google/ai/edge/gallery/ui/home/SettingsDialog.kt",
    "content": "/*\n * Copyright 2025 Google LLC\n *\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n *     http://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\n\npackage com.google.ai.edge.gallery.ui.home\n\nimport com.google.android.gms.oss.licenses.OssLicensesMenuActivity\nimport android.app.UiModeManager\nimport android.content.Context\nimport android.content.Intent\nimport androidx.compose.foundation.border\nimport androidx.compose.foundation.clickable\nimport androidx.compose.foundation.interaction.MutableInteractionSource\nimport androidx.compose.foundation.layout.Arrangement\nimport androidx.compose.foundation.layout.Box\nimport androidx.compose.foundation.layout.Column\nimport androidx.compose.foundation.layout.Row\nimport androidx.compose.foundation.layout.fillMaxWidth\nimport androidx.compose.foundation.layout.height\nimport androidx.compose.foundation.layout.offset\nimport androidx.compose.foundation.layout.padding\nimport androidx.compose.foundation.rememberScrollState\nimport androidx.compose.foundation.shape.CircleShape\nimport androidx.compose.foundation.shape.RoundedCornerShape\nimport androidx.compose.foundation.text.BasicTextField\nimport androidx.compose.foundation.text.KeyboardActions\nimport androidx.compose.foundation.text.KeyboardOptions\nimport androidx.compose.foundation.verticalScroll\nimport androidx.compose.material.icons.Icons\nimport androidx.compose.material.icons.rounded.CheckCircle\nimport androidx.compose.material3.Button\nimport androidx.compose.material3.Card\nimport androidx.compose.material3.ExperimentalMaterial3Api\nimport androidx.compose.material3.Icon\nimport androidx.compose.material3.IconButton\nimport androidx.compose.material3.MaterialTheme\nimport androidx.compose.material3.MultiChoiceSegmentedButtonRow\nimport androidx.compose.material3.OutlinedButton\nimport androidx.compose.material3.SegmentedButton\nimport androidx.compose.material3.SegmentedButtonDefaults\nimport androidx.compose.material3.Text\nimport androidx.compose.runtime.Composable\nimport androidx.compose.runtime.getValue\nimport androidx.compose.runtime.mutableStateOf\nimport androidx.compose.runtime.remember\nimport androidx.compose.runtime.setValue\nimport androidx.compose.ui.Alignment\nimport androidx.compose.ui.Modifier\nimport androidx.compose.ui.focus.FocusRequester\nimport androidx.compose.ui.focus.focusRequester\nimport androidx.compose.ui.focus.onFocusChanged\nimport androidx.compose.ui.graphics.SolidColor\nimport androidx.compose.ui.platform.LocalContext\nimport androidx.compose.ui.platform.LocalFocusManager\nimport androidx.compose.ui.res.stringResource\nimport androidx.compose.ui.semantics.semantics\nimport androidx.compose.ui.text.TextStyle\nimport androidx.compose.ui.text.font.FontWeight\nimport androidx.compose.ui.text.input.ImeAction\nimport androidx.compose.ui.unit.dp\nimport androidx.compose.ui.window.Dialog\nimport com.google.ai.edge.gallery.BuildConfig\nimport com.google.ai.edge.gallery.R\nimport com.google.ai.edge.gallery.proto.Theme\nimport com.google.ai.edge.gallery.ui.common.ClickableLink\nimport com.google.ai.edge.gallery.ui.common.tos.AppTosDialog\nimport com.google.ai.edge.gallery.ui.modelmanager.ModelManagerViewModel\nimport com.google.ai.edge.gallery.ui.theme.ThemeSettings\nimport com.google.ai.edge.gallery.ui.theme.labelSmallNarrow\nimport java.time.Instant\nimport java.time.ZoneId\nimport java.time.format.DateTimeFormatter\nimport java.util.Locale\nimport kotlin.math.min\n\nprivate val THEME_OPTIONS = listOf(Theme.THEME_AUTO, Theme.THEME_LIGHT, Theme.THEME_DARK)\n\n@OptIn(ExperimentalMaterial3Api::class)\n@Composable\nfun SettingsDialog(\n  curThemeOverride: Theme,\n  modelManagerViewModel: ModelManagerViewModel,\n  onDismissed: () -> Unit,\n) {\n  var selectedTheme by remember { mutableStateOf(curThemeOverride) }\n  var hfToken by remember { mutableStateOf(modelManagerViewModel.getTokenStatusAndData().data) }\n  val dateFormatter = remember {\n    DateTimeFormatter.ofPattern(\"yyyy-MM-dd HH:mm:ss\")\n      .withZone(ZoneId.systemDefault())\n      .withLocale(Locale.getDefault())\n  }\n  var customHfToken by remember { mutableStateOf(\"\") }\n  var isFocused by remember { mutableStateOf(false) }\n  val focusRequester = remember { FocusRequester() }\n  val interactionSource = remember { MutableInteractionSource() }\n  var showTos by remember { mutableStateOf(false) }\n\n  Dialog(onDismissRequest = onDismissed) {\n    val focusManager = LocalFocusManager.current\n    Card(\n      modifier =\n        Modifier.fillMaxWidth().clickable(\n          interactionSource = interactionSource,\n          indication = null, // Disable the ripple effect\n        ) {\n          focusManager.clearFocus()\n        },\n      shape = RoundedCornerShape(16.dp),\n    ) {\n      Column(\n        modifier = Modifier.padding(20.dp),\n        verticalArrangement = Arrangement.spacedBy(16.dp),\n      ) {\n        // Dialog title and subtitle.\n        Column {\n          Text(\n            \"Settings\",\n            style = MaterialTheme.typography.titleLarge,\n            modifier = Modifier.padding(bottom = 8.dp),\n          )\n          // Subtitle.\n          Text(\n            \"App version: ${BuildConfig.VERSION_NAME}\",\n            style = labelSmallNarrow,\n            color = MaterialTheme.colorScheme.onSurfaceVariant,\n            modifier = Modifier.offset(y = (-6).dp),\n          )\n        }\n\n        Column(\n          modifier = Modifier.verticalScroll(rememberScrollState()).weight(1f, fill = false),\n          verticalArrangement = Arrangement.spacedBy(16.dp),\n        ) {\n          val context = LocalContext.current\n          // Theme switcher.\n          Column(modifier = Modifier.fillMaxWidth().semantics(mergeDescendants = true) {}) {\n            Text(\n              \"Theme\",\n              style = MaterialTheme.typography.titleSmall.copy(fontWeight = FontWeight.Medium),\n            )\n            MultiChoiceSegmentedButtonRow {\n              THEME_OPTIONS.forEachIndexed { index, theme ->\n                SegmentedButton(\n                  shape =\n                    SegmentedButtonDefaults.itemShape(index = index, count = THEME_OPTIONS.size),\n                  onCheckedChange = {\n                    selectedTheme = theme\n\n                    // Update theme settings.\n                    // This will update app's theme.\n                    ThemeSettings.themeOverride.value = theme\n\n                    // Save to data store.\n                    modelManagerViewModel.saveThemeOverride(theme)\n\n                    // Update ui mode.\n                    //\n                    // This is necessary to make other Activities launched from MainActivity to have\n                    // the correct theme.\n                    val uiModeManager =\n                      context.applicationContext.getSystemService(Context.UI_MODE_SERVICE)\n                        as UiModeManager\n                    if (theme == Theme.THEME_AUTO) {\n                      uiModeManager.setApplicationNightMode(UiModeManager.MODE_NIGHT_AUTO)\n                    } else if (theme == Theme.THEME_LIGHT) {\n                      uiModeManager.setApplicationNightMode(UiModeManager.MODE_NIGHT_NO)\n                    } else {\n                      uiModeManager.setApplicationNightMode(UiModeManager.MODE_NIGHT_YES)\n                    }\n                  },\n                  checked = theme == selectedTheme,\n                  label = { Text(themeLabel(theme)) },\n                )\n              }\n            }\n          }\n\n          // HF Token management.\n          Column(\n            modifier = Modifier.fillMaxWidth().semantics(mergeDescendants = true) {},\n            verticalArrangement = Arrangement.spacedBy(4.dp),\n          ) {\n            Text(\n              \"HuggingFace access token\",\n              style = MaterialTheme.typography.titleSmall.copy(fontWeight = FontWeight.Medium),\n            )\n            // Show the start of the token.\n            val curHfToken = hfToken\n            if (curHfToken != null && curHfToken.accessToken.isNotEmpty()) {\n              Text(\n                curHfToken.accessToken.substring(0, min(16, curHfToken.accessToken.length)) + \"...\",\n                style = MaterialTheme.typography.bodyMedium,\n                color = MaterialTheme.colorScheme.onSurfaceVariant,\n              )\n              Text(\n                \"Expires at: ${dateFormatter.format(Instant.ofEpochMilli(curHfToken.expiresAtMs))}\",\n                style = MaterialTheme.typography.bodyMedium,\n                color = MaterialTheme.colorScheme.onSurfaceVariant,\n              )\n            } else {\n              Text(\n                \"Not available\",\n                style = MaterialTheme.typography.bodyMedium,\n                color = MaterialTheme.colorScheme.onSurfaceVariant,\n              )\n              Text(\n                \"The token will be automatically retrieved when a gated model is downloaded\",\n                style = MaterialTheme.typography.bodySmall,\n                color = MaterialTheme.colorScheme.onSurfaceVariant,\n              )\n            }\n            Row(horizontalArrangement = Arrangement.spacedBy(4.dp)) {\n              OutlinedButton(\n                onClick = {\n                  modelManagerViewModel.clearAccessToken()\n                  hfToken = null\n                },\n                enabled = curHfToken != null,\n              ) {\n                Text(\"Clear\")\n              }\n              val handleSaveToken = {\n                modelManagerViewModel.saveAccessToken(\n                  accessToken = customHfToken,\n                  refreshToken = \"\",\n                  expiresAt = System.currentTimeMillis() + 1000L * 60 * 60 * 24 * 365 * 10,\n                )\n                hfToken = modelManagerViewModel.getTokenStatusAndData().data\n                focusManager.clearFocus()\n              }\n              BasicTextField(\n                value = customHfToken,\n                singleLine = true,\n                keyboardOptions = KeyboardOptions(imeAction = ImeAction.Done),\n                keyboardActions = KeyboardActions(onDone = { handleSaveToken() }),\n                modifier =\n                  Modifier.fillMaxWidth()\n                    .padding(top = 4.dp)\n                    .focusRequester(focusRequester)\n                    .onFocusChanged { isFocused = it.isFocused },\n                onValueChange = { customHfToken = it },\n                textStyle = TextStyle(color = MaterialTheme.colorScheme.onSurface),\n                cursorBrush = SolidColor(MaterialTheme.colorScheme.onSurface),\n              ) { innerTextField ->\n                Box(\n                  modifier =\n                    Modifier.border(\n                        width = if (isFocused) 2.dp else 1.dp,\n                        color =\n                          if (isFocused) MaterialTheme.colorScheme.primary\n                          else MaterialTheme.colorScheme.outline,\n                        shape = CircleShape,\n                      )\n                      .height(40.dp),\n                  contentAlignment = Alignment.CenterStart,\n                ) {\n                  Row(verticalAlignment = Alignment.CenterVertically) {\n                    Box(modifier = Modifier.padding(start = 16.dp).weight(1f)) {\n                      if (customHfToken.isEmpty()) {\n                        Text(\n                          \"Enter token manually\",\n                          color = MaterialTheme.colorScheme.onSurfaceVariant,\n                          style = MaterialTheme.typography.bodySmall,\n                        )\n                      }\n                      innerTextField()\n                    }\n                    if (customHfToken.isNotEmpty()) {\n                      IconButton(modifier = Modifier.offset(x = 1.dp), onClick = handleSaveToken) {\n                        Icon(\n                          Icons.Rounded.CheckCircle,\n                          contentDescription = stringResource(R.string.cd_done_icon),\n                        )\n                      }\n                    }\n                  }\n                }\n              }\n            }\n          }\n\n          // Third party licenses.\n          Column(modifier = Modifier.fillMaxWidth().semantics(mergeDescendants = true) {}) {\n            Text(\n              \"Third-party libraries\",\n              style = MaterialTheme.typography.titleSmall.copy(fontWeight = FontWeight.Medium),\n            )\n            OutlinedButton(\n              onClick = {\n                // Create an Intent to launch a license viewer that displays a list of\n                // third-party library names. Clicking a name will show its license content.\n                val intent = Intent(context, OssLicensesMenuActivity::class.java)\n                context.startActivity(intent)\n              }\n            ) {\n              Text(\"View licenses\")\n            }\n          }\n\n          // Tos\n          Column(modifier = Modifier.fillMaxWidth().semantics(mergeDescendants = true) {}) {\n            Text(\n              stringResource(R.string.settings_dialog_tos_title),\n              style = MaterialTheme.typography.titleSmall.copy(fontWeight = FontWeight.Medium),\n            )\n            OutlinedButton(onClick = { showTos = true }) {\n              Text(stringResource(R.string.settings_dialog_view_app_terms_of_service))\n            }\n            ClickableLink(\n              url = \"https://ai.google.dev/gemma/terms\",\n              linkText = stringResource(R.string.tos_dialog_title_gemma),\n              modifier = Modifier.padding(top = 4.dp),\n            )\n            ClickableLink(\n              url = \"https://ai.google.dev/gemma/prohibited_use_policy\",\n              linkText = stringResource(R.string.settings_dialog_gemma_prohibited_use_policy),\n              modifier = Modifier.padding(top = 8.dp),\n            )\n          }\n        }\n\n        // Button row.\n        Row(\n          modifier = Modifier.fillMaxWidth().padding(top = 8.dp),\n          horizontalArrangement = Arrangement.End,\n        ) {\n          // Close button\n          Button(onClick = { onDismissed() }) { Text(\"Close\") }\n        }\n      }\n    }\n  }\n\n  if (showTos) {\n    AppTosDialog(onTosAccepted = { showTos = false }, viewingMode = true)\n  }\n}\n\nprivate fun themeLabel(theme: Theme): String {\n  return when (theme) {\n    Theme.THEME_AUTO -> \"Auto\"\n    Theme.THEME_LIGHT -> \"Light\"\n    Theme.THEME_DARK -> \"Dark\"\n    else -> \"Unknown\"\n  }\n}\n"
  },
  {
    "path": "Android/src/app/src/main/java/com/google/ai/edge/gallery/ui/home/SquareDrawerItem.kt",
    "content": "/*\n * Copyright 2026 Google LLC\n *\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n *     http://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\n\npackage com.google.ai.edge.gallery.ui.home\n\nimport androidx.compose.foundation.border\nimport androidx.compose.foundation.clickable\nimport androidx.compose.foundation.layout.Arrangement\nimport androidx.compose.foundation.layout.Column\nimport androidx.compose.foundation.layout.aspectRatio\nimport androidx.compose.foundation.layout.fillMaxSize\nimport androidx.compose.foundation.layout.fillMaxWidth\nimport androidx.compose.foundation.layout.padding\nimport androidx.compose.foundation.layout.size\nimport androidx.compose.foundation.shape.RoundedCornerShape\nimport androidx.compose.foundation.text.TextAutoSize\nimport androidx.compose.material3.Icon\nimport androidx.compose.material3.MaterialTheme\nimport androidx.compose.material3.Text\nimport androidx.compose.runtime.Composable\nimport androidx.compose.ui.Alignment\nimport androidx.compose.ui.Modifier\nimport androidx.compose.ui.draw.clip\nimport androidx.compose.ui.draw.drawWithContent\nimport androidx.compose.ui.graphics.BlendMode\nimport androidx.compose.ui.graphics.Brush\nimport androidx.compose.ui.graphics.graphicsLayer\nimport androidx.compose.ui.graphics.vector.ImageVector\nimport androidx.compose.ui.text.font.FontWeight\nimport androidx.compose.ui.unit.dp\nimport androidx.compose.ui.unit.sp\n\n@Composable\nfun SquareDrawerItem(\n  label: String,\n  description: String,\n  icon: ImageVector,\n  onClick: () -> Unit,\n  modifier: Modifier = Modifier,\n  iconBrush: Brush? = null,\n) {\n  Column(\n    modifier =\n      modifier\n        .aspectRatio(1f)\n        .clip(RoundedCornerShape(24.dp))\n        .clickable { onClick() }\n        .border(\n          width = 2.dp,\n          color = MaterialTheme.colorScheme.surfaceContainerHigh,\n          shape = RoundedCornerShape(24.dp),\n        )\n  ) {\n    Column(\n      verticalArrangement = Arrangement.SpaceBetween,\n      horizontalAlignment = Alignment.Start,\n      modifier = Modifier.padding(18.dp).fillMaxSize(),\n    ) {\n      Icon(\n        icon,\n        contentDescription = null,\n        modifier =\n          Modifier.size(40.dp)\n            .then(\n              if (iconBrush != null) {\n                Modifier.graphicsLayer(\n                    // Required for some devices to blend correctly\n                    alpha = 0.99f\n                  )\n                  .drawWithContent {\n                    // Draws the icon first\n                    drawContent()\n                    // Masks the brush to the icon's shape\n                    drawRect(brush = iconBrush, blendMode = BlendMode.SrcIn)\n                  }\n              } else {\n                Modifier\n              }\n            ),\n      )\n      Column(modifier = Modifier.fillMaxWidth(), verticalArrangement = Arrangement.spacedBy(4.dp)) {\n        Text(\n          label,\n          color = MaterialTheme.colorScheme.onSurface,\n          style = MaterialTheme.typography.bodyLarge.copy(fontWeight = FontWeight.Medium),\n        )\n        Text(\n          description,\n          color = MaterialTheme.colorScheme.onSurfaceVariant,\n          style = MaterialTheme.typography.bodySmall,\n          maxLines = 2,\n          autoSize =\n            TextAutoSize.StepBased(minFontSize = 8.sp, maxFontSize = 12.sp, stepSize = 1.sp),\n        )\n      }\n    }\n  }\n}\n"
  },
  {
    "path": "Android/src/app/src/main/java/com/google/ai/edge/gallery/ui/icon/Deploy.kt",
    "content": "/*\n * Copyright 2025 Google LLC\n *\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n *     http://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\n\npackage com.google.ai.edge.gallery.ui.icon\n\nimport androidx.compose.ui.graphics.Color\nimport androidx.compose.ui.graphics.PathFillType\nimport androidx.compose.ui.graphics.SolidColor\nimport androidx.compose.ui.graphics.StrokeCap\nimport androidx.compose.ui.graphics.StrokeJoin\nimport androidx.compose.ui.graphics.vector.ImageVector\nimport androidx.compose.ui.graphics.vector.path\nimport androidx.compose.ui.unit.dp\n\nval Deployed_code: ImageVector\n  get() {\n    if (internal_Deployed_code != null) {\n      return internal_Deployed_code!!\n    }\n    internal_Deployed_code =\n      ImageVector.Builder(\n          name = \"Deployed_code\",\n          defaultWidth = 24.dp,\n          defaultHeight = 24.dp,\n          viewportWidth = 960f,\n          viewportHeight = 960f,\n        )\n        .apply {\n          path(\n            fill = SolidColor(Color.Black),\n            fillAlpha = 1.0f,\n            stroke = null,\n            strokeAlpha = 1.0f,\n            strokeLineWidth = 1.0f,\n            strokeLineCap = StrokeCap.Butt,\n            strokeLineJoin = StrokeJoin.Miter,\n            strokeLineMiter = 1.0f,\n            pathFillType = PathFillType.NonZero,\n          ) {\n            moveTo(440f, 777f)\n            verticalLineToRelative(-274f)\n            lineTo(200f, 364f)\n            verticalLineToRelative(274f)\n            close()\n            moveToRelative(80f, 0f)\n            lineToRelative(240f, -139f)\n            verticalLineToRelative(-274f)\n            lineTo(520f, 503f)\n            close()\n            moveToRelative(-40f, -343f)\n            lineToRelative(237f, -137f)\n            lineToRelative(-237f, -137f)\n            lineToRelative(-237f, 137f)\n            close()\n            moveTo(160f, 708f)\n            quadToRelative(-19f, -11f, -29.5f, -29f)\n            reflectiveQuadTo(120f, 639f)\n            verticalLineToRelative(-318f)\n            quadToRelative(0f, -22f, 10.5f, -40f)\n            reflectiveQuadToRelative(29.5f, -29f)\n            lineToRelative(280f, -161f)\n            quadToRelative(19f, -11f, 40f, -11f)\n            reflectiveQuadToRelative(40f, 11f)\n            lineToRelative(280f, 161f)\n            quadToRelative(19f, 11f, 29.5f, 29f)\n            reflectiveQuadToRelative(10.5f, 40f)\n            verticalLineToRelative(318f)\n            quadToRelative(0f, 22f, -10.5f, 40f)\n            reflectiveQuadTo(800f, 708f)\n            lineTo(520f, 869f)\n            quadToRelative(-19f, 11f, -40f, 11f)\n            reflectiveQuadToRelative(-40f, -11f)\n            close()\n            moveToRelative(320f, -228f)\n          }\n        }\n        .build()\n    return internal_Deployed_code!!\n  }\n\nprivate var internal_Deployed_code: ImageVector? = null\n"
  },
  {
    "path": "Android/src/app/src/main/java/com/google/ai/edge/gallery/ui/llmchat/LlmChatModelHelper.kt",
    "content": "/*\n * Copyright 2025 Google LLC\n *\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n *     http://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\n\npackage com.google.ai.edge.gallery.ui.llmchat\n\nimport android.content.Context\nimport android.graphics.Bitmap\nimport android.util.Log\nimport com.google.ai.edge.gallery.common.cleanUpMediapipeTaskErrorMessage\nimport com.google.ai.edge.gallery.data.Accelerator\nimport com.google.ai.edge.gallery.data.ConfigKeys\nimport com.google.ai.edge.gallery.data.DEFAULT_MAX_TOKEN\nimport com.google.ai.edge.gallery.data.DEFAULT_TEMPERATURE\nimport com.google.ai.edge.gallery.data.DEFAULT_TOPK\nimport com.google.ai.edge.gallery.data.DEFAULT_TOPP\nimport com.google.ai.edge.gallery.data.DEFAULT_VISION_ACCELERATOR\nimport com.google.ai.edge.gallery.data.Model\nimport com.google.ai.edge.gallery.runtime.CleanUpListener\nimport com.google.ai.edge.gallery.runtime.LlmModelHelper\nimport com.google.ai.edge.gallery.runtime.ResultListener\nimport com.google.ai.edge.litertlm.Backend\nimport com.google.ai.edge.litertlm.Content\nimport com.google.ai.edge.litertlm.Contents\nimport com.google.ai.edge.litertlm.Conversation\nimport com.google.ai.edge.litertlm.ConversationConfig\nimport com.google.ai.edge.litertlm.Engine\nimport com.google.ai.edge.litertlm.EngineConfig\nimport com.google.ai.edge.litertlm.ExperimentalApi\nimport com.google.ai.edge.litertlm.ExperimentalFlags\nimport com.google.ai.edge.litertlm.Message\nimport com.google.ai.edge.litertlm.MessageCallback\nimport com.google.ai.edge.litertlm.SamplerConfig\nimport java.io.ByteArrayOutputStream\nimport java.util.concurrent.CancellationException\nimport kotlinx.coroutines.CoroutineScope\n\nprivate const val TAG = \"AGLlmChatModelHelper\"\n\ndata class LlmModelInstance(val engine: Engine, var conversation: Conversation)\n\nobject LlmChatModelHelper : LlmModelHelper {\n  // Indexed by model name.\n  private val cleanUpListeners: MutableMap<String, CleanUpListener> = mutableMapOf()\n\n  @OptIn(ExperimentalApi::class) // opt-in experimental flags\n  override fun initialize(\n    context: Context,\n    model: Model,\n    supportImage: Boolean,\n    supportAudio: Boolean,\n    onDone: (String) -> Unit,\n    systemInstruction: Contents?,\n    tools: List<Any>,\n    enableConversationConstrainedDecoding: Boolean,\n    coroutineScope: CoroutineScope?,\n  ) {\n    // Prepare options.\n    val maxTokens =\n      model.getIntConfigValue(key = ConfigKeys.MAX_TOKENS, defaultValue = DEFAULT_MAX_TOKEN)\n    val topK = model.getIntConfigValue(key = ConfigKeys.TOPK, defaultValue = DEFAULT_TOPK)\n    val topP = model.getFloatConfigValue(key = ConfigKeys.TOPP, defaultValue = DEFAULT_TOPP)\n    val temperature =\n      model.getFloatConfigValue(key = ConfigKeys.TEMPERATURE, defaultValue = DEFAULT_TEMPERATURE)\n    val accelerator =\n      model.getStringConfigValue(key = ConfigKeys.ACCELERATOR, defaultValue = Accelerator.GPU.label)\n    val visionAccelerator =\n      model.getStringConfigValue(\n        key = ConfigKeys.VISION_ACCELERATOR,\n        defaultValue = DEFAULT_VISION_ACCELERATOR.label,\n      )\n    val visionBackend =\n      when (visionAccelerator) {\n        Accelerator.CPU.label -> Backend.CPU()\n        Accelerator.GPU.label -> Backend.GPU()\n        Accelerator.NPU.label -> Backend.NPU()\n        else -> Backend.GPU()\n      }\n    val shouldEnableImage = supportImage\n    val shouldEnableAudio = supportAudio\n    val preferredBackend =\n      when (accelerator) {\n        Accelerator.CPU.label -> Backend.CPU()\n        Accelerator.GPU.label -> Backend.GPU()\n        Accelerator.NPU.label -> Backend.NPU()\n        else -> Backend.CPU()\n      }\n    Log.d(TAG, \"Preferred backend: $preferredBackend\")\n\n    if (preferredBackend is Backend.NPU) {\n      ExperimentalFlags.npuLibrariesDir = context.applicationInfo.nativeLibraryDir\n    }\n\n    val modelPath = model.getPath(context = context)\n    val engineConfig =\n      EngineConfig(\n        modelPath = modelPath,\n        backend = preferredBackend,\n        visionBackend = if (shouldEnableImage) visionBackend else null, // must be GPU for Gemma 3n\n        audioBackend = if (shouldEnableAudio) Backend.CPU() else null, // must be CPU for Gemma 3n\n        maxNumTokens = maxTokens,\n        cacheDir =\n          if (modelPath.startsWith(\"/data/local/tmp\"))\n            context.getExternalFilesDir(null)?.absolutePath\n          else null,\n      )\n\n    // Create an instance of LiteRT LM engine and conversation.\n    try {\n      val engine = Engine(engineConfig)\n      engine.initialize()\n\n      ExperimentalFlags.enableConversationConstrainedDecoding =\n        enableConversationConstrainedDecoding\n      val conversation =\n        engine.createConversation(\n          ConversationConfig(\n            samplerConfig =\n              if (preferredBackend is Backend.NPU) {\n                null\n              } else {\n                SamplerConfig(\n                  topK = topK,\n                  topP = topP.toDouble(),\n                  temperature = temperature.toDouble(),\n                )\n              },\n            systemInstruction = systemInstruction,\n            tools = tools,\n          )\n        )\n      ExperimentalFlags.enableConversationConstrainedDecoding = false\n      model.instance = LlmModelInstance(engine = engine, conversation = conversation)\n    } catch (e: Exception) {\n      onDone(cleanUpMediapipeTaskErrorMessage(e.message ?: \"Unknown error\"))\n      return\n    }\n    onDone(\"\")\n  }\n\n  @OptIn(ExperimentalApi::class) // opt-in experimental flags\n  override fun resetConversation(\n    model: Model,\n    supportImage: Boolean,\n    supportAudio: Boolean,\n    systemInstruction: Contents?,\n    tools: List<Any>,\n    enableConversationConstrainedDecoding: Boolean,\n  ) {\n    try {\n      Log.d(TAG, \"Resetting conversation for model '${model.name}'\")\n\n      val instance = model.instance as LlmModelInstance? ?: return\n      instance.conversation.close()\n\n      val engine = instance.engine\n      val topK = model.getIntConfigValue(key = ConfigKeys.TOPK, defaultValue = DEFAULT_TOPK)\n      val topP = model.getFloatConfigValue(key = ConfigKeys.TOPP, defaultValue = DEFAULT_TOPP)\n      val temperature =\n        model.getFloatConfigValue(key = ConfigKeys.TEMPERATURE, defaultValue = DEFAULT_TEMPERATURE)\n      val shouldEnableImage = supportImage\n      val shouldEnableAudio = supportAudio\n      Log.d(TAG, \"Enable image: $shouldEnableImage, enable audio: $shouldEnableAudio\")\n\n      val accelerator =\n        model.getStringConfigValue(\n          key = ConfigKeys.ACCELERATOR,\n          defaultValue = Accelerator.GPU.label,\n        )\n      ExperimentalFlags.enableConversationConstrainedDecoding =\n        enableConversationConstrainedDecoding\n      val newConversation =\n        engine.createConversation(\n          ConversationConfig(\n            samplerConfig =\n              if (accelerator == Accelerator.NPU.label) {\n                null\n              } else {\n                SamplerConfig(\n                  topK = topK,\n                  topP = topP.toDouble(),\n                  temperature = temperature.toDouble(),\n                )\n              },\n            systemInstruction = systemInstruction,\n            tools = tools,\n          )\n        )\n      ExperimentalFlags.enableConversationConstrainedDecoding = false\n      instance.conversation = newConversation\n\n      Log.d(TAG, \"Resetting done\")\n    } catch (e: Exception) {\n      Log.d(TAG, \"Failed to reset conversation\", e)\n    }\n  }\n\n  override fun cleanUp(model: Model, onDone: () -> Unit) {\n    if (model.instance == null) {\n      return\n    }\n\n    val instance = model.instance as LlmModelInstance\n\n    try {\n      instance.conversation.close()\n    } catch (e: Exception) {\n      Log.e(TAG, \"Failed to close the conversation: ${e.message}\")\n    }\n\n    try {\n      instance.engine.close()\n    } catch (e: Exception) {\n      Log.e(TAG, \"Failed to close the engine: ${e.message}\")\n    }\n\n    val onCleanUp = cleanUpListeners.remove(model.name)\n    if (onCleanUp != null) {\n      onCleanUp()\n    }\n    model.instance = null\n\n    onDone()\n    Log.d(TAG, \"Clean up done.\")\n  }\n\n  override fun stopResponse(model: Model) {\n    val instance = model.instance as? LlmModelInstance ?: return\n    instance.conversation.cancelProcess()\n  }\n\n  override fun runInference(\n    model: Model,\n    input: String,\n    resultListener: ResultListener,\n    cleanUpListener: CleanUpListener,\n    onError: (message: String) -> Unit,\n    images: List<Bitmap>,\n    audioClips: List<ByteArray>,\n    coroutineScope: CoroutineScope?,\n  ) {\n    val instance = model.instance as? LlmModelInstance\n    if (instance == null) {\n      onError(\"LlmModelInstance is not initialized.\")\n      return\n    }\n\n    // Set listener.\n    if (!cleanUpListeners.containsKey(model.name)) {\n      cleanUpListeners[model.name] = cleanUpListener\n    }\n\n    val conversation = instance.conversation\n\n    val contents = mutableListOf<Content>()\n    for (image in images) {\n      contents.add(Content.ImageBytes(image.toPngByteArray()))\n    }\n    for (audioClip in audioClips) {\n      contents.add(Content.AudioBytes(audioClip))\n    }\n    // add the text after image and audio for the accurate last token\n    if (input.trim().isNotEmpty()) {\n      contents.add(Content.Text(input))\n    }\n\n    conversation.sendMessageAsync(\n      Contents.of(contents),\n      object : MessageCallback {\n        override fun onMessage(message: Message) {\n          resultListener(message.toString(), false)\n        }\n\n        override fun onDone() {\n          resultListener(\"\", true)\n        }\n\n        override fun onError(throwable: Throwable) {\n          if (throwable is CancellationException) {\n            Log.i(TAG, \"The inference is cancelled.\")\n            resultListener(\"\", true)\n          } else {\n            Log.e(TAG, \"onError\", throwable)\n            onError(\"Error: ${throwable.message}\")\n          }\n        }\n      },\n    )\n  }\n\n  private fun Bitmap.toPngByteArray(): ByteArray {\n    val stream = ByteArrayOutputStream()\n    this.compress(Bitmap.CompressFormat.PNG, 100, stream)\n    return stream.toByteArray()\n  }\n}\n"
  },
  {
    "path": "Android/src/app/src/main/java/com/google/ai/edge/gallery/ui/llmchat/LlmChatScreen.kt",
    "content": "/*\n * Copyright 2025 Google LLC\n *\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n *     http://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\n\npackage com.google.ai.edge.gallery.ui.llmchat\n\nimport androidx.hilt.navigation.compose.hiltViewModel\n\nimport android.graphics.Bitmap\nimport androidx.compose.foundation.layout.Arrangement\nimport androidx.compose.foundation.layout.Column\nimport androidx.compose.foundation.layout.fillMaxSize\nimport androidx.compose.foundation.layout.padding\nimport androidx.compose.runtime.Composable\nimport androidx.compose.ui.Alignment\nimport androidx.compose.ui.Modifier\nimport androidx.compose.ui.platform.LocalContext\nimport androidx.compose.ui.semantics.LiveRegionMode\nimport androidx.compose.ui.semantics.liveRegion\nimport androidx.compose.ui.semantics.semantics\nimport androidx.compose.ui.unit.dp\nimport androidx.core.os.bundleOf\nimport com.google.ai.edge.gallery.GalleryEvent\nimport com.google.ai.edge.gallery.data.BuiltInTaskId\nimport com.google.ai.edge.gallery.data.Model\nimport com.google.ai.edge.gallery.data.Task\nimport com.google.ai.edge.gallery.firebaseAnalytics\nimport com.google.ai.edge.gallery.ui.common.chat.ChatMessage\nimport com.google.ai.edge.gallery.ui.common.chat.ChatMessageAudioClip\nimport com.google.ai.edge.gallery.ui.common.chat.ChatMessageImage\nimport com.google.ai.edge.gallery.ui.common.chat.ChatMessageInfo\nimport com.google.ai.edge.gallery.ui.common.chat.ChatMessageText\nimport com.google.ai.edge.gallery.ui.common.chat.ChatView\nimport com.google.ai.edge.gallery.ui.common.chat.MessageBodyInfo\nimport com.google.ai.edge.gallery.ui.modelmanager.ModelManagerViewModel\n\nprivate const val TAG = \"AGLlmChatScreen\"\n\n@Composable\nfun LlmChatScreen(\n  modelManagerViewModel: ModelManagerViewModel,\n  navigateUp: () -> Unit,\n  modifier: Modifier = Modifier,\n  taskId: String = BuiltInTaskId.LLM_CHAT,\n  onFirstToken: (Model) -> Unit = {},\n  onGenerateResponseDone: (Model) -> Unit = {},\n  onResetSessionClickedOverride: ((Task, Model) -> Unit)? = null,\n  composableBelowMessageList: @Composable (Model) -> Unit = {},\n  viewModel: LlmChatViewModel = hiltViewModel(),\n  allowEditingSystemPrompt: Boolean = false,\n  curSystemPrompt: String = \"\",\n  onSystemPromptChanged: (String) -> Unit = {},\n  emptyStateComposable: @Composable () -> Unit = {},\n  sendMessageTrigger: Pair<Model, List<ChatMessage>>? = null,\n) {\n  ChatViewWrapper(\n    viewModel = viewModel,\n    modelManagerViewModel = modelManagerViewModel,\n    taskId = taskId,\n    navigateUp = navigateUp,\n    modifier = modifier,\n    onFirstToken = onFirstToken,\n    onGenerateResponseDone = onGenerateResponseDone,\n    onResetSessionClickedOverride = onResetSessionClickedOverride,\n    composableBelowMessageList = composableBelowMessageList,\n    allowEditingSystemPrompt = allowEditingSystemPrompt,\n    curSystemPrompt = curSystemPrompt,\n    onSystemPromptChanged = onSystemPromptChanged,\n    emptyStateComposable = emptyStateComposable,\n    sendMessageTrigger = sendMessageTrigger,\n  )\n}\n\n@Composable\nfun LlmAskImageScreen(\n  modelManagerViewModel: ModelManagerViewModel,\n  navigateUp: () -> Unit,\n  modifier: Modifier = Modifier,\n  viewModel: LlmAskImageViewModel = hiltViewModel(),\n) {\n  ChatViewWrapper(\n    viewModel = viewModel,\n    modelManagerViewModel = modelManagerViewModel,\n    taskId = BuiltInTaskId.LLM_ASK_IMAGE,\n    navigateUp = navigateUp,\n    modifier = modifier,\n    emptyStateComposable = {\n      Column(\n        modifier =\n          Modifier.padding(horizontal = 16.dp).fillMaxSize().semantics(mergeDescendants = true) {\n            liveRegion = LiveRegionMode.Polite\n          },\n        horizontalAlignment = Alignment.CenterHorizontally,\n        verticalArrangement = Arrangement.Center,\n      ) {\n        MessageBodyInfo(\n          ChatMessageInfo(\n            content =\n              \"To get started, tap the Add image button below to add images (up to 10 in a single session) and type a prompt to ask a question about it.\"\n          ),\n          smallFontSize = false,\n        )\n      }\n    },\n  )\n}\n\n@Composable\nfun LlmAskAudioScreen(\n  modelManagerViewModel: ModelManagerViewModel,\n  navigateUp: () -> Unit,\n  modifier: Modifier = Modifier,\n  viewModel: LlmAskAudioViewModel = hiltViewModel(),\n) {\n  ChatViewWrapper(\n    viewModel = viewModel,\n    modelManagerViewModel = modelManagerViewModel,\n    taskId = BuiltInTaskId.LLM_ASK_AUDIO,\n    navigateUp = navigateUp,\n    modifier = modifier,\n    emptyStateComposable = {\n      Column(\n        modifier =\n          Modifier.padding(horizontal = 16.dp).fillMaxSize().semantics(mergeDescendants = true) {\n            liveRegion = LiveRegionMode.Polite\n          },\n        horizontalAlignment = Alignment.CenterHorizontally,\n        verticalArrangement = Arrangement.Center,\n      ) {\n        MessageBodyInfo(\n          ChatMessageInfo(\n            content =\n              \"To get started, tap the Add audio button below to add your audio clip. Limited to 1 clip up to 30 seconds long.\"\n          ),\n          smallFontSize = false,\n        )\n      }\n    },\n  )\n}\n\n@Composable\nfun ChatViewWrapper(\n  viewModel: LlmChatViewModelBase,\n  modelManagerViewModel: ModelManagerViewModel,\n  taskId: String,\n  navigateUp: () -> Unit,\n  modifier: Modifier = Modifier,\n  onFirstToken: (Model) -> Unit = {},\n  onGenerateResponseDone: (Model) -> Unit = {},\n  onResetSessionClickedOverride: ((Task, Model) -> Unit)? = null,\n  composableBelowMessageList: @Composable (Model) -> Unit = {},\n  emptyStateComposable: @Composable () -> Unit = {},\n  allowEditingSystemPrompt: Boolean = false,\n  curSystemPrompt: String = \"\",\n  onSystemPromptChanged: (String) -> Unit = {},\n  sendMessageTrigger: Pair<Model, List<ChatMessage>>? = null,\n) {\n  val context = LocalContext.current\n  val task = modelManagerViewModel.getTaskById(id = taskId)!!\n\n  ChatView(\n    task = task,\n    viewModel = viewModel,\n    modelManagerViewModel = modelManagerViewModel,\n    onSendMessage = { model, messages ->\n      for (message in messages) {\n        viewModel.addMessage(model = model, message = message)\n      }\n\n      var text = \"\"\n      val images: MutableList<Bitmap> = mutableListOf()\n      val audioMessages: MutableList<ChatMessageAudioClip> = mutableListOf()\n      var chatMessageText: ChatMessageText? = null\n      for (message in messages) {\n        if (message is ChatMessageText) {\n          chatMessageText = message\n          text = message.content\n        } else if (message is ChatMessageImage) {\n          images.addAll(message.bitmaps)\n        } else if (message is ChatMessageAudioClip) {\n          audioMessages.add(message)\n        }\n      }\n      if ((text.isNotEmpty() && chatMessageText != null) || audioMessages.isNotEmpty()) {\n        modelManagerViewModel.addTextInputHistory(text)\n        viewModel.generateResponse(\n          model = model,\n          input = text,\n          images = images,\n          audioMessages = audioMessages,\n          onFirstToken = onFirstToken,\n          onDone = { onGenerateResponseDone(model) },\n          onError = { errorMessage ->\n            viewModel.handleError(\n              context = context,\n              task = task,\n              model = model,\n              errorMessage = errorMessage,\n              modelManagerViewModel = modelManagerViewModel,\n            )\n          },\n        )\n\n        firebaseAnalytics?.logEvent(\n          GalleryEvent.GENERATE_ACTION.id,\n          bundleOf(\"capability_name\" to task.id, \"model_id\" to model.name),\n        )\n      }\n    },\n    onRunAgainClicked = { model, message ->\n      if (message is ChatMessageText) {\n        viewModel.runAgain(\n          model = model,\n          message = message,\n          onError = { errorMessage ->\n            viewModel.handleError(\n              context = context,\n              task = task,\n              model = model,\n              errorMessage = errorMessage,\n              modelManagerViewModel = modelManagerViewModel,\n            )\n          },\n        )\n      }\n    },\n    onBenchmarkClicked = { _, _, _, _ -> },\n    onResetSessionClicked = { model ->\n      if (onResetSessionClickedOverride != null) {\n        onResetSessionClickedOverride(task, model)\n      } else {\n        viewModel.resetSession(task = task, model = model)\n      }\n    },\n    showStopButtonInInputWhenInProgress = true,\n    onStopButtonClicked = { model -> viewModel.stopResponse(model = model) },\n    navigateUp = navigateUp,\n    modifier = modifier,\n    composableBelowMessageList = composableBelowMessageList,\n    emptyStateComposable = emptyStateComposable,\n    allowEditingSystemPrompt = allowEditingSystemPrompt,\n    curSystemPrompt = curSystemPrompt,\n    onSystemPromptChanged = onSystemPromptChanged,\n    sendMessageTrigger = sendMessageTrigger,\n  )\n}\n"
  },
  {
    "path": "Android/src/app/src/main/java/com/google/ai/edge/gallery/ui/llmchat/LlmChatTaskModule.kt",
    "content": "/*\n * Copyright 2025 Google LLC\n *\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n *     http://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\n\npackage com.google.ai.edge.gallery.ui.llmchat\n\nimport android.content.Context\nimport androidx.compose.material.icons.Icons\nimport androidx.compose.material.icons.outlined.Forum\nimport androidx.compose.material.icons.outlined.Mic\nimport androidx.compose.material.icons.outlined.Mms\nimport androidx.compose.runtime.Composable\nimport com.google.ai.edge.gallery.R\nimport com.google.ai.edge.gallery.customtasks.common.CustomTask\nimport com.google.ai.edge.gallery.customtasks.common.CustomTaskDataForBuiltinTask\nimport com.google.ai.edge.gallery.data.BuiltInTaskId\nimport com.google.ai.edge.gallery.data.Category\nimport com.google.ai.edge.gallery.data.Model\nimport com.google.ai.edge.gallery.data.Task\nimport com.google.ai.edge.gallery.runtime.runtimeHelper\nimport dagger.Module\nimport dagger.Provides\nimport dagger.hilt.InstallIn\nimport dagger.hilt.components.SingletonComponent\nimport dagger.multibindings.IntoSet\nimport javax.inject.Inject\nimport kotlinx.coroutines.CoroutineScope\n\n////////////////////////////////////////////////////////////////////////////////////////////////////\n// AI Chat.\n\nclass LlmChatTask @Inject constructor() : CustomTask {\n  override val task: Task =\n    Task(\n      id = BuiltInTaskId.LLM_CHAT,\n      label = \"AI Chat\",\n      category = Category.LLM,\n      icon = Icons.Outlined.Forum,\n      models = mutableListOf(),\n      description = \"Chat with on-device large language models\",\n      docUrl = \"https://github.com/google-ai-edge/LiteRT-LM/blob/main/kotlin/README.md\",\n      sourceCodeUrl =\n        \"https://github.com/google-ai-edge/gallery/blob/main/Android/src/app/src/main/java/com/google/ai/edge/gallery/ui/llmchat/LlmChatModelHelper.kt\",\n      textInputPlaceHolderRes = R.string.text_input_placeholder_llm_chat,\n    )\n\n  override fun initializeModelFn(\n    context: Context,\n    coroutineScope: CoroutineScope,\n    model: Model,\n    onDone: (String) -> Unit,\n  ) {\n    model.runtimeHelper.initialize(\n      context = context,\n      model = model,\n      supportImage = false,\n      supportAudio = false,\n      onDone = onDone,\n      coroutineScope = coroutineScope,\n    )\n  }\n\n  override fun cleanUpModelFn(\n    context: Context,\n    coroutineScope: CoroutineScope,\n    model: Model,\n    onDone: () -> Unit,\n  ) {\n    model.runtimeHelper.cleanUp(model = model, onDone = onDone)\n  }\n\n  @Composable\n  override fun MainScreen(data: Any) {\n    val myData = data as CustomTaskDataForBuiltinTask\n    LlmChatScreen(modelManagerViewModel = myData.modelManagerViewModel, navigateUp = myData.onNavUp)\n  }\n}\n\n@Module\n@InstallIn(SingletonComponent::class) // Or another component that fits your scope\ninternal object LlmChatTaskModule {\n  @Provides\n  @IntoSet\n  fun provideTask(): CustomTask {\n    return LlmChatTask()\n  }\n}\n\n////////////////////////////////////////////////////////////////////////////////////////////////////\n// Ask image.\n\nclass LlmAskImageTask @Inject constructor() : CustomTask {\n  override val task: Task =\n    Task(\n      id = BuiltInTaskId.LLM_ASK_IMAGE,\n      label = \"Ask Image\",\n      category = Category.LLM,\n      icon = Icons.Outlined.Mms,\n      models = mutableListOf(),\n      description = \"Ask questions about images with on-device large language models\",\n      docUrl = \"https://github.com/google-ai-edge/LiteRT-LM/blob/main/kotlin/README.md\",\n      sourceCodeUrl =\n        \"https://github.com/google-ai-edge/gallery/blob/main/Android/src/app/src/main/java/com/google/ai/edge/gallery/ui/llmchat/LlmChatModelHelper.kt\",\n      textInputPlaceHolderRes = R.string.text_input_placeholder_llm_chat,\n    )\n\n  override fun initializeModelFn(\n    context: Context,\n    coroutineScope: CoroutineScope,\n    model: Model,\n    onDone: (String) -> Unit,\n  ) {\n    model.runtimeHelper.initialize(\n      context = context,\n      model = model,\n      supportImage = true,\n      supportAudio = false,\n      onDone = onDone,\n      coroutineScope = coroutineScope,\n    )\n  }\n\n  override fun cleanUpModelFn(\n    context: Context,\n    coroutineScope: CoroutineScope,\n    model: Model,\n    onDone: () -> Unit,\n  ) {\n    model.runtimeHelper.cleanUp(model = model, onDone = onDone)\n  }\n\n  @Composable\n  override fun MainScreen(data: Any) {\n    val myData = data as CustomTaskDataForBuiltinTask\n    LlmAskImageScreen(\n      modelManagerViewModel = myData.modelManagerViewModel,\n      navigateUp = myData.onNavUp,\n    )\n  }\n}\n\n@Module\n@InstallIn(SingletonComponent::class) // Or another component that fits your scope\ninternal object LlmAskImageModule {\n  @Provides\n  @IntoSet\n  fun provideTask(): CustomTask {\n    return LlmAskImageTask()\n  }\n}\n\n////////////////////////////////////////////////////////////////////////////////////////////////////\n// Ask audio.\n\nclass LlmAskAudioTask @Inject constructor() : CustomTask {\n  override val task: Task =\n    Task(\n      id = BuiltInTaskId.LLM_ASK_AUDIO,\n      label = \"Audio Scribe\",\n      category = Category.LLM,\n      icon = Icons.Outlined.Mic,\n      models = mutableListOf(),\n      description =\n        \"Instantly transcribe and/or translate audio clips using on-device large language models\",\n      docUrl = \"https://github.com/google-ai-edge/LiteRT-LM/blob/main/kotlin/README.md\",\n      sourceCodeUrl =\n        \"https://github.com/google-ai-edge/gallery/blob/main/Android/src/app/src/main/java/com/google/ai/edge/gallery/ui/llmchat/LlmChatModelHelper.kt\",\n      textInputPlaceHolderRes = R.string.text_input_placeholder_llm_chat,\n    )\n\n  override fun initializeModelFn(\n    context: Context,\n    coroutineScope: CoroutineScope,\n    model: Model,\n    onDone: (String) -> Unit,\n  ) {\n    model.runtimeHelper.initialize(\n      context = context,\n      model = model,\n      supportImage = false,\n      supportAudio = true,\n      onDone = onDone,\n      coroutineScope = coroutineScope,\n    )\n  }\n\n  override fun cleanUpModelFn(\n    context: Context,\n    coroutineScope: CoroutineScope,\n    model: Model,\n    onDone: () -> Unit,\n  ) {\n    model.runtimeHelper.cleanUp(model = model, onDone = onDone)\n  }\n\n  @Composable\n  override fun MainScreen(data: Any) {\n    val myData = data as CustomTaskDataForBuiltinTask\n    LlmAskAudioScreen(\n      modelManagerViewModel = myData.modelManagerViewModel,\n      navigateUp = myData.onNavUp,\n    )\n  }\n}\n\n@Module\n@InstallIn(SingletonComponent::class) // Or another component that fits your scope\ninternal object LlmAskAudioModule {\n  @Provides\n  @IntoSet\n  fun provideTask(): CustomTask {\n    return LlmAskAudioTask()\n  }\n}\n"
  },
  {
    "path": "Android/src/app/src/main/java/com/google/ai/edge/gallery/ui/llmchat/LlmChatViewModel.kt",
    "content": "/*\n * Copyright 2025 Google LLC\n *\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n *     http://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\n\npackage com.google.ai.edge.gallery.ui.llmchat\n\nimport android.content.Context\nimport android.graphics.Bitmap\nimport android.util.Log\nimport androidx.lifecycle.viewModelScope\nimport com.google.ai.edge.gallery.data.ConfigKeys\nimport com.google.ai.edge.gallery.data.Model\nimport com.google.ai.edge.gallery.data.Task\nimport com.google.ai.edge.gallery.runtime.runtimeHelper\nimport com.google.ai.edge.gallery.ui.common.chat.ChatMessageAudioClip\nimport com.google.ai.edge.gallery.ui.common.chat.ChatMessageError\nimport com.google.ai.edge.gallery.ui.common.chat.ChatMessageLoading\nimport com.google.ai.edge.gallery.ui.common.chat.ChatMessageText\nimport com.google.ai.edge.gallery.ui.common.chat.ChatMessageType\nimport com.google.ai.edge.gallery.ui.common.chat.ChatMessageWarning\nimport com.google.ai.edge.gallery.ui.common.chat.ChatSide\nimport com.google.ai.edge.gallery.ui.common.chat.ChatViewModel\nimport com.google.ai.edge.gallery.ui.modelmanager.ModelManagerViewModel\nimport com.google.ai.edge.litertlm.Contents\nimport com.google.ai.edge.litertlm.ExperimentalApi\nimport dagger.hilt.android.lifecycle.HiltViewModel\nimport javax.inject.Inject\nimport kotlinx.coroutines.Dispatchers\nimport kotlinx.coroutines.delay\nimport kotlinx.coroutines.launch\n\nprivate const val TAG = \"AGLlmChatViewModel\"\n\n@OptIn(ExperimentalApi::class)\nopen class LlmChatViewModelBase() : ChatViewModel() {\n  fun generateResponse(\n    model: Model,\n    input: String,\n    images: List<Bitmap> = listOf(),\n    audioMessages: List<ChatMessageAudioClip> = listOf(),\n    onFirstToken: (Model) -> Unit = {},\n    onDone: () -> Unit = {},\n    onError: (String) -> Unit,\n  ) {\n    val accelerator = model.getStringConfigValue(key = ConfigKeys.ACCELERATOR, defaultValue = \"\")\n    viewModelScope.launch(Dispatchers.Default) {\n      setInProgress(true)\n      setPreparing(true)\n\n      // Loading.\n      addMessage(model = model, message = ChatMessageLoading(accelerator = accelerator))\n\n      // Wait for instance to be initialized.\n      while (model.instance == null) {\n        delay(100)\n      }\n      delay(500)\n\n      // Run inference.\n      val audioClips: MutableList<ByteArray> = mutableListOf()\n      for (audioMessage in audioMessages) {\n        audioClips.add(audioMessage.genByteArrayForWav())\n      }\n\n      var firstRun = true\n      val start = System.currentTimeMillis()\n\n      try {\n        val resultListener: (String, Boolean) -> Unit = { partialResult, done ->\n          if (partialResult.startsWith(\"<ctrl\")) {\n            // Do nothing. Ignore control tokens.\n          } else {\n            // Remove the last message if it is a \"loading\" message.\n            // This will only be done once.\n            val lastMessage = getLastMessage(model = model)\n            if (lastMessage?.type == ChatMessageType.LOADING) {\n              removeLastMessage(model = model)\n            }\n            if (\n              lastMessage?.type == ChatMessageType.LOADING ||\n                lastMessage?.type == ChatMessageType.COLLAPSABLE_PROGRESS_PANEL\n            ) {\n              // Add an empty message that will receive streaming results.\n              addMessage(\n                model = model,\n                message =\n                  ChatMessageText(\n                    content = \"\",\n                    side = ChatSide.AGENT,\n                    accelerator = accelerator,\n                    hideSenderLabel = lastMessage.type == ChatMessageType.COLLAPSABLE_PROGRESS_PANEL,\n                  ),\n              )\n            }\n\n            // Incrementally update the streamed partial results.\n            val latencyMs: Long = if (done) System.currentTimeMillis() - start else -1\n            updateLastTextMessageContentIncrementally(\n              model = model,\n              partialContent = partialResult,\n              latencyMs = latencyMs.toFloat(),\n            )\n\n            if (firstRun) {\n              firstRun = false\n              setPreparing(false)\n              onFirstToken(model)\n            }\n\n            if (done) {\n              setInProgress(false)\n              onDone()\n            }\n          }\n        }\n\n        val cleanUpListener: () -> Unit = {\n          setInProgress(false)\n          setPreparing(false)\n        }\n\n        val errorListener: (String) -> Unit = { message ->\n          Log.e(TAG, \"Error occurred while running inference\")\n          setInProgress(false)\n          setPreparing(false)\n          onError(message)\n        }\n\n        model.runtimeHelper.runInference(\n          model = model,\n          input = input,\n          images = images,\n          audioClips = audioClips,\n          resultListener = resultListener,\n          cleanUpListener = cleanUpListener,\n          onError = errorListener,\n          coroutineScope = viewModelScope,\n        )\n      } catch (e: Exception) {\n        Log.e(TAG, \"Error occurred while running inference\", e)\n        setInProgress(false)\n        setPreparing(false)\n        onError(e.message ?: \"\")\n      }\n    }\n  }\n\n  fun stopResponse(model: Model) {\n    Log.d(TAG, \"Stopping response for model ${model.name}...\")\n    if (getLastMessage(model = model) is ChatMessageLoading) {\n      removeLastMessage(model = model)\n    }\n    setInProgress(false)\n    model.runtimeHelper.stopResponse(model)\n    Log.d(TAG, \"Done stopping response\")\n  }\n\n  fun resetSession(\n    task: Task,\n    model: Model,\n    systemInstruction: Contents? = null,\n    tools: List<Any> = listOf(),\n    onDone: () -> Unit = {},\n  ) {\n    viewModelScope.launch(Dispatchers.Default) {\n      setIsResettingSession(true)\n      clearAllMessages(model = model)\n      stopResponse(model = model)\n\n      while (true) {\n        try {\n          val supportImage =\n            model.llmSupportImage &&\n              task.id == com.google.ai.edge.gallery.data.BuiltInTaskId.LLM_ASK_IMAGE\n          val supportAudio =\n            model.llmSupportAudio &&\n              task.id == com.google.ai.edge.gallery.data.BuiltInTaskId.LLM_ASK_AUDIO\n          model.runtimeHelper.resetConversation(\n            model = model,\n            supportImage = supportImage,\n            supportAudio = supportAudio,\n            systemInstruction = systemInstruction,\n            tools = tools,\n          )\n          break\n        } catch (e: Exception) {\n          Log.d(TAG, \"Failed to reset session. Trying again\")\n        }\n        delay(200)\n      }\n      setIsResettingSession(false)\n      onDone()\n    }\n  }\n\n  fun runAgain(model: Model, message: ChatMessageText, onError: (String) -> Unit) {\n    viewModelScope.launch(Dispatchers.Default) {\n      // Wait for model to be initialized.\n      while (model.instance == null) {\n        delay(100)\n      }\n\n      // Clone the clicked message and add it.\n      addMessage(model = model, message = message.clone())\n\n      // Run inference.\n      generateResponse(model = model, input = message.content, onError = onError)\n    }\n  }\n\n  fun handleError(\n    context: Context,\n    task: Task,\n    model: Model,\n    modelManagerViewModel: ModelManagerViewModel,\n    errorMessage: String,\n  ) {\n    // Remove the \"loading\" message.\n    if (getLastMessage(model = model) is ChatMessageLoading) {\n      removeLastMessage(model = model)\n    }\n\n    // Show error message.\n    addMessage(model = model, message = ChatMessageError(content = errorMessage))\n\n    // Clean up and re-initialize.\n    viewModelScope.launch(Dispatchers.Default) {\n      modelManagerViewModel.cleanupModel(\n        context = context,\n        task = task,\n        model = model,\n        onDone = {\n          modelManagerViewModel.initializeModel(context = context, task = task, model = model)\n\n          // Add a warning message for re-initializing the session.\n          addMessage(\n            model = model,\n            message = ChatMessageWarning(content = \"Session re-initialized\"),\n          )\n        },\n      )\n    }\n  }\n}\n\n@HiltViewModel class LlmChatViewModel @Inject constructor() : LlmChatViewModelBase()\n\n@HiltViewModel class LlmAskImageViewModel @Inject constructor() : LlmChatViewModelBase()\n\n@HiltViewModel class LlmAskAudioViewModel @Inject constructor() : LlmChatViewModelBase()\n"
  },
  {
    "path": "Android/src/app/src/main/java/com/google/ai/edge/gallery/ui/llmsingleturn/LlmSingleTurnScreen.kt",
    "content": "/*\n * Copyright 2025 Google LLC\n *\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n *     http://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\n\npackage com.google.ai.edge.gallery.ui.llmsingleturn\n\nimport androidx.hilt.navigation.compose.hiltViewModel\n\n// import androidx.compose.ui.tooling.preview.Preview\n// import com.google.ai.edge.gallery.ui.preview.PreviewLlmSingleTurnViewModel\n// import com.google.ai.edge.gallery.ui.preview.PreviewModelManagerViewModel\n// import com.google.ai.edge.gallery.ui.theme.GalleryTheme\nimport android.util.Log\nimport androidx.activity.compose.BackHandler\nimport androidx.compose.animation.AnimatedVisibility\nimport androidx.compose.animation.core.animateFloatAsState\nimport androidx.compose.animation.fadeIn\nimport androidx.compose.animation.fadeOut\nimport androidx.compose.animation.scaleIn\nimport androidx.compose.animation.scaleOut\nimport androidx.compose.foundation.background\nimport androidx.compose.foundation.layout.Box\nimport androidx.compose.foundation.layout.calculateStartPadding\nimport androidx.compose.foundation.layout.fillMaxSize\nimport androidx.compose.foundation.layout.padding\nimport androidx.compose.material3.MaterialTheme\nimport androidx.compose.material3.Scaffold\nimport androidx.compose.runtime.Composable\nimport androidx.compose.runtime.LaunchedEffect\nimport androidx.compose.runtime.collectAsState\nimport androidx.compose.runtime.getValue\nimport androidx.compose.runtime.mutableStateOf\nimport androidx.compose.runtime.remember\nimport androidx.compose.runtime.rememberCoroutineScope\nimport androidx.compose.runtime.setValue\nimport androidx.compose.ui.Alignment\nimport androidx.compose.ui.Modifier\nimport androidx.compose.ui.graphics.graphicsLayer\nimport androidx.compose.ui.platform.LocalContext\nimport androidx.compose.ui.platform.LocalLayoutDirection\nimport androidx.core.os.bundleOf\nimport com.google.ai.edge.gallery.GalleryEvent\nimport com.google.ai.edge.gallery.data.BuiltInTaskId\nimport com.google.ai.edge.gallery.data.ModelDownloadStatusType\nimport com.google.ai.edge.gallery.firebaseAnalytics\nimport com.google.ai.edge.gallery.ui.common.ErrorDialog\nimport com.google.ai.edge.gallery.ui.common.ModelPageAppBar\nimport com.google.ai.edge.gallery.ui.common.chat.ModelDownloadStatusInfoPanel\nimport com.google.ai.edge.gallery.ui.modelmanager.ModelInitializationStatusType\nimport com.google.ai.edge.gallery.ui.modelmanager.ModelManagerViewModel\nimport com.google.ai.edge.gallery.ui.theme.customColors\nimport kotlinx.coroutines.Dispatchers\nimport kotlinx.coroutines.launch\n\nprivate const val TAG = \"AGLlmSingleTurnScreen\"\n\n@Composable\nfun LlmSingleTurnScreen(\n  modelManagerViewModel: ModelManagerViewModel,\n  navigateUp: () -> Unit,\n  modifier: Modifier = Modifier,\n  viewModel: LlmSingleTurnViewModel = hiltViewModel(),\n) {\n  val task = modelManagerViewModel.getTaskById(id = BuiltInTaskId.LLM_PROMPT_LAB)!!\n  val modelManagerUiState by modelManagerViewModel.uiState.collectAsState()\n  val uiState by viewModel.uiState.collectAsState()\n  val selectedModel = modelManagerUiState.selectedModel\n  val scope = rememberCoroutineScope()\n  val context = LocalContext.current\n  var navigatingUp by remember { mutableStateOf(false) }\n  var showErrorDialog by remember { mutableStateOf(false) }\n\n  val handleNavigateUp = {\n    navigatingUp = true\n    navigateUp()\n\n    // clean up all models.\n    scope.launch(Dispatchers.Default) {\n      for (model in task.models) {\n        modelManagerViewModel.cleanupModel(context = context, task = task, model = model)\n      }\n    }\n  }\n\n  // Handle system's edge swipe.\n  BackHandler {\n    val modelInitializationStatus =\n      modelManagerUiState.modelInitializationStatus[selectedModel.name]\n    val isModelInitializing =\n      modelInitializationStatus?.status == ModelInitializationStatusType.INITIALIZING\n    if (!isModelInitializing && !uiState.inProgress) {\n      handleNavigateUp()\n    }\n  }\n\n  // Initialize model when model/download state changes.\n  val curDownloadStatus = modelManagerUiState.modelDownloadStatus[selectedModel.name]\n  LaunchedEffect(curDownloadStatus, selectedModel.name) {\n    if (!navigatingUp) {\n      if (curDownloadStatus?.status == ModelDownloadStatusType.SUCCEEDED) {\n        Log.d(\n          TAG,\n          \"Initializing model '${selectedModel.name}' from LlmsingleTurnScreen launched effect\",\n        )\n        modelManagerViewModel.initializeModel(context, task = task, model = selectedModel)\n      }\n    }\n  }\n\n  val modelInitializationStatus = modelManagerUiState.modelInitializationStatus[selectedModel.name]\n  LaunchedEffect(modelInitializationStatus) {\n    showErrorDialog = modelInitializationStatus?.status == ModelInitializationStatusType.ERROR\n  }\n\n  Scaffold(\n    modifier = modifier,\n    topBar = {\n      ModelPageAppBar(\n        task = task,\n        model = selectedModel,\n        modelManagerViewModel = modelManagerViewModel,\n        inProgress = uiState.inProgress,\n        modelPreparing = uiState.preparing,\n        onConfigChanged = { _, _ -> },\n        onBackClicked = { handleNavigateUp() },\n        onModelSelected = { prevModel, newSelectedModel ->\n          scope.launch(Dispatchers.Default) {\n            if (prevModel.name != newSelectedModel.name) {\n              // Clean up prev model.\n              modelManagerViewModel.cleanupModel(context = context, task = task, model = prevModel)\n            }\n\n            // Update selected model.\n            modelManagerViewModel.selectModel(model = newSelectedModel)\n          }\n        },\n      )\n    },\n  ) { innerPadding ->\n    Box(\n      modifier =\n        Modifier.padding(\n          top = innerPadding.calculateTopPadding(),\n          start = innerPadding.calculateStartPadding(LocalLayoutDirection.current),\n          end = innerPadding.calculateStartPadding(LocalLayoutDirection.current),\n        )\n    ) {\n      val modelDownloaded = curDownloadStatus?.status == ModelDownloadStatusType.SUCCEEDED\n      AnimatedVisibility(\n        visible = !modelDownloaded,\n        enter = scaleIn(initialScale = 0.9f) + fadeIn(),\n        exit = scaleOut(targetScale = 0.9f) + fadeOut(),\n      ) {\n        ModelDownloadStatusInfoPanel(\n          model = selectedModel,\n          task = task,\n          modelManagerViewModel = modelManagerViewModel,\n        )\n      }\n\n      // Main UI after model is downloaded.\n      var mainUiVisible by remember { mutableStateOf(modelDownloaded) }\n      LaunchedEffect(modelDownloaded) { mainUiVisible = modelDownloaded }\n      val animatedAlpha by animateFloatAsState(targetValue = if (mainUiVisible) 1.0f else 0f)\n      Box(\n        contentAlignment = Alignment.BottomCenter,\n        modifier =\n          Modifier.fillMaxSize()\n            // Just hide the UI without removing it from the screen so that the scroll syncing\n            // from ResponsePanel still works.\n            .graphicsLayer { alpha = animatedAlpha },\n      ) {\n        VerticalSplitView(\n          modifier = Modifier.fillMaxSize(),\n          topView = {\n            PromptTemplatesPanel(\n              model = selectedModel,\n              viewModel = viewModel,\n              modelManagerViewModel = modelManagerViewModel,\n              onSend = { fullPrompt ->\n                viewModel.generateResponse(task = task, model = selectedModel, input = fullPrompt)\n\n                firebaseAnalytics?.logEvent(\n                  GalleryEvent.GENERATE_ACTION.id,\n                  bundleOf(\"capability_name\" to task.id, \"model_id\" to selectedModel.name),\n                )\n              },\n              onStopButtonClicked = { model -> viewModel.stopResponse(model = model) },\n              modifier = Modifier.fillMaxSize(),\n            )\n          },\n          bottomView = {\n            Box(\n              contentAlignment = Alignment.BottomCenter,\n              modifier =\n                Modifier.fillMaxSize().background(MaterialTheme.customColors.agentBubbleBgColor),\n            ) {\n              if (task.models.indexOf(selectedModel) >= 0) {\n                ResponsePanel(\n                  task = task,\n                  model = selectedModel,\n                  viewModel = viewModel,\n                  modelManagerViewModel = modelManagerViewModel,\n                  modifier =\n                    Modifier.fillMaxSize().padding(bottom = innerPadding.calculateBottomPadding()),\n                )\n              }\n            }\n          },\n        )\n      }\n\n      if (showErrorDialog) {\n        ErrorDialog(\n          error = modelInitializationStatus?.error ?: \"\",\n          onDismiss = { showErrorDialog = false },\n        )\n      }\n    }\n  }\n}\n\n// @Preview(showBackground = true)\n// @Composable\n// fun LlmSingleTurnScreenPreview() {\n//   val context = LocalContext.current\n//   GalleryTheme {\n//     LlmSingleTurnScreen(\n//       modelManagerViewModel = PreviewModelManagerViewModel(context = context),\n//       viewModel = PreviewLlmSingleTurnViewModel(),\n//       navigateUp = {},\n//     )\n//   }\n// }\n"
  },
  {
    "path": "Android/src/app/src/main/java/com/google/ai/edge/gallery/ui/llmsingleturn/LlmSingleTurnTaskModule.kt",
    "content": "/*\n * Copyright 2025 Google LLC\n *\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n *     http://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\n\npackage com.google.ai.edge.gallery.ui.llmsingleturn\n\nimport android.content.Context\nimport androidx.compose.material.icons.Icons\nimport androidx.compose.material.icons.outlined.Widgets\nimport androidx.compose.runtime.Composable\nimport com.google.ai.edge.gallery.R\nimport com.google.ai.edge.gallery.customtasks.common.CustomTask\nimport com.google.ai.edge.gallery.customtasks.common.CustomTaskDataForBuiltinTask\nimport com.google.ai.edge.gallery.data.BuiltInTaskId\nimport com.google.ai.edge.gallery.data.Category\nimport com.google.ai.edge.gallery.data.Model\nimport com.google.ai.edge.gallery.data.Task\nimport com.google.ai.edge.gallery.ui.llmchat.LlmChatModelHelper\nimport dagger.Module\nimport dagger.Provides\nimport dagger.hilt.InstallIn\nimport dagger.hilt.components.SingletonComponent\nimport dagger.multibindings.IntoSet\nimport javax.inject.Inject\nimport kotlinx.coroutines.CoroutineScope\n\nclass LlmSingleTurnTask @Inject constructor() : CustomTask {\n  override val task: Task =\n    Task(\n      id = BuiltInTaskId.LLM_PROMPT_LAB,\n      label = \"Prompt Lab\",\n      category = Category.LLM,\n      icon = Icons.Outlined.Widgets,\n      models = mutableListOf(),\n      description = \"Single turn use cases with on-device large language models\",\n      docUrl = \"https://github.com/google-ai-edge/LiteRT-LM/blob/main/kotlin/README.md\",\n      sourceCodeUrl =\n        \"https://github.com/google-ai-edge/gallery/blob/main/Android/src/app/src/main/java/com/google/ai/edge/gallery/ui/llmchat/LlmChatModelHelper.kt\",\n      textInputPlaceHolderRes = R.string.text_input_placeholder_llm_chat,\n    )\n\n  override fun initializeModelFn(\n    context: Context,\n    coroutineScope: CoroutineScope,\n    model: Model,\n    onDone: (String) -> Unit,\n  ) {\n    LlmChatModelHelper.initialize(\n      context = context,\n      model = model,\n      supportImage = false,\n      supportAudio = false,\n      onDone = onDone,\n    )\n  }\n\n  override fun cleanUpModelFn(\n    context: Context,\n    coroutineScope: CoroutineScope,\n    model: Model,\n    onDone: () -> Unit,\n  ) {\n    LlmChatModelHelper.cleanUp(model = model, onDone = onDone)\n  }\n\n  @Composable\n  override fun MainScreen(data: Any) {\n    val myData = data as CustomTaskDataForBuiltinTask\n    LlmSingleTurnScreen(\n      modelManagerViewModel = myData.modelManagerViewModel,\n      navigateUp = myData.onNavUp,\n    )\n  }\n}\n\n@Module\n@InstallIn(SingletonComponent::class) // Or another component that fits your scope\ninternal object LlmSingleTurnTaskModule {\n  @Provides\n  @IntoSet\n  fun provideTask(): CustomTask {\n    return LlmSingleTurnTask()\n  }\n}\n"
  },
  {
    "path": "Android/src/app/src/main/java/com/google/ai/edge/gallery/ui/llmsingleturn/LlmSingleTurnViewModel.kt",
    "content": "/*\n * Copyright 2025 Google LLC\n *\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n *     http://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\n\npackage com.google.ai.edge.gallery.ui.llmsingleturn\n\nimport android.util.Log\nimport androidx.lifecycle.ViewModel\nimport androidx.lifecycle.viewModelScope\nimport com.google.ai.edge.gallery.common.processLlmResponse\nimport com.google.ai.edge.gallery.data.Model\nimport com.google.ai.edge.gallery.data.Task\nimport com.google.ai.edge.gallery.runtime.runtimeHelper\nimport dagger.hilt.android.lifecycle.HiltViewModel\nimport javax.inject.Inject\nimport kotlinx.coroutines.Dispatchers\nimport kotlinx.coroutines.delay\nimport kotlinx.coroutines.flow.MutableStateFlow\nimport kotlinx.coroutines.flow.asStateFlow\nimport kotlinx.coroutines.flow.update\nimport kotlinx.coroutines.launch\n\nprivate const val TAG = \"AGLlmSingleTurnVM\"\n\ndata class LlmSingleTurnUiState(\n  /** Indicates whether the runtime is currently processing a message. */\n  val inProgress: Boolean = false,\n\n  /**\n   * Indicates whether the model is preparing (before outputting any result and after initializing).\n   */\n  val preparing: Boolean = false,\n\n  // model -> <template label -> response>\n  val responsesByModel: Map<String, Map<String, String>>,\n\n  /** Selected prompt template type. */\n  val selectedPromptTemplateType: PromptTemplateType = PromptTemplateType.entries[0],\n)\n\n@HiltViewModel\nclass LlmSingleTurnViewModel @Inject constructor() : ViewModel() {\n  private val _uiState = MutableStateFlow(createUiState())\n  val uiState = _uiState.asStateFlow()\n\n  fun generateResponse(task: Task, model: Model, input: String) {\n    viewModelScope.launch(Dispatchers.Default) {\n      setInProgress(true)\n      setPreparing(true)\n\n      // Wait for instance to be initialized.\n      while (model.instance == null) {\n        delay(100)\n      }\n\n      val supportImage =\n        model.llmSupportImage &&\n          task.id == com.google.ai.edge.gallery.data.BuiltInTaskId.LLM_ASK_IMAGE\n      val supportAudio =\n        model.llmSupportAudio &&\n          task.id == com.google.ai.edge.gallery.data.BuiltInTaskId.LLM_ASK_AUDIO\n      model.runtimeHelper.resetConversation(\n        model = model,\n        supportImage = supportImage,\n        supportAudio = supportAudio,\n      )\n      delay(500)\n\n      // Run inference.\n      var firstRun = true\n      var response = \"\"\n      model.runtimeHelper.runInference(\n        model = model,\n        input = input,\n        resultListener = { partialResult: String, done: Boolean ->\n          if (firstRun) {\n            setPreparing(false)\n            firstRun = false\n          }\n\n          // Incrementally update the streamed partial results.\n          response = processLlmResponse(response = \"$response$partialResult\")\n\n          // Update response.\n          updateResponse(\n            model = model,\n            promptTemplateType = uiState.value.selectedPromptTemplateType,\n            response = response,\n          )\n\n          if (done) {\n            setInProgress(false)\n          }\n        },\n        cleanUpListener = {\n          setPreparing(false)\n          setInProgress(false)\n        },\n        onError = { _: String ->\n          setPreparing(false)\n          setInProgress(false)\n        },\n        coroutineScope = viewModelScope,\n      )\n    }\n  }\n\n  fun selectPromptTemplate(model: Model, promptTemplateType: PromptTemplateType) {\n    Log.d(TAG, \"selecting prompt template: ${promptTemplateType.label}\")\n\n    // Clear response.\n    updateResponse(model = model, promptTemplateType = promptTemplateType, response = \"\")\n\n    this._uiState.update {\n      this.uiState.value.copy(selectedPromptTemplateType = promptTemplateType)\n    }\n  }\n\n  fun setInProgress(inProgress: Boolean) {\n    _uiState.update { _uiState.value.copy(inProgress = inProgress) }\n  }\n\n  fun setPreparing(preparing: Boolean) {\n    _uiState.update { _uiState.value.copy(preparing = preparing) }\n  }\n\n  fun updateResponse(model: Model, promptTemplateType: PromptTemplateType, response: String) {\n    _uiState.update { currentState ->\n      val currentResponses = currentState.responsesByModel\n      val modelResponses = currentResponses[model.name]?.toMutableMap() ?: mutableMapOf()\n      modelResponses[promptTemplateType.label] = response\n      val newResponses = currentResponses.toMutableMap()\n      newResponses[model.name] = modelResponses\n      currentState.copy(responsesByModel = newResponses)\n    }\n  }\n\n  fun stopResponse(model: Model) {\n    Log.d(TAG, \"Stopping response for model ${model.name}...\")\n    viewModelScope.launch(Dispatchers.Default) {\n      setInProgress(false)\n      model.runtimeHelper.stopResponse(model)\n    }\n  }\n\n  private fun createUiState(): LlmSingleTurnUiState {\n    val responsesByModel: MutableMap<String, Map<String, String>> = mutableMapOf()\n    return LlmSingleTurnUiState(responsesByModel = responsesByModel)\n  }\n}\n"
  },
  {
    "path": "Android/src/app/src/main/java/com/google/ai/edge/gallery/ui/llmsingleturn/PromptTemplateConfigs.kt",
    "content": "/*\n * Copyright 2025 Google LLC\n *\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n *     http://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\n\npackage com.google.ai.edge.gallery.ui.llmsingleturn\n\nimport androidx.compose.ui.graphics.Brush.Companion.linearGradient\nimport androidx.compose.ui.graphics.Color\nimport androidx.compose.ui.text.AnnotatedString\nimport androidx.compose.ui.text.SpanStyle\nimport androidx.compose.ui.text.buildAnnotatedString\nimport androidx.compose.ui.text.withStyle\n\nenum class PromptTemplateInputEditorType {\n  SINGLE_SELECT\n}\n\nenum class RewriteToneType(val label: String) {\n  FORMAL(label = \"Formal\"),\n  CASUAL(label = \"Casual\"),\n  FRIENDLY(label = \"Friendly\"),\n  POLITE(label = \"Polite\"),\n  ENTHUSIASTIC(label = \"Enthusiastic\"),\n  CONCISE(label = \"Concise\"),\n}\n\nenum class SummarizationType(val label: String) {\n  KEY_BULLET_POINT(label = \"Key bullet points (3-5)\"),\n  SHORT_PARAGRAPH(label = \"Short paragraph (1-2 sentences)\"),\n  CONCISE_SUMMARY(label = \"Concise summary (~50 words)\"),\n  HEADLINE_TITLE(label = \"Headline / title\"),\n  ONE_SENTENCE_SUMMARY(label = \"One-sentence summary\"),\n}\n\nenum class LanguageType(val label: String) {\n  CPP(label = \"C++\"),\n  JAVA(label = \"Java\"),\n  JAVASCRIPT(label = \"JavaScript\"),\n  KOTLIN(label = \"Kotlin\"),\n  PYTHON(label = \"Python\"),\n  SWIFT(label = \"Swift\"),\n  TYPESCRIPT(label = \"TypeScript\"),\n}\n\nenum class InputEditorLabel(val label: String) {\n  TONE(label = \"Tone\"),\n  STYLE(label = \"Style\"),\n  LANGUAGE(label = \"Language\"),\n}\n\nopen class PromptTemplateInputEditor(\n  open val label: String,\n  open val type: PromptTemplateInputEditorType,\n  open val defaultOption: String = \"\",\n)\n\n/** Single select that shows options in bottom sheet. */\nclass PromptTemplateSingleSelectInputEditor(\n  override val label: String,\n  val options: List<String> = listOf(),\n  override val defaultOption: String = \"\",\n) :\n  PromptTemplateInputEditor(\n    label = label,\n    type = PromptTemplateInputEditorType.SINGLE_SELECT,\n    defaultOption = defaultOption,\n  )\n\ndata class PromptTemplateConfig(val inputEditors: List<PromptTemplateInputEditor> = listOf())\n\nprivate val GEMINI_GRADIENT_STYLE =\n  SpanStyle(\n    brush = linearGradient(colors = listOf(Color(0xFF4285f4), Color(0xFF9b72cb), Color(0xFFd96570)))\n  )\n\n@Suppress(\"ImmutableEnum\")\nenum class PromptTemplateType(\n  val label: String,\n  val config: PromptTemplateConfig,\n  val genFullPrompt: (userInput: String, inputEditorValues: Map<String, Any>) -> AnnotatedString =\n    { _, _ ->\n      AnnotatedString(\"\")\n    },\n  val examplePrompts: List<String> = listOf(),\n) {\n  FREE_FORM(\n    label = \"Free form\",\n    config = PromptTemplateConfig(),\n    genFullPrompt = { userInput, _ -> AnnotatedString(userInput) },\n    examplePrompts =\n      listOf(\n        \"Suggest 3 topics for a podcast about \\\"Friendships in your 20s\\\".\",\n        \"Outline the key sections needed in a basic logo design brief.\",\n        \"List 3 pros and 3 cons to consider before buying a smart watch.\",\n        \"Write a short, optimistic quote about the future of technology.\",\n        \"Generate 3 potential names for a mobile app that helps users identify plants.\",\n        \"Explain the difference between AI and machine learning in 2 sentences.\",\n        \"Create a simple haiku about a cat sleeping in the sun.\",\n        \"List 3 ways to make instant noodles taste better using common kitchen ingredients.\",\n      ),\n  ),\n  REWRITE_TONE(\n    label = \"Rewrite tone\",\n    config =\n      PromptTemplateConfig(\n        inputEditors =\n          listOf(\n            PromptTemplateSingleSelectInputEditor(\n              label = InputEditorLabel.TONE.label,\n              options = RewriteToneType.entries.map { it.label },\n              defaultOption = RewriteToneType.FORMAL.label,\n            )\n          )\n      ),\n    genFullPrompt = { userInput, inputEditorValues ->\n      val tone = inputEditorValues[InputEditorLabel.TONE.label] as String\n      buildAnnotatedString {\n        withStyle(GEMINI_GRADIENT_STYLE) {\n          append(\"Rewrite the following text using a ${tone.lowercase()} tone: \")\n        }\n        append(userInput)\n      }\n    },\n    examplePrompts =\n      listOf(\n        \"Hey team, just wanted to remind everyone about the meeting tomorrow @ 10. Be there!\",\n        \"Our new software update includes several bug fixes and performance improvements.\",\n        \"Due to the fact that the weather was bad, we decided to postpone the event.\",\n        \"Please find attached the requested documentation for your perusal.\",\n        \"Welcome to the team. Review the onboarding materials.\",\n      ),\n  ),\n  SUMMARIZE_TEXT(\n    label = \"Summarize text\",\n    config =\n      PromptTemplateConfig(\n        inputEditors =\n          listOf(\n            PromptTemplateSingleSelectInputEditor(\n              label = InputEditorLabel.STYLE.label,\n              options = SummarizationType.entries.map { it.label },\n              defaultOption = SummarizationType.KEY_BULLET_POINT.label,\n            )\n          )\n      ),\n    genFullPrompt = { userInput, inputEditorValues ->\n      val style = inputEditorValues[InputEditorLabel.STYLE.label] as String\n      buildAnnotatedString {\n        withStyle(GEMINI_GRADIENT_STYLE) {\n          append(\"Please summarize the following in ${style.lowercase()}: \")\n        }\n        append(userInput)\n      }\n    },\n    examplePrompts =\n      listOf(\n        \"The new Pixel phone features an advanced camera system with improved low-light performance and AI-powered editing tools. The display is brighter and more energy-efficient. It runs on the latest Tensor chip, offering faster processing and enhanced security features. Battery life has also been extended, providing all-day power for most users.\",\n        \"Beginning this Friday, January 24, giant pandas Bao Li and Qing Bao are officially on view to the public at the Smithsonian’s National Zoo and Conservation Biology Institute (NZCBI). The 3-year-old bears arrived in Washington this past October, undergoing a quarantine period before making their debut. Under NZCBI’s new agreement with the CWCA, Qing Bao and Bao Li will remain in the United States for ten years, until April 2034, in exchange for an annual fee of \\$1 million. The pair are still too young to breed, as pandas only reach sexual maturity between ages 4 and 7. “Kind of picture them as like awkward teenagers right now,” Lally told WUSA9. “We still have about two years before we would probably even see signs that they’re ready to start mating.”\",\n      ),\n  ),\n  CODE_SNIPPET(\n    label = \"Code snippet\",\n    config =\n      PromptTemplateConfig(\n        inputEditors =\n          listOf(\n            PromptTemplateSingleSelectInputEditor(\n              label = InputEditorLabel.LANGUAGE.label,\n              options = LanguageType.entries.map { it.label },\n              defaultOption = LanguageType.JAVASCRIPT.label,\n            )\n          )\n      ),\n    genFullPrompt = { userInput, inputEditorValues ->\n      val language = inputEditorValues[InputEditorLabel.LANGUAGE.label] as String\n      buildAnnotatedString {\n        withStyle(GEMINI_GRADIENT_STYLE) { append(\"Write a $language code snippet to \") }\n        append(userInput)\n      }\n    },\n    examplePrompts =\n      listOf(\n        \"Create an alert box that says \\\"Hello, World!\\\"\",\n        \"Declare an immutable variable named 'appName' with the value \\\"AI Gallery\\\"\",\n        \"Print the numbers from 1 to 5 using a for loop.\",\n        \"Write a function that returns the square of an integer input.\",\n      ),\n  ),\n}\n"
  },
  {
    "path": "Android/src/app/src/main/java/com/google/ai/edge/gallery/ui/llmsingleturn/PromptTemplatesPanel.kt",
    "content": "/*\n * Copyright 2025 Google LLC\n *\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n *     http://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\n\npackage com.google.ai.edge.gallery.ui.llmsingleturn\n\nimport android.content.ClipData\nimport androidx.compose.foundation.BorderStroke\nimport androidx.compose.foundation.background\nimport androidx.compose.foundation.border\nimport androidx.compose.foundation.clickable\nimport androidx.compose.foundation.interaction.MutableInteractionSource\nimport androidx.compose.foundation.layout.Arrangement\nimport androidx.compose.foundation.layout.Box\nimport androidx.compose.foundation.layout.Column\nimport androidx.compose.foundation.layout.Row\nimport androidx.compose.foundation.layout.Spacer\nimport androidx.compose.foundation.layout.fillMaxSize\nimport androidx.compose.foundation.layout.fillMaxWidth\nimport androidx.compose.foundation.layout.height\nimport androidx.compose.foundation.layout.offset\nimport androidx.compose.foundation.layout.padding\nimport androidx.compose.foundation.layout.size\nimport androidx.compose.foundation.layout.wrapContentHeight\nimport androidx.compose.foundation.rememberScrollState\nimport androidx.compose.foundation.shape.CircleShape\nimport androidx.compose.foundation.verticalScroll\nimport androidx.compose.material.icons.Icons\nimport androidx.compose.material.icons.automirrored.rounded.Send\nimport androidx.compose.material.icons.outlined.ContentCopy\nimport androidx.compose.material.icons.outlined.Description\nimport androidx.compose.material.icons.outlined.ExpandLess\nimport androidx.compose.material.icons.outlined.ExpandMore\nimport androidx.compose.material.icons.rounded.Add\nimport androidx.compose.material.icons.rounded.Stop\nimport androidx.compose.material.icons.rounded.Visibility\nimport androidx.compose.material.icons.rounded.VisibilityOff\nimport androidx.compose.material3.ExperimentalMaterial3Api\nimport androidx.compose.material3.FilterChipDefaults\nimport androidx.compose.material3.Icon\nimport androidx.compose.material3.IconButton\nimport androidx.compose.material3.IconButtonDefaults\nimport androidx.compose.material3.MaterialTheme\nimport androidx.compose.material3.ModalBottomSheet\nimport androidx.compose.material3.OutlinedIconButton\nimport androidx.compose.material3.PrimaryScrollableTabRow\nimport androidx.compose.material3.Tab\nimport androidx.compose.material3.Text\nimport androidx.compose.material3.TextField\nimport androidx.compose.material3.TextFieldDefaults\nimport androidx.compose.material3.rememberModalBottomSheetState\nimport androidx.compose.runtime.Composable\nimport androidx.compose.runtime.LaunchedEffect\nimport androidx.compose.runtime.collectAsState\nimport androidx.compose.runtime.derivedStateOf\nimport androidx.compose.runtime.getValue\nimport androidx.compose.runtime.mutableIntStateOf\nimport androidx.compose.runtime.mutableStateMapOf\nimport androidx.compose.runtime.mutableStateOf\nimport androidx.compose.runtime.remember\nimport androidx.compose.runtime.rememberCoroutineScope\nimport androidx.compose.runtime.setValue\nimport androidx.compose.runtime.snapshots.SnapshotStateMap\nimport androidx.compose.ui.Alignment\nimport androidx.compose.ui.Modifier\nimport androidx.compose.ui.draw.alpha\nimport androidx.compose.ui.draw.clip\nimport androidx.compose.ui.focus.FocusRequester\nimport androidx.compose.ui.focus.focusRequester\nimport androidx.compose.ui.graphics.Color\nimport androidx.compose.ui.platform.ClipEntry\nimport androidx.compose.ui.platform.LocalClipboard\nimport androidx.compose.ui.platform.LocalFocusManager\nimport androidx.compose.ui.res.dimensionResource\nimport androidx.compose.ui.res.stringResource\nimport androidx.compose.ui.semantics.contentDescription\nimport androidx.compose.ui.semantics.semantics\nimport androidx.compose.ui.text.TextLayoutResult\nimport androidx.compose.ui.text.style.TextOverflow\nimport androidx.compose.ui.unit.dp\nimport com.google.ai.edge.gallery.R\nimport com.google.ai.edge.gallery.data.Model\nimport com.google.ai.edge.gallery.ui.common.chat.MessageBubbleShape\nimport com.google.ai.edge.gallery.ui.modelmanager.ModelInitializationStatusType\nimport com.google.ai.edge.gallery.ui.modelmanager.ModelManagerViewModel\nimport com.google.ai.edge.gallery.ui.theme.bodyLargeNarrow\nimport com.google.ai.edge.gallery.ui.theme.customColors\nimport kotlinx.coroutines.delay\nimport kotlinx.coroutines.launch\n\nprivate val promptTemplateTypes: List<PromptTemplateType> = PromptTemplateType.entries\nprivate val TAB_TITLES = PromptTemplateType.entries.map { it.label }\nprivate val ICON_BUTTON_SIZE = 42.dp\n\nconst val FULL_PROMPT_SWITCH_KEY = \"full_prompt\"\n\n@OptIn(ExperimentalMaterial3Api::class)\n@Composable\nfun PromptTemplatesPanel(\n  model: Model,\n  viewModel: LlmSingleTurnViewModel,\n  modelManagerViewModel: ModelManagerViewModel,\n  onSend: (fullPrompt: String) -> Unit,\n  onStopButtonClicked: (Model) -> Unit,\n  modifier: Modifier = Modifier,\n) {\n  val scope = rememberCoroutineScope()\n  val uiState by viewModel.uiState.collectAsState()\n  val modelManagerUiState by modelManagerViewModel.uiState.collectAsState()\n  val selectedPromptTemplateType = uiState.selectedPromptTemplateType\n  val inProgress = uiState.inProgress\n  var selectedTabIndex by remember { mutableIntStateOf(0) }\n  var curTextInputContent by remember { mutableStateOf(\"\") }\n  val inputEditorValues: SnapshotStateMap<String, Any> = remember {\n    mutableStateMapOf(FULL_PROMPT_SWITCH_KEY to false)\n  }\n  val fullPrompt by remember {\n    derivedStateOf {\n      uiState.selectedPromptTemplateType.genFullPrompt(curTextInputContent, inputEditorValues)\n    }\n  }\n  val clipboard = LocalClipboard.current\n  val focusRequester = remember { FocusRequester() }\n  val focusManager = LocalFocusManager.current\n  val interactionSource = remember { MutableInteractionSource() }\n  val expandedStates = remember { mutableStateMapOf<String, Boolean>() }\n  val modelInitializationStatus = modelManagerUiState.modelInitializationStatus[model.name]\n\n  // Update input editor values when prompt template changes.\n  LaunchedEffect(selectedPromptTemplateType) {\n    for (config in selectedPromptTemplateType.config.inputEditors) {\n      inputEditorValues[config.label] = config.defaultOption\n    }\n    expandedStates.clear()\n  }\n\n  var showExamplePromptBottomSheet by remember { mutableStateOf(false) }\n  val sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true)\n  val bubbleBorderRadius = dimensionResource(R.dimen.chat_bubble_corner_radius)\n\n  Column(modifier = modifier) {\n    // Scrollable tab row for all prompt templates.\n    PrimaryScrollableTabRow(selectedTabIndex = selectedTabIndex) {\n      TAB_TITLES.forEachIndexed { index, title ->\n        Tab(\n          selected = selectedTabIndex == index,\n          enabled = !inProgress,\n          onClick = {\n            // Clear input when tab changes.\n            curTextInputContent = \"\"\n            // Reset full prompt switch.\n            inputEditorValues[FULL_PROMPT_SWITCH_KEY] = false\n\n            selectedTabIndex = index\n            viewModel.selectPromptTemplate(\n              model = model,\n              promptTemplateType = promptTemplateTypes[index],\n            )\n          },\n          text = {\n            Text(\n              text = title,\n              modifier = Modifier.alpha(if (inProgress) 0.5f else 1f),\n              color =\n                if (selectedTabIndex == index) MaterialTheme.colorScheme.primary\n                else MaterialTheme.colorScheme.onSurfaceVariant,\n            )\n          },\n        )\n      }\n    }\n\n    // Content.\n    Column(modifier = Modifier.weight(1f).fillMaxWidth()) {\n      // Input editor row.\n      if (selectedPromptTemplateType.config.inputEditors.isNotEmpty()) {\n        Row(\n          verticalAlignment = Alignment.CenterVertically,\n          horizontalArrangement = Arrangement.spacedBy(8.dp),\n          modifier =\n            Modifier.fillMaxWidth()\n              .background(MaterialTheme.colorScheme.surfaceContainerLow)\n              .padding(horizontal = 16.dp, vertical = 10.dp),\n        ) {\n          // Input editors.\n          for (inputEditor in selectedPromptTemplateType.config.inputEditors) {\n            when (inputEditor.type) {\n              PromptTemplateInputEditorType.SINGLE_SELECT ->\n                SingleSelectButton(\n                  config = inputEditor as PromptTemplateSingleSelectInputEditor,\n                  onSelected = { option -> inputEditorValues[inputEditor.label] = option },\n                )\n            }\n          }\n        }\n      }\n\n      // Text input box.\n      Box(contentAlignment = Alignment.BottomCenter, modifier = Modifier.weight(1f)) {\n        Column(\n          modifier =\n            Modifier.fillMaxSize().verticalScroll(rememberScrollState()).clickable(\n              interactionSource = interactionSource,\n              indication = null, // Disable the ripple effect\n            ) {\n              // Request focus on the TextField when the Column is clicked\n              focusRequester.requestFocus()\n            }\n        ) {\n          if (inputEditorValues[FULL_PROMPT_SWITCH_KEY] as Boolean) {\n            Text(\n              fullPrompt,\n              style = MaterialTheme.typography.bodyMedium,\n              modifier =\n                Modifier.fillMaxWidth()\n                  .padding(16.dp)\n                  .padding(bottom = 40.dp)\n                  .clip(MessageBubbleShape(radius = bubbleBorderRadius))\n                  .background(MaterialTheme.customColors.agentBubbleBgColor)\n                  .padding(16.dp)\n                  .focusRequester(focusRequester),\n            )\n          } else {\n            val cdContentInput = stringResource(R.string.cd_content_input_field)\n            TextField(\n              value = curTextInputContent,\n              onValueChange = { curTextInputContent = it },\n              colors =\n                TextFieldDefaults.colors(\n                  unfocusedContainerColor = Color.Transparent,\n                  focusedContainerColor = Color.Transparent,\n                  focusedIndicatorColor = Color.Transparent,\n                  unfocusedIndicatorColor = Color.Transparent,\n                  disabledIndicatorColor = Color.Transparent,\n                  disabledContainerColor = Color.Transparent,\n                ),\n              textStyle = bodyLargeNarrow,\n              placeholder = { Text(\"Enter content\") },\n              modifier =\n                Modifier.padding(bottom = 40.dp).focusRequester(focusRequester).semantics {\n                  contentDescription = cdContentInput\n                },\n            )\n          }\n        }\n\n        // Text action row.\n        Row(\n          verticalAlignment = Alignment.CenterVertically,\n          horizontalArrangement = Arrangement.spacedBy(4.dp),\n          modifier = Modifier.fillMaxWidth().padding(vertical = 4.dp, horizontal = 16.dp),\n        ) {\n          // Full prompt switch.\n          if (\n            selectedPromptTemplateType != PromptTemplateType.FREE_FORM &&\n              curTextInputContent.isNotEmpty()\n          ) {\n            Row(\n              verticalAlignment = Alignment.CenterVertically,\n              horizontalArrangement = Arrangement.spacedBy(4.dp),\n              modifier =\n                Modifier.clip(CircleShape)\n                  .background(\n                    if (inputEditorValues[FULL_PROMPT_SWITCH_KEY] as Boolean)\n                      MaterialTheme.colorScheme.secondaryContainer\n                    else MaterialTheme.customColors.agentBubbleBgColor\n                  )\n                  .clickable {\n                    inputEditorValues[FULL_PROMPT_SWITCH_KEY] =\n                      !(inputEditorValues[FULL_PROMPT_SWITCH_KEY] as Boolean)\n                  }\n                  .height(40.dp)\n                  .border(\n                    width = 1.dp,\n                    color = MaterialTheme.colorScheme.surface,\n                    shape = CircleShape,\n                  )\n                  .padding(horizontal = 12.dp),\n            ) {\n              if (inputEditorValues[FULL_PROMPT_SWITCH_KEY] as Boolean) {\n                Icon(\n                  imageVector = Icons.Rounded.Visibility,\n                  contentDescription = null,\n                  modifier = Modifier.size(FilterChipDefaults.IconSize),\n                )\n              } else {\n                Icon(\n                  imageVector = Icons.Rounded.VisibilityOff,\n                  contentDescription = null,\n                  modifier = Modifier.size(FilterChipDefaults.IconSize).alpha(0.3f),\n                )\n              }\n              Text(\"Preview prompt\", style = MaterialTheme.typography.labelMedium)\n            }\n          }\n\n          Spacer(modifier = Modifier.weight(1f))\n\n          // Button to copy full prompt.\n          if (curTextInputContent.isNotEmpty()) {\n            OutlinedIconButton(\n              onClick = {\n                scope.launch {\n                  val clipData = ClipData.newPlainText(\"prompt\", fullPrompt)\n                  val clipEntry = ClipEntry(clipData = clipData)\n                  clipboard.setClipEntry(clipEntry = clipEntry)\n                }\n              },\n              colors =\n                IconButtonDefaults.iconButtonColors(\n                  containerColor = MaterialTheme.customColors.agentBubbleBgColor,\n                  disabledContainerColor =\n                    MaterialTheme.customColors.agentBubbleBgColor.copy(alpha = 0.4f),\n                  contentColor = MaterialTheme.colorScheme.onSurface,\n                  disabledContentColor = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.2f),\n                ),\n              border = BorderStroke(width = 1.dp, color = MaterialTheme.colorScheme.surface),\n              modifier = Modifier.size(ICON_BUTTON_SIZE),\n            ) {\n              Icon(\n                Icons.Outlined.ContentCopy,\n                contentDescription = stringResource(R.string.cd_copy_to_clipboard_icon),\n                modifier = Modifier.size(20.dp),\n              )\n            }\n          }\n\n          // Add example prompt button.\n          OutlinedIconButton(\n            enabled = !inProgress,\n            onClick = { showExamplePromptBottomSheet = true },\n            colors =\n              IconButtonDefaults.iconButtonColors(\n                containerColor = MaterialTheme.customColors.agentBubbleBgColor,\n                disabledContainerColor =\n                  MaterialTheme.customColors.agentBubbleBgColor.copy(alpha = 0.4f),\n                contentColor = MaterialTheme.colorScheme.onSurface,\n                disabledContentColor = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.2f),\n              ),\n            border = BorderStroke(width = 1.dp, color = MaterialTheme.colorScheme.surface),\n            modifier = Modifier.size(ICON_BUTTON_SIZE),\n          ) {\n            Icon(\n              Icons.Rounded.Add,\n              contentDescription = stringResource(R.string.cd_add_example_prompt_icon),\n              modifier = Modifier.size(20.dp),\n            )\n          }\n\n          val modelInitializing =\n            modelInitializationStatus?.status == ModelInitializationStatusType.INITIALIZING\n          if (inProgress && !modelInitializing && !uiState.preparing) {\n            IconButton(\n              onClick = { onStopButtonClicked(model) },\n              colors =\n                IconButtonDefaults.iconButtonColors(\n                  containerColor = MaterialTheme.colorScheme.secondaryContainer\n                ),\n              modifier = Modifier.size(ICON_BUTTON_SIZE),\n            ) {\n              Icon(\n                Icons.Rounded.Stop,\n                contentDescription = stringResource(R.string.cd_stop_icon),\n                tint = MaterialTheme.colorScheme.primary,\n              )\n            }\n          } else {\n            // Send button\n            OutlinedIconButton(\n              enabled = !inProgress && curTextInputContent.isNotEmpty(),\n              onClick = {\n                focusManager.clearFocus()\n                onSend(fullPrompt.text)\n              },\n              colors =\n                IconButtonDefaults.iconButtonColors(\n                  containerColor = MaterialTheme.colorScheme.secondaryContainer,\n                  disabledContainerColor =\n                    MaterialTheme.colorScheme.secondaryContainer.copy(alpha = 0.3f),\n                  contentColor = MaterialTheme.colorScheme.onSurface,\n                  disabledContentColor = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.1f),\n                ),\n              border = BorderStroke(width = 1.dp, color = MaterialTheme.colorScheme.surface),\n              modifier = Modifier.size(ICON_BUTTON_SIZE),\n            ) {\n              Icon(\n                Icons.AutoMirrored.Rounded.Send,\n                contentDescription = stringResource(R.string.cd_send_prompt_icon),\n                modifier = Modifier.size(20.dp).offset(x = 2.dp),\n              )\n            }\n          }\n        }\n      }\n    }\n  }\n\n  if (showExamplePromptBottomSheet) {\n    ModalBottomSheet(\n      onDismissRequest = { showExamplePromptBottomSheet = false },\n      sheetState = sheetState,\n      modifier = Modifier.wrapContentHeight(),\n    ) {\n      Column(modifier = Modifier.padding(bottom = 16.dp)) {\n        // Title\n        Text(\n          \"Select an example\",\n          modifier = Modifier.fillMaxWidth().padding(16.dp),\n          style = MaterialTheme.typography.titleLarge,\n        )\n\n        // Examples\n        for (prompt in selectedPromptTemplateType.examplePrompts) {\n          var textLayoutResultState by remember { mutableStateOf<TextLayoutResult?>(null) }\n          val hasOverflow =\n            remember(textLayoutResultState) { textLayoutResultState?.hasVisualOverflow ?: false }\n          val isExpanded = expandedStates[prompt] ?: false\n\n          Column(\n            modifier =\n              Modifier.fillMaxWidth()\n                .clickable {\n                  curTextInputContent = prompt\n                  scope.launch {\n                    // Give it sometime to show the click effect.\n                    delay(200)\n                    showExamplePromptBottomSheet = false\n                  }\n                }\n                .padding(horizontal = 16.dp, vertical = 8.dp)\n          ) {\n            Row(\n              verticalAlignment = Alignment.CenterVertically,\n              horizontalArrangement = Arrangement.spacedBy(8.dp),\n            ) {\n              Icon(Icons.Outlined.Description, contentDescription = null)\n              Text(\n                prompt,\n                maxLines = if (isExpanded) Int.MAX_VALUE else 3,\n                overflow = TextOverflow.Ellipsis,\n                style = MaterialTheme.typography.bodySmall,\n                modifier = Modifier.weight(1f),\n                onTextLayout = { textLayoutResultState = it },\n              )\n            }\n\n            if (hasOverflow && !isExpanded) {\n              Row(\n                modifier = Modifier.fillMaxWidth().padding(top = 2.dp),\n                horizontalArrangement = Arrangement.End,\n              ) {\n                Box(\n                  modifier =\n                    Modifier.padding(end = 16.dp)\n                      .clip(CircleShape)\n                      .background(MaterialTheme.colorScheme.surfaceContainerHighest)\n                      .clickable { expandedStates[prompt] = true }\n                      .padding(vertical = 1.dp, horizontal = 6.dp)\n                ) {\n                  Icon(\n                    Icons.Outlined.ExpandMore,\n                    contentDescription = stringResource(R.string.cd_expand_icon),\n                    modifier = Modifier.size(12.dp),\n                  )\n                }\n              }\n            } else if (isExpanded) {\n              Row(\n                modifier = Modifier.fillMaxWidth().padding(top = 2.dp),\n                horizontalArrangement = Arrangement.End,\n              ) {\n                Box(\n                  modifier =\n                    Modifier.padding(end = 16.dp)\n                      .clip(CircleShape)\n                      .background(MaterialTheme.colorScheme.surfaceContainerHighest)\n                      .clickable { expandedStates[prompt] = false }\n                      .padding(vertical = 1.dp, horizontal = 6.dp)\n                ) {\n                  Icon(\n                    Icons.Outlined.ExpandLess,\n                    contentDescription = stringResource(R.string.cd_collapse_icon),\n                    modifier = Modifier.size(12.dp),\n                  )\n                }\n              }\n            }\n          }\n        }\n      }\n    }\n  }\n}\n"
  },
  {
    "path": "Android/src/app/src/main/java/com/google/ai/edge/gallery/ui/llmsingleturn/ResponsePanel.kt",
    "content": "/*\n * Copyright 2025 Google LLC\n *\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n *     http://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\n\npackage com.google.ai.edge.gallery.ui.llmsingleturn\n\nimport android.content.ClipData\nimport android.util.Log\nimport androidx.compose.foundation.layout.Arrangement\nimport androidx.compose.foundation.layout.Box\nimport androidx.compose.foundation.layout.Column\nimport androidx.compose.foundation.layout.Row\nimport androidx.compose.foundation.layout.fillMaxSize\nimport androidx.compose.foundation.layout.padding\nimport androidx.compose.foundation.layout.size\nimport androidx.compose.foundation.pager.HorizontalPager\nimport androidx.compose.foundation.pager.rememberPagerState\nimport androidx.compose.foundation.rememberScrollState\nimport androidx.compose.foundation.verticalScroll\nimport androidx.compose.material.icons.Icons\nimport androidx.compose.material.icons.outlined.ContentCopy\nimport androidx.compose.material3.ExperimentalMaterial3Api\nimport androidx.compose.material3.Icon\nimport androidx.compose.material3.IconButton\nimport androidx.compose.material3.IconButtonDefaults\nimport androidx.compose.material3.MaterialTheme\nimport androidx.compose.material3.Text\nimport androidx.compose.runtime.Composable\nimport androidx.compose.runtime.LaunchedEffect\nimport androidx.compose.runtime.collectAsState\nimport androidx.compose.runtime.getValue\nimport androidx.compose.runtime.mutableIntStateOf\nimport androidx.compose.runtime.remember\nimport androidx.compose.runtime.rememberCoroutineScope\nimport androidx.compose.runtime.setValue\nimport androidx.compose.ui.Alignment\nimport androidx.compose.ui.Modifier\nimport androidx.compose.ui.draw.alpha\nimport androidx.compose.ui.platform.ClipEntry\nimport androidx.compose.ui.platform.LocalClipboard\nimport androidx.compose.ui.platform.LocalContext\nimport androidx.compose.ui.res.stringResource\nimport androidx.compose.ui.semantics.LiveRegionMode\nimport androidx.compose.ui.semantics.liveRegion\nimport androidx.compose.ui.semantics.semantics\nimport androidx.compose.ui.unit.dp\nimport com.google.ai.edge.gallery.R\nimport com.google.ai.edge.gallery.data.ConfigKeys\nimport com.google.ai.edge.gallery.data.Model\nimport com.google.ai.edge.gallery.data.Task\nimport com.google.ai.edge.gallery.ui.common.MarkdownText\nimport com.google.ai.edge.gallery.ui.common.chat.MessageBodyLoading\nimport com.google.ai.edge.gallery.ui.modelmanager.ModelManagerViewModel\nimport kotlinx.coroutines.launch\n\nprivate const val TAG = \"AGResponsePanel\"\n\n@OptIn(ExperimentalMaterial3Api::class)\n@Composable\nfun ResponsePanel(\n  task: Task,\n  model: Model,\n  viewModel: LlmSingleTurnViewModel,\n  modelManagerViewModel: ModelManagerViewModel,\n  modifier: Modifier = Modifier,\n) {\n  val uiState by viewModel.uiState.collectAsState()\n  val modelManagerUiState by modelManagerViewModel.uiState.collectAsState()\n  val inProgress = uiState.inProgress\n  val initializing = uiState.preparing\n  val selectedPromptTemplateType = uiState.selectedPromptTemplateType\n  val responseScrollState = rememberScrollState()\n  var selectedOptionIndex by remember { mutableIntStateOf(0) }\n  val clipboard = LocalClipboard.current\n  val scope = rememberCoroutineScope()\n  val pagerState =\n    rememberPagerState(initialPage = task.models.indexOf(model), pageCount = { task.models.size })\n  val accelerator = model.getStringConfigValue(key = ConfigKeys.ACCELERATOR, defaultValue = \"\")\n  val context = LocalContext.current\n\n  // Select the \"response\" tab when prompt template changes.\n  LaunchedEffect(selectedPromptTemplateType) { selectedOptionIndex = 0 }\n\n  // Update selected model and clean up previous model when page is settled on a model page.\n  LaunchedEffect(pagerState.settledPage) {\n    val curSelectedModel = task.models[pagerState.settledPage]\n    Log.d(\n      TAG,\n      \"Pager settled on model '${curSelectedModel.name}' from '${model.name}'. Updating selected model.\",\n    )\n    if (curSelectedModel.name != model.name) {\n      modelManagerViewModel.cleanupModel(context = context, task = task, model = model)\n    }\n    modelManagerViewModel.selectModel(curSelectedModel)\n  }\n\n  // Scroll pager when selected model changes.\n  LaunchedEffect(modelManagerUiState.selectedModel) {\n    pagerState.animateScrollToPage(task.models.indexOf(model))\n  }\n\n  HorizontalPager(state = pagerState, userScrollEnabled = false) { pageIndex ->\n    val curPageModel = task.models[pageIndex]\n\n    val response =\n      uiState.responsesByModel[curPageModel.name]?.get(selectedPromptTemplateType.label) ?: \"\"\n\n    // Scroll to bottom when response changes.\n    LaunchedEffect(response) {\n      if (inProgress && responseScrollState.maxValue - responseScrollState.value < 80) {\n        responseScrollState.animateScrollTo(1000000)\n      }\n    }\n\n    if (initializing) {\n      Box(\n        contentAlignment = Alignment.TopStart,\n        modifier = modifier.fillMaxSize().padding(horizontal = 16.dp),\n      ) {\n        MessageBodyLoading()\n      }\n    } else {\n      // Message when response is empty.\n      if (response.isEmpty()) {\n        Row(\n          modifier = Modifier.fillMaxSize(),\n          horizontalArrangement = Arrangement.Center,\n          verticalAlignment = Alignment.CenterVertically,\n        ) {\n          Text(\n            \"Response will appear here\",\n            modifier = Modifier.alpha(0.5f),\n            style = MaterialTheme.typography.labelMedium,\n          )\n        }\n      }\n      // Response markdown.\n      else {\n        Column(modifier = modifier.padding(horizontal = 16.dp).padding(bottom = 4.dp)) {\n          if (selectedOptionIndex == 0) {\n            Box(contentAlignment = Alignment.BottomEnd, modifier = Modifier.weight(1f)) {\n              Column(modifier = Modifier.fillMaxSize().verticalScroll(responseScrollState)) {\n                MarkdownText(\n                  text = response,\n                  modifier =\n                    Modifier.padding(top = 8.dp, bottom = 40.dp).semantics {\n                      // Only announce when message is complete.\n                      if (!inProgress) {\n                        liveRegion = LiveRegionMode.Polite\n                      }\n                    },\n                )\n              }\n              // Copy button.\n              IconButton(\n                onClick = {\n                  scope.launch {\n                    val clipData = ClipData.newPlainText(\"response\", response)\n                    val clipEntry = ClipEntry(clipData = clipData)\n                    clipboard.setClipEntry(clipEntry = clipEntry)\n                  }\n                },\n                colors =\n                  IconButtonDefaults.iconButtonColors(\n                    containerColor = MaterialTheme.colorScheme.surfaceContainerHighest,\n                    contentColor = MaterialTheme.colorScheme.primary,\n                  ),\n              ) {\n                Icon(\n                  Icons.Outlined.ContentCopy,\n                  contentDescription = stringResource(R.string.cd_copy_to_clipboard_icon),\n                  modifier = Modifier.size(20.dp),\n                )\n              }\n            }\n          }\n        }\n      }\n    }\n  }\n}\n"
  },
  {
    "path": "Android/src/app/src/main/java/com/google/ai/edge/gallery/ui/llmsingleturn/SingleSelectButton.kt",
    "content": "/*\n * Copyright 2025 Google LLC\n *\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n *     http://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\n\npackage com.google.ai.edge.gallery.ui.llmsingleturn\n\nimport androidx.compose.foundation.background\nimport androidx.compose.foundation.clickable\nimport androidx.compose.foundation.layout.Arrangement\nimport androidx.compose.foundation.layout.Box\nimport androidx.compose.foundation.layout.Row\nimport androidx.compose.foundation.layout.padding\nimport androidx.compose.foundation.shape.RoundedCornerShape\nimport androidx.compose.material.icons.Icons\nimport androidx.compose.material.icons.rounded.ArrowDropDown\nimport androidx.compose.material3.DropdownMenu\nimport androidx.compose.material3.DropdownMenuItem\nimport androidx.compose.material3.Icon\nimport androidx.compose.material3.MaterialTheme\nimport androidx.compose.material3.Text\nimport androidx.compose.runtime.Composable\nimport androidx.compose.runtime.LaunchedEffect\nimport androidx.compose.runtime.getValue\nimport androidx.compose.runtime.mutableStateOf\nimport androidx.compose.runtime.remember\nimport androidx.compose.runtime.setValue\nimport androidx.compose.ui.Alignment\nimport androidx.compose.ui.Modifier\nimport androidx.compose.ui.draw.clip\nimport androidx.compose.ui.unit.dp\n\n@Composable\nfun SingleSelectButton(\n  config: PromptTemplateSingleSelectInputEditor,\n  onSelected: (String) -> Unit,\n) {\n  var showMenu by remember { mutableStateOf(false) }\n  var selectedOption by remember { mutableStateOf(config.defaultOption) }\n\n  LaunchedEffect(config) { selectedOption = config.defaultOption }\n\n  Box {\n    Row(\n      verticalAlignment = Alignment.CenterVertically,\n      horizontalArrangement = Arrangement.spacedBy(2.dp),\n      modifier =\n        Modifier.clip(RoundedCornerShape(8.dp))\n          .background(MaterialTheme.colorScheme.secondaryContainer)\n          .clickable { showMenu = true }\n          .padding(vertical = 4.dp, horizontal = 6.dp)\n          .padding(start = 8.dp),\n    ) {\n      Text(\"${config.label}: $selectedOption\", style = MaterialTheme.typography.labelLarge)\n      Icon(Icons.Rounded.ArrowDropDown, contentDescription = null)\n    }\n\n    DropdownMenu(expanded = showMenu, onDismissRequest = { showMenu = false }) {\n      // Options\n      for (option in config.options) {\n        DropdownMenuItem(\n          text = { Text(option) },\n          onClick = {\n            selectedOption = option\n            showMenu = false\n            onSelected(option)\n          },\n        )\n      }\n    }\n  }\n}\n"
  },
  {
    "path": "Android/src/app/src/main/java/com/google/ai/edge/gallery/ui/llmsingleturn/VerticalSplitView.kt",
    "content": "/*\n * Copyright 2025 Google LLC\n *\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n *     http://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\n\npackage com.google.ai.edge.gallery.ui.llmsingleturn\n\n// import androidx.compose.ui.tooling.preview.Preview\n// import com.google.ai.edge.gallery.ui.theme.GalleryTheme\nimport androidx.compose.foundation.background\nimport androidx.compose.foundation.gestures.detectDragGestures\nimport androidx.compose.foundation.layout.Box\nimport androidx.compose.foundation.layout.Column\nimport androidx.compose.foundation.layout.fillMaxSize\nimport androidx.compose.foundation.layout.fillMaxWidth\nimport androidx.compose.foundation.layout.height\nimport androidx.compose.foundation.layout.width\nimport androidx.compose.foundation.shape.CircleShape\nimport androidx.compose.material3.MaterialTheme\nimport androidx.compose.runtime.Composable\nimport androidx.compose.runtime.getValue\nimport androidx.compose.runtime.mutableFloatStateOf\nimport androidx.compose.runtime.mutableStateOf\nimport androidx.compose.runtime.remember\nimport androidx.compose.runtime.setValue\nimport androidx.compose.ui.Alignment\nimport androidx.compose.ui.Modifier\nimport androidx.compose.ui.draw.clip\nimport androidx.compose.ui.input.pointer.pointerInput\nimport androidx.compose.ui.layout.onGloballyPositioned\nimport androidx.compose.ui.platform.LocalDensity\nimport androidx.compose.ui.unit.Dp\nimport androidx.compose.ui.unit.dp\nimport com.google.ai.edge.gallery.ui.theme.customColors\n\n@Composable\nfun VerticalSplitView(\n  topView: @Composable () -> Unit,\n  bottomView: @Composable () -> Unit,\n  modifier: Modifier = Modifier,\n  initialRatio: Float = 0.5f,\n  minTopHeight: Dp = 250.dp,\n  minBottomHeight: Dp = 200.dp,\n  handleThickness: Dp = 20.dp,\n) {\n  var splitRatio by remember { mutableFloatStateOf(initialRatio) }\n  var columnHeightPx by remember { mutableFloatStateOf(0f) }\n  var columnHeightDp by remember { mutableStateOf(0.dp) }\n  val localDensity = LocalDensity.current\n\n  Column(\n    modifier =\n      modifier.fillMaxSize().onGloballyPositioned { coordinates ->\n        // Set column height using the LayoutCoordinates\n        columnHeightPx = coordinates.size.height.toFloat()\n        columnHeightDp = with(localDensity) { coordinates.size.height.toDp() }\n      }\n  ) {\n    Box(modifier = Modifier.fillMaxWidth().weight(splitRatio)) { topView() }\n\n    Box(\n      modifier =\n        Modifier.fillMaxWidth()\n          .height(handleThickness)\n          .background(MaterialTheme.customColors.agentBubbleBgColor)\n          .pointerInput(Unit) {\n            detectDragGestures { change, dragAmount ->\n              val newTopHeightPx = columnHeightPx * splitRatio + dragAmount.y\n              var newTopHeightDp = with(localDensity) { newTopHeightPx.toDp() }\n              if (newTopHeightDp < minTopHeight) {\n                newTopHeightDp = minTopHeight\n              }\n              if (columnHeightDp - newTopHeightDp < minBottomHeight) {\n                newTopHeightDp = columnHeightDp - minBottomHeight\n              }\n              splitRatio = newTopHeightDp / columnHeightDp\n              change.consume()\n            }\n          },\n      contentAlignment = Alignment.Center,\n    ) {\n      Box(\n        modifier =\n          Modifier.width(32.dp)\n            .height(4.dp)\n            .clip(CircleShape)\n            .background(MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.4f))\n      )\n    }\n\n    Box(modifier = Modifier.fillMaxWidth().weight(1f - splitRatio)) { bottomView() }\n  }\n}\n\n// @Preview(showBackground = true)\n// @Composable\n// fun VerticalSplitViewPreview() {\n//   GalleryTheme { VerticalSplitView(topView = { Text(\"top\") }, bottomView = { Text(\"bottom\") }) }\n// }\n"
  },
  {
    "path": "Android/src/app/src/main/java/com/google/ai/edge/gallery/ui/modelmanager/GlobalModelManager.kt",
    "content": "/*\n * Copyright 2026 Google LLC\n *\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n *     http://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\n\npackage com.google.ai.edge.gallery.ui.modelmanager\n\nimport android.content.Context\nimport android.content.Intent\nimport android.net.Uri\nimport android.provider.OpenableColumns\nimport android.util.Log\nimport androidx.activity.compose.BackHandler\nimport androidx.activity.compose.rememberLauncherForActivityResult\nimport androidx.activity.result.ActivityResultLauncher\nimport androidx.activity.result.contract.ActivityResultContracts\nimport androidx.compose.foundation.background\nimport androidx.compose.foundation.clickable\nimport androidx.compose.foundation.layout.Arrangement\nimport androidx.compose.foundation.layout.Box\nimport androidx.compose.foundation.layout.Column\nimport androidx.compose.foundation.layout.PaddingValues\nimport androidx.compose.foundation.layout.Row\nimport androidx.compose.foundation.layout.fillMaxWidth\nimport androidx.compose.foundation.layout.height\nimport androidx.compose.foundation.layout.padding\nimport androidx.compose.foundation.layout.size\nimport androidx.compose.foundation.lazy.LazyColumn\nimport androidx.compose.foundation.lazy.items\nimport androidx.compose.material.icons.Icons\nimport androidx.compose.material.icons.automirrored.outlined.NoteAdd\nimport androidx.compose.material.icons.automirrored.rounded.ListAlt\nimport androidx.compose.material.icons.filled.Add\nimport androidx.compose.material.icons.rounded.Close\nimport androidx.compose.material.icons.rounded.Error\nimport androidx.compose.material3.AlertDialog\nimport androidx.compose.material3.Button\nimport androidx.compose.material3.CenterAlignedTopAppBar\nimport androidx.compose.material3.ExperimentalMaterial3Api\nimport androidx.compose.material3.Icon\nimport androidx.compose.material3.IconButton\nimport androidx.compose.material3.MaterialTheme\nimport androidx.compose.material3.ModalBottomSheet\nimport androidx.compose.material3.Scaffold\nimport androidx.compose.material3.SmallFloatingActionButton\nimport androidx.compose.material3.SnackbarHost\nimport androidx.compose.material3.SnackbarHostState\nimport androidx.compose.material3.Text\nimport androidx.compose.material3.rememberModalBottomSheetState\nimport androidx.compose.runtime.Composable\nimport androidx.compose.runtime.LaunchedEffect\nimport androidx.compose.runtime.collectAsState\nimport androidx.compose.runtime.getValue\nimport androidx.compose.runtime.mutableStateListOf\nimport androidx.compose.runtime.mutableStateMapOf\nimport androidx.compose.runtime.mutableStateOf\nimport androidx.compose.runtime.remember\nimport androidx.compose.runtime.rememberCoroutineScope\nimport androidx.compose.runtime.setValue\nimport androidx.compose.ui.Alignment\nimport androidx.compose.ui.Modifier\nimport androidx.compose.ui.graphics.Brush\nimport androidx.compose.ui.graphics.Color\nimport androidx.compose.ui.platform.LocalContext\nimport androidx.compose.ui.res.stringResource\nimport androidx.compose.ui.semantics.Role\nimport androidx.compose.ui.semantics.clearAndSetSemantics\nimport androidx.compose.ui.semantics.contentDescription\nimport androidx.compose.ui.semantics.role\nimport androidx.compose.ui.semantics.semantics\nimport androidx.compose.ui.unit.dp\nimport com.google.ai.edge.gallery.R\nimport com.google.ai.edge.gallery.data.Model\nimport com.google.ai.edge.gallery.data.Task\nimport com.google.ai.edge.gallery.proto.ImportedModel\nimport com.google.ai.edge.gallery.ui.common.TaskIcon\nimport com.google.ai.edge.gallery.ui.common.modelitem.ModelItem\nimport kotlin.text.endsWith\nimport kotlin.text.lowercase\nimport kotlinx.coroutines.delay\nimport kotlinx.coroutines.launch\n\nprivate const val TAG = \"AGGlobalMM\"\n\n@OptIn(ExperimentalMaterial3Api::class)\n@Composable\nfun GlobalModelManager(\n  viewModel: ModelManagerViewModel,\n  navigateUp: () -> Unit,\n  onModelSelected: (Task, Model) -> Unit,\n  onBenchmarkClicked: (Model) -> Unit,\n  modifier: Modifier = Modifier,\n) {\n  val uiState by viewModel.uiState.collectAsState()\n  val builtInModels = remember { mutableStateListOf<Model>() }\n  val importedModels = remember { mutableStateListOf<Model>() }\n  val taskCandidates = remember { mutableStateListOf<Task>() }\n  var modelForTaskCandidate by remember { mutableStateOf<Model?>(null) }\n  var showTaskSelectorBottomSheet by remember { mutableStateOf(false) }\n  var showImportModelSheet by remember { mutableStateOf(false) }\n  var showUnsupportedFileTypeDialog by remember { mutableStateOf(false) }\n  var showUnsupportedWebModelDialog by remember { mutableStateOf(false) }\n  val selectedLocalModelFileUri = remember { mutableStateOf<Uri?>(null) }\n  val selectedImportedModelInfo = remember { mutableStateOf<ImportedModel?>(null) }\n  val sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true)\n  var showImportDialog by remember { mutableStateOf(false) }\n  var showImportingDialog by remember { mutableStateOf(false) }\n  val scope = rememberCoroutineScope()\n  val context = LocalContext.current\n  val snackbarHostState = remember { SnackbarHostState() }\n  val modelItemExpandedStates = remember { mutableStateMapOf<String, Boolean>() }\n\n  val filePickerLauncher: ActivityResultLauncher<Intent> =\n    rememberLauncherForActivityResult(\n      contract = ActivityResultContracts.StartActivityForResult()\n    ) { result ->\n      if (result.resultCode == android.app.Activity.RESULT_OK) {\n        result.data?.data?.let { uri ->\n          val fileName = getFileName(context = context, uri = uri)\n          Log.d(TAG, \"Selected file: $fileName\")\n          // Show warning for model file types other than .task and .litertlm.\n          if (fileName != null && !fileName.endsWith(\".task\") && !fileName.endsWith(\".litertlm\")) {\n            showUnsupportedFileTypeDialog = true\n          }\n          // Show warning for web-only model (by checking if the file name has \"-web\" in it).\n          else if (fileName != null && fileName.lowercase().contains(\"-web\")) {\n            showUnsupportedWebModelDialog = true\n          } else {\n            selectedLocalModelFileUri.value = uri\n            showImportDialog = true\n          }\n        } ?: run { Log.d(TAG, \"No file selected or URI is null.\") }\n      } else {\n        Log.d(TAG, \"File picking cancelled.\")\n      }\n    }\n\n  LaunchedEffect(uiState.modelImportingUpdateTrigger) {\n    val allModelsSet = mutableSetOf<Model>()\n    for (task in uiState.tasks) {\n      for (model in task.models) {\n        allModelsSet.add(model)\n      }\n    }\n    val sortedModels = allModelsSet.toList().sortedBy { it.displayName.ifEmpty { it.name } }\n    builtInModels.clear()\n    builtInModels.addAll(sortedModels.filter { !it.imported })\n    importedModels.clear()\n    importedModels.addAll(sortedModels.filter { it.imported })\n  }\n\n  val handleClickModel: (Model) -> Unit = { model ->\n    val tasks = viewModel.uiState.value.tasks\n    val tasksForModel = tasks.filter { task -> task.models.any { it.name == model.name } }\n    // If there is only one task for the model, navigate to the model directly.\n    if (tasksForModel.size == 1) {\n      onModelSelected(tasksForModel[0], model)\n    }\n    // If there are multiple tasks for the model, show a bottom sheet for the user to choose which\n    // task to use.\n    else if (tasksForModel.size > 1) {\n      taskCandidates.clear()\n      taskCandidates.addAll(tasksForModel)\n      modelForTaskCandidate = model\n      showTaskSelectorBottomSheet = true\n    }\n  }\n\n  // Handle system's edge swipe.\n  BackHandler { navigateUp() }\n\n  Scaffold(\n    modifier = modifier,\n    topBar = {\n      CenterAlignedTopAppBar(\n        title = {\n          Column(horizontalAlignment = Alignment.CenterHorizontally) {\n            Row(\n              verticalAlignment = Alignment.CenterVertically,\n              horizontalArrangement = Arrangement.spacedBy(12.dp),\n            ) {\n              Icon(\n                Icons.AutoMirrored.Rounded.ListAlt,\n                modifier = Modifier.size(20.dp),\n                contentDescription = null,\n                tint = MaterialTheme.colorScheme.onSurface,\n              )\n              Text(\n                text =\n                  \"${stringResource(R.string.drawer_models_label)} (${builtInModels.size + importedModels.size})\",\n                color = MaterialTheme.colorScheme.onSurface,\n                style = MaterialTheme.typography.titleMedium,\n              )\n            }\n          }\n        },\n        // The \"action\" component at the right.\n        actions = {\n          IconButton(onClick = { navigateUp() }) {\n            Icon(\n              imageVector = Icons.Rounded.Close,\n              contentDescription = stringResource(R.string.cd_close_icon),\n              tint = MaterialTheme.colorScheme.onSurface,\n            )\n          }\n        },\n        modifier = modifier,\n      )\n    },\n    floatingActionButton = {\n      // A floating action button to show \"import model\" bottom sheet.\n      val cdImportModelFab = stringResource(R.string.cd_import_model_button)\n      SmallFloatingActionButton(\n        onClick = { showImportModelSheet = true },\n        containerColor = MaterialTheme.colorScheme.secondaryContainer,\n        contentColor = MaterialTheme.colorScheme.secondary,\n        modifier = Modifier.semantics { contentDescription = cdImportModelFab },\n      ) {\n        Icon(Icons.Filled.Add, contentDescription = null)\n      }\n    },\n  ) { innerPadding ->\n    Box() {\n      LazyColumn(\n        modifier =\n          Modifier.background(MaterialTheme.colorScheme.surfaceContainer)\n            .fillMaxWidth()\n            .padding(horizontal = 16.dp)\n            .padding(top = innerPadding.calculateTopPadding()),\n        verticalArrangement = Arrangement.spacedBy(8.dp),\n        contentPadding =\n          PaddingValues(top = 16.dp, bottom = innerPadding.calculateBottomPadding() + 80.dp),\n      ) {\n        items(builtInModels) { model ->\n          val expanded = modelItemExpandedStates.getOrDefault(model.name, true)\n          ModelItem(\n            model = model,\n            task = null,\n            modelManagerViewModel = viewModel,\n            onModelClicked = handleClickModel,\n            onBenchmarkClicked = onBenchmarkClicked,\n            expanded = expanded,\n            showBenchmarkButton = true,\n            onExpanded = { modelItemExpandedStates[model.name] = it },\n          )\n        }\n\n        // Imported models.\n        if (importedModels.isNotEmpty()) {\n          item(key = \"imported_models_label\") {\n            Text(\n              stringResource(R.string.model_list_imported_models_title),\n              color = MaterialTheme.colorScheme.onSurface,\n              style = MaterialTheme.typography.labelLarge,\n              modifier = Modifier.padding(horizontal = 16.dp).padding(top = 32.dp, bottom = 8.dp),\n            )\n          }\n        }\n        items(importedModels) { model ->\n          ModelItem(\n            model = model,\n            task = null,\n            modelManagerViewModel = viewModel,\n            onModelClicked = handleClickModel,\n            onBenchmarkClicked = onBenchmarkClicked,\n            expanded = true,\n            showBenchmarkButton = true,\n          )\n        }\n      }\n\n      SnackbarHost(\n        hostState = snackbarHostState,\n        modifier = Modifier.align(alignment = Alignment.BottomCenter).padding(bottom = 32.dp),\n      )\n\n      // Gradient overlay at the bottom.\n      Box(\n        modifier =\n          Modifier.fillMaxWidth()\n            .height(innerPadding.calculateBottomPadding())\n            .background(\n              Brush.verticalGradient(\n                colors = listOf(Color.Transparent, MaterialTheme.colorScheme.surfaceContainer)\n              )\n            )\n            .align(Alignment.BottomCenter)\n      )\n    }\n  }\n\n  if (showTaskSelectorBottomSheet) {\n    ModalBottomSheet(\n      onDismissRequest = { showTaskSelectorBottomSheet = false },\n      sheetState = sheetState,\n    ) {\n      Column(\n        modifier = Modifier.padding(bottom = 16.dp),\n        verticalArrangement = Arrangement.spacedBy(8.dp),\n      ) {\n        Text(\n          stringResource(R.string.model_manager_select_task_title),\n          color = MaterialTheme.colorScheme.onSurface,\n          style = MaterialTheme.typography.titleLarge,\n          modifier = Modifier.padding(bottom = 8.dp).padding(start = 16.dp),\n        )\n        for (task in taskCandidates) {\n          Row(\n            verticalAlignment = Alignment.CenterVertically,\n            horizontalArrangement = Arrangement.SpaceBetween,\n            modifier =\n              Modifier.fillMaxWidth()\n                .clickable {\n                  val model = modelForTaskCandidate\n                  if (model != null) {\n                    onModelSelected(task, model)\n                  }\n                  scope.launch {\n                    sheetState.hide()\n                    showTaskSelectorBottomSheet = false\n                  }\n                }\n                .padding(horizontal = 16.dp, vertical = 4.dp),\n          ) {\n            Text(\n              task.label,\n              color = MaterialTheme.colorScheme.onSurface,\n              style = MaterialTheme.typography.titleMedium,\n            )\n            TaskIcon(task = task, width = 40.dp)\n          }\n        }\n      }\n    }\n  }\n\n  // Import model bottom sheet.\n  if (showImportModelSheet) {\n    ModalBottomSheet(onDismissRequest = { showImportModelSheet = false }, sheetState = sheetState) {\n      Text(\n        \"Import model\",\n        style = MaterialTheme.typography.titleLarge,\n        modifier = Modifier.padding(vertical = 4.dp, horizontal = 16.dp),\n      )\n      val cbImportFromLocalFile = stringResource(R.string.cd_import_model_from_local_file_button)\n      Box(\n        modifier =\n          Modifier.clickable {\n              scope.launch {\n                // Give it sometime to show the click effect.\n                delay(200)\n                showImportModelSheet = false\n\n                // Show file picker.\n                val intent =\n                  Intent(Intent.ACTION_OPEN_DOCUMENT).apply {\n                    addCategory(Intent.CATEGORY_OPENABLE)\n                    type = \"*/*\"\n                    // Single select.\n                    putExtra(Intent.EXTRA_ALLOW_MULTIPLE, false)\n                  }\n                filePickerLauncher.launch(intent)\n              }\n            }\n            .semantics {\n              role = Role.Button\n              contentDescription = cbImportFromLocalFile\n            }\n      ) {\n        Row(\n          verticalAlignment = Alignment.CenterVertically,\n          horizontalArrangement = Arrangement.spacedBy(6.dp),\n          modifier = Modifier.fillMaxWidth().padding(16.dp),\n        ) {\n          Icon(Icons.AutoMirrored.Outlined.NoteAdd, contentDescription = null)\n          Text(\"From local model file\", modifier = Modifier.clearAndSetSemantics {})\n        }\n      }\n    }\n  }\n\n  // Import dialog\n  if (showImportDialog) {\n    selectedLocalModelFileUri.value?.let { uri ->\n      ModelImportDialog(\n        uri = uri,\n        onDismiss = { showImportDialog = false },\n        onDone = { info ->\n          selectedImportedModelInfo.value = info\n          showImportDialog = false\n          showImportingDialog = true\n        },\n      )\n    }\n  }\n\n  // Importing in progress dialog.\n  if (showImportingDialog) {\n    selectedLocalModelFileUri.value?.let { uri ->\n      selectedImportedModelInfo.value?.let { info ->\n        ModelImportingDialog(\n          uri = uri,\n          info = info,\n          onDismiss = { showImportingDialog = false },\n          onDone = {\n            viewModel.addImportedLlmModel(info = it)\n            showImportingDialog = false\n\n            // Show a snack bar for successful import.\n            scope.launch { snackbarHostState.showSnackbar(\"Model imported successfully\") }\n          },\n        )\n      }\n    }\n  }\n\n  // Alert dialog for unsupported file type.\n  if (showUnsupportedFileTypeDialog) {\n    AlertDialog(\n      icon = {\n        Icon(\n          Icons.Rounded.Error,\n          contentDescription = stringResource(R.string.cd_error),\n          tint = MaterialTheme.colorScheme.error,\n        )\n      },\n      onDismissRequest = { showUnsupportedFileTypeDialog = false },\n      title = { Text(\"Unsupported file type\") },\n      text = { Text(\"Only \\\".task\\\" or \\\".litertlm\\\" file type is supported.\") },\n      confirmButton = {\n        Button(onClick = { showUnsupportedFileTypeDialog = false }) {\n          Text(stringResource(R.string.ok))\n        }\n      },\n    )\n  }\n\n  // Alert dialog for unsupported web model.\n  if (showUnsupportedWebModelDialog) {\n    AlertDialog(\n      icon = {\n        Icon(\n          Icons.Rounded.Error,\n          contentDescription = stringResource(R.string.cd_error),\n          tint = MaterialTheme.colorScheme.error,\n        )\n      },\n      onDismissRequest = { showUnsupportedWebModelDialog = false },\n      title = { Text(\"Unsupported model type\") },\n      text = { Text(\"Looks like the model is a web-only model and is not supported by the app.\") },\n      confirmButton = {\n        Button(onClick = { showUnsupportedWebModelDialog = false }) {\n          Text(stringResource(R.string.ok))\n        }\n      },\n    )\n  }\n}\n\n// Helper function to get the file name from a URI\nprivate fun getFileName(context: Context, uri: Uri): String? {\n  if (uri.scheme == \"content\") {\n    context.contentResolver.query(uri, null, null, null, null)?.use { cursor ->\n      if (cursor.moveToFirst()) {\n        val nameIndex = cursor.getColumnIndex(OpenableColumns.DISPLAY_NAME)\n        if (nameIndex != -1) {\n          return cursor.getString(nameIndex)\n        }\n      }\n    }\n  } else if (uri.scheme == \"file\") {\n    return uri.lastPathSegment\n  }\n  return null\n}\n"
  },
  {
    "path": "Android/src/app/src/main/java/com/google/ai/edge/gallery/ui/modelmanager/ModelImportDialog.kt",
    "content": "/*\n * Copyright 2025 Google LLC\n *\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n *     http://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\n\npackage com.google.ai.edge.gallery.ui.modelmanager\n\nimport android.content.Context\nimport android.net.Uri\nimport android.provider.OpenableColumns\nimport android.util.Log\nimport androidx.compose.animation.core.Animatable\nimport androidx.compose.animation.core.tween\nimport androidx.compose.foundation.clickable\nimport androidx.compose.foundation.interaction.MutableInteractionSource\nimport androidx.compose.foundation.layout.Arrangement\nimport androidx.compose.foundation.layout.Column\nimport androidx.compose.foundation.layout.Row\nimport androidx.compose.foundation.layout.fillMaxWidth\nimport androidx.compose.foundation.layout.padding\nimport androidx.compose.foundation.rememberScrollState\nimport androidx.compose.foundation.shape.RoundedCornerShape\nimport androidx.compose.foundation.verticalScroll\nimport androidx.compose.material.icons.Icons\nimport androidx.compose.material.icons.rounded.Error\nimport androidx.compose.material3.Button\nimport androidx.compose.material3.Card\nimport androidx.compose.material3.Icon\nimport androidx.compose.material3.LinearProgressIndicator\nimport androidx.compose.material3.MaterialTheme\nimport androidx.compose.material3.Text\nimport androidx.compose.material3.TextButton\nimport androidx.compose.runtime.Composable\nimport androidx.compose.runtime.LaunchedEffect\nimport androidx.compose.runtime.getValue\nimport androidx.compose.runtime.mutableFloatStateOf\nimport androidx.compose.runtime.mutableLongStateOf\nimport androidx.compose.runtime.mutableStateMapOf\nimport androidx.compose.runtime.mutableStateOf\nimport androidx.compose.runtime.remember\nimport androidx.compose.runtime.rememberCoroutineScope\nimport androidx.compose.runtime.setValue\nimport androidx.compose.runtime.snapshots.SnapshotStateMap\nimport androidx.compose.ui.Alignment\nimport androidx.compose.ui.Modifier\nimport androidx.compose.ui.platform.LocalContext\nimport androidx.compose.ui.platform.LocalFocusManager\nimport androidx.compose.ui.res.stringResource\nimport androidx.compose.ui.unit.dp\nimport androidx.compose.ui.window.Dialog\nimport androidx.compose.ui.window.DialogProperties\nimport com.google.ai.edge.gallery.R\nimport com.google.ai.edge.gallery.common.isPixel10\nimport com.google.ai.edge.gallery.data.Accelerator\nimport com.google.ai.edge.gallery.data.BooleanSwitchConfig\nimport com.google.ai.edge.gallery.data.Config\nimport com.google.ai.edge.gallery.data.ConfigKey\nimport com.google.ai.edge.gallery.data.ConfigKeys\nimport com.google.ai.edge.gallery.data.DEFAULT_MAX_TOKEN\nimport com.google.ai.edge.gallery.data.DEFAULT_TEMPERATURE\nimport com.google.ai.edge.gallery.data.DEFAULT_TOPK\nimport com.google.ai.edge.gallery.data.DEFAULT_TOPP\nimport com.google.ai.edge.gallery.data.IMPORTS_DIR\nimport com.google.ai.edge.gallery.data.LabelConfig\nimport com.google.ai.edge.gallery.data.NumberSliderConfig\nimport com.google.ai.edge.gallery.data.SegmentedButtonConfig\nimport com.google.ai.edge.gallery.data.ValueType\nimport com.google.ai.edge.gallery.data.convertValueToTargetType\nimport com.google.ai.edge.gallery.proto.ImportedModel\nimport com.google.ai.edge.gallery.proto.LlmConfig\nimport com.google.ai.edge.gallery.ui.common.ConfigEditorsPanel\nimport com.google.ai.edge.gallery.ui.common.ensureValidFileName\nimport com.google.ai.edge.gallery.ui.common.humanReadableSize\nimport java.io.File\nimport java.io.FileOutputStream\nimport java.net.URLDecoder\nimport java.nio.charset.StandardCharsets\nimport kotlinx.coroutines.CoroutineScope\nimport kotlinx.coroutines.Dispatchers\nimport kotlinx.coroutines.launch\n\nprivate const val TAG = \"AGModelImportDialog\"\n\nprivate val SUPPORTED_ACCELERATORS: List<Accelerator> =\n  if (isPixel10()) {\n    listOf(Accelerator.CPU)\n  } else {\n    listOf(Accelerator.CPU, Accelerator.GPU, Accelerator.NPU)\n  }\n\nprivate val IMPORT_CONFIGS_LLM: List<Config> =\n  listOf(\n    LabelConfig(key = ConfigKeys.NAME),\n    LabelConfig(key = ConfigKeys.MODEL_TYPE),\n    NumberSliderConfig(\n      key = ConfigKeys.DEFAULT_MAX_TOKENS,\n      sliderMin = 100f,\n      sliderMax = 4096f,\n      defaultValue = DEFAULT_MAX_TOKEN.toFloat(),\n      valueType = ValueType.INT,\n    ),\n    NumberSliderConfig(\n      key = ConfigKeys.DEFAULT_TOPK,\n      sliderMin = 5f,\n      sliderMax = 40f,\n      defaultValue = DEFAULT_TOPK.toFloat(),\n      valueType = ValueType.INT,\n    ),\n    NumberSliderConfig(\n      key = ConfigKeys.DEFAULT_TOPP,\n      sliderMin = 0.0f,\n      sliderMax = 1.0f,\n      defaultValue = DEFAULT_TOPP,\n      valueType = ValueType.FLOAT,\n    ),\n    NumberSliderConfig(\n      key = ConfigKeys.DEFAULT_TEMPERATURE,\n      sliderMin = 0.0f,\n      sliderMax = 2.0f,\n      defaultValue = DEFAULT_TEMPERATURE,\n      valueType = ValueType.FLOAT,\n    ),\n    BooleanSwitchConfig(key = ConfigKeys.SUPPORT_IMAGE, defaultValue = false),\n    BooleanSwitchConfig(key = ConfigKeys.SUPPORT_AUDIO, defaultValue = false),\n    BooleanSwitchConfig(key = ConfigKeys.SUPPORT_TINY_GARDEN, defaultValue = false),\n    BooleanSwitchConfig(key = ConfigKeys.SUPPORT_MOBILE_ACTIONS, defaultValue = false),\n    SegmentedButtonConfig(\n      key = ConfigKeys.COMPATIBLE_ACCELERATORS,\n      defaultValue = SUPPORTED_ACCELERATORS[0].label,\n      options = SUPPORTED_ACCELERATORS.map { it.label },\n      allowMultiple = true,\n    ),\n  )\n\n@Composable\nfun ModelImportDialog(\n  uri: Uri,\n  onDismiss: () -> Unit,\n  onDone: (ImportedModel) -> Unit,\n  defaultValues: Map<ConfigKey, Any> = emptyMap(),\n) {\n  val context = LocalContext.current\n  val info = remember { getFileSizeAndDisplayNameFromUri(context = context, uri = uri) }\n  val fileSize by remember { mutableLongStateOf(info.first) }\n  val fileName by remember { mutableStateOf(ensureValidFileName(info.second)) }\n\n  val initialValues: Map<String, Any> = remember {\n    mutableMapOf<String, Any>().apply {\n      for (config in IMPORT_CONFIGS_LLM) {\n        put(config.key.label, config.defaultValue)\n      }\n      put(ConfigKeys.NAME.label, fileName)\n      // TODO: support other types.\n      put(ConfigKeys.MODEL_TYPE.label, \"LLM\")\n\n      for ((key, value) in defaultValues) {\n        put(key.label, value)\n      }\n    }\n  }\n  val values: SnapshotStateMap<String, Any> = remember {\n    mutableStateMapOf<String, Any>().apply { putAll(initialValues) }\n  }\n  val interactionSource = remember { MutableInteractionSource() }\n\n  Dialog(onDismissRequest = onDismiss) {\n    val focusManager = LocalFocusManager.current\n    Card(\n      modifier =\n        Modifier.fillMaxWidth().clickable(\n          interactionSource = interactionSource,\n          indication = null, // Disable the ripple effect\n        ) {\n          focusManager.clearFocus()\n        },\n      shape = RoundedCornerShape(16.dp),\n    ) {\n      Column(\n        modifier = Modifier.padding(20.dp),\n        verticalArrangement = Arrangement.spacedBy(16.dp),\n      ) {\n        // Title.\n        Text(\n          \"Import Model\",\n          style = MaterialTheme.typography.titleLarge,\n          modifier = Modifier.padding(bottom = 8.dp),\n        )\n\n        Column(\n          modifier = Modifier.verticalScroll(rememberScrollState()).weight(1f, fill = false),\n          verticalArrangement = Arrangement.spacedBy(16.dp),\n        ) {\n          // Default configs for users to set.\n          ConfigEditorsPanel(configs = IMPORT_CONFIGS_LLM, values = values)\n        }\n\n        // Button row.\n        Row(\n          modifier = Modifier.fillMaxWidth().padding(top = 8.dp),\n          horizontalArrangement = Arrangement.End,\n        ) {\n          // Cancel button.\n          TextButton(onClick = { onDismiss() }) { Text(\"Cancel\") }\n\n          // Import button\n          Button(\n            onClick = {\n              val supportedAccelerators =\n                (convertValueToTargetType(\n                    value = values.get(ConfigKeys.COMPATIBLE_ACCELERATORS.label)!!,\n                    valueType = ValueType.STRING,\n                  )\n                    as String)\n                  .split(\",\")\n              val defaultMaxTokens =\n                convertValueToTargetType(\n                  value = values.get(ConfigKeys.DEFAULT_MAX_TOKENS.label)!!,\n                  valueType = ValueType.INT,\n                )\n                  as Int\n              val defaultTopk =\n                convertValueToTargetType(\n                  value = values.get(ConfigKeys.DEFAULT_TOPK.label)!!,\n                  valueType = ValueType.INT,\n                )\n                  as Int\n              val defaultTopp =\n                convertValueToTargetType(\n                  value = values.get(ConfigKeys.DEFAULT_TOPP.label)!!,\n                  valueType = ValueType.FLOAT,\n                )\n                  as Float\n              val defaultTemperature =\n                convertValueToTargetType(\n                  value = values.get(ConfigKeys.DEFAULT_TEMPERATURE.label)!!,\n                  valueType = ValueType.FLOAT,\n                )\n                  as Float\n              val supportImage =\n                convertValueToTargetType(\n                  value = values.get(ConfigKeys.SUPPORT_IMAGE.label)!!,\n                  valueType = ValueType.BOOLEAN,\n                )\n                  as Boolean\n              val supportAudio =\n                convertValueToTargetType(\n                  value = values.get(ConfigKeys.SUPPORT_AUDIO.label)!!,\n                  valueType = ValueType.BOOLEAN,\n                )\n                  as Boolean\n              val supportTinyGarden =\n                convertValueToTargetType(\n                  value = values.get(ConfigKeys.SUPPORT_TINY_GARDEN.label)!!,\n                  valueType = ValueType.BOOLEAN,\n                )\n                  as Boolean\n              val supportMobileActions =\n                convertValueToTargetType(\n                  value = values.get(ConfigKeys.SUPPORT_MOBILE_ACTIONS.label)!!,\n                  valueType = ValueType.BOOLEAN,\n                )\n                  as Boolean\n              val importedModel: ImportedModel =\n                ImportedModel.newBuilder()\n                  .setFileName(fileName)\n                  .setFileSize(fileSize)\n                  .setLlmConfig(\n                    LlmConfig.newBuilder()\n                      .addAllCompatibleAccelerators(supportedAccelerators)\n                      .setDefaultMaxTokens(defaultMaxTokens)\n                      .setDefaultTopk(defaultTopk)\n                      .setDefaultTopp(defaultTopp)\n                      .setDefaultTemperature(defaultTemperature)\n                      .setSupportImage(supportImage)\n                      .setSupportAudio(supportAudio)\n                      .setSupportMobileActions(supportMobileActions)\n                      .setSupportTinyGarden(supportTinyGarden)\n                      .build()\n                  )\n                  .build()\n              onDone(importedModel)\n            }\n          ) {\n            Text(\"Import\")\n          }\n        }\n      }\n    }\n  }\n}\n\n@Composable\nfun ModelImportingDialog(\n  uri: Uri,\n  info: ImportedModel,\n  onDismiss: () -> Unit,\n  onDone: (ImportedModel) -> Unit,\n) {\n  var error by remember { mutableStateOf(\"\") }\n  val context = LocalContext.current\n  val coroutineScope = rememberCoroutineScope()\n  var progress by remember { mutableFloatStateOf(0f) }\n\n  LaunchedEffect(Unit) {\n    // Import.\n    importModel(\n      context = context,\n      coroutineScope = coroutineScope,\n      fileName = info.fileName,\n      fileSize = info.fileSize,\n      uri = uri,\n      onDone = { onDone(info) },\n      onProgress = { progress = it },\n      onError = { error = it },\n    )\n  }\n\n  Dialog(\n    properties = DialogProperties(dismissOnBackPress = false, dismissOnClickOutside = false),\n    onDismissRequest = onDismiss,\n  ) {\n    Card(modifier = Modifier.fillMaxWidth(), shape = RoundedCornerShape(16.dp)) {\n      Column(\n        modifier = Modifier.padding(20.dp),\n        verticalArrangement = Arrangement.spacedBy(16.dp),\n      ) {\n        // Title.\n        Text(\n          \"Import Model\",\n          style = MaterialTheme.typography.titleLarge,\n          modifier = Modifier.padding(bottom = 8.dp),\n        )\n\n        // No error.\n        if (error.isEmpty()) {\n          // Progress bar.\n          Column(verticalArrangement = Arrangement.spacedBy(4.dp)) {\n            Text(\n              \"${info.fileName} (${info.fileSize.humanReadableSize()})\",\n              style = MaterialTheme.typography.labelSmall,\n            )\n            val animatedProgress = remember { Animatable(0f) }\n            LinearProgressIndicator(\n              progress = { animatedProgress.value },\n              modifier = Modifier.fillMaxWidth().padding(bottom = 8.dp),\n            )\n            LaunchedEffect(progress) {\n              animatedProgress.animateTo(progress, animationSpec = tween(150))\n            }\n          }\n        }\n        // Has error.\n        else {\n          Row(\n            verticalAlignment = Alignment.Top,\n            horizontalArrangement = Arrangement.spacedBy(6.dp),\n          ) {\n            Icon(\n              Icons.Rounded.Error,\n              contentDescription = stringResource(R.string.cd_error),\n              tint = MaterialTheme.colorScheme.error,\n            )\n            Text(\n              error,\n              style = MaterialTheme.typography.labelSmall,\n              color = MaterialTheme.colorScheme.error,\n              modifier = Modifier.padding(top = 4.dp),\n            )\n          }\n          Row(modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.End) {\n            Button(onClick = { onDismiss() }) { Text(\"Close\") }\n          }\n        }\n      }\n    }\n  }\n}\n\nprivate fun importModel(\n  context: Context,\n  coroutineScope: CoroutineScope,\n  fileName: String,\n  fileSize: Long,\n  uri: Uri,\n  onDone: () -> Unit,\n  onProgress: (Float) -> Unit,\n  onError: (String) -> Unit,\n) {\n  // TODO: handle error.\n  coroutineScope.launch(Dispatchers.IO) {\n    // Get the last component of the uri path as the imported file name.\n    val decodedUri = URLDecoder.decode(uri.toString(), StandardCharsets.UTF_8.name())\n    Log.d(TAG, \"importing model from $decodedUri. File name: $fileName. File size: $fileSize\")\n\n    // Create <app_external_dir>/imports if not exist.\n    val importsDir = File(context.getExternalFilesDir(null), IMPORTS_DIR)\n    if (!importsDir.exists()) {\n      importsDir.mkdirs()\n    }\n\n    // Import by copying the file over.\n    val outputFile = File(context.getExternalFilesDir(null), \"$IMPORTS_DIR/$fileName\")\n    val outputStream = FileOutputStream(outputFile)\n    val buffer = ByteArray(DEFAULT_BUFFER_SIZE)\n    var bytesRead: Int\n    var lastSetProgressTs: Long = 0\n    var importedBytes = 0L\n    val inputStream = context.contentResolver.openInputStream(uri)\n    try {\n      if (inputStream != null) {\n        while (inputStream.read(buffer).also { bytesRead = it } != -1) {\n          outputStream.write(buffer, 0, bytesRead)\n          importedBytes += bytesRead\n\n          // Report progress every 200 ms.\n          val curTs = System.currentTimeMillis()\n          if (curTs - lastSetProgressTs > 200) {\n            Log.d(TAG, \"importing progress: $importedBytes, $fileSize\")\n            lastSetProgressTs = curTs\n            if (fileSize != 0L) {\n              onProgress(importedBytes.toFloat() / fileSize.toFloat())\n            }\n          }\n        }\n      }\n    } catch (e: Exception) {\n      e.printStackTrace()\n      onError(e.message ?: \"Failed to import\")\n      return@launch\n    } finally {\n      inputStream?.close()\n      outputStream.close()\n    }\n    Log.d(TAG, \"import done\")\n    onProgress(1f)\n    onDone()\n  }\n}\n\nprivate fun getFileSizeAndDisplayNameFromUri(context: Context, uri: Uri): Pair<Long, String> {\n  val contentResolver = context.contentResolver\n  var fileSize = 0L\n  var displayName = \"\"\n\n  try {\n    contentResolver\n      .query(uri, arrayOf(OpenableColumns.SIZE, OpenableColumns.DISPLAY_NAME), null, null, null)\n      ?.use { cursor ->\n        if (cursor.moveToFirst()) {\n          val sizeIndex = cursor.getColumnIndexOrThrow(OpenableColumns.SIZE)\n          fileSize = cursor.getLong(sizeIndex)\n\n          val nameIndex = cursor.getColumnIndexOrThrow(OpenableColumns.DISPLAY_NAME)\n          displayName = cursor.getString(nameIndex)\n        }\n      }\n  } catch (e: Exception) {\n    e.printStackTrace()\n    return Pair(0L, \"\")\n  }\n\n  return Pair(fileSize, displayName)\n}\n"
  },
  {
    "path": "Android/src/app/src/main/java/com/google/ai/edge/gallery/ui/modelmanager/ModelList.kt",
    "content": "/*\n * Copyright 2025 Google LLC\n *\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n *     http://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\n\npackage com.google.ai.edge.gallery.ui.modelmanager\n\n// import androidx.compose.ui.tooling.preview.Preview\n// import com.google.ai.edge.gallery.ui.preview.PreviewModelManagerViewModel\n// import com.google.ai.edge.gallery.ui.preview.TASK_TEST1\n// import com.google.ai.edge.gallery.ui.theme.GalleryTheme\n\nimport androidx.compose.ui.platform.LocalContext\nimport androidx.compose.foundation.background\nimport androidx.compose.foundation.layout.Arrangement\nimport androidx.compose.foundation.layout.Box\nimport androidx.compose.foundation.layout.Column\nimport androidx.compose.foundation.layout.PaddingValues\nimport androidx.compose.foundation.layout.fillMaxWidth\nimport androidx.compose.foundation.layout.height\nimport androidx.compose.foundation.layout.offset\nimport androidx.compose.foundation.layout.padding\nimport androidx.compose.foundation.lazy.LazyColumn\nimport androidx.compose.foundation.lazy.items\nimport androidx.compose.foundation.lazy.rememberLazyListState\nimport androidx.compose.foundation.shape.CircleShape\nimport androidx.compose.material.icons.Icons\nimport androidx.compose.material.icons.outlined.Code\nimport androidx.compose.material.icons.outlined.Description\nimport androidx.compose.material3.MaterialTheme\nimport androidx.compose.material3.Surface\nimport androidx.compose.material3.Text\nimport androidx.compose.runtime.Composable\nimport androidx.compose.runtime.derivedStateOf\nimport androidx.compose.runtime.getValue\nimport androidx.compose.runtime.mutableStateMapOf\nimport androidx.compose.runtime.remember\nimport androidx.compose.ui.Alignment\nimport androidx.compose.ui.Modifier\nimport androidx.compose.ui.draw.alpha\nimport androidx.compose.ui.graphics.Brush\nimport androidx.compose.ui.graphics.Color\nimport androidx.compose.ui.graphics.graphicsLayer\nimport androidx.compose.ui.res.stringResource\nimport androidx.compose.ui.semantics.contentDescription\nimport androidx.compose.ui.semantics.semantics\nimport androidx.compose.ui.text.font.FontWeight\nimport androidx.compose.ui.text.style.TextAlign\nimport androidx.compose.ui.unit.dp\nimport com.google.ai.edge.gallery.R\nimport com.google.ai.edge.gallery.data.Model\nimport com.google.ai.edge.gallery.data.Task\nimport com.google.ai.edge.gallery.ui.common.ClickableLink\nimport com.google.ai.edge.gallery.ui.common.RevealingText\nimport com.google.ai.edge.gallery.ui.common.TaskIcon\nimport com.google.ai.edge.gallery.ui.common.getTaskBgColor\nimport com.google.ai.edge.gallery.ui.common.getTaskBgGradientColors\nimport com.google.ai.edge.gallery.ui.common.modelitem.ModelItem\nimport com.google.ai.edge.gallery.ui.common.rememberDelayedAnimationProgress\nimport com.google.ai.edge.gallery.ui.theme.bodyLargeNarrow\nimport com.google.ai.edge.gallery.ui.theme.headlineLargeMedium\n\nprivate const val TAG = \"AGModelList\"\nprivate val CONTENT_ANIMATION_OFFSET = 16.dp\nprivate const val ANIMATION_INIT_DELAY = 80L\nprivate const val TASK_DESCRIPTION_SECTION_ANIMATION_START = 400\nprivate const val MODEL_LIST_ANIMATION_START = TASK_DESCRIPTION_SECTION_ANIMATION_START + 150\nprivate const val DEFAULT_ANIMATION_DURATION = 700\nprivate const val TASK_ICON_ANIMATION_DURATION = 1100\n\n/** The list of models in the model manager. */\n@Composable\nfun ModelList(\n  task: Task,\n  modelManagerViewModel: ModelManagerViewModel,\n  contentPadding: PaddingValues,\n  enableAnimation: Boolean,\n  onModelClicked: (Model) -> Unit,\n  onBenchmarkClicked: (Model) -> Unit,\n  modifier: Modifier = Modifier,\n) {\n  // This is just to update \"models\" list when task.updateTrigger is updated so that the UI can\n  // be properly updated.\n  val models by\n    remember(task) {\n      derivedStateOf {\n        val trigger = task.updateTrigger.value\n        if (trigger >= 0) {\n          task.models.toList().filter { !it.imported }\n        } else {\n          listOf()\n        }\n      }\n    }\n  val importedModels by\n    remember(task) {\n      derivedStateOf {\n        val trigger = task.updateTrigger.value\n        if (trigger >= 0) {\n          task.models.toList().filter { it.imported }\n        } else {\n          listOf()\n        }\n      }\n    }\n\n  val listState = rememberLazyListState()\n\n  val taskIconProgress =\n    if (!enableAnimation) 1f\n    else\n      rememberDelayedAnimationProgress(\n        initialDelay = ANIMATION_INIT_DELAY,\n        animationDurationMs = TASK_ICON_ANIMATION_DURATION,\n        animationLabel = \"task icon\",\n      )\n\n  val taskLabelProgress =\n    if (!enableAnimation) 1f\n    else\n      rememberDelayedAnimationProgress(\n        initialDelay = ANIMATION_INIT_DELAY + 300,\n        animationDurationMs = TASK_ICON_ANIMATION_DURATION,\n        animationLabel = \"task label\",\n      )\n\n  val descriptionProgress =\n    if (!enableAnimation) 1f\n    else\n      rememberDelayedAnimationProgress(\n        initialDelay = ANIMATION_INIT_DELAY + TASK_DESCRIPTION_SECTION_ANIMATION_START,\n        animationDurationMs = DEFAULT_ANIMATION_DURATION,\n        animationLabel = \"description\",\n      )\n\n  val modelListProgress =\n    if (!enableAnimation) 1f\n    else\n      rememberDelayedAnimationProgress(\n        initialDelay = ANIMATION_INIT_DELAY + MODEL_LIST_ANIMATION_START,\n        animationDurationMs = DEFAULT_ANIMATION_DURATION,\n        animationLabel = \"model_list\",\n      )\n  val modelItemExpandedStates = remember { mutableStateMapOf<String, Boolean>() }\n\n  Box(\n    contentAlignment = Alignment.BottomEnd,\n    modifier = Modifier.background(color = getTaskBgColor(task = task)),\n  ) {\n    LazyColumn(\n      modifier = modifier.padding(top = 32.dp).padding(horizontal = 16.dp),\n      contentPadding = contentPadding,\n      verticalArrangement = Arrangement.spacedBy(8.dp),\n      state = listState,\n    ) {\n      // Task header area.\n      item(key = \"taskHeader\") {\n        Column(\n          verticalArrangement = Arrangement.spacedBy(8.dp),\n          horizontalAlignment = Alignment.CenterHorizontally,\n          modifier = Modifier.fillMaxWidth().padding(bottom = 24.dp),\n        ) {\n          // Task icon.\n          TaskIcon(task = task, width = 64.dp, animationProgress = taskIconProgress)\n\n          // Task name.\n          Box(\n            modifier =\n              Modifier.offset(x = (20f * (1f - taskIconProgress)).dp).semantics {\n                contentDescription = task.label\n              }\n          ) {\n            RevealingText(\n              text = task.label,\n              style =\n                headlineLargeMedium.copy(\n                  brush = Brush.linearGradient(getTaskBgGradientColors(task = task))\n                ),\n              textAlign = TextAlign.Center,\n              animationProgress = taskIconProgress,\n            )\n            RevealingText(\n              text = task.label,\n              style = headlineLargeMedium,\n              textAlign = TextAlign.Center,\n              animationProgress = taskLabelProgress,\n            )\n          }\n\n          // Experimental pill\n          if (task.experimental) {\n            Box(modifier = Modifier.fillMaxWidth()) {\n              Surface(\n                shape = CircleShape, // This creates the \"pill\" effect\n                color = MaterialTheme.colorScheme.secondaryContainer,\n                modifier =\n                  Modifier.align(Alignment.Center).graphicsLayer {\n                    alpha = descriptionProgress\n                    translationY = (CONTENT_ANIMATION_OFFSET * (1 - descriptionProgress)).toPx()\n                  },\n              ) {\n                Text(\n                  text = stringResource(R.string.model_list_experimental_label),\n                  style = bodyLargeNarrow.copy(fontWeight = FontWeight.Bold),\n                  modifier = Modifier.padding(horizontal = 12.dp, vertical = 4.dp),\n                )\n              }\n            }\n          }\n\n          // Description.\n          Text(\n            task.description,\n            textAlign = TextAlign.Center,\n            style = bodyLargeNarrow,\n            modifier =\n              Modifier.graphicsLayer {\n                alpha = descriptionProgress\n                translationY = (CONTENT_ANIMATION_OFFSET * (1 - descriptionProgress)).toPx()\n              },\n          )\n\n          // Urls.\n          if (task.docUrl.isNotEmpty() || task.sourceCodeUrl.isNotEmpty()) {\n            Box(\n              modifier =\n                Modifier.padding(vertical = 8.dp).graphicsLayer {\n                  alpha = descriptionProgress\n                  translationY = (CONTENT_ANIMATION_OFFSET * (1 - descriptionProgress)).toPx()\n                }\n            ) {\n              Column(\n                horizontalAlignment = Alignment.Start,\n                verticalArrangement = Arrangement.spacedBy(4.dp),\n              ) {\n                if (task.docUrl.isNotEmpty()) {\n                  ClickableLink(\n                    url = task.docUrl,\n                    linkText = \"API Documentation\",\n                    icon = Icons.Outlined.Description,\n                  )\n                }\n                if (task.sourceCodeUrl.isNotEmpty()) {\n                  ClickableLink(\n                    url = task.sourceCodeUrl,\n                    linkText = \"Example code\",\n                    icon = Icons.Outlined.Code,\n                  )\n                }\n              }\n            }\n          }\n\n          // Models available.\n          val resources = LocalContext.current.resources\n          Text(\n            resources.getQuantityString(\n              R.plurals.model_list_number_of_models_available,\n              models.size + importedModels.size,\n              models.size + importedModels.size,\n            ),\n            color = MaterialTheme.colorScheme.onSurface,\n            style = MaterialTheme.typography.bodyMedium,\n            modifier =\n              Modifier.alpha(0.6f).graphicsLayer {\n                alpha = descriptionProgress * 0.6f\n                translationY = (CONTENT_ANIMATION_OFFSET * (1 - descriptionProgress)).toPx()\n              },\n          )\n        }\n      }\n\n      // Title for recommended models.\n      if (!models.isEmpty())\n        item(key = \"recommendedModelsTitle\") {\n          Text(\n            stringResource(R.string.model_list_recommended_models_title),\n            color = MaterialTheme.colorScheme.onSurface,\n            style = MaterialTheme.typography.labelLarge,\n            modifier =\n              Modifier.padding(horizontal = 16.dp, vertical = 8.dp).graphicsLayer {\n                alpha = modelListProgress\n                translationY = (CONTENT_ANIMATION_OFFSET * (1 - modelListProgress)).toPx()\n              },\n          )\n        }\n\n      // List of models within a task.\n      items(items = models) { model ->\n        val expanded = modelItemExpandedStates.getOrDefault(model.name, null)\n        ModelItem(\n          model = model,\n          task = task,\n          modelManagerViewModel = modelManagerViewModel,\n          onModelClicked = onModelClicked,\n          onBenchmarkClicked = onBenchmarkClicked,\n          expanded = expanded,\n          onExpanded = { modelItemExpandedStates[model.name] = it },\n          modifier =\n            Modifier.graphicsLayer {\n              alpha = modelListProgress\n              translationY = (CONTENT_ANIMATION_OFFSET * (1 - modelListProgress)).toPx()\n            },\n        )\n      }\n\n      // Title for imported models.\n      if (importedModels.isNotEmpty()) {\n        item(key = \"importedModelsTitle\") {\n          Text(\n            stringResource(R.string.model_list_imported_models_title),\n            color = MaterialTheme.colorScheme.onSurface,\n            style = MaterialTheme.typography.labelLarge,\n            modifier =\n              Modifier.padding(horizontal = 16.dp)\n                .padding(top = 32.dp, bottom = 8.dp)\n                .graphicsLayer {\n                  alpha = modelListProgress\n                  translationY = (CONTENT_ANIMATION_OFFSET * (1 - modelListProgress)).toPx()\n                },\n          )\n        }\n      }\n\n      // List of imported models within a task.\n      items(items = importedModels, key = { it.name }) { model ->\n        Box {\n          ModelItem(\n            model = model,\n            task = task,\n            modelManagerViewModel = modelManagerViewModel,\n            onModelClicked = onModelClicked,\n            onBenchmarkClicked = onBenchmarkClicked,\n            modifier =\n              Modifier.graphicsLayer {\n                alpha = modelListProgress\n                translationY = (CONTENT_ANIMATION_OFFSET * (1 - modelListProgress)).toPx()\n              },\n          )\n        }\n      }\n    }\n\n    // Gradient overlay at the bottom.\n    Box(\n      modifier =\n        Modifier.fillMaxWidth()\n          .height(contentPadding.calculateBottomPadding())\n          .background(\n            Brush.verticalGradient(\n              colors = listOf(Color.Transparent, MaterialTheme.colorScheme.surfaceContainer)\n            )\n          )\n          .align(Alignment.BottomCenter)\n    )\n  }\n}\n\n// @Preview(showBackground = true)\n// @Composable\n// fun ModelListPreview() {\n//   val context = LocalContext.current\n\n//   GalleryTheme {\n//     ModelList(\n//       task = TASK_TEST1,\n//       modelManagerViewModel = PreviewModelManagerViewModel(context = context),\n//       onModelClicked = {},\n//       contentPadding = PaddingValues(all = 16.dp),\n//     )\n//   }\n// }\n"
  },
  {
    "path": "Android/src/app/src/main/java/com/google/ai/edge/gallery/ui/modelmanager/ModelManager.kt",
    "content": "/*\n * Copyright 2025 Google LLC\n *\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n *     http://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\n\npackage com.google.ai.edge.gallery.ui.modelmanager\n\n// import androidx.compose.ui.tooling.preview.Preview\n// import com.google.ai.edge.gallery.ui.preview.PreviewModelManagerViewModel\n// import com.google.ai.edge.gallery.ui.preview.TASK_TEST1\n// import com.google.ai.edge.gallery.ui.theme.GalleryTheme\n\nimport androidx.activity.compose.BackHandler\nimport androidx.compose.foundation.layout.fillMaxSize\nimport androidx.compose.material3.ExperimentalMaterial3Api\nimport androidx.compose.material3.Scaffold\nimport androidx.compose.runtime.Composable\nimport androidx.compose.runtime.LaunchedEffect\nimport androidx.compose.runtime.derivedStateOf\nimport androidx.compose.runtime.getValue\nimport androidx.compose.runtime.remember\nimport androidx.compose.ui.Modifier\nimport com.google.ai.edge.gallery.GalleryTopAppBar\nimport com.google.ai.edge.gallery.data.AppBarAction\nimport com.google.ai.edge.gallery.data.AppBarActionType\nimport com.google.ai.edge.gallery.data.Model\nimport com.google.ai.edge.gallery.data.Task\n\n/** A screen to manage models. */\n@OptIn(ExperimentalMaterial3Api::class)\n@Composable\nfun ModelManager(\n  task: Task,\n  viewModel: ModelManagerViewModel,\n  enableAnimation: Boolean,\n  navigateUp: () -> Unit,\n  onModelClicked: (Model) -> Unit,\n  modifier: Modifier = Modifier,\n) {\n  // Set title based on the task.\n  val title = task.label\n  // Model count.\n  val modelCount by remember {\n    derivedStateOf {\n      val trigger = task.updateTrigger.value\n      if (trigger >= 0) {\n        task.models.size\n      } else {\n        -1\n      }\n    }\n  }\n\n  // Navigate up when there are no models left.\n  LaunchedEffect(modelCount) {\n    if (modelCount == 0) {\n      navigateUp()\n    }\n  }\n\n  // Handle system's edge swipe.\n  BackHandler { navigateUp() }\n\n  Scaffold(\n    modifier = modifier,\n    topBar = {\n      GalleryTopAppBar(\n        title = title,\n        leftAction = AppBarAction(actionType = AppBarActionType.NAVIGATE_UP, actionFn = navigateUp),\n      )\n    },\n  ) { innerPadding ->\n    ModelList(\n      task = task,\n      modelManagerViewModel = viewModel,\n      contentPadding = innerPadding,\n      enableAnimation = enableAnimation,\n      onModelClicked = onModelClicked,\n      onBenchmarkClicked = {},\n      modifier = Modifier.fillMaxSize(),\n    )\n  }\n}\n\n// @Preview\n// @Composable\n// fun ModelManagerPreview() {\n//   val context = LocalContext.current\n\n//   GalleryTheme {\n//     ModelManager(\n//       viewModel = PreviewModelManagerViewModel(context = context),\n//       onModelClicked = {},\n//       task = TASK_TEST1,\n//       navigateUp = {},\n//     )\n//   }\n// }\n"
  },
  {
    "path": "Android/src/app/src/main/java/com/google/ai/edge/gallery/ui/modelmanager/ModelManagerViewModel.kt",
    "content": "/*\n * Copyright 2025 Google LLC\n *\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n *     http://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\n\npackage com.google.ai.edge.gallery.ui.modelmanager\n\nimport android.content.Context\nimport android.util.Log\nimport androidx.activity.result.ActivityResult\nimport androidx.core.net.toUri\nimport androidx.lifecycle.ViewModel\nimport androidx.lifecycle.viewModelScope\nimport com.google.ai.edge.gallery.AppLifecycleProvider\nimport com.google.ai.edge.gallery.BuildConfig\nimport com.google.ai.edge.gallery.R\nimport com.google.ai.edge.gallery.common.ProjectConfig\nimport com.google.ai.edge.gallery.common.getJsonResponse\nimport com.google.ai.edge.gallery.customtasks.common.CustomTask\nimport com.google.ai.edge.gallery.data.Accelerator\nimport com.google.ai.edge.gallery.data.BuiltInTaskId\nimport com.google.ai.edge.gallery.data.Category\nimport com.google.ai.edge.gallery.data.CategoryInfo\nimport com.google.ai.edge.gallery.data.Config\nimport com.google.ai.edge.gallery.data.ConfigKeys\nimport com.google.ai.edge.gallery.data.DataStoreRepository\nimport com.google.ai.edge.gallery.data.DownloadRepository\nimport com.google.ai.edge.gallery.data.EMPTY_MODEL\nimport com.google.ai.edge.gallery.data.IMPORTS_DIR\nimport com.google.ai.edge.gallery.data.Model\nimport com.google.ai.edge.gallery.data.ModelAllowlist\nimport com.google.ai.edge.gallery.data.ModelDownloadStatus\nimport com.google.ai.edge.gallery.data.ModelDownloadStatusType\nimport com.google.ai.edge.gallery.data.NumberSliderConfig\nimport com.google.ai.edge.gallery.data.RuntimeType\nimport com.google.ai.edge.gallery.data.SOC\nimport com.google.ai.edge.gallery.data.TMP_FILE_EXT\nimport com.google.ai.edge.gallery.data.Task\nimport com.google.ai.edge.gallery.data.ValueType\nimport com.google.ai.edge.gallery.data.createLlmChatConfigs\nimport com.google.ai.edge.gallery.proto.AccessTokenData\nimport com.google.ai.edge.gallery.proto.ImportedModel\nimport com.google.ai.edge.gallery.proto.Theme\nimport com.google.gson.Gson\nimport com.google.gson.JsonSyntaxException\nimport dagger.hilt.android.lifecycle.HiltViewModel\nimport dagger.hilt.android.qualifiers.ApplicationContext\nimport java.io.File\nimport java.net.HttpURLConnection\nimport java.net.URL\nimport javax.inject.Inject\nimport kotlin.collections.sortedWith\nimport kotlinx.coroutines.Dispatchers\nimport kotlinx.coroutines.flow.MutableStateFlow\nimport kotlinx.coroutines.flow.asStateFlow\nimport kotlinx.coroutines.flow.update\nimport kotlinx.coroutines.launch\nimport net.openid.appauth.AuthorizationException\nimport net.openid.appauth.AuthorizationRequest\nimport net.openid.appauth.AuthorizationResponse\nimport net.openid.appauth.AuthorizationService\nimport net.openid.appauth.ResponseTypeValues\n\nprivate const val TAG = \"AGModelManagerViewModel\"\nprivate const val TEXT_INPUT_HISTORY_MAX_SIZE = 50\nprivate const val MODEL_ALLOWLIST_FILENAME = \"model_allowlist.json\"\nprivate const val MODEL_ALLOWLIST_TEST_FILENAME = \"model_allowlist_test.json\"\nprivate const val ALLOWLIST_BASE_URL =\n  \"https://raw.githubusercontent.com/google-ai-edge/gallery/refs/heads/main/model_allowlists\"\n\nprivate const val TEST_MODEL_ALLOW_LIST = \"\"\n\ndata class ModelInitializationStatus(\n  val status: ModelInitializationStatusType,\n  var error: String = \"\",\n)\n\nenum class ModelInitializationStatusType {\n  NOT_INITIALIZED,\n  INITIALIZING,\n  INITIALIZED,\n  ERROR,\n}\n\nenum class TokenStatus {\n  NOT_STORED,\n  EXPIRED,\n  NOT_EXPIRED,\n}\n\nenum class TokenRequestResultType {\n  FAILED,\n  SUCCEEDED,\n  USER_CANCELLED,\n}\n\ndata class TokenStatusAndData(val status: TokenStatus, val data: AccessTokenData?)\n\ndata class TokenRequestResult(val status: TokenRequestResultType, val errorMessage: String? = null)\n\ndata class ModelManagerUiState(\n  /** A list of tasks available in the application. */\n  val tasks: List<Task>,\n\n  /** Tasks grouped by category. */\n  val tasksByCategory: Map<String, List<Task>>,\n\n  /** A map that tracks the download status of each model, indexed by model name. */\n  val modelDownloadStatus: Map<String, ModelDownloadStatus>,\n\n  /** A map that tracks the initialization status of each model, indexed by model name. */\n  val modelInitializationStatus: Map<String, ModelInitializationStatus>,\n\n  /** Whether the app is loading and processing the model allowlist. */\n  val loadingModelAllowlist: Boolean = true,\n\n  /** The error message when loading the model allowlist. */\n  val loadingModelAllowlistError: String = \"\",\n\n  /** The currently selected model. */\n  val selectedModel: Model = EMPTY_MODEL,\n\n  /** The history of text inputs entered by the user. */\n  val textInputHistory: List<String> = listOf(),\n  val configValuesUpdateTrigger: Long = 0L,\n  // Updated when model is imported of an imported model is deleted.\n  val modelImportingUpdateTrigger: Long = 0L,\n) {\n  fun isModelInitialized(model: Model): Boolean {\n    return modelInitializationStatus[model.name]?.status ==\n      ModelInitializationStatusType.INITIALIZED\n  }\n\n  fun isModelInitializing(model: Model): Boolean {\n    return modelInitializationStatus[model.name]?.status ==\n      ModelInitializationStatusType.INITIALIZING\n  }\n}\n\nprivate val RESET_CONVERSATION_TURN_COUNT_CONFIG =\n  NumberSliderConfig(\n    key = ConfigKeys.RESET_CONVERSATION_TURN_COUNT,\n    sliderMin = 1f,\n    sliderMax = 30f,\n    defaultValue = 3f,\n    valueType = ValueType.INT,\n  )\n\nprivate val PREDEFINED_LLM_TASK_ORDER =\n  listOf(\n    BuiltInTaskId.LLM_ASK_IMAGE,\n    BuiltInTaskId.LLM_ASK_AUDIO,\n    BuiltInTaskId.LLM_CHAT,\n    BuiltInTaskId.LLM_PROMPT_LAB,\n    BuiltInTaskId.LLM_TINY_GARDEN,\n    BuiltInTaskId.LLM_MOBILE_ACTIONS,\n    // BuiltInTaskId.MP_SCRAPBOOK,\n  )\n\n/**\n * ViewModel responsible for managing models, their download status, and initialization.\n *\n * This ViewModel handles model-related operations such as downloading, deleting, initializing, and\n * cleaning up models. It also manages the UI state for model management, including the list of\n * tasks, models, download statuses, and initialization statuses.\n */\n@HiltViewModel\nopen class ModelManagerViewModel\n@Inject\nconstructor(\n  private val downloadRepository: DownloadRepository,\n  private val dataStoreRepository: DataStoreRepository,\n  private val lifecycleProvider: AppLifecycleProvider,\n  private val customTasks: Set<@JvmSuppressWildcards CustomTask>,\n  @ApplicationContext private val context: Context,\n) : ViewModel() {\n  private val externalFilesDir = context.getExternalFilesDir(null)\n  protected val _uiState = MutableStateFlow(createEmptyUiState())\n  val uiState = _uiState.asStateFlow()\n\n  val authService = AuthorizationService(context)\n  var curAccessToken: String = \"\"\n\n  override fun onCleared() {\n    authService.dispose()\n  }\n\n  fun getTaskById(id: String): Task? {\n    return uiState.value.tasks.find { it.id == id }\n  }\n\n  fun getTasksByIds(ids: Set<String>): List<Task> {\n    return uiState.value.tasks.filter { ids.contains(it.id) }\n  }\n\n  fun getCustomTaskByTaskId(id: String): CustomTask? {\n    return getActiveCustomTasks().find { it.task.id == id }\n  }\n\n  fun getActiveCustomTasks(): List<CustomTask> {\n    return customTasks.filter {\n      true\n    }\n  }\n\n  fun getModelByName(name: String): Model? {\n    for (task in uiState.value.tasks) {\n      for (model in task.models) {\n        if (model.name == name) {\n          return model\n        }\n      }\n    }\n    return null\n  }\n\n  fun getAllModels(): List<Model> {\n    val allModels = mutableSetOf<Model>()\n    for (task in uiState.value.tasks) {\n      for (model in task.models) {\n        allModels.add(model)\n      }\n    }\n    return allModels.toList().sortedBy { it.displayName.ifEmpty { it.name } }\n  }\n\n  fun getAllDownloadedModels(): List<Model> {\n    return getAllModels().filter {\n      uiState.value.modelDownloadStatus[it.name]?.status == ModelDownloadStatusType.SUCCEEDED &&\n        it.isLlm\n    }\n  }\n\n  fun processTasks() {\n    val curTasks = getActiveCustomTasks().map { it.task }\n    for (task in curTasks) {\n      for (model in task.models) {\n        model.preProcess()\n      }\n      // Move the model that is best for this task to the front.\n      val bestModel = task.models.find { it.bestForTaskIds.contains(task.id) }\n      if (bestModel != null) {\n        task.models.remove(bestModel)\n        task.models.add(0, bestModel)\n      }\n    }\n  }\n\n  fun updateConfigValuesUpdateTrigger() {\n    _uiState.update { _uiState.value.copy(configValuesUpdateTrigger = System.currentTimeMillis()) }\n  }\n\n  fun selectModel(model: Model) {\n    if (_uiState.value.selectedModel.name != model.name) {\n      _uiState.update { _uiState.value.copy(selectedModel = model) }\n    }\n  }\n\n  fun downloadModel(task: Task?, model: Model) {\n    // Update status.\n    setDownloadStatus(\n      curModel = model,\n      status = ModelDownloadStatus(status = ModelDownloadStatusType.IN_PROGRESS),\n    )\n\n    // Delete the model files first.\n    deleteModel(model = model)\n\n    // Start to send download request.\n    downloadRepository.downloadModel(\n      task = task,\n      model = model,\n      onStatusUpdated = this::setDownloadStatus,\n    )\n  }\n\n  fun cancelDownloadModel(model: Model) {\n    downloadRepository.cancelDownloadModel(model)\n    deleteModel(model = model)\n  }\n\n  fun deleteModel(model: Model) {\n    if (model.imported) {\n      deleteFilesFromImportDir(model.downloadFileName)\n    } else {\n      deleteDirFromExternalFilesDir(model.normalizedName)\n    }\n\n    // Update model download status to NotDownloaded.\n    val curModelDownloadStatus = uiState.value.modelDownloadStatus.toMutableMap()\n    curModelDownloadStatus[model.name] =\n      ModelDownloadStatus(status = ModelDownloadStatusType.NOT_DOWNLOADED)\n\n    // Delete model from the list if model is imported as a local model.\n    if (model.imported) {\n      for (curTask in uiState.value.tasks) {\n        val index = curTask.models.indexOf(model)\n        if (index >= 0) {\n          curTask.models.removeAt(index)\n        }\n        curTask.updateTrigger.value = System.currentTimeMillis()\n      }\n      curModelDownloadStatus.remove(model.name)\n\n      // Update data store.\n      val importedModels = dataStoreRepository.readImportedModels().toMutableList()\n      val importedModelIndex = importedModels.indexOfFirst { it.fileName == model.name }\n      if (importedModelIndex >= 0) {\n        importedModels.removeAt(importedModelIndex)\n      }\n      dataStoreRepository.saveImportedModels(importedModels = importedModels)\n    }\n    val newUiState =\n      uiState.value.copy(\n        modelDownloadStatus = curModelDownloadStatus,\n        tasks = uiState.value.tasks.toList(),\n        modelImportingUpdateTrigger = System.currentTimeMillis(),\n      )\n    _uiState.update { newUiState }\n  }\n\n  fun initializeModel(\n    context: Context,\n    task: Task,\n    model: Model,\n    force: Boolean = false,\n    onDone: () -> Unit = {},\n  ) {\n    viewModelScope.launch(Dispatchers.Default) {\n      // Skip if initialized already.\n      if (\n        !force &&\n          uiState.value.modelInitializationStatus[model.name]?.status ==\n            ModelInitializationStatusType.INITIALIZED\n      ) {\n        Log.d(TAG, \"Model '${model.name}' has been initialized. Skipping.\")\n        return@launch\n      }\n\n      // Skip if initialization is in progress.\n      if (model.initializing) {\n        model.cleanUpAfterInit = false\n        Log.d(TAG, \"Model '${model.name}' is being initialized. Skipping.\")\n        return@launch\n      }\n\n      // Clean up.\n      cleanupModel(context = context, task = task, model = model)\n\n      // Start initialization.\n      Log.d(TAG, \"Initializing model '${model.name}'...\")\n      model.initializing = true\n      updateModelInitializationStatus(\n        model = model,\n        status = ModelInitializationStatusType.INITIALIZING,\n      )\n\n      val onDoneFn: (error: String) -> Unit = { error ->\n        model.initializing = false\n        if (model.instance != null) {\n          Log.d(TAG, \"Model '${model.name}' initialized successfully\")\n          updateModelInitializationStatus(\n            model = model,\n            status = ModelInitializationStatusType.INITIALIZED,\n          )\n          if (model.cleanUpAfterInit) {\n            Log.d(TAG, \"Model '${model.name}' needs cleaning up after init.\")\n            cleanupModel(context = context, task = task, model = model)\n          }\n          onDone()\n        } else if (error.isNotEmpty()) {\n          Log.d(TAG, \"Model '${model.name}' failed to initialize\")\n          updateModelInitializationStatus(\n            model = model,\n            status = ModelInitializationStatusType.ERROR,\n            error = error,\n          )\n        }\n      }\n\n      // Call the model initialization function.\n      getCustomTaskByTaskId(id = task.id)\n        ?.initializeModelFn(\n          context = context,\n          coroutineScope = viewModelScope,\n          model = model,\n          onDone = onDoneFn,\n        )\n    }\n  }\n\n  fun cleanupModel(\n    context: Context,\n    task: Task,\n    model: Model,\n    instanceToCleanUp: Any? = model.instance,\n    onDone: () -> Unit = {},\n  ) {\n    if (instanceToCleanUp != null && instanceToCleanUp !== model.instance) {\n      Log.d(TAG, \"Stale cleanup request for ${model.name}. Aborting.\")\n      onDone()\n      return\n    }\n\n    if (model.instance != null) {\n      model.cleanUpAfterInit = false\n      Log.d(TAG, \"Cleaning up model '${model.name}'...\")\n      val onDoneFn: () -> Unit = {\n        model.instance = null\n        model.initializing = false\n        updateModelInitializationStatus(\n          model = model,\n          status = ModelInitializationStatusType.NOT_INITIALIZED,\n        )\n        Log.d(TAG, \"Clean up model '${model.name}' done\")\n        onDone()\n      }\n      getCustomTaskByTaskId(id = task.id)\n        ?.cleanUpModelFn(\n          context = context,\n          coroutineScope = viewModelScope,\n          model = model,\n          onDone = onDoneFn,\n        )\n    } else {\n      // When model is being initialized and we are trying to clean it up at same time, we mark it\n      // to clean up and it will be cleaned up after initialization is done.\n      if (model.initializing) {\n        Log.d(\n          TAG,\n          \"Model '${model.name}' is still initializing.. Will clean up after it is done initializing\",\n        )\n        model.cleanUpAfterInit = true\n      }\n    }\n  }\n\n  fun setDownloadStatus(curModel: Model, status: ModelDownloadStatus) {\n    // Update model download progress.\n    val curModelDownloadStatus = uiState.value.modelDownloadStatus.toMutableMap()\n    curModelDownloadStatus[curModel.name] = status\n    val newUiState = uiState.value.copy(modelDownloadStatus = curModelDownloadStatus)\n\n    // Delete downloaded file if status is failed or not_downloaded.\n    if (\n      status.status == ModelDownloadStatusType.FAILED ||\n        status.status == ModelDownloadStatusType.NOT_DOWNLOADED\n    ) {\n      deleteFileFromExternalFilesDir(curModel.downloadFileName)\n    }\n\n    _uiState.update { newUiState }\n  }\n\n  fun setInitializationStatus(model: Model, status: ModelInitializationStatus) {\n    val curStatus = uiState.value.modelInitializationStatus.toMutableMap()\n    if (curStatus.containsKey(model.name)) {\n      curStatus[model.name] = status\n      _uiState.update { _uiState.value.copy(modelInitializationStatus = curStatus) }\n    }\n  }\n\n  fun addTextInputHistory(text: String) {\n    if (uiState.value.textInputHistory.indexOf(text) < 0) {\n      val newHistory = uiState.value.textInputHistory.toMutableList()\n      newHistory.add(0, text)\n      if (newHistory.size > TEXT_INPUT_HISTORY_MAX_SIZE) {\n        newHistory.removeAt(newHistory.size - 1)\n      }\n      _uiState.update { _uiState.value.copy(textInputHistory = newHistory) }\n      dataStoreRepository.saveTextInputHistory(_uiState.value.textInputHistory)\n    } else {\n      promoteTextInputHistoryItem(text)\n    }\n  }\n\n  fun promoteTextInputHistoryItem(text: String) {\n    val index = uiState.value.textInputHistory.indexOf(text)\n    if (index >= 0) {\n      val newHistory = uiState.value.textInputHistory.toMutableList()\n      newHistory.removeAt(index)\n      newHistory.add(0, text)\n      _uiState.update { _uiState.value.copy(textInputHistory = newHistory) }\n      dataStoreRepository.saveTextInputHistory(_uiState.value.textInputHistory)\n    }\n  }\n\n  fun deleteTextInputHistory(text: String) {\n    val index = uiState.value.textInputHistory.indexOf(text)\n    if (index >= 0) {\n      val newHistory = uiState.value.textInputHistory.toMutableList()\n      newHistory.removeAt(index)\n      _uiState.update { _uiState.value.copy(textInputHistory = newHistory) }\n      dataStoreRepository.saveTextInputHistory(_uiState.value.textInputHistory)\n    }\n  }\n\n  fun clearTextInputHistory() {\n    _uiState.update { _uiState.value.copy(textInputHistory = mutableListOf()) }\n    dataStoreRepository.saveTextInputHistory(_uiState.value.textInputHistory)\n  }\n\n  fun readThemeOverride(): Theme {\n    return dataStoreRepository.readTheme()\n  }\n\n  fun saveThemeOverride(theme: Theme) {\n    dataStoreRepository.saveTheme(theme = theme)\n  }\n\n  fun getModelUrlResponse(model: Model, accessToken: String? = null): Int {\n    try {\n      val url = URL(model.url)\n      val connection = url.openConnection() as HttpURLConnection\n      if (accessToken != null) {\n        connection.setRequestProperty(\"Authorization\", \"Bearer $accessToken\")\n      }\n      connection.connect()\n\n      // Report the result.\n      return connection.responseCode\n    } catch (e: Exception) {\n      Log.e(TAG, \"$e\")\n      return -1\n    }\n  }\n\n  fun addImportedLlmModel(info: ImportedModel) {\n    Log.d(TAG, \"adding imported llm model: $info\")\n\n    // Create model.\n    val model = createModelFromImportedModelInfo(info = info)\n\n    val setOfTasks =\n      mutableSetOf(\n        BuiltInTaskId.LLM_CHAT,\n        BuiltInTaskId.LLM_ASK_IMAGE,\n        BuiltInTaskId.LLM_ASK_AUDIO,\n        BuiltInTaskId.LLM_PROMPT_LAB,\n        BuiltInTaskId.LLM_TINY_GARDEN,\n        BuiltInTaskId.LLM_MOBILE_ACTIONS,\n      )\n    for (task in getTasksByIds(ids = setOfTasks)) {\n      // Remove duplicated imported model if existed.\n      val modelIndex = task.models.indexOfFirst { info.fileName == it.name && it.imported }\n      if (modelIndex >= 0) {\n        Log.d(TAG, \"duplicated imported model found in task. Removing it first\")\n        task.models.removeAt(modelIndex)\n      }\n      if (\n        (task.id == BuiltInTaskId.LLM_ASK_IMAGE && model.llmSupportImage) ||\n          (task.id == BuiltInTaskId.LLM_ASK_AUDIO && model.llmSupportAudio) ||\n          (task.id == BuiltInTaskId.LLM_TINY_GARDEN && model.llmSupportTinyGarden) ||\n          (task.id == BuiltInTaskId.LLM_MOBILE_ACTIONS && model.llmSupportMobileActions) ||\n          (task.id != BuiltInTaskId.LLM_ASK_IMAGE &&\n            task.id != BuiltInTaskId.LLM_ASK_AUDIO &&\n            task.id != BuiltInTaskId.LLM_TINY_GARDEN &&\n            task.id != BuiltInTaskId.LLM_MOBILE_ACTIONS)\n      ) {\n        task.models.add(model)\n        if (task.id == BuiltInTaskId.LLM_TINY_GARDEN) {\n          val newConfigs = model.configs.toMutableList()\n          newConfigs.add(RESET_CONVERSATION_TURN_COUNT_CONFIG)\n          model.configs = newConfigs\n          model.preProcess()\n        }\n      }\n      task.updateTrigger.value = System.currentTimeMillis()\n    }\n\n    // Add initial status and states.\n    val modelDownloadStatus = uiState.value.modelDownloadStatus.toMutableMap()\n    val modelInstances = uiState.value.modelInitializationStatus.toMutableMap()\n    modelDownloadStatus[model.name] =\n      ModelDownloadStatus(\n        status = ModelDownloadStatusType.SUCCEEDED,\n        receivedBytes = info.fileSize,\n        totalBytes = info.fileSize,\n      )\n    modelInstances[model.name] =\n      ModelInitializationStatus(status = ModelInitializationStatusType.NOT_INITIALIZED)\n\n    // Update ui state.\n    _uiState.update {\n      uiState.value.copy(\n        tasks = uiState.value.tasks.toList(),\n        modelDownloadStatus = modelDownloadStatus,\n        modelInitializationStatus = modelInstances,\n        modelImportingUpdateTrigger = System.currentTimeMillis(),\n      )\n    }\n\n    // Add to data store.\n    val importedModels = dataStoreRepository.readImportedModels().toMutableList()\n    val importedModelIndex = importedModels.indexOfFirst { info.fileName == it.fileName }\n    if (importedModelIndex >= 0) {\n      Log.d(TAG, \"duplicated imported model found in data store. Removing it first\")\n      importedModels.removeAt(importedModelIndex)\n    }\n    importedModels.add(info)\n    dataStoreRepository.saveImportedModels(importedModels = importedModels)\n  }\n\n  fun getTokenStatusAndData(): TokenStatusAndData {\n    // Try to load token data from DataStore.\n    var tokenStatus = TokenStatus.NOT_STORED\n    Log.d(TAG, \"Reading token data from data store...\")\n    val tokenData = dataStoreRepository.readAccessTokenData()\n\n    // Token exists.\n    if (tokenData != null && tokenData.accessToken.isNotEmpty()) {\n      Log.d(TAG, \"Token exists and loaded.\")\n\n      // Check expiration (with 5-minute buffer).\n      val curTs = System.currentTimeMillis()\n      val expirationTs = tokenData.expiresAtMs - 5 * 60\n      Log.d(\n        TAG,\n        \"Checking whether token has expired or not. Current ts: $curTs, expires at: $expirationTs\",\n      )\n      if (curTs >= expirationTs) {\n        Log.d(TAG, \"Token expired!\")\n        tokenStatus = TokenStatus.EXPIRED\n      } else {\n        Log.d(TAG, \"Token not expired.\")\n        tokenStatus = TokenStatus.NOT_EXPIRED\n        curAccessToken = tokenData.accessToken\n      }\n    } else {\n      Log.d(TAG, \"Token doesn't exists.\")\n    }\n\n    return TokenStatusAndData(status = tokenStatus, data = tokenData)\n  }\n\n  fun getAuthorizationRequest(): AuthorizationRequest {\n    return AuthorizationRequest.Builder(\n        ProjectConfig.authServiceConfig,\n        ProjectConfig.clientId,\n        ResponseTypeValues.CODE,\n        ProjectConfig.redirectUri.toUri(),\n      )\n      .setScope(\"read-repos\")\n      .build()\n  }\n\n  fun handleAuthResult(result: ActivityResult, onTokenRequested: (TokenRequestResult) -> Unit) {\n    val dataIntent = result.data\n    if (dataIntent == null) {\n      onTokenRequested(\n        TokenRequestResult(\n          status = TokenRequestResultType.FAILED,\n          errorMessage = \"Empty auth result\",\n        )\n      )\n      return\n    }\n\n    val response = AuthorizationResponse.fromIntent(dataIntent)\n    val exception = AuthorizationException.fromIntent(dataIntent)\n\n    when {\n      response?.authorizationCode != null -> {\n        // Authorization successful, exchange the code for tokens\n        var errorMessage: String? = null\n        authService.performTokenRequest(response.createTokenExchangeRequest()) {\n          tokenResponse,\n          tokenEx ->\n          if (tokenResponse != null) {\n            if (tokenResponse.accessToken == null) {\n              errorMessage = \"Empty access token\"\n            } else if (tokenResponse.refreshToken == null) {\n              errorMessage = \"Empty refresh token\"\n            } else if (tokenResponse.accessTokenExpirationTime == null) {\n              errorMessage = \"Empty expiration time\"\n            } else {\n              // Token exchange successful. Store the tokens securely\n              Log.d(TAG, \"Token exchange successful. Storing tokens...\")\n              saveAccessToken(\n                accessToken = tokenResponse.accessToken!!,\n                refreshToken = tokenResponse.refreshToken!!,\n                expiresAt = tokenResponse.accessTokenExpirationTime!!,\n              )\n              curAccessToken = tokenResponse.accessToken!!\n              Log.d(TAG, \"Token successfully saved.\")\n            }\n          } else if (tokenEx != null) {\n            errorMessage = \"Token exchange failed: ${tokenEx.message}\"\n          } else {\n            errorMessage = \"Token exchange failed\"\n          }\n          if (errorMessage == null) {\n            onTokenRequested(TokenRequestResult(status = TokenRequestResultType.SUCCEEDED))\n          } else {\n            onTokenRequested(\n              TokenRequestResult(\n                status = TokenRequestResultType.FAILED,\n                errorMessage = errorMessage,\n              )\n            )\n          }\n        }\n      }\n\n      exception != null -> {\n        onTokenRequested(\n          TokenRequestResult(\n            status =\n              if (exception.message == \"User cancelled flow\") TokenRequestResultType.USER_CANCELLED\n              else TokenRequestResultType.FAILED,\n            errorMessage = exception.message,\n          )\n        )\n      }\n\n      else -> {\n        onTokenRequested(TokenRequestResult(status = TokenRequestResultType.USER_CANCELLED))\n      }\n    }\n  }\n\n  fun saveAccessToken(accessToken: String, refreshToken: String, expiresAt: Long) {\n    dataStoreRepository.saveAccessTokenData(\n      accessToken = accessToken,\n      refreshToken = refreshToken,\n      expiresAt = expiresAt,\n    )\n  }\n\n  fun clearAccessToken() {\n    dataStoreRepository.clearAccessTokenData()\n  }\n\n  private fun processPendingDownloads() {\n    // Cancel all pending downloads for the retrieved models.\n    downloadRepository.cancelAll {\n      Log.d(TAG, \"All workers are cancelled.\")\n\n      viewModelScope.launch(Dispatchers.Main) {\n        val checkedModelNames = mutableSetOf<String>()\n        val tokenStatusAndData = getTokenStatusAndData()\n        for (task in uiState.value.tasks) {\n          for (model in task.models) {\n            if (checkedModelNames.contains(model.name)) {\n              continue\n            }\n\n            // Start download for partially downloaded models.\n            val downloadStatus = uiState.value.modelDownloadStatus[model.name]?.status\n            if (downloadStatus == ModelDownloadStatusType.PARTIALLY_DOWNLOADED) {\n              if (\n                tokenStatusAndData.status == TokenStatus.NOT_EXPIRED &&\n                  tokenStatusAndData.data != null\n              ) {\n                model.accessToken = tokenStatusAndData.data.accessToken\n              }\n              Log.d(TAG, \"Sending a new download request for '${model.name}'\")\n              downloadRepository.downloadModel(\n                task = task,\n                model = model,\n                onStatusUpdated = this@ModelManagerViewModel::setDownloadStatus,\n              )\n            }\n\n            checkedModelNames.add(model.name)\n          }\n        }\n      }\n    }\n  }\n\n  fun loadModelAllowlist() {\n    _uiState.update {\n      uiState.value.copy(loadingModelAllowlist = true, loadingModelAllowlistError = \"\")\n    }\n\n    viewModelScope.launch(Dispatchers.IO) {\n      try {\n        // Load model allowlist json.\n        var modelAllowlist: ModelAllowlist? = null\n\n        // Try to read the test allowlist first.\n        Log.d(TAG, \"Loading test model allowlist.\")\n        modelAllowlist = readModelAllowlistFromDisk(fileName = MODEL_ALLOWLIST_TEST_FILENAME)\n\n        // Local test only.\n        if (TEST_MODEL_ALLOW_LIST.isNotEmpty()) {\n          Log.d(TAG, \"Loading local model allowlist for testing.\")\n          val gson = Gson()\n          try {\n            modelAllowlist = gson.fromJson(TEST_MODEL_ALLOW_LIST, ModelAllowlist::class.java)\n          } catch (e: JsonSyntaxException) {\n            Log.e(TAG, \"Failed to parse local test json\", e)\n          }\n        }\n\n        if (modelAllowlist == null) {\n          // Load from github.\n          var version = BuildConfig.VERSION_NAME.replace(\".\", \"_\")\n          val url = getAllowlistUrl(version)\n          Log.d(TAG, \"Loading model allowlist from internet. Url: $url\")\n          val data = getJsonResponse<ModelAllowlist>(url = url)\n          modelAllowlist = data?.jsonObj\n\n          if (modelAllowlist == null) {\n            Log.w(TAG, \"Failed to load model allowlist from internet. Trying to load it from disk\")\n            modelAllowlist = readModelAllowlistFromDisk()\n          } else {\n            Log.d(TAG, \"Done: loading model allowlist from internet\")\n            saveModelAllowlistToDisk(modelAllowlistContent = data?.textContent ?: \"{}\")\n          }\n        }\n\n        if (modelAllowlist == null) {\n          _uiState.update {\n            uiState.value.copy(loadingModelAllowlistError = \"Failed to load model list\")\n          }\n          return@launch\n        }\n\n        Log.d(TAG, \"Allowlist: $modelAllowlist\")\n\n        // Convert models in the allowlist.\n        val curTasks = getActiveCustomTasks().map { it.task }\n        val nameToModel = mutableMapOf<String, Model>()\n        for (allowedModel in modelAllowlist.models) {\n          if (allowedModel.disabled == true) {\n            continue\n          }\n\n          // Ignore the allowedModel if its accelerator is only npu and this device's soc is not in\n          // its socToModelFiles.\n          val accelerators = allowedModel.defaultConfig.accelerators ?: \"\"\n          val acceleratorList = accelerators.split(\",\").map { it.trim() }.filter { it.isNotEmpty() }\n          if (acceleratorList.size == 1 && acceleratorList[0] == \"npu\") {\n            val socToModelFiles = allowedModel.socToModelFiles\n            if (socToModelFiles != null && !socToModelFiles.containsKey(SOC)) {\n              Log.d(\n                TAG,\n                \"Ignoring model '${allowedModel.name}' because it's NPU-only and not supported on SOC: $SOC\",\n              )\n              continue\n            }\n          }\n\n          val model = allowedModel.toModel()\n          nameToModel.put(model.name, model)\n          for (taskType in allowedModel.taskTypes) {\n            val task = curTasks.find { it.id == taskType }\n            task?.models?.add(model)\n\n            if (task?.id == BuiltInTaskId.LLM_TINY_GARDEN) {\n              val newConfigs = model.configs.toMutableList()\n              newConfigs.add(RESET_CONVERSATION_TURN_COUNT_CONFIG)\n              model.configs = newConfigs\n            }\n          }\n        }\n\n        // Find models from allowlist if a task's `modelNames` field is not empty.\n        for (task in curTasks) {\n          if (task.modelNames.isNotEmpty()) {\n            for (modelName in task.modelNames) {\n              val model = nameToModel[modelName]\n              if (model == null) {\n                Log.w(TAG, \"Model '${modelName}' in task '${task.label}' not found in allowlist.\")\n                continue\n              }\n              task.models.add(model)\n            }\n          }\n        }\n\n        // Process all tasks.\n        processTasks()\n\n        // Update UI state.\n        _uiState.update {\n          createUiState()\n            .copy(\n              loadingModelAllowlist = false,\n              tasks = curTasks,\n              tasksByCategory = groupTasksByCategory(),\n            )\n        }\n\n        // Process pending downloads.\n        processPendingDownloads()\n      } catch (e: Exception) {\n        e.printStackTrace()\n      }\n    }\n  }\n\n  fun clearLoadModelAllowlistError() {\n    val curTasks = getActiveCustomTasks().map { it.task }\n    processTasks()\n    _uiState.update {\n      createUiState()\n        .copy(\n          loadingModelAllowlist = false,\n          tasks = curTasks,\n          loadingModelAllowlistError = \"\",\n          tasksByCategory = groupTasksByCategory(),\n        )\n    }\n  }\n\n  fun setAppInForeground(foreground: Boolean) {\n    lifecycleProvider.isAppInForeground = foreground\n  }\n\n  private fun saveModelAllowlistToDisk(modelAllowlistContent: String) {\n    try {\n      Log.d(TAG, \"Saving model allowlist to disk...\")\n      val file = File(externalFilesDir, MODEL_ALLOWLIST_FILENAME)\n      file.writeText(modelAllowlistContent)\n      Log.d(TAG, \"Done: saving model allowlist to disk.\")\n    } catch (e: Exception) {\n      Log.e(TAG, \"failed to write model allowlist to disk\", e)\n    }\n  }\n\n  private fun readModelAllowlistFromDisk(\n    fileName: String = MODEL_ALLOWLIST_FILENAME\n  ): ModelAllowlist? {\n    try {\n      Log.d(TAG, \"Reading model allowlist from disk: $fileName\")\n      val baseDir =\n        if (fileName == MODEL_ALLOWLIST_TEST_FILENAME) File(\"/data/local/tmp\") else externalFilesDir\n      val file = File(baseDir, fileName)\n      if (file.exists()) {\n        val content = file.readText()\n        Log.d(TAG, \"Model allowlist content from local file: $content\")\n\n        val gson = Gson()\n        return gson.fromJson(content, ModelAllowlist::class.java)\n      }\n    } catch (e: Exception) {\n      Log.e(TAG, \"failed to read model allowlist from disk\", e)\n      return null\n    }\n\n    return null\n  }\n\n  private fun isModelPartiallyDownloaded(model: Model): Boolean {\n    if (model.localModelFilePathOverride.isNotEmpty()) {\n      return false\n    }\n\n    // A model is partially downloaded when the tmp file exists.\n    val tmpFilePath =\n      model.getPath(context = context, fileName = \"${model.downloadFileName}.$TMP_FILE_EXT\")\n    return File(tmpFilePath).exists()\n  }\n\n  private fun createEmptyUiState(): ModelManagerUiState {\n    return ModelManagerUiState(\n      tasks = listOf(),\n      tasksByCategory = mapOf(),\n      modelDownloadStatus = mapOf(),\n      modelInitializationStatus = mapOf(),\n    )\n  }\n\n  private fun createUiState(): ModelManagerUiState {\n    val modelDownloadStatus: MutableMap<String, ModelDownloadStatus> = mutableMapOf()\n    val modelInstances: MutableMap<String, ModelInitializationStatus> = mutableMapOf()\n    val tasks: MutableMap<String, Task> = mutableMapOf()\n    val checkedModelNames = mutableSetOf<String>()\n    for (customTask in getActiveCustomTasks()) {\n      val task = customTask.task\n      tasks.put(key = task.id, value = task)\n      for (model in task.models) {\n        if (checkedModelNames.contains(model.name)) {\n          continue\n        }\n        modelDownloadStatus[model.name] = getModelDownloadStatus(model = model)\n        modelInstances[model.name] =\n          ModelInitializationStatus(status = ModelInitializationStatusType.NOT_INITIALIZED)\n        checkedModelNames.add(model.name)\n      }\n    }\n\n    // Load imported models.\n    for (importedModel in dataStoreRepository.readImportedModels()) {\n      Log.d(TAG, \"stored imported model: $importedModel\")\n\n      // Create model.\n      val model = createModelFromImportedModelInfo(info = importedModel)\n\n      // Add to task.\n      tasks.get(key = BuiltInTaskId.LLM_CHAT)?.models?.add(model)\n      tasks.get(key = BuiltInTaskId.LLM_PROMPT_LAB)?.models?.add(model)\n      if (model.llmSupportImage) {\n        tasks.get(key = BuiltInTaskId.LLM_ASK_IMAGE)?.models?.add(model)\n      }\n      if (model.llmSupportAudio) {\n        tasks.get(key = BuiltInTaskId.LLM_ASK_AUDIO)?.models?.add(model)\n      }\n      if (model.llmSupportTinyGarden) {\n        tasks.get(key = BuiltInTaskId.LLM_TINY_GARDEN)?.models?.add(model)\n        val newConfigs = model.configs.toMutableList()\n        newConfigs.add(RESET_CONVERSATION_TURN_COUNT_CONFIG)\n        model.configs = newConfigs\n        model.preProcess()\n      }\n      if (model.llmSupportMobileActions) {\n        tasks.get(key = BuiltInTaskId.LLM_MOBILE_ACTIONS)?.models?.add(model)\n      }\n\n      // Update status.\n      modelDownloadStatus[model.name] =\n        ModelDownloadStatus(\n          status = ModelDownloadStatusType.SUCCEEDED,\n          receivedBytes = importedModel.fileSize,\n          totalBytes = importedModel.fileSize,\n        )\n    }\n\n    val textInputHistory = dataStoreRepository.readTextInputHistory()\n    Log.d(TAG, \"text input history: $textInputHistory\")\n\n    Log.d(TAG, \"model download status: $modelDownloadStatus\")\n    return ModelManagerUiState(\n      tasks = getActiveCustomTasks().map { it.task }.toList(),\n      tasksByCategory = mapOf(),\n      modelDownloadStatus = modelDownloadStatus,\n      modelInitializationStatus = modelInstances,\n      textInputHistory = textInputHistory,\n    )\n  }\n\n  private fun createModelFromImportedModelInfo(info: ImportedModel): Model {\n    val accelerators: MutableList<Accelerator> =\n      info.llmConfig.compatibleAcceleratorsList\n        .mapNotNull { acceleratorLabel ->\n          when (acceleratorLabel.trim()) {\n            Accelerator.GPU.label -> Accelerator.GPU\n            Accelerator.CPU.label -> Accelerator.CPU\n            Accelerator.NPU.label -> Accelerator.NPU\n            else -> null // Ignore unknown accelerator labels\n          }\n        }\n        .toMutableList()\n    val llmMaxToken = info.llmConfig.defaultMaxTokens\n    val configs: MutableList<Config> =\n      createLlmChatConfigs(\n          defaultMaxToken = llmMaxToken,\n          defaultTopK = info.llmConfig.defaultTopk,\n          defaultTopP = info.llmConfig.defaultTopp,\n          defaultTemperature = info.llmConfig.defaultTemperature,\n          accelerators = accelerators,\n        )\n        .toMutableList()\n    val llmSupportImage = info.llmConfig.supportImage\n    val llmSupportAudio = info.llmConfig.supportAudio\n    val llmSupportTinyGarden = info.llmConfig.supportTinyGarden\n    val llmSupportMobileActions = info.llmConfig.supportMobileActions\n    val model =\n      Model(\n        name = info.fileName,\n        url = \"\",\n        configs = configs,\n        sizeInBytes = info.fileSize,\n        downloadFileName = \"$IMPORTS_DIR/${info.fileName}\",\n        showBenchmarkButton = false,\n        showRunAgainButton = false,\n        imported = true,\n        llmSupportImage = llmSupportImage,\n        llmSupportAudio = llmSupportAudio,\n        llmSupportTinyGarden = llmSupportTinyGarden,\n        llmSupportMobileActions = llmSupportMobileActions,\n        llmMaxToken = llmMaxToken,\n        accelerators = accelerators,\n        // We assume all imported models are LLM for now.\n        isLlm = true,\n      )\n    model.preProcess()\n\n    return model\n  }\n\n  private fun groupTasksByCategory(): Map<String, List<Task>> {\n    val tasks = getActiveCustomTasks().map { it.task }\n\n    val categoryMap: Map<String, CategoryInfo> =\n      tasks.associateBy { it.category.id }.mapValues { it.value.category }\n\n    val groupedTasks = tasks.groupBy { it.category.id }\n    val groupedSortedTasks: MutableMap<String, List<Task>> = mutableMapOf()\n    // Sort the tasks in categories by pre-defined order. Sort other tasks by label.\n    for (categoryId in groupedTasks.keys) {\n      val sortedTasks =\n        groupedTasks[categoryId]!!.sortedWith { a, b ->\n          if (categoryId == Category.LLM.id) {\n            val order: List<String> =\n              when (categoryId) {\n                Category.LLM.id -> PREDEFINED_LLM_TASK_ORDER\n                else -> listOf()\n              }\n            val indexA = order.indexOf(a.id)\n            val indexB = order.indexOf(b.id)\n            if (indexA != -1 && indexB != -1) {\n              indexA.compareTo(indexB)\n            } else if (indexA != -1) {\n              -1\n            } else if (indexB != -1) {\n              1\n            } else {\n              val ca = categoryMap[a.id]!!\n              val cb = categoryMap[b.id]!!\n              val caLabel = getCategoryLabel(context = context, category = ca)\n              val cbLabel = getCategoryLabel(context = context, category = cb)\n              caLabel.compareTo(cbLabel)\n            }\n          } else {\n            a.label.compareTo(b.label)\n          }\n        }\n      for ((index, task) in sortedTasks.withIndex()) {\n        task.index = index\n      }\n      groupedSortedTasks[categoryId] = sortedTasks\n    }\n\n    return groupedSortedTasks\n  }\n\n  private fun getCategoryLabel(context: Context, category: CategoryInfo): String {\n    val stringRes = category.labelStringRes\n    val label = category.label\n    if (stringRes != null) {\n      return context.getString(stringRes)\n    } else if (label != null) {\n      return label\n    }\n    return context.getString(R.string.category_unlabeled)\n  }\n\n  /**\n   * Retrieves the download status of a model.\n   *\n   * This function determines the download status of a given model by checking if it's fully\n   * downloaded, partially downloaded, or not downloaded at all. It also retrieves the received and\n   * total bytes for partially downloaded models.\n   */\n  private fun getModelDownloadStatus(model: Model): ModelDownloadStatus {\n    Log.d(TAG, \"Checking model ${model.name} download status...\")\n\n    if (model.localFileRelativeDirPathOverride.isNotEmpty()) {\n      Log.d(TAG, \"Model has localFileRelativeDirPathOverride set. Set status to SUCCEEDED\")\n      return ModelDownloadStatus(\n        status = ModelDownloadStatusType.SUCCEEDED,\n        receivedBytes = 0,\n        totalBytes = 0,\n      )\n    }\n\n    var status = ModelDownloadStatusType.NOT_DOWNLOADED\n    var receivedBytes = 0L\n    var totalBytes = 0L\n\n    // Partially downloaded.\n    if (isModelPartiallyDownloaded(model = model)) {\n      status = ModelDownloadStatusType.PARTIALLY_DOWNLOADED\n      val tmpFilePath =\n        model.getPath(context = context, fileName = \"${model.downloadFileName}.$TMP_FILE_EXT\")\n      val tmpFile = File(tmpFilePath)\n      receivedBytes = tmpFile.length()\n      totalBytes = model.totalBytes\n      Log.d(TAG, \"${model.name} is partially downloaded. $receivedBytes/$totalBytes\")\n    }\n    // Fully downloaded.\n    else if (isModelDownloaded(model = model)) {\n      status = ModelDownloadStatusType.SUCCEEDED\n      Log.d(TAG, \"${model.name} has been downloaded.\")\n    }\n    // Not downloaded.\n    else {\n      Log.d(TAG, \"${model.name} has not been downloaded.\")\n    }\n\n    return ModelDownloadStatus(\n      status = status,\n      receivedBytes = receivedBytes,\n      totalBytes = totalBytes,\n    )\n  }\n\n  private fun isFileInExternalFilesDir(fileName: String): Boolean {\n    if (externalFilesDir != null) {\n      val file = File(externalFilesDir, fileName)\n      return file.exists()\n    } else {\n      return false\n    }\n  }\n\n  private fun isFileInDataLocalTmpDir(fileName: String): Boolean {\n    val file = File(\"/data/local/tmp\", fileName)\n    return file.exists()\n  }\n\n  private fun deleteFileFromExternalFilesDir(fileName: String) {\n    if (isFileInExternalFilesDir(fileName)) {\n      val file = File(externalFilesDir, fileName)\n      file.delete()\n    }\n  }\n\n  /**\n   * Deletes files from the the model imports directory whose absolute paths start with a given\n   * prefix.\n   */\n  private fun deleteFilesFromImportDir(fileName: String) {\n    val dir = context.getExternalFilesDir(null) ?: return\n\n    val prefixAbsolutePath = \"${context.getExternalFilesDir(null)}${File.separator}$fileName\"\n    val filesToDelete =\n      File(dir, IMPORTS_DIR).listFiles { dirFile, name ->\n        File(dirFile, name).absolutePath.startsWith(prefixAbsolutePath)\n      } ?: arrayOf()\n    for (file in filesToDelete) {\n      Log.d(TAG, \"Deleting file: ${file.name}\")\n      file.delete()\n    }\n  }\n\n  private fun deleteDirFromExternalFilesDir(dir: String) {\n    if (isFileInExternalFilesDir(dir)) {\n      val file = File(externalFilesDir, dir)\n      file.deleteRecursively()\n    }\n  }\n\n  private fun updateModelInitializationStatus(\n    model: Model,\n    status: ModelInitializationStatusType,\n    error: String = \"\",\n  ) {\n    val curModelInstance = uiState.value.modelInitializationStatus.toMutableMap()\n    curModelInstance[model.name] = ModelInitializationStatus(status = status, error = error)\n    val newUiState = uiState.value.copy(modelInitializationStatus = curModelInstance)\n    _uiState.update { newUiState }\n  }\n\n  private fun isModelDownloaded(model: Model): Boolean {\n    val modelRelativePath =\n      listOf(model.normalizedName, model.version, model.downloadFileName)\n        .joinToString(File.separator)\n    val downloadedFileExists =\n      model.downloadFileName.isNotEmpty() &&\n        ((model.localModelFilePathOverride.isEmpty() &&\n          isFileInExternalFilesDir(modelRelativePath)) ||\n          (model.localModelFilePathOverride.isNotEmpty() &&\n            File(model.localModelFilePathOverride).exists()))\n\n    val unzippedDirectoryExists =\n      model.isZip &&\n        model.unzipDir.isNotEmpty() &&\n        isFileInExternalFilesDir(\n          listOf(model.normalizedName, model.version, model.unzipDir).joinToString(File.separator)\n        )\n\n    return downloadedFileExists || unzippedDirectoryExists\n  }\n}\n\nprivate fun getAllowlistUrl(version: String): String {\n  return \"$ALLOWLIST_BASE_URL/${version}.json\"\n}\n"
  },
  {
    "path": "Android/src/app/src/main/java/com/google/ai/edge/gallery/ui/navigation/GalleryNavGraph.kt",
    "content": "/*\n * Copyright 2025 Google LLC\n *\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n *     http://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\n\npackage com.google.ai.edge.gallery.ui.navigation\n\nimport androidx.hilt.navigation.compose.hiltViewModel\n\nimport android.os.Bundle\nimport android.util.Log\nimport androidx.activity.compose.BackHandler\nimport androidx.compose.animation.AnimatedContent\nimport androidx.compose.animation.AnimatedContentTransitionScope\nimport androidx.compose.animation.AnimatedVisibility\nimport androidx.compose.animation.EnterTransition\nimport androidx.compose.animation.ExitTransition\nimport androidx.compose.animation.core.EaseOutExpo\nimport androidx.compose.animation.core.FastOutSlowInEasing\nimport androidx.compose.animation.core.FiniteAnimationSpec\nimport androidx.compose.animation.core.animateDpAsState\nimport androidx.compose.animation.core.tween\nimport androidx.compose.animation.slideInVertically\nimport androidx.compose.animation.slideOutVertically\nimport androidx.compose.foundation.layout.Box\nimport androidx.compose.foundation.layout.WindowInsets\nimport androidx.compose.foundation.layout.asPaddingValues\nimport androidx.compose.foundation.layout.calculateStartPadding\nimport androidx.compose.foundation.layout.padding\nimport androidx.compose.foundation.layout.statusBars\nimport androidx.compose.material3.Scaffold\nimport androidx.compose.runtime.Composable\nimport androidx.compose.runtime.DisposableEffect\nimport androidx.compose.runtime.LaunchedEffect\nimport androidx.compose.runtime.collectAsState\nimport androidx.compose.runtime.getValue\nimport androidx.compose.runtime.mutableIntStateOf\nimport androidx.compose.runtime.mutableStateOf\nimport androidx.compose.runtime.remember\nimport androidx.compose.runtime.rememberCoroutineScope\nimport androidx.compose.runtime.setValue\nimport androidx.compose.ui.Modifier\nimport androidx.compose.ui.layout.onGloballyPositioned\nimport androidx.compose.ui.platform.LocalContext\nimport androidx.compose.ui.platform.LocalDensity\nimport androidx.compose.ui.platform.LocalLayoutDirection\nimport androidx.compose.ui.unit.Dp\nimport androidx.compose.ui.unit.IntOffset\nimport androidx.lifecycle.Lifecycle\nimport androidx.lifecycle.LifecycleEventObserver\nimport androidx.lifecycle.compose.LocalLifecycleOwner\nimport androidx.navigation.NavHostController\nimport androidx.navigation.NavType\nimport androidx.navigation.compose.NavHost\nimport androidx.navigation.compose.composable\nimport androidx.navigation.navArgument\nimport com.google.ai.edge.gallery.GalleryEvent\nimport com.google.ai.edge.gallery.customtasks.common.CustomTaskData\nimport com.google.ai.edge.gallery.customtasks.common.CustomTaskDataForBuiltinTask\nimport com.google.ai.edge.gallery.data.ModelDownloadStatusType\nimport com.google.ai.edge.gallery.data.Task\nimport com.google.ai.edge.gallery.data.isLegacyTasks\nimport com.google.ai.edge.gallery.firebaseAnalytics\nimport com.google.ai.edge.gallery.ui.benchmark.BenchmarkScreen\nimport com.google.ai.edge.gallery.ui.common.ErrorDialog\nimport com.google.ai.edge.gallery.ui.common.ModelPageAppBar\nimport com.google.ai.edge.gallery.ui.common.chat.ModelDownloadStatusInfoPanel\nimport com.google.ai.edge.gallery.ui.home.HomeScreen\nimport com.google.ai.edge.gallery.ui.modelmanager.GlobalModelManager\nimport com.google.ai.edge.gallery.ui.modelmanager.ModelInitializationStatusType\nimport com.google.ai.edge.gallery.ui.modelmanager.ModelManager\nimport com.google.ai.edge.gallery.ui.modelmanager.ModelManagerViewModel\nimport kotlinx.coroutines.Dispatchers\nimport kotlinx.coroutines.launch\n\nprivate const val TAG = \"AGGalleryNavGraph\"\nprivate const val ROUTE_HOMESCREEN = \"homepage\"\nprivate const val ROUTE_MODEL_LIST = \"model_list\"\nprivate const val ROUTE_MODEL = \"route_model\"\nprivate const val ROUTE_BENCHMARK = \"benchmark\"\nprivate const val ROUTE_MODEL_MANAGER = \"model_manager\"\nprivate const val ENTER_ANIMATION_DURATION_MS = 500\nprivate val ENTER_ANIMATION_EASING = EaseOutExpo\nprivate const val ENTER_ANIMATION_DELAY_MS = 100\n\nprivate const val EXIT_ANIMATION_DURATION_MS = 500\nprivate val EXIT_ANIMATION_EASING = EaseOutExpo\n\nprivate fun enterTween(): FiniteAnimationSpec<IntOffset> {\n  return tween(\n    ENTER_ANIMATION_DURATION_MS,\n    easing = ENTER_ANIMATION_EASING,\n    delayMillis = ENTER_ANIMATION_DELAY_MS,\n  )\n}\n\nprivate fun exitTween(): FiniteAnimationSpec<IntOffset> {\n  return tween(EXIT_ANIMATION_DURATION_MS, easing = EXIT_ANIMATION_EASING)\n}\n\nprivate fun AnimatedContentTransitionScope<*>.slideEnter(): EnterTransition {\n  return slideIntoContainer(\n    animationSpec = enterTween(),\n    towards = AnimatedContentTransitionScope.SlideDirection.Left,\n  )\n}\n\nprivate fun AnimatedContentTransitionScope<*>.slideExit(): ExitTransition {\n  return slideOutOfContainer(\n    animationSpec = exitTween(),\n    towards = AnimatedContentTransitionScope.SlideDirection.Right,\n  )\n}\n\nprivate fun AnimatedContentTransitionScope<*>.slideUpEnter(): EnterTransition {\n  return slideIntoContainer(\n    animationSpec = enterTween(),\n    towards = AnimatedContentTransitionScope.SlideDirection.Up,\n  )\n}\n\nprivate fun AnimatedContentTransitionScope<*>.slideDownExit(): ExitTransition {\n  return slideOutOfContainer(\n    animationSpec = exitTween(),\n    towards = AnimatedContentTransitionScope.SlideDirection.Down,\n  )\n}\n\n/** Navigation routes. */\n@Composable\nfun GalleryNavHost(\n  navController: NavHostController,\n  modifier: Modifier = Modifier,\n  modelManagerViewModel: ModelManagerViewModel,\n) {\n  val lifecycleOwner = LocalLifecycleOwner.current\n  var showModelManager by remember { mutableStateOf(false) }\n  var pickedTask by remember { mutableStateOf<Task?>(null) }\n  var enableHomeScreenAnimation by remember { mutableStateOf(true) }\n  var enableModelListAnimation by remember { mutableStateOf(true) }\n  var lastNavigatedModelName = remember { \"\" }\n\n  // Track whether app is in foreground.\n  DisposableEffect(lifecycleOwner) {\n    val observer = LifecycleEventObserver { _, event ->\n      when (event) {\n        Lifecycle.Event.ON_START,\n        Lifecycle.Event.ON_RESUME -> {\n          modelManagerViewModel.setAppInForeground(foreground = true)\n        }\n        Lifecycle.Event.ON_STOP,\n        Lifecycle.Event.ON_PAUSE -> {\n          modelManagerViewModel.setAppInForeground(foreground = false)\n        }\n        else -> {\n          /* Do nothing for other events */\n        }\n      }\n    }\n\n    lifecycleOwner.lifecycle.addObserver(observer)\n\n    onDispose { lifecycleOwner.lifecycle.removeObserver(observer) }\n  }\n\n  NavHost(\n    navController = navController,\n    startDestination = ROUTE_HOMESCREEN,\n    enterTransition = { EnterTransition.None },\n    exitTransition = { ExitTransition.None },\n  ) {\n    // Home screen.\n    composable(route = ROUTE_HOMESCREEN) {\n      HomeScreen(\n        modelManagerViewModel = modelManagerViewModel,\n        tosViewModel = hiltViewModel(),\n        enableAnimation = enableHomeScreenAnimation,\n        navigateToTaskScreen = { task ->\n          pickedTask = task\n          enableModelListAnimation = true\n          navController.navigate(ROUTE_MODEL_LIST)\n          firebaseAnalytics?.logEvent(\n            GalleryEvent.CAPABILITY_SELECT.id,\n            Bundle().apply { putString(\"capability_name\", task.id) },\n          )\n        },\n        onModelsClicked = { navController.navigate(ROUTE_MODEL_MANAGER) },\n      )\n    }\n\n    // Model list.\n    composable(\n      route = ROUTE_MODEL_LIST,\n      enterTransition = {\n        if (initialState.destination.route == ROUTE_HOMESCREEN) {\n          slideEnter()\n        } else {\n          EnterTransition.None\n        }\n      },\n      exitTransition = {\n        if (targetState.destination.route == ROUTE_HOMESCREEN) {\n          slideExit()\n        } else {\n          ExitTransition.None\n        }\n      },\n    ) {\n      pickedTask?.let {\n        ModelManager(\n          viewModel = modelManagerViewModel,\n          task = it,\n          enableAnimation = enableModelListAnimation,\n          onModelClicked = { model ->\n            navController.navigate(\"$ROUTE_MODEL/${it.id}/${model.name}\")\n          },\n          navigateUp = {\n            enableHomeScreenAnimation = false\n            navController.navigateUp()\n          },\n        )\n      }\n    }\n\n    // Model page.\n    composable(\n      route = \"$ROUTE_MODEL/{taskId}/{modelName}\",\n      arguments =\n        listOf(\n          navArgument(\"taskId\") { type = NavType.StringType },\n          navArgument(\"modelName\") { type = NavType.StringType },\n        ),\n      enterTransition = { slideEnter() },\n      exitTransition = { slideExit() },\n    ) { backStackEntry ->\n      val modelName = backStackEntry.arguments?.getString(\"modelName\") ?: \"\"\n      val taskId = backStackEntry.arguments?.getString(\"taskId\") ?: \"\"\n      val scope = rememberCoroutineScope()\n      val context = LocalContext.current\n\n      modelManagerViewModel.getModelByName(name = modelName)?.let { initialModel ->\n        if (lastNavigatedModelName != modelName) {\n          modelManagerViewModel.selectModel(initialModel)\n          lastNavigatedModelName = modelName\n        }\n\n        val customTask = modelManagerViewModel.getCustomTaskByTaskId(id = taskId)\n        if (customTask != null) {\n          if (isLegacyTasks(customTask.task.id)) {\n            customTask.MainScreen(\n              data =\n                CustomTaskDataForBuiltinTask(\n                  modelManagerViewModel = modelManagerViewModel,\n                  onNavUp = {\n                    enableModelListAnimation = false\n                    lastNavigatedModelName = \"\"\n                    navController.navigateUp()\n                  },\n                )\n            )\n          } else {\n            var disableAppBarControls by remember { mutableStateOf(false) }\n            var hideTopBar by remember { mutableStateOf(false) }\n            var customNavigateUpCallback by remember { mutableStateOf<(() -> Unit)?>(null) }\n            CustomTaskScreen(\n              task = customTask.task,\n              modelManagerViewModel = modelManagerViewModel,\n              onNavigateUp = {\n                if (customNavigateUpCallback != null) {\n                  customNavigateUpCallback?.invoke()\n                } else {\n                  enableModelListAnimation = false\n                  lastNavigatedModelName = \"\"\n                  navController.navigateUp()\n\n                  // clean up all models.\n                  for (curModel in customTask.task.models) {\n                    val instanceToCleanUp = curModel.instance\n                    scope.launch(Dispatchers.Default) {\n                      modelManagerViewModel.cleanupModel(\n                        context = context,\n                        task = customTask.task,\n                        model = curModel,\n                        instanceToCleanUp = instanceToCleanUp,\n                      )\n                    }\n                  }\n                }\n              },\n              disableAppBarControls = disableAppBarControls,\n              hideTopBar = hideTopBar,\n              useThemeColor = customTask.task.useThemeColor,\n            ) { bottomPadding ->\n              customTask.MainScreen(\n                data =\n                  CustomTaskData(\n                    modelManagerViewModel = modelManagerViewModel,\n                    bottomPadding = bottomPadding,\n                    setAppBarControlsDisabled = { disableAppBarControls = it },\n                    setTopBarVisible = { hideTopBar = !it },\n                    setCustomNavigateUpCallback = { customNavigateUpCallback = it },\n                  )\n              )\n            }\n          }\n        }\n      }\n    }\n\n    // Global model manager page.\n    composable(\n      route = ROUTE_MODEL_MANAGER,\n      enterTransition = {\n        if (\n          initialState.destination.route?.startsWith(ROUTE_BENCHMARK) == true ||\n            initialState.destination.route?.startsWith(ROUTE_MODEL) == true\n        ) {\n          null\n        } else {\n          slideUpEnter()\n        }\n      },\n      exitTransition = {\n        if (\n          targetState.destination.route?.startsWith(ROUTE_BENCHMARK) == true ||\n            targetState.destination.route?.startsWith(ROUTE_MODEL) == true\n        ) {\n          null\n        } else {\n          slideDownExit()\n        }\n      },\n    ) { backStackEntry ->\n      GlobalModelManager(\n        viewModel = modelManagerViewModel,\n        navigateUp = {\n          enableHomeScreenAnimation = false\n          navController.navigateUp()\n        },\n        onModelSelected = { task, model ->\n          navController.navigate(\"$ROUTE_MODEL/${task.id}/${model.name}\")\n        },\n        onBenchmarkClicked = { model ->\n          firebaseAnalytics?.logEvent(\n            GalleryEvent.CAPABILITY_SELECT.id,\n            Bundle().apply { putString(\"capability_name\", \"benchmark_${model.name}\") },\n          )\n          navController.navigate(\"$ROUTE_BENCHMARK/${model.name}\")\n        },\n      )\n    }\n\n    // Benchmark creation page.\n    composable(\n      route = \"$ROUTE_BENCHMARK/{modelName}\",\n      arguments = listOf(navArgument(\"modelName\") { type = NavType.StringType }),\n      enterTransition = { slideEnter() },\n      exitTransition = { slideExit() },\n    ) { backStackEntry ->\n      val modelName = backStackEntry.arguments?.getString(\"modelName\") ?: \"\"\n\n      modelManagerViewModel.getModelByName(name = modelName)?.let { model ->\n        BenchmarkScreen(\n          initialModel = model,\n          modelManagerViewModel = modelManagerViewModel,\n          onBackClicked = {\n            enableModelListAnimation = false\n            navController.navigateUp()\n          },\n        )\n      }\n    }\n  }\n\n  // Handle incoming intents for deep links\n  val intent = androidx.activity.compose.LocalActivity.current?.intent\n  val data = intent?.data\n  if (data != null) {\n    intent.data = null\n    Log.d(TAG, \"navigation link clicked: $data\")\n    if (data.toString().startsWith(\"com.google.ai.edge.gallery://model/\")) {\n      if (data.pathSegments.size >= 2) {\n        val taskId = data.pathSegments.get(data.pathSegments.size - 2)\n        val modelName = data.pathSegments.last()\n        modelManagerViewModel.getModelByName(name = modelName)?.let { model ->\n          navController.navigate(\"$ROUTE_MODEL/${taskId}/${model.name}\")\n        }\n      } else {\n        Log.e(TAG, \"Malformed deep link URI received: $data\")\n      }\n    } else if (data.toString() == \"com.google.ai.edge.gallery://global_model_manager\") {\n      navController.navigate(ROUTE_MODEL_MANAGER)\n    }\n  }\n}\n\n@Composable\nprivate fun CustomTaskScreen(\n  task: Task,\n  modelManagerViewModel: ModelManagerViewModel,\n  disableAppBarControls: Boolean,\n  hideTopBar: Boolean,\n  useThemeColor: Boolean,\n  onNavigateUp: () -> Unit,\n  content: @Composable (bottomPadding: Dp) -> Unit,\n) {\n  val modelManagerUiState by modelManagerViewModel.uiState.collectAsState()\n  val selectedModel = modelManagerUiState.selectedModel\n  val scope = rememberCoroutineScope()\n  val context = LocalContext.current\n  var navigatingUp by remember { mutableStateOf(false) }\n  var showErrorDialog by remember { mutableStateOf(false) }\n  var appBarHeight by remember { mutableIntStateOf(0) }\n\n  val handleNavigateUp = {\n    navigatingUp = true\n    onNavigateUp()\n  }\n\n  // Handle system's edge swipe.\n  BackHandler { handleNavigateUp() }\n\n  // Initialize model when model/download state changes.\n  val curDownloadStatus = modelManagerUiState.modelDownloadStatus[selectedModel.name]\n  LaunchedEffect(curDownloadStatus, selectedModel.name) {\n    if (!navigatingUp) {\n      if (curDownloadStatus?.status == ModelDownloadStatusType.SUCCEEDED) {\n        Log.d(\n          TAG,\n          \"Initializing model '${selectedModel.name}' from CustomTaskScreen launched effect\",\n        )\n        modelManagerViewModel.initializeModel(context, task = task, model = selectedModel)\n      }\n    }\n  }\n\n  val modelInitializationStatus = modelManagerUiState.modelInitializationStatus[selectedModel.name]\n  LaunchedEffect(modelInitializationStatus) {\n    showErrorDialog = modelInitializationStatus?.status == ModelInitializationStatusType.ERROR\n  }\n\n  Scaffold(\n    topBar = {\n      AnimatedVisibility(\n        !hideTopBar,\n        enter = slideInVertically { -it },\n        exit = slideOutVertically { -it },\n      ) {\n        ModelPageAppBar(\n          task = task,\n          model = selectedModel,\n          modelManagerViewModel = modelManagerViewModel,\n          inProgress = disableAppBarControls,\n          modelPreparing = disableAppBarControls,\n          canShowResetSessionButton = false,\n          useThemeColor = useThemeColor,\n          modifier =\n            Modifier.onGloballyPositioned { coordinates -> appBarHeight = coordinates.size.height },\n          hideModelSelector = task.models.size <= 1,\n          onConfigChanged = { _, _ -> },\n          onBackClicked = { handleNavigateUp() },\n          onModelSelected = { prevModel, newSelectedModel ->\n            val instanceToCleanUp = prevModel.instance\n            scope.launch(Dispatchers.Default) {\n              // Clean up prev model.\n              if (prevModel.name != newSelectedModel.name) {\n                modelManagerViewModel.cleanupModel(\n                  context = context,\n                  task = task,\n                  model = prevModel,\n                  instanceToCleanUp = instanceToCleanUp,\n                )\n              }\n\n              // Update selected model.\n              Log.d(TAG, \"from model picker. new: ${newSelectedModel.name}\")\n              modelManagerViewModel.selectModel(model = newSelectedModel)\n            }\n          },\n        )\n      }\n    }\n  ) { innerPadding ->\n    // Calculate the target height in Dp for the content's top padding.\n    val targetPaddingDp =\n      if (!hideTopBar && appBarHeight > 0) {\n        // Convert measured pixel height to Dp\n        with(LocalDensity.current) { appBarHeight.toDp() }\n      } else {\n        WindowInsets.statusBars.asPaddingValues().calculateTopPadding()\n      }\n\n    // Animate the actual top padding value.\n    val animatedTopPadding by\n      animateDpAsState(\n        targetValue = targetPaddingDp,\n        animationSpec = tween(durationMillis = 220, easing = FastOutSlowInEasing),\n        label = \"TopPaddingAnimation\",\n      )\n\n    Box(\n      modifier =\n        Modifier.padding(\n          top = if (!hideTopBar) innerPadding.calculateTopPadding() else animatedTopPadding,\n          start = innerPadding.calculateStartPadding(LocalLayoutDirection.current),\n          end = innerPadding.calculateStartPadding(LocalLayoutDirection.current),\n        )\n    ) {\n      val curModelDownloadStatus = modelManagerUiState.modelDownloadStatus[selectedModel.name]\n      AnimatedContent(\n        targetState = curModelDownloadStatus?.status == ModelDownloadStatusType.SUCCEEDED\n      ) { targetState ->\n        when (targetState) {\n          // Main UI when model is downloaded.\n          true -> content(innerPadding.calculateBottomPadding())\n          // Model download\n          false ->\n            ModelDownloadStatusInfoPanel(\n              model = selectedModel,\n              task = task,\n              modelManagerViewModel = modelManagerViewModel,\n            )\n        }\n      }\n    }\n  }\n\n  if (showErrorDialog) {\n    ErrorDialog(\n      error = modelInitializationStatus?.error ?: \"\",\n      onDismiss = {\n        showErrorDialog = false\n        onNavigateUp()\n      },\n    )\n  }\n}\n"
  },
  {
    "path": "Android/src/app/src/main/java/com/google/ai/edge/gallery/ui/theme/Color.kt",
    "content": "/*\n * Copyright 2025 Google LLC\n *\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n *     http://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\n\npackage com.google.ai.edge.gallery.ui.theme\n\nimport androidx.compose.ui.graphics.Color\n\nval primaryLight = Color(0xFF0B57D0)\nval onPrimaryLight = Color(0xFFFFFFFF)\nval primaryContainerLight = Color(0xFFD3E3FD)\nval onPrimaryContainerLight = Color(0xFF0842A0)\nval secondaryLight = Color(0xFF00639B)\nval onSecondaryLight = Color(0xFFFFFFFF)\nval secondaryContainerLight = Color(0xFFC2E7FF)\nval onSecondaryContainerLight = Color(0xFF004A77)\nval tertiaryLight = Color(0xFF146C2E)\nval onTertiaryLight = Color(0xFFFFFFFF)\nval tertiaryContainerLight = Color(0xFFC4EED0)\nval onTertiaryContainerLight = Color(0xFF0F5223)\nval errorLight = Color(0xFFB3261E)\nval onErrorLight = Color(0xFFFFFFFF)\nval errorContainerLight = Color(0xFFF9DEDC)\nval onErrorContainerLight = Color(0xFF8C1D18)\nval backgroundLight = Color(0xFFFFFFFF)\nval onBackgroundLight = Color(0xFF1F1F1F)\nval surfaceLight = Color(0xFFFFFFFF)\nval onSurfaceLight = Color(0xFF1F1F1F)\nval surfaceVariantLight = Color(0xFFE1E3E1)\nval onSurfaceVariantLight = Color(0xFF444746)\nval surfaceContainerLowestLight = Color(0xFFFFFFFF)\nval surfaceContainerLowLight = Color(0xFFF8FAFD)\nval surfaceContainerLight = Color(0xFFF0F4F9)\nval surfaceContainerHighLight = Color(0xFFE9EEF6)\nval surfaceContainerHighestLight = Color(0xFFDDE3EA)\nval inverseSurfaceLight = Color(0xFF303030)\nval inverseOnSurfaceLight = Color(0xFFF2F2F2)\nval outlineLight = Color(0xFF747775)\nval outlineVariantLight = Color(0xFFC4C7C5)\nval inversePrimaryLight = Color(0xFFA8C7FA)\nval surfaceDimLight = Color(0xFFD3DBE5)\nval surfaceBrightLight = Color(0xFFFFFFFF)\nval scrimLight = Color(0xFF000000)\n\nval primaryDark = Color(0xFFA8C7FA)\nval onPrimaryDark = Color(0xFF062E6F)\nval primaryContainerDark = Color(0xFF0842A0)\nval onPrimaryContainerDark = Color(0xFFD3E3FD)\nval secondaryDark = Color(0xFF7FCFFF)\nval onSecondaryDark = Color(0xFF003355)\nval secondaryContainerDark = Color(0xFF004A77)\nval onSecondaryContainerDark = Color(0xFFC2E7FF)\nval tertiaryDark = Color(0xFF6DD58C)\nval onTertiaryDark = Color(0xFF0A3818)\nval tertiaryContainerDark = Color(0xFF0F5223)\nval onTertiaryContainerDark = Color(0xFFC4EED0)\nval errorDark = Color(0xFFF2B8B5)\nval onErrorDark = Color(0xFF601410)\nval errorContainerDark = Color(0xFF8C1D18)\nval onErrorContainerDark = Color(0xFFF9DEDC)\nval backgroundDark = Color(0xFF131314)\nval onBackgroundDark = Color(0xFFE3E3E3)\nval surfaceDark = Color(0xFF131314)\nval onSurfaceDark = Color(0xFFE3E3E3)\nval surfaceVariantDark = Color(0xFF444746)\nval onSurfaceVariantDark = Color(0xFFC4C7C5)\nval surfaceContainerLowestDark = Color(0xFF0E0E0E)\nval surfaceContainerLowDark = Color(0xFF1B1B1B)\nval surfaceContainerDark = Color(0xFF1E1F20)\nval surfaceContainerHighDark = Color(0xFF282A2C)\nval surfaceContainerHighestDark = Color(0xFF333537)\nval inverseSurfaceDark = Color(0xFFE3E3E3)\nval inverseOnSurfaceDark = Color(0xFF303030)\nval outlineDark = Color(0xFF8E918F)\nval outlineVariantDark = Color(0xFF444746)\nval inversePrimaryDark = Color(0xFF0B57D0)\nval surfaceDimDark = Color(0xFF131314)\nval surfaceBrightDark = Color(0xFF37393B)\nval scrimDark = Color(0xFF000000)\n"
  },
  {
    "path": "Android/src/app/src/main/java/com/google/ai/edge/gallery/ui/theme/Theme.kt",
    "content": "/*\n * Copyright 2025 Google LLC\n *\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n *     http://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\n\npackage com.google.ai.edge.gallery.ui.theme\n\nimport android.app.Activity\nimport android.os.Build\nimport androidx.compose.foundation.isSystemInDarkTheme\nimport androidx.compose.material3.MaterialTheme\nimport androidx.compose.material3.darkColorScheme\nimport androidx.compose.material3.lightColorScheme\nimport androidx.compose.runtime.Composable\nimport androidx.compose.runtime.CompositionLocalProvider\nimport androidx.compose.runtime.Immutable\nimport androidx.compose.runtime.LaunchedEffect\nimport androidx.compose.runtime.ReadOnlyComposable\nimport androidx.compose.runtime.SideEffect\nimport androidx.compose.runtime.staticCompositionLocalOf\nimport androidx.compose.ui.graphics.Color\nimport androidx.compose.ui.platform.LocalView\nimport androidx.core.view.WindowCompat\nimport com.google.ai.edge.gallery.proto.Theme\n\nprivate val lightScheme =\n  lightColorScheme(\n    primary = primaryLight,\n    onPrimary = onPrimaryLight,\n    primaryContainer = primaryContainerLight,\n    onPrimaryContainer = onPrimaryContainerLight,\n    secondary = secondaryLight,\n    onSecondary = onSecondaryLight,\n    secondaryContainer = secondaryContainerLight,\n    onSecondaryContainer = onSecondaryContainerLight,\n    tertiary = tertiaryLight,\n    onTertiary = onTertiaryLight,\n    tertiaryContainer = tertiaryContainerLight,\n    onTertiaryContainer = onTertiaryContainerLight,\n    error = errorLight,\n    onError = onErrorLight,\n    errorContainer = errorContainerLight,\n    onErrorContainer = onErrorContainerLight,\n    background = backgroundLight,\n    onBackground = onBackgroundLight,\n    surface = surfaceLight,\n    onSurface = onSurfaceLight,\n    surfaceVariant = surfaceVariantLight,\n    onSurfaceVariant = onSurfaceVariantLight,\n    outline = outlineLight,\n    outlineVariant = outlineVariantLight,\n    scrim = scrimLight,\n    inverseSurface = inverseSurfaceLight,\n    inverseOnSurface = inverseOnSurfaceLight,\n    inversePrimary = inversePrimaryLight,\n    surfaceDim = surfaceDimLight,\n    surfaceBright = surfaceBrightLight,\n    surfaceContainerLowest = surfaceContainerLowestLight,\n    surfaceContainerLow = surfaceContainerLowLight,\n    surfaceContainer = surfaceContainerLight,\n    surfaceContainerHigh = surfaceContainerHighLight,\n    surfaceContainerHighest = surfaceContainerHighestLight,\n  )\n\nprivate val darkScheme =\n  darkColorScheme(\n    primary = primaryDark,\n    onPrimary = onPrimaryDark,\n    primaryContainer = primaryContainerDark,\n    onPrimaryContainer = onPrimaryContainerDark,\n    secondary = secondaryDark,\n    onSecondary = onSecondaryDark,\n    secondaryContainer = secondaryContainerDark,\n    onSecondaryContainer = onSecondaryContainerDark,\n    tertiary = tertiaryDark,\n    onTertiary = onTertiaryDark,\n    tertiaryContainer = tertiaryContainerDark,\n    onTertiaryContainer = onTertiaryContainerDark,\n    error = errorDark,\n    onError = onErrorDark,\n    errorContainer = errorContainerDark,\n    onErrorContainer = onErrorContainerDark,\n    background = backgroundDark,\n    onBackground = onBackgroundDark,\n    surface = surfaceDark,\n    onSurface = onSurfaceDark,\n    surfaceVariant = surfaceVariantDark,\n    onSurfaceVariant = onSurfaceVariantDark,\n    outline = outlineDark,\n    outlineVariant = outlineVariantDark,\n    scrim = scrimDark,\n    inverseSurface = inverseSurfaceDark,\n    inverseOnSurface = inverseOnSurfaceDark,\n    inversePrimary = inversePrimaryDark,\n    surfaceDim = surfaceDimDark,\n    surfaceBright = surfaceBrightDark,\n    surfaceContainerLowest = surfaceContainerLowestDark,\n    surfaceContainerLow = surfaceContainerLowDark,\n    surfaceContainer = surfaceContainerDark,\n    surfaceContainerHigh = surfaceContainerHighDark,\n    surfaceContainerHighest = surfaceContainerHighestDark,\n  )\n\n@Immutable\ndata class CustomColors(\n  val appTitleGradientColors: List<Color> = listOf(),\n  val tabHeaderBgColor: Color = Color.Transparent,\n  val taskCardBgColor: Color = Color.Transparent,\n  val taskBgColors: List<Color> = listOf(),\n  val taskBgGradientColors: List<List<Color>> = listOf(),\n  val taskIconColors: List<Color> = listOf(),\n  val taskIconShapeBgColor: Color = Color.Transparent,\n  val homeBottomGradient: List<Color> = listOf(),\n  val userBubbleBgColor: Color = Color.Transparent,\n  val agentBubbleBgColor: Color = Color.Transparent,\n  val linkColor: Color = Color.Transparent,\n  val successColor: Color = Color.Transparent,\n  val recordButtonBgColor: Color = Color.Transparent,\n  val waveFormBgColor: Color = Color.Transparent,\n  val modelInfoIconColor: Color = Color.Transparent,\n  val warningContainerColor: Color = Color.Transparent,\n  val warningTextColor: Color = Color.Transparent,\n  val errorContainerColor: Color = Color.Transparent,\n  val errorTextColor: Color = Color.Transparent,\n)\n\nval LocalCustomColors = staticCompositionLocalOf { CustomColors() }\n\nval lightCustomColors =\n  CustomColors(\n    appTitleGradientColors = listOf(Color(0xFF85B1F8), Color(0xFF3174F1)),\n    tabHeaderBgColor = Color(0xFF3174F1),\n    taskCardBgColor = surfaceContainerLowestLight,\n    taskBgColors =\n      listOf(\n        // red\n        Color(0xFFFFF5F5),\n        // green\n        Color(0xFFF4FBF6),\n        // blue\n        Color(0xFFF1F6FE),\n        // yellow\n        Color(0xFFFFFBF0),\n      ),\n    taskBgGradientColors =\n      listOf(\n        // red\n        listOf(Color(0xFFE25F57), Color(0xFFDB372D)),\n        // green\n        listOf(Color(0xFF41A15F), Color(0xFF128937)),\n        // blue\n        listOf(Color(0xFF669DF6), Color(0xFF3174F1)),\n        // yellow\n        listOf(Color(0xFFFDD45D), Color(0xFFCAA12A)),\n      ),\n    taskIconColors =\n      listOf(\n        // red.\n        Color(0xFFDB372D),\n        // green\n        Color(0xFF128937),\n        // blue\n        Color(0xFF3174F1),\n        // yellow\n        Color(0xFFCAA12A),\n      ),\n    taskIconShapeBgColor = Color.White,\n    homeBottomGradient = listOf(Color(0x00F8F9FF), Color(0xffFFEFC9)),\n    agentBubbleBgColor = Color(0xFFe9eef6),\n    userBubbleBgColor = Color(0xFF32628D),\n    linkColor = Color(0xFF32628D),\n    successColor = Color(0xff3d860b),\n    recordButtonBgColor = Color(0xFFEE675C),\n    waveFormBgColor = Color(0xFFaaaaaa),\n    modelInfoIconColor = Color(0xFFCCCCCC),\n    warningContainerColor = Color(0xfffef7e0),\n    warningTextColor = Color(0xffe37400),\n    errorContainerColor = Color(0xfffce8e6),\n    errorTextColor = Color(0xffd93025),\n  )\n\nval darkCustomColors =\n  CustomColors(\n    appTitleGradientColors = listOf(Color(0xFF85B1F8), Color(0xFF3174F1)),\n    tabHeaderBgColor = Color(0xFF3174F1),\n    taskCardBgColor = surfaceContainerHighDark,\n    taskBgColors =\n      listOf(\n        // red\n        Color(0xFF181210),\n        // green\n        Color(0xFF131711),\n        // blue\n        Color(0xFF191924),\n        // yellow\n        Color(0xFF1A1813),\n      ),\n    taskBgGradientColors =\n      listOf(\n        // red\n        listOf(Color(0xFFE25F57), Color(0xFFDB372D)),\n        // green\n        listOf(Color(0xFF41A15F), Color(0xFF128937)),\n        // blue\n        listOf(Color(0xFF669DF6), Color(0xFF3174F1)),\n        // yellow\n        listOf(Color(0xFFFDD45D), Color(0xFFCAA12A)),\n      ),\n    taskIconColors =\n      listOf(\n        // red.\n        Color(0xFFE25F57),\n        // green\n        Color(0xFF41A15F),\n        // blue\n        Color(0xFF669DF6),\n        // yellow\n        Color(0xFFCAA12A),\n      ),\n    taskIconShapeBgColor = Color(0xFF202124),\n    homeBottomGradient = listOf(Color(0x00F8F9FF), Color(0x1AF6AD01)),\n    agentBubbleBgColor = Color(0xFF1b1c1d),\n    userBubbleBgColor = Color(0xFF1f3760),\n    linkColor = Color(0xFF9DCAFC),\n    successColor = Color(0xFFA1CE83),\n    recordButtonBgColor = Color(0xFFEE675C),\n    waveFormBgColor = Color(0xFFaaaaaa),\n    modelInfoIconColor = Color(0xFFCCCCCC),\n    warningContainerColor = Color(0xff554c33),\n    warningTextColor = Color(0xfffcc934),\n    errorContainerColor = Color(0xff523a3b),\n    errorTextColor = Color(0xffee675c),\n  )\n\nval MaterialTheme.customColors: CustomColors\n  @Composable @ReadOnlyComposable get() = LocalCustomColors.current\n\n/**\n * Controls the color of the phone's status bar icons based on whether the app is using a dark\n * theme.\n */\n@Composable\nfun StatusBarColorController(useDarkTheme: Boolean) {\n  val view = LocalView.current\n  val currentWindow = (view.context as? Activity)?.window\n\n  if (currentWindow != null) {\n    SideEffect {\n      WindowCompat.setDecorFitsSystemWindows(currentWindow, false)\n      val controller = WindowCompat.getInsetsController(currentWindow, view)\n      controller.isAppearanceLightStatusBars = !useDarkTheme // Set to true for light icons\n    }\n  }\n}\n\n@Composable\nfun GalleryTheme(content: @Composable () -> Unit) {\n  val themeOverride = ThemeSettings.themeOverride\n  val darkTheme: Boolean =\n    (isSystemInDarkTheme() || themeOverride.value == Theme.THEME_DARK) &&\n      themeOverride.value != Theme.THEME_LIGHT\n  val view = LocalView.current\n\n  StatusBarColorController(useDarkTheme = darkTheme)\n\n  val colorScheme =\n    when {\n      darkTheme -> darkScheme\n      else -> lightScheme\n    }\n\n  val customColorsPalette = if (darkTheme) darkCustomColors else lightCustomColors\n\n  CompositionLocalProvider(LocalCustomColors provides customColorsPalette) {\n    MaterialTheme(colorScheme = colorScheme, typography = AppTypography, content = content)\n  }\n\n  // Make sure the navigation bar stays transparent on manual theme changes.\n  LaunchedEffect(darkTheme) {\n    val window = (view.context as Activity).window\n\n    if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {\n      window.isNavigationBarContrastEnforced = false\n    }\n  }\n}\n"
  },
  {
    "path": "Android/src/app/src/main/java/com/google/ai/edge/gallery/ui/theme/ThemeSettings.kt",
    "content": "/*\n * Copyright 2025 Google LLC\n *\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n *     http://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\n\npackage com.google.ai.edge.gallery.ui.theme\n\nimport androidx.compose.runtime.mutableStateOf\nimport com.google.ai.edge.gallery.proto.Theme\n\nobject ThemeSettings {\n  val themeOverride = mutableStateOf<Theme>(Theme.THEME_AUTO)\n}\n"
  },
  {
    "path": "Android/src/app/src/main/java/com/google/ai/edge/gallery/ui/theme/Type.kt",
    "content": "/*\n * Copyright 2025 Google LLC\n *\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n *     http://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\n\npackage com.google.ai.edge.gallery.ui.theme\n\nimport androidx.compose.ui.text.font.Font\nimport androidx.compose.material3.Typography\nimport androidx.compose.ui.text.font.FontFamily\nimport androidx.compose.ui.text.font.FontWeight\nimport androidx.compose.ui.unit.sp\nimport com.google.ai.edge.gallery.R\n\nval appFontFamily =\n  FontFamily(\n    Font(R.font.nunito_regular, FontWeight.Normal),\n    Font(R.font.nunito_extralight, FontWeight.ExtraLight),\n    Font(R.font.nunito_light, FontWeight.Light),\n    Font(R.font.nunito_medium, FontWeight.Medium),\n    Font(R.font.nunito_semibold, FontWeight.SemiBold),\n    Font(R.font.nunito_bold, FontWeight.Bold),\n    Font(R.font.nunito_extrabold, FontWeight.ExtraBold),\n    Font(R.font.nunito_black, FontWeight.Black),\n  )\n\nval baseline = Typography()\n\nval AppTypography =\n  Typography(\n    displayLarge = baseline.displayLarge.copy(fontFamily = appFontFamily),\n    displayMedium = baseline.displayMedium.copy(fontFamily = appFontFamily),\n    displaySmall = baseline.displaySmall.copy(fontFamily = appFontFamily),\n    headlineLarge = baseline.headlineLarge.copy(fontFamily = appFontFamily),\n    headlineMedium = baseline.headlineMedium.copy(fontFamily = appFontFamily),\n    headlineSmall = baseline.headlineSmall.copy(fontFamily = appFontFamily),\n    titleLarge = baseline.titleLarge.copy(fontFamily = appFontFamily),\n    titleMedium = baseline.titleMedium.copy(fontFamily = appFontFamily),\n    titleSmall = baseline.titleSmall.copy(fontFamily = appFontFamily),\n    bodyLarge = baseline.bodyLarge.copy(fontFamily = appFontFamily),\n    bodyMedium = baseline.bodyMedium.copy(fontFamily = appFontFamily),\n    bodySmall = baseline.bodySmall.copy(fontFamily = appFontFamily),\n    labelLarge = baseline.labelLarge.copy(fontFamily = appFontFamily),\n    labelMedium = baseline.labelMedium.copy(fontFamily = appFontFamily),\n    labelSmall = baseline.labelSmall.copy(fontFamily = appFontFamily),\n  )\n\nval titleMediumNarrow =\n  baseline.titleMedium.copy(fontFamily = appFontFamily, letterSpacing = 0.0.sp)\n\nval titleSmaller =\n  baseline.titleSmall.copy(\n    fontFamily = appFontFamily,\n    fontSize = 12.sp,\n    fontWeight = FontWeight.Bold,\n  )\n\nval labelSmallNarrow = baseline.labelSmall.copy(fontFamily = appFontFamily, letterSpacing = 0.0.sp)\n\nval labelSmallNarrowMedium =\n  baseline.labelSmall.copy(\n    fontFamily = appFontFamily,\n    fontWeight = FontWeight.Medium,\n    letterSpacing = 0.0.sp,\n  )\n\nval bodySmallNarrow = baseline.bodySmall.copy(fontFamily = appFontFamily, letterSpacing = 0.0.sp)\n\nval bodySmallMediumNarrow =\n  baseline.bodySmall.copy(fontFamily = appFontFamily, letterSpacing = 0.0.sp, fontSize = 14.sp)\n\nval bodySmallMediumNarrowBold =\n  baseline.bodySmall.copy(\n    fontFamily = appFontFamily,\n    letterSpacing = 0.0.sp,\n    fontSize = 14.sp,\n    fontWeight = FontWeight.Bold,\n  )\n\nval homePageTitleStyle =\n  baseline.displayMedium.copy(\n    fontFamily = appFontFamily,\n    fontSize = 48.sp,\n    lineHeight = 48.sp,\n    letterSpacing = -1.sp,\n    fontWeight = FontWeight.Medium,\n  )\n\nval bodyLargeNarrow = baseline.bodyLarge.copy(letterSpacing = 0.2.sp)\n\nval headlineLargeMedium = baseline.headlineLarge.copy(fontWeight = FontWeight.Medium)\n"
  },
  {
    "path": "Android/src/app/src/main/java/com/google/ai/edge/gallery/worker/AndroidManifest.xml",
    "content": "<?xml version=\"1.0\" encoding=\"utf-8\"?>\n<!--\n Copyright 2025 Google LLC\n\n Licensed under the Apache License, Version 2.0 (the \"License\");\n you may not use this file except in compliance with the License.\n You may obtain a copy of the License at\n\n     http://www.apache.org/licenses/LICENSE-2.0\n\n Unless required by applicable law or agreed to in writing, software\n distributed under the License is distributed on an \"AS IS\" BASIS,\n WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n See the License for the specific language governing permissions and\n limitations under the License.\n-->\n\n<manifest xmlns:android=\"http://schemas.android.com/apk/res/android\"\n    package=\"com.google.ai.edge.gallery.worker\">\n\n  <uses-sdk\n      android:minSdkVersion=\"31\"\n      android:targetSdkVersion=\"35\"/>\n  <application/>\n</manifest>\n"
  },
  {
    "path": "Android/src/app/src/main/java/com/google/ai/edge/gallery/worker/DownloadWorker.kt",
    "content": "/*\n * Copyright 2025 Google LLC\n *\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n *     http://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\n\npackage com.google.ai.edge.gallery.worker\n\nimport android.app.NotificationChannel\nimport android.app.NotificationManager\nimport android.app.PendingIntent\nimport android.content.Context\nimport android.content.Intent\nimport android.content.pm.ServiceInfo\nimport android.util.Log\nimport androidx.core.app.NotificationCompat\nimport androidx.work.CoroutineWorker\nimport androidx.work.Data\nimport androidx.work.ForegroundInfo\nimport androidx.work.WorkerParameters\nimport com.google.ai.edge.gallery.data.KEY_MODEL_COMMIT_HASH\nimport com.google.ai.edge.gallery.data.KEY_MODEL_DOWNLOAD_ACCESS_TOKEN\nimport com.google.ai.edge.gallery.data.KEY_MODEL_DOWNLOAD_ERROR_MESSAGE\nimport com.google.ai.edge.gallery.data.KEY_MODEL_DOWNLOAD_FILE_NAME\nimport com.google.ai.edge.gallery.data.KEY_MODEL_DOWNLOAD_MODEL_DIR\nimport com.google.ai.edge.gallery.data.KEY_MODEL_DOWNLOAD_RATE\nimport com.google.ai.edge.gallery.data.KEY_MODEL_DOWNLOAD_RECEIVED_BYTES\nimport com.google.ai.edge.gallery.data.KEY_MODEL_DOWNLOAD_REMAINING_MS\nimport com.google.ai.edge.gallery.data.KEY_MODEL_EXTRA_DATA_DOWNLOAD_FILE_NAMES\nimport com.google.ai.edge.gallery.data.KEY_MODEL_EXTRA_DATA_URLS\nimport com.google.ai.edge.gallery.data.KEY_MODEL_IS_ZIP\nimport com.google.ai.edge.gallery.data.KEY_MODEL_NAME\nimport com.google.ai.edge.gallery.data.KEY_MODEL_START_UNZIPPING\nimport com.google.ai.edge.gallery.data.KEY_MODEL_TOTAL_BYTES\nimport com.google.ai.edge.gallery.data.KEY_MODEL_UNZIPPED_DIR\nimport com.google.ai.edge.gallery.data.KEY_MODEL_URL\nimport com.google.ai.edge.gallery.data.TMP_FILE_EXT\nimport java.io.BufferedInputStream\nimport java.io.File\nimport java.io.FileInputStream\nimport java.io.FileOutputStream\nimport java.io.IOException\nimport java.net.HttpURLConnection\nimport java.net.URL\nimport java.util.zip.ZipEntry\nimport java.util.zip.ZipInputStream\nimport kotlinx.coroutines.Dispatchers\nimport kotlinx.coroutines.withContext\n\nprivate const val TAG = \"AGDownloadWorker\"\n\ndata class UrlAndFileName(val url: String, val fileName: String)\n\nprivate const val FOREGROUND_NOTIFICATION_CHANNEL_ID = \"model_download_channel_foreground\"\nprivate var channelCreated = false\n\nclass DownloadWorker(context: Context, params: WorkerParameters) :\n  CoroutineWorker(context, params) {\n  private val externalFilesDir = context.getExternalFilesDir(null)\n\n  private val notificationManager =\n    context.getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager\n\n  // Unique notification id.\n  private val notificationId: Int = params.id.hashCode()\n\n  init {\n    if (!channelCreated) {\n      // Create a notification channel for showing notifications for model downloading progress.\n      val channel =\n        NotificationChannel(\n            FOREGROUND_NOTIFICATION_CHANNEL_ID,\n            \"Model Downloading\",\n            // Make it silent.\n            NotificationManager.IMPORTANCE_LOW,\n          )\n          .apply { description = \"Notifications for model downloading\" }\n      notificationManager.createNotificationChannel(channel)\n      channelCreated = true\n    }\n  }\n\n  override suspend fun doWork(): Result {\n    val fileUrl = inputData.getString(KEY_MODEL_URL)\n    val modelName = inputData.getString(KEY_MODEL_NAME) ?: \"Model\"\n    val version = inputData.getString(KEY_MODEL_COMMIT_HASH)!!\n    val fileName = inputData.getString(KEY_MODEL_DOWNLOAD_FILE_NAME)\n    val modelDir = inputData.getString(KEY_MODEL_DOWNLOAD_MODEL_DIR)!!\n    val isZip = inputData.getBoolean(KEY_MODEL_IS_ZIP, false)\n    val unzippedDir = inputData.getString(KEY_MODEL_UNZIPPED_DIR)\n    val extraDataFileUrls = inputData.getString(KEY_MODEL_EXTRA_DATA_URLS)?.split(\",\") ?: listOf()\n    val extraDataFileNames =\n      inputData.getString(KEY_MODEL_EXTRA_DATA_DOWNLOAD_FILE_NAMES)?.split(\",\") ?: listOf()\n    val totalBytes = inputData.getLong(KEY_MODEL_TOTAL_BYTES, 0L)\n    val accessToken = inputData.getString(KEY_MODEL_DOWNLOAD_ACCESS_TOKEN)\n\n    return withContext(Dispatchers.IO) {\n      if (fileUrl == null || fileName == null) {\n        Result.failure()\n      } else {\n        return@withContext try {\n          // Set the worker as a foreground service immediately.\n          setForeground(createForegroundInfo(progress = 0, modelName = modelName))\n\n          // Collect data for all files.\n          val allFiles: MutableList<UrlAndFileName> = mutableListOf()\n          allFiles.add(UrlAndFileName(url = fileUrl, fileName = fileName))\n          for (index in extraDataFileUrls.indices) {\n            allFiles.add(\n              UrlAndFileName(url = extraDataFileUrls[index], fileName = extraDataFileNames[index])\n            )\n          }\n          Log.d(TAG, \"About to download: $allFiles\")\n\n          // Download them in sequence.\n          // TODO: maybe consider downloading them in parallel.\n          var downloadedBytes = 0L\n          val bytesReadSizeBuffer: MutableList<Long> = mutableListOf()\n          val bytesReadLatencyBuffer: MutableList<Long> = mutableListOf()\n          for (file in allFiles) {\n            val url = URL(file.url)\n\n            val connection = url.openConnection() as HttpURLConnection\n            if (accessToken != null) {\n              Log.d(TAG, \"Using access token: ${accessToken.subSequence(0, 10)}...\")\n              connection.setRequestProperty(\"Authorization\", \"Bearer $accessToken\")\n            }\n\n            // Prepare output file's dir.\n            val outputDir =\n              File(\n                applicationContext.getExternalFilesDir(null),\n                listOf(modelDir, version).joinToString(separator = File.separator),\n              )\n            if (!outputDir.exists()) {\n              outputDir.mkdirs()\n            }\n\n            // Read the tmp file and see if it is partially downloaded.\n            val outputTmpFile =\n              File(\n                applicationContext.getExternalFilesDir(null),\n                listOf(modelDir, version, \"${file.fileName}.$TMP_FILE_EXT\")\n                  .joinToString(separator = File.separator),\n              )\n            val outputFileBytes = outputTmpFile.length()\n            if (outputFileBytes > 0) {\n              Log.d(\n                TAG,\n                \"File '${outputTmpFile.name}' partial size: ${outputFileBytes}. Trying to resume download\",\n              )\n              connection.setRequestProperty(\"Range\", \"bytes=${outputFileBytes}-\")\n              // Force the server to send non-compressed data to make download resuming work.\n              connection.setRequestProperty(\"Accept-Encoding\", \"identity\")\n            }\n            connection.connect()\n            Log.d(TAG, \"response code: ${connection.responseCode}\")\n\n            if (\n              connection.responseCode == HttpURLConnection.HTTP_OK ||\n                connection.responseCode == HttpURLConnection.HTTP_PARTIAL\n            ) {\n              val contentRange = connection.getHeaderField(\"Content-Range\")\n\n              if (contentRange != null) {\n                // Parse the Content-Range header\n                val rangeParts = contentRange.substringAfter(\"bytes \").split(\"/\")\n                val byteRange = rangeParts[0].split(\"-\")\n                val startByte = byteRange[0].toLong()\n                val endByte = byteRange[1].toLong()\n\n                Log.d(\n                  TAG,\n                  \"Content-Range: $contentRange. Start bytes: ${startByte}, end bytes: $endByte\",\n                )\n\n                downloadedBytes += startByte\n              } else {\n                Log.d(TAG, \"Download starts from beginning.\")\n              }\n            } else {\n              throw IOException(\"HTTP error code: ${connection.responseCode}\")\n            }\n\n            val inputStream = connection.inputStream\n            val outputStream = FileOutputStream(outputTmpFile, true /* append */)\n\n            val buffer = ByteArray(DEFAULT_BUFFER_SIZE)\n            var bytesRead: Int\n            var lastSetProgressTs: Long = 0\n            var deltaBytes = 0L\n            while (inputStream.read(buffer).also { bytesRead = it } != -1) {\n              outputStream.write(buffer, 0, bytesRead)\n              downloadedBytes += bytesRead\n              deltaBytes += bytesRead\n\n              // Report progress every 200 ms.\n              val curTs = System.currentTimeMillis()\n              if (curTs - lastSetProgressTs > 200) {\n                // Calculate download rate.\n                var bytesPerMs = 0f\n                if (lastSetProgressTs != 0L) {\n                  if (bytesReadSizeBuffer.size == 5) {\n                    bytesReadSizeBuffer.removeAt(0)\n                  }\n                  bytesReadSizeBuffer.add(deltaBytes)\n                  if (bytesReadLatencyBuffer.size == 5) {\n                    bytesReadLatencyBuffer.removeAt(0)\n                  }\n                  bytesReadLatencyBuffer.add(curTs - lastSetProgressTs)\n                  deltaBytes = 0L\n                  bytesPerMs = bytesReadSizeBuffer.sum().toFloat() / bytesReadLatencyBuffer.sum()\n                }\n\n                // Calculate remaining seconds\n                var remainingMs = 0f\n                if (bytesPerMs > 0f && totalBytes > 0L) {\n                  remainingMs = (totalBytes - downloadedBytes) / bytesPerMs\n                }\n\n                setProgress(\n                  Data.Builder()\n                    .putLong(KEY_MODEL_DOWNLOAD_RECEIVED_BYTES, downloadedBytes)\n                    .putLong(KEY_MODEL_DOWNLOAD_RATE, (bytesPerMs * 1000).toLong())\n                    .putLong(KEY_MODEL_DOWNLOAD_REMAINING_MS, remainingMs.toLong())\n                    .build()\n                )\n                setForeground(\n                  createForegroundInfo(\n                    progress = (downloadedBytes * 100 / totalBytes).toInt(),\n                    modelName = modelName,\n                  )\n                )\n                Log.d(TAG, \"downloadedBytes: $downloadedBytes\")\n                lastSetProgressTs = curTs\n              }\n            }\n\n            outputStream.close()\n            inputStream.close()\n\n            // Rename the tmp file to the original file name by removing the tmp file ext.\n            val originalFilePath = outputTmpFile.absolutePath.replace(\".$TMP_FILE_EXT\", \"\")\n            val originalFile = File(originalFilePath)\n            if (originalFile.exists()) {\n              originalFile.delete()\n            }\n            outputTmpFile.renameTo(originalFile)\n            Log.d(TAG, \"Download done\")\n\n            // Unzip if the downloaded file is a zip.\n            if (isZip && unzippedDir != null) {\n              setProgress(Data.Builder().putBoolean(KEY_MODEL_START_UNZIPPING, true).build())\n\n              // Prepare target dir.\n              val destDir =\n                File(\n                  externalFilesDir,\n                  listOf(modelDir, version, unzippedDir).joinToString(File.separator),\n                )\n              if (!destDir.exists()) {\n                destDir.mkdirs()\n              }\n\n              // Unzip.\n              val unzipBuffer = ByteArray(4096)\n              val zipFilePath =\n                \"${externalFilesDir}${File.separator}$modelDir${File.separator}$version${File.separator}${fileName}\"\n              val zipIn = ZipInputStream(BufferedInputStream(FileInputStream(zipFilePath)))\n              var zipEntry: ZipEntry? = zipIn.nextEntry\n\n              while (zipEntry != null) {\n                val filePath = destDir.absolutePath + File.separator + zipEntry.name\n\n                // Extract files.\n                if (!zipEntry.isDirectory) {\n                  // extract file\n                  val bos = FileOutputStream(filePath)\n                  bos.use { curBos ->\n                    var len: Int\n                    while (zipIn.read(unzipBuffer).also { len = it } > 0) {\n                      curBos.write(unzipBuffer, 0, len)\n                    }\n                  }\n                }\n                // Create dir.\n                else {\n                  val dir = File(filePath)\n                  dir.mkdirs()\n                }\n\n                zipIn.closeEntry()\n                zipEntry = zipIn.nextEntry\n              }\n              zipIn.close()\n\n              // Delete the original file.\n              val zipFile = File(zipFilePath)\n              zipFile.delete()\n            }\n          }\n          Result.success()\n        } catch (e: IOException) {\n          Log.e(TAG, e.message, e)\n          Result.failure(\n            Data.Builder().putString(KEY_MODEL_DOWNLOAD_ERROR_MESSAGE, e.message).build()\n          )\n        }\n      }\n    }\n  }\n\n  override suspend fun getForegroundInfo(): ForegroundInfo {\n    // Initial progress is 0\n    return createForegroundInfo(0)\n  }\n\n  /**\n   * Creates a [ForegroundInfo] object for the download worker's ongoing notification. This\n   * notification is used to keep the worker running in the foreground, indicating to the user that\n   * an active download is in progress.\n   */\n  private fun createForegroundInfo(progress: Int, modelName: String? = null): ForegroundInfo {\n    // Create a notification for the foreground service\n    var title = \"Downloading model\"\n    if (modelName != null) {\n      title = \"Downloading \\\"$modelName\\\"\"\n    }\n    val content = \"Downloading in progress: $progress%\"\n\n    val intent =\n      Intent(applicationContext, Class.forName(\"com.google.ai.edge.gallery.MainActivity\")).apply {\n        flags = Intent.FLAG_ACTIVITY_SINGLE_TOP\n      }\n    val pendingIntent =\n      PendingIntent.getActivity(\n        applicationContext,\n        0,\n        intent,\n        PendingIntent.FLAG_IMMUTABLE or PendingIntent.FLAG_UPDATE_CURRENT,\n      )\n\n    val notification =\n      NotificationCompat.Builder(applicationContext, FOREGROUND_NOTIFICATION_CHANNEL_ID)\n        .setContentTitle(title)\n        .setContentText(content)\n        .setSmallIcon(android.R.drawable.ic_dialog_info)\n        .setOngoing(true) // Makes the notification non-dismissable\n        .setProgress(100, progress, false) // Show progress\n        .setContentIntent(pendingIntent)\n        .build()\n\n    return ForegroundInfo(\n      notificationId,\n      notification,\n      ServiceInfo.FOREGROUND_SERVICE_TYPE_DATA_SYNC,\n    )\n  }\n}\n"
  },
  {
    "path": "Android/src/app/src/main/proto/benchmark.proto",
    "content": "/* Copyright 2026 Google LLC\n\nLicensed under the Apache License, Version 2.0 (the \"License\");\nyou may not use this file except in compliance with the License.\nYou may obtain a copy of the License at\n\n    http://www.apache.org/licenses/LICENSE-2.0\n\nUnless required by applicable law or agreed to in writing, software\ndistributed under the License is distributed on an \"AS IS\" BASIS,\nWITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\nSee the License for the specific language governing permissions and\nlimitations under the License.\n==============================================================================*/\n\nsyntax = \"proto3\";\n\npackage com.google.ai.edge.gallery.proto;\n\noption java_multiple_files = true;\noption java_outer_classname = \"Benchmark\";\noption java_package = \"com.google.ai.edge.gallery.proto\";\n\nmessage BenchmarkResults {\n  repeated BenchmarkResult result = 1;\n}\n\nmessage BenchmarkResult {\n  oneof result {\n    LlmBenchmarkResult llm_result = 2;\n  }\n}\n\nmessage LlmBenchmarkResult {\n  LlmBenchmarkBasicInfo baisc_info = 2;\n  LlmBenchmarkStats stats = 3;\n}\n\nmessage LlmBenchmarkBasicInfo {\n  int64 start_ms = 1;\n  int64 end_ms = 2;\n  string model_name = 3;\n  string accelerator = 4;\n  int32 prefill_tokens = 5;\n  int32 decode_tokens = 6;\n  int32 number_of_runs = 7;\n  string app_version = 8;\n}\n\nmessage LlmBenchmarkStats {\n  // tokens/sec\n  ValueSeries prefill_speed = 1;\n  // tokens/sec\n  ValueSeries decode_speed = 2;\n  // seconds\n  ValueSeries time_to_first_token = 3;\n  // ms\n  double first_init_time_ms = 4;\n  // ms\n  ValueSeries non_first_init_time_ms = 5;\n}\n\nmessage ValueSeries {\n  repeated double value = 1;\n  double min = 2;\n  double max = 3;\n  double avg = 4;\n  double medium = 5;\n  double pct25 = 7;\n  double pct75 = 8;\n}\n"
  },
  {
    "path": "Android/src/app/src/main/proto/settings.proto",
    "content": "/* Copyright 2025 Google LLC\n\nLicensed under the Apache License, Version 2.0 (the \"License\");\nyou may not use this file except in compliance with the License.\nYou may obtain a copy of the License at\n\n    http://www.apache.org/licenses/LICENSE-2.0\n\nUnless required by applicable law or agreed to in writing, software\ndistributed under the License is distributed on an \"AS IS\" BASIS,\nWITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\nSee the License for the specific language governing permissions and\nlimitations under the License.\n==============================================================================*/\n\nsyntax = \"proto3\";\n\npackage com.google.ai.edge.gallery.proto;\n\noption java_package = \"com.google.ai.edge.gallery.proto\";\noption java_multiple_files = true;\n\nenum Theme {\n  THEME_UNSPECIFIED = 0;\n\n  // Force to use light theme.\n  THEME_LIGHT = 1;\n\n  // Force to use dark theme.\n  THEME_DARK = 2;\n\n  // Use the system them setting on user's phone.\n  THEME_AUTO = 3;\n}\n\nmessage AccessTokenData {\n  string access_token = 1;\n  string refresh_token = 2;\n  int64 expires_at_ms = 3;\n}\n\nmessage ImportedModel {\n  string file_name = 1;\n  int64 file_size = 2;\n\n  oneof config {\n    LlmConfig llm_config = 3;\n  }\n}\n\nmessage LlmConfig {\n  repeated string compatible_accelerators = 1;\n  int32 default_max_tokens = 2;\n  int32 default_topk = 3;\n  float default_topp = 4;\n  float default_temperature = 5;\n  bool support_image = 6;\n  bool support_audio = 7;\n  bool support_tiny_garden = 8;\n  bool support_mobile_actions = 9;\n}\n\nmessage Settings {\n  Theme theme = 1;\n  AccessTokenData access_token_data = 2;  // deprecated\n  repeated string text_input_history = 3;\n  repeated ImportedModel imported_model = 4;\n  // Has the app tos been accepted.\n  bool is_tos_accepted = 5;\n  bool has_run_tiny_garden = 6;\n  bool has_seen_benchmark_comparison_help = 7;\n  // Has the Gemma terms of use been accepted.\n  bool is_gemma_terms_accepted = 8;\n  // copybara:strip_begin(google-internal)\n  // Feature flags\n  map<string, bool> feature_flags = 9;\n  // copybara:strip_end\n}\n\nmessage UserData {\n  AccessTokenData access_token_data = 1;\n  map<string, string> secrets = 2;\n}\n\nenum FillMode {\n  FILL_MODE_UNSPECIFIED = 0;\n  FILL_MODE_DISABLED = 1;\n  FILL_MODE_SOLID = 2;\n  FILL_MODE_COLORIZE = 3;\n}\n\nmessage Point {\n  float x = 1;\n  float y = 2;\n}\n\nmessage StrokePath {\n  repeated Point point = 1;\n  int32 brush_color = 2;\n  float brush_size = 3;\n  float brush_softness = 4;\n  int32 blur_type = 5;\n}\n\n// A single cutout, used in scrapbook demo.\nmessage Cutout {\n  // id of the cutout.\n  string id = 1;\n\n  // Rotation degree applied to the cutout.\n  int32 rotation_degree = 2;\n\n  // The width of the border.\n  int32 border_width = 3;\n\n  // The color of the border.\n  int32 border_color = 4;\n\n  // The fill color.\n  int32 fill_color = 5;\n\n  // The fill mode.\n  FillMode fill_mode = 6;\n\n  // Doodle stroke paths.\n  repeated StrokePath doodle_stroke = 7;\n}\n\n// A collection of cutout, used in scrapbook demo.\nmessage CutoutCollection {\n  repeated Cutout cutout = 1;\n}\n"
  },
  {
    "path": "Android/src/app/src/main/res/drawable/chat_spark.xml",
    "content": "<!--\n Copyright 2025 Google LLC\n\n Licensed under the Apache License, Version 2.0 (the \"License\");\n you may not use this file except in compliance with the License.\n You may obtain a copy of the License at\n\n     http://www.apache.org/licenses/LICENSE-2.0\n\n Unless required by applicable law or agreed to in writing, software\n distributed under the License is distributed on an \"AS IS\" BASIS,\n WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n See the License for the specific language governing permissions and\n limitations under the License.\n-->\n\n<vector xmlns:android=\"http://schemas.android.com/apk/res/android\"\n    android:width=\"38dp\"\n    android:height=\"38dp\"\n    android:viewportWidth=\"38\"\n    android:viewportHeight=\"38\">\n    <group>\n        <path\n            android:fillColor=\"#FF1967D2\"\n            android:pathData=\"M9.32 21.5h15.3v-1.7c-0.44-0.1-0.87-0.24-1.28-0.4-0.41-0.17-0.82-0.37-1.2-0.57H9.32v2.67Zm0-4.89h10.1c-0.34-0.39-0.65-0.8-0.93-1.24-0.29-0.44-0.54-0.92-0.74-1.44H9.32v2.68Zm0-4.9h7.53V9.09H9.32v2.64Zm17.86 6.42c0-2.3-0.81-4.26-2.44-5.87-1.6-1.63-3.56-2.44-5.87-2.44 2.3 0 4.26-0.8 5.87-2.41 1.63-1.63 2.44-3.6 2.44-5.9 0 2.3 0.8 4.27 2.41 5.9 1.63 1.6 3.6 2.4 5.9 2.4-2.3 0-4.27 0.82-5.9 2.45-1.6 1.6-2.4 3.56-2.4 5.87Zm-21.4 1.04V25v2.49V5.5v2.1 2.22 7.34 2.99-0.24-0.74ZM3.12 33.9V5.5c0-0.72 0.26-1.34 0.77-1.86 0.55-0.54 1.18-0.81 1.9-0.81h13.83c-0.36 0.38-0.7 0.8-1 1.24-0.32 0.44-0.58 0.92-0.78 1.43H5.79V27.5L8.16 25H31.5v-5.83c0.51-0.2 1-0.46 1.43-0.77 0.44-0.31 0.86-0.65 1.24-1.01V25c0 0.72-0.27 1.36-0.81 1.9-0.52 0.52-1.14 0.78-1.86 0.78H9.32l-6.21 6.21Z\"/>\n    </group>\n</vector>"
  },
  {
    "path": "Android/src/app/src/main/res/drawable/circle.xml",
    "content": "<!--\n Copyright 2025 Google LLC\n\n Licensed under the Apache License, Version 2.0 (the \"License\");\n you may not use this file except in compliance with the License.\n You may obtain a copy of the License at\n\n     http://www.apache.org/licenses/LICENSE-2.0\n\n Unless required by applicable law or agreed to in writing, software\n distributed under the License is distributed on an \"AS IS\" BASIS,\n WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n See the License for the specific language governing permissions and\n limitations under the License.\n-->\n\n<vector xmlns:android=\"http://schemas.android.com/apk/res/android\" xmlns:aapt=\"http://schemas.android.com/aapt\"\n    android:viewportWidth=\"63\"\n    android:viewportHeight=\"63\"\n    android:width=\"63dp\"\n    android:height=\"63dp\">\n    <path\n        android:pathData=\"M63 31.5C63 14.103 48.897 0 31.5 0C14.103 0 0 14.103 0 31.5C0 48.897 14.103 63 31.5 63C48.897 63 63 48.897 63 31.5Z\"\n        android:fillColor=\"#FFFFFF\"\n        android:fillAlpha=\"1\" />\n</vector>"
  },
  {
    "path": "Android/src/app/src/main/res/drawable/double_circle.xml",
    "content": "<!--\n Copyright 2025 Google LLC\n\n Licensed under the Apache License, Version 2.0 (the \"License\");\n you may not use this file except in compliance with the License.\n You may obtain a copy of the License at\n\n     http://www.apache.org/licenses/LICENSE-2.0\n\n Unless required by applicable law or agreed to in writing, software\n distributed under the License is distributed on an \"AS IS\" BASIS,\n WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n See the License for the specific language governing permissions and\n limitations under the License.\n-->\n\n<vector xmlns:android=\"http://schemas.android.com/apk/res/android\" xmlns:aapt=\"http://schemas.android.com/aapt\"\n    android:viewportWidth=\"62\"\n    android:viewportHeight=\"62\"\n    android:width=\"62dp\"\n    android:height=\"62dp\">\n    <path\n        android:pathData=\"M7.78628 54.2125C-2.59542 43.8291 -2.59542 26.9941 7.78628 16.6107L16.6114 7.78754C26.9931 -2.59584 43.8287 -2.59584 54.2137 7.78754C64.5954 18.1709 64.5954 35.0059 54.2137 45.3893L45.3886 54.2125C35.0069 64.5958 18.1713 64.5958 7.78628 54.2125Z\"\n        android:fillColor=\"#FFFFFF\"\n        android:fillAlpha=\"1\" />\n</vector>"
  },
  {
    "path": "Android/src/app/src/main/res/drawable/four_circle.xml",
    "content": "<!--\n Copyright 2025 Google LLC\n\n Licensed under the Apache License, Version 2.0 (the \"License\");\n you may not use this file except in compliance with the License.\n You may obtain a copy of the License at\n\n     http://www.apache.org/licenses/LICENSE-2.0\n\n Unless required by applicable law or agreed to in writing, software\n distributed under the License is distributed on an \"AS IS\" BASIS,\n WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n See the License for the specific language governing permissions and\n limitations under the License.\n-->\n\n<vector xmlns:android=\"http://schemas.android.com/apk/res/android\" xmlns:aapt=\"http://schemas.android.com/aapt\"\n    android:viewportWidth=\"59\"\n    android:viewportHeight=\"59\"\n    android:width=\"59dp\"\n    android:height=\"59dp\">\n    <path\n        android:pathData=\"M1.3077 21.3393C-4.19593 8.66644 8.66702 -4.19605 21.3396 1.30784L23.4364 2.21791C27.3032 3.89848 31.6966 3.89848 35.5666 2.21791L37.6602 1.30784C50.3359 -4.19605 63.1957 8.66644 57.6921 21.3393L56.7817 23.4344C55.1036 27.3037 55.1036 31.6964 56.7817 35.5654L57.6921 37.6609C63.1957 50.3337 50.3359 63.1962 37.6602 57.692L35.5666 56.782C31.6966 55.1017 27.3032 55.1017 23.4364 56.782L21.3396 57.692C8.66702 63.1962 -4.19593 50.3337 1.3077 37.6609L2.21809 35.5654C3.89932 31.6964 3.89932 27.3037 2.21809 23.4344L1.3077 21.3393Z\"\n        android:fillColor=\"#FFFFFF\"\n        android:fillAlpha=\"1\" />\n</vector>"
  },
  {
    "path": "Android/src/app/src/main/res/drawable/ic_experiment.xml",
    "content": "<!--\n Copyright 2025 Google LLC\n\n Licensed under the Apache License, Version 2.0 (the \"License\");\n you may not use this file except in compliance with the License.\n You may obtain a copy of the License at\n\n     http://www.apache.org/licenses/LICENSE-2.0\n\n Unless required by applicable law or agreed to in writing, software\n distributed under the License is distributed on an \"AS IS\" BASIS,\n WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n See the License for the specific language governing permissions and\n limitations under the License.\n-->\n\n<vector xmlns:android=\"http://schemas.android.com/apk/res/android\"\n    android:width=\"24dp\"\n    android:height=\"24dp\"\n    android:viewportWidth=\"960\"\n    android:viewportHeight=\"960\">\n  <path\n      android:fillColor=\"@android:color/white\"\n      android:pathData=\"M200,840Q149,840 127.5,794.5Q106,749 138,710L360,440L360,200L320,200Q303,200 291.5,188.5Q280,177 280,160Q280,143 291.5,131.5Q303,120 320,120L640,120Q657,120 668.5,131.5Q680,143 680,160Q680,177 668.5,188.5Q657,200 640,200L600,200L600,440L822,710Q854,749 832.5,794.5Q811,840 760,840L200,840ZM280,720L680,720L544,560L416,560L280,720ZM200,760L760,760L520,468L520,200L440,200L440,468L200,760ZM480,480L480,480L480,480L480,480L480,480L480,480Z\"/>\n</vector>\n"
  },
  {
    "path": "Android/src/app/src/main/res/drawable/ic_launcher_background.xml",
    "content": "<?xml version=\"1.0\" encoding=\"utf-8\"?>\n<!--\n Copyright 2025 Google LLC\n\n Licensed under the Apache License, Version 2.0 (the \"License\");\n you may not use this file except in compliance with the License.\n You may obtain a copy of the License at\n\n     http://www.apache.org/licenses/LICENSE-2.0\n\n Unless required by applicable law or agreed to in writing, software\n distributed under the License is distributed on an \"AS IS\" BASIS,\n WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n See the License for the specific language governing permissions and\n limitations under the License.\n-->\n\n<vector xmlns:android=\"http://schemas.android.com/apk/res/android\"\n    android:width=\"108dp\"\n    android:height=\"108dp\"\n    android:viewportWidth=\"108\"\n    android:viewportHeight=\"108\">\n    <path\n        android:fillColor=\"#3DDC84\"\n        android:pathData=\"M0,0h108v108h-108z\" />\n    <path\n        android:fillColor=\"#00000000\"\n        android:pathData=\"M9,0L9,108\"\n        android:strokeWidth=\"0.8\"\n        android:strokeColor=\"#33FFFFFF\" />\n    <path\n        android:fillColor=\"#00000000\"\n        android:pathData=\"M19,0L19,108\"\n        android:strokeWidth=\"0.8\"\n        android:strokeColor=\"#33FFFFFF\" />\n    <path\n        android:fillColor=\"#00000000\"\n        android:pathData=\"M29,0L29,108\"\n        android:strokeWidth=\"0.8\"\n        android:strokeColor=\"#33FFFFFF\" />\n    <path\n        android:fillColor=\"#00000000\"\n        android:pathData=\"M39,0L39,108\"\n        android:strokeWidth=\"0.8\"\n        android:strokeColor=\"#33FFFFFF\" />\n    <path\n        android:fillColor=\"#00000000\"\n        android:pathData=\"M49,0L49,108\"\n        android:strokeWidth=\"0.8\"\n        android:strokeColor=\"#33FFFFFF\" />\n    <path\n        android:fillColor=\"#00000000\"\n        android:pathData=\"M59,0L59,108\"\n        android:strokeWidth=\"0.8\"\n        android:strokeColor=\"#33FFFFFF\" />\n    <path\n        android:fillColor=\"#00000000\"\n        android:pathData=\"M69,0L69,108\"\n        android:strokeWidth=\"0.8\"\n        android:strokeColor=\"#33FFFFFF\" />\n    <path\n        android:fillColor=\"#00000000\"\n        android:pathData=\"M79,0L79,108\"\n        android:strokeWidth=\"0.8\"\n        android:strokeColor=\"#33FFFFFF\" />\n    <path\n        android:fillColor=\"#00000000\"\n        android:pathData=\"M89,0L89,108\"\n        android:strokeWidth=\"0.8\"\n        android:strokeColor=\"#33FFFFFF\" />\n    <path\n        android:fillColor=\"#00000000\"\n        android:pathData=\"M99,0L99,108\"\n        android:strokeWidth=\"0.8\"\n        android:strokeColor=\"#33FFFFFF\" />\n    <path\n        android:fillColor=\"#00000000\"\n        android:pathData=\"M0,9L108,9\"\n        android:strokeWidth=\"0.8\"\n        android:strokeColor=\"#33FFFFFF\" />\n    <path\n        android:fillColor=\"#00000000\"\n        android:pathData=\"M0,19L108,19\"\n        android:strokeWidth=\"0.8\"\n        android:strokeColor=\"#33FFFFFF\" />\n    <path\n        android:fillColor=\"#00000000\"\n        android:pathData=\"M0,29L108,29\"\n        android:strokeWidth=\"0.8\"\n        android:strokeColor=\"#33FFFFFF\" />\n    <path\n        android:fillColor=\"#00000000\"\n        android:pathData=\"M0,39L108,39\"\n        android:strokeWidth=\"0.8\"\n        android:strokeColor=\"#33FFFFFF\" />\n    <path\n        android:fillColor=\"#00000000\"\n        android:pathData=\"M0,49L108,49\"\n        android:strokeWidth=\"0.8\"\n        android:strokeColor=\"#33FFFFFF\" />\n    <path\n        android:fillColor=\"#00000000\"\n        android:pathData=\"M0,59L108,59\"\n        android:strokeWidth=\"0.8\"\n        android:strokeColor=\"#33FFFFFF\" />\n    <path\n        android:fillColor=\"#00000000\"\n        android:pathData=\"M0,69L108,69\"\n        android:strokeWidth=\"0.8\"\n        android:strokeColor=\"#33FFFFFF\" />\n    <path\n        android:fillColor=\"#00000000\"\n        android:pathData=\"M0,79L108,79\"\n        android:strokeWidth=\"0.8\"\n        android:strokeColor=\"#33FFFFFF\" />\n    <path\n        android:fillColor=\"#00000000\"\n        android:pathData=\"M0,89L108,89\"\n        android:strokeWidth=\"0.8\"\n        android:strokeColor=\"#33FFFFFF\" />\n    <path\n        android:fillColor=\"#00000000\"\n        android:pathData=\"M0,99L108,99\"\n        android:strokeWidth=\"0.8\"\n        android:strokeColor=\"#33FFFFFF\" />\n    <path\n        android:fillColor=\"#00000000\"\n        android:pathData=\"M19,29L89,29\"\n        android:strokeWidth=\"0.8\"\n        android:strokeColor=\"#33FFFFFF\" />\n    <path\n        android:fillColor=\"#00000000\"\n        android:pathData=\"M19,39L89,39\"\n        android:strokeWidth=\"0.8\"\n        android:strokeColor=\"#33FFFFFF\" />\n    <path\n        android:fillColor=\"#00000000\"\n        android:pathData=\"M19,49L89,49\"\n        android:strokeWidth=\"0.8\"\n        android:strokeColor=\"#33FFFFFF\" />\n    <path\n        android:fillColor=\"#00000000\"\n        android:pathData=\"M19,59L89,59\"\n        android:strokeWidth=\"0.8\"\n        android:strokeColor=\"#33FFFFFF\" />\n    <path\n        android:fillColor=\"#00000000\"\n        android:pathData=\"M19,69L89,69\"\n        android:strokeWidth=\"0.8\"\n        android:strokeColor=\"#33FFFFFF\" />\n    <path\n        android:fillColor=\"#00000000\"\n        android:pathData=\"M19,79L89,79\"\n        android:strokeWidth=\"0.8\"\n        android:strokeColor=\"#33FFFFFF\" />\n    <path\n        android:fillColor=\"#00000000\"\n        android:pathData=\"M29,19L29,89\"\n        android:strokeWidth=\"0.8\"\n        android:strokeColor=\"#33FFFFFF\" />\n    <path\n        android:fillColor=\"#00000000\"\n        android:pathData=\"M39,19L39,89\"\n        android:strokeWidth=\"0.8\"\n        android:strokeColor=\"#33FFFFFF\" />\n    <path\n        android:fillColor=\"#00000000\"\n        android:pathData=\"M49,19L49,89\"\n        android:strokeWidth=\"0.8\"\n        android:strokeColor=\"#33FFFFFF\" />\n    <path\n        android:fillColor=\"#00000000\"\n        android:pathData=\"M59,19L59,89\"\n        android:strokeWidth=\"0.8\"\n        android:strokeColor=\"#33FFFFFF\" />\n    <path\n        android:fillColor=\"#00000000\"\n        android:pathData=\"M69,19L69,89\"\n        android:strokeWidth=\"0.8\"\n        android:strokeColor=\"#33FFFFFF\" />\n    <path\n        android:fillColor=\"#00000000\"\n        android:pathData=\"M79,19L79,89\"\n        android:strokeWidth=\"0.8\"\n        android:strokeColor=\"#33FFFFFF\" />\n</vector>\n"
  },
  {
    "path": "Android/src/app/src/main/res/drawable/ic_launcher_foreground.xml",
    "content": "<!--\n Copyright 2025 Google LLC\n\n Licensed under the Apache License, Version 2.0 (the \"License\");\n you may not use this file except in compliance with the License.\n You may obtain a copy of the License at\n\n     http://www.apache.org/licenses/LICENSE-2.0\n\n Unless required by applicable law or agreed to in writing, software\n distributed under the License is distributed on an \"AS IS\" BASIS,\n WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n See the License for the specific language governing permissions and\n limitations under the License.\n-->\n\n<vector xmlns:android=\"http://schemas.android.com/apk/res/android\"\n    xmlns:aapt=\"http://schemas.android.com/aapt\"\n    android:width=\"108dp\"\n    android:height=\"108dp\"\n    android:viewportWidth=\"108\"\n    android:viewportHeight=\"108\">\n    <path android:pathData=\"M31,63.928c0,0 6.4,-11 12.1,-13.1c7.2,-2.6 26,-1.4 26,-1.4l38.1,38.1L107,108.928l-32,-1L31,63.928z\">\n        <aapt:attr name=\"android:fillColor\">\n            <gradient\n                android:endX=\"85.84757\"\n                android:endY=\"92.4963\"\n                android:startX=\"42.9492\"\n                android:startY=\"49.59793\"\n                android:type=\"linear\">\n                <item\n                    android:color=\"#44000000\"\n                    android:offset=\"0.0\" />\n                <item\n                    android:color=\"#00000000\"\n                    android:offset=\"1.0\" />\n            </gradient>\n        </aapt:attr>\n    </path>\n    <path\n        android:fillColor=\"#FFFFFF\"\n        android:fillType=\"nonZero\"\n        android:pathData=\"M65.3,45.828l3.8,-6.6c0.2,-0.4 0.1,-0.9 -0.3,-1.1c-0.4,-0.2 -0.9,-0.1 -1.1,0.3l-3.9,6.7c-6.3,-2.8 -13.4,-2.8 -19.7,0l-3.9,-6.7c-0.2,-0.4 -0.7,-0.5 -1.1,-0.3C38.8,38.328 38.7,38.828 38.9,39.228l3.8,6.6C36.2,49.428 31.7,56.028 31,63.928h46C76.3,56.028 71.8,49.428 65.3,45.828zM43.4,57.328c-0.8,0 -1.5,-0.5 -1.8,-1.2c-0.3,-0.7 -0.1,-1.5 0.4,-2.1c0.5,-0.5 1.4,-0.7 2.1,-0.4c0.7,0.3 1.2,1 1.2,1.8C45.3,56.528 44.5,57.328 43.4,57.328L43.4,57.328zM64.6,57.328c-0.8,0 -1.5,-0.5 -1.8,-1.2s-0.1,-1.5 0.4,-2.1c0.5,-0.5 1.4,-0.7 2.1,-0.4c0.7,0.3 1.2,1 1.2,1.8C66.5,56.528 65.6,57.328 64.6,57.328L64.6,57.328z\"\n        android:strokeWidth=\"1\"\n        android:strokeColor=\"#00000000\" />\n</vector>"
  },
  {
    "path": "Android/src/app/src/main/res/drawable/image_spark.xml",
    "content": "<!--\n Copyright 2025 Google LLC\n\n Licensed under the Apache License, Version 2.0 (the \"License\");\n you may not use this file except in compliance with the License.\n You may obtain a copy of the License at\n\n     http://www.apache.org/licenses/LICENSE-2.0\n\n Unless required by applicable law or agreed to in writing, software\n distributed under the License is distributed on an \"AS IS\" BASIS,\n WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n See the License for the specific language governing permissions and\n limitations under the License.\n-->\n\n<vector xmlns:android=\"http://schemas.android.com/apk/res/android\"\nandroid:width=\"38dp\"\nandroid:height=\"38dp\"\nandroid:viewportWidth=\"38\"\nandroid:viewportHeight=\"38\">\n<group>\n    <path\n        android:fillColor=\"#FF34A853\"\n        android:pathData=\"M9.24 26.2h10.1l5-5-2.05-2.69-4.9 6.45-3.56-4.81-4.59 6.05Zm-1.9 6.14c-0.73 0-1.36-0.26-1.9-0.78-0.52-0.54-0.78-1.17-0.78-1.9V16.53h1.28 1.4v13.13h11.73v2.68H7.34Zm22.6-16.7V7.06H16.82v-1.4-1.28h13.12c0.73 0 1.35 0.27 1.87 0.81 0.54 0.52 0.81 1.14 0.81 1.87v8.58h-1.36-1.32Zm-8.2 16.7v-4.78l8.59-8.54c0.23-0.23 0.5-0.4 0.78-0.5 0.28-0.1 0.57-0.16 0.85-0.16 0.31 0 0.61 0.06 0.9 0.2 0.28 0.1 0.54 0.27 0.77 0.5l1.44 1.44c0.23 0.23 0.4 0.49 0.5 0.77 0.1 0.29 0.16 0.57 0.16 0.86 0 0.28-0.07 0.58-0.2 0.89-0.1 0.28-0.27 0.54-0.5 0.78l-8.5 8.54h-4.78ZM33.4 22.13l-1.44-1.44 1.44 1.44ZM24.08 30h1.47l4.7-4.74-1.43-1.43-4.74 4.7V30Zm5.47-5.48l-0.73-0.7 1.43 1.44-0.7-0.74Zm-20.97-9.4c0-1.88-0.66-3.49-1.98-4.81C5.28 8.97 3.68 8.3 1.8 8.3c1.89 0 3.5-0.66 4.81-1.98C7.92 5 8.58 3.39 8.58 1.5c0 1.9 0.66 3.5 1.98 4.82 1.35 1.32 2.97 1.98 4.86 1.98-1.9 0-3.51 0.67-4.86 2.02-1.32 1.32-1.98 2.93-1.98 4.82Z\"/>\n</group>\n</vector>"
  },
  {
    "path": "Android/src/app/src/main/res/drawable/logo.xml",
    "content": "<!--\n Copyright 2025 Google LLC\n\n Licensed under the Apache License, Version 2.0 (the \"License\");\n you may not use this file except in compliance with the License.\n You may obtain a copy of the License at\n\n     http://www.apache.org/licenses/LICENSE-2.0\n\n Unless required by applicable law or agreed to in writing, software\n distributed under the License is distributed on an \"AS IS\" BASIS,\n WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n See the License for the specific language governing permissions and\n limitations under the License.\n-->\n\n<vector xmlns:android=\"http://schemas.android.com/apk/res/android\"\n    android:width=\"196dp\"\n    android:height=\"199dp\"\n    android:viewportWidth=\"196\"\n    android:viewportHeight=\"199\">\n    <path\n        android:fillColor=\"#FFF6B704\"\n        android:pathData=\"M37.46 2.58c4.46-3.44 10.66-3.44 15.12 0l11.2 8.64c0.87 0.66 1.82 1.21 2.82 1.63l13.06 5.43c5.2 2.15 8.3 7.55 7.56 13.14L85.37 45.5c-0.14 1.08-0.14 2.18 0 3.26l1.85 14.07c0.74 5.59-2.36 10.98-7.56 13.15L66.6 81.39c-1 0.42-1.95 0.96-2.81 1.63l-11.2 8.64c-4.47 3.44-10.67 3.44-15.13 0l-11.2-8.64c-0.87-0.67-1.82-1.21-2.82-1.63l-13.06-5.42c-5.2-2.17-8.3-7.56-7.56-13.15l1.85-14.07c0.14-1.08 0.14-2.18 0-3.26L2.82 31.42c-0.74-5.6 2.37-10.99 7.56-13.14l13.06-5.43c1-0.42 1.95-0.97 2.81-1.63l11.21-8.64Z\"/>\n    <path\n        android:fillColor=\"#FFE54335\"\n        android:pathData=\"M118.52 77.54c-14.84-14.85-14.84-38.93 0-53.78l12.6-12.62c14.85-14.85 38.9-14.85 53.75 0 14.84 14.85 14.84 38.93 0 53.78l-12.61 12.62c-14.84 14.85-38.9 14.85-53.74 0Z\"/>\n    <path\n        android:fillColor=\"#FF34A353\"\n        android:pathData=\"M90.04 153.95c0-24.89-20.15-45.06-45.02-45.06C20.16 108.9 0 129.06 0 153.95 0 178.83 20.16 199 45.02 199c24.87 0 45.02-20.17 45.02-45.05Z\"/>\n    <path\n        android:fillColor=\"#FF4280EF\"\n        android:pathData=\"M109.25 145.13c-7.86-18.12 10.52-36.52 28.64-28.64l3 1.3c5.52 2.4 11.8 2.4 17.33 0l3-1.3c18.1-7.88 36.49 10.52 28.62 28.64l-1.3 3c-2.4 5.54-2.4 11.82 0 17.35l1.3 3c7.87 18.13-10.51 36.52-28.63 28.65l-2.99-1.3c-5.53-2.4-11.81-2.4-17.34 0l-3 1.3c-18.1 7.87-36.5-10.53-28.63-28.65l1.3-3c2.41-5.53 2.41-11.81 0-17.35l-1.3-3Z\"/>\n</vector>"
  },
  {
    "path": "Android/src/app/src/main/res/drawable/pantegon.xml",
    "content": "<!--\n Copyright 2025 Google LLC\n\n Licensed under the Apache License, Version 2.0 (the \"License\");\n you may not use this file except in compliance with the License.\n You may obtain a copy of the License at\n\n     http://www.apache.org/licenses/LICENSE-2.0\n\n Unless required by applicable law or agreed to in writing, software\n distributed under the License is distributed on an \"AS IS\" BASIS,\n WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n See the License for the specific language governing permissions and\n limitations under the License.\n-->\n\n<vector xmlns:android=\"http://schemas.android.com/apk/res/android\"\n    android:width=\"40dp\"\n    android:height=\"40dp\"\n    android:viewportHeight=\"40\"\n    android:viewportWidth=\"40\">\n  <path\n      android:fillColor=\"#FFFFFFFF\"\n      android:pathData=\"M16.78 1.1c1.9-1.46 4.54-1.46 6.44 0l4.76 3.66c0.37 0.28 0.77 0.52 1.2 0.7l5.56 2.3c2.2 0.91 3.53 3.2 3.21 5.58l-0.78 5.97c-0.06 0.46-0.06 0.92 0 1.38l0.78 5.97c0.32 2.38-1 4.67-3.21 5.58l-5.56 2.3c-0.43 0.18-0.83 0.42-1.2 0.7l-4.76 3.67c-1.9 1.45-4.54 1.45-6.44 0l-4.76-3.67c-0.37-0.28-0.77-0.52-1.2-0.7l-5.56-2.3c-2.2-0.91-3.53-3.2-3.21-5.58l0.78-5.97c0.06-0.46 0.06-0.92 0-1.38l-0.78-5.97c-0.32-2.38 1-4.67 3.21-5.58l5.56-2.3c0.43-0.18 0.83-0.42 1.2-0.7l4.76-3.67Z\" />\n</vector>"
  },
  {
    "path": "Android/src/app/src/main/res/drawable/splash_screen_animated_icon.xml",
    "content": "<!--\n Copyright 2025 Google LLC\n\n Licensed under the Apache License, Version 2.0 (the \"License\");\n you may not use this file except in compliance with the License.\n You may obtain a copy of the License at\n\n     http://www.apache.org/licenses/LICENSE-2.0\n\n Unless required by applicable law or agreed to in writing, software\n distributed under the License is distributed on an \"AS IS\" BASIS,\n WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n See the License for the specific language governing permissions and\n limitations under the License.\n-->\n\n<animated-vector xmlns:android=\"http://schemas.android.com/apk/res/android\"\n    xmlns:aapt=\"http://schemas.android.com/aapt\">\n  <aapt:attr name=\"android:drawable\">\n    <vector\n        android:width=\"432dp\"\n        android:height=\"432dp\"\n        android:viewportHeight=\"432\"\n        android:viewportWidth=\"432\">\n      <group android:name=\"_R_G\">\n        <group\n            android:name=\"_R_G_L_3_G_N_1_T_0\"\n            android:rotation=\"0\"\n            android:scaleX=\"1.12\"\n            android:scaleY=\"1.12\"\n            android:translateX=\"216.47\"\n            android:translateY=\"216.377\">\n          <group\n              android:name=\"_R_G_L_3_G\"\n              android:scaleX=\"0\"\n              android:scaleY=\"0\"\n              android:translateX=\"64\"\n              android:translateY=\"-1\">\n            <path\n                android:name=\"_R_G_L_3_G_D_0_P_0\"\n                android:fillAlpha=\"1\"\n                android:fillType=\"nonZero\"\n                android:pathData=\" M32 0 C32,-17.67 17.67,-32 0,-32 C-17.67,-32 -32,-17.67 -32,0 C-32,17.67 -17.67,32 0,32 C17.67,32 32,17.67 32,0c \">\n              <aapt:attr\n                  name=\"android:fillColor\">\n                <gradient\n                    android:type=\"linear\"\n                    android:startX=\"-30\"\n                    android:startY=\"0\"\n                    android:endX=\"50\"\n                    android:endY=\"0\"\n                    android:tileMode=\"clamp\">\n                  <item\n                      android:color=\"#41A15F\"\n                      android:offset=\"0\"/>\n                  <item\n                      android:color=\"#128937\"\n                      android:offset=\"1\"/>\n                </gradient>\n              </aapt:attr>\n            </path>\n          </group>\n        </group>\n        <group\n            android:name=\"_R_G_L_2_G_N_1_T_0\"\n            android:rotation=\"0\"\n            android:scaleX=\"1.12\"\n            android:scaleY=\"1.12\"\n            android:translateX=\"216.47\"\n            android:translateY=\"216.377\">\n          <group\n              android:name=\"_R_G_L_2_G\"\n              android:rotation=\"0\"\n              android:scaleX=\"0\"\n              android:scaleY=\"0\"\n              android:translateY=\"64\">\n            <path\n                android:name=\"_R_G_L_2_G_D_0_P_0\"\n                android:fillAlpha=\"1\"\n                android:fillType=\"nonZero\"\n                android:pathData=\" M5.36 -31.19 C2.2,-33.6 -2.2,-33.6 -5.36,-31.19 C-5.36,-31.19 -13.31,-25.14 -13.31,-25.14 C-13.92,-24.68 -14.59,-24.29 -15.3,-24 C-15.3,-24 -24.56,-20.2 -24.56,-20.2 C-28.25,-18.69 -30.45,-14.91 -29.92,-10.99 C-29.92,-10.99 -28.61,-1.14 -28.61,-1.14 C-28.51,-0.38 -28.51,0.38 -28.61,1.14 C-28.61,1.14 -29.92,11 -29.92,11 C-30.45,14.91 -28.25,18.69 -24.56,20.2 C-24.56,20.2 -15.3,24 -15.3,24 C-14.59,24.3 -13.92,24.68 -13.31,25.14 C-13.31,25.14 -5.36,31.19 -5.36,31.19 C-2.2,33.6 2.2,33.6 5.36,31.19 C5.36,31.19 13.31,25.14 13.31,25.14 C13.92,24.68 14.59,24.3 15.3,24 C15.3,24 24.56,20.2 24.56,20.2 C28.25,18.69 30.45,14.91 29.92,11 C29.92,11 28.61,1.14 28.61,1.14 C28.51,0.38 28.51,-0.38 28.61,-1.14 C28.61,-1.14 29.92,-10.99 29.92,-10.99 C30.45,-14.91 28.25,-18.69 24.56,-20.2 C24.56,-20.2 15.3,-24 15.3,-24 C14.59,-24.29 13.92,-24.68 13.31,-25.14 C13.31,-25.14 5.36,-31.19 5.36,-31.19c \">\n              <aapt:attr\n                  name=\"android:fillColor\">\n                <gradient\n                    android:type=\"linear\"\n                    android:startX=\"-40\"\n                    android:startY=\"0\"\n                    android:endX=\"30\"\n                    android:endY=\"0\"\n                    android:tileMode=\"clamp\">\n                  <item\n                      android:color=\"#FDD45D\"\n                      android:offset=\"0\"/>\n                  <item\n                      android:color=\"#CAA12A\"\n                      android:offset=\"1\"/>\n                </gradient>\n              </aapt:attr>\n            </path>\n          </group>\n        </group>\n        <group\n            android:name=\"_R_G_L_1_G_N_1_T_0\"\n            android:rotation=\"0\"\n            android:scaleX=\"1.12\"\n            android:scaleY=\"1.12\"\n            android:translateX=\"216.47\"\n            android:translateY=\"216.377\">\n          <group\n              android:name=\"_R_G_L_1_G\"\n              android:rotation=\"0\"\n              android:scaleX=\"0\"\n              android:scaleY=\"0\"\n              android:translateX=\"-64\"\n              android:translateY=\"-1\">\n            <path\n                android:name=\"_R_G_L_1_G_D_0_P_0\"\n                android:fillAlpha=\"1\"\n                android:fillType=\"nonZero\"\n                android:pathData=\" M-23.96 -14.85 C-34.68,-4.13 -34.68,13.24 -23.96,23.96 C-13.24,34.68 4.14,34.68 14.85,23.96 C14.85,23.96 23.96,14.85 23.96,14.85 C34.68,4.14 34.68,-13.24 23.96,-23.96 C13.24,-34.68 -4.14,-34.68 -14.85,-23.96 C-14.85,-23.96 -23.96,-14.85 -23.96,-14.85c \">\n              <aapt:attr\n                  name=\"android:fillColor\">\n                <gradient\n                    android:type=\"linear\"\n                    android:startX=\"-40\"\n                    android:startY=\"0\"\n                    android:endX=\"30\"\n                    android:endY=\"0\"\n                    android:tileMode=\"clamp\">\n                  <item\n                      android:color=\"#E25F57\"\n                      android:offset=\"0\"/>\n                  <item\n                      android:color=\"#DB372D\"\n                      android:offset=\"1\"/>\n                </gradient>\n              </aapt:attr>\n            </path>\n          </group>\n        </group>\n        <group\n            android:name=\"_R_G_L_0_G_N_1_T_0\"\n            android:rotation=\"0\"\n            android:scaleX=\"1.12\"\n            android:scaleY=\"1.12\"\n            android:translateX=\"216.47\"\n            android:translateY=\"216.377\">\n          <group\n              android:name=\"_R_G_L_0_G\"\n              android:rotation=\"0\"\n              android:scaleX=\"0\"\n              android:scaleY=\"0\"\n              android:translateY=\"-65\">\n            <path\n                android:name=\"_R_G_L_0_G_D_0_P_0\"\n                android:fillAlpha=\"1\"\n                android:fillType=\"nonZero\"\n                android:pathData=\" M-8.85 -30.58 C-22.6,-36.55 -36.55,-22.6 -30.58,-8.85 C-30.58,-8.85 -29.59,-6.58 -29.59,-6.58 C-27.77,-2.38 -27.77,2.38 -29.59,6.58 C-29.59,6.58 -30.58,8.85 -30.58,8.85 C-36.55,22.6 -22.6,36.55 -8.85,30.58 C-8.85,30.58 -6.58,29.59 -6.58,29.59 C-2.38,27.77 2.38,27.77 6.58,29.59 C6.58,29.59 8.85,30.58 8.85,30.58 C22.6,36.55 36.55,22.6 30.58,8.85 C30.58,8.85 29.59,6.58 29.59,6.58 C27.77,2.38 27.77,-2.38 29.59,-6.58 C29.59,-6.58 30.58,-8.85 30.58,-8.85 C36.55,-22.6 22.6,-36.55 8.85,-30.58 C8.85,-30.58 6.58,-29.59 6.58,-29.59 C2.38,-27.77 -2.38,-27.77 -6.58,-29.59 C-6.58,-29.59 -8.85,-30.58 -8.85,-30.58c \">\n              <aapt:attr\n                  name=\"android:fillColor\">\n                <gradient\n                    android:type=\"linear\"\n                    android:startX=\"-40\"\n                    android:startY=\"0\"\n                    android:endX=\"30\"\n                    android:endY=\"0\"\n                    android:tileMode=\"clamp\">\n                  <item\n                      android:color=\"#669DF6\"\n                      android:offset=\"0\"/>\n                  <item\n                      android:color=\"#3174F1\"\n                      android:offset=\"1\"/>\n                </gradient>\n              </aapt:attr>\n            </path>\n          </group>\n        </group>\n      </group>\n      <group android:name=\"time_group\" />\n    </vector>\n  </aapt:attr>\n  <target android:name=\"_R_G_L_3_G\">\n    <aapt:attr name=\"android:animation\">\n      <set android:ordering=\"together\">\n        <objectAnimator\n            android:duration=\"250\"\n            android:propertyName=\"scaleX\"\n            android:startOffset=\"0\"\n            android:valueFrom=\"0\"\n            android:valueTo=\"0\"\n            android:valueType=\"floatType\">\n          <aapt:attr name=\"android:interpolator\">\n            <pathInterpolator android:pathData=\"M 0.0,0.0 c0.4,0 0.2,1 1.0,1.0\" />\n          </aapt:attr>\n        </objectAnimator>\n        <objectAnimator\n            android:duration=\"250\"\n            android:propertyName=\"scaleY\"\n            android:startOffset=\"0\"\n            android:valueFrom=\"0\"\n            android:valueTo=\"0\"\n            android:valueType=\"floatType\">\n          <aapt:attr name=\"android:interpolator\">\n            <pathInterpolator android:pathData=\"M 0.0,0.0 c0.4,0 0.2,1 1.0,1.0\" />\n          </aapt:attr>\n        </objectAnimator>\n        <objectAnimator\n            android:duration=\"1083\"\n            android:propertyName=\"scaleX\"\n            android:startOffset=\"250\"\n            android:valueFrom=\"0\"\n            android:valueTo=\"1\"\n            android:valueType=\"floatType\">\n          <aapt:attr name=\"android:interpolator\">\n            <pathInterpolator android:pathData=\"M 0.0,0.0 c0.4,0 0.2,1 1.0,1.0\" />\n          </aapt:attr>\n        </objectAnimator>\n        <objectAnimator\n            android:duration=\"1083\"\n            android:propertyName=\"scaleY\"\n            android:startOffset=\"250\"\n            android:valueFrom=\"0\"\n            android:valueTo=\"1\"\n            android:valueType=\"floatType\">\n          <aapt:attr name=\"android:interpolator\">\n            <pathInterpolator android:pathData=\"M 0.0,0.0 c0.4,0 0.2,1 1.0,1.0\" />\n          </aapt:attr>\n        </objectAnimator>\n        <objectAnimator\n            android:duration=\"783\"\n            android:propertyName=\"scaleX\"\n            android:startOffset=\"1333\"\n            android:valueFrom=\"1\"\n            android:valueTo=\"1\"\n            android:valueType=\"floatType\">\n          <aapt:attr name=\"android:interpolator\">\n            <pathInterpolator android:pathData=\"M 0.0,0.0 c0.167,0 0,1 1.0,1.0\" />\n          </aapt:attr>\n        </objectAnimator>\n        <objectAnimator\n            android:duration=\"783\"\n            android:propertyName=\"scaleY\"\n            android:startOffset=\"1333\"\n            android:valueFrom=\"1\"\n            android:valueTo=\"1\"\n            android:valueType=\"floatType\">\n          <aapt:attr name=\"android:interpolator\">\n            <pathInterpolator android:pathData=\"M 0.0,0.0 c0.167,0 0,1 1.0,1.0\" />\n          </aapt:attr>\n        </objectAnimator>\n        <objectAnimator\n            android:duration=\"517\"\n            android:propertyName=\"scaleX\"\n            android:startOffset=\"2117\"\n            android:valueFrom=\"1\"\n            android:valueTo=\"0\"\n            android:valueType=\"floatType\">\n          <aapt:attr name=\"android:interpolator\">\n            <pathInterpolator android:pathData=\"M 0.0,0.0 c0.4,0 0.6,1 1.0,1.0\" />\n          </aapt:attr>\n        </objectAnimator>\n        <objectAnimator\n            android:duration=\"517\"\n            android:propertyName=\"scaleY\"\n            android:startOffset=\"2117\"\n            android:valueFrom=\"1\"\n            android:valueTo=\"0\"\n            android:valueType=\"floatType\">\n          <aapt:attr name=\"android:interpolator\">\n            <pathInterpolator android:pathData=\"M 0.0,0.0 c0.4,0 0.6,1 1.0,1.0\" />\n          </aapt:attr>\n        </objectAnimator>\n      </set>\n    </aapt:attr>\n  </target>\n  <target android:name=\"_R_G_L_3_G_N_1_T_0\">\n    <aapt:attr name=\"android:animation\">\n      <set android:ordering=\"together\">\n        <objectAnimator\n            android:duration=\"1833\"\n            android:propertyName=\"rotation\"\n            android:startOffset=\"0\"\n            android:valueFrom=\"0\"\n            android:valueTo=\"200\"\n            android:valueType=\"floatType\">\n          <aapt:attr name=\"android:interpolator\">\n            <pathInterpolator android:pathData=\"M 0.0,0.0 c0.207,0.857 0.836,0.744 1.0,1.0\" />\n          </aapt:attr>\n        </objectAnimator>\n        <objectAnimator\n            android:duration=\"800\"\n            android:propertyName=\"rotation\"\n            android:startOffset=\"1833\"\n            android:valueFrom=\"200\"\n            android:valueTo=\"450\"\n            android:valueType=\"floatType\">\n          <aapt:attr name=\"android:interpolator\">\n            <pathInterpolator android:pathData=\"M 0.0,0.0 c0.562,0.307 0.833,0.824 1.0,1.0\" />\n          </aapt:attr>\n        </objectAnimator>\n      </set>\n    </aapt:attr>\n  </target>\n  <target android:name=\"_R_G_L_2_G\">\n    <aapt:attr name=\"android:animation\">\n      <set android:ordering=\"together\">\n        <objectAnimator\n            android:duration=\"1833\"\n            android:propertyName=\"rotation\"\n            android:startOffset=\"0\"\n            android:valueFrom=\"0\"\n            android:valueTo=\"-200\"\n            android:valueType=\"floatType\">\n          <aapt:attr name=\"android:interpolator\">\n            <pathInterpolator android:pathData=\"M 0.0,0.0 c0.203,0.879 0.837,0.754 1.0,1.0\" />\n          </aapt:attr>\n        </objectAnimator>\n        <objectAnimator\n            android:duration=\"800\"\n            android:propertyName=\"rotation\"\n            android:startOffset=\"1833\"\n            android:valueFrom=\"-200\"\n            android:valueTo=\"-450\"\n            android:valueType=\"floatType\">\n          <aapt:attr name=\"android:interpolator\">\n            <pathInterpolator android:pathData=\"M 0.0,0.0 c0.583,0.308 0.836,0.785 1.0,1.0\" />\n          </aapt:attr>\n        </objectAnimator>\n      </set>\n    </aapt:attr>\n  </target>\n  <target android:name=\"_R_G_L_2_G\">\n    <aapt:attr name=\"android:animation\">\n      <set android:ordering=\"together\">\n        <objectAnimator\n            android:duration=\"167\"\n            android:propertyName=\"scaleX\"\n            android:startOffset=\"0\"\n            android:valueFrom=\"0\"\n            android:valueTo=\"0\"\n            android:valueType=\"floatType\">\n          <aapt:attr name=\"android:interpolator\">\n            <pathInterpolator android:pathData=\"M 0.0,0.0 c0.4,0 0.2,1 1.0,1.0\" />\n          </aapt:attr>\n        </objectAnimator>\n        <objectAnimator\n            android:duration=\"167\"\n            android:propertyName=\"scaleY\"\n            android:startOffset=\"0\"\n            android:valueFrom=\"0\"\n            android:valueTo=\"0\"\n            android:valueType=\"floatType\">\n          <aapt:attr name=\"android:interpolator\">\n            <pathInterpolator android:pathData=\"M 0.0,0.0 c0.4,0 0.2,1 1.0,1.0\" />\n          </aapt:attr>\n        </objectAnimator>\n        <objectAnimator\n            android:duration=\"1050\"\n            android:propertyName=\"scaleX\"\n            android:startOffset=\"167\"\n            android:valueFrom=\"0\"\n            android:valueTo=\"1\"\n            android:valueType=\"floatType\">\n          <aapt:attr name=\"android:interpolator\">\n            <pathInterpolator android:pathData=\"M 0.0,0.0 c0.4,0 0.2,1 1.0,1.0\" />\n          </aapt:attr>\n        </objectAnimator>\n        <objectAnimator\n            android:duration=\"1050\"\n            android:propertyName=\"scaleY\"\n            android:startOffset=\"167\"\n            android:valueFrom=\"0\"\n            android:valueTo=\"1\"\n            android:valueType=\"floatType\">\n          <aapt:attr name=\"android:interpolator\">\n            <pathInterpolator android:pathData=\"M 0.0,0.0 c0.4,0 0.2,1 1.0,1.0\" />\n          </aapt:attr>\n        </objectAnimator>\n        <objectAnimator\n            android:duration=\"833\"\n            android:propertyName=\"scaleX\"\n            android:startOffset=\"1217\"\n            android:valueFrom=\"1\"\n            android:valueTo=\"1\"\n            android:valueType=\"floatType\">\n          <aapt:attr name=\"android:interpolator\">\n            <pathInterpolator android:pathData=\"M 0.0,0.0 c0.167,0 0,1 1.0,1.0\" />\n          </aapt:attr>\n        </objectAnimator>\n        <objectAnimator\n            android:duration=\"833\"\n            android:propertyName=\"scaleY\"\n            android:startOffset=\"1217\"\n            android:valueFrom=\"1\"\n            android:valueTo=\"1\"\n            android:valueType=\"floatType\">\n          <aapt:attr name=\"android:interpolator\">\n            <pathInterpolator android:pathData=\"M 0.0,0.0 c0.167,0 0,1 1.0,1.0\" />\n          </aapt:attr>\n        </objectAnimator>\n        <objectAnimator\n            android:duration=\"517\"\n            android:propertyName=\"scaleX\"\n            android:startOffset=\"2050\"\n            android:valueFrom=\"1\"\n            android:valueTo=\"0\"\n            android:valueType=\"floatType\">\n          <aapt:attr name=\"android:interpolator\">\n            <pathInterpolator android:pathData=\"M 0.0,0.0 c0.4,0 0.6,1 1.0,1.0\" />\n          </aapt:attr>\n        </objectAnimator>\n        <objectAnimator\n            android:duration=\"517\"\n            android:propertyName=\"scaleY\"\n            android:startOffset=\"2050\"\n            android:valueFrom=\"1\"\n            android:valueTo=\"0\"\n            android:valueType=\"floatType\">\n          <aapt:attr name=\"android:interpolator\">\n            <pathInterpolator android:pathData=\"M 0.0,0.0 c0.4,0 0.6,1 1.0,1.0\" />\n          </aapt:attr>\n        </objectAnimator>\n      </set>\n    </aapt:attr>\n  </target>\n  <target android:name=\"_R_G_L_2_G_N_1_T_0\">\n    <aapt:attr name=\"android:animation\">\n      <set android:ordering=\"together\">\n        <objectAnimator\n            android:duration=\"1833\"\n            android:propertyName=\"rotation\"\n            android:startOffset=\"0\"\n            android:valueFrom=\"0\"\n            android:valueTo=\"200\"\n            android:valueType=\"floatType\">\n          <aapt:attr name=\"android:interpolator\">\n            <pathInterpolator android:pathData=\"M 0.0,0.0 c0.207,0.857 0.836,0.744 1.0,1.0\" />\n          </aapt:attr>\n        </objectAnimator>\n        <objectAnimator\n            android:duration=\"800\"\n            android:propertyName=\"rotation\"\n            android:startOffset=\"1833\"\n            android:valueFrom=\"200\"\n            android:valueTo=\"450\"\n            android:valueType=\"floatType\">\n          <aapt:attr name=\"android:interpolator\">\n            <pathInterpolator android:pathData=\"M 0.0,0.0 c0.562,0.307 0.833,0.824 1.0,1.0\" />\n          </aapt:attr>\n        </objectAnimator>\n      </set>\n    </aapt:attr>\n  </target>\n  <target android:name=\"_R_G_L_1_G\">\n    <aapt:attr name=\"android:animation\">\n      <set android:ordering=\"together\">\n        <objectAnimator\n            android:duration=\"1833\"\n            android:propertyName=\"rotation\"\n            android:startOffset=\"0\"\n            android:valueFrom=\"0\"\n            android:valueTo=\"-200\"\n            android:valueType=\"floatType\">\n          <aapt:attr name=\"android:interpolator\">\n            <pathInterpolator android:pathData=\"M 0.0,0.0 c0.203,0.879 0.837,0.754 1.0,1.0\" />\n          </aapt:attr>\n        </objectAnimator>\n        <objectAnimator\n            android:duration=\"800\"\n            android:propertyName=\"rotation\"\n            android:startOffset=\"1833\"\n            android:valueFrom=\"-200\"\n            android:valueTo=\"-450\"\n            android:valueType=\"floatType\">\n          <aapt:attr name=\"android:interpolator\">\n            <pathInterpolator android:pathData=\"M 0.0,0.0 c0.583,0.308 0.836,0.785 1.0,1.0\" />\n          </aapt:attr>\n        </objectAnimator>\n      </set>\n    </aapt:attr>\n  </target>\n  <target android:name=\"_R_G_L_1_G\">\n    <aapt:attr name=\"android:animation\">\n      <set android:ordering=\"together\">\n        <objectAnimator\n            android:duration=\"83\"\n            android:propertyName=\"scaleX\"\n            android:startOffset=\"0\"\n            android:valueFrom=\"0\"\n            android:valueTo=\"0\"\n            android:valueType=\"floatType\">\n          <aapt:attr name=\"android:interpolator\">\n            <pathInterpolator android:pathData=\"M 0.0,0.0 c0.4,0 0.2,1 1.0,1.0\" />\n          </aapt:attr>\n        </objectAnimator>\n        <objectAnimator\n            android:duration=\"83\"\n            android:propertyName=\"scaleY\"\n            android:startOffset=\"0\"\n            android:valueFrom=\"0\"\n            android:valueTo=\"0\"\n            android:valueType=\"floatType\">\n          <aapt:attr name=\"android:interpolator\">\n            <pathInterpolator android:pathData=\"M 0.0,0.0 c0.4,0 0.2,1 1.0,1.0\" />\n          </aapt:attr>\n        </objectAnimator>\n        <objectAnimator\n            android:duration=\"1017\"\n            android:propertyName=\"scaleX\"\n            android:startOffset=\"83\"\n            android:valueFrom=\"0\"\n            android:valueTo=\"1\"\n            android:valueType=\"floatType\">\n          <aapt:attr name=\"android:interpolator\">\n            <pathInterpolator android:pathData=\"M 0.0,0.0 c0.4,0 0.2,1 1.0,1.0\" />\n          </aapt:attr>\n        </objectAnimator>\n        <objectAnimator\n            android:duration=\"1017\"\n            android:propertyName=\"scaleY\"\n            android:startOffset=\"83\"\n            android:valueFrom=\"0\"\n            android:valueTo=\"1\"\n            android:valueType=\"floatType\">\n          <aapt:attr name=\"android:interpolator\">\n            <pathInterpolator android:pathData=\"M 0.0,0.0 c0.4,0 0.2,1 1.0,1.0\" />\n          </aapt:attr>\n        </objectAnimator>\n        <objectAnimator\n            android:duration=\"883\"\n            android:propertyName=\"scaleX\"\n            android:startOffset=\"1100\"\n            android:valueFrom=\"1\"\n            android:valueTo=\"1\"\n            android:valueType=\"floatType\">\n          <aapt:attr name=\"android:interpolator\">\n            <pathInterpolator android:pathData=\"M 0.0,0.0 c0.333,0 0,1 1.0,1.0\" />\n          </aapt:attr>\n        </objectAnimator>\n        <objectAnimator\n            android:duration=\"883\"\n            android:propertyName=\"scaleY\"\n            android:startOffset=\"1100\"\n            android:valueFrom=\"1\"\n            android:valueTo=\"1\"\n            android:valueType=\"floatType\">\n          <aapt:attr name=\"android:interpolator\">\n            <pathInterpolator android:pathData=\"M 0.0,0.0 c0.333,0 0,1 1.0,1.0\" />\n          </aapt:attr>\n        </objectAnimator>\n        <objectAnimator\n            android:duration=\"517\"\n            android:propertyName=\"scaleX\"\n            android:startOffset=\"1983\"\n            android:valueFrom=\"1\"\n            android:valueTo=\"0\"\n            android:valueType=\"floatType\">\n          <aapt:attr name=\"android:interpolator\">\n            <pathInterpolator android:pathData=\"M 0.0,0.0 c0.4,0 0.6,1 1.0,1.0\" />\n          </aapt:attr>\n        </objectAnimator>\n        <objectAnimator\n            android:duration=\"517\"\n            android:propertyName=\"scaleY\"\n            android:startOffset=\"1983\"\n            android:valueFrom=\"1\"\n            android:valueTo=\"0\"\n            android:valueType=\"floatType\">\n          <aapt:attr name=\"android:interpolator\">\n            <pathInterpolator android:pathData=\"M 0.0,0.0 c0.4,0 0.6,1 1.0,1.0\" />\n          </aapt:attr>\n        </objectAnimator>\n      </set>\n    </aapt:attr>\n  </target>\n  <target android:name=\"_R_G_L_1_G_N_1_T_0\">\n    <aapt:attr name=\"android:animation\">\n      <set android:ordering=\"together\">\n        <objectAnimator\n            android:duration=\"1833\"\n            android:propertyName=\"rotation\"\n            android:startOffset=\"0\"\n            android:valueFrom=\"0\"\n            android:valueTo=\"200\"\n            android:valueType=\"floatType\">\n          <aapt:attr name=\"android:interpolator\">\n            <pathInterpolator android:pathData=\"M 0.0,0.0 c0.207,0.857 0.836,0.744 1.0,1.0\" />\n          </aapt:attr>\n        </objectAnimator>\n        <objectAnimator\n            android:duration=\"800\"\n            android:propertyName=\"rotation\"\n            android:startOffset=\"1833\"\n            android:valueFrom=\"200\"\n            android:valueTo=\"450\"\n            android:valueType=\"floatType\">\n          <aapt:attr name=\"android:interpolator\">\n            <pathInterpolator android:pathData=\"M 0.0,0.0 c0.562,0.307 0.833,0.824 1.0,1.0\" />\n          </aapt:attr>\n        </objectAnimator>\n      </set>\n    </aapt:attr>\n  </target>\n  <target android:name=\"_R_G_L_0_G\">\n    <aapt:attr name=\"android:animation\">\n      <set android:ordering=\"together\">\n        <objectAnimator\n            android:duration=\"1833\"\n            android:propertyName=\"rotation\"\n            android:startOffset=\"0\"\n            android:valueFrom=\"0\"\n            android:valueTo=\"-200\"\n            android:valueType=\"floatType\">\n          <aapt:attr name=\"android:interpolator\">\n            <pathInterpolator android:pathData=\"M 0.0,0.0 c0.203,0.879 0.837,0.754 1.0,1.0\" />\n          </aapt:attr>\n        </objectAnimator>\n        <objectAnimator\n            android:duration=\"800\"\n            android:propertyName=\"rotation\"\n            android:startOffset=\"1833\"\n            android:valueFrom=\"-200\"\n            android:valueTo=\"-450\"\n            android:valueType=\"floatType\">\n          <aapt:attr name=\"android:interpolator\">\n            <pathInterpolator android:pathData=\"M 0.0,0.0 c0.583,0.308 0.836,0.785 1.0,1.0\" />\n          </aapt:attr>\n        </objectAnimator>\n      </set>\n    </aapt:attr>\n  </target>\n  <target android:name=\"_R_G_L_0_G\">\n    <aapt:attr name=\"android:animation\">\n      <set android:ordering=\"together\">\n        <objectAnimator\n            android:duration=\"983\"\n            android:propertyName=\"scaleX\"\n            android:startOffset=\"0\"\n            android:valueFrom=\"0\"\n            android:valueTo=\"1\"\n            android:valueType=\"floatType\">\n          <aapt:attr name=\"android:interpolator\">\n            <pathInterpolator android:pathData=\"M 0.0,0.0 c0.4,0 0.2,1 1.0,1.0\" />\n          </aapt:attr>\n        </objectAnimator>\n        <objectAnimator\n            android:duration=\"983\"\n            android:propertyName=\"scaleY\"\n            android:startOffset=\"0\"\n            android:valueFrom=\"0\"\n            android:valueTo=\"1\"\n            android:valueType=\"floatType\">\n          <aapt:attr name=\"android:interpolator\">\n            <pathInterpolator android:pathData=\"M 0.0,0.0 c0.4,0 0.2,1 1.0,1.0\" />\n          </aapt:attr>\n        </objectAnimator>\n        <objectAnimator\n            android:duration=\"933\"\n            android:propertyName=\"scaleX\"\n            android:startOffset=\"983\"\n            android:valueFrom=\"1\"\n            android:valueTo=\"1\"\n            android:valueType=\"floatType\">\n          <aapt:attr name=\"android:interpolator\">\n            <pathInterpolator android:pathData=\"M 0.0,0.0 c0.333,0 0,1 1.0,1.0\" />\n          </aapt:attr>\n        </objectAnimator>\n        <objectAnimator\n            android:duration=\"933\"\n            android:propertyName=\"scaleY\"\n            android:startOffset=\"983\"\n            android:valueFrom=\"1\"\n            android:valueTo=\"1\"\n            android:valueType=\"floatType\">\n          <aapt:attr name=\"android:interpolator\">\n            <pathInterpolator android:pathData=\"M 0.0,0.0 c0.333,0 0,1 1.0,1.0\" />\n          </aapt:attr>\n        </objectAnimator>\n        <objectAnimator\n            android:duration=\"517\"\n            android:propertyName=\"scaleX\"\n            android:startOffset=\"1917\"\n            android:valueFrom=\"1\"\n            android:valueTo=\"0\"\n            android:valueType=\"floatType\">\n          <aapt:attr name=\"android:interpolator\">\n            <pathInterpolator android:pathData=\"M 0.0,0.0 c0.4,0 0.6,1 1.0,1.0\" />\n          </aapt:attr>\n        </objectAnimator>\n        <objectAnimator\n            android:duration=\"517\"\n            android:propertyName=\"scaleY\"\n            android:startOffset=\"1917\"\n            android:valueFrom=\"1\"\n            android:valueTo=\"0\"\n            android:valueType=\"floatType\">\n          <aapt:attr name=\"android:interpolator\">\n            <pathInterpolator android:pathData=\"M 0.0,0.0 c0.4,0 0.6,1 1.0,1.0\" />\n          </aapt:attr>\n        </objectAnimator>\n      </set>\n    </aapt:attr>\n  </target>\n  <target android:name=\"_R_G_L_0_G_N_1_T_0\">\n    <aapt:attr name=\"android:animation\">\n      <set android:ordering=\"together\">\n        <objectAnimator\n            android:duration=\"1833\"\n            android:propertyName=\"rotation\"\n            android:startOffset=\"0\"\n            android:valueFrom=\"0\"\n            android:valueTo=\"200\"\n            android:valueType=\"floatType\">\n          <aapt:attr name=\"android:interpolator\">\n            <pathInterpolator android:pathData=\"M 0.0,0.0 c0.207,0.857 0.836,0.744 1.0,1.0\" />\n          </aapt:attr>\n        </objectAnimator>\n        <objectAnimator\n            android:duration=\"800\"\n            android:propertyName=\"rotation\"\n            android:startOffset=\"1833\"\n            android:valueFrom=\"200\"\n            android:valueTo=\"450\"\n            android:valueType=\"floatType\">\n          <aapt:attr name=\"android:interpolator\">\n            <pathInterpolator android:pathData=\"M 0.0,0.0 c0.562,0.307 0.833,0.824 1.0,1.0\" />\n          </aapt:attr>\n        </objectAnimator>\n      </set>\n    </aapt:attr>\n  </target>\n  <target android:name=\"time_group\">\n    <aapt:attr name=\"android:animation\">\n      <set android:ordering=\"together\">\n        <objectAnimator\n            android:duration=\"2633\"\n            android:propertyName=\"translateX\"\n            android:startOffset=\"0\"\n            android:valueFrom=\"0\"\n            android:valueTo=\"1\"\n            android:valueType=\"floatType\" />\n      </set>\n    </aapt:attr>\n  </target>\n</animated-vector>"
  },
  {
    "path": "Android/src/app/src/main/res/drawable/text_spark.xml",
    "content": "<!--\n Copyright 2025 Google LLC\n\n Licensed under the Apache License, Version 2.0 (the \"License\");\n you may not use this file except in compliance with the License.\n You may obtain a copy of the License at\n\n     http://www.apache.org/licenses/LICENSE-2.0\n\n Unless required by applicable law or agreed to in writing, software\n distributed under the License is distributed on an \"AS IS\" BASIS,\n WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n See the License for the specific language governing permissions and\n limitations under the License.\n-->\n\n<vector xmlns:android=\"http://schemas.android.com/apk/res/android\"\nandroid:width=\"37dp\"\nandroid:height=\"36dp\"\nandroid:viewportWidth=\"37\"\nandroid:viewportHeight=\"36\">\n<group>\n    <path\n        android:fillColor=\"#FFE37400\"\n        android:pathData=\"M19.8 33.25l-6.34-6.37 1.87-1.86 4.46 4.47 9.13-9.1 1.86 1.91L19.8 33.25ZM2.7 24.1l7.7-20.31h3.03l7.68 20.3h-2.99l-1.98-5.35H7.6L5.66 24.1H2.7Zm5.8-7.89h6.75L11.95 7h-0.08L8.5 16.2Zm18.28 1.29c0-2.3-0.81-4.26-2.44-5.87-1.6-1.63-3.56-2.44-5.87-2.44 2.3 0 4.26-0.8 5.87-2.41 1.63-1.63 2.44-3.6 2.44-5.9 0 2.3 0.8 4.27 2.41 5.9 1.63 1.6 3.6 2.4 5.9 2.4-2.3 0-4.27 0.82-5.9 2.45-1.6 1.6-2.4 3.56-2.4 5.87Z\"/>\n</group>\n</vector>"
  },
  {
    "path": "Android/src/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml",
    "content": "<?xml version=\"1.0\" encoding=\"utf-8\"?>\n<!--\n Copyright 2025 Google LLC\n\n Licensed under the Apache License, Version 2.0 (the \"License\");\n you may not use this file except in compliance with the License.\n You may obtain a copy of the License at\n\n     http://www.apache.org/licenses/LICENSE-2.0\n\n Unless required by applicable law or agreed to in writing, software\n distributed under the License is distributed on an \"AS IS\" BASIS,\n WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n See the License for the specific language governing permissions and\n limitations under the License.\n-->\n<adaptive-icon xmlns:android=\"http://schemas.android.com/apk/res/android\">\n  <background android:drawable=\"@mipmap/ic_launcher_background\"/>\n  <foreground android:drawable=\"@mipmap/ic_launcher_foreground\"/>\n  <monochrome android:drawable=\"@mipmap/ic_launcher_monochrome\"/>\n</adaptive-icon>"
  },
  {
    "path": "Android/src/app/src/main/res/values/dimens.xml",
    "content": "<?xml version=\"1.0\" encoding=\"utf-8\"?>\n<!--\n Copyright 2025 Google LLC\n\n Licensed under the Apache License, Version 2.0 (the \"License\");\n you may not use this file except in compliance with the License.\n You may obtain a copy of the License at\n\n     http://www.apache.org/licenses/LICENSE-2.0\n\n Unless required by applicable law or agreed to in writing, software\n distributed under the License is distributed on an \"AS IS\" BASIS,\n WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n See the License for the specific language governing permissions and\n limitations under the License.\n-->\n\n\n<resources>\n    <dimen name=\"model_selector_height\">54dp</dimen>\n    <dimen name=\"chat_bubble_corner_radius\">24dp</dimen>\n</resources>"
  },
  {
    "path": "Android/src/app/src/main/res/values/ic_launcher_background.xml",
    "content": "<?xml version=\"1.0\" encoding=\"utf-8\"?>\n<!--\n Copyright 2025 Google LLC\n\n Licensed under the Apache License, Version 2.0 (the \"License\");\n you may not use this file except in compliance with the License.\n You may obtain a copy of the License at\n\n     http://www.apache.org/licenses/LICENSE-2.0\n\n Unless required by applicable law or agreed to in writing, software\n distributed under the License is distributed on an \"AS IS\" BASIS,\n WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n See the License for the specific language governing permissions and\n limitations under the License.\n-->\n\n<resources>\n    <color name=\"ic_launcher_background\">#ffffff</color>\n</resources>"
  },
  {
    "path": "Android/src/app/src/main/res/values/strings.xml",
    "content": "<!--\n Copyright 2025 Google LLC\n\n Licensed under the Apache License, Version 2.0 (the \"License\");\n you may not use this file except in compliance with the License.\n You may obtain a copy of the License at\n\n     http://www.apache.org/licenses/LICENSE-2.0\n\n Unless required by applicable law or agreed to in writing, software\n distributed under the License is distributed on an \"AS IS\" BASIS,\n WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n See the License for the specific language governing permissions and\n limitations under the License.\n-->\n\n<resources>\n  <!-- TODO(jingjin): Add translation support when i18n is ready. -->\n  <string name=\"all\" translatable=\"false\">All</string>\n  <string name=\"add\" translatable=\"false\">Add</string>\n  <string name=\"add_audio\" translatable=\"false\">Add audio</string>\n  <string name=\"add_image\" translatable=\"false\">Add image</string>\n  <string name=\"app_intro\" translatable=\"false\">Explore a world of amazing on-device models from</string>\n  <string name=\"app_name\" translatable=\"false\">Google AI Edge Gallery</string>\n  <string name=\"app_name_first_part\" translatable=\"false\">Google AI</string>\n  <string name=\"app_name_second_part\" translatable=\"false\">Edge Gallery</string>\n  <string name=\"gallery_news_notification_title\" translatable=\"false\">Gallery News</string>\n  <string name=\"baseline\" translatable=\"false\">Baseline</string>\n  <string name=\"basic_info\" translatable=\"false\">Basic info</string>\n  <string name=\"benchmark\" translatable=\"false\">Benchmark</string>\n  <string name=\"benchmark_comparison_help_title\" translatable=\"false\">How to compare benchmark runs</string>\n  <string name=\"benchmark_comparison_help_content\" translatable=\"false\">Compare benchmark runs by tapping the **Baseline** chip on any run result card to set the run as the comparison baseline. We\\'ll automatically calculate and display the percentage differences in **all other** run result cards.</string>\n  <string name=\"benchmark_model\" translatable=\"false\">Benchmark model</string>\n  <string name=\"benchmark_results\" translatable=\"false\">Benchmark results</string>\n  <string name=\"benchmark_no_results\" translatable=\"false\">No benchmark results</string>\n  <string name=\"benchmark_tokens_limit_message\" translatable=\"false\">Ensure that the sum of prefill and decode tokens remains under the model\\'s maximum token limit (%d vs %d)</string>\n  <string name=\"best_overall\" translatable=\"false\">Best overall</string>\n  <string name=\"model_list_experimental_label\" translatable=\"false\">EXPERIMENTAL</string>\n  <string name=\"cancel\" translatable=\"false\">Cancel</string>\n  <string name=\"category_unlabeled\" translatable=\"false\">Unlabeled</string>\n  <string name=\"category_llm\" translatable=\"false\">LLM</string>\n  <string name=\"category_agents\" translatable=\"false\">Agents</string>\n  <string name=\"category_classical_ml\" translatable=\"false\">Classical ML</string>\n  <string name=\"category_experimental\" translatable=\"false\">Experimental</string>\n  <string name=\"chat_textinput_placeholder\" translatable=\"false\">Type message…</string>\n  <string name=\"chat_you\" translatable=\"false\">You</string>\n  <string name=\"chat_llm_agent_name\" translatable=\"false\">LLM</string>\n  <string name=\"checking_access\" translatable=\"false\">Checking access...</string>\n  <string name=\"close\" translatable=\"false\">Close</string>\n  <string name=\"collapse_all\" translatable=\"false\">Collapse all</string>\n  <string name=\"discard\" translatable=\"false\">Discard</string>\n  <string name=\"discard_changes_dialog_title\" translatable=\"false\">Discard changes?</string>\n  <string name=\"discard_changes_dialog_content\" translatable=\"false\">You have unsaved changes. Are you sure you want to discard them?</string>\n  <string name=\"delete_benchmark_result_dialog_title\" translatable=\"false\">Delete benchmark result</string>\n  <string name=\"delete_benchmark_result_dialog_content\" translatable=\"false\">Are you sure you want to delete the benchmark result?</string>\n  <string name=\"error\" translatable=\"false\">Error</string>\n  <string name=\"confirm_delete_model_dialog_title\" translatable=\"false\">Delete download</string>\n  <string name=\"confirm_delete_model_dialog_content\" translatable=\"false\">Are you sure you want to delete the downloaded model \\\"%s\\\"?</string>\n  <string name=\"conversation_reset_message\" translatable=\"false\">Conversation was reset</string>\n  <string name=\"engin_reset_message\" translatable=\"false\">Engine was reset</string>\n  <string name=\"chat_agent_agent_name\" translatable=\"false\">Agent</string>\n  <string name=\"chat_generic_agent_name\" translatable=\"false\">Model</string>\n  <string name=\"chat_generic_result_name\" translatable=\"false\">Result</string>\n  <string name=\"clear\" translatable=\"false\">Clear</string>\n  <string name=\"collect\" translatable=\"false\">Collect</string>\n  <string name=\"color\" translatable=\"false\">Color</string>\n  <string name=\"config_dialog_tab_model_configs\" translatable=\"false\">Model configs</string>\n  <string name=\"config_dialog_tab_system_prompt\" translatable=\"false\">System prompt</string>\n  <string name=\"continue_button_label\" translatable=\"false\">Continue</string>\n  <string name=\"conversation_history\" translatable=\"false\">Conversation History</string>\n  <string name=\"copy\" translatable=\"false\">Copy</string>\n  <string name=\"create\" translatable=\"false\">Create</string>\n  <string name=\"delete\" translatable=\"false\">Delete</string>\n  <string name=\"description_required\" translatable=\"false\">Description*</string>\n  <string name=\"deselect_all\" translatable=\"false\">Deselect all</string>\n  <string name=\"disabled\" translatable=\"false\">Disabled</string>\n  <string name=\"dismiss\" translatable=\"false\">Dismiss</string>\n  <string name=\"done\" translatable=\"false\">Done</string>\n  <string name=\"download\" translatable=\"false\">Download</string>\n  <string name=\"downloaded_size\" translatable=\"false\">%1$s downloaded</string>\n  <string name=\"drawer_settings_label\" translatable=\"false\">Settings</string>\n  <string name=\"drawer_settings_description\" translatable=\"false\">Manage application settings</string>\n  <string name=\"drawer_models_label\" translatable=\"false\">Models</string>\n  <string name=\"drawer_models_description\" translatable=\"false\">Browse, try, and benchmark models</string>\n  <string name=\"edit\" translatable=\"false\">Edit</string>\n  <string name=\"edit_secret\" translatable=\"false\">Edit secret</string>\n  <string name=\"enter_instruction\" translatable=\"false\">Enter instruction</string>\n  <string name=\"expand_all\" translatable=\"false\">Expand all</string>\n  <string name=\"failed_to_save\" translatable=\"false\">Failed to save</string>\n  <string name=\"flashlight_off_message\" translatable=\"false\">I\\'ve turned off the flashlight.</string>\n  <string name=\"flashlight_on_message\" translatable=\"false\">I\\'ve turned on the flashlight.</string>\n  <string name=\"function_name\" translatable=\"false\">Function name</string>\n  <string name=\"generate_and_copy\" translatable=\"false\">Generate and copy</string>\n  <string name=\"grant_permissions\" translatable=\"false\">Grant permissions</string>\n  <string name=\"grant_record_audio_permission\" translatable=\"false\">Grant record audio permission</string>\n  <string name=\"hold_down_to_talk\" translatable=\"false\">Hold to talk</string>\n  <string name=\"input_history\" translatable=\"false\">Input history</string>\n  <string name=\"instructions\" translatable=\"false\">Instructions</string>\n  <string name=\"introducing\" translatable=\"false\">Introducing</string>\n  <string name=\"invalid_response\" translatable=\"false\">Invalid response</string>\n  <string name=\"learn_more\" translatable=\"false\">Learn more</string>\n  <string name=\"listening\" translatable=\"false\">Listening...</string>\n  <string name=\"litert_community_label\" translatable=\"false\">LiteRT community</string>\n  <string name=\"loading_model_list\" translatable=\"false\">Loading model list...</string>\n  <string name=\"memory_warning_content\" translatable=\"false\">The model you\\'ve selected may exceed your device\\'s memory, which can cause the app to crash. For the best experience, we recommend trying a smaller model.</string>\n  <string name=\"memory_warning_proceed_anyway\" translatable=\"false\">Proceed anyway</string>\n  <string name=\"memory_warning_title\" translatable=\"false\">Memory Warning</string>\n  <string name=\"model_is_initializing_msg\" translatable=\"false\">Initializing model…</string>\n  <string name=\"model_list_imported_models_title\" translatable=\"false\">Imported models</string>\n  <plurals name=\"model_list_number_of_models_available\" translatable=\"false\">\n    <item quantity=\"one\">%d model available</item>\n    <item quantity=\"other\">%d models available</item>\n  </plurals>\n  <string name=\"model_list_recommended_models_title\" translatable=\"false\">Recommended models</string>\n  <string name=\"model_manager\" translatable=\"false\">Model Manager</string>\n  <string name=\"model_manager_select_task_title\" translatable=\"false\">Select a use case to try</string>\n  <string name=\"model_not_downloaded_msg\" translatable=\"false\">Model not downloaded yet</string>\n  <string name=\"name\" translatable=\"false\">Name</string>\n  <string name=\"no_cutouts_collected\" translatable=\"false\">No cutouts collected</string>\n  <string name=\"notification_content_fail\" translatable=\"false\">Failed to download model \"%s\"</string>\n  <string name=\"notification_content_success\" translatable=\"false\">Model \"%s\" has been downloaded</string>\n  <string name=\"notification_title_fail\" translatable=\"false\">Model download failed</string>\n  <string name=\"notification_title_success\" translatable=\"false\">Model download succeeded</string>\n  <string name=\"ok\" translatable=\"false\">OK</string>\n  <string name=\"paste_from_clipboard\" translatable=\"false\">Paste from clipboard</string>\n  <string name=\"pick_from_album\" translatable=\"false\">Pick from album</string>\n  <string name=\"preview\" translatable=\"false\">Preview</string>\n  <string name=\"release_to_send\" translatable=\"false\">Release to send</string>\n  <string name=\"replace\" translatable=\"false\">Replace</string>\n  <string name=\"results\" translatable=\"false\">Results</string>\n  <string name=\"reset\" translatable=\"false\">Reset</string>\n  <string name=\"reset_note\" translatable=\"false\">Tapping \\\"Reset\\\" will reset LiteRT-LM engine only. The game progress won\\'t be affected.</string>\n  <string name=\"resetting_engine\" translatable=\"false\">Resetting engine...</string>\n  <string name=\"restore_default\" translatable=\"false\">Restore default</string>\n  <string name=\"reinitializing_description\" translatable=\"false\">The game progress won\\'t be affected</string>\n  <string name=\"run\" translatable=\"false\">Run</string>\n  <string name=\"running\" translatable=\"false\">Running...</string>\n  <plurals name=\"runs\" translatable=\"false\">\n    <item quantity=\"one\">%d run</item>\n    <item quantity=\"other\">%d runs</item>\n  </plurals>\n  <string name=\"run_again\" translatable=\"false\">Run again</string>\n  <string name=\"run_benchmark\" translatable=\"false\">Run benchmark</string>\n  <string name=\"run_benchmark_confirmation_msg\" translatable=\"false\">This process typically takes several minutes and cannot be interrupted once started. Would you like to proceed?</string>\n  <string name=\"running_benchmark_msg\" translatable=\"false\">Running model benchmark...</string>\n  <string name=\"secret\" translatable=\"false\">Secret</string>\n  <string name=\"select_all\" translatable=\"false\">Select all</string>\n  <string name=\"select_downloaded_model\" translatable=\"false\">Select a downloaded model</string>\n  <string name=\"select_model\" translatable=\"false\">Select a model</string>\n  <string name=\"selected_count\" translatable=\"false\">%d selected</string>\n  <string name=\"settings_dialog_tos_title\" translatable=\"false\">Terms of service</string>\n  <string name=\"settings_dialog_view_app_terms_of_service\" translatable=\"false\">View App Terms of Service</string>\n  <string name=\"settings_dialog_gemma_prohibited_use_policy\" translatable=\"false\">Gemma Prohibited Use Policy</string>\n  <string name=\"share\" translatable=\"false\">Share</string>\n  <string name=\"slide_up_to_cancel\" translatable=\"false\">Slide up to cancel</string>\n  <string name=\"system_prompt_updated\" translatable=\"false\">System prompt updated</string>\n  <string name=\"take_a_picture\" translatable=\"false\">Take a picture</string>\n  <string name=\"tap_to_change_color\" translatable=\"false\">Tap to change color</string>\n  <string name=\"tap_to_see_value\" translatable=\"false\">Tap on graph to see value</string>\n  <string name=\"test\" translatable=\"false\">Test</string>\n  <string name=\"text_image_generation_text_field_placeholder\" translatable=\"false\">Type prompt…</string>\n  <string name=\"text_input_placeholder_llm_chat\" translatable=\"false\">Type prompt…</string>\n  <string name=\"tos_dialog_title_app\" translatable=\"false\">Welcome to Google AI Edge Gallery</string>\n  <string name=\"tos_dialog_title_gemma\" translatable=\"false\">Gemma Terms of Use</string>\n  <string name=\"tos_dialog_accept_and_continue_button_label\" translatable=\"false\">Accept &amp; continue</string>\n  <string name=\"tos_dialog_agree_and_continue_button_label\" translatable=\"false\">Agree &amp; continue</string>\n  <string name=\"try_it\" translatable=\"false\">Try it</string>\n  <string name=\"undo\" translatable=\"false\">Undo</string>\n  <string name=\"unsupported_action\" translatable=\"false\">Unsupported action</string>\n  <string name=\"set_screen_brightness_message\" translatable=\"false\">I\\'ve set the screen brightness to %1$d%%.</string>\n  <string name=\"set_timer_message\" translatable=\"false\">Setting the timer for %1$d seconds.</string>\n  <string name=\"add_calendar_event_message\" translatable=\"false\">Adding \\\"Team meeting (10AM-11AM)\\\" to your calendar.</string>\n  <string name=\"take_picture_message\" translatable=\"false\">Opening the camera app.</string>\n  <string name=\"mobile_actions_title\" translatable=\"false\">Mobile Actions</string>\n  <string name=\"mobile_actions_description\" translatable=\"false\">Control your device with simple commands</string>\n  <string name=\"mobile_actions_supported_actions\" translatable=\"false\">Supported sample actions:</string>\n  <string name=\"mobile_actions_tab_model_response\" translatable=\"false\">Model response</string>\n  <string name=\"mobile_actions_tab_function_called\" translatable=\"false\">Function(s) called</string>\n  <string name=\"view_results\" translatable=\"false\">View results</string>\n  <string name=\"warming_up\" translatable=\"false\">warming up…</string>\n  <string name=\"warning_no_function_call\" translatable=\"false\">No function recognized</string>\n  <string name=\"snackbar_no_function_call\" translatable=\"false\">No function recognized</string>\n  <string name=\"unknown_error\" translatable=\"false\">Unknown error</string>\n  <string name=\"view\" translatable=\"false\">View</string>\n\n  <plurals name=\"parameter\" translatable=\"false\">\n    <item quantity=\"one\">Parameter</item>\n    <item quantity=\"other\">Parameters</item>\n  </plurals>\n  <string name=\"prompt_template_label_flash_on_off\" translatable=\"false\">Turn flashlight on/off</string>\n  <string name=\"prompt_template_label_flash_on\" translatable=\"false\">Flashlight on</string>\n  <string name=\"prompt_template_label_flash_off\" translatable=\"false\">Flashlight off</string>\n  <string name=\"prompt_template_label_create_contact\" translatable=\"false\">Create contact</string>\n  <string name=\"prompt_template_label_send_email\" translatable=\"false\">Send email</string>\n  <string name=\"prompt_template_label_create_calendar_event\" translatable=\"false\">Create calendar event</string>\n  <string name=\"prompt_template_label_show_location_on_map\" translatable=\"false\">Show location on map</string>\n  <string name=\"prompt_template_label_open_wifi_settings\" translatable=\"false\">Open WIFI settings</string>\n\n  <!-- Strings for MobileActionsChallengeDialog -->\n  <string name=\"mobile_actions_challenge_title\" translatable=\"false\">Mobile Actions Finetune Challenge</string>\n  <string name=\"mobile_actions_challenge_subtitle\" translatable=\"false\">Build it to play it!</string>\n  <string name=\"mobile_actions_challenge_description\" translatable=\"false\">This demo is locked. To unlock the Mobile Actions agent, you need to compile the model yourself using our open-source recipe.</string>\n  <string name=\"mobile_actions_challenge_instructions_title\" translatable=\"false\">Instructions:</string>\n  <string name=\"mobile_actions_challenge_instruction_1\" translatable=\"false\">1. <![CDATA[<b>On your computer</b>]]>, open <![CDATA[<a href=\"https://ai.google.dev/gemma/docs/mobile-actions\">this guide</a>]]></string>\n  <string name=\"mobile_actions_challenge_instruction_2\" translatable=\"false\">2. Follow the instructions to fine tune the model and convert it to .litertlm format.</string>\n  <string name=\"mobile_actions_challenge_instruction_3\" translatable=\"false\">3. Transfer the file to this phone.</string>\n  <string name=\"mobile_actions_challenge_instruction_4\" translatable=\"false\">4. Tap <![CDATA[<b>Load Model</b>]]> below to unlock the demo.</string>\n  <string name=\"mobile_actions_challenge_email_colab\" translatable=\"false\">Email guide to myself</string>\n  <string name=\"mobile_actions_challenge_load_model\" translatable=\"false\">Load Model</string>\n\n  <!-- The following entries are for accessibility -->\n\n  <!-- Content description spoken when the \"import model\" icon is focused. [CHAR_LIMIT=NONE] -->\n  <string name=\"cd_import_model_button\">Import model</string>\n  <!-- Content description spoken when the task card is focused. [CHAR_LIMIT=NONE] -->\n  <string name=\"cd_task_card\">%1$s task with %2$d models</string>\n  <!-- Content description spoken when the error icon is focused. [CHAR_LIMIT=NONE] -->\n  <string name=\"cd_error\">Error</string>\n  <!-- Content description spoken when the \"import model from local file\" button is focused. [CHAR_LIMIT=NONE] -->\n  <string name=\"cd_import_model_from_local_file_button\">Import model from local file</string>\n  <!-- Content description spoken when the back arrow icon is focused. [CHAR_LIMIT=NONE] -->\n  <string name=\"cd_navigate_back_icon\">Go back</string>\n  <!-- Content description spoken when the settings icon is focused. [CHAR_LIMIT=NONE] -->\n  <string name=\"cd_app_settings_icon\">Settings</string>\n  <!-- Content description spoken when the done icon is focused. [CHAR_LIMIT=NONE] -->\n  <string name=\"cd_done_icon\">Done</string>\n  <!-- Content description spoken when the collapse icon is focused. [CHAR_LIMIT=NONE] -->\n  <string name=\"cd_collapse_icon\">Collapse</string>\n  <!-- Content description spoken when the expand icon is focused. [CHAR_LIMIT=NONE] -->\n  <string name=\"cd_expand_icon\">Expand</string>\n  <!-- Content description spoken when the stop icon is focused. [CHAR_LIMIT=NONE] -->\n  <string name=\"cd_stop_icon\">Stop</string>\n  <!-- Content description spoken when the delete icon is focused. [CHAR_LIMIT=NONE] -->\n  <string name=\"cd_delete_icon\">Delete</string>\n  <!-- Content description spoken when the close icon is focused. [CHAR_LIMIT=NONE] -->\n  <string name=\"cd_close_icon\">Close</string>\n  <!-- Content description spoken when the selected icon is focused. [CHAR_LIMIT=NONE] -->\n  <string name=\"cd_selected_icon\">Selected</string>\n  <!-- Content description spoken when the \"model settings\" icon is focused. [CHAR_LIMIT=NONE] -->\n  <string name=\"cd_model_settings_icon\">Model settings</string>\n  <!-- Content description spoken when the \"reset session\" icon is focused. [CHAR_LIMIT=NONE] -->\n  <string name=\"cd_reset_session_icon\">Reset session</string>\n  <!-- Content description spoken when the \"model picker chip\" is focused. [CHAR_LIMIT=NONE] -->\n  <string name=\"cd_change_model\">Current model is %1$s. Tap to change model.</string>\n  <!-- Content description spoken when the \"close image viewer\" icon is focused. [CHAR_LIMIT=NONE] -->\n  <string name=\"cd_close_image_viewer_icon\">Close image viewer</string>\n  <!-- Content description spoken when the \"copy to clipboard\" icon is focused. [CHAR_LIMIT=NONE] -->\n  <string name=\"cd_copy_to_clipboard_icon\">Copy to clipboard</string>\n  <!-- Content description spoken when the \"prompt input\" text field is focused. [CHAR_LIMIT=NONE] -->\n  <string name=\"cd_prompt_input_text_field\">Prompt input</string>\n  <!-- Content description spoken when the \"send prompt\" icon is focused. [CHAR_LIMIT=NONE] -->\n  <string name=\"cd_send_prompt_icon\">Send prompt</string>\n  <!-- Content description spoken when the \"model response\" text is focused. [CHAR_LIMIT=NONE] -->\n  <string name=\"cd_model_response_text\">Model response text</string>\n  <!-- Content description spoken when the \"clear input history\" icon is focused. [CHAR_LIMIT=NONE] -->\n  <string name=\"cd_clear_input_history_icon\">Clear input history</string>\n  <!-- Content description spoken when the \"delete input history entry\" icon is focused. [CHAR_LIMIT=NONE] -->\n  <string name=\"cd_delete_input_history_entry_icon\">Delete input history entry</string>\n  <!-- Content description spoken when the \"content input field\" is focused. [CHAR_LIMIT=NONE] -->\n  <string name=\"cd_content_input_field\">Content input</string>\n  <!-- Content description spoken when the \"add example prompt\" icon is focused. [CHAR_LIMIT=NONE] -->\n  <string name=\"cd_add_example_prompt_icon\">Add example prompt</string>\n  <!-- Content description spoken when the image thumbnail is focused. [CHAR_LIMIT=NONE] -->\n  <string name=\"cd_image_thumbnail\">Image thumbnail</string>\n  <!-- Content description spoken when an user image is focused. [CHAR_LIMIT=NONE] -->\n  <string name=\"cd_user_image\">User image %1$d</string>\n  <!-- Content description spoken when an user image in a group is focused. [CHAR_LIMIT=NONE] -->\n  <string name=\"cd_user_image_in_group\">User image %1$d of %2$d</string>\n  <!-- Content description spoken when the \"add content\" icon is focused. [CHAR_LIMIT=NONE] -->\n  <string name=\"cd_add_content_icon\">Add content</string>\n  <!-- Content description spoken when the \"camera shutter\" icon is focused. [CHAR_LIMIT=NONE] -->\n  <string name=\"cd_camera_shutter_icon\">Camera shutter</string>\n  <!-- Content description spoken when the \"toggle front or back camera\" icon is focused. [CHAR_LIMIT=NONE] -->\n  <string name=\"cd_toggle_front_back_camera_icon\">Toggle front or back camera</string>\n  <!-- Content description spoken when the \"pick file\" icon is focused. [CHAR_LIMIT=NONE] -->\n  <string name=\"cd_pick_file\">Pick file</string>\n  <!-- Content description spoken when the \"play audio\" icon is focused. [CHAR_LIMIT=NONE] -->\n  <string name=\"cd_play_audio_icon\">Play audio</string>\n  <!-- Content description spoken when the \"stop playback\" icon is focused. [CHAR_LIMIT=NONE] -->\n  <string name=\"cd_stop_playback_icon\">Stop playback</string>\n  <!-- Content description spoken when the \"send audio clip\" icon is focused. [CHAR_LIMIT=NONE] -->\n  <string name=\"cd_send_audio_clip_icon\">Send audio clip</string>\n  <!-- Content description spoken when the \"start recording\" icon is focused. [CHAR_LIMIT=NONE] -->\n  <string name=\"cd_start_recording\">Start recording</string>\n  <!-- Content description spoken when the \"downloaded\" icon is focused. [CHAR_LIMIT=NONE] -->\n  <string name=\"cd_downloaded_icon\">Downloaded</string>\n  <!-- Content description spoken when the \"not downloaded\" icon is focused. [CHAR_LIMIT=NONE] -->\n  <string name=\"cd_not_downloaded_icon\">Not downloaded</string>\n  <!-- Content description spoken when the \"downloading\" icon is focused. [CHAR_LIMIT=NONE] -->\n  <string name=\"cd_downloading_icon\">Downloading</string>\n  <!-- Content description spoken when the \"download failed\" icon is focused. [CHAR_LIMIT=NONE] -->\n  <string name=\"cd_download_failed_icon\">Download failed</string>\n  <!-- Content description spoken when the \"download failed\" icon is focused. [CHAR_LIMIT=NONE] -->\n  <string name=\"cd_chat_panel\">Chat panel</string>\n  <!-- Content description spoken when the \"switch to keyboard\" icon is focused. [CHAR_LIMIT=NONE] -->\n  <string name=\"cd_switch_to_keyboard\">Switch to keyboard</string>\n  <!-- Content description spoken when the \"switch to voice\" icon is focused. [CHAR_LIMIT=NONE] -->\n  <string name=\"cd_switch_to_voice\">Switch to voice</string>\n  <!-- Content description spoken when the \"show history\" icon is focused. [CHAR_LIMIT=NONE] -->\n  <string name=\"cd_show_history\">Show history</string>\n  <!-- Content description spoken when the \"more options\" icon is focused. [CHAR_LIMIT=NONE] -->\n  <string name=\"cd_more_options\">More options</string>\n  <!-- Content description spoken when the \"help\" icon is focused. [CHAR_LIMIT=NONE] -->\n  <string name=\"cd_help\">Help</string>\n  <!-- Content description spoken when the \"menu\" icon is focused. [CHAR_LIMIT=NONE] -->\n  <string name=\"cd_menu\">Menu</string>\n\n</resources>\n"
  },
  {
    "path": "Android/src/app/src/main/res/values/themes.xml",
    "content": "<?xml version=\"1.0\" encoding=\"utf-8\"?>\n<!--\n Copyright 2025 Google LLC\n\n Licensed under the Apache License, Version 2.0 (the \"License\");\n you may not use this file except in compliance with the License.\n You may obtain a copy of the License at\n\n     http://www.apache.org/licenses/LICENSE-2.0\n\n Unless required by applicable law or agreed to in writing, software\n distributed under the License is distributed on an \"AS IS\" BASIS,\n WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n See the License for the specific language governing permissions and\n limitations under the License.\n-->\n\n<resources xmlns:tools=\"http://schemas.android.com/tools\">\n  <style name=\"Theme.Gallery\" parent=\"android:Theme.Material.Light.NoActionBar\" />\n  <style name=\"Theme.Gallery.SplashScreen\" parent=\"Theme.SplashScreen\">\n      <!-- Need to match surfaceLight in Colors.kt -->\n      <item name=\"windowSplashScreenBackground\">#FFFFFFFF</item>\n      <item name=\"windowSplashScreenAnimatedIcon\">@drawable/splash_screen_animated_icon</item>\n      <item name=\"postSplashScreenTheme\">@style/Theme.Gallery</item>\n  </style>\n  <style name=\"Theme.Gallery.OssLicenses\" parent=\"Theme.AppCompat.Light.DarkActionBar\">\n      <item name=\"android:windowOptOutEdgeToEdgeEnforcement\" tools:targetApi=\"35\">true</item>\n  </style>\n</resources>"
  },
  {
    "path": "Android/src/app/src/main/res/values-night/themes.xml",
    "content": "<?xml version=\"1.0\" encoding=\"utf-8\"?>\n<!--\n Copyright 2025 Google LLC\n\n Licensed under the Apache License, Version 2.0 (the \"License\");\n you may not use this file except in compliance with the License.\n You may obtain a copy of the License at\n\n     http://www.apache.org/licenses/LICENSE-2.0\n\n Unless required by applicable law or agreed to in writing, software\n distributed under the License is distributed on an \"AS IS\" BASIS,\n WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n See the License for the specific language governing permissions and\n limitations under the License.\n-->\n\n<resources xmlns:tools=\"http://schemas.android.com/tools\">\n  <style name=\"Theme.Gallery.SplashScreen\" parent=\"Theme.SplashScreen\">\n    <!-- Need to match surfaceDark in Colors.kt -->\n    <item name=\"windowSplashScreenBackground\">#FF131314</item>\n    <item name=\"windowSplashScreenAnimatedIcon\">@drawable/splash_screen_animated_icon</item>\n    <item name=\"postSplashScreenTheme\">@style/Theme.Gallery</item>\n  </style>\n  <style name=\"Theme.Gallery.OssLicenses\" parent=\"Theme.AppCompat\">\n    <item name=\"android:windowOptOutEdgeToEdgeEnforcement\" tools:targetApi=\"35\">true</item>\n  </style>\n</resources>"
  },
  {
    "path": "Android/src/app/src/main/res/xml/backup_rules.xml",
    "content": "<?xml version=\"1.0\" encoding=\"utf-8\"?><!--\n Copyright 2025 Google LLC\n\n Licensed under the Apache License, Version 2.0 (the \"License\");\n you may not use this file except in compliance with the License.\n You may obtain a copy of the License at\n\n     http://www.apache.org/licenses/LICENSE-2.0\n\n Unless required by applicable law or agreed to in writing, software\n distributed under the License is distributed on an \"AS IS\" BASIS,\n WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n See the License for the specific language governing permissions and\n limitations under the License.\n\n   Sample backup rules file; uncomment and customize as necessary.\n   See https://developer.android.com/guide/topics/data/autobackup\n   for details.\n   Note: This file is ignored for devices older that API 31\n   See https://developer.android.com/about/versions/12/backup-restore\n-->\n<full-backup-content>\n    <!--\n   <include domain=\"sharedpref\" path=\".\"/>\n   <exclude domain=\"sharedpref\" path=\"device.xml\"/>\n-->\n</full-backup-content>"
  },
  {
    "path": "Android/src/app/src/main/res/xml/data_extraction_rules.xml",
    "content": "<?xml version=\"1.0\" encoding=\"utf-8\"?><!--\n Copyright 2025 Google LLC\n\n Licensed under the Apache License, Version 2.0 (the \"License\");\n you may not use this file except in compliance with the License.\n You may obtain a copy of the License at\n\n     http://www.apache.org/licenses/LICENSE-2.0\n\n Unless required by applicable law or agreed to in writing, software\n distributed under the License is distributed on an \"AS IS\" BASIS,\n WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n See the License for the specific language governing permissions and\n limitations under the License.\n\n   Sample data extraction rules file; uncomment and customize as necessary.\n   See https://developer.android.com/about/versions/12/backup-restore#xml-changes\n   for details.\n-->\n<data-extraction-rules>\n    <cloud-backup>\n        <!-- TODO: Use <include> and <exclude> to control what is backed up.\n        <include .../>\n        <exclude .../>\n        -->\n    </cloud-backup>\n    <!--\n    <device-transfer>\n        <include .../>\n        <exclude .../>\n    </device-transfer>\n    -->\n</data-extraction-rules>"
  },
  {
    "path": "Android/src/app/src/main/res/xml/file_paths.xml",
    "content": "<?xml version=\"1.0\" encoding=\"utf-8\"?>\n<!--\n Copyright 2025 Google LLC\n\n Licensed under the Apache License, Version 2.0 (the \"License\");\n you may not use this file except in compliance with the License.\n You may obtain a copy of the License at\n\n     http://www.apache.org/licenses/LICENSE-2.0\n\n Unless required by applicable law or agreed to in writing, software\n distributed under the License is distributed on an \"AS IS\" BASIS,\n WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n See the License for the specific language governing permissions and\n limitations under the License.\n-->\n\n<paths>\n    <cache-path\n        name=\"cache_pictures\"\n        path=\"images/\" />\n</paths>"
  },
  {
    "path": "Android/src/build.gradle.kts",
    "content": "/*\n * Copyright 2025 Google LLC\n *\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n *     http://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\n\n// Top-level build file where you can add configuration options common to all sub-projects/modules.\nplugins {\n  alias(libs.plugins.android.application) apply false\n  alias(libs.plugins.google.services) apply false\n  alias(libs.plugins.kotlin.android) apply false\n  alias(libs.plugins.kotlin.compose) apply false\n  alias(libs.plugins.hilt.application) apply false\n  alias(libs.plugins.ksp) apply false\n}\n"
  },
  {
    "path": "Android/src/gradle/libs.versions.toml",
    "content": "[versions]\nagp = \"8.8.2\"\nkotlin = \"2.1.0\"\ncoreKtx = \"1.15.0\"\njunit = \"4.13.2\"\njunitVersion = \"1.2.1\"\nespressoCore = \"3.6.1\"\nlifecycleRuntimeKtx = \"2.8.7\"\nactivityCompose = \"1.10.1\"\ncomposeBom = \"2026.02.00\"\nnavigation = \"2.8.9\"\nserializationPlugin = \"2.0.21\"\nserializationJson = \"1.7.3\"\nmaterialIconExtended = \"1.7.8\"\nworkRuntime = \"2.10.0\"\ndataStore = \"1.1.7\"\ngson = \"2.12.1\"\nlifecycleProcess = \"2.8.7\"\nwebkit = \"1.14.0\"\nprotobuf = \"0.9.5\"\nprotobufJavaLite = \"4.26.1\"\n#noinspection GradleDependency\nlitertlm = \"0.9.0-alpha06\"\ncommonmark = \"1.0.0-alpha02\"\nrichtext = \"1.0.0-alpha02\"\nplayServicesTfliteJava = \"16.4.0\"\nplayServicesTfliteGpu= \"16.4.0\"\ncameraX = \"1.4.2\"\nnetOpenidAppauth = \"0.11.1\"\nsplashscreen = \"1.2.0-beta01\"\nhilt = \"2.57.2\"\nhiltNavigation = \"1.3.0\"\nossLicenses = \"0.10.6\"\nplayServicesOssLicenses = \"17.1.0\"\ngoogleService = \"4.4.3\"\nfirebaseBom = \"33.16.0\"\nexifinterface = \"1.4.1\"\nsecurityCrypto = \"1.1.0\"\nkotlinReflect = \"2.2.21\"\nmoshi = \"1.15.2\"\nksp = \"2.1.10-1.0.30\"\n\n[libraries]\nandroidx-core-ktx = { group = \"androidx.core\", name = \"core-ktx\", version.ref = \"coreKtx\" }\njunit = { group = \"junit\", name = \"junit\", version.ref = \"junit\" }\nandroidx-junit = { group = \"androidx.test.ext\", name = \"junit\", version.ref = \"junitVersion\" }\nandroidx-espresso-core = { group = \"androidx.test.espresso\", name = \"espresso-core\", version.ref = \"espressoCore\" }\nandroidx-lifecycle-runtime-ktx = { group = \"androidx.lifecycle\", name = \"lifecycle-runtime-ktx\", version.ref = \"lifecycleRuntimeKtx\" }\nandroidx-activity-compose = { group = \"androidx.activity\", name = \"activity-compose\", version.ref = \"activityCompose\" }\nandroidx-compose-bom = { group = \"androidx.compose\", name = \"compose-bom\", version.ref = \"composeBom\" }\nandroidx-security-crypto = { group = \"androidx.security\", name = \"security-crypto\", version.ref = \"securityCrypto\" }\nandroidx-ui = { group = \"androidx.compose.ui\", name = \"ui\" }\nandroidx-ui-graphics = { group = \"androidx.compose.ui\", name = \"ui-graphics\" }\nandroidx-ui-tooling = { group = \"androidx.compose.ui\", name = \"ui-tooling\" }\nandroidx-ui-tooling-preview = { group = \"androidx.compose.ui\", name = \"ui-tooling-preview\" }\nandroidx-ui-test-manifest = { group = \"androidx.compose.ui\", name = \"ui-test-manifest\" }\nandroidx-ui-test-junit4 = { group = \"androidx.compose.ui\", name = \"ui-test-junit4\" }\nandroidx-material3 = { group = \"androidx.compose.material3\", name = \"material3\" }\nandroidx-compose-navigation = { group = \"androidx.navigation\", name = \"navigation-compose\", version.ref = \"navigation\" }\nkotlin-reflect = { group = \"org.jetbrains.kotlin\", name = \"kotlin-reflect\", version.ref = \"kotlinReflect\" }\nkotlinx-serialization-json = { group = \"org.jetbrains.kotlinx\", name = \"kotlinx-serialization-json\", version.ref = \"serializationJson\" }\nmaterial-icon-extended = { group = \"androidx.compose.material\", name = \"material-icons-extended\", version.ref = \"materialIconExtended\" }\nandroidx-work-runtime = { group = \"androidx.work\", name = \"work-runtime-ktx\", version.ref = \"workRuntime\" }\nandroidx-datastore = { group = \"androidx.datastore\", name = \"datastore\", version.ref = \"dataStore\" }\ncom-google-code-gson = { group = \"com.google.code.gson\", name = \"gson\", version.ref = \"gson\" }\nandroidx-lifecycle-process = { group = \"androidx.lifecycle\", name = \"lifecycle-process\", version.ref = \"lifecycleProcess\" }\nandroidx-webkit = { group = \"androidx.webkit\", name = \"webkit\", version.ref = \"webkit\"}\nlitertlm = { group = \"com.google.ai.edge.litertlm\", name = \"litertlm-android\", version.ref = \"litertlm\" }\ncommonmark = { group = \"com.halilibo.compose-richtext\", name = \"richtext-commonmark\", version.ref = \"commonmark\" }\nrichtext = { group = \"com.halilibo.compose-richtext\", name = \"richtext-ui-material3\", version.ref = \"richtext\" }\ntflite = { group = \"com.google.android.gms\", name = \"play-services-tflite-java\", version.ref = \"playServicesTfliteJava\" }\ntflite-gpu = { group = \"com.google.android.gms\", name = \"play-services-tflite-gpu\", version.ref = \"playServicesTfliteGpu\" }\ntflite-support = { group = \"com.google.android.gms\", name = \"play-services-tflite-support\", version.ref = \"playServicesTfliteJava\" }\ncamerax-core = { group = \"androidx.camera\", name = \"camera-core\", version.ref = \"cameraX\"}\ncamerax-camera2 = { group = \"androidx.camera\", name = \"camera-camera2\", version.ref = \"cameraX\"}\ncamerax-lifecycle = { group = \"androidx.camera\", name = \"camera-lifecycle\", version.ref = \"cameraX\"}\ncamerax-view = { group = \"androidx.camera\", name = \"camera-view\", version.ref = \"cameraX\"}\nopenid-appauth = { group = \"net.openid\", name = \"appauth\", version.ref = \"netOpenidAppauth\" }\nandroidx-splashscreen = { group = \"androidx.core\", name = \"core-splashscreen\", version.ref = \"splashscreen\" }\nprotobuf-javalite = { group = \"com.google.protobuf\", name = \"protobuf-javalite\", version.ref = \"protobufJavaLite\" }\nhilt-android = { module = \"com.google.dagger:hilt-android\", version.ref = \"hilt\" }\nhilt-navigation-compose = { module = \"androidx.hilt:hilt-navigation-compose\", version.ref = \"hiltNavigation\" }\nhilt-android-testing = { module = \"com.google.dagger:hilt-android-testing\", version.ref = \"hilt\" }\nhilt-android-compiler = { module = \"com.google.dagger:hilt-android-compiler\", version.ref = \"hilt\" }\nplay-services-oss-licenses = { module = \"com.google.android.gms:play-services-oss-licenses\", version.ref = \"playServicesOssLicenses\"}\nfirebase-bom = { group = \"com.google.firebase\", name = \"firebase-bom\", version.ref = \"firebaseBom\" }\n# When using the Firebase BoM, you don't specify versions in Firebase\n# library dependencies.\nfirebase-analytics = { group = \"com.google.firebase\", name = \"firebase-analytics\" }\nfirebase-messaging = { group = \"com.google.firebase\", name = \"firebase-messaging\" }\nandroidx-exifinterface = { group = \"androidx.exifinterface\", name = \"exifinterface\", version.ref = \"exifinterface\" }\nmoshi-kotlin = { group = \"com.squareup.moshi\", name = \"moshi-kotlin\", version.ref = \"moshi\" }\nmoshi-kotlin-codegen = { group = \"com.squareup.moshi\", name = \"moshi-kotlin-codegen\", version.ref = \"moshi\" }\n\n[plugins]\nandroid-application = { id = \"com.android.application\", version.ref = \"agp\" }\nkotlin-android = { id = \"org.jetbrains.kotlin.android\", version.ref = \"kotlin\" }\nkotlin-compose = { id = \"org.jetbrains.kotlin.plugin.compose\", version.ref = \"kotlin\" }\nkotlin-serialization = { id = \"org.jetbrains.kotlin.plugin.serialization\", version.ref = \"serializationPlugin\" }\nprotobuf = {id = \"com.google.protobuf\", version.ref = \"protobuf\"}\nhilt-application = { id = \"com.google.dagger.hilt.android\", version.ref = \"hilt\" }\noss-licenses = {id = \"com.google.android.gms.oss-licenses-plugin\", version.ref = \"ossLicenses\"}\ngoogle-services = { id = \"com.google.gms.google-services\", version.ref = \"googleService\" }\nksp = { id = \"com.google.devtools.ksp\", version.ref = \"ksp\" }"
  },
  {
    "path": "Android/src/gradle/wrapper/gradle-wrapper.properties",
    "content": "#Sun Mar 02 09:29:13 PST 2025\ndistributionBase=GRADLE_USER_HOME\ndistributionPath=wrapper/dists\ndistributionUrl=https\\://services.gradle.org/distributions/gradle-8.10.2-bin.zip\nzipStoreBase=GRADLE_USER_HOME\nzipStorePath=wrapper/dists\n"
  },
  {
    "path": "Android/src/gradle.properties",
    "content": "# Project-wide Gradle settings.\n# IDE (e.g. Android Studio) users:\n# Gradle settings configured through the IDE *will override*\n# any settings specified in this file.\n# For more details on how to configure your build environment visit\n# http://www.gradle.org/docs/current/userguide/build_environment.html\n# Specifies the JVM arguments used for the daemon process.\n# The setting is particularly useful for tweaking memory settings.\norg.gradle.jvmargs=-Xmx2048m -Dfile.encoding=UTF-8\n# When configured, Gradle will run in incubating parallel mode.\n# This option should only be used with decoupled projects. For more details, visit\n# https://developer.android.com/r/tools/gradle-multi-project-decoupled-projects\n# org.gradle.parallel=true\n# AndroidX package structure to make it clearer which packages are bundled with the\n# Android operating system, and which are packaged with your app's APK\n# https://developer.android.com/topic/libraries/support-library/androidx-rn\nandroid.useAndroidX=true\n# Kotlin code style for this project: \"official\" or \"obsolete\":\nkotlin.code.style=official\n# Enables namespacing of each library's R class so that its R class includes only the\n# resources declared in the library itself and none from the library's dependencies,\n# thereby reducing the size of the R class for that library\nandroid.nonTransitiveRClass=true"
  },
  {
    "path": "Android/src/gradlew",
    "content": "#!/usr/bin/env sh\n\n#\n# Copyright 2015 the original author or authors.\n#\n# Licensed under the Apache License, Version 2.0 (the \"License\");\n# you may not use this file except in compliance with the License.\n# You may obtain a copy of the License at\n#\n#      https://www.apache.org/licenses/LICENSE-2.0\n#\n# Unless required by applicable law or agreed to in writing, software\n# distributed under the License is distributed on an \"AS IS\" BASIS,\n# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n# See the License for the specific language governing permissions and\n# limitations under the License.\n#\n\n##############################################################################\n##\n##  Gradle start up script for UN*X\n##\n##############################################################################\n\n# Attempt to set APP_HOME\n# Resolve links: $0 may be a link\nPRG=\"$0\"\n# Need this for relative symlinks.\nwhile [ -h \"$PRG\" ] ; do\n    ls=`ls -ld \"$PRG\"`\n    link=`expr \"$ls\" : '.*-> \\(.*\\)$'`\n    if expr \"$link\" : '/.*' > /dev/null; then\n        PRG=\"$link\"\n    else\n        PRG=`dirname \"$PRG\"`\"/$link\"\n    fi\ndone\nSAVED=\"`pwd`\"\ncd \"`dirname \\\"$PRG\\\"`/\" >/dev/null\nAPP_HOME=\"`pwd -P`\"\ncd \"$SAVED\" >/dev/null\n\nAPP_NAME=\"Gradle\"\nAPP_BASE_NAME=`basename \"$0\"`\n\n# Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.\nDEFAULT_JVM_OPTS='\"-Xmx64m\" \"-Xms64m\"'\n\n# Use the maximum available, or set MAX_FD != -1 to use that value.\nMAX_FD=\"maximum\"\n\nwarn () {\n    echo \"$*\"\n}\n\ndie () {\n    echo\n    echo \"$*\"\n    echo\n    exit 1\n}\n\n# OS specific support (must be 'true' or 'false').\ncygwin=false\nmsys=false\ndarwin=false\nnonstop=false\ncase \"`uname`\" in\n  CYGWIN* )\n    cygwin=true\n    ;;\n  Darwin* )\n    darwin=true\n    ;;\n  MINGW* )\n    msys=true\n    ;;\n  NONSTOP* )\n    nonstop=true\n    ;;\nesac\n\nCLASSPATH=$APP_HOME/gradle/wrapper/gradle-wrapper.jar\n\n\n# Determine the Java command to use to start the JVM.\nif [ -n \"$JAVA_HOME\" ] ; then\n    if [ -x \"$JAVA_HOME/jre/sh/java\" ] ; then\n        # IBM's JDK on AIX uses strange locations for the executables\n        JAVACMD=\"$JAVA_HOME/jre/sh/java\"\n    else\n        JAVACMD=\"$JAVA_HOME/bin/java\"\n    fi\n    if [ ! -x \"$JAVACMD\" ] ; then\n        die \"ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME\n\nPlease set the JAVA_HOME variable in your environment to match the\nlocation of your Java installation.\"\n    fi\nelse\n    JAVACMD=\"java\"\n    which java >/dev/null 2>&1 || die \"ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH.\n\nPlease set the JAVA_HOME variable in your environment to match the\nlocation of your Java installation.\"\nfi\n\n# Increase the maximum file descriptors if we can.\nif [ \"$cygwin\" = \"false\" -a \"$darwin\" = \"false\" -a \"$nonstop\" = \"false\" ] ; then\n    MAX_FD_LIMIT=`ulimit -H -n`\n    if [ $? -eq 0 ] ; then\n        if [ \"$MAX_FD\" = \"maximum\" -o \"$MAX_FD\" = \"max\" ] ; then\n            MAX_FD=\"$MAX_FD_LIMIT\"\n        fi\n        ulimit -n $MAX_FD\n        if [ $? -ne 0 ] ; then\n            warn \"Could not set maximum file descriptor limit: $MAX_FD\"\n        fi\n    else\n        warn \"Could not query maximum file descriptor limit: $MAX_FD_LIMIT\"\n    fi\nfi\n\n# For Darwin, add options to specify how the application appears in the dock\nif $darwin; then\n    GRADLE_OPTS=\"$GRADLE_OPTS \\\"-Xdock:name=$APP_NAME\\\" \\\"-Xdock:icon=$APP_HOME/media/gradle.icns\\\"\"\nfi\n\n# For Cygwin or MSYS, switch paths to Windows format before running java\nif [ \"$cygwin\" = \"true\" -o \"$msys\" = \"true\" ] ; then\n    APP_HOME=`cygpath --path --mixed \"$APP_HOME\"`\n    CLASSPATH=`cygpath --path --mixed \"$CLASSPATH\"`\n\n    JAVACMD=`cygpath --unix \"$JAVACMD\"`\n\n    # We build the pattern for arguments to be converted via cygpath\n    ROOTDIRSRAW=`find -L / -maxdepth 1 -mindepth 1 -type d 2>/dev/null`\n    SEP=\"\"\n    for dir in $ROOTDIRSRAW ; do\n        ROOTDIRS=\"$ROOTDIRS$SEP$dir\"\n        SEP=\"|\"\n    done\n    OURCYGPATTERN=\"(^($ROOTDIRS))\"\n    # Add a user-defined pattern to the cygpath arguments\n    if [ \"$GRADLE_CYGPATTERN\" != \"\" ] ; then\n        OURCYGPATTERN=\"$OURCYGPATTERN|($GRADLE_CYGPATTERN)\"\n    fi\n    # Now convert the arguments - kludge to limit ourselves to /bin/sh\n    i=0\n    for arg in \"$@\" ; do\n        CHECK=`echo \"$arg\"|egrep -c \"$OURCYGPATTERN\" -`\n        CHECK2=`echo \"$arg\"|egrep -c \"^-\"`                                 ### Determine if an option\n\n        if [ $CHECK -ne 0 ] && [ $CHECK2 -eq 0 ] ; then                    ### Added a condition\n            eval `echo args$i`=`cygpath --path --ignore --mixed \"$arg\"`\n        else\n            eval `echo args$i`=\"\\\"$arg\\\"\"\n        fi\n        i=`expr $i + 1`\n    done\n    case $i in\n        0) set -- ;;\n        1) set -- \"$args0\" ;;\n        2) set -- \"$args0\" \"$args1\" ;;\n        3) set -- \"$args0\" \"$args1\" \"$args2\" ;;\n        4) set -- \"$args0\" \"$args1\" \"$args2\" \"$args3\" ;;\n        5) set -- \"$args0\" \"$args1\" \"$args2\" \"$args3\" \"$args4\" ;;\n        6) set -- \"$args0\" \"$args1\" \"$args2\" \"$args3\" \"$args4\" \"$args5\" ;;\n        7) set -- \"$args0\" \"$args1\" \"$args2\" \"$args3\" \"$args4\" \"$args5\" \"$args6\" ;;\n        8) set -- \"$args0\" \"$args1\" \"$args2\" \"$args3\" \"$args4\" \"$args5\" \"$args6\" \"$args7\" ;;\n        9) set -- \"$args0\" \"$args1\" \"$args2\" \"$args3\" \"$args4\" \"$args5\" \"$args6\" \"$args7\" \"$args8\" ;;\n    esac\nfi\n\n# Escape application args\nsave () {\n    for i do printf %s\\\\n \"$i\" | sed \"s/'/'\\\\\\\\''/g;1s/^/'/;\\$s/\\$/' \\\\\\\\/\" ; done\n    echo \" \"\n}\nAPP_ARGS=`save \"$@\"`\n\n# Collect all arguments for the java command, following the shell quoting and substitution rules\neval set -- $DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS \"\\\"-Dorg.gradle.appname=$APP_BASE_NAME\\\"\" -classpath \"\\\"$CLASSPATH\\\"\" org.gradle.wrapper.GradleWrapperMain \"$APP_ARGS\"\n\nexec \"$JAVACMD\" \"$@\"\n"
  },
  {
    "path": "Android/src/gradlew.bat",
    "content": "@rem\r\n@rem Copyright 2015 the original author or authors.\r\n@rem\r\n@rem Licensed under the Apache License, Version 2.0 (the \"License\");\r\n@rem you may not use this file except in compliance with the License.\r\n@rem You may obtain a copy of the License at\r\n@rem\r\n@rem      https://www.apache.org/licenses/LICENSE-2.0\r\n@rem\r\n@rem Unless required by applicable law or agreed to in writing, software\r\n@rem distributed under the License is distributed on an \"AS IS\" BASIS,\r\n@rem WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\r\n@rem See the License for the specific language governing permissions and\r\n@rem limitations under the License.\r\n@rem\r\n\r\n@if \"%DEBUG%\" == \"\" @echo off\r\n@rem ##########################################################################\r\n@rem\r\n@rem  Gradle startup script for Windows\r\n@rem\r\n@rem ##########################################################################\r\n\r\n@rem Set local scope for the variables with windows NT shell\r\nif \"%OS%\"==\"Windows_NT\" setlocal\r\n\r\nset DIRNAME=%~dp0\r\nif \"%DIRNAME%\" == \"\" set DIRNAME=.\r\nset APP_BASE_NAME=%~n0\r\nset APP_HOME=%DIRNAME%\r\n\r\n@rem Resolve any \".\" and \"..\" in APP_HOME to make it shorter.\r\nfor %%i in (\"%APP_HOME%\") do set APP_HOME=%%~fi\r\n\r\n@rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.\r\nset DEFAULT_JVM_OPTS=\"-Xmx64m\" \"-Xms64m\"\r\n\r\n@rem Find java.exe\r\nif defined JAVA_HOME goto findJavaFromJavaHome\r\n\r\nset JAVA_EXE=java.exe\r\n%JAVA_EXE% -version >NUL 2>&1\r\nif \"%ERRORLEVEL%\" == \"0\" goto execute\r\n\r\necho.\r\necho ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH.\r\necho.\r\necho Please set the JAVA_HOME variable in your environment to match the\r\necho location of your Java installation.\r\n\r\ngoto fail\r\n\r\n:findJavaFromJavaHome\r\nset JAVA_HOME=%JAVA_HOME:\"=%\r\nset JAVA_EXE=%JAVA_HOME%/bin/java.exe\r\n\r\nif exist \"%JAVA_EXE%\" goto execute\r\n\r\necho.\r\necho ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME%\r\necho.\r\necho Please set the JAVA_HOME variable in your environment to match the\r\necho location of your Java installation.\r\n\r\ngoto fail\r\n\r\n:execute\r\n@rem Setup the command line\r\n\r\nset CLASSPATH=%APP_HOME%\\gradle\\wrapper\\gradle-wrapper.jar\r\n\r\n\r\n@rem Execute Gradle\r\n\"%JAVA_EXE%\" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% \"-Dorg.gradle.appname=%APP_BASE_NAME%\" -classpath \"%CLASSPATH%\" org.gradle.wrapper.GradleWrapperMain %*\r\n\r\n:end\r\n@rem End local scope for the variables with windows NT shell\r\nif \"%ERRORLEVEL%\"==\"0\" goto mainEnd\r\n\r\n:fail\r\nrem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of\r\nrem the _cmd.exe /c_ return code!\r\nif  not \"\" == \"%GRADLE_EXIT_CONSOLE%\" exit 1\r\nexit /b 1\r\n\r\n:mainEnd\r\nif \"%OS%\"==\"Windows_NT\" endlocal\r\n\r\n:omega\r\n"
  },
  {
    "path": "Android/src/settings.gradle.kts",
    "content": "/*\n * Copyright 2025 Google LLC\n *\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n *     http://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\n\npluginManagement {\n  repositories {\n    google {\n      content {\n        includeGroupByRegex(\"com\\\\.android.*\")\n        includeGroupByRegex(\"com\\\\.google.*\")\n        includeGroupByRegex(\"androidx.*\")\n      }\n    }\n    mavenCentral()\n    gradlePluginPortal()\n  }\n  resolutionStrategy {\n    eachPlugin {\n      if (requested.id.id == \"com.google.android.gms.oss-licenses-plugin\") {\n        useModule(\"com.google.android.gms:oss-licenses-plugin:0.10.6\")\n      }\n    }\n  }\n}\n\ndependencyResolutionManagement {\n  repositoriesMode.set(RepositoriesMode.FAIL_ON_PROJECT_REPOS)\n  repositories {\n    //        mavenLocal()\n    google()\n    mavenCentral()\n  }\n}\n\nrootProject.name = \"AI Edge Gallery\"\n\ninclude(\":app\")\n"
  },
  {
    "path": "Bug_Reporting_Guide.md",
    "content": "# **The Complete Guide to Capturing AI Edge Gallery Bug Reports for ANDROID devices**\n\nThank you for helping us improve the AI Edge Gallery app\\! To find and fix bugs effectively, our engineers need detailed diagnostic information from your device. A **Full Bug Report** is the best way to provide this.\n\nPlease note that this guide is specifically for capturing bug reports on **Android devices**.\n\nThis guide covers the simple on-device method for all users and the more advanced `adb` method for developers.\n\n### **Part 1: The Recommended Method (On Your Device)**\n\nThis is the fastest and easiest way to generate a complete bug report.\n\n#### **1\\. Enable Developer Options**\n\nFirst, you need to enable the hidden \"Developer options\" menu on your phone.\n\n* Open your phone's **Settings** app.  \n* Scroll down and tap **\"About phone\"**.  \n* Find the **\"Build number\"** and tap on it **7 times** in a row. You will see a \"You are now a developer\\!\" message.\n\n#### **2\\. Capture the Bug Report**\n\nIt's best to capture the report **immediately after** you've experienced the bug.\n\n* Go back to the main **Settings** page and find the new **\"Developer options\"** menu (it may be under \"System\").  \n* Inside Developer options, tap **\"Take bug report\"**.  \n* Select the **\"Full report\"** option and tap **\"Report\"**. This provides the most detailed information and is strongly preferred.\n\n#### **3\\. Wait and Share**\n\n* Your phone will take a moment to collect all the data. When it's ready, a notification will appear saying **\"Bug report captured\"**.  \n* Tap this notification.  \n* The Android share menu will open. You can now share the `.zip` file with us. The easiest way is to **save it to your Google Drive** and share the link, or attach it directly to the GitHub issue.\n\n### **Part 2: For Developers & Advanced Users (Using ADB)**\n\nThis section is for users comfortable with the Android Debug Bridge (`adb`) command-line tool.\n\n#### **Capture a Bug Report Directly**\n\nIf you have a device connected to your computer with USB debugging enabled, you can use the following commands.\n\n* **For a single connected device:**\n\n```shell\n# This saves the report to the specified path on your computer.\nadb bugreport C:\\Reports\\MyBugReports\n```\n\n* **For multiple connected devices:**\n\n```shell\n# First, list devices to get the serial number.\nadb devices\n\n# Then, use the serial number to target the correct device.\nadb -s <your_device_serial_number> bugreport\n```\n\n#### **Access Older Bug Reports from Your Device**\n\nAndroid automatically saves recent bug reports on the device.\n\n1. **List Saved Reports:**\n\n```shell\nadb shell ls /bugreports/\n```\n\n2. **Pull a Specific Report:**\n\n```shell\nadb pull /bugreports/<bug_report_filename.zip>\n```\n\n#### **Understanding the Bug Report File**\n\nYour bug report is a `.zip` file. Inside, the most important file is **`bugreport-[...].txt`**. This text file contains the full system log (logcat), error logs (`dumpstate`), and detailed diagnostic output for all system services (`dumpsys`), giving engineers a complete picture of the device's state at the time of the bug.\n"
  },
  {
    "path": "CONTRIBUTING.md",
    "content": "The repository is not currently ready for code contributions. We will\nmake a separate announcement when we are ready for OSS users to make\ncontributions to it.\n\n"
  },
  {
    "path": "DEVELOPMENT.md",
    "content": "# Development notes\n\n## Build app locally\n\nTo successfully build and run the application through Android Studio, you need to configure it with your own HuggingFace Developer Application ([official doc](https://huggingface.co/docs/hub/oauth#creating-an-oauth-app)). This is required for the model download functionality to work correctly.\n\nAfter you've created a developer application:\n\n1. In [`ProjectConfig.kt`](https://github.com/google-ai-edge/gallery/blob/main/Android/src/app/src/main/java/com/google/ai/edge/gallery/common/ProjectConfig.kt), replace the placeholders for `clientId` and `redirectUri` with the values from your HuggingFace developer application.\n\n1. In [`app/build.gradle.kts`](https://github.com/google-ai-edge/gallery/blob/c1b50e160a66d5ea2ec2d8d8e63088b3cc0761bc/Android/src/app/build.gradle.kts#L41-L44), modify the `manifestPlaceholders[\"appAuthRedirectScheme\"]` value to match the redirect URL you configured in your HuggingFace developer application.\n"
  },
  {
    "path": "Function_Calling_Guide.md",
    "content": "# Implementing Your Custom Logic\nTo build specialized agents that go beyond our provided demos, you can fine-tune your own version of the model and customize the app with your functions to call.\n\n## Clone the Repository\n```shell\ngit clone git@github.com:google-ai-edge/gallery.git\n```\n\nThis will create a local copy of the repository.\n\n## Define Your Action Type \n\nIn [Actions.kt](Android/src/app/src/main/java/com/google/ai/edge/gallery/customtasks/mobileactions/Actions.kt), add a new entry to the `ActionType` enum and create a class that extends `Action` to define your specific function name, icon, and parameters.\n\n```kotlin\nenum class ActionType {\n  // ... existing types\n  ACTION_NEW_CUSTOM_FUNCTION,\n}\n\nclass NewCustomAction(val param: String) : Action(\n  type = ActionType.ACTION_NEW_CUSTOM_FUNCTION,\n  icon = Icons.Outlined.Favorite, // Choose an appropriate icon\n  functionCallDetails = FunctionCallDetails(\n    functionName = \"newCustomFunction\",\n    parameters = listOf(Pair(\"param\", param))\n  )\n)\n```\n\n## Add Your Tool Definition\n\nIn [MobileActionsTools.kt](Android/src/app/src/main/java/com/google/ai/edge/gallery/customtasks/mobileactions/MobileActionsTools.kt), create a new function annotated with `@Tool` and `@ToolParam`. This function should call the `onFunctionCalled` callback to pass the specific action to your app logic.\n\n```kotlin\nclass MobileActionsTools(val onFunctionCalled: (Action) -> Unit): Toolset {\n  // ... existing tools\n\n  /** Description for the model. */\n  @Tool(description = \"Description of what this function does\")\n  fun newCustomFunction(\n    @ToolParam(description = \"Description of the parameter\") param: String\n  ): Map<String, String> {\n    onFunctionCalled(NewCustomAction(param = param))\n    return mapOf(\"result\" to \"success\")\n  }\n}\n```\n\n## Implement Your Action Logic \n\nUpdate the `performAction` method in [MobileActionsViewModel.kt](Android/src/app/src/main/java/com/google/ai/edge/gallery/customtasks/mobileactions/MobileActionsViewModel.kt) to handle your new action type. This is where you implement the actual Android logic, such as using the `CameraManager` or starting a new `Intent`.\n\n```kotlin\nfun performAction(action: Action, context: Context): String {\n  return when (action) {\n    // ... existing actions\n    is NewCustomAction -> handleNewCustomAction(context, action.param)\n    else -> \"\"\n  }\n}\n\nprivate fun handleNewCustomAction(context: Context, param: String): String {\n  // Implement your Android logic here (e.g., Toast, Intent, etc.)\n  return \"\"\n}\n```\n\n## Update the System Prompt (Optional) \n\nIf your function requires specific context like the current time or device state, update the `getSystemPrompt()` function in [MobileActionsTask.kt](Android/src/app/src/main/java/com/google/ai/edge/gallery/customtasks/mobileactions/MobileActionsTask.kt) to ensure the model has the information it needs.\n\n## Build and Install \n\nNavigate to the `Android/src/` directory in your terminal and use the Gradle wrapper to build the debug version of the app and install it directly onto your connected device:\n\n```shell\ncd gallery/Android/src/\n./gradlew installDebug\n```\n\nGradle will take care of downloading dependencies, compiling the code, and deploying the APK. Once finished, you should see \"Edge Gallery\" appearing in your app drawer!\n"
  },
  {
    "path": "LICENSE",
    "content": "                                 Apache License\n                           Version 2.0, January 2004\n                        http://www.apache.org/licenses/\n\n   TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION\n\n   1. Definitions.\n\n      \"License\" shall mean the terms and conditions for use, reproduction,\n      and distribution as defined by Sections 1 through 9 of this document.\n\n      \"Licensor\" shall mean the copyright owner or entity authorized by\n      the copyright owner that is granting the License.\n\n      \"Legal Entity\" shall mean the union of the acting entity and all\n      other entities that control, are controlled by, or are under common\n      control with that entity. For the purposes of this definition,\n      \"control\" means (i) the power, direct or indirect, to cause the\n      direction or management of such entity, whether by contract or\n      otherwise, or (ii) ownership of fifty percent (50%) or more of the\n      outstanding shares, or (iii) beneficial ownership of such entity.\n\n      \"You\" (or \"Your\") shall mean an individual or Legal Entity\n      exercising permissions granted by this License.\n\n      \"Source\" form shall mean the preferred form for making modifications,\n      including but not limited to software source code, documentation\n      source, and configuration files.\n\n      \"Object\" form shall mean any form resulting from mechanical\n      transformation or translation of a Source form, including but\n      not limited to compiled object code, generated documentation,\n      and conversions to other media types.\n\n      \"Work\" shall mean the work of authorship, whether in Source or\n      Object form, made available under the License, as indicated by a\n      copyright notice that is included in or attached to the work\n      (an example is provided in the Appendix below).\n\n      \"Derivative Works\" shall mean any work, whether in Source or Object\n      form, that is based on (or derived from) the Work and for which the\n      editorial revisions, annotations, elaborations, or other modifications\n      represent, as a whole, an original work of authorship. For the purposes\n      of this License, Derivative Works shall not include works that remain\n      separable from, or merely link (or bind by name) to the interfaces of,\n      the Work and Derivative Works thereof.\n\n      \"Contribution\" shall mean any work of authorship, including\n      the original version of the Work and any modifications or additions\n      to that Work or Derivative Works thereof, that is intentionally\n      submitted to Licensor for inclusion in the Work by the copyright owner\n      or by an individual or Legal Entity authorized to submit on behalf of\n      the copyright owner. For the purposes of this definition, \"submitted\"\n      means any form of electronic, verbal, or written communication sent\n      to the Licensor or its representatives, including but not limited to\n      communication on electronic mailing lists, source code control systems,\n      and issue tracking systems that are managed by, or on behalf of, the\n      Licensor for the purpose of discussing and improving the Work, but\n      excluding communication that is conspicuously marked or otherwise\n      designated in writing by the copyright owner as \"Not a Contribution.\"\n\n      \"Contributor\" shall mean Licensor and any individual or Legal Entity\n      on behalf of whom a Contribution has been received by Licensor and\n      subsequently incorporated within the Work.\n\n   2. Grant of Copyright License. Subject to the terms and conditions of\n      this License, each Contributor hereby grants to You a perpetual,\n      worldwide, non-exclusive, no-charge, royalty-free, irrevocable\n      copyright license to reproduce, prepare Derivative Works of,\n      publicly display, publicly perform, sublicense, and distribute the\n      Work and such Derivative Works in Source or Object form.\n\n   3. Grant of Patent License. Subject to the terms and conditions of\n      this License, each Contributor hereby grants to You a perpetual,\n      worldwide, non-exclusive, no-charge, royalty-free, irrevocable\n      (except as stated in this section) patent license to make, have made,\n      use, offer to sell, sell, import, and otherwise transfer the Work,\n      where such license applies only to those patent claims licensable\n      by such Contributor that are necessarily infringed by their\n      Contribution(s) alone or by combination of their Contribution(s)\n      with the Work to which such Contribution(s) was submitted. If You\n      institute patent litigation against any entity (including a\n      cross-claim or counterclaim in a lawsuit) alleging that the Work\n      or a Contribution incorporated within the Work constitutes direct\n      or contributory patent infringement, then any patent licenses\n      granted to You under this License for that Work shall terminate\n      as of the date such litigation is filed.\n\n   4. Redistribution. You may reproduce and distribute copies of the\n      Work or Derivative Works thereof in any medium, with or without\n      modifications, and in Source or Object form, provided that You\n      meet the following conditions:\n\n      (a) You must give any other recipients of the Work or\n          Derivative Works a copy of this License; and\n\n      (b) You must cause any modified files to carry prominent notices\n          stating that You changed the files; and\n\n      (c) You must retain, in the Source form of any Derivative Works\n          that You distribute, all copyright, patent, trademark, and\n          attribution notices from the Source form of the Work,\n          excluding those notices that do not pertain to any part of\n          the Derivative Works; and\n\n      (d) If the Work includes a \"NOTICE\" text file as part of its\n          distribution, then any Derivative Works that You distribute must\n          include a readable copy of the attribution notices contained\n          within such NOTICE file, excluding those notices that do not\n          pertain to any part of the Derivative Works, in at least one\n          of the following places: within a NOTICE text file distributed\n          as part of the Derivative Works; within the Source form or\n          documentation, if provided along with the Derivative Works; or,\n          within a display generated by the Derivative Works, if and\n          wherever such third-party notices normally appear. The contents\n          of the NOTICE file are for informational purposes only and\n          do not modify the License. You may add Your own attribution\n          notices within Derivative Works that You distribute, alongside\n          or as an addendum to the NOTICE text from the Work, provided\n          that such additional attribution notices cannot be construed\n          as modifying the License.\n\n      You may add Your own copyright statement to Your modifications and\n      may provide additional or different license terms and conditions\n      for use, reproduction, or distribution of Your modifications, or\n      for any such Derivative Works as a whole, provided Your use,\n      reproduction, and distribution of the Work otherwise complies with\n      the conditions stated in this License.\n\n   5. Submission of Contributions. Unless You explicitly state otherwise,\n      any Contribution intentionally submitted for inclusion in the Work\n      by You to the Licensor shall be under the terms and conditions of\n      this License, without any additional terms or conditions.\n      Notwithstanding the above, nothing herein shall supersede or modify\n      the terms of any separate license agreement you may have executed\n      with Licensor regarding such Contributions.\n\n   6. Trademarks. This License does not grant permission to use the trade\n      names, trademarks, service marks, or product names of the Licensor,\n      except as required for reasonable and customary use in describing the\n      origin of the Work and reproducing the content of the NOTICE file.\n\n   7. Disclaimer of Warranty. Unless required by applicable law or\n      agreed to in writing, Licensor provides the Work (and each\n      Contributor provides its Contributions) on an \"AS IS\" BASIS,\n      WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or\n      implied, including, without limitation, any warranties or conditions\n      of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A\n      PARTICULAR PURPOSE. You are solely responsible for determining the\n      appropriateness of using or redistributing the Work and assume any\n      risks associated with Your exercise of permissions under this License.\n\n   8. Limitation of Liability. In no event and under no legal theory,\n      whether in tort (including negligence), contract, or otherwise,\n      unless required by applicable law (such as deliberate and grossly\n      negligent acts) or agreed to in writing, shall any Contributor be\n      liable to You for damages, including any direct, indirect, special,\n      incidental, or consequential damages of any character arising as a\n      result of this License or out of the use or inability to use the\n      Work (including but not limited to damages for loss of goodwill,\n      work stoppage, computer failure or malfunction, or any and all\n      other commercial damages or losses), even if such Contributor\n      has been advised of the possibility of such damages.\n\n   9. Accepting Warranty or Additional Liability. While redistributing\n      the Work or Derivative Works thereof, You may choose to offer,\n      and charge a fee for, acceptance of support, warranty, indemnity,\n      or other liability obligations and/or rights consistent with this\n      License. However, in accepting such obligations, You may act only\n      on Your own behalf and on Your sole responsibility, not on behalf\n      of any other Contributor, and only if You agree to indemnify,\n      defend, and hold each Contributor harmless for any liability\n      incurred by, or claims asserted against, such Contributor by reason\n      of your accepting any such warranty or additional liability.\n\n   END OF TERMS AND CONDITIONS\n\n   APPENDIX: How to apply the Apache License to your work.\n\n      To apply the Apache License to your work, attach the following\n      boilerplate notice, with the fields enclosed by brackets \"[]\"\n      replaced with your own identifying information. (Don't include\n      the brackets!)  The text should be enclosed in the appropriate\n      comment syntax for the file format. We also recommend that a\n      file or class name and description of purpose be included on the\n      same \"printed page\" as the copyright notice for easier\n      identification within third-party archives.\n\n   Copyright [yyyy] [name of copyright owner]\n\n   Licensed under the Apache License, Version 2.0 (the \"License\");\n   you may not use this file except in compliance with the License.\n   You may obtain a copy of the License at\n\n       http://www.apache.org/licenses/LICENSE-2.0\n\n   Unless required by applicable law or agreed to in writing, software\n   distributed under the License is distributed on an \"AS IS\" BASIS,\n   WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n   See the License for the specific language governing permissions and\n   limitations under the License.\n"
  },
  {
    "path": "README.md",
    "content": "# Google AI Edge Gallery ✨\n\n[![License](https://img.shields.io/badge/License-Apache%202.0-blue.svg)](LICENSE)\n[![GitHub release (latest by date)](https://img.shields.io/github/v/release/google-ai-edge/gallery)](https://github.com/google-ai-edge/gallery/releases)\n\n**Explore, Experience, and Evaluate the Future of On-Device Generative AI with Google AI Edge.**\n\nThe Google AI Edge Gallery is an experimental app that puts the power of cutting-edge Generative AI models directly into your hands, running entirely on your Android *(available now)* and iOS *(available now)* devices. Dive into a world of creative and practical AI use cases, all running locally, without needing an internet connection once the model is loaded. Experiment with different models, chat, ask questions with images and audio clip, explore prompts, and more!\n\n| **Install the app today from Google Play** | **Install the app today from App Store** |\n| :--- | :--- |\n| <a href='https://play.google.com/store/apps/details?id=com.google.ai.edge.gallery'><img alt='Get it on Google Play' height=\"120\" src='https://play.google.com/intl/en_us/badges/static/images/badges/en_badge_web_generic.png'/></a> | <a href=\"https://apps.apple.com/us/app/google-ai-edge-gallery/id6749645337?itscg=30200&itsct=apps_box_badge&mttnsubad=6749645337\" style=\"display: inline-block;\"> <img src=\"https://toolbox.marketingtools.apple.com/api/v2/badges/download-on-the-app-store/black/en-us?releaseDate=1771977600\" alt=\"Download on the App Store\" style=\"width: 246px; height: 90px; vertical-align: middle; object-fit: contain;\" /></a> |\n\nFor users without Google Play access, install the apk from the [**latest release**](https://github.com/google-ai-edge/gallery/releases/latest/)\n> [!IMPORTANT]\n> You must uninstall all previous versions of the app before installing this one. Past versions will no longer be working and supported.\n\n\n## App Preview\n\n<img width=\"480\" alt=\"01\" src=\"https://github.com/user-attachments/assets/09dbcf7e-a298-4063-920e-bfc88591f4a2\" />\n<img width=\"480\" alt=\"02\" src=\"https://github.com/user-attachments/assets/e2986bba-f807-42e1-9d5e-a5a978fa97e9\" />\n<img width=\"480\" alt=\"03\" src=\"https://github.com/user-attachments/assets/ad3aa9ab-e3b6-4a12-bbd4-885bb202aa0f\" />\n<img width=\"480\" alt=\"04\" src=\"https://github.com/user-attachments/assets/6441e752-e5f5-4753-9611-fa0122cdae49\" />\n<img width=\"480\" alt=\"05\" src=\"https://github.com/user-attachments/assets/a5ebcf15-640a-4c11-93ce-b92fe365f1a3\" />\n<img width=\"480\" alt=\"06\" src=\"https://github.com/user-attachments/assets/973c7a66-1906-400e-8fac-ee9b13b21aa1\" />\n<img width=\"480\" alt=\"07\" src=\"https://github.com/user-attachments/assets/d3227bc6-8d78-47a1-bbfa-93f009117882\" />\n\n## ✨ Core Features\n\n*   **📱 Run Locally, Fully Offline:** Experience the magic of GenAI without an internet connection. All processing happens directly on your device.\n*   **🤖 Choose Your Model:** Easily switch between different models from Hugging Face and compare their performance.\n*   **🌻 Tiny Garden**: Play an experimental and fully offline mini game that uses natural language to plant, water, and harvest flowers.\n*   **📳 Mobile Actions**: Use our [open-source recipe](https://github.com/google-gemini/gemma-cookbook/blob/main/FunctionGemma/%5BFunctionGemma%5DFinetune_FunctionGemma_270M_for_Mobile_Actions_with_Hugging_Face.ipynb) to learn model fine-tuning, then load it in app to unlock offline device controls.\n*   **🖼️ Ask Image:** Upload images and ask questions about them. Get descriptions, solve problems, or identify objects.\n*   **🎙️ Audio Scribe:** Transcribe an uploaded or recorded audio clip into text or translate it into another language.\n*   **✍️ Prompt Lab:** Summarize, rewrite, generate code, or use freeform prompts to explore single-turn LLM use cases.\n*   **💬 AI Chat:** Engage in multi-turn conversations.\n*   **📊 Performance Insights:** Real-time benchmarks (TTFT, decode speed, latency).\n*   **🧩 Bring Your Own Model:** Test your local LiteRT `.litertlm` models.\n*   **🔗 Developer Resources:** Quick links to model cards and source code.\n\n## 🏁 Get Started in Minutes!\n\n1. **Check OS Requirement**: Android 12 and up\n2.  **Download the App:**\n    - Install the app from [Google Play](https://play.google.com/store/apps/details?id=com.google.ai.edge.gallery).\n    - For users without Google Play access: install the apk from the [**latest release**](https://github.com/google-ai-edge/gallery/releases/latest/)\n3.  **Install & Explore:** For detailed installation instructions (including for corporate devices) and a full user guide, head over to our [**Project Wiki**](https://github.com/google-ai-edge/gallery/wiki)!\n\n## 🛠️ Technology Highlights\n\n*   **Google AI Edge:** Core APIs and tools for on-device ML.\n*   **LiteRT:** Lightweight runtime for optimized model execution.\n*   **LLM Inference API:** Powering on-device Large Language Models.\n*   **Hugging Face Integration:** For model discovery and download.\n\n## ⌨️ Development\n\nCheck out the [development notes](DEVELOPMENT.md) for instructions about how to build the app locally.\n\n## 🤝 Feedback\n\nThis is an **experimental Beta release**, and your input is crucial!\n\n*   🐞 **Found a bug?** [Report it here!](https://github.com/google-ai-edge/gallery/issues/new?assignees=&labels=bug&template=bug_report.md&title=%5BBUG%5D)\n*   💡 **Have an idea?** [Suggest a feature!](https://github.com/google-ai-edge/gallery/issues/new?assignees=&labels=enhancement&template=feature_request.md&title=%5BFEATURE%5D)\n\n## 📄 License\n\nLicensed under the Apache License, Version 2.0. See the [LICENSE](LICENSE) file for details.\n\n## 🔗 Useful Links\n\n*   [**Project Wiki (Detailed Guides)**](https://github.com/google-ai-edge/gallery/wiki)\n*   [Hugging Face LiteRT Community](https://huggingface.co/litert-community)\n*   [LLM Inference guide for Android](https://ai.google.dev/edge/mediapipe/solutions/genai/llm_inference/android)\n*   [LiteRT-LM](https://github.com/google-ai-edge/LiteRT-LM)\n*   [Google AI Edge Documentation](https://ai.google.dev/edge)\n"
  },
  {
    "path": "model_allowlist.json",
    "content": "{\n  \"models\": [\n    {\n      \"name\": \"Gemma-3n-E2B-it-int4\",\n      \"modelId\": \"google/gemma-3n-E2B-it-litert-preview\",\n      \"modelFile\": \"gemma-3n-E2B-it-int4.task\",\n      \"description\": \"Preview version of [Gemma 3n E2B](https://ai.google.dev/gemma/docs/gemma-3n) ready for deployment on Android using the [MediaPipe LLM Inference API](https://ai.google.dev/edge/mediapipe/solutions/genai/llm_inference). The current checkpoint only supports text and vision input, with 4096 context length.\",\n      \"sizeInBytes\": 3136226711,\n      \"estimatedPeakMemoryInBytes\": 5905580032,\n      \"version\": \"20250520\",\n      \"llmSupportImage\": true,\n      \"defaultConfig\": {\n        \"topK\": 64,\n        \"topP\": 0.95,\n        \"temperature\": 1.0,\n        \"maxTokens\": 4096,\n        \"accelerators\": \"cpu,gpu\"\n      },\n      \"taskTypes\": [\"llm_chat\", \"llm_prompt_lab\", \"llm_ask_image\"]\n    },\n    {\n      \"name\": \"Gemma-3n-E4B-it-int4\",\n      \"modelId\": \"google/gemma-3n-E4B-it-litert-preview\",\n      \"modelFile\": \"gemma-3n-E4B-it-int4.task\",\n      \"description\": \"Preview version of [Gemma 3n E4B](https://ai.google.dev/gemma/docs/gemma-3n) ready for deployment on Android using the [MediaPipe LLM Inference API](https://ai.google.dev/edge/mediapipe/solutions/genai/llm_inference). The current checkpoint only supports text and vision input, with 4096 context length.\",\n      \"sizeInBytes\": 4405655031,\n      \"estimatedPeakMemoryInBytes\": 6979321856,\n      \"version\": \"20250520\",\n      \"llmSupportImage\": true,\n      \"defaultConfig\": {\n        \"topK\": 64,\n        \"topP\": 0.95,\n        \"temperature\": 1.0,\n        \"maxTokens\": 4096,\n        \"accelerators\": \"cpu,gpu\"\n      },\n      \"taskTypes\": [\"llm_chat\", \"llm_prompt_lab\", \"llm_ask_image\"]\n    },\n    {\n      \"name\": \"Gemma3-1B-IT q4\",\n      \"modelId\": \"litert-community/Gemma3-1B-IT\",\n      \"modelFile\": \"Gemma3-1B-IT_multi-prefill-seq_q4_ekv2048.task\",\n      \"description\": \"A variant of [google/Gemma-3-1B-IT](https://huggingface.co/google/Gemma-3-1B-IT) with 4-bit quantization ready for deployment on Android using the [MediaPipe LLM Inference API](https://ai.google.dev/edge/mediapipe/solutions/genai/llm_inference)\",\n      \"sizeInBytes\": 554661246,\n      \"estimatedPeakMemoryInBytes\": 2147483648,\n      \"version\": \"20250514\",\n      \"defaultConfig\": {\n        \"topK\": 64,\n        \"topP\": 0.95,\n        \"temperature\": 1.0,\n        \"maxTokens\": 1024,\n        \"accelerators\": \"gpu,cpu\"\n      },\n      \"taskTypes\": [\"llm_chat\", \"llm_prompt_lab\"]\n    },\n    {\n      \"name\": \"Qwen2.5-1.5B-Instruct q8\",\n      \"modelId\": \"litert-community/Qwen2.5-1.5B-Instruct\",\n      \"modelFile\": \"Qwen2.5-1.5B-Instruct_multi-prefill-seq_q8_ekv1280.task\",\n      \"description\": \"A variant of [Qwen/Qwen2.5-1.5B-Instruct](https://huggingface.co/Qwen/Qwen2.5-1.5B-Instruct) with 8-bit quantization ready for deployment on Android using the [MediaPipe LLM Inference API](https://ai.google.dev/edge/mediapipe/solutions/genai/llm_inference)\",\n      \"sizeInBytes\": 1625493432,\n      \"estimatedPeakMemoryInBytes\": 2684354560,\n      \"version\": \"20250514\",\n      \"defaultConfig\": {\n        \"topK\": 40,\n        \"topP\": 0.95,\n        \"temperature\": 1.0,\n        \"maxTokens\": 1024,\n        \"accelerators\": \"cpu\"\n      },\n      \"taskTypes\": [\"llm_chat\", \"llm_prompt_lab\"]\n    }\n  ]\n}\n"
  },
  {
    "path": "model_allowlists/1_0_10.json",
    "content": "{\n  \"models\": [\n    {\n      \"name\": \"Gemma-3n-E2B-it\",\n      \"modelId\": \"google/gemma-3n-E2B-it-litert-lm\",\n      \"modelFile\": \"gemma-3n-E2B-it-int4.litertlm\",\n      \"description\": \"A variant of [Gemma 3n E2B](https://ai.google.dev/gemma/docs/gemma-3n) ready for deployment on Android using [LiteRT-LM](https://github.com/google-ai-edge/LiteRT-LM/blob/main/kotlin/README.md). It supports text, vision, and audio input, with 4096 context length.\",\n      \"sizeInBytes\": 3655827456,\n      \"minDeviceMemoryInGb\": 8,\n      \"commitHash\": \"ba9ca88da013b537b6ed38108be609b8db1c3a16\",\n      \"llmSupportImage\": true,\n      \"llmSupportAudio\": true,\n      \"defaultConfig\": {\n        \"topK\": 64,\n        \"topP\": 0.95,\n        \"temperature\": 1.0,\n        \"maxTokens\": 4096,\n        \"accelerators\": \"cpu,gpu\"\n      },\n      \"taskTypes\": [\"llm_chat\", \"llm_prompt_lab\", \"llm_ask_image\", \"llm_ask_audio\"],\n      \"bestForTaskTypes\": [\"llm_ask_image\", \"llm_ask_audio\"]\n    },\n    {\n      \"name\": \"Gemma-3n-E4B-it\",\n      \"modelId\": \"google/gemma-3n-E4B-it-litert-lm\",\n      \"modelFile\": \"gemma-3n-E4B-it-int4.litertlm\",\n      \"description\": \"A variant of [Gemma 3n E4B](https://ai.google.dev/gemma/docs/gemma-3n) ready for deployment on Android using [LiteRT-LM](https://github.com/google-ai-edge/LiteRT-LM/blob/main/kotlin/README.md). It supports text, vision, and audio input, with 4096 context length.\",\n      \"sizeInBytes\": 4919541760,\n      \"minDeviceMemoryInGb\": 12,\n      \"commitHash\": \"297ed75955702dec3503e00c2c2ecbbf475300bc\",\n      \"llmSupportImage\": true,\n      \"llmSupportAudio\": true,\n      \"defaultConfig\": {\n        \"topK\": 64,\n        \"topP\": 0.95,\n        \"temperature\": 1.0,\n        \"maxTokens\": 4096,\n        \"accelerators\": \"cpu,gpu\"\n      },\n      \"taskTypes\": [\"llm_chat\", \"llm_prompt_lab\", \"llm_ask_image\", \"llm_ask_audio\"]\n    },\n    {\n      \"name\": \"Gemma3-1B-IT\",\n      \"modelId\": \"litert-community/Gemma3-1B-IT\",\n      \"modelFile\": \"gemma3-1b-it-int4.litertlm\",\n      \"description\": \"A variant of [google/Gemma-3-1B-IT](https://huggingface.co/google/Gemma-3-1B-IT) with 4-bit quantization ready for deployment on Android using [LiteRT-LM](https://github.com/google-ai-edge/LiteRT-LM/blob/main/kotlin/README.md).\",\n      \"sizeInBytes\": 584417280,\n      \"minDeviceMemoryInGb\": 6,\n      \"commitHash\": \"42d538a932e8d5b12e6b3b455f5572560bd60b2c\",\n      \"defaultConfig\": {\n        \"topK\": 64,\n        \"topP\": 0.95,\n        \"temperature\": 1.0,\n        \"maxTokens\": 1024,\n        \"accelerators\": \"gpu,cpu\"\n      },\n      \"taskTypes\": [\"llm_chat\", \"llm_prompt_lab\"],\n      \"bestForTaskTypes\": [\"llm_chat\", \"llm_prompt_lab\"]\n    },\n    {\n      \"name\": \"Qwen2.5-1.5B-Instruct\",\n      \"modelId\": \"litert-community/Qwen2.5-1.5B-Instruct\",\n      \"modelFile\": \"Qwen2.5-1.5B-Instruct_multi-prefill-seq_q8_ekv4096.litertlm\",\n      \"description\": \"A variant of [Qwen/Qwen2.5-1.5B-Instruct](https://huggingface.co/Qwen/Qwen2.5-1.5B-Instruct) ready for deployment on Android using [LiteRT-LM](https://github.com/google-ai-edge/LiteRT-LM/blob/main/kotlin/README.md).\",\n      \"sizeInBytes\": 1597931520,\n      \"minDeviceMemoryInGb\": 6,\n      \"commitHash\": \"19edb84c69a0212f29a6ef17ba0d6f278b6a1614\",\n      \"defaultConfig\": {\n        \"topK\": 20,\n        \"topP\": 0.8,\n        \"temperature\": 0.7,\n        \"maxTokens\": 4096,\n        \"accelerators\": \"gpu,cpu\"\n      },\n      \"taskTypes\": [\"llm_chat\", \"llm_prompt_lab\"]\n    },\n    {\n      \"name\": \"DeepSeek-R1-Distill-Qwen-1.5B\",\n      \"modelId\": \"litert-community/DeepSeek-R1-Distill-Qwen-1.5B\",\n      \"modelFile\": \"DeepSeek-R1-Distill-Qwen-1.5B_multi-prefill-seq_q8_ekv4096.litertlm\",\n      \"description\": \"A variant of [deepseek-ai/DeepSeek-R1-Distill-Qwen-1.5B](https://huggingface.co/deepseek-ai/DeepSeek-R1-Distill-Qwen-1.5B) ready for deployment on Android using [LiteRT-LM](https://github.com/google-ai-edge/LiteRT-LM/blob/main/kotlin/README.md).\",\n      \"sizeInBytes\": 1833451520,\n      \"minDeviceMemoryInGb\": 6,\n      \"commitHash\": \"e34bb88632342d1f9640bad579a45134eb1cf988\",\n      \"defaultConfig\": {\n        \"topK\": 64,\n        \"topP\": 0.95,\n        \"temperature\": 1.0,\n        \"maxTokens\": 4096,\n        \"accelerators\": \"gpu,cpu\"\n      },\n      \"taskTypes\": [\"llm_chat\", \"llm_prompt_lab\"]\n    },\n    {\n      \"name\": \"TinyGarden-270M\",\n      \"modelId\": \"litert-community/functiongemma-270m-ft-tiny-garden\",\n      \"modelFile\": \"tiny_garden_q8_ekv1024.litertlm\",\n      \"description\": \"Fine-tuned Function Gemma 270M model for Tiny Garden.\",\n      \"sizeInBytes\": 288964608,\n      \"minDeviceMemoryInGb\": 6,\n      \"commitHash\": \"c205853ff82da86141a1105faa2344a8b176dfe7\",\n      \"defaultConfig\": {\n        \"topK\": 64,\n        \"topP\": 0.95,\n        \"temperature\": 0.0,\n        \"maxTokens\": 1024,\n        \"accelerators\": \"cpu\"\n      },\n      \"taskTypes\": [\n        \"llm_tiny_garden\"\n      ],\n      \"bestForTaskTypes\": [\n        \"llm_tiny_garden\"\n      ]\n    },\n    {\n      \"name\": \"MobileActions-270M\",\n      \"modelId\": \"litert-community/functiongemma-270m-ft-mobile-actions\",\n      \"modelFile\": \"mobile_actions_q8_ekv1024.litertlm\",\n      \"description\": \"Fine-tuned Function Gemma 270M model for Mobile Actions.\",\n      \"sizeInBytes\": 288964608,\n      \"minDeviceMemoryInGb\": 6,\n      \"commitHash\": \"38942192c9b723af836d489074823ff33d4a3e7a\",\n      \"defaultConfig\": {\n        \"topK\": 64,\n        \"topP\": 0.95,\n        \"temperature\": 0.0,\n        \"maxTokens\": 1024,\n        \"accelerators\": \"cpu\"\n      },\n      \"taskTypes\": [\n        \"llm_mobile_actions\"\n      ],\n      \"bestForTaskTypes\": [\n        \"llm_mobile_actions\"\n      ]\n    }\n  ]\n}\n"
  },
  {
    "path": "model_allowlists/1_0_4.json",
    "content": "{\n  \"models\": [\n    {\n      \"name\": \"Gemma-3n-E2B-it\",\n      \"modelId\": \"google/gemma-3n-E2B-it-litert-lm\",\n      \"modelFile\": \"gemma-3n-E2B-it-int4.litertlm\",\n      \"description\": \"**⚠️⚠️⚠️ Your current app version is too old to support this model. For continued access and the best experience, please update your app to the newest version.**\",\n      \"sizeInBytes\": 3388604416,\n      \"minDeviceMemoryInGb\": 8,\n      \"commitHash\": \"73b019b63436d346f68dd9c1dbfd117eb264d888\",\n      \"llmSupportImage\": true,\n      \"llmSupportAudio\": true,\n      \"defaultConfig\": {\n        \"topK\": 64,\n        \"topP\": 0.95,\n        \"temperature\": 1.0,\n        \"maxTokens\": 4096,\n        \"accelerators\": \"cpu,gpu\"\n      },\n      \"taskTypes\": [\"llm_chat\", \"llm_prompt_lab\", \"llm_ask_image\", \"llm_ask_audio\"],\n      \"bestForTaskTypes\": [\"llm_ask_image\", \"llm_ask_audio\"]\n    },\n    {\n      \"name\": \"Gemma-3n-E4B-it\",\n      \"modelId\": \"google/gemma-3n-E4B-it-litert-lm\",\n      \"modelFile\": \"gemma-3n-E4B-it-int4.litertlm\",\n      \"description\": \"**⚠️⚠️⚠️ Your current app version is too old to support this model. For continued access and the best experience, please update your app to the newest version.**\",\n      \"sizeInBytes\": 4652318720,\n      \"minDeviceMemoryInGb\": 12,\n      \"commitHash\": \"3d0179a0648381585ab337e170b7517aae8e0ce4\",\n      \"llmSupportImage\": true,\n      \"llmSupportAudio\": true,\n      \"defaultConfig\": {\n        \"topK\": 64,\n        \"topP\": 0.95,\n        \"temperature\": 1.0,\n        \"maxTokens\": 4096,\n        \"accelerators\": \"cpu,gpu\"\n      },\n      \"taskTypes\": [\"llm_chat\", \"llm_prompt_lab\", \"llm_ask_image\", \"llm_ask_audio\"]\n    },\n    {\n      \"name\": \"Gemma3-1B-IT\",\n      \"modelId\": \"litert-community/Gemma3-1B-IT\",\n      \"modelFile\": \"gemma3-1b-it-int4.litertlm\",\n      \"description\": \"A variant of [google/Gemma-3-1B-IT](https://huggingface.co/google/Gemma-3-1B-IT) with 4-bit quantization ready for deployment on Android using the [MediaPipe LLM Inference API](https://ai.google.dev/edge/mediapipe/solutions/genai/llm_inference)\",\n      \"sizeInBytes\": 584417280,\n      \"minDeviceMemoryInGb\": 6,\n      \"commitHash\": \"42d538a932e8d5b12e6b3b455f5572560bd60b2c\",\n      \"defaultConfig\": {\n        \"topK\": 64,\n        \"topP\": 0.95,\n        \"temperature\": 1.0,\n        \"maxTokens\": 1024,\n        \"accelerators\": \"gpu,cpu\"\n      },\n      \"taskTypes\": [\"llm_chat\", \"llm_prompt_lab\"],\n      \"bestForTaskTypes\": [\"llm_chat\", \"llm_prompt_lab\"]\n    }\n  ]\n}\n"
  },
  {
    "path": "model_allowlists/1_0_5.json",
    "content": "{\n  \"models\": [\n    {\n      \"name\": \"Gemma-3n-E2B-it\",\n      \"modelId\": \"google/gemma-3n-E2B-it-litert-lm\",\n      \"modelFile\": \"gemma-3n-E2B-it-int4.litertlm\",\n      \"description\": \"**⚠️⚠️⚠️ Your current app version is too old to support this model. For continued access and the best experience, please update your app to the newest version.**\",\n      \"sizeInBytes\": 3388604416,\n      \"minDeviceMemoryInGb\": 8,\n      \"commitHash\": \"73b019b63436d346f68dd9c1dbfd117eb264d888\",\n      \"llmSupportImage\": true,\n      \"llmSupportAudio\": true,\n      \"defaultConfig\": {\n        \"topK\": 64,\n        \"topP\": 0.95,\n        \"temperature\": 1.0,\n        \"maxTokens\": 4096,\n        \"accelerators\": \"cpu,gpu\"\n      },\n      \"taskTypes\": [\"llm_chat\", \"llm_prompt_lab\", \"llm_ask_image\", \"llm_ask_audio\"],\n      \"bestForTaskTypes\": [\"llm_ask_image\", \"llm_ask_audio\"]\n    },\n    {\n      \"name\": \"Gemma-3n-E4B-it\",\n      \"modelId\": \"google/gemma-3n-E4B-it-litert-lm\",\n      \"modelFile\": \"gemma-3n-E4B-it-int4.litertlm\",\n      \"description\": \"**⚠️⚠️⚠️ Your current app version is too old to support this model. For continued access and the best experience, please update your app to the newest version.**\",\n      \"sizeInBytes\": 4652318720,\n      \"minDeviceMemoryInGb\": 12,\n      \"commitHash\": \"3d0179a0648381585ab337e170b7517aae8e0ce4\",\n      \"llmSupportImage\": true,\n      \"llmSupportAudio\": true,\n      \"defaultConfig\": {\n        \"topK\": 64,\n        \"topP\": 0.95,\n        \"temperature\": 1.0,\n        \"maxTokens\": 4096,\n        \"accelerators\": \"cpu,gpu\"\n      },\n      \"taskTypes\": [\"llm_chat\", \"llm_prompt_lab\", \"llm_ask_image\", \"llm_ask_audio\"]\n    },\n    {\n      \"name\": \"Gemma3-1B-IT\",\n      \"modelId\": \"litert-community/Gemma3-1B-IT\",\n      \"modelFile\": \"gemma3-1b-it-int4.litertlm\",\n      \"description\": \"A variant of [google/Gemma-3-1B-IT](https://huggingface.co/google/Gemma-3-1B-IT) with 4-bit quantization ready for deployment on Android using the [MediaPipe LLM Inference API](https://ai.google.dev/edge/mediapipe/solutions/genai/llm_inference)\",\n      \"sizeInBytes\": 584417280,\n      \"minDeviceMemoryInGb\": 6,\n      \"commitHash\": \"42d538a932e8d5b12e6b3b455f5572560bd60b2c\",\n      \"defaultConfig\": {\n        \"topK\": 64,\n        \"topP\": 0.95,\n        \"temperature\": 1.0,\n        \"maxTokens\": 1024,\n        \"accelerators\": \"gpu,cpu\"\n      },\n      \"taskTypes\": [\"llm_chat\", \"llm_prompt_lab\"],\n      \"bestForTaskTypes\": [\"llm_chat\", \"llm_prompt_lab\"]\n    }\n  ]\n}\n"
  },
  {
    "path": "model_allowlists/1_0_6.json",
    "content": "{\n  \"models\": [\n    {\n      \"name\": \"Gemma-3n-E2B-it\",\n      \"modelId\": \"google/gemma-3n-E2B-it-litert-lm\",\n      \"modelFile\": \"gemma-3n-E2B-it-int4.litertlm\",\n      \"description\": \"**⚠️⚠️⚠️ Your current app version is too old to support this model. For continued access and the best experience, please update your app to the newest version.**\",\n      \"sizeInBytes\": 3388604416,\n      \"minDeviceMemoryInGb\": 8,\n      \"commitHash\": \"73b019b63436d346f68dd9c1dbfd117eb264d888\",\n      \"llmSupportImage\": true,\n      \"llmSupportAudio\": true,\n      \"defaultConfig\": {\n        \"topK\": 64,\n        \"topP\": 0.95,\n        \"temperature\": 1.0,\n        \"maxTokens\": 4096,\n        \"accelerators\": \"cpu,gpu\"\n      },\n      \"taskTypes\": [\"llm_chat\", \"llm_prompt_lab\", \"llm_ask_image\", \"llm_ask_audio\"],\n      \"bestForTaskTypes\": [\"llm_ask_image\", \"llm_ask_audio\"]\n    },\n    {\n      \"name\": \"Gemma-3n-E4B-it\",\n      \"modelId\": \"google/gemma-3n-E4B-it-litert-lm\",\n      \"modelFile\": \"gemma-3n-E4B-it-int4.litertlm\",\n      \"description\": \"**⚠️⚠️⚠️ Your current app version is too old to support this model. For continued access and the best experience, please update your app to the newest version.**\",\n      \"sizeInBytes\": 4652318720,\n      \"minDeviceMemoryInGb\": 12,\n      \"commitHash\": \"3d0179a0648381585ab337e170b7517aae8e0ce4\",\n      \"llmSupportImage\": true,\n      \"llmSupportAudio\": true,\n      \"defaultConfig\": {\n        \"topK\": 64,\n        \"topP\": 0.95,\n        \"temperature\": 1.0,\n        \"maxTokens\": 4096,\n        \"accelerators\": \"cpu,gpu\"\n      },\n      \"taskTypes\": [\"llm_chat\", \"llm_prompt_lab\", \"llm_ask_image\", \"llm_ask_audio\"]\n    },\n    {\n      \"name\": \"Gemma3-1B-IT\",\n      \"modelId\": \"litert-community/Gemma3-1B-IT\",\n      \"modelFile\": \"gemma3-1b-it-int4.litertlm\",\n      \"description\": \"A variant of [google/Gemma-3-1B-IT](https://huggingface.co/google/Gemma-3-1B-IT) with 4-bit quantization ready for deployment on Android using the [MediaPipe LLM Inference API](https://ai.google.dev/edge/mediapipe/solutions/genai/llm_inference)\",\n      \"sizeInBytes\": 584417280,\n      \"minDeviceMemoryInGb\": 6,\n      \"commitHash\": \"42d538a932e8d5b12e6b3b455f5572560bd60b2c\",\n      \"defaultConfig\": {\n        \"topK\": 64,\n        \"topP\": 0.95,\n        \"temperature\": 1.0,\n        \"maxTokens\": 1024,\n        \"accelerators\": \"gpu,cpu\"\n      },\n      \"taskTypes\": [\"llm_chat\", \"llm_prompt_lab\"],\n      \"bestForTaskTypes\": [\"llm_chat\", \"llm_prompt_lab\"]\n    }\n  ]\n}\n"
  },
  {
    "path": "model_allowlists/1_0_7.json",
    "content": "{\n  \"models\": [\n    {\n      \"name\": \"Gemma-3n-E2B-it\",\n      \"modelId\": \"google/gemma-3n-E2B-it-litert-lm\",\n      \"modelFile\": \"gemma-3n-E2B-it-int4.litertlm\",\n      \"description\": \"Preview version of [Gemma 3n E2B](https://ai.google.dev/gemma/docs/gemma-3n) ready for deployment on Android using the [MediaPipe LLM Inference API](https://ai.google.dev/edge/mediapipe/solutions/genai/llm_inference). The current checkpoint supports text, vision, and audio input, with 4096 context length.\",\n      \"sizeInBytes\": 3388604416,\n      \"minDeviceMemoryInGb\": 8,\n      \"commitHash\": \"73b019b63436d346f68dd9c1dbfd117eb264d888\",\n      \"llmSupportImage\": true,\n      \"llmSupportAudio\": true,\n      \"defaultConfig\": {\n        \"topK\": 64,\n        \"topP\": 0.95,\n        \"temperature\": 1.0,\n        \"maxTokens\": 4096,\n        \"accelerators\": \"cpu,gpu\"\n      },\n      \"taskTypes\": [\"llm_chat\", \"llm_prompt_lab\", \"llm_ask_image\", \"llm_ask_audio\"],\n      \"bestForTaskTypes\": [\"llm_ask_image\", \"llm_ask_audio\"]\n    },\n    {\n      \"name\": \"Gemma-3n-E4B-it\",\n      \"modelId\": \"google/gemma-3n-E4B-it-litert-lm\",\n      \"modelFile\": \"gemma-3n-E4B-it-int4.litertlm\",\n      \"description\": \"Preview version of [Gemma 3n E4B](https://ai.google.dev/gemma/docs/gemma-3n) ready for deployment on Android using the [MediaPipe LLM Inference API](https://ai.google.dev/edge/mediapipe/solutions/genai/llm_inference). The current checkpoint supports text, vision, and audio input, with 4096 context length.\",\n      \"sizeInBytes\": 4652318720,\n      \"minDeviceMemoryInGb\": 12,\n      \"commitHash\": \"3d0179a0648381585ab337e170b7517aae8e0ce4\",\n      \"llmSupportImage\": true,\n      \"llmSupportAudio\": true,\n      \"defaultConfig\": {\n        \"topK\": 64,\n        \"topP\": 0.95,\n        \"temperature\": 1.0,\n        \"maxTokens\": 4096,\n        \"accelerators\": \"cpu,gpu\"\n      },\n      \"taskTypes\": [\"llm_chat\", \"llm_prompt_lab\", \"llm_ask_image\", \"llm_ask_audio\"]\n    },\n    {\n      \"name\": \"Gemma3-1B-IT\",\n      \"modelId\": \"litert-community/Gemma3-1B-IT\",\n      \"modelFile\": \"gemma3-1b-it-int4.litertlm\",\n      \"description\": \"A variant of [google/Gemma-3-1B-IT](https://huggingface.co/google/Gemma-3-1B-IT) with 4-bit quantization ready for deployment on Android using the [MediaPipe LLM Inference API](https://ai.google.dev/edge/mediapipe/solutions/genai/llm_inference)\",\n      \"sizeInBytes\": 584417280,\n      \"minDeviceMemoryInGb\": 6,\n      \"commitHash\": \"42d538a932e8d5b12e6b3b455f5572560bd60b2c\",\n      \"defaultConfig\": {\n        \"topK\": 64,\n        \"topP\": 0.95,\n        \"temperature\": 1.0,\n        \"maxTokens\": 1024,\n        \"accelerators\": \"gpu,cpu\"\n      },\n      \"taskTypes\": [\"llm_chat\", \"llm_prompt_lab\"],\n      \"bestForTaskTypes\": [\"llm_chat\", \"llm_prompt_lab\"]\n    }\n  ]\n}\n"
  },
  {
    "path": "model_allowlists/1_0_8.json",
    "content": "{\n  \"models\": [\n    {\n      \"name\": \"Gemma-3n-E2B-it\",\n      \"modelId\": \"google/gemma-3n-E2B-it-litert-lm\",\n      \"modelFile\": \"gemma-3n-E2B-it-int4.litertlm\",\n      \"description\": \"A variant of [Gemma 3n E2B](https://ai.google.dev/gemma/docs/gemma-3n) ready for deployment on Android using [LiteRT-LM](https://github.com/google-ai-edge/LiteRT-LM/blob/main/kotlin/README.md). It supports text, vision, and audio input, with 4096 context length.\",\n      \"sizeInBytes\": 3655827456,\n      \"minDeviceMemoryInGb\": 8,\n      \"commitHash\": \"ba9ca88da013b537b6ed38108be609b8db1c3a16\",\n      \"llmSupportImage\": true,\n      \"llmSupportAudio\": true,\n      \"defaultConfig\": {\n        \"topK\": 64,\n        \"topP\": 0.95,\n        \"temperature\": 1.0,\n        \"maxTokens\": 4096,\n        \"accelerators\": \"cpu,gpu\"\n      },\n      \"taskTypes\": [\"llm_chat\", \"llm_prompt_lab\", \"llm_ask_image\", \"llm_ask_audio\"],\n      \"bestForTaskTypes\": [\"llm_ask_image\", \"llm_ask_audio\"]\n    },\n    {\n      \"name\": \"Gemma-3n-E4B-it\",\n      \"modelId\": \"google/gemma-3n-E4B-it-litert-lm\",\n      \"modelFile\": \"gemma-3n-E4B-it-int4.litertlm\",\n      \"description\": \"A variant of [Gemma 3n E4B](https://ai.google.dev/gemma/docs/gemma-3n) ready for deployment on Android using [LiteRT-LM](https://github.com/google-ai-edge/LiteRT-LM/blob/main/kotlin/README.md). It supports text, vision, and audio input, with 4096 context length.\",\n      \"sizeInBytes\": 4919541760,\n      \"minDeviceMemoryInGb\": 12,\n      \"commitHash\": \"297ed75955702dec3503e00c2c2ecbbf475300bc\",\n      \"llmSupportImage\": true,\n      \"llmSupportAudio\": true,\n      \"defaultConfig\": {\n        \"topK\": 64,\n        \"topP\": 0.95,\n        \"temperature\": 1.0,\n        \"maxTokens\": 4096,\n        \"accelerators\": \"cpu,gpu\"\n      },\n      \"taskTypes\": [\"llm_chat\", \"llm_prompt_lab\", \"llm_ask_image\", \"llm_ask_audio\"]\n    },\n    {\n      \"name\": \"Gemma3-1B-IT\",\n      \"modelId\": \"litert-community/Gemma3-1B-IT\",\n      \"modelFile\": \"gemma3-1b-it-int4.litertlm\",\n      \"description\": \"A variant of [google/Gemma-3-1B-IT](https://huggingface.co/google/Gemma-3-1B-IT) with 4-bit quantization ready for deployment on Android using [LiteRT-LM](https://github.com/google-ai-edge/LiteRT-LM/blob/main/kotlin/README.md).\",\n      \"sizeInBytes\": 584417280,\n      \"minDeviceMemoryInGb\": 6,\n      \"commitHash\": \"42d538a932e8d5b12e6b3b455f5572560bd60b2c\",\n      \"defaultConfig\": {\n        \"topK\": 64,\n        \"topP\": 0.95,\n        \"temperature\": 1.0,\n        \"maxTokens\": 1024,\n        \"accelerators\": \"gpu,cpu\"\n      },\n      \"taskTypes\": [\"llm_chat\", \"llm_prompt_lab\"],\n      \"bestForTaskTypes\": [\"llm_chat\", \"llm_prompt_lab\"]\n    },\n    {\n      \"name\": \"Qwen2.5-1.5B-Instruct\",\n      \"modelId\": \"litert-community/Qwen2.5-1.5B-Instruct\",\n      \"modelFile\": \"Qwen2.5-1.5B-Instruct_multi-prefill-seq_q8_ekv4096.litertlm\",\n      \"description\": \"A variant of [Qwen/Qwen2.5-1.5B-Instruct](https://huggingface.co/Qwen/Qwen2.5-1.5B-Instruct) ready for deployment on Android using [LiteRT-LM](https://github.com/google-ai-edge/LiteRT-LM/blob/main/kotlin/README.md).\",\n      \"sizeInBytes\": 1597931520,\n      \"minDeviceMemoryInGb\": 6,\n      \"commitHash\": \"19edb84c69a0212f29a6ef17ba0d6f278b6a1614\",\n      \"defaultConfig\": {\n        \"topK\": 20,\n        \"topP\": 0.8,\n        \"temperature\": 0.7,\n        \"maxTokens\": 4096,\n        \"accelerators\": \"gpu,cpu\"\n      },\n      \"taskTypes\": [\"llm_chat\", \"llm_prompt_lab\"]\n    },\n    {\n      \"name\": \"Phi-4-mini-instruct\",\n      \"modelId\": \"litert-community/Phi-4-mini-instruct\",\n      \"modelFile\": \"Phi-4-mini-instruct_multi-prefill-seq_q8_ekv4096.litertlm\",\n      \"description\": \"A variant of [microsoft/Phi-4-mini-instruct](https://huggingface.co/microsoft/Phi-4-mini-instruct) ready for deployment on Android using [LiteRT-LM](https://github.com/google-ai-edge/LiteRT-LM/blob/main/kotlin/README.md).\",\n      \"sizeInBytes\": 3910090752,\n      \"minDeviceMemoryInGb\": 6,\n      \"commitHash\": \"054f4e2694a86f81a129a40596e08b8d74770a9d\",\n      \"defaultConfig\": {\n        \"topK\": 64,\n        \"topP\": 0.95,\n        \"temperature\": 1.0,\n        \"maxTokens\": 4096,\n        \"accelerators\": \"gpu,cpu\"\n      },\n      \"taskTypes\": [\"llm_chat\", \"llm_prompt_lab\"]\n    },\n    {\n      \"name\": \"DeepSeek-R1-Distill-Qwen-1.5B\",\n      \"modelId\": \"litert-community/DeepSeek-R1-Distill-Qwen-1.5B\",\n      \"modelFile\": \"DeepSeek-R1-Distill-Qwen-1.5B_multi-prefill-seq_q8_ekv4096.litertlm\",\n      \"description\": \"A variant of [deepseek-ai/DeepSeek-R1-Distill-Qwen-1.5B](https://huggingface.co/deepseek-ai/DeepSeek-R1-Distill-Qwen-1.5B) ready for deployment on Android using [LiteRT-LM](https://github.com/google-ai-edge/LiteRT-LM/blob/main/kotlin/README.md).\",\n      \"sizeInBytes\": 1833451520,\n      \"minDeviceMemoryInGb\": 6,\n      \"commitHash\": \"e34bb88632342d1f9640bad579a45134eb1cf988\",\n      \"defaultConfig\": {\n        \"topK\": 64,\n        \"topP\": 0.95,\n        \"temperature\": 1.0,\n        \"maxTokens\": 4096,\n        \"accelerators\": \"gpu,cpu\"\n      },\n      \"taskTypes\": [\"llm_chat\", \"llm_prompt_lab\"]\n    }\n  ]\n}\n"
  },
  {
    "path": "model_allowlists/1_0_9.json",
    "content": "{\n  \"models\": [\n    {\n      \"name\": \"Gemma-3n-E2B-it\",\n      \"modelId\": \"google/gemma-3n-E2B-it-litert-lm\",\n      \"modelFile\": \"gemma-3n-E2B-it-int4.litertlm\",\n      \"description\": \"A variant of [Gemma 3n E2B](https://ai.google.dev/gemma/docs/gemma-3n) ready for deployment on Android using [LiteRT-LM](https://github.com/google-ai-edge/LiteRT-LM/blob/main/kotlin/README.md). It supports text, vision, and audio input, with 4096 context length.\",\n      \"sizeInBytes\": 3655827456,\n      \"minDeviceMemoryInGb\": 8,\n      \"commitHash\": \"ba9ca88da013b537b6ed38108be609b8db1c3a16\",\n      \"llmSupportImage\": true,\n      \"llmSupportAudio\": true,\n      \"defaultConfig\": {\n        \"topK\": 64,\n        \"topP\": 0.95,\n        \"temperature\": 1.0,\n        \"maxTokens\": 4096,\n        \"accelerators\": \"cpu,gpu\"\n      },\n      \"taskTypes\": [\"llm_chat\", \"llm_prompt_lab\", \"llm_ask_image\", \"llm_ask_audio\"],\n      \"bestForTaskTypes\": [\"llm_ask_image\", \"llm_ask_audio\"]\n    },\n    {\n      \"name\": \"Gemma-3n-E4B-it\",\n      \"modelId\": \"google/gemma-3n-E4B-it-litert-lm\",\n      \"modelFile\": \"gemma-3n-E4B-it-int4.litertlm\",\n      \"description\": \"A variant of [Gemma 3n E4B](https://ai.google.dev/gemma/docs/gemma-3n) ready for deployment on Android using [LiteRT-LM](https://github.com/google-ai-edge/LiteRT-LM/blob/main/kotlin/README.md). It supports text, vision, and audio input, with 4096 context length.\",\n      \"sizeInBytes\": 4919541760,\n      \"minDeviceMemoryInGb\": 12,\n      \"commitHash\": \"297ed75955702dec3503e00c2c2ecbbf475300bc\",\n      \"llmSupportImage\": true,\n      \"llmSupportAudio\": true,\n      \"defaultConfig\": {\n        \"topK\": 64,\n        \"topP\": 0.95,\n        \"temperature\": 1.0,\n        \"maxTokens\": 4096,\n        \"accelerators\": \"cpu,gpu\"\n      },\n      \"taskTypes\": [\"llm_chat\", \"llm_prompt_lab\", \"llm_ask_image\", \"llm_ask_audio\"]\n    },\n    {\n      \"name\": \"Gemma3-1B-IT\",\n      \"modelId\": \"litert-community/Gemma3-1B-IT\",\n      \"modelFile\": \"gemma3-1b-it-int4.litertlm\",\n      \"description\": \"A variant of [google/Gemma-3-1B-IT](https://huggingface.co/google/Gemma-3-1B-IT) with 4-bit quantization ready for deployment on Android using [LiteRT-LM](https://github.com/google-ai-edge/LiteRT-LM/blob/main/kotlin/README.md).\",\n      \"sizeInBytes\": 584417280,\n      \"minDeviceMemoryInGb\": 6,\n      \"commitHash\": \"42d538a932e8d5b12e6b3b455f5572560bd60b2c\",\n      \"defaultConfig\": {\n        \"topK\": 64,\n        \"topP\": 0.95,\n        \"temperature\": 1.0,\n        \"maxTokens\": 1024,\n        \"accelerators\": \"gpu,cpu\"\n      },\n      \"taskTypes\": [\"llm_chat\", \"llm_prompt_lab\"],\n      \"bestForTaskTypes\": [\"llm_chat\", \"llm_prompt_lab\"]\n    },\n    {\n      \"name\": \"Qwen2.5-1.5B-Instruct\",\n      \"modelId\": \"litert-community/Qwen2.5-1.5B-Instruct\",\n      \"modelFile\": \"Qwen2.5-1.5B-Instruct_multi-prefill-seq_q8_ekv4096.litertlm\",\n      \"description\": \"A variant of [Qwen/Qwen2.5-1.5B-Instruct](https://huggingface.co/Qwen/Qwen2.5-1.5B-Instruct) ready for deployment on Android using [LiteRT-LM](https://github.com/google-ai-edge/LiteRT-LM/blob/main/kotlin/README.md).\",\n      \"sizeInBytes\": 1597931520,\n      \"minDeviceMemoryInGb\": 6,\n      \"commitHash\": \"19edb84c69a0212f29a6ef17ba0d6f278b6a1614\",\n      \"defaultConfig\": {\n        \"topK\": 20,\n        \"topP\": 0.8,\n        \"temperature\": 0.7,\n        \"maxTokens\": 4096,\n        \"accelerators\": \"gpu,cpu\"\n      },\n      \"taskTypes\": [\"llm_chat\", \"llm_prompt_lab\"]\n    },\n    {\n      \"name\": \"Phi-4-mini-instruct\",\n      \"modelId\": \"litert-community/Phi-4-mini-instruct\",\n      \"modelFile\": \"Phi-4-mini-instruct_multi-prefill-seq_q8_ekv4096.litertlm\",\n      \"description\": \"A variant of [microsoft/Phi-4-mini-instruct](https://huggingface.co/microsoft/Phi-4-mini-instruct) ready for deployment on Android using [LiteRT-LM](https://github.com/google-ai-edge/LiteRT-LM/blob/main/kotlin/README.md).\",\n      \"sizeInBytes\": 3910090752,\n      \"minDeviceMemoryInGb\": 6,\n      \"commitHash\": \"054f4e2694a86f81a129a40596e08b8d74770a9d\",\n      \"defaultConfig\": {\n        \"topK\": 64,\n        \"topP\": 0.95,\n        \"temperature\": 1.0,\n        \"maxTokens\": 4096,\n        \"accelerators\": \"gpu,cpu\"\n      },\n      \"taskTypes\": [\"llm_chat\", \"llm_prompt_lab\"]\n    },\n    {\n      \"name\": \"DeepSeek-R1-Distill-Qwen-1.5B\",\n      \"modelId\": \"litert-community/DeepSeek-R1-Distill-Qwen-1.5B\",\n      \"modelFile\": \"DeepSeek-R1-Distill-Qwen-1.5B_multi-prefill-seq_q8_ekv4096.litertlm\",\n      \"description\": \"A variant of [deepseek-ai/DeepSeek-R1-Distill-Qwen-1.5B](https://huggingface.co/deepseek-ai/DeepSeek-R1-Distill-Qwen-1.5B) ready for deployment on Android using [LiteRT-LM](https://github.com/google-ai-edge/LiteRT-LM/blob/main/kotlin/README.md).\",\n      \"sizeInBytes\": 1833451520,\n      \"minDeviceMemoryInGb\": 6,\n      \"commitHash\": \"e34bb88632342d1f9640bad579a45134eb1cf988\",\n      \"defaultConfig\": {\n        \"topK\": 64,\n        \"topP\": 0.95,\n        \"temperature\": 1.0,\n        \"maxTokens\": 4096,\n        \"accelerators\": \"gpu,cpu\"\n      },\n      \"taskTypes\": [\"llm_chat\", \"llm_prompt_lab\"]\n    },\n    {\n      \"name\": \"TinyGarden-270M\",\n      \"modelId\": \"google/functiongemma-270m-it\",\n      \"modelFile\": \"tiny_garden.litertlm\",\n      \"description\": \"Fine-tuned Function Gemma 270M model for Tiny Garden.\",\n      \"sizeInBytes\": 288440320,\n      \"minDeviceMemoryInGb\": 6,\n      \"commitHash\": \"f54f8715e2b205f72c350f6efa748fd29fa19d98\",\n      \"defaultConfig\": {\n        \"topK\": 64,\n        \"topP\": 0.95,\n        \"temperature\": 0.0,\n        \"maxTokens\": 1024,\n        \"accelerators\": \"cpu\"\n      },\n      \"taskTypes\": [\n        \"llm_tiny_garden\"\n      ],\n      \"bestForTaskTypes\": [\n        \"llm_tiny_garden\"\n      ]\n    }\n  ]\n}\n"
  },
  {
    "path": "model_allowlists/ios_1_0_0.json",
    "content": "{\n  \"models\": [\n    {\n      \"name\": \"Gemma-3n-E2B-it\",\n      \"modelId\": \"google/gemma-3n-E2B-it-litert-lm\",\n      \"modelFile\": \"gemma-3n-E2B-it-int4.litertlm\",\n      \"description\": \"A variant of [Gemma 3n E2B](https://ai.google.dev/gemma/docs/gemma-3n) on iOS. The current checkpoint suppots text, vision, and audio input, with 4096 context length.\",\n      \"sizeInBytes\": 3388604416,\n      \"minDeviceMemoryInGb\": 6,\n      \"commitHash\": \"73b019b63436d346f68dd9c1dbfd117eb264d888\",\n      \"llmSupportImage\": true,\n      \"llmSupportAudio\": true,\n      \"defaultConfig\": {\n        \"topK\": 64,\n        \"topP\": 0.95,\n        \"temperature\": 1.0,\n        \"maxTokens\": 4096,\n        \"accelerators\": \"gpu\"\n      },\n      \"taskTypes\": [\"llm_chat\", \"llm_prompt_lab\", \"llm_ask_image\", \"llm_ask_audio\"],\n      \"bestForTaskTypes\": [\"llm_ask_image\", \"llm_ask_audio\"]\n    },\n    {\n      \"name\": \"Gemma-3n-E4B-it\",\n      \"modelId\": \"google/gemma-3n-E4B-it-litert-lm\",\n      \"modelFile\": \"gemma-3n-E4B-it-int4.litertlm\",\n      \"description\": \"A variant of [Gemma 3n E4B](https://ai.google.dev/gemma/docs/gemma-3n) on iOS. The current checkpoint supports text, vision, and audio input, with 4096 context length.\",\n      \"sizeInBytes\": 4652318720,\n      \"minDeviceMemoryInGb\": 8,\n      \"commitHash\": \"3d0179a0648381585ab337e170b7517aae8e0ce4\",\n      \"llmSupportImage\": true,\n      \"llmSupportAudio\": true,\n      \"defaultConfig\": {\n        \"topK\": 64,\n        \"topP\": 0.95,\n        \"temperature\": 1.0,\n        \"maxTokens\": 4096,\n        \"accelerators\": \"gpu\"\n      },\n      \"taskTypes\": [\"llm_chat\", \"llm_prompt_lab\", \"llm_ask_image\", \"llm_ask_audio\"]\n    },\n    {\n      \"name\": \"Gemma3-1B-IT\",\n      \"modelId\": \"litert-community/Gemma3-1B-IT\",\n      \"modelFile\": \"gemma3-1b-it-int4.litertlm\",\n      \"description\": \"A variant of [google/Gemma-3-1B-IT](https://huggingface.co/google/Gemma-3-1B-IT) with 4-bit quantization ready for deployment on iOS\",\n      \"sizeInBytes\": 584417280,\n      \"minDeviceMemoryInGb\": 4,\n      \"commitHash\": \"42d538a932e8d5b12e6b3b455f5572560bd60b2c\",\n      \"defaultConfig\": {\n        \"topK\": 64,\n        \"topP\": 0.95,\n        \"temperature\": 1.0,\n        \"maxTokens\": 4096,\n        \"accelerators\": \"cpu\"\n      },\n      \"taskTypes\": [\"llm_chat\", \"llm_prompt_lab\"],\n      \"bestForTaskTypes\": [\"llm_chat\", \"llm_prompt_lab\"]\n    }\n  ]\n}\n"
  }
]