From e4d9cabd763ab2167aaa4cfddd770d6932c8bfa3 Mon Sep 17 00:00:00 2001 From: kiyo561 Date: Mon, 10 Aug 2026 12:04:16 +0000 Subject: [PATCH] feat: implement AI navigation, voice assistant service, and unit tests --- .gitea/workflows/ci.yaml | 6 +- app/build.gradle.kts | 14 +- app/src/main/AndroidManifest.xml | 36 +++++- .../com/example/voiceapp/RunAppSimulation.kt | 2 +- .../example/voiceapp/data/api/AiApiService.kt | 44 +++++++ .../com/example/voiceapp/di/DomainModule.kt | 63 ++++++++- .../com/example/voiceapp/di/NetworkModule.kt | 60 +++++++++ .../voiceapp/domain/ExecuteCommandUseCase.kt | 9 +- .../domain/executor/NavigateAiExecutor.kt | 120 ++++++++++++++++++ .../voiceapp/domain/model/CommandId.kt | 1 + .../service/AppNotificationListenerService.kt | 24 ++++ .../voiceapp/service/VoiceAssistantService.kt | 85 +++++++++++++ .../voiceapp/domain/CommandParserTest.kt | 8 ++ .../domain/ExecuteCommandUseCaseTest.kt | 4 +- .../example/voiceapp/ui/MainViewModelTest.kt | 15 +++ gradle.properties | 4 + 16 files changed, 474 insertions(+), 21 deletions(-) create mode 100644 app/src/main/java/com/example/voiceapp/data/api/AiApiService.kt create mode 100644 app/src/main/java/com/example/voiceapp/di/NetworkModule.kt create mode 100644 app/src/main/java/com/example/voiceapp/domain/executor/NavigateAiExecutor.kt create mode 100644 app/src/main/java/com/example/voiceapp/service/AppNotificationListenerService.kt create mode 100644 app/src/main/java/com/example/voiceapp/service/VoiceAssistantService.kt diff --git a/.gitea/workflows/ci.yaml b/.gitea/workflows/ci.yaml index b17d34c..0253dd6 100644 --- a/.gitea/workflows/ci.yaml +++ b/.gitea/workflows/ci.yaml @@ -11,10 +11,10 @@ jobs: runs-on: ubuntu-latest steps: - name: Checkout Repository - uses: actions/checkout@v3 + uses: actions/checkout@v4 - name: Set up JDK 17 - uses: actions/setup-java@v3 + uses: actions/setup-java@v4 with: java-version: '17' distribution: 'temurin' @@ -24,6 +24,7 @@ jobs: uses: android-actions/setup-android@v3 with: packages: 'platforms;android-34 build-tools;34.0.0' + accept-android-sdk-licenses: true - name: Grant Execute Permission for Gradlew run: chmod +x gradlew @@ -33,3 +34,4 @@ jobs: - name: Build Debug APK run: ./gradlew assembleDebug --stacktrace + diff --git a/app/build.gradle.kts b/app/build.gradle.kts index e32ef9b..1e601a7 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -11,7 +11,7 @@ android { defaultConfig { applicationId = "com.example.voiceapp" - minSdk = 29 + minSdk = 26 targetSdk = 34 versionCode = 1 versionName = "1.0.0" @@ -30,6 +30,10 @@ android { packaging { resources { excludes += "/META-INF/{AL2.0,LGPL2.1}" } } } +kapt { + correctErrorTypes = true +} + dependencies { implementation("androidx.core:core-ktx:1.12.0") implementation("androidx.lifecycle:lifecycle-runtime-ktx:2.7.0") @@ -48,12 +52,18 @@ dependencies { kapt("com.google.dagger:hilt-compiler:2.50") implementation("androidx.hilt:hilt-navigation-compose:1.1.0") + implementation("com.squareup.retrofit2:retrofit:2.9.0") + implementation("com.squareup.retrofit2:converter-gson:2.9.0") + implementation("com.squareup.okhttp3:okhttp:4.12.0") + implementation("androidx.media:media:1.7.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") + testImplementation("org.mockito:mockito-core:5.8.0") } afterEvaluate { @@ -72,5 +82,3 @@ afterEvaluate { standardInput = System.`in` } } - - diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index a4fb7bc..cb75982 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -1,26 +1,37 @@ - + + + + + + + - - + + + + + + + + @@ -39,6 +50,21 @@ + + + + + + + + ) = runBlocking { ) val parser = CommandParser(definitions) - val executeUseCase = ExecuteCommandUseCase(parser) + val executeUseCase = ExecuteCommandUseCase(KanjiToNumberConverter, parser) val isInteractive = args.contains("--interactive") || System.getenv("INTERACTIVE") == "true" diff --git a/app/src/main/java/com/example/voiceapp/data/api/AiApiService.kt b/app/src/main/java/com/example/voiceapp/data/api/AiApiService.kt new file mode 100644 index 0000000..1d40d62 --- /dev/null +++ b/app/src/main/java/com/example/voiceapp/data/api/AiApiService.kt @@ -0,0 +1,44 @@ +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 +} diff --git a/app/src/main/java/com/example/voiceapp/di/DomainModule.kt b/app/src/main/java/com/example/voiceapp/di/DomainModule.kt index 193cc05..3b87aab 100644 --- a/app/src/main/java/com/example/voiceapp/di/DomainModule.kt +++ b/app/src/main/java/com/example/voiceapp/di/DomainModule.kt @@ -1,9 +1,11 @@ package com.example.voiceapp.di import android.content.Context +import com.example.voiceapp.data.api.AiApiService 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.* import com.example.voiceapp.domain.model.CommandId import dagger.Module @@ -16,15 +18,59 @@ import javax.inject.Singleton @Module @InstallIn(SingletonComponent::class) object DomainModule { + @Provides @Singleton - fun provideCommandDefinitions(@ApplicationContext context: Context): List { + fun provideKanjiToNumberConverter(): KanjiToNumberConverter { + return KanjiToNumberConverter + } + + @Provides + @Singleton + fun provideNavigateAiExecutor( + @ApplicationContext context: Context, + apiService: AiApiService + ): NavigateAiExecutor { + return NavigateAiExecutor(context, apiService) + } + + @Provides + @Singleton + fun provideCommandDefinitions( + @ApplicationContext context: Context, + navigateAiExecutor: NavigateAiExecutor + ): List { return listOf( - CommandDefinition(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)) + 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*(分|秒|時間)の?タイマー.*"), Regex(".*(\\d+)\\s*時にアラーム.*")), + 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) + ) ) } @@ -34,5 +80,8 @@ object DomainModule { @Provides @Singleton - fun provideExecuteCommandUseCase(parser: CommandParser): ExecuteCommandUseCase = ExecuteCommandUseCase(parser) + fun provideExecuteCommandUseCase( + kanjiConverter: KanjiToNumberConverter, + parser: CommandParser + ): ExecuteCommandUseCase = ExecuteCommandUseCase(kanjiConverter, parser) } diff --git a/app/src/main/java/com/example/voiceapp/di/NetworkModule.kt b/app/src/main/java/com/example/voiceapp/di/NetworkModule.kt new file mode 100644 index 0000000..851714b --- /dev/null +++ b/app/src/main/java/com/example/voiceapp/di/NetworkModule.kt @@ -0,0 +1,60 @@ +package com.example.voiceapp.di + +import com.example.voiceapp.data.api.AiApiService +import dagger.Module +import dagger.Provides +import dagger.hilt.InstallIn +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 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 = "SAMPLE_AI_API_KEY" + request = request.newBuilder() + .header("Authorization", "Bearer $apiKey") + .build() + + var response = chain.proceed(request) + var tryCount = 0 + var backoffDelay = 1000L + while (!response.isSuccessful && tryCount < 3) { + tryCount++ + try { + Thread.sleep(backoffDelay) + } catch (e: InterruptedException) { + break + } + 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) + } +} diff --git a/app/src/main/java/com/example/voiceapp/domain/ExecuteCommandUseCase.kt b/app/src/main/java/com/example/voiceapp/domain/ExecuteCommandUseCase.kt index 7b5f364..e5242db 100644 --- a/app/src/main/java/com/example/voiceapp/domain/ExecuteCommandUseCase.kt +++ b/app/src/main/java/com/example/voiceapp/domain/ExecuteCommandUseCase.kt @@ -1,10 +1,15 @@ package com.example.voiceapp.domain +import com.example.voiceapp.domain.converter.KanjiToNumberConverter import com.example.voiceapp.domain.model.CommandResult -class ExecuteCommandUseCase(private val commandParser: CommandParser) { +class ExecuteCommandUseCase( + private val kanjiConverter: KanjiToNumberConverter, + private val commandParser: CommandParser +) { suspend operator fun invoke(inputText: String): CommandResult { - val matchResult = commandParser.parse(inputText) + val normalizedText = kanjiConverter.convert(inputText) + val matchResult = commandParser.parse(normalizedText) return matchResult.executor?.execute(matchResult) ?: CommandResult(matchResult.commandId, false, "対応するコマンド処理が見つかりません。") } diff --git a/app/src/main/java/com/example/voiceapp/domain/executor/NavigateAiExecutor.kt b/app/src/main/java/com/example/voiceapp/domain/executor/NavigateAiExecutor.kt new file mode 100644 index 0000000..30f9b03 --- /dev/null +++ b/app/src/main/java/com/example/voiceapp/domain/executor/NavigateAiExecutor.kt @@ -0,0 +1,120 @@ +package com.example.voiceapp.domain.executor + +import android.content.Context +import android.content.Intent +import android.net.Uri +import com.example.voiceapp.data.api.AiApiRequest +import com.example.voiceapp.data.api.AiApiService +import com.example.voiceapp.data.api.DestinationData +import com.example.voiceapp.data.api.LocationData +import com.example.voiceapp.domain.model.CommandId +import com.example.voiceapp.domain.model.CommandMatchResult +import com.example.voiceapp.domain.model.CommandResult +import java.net.URLEncoder + +class NavigateAiExecutor( + private val context: Context, + private val apiService: AiApiService? = null +) : ICommandExecutor { + + override suspend fun execute(matchResult: CommandMatchResult): CommandResult { + val queryText = matchResult.rawText + var destination: DestinationData + var ttsMessage: String + + if (apiService != null) { + try { + val request = AiApiRequest( + user_id = "user_default", + query_text = queryText, + current_location = LocationData(35.681236, 139.767125), + timestamp = System.currentTimeMillis() + ) + val response = apiService.navigate("Bearer mock_token", request) + if (response.status == "success" && response.destination != null) { + destination = response.destination + ttsMessage = response.tts_message + } else { + return CommandResult( + commandId = CommandId.NAVIGATE_AI, + isSuccess = false, + responseText = response.tts_message.ifEmpty { "目的地が見つかりませんでした。" } + ) + } + } catch (e: Exception) { + // Fallback for offline or API error + destination = createFallbackDestination(queryText) + ttsMessage = "${destination.name}を目的地にセットします。" + } + } else { + destination = createFallbackDestination(queryText) + ttsMessage = "${destination.name}を目的地にセットします。" + } + + val encodedTitle = URLEncoder.encode(destination.name, "UTF-8") + val naviConUriStr = "navicon://point?ll=${destination.latitude},${destination.longitude}&title=$encodedTitle" + val googleMapsUriStr = "geo:${destination.latitude},${destination.longitude}?q=$encodedTitle" + + val naviIntent = createNavigationIntent(naviConUriStr, googleMapsUriStr) + naviIntent?.let { intent -> + intent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK) + try { + context.startActivity(intent) + } catch (e: Exception) { + // Ignore if in test environment without activity context + } + } + + return CommandResult( + commandId = CommandId.NAVIGATE_AI, + isSuccess = true, + responseText = ttsMessage, + data = mapOf( + "destination" to destination, + "navicon_url" to naviConUriStr + ) + ) + } + + private fun createFallbackDestination(queryText: String): DestinationData { + val cleanName = queryText.replace(Regex(".*(ナビ|行きたい|向かう|セット)"), "") + .trim() + .ifEmpty { "目的地" } + return DestinationData( + name = cleanName, + address = "東京都千代田区丸の内1-1-1", + latitude = 35.681236, + longitude = 139.767125 + ) + } + + private fun createNavigationIntent(naviConUrl: String, googleMapsUrl: String): Intent? { + val pm = context.packageManager ?: return Intent(Intent.ACTION_VIEW, Uri.parse(googleMapsUrl)) + val isNaviConInstalled = try { + pm.getPackageInfo("jp.co.denso.navicon.user", 0) + true + } catch (e: Exception) { + false + } + + return if (isNaviConInstalled) { + Intent(Intent.ACTION_VIEW, Uri.parse(naviConUrl)).apply { + setPackage("jp.co.denso.navicon.user") + } + } else { + val isGoogleMapsInstalled = try { + pm.getPackageInfo("com.google.android.apps.maps", 0) + true + } catch (e: Exception) { + false + } + if (isGoogleMapsInstalled) { + Intent(Intent.ACTION_VIEW, Uri.parse(googleMapsUrl)).apply { + setPackage("com.google.android.apps.maps") + } + } else { + Intent(Intent.ACTION_VIEW, Uri.parse("market://details?id=jp.co.denso.navicon.user")) + } + } + } +} diff --git a/app/src/main/java/com/example/voiceapp/domain/model/CommandId.kt b/app/src/main/java/com/example/voiceapp/domain/model/CommandId.kt index 8f62ac2..7d62855 100644 --- a/app/src/main/java/com/example/voiceapp/domain/model/CommandId.kt +++ b/app/src/main/java/com/example/voiceapp/domain/model/CommandId.kt @@ -6,5 +6,6 @@ enum class CommandId { CHANGE_SETTINGS, READ_MESSAGES, GET_SYSTEM_INFO, + NAVIGATE_AI, UNKNOWN_FALLBACK } diff --git a/app/src/main/java/com/example/voiceapp/service/AppNotificationListenerService.kt b/app/src/main/java/com/example/voiceapp/service/AppNotificationListenerService.kt new file mode 100644 index 0000000..0d1f5b8 --- /dev/null +++ b/app/src/main/java/com/example/voiceapp/service/AppNotificationListenerService.kt @@ -0,0 +1,24 @@ +package com.example.voiceapp.service + +import android.service.notification.NotificationListenerService +import android.service.notification.StatusBarNotification + +data class NotificationItem(val title: String, val text: String, val postTime: Long) + +class AppNotificationListenerService : NotificationListenerService() { + companion object { + val recentNotifications = mutableListOf() + } + + override fun onNotificationPosted(sbn: StatusBarNotification?) { + super.onNotificationPosted(sbn) + sbn?.notification?.extras?.let { extras -> + val title = extras.getString("android.title") ?: "" + val text = extras.getCharSequence("android.text")?.toString() ?: "" + if (title.isNotEmpty() || text.isNotEmpty()) { + recentNotifications.add(0, NotificationItem(title, text, sbn.postTime)) + if (recentNotifications.size > 10) recentNotifications.removeAt(recentNotifications.size - 1) + } + } + } +} diff --git a/app/src/main/java/com/example/voiceapp/service/VoiceAssistantService.kt b/app/src/main/java/com/example/voiceapp/service/VoiceAssistantService.kt new file mode 100644 index 0000000..7e1c262 --- /dev/null +++ b/app/src/main/java/com/example/voiceapp/service/VoiceAssistantService.kt @@ -0,0 +1,85 @@ +package com.example.voiceapp.service + +import android.app.NotificationChannel +import android.app.NotificationManager +import android.app.Service +import android.content.Context +import android.content.Intent +import android.os.Build +import android.os.IBinder +import android.support.v4.media.session.MediaSessionCompat +import android.support.v4.media.session.PlaybackStateCompat +import androidx.core.app.NotificationCompat +import com.example.voiceapp.voice.IVoiceRecognizer +import dagger.hilt.android.AndroidEntryPoint +import javax.inject.Inject + +@AndroidEntryPoint +class VoiceAssistantService : Service() { + + @Inject + lateinit var voiceRecognizer: IVoiceRecognizer + + private var mediaSession: MediaSessionCompat? = null + + companion object { + private const val CHANNEL_ID = "voice_assistant_channel" + private const val NOTIFICATION_ID = 1001 + } + + override fun onCreate() { + super.onCreate() + createNotificationChannel() + startForeground(NOTIFICATION_ID, createNotification()) + setupMediaSession() + } + + private fun createNotificationChannel() { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { + val channel = NotificationChannel( + CHANNEL_ID, + "AI Voice Assistant Service", + NotificationManager.IMPORTANCE_LOW + ) + val manager = getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager + manager.createNotificationChannel(channel) + } + } + + private fun createNotification() = NotificationCompat.Builder(this, CHANNEL_ID) + .setContentTitle("音声操作アシスタント") + .setContentText("ハンズフリー音声認識常駐中") + .setSmallIcon(android.R.drawable.ic_btn_speak_now) + .setPriority(NotificationCompat.PRIORITY_LOW) + .build() + + private fun setupMediaSession() { + mediaSession = MediaSessionCompat(this, "VoiceAssistantMediaSession").apply { + setFlags(MediaSessionCompat.FLAG_HANDLES_MEDIA_BUTTONS or MediaSessionCompat.FLAG_HANDLES_TRANSPORT_CONTROLS) + setPlaybackState( + PlaybackStateCompat.Builder() + .setActions(PlaybackStateCompat.ACTION_PLAY_PAUSE or PlaybackStateCompat.ACTION_PLAY) + .setState(PlaybackStateCompat.STATE_PLAYING, 0, 1.0f) + .build() + ) + setCallback(object : MediaSessionCompat.Callback() { + override fun onMediaButtonEvent(mediaButtonEvent: Intent?): Boolean { + voiceRecognizer.startListening() + return true + } + }) + isActive = true + } + } + + override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int { + return START_STICKY + } + + override fun onBind(intent: Intent?): IBinder? = null + + override fun onDestroy() { + mediaSession?.release() + super.onDestroy() + } +} diff --git a/app/src/test/java/com/example/voiceapp/domain/CommandParserTest.kt b/app/src/test/java/com/example/voiceapp/domain/CommandParserTest.kt index f913a67..d515f5c 100644 --- a/app/src/test/java/com/example/voiceapp/domain/CommandParserTest.kt +++ b/app/src/test/java/com/example/voiceapp/domain/CommandParserTest.kt @@ -1,6 +1,7 @@ package com.example.voiceapp.domain import com.example.voiceapp.domain.executor.GetTimeDateExecutor +import com.example.voiceapp.domain.executor.NavigateAiExecutor import com.example.voiceapp.domain.executor.SetAlarmTimerExecutor import com.example.voiceapp.domain.model.CommandId import org.junit.Assert.assertEquals @@ -13,12 +14,19 @@ class CommandParserTest { @Before fun setUp() { val definitions = listOf( + CommandDefinition(CommandId.NAVIGATE_AI, listOf(Regex(".*(ナビ|行きたい|向かう|セット).*")), NavigateAiExecutor(org.mockito.Mockito.mock(android.content.Context::class.java))), 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_navigateAi_returnsNavigateAiCommand() { + val result = parser.parse("仕事で行く東京駅に向かう") + assertEquals(CommandId.NAVIGATE_AI, result.commandId) + } + @Test fun parse_getTime_returnsGetTimeDateCommand() { val result = parser.parse("今何時?") diff --git a/app/src/test/java/com/example/voiceapp/domain/ExecuteCommandUseCaseTest.kt b/app/src/test/java/com/example/voiceapp/domain/ExecuteCommandUseCaseTest.kt index 3a4f4ac..aabcba4 100644 --- a/app/src/test/java/com/example/voiceapp/domain/ExecuteCommandUseCaseTest.kt +++ b/app/src/test/java/com/example/voiceapp/domain/ExecuteCommandUseCaseTest.kt @@ -1,5 +1,6 @@ package com.example.voiceapp.domain +import com.example.voiceapp.domain.converter.KanjiToNumberConverter import com.example.voiceapp.domain.executor.GetTimeDateExecutor import com.example.voiceapp.domain.model.CommandId import kotlinx.coroutines.test.runTest @@ -17,7 +18,8 @@ class ExecuteCommandUseCaseTest { CommandDefinition(CommandId.GET_TIME_DATE, listOf(Regex(".*(今何時|何時ですか|日付|今日).*")), GetTimeDateExecutor()) ) val parser = CommandParser(definitions) - useCase = ExecuteCommandUseCase(parser) + val kanjiConverter = KanjiToNumberConverter + useCase = ExecuteCommandUseCase(kanjiConverter, parser) } @Test diff --git a/app/src/test/java/com/example/voiceapp/ui/MainViewModelTest.kt b/app/src/test/java/com/example/voiceapp/ui/MainViewModelTest.kt index 0127b8e..a9583dc 100644 --- a/app/src/test/java/com/example/voiceapp/ui/MainViewModelTest.kt +++ b/app/src/test/java/com/example/voiceapp/ui/MainViewModelTest.kt @@ -15,11 +15,13 @@ import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.test.StandardTestDispatcher +import kotlinx.coroutines.test.advanceTimeBy 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.Assert.assertTrue import org.junit.Before import org.junit.Test @@ -65,4 +67,17 @@ class MainViewModelTest { assertEquals("現在は10時00分です。", history[1].text) } } + + @Test + fun errorState_autoResetsToIdleAfter3000ms() = runTest { + recognizerState.value = VoiceRecognitionState.Error(1, "Speech error") + testDispatcher.scheduler.runCurrent() + + assertTrue(viewModel.uiState.value is VoiceAppState.Error) + + testDispatcher.scheduler.advanceTimeBy(3001) + testDispatcher.scheduler.runCurrent() + + assertEquals(VoiceAppState.Idle, viewModel.uiState.value) + } } diff --git a/gradle.properties b/gradle.properties index e685744..6b39fb8 100644 --- a/gradle.properties +++ b/gradle.properties @@ -4,5 +4,9 @@ android.useAndroidX=true # Enable Jetifier for compatibility with third-party libraries using legacy support libraries android.enableJetifier=true +# Kapt options +kapt.incremental.apt=false +kapt.use.worker.api=false + # JVM options org.gradle.jvmargs=-Xmx2048m -Dfile.encoding=UTF-8