feat: switch screen capture to AccessibilityService with clean architecture

Replace MediaProjection-based screen capture with AccessibilityService.takeScreenshot()
(API 30+) to eliminate per-session consent dialogs. Extract ScreenshotSource interface
in feature/chat module with implementation in app module via Koin DI, maintaining
unidirectional dependency flow.

- Raise minSdk 29 → 30
- Add ScreenshotSource interface and AccessibilityScreenshotSource implementation
- Add PicoClawAccessibilityService for screenshot capability
- Remove MediaProjection code (ScreenCaptureConsentActivity, broadcast receivers)
- Add screen capture toggle with camera mutual exclusion

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Kohei 2026-02-22 02:09:24 +09:00
parent e3131cfe1a
commit c91a09f0c1
13 changed files with 251 additions and 6 deletions

View file

@ -82,6 +82,17 @@
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" />
</service> </service>
<service
android:name=".assistant.PicoClawAccessibilityService"
android:exported="false"
android:permission="android.permission.BIND_ACCESSIBILITY_SERVICE">
<intent-filter>
<action android:name="android.accessibilityservice.AccessibilityService" />
</intent-filter>
<meta-data
android:name="android.accessibilityservice"
android:resource="@xml/accessibility_service_config" />
</service>
</application> </application>
</manifest> </manifest>

View file

@ -0,0 +1,55 @@
package io.picoclaw.android.assistant
import android.accessibilityservice.AccessibilityService
import android.graphics.Bitmap
import android.util.Log
import android.view.Display
import io.picoclaw.android.feature.chat.voice.ScreenshotSource
import kotlinx.coroutines.suspendCancellableCoroutine
import kotlin.coroutines.resume
class AccessibilityScreenshotSource : ScreenshotSource {
@Volatile
private var service: AccessibilityService? = null
override val isAvailable: Boolean get() = service != null
fun setService(s: AccessibilityService) {
service = s
}
fun clearService() {
service = null
}
override suspend fun takeScreenshot(): Bitmap? {
val svc = service ?: return null
return suspendCancellableCoroutine { cont ->
svc.takeScreenshot(
Display.DEFAULT_DISPLAY,
svc.mainExecutor,
object : AccessibilityService.TakeScreenshotCallback {
override fun onSuccess(result: AccessibilityService.ScreenshotResult) {
val hwBitmap = Bitmap.wrapHardwareBuffer(
result.hardwareBuffer, result.colorSpace
)
result.hardwareBuffer.close()
val swBitmap = hwBitmap?.copy(Bitmap.Config.ARGB_8888, false)
hwBitmap?.recycle()
cont.resume(swBitmap)
}
override fun onFailure(errorCode: Int) {
Log.w(TAG, "takeScreenshot failed with errorCode=$errorCode")
cont.resume(null)
}
}
)
}
}
companion object {
private const val TAG = "A11yScreenshotSource"
}
}

View file

