fix: add FOREGROUND_SERVICE_TYPE_CAMERA to keep camera alive across turns
The camera preview went black on the 2nd assistant turn because AssistantService lacked the camera foreground service type, causing Android 14+ to revoke camera access after the grace period. - Declare FOREGROUND_SERVICE_CAMERA permission and foregroundServiceType - Dynamically include CAMERA type in startForeground when permitted - Replace ImageCapture.takePicture with PreviewView.bitmap to avoid disrupting the preview pipeline - Add ImplementationMode.COMPATIBLE to VoiceModeOverlay PreviewView - Move capture file I/O to Dispatchers.IO with proper error handling Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
parent
03e46c3d64
commit
64e7a42f9a
4 changed files with 40 additions and 42 deletions
|
|
@ -7,6 +7,7 @@
|
|||
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
|
||||
<uses-permission android:name="android.permission.SYSTEM_ALERT_WINDOW" />
|
||||
<uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
|
||||
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_CAMERA" />
|
||||
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_MICROPHONE" />
|
||||
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_SPECIAL_USE" />
|
||||
|
||||
|
|
@ -76,7 +77,7 @@
|
|||
<service
|
||||
android:name=".assistant.AssistantService"
|
||||
android:exported="false"
|
||||
android:foregroundServiceType="microphone|specialUse">
|
||||
android:foregroundServiceType="camera|microphone|specialUse">
|
||||
<property
|
||||
android:name="android.app.PROPERTY_SPECIAL_USE_FGS_SUBTYPE"
|
||||
android:value="AI voice assistant overlay for real-time conversation" />
|
||||
|
|
|
|||
|
|
@ -75,6 +75,7 @@ class AssistantService : LifecycleService(), SavedStateRegistryOwner {
|
|||
val permission = intent.getStringExtra(PermissionRequestActivity.EXTRA_PERMISSION)
|
||||
val granted = intent.getBooleanExtra(PermissionRequestActivity.EXTRA_GRANTED, false)
|
||||
if (permission == Manifest.permission.CAMERA && granted) {
|
||||
startForeground(NOTIFICATION_ID, buildNotification(), computeForegroundTypes())
|
||||
assistantManager.toggleCamera()
|
||||
}
|
||||
}
|
||||
|
|
@ -115,8 +116,7 @@ class AssistantService : LifecycleService(), SavedStateRegistryOwner {
|
|||
startForeground(
|
||||
NOTIFICATION_ID,
|
||||
buildNotification(),
|
||||
ServiceInfo.FOREGROUND_SERVICE_TYPE_MICROPHONE
|
||||
or ServiceInfo.FOREGROUND_SERVICE_TYPE_SPECIAL_USE
|
||||
computeForegroundTypes()
|
||||
)
|
||||
|
||||
// Resolve wsUrl from the main WebSocketClient
|
||||
|
|
@ -152,6 +152,7 @@ class AssistantService : LifecycleService(), SavedStateRegistryOwner {
|
|||
if (ContextCompat.checkSelfPermission(this, Manifest.permission.CAMERA)
|
||||
== PackageManager.PERMISSION_GRANTED
|
||||
) {
|
||||
startForeground(NOTIFICATION_ID, buildNotification(), computeForegroundTypes())
|
||||
assistantManager.toggleCamera()
|
||||
} else {
|
||||
startActivity(PermissionRequestActivity.intent(this, Manifest.permission.CAMERA))
|
||||
|
|
@ -233,6 +234,17 @@ class AssistantService : LifecycleService(), SavedStateRegistryOwner {
|
|||
}
|
||||
}
|
||||
|
||||
private fun computeForegroundTypes(): Int {
|
||||
var types = ServiceInfo.FOREGROUND_SERVICE_TYPE_MICROPHONE or
|
||||
ServiceInfo.FOREGROUND_SERVICE_TYPE_SPECIAL_USE
|
||||
if (ContextCompat.checkSelfPermission(this, Manifest.permission.CAMERA)
|
||||
== PackageManager.PERMISSION_GRANTED
|
||||
) {
|
||||
types = types or ServiceInfo.FOREGROUND_SERVICE_TYPE_CAMERA
|
||||
}
|
||||
return types
|
||||
}
|
||||
|
||||
private fun buildNotification(): Notification {
|
||||
return NotificationCompat.Builder(this, NotificationHelper.ASSISTANT_CHANNEL_ID)
|
||||
.setSmallIcon(android.R.drawable.ic_btn_speak_now)
|
||||
|
|
|
|||
|
|
@ -1,23 +1,21 @@
|
|||
package io.picoclaw.android.feature.chat.voice
|
||||
|
||||
import android.content.Context
|
||||
import android.graphics.Bitmap
|
||||
import android.util.Log
|
||||
import androidx.camera.core.CameraSelector
|
||||
import androidx.camera.core.ImageCapture
|
||||
import androidx.camera.core.ImageCaptureException
|
||||
import androidx.camera.core.Preview
|
||||
import androidx.camera.lifecycle.ProcessCameraProvider
|
||||
import androidx.camera.view.PreviewView
|
||||
import androidx.core.content.ContextCompat
|
||||
import androidx.lifecycle.LifecycleOwner
|
||||
import io.picoclaw.android.core.domain.model.ImageAttachment
|
||||
import kotlinx.coroutines.suspendCancellableCoroutine
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.withContext
|
||||
import java.io.File
|
||||
import kotlin.coroutines.resume
|
||||
|
||||
class CameraCaptureManager(private val context: Context) {
|
||||
|
||||
private var imageCapture: ImageCapture? = null
|
||||
private var cameraProvider: ProcessCameraProvider? = null
|
||||
private var currentPreviewView: PreviewView? = null
|
||||
|
||||
|
|
@ -39,17 +37,11 @@ class CameraCaptureManager(private val context: Context) {
|
|||
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
|
||||
preview
|
||||
)
|
||||
} catch (e: Exception) {
|
||||
Log.w(TAG, "Failed to bind camera", e)
|
||||
|
|
@ -60,35 +52,26 @@ class CameraCaptureManager(private val context: Context) {
|
|||
fun unbind() {
|
||||
cameraProvider?.unbindAll()
|
||||
cameraProvider = null
|
||||
imageCapture = null
|
||||
currentPreviewView = null
|
||||
}
|
||||
|
||||
suspend fun captureFrame(): ImageAttachment? {
|
||||
val capture = imageCapture ?: return null
|
||||
val bitmap = currentPreviewView?.bitmap ?: return null
|
||||
return try {
|
||||
withContext(Dispatchers.IO) {
|
||||
val imagesDir = File(context.cacheDir, "images").apply { mkdirs() }
|
||||
val file = File(imagesDir, "voice_cam_${System.currentTimeMillis()}.jpg")
|
||||
val outputOptions = ImageCapture.OutputFileOptions.Builder(file).build()
|
||||
|
||||
return suspendCancellableCoroutine { cont ->
|
||||
capture.takePicture(
|
||||
outputOptions,
|
||||
ContextCompat.getMainExecutor(context),
|
||||
object : ImageCapture.OnImageSavedCallback {
|
||||
override fun onImageSaved(output: ImageCapture.OutputFileResults) {
|
||||
file.outputStream().use { bitmap.compress(Bitmap.CompressFormat.JPEG, 80, it) }
|
||||
val uri = androidx.core.content.FileProvider.getUriForFile(
|
||||
context,
|
||||
"${context.packageName}.fileprovider",
|
||||
file
|
||||
)
|
||||
cont.resume(ImageAttachment(uri = uri.toString()))
|
||||
ImageAttachment(uri = uri.toString())
|
||||
}
|
||||
|
||||
override fun onError(exception: ImageCaptureException) {
|
||||
cont.resume(null)
|
||||
}
|
||||
}
|
||||
)
|
||||
} catch (e: Exception) {
|
||||
Log.w(TAG, "Failed to capture frame", e)
|
||||
null
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
package io.picoclaw.android.feature.chat.voice
|
||||
|
||||
import androidx.camera.view.PreviewView
|
||||
import androidx.camera.view.PreviewView.ImplementationMode
|
||||
import androidx.compose.animation.AnimatedVisibility
|
||||
import androidx.compose.animation.fadeIn
|
||||
import androidx.compose.animation.fadeOut
|
||||
|
|
@ -177,8 +178,9 @@ fun VoiceModeOverlay(
|
|||
) {
|
||||
AndroidView(
|
||||
factory = { ctx ->
|
||||
PreviewView(ctx).also { preview ->
|
||||
cameraCaptureManager.bind(lifecycleOwner, preview)
|
||||
PreviewView(ctx).apply {
|
||||
implementationMode = ImplementationMode.COMPATIBLE
|
||||
cameraCaptureManager.bind(lifecycleOwner, this)
|
||||
}
|
||||
},
|
||||
modifier = Modifier.fillMaxSize()
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue