From 8fd9f5815a34165d60ba8b9a93f00ddc9910955b Mon Sep 17 00:00:00 2001 From: omigamedev Date: Wed, 4 Mar 2026 14:40:39 +0100 Subject: [PATCH] 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) --- app/build.gradle.kts | 3 + app/src/main/AndroidManifest.xml | 11 + app/src/main/cpp/jni_bridge.cpp | 19 ++ app/src/main/cpp/streaming_engine.cpp | 10 + app/src/main/cpp/streaming_engine.h | 5 + .../com/omixlab/lckcontrol/LckControlApp.kt | 9 +- .../lckcontrol/data/remote/LckApiService.kt | 10 + .../lckcontrol/p2p/PeerSessionManager.kt | 86 ++++++ .../p2p/channels/ControlChannelHandler.kt | 143 ++++++++++ .../p2p/channels/ControlProtocol.kt | 36 +++ .../p2p/channels/FileChannelHandler.kt | 130 +++++++++ .../lckcontrol/p2p/discovery/NsdAdvertiser.kt | 72 +++++ .../p2p/discovery/P2pPreferences.kt | 34 +++ .../p2p/webrtc/EncodedVideoSource.kt | 61 +++++ .../p2p/webrtc/LanSignalingServer.kt | 254 ++++++++++++++++++ .../p2p/webrtc/RemoteSignalingClient.kt | 116 ++++++++ .../p2p/webrtc/WebRtcPeerManager.kt | 200 ++++++++++++++ .../lckcontrol/service/BootReceiver.kt | 13 + .../lckcontrol/service/LckControlService.kt | 22 ++ .../streaming/NativeStreamingEngine.kt | 6 + app/src/main/res/raw/lck_lan.p12 | Bin 0 -> 1094 bytes 21 files changed, 1239 insertions(+), 1 deletion(-) create mode 100644 app/src/main/java/com/omixlab/lckcontrol/p2p/PeerSessionManager.kt create mode 100644 app/src/main/java/com/omixlab/lckcontrol/p2p/channels/ControlChannelHandler.kt create mode 100644 app/src/main/java/com/omixlab/lckcontrol/p2p/channels/ControlProtocol.kt create mode 100644 app/src/main/java/com/omixlab/lckcontrol/p2p/channels/FileChannelHandler.kt create mode 100644 app/src/main/java/com/omixlab/lckcontrol/p2p/discovery/NsdAdvertiser.kt create mode 100644 app/src/main/java/com/omixlab/lckcontrol/p2p/discovery/P2pPreferences.kt create mode 100644 app/src/main/java/com/omixlab/lckcontrol/p2p/webrtc/EncodedVideoSource.kt create mode 100644 app/src/main/java/com/omixlab/lckcontrol/p2p/webrtc/LanSignalingServer.kt create mode 100644 app/src/main/java/com/omixlab/lckcontrol/p2p/webrtc/RemoteSignalingClient.kt create mode 100644 app/src/main/java/com/omixlab/lckcontrol/p2p/webrtc/WebRtcPeerManager.kt create mode 100644 app/src/main/java/com/omixlab/lckcontrol/service/BootReceiver.kt create mode 100644 app/src/main/res/raw/lck_lan.p12 diff --git a/app/build.gradle.kts b/app/build.gradle.kts index d81b055..03d9229 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -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) diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index c91af52..106ab52 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -6,6 +6,9 @@ + + + @@ -82,6 +85,14 @@ + + + + + + diff --git a/app/src/main/cpp/jni_bridge.cpp b/app/src/main/cpp/jni_bridge.cpp index 5b3f8be..f32ffb4 100644 --- a/app/src/main/cpp/jni_bridge.cpp +++ b/app/src/main/cpp/jni_bridge.cpp @@ -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(&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(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, diff --git a/app/src/main/cpp/streaming_engine.cpp b/app/src/main/cpp/streaming_engine.cpp index 180b10b..027443d 100644 --- a/app/src/main/cpp/streaming_engine.cpp +++ b/app/src/main/cpp/streaming_engine.cpp @@ -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 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); +} diff --git a/app/src/main/cpp/streaming_engine.h b/app/src/main/cpp/streaming_engine.h index 6a3cb85..24e05c2 100644 --- a/app/src/main/cpp/streaming_engine.h +++ b/app/src/main/cpp/streaming_engine.h @@ -50,6 +50,7 @@ public: using StatsCallback = std::function; using ErrorCallback = std::function; using BufferReleasedCallback = std::function; + using EncodedFrameCallback = std::function; 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(); diff --git a/app/src/main/java/com/omixlab/lckcontrol/LckControlApp.kt b/app/src/main/java/com/omixlab/lckcontrol/LckControlApp.kt index ae9327f..f5b9f4d 100644 --- a/app/src/main/java/com/omixlab/lckcontrol/LckControlApp.kt +++ b/app/src/main/java/com/omixlab/lckcontrol/LckControlApp.kt @@ -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)) + } +} diff --git a/app/src/main/java/com/omixlab/lckcontrol/data/remote/LckApiService.kt b/app/src/main/java/com/omixlab/lckcontrol/data/remote/LckApiService.kt index 182caa6..54951bb 100644 --- a/app/src/main/java/com/omixlab/lckcontrol/data/remote/LckApiService.kt +++ b/app/src/main/java/com/omixlab/lckcontrol/data/remote/LckApiService.kt @@ -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, + ) } diff --git a/app/src/main/java/com/omixlab/lckcontrol/p2p/PeerSessionManager.kt b/app/src/main/java/com/omixlab/lckcontrol/p2p/PeerSessionManager.kt new file mode 100644 index 0000000..66ee449 --- /dev/null +++ b/app/src/main/java/com/omixlab/lckcontrol/p2p/PeerSessionManager.kt @@ -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() + 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() + } +} diff --git a/app/src/main/java/com/omixlab/lckcontrol/p2p/channels/ControlChannelHandler.kt b/app/src/main/java/com/omixlab/lckcontrol/p2p/channels/ControlChannelHandler.kt new file mode 100644 index 0000000..584c3c1 --- /dev/null +++ b/app/src/main/java/com/omixlab/lckcontrol/p2p/channels/ControlChannelHandler.kt @@ -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>() + + 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) { + 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) { + 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) + } +} diff --git a/app/src/main/java/com/omixlab/lckcontrol/p2p/channels/ControlProtocol.kt b/app/src/main/java/com/omixlab/lckcontrol/p2p/channels/ControlProtocol.kt new file mode 100644 index 0000000..47633c1 --- /dev/null +++ b/app/src/main/java/com/omixlab/lckcontrol/p2p/channels/ControlProtocol.kt @@ -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? = 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 +} diff --git a/app/src/main/java/com/omixlab/lckcontrol/p2p/channels/FileChannelHandler.kt b/app/src/main/java/com/omixlab/lckcontrol/p2p/channels/FileChannelHandler.kt new file mode 100644 index 0000000..a20d55f --- /dev/null +++ b/app/src/main/java/com/omixlab/lckcontrol/p2p/channels/FileChannelHandler.kt @@ -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>() + + 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>) { + 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) + } + } +} diff --git a/app/src/main/java/com/omixlab/lckcontrol/p2p/discovery/NsdAdvertiser.kt b/app/src/main/java/com/omixlab/lckcontrol/p2p/discovery/NsdAdvertiser.kt new file mode 100644 index 0000000..41bfe8a --- /dev/null +++ b/app/src/main/java/com/omixlab/lckcontrol/p2p/discovery/NsdAdvertiser.kt @@ -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 + } +} diff --git a/app/src/main/java/com/omixlab/lckcontrol/p2p/discovery/P2pPreferences.kt b/app/src/main/java/com/omixlab/lckcontrol/p2p/discovery/P2pPreferences.kt new file mode 100644 index 0000000..5782b07 --- /dev/null +++ b/app/src/main/java/com/omixlab/lckcontrol/p2p/discovery/P2pPreferences.kt @@ -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" + } +} diff --git a/app/src/main/java/com/omixlab/lckcontrol/p2p/webrtc/EncodedVideoSource.kt b/app/src/main/java/com/omixlab/lckcontrol/p2p/webrtc/EncodedVideoSource.kt new file mode 100644 index 0000000..ca2d55d --- /dev/null +++ b/app/src/main/java/com/omixlab/lckcontrol/p2p/webrtc/EncodedVideoSource.kt @@ -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 + } +} diff --git a/app/src/main/java/com/omixlab/lckcontrol/p2p/webrtc/LanSignalingServer.kt b/app/src/main/java/com/omixlab/lckcontrol/p2p/webrtc/LanSignalingServer.kt new file mode 100644 index 0000000..c5792ff --- /dev/null +++ b/app/src/main/java/com/omixlab/lckcontrol/p2p/webrtc/LanSignalingServer.kt @@ -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, + val nonce: String, +) + +@JsonClass(generateAdapter = true) +data class OfferResponseBody( + val sdp: String, + val type: String, + val iceCandidates: List, +) + +@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() // nonce -> expiry timestamp + + var onOfferReceived: ((String, List, 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() + 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 { + 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 { + 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 { + 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"}""" + } + } +} diff --git a/app/src/main/java/com/omixlab/lckcontrol/p2p/webrtc/RemoteSignalingClient.kt b/app/src/main/java/com/omixlab/lckcontrol/p2p/webrtc/RemoteSignalingClient.kt new file mode 100644 index 0000000..5bfb2f6 --- /dev/null +++ b/app/src/main/java/com/omixlab/lckcontrol/p2p/webrtc/RemoteSignalingClient.kt @@ -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) + } +} diff --git a/app/src/main/java/com/omixlab/lckcontrol/p2p/webrtc/WebRtcPeerManager.kt b/app/src/main/java/com/omixlab/lckcontrol/p2p/webrtc/WebRtcPeerManager.kt new file mode 100644 index 0000000..22003f4 --- /dev/null +++ b/app/src/main/java/com/omixlab/lckcontrol/p2p/webrtc/WebRtcPeerManager.kt @@ -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, + ): OfferResponseBody? { + val config = PeerConnection.RTCConfiguration(emptyList()).apply { + sdpSemantics = PeerConnection.SdpSemantics.UNIFIED_PLAN + } + + var answerSdp: SessionDescription? = null + val localCandidates = mutableListOf() + + 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?) {} + 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?) {} + }) + + // 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) + } + } + } +} diff --git a/app/src/main/java/com/omixlab/lckcontrol/service/BootReceiver.kt b/app/src/main/java/com/omixlab/lckcontrol/service/BootReceiver.kt new file mode 100644 index 0000000..df95200 --- /dev/null +++ b/app/src/main/java/com/omixlab/lckcontrol/service/BootReceiver.kt @@ -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)) + } + } +} diff --git a/app/src/main/java/com/omixlab/lckcontrol/service/LckControlService.kt b/app/src/main/java/com/omixlab/lckcontrol/service/LckControlService.kt index 7be8b8c..93be70a 100644 --- a/app/src/main/java/com/omixlab/lckcontrol/service/LckControlService.kt +++ b/app/src/main/java/com/omixlab/lckcontrol/service/LckControlService.kt @@ -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() diff --git a/app/src/main/java/com/omixlab/lckcontrol/streaming/NativeStreamingEngine.kt b/app/src/main/java/com/omixlab/lckcontrol/streaming/NativeStreamingEngine.kt index a4495e6..1af7911 100644 --- a/app/src/main/java/com/omixlab/lckcontrol/streaming/NativeStreamingEngine.kt +++ b/app/src/main/java/com/omixlab/lckcontrol/streaming/NativeStreamingEngine.kt @@ -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, diff --git a/app/src/main/res/raw/lck_lan.p12 b/app/src/main/res/raw/lck_lan.p12 new file mode 100644 index 0000000000000000000000000000000000000000..074daff867f923b37cba100fbf62f9eff1489c32 GIT binary patch literal 1094 zcmXqLVsT<(WHxAGe#6G8)#lOmotKfFaX}OFU6v;1n?T{K22G4QC{m2FEKQ7(Kw(iJ z7G&dw>f+&IWLnU;*PwBiK^j~=E3ZMMfdzugW1!6la;|hmW?x^&4V$OnT1h{MIh+Ufn}k0?k_Li8vor)A^aCh zz?pRk>vyCc`J1q7!T}{2ZXf5+3k)Ch)g)eUb0?+=IGxnvni8>4xzkW=@w%|-pKe;5 zFVQ&e@#W4YUvIm3odcz(f{!0F4^Dmi<*%`*yyLg&zYU#0Rzf|*DPkxf$H$Pvkj#+H zpbMlE8S)Gi5z?ZDA}m57nYpP7hUVr*W=6(FCI+UKMg~nx3*m~`*%mZ0O#%w_F)=a# zsdj`6BcWhoy(hZoFiWQUWVt-ywUVJzRg~teB_^1xPv4g++J3@uozkJ*8jH+T8k(2{ z6crvGX4>4}#AY08FWnlGq4~r_qCeCsr1fD>LHxC|_cuIl4{2D%*m-e<=AlO&6F;)7 zI*@qpB$s5e@$I72#}e{JvE0rTn#+7xwzIn)*`yyNmGJPK)zKv@YvZ+;9FBE;gB|W<+Q`DJR_b--1^tE+NiD~}71v~fJ#lGpj z{)K;KzsQR>75c84)0td$M!#12drRQH${GEg!B5j=Uc5O~dMfpo0r%-*x0~@ws~U{m z&DY+nliR;cM&9IwrE|maYr&JtcpSO*{{Q%MUP{x#jWbUDZY^m_W`FZFD6Wd_Kt^C` za);|&@hvImYxv%l8!Y&L-+f(7j)t(|;Q)E*#(%eEd3229&$#n5w@l_-`ah!1er3nc ziOW>;F8GHP%`Rd5KIw5`a=|k%>4ztNzUIu6@_6uYdV3bH;oIZFQ#Cv$NuCnu*}n92 zZcpq4aiNCyOZMG-ckC&n2S>edK;4XrTSa+c7uPsPCkl!znbUQvs`y8W)VJ zx*2?#PO^8eT4yTOKIv|umB>Er)$6S)aclAV-mgxnW;ru7UIP=qc7Z)>6O21on z@rUQKze#fhjMgdJJURVfp>ylsN!{KNOnQz}Ztj2ZN5a9?_$TA3FuIcJ(MkKxxh(e59?B^Tcs_!<}*@WOK!6C*1Fi$bJy z1goh{$6Cp>Lm3q{TIbl!{syb~D`s@F8@Rn}Ts@6NWKWCahLwsFColWj`QzfkTF%Gn JzuG`a5&+$9u5JJT literal 0 HcmV?d00001