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

View File

@@ -0,0 +1,14 @@
{
"permissions": {
"allow": [
"WebSearch",
"WebFetch(domain:developers.google.com)",
"WebFetch(domain:dev.twitch.tv)",
"WebFetch(domain:dev.epicgames.com)",
"WebFetch(domain:mvnrepository.com)",
"WebFetch(domain:developers.meta.com)",
"WebFetch(domain:central.sonatype.com)",
"Bash(javap:*)"
]
}
}

20
.gitignore vendored Normal file
View File

@@ -0,0 +1,20 @@
*.iml
.gradle
/local.properties
/.idea/caches
/.idea/libraries
/.idea/modules.xml
/.idea/workspace.xml
/.idea/navEditor.xml
/.idea/assetWizardSettings.xml
.DS_Store
/build
**/build/
/captures
.externalNativeBuild
.cxx
.kotlin
local.properties
# Platform tools
ovr-platform-util.exe

3
.idea/.gitignore generated vendored Normal file
View File

@@ -0,0 +1,3 @@
# Default ignored files
/shelf/
/workspace.xml

1
.idea/.name generated Normal file
View File

@@ -0,0 +1 @@
LCK Control

6
.idea/AndroidProjectSystem.xml generated Normal file
View File

@@ -0,0 +1,6 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="AndroidProjectSystem">
<option name="providerId" value="com.android.tools.idea.GradleProjectSystem" />
</component>
</project>

6
.idea/compiler.xml generated Normal file
View File

@@ -0,0 +1,6 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="CompilerConfiguration">
<bytecodeTargetLevel target="21" />
</component>
</project>

10
.idea/deploymentTargetSelector.xml generated Normal file
View File

@@ -0,0 +1,10 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="deploymentTargetSelector">
<selectionStates>
<SelectionState runConfigName="app">
<option name="selectionMode" value="DROPDOWN" />
</SelectionState>
</selectionStates>
</component>
</project>

20
.idea/gradle.xml generated Normal file
View File

@@ -0,0 +1,20 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="GradleMigrationSettings" migrationVersion="1" />
<component name="GradleSettings">
<option name="linkedExternalProjectsSettings">
<GradleProjectSettings>
<option name="testRunner" value="CHOOSE_PER_TEST" />
<option name="externalProjectPath" value="$PROJECT_DIR$" />
<option name="modules">
<set>
<option value="$PROJECT_DIR$" />
<option value="$PROJECT_DIR$/app" />
<option value="$PROJECT_DIR$/sdk" />
<option value="$PROJECT_DIR$/shared" />
</set>
</option>
</GradleProjectSettings>
</option>
</component>
</project>

View File

@@ -0,0 +1,61 @@
<component name="InspectionProjectProfileManager">
<profile version="1.0">
<option name="myName" value="Project Default" />
<inspection_tool class="ComposePreviewDimensionRespectsLimit" enabled="true" level="WARNING" enabled_by_default="true">
<option name="composableFile" value="true" />
<option name="previewFile" value="true" />
</inspection_tool>
<inspection_tool class="ComposePreviewMustBeTopLevelFunction" enabled="true" level="ERROR" enabled_by_default="true">
<option name="composableFile" value="true" />
<option name="previewFile" value="true" />
</inspection_tool>
<inspection_tool class="ComposePreviewNeedsComposableAnnotation" enabled="true" level="ERROR" enabled_by_default="true">
<option name="composableFile" value="true" />
<option name="previewFile" value="true" />
</inspection_tool>
<inspection_tool class="ComposePreviewNotSupportedInUnitTestFiles" enabled="true" level="ERROR" enabled_by_default="true">
<option name="composableFile" value="true" />
<option name="previewFile" value="true" />
</inspection_tool>
<inspection_tool class="GlancePreviewDimensionRespectsLimit" enabled="true" level="WARNING" enabled_by_default="true">
<option name="composableFile" value="true" />
</inspection_tool>
<inspection_tool class="GlancePreviewMustBeTopLevelFunction" enabled="true" level="ERROR" enabled_by_default="true">
<option name="composableFile" value="true" />
</inspection_tool>
<inspection_tool class="GlancePreviewNeedsComposableAnnotation" enabled="true" level="ERROR" enabled_by_default="true">
<option name="composableFile" value="true" />
</inspection_tool>
<inspection_tool class="GlancePreviewNotSupportedInUnitTestFiles" enabled="true" level="ERROR" enabled_by_default="true">
<option name="composableFile" value="true" />
</inspection_tool>
<inspection_tool class="PreviewAnnotationInFunctionWithParameters" enabled="true" level="ERROR" enabled_by_default="true">
<option name="composableFile" value="true" />
<option name="previewFile" value="true" />
</inspection_tool>
<inspection_tool class="PreviewApiLevelMustBeValid" enabled="true" level="ERROR" enabled_by_default="true">
<option name="composableFile" value="true" />
<option name="previewFile" value="true" />
</inspection_tool>
<inspection_tool class="PreviewDeviceShouldUseNewSpec" enabled="true" level="WEAK WARNING" enabled_by_default="true">
<option name="composableFile" value="true" />
<option name="previewFile" value="true" />
</inspection_tool>
<inspection_tool class="PreviewFontScaleMustBeGreaterThanZero" enabled="true" level="ERROR" enabled_by_default="true">
<option name="composableFile" value="true" />
<option name="previewFile" value="true" />
</inspection_tool>
<inspection_tool class="PreviewMultipleParameterProviders" enabled="true" level="ERROR" enabled_by_default="true">
<option name="composableFile" value="true" />
<option name="previewFile" value="true" />
</inspection_tool>
<inspection_tool class="PreviewParameterProviderOnFirstParameter" enabled="true" level="ERROR" enabled_by_default="true">
<option name="composableFile" value="true" />
<option name="previewFile" value="true" />
</inspection_tool>
<inspection_tool class="PreviewPickerAnnotation" enabled="true" level="ERROR" enabled_by_default="true">
<option name="composableFile" value="true" />
<option name="previewFile" value="true" />
</inspection_tool>
</profile>
</component>

