init
This commit is contained in:
@@ -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
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user