This commit is contained in:
2026-09-25 13:24:56 +03:00
parent 45066a65a1
commit 987f9e5547
47 changed files with 4998 additions and 1 deletions
@@ -0,0 +1,245 @@
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
/**
* 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")
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)
}
}
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")
},
onDisconnected = { connId ->
Log.i(TAG, "Phone disconnected: $connId")
controller.setState(GlassesScreenState.NoConnection)
},
)
} 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}")
// Реальный запуск — через Shizuku service (см. VmShizukuService).
// Пока — переключаем состояние для UI.
controller.setState(GlassesScreenState.Running(msg.packageName))
}
is TouchEvent -> Log.d(TAG, "Touch: ${msg.actions.size}")
is VmKeyEvent -> Log.d(TAG, "Key: ${msg.keyCode}")
is SetAudioMode -> Log.d(TAG, "SetAudioMode: ${msg.mode}")
is Shutdown -> {
Log.i(TAG, "Shutdown")
controller.setState(GlassesScreenState.NoAppSelected)
}
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
}
}
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
}
}