9
.idea/misc.xml generated Normal file
View File

@@ -0,0 +1,9 @@
<project version="4">
<component name="ExternalStorageConfigurationManager" enabled="true" />
<component name="ProjectRootManager" version="2" languageLevel="JDK_21" default="true" project-jdk-name="jbr-21" project-jdk-type="JavaSDK">
<output url="file://$PROJECT_DIR$/build/classes" />
</component>
<component name="ProjectType">
<option name="id" value="Android" />
</component>
</project>

17
.idea/runConfigurations.xml generated Normal file
View File

@@ -0,0 +1,17 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="RunConfigurationProducerService">
<option name="ignoredProducers">
<set>
<option value="com.intellij.execution.junit.AbstractAllInDirectoryConfigurationProducer" />
<option value="com.intellij.execution.junit.AllInPackageConfigurationProducer" />
<option value="com.intellij.execution.junit.PatternConfigurationProducer" />
<option value="com.intellij.execution.junit.TestInClassConfigurationProducer" />
<option value="com.intellij.execution.junit.UniqueIdConfigurationProducer" />
<option value="com.intellij.execution.junit.testDiscovery.JUnitTestDiscoveryConfigurationProducer" />
<option value="org.jetbrains.kotlin.idea.junit.KotlinJUnitRunConfigurationProducer" />
<option value="org.jetbrains.kotlin.idea.junit.KotlinPatternConfigurationProducer" />
</set>
</option>
</component>
</project>

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

8
build.gradle.kts Normal file
View File

@@ -0,0 +1,8 @@
plugins {
alias(libs.plugins.android.application) apply false
alias(libs.plugins.android.library) apply false
alias(libs.plugins.kotlin.compose) apply false
alias(libs.plugins.kotlin.parcelize) apply false
alias(libs.plugins.hilt) apply false
alias(libs.plugins.ksp) apply false
}

51
deploy.ps1 Normal file
View File

@@ -0,0 +1,51 @@
param(
[Parameter(Mandatory=$true)]
[string]$Channel,
[ValidateSet("release", "debug")]
[string]$BuildType = "release"
)
$ErrorActionPreference = "Stop"
$AppId = "25653777174321448"
$Token = "OC|25653777174321448|b861e3eeaf58edf097812b5fe588dabb"
$BuildGradle = "$PSScriptRoot\app\build.gradle.kts"
$OvrUtil = "$PSScriptRoot\ovr-platform-util.exe"
# --- Bump versionCode ---
$content = Get-Content $BuildGradle -Raw
if ($content -match 'versionCode\s*=\s*(\d+)') {
$oldCode = [int]$Matches[1]
$newCode = $oldCode + 1
$content = $content -replace "versionCode\s*=\s*$oldCode", "versionCode = $newCode"
Set-Content $BuildGradle $content -NoNewline
Write-Host "Bumped versionCode: $oldCode -> $newCode" -ForegroundColor Cyan
} else {
Write-Error "Could not find versionCode in build.gradle.kts"
}
# --- Build APK ---
$task = if ($BuildType -eq "release") { ":app:assembleRelease" } else { ":app:assembleDebug" }
Write-Host "Building $BuildType APK..." -ForegroundColor Cyan
& "$PSScriptRoot\gradlew.bat" $task
if ($LASTEXITCODE -ne 0) { Write-Error "Build failed" }
$apk = "$PSScriptRoot\app\build\outputs\apk\$BuildType\app-$BuildType.apk"
if (-not (Test-Path $apk)) { Write-Error "APK not found: $apk" }
Write-Host "APK: $apk" -ForegroundColor Green
# --- Upload ---
Write-Host "Uploading to channel '$Channel'..." -ForegroundColor Cyan
& $OvrUtil upload-quest-build `
--app-id $AppId `
--token $Token `
--apk $apk `
--channel $Channel `
--age-group MIXED_AGES `
--notes "v$newCode $BuildType build"
if ($LASTEXITCODE -ne 0) { Write-Error "Upload failed" }
Write-Host "Deployed versionCode $newCode ($BuildType) to '$Channel'" -ForegroundColor Green

25
gradle.properties Normal file
View File

