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