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:name="com.omixlab.lckcontrol.permission.USE_LCK_CONTROL"
|
||||||
android:label="Access LCK Control Service"
|
android:label="Access LCK Control Service"
|
||||||
android:description="@string/permission_use_lck_control_desc"
|
android:description="@string/permission_use_lck_control_desc"
|
||||||
android:protectionLevel="dangerous" />
|
android:protectionLevel="normal" />
|
||||||
|
|
||||||
<application
|
<application
|
||||||
android:name=".LckControlApp"
|
android:name=".LckControlApp"
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import androidx.activity.ComponentActivity
|
|||||||
import androidx.activity.compose.setContent
|
import androidx.activity.compose.setContent
|
||||||
import androidx.activity.enableEdgeToEdge
|
import androidx.activity.enableEdgeToEdge
|
||||||
import com.omixlab.lckcontrol.data.local.TokenStore
|
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.navigation.AppNavigation
|
||||||
import com.omixlab.lckcontrol.ui.theme.LCKControlTheme
|
import com.omixlab.lckcontrol.ui.theme.LCKControlTheme
|
||||||
import dagger.hilt.android.AndroidEntryPoint
|
import dagger.hilt.android.AndroidEntryPoint
|
||||||
@@ -14,13 +15,14 @@ import javax.inject.Inject
|
|||||||
class MainActivity : ComponentActivity() {
|
class MainActivity : ComponentActivity() {
|
||||||
|
|
||||||
@Inject lateinit var tokenStore: TokenStore
|
@Inject lateinit var tokenStore: TokenStore
|
||||||
|
@Inject lateinit var apiService: LckApiService
|
||||||
|
|
||||||
override fun onCreate(savedInstanceState: Bundle?) {
|
override fun onCreate(savedInstanceState: Bundle?) {
|
||||||
super.onCreate(savedInstanceState)
|
super.onCreate(savedInstanceState)
|
||||||
enableEdgeToEdge()
|
enableEdgeToEdge()
|
||||||
setContent {
|
setContent {
|
||||||
LCKControlTheme {
|
LCKControlTheme {
|
||||||
AppNavigation(tokenStore)
|
AppNavigation(tokenStore, apiService)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,40 +2,39 @@ package com.omixlab.lckcontrol.auth
|
|||||||
|
|
||||||
import android.content.Intent
|
import android.content.Intent
|
||||||
import android.os.Bundle
|
import android.os.Bundle
|
||||||
|
import android.util.Log
|
||||||
import androidx.activity.ComponentActivity
|
import androidx.activity.ComponentActivity
|
||||||
|
import androidx.lifecycle.lifecycleScope
|
||||||
import com.omixlab.lckcontrol.MainActivity
|
import com.omixlab.lckcontrol.MainActivity
|
||||||
import com.omixlab.lckcontrol.data.remote.LckApiService
|
import com.omixlab.lckcontrol.data.repository.AccountRepository
|
||||||
import com.omixlab.lckcontrol.data.remote.ProviderCallbackRequest
|
|
||||||
import dagger.hilt.android.AndroidEntryPoint
|
import dagger.hilt.android.AndroidEntryPoint
|
||||||
import kotlinx.coroutines.CoroutineScope
|
|
||||||
import kotlinx.coroutines.Dispatchers
|
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
import javax.inject.Inject
|
import javax.inject.Inject
|
||||||
|
|
||||||
@AndroidEntryPoint
|
@AndroidEntryPoint
|
||||||
class TwitchAuthRedirectActivity : ComponentActivity() {
|
class TwitchAuthRedirectActivity : ComponentActivity() {
|
||||||
|
|
||||||
@Inject lateinit var apiService: LckApiService
|
@Inject lateinit var accountRepository: AccountRepository
|
||||||
|
|
||||||
override fun onCreate(savedInstanceState: Bundle?) {
|
override fun onCreate(savedInstanceState: Bundle?) {
|
||||||
super.onCreate(savedInstanceState)
|
super.onCreate(savedInstanceState)
|
||||||
|
|
||||||
val code = intent?.data?.getQueryParameter("code")
|
val code = intent?.data?.getQueryParameter("code")
|
||||||
val state = intent?.data?.getQueryParameter("state")
|
val state = intent?.data?.getQueryParameter("state")
|
||||||
if (code != null && state != null) {
|
|
||||||
CoroutineScope(Dispatchers.IO).launch {
|
lifecycleScope.launch {
|
||||||
|
if (code != null && state != null) {
|
||||||
try {
|
try {
|
||||||
apiService.twitchCallback(ProviderCallbackRequest(code = code, state = state))
|
accountRepository.handleTwitchCallback(code, state)
|
||||||
} catch (e: Exception) {
|
} 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.content.Intent
|
||||||
import android.os.Bundle
|
import android.os.Bundle
|
||||||
|
import android.util.Log
|
||||||
import androidx.activity.ComponentActivity
|
import androidx.activity.ComponentActivity
|
||||||
|
import androidx.lifecycle.lifecycleScope
|
||||||
import com.omixlab.lckcontrol.MainActivity
|
import com.omixlab.lckcontrol.MainActivity
|
||||||
import com.omixlab.lckcontrol.data.remote.LckApiService
|
import com.omixlab.lckcontrol.data.repository.AccountRepository
|
||||||
import com.omixlab.lckcontrol.data.remote.ProviderCallbackRequest
|
|
||||||
import dagger.hilt.android.AndroidEntryPoint
|
import dagger.hilt.android.AndroidEntryPoint
|
||||||
import kotlinx.coroutines.CoroutineScope
|
|
||||||
import kotlinx.coroutines.Dispatchers
|
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
import javax.inject.Inject
|
import javax.inject.Inject
|
||||||
|
|
||||||
@AndroidEntryPoint
|
@AndroidEntryPoint
|
||||||
class YouTubeAuthRedirectActivity : ComponentActivity() {
|
class YouTubeAuthRedirectActivity : ComponentActivity() {
|
||||||
|
|
||||||
@Inject lateinit var apiService: LckApiService
|
@Inject lateinit var accountRepository: AccountRepository
|
||||||
|
|
||||||
override fun onCreate(savedInstanceState: Bundle?) {
|
override fun onCreate(savedInstanceState: Bundle?) {
|
||||||
super.onCreate(savedInstanceState)
|
super.onCreate(savedInstanceState)
|
||||||
|
|
||||||
val code = intent?.data?.getQueryParameter("code")
|
val code = intent?.data?.getQueryParameter("code")
|
||||||
val state = intent?.data?.getQueryParameter("state")
|
val state = intent?.data?.getQueryParameter("state")
|
||||||
if (code != null && state != null) {
|
|
||||||
CoroutineScope(Dispatchers.IO).launch {
|
lifecycleScope.launch {
|
||||||
|
if (code != null && state != null) {
|
||||||
try {
|
try {
|
||||||
apiService.youtubeCallback(ProviderCallbackRequest(code = code, state = state))
|
accountRepository.handleYouTubeCallback(code, state)
|
||||||
} catch (e: Exception) {
|
} 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
|
import com.squareup.moshi.JsonClass
|
||||||
|
|
||||||
|
// ── Health ───────────────────────────────────────────────
|
||||||
|
|
||||||
|
@JsonClass(generateAdapter = true)
|
||||||
|
data class HealthResponse(
|
||||||
|
val status: String,
|
||||||
|
val timestamp: String,
|
||||||
|
)
|
||||||
|
|
||||||
// ── Auth ─────────────────────────────────────────────────
|
// ── Auth ─────────────────────────────────────────────────
|
||||||
|
|
||||||
@JsonClass(generateAdapter = true)
|
@JsonClass(generateAdapter = true)
|
||||||
|
|||||||
@@ -18,7 +18,7 @@ class AuthInterceptor @Inject constructor(
|
|||||||
|
|
||||||
// Don't add auth header to auth endpoints (login, refresh)
|
// Don't add auth header to auth endpoints (login, refresh)
|
||||||
val path = original.url.encodedPath
|
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)
|
return chain.proceed(original)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -4,6 +4,11 @@ import retrofit2.http.*
|
|||||||
|
|
||||||
interface LckApiService {
|
interface LckApiService {
|
||||||
|
|
||||||
|
// ── Health ────────────────────────────────────────────
|
||||||
|
|
||||||
|
@GET("health")
|
||||||
|
suspend fun healthCheck(): HealthResponse
|
||||||
|
|
||||||
// ── Auth ─────────────────────────────────────────────
|
// ── Auth ─────────────────────────────────────────────
|
||||||
|
|
||||||
@POST("auth/meta/callback")
|
@POST("auth/meta/callback")
|
||||||
|
|||||||
@@ -18,8 +18,7 @@ import javax.inject.Singleton
|
|||||||
@InstallIn(SingletonComponent::class)
|
@InstallIn(SingletonComponent::class)
|
||||||
object AppModule {
|
object AppModule {
|
||||||
|
|
||||||
// TODO: Set from BuildConfig or remote config
|
private const val BASE_URL = "https://lck.omigame.dev/"
|
||||||
private const val BASE_URL = "http://192.168.1.60:3100/"
|
|
||||||
|
|
||||||
@Provides
|
@Provides
|
||||||
@Singleton
|
@Singleton
|
||||||
|
|||||||
@@ -8,12 +8,22 @@ import android.content.Intent
|
|||||||
import android.content.pm.ServiceInfo
|
import android.content.pm.ServiceInfo
|
||||||
import android.os.IBinder
|
import android.os.IBinder
|
||||||
import android.os.RemoteCallbackList
|
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.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.AccountRepository
|
||||||
import com.omixlab.lckcontrol.data.repository.StreamPlanRepository
|
import com.omixlab.lckcontrol.data.repository.StreamPlanRepository
|
||||||
|
import com.omixlab.lckcontrol.shared.ConnectedClientInfo
|
||||||
import com.omixlab.lckcontrol.shared.ILckControlCallback
|
import com.omixlab.lckcontrol.shared.ILckControlCallback
|
||||||
import com.omixlab.lckcontrol.shared.ILckControlService
|
import com.omixlab.lckcontrol.shared.ILckControlService
|
||||||
import com.omixlab.lckcontrol.shared.LinkedAccount
|
import com.omixlab.lckcontrol.shared.LinkedAccount
|
||||||
|
import com.omixlab.lckcontrol.shared.StreamDestination
|
||||||
import com.omixlab.lckcontrol.shared.StreamPlan
|
import com.omixlab.lckcontrol.shared.StreamPlan
|
||||||
import com.omixlab.lckcontrol.shared.StreamPlanConfig
|
import com.omixlab.lckcontrol.shared.StreamPlanConfig
|
||||||
import dagger.hilt.android.AndroidEntryPoint
|
import dagger.hilt.android.AndroidEntryPoint
|
||||||
@@ -21,20 +31,29 @@ import kotlinx.coroutines.CoroutineScope
|
|||||||
import kotlinx.coroutines.Dispatchers
|
import kotlinx.coroutines.Dispatchers
|
||||||
import kotlinx.coroutines.SupervisorJob
|
import kotlinx.coroutines.SupervisorJob
|
||||||
import kotlinx.coroutines.cancel
|
import kotlinx.coroutines.cancel
|
||||||
|
import kotlinx.coroutines.delay
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
import kotlinx.coroutines.runBlocking
|
import kotlinx.coroutines.runBlocking
|
||||||
import javax.inject.Inject
|
import javax.inject.Inject
|
||||||
|
import kotlin.coroutines.resume
|
||||||
|
import kotlin.coroutines.resumeWithException
|
||||||
|
import kotlin.coroutines.suspendCoroutine
|
||||||
|
|
||||||
@AndroidEntryPoint
|
@AndroidEntryPoint
|
||||||
class LckControlService : Service() {
|
class LckControlService : Service() {
|
||||||
|
|
||||||
companion object {
|
companion object {
|
||||||
|
private const val TAG = "LckControlService"
|
||||||
private const val CHANNEL_ID = "lck_control_service"
|
private const val CHANNEL_ID = "lck_control_service"
|
||||||
private const val NOTIFICATION_ID = 1
|
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 accountRepository: AccountRepository
|
||||||
@Inject lateinit var streamPlanRepository: StreamPlanRepository
|
@Inject lateinit var streamPlanRepository: StreamPlanRepository
|
||||||
|
@Inject lateinit var tokenStore: TokenStore
|
||||||
|
@Inject lateinit var apiService: LckApiService
|
||||||
|
|
||||||
private val serviceScope = CoroutineScope(SupervisorJob() + Dispatchers.Main)
|
private val serviceScope = CoroutineScope(SupervisorJob() + Dispatchers.Main)
|
||||||
private val clientTracker = ClientTracker()
|
private val clientTracker = ClientTracker()
|
||||||
@@ -42,16 +61,49 @@ class LckControlService : Service() {
|
|||||||
|
|
||||||
private val binder = object : ILckControlService.Stub() {
|
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 {
|
override fun getLinkedAccounts(): List<LinkedAccount> = runBlocking {
|
||||||
accountRepository.getAccounts()
|
accountRepository.getAccounts()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── Stream plans ────────────────────────────────────
|
||||||
|
|
||||||
override fun createStreamPlan(config: StreamPlanConfig): StreamPlan = runBlocking {
|
override fun createStreamPlan(config: StreamPlanConfig): StreamPlan = runBlocking {
|
||||||
val plan = streamPlanRepository.createPlan(config.name, config.destinations)
|
val plan = streamPlanRepository.createPlan(config.name, config.destinations)
|
||||||
broadcastPlansChanged()
|
broadcastPlansChanged()
|
||||||
plan
|
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 {
|
override fun prepareStreamPlan(planId: String): StreamPlan = runBlocking {
|
||||||
streamPlanRepository.preparePlan(planId)
|
streamPlanRepository.preparePlan(planId)
|
||||||
val plan = streamPlanRepository.getPlan(planId) ?: error("Plan not found after prepare")
|
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 {
|
override fun startStreamPlan(planId: String): Boolean = runBlocking {
|
||||||
val plan = streamPlanRepository.getPlan(planId) ?: return@runBlocking false
|
val plan = streamPlanRepository.getPlan(planId) ?: return@runBlocking false
|
||||||
|
if (plan.status == "LIVE") return@runBlocking true
|
||||||
if (plan.status != "READY") return@runBlocking false
|
if (plan.status != "READY") return@runBlocking false
|
||||||
streamPlanRepository.startPlan(planId)
|
try {
|
||||||
val updated = streamPlanRepository.getPlan(planId)
|
streamPlanRepository.startPlan(planId)
|
||||||
if (updated != null) broadcastPlanUpdated(updated)
|
val updated = streamPlanRepository.getPlan(planId)
|
||||||
true
|
if (updated != null) broadcastPlanUpdated(updated)
|
||||||
|
true
|
||||||
|
} catch (_: Exception) { false }
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun endStreamPlan(planId: String): Boolean = runBlocking {
|
override fun endStreamPlan(planId: String): Boolean = runBlocking {
|
||||||
val plan = streamPlanRepository.getPlan(planId) ?: return@runBlocking false
|
val plan = streamPlanRepository.getPlan(planId) ?: return@runBlocking false
|
||||||
if (plan.status != "LIVE") return@runBlocking false
|
if (plan.status == "ENDED") return@runBlocking true
|
||||||
streamPlanRepository.endPlan(planId)
|
if (plan.status != "LIVE" && plan.status != "READY") return@runBlocking false
|
||||||
val updated = streamPlanRepository.getPlan(planId)
|
try {
|
||||||
if (updated != null) broadcastPlanUpdated(updated)
|
streamPlanRepository.endPlan(planId)
|
||||||
true
|
val updated = streamPlanRepository.getPlan(planId)
|
||||||
|
if (updated != null) broadcastPlanUpdated(updated)
|
||||||
|
true
|
||||||
|
} catch (_: Exception) { false }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── Clients ─────────────────────────────────────────
|
||||||
|
|
||||||
override fun registerClient(clientName: String, packageName: String): String {
|
override fun registerClient(clientName: String, packageName: String): String {
|
||||||
val clientId = clientTracker.register(clientName, packageName)
|
val clientId = clientTracker.register(clientName, packageName)
|
||||||
broadcastClientRegistered(clientId)
|
broadcastClientRegistered(clientId)
|
||||||
@@ -100,6 +160,19 @@ class LckControlService : Service() {
|
|||||||
clientTracker.setActivePlan(clientId, planId)
|
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) {
|
override fun registerCallback(callback: ILckControlCallback) {
|
||||||
callbacks.register(callback)
|
callbacks.register(callback)
|
||||||
}
|
}
|
||||||
@@ -117,6 +190,25 @@ class LckControlService : Service() {
|
|||||||
buildNotification(),
|
buildNotification(),
|
||||||
ServiceInfo.FOREGROUND_SERVICE_TYPE_CONNECTED_DEVICE,
|
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
|
override fun onBind(intent: Intent?): IBinder = binder
|
||||||
@@ -127,6 +219,119 @@ class LckControlService : Service() {
|
|||||||
super.onDestroy()
|
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() {
|
private fun createNotificationChannel() {
|
||||||
val channel = NotificationChannel(
|
val channel = NotificationChannel(
|
||||||
CHANNEL_ID,
|
CHANNEL_ID,
|
||||||
@@ -146,6 +351,21 @@ class LckControlService : Service() {
|
|||||||
.setOngoing(true)
|
.setOngoing(true)
|
||||||
.build()
|
.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() {
|
private fun broadcastPlansChanged() {
|
||||||
serviceScope.launch {
|
serviceScope.launch {
|
||||||
val plans = streamPlanRepository.getPlans()
|
val plans = streamPlanRepository.getPlans()
|
||||||
|
|||||||
@@ -18,6 +18,7 @@ import androidx.compose.material3.ElevatedCard
|
|||||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||||
import androidx.compose.material3.Icon
|
import androidx.compose.material3.Icon
|
||||||
import androidx.compose.material3.MaterialTheme
|
import androidx.compose.material3.MaterialTheme
|
||||||
|
import androidx.compose.material3.OutlinedButton
|
||||||
import androidx.compose.material3.Scaffold
|
import androidx.compose.material3.Scaffold
|
||||||
import androidx.compose.material3.Text
|
import androidx.compose.material3.Text
|
||||||
import androidx.compose.material3.TopAppBar
|
import androidx.compose.material3.TopAppBar
|
||||||
@@ -102,30 +103,41 @@ fun ActiveClientsScreen(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
items(clients, key = { it.planId }) { client ->
|
items(clients, key = { it.clientId }) { client ->
|
||||||
ElevatedCard(modifier = Modifier.fillMaxWidth()) {
|
ElevatedCard(modifier = Modifier.fillMaxWidth()) {
|
||||||
Row(
|
Column(
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.fillMaxWidth()
|
.fillMaxWidth()
|
||||||
.padding(16.dp),
|
.padding(16.dp),
|
||||||
verticalAlignment = Alignment.CenterVertically,
|
|
||||||
) {
|
) {
|
||||||
Column(modifier = Modifier.weight(1f)) {
|
Row(
|
||||||
Text(client.planName, style = MaterialTheme.typography.titleSmall)
|
modifier = Modifier.fillMaxWidth(),
|
||||||
Text(
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
"${client.destinationCount} destination(s)",
|
) {
|
||||||
style = MaterialTheme.typography.bodySmall,
|
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.content.ServiceConnection
|
||||||
import android.os.IBinder
|
import android.os.IBinder
|
||||||
import androidx.lifecycle.ViewModel
|
import androidx.lifecycle.ViewModel
|
||||||
import com.omixlab.lckcontrol.service.ClientTracker
|
import com.omixlab.lckcontrol.shared.ConnectedClientInfo
|
||||||
import com.omixlab.lckcontrol.service.ConnectedClient
|
|
||||||
import com.omixlab.lckcontrol.shared.ILckControlCallback
|
import com.omixlab.lckcontrol.shared.ILckControlCallback
|
||||||
import com.omixlab.lckcontrol.shared.ILckControlService
|
import com.omixlab.lckcontrol.shared.ILckControlService
|
||||||
import com.omixlab.lckcontrol.shared.StreamPlan
|
import com.omixlab.lckcontrol.shared.StreamPlan
|
||||||
@@ -25,20 +24,16 @@ class ActiveClientsViewModel @Inject constructor(
|
|||||||
|
|
||||||
private var service: ILckControlService? = null
|
private var service: ILckControlService? = null
|
||||||
|
|
||||||
private val _clients = MutableStateFlow<List<ClientInfo>>(emptyList())
|
private val _clients = MutableStateFlow<List<ConnectedClientInfo>>(emptyList())
|
||||||
val clients: StateFlow<List<ClientInfo>> = _clients.asStateFlow()
|
val clients: StateFlow<List<ConnectedClientInfo>> = _clients.asStateFlow()
|
||||||
|
|
||||||
private val _isConnected = MutableStateFlow(false)
|
private val _isConnected = MutableStateFlow(false)
|
||||||
val isConnected: StateFlow<Boolean> = _isConnected.asStateFlow()
|
val isConnected: StateFlow<Boolean> = _isConnected.asStateFlow()
|
||||||
|
|
||||||
private val callback = object : ILckControlCallback.Stub() {
|
private val callback = object : ILckControlCallback.Stub() {
|
||||||
override fun onStreamPlansChanged(plans: List<StreamPlan>) {
|
override fun onStreamPlansChanged(plans: List<StreamPlan>) {}
|
||||||
refreshClients()
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun onStreamPlanUpdated(plan: StreamPlan) {
|
override fun onStreamPlanUpdated(plan: StreamPlan) {}
|
||||||
refreshClients()
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun onClientRegistered(clientId: String) {
|
override fun onClientRegistered(clientId: String) {
|
||||||
refreshClients()
|
refreshClients()
|
||||||
@@ -47,6 +42,8 @@ class ActiveClientsViewModel @Inject constructor(
|
|||||||
override fun onClientUnregistered(clientId: String) {
|
override fun onClientUnregistered(clientId: String) {
|
||||||
refreshClients()
|
refreshClients()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
override fun onAuthStateChanged(authenticated: Boolean) {}
|
||||||
}
|
}
|
||||||
|
|
||||||
private val connection = object : ServiceConnection {
|
private val connection = object : ServiceConnection {
|
||||||
@@ -79,19 +76,11 @@ class ActiveClientsViewModel @Inject constructor(
|
|||||||
}
|
}
|
||||||
|
|
||||||
private fun refreshClients() {
|
private fun refreshClients() {
|
||||||
// The service tracks clients internally; for the UI we present
|
_clients.value = service?.connectedClients ?: emptyList()
|
||||||
// plans and their associated clients. This is a simplified view.
|
}
|
||||||
val plans = service?.streamPlans ?: emptyList()
|
|
||||||
_clients.value = plans
|
fun createDefaultPlan(clientName: String): StreamPlan? {
|
||||||
.filter { it.status == "LIVE" || it.status == "READY" }
|
return service?.createDefaultPlan(clientName)
|
||||||
.map { plan ->
|
|
||||||
ClientInfo(
|
|
||||||
planId = plan.planId,
|
|
||||||
planName = plan.name,
|
|
||||||
planStatus = plan.status,
|
|
||||||
destinationCount = plan.destinations.size,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun onCleared() {
|
override fun onCleared() {
|
||||||
@@ -102,10 +91,3 @@ class ActiveClientsViewModel @Inject constructor(
|
|||||||
super.onCleared()
|
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.SharingStarted
|
||||||
import kotlinx.coroutines.flow.StateFlow
|
import kotlinx.coroutines.flow.StateFlow
|
||||||
import kotlinx.coroutines.flow.stateIn
|
import kotlinx.coroutines.flow.stateIn
|
||||||
|
import kotlinx.coroutines.launch
|
||||||
import javax.inject.Inject
|
import javax.inject.Inject
|
||||||
|
|
||||||
@HiltViewModel
|
@HiltViewModel
|
||||||
class DashboardViewModel @Inject constructor(
|
class DashboardViewModel @Inject constructor(
|
||||||
accountRepository: AccountRepository,
|
accountRepository: AccountRepository,
|
||||||
streamPlanRepository: StreamPlanRepository,
|
private val streamPlanRepository: StreamPlanRepository,
|
||||||
) : ViewModel() {
|
) : ViewModel() {
|
||||||
|
|
||||||
val accounts: StateFlow<List<LinkedAccount>> = accountRepository.observeAccounts()
|
val accounts: StateFlow<List<LinkedAccount>> = accountRepository.observeAccounts()
|
||||||
@@ -23,4 +24,10 @@ class DashboardViewModel @Inject constructor(
|
|||||||
|
|
||||||
val plans: StateFlow<List<StreamPlan>> = streamPlanRepository.observePlans()
|
val plans: StateFlow<List<StreamPlan>> = streamPlanRepository.observePlans()
|
||||||
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), emptyList())
|
.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.MaterialTheme
|
||||||
import androidx.compose.material3.Text
|
import androidx.compose.material3.Text
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.LaunchedEffect
|
||||||
import androidx.compose.runtime.getValue
|
import androidx.compose.runtime.getValue
|
||||||
import androidx.compose.ui.Alignment
|
import androidx.compose.ui.Alignment
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
@@ -30,8 +31,19 @@ fun LoginScreen(
|
|||||||
) {
|
) {
|
||||||
val isLoading by viewModel.isLoading.collectAsStateWithLifecycle()
|
val isLoading by viewModel.isLoading.collectAsStateWithLifecycle()
|
||||||
val error by viewModel.error.collectAsStateWithLifecycle()
|
val error by viewModel.error.collectAsStateWithLifecycle()
|
||||||
|
val loginSuccess by viewModel.loginSuccess.collectAsStateWithLifecycle()
|
||||||
val context = LocalContext.current
|
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(
|
Box(
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.fillMaxSize()
|
.fillMaxSize()
|
||||||
|
|||||||
@@ -7,10 +7,8 @@ import androidx.lifecycle.viewModelScope
|
|||||||
import com.omixlab.lckcontrol.data.local.TokenStore
|
import com.omixlab.lckcontrol.data.local.TokenStore
|
||||||
import com.omixlab.lckcontrol.data.remote.LckApiService
|
import com.omixlab.lckcontrol.data.remote.LckApiService
|
||||||
import com.omixlab.lckcontrol.data.remote.MetaCallbackRequest
|
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.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.Request
|
||||||
import com.meta.horizon.platform.ovr.requests.Users
|
import com.meta.horizon.platform.ovr.requests.Users
|
||||||
import dagger.hilt.android.lifecycle.HiltViewModel
|
import dagger.hilt.android.lifecycle.HiltViewModel
|
||||||
@@ -35,61 +33,61 @@ class LoginViewModel @Inject constructor(
|
|||||||
private val _error = MutableStateFlow<String?>(null)
|
private val _error = MutableStateFlow<String?>(null)
|
||||||
val error: StateFlow<String?> = _error.asStateFlow()
|
val error: StateFlow<String?> = _error.asStateFlow()
|
||||||
|
|
||||||
|
private val _loginSuccess = MutableStateFlow(false)
|
||||||
|
val loginSuccess: StateFlow<Boolean> = _loginSuccess.asStateFlow()
|
||||||
|
|
||||||
fun isLoggedIn(): Boolean = tokenStore.isLoggedIn()
|
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) {
|
fun loginWithQuest(activity: Activity) {
|
||||||
viewModelScope.launch {
|
viewModelScope.launch {
|
||||||
_isLoading.value = true
|
_isLoading.value = true
|
||||||
_error.value = null
|
_error.value = null
|
||||||
try {
|
try {
|
||||||
// Initialize Platform SDK (async, wait for completion via message pump)
|
doQuestLogin(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")
|
|
||||||
} 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!")
|
|
||||||
} catch (e: Exception) {
|
} catch (e: Exception) {
|
||||||
Log.e(TAG, "Quest login failed", e)
|
Log.e(TAG, "Quest login failed", e)
|
||||||
_error.value = e.message ?: "Login failed"
|
_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() {
|
fun clearError() {
|
||||||
_error.value = null
|
_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(
|
private suspend fun <T> awaitWithPump(
|
||||||
block: () -> Request<T>,
|
block: () -> Request<T>,
|
||||||
): T = suspendCoroutine { cont ->
|
): T = suspendCoroutine { cont ->
|
||||||
var completed = false
|
var completed = false
|
||||||
block()
|
block()
|
||||||
.onSuccess { result: T ->
|
.onSuccess { result: T ->
|
||||||
Log.d(TAG, "SDK request succeeded: ${result?.javaClass?.simpleName}")
|
|
||||||
if (!completed) {
|
if (!completed) {
|
||||||
completed = true
|
completed = true
|
||||||
cont.resume(result)
|
cont.resume(result)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.onError { error ->
|
.onError { error ->
|
||||||
Log.e(TAG, "SDK request failed: code=${error.code} http=${error.httpCode} msg=${error.message}")
|
|
||||||
if (!completed) {
|
if (!completed) {
|
||||||
completed = true
|
completed = true
|
||||||
cont.resumeWithException(Exception("SDK error ${error.code}: ${error.message}"))
|
cont.resumeWithException(Exception("SDK error ${error.code}: ${error.message}"))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Pump messages on a background thread
|
|
||||||
Thread {
|
Thread {
|
||||||
val timeout = System.currentTimeMillis() + 15_000 // 15s timeout
|
val timeout = System.currentTimeMillis() + 15_000
|
||||||
var msgCount = 0
|
|
||||||
while (!completed && System.currentTimeMillis() < timeout) {
|
while (!completed && System.currentTimeMillis() < timeout) {
|
||||||
try {
|
try {
|
||||||
val msg = Core.popSDKMessage()
|
val msg = Core.popSDKMessage()
|
||||||
if (msg != null) {
|
if (msg != null) Request.handleMessage(msg)
|
||||||
msgCount++
|
|
||||||
Log.d(TAG, "Pumped message #$msgCount type=${msg.type} isError=${msg.isError}")
|
|
||||||
Request.handleMessage(msg)
|
|
||||||
}
|
|
||||||
} catch (e: Exception) {
|
} catch (e: Exception) {
|
||||||
Log.w(TAG, "Message pump error", e)
|
Log.w(TAG, "Message pump error", e)
|
||||||
}
|
}
|
||||||
Thread.sleep(50)
|
Thread.sleep(50)
|
||||||
}
|
}
|
||||||
Log.d(TAG, "Message pump done: completed=$completed msgs=$msgCount")
|
|
||||||
if (!completed) {
|
if (!completed) {
|
||||||
completed = true
|
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()
|
}.start()
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,19 +1,29 @@
|
|||||||
package com.omixlab.lckcontrol.ui.navigation
|
package com.omixlab.lckcontrol.ui.navigation
|
||||||
|
|
||||||
|
import androidx.compose.foundation.layout.Box
|
||||||
import androidx.compose.foundation.layout.padding
|
import androidx.compose.foundation.layout.padding
|
||||||
|
import androidx.compose.foundation.layout.size
|
||||||
import androidx.compose.material.icons.Icons
|
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.Dashboard
|
||||||
import androidx.compose.material.icons.filled.Devices
|
import androidx.compose.material.icons.filled.Devices
|
||||||
import androidx.compose.material.icons.filled.Person
|
import androidx.compose.material.icons.filled.Person
|
||||||
import androidx.compose.material3.Icon
|
import androidx.compose.material3.Icon
|
||||||
|
import androidx.compose.material3.MaterialTheme
|
||||||
import androidx.compose.material3.NavigationBar
|
import androidx.compose.material3.NavigationBar
|
||||||
import androidx.compose.material3.NavigationBarItem
|
import androidx.compose.material3.NavigationBarItem
|
||||||
import androidx.compose.material3.Scaffold
|
import androidx.compose.material3.Scaffold
|
||||||
import androidx.compose.material3.Text
|
import androidx.compose.material3.Text
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.LaunchedEffect
|
||||||
import androidx.compose.runtime.getValue
|
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.Modifier
|
||||||
import androidx.compose.ui.graphics.vector.ImageVector
|
import androidx.compose.ui.graphics.vector.ImageVector
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
import androidx.navigation.NavGraph.Companion.findStartDestination
|
import androidx.navigation.NavGraph.Companion.findStartDestination
|
||||||
import androidx.navigation.NavType
|
import androidx.navigation.NavType
|
||||||
import androidx.navigation.compose.NavHost
|
import androidx.navigation.compose.NavHost
|
||||||
@@ -22,12 +32,14 @@ import androidx.navigation.compose.currentBackStackEntryAsState
|
|||||||
import androidx.navigation.compose.rememberNavController
|
import androidx.navigation.compose.rememberNavController
|
||||||
import androidx.navigation.navArgument
|
import androidx.navigation.navArgument
|
||||||
import com.omixlab.lckcontrol.data.local.TokenStore
|
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.accounts.AccountsScreen
|
||||||
import com.omixlab.lckcontrol.ui.clients.ActiveClientsScreen
|
import com.omixlab.lckcontrol.ui.clients.ActiveClientsScreen
|
||||||
import com.omixlab.lckcontrol.ui.dashboard.DashboardScreen
|
import com.omixlab.lckcontrol.ui.dashboard.DashboardScreen
|
||||||
import com.omixlab.lckcontrol.ui.login.LoginScreen
|
import com.omixlab.lckcontrol.ui.login.LoginScreen
|
||||||
import com.omixlab.lckcontrol.ui.plans.CreatePlanScreen
|
import com.omixlab.lckcontrol.ui.plans.CreatePlanScreen
|
||||||
import com.omixlab.lckcontrol.ui.plans.PlanDetailScreen
|
import com.omixlab.lckcontrol.ui.plans.PlanDetailScreen
|
||||||
|
import kotlinx.coroutines.delay
|
||||||
|
|
||||||
private data class BottomNavItem(
|
private data class BottomNavItem(
|
||||||
val screen: Screen,
|
val screen: Screen,
|
||||||
@@ -42,7 +54,7 @@ private val bottomNavItems = listOf(
|
|||||||
)
|
)
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
fun AppNavigation(tokenStore: TokenStore) {
|
fun AppNavigation(tokenStore: TokenStore, apiService: LckApiService) {
|
||||||
val navController = rememberNavController()
|
val navController = rememberNavController()
|
||||||
val navBackStackEntry by navController.currentBackStackEntryAsState()
|
val navBackStackEntry by navController.currentBackStackEntryAsState()
|
||||||
val currentRoute = navBackStackEntry?.destination?.route
|
val currentRoute = navBackStackEntry?.destination?.route
|
||||||
@@ -50,13 +62,64 @@ fun AppNavigation(tokenStore: TokenStore) {
|
|||||||
val showBottomBar = currentRoute in bottomNavItems.map { it.screen.route }
|
val showBottomBar = currentRoute in bottomNavItems.map { it.screen.route }
|
||||||
val startDestination = if (tokenStore.isLoggedIn()) Screen.Dashboard.route else Screen.Login.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(
|
Scaffold(
|
||||||
bottomBar = {
|
bottomBar = {
|
||||||
if (showBottomBar) {
|
if (showBottomBar) {
|
||||||
NavigationBar {
|
NavigationBar {
|
||||||
bottomNavItems.forEach { item ->
|
bottomNavItems.forEach { item ->
|
||||||
NavigationBarItem(
|
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) },
|
label = { Text(item.label) },
|
||||||
selected = currentRoute == item.screen.route,
|
selected = currentRoute == item.screen.route,
|
||||||
onClick = {
|
onClick = {
|
||||||
@@ -80,7 +143,13 @@ fun AppNavigation(tokenStore: TokenStore) {
|
|||||||
modifier = Modifier.padding(innerPadding),
|
modifier = Modifier.padding(innerPadding),
|
||||||
) {
|
) {
|
||||||
composable(Screen.Login.route) {
|
composable(Screen.Login.route) {
|
||||||
LoginScreen()
|
LoginScreen(
|
||||||
|
onLoginSuccess = {
|
||||||
|
navController.navigate(Screen.Dashboard.route) {
|
||||||
|
popUpTo(Screen.Login.route) { inclusive = true }
|
||||||
|
}
|
||||||
|
},
|
||||||
|
)
|
||||||
}
|
}
|
||||||
composable(Screen.Dashboard.route) {
|
composable(Screen.Dashboard.route) {
|
||||||
DashboardScreen(
|
DashboardScreen(
|
||||||
|
|||||||
@@ -41,6 +41,7 @@ import androidx.compose.ui.Modifier
|
|||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
import androidx.hilt.navigation.compose.hiltViewModel
|
import androidx.hilt.navigation.compose.hiltViewModel
|
||||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||||
|
import com.omixlab.lckcontrol.shared.LinkedAccount
|
||||||
|
|
||||||
@OptIn(ExperimentalMaterial3Api::class)
|
@OptIn(ExperimentalMaterial3Api::class)
|
||||||
@Composable
|
@Composable
|
||||||
@@ -112,7 +113,7 @@ fun CreatePlanScreen(
|
|||||||
itemsIndexed(destinations) { index, dest ->
|
itemsIndexed(destinations) { index, dest ->
|
||||||
DestinationCard(
|
DestinationCard(
|
||||||
destination = dest,
|
destination = dest,
|
||||||
availableServices = linkedAccounts.map { it.serviceId },
|
linkedAccounts = linkedAccounts,
|
||||||
onUpdate = { viewModel.updateDestination(index, it) },
|
onUpdate = { viewModel.updateDestination(index, it) },
|
||||||
onRemove = { viewModel.removeDestination(index) },
|
onRemove = { viewModel.removeDestination(index) },
|
||||||
)
|
)
|
||||||
@@ -137,11 +138,11 @@ fun CreatePlanScreen(
|
|||||||
@Composable
|
@Composable
|
||||||
private fun DestinationCard(
|
private fun DestinationCard(
|
||||||
destination: DestinationInput,
|
destination: DestinationInput,
|
||||||
availableServices: List<String>,
|
linkedAccounts: List<LinkedAccount>,
|
||||||
onUpdate: (DestinationInput) -> Unit,
|
onUpdate: (DestinationInput) -> Unit,
|
||||||
onRemove: () -> Unit,
|
onRemove: () -> Unit,
|
||||||
) {
|
) {
|
||||||
var serviceExpanded by remember { mutableStateOf(false) }
|
var accountExpanded by remember { mutableStateOf(false) }
|
||||||
var privacyExpanded by remember { mutableStateOf(false) }
|
var privacyExpanded by remember { mutableStateOf(false) }
|
||||||
|
|
||||||
ElevatedCard(modifier = Modifier.fillMaxWidth()) {
|
ElevatedCard(modifier = Modifier.fillMaxWidth()) {
|
||||||
@@ -159,31 +160,35 @@ private fun DestinationCard(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Service picker
|
// Account picker (shows "YouTube - DisplayName" per account)
|
||||||
ExposedDropdownMenuBox(
|
ExposedDropdownMenuBox(
|
||||||
expanded = serviceExpanded,
|
expanded = accountExpanded,
|
||||||
onExpandedChange = { serviceExpanded = it },
|
onExpandedChange = { accountExpanded = it },
|
||||||
) {
|
) {
|
||||||
OutlinedTextField(
|
OutlinedTextField(
|
||||||
value = destination.service,
|
value = destination.linkedAccountLabel,
|
||||||
onValueChange = {},
|
onValueChange = {},
|
||||||
readOnly = true,
|
readOnly = true,
|
||||||
label = { Text("Service") },
|
label = { Text("Account") },
|
||||||
trailingIcon = { ExposedDropdownMenuDefaults.TrailingIcon(serviceExpanded) },
|
trailingIcon = { ExposedDropdownMenuDefaults.TrailingIcon(accountExpanded) },
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.fillMaxWidth()
|
.fillMaxWidth()
|
||||||
.menuAnchor(MenuAnchorType.PrimaryNotEditable),
|
.menuAnchor(MenuAnchorType.PrimaryNotEditable),
|
||||||
)
|
)
|
||||||
ExposedDropdownMenu(
|
ExposedDropdownMenu(
|
||||||
expanded = serviceExpanded,
|
expanded = accountExpanded,
|
||||||
onDismissRequest = { serviceExpanded = false },
|
onDismissRequest = { accountExpanded = false },
|
||||||
) {
|
) {
|
||||||
availableServices.forEach { service ->
|
linkedAccounts.forEach { account ->
|
||||||
|
val label = "${account.serviceId} - ${account.displayName}"
|
||||||
DropdownMenuItem(
|
DropdownMenuItem(
|
||||||
text = { Text(service) },
|
text = { Text(label) },
|
||||||
onClick = {
|
onClick = {
|
||||||
onUpdate(destination.copy(service = service))
|
onUpdate(destination.copy(
|
||||||
serviceExpanded = false
|
linkedAccountId = account.id,
|
||||||
|
linkedAccountLabel = label,
|
||||||
|
))
|
||||||
|
accountExpanded = false
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -16,7 +16,8 @@ import kotlinx.coroutines.launch
|
|||||||
import javax.inject.Inject
|
import javax.inject.Inject
|
||||||
|
|
||||||
data class DestinationInput(
|
data class DestinationInput(
|
||||||
val service: String = "",
|
val linkedAccountId: String = "",
|
||||||
|
val linkedAccountLabel: String = "",
|
||||||
val title: String = "",
|
val title: String = "",
|
||||||
val description: String = "",
|
val description: String = "",
|
||||||
val privacyStatus: String = "public",
|
val privacyStatus: String = "public",
|
||||||
@@ -77,8 +78,8 @@ class CreatePlanViewModel @Inject constructor(
|
|||||||
_error.value = "Add at least one destination"
|
_error.value = "Add at least one destination"
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if (dests.any { it.service.isBlank() || it.title.isBlank() }) {
|
if (dests.any { it.linkedAccountId.isBlank() || it.title.isBlank() }) {
|
||||||
_error.value = "All destinations need a service and title"
|
_error.value = "All destinations need an account and title"
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -86,9 +87,12 @@ class CreatePlanViewModel @Inject constructor(
|
|||||||
_isCreating.value = true
|
_isCreating.value = true
|
||||||
_error.value = null
|
_error.value = null
|
||||||
try {
|
try {
|
||||||
|
val accounts = linkedAccounts.value
|
||||||
val streamDests = dests.map { input ->
|
val streamDests = dests.map { input ->
|
||||||
|
val account = accounts.find { it.id == input.linkedAccountId }
|
||||||
StreamDestination(
|
StreamDestination(
|
||||||
service = input.service,
|
service = account?.serviceId ?: "",
|
||||||
|
linkedAccountId = input.linkedAccountId,
|
||||||
title = input.title,
|
title = input.title,
|
||||||
description = input.description,
|
description = input.description,
|
||||||
privacyStatus = input.privacyStatus,
|
privacyStatus = input.privacyStatus,
|
||||||
|
|||||||
@@ -70,7 +70,7 @@ fun PlanDetailScreen(
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
actions = {
|
actions = {
|
||||||
if (plan?.status == "DRAFT" || plan?.status == "ENDED") {
|
if (plan?.status != "LIVE") {
|
||||||
IconButton(onClick = { viewModel.deletePlan(onBack) }) {
|
IconButton(onClick = { viewModel.deletePlan(onBack) }) {
|
||||||
Icon(Icons.Default.Delete, contentDescription = "Delete")
|
Icon(Icons.Default.Delete, contentDescription = "Delete")
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -25,6 +25,13 @@ class PlanDetailViewModel @Inject constructor(
|
|||||||
val plan: StateFlow<StreamPlan?> = streamPlanRepository.observePlan(planId)
|
val plan: StateFlow<StreamPlan?> = streamPlanRepository.observePlan(planId)
|
||||||
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), null)
|
.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)
|
private val _isLoading = MutableStateFlow(false)
|
||||||
val isLoading: StateFlow<Boolean> = _isLoading.asStateFlow()
|
val isLoading: StateFlow<Boolean> = _isLoading.asStateFlow()
|
||||||
|
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import android.content.Context
|
|||||||
import android.content.Intent
|
import android.content.Intent
|
||||||
import android.content.ServiceConnection
|
import android.content.ServiceConnection
|
||||||
import android.os.IBinder
|
import android.os.IBinder
|
||||||
|
import com.omixlab.lckcontrol.shared.ConnectedClientInfo
|
||||||
import com.omixlab.lckcontrol.shared.ILckControlCallback
|
import com.omixlab.lckcontrol.shared.ILckControlCallback
|
||||||
import com.omixlab.lckcontrol.shared.ILckControlService
|
import com.omixlab.lckcontrol.shared.ILckControlService
|
||||||
import com.omixlab.lckcontrol.shared.LinkedAccount
|
import com.omixlab.lckcontrol.shared.LinkedAccount
|
||||||
@@ -28,6 +29,9 @@ class LckControlClient(private val context: Context) {
|
|||||||
private val _connected = MutableStateFlow(false)
|
private val _connected = MutableStateFlow(false)
|
||||||
val connected: StateFlow<Boolean> = _connected.asStateFlow()
|
val connected: StateFlow<Boolean> = _connected.asStateFlow()
|
||||||
|
|
||||||
|
private val _authenticated = MutableStateFlow(false)
|
||||||
|
val authenticated: StateFlow<Boolean> = _authenticated.asStateFlow()
|
||||||
|
|
||||||
private val _streamPlans = MutableStateFlow<List<StreamPlan>>(emptyList())
|
private val _streamPlans = MutableStateFlow<List<StreamPlan>>(emptyList())
|
||||||
val streamPlans: StateFlow<List<StreamPlan>> = _streamPlans.asStateFlow()
|
val streamPlans: StateFlow<List<StreamPlan>> = _streamPlans.asStateFlow()
|
||||||
|
|
||||||
@@ -44,6 +48,10 @@ class LckControlClient(private val context: Context) {
|
|||||||
|
|
||||||
override fun onClientRegistered(id: String) {}
|
override fun onClientRegistered(id: String) {}
|
||||||
override fun onClientUnregistered(id: String) {}
|
override fun onClientUnregistered(id: String) {}
|
||||||
|
|
||||||
|
override fun onAuthStateChanged(authenticated: Boolean) {
|
||||||
|
_authenticated.value = authenticated
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private val connection = object : ServiceConnection {
|
private val connection = object : ServiceConnection {
|
||||||
@@ -51,12 +59,14 @@ class LckControlClient(private val context: Context) {
|
|||||||
service = ILckControlService.Stub.asInterface(binder)
|
service = ILckControlService.Stub.asInterface(binder)
|
||||||
service?.registerCallback(callback)
|
service?.registerCallback(callback)
|
||||||
_connected.value = true
|
_connected.value = true
|
||||||
|
_authenticated.value = service?.isAuthenticated ?: false
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun onServiceDisconnected(name: ComponentName?) {
|
override fun onServiceDisconnected(name: ComponentName?) {
|
||||||
service = null
|
service = null
|
||||||
clientId = null
|
clientId = null
|
||||||
_connected.value = false
|
_connected.value = false
|
||||||
|
_authenticated.value = false
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -80,18 +90,39 @@ class LckControlClient(private val context: Context) {
|
|||||||
service = null
|
service = null
|
||||||
clientId = null
|
clientId = null
|
||||||
_connected.value = false
|
_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? {
|
fun registerAsClient(clientName: String, packageName: String): String? {
|
||||||
val id = service?.registerClient(clientName, packageName)
|
val id = service?.registerClient(clientName, packageName)
|
||||||
clientId = id
|
clientId = id
|
||||||
return id
|
return id
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fun getConnectedClients(): List<ConnectedClientInfo> {
|
||||||
|
return service?.connectedClients ?: emptyList()
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Accounts ────────────────────────────────────────
|
||||||
|
|
||||||
fun getLinkedAccounts(): List<LinkedAccount> {
|
fun getLinkedAccounts(): List<LinkedAccount> {
|
||||||
return service?.linkedAccounts ?: emptyList()
|
return service?.linkedAccounts ?: emptyList()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── Stream plans ────────────────────────────────────
|
||||||
|
|
||||||
fun getStreamPlans(): List<StreamPlan> {
|
fun getStreamPlans(): List<StreamPlan> {
|
||||||
return service?.streamPlans ?: emptyList()
|
return service?.streamPlans ?: emptyList()
|
||||||
}
|
}
|
||||||
@@ -104,6 +135,10 @@ class LckControlClient(private val context: Context) {
|
|||||||
return service?.createStreamPlan(config)
|
return service?.createStreamPlan(config)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fun createDefaultPlan(clientName: String): StreamPlan? {
|
||||||
|
return service?.createDefaultPlan(clientName)
|
||||||
|
}
|
||||||
|
|
||||||
fun prepareStreamPlan(planId: String): StreamPlan? {
|
fun prepareStreamPlan(planId: String): StreamPlan? {
|
||||||
return service?.prepareStreamPlan(planId)
|
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 onStreamPlanUpdated(in StreamPlan plan);
|
||||||
void onClientRegistered(String clientId);
|
void onClientRegistered(String clientId);
|
||||||
void onClientUnregistered(String clientId);
|
void onClientUnregistered(String clientId);
|
||||||
|
void onAuthStateChanged(boolean authenticated);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,21 +1,35 @@
|
|||||||
package com.omixlab.lckcontrol.shared;
|
package com.omixlab.lckcontrol.shared;
|
||||||
|
|
||||||
|
import com.omixlab.lckcontrol.shared.ConnectedClientInfo;
|
||||||
import com.omixlab.lckcontrol.shared.LinkedAccount;
|
import com.omixlab.lckcontrol.shared.LinkedAccount;
|
||||||
import com.omixlab.lckcontrol.shared.StreamPlan;
|
import com.omixlab.lckcontrol.shared.StreamPlan;
|
||||||
import com.omixlab.lckcontrol.shared.StreamPlanConfig;
|
import com.omixlab.lckcontrol.shared.StreamPlanConfig;
|
||||||
import com.omixlab.lckcontrol.shared.ILckControlCallback;
|
import com.omixlab.lckcontrol.shared.ILckControlCallback;
|
||||||
|
|
||||||
interface ILckControlService {
|
interface ILckControlService {
|
||||||
|
// Auth
|
||||||
|
boolean isAuthenticated();
|
||||||
|
void login();
|
||||||
|
|
||||||
|
// Accounts
|
||||||
List<LinkedAccount> getLinkedAccounts();
|
List<LinkedAccount> getLinkedAccounts();
|
||||||
|
|
||||||
|
// Stream plans
|
||||||
StreamPlan createStreamPlan(in StreamPlanConfig config);
|
StreamPlan createStreamPlan(in StreamPlanConfig config);
|
||||||
|
StreamPlan createDefaultPlan(String clientName);
|
||||||
StreamPlan prepareStreamPlan(String planId);
|
StreamPlan prepareStreamPlan(String planId);
|
||||||
List<StreamPlan> getStreamPlans();
|
List<StreamPlan> getStreamPlans();
|
||||||
StreamPlan getStreamPlan(String planId);
|
StreamPlan getStreamPlan(String planId);
|
||||||
boolean startStreamPlan(String planId);
|
boolean startStreamPlan(String planId);
|
||||||
boolean endStreamPlan(String planId);
|
boolean endStreamPlan(String planId);
|
||||||
|
|
||||||
|
// Clients
|
||||||
String registerClient(String clientName, String packageName);
|
String registerClient(String clientName, String packageName);
|
||||||
void unregisterClient(String clientId);
|
void unregisterClient(String clientId);
|
||||||
void setClientActivePlan(String clientId, String planId);
|
void setClientActivePlan(String clientId, String planId);
|
||||||
|
List<ConnectedClientInfo> getConnectedClients();
|
||||||
|
|
||||||
|
// Callbacks
|
||||||
void registerCallback(ILckControlCallback callback);
|
void registerCallback(ILckControlCallback callback);
|
||||||
void unregisterCallback(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