From d4494691168aa58382195461303476c692f5da8e Mon Sep 17 00:00:00 2001 From: kiyo561 Date: Mon, 10 Aug 2026 08:25:56 +0000 Subject: [PATCH] Initial commit: Voice controlled device application, specs and Gitea Actions workflow --- .gitattributes | 6 + .gitignore | 7 + Dockerfile | 47 + Makefile | 20 + README.md | 115 +++ app/build.gradle.kts | 76 ++ app/src/main/AndroidManifest.xml | 52 + .../java/com/example/voiceapp/MainActivity.kt | 24 + .../com/example/voiceapp/RunAppSimulation.kt | 130 +++ .../com/example/voiceapp/VoiceApplication.kt | 7 + .../preferences/UserPreferencesRepository.kt | 25 + .../com/example/voiceapp/di/DataModule.kt | 19 + .../com/example/voiceapp/di/DomainModule.kt | 38 + .../com/example/voiceapp/di/VoiceModule.kt | 25 + .../voiceapp/domain/CommandDefinition.kt | 10 + .../example/voiceapp/domain/CommandParser.kt | 28 + .../voiceapp/domain/ExecuteCommandUseCase.kt | 11 + .../converter/KanjiToNumberConverter.kt | 43 + .../domain/executor/ChangeSettingsExecutor.kt | 28 + .../domain/executor/GetSystemInfoExecutor.kt | 39 + .../domain/executor/GetTimeDateExecutor.kt | 23 + .../domain/executor/ICommandExecutor.kt | 8 + .../domain/executor/ReadMessagesExecutor.kt | 21 + .../domain/executor/SetAlarmTimerExecutor.kt | 24 + .../executor/UnknownFallbackExecutor.kt | 11 + .../voiceapp/domain/model/CommandId.kt | 10 + .../domain/model/CommandMatchResult.kt | 12 + .../voiceapp/domain/model/CommandResult.kt | 8 + .../com/example/voiceapp/ui/ChatMessage.kt | 14 + .../com/example/voiceapp/ui/MainViewModel.kt | 106 ++ .../com/example/voiceapp/ui/VoiceAppState.kt | 9 + .../voiceapp/ui/components/ControlBar.kt | 15 + .../ui/components/ConversationHistory.kt | 17 + .../voiceapp/ui/components/MainScreen.kt | 32 + .../ui/components/PermissionDialog.kt | 15 + .../voiceapp/ui/components/StatusBanner.kt | 37 + .../ui/components/WaveformIndicator.kt | 18 + .../com/example/voiceapp/ui/theme/Color.kt | 9 + .../com/example/voiceapp/ui/theme/Theme.kt | 10 + .../com/example/voiceapp/ui/theme/Type.kt | 5 + .../example/voiceapp/voice/ITextToSpeech.kt | 11 + .../voiceapp/voice/IVoiceRecognizer.kt | 12 + .../voiceapp/voice/TextToSpeechManager.kt | 52 + .../com/example/voiceapp/voice/TtsStatus.kt | 8 + .../voice/VoiceNotificationListenerService.kt | 24 + .../voiceapp/voice/VoiceRecognitionManager.kt | 70 ++ .../voiceapp/voice/VoiceRecognitionState.kt | 9 + app/src/main/res/values/colors.xml | 6 + app/src/main/res/values/strings.xml | 8 + app/src/main/res/values/themes.xml | 6 + .../voiceapp/domain/CommandParserTest.kt | 41 + .../domain/ExecuteCommandUseCaseTest.kt | 30 + .../domain/KanjiToNumberConverterTest.kt | 16 + .../example/voiceapp/ui/MainViewModelTest.kt | 68 ++ build.gradle.kts | 5 + docker-compose.yml | 51 + docs/00_project_config.md | 60 ++ docs/02_specifications.md | 277 +++++ docs/03_hardware_design.md | 957 ++++++++++++++++++ docs/04_review_report.md | 104 ++ gradle.properties | 8 + gradle/wrapper/gradle-wrapper.jar | Bin 0 -> 43462 bytes gradle/wrapper/gradle-wrapper.properties | 7 + gradlew | 48 + gradlew.bat | 81 ++ local.properties | 1 + run.bat | 44 + run.sh | 34 + settings.gradle.kts | 17 + 69 files changed, 3209 insertions(+) create mode 100644 .gitattributes create mode 100644 .gitignore create mode 100644 Dockerfile create mode 100644 Makefile create mode 100644 README.md create mode 100644 app/build.gradle.kts create mode 100644 app/src/main/AndroidManifest.xml create mode 100644 app/src/main/java/com/example/voiceapp/MainActivity.kt create mode 100644 app/src/main/java/com/example/voiceapp/RunAppSimulation.kt create mode 100644 app/src/main/java/com/example/voiceapp/VoiceApplication.kt create mode 100644 app/src/main/java/com/example/voiceapp/data/preferences/UserPreferencesRepository.kt create mode 100644 app/src/main/java/com/example/voiceapp/di/DataModule.kt create mode 100644 app/src/main/java/com/example/voiceapp/di/DomainModule.kt create mode 100644 app/src/main/java/com/example/voiceapp/di/VoiceModule.kt create mode 100644 app/src/main/java/com/example/voiceapp/domain/CommandDefinition.kt create mode 100644 app/src/main/java/com/example/voiceapp/domain/CommandParser.kt create mode 100644 app/src/main/java/com/example/voiceapp/domain/ExecuteCommandUseCase.kt create mode 100644 app/src/main/java/com/example/voiceapp/domain/converter/KanjiToNumberConverter.kt create mode 100644 app/src/main/java/com/example/voiceapp/domain/executor/ChangeSettingsExecutor.kt create mode 100644 app/src/main/java/com/example/voiceapp/domain/executor/GetSystemInfoExecutor.kt create mode 100644 app/src/main/java/com/example/voiceapp/domain/executor/GetTimeDateExecutor.kt create mode 100644 app/src/main/java/com/example/voiceapp/domain/executor/ICommandExecutor.kt create mode 100644 app/src/main/java/com/example/voiceapp/domain/executor/ReadMessagesExecutor.kt create mode 100644 app/src/main/java/com/example/voiceapp/domain/executor/SetAlarmTimerExecutor.kt create mode 100644 app/src/main/java/com/example/voiceapp/domain/executor/UnknownFallbackExecutor.kt create mode 100644 app/src/main/java/com/example/voiceapp/domain/model/CommandId.kt create mode 100644 app/src/main/java/com/example/voiceapp/domain/model/CommandMatchResult.kt create mode 100644 app/src/main/java/com/example/voiceapp/domain/model/CommandResult.kt create mode 100644 app/src/main/java/com/example/voiceapp/ui/ChatMessage.kt create mode 100644 app/src/main/java/com/example/voiceapp/ui/MainViewModel.kt create mode 100644 app/src/main/java/com/example/voiceapp/ui/VoiceAppState.kt create mode 100644 app/src/main/java/com/example/voiceapp/ui/components/ControlBar.kt create mode 100644 app/src/main/java/com/example/voiceapp/ui/components/ConversationHistory.kt create mode 100644 app/src/main/java/com/example/voiceapp/ui/components/MainScreen.kt create mode 100644 app/src/main/java/com/example/voiceapp/ui/components/PermissionDialog.kt create mode 100644 app/src/main/java/com/example/voiceapp/ui/components/StatusBanner.kt create mode 100644 app/src/main/java/com/example/voiceapp/ui/components/WaveformIndicator.kt create mode 100644 app/src/main/java/com/example/voiceapp/ui/theme/Color.kt create mode 100644 app/src/main/java/com/example/voiceapp/ui/theme/Theme.kt create mode 100644 app/src/main/java/com/example/voiceapp/ui/theme/Type.kt create mode 100644 app/src/main/java/com/example/voiceapp/voice/ITextToSpeech.kt create mode 100644 app/src/main/java/com/example/voiceapp/voice/IVoiceRecognizer.kt create mode 100644 app/src/main/java/com/example/voiceapp/voice/TextToSpeechManager.kt create mode 100644 app/src/main/java/com/example/voiceapp/voice/TtsStatus.kt create mode 100644 app/src/main/java/com/example/voiceapp/voice/VoiceNotificationListenerService.kt create mode 100644 app/src/main/java/com/example/voiceapp/voice/VoiceRecognitionManager.kt create mode 100644 app/src/main/java/com/example/voiceapp/voice/VoiceRecognitionState.kt create mode 100644 app/src/main/res/values/colors.xml create mode 100644 app/src/main/res/values/strings.xml create mode 100644 app/src/main/res/values/themes.xml create mode 100644 app/src/test/java/com/example/voiceapp/domain/CommandParserTest.kt create mode 100644 app/src/test/java/com/example/voiceapp/domain/ExecuteCommandUseCaseTest.kt create mode 100644 app/src/test/java/com/example/voiceapp/domain/KanjiToNumberConverterTest.kt create mode 100644 app/src/test/java/com/example/voiceapp/ui/MainViewModelTest.kt create mode 100644 build.gradle.kts create mode 100644 docker-compose.yml create mode 100644 docs/00_project_config.md create mode 100644 docs/02_specifications.md create mode 100644 docs/03_hardware_design.md create mode 100644 docs/04_review_report.md create mode 100644 gradle.properties create mode 100644 gradle/wrapper/gradle-wrapper.jar create mode 100644 gradle/wrapper/gradle-wrapper.properties create mode 100755 gradlew create mode 100644 gradlew.bat create mode 100644 local.properties create mode 100644 run.bat create mode 100755 run.sh create mode 100644 settings.gradle.kts diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 0000000..d917483 --- /dev/null +++ b/.gitattributes @@ -0,0 +1,6 @@ +# Enforce LF line endings for shell scripts and gradlew on Windows +* text=auto +gradlew text eol=lf +gradlew.bat text eol=crlf +*.sh text eol=lf +*.bat text eol=crlf diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..00f0f33 --- /dev/null +++ b/.gitignore @@ -0,0 +1,7 @@ +.gradle/ +build/ +app/build/ +*.iml +.idea/ +local.properties +.DS_Store diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..fe451fe --- /dev/null +++ b/Dockerfile @@ -0,0 +1,47 @@ +FROM eclipse-temurin:17-jdk-jammy AS base + +# 必要な基本パッケージのインストールと日本語ロケールの設定 +RUN apt-get update && apt-get install -y --no-install-recommends \ + wget \ + curl \ + unzip \ + git \ + locales \ + && rm -rf /var/lib/apt/lists/* + +RUN locale-gen ja_JP.UTF-8 +ENV LANG=ja_JP.UTF-8 +ENV LANGUAGE=ja_JP:ja +ENV LC_ALL=ja_JP.UTF-8 + +# Android SDKの環境変数設定 +ENV ANDROID_HOME=/opt/android-sdk +ENV ANDROID_SDK_ROOT=/opt/android-sdk +ENV PATH=${PATH}:${ANDROID_HOME}/cmdline-tools/latest/bin:${ANDROID_HOME}/platform-tools + +# Android Command Line Tools & SDK コンポーネントのダウンロードと配置 +RUN mkdir -p ${ANDROID_HOME}/cmdline-tools && \ + wget -q https://dl.google.com/android/repository/commandlinetools-linux-11076708_latest.zip -O /tmp/cmdline-tools.zip && \ + unzip -q /tmp/cmdline-tools.zip -d ${ANDROID_HOME}/cmdline-tools && \ + mv ${ANDROID_HOME}/cmdline-tools/cmdline-tools ${ANDROID_HOME}/cmdline-tools/latest && \ + rm /tmp/cmdline-tools.zip + +# ライセンス許諾と必要パッケージの取得 +RUN yes | sdkmanager --licenses > /dev/null && \ + sdkmanager "platforms;android-34" "build-tools;34.0.0" "platform-tools" + +WORKDIR /workspace + +# Gradle設定ファイルを先にコピーして依存キャッシュを最大化 +COPY gradlew gradlew.bat ./ +COPY gradle ./gradle +COPY build.gradle.kts settings.gradle.kts gradle.properties ./ +COPY app/build.gradle.kts ./app/ + +RUN chmod +x gradlew + +# 全ソースコードのコピー +COPY . . + +# デフォルトのエントリポイントコマンド (シミュレーターのデモ実行) +CMD ["./gradlew", ":app:runSimulation"] diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..2975e0d --- /dev/null +++ b/Makefile @@ -0,0 +1,20 @@ +.PHONY: sim interactive test build help + +help: + @echo "利用可能なコマンド:" + @echo " make sim - デモシミュレーションの実行" + @echo " make interactive - 対話型音声入力シミュレータの起動" + @echo " make test - 単体テストの実行" + @echo " make build - APKのビルド" + +sim: + docker compose run --rm simulation + +interactive: + docker compose run --rm interactive + +test: + docker compose run --rm test + +build: + docker compose run --rm build diff --git a/README.md b/README.md new file mode 100644 index 0000000..6b17e12 --- /dev/null +++ b/README.md @@ -0,0 +1,115 @@ +# 📱 Android 音声操作端末アプリ & Docker動作環境 + +本リポジトリは、Android向け音声操作アプリケーションのドメインロジック、コマンド解析エンジン、音声合成(TTS)・音声認識(SpeechRecognizer)のアーキテクチャ実装、および**どのようなPC環境(Windows / macOS / Linux)でも一発でビルド・テスト・シミュレーションが実行できるDocker環境**を提供します。 + +--- + +## 🌟 主な特徴 + +- **環境構築不要のDockerフル対応**: Java/Android SDK/Gradleが未インストールのPCでも、Dockerさえあれば数秒でシミュレーションや単体テストが実行可能。 +- **アーキテクチャ設計**: Clean Architecture + MVVM (StateFlow / Kotlin Coroutines)。 +- **日本語音声コマンド解析**: 漢数字→アラビア数字変換 (`KanjiToNumberConverter`)、正規表現パターンマッチング (`CommandParser`)。 +- **動作シミュレーター (`RunAppSimulation.kt`)**: 実際のAndroid実機がなくても、ターミナル上で音声入力・解析・実行・TTS応答のフローを完全再現。 + +--- + +## 🚀 動作確認手順 (Docker環境) + +DockerおよびDocker Composeがインストールされている環境であれば、以下のコマンド一発で全機能の動作確認が可能です。 + +### 1. デモシミュレーションの実行 (推薦) +定義済みの各種音声コマンド(時刻確認、タイマー設定、アラーム、メッセージ読み上げ、設定変更等)が正常にパース・実行される様子を確認します。 + +```bash +# docker-compose を使用 +docker compose run --rm simulation + +# または付属のスクリプトを使用 (Mac/Linux) +./run.sh simulation + +# Windows (Command Prompt / PowerShell) +run.bat simulation +``` + +### 2. 対話型シミュレーションモード (CLI) +任意の日本語テキストキーボード入力し、リアルタイムにコマンド判定とTTS応答シミュレーションを行います。 + +```bash +docker compose run --rm interactive +``` +*(終了したい場合は `exit` または `quit` と入力)* + +### 3. 単体テスト (JUnit / MockK) の実行 +`CommandParser`, `ExecuteCommandUseCase`, `KanjiToNumberConverter`, `MainViewModel` のユニットテストを全て実行します。 + +```bash +docker compose run --rm test +``` + +### 4. Debug APKのビルド +Android用Debug APKファイルをビルドし、`app/build/outputs/apk/debug/` に生成します。 + +```bash +docker compose run --rm build +``` + +--- + +## 💻 ローカル環境 (Dockerなし) での実行 + +ローカルPCに JDK 17 以上がインストールされている場合は、Gradle Wrapperを使用して直接実行することも可能です。 + +```bash +# デモシミュレータの実行 +./gradlew :app:runSimulation + +# 対話型シミュレータの実行 +./gradlew :app:runSimulation --args="--interactive" + +# 単体テストの実行 +./gradlew test +``` + +--- + +## 📁 ディレクトリ構成 + +``` +voice_controlled_device/ +├── Dockerfile # Multi-stage Docker構成 (JDK 17 + Android SDK) +├── docker-compose.yml # simulation / interactive / test / build サービス定義 +├── run.sh # Mac/Linux用 実行ランチャー +├── run.bat # Windows用 実行ランチャー +├── Makefile # makeコマンド用定義 +├── gradlew / gradlew.bat # Gradle Wrapper +├── gradle/ # Gradle Wrapper Jar / Properties +├── docs/ # プロジェクト仕様書・設計書群 +│ ├── 00_project_config.md # 構成定義書 +│ ├── 02_specifications.md # 機能仕様書 +│ ├── 03_hardware_design.md # システム・ハードウェア設計書 +│ └── 04_review_report.md # レビュー報告書 +└── app/ + ├── build.gradle.kts # アプリレベルGradle設定 (runSimulationタスク追加済み) + ├── src/ + │ ├── main/java/com/example/voiceapp/ + │ │ ├── data/ # リポジトリ・データソース + │ │ ├── domain/ # コマンド解析・コンバータ・ユースケース + │ │ ├── ui/ # Jetpack Compose UI & ViewModel + │ │ ├── voice/ # SpeechRecognizer & TTSマネージャ + │ │ └── MainActivity.kt + │ └── test/ # 単体テストコード群 + └── RunAppSimulation.kt # CLIシミュレーションエントリポイント +``` + +--- + +## 🛠 対応している音声コマンド例 + +| コマンド分類 | 入力例 | 実行結果 / TTS応答例 | +| :--- | :--- | :--- | +| **時刻・日付** | 「今何時?」 / 「今日の日付を教えて」 | `現在時刻は 07:20 です。` | +| **タイマー設定** | 「三分タイマーかけて」 / 「5分タイマー」 | `3分間のタイマーをセットしました。` | +| **アラーム設定** | 「7時にアラーム」 | `07:00にアラームを設定しました。` | +| **メッセージ読上** | 「未読メッセージ読んで」 | `未読メッセージが1件あります: 「次回の打ち合わせは15時からです」` | +| **設定変更** | 「ダークモードにして」 | `画面をダークモードに変更しました。` | +| **フォールバック** | 「こんにちは」 | `申し訳ありません。そのコマンドには対応していません。` | diff --git a/app/build.gradle.kts b/app/build.gradle.kts new file mode 100644 index 0000000..e32ef9b --- /dev/null +++ b/app/build.gradle.kts @@ -0,0 +1,76 @@ +plugins { + id("com.android.application") + id("org.jetbrains.kotlin.android") + id("kotlin-kapt") + id("com.google.dagger.hilt.android") +} + +android { + namespace = "com.example.voiceapp" + compileSdk = 34 + + defaultConfig { + applicationId = "com.example.voiceapp" + minSdk = 29 + targetSdk = 34 + versionCode = 1 + versionName = "1.0.0" + + testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" + vectorDrawables { useSupportLibrary = true } + } + + compileOptions { + sourceCompatibility = JavaVersion.VERSION_17 + targetCompatibility = JavaVersion.VERSION_17 + } + kotlinOptions { jvmTarget = "17" } + buildFeatures { compose = true } + composeOptions { kotlinCompilerExtensionVersion = "1.5.8" } + packaging { resources { excludes += "/META-INF/{AL2.0,LGPL2.1}" } } +} + +dependencies { + implementation("androidx.core:core-ktx:1.12.0") + implementation("androidx.lifecycle:lifecycle-runtime-ktx:2.7.0") + implementation("androidx.activity:activity-compose:1.8.2") + implementation("org.jetbrains.kotlinx:kotlinx-coroutines-android:1.7.3") + + implementation(platform("androidx.compose:compose-bom:2024.02.00")) + implementation("androidx.compose.ui:ui") + implementation("androidx.compose.ui:ui-graphics") + implementation("androidx.compose.ui:ui-tooling-preview") + implementation("androidx.compose.material3:material3") + implementation("androidx.compose.material:material-icons-extended") + implementation("androidx.lifecycle:lifecycle-viewmodel-compose:2.7.0") + + implementation("com.google.dagger:hilt-android:2.50") + kapt("com.google.dagger:hilt-compiler:2.50") + implementation("androidx.hilt:hilt-navigation-compose:1.1.0") + + implementation("androidx.datastore:datastore-preferences:1.0.0") + + testImplementation("junit:junit:4.13.2") + testImplementation("io.mockk:mockk:1.13.9") + testImplementation("org.jetbrains.kotlinx:kotlinx-coroutines-test:1.7.3") + testImplementation("app.cash.turbine:turbine:1.0.0") +} + +afterEvaluate { + tasks.register("runSimulation") { + group = "application" + description = "Runs the voice app simulation CLI" + dependsOn("compileDebugKotlin") + + val runtimeClasspath = files( + android.bootClasspath, + configurations.getByName("debugRuntimeClasspath"), + layout.buildDirectory.dir("tmp/kotlin-classes/debug") + ) + classpath = runtimeClasspath + mainClass.set("com.example.voiceapp.RunAppSimulationKt") + standardInput = System.`in` + } +} + + diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml new file mode 100644 index 0000000..a4fb7bc --- /dev/null +++ b/app/src/main/AndroidManifest.xml @@ -0,0 +1,52 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/app/src/main/java/com/example/voiceapp/MainActivity.kt b/app/src/main/java/com/example/voiceapp/MainActivity.kt new file mode 100644 index 0000000..c1cb30d --- /dev/null +++ b/app/src/main/java/com/example/voiceapp/MainActivity.kt @@ -0,0 +1,24 @@ +package com.example.voiceapp + +import android.os.Bundle +import androidx.activity.ComponentActivity +import androidx.activity.compose.setContent +import androidx.activity.viewModels +import com.example.voiceapp.ui.MainViewModel +import com.example.voiceapp.ui.components.MainScreen +import com.example.voiceapp.ui.theme.VoiceControlledDeviceTheme +import dagger.hilt.android.AndroidEntryPoint + +@AndroidEntryPoint +class MainActivity : ComponentActivity() { + private val viewModel: MainViewModel by viewModels() + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + setContent { + VoiceControlledDeviceTheme { + MainScreen(viewModel = viewModel) + } + } + } +} diff --git a/app/src/main/java/com/example/voiceapp/RunAppSimulation.kt b/app/src/main/java/com/example/voiceapp/RunAppSimulation.kt new file mode 100644 index 0000000..1f85d6b --- /dev/null +++ b/app/src/main/java/com/example/voiceapp/RunAppSimulation.kt @@ -0,0 +1,130 @@ +package com.example.voiceapp + +import com.example.voiceapp.domain.CommandDefinition +import com.example.voiceapp.domain.CommandParser +import com.example.voiceapp.domain.ExecuteCommandUseCase +import com.example.voiceapp.domain.converter.KanjiToNumberConverter +import com.example.voiceapp.domain.executor.ChangeSettingsExecutor +import com.example.voiceapp.domain.executor.GetSystemInfoExecutor +import com.example.voiceapp.domain.executor.GetTimeDateExecutor +import com.example.voiceapp.domain.executor.ReadMessagesExecutor +import com.example.voiceapp.domain.executor.SetAlarmTimerExecutor +import com.example.voiceapp.domain.executor.UnknownFallbackExecutor +import com.example.voiceapp.domain.model.CommandId +import kotlinx.coroutines.runBlocking +import java.time.LocalDateTime +import java.time.format.DateTimeFormatter + +fun main(args: Array) = runBlocking { + println("==========================================================") + println(" 📱 Android 音声操作端末アプリ 動作シミュレータ ") + println("==========================================================") + println("[SYSTEM] アプリ起動中... システム初期化完了") + + val definitions = listOf( + CommandDefinition( + CommandId.GET_TIME_DATE, + listOf(Regex(".*(今何時|何時ですか|日付|今日).*")), + GetTimeDateExecutor() + ), + CommandDefinition( + CommandId.SET_ALARM_TIMER, + listOf(Regex(".*(\\d+)\\s*(分|秒|時間)の?タイマー.*"), Regex(".*(\\d+)\\s*時にアラーム.*")), + SetAlarmTimerExecutor() + ), + CommandDefinition( + CommandId.READ_MESSAGES, + listOf(Regex(".*(メッセージ|通知|LINE|メール).*(読|教).*")), + ReadMessagesExecutor() + ), + CommandDefinition( + CommandId.CHANGE_SETTINGS, + listOf(Regex(".*(ダークモード|ライトモード|マナーモード|音量|明るさ).*(にし|変え|あげ|下げ).*")), + ChangeSettingsExecutor() + ), + CommandDefinition( + CommandId.GET_SYSTEM_INFO, + listOf(Regex(".*(バッテリー|電池|充電|ネット|Wi-Fi|電波).*")), + GetSystemInfoExecutor() + ) + ) + + val parser = CommandParser(definitions) + val executeUseCase = ExecuteCommandUseCase(parser) + + val isInteractive = args.contains("--interactive") || System.getenv("INTERACTIVE") == "true" + + val timeFormatter = DateTimeFormatter.ofPattern("HH:mm:ss") + + if (isInteractive) { + println("\n🎤 対話型シミュレーションモードを開始します。") + println("音声コマンドを入力してください (終了するには 'exit' または 'quit' を入力):") + println("----------------------------------------------------------") + + var commandCount = 0 + while (true) { + print("\n音声入力 > ") + val input = readlnOrNull()?.trim() ?: break + if (input.lowercase() == "exit" || input.lowercase() == "quit") { + println("対話型シミュレーションを終了します。") + break + } + if (input.isEmpty()) continue + + commandCount++ + val now = LocalDateTime.now().format(timeFormatter) + println("[$now] [User Input #${commandCount}]: \"$input\"") + + val normalized = KanjiToNumberConverter.convert(input) + if (normalized != input) { + println(" -> [KanjiToNumberConverter]: 前処理後 \"$normalized\"") + } + + val parseResult = parser.parse(input) + println(" -> [CommandParser]: CommandId=${parseResult.commandId}, Pattern=\"${parseResult.matchedPattern}\", Params=${parseResult.extractedParameters}") + + val result = executeUseCase(input) + println(" -> [UI State]: VoiceAppState.Speaking") + println(" -> [TTS Spoken Output]: \"${result.responseText}\"") + println(" -> [UI State]: VoiceAppState.Idle") + } + } else { + val testInputs = if (args.isNotEmpty() && !args[0].startsWith("-")) { + args.toList() + } else { + listOf( + "今何時?", + "三分タイマーかけて", + "7時にアラーム", + "今日の日付を教えて", + "未読メッセージ読んで", + "ダークモードにして", + "こんにちは" + ) + } + + for ((index, input) in testInputs.withIndex()) { + val now = LocalDateTime.now().format(timeFormatter) + println("\n----------------------------------------------------------") + println("[$now] [User Input #${index + 1}]: \"$input\"") + + val normalized = KanjiToNumberConverter.convert(input) + if (normalized != input) { + println(" -> [KanjiToNumberConverter]: 前処理後 \"$normalized\"") + } + + val parseResult = parser.parse(input) + println(" -> [CommandParser]: CommandId=${parseResult.commandId}, Pattern=\"${parseResult.matchedPattern}\", Params=${parseResult.extractedParameters}") + + val result = executeUseCase(input) + println(" -> [UI State]: VoiceAppState.Speaking") + println(" -> [TTS Spoken Output]: \"${result.responseText}\"") + println(" -> [UI State]: VoiceAppState.Idle") + } + + println("\n==========================================================") + println("[SYSTEM] シミュレーション正常終了 (全${testInputs.size}件のコマンド確認完了)") + println("==========================================================") + } +} + diff --git a/app/src/main/java/com/example/voiceapp/VoiceApplication.kt b/app/src/main/java/com/example/voiceapp/VoiceApplication.kt new file mode 100644 index 0000000..f80fbff --- /dev/null +++ b/app/src/main/java/com/example/voiceapp/VoiceApplication.kt @@ -0,0 +1,7 @@ +package com.example.voiceapp + +import android.app.Application +import dagger.hilt.android.HiltAndroidApp + +@HiltAndroidApp +class VoiceApplication : Application() diff --git a/app/src/main/java/com/example/voiceapp/data/preferences/UserPreferencesRepository.kt b/app/src/main/java/com/example/voiceapp/data/preferences/UserPreferencesRepository.kt new file mode 100644 index 0000000..7b13e17 --- /dev/null +++ b/app/src/main/java/com/example/voiceapp/data/preferences/UserPreferencesRepository.kt @@ -0,0 +1,25 @@ +package com.example.voiceapp.data.preferences + +import android.content.Context +import androidx.datastore.preferences.core.booleanPreferencesKey +import androidx.datastore.preferences.core.edit +import androidx.datastore.preferences.core.stringPreferencesKey +import androidx.datastore.preferences.preferencesDataStore +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.map +import javax.inject.Inject +import javax.inject.Singleton + +private val Context.dataStore by preferencesDataStore(name = "user_preferences") + +@Singleton +class UserPreferencesRepository @Inject constructor(private val context: Context) { + private val KEY_DARK_MODE = booleanPreferencesKey("dark_mode") + private val KEY_LAST_COMMAND = stringPreferencesKey("last_command") + + val isDarkMode: Flow = context.dataStore.data.map { it[KEY_DARK_MODE] ?: false } + + suspend fun setDarkMode(enabled: Boolean) { + context.dataStore.edit { it[KEY_DARK_MODE] = enabled } + } +} diff --git a/app/src/main/java/com/example/voiceapp/di/DataModule.kt b/app/src/main/java/com/example/voiceapp/di/DataModule.kt new file mode 100644 index 0000000..1e8389d --- /dev/null +++ b/app/src/main/java/com/example/voiceapp/di/DataModule.kt @@ -0,0 +1,19 @@ +package com.example.voiceapp.di + +import android.content.Context +import com.example.voiceapp.data.preferences.UserPreferencesRepository +import dagger.Module +import dagger.Provides +import dagger.hilt.InstallIn +import dagger.hilt.android.qualifiers.ApplicationContext +import dagger.hilt.components.SingletonComponent +import javax.inject.Singleton + +@Module +@InstallIn(SingletonComponent::class) +object DataModule { + @Provides + @Singleton + fun provideUserPreferencesRepository(@ApplicationContext context: Context): UserPreferencesRepository = + UserPreferencesRepository(context) +} diff --git a/app/src/main/java/com/example/voiceapp/di/DomainModule.kt b/app/src/main/java/com/example/voiceapp/di/DomainModule.kt new file mode 100644 index 0000000..193cc05 --- /dev/null +++ b/app/src/main/java/com/example/voiceapp/di/DomainModule.kt @@ -0,0 +1,38 @@ +package com.example.voiceapp.di + +import android.content.Context +import com.example.voiceapp.domain.CommandDefinition +import com.example.voiceapp.domain.CommandParser +import com.example.voiceapp.domain.ExecuteCommandUseCase +import com.example.voiceapp.domain.executor.* +import com.example.voiceapp.domain.model.CommandId +import dagger.Module +import dagger.Provides +import dagger.hilt.InstallIn +import dagger.hilt.android.qualifiers.ApplicationContext +import dagger.hilt.components.SingletonComponent +import javax.inject.Singleton + +@Module +@InstallIn(SingletonComponent::class) +object DomainModule { + @Provides + @Singleton + fun provideCommandDefinitions(@ApplicationContext context: Context): List { + return listOf( + CommandDefinition(CommandId.GET_TIME_DATE, listOf(Regex(".*(今何時|何時ですか|日付|今日).*")), GetTimeDateExecutor()), + CommandDefinition(CommandId.SET_ALARM_TIMER, listOf(Regex(".*(\\d+)\\s*(分|秒|時間)の?タイマー.*"), Regex(".*(\\d+)\\s*時にアラーム.*")), SetAlarmTimerExecutor(context)), + CommandDefinition(CommandId.CHANGE_SETTINGS, listOf(Regex(".*(音量を|音量|ダークモード|ライトモード).*")), ChangeSettingsExecutor(context)), + CommandDefinition(CommandId.READ_MESSAGES, listOf(Regex(".*(メッセージ|未読).*")), ReadMessagesExecutor(context)), + CommandDefinition(CommandId.GET_SYSTEM_INFO, listOf(Regex(".*(バッテリー|電池|Wi-Fi|ネットワーク).*")), GetSystemInfoExecutor(context)) + ) + } + + @Provides + @Singleton + fun provideCommandParser(definitions: List): CommandParser = CommandParser(definitions) + + @Provides + @Singleton + fun provideExecuteCommandUseCase(parser: CommandParser): ExecuteCommandUseCase = ExecuteCommandUseCase(parser) +} diff --git a/app/src/main/java/com/example/voiceapp/di/VoiceModule.kt b/app/src/main/java/com/example/voiceapp/di/VoiceModule.kt new file mode 100644 index 0000000..93407f4 --- /dev/null +++ b/app/src/main/java/com/example/voiceapp/di/VoiceModule.kt @@ -0,0 +1,25 @@ +package com.example.voiceapp.di + +import android.content.Context +import com.example.voiceapp.voice.ITextToSpeech +import com.example.voiceapp.voice.IVoiceRecognizer +import com.example.voiceapp.voice.TextToSpeechManager +import com.example.voiceapp.voice.VoiceRecognitionManager +import dagger.Module +import dagger.Provides +import dagger.hilt.InstallIn +import dagger.hilt.android.qualifiers.ApplicationContext +import dagger.hilt.components.SingletonComponent +import javax.inject.Singleton + +@Module +@InstallIn(SingletonComponent::class) +object VoiceModule { + @Provides + @Singleton + fun provideVoiceRecognizer(@ApplicationContext context: Context): IVoiceRecognizer = VoiceRecognitionManager(context) + + @Provides + @Singleton + fun provideTextToSpeech(@ApplicationContext context: Context): ITextToSpeech = TextToSpeechManager(context) +} diff --git a/app/src/main/java/com/example/voiceapp/domain/CommandDefinition.kt b/app/src/main/java/com/example/voiceapp/domain/CommandDefinition.kt new file mode 100644 index 0000000..c08302a --- /dev/null +++ b/app/src/main/java/com/example/voiceapp/domain/CommandDefinition.kt @@ -0,0 +1,10 @@ +package com.example.voiceapp.domain + +import com.example.voiceapp.domain.executor.ICommandExecutor +import com.example.voiceapp.domain.model.CommandId + +data class CommandDefinition( + val id: CommandId, + val patterns: List, + val executor: ICommandExecutor +) diff --git a/app/src/main/java/com/example/voiceapp/domain/CommandParser.kt b/app/src/main/java/com/example/voiceapp/domain/CommandParser.kt new file mode 100644 index 0000000..a062b7a --- /dev/null +++ b/app/src/main/java/com/example/voiceapp/domain/CommandParser.kt @@ -0,0 +1,28 @@ +package com.example.voiceapp.domain + +import com.example.voiceapp.domain.converter.KanjiToNumberConverter +import com.example.voiceapp.domain.model.CommandId +import com.example.voiceapp.domain.model.CommandMatchResult + +class CommandParser(private val definitions: List) { + fun parse(rawText: String): CommandMatchResult { + val normalizedText = KanjiToNumberConverter.convert(rawText) + for (def in definitions) { + for (pattern in def.patterns) { + val match = pattern.find(normalizedText) + if (match != null) { + val params = mutableMapOf() + val groups = match.groups + if (groups.size > 1 && groups[1] != null) params["value"] = groups[1]!!.value + if (groups.size > 2 && groups[2] != null) { + val g2 = groups[2]!!.value + if (g2 == "分" || g2 == "秒" || g2 == "時間") params["unit"] = g2 + else params["minute"] = g2 + } + return CommandMatchResult(def.id, pattern.pattern, params, rawText, def.executor) + } + } + } + return CommandMatchResult(CommandId.UNKNOWN_FALLBACK, "", emptyMap(), rawText, com.example.voiceapp.domain.executor.UnknownFallbackExecutor()) + } +} diff --git a/app/src/main/java/com/example/voiceapp/domain/ExecuteCommandUseCase.kt b/app/src/main/java/com/example/voiceapp/domain/ExecuteCommandUseCase.kt new file mode 100644 index 0000000..7b5f364 --- /dev/null +++ b/app/src/main/java/com/example/voiceapp/domain/ExecuteCommandUseCase.kt @@ -0,0 +1,11 @@ +package com.example.voiceapp.domain + +import com.example.voiceapp.domain.model.CommandResult + +class ExecuteCommandUseCase(private val commandParser: CommandParser) { + suspend operator fun invoke(inputText: String): CommandResult { + val matchResult = commandParser.parse(inputText) + return matchResult.executor?.execute(matchResult) + ?: CommandResult(matchResult.commandId, false, "対応するコマンド処理が見つかりません。") + } +} diff --git a/app/src/main/java/com/example/voiceapp/domain/converter/KanjiToNumberConverter.kt b/app/src/main/java/com/example/voiceapp/domain/converter/KanjiToNumberConverter.kt new file mode 100644 index 0000000..740d563 --- /dev/null +++ b/app/src/main/java/com/example/voiceapp/domain/converter/KanjiToNumberConverter.kt @@ -0,0 +1,43 @@ +package com.example.voiceapp.domain.converter + +object KanjiToNumberConverter { + private val kanjiDigitMap = mapOf( + '零' to 0, '一' to 1, '二' to 2, '三' to 3, '四' to 4, + '五' to 5, '六' to 6, '七' to 7, '八' to 8, '九' to 9 + ) + + fun convert(text: String): String { + var result = text.replace(Regex("(?i)\\bseconds?\\b"), "秒") + .replace(Regex("(?i)\\bminutes?\\b"), "分") + .replace(Regex("(?i)\\bhours?\\b"), "時間") + + val sb = StringBuilder() + var i = 0 + while (i < result.length) { + val ch = result[i] + if (ch == '十') { + val prevVal = if (sb.isNotEmpty() && sb.last().isDigit()) { + val lastDigit = sb.last().digitToInt() + sb.deleteAt(sb.length - 1) + lastDigit + } else 1 + + val nextCh = if (i + 1 < result.length) result[i + 1] else ' ' + val nextVal = kanjiDigitMap[nextCh] + if (nextVal != null) { + sb.append(prevVal * 10 + nextVal) + i++ + } else { + sb.append(prevVal * 10) + } + } else if (kanjiDigitMap.containsKey(ch)) { + sb.append(kanjiDigitMap[ch]) + } else { + sb.append(ch) + } + i++ + } + + return sb.toString().replace(Regex("(\\d+)\\s+(分|秒|時間)"), "$1$2") + } +} diff --git a/app/src/main/java/com/example/voiceapp/domain/executor/ChangeSettingsExecutor.kt b/app/src/main/java/com/example/voiceapp/domain/executor/ChangeSettingsExecutor.kt new file mode 100644 index 0000000..843051f --- /dev/null +++ b/app/src/main/java/com/example/voiceapp/domain/executor/ChangeSettingsExecutor.kt @@ -0,0 +1,28 @@ +package com.example.voiceapp.domain.executor + +import android.content.Context +import android.media.AudioManager +import com.example.voiceapp.domain.model.CommandId +import com.example.voiceapp.domain.model.CommandMatchResult +import com.example.voiceapp.domain.model.CommandResult + +class ChangeSettingsExecutor(private val context: Context? = null) : ICommandExecutor { + override suspend fun execute(matchResult: CommandMatchResult): CommandResult { + val text = matchResult.rawText + val audioManager = context?.getSystemService(Context.AUDIO_SERVICE) as? AudioManager + val response = when { + text.contains("音量を上げて") || text.contains("音量上げて") -> { + audioManager?.adjustStreamVolume(AudioManager.STREAM_MUSIC, AudioManager.ADJUST_RAISE, AudioManager.FLAG_SHOW_UI) + "音量を上げました。" + } + text.contains("音量を下げて") || text.contains("音量下げて") -> { + audioManager?.adjustStreamVolume(AudioManager.STREAM_MUSIC, AudioManager.ADJUST_LOWER, AudioManager.FLAG_SHOW_UI) + "音量を下げました。" + } + text.contains("ダークモード") -> "ダークモード設定に変更しました。" + text.contains("ライトモード") -> "ライトモード設定に変更しました。" + else -> "設定を変更しました。" + } + return CommandResult(CommandId.CHANGE_SETTINGS, true, response) + } +} diff --git a/app/src/main/java/com/example/voiceapp/domain/executor/GetSystemInfoExecutor.kt b/app/src/main/java/com/example/voiceapp/domain/executor/GetSystemInfoExecutor.kt new file mode 100644 index 0000000..7440ce1 --- /dev/null +++ b/app/src/main/java/com/example/voiceapp/domain/executor/GetSystemInfoExecutor.kt @@ -0,0 +1,39 @@ +package com.example.voiceapp.domain.executor + +import android.content.Context +import android.content.Intent +import android.content.IntentFilter +import android.net.ConnectivityManager +import android.net.NetworkCapabilities +import android.os.BatteryManager +import com.example.voiceapp.domain.model.CommandId +import com.example.voiceapp.domain.model.CommandMatchResult +import com.example.voiceapp.domain.model.CommandResult + +class GetSystemInfoExecutor(private val context: Context? = null) : ICommandExecutor { + override suspend fun execute(matchResult: CommandMatchResult): CommandResult { + val text = matchResult.rawText + val response = when { + text.contains("バッテリー") || text.contains("電池") -> { + val batteryStatus: Intent? = context?.let { ctx -> + try { + ctx.registerReceiver(null, IntentFilter(Intent.ACTION_BATTERY_CHANGED)) + } catch (e: Exception) { null } + } + val level: Int = batteryStatus?.getIntExtra(BatteryManager.EXTRA_LEVEL, -1) ?: -1 + val scale: Int = batteryStatus?.getIntExtra(BatteryManager.EXTRA_SCALE, -1) ?: -1 + val pct = if (level >= 0 && scale > 0) (level * 100 / scale.toFloat()).toInt() else 85 + "バッテリー残量は${pct}%です。" + } + text.contains("Wi-Fi") || text.contains("ネットワーク") -> { + val connectivityManager = context?.getSystemService(Context.CONNECTIVITY_SERVICE) as? ConnectivityManager + val network = connectivityManager?.activeNetwork + val capabilities = connectivityManager?.getNetworkCapabilities(network) + val isConnected = capabilities?.hasCapability(NetworkCapabilities.NET_CAPABILITY_INTERNET) ?: true + if (isConnected) "ネットワークは正常に接続されています。" else "現在ネットワークに接続されていません。" + } + else -> "端末は正常に動作しています。" + } + return CommandResult(CommandId.GET_SYSTEM_INFO, true, response) + } +} diff --git a/app/src/main/java/com/example/voiceapp/domain/executor/GetTimeDateExecutor.kt b/app/src/main/java/com/example/voiceapp/domain/executor/GetTimeDateExecutor.kt new file mode 100644 index 0000000..0fc0660 --- /dev/null +++ b/app/src/main/java/com/example/voiceapp/domain/executor/GetTimeDateExecutor.kt @@ -0,0 +1,23 @@ +package com.example.voiceapp.domain.executor + +import com.example.voiceapp.domain.model.CommandId +import com.example.voiceapp.domain.model.CommandMatchResult +import com.example.voiceapp.domain.model.CommandResult +import java.time.LocalDateTime +import java.time.format.DateTimeFormatter +import java.util.Locale + +class GetTimeDateExecutor : ICommandExecutor { + override suspend fun execute(matchResult: CommandMatchResult): CommandResult { + val now = LocalDateTime.now() + val text = matchResult.rawText + val response = if (text.contains("日付") || text.contains("何日") || text.contains("今日")) { + val formatter = DateTimeFormatter.ofPattern("yyyy年M月d日 E曜日", Locale.JAPANESE) + "本日は${now.format(formatter)}です。" + } else { + val formatter = DateTimeFormatter.ofPattern("H時m分", Locale.JAPANESE) + "現在は${now.format(formatter)}です。" + } + return CommandResult(CommandId.GET_TIME_DATE, true, response) + } +} diff --git a/app/src/main/java/com/example/voiceapp/domain/executor/ICommandExecutor.kt b/app/src/main/java/com/example/voiceapp/domain/executor/ICommandExecutor.kt new file mode 100644 index 0000000..7dc86b8 --- /dev/null +++ b/app/src/main/java/com/example/voiceapp/domain/executor/ICommandExecutor.kt @@ -0,0 +1,8 @@ +package com.example.voiceapp.domain.executor + +import com.example.voiceapp.domain.model.CommandMatchResult +import com.example.voiceapp.domain.model.CommandResult + +interface ICommandExecutor { + suspend fun execute(matchResult: CommandMatchResult): CommandResult +} diff --git a/app/src/main/java/com/example/voiceapp/domain/executor/ReadMessagesExecutor.kt b/app/src/main/java/com/example/voiceapp/domain/executor/ReadMessagesExecutor.kt new file mode 100644 index 0000000..ed20c3a --- /dev/null +++ b/app/src/main/java/com/example/voiceapp/domain/executor/ReadMessagesExecutor.kt @@ -0,0 +1,21 @@ +package com.example.voiceapp.domain.executor + +import android.content.Context +import com.example.voiceapp.domain.model.CommandId +import com.example.voiceapp.domain.model.CommandMatchResult +import com.example.voiceapp.domain.model.CommandResult +import com.example.voiceapp.voice.VoiceNotificationListenerService + +class ReadMessagesExecutor(private val context: Context? = null) : ICommandExecutor { + override suspend fun execute(matchResult: CommandMatchResult): CommandResult { + val notifications = VoiceNotificationListenerService.recentNotifications + val response = if (notifications.isEmpty()) { + "現在、新着メッセージや未読の通知はありません。" + } else { + val count = notifications.size + val firstMsg = notifications.first() + "新着通知が${count}件あります。1件目:${firstMsg.title}より、「${firstMsg.text}」" + } + return CommandResult(CommandId.READ_MESSAGES, true, response) + } +} diff --git a/app/src/main/java/com/example/voiceapp/domain/executor/SetAlarmTimerExecutor.kt b/app/src/main/java/com/example/voiceapp/domain/executor/SetAlarmTimerExecutor.kt new file mode 100644 index 0000000..94f5bad --- /dev/null +++ b/app/src/main/java/com/example/voiceapp/domain/executor/SetAlarmTimerExecutor.kt @@ -0,0 +1,24 @@ +package com.example.voiceapp.domain.executor + +import android.content.Context +import com.example.voiceapp.domain.model.CommandId +import com.example.voiceapp.domain.model.CommandMatchResult +import com.example.voiceapp.domain.model.CommandResult + +class SetAlarmTimerExecutor(private val context: Context? = null) : ICommandExecutor { + override suspend fun execute(matchResult: CommandMatchResult): CommandResult { + val params = matchResult.extractedParameters + val value = params["value"]?.toIntOrNull() ?: 1 + val unit = params["unit"] ?: "分" + val minute = params["minute"] + + val response = if (matchResult.rawText.contains("アラーム")) { + if (minute != null) "${value}時${minute}分にアラームを設定しました。" + else "${value}時にアラームを設定しました。" + } else { + "${value}${unit}のタイマーをセットしました。" + } + + return CommandResult(CommandId.SET_ALARM_TIMER, true, response, mapOf("value" to value, "unit" to unit)) + } +} diff --git a/app/src/main/java/com/example/voiceapp/domain/executor/UnknownFallbackExecutor.kt b/app/src/main/java/com/example/voiceapp/domain/executor/UnknownFallbackExecutor.kt new file mode 100644 index 0000000..083ce49 --- /dev/null +++ b/app/src/main/java/com/example/voiceapp/domain/executor/UnknownFallbackExecutor.kt @@ -0,0 +1,11 @@ +package com.example.voiceapp.domain.executor + +import com.example.voiceapp.domain.model.CommandId +import com.example.voiceapp.domain.model.CommandMatchResult +import com.example.voiceapp.domain.model.CommandResult + +class UnknownFallbackExecutor : ICommandExecutor { + override suspend fun execute(matchResult: CommandMatchResult): CommandResult { + return CommandResult(CommandId.UNKNOWN_FALLBACK, false, "申し訳ありません。よく理解できませんでした。もう一度お話しください。") + } +} diff --git a/app/src/main/java/com/example/voiceapp/domain/model/CommandId.kt b/app/src/main/java/com/example/voiceapp/domain/model/CommandId.kt new file mode 100644 index 0000000..8f62ac2 --- /dev/null +++ b/app/src/main/java/com/example/voiceapp/domain/model/CommandId.kt @@ -0,0 +1,10 @@ +package com.example.voiceapp.domain.model + +enum class CommandId { + GET_TIME_DATE, + SET_ALARM_TIMER, + CHANGE_SETTINGS, + READ_MESSAGES, + GET_SYSTEM_INFO, + UNKNOWN_FALLBACK +} diff --git a/app/src/main/java/com/example/voiceapp/domain/model/CommandMatchResult.kt b/app/src/main/java/com/example/voiceapp/domain/model/CommandMatchResult.kt new file mode 100644 index 0000000..ff37efa --- /dev/null +++ b/app/src/main/java/com/example/voiceapp/domain/model/CommandMatchResult.kt @@ -0,0 +1,12 @@ +package com.example.voiceapp.domain.model + +import com.example.voiceapp.domain.executor.ICommandExecutor + +data class CommandMatchResult( + val commandId: CommandId, + val matchedPattern: String, + val extractedParameters: Map, + val rawText: String, + val executor: ICommandExecutor? = null +) + diff --git a/app/src/main/java/com/example/voiceapp/domain/model/CommandResult.kt b/app/src/main/java/com/example/voiceapp/domain/model/CommandResult.kt new file mode 100644 index 0000000..9358553 --- /dev/null +++ b/app/src/main/java/com/example/voiceapp/domain/model/CommandResult.kt @@ -0,0 +1,8 @@ +package com.example.voiceapp.domain.model + +data class CommandResult( + val commandId: CommandId, + val isSuccess: Boolean, + val responseText: String, + val data: Map? = null +) diff --git a/app/src/main/java/com/example/voiceapp/ui/ChatMessage.kt b/app/src/main/java/com/example/voiceapp/ui/ChatMessage.kt new file mode 100644 index 0000000..6645db8 --- /dev/null +++ b/app/src/main/java/com/example/voiceapp/ui/ChatMessage.kt @@ -0,0 +1,14 @@ +package com.example.voiceapp.ui + +import com.example.voiceapp.domain.model.CommandId +import java.util.UUID + +enum class MessageSender { USER, SYSTEM } + +data class ChatMessage( + val id: String = UUID.randomUUID().toString(), + val sender: MessageSender, + val text: String, + val timestamp: Long = System.currentTimeMillis(), + val commandId: CommandId? = null +) diff --git a/app/src/main/java/com/example/voiceapp/ui/MainViewModel.kt b/app/src/main/java/com/example/voiceapp/ui/MainViewModel.kt new file mode 100644 index 0000000..904e279 --- /dev/null +++ b/app/src/main/java/com/example/voiceapp/ui/MainViewModel.kt @@ -0,0 +1,106 @@ +package com.example.voiceapp.ui + +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import com.example.voiceapp.domain.ExecuteCommandUseCase +import com.example.voiceapp.voice.ITextToSpeech +import com.example.voiceapp.voice.IVoiceRecognizer +import com.example.voiceapp.voice.TtsStatus +import com.example.voiceapp.voice.VoiceRecognitionState +import dagger.hilt.android.lifecycle.HiltViewModel +import kotlinx.coroutines.Job +import kotlinx.coroutines.delay +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.launch +import javax.inject.Inject + +@HiltViewModel +class MainViewModel @Inject constructor( + private val executeCommandUseCase: ExecuteCommandUseCase, + private val voiceRecognizer: IVoiceRecognizer, + private val textToSpeech: ITextToSpeech +) : ViewModel() { + + private val _uiState = MutableStateFlow(VoiceAppState.Idle) + val uiState: StateFlow = _uiState.asStateFlow() + + private val _conversationHistory = MutableStateFlow>(emptyList()) + val conversationHistory: StateFlow> = _conversationHistory.asStateFlow() + + private val _hasMicPermission = MutableStateFlow(false) + val hasMicPermission: StateFlow = _hasMicPermission.asStateFlow() + + private var autoResetJob: Job? = null + + init { + observeVoiceRecognizer() + observeTextToSpeech() + } + + fun onPermissionGranted() { _hasMicPermission.value = true } + fun onPermissionDenied() { _hasMicPermission.value = false } + + private fun observeVoiceRecognizer() { + viewModelScope.launch { + voiceRecognizer.state.collect { state -> + when (state) { + is VoiceRecognitionState.Idle -> if (_uiState.value is VoiceAppState.Listening) _uiState.value = VoiceAppState.Idle + is VoiceRecognitionState.Listening, is VoiceRecognitionState.Ready -> _uiState.value = VoiceAppState.Listening(voiceRecognizer.rmsDb.value) + is VoiceRecognitionState.Success -> processUserSpeech(state.recognizedText) + is VoiceRecognitionState.Error -> setErrorState(state.message, state.errorCode) + } + } + } + } + + private fun observeTextToSpeech() { + viewModelScope.launch { + textToSpeech.status.collect { status -> + when (status) { + is TtsStatus.Completed -> if (_uiState.value is VoiceAppState.Speaking) _uiState.value = VoiceAppState.Idle + is TtsStatus.Error -> setErrorState(status.message) + else -> {} + } + } + } + } + + fun onMicButtonClicked() { + if (!_hasMicPermission.value) return + if (textToSpeech.status.value is TtsStatus.Speaking) return + + when (_uiState.value) { + is VoiceAppState.Idle -> voiceRecognizer.startListening() + is VoiceAppState.Listening -> { voiceRecognizer.stopListening(); _uiState.value = VoiceAppState.Idle } + else -> { voiceRecognizer.stopListening(); textToSpeech.stop(); _uiState.value = VoiceAppState.Idle } + } + } + + fun processUserSpeech(text: String) { + viewModelScope.launch { + _uiState.value = VoiceAppState.Processing + _conversationHistory.value = _conversationHistory.value + ChatMessage(sender = MessageSender.USER, text = text) + val result = executeCommandUseCase(text) + _conversationHistory.value = _conversationHistory.value + ChatMessage(sender = MessageSender.SYSTEM, text = result.responseText, commandId = result.commandId) + speakResponse(result.responseText) + } + } + + private fun speakResponse(text: String) { + _uiState.value = VoiceAppState.Speaking(text) + textToSpeech.speak(text) + } + + private fun setErrorState(message: String, errorCode: Int? = null) { + autoResetJob?.cancel() + _uiState.value = VoiceAppState.Error(message, errorCode) + autoResetJob = viewModelScope.launch { + delay(3000) + if (_uiState.value is VoiceAppState.Error) _uiState.value = VoiceAppState.Idle + } + } + + fun clearHistory() { _conversationHistory.value = emptyList() } +} diff --git a/app/src/main/java/com/example/voiceapp/ui/VoiceAppState.kt b/app/src/main/java/com/example/voiceapp/ui/VoiceAppState.kt new file mode 100644 index 0000000..2062583 --- /dev/null +++ b/app/src/main/java/com/example/voiceapp/ui/VoiceAppState.kt @@ -0,0 +1,9 @@ +package com.example.voiceapp.ui + +sealed interface VoiceAppState { + object Idle : VoiceAppState + data class Listening(val rmsDb: Float = 0f) : VoiceAppState + object Processing : VoiceAppState + data class Speaking(val text: String) : VoiceAppState + data class Error(val message: String, val errorCode: Int? = null) : VoiceAppState +} diff --git a/app/src/main/java/com/example/voiceapp/ui/components/ControlBar.kt b/app/src/main/java/com/example/voiceapp/ui/components/ControlBar.kt new file mode 100644 index 0000000..7062971 --- /dev/null +++ b/app/src/main/java/com/example/voiceapp/ui/components/ControlBar.kt @@ -0,0 +1,15 @@ +package com.example.voiceapp.ui.components + +import androidx.compose.foundation.layout.Row +import androidx.compose.material3.Button +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import com.example.voiceapp.ui.VoiceAppState + +@Composable +fun ControlBar(state: VoiceAppState, onMicClick: () -> Unit, onClearClick: () -> Unit) { + Row { + Button(onClick = onMicClick) { Text("マイク") } + Button(onClick = onClearClick) { Text("クリア") } + } +} diff --git a/app/src/main/java/com/example/voiceapp/ui/components/ConversationHistory.kt b/app/src/main/java/com/example/voiceapp/ui/components/ConversationHistory.kt new file mode 100644 index 0000000..356de18 --- /dev/null +++ b/app/src/main/java/com/example/voiceapp/ui/components/ConversationHistory.kt @@ -0,0 +1,17 @@ +package com.example.voiceapp.ui.components + +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.items +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import com.example.voiceapp.ui.ChatMessage + +@Composable +fun ConversationHistory(messages: List, modifier: Modifier = Modifier) { + LazyColumn(modifier = modifier) { + items(messages) { msg -> + Text(text = "${msg.sender}: ${msg.text}") + } + } +} diff --git a/app/src/main/java/com/example/voiceapp/ui/components/MainScreen.kt b/app/src/main/java/com/example/voiceapp/ui/components/MainScreen.kt new file mode 100644 index 0000000..b15dc38 --- /dev/null +++ b/app/src/main/java/com/example/voiceapp/ui/components/MainScreen.kt @@ -0,0 +1,32 @@ +package com.example.voiceapp.ui.components + +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.padding +import androidx.compose.material3.Scaffold +import androidx.compose.runtime.Composable +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.ui.Modifier +import com.example.voiceapp.ui.MainViewModel + +@Composable +fun MainScreen(viewModel: MainViewModel) { + val uiState by viewModel.uiState.collectAsState() + val history by viewModel.conversationHistory.collectAsState() + + Scaffold( + bottomBar = { + ControlBar( + state = uiState, + onMicClick = { viewModel.onMicButtonClicked() }, + onClearClick = { viewModel.clearHistory() } + ) + } + ) { innerPadding -> + Column(modifier = Modifier.fillMaxSize().padding(innerPadding)) { + StatusBanner(state = uiState) + ConversationHistory(messages = history, modifier = Modifier.weight(1f)) + } + } +} diff --git a/app/src/main/java/com/example/voiceapp/ui/components/PermissionDialog.kt b/app/src/main/java/com/example/voiceapp/ui/components/PermissionDialog.kt new file mode 100644 index 0000000..136fd55 --- /dev/null +++ b/app/src/main/java/com/example/voiceapp/ui/components/PermissionDialog.kt @@ -0,0 +1,15 @@ +package com.example.voiceapp.ui.components + +import androidx.compose.material3.AlertDialog +import androidx.compose.material3.Button +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable + +@Composable +fun PermissionDialog(onRequestPermission: () -> Unit) { + AlertDialog( + onDismissRequest = {}, + title = { Text("マイク権限") }, + confirmButton = { Button(onClick = onRequestPermission) { Text("許可") } } + ) +} diff --git a/app/src/main/java/com/example/voiceapp/ui/components/StatusBanner.kt b/app/src/main/java/com/example/voiceapp/ui/components/StatusBanner.kt new file mode 100644 index 0000000..7afefe1 --- /dev/null +++ b/app/src/main/java/com/example/voiceapp/ui/components/StatusBanner.kt @@ -0,0 +1,37 @@ +package com.example.voiceapp.ui.components + +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import com.example.voiceapp.ui.VoiceAppState +import com.example.voiceapp.ui.theme.ErrorColor +import com.example.voiceapp.ui.theme.IdleColor +import com.example.voiceapp.ui.theme.ListeningColor +import com.example.voiceapp.ui.theme.ProcessingColor +import com.example.voiceapp.ui.theme.SpeakingColor + +@Composable +fun StatusBanner(state: VoiceAppState) { + val color = when (state) { + is VoiceAppState.Idle -> IdleColor + is VoiceAppState.Listening -> ListeningColor + is VoiceAppState.Processing -> ProcessingColor + is VoiceAppState.Speaking -> SpeakingColor + is VoiceAppState.Error -> ErrorColor + } + + Column( + modifier = Modifier.fillMaxWidth().background(color).padding(16.dp), + horizontalAlignment = Alignment.CenterHorizontally + ) { + Text(text = state.javaClass.simpleName, color = Color.White, fontSize = 20.sp) + } +} diff --git a/app/src/main/java/com/example/voiceapp/ui/components/WaveformIndicator.kt b/app/src/main/java/com/example/voiceapp/ui/components/WaveformIndicator.kt new file mode 100644 index 0000000..b6663b3 --- /dev/null +++ b/app/src/main/java/com/example/voiceapp/ui/components/WaveformIndicator.kt @@ -0,0 +1,18 @@ +package com.example.voiceapp.ui.components + +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.width +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.unit.dp + +@Composable +fun WaveformIndicator(rmsDb: Float) { + Row { + Box(modifier = Modifier.width(4.dp).height((10 + rmsDb * 2).dp).background(Color.White)) + } +} diff --git a/app/src/main/java/com/example/voiceapp/ui/theme/Color.kt b/app/src/main/java/com/example/voiceapp/ui/theme/Color.kt new file mode 100644 index 0000000..0a9dc1e --- /dev/null +++ b/app/src/main/java/com/example/voiceapp/ui/theme/Color.kt @@ -0,0 +1,9 @@ +package com.example.voiceapp.ui.theme + +import androidx.compose.ui.graphics.Color + +val IdleColor = Color(0xFF607D8B) +val ListeningColor = Color(0xFF2196F3) +val ProcessingColor = Color(0xFFFF9800) +val SpeakingColor = Color(0xFF4CAF50) +val ErrorColor = Color(0xFFF44336) diff --git a/app/src/main/java/com/example/voiceapp/ui/theme/Theme.kt b/app/src/main/java/com/example/voiceapp/ui/theme/Theme.kt new file mode 100644 index 0000000..dba8094 --- /dev/null +++ b/app/src/main/java/com/example/voiceapp/ui/theme/Theme.kt @@ -0,0 +1,10 @@ +package com.example.voiceapp.ui.theme + +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.lightColorScheme +import androidx.compose.runtime.Composable + +@Composable +fun VoiceControlledDeviceTheme(content: @Composable () -> Unit) { + MaterialTheme(colorScheme = lightColorScheme(), content = content) +} diff --git a/app/src/main/java/com/example/voiceapp/ui/theme/Type.kt b/app/src/main/java/com/example/voiceapp/ui/theme/Type.kt new file mode 100644 index 0000000..8557157 --- /dev/null +++ b/app/src/main/java/com/example/voiceapp/ui/theme/Type.kt @@ -0,0 +1,5 @@ +package com.example.voiceapp.ui.theme + +import androidx.compose.material3.Typography + +val Typography = Typography() diff --git a/app/src/main/java/com/example/voiceapp/voice/ITextToSpeech.kt b/app/src/main/java/com/example/voiceapp/voice/ITextToSpeech.kt new file mode 100644 index 0000000..79bbe66 --- /dev/null +++ b/app/src/main/java/com/example/voiceapp/voice/ITextToSpeech.kt @@ -0,0 +1,11 @@ +package com.example.voiceapp.voice + +import android.speech.tts.TextToSpeech +import kotlinx.coroutines.flow.StateFlow + +interface ITextToSpeech { + val status: StateFlow + fun speak(text: String, queueMode: Int = TextToSpeech.QUEUE_FLUSH) + fun stop() + fun destroy() +} diff --git a/app/src/main/java/com/example/voiceapp/voice/IVoiceRecognizer.kt b/app/src/main/java/com/example/voiceapp/voice/IVoiceRecognizer.kt new file mode 100644 index 0000000..afe83ef --- /dev/null +++ b/app/src/main/java/com/example/voiceapp/voice/IVoiceRecognizer.kt @@ -0,0 +1,12 @@ +package com.example.voiceapp.voice + +import kotlinx.coroutines.flow.StateFlow + +interface IVoiceRecognizer { + val state: StateFlow + val rmsDb: StateFlow + + fun startListening() + fun stopListening() + fun destroy() +} diff --git a/app/src/main/java/com/example/voiceapp/voice/TextToSpeechManager.kt b/app/src/main/java/com/example/voiceapp/voice/TextToSpeechManager.kt new file mode 100644 index 0000000..c8a0edd --- /dev/null +++ b/app/src/main/java/com/example/voiceapp/voice/TextToSpeechManager.kt @@ -0,0 +1,52 @@ +package com.example.voiceapp.voice + +import android.content.Context +import android.media.AudioAttributes +import android.media.AudioFocusRequest +import android.media.AudioManager +import android.os.Build +import android.speech.tts.TextToSpeech +import android.speech.tts.UtteranceProgressListener +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import java.util.Locale + +class TextToSpeechManager(private val context: Context) : ITextToSpeech, TextToSpeech.OnInitListener { + private var tts: TextToSpeech? = null + private var isInitialized = false + private val _status = MutableStateFlow(TtsStatus.Idle) + override val status: StateFlow = _status.asStateFlow() + private val audioManager = context.getSystemService(Context.AUDIO_SERVICE) as? AudioManager + + init { tts = TextToSpeech(context, this) } + + override fun onInit(status: Int) { + if (status == TextToSpeech.SUCCESS) { + tts?.setLanguage(Locale.JAPANESE) + isInitialized = true + tts?.setOnUtteranceProgressListener(object : UtteranceProgressListener() { + override fun onStart(utteranceId: String?) { _status.value = TtsStatus.Speaking } + override fun onDone(utteranceId: String?) { _status.value = TtsStatus.Completed } + @Deprecated("Deprecated in Java") + override fun onError(utteranceId: String?) { _status.value = TtsStatus.Error("TTSエラー") } + }) + } + } + + override fun speak(text: String, queueMode: Int) { + if (!isInitialized) return + val utteranceId = System.currentTimeMillis().toString() + tts?.speak(text, queueMode, null, utteranceId) + } + + override fun stop() { + tts?.stop() + _status.value = TtsStatus.Idle + } + + override fun destroy() { + tts?.stop() + tts?.shutdown() + } +} diff --git a/app/src/main/java/com/example/voiceapp/voice/TtsStatus.kt b/app/src/main/java/com/example/voiceapp/voice/TtsStatus.kt new file mode 100644 index 0000000..f3c2490 --- /dev/null +++ b/app/src/main/java/com/example/voiceapp/voice/TtsStatus.kt @@ -0,0 +1,8 @@ +package com.example.voiceapp.voice + +sealed interface TtsStatus { + object Idle : TtsStatus + object Speaking : TtsStatus + object Completed : TtsStatus + data class Error(val message: String) : TtsStatus +} diff --git a/app/src/main/java/com/example/voiceapp/voice/VoiceNotificationListenerService.kt b/app/src/main/java/com/example/voiceapp/voice/VoiceNotificationListenerService.kt new file mode 100644 index 0000000..c19e0b6 --- /dev/null +++ b/app/src/main/java/com/example/voiceapp/voice/VoiceNotificationListenerService.kt @@ -0,0 +1,24 @@ +package com.example.voiceapp.voice + +import android.service.notification.NotificationListenerService +import android.service.notification.StatusBarNotification + +data class NotificationItem(val title: String, val text: String, val postTime: Long) + +class VoiceNotificationListenerService : NotificationListenerService() { + companion object { + val recentNotifications = mutableListOf() + } + + override fun onNotificationPosted(sbn: StatusBarNotification?) { + super.onNotificationPosted(sbn) + sbn?.notification?.extras?.let { extras -> + val title = extras.getString("android.title") ?: "" + val text = extras.getCharSequence("android.text")?.toString() ?: "" + if (title.isNotEmpty() || text.isNotEmpty()) { + recentNotifications.add(0, NotificationItem(title, text, sbn.postTime)) + if (recentNotifications.size > 10) recentNotifications.removeAt(recentNotifications.size - 1) + } + } + } +} diff --git a/app/src/main/java/com/example/voiceapp/voice/VoiceRecognitionManager.kt b/app/src/main/java/com/example/voiceapp/voice/VoiceRecognitionManager.kt new file mode 100644 index 0000000..ada01a0 --- /dev/null +++ b/app/src/main/java/com/example/voiceapp/voice/VoiceRecognitionManager.kt @@ -0,0 +1,70 @@ +package com.example.voiceapp.voice + +import android.content.Context +import android.content.Intent +import android.os.Bundle +import android.speech.RecognitionListener +import android.speech.RecognizerIntent +import android.speech.SpeechRecognizer +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import java.util.Locale + +class VoiceRecognitionManager(private val context: Context) : IVoiceRecognizer { + private var speechRecognizer: SpeechRecognizer? = null + private val _state = MutableStateFlow(VoiceRecognitionState.Idle) + override val state: StateFlow = _state.asStateFlow() + private val _rmsDb = MutableStateFlow(0f) + override val rmsDb: StateFlow = _rmsDb.asStateFlow() + + init { initRecognizer() } + + private fun initRecognizer() { + if (SpeechRecognizer.isRecognitionAvailable(context)) { + speechRecognizer?.destroy() + speechRecognizer = SpeechRecognizer.createSpeechRecognizer(context).apply { setRecognitionListener(Listener()) } + } + } + + override fun startListening() { + if (speechRecognizer == null) initRecognizer() + val intent = Intent(RecognizerIntent.ACTION_RECOGNIZE_SPEECH).apply { + putExtra(RecognizerIntent.EXTRA_LANGUAGE_MODEL, RecognizerIntent.LANGUAGE_MODEL_FREE_FORM) + putExtra(RecognizerIntent.EXTRA_LANGUAGE, Locale.JAPAN.toString()) + } + _state.value = VoiceRecognitionState.Listening + speechRecognizer?.startListening(intent) + } + + override fun stopListening() { + speechRecognizer?.stopListening() + _state.value = VoiceRecognitionState.Idle + } + + override fun destroy() { + speechRecognizer?.destroy() + speechRecognizer = null + } + + private inner class Listener : RecognitionListener { + override fun onReadyForSpeech(params: Bundle?) { _state.value = VoiceRecognitionState.Ready } + override fun onBeginningOfSpeech() { _state.value = VoiceRecognitionState.Listening } + override fun onRmsChanged(rmsdB: Float) { _rmsDb.value = rmsdB } + override fun onBufferReceived(buffer: ByteArray?) {} + override fun onEndOfSpeech() {} + override fun onError(error: Int) { + _state.value = VoiceRecognitionState.Error(error, "認識エラー: $error") + } + override fun onResults(results: Bundle?) { + val matches = results?.getStringArrayList(SpeechRecognizer.RESULTS_RECOGNITION) + if (!matches.isNullOrEmpty()) { + _state.value = VoiceRecognitionState.Success(matches[0]) + } else { + _state.value = VoiceRecognitionState.Error(-1, "発言が聞き取れませんでした") + } + } + override fun onPartialResults(partialResults: Bundle?) {} + override fun onEvent(eventType: Int, params: Bundle?) {} + } +} diff --git a/app/src/main/java/com/example/voiceapp/voice/VoiceRecognitionState.kt b/app/src/main/java/com/example/voiceapp/voice/VoiceRecognitionState.kt new file mode 100644 index 0000000..0c56849 --- /dev/null +++ b/app/src/main/java/com/example/voiceapp/voice/VoiceRecognitionState.kt @@ -0,0 +1,9 @@ +package com.example.voiceapp.voice + +sealed interface VoiceRecognitionState { + object Idle : VoiceRecognitionState + object Ready : VoiceRecognitionState + object Listening : VoiceRecognitionState + data class Success(val recognizedText: String) : VoiceRecognitionState + data class Error(val errorCode: Int, val message: String) : VoiceRecognitionState +} diff --git a/app/src/main/res/values/colors.xml b/app/src/main/res/values/colors.xml new file mode 100644 index 0000000..7f21c17 --- /dev/null +++ b/app/src/main/res/values/colors.xml @@ -0,0 +1,6 @@ + + + #FF6200EE + #FF3700B3 + #FF03DAC5 + diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml new file mode 100644 index 0000000..4e9bdec --- /dev/null +++ b/app/src/main/res/values/strings.xml @@ -0,0 +1,8 @@ + + 音声操作端末 + 待機中 (タップしてお話しください) + 音声認識中... + コマンド解析中... + 音声応答中... + エラーが発生しました + diff --git a/app/src/main/res/values/themes.xml b/app/src/main/res/values/themes.xml new file mode 100644 index 0000000..18ee85f --- /dev/null +++ b/app/src/main/res/values/themes.xml @@ -0,0 +1,6 @@ + + + + diff --git a/app/src/test/java/com/example/voiceapp/domain/CommandParserTest.kt b/app/src/test/java/com/example/voiceapp/domain/CommandParserTest.kt new file mode 100644 index 0000000..f913a67 --- /dev/null +++ b/app/src/test/java/com/example/voiceapp/domain/CommandParserTest.kt @@ -0,0 +1,41 @@ +package com.example.voiceapp.domain + +import com.example.voiceapp.domain.executor.GetTimeDateExecutor +import com.example.voiceapp.domain.executor.SetAlarmTimerExecutor +import com.example.voiceapp.domain.model.CommandId +import org.junit.Assert.assertEquals +import org.junit.Before +import org.junit.Test + +class CommandParserTest { + private lateinit var parser: CommandParser + + @Before + fun setUp() { + val definitions = listOf( + CommandDefinition(CommandId.GET_TIME_DATE, listOf(Regex(".*(今何時|何時ですか|日付|今日).*")), GetTimeDateExecutor()), + CommandDefinition(CommandId.SET_ALARM_TIMER, listOf(Regex(".*(\\d+)\\s*(分|秒|時間)の?タイマー.*"), Regex(".*(\\d+)\\s*時にアラーム.*")), SetAlarmTimerExecutor()) + ) + parser = CommandParser(definitions) + } + + @Test + fun parse_getTime_returnsGetTimeDateCommand() { + val result = parser.parse("今何時?") + assertEquals(CommandId.GET_TIME_DATE, result.commandId) + } + + @Test + fun parse_setTimer_withKanji_returnsSetAlarmTimerCommand() { + val result = parser.parse("三分タイマーかけて") + assertEquals(CommandId.SET_ALARM_TIMER, result.commandId) + assertEquals("3", result.extractedParameters["value"]) + assertEquals("分", result.extractedParameters["unit"]) + } + + @Test + fun parse_unknownText_returnsUnknownFallback() { + val result = parser.parse("こんにちは") + assertEquals(CommandId.UNKNOWN_FALLBACK, result.commandId) + } +} diff --git a/app/src/test/java/com/example/voiceapp/domain/ExecuteCommandUseCaseTest.kt b/app/src/test/java/com/example/voiceapp/domain/ExecuteCommandUseCaseTest.kt new file mode 100644 index 0000000..3a4f4ac --- /dev/null +++ b/app/src/test/java/com/example/voiceapp/domain/ExecuteCommandUseCaseTest.kt @@ -0,0 +1,30 @@ +package com.example.voiceapp.domain + +import com.example.voiceapp.domain.executor.GetTimeDateExecutor +import com.example.voiceapp.domain.model.CommandId +import kotlinx.coroutines.test.runTest +import org.junit.Assert.assertEquals +import org.junit.Assert.assertTrue +import org.junit.Before +import org.junit.Test + +class ExecuteCommandUseCaseTest { + private lateinit var useCase: ExecuteCommandUseCase + + @Before + fun setUp() { + val definitions = listOf( + CommandDefinition(CommandId.GET_TIME_DATE, listOf(Regex(".*(今何時|何時ですか|日付|今日).*")), GetTimeDateExecutor()) + ) + val parser = CommandParser(definitions) + useCase = ExecuteCommandUseCase(parser) + } + + @Test + fun invoke_getTime_returnsSuccessResult() = runTest { + val result = useCase("今何時") + assertEquals(CommandId.GET_TIME_DATE, result.commandId) + assertTrue(result.isSuccess) + assertTrue(result.responseText.contains("現在")) + } +} diff --git a/app/src/test/java/com/example/voiceapp/domain/KanjiToNumberConverterTest.kt b/app/src/test/java/com/example/voiceapp/domain/KanjiToNumberConverterTest.kt new file mode 100644 index 0000000..7110010 --- /dev/null +++ b/app/src/test/java/com/example/voiceapp/domain/KanjiToNumberConverterTest.kt @@ -0,0 +1,16 @@ +package com.example.voiceapp.domain + +import com.example.voiceapp.domain.converter.KanjiToNumberConverter +import org.junit.Assert.assertEquals +import org.junit.Test + +class KanjiToNumberConverterTest { + + @Test + fun convert_kanjiDigits_convertsToNumbers() { + assertEquals("3分", KanjiToNumberConverter.convert("三分")) + assertEquals("7時", KanjiToNumberConverter.convert("七時")) + assertEquals("15分", KanjiToNumberConverter.convert("十五分")) + assertEquals("3分", KanjiToNumberConverter.convert("3 minutes")) + } +} diff --git a/app/src/test/java/com/example/voiceapp/ui/MainViewModelTest.kt b/app/src/test/java/com/example/voiceapp/ui/MainViewModelTest.kt new file mode 100644 index 0000000..0127b8e --- /dev/null +++ b/app/src/test/java/com/example/voiceapp/ui/MainViewModelTest.kt @@ -0,0 +1,68 @@ +package com.example.voiceapp.ui + +import app.cash.turbine.test +import com.example.voiceapp.domain.ExecuteCommandUseCase +import com.example.voiceapp.domain.model.CommandId +import com.example.voiceapp.domain.model.CommandResult +import com.example.voiceapp.voice.ITextToSpeech +import com.example.voiceapp.voice.IVoiceRecognizer +import com.example.voiceapp.voice.TtsStatus +import com.example.voiceapp.voice.VoiceRecognitionState +import io.mockk.coEvery +import io.mockk.every +import io.mockk.mockk +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.test.StandardTestDispatcher +import kotlinx.coroutines.test.resetMain +import kotlinx.coroutines.test.runTest +import kotlinx.coroutines.test.setMain +import org.junit.After +import org.junit.Assert.assertEquals +import org.junit.Before +import org.junit.Test + +@OptIn(ExperimentalCoroutinesApi::class) +class MainViewModelTest { + private val testDispatcher = StandardTestDispatcher() + private val useCase: ExecuteCommandUseCase = mockk() + private val recognizer: IVoiceRecognizer = mockk(relaxed = true) + private val tts: ITextToSpeech = mockk(relaxed = true) + + private val recognizerState = MutableStateFlow(VoiceRecognitionState.Idle) + private val rmsDbState = MutableStateFlow(0f) + private val ttsStatusState = MutableStateFlow(TtsStatus.Idle) + + private lateinit var viewModel: MainViewModel + + @Before + fun setUp() { + Dispatchers.setMain(testDispatcher) + every { recognizer.state } returns recognizerState + every { recognizer.rmsDb } returns rmsDbState + every { tts.status } returns ttsStatusState + + viewModel = MainViewModel(useCase, recognizer, tts) + } + + @After + fun tearDown() { + Dispatchers.resetMain() + } + + @Test + fun processUserSpeech_updatesHistoryAndState() = runTest { + coEvery { useCase("今何時") } returns CommandResult(CommandId.GET_TIME_DATE, true, "現在は10時00分です。") + + viewModel.processUserSpeech("今何時") + testDispatcher.scheduler.advanceUntilIdle() + + viewModel.conversationHistory.test { + val history = awaitItem() + assertEquals(2, history.size) + assertEquals("今何時", history[0].text) + assertEquals("現在は10時00分です。", history[1].text) + } + } +} diff --git a/build.gradle.kts b/build.gradle.kts new file mode 100644 index 0000000..b7ee7ce --- /dev/null +++ b/build.gradle.kts @@ -0,0 +1,5 @@ +plugins { + id("com.android.application") version "8.2.2" apply false + id("org.jetbrains.kotlin.android") version "1.9.22" apply false + id("com.google.dagger.hilt.android") version "2.50" apply false +} diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..d0dabf0 --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,51 @@ +version: '3.8' + +services: + # 1. 動作シミュレーション (標準デモ実行) + simulation: + build: + context: . + dockerfile: Dockerfile + container_name: voice_app_simulation + command: ./gradlew :app:runSimulation --console=plain + volumes: + - .:/workspace + - gradle-cache:/root/.gradle + + # 2. 対話型シミュレーション (ターミナルから直接音声テキスト入力) + interactive: + build: + context: . + dockerfile: Dockerfile + container_name: voice_app_interactive + command: ./gradlew :app:runSimulation --args="--interactive" --console=plain + stdin_open: true + tty: true + volumes: + - .:/workspace + - gradle-cache:/root/.gradle + + # 3. 単体テスト実行 (JUnit/MockK/Coroutinesテスト) + test: + build: + context: . + dockerfile: Dockerfile + container_name: voice_app_test + command: ./gradlew test --console=plain + volumes: + - .:/workspace + - gradle-cache:/root/.gradle + + # 4. Android APK ビルド (app/build/outputs/apk/debug/ に出力) + build: + build: + context: . + dockerfile: Dockerfile + container_name: voice_app_build + command: ./gradlew assembleDebug --console=plain + volumes: + - .:/workspace + - gradle-cache:/root/.gradle + +volumes: + gradle-cache: diff --git a/docs/00_project_config.md b/docs/00_project_config.md new file mode 100644 index 0000000..820e74a --- /dev/null +++ b/docs/00_project_config.md @@ -0,0 +1,60 @@ +# プロジェクト構成定義書 (Project Configuration) + +## 1. プロジェクト基本情報 (Basic Information) +- **プロジェクト名**: Android 音声操作端末アプリ (Voice-Controlled Android App) +- **対象パス**: `/root/.gemini/antigravity-cli/scratch/voice_controlled_device` +- **開発種別**: 新規開発 (New Development) +- **対象デバイス / OS**: Android OS 10.0+ (API Level 29 以上推奨) +- **ハードウェア設計の有無**: なし (市販/標準Android端末の上で動作するソフトウェア開発。内蔵マイク・スピーカー・Bluetoothオーディオ機器等を使用) +- **主なユースケース**: ハンズフリーによる音声認識・コマンド入力、対話システム、音声読み上げ (TTS)、端末状態管理 + +--- + +## 2. 技術スタック (Tech Stack) +- **プログラミング言語**: Kotlin (v1.9+) +- **UIフレームワーク**: Jetpack Compose (宣言型UI) +- **音声認識 (Speech Recognition)**: `SpeechRecognizer` API / Android Voice Interaction API +- **音声合成 (Text-to-Speech)**: `TextToSpeech` (TTS) Engine +- **非同期処理・状態管理**: Kotlin Coroutines, StateFlow / SharedFlow +- **アーキテクチャモデル**: Clean Architecture + MVVM (Model-View-ViewModel) +- **依存関係注入 (DI)**: Hilt / Koin +- **データ保持・キャッシュ**: Room Database / DataStore Preferences + +--- + +## 3. テスト実行方式 & CI/CD (Testing & CI Execution) +- **テスト実行方式**: + - **ローカルテスト (Local Execution)**: + - 単体テスト: JUnit 5, MockK, Turbine (Flow/Coroutinesテスト) + - UI・結合テスト: Jetpack Compose Test Framework, Robolectric + - **CI実行環境 (CI Pipeline)**: + - Gitea Runner / GitHub Actions によるビルド自動化、静的解析 (ktlint/detekt)、単体テスト実行 +- **テストスコープ**: + - Domain / Data レイヤーのユニットテスト完全自動化 + - SpeechRecognizer / TTS ラッパーインターフェースのモックテスト + +--- + +## 4. 推奨ディレクトリ構成 (Directory Structure) +``` +voice_controlled_device/ +├── docs/ +│ ├── 00_project_config.md # 本構成定義ファイル +│ └── 02_specifications.md # 機能仕様書 +├── app/ +│ ├── src/ +│ │ ├── main/ +│ │ │ ├── java/com/example/voiceapp/ +│ │ │ │ ├── data/ # リポジトリ・ローカルデータソース +│ │ │ │ ├── domain/ # ユースケース・モデル定義 +│ │ │ │ ├── ui/ # Jetpack Compose UI / ViewModel +│ │ │ │ └── voice/ # SpeechRecognizer & TTS ラッパーモジュール +│ │ │ └── AndroidManifest.xml +│ │ └── test/ # 単体テストコード +└── build.gradle.kts +``` + +--- + +## 5. 次の工程への引継ぎ事項 +- 機能仕様書 (`docs/02_specifications.md`) の作成に進みます。 diff --git a/docs/02_specifications.md b/docs/02_specifications.md new file mode 100644 index 0000000..7a017af --- /dev/null +++ b/docs/02_specifications.md @@ -0,0 +1,277 @@ +# **システム機能・構成仕様書:Android Dockerビルド&Gitea CI/CD環境およびAI音声アシスタントアプリ (改訂完全統合版)** + +## **1. システム概要および運用モデル** + +### **1.1 背景と目的** + +本仕様書は、Android OS 8.0+ (API Level 26 以上 / Target API Level 34) を対象とした「ハンズフリーAI音声ナビゲーション連携アシスタントアプリ」の設計要件、およびDockerコンテナとGitea Runner (Act Runner) を活用した自動ビルド・CI/CDパイプライン構築要件を統合定義する。 + +運転中や作業中の手動操作を排除し、Bluetoothヘッドセットや画面タップによる音声入力、AIサーバーとの連携(JSON API)、TTS音声応答(リアクション)、ならびに NaviCon / Google Maps 経由での車載ナビ目的地自動転送を安全かつ低コストで実現する。 + +### **1.2 システム運用・開発モデル** + +1. **ローカル開発環境**: Docker Compose を用いて、ホスト環境に依存せずコンテナ内で Gradle ビルドおよび単体テストを実行可能とする。 +2. **CI/CD環境**: Gitea への Push / Pull Request をトリガーとして Gitea Runner 上で同一コンテナ環境を起動し、自動テストおよび APK/AAB の生成を行う。 +3. **エミュレータ・実機検証モデル**: Android Studio エミュレータ(Android 8.0〜14)での仮想動作確認を標準とし、最終動作を Bluetooth ヘッドセット(エレコム LBT-HSC41BK-EC 等)および車載ナビ連携環境にて検証する。 + +## **2. システム要件および技術スタック** + +### **2.1 動作環境・開発基盤** + +* **ターゲットプラットフォーム**: Linux (x86_64) コンテナ環境(ビルド用) +* **対象端末OS**: Android OS 8.0 (API Level 26) 以上推奨 / Target API Level 34 (Android 14) +* **開発言語・フレームワーク**: Kotlin 1.9+ / Jetpack Compose / Coroutines & Flow / Clean Architecture + MVVM + Hilt +* **コンテナ・CI/CD環境**: Docker Engine 20.10+ / Docker Compose V2 / Gitea Runner (Act Runner) +* **ビルドツール・JDK**: OpenJDK 17 (または AGP 互換 JDK 21) / Gradle (AGP 8.x 対応) + +### **2.2 必須権限およびサービス宣言 (AndroidManifest.xml)** + +* android.permission.RECORD_AUDIO (マイク音声入力) +* android.permission.INTERNET / ACCESS_NETWORK_STATE (AIサーバー通信・ネットワーク確認) +* android.permission.ACCESS_FINE_LOCATION / ACCESS_COARSE_LOCATION (GPS現在地情報付与) +* android.permission.MODIFY_AUDIO_SETTINGS (オーディオフォーカス・音量制御) +* android.permission.POST_NOTIFICATIONS (通知出力) +* android.permission.BIND_NOTIFICATION_LISTENER_SERVICE (未読メッセージ・通知本文取得用バインド権限) +* android.permission.SCHEDULE_EXACT_ALARM (アラーム・タイマー設定権限) +* android.permission.FOREGROUND_SERVICE / android.permission.FOREGROUND_SERVICE_MICROPHONE / android.permission.FOREGROUND_SERVICE_LOCATION (Android 14 / API 34 必須フォアグラウンドサービス権限) + +## **3. アプリ主要機能仕様** + +### **3.1 画面仕様 (Jetpack Compose UI)** + +シングルアクティビティ構成のレスポンシブなJetpack Composeレイアウトを採用する。 + +1. **ステータス表示エリア (Top / Hero Banner)** + * アプリの状態 (Idle, Listening, Processing, Speaking, Error) を色とアイコンで視覚表示。 + * Listening 時はマイク入力音量レベル (RmsdB) に同期する波形アニメーション (WaveformIndicator) を描画 (60fps)。 +2. **対話・コマンド履歴エリア (Center Scroll Area)** + * ユーザー発話テキストとシステム応答テキスト(目的地情報含む)をタイムスタンプ付きチャット風UI (LazyColumn) で表示。 +3. **音声操作コントロールエリア (Bottom Floating Area)** + * マイクボタン (FAB): タップにより音声認識の開始/停止をトグル。 + * クリアボタン: 会話履歴のリセット。 +4. **パーミッション誘導ダイアログ** + * マイク権限未許可時に許可リクエストを表示。永久拒否時は端末設定画面 (ACTION_APPLICATION_DETAILS_SETTINGS) への移行ダイアログを提示。 + +### **3.2 音声処理・オーディオ制御仕様** + +#### **3.2.1 起動トリガー仕様** + +* 常時マイク監視(WakeWord検知)によるバッテリー消費を防ぐため、トリガーを以下に一本化する。 + 1. 画面上のマイクボタンタップ + 2. Bluetoothヘッドセット (LBT-HSC41BK-EC 等) のボタン操作・メディアキーインテント (Intent.ACTION_MEDIA_BUTTON / ACTION_VOICE_COMMAND) +* **バックグラウンド起動制限 (Background Activity Launch restrictions) 対策**: + Android 10+ ではスリープ中・バックグラウンドからのダイレクト Activity 起動が制限されるため、常駐フォアグラウンドサービス (`VoiceAssistantService`) および `MediaSessionCompat` を併用する。Bluetooth メディアボタン押下時は `MediaSessionCompat` の Callback にてイベントを補捉し、フォアグラウンドサービス経由で音声認識および TTS 処理を開始する。 + +#### **3.2.2 音声認識モジュール (IVoiceRecognizer / VoiceRecognitionManager)** + +* **技術方式**: Android標準 android.speech.SpeechRecognizer のラッパーモジュール。 +* **インターフェース定義**: + interface IVoiceRecognizer { + val state: StateFlow + val rmsDb: StateFlow + fun startListening() + fun stopListening() + fun destroy() + } + +* **排他制御・フィードバック防止**: + * ITextToSpeech の状態が Speaking の間は startListening() を呼び出し不可とし、スピーカー音声の誤認識ループを防止。 + +#### **3.2.3 音声合成 (TTS) モジュール (ITextToSpeech / TextToSpeechManager)** + +* **技術方式**: Android標準 android.speech.tts.TextToSpeech のラッパーモジュール。 +* **状態管理の単一化**: + * 状態変化は val status: StateFlow に集約。speak() は Unit を返す。 + +sealed interface TtsStatus { + object Idle : TtsStatus + object Speaking : TtsStatus + object Completed : TtsStatus + data class Error(val message: String) : TtsStatus +} + +interface ITextToSpeech { + val status: StateFlow + fun speak(text: String, queueMode: Int = TextToSpeech.QUEUE_FLUSH) + fun stop() + fun destroy() +} + +* **オーディオフォーカス制御**: + * speak() 実行時に AudioManager.requestAudioFocus (AUDIOFOCUS_GAIN_TRANSIENT_MAY_DUCK) を呼び出し、他アプリの音量をミュート/減衰。発話完了 (UtteranceProgressListener.onDone) 時に abandonAudioFocus を呼出し解放する。 + +### **3.3 AIサーバー連携およびコマンドパース仕様** + +#### **3.3.1 AIサーバー通信インターフェース仕様 (JSON API)** + +* **エンドポイント**: POST /api/v1/navigate +* **認証ヘッダー**: `Authorization: Bearer ` または `X-API-Key: ` +* **通信タイムアウト & リトライ方針**: + * Connect Timeout: 5,000 ms / Read Timeout: 10,000 ms + * ネットワークエラー発生時は Exponential Backoff(初期遅延 1秒、倍率 2、最大 3 回試行)でリトライを実施。 +* **リクエスト構造 (App -> Server)**: + ```json + { + "user_id": "string", + "query_text": "仕事で行く〇〇会社に設定して", + "current_location": { + "latitude": 35.681236, + "longitude": 139.767125 + }, + "timestamp": 1700000000 + } + ``` + +* **成功レスポンス構造 (Server -> App / HTTP 200)**: + ```json + { + "status": "success", + "tts_message": "〇〇会社を目的地にセットします。", + "destination": { + "name": "〇〇会社 本社", + "address": "東京都千代田区丸の内1-1-1", + "latitude": 35.681236, + "longitude": 139.767125 + } + } + ``` + +* **エラーレスポンス構造 (Server -> App / HTTP 400, 404, 500)**: + ```json + { + "status": "error", + "error_code": "LOCATION_NOT_FOUND", + "message": "該当する目的地を見つけることができませんでした。" + } + ``` + +#### **3.3.2 漢数字・表記揺れコンバータ (KanjiToNumberConverter)** + +* 正規表現マッチングの前に、入力テキスト内の漢数字(「一」「二」「三分」「七時」等)および全角数字をアラビア数字(「1」「2」「3分」「7時」)へ事前変換する処理を実行する。 + +#### **3.3.3 コマンドパターンおよびパラメータ抽出定義** + +| コマンド識別子 | 正規表現パターン (前処理後) | 抽出パラメータキー | 実行処理・連携内容 | 音声応答メッセージ例 | +| :---- | :---- | :---- | :---- | :---- | +| NAVIGATE_AI | `.*(ナビ \| 行きたい \| 向かう \| セット).*` | query_text | AIサーバー通信 & NaviCon転送 | tts_message (レスポンス依存) | +| GET_TIME_DATE | `.*(今何時 \| 何時ですか \| 日付 \| 今日は何日).*` | なし | システム日時取得 | 「現在は14時30分です」 | +| SET_ALARM_TIMER | `.*((\d+)\s*(分 \| 秒 \| 時間)タイマー \| (\d+)時にアラーム).*` | timer_val, unit / alarm_hour | アラーム・タイマーインテント | 「5分タイマーをセットしました」 | +| CHANGE_SETTINGS | `.*(音量を \| ダークモード \| ライトモード).*` | setting_type, value | 設定変更 | 「音量を変更しました」 | +| READ_MESSAGES | `.*(メッセージ \| 未読 \| 通知).*` | なし | AppNotificationListenerService 連携 | 「未読メッセージは〇件です」 | +| GET_SYSTEM_INFO | `.*(バッテリー \| 電池 \| Wi-Fi \| ネットワーク).*` | info_type | バッテリー/通信状態取得 | 「バッテリー残量は80%です」 | +| UNKNOWN_FALLBACK | 上記いずれにも未マッチ | なし | ガイダンス応答 | 「申し訳ありません。よく理解できませんでした。」 | + +### **3.4 外部ナビ連携仕様 (NaviCon & Google Maps)** + +1. **NaviCon連携 (メイン軸 / 完全無料)**: + * AIサーバーから目的地の緯度・経度・地点名を受信後、URLスキームを発行して起動。 + * 地点名 (`name`) パラメータは必ず UTF-8 URL エンコード(`URLEncoder.encode(name, "UTF-8")`)を行う。 + * フォーマット: `navicon://point?ll={latitude},{longitude}&title={encoded_name}` +2. **Google Maps連携 (フォールバック / 完全無料)**: + * NaviCon未インストール時またはユーザー選択時、標準Intent(`geo:{latitude},{longitude}?q={encoded_name}`)を発行して起動。 +3. **未インストール時の自動フォールバック順序**: + * パッケージ管理 (`PackageManager.getPackageInfo("jp.co.denso.navicon.user", 0)`) により NaviCon インストール状態を確認する。 + * (1) NaviCon が検出可能 -> NaviCon Intent を発行。 + * (2) NaviCon 未検出 且つ Google Maps が利用可能 -> 音声で「NaviConが未検出のため、Google Mapsで表示します」とアナウンスし、Google Maps Intent を発行。 + * (3) いずれも未検出時 -> Playストアの NaviCon ページ (`market://details?id=jp.co.denso.navicon.user`) を起動。 + +### **3.5 未読メッセージ・通知取得サービスおよびフォアグラウンドサービス仕様** + +1. **AppNotificationListenerService (未読メッセージ読み上げ)**: + * 他アプリの通知本文を読み上げるため、NotificationListenerService を継承し AndroidManifest.xml に定義する。 +2. **VoiceAssistantService (常駐フォアグラウンドサービス)**: + * Android 14 (API 34) 準拠のフォアグラウンドサービスとして定義し、`foregroundServiceType="microphone|location"` を付与する。 + * `AndroidManifest.xml` 定義例: + ```xml + + + + + + + ``` + +## **4. 状態遷移設計 (State Machine Design)** + +### **4.1 アプリケーション状態モデル (VoiceAppState)** + +sealed interface VoiceAppState { + object Idle : VoiceAppState + data class Listening(val rmsDb: Float = 0f) : VoiceAppState + object Processing : VoiceAppState + data class Speaking(val text: String) : VoiceAppState + data class Error(val message: String, val errorCode: Int? = null) : VoiceAppState +} + +### **4.2 状態遷移およびエラー時 3秒自動復帰制御** + +* MainViewModel において、VoiceAppState.Error へ遷移した場合は viewModelScope.launch 内で delay(3000) を実行し、ユーザーの手動操作なしで3秒後に自動的に VoiceAppState.Idle へ安全に復帰させる。 + +stateDiagram-v2 + [*] --> Idle + + Idle --> Listening : マイクタップ / Bluetoothボタン + Listening --> Processing : 音声入力完了 (onResults) + Listening --> Error : 認識エラー / タイムアウト (onError) + Listening --> Idle : キャンセル操作 + + Processing --> Speaking : AI応答 / コマンド解析成功 (TTS開始) + Processing --> Error : AI通信エラー / 解析失敗 + + Speaking --> Idle : TTS再生完了 (onDone) + Speaking --> Error : TTS出力エラー + + Error --> Idle : 3秒経過による自動復帰 (delay 3000ms) + +## **5. ディレクトリ構造および成果物定義** + +### **5.1 プロジェクトディレクトリ構造** + +. +├── .gitea/ +│ └── workflows/ +│ └── build.yaml # Gitea Runner 用 CI/CD ワークフロー定義ファイル +├── docker/ +│ ├── Dockerfile # Android ビルド環境定義 Dockerfile (Ubuntu 22.04 + OpenJDK 17 + Android SDK) +│ └── entrypoint.sh # 権限・初期化処理スクリプト +├── app/ # Android アプリソースコード (Kotlin / Jetpack Compose) +│ ├── build.gradle.kts +│ └── src/ +│ ├── main/ +│ │ ├── java/com/example/voiceapp/ +│ │ │ ├── VoiceApplication.kt +│ │ │ ├── MainActivity.kt +│ │ │ ├── di/ # Hilt DI モジュール (VoiceModule, DomainModule) +│ │ │ ├── domain/ # UseCases, CommandParser, KanjiToNumberConverter +│ │ │ │ ├── model/ # CommandId, CommandMatchResult, CommandResult +│ │ │ │ ├── executor/ # NavigateAiExecutor, GetTimeDateExecutor, etc. +│ │ │ │ └── converter/ # KanjiToNumberConverter +│ │ │ ├── voice/ # SpeechRecognizer / TTS ラッパー +│ │ │ ├── service/ # VoiceAssistantService, AppNotificationListenerService +│ │ │ └── ui/ # Jetpack Compose UI / ViewModels +│ │ └── AndroidManifest.xml +│ └── test/ # ユニットテスト (JUnit 5, MockK, Turbine) +├── docker-compose.yml # ローカル環境用 Docker 実行定義ファイル +└── README.md # 仕様書・環境構築/実行マニュアル + +### **5.2 Gitea CI/CD 実行モデル仕様** + +* **Runner 構成**: Gitea Act Runner の Docker Runner モデルを採用。 +* **実行環境**: Push / Pull Request 発生時、Runner 上で `docker/Dockerfile` にて生成されるコンテナイメージを起動。 +* **自動パイプライン**: コンテナ内で `./gradlew testDebugUnitTest` (単体テスト) および `./gradlew assembleDebug` (Debug APK 生成) を逐次実行し、ビルド成果物をアーティファクトとして保存する。 + +## **6. 受入・評価基準 (Acceptance Criteria)** + +1. **CI/CDパイプラインビルドの成功**: Gitea Runner 上で ./gradlew testDebugUnitTest および ./gradlew assembleDebug が正常終了し、エラーなくAPKが出力されること。 +2. **単体テスト網羅性**: + * KanjiToNumberConverterTest: 漢数字(「三分」「7時」「15分」等)の変換正確性を検証。 + * CommandParserTest: アラーム、タイマー、NAVIGATE_AI 含む全コマンドの正規表現パラメータ抽出を検証。 + * MainViewModelTest: Turbine を用い、正常系フローおよび Error 遷移から3秒後に自動 Idle 復帰する非同期状態遷移を検証。 +3. **堅牢性とエラーハンドリング**: モック応答環境において、Listening -> Processing -> Speaking -> Idle への単方向フローが一切クラッシュなく完了し、NaviCon未インストール時もGoogle Mapsへ安全にフォールバックすること。 diff --git a/docs/03_hardware_design.md b/docs/03_hardware_design.md new file mode 100644 index 0000000..3b1c908 --- /dev/null +++ b/docs/03_hardware_design.md @@ -0,0 +1,957 @@ +# ハードウェア・システム詳細設計書 (Voice-Controlled Android App) + +## 1. 使用部品・コンポーネント一覧 + +### 1.1 ハードウェア・コンポーネント構成 (Hardware System Layer) +1. **メイン演算・制御ユニット (SoC / Core Processor)** + - 対象端末: Android OS 8.0+ (API Level 26 以上 / Target API Level 34) 搭載の標準 Android 端末 / モジュール (クアッドコア ARM64 2.0GHz 以上推奨) + - RAM: 3GB 以上 + - ROM/ストレージ: 32GB 以上 (アプリ本体・対話ログ・ローカルモデル保持用) +2. **音声入力モジュール (Audio Input Device)** + - 内蔵マイクアレイ (Dual/Multi-Microphone Array) または Bluetooth 外部 HFP/A2DP マイク (エレコム LBT-HSC41BK-EC 等) + - 入力仕様: PCM 16kHz / 16-bit モノラル (Android 音声認識 `SpeechRecognizer` 前処理入力) +3. **音声出力モジュール (Audio Output Device)** + - 内蔵スピーカー / D級オーディオアンプ または Bluetooth 外部 A2DP スピーカー / 車載ナビゲーションシステム + - 出力仕様: PCM 44.1kHz / 48kHz 16-bit ステレオ (Android `TextToSpeech` 合成出力) +4. **表示・操作インターフェース (Display & Touch Controller)** + - ディスプレイ: 5インチ〜10インチ静電容量式タッチパネル (1080x1920 解像度等) + - フィードバック: GPU アクセラレーション対応 (Jetpack Compose 60fps 描画) +5. **通信モジュール (Network Communications)** + - Wi-Fi (802.11 a/b/g/n/ac) / LTE (4G/5G) セルラー通信モジュール (AI サーバー REST API 通信用) +6. **外部入力デバイス (Bluetooth Media Control Button)** + - Bluetooth ヘッドセットボタン / リモートメディアキー (AVRCP プロファイル / `Intent.ACTION_MEDIA_BUTTON` / `ACTION_VOICE_COMMAND`) + +### 1.2 ソフトウェア・アーキテクチャ概要 (Clean Architecture + MVVM + Foreground Service) +本システムは Clean Architecture, MVVM (Model-View-ViewModel), および常駐 Foreground Service + `MediaSessionCompat` を組み合わせたアーキテクチャ構造を採用する。レイヤー間の依存関係を一方向 (UI/Service -> Domain <- Voice Engine/Network Layer) に制限し、単体テスト可能性およびバックグラウンド実行時の起動制御・堅牢性を保証する。 + +- **UI / Presentation Layer**: Jetpack Compose によるシングルアクティビティ型 UI、`MainViewModel` による状態管理と `VoiceAppState` (Sealed Interface) に基づく単方向データフロー (UDF)。エラー発生時は `delay(3000)` による 3秒後自動 Idle 復帰制御を実施。 +- **Domain Layer**: 純粋 Kotlin によるコアビジネスロジック。`KanjiToNumberConverter` (前処理コンバータ)、`ExecuteCommandUseCase`、`CommandParser`、`CommandDefinition`、`CommandId` (`NAVIGATE_AI` 含む)、および各種 `ICommandExecutor`。 +- **Voice Engine Layer**: Android プラットフォーム固有 API のラッパーモジュール。`IVoiceRecognizer` (`VoiceRecognitionManager`)、`ITextToSpeech` (`TextToSpeechManager`)。`ITextToSpeech.speak()` は `Unit` を返し `StateFlow` で一元状態管理。 +- **Network Layer**: Retrofit 2 + OkHttp 4 による AI サーバー REST API クライアント (`AiApiService`)。認証ヘッダー、タイムアウト設定、Exponential Backoff 自動リトライロジックを保持。 +- **Service / Background Layer**: Android 14 (API 34) 準拠の常駐フォアグラウンドサービス `VoiceAssistantService` (`foregroundServiceType="microphone|location"`) および `MediaSessionCompat` による Bluetooth メディアボタン捕捉・バックグラウンド起動制限 (BAL) 回避モジュール。および `AppNotificationListenerService` (未読メッセージ取得)。 + +### 1.3 ソフトウェアコンポーネント・モジュール詳細 +#### 1.3.1 Voice Engine Layer +- **`IVoiceRecognizer`**: `android.speech.SpeechRecognizer` のラッパー抽象インターフェース。音声入力の開始/停止、認識結果 (Flow/Callback)、マイク音量 (RmsdB) のリアルタイム更新を管理。 +- **`VoiceRecognitionManager`**: `IVoiceRecognizer` の実装クラス。`RecognitionListener` を内部リスナーとして保持。 +- **`ITextToSpeech`**: `android.speech.tts.TextToSpeech` のラッパー抽象インターフェース。`speak(text: String, queueMode: Int = TextToSpeech.QUEUE_FLUSH): Unit` のシグネチャを持ち、戻り値型を `Unit` に統一。 +- **`TextToSpeechManager`**: `ITextToSpeech` の実装クラス。`UtteranceProgressListener` を用いて `Speaking` から `Idle` への状態遷移を管理。 + +#### 1.3.2 Network Layer (AI API Communication) +- **`AiApiService`**: Retrofit 2 インターフェース。POST `/api/v1/navigate` を提供。 +- **データモデル**: + - `AiApiRequest`: `user_id`, `query_text`, `current_location` (latitude, longitude), `timestamp` + - `AiApiResponse`: `status` ("success"), `tts_message`, `destination` (name, address, latitude, longitude) + - `AiApiErrorResponse`: `status` ("error"), `error_code`, `message` +- **通信信頼性設計**: + - 認証ヘッダー: `Authorization: Bearer ` または `X-API-Key: ` + - タイムアウト: Connect Timeout 5,000ms / Read Timeout 10,000ms + - 自動リトライ: Exponential Backoff (初期遅延 1,000ms, 倍率 2.0, 最大 3 回試行) + +#### 1.3.3 Domain Layer +- **`KanjiToNumberConverter`**: `domain/converter/KanjiToNumberConverter` に配置。正規表現パース前の事前処理として「一」「二」「三分」「七時」等の漢数字および全角数字を「1」「2」「3分」「7時」等のアラビア数字に前処理変換。 +- **`CommandId`**: コマンド識別子 Enum (`GET_TIME_DATE`, `SET_ALARM_TIMER`, `CHANGE_SETTINGS`, `READ_MESSAGES`, `GET_SYSTEM_INFO`, `NAVIGATE_AI`, `UNKNOWN_FALLBACK`)。 +- **`CommandParser`**: 事前変換後のテキストを入力とし、正規表現マッチングおよびパラメータ抽出を実行。 +- **`ExecuteCommandUseCase`**: コマンド解析と実行フローの制御。 +- **`ICommandExecutor` & 実装クラス**: + - `GetTimeDateExecutor`: システム日時の取得。 + - `SetAlarmTimerExecutor`: アラーム・タイマーのインテント発行。 + - `ChangeSettingsExecutor`: 設定変更。 + - `ReadMessagesExecutor`: NotificationListener 連携による未読メッセージ取得。 + - `GetSystemInfoExecutor`: バッテリー/通信状態の取得。 + - `NavigateAiExecutor`: AI サーバー API 通信を行い、目的地の取得後に NaviCon (`navicon://point...`) URL スキーム生成 (UTF-8 URLエンコード `URLEncoder.encode(name, "UTF-8")` 適用) および Google Maps / Play Store への段階的フォールバックを実行。 + - `UnknownFallbackExecutor`: ガイダンス応答。 + +#### 1.3.4 Service Layer & Background Launch +- **`VoiceAssistantService`**: `foregroundServiceType="microphone|location"` 指定の常駐 Service。通知バーにインジケータを常駐表示し、バックグラウンドでの音声認識・TTS実行基盤となる。 +- **`MediaSessionCompat` & Callback**: Bluetooth メディアボタン (`Intent.ACTION_MEDIA_BUTTON` / `ACTION_VOICE_COMMAND`) を `MediaSessionCompat` の KeyEvent リスナーで直接受託。Android 10+ の Background Activity Launch Restrictions (バックグラウンド起動制限) を回避し、常駐 Service 内から音声認識を即座に開始。 +- **`AppNotificationListenerService`**: `android.permission.BIND_NOTIFICATION_LISTENER_SERVICE` を持つ NotificationListenerService。他アプリの通知本文から未読メッセージを取得。 + +#### 1.3.5 UI / Presentation Layer +- **`MainViewModel`**: アプリ状態の統合プロセスマネージャー。`VoiceAppState.Error` 遷移時には `viewModelScope` 内で `delay(3000)` を実行し、3秒後に自動で `VoiceAppState.Idle` へ無害に復帰させる。 +- **`VoiceAppState`**: `Idle`, `Listening(val rmsDb: Float)`, `Processing`, `Speaking(val text: String)`, `Error(val message: String, val errorCode: Int?)` +- **Compose Components**: `MainScreen`, `StatusBanner`, `WaveformIndicator`, `ConversationHistory`, `ControlBar`, `PermissionDialog` + +## 2. ピン配置・インターフェース定義 (GPIO, SPI, I2C, UART, 電圧レベル等) + +### 2.1 物理・論理インターフェース & バス・信号レベル定義 + +| インターフェース名 | 物理/論理区分 | 信号規格・データ形式 | 物理電圧 / バスレベル | 制御・連携方式 | +|---|---|---|---|---| +| **マイク音声入力バス (Audio In)** | 物理 / 論理 | PCM 16kHz, 16-bit, Mono | 1.8V / 3.3V (ADC / I2S 内蔵バス) | `AudioRecord` / `SpeechRecognizer` API via Binder IPC | +| **スピーカー音声出力バス (Audio Out)** | 物理 / 論理 | PCM 44.1kHz / 48kHz, 16-bit, Stereo | D級アンプ駆動 / DAC 出力 | `AudioTrack` / `TextToSpeech` API via AudioFlinger | +| **タッチパネル / ディスプレイ (I/O)** | 物理 | MIPI DSI / SPI / I2C (Touch) | 1.8V / 3.3V GPIO | Android Input subsystem & SurfaceFlinger / Compose GPU | +| **AI サーバー REST API (Network IPC)** | 論理 | HTTPS / JSON (POST /api/v1/navigate) | TCP/IP Port 443 (Wi-Fi/Cellular) | Retrofit 2 + OkHttp 4 (Auth Header, 5s/10s Timeout, Exp. Backoff) | +| **NaviCon / Google Maps / Play Store IPC** | 論理 | Android Intent / URL Scheme (`navicon://point...`, `geo:...`, `market://...`) | OS Binder IPC / Intent Subsystem | `URLEncoder.encode(name, "UTF-8")` 適用 Intent 発行 & パッケージ検出 | +| **Bluetooth メディアボタン (AVRCP)** | 物理 / 論理 | Bluetooth HID/AVRCP (`ACTION_MEDIA_BUTTON`) | 2.4GHz RF (Bluetooth HFP/A2DP) | `MediaSessionCompat.Callback` キーハンドリング via `VoiceAssistantService` | +| **位置情報 API (GPS / Location)** | 論理 | Android Location Manager / FusedLocationProviderClient | OS Binder IPC | `ACCESS_FINE_LOCATION` / `ACCESS_COARSE_LOCATION` | +| **アラーム・タイマー API (AlarmManager)** | 論理 | Android AlarmManager | OS Binder IPC | `SCHEDULE_EXACT_ALARM` | +| **通知受託 API (NotificationListener)** | 論理 | NotificationListenerService IPC | OS Binder IPC | `BIND_NOTIFICATION_LISTENER_SERVICE` | + +### 2.2 クラス構造と抽象インターフェース設計 (Kotlin / Clean Architecture) + +#### 2.2.1 クラス図 (Mermaid Diagram) +```mermaid +classDiagram + namespace UI_Layer { + class MainScreen { + +ComposableContent() + } + class MainViewModel { + -ExecuteCommandUseCase executeCommandUseCase + -IVoiceRecognizer voiceRecognizer + -ITextToSpeech textToSpeech + +StateFlow~VoiceAppState~ uiState + +StateFlow~List~ChatMessage~~ conversationHistory + +onMicButtonClicked() + +onPermissionGranted() + +clearHistory() + } + class VoiceAppState { + <> + } + } + + namespace Domain_Layer { + class ExecuteCommandUseCase { + -KanjiToNumberConverter kanjiConverter + -CommandParser commandParser + +invoke(String text) Flow~CommandResult~ + } + class KanjiToNumberConverter { + +convert(String text) String + } + class CommandParser { + -List~CommandDefinition~ commands + +parse(String text) CommandMatchResult + } + class CommandDefinition { + +CommandId id + +List~Regex~ patterns + +ICommandExecutor executor + } + class CommandId { + <> + GET_TIME_DATE + SET_ALARM_TIMER + CHANGE_SETTINGS + READ_MESSAGES + GET_SYSTEM_INFO + NAVIGATE_AI + UNKNOWN_FALLBACK + } + class ICommandExecutor { + <> + +execute(CommandMatchResult match) CommandResult + } + class NavigateAiExecutor { + -AiApiService apiService + -Context context + +execute(CommandMatchResult match) CommandResult + } + } + + namespace Voice_Engine_Layer { + class IVoiceRecognizer { + <> + +StateFlow~VoiceRecognitionState~ state + +StateFlow~Float~ rmsDb + +startListening() + +stopListening() + +destroy() + } + class VoiceRecognitionManager { + -SpeechRecognizer speechRecognizer + } + class ITextToSpeech { + <> + +StateFlow~TtsStatus~ status + +speak(String text, Int queueMode) Unit + +stop() + +destroy() + } + class TextToSpeechManager { + -TextToSpeech ttsEngine + } + } + + namespace Network_Layer { + class AiApiService { + <> + +navigate(AiApiRequest request) AiApiResponse + } + class AiApiRequest { + +String userId + +String queryText + +LocationData currentLocation + +Long timestamp + } + class AiApiResponse { + +String status + +String ttsMessage + +DestinationData destination + } + } + + namespace Service_Layer { + class VoiceAssistantService { + -MediaSessionCompat mediaSession + +onStartCommand() + } + class AppNotificationListenerService { + +onNotificationPosted() + } + } + + MainScreen ..> MainViewModel : Observe / Event + MainViewModel --> ExecuteCommandUseCase : Invoke + MainViewModel --> IVoiceRecognizer : Control + MainViewModel --> ITextToSpeech : Control + ExecuteCommandUseCase --> KanjiToNumberConverter : Pre-process + ExecuteCommandUseCase --> CommandParser : Use + CommandParser --> CommandDefinition : Contains + CommandDefinition --> ICommandExecutor : Delegates + ICommandExecutor <|.. NavigateAiExecutor : Implements + NavigateAiExecutor --> AiApiService : Call API + IVoiceRecognizer <|.. VoiceRecognitionManager : Implements + ITextToSpeech <|.. TextToSpeechManager : Implements + VoiceAssistantService --> IVoiceRecognizer : Trigger +``` + +#### 2.2.2 抽象インターフェース&データモデル定義 (Kotlin コード詳細) + +##### 1. Voice Engine Layer インターフェース (`speak()` 戻り値 Unit 統一) +```kotlin +package com.example.voiceapp.voice + +import kotlinx.coroutines.flow.StateFlow + +sealed interface VoiceRecognitionState { + object Idle : VoiceRecognitionState + object Ready : VoiceRecognitionState + object Listening : VoiceRecognitionState + data class Success(val recognizedText: String) : VoiceRecognitionState + data class Error(val errorCode: Int, val message: String) : VoiceRecognitionState +} + +interface IVoiceRecognizer { + val state: StateFlow + val rmsDb: StateFlow + + fun startListening() + fun stopListening() + fun destroy() +} + +sealed interface TtsStatus { + object Idle : TtsStatus + object Speaking : TtsStatus + object Completed : TtsStatus + data class Error(val message: String) : TtsStatus +} + +interface ITextToSpeech { + val status: StateFlow + + fun speak(text: String, queueMode: Int = 0): Unit + fun stop() + fun destroy() +} +``` + +##### 2. Network Layer API 定義 & データ構造 +```kotlin +package com.example.voiceapp.data.api + +import retrofit2.http.Body +import retrofit2.http.Header +import retrofit2.http.POST + +data class LocationData( + val latitude: Double, + val longitude: Double +) + +data class AiApiRequest( + val user_id: String, + val query_text: String, + val current_location: LocationData?, + val timestamp: Long +) + +data class DestinationData( + val name: String, + val address: String, + val latitude: Double, + val longitude: Double +) + +data class AiApiResponse( + val status: String, + val tts_message: String, + val destination: DestinationData? +) + +data class AiApiErrorResponse( + val status: String, + val error_code: String, + val message: String +) + +interface AiApiService { + @POST("api/v1/navigate") + suspend fun navigate( + @Header("Authorization") authHeader: String, + @Body request: AiApiRequest + ): AiApiResponse +} +``` + +##### 3. Domain Layer インターフェース、漢数字コンバータ、コマンド構造 +```kotlin +package com.example.voiceapp.domain.converter + +class KanjiToNumberConverter { + private val kanjiMap = mapOf( + '零' to '0', '一' to '1', '二' to '2', '三' to '3', '四' to '4', + '五' to '5', '六' to '6', '七' to '7', '八' to '8', '九' to '9', + '0' to '0', '1' to '1', '2' to '2', '3' to '3', '4' to '4', + '5' to '5', '6' to '6', '7' to '7', '8' to '8', '9' to '9' + ) + + fun convert(input: String): String { + val sb = StringBuilder() + for (char in input) { + val replaced = kanjiMap[char] + if (replaced != null) { + sb.append(replaced) + } else { + sb.append(char) + } + } + return sb.toString() + } +} +``` + +```kotlin +package com.example.voiceapp.domain.model + +enum class CommandId { + GET_TIME_DATE, + SET_ALARM_TIMER, + CHANGE_SETTINGS, + READ_MESSAGES, + GET_SYSTEM_INFO, + NAVIGATE_AI, + UNKNOWN_FALLBACK +} + +data class CommandMatchResult( + val commandId: CommandId, + val matchedPattern: String, + val extractedParameters: Map, + val rawText: String +) + +data class CommandResult( + val commandId: CommandId, + val isSuccess: Boolean, + val responseText: String, + val data: Map? = null +) + +interface ICommandExecutor { + suspend fun execute(matchResult: CommandMatchResult): CommandResult +} + +data class CommandDefinition( + val id: CommandId, + val patterns: List, + val executor: ICommandExecutor +) +``` + +```kotlin +package com.example.voiceapp.domain + +import com.example.voiceapp.domain.converter.KanjiToNumberConverter +import com.example.voiceapp.domain.model.* + +class CommandParser(private val definitions: List) { + fun parse(text: String): CommandMatchResult { + for (def in definitions) { + for (pattern in def.patterns) { + val match = pattern.find(text) + if (match != null) { + val params = match.groups.mapIndexedNotNull { index, group -> + if (index > 0 && group != null) "param_$index" to group.value else null + }.toMap() + return CommandMatchResult(def.id, pattern.pattern, params, text) + } + } + } + return CommandMatchResult(CommandId.UNKNOWN_FALLBACK, "", emptyMap(), text) + } +} + +class ExecuteCommandUseCase( + private val kanjiConverter: KanjiToNumberConverter, + private val commandParser: CommandParser +) { + suspend operator fun invoke(inputText: String): CommandResult { + val normalizedText = kanjiConverter.convert(inputText) + val matchResult = commandParser.parse(normalizedText) + return matchResult.executor.execute(matchResult) + } +} +``` + +##### 4. Presentation Layer 状態・メッセージモデル +```kotlin +package com.example.voiceapp.ui + +import com.example.voiceapp.domain.model.CommandId + +sealed interface VoiceAppState { + object Idle : VoiceAppState + data class Listening(val rmsDb: Float = 0f) : VoiceAppState + object Processing : VoiceAppState + data class Speaking(val text: String) : VoiceAppState + data class Error(val message: String, val errorCode: Int? = null) : VoiceAppState +} + +enum class MessageSender { USER, SYSTEM } + +data class ChatMessage( + val id: String = java.util.UUID.randomUUID().toString(), + val sender: MessageSender, + val text: String, + val timestamp: Long = System.currentTimeMillis(), + val commandId: CommandId? = null +) +``` + +#### 2.2.3 依存性注入 (DI: Hilt Framework Design) +```kotlin +package com.example.voiceapp.di + +import android.content.Context +import com.example.voiceapp.data.api.AiApiService +import com.example.voiceapp.domain.* +import com.example.voiceapp.domain.converter.KanjiToNumberConverter +import com.example.voiceapp.domain.executor.* +import com.example.voiceapp.domain.model.* +import com.example.voiceapp.voice.* +import dagger.Module +import dagger.Provides +import dagger.hilt.InstallIn +import dagger.hilt.android.qualifiers.ApplicationContext +import dagger.hilt.components.SingletonComponent +import okhttp3.OkHttpClient +import retrofit2.Retrofit +import retrofit2.converter.gson.GsonConverterFactory +import java.util.concurrent.TimeUnit +import javax.inject.Singleton + +@Module +@InstallIn(SingletonComponent::class) +object VoiceModule { + + @Provides + @Singleton + fun provideVoiceRecognizer( + @ApplicationContext context: Context + ): IVoiceRecognizer = VoiceRecognitionManager(context) + + @Provides + @Singleton + fun provideTextToSpeech( + @ApplicationContext context: Context + ): ITextToSpeech = TextToSpeechManager(context) +} + +@Module +@InstallIn(SingletonComponent::class) +object NetworkModule { + + @Provides + @Singleton + fun provideOkHttpClient(): OkHttpClient { + return OkHttpClient.Builder() + .connectTimeout(5000, TimeUnit.MILLISECONDS) + .readTimeout(10000, TimeUnit.MILLISECONDS) + .addInterceptor { chain -> + var request = chain.request() + val apiKey = "YOUR_API_KEY_HERE" + request = request.newBuilder() + .header("Authorization", "Bearer $apiKey") + .build() + + // Exponential Backoff Retry (Max 3 Tries) + var response = chain.proceed(request) + var tryCount = 0 + var backoffDelay = 1000L + while (!response.isSuccessful && tryCount < 3) { + tryCount++ + Thread.sleep(backoffDelay) + backoffDelay *= 2 + response.close() + response = chain.proceed(request) + } + response + } + .build() + } + + @Provides + @Singleton + fun provideAiApiService(okHttpClient: OkHttpClient): AiApiService { + return Retrofit.Builder() + .baseUrl("https://api.example.com/") + .client(okHttpClient) + .addConverterFactory(GsonConverterFactory.create()) + .build() + .create(AiApiService::class.java) + } +} + +@Module +@InstallIn(SingletonComponent::class) +object DomainModule { + + @Provides + @Singleton + fun provideKanjiToNumberConverter(): KanjiToNumberConverter { + return KanjiToNumberConverter() + } + + @Provides + @Singleton + fun provideNavigateAiExecutor( + @ApplicationContext context: Context, + apiService: AiApiService + ): NavigateAiExecutor { + return NavigateAiExecutor(context, apiService) + } + + @Provides + @Singleton + fun provideCommandDefinitions( + @ApplicationContext context: Context, + navigateAiExecutor: NavigateAiExecutor + ): List { + return listOf( + CommandDefinition( + id = CommandId.NAVIGATE_AI, + patterns = listOf(Regex(".*(ナビ|行きたい|向かう|セット).*")), + executor = navigateAiExecutor + ), + CommandDefinition( + id = CommandId.GET_TIME_DATE, + patterns = listOf(Regex(".*(今何時|何時ですか|日付|今日は何日).*")), + executor = GetTimeDateExecutor() + ), + CommandDefinition( + id = CommandId.SET_ALARM_TIMER, + patterns = listOf(Regex(".*(\\d+)\\s*(分|秒|時間)タイマー.*|.*(\\d+)時にアラーム.*")), + executor = SetAlarmTimerExecutor(context) + ), + CommandDefinition( + id = CommandId.CHANGE_SETTINGS, + patterns = listOf(Regex(".*(音量を|ダークモード|ライトモード).*")), + executor = ChangeSettingsExecutor(context) + ), + CommandDefinition( + id = CommandId.READ_MESSAGES, + patterns = listOf(Regex(".*(メッセージ|未読|通知).*")), + executor = ReadMessagesExecutor(context) + ), + CommandDefinition( + id = CommandId.GET_SYSTEM_INFO, + patterns = listOf(Regex(".*(バッテリー|電池|Wi-Fi|ネットワーク).*")), + executor = GetSystemInfoExecutor(context) + ), + CommandDefinition( + id = CommandId.UNKNOWN_FALLBACK, + patterns = emptyList(), + executor = UnknownFallbackExecutor() + ) + ) + } + + @Provides + @Singleton + fun provideCommandParser(definitions: List): CommandParser { + return CommandParser(definitions) + } + + @Provides + @Singleton + fun provideExecuteCommandUseCase( + kanjiConverter: KanjiToNumberConverter, + parser: CommandParser + ): ExecuteCommandUseCase { + return ExecuteCommandUseCase(kanjiConverter, parser) + } +} +``` + +## 3. 回路・構造・システムの注意事項 + +### 3.1 物理・ハードウェア、電力、オーディオ、ネットワーク注意事項 + +1. **マイクフィードバック (AEC) & オーディオフォーカス制御** + - TTS 発話中のスピーカー音量をマイクが拾うループを完全防止するため、`ITextToSpeech` の `status` が `Speaking` の間は `IVoiceRecognizer` の `startListening()` 呼び出しをシステムレベルでロックする。 + - `speak()` 呼出時に `AudioManager.requestAudioFocus(AUDIOFOCUS_GAIN_TRANSIENT_MAY_DUCK)` を要求し、他アプリの音量を自動ミュート/減衰させる。発話完了 (`onDone`) 時に `abandonAudioFocus()` を実行する。 + +2. **バッテリー消費抑制 & Bluetooth バックグラウンド起動制御 (BAL 対策)** + - Android 10+ (API 29+) のスリープ中・バックグラウンドからの Activity 起動制限 (Background Activity Launch restrictions) に対応するため、`VoiceAssistantService` を常駐 Foreground Service (`foregroundServiceType="microphone|location"`) として動作させる。 + - Bluetooth ヘッドセットボタン (`Intent.ACTION_MEDIA_BUTTON`) 押下時は `MediaSessionCompat` の KeyEventListener でイベントを直接受信し、Service 内でマイク録音・音声認識を開始する。不要な Activity ダイレクト起動を行わないことで制限を回避し、かつ常時マイク監視を行わないため CPU の省電力サスペンドを維持する。 + +3. **通信信頼性・エラーハンドリング (Exponential Backoff & タイムアウト)** + - モバイル回線接続の不安定性に備え、REST API 通信には Connect Timeout 5,000ms, Read Timeout 10,000ms を設定。 + - API 通信失敗時は Exponential Backoff (初期遅延 1,000ms, 倍率 2, 最大 3 回) にて自動リトライ。失敗継続時は `AiApiErrorResponse` (`LOCATION_NOT_FOUND` 等) を受託し、ユーザーへ親切なガイド音声を読み上げる。 + +### 3.2 システム制御シーケンス (シーケンス図) + +#### 3.2.1 AI ナビゲーション実行 & 段階的フォールバックシーケンス +```mermaid +sequenceDiagram + autonumber + actor User as ユーザー + participant UI as Compose UI (MainScreen) + participant VM as MainViewModel + participant VR as IVoiceRecognizer + participant UC as ExecuteCommandUseCase + participant KC as KanjiToNumberConverter + participant CP as CommandParser + participant EX as NavigateAiExecutor + participant API as AiApiService (REST API) + participant TTS as ITextToSpeech + + User->>UI: マイクボタンタップ / Bluetoothボタン + UI->>VM: onMicButtonClicked() + VM->>VR: startListening() + VR->>VM: state = Listening(rmsDb) + VM->>UI: StateFlow Update: VoiceAppState.Listening + + User->>VR: 発話 (例:「仕事で行く三分後の〇〇会社に向かう」) + VR->>VM: state = Success("仕事で行く三分後の〇〇会社に向かう") + VM->>UI: StateFlow Update: VoiceAppState.Processing + VM->>VM: 会話履歴追加 (USER) + + VM->>UC: invoke("仕事で行く三分後の〇〇会社に向かう") + UC->>KC: convert("仕事で行く三分後の〇〇会社に向かう") + KC-->>UC: 前処理結果 ("仕事で行く3分後の〇〇会社に向かう") + UC->>CP: parse("仕事で行く3分後の〇〇会社に向かう") + CP-->>UC: CommandMatchResult (NAVIGATE_AI) + UC->>EX: NavigateAiExecutor.execute() + + EX->>API: POST /api/v1/navigate (Auth Header, 5s/10s Timeout) + API-->>EX: HTTP 200 (AiApiResponse: tts_message, destination) + + EX->>EX: UTF-8 URL Encode: URLEncoder.encode(name, "UTF-8") + alt NaviCon インストール検出時 + EX->>EX: NaviCon Intent (navicon://point?ll=lat,lng&title=encoded_name) + else NaviCon 未検出 且つ Google Maps インストール時 + EX->>EX: Google Maps Intent (geo:lat,lng?q=encoded_name) + else いずれも未検出時 + EX->>EX: Play Store Intent (market://details?id=jp.co.denso.navicon.user) + end + + EX-->>UC: CommandResult (responseText = tts_message) + UC-->>VM: CommandResult 返却 + + VM->>TTS: speak(tts_message) + TTS->>VM: status = Speaking + VM->>UI: StateFlow Update: VoiceAppState.Speaking + TTS->>User: 音声出力 + TTS->>VM: status = Completed + VM->>UI: StateFlow Update: VoiceAppState.Idle +``` + +#### 3.2.2 エラー発生時 3秒自動復帰シーケンス (delay 3000ms) +```mermaid +sequenceDiagram + autonumber + actor User as ユーザー + participant UI as MainScreen + participant VM as MainViewModel + participant VR as IVoiceRecognizer + participant TTS as ITextToSpeech + + User->>VR: 発話不能 / ノイズ入力 + VR->>VM: state = Error(ERROR_SPEECH_TIMEOUT) + VM->>UI: StateFlow Update: VoiceAppState.Error("音声が認識できませんでした") + VM->>TTS: speak("音声が認識できませんでした") + + note over VM: MainViewModel 内で Coroutine delay(3000) 起動 + VM->>VM: viewModelScope.launch { delay(3000); updateState(VoiceAppState.Idle) } + + note over VM,UI: 3秒間エラーメッセージ・ダイアログを表示 + VM->>UI: StateFlow Update: VoiceAppState.Idle + UI->>User: Idle 状態表示 (自動安全復帰完了) +``` + +#### 3.2.3 Bluetooth メディアボタン バックグラウンド起動シーケンス +```mermaid +sequenceDiagram + autonumber + actor User as ユーザー (ヘッドセット) + participant BT as Bluetooth Headset + participant SVC as VoiceAssistantService (Foreground) + participant MS as MediaSessionCompat.Callback + participant VR as IVoiceRecognizer + participant VM as MainViewModel + + User->>BT: メディアボタン押下 + BT->>MS: Intent.ACTION_MEDIA_BUTTON + MS->>SVC: onMediaButtonEvent() + SVC->>SVC: チェック: 常駐 Foreground Service 動作中 + SVC->>VR: startListening() + VR->>VM: state = Listening + VM->>User: 音声認識開始 (画面オフ状態でも制御継続) +``` + +### 3.3 ナビゲーション連携 (NaviCon & Google Maps) & URL エンコード仕様 + +1. **NaviCon 連携 (メイン軸 / 完全無料)** + - API 応答から受信した目的地名 (`name`) に必ず `URLEncoder.encode(name, "UTF-8")` 処理を行う。 + - スキームフォーマット: `navicon://point?ll={latitude},{longitude}&title={encoded_name}` +2. **Google Maps 連携 (フォールバック 1)** + - スキームフォーマット: `geo:{latitude},{longitude}?q={encoded_name}` +3. **Google Play ストア誘導 (フォールバック 2)** + - スキームフォーマット: `market://details?id=jp.co.denso.navicon.user` +4. **パッケージ確認ロジック (フォールバック判定順序)** + - `PackageManager.getPackageInfo("jp.co.denso.navicon.user", 0)` を試行。 + - 存在する場合 -> NaviCon Intent 発行。 + - 存在しない場合 -> 「NaviConが未検出のため、Google Mapsで表示します」と TTS 発話後、Google Maps Intent 発行。 + - Google Maps も未検出の場合 -> Playストア Intent 発行。 + +### 3.4 ディレクトリ構造とソースファイル配置設計 + +ルートディレクトリ構成からアプリ内部のパッケージ構成に至る完全なディレクトリ構造は以下の通り。 + +``` +. +├── .gitea/ +│ └── workflows/ +│ └── build.yaml # Gitea Runner CI/CD ワークフロー +├── docker/ +│ ├── Dockerfile # Android ビルド用 Dockerfile (Ubuntu 22.04 + JDK 17 + Android SDK) +│ └── entrypoint.sh # コンテナ起動初期化スクリプト +├── docker-compose.yml # ローカル Docker ビルド定義 +├── app/ +│ ├── build.gradle.kts # アプリ依存関係設定 +│ └── src/ +│ ├── main/ +│ │ ├── java/com/example/voiceapp/ +│ │ │ ├── VoiceApplication.kt # Application クラス (@HiltAndroidApp) +│ │ │ ├── MainActivity.kt # シングルアクティビティ +│ │ │ ├── data/ +│ │ │ │ └── api/ +│ │ │ │ ├── AiApiService.kt +│ │ │ │ └── models/ # AiApiRequest, AiApiResponse, AiApiErrorResponse +│ │ │ ├── di/ +│ │ │ │ ├── VoiceModule.kt +│ │ │ │ ├── NetworkModule.kt +│ │ │ │ └── DomainModule.kt +│ │ │ ├── domain/ +│ │ │ │ ├── converter/ +│ │ │ │ │ └── KanjiToNumberConverter.kt +│ │ │ │ ├── executor/ +│ │ │ │ │ ├── ICommandExecutor.kt +│ │ │ │ │ ├── GetTimeDateExecutor.kt +│ │ │ │ │ ├── SetAlarmTimerExecutor.kt +│ │ │ │ │ ├── ChangeSettingsExecutor.kt +│ │ │ │ │ ├── ReadMessagesExecutor.kt +│ │ │ │ │ ├── GetSystemInfoExecutor.kt +│ │ │ │ │ ├── NavigateAiExecutor.kt +│ │ │ │ │ └── UnknownFallbackExecutor.kt +│ │ │ │ ├── model/ +│ │ │ │ │ ├── CommandId.kt +│ │ │ │ │ ├── CommandMatchResult.kt +│ │ │ │ │ └── CommandResult.kt +│ │ │ │ ├── CommandDefinition.kt +│ │ │ │ ├── CommandParser.kt +│ │ │ │ └── ExecuteCommandUseCase.kt +│ │ │ ├── voice/ +│ │ │ │ ├── IVoiceRecognizer.kt +│ │ │ │ ├── VoiceRecognitionManager.kt +│ │ │ │ ├── ITextToSpeech.kt +│ │ │ │ └── TextToSpeechManager.kt +│ │ │ ├── service/ +│ │ │ │ ├── VoiceAssistantService.kt +│ │ │ │ └── AppNotificationListenerService.kt +│ │ │ └── ui/ +│ │ │ ├── VoiceAppState.kt +│ │ │ ├── ChatMessage.kt +│ │ │ ├── MainViewModel.kt +│ │ │ └── components/ +│ │ │ ├── MainScreen.kt +│ │ │ ├── StatusBanner.kt +│ │ │ ├── WaveformIndicator.kt +│ │ │ ├── ConversationHistory.kt +│ │ │ ├── ControlBar.kt +│ │ │ └── PermissionDialog.kt +│ │ └── AndroidManifest.xml +│ └── test/ +│ └── java/com/example/voiceapp/ +│ ├── domain/ +│ │ ├── KanjiToNumberConverterTest.kt +│ │ ├── CommandParserTest.kt +│ │ └── ExecuteCommandUseCaseTest.kt +│ └── ui/ +│ └── MainViewModelTest.kt +└── README.md +``` + +### 3.5 AndroidManifest.xml 権限設定・サービス設定設計 + +Android OS 8.0+ (API Level 26) から Android 14 (API Level 34) までの互換性および必須パーミッション・フォアグラウンドサービス宣言を網羅する `AndroidManifest.xml` の完全記述。 + +```xml + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +``` + +### 3.6 Gitea CI/CD Docker Runner 環境・ビルドパイプライン設計 + +1. **Docker Runner ビルドモデル** + - CI/CD パイプラインは Gitea Act Runner の Docker Runner モードにて動的コンテナ環境を生成して実行する。 + - ホストOSに依存せず、コンテナイメージ (`docker/Dockerfile`) 内で JDK 17, Android SDK (API 34 Build-tools), Gradle 8.x 環境を完全にカプセル化する。 + +2. **Gitea ワークフロー定義 (`.gitea/workflows/build.yaml`)** + ```yaml + name: Android CI Build & Test + + on: + push: + branches: [ main, develop ] + pull_request: + branches: [ main, develop ] + + jobs: + build-and-test: + runs-on: ubuntu-latest + container: + image: voice-app-build:latest + steps: + - name: Checkout Repository + uses: actions/checkout@v3 + + - name: Run Unit Tests + run: ./gradlew testDebugUnitTest --stacktrace + + - name: Assemble Debug APK + run: ./gradlew assembleDebug --stacktrace + + - name: Upload Artifacts + uses: actions/upload-artifact@v3 + with: + name: debug-apk + path: app/build/outputs/apk/debug/app-debug.apk + ``` + +3. **ローカル検証モデル (`docker-compose.yml`)** + - ローカル環境でも `docker-compose run --rm build-env ./gradlew testDebugUnitTest` で CI と同一のテスト・ビルド環境を実行可能とする。 diff --git a/docs/04_review_report.md b/docs/04_review_report.md new file mode 100644 index 0000000..30d2b51 --- /dev/null +++ b/docs/04_review_report.md @@ -0,0 +1,104 @@ +# 整合性・仕様検証レポート + +## 1. 検証結果サマリ (PASS / FAIL / WARNING) +**判定**: **FAIL** + +### 検証サマリ概要 +改訂された仕様書 (`docs/02_specifications.md`) と詳細設計書 (`docs/03_hardware_design.md`)、および前回のレビューレポート (`docs/04_review_report.md`) の指摘事項に対する包括的な比較・検証を実施いたしました。 + +前回の指摘事項のうち「参照ドキュメント `docs/03_hardware_design.md` の存在」については設計書が作成されたことで解消されましたが、**Android 14における `FOREGROUND_SERVICE` のタイプおよび個別パーミッションの未宣言、Bluetoothメディアボタンによるバックグラウンド起動制限(Background Activity Launch restrictions)に対する回避設計の欠落、Gitea CI/CDのコンテナ実行モデルの未定義、APIエラーレスポンス・認証ヘッダー・通信タイムアウト等の未定義、URLエンコード処理の規定不足** などの重要課題が仕様書・設計書の両面で未解決のまま存続しています。 + +さらに、仕様書と設計書の詳細比較検証において、以下の致命的な技術的齟齬・設計欠落が新たに確認されました: +1. **核心機能である AI サーバー連携 (`NAVIGATE_AI`) コマンドおよびナビ連携モジュールの設計書における完全欠落** +2. **`ITextToSpeech.speak()` の戻り値シグネチャの直接的矛盾 (`Unit` vs `StateFlow`)** +3. **前処理コンバータ (`KanjiToNumberConverter`) の設計書における完全欠落** +4. **位置情報パーミッション (`ACCESS_FINE_LOCATION` / `ACCESS_COARSE_LOCATION`) および未読メッセージ読み上げサービス (`AppNotificationListenerService`) の `AndroidManifest.xml` からの脱落** +5. **対象 OS バージョンの不一致 (Min API Level 26 vs API Level 29)** +6. **エラー発生時 3秒自動復帰制御の設計書シーケンスにおける欠落** + +以上の理由により、現時点での検証判定は **FAIL** といたします。開発(Coder)およびテスト(Tester)フェーズへの移行前に、仕様書・設計書の双方向における修正および技術整合性の確保が必須です。 + +--- + +## 2. 齟齬・不一致項目 + +### ① AI サーバー連携 (`NAVIGATE_AI`) コマンドおよびナビゲーション連携モジュールの設計書における完全欠落 +- **仕様書**: 3.3.1 節で AI サーバー通信 API `POST /api/v1/navigate`(リクエスト/レスポンス)、3.3.3 表で `NAVIGATE_AI` コマンド (`.*(ナビ|行きたい|向かう|セット).*`)、3.4 節で NaviCon (`navicon://point...`) および Google Maps (`geo:...`) 連携を主軸機能として明記。 +- **設計書**: `CommandId` Enum (2.2.2-2) に `NAVIGATE_AI` が存在せず、`DomainModule` (2.2.3)、`executor/` パッケージ、クラス図、API 通信クライアント (Retrofit/OkHttp 等) が**設計書から完全に欠落**している。 + +### ② `ITextToSpeech.speak()` メソッドシグネチャ・戻り値の不一致 +- **仕様書**: 3.2.3 節にて「状態変化は `val status: StateFlow` に集約。`speak()` は `Unit` を返す」と明記。 +- **設計書**: 2.2.2-1 節のコード定義で `fun speak(text: String, queueMode: Int = 0): StateFlow` と定義されており、戻り値型が仕様書と直接衝突している。 + +### ③ 漢数字コンバータ (`KanjiToNumberConverter`) の設計書における完全欠落 +- **仕様書**: 3.3.2 節で正規表現パース前の事前変換処理として `KanjiToNumberConverter`(「三分」「7時」等の漢数字・全角数字をアラビア数字へ変換)を必須定義し、受入基準 (6.2) にテスト項目を指定。 +- **設計書**: クラス図、Domain Layer インターフェース、`CommandParser.parse()` 実装例、`DomainModule` DI 構成、ディレクトリ構成のいずれにも `KanjiToNumberConverter` が存在しない。 + +### ④ 必須パーミッションおよび Service 宣言の `AndroidManifest.xml` における不一致・脱落 +- **仕様書**: 2.2 節で `ACCESS_FINE_LOCATION`, `ACCESS_COARSE_LOCATION`, `BIND_NOTIFICATION_LISTENER_SERVICE` を必須指定し、3.5 節で `AppNotificationListenerService` のマニフェスト定義を指定。 +- **設計書**: 3.4 節 `AndroidManifest.xml` に位置情報パーミッション (`ACCESS_FINE_LOCATION` / `ACCESS_COARSE_LOCATION`) および `AppNotificationListenerService` / `BIND_NOTIFICATION_LISTENER_SERVICE` の宣言が脱落。一方で仕様書一覧にない `SCHEDULE_EXACT_ALARM` が設計書マニフェストに追加されている。 + +### ⑤ 対象 OS バージョン (Min SDK API Level) の不一致 +- **仕様書**: 1.1 / 2.1 節で `Android OS 8.0+ (API Level 26 以上 / Target API Level 34)` と定義。 +- **設計書**: 1.1-1 節で `Android 10.0+ (API Level 29 以上)` と記述されており、最小サポート OS バージョンの仕様が相違している。 + +### ⑥ エラー時 3秒自動復帰制御の設計書における欠落 +- **仕様書**: 4.2 節で `VoiceAppState.Error` 遷移時に `delay(3000)` により 3秒後に自動 `Idle` 復帰すると明確に定義。 +- **設計書**: 3.2 節のシーケンス図および動作説明で正常系フローのみが記載され、エラー時 3秒自動復帰タイマー制御の記述が欠落している。 + +### ⑦ ディレクトリ構造・CI/CD 構成定義の乖離 +- **仕様書**: 5.1 節で `.gitea/workflows/build.yaml`, `docker/Dockerfile`, `docker/entrypoint.sh`, `docker-compose.yml` などの CI/CD・コンテナ構成をプロジェクトルート下に記載。 +- **設計書**: 3.3 節で `voice_controlled_device/` ルート下のアプリコード構造のみを記載し、CI/CD や Docker 関連構成が完全に除外されている。 + +--- + +## 3. 仕様の未定義・検討不足項目 + +### ① Target SDK Version 34 (Android 14) における `FOREGROUND_SERVICE` タイプ・権限の未定義 +- Android 14 (API Level 34) 必須の `android.permission.FOREGROUND_SERVICE` およびサービスタイプ (`foregroundServiceType="microphone|location"` 等)、個別の `FOREGROUND_SERVICE_MICROPHONE`, `FOREGROUND_SERVICE_LOCATION` 権限などの宣言が仕様書・設計書双方のマニフェストから脱落している。 + +### ② Bluetooth メディアボタンによるバックグラウンド起動制限 (Background Activity Launch restrictions) 対策の未設計 +- 画面オフ・スリープ時に `ACTION_MEDIA_BUTTON` や `ACTION_VOICE_COMMAND` から直接 Activity を起動することは Android 10+ OS 制約上不可能である。バックグラウンド常駐 Service + `MediaSessionCompat` による音響受信およびフォアグラウンドサービス制御設計が仕様書・設計書ともに未具体化。 + +### ③ AI サーバー API 仕様における未定義事項 +1. **エラーレスポンス JSON フォーマットの未定義**: 正常系 (`status: "success"`) のみの定義であり、エラー発生時 (`status: "error"`) の JSON 構造(エラーコード、理由メッセージ)が未定義。 +2. **認証・セキュリティヘッダーの欠落**: `POST /api/v1/navigate` リクエストに API キーや Authorization トークンが規定されていない。 +3. **通信タイムアウト & 再試行方針の未定義**: モバイル回線接続時の Connect/Read タイムアウト値および Exponential Backoff リトライ方針が規定されていない。 + +### ④ NaviCon URL スキームの URL エンコードおよびフォールバック条件の未定義 +- `navicon://point?ll={latitude},{longitude}&title={name}` の `title` に対する `UTF-8` URLエンコード規定が未記載。 +- NaviCon 未インストール時に Google Maps アプリ Intent を優先するのか Google Play への誘導を優先するかの判定順序・ロジックが未決定。 + +### ⑤ Gitea CI/CD 実行モデルの詳細定義不足 +- `.gitea/workflows/build.yaml` の Runner タイプ (Docker Runner / Host Runner) やコンテナ環境の紐付け記述が不足しており、ローカル `docker-compose` 環境との完全一致性が担保されていない。 + +--- + +## 4. Coder / tester への引き継ぎ注意事項 + +### 4.1 Coder (開発担当者) への注意事項 +1. **`ITextToSpeech.speak()` のインターフェース実装**: + - 仕様書の原則に従い、`speak()` の戻り値は `Unit` とし、状態管理は `StateFlow` のみで一元化すること。 +2. **`NAVIGATE_AI` コマンド & ナビゲーション連携の実装**: + - `CommandId` に `NAVIGATE_AI` を追加し、`NavigateAiExecutor` および Retrofit/OkHttp による `POST /api/v1/navigate` 通信クライアント、NaviCon/Google Maps 連携 Intent ロジックを構築すること。 +3. **漢数字コンバータ (`KanjiToNumberConverter`) の組み込み**: + - 正規表現パース処理の前に必ず `KanjiToNumberConverter` を割り当て、漢数字(「一」「二」「三分」「七時」等)および全角数字をアラビア数字へ変換してから `CommandParser` へ入力すること。 +4. **`AndroidManifest.xml` 権限・サービス宣言の網羅**: + - 位置情報権限 (`ACCESS_FINE_LOCATION`, `ACCESS_COARSE_LOCATION`)、`BIND_NOTIFICATION_LISTENER_SERVICE` (および `AppNotificationListenerService`) を追加すること。 + - Android 14 (API 34) 対策として `FOREGROUND_SERVICE`, `FOREGROUND_SERVICE_MICROPHONE`, `FOREGROUND_SERVICE_LOCATION` を宣言し、適切なサービスタイプを設定すること。 +5. **エラー時 3秒自動復帰タイマーの実装**: + - `VoiceAppState.Error` 遷移時は `MainViewModel` 内で Coroutine `delay(3000)` を起動し、3秒後に `VoiceAppState.Idle` へ自動復帰させる制御を組み込むこと。 +6. **URL エンコード処理**: + - NaviCon URL スキーム生成時、パラメータ `title` は必ず `URLEncoder.encode(title, "UTF-8")` 処理を行うこと。 + +### 4.2 Tester (テスト担当者) への注意事項 +1. **漢数字前処理変換単体テスト (`KanjiToNumberConverterTest`)**: + - 「三分」「7時」「十五分」「百二十秒」等の漢数字・全角・アラビア数字混合パターンが正確にアラビア数字へ変換されるかをテストすること。 +2. **コマンドパース単体テスト (`CommandParserTest`)**: + - `NAVIGATE_AI`(「〇〇に行きたい」「ナビ開始」等)を含め、全 7 種のコマンドパターンの抽出正確性をテストすること。 +3. **ViewModel 状態遷移非同期テスト (`MainViewModelTest`)**: + - Turbine を活用し、`VoiceAppState.Error` へ遷移後、 precisely 3000ms 後に `VoiceAppState.Idle` へ自動復帰する非同期制御をテストすること。 +4. **パーミッション拒否時の堅牢性テスト**: + - 位置情報権限 (`ACCESS_FINE_LOCATION`) やマイク権限 (`RECORD_AUDIO`) を拒否された状態でアプリおよび音声ナビリクエストがクラッシュせず、エラーメッセージを TTS 読み上げ/ダイアログ表示するか確認すること。 +5. **NaviCon 未インストール端末でのフォールバックテスト**: + - NaviCon 非インストールの実機/エミュレータにて目的地設定を実行し、Google Maps または Google Play 画面へ安全にフォールバックすることを確認すること。 diff --git a/gradle.properties b/gradle.properties new file mode 100644 index 0000000..e685744 --- /dev/null +++ b/gradle.properties @@ -0,0 +1,8 @@ +# Enable AndroidX support +android.useAndroidX=true + +# Enable Jetifier for compatibility with third-party libraries using legacy support libraries +android.enableJetifier=true + +# JVM options +org.gradle.jvmargs=-Xmx2048m -Dfile.encoding=UTF-8 diff --git a/gradle/wrapper/gradle-wrapper.jar b/gradle/wrapper/gradle-wrapper.jar new file mode 100644 index 0000000000000000000000000000000000000000..d64cd4917707c1f8861d8cb53dd15194d4248596 GIT binary patch literal 43462 zcma&NWl&^owk(X(xVyW%ySuwf;qI=D6|RlDJ2cR^yEKh!@I- zp9QeisK*rlxC>+~7Dk4IxIRsKBHqdR9b3+fyL=ynHmIDe&|>O*VlvO+%z5;9Z$|DJ zb4dO}-R=MKr^6EKJiOrJdLnCJn>np?~vU-1sSFgPu;pthGwf}bG z(1db%xwr#x)r+`4AGu$j7~u2MpVs3VpLp|mx&;>`0p0vH6kF+D2CY0fVdQOZ@h;A` z{infNyvmFUiu*XG}RNMNwXrbec_*a3N=2zJ|Wh5z* z5rAX$JJR{#zP>KY**>xHTuw?|-Rg|o24V)74HcfVT;WtQHXlE+_4iPE8QE#DUm%x0 zEKr75ur~W%w#-My3Tj`hH6EuEW+8K-^5P62$7Sc5OK+22qj&Pd1;)1#4tKihi=~8C zHiQSst0cpri6%OeaR`PY>HH_;CPaRNty%WTm4{wDK8V6gCZlG@U3$~JQZ;HPvDJcT1V{ z?>H@13MJcCNe#5z+MecYNi@VT5|&UiN1D4ATT+%M+h4c$t;C#UAs3O_q=GxK0}8%8 z8J(_M9bayxN}69ex4dzM_P3oh@ZGREjVvn%%r7=xjkqxJP4kj}5tlf;QosR=%4L5y zWhgejO=vao5oX%mOHbhJ8V+SG&K5dABn6!WiKl{|oPkq(9z8l&Mm%(=qGcFzI=eLu zWc_oCLyf;hVlB@dnwY98?75B20=n$>u3b|NB28H0u-6Rpl((%KWEBOfElVWJx+5yg z#SGqwza7f}$z;n~g%4HDU{;V{gXIhft*q2=4zSezGK~nBgu9-Q*rZ#2f=Q}i2|qOp z!!y4p)4o=LVUNhlkp#JL{tfkhXNbB=Ox>M=n6soptJw-IDI|_$is2w}(XY>a=H52d z3zE$tjPUhWWS+5h=KVH&uqQS=$v3nRs&p$%11b%5qtF}S2#Pc`IiyBIF4%A!;AVoI zXU8-Rpv!DQNcF~(qQnyyMy=-AN~U>#&X1j5BLDP{?K!%h!;hfJI>$mdLSvktEr*89 zdJHvby^$xEX0^l9g$xW-d?J;L0#(`UT~zpL&*cEh$L|HPAu=P8`OQZV!-}l`noSp_ zQ-1$q$R-gDL)?6YaM!=8H=QGW$NT2SeZlb8PKJdc=F-cT@j7Xags+Pr*jPtlHFnf- zh?q<6;)27IdPc^Wdy-mX%2s84C1xZq9Xms+==F4);O`VUASmu3(RlgE#0+#giLh-& zcxm3_e}n4{%|X zJp{G_j+%`j_q5}k{eW&TlP}J2wtZ2^<^E(O)4OQX8FDp6RJq!F{(6eHWSD3=f~(h} zJXCf7=r<16X{pHkm%yzYI_=VDP&9bmI1*)YXZeB}F? z(%QsB5fo*FUZxK$oX~X^69;x~j7ms8xlzpt-T15e9}$4T-pC z6PFg@;B-j|Ywajpe4~bk#S6(fO^|mm1hKOPfA%8-_iGCfICE|=P_~e;Wz6my&)h_~ zkv&_xSAw7AZ%ThYF(4jADW4vg=oEdJGVOs>FqamoL3Np8>?!W#!R-0%2Bg4h?kz5I zKV-rKN2n(vUL%D<4oj@|`eJ>0i#TmYBtYmfla;c!ATW%;xGQ0*TW@PTlGG><@dxUI zg>+3SiGdZ%?5N=8uoLA|$4isK$aJ%i{hECP$bK{J#0W2gQ3YEa zZQ50Stn6hqdfxJ*9#NuSLwKFCUGk@c=(igyVL;;2^wi4o30YXSIb2g_ud$ zgpCr@H0qWtk2hK8Q|&wx)}4+hTYlf;$a4#oUM=V@Cw#!$(nOFFpZ;0lc!qd=c$S}Z zGGI-0jg~S~cgVT=4Vo)b)|4phjStD49*EqC)IPwyeKBLcN;Wu@Aeph;emROAwJ-0< z_#>wVm$)ygH|qyxZaet&(Vf%pVdnvKWJn9`%DAxj3ot;v>S$I}jJ$FLBF*~iZ!ZXE zkvui&p}fI0Y=IDX)mm0@tAd|fEHl~J&K}ZX(Mm3cm1UAuwJ42+AO5@HwYfDH7ipIc zmI;1J;J@+aCNG1M`Btf>YT>~c&3j~Qi@Py5JT6;zjx$cvOQW@3oQ>|}GH?TW-E z1R;q^QFjm5W~7f}c3Ww|awg1BAJ^slEV~Pk`Kd`PS$7;SqJZNj->it4DW2l15}xP6 zoCl$kyEF%yJni0(L!Z&14m!1urXh6Btj_5JYt1{#+H8w?5QI%% zo-$KYWNMJVH?Hh@1n7OSu~QhSswL8x0=$<8QG_zepi_`y_79=nK=_ZP_`Em2UI*tyQoB+r{1QYZCpb?2OrgUw#oRH$?^Tj!Req>XiE#~B|~ z+%HB;=ic+R@px4Ld8mwpY;W^A%8%l8$@B@1m5n`TlKI6bz2mp*^^^1mK$COW$HOfp zUGTz-cN9?BGEp}5A!mDFjaiWa2_J2Iq8qj0mXzk; z66JBKRP{p%wN7XobR0YjhAuW9T1Gw3FDvR5dWJ8ElNYF94eF3ebu+QwKjtvVu4L zI9ip#mQ@4uqVdkl-TUQMb^XBJVLW(-$s;Nq;@5gr4`UfLgF$adIhd?rHOa%D);whv z=;krPp~@I+-Z|r#s3yCH+c1US?dnm+C*)r{m+86sTJusLdNu^sqLrfWed^ndHXH`m zd3#cOe3>w-ga(Dus_^ppG9AC>Iq{y%%CK+Cro_sqLCs{VLuK=dev>OL1dis4(PQ5R zcz)>DjEkfV+MO;~>VUlYF00SgfUo~@(&9$Iy2|G0T9BSP?&T22>K46D zL*~j#yJ?)^*%J3!16f)@Y2Z^kS*BzwfAQ7K96rFRIh>#$*$_Io;z>ux@}G98!fWR@ zGTFxv4r~v)Gsd|pF91*-eaZ3Qw1MH$K^7JhWIdX%o$2kCbvGDXy)a?@8T&1dY4`;L z4Kn+f%SSFWE_rpEpL9bnlmYq`D!6F%di<&Hh=+!VI~j)2mfil03T#jJ_s?}VV0_hp z7T9bWxc>Jm2Z0WMU?`Z$xE74Gu~%s{mW!d4uvKCx@WD+gPUQ zV0vQS(Ig++z=EHN)BR44*EDSWIyT~R4$FcF*VEY*8@l=218Q05D2$|fXKFhRgBIEE zdDFB}1dKkoO^7}{5crKX!p?dZWNz$m>1icsXG2N+((x0OIST9Zo^DW_tytvlwXGpn zs8?pJXjEG;T@qrZi%#h93?FP$!&P4JA(&H61tqQi=opRzNpm zkrG}$^t9&XduK*Qa1?355wd8G2CI6QEh@Ua>AsD;7oRUNLPb76m4HG3K?)wF~IyS3`fXuNM>${?wmB zpVz;?6_(Fiadfd{vUCBM*_kt$+F3J+IojI;9L(gc9n3{sEZyzR9o!_mOwFC#tQ{Q~ zP3-`#uK#tP3Q7~Q;4H|wjZHO8h7e4IuBxl&vz2w~D8)w=Wtg31zpZhz%+kzSzL*dV zwp@{WU4i;hJ7c2f1O;7Mz6qRKeASoIv0_bV=i@NMG*l<#+;INk-^`5w@}Dj~;k=|}qM1vq_P z|GpBGe_IKq|LNy9SJhKOQ$c=5L{Dv|Q_lZl=-ky*BFBJLW9&y_C|!vyM~rQx=!vun z?rZJQB5t}Dctmui5i31C_;_}CEn}_W%>oSXtt>@kE1=JW*4*v4tPp;O6 zmAk{)m!)}34pTWg8{i>($%NQ(Tl;QC@J@FfBoc%Gr&m560^kgSfodAFrIjF}aIw)X zoXZ`@IsMkc8_=w%-7`D6Y4e*CG8k%Ud=GXhsTR50jUnm+R*0A(O3UKFg0`K;qp1bl z7``HN=?39ic_kR|^R^~w-*pa?Vj#7|e9F1iRx{GN2?wK!xR1GW!qa=~pjJb-#u1K8 zeR?Y2i-pt}yJq;SCiVHODIvQJX|ZJaT8nO+(?HXbLefulKKgM^B(UIO1r+S=7;kLJ zcH}1J=Px2jsh3Tec&v8Jcbng8;V-`#*UHt?hB(pmOipKwf3Lz8rG$heEB30Sg*2rx zV<|KN86$soN(I!BwO`1n^^uF2*x&vJ$2d$>+`(romzHP|)K_KkO6Hc>_dwMW-M(#S zK(~SiXT1@fvc#U+?|?PniDRm01)f^#55;nhM|wi?oG>yBsa?~?^xTU|fX-R(sTA+5 zaq}-8Tx7zrOy#3*JLIIVsBmHYLdD}!0NP!+ITW+Thn0)8SS!$@)HXwB3tY!fMxc#1 zMp3H?q3eD?u&Njx4;KQ5G>32+GRp1Ee5qMO0lZjaRRu&{W<&~DoJNGkcYF<5(Ab+J zgO>VhBl{okDPn78<%&e2mR{jwVCz5Og;*Z;;3%VvoGo_;HaGLWYF7q#jDX=Z#Ml`H z858YVV$%J|e<1n`%6Vsvq7GmnAV0wW4$5qQ3uR@1i>tW{xrl|ExywIc?fNgYlA?C5 zh$ezAFb5{rQu6i7BSS5*J-|9DQ{6^BVQ{b*lq`xS@RyrsJN?-t=MTMPY;WYeKBCNg z^2|pN!Q^WPJuuO4!|P@jzt&tY1Y8d%FNK5xK(!@`jO2aEA*4 zkO6b|UVBipci?){-Ke=+1;mGlND8)6+P;8sq}UXw2hn;fc7nM>g}GSMWu&v&fqh

