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

@@ -11,7 +11,7 @@
android:name="com.omixlab.lckcontrol.permission.USE_LCK_CONTROL"
android:label="Access LCK Control Service"
android:description="@string/permission_use_lck_control_desc"
android:protectionLevel="dangerous" />
android:protectionLevel="normal" />
<application
android:name=".LckControlApp"

View File

@@ -5,6 +5,7 @@ import androidx.activity.ComponentActivity
import androidx.activity.compose.setContent
import androidx.activity.enableEdgeToEdge
import com.omixlab.lckcontrol.data.local.TokenStore
import com.omixlab.lckcontrol.data.remote.LckApiService
import com.omixlab.lckcontrol.ui.navigation.AppNavigation
import com.omixlab.lckcontrol.ui.theme.LCKControlTheme
import dagger.hilt.android.AndroidEntryPoint
@@ -14,13 +15,14 @@ import javax.inject.Inject
class MainActivity : ComponentActivity() {
@Inject lateinit var tokenStore: TokenStore
@Inject lateinit var apiService: LckApiService
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
enableEdgeToEdge()
setContent {
LCKControlTheme {
AppNavigation(tokenStore)
AppNavigation(tokenStore, apiService)
}
}
}

View File

@@ -2,40 +2,39 @@ package com.omixlab.lckcontrol.auth
import android.content.Intent
import android.os.Bundle
import android.util.Log
import androidx.activity.ComponentActivity
import androidx.lifecycle.lifecycleScope
import com.omixlab.lckcontrol.MainActivity
import com.omixlab.lckcontrol.data.remote.LckApiService
import com.omixlab.lckcontrol.data.remote.ProviderCallbackRequest
import com.omixlab.lckcontrol.data.repository.AccountRepository
import dagger.hilt.android.AndroidEntryPoint
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import javax.inject.Inject
@AndroidEntryPoint
class TwitchAuthRedirectActivity : ComponentActivity() {
@Inject lateinit var apiService: LckApiService
@Inject lateinit var accountRepository: AccountRepository
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
val code = intent?.data?.getQueryParameter("code")
val state = intent?.data?.getQueryParameter("state")
if (code != null && state != null) {
CoroutineScope(Dispatchers.IO).launch {
lifecycleScope.launch {
if (code != null && state != null) {
try {
apiService.twitchCallback(ProviderCallbackRequest(code = code, state = state))
accountRepository.handleTwitchCallback(code, state)
} catch (e: Exception) {
// Error will be surfaced when accounts screen refreshes
Log.e("TwitchAuth", "Callback failed", e)
}
}
val mainIntent = Intent(this@TwitchAuthRedirectActivity, MainActivity::class.java).apply {
flags = Intent.FLAG_ACTIVITY_CLEAR_TOP or Intent.FLAG_ACTIVITY_SINGLE_TOP
}
startActivity(mainIntent)
finish()
}
val mainIntent = Intent(this, MainActivity::class.java).apply {
flags = Intent.FLAG_ACTIVITY_CLEAR_TOP or Intent.FLAG_ACTIVITY_SINGLE_TOP
}
startActivity(mainIntent)
finish()
}
}

View File

@@ -2,40 +2,39 @@ package com.omixlab.lckcontrol.auth
import android.content.Intent
import android.os.Bundle
import android.util.Log
import androidx.activity.ComponentActivity
import androidx.lifecycle.lifecycleScope
import com.omixlab.lckcontrol.MainActivity
import com.omixlab.lckcontrol.data.remote.LckApiService
import com.omixlab.lckcontrol.data.remote.ProviderCallbackRequest
import com.omixlab.lckcontrol.data.repository.AccountRepository
import dagger.hilt.android.AndroidEntryPoint
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import javax.inject.Inject
@AndroidEntryPoint
class YouTubeAuthRedirectActivity : ComponentActivity() {
@Inject lateinit var apiService: LckApiService
@Inject lateinit var accountRepository: AccountRepository
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
val code = intent?.data?.getQueryParameter("code")
val state = intent?.data?.getQueryParameter("state")
if (code != null && state != null) {
CoroutineScope(Dispatchers.IO).launch {
lifecycleScope.launch {
if (code != null && state != null) {
try {
apiService.youtubeCallback(ProviderCallbackRequest(code = code, state = state))
accountRepository.handleYouTubeCallback(code, state)
} catch (e: Exception) {
// Error will be surfaced when accounts screen refreshes
Log.e("YouTubeAuth", "Callback failed", e)
}
}
val mainIntent = Intent(this@YouTubeAuthRedirectActivity, MainActivity::class.java).apply {
flags = Intent.FLAG_ACTIVITY_CLEAR_TOP or Intent.FLAG_ACTIVITY_SINGLE_TOP
}
startActivity(mainIntent)
finish()
}
val mainIntent = Intent(this, MainActivity::class.java).apply {
flags = Intent.FLAG_ACTIVITY_CLEAR_TOP or Intent.FLAG_ACTIVITY_SINGLE_TOP
}
startActivity(mainIntent)
finish()
}
}

View File

@@ -2,6 +2,14 @@ package com.omixlab.lckcontrol.data.remote
import com.squareup.moshi.JsonClass
// ── Health ───────────────────────────────────────────────
@JsonClass(generateAdapter = true)
data class HealthResponse(
val status: String,
val timestamp: String,
)
// ── Auth ─────────────────────────────────────────────────
@JsonClass(generateAdapter = true)

View File

@@ -18,7 +18,7 @@ class AuthInterceptor @Inject constructor(
// Don't add auth header to auth endpoints (login, refresh)
val path = original.url.encodedPath
if (path.contains("/auth/meta/callback") || path.contains("/auth/refresh")) {
if (path.contains("/auth/meta/callback") || path.contains("/auth/refresh") || path.contains("/health")) {
return chain.proceed(original)
}

View File

@@ -4,6 +4,11 @@ import retrofit2.http.*
interface LckApiService {
// ── Health ────────────────────────────────────────────
@GET("health")
suspend fun healthCheck(): HealthResponse
// ── Auth ─────────────────────────────────────────────
@POST("auth/meta/callback")

View File

@@ -18,8 +18,7 @@ import javax.inject.Singleton
@InstallIn(SingletonComponent::class)
object AppModule {
// TODO: Set from BuildConfig or remote config
private const val BASE_URL = "http://192.168.1.60:3100/"
private const val BASE_URL = "https://lck.omigame.dev/"
@Provides
@Singleton

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()

View File

@@ -18,6 +18,7 @@ import androidx.compose.material3.ElevatedCard
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedButton
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Text
import androidx.compose.material3.TopAppBar
@@ -102,30 +103,41 @@ fun ActiveClientsScreen(
}
}
} else {
items(clients, key = { it.planId }) { client ->
items(clients, key = { it.clientId }) { client ->
ElevatedCard(modifier = Modifier.fillMaxWidth()) {
Row(
Column(
modifier = Modifier
.fillMaxWidth()
.padding(16.dp),
verticalAlignment = Alignment.CenterVertically,
) {
Column(modifier = Modifier.weight(1f)) {
Text(client.planName, style = MaterialTheme.typography.titleSmall)
Text(
"${client.destinationCount} destination(s)",
style = MaterialTheme.typography.bodySmall,
)
Row(
modifier = Modifier.fillMaxWidth(),
verticalAlignment = Alignment.CenterVertically,
) {
Column(modifier = Modifier.weight(1f)) {
Text(client.clientName, style = MaterialTheme.typography.titleSmall)
Text(
client.packageName,
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
if (client.activePlanId != null) {
Text(
"Streaming",
style = MaterialTheme.typography.labelMedium,
color = MaterialTheme.colorScheme.primary,
)
}
}
if (client.activePlanId == null) {
Spacer(Modifier.height(8.dp))
OutlinedButton(
onClick = { viewModel.createDefaultPlan(client.clientName) },
) {
Text("Create Default Plan")
}
}
Text(
client.planStatus,
style = MaterialTheme.typography.labelMedium,
color = when (client.planStatus) {
"LIVE" -> MaterialTheme.colorScheme.error
"READY" -> MaterialTheme.colorScheme.primary
else -> MaterialTheme.colorScheme.onSurfaceVariant
},
)
}
}
}

View File

@@ -6,8 +6,7 @@ import android.content.Intent
import android.content.ServiceConnection
import android.os.IBinder
import androidx.lifecycle.ViewModel
import com.omixlab.lckcontrol.service.ClientTracker
import com.omixlab.lckcontrol.service.ConnectedClient
import com.omixlab.lckcontrol.shared.ConnectedClientInfo
import com.omixlab.lckcontrol.shared.ILckControlCallback
import com.omixlab.lckcontrol.shared.ILckControlService
import com.omixlab.lckcontrol.shared.StreamPlan
@@ -25,20 +24,16 @@ class ActiveClientsViewModel @Inject constructor(
private var service: ILckControlService? = null
private val _clients = MutableStateFlow<List<ClientInfo>>(emptyList())
val clients: StateFlow<List<ClientInfo>> = _clients.asStateFlow()
private val _clients = MutableStateFlow<List<ConnectedClientInfo>>(emptyList())
val clients: StateFlow<List<ConnectedClientInfo>> = _clients.asStateFlow()
private val _isConnected = MutableStateFlow(false)
val isConnected: StateFlow<Boolean> = _isConnected.asStateFlow()
private val callback = object : ILckControlCallback.Stub() {
override fun onStreamPlansChanged(plans: List<StreamPlan>) {
refreshClients()
}
override fun onStreamPlansChanged(plans: List<StreamPlan>) {}
override fun onStreamPlanUpdated(plan: StreamPlan) {
refreshClients()
}
override fun onStreamPlanUpdated(plan: StreamPlan) {}
override fun onClientRegistered(clientId: String) {
refreshClients()
@@ -47,6 +42,8 @@ class ActiveClientsViewModel @Inject constructor(
override fun onClientUnregistered(clientId: String) {
refreshClients()
}
override fun onAuthStateChanged(authenticated: Boolean) {}
}
private val connection = object : ServiceConnection {
@@ -79,19 +76,11 @@ class ActiveClientsViewModel @Inject constructor(
}
private fun refreshClients() {
// The service tracks clients internally; for the UI we present
// plans and their associated clients. This is a simplified view.
val plans = service?.streamPlans ?: emptyList()
_clients.value = plans
.filter { it.status == "LIVE" || it.status == "READY" }
.map { plan ->
ClientInfo(
planId = plan.planId,
planName = plan.name,
planStatus = plan.status,
destinationCount = plan.destinations.size,
)
}
_clients.value = service?.connectedClients ?: emptyList()
}
fun createDefaultPlan(clientName: String): StreamPlan? {
return service?.createDefaultPlan(clientName)
}
override fun onCleared() {
@@ -102,10 +91,3 @@ class ActiveClientsViewModel @Inject constructor(
super.onCleared()
}
}
data class ClientInfo(
val planId: String,
val planName: String,
val planStatus: String,
val destinationCount: Int,
)

View File

@@ -10,12 +10,13 @@ import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.launch
import javax.inject.Inject
@HiltViewModel
class DashboardViewModel @Inject constructor(
accountRepository: AccountRepository,
streamPlanRepository: StreamPlanRepository,
private val streamPlanRepository: StreamPlanRepository,
) : ViewModel() {
val accounts: StateFlow<List<LinkedAccount>> = accountRepository.observeAccounts()
@@ -23,4 +24,10 @@ class DashboardViewModel @Inject constructor(
val plans: StateFlow<List<StreamPlan>> = streamPlanRepository.observePlans()
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), emptyList())
init {
viewModelScope.launch {
try { streamPlanRepository.syncPlans() } catch (_: Exception) {}
}
}
}

View File

@@ -14,6 +14,7 @@ import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
@@ -30,8 +31,19 @@ fun LoginScreen(
) {
val isLoading by viewModel.isLoading.collectAsStateWithLifecycle()
val error by viewModel.error.collectAsStateWithLifecycle()
val loginSuccess by viewModel.loginSuccess.collectAsStateWithLifecycle()
val context = LocalContext.current
LaunchedEffect(loginSuccess) {
if (loginSuccess) onLoginSuccess()
}
// Auto-login on screen composition
LaunchedEffect(Unit) {
val activity = context as? Activity ?: return@LaunchedEffect
viewModel.attemptAutoLogin(activity)
}
Box(
modifier = Modifier
.fillMaxSize()

View File

@@ -7,10 +7,8 @@ import androidx.lifecycle.viewModelScope
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.meta.horizon.platform.ovr.Core
import com.meta.horizon.platform.ovr.models.PlatformInitialize
import com.meta.horizon.platform.ovr.models.User
import com.meta.horizon.platform.ovr.models.UserProof
import com.meta.horizon.platform.ovr.requests.Request
import com.meta.horizon.platform.ovr.requests.Users
import dagger.hilt.android.lifecycle.HiltViewModel
@@ -35,61 +33,61 @@ class LoginViewModel @Inject constructor(
private val _error = MutableStateFlow<String?>(null)
val error: StateFlow<String?> = _error.asStateFlow()
private val _loginSuccess = MutableStateFlow(false)
val loginSuccess: StateFlow<Boolean> = _loginSuccess.asStateFlow()
fun isLoggedIn(): Boolean = tokenStore.isLoggedIn()
/** Try to validate existing session, refresh if needed, or do full Quest SDK login */
fun attemptAutoLogin(activity: Activity) {
viewModelScope.launch {
_isLoading.value = true
_error.value = null
try {
// If we have a JWT, try validating it
if (tokenStore.isLoggedIn()) {
try {
apiService.getMe()
Log.d(TAG, "Existing session valid")
_loginSuccess.value = true
return@launch
} catch (e: Exception) {
Log.w(TAG, "Session validation failed, will try refresh", e)
}
}
// Try refresh token
val refreshToken = tokenStore.getRefreshToken()
if (refreshToken != null) {
try {
val response = apiService.refreshSession(RefreshRequest(refreshToken))
tokenStore.saveSession(response.accessToken, response.refreshToken)
Log.d(TAG, "Token refresh successful")
_loginSuccess.value = true
return@launch
} catch (e: Exception) {
Log.w(TAG, "Token refresh failed, falling back to full login", e)
tokenStore.clearSession()
}
}
// Full Quest SDK login
doQuestLogin(activity)
} catch (e: Exception) {
Log.e(TAG, "Auto-login failed", e)
_error.value = e.message ?: "Auto-login failed"
} finally {
_isLoading.value = false
}
}
}
fun loginWithQuest(activity: Activity) {
viewModelScope.launch {
_isLoading.value = true
_error.value = null
try {
// Initialize Platform SDK (async, wait for completion via message pump)
if (!Core.isInitialized()) {
Log.d(TAG, "Async initializing Platform SDK with appId=$QUEST_APP_ID")
val initResult = awaitWithPump { Core.asyncInitialize(QUEST_APP_ID, activity.applicationContext) }
Log.d(TAG, "Platform SDK initialized: $initResult")
} else {
Log.d(TAG, "Platform SDK already initialized")
}
// Get logged-in user (async with message pump)
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")
// Use numeric ID if available (requires Data Use Checkup), fall back to oculusId
val userId = if (user.getID() != 0L) numericId else oculusId
if (userId.isNullOrEmpty()) {
throw Exception("Platform SDK returned no user identifier. " +
"Make sure the app is installed from the Horizon store " +
"and your account is a test user for this app.")
}
// Get user proof (nonce) — may fail without DUC approval
var nonce = ""
try {
Log.d(TAG, "Requesting user proof...")
val proof = awaitWithPump { Users.getUserProof() }
nonce = proof.value
Log.d(TAG, "UserProof nonce length=${nonce.length}")
} catch (e: Exception) {
Log.w(TAG, "getUserProof failed (DUC pending?), proceeding without nonce", e)
}
// Send to backend for verification
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,
)
)
// Save session tokens
tokenStore.saveSession(response.accessToken, response.refreshToken)
Log.d(TAG, "Login successful!")
doQuestLogin(activity)
} catch (e: Exception) {
Log.e(TAG, "Quest login failed", e)
_error.value = e.message ?: "Login failed"
@@ -99,55 +97,81 @@ class LoginViewModel @Inject constructor(
}
}
private suspend fun doQuestLogin(activity: Activity) {
if (!Core.isInitialized()) {
Log.d(TAG, "Async initializing Platform SDK with appId=$QUEST_APP_ID")
val initResult = awaitWithPump { Core.asyncInitialize(QUEST_APP_ID, activity.applicationContext) }
Log.d(TAG, "Platform SDK initialized: $initResult")
}
val user = awaitWithPump { Users.getLoggedInUser() }
val numericId = user.getID().toString()
val oculusId = user.oculusID
val userId = if (user.getID() != 0L) numericId else oculusId
if (userId.isNullOrEmpty()) {
throw Exception("Platform SDK returned no user identifier. " +
"Make sure the app is installed from the Horizon store " +
"and your account is a test user for this app.")
}
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)
}
val response = apiService.metaCallback(
MetaCallbackRequest(
userId = userId,
nonce = nonce,
deviceInfo = android.os.Build.MODEL,
)
)
tokenStore.saveSession(response.accessToken, response.refreshToken)
Log.d(TAG, "Login successful!")
_loginSuccess.value = true
}
fun clearError() {
_error.value = null
}
/**
* Awaits a Platform SDK request by manually pumping the message queue.
* The SDK delivers responses via Core.popSDKMessage() which must be polled.
*/
private suspend fun <T> awaitWithPump(
block: () -> Request<T>,
): T = suspendCoroutine { cont ->
var completed = false
block()
.onSuccess { result: T ->
Log.d(TAG, "SDK request succeeded: ${result?.javaClass?.simpleName}")
if (!completed) {
completed = true
cont.resume(result)
}
}
.onError { error ->
Log.e(TAG, "SDK request failed: code=${error.code} http=${error.httpCode} msg=${error.message}")
if (!completed) {
completed = true
cont.resumeWithException(Exception("SDK error ${error.code}: ${error.message}"))
}
}
// Pump messages on a background thread
Thread {
val timeout = System.currentTimeMillis() + 15_000 // 15s timeout
var msgCount = 0
val timeout = System.currentTimeMillis() + 15_000
while (!completed && System.currentTimeMillis() < timeout) {
try {
val msg = Core.popSDKMessage()
if (msg != null) {
msgCount++
Log.d(TAG, "Pumped message #$msgCount type=${msg.type} isError=${msg.isError}")
Request.handleMessage(msg)
}
if (msg != null) Request.handleMessage(msg)
} catch (e: Exception) {
Log.w(TAG, "Message pump error", e)
}
Thread.sleep(50)
}
Log.d(TAG, "Message pump done: completed=$completed msgs=$msgCount")
if (!completed) {
completed = true
cont.resumeWithException(Exception("Platform SDK request timed out after 15s (pumped $msgCount messages)"))
cont.resumeWithException(Exception("Platform SDK request timed out after 15s"))
}
}.start()
}

View File

@@ -1,19 +1,29 @@
package com.omixlab.lckcontrol.ui.navigation
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Circle
import androidx.compose.material.icons.filled.Dashboard
import androidx.compose.material.icons.filled.Devices
import androidx.compose.material.icons.filled.Person
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.NavigationBar
import androidx.compose.material3.NavigationBarItem
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.unit.dp
import androidx.navigation.NavGraph.Companion.findStartDestination
import androidx.navigation.NavType
import androidx.navigation.compose.NavHost
@@ -22,12 +32,14 @@ import androidx.navigation.compose.currentBackStackEntryAsState
import androidx.navigation.compose.rememberNavController
import androidx.navigation.navArgument
import com.omixlab.lckcontrol.data.local.TokenStore
import com.omixlab.lckcontrol.data.remote.LckApiService
import com.omixlab.lckcontrol.ui.accounts.AccountsScreen
import com.omixlab.lckcontrol.ui.clients.ActiveClientsScreen
import com.omixlab.lckcontrol.ui.dashboard.DashboardScreen
import com.omixlab.lckcontrol.ui.login.LoginScreen
import com.omixlab.lckcontrol.ui.plans.CreatePlanScreen
import com.omixlab.lckcontrol.ui.plans.PlanDetailScreen
import kotlinx.coroutines.delay
private data class BottomNavItem(
val screen: Screen,
@@ -42,7 +54,7 @@ private val bottomNavItems = listOf(
)
@Composable
fun AppNavigation(tokenStore: TokenStore) {
fun AppNavigation(tokenStore: TokenStore, apiService: LckApiService) {
val navController = rememberNavController()
val navBackStackEntry by navController.currentBackStackEntryAsState()
val currentRoute = navBackStackEntry?.destination?.route
@@ -50,13 +62,64 @@ fun AppNavigation(tokenStore: TokenStore) {
val showBottomBar = currentRoute in bottomNavItems.map { it.screen.route }
val startDestination = if (tokenStore.isLoggedIn()) Screen.Dashboard.route else Screen.Login.route
// Backend health state
var backendHealthy by remember { mutableStateOf<Boolean?>(null) }
// Poll backend health every 5 seconds
LaunchedEffect(Unit) {
while (true) {
backendHealthy = try {
apiService.healthCheck()
true
} catch (_: Exception) {
false
}
delay(5_000)
}
}
// Session validation on app open — if we think we're logged in, verify it
LaunchedEffect(Unit) {
if (tokenStore.isLoggedIn()) {
try {
apiService.getMe()
} catch (_: Exception) {
// AuthInterceptor will try refresh; if that also fails, session is invalid
if (!tokenStore.isLoggedIn()) {
navController.navigate(Screen.Login.route) {
popUpTo(0) { inclusive = true }
}
}
}
}
}
Scaffold(
bottomBar = {
if (showBottomBar) {
NavigationBar {
bottomNavItems.forEach { item ->
NavigationBarItem(
icon = { Icon(item.icon, contentDescription = item.label) },
icon = {
if (item.screen == Screen.Dashboard && backendHealthy != null) {
Box {
Icon(item.icon, contentDescription = item.label)
Icon(
Icons.Default.Circle,
contentDescription = if (backendHealthy == true) "Backend healthy" else "Backend unreachable",
tint = if (backendHealthy == true)
MaterialTheme.colorScheme.primary
else
MaterialTheme.colorScheme.error,
modifier = Modifier
.size(8.dp)
.align(Alignment.TopEnd),
)
}
} else {
Icon(item.icon, contentDescription = item.label)
}
},
label = { Text(item.label) },
selected = currentRoute == item.screen.route,
onClick = {
@@ -80,7 +143,13 @@ fun AppNavigation(tokenStore: TokenStore) {
modifier = Modifier.padding(innerPadding),
) {
composable(Screen.Login.route) {
LoginScreen()
LoginScreen(
onLoginSuccess = {
navController.navigate(Screen.Dashboard.route) {
popUpTo(Screen.Login.route) { inclusive = true }
}
},
)
}
composable(Screen.Dashboard.route) {
DashboardScreen(

View File

@@ -41,6 +41,7 @@ import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp
import androidx.hilt.navigation.compose.hiltViewModel
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.omixlab.lckcontrol.shared.LinkedAccount
@OptIn(ExperimentalMaterial3Api::class)
@Composable
@@ -112,7 +113,7 @@ fun CreatePlanScreen(
itemsIndexed(destinations) { index, dest ->
DestinationCard(
destination = dest,
availableServices = linkedAccounts.map { it.serviceId },
linkedAccounts = linkedAccounts,
onUpdate = { viewModel.updateDestination(index, it) },
onRemove = { viewModel.removeDestination(index) },
)
@@ -137,11 +138,11 @@ fun CreatePlanScreen(
@Composable
private fun DestinationCard(
destination: DestinationInput,
availableServices: List<String>,
linkedAccounts: List<LinkedAccount>,
onUpdate: (DestinationInput) -> Unit,
onRemove: () -> Unit,
) {
var serviceExpanded by remember { mutableStateOf(false) }
var accountExpanded by remember { mutableStateOf(false) }
var privacyExpanded by remember { mutableStateOf(false) }
ElevatedCard(modifier = Modifier.fillMaxWidth()) {
@@ -159,31 +160,35 @@ private fun DestinationCard(
}
}
// Service picker
// Account picker (shows "YouTube - DisplayName" per account)
ExposedDropdownMenuBox(
expanded = serviceExpanded,
onExpandedChange = { serviceExpanded = it },
expanded = accountExpanded,
onExpandedChange = { accountExpanded = it },
) {
OutlinedTextField(
value = destination.service,
value = destination.linkedAccountLabel,
onValueChange = {},
readOnly = true,
label = { Text("Service") },
trailingIcon = { ExposedDropdownMenuDefaults.TrailingIcon(serviceExpanded) },
label = { Text("Account") },
trailingIcon = { ExposedDropdownMenuDefaults.TrailingIcon(accountExpanded) },
modifier = Modifier
.fillMaxWidth()
.menuAnchor(MenuAnchorType.PrimaryNotEditable),
)
ExposedDropdownMenu(
expanded = serviceExpanded,
onDismissRequest = { serviceExpanded = false },
expanded = accountExpanded,
onDismissRequest = { accountExpanded = false },
) {
availableServices.forEach { service ->
linkedAccounts.forEach { account ->
val label = "${account.serviceId} - ${account.displayName}"
DropdownMenuItem(
text = { Text(service) },
text = { Text(label) },
onClick = {
onUpdate(destination.copy(service = service))
serviceExpanded = false
onUpdate(destination.copy(
linkedAccountId = account.id,
linkedAccountLabel = label,
))
accountExpanded = false
},
)
}

View File

@@ -16,7 +16,8 @@ import kotlinx.coroutines.launch
import javax.inject.Inject
data class DestinationInput(
val service: String = "",
val linkedAccountId: String = "",
val linkedAccountLabel: String = "",
val title: String = "",
val description: String = "",
val privacyStatus: String = "public",
@@ -77,8 +78,8 @@ class CreatePlanViewModel @Inject constructor(
_error.value = "Add at least one destination"
return
}
if (dests.any { it.service.isBlank() || it.title.isBlank() }) {
_error.value = "All destinations need a service and title"
if (dests.any { it.linkedAccountId.isBlank() || it.title.isBlank() }) {
_error.value = "All destinations need an account and title"
return
}
@@ -86,9 +87,12 @@ class CreatePlanViewModel @Inject constructor(
_isCreating.value = true
_error.value = null
try {
val accounts = linkedAccounts.value
val streamDests = dests.map { input ->
val account = accounts.find { it.id == input.linkedAccountId }
StreamDestination(
service = input.service,
service = account?.serviceId ?: "",
linkedAccountId = input.linkedAccountId,
title = input.title,
description = input.description,
privacyStatus = input.privacyStatus,

View File

@@ -70,7 +70,7 @@ fun PlanDetailScreen(
}
},
actions = {
if (plan?.status == "DRAFT" || plan?.status == "ENDED") {
if (plan?.status != "LIVE") {
IconButton(onClick = { viewModel.deletePlan(onBack) }) {
Icon(Icons.Default.Delete, contentDescription = "Delete")
}

View File

@@ -25,6 +25,13 @@ class PlanDetailViewModel @Inject constructor(
val plan: StateFlow<StreamPlan?> = streamPlanRepository.observePlan(planId)
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), null)
init {
// Sync fresh state from backend
viewModelScope.launch {
try { streamPlanRepository.syncPlans() } catch (_: Exception) {}
}
}
private val _isLoading = MutableStateFlow(false)
val isLoading: StateFlow<Boolean> = _isLoading.asStateFlow()