@@ -0,0 +1,25 @@
# Project-wide Gradle settings.
# IDE (e.g. Android Studio) users:
# Gradle settings configured through the IDE *will override*
# any settings specified in this file.
# For more details on how to configure your build environment visit
# http://www.gradle.org/docs/current/userguide/build_environment.html
# Specifies the JVM arguments used for the daemon process.
# The setting is particularly useful for tweaking memory settings.
org.gradle.jvmargs=-Xmx2048m -Dfile.encoding=UTF-8
# When configured, Gradle will run in incubating parallel mode.
# This option should only be used with decoupled projects. For more details, visit
# https://developer.android.com/r/tools/gradle-multi-project-decoupled-projects
# org.gradle.parallel=true
# AndroidX package structure to make it clearer which packages are bundled with the
# Android operating system, and which are packaged with your app's APK
# https://developer.android.com/topic/libraries/support-library/androidx-rn
android.useAndroidX=true
# Kotlin code style for this project: "official" or "obsolete":
kotlin.code.style=official
# Enables namespacing of each library's R class so that its R class includes only the
# resources declared in the library itself and none from the library's dependencies,
# thereby reducing the size of the R class for that library
android.nonTransitiveRClass=true
# Allow KSP to add Kotlin source sets (needed for KSP + AGP 9 built-in Kotlin)
android.disallowKotlinSourceSets=false

View File

@@ -0,0 +1,12 @@
#This file is generated by updateDaemonJvm
toolchainUrl.FREE_BSD.AARCH64=https\://api.foojay.io/disco/v3.0/ids/ec7520a1e057cd116f9544c42142a16b/redirect
toolchainUrl.FREE_BSD.X86_64=https\://api.foojay.io/disco/v3.0/ids/4c4f879899012ff0a8b2e2117df03b0e/redirect
toolchainUrl.LINUX.AARCH64=https\://api.foojay.io/disco/v3.0/ids/ec7520a1e057cd116f9544c42142a16b/redirect
toolchainUrl.LINUX.X86_64=https\://api.foojay.io/disco/v3.0/ids/4c4f879899012ff0a8b2e2117df03b0e/redirect
toolchainUrl.MAC_OS.AARCH64=https\://api.foojay.io/disco/v3.0/ids/73bcfb608d1fde9fb62e462f834a3299/redirect
toolchainUrl.MAC_OS.X86_64=https\://api.foojay.io/disco/v3.0/ids/846ee0d876d26a26f37aa1ce8de73224/redirect
toolchainUrl.UNIX.AARCH64=https\://api.foojay.io/disco/v3.0/ids/ec7520a1e057cd116f9544c42142a16b/redirect
toolchainUrl.UNIX.X86_64=https\://api.foojay.io/disco/v3.0/ids/4c4f879899012ff0a8b2e2117df03b0e/redirect
toolchainUrl.WINDOWS.AARCH64=https\://api.foojay.io/disco/v3.0/ids/9482ddec596298c84656d31d16652665/redirect
toolchainUrl.WINDOWS.X86_64=https\://api.foojay.io/disco/v3.0/ids/39701d92e1756bb2f141eb67cd4c660e/redirect
toolchainVersion=21

88
gradle/libs.versions.toml Normal file
View File

