package com.omixlab.lckcontrol.service import android.app.Notification import android.app.NotificationChannel import android.app.NotificationManager import android.app.Service import android.content.Intent import android.content.pm.ServiceInfo import android.os.IBinder import android.os.RemoteCallbackList import android.util.Log import com.meta.horizon.platform.ovr.Core import com.meta.horizon.platform.ovr.requests.Request import com.meta.horizon.platform.ovr.requests.Users import com.omixlab.lckcontrol.R import com.omixlab.lckcontrol.data.local.TokenStore import com.omixlab.lckcontrol.data.remote.LckApiService import com.omixlab.lckcontrol.data.remote.MetaCallbackRequest import com.omixlab.lckcontrol.data.remote.RefreshRequest import com.omixlab.lckcontrol.data.repository.AccountRepository import com.omixlab.lckcontrol.data.repository.StreamPlanRepository import com.omixlab.lckcontrol.shared.ConnectedClientInfo import com.omixlab.lckcontrol.shared.ILckControlCallback import com.omixlab.lckcontrol.shared.ILckControlService import com.omixlab.lckcontrol.shared.LinkedAccount import com.omixlab.lckcontrol.shared.StreamDestination import com.omixlab.lckcontrol.shared.StreamPlan import com.omixlab.lckcontrol.shared.StreamPlanConfig import dagger.hilt.android.AndroidEntryPoint import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.SupervisorJob import kotlinx.coroutines.cancel import kotlinx.coroutines.delay import kotlinx.coroutines.launch import kotlinx.coroutines.runBlocking import javax.inject.Inject import kotlin.coroutines.resume import kotlin.coroutines.resumeWithException import kotlin.coroutines.suspendCoroutine @AndroidEntryPoint class LckControlService : Service() { companion object { private const val TAG = "LckControlService" private const val CHANNEL_ID = "lck_control_service" private const val NOTIFICATION_ID = 1 private const val QUEST_APP_ID = "25653777174321448" private const val TOKEN_REFRESH_INTERVAL_MS = 60_000L } @Inject lateinit var accountRepository: AccountRepository @Inject lateinit var streamPlanRepository: StreamPlanRepository @Inject lateinit var tokenStore: TokenStore @Inject lateinit var apiService: LckApiService private val serviceScope = CoroutineScope(SupervisorJob() + Dispatchers.Main) private val clientTracker = ClientTracker() private val callbacks = RemoteCallbackList() private val binder = object : ILckControlService.Stub() { // ── Auth ──────────────────────────────────────────── override fun isAuthenticated(): Boolean = tokenStore.isLoggedIn() override fun login() { serviceScope.launch { try { doAutoLogin() } catch (e: Exception) { Log.e(TAG, "login() failed", e) } } } // ── Accounts ──────────────────────────────────────── override fun getLinkedAccounts(): List = runBlocking { accountRepository.getAccounts() } // ── Stream plans ──────────────────────────────────── override fun createStreamPlan(config: StreamPlanConfig): StreamPlan = runBlocking { val plan = streamPlanRepository.createPlan(config.name, config.destinations) broadcastPlansChanged() plan } override fun createDefaultPlan(clientName: String): StreamPlan = runBlocking { val accounts = accountRepository.getAccounts() val destinations = accounts.map { account -> StreamDestination( service = account.serviceId, linkedAccountId = account.id, title = "Stream", privacyStatus = "unlisted", ) } val plan = streamPlanRepository.createPlan("$clientName Stream", destinations) broadcastPlansChanged() plan } override fun prepareStreamPlan(planId: String): StreamPlan = runBlocking { streamPlanRepository.preparePlan(planId) val plan = streamPlanRepository.getPlan(planId) ?: error("Plan not found after prepare") broadcastPlanUpdated(plan) plan } override fun getStreamPlans(): List = runBlocking { streamPlanRepository.getPlans() } override fun getStreamPlan(planId: String): StreamPlan? = runBlocking { streamPlanRepository.getPlan(planId) } override fun startStreamPlan(planId: String): Boolean = runBlocking { val plan = streamPlanRepository.getPlan(planId) ?: return@runBlocking false if (plan.status == "LIVE") return@runBlocking true if (plan.status != "READY") return@runBlocking false try { streamPlanRepository.startPlan(planId) val updated = streamPlanRepository.getPlan(planId) if (updated != null) broadcastPlanUpdated(updated) true } catch (_: Exception) { false } } override fun endStreamPlan(planId: String): Boolean = runBlocking { val plan = streamPlanRepository.getPlan(planId) ?: return@runBlocking false if (plan.status == "ENDED") return@runBlocking true if (plan.status != "LIVE" && plan.status != "READY") return@runBlocking false try { streamPlanRepository.endPlan(planId) val updated = streamPlanRepository.getPlan(planId) if (updated != null) broadcastPlanUpdated(updated) true } catch (_: Exception) { false } } // ── Clients ───────────────────────────────────────── override fun registerClient(clientName: String, packageName: String): String { val clientId = clientTracker.register(clientName, packageName) broadcastClientRegistered(clientId) return clientId } override fun unregisterClient(clientId: String) { clientTracker.unregister(clientId) broadcastClientUnregistered(clientId) } override fun setClientActivePlan(clientId: String, planId: String) { clientTracker.setActivePlan(clientId, planId) } override fun getConnectedClients(): List { return clientTracker.getAll().map { client -> ConnectedClientInfo( clientId = client.clientId, clientName = client.clientName, packageName = client.packageName, activePlanId = client.activePlanId, ) } } // ── Callbacks ─────────────────────────────────────── override fun registerCallback(callback: ILckControlCallback) { callbacks.register(callback) } override fun unregisterCallback(callback: ILckControlCallback) { callbacks.unregister(callback) } } override fun onCreate() { super.onCreate() createNotificationChannel() startForeground( NOTIFICATION_ID, buildNotification(), ServiceInfo.FOREGROUND_SERVICE_TYPE_CONNECTED_DEVICE, ) // Auto-login on service start serviceScope.launch { try { doAutoLogin() } catch (e: Exception) { Log.e(TAG, "Auto-login on service start failed", e) } } // Periodic token refresh serviceScope.launch { while (true) { delay(TOKEN_REFRESH_INTERVAL_MS) if (tokenStore.isLoggedIn()) { tryRefreshToken() } } } } override fun onBind(intent: Intent?): IBinder = binder override fun onDestroy() { serviceScope.cancel() callbacks.kill() super.onDestroy() } // ── Auth logic ────────────────────────────────────────── private suspend fun doAutoLogin() { // Try token refresh first val refreshToken = tokenStore.getRefreshToken() if (refreshToken != null) { Log.d(TAG, "Attempting token refresh...") try { val response = apiService.refreshSession(RefreshRequest(refreshToken)) tokenStore.saveSession(response.accessToken, response.refreshToken) Log.d(TAG, "Token refresh successful") broadcastAuthStateChanged(true) return } catch (e: Exception) { Log.w(TAG, "Token refresh failed, falling back to Quest SDK login", e) tokenStore.clearSession() } } // Full Quest SDK login doQuestLogin() } private suspend fun doQuestLogin() { if (!Core.isInitialized()) { Log.d(TAG, "Initializing Platform SDK with appId=$QUEST_APP_ID") val initResult = awaitWithPump { Core.asyncInitialize(QUEST_APP_ID, applicationContext) } Log.d(TAG, "Platform SDK initialized: $initResult") } Log.d(TAG, "Requesting logged-in user...") val user = awaitWithPump { Users.getLoggedInUser() } val numericId = user.getID().toString() val oculusId = user.oculusID Log.d(TAG, "User: id=$numericId displayName=${user.displayName} oculusId=$oculusId") val userId = if (user.getID() != 0L) numericId else oculusId if (userId.isNullOrEmpty()) { throw Exception("Platform SDK returned no user identifier") } var nonce = "" try { val proof = awaitWithPump { Users.getUserProof() } nonce = proof.value } catch (e: Exception) { Log.w(TAG, "getUserProof failed (DUC pending?), proceeding without nonce", e) } Log.d(TAG, "Sending to backend: userId=$userId hasNonce=${nonce.isNotEmpty()}") val response = apiService.metaCallback( MetaCallbackRequest( userId = userId, nonce = nonce, deviceInfo = android.os.Build.MODEL, ) ) tokenStore.saveSession(response.accessToken, response.refreshToken) Log.d(TAG, "Quest SDK login successful") broadcastAuthStateChanged(true) } private suspend fun tryRefreshToken() { val refreshToken = tokenStore.getRefreshToken() ?: return try { val response = apiService.refreshSession(RefreshRequest(refreshToken)) tokenStore.saveSession(response.accessToken, response.refreshToken) } catch (e: Exception) { Log.w(TAG, "Periodic token refresh failed", e) tokenStore.clearSession() broadcastAuthStateChanged(false) } } private suspend fun awaitWithPump( block: () -> Request, ): T = suspendCoroutine { cont -> var completed = false block() .onSuccess { result: T -> if (!completed) { completed = true cont.resume(result) } } .onError { error -> if (!completed) { completed = true cont.resumeWithException(Exception("SDK error ${error.code}: ${error.message}")) } } Thread { val timeout = System.currentTimeMillis() + 15_000 while (!completed && System.currentTimeMillis() < timeout) { try { val msg = Core.popSDKMessage() if (msg != null) Request.handleMessage(msg) } catch (e: Exception) { Log.w(TAG, "Message pump error", e) } Thread.sleep(50) } if (!completed) { completed = true cont.resumeWithException(Exception("Platform SDK request timed out")) } }.start() } // ── Notifications ─────────────────────────────────────── private fun createNotificationChannel() { val channel = NotificationChannel( CHANNEL_ID, "LCK Control Service", NotificationManager.IMPORTANCE_LOW, ).apply { description = "Manages connected game clients and stream plans" } getSystemService(NotificationManager::class.java).createNotificationChannel(channel) } private fun buildNotification(): Notification = Notification.Builder(this, CHANNEL_ID) .setContentTitle("LCK Control") .setContentText("Managing stream connections") .setSmallIcon(R.drawable.ic_launcher_foreground) .setOngoing(true) .build() // ── Broadcast helpers ─────────────────────────────────── private fun broadcastAuthStateChanged(authenticated: Boolean) { val count = callbacks.beginBroadcast() try { for (i in 0 until count) { try { callbacks.getBroadcastItem(i).onAuthStateChanged(authenticated) } catch (_: Exception) {} } } finally { callbacks.finishBroadcast() } } private fun broadcastPlansChanged() { serviceScope.launch { val plans = streamPlanRepository.getPlans() val count = callbacks.beginBroadcast() try { for (i in 0 until count) { try { callbacks.getBroadcastItem(i).onStreamPlansChanged(plans) } catch (_: Exception) {} } } finally { callbacks.finishBroadcast() } } } private fun broadcastPlanUpdated(plan: StreamPlan) { val count = callbacks.beginBroadcast() try { for (i in 0 until count) { try { callbacks.getBroadcastItem(i).onStreamPlanUpdated(plan) } catch (_: Exception) {} } } finally { callbacks.finishBroadcast() } } private fun broadcastClientRegistered(clientId: String) { val count = callbacks.beginBroadcast() try { for (i in 0 until count) { try { callbacks.getBroadcastItem(i).onClientRegistered(clientId) } catch (_: Exception) {} } } finally { callbacks.finishBroadcast() } } private fun broadcastClientUnregistered(clientId: String) { val count = callbacks.beginBroadcast() try { for (i in 0 until count) { try { callbacks.getBroadcastItem(i).onClientUnregistered(clientId) } catch (_: Exception) {} } } finally { callbacks.finishBroadcast() } } }