feat: implement AI navigation, voice assistant service, and unit tests
Gitea Actions CI/CD Pipeline / build-and-test (push) Failing after 3m53s

This commit is contained in:
2026-08-10 12:04:16 +00:00
parent 905db0da56
commit e4d9cabd76
16 changed files with 474 additions and 21 deletions
+11 -3
View File
@@ -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`
}
}
+31 -5
View File
@@ -1,26 +1,37 @@
<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
package="com.example.voiceapp">
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
<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.ACCESS_FINE_LOCATION" />
<uses-permission android:name="android.permission.ACCESS_COARSE_LOCATION" />
<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-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" />
<queries>
<intent><action android:name="android.speech.RecognitionService" /></intent>
<intent><action android:name="android.intent.action.TTS_SERVICE" /></intent>
<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
android:name=".VoiceApplication"
android:allowBackup="true"
android:label="音声操作端末"
android:label="音声操作ナビ端末"
android:supportsRtl="true"
android:theme="@style/Theme.VoiceControlledDevice">
@@ -39,6 +50,21 @@
</intent-filter>
</activity>
<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>
<service
android:name=".voice.VoiceNotificationListenerService"
android:label="音声操作通知リスナー"
@@ -50,7 +50,7 @@ fun main(args: Array<String>) = runBlocking {
)
val parser = CommandParser(definitions)
val executeUseCase = ExecuteCommandUseCase(parser)
val executeUseCase = ExecuteCommandUseCase(KanjiToNumberConverter, parser)
val isInteractive = args.contains("--interactive") || System.getenv("INTERACTIVE") == "true"
@@ -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
}
@@ -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<CommandDefinition> {
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(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)
}
@@ -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)
}
}
@@ -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, "対応するコマンド処理が見つかりません。")
}
@@ -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"))
}
}
}
}
@@ -6,5 +6,6 @@ enum class CommandId {
CHANGE_SETTINGS,
READ_MESSAGES,
GET_SYSTEM_INFO,
NAVIGATE_AI,
UNKNOWN_FALLBACK
}
@@ -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<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,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()
}
}
@@ -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("今何時?")
@@ -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
@@ -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)
}
}