@@ -0,0 +1,88 @@
[versions]
agp = "9.0.1"
kotlin = "2.2.10"
ksp = "2.2.10-2.0.2"
coreKtx = "1.10.1"
junit = "4.13.2"
junitVersion = "1.1.5"
espressoCore = "3.5.1"
lifecycleRuntimeKtx = "2.6.1"
activityCompose = "1.8.0"
composeBom = "2024.09.00"
hilt = "2.59.2"
hiltNavigationCompose = "1.2.0"
room = "2.8.4"
navigationCompose = "2.8.4"
retrofit = "2.11.0"
moshi = "1.15.1"
okhttp = "4.12.0"
securityCrypto = "1.0.0"
browser = "1.8.0"
coroutines = "1.9.0"
credentials = "1.3.0"
googleid = "1.1.1"
googleApiClientAndroid = "2.7.0"
googleHttpClientGson = "1.44.2"
youtubeApi = "v3-rev20231011-2.0.0"
[libraries]
androidx-core-ktx = { group = "androidx.core", name = "core-ktx", version.ref = "coreKtx" }
junit = { group = "junit", name = "junit", version.ref = "junit" }
androidx-junit = { group = "androidx.test.ext", name = "junit", version.ref = "junitVersion" }
androidx-espresso-core = { group = "androidx.test.espresso", name = "espresso-core", version.ref = "espressoCore" }
androidx-lifecycle-runtime-ktx = { group = "androidx.lifecycle", name = "lifecycle-runtime-ktx", version.ref = "lifecycleRuntimeKtx" }
androidx-lifecycle-viewmodel-compose = { group = "androidx.lifecycle", name = "lifecycle-viewmodel-compose", version.ref = "lifecycleRuntimeKtx" }
androidx-lifecycle-runtime-compose = { group = "androidx.lifecycle", name = "lifecycle-runtime-compose", version.ref = "lifecycleRuntimeKtx" }
androidx-activity-compose = { group = "androidx.activity", name = "activity-compose", version.ref = "activityCompose" }
androidx-compose-bom = { group = "androidx.compose", name = "compose-bom", version.ref = "composeBom" }
androidx-compose-ui = { group = "androidx.compose.ui", name = "ui" }
androidx-compose-ui-graphics = { group = "androidx.compose.ui", name = "ui-graphics" }
androidx-compose-ui-tooling = { group = "androidx.compose.ui", name = "ui-tooling" }
androidx-compose-ui-tooling-preview = { group = "androidx.compose.ui", name = "ui-tooling-preview" }
androidx-compose-ui-test-manifest = { group = "androidx.compose.ui", name = "ui-test-manifest" }
androidx-compose-ui-test-junit4 = { group = "androidx.compose.ui", name = "ui-test-junit4" }
androidx-compose-material3 = { group = "androidx.compose.material3", name = "material3" }
androidx-compose-material-icons-extended = { group = "androidx.compose.material", name = "material-icons-extended" }
androidx-navigation-compose = { group = "androidx.navigation", name = "navigation-compose", version.ref = "navigationCompose" }
androidx-hilt-navigation-compose = { group = "androidx.hilt", name = "hilt-navigation-compose", version.ref = "hiltNavigationCompose" }
# Hilt
hilt-android = { group = "com.google.dagger", name = "hilt-android", version.ref = "hilt" }
hilt-compiler = { group = "com.google.dagger", name = "hilt-compiler", version.ref = "hilt" }
# Room
room-runtime = { group = "androidx.room", name = "room-runtime", version.ref = "room" }
room-ktx = { group = "androidx.room", name = "room-ktx", version.ref = "room" }
room-compiler = { group = "androidx.room", name = "room-compiler", version.ref = "room" }
# Networking
retrofit = { group = "com.squareup.retrofit2", name = "retrofit", version.ref = "retrofit" }
retrofit-converter-moshi = { group = "com.squareup.retrofit2", name = "converter-moshi", version.ref = "retrofit" }
moshi-kotlin = { group = "com.squareup.moshi", name = "moshi-kotlin", version.ref = "moshi" }
okhttp = { group = "com.squareup.okhttp3", name = "okhttp", version.ref = "okhttp" }
okhttp-logging = { group = "com.squareup.okhttp3", name = "logging-interceptor", version.ref = "okhttp" }
# Security
androidx-security-crypto = { group = "androidx.security", name = "security-crypto", version.ref = "securityCrypto" }
# Auth
androidx-credentials = { group = "androidx.credentials", name = "credentials", version.ref = "credentials" }
androidx-credentials-play-services = { group = "androidx.credentials", name = "credentials-play-services-auth", version.ref = "credentials" }
googleid = { group = "com.google.android.libraries.identity.googleid", name = "googleid", version.ref = "googleid" }
google-api-client-android = { group = "com.google.api-client", name = "google-api-client-android", version.ref = "googleApiClientAndroid" }
google-http-client-gson = { group = "com.google.http-client", name = "google-http-client-gson", version.ref = "googleHttpClientGson" }
google-api-services-youtube = { group = "com.google.apis", name = "google-api-services-youtube", version.ref = "youtubeApi" }
# Browser (Custom Tabs)
androidx-browser = { group = "androidx.browser", name = "browser", version.ref = "browser" }
# Coroutines
kotlinx-coroutines-android = { group = "org.jetbrains.kotlinx", name = "kotlinx-coroutines-android", version.ref = "coroutines" }
[plugins]
android-application = { id = "com.android.application", version.ref = "agp" }
android-library = { id = "com.android.library", version.ref = "agp" }
kotlin-compose = { id = "org.jetbrains.kotlin.plugin.compose", version.ref = "kotlin" }
kotlin-parcelize = { id = "org.jetbrains.kotlin.plugin.parcelize", version.ref = "kotlin" }
hilt = { id = "com.google.dagger.hilt.android", version.ref = "hilt" }
ksp = { id = "com.google.devtools.ksp", version.ref = "ksp" }

BIN
gradle/wrapper/gradle-wrapper.jar vendored Normal file

Binary file not shown.

View File

@@ -0,0 +1,9 @@
#Mon Feb 23 08:53:07 CET 2026
distributionBase=GRADLE_USER_HOME
distributionPath=wrapper/dists
distributionSha256Sum=72f44c9f8ebcb1af43838f45ee5c4aa9c5444898b3468ab3f4af7b6076c5bc3f
distributionUrl=https\://services.gradle.org/distributions/gradle-9.2.1-bin.zip
networkTimeout=10000
validateDistributionUrl=true
zipStoreBase=GRADLE_USER_HOME
zipStorePath=wrapper/dists

251
gradlew vendored Normal file
View File

