diff --git a/.github/workflows/build-release.yml b/.github/workflows/build-release.yml index e93f6ab..aa58b64 100644 --- a/.github/workflows/build-release.yml +++ b/.github/workflows/build-release.yml @@ -69,7 +69,7 @@ jobs: files: | app/build/outputs/apk/release/OpenClawAssistant-${{ github.ref_name }}.apk app/build/outputs/bundle/release/OpenClawAssistant-${{ github.ref_name }}.aab - draft: true + draft: false prerelease: false - name: Upload Artifacts (Non-tag push) diff --git a/app/src/main/java/com/openclaw/assistant/ChatActivity.kt b/app/src/main/java/com/openclaw/assistant/ChatActivity.kt index 19fb97d..5a81035 100644 --- a/app/src/main/java/com/openclaw/assistant/ChatActivity.kt +++ b/app/src/main/java/com/openclaw/assistant/ChatActivity.kt @@ -1,13 +1,17 @@ package com.openclaw.assistant import android.Manifest +import android.content.Intent import android.content.pm.PackageManager +import android.net.Uri import android.os.Bundle +import android.provider.Settings import android.speech.tts.TextToSpeech import android.util.Log import android.widget.Toast import androidx.activity.ComponentActivity import androidx.activity.compose.setContent +import androidx.activity.result.contract.ActivityResultContracts import androidx.activity.viewModels import androidx.compose.foundation.background import androidx.compose.foundation.layout.* @@ -63,6 +67,16 @@ class ChatActivity : ComponentActivity(), TextToSpeech.OnInitListener { private var isRetry = false private lateinit var settings: SettingsRepository + private val permissionLauncher = registerForActivityResult( + ActivityResultContracts.RequestPermission() + ) { granted -> + if (!granted) { + if (!ActivityCompat.shouldShowRequestPermissionRationale(this, Manifest.permission.RECORD_AUDIO)) { + showPermissionSettingsDialog() + } + } + } + override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) settings = SettingsRepository.getInstance(this) @@ -72,13 +86,9 @@ class ChatActivity : ComponentActivity(), TextToSpeech.OnInitListener { initializeTTS() // Request Microphone permission if not granted - if (ContextCompat.checkSelfPermission(this, Manifest.permission.RECORD_AUDIO) + if (ContextCompat.checkSelfPermission(this, Manifest.permission.RECORD_AUDIO) != PackageManager.PERMISSION_GRANTED) { - ActivityCompat.requestPermissions( - this, - arrayOf(Manifest.permission.RECORD_AUDIO), - REQUEST_RECORD_AUDIO - ) + permissionLauncher.launch(Manifest.permission.RECORD_AUDIO) } setContent { @@ -97,7 +107,7 @@ class ChatActivity : ComponentActivity(), TextToSpeech.OnInitListener { if (checkPermission()) { viewModel.startListening() } else { - Toast.makeText(this, getString(R.string.mic_permission_required), Toast.LENGTH_SHORT).show() + requestMicPermissionForListening() } }, onStopListening = { viewModel.stopListening() }, @@ -157,8 +167,26 @@ class ChatActivity : ComponentActivity(), TextToSpeech.OnInitListener { return ContextCompat.checkSelfPermission(this, Manifest.permission.RECORD_AUDIO) == PackageManager.PERMISSION_GRANTED } - companion object { - private const val REQUEST_RECORD_AUDIO = 200 + private fun requestMicPermissionForListening() { + if (ActivityCompat.shouldShowRequestPermissionRationale(this, Manifest.permission.RECORD_AUDIO)) { + permissionLauncher.launch(Manifest.permission.RECORD_AUDIO) + } else if (!checkPermission()) { + // First-time request or permanently denied + permissionLauncher.launch(Manifest.permission.RECORD_AUDIO) + } + } + + private fun showPermissionSettingsDialog() { + android.app.AlertDialog.Builder(this) + .setTitle(getString(R.string.mic_permission_required)) + .setMessage(getString(R.string.mic_permission_denied_permanently)) + .setPositiveButton(getString(R.string.open_settings)) { _, _ -> + startActivity(Intent(Settings.ACTION_APPLICATION_DETAILS_SETTINGS).apply { + data = Uri.fromParts("package", packageName, null) + }) + } + .setNegativeButton(getString(R.string.cancel), null) + .show() } } diff --git a/app/src/main/java/com/openclaw/assistant/MainActivity.kt b/app/src/main/java/com/openclaw/assistant/MainActivity.kt index d5fd0ee..06fb44f 100644 --- a/app/src/main/java/com/openclaw/assistant/MainActivity.kt +++ b/app/src/main/java/com/openclaw/assistant/MainActivity.kt @@ -3,6 +3,7 @@ package com.openclaw.assistant import android.Manifest import android.content.Intent import android.content.pm.PackageManager +import android.net.Uri import android.os.Build import android.os.Bundle import android.provider.Settings @@ -55,13 +56,30 @@ class MainActivity : ComponentActivity(), TextToSpeech.OnInitListener { private lateinit var settings: SettingsRepository private var tts: TextToSpeech? = null private var voiceDiagnostic by mutableStateOf(null) + private var pendingHotwordStart = false private val permissionLauncher = registerForActivityResult( ActivityResultContracts.RequestMultiplePermissions() ) { permissions -> - val allGranted = permissions.values.all { it } - if (!allGranted) { - Toast.makeText(this, getString(R.string.permissions_required), Toast.LENGTH_SHORT).show() + val recordAudioGranted = permissions[Manifest.permission.RECORD_AUDIO] ?: false + if (pendingHotwordStart) { + pendingHotwordStart = false + if (recordAudioGranted) { + settings.hotwordEnabled = true + HotwordService.start(this) + Toast.makeText(this, getString(R.string.hotword_started), Toast.LENGTH_SHORT).show() + } else { + if (!ActivityCompat.shouldShowRequestPermissionRationale(this, Manifest.permission.RECORD_AUDIO)) { + showPermissionSettingsDialog() + } else { + Toast.makeText(this, getString(R.string.mic_permission_required), Toast.LENGTH_SHORT).show() + } + } + } else { + val allGranted = permissions.values.all { it } + if (!allGranted) { + Toast.makeText(this, getString(R.string.permissions_required), Toast.LENGTH_SHORT).show() + } } } @@ -137,14 +155,19 @@ class MainActivity : ComponentActivity(), TextToSpeech.OnInitListener { private fun toggleHotwordService(enabled: Boolean) { if (enabled) { if (ContextCompat.checkSelfPermission(this, Manifest.permission.RECORD_AUDIO) - != PackageManager.PERMISSION_GRANTED) { + == PackageManager.PERMISSION_GRANTED) { + settings.hotwordEnabled = true + HotwordService.start(this) + Toast.makeText(this, getString(R.string.hotword_started), Toast.LENGTH_SHORT).show() + } else if (ActivityCompat.shouldShowRequestPermissionRationale(this, Manifest.permission.RECORD_AUDIO)) { + pendingHotwordStart = true Toast.makeText(this, getString(R.string.mic_permission_required), Toast.LENGTH_SHORT).show() permissionLauncher.launch(arrayOf(Manifest.permission.RECORD_AUDIO)) - return + } else { + // First-time request or permanently denied: launch and decide in callback + pendingHotwordStart = true + permissionLauncher.launch(arrayOf(Manifest.permission.RECORD_AUDIO)) } - settings.hotwordEnabled = true - HotwordService.start(this) - Toast.makeText(this, getString(R.string.hotword_started), Toast.LENGTH_SHORT).show() } else { settings.hotwordEnabled = false HotwordService.stop(this) @@ -152,6 +175,22 @@ class MainActivity : ComponentActivity(), TextToSpeech.OnInitListener { } } + private fun showPermissionSettingsDialog() { + android.app.AlertDialog.Builder(this) + .setTitle(getString(R.string.mic_permission_required)) + .setMessage(getString(R.string.mic_permission_denied_permanently)) + .setPositiveButton(getString(R.string.open_settings)) { _, _ -> openAppSettings() } + .setNegativeButton(getString(R.string.cancel), null) + .show() + } + + private fun openAppSettings() { + val intent = Intent(Settings.ACTION_APPLICATION_DETAILS_SETTINGS).apply { + data = Uri.fromParts("package", packageName, null) + } + startActivity(intent) + } + fun isAssistantActive(): Boolean { return try { Settings.Secure.getString(contentResolver, "assistant")?.contains(packageName) == true diff --git a/app/src/main/java/com/openclaw/assistant/speech/SpeechRecognizerManager.kt b/app/src/main/java/com/openclaw/assistant/speech/SpeechRecognizerManager.kt index 91b1fca..60e30ca 100644 --- a/app/src/main/java/com/openclaw/assistant/speech/SpeechRecognizerManager.kt +++ b/app/src/main/java/com/openclaw/assistant/speech/SpeechRecognizerManager.kt @@ -1,9 +1,12 @@ package com.openclaw.assistant.speech +import android.content.ComponentName import android.content.Context import android.content.Intent +import android.content.pm.PackageManager import android.os.Bundle import android.speech.RecognitionListener +import android.speech.RecognitionService import android.speech.RecognizerIntent import android.speech.SpeechRecognizer import kotlinx.coroutines.channels.awaitClose @@ -44,9 +47,13 @@ class SpeechRecognizerManager(private val context: Context) { // Ensure creation on Main thread withContext(Dispatchers.Main) { if (recognizer == null && SpeechRecognizer.isRecognitionAvailable(context)) { - // Use application context to avoid activity/service lifecycle leaks val appContext = context.applicationContext - recognizer = SpeechRecognizer.createSpeechRecognizer(appContext) + val serviceComponent = findRecognitionService(appContext) + recognizer = if (serviceComponent != null) { + SpeechRecognizer.createSpeechRecognizer(appContext, serviceComponent) + } else { + SpeechRecognizer.createSpeechRecognizer(appContext) + } } } } @@ -177,7 +184,7 @@ class SpeechRecognizerManager(private val context: Context) { /** * Completely destroy the recognizer resources */ - fun destroy() { + fun destroy() { try { recognizer?.destroy() } catch (e: Exception) { @@ -185,6 +192,38 @@ class SpeechRecognizerManager(private val context: Context) { } recognizer = null } + + /** + * Find a real speech recognition service, skipping our own stub service. + * This app registers a no-op RecognitionService (required for VoiceInteractionService), + * which some devices may select as the default, breaking SpeechRecognizer. + */ + private fun findRecognitionService(context: Context): ComponentName? { + val pm = context.packageManager + val services = pm.queryIntentServices( + Intent(RecognitionService.SERVICE_INTERFACE), + PackageManager.GET_META_DATA + ) + + val ownPackage = context.packageName + + // Prefer Google's service + val google = services.firstOrNull { + it.serviceInfo.packageName == "com.google.android.googlequicksearchbox" + } + if (google != null) { + return ComponentName(google.serviceInfo.packageName, google.serviceInfo.name) + } + + // Use any other service that is NOT our own stub + val other = services.firstOrNull { it.serviceInfo.packageName != ownPackage } + if (other != null) { + return ComponentName(other.serviceInfo.packageName, other.serviceInfo.name) + } + + // No external service found; fall back to default + return null + } } /** diff --git a/app/src/main/res/values-ja/strings.xml b/app/src/main/res/values-ja/strings.xml index 940b6f3..2b7d7dd 100644 --- a/app/src/main/res/values-ja/strings.xml +++ b/app/src/main/res/values-ja/strings.xml @@ -111,6 +111,11 @@ 未接続 再接続中… + + マイクの権限が永久に拒否されています。ウェイクワード検知を使用するには、アプリの設定から権限を有効にしてください。 + 設定を開く + キャンセル + 会話 新規チャット diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index daa1382..5e9ddda 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -135,6 +135,11 @@ Disconnected Reconnecting… + + Microphone permission has been permanently denied. Please enable it in app settings to use wake word detection. + Open Settings + Cancel + Conversations New Chat