From e21e2e90effc4205b5ed6ef173e91592865a5ca1 Mon Sep 17 00:00:00 2001 From: Kohei Date: Sat, 21 Feb 2026 21:42:09 +0900 Subject: [PATCH] fix: resolve black camera preview and permission issues in assistant overlay - Use PreviewView COMPATIBLE mode (TextureView) for overlay window rendering - Add generic PermissionRequestActivity for runtime permission requests from Service context, with broadcast-based result notification - Auto-enable camera after permission grant via BroadcastReceiver - Fix camera bind/unbind race condition with PreviewView tracking - Change AssistantActivity theme to Translucent for reliable permission dialogs Co-Authored-By: Claude Opus 4.6 --- android/app/src/main/AndroidManifest.xml | 10 +++- .../android/PermissionRequestActivity.kt | 59 +++++++++++++++++++ .../android/assistant/AssistantService.kt | 41 ++++++++++++- .../chat/assistant/AssistantPillBar.kt | 6 +- .../chat/voice/CameraCaptureManager.kt | 52 ++++++++++------ 5 files changed, 147 insertions(+), 21 deletions(-) create mode 100644 android/app/src/main/java/io/picoclaw/android/PermissionRequestActivity.kt diff --git a/android/app/src/main/AndroidManifest.xml b/android/app/src/main/AndroidManifest.xml index 835031b0d..72952da53 100644 --- a/android/app/src/main/AndroidManifest.xml +++ b/android/app/src/main/AndroidManifest.xml @@ -58,13 +58,21 @@ android:excludeFromRecents="true" android:taskAffinity="" android:launchMode="singleTask" - android:theme="@android:style/Theme.NoDisplay"> + android:theme="@android:style/Theme.Translucent.NoTitleBar"> + + + broadcastResult( + intent.getStringExtra(EXTRA_PERMISSION).orEmpty(), + granted + ) + finish() + } + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + val permission = intent.getStringExtra(EXTRA_PERMISSION) + if (permission == null) { + finish() + return + } + if (ContextCompat.checkSelfPermission(this, permission) + == PackageManager.PERMISSION_GRANTED + ) { + broadcastResult(permission, true) + finish() + return + } + launcher.launch(permission) + } + + private fun broadcastResult(permission: String, granted: Boolean) { + sendBroadcast( + Intent(ACTION_RESULT) + .setPackage(packageName) + .putExtra(EXTRA_PERMISSION, permission) + .putExtra(EXTRA_GRANTED, granted) + ) + } + + companion object { + const val EXTRA_PERMISSION = "permission" + const val EXTRA_GRANTED = "granted" + const val ACTION_RESULT = "io.picoclaw.android.PERMISSION_RESULT" + + fun intent(context: Context, permission: String): Intent = + Intent(context, PermissionRequestActivity::class.java) + .putExtra(EXTRA_PERMISSION, permission) + .addFlags(Intent.FLAG_ACTIVITY_NEW_TASK) + } +} 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 39de84fcf..1a74f5a0a 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 @@ -1,7 +1,12 @@ package io.picoclaw.android.assistant +import android.Manifest import android.app.Notification +import android.content.BroadcastReceiver +import android.content.Context import android.content.Intent +import android.content.IntentFilter +import android.content.pm.PackageManager import android.content.pm.ServiceInfo import android.graphics.PixelFormat import android.os.IBinder @@ -20,6 +25,7 @@ import androidx.compose.ui.layout.onGloballyPositioned import androidx.compose.ui.layout.positionInWindow import androidx.compose.ui.platform.ComposeView import androidx.core.app.NotificationCompat +import androidx.core.content.ContextCompat import androidx.lifecycle.LifecycleService import androidx.lifecycle.setViewTreeLifecycleOwner import androidx.savedstate.SavedStateRegistry @@ -27,6 +33,7 @@ import androidx.savedstate.SavedStateRegistryController import androidx.savedstate.SavedStateRegistryOwner import androidx.savedstate.setViewTreeSavedStateRegistryOwner import io.ktor.client.HttpClient +import io.picoclaw.android.PermissionRequestActivity import io.picoclaw.android.core.data.remote.WebSocketClient import io.picoclaw.android.core.data.repository.AssistantConnectionImpl import io.picoclaw.android.core.domain.repository.AssistantConnection @@ -63,6 +70,16 @@ class AssistantService : LifecycleService(), SavedStateRegistryOwner { override val savedStateRegistry: SavedStateRegistry get() = savedStateRegistryController.savedStateRegistry + private val permissionReceiver = object : BroadcastReceiver() { + override fun onReceive(context: Context, intent: Intent) { + val permission = intent.getStringExtra(PermissionRequestActivity.EXTRA_PERMISSION) + val granted = intent.getBooleanExtra(PermissionRequestActivity.EXTRA_GRANTED, false) + if (permission == Manifest.permission.CAMERA && granted) { + assistantManager.toggleCamera() + } + } + } + override fun onCreate() { savedStateRegistryController.performAttach() savedStateRegistryController.performRestore(null) @@ -83,6 +100,13 @@ class AssistantService : LifecycleService(), SavedStateRegistryOwner { cameraCaptureManager = cameraCaptureManager, contentResolver = contentResolver ) + + ContextCompat.registerReceiver( + this, + permissionReceiver, + IntentFilter(PermissionRequestActivity.ACTION_RESULT), + ContextCompat.RECEIVER_NOT_EXPORTED + ) } override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int { @@ -106,6 +130,7 @@ class AssistantService : LifecycleService(), SavedStateRegistryOwner { } override fun onDestroy() { + unregisterReceiver(permissionReceiver) removeOverlay() assistantManager.destroy() ttsWrapper.destroy() @@ -119,6 +144,20 @@ class AssistantService : LifecycleService(), SavedStateRegistryOwner { return null } + private fun handleCameraToggle() { + if (assistantManager.state.value.isCameraActive) { + assistantManager.toggleCamera() + return + } + if (ContextCompat.checkSelfPermission(this, Manifest.permission.CAMERA) + == PackageManager.PERMISSION_GRANTED + ) { + assistantManager.toggleCamera() + } else { + startActivity(PermissionRequestActivity.intent(this, Manifest.permission.CAMERA)) + } + } + private fun shutdown() { stopForeground(STOP_FOREGROUND_REMOVE) stopSelf() @@ -168,7 +207,7 @@ class AssistantService : LifecycleService(), SavedStateRegistryOwner { state = state, onClose = { shutdown() }, onInterrupt = { assistantManager.interrupt() }, - onCameraToggle = { assistantManager.toggleCamera() }, + onCameraToggle = { handleCameraToggle() }, cameraCaptureManager = cameraCaptureManager, modifier = Modifier.onGloballyPositioned { coordinates -> wrapper.contentTop = coordinates.positionInWindow().y.toInt() diff --git a/android/feature/chat/src/main/java/io/picoclaw/android/feature/chat/assistant/AssistantPillBar.kt b/android/feature/chat/src/main/java/io/picoclaw/android/feature/chat/assistant/AssistantPillBar.kt index a94b502b8..93501c4c0 100644 --- a/android/feature/chat/src/main/java/io/picoclaw/android/feature/chat/assistant/AssistantPillBar.kt +++ b/android/feature/chat/src/main/java/io/picoclaw/android/feature/chat/assistant/AssistantPillBar.kt @@ -1,6 +1,7 @@ package io.picoclaw.android.feature.chat.assistant import androidx.camera.view.PreviewView +import androidx.camera.view.PreviewView.ImplementationMode import androidx.compose.animation.AnimatedVisibility import androidx.compose.animation.animateContentSize import androidx.compose.animation.core.LinearEasing @@ -104,8 +105,9 @@ fun AssistantPillBar( ) { AndroidView( factory = { ctx -> - PreviewView(ctx).also { preview -> - cameraCaptureManager.bind(lifecycleOwner, preview) + PreviewView(ctx).apply { + implementationMode = ImplementationMode.COMPATIBLE + cameraCaptureManager.bind(lifecycleOwner, this) } }, modifier = Modifier.fillMaxSize() 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 d0fbbe202..1a186f95d 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,6 +1,7 @@ package io.picoclaw.android.feature.chat.voice import android.content.Context +import android.util.Log import androidx.camera.core.CameraSelector import androidx.camera.core.ImageCapture import androidx.camera.core.ImageCaptureException @@ -18,29 +19,41 @@ class CameraCaptureManager(private val context: Context) { private var imageCapture: ImageCapture? = null private var cameraProvider: ProcessCameraProvider? = null + private var currentPreviewView: PreviewView? = null fun bind(lifecycleOwner: LifecycleOwner, previewView: PreviewView) { + if (currentPreviewView === previewView && cameraProvider != null) return + unbind() + currentPreviewView = previewView + val providerFuture = ProcessCameraProvider.getInstance(context) providerFuture.addListener({ - val provider = providerFuture.get() - cameraProvider = provider + try { + // Stale callback – unbind() was called while waiting + if (currentPreviewView !== previewView) return@addListener - val preview = Preview.Builder().build().also { - it.surfaceProvider = previewView.surfaceProvider + val provider = providerFuture.get() + cameraProvider = provider + + val preview = Preview.Builder().build().also { + 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 + ) + } catch (e: Exception) { + Log.w(TAG, "Failed to bind camera", e) } - - val capture = ImageCapture.Builder() - .setCaptureMode(ImageCapture.CAPTURE_MODE_MINIMIZE_LATENCY) - .build() - imageCapture = capture - - provider.unbindAll() - provider.bindToLifecycle( - lifecycleOwner, - CameraSelector.DEFAULT_BACK_CAMERA, - preview, - capture - ) }, ContextCompat.getMainExecutor(context)) } @@ -48,6 +61,7 @@ class CameraCaptureManager(private val context: Context) { cameraProvider?.unbindAll() cameraProvider = null imageCapture = null + currentPreviewView = null } suspend fun captureFrame(): ImageAttachment? { @@ -77,4 +91,8 @@ class CameraCaptureManager(private val context: Context) { ) } } + + companion object { + private const val TAG = "CameraCaptureManager" + } }