Initial commit: LCK Control Android app

Multi-module Android app (app/shared/sdk) with backend-driven auth,
Quest Platform SDK login, YouTube/Twitch OAuth linking, stream
management via AIDL service. Compose UI with Hilt DI.
This commit is contained in:
2026-02-24 12:03:43 +01:00
commit 82aa207f9a
101 changed files with 4723 additions and 0 deletions

1
app/.gitignore vendored Normal file
View File

@@ -0,0 +1 @@
/build

158
app/build.gradle.kts Normal file
View File

@@ -0,0 +1,158 @@
import java.io.ByteArrayOutputStream
plugins {
alias(libs.plugins.android.application)
alias(libs.plugins.kotlin.compose)
alias(libs.plugins.kotlin.parcelize)
alias(libs.plugins.hilt)
alias(libs.plugins.ksp)
}
// ── Git-based versioning ────────────────────────────────────
fun gitVersionName(): String = runGit("describe", "--tags", "--abbrev=0")
.removePrefix("v")
.ifEmpty { "0.1.0" }
fun gitCommitCount(): Int = runGit("rev-list", "--count", "HEAD")
.toIntOrNull() ?: 1
fun gitShortHash(): String = runGit("rev-parse", "--short", "HEAD")
.ifEmpty { "unknown" }
fun gitDisplayVersion(): String {
val base = gitVersionName()
val count = gitCommitCount()
val hash = gitShortHash()
return "$base+$count.$hash"
}
fun runGit(vararg args: String): String = try {
val out = ByteArrayOutputStream()
project.exec {
commandLine("git", *args)
standardOutput = out
isIgnoreExitValue = true
}
out.toString().trim()
} catch (_: Exception) { "" }
// ─────────────────────────────────────────────────────────────
android {
namespace = "com.omixlab.lckcontrol"
compileSdk {
version = release(36) {
minorApiLevel = 1
}
}
signingConfigs {
create("release") {
storeFile = rootProject.file("lck-control.keystore")
storePassword = "4gx%wx4NOhS6"
keyAlias = "lck-control"
keyPassword = "4gx%wx4NOhS6"
}
}
defaultConfig {
applicationId = "com.omixlab.lckcontrol"
minSdk = 32
targetSdk = 34
versionCode = gitCommitCount()
versionName = gitVersionName()
buildConfigField("String", "DISPLAY_VERSION", "\"${gitDisplayVersion()}\"")
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
}
buildTypes {
debug {
signingConfig = signingConfigs.getByName("release")
}
release {
signingConfig = signingConfigs.getByName("release")
isMinifyEnabled = false
proguardFiles(
getDefaultProguardFile("proguard-android-optimize.txt"),
"proguard-rules.pro"
)
}
}
compileOptions {
sourceCompatibility = JavaVersion.VERSION_11
targetCompatibility = JavaVersion.VERSION_11
}
buildFeatures {
compose = true
buildConfig = true
}
packaging {
resources {
excludes += setOf(
"META-INF/DEPENDENCIES",
"META-INF/LICENSE",
"META-INF/LICENSE.txt",
"META-INF/NOTICE",
"META-INF/NOTICE.txt",
)
}
}
}
dependencies {
implementation(project(":shared"))
// Core
implementation(libs.androidx.core.ktx)
implementation(libs.androidx.lifecycle.runtime.ktx)
implementation(libs.androidx.lifecycle.viewmodel.compose)
implementation(libs.androidx.lifecycle.runtime.compose)
implementation(libs.kotlinx.coroutines.android)
// Compose
implementation(libs.androidx.activity.compose)
implementation(platform(libs.androidx.compose.bom))
implementation(libs.androidx.compose.ui)
implementation(libs.androidx.compose.ui.graphics)
implementation(libs.androidx.compose.ui.tooling.preview)
implementation(libs.androidx.compose.material3)
implementation(libs.androidx.compose.material.icons.extended)
implementation(libs.androidx.navigation.compose)
// Hilt
implementation(libs.hilt.android)
ksp(libs.hilt.compiler)
implementation(libs.androidx.hilt.navigation.compose)
// Room
implementation(libs.room.runtime)
implementation(libs.room.ktx)
ksp(libs.room.compiler)
// Networking
implementation(libs.retrofit)
implementation(libs.retrofit.converter.moshi)
implementation(libs.moshi.kotlin)
implementation(libs.okhttp)
implementation(libs.okhttp.logging)
// Security
implementation(libs.androidx.security.crypto)
// Meta Quest Platform SDK
implementation("com.meta.horizon.platform.ovr:android-platform-sdk:77.0.1")
// Browser (Custom Tabs for OAuth flows)
implementation(libs.androidx.browser)
// Test
testImplementation(libs.junit)
androidTestImplementation(libs.androidx.junit)
androidTestImplementation(libs.androidx.espresso.core)
androidTestImplementation(platform(libs.androidx.compose.bom))
androidTestImplementation(libs.androidx.compose.ui.test.junit4)
debugImplementation(libs.androidx.compose.ui.tooling)
debugImplementation(libs.androidx.compose.ui.test.manifest)
}

21
app/proguard-rules.pro vendored Normal file
View File

@@ -0,0 +1,21 @@
# Add project specific ProGuard rules here.
# You can control the set of applied configuration files using the
# proguardFiles setting in build.gradle.
#
# For more details, see
# http://developer.android.com/guide/developing/tools/proguard.html
# If your project uses WebView with JS, uncomment the following
# and specify the fully qualified class name to the JavaScript interface
# class:
#-keepclassmembers class fqcn.of.javascript.interface.for.webview {
# public *;
#}
# Uncomment this to preserve the line number information for
# debugging stack traces.
#-keepattributes SourceFile,LineNumberTable
# If you keep the line number information, uncomment this to
# hide the original source file name.
#-renamesourcefileattribute SourceFile

View File

@@ -0,0 +1,24 @@
package com.omixlab.lckcontrol
import androidx.test.platform.app.InstrumentationRegistry
import androidx.test.ext.junit.runners.AndroidJUnit4
import org.junit.Test
import org.junit.runner.RunWith
import org.junit.Assert.*
/**
* Instrumented test, which will execute on an Android device.
*
* See [testing documentation](http://d.android.com/tools/testing).
*/
@RunWith(AndroidJUnit4::class)
class ExampleInstrumentedTest {
@Test
fun useAppContext() {
// Context of the app under test.
val appContext = InstrumentationRegistry.getInstrumentation().targetContext
assertEquals("com.omixlab.lckcontrol", appContext.packageName)
}
}

View File

@@ -0,0 +1,83 @@
<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:tools="http://schemas.android.com/tools">
<uses-permission android:name="android.permission.INTERNET" />
<uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_CONNECTED_DEVICE" />
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
<permission
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" />
<application
android:name=".LckControlApp"
android:allowBackup="true"
android:dataExtractionRules="@xml/data_extraction_rules"
android:fullBackupContent="@xml/backup_rules"
android:icon="@mipmap/ic_launcher"
android:label="@string/app_name"
android:roundIcon="@mipmap/ic_launcher_round"
android:supportsRtl="true"
android:theme="@style/Theme.LCKControl"
android:networkSecurityConfig="@xml/network_security_config">
<activity
android:name=".MainActivity"
android:exported="true"
android:label="@string/app_name"
android:theme="@style/Theme.LCKControl">
<intent-filter>
<action android:name="android.intent.action.MAIN" />
<category android:name="android.intent.category.LAUNCHER" />
</intent-filter>
</activity>
<!-- YouTube OAuth redirect handler -->
<activity
android:name=".auth.YouTubeAuthRedirectActivity"
android:exported="true"
android:launchMode="singleTask">
<intent-filter>
<action android:name="android.intent.action.VIEW" />
<category android:name="android.intent.category.DEFAULT" />
<category android:name="android.intent.category.BROWSABLE" />
<data
android:scheme="com.omixlab.lckcontrol"
android:host="youtube"
android:pathPrefix="/callback" />
</intent-filter>
</activity>
<!-- Twitch OAuth redirect handler -->
<activity
android:name=".auth.TwitchAuthRedirectActivity"
android:exported="true"
android:launchMode="singleTask">
<intent-filter>
<action android:name="android.intent.action.VIEW" />
<category android:name="android.intent.category.DEFAULT" />
<category android:name="android.intent.category.BROWSABLE" />
<data
android:scheme="com.omixlab.lckcontrol"
android:host="twitch"
android:pathPrefix="/callback" />
</intent-filter>
</activity>
<service
android:name=".service.LckControlService"
android:exported="true"
android:permission="com.omixlab.lckcontrol.permission.USE_LCK_CONTROL"
android:foregroundServiceType="connectedDevice">
<intent-filter>
<action android:name="com.omixlab.lckcontrol.BIND" />
</intent-filter>
</service>
</application>
</manifest>

View File

@@ -0,0 +1,7 @@
package com.omixlab.lckcontrol
import android.app.Application
import dagger.hilt.android.HiltAndroidApp
@HiltAndroidApp
class LckControlApp : Application()

View File

@@ -0,0 +1,27 @@
package com.omixlab.lckcontrol
import android.os.Bundle
import androidx.activity.ComponentActivity
import androidx.activity.compose.setContent
import androidx.activity.enableEdgeToEdge
import com.omixlab.lckcontrol.data.local.TokenStore
import com.omixlab.lckcontrol.ui.navigation.AppNavigation
import com.omixlab.lckcontrol.ui.theme.LCKControlTheme
import dagger.hilt.android.AndroidEntryPoint
import javax.inject.Inject
@AndroidEntryPoint
class MainActivity : ComponentActivity() {
@Inject lateinit var tokenStore: TokenStore
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
enableEdgeToEdge()
setContent {
LCKControlTheme {
AppNavigation(tokenStore)
}
}
}
}

View File

@@ -0,0 +1,41 @@
package com.omixlab.lckcontrol.auth
import android.content.Intent
import android.os.Bundle
import androidx.activity.ComponentActivity
import com.omixlab.lckcontrol.MainActivity
import com.omixlab.lckcontrol.data.remote.LckApiService
import com.omixlab.lckcontrol.data.remote.ProviderCallbackRequest
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
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 {
try {
apiService.twitchCallback(ProviderCallbackRequest(code = code, state = state))
} catch (e: Exception) {
// Error will be surfaced when accounts screen refreshes
}
}
}
val mainIntent = Intent(this, MainActivity::class.java).apply {
flags = Intent.FLAG_ACTIVITY_CLEAR_TOP or Intent.FLAG_ACTIVITY_SINGLE_TOP
}
startActivity(mainIntent)
finish()
}
}

View File

@@ -0,0 +1,41 @@
package com.omixlab.lckcontrol.auth
import android.content.Intent
import android.os.Bundle
import androidx.activity.ComponentActivity
import com.omixlab.lckcontrol.MainActivity
import com.omixlab.lckcontrol.data.remote.LckApiService
import com.omixlab.lckcontrol.data.remote.ProviderCallbackRequest
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
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 {
try {
apiService.youtubeCallback(ProviderCallbackRequest(code = code, state = state))
} catch (e: Exception) {
// Error will be surfaced when accounts screen refreshes
}
}
}
val mainIntent = Intent(this, MainActivity::class.java).apply {
flags = Intent.FLAG_ACTIVITY_CLEAR_TOP or Intent.FLAG_ACTIVITY_SINGLE_TOP
}
startActivity(mainIntent)
finish()
}
}

View File

