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