Initial commit: Voice controlled device application, specs and Gitea Actions workflow

This commit is contained in:
2026-08-10 08:26:00 +00:00
commit d449469116
69 changed files with 3209 additions and 0 deletions
+6
View File
@@ -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
+7
View File
@@ -0,0 +1,7 @@
.gradle/
build/
app/build/
*.iml
.idea/
local.properties
.DS_Store
+47
View File
@@ -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"]
+20
View File
@@ -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
+115
View File
@@ -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時からです」` |
| **設定変更** | 「ダークモードにして」 | `画面をダークモードに変更しました。` |
| **フォールバック** | 「こんにちは」 | `申し訳ありません。そのコマンドには対応していません。` |
+76
View File
@@ -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<JavaExec>("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`
}
}
+52
View File
@@ -0,0 +1,52 @@
<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
package="com.example.voiceapp">
<uses-permission android:name="android.permission.RECORD_AUDIO" />
<uses-permission android:name="android.permission.INTERNET" />
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />
<uses-permission android:name="android.permission.MODIFY_AUDIO_SETTINGS" />
<uses-permission android:name="android.permission.SCHEDULE_EXACT_ALARM" />
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
<uses-permission android:name="android.permission.BIND_NOTIFICATION_LISTENER_SERVICE" />
<uses-feature android:name="android.hardware.microphone" android:required="true" />
<queries>
<intent><action android:name="android.speech.RecognitionService" /></intent>
<intent><action android:name="android.intent.action.TTS_SERVICE" /></intent>
</queries>
<application
android:name=".VoiceApplication"
android:allowBackup="true"
android:label="音声操作端末"
android:supportsRtl="true"
android:theme="@style/Theme.VoiceControlledDevice">
<activity
android:name=".MainActivity"
android:exported="true"
android:launchMode="singleTop"
android:screenOrientation="portrait">
<intent-filter>
<action android:name="android.intent.action.MAIN" />
<category android:name="android.intent.category.LAUNCHER" />
</intent-filter>
<intent-filter>
<action android:name="android.intent.action.VOICE_COMMAND" />
<category android:name="android.intent.category.DEFAULT" />
</intent-filter>
</activity>
<service
android:name=".voice.VoiceNotificationListenerService"
android:label="音声操作通知リスナー"
android:permission="android.permission.BIND_NOTIFICATION_LISTENER_SERVICE"
android:exported="true">
<intent-filter>
<action android:name="android.service.notification.NotificationListenerService" />
</intent-filter>
</service>
</application>
</manifest>
@@ -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)
}
}
}
}
@@ -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<String>) = 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("==========================================================")
}
}
@@ -0,0 +1,7 @@
package com.example.voiceapp
import android.app.Application
import dagger.hilt.android.HiltAndroidApp
@HiltAndroidApp
class VoiceApplication : Application()
@@ -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<Boolean> = context.dataStore.data.map { it[KEY_DARK_MODE] ?: false }
suspend fun setDarkMode(enabled: Boolean) {
context.dataStore.edit { it[KEY_DARK_MODE] = enabled }
}
}
@@ -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)
}
@@ -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<CommandDefinition> {
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<CommandDefinition>): CommandParser = CommandParser(definitions)
@Provides
@Singleton
fun provideExecuteCommandUseCase(parser: CommandParser): ExecuteCommandUseCase = ExecuteCommandUseCase(parser)
}
@@ -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)
}
@@ -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<Regex>,
val executor: ICommandExecutor
)
@@ -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<CommandDefinition>) {
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<String, String>()
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())
}
}
@@ -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, "対応するコマンド処理が見つかりません。")
}
}
@@ -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")
}
}
@@ -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)
}
}
@@ -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)
}
}
@@ -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)
}
}
@@ -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
}
@@ -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)
}
}
@@ -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))
}
}
@@ -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, "申し訳ありません。よく理解できませんでした。もう一度お話しください。")
}
}
@@ -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
}
@@ -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<String, String>,
val rawText: String,
val executor: ICommandExecutor? = null
)
@@ -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<String, Any>? = null
)
@@ -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
)
@@ -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>(VoiceAppState.Idle)
val uiState: StateFlow<VoiceAppState> = _uiState.asStateFlow()
private val _conversationHistory = MutableStateFlow<List<ChatMessage>>(emptyList())
val conversationHistory: StateFlow<List<ChatMessage>> = _conversationHistory.asStateFlow()
private val _hasMicPermission = MutableStateFlow(false)
val hasMicPermission: StateFlow<Boolean> = _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() }
}
@@ -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
}
@@ -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("クリア") }
}
}
@@ -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<ChatMessage>, modifier: Modifier = Modifier) {
LazyColumn(modifier = modifier) {
items(messages) { msg ->
Text(text = "${msg.sender}: ${msg.text}")
}
}
}
@@ -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))
}
}
}
@@ -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("許可") } }
)
}
@@ -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)
}
}
@@ -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))
}
}
@@ -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)
@@ -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)
}
@@ -0,0 +1,5 @@
package com.example.voiceapp.ui.theme
import androidx.compose.material3.Typography
val Typography = Typography()
@@ -0,0 +1,11 @@
package com.example.voiceapp.voice
import android.speech.tts.TextToSpeech
import kotlinx.coroutines.flow.StateFlow
interface ITextToSpeech {
val status: StateFlow<TtsStatus>
fun speak(text: String, queueMode: Int = TextToSpeech.QUEUE_FLUSH)
fun stop()
fun destroy()
}
@@ -0,0 +1,12 @@
package com.example.voiceapp.voice
import kotlinx.coroutines.flow.StateFlow
interface IVoiceRecognizer {
val state: StateFlow<VoiceRecognitionState>
val rmsDb: StateFlow<Float>
fun startListening()
fun stopListening()
fun destroy()
}
@@ -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>(TtsStatus.Idle)
override val status: StateFlow<TtsStatus> = _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()
}
}
@@ -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
}
@@ -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<NotificationItem>()
}
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)
}
}
}
}
@@ -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>(VoiceRecognitionState.Idle)
override val state: StateFlow<VoiceRecognitionState> = _state.asStateFlow()
private val _rmsDb = MutableStateFlow(0f)
override val rmsDb: StateFlow<Float> = _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?) {}
}
}
@@ -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
}
+6
View File
@@ -0,0 +1,6 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<color name="purple_500">#FF6200EE</color>
<color name="purple_700">#FF3700B3</color>
<color name="teal_200">#FF03DAC5</color>
</resources>
+8
View File
@@ -0,0 +1,8 @@
<resources>
<string name="app_name">音声操作端末</string>
<string name="status_idle">待機中 (タップしてお話しください)</string>
<string name="status_listening">音声認識中...</string>
<string name="status_processing">コマンド解析中...</string>
<string name="status_speaking">音声応答中...</string>
<string name="status_error">エラーが発生しました</string>
</resources>
+6
View File
@@ -0,0 +1,6 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<style name="Theme.VoiceControlledDevice" parent="android:Theme.Material.Light.NoActionBar">
<item name="android:statusBarColor">@color/purple_700</item>
</style>
</resources>
@@ -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)
}
}
@@ -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("現在"))
}
}
@@ -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"))
}
}
@@ -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>(VoiceRecognitionState.Idle)
private val rmsDbState = MutableStateFlow(0f)
private val ttsStatusState = MutableStateFlow<TtsStatus>(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)
}
}
}
+5
View File
@@ -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
}
+51
View File
@@ -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:
+60
View File
@@ -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`) の作成に進みます。
+277
View File
@@ -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<VoiceRecognitionState>
val rmsDb: StateFlow<Float>
fun startListening()
fun stopListening()
fun destroy()
}
* **排他制御・フィードバック防止**:
* ITextToSpeech の状態が Speaking の間は startListening() を呼び出し不可とし、スピーカー音声の誤認識ループを防止。
#### **3.2.3 音声合成 (TTS) モジュール (ITextToSpeech / TextToSpeechManager)**
* **技術方式**: Android標準 android.speech.tts.TextToSpeech のラッパーモジュール。
* **状態管理の単一化**:
* 状態変化は val status: StateFlow<TtsStatus> に集約。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<TtsStatus>
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 <API_KEY>` または `X-API-Key: <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
<service
android:name=".service.VoiceAssistantService"
android:exported="false"
android:foregroundServiceType="microphone|location" />
<service
android:name=".service.AppNotificationListenerService"
android:label="メッセージ読み上げサービス"
android:permission="android.permission.BIND_NOTIFICATION_LISTENER_SERVICE"
android:exported="true">
<intent-filter>
<action android:name="android.service.notification.NotificationListenerService" />
</intent-filter>
</service>
```
## **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へ安全にフォールバックすること。
+957
View File
@@ -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<TtsStatus>` で一元状態管理。
- **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 <API_KEY>` または `X-API-Key: <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 {
<<sealed interface>>
}
}
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 {
<<enum>>
GET_TIME_DATE
SET_ALARM_TIMER
CHANGE_SETTINGS
READ_MESSAGES
GET_SYSTEM_INFO
NAVIGATE_AI
UNKNOWN_FALLBACK
}
class ICommandExecutor {
<<interface>>
+execute(CommandMatchResult match) CommandResult
}
class NavigateAiExecutor {
-AiApiService apiService
-Context context
+execute(CommandMatchResult match) CommandResult
}
}
namespace Voice_Engine_Layer {
class IVoiceRecognizer {
<<interface>>
+StateFlow~VoiceRecognitionState~ state
+StateFlow~Float~ rmsDb
+startListening()
+stopListening()
+destroy()
}
class VoiceRecognitionManager {
-SpeechRecognizer speechRecognizer
}
class ITextToSpeech {
<<interface>>
+StateFlow~TtsStatus~ status
+speak(String text, Int queueMode) Unit
+stop()
+destroy()
}
class TextToSpeechManager {
-TextToSpeech ttsEngine
}
}
namespace Network_Layer {
class AiApiService {
<<interface>>
+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<VoiceRecognitionState>
val rmsDb: StateFlow<Float>
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<TtsStatus>
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<String, String>,
val rawText: String
)
data class CommandResult(
val commandId: CommandId,
val isSuccess: Boolean,
val responseText: String,
val data: Map<String, Any>? = null
)
interface ICommandExecutor {
suspend fun execute(matchResult: CommandMatchResult): CommandResult
}
data class CommandDefinition(
val id: CommandId,
val patterns: List<Regex>,
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<CommandDefinition>) {
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<CommandDefinition> {
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<CommandDefinition>): 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
<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
package="com.example.voiceapp">
<!-- 必須パーミッション宣言 -->
<!-- 1. 音声入力権限 (危険権限) -->
<uses-permission android:name="android.permission.RECORD_AUDIO" />
<!-- 2. AI サーバー通信・ネットワーク確認権限 -->
<uses-permission android:name="android.permission.INTERNET" />
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />
<!-- 3. 位置情報権限 (AI ナビゲーション現在地付与用) -->
<uses-permission android:name="android.permission.ACCESS_FINE_LOCATION" />
<uses-permission android:name="android.permission.ACCESS_COARSE_LOCATION" />
<!-- 4. オーディオ設定(音量変更・フォーカス要求)権限 -->
<uses-permission android:name="android.permission.MODIFY_AUDIO_SETTINGS" />
<!-- 5. アラーム・タイマーインテント設定権限 (Android 12+ API 31+) -->
<uses-permission android:name="android.permission.SCHEDULE_EXACT_ALARM" />
<!-- 6. 通知出力権限 (Android 13+ API 33+) -->
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
<!-- 7. 未読メッセージ通知受託権限 -->
<uses-permission android:name="android.permission.BIND_NOTIFICATION_LISTENER_SERVICE" />
<!-- 8. Android 14 (API 34) 必須フォアグラウンドサービス権限 -->
<uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_MICROPHONE" />
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_LOCATION" />
<!-- ハードウェア機能制限の設定 (マイク必須設定) -->
<uses-feature
android:name="android.hardware.microphone"
android:required="true" />
<uses-feature
android:name="android.hardware.location.gps"
android:required="false" />
<application
android:name=".VoiceApplication"
android:allowBackup="true"
android:icon="@mipmap/ic_launcher"
android:label="音声操作ナビ端末"
android:roundIcon="@mipmap/ic_launcher_round"
android:supportsRtl="true"
android:theme="@style/Theme.VoiceControlledDevice">
<!-- シングルアクティビティ構成 -->
<activity
android:name=".MainActivity"
android:exported="true"
android:launchMode="singleTop"
android:screenOrientation="portrait"
android:windowSoftInputMode="adjustResize">
<intent-filter>
<action android:name="android.intent.action.MAIN" />
<category android:name="android.intent.category.LAUNCHER" />
</intent-filter>
<!-- 音声アシスタント呼び出しインテント -->
<intent-filter>
<action android:name="android.intent.action.VOICE_COMMAND" />
<category android:name="android.intent.category.DEFAULT" />
</intent-filter>
</activity>
<!-- 常駐フォアグラウンドサービス (Android 14 API 34 対応) -->
<service
android:name=".service.VoiceAssistantService"
android:exported="false"
android:foregroundServiceType="microphone|location" />
<!-- 未読メッセージ読み上げ通知受託サービス -->
<service
android:name=".service.AppNotificationListenerService"
android:label="メッセージ読み上げサービス"
android:permission="android.permission.BIND_NOTIFICATION_LISTENER_SERVICE"
android:exported="true">
<intent-filter>
<action android:name="android.service.notification.NotificationListenerService" />
</intent-filter>
</service>
<!-- SpeechRecognizer, NaviCon, Google Maps パッケージクエリ設定 (Android 11+ API 30+) -->
<queries>
<intent>
<action android:name="android.speech.RecognitionService" />
</intent>
<intent>
<action android:name="android.intent.action.TTS_SERVICE" />
</intent>
<package android:name="jp.co.denso.navicon.user" />
<package android:name="com.google.android.apps.maps" />
</queries>
</application>
</manifest>
```
### 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 と同一のテスト・ビルド環境を実行可能とする。
+104
View File
@@ -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<TtsStatus>`)**
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<TtsStatus>` に集約。`speak()` は `Unit` を返す」と明記。
- **設計書**: 2.2.2-1 節のコード定義で `fun speak(text: String, queueMode: Int = 0): StateFlow<TtsStatus>` と定義されており、戻り値型が仕様書と直接衝突している。
### ③ 漢数字コンバータ (`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<TtsStatus>` のみで一元化すること。
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 画面へ安全にフォールバックすることを確認すること。
+8
View File
@@ -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
Binary file not shown.
+7
View File
@@ -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
Vendored Executable
+48
View File
@@ -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 "$@"
Vendored
+81
View File
@@ -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
+1
View File
@@ -0,0 +1 @@
sdk.dir=/root/android-sdk
+44
View File
@@ -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
Executable
+34
View File
@@ -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
+17
View File
@@ -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")