@@ -0,0 +1,73 @@
package com.omixlab.lckcontrol.data.local
import androidx.room.Database
import androidx.room.RoomDatabase
import androidx.room.migration.Migration
import androidx.sqlite.db.SupportSQLiteDatabase
import com.omixlab.lckcontrol.data.local.dao.LinkedAccountDao
import com.omixlab.lckcontrol.data.local.dao.StreamPlanDao
import com.omixlab.lckcontrol.data.local.entity.LinkedAccountEntity
import com.omixlab.lckcontrol.data.local.entity.StreamDestinationEntity
import com.omixlab.lckcontrol.data.local.entity.StreamPlanEntity
@Database(
entities = [
LinkedAccountEntity::class,
StreamPlanEntity::class,
StreamDestinationEntity::class,
],
version = 2,
exportSchema = false,
)
abstract class LckDatabase : RoomDatabase() {
abstract fun linkedAccountDao(): LinkedAccountDao
abstract fun streamPlanDao(): StreamPlanDao
companion object {
val MIGRATION_1_2 = object : Migration(1, 2) {
override fun migrate(db: SupportSQLiteDatabase) {
// 1. Remove token columns from linked_accounts
db.execSQL("""
CREATE TABLE linked_accounts_new (
serviceId TEXT NOT NULL PRIMARY KEY,
displayName TEXT NOT NULL,
accountId TEXT NOT NULL,
avatarUrl TEXT
)
""".trimIndent())
db.execSQL("""
INSERT INTO linked_accounts_new (serviceId, displayName, accountId, avatarUrl)
SELECT serviceId, displayName, accountId, avatarUrl FROM linked_accounts
""".trimIndent())
db.execSQL("DROP TABLE linked_accounts")
db.execSQL("ALTER TABLE linked_accounts_new RENAME TO linked_accounts")
// 2. Change stream_destinations.id from INTEGER to TEXT
db.execSQL("""
CREATE TABLE stream_destinations_new (
id TEXT NOT NULL PRIMARY KEY,
planId TEXT NOT NULL,
service TEXT NOT NULL,
title TEXT NOT NULL,
description TEXT NOT NULL DEFAULT '',
privacyStatus TEXT NOT NULL DEFAULT 'public',
gameId TEXT NOT NULL DEFAULT '',
tags TEXT NOT NULL DEFAULT '',
rtmpUrl TEXT NOT NULL DEFAULT '',
streamKey TEXT NOT NULL DEFAULT '',
broadcastId TEXT NOT NULL DEFAULT '',
status TEXT NOT NULL DEFAULT 'PENDING',
FOREIGN KEY (planId) REFERENCES stream_plans(planId) ON DELETE CASCADE
)
""".trimIndent())
db.execSQL("CREATE INDEX index_stream_destinations_planId ON stream_destinations_new(planId)")
db.execSQL("""
INSERT INTO stream_destinations_new (id, planId, service, title, description, privacyStatus, gameId, tags, rtmpUrl, streamKey, broadcastId, status)
SELECT CAST(id AS TEXT), planId, service, title, description, privacyStatus, gameId, tags, rtmpUrl, streamKey, broadcastId, status FROM stream_destinations
""".trimIndent())
db.execSQL("DROP TABLE stream_destinations")
db.execSQL("ALTER TABLE stream_destinations_new RENAME TO stream_destinations")
}
}
}
}

View File

@@ -0,0 +1,50 @@
package com.omixlab.lckcontrol.data.local
import android.content.Context
import androidx.security.crypto.EncryptedSharedPreferences
import androidx.security.crypto.MasterKeys
import dagger.hilt.android.qualifiers.ApplicationContext
import javax.inject.Inject
import javax.inject.Singleton
@Singleton
class TokenStore @Inject constructor(
@ApplicationContext context: Context,
) {
private val masterKey = MasterKeys.getOrCreate(MasterKeys.AES256_GCM_SPEC)
private val prefs = EncryptedSharedPreferences.create(
"lck_control_tokens",
masterKey,
context,
EncryptedSharedPreferences.PrefKeyEncryptionScheme.AES256_SIV,
EncryptedSharedPreferences.PrefValueEncryptionScheme.AES256_GCM,
)
fun getJwt(): String? =
prefs.getString(KEY_JWT, null)
fun getRefreshToken(): String? =
prefs.getString(KEY_REFRESH_TOKEN, null)
fun saveSession(jwt: String, refreshToken: String) {
prefs.edit()
.putString(KEY_JWT, jwt)
.putString(KEY_REFRESH_TOKEN, refreshToken)
.apply()
}
fun clearSession() {
prefs.edit()
.remove(KEY_JWT)
.remove(KEY_REFRESH_TOKEN)
.apply()
}
fun isLoggedIn(): Boolean = getJwt() != null
companion object {
private const val KEY_JWT = "session_jwt"
private const val KEY_REFRESH_TOKEN = "session_refresh_token"
}
}

View File

@@ -0,0 +1,30 @@
package com.omixlab.lckcontrol.data.local.dao
import androidx.room.Dao
import androidx.room.Delete
import androidx.room.Insert
import androidx.room.OnConflictStrategy
import androidx.room.Query
import com.omixlab.lckcontrol.data.local.entity.LinkedAccountEntity
import kotlinx.coroutines.flow.Flow
@Dao
interface LinkedAccountDao {
@Query("SELECT * FROM linked_accounts")
fun observeAll(): Flow<List<LinkedAccountEntity>>
@Query("SELECT * FROM linked_accounts")
suspend fun getAll(): List<LinkedAccountEntity>
@Query("SELECT * FROM linked_accounts WHERE serviceId = :serviceId")
suspend fun getByService(serviceId: String): LinkedAccountEntity?
@Insert(onConflict = OnConflictStrategy.REPLACE)
suspend fun upsert(account: LinkedAccountEntity)
@Delete
suspend fun delete(account: LinkedAccountEntity)
@Query("DELETE FROM linked_accounts WHERE serviceId = :serviceId")
suspend fun deleteByService(serviceId: String)
}

View File

@@ -0,0 +1,58 @@
package com.omixlab.lckcontrol.data.local.dao
import androidx.room.Dao
import androidx.room.Insert
import androidx.room.OnConflictStrategy
import androidx.room.Query
import androidx.room.Transaction
import com.omixlab.lckcontrol.data.local.entity.StreamDestinationEntity
import com.omixlab.lckcontrol.data.local.entity.StreamPlanEntity
import com.omixlab.lckcontrol.data.local.entity.StreamPlanWithDestinations
import kotlinx.coroutines.flow.Flow
@Dao
interface StreamPlanDao {
@Transaction
@Query("SELECT * FROM stream_plans ORDER BY createdAt DESC")
fun observeAll(): Flow<List<StreamPlanWithDestinations>>
@Transaction
@Query("SELECT * FROM stream_plans ORDER BY createdAt DESC")
suspend fun getAll(): List<StreamPlanWithDestinations>
@Transaction
@Query("SELECT * FROM stream_plans WHERE planId = :planId")
suspend fun getById(planId: String): StreamPlanWithDestinations?
@Transaction
@Query("SELECT * FROM stream_plans WHERE planId = :planId")
fun observeById(planId: String): Flow<StreamPlanWithDestinations?>
@Insert(onConflict = OnConflictStrategy.REPLACE)
suspend fun upsertPlan(plan: StreamPlanEntity)
@Insert(onConflict = OnConflictStrategy.REPLACE)
suspend fun upsertDestination(destination: StreamDestinationEntity)
@Insert(onConflict = OnConflictStrategy.REPLACE)
suspend fun upsertDestinations(destinations: List<StreamDestinationEntity>)
@Query("DELETE FROM stream_destinations WHERE planId = :planId")
suspend fun deleteDestinations(planId: String)
@Query("DELETE FROM stream_plans WHERE planId = :planId")
suspend fun deletePlan(planId: String)
@Query("UPDATE stream_plans SET status = :status WHERE planId = :planId")
suspend fun updateStatus(planId: String, status: String)
@Query("UPDATE stream_destinations SET rtmpUrl = :rtmpUrl, streamKey = :streamKey, broadcastId = :broadcastId, status = :status WHERE id = :id")
suspend fun updateDestinationStream(id: String, rtmpUrl: String, streamKey: String, broadcastId: String, status: String)
@Transaction
suspend fun insertPlanWithDestinations(plan: StreamPlanEntity, destinations: List<StreamDestinationEntity>) {
upsertPlan(plan)
deleteDestinations(plan.planId)
upsertDestinations(destinations)
}
}

View File

@@ -0,0 +1,12 @@
package com.omixlab.lckcontrol.data.local.entity
import androidx.room.Entity
import androidx.room.PrimaryKey
@Entity(tableName = "linked_accounts")
data class LinkedAccountEntity(
@PrimaryKey val serviceId: String,
val displayName: String,
val accountId: String,
val avatarUrl: String? = null,
)

View File

@@ -0,0 +1,34 @@
package com.omixlab.lckcontrol.data.local.entity
import androidx.room.Entity
import androidx.room.ForeignKey
import androidx.room.Index
import androidx.room.PrimaryKey
import java.util.UUID
@Entity(
tableName = "stream_destinations",
foreignKeys = [
ForeignKey(
entity = StreamPlanEntity::class,
parentColumns = ["planId"],
childColumns = ["planId"],
onDelete = ForeignKey.CASCADE,
)
],
indices = [Index("planId")],
)
data class StreamDestinationEntity(
@PrimaryKey val id: String = UUID.randomUUID().toString(),
val planId: String,
val service: String,
val title: String,
val description: String = "",
val privacyStatus: String = "public",
val gameId: String = "",
val tags: String = "",
val rtmpUrl: String = "",
val streamKey: String = "",
val broadcastId: String = "",
val status: String = "PENDING",
)

View File

@@ -0,0 +1,12 @@
package com.omixlab.lckcontrol.data.local.entity
import androidx.room.Entity
import androidx.room.PrimaryKey
@Entity(tableName = "stream_plans")
data class StreamPlanEntity(
@PrimaryKey val planId: String,
val name: String,
val status: String = "DRAFT",
val createdAt: Long = System.currentTimeMillis(),
)

View File

@@ -0,0 +1,13 @@
package com.omixlab.lckcontrol.data.local.entity
import androidx.room.Embedded
import androidx.room.Relation
data class StreamPlanWithDestinations(
@Embedded val plan: StreamPlanEntity,
@Relation(
parentColumn = "planId",
entityColumn = "planId",
)
val destinations: List<StreamDestinationEntity>,
)

View File

@@ -0,0 +1,128 @@
package com.omixlab.lckcontrol.data.remote
import com.squareup.moshi.JsonClass
// ── Auth ─────────────────────────────────────────────────
@JsonClass(generateAdapter = true)
data class MetaCallbackRequest(
val userId: String,
val nonce: String,
val deviceInfo: String? = null,
)
@JsonClass(generateAdapter = true)
data class RefreshRequest(
val refreshToken: String,
)
@JsonClass(generateAdapter = true)
data class AuthTokensResponse(
val accessToken: String,
val refreshToken: String,
val expiresIn: Int,
)
@JsonClass(generateAdapter = true)
data class UserProfileResponse(
val id: String,
val displayName: String,
val email: String?,
val avatarUrl: String?,
)
// ── Providers ────────────────────────────────────────────
@JsonClass(generateAdapter = true)
data class AuthUrlResponse(
val url: String,
val state: String,
)
@JsonClass(generateAdapter = true)
data class ProviderCallbackRequest(
val code: String,
val state: String,
)
@JsonClass(generateAdapter = true)
data class LinkedAccountResponse(
val id: String,
val serviceId: String,
val displayName: String,
val accountId: String,
val avatarUrl: String?,
)
// ── Streams ──────────────────────────────────────────────
@JsonClass(generateAdapter = true)
data class CreateStreamPlanRequest(
val name: String,
val destinations: List<CreateDestinationRequest>,
)
@JsonClass(generateAdapter = true)
data class CreateDestinationRequest(
val serviceId: String,
val title: String,
val description: String? = null,
val privacyStatus: String? = null,
val gameId: String? = null,
val tags: String? = null,
)
@JsonClass(generateAdapter = true)
data class StreamPlanResponse(
val id: String,
val name: String,
val status: String,
val createdAt: String,
val updatedAt: String,
val destinations: List<StreamDestinationResponse>,
)
@JsonClass(generateAdapter = true)
data class StreamDestinationResponse(
val id: String,
val serviceId: String,
val title: String,
val description: String,
val privacyStatus: String,
val gameId: String,
val tags: String,
val rtmpUrl: String,
val streamKey: String,
val broadcastId: String,
val status: String,
)
@JsonClass(generateAdapter = true)
data class PrepareResponse(
val planId: String,
val destinations: List<PreparedDestination>,
)
@JsonClass(generateAdapter = true)
data class PreparedDestination(
val serviceId: String,
val rtmpUrl: String,
val streamKey: String,
val broadcastId: String,
)
@JsonClass(generateAdapter = true)
data class SuccessResponse(
val success: Boolean,
)
@JsonClass(generateAdapter = true)
data class StatusResponse(
val success: Boolean,
val status: String,
)
@JsonClass(generateAdapter = true)
data class ErrorResponse(
val error: String,
)

