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.chat.ChatNotificationManager import com.omixlab.lckcontrol.data.local.AppPreferences 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.ChatRepository 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 com.omixlab.lckcontrol.shared.StreamingConfig import com.omixlab.lckcontrol.streaming.StreamingManager import com.omixlab.lckcontrol.streaming.StreamingState 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 private const val ACTION_BIND_STREAMING = "com.omixlab.lckcontrol.BIND_STREAMING" } @Inject lateinit var appPreferences: AppPreferences @Inject lateinit var accountRepository: AccountRepository @Inject lateinit var streamPlanRepository: StreamPlanRepository @Inject lateinit var tokenStore: TokenStore @Inject lateinit var apiService: LckApiService @Inject lateinit var streamingManager: StreamingManager @Inject lateinit var chatRepository: ChatRepository @Inject lateinit var chatNotificationManager: ChatNotificationManager private val serviceScope = CoroutineScope(SupervisorJob() + Dispatchers.Main) private val clientTracker = ClientTracker() private var streamingServiceImpl: LckStreamingServiceImpl? = null private val callbacks = object : RemoteCallbackList() { override fun onCallbackDied(callback: ILckControlCallback, cookie: Any?) { val uid = cookie as? Int ?: return serviceScope.launch { val removed = clientTracker.unregisterByUid(uid) for (client in removed) { Log.d(TAG, "Auto-unregistered client ${client.clientId} (${client.packageName}) - process died") broadcastClientUnregistered(client.clientId) } } } } 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( name = config.name, destinations = config.destinations, executionMode = config.executionMode, gameId = config.gameId, ) broadcastPlansChanged() plan } override fun createDefaultPlan(clientName: String): StreamPlan = runBlocking { val accounts = accountRepository.getAccounts().filter { it.isEnabled } val gameId = clientTracker.getAll() .find { it.clientName == clientName }?.packageName ?: "" val execMode = appPreferences.getDefaultExecutionMode() Log.d(TAG, "createDefaultPlan: clientName=$clientName, executionMode=$execMode, accounts=${accounts.size}") val destinations = accounts.map { account -> StreamDestination( service = account.serviceId, linkedAccountId = account.id, title = "Stream", privacyStatus = "unlisted", ) } val plan = streamPlanRepository.createPlan( name = "$clientName Stream", destinations = destinations, executionMode = execMode, gameId = gameId, ) Log.d(TAG, "createDefaultPlan: created plan ${plan.planId} with executionMode=${plan.executionMode}") 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") { // Plan already LIVE — ensure streaming engine is running for APP_STREAMING if (plan.executionMode == "APP_STREAMING" && !streamingManager.isStreaming()) { Log.d(TAG, "startStreamPlan: plan already LIVE but engine not running, starting engine") streamingManager.startStreaming( plan = plan, config = StreamingConfig(), width = 1920, height = 1080, ) } return@runBlocking true } if (plan.status != "READY") return@runBlocking false try { streamPlanRepository.startPlan(planId) val updated = streamPlanRepository.getPlan(planId) // If APP_STREAMING mode, start the streaming engine if (updated?.executionMode == "APP_STREAMING") { streamingManager.startStreaming( plan = updated, config = StreamingConfig(), width = 1920, height = 1080, ) } if (updated != null) broadcastPlanUpdated(updated) true } catch (e: Throwable) { Log.e(TAG, "startStreamPlan failed", e) 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 { // Stop streaming engine if running if (plan.executionMode == "APP_STREAMING") { streamingManager.stopStreaming() } 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 uid = android.os.Binder.getCallingUid() val clientId = clientTracker.register(clientName, packageName, uid) 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) { val uid = android.os.Binder.getCallingUid() callbacks.register(callback, uid) } 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() } } } // Forward streaming state changes to AIDL callbacks serviceScope.launch { streamingManager.state.collect { state -> streamingServiceImpl?.broadcastStateChanged(state) } } serviceScope.launch { streamingManager.stats.collect { stats -> streamingServiceImpl?.broadcastStats( stats.videoBitrate, stats.audioBitrate, stats.fps, stats.droppedFrames, ) } } // Forward buffer release events to AIDL callbacks streamingManager.onBufferReleased = { bufferIndex -> streamingServiceImpl?.broadcastBufferReleased(bufferIndex) } // Initialize chat notification manager and connect WebSocket chatNotificationManager.init() chatRepository.connect() // Auto-subscribe/unsubscribe chat when plans go LIVE/ENDED serviceScope.launch { streamPlanRepository.observePlans().collect { plans -> Log.d(TAG, "observePlans emitted ${plans.size} plans") for (plan in plans) { val destServices = plan.destinations.map { it.service } Log.d(TAG, "Plan ${plan.planId} status=${plan.status} destinations=$destServices") val hasChat = plan.destinations.any { it.service == "YOUTUBE" || it.service == "TWITCH" } if (plan.status == "LIVE" && hasChat) { Log.d(TAG, "Subscribing chat for plan ${plan.planId}") chatRepository.subscribe(plan.planId) } else if (plan.status == "ENDED") { chatRepository.unsubscribe(plan.planId) } } } } } override fun onBind(intent: Intent?): IBinder? { return when (intent?.action) { ACTION_BIND_STREAMING -> { if (streamingServiceImpl == null) { streamingServiceImpl = LckStreamingServiceImpl(streamingManager) } streamingServiceImpl!!.asBinder() } else -> binder } } override fun onDestroy() { chatRepository.disconnect() streamingManager.stopStreaming() streamingServiceImpl?.kill() serviceScope.cancel() callbacks.kill() super.onDestroy() } // ── Auth logic ────────────────────────────────────────── private fun extractJwtSub(jwt: String): String? { return try { val parts = jwt.split(".") if (parts.size < 2) return null val payload = String(android.util.Base64.decode(parts[1], android.util.Base64.URL_SAFE or android.util.Base64.NO_WRAP)) org.json.JSONObject(payload).optString("sub", "").ifEmpty { null } } catch (_: Exception) { null } } private suspend fun doAutoLogin() { // Try token refresh first val refreshToken = tokenStore.getRefreshToken() val oldJwt = tokenStore.getJwt() val oldSub = oldJwt?.let { extractJwtSub(it) } Log.d(TAG, "doAutoLogin: hasRefreshToken=${refreshToken != null}, currentUserId=$oldSub") if (refreshToken != null) { Log.d(TAG, "Attempting token refresh...") try { val response = apiService.refreshSession(RefreshRequest(refreshToken)) val newSub = extractJwtSub(response.accessToken) Log.d(TAG, "Token refresh successful, userId=$newSub (was $oldSub)") tokenStore.saveSession(response.accessToken, response.refreshToken) 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 Log.d(TAG, "Starting Quest SDK login (previous userId=$oldSub)") 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, ) ) val loginSub = extractJwtSub(response.accessToken) tokenStore.saveSession(response.accessToken, response.refreshToken) Log.d(TAG, "Quest SDK login successful, userId=$loginSub") 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() } } }