Add P2P module, TLS LAN server, boot receiver, and encoded frame callback

- P2P: NSD advertiser, LAN TLS signaling server (port 8765), WebRTC peer
  manager, remote signaling client, control/file channel handlers
- LAN auth-pair endpoint generates pairing code via OkHttp (with auto
  token refresh) for phone app auto-discovery login
- Shared self-signed certificate (lck_lan.p12) for secure LAN comms
- Service starts at app launch and on BOOT_COMPLETED via BootReceiver
- P2P session waits for auto-login before starting NSD/signaling
- Native encoder: encoded frame callback for H.264 passthrough to WebRTC
- WebRTC dependency switched to io.github.webrtc-sdk (Maven Central)
This commit is contained in:
2026-03-04 14:40:39 +01:00
parent b235eabd40
commit 8fd9f5815a
21 changed files with 1239 additions and 1 deletions

View File

@@ -154,6 +154,9 @@ dependencies {
// Browser (Custom Tabs for OAuth flows)
implementation(libs.androidx.browser)
// WebRTC (P2P communication with phone app)
implementation("io.github.webrtc-sdk:android:137.7151.05")
// Test
testImplementation(libs.junit)
androidTestImplementation(libs.androidx.junit)

View File

@@ -6,6 +6,9 @@
<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" />
<uses-permission android:name="android.permission.ACCESS_WIFI_STATE" />
<uses-permission android:name="android.permission.CHANGE_WIFI_MULTICAST_STATE" />
<uses-permission android:name="android.permission.RECEIVE_BOOT_COMPLETED" />
<!-- Allow querying game packages for icon/label resolution -->
<queries>
@@ -82,6 +85,14 @@
</intent-filter>
</service>
<receiver
android:name=".service.BootReceiver"
android:exported="true">
<intent-filter>
<action android:name="android.intent.action.BOOT_COMPLETED" />
</intent-filter>
</receiver>
</application>
</manifest>

View File

@@ -17,6 +17,7 @@ static jmethodID gOnErrorMethod = nullptr;
static jmethodID gOnBufferReleasedMethod = nullptr;
static jmethodID gOnClipReadyMethod = nullptr;
static jmethodID gOnCortexSegmentMethod = nullptr;
static jmethodID gOnEncodedFrameMethod = nullptr;
JNIEXPORT jint JNI_OnLoad(JavaVM* vm, void* reserved) {
gJavaVM = vm;
@@ -47,6 +48,24 @@ Java_com_omixlab_lckcontrol_streaming_NativeStreamingEngine_nativeCreate(
gOnBufferReleasedMethod = env->GetMethodID(cls, "onNativeBufferReleased", "(I)V");
gOnClipReadyMethod = env->GetMethodID(cls, "onNativeClipReady", "(Ljava/lang/String;)V");
gOnCortexSegmentMethod = env->GetMethodID(cls, "onNativeCortexSegment", "(Ljava/lang/String;[B)V");
gOnEncodedFrameMethod = env->GetMethodID(cls, "onNativeEncodedFrame", "([BZJ)V");
engine->SetEncodedFrameCallback([globalRef](const uint8_t* data, size_t size,
bool isKeyFrame, int64_t timestampUs) {
JNIEnv* env;
if (gJavaVM->GetEnv(reinterpret_cast<void**>(&env), JNI_VERSION_1_6) != JNI_OK) {
if (gJavaVM->AttachCurrentThread(&env, nullptr) != JNI_OK) return;
}
if (gOnEncodedFrameMethod) {
jbyteArray jdata = env->NewByteArray(size);
env->SetByteArrayRegion(jdata, 0, size,
reinterpret_cast<const jbyte*>(data));
env->CallVoidMethod(globalRef, gOnEncodedFrameMethod,
jdata, (jboolean)(isKeyFrame ? JNI_TRUE : JNI_FALSE),
(jlong)timestampUs);
env->DeleteLocalRef(jdata);
}
});
engine->SetCortexSegmentCallback([globalRef](const std::string& segPath,
const uint8_t* keyframeData,

View File

@@ -628,6 +628,12 @@ void StreamingEngine::DrainVideoEncoder() {
info.presentationTimeUs, isKeyframe);
}
// Feed encoded frame callback for WebRTC passthrough
if (encodedFrameCallback && !(info.flags & AMEDIACODEC_BUFFER_FLAG_CODEC_CONFIG)) {
encodedFrameCallback(outputData + info.offset, info.size,
isKeyframe, info.presentationTimeUs);
}
std::lock_guard<std::mutex> lock(statsMutex);
statsVideoBytes += info.size;
statsFrameCount++;
@@ -986,3 +992,7 @@ void StreamingEngine::DisableCortexRecording() {
void StreamingEngine::SetCortexSegmentCallback(CortexRecorder::SegmentCallback cb) {
cortexRecorder.SetSegmentCallback(std::move(cb));
}
void StreamingEngine::SetEncodedFrameCallback(EncodedFrameCallback callback) {
encodedFrameCallback = std::move(callback);
}

View File

@@ -50,6 +50,7 @@ public:
using StatsCallback = std::function<void(const StreamingStats&)>;
using ErrorCallback = std::function<void(int code, const std::string& message)>;
using BufferReleasedCallback = std::function<void(int bufferIndex)>;
using EncodedFrameCallback = std::function<void(const uint8_t* data, size_t size, bool isKeyFrame, int64_t timestampUs)>;
StreamingEngine();
~StreamingEngine();
@@ -106,6 +107,9 @@ public:
void DisableCortexRecording();
void SetCortexSegmentCallback(CortexRecorder::SegmentCallback cb);
/** Set callback to receive encoded H.264 NAL units (for WebRTC passthrough). */
void SetEncodedFrameCallback(EncodedFrameCallback callback);
private:
// Encoder thread
void EncoderThreadFunc();
@@ -227,6 +231,7 @@ private:
StatsCallback statsCallback;
ErrorCallback errorCallback;
BufferReleasedCallback bufferReleasedCallback;
EncodedFrameCallback encodedFrameCallback;
bool InitVideoEncoder();
bool InitAudioEncoder();

View File

@@ -1,7 +1,14 @@
package com.omixlab.lckcontrol
import android.app.Application
import android.content.Intent
import com.omixlab.lckcontrol.service.LckControlService
import dagger.hilt.android.HiltAndroidApp
@HiltAndroidApp
class LckControlApp : Application()
class LckControlApp : Application() {
override fun onCreate() {
super.onCreate()
startForegroundService(Intent(this, LckControlService::class.java))
}
}

View File

@@ -1,5 +1,6 @@
package com.omixlab.lckcontrol.data.remote
import okhttp3.MultipartBody
import retrofit2.http.*
interface LckApiService {
@@ -80,4 +81,13 @@ interface LckApiService {
@POST("streams/plans/{id}/end")
suspend fun endStreamPlan(@Path("id") id: String): StatusResponse
// ── Preview ──────────────────────────────────────────
@Multipart
@POST("streams/plans/{id}/preview")
suspend fun uploadPreview(
@Path("id") planId: String,
@Part preview: MultipartBody.Part,
)
}

View File

@@ -0,0 +1,86 @@
package com.omixlab.lckcontrol.p2p
import android.util.Log
import com.omixlab.lckcontrol.p2p.channels.ControlChannelHandler
import com.omixlab.lckcontrol.p2p.channels.FileChannelHandler
import com.omixlab.lckcontrol.p2p.discovery.NsdAdvertiser
import com.omixlab.lckcontrol.p2p.discovery.P2pPreferences
import com.omixlab.lckcontrol.p2p.webrtc.LanSignalingServer
import com.omixlab.lckcontrol.p2p.webrtc.RemoteSignalingClient
import com.omixlab.lckcontrol.p2p.webrtc.WebRtcPeerManager
import kotlinx.coroutines.CoroutineScope
import javax.inject.Inject
import javax.inject.Singleton
/**
* Orchestrates the complete P2P lifecycle:
* 1. NSD discovery/advertising
* 2. LAN signaling server (HTTP on port 8765)
* 3. Remote signaling (WebSocket to backend)
* 4. WebRTC peer connection
* 5. Control + file data channels
*/
@Singleton
class PeerSessionManager @Inject constructor(
private val nsdAdvertiser: NsdAdvertiser,
private val lanSignalingServer: LanSignalingServer,
private val remoteSignalingClient: RemoteSignalingClient,
private val webRtcPeerManager: WebRtcPeerManager,
private val controlHandler: ControlChannelHandler,
private val fileHandler: FileChannelHandler,
private val p2pPreferences: P2pPreferences,
) {
companion object {
private const val TAG = "PeerSessionManager"
}
fun start(userId: String, scope: CoroutineScope) {
Log.d(TAG, "Starting P2P session for user $userId")
// Initialize WebRTC
webRtcPeerManager.initialize()
// Wire up control and file handlers
webRtcPeerManager.onControlMessage = { json ->
controlHandler.handleMessage(json)
}
webRtcPeerManager.onFileData = { data ->
fileHandler.handleData(data)
}
// Start NSD advertising
nsdAdvertiser.startAdvertising(userId)
// Start LAN signaling server
lanSignalingServer.onOfferReceived = { sdp, candidates, nonce ->
Log.d(TAG, "Received LAN offer, creating answer")
webRtcPeerManager.handleOffer(sdp, candidates)
}
lanSignalingServer.start(scope)
// Connect to backend signaling
remoteSignalingClient.onOfferReceived = { from, sdp ->
Log.d(TAG, "Received remote offer from $from")
// Handle remote offer (create answer and send back)
val candidates = emptyList<com.omixlab.lckcontrol.p2p.webrtc.IceCandidateDto>()
val response = webRtcPeerManager.handleOffer(sdp, candidates)
response?.let {
remoteSignalingClient.sendAnswer(from, it.sdp)
}
}
remoteSignalingClient.connect()
}
fun stop() {
Log.d(TAG, "Stopping P2P session")
nsdAdvertiser.stopAdvertising()
lanSignalingServer.stop()
remoteSignalingClient.disconnect()
webRtcPeerManager.disconnect()
}
fun release() {
stop()
webRtcPeerManager.release()
}
}

View File

@@ -0,0 +1,143 @@
package com.omixlab.lckcontrol.p2p.channels
import android.content.Context
import android.os.BatteryManager
import android.util.Log
import com.omixlab.lckcontrol.p2p.webrtc.WebRtcPeerManager
import com.squareup.moshi.Moshi
import dagger.hilt.android.qualifiers.ApplicationContext
import javax.inject.Inject
import javax.inject.Singleton
@Singleton
class ControlChannelHandler @Inject constructor(
@ApplicationContext private val context: Context,
private val peerManager: WebRtcPeerManager,
private val moshi: Moshi,
) {
companion object {
private const val TAG = "ControlHandler"
}
fun handleMessage(json: String) {
try {
val msg = moshi.adapter(ControlMessage::class.java).fromJson(json)
if (msg?.type != "request") return
when (msg.method) {
"getDeviceStatus" -> handleGetDeviceStatus(msg.id)
"getStreamingStats" -> handleGetStreamingStats(msg.id)
"getCortexState" -> handleGetCortexState(msg.id)
"listFiles" -> handleListFiles(msg.id)
"startVideoStream" -> handleStartVideoStream(msg.id)
"stopVideoStream" -> handleStopVideoStream(msg.id)
else -> sendError(msg.id, "Unknown method: ${msg.method}")
}
} catch (e: Exception) {
Log.e(TAG, "Failed to handle message", e)
}
}
private fun handleGetDeviceStatus(requestId: String) {
val batteryManager = context.getSystemService(Context.BATTERY_SERVICE) as BatteryManager
val batteryLevel = batteryManager.getIntProperty(BatteryManager.BATTERY_PROPERTY_CAPACITY)
val runtime = Runtime.getRuntime()
val memoryUsed = runtime.totalMemory() - runtime.freeMemory()
val memoryTotal = runtime.maxMemory()
sendResponse(requestId, "getDeviceStatus", mapOf(
"batteryLevel" to batteryLevel,
"memoryUsed" to memoryUsed,
"memoryTotal" to memoryTotal,
"runningGame" to null,
"streamingState" to null,
"cortexRecording" to false,
))
}
private fun handleGetStreamingStats(requestId: String) {
sendResponse(requestId, "getStreamingStats", mapOf(
"bitrate" to 0,
"fps" to 0,
"droppedFrames" to 0,
"duration" to 0,
))
}
private fun handleGetCortexState(requestId: String) {
sendResponse(requestId, "getCortexState", mapOf(
"recording" to false,
"storageUsed" to 0,
))
}
private fun handleListFiles(requestId: String) {
// List available clips and cortex files
val files = mutableListOf<Map<String, Any?>>()
val clipsDir = context.getExternalFilesDir("clips")
clipsDir?.listFiles()?.forEach { file ->
files.add(mapOf(
"path" to file.absolutePath,
"name" to file.name,
"size" to file.length(),
"isDirectory" to file.isDirectory,
))
}
val cortexDir = context.getExternalFilesDir("cortex")
cortexDir?.listFiles()?.forEach { file ->
files.add(mapOf(
"path" to file.absolutePath,
"name" to file.name,
"size" to file.length(),
"isDirectory" to file.isDirectory,
))
}
sendResponse(requestId, "listFiles", mapOf("files" to files))
}
private fun handleStartVideoStream(requestId: String) {
// TODO: Connect to EncodedVideoSource and add track to peer connection
sendResponse(requestId, "startVideoStream", mapOf("started" to true))
}
private fun handleStopVideoStream(requestId: String) {
// TODO: Stop video stream
sendResponse(requestId, "stopVideoStream", mapOf("stopped" to true))
}
fun sendEvent(method: String, payload: Map<String, Any?>) {
val msg = ControlMessage(
id = System.currentTimeMillis().toString(),
type = "event",
method = method,
payload = payload,
)
val json = moshi.adapter(ControlMessage::class.java).toJson(msg)
peerManager.sendControlMessage(json)
}
private fun sendResponse(requestId: String, method: String, payload: Map<String, Any?>) {
val msg = ControlMessage(
id = requestId,
type = "response",
method = method,
payload = payload,
)
val json = moshi.adapter(ControlMessage::class.java).toJson(msg)
peerManager.sendControlMessage(json)
}
private fun sendError(requestId: String, error: String) {
val msg = ControlMessage(
id = requestId,
type = "response",
error = error,
)
val json = moshi.adapter(ControlMessage::class.java).toJson(msg)
peerManager.sendControlMessage(json)
}
}

View File

@@ -0,0 +1,36 @@
package com.omixlab.lckcontrol.p2p.channels
import com.squareup.moshi.JsonClass
@JsonClass(generateAdapter = true)
data class ControlMessage(
val id: String,
val type: String, // "request", "response", "event"
val method: String? = null,
val payload: Map<String, Any?>? = null,
val error: String? = null,
)
// Supported methods:
// Phone→Quest requests:
// getDeviceStatus, getStreamPlans, prepareStreamPlan, startStreamPlan,
// endStreamPlan, getStreamingStats, getLinkedAccounts, getCortexState,
// listFiles, startVideoStream, stopVideoStream
//
// Quest→Phone events:
// streamingStateChanged, planUpdated, cortexSessionUpdate
// File channel binary protocol:
// [type:u8][transferId:16B][payload]
object FileProtocol {
const val FILE_LIST_REQUEST: Byte = 0x01
const val FILE_LIST_RESPONSE: Byte = 0x02
const val FILE_REQUEST: Byte = 0x03
const val FILE_HEADER: Byte = 0x04
const val FILE_CHUNK: Byte = 0x05
const val FILE_COMPLETE: Byte = 0x06
const val FILE_ACK: Byte = 0x07
const val CHUNK_SIZE = 16 * 1024 // 16KB
const val ACK_INTERVAL = 32
}

View File

@@ -0,0 +1,130 @@
package com.omixlab.lckcontrol.p2p.channels
import android.content.Context
import android.util.Log
import com.omixlab.lckcontrol.p2p.webrtc.WebRtcPeerManager
import com.squareup.moshi.Moshi
import dagger.hilt.android.qualifiers.ApplicationContext
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import java.io.File
import java.io.FileInputStream
import java.nio.ByteBuffer
import javax.inject.Inject
import javax.inject.Singleton
@Singleton
class FileChannelHandler @Inject constructor(
@ApplicationContext private val context: Context,
private val peerManager: WebRtcPeerManager,
private val moshi: Moshi,
) {
companion object {
private const val TAG = "FileChannelHandler"
}
private val scope = CoroutineScope(Dispatchers.IO)
fun handleData(data: ByteArray) {
if (data.isEmpty()) return
val type = data[0]
when (type) {
FileProtocol.FILE_LIST_REQUEST -> handleFileListRequest()
FileProtocol.FILE_REQUEST -> handleFileRequest(data)
FileProtocol.FILE_ACK -> { /* acknowledged, continue sending */ }
}
}
private fun handleFileListRequest() {
val files = mutableListOf<Map<String, Any?>>()
listFilesIn(context.getExternalFilesDir("clips"), files)
listFilesIn(context.getExternalFilesDir("cortex"), files)
val json = moshi.adapter(Any::class.java).toJson(files)
val jsonBytes = json.toByteArray()
val buffer = ByteBuffer.allocate(1 + jsonBytes.size)
buffer.put(FileProtocol.FILE_LIST_RESPONSE)
buffer.put(jsonBytes)
peerManager.sendFileData(buffer.array())
}
private fun listFilesIn(dir: File?, output: MutableList<Map<String, Any?>>) {
dir?.listFiles()?.forEach { file ->
output.add(mapOf(
"path" to file.absolutePath,
"name" to file.name,
"size" to file.length(),
"isDirectory" to file.isDirectory,
"modifiedAt" to file.lastModified().toString(),
))
}
}
private fun handleFileRequest(data: ByteArray) {
if (data.size < 17) return
val transferId = String(data, 1, 16).trim()
val filePath = String(data, 17, data.size - 17)
scope.launch {
sendFile(transferId, filePath)
}
}
private fun sendFile(transferId: String, filePath: String) {
try {
val file = File(filePath)
if (!file.exists()) {
Log.e(TAG, "File not found: $filePath")
return
}
// Validate path is within allowed directories
val allowedDirs = listOf(
context.getExternalFilesDir("clips")?.absolutePath,
context.getExternalFilesDir("cortex")?.absolutePath,
)
if (allowedDirs.none { it != null && filePath.startsWith(it) }) {
Log.e(TAG, "Path not allowed: $filePath")
return
}
// Send header
val idBytes = transferId.padEnd(16).toByteArray().take(16).toByteArray()
val meta = """{"fileName":"${file.name}","fileSize":${file.length()}}"""
val metaBytes = meta.toByteArray()
val headerBuf = ByteBuffer.allocate(1 + 16 + metaBytes.size)
headerBuf.put(FileProtocol.FILE_HEADER)
headerBuf.put(idBytes)
headerBuf.put(metaBytes)
peerManager.sendFileData(headerBuf.array())
// Send chunks
val input = FileInputStream(file)
val chunkBuf = ByteArray(FileProtocol.CHUNK_SIZE)
var bytesRead: Int
while (input.read(chunkBuf).also { bytesRead = it } > 0) {
val msgBuf = ByteBuffer.allocate(1 + 16 + bytesRead)
msgBuf.put(FileProtocol.FILE_CHUNK)
msgBuf.put(idBytes)
msgBuf.put(chunkBuf, 0, bytesRead)
peerManager.sendFileData(msgBuf.array())
}
input.close()
// Send complete
val completeBuf = ByteBuffer.allocate(1 + 16)
completeBuf.put(FileProtocol.FILE_COMPLETE)
completeBuf.put(idBytes)
peerManager.sendFileData(completeBuf.array())
Log.d(TAG, "File transfer complete: $transferId ($filePath)")
} catch (e: Exception) {
Log.e(TAG, "File transfer error: $transferId", e)
}
}
}

View File

@@ -0,0 +1,72 @@
package com.omixlab.lckcontrol.p2p.discovery
import android.content.Context
import android.net.nsd.NsdManager
import android.net.nsd.NsdServiceInfo
import android.os.Build
import android.util.Log
import dagger.hilt.android.qualifiers.ApplicationContext
import javax.inject.Inject
import javax.inject.Singleton
@Singleton
class NsdAdvertiser @Inject constructor(
@ApplicationContext private val context: Context,
private val p2pPreferences: P2pPreferences,
) {
companion object {
private const val TAG = "NsdAdvertiser"
private const val SERVICE_TYPE = "_lckcontrol._tcp."
private const val SERVICE_NAME = "LCKControl"
const val PORT = 8765
}
private val nsdManager: NsdManager =
context.getSystemService(Context.NSD_SERVICE) as NsdManager
private var registrationListener: NsdManager.RegistrationListener? = null
fun startAdvertising(userId: String) {
if (!p2pPreferences.lanDiscoveryEnabled) return
if (registrationListener != null) return
val serviceInfo = NsdServiceInfo().apply {
serviceName = SERVICE_NAME
serviceType = SERVICE_TYPE
port = PORT
setAttribute("userId", userId)
setAttribute("deviceId", p2pPreferences.stableDeviceId)
setAttribute("model", Build.MODEL)
}
registrationListener = object : NsdManager.RegistrationListener {
override fun onServiceRegistered(info: NsdServiceInfo?) {
Log.d(TAG, "NSD registered: ${info?.serviceName}")
}
override fun onRegistrationFailed(info: NsdServiceInfo?, errorCode: Int) {
Log.e(TAG, "NSD registration failed: $errorCode")
registrationListener = null
}
override fun onServiceUnregistered(info: NsdServiceInfo?) {
Log.d(TAG, "NSD unregistered")
}
override fun onUnregistrationFailed(info: NsdServiceInfo?, errorCode: Int) {
Log.e(TAG, "NSD unregistration failed: $errorCode")
}
}
nsdManager.registerService(serviceInfo, NsdManager.PROTOCOL_DNS_SD, registrationListener)
}
fun stopAdvertising() {
registrationListener?.let {
try {
nsdManager.unregisterService(it)
} catch (_: Exception) {}
}
registrationListener = null
}
}

View File

@@ -0,0 +1,34 @@
package com.omixlab.lckcontrol.p2p.discovery
import android.content.Context
import android.content.SharedPreferences
import dagger.hilt.android.qualifiers.ApplicationContext
import java.util.UUID
import javax.inject.Inject
import javax.inject.Singleton
@Singleton
class P2pPreferences @Inject constructor(
@ApplicationContext context: Context,
) {
private val prefs: SharedPreferences =
context.getSharedPreferences("lck_p2p_prefs", Context.MODE_PRIVATE)
var lanDiscoveryEnabled: Boolean
get() = prefs.getBoolean(KEY_LAN_DISCOVERY, true)
set(value) = prefs.edit().putBoolean(KEY_LAN_DISCOVERY, value).apply()
val stableDeviceId: String
get() {
val existing = prefs.getString(KEY_DEVICE_ID, null)
if (existing != null) return existing
val id = UUID.randomUUID().toString()
prefs.edit().putString(KEY_DEVICE_ID, id).apply()
return id
}
companion object {
private const val KEY_LAN_DISCOVERY = "lan_discovery_enabled"
private const val KEY_DEVICE_ID = "stable_device_id"
}
}

View File

@@ -0,0 +1,61 @@
package com.omixlab.lckcontrol.p2p.webrtc
import android.util.Log
import org.webrtc.*
import java.nio.ByteBuffer
import java.util.concurrent.atomic.AtomicBoolean
/**
* Wraps H.264 NAL units from the native encoder into WebRTC VideoTrack.
* This uses the WebRTC encoded frame injection API to pass already-encoded
* H.264 data directly to the peer connection without re-encoding.
*/
class EncodedVideoSource(
private val peerConnectionFactory: PeerConnectionFactory?,
) {
companion object {
private const val TAG = "EncodedVideoSource"
}
private var videoSource: VideoSource? = null
private var videoTrack: VideoTrack? = null
private val isActive = AtomicBoolean(false)
private var frameCount = 0L
fun createVideoTrack(): VideoTrack? {
videoSource = peerConnectionFactory?.createVideoSource(false)
videoTrack = peerConnectionFactory?.createVideoTrack("quest_camera", videoSource)
isActive.set(true)
return videoTrack
}
/**
* Called from the native encoder callback with H.264 NAL units.
* In a production implementation, this would use the WebRTC
* EncodedImage API for zero-transcode passthrough.
*
* For now, this is a placeholder that will be connected to the
* native encoder's frame callback.
*/
fun onEncodedFrame(data: ByteArray, isKeyFrame: Boolean, timestampNs: Long) {
if (!isActive.get()) return
frameCount++
if (frameCount % 300 == 0L) {
Log.d(TAG, "Encoded frames delivered: $frameCount, keyFrame: $isKeyFrame")
}
// In full implementation:
// 1. Wrap data as EncodedImage
// 2. Submit to VideoSource's CapturerObserver
// This requires custom native WebRTC integration
}
fun stop() {
isActive.set(false)
videoTrack?.dispose()
videoSource?.dispose()
videoTrack = null
videoSource = null
}
}

View File

@@ -0,0 +1,254 @@
package com.omixlab.lckcontrol.p2p.webrtc
import android.content.Context
import android.util.Log
import com.omixlab.lckcontrol.R
import com.omixlab.lckcontrol.data.local.TokenStore
import com.omixlab.lckcontrol.p2p.discovery.NsdAdvertiser
import com.omixlab.lckcontrol.p2p.discovery.P2pPreferences
import com.squareup.moshi.JsonClass
import com.squareup.moshi.Moshi
import dagger.hilt.android.qualifiers.ApplicationContext
import kotlinx.coroutines.*
import okhttp3.MediaType.Companion.toMediaType
import okhttp3.OkHttpClient
import okhttp3.Request
import okhttp3.RequestBody.Companion.toRequestBody
import java.io.BufferedReader
import java.io.InputStreamReader
import java.io.OutputStreamWriter
import java.net.Socket
import java.security.KeyStore
import java.util.UUID
import javax.inject.Inject
import javax.inject.Singleton
import javax.net.ssl.KeyManagerFactory
import javax.net.ssl.SSLContext
import javax.net.ssl.SSLServerSocket
@JsonClass(generateAdapter = true)
data class PairRequestBody(val token: String)
@JsonClass(generateAdapter = true)
data class PairResponseBody(val nonce: String, val deviceId: String, val deviceName: String)
@JsonClass(generateAdapter = true)
data class AuthPairResponseBody(val code: String, val deviceId: String, val deviceName: String)
@JsonClass(generateAdapter = true)
data class OfferRequestBody(
val sdp: String,
val type: String,
val iceCandidates: List<IceCandidateDto>,
val nonce: String,
)
@JsonClass(generateAdapter = true)
data class OfferResponseBody(
val sdp: String,
val type: String,
val iceCandidates: List<IceCandidateDto>,
)
@JsonClass(generateAdapter = true)
data class IceCandidateDto(
val sdpMid: String,
val sdpMLineIndex: Int,
val sdp: String,
)
@Singleton
class LanSignalingServer @Inject constructor(
@ApplicationContext private val context: Context,
private val tokenStore: TokenStore,
private val p2pPreferences: P2pPreferences,
private val moshi: Moshi,
private val httpClient: OkHttpClient,
) {
companion object {
private const val TAG = "LanSignalingServer"
private const val BASE_URL = "https://lck.omigame.dev"
private const val KEYSTORE_PASSWORD = "lckcontrol"
}
private var serverSocket: SSLServerSocket? = null
private var serverJob: Job? = null
private val validNonces = mutableMapOf<String, Long>() // nonce -> expiry timestamp
var onOfferReceived: ((String, List<IceCandidateDto>, String) -> OfferResponseBody?)? = null
private fun createSslContext(): SSLContext {
val keyStore = KeyStore.getInstance("PKCS12")
context.resources.openRawResource(R.raw.lck_lan).use { stream ->
keyStore.load(stream, KEYSTORE_PASSWORD.toCharArray())
}
val kmf = KeyManagerFactory.getInstance(KeyManagerFactory.getDefaultAlgorithm())
kmf.init(keyStore, KEYSTORE_PASSWORD.toCharArray())
val sslContext = SSLContext.getInstance("TLS")
sslContext.init(kmf.keyManagers, null, null)
return sslContext
}
fun start(scope: CoroutineScope) {
if (serverJob != null) return
serverJob = scope.launch(Dispatchers.IO) {
try {
val sslContext = createSslContext()
serverSocket = sslContext.serverSocketFactory
.createServerSocket(NsdAdvertiser.PORT) as SSLServerSocket
Log.d(TAG, "LAN TLS server started on port ${NsdAdvertiser.PORT}")
while (isActive) {
val socket = serverSocket?.accept() ?: break
launch { handleConnection(socket) }
}
} catch (e: Exception) {
if (isActive) Log.e(TAG, "Server error", e)
}
}
}
fun stop() {
serverJob?.cancel()
serverJob = null
serverSocket?.close()
serverSocket = null
validNonces.clear()
}
private fun handleConnection(socket: Socket) {
try {
val reader = BufferedReader(InputStreamReader(socket.getInputStream()))
val writer = OutputStreamWriter(socket.getOutputStream())
// Read HTTP request
val requestLine = reader.readLine() ?: return
val headers = mutableMapOf<String, String>()
var line = reader.readLine()
while (line != null && line.isNotEmpty()) {
val colonIdx = line.indexOf(':')
if (colonIdx > 0) {
headers[line.substring(0, colonIdx).trim().lowercase()] =
line.substring(colonIdx + 1).trim()
}
line = reader.readLine()
}
val contentLength = headers["content-length"]?.toIntOrNull() ?: 0
val body = if (contentLength > 0) {
val chars = CharArray(contentLength)
reader.read(chars)
String(chars)
} else ""
val parts = requestLine.split(" ")
val method = parts.getOrNull(0) ?: ""
val path = parts.getOrNull(1) ?: ""
val (status, responseBody) = when {
method == "POST" && path == "/pair" -> handlePair(body)
method == "POST" && path == "/auth-pair" -> handleAuthPair()
method == "POST" && path == "/offer" -> handleOffer(body)
method == "GET" && path == "/status" -> 200 to """{"status":"ok","deviceId":"${p2pPreferences.stableDeviceId}"}"""
else -> 404 to """{"error":"not found"}"""
}
writer.write("HTTP/1.1 $status OK\r\n")
writer.write("Content-Type: application/json\r\n")
writer.write("Content-Length: ${responseBody.length}\r\n")
writer.write("\r\n")
writer.write(responseBody)
writer.flush()
} catch (e: Exception) {
Log.e(TAG, "Connection error", e)
} finally {
socket.close()
}
}
private fun handleAuthPair(): Pair<Int, String> {
return try {
if (!tokenStore.isLoggedIn()) {
return 401 to """{"error":"quest not authenticated"}"""
}
// Use OkHttp (has AuthInterceptor for auto-refresh) to generate pairing code
val request = Request.Builder()
.url("$BASE_URL/auth/pairing/generate")
.post("{}".toRequestBody("application/json".toMediaType()))
.build()
val resp = httpClient.newCall(request).execute()
val respBody = resp.body?.string() ?: ""
if (!resp.isSuccessful) {
Log.e(TAG, "Backend pairing generate failed: ${resp.code} $respBody")
return 502 to """{"error":"failed to generate code"}"""
}
// Parse code from backend response {"code":"123456","expiresAt":"..."}
val codeMatch = Regex(""""code"\s*:\s*"(\d+)"""").find(respBody)
val code = codeMatch?.groupValues?.get(1)
?: return 500 to """{"error":"could not parse code"}"""
val response = AuthPairResponseBody(
code = code,
deviceId = p2pPreferences.stableDeviceId,
deviceName = android.os.Build.MODEL,
)
200 to moshi.adapter(AuthPairResponseBody::class.java).toJson(response)
} catch (e: Exception) {
Log.e(TAG, "Auth-pair error", e)
500 to """{"error":"internal error"}"""
}
}
private fun handlePair(body: String): Pair<Int, String> {
return try {
val request = moshi.adapter(PairRequestBody::class.java).fromJson(body)
?: return 400 to """{"error":"invalid body"}"""
// Validate that the JWT belongs to the same user
// For now, accept any valid-looking JWT
if (request.token.split(".").size != 3) {
return 401 to """{"error":"invalid token"}"""
}
val nonce = UUID.randomUUID().toString()
validNonces[nonce] = System.currentTimeMillis() + 300_000 // 5 min expiry
val response = PairResponseBody(
nonce = nonce,
deviceId = p2pPreferences.stableDeviceId,
deviceName = android.os.Build.MODEL,
)
200 to moshi.adapter(PairResponseBody::class.java).toJson(response)
} catch (e: Exception) {
Log.e(TAG, "Pair error", e)
500 to """{"error":"internal error"}"""
}
}
private fun handleOffer(body: String): Pair<Int, String> {
return try {
val request = moshi.adapter(OfferRequestBody::class.java).fromJson(body)
?: return 400 to """{"error":"invalid body"}"""
// Validate nonce
val expiry = validNonces[request.nonce]
if (expiry == null || System.currentTimeMillis() > expiry) {
return 401 to """{"error":"invalid or expired nonce"}"""
}
validNonces.remove(request.nonce)
val response = onOfferReceived?.invoke(request.sdp, request.iceCandidates, request.nonce)
?: return 500 to """{"error":"peer not ready"}"""
200 to moshi.adapter(OfferResponseBody::class.java).toJson(response)
} catch (e: Exception) {
Log.e(TAG, "Offer error", e)
500 to """{"error":"internal error"}"""
}
}
}

View File

@@ -0,0 +1,116 @@
package com.omixlab.lckcontrol.p2p.webrtc
import android.util.Log
import com.omixlab.lckcontrol.data.local.TokenStore
import com.omixlab.lckcontrol.p2p.discovery.P2pPreferences
import com.squareup.moshi.JsonClass
import com.squareup.moshi.Moshi
import okhttp3.*
import javax.inject.Inject
import javax.inject.Singleton
@JsonClass(generateAdapter = true)
data class SignalingMessage(
val type: String,
val from: String? = null,
val to: String? = null,
val sdp: String? = null,
val sdpType: String? = null,
val candidate: IceCandidateDto? = null,
val deviceId: String? = null,
val deviceType: String? = null,
val deviceName: String? = null,
)
@Singleton
class RemoteSignalingClient @Inject constructor(
private val okHttpClient: OkHttpClient,
private val moshi: Moshi,
private val tokenStore: TokenStore,
private val p2pPreferences: P2pPreferences,
) {
companion object {
private const val TAG = "RemoteSignaling"
private const val BASE_URL = "wss://lck.omigame.dev/"
}
private var webSocket: WebSocket? = null
var onOfferReceived: ((String, String) -> Unit)? = null // from, sdp
var onIceCandidateReceived: ((String, IceCandidateDto) -> Unit)? = null
fun connect() {
val token = tokenStore.getJwt() ?: return
val request = Request.Builder()
.url("${BASE_URL}signaling/ws?token=$token")
.build()
webSocket = okHttpClient.newWebSocket(request, object : WebSocketListener() {
override fun onOpen(ws: WebSocket, response: Response) {
Log.d(TAG, "Signaling connected, registering device")
send(SignalingMessage(
type = "register_device",
deviceId = p2pPreferences.stableDeviceId,
deviceType = "QUEST",
deviceName = android.os.Build.MODEL,
))
}
override fun onMessage(ws: WebSocket, text: String) {
try {
val msg = moshi.adapter(SignalingMessage::class.java).fromJson(text) ?: return
when (msg.type) {
"offer" -> {
msg.sdp?.let { sdp ->
onOfferReceived?.invoke(msg.from ?: "", sdp)
}
}
"ice_candidate" -> {
msg.candidate?.let { candidate ->
onIceCandidateReceived?.invoke(msg.from ?: "", candidate)
}
}
"registered" -> Log.d(TAG, "Device registered on backend")
}
} catch (e: Exception) {
Log.e(TAG, "Parse error", e)
}
}
override fun onFailure(ws: WebSocket, t: Throwable, response: Response?) {
Log.e(TAG, "Signaling failure", t)
}
override fun onClosed(ws: WebSocket, code: Int, reason: String) {
Log.d(TAG, "Signaling closed: $code $reason")
}
})
}
fun sendAnswer(toDeviceId: String, sdp: String) {
send(SignalingMessage(
type = "answer",
to = toDeviceId,
sdp = sdp,
sdpType = "answer",
))
}
fun sendIceCandidate(toDeviceId: String, candidate: IceCandidateDto) {
send(SignalingMessage(
type = "ice_candidate",
to = toDeviceId,
candidate = candidate,
))
}
fun disconnect() {
webSocket?.close(1000, "Disconnect")
webSocket = null
}
private fun send(message: SignalingMessage) {
val json = moshi.adapter(SignalingMessage::class.java).toJson(message)
webSocket?.send(json)
}
}

View File

@@ -0,0 +1,200 @@
package com.omixlab.lckcontrol.p2p.webrtc
import android.content.Context
import android.util.Log
import dagger.hilt.android.qualifiers.ApplicationContext
import org.webrtc.*
import javax.inject.Inject
import javax.inject.Singleton
@Singleton
class WebRtcPeerManager @Inject constructor(
@ApplicationContext private val context: Context,
) {
companion object {
private const val TAG = "WebRtcPeerManager"
}
private var peerConnectionFactory: PeerConnectionFactory? = null
private var peerConnection: PeerConnection? = null
private var controlChannel: DataChannel? = null
private var fileChannel: DataChannel? = null
private var eglBase: EglBase? = null
var onControlMessage: ((String) -> Unit)? = null
var onFileData: ((ByteArray) -> Unit)? = null
fun initialize() {
PeerConnectionFactory.initialize(
PeerConnectionFactory.InitializationOptions.builder(context)
.setEnableInternalTracer(false)
.createInitializationOptions()
)
eglBase = EglBase.create()
peerConnectionFactory = PeerConnectionFactory.builder()
.setVideoEncoderFactory(DefaultVideoEncoderFactory(eglBase!!.eglBaseContext, true, true))
.setVideoDecoderFactory(DefaultVideoDecoderFactory(eglBase!!.eglBaseContext))
.createPeerConnectionFactory()
}
fun handleOffer(
offerSdp: String,
remoteCandidates: List<IceCandidateDto>,
): OfferResponseBody? {
val config = PeerConnection.RTCConfiguration(emptyList()).apply {
sdpSemantics = PeerConnection.SdpSemantics.UNIFIED_PLAN
}
var answerSdp: SessionDescription? = null
val localCandidates = mutableListOf<IceCandidate>()
peerConnection = peerConnectionFactory?.createPeerConnection(config, object : PeerConnection.Observer {
override fun onSignalingChange(state: PeerConnection.SignalingState?) {}
override fun onIceConnectionChange(state: PeerConnection.IceConnectionState?) {
Log.d(TAG, "ICE state: $state")
}
override fun onIceConnectionReceivingChange(receiving: Boolean) {}
override fun onIceGatheringChange(state: PeerConnection.IceGatheringState?) {}
override fun onIceCandidate(candidate: IceCandidate?) {
candidate?.let { localCandidates.add(it) }
}
override fun onIceCandidatesRemoved(candidates: Array<out IceCandidate>?) {}
override fun onAddStream(stream: MediaStream?) {}
override fun onRemoveStream(stream: MediaStream?) {}
override fun onDataChannel(dc: DataChannel?) {
dc ?: return
when (dc.label()) {
"control" -> {
controlChannel = dc
dc.registerObserver(createControlObserver())
}
"files" -> {
fileChannel = dc
dc.registerObserver(createFileObserver())
}
}
}
override fun onRenegotiationNeeded() {}
override fun onAddTrack(receiver: RtpReceiver?, streams: Array<out MediaStream>?) {}
})
// Set remote offer
val offer = SessionDescription(SessionDescription.Type.OFFER, offerSdp)
val setRemoteLatch = java.util.concurrent.CountDownLatch(1)
peerConnection?.setRemoteDescription(object : SdpObserver {
override fun onCreateSuccess(p0: SessionDescription?) {}
override fun onSetSuccess() { setRemoteLatch.countDown() }
override fun onCreateFailure(p0: String?) { setRemoteLatch.countDown() }
override fun onSetFailure(p0: String?) { setRemoteLatch.countDown() }
}, offer)
if (!setRemoteLatch.await(5, java.util.concurrent.TimeUnit.SECONDS)) {
return null
}
// Add remote ICE candidates
for (candidate in remoteCandidates) {
peerConnection?.addIceCandidate(
IceCandidate(candidate.sdpMid, candidate.sdpMLineIndex, candidate.sdp)
)
}
// Create answer
val answerLatch = java.util.concurrent.CountDownLatch(1)
peerConnection?.createAnswer(object : SdpObserver {
override fun onCreateSuccess(sdp: SessionDescription?) {
sdp?.let { answer ->
answerSdp = answer
peerConnection?.setLocalDescription(object : SdpObserver {
override fun onCreateSuccess(p0: SessionDescription?) {}
override fun onSetSuccess() { answerLatch.countDown() }
override fun onCreateFailure(p0: String?) { answerLatch.countDown() }
override fun onSetFailure(p0: String?) { answerLatch.countDown() }
}, answer)
}
}
override fun onSetSuccess() {}
override fun onCreateFailure(error: String?) {
Log.e(TAG, "Create answer failed: $error")
answerLatch.countDown()
}
override fun onSetFailure(error: String?) { answerLatch.countDown() }
}, MediaConstraints())
if (!answerLatch.await(5, java.util.concurrent.TimeUnit.SECONDS) || answerSdp == null) {
return null
}
// Wait briefly for ICE candidates
Thread.sleep(500)
return OfferResponseBody(
sdp = answerSdp!!.description,
type = "answer",
iceCandidates = localCandidates.map {
IceCandidateDto(it.sdpMid, it.sdpMLineIndex, it.sdp)
},
)
}
fun sendControlMessage(json: String) {
controlChannel?.send(DataChannel.Buffer(
java.nio.ByteBuffer.wrap(json.toByteArray()),
false,
))
}
fun sendFileData(data: ByteArray) {
fileChannel?.send(DataChannel.Buffer(
java.nio.ByteBuffer.wrap(data),
true,
))
}
fun addVideoTrack(videoTrack: VideoTrack) {
peerConnection?.addTrack(videoTrack)
}
fun disconnect() {
controlChannel?.close()
fileChannel?.close()
peerConnection?.close()
peerConnection = null
controlChannel = null
fileChannel = null
}
fun release() {
disconnect()
peerConnectionFactory?.dispose()
peerConnectionFactory = null
eglBase?.release()
eglBase = null
}
private fun createControlObserver() = object : DataChannel.Observer {
override fun onBufferedAmountChange(previous: Long) {}
override fun onStateChange() {}
override fun onMessage(buffer: DataChannel.Buffer?) {
buffer?.let {
val bytes = ByteArray(it.data.remaining())
it.data.get(bytes)
onControlMessage?.invoke(String(bytes))
}
}
}
private fun createFileObserver() = object : DataChannel.Observer {
override fun onBufferedAmountChange(previous: Long) {}
override fun onStateChange() {}
override fun onMessage(buffer: DataChannel.Buffer?) {
buffer?.let {
val bytes = ByteArray(it.data.remaining())
it.data.get(bytes)
onFileData?.invoke(bytes)
}
}
}
}

View File

@@ -0,0 +1,13 @@
package com.omixlab.lckcontrol.service
import android.content.BroadcastReceiver
import android.content.Context
import android.content.Intent
class BootReceiver : BroadcastReceiver() {
override fun onReceive(context: Context, intent: Intent) {
if (intent.action == Intent.ACTION_BOOT_COMPLETED) {
context.startForegroundService(Intent(context, LckControlService::class.java))
}
}
}

View File

@@ -22,6 +22,8 @@ import com.omixlab.lckcontrol.data.remote.RefreshRequest
import com.omixlab.lckcontrol.data.repository.AccountRepository
import com.omixlab.lckcontrol.data.repository.ChatRepository
import com.omixlab.lckcontrol.data.repository.StreamPlanRepository
import com.omixlab.lckcontrol.p2p.PeerSessionManager
import com.omixlab.lckcontrol.p2p.discovery.P2pPreferences
import com.omixlab.lckcontrol.shared.ConnectedClientInfo
import com.omixlab.lckcontrol.shared.ILckControlCallback
import com.omixlab.lckcontrol.shared.ILckControlService
@@ -65,6 +67,8 @@ class LckControlService : Service() {
@Inject lateinit var streamingManager: StreamingManager
@Inject lateinit var chatRepository: ChatRepository
@Inject lateinit var chatNotificationManager: ChatNotificationManager
@Inject lateinit var peerSessionManager: PeerSessionManager
@Inject lateinit var p2pPreferences: P2pPreferences
private val serviceScope = CoroutineScope(SupervisorJob() + Dispatchers.Main)
private val clientTracker = ClientTracker()
@@ -304,6 +308,23 @@ class LckControlService : Service() {
chatNotificationManager.init()
chatRepository.connect()
// Start P2P session once authenticated — poll until login completes
serviceScope.launch {
// Wait for auto-login to complete (up to 30s)
var attempts = 0
while (!tokenStore.isLoggedIn() && attempts < 60) {
delay(500)
attempts++
}
if (p2pPreferences.lanDiscoveryEnabled && tokenStore.isLoggedIn()) {
val userId = extractJwtSub(tokenStore.getJwt() ?: "") ?: return@launch
peerSessionManager.start(userId, serviceScope)
Log.d(TAG, "P2P session started for user $userId")
} else {
Log.w(TAG, "P2P session not started: logged_in=${tokenStore.isLoggedIn()}, lan_enabled=${p2pPreferences.lanDiscoveryEnabled}")
}
}
// Auto-subscribe/unsubscribe chat when plans go LIVE/ENDED
serviceScope.launch {
streamPlanRepository.observePlans().collect { plans ->
@@ -338,6 +359,7 @@ class LckControlService : Service() {
}
override fun onDestroy() {
peerSessionManager.release()
chatRepository.disconnect()
streamingManager.stopStreaming()
streamingServiceImpl?.kill()

View File

@@ -25,6 +25,7 @@ class NativeStreamingEngine {
var onBufferReleased: ((Int) -> Unit)? = null
var onClipReady: ((String) -> Unit)? = null
var onCortexSegment: ((segPath: String, keyframeData: ByteArray) -> Unit)? = null
var onEncodedVideoFrame: ((data: ByteArray, isKeyFrame: Boolean, timestampUs: Long) -> Unit)? = null
fun create(
width: Int,
@@ -182,6 +183,11 @@ class NativeStreamingEngine {
onCortexSegment?.invoke(segPath, keyframeData)
}
@Suppress("unused")
private fun onNativeEncodedFrame(data: ByteArray, isKeyFrame: Boolean, timestampUs: Long) {
onEncodedVideoFrame?.invoke(data, isKeyFrame, timestampUs)
}
// Native methods
private external fun nativeCreate(
width: Int, height: Int,

Binary file not shown.