diff --git a/android/app/src/main/AndroidManifest.xml b/android/app/src/main/AndroidManifest.xml
index 72952da53..60bb596bf 100644
--- a/android/app/src/main/AndroidManifest.xml
+++ b/android/app/src/main/AndroidManifest.xml
@@ -7,6 +7,7 @@
+
@@ -76,7 +77,7 @@
+ android:foregroundServiceType="camera|microphone|specialUse">
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 1a74f5a0a..18a0cb7c3 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
@@ -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)
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 1a186f95d..861fca74b 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,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
}
}
diff --git a/android/feature/chat/src/main/java/io/picoclaw/android/feature/chat/voice/VoiceModeOverlay.kt b/android/feature/chat/src/main/java/io/picoclaw/android/feature/chat/voice/VoiceModeOverlay.kt
index 8a6930575..0a8a35bda 100644
--- a/android/feature/chat/src/main/java/io/picoclaw/android/feature/chat/voice/VoiceModeOverlay.kt
+++ b/android/feature/chat/src/main/java/io/picoclaw/android/feature/chat/voice/VoiceModeOverlay.kt
@@ -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()