View File

@@ -0,0 +1,88 @@
package com.omixlab.lckcontrol.data.remote
import com.omixlab.lckcontrol.data.local.TokenStore
import okhttp3.Interceptor
import okhttp3.MediaType.Companion.toMediaTypeOrNull
import okhttp3.RequestBody.Companion.toRequestBody
import okhttp3.Response
import javax.inject.Inject
import javax.inject.Singleton
@Singleton
class AuthInterceptor @Inject constructor(
private val tokenStore: TokenStore,
) : Interceptor {
override fun intercept(chain: Interceptor.Chain): Response {
val original = chain.request()
// 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")) {
return chain.proceed(original)
}
val jwt = tokenStore.getJwt()
val request = if (jwt != null) {
original.newBuilder()
.header("Authorization", "Bearer $jwt")
.build()
} else {
original
}
val response = chain.proceed(request)
// If 401 and we have a refresh token, try to refresh
if (response.code == 401) {
val refreshToken = tokenStore.getRefreshToken()
if (refreshToken != null) {
response.close()
val newTokens = refreshTokenSync(chain, refreshToken)
if (newTokens != null) {
tokenStore.saveSession(newTokens.accessToken, newTokens.refreshToken)
// Retry original request with new token
val retryRequest = original.newBuilder()
.header("Authorization", "Bearer ${newTokens.accessToken}")
.build()
return chain.proceed(retryRequest)
} else {
// Refresh failed, clear session
tokenStore.clearSession()
}
}
}
return response
}
private fun refreshTokenSync(chain: Interceptor.Chain, refreshToken: String): AuthTokensResponse? {
return try {
val baseUrl = chain.request().url.newBuilder()
.encodedPath("/auth/refresh")
.build()
val json = """{"refreshToken":"$refreshToken"}"""
val mediaType = "application/json".toMediaTypeOrNull()
val body = json.toRequestBody(mediaType)
val refreshRequest = okhttp3.Request.Builder()
.url(baseUrl)
.post(body)
.build()
val refreshResponse = chain.proceed(refreshRequest)
if (refreshResponse.isSuccessful) {
val responseBody = refreshResponse.body?.string() ?: return null
val moshi = com.squareup.moshi.Moshi.Builder().build()
val adapter = moshi.adapter(AuthTokensResponse::class.java)
adapter.fromJson(responseBody)
} else {
refreshResponse.close()
null
}
} catch (e: Exception) {
null
}
}
}

View File

@@ -0,0 +1,63 @@
package com.omixlab.lckcontrol.data.remote
import retrofit2.http.*
interface LckApiService {
// ── Auth ─────────────────────────────────────────────
@POST("auth/meta/callback")
suspend fun metaCallback(@Body body: MetaCallbackRequest): AuthTokensResponse
@POST("auth/refresh")
suspend fun refreshSession(@Body body: RefreshRequest): AuthTokensResponse
@GET("auth/me")
suspend fun getMe(): UserProfileResponse
@POST("auth/logout")
suspend fun logout(): SuccessResponse
// ── Providers ────────────────────────────────────────
@GET("providers/accounts")
suspend fun getLinkedAccounts(): List<LinkedAccountResponse>
@GET("providers/youtube/auth-url")
suspend fun getYouTubeAuthUrl(): AuthUrlResponse
@POST("providers/youtube/callback")
suspend fun youtubeCallback(@Body body: ProviderCallbackRequest): LinkedAccountResponse
@GET("providers/twitch/auth-url")
suspend fun getTwitchAuthUrl(): AuthUrlResponse
@POST("providers/twitch/callback")
suspend fun twitchCallback(@Body body: ProviderCallbackRequest): LinkedAccountResponse
@DELETE("providers/{serviceId}")
suspend fun unlinkAccount(@Path("serviceId") serviceId: String): SuccessResponse
// ── Streams ──────────────────────────────────────────
@GET("streams/plans")
suspend fun getStreamPlans(): List<StreamPlanResponse>
@POST("streams/plans")
suspend fun createStreamPlan(@Body body: CreateStreamPlanRequest): StreamPlanResponse
@GET("streams/plans/{id}")
suspend fun getStreamPlan(@Path("id") id: String): StreamPlanResponse
@DELETE("streams/plans/{id}")
suspend fun deleteStreamPlan(@Path("id") id: String): SuccessResponse
@POST("streams/plans/{id}/prepare")
suspend fun prepareStreamPlan(@Path("id") id: String): PrepareResponse
@POST("streams/plans/{id}/start")
suspend fun startStreamPlan(@Path("id") id: String): StatusResponse
@POST("streams/plans/{id}/end")
suspend fun endStreamPlan(@Path("id") id: String): StatusResponse
}

View File

@@ -0,0 +1,88 @@
package com.omixlab.lckcontrol.data.repository
import com.omixlab.lckcontrol.data.local.dao.LinkedAccountDao
import com.omixlab.lckcontrol.data.local.entity.LinkedAccountEntity
import com.omixlab.lckcontrol.data.remote.LckApiService
import com.omixlab.lckcontrol.data.remote.ProviderCallbackRequest
import com.omixlab.lckcontrol.shared.LinkedAccount
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.map
import javax.inject.Inject
import javax.inject.Singleton
@Singleton
class AccountRepository @Inject constructor(
private val accountDao: LinkedAccountDao,
private val apiService: LckApiService,
) {
fun observeAccounts(): Flow<List<LinkedAccount>> =
accountDao.observeAll().map { entities ->
entities.map { it.toLinkedAccount() }
}
suspend fun getAccounts(): List<LinkedAccount> =
accountDao.getAll().map { it.toLinkedAccount() }
/** Fetch accounts from backend and sync to Room cache */
suspend fun syncAccounts() {
val remote = apiService.getLinkedAccounts()
// Clear local and replace with remote data
val entities = remote.map { account ->
LinkedAccountEntity(
serviceId = account.serviceId,
displayName = account.displayName,
accountId = account.accountId,
avatarUrl = account.avatarUrl,
)
}
// Get current local accounts to detect removals
val local = accountDao.getAll()
val remoteServiceIds = entities.map { it.serviceId }.toSet()
for (localAccount in local) {
if (localAccount.serviceId !in remoteServiceIds) {
accountDao.deleteByService(localAccount.serviceId)
}
}
for (entity in entities) {
accountDao.upsert(entity)
}
}
/** Get YouTube OAuth URL from backend (for Custom Tabs) */
suspend fun getYouTubeAuthUrl(): String {
val response = apiService.getYouTubeAuthUrl()
return response.url
}
/** Get Twitch OAuth URL from backend (for Custom Tabs) */
suspend fun getTwitchAuthUrl(): String {
val response = apiService.getTwitchAuthUrl()
return response.url
}
/** Send YouTube callback code to backend */
suspend fun handleYouTubeCallback(code: String, state: String) {
apiService.youtubeCallback(ProviderCallbackRequest(code, state))
syncAccounts()
}
/** Send Twitch callback code to backend */
suspend fun handleTwitchCallback(code: String, state: String) {
apiService.twitchCallback(ProviderCallbackRequest(code, state))
syncAccounts()
}
/** Unlink account via backend and update local cache */
suspend fun unlinkAccount(serviceId: String) {
apiService.unlinkAccount(serviceId)
accountDao.deleteByService(serviceId)
}
private fun LinkedAccountEntity.toLinkedAccount() = LinkedAccount(
serviceId = serviceId,
displayName = displayName,
accountId = accountId,
avatarUrl = avatarUrl,
isAuthenticated = true, // Backend manages auth state
)
}

View File

@@ -0,0 +1,143 @@
package com.omixlab.lckcontrol.data.repository
import com.omixlab.lckcontrol.data.local.dao.StreamPlanDao
import com.omixlab.lckcontrol.data.local.entity.StreamDestinationEntity
import com.omixlab.lckcontrol.data.local.entity.StreamPlanEntity
import com.omixlab.lckcontrol.data.local.entity.StreamPlanWithDestinations
import com.omixlab.lckcontrol.data.remote.CreateDestinationRequest
import com.omixlab.lckcontrol.data.remote.CreateStreamPlanRequest
import com.omixlab.lckcontrol.data.remote.LckApiService
import com.omixlab.lckcontrol.data.remote.PrepareResponse
import com.omixlab.lckcontrol.data.remote.StreamPlanResponse
import com.omixlab.lckcontrol.shared.StreamDestination
import com.omixlab.lckcontrol.shared.StreamPlan
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.map
import javax.inject.Inject
import javax.inject.Singleton
@Singleton
class StreamPlanRepository @Inject constructor(
private val planDao: StreamPlanDao,
private val apiService: LckApiService,
) {
fun observePlans(): Flow<List<StreamPlan>> =
planDao.observeAll().map { list -> list.map { it.toStreamPlan() } }
fun observePlan(planId: String): Flow<StreamPlan?> =
planDao.observeById(planId).map { it?.toStreamPlan() }
suspend fun getPlans(): List<StreamPlan> =
planDao.getAll().map { it.toStreamPlan() }
suspend fun getPlan(planId: String): StreamPlan? =
planDao.getById(planId)?.toStreamPlan()
/** Sync plans from backend to local Room cache */
suspend fun syncPlans() {
val remotePlans = apiService.getStreamPlans()
for (remote in remotePlans) {
cacheRemotePlan(remote)
}
}
/** Create plan via backend and cache locally */
suspend fun createPlan(name: String, destinations: List<StreamDestination>): StreamPlan {
val request = CreateStreamPlanRequest(
name = name,
destinations = destinations.map { dest ->
CreateDestinationRequest(
serviceId = dest.service,
title = dest.title,
description = dest.description,
privacyStatus = dest.privacyStatus,
gameId = dest.gameId,
tags = dest.tags.joinToString(","),
)
},
)
val response = apiService.createStreamPlan(request)
cacheRemotePlan(response)
return planDao.getById(response.id)!!.toStreamPlan()
}
/** Prepare plan via backend — returns RTMP info */
suspend fun preparePlan(planId: String): PrepareResponse {
val response = apiService.prepareStreamPlan(planId)
// Update local cache with RTMP info
for (dest in response.destinations) {
// Find local destination by serviceId within this plan
val local = planDao.getById(planId)?.destinations
?.find { it.service == dest.serviceId }
if (local != null) {
planDao.updateDestinationStream(
id = local.id,
rtmpUrl = dest.rtmpUrl,
streamKey = dest.streamKey,
broadcastId = dest.broadcastId,
status = "READY",
)
}
}
planDao.updateStatus(planId, "READY")
return response
}
/** Start plan via backend */
suspend fun startPlan(planId: String) {
apiService.startStreamPlan(planId)
planDao.updateStatus(planId, "LIVE")
}
/** End plan via backend */
suspend fun endPlan(planId: String) {
apiService.endStreamPlan(planId)
planDao.updateStatus(planId, "ENDED")
}
suspend fun deletePlan(planId: String) {
apiService.deleteStreamPlan(planId)
planDao.deletePlan(planId)
}
private suspend fun cacheRemotePlan(remote: StreamPlanResponse) {
val planEntity = StreamPlanEntity(planId = remote.id, name = remote.name, status = remote.status)
val destEntities = remote.destinations.map { d ->
StreamDestinationEntity(
id = d.id,
planId = remote.id,
service = d.serviceId,
title = d.title,
description = d.description,
privacyStatus = d.privacyStatus,
gameId = d.gameId,
tags = d.tags,
rtmpUrl = d.rtmpUrl,
streamKey = d.streamKey,
broadcastId = d.broadcastId,
status = d.status,
)
}
planDao.insertPlanWithDestinations(planEntity, destEntities)
}
private fun StreamPlanWithDestinations.toStreamPlan() = StreamPlan(
planId = plan.planId,
name = plan.name,
status = plan.status,
destinations = destinations.map { it.toStreamDestination() },
)
private fun StreamDestinationEntity.toStreamDestination() = StreamDestination(
service = service,
title = title,
description = description,
privacyStatus = privacyStatus,
gameId = gameId,
tags = tags.split(",").filter { it.isNotBlank() },
rtmpUrl = rtmpUrl,
streamKey = streamKey,
broadcastId = broadcastId,
status = status,
)
}

