Files
lck-control/app/src/main/java/com/omixlab/lckcontrol/service/LckControlService.kt
omigamedev 5b8ede3a19 Move auth to service, UI/UX improvements
- Service handles auth: auto-login via Quest SDK on onCreate, periodic
  token refresh, isAuthenticated/login AIDL methods
- Clients tab shows actual connected game clients from ClientTracker
  instead of plans filtered by status
- Create Plan service picker shows "YouTube - DisplayName" per linked
  account instead of raw service IDs
- createDefaultPlan AIDL method creates plan with one destination per
  linked account (unlisted)
- Session validation on Activity open via getMe() call
- Backend health indicator (green/red dot) on Dashboard nav icon
- ConnectedClientInfo parcelable for client data over AIDL
- SDK client exposes isAuthenticated, login, createDefaultPlan,
  getConnectedClients, authenticated StateFlow
2026-02-27 13:20:48 +01:00

424 lines
16 KiB
Kotlin

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<ILckControlCallback>()
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<LinkedAccount> = 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<StreamPlan> = 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<ConnectedClientInfo> {
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 <T> awaitWithPump(
block: () -> Request<T>,
): 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()
}
}
}