- 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
424 lines
16 KiB
Kotlin
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()
|
|
}
|
|
}
|
|
}
|