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

View file

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

View file

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

View file

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