@@ -0,0 +1,251 @@
#!/bin/sh
#
# Copyright © 2015 the original authors.
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# https://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
#
# SPDX-License-Identifier: Apache-2.0
#
##############################################################################
#
# Gradle start up script for POSIX generated by Gradle.
#
# Important for running:
#
# (1) You need a POSIX-compliant shell to run this script. If your /bin/sh is
# noncompliant, but you have some other compliant shell such as ksh or
# bash, then to run this script, type that shell name before the whole
# command line, like:
#
# ksh Gradle
#
# Busybox and similar reduced shells will NOT work, because this script
# requires all of these POSIX shell features:
# * functions;
# * expansions «$var», «${var}», «${var:-default}», «${var+SET}»,
# «${var#prefix}», «${var%suffix}», and «$( cmd )»;
# * compound commands having a testable exit status, especially «case»;
# * various built-in commands including «command», «set», and «ulimit».
#
# Important for patching:
#
# (2) This script targets any POSIX shell, so it avoids extensions provided
# by Bash, Ksh, etc; in particular arrays are avoided.
#
# The "traditional" practice of packing multiple parameters into a
# space-separated string is a well documented source of bugs and security
# problems, so this is (mostly) avoided, by progressively accumulating
# options in "$@", and eventually passing that to Java.
#
# Where the inherited environment variables (DEFAULT_JVM_OPTS, JAVA_OPTS,
# and GRADLE_OPTS) rely on word-splitting, this is performed explicitly;
# see the in-line comments for details.
#
# There are tweaks for specific operating systems such as AIX, CygWin,
# Darwin, MinGW, and NonStop.
#
# (3) This script is generated from the Groovy template
# https://github.com/gradle/gradle/blob/HEAD/platforms/jvm/plugins-application/src/main/resources/org/gradle/api/internal/plugins/unixStartScript.txt
# within the Gradle project.
#
# You can find Gradle at https://github.com/gradle/gradle/.
#
##############################################################################
# Attempt to set APP_HOME
# Resolve links: $0 may be a link
app_path=$0
# Need this for daisy-chained symlinks.
while
APP_HOME=${app_path%"${app_path##*/}"} # leaves a trailing /; empty if no leading path
[ -h "$app_path" ]
do
ls=$( ls -ld "$app_path" )
link=${ls#*' -> '}
case $link in #(
/*) app_path=$link ;; #(
*) app_path=$APP_HOME$link ;;
esac
done
# This is normally unused
# shellcheck disable=SC2034
APP_BASE_NAME=${0##*/}
# Discard cd standard output in case $CDPATH is set (https://github.com/gradle/gradle/issues/25036)
APP_HOME=$( cd -P "${APP_HOME:-./}" > /dev/null && printf '%s\n' "$PWD" ) || exit
# Use the maximum available, or set MAX_FD != -1 to use that value.
MAX_FD=maximum
warn () {
echo "$*"
} >&2
die () {
echo
echo "$*"
echo
exit 1
} >&2
# OS specific support (must be 'true' or 'false').
cygwin=false
msys=false
darwin=false
nonstop=false
case "$( uname )" in #(
CYGWIN* ) cygwin=true ;; #(
Darwin* ) darwin=true ;; #(
MSYS* | MINGW* ) msys=true ;; #(
NONSTOP* ) nonstop=true ;;
esac
CLASSPATH="\\\"\\\""
# Determine the Java command to use to start the JVM.
if [ -n "$JAVA_HOME" ] ; then
if [ -x "$JAVA_HOME/jre/sh/java" ] ; then
# IBM's JDK on AIX uses strange locations for the executables
JAVACMD=$JAVA_HOME/jre/sh/java
else
JAVACMD=$JAVA_HOME/bin/java
fi
if [ ! -x "$JAVACMD" ] ; then
die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME
Please set the JAVA_HOME variable in your environment to match the
location of your Java installation."
fi
else
JAVACMD=java
if ! command -v java >/dev/null 2>&1
then
die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH.
Please set the JAVA_HOME variable in your environment to match the
location of your Java installation."
fi
fi
# Increase the maximum file descriptors if we can.
if ! "$cygwin" && ! "$darwin" && ! "$nonstop" ; then
case $MAX_FD in #(
max*)
# In POSIX sh, ulimit -H is undefined. That's why the result is checked to see if it worked.
# shellcheck disable=SC2039,SC3045
MAX_FD=$( ulimit -H -n ) ||
warn "Could not query maximum file descriptor limit"
esac
case $MAX_FD in #(
'' | soft) :;; #(
*)
# In POSIX sh, ulimit -n is undefined. That's why the result is checked to see if it worked.
# shellcheck disable=SC2039,SC3045
ulimit -n "$MAX_FD" ||
warn "Could not set maximum file descriptor limit to $MAX_FD"
esac
fi
# Collect all arguments for the java command, stacking in reverse order:
# * args from the command line
# * the main class name
# * -classpath
# * -D...appname settings
# * --module-path (only if needed)
# * DEFAULT_JVM_OPTS, JAVA_OPTS, and GRADLE_OPTS environment variables.
# For Cygwin or MSYS, switch paths to Windows format before running java
if "$cygwin" || "$msys" ; then
APP_HOME=$( cygpath --path --mixed "$APP_HOME" )
CLASSPATH=$( cygpath --path --mixed "$CLASSPATH" )
JAVACMD=$( cygpath --unix "$JAVACMD" )
# Now convert the arguments - kludge to limit ourselves to /bin/sh
for arg do
if
case $arg in #(
-*) false ;; # don't mess with options #(
/?*) t=${arg#/} t=/${t%%/*} # looks like a POSIX filepath
[ -e "$t" ] ;; #(
*) false ;;
esac
then
arg=$( cygpath --path --ignore --mixed "$arg" )
fi
# Roll the args list around exactly as many times as the number of
# args, so each arg winds up back in the position where it started, but
# possibly modified.
#
# NB: a `for` loop captures its iteration list before it begins, so
# changing the positional parameters here affects neither the number of
# iterations, nor the values presented in `arg`.
shift # remove old arg
set -- "$@" "$arg" # push replacement arg
done
fi
# Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.
DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"'
# Collect all arguments for the java command:
# * DEFAULT_JVM_OPTS, JAVA_OPTS, and optsEnvironmentVar are not allowed to contain shell fragments,
# and any embedded shellness will be escaped.
# * For example: A user cannot expect ${Hostname} to be expanded, as it is an environment variable and will be
# treated as '${Hostname}' itself on the command line.
set -- \
"-Dorg.gradle.appname=$APP_BASE_NAME" \
-classpath "$CLASSPATH" \
-jar "$APP_HOME/gradle/wrapper/gradle-wrapper.jar" \
"$@"
# Stop when "xargs" is not available.
if ! command -v xargs >/dev/null 2>&1
then
die "xargs is not available"
fi
# Use "xargs" to parse quoted args.
#
# With -n1 it outputs one arg per line, with the quotes and backslashes removed.
#
# In Bash we could simply go:
#
# readarray ARGS < <( xargs -n1 <<<"$var" ) &&
# set -- "${ARGS[@]}" "$@"
#
# but POSIX shell has neither arrays nor command substitution, so instead we
# post-process each arg (as a line of input to sed) to backslash-escape any
# character that might be a shell metacharacter, then use eval to reverse
# that process (while maintaining the separation between arguments), and wrap
# the whole thing up as a single "set" statement.
#
# This will of course break if any of these variables contains a newline or
# an unmatched quote.
#
eval "set -- $(
printf '%s\n' "$DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS" |
xargs -n1 |
sed ' s~[^-[:alnum:]+,./:=@_]~\\&~g; ' |
tr '\n' ' '
)" '"$@"'
exec "$JAVACMD" "$@"

