Files
rayneo-vm/app-glasses-vm/src/main/kotlin/pw/binom/rayneovm/glasses/VmService.kt
T

313 lines
13 KiB
Kotlin

package pw.binom.rayneovm.glasses
import android.app.Notification
import android.app.NotificationChannel
import android.app.NotificationManager
import android.app.PendingIntent
import android.content.Context
import android.content.Intent
import android.content.pm.ServiceInfo
import android.net.nsd.NsdManager
import android.net.nsd.NsdServiceInfo
import android.os.Build
import android.os.IBinder
import android.util.Log
import androidx.core.app.NotificationCompat
import androidx.lifecycle.LifecycleService
import androidx.lifecycle.lifecycleScope
import kotlinx.coroutines.launch
import pw.binom.mercury.transport.ServerTransport
import pw.binom.mercury.transport.Transports
import pw.binom.rayneovm.glasses.protocol.VmGlassesController
import pw.binom.rayneovm.glasses.vm.GlassesScreenState
import pw.binom.rayneovm.shared.KeyEvent as VmKeyEvent
import pw.binom.rayneovm.shared.LaunchAppCommand
import pw.binom.rayneovm.shared.Ping
import pw.binom.rayneovm.shared.Pong
import pw.binom.rayneovm.shared.SetAudioMode
import pw.binom.rayneovm.shared.Shutdown
import pw.binom.rayneovm.shared.TouchEvent
import pw.binom.rayneovm.shared.VmMessage
import pw.binom.rayneovm.shared.VmProtocol
import pw.binom.rayneovm.shared.GetAppList
import pw.binom.rayneovm.shared.AppEntry
import pw.binom.rayneovm.shared.AppListResponse
import pw.binom.rayneovm.shared.CursorScroll
import pw.binom.rayneovm.shared.CursorClick
import pw.binom.rayneovm.shared.CursorMove
/**
* Foreground service на стороне очков.
*
* Запускает Ktor WebSocket-сервер (порт [WS_PORT]) и публикует себя через mDNS.
* Принимает подключения от телефона и маршрутизирует сообщения в [VmGlassesController].
*
* Жизненный цикл:
* - Запускается из [GlassesVmApp.onCreate] (стартует сразу при загрузке очков).
* - Продолжает работать после сворачивания Activity.
* - Останавливается из [GlassesVmApp.onTerminate] (или системой при необходимости).
*/
class VmService : LifecycleService() {
private var server: ServerTransport<String>? = null
private var nsdManager: NsdManager? = null
private val registeredServiceName: String = "RayNeo VM"
private var nsdRegistered: Boolean = false
private lateinit var controller: VmGlassesController
override fun onCreate() {
super.onCreate()
instance = this
controller = VmGlassesController.fromContext(this)
Log.i(TAG, "VmService onCreate")
// Стража поднимаем сразу при старте сервиса, а не в момент запуска гостя: убийца
// (BackgroundAppManager) успевает убить за ~2 c после смены top_app, а запуск гостя
// сам эту смену и вызывает. Подробности — в VmGlassesController.ensureMercuryKillGuard.
controller.ensureMercuryKillGuard()
startForeground()
startServer()
registerMdns()
}
override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
super.onStartCommand(intent, flags, startId)
return START_STICKY
}
override fun onBind(intent: Intent): IBinder? {
super.onBind(intent)
return null
}
private fun startForeground() {
val channelId = createNotificationChannel()
val notification = buildNotification(channelId)
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
startForeground(NOTIFICATION_ID, notification, ServiceInfo.FOREGROUND_SERVICE_TYPE_MEDIA_PLAYBACK)
} else {
startForeground(NOTIFICATION_ID, notification)
}
}
/**
* Текущая активная сессия телефона.
*
* Нужна, чтобы отключение «чужой»/устаревшей сессии (например, пробного WebSocket-хендшейка
* из subnet-фоллбека при поиске очков) не сбрасывало экран в NoConnection, пока жива настоящая.
*/
private val currentConn = java.util.concurrent.atomic.AtomicReference<String?>(null)
private fun startServer() {
server = Transports.wifiServer(port = WS_PORT, path = WS_PATH)
lifecycleScope.launch(kotlinx.coroutines.Dispatchers.IO) {
try {
server!!.accept(
onMessage = { connId, json ->
handleIncoming(connId, json)
},
onConnected = { connId ->
Log.i(TAG, "Phone connected: $connId")
currentConn.set(connId)
},
onDisconnected = { connId ->
Log.i(TAG, "Phone disconnected: $connId")
if (currentConn.compareAndSet(connId, null)) {
controller.setState(GlassesScreenState.NoConnection)
} else {
Log.i(TAG, "Ignoring disconnect of stale session $connId")
}
},
)
} catch (t: Throwable) {
Log.e(TAG, "Server crashed", t)
}
}
}
private suspend fun handleIncoming(connId: String, json: String) {
val msg = try {
VmProtocol.decodeAny(json)
} catch (t: Throwable) {
Log.w(TAG, "Bad message: ${t.message}")
return
}
when (msg) {
is LaunchAppCommand -> {
Log.i(TAG, "LaunchAppCommand: ${msg.packageName}")
// Реальный запуск: VirtualDisplay + launchActivity через Shizuku.
// acquireFrameSource сам освобождает предыдущий VirtualDisplay, если был.
controller.launchGuest(msg.packageName)
}
is TouchEvent -> Log.d(TAG, "Touch: ${msg.actions.size}")
is CursorMove -> {
Log.d(TAG, "CursorMove: ${msg.x},${msg.y}")
controller.moveCursor(msg.x, msg.y)
}
is CursorClick -> {
Log.d(TAG, "CursorClick")
controller.clickCursor()
}
is CursorScroll -> {
Log.d(TAG, "CursorScroll: ${msg.dx},${msg.dy}")
controller.scrollCursor(msg.dx, msg.dy)
}
is VmKeyEvent -> {
Log.d(TAG, "Key: ${msg.keyCode} action=${msg.action}")
controller.sendKey(msg.keyCode, msg.action)
}
is SetAudioMode -> Log.d(TAG, "SetAudioMode: ${msg.mode}")
is Shutdown -> {
Log.i(TAG, "Shutdown")
controller.stopGuest()
}
is GetAppList -> {
Log.i(TAG, "GetAppList")
val apps = listInstalledLaunchableApps()
val response = AppListResponse(apps = apps)
val json = VmProtocol.encode(response)
try {
server!!.send(connId, json)
Log.i(TAG, "Sent app list: ${apps.size} apps")
} catch (t: Throwable) {
Log.e(TAG, "Failed to send app list", t)
}
}
is Ping -> server?.send(connId, VmProtocol.encode(Pong))
else -> Log.d(TAG, "Unhandled: $msg")
}
}
private fun createNotificationChannel(): String {
val channelId = "vm_service_channel"
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
val mgr = getSystemService(NotificationManager::class.java)
val ch = NotificationChannel(channelId, "RayNeo VM", NotificationManager.IMPORTANCE_LOW)
mgr.createNotificationChannel(ch)
}
return channelId
}
private fun buildNotification(channelId: String): Notification {
val openApp = PendingIntent.getActivity(
this, 0, Intent(this, MainActivity::class.java),
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE
)
return NotificationCompat.Builder(this, channelId)
.setSmallIcon(android.R.drawable.ic_media_play)
.setContentTitle("RayNeo VM")
.setContentText("Сервер активен на порту $WS_PORT")
.setContentIntent(openApp)
.setOngoing(true)
.build()
}
override fun onDestroy() {
super.onDestroy()
unregisterMdns()
server?.close()
server = null
instance = null
}
/**
* Регистрирует WebSocket-сервер в Android NSD (mDNS/DNS-SD) как `_rayneo-vm._tcp`,
* чтобы PhoneApp мог найти очки по сети без знания IP.
* Без этой публикации телефон не находит очки вообще (правило #4742 врёт —
* публикуем с очков, не с телефона — см. SPEC.md:106/304).
*/
private fun registerMdns() {
val mgr = getSystemService(NSD_SERVICE) as? NsdManager ?: run {
Log.w(TAG, "NsdManager not available")
return
}
nsdManager = mgr
val info = NsdServiceInfo().apply {
serviceName = registeredServiceName
serviceType = "_rayneo-vm._tcp"
port = WS_PORT
// txtRecord: version/appId — для диагностики на стороне телефона
setAttribute("version", "0.1.0")
setAttribute("app", "pw.binom.rayneovm.glasses")
}
mgr.registerService(info, NsdManager.PROTOCOL_DNS_SD, registrationListener)
}
private val registrationListener = object : NsdManager.RegistrationListener {
override fun onServiceRegistered(serviceInfo: NsdServiceInfo) {
nsdRegistered = true
Log.i(TAG, "mDNS registered: ${serviceInfo.serviceName} (${serviceInfo.serviceType}:${serviceInfo.port})")
}
override fun onRegistrationFailed(serviceInfo: NsdServiceInfo, errorCode: Int) {
nsdRegistered = false
Log.e(TAG, "mDNS registration failed: code=$errorCode name=${serviceInfo.serviceName}")
}
override fun onServiceUnregistered(arg: NsdServiceInfo) {
nsdRegistered = false
Log.i(TAG, "mDNS unregistered: ${arg.serviceName}")
}
override fun onUnregistrationFailed(serviceInfo: NsdServiceInfo, errorCode: Int) {
Log.w(TAG, "mDNS unregistration failed: code=$errorCode")
}
}
private fun unregisterMdns() {
val mgr = nsdManager ?: return
if (!nsdRegistered) {
nsdManager = null
return
}
// NsdManager требует тот же RegistrationListener, что и при регистрации.
// Хранить ссылку на лисенера проще, чем доставать её — выделим поле.
try {
mgr.unregisterService(registrationListener)
} catch (t: Throwable) {
Log.w(TAG, "unregisterService failed: ${t.message}")
} finally {
nsdManager = null
nsdRegistered = false
}
}
private fun listInstalledLaunchableApps(): List<AppEntry> {
val pm = packageManager
val mainIntent = android.content.Intent(android.content.Intent.ACTION_MAIN)
.addCategory(android.content.Intent.CATEGORY_LAUNCHER)
// MATCH_ALL: на Android 11+ без `<queries>` для конкретного пакета обычные флаги
// могут не возвращать часть приложений. free.zona — пример.
val flags = android.content.pm.PackageManager.MATCH_ALL or
android.content.pm.PackageManager.GET_RESOLVED_FILTER
val resolved = pm.queryIntentActivities(mainIntent, flags)
return resolved.map { ri ->
AppEntry(
packageName = ri.activityInfo.packageName,
displayName = ri.loadLabel(pm).toString(),
)
}.sortedBy { it.displayName.lowercase() }
}
companion object {
const val WS_PORT = 8080
const val WS_PATH = "/vm"
private const val NOTIFICATION_ID = 100
private const val TAG = "VmService"
@Volatile
private var instance: VmService? = null
fun start(context: Context) {
val intent = Intent(context, VmService::class.java)
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
context.startForegroundService(intent)
} else {
context.startService(intent)
}
}
fun stop(context: Context) {
context.stopService(Intent(context, VmService::class.java))
}
fun get(): VmService? = instance
}
}