iViYT=fZ(|3Ox^$aWPp4a8h24tD<|8-!aK0lHgL$N7Efw}J zVIB!7=T$U`ao1?upi5V4Et*-lTG0XvExbf!ya{cua==$WJyVG(CmA6Of*8E@DSE%L z`V^$qz&RU$7G5mg;8;=#`@rRG`-uS18$0WPN@!v2d{H2sOqP|!(cQ@ zUHo!d>>yFArLPf1q`uBvY32miqShLT1B@gDL4XoVTK&@owOoD)OIHXrYK-a1d$B{v zF^}8D3Y^g%^cnvScOSJR5QNH+BI%d|;J;wWM3~l>${fb8DNPg)wrf|GBP8p%LNGN# z3EaIiItgwtGgT&iYCFy9-LG}bMI|4LdmmJt@V@% zb6B)1kc=T)(|L@0;wr<>=?r04N;E&ef+7C^`wPWtyQe(*pD1pI_&XHy|0gIGHMekd zF_*M4yi6J&Z4LQj65)S zXwdM{SwUo%3SbPwFsHgqF@V|6afT|R6?&S;lw=8% z3}@9B=#JI3@B*#4s!O))~z zc>2_4Q_#&+5V`GFd?88^;c1i7;Vv_I*qt!_Yx*n=;rj!82rrR2rQ8u5(Ejlo{15P% zs~!{%XJ>FmJ})H^I9bn^Re&38H{xA!0l3^89k(oU;bZWXM@kn$#aoS&Y4l^-WEn-fH39Jb9lA%s*WsKJQl?n9B7_~P z-XM&WL7Z!PcoF6_D>V@$CvUIEy=+Z&0kt{szMk=f1|M+r*a43^$$B^MidrT0J;RI` z(?f!O<8UZkm$_Ny$Hth1J#^4ni+im8M9mr&k|3cIgwvjAgjH z8`N&h25xV#v*d$qBX5jkI|xOhQn!>IYZK7l5#^P4M&twe9&Ey@@GxYMxBZq2e7?`q z$~Szs0!g{2fGcp9PZEt|rdQ6bhAgpcLHPz?f-vB?$dc*!9OL?Q8mn7->bFD2Si60* z!O%y)fCdMSV|lkF9w%x~J*A&srMyYY3{=&$}H zGQ4VG_?$2X(0|vT0{=;W$~icCI{b6W{B!Q8xdGhF|D{25G_5_+%s(46lhvNLkik~R z>nr(&C#5wwOzJZQo9m|U<;&Wk!_#q|V>fsmj1g<6%hB{jGoNUPjgJslld>xmODzGjYc?7JSuA?A_QzjDw5AsRgi@Y|Z0{F{!1=!NES-#*f^s4l0Hu zz468))2IY5dmD9pa*(yT5{EyP^G>@ZWumealS-*WeRcZ}B%gxq{MiJ|RyX-^C1V=0 z@iKdrGi1jTe8Ya^x7yyH$kBNvM4R~`fbPq$BzHum-3Zo8C6=KW@||>zsA8-Y9uV5V z#oq-f5L5}V<&wF4@X@<3^C%ptp6+Ce)~hGl`kwj)bsAjmo_GU^r940Z-|`<)oGnh7 zFF0Tde3>ui?8Yj{sF-Z@)yQd~CGZ*w-6p2U<8}JO-sRsVI5dBji`01W8A&3$?}lxBaC&vn0E$c5tW* zX>5(zzZ=qn&!J~KdsPl;P@bmA-Pr8T*)eh_+Dv5=Ma|XSle6t(k8qcgNyar{*ReQ8 zTXwi=8vr>!3Ywr+BhggHDw8ke==NTQVMCK`$69fhzEFB*4+H9LIvdt-#IbhZvpS}} zO3lz;P?zr0*0$%-Rq_y^k(?I{Mk}h@w}cZpMUp|ucs55bcloL2)($u%mXQw({Wzc~ z;6nu5MkjP)0C(@%6Q_I_vsWrfhl7Zpoxw#WoE~r&GOSCz;_ro6i(^hM>I$8y>`!wW z*U^@?B!MMmb89I}2(hcE4zN2G^kwyWCZp5JG>$Ez7zP~D=J^LMjSM)27_0B_X^C(M z`fFT+%DcKlu?^)FCK>QzSnV%IsXVcUFhFdBP!6~se&xxrIxsvySAWu++IrH;FbcY$ z2DWTvSBRfLwdhr0nMx+URA$j3i7_*6BWv#DXfym?ZRDcX9C?cY9sD3q)uBDR3uWg= z(lUIzB)G$Hr!){>E{s4Dew+tb9kvToZp-1&c?y2wn@Z~(VBhqz`cB;{E4(P3N2*nJ z_>~g@;UF2iG{Kt(<1PyePTKahF8<)pozZ*xH~U-kfoAayCwJViIrnqwqO}7{0pHw$ zs2Kx?s#vQr7XZ264>5RNKSL8|Ty^=PsIx^}QqOOcfpGUU4tRkUc|kc7-!Ae6!+B{o~7nFpm3|G5^=0#Bnm6`V}oSQlrX(u%OWnC zoLPy&Q;1Jui&7ST0~#+}I^&?vcE*t47~Xq#YwvA^6^} z`WkC)$AkNub|t@S!$8CBlwbV~?yp&@9h{D|3z-vJXgzRC5^nYm+PyPcgRzAnEi6Q^gslXYRv4nycsy-SJu?lMps-? zV`U*#WnFsdPLL)Q$AmD|0`UaC4ND07+&UmOu!eHruzV|OUox<+Jl|Mr@6~C`T@P%s zW7sgXLF2SSe9Fl^O(I*{9wsFSYb2l%-;&Pi^dpv!{)C3d0AlNY6!4fgmSgj_wQ*7Am7&$z;Jg&wgR-Ih;lUvWS|KTSg!&s_E9_bXBkZvGiC6bFKDWZxsD$*NZ#_8bl zG1P-#@?OQzED7@jlMJTH@V!6k;W>auvft)}g zhoV{7$q=*;=l{O>Q4a@ ziMjf_u*o^PsO)#BjC%0^h>Xp@;5$p{JSYDt)zbb}s{Kbt!T*I@Pk@X0zds6wsefuU zW$XY%yyRGC94=6mf?x+bbA5CDQ2AgW1T-jVAJbm7K(gp+;v6E0WI#kuACgV$r}6L? zd|Tj?^%^*N&b>Dd{Wr$FS2qI#Ucs1yd4N+RBUQiSZGujH`#I)mG&VKoDh=KKFl4=G z&MagXl6*<)$6P}*Tiebpz5L=oMaPrN+caUXRJ`D?=K9!e0f{@D&cZLKN?iNP@X0aF zE(^pl+;*T5qt?1jRC=5PMgV!XNITRLS_=9{CJExaQj;lt!&pdzpK?8p>%Mb+D z?yO*uSung=-`QQ@yX@Hyd4@CI^r{2oiu`%^bNkz+Nkk!IunjwNC|WcqvX~k=><-I3 zDQdbdb|!v+Iz01$w@aMl!R)koD77Xp;eZwzSl-AT zr@Vu{=xvgfq9akRrrM)}=!=xcs+U1JO}{t(avgz`6RqiiX<|hGG1pmop8k6Q+G_mv zJv|RfDheUp2L3=^C=4aCBMBn0aRCU(DQwX-W(RkRwmLeuJYF<0urcaf(=7)JPg<3P zQs!~G)9CT18o!J4{zX{_e}4eS)U-E)0FAt}wEI(c0%HkxgggW;(1E=>J17_hsH^sP z%lT0LGgbUXHx-K*CI-MCrP66UP0PvGqM$MkeLyqHdbgP|_Cm!7te~b8p+e6sQ_3k| zVcwTh6d83ltdnR>D^)BYQpDKlLk3g0Hdcgz2}%qUs9~~Rie)A-BV1mS&naYai#xcZ z(d{8=-LVpTp}2*y)|gR~;qc7fp26}lPcLZ#=JpYcn3AT9(UIdOyg+d(P5T7D&*P}# zQCYplZO5|7+r19%9e`v^vfSS1sbX1c%=w1;oyruXB%Kl$ACgKQ6=qNWLsc=28xJjg zwvsI5-%SGU|3p>&zXVl^vVtQT3o-#$UT9LI@Npz~6=4!>mc431VRNN8od&Ul^+G_kHC`G=6WVWM z%9eWNyy(FTO|A+@x}Ou3CH)oi;t#7rAxdIXfNFwOj_@Y&TGz6P_sqiB`Q6Lxy|Q{`|fgmRG(k+!#b*M+Z9zFce)f-7;?Km5O=LHV9f9_87; zF7%R2B+$?@sH&&-$@tzaPYkw0;=i|;vWdI|Wl3q_Zu>l;XdIw2FjV=;Mq5t1Q0|f< zs08j54Bp`3RzqE=2enlkZxmX6OF+@|2<)A^RNQpBd6o@OXl+i)zO%D4iGiQNuXd+zIR{_lb96{lc~bxsBveIw6umhShTX+3@ZJ=YHh@ zWY3(d0azg;7oHn>H<>?4@*RQbi>SmM=JrHvIG(~BrvI)#W(EAeO6fS+}mxxcc+X~W6&YVl86W9WFSS}Vz-f9vS?XUDBk)3TcF z8V?$4Q)`uKFq>xT=)Y9mMFVTUk*NIA!0$?RP6Ig0TBmUFrq*Q-Agq~DzxjStQyJ({ zBeZ;o5qUUKg=4Hypm|}>>L=XKsZ!F$yNTDO)jt4H0gdQ5$f|d&bnVCMMXhNh)~mN z@_UV6D7MVlsWz+zM+inZZp&P4fj=tm6fX)SG5H>OsQf_I8c~uGCig$GzuwViK54bcgL;VN|FnyQl>Ed7(@>=8$a_UKIz|V6CeVSd2(P z0Uu>A8A+muM%HLFJQ9UZ5c)BSAv_zH#1f02x?h9C}@pN@6{>UiAp>({Fn(T9Q8B z^`zB;kJ5b`>%dLm+Ol}ty!3;8f1XDSVX0AUe5P#@I+FQ-`$(a;zNgz)4x5hz$Hfbg z!Q(z26wHLXko(1`;(BAOg_wShpX0ixfWq3ponndY+u%1gyX)_h=v1zR#V}#q{au6; z!3K=7fQwnRfg6FXtNQmP>`<;!N137paFS%y?;lb1@BEdbvQHYC{976l`cLqn;b8lp zIDY>~m{gDj(wfnK!lpW6pli)HyLEiUrNc%eXTil|F2s(AY+LW5hkKb>TQ3|Q4S9rr zpDs4uK_co6XPsn_z$LeS{K4jFF`2>U`tbgKdyDne`xmR<@6AA+_hPNKCOR-Zqv;xk zu5!HsBUb^!4uJ7v0RuH-7?l?}b=w5lzzXJ~gZcxRKOovSk@|#V+MuX%Y+=;14i*%{)_gSW9(#4%)AV#3__kac1|qUy!uyP{>?U#5wYNq}y$S9pCc zFc~4mgSC*G~j0u#qqp9 z${>3HV~@->GqEhr_Xwoxq?Hjn#=s2;i~g^&Hn|aDKpA>Oc%HlW(KA1?BXqpxB;Ydx)w;2z^MpjJ(Qi(X!$5RC z*P{~%JGDQqojV>2JbEeCE*OEu!$XJ>bWA9Oa_Hd;y)F%MhBRi*LPcdqR8X`NQ&1L# z5#9L*@qxrx8n}LfeB^J{%-?SU{FCwiWyHp682F+|pa+CQa3ZLzBqN1{)h4d6+vBbV zC#NEbQLC;}me3eeYnOG*nXOJZEU$xLZ1<1Y=7r0(-U0P6-AqwMAM`a(Ed#7vJkn6plb4eI4?2y3yOTGmmDQ!z9`wzbf z_OY#0@5=bnep;MV0X_;;SJJWEf^E6Bd^tVJ9znWx&Ks8t*B>AM@?;D4oWUGc z!H*`6d7Cxo6VuyS4Eye&L1ZRhrRmN6Lr`{NL(wDbif|y&z)JN>Fl5#Wi&mMIr5i;x zBx}3YfF>>8EC(fYnmpu~)CYHuHCyr5*`ECap%t@y=jD>!_%3iiE|LN$mK9>- zHdtpy8fGZtkZF?%TW~29JIAfi2jZT8>OA7=h;8T{{k?c2`nCEx9$r zS+*&vt~2o^^J+}RDG@+9&M^K*z4p{5#IEVbz`1%`m5c2};aGt=V?~vIM}ZdPECDI)47|CWBCfDWUbxBCnmYivQ*0Nu_xb*C>~C9(VjHM zxe<*D<#dQ8TlpMX2c@M<9$w!RP$hpG4cs%AI){jp*Sj|*`m)5(Bw*A0$*i-(CA5#%>a)$+jI2C9r6|(>J8InryENI z$NohnxDUB;wAYDwrb*!N3noBTKPpPN}~09SEL18tkG zxgz(RYU_;DPT{l?Q$+eaZaxnsWCA^ds^0PVRkIM%bOd|G2IEBBiz{&^JtNsODs;5z zICt_Zj8wo^KT$7Bg4H+y!Df#3mbl%%?|EXe!&(Vmac1DJ*y~3+kRKAD=Ovde4^^%~ zw<9av18HLyrf*_>Slp;^i`Uy~`mvBjZ|?Ad63yQa#YK`4+c6;pW4?XIY9G1(Xh9WO8{F-Aju+nS9Vmv=$Ac0ienZ+p9*O%NG zMZKy5?%Z6TAJTE?o5vEr0r>f>hb#2w2U3DL64*au_@P!J!TL`oH2r*{>ffu6|A7tv zL4juf$DZ1MW5ZPsG!5)`k8d8c$J$o;%EIL0va9&GzWvkS%ZsGb#S(?{!UFOZ9<$a| zY|a+5kmD5N&{vRqkgY>aHsBT&`rg|&kezoD)gP0fsNYHsO#TRc_$n6Lf1Z{?+DLziXlHrq4sf(!>O{?Tj;Eh@%)+nRE_2VxbN&&%%caU#JDU%vL3}Cb zsb4AazPI{>8H&d=jUaZDS$-0^AxE@utGs;-Ez_F(qC9T=UZX=>ok2k2 ziTn{K?y~a5reD2A)P${NoI^>JXn>`IeArow(41c-Wm~)wiryEP(OS{YXWi7;%dG9v zI?mwu1MxD{yp_rrk!j^cKM)dc4@p4Ezyo%lRN|XyD}}>v=Xoib0gOcdXrQ^*61HNj z=NP|pd>@yfvr-=m{8$3A8TQGMTE7g=z!%yt`8`Bk-0MMwW~h^++;qyUP!J~ykh1GO z(FZ59xuFR$(WE;F@UUyE@Sp>`aVNjyj=Ty>_Vo}xf`e7`F;j-IgL5`1~-#70$9_=uBMq!2&1l zomRgpD58@)YYfvLtPW}{C5B35R;ZVvB<<#)x%srmc_S=A7F@DW8>QOEGwD6suhwCg z>Pa+YyULhmw%BA*4yjDp|2{!T98~<6Yfd(wo1mQ!KWwq0eg+6)o1>W~f~kL<-S+P@$wx*zeI|1t7z#Sxr5 zt6w+;YblPQNplq4Z#T$GLX#j6yldXAqj>4gAnnWtBICUnA&-dtnlh=t0Ho_vEKwV` z)DlJi#!@nkYV#$!)@>udAU*hF?V`2$Hf=V&6PP_|r#Iv*J$9)pF@X3`k;5})9^o4y z&)~?EjX5yX12O(BsFy-l6}nYeuKkiq`u9145&3Ssg^y{5G3Pse z9w(YVa0)N-fLaBq1`P!_#>SS(8fh_5!f{UrgZ~uEdeMJIz7DzI5!NHHqQtm~#CPij z?=N|J>nPR6_sL7!f4hD_|KH`vf8(Wpnj-(gPWH+ZvID}%?~68SwhPTC3u1_cB`otq z)U?6qo!ZLi5b>*KnYHWW=3F!p%h1;h{L&(Q&{qY6)_qxNfbP6E3yYpW!EO+IW3?@J z);4>g4gnl^8klu7uA>eGF6rIGSynacogr)KUwE_R4E5Xzi*Qir@b-jy55-JPC8c~( zo!W8y9OGZ&`xmc8;=4-U9=h{vCqfCNzYirONmGbRQlR`WWlgnY+1wCXbMz&NT~9*| z6@FrzP!LX&{no2!Ln_3|I==_4`@}V?4a;YZKTdw;vT<+K+z=uWbW(&bXEaWJ^W8Td z-3&1bY^Z*oM<=M}LVt>_j+p=2Iu7pZmbXrhQ_k)ysE9yXKygFNw$5hwDn(M>H+e1&9BM5!|81vd%r%vEm zqxY3?F@fb6O#5UunwgAHR9jp_W2zZ}NGp2%mTW@(hz7$^+a`A?mb8|_G*GNMJ) zjqegXQio=i@AINre&%ofexAr95aop5C+0MZ0m-l=MeO8m3epm7U%vZB8+I+C*iNFM z#T3l`gknX;D$-`2XT^Cg*vrv=RH+P;_dfF++cP?B_msQI4j+lt&rX2)3GaJx%W*Nn zkML%D{z5tpHH=dksQ*gzc|}gzW;lwAbxoR07VNgS*-c3d&8J|;@3t^ zVUz*J*&r7DFRuFVDCJDK8V9NN5hvpgGjwx+5n)qa;YCKe8TKtdnh{I7NU9BCN!0dq zczrBk8pE{{@vJa9ywR@mq*J=v+PG;?fwqlJVhijG!3VmIKs>9T6r7MJpC)m!Tc#>g zMtVsU>wbwFJEfwZ{vB|ZlttNe83)$iz`~#8UJ^r)lJ@HA&G#}W&ZH*;k{=TavpjWE z7hdyLZPf*X%Gm}i`Y{OGeeu^~nB8=`{r#TUrM-`;1cBvEd#d!kPqIgYySYhN-*1;L z^byj%Yi}Gx)Wnkosi337BKs}+5H5dth1JA{Ir-JKN$7zC)*}hqeoD(WfaUDPT>0`- z(6sa0AoIqASwF`>hP}^|)a_j2s^PQn*qVC{Q}htR z5-)duBFXT_V56-+UohKXlq~^6uf!6sA#ttk1o~*QEy_Y-S$gAvq47J9Vtk$5oA$Ct zYhYJ@8{hsC^98${!#Ho?4y5MCa7iGnfz}b9jE~h%EAAv~Qxu)_rAV;^cygV~5r_~?l=B`zObj7S=H=~$W zPtI_m%g$`kL_fVUk9J@>EiBH zOO&jtn~&`hIFMS5S`g8w94R4H40mdNUH4W@@XQk1sr17b{@y|JB*G9z1|CrQjd+GX z6+KyURG3;!*BQrentw{B2R&@2&`2}n(z-2&X7#r!{yg@Soy}cRD~j zj9@UBW+N|4HW4AWapy4wfUI- zZ`gSL6DUlgj*f1hSOGXG0IVH8HxK?o2|3HZ;KW{K+yPAlxtb)NV_2AwJm|E)FRs&& z=c^e7bvUsztY|+f^k7NXs$o1EUq>cR7C0$UKi6IooHWlK_#?IWDkvywnzg&ThWo^? z2O_N{5X39#?eV9l)xI(>@!vSB{DLt*oY!K1R8}_?%+0^C{d9a%N4 zoxHVT1&Lm|uDX%$QrBun5e-F`HJ^T$ zmzv)p@4ZHd_w9!%Hf9UYNvGCw2TTTbrj9pl+T9%-_-}L(tES>Or-}Z4F*{##n3~L~TuxjirGuIY#H7{%$E${?p{Q01 zi6T`n;rbK1yIB9jmQNycD~yZq&mbIsFWHo|ZAChSFPQa<(%d8mGw*V3fh|yFoxOOiWJd(qvVb!Z$b88cg->N=qO*4k~6;R==|9ihg&riu#P~s4Oap9O7f%crSr^rljeIfXDEg>wi)&v*a%7zpz<9w z*r!3q9J|390x`Zk;g$&OeN&ctp)VKRpDSV@kU2Q>jtok($Y-*x8_$2piTxun81@vt z!Vj?COa0fg2RPXMSIo26T=~0d`{oGP*eV+$!0I<(4azk&Vj3SiG=Q!6mX0p$z7I}; z9BJUFgT-K9MQQ-0@Z=^7R<{bn2Fm48endsSs`V7_@%8?Bxkqv>BDoVcj?K#dV#uUP zL1ND~?D-|VGKe3Rw_7-Idpht>H6XRLh*U7epS6byiGvJpr%d}XwfusjH9g;Z98H`x zyde%%5mhGOiL4wljCaWCk-&uE4_OOccb9c!ZaWt4B(wYl!?vyzl%7n~QepN&eFUrw zFIOl9c({``6~QD+43*_tzP{f2x41h(?b43^y6=iwyB)2os5hBE!@YUS5?N_tXd=h( z)WE286Fbd>R4M^P{!G)f;h<3Q>Fipuy+d2q-)!RyTgt;wr$(?9ox3;q+{E*ZQHhOn;lM`cjnu9 zXa48ks-v(~b*;MAI<>YZH(^NV8vjb34beE<_cwKlJoR;k6lJNSP6v}uiyRD?|0w+X@o1ONrH8a$fCxXpf? z?$DL0)7|X}Oc%h^zrMKWc-NS9I0Utu@>*j}b@tJ=ixQSJ={4@854wzW@E>VSL+Y{i z#0b=WpbCZS>kUCO_iQz)LoE>P5LIG-hv9E+oG}DtlIDF>$tJ1aw9^LuhLEHt?BCj& z(O4I8v1s#HUi5A>nIS-JK{v!7dJx)^Yg%XjNmlkWAq2*cv#tHgz`Y(bETc6CuO1VkN^L-L3j_x<4NqYb5rzrLC-7uOv z!5e`GZt%B782C5-fGnn*GhDF$%(qP<74Z}3xx+{$4cYKy2ikxI7B2N+2r07DN;|-T->nU&!=Cm#rZt%O_5c&1Z%nlWq3TKAW0w zQqemZw_ue--2uKQsx+niCUou?HjD`xhEjjQd3%rrBi82crq*~#uA4+>vR<_S{~5ce z-2EIl?~s z1=GVL{NxP1N3%=AOaC}j_Fv=ur&THz zyO!d9kHq|c73kpq`$+t+8Bw7MgeR5~`d7ChYyGCBWSteTB>8WAU(NPYt2Dk`@#+}= zI4SvLlyk#pBgVigEe`?NG*vl7V6m+<}%FwPV=~PvvA)=#ths==DRTDEYh4V5}Cf$z@#;< zyWfLY_5sP$gc3LLl2x+Ii)#b2nhNXJ{R~vk`s5U7Nyu^3yFg&D%Txwj6QezMX`V(x z=C`{76*mNb!qHHs)#GgGZ_7|vkt9izl_&PBrsu@}L`X{95-2jf99K)0=*N)VxBX2q z((vkpP2RneSIiIUEnGb?VqbMb=Zia+rF~+iqslydE34cSLJ&BJW^3knX@M;t*b=EA zNvGzv41Ld_T+WT#XjDB840vovUU^FtN_)G}7v)1lPetgpEK9YS^OWFkPoE{ovj^=@ zO9N$S=G$1ecndT_=5ehth2Lmd1II-PuT~C9`XVePw$y8J#dpZ?Tss<6wtVglm(Ok7 z3?^oi@pPio6l&!z8JY(pJvG=*pI?GIOu}e^EB6QYk$#FJQ%^AIK$I4epJ+9t?KjqA+bkj&PQ*|vLttme+`9G=L% ziadyMw_7-M)hS(3E$QGNCu|o23|%O+VN7;Qggp?PB3K-iSeBa2b}V4_wY`G1Jsfz4 z9|SdB^;|I8E8gWqHKx!vj_@SMY^hLEIbSMCuE?WKq=c2mJK z8LoG-pnY!uhqFv&L?yEuxo{dpMTsmCn)95xanqBrNPTgXP((H$9N${Ow~Is-FBg%h z53;|Y5$MUN)9W2HBe2TD`ct^LHI<(xWrw}$qSoei?}s)&w$;&!14w6B6>Yr6Y8b)S z0r71`WmAvJJ`1h&poLftLUS6Ir zC$bG9!Im_4Zjse)#K=oJM9mHW1{%l8sz$1o?ltdKlLTxWWPB>Vk22czVt|1%^wnN@*!l)}?EgtvhC>vlHm^t+ogpgHI1_$1ox9e;>0!+b(tBrmXRB`PY1vp-R**8N7 zGP|QqI$m(Rdu#=(?!(N}G9QhQ%o!aXE=aN{&wtGP8|_qh+7a_j_sU5|J^)vxq;# zjvzLn%_QPHZZIWu1&mRAj;Sa_97p_lLq_{~j!M9N^1yp3U_SxRqK&JnR%6VI#^E12 z>CdOVI^_9aPK2eZ4h&^{pQs}xsijXgFYRIxJ~N7&BB9jUR1fm!(xl)mvy|3e6-B3j zJn#ajL;bFTYJ2+Q)tDjx=3IklO@Q+FFM}6UJr6km7hj7th9n_&JR7fnqC!hTZoM~T zBeaVFp%)0cbPhejX<8pf5HyRUj2>aXnXBqDJe73~J%P(2C?-RT{c3NjE`)om! zl$uewSgWkE66$Kb34+QZZvRn`fob~Cl9=cRk@Es}KQm=?E~CE%spXaMO6YmrMl%9Q zlA3Q$3|L1QJ4?->UjT&CBd!~ru{Ih^in&JXO=|<6J!&qp zRe*OZ*cj5bHYlz!!~iEKcuE|;U4vN1rk$xq6>bUWD*u(V@8sG^7>kVuo(QL@Ki;yL zWC!FT(q{E8#on>%1iAS0HMZDJg{Z{^!De(vSIq&;1$+b)oRMwA3nc3mdTSG#3uYO_ z>+x;7p4I;uHz?ZB>dA-BKl+t-3IB!jBRgdvAbW!aJ(Q{aT>+iz?91`C-xbe)IBoND z9_Xth{6?(y3rddwY$GD65IT#f3<(0o#`di{sh2gm{dw*#-Vnc3r=4==&PU^hCv$qd zjw;>i&?L*Wq#TxG$mFIUf>eK+170KG;~+o&1;Tom9}}mKo23KwdEM6UonXgc z!6N(@k8q@HPw{O8O!lAyi{rZv|DpgfU{py+j(X_cwpKqcalcqKIr0kM^%Br3SdeD> zHSKV94Yxw;pjzDHo!Q?8^0bb%L|wC;4U^9I#pd5O&eexX+Im{ z?jKnCcsE|H?{uGMqVie_C~w7GX)kYGWAg%-?8|N_1#W-|4F)3YTDC+QSq1s!DnOML3@d`mG%o2YbYd#jww|jD$gotpa)kntakp#K;+yo-_ZF9qrNZw<%#C zuPE@#3RocLgPyiBZ+R_-FJ_$xP!RzWm|aN)S+{$LY9vvN+IW~Kf3TsEIvP+B9Mtm! zpfNNxObWQpLoaO&cJh5>%slZnHl_Q~(-Tfh!DMz(dTWld@LG1VRF`9`DYKhyNv z2pU|UZ$#_yUx_B_|MxUq^glT}O5Xt(Vm4Mr02><%C)@v;vPb@pT$*yzJ4aPc_FZ3z z3}PLoMBIM>q_9U2rl^sGhk1VUJ89=*?7|v`{!Z{6bqFMq(mYiA?%KbsI~JwuqVA9$H5vDE+VocjX+G^%bieqx->s;XWlKcuv(s%y%D5Xbc9+ zc(_2nYS1&^yL*ey664&4`IoOeDIig}y-E~_GS?m;D!xv5-xwz+G`5l6V+}CpeJDi^ z%4ed$qowm88=iYG+(`ld5Uh&>Dgs4uPHSJ^TngXP_V6fPyl~>2bhi20QB%lSd#yYn zO05?KT1z@?^-bqO8Cg`;ft>ilejsw@2%RR7;`$Vs;FmO(Yr3Fp`pHGr@P2hC%QcA|X&N2Dn zYf`MqXdHi%cGR@%y7Rg7?d3?an){s$zA{!H;Ie5exE#c~@NhQUFG8V=SQh%UxUeiV zd7#UcYqD=lk-}sEwlpu&H^T_V0{#G?lZMxL7ih_&{(g)MWBnCZxtXg znr#}>U^6!jA%e}@Gj49LWG@*&t0V>Cxc3?oO7LSG%~)Y5}f7vqUUnQ;STjdDU}P9IF9d9<$;=QaXc zL1^X7>fa^jHBu_}9}J~#-oz3Oq^JmGR#?GO7b9a(=R@fw@}Q{{@`Wy1vIQ#Bw?>@X z-_RGG@wt|%u`XUc%W{J z>iSeiz8C3H7@St3mOr_mU+&bL#Uif;+Xw-aZdNYUpdf>Rvu0i0t6k*}vwU`XNO2he z%miH|1tQ8~ZK!zmL&wa3E;l?!!XzgV#%PMVU!0xrDsNNZUWKlbiOjzH-1Uoxm8E#r`#2Sz;-o&qcqB zC-O_R{QGuynW14@)7&@yw1U}uP(1cov)twxeLus0s|7ayrtT8c#`&2~Fiu2=R;1_4bCaD=*E@cYI>7YSnt)nQc zohw5CsK%m?8Ack)qNx`W0_v$5S}nO|(V|RZKBD+btO?JXe|~^Qqur%@eO~<8-L^9d z=GA3-V14ng9L29~XJ>a5k~xT2152zLhM*@zlp2P5Eu}bywkcqR;ISbas&#T#;HZSf z2m69qTV(V@EkY(1Dk3`}j)JMo%ZVJ*5eB zYOjIisi+igK0#yW*gBGj?@I{~mUOvRFQR^pJbEbzFxTubnrw(Muk%}jI+vXmJ;{Q6 zrSobKD>T%}jV4Ub?L1+MGOD~0Ir%-`iTnWZN^~YPrcP5y3VMAzQ+&en^VzKEb$K!Q z<7Dbg&DNXuow*eD5yMr+#08nF!;%4vGrJI++5HdCFcGLfMW!KS*Oi@=7hFwDG!h2< zPunUEAF+HncQkbfFj&pbzp|MU*~60Z(|Ik%Tn{BXMN!hZOosNIseT?R;A`W?=d?5X zK(FB=9mZusYahp|K-wyb={rOpdn=@;4YI2W0EcbMKyo~-#^?h`BA9~o285%oY zfifCh5Lk$SY@|2A@a!T2V+{^!psQkx4?x0HSV`(w9{l75QxMk!)U52Lbhn{8ol?S) zCKo*7R(z!uk<6*qO=wh!Pul{(qq6g6xW;X68GI_CXp`XwO zxuSgPRAtM8K7}5E#-GM!*ydOOG_{A{)hkCII<|2=ma*71ci_-}VPARm3crFQjLYV! z9zbz82$|l01mv`$WahE2$=fAGWkd^X2kY(J7iz}WGS z@%MyBEO=A?HB9=^?nX`@nh;7;laAjs+fbo!|K^mE!tOB>$2a_O0y-*uaIn8k^6Y zSbuv;5~##*4Y~+y7Z5O*3w4qgI5V^17u*ZeupVGH^nM&$qmAk|anf*>r zWc5CV;-JY-Z@Uq1Irpb^O`L_7AGiqd*YpGUShb==os$uN3yYvb`wm6d=?T*it&pDk zo`vhw)RZX|91^^Wa_ti2zBFyWy4cJu#g)_S6~jT}CC{DJ_kKpT`$oAL%b^!2M;JgT zM3ZNbUB?}kP(*YYvXDIH8^7LUxz5oE%kMhF!rnPqv!GiY0o}NR$OD=ITDo9r%4E>E0Y^R(rS^~XjWyVI6 zMOR5rPXhTp*G*M&X#NTL`Hu*R+u*QNoiOKg4CtNPrjgH>c?Hi4MUG#I917fx**+pJfOo!zFM&*da&G_x)L(`k&TPI*t3e^{crd zX<4I$5nBQ8Ax_lmNRa~E*zS-R0sxkz`|>7q_?*e%7bxqNm3_eRG#1ae3gtV9!fQpY z+!^a38o4ZGy9!J5sylDxZTx$JmG!wg7;>&5H1)>f4dXj;B+@6tMlL=)cLl={jLMxY zbbf1ax3S4>bwB9-$;SN2?+GULu;UA-35;VY*^9Blx)Jwyb$=U!D>HhB&=jSsd^6yw zL)?a|>GxU!W}ocTC(?-%z3!IUhw^uzc`Vz_g>-tv)(XA#JK^)ZnC|l1`@CdX1@|!| z_9gQ)7uOf?cR@KDp97*>6X|;t@Y`k_N@)aH7gY27)COv^P3ya9I{4z~vUjLR9~z1Z z5=G{mVtKH*&$*t0@}-i_v|3B$AHHYale7>E+jP`ClqG%L{u;*ff_h@)al?RuL7tOO z->;I}>%WI{;vbLP3VIQ^iA$4wl6@0sDj|~112Y4OFjMs`13!$JGkp%b&E8QzJw_L5 zOnw9joc0^;O%OpF$Qp)W1HI!$4BaXX84`%@#^dk^hFp^pQ@rx4g(8Xjy#!X%+X5Jd@fs3amGT`}mhq#L97R>OwT5-m|h#yT_-v@(k$q7P*9X~T*3)LTdzP!*B} z+SldbVWrrwQo9wX*%FyK+sRXTa@O?WM^FGWOE?S`R(0P{<6p#f?0NJvnBia?k^fX2 zNQs7K-?EijgHJY}&zsr;qJ<*PCZUd*x|dD=IQPUK_nn)@X4KWtqoJNHkT?ZWL_hF? zS8lp2(q>;RXR|F;1O}EE#}gCrY~#n^O`_I&?&z5~7N;zL0)3Tup`%)oHMK-^r$NT% zbFg|o?b9w(q@)6w5V%si<$!U<#}s#x@0aX-hP>zwS#9*75VXA4K*%gUc>+yzupTDBOKH8WR4V0pM(HrfbQ&eJ79>HdCvE=F z|J>s;;iDLB^3(9}?biKbxf1$lI!*Z%*0&8UUq}wMyPs_hclyQQi4;NUY+x2qy|0J; zhn8;5)4ED1oHwg+VZF|80<4MrL97tGGXc5Sw$wAI#|2*cvQ=jB5+{AjMiDHmhUC*a zlmiZ`LAuAn_}hftXh;`Kq0zblDk8?O-`tnilIh|;3lZp@F_osJUV9`*R29M?7H{Fy z`nfVEIDIWXmU&YW;NjU8)EJpXhxe5t+scf|VXM!^bBlwNh)~7|3?fWwo_~ZFk(22% zTMesYw+LNx3J-_|DM~`v93yXe=jPD{q;li;5PD?Dyk+b? zo21|XpT@)$BM$%F=P9J19Vi&1#{jM3!^Y&fr&_`toi`XB1!n>sbL%U9I5<7!@?t)~ z;&H%z>bAaQ4f$wIzkjH70;<8tpUoxzKrPhn#IQfS%9l5=Iu))^XC<58D!-O z{B+o5R^Z21H0T9JQ5gNJnqh#qH^na|z92=hONIM~@_iuOi|F>jBh-?aA20}Qx~EpDGElELNn~|7WRXRFnw+Wdo`|# zBpU=Cz3z%cUJ0mx_1($X<40XEIYz(`noWeO+x#yb_pwj6)R(__%@_Cf>txOQ74wSJ z0#F3(zWWaR-jMEY$7C*3HJrohc79>MCUu26mfYN)f4M~4gD`}EX4e}A!U}QV8!S47 z6y-U-%+h`1n`*pQuKE%Av0@)+wBZr9mH}@vH@i{v(m-6QK7Ncf17x_D=)32`FOjjo zg|^VPf5c6-!FxN{25dvVh#fog=NNpXz zfB$o+0jbRkHH{!TKhE709f+jI^$3#v1Nmf80w`@7-5$1Iv_`)W^px8P-({xwb;D0y z7LKDAHgX<84?l!I*Dvi2#D@oAE^J|g$3!)x1Ua;_;<@#l1fD}lqU2_tS^6Ht$1Wl} zBESo7o^)9-Tjuz$8YQSGhfs{BQV6zW7dA?0b(Dbt=UnQs&4zHfe_sj{RJ4uS-vQpC zX;Bbsuju4%!o8?&m4UZU@~ZZjeFF6ex2ss5_60_JS_|iNc+R0GIjH1@Z z=rLT9%B|WWgOrR7IiIwr2=T;Ne?30M!@{%Qf8o`!>=s<2CBpCK_TWc(DX51>e^xh8 z&@$^b6CgOd7KXQV&Y4%}_#uN*mbanXq(2=Nj`L7H7*k(6F8s6{FOw@(DzU`4-*77{ zF+dxpv}%mFpYK?>N_2*#Y?oB*qEKB}VoQ@bzm>ptmVS_EC(#}Lxxx730trt0G)#$b zE=wVvtqOct1%*9}U{q<)2?{+0TzZzP0jgf9*)arV)*e!f`|jgT{7_9iS@e)recI#z zbzolURQ+TOzE!ymqvBY7+5NnAbWxvMLsLTwEbFqW=CPyCsmJ}P1^V30|D5E|p3BC5 z)3|qgw@ra7aXb-wsa|l^in~1_fm{7bS9jhVRkYVO#U{qMp z)Wce+|DJ}4<2gp8r0_xfZpMo#{Hl2MfjLcZdRB9(B(A(f;+4s*FxV{1F|4d`*sRNd zp4#@sEY|?^FIJ;tmH{@keZ$P(sLh5IdOk@k^0uB^BWr@pk6mHy$qf&~rI>P*a;h0C{%oA*i!VjWn&D~O#MxN&f@1Po# zKN+ zrGrkSjcr?^R#nGl<#Q722^wbYcgW@{+6CBS<1@%dPA8HC!~a`jTz<`g_l5N1M@9wn9GOAZ>nqNgq!yOCbZ@1z`U_N`Z>}+1HIZxk*5RDc&rd5{3qjRh8QmT$VyS;jK z;AF+r6XnnCp=wQYoG|rT2@8&IvKq*IB_WvS%nt%e{MCFm`&W*#LXc|HrD?nVBo=(8*=Aq?u$sDA_sC_RPDUiQ+wnIJET8vx$&fxkW~kP9qXKt zozR)@xGC!P)CTkjeWvXW5&@2?)qt)jiYWWBU?AUtzAN}{JE1I)dfz~7$;}~BmQF`k zpn11qmObXwRB8&rnEG*#4Xax3XBkKlw(;tb?Np^i+H8m(Wyz9k{~ogba@laiEk;2! zV*QV^6g6(QG%vX5Um#^sT&_e`B1pBW5yVth~xUs#0}nv?~C#l?W+9Lsb_5)!71rirGvY zTIJ$OPOY516Y|_014sNv+Z8cc5t_V=i>lWV=vNu#!58y9Zl&GsMEW#pPYPYGHQ|;vFvd*9eM==$_=vc7xnyz0~ zY}r??$<`wAO?JQk@?RGvkWVJlq2dk9vB(yV^vm{=NVI8dhsX<)O(#nr9YD?I?(VmQ z^r7VfUBn<~p3()8yOBjm$#KWx!5hRW)5Jl7wY@ky9lNM^jaT##8QGVsYeaVywmpv>X|Xj7gWE1Ezai&wVLt3p)k4w~yrskT-!PR!kiyQlaxl(( zXhF%Q9x}1TMt3~u@|#wWm-Vq?ZerK={8@~&@9r5JW}r#45#rWii};t`{5#&3$W)|@ zbAf2yDNe0q}NEUvq_Quq3cTjcw z@H_;$hu&xllCI9CFDLuScEMg|x{S7GdV8<&Mq=ezDnRZAyX-8gv97YTm0bg=d)(>N z+B2FcqvI9>jGtnK%eO%y zoBPkJTk%y`8TLf4)IXPBn`U|9>O~WL2C~C$z~9|0m*YH<-vg2CD^SX#&)B4ngOSG$ zV^wmy_iQk>dfN@Pv(ckfy&#ak@MLC7&Q6Ro#!ezM*VEh`+b3Jt%m(^T&p&WJ2Oqvj zs-4nq0TW6cv~(YI$n0UkfwN}kg3_fp?(ijSV#tR9L0}l2qjc7W?i*q01=St0eZ=4h zyGQbEw`9OEH>NMuIe)hVwYHsGERWOD;JxEiO7cQv%pFCeR+IyhwQ|y@&^24k+|8fD zLiOWFNJ2&vu2&`Jv96_z-Cd5RLgmeY3*4rDOQo?Jm`;I_(+ejsPM03!ly!*Cu}Cco zrQSrEDHNyzT(D5s1rZq!8#?f6@v6dB7a-aWs(Qk>N?UGAo{gytlh$%_IhyL7h?DLXDGx zgxGEBQoCAWo-$LRvM=F5MTle`M})t3vVv;2j0HZY&G z22^iGhV@uaJh(XyyY%} zd4iH_UfdV#T=3n}(Lj^|n;O4|$;xhu*8T3hR1mc_A}fK}jfZ7LX~*n5+`8N2q#rI$ z@<_2VANlYF$vIH$ zl<)+*tIWW78IIINA7Rr7i{<;#^yzxoLNkXL)eSs=%|P>$YQIh+ea_3k z_s7r4%j7%&*NHSl?R4k%1>Z=M9o#zxY!n8sL5>BO-ZP;T3Gut>iLS@U%IBrX6BA3k z)&@q}V8a{X<5B}K5s(c(LQ=%v1ocr`t$EqqY0EqVjr65usa=0bkf|O#ky{j3)WBR(((L^wmyHRzoWuL2~WTC=`yZ zn%VX`L=|Ok0v7?s>IHg?yArBcync5rG#^+u)>a%qjES%dRZoIyA8gQ;StH z1Ao7{<&}6U=5}4v<)1T7t!J_CL%U}CKNs-0xWoTTeqj{5{?Be$L0_tk>M9o8 zo371}S#30rKZFM{`H_(L`EM9DGp+Mifk&IP|C2Zu_)Ghr4Qtpmkm1osCf@%Z$%t+7 zYH$Cr)Ro@3-QDeQJ8m+x6%;?YYT;k6Z0E-?kr>x33`H%*ueBD7Zx~3&HtWn0?2Wt} zTG}*|v?{$ajzt}xPzV%lL1t-URi8*Zn)YljXNGDb>;!905Td|mpa@mHjIH%VIiGx- zd@MqhpYFu4_?y5N4xiHn3vX&|e6r~Xt> zZG`aGq|yTNjv;9E+Txuoa@A(9V7g?1_T5FzRI;!=NP1Kqou1z5?%X~Wwb{trRfd>i z8&y^H)8YnKyA_Fyx>}RNmQIczT?w2J4SNvI{5J&}Wto|8FR(W;Qw#b1G<1%#tmYzQ zQ2mZA-PAdi%RQOhkHy9Ea#TPSw?WxwL@H@cbkZwIq0B!@ns}niALidmn&W?!Vd4Gj zO7FiuV4*6Mr^2xlFSvM;Cp_#r8UaqIzHJQg_z^rEJw&OMm_8NGAY2)rKvki|o1bH~ z$2IbfVeY2L(^*rMRU1lM5Y_sgrDS`Z??nR2lX;zyR=c%UyGb*%TC-Dil?SihkjrQy~TMv6;BMs7P8il`H7DmpVm@rJ;b)hW)BL)GjS154b*xq-NXq2cwE z^;VP7ua2pxvCmxrnqUYQMH%a%nHmwmI33nJM(>4LznvY*k&C0{8f*%?zggpDgkuz&JBx{9mfb@wegEl2v!=}Sq2Gaty0<)UrOT0{MZtZ~j5y&w zXlYa_jY)I_+VA-^#mEox#+G>UgvM!Ac8zI<%JRXM_73Q!#i3O|)lOP*qBeJG#BST0 zqohi)O!|$|2SeJQo(w6w7%*92S})XfnhrH_Z8qe!G5>CglP=nI7JAOW?(Z29;pXJ9 zR9`KzQ=WEhy*)WH>$;7Cdz|>*i>=##0bB)oU0OR>>N<21e4rMCHDemNi2LD>Nc$;& zQRFthpWniC1J6@Zh~iJCoLOxN`oCKD5Q4r%ynwgUKPlIEd#?QViIqovY|czyK8>6B zSP%{2-<;%;1`#0mG^B(8KbtXF;Nf>K#Di72UWE4gQ%(_26Koiad)q$xRL~?pN71ZZ zujaaCx~jXjygw;rI!WB=xrOJO6HJ!!w}7eiivtCg5K|F6$EXa)=xUC za^JXSX98W`7g-tm@uo|BKj39Dl;sg5ta;4qjo^pCh~{-HdLl6qI9Ix6f$+qiZ$}s= zNguKrU;u+T@ko(Vr1>)Q%h$?UKXCY>3se%&;h2osl2D zE4A9bd7_|^njDd)6cI*FupHpE3){4NQ*$k*cOWZ_?CZ>Z4_fl@n(mMnYK62Q1d@+I zr&O))G4hMihgBqRIAJkLdk(p(D~X{-oBUA+If@B}j& zsHbeJ3RzTq96lB7d($h$xTeZ^gP0c{t!Y0c)aQE;$FY2!mACg!GDEMKXFOPI^)nHZ z`aSPJpvV0|bbrzhWWkuPURlDeN%VT8tndV8?d)eN*i4I@u zVKl^6{?}A?P)Fsy?3oi#clf}L18t;TjNI2>eI&(ezDK7RyqFxcv%>?oxUlonv(px) z$vnPzRH`y5A(x!yOIfL0bmgeMQB$H5wenx~!ujQK*nUBW;@Em&6Xv2%s(~H5WcU2R z;%Nw<$tI)a`Ve!>x+qegJnQsN2N7HaKzrFqM>`6R*gvh%O*-%THt zrB$Nk;lE;z{s{r^PPm5qz(&lM{sO*g+W{sK+m3M_z=4=&CC>T`{X}1Vg2PEfSj2x_ zmT*(x;ov%3F?qoEeeM>dUn$a*?SIGyO8m806J1W1o+4HRhc2`9$s6hM#qAm zChQ87b~GEw{ADfs+5}FJ8+|bIlIv(jT$Ap#hSHoXdd9#w<#cA<1Rkq^*EEkknUd4& zoIWIY)sAswy6fSERVm&!SO~#iN$OgOX*{9@_BWFyJTvC%S++ilSfCrO(?u=Dc?CXZ zzCG&0yVR{Z`|ZF0eEApWEo#s9osV>F{uK{QA@BES#&;#KsScf>y zvs?vIbI>VrT<*!;XmQS=bhq%46-aambZ(8KU-wOO2=en~D}MCToB_u;Yz{)1ySrPZ z@=$}EvjTdzTWU7c0ZI6L8=yP+YRD_eMMos}b5vY^S*~VZysrkq<`cK3>>v%uy7jgq z0ilW9KjVDHLv0b<1K_`1IkbTOINs0=m-22c%M~l=^S}%hbli-3?BnNq?b`hx^HX2J zIe6ECljRL0uBWb`%{EA=%!i^4sMcj+U_TaTZRb+~GOk z^ZW!nky0n*Wb*r+Q|9H@ml@Z5gU&W`(z4-j!OzC1wOke`TRAYGZVl$PmQ16{3196( zO*?`--I}Qf(2HIwb2&1FB^!faPA2=sLg(@6P4mN)>Dc3i(B0;@O-y2;lM4akD>@^v z=u>*|!s&9zem70g7zfw9FXl1bpJW(C#5w#uy5!V?Q(U35A~$dR%LDVnq@}kQm13{} zd53q3N(s$Eu{R}k2esbftfjfOITCL;jWa$}(mmm}d(&7JZ6d3%IABCapFFYjdEjdK z&4Edqf$G^MNAtL=uCDRs&Fu@FXRgX{*0<(@c3|PNHa>L%zvxWS={L8%qw`STm+=Rd zA}FLspESSIpE_^41~#5yI2bJ=9`oc;GIL!JuW&7YetZ?0H}$$%8rW@*J37L-~Rsx!)8($nI4 zZhcZ2^=Y+p4YPl%j!nFJA|*M^gc(0o$i3nlphe+~-_m}jVkRN{spFs(o0ajW@f3K{ zDV!#BwL322CET$}Y}^0ixYj2w>&Xh12|R8&yEw|wLDvF!lZ#dOTHM9pK6@Nm-@9Lnng4ZHBgBSrr7KI8YCC9DX5Kg|`HsiwJHg2(7#nS;A{b3tVO?Z% za{m5b3rFV6EpX;=;n#wltDv1LE*|g5pQ+OY&*6qCJZc5oDS6Z6JD#6F)bWxZSF@q% z+1WV;m!lRB!n^PC>RgQCI#D1br_o^#iPk>;K2hB~0^<~)?p}LG%kigm@moD#q3PE+ zA^Qca)(xnqw6x>XFhV6ku9r$E>bWNrVH9fum0?4s?Rn2LG{Vm_+QJHse6xa%nzQ?k zKug4PW~#Gtb;#5+9!QBgyB@q=sk9=$S{4T>wjFICStOM?__fr+Kei1 z3j~xPqW;W@YkiUM;HngG!;>@AITg}vAE`M2Pj9Irl4w1fo4w<|Bu!%rh%a(Ai^Zhi zs92>v5;@Y(Zi#RI*ua*h`d_7;byQSa*v9E{2x$<-_=5Z<7{%)}4XExANcz@rK69T0x3%H<@frW>RA8^swA+^a(FxK| zFl3LD*ImHN=XDUkrRhp6RY5$rQ{bRgSO*(vEHYV)3Mo6Jy3puiLmU&g82p{qr0F?ohmbz)f2r{X2|T2 z$4fdQ=>0BeKbiVM!e-lIIs8wVTuC_m7}y4A_%ikI;Wm5$9j(^Y z(cD%U%k)X>_>9~t8;pGzL6L-fmQO@K; zo&vQzMlgY95;1BSkngY)e{`n0!NfVgf}2mB3t}D9@*N;FQ{HZ3Pb%BK6;5#-O|WI( zb6h@qTLU~AbVW#_6?c!?Dj65Now7*pU{h!1+eCV^KCuPAGs28~3k@ueL5+u|Z-7}t z9|lskE`4B7W8wMs@xJa{#bsCGDFoRSNSnmNYB&U7 zVGKWe%+kFB6kb)e;TyHfqtU6~fRg)f|>=5(N36)0+C z`hv65J<$B}WUc!wFAb^QtY31yNleq4dzmG`1wHTj=c*=hay9iD071Hc?oYoUk|M*_ zU1GihAMBsM@5rUJ(qS?9ZYJ6@{bNqJ`2Mr+5#hKf?doa?F|+^IR!8lq9)wS3tF_9n zW_?hm)G(M+MYb?V9YoX^_mu5h-LP^TL^!Q9Z7|@sO(rg_4+@=PdI)WL(B7`!K^ND- z-uIuVDCVEdH_C@c71YGYT^_Scf_dhB8Z2Xy6vGtBSlYud9vggOqv^L~F{BraSE_t} zIkP+Hp2&nH^-MNEs}^`oMLy11`PQW$T|K(`Bu*(f@)mv1-qY(_YG&J2M2<7k;;RK~ zL{Fqj9yCz8(S{}@c)S!65aF<=&eLI{hAMErCx&>i7OeDN>okvegO87OaG{Jmi<|}D zaT@b|0X{d@OIJ7zvT>r+eTzgLq~|Dpu)Z&db-P4z*`M$UL51lf>FLlq6rfG)%doyp z)3kk_YIM!03eQ8Vu_2fg{+osaEJPtJ-s36R+5_AEG12`NG)IQ#TF9c@$99%0iye+ zUzZ57=m2)$D(5Nx!n)=5Au&O0BBgwxIBaeI(mro$#&UGCr<;C{UjJVAbVi%|+WP(a zL$U@TYCxJ=1{Z~}rnW;7UVb7+ZnzgmrogDxhjLGo>c~MiJAWs&&;AGg@%U?Y^0JhL ze(x6Z74JG6FlOFK(T}SXQfhr}RIFl@QXKnIcXYF)5|V~e-}suHILKT-k|<*~Ij|VF zC;t@=uj=hot~*!C68G8hTA%8SzOfETOXQ|3FSaIEjvBJp(A)7SWUi5!Eu#yWgY+;n zlm<$+UDou*V+246_o#V4kMdto8hF%%Lki#zPh}KYXmMf?hrN0;>Mv%`@{0Qn`Ujp) z=lZe+13>^Q!9zT);H<(#bIeRWz%#*}sgUX9P|9($kexOyKIOc`dLux}c$7It4u|Rl z6SSkY*V~g_B-hMPo_ak>>z@AVQ(_N)VY2kB3IZ0G(iDUYw+2d7W^~(Jq}KY=JnWS( z#rzEa&0uNhJ>QE8iiyz;n2H|SV#Og+wEZv=f2%1ELX!SX-(d3tEj$5$1}70Mp<&eI zCkfbByL7af=qQE@5vDVxx1}FSGt_a1DoE3SDI+G)mBAna)KBG4p8Epxl9QZ4BfdAN zFnF|Y(umr;gRgG6NLQ$?ZWgllEeeq~z^ZS7L?<(~O&$5|y)Al^iMKy}&W+eMm1W z7EMU)u^ke(A1#XCV>CZ71}P}0x)4wtHO8#JRG3MA-6g=`ZM!FcICCZ{IEw8Dm2&LQ z1|r)BUG^0GzI6f946RrBlfB1Vs)~8toZf~7)+G;pv&XiUO(%5bm)pl=p>nV^o*;&T z;}@oZSibzto$arQgfkp|z4Z($P>dTXE{4O=vY0!)kDO* zGF8a4wq#VaFpLfK!iELy@?-SeRrdz%F*}hjKcA*y@mj~VD3!it9lhRhX}5YOaR9$} z3mS%$2Be7{l(+MVx3 z(4?h;P!jnRmX9J9sYN#7i=iyj_5q7n#X(!cdqI2lnr8T$IfOW<_v`eB!d9xY1P=2q&WtOXY=D9QYteP)De?S4}FK6#6Ma z=E*V+#s8>L;8aVroK^6iKo=MH{4yEZ_>N-N z`(|;aOATba1^asjxlILk<4}f~`39dBFlxj>Dw(hMYKPO3EEt1@S`1lxFNM+J@uB7T zZ8WKjz7HF1-5&2=l=fqF-*@>n5J}jIxdDwpT?oKM3s8Nr`x8JnN-kCE?~aM1H!hAE z%%w(3kHfGwMnMmNj(SU(w42OrC-euI>Dsjk&jz3ts}WHqmMpzQ3vZrsXrZ|}+MHA7 z068obeXZTsO*6RS@o3x80E4ok``rV^Y3hr&C1;|ZZ0|*EKO`$lECUYG2gVFtUTw)R z4Um<0ZzlON`zTdvVdL#KFoMFQX*a5wM0Czp%wTtfK4Sjs)P**RW&?lP$(<}q%r68Z zS53Y!d@&~ne9O)A^tNrXHhXBkj~$8j%pT1%%mypa9AW5E&s9)rjF4@O3ytH{0z6riz|@< zB~UPh*wRFg2^7EbQrHf0y?E~dHlkOxof_a?M{LqQ^C!i2dawHTPYUE=X@2(3<=OOxs8qn_(y>pU>u^}3y&df{JarR0@VJn0f+U%UiF=$Wyq zQvnVHESil@d|8&R<%}uidGh7@u^(%?$#|&J$pvFC-n8&A>utA=n3#)yMkz+qnG3wd zP7xCnF|$9Dif@N~L)Vde3hW8W!UY0BgT2v(wzp;tlLmyk2%N|0jfG$%<;A&IVrOI< z!L)o>j>;dFaqA3pL}b-Je(bB@VJ4%!JeX@3x!i{yIeIso^=n?fDX`3bU=eG7sTc%g%ye8$v8P@yKE^XD=NYxTb zbf!Mk=h|otpqjFaA-vs5YOF-*GwWPc7VbaOW&stlANnCN8iftFMMrUdYNJ_Bnn5Vt zxfz@Ah|+4&P;reZxp;MmEI7C|FOv8NKUm8njF7Wb6Gi7DeODLl&G~}G4be&*Hi0Qw z5}77vL0P+7-B%UL@3n1&JPxW^d@vVwp?u#gVcJqY9#@-3X{ok#UfW3<1fb%FT`|)V~ggq z(3AUoUS-;7)^hCjdT0Kf{i}h)mBg4qhtHHBti=~h^n^OTH5U*XMgDLIR@sre`AaB$ zg)IGBET_4??m@cx&c~bA80O7B8CHR7(LX7%HThkeC*@vi{-pL%e)yXp!B2InafbDF zjPXf1mko3h59{lT6EEbxKO1Z5GF71)WwowO6kY|6tjSVSWdQ}NsK2x{>i|MKZK8%Q zfu&_0D;CO-Jg0#YmyfctyJ!mRJp)e#@O0mYdp|8x;G1%OZQ3Q847YWTyy|%^cpA;m zze0(5p{tMu^lDkpe?HynyO?a1$_LJl2L&mpeKu%8YvgRNr=%2z${%WThHG=vrWY@4 zsA`OP#O&)TetZ>s%h!=+CE15lOOls&nvC~$Qz0Ph7tHiP;O$i|eDwpT{cp>+)0-|; zY$|bB+Gbel>5aRN3>c0x)4U=|X+z+{ zn*_p*EQoquRL+=+p;=lm`d71&1NqBz&_ph)MXu(Nv6&XE7(RsS)^MGj5Q?Fwude-(sq zjJ>aOq!7!EN>@(fK7EE#;i_BGvli`5U;r!YA{JRodLBc6-`n8K+Fjgwb%sX;j=qHQ z7&Tr!)!{HXoO<2BQrV9Sw?JRaLXV8HrsNevvnf>Y-6|{T!pYLl7jp$-nEE z#X!4G4L#K0qG_4Z;Cj6=;b|Be$hi4JvMH!-voxqx^@8cXp`B??eFBz2lLD8RRaRGh zn7kUfy!YV~p(R|p7iC1Rdgt$_24i0cd-S8HpG|`@my70g^y`gu%#Tf_L21-k?sRRZHK&at(*ED0P8iw{7?R$9~OF$Ko;Iu5)ur5<->x!m93Eb zFYpIx60s=Wxxw=`$aS-O&dCO_9?b1yKiPCQmSQb>T)963`*U+Ydj5kI(B(B?HNP8r z*bfSBpSu)w(Z3j7HQoRjUG(+d=IaE~tv}y14zHHs|0UcN52fT8V_<@2ep_ee{QgZG zmgp8iv4V{k;~8@I%M3<#B;2R>Ef(Gg_cQM7%}0s*^)SK6!Ym+~P^58*wnwV1BW@eG z4sZLqsUvBbFsr#8u7S1r4teQ;t)Y@jnn_m5jS$CsW1um!p&PqAcc8!zyiXHVta9QC zY~wCwCF0U%xiQPD_INKtTb;A|Zf29(mu9NI;E zc-e>*1%(LSXB`g}kd`#}O;veb<(sk~RWL|f3ljxCnEZDdNSTDV6#Td({6l&y4IjKF z^}lIUq*ZUqgTPumD)RrCN{M^jhY>E~1pn|KOZ5((%F)G|*ZQ|r4zIbrEiV%42hJV8 z3xS)=!X1+=olbdGJ=yZil?oXLct8FM{(6ikLL3E%=q#O6(H$p~gQu6T8N!plf!96| z&Q3=`L~>U0zZh;z(pGR2^S^{#PrPxTRHD1RQOON&f)Siaf`GLj#UOk&(|@0?zm;Sx ztsGt8=29-MZs5CSf1l1jNFtNt5rFNZxJPvkNu~2}7*9468TWm>nN9TP&^!;J{-h)_ z7WsHH9|F%I`Pb!>KAS3jQWKfGivTVkMJLO-HUGM_a4UQ_%RgL6WZvrW+Z4ujZn;y@ zz9$=oO!7qVTaQAA^BhX&ZxS*|5dj803M=k&2%QrXda`-Q#IoZL6E(g+tN!6CA!CP* zCpWtCujIea)ENl0liwVfj)Nc<9mV%+e@=d`haoZ*`B7+PNjEbXBkv=B+Pi^~L#EO$D$ZqTiD8f<5$eyb54-(=3 zh)6i8i|jp(@OnRrY5B8t|LFXFQVQ895n*P16cEKTrT*~yLH6Z4e*bZ5otpRDri&+A zfNbK1D5@O=sm`fN=WzWyse!za5n%^+6dHPGX#8DyIK>?9qyX}2XvBWVqbP%%D)7$= z=#$WulZlZR<{m#gU7lwqK4WS1Ne$#_P{b17qe$~UOXCl>5b|6WVh;5vVnR<%d+Lnp z$uEmML38}U4vaW8>shm6CzB(Wei3s#NAWE3)a2)z@i{4jTn;;aQS)O@l{rUM`J@K& l00vQ5JBs~;vo!vr%%-k{2_Fq1Mn4QF81S)AQ99zk{{c4yR+0b! literal 0 HcmV?d00001 diff --git a/gradle/wrapper/gradle-wrapper.properties b/gradle/wrapper/gradle-wrapper.properties new file mode 100644 index 0000000..1af9e09 --- /dev/null +++ b/gradle/wrapper/gradle-wrapper.properties @@ -0,0 +1,7 @@ +distributionBase=GRADLE_USER_HOME +distributionPath=wrapper/dists +distributionUrl=https\://services.gradle.org/distributions/gradle-8.5-bin.zip +networkTimeout=10000 +validateDistributionUrl=true +zipStoreBase=GRADLE_USER_HOME +zipStorePath=wrapper/dists diff --git a/gradlew b/gradlew new file mode 100755 index 0000000..e21cc5f --- /dev/null +++ b/gradlew @@ -0,0 +1,48 @@ +#!/usr/bin/env sh + +############################################################################## +## +## Gradle start up script for UN*X +## +############################################################################## + +# Attempt to set APP_HOME +PRG="$0" +while [ -h "$PRG" ]; do + ls=`ls -ld "$PRG"` + link=`expr "$ls" : '.*-> \(.*\)$'` + if expr "$link" : '/.*' > /dev/null; then + PRG="$link" + else + PRG=`dirname "$PRG"`"/$link" + fi +done +SAVED="`pwd`" +CDPATH= cd "`dirname \"$PRG\"`/" >/dev/null 2>&1 +APP_HOME="`pwd -P`" +cd "$SAVED" >/dev/null 2>&1 + +APP_NAME="Gradle" +APP_BASE_NAME=`basename "$0"` + +CLASSPATH=$APP_HOME/gradle/wrapper/gradle-wrapper.jar + +if [ -n "$JAVA_HOME" ] ; then + if [ -x "$JAVA_HOME/jre/sh/java" ] ; then + JAVACMD="$JAVA_HOME/jre/sh/java" + else + JAVACMD="$JAVA_HOME/bin/java" + fi + if [ ! -x "$JAVACMD" ] ; then + echo "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME" >&2 + exit 1 + fi +else + JAVACMD="java" + which java >/dev/null 2>&1 || { + echo "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH." >&2 + exit 1 + } +fi + +exec "$JAVACMD" "-Dorg.gradle.appname=$APP_BASE_NAME" -classpath "$CLASSPATH" org.gradle.wrapper.GradleWrapperMain "$@" diff --git a/gradlew.bat b/gradlew.bat new file mode 100644 index 0000000..81fccb1 --- /dev/null +++ b/gradlew.bat @@ -0,0 +1,81 @@ +@rem +@rem Copyright 2015 the original author or authors. +@rem +@rem Licensed under the Apache License, Version 2.0 (the "License"); +@rem you may not use this file except in compliance with the License. +@rem You may obtain a copy of the License at +@rem +@rem https://www.apache.org/licenses/LICENSE-2.0 +@rem +@rem Unless required by applicable law or agreed to in writing, software +@rem distributed under the License is distributed on an "AS IS" BASIS, +@rem WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +@rem See the License for the specific language governing permissions and +@rem limitations under the License. +@rem + +@if "%DEBUG%" == "" @echo off +@rem ########################################################################## +@rem +@rem Gradle startup script for Windows +@rem +@rem ########################################################################## + +@rem Set local scope for the variables with windows NT shell +if "%OS%"=="Windows_NT" setlocal + +set DIRNAME=%~dp0 +if "%DIRNAME%" == "" set DIRNAME=. +set APP_BASE_NAME=%~n0 +set APP_HOME=%DIRNAME% + +@rem Resolve any "." and ".." in APP_HOME to make it shorter. +for %%i in ("%APP_HOME%") do set APP_HOME=%%~fi + +@rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. +set DEFAULT_JVM_OPTS="-Xmx64m" "-Xms64m" + +@rem Find java.exe +if defined JAVA_HOME goto findJavaFromJavaHome + +set JAVA_EXE=java.exe +%JAVA_EXE% -version >NUL 2>&1 +if "%ERRORLEVEL%" == "0" goto execute + +echo. +echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. +echo. +echo Please set the JAVA_HOME variable in your environment to match the +echo location of your Java installation. +goto fail + +:findJavaFromJavaHome +set JAVA_HOME=%JAVA_HOME:"=% +set JAVA_EXE=%JAVA_HOME%/bin/java.exe + +if exist "%JAVA_EXE%" goto execute + +echo. +echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% +echo. +echo Please set the JAVA_HOME variable in your environment to match the +echo location of your Java installation. +goto fail + +:execute +@rem Setup the command line + +set CLASSPATH=%APP_HOME%\gradle\wrapper\gradle-wrapper.jar + +@rem Execute Gradle +"%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" org.gradle.wrapper.GradleWrapperMain %* + +:fail +rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of +rem the _cmd.exe /c_ return code! +if %ERRORLEVEL% == 0 goto mainEnd +:error +exit /b %ERRORLEVEL% + +:mainEnd +if "%OS%"=="Windows_NT" endlocal diff --git a/local.properties b/local.properties new file mode 100644 index 0000000..6a92907 --- /dev/null +++ b/local.properties @@ -0,0 +1 @@ +sdk.dir=/root/android-sdk diff --git a/run.bat b/run.bat new file mode 100644 index 0000000..324ea64 --- /dev/null +++ b/run.bat @@ -0,0 +1,44 @@ +@echo off +chcp 65001 > nul +echo ========================================================== +echo Android 音声操作端末 Docker 実行ランチャー +echo ========================================================== + +set MODE=%~1 +if "%MODE%"=="" set MODE=simulation + +if /i "%MODE%"=="simulation" goto run_sim +if /i "%MODE%"=="demo" goto run_sim +if /i "%MODE%"=="interactive" goto run_interactive +if /i "%MODE%"=="cli" goto run_interactive +if /i "%MODE%"=="test" goto run_test +if /i "%MODE%"=="build" goto run_build + +echo 使用方法: run.bat [モード] +echo simulation (または指定なし) : デモ入力シナリオの動作検証 +echo interactive : 対話型CLIによる任意音声コマンド検証 +echo test : 全ユニットテストの実行 +echo build : Debug APKのビルド +goto end + +:run_sim +echo [INFO] デモ・動作シミュレーションを実行中... +docker compose run --rm simulation +goto end + +:run_interactive +echo [INFO] 対話型シミュレーションを起動中... +docker compose run --rm -it interactive +goto end + +:run_test +echo [INFO] 単体テストを実行中... +docker compose run --rm test +goto end + +:run_build +echo [INFO] Android APK ビルドを実行中... +docker compose run --rm build +goto end + +:end diff --git a/run.sh b/run.sh new file mode 100755 index 0000000..670086a --- /dev/null +++ b/run.sh @@ -0,0 +1,34 @@ +#!/bin/bash +set -e + +echo "==========================================================" +echo " 📱 Android 音声操作端末 Docker 実行ランチャー " +echo "==========================================================" + +MODE=${1:-"simulation"} + +case "$MODE" in + "simulation"|"demo") + echo "[INFO] デモ・動作シミュレーションを実行中..." + docker compose run --rm simulation + ;; + "interactive"|"cli") + echo "[INFO] 対話型シミュレーションを起動中..." + docker compose run --rm interactive + ;; + "test") + echo "[INFO] 単体テストを実行中..." + docker compose run --rm test + ;; + "build") + echo "[INFO] Android APK ビルドを実行中..." + docker compose run --rm build + ;; + "help"|*) + echo "使用方法: ./run.sh [モード]" + echo " simulation (または指定なし) : デモ入力シナリオの動作検証" + echo " interactive : 対話型CLIによる任意音声コマンド検証" + echo " test : 全ユニットテストの実行" + echo " build : Debug APKのビルド" + ;; +esac diff --git a/settings.gradle.kts b/settings.gradle.kts new file mode 100644 index 0000000..d4d0132 --- /dev/null +++ b/settings.gradle.kts @@ -0,0 +1,17 @@ +pluginManagement { + repositories { + google() + mavenCentral() + gradlePluginPortal() + } +} +dependencyResolutionManagement { + repositoriesMode.set(RepositoriesMode.FAIL_ON_PROJECT_REPOS) + repositories { + google() + mavenCentral() + } +} + +rootProject.name = "VoiceControlledApp" +include(":app")