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:
@@ -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()
|
||||
|
||||
Reference in New Issue
Block a user