View File

@@ -0,0 +1,56 @@
package com.omixlab.lckcontrol.di
import com.omixlab.lckcontrol.data.remote.AuthInterceptor
import com.omixlab.lckcontrol.data.remote.LckApiService
import com.squareup.moshi.Moshi
import com.squareup.moshi.kotlin.reflect.KotlinJsonAdapterFactory
import dagger.Module
import dagger.Provides
import dagger.hilt.InstallIn
import dagger.hilt.components.SingletonComponent
import okhttp3.OkHttpClient
import okhttp3.logging.HttpLoggingInterceptor
import retrofit2.Retrofit
import retrofit2.converter.moshi.MoshiConverterFactory
import javax.inject.Singleton
@Module
@InstallIn(SingletonComponent::class)
object AppModule {
// TODO: Set from BuildConfig or remote config
private const val BASE_URL = "http://192.168.1.60:3100/"
@Provides
@Singleton
fun provideMoshi(): Moshi =
Moshi.Builder()
.addLast(KotlinJsonAdapterFactory())
.build()
@Provides
@Singleton
fun provideOkHttpClient(authInterceptor: AuthInterceptor): OkHttpClient =
OkHttpClient.Builder()
.addInterceptor(authInterceptor)
.addInterceptor(
HttpLoggingInterceptor().apply {
level = HttpLoggingInterceptor.Level.BODY
}
)
.build()
@Provides
@Singleton
fun provideRetrofit(okHttpClient: OkHttpClient, moshi: Moshi): Retrofit =
Retrofit.Builder()
.baseUrl(BASE_URL)
.client(okHttpClient)
.addConverterFactory(MoshiConverterFactory.create(moshi))
.build()
@Provides
@Singleton
fun provideLckApiService(retrofit: Retrofit): LckApiService =
retrofit.create(LckApiService::class.java)
}

View File

@@ -0,0 +1,31 @@
package com.omixlab.lckcontrol.di
import android.content.Context
import androidx.room.Room
import com.omixlab.lckcontrol.data.local.LckDatabase
import com.omixlab.lckcontrol.data.local.dao.LinkedAccountDao
import com.omixlab.lckcontrol.data.local.dao.StreamPlanDao
import dagger.Module
import dagger.Provides
import dagger.hilt.InstallIn
import dagger.hilt.android.qualifiers.ApplicationContext
import dagger.hilt.components.SingletonComponent
import javax.inject.Singleton
@Module
@InstallIn(SingletonComponent::class)
object DatabaseModule {
@Provides
@Singleton
fun provideDatabase(@ApplicationContext context: Context): LckDatabase =
Room.databaseBuilder(context, LckDatabase::class.java, "lck_control.db")
.addMigrations(LckDatabase.MIGRATION_1_2)
.build()
@Provides
fun provideLinkedAccountDao(db: LckDatabase): LinkedAccountDao = db.linkedAccountDao()
@Provides
fun provideStreamPlanDao(db: LckDatabase): StreamPlanDao = db.streamPlanDao()
}

View File

@@ -0,0 +1,41 @@
package com.omixlab.lckcontrol.service
import java.util.UUID
import java.util.concurrent.ConcurrentHashMap
data class ConnectedClient(
val clientId: String,
val clientName: String,
val packageName: String,
val activePlanId: String? = null,
val connectedAt: Long = System.currentTimeMillis(),
)
class ClientTracker {
private val clients = ConcurrentHashMap<String, ConnectedClient>()
fun register(clientName: String, packageName: String): String {
val clientId = UUID.randomUUID().toString()
clients[clientId] = ConnectedClient(
clientId = clientId,
clientName = clientName,
packageName = packageName,
)
return clientId
}
fun unregister(clientId: String): ConnectedClient? = clients.remove(clientId)
fun setActivePlan(clientId: String, planId: String?) {
clients.computeIfPresent(clientId) { _, client ->
client.copy(activePlanId = planId)
}
}
fun getClient(clientId: String): ConnectedClient? = clients[clientId]
fun getAll(): List<ConnectedClient> = clients.values.toList()
fun count(): Int = clients.size
}

View File

@@ -0,0 +1,203 @@
package com.omixlab.lckcontrol.service
import android.app.Notification
import android.app.NotificationChannel
import android.app.NotificationManager
import android.app.Service
import android.content.Intent
import android.content.pm.ServiceInfo
import android.os.IBinder
import android.os.RemoteCallbackList
import com.omixlab.lckcontrol.R
import com.omixlab.lckcontrol.data.repository.AccountRepository
import com.omixlab.lckcontrol.data.repository.StreamPlanRepository
import com.omixlab.lckcontrol.shared.ILckControlCallback
import com.omixlab.lckcontrol.shared.ILckControlService
import com.omixlab.lckcontrol.shared.LinkedAccount
import com.omixlab.lckcontrol.shared.StreamPlan
import com.omixlab.lckcontrol.shared.StreamPlanConfig
import dagger.hilt.android.AndroidEntryPoint
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.cancel
import kotlinx.coroutines.launch
import kotlinx.coroutines.runBlocking
import javax.inject.Inject
@AndroidEntryPoint
class LckControlService : Service() {
companion object {
private const val CHANNEL_ID = "lck_control_service"
private const val NOTIFICATION_ID = 1
}
@Inject lateinit var accountRepository: AccountRepository
@Inject lateinit var streamPlanRepository: StreamPlanRepository
private val serviceScope = CoroutineScope(SupervisorJob() + Dispatchers.Main)
private val clientTracker = ClientTracker()
private val callbacks = RemoteCallbackList<ILckControlCallback>()
private val binder = object : ILckControlService.Stub() {
override fun getLinkedAccounts(): List<LinkedAccount> = runBlocking {
accountRepository.getAccounts()
}
override fun createStreamPlan(config: StreamPlanConfig): StreamPlan = runBlocking {
val plan = streamPlanRepository.createPlan(config.name, config.destinations)
broadcastPlansChanged()
plan
}
override fun prepareStreamPlan(planId: String): StreamPlan = runBlocking {
streamPlanRepository.preparePlan(planId)
val plan = streamPlanRepository.getPlan(planId) ?: error("Plan not found after prepare")
broadcastPlanUpdated(plan)
plan
}
override fun getStreamPlans(): List<StreamPlan> = runBlocking {
streamPlanRepository.getPlans()
}
override fun getStreamPlan(planId: String): StreamPlan? = runBlocking {
streamPlanRepository.getPlan(planId)
}
override fun startStreamPlan(planId: String): Boolean = runBlocking {
val plan = streamPlanRepository.getPlan(planId) ?: return@runBlocking false
if (plan.status != "READY") return@runBlocking false
streamPlanRepository.startPlan(planId)
val updated = streamPlanRepository.getPlan(planId)
if (updated != null) broadcastPlanUpdated(updated)
true
}
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
}
override fun registerClient(clientName: String, packageName: String): String {
val clientId = clientTracker.register(clientName, packageName)
broadcastClientRegistered(clientId)
return clientId
}
override fun unregisterClient(clientId: String) {
clientTracker.unregister(clientId)
broadcastClientUnregistered(clientId)
}
override fun setClientActivePlan(clientId: String, planId: String) {
clientTracker.setActivePlan(clientId, planId)
}
override fun registerCallback(callback: ILckControlCallback) {
callbacks.register(callback)
}
override fun unregisterCallback(callback: ILckControlCallback) {
callbacks.unregister(callback)
}
}
override fun onCreate() {
super.onCreate()
createNotificationChannel()
startForeground(
NOTIFICATION_ID,
buildNotification(),
ServiceInfo.FOREGROUND_SERVICE_TYPE_CONNECTED_DEVICE,
)
}
override fun onBind(intent: Intent?): IBinder = binder
override fun onDestroy() {
serviceScope.cancel()
callbacks.kill()
super.onDestroy()
}
private fun createNotificationChannel() {
val channel = NotificationChannel(
CHANNEL_ID,
"LCK Control Service",
NotificationManager.IMPORTANCE_LOW,
).apply {
description = "Manages connected game clients and stream plans"
}
getSystemService(NotificationManager::class.java).createNotificationChannel(channel)
}
private fun buildNotification(): Notification =
Notification.Builder(this, CHANNEL_ID)
.setContentTitle("LCK Control")
.setContentText("Managing stream connections")
.setSmallIcon(R.drawable.ic_launcher_foreground)
.setOngoing(true)
.build()
private fun broadcastPlansChanged() {
serviceScope.launch {
val plans = streamPlanRepository.getPlans()
val count = callbacks.beginBroadcast()
try {
for (i in 0 until count) {
try {
callbacks.getBroadcastItem(i).onStreamPlansChanged(plans)
} catch (_: Exception) {}
}
} finally {
callbacks.finishBroadcast()
}
}
}
private fun broadcastPlanUpdated(plan: StreamPlan) {
val count = callbacks.beginBroadcast()
try {
for (i in 0 until count) {
try {
callbacks.getBroadcastItem(i).onStreamPlanUpdated(plan)
} catch (_: Exception) {}
}
} finally {
callbacks.finishBroadcast()
}
}
private fun broadcastClientRegistered(clientId: String) {
val count = callbacks.beginBroadcast()
try {
for (i in 0 until count) {
try {
callbacks.getBroadcastItem(i).onClientRegistered(clientId)
} catch (_: Exception) {}
}
} finally {
callbacks.finishBroadcast()
}
}
private fun broadcastClientUnregistered(clientId: String) {
val count = callbacks.beginBroadcast()
try {
for (i in 0 until count) {
try {
callbacks.getBroadcastItem(i).onClientUnregistered(clientId)
} catch (_: Exception) {}
}
} finally {
callbacks.finishBroadcast()
}
}
}

View File

@@ -0,0 +1,119 @@
package com.omixlab.lckcontrol.ui.accounts
import android.app.Activity
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Add
import androidx.compose.material.icons.filled.LinkOff
import androidx.compose.material3.ElevatedCard
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedButton
import androidx.compose.material3.Scaffold
import androidx.compose.material3.SnackbarHost
import androidx.compose.material3.SnackbarHostState
import androidx.compose.material3.Text
import androidx.compose.material3.TopAppBar
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.unit.dp
import androidx.hilt.navigation.compose.hiltViewModel
import androidx.lifecycle.compose.collectAsStateWithLifecycle
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun AccountsScreen(
viewModel: AccountsViewModel = hiltViewModel(),
) {
val accounts by viewModel.accounts.collectAsStateWithLifecycle()
val linkError by viewModel.linkError.collectAsStateWithLifecycle()
val snackbarHostState = remember { SnackbarHostState() }
val context = LocalContext.current
LaunchedEffect(linkError) {
linkError?.let {
snackbarHostState.showSnackbar(it)
viewModel.clearError()
}
}
Scaffold(
topBar = {
TopAppBar(title = { Text("Linked Accounts") })
},
snackbarHost = { SnackbarHost(snackbarHostState) },
) { padding ->
LazyColumn(
modifier = Modifier
.fillMaxSize()
.padding(padding)
.padding(horizontal = 16.dp),
verticalArrangement = Arrangement.spacedBy(12.dp),
) {
item { Spacer(Modifier.height(8.dp)) }
items(accounts, key = { it.serviceId }) { account ->
ElevatedCard(modifier = Modifier.fillMaxWidth()) {
Row(
modifier = Modifier
.fillMaxWidth()
.padding(16.dp),
verticalAlignment = Alignment.CenterVertically,
) {
Column(modifier = Modifier.weight(1f)) {
Text(account.displayName, style = MaterialTheme.typography.titleSmall)
Text(account.serviceId, style = MaterialTheme.typography.bodySmall)
}
IconButton(onClick = { viewModel.unlinkAccount(account.serviceId) }) {
Icon(Icons.Default.LinkOff, contentDescription = "Unlink")
}
}
}
}
// Show link buttons for providers not yet linked
val linkedServiceIds = accounts.map { it.serviceId }.toSet()
val unlinked = viewModel.getUnlinkedProviders(linkedServiceIds)
if (unlinked.isNotEmpty()) {
item {
Spacer(Modifier.height(8.dp))
Text("Add Account", style = MaterialTheme.typography.titleMedium)
Spacer(Modifier.height(4.dp))
}
items(unlinked, key = { it.serviceId }) { provider ->
OutlinedButton(
onClick = {
val activity = context as? Activity ?: return@OutlinedButton
viewModel.linkAccount(activity, provider.serviceId)
},
modifier = Modifier.fillMaxWidth(),
) {
Icon(Icons.Default.Add, contentDescription = null)
Spacer(Modifier.padding(4.dp))
Text("Link ${provider.displayName}")
}
}
}
item { Spacer(Modifier.height(16.dp)) }
}
}
}