94
gradlew.bat vendored Normal file
View File

@@ -0,0 +1,94 @@
@rem
@rem Copyright 2015 the original author or authors.
@rem
@rem Licensed under the Apache License, Version 2.0 (the "License");
@rem you may not use this file except in compliance with the License.
@rem You may obtain a copy of the License at
@rem
@rem https://www.apache.org/licenses/LICENSE-2.0
@rem
@rem Unless required by applicable law or agreed to in writing, software
@rem distributed under the License is distributed on an "AS IS" BASIS,
@rem WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
@rem See the License for the specific language governing permissions and
@rem limitations under the License.
@rem
@rem SPDX-License-Identifier: Apache-2.0
@rem
@if "%DEBUG%"=="" @echo off
@rem ##########################################################################
@rem
@rem Gradle startup script for Windows
@rem
@rem ##########################################################################
@rem Set local scope for the variables with windows NT shell
if "%OS%"=="Windows_NT" setlocal
set DIRNAME=%~dp0
if "%DIRNAME%"=="" set DIRNAME=.
@rem This is normally unused
set APP_BASE_NAME=%~n0
set APP_HOME=%DIRNAME%
@rem Resolve any "." and ".." in APP_HOME to make it shorter.
for %%i in ("%APP_HOME%") do set APP_HOME=%%~fi
@rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.
set DEFAULT_JVM_OPTS="-Xmx64m" "-Xms64m"
@rem Find java.exe
if defined JAVA_HOME goto findJavaFromJavaHome
set JAVA_EXE=java.exe
%JAVA_EXE% -version >NUL 2>&1
if %ERRORLEVEL% equ 0 goto execute
echo. 1>&2
echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. 1>&2
echo. 1>&2
echo Please set the JAVA_HOME variable in your environment to match the 1>&2
echo location of your Java installation. 1>&2
goto fail
:findJavaFromJavaHome
set JAVA_HOME=%JAVA_HOME:"=%
set JAVA_EXE=%JAVA_HOME%/bin/java.exe
if exist "%JAVA_EXE%" goto execute
echo. 1>&2
echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% 1>&2
echo. 1>&2
echo Please set the JAVA_HOME variable in your environment to match the 1>&2
echo location of your Java installation. 1>&2
goto fail
:execute
@rem Setup the command line
set CLASSPATH=
@rem Execute Gradle
"%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" -jar "%APP_HOME%\gradle\wrapper\gradle-wrapper.jar" %*
:end
@rem End local scope for the variables with windows NT shell
if %ERRORLEVEL% equ 0 goto mainEnd
:fail
rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of
rem the _cmd.exe /c_ return code!
set EXIT_CODE=%ERRORLEVEL%
if %EXIT_CODE% equ 0 set EXIT_CODE=1
if not ""=="%GRADLE_EXIT_CONSOLE%" exit %EXIT_CODE%
exit /b %EXIT_CODE%
:mainEnd
if "%OS%"=="Windows_NT" endlocal
:omega

BIN
lck-control.keystore Normal file

Binary file not shown.

23
sdk/build.gradle.kts Normal file
View File

@@ -0,0 +1,23 @@
plugins {
alias(libs.plugins.android.library)
}
android {
namespace = "com.omixlab.lckcontrol.sdk"
compileSdk = 36
defaultConfig {
minSdk = 33
}
compileOptions {
sourceCompatibility = JavaVersion.VERSION_11
targetCompatibility = JavaVersion.VERSION_11
}
}
dependencies {
api(project(":shared"))
implementation(libs.androidx.core.ktx)
implementation(libs.kotlinx.coroutines.android)
}

View File

