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 <noreply@anthropic.com>
This commit is contained in:
Kohei 2026-02-21 21:42:09 +09:00
parent f94e299f65
commit e21e2e90ef
5 changed files with 147 additions and 21 deletions

View file

@ -58,13 +58,21 @@
android:excludeFromRecents="true" android:excludeFromRecents="true"
android:taskAffinity="" android:taskAffinity=""
android:launchMode="singleTask" android:launchMode="singleTask"
android:theme="@android:style/Theme.NoDisplay"> android:theme="@android:style/Theme.Translucent.NoTitleBar">
<intent-filter> <intent-filter>
<action android:name="android.intent.action.ASSIST" /> <action android:name="android.intent.action.ASSIST" />
<category android:name="android.intent.category.DEFAULT" /> <category android:name="android.intent.category.DEFAULT" />
</intent-filter> </intent-filter>
</activity> </activity>
<activity
android:name=".PermissionRequestActivity"
android:exported="false"
android:excludeFromRecents="true"
android:taskAffinity=""
android:launchMode="singleTop"
android:theme="@android:style/Theme.Translucent.NoTitleBar" />
<service <service
android:name=".assistant.AssistantService" android:name=".assistant.AssistantService"
android:exported="false" android:exported="false"

View file

@ -0,0 +1,59 @@
package io.picoclaw.android
import android.content.Context
import android.content.Intent
import android.content.pm.PackageManager
import android.os.Bundle
import androidx.activity.ComponentActivity
import androidx.activity.result.contract.ActivityResultContracts
import androidx.core.content.ContextCompat
class PermissionRequestActivity : ComponentActivity() {
private val launcher = registerForActivityResult(
ActivityResultContracts.RequestPermission()
) { granted ->
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)
}
}

View file