View File

@@ -0,0 +1,84 @@
package com.omixlab.lckcontrol.ui.accounts
import android.app.Activity
import android.net.Uri
import androidx.browser.customtabs.CustomTabsIntent
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import com.omixlab.lckcontrol.data.repository.AccountRepository
import com.omixlab.lckcontrol.shared.LinkedAccount
import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.launch
import javax.inject.Inject
data class AvailableProvider(
val serviceId: String,
val displayName: String,
)
val ALL_PROVIDERS = listOf(
AvailableProvider("YOUTUBE", "YouTube"),
AvailableProvider("TWITCH", "Twitch"),
)
@HiltViewModel
class AccountsViewModel @Inject constructor(
private val accountRepository: AccountRepository,
) : ViewModel() {
val accounts: StateFlow<List<LinkedAccount>> = accountRepository.observeAccounts()
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), emptyList())
private val _linkError = MutableStateFlow<String?>(null)
val linkError: StateFlow<String?> = _linkError.asStateFlow()
init {
// Sync accounts from backend on load
viewModelScope.launch {
try {
accountRepository.syncAccounts()
} catch (e: Exception) {
// Offline — local cache still works
}
}
}
fun getUnlinkedProviders(linkedServiceIds: Set<String>): List<AvailableProvider> =
ALL_PROVIDERS.filter { it.serviceId !in linkedServiceIds }
fun linkAccount(activity: Activity, serviceId: String) {
viewModelScope.launch {
_linkError.value = null
try {
val url = when (serviceId) {
"YOUTUBE" -> accountRepository.getYouTubeAuthUrl()
"TWITCH" -> accountRepository.getTwitchAuthUrl()
else -> throw IllegalArgumentException("Unknown service: $serviceId")
}
val customTabsIntent = CustomTabsIntent.Builder().build()
customTabsIntent.launchUrl(activity, Uri.parse(url))
} catch (e: Exception) {
_linkError.value = e.message ?: "Failed to start auth flow"
}
}
}
fun unlinkAccount(serviceId: String) {
viewModelScope.launch {
try {
accountRepository.unlinkAccount(serviceId)
} catch (e: Exception) {
_linkError.value = e.message ?: "Failed to unlink account"
}
}
}
fun clearError() {
_linkError.value = null
}
}

View File

@@ -0,0 +1,137 @@
package com.omixlab.lckcontrol.ui.clients
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Circle
import androidx.compose.material.icons.filled.Devices
import androidx.compose.material3.ElevatedCard
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Text
import androidx.compose.material3.TopAppBar
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp
import androidx.hilt.navigation.compose.hiltViewModel
import androidx.lifecycle.compose.collectAsStateWithLifecycle
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun ActiveClientsScreen(
viewModel: ActiveClientsViewModel = hiltViewModel(),
) {
val clients by viewModel.clients.collectAsStateWithLifecycle()
val isConnected by viewModel.isConnected.collectAsStateWithLifecycle()
Scaffold(
topBar = {
TopAppBar(title = { Text("Active Clients") })
},
) { padding ->
LazyColumn(
modifier = Modifier
.fillMaxSize()
.padding(padding)
.padding(horizontal = 16.dp),
verticalArrangement = Arrangement.spacedBy(12.dp),
) {
item {
Spacer(Modifier.height(8.dp))
// Service status
ElevatedCard(modifier = Modifier.fillMaxWidth()) {
Row(
modifier = Modifier.padding(16.dp),
verticalAlignment = Alignment.CenterVertically,
) {
Icon(
Icons.Default.Circle,
contentDescription = null,
tint = if (isConnected) MaterialTheme.colorScheme.primary
else MaterialTheme.colorScheme.error,
modifier = Modifier.height(12.dp).width(12.dp),
)
Spacer(Modifier.width(8.dp))
Text(
if (isConnected) "Service Running" else "Service Disconnected",
style = MaterialTheme.typography.titleSmall,
)
}
}
}
if (clients.isEmpty()) {
item {
Column(
modifier = Modifier
.fillMaxWidth()
.padding(vertical = 32.dp),
horizontalAlignment = Alignment.CenterHorizontally,
) {
Icon(
Icons.Default.Devices,
contentDescription = null,
modifier = Modifier.height(48.dp).width(48.dp),
tint = MaterialTheme.colorScheme.onSurfaceVariant,
)
Spacer(Modifier.height(12.dp))
Text(
"No active clients",
style = MaterialTheme.typography.bodyLarge,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
Text(
"Game clients will appear here when connected",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
}
} else {
items(clients, key = { it.planId }) { client ->
ElevatedCard(modifier = Modifier.fillMaxWidth()) {
Row(
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,
)
}
Text(
client.planStatus,
style = MaterialTheme.typography.labelMedium,
color = when (client.planStatus) {
"LIVE" -> MaterialTheme.colorScheme.error
"READY" -> MaterialTheme.colorScheme.primary
else -> MaterialTheme.colorScheme.onSurfaceVariant
},
)
}
}
}
}
item { Spacer(Modifier.height(16.dp)) }
}
}
}

View File

@@ -0,0 +1,111 @@
package com.omixlab.lckcontrol.ui.clients
import android.content.ComponentName
import android.content.Context
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.ILckControlCallback
import com.omixlab.lckcontrol.shared.ILckControlService
import com.omixlab.lckcontrol.shared.StreamPlan
import dagger.hilt.android.lifecycle.HiltViewModel
import dagger.hilt.android.qualifiers.ApplicationContext
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import javax.inject.Inject
@HiltViewModel
class ActiveClientsViewModel @Inject constructor(
@ApplicationContext private val context: Context,
) : ViewModel() {
private var service: ILckControlService? = null
private val _clients = MutableStateFlow<List<ClientInfo>>(emptyList())
val clients: StateFlow<List<ClientInfo>> = _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 onStreamPlanUpdated(plan: StreamPlan) {
refreshClients()
}
override fun onClientRegistered(clientId: String) {
refreshClients()
}
override fun onClientUnregistered(clientId: String) {
refreshClients()
}
}
private val connection = object : ServiceConnection {
override fun onServiceConnected(name: ComponentName?, binder: IBinder?) {
service = ILckControlService.Stub.asInterface(binder)
service?.registerCallback(callback)
_isConnected.value = true
refreshClients()
}
override fun onServiceDisconnected(name: ComponentName?) {
service = null
_isConnected.value = false
_clients.value = emptyList()
}
}
init {
bindToService()
}
private fun bindToService() {
val intent = Intent().apply {
component = ComponentName(
context.packageName,
"com.omixlab.lckcontrol.service.LckControlService",
)
}
context.bindService(intent, connection, Context.BIND_AUTO_CREATE)
}
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,
)
}
}
override fun onCleared() {
service?.unregisterCallback(callback)
try {
context.unbindService(connection)
} catch (_: IllegalArgumentException) {}
super.onCleared()
}
}
data class ClientInfo(
val planId: String,
val planName: String,
val planStatus: String,
val destinationCount: Int,
)

View File

@@ -0,0 +1,177 @@
package com.omixlab.lckcontrol.ui.dashboard
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Add
import androidx.compose.material.icons.filled.Circle
import androidx.compose.material3.Card
import androidx.compose.material3.CardDefaults
import androidx.compose.material3.ElevatedCard
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.FloatingActionButton
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Text
import androidx.compose.material3.TopAppBar
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.ui.Alignment
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.StreamPlan
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun DashboardScreen(
onNavigateToCreatePlan: () -> Unit,
onNavigateToPlan: (String) -> Unit,
viewModel: DashboardViewModel = hiltViewModel(),
) {
val accounts by viewModel.accounts.collectAsStateWithLifecycle()
val plans by viewModel.plans.collectAsStateWithLifecycle()
Scaffold(
topBar = {
TopAppBar(title = { Text("LCK Control") })
},
floatingActionButton = {
FloatingActionButton(onClick = onNavigateToCreatePlan) {
Icon(Icons.Default.Add, contentDescription = "Create Plan")
}
},
) { padding ->
LazyColumn(
modifier = Modifier
.fillMaxSize()
.padding(padding)
.padding(horizontal = 16.dp),
verticalArrangement = Arrangement.spacedBy(12.dp),
) {
item {
Spacer(Modifier.height(8.dp))
Text("Linked Accounts", style = MaterialTheme.typography.titleMedium)
Spacer(Modifier.height(4.dp))
}
if (accounts.isEmpty()) {
item {
Card(
modifier = Modifier.fillMaxWidth(),
colors = CardDefaults.cardColors(
containerColor = MaterialTheme.colorScheme.surfaceVariant,
),
) {
Text(
"No accounts linked yet. Go to Accounts to get started.",
modifier = Modifier.padding(16.dp),
style = MaterialTheme.typography.bodyMedium,
)
}
}
} else {
item {
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
accounts.forEach { account ->
ElevatedCard {
Column(modifier = Modifier.padding(12.dp)) {
Text(account.displayName, style = MaterialTheme.typography.labelLarge)
Text(account.serviceId, style = MaterialTheme.typography.bodySmall)
}
}
}
}
}
}
item {
Spacer(Modifier.height(8.dp))
Text("Stream Plans", style = MaterialTheme.typography.titleMedium)
Spacer(Modifier.height(4.dp))
}
if (plans.isEmpty()) {
item {
Card(
modifier = Modifier.fillMaxWidth(),
colors = CardDefaults.cardColors(
containerColor = MaterialTheme.colorScheme.surfaceVariant,
),
) {
Text(
"No stream plans yet. Tap + to create one.",
modifier = Modifier.padding(16.dp),
style = MaterialTheme.typography.bodyMedium,
)
}
}
} else {
items(plans, key = { it.planId }) { plan ->
PlanCard(plan = plan, onClick = { onNavigateToPlan(plan.planId) })
}
}
item { Spacer(Modifier.height(80.dp)) }
}
}
}
@Composable
private fun PlanCard(plan: StreamPlan, onClick: () -> Unit) {
ElevatedCard(
modifier = Modifier
.fillMaxWidth()
.clickable(onClick = onClick),
) {
Column(modifier = Modifier.padding(16.dp)) {
Row(
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier.fillMaxWidth(),
) {
Text(plan.name, style = MaterialTheme.typography.titleSmall, modifier = Modifier.weight(1f))
Spacer(Modifier.width(8.dp))
StatusChip(plan.status)
}
Spacer(Modifier.height(4.dp))
Text(
"${plan.destinations.size} destination(s)",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
}
}
@Composable
private fun StatusChip(status: String) {
val color = when (status) {
"LIVE" -> MaterialTheme.colorScheme.error
"READY" -> MaterialTheme.colorScheme.primary
"PREPARING" -> MaterialTheme.colorScheme.tertiary
"ENDED" -> MaterialTheme.colorScheme.outline
else -> MaterialTheme.colorScheme.onSurfaceVariant
}
Row(verticalAlignment = Alignment.CenterVertically) {
Icon(
Icons.Default.Circle,
contentDescription = null,
tint = color,
modifier = Modifier.height(8.dp).width(8.dp),
)
Spacer(Modifier.width(4.dp))
Text(status, style = MaterialTheme.typography.labelSmall, color = color)
}
}

View File

@@ -0,0 +1,26 @@
package com.omixlab.lckcontrol.ui.dashboard
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import com.omixlab.lckcontrol.data.repository.AccountRepository
import com.omixlab.lckcontrol.data.repository.StreamPlanRepository
import com.omixlab.lckcontrol.shared.LinkedAccount
import com.omixlab.lckcontrol.shared.StreamPlan
import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.stateIn
import javax.inject.Inject
@HiltViewModel
class DashboardViewModel @Inject constructor(
accountRepository: AccountRepository,
streamPlanRepository: StreamPlanRepository,
) : ViewModel() {
val accounts: StateFlow<List<LinkedAccount>> = accountRepository.observeAccounts()
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), emptyList())
val plans: StateFlow<List<StreamPlan>> = streamPlanRepository.observePlans()
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), emptyList())
}

