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:
@@ -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)
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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"}"""
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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))
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
|
||||
@@ -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,
|
||||
|
||||
BIN
app/src/main/res/raw/lck_lan.p12
Normal file
BIN
app/src/main/res/raw/lck_lan.p12
Normal file
Binary file not shown.
Reference in New Issue
Block a user