@ -10,6 +10,7 @@ import android.content.pm.PackageManager
import android.content.pm.ServiceInfo import android.content.pm.ServiceInfo
import android.graphics.PixelFormat import android.graphics.PixelFormat
import android.os.IBinder import android.os.IBinder
import android.provider.Settings
import android.view.Gravity import android.view.Gravity
import android.view.MotionEvent import android.view.MotionEvent
import android.view.View import android.view.View
@ -42,6 +43,8 @@ import io.picoclaw.android.core.ui.theme.PicoClawTheme
import io.picoclaw.android.feature.chat.assistant.AssistantManager import io.picoclaw.android.feature.chat.assistant.AssistantManager
import io.picoclaw.android.feature.chat.assistant.AssistantPillBar import io.picoclaw.android.feature.chat.assistant.AssistantPillBar
import io.picoclaw.android.feature.chat.voice.CameraCaptureManager import io.picoclaw.android.feature.chat.voice.CameraCaptureManager
import io.picoclaw.android.feature.chat.voice.ScreenCaptureManager
import io.picoclaw.android.feature.chat.voice.ScreenshotSource
import io.picoclaw.android.feature.chat.voice.SpeechRecognizerWrapper import io.picoclaw.android.feature.chat.voice.SpeechRecognizerWrapper
import io.picoclaw.android.feature.chat.voice.TextToSpeechWrapper import io.picoclaw.android.feature.chat.voice.TextToSpeechWrapper
import io.picoclaw.android.receiver.NotificationHelper import io.picoclaw.android.receiver.NotificationHelper
@ -55,6 +58,7 @@ class AssistantService : LifecycleService(), SavedStateRegistryOwner {
private val httpClient: HttpClient by inject() private val httpClient: HttpClient by inject()
private val ttsSettingsRepo: TtsSettingsRepository by inject() private val ttsSettingsRepo: TtsSettingsRepository by inject()
private val screenshotSource: ScreenshotSource by inject()
private lateinit var serviceScope: CoroutineScope private lateinit var serviceScope: CoroutineScope
private lateinit var connection: AssistantConnection private lateinit var connection: AssistantConnection
@ -62,6 +66,7 @@ class AssistantService : LifecycleService(), SavedStateRegistryOwner {
private lateinit var ttsWrapper: TextToSpeechWrapper private lateinit var ttsWrapper: TextToSpeechWrapper
private lateinit var sttWrapper: SpeechRecognizerWrapper private lateinit var sttWrapper: SpeechRecognizerWrapper
private lateinit var cameraCaptureManager: CameraCaptureManager private lateinit var cameraCaptureManager: CameraCaptureManager
private lateinit var screenCaptureManager: ScreenCaptureManager
private var overlayView: View? = null private var overlayView: View? = null
private val windowManager by lazy { getSystemService(WINDOW_SERVICE) as WindowManager } private val windowManager by lazy { getSystemService(WINDOW_SERVICE) as WindowManager }
@ -93,12 +98,16 @@ class AssistantService : LifecycleService(), SavedStateRegistryOwner {
sttWrapper = SpeechRecognizerWrapper(this) sttWrapper = SpeechRecognizerWrapper(this)
ttsWrapper = TextToSpeechWrapper(this, ttsSettingsRepo.ttsConfig) ttsWrapper = TextToSpeechWrapper(this, ttsSettingsRepo.ttsConfig)
cameraCaptureManager = CameraCaptureManager(this) cameraCaptureManager = CameraCaptureManager(this)
screenCaptureManager = ScreenCaptureManager(screenshotSource, applicationContext) { visible ->
overlayView?.visibility = if (visible) View.VISIBLE else View.INVISIBLE
}
assistantManager = AssistantManager( assistantManager = AssistantManager(
sttWrapper = sttWrapper, sttWrapper = sttWrapper,
ttsWrapper = ttsWrapper, ttsWrapper = ttsWrapper,
connection = connection, connection = connection,
cameraCaptureManager = cameraCaptureManager, cameraCaptureManager = cameraCaptureManager,
screenCaptureManager = screenCaptureManager,
contentResolver = contentResolver contentResolver = contentResolver
) )
@ -159,6 +168,25 @@ class AssistantService : LifecycleService(), SavedStateRegistryOwner {
} }
} }
private fun handleScreenCaptureToggle() {
if (assistantManager.state.value.isScreenCaptureActive) {
assistantManager.toggleScreenCapture()
return
}
if (screenCaptureManager.isAvailable) {
// Turn off camera first if active
if (assistantManager.state.value.isCameraActive) {
assistantManager.toggleCamera()
}
assistantManager.toggleScreenCapture()
} else {
// Open accessibility settings so the user can enable the service
val intent = Intent(Settings.ACTION_ACCESSIBILITY_SETTINGS)
.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
startActivity(intent)
}
}
private fun shutdown() { private fun shutdown() {
stopForeground(STOP_FOREGROUND_REMOVE) stopForeground(STOP_FOREGROUND_REMOVE)
stopSelf() stopSelf()
@ -209,6 +237,7 @@ class AssistantService : LifecycleService(), SavedStateRegistryOwner {
onClose = { shutdown() }, onClose = { shutdown() },
onInterrupt = { assistantManager.interrupt() }, onInterrupt = { assistantManager.interrupt() },
onCameraToggle = { handleCameraToggle() }, onCameraToggle = { handleCameraToggle() },
onScreenCaptureToggle = { handleScreenCaptureToggle() },
cameraCaptureManager = cameraCaptureManager, cameraCaptureManager = cameraCaptureManager,
modifier = Modifier.onGloballyPositioned { coordinates -> modifier = Modifier.onGloballyPositioned { coordinates ->
wrapper.contentTop = coordinates.positionInWindow().y.toInt() wrapper.contentTop = coordinates.positionInWindow().y.toInt()

View file

@ -0,0 +1,28 @@
package io.picoclaw.android.assistant
import android.accessibilityservice.AccessibilityService
import android.view.accessibility.AccessibilityEvent
import org.koin.android.ext.android.inject
class PicoClawAccessibilityService : AccessibilityService() {
private val screenshotSource: AccessibilityScreenshotSource by inject()
override fun onServiceConnected() {
super.onServiceConnected()
screenshotSource.setService(this)
}
override fun onDestroy() {
screenshotSource.clearService()
super.onDestroy()
}
override fun onAccessibilityEvent(event: AccessibilityEvent?) {
// No-op: used only for screenshot capture
}
override fun onInterrupt() {
// No-op
}
}

View file

@ -5,7 +5,9 @@ import io.ktor.client.HttpClient
import io.ktor.client.engine.okhttp.OkHttp import io.ktor.client.engine.okhttp.OkHttp
import io.ktor.client.plugins.websocket.WebSockets import io.ktor.client.plugins.websocket.WebSockets
import io.picoclaw.android.core.data.local.AppDatabase import io.picoclaw.android.core.data.local.AppDatabase
import io.picoclaw.android.assistant.AccessibilityScreenshotSource
import io.picoclaw.android.core.data.local.ImageFileStorage import io.picoclaw.android.core.data.local.ImageFileStorage
import io.picoclaw.android.feature.chat.voice.ScreenshotSource
import io.picoclaw.android.core.data.remote.WebSocketClient import io.picoclaw.android.core.data.remote.WebSocketClient
import io.picoclaw.android.core.data.repository.ChatRepositoryImpl import io.picoclaw.android.core.data.repository.ChatRepositoryImpl
import io.picoclaw.android.core.data.repository.TtsCatalogRepositoryImpl import io.picoclaw.android.core.data.repository.TtsCatalogRepositoryImpl
@ -90,6 +92,10 @@ val appModule = module {
factory { ConnectChatUseCase(get()) } factory { ConnectChatUseCase(get()) }
factory { DisconnectChatUseCase(get()) } factory { DisconnectChatUseCase(get()) }
// Screenshot
single { AccessibilityScreenshotSource() }
single<ScreenshotSource> { get<AccessibilityScreenshotSource>() }
// Voice // Voice
factory { SpeechRecognizerWrapper(androidContext()) } factory { SpeechRecognizerWrapper(androidContext()) }
single { TextToSpeechWrapper(androidContext(), get<TtsSettingsRepository>().ttsConfig) } single { TextToSpeechWrapper(androidContext(), get<TtsSettingsRepository>().ttsConfig) }

View file

@ -1,3 +1,4 @@
<resources> <resources>
<string name="app_name">PicoClaw</string> <string name="app_name">PicoClaw</string>
<string name="accessibility_service_description">Allows PicoClaw to capture screenshots during voice assistant conversations.</string>
</resources> </resources>

View file

@ -0,0 +1,5 @@
<?xml version="1.0" encoding="utf-8"?>
<accessibility-service xmlns:android="http://schemas.android.com/apk/res/android"
android:canTakeScreenshot="true"
android:canRetrieveWindowContent="false"
android:description="@string/accessibility_service_description" />

View file

@ -8,6 +8,7 @@ import io.picoclaw.android.core.domain.model.AssistantMessage
import io.picoclaw.android.core.domain.model.VoicePhase import io.picoclaw.android.core.domain.model.VoicePhase
import io.picoclaw.android.core.domain.repository.AssistantConnection import io.picoclaw.android.core.domain.repository.AssistantConnection
import io.picoclaw.android.feature.chat.voice.CameraCaptureManager import io.picoclaw.android.feature.chat.voice.CameraCaptureManager
import io.picoclaw.android.feature.chat.voice.ScreenCaptureManager
import io.picoclaw.android.feature.chat.voice.SpeechRecognizerWrapper import io.picoclaw.android.feature.chat.voice.SpeechRecognizerWrapper
import io.picoclaw.android.feature.chat.voice.SttResult import io.picoclaw.android.feature.chat.voice.SttResult
import io.picoclaw.android.feature.chat.voice.TextToSpeechWrapper import io.picoclaw.android.feature.chat.voice.TextToSpeechWrapper
@ -34,6 +35,7 @@ class AssistantManager(
private val ttsWrapper: TextToSpeechWrapper, private val ttsWrapper: TextToSpeechWrapper,
private val connection: AssistantConnection, private val connection: AssistantConnection,
private val cameraCaptureManager: CameraCaptureManager, private val cameraCaptureManager: CameraCaptureManager,
private val screenCaptureManager: ScreenCaptureManager,
private val contentResolver: ContentResolver private val contentResolver: ContentResolver
) { ) {
@ -44,14 +46,33 @@ class AssistantManager(
private var parentScope: CoroutineScope? = null private var parentScope: CoroutineScope? = null
fun toggleCamera() { fun toggleCamera() {
_state.update { it.copy(isCameraActive = !it.isCameraActive) } _state.update {
it.copy(
isCameraActive = !it.isCameraActive,
isScreenCaptureActive = false
)
}
}
fun toggleScreenCapture() {
if (_state.value.isScreenCaptureActive) {
_state.update { it.copy(isScreenCaptureActive = false) }
} else {
_state.update {
it.copy(
isScreenCaptureActive = true,
isCameraActive = false
)
}
cameraCaptureManager.unbind()
}
} }
fun start(scope: CoroutineScope) { fun start(scope: CoroutineScope) {
if (loopJob?.isActive == true) return if (loopJob?.isActive == true) return
parentScope = scope parentScope = scope
_state.update { _state.update {
VoiceModeState(isActive = true, phase = VoicePhase.LISTENING, isCameraActive = it.isCameraActive, chatHistory = it.chatHistory) VoiceModeState(isActive = true, phase = VoicePhase.LISTENING, isCameraActive = it.isCameraActive, isScreenCaptureActive = it.isScreenCaptureActive, chatHistory = it.chatHistory)
} }
loopJob = scope.launch { loopJob = scope.launch {
voiceLoop() voiceLoop()
@ -76,7 +97,7 @@ class AssistantManager(
loopJob = null loopJob = null
_state.update { _state.update {
VoiceModeState(isActive = true, phase = VoicePhase.LISTENING, isCameraActive = it.isCameraActive, chatHistory = it.chatHistory) VoiceModeState(isActive = true, phase = VoicePhase.LISTENING, isCameraActive = it.isCameraActive, isScreenCaptureActive = it.isScreenCaptureActive, chatHistory = it.chatHistory)
} }
loopJob = scope.launch { voiceLoop() } loopJob = scope.launch { voiceLoop() }
} }
@ -126,7 +147,7 @@ class AssistantManager(
if (!text.isNullOrBlank()) { if (!text.isNullOrBlank()) {
_state.update { it.copy(phase = VoicePhase.SENDING, recognizedText = text, chatHistory = it.chatHistory + ChatTurn("user", text)) } _state.update { it.copy(phase = VoicePhase.SENDING, recognizedText = text, chatHistory = it.chatHistory + ChatTurn("user", text)) }
try { try {
val base64Images = if (_state.value.isCameraActive) { val base64Images = if (_state.value.isCameraActive || _state.value.isScreenCaptureActive) {
captureAndEncode() captureAndEncode()
} else emptyList() } else emptyList()
connection.send(text, base64Images) connection.send(text, base64Images)
@ -265,7 +286,11 @@ class AssistantManager(
} }
private suspend fun captureAndEncode(): List<String> { private suspend fun captureAndEncode(): List<String> {
val attachment = cameraCaptureManager.captureFrame() ?: return emptyList() val attachment = when {
_state.value.isScreenCaptureActive -> screenCaptureManager.captureFrame()
_state.value.isCameraActive -> cameraCaptureManager.captureFrame()
else -> null
} ?: return emptyList()
val uri = android.net.Uri.parse(attachment.uri) val uri = android.net.Uri.parse(attachment.uri)
return try { return try {
val bytes = contentResolver.openInputStream(uri)?.use { it.readBytes() } val bytes = contentResolver.openInputStream(uri)?.use { it.readBytes() }

View file

@ -76,6 +76,7 @@ fun AssistantPillBar(
onClose: () -> Unit, onClose: () -> Unit,
onInterrupt: () -> Unit, onInterrupt: () -> Unit,
onCameraToggle: () -> Unit, onCameraToggle: () -> Unit,
onScreenCaptureToggle: () -> Unit,
cameraCaptureManager: CameraCaptureManager, cameraCaptureManager: CameraCaptureManager,
modifier: Modifier = Modifier modifier: Modifier = Modifier
) { ) {
@ -253,6 +254,22 @@ fun AssistantPillBar(
Spacer(modifier = Modifier.width(8.dp)) Spacer(modifier = Modifier.width(8.dp))
// Screen capture toggle
IconButton(
onClick = onScreenCaptureToggle,
modifier = Modifier.size(36.dp)
) {
Icon(
painter = painterResource(
if (state.isScreenCaptureActive) LucideR.drawable.lucide_ic_monitor_off
else LucideR.drawable.lucide_ic_monitor
),
contentDescription = if (state.isScreenCaptureActive) "Turn off screen capture" else "Turn on screen capture",
modifier = Modifier.size(18.dp),
tint = if (state.isScreenCaptureActive) GradientCyan else TextSecondary
)
}
// Camera toggle // Camera toggle
IconButton( IconButton(
onClick = onCameraToggle, onClick = onCameraToggle,

View file

@ -0,0 +1,59 @@
package io.picoclaw.android.feature.chat.voice
import android.content.Context
import android.graphics.Bitmap
import android.util.Log
import io.picoclaw.android.core.domain.model.ImageAttachment
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.delay
import kotlinx.coroutines.withContext
import java.io.File
class ScreenCaptureManager(
private val screenshotSource: ScreenshotSource,
private val context: Context,
private val setOverlayVisibility: (Boolean) -> Unit
) {
val isAvailable: Boolean get() = screenshotSource.isAvailable
suspend fun captureFrame(): ImageAttachment? {
if (!screenshotSource.isAvailable) return null
return try {
withContext(Dispatchers.Main) {
setOverlayVisibility(false)
}
delay(150)
val bitmap = screenshotSource.takeScreenshot() ?: return null
withContext(Dispatchers.IO) {
try {
val imagesDir = File(context.cacheDir, "images").apply { mkdirs() }
val file = File(imagesDir, "screen_cap_${System.currentTimeMillis()}.jpg")
file.outputStream().use { bitmap.compress(Bitmap.CompressFormat.JPEG, 80, it) }
bitmap.recycle()
val uri = androidx.core.content.FileProvider.getUriForFile(
context,
"${context.packageName}.fileprovider",
file
)
ImageAttachment(uri = uri.toString())
} catch (e: Exception) {
bitmap.recycle()
throw e
}
}
} catch (e: Exception) {
Log.w(TAG, "Failed to capture screen frame", e)
null
} finally {
withContext(Dispatchers.Main) {
setOverlayVisibility(true)
}
}
}
companion object {
private const val TAG = "ScreenCaptureManager"
}
}

View file

@ -0,0 +1,8 @@
package io.picoclaw.android.feature.chat.voice
import android.graphics.Bitmap
interface ScreenshotSource {
val isAvailable: Boolean
suspend fun takeScreenshot(): Bitmap?
}

View file

@ -13,5 +13,6 @@ data class VoiceModeState(
val errorMessage: String? = null, val errorMessage: String? = null,
val amplitudeNormalized: Float = 0f, val amplitudeNormalized: Float = 0f,
val isCameraActive: Boolean = false, val isCameraActive: Boolean = false,
val isScreenCaptureActive: Boolean = false,
val chatHistory: List<ChatTurn> = emptyList() val chatHistory: List<ChatTurn> = emptyList()
) )

View file

@ -1,6 +1,6 @@
[versions] [versions]
android-compileSdk = "36" android-compileSdk = "36"
android-minSdk = "29" android-minSdk = "30"
android-targetSdk = "36" android-targetSdk = "36"
agp = "9.0.1" agp = "9.0.1"
kotlin = "2.3.0" kotlin = "2.3.0"