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.POST_NOTIFICATIONS" />
|
||||||
<uses-permission android:name="android.permission.SYSTEM_ALERT_WINDOW" />
|
<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" />
|
||||||
|
<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_MICROPHONE" />
|
||||||
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_SPECIAL_USE" />
|
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_SPECIAL_USE" />
|
||||||
|
|
||||||
|
|
@ -76,7 +77,7 @@
|
||||||
<service
|
<service
|
||||||
android:name=".assistant.AssistantService"
|
android:name=".assistant.AssistantService"
|
||||||
android:exported="false"
|
android:exported="false"
|
||||||
android:foregroundServiceType="microphone|specialUse">
|
android:foregroundServiceType="camera|microphone|specialUse">
|
||||||
<property
|
<property
|
||||||
android:name="android.app.PROPERTY_SPECIAL_USE_FGS_SUBTYPE"
|
android:name="android.app.PROPERTY_SPECIAL_USE_FGS_SUBTYPE"
|
||||||
android:value="AI voice assistant overlay for real-time conversation" />
|
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 permission = intent.getStringExtra(PermissionRequestActivity.EXTRA_PERMISSION)
|
||||||
val granted = intent.getBooleanExtra(PermissionRequestActivity.EXTRA_GRANTED, false)
|
val granted = intent.getBooleanExtra(PermissionRequestActivity.EXTRA_GRANTED, false)
|
||||||
if (permission == Manifest.permission.CAMERA && granted) {
|
if (permission == Manifest.permission.CAMERA && granted) {
|
||||||
|
startForeground(NOTIFICATION_ID, buildNotification(), computeForegroundTypes())
|
||||||
assistantManager.toggleCamera()
|
assistantManager.toggleCamera()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -115,8 +116,7 @@ class AssistantService : LifecycleService(), SavedStateRegistryOwner {
|
||||||
startForeground(
|
startForeground(
|
||||||
NOTIFICATION_ID,
|
NOTIFICATION_ID,
|
||||||
buildNotification(),
|
buildNotification(),
|
||||||
ServiceInfo.FOREGROUND_SERVICE_TYPE_MICROPHONE
|
computeForegroundTypes()
|
||||||
or ServiceInfo.FOREGROUND_SERVICE_TYPE_SPECIAL_USE
|
|
||||||
)
|
)
|
||||||
|
|
||||||
// Resolve wsUrl from the main WebSocketClient
|
// Resolve wsUrl from the main WebSocketClient
|
||||||
|
|
@ -152,6 +152,7 @@ class AssistantService : LifecycleService(), SavedStateRegistryOwner {
|
||||||
if (ContextCompat.checkSelfPermission(this, Manifest.permission.CAMERA)
|
if (ContextCompat.checkSelfPermission(this, Manifest.permission.CAMERA)
|
||||||
== PackageManager.PERMISSION_GRANTED
|
== PackageManager.PERMISSION_GRANTED
|
||||||
) {
|
) {
|
||||||
|
startForeground(NOTIFICATION_ID, buildNotification(), computeForegroundTypes())
|
||||||
assistantManager.toggleCamera()
|
assistantManager.toggleCamera()
|
||||||
} else {
|
} else {
|
||||||
startActivity(PermissionRequestActivity.intent(this, Manifest.permission.CAMERA))
|
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 {
|
private fun buildNotification(): Notification {
|
||||||
return NotificationCompat.Builder(this, NotificationHelper.ASSISTANT_CHANNEL_ID)
|
return NotificationCompat.Builder(this, NotificationHelper.ASSISTANT_CHANNEL_ID)
|
||||||
.setSmallIcon(android.R.drawable.ic_btn_speak_now)
|
.setSmallIcon(android.R.drawable.ic_btn_speak_now)
|
||||||
|
|
|
||||||
|
|
@ -1,23 +1,21 @@
|
||||||
package io.picoclaw.android.feature.chat.voice
|
package io.picoclaw.android.feature.chat.voice
|
||||||
|
|
||||||
import android.content.Context
|
import android.content.Context
|
||||||
|
import android.graphics.Bitmap
|
||||||
import android.util.Log
|
import android.util.Log
|
||||||
import androidx.camera.core.CameraSelector
|
import androidx.camera.core.CameraSelector
|
||||||
import androidx.camera.core.ImageCapture
|
|
||||||
import androidx.camera.core.ImageCaptureException
|
|
||||||
import androidx.camera.core.Preview
|
import androidx.camera.core.Preview
|
||||||
import androidx.camera.lifecycle.ProcessCameraProvider
|
import androidx.camera.lifecycle.ProcessCameraProvider
|
||||||
import androidx.camera.view.PreviewView
|
import androidx.camera.view.PreviewView
|
||||||
import androidx.core.content.ContextCompat
|
import androidx.core.content.ContextCompat
|
||||||
import androidx.lifecycle.LifecycleOwner
|
import androidx.lifecycle.LifecycleOwner
|
||||||
import io.picoclaw.android.core.domain.model.ImageAttachment
|
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 java.io.File
|
||||||
import kotlin.coroutines.resume
|
|
||||||
|
|
||||||
class CameraCaptureManager(private val context: Context) {
|
class CameraCaptureManager(private val context: Context) {
|
||||||
|
|
||||||
private var imageCapture: ImageCapture? = null
|
|
||||||
private var cameraProvider: ProcessCameraProvider? = null
|
private var cameraProvider: ProcessCameraProvider? = null
|
||||||
private var currentPreviewView: PreviewView? = null
|
private var currentPreviewView: PreviewView? = null
|
||||||
|
|
||||||
|
|
@ -39,17 +37,11 @@ class CameraCaptureManager(private val context: Context) {
|
||||||
it.surfaceProvider = previewView.surfaceProvider
|
it.surfaceProvider = previewView.surfaceProvider
|
||||||
}
|
}
|
||||||
|
|
||||||
val capture = ImageCapture.Builder()
|
|
||||||
.setCaptureMode(ImageCapture.CAPTURE_MODE_MINIMIZE_LATENCY)
|
|
||||||
.build()
|
|
||||||
imageCapture = capture
|
|
||||||
|
|
||||||
provider.unbindAll()
|
provider.unbindAll()
|
||||||
provider.bindToLifecycle(
|
provider.bindToLifecycle(
|
||||||
lifecycleOwner,
|
lifecycleOwner,
|
||||||
CameraSelector.DEFAULT_BACK_CAMERA,
|
CameraSelector.DEFAULT_BACK_CAMERA,
|
||||||
preview,
|
preview
|
||||||
capture
|
|
||||||
)
|
)
|
||||||
} catch (e: Exception) {
|
} catch (e: Exception) {
|
||||||
Log.w(TAG, "Failed to bind camera", e)
|
Log.w(TAG, "Failed to bind camera", e)
|
||||||
|
|
@ -60,35 +52,26 @@ class CameraCaptureManager(private val context: Context) {
|
||||||
fun unbind() {
|
fun unbind() {
|
||||||
cameraProvider?.unbindAll()
|
cameraProvider?.unbindAll()
|
||||||
cameraProvider = null
|
cameraProvider = null
|
||||||
imageCapture = null
|
|
||||||
currentPreviewView = null
|
currentPreviewView = null
|
||||||
}
|
}
|
||||||
|
|
||||||
suspend fun captureFrame(): ImageAttachment? {
|
suspend fun captureFrame(): ImageAttachment? {
|
||||||
val capture = imageCapture ?: return null
|
val bitmap = currentPreviewView?.bitmap ?: return null
|
||||||
val imagesDir = File(context.cacheDir, "images").apply { mkdirs() }
|
return try {
|
||||||
val file = File(imagesDir, "voice_cam_${System.currentTimeMillis()}.jpg")
|
withContext(Dispatchers.IO) {
|
||||||
val outputOptions = ImageCapture.OutputFileOptions.Builder(file).build()
|
val imagesDir = File(context.cacheDir, "images").apply { mkdirs() }
|
||||||
|
val file = File(imagesDir, "voice_cam_${System.currentTimeMillis()}.jpg")
|
||||||
return suspendCancellableCoroutine { cont ->
|
file.outputStream().use { bitmap.compress(Bitmap.CompressFormat.JPEG, 80, it) }
|
||||||
capture.takePicture(
|
val uri = androidx.core.content.FileProvider.getUriForFile(
|
||||||
outputOptions,
|
context,
|
||||||
ContextCompat.getMainExecutor(context),
|
"${context.packageName}.fileprovider",
|
||||||
object : ImageCapture.OnImageSavedCallback {
|
file
|
||||||
override fun onImageSaved(output: ImageCapture.OutputFileResults) {
|
)
|
||||||
val uri = androidx.core.content.FileProvider.getUriForFile(
|
ImageAttachment(uri = uri.toString())
|
||||||
context,
|
}
|
||||||
"${context.packageName}.fileprovider",
|
} catch (e: Exception) {
|
||||||
file
|
Log.w(TAG, "Failed to capture frame", e)
|
||||||
)
|
null
|
||||||
cont.resume(ImageAttachment(uri = uri.toString()))
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun onError(exception: ImageCaptureException) {
|
|
||||||
cont.resume(null)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,7 @@
|
||||||
package io.picoclaw.android.feature.chat.voice
|
package io.picoclaw.android.feature.chat.voice
|
||||||
|
|
||||||
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.fadeIn
|
import androidx.compose.animation.fadeIn
|
||||||
import androidx.compose.animation.fadeOut
|
import androidx.compose.animation.fadeOut
|
||||||
|
|
@ -177,8 +178,9 @@ fun VoiceModeOverlay(
|
||||||
) {
|
) {
|
||||||
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()
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue