diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index 6df87a4..e689234 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -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" /> = 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 { + 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 awaitWithPump( + block: () -> Request, + ): 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() diff --git a/app/src/main/java/com/omixlab/lckcontrol/ui/clients/ActiveClientsScreen.kt b/app/src/main/java/com/omixlab/lckcontrol/ui/clients/ActiveClientsScreen.kt index 191a457..ff067ac 100644 --- a/app/src/main/java/com/omixlab/lckcontrol/ui/clients/ActiveClientsScreen.kt +++ b/app/src/main/java/com/omixlab/lckcontrol/ui/clients/ActiveClientsScreen.kt @@ -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 - }, - ) } } } diff --git a/app/src/main/java/com/omixlab/lckcontrol/ui/clients/ActiveClientsViewModel.kt b/app/src/main/java/com/omixlab/lckcontrol/ui/clients/ActiveClientsViewModel.kt index d3c138b..cc637b1 100644 --- a/app/src/main/java/com/omixlab/lckcontrol/ui/clients/ActiveClientsViewModel.kt +++ b/app/src/main/java/com/omixlab/lckcontrol/ui/clients/ActiveClientsViewModel.kt @@ -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>(emptyList()) - val clients: StateFlow> = _clients.asStateFlow() + private val _clients = MutableStateFlow>(emptyList()) + val clients: StateFlow> = _clients.asStateFlow() private val _isConnected = MutableStateFlow(false) val isConnected: StateFlow = _isConnected.asStateFlow() private val callback = object : ILckControlCallback.Stub() { - override fun onStreamPlansChanged(plans: List) { - refreshClients() - } + override fun onStreamPlansChanged(plans: List) {} - 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, -) diff --git a/app/src/main/java/com/omixlab/lckcontrol/ui/dashboard/DashboardViewModel.kt b/app/src/main/java/com/omixlab/lckcontrol/ui/dashboard/DashboardViewModel.kt index 4ec5c9a..f04be70 100644 --- a/app/src/main/java/com/omixlab/lckcontrol/ui/dashboard/DashboardViewModel.kt +++ b/app/src/main/java/com/omixlab/lckcontrol/ui/dashboard/DashboardViewModel.kt @@ -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> = accountRepository.observeAccounts() @@ -23,4 +24,10 @@ class DashboardViewModel @Inject constructor( val plans: StateFlow> = streamPlanRepository.observePlans() .stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), emptyList()) + + init { + viewModelScope.launch { + try { streamPlanRepository.syncPlans() } catch (_: Exception) {} + } + } } diff --git a/app/src/main/java/com/omixlab/lckcontrol/ui/login/LoginScreen.kt b/app/src/main/java/com/omixlab/lckcontrol/ui/login/LoginScreen.kt index d8c49ab..3f76482 100644 --- a/app/src/main/java/com/omixlab/lckcontrol/ui/login/LoginScreen.kt +++ b/app/src/main/java/com/omixlab/lckcontrol/ui/login/LoginScreen.kt @@ -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() diff --git a/app/src/main/java/com/omixlab/lckcontrol/ui/login/LoginViewModel.kt b/app/src/main/java/com/omixlab/lckcontrol/ui/login/LoginViewModel.kt index 2800a1d..8215424 100644 --- a/app/src/main/java/com/omixlab/lckcontrol/ui/login/LoginViewModel.kt +++ b/app/src/main/java/com/omixlab/lckcontrol/ui/login/LoginViewModel.kt @@ -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(null) val error: StateFlow = _error.asStateFlow() + private val _loginSuccess = MutableStateFlow(false) + val loginSuccess: StateFlow = _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 awaitWithPump( block: () -> Request, ): 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() } diff --git a/app/src/main/java/com/omixlab/lckcontrol/ui/navigation/AppNavigation.kt b/app/src/main/java/com/omixlab/lckcontrol/ui/navigation/AppNavigation.kt index 6beebd9..517bbb5 100644 --- a/app/src/main/java/com/omixlab/lckcontrol/ui/navigation/AppNavigation.kt +++ b/app/src/main/java/com/omixlab/lckcontrol/ui/navigation/AppNavigation.kt @@ -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(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( diff --git a/app/src/main/java/com/omixlab/lckcontrol/ui/plans/CreatePlanScreen.kt b/app/src/main/java/com/omixlab/lckcontrol/ui/plans/CreatePlanScreen.kt index 0a243c7..468249b 100644 --- a/app/src/main/java/com/omixlab/lckcontrol/ui/plans/CreatePlanScreen.kt +++ b/app/src/main/java/com/omixlab/lckcontrol/ui/plans/CreatePlanScreen.kt @@ -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, + linkedAccounts: List, 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 }, ) } diff --git a/app/src/main/java/com/omixlab/lckcontrol/ui/plans/CreatePlanViewModel.kt b/app/src/main/java/com/omixlab/lckcontrol/ui/plans/CreatePlanViewModel.kt index 6320fb1..47da63d 100644 --- a/app/src/main/java/com/omixlab/lckcontrol/ui/plans/CreatePlanViewModel.kt +++ b/app/src/main/java/com/omixlab/lckcontrol/ui/plans/CreatePlanViewModel.kt @@ -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, diff --git a/app/src/main/java/com/omixlab/lckcontrol/ui/plans/PlanDetailScreen.kt b/app/src/main/java/com/omixlab/lckcontrol/ui/plans/PlanDetailScreen.kt index 620e0b5..185dad5 100644 --- a/app/src/main/java/com/omixlab/lckcontrol/ui/plans/PlanDetailScreen.kt +++ b/app/src/main/java/com/omixlab/lckcontrol/ui/plans/PlanDetailScreen.kt @@ -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") } diff --git a/app/src/main/java/com/omixlab/lckcontrol/ui/plans/PlanDetailViewModel.kt b/app/src/main/java/com/omixlab/lckcontrol/ui/plans/PlanDetailViewModel.kt index bfceccb..4fe4634 100644 --- a/app/src/main/java/com/omixlab/lckcontrol/ui/plans/PlanDetailViewModel.kt +++ b/app/src/main/java/com/omixlab/lckcontrol/ui/plans/PlanDetailViewModel.kt @@ -25,6 +25,13 @@ class PlanDetailViewModel @Inject constructor( val plan: StateFlow = 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 = _isLoading.asStateFlow() diff --git a/sdk/src/main/java/com/omixlab/lckcontrol/sdk/LckControlClient.kt b/sdk/src/main/java/com/omixlab/lckcontrol/sdk/LckControlClient.kt index 43bd8a3..4f56020 100644 --- a/sdk/src/main/java/com/omixlab/lckcontrol/sdk/LckControlClient.kt +++ b/sdk/src/main/java/com/omixlab/lckcontrol/sdk/LckControlClient.kt @@ -5,6 +5,7 @@ import android.content.Context import android.content.Intent import android.content.ServiceConnection import android.os.IBinder +import com.omixlab.lckcontrol.shared.ConnectedClientInfo import com.omixlab.lckcontrol.shared.ILckControlCallback import com.omixlab.lckcontrol.shared.ILckControlService import com.omixlab.lckcontrol.shared.LinkedAccount @@ -28,6 +29,9 @@ class LckControlClient(private val context: Context) { private val _connected = MutableStateFlow(false) val connected: StateFlow = _connected.asStateFlow() + private val _authenticated = MutableStateFlow(false) + val authenticated: StateFlow = _authenticated.asStateFlow() + private val _streamPlans = MutableStateFlow>(emptyList()) val streamPlans: StateFlow> = _streamPlans.asStateFlow() @@ -44,6 +48,10 @@ class LckControlClient(private val context: Context) { override fun onClientRegistered(id: String) {} override fun onClientUnregistered(id: String) {} + + override fun onAuthStateChanged(authenticated: Boolean) { + _authenticated.value = authenticated + } } private val connection = object : ServiceConnection { @@ -51,12 +59,14 @@ class LckControlClient(private val context: Context) { service = ILckControlService.Stub.asInterface(binder) service?.registerCallback(callback) _connected.value = true + _authenticated.value = service?.isAuthenticated ?: false } override fun onServiceDisconnected(name: ComponentName?) { service = null clientId = null _connected.value = false + _authenticated.value = false } } @@ -80,18 +90,39 @@ class LckControlClient(private val context: Context) { service = null clientId = null _connected.value = false + _authenticated.value = false } + // ── Auth ──────────────────────────────────────────── + + fun isAuthenticated(): Boolean { + return service?.isAuthenticated ?: false + } + + fun login() { + service?.login() + } + + // ── Client registration ───────────────────────────── + fun registerAsClient(clientName: String, packageName: String): String? { val id = service?.registerClient(clientName, packageName) clientId = id return id } + fun getConnectedClients(): List { + return service?.connectedClients ?: emptyList() + } + + // ── Accounts ──────────────────────────────────────── + fun getLinkedAccounts(): List { return service?.linkedAccounts ?: emptyList() } + // ── Stream plans ──────────────────────────────────── + fun getStreamPlans(): List { return service?.streamPlans ?: emptyList() } @@ -104,6 +135,10 @@ class LckControlClient(private val context: Context) { return service?.createStreamPlan(config) } + fun createDefaultPlan(clientName: String): StreamPlan? { + return service?.createDefaultPlan(clientName) + } + fun prepareStreamPlan(planId: String): StreamPlan? { return service?.prepareStreamPlan(planId) } diff --git a/shared/src/main/aidl/com/omixlab/lckcontrol/shared/ConnectedClientInfo.aidl b/shared/src/main/aidl/com/omixlab/lckcontrol/shared/ConnectedClientInfo.aidl new file mode 100644 index 0000000..71856f9 --- /dev/null +++ b/shared/src/main/aidl/com/omixlab/lckcontrol/shared/ConnectedClientInfo.aidl @@ -0,0 +1,3 @@ +package com.omixlab.lckcontrol.shared; + +parcelable ConnectedClientInfo; diff --git a/shared/src/main/aidl/com/omixlab/lckcontrol/shared/ILckControlCallback.aidl b/shared/src/main/aidl/com/omixlab/lckcontrol/shared/ILckControlCallback.aidl index a940b12..e121b0c 100644 --- a/shared/src/main/aidl/com/omixlab/lckcontrol/shared/ILckControlCallback.aidl +++ b/shared/src/main/aidl/com/omixlab/lckcontrol/shared/ILckControlCallback.aidl @@ -7,4 +7,5 @@ interface ILckControlCallback { void onStreamPlanUpdated(in StreamPlan plan); void onClientRegistered(String clientId); void onClientUnregistered(String clientId); + void onAuthStateChanged(boolean authenticated); } diff --git a/shared/src/main/aidl/com/omixlab/lckcontrol/shared/ILckControlService.aidl b/shared/src/main/aidl/com/omixlab/lckcontrol/shared/ILckControlService.aidl index 2a2b70c..30a5742 100644 --- a/shared/src/main/aidl/com/omixlab/lckcontrol/shared/ILckControlService.aidl +++ b/shared/src/main/aidl/com/omixlab/lckcontrol/shared/ILckControlService.aidl @@ -1,21 +1,35 @@ package com.omixlab.lckcontrol.shared; +import com.omixlab.lckcontrol.shared.ConnectedClientInfo; import com.omixlab.lckcontrol.shared.LinkedAccount; import com.omixlab.lckcontrol.shared.StreamPlan; import com.omixlab.lckcontrol.shared.StreamPlanConfig; import com.omixlab.lckcontrol.shared.ILckControlCallback; interface ILckControlService { + // Auth + boolean isAuthenticated(); + void login(); + + // Accounts List getLinkedAccounts(); + + // Stream plans StreamPlan createStreamPlan(in StreamPlanConfig config); + StreamPlan createDefaultPlan(String clientName); StreamPlan prepareStreamPlan(String planId); List getStreamPlans(); StreamPlan getStreamPlan(String planId); boolean startStreamPlan(String planId); boolean endStreamPlan(String planId); + + // Clients String registerClient(String clientName, String packageName); void unregisterClient(String clientId); void setClientActivePlan(String clientId, String planId); + List getConnectedClients(); + + // Callbacks void registerCallback(ILckControlCallback callback); void unregisterCallback(ILckControlCallback callback); } diff --git a/shared/src/main/java/com/omixlab/lckcontrol/shared/ConnectedClientInfo.kt b/shared/src/main/java/com/omixlab/lckcontrol/shared/ConnectedClientInfo.kt new file mode 100644 index 0000000..46e0bd5 --- /dev/null +++ b/shared/src/main/java/com/omixlab/lckcontrol/shared/ConnectedClientInfo.kt @@ -0,0 +1,33 @@ +package com.omixlab.lckcontrol.shared + +import android.os.Parcel +import android.os.Parcelable + +data class ConnectedClientInfo( + val clientId: String, + val clientName: String, + val packageName: String, + val activePlanId: String? = null, +) : Parcelable { + + constructor(parcel: Parcel) : this( + clientId = parcel.readString()!!, + clientName = parcel.readString()!!, + packageName = parcel.readString()!!, + activePlanId = parcel.readString(), + ) + + override fun writeToParcel(parcel: Parcel, flags: Int) { + parcel.writeString(clientId) + parcel.writeString(clientName) + parcel.writeString(packageName) + parcel.writeString(activePlanId) + } + + override fun describeContents(): Int = 0 + + companion object CREATOR : Parcelable.Creator { + override fun createFromParcel(parcel: Parcel) = ConnectedClientInfo(parcel) + override fun newArray(size: Int) = arrayOfNulls(size) + } +}