View File

@@ -0,0 +1,91 @@
package com.omixlab.lckcontrol.ui.login
import android.app.Activity
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.material3.Button
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.unit.dp
import androidx.hilt.navigation.compose.hiltViewModel
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.omixlab.lckcontrol.BuildConfig
@Composable
fun LoginScreen(
onLoginSuccess: () -> Unit = {},
viewModel: LoginViewModel = hiltViewModel(),
) {
val isLoading by viewModel.isLoading.collectAsStateWithLifecycle()
val error by viewModel.error.collectAsStateWithLifecycle()
val context = LocalContext.current
Box(
modifier = Modifier
.fillMaxSize()
.padding(32.dp),
) {
Column(
modifier = Modifier.fillMaxSize(),
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.Center,
) {
Text(
text = "LCK Control",
style = MaterialTheme.typography.headlineLarge,
)
Spacer(Modifier.height(8.dp))
Text(
text = "Stream management for Quest",
style = MaterialTheme.typography.bodyLarge,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
Spacer(Modifier.height(48.dp))
if (isLoading) {
CircularProgressIndicator()
} else {
Button(
onClick = {
val activity = context as? Activity ?: return@Button
viewModel.loginWithQuest(activity)
},
modifier = Modifier.fillMaxWidth(),
) {
Text("Sign in with Quest")
}
}
error?.let { errorMsg ->
Spacer(Modifier.height(16.dp))
Text(
text = errorMsg,
color = MaterialTheme.colorScheme.error,
style = MaterialTheme.typography.bodySmall,
)
}
}
Text(
text = BuildConfig.DISPLAY_VERSION,
modifier = Modifier.align(Alignment.BottomCenter),
style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.5f),
)
}
}

View File

@@ -0,0 +1,145 @@
package com.omixlab.lckcontrol.ui.login
import android.app.Activity
import android.util.Log
import androidx.lifecycle.ViewModel
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.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
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.launch
import javax.inject.Inject
import kotlin.coroutines.resume
import kotlin.coroutines.resumeWithException
import kotlin.coroutines.suspendCoroutine
@HiltViewModel
class LoginViewModel @Inject constructor(
private val tokenStore: TokenStore,
private val apiService: LckApiService,
) : ViewModel() {
private val _isLoading = MutableStateFlow(false)
val isLoading: StateFlow<Boolean> = _isLoading.asStateFlow()
private val _error = MutableStateFlow<String?>(null)
val error: StateFlow<String?> = _error.asStateFlow()
fun isLoggedIn(): Boolean = tokenStore.isLoggedIn()
fun loginWithQuest(activity: Activity) {
viewModelScope.launch {
_isLoading.value = true
_error.value = null
try {
// Initialize Platform SDK
if (!Core.isInitialized()) {
Log.d(TAG, "Initializing Platform SDK with appId=$QUEST_APP_ID")
val initResult = Core.initialize(QUEST_APP_ID, activity.applicationContext)
Log.d(TAG, "Core.initialize returned: $initResult")
}
Log.d(TAG, "Core.isInitialized=${Core.isInitialized()}")
// Check cached user ID first (synchronous)
val cachedUserId = Core.getLoggedInUserID()
Log.d(TAG, "Core.getLoggedInUserID() = $cachedUserId")
if (cachedUserId == 0L) {
throw Exception("Platform SDK returned user ID 0. " +
"Make sure the app is installed from the Horizon store (not sideloaded) " +
"and your account is a test user for this app.")
}
// Get full user via async call with manual message pump
val user = awaitWithPump { Users.getLoggedInUser() }
val userId = user.getID().toString()
Log.d(TAG, "User: id=$userId displayName=${user.displayName}")
// Get user proof (nonce)
val proof = awaitWithPump { Users.getUserProof() }
val nonce = proof.value
Log.d(TAG, "UserProof nonce=$nonce")
// Send to backend for verification
val response = apiService.metaCallback(
MetaCallbackRequest(
userId = userId,
nonce = nonce,
deviceInfo = android.os.Build.MODEL,
)
)
// Save session tokens
tokenStore.saveSession(response.accessToken, response.refreshToken)
} catch (e: Exception) {
Log.e(TAG, "Quest login failed", e)
_error.value = e.message ?: "Login failed"
} finally {
_isLoading.value = false
}
}
}
fun clearError() {
_error.value = null
}
/**
* Awaits a Platform SDK request by manually pumping the message queue.
* The SDK requires Request.runCallbacks() to be called for callbacks to fire.
*/
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(error.message))
}
}
// Pump messages on a background thread
Thread {
val timeout = System.currentTimeMillis() + 10_000 // 10s timeout
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()
}
companion object {
private const val TAG = "LoginViewModel"
const val QUEST_APP_ID = "25653777174321448"
}
}

View File

@@ -0,0 +1,121 @@
package com.omixlab.lckcontrol.ui.navigation
import androidx.compose.foundation.layout.padding
import androidx.compose.material.icons.Icons
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.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.getValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.navigation.NavGraph.Companion.findStartDestination
import androidx.navigation.NavType
import androidx.navigation.compose.NavHost
import androidx.navigation.compose.composable
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.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
private data class BottomNavItem(
val screen: Screen,
val label: String,
val icon: ImageVector,
)
private val bottomNavItems = listOf(
BottomNavItem(Screen.Dashboard, "Dashboard", Icons.Default.Dashboard),
BottomNavItem(Screen.Accounts, "Accounts", Icons.Default.Person),
BottomNavItem(Screen.ActiveClients, "Clients", Icons.Default.Devices),
)
@Composable
fun AppNavigation(tokenStore: TokenStore) {
val navController = rememberNavController()
val navBackStackEntry by navController.currentBackStackEntryAsState()
val currentRoute = navBackStackEntry?.destination?.route
val showBottomBar = currentRoute in bottomNavItems.map { it.screen.route }
val startDestination = if (tokenStore.isLoggedIn()) Screen.Dashboard.route else Screen.Login.route
Scaffold(
bottomBar = {
if (showBottomBar) {
NavigationBar {
bottomNavItems.forEach { item ->
NavigationBarItem(
icon = { Icon(item.icon, contentDescription = item.label) },
label = { Text(item.label) },
selected = currentRoute == item.screen.route,
onClick = {
navController.navigate(item.screen.route) {
popUpTo(navController.graph.findStartDestination().id) {
saveState = true
}
launchSingleTop = true
restoreState = true
}
},
)
}
}
}
},
) { innerPadding ->
NavHost(
navController = navController,
startDestination = startDestination,
modifier = Modifier.padding(innerPadding),
) {
composable(Screen.Login.route) {
LoginScreen()
}
composable(Screen.Dashboard.route) {
DashboardScreen(
onNavigateToCreatePlan = { navController.navigate(Screen.CreatePlan.route) },
onNavigateToPlan = { planId ->
navController.navigate(Screen.PlanDetail.createRoute(planId))
},
)
}
composable(Screen.Accounts.route) {
AccountsScreen()
}
composable(Screen.CreatePlan.route) {
CreatePlanScreen(
onPlanCreated = { planId ->
navController.navigate(Screen.PlanDetail.createRoute(planId)) {
popUpTo(Screen.Dashboard.route)
}
},
onBack = { navController.popBackStack() },
)
}
composable(
route = Screen.PlanDetail.route,
arguments = listOf(navArgument("planId") { type = NavType.StringType }),
) { backStackEntry ->
val planId = backStackEntry.arguments?.getString("planId") ?: return@composable
PlanDetailScreen(
planId = planId,
onBack = { navController.popBackStack() },
)
}
composable(Screen.ActiveClients.route) {
ActiveClientsScreen()
}
}
}
}

View File

@@ -0,0 +1,12 @@
package com.omixlab.lckcontrol.ui.navigation
sealed class Screen(val route: String) {
data object Login : Screen("login")
data object Dashboard : Screen("dashboard")
data object Accounts : Screen("accounts")
data object CreatePlan : Screen("create_plan")
data object PlanDetail : Screen("plan_detail/{planId}") {
fun createRoute(planId: String) = "plan_detail/$planId"
}
data object ActiveClients : Screen("active_clients")
}

View File

