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:
parent
f94e299f65
commit
e21e2e90ef
5 changed files with 147 additions and 21 deletions
|
|
@ -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"
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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()
|
||||||
|
|
|
||||||
|
|
@ -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()
|
||||||
|
|
|
||||||
|
|
@ -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"
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue