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
+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)
}
}
}