From ea31fd05444dd2498be1c4bc08c24b99efa1c2ff Mon Sep 17 00:00:00 2001 From: Jing Jin <8752427+jinjingforever@users.noreply.github.com> Date: Mon, 14 Apr 2025 16:42:40 -0700 Subject: [PATCH] Initial checkin --- .gitignore | 1 + Android/.gitignore | 35 + Android/README.md | 1 + Android/src/.gitignore | 15 + Android/src/app/.gitignore | 1 + Android/src/app/build.gradle.kts | 101 +++ Android/src/app/proguard-rules.pro | 21 + .../aiedge/gallery/ExampleInstrumentedTest.kt | 40 + Android/src/app/src/main/AndroidManifest.xml | 83 +++ .../com/google/aiedge/gallery/GalleryApp.kt | 192 +++++ .../aiedge/gallery/GalleryApplication.kt | 51 ++ .../gallery/GalleryLifecycleProvider.kt | 44 ++ .../com/google/aiedge/gallery/MainActivity.kt | 45 ++ .../java/com/google/aiedge/gallery/Version.kt | 19 + .../aiedge/gallery/data/AppBarAction.kt | 30 + .../aiedge/gallery/data/AppContainer.kt | 47 ++ .../com/google/aiedge/gallery/data/Config.kt | 107 +++ .../com/google/aiedge/gallery/data/Consts.kt | 32 + .../gallery/data/DataStoreRepository.kt | 208 ++++++ .../aiedge/gallery/data/DownloadRepository.kt | 312 ++++++++ .../google/aiedge/gallery/data/HuggingFace.kt | 175 +++++ .../com/google/aiedge/gallery/data/Model.kt | 376 ++++++++++ .../com/google/aiedge/gallery/data/Tasks.kt | 111 +++ .../aiedge/gallery/ui/ViewModelProvider.kt | 70 ++ .../aiedge/gallery/ui/common/AuthConfig.kt | 41 ++ .../gallery/ui/common/DownloadAndTryButton.kt | 334 +++++++++ .../aiedge/gallery/ui/common/TaskIcon.kt | 105 +++ .../google/aiedge/gallery/ui/common/Utils.kt | 442 +++++++++++ .../ui/common/chat/BenchmarkConfigDialog.kt | 102 +++ .../gallery/ui/common/chat/ChatMessage.kt | 180 +++++ .../gallery/ui/common/chat/ChatPanel.kt | 491 ++++++++++++ .../aiedge/gallery/ui/common/chat/ChatView.kt | 306 ++++++++ .../gallery/ui/common/chat/ChatViewModel.kt | 189 +++++ .../gallery/ui/common/chat/ConfigDialog.kt | 313 ++++++++ .../aiedge/gallery/ui/common/chat/DataCard.kt | 93 +++ .../ui/common/chat/LiveCameraDialog.kt | 226 ++++++ .../gallery/ui/common/chat/MarkdownText.kt | 76 ++ .../ui/common/chat/MessageActionButton.kt | 95 +++ .../ui/common/chat/MessageBodyBenchmark.kt | 140 ++++ .../ui/common/chat/MessageBodyBenchmarkLlm.kt | 76 ++ .../common/chat/MessageBodyClassification.kt | 115 +++ .../ui/common/chat/MessageBodyConfigUpdate.kt | 144 ++++ .../ui/common/chat/MessageBodyImage.kt | 43 ++ .../chat/MessageBodyImageWithHistory.kt | 91 +++ .../gallery/ui/common/chat/MessageBodyInfo.kt | 63 ++ .../ui/common/chat/MessageBodyLoading.kt | 142 ++++ .../common/chat/MessageBodyPromptTemplates.kt | 168 +++++ .../gallery/ui/common/chat/MessageBodyText.kt | 80 ++ .../ui/common/chat/MessageBubbleShape.kt | 69 ++ .../ui/common/chat/MessageInputImage.kt | 269 +++++++ .../ui/common/chat/MessageInputText.kt | 268 +++++++ .../gallery/ui/common/chat/MessageLatency.kt | 63 ++ .../gallery/ui/common/chat/MessageSender.kt | 256 +++++++ .../common/chat/ModelDownloadingAnimation.kt | 176 +++++ .../common/chat/ModelInitializationStatus.kt | 89 +++ .../ui/common/chat/ModelNotDownloaded.kt | 54 ++ .../gallery/ui/common/chat/ModelSelector.kt | 170 +++++ .../ui/common/chat/TextInputHistorySheet.kt | 211 ++++++ .../modelitem/AnimatedLayoutModifier.kt | 73 ++ .../modelitem/ConfirmDeleteModelDialog.kt | 52 ++ .../gallery/ui/common/modelitem/ModelItem.kt | 405 ++++++++++ .../common/modelitem/ModelItemActionButton.kt | 133 ++++ .../ui/common/modelitem/ModelNameAndStatus.kt | 187 +++++ .../gallery/ui/common/modelitem/StatusIcon.kt | 94 +++ .../aiedge/gallery/ui/home/HomeScreen.kt | 273 +++++++ .../aiedge/gallery/ui/home/SettingsDialog.kt | 60 ++ .../google/aiedge/gallery/ui/icon/Deploy.kt | 91 +++ .../ImageClassificationModelHelper.kt | 154 ++++ .../ImageClassificationScreen.kt | 97 +++ .../ImageClassificationViewModel.kt | 165 +++++ .../ImageGenerationModelHelper.kt | 77 ++ .../imagegeneration/ImageGenerationScreen.kt | 64 ++ .../ImageGenerationViewModel.kt | 87 +++ .../gallery/ui/llmchat/LlmChatConfigs.kt | 84 +++ .../gallery/ui/llmchat/LlmChatModelHelper.kt | 135 ++++ .../gallery/ui/llmchat/LlmChatScreen.kt | 77 ++ .../gallery/ui/llmchat/LlmChatViewModel.kt | 209 ++++++ .../gallery/ui/modelmanager/ModelList.kt | 98 +++ .../gallery/ui/modelmanager/ModelManager.kt | 130 ++++ .../ui/modelmanager/ModelManagerViewModel.kt | 697 ++++++++++++++++++ .../gallery/ui/navigation/GalleryNavGraph.kt | 265 +++++++ .../gallery/ui/preview/PreviewChatModel.kt | 90 +++ .../ui/preview/PreviewDataStoreRepository.kt | 43 ++ .../ui/preview/PreviewDownloadRepository.kt | 47 ++ .../preview/PreviewModelManagerViewModel.kt | 71 ++ .../aiedge/gallery/ui/preview/PreviewTasks.kt | 96 +++ .../TextClassificationModelHelper.kt | 95 +++ .../TextClassificationScreen.kt | 74 ++ .../TextClassificationViewModel.kt | 128 ++++ .../google/aiedge/gallery/ui/theme/Color.kt | 92 +++ .../google/aiedge/gallery/ui/theme/Theme.kt | 223 ++++++ .../aiedge/gallery/ui/theme/ThemeSettings.kt | 27 + .../google/aiedge/gallery/ui/theme/Type.kt | 91 +++ .../aiedge/gallery/worker/DownloadWorker.kt | 243 ++++++ .../app/src/main/res/drawable/chat_spark.xml | 27 + .../src/app/src/main/res/drawable/circle.xml | 26 + .../src/main/res/drawable/double_circle.xml | 26 + .../app/src/main/res/drawable/four_circle.xml | 26 + .../res/drawable/ic_launcher_background.xml | 186 +++++ .../res/drawable/ic_launcher_foreground.xml | 46 ++ .../app/src/main/res/drawable/image_spark.xml | 27 + .../src/app/src/main/res/drawable/logo.xml | 34 + .../app/src/main/res/drawable/pantegon.xml | 26 + .../app/src/main/res/drawable/text_spark.xml | 27 + .../app/src/main/res/font/nunito_black.ttf | Bin 0 -> 132032 bytes .../src/app/src/main/res/font/nunito_bold.ttf | Bin 0 -> 132152 bytes .../src/main/res/font/nunito_extrabold.ttf | Bin 0 -> 132072 bytes .../src/main/res/font/nunito_extralight.ttf | Bin 0 -> 131992 bytes .../app/src/main/res/font/nunito_light.ttf | Bin 0 -> 132220 bytes .../app/src/main/res/font/nunito_medium.ttf | Bin 0 -> 132304 bytes .../app/src/main/res/font/nunito_regular.ttf | Bin 0 -> 132204 bytes .../app/src/main/res/font/nunito_semibold.ttf | Bin 0 -> 132156 bytes .../res/mipmap-anydpi-v26/ic_launcher.xml | 22 + .../src/main/res/mipmap-hdpi/ic_launcher.png | Bin 0 -> 5530 bytes .../mipmap-hdpi/ic_launcher_background.png | Bin 0 -> 855 bytes .../mipmap-hdpi/ic_launcher_foreground.png | Bin 0 -> 4458 bytes .../mipmap-hdpi/ic_launcher_monochrome.png | Bin 0 -> 4458 bytes .../src/main/res/mipmap-mdpi/ic_launcher.png | Bin 0 -> 3354 bytes .../mipmap-mdpi/ic_launcher_background.png | Bin 0 -> 463 bytes .../mipmap-mdpi/ic_launcher_foreground.png | Bin 0 -> 2922 bytes .../mipmap-mdpi/ic_launcher_monochrome.png | Bin 0 -> 2922 bytes .../src/main/res/mipmap-xhdpi/ic_launcher.png | Bin 0 -> 8056 bytes .../mipmap-xhdpi/ic_launcher_background.png | Bin 0 -> 1320 bytes .../mipmap-xhdpi/ic_launcher_foreground.png | Bin 0 -> 6191 bytes .../mipmap-xhdpi/ic_launcher_monochrome.png | Bin 0 -> 6191 bytes .../main/res/mipmap-xxhdpi/ic_launcher.png | Bin 0 -> 13579 bytes .../mipmap-xxhdpi/ic_launcher_background.png | Bin 0 -> 2953 bytes .../mipmap-xxhdpi/ic_launcher_foreground.png | Bin 0 -> 10537 bytes .../mipmap-xxhdpi/ic_launcher_monochrome.png | Bin 0 -> 10537 bytes .../main/res/mipmap-xxxhdpi/ic_launcher.png | Bin 0 -> 19006 bytes .../mipmap-xxxhdpi/ic_launcher_background.png | Bin 0 -> 4236 bytes .../mipmap-xxxhdpi/ic_launcher_foreground.png | Bin 0 -> 15036 bytes .../mipmap-xxxhdpi/ic_launcher_monochrome.png | Bin 0 -> 15036 bytes .../src/app/src/main/res/values/dimens.xml | 22 + .../res/values/ic_launcher_background.xml | 20 + .../src/app/src/main/res/values/strings.xml | 43 ++ .../src/app/src/main/res/values/themes.xml | 24 + .../src/app/src/main/res/xml/backup_rules.xml | 27 + .../main/res/xml/data_extraction_rules.xml | 33 + .../src/app/src/main/res/xml/file_paths.xml | 22 + .../google/aiedge/gallery/ExampleUnitTest.kt | 33 + Android/src/build.gradle.kts | 22 + Android/src/gradle.properties | 23 + Android/src/gradle/libs.versions.toml | 73 ++ Android/src/gradle/wrapper/gradle-wrapper.jar | Bin 0 -> 59203 bytes .../gradle/wrapper/gradle-wrapper.properties | 6 + Android/src/gradlew | 185 +++++ Android/src/gradlew.bat | 89 +++ Android/src/settings.gradle.kts | 40 + CONTRIBUTING.md | 4 + LICENSE | 201 +++++ README.md | 8 +- 152 files changed, 14171 insertions(+), 1 deletion(-) create mode 100644 .gitignore create mode 100644 Android/.gitignore create mode 100644 Android/README.md create mode 100644 Android/src/.gitignore create mode 100644 Android/src/app/.gitignore create mode 100644 Android/src/app/build.gradle.kts create mode 100644 Android/src/app/proguard-rules.pro create mode 100644 Android/src/app/src/androidTest/java/com/google/aiedge/gallery/ExampleInstrumentedTest.kt create mode 100644 Android/src/app/src/main/AndroidManifest.xml create mode 100644 Android/src/app/src/main/java/com/google/aiedge/gallery/GalleryApp.kt create mode 100644 Android/src/app/src/main/java/com/google/aiedge/gallery/GalleryApplication.kt create mode 100644 Android/src/app/src/main/java/com/google/aiedge/gallery/GalleryLifecycleProvider.kt create mode 100644 Android/src/app/src/main/java/com/google/aiedge/gallery/MainActivity.kt create mode 100644 Android/src/app/src/main/java/com/google/aiedge/gallery/Version.kt create mode 100644 Android/src/app/src/main/java/com/google/aiedge/gallery/data/AppBarAction.kt create mode 100644 Android/src/app/src/main/java/com/google/aiedge/gallery/data/AppContainer.kt create mode 100644 Android/src/app/src/main/java/com/google/aiedge/gallery/data/Config.kt create mode 100644 Android/src/app/src/main/java/com/google/aiedge/gallery/data/Consts.kt create mode 100644 Android/src/app/src/main/java/com/google/aiedge/gallery/data/DataStoreRepository.kt create mode 100644 Android/src/app/src/main/java/com/google/aiedge/gallery/data/DownloadRepository.kt create mode 100644 Android/src/app/src/main/java/com/google/aiedge/gallery/data/HuggingFace.kt create mode 100644 Android/src/app/src/main/java/com/google/aiedge/gallery/data/Model.kt create mode 100644 Android/src/app/src/main/java/com/google/aiedge/gallery/data/Tasks.kt create mode 100644 Android/src/app/src/main/java/com/google/aiedge/gallery/ui/ViewModelProvider.kt create mode 100644 Android/src/app/src/main/java/com/google/aiedge/gallery/ui/common/AuthConfig.kt create mode 100644 Android/src/app/src/main/java/com/google/aiedge/gallery/ui/common/DownloadAndTryButton.kt create mode 100644 Android/src/app/src/main/java/com/google/aiedge/gallery/ui/common/TaskIcon.kt create mode 100644 Android/src/app/src/main/java/com/google/aiedge/gallery/ui/common/Utils.kt create mode 100644 Android/src/app/src/main/java/com/google/aiedge/gallery/ui/common/chat/BenchmarkConfigDialog.kt create mode 100644 Android/src/app/src/main/java/com/google/aiedge/gallery/ui/common/chat/ChatMessage.kt create mode 100644 Android/src/app/src/main/java/com/google/aiedge/gallery/ui/common/chat/ChatPanel.kt create mode 100644 Android/src/app/src/main/java/com/google/aiedge/gallery/ui/common/chat/ChatView.kt create mode 100644 Android/src/app/src/main/java/com/google/aiedge/gallery/ui/common/chat/ChatViewModel.kt create mode 100644 Android/src/app/src/main/java/com/google/aiedge/gallery/ui/common/chat/ConfigDialog.kt create mode 100644 Android/src/app/src/main/java/com/google/aiedge/gallery/ui/common/chat/DataCard.kt create mode 100644 Android/src/app/src/main/java/com/google/aiedge/gallery/ui/common/chat/LiveCameraDialog.kt create mode 100644 Android/src/app/src/main/java/com/google/aiedge/gallery/ui/common/chat/MarkdownText.kt create mode 100644 Android/src/app/src/main/java/com/google/aiedge/gallery/ui/common/chat/MessageActionButton.kt create mode 100644 Android/src/app/src/main/java/com/google/aiedge/gallery/ui/common/chat/MessageBodyBenchmark.kt create mode 100644 Android/src/app/src/main/java/com/google/aiedge/gallery/ui/common/chat/MessageBodyBenchmarkLlm.kt create mode 100644 Android/src/app/src/main/java/com/google/aiedge/gallery/ui/common/chat/MessageBodyClassification.kt create mode 100644 Android/src/app/src/main/java/com/google/aiedge/gallery/ui/common/chat/MessageBodyConfigUpdate.kt create mode 100644 Android/src/app/src/main/java/com/google/aiedge/gallery/ui/common/chat/MessageBodyImage.kt create mode 100644 Android/src/app/src/main/java/com/google/aiedge/gallery/ui/common/chat/MessageBodyImageWithHistory.kt create mode 100644 Android/src/app/src/main/java/com/google/aiedge/gallery/ui/common/chat/MessageBodyInfo.kt create mode 100644 Android/src/app/src/main/java/com/google/aiedge/gallery/ui/common/chat/MessageBodyLoading.kt create mode 100644 Android/src/app/src/main/java/com/google/aiedge/gallery/ui/common/chat/MessageBodyPromptTemplates.kt create mode 100644 Android/src/app/src/main/java/com/google/aiedge/gallery/ui/common/chat/MessageBodyText.kt create mode 100644 Android/src/app/src/main/java/com/google/aiedge/gallery/ui/common/chat/MessageBubbleShape.kt create mode 100644 Android/src/app/src/main/java/com/google/aiedge/gallery/ui/common/chat/MessageInputImage.kt create mode 100644 Android/src/app/src/main/java/com/google/aiedge/gallery/ui/common/chat/MessageInputText.kt create mode 100644 Android/src/app/src/main/java/com/google/aiedge/gallery/ui/common/chat/MessageLatency.kt create mode 100644 Android/src/app/src/main/java/com/google/aiedge/gallery/ui/common/chat/MessageSender.kt create mode 100644 Android/src/app/src/main/java/com/google/aiedge/gallery/ui/common/chat/ModelDownloadingAnimation.kt create mode 100644 Android/src/app/src/main/java/com/google/aiedge/gallery/ui/common/chat/ModelInitializationStatus.kt create mode 100644 Android/src/app/src/main/java/com/google/aiedge/gallery/ui/common/chat/ModelNotDownloaded.kt create mode 100644 Android/src/app/src/main/java/com/google/aiedge/gallery/ui/common/chat/ModelSelector.kt create mode 100644 Android/src/app/src/main/java/com/google/aiedge/gallery/ui/common/chat/TextInputHistorySheet.kt create mode 100644 Android/src/app/src/main/java/com/google/aiedge/gallery/ui/common/modelitem/AnimatedLayoutModifier.kt create mode 100644 Android/src/app/src/main/java/com/google/aiedge/gallery/ui/common/modelitem/ConfirmDeleteModelDialog.kt create mode 100644 Android/src/app/src/main/java/com/google/aiedge/gallery/ui/common/modelitem/ModelItem.kt create mode 100644 Android/src/app/src/main/java/com/google/aiedge/gallery/ui/common/modelitem/ModelItemActionButton.kt create mode 100644 Android/src/app/src/main/java/com/google/aiedge/gallery/ui/common/modelitem/ModelNameAndStatus.kt create mode 100644 Android/src/app/src/main/java/com/google/aiedge/gallery/ui/common/modelitem/StatusIcon.kt create mode 100644 Android/src/app/src/main/java/com/google/aiedge/gallery/ui/home/HomeScreen.kt create mode 100644 Android/src/app/src/main/java/com/google/aiedge/gallery/ui/home/SettingsDialog.kt create mode 100644 Android/src/app/src/main/java/com/google/aiedge/gallery/ui/icon/Deploy.kt create mode 100644 Android/src/app/src/main/java/com/google/aiedge/gallery/ui/imageclassification/ImageClassificationModelHelper.kt create mode 100644 Android/src/app/src/main/java/com/google/aiedge/gallery/ui/imageclassification/ImageClassificationScreen.kt create mode 100644 Android/src/app/src/main/java/com/google/aiedge/gallery/ui/imageclassification/ImageClassificationViewModel.kt create mode 100644 Android/src/app/src/main/java/com/google/aiedge/gallery/ui/imagegeneration/ImageGenerationModelHelper.kt create mode 100644 Android/src/app/src/main/java/com/google/aiedge/gallery/ui/imagegeneration/ImageGenerationScreen.kt create mode 100644 Android/src/app/src/main/java/com/google/aiedge/gallery/ui/imagegeneration/ImageGenerationViewModel.kt create mode 100644 Android/src/app/src/main/java/com/google/aiedge/gallery/ui/llmchat/LlmChatConfigs.kt create mode 100644 Android/src/app/src/main/java/com/google/aiedge/gallery/ui/llmchat/LlmChatModelHelper.kt create mode 100644 Android/src/app/src/main/java/com/google/aiedge/gallery/ui/llmchat/LlmChatScreen.kt create mode 100644 Android/src/app/src/main/java/com/google/aiedge/gallery/ui/llmchat/LlmChatViewModel.kt create mode 100644 Android/src/app/src/main/java/com/google/aiedge/gallery/ui/modelmanager/ModelList.kt create mode 100644 Android/src/app/src/main/java/com/google/aiedge/gallery/ui/modelmanager/ModelManager.kt create mode 100644 Android/src/app/src/main/java/com/google/aiedge/gallery/ui/modelmanager/ModelManagerViewModel.kt create mode 100644 Android/src/app/src/main/java/com/google/aiedge/gallery/ui/navigation/GalleryNavGraph.kt create mode 100644 Android/src/app/src/main/java/com/google/aiedge/gallery/ui/preview/PreviewChatModel.kt create mode 100644 Android/src/app/src/main/java/com/google/aiedge/gallery/ui/preview/PreviewDataStoreRepository.kt create mode 100644 Android/src/app/src/main/java/com/google/aiedge/gallery/ui/preview/PreviewDownloadRepository.kt create mode 100644 Android/src/app/src/main/java/com/google/aiedge/gallery/ui/preview/PreviewModelManagerViewModel.kt create mode 100644 Android/src/app/src/main/java/com/google/aiedge/gallery/ui/preview/PreviewTasks.kt create mode 100644 Android/src/app/src/main/java/com/google/aiedge/gallery/ui/textclassification/TextClassificationModelHelper.kt create mode 100644 Android/src/app/src/main/java/com/google/aiedge/gallery/ui/textclassification/TextClassificationScreen.kt create mode 100644 Android/src/app/src/main/java/com/google/aiedge/gallery/ui/textclassification/TextClassificationViewModel.kt create mode 100644 Android/src/app/src/main/java/com/google/aiedge/gallery/ui/theme/Color.kt create mode 100644 Android/src/app/src/main/java/com/google/aiedge/gallery/ui/theme/Theme.kt create mode 100644 Android/src/app/src/main/java/com/google/aiedge/gallery/ui/theme/ThemeSettings.kt create mode 100644 Android/src/app/src/main/java/com/google/aiedge/gallery/ui/theme/Type.kt create mode 100644 Android/src/app/src/main/java/com/google/aiedge/gallery/worker/DownloadWorker.kt create mode 100644 Android/src/app/src/main/res/drawable/chat_spark.xml create mode 100644 Android/src/app/src/main/res/drawable/circle.xml create mode 100644 Android/src/app/src/main/res/drawable/double_circle.xml create mode 100644 Android/src/app/src/main/res/drawable/four_circle.xml create mode 100644 Android/src/app/src/main/res/drawable/ic_launcher_background.xml create mode 100644 Android/src/app/src/main/res/drawable/ic_launcher_foreground.xml create mode 100644 Android/src/app/src/main/res/drawable/image_spark.xml create mode 100644 Android/src/app/src/main/res/drawable/logo.xml create mode 100644 Android/src/app/src/main/res/drawable/pantegon.xml create mode 100644 Android/src/app/src/main/res/drawable/text_spark.xml create mode 100644 Android/src/app/src/main/res/font/nunito_black.ttf create mode 100644 Android/src/app/src/main/res/font/nunito_bold.ttf create mode 100644 Android/src/app/src/main/res/font/nunito_extrabold.ttf create mode 100644 Android/src/app/src/main/res/font/nunito_extralight.ttf create mode 100644 Android/src/app/src/main/res/font/nunito_light.ttf create mode 100644 Android/src/app/src/main/res/font/nunito_medium.ttf create mode 100644 Android/src/app/src/main/res/font/nunito_regular.ttf create mode 100644 Android/src/app/src/main/res/font/nunito_semibold.ttf create mode 100644 Android/src/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml create mode 100644 Android/src/app/src/main/res/mipmap-hdpi/ic_launcher.png create mode 100644 Android/src/app/src/main/res/mipmap-hdpi/ic_launcher_background.png create mode 100644 Android/src/app/src/main/res/mipmap-hdpi/ic_launcher_foreground.png create mode 100644 Android/src/app/src/main/res/mipmap-hdpi/ic_launcher_monochrome.png create mode 100644 Android/src/app/src/main/res/mipmap-mdpi/ic_launcher.png create mode 100644 Android/src/app/src/main/res/mipmap-mdpi/ic_launcher_background.png create mode 100644 Android/src/app/src/main/res/mipmap-mdpi/ic_launcher_foreground.png create mode 100644 Android/src/app/src/main/res/mipmap-mdpi/ic_launcher_monochrome.png create mode 100644 Android/src/app/src/main/res/mipmap-xhdpi/ic_launcher.png create mode 100644 Android/src/app/src/main/res/mipmap-xhdpi/ic_launcher_background.png create mode 100644 Android/src/app/src/main/res/mipmap-xhdpi/ic_launcher_foreground.png create mode 100644 Android/src/app/src/main/res/mipmap-xhdpi/ic_launcher_monochrome.png create mode 100644 Android/src/app/src/main/res/mipmap-xxhdpi/ic_launcher.png create mode 100644 Android/src/app/src/main/res/mipmap-xxhdpi/ic_launcher_background.png create mode 100644 Android/src/app/src/main/res/mipmap-xxhdpi/ic_launcher_foreground.png create mode 100644 Android/src/app/src/main/res/mipmap-xxhdpi/ic_launcher_monochrome.png create mode 100644 Android/src/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png create mode 100644 Android/src/app/src/main/res/mipmap-xxxhdpi/ic_launcher_background.png create mode 100644 Android/src/app/src/main/res/mipmap-xxxhdpi/ic_launcher_foreground.png create mode 100644 Android/src/app/src/main/res/mipmap-xxxhdpi/ic_launcher_monochrome.png create mode 100644 Android/src/app/src/main/res/values/dimens.xml create mode 100644 Android/src/app/src/main/res/values/ic_launcher_background.xml create mode 100644 Android/src/app/src/main/res/values/strings.xml create mode 100644 Android/src/app/src/main/res/values/themes.xml create mode 100644 Android/src/app/src/main/res/xml/backup_rules.xml create mode 100644 Android/src/app/src/main/res/xml/data_extraction_rules.xml create mode 100644 Android/src/app/src/main/res/xml/file_paths.xml create mode 100644 Android/src/app/src/test/java/com/google/aiedge/gallery/ExampleUnitTest.kt create mode 100644 Android/src/build.gradle.kts create mode 100644 Android/src/gradle.properties create mode 100644 Android/src/gradle/libs.versions.toml create mode 100644 Android/src/gradle/wrapper/gradle-wrapper.jar create mode 100644 Android/src/gradle/wrapper/gradle-wrapper.properties create mode 100755 Android/src/gradlew create mode 100644 Android/src/gradlew.bat create mode 100644 Android/src/settings.gradle.kts create mode 100644 CONTRIBUTING.md create mode 100644 LICENSE diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..e43b0f9 --- /dev/null +++ b/.gitignore @@ -0,0 +1 @@ +.DS_Store diff --git a/Android/.gitignore b/Android/.gitignore new file mode 100644 index 0000000..1ce5acb --- /dev/null +++ b/Android/.gitignore @@ -0,0 +1,35 @@ +# Gradle files +.gradle/ +build/ + +# Local configuration file (sdk path, etc) +local.properties + +# Log/OS Files +*.log + +# Android Studio generated files and folders +captures/ +.externalNativeBuild/ +.cxx/ +*.apk +output.json + +# IntelliJ +*.iml +.idea/ +misc.xml +deploymentTargetDropDown.xml +render.experimental.xml + +# Keystore files +*.jks +*.keystore + +# Google Services (e.g. APIs or Firebase) +google-services.json + +# Android Profiling +*.hprof + +.DS_Store diff --git a/Android/README.md b/Android/README.md new file mode 100644 index 0000000..30c24d9 --- /dev/null +++ b/Android/README.md @@ -0,0 +1 @@ +# AI Edge Gallery (Android) diff --git a/Android/src/.gitignore b/Android/src/.gitignore new file mode 100644 index 0000000..aa724b7 --- /dev/null +++ b/Android/src/.gitignore @@ -0,0 +1,15 @@ +*.iml +.gradle +/local.properties +/.idea/caches +/.idea/libraries +/.idea/modules.xml +/.idea/workspace.xml +/.idea/navEditor.xml +/.idea/assetWizardSettings.xml +.DS_Store +/build +/captures +.externalNativeBuild +.cxx +local.properties diff --git a/Android/src/app/.gitignore b/Android/src/app/.gitignore new file mode 100644 index 0000000..42afabf --- /dev/null +++ b/Android/src/app/.gitignore @@ -0,0 +1 @@ +/build \ No newline at end of file diff --git a/Android/src/app/build.gradle.kts b/Android/src/app/build.gradle.kts new file mode 100644 index 0000000..d06b8d7 --- /dev/null +++ b/Android/src/app/build.gradle.kts @@ -0,0 +1,101 @@ +/* + * Copyright 2025 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +plugins { + alias(libs.plugins.android.application) + alias(libs.plugins.kotlin.android) + alias(libs.plugins.kotlin.compose) + alias(libs.plugins.kotlin.serialization) +} + +android { + namespace = "com.google.aiedge.gallery" + compileSdk = 35 + + defaultConfig { + applicationId = "com.google.aiedge.gallery" + minSdk = 24 + targetSdk = 35 + versionCode = 1 + versionName = "1.0" + + // Needed for HuggingFace auth workflows. + manifestPlaceholders["appAuthRedirectScheme"] = "com.google.aiedge.gallery.oauth" + + testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" + } + + buildTypes { + release { + isMinifyEnabled = false + proguardFiles( + getDefaultProguardFile("proguard-android-optimize.txt"), + "proguard-rules.pro" + ) + signingConfig = signingConfigs.getByName("debug") + } + } + compileOptions { + sourceCompatibility = JavaVersion.VERSION_11 + targetCompatibility = JavaVersion.VERSION_11 + } + kotlinOptions { + jvmTarget = "11" + freeCompilerArgs += "-Xcontext-receivers" + } + buildFeatures { + compose = true + } +} + +dependencies { + implementation(libs.androidx.core.ktx) + implementation(libs.androidx.lifecycle.runtime.ktx) + implementation(libs.androidx.activity.compose) + implementation(platform(libs.androidx.compose.bom)) + implementation(libs.androidx.ui) + implementation(libs.androidx.ui.graphics) + implementation(libs.androidx.ui.tooling.preview) + implementation(libs.androidx.material3) + implementation(libs.androidx.compose.navigation) + implementation(libs.kotlinx.serialization.json) + implementation(libs.material.icon.extended) + implementation(libs.androidx.work.runtime) + implementation(libs.androidx.datastore.preferences) + implementation(libs.com.google.code.gson) + implementation(libs.androidx.lifecycle.process) + implementation(libs.mediapipe.tasks.text) + implementation(libs.mediapipe.tasks.genai) + implementation(libs.mediapipe.tasks.imagegen) + implementation(libs.commonmark) + implementation(libs.richtext) + implementation(libs.tflite) + implementation(libs.tflite.gpu) + implementation(libs.tflite.support) + implementation(libs.camerax.core) + implementation(libs.camerax.camera2) + implementation(libs.camerax.lifecycle) + implementation(libs.camerax.view) + implementation(libs.openid.appauth) + implementation(libs.androidx.splashscreen) + testImplementation(libs.junit) + androidTestImplementation(libs.androidx.junit) + androidTestImplementation(libs.androidx.espresso.core) + androidTestImplementation(platform(libs.androidx.compose.bom)) + androidTestImplementation(libs.androidx.ui.test.junit4) + debugImplementation(libs.androidx.ui.tooling) + debugImplementation(libs.androidx.ui.test.manifest) +} \ No newline at end of file diff --git a/Android/src/app/proguard-rules.pro b/Android/src/app/proguard-rules.pro new file mode 100644 index 0000000..481bb43 --- /dev/null +++ b/Android/src/app/proguard-rules.pro @@ -0,0 +1,21 @@ +# Add project specific ProGuard rules here. +# You can control the set of applied configuration files using the +# proguardFiles setting in build.gradle. +# +# For more details, see +# http://developer.android.com/guide/developing/tools/proguard.html + +# If your project uses WebView with JS, uncomment the following +# and specify the fully qualified class name to the JavaScript interface +# class: +#-keepclassmembers class fqcn.of.javascript.interface.for.webview { +# public *; +#} + +# Uncomment this to preserve the line number information for +# debugging stack traces. +#-keepattributes SourceFile,LineNumberTable + +# If you keep the line number information, uncomment this to +# hide the original source file name. +#-renamesourcefileattribute SourceFile \ No newline at end of file diff --git a/Android/src/app/src/androidTest/java/com/google/aiedge/gallery/ExampleInstrumentedTest.kt b/Android/src/app/src/androidTest/java/com/google/aiedge/gallery/ExampleInstrumentedTest.kt new file mode 100644 index 0000000..f26352a --- /dev/null +++ b/Android/src/app/src/androidTest/java/com/google/aiedge/gallery/ExampleInstrumentedTest.kt @@ -0,0 +1,40 @@ +/* + * Copyright 2025 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.google.aiedge.gallery + +import androidx.test.platform.app.InstrumentationRegistry +import androidx.test.ext.junit.runners.AndroidJUnit4 + +import org.junit.Test +import org.junit.runner.RunWith + +import org.junit.Assert.* + +/** + * Instrumented test, which will execute on an Android device. + * + * See [testing documentation](http://d.android.com/tools/testing). + */ +@RunWith(AndroidJUnit4::class) +class ExampleInstrumentedTest { + @Test + fun useAppContext() { + // Context of the app under test. + val appContext = InstrumentationRegistry.getInstrumentation().targetContext + assertEquals("com.google.aiedge.gallery", appContext.packageName) + } +} \ No newline at end of file diff --git a/Android/src/app/src/main/AndroidManifest.xml b/Android/src/app/src/main/AndroidManifest.xml new file mode 100644 index 0000000..6c366fb --- /dev/null +++ b/Android/src/app/src/main/AndroidManifest.xml @@ -0,0 +1,83 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/Android/src/app/src/main/java/com/google/aiedge/gallery/GalleryApp.kt b/Android/src/app/src/main/java/com/google/aiedge/gallery/GalleryApp.kt new file mode 100644 index 0000000..9c01f75 --- /dev/null +++ b/Android/src/app/src/main/java/com/google/aiedge/gallery/GalleryApp.kt @@ -0,0 +1,192 @@ +/* + * Copyright 2025 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +@file:OptIn(ExperimentalMaterial3Api::class) + +package com.google.aiedge.gallery + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.rounded.ArrowBack +import androidx.compose.material.icons.rounded.Refresh +import androidx.compose.material.icons.rounded.Settings +import androidx.compose.material3.CenterAlignedTopAppBar +import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.material3.TopAppBarScrollBehavior +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.dp +import androidx.navigation.NavHostController +import androidx.navigation.compose.rememberNavController +import com.google.aiedge.gallery.data.AppBarAction +import com.google.aiedge.gallery.data.AppBarActionType +import com.google.aiedge.gallery.ui.navigation.GalleryNavHost + +/** + * Top level composable representing the main screen of the application. + */ +@Composable +fun GalleryApp(navController: NavHostController = rememberNavController()) { + GalleryNavHost(navController = navController) +} + +/** + * The top app bar. + */ +@Composable +fun GalleryTopAppBar( + title: String, + modifier: Modifier = Modifier, + leftAction: AppBarAction? = null, + rightAction: AppBarAction? = null, + scrollBehavior: TopAppBarScrollBehavior? = null, + loadingHfModels: Boolean = false, + subtitle: String = "", +) { + CenterAlignedTopAppBar( + title = { + Column(horizontalAlignment = Alignment.CenterHorizontally) { + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(12.dp) + ) { + if (title == stringResource(R.string.app_name)) { + Icon( + painterResource(R.drawable.logo), + modifier = Modifier.size(20.dp), + contentDescription = "", + tint = Color.Unspecified, + ) + } + Text( + title, + style = MaterialTheme.typography.titleLarge.copy(fontWeight = FontWeight.SemiBold) + ) + } + if (subtitle.isNotEmpty()) { + Text( + subtitle, + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.secondary + ) + } + } + }, + modifier = modifier, + scrollBehavior = scrollBehavior, + // The button at the left. + navigationIcon = { + when (leftAction?.actionType) { + AppBarActionType.NAVIGATE_UP -> { + IconButton(onClick = leftAction.actionFn) { + Icon( + imageVector = Icons.AutoMirrored.Rounded.ArrowBack, + contentDescription = "", + ) + } + } + + AppBarActionType.REFRESH_MODELS -> { + IconButton(onClick = leftAction.actionFn) { + Icon( + imageVector = Icons.Rounded.Refresh, + contentDescription = "", + tint = MaterialTheme.colorScheme.secondary + ) + } + } + + AppBarActionType.REFRESHING_MODELS -> { + CircularProgressIndicator( + trackColor = MaterialTheme.colorScheme.surfaceContainerHighest, + strokeWidth = 3.dp, + modifier = Modifier + .padding(start = 16.dp) + .size(20.dp) + ) + } + + else -> {} + } + }, + // The "action" component at the right. + actions = { + when (rightAction?.actionType) { + // Click an icon to open "app setting". + AppBarActionType.APP_SETTING -> { + IconButton(onClick = rightAction.actionFn) { + Icon( + imageVector = Icons.Rounded.Settings, + contentDescription = "", + tint = MaterialTheme.colorScheme.primary + ) + } + } + + // Click an icon to open "download manager". + AppBarActionType.DOWNLOAD_MANAGER -> { + if (loadingHfModels) { + CircularProgressIndicator( + trackColor = MaterialTheme.colorScheme.surfaceContainerHighest, + strokeWidth = 3.dp, + modifier = Modifier + .padding(end = 12.dp) + .size(20.dp) + ) + } +// else { +// IconButton(onClick = rightAction.actionFn) { +// Icon( +// imageVector = Deployed_code, +// contentDescription = "", +// tint = MaterialTheme.colorScheme.primary +// ) +// } +// } + } + + AppBarActionType.MODEL_SELECTOR -> { + Text("ms") + } + + // Click a button to navigate up. + AppBarActionType.NAVIGATE_UP -> { + TextButton(onClick = rightAction.actionFn) { + Text("Done") + } + } + + else -> {} + } + } + + ) +} \ No newline at end of file diff --git a/Android/src/app/src/main/java/com/google/aiedge/gallery/GalleryApplication.kt b/Android/src/app/src/main/java/com/google/aiedge/gallery/GalleryApplication.kt new file mode 100644 index 0000000..f460d80 --- /dev/null +++ b/Android/src/app/src/main/java/com/google/aiedge/gallery/GalleryApplication.kt @@ -0,0 +1,51 @@ +/* + * Copyright 2025 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.google.aiedge.gallery + +import android.app.Application +import android.content.Context +import androidx.datastore.core.DataStore +import androidx.datastore.preferences.core.Preferences +import androidx.datastore.preferences.preferencesDataStore +import com.google.aiedge.gallery.data.AppContainer +import com.google.aiedge.gallery.data.DefaultAppContainer +import com.google.aiedge.gallery.data.TASKS +import com.google.aiedge.gallery.ui.theme.ThemeSettings + +private val Context.dataStore: DataStore by preferencesDataStore(name = "app_gallery_preferences") + +class GalleryApplication : Application() { + /** AppContainer instance used by the rest of classes to obtain dependencies */ + lateinit var container: AppContainer + + override fun onCreate() { + super.onCreate() + + // Process tasks. + for ((index, task) in TASKS.withIndex()) { + task.index = index + for (model in task.models) { + model.preProcess(task = task) + } + } + + container = DefaultAppContainer(this, dataStore) + + // Load theme. + ThemeSettings.themeOverride.value = container.dataStoreRepository.readThemeOverride() + } +} \ No newline at end of file diff --git a/Android/src/app/src/main/java/com/google/aiedge/gallery/GalleryLifecycleProvider.kt b/Android/src/app/src/main/java/com/google/aiedge/gallery/GalleryLifecycleProvider.kt new file mode 100644 index 0000000..fde845b --- /dev/null +++ b/Android/src/app/src/main/java/com/google/aiedge/gallery/GalleryLifecycleProvider.kt @@ -0,0 +1,44 @@ +/* + * Copyright 2025 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.google.aiedge.gallery + +import androidx.lifecycle.DefaultLifecycleObserver +import androidx.lifecycle.LifecycleOwner +import androidx.lifecycle.ProcessLifecycleOwner + +interface AppLifecycleProvider { + val isAppInForeground: Boolean +} + +class GalleryLifecycleProvider : AppLifecycleProvider, DefaultLifecycleObserver { + private var _isAppInForeground = false + + init { + ProcessLifecycleOwner.get().lifecycle.addObserver(this) + } + + override val isAppInForeground: Boolean + get() = _isAppInForeground + + override fun onResume(owner: LifecycleOwner) { + _isAppInForeground = true + } + + override fun onPause(owner: LifecycleOwner) { + _isAppInForeground = false + } +} diff --git a/Android/src/app/src/main/java/com/google/aiedge/gallery/MainActivity.kt b/Android/src/app/src/main/java/com/google/aiedge/gallery/MainActivity.kt new file mode 100644 index 0000000..5512a8e --- /dev/null +++ b/Android/src/app/src/main/java/com/google/aiedge/gallery/MainActivity.kt @@ -0,0 +1,45 @@ +/* + * Copyright 2025 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.google.aiedge.gallery + +import android.os.Bundle +import androidx.activity.ComponentActivity +import androidx.activity.compose.setContent +import androidx.activity.enableEdgeToEdge +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.material3.Surface +import androidx.compose.ui.Modifier +import androidx.core.splashscreen.SplashScreen.Companion.installSplashScreen +import com.google.aiedge.gallery.ui.theme.GalleryTheme + +class MainActivity : ComponentActivity() { + override fun onCreate(savedInstanceState: Bundle?) { + installSplashScreen() + + super.onCreate(savedInstanceState) + enableEdgeToEdge() + setContent { + GalleryTheme { + Surface( + modifier = Modifier.fillMaxSize() + ) { + GalleryApp() + } + } + } + } +} diff --git a/Android/src/app/src/main/java/com/google/aiedge/gallery/Version.kt b/Android/src/app/src/main/java/com/google/aiedge/gallery/Version.kt new file mode 100644 index 0000000..7924e7a --- /dev/null +++ b/Android/src/app/src/main/java/com/google/aiedge/gallery/Version.kt @@ -0,0 +1,19 @@ +/* + * Copyright 2025 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.google.aiedge.gallery + +const val VERSION = "20250413" \ No newline at end of file diff --git a/Android/src/app/src/main/java/com/google/aiedge/gallery/data/AppBarAction.kt b/Android/src/app/src/main/java/com/google/aiedge/gallery/data/AppBarAction.kt new file mode 100644 index 0000000..6a6dbcf --- /dev/null +++ b/Android/src/app/src/main/java/com/google/aiedge/gallery/data/AppBarAction.kt @@ -0,0 +1,30 @@ +/* + * Copyright 2025 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.google.aiedge.gallery.data + +/** Possible action for app bar. */ +enum class AppBarActionType { + NO_ACTION, + APP_SETTING, + DOWNLOAD_MANAGER, + MODEL_SELECTOR, + NAVIGATE_UP, + REFRESH_MODELS, + REFRESHING_MODELS, +} + +class AppBarAction(val actionType: AppBarActionType, val actionFn: () -> Unit) \ No newline at end of file diff --git a/Android/src/app/src/main/java/com/google/aiedge/gallery/data/AppContainer.kt b/Android/src/app/src/main/java/com/google/aiedge/gallery/data/AppContainer.kt new file mode 100644 index 0000000..c95d079 --- /dev/null +++ b/Android/src/app/src/main/java/com/google/aiedge/gallery/data/AppContainer.kt @@ -0,0 +1,47 @@ +/* + * Copyright 2025 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.google.aiedge.gallery.data + +import android.content.Context +import androidx.datastore.core.DataStore +import androidx.datastore.preferences.core.Preferences +import com.google.aiedge.gallery.GalleryLifecycleProvider +import com.google.aiedge.gallery.AppLifecycleProvider + +/** + * App container for Dependency injection. + * + * This interface defines the dependencies required by the application. + */ +interface AppContainer { + val context: Context + val lifecycleProvider: AppLifecycleProvider + val dataStoreRepository: DataStoreRepository + val downloadRepository: DownloadRepository +} + +/** + * Default implementation of the AppContainer interface. + * + * This class provides concrete implementations for the application's dependencies, + */ +class DefaultAppContainer(ctx: Context, dataStore: DataStore) : AppContainer { + override val context = ctx + override val lifecycleProvider = GalleryLifecycleProvider() + override val dataStoreRepository = DefaultDataStoreRepository(dataStore) + override val downloadRepository = DefaultDownloadRepository(ctx, lifecycleProvider) +} \ No newline at end of file diff --git a/Android/src/app/src/main/java/com/google/aiedge/gallery/data/Config.kt b/Android/src/app/src/main/java/com/google/aiedge/gallery/data/Config.kt new file mode 100644 index 0000000..73814e2 --- /dev/null +++ b/Android/src/app/src/main/java/com/google/aiedge/gallery/data/Config.kt @@ -0,0 +1,107 @@ +/* + * Copyright 2025 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.google.aiedge.gallery.data + +/** + * The types of configuration editors available. + * + * This enum defines the different UI components used to edit configuration values. + * Each type corresponds to a specific editor widget, such as a slider or a switch. + */ +enum class ConfigEditorType { + NUMBER_SLIDER, + BOOLEAN_SWITCH, + DROPDOWN, +} + +/** + * The data types of configuration values. + */ +enum class ValueType { + INT, + FLOAT, + DOUBLE, + STRING, + BOOLEAN, +} + +/** + * Base class for configuration settings. + * + * @param type The type of configuration editor. + * @param key The unique key for the configuration setting. + * @param defaultValue The default value for the configuration setting. + * @param valueType The data type of the configuration value. + * @param needReinitialization Indicates whether the model needs to be reinitialized after changing + * this config. + */ +open class Config( + val type: ConfigEditorType, + open val key: ConfigKey, + open val defaultValue: Any, + open val valueType: ValueType, + open val needReinitialization: Boolean = true, +) + +/** + * Configuration setting for a number slider. + * + * @param sliderMin The minimum value of the slider. + * @param sliderMax The maximum value of the slider. + */ +class NumberSliderConfig( + override val key: ConfigKey, + val sliderMin: Float, + val sliderMax: Float, + override val defaultValue: Float, + override val valueType: ValueType, + override val needReinitialization: Boolean = true, +) : + Config( + type = ConfigEditorType.NUMBER_SLIDER, + key = key, + defaultValue = defaultValue, + valueType = valueType + ) + +/** + * Configuration setting for a boolean switch. + */ +class BooleanSwitchConfig( + override val key: ConfigKey, + override val defaultValue: Boolean, + override val needReinitialization: Boolean = true, +) : Config( + type = ConfigEditorType.BOOLEAN_SWITCH, + key = key, + defaultValue = defaultValue, + valueType = ValueType.BOOLEAN, +) + +/** + * Configuration setting for a dropdown. + */ +class SegmentedButtonConfig( + override val key: ConfigKey, + override val defaultValue: String, + val options: List, +) : Config( + type = ConfigEditorType.DROPDOWN, + key = key, + defaultValue = defaultValue, + valueType = ValueType.STRING, +) \ No newline at end of file diff --git a/Android/src/app/src/main/java/com/google/aiedge/gallery/data/Consts.kt b/Android/src/app/src/main/java/com/google/aiedge/gallery/data/Consts.kt new file mode 100644 index 0000000..ed666e7 --- /dev/null +++ b/Android/src/app/src/main/java/com/google/aiedge/gallery/data/Consts.kt @@ -0,0 +1,32 @@ +/* + * Copyright 2025 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.google.aiedge.gallery.data + +// Keys used to send/receive data to Work. +const val KEY_MODEL_URL = "KEY_MODEL_URL" +const val KEY_MODEL_DOWNLOAD_FILE_NAME = "KEY_MODEL_DOWNLOAD_FILE_NAME" +const val KEY_MODEL_TOTAL_BYTES = "KEY_MODEL_TOTAL_BYTES" +const val KEY_MODEL_DOWNLOAD_RECEIVED_BYTES = "KEY_MODEL_DOWNLOAD_RECEIVED_BYTES" +const val KEY_MODEL_DOWNLOAD_RATE = "KEY_MODEL_DOWNLOAD_RATE" +const val KEY_MODEL_DOWNLOAD_REMAINING_MS = "KEY_MODEL_DOWNLOAD_REMAINING_SECONDS" +const val KEY_MODEL_DOWNLOAD_ERROR_MESSAGE = "KEY_MODEL_DOWNLOAD_ERROR_MESSAGE" +const val KEY_MODEL_DOWNLOAD_ACCESS_TOKEN = "KEY_MODEL_DOWNLOAD_ACCESS_TOKEN" +const val KEY_MODEL_EXTRA_DATA_URLS = "KEY_MODEL_EXTRA_DATA_URLS" +const val KEY_MODEL_EXTRA_DATA_DOWNLOAD_FILE_NAMES = "KEY_MODEL_EXTRA_DATA_DOWNLOAD_FILE_NAMES" +const val KEY_MODEL_IS_ZIP = "KEY_MODEL_IS_ZIP" +const val KEY_MODEL_UNZIPPED_DIR = "KEY_MODEL_UNZIPPED_DIR" +const val KEY_MODEL_START_UNZIPPING = "KEY_MODEL_START_UNZIPPING" diff --git a/Android/src/app/src/main/java/com/google/aiedge/gallery/data/DataStoreRepository.kt b/Android/src/app/src/main/java/com/google/aiedge/gallery/data/DataStoreRepository.kt new file mode 100644 index 0000000..0a61e1d --- /dev/null +++ b/Android/src/app/src/main/java/com/google/aiedge/gallery/data/DataStoreRepository.kt @@ -0,0 +1,208 @@ +/* + * Copyright 2025 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.google.aiedge.gallery.data + +import android.security.keystore.KeyGenParameterSpec +import android.security.keystore.KeyProperties +import android.util.Base64 +import androidx.datastore.core.DataStore +import androidx.datastore.preferences.core.Preferences +import androidx.datastore.preferences.core.edit +import androidx.datastore.preferences.core.longPreferencesKey +import androidx.datastore.preferences.core.stringPreferencesKey +import com.google.gson.Gson +import com.google.gson.reflect.TypeToken +import com.google.aiedge.gallery.ui.theme.THEME_AUTO +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.runBlocking +import java.security.KeyStore +import javax.crypto.Cipher +import javax.crypto.KeyGenerator +import javax.crypto.SecretKey + +data class AccessTokenData( + val accessToken: String, + val refreshToken: String, + val expiresAtSeconds: Long +) + +interface DataStoreRepository { + fun saveTextInputHistory(history: List) + fun readTextInputHistory(): List + fun saveThemeOverride(theme: String) + fun readThemeOverride(): String + fun saveAccessTokenData(accessToken: String, refreshToken: String, expiresAt: Long) + fun readAccessTokenData(): AccessTokenData? +} + +/** + * Repository for managing data using DataStore, with JSON serialization. + * + * This class provides methods to read, add, remove, and clear data stored in DataStore, + * using JSON serialization for complex objects. It uses Gson for serializing and deserializing + * lists of objects to/from JSON strings. + * + * DataStore is used to persist data as JSON strings under specified keys. + */ +class DefaultDataStoreRepository( + private val dataStore: DataStore +) : + DataStoreRepository { + + private object PreferencesKeys { + val TEXT_INPUT_HISTORY = stringPreferencesKey("text_input_history") + + val THEME_OVERRIDE = stringPreferencesKey("theme_override") + + val ENCRYPTED_ACCESS_TOKEN = stringPreferencesKey("encrypted_access_token") + + // Store Initialization Vector + val ACCESS_TOKEN_IV = stringPreferencesKey("access_token_iv") + + val ENCRYPTED_REFRESH_TOKEN = stringPreferencesKey("encrypted_refresh_token") + + // Store Initialization Vector + val REFRESH_TOKEN_IV = stringPreferencesKey("refresh_token_iv") + + val ACCESS_TOKEN_EXPIRES_AT = longPreferencesKey("access_token_expires_at") + } + + private val keystoreAlias: String = "com_google_aiedge_gallery_access_token_key" + private val keyStore: KeyStore = KeyStore.getInstance("AndroidKeyStore").apply { load(null) } + + override fun saveTextInputHistory(history: List) { + runBlocking { + dataStore.edit { preferences -> + val gson = Gson() + val jsonString = gson.toJson(history) + preferences[PreferencesKeys.TEXT_INPUT_HISTORY] = jsonString + } + } + } + + override fun readTextInputHistory(): List { + return runBlocking { + val preferences = dataStore.data.first() + getTextInputHistory(preferences) + } + } + + override fun saveThemeOverride(theme: String) { + runBlocking { + dataStore.edit { preferences -> + preferences[PreferencesKeys.THEME_OVERRIDE] = theme + } + } + } + + override fun readThemeOverride(): String { + return runBlocking { + val preferences = dataStore.data.first() + preferences[PreferencesKeys.THEME_OVERRIDE] ?: THEME_AUTO + } + } + + override fun saveAccessTokenData(accessToken: String, refreshToken: String, expiresAt: Long) { + runBlocking { + val (encryptedAccessToken, accessTokenIv) = encrypt(accessToken) + val (encryptedRefreshToken, refreshTokenIv) = encrypt(refreshToken) + dataStore.edit { preferences -> + preferences[PreferencesKeys.ENCRYPTED_ACCESS_TOKEN] = encryptedAccessToken + preferences[PreferencesKeys.ACCESS_TOKEN_IV] = accessTokenIv + preferences[PreferencesKeys.ENCRYPTED_REFRESH_TOKEN] = encryptedRefreshToken + preferences[PreferencesKeys.REFRESH_TOKEN_IV] = refreshTokenIv + preferences[PreferencesKeys.ACCESS_TOKEN_EXPIRES_AT] = expiresAt + } + } + } + + override fun readAccessTokenData(): AccessTokenData? { + return runBlocking { + val preferences = dataStore.data.first() + val encryptedAccessToken = preferences[PreferencesKeys.ENCRYPTED_ACCESS_TOKEN] + val encryptedRefreshToken = preferences[PreferencesKeys.ENCRYPTED_REFRESH_TOKEN] + val accessTokenIv = preferences[PreferencesKeys.ACCESS_TOKEN_IV] + val refreshTokenIv = preferences[PreferencesKeys.REFRESH_TOKEN_IV] + val expiresAt = preferences[PreferencesKeys.ACCESS_TOKEN_EXPIRES_AT] + + var decryptedAccessToken: String? = null + var decryptedRefreshToken: String? = null + if (encryptedAccessToken != null && accessTokenIv != null) { + decryptedAccessToken = decrypt(encryptedAccessToken, accessTokenIv) + } + if (encryptedRefreshToken != null && refreshTokenIv != null) { + decryptedRefreshToken = decrypt(encryptedRefreshToken, refreshTokenIv) + } + if (decryptedAccessToken != null && decryptedRefreshToken != null && expiresAt != null) { + AccessTokenData(decryptedAccessToken, decryptedRefreshToken, expiresAt) + } else { + null + } + } + } + + private fun getTextInputHistory(preferences: Preferences): List { + val infosStr = preferences[PreferencesKeys.TEXT_INPUT_HISTORY] ?: "[]" + val gson = Gson() + val listType = object : TypeToken>() {}.type + return gson.fromJson(infosStr, listType) + } + + private fun getOrCreateSecretKey(): SecretKey { + return (keyStore.getKey(keystoreAlias, null) as? SecretKey) ?: run { + val keyGenerator = + KeyGenerator.getInstance(KeyProperties.KEY_ALGORITHM_AES, "AndroidKeyStore") + val keyGenParameterSpec = KeyGenParameterSpec.Builder( + keystoreAlias, + KeyProperties.PURPOSE_ENCRYPT or KeyProperties.PURPOSE_DECRYPT + ) + .setBlockModes(KeyProperties.BLOCK_MODE_GCM) + .setEncryptionPaddings(KeyProperties.ENCRYPTION_PADDING_NONE) + .setUserAuthenticationRequired(false) // Consider setting to true for added security + .build() + keyGenerator.init(keyGenParameterSpec) + keyGenerator.generateKey() + } + } + + private fun encrypt(plainText: String): Pair { + val secretKey = getOrCreateSecretKey() + val cipher = Cipher.getInstance("AES/GCM/NoPadding") + cipher.init(Cipher.ENCRYPT_MODE, secretKey) + val iv = cipher.iv + val encryptedBytes = cipher.doFinal(plainText.toByteArray(Charsets.UTF_8)) + return Base64.encodeToString(encryptedBytes, Base64.DEFAULT) to Base64.encodeToString( + iv, + Base64.DEFAULT + ) + } + + private fun decrypt(encryptedText: String, ivText: String): String? { + val secretKey = getOrCreateSecretKey() + val cipher = Cipher.getInstance("AES/GCM/NoPadding") + val ivBytes = Base64.decode(ivText, Base64.DEFAULT) + val spec = javax.crypto.spec.GCMParameterSpec(128, ivBytes) // 128 bit tag length + cipher.init(Cipher.DECRYPT_MODE, secretKey, spec) + val encryptedBytes = Base64.decode(encryptedText, Base64.DEFAULT) + return try { + String(cipher.doFinal(encryptedBytes), Charsets.UTF_8) + } catch (e: Exception) { + // Handle decryption errors (e.g., key not found) + null + } + } +} diff --git a/Android/src/app/src/main/java/com/google/aiedge/gallery/data/DownloadRepository.kt b/Android/src/app/src/main/java/com/google/aiedge/gallery/data/DownloadRepository.kt new file mode 100644 index 0000000..eea9306 --- /dev/null +++ b/Android/src/app/src/main/java/com/google/aiedge/gallery/data/DownloadRepository.kt @@ -0,0 +1,312 @@ +/* + * Copyright 2025 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.google.aiedge.gallery.data + +import android.Manifest +import android.app.NotificationChannel +import android.app.NotificationManager +import android.app.PendingIntent +import android.content.Context +import android.content.Intent +import android.content.pm.PackageManager +import android.net.Uri +import android.os.Build +import android.util.Log +import androidx.core.app.ActivityCompat +import androidx.core.app.NotificationCompat +import androidx.core.app.NotificationManagerCompat +import androidx.work.Data +import androidx.work.ExistingWorkPolicy +import androidx.work.OneTimeWorkRequestBuilder +import androidx.work.Operation +import androidx.work.OutOfQuotaPolicy +import androidx.work.WorkInfo +import androidx.work.WorkManager +import androidx.work.WorkQuery +import com.google.common.util.concurrent.FutureCallback +import com.google.common.util.concurrent.Futures +import com.google.common.util.concurrent.ListenableFuture +import com.google.common.util.concurrent.MoreExecutors +import com.google.aiedge.gallery.AppLifecycleProvider +import com.google.aiedge.gallery.R +import com.google.aiedge.gallery.worker.DownloadWorker +import java.util.UUID + +private const val TAG = "AGDownloadRepository" +private const val MODEL_NAME_TAG = "modelName" + +data class AGWorkInfo(val modelName: String, val workId: String) + +interface DownloadRepository { + fun downloadModel( + model: Model, onStatusUpdated: (model: Model, status: ModelDownloadStatus) -> Unit + ) + + fun cancelDownloadModel(model: Model) + + fun cancelAll(models: List, onComplete: () -> Unit) + + fun observerWorkerProgress( + workerId: UUID, + model: Model, + onStatusUpdated: (model: Model, status: ModelDownloadStatus) -> Unit, + ) + + fun getEnqueuedOrRunningWorkInfos(): List +} + +/** + * Repository for managing model downloads using WorkManager. + * + * This class provides methods to initiate model downloads, cancel downloads, observe download + * progress, and retrieve information about enqueued or running download tasks. It utilizes + * WorkManager to handle background download operations. + */ +class DefaultDownloadRepository( + private val context: Context, + private val lifecycleProvider: AppLifecycleProvider, +) : DownloadRepository { + private val workManager = WorkManager.getInstance(context) + + override fun downloadModel( + model: Model, onStatusUpdated: (model: Model, status: ModelDownloadStatus) -> Unit + ) { + // Create input data. + val builder = Data.Builder() + val totalBytes = model.totalBytes + model.extraDataFiles.sumOf { it.sizeInBytes } + val inputDataBuilder = builder.putString(KEY_MODEL_URL, model.url) + .putString(KEY_MODEL_DOWNLOAD_FILE_NAME, model.downloadFileName) + .putBoolean(KEY_MODEL_IS_ZIP, model.isZip).putString(KEY_MODEL_UNZIPPED_DIR, model.unzipDir) + .putLong( + KEY_MODEL_TOTAL_BYTES, totalBytes + ) + if (model.extraDataFiles.isNotEmpty()) { + inputDataBuilder.putString( + KEY_MODEL_EXTRA_DATA_URLS, model.extraDataFiles.joinToString(",") { it.url } + ).putString( + KEY_MODEL_EXTRA_DATA_DOWNLOAD_FILE_NAMES, + model.extraDataFiles.joinToString(",") { it.downloadFileName } + ) + } + if (model.accessToken != null) { + inputDataBuilder.putString(KEY_MODEL_DOWNLOAD_ACCESS_TOKEN, model.accessToken) + } + val inputData = inputDataBuilder.build() + + // Create worker request. + val downloadWorkRequest = + OneTimeWorkRequestBuilder().setExpedited(OutOfQuotaPolicy.RUN_AS_NON_EXPEDITED_WORK_REQUEST) + .setInputData(inputData).addTag("${MODEL_NAME_TAG}:${model.name}").build() + + val workerId = downloadWorkRequest.id + + // Start! + workManager.enqueueUniqueWork( + model.name, ExistingWorkPolicy.REPLACE, downloadWorkRequest + ) + + // Observe progress. + observerWorkerProgress( + workerId = workerId, model = model, onStatusUpdated = onStatusUpdated + ) + } + + override fun cancelDownloadModel(model: Model) { + workManager.cancelAllWorkByTag("${MODEL_NAME_TAG}:${model.name}") + } + + override fun cancelAll(models: List, onComplete: () -> Unit) { + if (models.isEmpty()) { + onComplete() + return + } + + val futures = mutableListOf>() + for (tag in models.map { "${MODEL_NAME_TAG}:${it.name}" }) { + futures.add(workManager.cancelAllWorkByTag(tag).result) + } + val combinedFuture: ListenableFuture> = Futures.allAsList(futures) + Futures.addCallback( + combinedFuture, object : FutureCallback> { + override fun onSuccess(result: List?) { + // All cancellations are complete + onComplete() + } + + override fun onFailure(t: Throwable) { + // At least one cancellation failed + t.printStackTrace() + onComplete() + } + }, MoreExecutors.directExecutor() + ) + } + + override fun observerWorkerProgress( + workerId: UUID, + model: Model, + onStatusUpdated: (model: Model, status: ModelDownloadStatus) -> Unit, + ) { + workManager.getWorkInfoByIdLiveData(workerId).observeForever { workInfo -> + if (workInfo != null) { + when (workInfo.state) { + WorkInfo.State.RUNNING -> { + val receivedBytes = workInfo.progress.getLong(KEY_MODEL_DOWNLOAD_RECEIVED_BYTES, 0L) + val downloadRate = workInfo.progress.getLong(KEY_MODEL_DOWNLOAD_RATE, 0L) + val remainingSeconds = workInfo.progress.getLong(KEY_MODEL_DOWNLOAD_REMAINING_MS, 0L) + val startUnzipping = workInfo.progress.getBoolean(KEY_MODEL_START_UNZIPPING, false) + + if (!startUnzipping) { + if (receivedBytes != 0L) { + onStatusUpdated( + model, ModelDownloadStatus( + status = ModelDownloadStatusType.IN_PROGRESS, + totalBytes = model.totalBytes, + receivedBytes = receivedBytes, + bytesPerSecond = downloadRate, + remainingMs = remainingSeconds, + ) + ) + } + } else { + onStatusUpdated( + model, ModelDownloadStatus( + status = ModelDownloadStatusType.UNZIPPING, + ) + ) + } + } + + WorkInfo.State.SUCCEEDED -> { + Log.d("repo", "worker %s success".format(workerId.toString())) + onStatusUpdated( + model, ModelDownloadStatus( + status = ModelDownloadStatusType.SUCCEEDED, + ) + ) + sendNotification( + title = context.getString( + R.string.notification_title_success + ), + text = context.getString(R.string.notification_content_success).format(model.name), + modelName = model.name, + ) + } + + WorkInfo.State.FAILED, WorkInfo.State.CANCELLED -> { + var status = ModelDownloadStatusType.FAILED + val errorMessage = workInfo.outputData.getString(KEY_MODEL_DOWNLOAD_ERROR_MESSAGE) ?: "" + Log.d( + "repo", "worker %s FAILED or CANCELLED: %s".format(workerId.toString(), errorMessage) + ) + if (workInfo.state == WorkInfo.State.CANCELLED) { + status = ModelDownloadStatusType.NOT_DOWNLOADED + } else { + sendNotification( + title = context.getString(R.string.notification_title_fail), + text = context.getString(R.string.notification_content_success).format(model.name), + modelName = "", + ) + } + onStatusUpdated( + model, ModelDownloadStatus(status = status, errorMessage = errorMessage) + ) + } + + else -> {} + } + } + } + } + + /** + * Retrieves a list of AGWorkInfo objects representing WorkManager work items that are either + * enqueued or currently running. + */ + override fun getEnqueuedOrRunningWorkInfos(): List { + val workQuery = + WorkQuery.Builder.fromStates(listOf(WorkInfo.State.ENQUEUED, WorkInfo.State.RUNNING)).build() + + return workManager.getWorkInfos(workQuery).get().map { info -> + val tags = info.tags + var modelName = "" + Log.d(TAG, "work: ${info.id}, tags: $tags") + for (tag in tags) { + if (tag.startsWith("${MODEL_NAME_TAG}:")) { + val index = tag.indexOf(':') + if (index >= 0) { + modelName = tag.substring(index + 1) + break + } + } + } + return@map AGWorkInfo(modelName = modelName, workId = info.id.toString()) + } + } + + private fun sendNotification(title: String, text: String, modelName: String) { + // Don't send notification if app is in foreground. + if (lifecycleProvider.isAppInForeground) { + return + } + + val channelId = "download_notification" + val channelName = "AI Edge Gallery download notification" + + // Create the NotificationChannel, but only on API 26+ because + // the NotificationChannel class is new and not in the support library + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { + val importance = NotificationManager.IMPORTANCE_HIGH + val channel = NotificationChannel(channelId, channelName, importance) + val notificationManager: NotificationManager = + context.getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager + notificationManager.createNotificationChannel(channel) + } + + // Create an Intent to open your app with a deep link. + val intent = Intent( + Intent.ACTION_VIEW, + Uri.parse("com.google.aiedge.gallery://model/${modelName}") + ).apply { + flags = Intent.FLAG_ACTIVITY_NEW_TASK + } + + // Create a PendingIntent + val pendingIntent: PendingIntent = PendingIntent.getActivity( + context, 0, intent, PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE + ) + + + val builder = NotificationCompat.Builder(context, channelId) + // TODO: replace icon. + .setSmallIcon(android.R.drawable.ic_dialog_info).setContentTitle(title).setContentText(text) + .setPriority(NotificationCompat.PRIORITY_HIGH).setContentIntent(pendingIntent) + .setAutoCancel(true) + + with(NotificationManagerCompat.from(context)) { + // notificationId is a unique int for each notification that you must define + if (ActivityCompat.checkSelfPermission( + context, Manifest.permission.POST_NOTIFICATIONS + ) != PackageManager.PERMISSION_GRANTED + ) { + // Permission not granted, return or handle accordingly. In real app, request permission. + return + } + notify(1, builder.build()) + } + } +} diff --git a/Android/src/app/src/main/java/com/google/aiedge/gallery/data/HuggingFace.kt b/Android/src/app/src/main/java/com/google/aiedge/gallery/data/HuggingFace.kt new file mode 100644 index 0000000..b1fd347 --- /dev/null +++ b/Android/src/app/src/main/java/com/google/aiedge/gallery/data/HuggingFace.kt @@ -0,0 +1,175 @@ +/* + * Copyright 2025 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.google.aiedge.gallery.data + +import com.google.aiedge.gallery.ui.llmchat.createLLmChatConfig +import kotlinx.serialization.KSerializer +import kotlinx.serialization.Serializable +import kotlinx.serialization.SerializationException +import kotlinx.serialization.descriptors.SerialDescriptor +import kotlinx.serialization.descriptors.buildClassSerialDescriptor +import kotlinx.serialization.encoding.Decoder +import kotlinx.serialization.encoding.Encoder +import kotlinx.serialization.json.JsonDecoder +import kotlinx.serialization.json.JsonPrimitive + +@Serializable +data class HfModelSummary(val modelId: String) + +@Serializable +data class HfModelDetails(val id: String, val siblings: List) + +@Serializable +data class HfModelFile(val rfilename: String) + +@Serializable(with = ConfigValueSerializer::class) +sealed class ConfigValue { + @Serializable + data class IntValue(val value: Int) : ConfigValue() + + @Serializable + data class FloatValue(val value: Float) : ConfigValue() + + @Serializable + data class StringValue(val value: String) : ConfigValue() +} + +/** + * Custom serializer for the ConfigValue class. + * + * This object implements the KSerializer interface to provide custom serialization and + * deserialization logic for the ConfigValue class. It handles different types of ConfigValue + * (IntValue, FloatValue, StringValue) and supports JSON format. + */ +object ConfigValueSerializer : KSerializer { + override val descriptor: SerialDescriptor = buildClassSerialDescriptor("ConfigValue") + + override fun serialize(encoder: Encoder, value: ConfigValue) { + when (value) { + is ConfigValue.IntValue -> encoder.encodeInt(value.value) + is ConfigValue.FloatValue -> encoder.encodeFloat(value.value) + is ConfigValue.StringValue -> encoder.encodeString(value.value) + } + } + + override fun deserialize(decoder: Decoder): ConfigValue { + val input = decoder as? JsonDecoder + ?: throw SerializationException("This serializer only works with Json") + return when (val element = input.decodeJsonElement()) { + is JsonPrimitive -> { + if (element.isString) { + ConfigValue.StringValue(element.content) + } else if (element.content.contains('.')) { + ConfigValue.FloatValue(element.content.toFloat()) + } else { + ConfigValue.IntValue(element.content.toInt()) + } + } + + else -> throw SerializationException("Expected JsonPrimitive") + } + } +} + +@Serializable +data class HfModel( + var id: String = "", + val task: String, + val name: String, + val url: String = "", + val file: String = "", + val sizeInBytes: Long, + val configs: Map, +) { + fun toModel(): Model { + val parts = if (url.isNotEmpty()) { + url.split('/') + } else if (file.isNotEmpty()) { + listOf(file) + } else { + listOf("") + } + val fileName = "${id}_${(parts.lastOrNull() ?: "")}".replace(Regex("[^a-zA-Z0-9._-]"), "_") + + // Generate configs based on the given default values. + val configs: List = when (task) { + TASK_LLM_CHAT.type.label -> createLLmChatConfig(defaults = configs) + // todo: add configs for other types. + else -> listOf() + } + + // Construct url. + var modelUrl = url + if (modelUrl.isEmpty() && file.isNotEmpty()) { + modelUrl = "https://huggingface.co/${id}/resolve/main/${file}?download=true" + } + + // Other parameters. + val showBenchmarkButton = when (task) { + TASK_LLM_CHAT.type.label -> false + else -> true + } + val showRunAgainButton = when (task) { + TASK_LLM_CHAT.type.label -> false + else -> true + } + + return Model( + hfModelId = id, + name = name, + url = modelUrl, + sizeInBytes = sizeInBytes, + downloadFileName = fileName, + configs = configs, + showBenchmarkButton = showBenchmarkButton, + showRunAgainButton = showRunAgainButton, + ) + } +} + +fun getIntConfigValue(configValue: ConfigValue?, default: Int): Int { + if (configValue == null) { + return default + } + return when (configValue) { + is ConfigValue.IntValue -> configValue.value + is ConfigValue.FloatValue -> configValue.value.toInt() + is ConfigValue.StringValue -> 0 + } +} + +fun getFloatConfigValue(configValue: ConfigValue?, default: Float): Float { + if (configValue == null) { + return default + } + return when (configValue) { + is ConfigValue.IntValue -> configValue.value.toFloat() + is ConfigValue.FloatValue -> configValue.value + is ConfigValue.StringValue -> 0f + } +} + +fun getStringConfigValue(configValue: ConfigValue?, default: String): String { + if (configValue == null) { + return default + } + return when (configValue) { + is ConfigValue.IntValue -> "${configValue.value}" + is ConfigValue.FloatValue -> "${configValue.value}" + is ConfigValue.StringValue -> configValue.value + } +} diff --git a/Android/src/app/src/main/java/com/google/aiedge/gallery/data/Model.kt b/Android/src/app/src/main/java/com/google/aiedge/gallery/data/Model.kt new file mode 100644 index 0000000..a99ccb8 --- /dev/null +++ b/Android/src/app/src/main/java/com/google/aiedge/gallery/data/Model.kt @@ -0,0 +1,376 @@ +/* + * Copyright 2025 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.google.aiedge.gallery.data + +import android.content.Context +import com.google.aiedge.gallery.ui.common.chat.PromptTemplate +import com.google.aiedge.gallery.ui.common.convertValueToTargetType +import com.google.aiedge.gallery.ui.llmchat.createLlmChatConfigs + +data class ModelDataFile( + val name: String, + val url: String, + val downloadFileName: String, + val sizeInBytes: Long, +) + +enum class LlmBackend { + CPU, GPU +} + +/** A model for a task */ +data class Model( + /** The Hugging Face model ID (if applicable). */ + val hfModelId: String = "", + + /** The name (for display purpose) of the model. */ + val name: String, + + /** The name of the downloaded model file. */ + val downloadFileName: String, + + /** The URL to download the model from. */ + val url: String, + + /** The size of the model file in bytes. */ + val sizeInBytes: Long, + + /** A list of additional data files required by the model. */ + val extraDataFiles: List = listOf(), + + /** + * A description or information about the model. + * + * Will be shown at the start of the chat session and in the expanded model item. + */ + val info: String = "", + + /** + * The url to jump to when clicking "learn more" in expanded model item. + */ + val learnMoreUrl: String = "", + + /** A list of configurable parameters for the model. */ + val configs: List = listOf(), + + /** Whether to show the "run again" button in the UI. */ + val showRunAgainButton: Boolean = true, + + /** Whether to show the "benchmark" button in the UI. */ + val showBenchmarkButton: Boolean = true, + + /** Indicates whether the model is a zip file. */ + val isZip: Boolean = false, + + /** The name of the directory to unzip the model to (if it's a zip file). */ + val unzipDir: String = "", + + /** The preferred backend of the model (only for LLM). */ + val llmBackend: LlmBackend = LlmBackend.GPU, + + /** The prompt templates for the model (only for LLM). */ + val llmPromptTemplates: List = listOf(), + + // The following fields are managed by the app. Don't need to set manually. + var taskType: TaskType? = null, + var instance: Any? = null, + var initializing: Boolean = false, + var configValues: Map = mapOf(), + var totalBytes: Long = 0L, + var accessToken: String? = null, +) { + fun preProcess(task: Task) { + this.taskType = task.type + val configValues: MutableMap = mutableMapOf() + for (config in this.configs) { + configValues[config.key.label] = config.defaultValue + } + this.configValues = configValues + this.totalBytes = this.sizeInBytes + this.extraDataFiles.sumOf { it.sizeInBytes } + } + + fun getPath(context: Context, fileName: String = downloadFileName): String { + return if (this.isZip && this.unzipDir.isNotEmpty()) { + "${context.getExternalFilesDir(null)}/${this.unzipDir}" + } else { + "${context.getExternalFilesDir(null)}/${fileName}" + } + } + + fun getIntConfigValue(key: ConfigKey, defaultValue: Int = 0): Int { + return getTypedConfigValue( + key = key, valueType = ValueType.INT, defaultValue = defaultValue + ) as Int + } + + fun getFloatConfigValue(key: ConfigKey, defaultValue: Float = 0.0f): Float { + return getTypedConfigValue( + key = key, valueType = ValueType.FLOAT, defaultValue = defaultValue + ) as Float + } + + fun getBooleanConfigValue(key: ConfigKey, defaultValue: Boolean = false): Boolean { + return getTypedConfigValue( + key = key, valueType = ValueType.BOOLEAN, defaultValue = defaultValue + ) as Boolean + } + + fun getExtraDataFile(name: String): ModelDataFile? { + return extraDataFiles.find { it.name == name } + } + + private fun getTypedConfigValue(key: ConfigKey, valueType: ValueType, defaultValue: Any): Any { + return convertValueToTargetType( + value = configValues.getOrDefault(key.label, defaultValue), valueType = valueType + ) + } +} + +enum class ModelDownloadStatusType { + NOT_DOWNLOADED, PARTIALLY_DOWNLOADED, IN_PROGRESS, UNZIPPING, SUCCEEDED, FAILED, +} + +data class ModelDownloadStatus( + val status: ModelDownloadStatusType, + val totalBytes: Long = 0, + val receivedBytes: Long = 0, + val errorMessage: String = "", + val bytesPerSecond: Long = 0, + val remainingMs: Long = 0, +) + +//////////////////////////////////////////////////////////////////////////////////////////////////// +// Configs. + +enum class ConfigKey(val label: String, val id: String) { + MAX_TOKENS("Max tokens", id = "max_token"), + TOPK("TopK", id = "topk"), + TOPP( + "TopP", + id = "topp" + ), + TEMPERATURE("Temperature", id = "temperature"), + MAX_RESULT_COUNT( + "Max result count", + id = "max_result_count" + ), + USE_GPU("Use GPU", id = "use_gpu"), + WARM_UP_ITERATIONS( + "Warm up iterations", + id = "warm_up_iterations" + ), + BENCHMARK_ITERATIONS( + "Benchmark iterations", + id = "benchmark_iterations" + ), + ITERATIONS("Iterations", id = "iterations"), + THEME("Theme", id = "theme"), +} + +val MOBILENET_CONFIGS: List = listOf( + NumberSliderConfig( + key = ConfigKey.MAX_RESULT_COUNT, + sliderMin = 1f, + sliderMax = 5f, + defaultValue = 3f, + valueType = ValueType.INT + ), BooleanSwitchConfig( + key = ConfigKey.USE_GPU, + defaultValue = false, + ) +) + +val IMAGE_GENERATION_CONFIGS: List = listOf( + NumberSliderConfig( + key = ConfigKey.ITERATIONS, + sliderMin = 5f, + sliderMax = 50f, + defaultValue = 10f, + valueType = ValueType.INT, + needReinitialization = false, + ) +) + +const val TEXT_CLASSIFICATION_INFO = + "Model is trained on movie reviews dataset. Type a movie review below and see the scores of positive or negative sentiment." + +const val TEXT_CLASSIFICATION_LEARN_MORE_URL = + "https://ai.google.dev/edge/mediapipe/solutions/text/text_classifier" + +const val IMAGE_CLASSIFICATION_INFO = "" + +const val IMAGE_CLASSIFICATION_LEARN_MORE_URL = "https://ai.google.dev/edge/litert/android" + +const val LLM_CHAT_INFO = + "Some description about this large language model. A community org for developers to discover models that are ready for deployment to edge platforms" + +const val LLM_CHAT_LEARN_MORE_URL = + "https://ai.google.dev/edge/mediapipe/solutions/genai/llm_inference/android" + +const val IMAGE_GENERATION_INFO = + "Powered by [MediaPipe Image Generation API](https://ai.google.dev/edge/mediapipe/solutions/vision/image_generator/android)" + +//////////////////////////////////////////////////////////////////////////////////////////////////// +// Model spec. + +val MODEL_LLM_GEMMA_2B_GPU_INT4: Model = Model( + name = "Gemma 2B (GPU int4)", + downloadFileName = "gemma-2b-it-gpu-int4.bin", + url = "https://storage.googleapis.com/tfweb/app_gallery_models/gemma-2b-it-gpu-int4.bin", + sizeInBytes = 1354301440L, + configs = createLlmChatConfigs(), + info = LLM_CHAT_INFO, + learnMoreUrl = LLM_CHAT_LEARN_MORE_URL, +) + +val MODEL_LLM_GEMMA_2_2B_GPU_INT8: Model = Model( + name = "Gemma 2 2B (GPU int8)", + downloadFileName = "gemma2-2b-it-gpu-int8.bin", + url = "https://storage.googleapis.com/tfweb/app_gallery_models/gemma2-2b-it-gpu-int8.bin", + sizeInBytes = 2627141632L, + configs = createLlmChatConfigs(), + info = LLM_CHAT_INFO, + learnMoreUrl = LLM_CHAT_LEARN_MORE_URL, +) + +val MODEL_LLM_GEMMA_3_1B_INT4: Model = Model( + name = "Gemma 3 1B (int4)", + downloadFileName = "gemma3-1b-it-int4.task", + url = "https://huggingface.co/litert-community/Gemma3-1B-IT/resolve/main/gemma3-1b-it-int4.task?download=true", + sizeInBytes = 554661243L, + configs = createLlmChatConfigs(defaultTopK = 64, defaultTopP = 0.95f), + info = LLM_CHAT_INFO, + learnMoreUrl = LLM_CHAT_LEARN_MORE_URL, + llmPromptTemplates = listOf( + PromptTemplate( + title = "Emoji Fun", + description = "Generate emojis by emotions", + prompt = "Show me emojis grouped by emotions" + ), + PromptTemplate( + title = "Trip Planner", + description = "Plan a trip to a destination", + prompt = "Plan a two-day trip to San Francisco" + ), + ) +) + +val MODEL_LLM_DEEPSEEK: Model = Model( + name = "Deepseek", + downloadFileName = "deepseek.task", + url = "https://huggingface.co/litert-community/DeepSeek-R1-Distill-Qwen-1.5B/resolve/main/deepseek_q8_ekv1280.task?download=true", + sizeInBytes = 1860686856L, + llmBackend = LlmBackend.CPU, + configs = createLlmChatConfigs(defaultTemperature = 0.6f, defaultTopK = 40, defaultTopP = 0.7f), + info = LLM_CHAT_INFO, + learnMoreUrl = LLM_CHAT_LEARN_MORE_URL, +) + +val MODEL_TEXT_CLASSIFICATION_MOBILEBERT: Model = Model( + name = "MobileBert", + downloadFileName = "bert_classifier.tflite", + url = "https://storage.googleapis.com/mediapipe-models/text_classifier/bert_classifier/float32/latest/bert_classifier.tflite", + sizeInBytes = 25707538L, + info = TEXT_CLASSIFICATION_INFO, + learnMoreUrl = TEXT_CLASSIFICATION_LEARN_MORE_URL, +) + +val MODEL_TEXT_CLASSIFICATION_AVERAGE_WORD_EMBEDDING: Model = Model( + name = "Average word embedding", + downloadFileName = "average_word_classifier.tflite", + url = "https://storage.googleapis.com/mediapipe-models/text_classifier/average_word_classifier/float32/latest/average_word_classifier.tflite", + sizeInBytes = 775708L, + info = TEXT_CLASSIFICATION_INFO, +) + +val MODEL_IMAGE_CLASSIFICATION_MOBILENET_V1: Model = Model( + name = "Mobilenet V1", + downloadFileName = "mobilenet_v1.tflite", + url = "https://storage.googleapis.com/tfweb/app_gallery_models/mobilenet_v1.tflite", + sizeInBytes = 16900760L, + extraDataFiles = listOf( + ModelDataFile( + name = "labels", + url = "https://raw.githubusercontent.com/leferrad/tensorflow-mobilenet/refs/heads/master/imagenet/labels.txt", + downloadFileName = "mobilenet_labels_v1.txt", + sizeInBytes = 21685L + ), + ), + configs = MOBILENET_CONFIGS, + info = IMAGE_CLASSIFICATION_INFO, + learnMoreUrl = IMAGE_CLASSIFICATION_LEARN_MORE_URL, +) + +val MODEL_IMAGE_CLASSIFICATION_MOBILENET_V2: Model = Model( + name = "Mobilenet V2", + downloadFileName = "mobilenet_v2.tflite", + url = "https://storage.googleapis.com/tfweb/app_gallery_models/mobilenet_v2.tflite", + sizeInBytes = 13978596L, + extraDataFiles = listOf( + ModelDataFile( + name = "labels", + url = "https://raw.githubusercontent.com/leferrad/tensorflow-mobilenet/refs/heads/master/imagenet/labels.txt", + downloadFileName = "mobilenet_labels_v2.txt", + sizeInBytes = 21685L + ), + ), + configs = MOBILENET_CONFIGS, + info = IMAGE_CLASSIFICATION_INFO, +) + +val MODEL_IMAGE_GENERATION_STABLE_DIFFUSION: Model = Model( + name = "Stable diffusion", + downloadFileName = "sd15.zip", + isZip = true, + unzipDir = "sd15", + url = "https://storage.googleapis.com/tfweb/app_gallery_models/sd15.zip", + sizeInBytes = 1906219565L, + showRunAgainButton = false, + showBenchmarkButton = false, + info = IMAGE_GENERATION_INFO, + configs = IMAGE_GENERATION_CONFIGS, +) + +val EMPTY_MODEL: Model = Model( + name = "empty", + downloadFileName = "empty.tflite", + url = "", + sizeInBytes = 0L, +) + +//////////////////////////////////////////////////////////////////////////////////////////////////// +// Model collections for different tasks. + +val MODELS_TEXT_CLASSIFICATION: MutableList = mutableListOf( + MODEL_TEXT_CLASSIFICATION_MOBILEBERT, + MODEL_TEXT_CLASSIFICATION_AVERAGE_WORD_EMBEDDING, +) + +val MODELS_IMAGE_CLASSIFICATION: MutableList = mutableListOf( + MODEL_IMAGE_CLASSIFICATION_MOBILENET_V1, + MODEL_IMAGE_CLASSIFICATION_MOBILENET_V2, +) + +val MODELS_LLM_CHAT: MutableList = mutableListOf( + MODEL_LLM_GEMMA_2B_GPU_INT4, + MODEL_LLM_GEMMA_2_2B_GPU_INT8, + MODEL_LLM_GEMMA_3_1B_INT4, + MODEL_LLM_DEEPSEEK, +) + +val MODELS_IMAGE_GENERATION: MutableList = + mutableListOf(MODEL_IMAGE_GENERATION_STABLE_DIFFUSION) diff --git a/Android/src/app/src/main/java/com/google/aiedge/gallery/data/Tasks.kt b/Android/src/app/src/main/java/com/google/aiedge/gallery/data/Tasks.kt new file mode 100644 index 0000000..f96dd8a --- /dev/null +++ b/Android/src/app/src/main/java/com/google/aiedge/gallery/data/Tasks.kt @@ -0,0 +1,111 @@ +/* + * Copyright 2025 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.google.aiedge.gallery.data + +import androidx.annotation.StringRes +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.rounded.ImageSearch +import androidx.compose.ui.graphics.vector.ImageVector +import com.google.aiedge.gallery.R + +/** Type of task. */ +enum class TaskType(val label: String) { + TEXT_CLASSIFICATION("Text Classification"), + IMAGE_CLASSIFICATION("Image Classification"), + IMAGE_GENERATION("Image Generation"), + LLM_CHAT("LLM Chat"), + + TEST_TASK_1("Test task 1"), + TEST_TASK_2("Test task 2") +} + +/** Data class for a task listed in home screen. */ +data class Task( + /** Type of the task. */ + val type: TaskType, + + /** Icon to be shown in the task tile. */ + val icon: ImageVector? = null, + + /** Vector resource id for the icon. This precedes the icon if both are set. */ + val iconVectorResourceId: Int? = null, + + /** List of models for the task. */ + val models: MutableList, + + /** Description of the task. */ + val description: String, + + /** Placeholder text for the name of the agent shown above chat messages. */ + @StringRes val agentNameRes: Int = R.string.chat_generic_agent_name, + + /** Placeholder text for the text input field. */ + @StringRes val textInputPlaceHolderRes: Int = R.string.chat_textinput_placeholder, + + // The following fields are managed by the app. Don't need to set manually. + var index: Int = -1 +) + +val TASK_TEXT_CLASSIFICATION = Task( + type = TaskType.TEXT_CLASSIFICATION, + iconVectorResourceId = R.drawable.text_spark, + models = MODELS_TEXT_CLASSIFICATION, + description = "Classify text into different categories", + textInputPlaceHolderRes = R.string.text_input_placeholder_text_classification +) + +val TASK_IMAGE_CLASSIFICATION = Task( + type = TaskType.IMAGE_CLASSIFICATION, + icon = Icons.Rounded.ImageSearch, + description = "Classify images into different categories", + models = MODELS_IMAGE_CLASSIFICATION +) + +val TASK_LLM_CHAT = Task( + type = TaskType.LLM_CHAT, + iconVectorResourceId = R.drawable.chat_spark, + models = MODELS_LLM_CHAT, + description = "Chat? with a on-device large language model", + textInputPlaceHolderRes = R.string.text_input_placeholder_llm_chat +) + +val TASK_IMAGE_GENERATION = Task( + type = TaskType.IMAGE_GENERATION, + iconVectorResourceId = R.drawable.image_spark, + models = MODELS_IMAGE_GENERATION, + description = "Generate images from text", + textInputPlaceHolderRes = R.string.text_image_generation_text_field_placeholder +) + +/** All tasks. */ +val TASKS: List = listOf( + TASK_TEXT_CLASSIFICATION, + TASK_IMAGE_CLASSIFICATION, + TASK_IMAGE_GENERATION, + TASK_LLM_CHAT, +) + +fun getModelByName(name: String): Model? { + for (task in TASKS) { + for (model in task.models) { + if (model.name == name) { + return model + } + } + } + return null +} diff --git a/Android/src/app/src/main/java/com/google/aiedge/gallery/ui/ViewModelProvider.kt b/Android/src/app/src/main/java/com/google/aiedge/gallery/ui/ViewModelProvider.kt new file mode 100644 index 0000000..6f25663 --- /dev/null +++ b/Android/src/app/src/main/java/com/google/aiedge/gallery/ui/ViewModelProvider.kt @@ -0,0 +1,70 @@ +/* + * Copyright 2025 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.google.aiedge.gallery.ui + +import android.app.Application +import androidx.lifecycle.ViewModelProvider.AndroidViewModelFactory +import androidx.lifecycle.viewmodel.CreationExtras +import androidx.lifecycle.viewmodel.initializer +import androidx.lifecycle.viewmodel.viewModelFactory +import com.google.aiedge.gallery.GalleryApplication +import com.google.aiedge.gallery.ui.imageclassification.ImageClassificationViewModel +import com.google.aiedge.gallery.ui.imagegeneration.ImageGenerationViewModel +import com.google.aiedge.gallery.ui.llmchat.LlmChatViewModel +import com.google.aiedge.gallery.ui.modelmanager.ModelManagerViewModel +import com.google.aiedge.gallery.ui.textclassification.TextClassificationViewModel + +object ViewModelProvider { + val Factory = viewModelFactory { + // Initializer for ModelManagerViewModel. + initializer { + val downloadRepository = galleryApplication().container.downloadRepository + val dataStoreRepository = galleryApplication().container.dataStoreRepository + ModelManagerViewModel( + downloadRepository = downloadRepository, + dataStoreRepository = dataStoreRepository, + context = galleryApplication().container.context, + ) + } + + // Initializer for TextClassificationViewModel + initializer { + TextClassificationViewModel() + } + + // Initializer for ImageClassificationViewModel + initializer { + ImageClassificationViewModel() + } + + // Initializer for LlmChatViewModel. + initializer { + LlmChatViewModel() + } + + initializer { + ImageGenerationViewModel() + } + } +} + +/** + * Extension function to queries for [Application] object and returns an instance of + * [GalleryApplication]. + */ +fun CreationExtras.galleryApplication(): GalleryApplication = + (this[AndroidViewModelFactory.APPLICATION_KEY] as GalleryApplication) diff --git a/Android/src/app/src/main/java/com/google/aiedge/gallery/ui/common/AuthConfig.kt b/Android/src/app/src/main/java/com/google/aiedge/gallery/ui/common/AuthConfig.kt new file mode 100644 index 0000000..737bcbb --- /dev/null +++ b/Android/src/app/src/main/java/com/google/aiedge/gallery/ui/common/AuthConfig.kt @@ -0,0 +1,41 @@ +/* + * Copyright 2025 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.google.aiedge.gallery.ui.common + +import android.net.Uri +import net.openid.appauth.AuthorizationServiceConfiguration + +object AuthConfig { + // Hugging Face Client ID. + const val clientId = "88a0ac25-fcf4-467b-b8cd-ebcc2aec9bd0" + // Registered redirect URI. + // + // The scheme needs to match the + // "android.defaultConfig.manifestPlaceholders["appAuthRedirectScheme"]" field in + // "build.gradle.kts". + const val redirectUri = "com.google.aiedge.gallery.oauth://oauthredirect" + + // OAuth 2.0 Endpoints (Authorization + Token Exchange) + private const val authEndpoint = "https://huggingface.co/oauth/authorize" + private const val tokenEndpoint = "https://huggingface.co/oauth/token" + + // OAuth service configuration (AppAuth library requires this) + val authServiceConfig = AuthorizationServiceConfiguration( + Uri.parse(authEndpoint), // Authorization endpoint + Uri.parse(tokenEndpoint) // Token exchange endpoint + ) +} \ No newline at end of file diff --git a/Android/src/app/src/main/java/com/google/aiedge/gallery/ui/common/DownloadAndTryButton.kt b/Android/src/app/src/main/java/com/google/aiedge/gallery/ui/common/DownloadAndTryButton.kt new file mode 100644 index 0000000..ef2d334 --- /dev/null +++ b/Android/src/app/src/main/java/com/google/aiedge/gallery/ui/common/DownloadAndTryButton.kt @@ -0,0 +1,334 @@ +/* + * Copyright 2025 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.google.aiedge.gallery.ui.common + +import android.content.Intent +import android.net.Uri +import android.util.Log +import androidx.activity.compose.rememberLauncherForActivityResult +import androidx.activity.result.ActivityResultLauncher +import androidx.activity.result.contract.ActivityResultContracts +import androidx.browser.customtabs.CustomTabsIntent +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.wrapContentHeight +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.rounded.ArrowForward +import androidx.compose.material3.Button +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.ModalBottomSheet +import androidx.compose.material3.Text +import androidx.compose.material3.rememberModalBottomSheetState +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.unit.dp +import com.google.aiedge.gallery.data.Model +import com.google.aiedge.gallery.ui.modelmanager.ModelManagerViewModel +import com.google.aiedge.gallery.ui.modelmanager.TokenRequestResultType +import com.google.aiedge.gallery.ui.modelmanager.TokenStatus +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext +import java.net.HttpURLConnection + + +private const val TAG = "AGDownloadAndTryButton" + +// TODO: +// - replace the download button in chat view page with this one, and add a flag to not "onclick" +// just download + +/** + * Handles the "Download & Try it" button click, managing the model download process based on + * various conditions. + * + * If the button is enabled and not currently checking the token, it initiates a coroutine to + * handle the download logic. + * + * For models requiring download first, it specifically addresses HuggingFace URLs by first + * checking if authentication is necessary. If no authentication is needed, the download starts + * directly. Otherwise, it checks the current token status; if the token is invalid or expired, + * a token exchange flow is initiated. If a valid token exists, it attempts to access the + * download URL. If access is granted, the download begins; if not, a new token is requested. + * + * For non-HuggingFace URLs that need downloading, the download starts directly. + * + * If the model doesn't need to be downloaded first, the provided `onClicked` callback is executed. + * + * Additionally, for gated HuggingFace models, if accessing the model after token exchange results + * in a forbidden error, a modal bottom sheet is displayed, prompting the user to acknowledge the + * user agreement by opening it in a custom tab. Upon closing the tab, the download process is + * retried. + * + * The composable also manages UI states for indicating token checking and displaying the agreement + * acknowledgement sheet, and it handles requesting notification permissions before initiating the + * actual download. + */ +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun DownloadAndTryButton( + model: Model, + enabled: Boolean, + needToDownloadFirst: Boolean, + modelManagerViewModel: ModelManagerViewModel, + onClicked: () -> Unit +) { + val scope = rememberCoroutineScope() + val context = LocalContext.current + var checkingToken by remember { mutableStateOf(false) } + var showAgreementAckSheet by remember { mutableStateOf(false) } + val sheetState = rememberModalBottomSheetState() + + // A launcher for requesting notification permission. + val permissionLauncher = rememberLauncherForActivityResult( + ActivityResultContracts.RequestPermission() + ) { + modelManagerViewModel.downloadModel(model) + } + + // Function to kick off download. + val startDownload: (accessToken: String?) -> Unit = { accessToken -> + model.accessToken = accessToken + onClicked() + checkNotificationPermissonAndStartDownload( + context = context, + launcher = permissionLauncher, + modelManagerViewModel = modelManagerViewModel, + model = model + ) + checkingToken = false + } + + // A launcher for opening the custom tabs intent for requesting user agreement ack. + // Once the tab is closed, try starting the download process. + val agreementAckLauncher: ActivityResultLauncher = rememberLauncherForActivityResult( + contract = ActivityResultContracts.StartActivityForResult() + ) { result -> + Log.d(TAG, "User closes the browser tab. Try to start downloading.") + startDownload(modelManagerViewModel.curAccessToken) + } + + // A launcher for handling the authentication flow. + // It processes the result of the authentication activity and then checks if a user agreement + // acknowledgement is needed before proceeding with the model download. + val authResultLauncher = rememberLauncherForActivityResult( + contract = ActivityResultContracts.StartActivityForResult() + ) { result -> + modelManagerViewModel.handleAuthResult(result, onTokenRequested = { tokenRequestResult -> + when (tokenRequestResult.status) { + TokenRequestResultType.SUCCEEDED -> { + Log.d(TAG, "Token request succeeded. Checking if we need user to ack user agreement") + scope.launch(Dispatchers.IO) { + // Check if we can use the current token to access model. If not, we might need to + // acknowledge the user agreement. + if (modelManagerViewModel.getModelUrlResponse( + model = model, + accessToken = modelManagerViewModel.curAccessToken + ) == HttpURLConnection.HTTP_FORBIDDEN + ) { + Log.d(TAG, "Model '${model.name}' needs user agreement ack.") + showAgreementAckSheet = true + } else { + Log.d( + TAG, + "Model '${model.name}' does NOT need user agreement ack. Start downloading..." + ) + withContext(Dispatchers.Main) { + startDownload(modelManagerViewModel.curAccessToken) + } + } + } + } + + TokenRequestResultType.FAILED -> { + Log.d(TAG, "Token request done. Error message: ${tokenRequestResult.errorMessage ?: ""}") + checkingToken = false + } + + TokenRequestResultType.USER_CANCELLED -> { + Log.d(TAG, "User cancelled. Do nothing") + checkingToken = false + } + } + }) + } + + // Function to kick off the authentication and token exchange flow. + val startTokenExchange = { + val authRequest = modelManagerViewModel.getAuthorizationRequest() + val authIntent = modelManagerViewModel.authService.getAuthorizationRequestIntent(authRequest) + authResultLauncher.launch(authIntent) + } + + Button( + onClick = { + if (!enabled || checkingToken) { + return@Button + } + + // Launches a coroutine to handle the initial check and potential authentication flow + // before downloading the model. It checks if the model needs to be downloaded first, + // handles HuggingFace URLs by verifying the need for authentication, and initiates + // the token exchange process if required or proceeds with the download if no auth is needed + // or a valid token is available. + scope.launch(Dispatchers.IO) { + if (needToDownloadFirst) { + // For HuggingFace urls + if (model.url.startsWith("https://huggingface.co")) { + checkingToken = true + + // Check if the url needs auth. + Log.d( + TAG, + "Model '${model.name}' is from HuggingFace. Checking if the url needs auth to download" + ) + if (modelManagerViewModel.getModelUrlResponse(model = model) == HttpURLConnection.HTTP_OK) { + Log.d(TAG, "Model '${model.name}' doesn't need auth. Start downloading the model...") + withContext(Dispatchers.Main) { + startDownload(null) + } + return@launch + } + Log.d(TAG, "Model '${model.name}' needs auth. Start token exchange process...") + + // Get current token status + val tokenStatusAndData = modelManagerViewModel.getTokenStatusAndData() + + when (tokenStatusAndData.status) { + // If token is not stored or expired, log in and request a new token. + TokenStatus.NOT_STORED, TokenStatus.EXPIRED -> { + withContext(Dispatchers.Main) { + startTokenExchange() + } + } + + // If token is still valid... + TokenStatus.NOT_EXPIRED -> { + // Use the current token to check the download url. + Log.d(TAG, "Checking the download url '${model.url}' with the current token...") + val responseCode = modelManagerViewModel.getModelUrlResponse( + model = model, accessToken = tokenStatusAndData.data!!.accessToken + ) + if (responseCode == HttpURLConnection.HTTP_OK) { + // Download url is accessible. Download the model. + Log.d(TAG, "Download url is accessible with the current token.") + + withContext(Dispatchers.Main) { + startDownload(tokenStatusAndData.data.accessToken) + } + } + // Download url is NOT accessible. Request a new token. + else { + Log.d( + TAG, + "Download url is NOT accessible. Response code: ${responseCode}. Trying to request a new token." + ) + + withContext(Dispatchers.Main) { + startTokenExchange() + } + } + } + } + } + // For other urls, just download the model. + else { + Log.d( + TAG, + "Model '${model.name}' is not from huggingface. Start downloading the model..." + ) + withContext(Dispatchers.Main) { + startDownload(null) + } + } + } else { + withContext(Dispatchers.Main) { + onClicked() + } + } + } + }, + ) { + Icon( + Icons.AutoMirrored.Rounded.ArrowForward, + contentDescription = "", + modifier = Modifier.padding(end = 4.dp) + ) + + if (checkingToken) { + Text("Checking access...") + } else { + if (needToDownloadFirst) { + Text("Download & Try it", maxLines = 1) + } else { + Text("Try it", maxLines = 1) + } + } + } + + // A ModalBottomSheet composable that displays information about the user agreement + // for a gated model and provides a button to open the agreement in a custom tab. + // Upon clicking the button, it constructs the agreement URL, launches it using a + // custom tab, and then dismisses the bottom sheet. + if (showAgreementAckSheet) { + ModalBottomSheet( + onDismissRequest = { + showAgreementAckSheet = false + checkingToken = false + }, + sheetState = sheetState, + modifier = Modifier.wrapContentHeight(), + ) { + Column( + horizontalAlignment = Alignment.CenterHorizontally, + modifier = Modifier.padding(horizontal = 16.dp) + ) { + Text("Acknowledge user agreement", style = MaterialTheme.typography.titleLarge) + Text( + "This is a gated model. Please click the button below to view and agree to the user agreement. After accepting, simply close that tab to proceed with the model download.", + style = MaterialTheme.typography.bodyMedium, + modifier = Modifier.padding(vertical = 16.dp) + ) + Button(onClick = { + // Get agreement url from model url. + val index = model.url.indexOf("/resolve/") + // Show it in a tab. + if (index >= 0) { + val agreementUrl = model.url.substring(0, index) + + val customTabsIntent = CustomTabsIntent.Builder().build() + customTabsIntent.intent.setData(Uri.parse(agreementUrl)) + agreementAckLauncher.launch(customTabsIntent.intent) + } + // Dismiss the sheet. + showAgreementAckSheet = false + }) { + Text("Open user agreement") + } + } + } + } +} diff --git a/Android/src/app/src/main/java/com/google/aiedge/gallery/ui/common/TaskIcon.kt b/Android/src/app/src/main/java/com/google/aiedge/gallery/ui/common/TaskIcon.kt new file mode 100644 index 0000000..02a5854 --- /dev/null +++ b/Android/src/app/src/main/java/com/google/aiedge/gallery/ui/common/TaskIcon.kt @@ -0,0 +1,105 @@ +/* + * Copyright 2025 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.google.aiedge.gallery.ui.common + +import androidx.compose.foundation.Image +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.aspectRatio +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.alpha +import androidx.compose.ui.graphics.BlendMode +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.ColorFilter +import androidx.compose.ui.graphics.painter.Painter +import androidx.compose.ui.graphics.vector.ImageVector +import androidx.compose.ui.layout.ContentScale +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.res.vectorResource +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.dp +import com.google.aiedge.gallery.R +import com.google.aiedge.gallery.data.TASKS +import com.google.aiedge.gallery.data.TASK_LLM_CHAT +import com.google.aiedge.gallery.data.Task +import com.google.aiedge.gallery.ui.theme.GalleryTheme +import com.google.aiedge.gallery.ui.theme.customColors + +private val SHAPES: List = + listOf(R.drawable.pantegon, R.drawable.double_circle, R.drawable.circle, R.drawable.four_circle) + +/** + * Composable that displays an icon representing a task. It consists of a background + * image and a foreground icon, both centered within a square box. + */ +@Composable +fun TaskIcon(task: Task, modifier: Modifier = Modifier, width: Dp = 56.dp) { + Box( + modifier = modifier + .width(width) + .aspectRatio(1f), + contentAlignment = Alignment.Center, + ) { + Image( + painter = getTaskIconBgShape(task = task), + contentDescription = "", + modifier = Modifier + .fillMaxSize() + .alpha(0.6f), + contentScale = ContentScale.Fit, + colorFilter = ColorFilter.tint( + MaterialTheme.customColors.taskIconShapeBgColor, + blendMode = BlendMode.SrcIn + ) + ) + Icon( + task.icon ?: ImageVector.vectorResource(task.iconVectorResourceId!!), + tint = getTaskIconColor(task = task), + modifier = Modifier.size(width * 0.6f), + contentDescription = "", + ) + } +} + +@Composable +private fun getTaskIconBgShape(task: Task): Painter { + val colorIndex: Int = task.index % SHAPES.size + return painterResource(SHAPES[colorIndex]) +} + +@Preview(showBackground = true) +@Composable +fun TaskIconPreview() { + for ((index, task) in TASKS.withIndex()) { + task.index = index + } + + GalleryTheme { + Column(modifier = Modifier.background(Color.Gray)) { + TaskIcon(task = TASK_LLM_CHAT, width = 80.dp) + } + } +} \ No newline at end of file diff --git a/Android/src/app/src/main/java/com/google/aiedge/gallery/ui/common/Utils.kt b/Android/src/app/src/main/java/com/google/aiedge/gallery/ui/common/Utils.kt new file mode 100644 index 0000000..5059527 --- /dev/null +++ b/Android/src/app/src/main/java/com/google/aiedge/gallery/ui/common/Utils.kt @@ -0,0 +1,442 @@ +/* + * Copyright 2025 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.google.aiedge.gallery.ui.common + +import android.Manifest +import android.content.Context +import android.content.pm.PackageManager +import android.net.Uri +import android.os.Build +import androidx.activity.compose.ManagedActivityResultLauncher +import androidx.compose.material3.MaterialTheme +import androidx.compose.runtime.Composable +import androidx.compose.ui.graphics.Color +import androidx.core.content.ContextCompat +import androidx.core.content.FileProvider +import com.google.aiedge.gallery.data.Config +import com.google.aiedge.gallery.data.Model +import com.google.aiedge.gallery.data.Task +import com.google.aiedge.gallery.data.ValueType +import com.google.aiedge.gallery.ui.common.chat.ChatMessageBenchmarkResult +import com.google.aiedge.gallery.ui.common.chat.ChatMessageType +import com.google.aiedge.gallery.ui.common.chat.ChatViewModel +import com.google.aiedge.gallery.ui.common.chat.Histogram +import com.google.aiedge.gallery.ui.common.chat.Stat +import com.google.aiedge.gallery.ui.modelmanager.ModelManagerViewModel +import com.google.aiedge.gallery.ui.theme.customColors +import java.io.File +import kotlin.math.abs +import kotlin.math.ln +import kotlin.math.max +import kotlin.math.min +import kotlin.math.pow +import kotlin.math.sqrt + +private val STATS = listOf( + Stat(id = "min", label = "Min", unit = "ms"), + Stat(id = "max", label = "Max", unit = "ms"), + Stat(id = "avg", label = "Avg", unit = "ms"), + Stat(id = "stddev", label = "Stddev", unit = "ms") +) + +interface LatencyProvider { + val latencyMs: Float +} + +/** Format the bytes into a human-readable format. */ +fun Long.humanReadableSize(si: Boolean = true, extraDecimalForGbAndAbove: Boolean = false): String { + val bytes = this + + val unit = if (si) 1000 else 1024 + if (bytes < unit) return "$bytes B" + val exp = (ln(bytes.toDouble()) / ln(unit.toDouble())).toInt() + val pre = (if (si) "kMGTPE" else "KMGTPE")[exp - 1] + if (si) "" else "i" + var formatString = "%.1f %sB" + if (extraDecimalForGbAndAbove && pre.lowercase() != "k" && pre != "M") { + formatString = "%.2f %sB" + } + return formatString.format(bytes / unit.toDouble().pow(exp.toDouble()), pre) +} + +fun Float.humanReadableDuration(): String { + val milliseconds = this + if (milliseconds < 1000) { + return "$milliseconds ms" + } + val seconds = milliseconds / 1000f + if (seconds < 60) { + return "%.1f s".format(seconds) + } + + val minutes = seconds / 60f + if (minutes < 60) { + return "%.1f min".format(minutes) + } + + val hours = minutes / 60f + return "%.1f h".format(hours) +} + +fun Long.formatToHourMinSecond(): String { + val ms = this + if (ms < 0) { + return "-" + } + + val seconds = ms / 1000 + val hours = seconds / 3600 + val minutes = (seconds % 3600) / 60 + val remainingSeconds = seconds % 60 + + val parts = mutableListOf() + + if (hours > 0) { + parts.add("$hours h") + } + if (minutes > 0) { + parts.add("$minutes min") + } + if (remainingSeconds > 0 || (hours == 0L && minutes == 0L)) { + parts.add("$remainingSeconds sec") + } + + return parts.joinToString(" ") +} + +fun convertValueToTargetType(value: Any, valueType: ValueType): Any { + return when (valueType) { + ValueType.INT -> when (value) { + is Int -> value + is Float -> value.toInt() + is Double -> value.toInt() + is String -> value.toIntOrNull() ?: "" + is Boolean -> if (value) 1 else 0 + else -> "" + } + + ValueType.FLOAT -> when (value) { + is Int -> value.toFloat() + is Float -> value + is Double -> value.toFloat() + is String -> value.toFloatOrNull() ?: "" + is Boolean -> if (value) 1f else 0f + else -> "" + } + + ValueType.DOUBLE -> when (value) { + is Int -> value.toDouble() + is Float -> value.toDouble() + is Double -> value + is String -> value.toDoubleOrNull() ?: "" + is Boolean -> if (value) 1.0 else 0.0 + else -> "" + } + + ValueType.BOOLEAN -> when (value) { + is Int -> value == 0 + is Boolean -> value + is Float -> abs(value) > 1e-6 + is Double -> abs(value) > 1e-6 + is String -> value.isNotEmpty() + else -> false + } + + ValueType.STRING -> value.toString() + } +} + +fun getDistinctiveColor(index: Int): Color { + val colors = listOf( +// Color(0xffe6194b), + Color(0xff3cb44b), + Color(0xffffe119), + Color(0xff4363d8), + Color(0xfff58231), + Color(0xff911eb4), + Color(0xff46f0f0), + Color(0xfff032e6), + Color(0xffbcf60c), + Color(0xfffabebe), + Color(0xff008080), + Color(0xffe6beff), + Color(0xff9a6324), + Color(0xfffffac8), + Color(0xff800000), + Color(0xffaaffc3), + Color(0xff808000), + Color(0xffffd8b1), + Color(0xff000075) + ) + return colors[index % colors.size] +} + +fun Context.createTempPictureUri( + fileName: String = "picture_${System.currentTimeMillis()}", fileExtension: String = ".png" +): Uri { + val tempFile = File.createTempFile( + fileName, fileExtension, cacheDir + ).apply { + createNewFile() + } + + return FileProvider.getUriForFile( + applicationContext, "com.google.aiedge.gallery.provider", tempFile + ) +} + +fun runBasicBenchmark( + model: Model, + warmupCount: Int, + iterations: Int, + chatViewModel: ChatViewModel, + inferenceFn: () -> LatencyProvider, + chatMessageType: ChatMessageType, +) { + val start = System.currentTimeMillis() + var lastUpdateTs = 0L + val update: (ChatMessageBenchmarkResult) -> Unit = { message -> + if (lastUpdateTs == 0L) { + chatViewModel.addMessage( + model = model, + message = message, + ) + lastUpdateTs = System.currentTimeMillis() + } else { + val curTs = System.currentTimeMillis() + if (curTs - lastUpdateTs > 500) { + chatViewModel.replaceLastMessage(model = model, message = message, type = chatMessageType) + lastUpdateTs = curTs + } + } + } + + // Warmup. + val latencies: MutableList = mutableListOf() + for (count in 1..warmupCount) { + inferenceFn() + update( + ChatMessageBenchmarkResult( + orderedStats = STATS, + statValues = calculateStats(min = 0f, max = 0f, sum = 0f, latencies = latencies), + histogram = calculateLatencyHistogram( + latencies = latencies, min = 0f, max = 0f, avg = 0f + ), + values = latencies, + warmupCurrent = count, + warmupTotal = warmupCount, + iterationCurrent = 0, + iterationTotal = iterations, + latencyMs = (System.currentTimeMillis() - start).toFloat(), + highlightStat = "avg" + ) + ) + } + + // Benchmark iterations. + var min = Float.MAX_VALUE + var max = 0f + var sum = 0f + for (count in 1..iterations) { + val result = inferenceFn() + val latency = result.latencyMs + min = min(min, latency) + max = max(max, latency) + sum += latency + latencies.add(latency) + + val curTs = System.currentTimeMillis() + if (curTs - lastUpdateTs > 500 || count == iterations) { + lastUpdateTs = curTs + + val stats = calculateStats(min = min, max = max, sum = sum, latencies = latencies) + chatViewModel.replaceLastMessage( + model = model, + message = ChatMessageBenchmarkResult( + orderedStats = STATS, + statValues = stats, + histogram = calculateLatencyHistogram( + latencies = latencies, + min = min, + max = max, + avg = stats["avg"] ?: 0f, + ), + values = latencies, + warmupCurrent = warmupCount, + warmupTotal = warmupCount, + iterationCurrent = count, + iterationTotal = iterations, + latencyMs = (System.currentTimeMillis() - start).toFloat(), + highlightStat = "avg" + ), + type = chatMessageType, + ) + } + + // Go through other benchmark messages and update their buckets for the common min/max values. + var allMin = Float.MAX_VALUE + var allMax = 0f + val allMessages = chatViewModel.uiState.value.messagesByModel[model.name] ?: listOf() + for (message in allMessages) { + if (message is ChatMessageBenchmarkResult) { + val curMin = message.statValues["min"] ?: 0f + val curMax = message.statValues["max"] ?: 0f + allMin = min(allMin, curMin) + allMax = max(allMax, curMax) + } + } + + for ((index, message) in allMessages.withIndex()) { + if (message === allMessages.last()) { + break + } + if (message is ChatMessageBenchmarkResult) { + val updatedMessage = ChatMessageBenchmarkResult( + orderedStats = STATS, + statValues = message.statValues, + histogram = calculateLatencyHistogram( + latencies = message.values, + min = allMin, + max = allMax, + avg = message.statValues["avg"] ?: 0f, + ), + values = message.values, + warmupCurrent = message.warmupCurrent, + warmupTotal = message.warmupTotal, + iterationCurrent = message.iterationCurrent, + iterationTotal = message.iterationTotal, + latencyMs = message.latencyMs, + highlightStat = "avg" + ) + chatViewModel.replaceMessage(model = model, index = index, message = updatedMessage) + } + } + } +} + +private fun calculateStats( + min: Float, max: Float, sum: Float, latencies: MutableList +): MutableMap { + val avg = if (latencies.size == 0) 0f else sum / latencies.size + val squaredDifferences = latencies.map { (it - avg).pow(2) } + val variance = squaredDifferences.average() + val stddev = if (latencies.size == 0) 0f else sqrt(variance).toFloat() + var medium = 0f + if (latencies.size == 1) { + medium = latencies[0] + } else if (latencies.size > 1) { + latencies.sort() + val middle = latencies.size / 2 + medium = + if (latencies.size % 2 == 0) (latencies[middle - 1] + latencies[middle]) / 2.0f else latencies[middle] + } + return mutableMapOf( + "min" to min, "max" to max, "avg" to avg, "stddev" to stddev, "medium" to medium + ) +} + +fun calculateLatencyHistogram( + latencies: List, min: Float, max: Float, avg: Float, numBuckets: Int = 20 +): Histogram { + if (latencies.isEmpty() || numBuckets <= 0) { + return Histogram( + buckets = List(numBuckets) { 0 }, maxCount = 0 + ) + } + + if (min == max) { + // All latencies are the same. + val result = MutableList(numBuckets) { 0 } + result[0] = latencies.size + return Histogram(buckets = result, maxCount = result[0], highlightBucketIndex = 0) + } + + val bucketSize = (max - min) / numBuckets + + val histogram = MutableList(numBuckets) { 0 } + + val getBucketIndex: (value: Float) -> Int = { + var bucketIndex = ((it - min) / bucketSize).toInt() + // Handle the case where latency equals maxLatency + if (bucketIndex == numBuckets) { + bucketIndex = numBuckets - 1 + } + bucketIndex + } + + for (latency in latencies) { + val bucketIndex = getBucketIndex(latency) + histogram[bucketIndex]++ + } + + val avgBucketIndex = getBucketIndex(avg) + return Histogram( + buckets = histogram, + maxCount = histogram.maxOrNull() ?: 0, + highlightBucketIndex = avgBucketIndex + ) +} + +fun getConfigValueString(value: Any, config: Config): String { + var strNewValue = "$value" + if (config.valueType == ValueType.FLOAT) { + strNewValue = "%.2f".format(value) + } + return strNewValue +} + +@Composable +fun getTaskBgColor(task: Task): Color { + val colorIndex: Int = task.index % MaterialTheme.customColors.taskBgColors.size + return MaterialTheme.customColors.taskBgColors[colorIndex] +} + +@Composable +fun getTaskIconColor(task: Task): Color { + val colorIndex: Int = task.index % MaterialTheme.customColors.taskIconColors.size + return MaterialTheme.customColors.taskIconColors[colorIndex] +} + +@Composable +fun getTaskIconColor(index: Int): Color { + val colorIndex: Int = index % MaterialTheme.customColors.taskIconColors.size + return MaterialTheme.customColors.taskIconColors[colorIndex] +} + +fun checkNotificationPermissonAndStartDownload( + context: Context, + launcher: ManagedActivityResultLauncher, + modelManagerViewModel: ModelManagerViewModel, + model: Model +) { + // Check permission + when (PackageManager.PERMISSION_GRANTED) { + // Already got permission. Call the lambda. + ContextCompat.checkSelfPermission( + context, Manifest.permission.POST_NOTIFICATIONS + ) -> { + modelManagerViewModel.downloadModel(model) + } + + // Otherwise, ask for permission + else -> { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { + launcher.launch(Manifest.permission.POST_NOTIFICATIONS) + } + } + } +} + diff --git a/Android/src/app/src/main/java/com/google/aiedge/gallery/ui/common/chat/BenchmarkConfigDialog.kt b/Android/src/app/src/main/java/com/google/aiedge/gallery/ui/common/chat/BenchmarkConfigDialog.kt new file mode 100644 index 0000000..79931ad --- /dev/null +++ b/Android/src/app/src/main/java/com/google/aiedge/gallery/ui/common/chat/BenchmarkConfigDialog.kt @@ -0,0 +1,102 @@ +/* + * Copyright 2025 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.google.aiedge.gallery.ui.common.chat + +import androidx.compose.runtime.Composable +import androidx.compose.ui.tooling.preview.Preview +import com.google.aiedge.gallery.data.Config +import com.google.aiedge.gallery.data.ConfigKey +import com.google.aiedge.gallery.data.NumberSliderConfig +import com.google.aiedge.gallery.data.ValueType +import com.google.aiedge.gallery.ui.common.convertValueToTargetType +import com.google.aiedge.gallery.ui.theme.GalleryTheme + +private const val DEFAULT_BENCHMARK_WARM_UP_ITERATIONS = 50f +private const val DEFAULT_BENCHMARK_ITERATIONS = 200f + +private val BENCHMARK_CONFIGS: List = listOf( + NumberSliderConfig( + key = ConfigKey.WARM_UP_ITERATIONS, + sliderMin = 10f, + sliderMax = 200f, + defaultValue = DEFAULT_BENCHMARK_WARM_UP_ITERATIONS, + valueType = ValueType.INT + ), + NumberSliderConfig( + key = ConfigKey.BENCHMARK_ITERATIONS, + sliderMin = 50f, + sliderMax = 500f, + defaultValue = DEFAULT_BENCHMARK_ITERATIONS, + valueType = ValueType.INT + ), +) + +private val BENCHMARK_CONFIGS_INITIAL_VALUES = mapOf( + ConfigKey.WARM_UP_ITERATIONS.label to DEFAULT_BENCHMARK_WARM_UP_ITERATIONS, + ConfigKey.BENCHMARK_ITERATIONS.label to DEFAULT_BENCHMARK_ITERATIONS +) + +/** + * Composable function to display a configuration dialog for benchmarking a chat message. + * + * This function renders a configuration dialog specifically tailored for setting up + * benchmark parameters. It allows users to specify warm-up and benchmark iterations + * before running a benchmark test on a given chat message. + */ +@Composable +fun BenchmarkConfigDialog( + onDismissed: () -> Unit, + messageToBenchmark: ChatMessage?, + onBenchmarkClicked: (ChatMessage, warmUpIterations: Int, benchmarkIterations: Int) -> Unit +) { + ConfigDialog( + title = "Benchmark configs", + okBtnLabel = "Start", + configs = BENCHMARK_CONFIGS, + initialValues = BENCHMARK_CONFIGS_INITIAL_VALUES, + onDismissed = onDismissed, + onOk = { curConfigValues -> + // Hide config dialog. + onDismissed() + + // Start benchmark. + messageToBenchmark?.let { message -> + val warmUpIterations = convertValueToTargetType( + value = curConfigValues.getValue(ConfigKey.WARM_UP_ITERATIONS.label), + valueType = ValueType.INT + ) as Int + val benchmarkIterations = convertValueToTargetType( + value = curConfigValues.getValue(ConfigKey.BENCHMARK_ITERATIONS.label), + valueType = ValueType.INT + ) as Int + onBenchmarkClicked(message, warmUpIterations, benchmarkIterations) + } + }, + ) +} + +@Preview(showBackground = true) +@Composable +fun BenchmarkConfigDialogPreview() { + GalleryTheme { + BenchmarkConfigDialog( + onDismissed = {}, + messageToBenchmark = null, + onBenchmarkClicked = { _, _, _ -> } + ) + } +} \ No newline at end of file diff --git a/Android/src/app/src/main/java/com/google/aiedge/gallery/ui/common/chat/ChatMessage.kt b/Android/src/app/src/main/java/com/google/aiedge/gallery/ui/common/chat/ChatMessage.kt new file mode 100644 index 0000000..c47b7ab --- /dev/null +++ b/Android/src/app/src/main/java/com/google/aiedge/gallery/ui/common/chat/ChatMessage.kt @@ -0,0 +1,180 @@ +/* + * Copyright 2025 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.google.aiedge.gallery.ui.common.chat + +import android.graphics.Bitmap +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.ImageBitmap +import androidx.compose.ui.unit.Dp +import com.google.aiedge.gallery.data.Model + +enum class ChatMessageType { + INFO, + TEXT, + IMAGE, + IMAGE_WITH_HISTORY, + LOADING, + CLASSIFICATION, + CONFIG_VALUES_CHANGE, + BENCHMARK_RESULT, + BENCHMARK_LLM_RESULT, + PROMPT_TEMPLATES +} + +enum class ChatSide { + USER, AGENT, SYSTEM +} + +data class Classification(val label: String, val score: Float, val color: Color) + +/** Base class for a chat message. */ +open class ChatMessage( + open val type: ChatMessageType, open val side: ChatSide, open val latencyMs: Float = -1f +) { + open fun clone(): ChatMessage { + return ChatMessage(type = type, side = side, latencyMs = latencyMs) + } +} + +/** Chat message for showing loading status. */ +class ChatMessageLoading : ChatMessage(type = ChatMessageType.LOADING, side = ChatSide.AGENT) + +/** Chat message for info (help). */ +class ChatMessageInfo(val content: String) : + ChatMessage(type = ChatMessageType.INFO, side = ChatSide.SYSTEM) + +/** Chat message for config values change. */ +class ChatMessageConfigValuesChange( + val model: Model, + val oldValues: Map, + val newValues: Map +) : ChatMessage(type = ChatMessageType.CONFIG_VALUES_CHANGE, side = ChatSide.SYSTEM) + +/** Chat message for plain text. */ +open class ChatMessageText( + val content: String, + override val side: ChatSide, + // Negative numbers will hide the latency display. + override val latencyMs: Float = 0f, + val isMarkdown: Boolean = true, +) : ChatMessage(type = ChatMessageType.TEXT, side = side, latencyMs = latencyMs) { + override fun clone(): ChatMessageText { + return ChatMessageText( + content = content, + side = side, + latencyMs = latencyMs, + isMarkdown = isMarkdown + ) + } +} + +/** Chat message for images. */ +class ChatMessageImage( + val bitmap: Bitmap, + val imageBitMap: ImageBitmap, + override val side: ChatSide, + override val latencyMs: Float = 0f +) : + ChatMessage(type = ChatMessageType.IMAGE, side = side, latencyMs = latencyMs) { + override fun clone(): ChatMessageImage { + return ChatMessageImage( + bitmap = bitmap, + imageBitMap = imageBitMap, + side = side, + latencyMs = latencyMs + ) + } +} + +/** Chat message for images with history. */ +class ChatMessageImageWithHistory( + val bitmaps: List, + val imageBitMaps: List, + val totalIterations: Int, + override val side: ChatSide, + override val latencyMs: Float = 0f, + var curIteration: Int = 0, // 0-based +) : + ChatMessage(type = ChatMessageType.IMAGE_WITH_HISTORY, side = side, latencyMs = latencyMs) { + fun isRunning(): Boolean { + return curIteration < totalIterations - 1 + } +} + +/** Chat message for showing classification result. */ +class ChatMessageClassification( + val classifications: List, + override val latencyMs: Float = 0f, + // Typical android phone width is > 320dp + val maxBarWidth: Dp? = null, +) : ChatMessage(type = ChatMessageType.CLASSIFICATION, side = ChatSide.AGENT, latencyMs = latencyMs) + +/** A stat used in benchmark result. */ +data class Stat(val id: String, val label: String, val unit: String) + +/** Chat message for showing benchmark result. */ +class ChatMessageBenchmarkResult( + val orderedStats: List, + val statValues: MutableMap, + val values: List, + val histogram: Histogram, + val warmupCurrent: Int, + val warmupTotal: Int, + val iterationCurrent: Int, + val iterationTotal: Int, + override val latencyMs: Float = 0f, + val highlightStat: String = "", +) : + ChatMessage( + type = ChatMessageType.BENCHMARK_RESULT, + side = ChatSide.AGENT, + latencyMs = latencyMs + ) { + fun isWarmingUp(): Boolean { + return warmupCurrent < warmupTotal + } + + fun isRunning(): Boolean { + return iterationCurrent < iterationTotal + } +} + +/** Chat message for showing LLM benchmark result. */ +class ChatMessageBenchmarkLlmResult( + val orderedStats: List, + val statValues: MutableMap, + val running: Boolean, + override val latencyMs: Float = 0f, +) : ChatMessage( + type = ChatMessageType.BENCHMARK_LLM_RESULT, + side = ChatSide.AGENT, + latencyMs = latencyMs +) + +data class Histogram( + val buckets: List, + val maxCount: Int, + val highlightBucketIndex: Int = -1 +) + +/** Chat message for showing prompt templates. */ +class ChatMessagePromptTemplates( + val templates: List, + val showMakeYourOwn: Boolean = true, +) : ChatMessage(type = ChatMessageType.PROMPT_TEMPLATES, side = ChatSide.SYSTEM) + +data class PromptTemplate(val title: String, val description: String, val prompt: String) \ No newline at end of file diff --git a/Android/src/app/src/main/java/com/google/aiedge/gallery/ui/common/chat/ChatPanel.kt b/Android/src/app/src/main/java/com/google/aiedge/gallery/ui/common/chat/ChatPanel.kt new file mode 100644 index 0000000..8211b7c --- /dev/null +++ b/Android/src/app/src/main/java/com/google/aiedge/gallery/ui/common/chat/ChatPanel.kt @@ -0,0 +1,491 @@ +/* + * Copyright 2025 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.google.aiedge.gallery.ui.common.chat + +import androidx.compose.animation.AnimatedVisibility +import androidx.compose.animation.fadeIn +import androidx.compose.animation.fadeOut +import androidx.compose.animation.scaleIn +import androidx.compose.animation.scaleOut +import androidx.compose.foundation.background +import androidx.compose.foundation.clickable +import androidx.compose.foundation.gestures.detectTapGestures +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.WindowInsets +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.ime +import androidx.compose.foundation.layout.imePadding +import androidx.compose.foundation.layout.offset +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.layout.wrapContentHeight +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.items +import androidx.compose.foundation.lazy.rememberLazyListState +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.outlined.Timer +import androidx.compose.material.icons.rounded.ContentCopy +import androidx.compose.material.icons.rounded.Refresh +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.ModalBottomSheet +import androidx.compose.material3.SnackbarHost +import androidx.compose.material3.SnackbarHostState +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.MutableState +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableIntStateOf +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.asImageBitmap +import androidx.compose.ui.hapticfeedback.HapticFeedbackType +import androidx.compose.ui.input.nestedscroll.NestedScrollConnection +import androidx.compose.ui.input.nestedscroll.NestedScrollSource +import androidx.compose.ui.input.nestedscroll.nestedScroll +import androidx.compose.ui.input.pointer.pointerInput +import androidx.compose.ui.platform.LocalClipboardManager +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.platform.LocalDensity +import androidx.compose.ui.platform.LocalFocusManager +import androidx.compose.ui.platform.LocalHapticFeedback +import androidx.compose.ui.res.dimensionResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.AnnotatedString +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import com.google.aiedge.gallery.R +import com.google.aiedge.gallery.data.Model +import com.google.aiedge.gallery.data.Task +import com.google.aiedge.gallery.data.TaskType +import com.google.aiedge.gallery.ui.modelmanager.ModelInitializationStatus +import com.google.aiedge.gallery.ui.modelmanager.ModelManagerViewModel +import com.google.aiedge.gallery.ui.preview.PreviewChatModel +import com.google.aiedge.gallery.ui.preview.PreviewModelManagerViewModel +import com.google.aiedge.gallery.ui.preview.TASK_TEST1 +import com.google.aiedge.gallery.ui.theme.GalleryTheme +import com.google.aiedge.gallery.ui.theme.customColors +import kotlinx.coroutines.launch + +enum class ChatInputType { + TEXT, IMAGE, +} + +/** + * Composable function for the main chat panel, displaying messages and handling user input. + */ +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun ChatPanel( + modelManagerViewModel: ModelManagerViewModel, + task: Task, + selectedModel: Model, + viewModel: ChatViewModel, + onSendMessage: (Model, ChatMessage) -> Unit, + onRunAgainClicked: (Model, ChatMessage) -> Unit, + onBenchmarkClicked: (Model, ChatMessage, warmUpIterations: Int, benchmarkIterations: Int) -> Unit, + modifier: Modifier = Modifier, + onStreamImageMessage: (Model, ChatMessageImage) -> Unit = { _, _ -> }, + onStreamEnd: (Int) -> Unit = {}, + onStopButtonClicked: () -> Unit = {}, + chatInputType: ChatInputType = ChatInputType.TEXT, + showStopButtonInInputWhenInProgress: Boolean = false, +) { + val uiState by viewModel.uiState.collectAsState() + val modelManagerUiState by modelManagerViewModel.uiState.collectAsState() + val messages = uiState.messagesByModel[selectedModel.name] ?: listOf() + val streamingMessage = uiState.streamingMessagesByModel[selectedModel.name] + val snackbarHostState = remember { SnackbarHostState() } + val scope = rememberCoroutineScope() + val haptic = LocalHapticFeedback.current + + var curMessage by remember { mutableStateOf("") } // Correct state + val focusManager = LocalFocusManager.current + + // Remember the LazyListState to control scrolling + val listState = rememberLazyListState() + val density = LocalDensity.current + var showBenchmarkConfigsDialog by remember { mutableStateOf(false) } + val benchmarkMessage: MutableState = remember { mutableStateOf(null) } + + var showMessageLongPressedSheet by remember { mutableStateOf(false) } + val longPressedMessage: MutableState = remember { mutableStateOf(null) } + + // Keep track of the last message and last message content. + val lastMessage: MutableState = remember { mutableStateOf(null) } + val lastMessageContent: MutableState = remember { mutableStateOf("") } + if (messages.isNotEmpty()) { + val tmpLastMessage = messages.last() + lastMessage.value = tmpLastMessage + if (tmpLastMessage is ChatMessageText) { + lastMessageContent.value = tmpLastMessage.content + } + } + + // Scroll the content to the bottom when any of these changes. + LaunchedEffect( + messages.size, + lastMessage.value, + lastMessageContent.value, + WindowInsets.ime.getBottom(density), + ) { + if (messages.isNotEmpty()) { + listState.animateScrollToItem(messages.lastIndex, scrollOffset = 10000) + } + } + + val nestedScrollConnection = remember { + object : NestedScrollConnection { + override fun onPreScroll(available: Offset, source: NestedScrollSource): Offset { + // If downward scroll, clear the focus from any currently focused composable. + // This is useful for dismissing software keyboards or hiding text input fields + // when the user starts scrolling down a list. + if (available.y > 0) { + focusManager.clearFocus() + } + // Let LazyColumn handle the scroll + return Offset.Zero + } + } + } + + val modelInitializationStatus = + modelManagerUiState.modelInitializationStatus[selectedModel.name] + + Column( + modifier = modifier.imePadding() + ) { + Box(contentAlignment = Alignment.BottomCenter, modifier = Modifier.weight(1f)) { + LazyColumn( + modifier = Modifier + .fillMaxSize() + .nestedScroll(nestedScrollConnection), + state = listState, verticalArrangement = Arrangement.Top, + ) { + items(messages) { message -> + val imageHistoryCurIndex = remember { mutableIntStateOf(0) } + var hAlign: Alignment.Horizontal = Alignment.End + var backgroundColor: Color = MaterialTheme.customColors.userBubbleBgColor + var hardCornerAtLeftOrRight = false + var extraPaddingStart = 48.dp + var extraPaddingEnd = 0.dp + if (message.side == ChatSide.AGENT) { + hAlign = Alignment.Start + backgroundColor = MaterialTheme.customColors.agentBubbleBgColor + hardCornerAtLeftOrRight = true + extraPaddingStart = 0.dp + extraPaddingEnd = 48.dp + } else if (message.side == ChatSide.SYSTEM) { + extraPaddingStart = 24.dp + extraPaddingEnd = 24.dp + if (message.type == ChatMessageType.PROMPT_TEMPLATES) { + extraPaddingStart = 12.dp + extraPaddingEnd = 12.dp + } + } + if (message.type == ChatMessageType.IMAGE) { + backgroundColor = Color.Transparent + } + val bubbleBorderRadius = dimensionResource(R.dimen.chat_bubble_corner_radius) + + Column( + modifier = Modifier + .fillMaxWidth() + .padding( + start = 12.dp + extraPaddingStart, + end = 12.dp + extraPaddingEnd, + top = 6.dp, + bottom = 6.dp, + ), + horizontalAlignment = hAlign, + ) { + // Sender row. + MessageSender( + message = message, + agentNameRes = task.agentNameRes, + imageHistoryCurIndex = imageHistoryCurIndex.intValue + ) + + // Message body. + when (message) { + // Loading. + is ChatMessageLoading -> MessageBodyLoading() + + // Info. + is ChatMessageInfo -> MessageBodyInfo(message = message) + + // Config values change. + is ChatMessageConfigValuesChange -> MessageBodyConfigUpdate(message = message) + + // Prompt templates. + is ChatMessagePromptTemplates -> MessageBodyPromptTemplates(message = message, + task = task, + onPromptClicked = { template -> + onSendMessage( + selectedModel, ChatMessageText(content = template.prompt, side = ChatSide.USER) + ) + }) + + // Non-system messages. + else -> { + // The bubble shape around the message body. + var messageBubbleModifier = Modifier + .clip( + MessageBubbleShape( + radius = bubbleBorderRadius, + hardCornerAtLeftOrRight = hardCornerAtLeftOrRight + ) + ) + .background(backgroundColor) + if (message is ChatMessageText) { + messageBubbleModifier = messageBubbleModifier + .pointerInput(Unit) { + detectTapGestures( + onLongPress = { + haptic.performHapticFeedback(HapticFeedbackType.LongPress) + longPressedMessage.value = message + showMessageLongPressedSheet = true + }, + ) + } + } + Box( + modifier = messageBubbleModifier, + ) { + when (message) { + // Text + is ChatMessageText -> MessageBodyText(message = message) + + // Image + is ChatMessageImage -> MessageBodyImage(message = message) + + // Image with history (for image gen) + is ChatMessageImageWithHistory -> MessageBodyImageWithHistory( + message = message, imageHistoryCurIndex = imageHistoryCurIndex + ) + + // Classification result + is ChatMessageClassification -> MessageBodyClassification( + message = message, + modifier = Modifier.width(message.maxBarWidth ?: CLASSIFICATION_BAR_MAX_WIDTH) + ) + + // Benchmark result. + is ChatMessageBenchmarkResult -> MessageBodyBenchmark(message = message) + + // Benchmark LLM result. + is ChatMessageBenchmarkLlmResult -> MessageBodyBenchmarkLlm(message = message) + + else -> {} + } + } + if (message.side == ChatSide.AGENT) { + LatencyText(message = message) + } else if (message.side == ChatSide.USER) { + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(4.dp) + ) { + // Run again button. + if (selectedModel.showRunAgainButton) { + MessageActionButton( + label = stringResource(R.string.run_again), + icon = Icons.Rounded.Refresh, + onClick = { + onRunAgainClicked(selectedModel, message) + }, + enabled = !uiState.inProgress + ) + } + + // Benchmark button + if (selectedModel.showBenchmarkButton) { + MessageActionButton( + label = stringResource(R.string.benchmark), + icon = Icons.Outlined.Timer, + onClick = { + if (selectedModel.taskType == TaskType.LLM_CHAT) { + onBenchmarkClicked(selectedModel, message, 0, 0) + } else { + showBenchmarkConfigsDialog = true + benchmarkMessage.value = message + } + }, + enabled = !uiState.inProgress + ) + } + } + } + } + } + } + } + } + + // Model initialization in-progress message. + this@Column.AnimatedVisibility( + visible = modelInitializationStatus == ModelInitializationStatus.INITIALIZING, + enter = scaleIn() + fadeIn(), + exit = scaleOut() + fadeOut(), + modifier = Modifier.offset(y = 12.dp) + ) { + ModelInitializationStatusChip() + } + + SnackbarHost(hostState = snackbarHostState, modifier = Modifier.padding(vertical = 4.dp)) + } + + + // Chat input + when (chatInputType) { + ChatInputType.TEXT -> { + val isLlmTask = selectedModel.taskType == TaskType.LLM_CHAT + val notLlmStartScreen = !(messages.size == 1 && messages[0] is ChatMessagePromptTemplates) + MessageInputText( + modelManagerViewModel = modelManagerViewModel, + curMessage = curMessage, + inProgress = uiState.inProgress, + textFieldPlaceHolderRes = task.textInputPlaceHolderRes, + onValueChanged = { curMessage = it }, + onSendMessage = { + onSendMessage(selectedModel, it) + curMessage = "" + }, + onOpenPromptTemplatesClicked = { + onSendMessage( + selectedModel, ChatMessagePromptTemplates( + templates = selectedModel.llmPromptTemplates, showMakeYourOwn = false + ) + ) + }, + onStopButtonClicked = onStopButtonClicked, + showPromptTemplatesInMenu = isLlmTask && notLlmStartScreen, + showStopButtonWhenInProgress = showStopButtonInInputWhenInProgress, + ) + } + + ChatInputType.IMAGE -> MessageInputImage( + disableButtons = uiState.inProgress, + streamingMessage = streamingMessage, + onImageSelected = { bitmap -> + onSendMessage( + selectedModel, ChatMessageImage( + bitmap = bitmap, imageBitMap = bitmap.asImageBitmap(), side = ChatSide.USER + ) + ) + }, + onStreamImage = { bitmap -> + onStreamImageMessage( + selectedModel, ChatMessageImage( + bitmap = bitmap, imageBitMap = bitmap.asImageBitmap(), side = ChatSide.USER + ) + ) + }, + onStreamEnd = onStreamEnd, + ) + } + } + + // Benchmark config dialog. + if (showBenchmarkConfigsDialog) { + BenchmarkConfigDialog(onDismissed = { showBenchmarkConfigsDialog = false }, + messageToBenchmark = benchmarkMessage.value, + onBenchmarkClicked = { message, warmUpIterations, benchmarkIterations -> + onBenchmarkClicked(selectedModel, message, warmUpIterations, benchmarkIterations) + }) + } + + // Sheet to show when a message is long-pressed. + if (showMessageLongPressedSheet) { + val message = longPressedMessage.value + if (message != null && message is ChatMessageText) { + val clipboardManager = LocalClipboardManager.current + + ModalBottomSheet( + onDismissRequest = { showMessageLongPressedSheet = false }, + modifier = Modifier.wrapContentHeight(), + ) { + Column { + // Copy text. + Box(modifier = Modifier + .fillMaxWidth() + .clickable { + // Copy text. + val clipData = AnnotatedString(message.content) + clipboardManager.setText(clipData) + + // Hide sheet. + showMessageLongPressedSheet = false + + // Show a snack bar. + scope.launch { + snackbarHostState.showSnackbar("Text copied to clipboard") + } + }) { + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(6.dp), + modifier = Modifier + .padding(vertical = 8.dp, horizontal = 16.dp) + ) { + Icon( + Icons.Rounded.ContentCopy, + contentDescription = "", + modifier = Modifier.size(18.dp) + ) + Text("Copy text") + } + } + } + } + } + + } +} + +@Preview(showBackground = true) +@Composable +fun ChatPanelPreview() { + GalleryTheme { + val context = LocalContext.current + val task = TASK_TEST1 + ChatPanel( + modelManagerViewModel = PreviewModelManagerViewModel(context = LocalContext.current), + task = task, + selectedModel = TASK_TEST1.models[1], + viewModel = PreviewChatModel(context = context), + onSendMessage = { _, _ -> }, + onRunAgainClicked = { _, _ -> }, + onBenchmarkClicked = { _, _, _, _ -> }, + ) + } +} diff --git a/Android/src/app/src/main/java/com/google/aiedge/gallery/ui/common/chat/ChatView.kt b/Android/src/app/src/main/java/com/google/aiedge/gallery/ui/common/chat/ChatView.kt new file mode 100644 index 0000000..fe7e5e0 --- /dev/null +++ b/Android/src/app/src/main/java/com/google/aiedge/gallery/ui/common/chat/ChatView.kt @@ -0,0 +1,306 @@ +/* + * Copyright 2025 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.google.aiedge.gallery.ui.common.chat + +import android.util.Log +import androidx.activity.compose.BackHandler +import androidx.activity.compose.rememberLauncherForActivityResult +import androidx.activity.result.contract.ActivityResultContracts +import androidx.compose.animation.AnimatedVisibility +import androidx.compose.animation.fadeIn +import androidx.compose.animation.fadeOut +import androidx.compose.animation.scaleIn +import androidx.compose.animation.scaleOut +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.pager.HorizontalPager +import androidx.compose.foundation.pager.rememberPagerState +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Scaffold +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.graphicsLayer +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.tooling.preview.Preview +import com.google.aiedge.gallery.GalleryTopAppBar +import com.google.aiedge.gallery.data.AppBarAction +import com.google.aiedge.gallery.data.AppBarActionType +import com.google.aiedge.gallery.data.Model +import com.google.aiedge.gallery.data.ModelDownloadStatusType +import com.google.aiedge.gallery.data.Task +import com.google.aiedge.gallery.ui.common.checkNotificationPermissonAndStartDownload +import com.google.aiedge.gallery.ui.modelmanager.ModelManagerViewModel +import com.google.aiedge.gallery.ui.preview.PreviewChatModel +import com.google.aiedge.gallery.ui.preview.PreviewModelManagerViewModel +import com.google.aiedge.gallery.ui.preview.TASK_TEST1 +import com.google.aiedge.gallery.ui.theme.GalleryTheme +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.delay +import kotlinx.coroutines.launch +import kotlin.math.absoluteValue + +private const val TAG = "AGChatView" + +/** + * A composable that displays a chat interface, allowing users to interact with different models + * associated with a given task. + * + * This composable provides a horizontal pager for switching between models, a model selector + * for configuring the selected model, and a chat panel for sending and receiving messages. It also + * manages model initialization, cleanup, and download status, and handles navigation and system + * back gestures. + */ +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun ChatView( + task: Task, + viewModel: ChatViewModel, + modelManagerViewModel: ModelManagerViewModel, + onSendMessage: (Model, ChatMessage) -> Unit, + onRunAgainClicked: (Model, ChatMessage) -> Unit, + onBenchmarkClicked: (Model, ChatMessage, Int, Int) -> Unit, + navigateUp: () -> Unit, + modifier: Modifier = Modifier, + onStreamImageMessage: (Model, ChatMessageImage) -> Unit = { _, _ -> }, + onStopButtonClicked: (Model) -> Unit = {}, + chatInputType: ChatInputType = ChatInputType.TEXT, + showStopButtonInInputWhenInProgress: Boolean = false, +) { + val modelManagerUiState by modelManagerViewModel.uiState.collectAsState() + val selectedModel = modelManagerUiState.selectedModel + + val pagerState = rememberPagerState(initialPage = task.models.indexOf(selectedModel), + pageCount = { task.models.size }) + val context = LocalContext.current + val scope = rememberCoroutineScope() + + val launcher = rememberLauncherForActivityResult( + ActivityResultContracts.RequestPermission() + ) { + modelManagerViewModel.downloadModel(selectedModel) + } + + val handleNavigateUp = { + navigateUp() + + // clean up all models. + scope.launch(Dispatchers.Default) { + for (model in task.models) { + modelManagerViewModel.cleanupModel(model = model) + } + } + } + + // Initialize model when model/download state changes. + val status = modelManagerUiState.modelDownloadStatus[selectedModel.name] + LaunchedEffect(status, selectedModel.name) { + if (status?.status == ModelDownloadStatusType.SUCCEEDED) { + Log.d(TAG, "Initializing model '${selectedModel.name}' from ChatView launched effect") + modelManagerViewModel.initializeModel(context, model = selectedModel) + } + } + + // Update selected model and clean up previous model when page is settled on a model page. + LaunchedEffect(pagerState.settledPage) { + val curSelectedModel = task.models[pagerState.settledPage] + Log.d( + TAG, + "Pager settled on model '${curSelectedModel.name}' from '${selectedModel.name}'. Updating selected model." + ) + if (curSelectedModel.name != selectedModel.name) { + modelManagerViewModel.cleanupModel(model = selectedModel) + } + modelManagerViewModel.selectModel(curSelectedModel) + } + + // Handle system's edge swipe. + BackHandler { + handleNavigateUp() + } + + Scaffold(modifier = modifier, topBar = { + GalleryTopAppBar( + title = task.type.label, + leftAction = AppBarAction(actionType = AppBarActionType.NAVIGATE_UP, actionFn = { + handleNavigateUp() + }), + rightAction = AppBarAction(actionType = AppBarActionType.NO_ACTION, actionFn = {}), + ) + }) { innerPadding -> + Box { + // A horizontal scrollable pager to switch between models. + HorizontalPager(state = pagerState) { pageIndex -> + val curSelectedModel = task.models[pageIndex] + + // Calculate the alpha of the current page based on how far they are from the center. + val pageOffset = ( + (pagerState.currentPage - pageIndex) + pagerState + .currentPageOffsetFraction + ).absoluteValue + val curAlpha = 1f - pageOffset.coerceIn(0f, 1f) + + Column( + modifier = Modifier + .padding(innerPadding) + .fillMaxSize() + .background(MaterialTheme.colorScheme.surface) + ) { + // Model selector at the top. + ModelSelector( + model = curSelectedModel, + task = task, + modelManagerViewModel = modelManagerViewModel, + onConfigChanged = { old, new -> + viewModel.addConfigChangedMessage( + oldConfigValues = old, + newConfigValues = new, + model = curSelectedModel + ) + }, + modifier = Modifier.fillMaxWidth(), + contentAlpha = curAlpha, + ) + + // Manages the conditional display of UI elements (download model button and downloading + // animation) based on the corresponding download status. + // + // It uses delayed visibility ensuring they are shown only after a short delay if their + // respective conditions remain true. This prevents UI flickering and provides a smoother + // user experience. + val curStatus = modelManagerUiState.modelDownloadStatus[curSelectedModel.name] + var shouldShowDownloadingAnimation by remember { mutableStateOf(false) } + var downloadingAnimationConditionMet by remember { mutableStateOf(false) } + var shouldShowDownloadModelButton by remember { mutableStateOf(false) } + var downloadModelButtonConditionMet by remember { mutableStateOf(false) } + + downloadingAnimationConditionMet = + curStatus?.status == ModelDownloadStatusType.IN_PROGRESS || + curStatus?.status == ModelDownloadStatusType.PARTIALLY_DOWNLOADED || + curStatus?.status == ModelDownloadStatusType.UNZIPPING + downloadModelButtonConditionMet = + curStatus?.status == ModelDownloadStatusType.FAILED || + curStatus?.status == ModelDownloadStatusType.NOT_DOWNLOADED + + LaunchedEffect(downloadingAnimationConditionMet) { + if (downloadingAnimationConditionMet) { + delay(100) + shouldShowDownloadingAnimation = true + } else { + shouldShowDownloadingAnimation = false + } + } + + LaunchedEffect(downloadModelButtonConditionMet) { + if (downloadModelButtonConditionMet) { + delay(700) + shouldShowDownloadModelButton = true + } else { + shouldShowDownloadModelButton = false + } + } + + AnimatedVisibility( + visible = shouldShowDownloadingAnimation, + enter = scaleIn(initialScale = 0.9f) + fadeIn(), + exit = scaleOut(targetScale = 0.9f) + fadeOut() + ) { + Box( + modifier = Modifier.fillMaxSize(), + contentAlignment = Alignment.Center + ) { + ModelDownloadingAnimation() + } + } + + AnimatedVisibility( + visible = shouldShowDownloadModelButton, + enter = fadeIn(), + exit = fadeOut() + ) { + ModelNotDownloaded(modifier = Modifier.weight(1f), onClicked = { + checkNotificationPermissonAndStartDownload( + context = context, + launcher = launcher, + modelManagerViewModel = modelManagerViewModel, + model = curSelectedModel + ) + }) + } + + // The main messages panel. + if (curStatus?.status == ModelDownloadStatusType.SUCCEEDED) { + ChatPanel( + modelManagerViewModel = modelManagerViewModel, + task = task, + selectedModel = curSelectedModel, + viewModel = viewModel, + onSendMessage = onSendMessage, + onRunAgainClicked = onRunAgainClicked, + onBenchmarkClicked = onBenchmarkClicked, + onStreamImageMessage = onStreamImageMessage, + onStreamEnd = { averageFps -> + viewModel.addMessage( + model = curSelectedModel, + message = ChatMessageInfo(content = "Live camera session ended. Average FPS: $averageFps") + ) + }, + onStopButtonClicked = { + onStopButtonClicked(curSelectedModel) + }, + modifier = Modifier + .weight(1f) + .graphicsLayer { alpha = curAlpha }, + chatInputType = chatInputType, + showStopButtonInInputWhenInProgress = showStopButtonInInputWhenInProgress, + ) + } + } + } + } + } +} + +@Preview +@Composable +fun ChatScreenPreview() { + GalleryTheme { + val context = LocalContext.current + val task = TASK_TEST1 + ChatView( + task = task, + viewModel = PreviewChatModel(context = context), + modelManagerViewModel = PreviewModelManagerViewModel(context = context), + onSendMessage = { _, _ -> }, + onRunAgainClicked = { _, _ -> }, + onBenchmarkClicked = { _, _, _, _ -> }, + navigateUp = {}, + ) + } +} diff --git a/Android/src/app/src/main/java/com/google/aiedge/gallery/ui/common/chat/ChatViewModel.kt b/Android/src/app/src/main/java/com/google/aiedge/gallery/ui/common/chat/ChatViewModel.kt new file mode 100644 index 0000000..0d3fde7 --- /dev/null +++ b/Android/src/app/src/main/java/com/google/aiedge/gallery/ui/common/chat/ChatViewModel.kt @@ -0,0 +1,189 @@ +/* + * Copyright 2025 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.google.aiedge.gallery.ui.common.chat + +import android.util.Log +import androidx.lifecycle.ViewModel +import com.google.aiedge.gallery.data.Model +import com.google.aiedge.gallery.data.Task +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.update + +private const val TAG = "AGChatViewModel" +private const val START_THINKING = "***Thinking...***" +private const val DONE_THINKING = "***Done thinking***" + +data class ChatUiState( + /** + * Indicates whether the runtime is currently processing a message. + */ + val inProgress: Boolean = false, + + /** + * A map of model names to lists of chat messages. + */ + val messagesByModel: Map> = mapOf(), + + /** + * A map of model names to the currently streaming chat message. + */ + val streamingMessagesByModel: Map = mapOf(), +) + +/** + * ViewModel responsible for managing the chat UI state and handling chat-related operations. + */ +open class ChatViewModel(val task: Task) : ViewModel() { + private val _uiState = MutableStateFlow(createUiState(task = task)) + val uiState = _uiState.asStateFlow() + + fun addMessage(model: Model, message: ChatMessage) { + val newMessagesByModel = _uiState.value.messagesByModel.toMutableMap() + val newMessages = newMessagesByModel[model.name]?.toMutableList() + if (newMessages != null) { + newMessagesByModel[model.name] = newMessages + // Remove prompt template message if it is the current last message. + if (newMessages.size > 0 && newMessages.last().type == ChatMessageType.PROMPT_TEMPLATES) { + newMessages.removeAt(newMessages.size - 1) + } + newMessages.add(message) + } + _uiState.update { _uiState.value.copy(messagesByModel = newMessagesByModel) } + } + + fun removeLastMessage(model: Model) { + val newMessagesByModel = _uiState.value.messagesByModel.toMutableMap() + val newMessages = newMessagesByModel[model.name]?.toMutableList() ?: mutableListOf() + if (newMessages.size > 0) { + newMessages.removeAt(newMessages.size - 1) + } + newMessagesByModel[model.name] = newMessages + _uiState.update { _uiState.value.copy(messagesByModel = newMessagesByModel) } + } + + fun getLastMessage(model: Model): ChatMessage? { + return (_uiState.value.messagesByModel[model.name] ?: listOf()).lastOrNull() + } + + fun updateLastMessageContentIncrementally( + model: Model, + partialContent: String, + latencyMs: Float, + ) { + val newMessagesByModel = _uiState.value.messagesByModel.toMutableMap() + val newMessages = newMessagesByModel[model.name]?.toMutableList() ?: mutableListOf() + if (newMessages.size > 0) { + val lastMessage = newMessages.last() + if (lastMessage is ChatMessageText) { + var newContent = "${lastMessage.content}${partialContent}" + // TODO: special handling for deepseek to remove the tag. + + // Add "thinking" and "done thinking" around the thinking content. + newContent = newContent + .replace("", "$START_THINKING\n") + .replace("", "\n$DONE_THINKING") + + // Remove empty thinking content. + val endThinkingIndex = newContent.indexOf(DONE_THINKING) + if (endThinkingIndex >= 0) { + val thinkingContent = + newContent.substring(0, endThinkingIndex + DONE_THINKING.length) + .replace(START_THINKING, "") + .replace(DONE_THINKING, "") + if (thinkingContent.isBlank()) { + newContent = newContent.substring(endThinkingIndex + DONE_THINKING.length) + } + } + + val newLastMessage = ChatMessageText( + content = newContent, + side = lastMessage.side, + latencyMs = latencyMs, + ) + newMessages.removeAt(newMessages.size - 1) + newMessages.add(newLastMessage) + } + } + newMessagesByModel[model.name] = newMessages + val newUiState = _uiState.value.copy(messagesByModel = newMessagesByModel) + _uiState.update { newUiState } + } + + fun replaceLastMessage(model: Model, message: ChatMessage, type: ChatMessageType) { + val newMessagesByModel = _uiState.value.messagesByModel.toMutableMap() + val newMessages = newMessagesByModel[model.name]?.toMutableList() ?: mutableListOf() + if (newMessages.size > 0) { + val index = newMessages.indexOfLast { it.type == type } + if (index >= 0) { + newMessages[index] = message + } + } + newMessagesByModel[model.name] = newMessages + val newUiState = _uiState.value.copy(messagesByModel = newMessagesByModel) + _uiState.update { newUiState } + } + + fun replaceMessage(model: Model, index: Int, message: ChatMessage) { + val newMessagesByModel = _uiState.value.messagesByModel.toMutableMap() + val newMessages = newMessagesByModel[model.name]?.toMutableList() ?: mutableListOf() + if (newMessages.size > 0) { + newMessages[index] = message + } + newMessagesByModel[model.name] = newMessages + val newUiState = _uiState.value.copy(messagesByModel = newMessagesByModel) + _uiState.update { newUiState } + } + + fun updateStreamingMessage(model: Model, message: ChatMessage) { + val newStreamingMessagesByModel = _uiState.value.streamingMessagesByModel.toMutableMap() + newStreamingMessagesByModel[model.name] = message + _uiState.update { _uiState.value.copy(streamingMessagesByModel = newStreamingMessagesByModel) } + } + + fun setInProgress(inProgress: Boolean) { + _uiState.update { _uiState.value.copy(inProgress = inProgress) } + } + + fun isInProgress(): Boolean { + return _uiState.value.inProgress + } + + fun addConfigChangedMessage( + oldConfigValues: Map, newConfigValues: Map, model: Model + ) { + Log.d(TAG, "Adding config changed message. Old: ${oldConfigValues}, new: $newConfigValues") + val message = ChatMessageConfigValuesChange( + model = model, oldValues = oldConfigValues, newValues = newConfigValues + ) + addMessage(message = message, model = model) + } + + private fun createUiState(task: Task): ChatUiState { + val messagesByModel: MutableMap> = mutableMapOf() + for (model in task.models) { + val messages: MutableList = mutableListOf() + if (model.llmPromptTemplates.isNotEmpty()) { + messages.add(ChatMessagePromptTemplates(templates = model.llmPromptTemplates)) + } + messagesByModel[model.name] = messages + } + return ChatUiState( + messagesByModel = messagesByModel + ) + } +} diff --git a/Android/src/app/src/main/java/com/google/aiedge/gallery/ui/common/chat/ConfigDialog.kt b/Android/src/app/src/main/java/com/google/aiedge/gallery/ui/common/chat/ConfigDialog.kt new file mode 100644 index 0000000..84990b3 --- /dev/null +++ b/Android/src/app/src/main/java/com/google/aiedge/gallery/ui/common/chat/ConfigDialog.kt @@ -0,0 +1,313 @@ +/* + * Copyright 2025 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.google.aiedge.gallery.ui.common.chat + +import android.util.Log +import androidx.compose.foundation.border +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.offset +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.foundation.text.BasicTextField +import androidx.compose.foundation.text.KeyboardOptions +import androidx.compose.material3.Button +import androidx.compose.material3.Card +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.SegmentedButton +import androidx.compose.material3.SegmentedButtonDefaults +import androidx.compose.material3.SingleChoiceSegmentedButtonRow +import androidx.compose.material3.Slider +import androidx.compose.material3.Switch +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableIntStateOf +import androidx.compose.runtime.mutableStateMapOf +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.runtime.snapshots.SnapshotStateMap +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.focus.FocusRequester +import androidx.compose.ui.focus.focusRequester +import androidx.compose.ui.focus.onFocusChanged +import androidx.compose.ui.text.input.KeyboardType +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import androidx.compose.ui.window.Dialog +import com.google.aiedge.gallery.data.BooleanSwitchConfig +import com.google.aiedge.gallery.data.Config +import com.google.aiedge.gallery.data.NumberSliderConfig +import com.google.aiedge.gallery.data.SegmentedButtonConfig +import com.google.aiedge.gallery.data.ValueType +import com.google.aiedge.gallery.ui.preview.MODEL_TEST1 +import com.google.aiedge.gallery.ui.theme.GalleryTheme +import com.google.aiedge.gallery.ui.theme.labelSmallNarrow +import kotlin.Double.Companion.NaN + +private const val TAG = "AGConfigDialog" + +/** + * Displays a configuration dialog allowing users to modify settings through various input controls. + */ +@Composable +fun ConfigDialog( + title: String, + configs: List, + initialValues: Map, + onDismissed: () -> Unit, + onOk: (Map) -> Unit, + okBtnLabel: String = "OK", + subtitle: String = "", + showCancel: Boolean = true, +) { + val values: SnapshotStateMap = remember { + mutableStateMapOf().apply { + putAll(initialValues) + } + } + + Dialog(onDismissRequest = onDismissed) { + Card(modifier = Modifier.fillMaxWidth(), shape = RoundedCornerShape(16.dp)) { + Column( + modifier = Modifier.padding(20.dp), verticalArrangement = Arrangement.spacedBy(16.dp) + ) { + // Dialog title and subtitle. + Column { + Text( + title, + style = MaterialTheme.typography.titleLarge, + modifier = Modifier.padding(bottom = 8.dp) + ) + // Subtitle. + if (subtitle.isNotEmpty()) { + Text( + subtitle, + style = labelSmallNarrow, + color = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.offset(y = (-6).dp) + ) + } + } + + + // List of config rows. + for (config in configs) { + when (config) { + // Number slider. + is NumberSliderConfig -> { + NumberSliderRow(config = config, values = values) + } + + // Boolean switch. + is BooleanSwitchConfig -> { + BooleanSwitchRow(config = config, values = values) + } + + is SegmentedButtonConfig -> { + SegmentedButtonRow(config = config, values = values) + } + + else -> {} + } + } + Row( + modifier = Modifier + .fillMaxWidth() + .padding(top = 8.dp), + horizontalArrangement = Arrangement.End, + ) { + // Cancel button. + if (showCancel) { + TextButton( + onClick = { onDismissed() }, + ) { + Text("Cancel") + } + } + + // Ok button + Button( + onClick = { + Log.d(TAG, "Values from dialog: $values") + onOk(values.toMap()) + }, + ) { + Text(okBtnLabel) + } + } + } + } + } +} + +/** + * Composable function to display a number slider with an associated text input field. + * + * This function renders a row containing a slider and a text field, both used to modify + * a numeric value. The slider allows users to visually adjust the value within a specified range, + * while the text field provides precise numeric input. + */ +@Composable +fun NumberSliderRow(config: NumberSliderConfig, values: SnapshotStateMap) { + Column(modifier = Modifier.fillMaxWidth()) { + // Field label. + Text(config.key.label, style = MaterialTheme.typography.titleSmall) + + // Controls row. + Row( + modifier = Modifier.fillMaxWidth(), verticalAlignment = Alignment.CenterVertically + ) { + var isFocused by remember { mutableStateOf(false) } + val focusRequester = remember { FocusRequester() } + + // Number slider. + val sliderValue = try { + values[config.key.label] as Float + } catch (e: Exception) { + 0f + } + Slider(modifier = Modifier + .height(24.dp) + .weight(1f), + value = sliderValue, + valueRange = config.sliderMin..config.sliderMax, + onValueChange = { values[config.key.label] = it }) + + Spacer(modifier = Modifier.width(8.dp)) + + // Text field. + val textFieldValue = try { + when (config.valueType) { + ValueType.FLOAT -> { + "%.2f".format(values[config.key.label] as Float) + } + + ValueType.INT -> { + "${(values[config.key.label] as Float).toInt()}" + } + + else -> { + "" + } + } + } catch (e: Exception) { + "" + } + // A smaller text field. + BasicTextField( + value = textFieldValue, + modifier = Modifier + .width(80.dp) + .focusRequester(focusRequester) + .onFocusChanged { + isFocused = it.isFocused + }, + keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number), + onValueChange = { + if (it.isNotEmpty()) { + values[config.key.label] = it.toFloatOrNull() ?: NaN + } else { + values[config.key.label] = NaN + } + }, + ) { innerTextField -> + Box( + modifier = Modifier.border( + width = if (isFocused) 2.dp else 1.dp, + color = if (isFocused) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.outline, + shape = RoundedCornerShape(4.dp) + ) + ) { + Box(modifier = Modifier.padding(8.dp)) { + innerTextField() + } + } + } + } + } +} + +/** + * Composable function to display a row with a boolean switch. + * + * This function renders a row containing a label and a switch, allowing users to toggle + * a boolean value. + */ +@Composable +fun BooleanSwitchRow(config: BooleanSwitchConfig, values: SnapshotStateMap) { + val switchValue = try { + values[config.key.label] as Boolean + } catch (e: Exception) { + false + } + Column(modifier = Modifier.fillMaxWidth()) { + Text(config.key.label, style = MaterialTheme.typography.titleSmall) + Switch(checked = switchValue, onCheckedChange = { values[config.key.label] = it }) + } +} + +@Composable +fun SegmentedButtonRow(config: SegmentedButtonConfig, values: SnapshotStateMap) { + var selectedIndex by remember { mutableIntStateOf(config.options.indexOf(values[config.key.label])) } + + Column(modifier = Modifier.fillMaxWidth()) { + Text(config.key.label, style = MaterialTheme.typography.titleSmall) + SingleChoiceSegmentedButtonRow { + config.options.forEachIndexed { index, label -> + SegmentedButton(shape = SegmentedButtonDefaults.itemShape( + index = index, count = config.options.size + ), onClick = { + selectedIndex = index + values[config.key.label] = label + }, selected = index == selectedIndex, label = { Text(label) }) + } + } + + } +} + +@Composable +@Preview(showBackground = true) +fun ConfigDialogPreview() { + GalleryTheme { + val defaultValues: MutableMap = mutableMapOf() + for (config in MODEL_TEST1.configs) { + defaultValues[config.key.label] = config.defaultValue + } + + Column { + ConfigDialog( + title = "Dialog title", + subtitle = "20250413", + configs = MODEL_TEST1.configs, + initialValues = defaultValues, + onDismissed = {}, + onOk = {}, + ) + } + } +} diff --git a/Android/src/app/src/main/java/com/google/aiedge/gallery/ui/common/chat/DataCard.kt b/Android/src/app/src/main/java/com/google/aiedge/gallery/ui/common/chat/DataCard.kt new file mode 100644 index 0000000..57e6c4e --- /dev/null +++ b/Android/src/app/src/main/java/com/google/aiedge/gallery/ui/common/chat/DataCard.kt @@ -0,0 +1,93 @@ +/* + * Copyright 2025 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.google.aiedge.gallery.ui.common.chat + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.offset +import androidx.compose.foundation.layout.padding +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.alpha +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import com.google.aiedge.gallery.ui.theme.GalleryTheme +import com.google.aiedge.gallery.ui.theme.bodySmallMediumNarrow +import com.google.aiedge.gallery.ui.theme.bodySmallMediumNarrowBold +import com.google.aiedge.gallery.ui.theme.labelSmallNarrow +import com.google.aiedge.gallery.ui.theme.labelSmallNarrowMedium + +/** + * Composable function to display a data card with a label and a numeric value. + * + * This function renders a column containing a label and a formatted numeric value. + * It provides options for highlighting the value and displaying a placeholder when the value is not + * available. + */ +@Composable +fun DataCard( + label: String, + value: Float?, + unit: String, + highlight: Boolean = false, + showPlaceholder: Boolean = false +) { + var strValue = "-" + Column { + Text(label, style = labelSmallNarrowMedium) + if (showPlaceholder) { + Text("-", style = bodySmallMediumNarrow) + } else { + strValue = if (value == null) "-" else "%.2f".format(value) + if (highlight) { + Text( + strValue, style = bodySmallMediumNarrowBold, color = MaterialTheme.colorScheme.primary + ) + } else { + Text(strValue, style = bodySmallMediumNarrow) + } + } + if (strValue != "-") { + Text( + unit, style = labelSmallNarrow, modifier = Modifier + .alpha(0.5f) + .offset(y = (-1).dp) + ) + } + } +} + +@Preview(showBackground = true) +@Composable +fun DataCardPreview() { + GalleryTheme { + Row(modifier = Modifier.padding(16.dp), horizontalArrangement = Arrangement.spacedBy(16.dp)) { + DataCard( + label = "sum", value = 123.45f, unit = "ms", highlight = true, showPlaceholder = false + ) + DataCard( + label = "average", value = 12.3f, unit = "ms", highlight = false, showPlaceholder = false + ) + DataCard( + label = "test", value = null, unit = "ms", highlight = false, showPlaceholder = false + ) + } + } +} \ No newline at end of file diff --git a/Android/src/app/src/main/java/com/google/aiedge/gallery/ui/common/chat/LiveCameraDialog.kt b/Android/src/app/src/main/java/com/google/aiedge/gallery/ui/common/chat/LiveCameraDialog.kt new file mode 100644 index 0000000..8099e45 --- /dev/null +++ b/Android/src/app/src/main/java/com/google/aiedge/gallery/ui/common/chat/LiveCameraDialog.kt @@ -0,0 +1,226 @@ +/* + * Copyright 2025 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.google.aiedge.gallery.ui.common.chat + +import android.graphics.Bitmap +import android.graphics.Matrix +import android.util.Size +import androidx.camera.core.CameraSelector +import androidx.camera.core.ImageAnalysis +import androidx.camera.core.resolutionselector.ResolutionSelector +import androidx.camera.core.resolutionselector.ResolutionStrategy +import androidx.camera.lifecycle.ProcessCameraProvider +import androidx.compose.foundation.Image +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.aspectRatio +import androidx.compose.foundation.layout.fillMaxHeight +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.Card +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableLongStateOf +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.graphics.ImageBitmap +import androidx.compose.ui.graphics.asImageBitmap +import androidx.compose.ui.layout.ContentScale +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.unit.dp +import androidx.compose.ui.window.Dialog +import androidx.core.content.ContextCompat +import androidx.lifecycle.compose.LocalLifecycleOwner +import java.util.concurrent.Executors +import kotlin.coroutines.resume +import kotlin.coroutines.suspendCoroutine + +/** + * Composable function to display a live camera feed in a dialog. + * + * This function renders a dialog that displays a live camera preview, along with optional + * classification results and FPS information. It manages camera initialization, frame capture, + * and dialog dismissal. + */ +@Composable +fun LiveCameraDialog( + onDismissed: (averageFps: Int) -> Unit, + onBitmap: (Bitmap) -> Unit, + streamingMessage: ChatMessage? = null, +) { + val context = LocalContext.current + val lifecycleOwner = LocalLifecycleOwner.current + var imageBitmap by remember { mutableStateOf(null) } + var cameraProvider: ProcessCameraProvider? by remember { mutableStateOf(null) } + var sumFps by remember { mutableLongStateOf(0L) } + var fpsCount by remember { mutableLongStateOf(0L) } + + LaunchedEffect(key1 = true) { + cameraProvider = startCamera( + context, + lifecycleOwner, + onBitmap = onBitmap, + onImageBitmap = { b -> imageBitmap = b }) + } + + Dialog(onDismissRequest = { + cameraProvider?.unbindAll() + onDismissed((sumFps / fpsCount).toInt()) + }) { + Card(modifier = Modifier.fillMaxWidth(), shape = RoundedCornerShape(16.dp)) { + Column( + modifier = Modifier.padding(20.dp), verticalArrangement = Arrangement.spacedBy(16.dp) + ) { + // Title + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.SpaceBetween, + modifier = Modifier + .fillMaxWidth() + .padding(bottom = 8.dp) + ) { + Text( + "Live camera", + style = MaterialTheme.typography.titleLarge, + ) + if (streamingMessage != null) { + val fps = (1000f / streamingMessage.latencyMs).toInt() + sumFps += fps.toLong() + fpsCount += 1 + + Text( + "%d FPS".format(fps), + style = MaterialTheme.typography.titleLarge, + ) + } + } + + // Camera live view. + Row( + modifier = Modifier + .fillMaxWidth() + .aspectRatio(1f), + horizontalArrangement = Arrangement.Center + ) { + val ib = imageBitmap + if (ib != null) { + Image( + bitmap = ib, + contentDescription = "", + modifier = Modifier + .fillMaxHeight() + .clip(RoundedCornerShape(8.dp)), + contentScale = ContentScale.Inside + ) + } + } + + // Result. + if (streamingMessage != null && streamingMessage is ChatMessageClassification) { + MessageBodyClassification( + message = streamingMessage, + modifier = Modifier.fillMaxWidth(), + oneLineLabel = true + ) + } + + // Button. + Row( + modifier = Modifier + .fillMaxWidth() + .padding(top = 8.dp), + horizontalArrangement = Arrangement.End, + ) { + TextButton( + onClick = { + cameraProvider?.unbindAll() + onDismissed((sumFps / fpsCount).toInt()) + }, + ) { + Text("OK") + } + } + } + } + } +} + +/** + * Asynchronously initializes and starts the camera for image capture and analysis. + * + * This function sets up the camera using CameraX, configures image analysis, and binds + * the camera lifecycle to the provided LifecycleOwner. It captures frames from the camera, + * converts them to Bitmaps and ImageBitmaps, and invokes the provided callbacks. + */ +private suspend fun startCamera( + context: android.content.Context, + lifecycleOwner: androidx.lifecycle.LifecycleOwner, + onBitmap: (Bitmap) -> Unit, + onImageBitmap: (ImageBitmap) -> Unit +): ProcessCameraProvider? = suspendCoroutine { continuation -> + val cameraProviderFuture = ProcessCameraProvider.getInstance(context) + + cameraProviderFuture.addListener({ + val cameraProvider = cameraProviderFuture.get() + + val resolutionSelector = ResolutionSelector.Builder().setResolutionStrategy( + ResolutionStrategy( + Size(1080, 1080), + ResolutionStrategy.FALLBACK_RULE_CLOSEST_LOWER_THEN_HIGHER + ) + ).build() + val imageAnalysis = + ImageAnalysis.Builder().setResolutionSelector(resolutionSelector) + .setBackpressureStrategy(ImageAnalysis.STRATEGY_KEEP_ONLY_LATEST).build().also { + it.setAnalyzer(Executors.newSingleThreadExecutor()) { imageProxy -> + var bitmap = imageProxy.toBitmap() + val rotation = imageProxy.imageInfo.rotationDegrees + bitmap = if (rotation != 0) { + val matrix = Matrix().apply { + postRotate(rotation.toFloat()) + } + Bitmap.createBitmap(bitmap, 0, 0, bitmap.width, bitmap.height, matrix, true) + } else bitmap + onBitmap(bitmap) + onImageBitmap(bitmap.asImageBitmap()) + imageProxy.close() + } + } + + val cameraSelector = CameraSelector.DEFAULT_BACK_CAMERA + try { + cameraProvider?.unbindAll() + cameraProvider?.bindToLifecycle( + lifecycleOwner, cameraSelector, imageAnalysis + ) + // Resume with the provider + continuation.resume(cameraProvider) + } catch (exc: Exception) { + // todo: Handle exceptions (e.g., camera initialization failure) + } + }, ContextCompat.getMainExecutor(context)) +} diff --git a/Android/src/app/src/main/java/com/google/aiedge/gallery/ui/common/chat/MarkdownText.kt b/Android/src/app/src/main/java/com/google/aiedge/gallery/ui/common/chat/MarkdownText.kt new file mode 100644 index 0000000..b0aca49 --- /dev/null +++ b/Android/src/app/src/main/java/com/google/aiedge/gallery/ui/common/chat/MarkdownText.kt @@ -0,0 +1,76 @@ +/* + * Copyright 2025 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.google.aiedge.gallery.ui.common.chat + +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.ProvideTextStyle +import androidx.compose.runtime.Composable +import androidx.compose.runtime.CompositionLocalProvider +import androidx.compose.ui.Modifier +import androidx.compose.ui.text.TextStyle +import androidx.compose.ui.text.font.FontFamily +import androidx.compose.ui.tooling.preview.Preview +import com.google.aiedge.gallery.ui.theme.GalleryTheme +import com.halilibo.richtext.commonmark.Markdown +import com.halilibo.richtext.ui.CodeBlockStyle +import com.halilibo.richtext.ui.RichTextStyle +import com.halilibo.richtext.ui.material3.RichText + +/** + * Composable function to display Markdown-formatted text. + */ +@Composable +fun MarkdownText( + text: String, + modifier: Modifier = Modifier, + smallFontSize: Boolean = false +) { + val fontSize = + if (smallFontSize) MaterialTheme.typography.bodySmall.fontSize else MaterialTheme.typography.bodyMedium.fontSize + CompositionLocalProvider { + ProvideTextStyle( + value = TextStyle( + fontSize = fontSize, + lineHeight = fontSize * 1.2, + ) + ) { + RichText( + modifier = modifier, + style = RichTextStyle( + codeBlockStyle = CodeBlockStyle( + textStyle = TextStyle( + fontSize = MaterialTheme.typography.bodySmall.fontSize, + fontFamily = FontFamily.Monospace, + ) + ) + ), + ) { + Markdown( + content = text + ) + } + } + } +} + +@Preview(showBackground = true) +@Composable +fun MarkdownTextPreview() { + GalleryTheme { + MarkdownText(text = "*Hello World*\n**Good morning!!**") + } +} diff --git a/Android/src/app/src/main/java/com/google/aiedge/gallery/ui/common/chat/MessageActionButton.kt b/Android/src/app/src/main/java/com/google/aiedge/gallery/ui/common/chat/MessageActionButton.kt new file mode 100644 index 0000000..ddf3467 --- /dev/null +++ b/Android/src/app/src/main/java/com/google/aiedge/gallery/ui/common/chat/MessageActionButton.kt @@ -0,0 +1,95 @@ +/* + * Copyright 2025 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.google.aiedge.gallery.ui.common.chat + +import androidx.compose.foundation.background +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.offset +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.PlayArrow +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.alpha +import androidx.compose.ui.draw.clip +import androidx.compose.ui.graphics.vector.ImageVector +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import com.google.aiedge.gallery.ui.theme.GalleryTheme +import com.google.aiedge.gallery.ui.theme.bodySmallNarrow + +/** + * Composable function to display an action button below a chat message. + */ +@Composable +fun MessageActionButton( + label: String, + icon: ImageVector, + onClick: () -> Unit, + enabled: Boolean = true +) { + val modifier = Modifier + .padding(top = 4.dp) + .clip(CircleShape) + .background(if (enabled) MaterialTheme.colorScheme.secondaryContainer else MaterialTheme.colorScheme.surfaceContainerHigh) + val alpha: Float = if (enabled) 1.0f else 0.3f + Row( + modifier = if (enabled) modifier.clickable { onClick() } else modifier, + verticalAlignment = Alignment.CenterVertically, + ) { + Icon( + icon, contentDescription = "", modifier = Modifier + .size(16.dp) + .offset(x = 6.dp) + .alpha(alpha) + ) + Text( + label, + color = MaterialTheme.colorScheme.onSecondaryContainer, + style = bodySmallNarrow, + modifier = Modifier + .padding( + start = 10.dp, end = 8.dp, top = 4.dp, bottom = 4.dp + ) + .alpha(alpha) + ) + } +} + +@Preview(showBackground = true) +@Composable +fun MessageActionButtonPreview() { + GalleryTheme { + Column { + MessageActionButton(label = "run", icon = Icons.Default.PlayArrow, onClick = {}) + MessageActionButton( + label = "run", + icon = Icons.Default.PlayArrow, + enabled = false, + onClick = {}) + } + } +} + diff --git a/Android/src/app/src/main/java/com/google/aiedge/gallery/ui/common/chat/MessageBodyBenchmark.kt b/Android/src/app/src/main/java/com/google/aiedge/gallery/ui/common/chat/MessageBodyBenchmark.kt new file mode 100644 index 0000000..b21fb4c --- /dev/null +++ b/Android/src/app/src/main/java/com/google/aiedge/gallery/ui/common/chat/MessageBodyBenchmark.kt @@ -0,0 +1,140 @@ +/* + * Copyright 2025 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.google.aiedge.gallery.ui.common.chat + +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.MaterialTheme +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.alpha +import androidx.compose.ui.draw.clip +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import com.google.aiedge.gallery.ui.theme.GalleryTheme +import kotlin.math.max + +private const val DEFAULT_HISTOGRAM_BAR_HEIGHT = 50f + +/** + * Composable function to display benchmark results within a chat message. + * + * This function renders benchmark statistics (e.g., average latency) in data cards and + * visualizes the latency distribution using a histogram. + */ +@Composable +fun MessageBodyBenchmark(message: ChatMessageBenchmarkResult) { + Column( + modifier = Modifier + .padding(12.dp) + .fillMaxWidth(), + verticalArrangement = Arrangement.spacedBy(8.dp) + ) { + // Data cards. + Row( + modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.SpaceBetween + ) { + for (stat in message.orderedStats) { + DataCard( + label = stat.label, + unit = stat.unit, + value = message.statValues[stat.id], + highlight = stat.id == message.highlightStat, + showPlaceholder = message.isWarmingUp() + ) + } + } + + // Histogram + if (message.histogram.buckets.isNotEmpty()) { + Row( + horizontalArrangement = Arrangement.spacedBy(2.dp) + ) { + for ((index, count) in message.histogram.buckets.withIndex()) { + var barBgColor = MaterialTheme.colorScheme.onSurfaceVariant + var alpha = 0.3f + if (count != 0) { + alpha = 0.5f + } + if (index == message.histogram.highlightBucketIndex) { + barBgColor = MaterialTheme.colorScheme.primary + alpha = 0.8f + } + // Bar container. + Column( + modifier = Modifier + .height(DEFAULT_HISTOGRAM_BAR_HEIGHT.dp) + .width(4.dp), + verticalArrangement = Arrangement.Bottom, + ) { + // Bar content. + Box( + modifier = Modifier + .height( + max( + 1f, + count.toFloat() / message.histogram.maxCount.toFloat() * DEFAULT_HISTOGRAM_BAR_HEIGHT + ).dp + ) + .fillMaxWidth() + .clip(RoundedCornerShape(20.dp, 20.dp, 0.dp, 0.dp)) + .alpha(alpha) + .background(barBgColor) + ) + } + } + } + } + } +} + +@Preview(showBackground = true) +@Composable +fun MessageBodyBenchmarkPreview() { + GalleryTheme { + MessageBodyBenchmark( + message = ChatMessageBenchmarkResult( + orderedStats = listOf( + Stat(id = "stat1", label = "Stat1", unit = "ms"), + Stat(id = "stat2", label = "Stat2", unit = "ms"), + Stat(id = "stat3", label = "Stat3", unit = "ms"), + Stat(id = "stat4", label = "Stat4", unit = "ms") + ), + statValues = mutableMapOf( + "stat1" to 0.3f, + "stat2" to 0.4f, + "stat3" to 0.5f, + ), + values = listOf(), + histogram = Histogram(listOf(), 0), + warmupCurrent = 0, + warmupTotal = 0, + iterationCurrent = 0, + iterationTotal = 0, + highlightStat = "stat2" + ) + ) + } +} \ No newline at end of file diff --git a/Android/src/app/src/main/java/com/google/aiedge/gallery/ui/common/chat/MessageBodyBenchmarkLlm.kt b/Android/src/app/src/main/java/com/google/aiedge/gallery/ui/common/chat/MessageBodyBenchmarkLlm.kt new file mode 100644 index 0000000..3b25de1 --- /dev/null +++ b/Android/src/app/src/main/java/com/google/aiedge/gallery/ui/common/chat/MessageBodyBenchmarkLlm.kt @@ -0,0 +1,76 @@ +/* + * Copyright 2025 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.google.aiedge.gallery.ui.common.chat + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.wrapContentWidth +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import com.google.aiedge.gallery.ui.theme.GalleryTheme + +/** + * Composable function to display benchmark LLM results within a chat message. + * + * This function renders benchmark statistics (e.g., various token speed) in data cards + */ +@Composable +fun MessageBodyBenchmarkLlm(message: ChatMessageBenchmarkLlmResult) { + Column( + modifier = Modifier + .padding(12.dp) + .wrapContentWidth(), + verticalArrangement = Arrangement.spacedBy(8.dp) + ) { + // Data cards. + Row( + modifier = Modifier.wrapContentWidth(), horizontalArrangement = Arrangement.spacedBy(16.dp) + ) { + for (stat in message.orderedStats) { + DataCard( + label = stat.label, + unit = stat.unit, + value = message.statValues[stat.id], + ) + } + } + } +} + +@Preview(showBackground = true) +@Composable +fun MessageBodyBenchmarkLlmPreview() { + GalleryTheme { + MessageBodyBenchmarkLlm( + message = ChatMessageBenchmarkLlmResult( + orderedStats = listOf( + Stat(id = "stat1", label = "Stat1", unit = "tokens/s"), + Stat(id = "stat2", label = "Stat2", unit = "tokens/s") + ), + statValues = mutableMapOf( + "stat1" to 0.3f, + "stat2" to 0.4f, + ), + running = false, + ) + ) + } +} diff --git a/Android/src/app/src/main/java/com/google/aiedge/gallery/ui/common/chat/MessageBodyClassification.kt b/Android/src/app/src/main/java/com/google/aiedge/gallery/ui/common/chat/MessageBodyClassification.kt new file mode 100644 index 0000000..6508a45 --- /dev/null +++ b/Android/src/app/src/main/java/com/google/aiedge/gallery/ui/common/chat/MessageBodyClassification.kt @@ -0,0 +1,115 @@ +/* + * Copyright 2025 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.google.aiedge.gallery.ui.common.chat + +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import com.google.aiedge.gallery.ui.theme.GalleryTheme + +val CLASSIFICATION_BAR_HEIGHT = 8.dp +val CLASSIFICATION_BAR_MAX_WIDTH = 200.dp + +/** + * Composable function to display classification results. + * + * This function renders a list of classifications, each with its label, score, and a visual score bar. + */ +@Composable +fun MessageBodyClassification( + message: ChatMessageClassification, + modifier: Modifier = Modifier, + oneLineLabel: Boolean = false, +) { + Column( + modifier = modifier.padding(12.dp) + ) { + for (classification in message.classifications) { + Row( + modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.SpaceBetween + ) { + // Classification label. + Text( + classification.label, + maxLines = if (oneLineLabel) 1 else Int.MAX_VALUE, + overflow = TextOverflow.Ellipsis, + style = MaterialTheme.typography.bodySmall, + modifier = Modifier.weight(1f) + ) + // Classification score. + Text( + "%.2f".format(classification.score), + style = MaterialTheme.typography.bodySmall, + modifier = Modifier + .align(Alignment.Bottom), + ) + } + Spacer(modifier = Modifier.height(2.dp)) + // Score bar. + Box { + Box( + modifier = Modifier + .fillMaxWidth() + .height(CLASSIFICATION_BAR_HEIGHT) + .clip(CircleShape) + .background(MaterialTheme.colorScheme.surfaceDim) + ) + Box( + modifier = Modifier + .fillMaxWidth(classification.score) + .height(CLASSIFICATION_BAR_HEIGHT) + .clip(CircleShape) + .background(classification.color) + ) + } + Spacer(modifier = Modifier.height(6.dp)) + } + } +} + +@Preview(showBackground = true) +@Composable +fun MessageBodyClassificationPreview() { + GalleryTheme { + MessageBodyClassification( + message = ChatMessageClassification( + classifications = listOf( + Classification(label = "label1", score = 0.3f, color = Color.Red), + Classification(label = "label2", score = 0.7f, color = Color.Blue) + ), + latencyMs = 12345f, + ), + ) + } +} \ No newline at end of file diff --git a/Android/src/app/src/main/java/com/google/aiedge/gallery/ui/common/chat/MessageBodyConfigUpdate.kt b/Android/src/app/src/main/java/com/google/aiedge/gallery/ui/common/chat/MessageBodyConfigUpdate.kt new file mode 100644 index 0000000..b05af41 --- /dev/null +++ b/Android/src/app/src/main/java/com/google/aiedge/gallery/ui/common/chat/MessageBodyConfigUpdate.kt @@ -0,0 +1,144 @@ +/* + * Copyright 2025 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.google.aiedge.gallery.ui.common.chat + +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.alpha +import androidx.compose.ui.draw.clip +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import com.google.aiedge.gallery.data.ConfigKey +import com.google.aiedge.gallery.ui.common.convertValueToTargetType +import com.google.aiedge.gallery.ui.common.getConfigValueString +import com.google.aiedge.gallery.ui.preview.MODEL_TEST1 +import com.google.aiedge.gallery.ui.theme.GalleryTheme +import com.google.aiedge.gallery.ui.theme.bodySmallNarrow +import com.google.aiedge.gallery.ui.theme.titleSmaller + +/** + * Composable function to display a message indicating configuration value changes. + * + * This function renders a centered row containing a box that displays the old and new + * values of configuration settings that have been updated. + */ +@Composable +fun MessageBodyConfigUpdate(message: ChatMessageConfigValuesChange) { + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.Center, + ) { + Box( + modifier = Modifier + .clip(RoundedCornerShape(4.dp)) + .background(MaterialTheme.colorScheme.tertiaryContainer) + ) { + Column(modifier = Modifier.padding(8.dp)) { + // Title. + Text( + "Configs updated", + color = MaterialTheme.colorScheme.onTertiaryContainer, + style = titleSmaller, + ) + + Row(modifier = Modifier.padding(top = 8.dp)) { + // Keys + Column { + for (config in message.model.configs) { + Text( + "${config.key.label}:", + style = bodySmallNarrow, + modifier = Modifier.alpha(0.6f), + ) + } + } + + Spacer(modifier = Modifier.width(4.dp)) + + // Values + Column { + for (config in message.model.configs) { + val key = config.key.label + val oldValue: Any = convertValueToTargetType( + value = message.oldValues.getValue(key), valueType = config.valueType + ) + val newValue: Any = convertValueToTargetType( + value = message.newValues.getValue(key), valueType = config.valueType + ) + if (oldValue == newValue) { + Text("$newValue", style = bodySmallNarrow) + } else { + Row(verticalAlignment = Alignment.CenterVertically) { + Text( + getConfigValueString(oldValue, config), style = bodySmallNarrow + ) + Text( + "▸", + style = bodySmallNarrow.copy(fontSize = 12.sp), + modifier = Modifier.padding(start = 4.dp, end = 4.dp) + ) + Text( + getConfigValueString(newValue, config), + style = bodySmallNarrow.copy(fontWeight = FontWeight.Bold), + color = MaterialTheme.colorScheme.primary, + ) + } + } + } + } + } + } + } + } +} + +@Preview(showBackground = true) +@Composable +fun MessageBodyConfigUpdatePreview() { + GalleryTheme { + Row(modifier = Modifier.padding(16.dp)) { + MessageBodyConfigUpdate( + message = ChatMessageConfigValuesChange( + model = MODEL_TEST1, + oldValues = mapOf( + ConfigKey.MAX_RESULT_COUNT.label to 100, + ConfigKey.USE_GPU.label to false + ), + newValues = mapOf( + ConfigKey.MAX_RESULT_COUNT.label to 200, + ConfigKey.USE_GPU.label to true + ) + ) + ) + } + } +} \ No newline at end of file diff --git a/Android/src/app/src/main/java/com/google/aiedge/gallery/ui/common/chat/MessageBodyImage.kt b/Android/src/app/src/main/java/com/google/aiedge/gallery/ui/common/chat/MessageBodyImage.kt new file mode 100644 index 0000000..b5a380f --- /dev/null +++ b/Android/src/app/src/main/java/com/google/aiedge/gallery/ui/common/chat/MessageBodyImage.kt @@ -0,0 +1,43 @@ +/* + * Copyright 2025 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.google.aiedge.gallery.ui.common.chat + +import androidx.compose.foundation.Image +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.width +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.layout.ContentScale +import androidx.compose.ui.unit.dp + +@Composable +fun MessageBodyImage(message: ChatMessageImage) { + val bitmapWidth = message.bitmap.width + val bitmapHeight = message.bitmap.height + val imageWidth = + if (bitmapWidth >= bitmapHeight) 200 else (200f / bitmapHeight * bitmapWidth).toInt() + val imageHeight = + if (bitmapHeight >= bitmapWidth) 200 else (200f / bitmapWidth * bitmapHeight).toInt() + Image( + bitmap = message.imageBitMap, + contentDescription = "", + modifier = Modifier + .height(imageHeight.dp) + .width(imageWidth.dp), + contentScale = ContentScale.Fit, + ) +} \ No newline at end of file diff --git a/Android/src/app/src/main/java/com/google/aiedge/gallery/ui/common/chat/MessageBodyImageWithHistory.kt b/Android/src/app/src/main/java/com/google/aiedge/gallery/ui/common/chat/MessageBodyImageWithHistory.kt new file mode 100644 index 0000000..9a6c162 --- /dev/null +++ b/Android/src/app/src/main/java/com/google/aiedge/gallery/ui/common/chat/MessageBodyImageWithHistory.kt @@ -0,0 +1,91 @@ +/* + * Copyright 2025 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.google.aiedge.gallery.ui.common.chat + +import androidx.compose.foundation.Image +import androidx.compose.foundation.gestures.detectHorizontalDragGestures +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.width +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.MutableIntState +import androidx.compose.runtime.MutableState +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableFloatStateOf +import androidx.compose.runtime.mutableIntStateOf +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.input.pointer.pointerInput +import androidx.compose.ui.layout.ContentScale +import androidx.compose.ui.unit.dp + +/** + * Composable function to display an image message with history, allowing users to navigate through + * different versions by sliding on the image. + */ +@Composable +fun MessageBodyImageWithHistory( + message: ChatMessageImageWithHistory, + imageHistoryCurIndex: MutableIntState +) { + val prevMessage: MutableState = remember { mutableStateOf(null) } + + LaunchedEffect(message) { + imageHistoryCurIndex.intValue = message.bitmaps.size - 1 + prevMessage.value = message + } + + Column { + val curImage = message.bitmaps[imageHistoryCurIndex.intValue] + val curImageBitmap = message.imageBitMaps[imageHistoryCurIndex.intValue] + + val bitmapWidth = curImage.width + val bitmapHeight = curImage.height + val imageWidth = + if (bitmapWidth >= bitmapHeight) 200 else (200f / bitmapHeight * bitmapWidth).toInt() + val imageHeight = + if (bitmapHeight >= bitmapWidth) 200 else (200f / bitmapWidth * bitmapHeight).toInt() + + var value by remember { mutableFloatStateOf(0f) } + var savedIndex by remember { mutableIntStateOf(0) } + Image( + bitmap = curImageBitmap, + contentDescription = "", + modifier = Modifier + .height(imageHeight.dp) + .width(imageWidth.dp) + .pointerInput(Unit) { + detectHorizontalDragGestures(onDragStart = { + value = 0f + savedIndex = imageHistoryCurIndex.intValue + }) { _, dragAmount -> + value += (dragAmount / 20f)// Adjust sensitivity here + imageHistoryCurIndex.intValue = (savedIndex + value).toInt() + if (imageHistoryCurIndex.intValue < 0) { + imageHistoryCurIndex.intValue = 0 + } else if (imageHistoryCurIndex.intValue > message.bitmaps.size - 1) { + imageHistoryCurIndex.intValue = message.bitmaps.size - 1 + } + } + }, + contentScale = ContentScale.Fit, + ) + } +} \ No newline at end of file diff --git a/Android/src/app/src/main/java/com/google/aiedge/gallery/ui/common/chat/MessageBodyInfo.kt b/Android/src/app/src/main/java/com/google/aiedge/gallery/ui/common/chat/MessageBodyInfo.kt new file mode 100644 index 0000000..03c8e3e --- /dev/null +++ b/Android/src/app/src/main/java/com/google/aiedge/gallery/ui/common/chat/MessageBodyInfo.kt @@ -0,0 +1,63 @@ +/* + * Copyright 2025 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.google.aiedge.gallery.ui.common.chat + +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.MaterialTheme +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import com.google.aiedge.gallery.ui.theme.GalleryTheme +import com.google.aiedge.gallery.ui.theme.customColors + +/** + * Composable function to display informational message content within a chat. + * + * Supports markdown. + */ +@Composable +fun MessageBodyInfo(message: ChatMessageInfo) { + Row( + modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.Center + ) { + Box( + modifier = Modifier + .clip(RoundedCornerShape(16.dp)) + .background(MaterialTheme.customColors.agentBubbleBgColor) + ) { + MarkdownText(text = message.content, modifier = Modifier.padding(12.dp), smallFontSize = true) + } + } +} + +@Preview(showBackground = true) +@Composable +fun MessageBodyInfoPreview() { + GalleryTheme { + Row(modifier = Modifier.padding(16.dp)) { + MessageBodyInfo(message = ChatMessageInfo(content = "This is a model")) + } + } +} \ No newline at end of file diff --git a/Android/src/app/src/main/java/com/google/aiedge/gallery/ui/common/chat/MessageBodyLoading.kt b/Android/src/app/src/main/java/com/google/aiedge/gallery/ui/common/chat/MessageBodyLoading.kt new file mode 100644 index 0000000..705625f --- /dev/null +++ b/Android/src/app/src/main/java/com/google/aiedge/gallery/ui/common/chat/MessageBodyLoading.kt @@ -0,0 +1,142 @@ +/* + * Copyright 2025 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.google.aiedge.gallery.ui.common.chat + +import androidx.compose.animation.core.Animatable +import androidx.compose.animation.core.tween +import androidx.compose.foundation.Image +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.mutableIntStateOf +import androidx.compose.runtime.remember +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.ColorFilter +import androidx.compose.ui.graphics.graphicsLayer +import androidx.compose.ui.layout.ContentScale +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import com.google.aiedge.gallery.R +import com.google.aiedge.gallery.ui.common.getTaskIconColor +import com.google.aiedge.gallery.ui.theme.GalleryTheme +import kotlinx.coroutines.delay +import kotlinx.coroutines.launch + +private val IMAGE_RESOURCES = listOf( + R.drawable.pantegon, + R.drawable.double_circle, + R.drawable.circle, + R.drawable.four_circle +) + +private const val ANIMATION_DURATION = 300 +private const val ANIMATION_DURATION2 = 300 +private const val PAUSE_DURATION = 200 +private const val PAUSE_DURATION2 = 0 + +/** + * Composable function to display a loading indicator. + */ +@Composable +fun MessageBodyLoading() { + val progress = remember { Animatable(0f) } + val alphaAnim = remember { Animatable(0f) } + val activeImageIndex = remember { mutableIntStateOf(0) } + + LaunchedEffect(Unit) { // Run this once + while (true) { + var progressJob = launch { + progress.animateTo( + targetValue = 1f, + animationSpec = tween( + durationMillis = ANIMATION_DURATION, + easing = multiBounceEasing(bounces = 3, decay = 0.02f) + ) + ) + } + var alphaJob = launch { + alphaAnim.animateTo( + targetValue = 1f, + animationSpec = tween( + durationMillis = ANIMATION_DURATION / 2, + ) + ) + } + progressJob.join() + alphaJob.join() + delay((PAUSE_DURATION).toLong()) + + progressJob = launch { + progress.animateTo( + targetValue = 0f, + animationSpec = tween( + durationMillis = ANIMATION_DURATION2, + easing = multiBounceEasing(bounces = 3, decay = 0.02f) + ) + ) + } + alphaJob = launch { + alphaAnim.animateTo( + targetValue = 0f, + animationSpec = tween( + durationMillis = ANIMATION_DURATION2 / 2, + ) + ) + } + + progressJob.join() + alphaJob.join() + delay(PAUSE_DURATION2.toLong()) + + activeImageIndex.intValue = (activeImageIndex.intValue + 1) % IMAGE_RESOURCES.size + } + } + + Box(contentAlignment = Alignment.Center) { + for ((index, imageResource) in IMAGE_RESOURCES.withIndex()) { + Image( + painter = painterResource(id = imageResource), + contentDescription = "", + contentScale = ContentScale.Fit, + colorFilter = ColorFilter.tint(getTaskIconColor(index = index)), + modifier = Modifier + .graphicsLayer { + scaleX = progress.value * 0.2f + 0.8f + scaleY = progress.value * 0.2f + 0.8f + rotationZ = progress.value * 100 + alpha = if (index != activeImageIndex.intValue) 0f else alphaAnim.value + } + .size(24.dp) + ) + } + } +} + +@Preview(showBackground = true) +@Composable +fun MessageBodyLoadingPreview() { + GalleryTheme { + Row(modifier = Modifier.padding(16.dp)) { + MessageBodyLoading() + } + } +} \ No newline at end of file diff --git a/Android/src/app/src/main/java/com/google/aiedge/gallery/ui/common/chat/MessageBodyPromptTemplates.kt b/Android/src/app/src/main/java/com/google/aiedge/gallery/ui/common/chat/MessageBodyPromptTemplates.kt new file mode 100644 index 0000000..0547ea4 --- /dev/null +++ b/Android/src/app/src/main/java/com/google/aiedge/gallery/ui/common/chat/MessageBodyPromptTemplates.kt @@ -0,0 +1,168 @@ +/* + * Copyright 2025 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.google.aiedge.gallery.ui.common.chat + +import androidx.compose.foundation.background +import androidx.compose.foundation.border +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.offset +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.items +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.shadow +import androidx.compose.ui.graphics.Brush +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import com.google.aiedge.gallery.data.Task +import com.google.aiedge.gallery.ui.common.getTaskIconColor +import com.google.aiedge.gallery.ui.preview.ALL_PREVIEW_TASKS +import com.google.aiedge.gallery.ui.preview.TASK_TEST1 +import com.google.aiedge.gallery.ui.theme.GalleryTheme + +private const val CARD_HEIGHT = 100 + +@Composable +fun MessageBodyPromptTemplates( + message: ChatMessagePromptTemplates, + task: Task, + onPromptClicked: (PromptTemplate) -> Unit = {}, +) { + val rowCount = message.templates.size.toFloat() + val color = getTaskIconColor(task) + val gradientColors = listOf(color.copy(alpha = 0.5f), color) + + Column( + modifier = Modifier.padding(top = 12.dp), + verticalArrangement = Arrangement.spacedBy(8.dp) + ) { + Text( + "Try an example prompt", + style = MaterialTheme.typography.titleLarge.copy( + fontWeight = FontWeight.Bold, + brush = Brush.linearGradient( + colors = gradientColors, + ) + ), + modifier = Modifier.fillMaxWidth(), + textAlign = TextAlign.Center, + ) + if (message.showMakeYourOwn) { + Text( + "Or make your own", + style = MaterialTheme.typography.titleSmall, + modifier = Modifier + .fillMaxWidth() + .offset(y = -4.dp), + textAlign = TextAlign.Center, + ) + } + LazyColumn( + modifier = Modifier + .height((rowCount * (CARD_HEIGHT + 8)).dp), + verticalArrangement = Arrangement.spacedBy(8.dp), + ) { + // Cards. + items(message.templates) { template -> + Box( + modifier = Modifier + .border( + width = 1.dp, + color = color.copy(alpha = 0.3f), + shape = RoundedCornerShape(24.dp) + ) + .height(CARD_HEIGHT.dp) + .shadow( + elevation = 2.dp, + shape = RoundedCornerShape(24.dp), + spotColor = color + ) + .background(MaterialTheme.colorScheme.surface) + .clickable { + onPromptClicked(template) + } + ) { + Column( + modifier = Modifier + .padding(horizontal = 12.dp, vertical = 20.dp) + .fillMaxSize(), + horizontalAlignment = Alignment.CenterHorizontally, + ) { + Text( + template.title, + style = MaterialTheme.typography.titleMedium.copy(fontWeight = FontWeight.Bold), + ) + Spacer(modifier = Modifier.weight(1f)) + Text( + template.description, + style = MaterialTheme.typography.bodyMedium, + textAlign = TextAlign.Center, + ) + } + } + } + } + } +} + +@Preview(showBackground = true) +@Composable +fun MessageBodyPromptTemplatesPreview() { + for ((index, task) in ALL_PREVIEW_TASKS.withIndex()) { + task.index = index + for (model in task.models) { + model.preProcess(task = task) + } + } + + GalleryTheme { + Row(modifier = Modifier.padding(16.dp)) { + MessageBodyPromptTemplates( + message = ChatMessagePromptTemplates( + templates = listOf( + PromptTemplate( + title = "Math Worksheets", + description = "Create a set of math worksheets for parents", + prompt = "" + ), + PromptTemplate( + title = "Shape Sequencer", + description = "Find the next shape in a sequence", + prompt = "" + ) + ) + ), + task = TASK_TEST1, + ) + } + } +} diff --git a/Android/src/app/src/main/java/com/google/aiedge/gallery/ui/common/chat/MessageBodyText.kt b/Android/src/app/src/main/java/com/google/aiedge/gallery/ui/common/chat/MessageBodyText.kt new file mode 100644 index 0000000..ed4c58f --- /dev/null +++ b/Android/src/app/src/main/java/com/google/aiedge/gallery/ui/common/chat/MessageBodyText.kt @@ -0,0 +1,80 @@ +/* + * Copyright 2025 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.google.aiedge.gallery.ui.common.chat + +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.padding +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import com.google.aiedge.gallery.ui.theme.GalleryTheme + +/** + * Composable function to display the text content of a ChatMessageText. + */ +@Composable +fun MessageBodyText(message: ChatMessageText) { + if (message.side == ChatSide.USER) { + Text( + message.content, + style = MaterialTheme.typography.bodyMedium.copy(fontWeight = FontWeight.Medium), + color = Color.White, + modifier = Modifier.padding(12.dp) + ) + } else if (message.side == ChatSide.AGENT) { + if (message.isMarkdown) { + MarkdownText(text = message.content, modifier = Modifier.padding(12.dp)) + } else { + Text( + message.content, + style = MaterialTheme.typography.bodyMedium.copy(fontWeight = FontWeight.Medium), + color = MaterialTheme.colorScheme.onSurface, + modifier = Modifier.padding(12.dp) + ) + } + } +} + +@Preview(showBackground = true) +@Composable +fun MessageBodyTextPreview() { + GalleryTheme { + Column { + Row( + modifier = Modifier + .padding(16.dp) + .background(MaterialTheme.colorScheme.primary), + ) { + MessageBodyText(ChatMessageText(content = "Hello world", side = ChatSide.USER)) + } + Row( + modifier = Modifier + .padding(16.dp) + .background(MaterialTheme.colorScheme.surfaceContainer), + ) { + MessageBodyText(ChatMessageText(content = "yes hello world", side = ChatSide.AGENT)) + } + } + } +} \ No newline at end of file diff --git a/Android/src/app/src/main/java/com/google/aiedge/gallery/ui/common/chat/MessageBubbleShape.kt b/Android/src/app/src/main/java/com/google/aiedge/gallery/ui/common/chat/MessageBubbleShape.kt new file mode 100644 index 0000000..26d523c --- /dev/null +++ b/Android/src/app/src/main/java/com/google/aiedge/gallery/ui/common/chat/MessageBubbleShape.kt @@ -0,0 +1,69 @@ +/* + * Copyright 2025 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.google.aiedge.gallery.ui.common.chat + +import androidx.compose.ui.geometry.CornerRadius +import androidx.compose.ui.geometry.RoundRect +import androidx.compose.ui.geometry.Size +import androidx.compose.ui.graphics.Outline +import androidx.compose.ui.graphics.Path +import androidx.compose.ui.graphics.Shape +import androidx.compose.ui.unit.Density +import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.LayoutDirection + +/** + * Custom Shape for creating message bubble outlines with configurable corner radii. + * + * This class defines a custom Shape that generates a rounded rectangle outline, + * suitable for message bubbles. It allows specifying a uniform corner radius for + * most corners, but also provides the option to have a hard (non-rounded) corner + * on either the left or right side. + */ +class MessageBubbleShape( + private val radius: Dp, + private val hardCornerAtLeftOrRight: Boolean = false +) : Shape { + override fun createOutline( + size: Size, + layoutDirection: LayoutDirection, + density: Density + ): Outline { + val radiusPx = with(density) { radius.toPx() } + val path = Path().apply { + addRoundRect( + RoundRect( + left = 0f, + top = 0f, + right = size.width, + bottom = size.height, + topLeftCornerRadius = if (hardCornerAtLeftOrRight) CornerRadius(0f, 0f) else CornerRadius( + radiusPx, + radiusPx + ), + topRightCornerRadius = if (hardCornerAtLeftOrRight) CornerRadius( + radiusPx, + radiusPx + ) else CornerRadius(0f, 0f), // No rounding here + bottomLeftCornerRadius = CornerRadius(radiusPx, radiusPx), + bottomRightCornerRadius = CornerRadius(radiusPx, radiusPx) + ) + ) + } + return Outline.Generic(path) + } +} diff --git a/Android/src/app/src/main/java/com/google/aiedge/gallery/ui/common/chat/MessageInputImage.kt b/Android/src/app/src/main/java/com/google/aiedge/gallery/ui/common/chat/MessageInputImage.kt new file mode 100644 index 0000000..29a95fd --- /dev/null +++ b/Android/src/app/src/main/java/com/google/aiedge/gallery/ui/common/chat/MessageInputImage.kt @@ -0,0 +1,269 @@ +/* + * Copyright 2025 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.google.aiedge.gallery.ui.common.chat + +import android.Manifest +import android.content.Context +import android.content.pm.PackageManager +import android.graphics.Bitmap +import android.graphics.BitmapFactory +import android.graphics.Matrix +import android.net.Uri +import android.util.Log +import androidx.activity.compose.rememberLauncherForActivityResult +import androidx.activity.result.PickVisualMediaRequest +import androidx.activity.result.contract.ActivityResultContracts +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.rounded.Photo +import androidx.compose.material.icons.rounded.PhotoCamera +import androidx.compose.material.icons.rounded.Videocam +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.IconButtonDefaults +import androidx.compose.material3.MaterialTheme +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.alpha +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import androidx.core.content.ContextCompat +import com.google.aiedge.gallery.ui.common.createTempPictureUri +import com.google.aiedge.gallery.ui.theme.GalleryTheme + +private const val TAG = "AGMessageInputImage" + +/** + * Composable function to display image input options for chat messages. + * + * This function renders a row containing buttons that allow the user to select images from albums, + * take photos using the camera, or initiate a live camera stream. It handles permission requests, + * image selection, and launching camera activities. + */ +@Composable +fun MessageInputImage( + onImageSelected: (Bitmap) -> Unit, + streamingMessage: ChatMessage? = null, + onStreamImage: (Bitmap) -> Unit = {}, + onStreamEnd: (Int) -> Unit = {}, + disableButtons: Boolean = false, +) { + val context = LocalContext.current + var tempPhotoUri by remember { mutableStateOf(value = Uri.EMPTY) } + var showLiveCameraDialog by remember { mutableStateOf(false) } + + // Registers a photo picker activity launcher in single-select mode. + val pickMedia = + rememberLauncherForActivityResult(ActivityResultContracts.PickVisualMedia()) { uri -> + // Callback is invoked after the user selects a media item or closes the + // photo picker. + if (uri != null) { + handleImageSelected(context = context, uri = uri, onImageSelected = onImageSelected) + } else { + Log.d(TAG, "No media selected") + } + } + + // launches camera + val cameraLauncher = + rememberLauncherForActivityResult(ActivityResultContracts.TakePicture()) { isImageSaved -> + if (isImageSaved) { + handleImageSelected( + context = context, + uri = tempPhotoUri, + onImageSelected = onImageSelected, + rotateForPortrait = true, + ) + } + } + + // Permission request when taking picture. + val takePicturePermissionLauncher = rememberLauncherForActivityResult( + ActivityResultContracts.RequestPermission() + ) { permissionGranted -> + if (permissionGranted) { + tempPhotoUri = context.createTempPictureUri() + cameraLauncher.launch(tempPhotoUri) + } + } + + // Permission request when using live camera. + val liveCameraPermissionLauncher = rememberLauncherForActivityResult( + ActivityResultContracts.RequestPermission() + ) { permissionGranted -> + if (permissionGranted) { + showLiveCameraDialog = true + } + } + + val buttonAlpha = if (disableButtons) 0.3f else 1f + + Row( + modifier = Modifier + .fillMaxWidth() + .padding(12.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.End, + ) { + // Pick from albums. + IconButton( + onClick = { + if (disableButtons) { + return@IconButton + } + + // Launch the photo picker and let the user choose only images. + pickMedia.launch(PickVisualMediaRequest(ActivityResultContracts.PickVisualMedia.ImageOnly)) + }, + colors = IconButtonDefaults.iconButtonColors( + containerColor = MaterialTheme.colorScheme.primary, + ), + modifier = Modifier.alpha(buttonAlpha), + ) { + Icon(Icons.Rounded.Photo, contentDescription = "", tint = MaterialTheme.colorScheme.onPrimary) + } + + // Take picture + IconButton( + onClick = { + if (disableButtons) { + return@IconButton + } + + // Check permission + when (PackageManager.PERMISSION_GRANTED) { + // Already got permission. Call the lambda. + ContextCompat.checkSelfPermission( + context, Manifest.permission.CAMERA + ) -> { + tempPhotoUri = context.createTempPictureUri() + cameraLauncher.launch(tempPhotoUri) + } + + // Otherwise, ask for permission + else -> { + takePicturePermissionLauncher.launch(Manifest.permission.CAMERA) + } + } + }, + colors = IconButtonDefaults.iconButtonColors( + containerColor = MaterialTheme.colorScheme.primary, + ), + modifier = Modifier.alpha(buttonAlpha), + ) { + Icon( + Icons.Rounded.PhotoCamera, + contentDescription = "", + tint = MaterialTheme.colorScheme.onPrimary + ) + } + + // Video stream. + IconButton( + onClick = { + if (disableButtons) { + return@IconButton + } + + // Check permission + when (PackageManager.PERMISSION_GRANTED) { + // Already got permission. Call the lambda. + ContextCompat.checkSelfPermission( + context, Manifest.permission.CAMERA + ) -> { + showLiveCameraDialog = true + } + + // Otherwise, ask for permission + else -> { + liveCameraPermissionLauncher.launch(Manifest.permission.CAMERA) + } + } + }, + colors = IconButtonDefaults.iconButtonColors( + containerColor = MaterialTheme.colorScheme.primary, + ), + modifier = Modifier.alpha(buttonAlpha), + ) { + Icon( + Icons.Rounded.Videocam, contentDescription = "", tint = MaterialTheme.colorScheme.onPrimary + ) + } + } + + // Live camera stream dialog. + if (showLiveCameraDialog) { + LiveCameraDialog( + streamingMessage = streamingMessage, onDismissed = { averageFps -> + onStreamEnd(averageFps) + showLiveCameraDialog = false + }, onBitmap = onStreamImage + ) + } +} + +private fun handleImageSelected( + context: Context, + uri: Uri, + onImageSelected: (Bitmap) -> Unit, + // For some reason, some Android phone would store the picture taken by the camera rotated + // horizontally. Use this flag to rotate the image back to portrait if the picture's width + // is bigger than height. + rotateForPortrait: Boolean = false, +) { + Log.d(TAG, "Selected URI: $uri") + + val bitmap: Bitmap? = try { + val inputStream = context.contentResolver.openInputStream(uri) + val tmpBitmap = BitmapFactory.decodeStream(inputStream) + if (rotateForPortrait && tmpBitmap.width > tmpBitmap.height) { + val matrix = Matrix() + matrix.postRotate(90f) + Bitmap.createBitmap(tmpBitmap, 0, 0, tmpBitmap.width, tmpBitmap.height, matrix, true) + } else { + tmpBitmap + } + } catch (e: Exception) { + e.printStackTrace() + null + } + if (bitmap != null) { + onImageSelected(bitmap) + } +} + +@Preview(showBackground = true) +@Composable +fun MessageInputImagePreview() { + GalleryTheme { + Column { + MessageInputImage(onImageSelected = {}) + MessageInputImage(disableButtons = true, onImageSelected = {}) + } + } +} + diff --git a/Android/src/app/src/main/java/com/google/aiedge/gallery/ui/common/chat/MessageInputText.kt b/Android/src/app/src/main/java/com/google/aiedge/gallery/ui/common/chat/MessageInputText.kt new file mode 100644 index 0000000..2be0ecc --- /dev/null +++ b/Android/src/app/src/main/java/com/google/aiedge/gallery/ui/common/chat/MessageInputText.kt @@ -0,0 +1,268 @@ +/* + * Copyright 2025 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.google.aiedge.gallery.ui.common.chat + +import androidx.annotation.StringRes +import androidx.compose.foundation.border +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.offset +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.rounded.Send +import androidx.compose.material.icons.rounded.Add +import androidx.compose.material.icons.rounded.History +import androidx.compose.material.icons.rounded.PostAdd +import androidx.compose.material.icons.rounded.Stop +import androidx.compose.material3.DropdownMenu +import androidx.compose.material3.DropdownMenuItem +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.IconButtonDefaults +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.material3.TextField +import androidx.compose.material3.TextFieldDefaults +import androidx.compose.runtime.Composable +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.alpha +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import com.google.aiedge.gallery.R +import com.google.aiedge.gallery.ui.modelmanager.ModelManagerViewModel +import com.google.aiedge.gallery.ui.preview.PreviewModelManagerViewModel +import com.google.aiedge.gallery.ui.theme.GalleryTheme + +/** + * Composable function to display a text input field for composing chat messages. + * + * This function renders a row containing a text field for message input and a send button. + * It handles message composition, input validation, and sending messages. + */ +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun MessageInputText( + modelManagerViewModel: ModelManagerViewModel, + curMessage: String, + inProgress: Boolean, + @StringRes textFieldPlaceHolderRes: Int, + onValueChanged: (String) -> Unit, + onSendMessage: (ChatMessage) -> Unit, + onOpenPromptTemplatesClicked: () -> Unit = {}, + onStopButtonClicked: () -> Unit = {}, + showPromptTemplatesInMenu: Boolean = true, + showStopButtonWhenInProgress: Boolean = false, +) { + val modelManagerUiState by modelManagerViewModel.uiState.collectAsState() + var showAddContentMenu by remember { mutableStateOf(false) } + var showTextInputHistorySheet by remember { mutableStateOf(false) } + + Box(contentAlignment = Alignment.CenterStart) { + // A plus button to show a popup menu to add stuff to the chat. + IconButton( + enabled = !inProgress, + onClick = { showAddContentMenu = true }, + modifier = Modifier + .offset(x = 16.dp) + .alpha(0.8f) + ) { + Icon( + Icons.Rounded.Add, + contentDescription = "", + modifier = Modifier.size(28.dp), + ) + } + Row( + modifier = Modifier + .fillMaxWidth() + .padding(12.dp) + .border(1.dp, MaterialTheme.colorScheme.outlineVariant, RoundedCornerShape(28.dp)), + verticalAlignment = Alignment.CenterVertically, + ) { + DropdownMenu( + expanded = showAddContentMenu, + onDismissRequest = { showAddContentMenu = false }) { + if (showPromptTemplatesInMenu) { + DropdownMenuItem(text = { + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(6.dp) + ) { + Icon(Icons.Rounded.PostAdd, contentDescription = "") + Text("Prompt templates") + } + }, onClick = { + onOpenPromptTemplatesClicked() + showAddContentMenu = false + }) + } + DropdownMenuItem(text = { + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(6.dp) + ) { + Icon(Icons.Rounded.History, contentDescription = "") + Text("Input history") + } + }, onClick = { + showAddContentMenu = false + showTextInputHistorySheet = true + }) + } + + // Text field. + TextField(value = curMessage, + minLines = 1, + maxLines = 3, + onValueChange = onValueChanged, + colors = TextFieldDefaults.colors( + unfocusedContainerColor = Color.Transparent, + focusedContainerColor = Color.Transparent, + focusedIndicatorColor = Color.Transparent, + unfocusedIndicatorColor = Color.Transparent, + disabledIndicatorColor = Color.Transparent, + disabledContainerColor = Color.Transparent, + ), + textStyle = MaterialTheme.typography.bodyMedium, + modifier = Modifier + .weight(1f) + .padding(start = 36.dp), + placeholder = { Text(stringResource(textFieldPlaceHolderRes)) }) + + Spacer(modifier = Modifier.width(8.dp)) + + if (inProgress && showStopButtonWhenInProgress) { + IconButton( + onClick = onStopButtonClicked, + colors = IconButtonDefaults.iconButtonColors( + containerColor = MaterialTheme.colorScheme.secondaryContainer, + ), + ) { + Icon( + Icons.Rounded.Stop, + contentDescription = "", + tint = MaterialTheme.colorScheme.primary + ) + } + } // Send button. Only shown when text is not empty. + else if (curMessage.isNotEmpty()) { + IconButton( + enabled = !inProgress, + onClick = { + onSendMessage(ChatMessageText(content = curMessage.trim(), side = ChatSide.USER)) + }, + colors = IconButtonDefaults.iconButtonColors( + containerColor = MaterialTheme.colorScheme.secondaryContainer, + ), + ) { + Icon( + Icons.AutoMirrored.Rounded.Send, + contentDescription = "", + modifier = Modifier.offset(x = 2.dp), + tint = if (inProgress) MaterialTheme.colorScheme.surfaceContainerHigh else MaterialTheme.colorScheme.primary + ) + } + } + Spacer(modifier = Modifier.width(4.dp)) + } + } + + + // A bottom sheet to show the text input history to pick from. + if (showTextInputHistorySheet) { + TextInputHistorySheet( + history = modelManagerUiState.textInputHistory, + onDismissed = { + showTextInputHistorySheet = false + }, + onHistoryItemClicked = { item -> + onSendMessage(ChatMessageText(content = item, side = ChatSide.USER)) + modelManagerViewModel.promoteTextInputHistoryItem(item) + }, + onHistoryItemDeleted = { item -> + modelManagerViewModel.deleteTextInputHistory(item) + }, + onHistoryItemsDeleteAll = { + modelManagerViewModel.clearTextInputHistory() + } + ) + } +} + +@Preview(showBackground = true) +@Composable +fun MessageInputTextPreview() { + val context = LocalContext.current + + GalleryTheme { + Column { + MessageInputText( + modelManagerViewModel = PreviewModelManagerViewModel(context = context), + curMessage = "hello", + inProgress = false, + textFieldPlaceHolderRes = R.string.chat_textinput_placeholder, + onValueChanged = {}, + onSendMessage = {}, + showStopButtonWhenInProgress = true, + ) + MessageInputText( + modelManagerViewModel = PreviewModelManagerViewModel(context = context), + curMessage = "hello", + inProgress = true, + textFieldPlaceHolderRes = R.string.chat_textinput_placeholder, + onValueChanged = {}, + onSendMessage = {}, + ) + MessageInputText( + modelManagerViewModel = PreviewModelManagerViewModel(context = context), + curMessage = "", + inProgress = false, + textFieldPlaceHolderRes = R.string.chat_textinput_placeholder, + onValueChanged = {}, + onSendMessage = {}, + ) + MessageInputText( + modelManagerViewModel = PreviewModelManagerViewModel(context = context), + curMessage = "", + inProgress = true, + textFieldPlaceHolderRes = R.string.chat_textinput_placeholder, + onValueChanged = {}, + onSendMessage = {}, + showStopButtonWhenInProgress = true, + ) + } + } +} + + diff --git a/Android/src/app/src/main/java/com/google/aiedge/gallery/ui/common/chat/MessageLatency.kt b/Android/src/app/src/main/java/com/google/aiedge/gallery/ui/common/chat/MessageLatency.kt new file mode 100644 index 0000000..c387a77 --- /dev/null +++ b/Android/src/app/src/main/java/com/google/aiedge/gallery/ui/common/chat/MessageLatency.kt @@ -0,0 +1,63 @@ +/* + * Copyright 2025 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.google.aiedge.gallery.ui.common.chat + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.padding +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.alpha +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import com.google.aiedge.gallery.ui.common.humanReadableDuration +import com.google.aiedge.gallery.ui.theme.GalleryTheme + +/** + * Composable function to display the latency of a chat message, if available. + */ +@Composable +fun LatencyText(message: ChatMessage) { + if (message.latencyMs >= 0) { + Text( + message.latencyMs.humanReadableDuration(), + modifier = Modifier.alpha(0.5f), + style = MaterialTheme.typography.labelSmall, + ) + } +} + + +@Preview(showBackground = true) +@Composable +fun LatencyTextPreview() { + GalleryTheme { + Column(modifier = Modifier.padding(16.dp), verticalArrangement = Arrangement.spacedBy(16.dp)) { + for (latencyMs in listOf(123f, 1234f, 123456f, 7234567f)) { + LatencyText( + message = ChatMessage( + latencyMs = latencyMs, + type = ChatMessageType.TEXT, + side = ChatSide.AGENT + ) + ) + } + } + } +} \ No newline at end of file diff --git a/Android/src/app/src/main/java/com/google/aiedge/gallery/ui/common/chat/MessageSender.kt b/Android/src/app/src/main/java/com/google/aiedge/gallery/ui/common/chat/MessageSender.kt new file mode 100644 index 0000000..56cb84f --- /dev/null +++ b/Android/src/app/src/main/java/com/google/aiedge/gallery/ui/common/chat/MessageSender.kt @@ -0,0 +1,256 @@ +/* + * Copyright 2025 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.google.aiedge.gallery.ui.common.chat + +import android.graphics.Bitmap +import androidx.annotation.StringRes +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import com.google.aiedge.gallery.R +import com.google.aiedge.gallery.ui.theme.GalleryTheme +import com.google.aiedge.gallery.ui.theme.bodySmallNarrow +import com.google.aiedge.gallery.ui.theme.bodySmallSemiBold + +data class MessageLayoutConfig( + val horizontalArrangement: Arrangement.Horizontal, + val modifier: Modifier, + val userLabel: String, + val rightSideLabel: String +) + +/** + * Composable function to display the sender information for a chat message. + * + * This function handles different types of chat messages, including system messages, + * benchmark results, and image generation results, and displays the appropriate sender label + * and status information. + */ +@Composable +fun MessageSender( + message: ChatMessage, @StringRes agentNameRes: Int, imageHistoryCurIndex: Int = 0 +) { + // No user label for system messages. + if (message.side == ChatSide.SYSTEM) { + return + } + + val (horizontalArrangement, modifier, userLabel, rightSideLabel) = getMessageLayoutConfig( + message = message, agentNameRes = agentNameRes, imageHistoryCurIndex = imageHistoryCurIndex + ) + + Row( + modifier = modifier, + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = horizontalArrangement, + ) { + Row(verticalAlignment = Alignment.CenterVertically) { + // Sender label. + Text( + userLabel, + style = bodySmallSemiBold, + ) + + when (message) { + // Benchmark running status. + is ChatMessageBenchmarkResult -> { + if (message.isRunning()) { + Spacer(modifier = Modifier.width(8.dp)) + CircularProgressIndicator( + modifier = Modifier.size(10.dp), + strokeWidth = 1.5.dp, + color = MaterialTheme.colorScheme.secondary + ) + Spacer(modifier = Modifier.width(4.dp)) + } + val statusLabel = if (message.isWarmingUp()) { + stringResource(R.string.warming_up) + } else if (message.isRunning()) { + stringResource(R.string.running) + } else "" + if (statusLabel.isNotEmpty()) { + Text( + statusLabel, + color = MaterialTheme.colorScheme.secondary, + style = bodySmallNarrow, + ) + } + } + + // Benchmark LLM running status. + is ChatMessageBenchmarkLlmResult -> { + if (message.running) { + Spacer(modifier = Modifier.width(8.dp)) + CircularProgressIndicator( + modifier = Modifier.size(10.dp), + strokeWidth = 1.5.dp, + color = MaterialTheme.colorScheme.secondary + ) + } + } + + // Image generation running status. + is ChatMessageImageWithHistory -> { + if (message.isRunning()) { + Spacer(modifier = Modifier.width(8.dp)) + CircularProgressIndicator( + modifier = Modifier.size(10.dp), + strokeWidth = 1.5.dp, + color = MaterialTheme.colorScheme.secondary + ) + Spacer(modifier = Modifier.width(4.dp)) + Text( + stringResource(R.string.running), + color = MaterialTheme.colorScheme.secondary, + style = bodySmallNarrow, + ) + } + } + } + } + + // Right-side text. + when (message) { + is ChatMessageBenchmarkResult, + is ChatMessageImageWithHistory, + is ChatMessageBenchmarkLlmResult, + -> { + Text(rightSideLabel, style = MaterialTheme.typography.bodySmall) + } + } + } +} + +@Composable +private fun getMessageLayoutConfig( + message: ChatMessage, + @StringRes agentNameRes: Int, + imageHistoryCurIndex: Int, +): MessageLayoutConfig { + var userLabel = stringResource(R.string.chat_you) + var rightSideLabel = "" + var horizontalArrangement = Arrangement.End + var modifier = Modifier.padding(bottom = 2.dp) + + if (message.side == ChatSide.AGENT) { + userLabel = stringResource(agentNameRes) + } + + when (message) { + is ChatMessageBenchmarkResult -> { + horizontalArrangement = Arrangement.SpaceBetween + modifier = modifier.fillMaxWidth() + userLabel = "Benchmark" + rightSideLabel = if (message.isWarmingUp()) { + "${message.warmupCurrent}/${message.warmupTotal}" + } else { + "${message.iterationCurrent}/${message.iterationTotal}" + } + } + + is ChatMessageBenchmarkLlmResult -> { + horizontalArrangement = Arrangement.SpaceBetween + modifier = modifier.fillMaxWidth() + userLabel = "Benchmark" + } + + is ChatMessageImageWithHistory -> { + horizontalArrangement = Arrangement.SpaceBetween + if (message.bitmaps.isNotEmpty()) { + modifier = modifier.width(200.dp) + } + rightSideLabel = "${imageHistoryCurIndex + 1}/${message.totalIterations}" + } + } + + return MessageLayoutConfig( + horizontalArrangement = horizontalArrangement, + modifier = modifier, + userLabel = userLabel, + rightSideLabel = rightSideLabel + ) +} + +@Preview(showBackground = true) +@Composable +fun MessageSenderPreview() { + GalleryTheme { + Column(modifier = Modifier.padding(16.dp), verticalArrangement = Arrangement.spacedBy(16.dp)) { + // Agent message. + MessageSender( + message = ChatMessageText(content = "hello world", side = ChatSide.AGENT), + agentNameRes = R.string.chat_generic_agent_name + ) + // User message. + MessageSender( + message = ChatMessageText(content = "hello world", side = ChatSide.USER), + agentNameRes = R.string.chat_generic_agent_name + ) + // Benchmark during warmup. + MessageSender( + message = ChatMessageBenchmarkResult( + orderedStats = listOf(), + statValues = mutableMapOf(), + values = listOf(), + histogram = Histogram(listOf(), 0), + warmupCurrent = 10, + warmupTotal = 50, + iterationCurrent = 0, + iterationTotal = 200 + ), agentNameRes = R.string.chat_generic_agent_name + ) + // Benchmark during running. + MessageSender( + message = ChatMessageBenchmarkResult( + orderedStats = listOf(), + statValues = mutableMapOf(), + values = listOf(), + histogram = Histogram(listOf(), 0), + warmupCurrent = 50, + warmupTotal = 50, + iterationCurrent = 123, + iterationTotal = 200 + ), agentNameRes = R.string.chat_generic_agent_name + ) + // Image generation during running. + MessageSender( + message = ChatMessageImageWithHistory( + bitmaps = listOf(Bitmap.createBitmap(100, 100, Bitmap.Config.ARGB_8888)), + imageBitMaps = listOf(), + totalIterations = 10, + ChatSide.AGENT + ), + agentNameRes = R.string.chat_generic_agent_name, + imageHistoryCurIndex = 4, + ) + } + } +} diff --git a/Android/src/app/src/main/java/com/google/aiedge/gallery/ui/common/chat/ModelDownloadingAnimation.kt b/Android/src/app/src/main/java/com/google/aiedge/gallery/ui/common/chat/ModelDownloadingAnimation.kt new file mode 100644 index 0000000..cdfbda6 --- /dev/null +++ b/Android/src/app/src/main/java/com/google/aiedge/gallery/ui/common/chat/ModelDownloadingAnimation.kt @@ -0,0 +1,176 @@ +/* + * Copyright 2025 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.google.aiedge.gallery.ui.common.chat + +import androidx.compose.animation.core.Animatable +import androidx.compose.animation.core.Easing +import androidx.compose.animation.core.tween +import androidx.compose.foundation.Image +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.offset +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.lazy.grid.GridCells +import androidx.compose.foundation.lazy.grid.LazyVerticalGrid +import androidx.compose.foundation.lazy.grid.itemsIndexed +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.remember +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.ColorFilter +import androidx.compose.ui.graphics.graphicsLayer +import androidx.compose.ui.layout.ContentScale +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import com.google.aiedge.gallery.R +import com.google.aiedge.gallery.ui.common.getTaskIconColor +import com.google.aiedge.gallery.ui.theme.GalleryTheme +import kotlinx.coroutines.delay +import kotlin.math.cos +import kotlin.math.pow + +private val GRID_SIZE = 240.dp +private val GRID_SPACING = 0.dp +private const val PAUSE_DURATION = 200 +private const val ANIMATION_DURATION = 500 +private const val START_SCALE = 0.9f +private const val END_SCALE = 0.6f + + +/** + * Composable function to display a loading animation using a 2x2 grid of images with a synchronized + * scaling and rotation effect. + */ +@Composable +fun ModelDownloadingAnimation() { + val scale = remember { Animatable(END_SCALE) } + + LaunchedEffect(Unit) { // Run this once + while (true) { + // Phase 1: Scale up + scale.animateTo( + targetValue = START_SCALE, + animationSpec = tween( + durationMillis = ANIMATION_DURATION, + easing = multiBounceEasing(bounces = 3, decay = 0.02f) + ) + ) + delay(PAUSE_DURATION.toLong()) + + // Phase 2: Scale down + scale.animateTo( + targetValue = END_SCALE, + animationSpec = tween( + durationMillis = ANIMATION_DURATION, + easing = multiBounceEasing(bounces = 3, decay = 0.02f) + ) + ) + delay(PAUSE_DURATION.toLong()) + } + } + + Column( + horizontalAlignment = Alignment.CenterHorizontally, + modifier = Modifier.offset(y = -GRID_SIZE / 8) + ) { + LazyVerticalGrid( + columns = GridCells.Fixed(2), + horizontalArrangement = Arrangement.spacedBy(GRID_SPACING), + verticalArrangement = Arrangement.spacedBy(GRID_SPACING), + modifier = Modifier + .width(GRID_SIZE) + .height(GRID_SIZE) + ) { + itemsIndexed( + listOf( + R.drawable.pantegon, + R.drawable.double_circle, + R.drawable.circle, + R.drawable.four_circle + ) + ) { index, imageResource -> + val currentScale = + if (index == 0 || index == 3) scale.value else START_SCALE + END_SCALE - scale.value + + Box( + modifier = Modifier + .width((GRID_SIZE - GRID_SPACING) / 2) + .height((GRID_SIZE - GRID_SPACING) / 2), + contentAlignment = when (index) { + 0 -> Alignment.BottomEnd + 1 -> Alignment.BottomStart + 2 -> Alignment.TopEnd + 3 -> Alignment.TopStart + else -> Alignment.Center + } + ) { + Image( + painter = painterResource(id = imageResource), + contentDescription = "", + contentScale = ContentScale.Fit, + colorFilter = ColorFilter.tint(getTaskIconColor(index = index)), + modifier = Modifier + .graphicsLayer { + scaleX = currentScale + scaleY = currentScale + rotationZ = currentScale * 120 + alpha = 0.8f + } + .size(70.dp) + ) + } + } + } + + Text( + "Feel free to switch apps or lock your device.\n" + + "The download will continue in the background.\n" + + "We'll send a notification when it's done.", + style = MaterialTheme.typography.bodyMedium, + textAlign = TextAlign.Center + ) + } +} + +// Custom Easing function for a multi-bounce effect +fun multiBounceEasing(bounces: Int, decay: Float): Easing = Easing { x -> + if (x == 1f) { + 1f + } else { + -decay.pow(x) * cos((x * (bounces + 0.9f) * Math.PI / 1.3f)).toFloat() + 1f + } +} + +@Preview(showBackground = true) +@Composable +fun ModelDownloadingAnimationPreview() { + GalleryTheme { + Row(modifier = Modifier.padding(16.dp)) { + ModelDownloadingAnimation() + } + } +} \ No newline at end of file diff --git a/Android/src/app/src/main/java/com/google/aiedge/gallery/ui/common/chat/ModelInitializationStatus.kt b/Android/src/app/src/main/java/com/google/aiedge/gallery/ui/common/chat/ModelInitializationStatus.kt new file mode 100644 index 0000000..b4eaa34 --- /dev/null +++ b/Android/src/app/src/main/java/com/google/aiedge/gallery/ui/common/chat/ModelInitializationStatus.kt @@ -0,0 +1,89 @@ +/* + * Copyright 2025 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.google.aiedge.gallery.ui.common.chat + +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import com.google.aiedge.gallery.R +import com.google.aiedge.gallery.ui.theme.GalleryTheme + +/** + * Composable function to display a visual indicator for model initialization status. + * + * This function renders a row containing a circular progress indicator and a message + * indicating that the model is currently initializing. It provides a visual cue to the + * user that the model is in a loading state. + */ +@Composable +fun ModelInitializationStatusChip() { + Row(modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.Center) { + Box( + modifier = Modifier + .padding(8.dp) + .clip(CircleShape) + .background(MaterialTheme.colorScheme.secondaryContainer) + ) { + Row( + modifier = Modifier.padding(top = 4.dp, bottom = 4.dp, start = 8.dp, end = 8.dp), + horizontalArrangement = Arrangement.Center, + verticalAlignment = Alignment.CenterVertically + ) { + // Circular progress indicator. + CircularProgressIndicator( + modifier = Modifier.size(14.dp), + strokeWidth = 2.dp, + color = MaterialTheme.colorScheme.onSecondaryContainer + ) + + Spacer(modifier = Modifier.width(8.dp)) + + // Text message. + Text( + stringResource(R.string.model_is_initializing_msg), + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSecondaryContainer, + ) + } + } + } +} + +@Preview(showBackground = true) +@Composable +fun ModelInitializationStatusPreview() { + GalleryTheme { + ModelInitializationStatusChip() + } +} diff --git a/Android/src/app/src/main/java/com/google/aiedge/gallery/ui/common/chat/ModelNotDownloaded.kt b/Android/src/app/src/main/java/com/google/aiedge/gallery/ui/common/chat/ModelNotDownloaded.kt new file mode 100644 index 0000000..eea3cdf --- /dev/null +++ b/Android/src/app/src/main/java/com/google/aiedge/gallery/ui/common/chat/ModelNotDownloaded.kt @@ -0,0 +1,54 @@ +/* + * Copyright 2025 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.google.aiedge.gallery.ui.common.chat + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.material3.Button +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.tooling.preview.Preview +import com.google.aiedge.gallery.ui.theme.GalleryTheme + +/** + * Composable function to display a button to download model if the model has not been downloaded. + */ +@Composable +fun ModelNotDownloaded(modifier: Modifier = Modifier, onClicked: () -> Unit) { + Column( + modifier = modifier.fillMaxSize(), + verticalArrangement = Arrangement.Center, + horizontalAlignment = Alignment.CenterHorizontally + ) { + Button( + onClick = onClicked, + ) { + Text("Download & Try it", maxLines = 1) + } + } +} + +@Preview(showBackground = true) +@Composable +fun Preview() { + GalleryTheme { + ModelNotDownloaded(onClicked = {}) + } +} \ No newline at end of file diff --git a/Android/src/app/src/main/java/com/google/aiedge/gallery/ui/common/chat/ModelSelector.kt b/Android/src/app/src/main/java/com/google/aiedge/gallery/ui/common/chat/ModelSelector.kt new file mode 100644 index 0000000..b8853ff --- /dev/null +++ b/Android/src/app/src/main/java/com/google/aiedge/gallery/ui/common/chat/ModelSelector.kt @@ -0,0 +1,170 @@ +/* + * Copyright 2025 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.google.aiedge.gallery.ui.common.chat + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.graphicsLayer +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import com.google.aiedge.gallery.data.Model +import com.google.aiedge.gallery.data.Task +import com.google.aiedge.gallery.ui.common.convertValueToTargetType +import com.google.aiedge.gallery.ui.common.modelitem.ModelItem +import com.google.aiedge.gallery.ui.modelmanager.ModelManagerViewModel +import com.google.aiedge.gallery.ui.preview.PreviewModelManagerViewModel +import com.google.aiedge.gallery.ui.preview.TASK_TEST1 +import com.google.aiedge.gallery.ui.preview.TASK_TEST2 +import com.google.aiedge.gallery.ui.theme.GalleryTheme + +/** + * Composable function to display a selectable model item with an option to configure its settings. + */ +@Composable +fun ModelSelector( + model: Model, + task: Task, + modelManagerViewModel: ModelManagerViewModel, + modifier: Modifier = Modifier, + contentAlpha: Float = 1f, + onConfigChanged: (oldConfigValues: Map, newConfigValues: Map) -> Unit = { _, _ -> }, +) { + var showConfigDialog by remember { mutableStateOf(false) } + val context = LocalContext.current + + Column( + modifier = modifier + ) { + Box( + modifier = Modifier + .fillMaxWidth().padding(bottom = 8.dp), + contentAlignment = Alignment.Center + ) { + // Model row. + Row( + modifier = Modifier + .fillMaxWidth() + .graphicsLayer { alpha = contentAlpha }, + verticalAlignment = Alignment.CenterVertically + ) { + ModelItem( + model = model, + task = task, + modelManagerViewModel = modelManagerViewModel, + onModelClicked = {}, + onConfigClicked = { + showConfigDialog = true + }, + verticalSpacing = 10.dp, + modifier = Modifier + .weight(1f) + .padding(horizontal = 16.dp), + showDeleteButton = false, + showConfigButtonIfExisted = true, + canExpand = false, + ) + } + } + } + + // Config dialog. + if (showConfigDialog) { + ConfigDialog( + title = "Model configs", + configs = model.configs, + initialValues = model.configValues, + onDismissed = { showConfigDialog = false }, + onOk = { curConfigValues -> + // Hide config dialog. + showConfigDialog = false + + // Check if the configs are changed or not. Also check if the model needs to be + // re-initialized. + var same = true + var needReinitialization = false + for (config in model.configs) { + val key = config.key.label + val oldValue = convertValueToTargetType( + value = model.configValues.getValue(key), valueType = config.valueType + ) + val newValue = convertValueToTargetType( + value = curConfigValues.getValue(key), valueType = config.valueType + ) + if (oldValue != newValue) { + same = false + if (config.needReinitialization) { + needReinitialization = true + } + break + } + } + if (same) { + return@ConfigDialog + } + + // Save the config values to Model. + val oldConfigValues = model.configValues + model.configValues = curConfigValues + + // Force to re-initialize the model with the new configs. + if (needReinitialization) { + modelManagerViewModel.initializeModel(context = context, model = model, force = true) + } + + // Notify. + onConfigChanged(oldConfigValues, model.configValues) + }, + ) + } +} + +@Preview(showBackground = true) +@Composable +fun ModelSelectorPreview( +) { + GalleryTheme { + Column(verticalArrangement = Arrangement.spacedBy(16.dp)) { + ModelSelector( + model = TASK_TEST1.models[0], + task = TASK_TEST1, + modelManagerViewModel = PreviewModelManagerViewModel(context = LocalContext.current), + ) + ModelSelector( + model = TASK_TEST1.models[1], + task = TASK_TEST1, + modelManagerViewModel = PreviewModelManagerViewModel(context = LocalContext.current), + ) + ModelSelector( + model = TASK_TEST2.models[1], + task = TASK_TEST2, + modelManagerViewModel = PreviewModelManagerViewModel(context = LocalContext.current), + ) + } + } +} diff --git a/Android/src/app/src/main/java/com/google/aiedge/gallery/ui/common/chat/TextInputHistorySheet.kt b/Android/src/app/src/main/java/com/google/aiedge/gallery/ui/common/chat/TextInputHistorySheet.kt new file mode 100644 index 0000000..67363a5 --- /dev/null +++ b/Android/src/app/src/main/java/com/google/aiedge/gallery/ui/common/chat/TextInputHistorySheet.kt @@ -0,0 +1,211 @@ +/* + * Copyright 2025 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.google.aiedge.gallery.ui.common.chat + +import androidx.compose.foundation.background +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.wrapContentHeight +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.items +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.rounded.Delete +import androidx.compose.material.icons.rounded.DeleteSweep +import androidx.compose.material3.AlertDialog +import androidx.compose.material3.Button +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.ModalBottomSheet +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.material3.rememberModalBottomSheetState +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import com.google.aiedge.gallery.R +import com.google.aiedge.gallery.ui.theme.GalleryTheme +import com.google.aiedge.gallery.ui.theme.customColors +import kotlinx.coroutines.delay +import kotlinx.coroutines.launch + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun TextInputHistorySheet( + history: List, + onHistoryItemClicked: (String) -> Unit, + onHistoryItemDeleted: (String) -> Unit, + onHistoryItemsDeleteAll: () -> Unit, + onDismissed: () -> Unit +) { + val sheetState = rememberModalBottomSheetState() + val scope = rememberCoroutineScope() + + ModalBottomSheet( + onDismissRequest = onDismissed, + sheetState = sheetState, + modifier = Modifier.wrapContentHeight(), + ) { + SheetContent( + history = history, + onHistoryItemClicked = { item -> + scope.launch { + sheetState.hide() + delay(100) + onHistoryItemClicked(item) + onDismissed() + } + }, + onHistoryItemDeleted = onHistoryItemDeleted, + onHistoryItemsDeleteAll = { + scope.launch { + sheetState.hide() + onDismissed() + onHistoryItemsDeleteAll() + } + }, + onDismissed = { + scope.launch { + sheetState.hide() + onDismissed() + } + } + ) + } +} + +@Composable +private fun SheetContent( + history: List, + onHistoryItemClicked: (String) -> Unit, + onHistoryItemDeleted: (String) -> Unit, + onHistoryItemsDeleteAll: () -> Unit, + onDismissed: () -> Unit +) { + val scope = rememberCoroutineScope() + var showConfirmDeleteDialog by remember { mutableStateOf(false) } + + Column { + Box(contentAlignment = Alignment.CenterEnd) { + Text( + "Text input history", + style = MaterialTheme.typography.titleLarge, + modifier = Modifier + .fillMaxWidth() + .padding(8.dp), + textAlign = TextAlign.Center + ) + IconButton(modifier = Modifier.padding(end = 12.dp), onClick = { + showConfirmDeleteDialog = true + }) { + Icon(Icons.Rounded.DeleteSweep, contentDescription = "") + } + } + LazyColumn(modifier = Modifier.weight(1f)) { + items(history, key = { it }) { item -> + Row( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 8.dp, vertical = 2.dp) + .clip(RoundedCornerShape(24.dp)) + .background(MaterialTheme.customColors.agentBubbleBgColor) + .clickable { + onHistoryItemClicked(item) + }, + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(8.dp) + ) { + Text( + item, + style = MaterialTheme.typography.bodyMedium, + modifier = Modifier + .padding(vertical = 16.dp) + .padding(start = 16.dp) + .weight(1f) + ) + IconButton(modifier = Modifier.padding(end = 8.dp), onClick = { + scope.launch { + delay(400) + onHistoryItemDeleted(item) + } + }) { + Icon(Icons.Rounded.Delete, contentDescription = "") + } + } + } + } + } + + if (showConfirmDeleteDialog) { + AlertDialog(onDismissRequest = { showConfirmDeleteDialog = false }, + title = { Text("Clear history?") }, + text = { + Text( + "Are you sure you want to clear the history? This action cannot be undone." + ) + }, + confirmButton = { + Button(onClick = { + showConfirmDeleteDialog = false + onHistoryItemsDeleteAll() + }) { + Text(stringResource(R.string.ok)) + } + }, + dismissButton = { + TextButton(onClick = { showConfirmDeleteDialog = false }) { + Text(stringResource(R.string.cancel)) + } + }) + } +} + +@Preview(showBackground = true) +@Composable +fun TextInputHistorySheetContentPreview() { + GalleryTheme { + SheetContent( + history = listOf( + "Analyze the sentiment of the following Tweets and classify them as POSITIVE, NEGATIVE, or NEUTRAL. \"It's so beautiful today!\"", + "I have the ingredients above. Not sure what to cook for lunch. Show me a list of foods with the recipes.", + "You are Santa Claus, write a letter back for this kid.", + "Generate a list of cookie recipes. Make the outputs in JSON format." + ), + onHistoryItemClicked = {}, + onHistoryItemDeleted = {}, + onHistoryItemsDeleteAll = {}, + onDismissed = {}, + ) + } +} \ No newline at end of file diff --git a/Android/src/app/src/main/java/com/google/aiedge/gallery/ui/common/modelitem/AnimatedLayoutModifier.kt b/Android/src/app/src/main/java/com/google/aiedge/gallery/ui/common/modelitem/AnimatedLayoutModifier.kt new file mode 100644 index 0000000..7212c57 --- /dev/null +++ b/Android/src/app/src/main/java/com/google/aiedge/gallery/ui/common/modelitem/AnimatedLayoutModifier.kt @@ -0,0 +1,73 @@ +/* + * Copyright 2025 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.google.aiedge.gallery.ui.common.modelitem + +import androidx.compose.animation.core.DeferredTargetAnimation +import androidx.compose.animation.core.ExperimentalAnimatableApi +import androidx.compose.animation.core.VectorConverter +import androidx.compose.animation.core.tween +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.ui.Modifier +import androidx.compose.ui.composed +import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.layout.LookaheadScope +import androidx.compose.ui.layout.approachLayout +import androidx.compose.ui.unit.Constraints +import androidx.compose.ui.unit.IntOffset +import androidx.compose.ui.unit.IntSize +import androidx.compose.ui.unit.round + +const val LAYOUT_ANIMATION_DURATION = 250 + +context(LookaheadScope) +@OptIn(ExperimentalAnimatableApi::class) +fun Modifier.animateLayout(): Modifier = composed { + val sizeAnim = remember { DeferredTargetAnimation(IntSize.VectorConverter) } + val offsetAnim = remember { DeferredTargetAnimation(IntOffset.VectorConverter) } + val scope = rememberCoroutineScope() + + this.approachLayout( + isMeasurementApproachInProgress = { lookaheadSize -> + sizeAnim.updateTarget(lookaheadSize, scope, tween(LAYOUT_ANIMATION_DURATION)) + !sizeAnim.isIdle + }, + isPlacementApproachInProgress = { lookaheadCoordinates -> + val target = lookaheadScopeCoordinates.localLookaheadPositionOf(lookaheadCoordinates) + offsetAnim.updateTarget(target.round(), scope, tween(LAYOUT_ANIMATION_DURATION)) + !offsetAnim.isIdle + } + ) { measurable, _ -> + val (animWidth, animHeight) = sizeAnim.updateTarget( + lookaheadSize, + scope, + tween(LAYOUT_ANIMATION_DURATION) + ) + measurable.measure(Constraints.fixed(animWidth, animHeight)) + .run { + layout(width, height) { + coordinates?.let { + val target = lookaheadScopeCoordinates.localLookaheadPositionOf(it).round() + val animOffset = offsetAnim.updateTarget(target, scope, tween(LAYOUT_ANIMATION_DURATION)) + val current = lookaheadScopeCoordinates.localPositionOf(it, Offset.Zero).round() + val (x, y) = animOffset - current + place(x, y) + } ?: place(0, 0) + } + } + } +} \ No newline at end of file diff --git a/Android/src/app/src/main/java/com/google/aiedge/gallery/ui/common/modelitem/ConfirmDeleteModelDialog.kt b/Android/src/app/src/main/java/com/google/aiedge/gallery/ui/common/modelitem/ConfirmDeleteModelDialog.kt new file mode 100644 index 0000000..9286131 --- /dev/null +++ b/Android/src/app/src/main/java/com/google/aiedge/gallery/ui/common/modelitem/ConfirmDeleteModelDialog.kt @@ -0,0 +1,52 @@ +/* + * Copyright 2025 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.google.aiedge.gallery.ui.common.modelitem + +import androidx.compose.material3.AlertDialog +import androidx.compose.material3.Button +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.runtime.Composable +import androidx.compose.ui.res.stringResource +import com.google.aiedge.gallery.R +import com.google.aiedge.gallery.data.Model + +/** + * Composable function to display a confirmation dialog for deleting a model. + */ +@Composable +fun ConfirmDeleteModelDialog(model: Model, onConfirm: () -> Unit, onDismiss: () -> Unit) { + AlertDialog(onDismissRequest = onDismiss, + title = { Text(stringResource(R.string.confirm_delete_model_dialog_title)) }, + text = { + Text( + stringResource(R.string.confirm_delete_model_dialog_content).format( + model.name + ) + ) + }, + confirmButton = { + Button(onClick = onConfirm) { + Text(stringResource(R.string.ok)) + } + }, + dismissButton = { + TextButton(onClick = onDismiss) { + Text(stringResource(R.string.cancel)) + } + }) +} diff --git a/Android/src/app/src/main/java/com/google/aiedge/gallery/ui/common/modelitem/ModelItem.kt b/Android/src/app/src/main/java/com/google/aiedge/gallery/ui/common/modelitem/ModelItem.kt new file mode 100644 index 0000000..97491fd --- /dev/null +++ b/Android/src/app/src/main/java/com/google/aiedge/gallery/ui/common/modelitem/ModelItem.kt @@ -0,0 +1,405 @@ +/* + * Copyright 2025 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.google.aiedge.gallery.ui.common.modelitem + +import android.content.Intent +import android.net.Uri +import androidx.activity.compose.rememberLauncherForActivityResult +import androidx.activity.result.contract.ActivityResultContracts +import androidx.compose.animation.core.animateFloatAsState +import androidx.compose.animation.core.tween +import androidx.compose.foundation.background +import androidx.compose.foundation.clickable +import androidx.compose.foundation.interaction.MutableInteractionSource +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.heightIn +import androidx.compose.foundation.layout.offset +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.rounded.Settings +import androidx.compose.material.icons.rounded.UnfoldLess +import androidx.compose.material.icons.rounded.UnfoldMore +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.OutlinedButton +import androidx.compose.material3.Text +import androidx.compose.material3.ripple +import androidx.compose.runtime.Composable +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.derivedStateOf +import androidx.compose.runtime.getValue +import androidx.compose.runtime.movableContentOf +import androidx.compose.runtime.movableContentWithReceiverOf +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.alpha +import androidx.compose.ui.draw.clip +import androidx.compose.ui.layout.LookaheadScope +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.dp +import com.google.aiedge.gallery.data.Model +import com.google.aiedge.gallery.data.ModelDownloadStatusType +import com.google.aiedge.gallery.data.Task +import com.google.aiedge.gallery.ui.common.DownloadAndTryButton +import com.google.aiedge.gallery.ui.common.TaskIcon +import com.google.aiedge.gallery.ui.common.chat.MarkdownText +import com.google.aiedge.gallery.ui.common.checkNotificationPermissonAndStartDownload +import com.google.aiedge.gallery.ui.common.getTaskBgColor +import com.google.aiedge.gallery.ui.common.getTaskIconColor +import com.google.aiedge.gallery.ui.modelmanager.ModelManagerViewModel +import com.google.aiedge.gallery.ui.preview.MODEL_TEST1 +import com.google.aiedge.gallery.ui.preview.MODEL_TEST2 +import com.google.aiedge.gallery.ui.preview.MODEL_TEST3 +import com.google.aiedge.gallery.ui.preview.MODEL_TEST4 +import com.google.aiedge.gallery.ui.preview.PreviewModelManagerViewModel +import com.google.aiedge.gallery.ui.preview.TASK_TEST1 +import com.google.aiedge.gallery.ui.preview.TASK_TEST2 +import com.google.aiedge.gallery.ui.theme.GalleryTheme + +private val DEFAULT_VERTICAL_PADDING = 16.dp + +/** + * Composable function to display a model item in the model manager list. + * + * This function renders a card representing a model, displaying its task icon, name, + * download status, and providing action buttons. It supports expanding to show a + * model description and buttons for learning more (opening a URL) and downloading/trying + * the model. + */ +@Composable +fun ModelItem( + model: Model, + task: Task, + modelManagerViewModel: ModelManagerViewModel, + onModelClicked: (Model) -> Unit, + modifier: Modifier = Modifier, + onConfigClicked: () -> Unit = {}, + verticalSpacing: Dp = DEFAULT_VERTICAL_PADDING, + showDeleteButton: Boolean = true, + showConfigButtonIfExisted: Boolean = false, + canExpand: Boolean = true, +) { + val context = LocalContext.current + val modelManagerUiState by modelManagerViewModel.uiState.collectAsState() + val downloadStatus by remember { + derivedStateOf { modelManagerUiState.modelDownloadStatus[model.name] } + } + val launcher = rememberLauncherForActivityResult( + ActivityResultContracts.RequestPermission() + ) { + modelManagerViewModel.downloadModel(model) + } + + var isExpanded by remember { mutableStateOf(false) } + + // Animate alpha for model description and button rows when switching between layouts. + val alphaAnimation by animateFloatAsState( + targetValue = if (isExpanded) 1f else 0f, + animationSpec = tween(durationMillis = LAYOUT_ANIMATION_DURATION - 50) + ) + + LookaheadScope { + // Task icon. + val taskIcon = remember { + movableContentOf { + TaskIcon( + task = task, modifier = Modifier.animateLayout() + ) + } + } + + // Model name and status. + val modelNameAndStatus = remember { + movableContentOf { + ModelNameAndStatus( + model = model, + task = task, + downloadStatus = downloadStatus, + isExpanded = isExpanded, + modifier = Modifier.animateLayout() + ) + } + } + + val actionButton = remember { + movableContentOf { + ModelItemActionButton( + context = context, + model = model, + task = task, + modelManagerViewModel = modelManagerViewModel, + downloadStatus = downloadStatus, + onDownloadClicked = { model -> + checkNotificationPermissonAndStartDownload( + context = context, + launcher = launcher, + modelManagerViewModel = modelManagerViewModel, + model = model + ) + }, + showDeleteButton = showDeleteButton, + showDownloadButton = false, + ) + } + } + + // Expand/collapse icon, or the config icon. + val expandButton = remember { + movableContentOf { + if (showConfigButtonIfExisted) { + if (downloadStatus?.status === ModelDownloadStatusType.SUCCEEDED) { + if (model.configs.isNotEmpty()) { + IconButton(onClick = onConfigClicked) { + Icon( + Icons.Rounded.Settings, + contentDescription = "", + tint = getTaskIconColor(task) + ) + } + } + } + } else { + Icon( + if (isExpanded) Icons.Rounded.UnfoldLess else Icons.Rounded.UnfoldMore, + contentDescription = "", + tint = getTaskIconColor(task), + ) + } + } + } + + // Model description shown in expanded layout. + val modelDescription = remember { + movableContentOf { m: Modifier -> + if (model.info.isNotEmpty()) { + MarkdownText( + model.info, + modifier = Modifier + .heightIn(min = 0.dp, max = if (isExpanded) 1000.dp else 0.dp) + .animateLayout() + .then(m) + ) + } + } + } + + // Button rows shown in expanded layout. + val buttonRows = remember { + movableContentOf { m: Modifier -> + Row( + modifier = Modifier + .heightIn(min = 0.dp, max = if (isExpanded) 1000.dp else 0.dp) + .animateLayout() + .then(m), + horizontalArrangement = Arrangement.spacedBy(12.dp), + ) { + // The "learn more" button. Click to show url in default browser. + if (model.learnMoreUrl.isNotEmpty()) { + OutlinedButton( + onClick = { + if (isExpanded) { + val intent = Intent(Intent.ACTION_VIEW, Uri.parse(model.learnMoreUrl)) + context.startActivity(intent) + } + }, + ) { + Text("Learn More", maxLines = 1) + } + } + + // Button to start the download and start the chat session with the model. + val needToDownloadFirst = + downloadStatus?.status == ModelDownloadStatusType.NOT_DOWNLOADED || downloadStatus?.status == ModelDownloadStatusType.FAILED + DownloadAndTryButton( + model = model, + enabled = isExpanded, + needToDownloadFirst = needToDownloadFirst, + modelManagerViewModel = modelManagerViewModel, + onClicked = { onModelClicked(model) } + ) +// Button( +// onClick = { +// if (isExpanded) { +// onModelClicked(model) +// if (needToDownloadFirst) { +// scope.launch { +// delay(80) +// checkNotificationPermissonAndStartDownload( +// context = context, +// launcher = launcher, +// modelManagerViewModel = modelManagerViewModel, +// model = model +// ) +// } +// } +// } +// }, +// ) { +// Icon( +// Icons.AutoMirrored.Rounded.ArrowForward, +// contentDescription = "", +// modifier = Modifier.padding(end = 4.dp) +// ) +// if (needToDownloadFirst) { +// Text("Download & Try it", maxLines = 1) +// } else { +// Text("Try it", maxLines = 1) +// } +// } + } + } + } + + val container = remember { + movableContentWithReceiverOf Unit> { content -> + Box( + modifier = Modifier.animateLayout(), + contentAlignment = Alignment.TopEnd, + ) { + content() + } + } + } + + var boxModifier = modifier + .fillMaxWidth() + .clip(RoundedCornerShape(size = 42.dp)) + .background( + getTaskBgColor(task) + ) + boxModifier = if (canExpand) { + boxModifier.clickable( + onClick = { isExpanded = !isExpanded }, + interactionSource = remember { MutableInteractionSource() }, + indication = ripple( + bounded = true, + radius = 500.dp, + ) + ) + } else { + boxModifier + } + Box( + modifier = boxModifier, + contentAlignment = Alignment.Center + ) { + if (isExpanded) { + container { + // The main part (icon, model name, status, etc) + Column( + verticalArrangement = Arrangement.spacedBy(14.dp), + horizontalAlignment = Alignment.CenterHorizontally, + modifier = Modifier + .fillMaxWidth() + .padding(vertical = verticalSpacing, horizontal = 18.dp) + ) { + Box(contentAlignment = Alignment.Center) { + taskIcon() + Row( + modifier = Modifier.fillMaxWidth(), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.End + ) { + actionButton() + expandButton() + } + } + modelNameAndStatus() + modelDescription(Modifier.alpha(alphaAnimation)) + buttonRows(Modifier.alpha(alphaAnimation)) // Apply alpha here + } + } + } else { + container { + Column(horizontalAlignment = Alignment.CenterHorizontally) { + // The main part (icon, model name, status, etc) + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(12.dp), + modifier = Modifier + .fillMaxWidth() + .padding(start = 18.dp, end = 18.dp) + .padding(vertical = verticalSpacing) + ) { + taskIcon() + Row(modifier = Modifier.weight(1f)) { + modelNameAndStatus() + } + Row(verticalAlignment = Alignment.CenterVertically) { + actionButton() + expandButton() + } + } + Column( + modifier = Modifier.offset(y = 30.dp), + horizontalAlignment = Alignment.CenterHorizontally + ) { + modelDescription(Modifier.alpha(alphaAnimation)) + buttonRows(Modifier.alpha(alphaAnimation)) + } + } + } + } + } + } + +} + +@Preview(showBackground = true) +@Composable +fun PreviewModelItem() { + GalleryTheme { + Column( + verticalArrangement = Arrangement.spacedBy(16.dp), modifier = Modifier.padding(16.dp) + ) { + ModelItem( + model = MODEL_TEST1, + task = TASK_TEST1, + onModelClicked = { }, + modelManagerViewModel = PreviewModelManagerViewModel(context = LocalContext.current), + ) + ModelItem( + model = MODEL_TEST2, + task = TASK_TEST1, + onModelClicked = { }, + modelManagerViewModel = PreviewModelManagerViewModel(context = LocalContext.current), + ) + ModelItem( + model = MODEL_TEST3, + task = TASK_TEST2, + onModelClicked = { }, + modelManagerViewModel = PreviewModelManagerViewModel(context = LocalContext.current), + ) + ModelItem( + model = MODEL_TEST4, + task = TASK_TEST2, + onModelClicked = { }, + modelManagerViewModel = PreviewModelManagerViewModel(context = LocalContext.current), + ) + } + } +} diff --git a/Android/src/app/src/main/java/com/google/aiedge/gallery/ui/common/modelitem/ModelItemActionButton.kt b/Android/src/app/src/main/java/com/google/aiedge/gallery/ui/common/modelitem/ModelItemActionButton.kt new file mode 100644 index 0000000..4ec8295 --- /dev/null +++ b/Android/src/app/src/main/java/com/google/aiedge/gallery/ui/common/modelitem/ModelItemActionButton.kt @@ -0,0 +1,133 @@ +/* + * Copyright 2025 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.google.aiedge.gallery.ui.common.modelitem + +import android.content.Context +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.rounded.Cancel +import androidx.compose.material.icons.rounded.Delete +import androidx.compose.material.icons.rounded.FileDownload +import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.unit.dp +import com.google.aiedge.gallery.data.Model +import com.google.aiedge.gallery.data.ModelDownloadStatus +import com.google.aiedge.gallery.data.ModelDownloadStatusType +import com.google.aiedge.gallery.data.Task +import com.google.aiedge.gallery.ui.common.getTaskIconColor +import com.google.aiedge.gallery.ui.modelmanager.ModelManagerViewModel + +/** + * Composable function to display action buttons for a model item, based on its download status. + * + * This function renders the appropriate action button (download, delete, cancel) based on the + * provided ModelDownloadStatus. It also handles notification permission requests for downloading + * and displays a confirmation dialog for deleting models. + */ +@Composable +fun ModelItemActionButton( + context: Context, + model: Model, + task: Task, + modelManagerViewModel: ModelManagerViewModel, + downloadStatus: ModelDownloadStatus?, + onDownloadClicked: (Model) -> Unit, + modifier: Modifier = Modifier, + showDeleteButton: Boolean = true, + showDownloadButton: Boolean = true, +) { + var showConfirmDeleteDialog by remember { mutableStateOf(false) } + + Row(verticalAlignment = Alignment.CenterVertically, modifier = modifier) { + when (downloadStatus?.status) { + // Button to start the download. + ModelDownloadStatusType.NOT_DOWNLOADED, ModelDownloadStatusType.FAILED -> + if (showDownloadButton) { + IconButton(onClick = { + onDownloadClicked(model) + }) { + Icon( + Icons.Rounded.FileDownload, + contentDescription = "", + tint = getTaskIconColor(task), + ) + } + } + + // Button to delete the download. + ModelDownloadStatusType.SUCCEEDED -> { + if (showDeleteButton) { + IconButton(onClick = { + showConfirmDeleteDialog = true + }) { + Icon( + Icons.Rounded.Delete, + contentDescription = "", + tint = getTaskIconColor(task), + ) + } + + } + } + + // Show spinner when the model is partially downloaded because it might some time for + // background task to be started by Android. + ModelDownloadStatusType.PARTIALLY_DOWNLOADED -> { + CircularProgressIndicator( + modifier = Modifier + .padding(end = 12.dp) + .size(24.dp) + ) + } + + // Button to cancel the download when it is in progress. + ModelDownloadStatusType.IN_PROGRESS, ModelDownloadStatusType.UNZIPPING -> IconButton(onClick = { + modelManagerViewModel.cancelDownloadModel( + model + ) + }) { + Icon( + Icons.Rounded.Cancel, + contentDescription = "", + tint = getTaskIconColor(task), + ) + } + + else -> {} + } + } + + if (showConfirmDeleteDialog) { + ConfirmDeleteModelDialog(model = model, onConfirm = { + modelManagerViewModel.deleteModel(model) + showConfirmDeleteDialog = false + }, onDismiss = { + showConfirmDeleteDialog = false + }) + } +} \ No newline at end of file diff --git a/Android/src/app/src/main/java/com/google/aiedge/gallery/ui/common/modelitem/ModelNameAndStatus.kt b/Android/src/app/src/main/java/com/google/aiedge/gallery/ui/common/modelitem/ModelNameAndStatus.kt new file mode 100644 index 0000000..07cc6a0 --- /dev/null +++ b/Android/src/app/src/main/java/com/google/aiedge/gallery/ui/common/modelitem/ModelNameAndStatus.kt @@ -0,0 +1,187 @@ +/* + * Copyright 2025 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.google.aiedge.gallery.ui.common.modelitem + +import androidx.compose.animation.core.Animatable +import androidx.compose.animation.core.tween +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.offset +import androidx.compose.foundation.layout.padding +import androidx.compose.material3.LinearProgressIndicator +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.remember +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import com.google.aiedge.gallery.data.Model +import com.google.aiedge.gallery.data.ModelDownloadStatus +import com.google.aiedge.gallery.data.ModelDownloadStatusType +import com.google.aiedge.gallery.data.Task +import com.google.aiedge.gallery.ui.common.formatToHourMinSecond +import com.google.aiedge.gallery.ui.common.getTaskIconColor +import com.google.aiedge.gallery.ui.common.humanReadableSize +import com.google.aiedge.gallery.ui.theme.labelSmallNarrow + +/** + * Composable function to display the model name and its download status information. + * + * This function renders the model's name and its current download status, including: + * - Model name. + * - Failure message (if download failed). + * - Download progress (received size, total size, download rate, remaining time) for + * in-progress downloads. + * - "Unzipping..." status for unzipping processes. + * - Model size for successful downloads. + */ +@Composable +fun ModelNameAndStatus( + model: Model, + task: Task, + downloadStatus: ModelDownloadStatus?, + isExpanded: Boolean, + modifier: Modifier = Modifier +) { + val inProgress = downloadStatus?.status == ModelDownloadStatusType.IN_PROGRESS + val isPartiallyDownloaded = downloadStatus?.status == ModelDownloadStatusType.PARTIALLY_DOWNLOADED + var curDownloadProgress = 0f + + Column( + horizontalAlignment = if (isExpanded) Alignment.CenterHorizontally else Alignment.Start + ) { + // Model name. + Row( + verticalAlignment = Alignment.CenterVertically, + ) { + Text( + model.name, + style = MaterialTheme.typography.titleMedium, + modifier = modifier, + ) + } + + Row(verticalAlignment = Alignment.CenterVertically) { + // Status icon. + if (!inProgress && !isPartiallyDownloaded) { + StatusIcon( + downloadStatus = downloadStatus, + modifier = modifier.padding(end = 4.dp) + ) + } + + // Failure message. + if (downloadStatus != null && downloadStatus.status == ModelDownloadStatusType.FAILED) { + Row(verticalAlignment = Alignment.CenterVertically) { + Text( + downloadStatus.errorMessage, + color = MaterialTheme.colorScheme.error, + style = labelSmallNarrow, + overflow = TextOverflow.Ellipsis, + modifier = modifier, + ) + } + } + + // Status label + else { + var sizeLabel = model.totalBytes.humanReadableSize() + var fontSize = 11.sp + + // Populate the status label. + if (downloadStatus != null) { + // For in-progress model, show {receivedSize} / {totalSize} - {rate} - {remainingTime} + if (inProgress || isPartiallyDownloaded) { + var totalSize = downloadStatus.totalBytes + if (totalSize == 0L) { + totalSize = model.totalBytes + } + sizeLabel = + "${downloadStatus.receivedBytes.humanReadableSize(extraDecimalForGbAndAbove = true)} of ${totalSize.humanReadableSize()}" + if (downloadStatus.bytesPerSecond > 0) { + sizeLabel = + "$sizeLabel · ${downloadStatus.bytesPerSecond.humanReadableSize()} / s" + if (downloadStatus.remainingMs >= 0) { + sizeLabel = + "$sizeLabel\n${downloadStatus.remainingMs.formatToHourMinSecond()} left" + } + } + if (isPartiallyDownloaded) { + sizeLabel = "$sizeLabel (resuming...)" + } + curDownloadProgress = + downloadStatus.receivedBytes.toFloat() / downloadStatus.totalBytes.toFloat() + if (curDownloadProgress.isNaN()) { + curDownloadProgress = 0f + } + fontSize = 9.sp + } + // Status for unzipping. + else if (downloadStatus.status == ModelDownloadStatusType.UNZIPPING) { + sizeLabel = "Unzipping..." + } + } + + Column( + horizontalAlignment = if (isExpanded) Alignment.CenterHorizontally else Alignment.Start, + ) { + for ((index, line) in sizeLabel.split("\n").withIndex()) { + Text( + line, + color = MaterialTheme.colorScheme.secondary, + maxLines = 1, + style = labelSmallNarrow.copy(fontSize = fontSize, lineHeight = 10.sp), + textAlign = if (isExpanded) TextAlign.Center else TextAlign.Start, + overflow = TextOverflow.Visible, + modifier = modifier.offset(y = if (index == 0) 0.dp else (-1).dp) + ) + } + } + } + } + + // Download progress bar. + if (inProgress || isPartiallyDownloaded) { + val animatedProgress = remember { Animatable(0f) } + LinearProgressIndicator( + progress = { animatedProgress.value }, + color = getTaskIconColor(task = task), + trackColor = MaterialTheme.colorScheme.surfaceContainerHighest, + modifier = modifier.padding(top = 2.dp) + ) + LaunchedEffect(curDownloadProgress) { + animatedProgress.animateTo(curDownloadProgress, animationSpec = tween(150)) + } + } + // Unzipping progress. + else if (downloadStatus?.status == ModelDownloadStatusType.UNZIPPING) { + LinearProgressIndicator( + color = getTaskIconColor(task = task), + trackColor = MaterialTheme.colorScheme.surfaceContainerHighest, + modifier = Modifier + .padding(top = 2.dp), + ) + } + + + } +} diff --git a/Android/src/app/src/main/java/com/google/aiedge/gallery/ui/common/modelitem/StatusIcon.kt b/Android/src/app/src/main/java/com/google/aiedge/gallery/ui/common/modelitem/StatusIcon.kt new file mode 100644 index 0000000..cb94e8b --- /dev/null +++ b/Android/src/app/src/main/java/com/google/aiedge/gallery/ui/common/modelitem/StatusIcon.kt @@ -0,0 +1,94 @@ +/* + * Copyright 2025 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.google.aiedge.gallery.ui.common.modelitem + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.size +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.outlined.HelpOutline +import androidx.compose.material.icons.filled.DownloadForOffline +import androidx.compose.material.icons.rounded.Error +import androidx.compose.material3.Icon +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import com.google.aiedge.gallery.data.ModelDownloadStatus +import com.google.aiedge.gallery.data.ModelDownloadStatusType +import com.google.aiedge.gallery.ui.theme.GalleryTheme + +/** + * Composable function to display an icon representing the download status of a model. + */ +@Composable +fun StatusIcon(downloadStatus: ModelDownloadStatus?, modifier: Modifier = Modifier) { + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.Center, + modifier = modifier + ) { + when (downloadStatus?.status) { + ModelDownloadStatusType.NOT_DOWNLOADED -> Icon( + Icons.AutoMirrored.Outlined.HelpOutline, + tint = Color(0xFFCCCCCC), + contentDescription = "", + modifier = Modifier.size(14.dp) + ) + + ModelDownloadStatusType.SUCCEEDED -> { + Icon( + Icons.Filled.DownloadForOffline, + tint = Color(0xff3d860b), + contentDescription = "", + modifier = Modifier.size(14.dp) + ) + } + + ModelDownloadStatusType.FAILED -> Icon( + Icons.Rounded.Error, + tint = Color(0xFFAA0000), + contentDescription = "", + modifier = Modifier.size(14.dp) + ) + + else -> {} + } + } +} + +@Preview(showBackground = true) +@Composable +fun StatusIconPreview() { + GalleryTheme { + Column { + for (downloadStatus in listOf( + ModelDownloadStatus(status = ModelDownloadStatusType.NOT_DOWNLOADED), + ModelDownloadStatus(status = ModelDownloadStatusType.IN_PROGRESS), + ModelDownloadStatus(status = ModelDownloadStatusType.SUCCEEDED), + ModelDownloadStatus(status = ModelDownloadStatusType.FAILED), + ModelDownloadStatus(status = ModelDownloadStatusType.UNZIPPING), + ModelDownloadStatus(status = ModelDownloadStatusType.PARTIALLY_DOWNLOADED), + )) { + StatusIcon(downloadStatus = downloadStatus) + } + } + } +} diff --git a/Android/src/app/src/main/java/com/google/aiedge/gallery/ui/home/HomeScreen.kt b/Android/src/app/src/main/java/com/google/aiedge/gallery/ui/home/HomeScreen.kt new file mode 100644 index 0000000..78bf841 --- /dev/null +++ b/Android/src/app/src/main/java/com/google/aiedge/gallery/ui/home/HomeScreen.kt @@ -0,0 +1,273 @@ +/* + * Copyright 2025 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.google.aiedge.gallery.ui.home + +import androidx.annotation.StringRes +import androidx.compose.foundation.background +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.aspectRatio +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.lazy.grid.GridCells +import androidx.compose.foundation.lazy.grid.GridItemSpan +import androidx.compose.foundation.lazy.grid.LazyVerticalGrid +import androidx.compose.foundation.lazy.grid.items +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.Card +import androidx.compose.material3.CardDefaults +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Scaffold +import androidx.compose.material3.Text +import androidx.compose.material3.TopAppBarDefaults +import androidx.compose.runtime.Composable +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.graphics.Brush +import androidx.compose.ui.input.nestedscroll.nestedScroll +import androidx.compose.ui.layout.layout +import androidx.compose.ui.platform.LocalConfiguration +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import com.google.aiedge.gallery.GalleryTopAppBar +import com.google.aiedge.gallery.R +import com.google.aiedge.gallery.data.AppBarAction +import com.google.aiedge.gallery.data.AppBarActionType +import com.google.aiedge.gallery.data.ConfigKey +import com.google.aiedge.gallery.data.Task +import com.google.aiedge.gallery.ui.common.TaskIcon +import com.google.aiedge.gallery.ui.common.getTaskBgColor +import com.google.aiedge.gallery.ui.modelmanager.ModelManagerViewModel +import com.google.aiedge.gallery.ui.preview.PreviewModelManagerViewModel +import com.google.aiedge.gallery.ui.theme.GalleryTheme +import com.google.aiedge.gallery.ui.theme.ThemeSettings +import com.google.aiedge.gallery.ui.theme.customColors +import com.google.aiedge.gallery.ui.theme.titleMediumNarrow + +/** Navigation destination data */ +object HomeScreenDestination { + @StringRes + val titleRes = R.string.app_name +} + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun HomeScreen( + modelManagerViewModel: ModelManagerViewModel, + navigateToTaskScreen: (Task) -> Unit, + modifier: Modifier = Modifier +) { + val scrollBehavior = TopAppBarDefaults.pinnedScrollBehavior() + val uiState by modelManagerViewModel.uiState.collectAsState() + var showSettingsDialog by remember { mutableStateOf(false) } + + val tasks = uiState.tasks + val loadingHfModels = uiState.loadingHfModels + + Scaffold(modifier = modifier.nestedScroll(scrollBehavior.nestedScrollConnection), topBar = { + GalleryTopAppBar( + title = stringResource(HomeScreenDestination.titleRes), + rightAction = AppBarAction( + actionType = AppBarActionType.APP_SETTING, actionFn = { + showSettingsDialog = true + } + ), + loadingHfModels = loadingHfModels, + scrollBehavior = scrollBehavior, + ) + }) { innerPadding -> + TaskList( + tasks = tasks, + navigateToTaskScreen = navigateToTaskScreen, + modifier = Modifier.fillMaxSize(), + contentPadding = innerPadding, + ) + } + + // Settings dialog. + if (showSettingsDialog) { + SettingsDialog( + curThemeOverride = modelManagerViewModel.readThemeOverride(), + onDismissed = { showSettingsDialog = false }, + onOk = { curConfigValues -> + // Update theme settings. + // This will update app's theme. + val themeOverride = curConfigValues[ConfigKey.THEME.label] as String + ThemeSettings.themeOverride.value = themeOverride + + // Save to data store. + modelManagerViewModel.saveThemeOverride(themeOverride) + }, + ) + } +} + +@Composable +private fun TaskList( + tasks: List, + navigateToTaskScreen: (Task) -> Unit, + modifier: Modifier = Modifier, + contentPadding: PaddingValues = PaddingValues(0.dp), +) { + Box(modifier = modifier.fillMaxSize()) { + LazyVerticalGrid( + columns = GridCells.Fixed(count = 2), + contentPadding = contentPadding, + modifier = modifier.padding(12.dp), + horizontalArrangement = Arrangement.spacedBy(8.dp), + verticalArrangement = Arrangement.spacedBy(8.dp), + ) { + // Headline. + item(span = { GridItemSpan(2) }) { + Text( + "Welcome to AI Edge Gallery! Explore a world of \namazing on-device models from LiteRT community", + textAlign = TextAlign.Center, + style = MaterialTheme.typography.bodyMedium.copy(fontWeight = FontWeight.SemiBold), + modifier = Modifier.padding(bottom = 20.dp) + ) + } + + // Cards. + items(tasks) { task -> + TaskCard( + task = task, + onClick = { + navigateToTaskScreen(task) + }, + modifier = Modifier + .fillMaxWidth() + .aspectRatio(1f) + ) + } + } + + // Gradient overlay at the bottom. + Box( + modifier = Modifier + .fillMaxWidth() + .height(LocalConfiguration.current.screenHeightDp.dp * 0.25f) + .background( + Brush.verticalGradient( + colors = MaterialTheme.customColors.homeBottomGradient, + ) + ) + .align(Alignment.BottomCenter) + ) + } +} + +@Composable +private fun TaskCard(task: Task, onClick: () -> Unit, modifier: Modifier = Modifier) { + Card( + modifier = modifier + .clip(RoundedCornerShape(43.5.dp)) + .clickable( + onClick = onClick, + ), + colors = CardDefaults.cardColors( + containerColor = getTaskBgColor(task = task) + ), + ) { + Column( + modifier = Modifier + .fillMaxSize() + .padding(24.dp), + ) { + // Icon. + TaskIcon(task = task) + + Spacer(modifier = Modifier.weight(1f)) + + // Title. + val pair = task.type.label.splitByFirstSpace() + Text( + pair.first, + color = MaterialTheme.colorScheme.primary, + style = titleMediumNarrow.copy( + fontSize = 20.sp, + fontWeight = FontWeight.Bold, + ), + ) + if (pair.second.isNotEmpty()) { + Text( + pair.second, + color = MaterialTheme.colorScheme.primary, + style = titleMediumNarrow.copy( + fontSize = 18.sp, + fontWeight = FontWeight.Bold, + ), + modifier = Modifier.layout { measurable, constraints -> + val placeable = measurable.measure(constraints) + layout(placeable.width, placeable.height) { + placeable.placeRelative(0, -4.dp.roundToPx()) + } + } + ) + } + + // Model count. + val modelCountLabel = when (task.models.size) { + 1 -> "1 Model" + else -> "%d Models".format(task.models.size) + } + Text( + modelCountLabel, + color = MaterialTheme.colorScheme.secondary, + style = MaterialTheme.typography.bodyMedium + ) + } + } +} + +private fun String.splitByFirstSpace(): Pair { + val spaceIndex = this.indexOf(' ') + if (spaceIndex == -1) { + return Pair(this, "") + } + return Pair(this.substring(0, spaceIndex), this.substring(spaceIndex + 1)) +} + +@Preview +@Composable +fun HomeScreenPreview( +) { + GalleryTheme { + HomeScreen( + modelManagerViewModel = PreviewModelManagerViewModel(context = LocalContext.current), + navigateToTaskScreen = {}, + ) + } +} + diff --git a/Android/src/app/src/main/java/com/google/aiedge/gallery/ui/home/SettingsDialog.kt b/Android/src/app/src/main/java/com/google/aiedge/gallery/ui/home/SettingsDialog.kt new file mode 100644 index 0000000..1852d71 --- /dev/null +++ b/Android/src/app/src/main/java/com/google/aiedge/gallery/ui/home/SettingsDialog.kt @@ -0,0 +1,60 @@ +/* + * Copyright 2025 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.google.aiedge.gallery.ui.home + +import androidx.compose.runtime.Composable +import com.google.aiedge.gallery.VERSION +import com.google.aiedge.gallery.data.Config +import com.google.aiedge.gallery.data.ConfigKey +import com.google.aiedge.gallery.data.SegmentedButtonConfig +import com.google.aiedge.gallery.ui.common.chat.ConfigDialog +import com.google.aiedge.gallery.ui.theme.THEME_AUTO +import com.google.aiedge.gallery.ui.theme.THEME_DARK +import com.google.aiedge.gallery.ui.theme.THEME_LIGHT + +private val CONFIGS: List = listOf( + SegmentedButtonConfig( + key = ConfigKey.THEME, + defaultValue = THEME_AUTO, + options = listOf(THEME_AUTO, THEME_LIGHT, THEME_DARK) + ) +) + +@Composable +fun SettingsDialog( + curThemeOverride: String, + onDismissed: () -> Unit, + onOk: (Map) -> Unit, +) { + val initialValues = mapOf( + ConfigKey.THEME.label to curThemeOverride + ) + ConfigDialog( + title = "Settings", + subtitle = "App version: $VERSION", + okBtnLabel = "OK", + configs = CONFIGS, + initialValues = initialValues, + onDismissed = onDismissed, + onOk = { curConfigValues -> + onOk(curConfigValues) + + // Hide config dialog. + onDismissed() + }, + ) +} diff --git a/Android/src/app/src/main/java/com/google/aiedge/gallery/ui/icon/Deploy.kt b/Android/src/app/src/main/java/com/google/aiedge/gallery/ui/icon/Deploy.kt new file mode 100644 index 0000000..85b391f --- /dev/null +++ b/Android/src/app/src/main/java/com/google/aiedge/gallery/ui/icon/Deploy.kt @@ -0,0 +1,91 @@ +/* + * Copyright 2025 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.google.aiedge.gallery.ui.icon + +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.PathFillType +import androidx.compose.ui.graphics.SolidColor +import androidx.compose.ui.graphics.StrokeCap +import androidx.compose.ui.graphics.StrokeJoin +import androidx.compose.ui.graphics.vector.ImageVector +import androidx.compose.ui.graphics.vector.path +import androidx.compose.ui.unit.dp + +val Deployed_code: ImageVector + get() { + if (internal_Deployed_code != null) { + return internal_Deployed_code!! + } + internal_Deployed_code = ImageVector.Builder( + name = "Deployed_code", + defaultWidth = 24.dp, + defaultHeight = 24.dp, + viewportWidth = 960f, + viewportHeight = 960f + ).apply { + path( + fill = SolidColor(Color.Black), + fillAlpha = 1.0f, + stroke = null, + strokeAlpha = 1.0f, + strokeLineWidth = 1.0f, + strokeLineCap = StrokeCap.Butt, + strokeLineJoin = StrokeJoin.Miter, + strokeLineMiter = 1.0f, + pathFillType = PathFillType.NonZero + ) { + moveTo(440f, 777f) + verticalLineToRelative(-274f) + lineTo(200f, 364f) + verticalLineToRelative(274f) + close() + moveToRelative(80f, 0f) + lineToRelative(240f, -139f) + verticalLineToRelative(-274f) + lineTo(520f, 503f) + close() + moveToRelative(-40f, -343f) + lineToRelative(237f, -137f) + lineToRelative(-237f, -137f) + lineToRelative(-237f, 137f) + close() + moveTo(160f, 708f) + quadToRelative(-19f, -11f, -29.5f, -29f) + reflectiveQuadTo(120f, 639f) + verticalLineToRelative(-318f) + quadToRelative(0f, -22f, 10.5f, -40f) + reflectiveQuadToRelative(29.5f, -29f) + lineToRelative(280f, -161f) + quadToRelative(19f, -11f, 40f, -11f) + reflectiveQuadToRelative(40f, 11f) + lineToRelative(280f, 161f) + quadToRelative(19f, 11f, 29.5f, 29f) + reflectiveQuadToRelative(10.5f, 40f) + verticalLineToRelative(318f) + quadToRelative(0f, 22f, -10.5f, 40f) + reflectiveQuadTo(800f, 708f) + lineTo(520f, 869f) + quadToRelative(-19f, 11f, -40f, 11f) + reflectiveQuadToRelative(-40f, -11f) + close() + moveToRelative(320f, -228f) + } + }.build() + return internal_Deployed_code!! + } + +private var internal_Deployed_code: ImageVector? = null diff --git a/Android/src/app/src/main/java/com/google/aiedge/gallery/ui/imageclassification/ImageClassificationModelHelper.kt b/Android/src/app/src/main/java/com/google/aiedge/gallery/ui/imageclassification/ImageClassificationModelHelper.kt new file mode 100644 index 0000000..b9415b2 --- /dev/null +++ b/Android/src/app/src/main/java/com/google/aiedge/gallery/ui/imageclassification/ImageClassificationModelHelper.kt @@ -0,0 +1,154 @@ +/* + * Copyright 2025 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.google.aiedge.gallery.ui.imageclassification + +import android.content.Context +import android.graphics.Bitmap +import android.util.Log +import androidx.compose.ui.graphics.Color +import com.google.android.gms.tflite.client.TfLiteInitializationOptions +import com.google.android.gms.tflite.gpu.support.TfLiteGpu +import com.google.android.gms.tflite.java.TfLite +import com.google.aiedge.gallery.ui.common.chat.Classification +import com.google.aiedge.gallery.data.ConfigKey +import com.google.aiedge.gallery.data.Model +import com.google.aiedge.gallery.ui.common.LatencyProvider +import org.tensorflow.lite.DataType +import org.tensorflow.lite.InterpreterApi +import org.tensorflow.lite.gpu.GpuDelegateFactory +import org.tensorflow.lite.support.common.FileUtil +import org.tensorflow.lite.support.common.ops.NormalizeOp +import org.tensorflow.lite.support.image.ImageProcessor +import org.tensorflow.lite.support.image.TensorImage +import org.tensorflow.lite.support.image.ops.ResizeOp +import org.tensorflow.lite.support.tensorbuffer.TensorBuffer +import java.io.File +import java.io.FileInputStream + +private const val TAG = "AGImageClassificationModelHelper" + +class ImageClassificationInferenceResult( + val categories: List, override val latencyMs: Float +) : LatencyProvider + +//TODO: handle error. + +object ImageClassificationModelHelper { + fun initialize(context: Context, model: Model, onDone: () -> Unit) { + val useGpu = model.getBooleanConfigValue(key = ConfigKey.USE_GPU) + TfLiteGpu.isGpuDelegateAvailable(context).continueWith { gpuTask -> + val optionsBuilder = TfLiteInitializationOptions.builder() + if (gpuTask.result) { + optionsBuilder.setEnableGpuDelegateSupport(true) + } + val task = TfLite.initialize( + context, + optionsBuilder.build() + ) + task.addOnSuccessListener { + val interpreterOption = + InterpreterApi.Options().setRuntime(InterpreterApi.Options.TfLiteRuntime.FROM_SYSTEM_ONLY) + if (useGpu) { + interpreterOption.addDelegateFactory(GpuDelegateFactory()) + } + val interpreter = InterpreterApi.create( + File(model.getPath(context = context)), interpreterOption + ) + model.instance = interpreter + onDone() + } + } + } + + fun cleanUp(model: Model) { + if (model.instance == null) { + return + } + val instance = model.instance as InterpreterApi + instance.close() + } + + fun runInference( + context: Context, + model: Model, + input: Bitmap, + primaryColor: Color, + ): ImageClassificationInferenceResult { + val instance = model.instance + if (instance == null) { + Log.d( + TAG, "Model '${model.name}' has not been initialized" + ) + return ImageClassificationInferenceResult(categories = listOf(), latencyMs = 0f) + } + + // Pre-process image. + val start = System.currentTimeMillis() + val interpreter = model.instance as InterpreterApi + val inputShape = interpreter.getInputTensor(0).shape() + val imageProcessor = ImageProcessor.Builder() + .add(ResizeOp(inputShape[1], inputShape[2], ResizeOp.ResizeMethod.BILINEAR)) + .add(NormalizeOp(127.5f, 127.5f)) // Normalize pixel values + .build() + val tensorImage = TensorImage(DataType.FLOAT32) + tensorImage.load(input) + val inputTensorBuffer = imageProcessor.process(tensorImage).tensorBuffer + + // Output buffer + val outputBuffer = + TensorBuffer.createFixedSize(interpreter.getOutputTensor(0).shape(), DataType.FLOAT32) + + // Run inference + interpreter.run(inputTensorBuffer.buffer, outputBuffer.buffer) + + // Post-process result. + val output = outputBuffer.floatArray + val labelsFilePath = model.getPath( + context = context, + fileName = model.getExtraDataFile(name = "labels")?.downloadFileName ?: "_" + ) + val labelsFileInputStream = FileInputStream(File(labelsFilePath)) + val labels = FileUtil.loadLabels(labelsFileInputStream) + labelsFileInputStream.close() + val topIndices = + getTopKMaxIndices(output = output, k = model.getIntConfigValue(ConfigKey.MAX_RESULT_COUNT)) + val categories: List = + topIndices.map { i -> + Classification( + label = labels[i], + score = output[i], + color = primaryColor + ) + } + return ImageClassificationInferenceResult( + categories = categories, + latencyMs = (System.currentTimeMillis() - start).toFloat() + ) + } + + private fun getTopKMaxIndices(output: FloatArray, k: Int): List { + if (k <= 0 || output.isEmpty()) { + return emptyList() + } + + val indexedValues = output.withIndex().toList() + val sortedIndexedValues = + indexedValues.sortedByDescending { it.value } + return sortedIndexedValues.take(k).map { it.index } // Take the top k and extract indices + } + +} diff --git a/Android/src/app/src/main/java/com/google/aiedge/gallery/ui/imageclassification/ImageClassificationScreen.kt b/Android/src/app/src/main/java/com/google/aiedge/gallery/ui/imageclassification/ImageClassificationScreen.kt new file mode 100644 index 0000000..9dc2bea --- /dev/null +++ b/Android/src/app/src/main/java/com/google/aiedge/gallery/ui/imageclassification/ImageClassificationScreen.kt @@ -0,0 +1,97 @@ +/* + * Copyright 2025 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.google.aiedge.gallery.ui.imageclassification + +import androidx.compose.material3.MaterialTheme +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalContext +import androidx.lifecycle.viewmodel.compose.viewModel +import com.google.aiedge.gallery.ui.ViewModelProvider +import com.google.aiedge.gallery.ui.common.chat.ChatInputType +import com.google.aiedge.gallery.ui.common.chat.ChatMessageImage +import com.google.aiedge.gallery.ui.common.chat.ChatView +import com.google.aiedge.gallery.ui.modelmanager.ModelManagerViewModel +import kotlinx.serialization.Serializable + +/** Navigation destination data */ +object ImageClassificationDestination { + @Serializable + val route = "ImageClassificationRoute" +} + +@Composable +fun ImageClassificationScreen( + modelManagerViewModel: ModelManagerViewModel, + navigateUp: () -> Unit, + modifier: Modifier = Modifier, + viewModel: ImageClassificationViewModel = viewModel( + factory = ViewModelProvider.Factory + ), +) { + val context = LocalContext.current + val primaryColor = MaterialTheme.colorScheme.primary + + ChatView( + task = viewModel.task, + viewModel = viewModel, + modelManagerViewModel = modelManagerViewModel, + onSendMessage = { model, message -> + viewModel.addMessage( + model = model, + message = message, + ) + if (message is ChatMessageImage) { + viewModel.generateResponse( + context = context, + model = model, + input = message.bitmap, + primaryColor = primaryColor, + ) + } + }, + onStreamImageMessage = { model, message -> + viewModel.generateStreamingResponse( + context = context, + model = model, + input = message.bitmap, + primaryColor = primaryColor, + ) + }, + onRunAgainClicked = { model, message -> + viewModel.runAgain( + context = context, + model = model, + message = message, + primaryColor = primaryColor, + ) + }, + onBenchmarkClicked = { model, message, warmUpIterations, benchmarkIterations -> + viewModel.benchmark( + context = context, + model = model, + message = message, + warmupCount = warmUpIterations, + iterations = benchmarkIterations, + primaryColor = primaryColor, + ) + }, + navigateUp = navigateUp, + modifier = modifier, + chatInputType = ChatInputType.IMAGE, + ) +} diff --git a/Android/src/app/src/main/java/com/google/aiedge/gallery/ui/imageclassification/ImageClassificationViewModel.kt b/Android/src/app/src/main/java/com/google/aiedge/gallery/ui/imageclassification/ImageClassificationViewModel.kt new file mode 100644 index 0000000..769f549 --- /dev/null +++ b/Android/src/app/src/main/java/com/google/aiedge/gallery/ui/imageclassification/ImageClassificationViewModel.kt @@ -0,0 +1,165 @@ +/* + * Copyright 2025 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.google.aiedge.gallery.ui.imageclassification + +import android.content.Context +import android.graphics.Bitmap +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.unit.dp +import androidx.lifecycle.viewModelScope +import com.google.aiedge.gallery.ui.common.chat.ChatMessage +import com.google.aiedge.gallery.ui.common.chat.ChatMessageClassification +import com.google.aiedge.gallery.ui.common.chat.ChatMessageImage +import com.google.aiedge.gallery.ui.common.chat.ChatMessageType +import com.google.aiedge.gallery.data.Model +import com.google.aiedge.gallery.data.TASK_IMAGE_CLASSIFICATION +import com.google.aiedge.gallery.ui.common.chat.ChatViewModel +import com.google.aiedge.gallery.ui.common.runBasicBenchmark +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.delay +import kotlinx.coroutines.launch +import kotlinx.coroutines.sync.Mutex + +class ImageClassificationViewModel : ChatViewModel(task = TASK_IMAGE_CLASSIFICATION) { + private val mutex = Mutex() + + fun generateResponse(context: Context, model: Model, input: Bitmap, primaryColor: Color) { + viewModelScope.launch(Dispatchers.Default) { + // Wait for model to be initialized. + while (model.instance == null) { + delay(100) + } + + val result = ImageClassificationModelHelper.runInference( + context = context, model = model, input = input, primaryColor = primaryColor + ) + + super.addMessage( + model = model, + message = ChatMessageClassification( + classifications = result.categories, + latencyMs = result.latencyMs, + maxBarWidth = 300.dp, + ), + ) + } + } + + fun generateStreamingResponse( + context: Context, + model: Model, + input: Bitmap, + primaryColor: Color + ) { + viewModelScope.launch(Dispatchers.Default) { + // Wait for model to be initialized. + while (model.instance == null) { + delay(100) + } + + if (mutex.tryLock()) { + try { + val result = ImageClassificationModelHelper.runInference( + context = context, model = model, input = input, primaryColor = primaryColor + ) + updateStreamingMessage( + model = model, + message = ChatMessageClassification( + classifications = result.categories, + latencyMs = result.latencyMs + ) + ) + } finally { + mutex.unlock() + } + } else { + // skip call if the previous call has not been finished (mutex is still locked). + } + } + } + + fun benchmark( + context: Context, + model: Model, + message: ChatMessage, + warmupCount: Int, + iterations: Int, + primaryColor: Color + ) { + viewModelScope.launch(Dispatchers.Default) { + // Wait for model to be initialized. + while (model.instance == null) { + delay(100) + } + + if (message is ChatMessageImage) { + setInProgress(true) + runBasicBenchmark( + model = model, + warmupCount = warmupCount, + iterations = iterations, + chatViewModel = this@ImageClassificationViewModel, + inferenceFn = { + ImageClassificationModelHelper.runInference( + context = context, + model = model, + input = message.bitmap, + primaryColor = primaryColor + ) + }, + chatMessageType = ChatMessageType.BENCHMARK_RESULT, + ) + setInProgress(false) + } + } + } + + fun runAgain(context: Context, model: Model, message: ChatMessage, primaryColor: Color) { + viewModelScope.launch(Dispatchers.Default) { + // Wait for model to be initialized. + while (model.instance == null) { + delay(100) + } + + if (message is ChatMessageImage) { + // Clone the clicked message and add it. + addMessage(model = model, message = message.clone()) + + // Run inference. + val result = + ImageClassificationModelHelper.runInference( + context = context, + model = model, + input = message.bitmap, + primaryColor = primaryColor + ) + + // Add response message. + val newMessage = generateClassificationMessage(result = result) + addMessage(model = model, message = newMessage) + } + } + } + + private fun generateClassificationMessage(result: ImageClassificationInferenceResult): ChatMessageClassification { + return ChatMessageClassification( + classifications = result.categories, + latencyMs = result.latencyMs, + maxBarWidth = 300.dp, + ) + } +} diff --git a/Android/src/app/src/main/java/com/google/aiedge/gallery/ui/imagegeneration/ImageGenerationModelHelper.kt b/Android/src/app/src/main/java/com/google/aiedge/gallery/ui/imagegeneration/ImageGenerationModelHelper.kt new file mode 100644 index 0000000..dfd4d83 --- /dev/null +++ b/Android/src/app/src/main/java/com/google/aiedge/gallery/ui/imagegeneration/ImageGenerationModelHelper.kt @@ -0,0 +1,77 @@ +/* + * Copyright 2025 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.google.aiedge.gallery.ui.imagegeneration + +import android.content.Context +import android.graphics.Bitmap +import android.util.Log +import com.google.mediapipe.framework.image.BitmapExtractor +import com.google.mediapipe.tasks.vision.imagegenerator.ImageGenerator +import com.google.aiedge.gallery.data.ConfigKey +import com.google.aiedge.gallery.data.Model +import com.google.aiedge.gallery.ui.common.LatencyProvider +import kotlin.random.Random + +private const val TAG = "AGImageGenerationModelHelper" + +class ImageGenerationInferenceResult( + val bitmap: Bitmap, override val latencyMs: Float +) : LatencyProvider + +object ImageGenerationModelHelper { + fun initialize(context: Context, model: Model, onDone: () -> Unit) { + val options = ImageGenerator.ImageGeneratorOptions.builder() + .setImageGeneratorModelDirectory(model.getPath(context = context)) + .build() + model.instance = ImageGenerator.createFromOptions(context, options) + onDone() + } + + fun cleanUp(model: Model) { + if (model.instance == null) { + return + } + val instance = model.instance as ImageGenerator + try { + instance.close() + } catch (e: Exception) { + // ignore + } + model.instance = null + Log.d(TAG, "Clean up done.") + } + + fun runInference( + model: Model, + input: String, + onStep: (curIteration: Int, totalIterations: Int, ImageGenerationInferenceResult, isLast: Boolean) -> Unit + ) { + val start = System.currentTimeMillis() + val instance = model.instance as ImageGenerator + val iterations = model.getIntConfigValue(ConfigKey.ITERATIONS) + instance.setInputs(input, iterations, Random.nextInt()) + for (i in 0.. Unit, + modifier: Modifier = Modifier, + viewModel: ImageGenerationViewModel = viewModel( + factory = ViewModelProvider.Factory + ), +) { + ChatView( + task = viewModel.task, + viewModel = viewModel, + modelManagerViewModel = modelManagerViewModel, + onSendMessage = { model, message -> + viewModel.addMessage( + model = model, + message = message, + ) + if (message is ChatMessageText) { + viewModel.generateResponse( + model = model, + input = message.content, + ) + } + }, + onRunAgainClicked = { _, _ -> }, + onBenchmarkClicked = { _, _, _, _ -> }, + navigateUp = navigateUp, + modifier = modifier, + ) +} \ No newline at end of file diff --git a/Android/src/app/src/main/java/com/google/aiedge/gallery/ui/imagegeneration/ImageGenerationViewModel.kt b/Android/src/app/src/main/java/com/google/aiedge/gallery/ui/imagegeneration/ImageGenerationViewModel.kt new file mode 100644 index 0000000..1715dc4 --- /dev/null +++ b/Android/src/app/src/main/java/com/google/aiedge/gallery/ui/imagegeneration/ImageGenerationViewModel.kt @@ -0,0 +1,87 @@ +/* + * Copyright 2025 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.google.aiedge.gallery.ui.imagegeneration + +import android.graphics.Bitmap +import androidx.compose.ui.graphics.ImageBitmap +import androidx.compose.ui.graphics.asImageBitmap +import androidx.lifecycle.viewModelScope +import com.google.aiedge.gallery.data.Model +import com.google.aiedge.gallery.data.TASK_IMAGE_GENERATION +import com.google.aiedge.gallery.ui.common.chat.ChatMessageImageWithHistory +import com.google.aiedge.gallery.ui.common.chat.ChatMessageLoading +import com.google.aiedge.gallery.ui.common.chat.ChatMessageType +import com.google.aiedge.gallery.ui.common.chat.ChatSide +import com.google.aiedge.gallery.ui.common.chat.ChatViewModel +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.delay +import kotlinx.coroutines.launch + +class ImageGenerationViewModel : ChatViewModel(task = TASK_IMAGE_GENERATION) { + fun generateResponse(model: Model, input: String) { + viewModelScope.launch(Dispatchers.Default) { + setInProgress(true) + + // Loading. + addMessage( + model = model, + message = ChatMessageLoading(), + ) + + // Wait for model to be initialized. + while (model.instance == null) { + delay(100) + } + + // Run inference. + val bitmaps: MutableList = mutableListOf() + val imageBitmaps: MutableList = mutableListOf() + ImageGenerationModelHelper.runInference( + model = model, input = input + ) { step, totalIterations, result, isLast -> + bitmaps.add(result.bitmap) + imageBitmaps.add(result.bitmap.asImageBitmap()) + val message = ChatMessageImageWithHistory( + bitmaps = bitmaps, + imageBitMaps = imageBitmaps, + totalIterations = totalIterations, + side = ChatSide.AGENT, + latencyMs = result.latencyMs, + curIteration = step, + ) + if (step == 0) { + removeLastMessage(model = model) + + super.addMessage( + model = model, + message = message, + ) + } else { + super.replaceLastMessage( + model = model, + message = message, + type = ChatMessageType.IMAGE_WITH_HISTORY + ) + } + + if (isLast) { + setInProgress(false) + } + } + } + } +} diff --git a/Android/src/app/src/main/java/com/google/aiedge/gallery/ui/llmchat/LlmChatConfigs.kt b/Android/src/app/src/main/java/com/google/aiedge/gallery/ui/llmchat/LlmChatConfigs.kt new file mode 100644 index 0000000..788a5c6 --- /dev/null +++ b/Android/src/app/src/main/java/com/google/aiedge/gallery/ui/llmchat/LlmChatConfigs.kt @@ -0,0 +1,84 @@ +/* + * Copyright 2025 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.google.aiedge.gallery.ui.llmchat + +import com.google.aiedge.gallery.data.Config +import com.google.aiedge.gallery.data.ConfigKey +import com.google.aiedge.gallery.data.ConfigValue +import com.google.aiedge.gallery.data.NumberSliderConfig +import com.google.aiedge.gallery.data.ValueType +import com.google.aiedge.gallery.data.getFloatConfigValue +import com.google.aiedge.gallery.data.getIntConfigValue + +private const val DEFAULT_MAX_TOKEN = 1024 +private const val DEFAULT_TOPK = 40 +private const val DEFAULT_TOPP = 0.9f +private const val DEFAULT_TEMPERATURE = 1.0f + +fun createLlmChatConfigs( + defaultMaxToken: Int = DEFAULT_MAX_TOKEN, + defaultTopK: Int = DEFAULT_TOPK, + defaultTopP: Float = DEFAULT_TOPP, + defaultTemperature: Float = DEFAULT_TEMPERATURE +): List { + return listOf( + NumberSliderConfig( + key = ConfigKey.MAX_TOKENS, + sliderMin = 100f, + sliderMax = 1024f, + defaultValue = defaultMaxToken.toFloat(), + valueType = ValueType.INT + ), + NumberSliderConfig( + key = ConfigKey.TOPK, + sliderMin = 5f, + sliderMax = 40f, + defaultValue = defaultTopK.toFloat(), + valueType = ValueType.INT + ), + NumberSliderConfig( + key = ConfigKey.TOPP, + sliderMin = 0.0f, + sliderMax = 1.0f, + defaultValue = defaultTopP, + valueType = ValueType.FLOAT + ), + NumberSliderConfig( + key = ConfigKey.TEMPERATURE, + sliderMin = 0.0f, + sliderMax = 2.0f, + defaultValue = defaultTemperature, + valueType = ValueType.FLOAT + ), + ) +} + +fun createLLmChatConfig(defaults: Map): List { + val defaultMaxToken = + getIntConfigValue(defaults[ConfigKey.MAX_TOKENS.id], default = DEFAULT_MAX_TOKEN) + val defaultTopK = getIntConfigValue(defaults[ConfigKey.TOPK.id], default = DEFAULT_TOPK) + val defaultTopP = getFloatConfigValue(defaults[ConfigKey.TOPP.id], default = DEFAULT_TOPP) + val defaultTemperature = + getFloatConfigValue(defaults[ConfigKey.TEMPERATURE.id], default = DEFAULT_TEMPERATURE) + + return createLlmChatConfigs( + defaultMaxToken = defaultMaxToken, + defaultTopK = defaultTopK, + defaultTopP = defaultTopP, + defaultTemperature = defaultTemperature + ) +} diff --git a/Android/src/app/src/main/java/com/google/aiedge/gallery/ui/llmchat/LlmChatModelHelper.kt b/Android/src/app/src/main/java/com/google/aiedge/gallery/ui/llmchat/LlmChatModelHelper.kt new file mode 100644 index 0000000..9d3b4f7 --- /dev/null +++ b/Android/src/app/src/main/java/com/google/aiedge/gallery/ui/llmchat/LlmChatModelHelper.kt @@ -0,0 +1,135 @@ +/* + * Copyright 2025 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.google.aiedge.gallery.ui.llmchat + +import android.content.Context +import android.util.Log +import com.google.common.util.concurrent.ListenableFuture +import com.google.mediapipe.tasks.genai.llminference.LlmInference +import com.google.mediapipe.tasks.genai.llminference.LlmInferenceSession +import com.google.aiedge.gallery.data.ConfigKey +import com.google.aiedge.gallery.data.LlmBackend +import com.google.aiedge.gallery.data.Model + +private const val TAG = "AGLlmChatModelHelper" +private const val DEFAULT_MAX_TOKEN = 1024 +private const val DEFAULT_TOPK = 40 +private const val DEFAULT_TOPP = 0.9f +private const val DEFAULT_TEMPERATURE = 1.0f + +typealias ResultListener = (partialResult: String, done: Boolean) -> Unit +typealias CleanUpListener = () -> Unit + +data class LlmModelInstance(val engine: LlmInference, val session: LlmInferenceSession) + +object LlmChatModelHelper { + // Indexed by model name. + private val cleanUpListeners: MutableMap = mutableMapOf() + private val generateResponseListenableFutures: MutableMap> = + mutableMapOf() + + fun initialize( + context: Context, model: Model, onDone: () -> Unit + ) { + val maxTokens = + model.getIntConfigValue(key = ConfigKey.MAX_TOKENS, defaultValue = DEFAULT_MAX_TOKEN) + val topK = model.getIntConfigValue(key = ConfigKey.TOPK, defaultValue = DEFAULT_TOPK) + val topP = model.getFloatConfigValue(key = ConfigKey.TOPP, defaultValue = DEFAULT_TOPP) + val temperature = + model.getFloatConfigValue(key = ConfigKey.TEMPERATURE, defaultValue = DEFAULT_TEMPERATURE) + Log.d(TAG, "Initializing...") + val preferredBackend = when (model.llmBackend) { + LlmBackend.CPU -> LlmInference.Backend.CPU + LlmBackend.GPU -> LlmInference.Backend.GPU + } + val options = + LlmInference.LlmInferenceOptions.builder().setModelPath(model.getPath(context = context)) + .setMaxTokens(maxTokens).setPreferredBackend(preferredBackend).build() + + // Create an instance of the LLM Inference task + try { + val llmInference = LlmInference.createFromOptions(context, options) + +// val session = LlmInferenceSession.createFromOptions( +// llmInference, +// LlmInferenceSession.LlmInferenceSessionOptions.builder().setTopK(topK).setTopP(topP) +// .setTemperature(temperature).build() +// ) + model.instance = llmInference +// LlmModelInstance(engine = llmInference, session = session) + } catch (e: Exception) { + e.printStackTrace() + } + onDone() + } + + fun cleanUp(model: Model) { + if (model.instance == null) { + return + } + + val instance = model.instance as LlmInference + try { + instance.close() +// instance.session.close() +// instance.engine.close() + } catch (e: Exception) { + // ignore + } + val onCleanUp = cleanUpListeners.remove(model.name) + if (onCleanUp != null) { + onCleanUp() + } + model.instance = null + Log.d(TAG, "Clean up done.") + } + + fun runInference( + model: Model, + input: String, + resultListener: ResultListener, + cleanUpListener: CleanUpListener, + ) { + val instance = model.instance as LlmInference + + // Set listener. + if (!cleanUpListeners.containsKey(model.name)) { + cleanUpListeners[model.name] = cleanUpListener + } + + // Start async inference. + val future = instance.generateResponseAsync(input, resultListener) + generateResponseListenableFutures[model.name] = future + +// val session = instance.session +// TODO: need to count token and reset session. +// session.addQueryChunk(input) +// session.generateResponseAsync(resultListener) + } + + fun stopInference(model: Model) { + val instance = model.instance as LlmInference + if (instance != null) { + instance.close() + } +// val future = generateResponseListenableFutures[model.name] +// if (future != null) { +// future.cancel(true) +// generateResponseListenableFutures.remove(model.name) +// } + } +} diff --git a/Android/src/app/src/main/java/com/google/aiedge/gallery/ui/llmchat/LlmChatScreen.kt b/Android/src/app/src/main/java/com/google/aiedge/gallery/ui/llmchat/LlmChatScreen.kt new file mode 100644 index 0000000..dbc54e5 --- /dev/null +++ b/Android/src/app/src/main/java/com/google/aiedge/gallery/ui/llmchat/LlmChatScreen.kt @@ -0,0 +1,77 @@ +/* + * Copyright 2025 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.google.aiedge.gallery.ui.llmchat + +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.lifecycle.viewmodel.compose.viewModel +import com.google.aiedge.gallery.ui.ViewModelProvider +import com.google.aiedge.gallery.ui.common.chat.ChatMessageText +import com.google.aiedge.gallery.ui.common.chat.ChatView +import com.google.aiedge.gallery.ui.modelmanager.ModelManagerViewModel +import kotlinx.serialization.Serializable + +/** Navigation destination data */ +object LlmChatDestination { + @Serializable + val route = "LlmChatRoute" +} + +@Composable +fun LlmChatScreen( + modelManagerViewModel: ModelManagerViewModel, + navigateUp: () -> Unit, + modifier: Modifier = Modifier, + viewModel: LlmChatViewModel = viewModel( + factory = ViewModelProvider.Factory + ), +) { + ChatView( + task = viewModel.task, + viewModel = viewModel, + modelManagerViewModel = modelManagerViewModel, + onSendMessage = { model, message -> + viewModel.addMessage( + model = model, + message = message, + ) + if (message is ChatMessageText) { + modelManagerViewModel.addTextInputHistory(message.content) + viewModel.generateResponse( + model = model, + input = message.content, + ) + } + }, + onRunAgainClicked = { model, message -> + if (message is ChatMessageText) { + viewModel.runAgain(model = model, message = message) + } + }, + onBenchmarkClicked = { model, message, warmUpIterations, benchmarkIterations -> + if (message is ChatMessageText) { + viewModel.benchmark( + model = model, + message = message + ) + } + }, + navigateUp = navigateUp, + modifier = modifier, + ) +} + diff --git a/Android/src/app/src/main/java/com/google/aiedge/gallery/ui/llmchat/LlmChatViewModel.kt b/Android/src/app/src/main/java/com/google/aiedge/gallery/ui/llmchat/LlmChatViewModel.kt new file mode 100644 index 0000000..324c254 --- /dev/null +++ b/Android/src/app/src/main/java/com/google/aiedge/gallery/ui/llmchat/LlmChatViewModel.kt @@ -0,0 +1,209 @@ +/* + * Copyright 2025 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.google.aiedge.gallery.ui.llmchat + +import androidx.lifecycle.viewModelScope +import com.google.mediapipe.tasks.genai.llminference.LlmInference +import com.google.aiedge.gallery.data.Model +import com.google.aiedge.gallery.data.TASK_LLM_CHAT +import com.google.aiedge.gallery.ui.common.chat.ChatMessageBenchmarkLlmResult +import com.google.aiedge.gallery.ui.common.chat.ChatMessageLoading +import com.google.aiedge.gallery.ui.common.chat.ChatMessageText +import com.google.aiedge.gallery.ui.common.chat.ChatMessageType +import com.google.aiedge.gallery.ui.common.chat.ChatSide +import com.google.aiedge.gallery.ui.common.chat.ChatViewModel +import com.google.aiedge.gallery.ui.common.chat.Stat +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.delay +import kotlinx.coroutines.launch + +private const val TAG = "AGLlmChatViewModel" +private val STATS = listOf( + Stat(id = "time_to_first_token", label = "Time to 1st token", unit = "sec"), + Stat(id = "prefill_speed", label = "Prefill speed", unit = "tokens/s"), + Stat(id = "decode_speed", label = "Decode speed", unit = "tokens/s"), + Stat(id = "latency", label = "Latency", unit = "sec") +) + +class LlmChatViewModel : ChatViewModel(task = TASK_LLM_CHAT) { + fun generateResponse(model: Model, input: String) { + viewModelScope.launch(Dispatchers.Default) { + setInProgress(true) + + // Loading. + addMessage( + model = model, + message = ChatMessageLoading(), + ) + + // Wait for instance to be initialized. + while (model.instance == null) { + delay(100) + } + + // Run inference. + val start = System.currentTimeMillis() + LlmChatModelHelper.runInference( + model = model, + input = input, + resultListener = { partialResult, done -> + // Remove the last message if it is a "loading" message. + // This will only be done once. + val lastMessage = getLastMessage(model = model) + if (lastMessage?.type == ChatMessageType.LOADING) { + removeLastMessage(model = model) + + // Add an empty message that will receive streaming results. + addMessage( + model = model, + message = ChatMessageText(content = "", side = ChatSide.AGENT) + ) + } + + // Incrementally update the streamed partial results. + val latencyMs: Long = if (done) System.currentTimeMillis() - start else -1 + updateLastMessageContentIncrementally( + model = model, + partialContent = partialResult, + latencyMs = latencyMs.toFloat() + ) + + if (done) { + setInProgress(false) + } + }, cleanUpListener = { + setInProgress(false) + }) + } + } + + fun runAgain(model: Model, message: ChatMessageText) { + viewModelScope.launch(Dispatchers.Default) { + // Wait for model to be initialized. + while (model.instance == null) { + delay(100) + } + + // Clone the clicked message and add it. + addMessage(model = model, message = message.clone()) + + // Run inference. + generateResponse( + model = model, + input = message.content, + ) + } + } + + fun benchmark(model: Model, message: ChatMessageText) { + viewModelScope.launch(Dispatchers.Default) { + setInProgress(true) + + // Wait for model to be initialized. + while (model.instance == null) { + delay(100) + } + val instance = model.instance as LlmInference + val prefillTokens = instance.sizeInTokens(message.content) + + // Add the message to show benchmark results. + val benchmarkLlmResult = ChatMessageBenchmarkLlmResult( + orderedStats = STATS, + statValues = mutableMapOf(), + running = true, + latencyMs = -1f, + ) + addMessage(model = model, message = benchmarkLlmResult) + + // Run inference. + val result = StringBuilder() + var firstRun = true + var timeToFirstToken = 0f + var firstTokenTs = 0L + var decodeTokens = 0 + var prefillSpeed = 0f + var decodeSpeed: Float + val start = System.currentTimeMillis() + var lastUpdateTime = 0L + LlmChatModelHelper.runInference( + model = model, + input = message.content, + resultListener = { partialResult, done -> + val curTs = System.currentTimeMillis() + + if (firstRun) { + firstTokenTs = System.currentTimeMillis() + timeToFirstToken = (firstTokenTs - start) / 1000f + prefillSpeed = prefillTokens / timeToFirstToken + firstRun = false + + // Update message to show prefill speed. + replaceLastMessage( + model = model, + message = ChatMessageBenchmarkLlmResult( + orderedStats = STATS, + statValues = mutableMapOf( + "prefill_speed" to prefillSpeed, + "time_to_first_token" to timeToFirstToken, + "latency" to (curTs - start).toFloat() / 1000f, + ), + running = false, + latencyMs = -1f, + ), + type = ChatMessageType.BENCHMARK_LLM_RESULT, + ) + } else { + decodeTokens++ + } + result.append(partialResult) + + if (curTs - lastUpdateTime > 500 || done) { + decodeSpeed = + decodeTokens / ((curTs - firstTokenTs) / 1000f) + if (decodeSpeed.isNaN()) { + decodeSpeed = 0f + } + replaceLastMessage( + model = model, + message = ChatMessageBenchmarkLlmResult( + orderedStats = STATS, + statValues = mutableMapOf( + "prefill_speed" to prefillSpeed, + "decode_speed" to decodeSpeed, + "time_to_first_token" to timeToFirstToken, + "latency" to (curTs - start).toFloat() / 1000f, + ), + running = !done, + latencyMs = -1f, + ), + type = ChatMessageType.BENCHMARK_LLM_RESULT + ) + lastUpdateTime = curTs + + if (done) { + setInProgress(false) + } + } + }, + cleanUpListener = { + setInProgress(false) + } + ) + } + } +} + diff --git a/Android/src/app/src/main/java/com/google/aiedge/gallery/ui/modelmanager/ModelList.kt b/Android/src/app/src/main/java/com/google/aiedge/gallery/ui/modelmanager/ModelList.kt new file mode 100644 index 0000000..59ed91c --- /dev/null +++ b/Android/src/app/src/main/java/com/google/aiedge/gallery/ui/modelmanager/ModelList.kt @@ -0,0 +1,98 @@ +/* + * Copyright 2025 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.google.aiedge.gallery.ui.modelmanager + +import androidx.compose.foundation.ExperimentalFoundationApi +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.items +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import com.google.aiedge.gallery.data.Model +import com.google.aiedge.gallery.data.Task +import com.google.aiedge.gallery.ui.common.modelitem.ModelItem +import com.google.aiedge.gallery.ui.preview.PreviewModelManagerViewModel +import com.google.aiedge.gallery.ui.preview.TASK_TEST1 +import com.google.aiedge.gallery.ui.theme.GalleryTheme + +/** The list of models in the model manager. */ +@OptIn(ExperimentalFoundationApi::class) +@Composable +fun ModelList( + task: Task, + modelManagerViewModel: ModelManagerViewModel, + contentPadding: PaddingValues, + onModelClicked: (Model) -> Unit, + modifier: Modifier = Modifier, +) { + LazyColumn( + modifier = modifier.padding(top = 8.dp), + contentPadding = contentPadding, + verticalArrangement = Arrangement.spacedBy(8.dp), + ) { + // Headline. + item(key = "headline") { + Text( + task.description, + textAlign = TextAlign.Center, + style = MaterialTheme.typography.bodyMedium.copy(fontWeight = FontWeight.SemiBold), + modifier = Modifier + .padding(bottom = 20.dp) + .fillMaxWidth() + ) + } + + // List of models within a task. + items(items = task.models) { model -> + Box { + ModelItem( + model = model, + task = task, + modelManagerViewModel = modelManagerViewModel, + onModelClicked = onModelClicked, + modifier = Modifier.padding(start = 12.dp, end = 12.dp) + ) + } + } + } +} + +@Preview(showBackground = true) +@Composable +fun ModelListPreview() { + val context = LocalContext.current + + GalleryTheme { + ModelList( + task = TASK_TEST1, + modelManagerViewModel = PreviewModelManagerViewModel(context = context), + onModelClicked = {}, + contentPadding = PaddingValues(all = 16.dp), + ) + } +} diff --git a/Android/src/app/src/main/java/com/google/aiedge/gallery/ui/modelmanager/ModelManager.kt b/Android/src/app/src/main/java/com/google/aiedge/gallery/ui/modelmanager/ModelManager.kt new file mode 100644 index 0000000..88eaabe --- /dev/null +++ b/Android/src/app/src/main/java/com/google/aiedge/gallery/ui/modelmanager/ModelManager.kt @@ -0,0 +1,130 @@ +/* + * Copyright 2025 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.google.aiedge.gallery.ui.modelmanager + +import androidx.activity.compose.BackHandler +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.Scaffold +import androidx.compose.runtime.Composable +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.tooling.preview.Preview +import com.google.aiedge.gallery.GalleryTopAppBar +import com.google.aiedge.gallery.data.AppBarAction +import com.google.aiedge.gallery.data.AppBarActionType +import com.google.aiedge.gallery.data.Model +import com.google.aiedge.gallery.data.ModelDownloadStatusType +import com.google.aiedge.gallery.data.Task +import com.google.aiedge.gallery.data.getModelByName +import com.google.aiedge.gallery.ui.preview.PreviewModelManagerViewModel +import com.google.aiedge.gallery.ui.preview.TASK_TEST1 +import com.google.aiedge.gallery.ui.theme.GalleryTheme + +/** A screen to manage models. */ +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun ModelManager( + task: Task, + viewModel: ModelManagerViewModel, + navigateUp: () -> Unit, + onModelClicked: (Model) -> Unit, + modifier: Modifier = Modifier, +) { + val uiState by viewModel.uiState.collectAsState() + val coroutineScope = rememberCoroutineScope() + + // Set title based on the task. + var title = "${task.type.label} model" + if (task.models.size != 1) { + title += "s" + } + + // Handle system's edge swipe. + BackHandler { + navigateUp() + } + + Scaffold( + modifier = modifier, + topBar = { + GalleryTopAppBar( + title = title, +// subtitle = String.format( +// stringResource(R.string.downloaded_size), +// totalSizeInBytes.humanReadableSize() +// ), + + // Refresh model list button at the left side of the app bar. +// leftAction = AppBarAction(actionType = if (uiState.loadingHfModels) { +// AppBarActionType.REFRESHING_MODELS +// } else { +// AppBarActionType.REFRESH_MODELS +// }, actionFn = { +// coroutineScope.launch(Dispatchers.IO) { +// viewModel.loadHfModels() +// } +// }), + leftAction = AppBarAction(actionType = AppBarActionType.NAVIGATE_UP, actionFn = navigateUp) + + // "Done" button at the right side of the app bar to navigate up. +// rightAction = AppBarAction( +// actionType = AppBarActionType.NAVIGATE_UP, actionFn = navigateUp +// ), + ) + }, + ) { innerPadding -> + ModelList( + task = task, + modelManagerViewModel = viewModel, + contentPadding = innerPadding, + onModelClicked = onModelClicked, + modifier = Modifier.fillMaxSize() + ) + } +} + +private fun getTotalDownloadedFileSize(uiState: ModelManagerUiState): Long { + var totalSizeInBytes = 0L + for ((name, status) in uiState.modelDownloadStatus.entries) { + if (status.status == ModelDownloadStatusType.SUCCEEDED) { + totalSizeInBytes += getModelByName(name)?.totalBytes ?: 0L + } else if (status.status == ModelDownloadStatusType.IN_PROGRESS) { + totalSizeInBytes += status.receivedBytes + } + } + return totalSizeInBytes +} + + +@Preview +@Composable +fun ModelManagerPreview() { + val context = LocalContext.current + + GalleryTheme { + ModelManager( + viewModel = PreviewModelManagerViewModel(context = context), + onModelClicked = {}, + task = TASK_TEST1, + navigateUp = {}, + ) + } +} diff --git a/Android/src/app/src/main/java/com/google/aiedge/gallery/ui/modelmanager/ModelManagerViewModel.kt b/Android/src/app/src/main/java/com/google/aiedge/gallery/ui/modelmanager/ModelManagerViewModel.kt new file mode 100644 index 0000000..6ed644c --- /dev/null +++ b/Android/src/app/src/main/java/com/google/aiedge/gallery/ui/modelmanager/ModelManagerViewModel.kt @@ -0,0 +1,697 @@ +/* + * Copyright 2025 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.google.aiedge.gallery.ui.modelmanager + +import android.content.Context +import android.net.Uri +import android.util.Log +import androidx.activity.result.ActivityResult +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import com.google.aiedge.gallery.data.AGWorkInfo +import com.google.aiedge.gallery.data.AccessTokenData +import com.google.aiedge.gallery.data.DataStoreRepository +import com.google.aiedge.gallery.data.DownloadRepository +import com.google.aiedge.gallery.data.EMPTY_MODEL +import com.google.aiedge.gallery.data.HfModel +import com.google.aiedge.gallery.data.HfModelDetails +import com.google.aiedge.gallery.data.HfModelSummary +import com.google.aiedge.gallery.data.Model +import com.google.aiedge.gallery.data.ModelDownloadStatus +import com.google.aiedge.gallery.data.ModelDownloadStatusType +import com.google.aiedge.gallery.data.TASKS +import com.google.aiedge.gallery.data.Task +import com.google.aiedge.gallery.data.TaskType +import com.google.aiedge.gallery.data.getModelByName +import com.google.aiedge.gallery.ui.common.AuthConfig +import com.google.aiedge.gallery.ui.imageclassification.ImageClassificationModelHelper +import com.google.aiedge.gallery.ui.imagegeneration.ImageGenerationModelHelper +import com.google.aiedge.gallery.ui.llmchat.LlmChatModelHelper +import com.google.aiedge.gallery.ui.textclassification.TextClassificationModelHelper +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.async +import kotlinx.coroutines.awaitAll +import kotlinx.coroutines.coroutineScope +import kotlinx.coroutines.delay +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.update +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext +import kotlinx.serialization.json.Json +import net.openid.appauth.AuthorizationException +import net.openid.appauth.AuthorizationRequest +import net.openid.appauth.AuthorizationResponse +import net.openid.appauth.AuthorizationService +import net.openid.appauth.ResponseTypeValues +import java.io.File +import java.net.HttpURLConnection +import java.net.URL + +private const val TAG = "AGModelManagerViewModel" +private const val HG_COMMUNITY = "jinjingforevercommunity" +private const val TEXT_INPUT_HISTORY_MAX_SIZE = 50 + +enum class ModelInitializationStatus { + NOT_INITIALIZED, INITIALIZING, INITIALIZED, +} + +enum class TokenStatus { + NOT_STORED, EXPIRED, NOT_EXPIRED, +} + +enum class TokenRequestResultType { + FAILED, SUCCEEDED, USER_CANCELLED +} + +data class TokenStatusAndData( + val status: TokenStatus, + val data: AccessTokenData?, +) + +data class TokenRequestResult( + val status: TokenRequestResultType, + val errorMessage: String? = null +) + +data class ModelManagerUiState( + /** + * A list of tasks available in the application. + */ + val tasks: List, + + /** + * A map that stores lists of models indexed by task name. + */ + val modelsByTaskName: Map>, + + /** + * A map that tracks the download status of each model, indexed by model name. + */ + val modelDownloadStatus: Map, + + /** + * A map that tracks the initialization status of each model, indexed by model name. + */ + val modelInitializationStatus: Map, + + /** + * Whether Hugging Face models from the given community are currently being loaded. + */ + val loadingHfModels: Boolean = false, + + /** + * The currently selected model. + */ + val selectedModel: Model = EMPTY_MODEL, + + /** + * The history of text inputs entered by the user. + */ + val textInputHistory: List = listOf(), +) + +/** + * ViewModel responsible for managing models, their download status, and initialization. + * + * This ViewModel handles model-related operations such as downloading, deleting, initializing, + * and cleaning up models. It also manages the UI state for model management, including the + * list of tasks, models, download statuses, and initialization statuses. + */ +open class ModelManagerViewModel( + private val downloadRepository: DownloadRepository, + private val dataStoreRepository: DataStoreRepository, + context: Context, +) : ViewModel() { + private val externalFilesDir = context.getExternalFilesDir(null) + private val inProgressWorkInfos: List = + downloadRepository.getEnqueuedOrRunningWorkInfos() + protected val _uiState = MutableStateFlow(createUiState()) + val uiState = _uiState.asStateFlow() + val authService = AuthorizationService(context) + var curAccessToken: String = "" + + init { + Log.d(TAG, "In-progress worker infos: $inProgressWorkInfos") + + // Iterate through the inProgressWorkInfos and retrieve the corresponding modes. + // Those models are the ones that have not finished downloading. + val models: MutableList = mutableListOf() + for (info in inProgressWorkInfos) { + getModelByName(info.modelName)?.let { model -> + models.add(model) + } + } + + // Cancel all pending downloads for the retrieved models. + downloadRepository.cancelAll(models) { + Log.d(TAG, "All pending work is cancelled") + + viewModelScope.launch(Dispatchers.IO) { + // Load models from hg community. + loadHfModels() + Log.d(TAG, "Done loading HF models") + + // Kick off downloads for these models . + withContext(Dispatchers.Main) { + for (info in inProgressWorkInfos) { + val model: Model? = getModelByName(info.modelName) + if (model != null) { + Log.d(TAG, "Sending a new download request for '${model.name}'") + downloadRepository.downloadModel( + model, onStatusUpdated = this@ModelManagerViewModel::setDownloadStatus + ) + } + } + } + } + } + } + + override fun onCleared() { + super.onCleared() + authService.dispose() + } + + fun selectModel(model: Model) { + _uiState.update { _uiState.value.copy(selectedModel = model) } + } + + fun downloadModel(model: Model) { + // Update status. + setDownloadStatus( + curModel = model, status = ModelDownloadStatus(status = ModelDownloadStatusType.IN_PROGRESS) + ) + + // Delete the model files first. + deleteModel(model = model) + + // Start to send download request. + downloadRepository.downloadModel( + model, onStatusUpdated = this::setDownloadStatus + ) + } + + fun cancelDownloadModel(model: Model) { + downloadRepository.cancelDownloadModel(model) + } + + fun deleteModel(model: Model) { + deleteFileFromExternalFilesDir(model.downloadFileName) + for (file in model.extraDataFiles) { + deleteFileFromExternalFilesDir(file.downloadFileName) + } + if (model.isZip && model.unzipDir.isNotEmpty()) { + deleteDirFromExternalFilesDir(model.unzipDir) + } + + // Update model download status to NotDownloaded. + val curModelDownloadStatus = uiState.value.modelDownloadStatus.toMutableMap() + curModelDownloadStatus[model.name] = + ModelDownloadStatus(status = ModelDownloadStatusType.NOT_DOWNLOADED) + val newUiState = uiState.value.copy(modelDownloadStatus = curModelDownloadStatus) + _uiState.update { newUiState } + } + + fun initializeModel(context: Context, model: Model, force: Boolean = false) { + viewModelScope.launch(Dispatchers.Default) { + // Skip if initialized already. + if (!force && uiState.value.modelInitializationStatus[model.name] == ModelInitializationStatus.INITIALIZED) { + Log.d(TAG, "Model '${model.name}' has been initialized. Skipping.") + return@launch + } + + // Skip if initialization is in progress. + if (model.initializing) { + Log.d(TAG, "Model '${model.name}' is being initialized. Skipping.") + return@launch + } + + // Clean up. + cleanupModel(model = model) + + // Start initialization. + Log.d(TAG, "Initializing model '${model.name}'...") + model.initializing = true + + // Show initializing status after a delay. When the delay expires, check if the model has + // been initialized or not. If so, skip. + launch { + delay(500) + if (model.instance == null) { + updateModelInitializationStatus( + model = model, status = ModelInitializationStatus.INITIALIZING + ) + } + } + + val onDone: () -> Unit = { + if (model.instance != null) { + Log.d(TAG, "Model '${model.name}' initialized successfully") + model.initializing = false + updateModelInitializationStatus( + model = model, + status = ModelInitializationStatus.INITIALIZED, + ) + } + } + when (model.taskType) { + TaskType.TEXT_CLASSIFICATION -> TextClassificationModelHelper.initialize( + context = context, + model = model, + onDone = onDone, + ) + + TaskType.IMAGE_CLASSIFICATION -> ImageClassificationModelHelper.initialize( + context = context, + model = model, + onDone = onDone, + ) + + TaskType.LLM_CHAT -> LlmChatModelHelper.initialize( + context = context, + model = model, + onDone = onDone, + ) + + TaskType.IMAGE_GENERATION -> ImageGenerationModelHelper.initialize( + context = context, model = model, onDone = onDone + ) + + else -> {} + } + } + } + + fun cleanupModel(model: Model) { + if (model.instance != null) { + Log.d(TAG, "Cleaning up model '${model.name}'...") + when (model.taskType) { + TaskType.TEXT_CLASSIFICATION -> TextClassificationModelHelper.cleanUp(model = model) + TaskType.IMAGE_CLASSIFICATION -> ImageClassificationModelHelper.cleanUp(model = model) + TaskType.LLM_CHAT -> LlmChatModelHelper.cleanUp(model = model) + TaskType.IMAGE_GENERATION -> ImageGenerationModelHelper.cleanUp(model = model) + else -> {} + } + model.instance = null + model.initializing = false + updateModelInitializationStatus( + model = model, status = ModelInitializationStatus.NOT_INITIALIZED + ) + } + } + + fun setDownloadStatus(curModel: Model, status: ModelDownloadStatus) { + // Update model download progress. + val curModelDownloadStatus = uiState.value.modelDownloadStatus.toMutableMap() + curModelDownloadStatus[curModel.name] = status + val newUiState = uiState.value.copy(modelDownloadStatus = curModelDownloadStatus) + + // Delete downloaded file if status is failed or not_downloaded. + if (status.status == ModelDownloadStatusType.FAILED || status.status == ModelDownloadStatusType.NOT_DOWNLOADED) { + deleteFileFromExternalFilesDir(curModel.downloadFileName) + } + + _uiState.update { newUiState } + } + + fun addTextInputHistory(text: String) { + if (uiState.value.textInputHistory.indexOf(text) < 0) { + val newHistory = uiState.value.textInputHistory.toMutableList() + newHistory.add(0, text) + if (newHistory.size > TEXT_INPUT_HISTORY_MAX_SIZE) { + newHistory.removeAt(newHistory.size - 1) + } + _uiState.update { _uiState.value.copy(textInputHistory = newHistory) } + dataStoreRepository.saveTextInputHistory(_uiState.value.textInputHistory) + } + } + + fun promoteTextInputHistoryItem(text: String) { + val index = uiState.value.textInputHistory.indexOf(text) + if (index >= 0) { + val newHistory = uiState.value.textInputHistory.toMutableList() + newHistory.removeAt(index) + newHistory.add(0, text) + _uiState.update { _uiState.value.copy(textInputHistory = newHistory) } + dataStoreRepository.saveTextInputHistory(_uiState.value.textInputHistory) + } + } + + fun deleteTextInputHistory(text: String) { + val index = uiState.value.textInputHistory.indexOf(text) + if (index >= 0) { + val newHistory = uiState.value.textInputHistory.toMutableList() + newHistory.removeAt(index) + _uiState.update { _uiState.value.copy(textInputHistory = newHistory) } + dataStoreRepository.saveTextInputHistory(_uiState.value.textInputHistory) + } + } + + fun clearTextInputHistory() { + _uiState.update { _uiState.value.copy(textInputHistory = mutableListOf()) } + dataStoreRepository.saveTextInputHistory(_uiState.value.textInputHistory) + } + + fun readThemeOverride(): String { + return dataStoreRepository.readThemeOverride() + } + + fun saveThemeOverride(theme: String) { + dataStoreRepository.saveThemeOverride(theme = theme) + } + + fun getModelUrlResponse(model: Model, accessToken: String? = null): Int { + val url = URL(model.url) + val connection = url.openConnection() as HttpURLConnection + if (accessToken != null) { + connection.setRequestProperty( + "Authorization", + "Bearer $accessToken" + ) + } + connection.connect() + + // Report the result. + return connection.responseCode + } + + fun getTokenStatusAndData(): TokenStatusAndData { + // Try to load token data from DataStore. + var tokenStatus = TokenStatus.NOT_STORED + Log.d(TAG, "Reading token data from data store...") + val tokenData = dataStoreRepository.readAccessTokenData() + + // Token exists. + if (tokenData != null) { + Log.d(TAG, "Token exists and loaded.") + + // Check expiration (with 5-minute buffer). + val curTs = System.currentTimeMillis() + val expirationTs = tokenData.expiresAtSeconds - 5 * 60 + Log.d( + TAG, + "Checking whether token has expired or not. Current ts: $curTs, expires at: $expirationTs" + ) + if (curTs >= expirationTs) { + Log.d(TAG, "Token expired!") + tokenStatus = TokenStatus.EXPIRED + } else { + Log.d(TAG, "Token not expired.") + tokenStatus = TokenStatus.NOT_EXPIRED + curAccessToken = tokenData.accessToken + } + } else { + Log.d(TAG, "Token doesn't exists.") + } + + return TokenStatusAndData(status = tokenStatus, data = tokenData) + } + + fun getAuthorizationRequest(): AuthorizationRequest { + return AuthorizationRequest.Builder( + AuthConfig.authServiceConfig, + AuthConfig.clientId, + ResponseTypeValues.CODE, + Uri.parse(AuthConfig.redirectUri) + ).setScope("read-repos").build() + } + + fun handleAuthResult(result: ActivityResult, onTokenRequested: (TokenRequestResult) -> Unit) { + val dataIntent = result.data + if (dataIntent == null) { + onTokenRequested( + TokenRequestResult( + status = TokenRequestResultType.FAILED, + errorMessage = "Empty auth result" + ) + ) + return + } + + val response = AuthorizationResponse.fromIntent(dataIntent) + val exception = AuthorizationException.fromIntent(dataIntent) + + when { + response?.authorizationCode != null -> { + // Authorization successful, exchange the code for tokens + var errorMessage: String? = null + authService.performTokenRequest( + response.createTokenExchangeRequest() + ) { tokenResponse, tokenEx -> + if (tokenResponse != null) { + if (tokenResponse.accessToken == null) { + errorMessage = "Empty access token" + } else if (tokenResponse.refreshToken == null) { + errorMessage = "Empty refresh token" + } else if (tokenResponse.accessTokenExpirationTime == null) { + errorMessage = "Empty expiration time" + } else { + // Token exchange successful. Store the tokens securely + Log.d(TAG, "Token exchange successful. Storing tokens...") + dataStoreRepository.saveAccessTokenData( + accessToken = tokenResponse.accessToken!!, + refreshToken = tokenResponse.refreshToken!!, + expiresAt = tokenResponse.accessTokenExpirationTime!! + ) + curAccessToken = tokenResponse.accessToken!! + Log.d(TAG, "Token successfully saved.") + } + } else if (tokenEx != null) { + errorMessage = "Token exchange failed: ${tokenEx.message}" + } else { + errorMessage = "Token exchange failed" + } + if (errorMessage == null) { + onTokenRequested(TokenRequestResult(status = TokenRequestResultType.SUCCEEDED)) + } else { + onTokenRequested( + TokenRequestResult( + status = TokenRequestResultType.FAILED, + errorMessage = errorMessage + ) + ) + } + } + } + + exception != null -> { + onTokenRequested( + TokenRequestResult( + status = if (exception.message == "User cancelled flow") TokenRequestResultType.USER_CANCELLED else TokenRequestResultType.FAILED, + errorMessage = "${exception.message}" + ) + ) + } + + else -> { + onTokenRequested( + TokenRequestResult( + status = TokenRequestResultType.USER_CANCELLED, + ) + ) + } + } + } + + private fun isModelPartiallyDownloaded(model: Model): Boolean { + return inProgressWorkInfos.find { it.modelName == model.name } != null + } + + private fun createUiState(): ModelManagerUiState { + val modelsByTaskName: Map> = + TASKS.associate { task -> task.type.label to task.models } + val modelDownloadStatus: MutableMap = mutableMapOf() + val modelInstances: MutableMap = mutableMapOf() + for ((_, models) in modelsByTaskName.entries) { + for (model in models) { + modelDownloadStatus[model.name] = getModelDownloadStatus(model = model) + modelInstances[model.name] = ModelInitializationStatus.NOT_INITIALIZED + } + } + + val textInputHistory = dataStoreRepository.readTextInputHistory() + Log.d(TAG, "text input history: $textInputHistory") + + return ModelManagerUiState( + tasks = TASKS, + modelsByTaskName = modelsByTaskName, + modelDownloadStatus = modelDownloadStatus, + modelInitializationStatus = modelInstances, + textInputHistory = textInputHistory, + ) + } + + /** + * Retrieves the download status of a model. + * + * This function determines the download status of a given model by checking if it's fully + * downloaded, partially downloaded, or not downloaded at all. It also retrieves the received and + * total bytes for partially downloaded models. + */ + private fun getModelDownloadStatus(model: Model): ModelDownloadStatus { + var status = ModelDownloadStatusType.NOT_DOWNLOADED + var receivedBytes = 0L + var totalBytes = 0L + if (isModelDownloaded(model = model)) { + if (isModelPartiallyDownloaded(model = model)) { + status = ModelDownloadStatusType.PARTIALLY_DOWNLOADED + val file = File(externalFilesDir, model.downloadFileName) + receivedBytes = file.length() + totalBytes = model.totalBytes + } else { + status = ModelDownloadStatusType.SUCCEEDED + } + } + return ModelDownloadStatus( + status = status, receivedBytes = receivedBytes, totalBytes = totalBytes + ) + } + + suspend fun loadHfModels() { + // Update loading state shown in ui. + _uiState.update { + uiState.value.copy( + loadingHfModels = true, + ) + } + + val modelDownloadStatus = uiState.value.modelDownloadStatus.toMutableMap() + val modelInstances = uiState.value.modelInitializationStatus.toMutableMap() + try { + // Load model summaries. + val modelSummaries = + getJsonResponse>(url = "https://huggingface.co/api/models?search=$HG_COMMUNITY") + Log.d(TAG, "HF model summaries: $modelSummaries") + + // Load individual models in parallel. + if (modelSummaries != null) { + coroutineScope { + val hfModels = modelSummaries.map { summary -> + async { + val details = + getJsonResponse(url = "https://huggingface.co/api/models/${summary.modelId}") + if (details != null && details.siblings.find { it.rfilename == "app.json" } != null) { + val hfModel = + getJsonResponse(url = "https://huggingface.co/${summary.modelId}/resolve/main/app.json") + if (hfModel != null) { + hfModel.id = details.id + } + return@async hfModel + } + return@async null + } + } + + // Process loaded app.json + for (hfModel in hfModels.awaitAll()) { + if (hfModel != null) { + Log.d(TAG, "HF model: $hfModel") + val task = TASKS.find { it.type.label == hfModel.task } + val model = hfModel.toModel() + if (task != null && task.models.find { it.hfModelId == model.hfModelId } == null) { + model.preProcess(task = task) + Log.d(TAG, "AG model: $model") + task.models.add(model) + + // Add initial status and states. + modelDownloadStatus[model.name] = getModelDownloadStatus(model = model) + modelInstances[model.name] = ModelInitializationStatus.NOT_INITIALIZED + } + } + } + } + } + + _uiState.update { + uiState.value.copy( + loadingHfModels = false, + modelDownloadStatus = modelDownloadStatus, + modelInitializationStatus = modelInstances + ) + } + } catch (e: Exception) { + e.printStackTrace() + } + } + + private inline fun getJsonResponse(url: String): T? { + try { + val connection = URL(url).openConnection() as HttpURLConnection + connection.requestMethod = "GET" + connection.connect() + + val responseCode = connection.responseCode + if (responseCode == HttpURLConnection.HTTP_OK) { + val inputStream = connection.inputStream + val response = inputStream.bufferedReader().use { it.readText() } + + // Parse JSON using kotlinx.serialization + val json = Json { ignoreUnknownKeys = true } // Handle potential extra fields + val jsonObj = json.decodeFromString(response) + return jsonObj + } else { + println("HTTP error: $responseCode") + } + } catch (e: Exception) { + e.printStackTrace() + } + + return null + } + + private fun isFileInExternalFilesDir(fileName: String): Boolean { + if (externalFilesDir != null) { + val file = File(externalFilesDir, fileName) + return file.exists() + } else { + return false + } + } + + private fun deleteFileFromExternalFilesDir(fileName: String) { + if (isFileInExternalFilesDir(fileName)) { + val file = File(externalFilesDir, fileName) + file.delete() + } + } + + private fun deleteDirFromExternalFilesDir(dir: String) { + if (isFileInExternalFilesDir(dir)) { + val file = File(externalFilesDir, dir) + file.deleteRecursively() + } + } + + private fun updateModelInitializationStatus(model: Model, status: ModelInitializationStatus) { + val curModelInstance = uiState.value.modelInitializationStatus.toMutableMap() + curModelInstance[model.name] = status + val newUiState = uiState.value.copy(modelInitializationStatus = curModelInstance) + _uiState.update { newUiState } + } + + private fun isModelDownloaded(model: Model): Boolean { + val downloadedFileExists = + model.downloadFileName.isNotEmpty() && isFileInExternalFilesDir(model.downloadFileName) + + val unzippedDirectoryExists = + model.isZip && model.unzipDir.isNotEmpty() && isFileInExternalFilesDir(model.unzipDir) + + // Will also return true if model is partially downloaded. + return downloadedFileExists || unzippedDirectoryExists + } +} diff --git a/Android/src/app/src/main/java/com/google/aiedge/gallery/ui/navigation/GalleryNavGraph.kt b/Android/src/app/src/main/java/com/google/aiedge/gallery/ui/navigation/GalleryNavGraph.kt new file mode 100644 index 0000000..8b7741a --- /dev/null +++ b/Android/src/app/src/main/java/com/google/aiedge/gallery/ui/navigation/GalleryNavGraph.kt @@ -0,0 +1,265 @@ +/* + * Copyright 2025 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.google.aiedge.gallery.ui.navigation + +import android.util.Log +import androidx.compose.animation.AnimatedContentTransitionScope +import androidx.compose.animation.AnimatedVisibility +import androidx.compose.animation.EnterTransition +import androidx.compose.animation.ExitTransition +import androidx.compose.animation.core.EaseOutExpo +import androidx.compose.animation.core.FiniteAnimationSpec +import androidx.compose.animation.core.tween +import androidx.compose.animation.slideInHorizontally +import androidx.compose.animation.slideOutHorizontally +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.unit.IntOffset +import androidx.compose.ui.zIndex +import androidx.lifecycle.viewmodel.compose.viewModel +import androidx.navigation.NavBackStackEntry +import androidx.navigation.NavHostController +import androidx.navigation.NavType +import androidx.navigation.compose.NavHost +import androidx.navigation.compose.composable +import androidx.navigation.navArgument +import com.google.aiedge.gallery.data.Model +import com.google.aiedge.gallery.data.TASK_IMAGE_CLASSIFICATION +import com.google.aiedge.gallery.data.TASK_IMAGE_GENERATION +import com.google.aiedge.gallery.data.TASK_LLM_CHAT +import com.google.aiedge.gallery.data.TASK_TEXT_CLASSIFICATION +import com.google.aiedge.gallery.data.Task +import com.google.aiedge.gallery.data.TaskType +import com.google.aiedge.gallery.data.getModelByName +import com.google.aiedge.gallery.ui.ViewModelProvider +import com.google.aiedge.gallery.ui.home.HomeScreen +import com.google.aiedge.gallery.ui.imageclassification.ImageClassificationDestination +import com.google.aiedge.gallery.ui.imageclassification.ImageClassificationScreen +import com.google.aiedge.gallery.ui.imagegeneration.ImageGenerationDestination +import com.google.aiedge.gallery.ui.imagegeneration.ImageGenerationScreen +import com.google.aiedge.gallery.ui.llmchat.LlmChatDestination +import com.google.aiedge.gallery.ui.llmchat.LlmChatScreen +import com.google.aiedge.gallery.ui.modelmanager.ModelManager +import com.google.aiedge.gallery.ui.modelmanager.ModelManagerViewModel +import com.google.aiedge.gallery.ui.textclassification.TextClassificationDestination +import com.google.aiedge.gallery.ui.textclassification.TextClassificationScreen + +private const val TAG = "AGGalleryNavGraph" +private const val ROUTE_PLACEHOLDER = "placeholder" +private const val ENTER_ANIMATION_DURATION_MS = 500 +private val ENTER_ANIMATION_EASING = EaseOutExpo +private const val ENTER_ANIMATION_DELAY_MS = 100 + +private const val EXIT_ANIMATION_DURATION_MS = 500 +private val EXIT_ANIMATION_EASING = EaseOutExpo + +private fun enterTween(): FiniteAnimationSpec { + return tween( + ENTER_ANIMATION_DURATION_MS, + easing = ENTER_ANIMATION_EASING, + delayMillis = ENTER_ANIMATION_DELAY_MS + ) +} + +private fun exitTween(): FiniteAnimationSpec { + return tween(EXIT_ANIMATION_DURATION_MS, easing = EXIT_ANIMATION_EASING) +} + +private fun AnimatedContentTransitionScope<*>.slideEnter(): EnterTransition { + return slideIntoContainer( + animationSpec = enterTween(), + towards = AnimatedContentTransitionScope.SlideDirection.Left, + ) +} + +private fun AnimatedContentTransitionScope<*>.slideExit(): ExitTransition { + return slideOutOfContainer( + animationSpec = exitTween(), + towards = AnimatedContentTransitionScope.SlideDirection.Right, + ) +} + +/** + * Navigation routes. + */ +@Composable +fun GalleryNavHost( + navController: NavHostController, + modifier: Modifier = Modifier, + modelManagerViewModel: ModelManagerViewModel = viewModel(factory = ViewModelProvider.Factory) +) { + var showModelManager by remember { mutableStateOf(false) } + var pickedTask by remember { mutableStateOf(null) } + + HomeScreen( + modelManagerViewModel = modelManagerViewModel, + navigateToTaskScreen = { task -> + pickedTask = task + showModelManager = true + }, + ) + + // Model manager. + AnimatedVisibility( + visible = showModelManager, + enter = slideInHorizontally(initialOffsetX = { it }), + exit = slideOutHorizontally(targetOffsetX = { it }), + ) { + val curPickedTask = pickedTask + if (curPickedTask != null) { + ModelManager( + viewModel = modelManagerViewModel, + task = curPickedTask, + onModelClicked = { model -> + navigateToTaskScreen( + navController = navController, taskType = model.taskType!!, model = model + ) + }, + navigateUp = { showModelManager = false }) + } + } + + NavHost( + navController = navController, + // Default to open home screen. + startDestination = ROUTE_PLACEHOLDER, + enterTransition = { EnterTransition.None }, + exitTransition = { ExitTransition.None }, + modifier = modifier.zIndex(1f) + ) { + // Placeholder root screen + composable( + route = ROUTE_PLACEHOLDER, + ) { + Text("") + } + + // Text classification. + composable( + route = "${TextClassificationDestination.route}/{modelName}", + arguments = listOf(navArgument("modelName") { type = NavType.StringType }), + enterTransition = { slideEnter() }, + exitTransition = { slideExit() }, + ) { + getModelFromNavigationParam(it, TASK_TEXT_CLASSIFICATION)?.let { defaultModel -> + modelManagerViewModel.selectModel(defaultModel) + + TextClassificationScreen( + modelManagerViewModel = modelManagerViewModel, + navigateUp = { navController.navigateUp() }, + ) + } + } + + // Image classification. + composable( + route = "${ImageClassificationDestination.route}/{modelName}", + arguments = listOf(navArgument("modelName") { type = NavType.StringType }), + enterTransition = { slideEnter() }, + exitTransition = { slideExit() }, + ) { + getModelFromNavigationParam(it, TASK_IMAGE_CLASSIFICATION)?.let { defaultModel -> + modelManagerViewModel.selectModel(defaultModel) + + ImageClassificationScreen( + modelManagerViewModel = modelManagerViewModel, + navigateUp = { navController.navigateUp() }, + ) + } + } + + // Image generation. + composable( + route = "${ImageGenerationDestination.route}/{modelName}", + arguments = listOf(navArgument("modelName") { type = NavType.StringType }), + enterTransition = { slideEnter() }, + exitTransition = { slideExit() }, + ) { + getModelFromNavigationParam(it, TASK_IMAGE_GENERATION)?.let { defaultModel -> + modelManagerViewModel.selectModel(defaultModel) + + ImageGenerationScreen( + modelManagerViewModel = modelManagerViewModel, + navigateUp = { navController.navigateUp() }, + ) + } + } + + // LLMm chat demos. + composable( + route = "${LlmChatDestination.route}/{modelName}", + arguments = listOf(navArgument("modelName") { type = NavType.StringType }), + enterTransition = { slideEnter() }, + exitTransition = { slideExit() }, + ) { + getModelFromNavigationParam(it, TASK_LLM_CHAT)?.let { defaultModel -> + modelManagerViewModel.selectModel(defaultModel) + + LlmChatScreen( + modelManagerViewModel = modelManagerViewModel, + navigateUp = { navController.navigateUp() }, + ) + } + } + } + + // Handle incoming intents for deep links + val intent = androidx.activity.compose.LocalActivity.current?.intent + val data = intent?.data + if (data != null) { + intent.data = null + Log.d(TAG, "navigation link clicked: $data") + if (data.toString().startsWith("com.google.aiedge.gallery://model/")) { + val modelName = data.pathSegments.last() + getModelByName(modelName)?.let { model -> + navigateToTaskScreen( + navController = navController, + taskType = model.taskType!!, + model = model + ) + } + } + } +} + +fun navigateToTaskScreen( + navController: NavHostController, taskType: TaskType, model: Model? = null +) { + val modelName = model?.name ?: "" + when (taskType) { + TaskType.TEXT_CLASSIFICATION -> navController.navigate("${TextClassificationDestination.route}/${modelName}") + TaskType.IMAGE_CLASSIFICATION -> navController.navigate("${ImageClassificationDestination.route}/${modelName}") + TaskType.LLM_CHAT -> navController.navigate("${LlmChatDestination.route}/${modelName}") + TaskType.IMAGE_GENERATION -> navController.navigate("${ImageGenerationDestination.route}/${modelName}") + TaskType.TEST_TASK_1 -> {} + TaskType.TEST_TASK_2 -> {} + } +} + +fun getModelFromNavigationParam(entry: NavBackStackEntry, task: Task): Model? { + var modelName = entry.arguments?.getString("modelName") ?: "" + if (modelName.isEmpty()) { + modelName = task.models[0].name + } + val model = getModelByName(modelName) + return model +} diff --git a/Android/src/app/src/main/java/com/google/aiedge/gallery/ui/preview/PreviewChatModel.kt b/Android/src/app/src/main/java/com/google/aiedge/gallery/ui/preview/PreviewChatModel.kt new file mode 100644 index 0000000..cdc6ec7 --- /dev/null +++ b/Android/src/app/src/main/java/com/google/aiedge/gallery/ui/preview/PreviewChatModel.kt @@ -0,0 +1,90 @@ +/* + * Copyright 2025 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.google.aiedge.gallery.ui.preview + +import android.content.Context +import android.graphics.Bitmap +import android.graphics.Canvas +import android.graphics.drawable.Drawable +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.asImageBitmap +import androidx.core.content.ContextCompat +import com.google.aiedge.gallery.R +import com.google.aiedge.gallery.ui.common.chat.ChatMessageClassification +import com.google.aiedge.gallery.ui.common.chat.ChatMessageImage +import com.google.aiedge.gallery.ui.common.chat.ChatMessageText +import com.google.aiedge.gallery.ui.common.chat.ChatSide +import com.google.aiedge.gallery.ui.common.chat.ChatViewModel +import com.google.aiedge.gallery.ui.common.chat.Classification + +class PreviewChatModel(context: Context) : ChatViewModel(task = TASK_TEST1) { + init { + val model = task.models[1] + addMessage( + model = model, + message = ChatMessageText( + content = "Thanks everyone for your enthusiasm on the team lunch, but people who can sign on the cheque is OOO next week \uD83D\uDE02,", + side = ChatSide.USER + ), + ) + addMessage( + model = model, + message = ChatMessageText( + content = "Today is Wednesday!", side = ChatSide.AGENT, latencyMs = 1232f + ), + ) + addMessage( + model = model, + message = ChatMessageClassification( + classifications = listOf( + Classification(label = "label1", score = 0.3f, color = Color.Red), + Classification(label = "label2", score = 0.7f, color = Color.Blue) + ), + latencyMs = 12345f, + ), + ) + val bitmap = + getBitmapFromVectorDrawable( + context = context, + drawableId = R.drawable.ic_launcher_background + )!! + addMessage( + model = model, + message = ChatMessageImage( + bitmap = bitmap, + imageBitMap = bitmap.asImageBitmap(), + side = ChatSide.USER, + ), + ) + } + + private fun getBitmapFromVectorDrawable(context: Context, drawableId: Int): Bitmap? { + val drawable: Drawable = ContextCompat.getDrawable(context, drawableId) + ?: return null // Drawable not found + + val bitmap = Bitmap.createBitmap( + drawable.intrinsicWidth, + drawable.intrinsicHeight, + Bitmap.Config.ARGB_8888 + ) + val canvas = Canvas(bitmap) + drawable.setBounds(0, 0, canvas.width, canvas.height) + drawable.draw(canvas) + + return bitmap + } +} \ No newline at end of file diff --git a/Android/src/app/src/main/java/com/google/aiedge/gallery/ui/preview/PreviewDataStoreRepository.kt b/Android/src/app/src/main/java/com/google/aiedge/gallery/ui/preview/PreviewDataStoreRepository.kt new file mode 100644 index 0000000..1e41c38 --- /dev/null +++ b/Android/src/app/src/main/java/com/google/aiedge/gallery/ui/preview/PreviewDataStoreRepository.kt @@ -0,0 +1,43 @@ +/* + * Copyright 2025 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.google.aiedge.gallery.ui.preview + +import com.google.aiedge.gallery.data.AccessTokenData +import com.google.aiedge.gallery.data.DataStoreRepository + +class PreviewDataStoreRepository : DataStoreRepository { + override fun saveTextInputHistory(history: List) { + } + + override fun readTextInputHistory(): List { + return listOf() + } + + override fun saveThemeOverride(theme: String) { + } + + override fun readThemeOverride(): String { + return "" + } + + override fun saveAccessTokenData(accessToken: String, refreshToken: String, expiresAt: Long) { + } + + override fun readAccessTokenData(): AccessTokenData? { + return null + } +} \ No newline at end of file diff --git a/Android/src/app/src/main/java/com/google/aiedge/gallery/ui/preview/PreviewDownloadRepository.kt b/Android/src/app/src/main/java/com/google/aiedge/gallery/ui/preview/PreviewDownloadRepository.kt new file mode 100644 index 0000000..a37fe20 --- /dev/null +++ b/Android/src/app/src/main/java/com/google/aiedge/gallery/ui/preview/PreviewDownloadRepository.kt @@ -0,0 +1,47 @@ +/* + * Copyright 2025 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.google.aiedge.gallery.ui.preview + +import com.google.aiedge.gallery.data.AGWorkInfo +import com.google.aiedge.gallery.data.DownloadRepository +import com.google.aiedge.gallery.data.Model +import com.google.aiedge.gallery.data.ModelDownloadStatus +import java.util.UUID + +class PreviewDownloadRepository : DownloadRepository { + override fun downloadModel( + model: Model, onStatusUpdated: (model: Model, status: ModelDownloadStatus) -> Unit + ) { + } + + override fun cancelDownloadModel(model: Model) { + } + + override fun cancelAll(models: List, onComplete: () -> Unit) { + } + + override fun observerWorkerProgress( + workerId: UUID, + model: Model, + onStatusUpdated: (model: Model, status: ModelDownloadStatus) -> Unit + ) { + } + + override fun getEnqueuedOrRunningWorkInfos(): List { + return listOf() + } +} diff --git a/Android/src/app/src/main/java/com/google/aiedge/gallery/ui/preview/PreviewModelManagerViewModel.kt b/Android/src/app/src/main/java/com/google/aiedge/gallery/ui/preview/PreviewModelManagerViewModel.kt new file mode 100644 index 0000000..31db9ee --- /dev/null +++ b/Android/src/app/src/main/java/com/google/aiedge/gallery/ui/preview/PreviewModelManagerViewModel.kt @@ -0,0 +1,71 @@ +/* + * Copyright 2025 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.google.aiedge.gallery.ui.preview + +import android.content.Context +import com.google.aiedge.gallery.data.Model +import com.google.aiedge.gallery.data.ModelDownloadStatus +import com.google.aiedge.gallery.data.ModelDownloadStatusType +import com.google.aiedge.gallery.ui.modelmanager.ModelManagerUiState +import com.google.aiedge.gallery.ui.modelmanager.ModelManagerViewModel +import kotlinx.coroutines.flow.update + +class PreviewModelManagerViewModel(context: Context) : + ModelManagerViewModel( + downloadRepository = PreviewDownloadRepository(), + dataStoreRepository = PreviewDataStoreRepository(), + context = context + ) { + + init { + for ((index, task) in ALL_PREVIEW_TASKS.withIndex()) { + task.index = index + for (model in task.models) { + model.preProcess(task = task) + } + } + + val modelsByTaskName: Map> = + ALL_PREVIEW_TASKS.associate { task -> task.type.label to task.models } + val modelDownloadStatus = mapOf( + MODEL_TEST1.name to ModelDownloadStatus( + status = ModelDownloadStatusType.IN_PROGRESS, + receivedBytes = 1234, + totalBytes = 3456, + bytesPerSecond = 2333, + remainingMs = 324, + ), + MODEL_TEST2.name to ModelDownloadStatus( + status = ModelDownloadStatusType.SUCCEEDED + ), + MODEL_TEST3.name to ModelDownloadStatus( + status = ModelDownloadStatusType.FAILED, errorMessage = "Http code 404" + ), + MODEL_TEST4.name to ModelDownloadStatus( + status = ModelDownloadStatusType.NOT_DOWNLOADED + ), + ) + val newUiState = ModelManagerUiState( + tasks = ALL_PREVIEW_TASKS, + modelsByTaskName = modelsByTaskName, + modelDownloadStatus = modelDownloadStatus, + modelInitializationStatus = mapOf(), + selectedModel = MODEL_TEST2, + ) + _uiState.update { newUiState } + } +} diff --git a/Android/src/app/src/main/java/com/google/aiedge/gallery/ui/preview/PreviewTasks.kt b/Android/src/app/src/main/java/com/google/aiedge/gallery/ui/preview/PreviewTasks.kt new file mode 100644 index 0000000..eec2aa2 --- /dev/null +++ b/Android/src/app/src/main/java/com/google/aiedge/gallery/ui/preview/PreviewTasks.kt @@ -0,0 +1,96 @@ +/* + * Copyright 2025 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.google.aiedge.gallery.ui.preview + +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.rounded.AccountBox +import androidx.compose.material.icons.rounded.AutoAwesome +import com.google.aiedge.gallery.data.BooleanSwitchConfig +import com.google.aiedge.gallery.data.Config +import com.google.aiedge.gallery.data.ConfigKey +import com.google.aiedge.gallery.data.SegmentedButtonConfig +import com.google.aiedge.gallery.data.Model +import com.google.aiedge.gallery.data.NumberSliderConfig +import com.google.aiedge.gallery.data.Task +import com.google.aiedge.gallery.data.TaskType +import com.google.aiedge.gallery.data.ValueType + +val TEST_CONFIGS1: List = listOf( + NumberSliderConfig( + key = ConfigKey.MAX_RESULT_COUNT, + sliderMin = 1f, + sliderMax = 5f, + defaultValue = 3f, + valueType = ValueType.INT + ), BooleanSwitchConfig( + key = ConfigKey.USE_GPU, + defaultValue = false, + ), SegmentedButtonConfig( + key = ConfigKey.THEME, + defaultValue = "Auto", + options = listOf("Auto", "Light", "Dark") + ) +) + +val MODEL_TEST1: Model = Model( + name = "deterministic3", + downloadFileName = "deterministric3.json", + url = "https://storage.googleapis.com/tfweb/model-graph-vis-v2-test-models/deterministic3.json", + sizeInBytes = 40146048L, + configs = TEST_CONFIGS1, +) + +val MODEL_TEST2: Model = Model( + name = "isnet", + downloadFileName = "isnet.tflite", + url = "https://storage.googleapis.com/tfweb/model-graph-vis-v2-test-models/isnet-general-use-int8.tflite", + sizeInBytes = 44366296L, + configs = TEST_CONFIGS1, +) + +val MODEL_TEST3: Model = Model( + name = "yolo", + downloadFileName = "yolo.json", + url = "https://storage.googleapis.com/tfweb/model-graph-vis-v2-test-models/yolo.json", + sizeInBytes = 40641364L +) + +val MODEL_TEST4: Model = Model( + name = "mobilenet v3", + downloadFileName = "mobilenet_v3_large.pt2", + url = "https://storage.googleapis.com/tfweb/model-graph-vis-v2-test-models/mobilenet_v3_large.pt2", + sizeInBytes = 277135998L +) + +val TASK_TEST1 = Task( + type = TaskType.TEST_TASK_1, + icon = Icons.Rounded.AutoAwesome, + models = mutableListOf(MODEL_TEST1, MODEL_TEST2), + description = "This is a test task (1)" +) + +val TASK_TEST2 = Task( + type = TaskType.TEST_TASK_2, + icon = Icons.Rounded.AccountBox, + models = mutableListOf(MODEL_TEST3, MODEL_TEST4), + description = "This is a test task (2)" +) + +val ALL_PREVIEW_TASKS: List = listOf( + TASK_TEST1, + TASK_TEST2, +) \ No newline at end of file diff --git a/Android/src/app/src/main/java/com/google/aiedge/gallery/ui/textclassification/TextClassificationModelHelper.kt b/Android/src/app/src/main/java/com/google/aiedge/gallery/ui/textclassification/TextClassificationModelHelper.kt new file mode 100644 index 0000000..91d5a81 --- /dev/null +++ b/Android/src/app/src/main/java/com/google/aiedge/gallery/ui/textclassification/TextClassificationModelHelper.kt @@ -0,0 +1,95 @@ +/* + * Copyright 2025 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.google.aiedge.gallery.ui.textclassification + +import android.content.Context +import android.util.Log +import com.google.mediapipe.tasks.components.containers.Category +import com.google.mediapipe.tasks.core.BaseOptions +import com.google.mediapipe.tasks.text.textclassifier.TextClassifier +import com.google.aiedge.gallery.data.Model +import com.google.aiedge.gallery.ui.common.LatencyProvider +import java.io.File +import java.io.FileInputStream +import java.nio.ByteBuffer +import java.nio.channels.FileChannel + +private const val TAG = "AGTextClassificationModelHelper" + +class TextClassificationInferenceResult( + val categories: List, override val latencyMs: Float +) : LatencyProvider + +// TODO: handle error. + +/** + * Helper object for managing text classification models. + */ +object TextClassificationModelHelper { + fun initialize(context: Context, model: Model, onDone: () -> Unit) { + val modelByteBuffer = readFileToByteBuffer(File(model.getPath(context = context))) + if (modelByteBuffer != null) { + val options = TextClassifier.TextClassifierOptions.builder().setBaseOptions( + BaseOptions.builder().setModelAssetBuffer(modelByteBuffer).build() + ).build() + model.instance = TextClassifier.createFromOptions(context, options) + onDone() + } + } + + fun runInference(model: Model, input: String): TextClassificationInferenceResult { + val instance = model.instance + val start = System.currentTimeMillis() + val classifier: TextClassifier = instance as TextClassifier + val result = classifier.classify(input) + val categories = result.classificationResult().classifications().first().categories() + val latencyMs = (System.currentTimeMillis() - start).toFloat() + return TextClassificationInferenceResult(categories = categories, latencyMs = latencyMs) + } + + fun cleanUp(model: Model) { + if (model.instance == null) { + return + } + val instance = model.instance as TextClassifier + + try { + instance.close() + } catch (e: Exception) { + // ignore + } + + model.instance = null + Log.d(TAG, "Clean up done.") + } + + + private fun readFileToByteBuffer(file: File): ByteBuffer? { + return try { + val fileInputStream = FileInputStream(file) + val fileChannel: FileChannel = fileInputStream.channel + val byteBuffer = ByteBuffer.allocateDirect(fileChannel.size().toInt()) + fileChannel.read(byteBuffer) + byteBuffer.rewind() + fileInputStream.close() + byteBuffer + } catch (e: Exception) { + e.printStackTrace() + null + } + } +} \ No newline at end of file diff --git a/Android/src/app/src/main/java/com/google/aiedge/gallery/ui/textclassification/TextClassificationScreen.kt b/Android/src/app/src/main/java/com/google/aiedge/gallery/ui/textclassification/TextClassificationScreen.kt new file mode 100644 index 0000000..7dab53c --- /dev/null +++ b/Android/src/app/src/main/java/com/google/aiedge/gallery/ui/textclassification/TextClassificationScreen.kt @@ -0,0 +1,74 @@ +/* + * Copyright 2025 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.google.aiedge.gallery.ui.textclassification + +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.lifecycle.viewmodel.compose.viewModel +import com.google.aiedge.gallery.ui.ViewModelProvider +import com.google.aiedge.gallery.ui.common.chat.ChatMessageText +import com.google.aiedge.gallery.ui.common.chat.ChatView +import com.google.aiedge.gallery.ui.modelmanager.ModelManagerViewModel +import kotlinx.serialization.Serializable + +/** Navigation destination data */ +object TextClassificationDestination { + @Serializable + val route = "TextClassificationRoute" +} + +@Composable +fun TextClassificationScreen( + modelManagerViewModel: ModelManagerViewModel, + navigateUp: () -> Unit, + modifier: Modifier = Modifier, + viewModel: TextClassificationViewModel = viewModel( + factory = ViewModelProvider.Factory + ), +) { + ChatView( + task = viewModel.task, + viewModel = viewModel, + modelManagerViewModel = modelManagerViewModel, + onSendMessage = { model, message -> + viewModel.addMessage( + model = model, + message = message, + ) + if (message is ChatMessageText) { + modelManagerViewModel.addTextInputHistory(message.content) + viewModel.generateResponse( + model = model, + input = message.content, + ) + } + }, + onRunAgainClicked = { model, message -> + viewModel.runAgain(model = model, message = message) + }, + onBenchmarkClicked = { model, message, warmUpIterations, benchmarkIterations -> + viewModel.benchmark( + model = model, + message = message, + warmupCount = warmUpIterations, + itertations = benchmarkIterations, + ) + }, + navigateUp = navigateUp, + modifier = modifier + ) +} diff --git a/Android/src/app/src/main/java/com/google/aiedge/gallery/ui/textclassification/TextClassificationViewModel.kt b/Android/src/app/src/main/java/com/google/aiedge/gallery/ui/textclassification/TextClassificationViewModel.kt new file mode 100644 index 0000000..41684bf --- /dev/null +++ b/Android/src/app/src/main/java/com/google/aiedge/gallery/ui/textclassification/TextClassificationViewModel.kt @@ -0,0 +1,128 @@ +/* + * Copyright 2025 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.google.aiedge.gallery.ui.textclassification + +import android.util.Log +import androidx.compose.ui.graphics.Color +import androidx.lifecycle.viewModelScope +import com.google.mediapipe.tasks.components.containers.Category +import com.google.aiedge.gallery.data.Model +import com.google.aiedge.gallery.data.TASK_TEXT_CLASSIFICATION +import com.google.aiedge.gallery.ui.common.chat.ChatMessage +import com.google.aiedge.gallery.ui.common.chat.ChatMessageClassification +import com.google.aiedge.gallery.ui.common.chat.ChatMessageText +import com.google.aiedge.gallery.ui.common.chat.ChatMessageType +import com.google.aiedge.gallery.ui.common.chat.ChatViewModel +import com.google.aiedge.gallery.ui.common.chat.Classification +import com.google.aiedge.gallery.ui.common.getDistinctiveColor +import com.google.aiedge.gallery.ui.common.runBasicBenchmark +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.delay +import kotlinx.coroutines.launch + +private const val TAG = "AGTextClassificationViewModel" + +class TextClassificationViewModel : ChatViewModel(task = TASK_TEXT_CLASSIFICATION) { + fun generateResponse(model: Model, input: String) { + viewModelScope.launch(Dispatchers.Default) { + // Wait for model to be initialized. + while (model.instance == null) { + delay(100) + } + + val result = TextClassificationModelHelper.runInference(model = model, input = input) + Log.d(TAG, "$result") + + addMessage( + model = model, + message = generateClassificationMessage(result = result), + ) + } + } + + fun runAgain(model: Model, message: ChatMessage) { + viewModelScope.launch(Dispatchers.Default) { + // Wait for model to be initialized. + while (model.instance == null) { + delay(100) + } + + if (message is ChatMessageText) { + // Clone the clicked message and add it. + addMessage(model = model, message = message.clone()) + + // Run inference. + val result = + TextClassificationModelHelper.runInference(model = model, input = message.content) + + // Add response message. + val newMessage = generateClassificationMessage(result = result) + addMessage( + model = model, + message = newMessage, + ) + } + } + } + + fun benchmark( + model: Model, message: ChatMessage, warmupCount: Int, itertations: Int + ) { + viewModelScope.launch(Dispatchers.Default) { + // Wait for model to be initialized. + while (model.instance == null) { + delay(100) + } + + if (message is ChatMessageText) { + setInProgress(true) + runBasicBenchmark( + model = model, + warmupCount = warmupCount, + iterations = itertations, + chatViewModel = this@TextClassificationViewModel, + inferenceFn = { + TextClassificationModelHelper.runInference(model = model, input = message.content) + }, + chatMessageType = ChatMessageType.BENCHMARK_RESULT, + ) + setInProgress(false) + } + } + } + + private fun generateClassificationMessage(result: TextClassificationInferenceResult): ChatMessageClassification { + return ChatMessageClassification(classifications = result.categories.mapIndexed { index, category -> + val color = when (category.categoryName().lowercase()) { + "negative", "0" -> Color(0xffe6194B) + "positive", "1" -> Color(0xff3cb44b) + else -> getDistinctiveColor(index) + } + category.toClassification(color = color) + }.sortedBy { it.label }, latencyMs = result.latencyMs) + } +} + +fun Category.toClassification(color: Color): Classification { + var categoryName = this.categoryName() + if (categoryName == "0") { + categoryName = "negative" + } else if (categoryName == "1") { + categoryName = "positive" + } + return Classification(label = categoryName, score = this.score(), color = color) +} diff --git a/Android/src/app/src/main/java/com/google/aiedge/gallery/ui/theme/Color.kt b/Android/src/app/src/main/java/com/google/aiedge/gallery/ui/theme/Color.kt new file mode 100644 index 0000000..4652713 --- /dev/null +++ b/Android/src/app/src/main/java/com/google/aiedge/gallery/ui/theme/Color.kt @@ -0,0 +1,92 @@ +/* + * Copyright 2025 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.google.aiedge.gallery.ui.theme + +import androidx.compose.ui.graphics.Color + +//val primaryLight = Color(0xFF32628D) +val primaryLight = Color(0xFF1F1F1F) +val onPrimaryLight = Color(0xFFFFFFFF) +val primaryContainerLight = Color(0xFFD0E4FF) +val onPrimaryContainerLight = Color(0xFF144A74) +val secondaryLight = Color(0xFF526070) +val onSecondaryLight = Color(0xFFFFFFFF) +val secondaryContainerLight = Color(0xFFD6E4F7) +val onSecondaryContainerLight = Color(0xFF3B4857) +val tertiaryLight = Color(0xFF775A0B) +val onTertiaryLight = Color(0xFFFFFFFF) +val tertiaryContainerLight = Color(0xFFFFDF9B) +val onTertiaryContainerLight = Color(0xFF5B4300) +val errorLight = Color(0xFF904A43) +val onErrorLight = Color(0xFFFFFFFF) +val errorContainerLight = Color(0xFFFFDAD5) +val onErrorContainerLight = Color(0xFF73342D) +val backgroundLight = Color(0xFFF8F9FF) +val onBackgroundLight = Color(0xFF191C20) +val surfaceLight = Color(0xFFF8F9FF) +val onSurfaceLight = Color(0xFF191C20) +val surfaceVariantLight = Color(0xFFDEE3EB) +val onSurfaceVariantLight = Color(0xFF42474E) +val outlineLight = Color(0xFF73777F) +val outlineVariantLight = Color(0xFFC2C7CF) +val scrimLight = Color(0xFF000000) +val inverseSurfaceLight = Color(0xFF2D3135) +val inverseOnSurfaceLight = Color(0xFFEFF1F6) +val inversePrimaryLight = Color(0xFF9DCAFC) +val surfaceDimLight = Color(0xFFD8DAE0) +val surfaceBrightLight = Color(0xFFF8F9FF) +val surfaceContainerLowestLight = Color(0xFFFFFFFF) +val surfaceContainerLowLight = Color(0xFFF2F3F9) +val surfaceContainerLight = Color(0xFFECEEF4) +val surfaceContainerHighLight = Color(0xFFE6E8EE) +val surfaceContainerHighestLight = Color(0xFFE0E2E8) + +val primaryDark = Color(0xFF9DCAFC) +val onPrimaryDark = Color(0xFF003355) +val primaryContainerDark = Color(0xFF144A74) +val onPrimaryContainerDark = Color(0xFFD0E4FF) +val secondaryDark = Color(0xFFBAC8DA) +val onSecondaryDark = Color(0xFF243240) +val secondaryContainerDark = Color(0xFF3B4857) +val onSecondaryContainerDark = Color(0xFFD6E4F7) +val tertiaryDark = Color(0xFFE8C26C) +val onTertiaryDark = Color(0xFF3F2E00) +val tertiaryContainerDark = Color(0xFF5B4300) +val onTertiaryContainerDark = Color(0xFFFFDF9B) +val errorDark = Color(0xFFFFB4AB) +val onErrorDark = Color(0xFF561E19) +val errorContainerDark = Color(0xFF73342D) +val onErrorContainerDark = Color(0xFFFFDAD5) +val backgroundDark = Color(0xFF101418) +val onBackgroundDark = Color(0xFFE0E2E8) +val surfaceDark = Color(0xFF101418) +val onSurfaceDark = Color(0xFFE0E2E8) +val surfaceVariantDark = Color(0xFF42474E) +val onSurfaceVariantDark = Color(0xFFC2C7CF) +val outlineDark = Color(0xFF8C9199) +val outlineVariantDark = Color(0xFF42474E) +val scrimDark = Color(0xFF000000) +val inverseSurfaceDark = Color(0xFFE0E2E8) +val inverseOnSurfaceDark = Color(0xFF2D3135) +val inversePrimaryDark = Color(0xFF32628D) +val surfaceDimDark = Color(0xFF101418) +val surfaceBrightDark = Color(0xFF36393E) +val surfaceContainerLowestDark = Color(0xFF0B0E12) +val surfaceContainerLowDark = Color(0xFF191C20) +val surfaceContainerDark = Color(0xFF1D2024) +val surfaceContainerHighDark = Color(0xFF272A2F) +val surfaceContainerHighestDark = Color(0xFF32353A) diff --git a/Android/src/app/src/main/java/com/google/aiedge/gallery/ui/theme/Theme.kt b/Android/src/app/src/main/java/com/google/aiedge/gallery/ui/theme/Theme.kt new file mode 100644 index 0000000..d223a8f --- /dev/null +++ b/Android/src/app/src/main/java/com/google/aiedge/gallery/ui/theme/Theme.kt @@ -0,0 +1,223 @@ +/* + * Copyright 2025 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.google.aiedge.gallery.ui.theme + +import android.app.Activity +import androidx.compose.foundation.isSystemInDarkTheme +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.darkColorScheme +import androidx.compose.material3.lightColorScheme +import androidx.compose.runtime.Composable +import androidx.compose.runtime.CompositionLocalProvider +import androidx.compose.runtime.Immutable +import androidx.compose.runtime.ReadOnlyComposable +import androidx.compose.runtime.SideEffect +import androidx.compose.runtime.staticCompositionLocalOf +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.platform.LocalView +import androidx.core.view.WindowCompat + +private val lightScheme = lightColorScheme( + primary = primaryLight, + onPrimary = onPrimaryLight, + primaryContainer = primaryContainerLight, + onPrimaryContainer = onPrimaryContainerLight, + secondary = secondaryLight, + onSecondary = onSecondaryLight, + secondaryContainer = secondaryContainerLight, + onSecondaryContainer = onSecondaryContainerLight, + tertiary = tertiaryLight, + onTertiary = onTertiaryLight, + tertiaryContainer = tertiaryContainerLight, + onTertiaryContainer = onTertiaryContainerLight, + error = errorLight, + onError = onErrorLight, + errorContainer = errorContainerLight, + onErrorContainer = onErrorContainerLight, + background = backgroundLight, + onBackground = onBackgroundLight, + surface = surfaceLight, + onSurface = onSurfaceLight, + surfaceVariant = surfaceVariantLight, + onSurfaceVariant = onSurfaceVariantLight, + outline = outlineLight, + outlineVariant = outlineVariantLight, + scrim = scrimLight, + inverseSurface = inverseSurfaceLight, + inverseOnSurface = inverseOnSurfaceLight, + inversePrimary = inversePrimaryLight, + surfaceDim = surfaceDimLight, + surfaceBright = surfaceBrightLight, + surfaceContainerLowest = surfaceContainerLowestLight, + surfaceContainerLow = surfaceContainerLowLight, + surfaceContainer = surfaceContainerLight, + surfaceContainerHigh = surfaceContainerHighLight, + surfaceContainerHighest = surfaceContainerHighestLight, +) + +private val darkScheme = darkColorScheme( + primary = primaryDark, + onPrimary = onPrimaryDark, + primaryContainer = primaryContainerDark, + onPrimaryContainer = onPrimaryContainerDark, + secondary = secondaryDark, + onSecondary = onSecondaryDark, + secondaryContainer = secondaryContainerDark, + onSecondaryContainer = onSecondaryContainerDark, + tertiary = tertiaryDark, + onTertiary = onTertiaryDark, + tertiaryContainer = tertiaryContainerDark, + onTertiaryContainer = onTertiaryContainerDark, + error = errorDark, + onError = onErrorDark, + errorContainer = errorContainerDark, + onErrorContainer = onErrorContainerDark, + background = backgroundDark, + onBackground = onBackgroundDark, + surface = surfaceDark, + onSurface = onSurfaceDark, + surfaceVariant = surfaceVariantDark, + onSurfaceVariant = onSurfaceVariantDark, + outline = outlineDark, + outlineVariant = outlineVariantDark, + scrim = scrimDark, + inverseSurface = inverseSurfaceDark, + inverseOnSurface = inverseOnSurfaceDark, + inversePrimary = inversePrimaryDark, + surfaceDim = surfaceDimDark, + surfaceBright = surfaceBrightDark, + surfaceContainerLowest = surfaceContainerLowestDark, + surfaceContainerLow = surfaceContainerLowDark, + surfaceContainer = surfaceContainerDark, + surfaceContainerHigh = surfaceContainerHighDark, + surfaceContainerHighest = surfaceContainerHighestDark, +) + +@Immutable +data class CustomColors( + val taskBgColors: List = listOf(), + val taskIconColors: List = listOf(), + val taskIconShapeBgColor: Color = Color.Transparent, + val homeBottomGradient: List = listOf(), + val userBubbleBgColor: Color = Color.Transparent, + val agentBubbleBgColor: Color = Color.Transparent, +) + +val LocalCustomColors = staticCompositionLocalOf { CustomColors() } + +val lightCustomColors = CustomColors( + taskBgColors = listOf( + // yellow + Color(0xFFFFEFC9), + // red + Color(0xFFFFEDE6), + // green + Color(0xFFE1F6DE), + // blue + Color(0xFFEDF0FF) + ), + taskIconColors = listOf( + Color(0xFFE37400), + Color(0xFFD93025), + Color(0xFF34A853), + Color(0xFF1967D2), + ), + taskIconShapeBgColor = Color.White, + homeBottomGradient = listOf( + Color(0x00F8F9FF), + Color(0xffFFEFC9) + ), + agentBubbleBgColor = Color(0xFFe9eef6), + userBubbleBgColor = Color(0xFF32628D), +) + +val darkCustomColors = CustomColors( + taskBgColors = listOf( + // yellow + Color(0xFF33302A), + // red + Color(0xFF362F2D), + // green + Color(0xFF2E312D), + // blue + Color(0xFF303033) + ), + taskIconColors = listOf( + Color(0xFFFFB955), + Color(0xFFFFB4AB), + Color(0xFF6DD58C), + Color(0xFFAAC7FF), + ), + taskIconShapeBgColor = Color(0xFF202124), + homeBottomGradient = listOf( + Color(0x00F8F9FF), + Color(0x1AF6AD01) + ), + agentBubbleBgColor = Color(0xFF1b1c1d), + userBubbleBgColor = Color(0xFF1f3760), +) + +val MaterialTheme.customColors: CustomColors + @Composable + @ReadOnlyComposable + get() = LocalCustomColors.current + +/** + * Controls the color of the phone's status bar icons based on whether the app is using a dark + * theme. + */ +@Composable +fun StatusBarColorController(useDarkTheme: Boolean) { + val view = LocalView.current + val currentWindow = (view.context as? Activity)?.window + + if (currentWindow != null) { + SideEffect { + WindowCompat.setDecorFitsSystemWindows(currentWindow, false) + val controller = WindowCompat.getInsetsController(currentWindow, view) + controller.isAppearanceLightStatusBars = !useDarkTheme // Set to true for light icons + } + } +} + +@Composable +fun GalleryTheme( + content: @Composable () -> Unit +) { + val themeOverride = ThemeSettings.themeOverride + val darkTheme: Boolean = isSystemInDarkTheme() || themeOverride.value == THEME_DARK + + StatusBarColorController(useDarkTheme = darkTheme) + + val colorScheme = when { + darkTheme -> darkScheme + else -> lightScheme + } + + val customColorsPalette = if (darkTheme) darkCustomColors else lightCustomColors + + CompositionLocalProvider( + LocalCustomColors provides customColorsPalette + ) { + MaterialTheme( + colorScheme = colorScheme, + typography = AppTypography, + content = content + ) + } +} + diff --git a/Android/src/app/src/main/java/com/google/aiedge/gallery/ui/theme/ThemeSettings.kt b/Android/src/app/src/main/java/com/google/aiedge/gallery/ui/theme/ThemeSettings.kt new file mode 100644 index 0000000..333ce41 --- /dev/null +++ b/Android/src/app/src/main/java/com/google/aiedge/gallery/ui/theme/ThemeSettings.kt @@ -0,0 +1,27 @@ +/* + * Copyright 2025 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.google.aiedge.gallery.ui.theme + +import androidx.compose.runtime.mutableStateOf + +const val THEME_AUTO = "Auto" +const val THEME_LIGHT = "Light" +const val THEME_DARK = "Dark" + +object ThemeSettings { + val themeOverride = mutableStateOf("") +} diff --git a/Android/src/app/src/main/java/com/google/aiedge/gallery/ui/theme/Type.kt b/Android/src/app/src/main/java/com/google/aiedge/gallery/ui/theme/Type.kt new file mode 100644 index 0000000..8630f05 --- /dev/null +++ b/Android/src/app/src/main/java/com/google/aiedge/gallery/ui/theme/Type.kt @@ -0,0 +1,91 @@ +/* + * Copyright 2025 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.google.aiedge.gallery.ui.theme + +import androidx.compose.material3.Typography +import androidx.compose.ui.text.font.Font +import androidx.compose.ui.text.font.FontFamily +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.sp +import com.google.aiedge.gallery.R + +val nunitoFontFamily = FontFamily( + Font(R.font.nunito_regular, FontWeight.Normal), + Font(R.font.nunito_extralight, FontWeight.ExtraLight), + Font(R.font.nunito_light, FontWeight.Light), + Font(R.font.nunito_medium, FontWeight.Medium), + Font(R.font.nunito_semibold, FontWeight.SemiBold), + Font(R.font.nunito_bold, FontWeight.Bold), + Font(R.font.nunito_extrabold, FontWeight.ExtraBold), + Font(R.font.nunito_black, FontWeight.Black), +) + +val baseline = Typography() + +val AppTypography = Typography( + displayLarge = baseline.displayLarge.copy(fontFamily = nunitoFontFamily), + displayMedium = baseline.displayMedium.copy(fontFamily = nunitoFontFamily), + displaySmall = baseline.displaySmall.copy(fontFamily = nunitoFontFamily), + headlineLarge = baseline.headlineLarge.copy(fontFamily = nunitoFontFamily), + headlineMedium = baseline.headlineMedium.copy(fontFamily = nunitoFontFamily), + headlineSmall = baseline.headlineSmall.copy(fontFamily = nunitoFontFamily), + titleLarge = baseline.titleLarge.copy(fontFamily = nunitoFontFamily), + titleMedium = baseline.titleMedium.copy(fontFamily = nunitoFontFamily), + titleSmall = baseline.titleSmall.copy(fontFamily = nunitoFontFamily), + bodyLarge = baseline.bodyLarge.copy(fontFamily = nunitoFontFamily), + bodyMedium = baseline.bodyMedium.copy(fontFamily = nunitoFontFamily), + bodySmall = baseline.bodySmall.copy(fontFamily = nunitoFontFamily), + labelLarge = baseline.labelLarge.copy(fontFamily = nunitoFontFamily), + labelMedium = baseline.labelMedium.copy(fontFamily = nunitoFontFamily), + labelSmall = baseline.labelSmall.copy(fontFamily = nunitoFontFamily), +) + +val titleMediumNarrow = + baseline.titleMedium.copy(fontFamily = nunitoFontFamily, letterSpacing = 0.0.sp) + +val titleSmaller = baseline.titleSmall.copy( + fontFamily = nunitoFontFamily, + fontSize = 12.sp, + fontWeight = FontWeight.Bold +) + +val labelSmallNarrow = + baseline.labelSmall.copy(fontFamily = nunitoFontFamily, letterSpacing = 0.0.sp) + +val labelSmallNarrowMedium = + baseline.labelSmall.copy( + fontFamily = nunitoFontFamily, + fontWeight = FontWeight.Medium, + letterSpacing = 0.0.sp + ) + +val bodySmallNarrow = + baseline.bodySmall.copy(fontFamily = nunitoFontFamily, letterSpacing = 0.0.sp) + +val bodySmallSemiBold = + baseline.bodySmall.copy(fontFamily = nunitoFontFamily, fontWeight = FontWeight.SemiBold) + +val bodySmallMediumNarrow = + baseline.bodySmall.copy(fontFamily = nunitoFontFamily, letterSpacing = 0.0.sp, fontSize = 14.sp) + +val bodySmallMediumNarrowBold = + baseline.bodySmall.copy( + fontFamily = nunitoFontFamily, + letterSpacing = 0.0.sp, + fontSize = 14.sp, + fontWeight = FontWeight.Bold + ) diff --git a/Android/src/app/src/main/java/com/google/aiedge/gallery/worker/DownloadWorker.kt b/Android/src/app/src/main/java/com/google/aiedge/gallery/worker/DownloadWorker.kt new file mode 100644 index 0000000..355c41e --- /dev/null +++ b/Android/src/app/src/main/java/com/google/aiedge/gallery/worker/DownloadWorker.kt @@ -0,0 +1,243 @@ +/* + * Copyright 2025 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.google.aiedge.gallery.worker + +import android.content.Context +import android.util.Log +import androidx.work.CoroutineWorker +import androidx.work.Data +import androidx.work.WorkerParameters +import com.google.aiedge.gallery.data.KEY_MODEL_DOWNLOAD_ACCESS_TOKEN +import com.google.aiedge.gallery.data.KEY_MODEL_DOWNLOAD_ERROR_MESSAGE +import com.google.aiedge.gallery.data.KEY_MODEL_DOWNLOAD_FILE_NAME +import com.google.aiedge.gallery.data.KEY_MODEL_DOWNLOAD_RATE +import com.google.aiedge.gallery.data.KEY_MODEL_DOWNLOAD_RECEIVED_BYTES +import com.google.aiedge.gallery.data.KEY_MODEL_DOWNLOAD_REMAINING_MS +import com.google.aiedge.gallery.data.KEY_MODEL_EXTRA_DATA_DOWNLOAD_FILE_NAMES +import com.google.aiedge.gallery.data.KEY_MODEL_EXTRA_DATA_URLS +import com.google.aiedge.gallery.data.KEY_MODEL_IS_ZIP +import com.google.aiedge.gallery.data.KEY_MODEL_START_UNZIPPING +import com.google.aiedge.gallery.data.KEY_MODEL_TOTAL_BYTES +import com.google.aiedge.gallery.data.KEY_MODEL_UNZIPPED_DIR +import com.google.aiedge.gallery.data.KEY_MODEL_URL +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext +import java.io.BufferedInputStream +import java.io.File +import java.io.FileInputStream +import java.io.FileOutputStream +import java.io.IOException +import java.net.HttpURLConnection +import java.net.URL +import java.util.zip.ZipEntry +import java.util.zip.ZipInputStream + +private const val TAG = "AGDownloadWorker" + +data class UrlAndFileName(val url: String, val fileName: String) + +class DownloadWorker(context: Context, params: WorkerParameters) : + CoroutineWorker(context, params) { + private val externalFilesDir = context.getExternalFilesDir(null) + + override suspend fun doWork(): Result { + val fileUrl = inputData.getString(KEY_MODEL_URL) + val fileName = inputData.getString(KEY_MODEL_DOWNLOAD_FILE_NAME) + val isZip = inputData.getBoolean(KEY_MODEL_IS_ZIP, false) + val unzippedDir = inputData.getString(KEY_MODEL_UNZIPPED_DIR) + val extraDataFileUrls = inputData.getString(KEY_MODEL_EXTRA_DATA_URLS)?.split(",") ?: listOf() + val extraDataFileNames = + inputData.getString(KEY_MODEL_EXTRA_DATA_DOWNLOAD_FILE_NAMES)?.split(",") ?: listOf() + val totalBytes = inputData.getLong(KEY_MODEL_TOTAL_BYTES, 0L) + val accessToken = inputData.getString(KEY_MODEL_DOWNLOAD_ACCESS_TOKEN) + + return withContext(Dispatchers.IO) { + if (fileUrl == null || fileName == null) { + Result.failure() + } else { + return@withContext try { + // Collect data for all files. + val allFiles: MutableList = mutableListOf() + allFiles.add(UrlAndFileName(url = fileUrl, fileName = fileName)) + for (index in extraDataFileUrls.indices) { + allFiles.add( + UrlAndFileName( + url = extraDataFileUrls[index], fileName = extraDataFileNames[index] + ) + ) + } + Log.d(TAG, "About to download: $allFiles") + + // Download them in sequence. + // TODO: maybe consider downloading them in parallel. + var downloadedBytes = 0L + val bytesReadSizeBuffer: MutableList = mutableListOf() + val bytesReadLatencyBuffer: MutableList = mutableListOf() + for (file in allFiles) { + val url = URL(file.url) + + val connection = url.openConnection() as HttpURLConnection + if (accessToken != null) { + connection.setRequestProperty("Authorization", "Bearer $accessToken") + } + + // Read the file and see if it is partially downloaded. + val outputFile = File(applicationContext.getExternalFilesDir(null), file.fileName) + val outputFileBytes = outputFile.length() + if (outputFileBytes > 0) { + Log.d( + TAG, + "File '${file.fileName}' partial size: ${outputFileBytes}. Trying to resume download" + ) + connection.setRequestProperty( + "Range", "bytes=${outputFileBytes}-" + ) + } + connection.connect() + Log.d(TAG, "response code: ${connection.responseCode}") + + if (connection.responseCode == HttpURLConnection.HTTP_OK || connection.responseCode == HttpURLConnection.HTTP_PARTIAL) { + val contentRange = connection.getHeaderField("Content-Range") + + if (contentRange != null) { + // Parse the Content-Range header + val rangeParts = contentRange.substringAfter("bytes ").split("/") + val byteRange = rangeParts[0].split("-") + val startByte = byteRange[0].toLong() + val endByte = byteRange[1].toLong() + + Log.d( + TAG, + "Content-Range: $contentRange. Start bytes: ${startByte}, end bytes: $endByte" + ) + + downloadedBytes += startByte + } else { + Log.d(TAG, "Download starts from beginning.") + } + } else { + throw IOException("HTTP error code: ${connection.responseCode}") + } + + val inputStream = connection.inputStream + val outputStream = FileOutputStream(outputFile, true /* append */) + + val buffer = ByteArray(DEFAULT_BUFFER_SIZE) + var bytesRead: Int + var lastSetProgressTs: Long = 0 + var deltaBytes = 0L + while (inputStream.read(buffer).also { bytesRead = it } != -1) { + outputStream.write(buffer, 0, bytesRead) + downloadedBytes += bytesRead + deltaBytes += bytesRead + + // Report progress every 200 ms. + val curTs = System.currentTimeMillis() + if (curTs - lastSetProgressTs > 200) { + // Calculate download rate. + var bytesPerMs = 0f + if (lastSetProgressTs != 0L) { + if (bytesReadSizeBuffer.size == 5) { + bytesReadSizeBuffer.removeAt(bytesReadLatencyBuffer.lastIndex) + } + bytesReadSizeBuffer.add(deltaBytes) + if (bytesReadLatencyBuffer.size == 5) { + bytesReadLatencyBuffer.removeAt(bytesReadLatencyBuffer.lastIndex) + } + bytesReadLatencyBuffer.add(curTs - lastSetProgressTs) + deltaBytes = 0L + bytesPerMs = bytesReadSizeBuffer.sum().toFloat() / bytesReadLatencyBuffer.sum() + } + + // Calculate remaining seconds + var remainingMs = 0f + if (bytesPerMs > 0f && totalBytes > 0L) { + remainingMs = (totalBytes - downloadedBytes) / bytesPerMs + } + + setProgress( + Data.Builder().putLong( + KEY_MODEL_DOWNLOAD_RECEIVED_BYTES, downloadedBytes + ).putLong(KEY_MODEL_DOWNLOAD_RATE, (bytesPerMs * 1000).toLong()).putLong( + KEY_MODEL_DOWNLOAD_REMAINING_MS, remainingMs.toLong() + ).build() + ) + lastSetProgressTs = curTs + } + } + + outputStream.close() + inputStream.close() + + Log.d(TAG, "Download done") + + // Unzip if the downloaded file is a zip. + if (isZip && unzippedDir != null) { + setProgress(Data.Builder().putBoolean(KEY_MODEL_START_UNZIPPING, true).build()) + + // Prepare target dir. + val destDir = File("${externalFilesDir}${File.separator}${unzippedDir}") + if (!destDir.exists()) { + destDir.mkdirs() + } + + // Unzip. + val unzipBuffer = ByteArray(4096) + val zipFilePath = "${externalFilesDir}${File.separator}${fileName}" + val zipIn = ZipInputStream(BufferedInputStream(FileInputStream(zipFilePath))) + var zipEntry: ZipEntry? = zipIn.nextEntry + + while (zipEntry != null) { + val filePath = destDir.absolutePath + File.separator + zipEntry.name + + // Extract files. + if (!zipEntry.isDirectory) { + // extract file + val bos = FileOutputStream(filePath) + bos.use { curBos -> + var len: Int + while (zipIn.read(unzipBuffer).also { len = it } > 0) { + curBos.write(unzipBuffer, 0, len) + } + } + } + // Create dir. + else { + val dir = File(filePath) + dir.mkdirs() + } + + zipIn.closeEntry() + zipEntry = zipIn.nextEntry + } + zipIn.close() + + // Delete the original file. + val zipFile = File(zipFilePath) + zipFile.delete() + } + } + Result.success() + } catch (e: IOException) { + Result.failure( + Data.Builder().putString(KEY_MODEL_DOWNLOAD_ERROR_MESSAGE, e.message).build() + ) + } + } + } + } +} \ No newline at end of file diff --git a/Android/src/app/src/main/res/drawable/chat_spark.xml b/Android/src/app/src/main/res/drawable/chat_spark.xml new file mode 100644 index 0000000..7489732 --- /dev/null +++ b/Android/src/app/src/main/res/drawable/chat_spark.xml @@ -0,0 +1,27 @@ + + + + + + + \ No newline at end of file diff --git a/Android/src/app/src/main/res/drawable/circle.xml b/Android/src/app/src/main/res/drawable/circle.xml new file mode 100644 index 0000000..dfa350b --- /dev/null +++ b/Android/src/app/src/main/res/drawable/circle.xml @@ -0,0 +1,26 @@ + + + + + \ No newline at end of file diff --git a/Android/src/app/src/main/res/drawable/double_circle.xml b/Android/src/app/src/main/res/drawable/double_circle.xml new file mode 100644 index 0000000..226248f --- /dev/null +++ b/Android/src/app/src/main/res/drawable/double_circle.xml @@ -0,0 +1,26 @@ + + + + + \ No newline at end of file diff --git a/Android/src/app/src/main/res/drawable/four_circle.xml b/Android/src/app/src/main/res/drawable/four_circle.xml new file mode 100644 index 0000000..1a2401b --- /dev/null +++ b/Android/src/app/src/main/res/drawable/four_circle.xml @@ -0,0 +1,26 @@ + + + + + \ No newline at end of file diff --git a/Android/src/app/src/main/res/drawable/ic_launcher_background.xml b/Android/src/app/src/main/res/drawable/ic_launcher_background.xml new file mode 100644 index 0000000..265c4b8 --- /dev/null +++ b/Android/src/app/src/main/res/drawable/ic_launcher_background.xml @@ -0,0 +1,186 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/Android/src/app/src/main/res/drawable/ic_launcher_foreground.xml b/Android/src/app/src/main/res/drawable/ic_launcher_foreground.xml new file mode 100644 index 0000000..0187e5c --- /dev/null +++ b/Android/src/app/src/main/res/drawable/ic_launcher_foreground.xml @@ -0,0 +1,46 @@ + + + + + + + + + + + + + \ No newline at end of file diff --git a/Android/src/app/src/main/res/drawable/image_spark.xml b/Android/src/app/src/main/res/drawable/image_spark.xml new file mode 100644 index 0000000..287913a --- /dev/null +++ b/Android/src/app/src/main/res/drawable/image_spark.xml @@ -0,0 +1,27 @@ + + + + + + + \ No newline at end of file diff --git a/Android/src/app/src/main/res/drawable/logo.xml b/Android/src/app/src/main/res/drawable/logo.xml new file mode 100644 index 0000000..8a04415 --- /dev/null +++ b/Android/src/app/src/main/res/drawable/logo.xml @@ -0,0 +1,34 @@ + + + + + + + + \ No newline at end of file diff --git a/Android/src/app/src/main/res/drawable/pantegon.xml b/Android/src/app/src/main/res/drawable/pantegon.xml new file mode 100644 index 0000000..b53cbc6 --- /dev/null +++ b/Android/src/app/src/main/res/drawable/pantegon.xml @@ -0,0 +1,26 @@ + + + + + \ No newline at end of file diff --git a/Android/src/app/src/main/res/drawable/text_spark.xml b/Android/src/app/src/main/res/drawable/text_spark.xml new file mode 100644 index 0000000..cb0ce7d --- /dev/null +++ b/Android/src/app/src/main/res/drawable/text_spark.xml @@ -0,0 +1,27 @@ + + + + + + + \ No newline at end of file diff --git a/Android/src/app/src/main/res/font/nunito_black.ttf b/Android/src/app/src/main/res/font/nunito_black.ttf new file mode 100644 index 0000000000000000000000000000000000000000..81d557c5b26d7ddbfe53a6b275685e264a01b496 GIT binary patch literal 132032 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