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:
Kohei 2026-02-21 23:19:53 +09:00
parent 03e46c3d64
commit 64e7a42f9a
4 changed files with 40 additions and 42 deletions

View file

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

View file

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

View file

@ -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 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) {
val uri = androidx.core.content.FileProvider.getUriForFile(
context,
"${context.packageName}.fileprovider",
file
)
cont.resume(ImageAttachment(uri = uri.toString()))
}
override fun onError(exception: ImageCaptureException) {
cont.resume(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")
file.outputStream().use { bitmap.compress(Bitmap.CompressFormat.JPEG, 80, it) }
val uri = androidx.core.content.FileProvider.getUriForFile(
context,
"${context.packageName}.fileprovider",
file
)
ImageAttachment(uri = uri.toString())
}
} catch (e: Exception) {
Log.w(TAG, "Failed to capture frame", e)
null
}
}

View file

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