From 64e7a42f9a9d683629616f2af94533e1c6a6bef5 Mon Sep 17 00:00:00 2001 From: Kohei Date: Sat, 21 Feb 2026 23:19:53 +0900 Subject: [PATCH] fix: add FOREGROUND_SERVICE_TYPE_CAMERA to keep camera alive across turns The camera preview went black on the 2nd assistant turn because AssistantService lacked the camera foreground service type, causing Android 14+ to revoke camera access after the grace period. - Declare FOREGROUND_SERVICE_CAMERA permission and foregroundServiceType - Dynamically include CAMERA type in startForeground when permitted - Replace ImageCapture.takePicture with PreviewView.bitmap to avoid disrupting the preview pipeline - Add ImplementationMode.COMPATIBLE to VoiceModeOverlay PreviewView - Move capture file I/O to Dispatchers.IO with proper error handling Co-Authored-By: Claude Opus 4.6 --- android/app/src/main/AndroidManifest.xml | 3 +- .../android/assistant/AssistantService.kt | 16 +++++- .../chat/voice/CameraCaptureManager.kt | 57 +++++++------------ .../feature/chat/voice/VoiceModeOverlay.kt | 6 +- 4 files changed, 40 insertions(+), 42 deletions(-) diff --git a/android/app/src/main/AndroidManifest.xml b/android/app/src/main/AndroidManifest.xml index 72952da53..60bb596bf 100644 --- a/android/app/src/main/AndroidManifest.xml +++ b/android/app/src/main/AndroidManifest.xml @@ -7,6 +7,7 @@ + @@ -76,7 +77,7 @@ + android:foregroundServiceType="camera|microphone|specialUse"> diff --git a/android/app/src/main/java/io/picoclaw/android/assistant/AssistantService.kt b/android/app/src/main/java/io/picoclaw/android/assistant/AssistantService.kt index 1a74f5a0a..18a0cb7c3 100644 --- a/android/app/src/main/java/io/picoclaw/android/assistant/AssistantService.kt +++ b/android/app/src/main/java/io/picoclaw/android/assistant/AssistantService.kt @@ -75,6 +75,7 @@ class AssistantService : LifecycleService(), SavedStateRegistryOwner { val permission = intent.getStringExtra(PermissionRequestActivity.EXTRA_PERMISSION) val granted = intent.getBooleanExtra(PermissionRequestActivity.EXTRA_GRANTED, false) if (permission == Manifest.permission.CAMERA && granted) { + startForeground(NOTIFICATION_ID, buildNotification(), computeForegroundTypes()) assistantManager.toggleCamera() } } @@ -115,8 +116,7 @@ class AssistantService : LifecycleService(), SavedStateRegistryOwner { startForeground( NOTIFICATION_ID, buildNotification(), - ServiceInfo.FOREGROUND_SERVICE_TYPE_MICROPHONE - or ServiceInfo.FOREGROUND_SERVICE_TYPE_SPECIAL_USE + computeForegroundTypes() ) // Resolve wsUrl from the main WebSocketClient @@ -152,6 +152,7 @@ class AssistantService : LifecycleService(), SavedStateRegistryOwner { if (ContextCompat.checkSelfPermission(this, Manifest.permission.CAMERA) == PackageManager.PERMISSION_GRANTED ) { + startForeground(NOTIFICATION_ID, buildNotification(), computeForegroundTypes()) assistantManager.toggleCamera() } else { startActivity(PermissionRequestActivity.intent(this, Manifest.permission.CAMERA)) @@ -233,6 +234,17 @@ class AssistantService : LifecycleService(), SavedStateRegistryOwner { } } + private fun computeForegroundTypes(): Int { + var types = ServiceInfo.FOREGROUND_SERVICE_TYPE_MICROPHONE or + ServiceInfo.FOREGROUND_SERVICE_TYPE_SPECIAL_USE + if (ContextCompat.checkSelfPermission(this, Manifest.permission.CAMERA) + == PackageManager.PERMISSION_GRANTED + ) { + types = types or ServiceInfo.FOREGROUND_SERVICE_TYPE_CAMERA + } + return types + } + private fun buildNotification(): Notification { return NotificationCompat.Builder(this, NotificationHelper.ASSISTANT_CHANNEL_ID) .setSmallIcon(android.R.drawable.ic_btn_speak_now) diff --git a/android/feature/chat/src/main/java/io/picoclaw/android/feature/chat/voice/CameraCaptureManager.kt b/android/feature/chat/src/main/java/io/picoclaw/android/feature/chat/voice/CameraCaptureManager.kt index 1a186f95d..861fca74b 100644 --- a/android/feature/chat/src/main/java/io/picoclaw/android/feature/chat/voice/CameraCaptureManager.kt +++ b/android/feature/chat/src/main/java/io/picoclaw/android/feature/chat/voice/CameraCaptureManager.kt @@ -1,23 +1,21 @@ package io.picoclaw.android.feature.chat.voice import android.content.Context +import android.graphics.Bitmap import android.util.Log import androidx.camera.core.CameraSelector -import androidx.camera.core.ImageCapture -import androidx.camera.core.ImageCaptureException import androidx.camera.core.Preview import androidx.camera.lifecycle.ProcessCameraProvider import androidx.camera.view.PreviewView import androidx.core.content.ContextCompat import androidx.lifecycle.LifecycleOwner import io.picoclaw.android.core.domain.model.ImageAttachment -import kotlinx.coroutines.suspendCancellableCoroutine +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext import java.io.File -import kotlin.coroutines.resume class CameraCaptureManager(private val context: Context) { - private var imageCapture: ImageCapture? = null private var cameraProvider: ProcessCameraProvider? = null private var currentPreviewView: PreviewView? = null @@ -39,17 +37,11 @@ class CameraCaptureManager(private val context: Context) { it.surfaceProvider = previewView.surfaceProvider } - val capture = ImageCapture.Builder() - .setCaptureMode(ImageCapture.CAPTURE_MODE_MINIMIZE_LATENCY) - .build() - imageCapture = capture - provider.unbindAll() provider.bindToLifecycle( lifecycleOwner, CameraSelector.DEFAULT_BACK_CAMERA, - preview, - capture + preview ) } catch (e: Exception) { Log.w(TAG, "Failed to bind camera", e) @@ -60,35 +52,26 @@ class CameraCaptureManager(private val context: Context) { fun unbind() { cameraProvider?.unbindAll() cameraProvider = null - imageCapture = null currentPreviewView = null } suspend fun captureFrame(): ImageAttachment? { - val capture = imageCapture ?: return null - val imagesDir = File(context.cacheDir, "images").apply { mkdirs() } - val file = File(imagesDir, "voice_cam_${System.currentTimeMillis()}.jpg") - val outputOptions = ImageCapture.OutputFileOptions.Builder(file).build() - - return suspendCancellableCoroutine { cont -> - capture.takePicture( - outputOptions, - ContextCompat.getMainExecutor(context), - object : ImageCapture.OnImageSavedCallback { - override fun onImageSaved(output: ImageCapture.OutputFileResults) { - val uri = androidx.core.content.FileProvider.getUriForFile( - context, - "${context.packageName}.fileprovider", - file - ) - cont.resume(ImageAttachment(uri = uri.toString())) - } - - override fun onError(exception: ImageCaptureException) { - cont.resume(null) - } - } - ) + val bitmap = currentPreviewView?.bitmap ?: return null + return try { + withContext(Dispatchers.IO) { + val imagesDir = File(context.cacheDir, "images").apply { mkdirs() } + val file = File(imagesDir, "voice_cam_${System.currentTimeMillis()}.jpg") + file.outputStream().use { bitmap.compress(Bitmap.CompressFormat.JPEG, 80, it) } + val uri = androidx.core.content.FileProvider.getUriForFile( + context, + "${context.packageName}.fileprovider", + file + ) + ImageAttachment(uri = uri.toString()) + } + } catch (e: Exception) { + Log.w(TAG, "Failed to capture frame", e) + null } } diff --git a/android/feature/chat/src/main/java/io/picoclaw/android/feature/chat/voice/VoiceModeOverlay.kt b/android/feature/chat/src/main/java/io/picoclaw/android/feature/chat/voice/VoiceModeOverlay.kt index 8a6930575..0a8a35bda 100644 --- a/android/feature/chat/src/main/java/io/picoclaw/android/feature/chat/voice/VoiceModeOverlay.kt +++ b/android/feature/chat/src/main/java/io/picoclaw/android/feature/chat/voice/VoiceModeOverlay.kt @@ -1,6 +1,7 @@ package io.picoclaw.android.feature.chat.voice import androidx.camera.view.PreviewView +import androidx.camera.view.PreviewView.ImplementationMode import androidx.compose.animation.AnimatedVisibility import androidx.compose.animation.fadeIn import androidx.compose.animation.fadeOut @@ -177,8 +178,9 @@ fun VoiceModeOverlay( ) { AndroidView( factory = { ctx -> - PreviewView(ctx).also { preview -> - cameraCaptureManager.bind(lifecycleOwner, preview) + PreviewView(ctx).apply { + implementationMode = ImplementationMode.COMPATIBLE + cameraCaptureManager.bind(lifecycleOwner, this) } }, modifier = Modifier.fillMaxSize()