@@ -0,0 +1,249 @@
package com.omixlab.lckcontrol.ui.plans
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.itemsIndexed
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.ArrowBack
import androidx.compose.material.icons.filled.Add
import androidx.compose.material.icons.filled.Delete
import androidx.compose.material3.Button
import androidx.compose.material3.DropdownMenuItem
import androidx.compose.material3.ElevatedCard
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.ExposedDropdownMenuBox
import androidx.compose.material3.ExposedDropdownMenuDefaults
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.MenuAnchorType
import androidx.compose.material3.OutlinedButton
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Scaffold
import androidx.compose.material3.SnackbarHost
import androidx.compose.material3.SnackbarHostState
import androidx.compose.material3.Text
import androidx.compose.material3.TopAppBar
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.Modifier
import androidx.compose.ui.unit.dp
import androidx.hilt.navigation.compose.hiltViewModel
import androidx.lifecycle.compose.collectAsStateWithLifecycle
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun CreatePlanScreen(
onPlanCreated: (String) -> Unit,
onBack: () -> Unit,
viewModel: CreatePlanViewModel = hiltViewModel(),
) {
val planName by viewModel.planName.collectAsStateWithLifecycle()
val destinations by viewModel.destinations.collectAsStateWithLifecycle()
val linkedAccounts by viewModel.linkedAccounts.collectAsStateWithLifecycle()
val isCreating by viewModel.isCreating.collectAsStateWithLifecycle()
val error by viewModel.error.collectAsStateWithLifecycle()
val snackbarHostState = remember { SnackbarHostState() }
LaunchedEffect(error) {
error?.let {
snackbarHostState.showSnackbar(it)
viewModel.clearError()
}
}
Scaffold(
topBar = {
TopAppBar(
title = { Text("Create Stream Plan") },
navigationIcon = {
IconButton(onClick = onBack) {
Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = "Back")
}
},
)
},
snackbarHost = { SnackbarHost(snackbarHostState) },
) { padding ->
LazyColumn(
modifier = Modifier
.fillMaxSize()
.padding(padding)
.padding(horizontal = 16.dp),
verticalArrangement = Arrangement.spacedBy(12.dp),
) {
item {
Spacer(Modifier.height(8.dp))
OutlinedTextField(
value = planName,
onValueChange = viewModel::setPlanName,
label = { Text("Plan Name") },
modifier = Modifier.fillMaxWidth(),
singleLine = true,
)
}
item {
Spacer(Modifier.height(8.dp))
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween,
) {
Text("Destinations", style = MaterialTheme.typography.titleMedium)
OutlinedButton(onClick = viewModel::addDestination) {
Icon(Icons.Default.Add, contentDescription = null)
Spacer(Modifier.padding(4.dp))
Text("Add")
}
}
}
itemsIndexed(destinations) { index, dest ->
DestinationCard(
destination = dest,
availableServices = linkedAccounts.map { it.serviceId },
onUpdate = { viewModel.updateDestination(index, it) },
onRemove = { viewModel.removeDestination(index) },
)
}
item {
Spacer(Modifier.height(16.dp))
Button(
onClick = { viewModel.createPlan(onPlanCreated) },
modifier = Modifier.fillMaxWidth(),
enabled = !isCreating,
) {
Text(if (isCreating) "Creating..." else "Create Plan")
}
Spacer(Modifier.height(16.dp))
}
}
}
}
@OptIn(ExperimentalMaterial3Api::class)
@Composable
private fun DestinationCard(
destination: DestinationInput,
availableServices: List<String>,
onUpdate: (DestinationInput) -> Unit,
onRemove: () -> Unit,
) {
var serviceExpanded by remember { mutableStateOf(false) }
var privacyExpanded by remember { mutableStateOf(false) }
ElevatedCard(modifier = Modifier.fillMaxWidth()) {
Column(
modifier = Modifier.padding(16.dp),
verticalArrangement = Arrangement.spacedBy(8.dp),
) {
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween,
) {
Text("Destination", style = MaterialTheme.typography.labelLarge)
IconButton(onClick = onRemove) {
Icon(Icons.Default.Delete, contentDescription = "Remove")
}
}
// Service picker
ExposedDropdownMenuBox(
expanded = serviceExpanded,
onExpandedChange = { serviceExpanded = it },
) {
OutlinedTextField(
value = destination.service,
onValueChange = {},
readOnly = true,
label = { Text("Service") },
trailingIcon = { ExposedDropdownMenuDefaults.TrailingIcon(serviceExpanded) },
modifier = Modifier
.fillMaxWidth()
.menuAnchor(MenuAnchorType.PrimaryNotEditable),
)
ExposedDropdownMenu(
expanded = serviceExpanded,
onDismissRequest = { serviceExpanded = false },
) {
availableServices.forEach { service ->
DropdownMenuItem(
text = { Text(service) },
onClick = {
onUpdate(destination.copy(service = service))
serviceExpanded = false
},
)
}
}
}
OutlinedTextField(
value = destination.title,
onValueChange = { onUpdate(destination.copy(title = it)) },
label = { Text("Stream Title") },
modifier = Modifier.fillMaxWidth(),
singleLine = true,
)
OutlinedTextField(
value = destination.description,
onValueChange = { onUpdate(destination.copy(description = it)) },
label = { Text("Description") },
modifier = Modifier.fillMaxWidth(),
minLines = 2,
)
// Privacy status
ExposedDropdownMenuBox(
expanded = privacyExpanded,
onExpandedChange = { privacyExpanded = it },
) {
OutlinedTextField(
value = destination.privacyStatus,
onValueChange = {},
readOnly = true,
label = { Text("Privacy") },
trailingIcon = { ExposedDropdownMenuDefaults.TrailingIcon(privacyExpanded) },
modifier = Modifier
.fillMaxWidth()
.menuAnchor(MenuAnchorType.PrimaryNotEditable),
)
ExposedDropdownMenu(
expanded = privacyExpanded,
onDismissRequest = { privacyExpanded = false },
) {
listOf("public", "unlisted", "private").forEach { status ->
DropdownMenuItem(
text = { Text(status) },
onClick = {
onUpdate(destination.copy(privacyStatus = status))
privacyExpanded = false
},
)
}
}
}
OutlinedTextField(
value = destination.tags,
onValueChange = { onUpdate(destination.copy(tags = it)) },
label = { Text("Tags (comma-separated)") },
modifier = Modifier.fillMaxWidth(),
singleLine = true,
)
}
}
}

View File

@@ -0,0 +1,112 @@
package com.omixlab.lckcontrol.ui.plans
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import com.omixlab.lckcontrol.data.repository.AccountRepository
import com.omixlab.lckcontrol.data.repository.StreamPlanRepository
import com.omixlab.lckcontrol.shared.LinkedAccount
import com.omixlab.lckcontrol.shared.StreamDestination
import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.launch
import javax.inject.Inject
data class DestinationInput(
val service: String = "",
val title: String = "",
val description: String = "",
val privacyStatus: String = "public",
val gameId: String = "",
val tags: String = "",
)
@HiltViewModel
class CreatePlanViewModel @Inject constructor(
accountRepository: AccountRepository,
private val streamPlanRepository: StreamPlanRepository,
) : ViewModel() {
val linkedAccounts: StateFlow<List<LinkedAccount>> = accountRepository.observeAccounts()
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), emptyList())
private val _planName = MutableStateFlow("")
val planName: StateFlow<String> = _planName.asStateFlow()
private val _destinations = MutableStateFlow<List<DestinationInput>>(emptyList())
val destinations: StateFlow<List<DestinationInput>> = _destinations.asStateFlow()
private val _isCreating = MutableStateFlow(false)
val isCreating: StateFlow<Boolean> = _isCreating.asStateFlow()
private val _error = MutableStateFlow<String?>(null)
val error: StateFlow<String?> = _error.asStateFlow()
fun setPlanName(name: String) {
_planName.value = name
}
fun addDestination() {
_destinations.value = _destinations.value + DestinationInput()
}
fun updateDestination(index: Int, destination: DestinationInput) {
_destinations.value = _destinations.value.toMutableList().apply {
set(index, destination)
}
}
fun removeDestination(index: Int) {
_destinations.value = _destinations.value.toMutableList().apply {
removeAt(index)
}
}
fun createPlan(onCreated: (String) -> Unit) {
val name = _planName.value.trim()
val dests = _destinations.value
if (name.isBlank()) {
_error.value = "Plan name is required"
return
}
if (dests.isEmpty()) {
_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"
return
}
viewModelScope.launch {
_isCreating.value = true
_error.value = null
try {
val streamDests = dests.map { input ->
StreamDestination(
service = input.service,
title = input.title,
description = input.description,
privacyStatus = input.privacyStatus,
gameId = input.gameId,
tags = input.tags.split(",").map { it.trim() }.filter { it.isNotBlank() },
)
}
val plan = streamPlanRepository.createPlan(name, streamDests)
onCreated(plan.planId)
} catch (e: Exception) {
_error.value = e.message ?: "Failed to create plan"
} finally {
_isCreating.value = false
}
}
}
fun clearError() {
_error.value = null
}
}

View File

@@ -0,0 +1,236 @@
package com.omixlab.lckcontrol.ui.plans
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.ArrowBack
import androidx.compose.material.icons.filled.ContentCopy
import androidx.compose.material.icons.filled.Delete
import androidx.compose.material3.Button
import androidx.compose.material3.ButtonDefaults
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.ElevatedCard
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedButton
import androidx.compose.material3.Scaffold
import androidx.compose.material3.SnackbarHost
import androidx.compose.material3.SnackbarHostState
import androidx.compose.material3.Text
import androidx.compose.material3.TopAppBar
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalClipboardManager
import androidx.compose.ui.text.AnnotatedString
import androidx.compose.ui.unit.dp
import androidx.hilt.navigation.compose.hiltViewModel
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.omixlab.lckcontrol.shared.StreamDestination
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun PlanDetailScreen(
planId: String,
onBack: () -> Unit,
viewModel: PlanDetailViewModel = hiltViewModel(),
) {
val plan by viewModel.plan.collectAsStateWithLifecycle()
val isLoading by viewModel.isLoading.collectAsStateWithLifecycle()
val error by viewModel.error.collectAsStateWithLifecycle()
val snackbarHostState = remember { SnackbarHostState() }
LaunchedEffect(error) {
error?.let {
snackbarHostState.showSnackbar(it)
viewModel.clearError()
}
}
Scaffold(
topBar = {
TopAppBar(
title = { Text(plan?.name ?: "Plan Detail") },
navigationIcon = {
IconButton(onClick = onBack) {
Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = "Back")
}
},
actions = {
if (plan?.status == "DRAFT" || plan?.status == "ENDED") {
IconButton(onClick = { viewModel.deletePlan(onBack) }) {
Icon(Icons.Default.Delete, contentDescription = "Delete")
}
}
},
)
},
snackbarHost = { SnackbarHost(snackbarHostState) },
) { padding ->
val currentPlan = plan
if (currentPlan == null) {
Column(
modifier = Modifier
.fillMaxSize()
.padding(padding),
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.Center,
) {
CircularProgressIndicator()
}
return@Scaffold
}
LazyColumn(
modifier = Modifier
.fillMaxSize()
.padding(padding)
.padding(horizontal = 16.dp),
verticalArrangement = Arrangement.spacedBy(12.dp),
) {
item {
Spacer(Modifier.height(8.dp))
// Status
ElevatedCard(modifier = Modifier.fillMaxWidth()) {
Column(modifier = Modifier.padding(16.dp)) {
Text("Status", style = MaterialTheme.typography.labelMedium)
Spacer(Modifier.height(4.dp))
Text(
currentPlan.status,
style = MaterialTheme.typography.headlineSmall,
color = when (currentPlan.status) {
"LIVE" -> MaterialTheme.colorScheme.error
"READY" -> MaterialTheme.colorScheme.primary
else -> MaterialTheme.colorScheme.onSurface
},
)
}
}
}
// Action buttons
item {
when (currentPlan.status) {
"DRAFT" -> {
Button(
onClick = viewModel::preparePlan,
modifier = Modifier.fillMaxWidth(),
enabled = !isLoading,
) {
if (isLoading) CircularProgressIndicator()
else Text("Prepare Stream")
}
}
"READY" -> {
Button(
onClick = viewModel::startPlan,
modifier = Modifier.fillMaxWidth(),
enabled = !isLoading,
) {
Text("Go Live")
}
}
"LIVE" -> {
OutlinedButton(
onClick = viewModel::endPlan,
modifier = Modifier.fillMaxWidth(),
colors = ButtonDefaults.outlinedButtonColors(
contentColor = MaterialTheme.colorScheme.error,
),
enabled = !isLoading,
) {
Text("End Stream")
}
}
}
}
// Destinations
item {
Spacer(Modifier.height(8.dp))
Text("Destinations", style = MaterialTheme.typography.titleMedium)
}
items(currentPlan.destinations) { dest ->
DestinationDetailCard(dest)
}
item { Spacer(Modifier.height(16.dp)) }
}
}
}
@Composable
private fun DestinationDetailCard(destination: StreamDestination) {
val clipboardManager = LocalClipboardManager.current
ElevatedCard(modifier = Modifier.fillMaxWidth()) {
Column(
modifier = Modifier.padding(16.dp),
verticalArrangement = Arrangement.spacedBy(4.dp),
) {
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically,
) {
Text(destination.service, style = MaterialTheme.typography.titleSmall)
Text(
destination.status,
style = MaterialTheme.typography.labelSmall,
color = if (destination.status == "READY") MaterialTheme.colorScheme.primary
else MaterialTheme.colorScheme.onSurfaceVariant,
)
}
Text(destination.title, style = MaterialTheme.typography.bodyMedium)
if (destination.rtmpUrl.isNotBlank()) {
Spacer(Modifier.height(4.dp))
Text("RTMP URL", style = MaterialTheme.typography.labelSmall)
Row(verticalAlignment = Alignment.CenterVertically) {
Text(
destination.rtmpUrl,
style = MaterialTheme.typography.bodySmall,
modifier = Modifier.weight(1f),
)
IconButton(onClick = {
clipboardManager.setText(AnnotatedString(destination.rtmpUrl))
}) {
Icon(Icons.Default.ContentCopy, contentDescription = "Copy", modifier = Modifier.padding(4.dp))
}
}
}
if (destination.streamKey.isNotBlank()) {
Text("Stream Key", style = MaterialTheme.typography.labelSmall)
Row(verticalAlignment = Alignment.CenterVertically) {
Text(
destination.streamKey.take(8) + "...",
style = MaterialTheme.typography.bodySmall,
modifier = Modifier.weight(1f),
)
IconButton(onClick = {
clipboardManager.setText(AnnotatedString(destination.streamKey))
}) {
Icon(Icons.Default.ContentCopy, contentDescription = "Copy", modifier = Modifier.padding(4.dp))
}
}
}
}
}
}