@@ -0,0 +1,2 @@
<?xml version="1.0" encoding="utf-8"?>
<manifest />

View File

@@ -0,0 +1,122 @@
package com.omixlab.lckcontrol.sdk
import android.content.ComponentName
import android.content.Context
import android.content.Intent
import android.content.ServiceConnection
import android.os.IBinder
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 kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
class LckControlClient(private val context: Context) {
companion object {
private const val SERVICE_PACKAGE = "com.omixlab.lckcontrol"
private const val SERVICE_CLASS = "$SERVICE_PACKAGE.service.LckControlService"
private const val PERMISSION = "$SERVICE_PACKAGE.permission.USE_LCK_CONTROL"
}
private var service: ILckControlService? = null
private var clientId: String? = null
private val _connected = MutableStateFlow(false)
val connected: StateFlow<Boolean> = _connected.asStateFlow()
private val _streamPlans = MutableStateFlow<List<StreamPlan>>(emptyList())
val streamPlans: StateFlow<List<StreamPlan>> = _streamPlans.asStateFlow()
private val callback = object : ILckControlCallback.Stub() {
override fun onStreamPlansChanged(plans: List<StreamPlan>) {
_streamPlans.value = plans
}
override fun onStreamPlanUpdated(plan: StreamPlan) {
_streamPlans.value = _streamPlans.value.map {
if (it.planId == plan.planId) plan else it
}
}
override fun onClientRegistered(id: String) {}
override fun onClientUnregistered(id: String) {}
}
private val connection = object : ServiceConnection {
override fun onServiceConnected(name: ComponentName?, binder: IBinder?) {
service = ILckControlService.Stub.asInterface(binder)
service?.registerCallback(callback)
_connected.value = true
}
override fun onServiceDisconnected(name: ComponentName?) {
service = null
clientId = null
_connected.value = false
}
}
fun bind(): Boolean {
val intent = Intent().apply {
component = ComponentName(SERVICE_PACKAGE, SERVICE_CLASS)
}
return context.bindService(intent, connection, Context.BIND_AUTO_CREATE)
}
fun unbind() {
service?.let { svc ->
clientId?.let { svc.unregisterClient(it) }
svc.unregisterCallback(callback)
}
try {
context.unbindService(connection)
} catch (_: IllegalArgumentException) {
// Already unbound
}
service = null
clientId = null
_connected.value = false
}
fun registerAsClient(clientName: String, packageName: String): String? {
val id = service?.registerClient(clientName, packageName)
clientId = id
return id
}
fun getLinkedAccounts(): List<LinkedAccount> {
return service?.linkedAccounts ?: emptyList()
}
fun getStreamPlans(): List<StreamPlan> {
return service?.streamPlans ?: emptyList()
}
fun getStreamPlan(planId: String): StreamPlan? {
return service?.getStreamPlan(planId)
}
fun createStreamPlan(config: StreamPlanConfig): StreamPlan? {
return service?.createStreamPlan(config)
}
fun prepareStreamPlan(planId: String): StreamPlan? {
return service?.prepareStreamPlan(planId)
}
fun startStreamPlan(planId: String): Boolean {
return service?.startStreamPlan(planId) ?: false
}
fun endStreamPlan(planId: String): Boolean {
return service?.endStreamPlan(planId) ?: false
}
fun setClientActivePlan(planId: String) {
clientId?.let { service?.setClientActivePlan(it, planId) }
}
}

28
settings.gradle.kts Normal file
View File

@@ -0,0 +1,28 @@
pluginManagement {
repositories {
google {
content {
includeGroupByRegex("com\\.android.*")
includeGroupByRegex("com\\.google.*")
includeGroupByRegex("androidx.*")
}
}
mavenCentral()
gradlePluginPortal()
}
}
plugins {
id("org.gradle.toolchains.foojay-resolver-convention") version "1.0.0"
}
dependencyResolutionManagement {
repositoriesMode.set(RepositoriesMode.FAIL_ON_PROJECT_REPOS)
repositories {
google()
mavenCentral()
}
}
rootProject.name = "LCK Control"
include(":app")
include(":shared")
include(":sdk")

25
shared/build.gradle.kts Normal file
View File

@@ -0,0 +1,25 @@
plugins {
alias(libs.plugins.android.library)
}
android {
namespace = "com.omixlab.lckcontrol.shared"
compileSdk = 36
defaultConfig {
minSdk = 32
}
buildFeatures {
aidl = true
}
compileOptions {
sourceCompatibility = JavaVersion.VERSION_11
targetCompatibility = JavaVersion.VERSION_11
}
}
dependencies {
implementation(libs.androidx.core.ktx)
}

View File

@@ -0,0 +1,2 @@
<?xml version="1.0" encoding="utf-8"?>
<manifest />

View File

@@ -0,0 +1,10 @@
package com.omixlab.lckcontrol.shared;
import com.omixlab.lckcontrol.shared.StreamPlan;
interface ILckControlCallback {
void onStreamPlansChanged(in List<StreamPlan> plans);
void onStreamPlanUpdated(in StreamPlan plan);
void onClientRegistered(String clientId);
void onClientUnregistered(String clientId);
}

View File

