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
This commit is contained in:
2026-02-27 13:20:48 +01:00
parent ad2c398d78
commit 5b8ede3a19
24 changed files with 623 additions and 183 deletions

View File

@@ -8,12 +8,22 @@ 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
@@ -21,20 +31,29 @@ 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()
@@ -42,16 +61,49 @@ class LckControlService : Service() {
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")
@@ -69,22 +121,30 @@ class LckControlService : Service() {
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
streamPlanRepository.startPlan(planId)
val updated = streamPlanRepository.getPlan(planId)
if (updated != null) broadcastPlanUpdated(updated)
true
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 != "LIVE") return@runBlocking false
streamPlanRepository.endPlan(planId)
val updated = streamPlanRepository.getPlan(planId)
if (updated != null) broadcastPlanUpdated(updated)
true
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)
@@ -100,6 +160,19 @@ class LckControlService : Service() {
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)
}
@@ -117,6 +190,25 @@ class LckControlService : Service() {
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
@@ -127,6 +219,119 @@ class LckControlService : Service() {
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,
@@ -146,6 +351,21 @@ class LckControlService : Service() {
.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()