View File

@@ -0,0 +1,90 @@
package com.omixlab.lckcontrol.ui.plans
import androidx.lifecycle.SavedStateHandle
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import com.omixlab.lckcontrol.data.repository.StreamPlanRepository
import com.omixlab.lckcontrol.shared.StreamPlan
import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.launch
import javax.inject.Inject
@HiltViewModel
class PlanDetailViewModel @Inject constructor(
savedStateHandle: SavedStateHandle,
private val streamPlanRepository: StreamPlanRepository,
) : ViewModel() {
private val planId: String = savedStateHandle["planId"] ?: ""
val plan: StateFlow<StreamPlan?> = streamPlanRepository.observePlan(planId)
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), null)
private val _isLoading = MutableStateFlow(false)
val isLoading: StateFlow<Boolean> = _isLoading.asStateFlow()
private val _error = MutableStateFlow<String?>(null)
val error: StateFlow<String?> = _error.asStateFlow()
fun preparePlan() {
viewModelScope.launch {
_isLoading.value = true
_error.value = null
try {
streamPlanRepository.preparePlan(planId)
} catch (e: Exception) {
_error.value = e.message ?: "Failed to prepare plan"
} finally {
_isLoading.value = false
}
}
}
fun startPlan() {
viewModelScope.launch {
_isLoading.value = true
_error.value = null
try {
streamPlanRepository.startPlan(planId)
} catch (e: Exception) {
_error.value = e.message ?: "Failed to start plan"
} finally {
_isLoading.value = false
}
}
}
fun endPlan() {
viewModelScope.launch {
_isLoading.value = true
_error.value = null
try {
streamPlanRepository.endPlan(planId)
} catch (e: Exception) {
_error.value = e.message ?: "Failed to end plan"
} finally {
_isLoading.value = false
}
}
}
fun deletePlan(onDeleted: () -> Unit) {
viewModelScope.launch {
try {
streamPlanRepository.deletePlan(planId)
onDeleted()
} catch (e: Exception) {
_error.value = e.message ?: "Failed to delete plan"
}
}
}
fun clearError() {
_error.value = null
}
}

View File

@@ -0,0 +1,11 @@
package com.omixlab.lckcontrol.ui.theme
import androidx.compose.ui.graphics.Color
val Purple80 = Color(0xFFD0BCFF)
val PurpleGrey80 = Color(0xFFCCC2DC)
val Pink80 = Color(0xFFEFB8C8)
val Purple40 = Color(0xFF6650a4)
val PurpleGrey40 = Color(0xFF625b71)
val Pink40 = Color(0xFF7D5260)

View File

@@ -0,0 +1,58 @@
package com.omixlab.lckcontrol.ui.theme
import android.app.Activity
import android.os.Build
import androidx.compose.foundation.isSystemInDarkTheme
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.darkColorScheme
import androidx.compose.material3.dynamicDarkColorScheme
import androidx.compose.material3.dynamicLightColorScheme
import androidx.compose.material3.lightColorScheme
import androidx.compose.runtime.Composable
import androidx.compose.ui.platform.LocalContext
private val DarkColorScheme = darkColorScheme(
primary = Purple80,
secondary = PurpleGrey80,
tertiary = Pink80
)
private val LightColorScheme = lightColorScheme(
primary = Purple40,
secondary = PurpleGrey40,
tertiary = Pink40
/* Other default colors to override
background = Color(0xFFFFFBFE),
surface = Color(0xFFFFFBFE),
onPrimary = Color.White,
onSecondary = Color.White,
onTertiary = Color.White,
onBackground = Color(0xFF1C1B1F),
onSurface = Color(0xFF1C1B1F),
*/
)
@Composable
fun LCKControlTheme(
darkTheme: Boolean = isSystemInDarkTheme(),
// Dynamic color is available on Android 12+
dynamicColor: Boolean = true,
content: @Composable () -> Unit
) {
val colorScheme = when {
dynamicColor && Build.VERSION.SDK_INT >= Build.VERSION_CODES.S -> {
val context = LocalContext.current
if (darkTheme) dynamicDarkColorScheme(context) else dynamicLightColorScheme(context)
}
darkTheme -> DarkColorScheme
else -> LightColorScheme
}
MaterialTheme(
colorScheme = colorScheme,
typography = Typography,
content = content
)
}

View File

@@ -0,0 +1,34 @@
package com.omixlab.lckcontrol.ui.theme
import androidx.compose.material3.Typography
import androidx.compose.ui.text.TextStyle
import androidx.compose.ui.text.font.FontFamily
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.sp
// Set of Material typography styles to start with
val Typography = Typography(
bodyLarge = TextStyle(
fontFamily = FontFamily.Default,
fontWeight = FontWeight.Normal,
fontSize = 16.sp,
lineHeight = 24.sp,
letterSpacing = 0.5.sp
)
/* Other default text styles to override
titleLarge = TextStyle(
fontFamily = FontFamily.Default,
fontWeight = FontWeight.Normal,
fontSize = 22.sp,
lineHeight = 28.sp,
letterSpacing = 0.sp
),
labelSmall = TextStyle(
fontFamily = FontFamily.Default,
fontWeight = FontWeight.Medium,
fontSize = 11.sp,
lineHeight = 16.sp,
letterSpacing = 0.5.sp
)
*/
)

View File

@@ -0,0 +1,170 @@
<?xml version="1.0" encoding="utf-8"?>
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="108dp"
android:height="108dp"
android:viewportWidth="108"
android:viewportHeight="108">
<path
android:fillColor="#3DDC84"
android:pathData="M0,0h108v108h-108z" />
<path
android:fillColor="#00000000"
android:pathData="M9,0L9,108"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M19,0L19,108"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M29,0L29,108"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M39,0L39,108"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M49,0L49,108"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M59,0L59,108"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M69,0L69,108"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M79,0L79,108"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M89,0L89,108"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M99,0L99,108"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M0,9L108,9"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M0,19L108,19"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M0,29L108,29"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M0,39L108,39"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M0,49L108,49"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M0,59L108,59"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M0,69L108,69"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M0,79L108,79"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M0,89L108,89"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M0,99L108,99"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M19,29L89,29"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M19,39L89,39"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M19,49L89,49"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M19,59L89,59"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M19,69L89,69"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M19,79L89,79"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M29,19L29,89"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M39,19L39,89"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M49,19L49,89"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M59,19L59,89"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M69,19L69,89"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M79,19L79,89"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
</vector>

View File

@@ -0,0 +1,30 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:aapt="http://schemas.android.com/aapt"
android:width="108dp"
android:height="108dp"
android:viewportWidth="108"
android:viewportHeight="108">
<path android:pathData="M31,63.928c0,0 6.4,-11 12.1,-13.1c7.2,-2.6 26,-1.4 26,-1.4l38.1,38.1L107,108.928l-32,-1L31,63.928z">
<aapt:attr name="android:fillColor">
<gradient
android:endX="85.84757"
android:endY="92.4963"
android:startX="42.9492"
android:startY="49.59793"
android:type="linear">
<item
android:color="#44000000"
android:offset="0.0" />
<item
android:color="#00000000"
android:offset="1.0" />
</gradient>
</aapt:attr>
</path>
<path
android:fillColor="#FFFFFF"
android:fillType="nonZero"
android:pathData="M65.3,45.828l3.8,-6.6c0.2,-0.4 0.1,-0.9 -0.3,-1.1c-0.4,-0.2 -0.9,-0.1 -1.1,0.3l-3.9,6.7c-6.3,-2.8 -13.4,-2.8 -19.7,0l-3.9,-6.7c-0.2,-0.4 -0.7,-0.5 -1.1,-0.3C38.8,38.328 38.7,38.828 38.9,39.228l3.8,6.6C36.2,49.428 31.7,56.028 31,63.928h46C76.3,56.028 71.8,49.428 65.3,45.828zM43.4,57.328c-0.8,0 -1.5,-0.5 -1.8,-1.2c-0.3,-0.7 -0.1,-1.5 0.4,-2.1c0.5,-0.5 1.4,-0.7 2.1,-0.4c0.7,0.3 1.2,1 1.2,1.8C45.3,56.528 44.5,57.328 43.4,57.328L43.4,57.328zM64.6,57.328c-0.8,0 -1.5,-0.5 -1.8,-1.2s-0.1,-1.5 0.4,-2.1c0.5,-0.5 1.4,-0.7 2.1,-0.4c0.7,0.3 1.2,1 1.2,1.8C66.5,56.528 65.6,57.328 64.6,57.328L64.6,57.328z"
android:strokeWidth="1"
android:strokeColor="#00000000" />
</vector>

View File

@@ -0,0 +1,6 @@
<?xml version="1.0" encoding="utf-8"?>
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
<background android:drawable="@drawable/ic_launcher_background" />
<foreground android:drawable="@drawable/ic_launcher_foreground" />
<monochrome android:drawable="@drawable/ic_launcher_foreground" />
</adaptive-icon>

View File

@@ -0,0 +1,6 @@
<?xml version="1.0" encoding="utf-8"?>
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
<background android:drawable="@drawable/ic_launcher_background" />
<foreground android:drawable="@drawable/ic_launcher_foreground" />
<monochrome android:drawable="@drawable/ic_launcher_foreground" />
</adaptive-icon>

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 982 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.6 KiB

View File

@@ -0,0 +1,10 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<color name="purple_200">#FFBB86FC</color>
<color name="purple_500">#FF6200EE</color>
<color name="purple_700">#FF3700B3</color>
<color name="teal_200">#FF03DAC5</color>
<color name="teal_700">#FF018786</color>
<color name="black">#FF000000</color>
<color name="white">#FFFFFFFF</color>
</resources>

View File

@@ -0,0 +1,4 @@
<resources>
<string name="app_name">LCK Control</string>
<string name="permission_use_lck_control_desc">Allows game clients to manage stream plans and receive RTMP endpoints through the LCK Control service.</string>
</resources>

View File

@@ -0,0 +1,5 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<style name="Theme.LCKControl" parent="android:Theme.Material.Light.NoActionBar" />
</resources>

View File

@@ -0,0 +1,13 @@
<?xml version="1.0" encoding="utf-8"?><!--
Sample backup rules file; uncomment and customize as necessary.
See https://developer.android.com/guide/topics/data/autobackup
for details.
Note: This file is ignored for devices older than API 31
See https://developer.android.com/about/versions/12/backup-restore
-->
<full-backup-content>
<!--
<include domain="sharedpref" path="."/>
<exclude domain="sharedpref" path="device.xml"/>
-->
</full-backup-content>

View File

@@ -0,0 +1,19 @@
<?xml version="1.0" encoding="utf-8"?><!--
Sample data extraction rules file; uncomment and customize as necessary.
See https://developer.android.com/about/versions/12/backup-restore#xml-changes
for details.
-->
<data-extraction-rules>
<cloud-backup>
<!-- TODO: Use <include> and <exclude> to control what is backed up.
<include .../>
<exclude .../>
-->
</cloud-backup>
<!--
<device-transfer>
<include .../>
<exclude .../>
</device-transfer>
-->
</data-extraction-rules>

View File

@@ -0,0 +1,7 @@
<?xml version="1.0" encoding="utf-8"?>
<network-security-config>
<!-- Allow cleartext for local LAN testing -->
<domain-config cleartextTrafficPermitted="true">
<domain includeSubdomains="false">192.168.1.60</domain>
</domain-config>
</network-security-config>

View File

@@ -0,0 +1,17 @@
package com.omixlab.lckcontrol
import org.junit.Test
import org.junit.Assert.*
/**
* Example local unit test, which will execute on the development machine (host).
*
* See [testing documentation](http://d.android.com/tools/testing).
*/
class ExampleUnitTest {
@Test
fun addition_isCorrect() {
assertEquals(4, 2 + 2)
}
}