@@ -0,0 +1,21 @@
package com.omixlab.lckcontrol.shared;
import com.omixlab.lckcontrol.shared.LinkedAccount;
import com.omixlab.lckcontrol.shared.StreamPlan;
import com.omixlab.lckcontrol.shared.StreamPlanConfig;
import com.omixlab.lckcontrol.shared.ILckControlCallback;
interface ILckControlService {
List<LinkedAccount> getLinkedAccounts();
StreamPlan createStreamPlan(in StreamPlanConfig config);
StreamPlan prepareStreamPlan(String planId);
List<StreamPlan> getStreamPlans();
StreamPlan getStreamPlan(String planId);
boolean startStreamPlan(String planId);
boolean endStreamPlan(String planId);
String registerClient(String clientName, String packageName);
void unregisterClient(String clientId);
void setClientActivePlan(String clientId, String planId);
void registerCallback(ILckControlCallback callback);
void unregisterCallback(ILckControlCallback callback);
}

View File

@@ -0,0 +1,3 @@
package com.omixlab.lckcontrol.shared;
parcelable LinkedAccount;

View File

@@ -0,0 +1,3 @@
package com.omixlab.lckcontrol.shared;
parcelable StreamDestination;

View File

@@ -0,0 +1,3 @@
package com.omixlab.lckcontrol.shared;
parcelable StreamPlan;

View File

@@ -0,0 +1,3 @@
package com.omixlab.lckcontrol.shared;
parcelable StreamPlanConfig;

View File

@@ -0,0 +1,36 @@
package com.omixlab.lckcontrol.shared
import android.os.Parcel
import android.os.Parcelable
data class LinkedAccount(
val serviceId: String,
val displayName: String,
val accountId: String,
val avatarUrl: String? = null,
val isAuthenticated: Boolean = false,
) : Parcelable {
constructor(parcel: Parcel) : this(
serviceId = parcel.readString()!!,
displayName = parcel.readString()!!,
accountId = parcel.readString()!!,
avatarUrl = parcel.readString(),
isAuthenticated = parcel.readInt() != 0,
)
override fun writeToParcel(parcel: Parcel, flags: Int) {
parcel.writeString(serviceId)
parcel.writeString(displayName)
parcel.writeString(accountId)
parcel.writeString(avatarUrl)
parcel.writeInt(if (isAuthenticated) 1 else 0)
}
override fun describeContents(): Int = 0
companion object CREATOR : Parcelable.Creator<LinkedAccount> {
override fun createFromParcel(parcel: Parcel) = LinkedAccount(parcel)
override fun newArray(size: Int) = arrayOfNulls<LinkedAccount>(size)
}
}

View File

@@ -0,0 +1,51 @@
package com.omixlab.lckcontrol.shared
import android.os.Parcel
import android.os.Parcelable
data class StreamDestination(
val service: String,
val title: String,
val description: String = "",
val privacyStatus: String = "public",
val gameId: String = "",
val tags: List<String> = emptyList(),
val rtmpUrl: String = "",
val streamKey: String = "",
val broadcastId: String = "",
val status: String = "PENDING",
) : Parcelable {
constructor(parcel: Parcel) : this(
service = parcel.readString()!!,
title = parcel.readString()!!,
description = parcel.readString() ?: "",
privacyStatus = parcel.readString() ?: "public",
gameId = parcel.readString() ?: "",
tags = parcel.createStringArrayList() ?: emptyList(),
rtmpUrl = parcel.readString() ?: "",
streamKey = parcel.readString() ?: "",
broadcastId = parcel.readString() ?: "",
status = parcel.readString() ?: "PENDING",
)
override fun writeToParcel(parcel: Parcel, flags: Int) {
parcel.writeString(service)
parcel.writeString(title)
parcel.writeString(description)
parcel.writeString(privacyStatus)
parcel.writeString(gameId)
parcel.writeStringList(tags)
parcel.writeString(rtmpUrl)
parcel.writeString(streamKey)
parcel.writeString(broadcastId)
parcel.writeString(status)
}
override fun describeContents(): Int = 0
companion object CREATOR : Parcelable.Creator<StreamDestination> {
override fun createFromParcel(parcel: Parcel) = StreamDestination(parcel)
override fun newArray(size: Int) = arrayOfNulls<StreamDestination>(size)
}
}

View File

@@ -0,0 +1,33 @@
package com.omixlab.lckcontrol.shared
import android.os.Parcel
import android.os.Parcelable
data class StreamPlan(
val planId: String,
val name: String,
val status: String = "DRAFT",
val destinations: List<StreamDestination> = emptyList(),
) : Parcelable {
constructor(parcel: Parcel) : this(
planId = parcel.readString()!!,
name = parcel.readString()!!,
status = parcel.readString() ?: "DRAFT",
destinations = parcel.createTypedArrayList(StreamDestination.CREATOR) ?: emptyList(),
)
override fun writeToParcel(parcel: Parcel, flags: Int) {
parcel.writeString(planId)
parcel.writeString(name)
parcel.writeString(status)
parcel.writeTypedList(destinations)
}
override fun describeContents(): Int = 0
companion object CREATOR : Parcelable.Creator<StreamPlan> {
override fun createFromParcel(parcel: Parcel) = StreamPlan(parcel)
override fun newArray(size: Int) = arrayOfNulls<StreamPlan>(size)
}
}

Some files were not shown because too many files have changed in this diff Show More