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)
|
// Browser (Custom Tabs for OAuth flows)
|
||||||
implementation(libs.androidx.browser)
|
implementation(libs.androidx.browser)
|
||||||
|
|
||||||
|
// WebRTC (P2P communication with phone app)
|
||||||
|
implementation("io.github.webrtc-sdk:android:137.7151.05")
|
||||||
|
|
||||||
// Test
|
// Test
|
||||||
testImplementation(libs.junit)
|
testImplementation(libs.junit)
|
||||||
androidTestImplementation(libs.androidx.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" />
|
||||||
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_CONNECTED_DEVICE" />
|
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_CONNECTED_DEVICE" />
|
||||||
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
|
<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 -->
|
<!-- Allow querying game packages for icon/label resolution -->
|
||||||
<queries>
|
<queries>
|
||||||
@@ -82,6 +85,14 @@
|
|||||||
</intent-filter>
|
</intent-filter>
|
||||||
</service>
|
</service>
|
||||||
|
|
||||||
|
<receiver
|
||||||
|
android:name=".service.BootReceiver"
|
||||||
|
android:exported="true">
|
||||||
|
<intent-filter>
|
||||||
|
<action android:name="android.intent.action.BOOT_COMPLETED" />
|
||||||
|
</intent-filter>
|
||||||
|
</receiver>
|
||||||
|
|
||||||
</application>
|
</application>
|
||||||
|
|
||||||
</manifest>
|
</manifest>
|
||||||
|
|||||||
@@ -17,6 +17,7 @@ static jmethodID gOnErrorMethod = nullptr;
|
|||||||
static jmethodID gOnBufferReleasedMethod = nullptr;
|
static jmethodID gOnBufferReleasedMethod = nullptr;
|
||||||
static jmethodID gOnClipReadyMethod = nullptr;
|
static jmethodID gOnClipReadyMethod = nullptr;
|
||||||
static jmethodID gOnCortexSegmentMethod = nullptr;
|
static jmethodID gOnCortexSegmentMethod = nullptr;
|
||||||
|
static jmethodID gOnEncodedFrameMethod = nullptr;
|
||||||
|
|
||||||
JNIEXPORT jint JNI_OnLoad(JavaVM* vm, void* reserved) {
|
JNIEXPORT jint JNI_OnLoad(JavaVM* vm, void* reserved) {
|
||||||
gJavaVM = vm;
|
gJavaVM = vm;
|
||||||
@@ -47,6 +48,24 @@ Java_com_omixlab_lckcontrol_streaming_NativeStreamingEngine_nativeCreate(
|
|||||||
gOnBufferReleasedMethod = env->GetMethodID(cls, "onNativeBufferReleased", "(I)V");
|
gOnBufferReleasedMethod = env->GetMethodID(cls, "onNativeBufferReleased", "(I)V");
|
||||||
gOnClipReadyMethod = env->GetMethodID(cls, "onNativeClipReady", "(Ljava/lang/String;)V");
|
gOnClipReadyMethod = env->GetMethodID(cls, "onNativeClipReady", "(Ljava/lang/String;)V");
|
||||||
gOnCortexSegmentMethod = env->GetMethodID(cls, "onNativeCortexSegment", "(Ljava/lang/String;[B)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,
|
engine->SetCortexSegmentCallback([globalRef](const std::string& segPath,
|
||||||
const uint8_t* keyframeData,
|
const uint8_t* keyframeData,
|
||||||
|
|||||||
@@ -628,6 +628,12 @@ void StreamingEngine::DrainVideoEncoder() {
|
|||||||
info.presentationTimeUs, isKeyframe);
|
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);
|
std::lock_guard<std::mutex> lock(statsMutex);
|
||||||
statsVideoBytes += info.size;
|
statsVideoBytes += info.size;
|
||||||
statsFrameCount++;
|
statsFrameCount++;
|
||||||
@@ -986,3 +992,7 @@ void StreamingEngine::DisableCortexRecording() {
|
|||||||
void StreamingEngine::SetCortexSegmentCallback(CortexRecorder::SegmentCallback cb) {
|
void StreamingEngine::SetCortexSegmentCallback(CortexRecorder::SegmentCallback cb) {
|
||||||
cortexRecorder.SetSegmentCallback(std::move(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 StatsCallback = std::function<void(const StreamingStats&)>;
|
||||||
using ErrorCallback = std::function<void(int code, const std::string& message)>;
|
using ErrorCallback = std::function<void(int code, const std::string& message)>;
|
||||||
using BufferReleasedCallback = std::function<void(int bufferIndex)>;
|
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();
|
||||||
~StreamingEngine();
|
~StreamingEngine();
|
||||||
@@ -106,6 +107,9 @@ public:
|
|||||||
void DisableCortexRecording();
|
void DisableCortexRecording();
|
||||||
void SetCortexSegmentCallback(CortexRecorder::SegmentCallback cb);
|
void SetCortexSegmentCallback(CortexRecorder::SegmentCallback cb);
|
||||||
|
|
||||||
|
/** Set callback to receive encoded H.264 NAL units (for WebRTC passthrough). */
|
||||||
|
void SetEncodedFrameCallback(EncodedFrameCallback callback);
|
||||||
|
|
||||||
private:
|
private:
|
||||||
// Encoder thread
|
// Encoder thread
|
||||||
void EncoderThreadFunc();
|
void EncoderThreadFunc();
|
||||||
@@ -227,6 +231,7 @@ private:
|
|||||||
StatsCallback statsCallback;
|
StatsCallback statsCallback;
|
||||||
ErrorCallback errorCallback;
|
ErrorCallback errorCallback;
|
||||||
BufferReleasedCallback bufferReleasedCallback;
|
BufferReleasedCallback bufferReleasedCallback;
|
||||||
|
EncodedFrameCallback encodedFrameCallback;
|
||||||
|
|
||||||
bool InitVideoEncoder();
|
bool InitVideoEncoder();
|
||||||
bool InitAudioEncoder();
|
bool InitAudioEncoder();
|
||||||
|
|||||||
@@ -1,7 +1,14 @@
|
|||||||
package com.omixlab.lckcontrol
|
package com.omixlab.lckcontrol
|
||||||
|
|
||||||
import android.app.Application
|
import android.app.Application
|
||||||
|
import android.content.Intent
|
||||||
|
import com.omixlab.lckcontrol.service.LckControlService
|
||||||
import dagger.hilt.android.HiltAndroidApp
|
import dagger.hilt.android.HiltAndroidApp
|
||||||
|
|
||||||
@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
|
package com.omixlab.lckcontrol.data.remote
|
||||||
|
|
||||||
|
import okhttp3.MultipartBody
|
||||||
import retrofit2.http.*
|
import retrofit2.http.*
|
||||||
|
|
||||||
interface LckApiService {
|
interface LckApiService {
|
||||||
@@ -80,4 +81,13 @@ interface LckApiService {
|
|||||||
|
|
||||||
@POST("streams/plans/{id}/end")
|
@POST("streams/plans/{id}/end")
|
||||||
suspend fun endStreamPlan(@Path("id") id: String): StatusResponse
|
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.AccountRepository
|
||||||
import com.omixlab.lckcontrol.data.repository.ChatRepository
|
import com.omixlab.lckcontrol.data.repository.ChatRepository
|
||||||
import com.omixlab.lckcontrol.data.repository.StreamPlanRepository
|
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.ConnectedClientInfo
|
||||||
import com.omixlab.lckcontrol.shared.ILckControlCallback
|
import com.omixlab.lckcontrol.shared.ILckControlCallback
|
||||||
import com.omixlab.lckcontrol.shared.ILckControlService
|
import com.omixlab.lckcontrol.shared.ILckControlService
|
||||||
@@ -65,6 +67,8 @@ class LckControlService : Service() {
|
|||||||
@Inject lateinit var streamingManager: StreamingManager
|
@Inject lateinit var streamingManager: StreamingManager
|
||||||
@Inject lateinit var chatRepository: ChatRepository
|
@Inject lateinit var chatRepository: ChatRepository
|
||||||
@Inject lateinit var chatNotificationManager: ChatNotificationManager
|
@Inject lateinit var chatNotificationManager: ChatNotificationManager
|
||||||
|
@Inject lateinit var peerSessionManager: PeerSessionManager
|
||||||
|
@Inject lateinit var p2pPreferences: P2pPreferences
|
||||||
|
|
||||||
private val serviceScope = CoroutineScope(SupervisorJob() + Dispatchers.Main)
|
private val serviceScope = CoroutineScope(SupervisorJob() + Dispatchers.Main)
|
||||||
private val clientTracker = ClientTracker()
|
private val clientTracker = ClientTracker()
|
||||||
@@ -304,6 +308,23 @@ class LckControlService : Service() {
|
|||||||
chatNotificationManager.init()
|
chatNotificationManager.init()
|
||||||
chatRepository.connect()
|
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
|
// Auto-subscribe/unsubscribe chat when plans go LIVE/ENDED
|
||||||
serviceScope.launch {
|
serviceScope.launch {
|
||||||
streamPlanRepository.observePlans().collect { plans ->
|
streamPlanRepository.observePlans().collect { plans ->
|
||||||
@@ -338,6 +359,7 @@ class LckControlService : Service() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
override fun onDestroy() {
|
override fun onDestroy() {
|
||||||
|
peerSessionManager.release()
|
||||||
chatRepository.disconnect()
|
chatRepository.disconnect()
|
||||||
streamingManager.stopStreaming()
|
streamingManager.stopStreaming()
|
||||||
streamingServiceImpl?.kill()
|
streamingServiceImpl?.kill()
|
||||||
|
|||||||
@@ -25,6 +25,7 @@ class NativeStreamingEngine {
|
|||||||
var onBufferReleased: ((Int) -> Unit)? = null
|
var onBufferReleased: ((Int) -> Unit)? = null
|
||||||
var onClipReady: ((String) -> Unit)? = null
|
var onClipReady: ((String) -> Unit)? = null
|
||||||
var onCortexSegment: ((segPath: String, keyframeData: ByteArray) -> Unit)? = null
|
var onCortexSegment: ((segPath: String, keyframeData: ByteArray) -> Unit)? = null
|
||||||
|
var onEncodedVideoFrame: ((data: ByteArray, isKeyFrame: Boolean, timestampUs: Long) -> Unit)? = null
|
||||||
|
|
||||||
fun create(
|
fun create(
|
||||||
width: Int,
|
width: Int,
|
||||||
@@ -182,6 +183,11 @@ class NativeStreamingEngine {
|
|||||||
onCortexSegment?.invoke(segPath, keyframeData)
|
onCortexSegment?.invoke(segPath, keyframeData)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Suppress("unused")
|
||||||
|
private fun onNativeEncodedFrame(data: ByteArray, isKeyFrame: Boolean, timestampUs: Long) {
|
||||||
|
onEncodedVideoFrame?.invoke(data, isKeyFrame, timestampUs)
|
||||||
|
}
|
||||||
|
|
||||||
// Native methods
|
// Native methods
|
||||||
private external fun nativeCreate(
|
private external fun nativeCreate(
|
||||||
width: Int, height: Int,
|
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