feat: implement AI navigation, voice assistant service, and unit tests
Gitea Actions CI/CD Pipeline / build-and-test (push) Failing after 3m53s
Gitea Actions CI/CD Pipeline / build-and-test (push) Failing after 3m53s
This commit is contained in:
@@ -11,10 +11,10 @@ jobs:
|
|||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout Repository
|
- name: Checkout Repository
|
||||||
uses: actions/checkout@v3
|
uses: actions/checkout@v4
|
||||||
|
|
||||||
- name: Set up JDK 17
|
- name: Set up JDK 17
|
||||||
uses: actions/setup-java@v3
|
uses: actions/setup-java@v4
|
||||||
with:
|
with:
|
||||||
java-version: '17'
|
java-version: '17'
|
||||||
distribution: 'temurin'
|
distribution: 'temurin'
|
||||||
@@ -24,6 +24,7 @@ jobs:
|
|||||||
uses: android-actions/setup-android@v3
|
uses: android-actions/setup-android@v3
|
||||||
with:
|
with:
|
||||||
packages: 'platforms;android-34 build-tools;34.0.0'
|
packages: 'platforms;android-34 build-tools;34.0.0'
|
||||||
|
accept-android-sdk-licenses: true
|
||||||
|
|
||||||
- name: Grant Execute Permission for Gradlew
|
- name: Grant Execute Permission for Gradlew
|
||||||
run: chmod +x gradlew
|
run: chmod +x gradlew
|
||||||
@@ -33,3 +34,4 @@ jobs:
|
|||||||
|
|
||||||
- name: Build Debug APK
|
- name: Build Debug APK
|
||||||
run: ./gradlew assembleDebug --stacktrace
|
run: ./gradlew assembleDebug --stacktrace
|
||||||
|
|
||||||
|
|||||||
+11
-3
@@ -11,7 +11,7 @@ android {
|
|||||||
|
|
||||||
defaultConfig {
|
defaultConfig {
|
||||||
applicationId = "com.example.voiceapp"
|
applicationId = "com.example.voiceapp"
|
||||||
minSdk = 29
|
minSdk = 26
|
||||||
targetSdk = 34
|
targetSdk = 34
|
||||||
versionCode = 1
|
versionCode = 1
|
||||||
versionName = "1.0.0"
|
versionName = "1.0.0"
|
||||||
@@ -30,6 +30,10 @@ android {
|
|||||||
packaging { resources { excludes += "/META-INF/{AL2.0,LGPL2.1}" } }
|
packaging { resources { excludes += "/META-INF/{AL2.0,LGPL2.1}" } }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
kapt {
|
||||||
|
correctErrorTypes = true
|
||||||
|
}
|
||||||
|
|
||||||
dependencies {
|
dependencies {
|
||||||
implementation("androidx.core:core-ktx:1.12.0")
|
implementation("androidx.core:core-ktx:1.12.0")
|
||||||
implementation("androidx.lifecycle:lifecycle-runtime-ktx:2.7.0")
|
implementation("androidx.lifecycle:lifecycle-runtime-ktx:2.7.0")
|
||||||
@@ -48,12 +52,18 @@ dependencies {
|
|||||||
kapt("com.google.dagger:hilt-compiler:2.50")
|
kapt("com.google.dagger:hilt-compiler:2.50")
|
||||||
implementation("androidx.hilt:hilt-navigation-compose:1.1.0")
|
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")
|
implementation("androidx.datastore:datastore-preferences:1.0.0")
|
||||||
|
|
||||||
testImplementation("junit:junit:4.13.2")
|
testImplementation("junit:junit:4.13.2")
|
||||||
testImplementation("io.mockk:mockk:1.13.9")
|
testImplementation("io.mockk:mockk:1.13.9")
|
||||||
testImplementation("org.jetbrains.kotlinx:kotlinx-coroutines-test:1.7.3")
|
testImplementation("org.jetbrains.kotlinx:kotlinx-coroutines-test:1.7.3")
|
||||||
testImplementation("app.cash.turbine:turbine:1.0.0")
|
testImplementation("app.cash.turbine:turbine:1.0.0")
|
||||||
|
testImplementation("org.mockito:mockito-core:5.8.0")
|
||||||
}
|
}
|
||||||
|
|
||||||
afterEvaluate {
|
afterEvaluate {
|
||||||
@@ -72,5 +82,3 @@ afterEvaluate {
|
|||||||
standardInput = System.`in`
|
standardInput = System.`in`
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -1,26 +1,37 @@
|
|||||||
<?xml version="1.0" encoding="utf-8"?>
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
|
<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.RECORD_AUDIO" />
|
||||||
<uses-permission android:name="android.permission.INTERNET" />
|
<uses-permission android:name="android.permission.INTERNET" />
|
||||||
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />
|
<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.MODIFY_AUDIO_SETTINGS" />
|
||||||
<uses-permission android:name="android.permission.SCHEDULE_EXACT_ALARM" />
|
<uses-permission android:name="android.permission.SCHEDULE_EXACT_ALARM" />
|
||||||
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
|
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
|
||||||
<uses-permission android:name="android.permission.BIND_NOTIFICATION_LISTENER_SERVICE" />
|
<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.microphone" android:required="true" />
|
||||||
|
<uses-feature android:name="android.hardware.location.gps" android:required="false" />
|
||||||
|
|
||||||
<queries>
|
<queries>
|
||||||
<intent><action android:name="android.speech.RecognitionService" /></intent>
|
<intent>
|
||||||
<intent><action android:name="android.intent.action.TTS_SERVICE" /></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>
|
</queries>
|
||||||
|
|
||||||
<application
|
<application
|
||||||
android:name=".VoiceApplication"
|
android:name=".VoiceApplication"
|
||||||
android:allowBackup="true"
|
android:allowBackup="true"
|
||||||
android:label="音声操作端末"
|
android:label="音声操作ナビ端末"
|
||||||
android:supportsRtl="true"
|
android:supportsRtl="true"
|
||||||
android:theme="@style/Theme.VoiceControlledDevice">
|
android:theme="@style/Theme.VoiceControlledDevice">
|
||||||
|
|
||||||
@@ -39,6 +50,21 @@
|
|||||||
</intent-filter>
|
</intent-filter>
|
||||||
</activity>
|
</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
|
<service
|
||||||
android:name=".voice.VoiceNotificationListenerService"
|
android:name=".voice.VoiceNotificationListenerService"
|
||||||
android:label="音声操作通知リスナー"
|
android:label="音声操作通知リスナー"
|
||||||
|
|||||||
@@ -50,7 +50,7 @@ fun main(args: Array<String>) = runBlocking {
|
|||||||
)
|
)
|
||||||
|
|
||||||
val parser = CommandParser(definitions)
|
val parser = CommandParser(definitions)
|
||||||
val executeUseCase = ExecuteCommandUseCase(parser)
|
val executeUseCase = ExecuteCommandUseCase(KanjiToNumberConverter, parser)
|
||||||
|
|
||||||
val isInteractive = args.contains("--interactive") || System.getenv("INTERACTIVE") == "true"
|
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
|
package com.example.voiceapp.di
|
||||||
|
|
||||||
import android.content.Context
|
import android.content.Context
|
||||||
|
import com.example.voiceapp.data.api.AiApiService
|
||||||
import com.example.voiceapp.domain.CommandDefinition
|
import com.example.voiceapp.domain.CommandDefinition
|
||||||
import com.example.voiceapp.domain.CommandParser
|
import com.example.voiceapp.domain.CommandParser
|
||||||
import com.example.voiceapp.domain.ExecuteCommandUseCase
|
import com.example.voiceapp.domain.ExecuteCommandUseCase
|
||||||
|
import com.example.voiceapp.domain.converter.KanjiToNumberConverter
|
||||||
import com.example.voiceapp.domain.executor.*
|
import com.example.voiceapp.domain.executor.*
|
||||||
import com.example.voiceapp.domain.model.CommandId
|
import com.example.voiceapp.domain.model.CommandId
|
||||||
import dagger.Module
|
import dagger.Module
|
||||||
@@ -16,15 +18,59 @@ import javax.inject.Singleton
|
|||||||
@Module
|
@Module
|
||||||
@InstallIn(SingletonComponent::class)
|
@InstallIn(SingletonComponent::class)
|
||||||
object DomainModule {
|
object DomainModule {
|
||||||
|
|
||||||
@Provides
|
@Provides
|
||||||
@Singleton
|
@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(
|
return listOf(
|
||||||
CommandDefinition(CommandId.GET_TIME_DATE, listOf(Regex(".*(今何時|何時ですか|日付|今日).*")), GetTimeDateExecutor()),
|
CommandDefinition(
|
||||||
CommandDefinition(CommandId.SET_ALARM_TIMER, listOf(Regex(".*(\\d+)\\s*(分|秒|時間)の?タイマー.*"), Regex(".*(\\d+)\\s*時にアラーム.*")), SetAlarmTimerExecutor(context)),
|
id = CommandId.NAVIGATE_AI,
|
||||||
CommandDefinition(CommandId.CHANGE_SETTINGS, listOf(Regex(".*(音量を|音量|ダークモード|ライトモード).*")), ChangeSettingsExecutor(context)),
|
patterns = listOf(Regex(".*(ナビ|行きたい|向かう|セット).*")),
|
||||||
CommandDefinition(CommandId.READ_MESSAGES, listOf(Regex(".*(メッセージ|未読).*")), ReadMessagesExecutor(context)),
|
executor = navigateAiExecutor
|
||||||
CommandDefinition(CommandId.GET_SYSTEM_INFO, listOf(Regex(".*(バッテリー|電池|Wi-Fi|ネットワーク).*")), GetSystemInfoExecutor(context))
|
),
|
||||||
|
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
|
@Provides
|
||||||
@Singleton
|
@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
|
package com.example.voiceapp.domain
|
||||||
|
|
||||||
|
import com.example.voiceapp.domain.converter.KanjiToNumberConverter
|
||||||
import com.example.voiceapp.domain.model.CommandResult
|
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 {
|
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)
|
return matchResult.executor?.execute(matchResult)
|
||||||
?: CommandResult(matchResult.commandId, false, "対応するコマンド処理が見つかりません。")
|
?: 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,
|
CHANGE_SETTINGS,
|
||||||
READ_MESSAGES,
|
READ_MESSAGES,
|
||||||
GET_SYSTEM_INFO,
|
GET_SYSTEM_INFO,
|
||||||
|
NAVIGATE_AI,
|
||||||
UNKNOWN_FALLBACK
|
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
|
package com.example.voiceapp.domain
|
||||||
|
|
||||||
import com.example.voiceapp.domain.executor.GetTimeDateExecutor
|
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.executor.SetAlarmTimerExecutor
|
||||||
import com.example.voiceapp.domain.model.CommandId
|
import com.example.voiceapp.domain.model.CommandId
|
||||||
import org.junit.Assert.assertEquals
|
import org.junit.Assert.assertEquals
|
||||||
@@ -13,12 +14,19 @@ class CommandParserTest {
|
|||||||
@Before
|
@Before
|
||||||
fun setUp() {
|
fun setUp() {
|
||||||
val definitions = listOf(
|
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.GET_TIME_DATE, listOf(Regex(".*(今何時|何時ですか|日付|今日).*")), GetTimeDateExecutor()),
|
||||||
CommandDefinition(CommandId.SET_ALARM_TIMER, listOf(Regex(".*(\\d+)\\s*(分|秒|時間)の?タイマー.*"), Regex(".*(\\d+)\\s*時にアラーム.*")), SetAlarmTimerExecutor())
|
CommandDefinition(CommandId.SET_ALARM_TIMER, listOf(Regex(".*(\\d+)\\s*(分|秒|時間)の?タイマー.*"), Regex(".*(\\d+)\\s*時にアラーム.*")), SetAlarmTimerExecutor())
|
||||||
)
|
)
|
||||||
parser = CommandParser(definitions)
|
parser = CommandParser(definitions)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun parse_navigateAi_returnsNavigateAiCommand() {
|
||||||
|
val result = parser.parse("仕事で行く東京駅に向かう")
|
||||||
|
assertEquals(CommandId.NAVIGATE_AI, result.commandId)
|
||||||
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
fun parse_getTime_returnsGetTimeDateCommand() {
|
fun parse_getTime_returnsGetTimeDateCommand() {
|
||||||
val result = parser.parse("今何時?")
|
val result = parser.parse("今何時?")
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
package com.example.voiceapp.domain
|
package com.example.voiceapp.domain
|
||||||
|
|
||||||
|
import com.example.voiceapp.domain.converter.KanjiToNumberConverter
|
||||||
import com.example.voiceapp.domain.executor.GetTimeDateExecutor
|
import com.example.voiceapp.domain.executor.GetTimeDateExecutor
|
||||||
import com.example.voiceapp.domain.model.CommandId
|
import com.example.voiceapp.domain.model.CommandId
|
||||||
import kotlinx.coroutines.test.runTest
|
import kotlinx.coroutines.test.runTest
|
||||||
@@ -17,7 +18,8 @@ class ExecuteCommandUseCaseTest {
|
|||||||
CommandDefinition(CommandId.GET_TIME_DATE, listOf(Regex(".*(今何時|何時ですか|日付|今日).*")), GetTimeDateExecutor())
|
CommandDefinition(CommandId.GET_TIME_DATE, listOf(Regex(".*(今何時|何時ですか|日付|今日).*")), GetTimeDateExecutor())
|
||||||
)
|
)
|
||||||
val parser = CommandParser(definitions)
|
val parser = CommandParser(definitions)
|
||||||
useCase = ExecuteCommandUseCase(parser)
|
val kanjiConverter = KanjiToNumberConverter
|
||||||
|
useCase = ExecuteCommandUseCase(kanjiConverter, parser)
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
|
|||||||
@@ -15,11 +15,13 @@ import kotlinx.coroutines.Dispatchers
|
|||||||
import kotlinx.coroutines.ExperimentalCoroutinesApi
|
import kotlinx.coroutines.ExperimentalCoroutinesApi
|
||||||
import kotlinx.coroutines.flow.MutableStateFlow
|
import kotlinx.coroutines.flow.MutableStateFlow
|
||||||
import kotlinx.coroutines.test.StandardTestDispatcher
|
import kotlinx.coroutines.test.StandardTestDispatcher
|
||||||
|
import kotlinx.coroutines.test.advanceTimeBy
|
||||||
import kotlinx.coroutines.test.resetMain
|
import kotlinx.coroutines.test.resetMain
|
||||||
import kotlinx.coroutines.test.runTest
|
import kotlinx.coroutines.test.runTest
|
||||||
import kotlinx.coroutines.test.setMain
|
import kotlinx.coroutines.test.setMain
|
||||||
import org.junit.After
|
import org.junit.After
|
||||||
import org.junit.Assert.assertEquals
|
import org.junit.Assert.assertEquals
|
||||||
|
import org.junit.Assert.assertTrue
|
||||||
import org.junit.Before
|
import org.junit.Before
|
||||||
import org.junit.Test
|
import org.junit.Test
|
||||||
|
|
||||||
@@ -65,4 +67,17 @@ class MainViewModelTest {
|
|||||||
assertEquals("現在は10時00分です。", history[1].text)
|
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)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,5 +4,9 @@ android.useAndroidX=true
|
|||||||
# Enable Jetifier for compatibility with third-party libraries using legacy support libraries
|
# Enable Jetifier for compatibility with third-party libraries using legacy support libraries
|
||||||
android.enableJetifier=true
|
android.enableJetifier=true
|
||||||
|
|
||||||
|
# Kapt options
|
||||||
|
kapt.incremental.apt=false
|
||||||
|
kapt.use.worker.api=false
|
||||||
|
|
||||||
# JVM options
|
# JVM options
|
||||||
org.gradle.jvmargs=-Xmx2048m -Dfile.encoding=UTF-8
|
org.gradle.jvmargs=-Xmx2048m -Dfile.encoding=UTF-8
|
||||||
|
|||||||
Reference in New Issue
Block a user