@ -1,7 +1,12 @@
package io.picoclaw.android.assistant package io.picoclaw.android.assistant
import android.Manifest
import android.app.Notification import android.app.Notification
import android.content.BroadcastReceiver
import android.content.Context
import android.content.Intent import android.content.Intent
import android.content.IntentFilter
import android.content.pm.PackageManager
import android.content.pm.ServiceInfo import android.content.pm.ServiceInfo
import android.graphics.PixelFormat import android.graphics.PixelFormat
import android.os.IBinder import android.os.IBinder
@ -20,6 +25,7 @@ import androidx.compose.ui.layout.onGloballyPositioned
import androidx.compose.ui.layout.positionInWindow import androidx.compose.ui.layout.positionInWindow
import androidx.compose.ui.platform.ComposeView import androidx.compose.ui.platform.ComposeView
import androidx.core.app.NotificationCompat import androidx.core.app.NotificationCompat
import androidx.core.content.ContextCompat
import androidx.lifecycle.LifecycleService import androidx.lifecycle.LifecycleService
import androidx.lifecycle.setViewTreeLifecycleOwner import androidx.lifecycle.setViewTreeLifecycleOwner
import androidx.savedstate.SavedStateRegistry import androidx.savedstate.SavedStateRegistry
@ -27,6 +33,7 @@ import androidx.savedstate.SavedStateRegistryController
import androidx.savedstate.SavedStateRegistryOwner import androidx.savedstate.SavedStateRegistryOwner
import androidx.savedstate.setViewTreeSavedStateRegistryOwner import androidx.savedstate.setViewTreeSavedStateRegistryOwner
import io.ktor.client.HttpClient import io.ktor.client.HttpClient
import io.picoclaw.android.PermissionRequestActivity
import io.picoclaw.android.core.data.remote.WebSocketClient import io.picoclaw.android.core.data.remote.WebSocketClient
import io.picoclaw.android.core.data.repository.AssistantConnectionImpl import io.picoclaw.android.core.data.repository.AssistantConnectionImpl
import io.picoclaw.android.core.domain.repository.AssistantConnection import io.picoclaw.android.core.domain.repository.AssistantConnection
@ -63,6 +70,16 @@ class AssistantService : LifecycleService(), SavedStateRegistryOwner {
override val savedStateRegistry: SavedStateRegistry override val savedStateRegistry: SavedStateRegistry
get() = savedStateRegistryController.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() { override fun onCreate() {
savedStateRegistryController.performAttach() savedStateRegistryController.performAttach()
savedStateRegistryController.performRestore(null) savedStateRegistryController.performRestore(null)
@ -83,6 +100,13 @@ class AssistantService : LifecycleService(), SavedStateRegistryOwner {
cameraCaptureManager = cameraCaptureManager, cameraCaptureManager = cameraCaptureManager,
contentResolver = contentResolver contentResolver = contentResolver
) )
ContextCompat.registerReceiver(
this,
permissionReceiver,
IntentFilter(PermissionRequestActivity.ACTION_RESULT),
ContextCompat.RECEIVER_NOT_EXPORTED
)
} }
override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int { override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
@ -106,6 +130,7 @@ class AssistantService : LifecycleService(), SavedStateRegistryOwner {
} }
override fun onDestroy() { override fun onDestroy() {
unregisterReceiver(permissionReceiver)
removeOverlay() removeOverlay()
assistantManager.destroy() assistantManager.destroy()
ttsWrapper.destroy() ttsWrapper.destroy()
@ -119,6 +144,20 @@ class AssistantService : LifecycleService(), SavedStateRegistryOwner {
return null 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() { private fun shutdown() {
stopForeground(STOP_FOREGROUND_REMOVE) stopForeground(STOP_FOREGROUND_REMOVE)
stopSelf() stopSelf()
@ -168,7 +207,7 @@ class AssistantService : LifecycleService(), SavedStateRegistryOwner {
state = state, state = state,
onClose = { shutdown() }, onClose = { shutdown() },
onInterrupt = { assistantManager.interrupt() }, onInterrupt = { assistantManager.interrupt() },
onCameraToggle = { assistantManager.toggleCamera() }, onCameraToggle = { handleCameraToggle() },
cameraCaptureManager = cameraCaptureManager, cameraCaptureManager = cameraCaptureManager,
modifier = Modifier.onGloballyPositioned { coordinates -> modifier = Modifier.onGloballyPositioned { coordinates ->
wrapper.contentTop = coordinates.positionInWindow().y.toInt() wrapper.contentTop = coordinates.positionInWindow().y.toInt()

View file

@ -1,6 +1,7 @@
package io.picoclaw.android.feature.chat.assistant package io.picoclaw.android.feature.chat.assistant
import androidx.camera.view.PreviewView import androidx.camera.view.PreviewView
import androidx.camera.view.PreviewView.ImplementationMode
import androidx.compose.animation.AnimatedVisibility import androidx.compose.animation.AnimatedVisibility
import androidx.compose.animation.animateContentSize import androidx.compose.animation.animateContentSize
import androidx.compose.animation.core.LinearEasing import androidx.compose.animation.core.LinearEasing
@ -104,8 +105,9 @@ fun AssistantPillBar(
) { ) {
AndroidView( AndroidView(
factory = { ctx -> factory = { ctx ->
PreviewView(ctx).also { preview -> PreviewView(ctx).apply {
cameraCaptureManager.bind(lifecycleOwner, preview) implementationMode = ImplementationMode.COMPATIBLE
cameraCaptureManager.bind(lifecycleOwner, this)
} }
}, },
modifier = Modifier.fillMaxSize() modifier = Modifier.fillMaxSize()

View file

@ -1,6 +1,7 @@
package io.picoclaw.android.feature.chat.voice package io.picoclaw.android.feature.chat.voice
import android.content.Context import android.content.Context
import android.util.Log
import androidx.camera.core.CameraSelector import androidx.camera.core.CameraSelector
import androidx.camera.core.ImageCapture import androidx.camera.core.ImageCapture
import androidx.camera.core.ImageCaptureException import androidx.camera.core.ImageCaptureException
@ -18,29 +19,41 @@ class CameraCaptureManager(private val context: Context) {
private var imageCapture: ImageCapture? = null private var imageCapture: ImageCapture? = null
private var cameraProvider: ProcessCameraProvider? = null private var cameraProvider: ProcessCameraProvider? = null
private var currentPreviewView: PreviewView? = null
fun bind(lifecycleOwner: LifecycleOwner, previewView: PreviewView) { fun bind(lifecycleOwner: LifecycleOwner, previewView: PreviewView) {
if (currentPreviewView === previewView && cameraProvider != null) return
unbind()
currentPreviewView = previewView
val providerFuture = ProcessCameraProvider.getInstance(context) val providerFuture = ProcessCameraProvider.getInstance(context)
providerFuture.addListener({ providerFuture.addListener({
val provider = providerFuture.get() try {
cameraProvider = provider // Stale callback unbind() was called while waiting
if (currentPreviewView !== previewView) return@addListener
val preview = Preview.Builder().build().also { val provider = providerFuture.get()
it.surfaceProvider = previewView.surfaceProvider 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)) }, ContextCompat.getMainExecutor(context))
} }
@ -48,6 +61,7 @@ class CameraCaptureManager(private val context: Context) {
cameraProvider?.unbindAll() cameraProvider?.unbindAll()
cameraProvider = null cameraProvider = null
imageCapture = null imageCapture = null
currentPreviewView = null
} }
suspend fun captureFrame(): ImageAttachment? { suspend fun captureFrame(): ImageAttachment? {
@ -77,4 +91,8 @@ class CameraCaptureManager(private val context: Context) {
) )
} }
} }
companion object {
private const val TAG = "CameraCaptureManager"
}
} }