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:
@@ -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"
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
|
||||
@@ -4,6 +4,11 @@ import retrofit2.http.*
|
||||
|
||||
interface LckApiService {
|
||||
|
||||
// ── Health ────────────────────────────────────────────
|
||||
|
||||
@GET("health")
|
||||
suspend fun healthCheck(): HealthResponse
|
||||
|
||||
// ── Auth ─────────────────────────────────────────────
|
||||
|
||||
@POST("auth/meta/callback")
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
|
||||
@@ -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) {}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
|
||||
|
||||
@@ -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<Boolean> = _connected.asStateFlow()
|
||||
|
||||
private val _authenticated = MutableStateFlow(false)
|
||||
val authenticated: StateFlow<Boolean> = _authenticated.asStateFlow()
|
||||
|
||||
private val _streamPlans = MutableStateFlow<List<StreamPlan>>(emptyList())
|
||||
val streamPlans: StateFlow<List<StreamPlan>> = _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<ConnectedClientInfo> {
|
||||
return service?.connectedClients ?: emptyList()
|
||||
}
|
||||
|
||||
// ── Accounts ────────────────────────────────────────
|
||||
|
||||
fun getLinkedAccounts(): List<LinkedAccount> {
|
||||
return service?.linkedAccounts ?: emptyList()
|
||||
}
|
||||
|
||||
// ── Stream plans ────────────────────────────────────
|
||||
|
||||
fun getStreamPlans(): List<StreamPlan> {
|
||||
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)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,3 @@
|
||||
package com.omixlab.lckcontrol.shared;
|
||||
|
||||
parcelable ConnectedClientInfo;
|
||||
@@ -7,4 +7,5 @@ interface ILckControlCallback {
|
||||
void onStreamPlanUpdated(in StreamPlan plan);
|
||||
void onClientRegistered(String clientId);
|
||||
void onClientUnregistered(String clientId);
|
||||
void onAuthStateChanged(boolean authenticated);
|
||||
}
|
||||
|
||||
@@ -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<LinkedAccount> getLinkedAccounts();
|
||||
|
||||
// Stream plans
|
||||
StreamPlan createStreamPlan(in StreamPlanConfig config);
|
||||
StreamPlan createDefaultPlan(String clientName);
|
||||
StreamPlan prepareStreamPlan(String planId);
|
||||
List<StreamPlan> 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<ConnectedClientInfo> getConnectedClients();
|
||||
|
||||
// Callbacks
|
||||
void registerCallback(ILckControlCallback callback);
|
||||
void unregisterCallback(ILckControlCallback callback);
|
||||
}
|
||||
|
||||
@@ -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<ConnectedClientInfo> {
|
||||
override fun createFromParcel(parcel: Parcel) = ConnectedClientInfo(parcel)
|
||||
override fun newArray(size: Int) = arrayOfNulls<ConnectedClientInfo>(size)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user