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? = 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(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 { val pm = packageManager val mainIntent = android.content.Intent(android.content.Intent.ACTION_MAIN) .addCategory(android.content.Intent.CATEGORY_LAUNCHER) // MATCH_ALL: на Android 11+ без `` для конкретного пакета обычные флаги // могут не возвращать часть приложений. 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 } }