#5 AccessibilityProvisioner: авто-включение VmAccessibilityService на очках при коннекте (по модели ShizukuProvisioner)

This commit is contained in:
2026-09-27 11:22:04 +03:00
parent 65f3f5d228
commit ad4e458f5c
2 changed files with 172 additions and 2 deletions
@@ -9,6 +9,7 @@ import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.collect
import kotlinx.coroutines.launch
import pw.binom.rayneovm.phone.adb.AdbController
import pw.binom.rayneovm.phone.bootstrap.AccessibilityProvisioner
import pw.binom.rayneovm.phone.bootstrap.ShizukuProvisioner
import pw.binom.rayneovm.phone.mercury.MercuryController
import pw.binom.rayneovm.phone.mercury.MercuryControllerProvider
@@ -24,7 +25,7 @@ import pw.binom.rayneovm.phone.protocol.VmPhoneController
* и [MercuryController] (второй канал — RayNeo Mercury BLE).
*/
class PhoneVmApp : Application(), ControllerProvider, MercuryControllerProvider,
ShizukuProvisionerProvider, AdbControllerProvider {
ShizukuProvisionerProvider, AdbControllerProvider, AccessibilityProvisionerProvider {
private val appScope = CoroutineScope(SupervisorJob() + Dispatchers.Default)
@@ -34,6 +35,9 @@ class PhoneVmApp : Application(), ControllerProvider, MercuryControllerProvider,
private val provisioner: ShizukuProvisioner by lazy {
ShizukuProvisioner(this, mercuryController, controller, adbController)
}
private val accessibilityProvisioner: AccessibilityProvisioner by lazy {
AccessibilityProvisioner(controller, adbController)
}
override fun onCreate() {
super.onCreate()
@@ -77,7 +81,28 @@ class PhoneVmApp : Application(), ControllerProvider, MercuryControllerProvider,
// Разрыв BLE — новая сессия: при следующем подключении проверяем Shizuku заново.
appScope.launch {
mercuryController.state.collect { st ->
if (st !is MercuryState.Connected) provisioner.onBleDisconnected()
if (st !is MercuryState.Connected) {
provisioner.onBleDisconnected()
accessibilityProvisioner.onBleDisconnected()
}
}
}
// Accessibility — отдельный provisioner, гоняется по тому же расписанию, что и Shizuku.
appScope.launch {
controller.state.collect { st ->
if (st is ConnectionState.Connected && mercuryController.state.value is MercuryState.Connected) {
Log.i(TAG, "WiFi подключение к очкам — проверяю Accessibility")
accessibilityProvisioner.ensure()
}
}
}
appScope.launch {
while (true) {
delay(PROVISION_RETRY_MS)
if (mercuryController.state.value is MercuryState.Connected) {
accessibilityProvisioner.ensure()
}
}
}
}
@@ -86,6 +111,7 @@ class PhoneVmApp : Application(), ControllerProvider, MercuryControllerProvider,
super.onTerminate()
ConnectionKeepAliveService.stop(this)
provisioner.close()
accessibilityProvisioner.close()
controller.stop()
mercuryController.close()
}
@@ -94,6 +120,7 @@ class PhoneVmApp : Application(), ControllerProvider, MercuryControllerProvider,
override fun provideMercuryController(): MercuryController = mercuryController
override fun provideShizukuProvisioner(): ShizukuProvisioner = provisioner
override fun provideAdbController(): AdbController = adbController
override fun provideAccessibilityProvisioner(): AccessibilityProvisioner = accessibilityProvisioner
companion object {
private const val TAG = "PhoneVmApp"
@@ -106,6 +133,11 @@ interface ShizukuProvisionerProvider {
fun provideShizukuProvisioner(): ShizukuProvisioner
}
/** Провайдер автовключения Accessibility-сервиса на очках для UI. */
interface AccessibilityProvisionerProvider {
fun provideAccessibilityProvisioner(): AccessibilityProvisioner
}
/** Провайдер adb-контроллера (единая точка входа для adb-команд) для UI. */
interface AdbControllerProvider {
fun provideAdbController(): AdbController
@@ -0,0 +1,138 @@
package pw.binom.rayneovm.phone.bootstrap
import android.util.Log
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.launch
import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock
import pw.binom.rayneovm.phone.adb.AdbController
import pw.binom.rayneovm.phone.protocol.VmPhoneController
/**
* Гарантирует, что VmAccessibilityService на очках включён.
*
* Используется ровно тот же путь, что и [ShizukuProvisioner] (через
* [AdbController] → беспроводной adb → `settings put secure`). Если у
* `AdbController` нет соединения, мы НЕ поднимаем adb силой — это работа
* бутстрапа Shizuku; accessibility всё равно будет включен при следующем
* успешном коннекте.
*
* Вызывается на каждый BLE-коннект (см. [VmPhoneController] и
* `ShizukuProvisioner.ensure()`). Так ловим случаи `am force-stop` /
* обновления APK, которые сбрасывают accessibility-флаг.
*/
class AccessibilityProvisioner(
private val controller: VmPhoneController,
private val adb: AdbController,
) {
private val scope = CoroutineScope(SupervisorJob() + Dispatchers.IO)
private val mutex = Mutex()
private val _state = MutableStateFlow<AccessibilityState>(AccessibilityState.Idle)
val state: StateFlow<AccessibilityState> = _state.asStateFlow()
/**
* Проверить, что VmAccessibilityService есть в `enabled_accessibility_services`.
* Если нет — добавить. Один раз за BLE-сессию.
*/
fun ensure() {
Log.i(TAG, "ensure(): состояние=${_state.value}")
if (mutex.isLocked) {
Log.i(TAG, "ensure(): уже идёт проверка")
return
}
scope.launch {
mutex.withLock { ensureLocked() }
}
}
/**
* BLE-сессия разорвана — при следующем коннекте снова проверим.
* (См. проверенное: `am force-stop` снимает accessibility-флаг,
* поэтому перепроверяем каждый раз.)
*/
fun onBleDisconnected() {
_state.value = AccessibilityState.Idle
}
private suspend fun ensureLocked() {
if (!adb.available) {
// Без adb мы ничего не сделаем — но состояние не сбрасываем, чтобы UI не моргал.
Log.i(TAG, "ensureLocked(): adb недоступен, пропуск")
return
}
// Шарим соединение с ShizukuProvisioner (если оно поднято).
val serial = adb.ensureConnected() ?: run {
Log.i(TAG, "ensureLocked(): нет adb-соединения")
return
}
Log.i(TAG, "ensureLocked(): adb=$serial, проверяю accessibility")
val current = readEnabledServices().getOrDefault("")
Log.i(TAG, "ensureLocked(): enabled_accessibility_services='$current'")
if (current.contains(SERVICE_NAME)) {
_state.value = AccessibilityState.Enabled
return
}
_state.value = AccessibilityState.Enabling
val append = if (current.isBlank()) SERVICE_FQDN else "$current:$SERVICE_FQDN"
val put = adb.shell(
"settings put secure enabled_accessibility_services '$append'",
timeoutMs = 15_000,
)
val enable = adb.shell(
"settings put secure accessibility_enabled 1",
timeoutMs = 15_000,
)
if (put.isSuccess && enable.isSuccess) {
Log.i(TAG, "ensureLocked(): accessibility включён")
_state.value = AccessibilityState.Enabled
} else {
val msg = "settings put failed: put=${put.isSuccess}, enable=${enable.isSuccess}"
Log.w(TAG, "ensureLocked(): $msg")
_state.value = AccessibilityState.Failed(msg)
}
}
private suspend fun readEnabledServices(): Result<String> = runCatching {
val out = adb.shell("settings get secure enabled_accessibility_services", timeoutMs = 15_000)
.getOrElse { error("shell failed: ${it.message}") }
// Если значение равно «null» — это дефолтное состояние, без включённых сервисов.
out.trim().takeIf { it.isNotEmpty() && it != "null" } ?: ""
}
fun close() {
scope.coroutineContext[kotlinx.coroutines.Job]?.cancel()
}
private companion object {
const val TAG = "AccessibilityProvisioner"
const val GLASSES_PACKAGE = "pw.binom.rayneovm.glasses"
const val SERVICE_NAME = "VmAccessibilityService"
// settings put ожидает формат ComponentName через package/class (НЕ .FQCN, а /FQCN).
const val SERVICE_FQDN = "$GLASSES_PACKAGE/$GLASSES_PACKAGE.$SERVICE_NAME"
}
}
/** Состояние автовключения accessibility на очках (для UI). */
sealed interface AccessibilityState {
data object Idle : AccessibilityState
data object Enabling : AccessibilityState
data object Enabled : AccessibilityState
data class Failed(val message: String) : AccessibilityState
val label: String
get() = when (this) {
Idle -> "не проверялся"
Enabling -> "включаем…"
Enabled -> "включён"
is Failed -> "ошибка: $message"
}
}