313 lines
13 KiB
Kotlin
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
|
|
}
|
|
}
|