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:taskAffinity=""
android:launchMode="singleTask"
android:theme="@android:style/Theme.NoDisplay">
android:theme="@android:style/Theme.Translucent.NoTitleBar">
<intent-filter>
<action android:name="android.intent.action.ASSIST" />
<category android:name="android.intent.category.DEFAULT" />
</intent-filter>
</activity>
<activity
android:name=".PermissionRequestActivity"
android:exported="false"
android:excludeFromRecents="true"
android:taskAffinity=""
android:launchMode="singleTop"
android:theme="@android:style/Theme.Translucent.NoTitleBar" />
<service
android:name=".assistant.AssistantService"
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
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()

View file

@ -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()

View file

@ -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"
}
}