diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 8114035..d81b055 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -60,9 +60,20 @@ android { buildConfigField("String", "DISPLAY_VERSION", "\"${gitDisplayVersion()}\"") + ndk { + abiFilters += listOf("arm64-v8a") + } + testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" } + externalNativeBuild { + cmake { + path = file("src/main/cpp/CMakeLists.txt") + version = "3.22.1" + } + } + buildTypes { debug { signingConfig = signingConfigs.getByName("release") diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index b31b298..2d56342 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -69,6 +69,9 @@ + + + diff --git a/app/src/main/cpp/CMakeLists.txt b/app/src/main/cpp/CMakeLists.txt new file mode 100644 index 0000000..3e4c2f6 --- /dev/null +++ b/app/src/main/cpp/CMakeLists.txt @@ -0,0 +1,49 @@ +cmake_minimum_required(VERSION 3.22.1) +project(lck_streaming) + +find_library(log-lib log) +find_library(android-lib android) +find_library(mediandk-lib mediandk) +find_library(egl-lib EGL) +find_library(glesv3-lib GLESv3) +find_library(nativewindow-lib nativewindow) + +add_library(lck_streaming SHARED + jni_bridge.cpp + rtmp_client.cpp + rtmp_sink.cpp + egl_context.cpp + streaming_engine.cpp +) + +target_include_directories(lck_streaming PRIVATE + ${CMAKE_SOURCE_DIR}/third_party/librtmp/include +) + +# Import pre-built librtmp from jniLibs +add_library(rtmp SHARED IMPORTED) +set_target_properties(rtmp PROPERTIES + IMPORTED_LOCATION ${CMAKE_SOURCE_DIR}/../jniLibs/arm64-v8a/librtmp.so +) + +add_library(ssl SHARED IMPORTED) +set_target_properties(ssl PROPERTIES + IMPORTED_LOCATION ${CMAKE_SOURCE_DIR}/../jniLibs/arm64-v8a/libssl.so +) + +add_library(crypto SHARED IMPORTED) +set_target_properties(crypto PROPERTIES + IMPORTED_LOCATION ${CMAKE_SOURCE_DIR}/../jniLibs/arm64-v8a/libcrypto.so +) + +target_link_libraries(lck_streaming + ${log-lib} + ${android-lib} + ${mediandk-lib} + ${egl-lib} + ${glesv3-lib} + ${nativewindow-lib} + rtmp + ssl + crypto +) diff --git a/app/src/main/cpp/egl_context.cpp b/app/src/main/cpp/egl_context.cpp new file mode 100644 index 0000000..013b4eb --- /dev/null +++ b/app/src/main/cpp/egl_context.cpp @@ -0,0 +1,219 @@ +#include "egl_context.h" + +#include +#include + +#define TAG "LckEglContext" +#define LOGI(...) __android_log_print(ANDROID_LOG_INFO, TAG, __VA_ARGS__) +#define LOGW(...) __android_log_print(ANDROID_LOG_WARN, TAG, __VA_ARGS__) +#define LOGE(...) __android_log_print(ANDROID_LOG_ERROR, TAG, __VA_ARGS__) + +#ifndef EGL_NATIVE_BUFFER_ANDROID +#define EGL_NATIVE_BUFFER_ANDROID 0x3140 +#endif + +#ifndef EGL_SYNC_NATIVE_FENCE_ANDROID +#define EGL_SYNC_NATIVE_FENCE_ANDROID 0x3144 +#endif + +#ifndef EGL_SYNC_NATIVE_FENCE_FD_ANDROID +#define EGL_SYNC_NATIVE_FENCE_FD_ANDROID 0x3145 +#endif + +#ifndef EGL_RECORDABLE_ANDROID +#define EGL_RECORDABLE_ANDROID 0x3142 +#endif + +EglContext::EglContext() {} + +EglContext::~EglContext() { + Release(); +} + +bool EglContext::LoadExtensions() { + eglCreateSyncKHR = (PFNEGLCREATESYNCKHRPROC)eglGetProcAddress("eglCreateSyncKHR"); + eglWaitSyncKHR = (PFNEGLWAITSYNCKHRPROC)eglGetProcAddress("eglWaitSyncKHR"); + eglDestroySyncKHR = (PFNEGLDESTROYSYNCKHRPROC)eglGetProcAddress("eglDestroySyncKHR"); + eglGetNativeClientBufferANDROID = (PFNEGLGETNATIVECLIENTBUFFERANDROIDPROC)eglGetProcAddress("eglGetNativeClientBufferANDROID"); + eglCreateImageKHR = (PFNEGLCREATEIMAGEKHRPROC)eglGetProcAddress("eglCreateImageKHR"); + eglDestroyImageKHR = (PFNEGLDESTROYIMAGEKHRPROC)eglGetProcAddress("eglDestroyImageKHR"); + glEGLImageTargetTexture2DOES = (PFNGLEGLIMAGETARGETTEXTURE2DOESPROC)eglGetProcAddress("glEGLImageTargetTexture2DOES"); + eglPresentationTimeANDROID = (PFNEGLPRESENTATIONTIMEANDROIDPROC)eglGetProcAddress("eglPresentationTimeANDROID"); + + if (!eglGetNativeClientBufferANDROID || !eglCreateImageKHR || + !eglDestroyImageKHR || !glEGLImageTargetTexture2DOES) { + LOGE("Missing required EGL extensions for HardwareBuffer import"); + return false; + } + + return true; +} + +bool EglContext::Init() { + display = eglGetDisplay(EGL_DEFAULT_DISPLAY); + if (display == EGL_NO_DISPLAY) { + LOGE("eglGetDisplay failed"); + return false; + } + + EGLint major, minor; + if (!eglInitialize(display, &major, &minor)) { + LOGE("eglInitialize failed"); + return false; + } + LOGI("EGL initialized: %d.%d", major, minor); + + // EGL config: RGBA8, ES3, recordable for MediaCodec + EGLint configAttribs[] = { + EGL_RENDERABLE_TYPE, EGL_OPENGL_ES3_BIT, + EGL_RED_SIZE, 8, + EGL_GREEN_SIZE, 8, + EGL_BLUE_SIZE, 8, + EGL_ALPHA_SIZE, 8, + EGL_SURFACE_TYPE, EGL_WINDOW_BIT, + EGL_RECORDABLE_ANDROID, EGL_TRUE, + EGL_NONE + }; + + EGLint numConfigs; + if (!eglChooseConfig(display, configAttribs, &config, 1, &numConfigs) || numConfigs == 0) { + LOGE("eglChooseConfig failed"); + return false; + } + + EGLint contextAttribs[] = { + EGL_CONTEXT_CLIENT_VERSION, 3, + EGL_NONE + }; + + context = eglCreateContext(display, config, EGL_NO_CONTEXT, contextAttribs); + if (context == EGL_NO_CONTEXT) { + LOGE("eglCreateContext failed"); + return false; + } + + if (!LoadExtensions()) { + return false; + } + + LOGI("EGL context created successfully"); + return true; +} + +bool EglContext::CreateWindowSurface(ANativeWindow* window) { + if (surface != EGL_NO_SURFACE) { + eglDestroySurface(display, surface); + } + + surface = eglCreateWindowSurface(display, config, window, nullptr); + if (surface == EGL_NO_SURFACE) { + LOGE("eglCreateWindowSurface failed: 0x%x", eglGetError()); + return false; + } + + eglQuerySurface(display, surface, EGL_WIDTH, &surfaceWidth); + eglQuerySurface(display, surface, EGL_HEIGHT, &surfaceHeight); + LOGI("EGL window surface created: %dx%d", surfaceWidth, surfaceHeight); + return true; +} + +GLuint EglContext::ImportHardwareBuffer(AHardwareBuffer* buffer) { + if (!eglGetNativeClientBufferANDROID || !eglCreateImageKHR || !glEGLImageTargetTexture2DOES) { + LOGE("Missing EGL extensions for HardwareBuffer import"); + return 0; + } + + EGLClientBuffer clientBuffer = eglGetNativeClientBufferANDROID(buffer); + if (!clientBuffer) { + LOGE("eglGetNativeClientBufferANDROID failed"); + return 0; + } + + EGLint imageAttribs[] = { + EGL_IMAGE_PRESERVED_KHR, EGL_TRUE, + EGL_NONE + }; + + EGLImageKHR image = eglCreateImageKHR(display, EGL_NO_CONTEXT, + EGL_NATIVE_BUFFER_ANDROID, + clientBuffer, imageAttribs); + if (image == EGL_NO_IMAGE_KHR) { + LOGE("eglCreateImageKHR failed: 0x%x", eglGetError()); + return 0; + } + + GLuint textureId; + glGenTextures(1, &textureId); + glBindTexture(GL_TEXTURE_EXTERNAL_OES, textureId); + glEGLImageTargetTexture2DOES(GL_TEXTURE_EXTERNAL_OES, image); + + // We need to keep the image alive — store it associated with the texture + // The caller must call ReleaseImportedTexture to clean up + // For now, we destroy the image immediately since the texture retains the content + eglDestroyImageKHR(display, image); + + return textureId; +} + +void EglContext::ReleaseImportedTexture(GLuint textureId, EGLImageKHR image) { + if (textureId) { + glDeleteTextures(1, &textureId); + } + if (image != EGL_NO_IMAGE_KHR && eglDestroyImageKHR) { + eglDestroyImageKHR(display, image); + } +} + +void EglContext::WaitFence(int fenceFd) { + if (fenceFd < 0) return; + + if (eglCreateSyncKHR && eglWaitSyncKHR && eglDestroySyncKHR) { + EGLint attribs[] = { + EGL_SYNC_NATIVE_FENCE_FD_ANDROID, fenceFd, + EGL_NONE + }; + + EGLSyncKHR sync = eglCreateSyncKHR(display, EGL_SYNC_NATIVE_FENCE_ANDROID, attribs); + if (sync != EGL_NO_SYNC_KHR) { + // GPU-side wait — doesn't block CPU + eglWaitSyncKHR(display, sync, 0); + eglDestroySyncKHR(display, sync); + // eglCreateSyncKHR takes ownership of fenceFd + return; + } + } + + // Fallback: CPU-side wait + close(fenceFd); +} + +void EglContext::SetPresentationTime(int64_t timestampNs) { + if (eglPresentationTimeANDROID && surface != EGL_NO_SURFACE) { + eglPresentationTimeANDROID(display, surface, timestampNs); + } +} + +bool EglContext::MakeCurrent() { + return eglMakeCurrent(display, surface, surface, context) == EGL_TRUE; +} + +bool EglContext::SwapBuffers() { + return eglSwapBuffers(display, surface) == EGL_TRUE; +} + +void EglContext::Release() { + if (display != EGL_NO_DISPLAY) { + eglMakeCurrent(display, EGL_NO_SURFACE, EGL_NO_SURFACE, EGL_NO_CONTEXT); + if (surface != EGL_NO_SURFACE) { + eglDestroySurface(display, surface); + surface = EGL_NO_SURFACE; + } + if (context != EGL_NO_CONTEXT) { + eglDestroyContext(display, context); + context = EGL_NO_CONTEXT; + } + eglTerminate(display); + display = EGL_NO_DISPLAY; + } + LOGI("EGL resources released"); +} diff --git a/app/src/main/cpp/egl_context.h b/app/src/main/cpp/egl_context.h new file mode 100644 index 0000000..62dc664 --- /dev/null +++ b/app/src/main/cpp/egl_context.h @@ -0,0 +1,69 @@ +#pragma once + +#include +#include +#include +#include +#include + +/** + * EGL context for importing HardwareBuffers and blitting to encoder Surface. + * Handles EGL setup, HardwareBuffer→EGLImage→texture import, and fence sync. + */ +class EglContext { +public: + EglContext(); + ~EglContext(); + + /** Initialize EGL with a recordable config. Returns true on success. */ + bool Init(); + + /** Create a window surface from an ANativeWindow (encoder input surface). */ + bool CreateWindowSurface(ANativeWindow* window); + + /** Import a HardwareBuffer as a GL texture. Returns texture ID (0 on failure). */ + GLuint ImportHardwareBuffer(AHardwareBuffer* buffer); + + /** Release a previously imported HardwareBuffer texture. */ + void ReleaseImportedTexture(GLuint textureId, EGLImageKHR image); + + /** Wait on a native GPU fence FD. Takes ownership of the FD. */ + void WaitFence(int fenceFd); + + /** Set presentation time on the current surface. */ + void SetPresentationTime(int64_t timestampNs); + + /** Make the window surface current. */ + bool MakeCurrent(); + + /** Swap buffers on the window surface. */ + bool SwapBuffers(); + + /** Release all EGL resources. */ + void Release(); + + EGLDisplay GetDisplay() const { return display; } + int GetWidth() const { return surfaceWidth; } + int GetHeight() const { return surfaceHeight; } + +private: + EGLDisplay display = EGL_NO_DISPLAY; + EGLContext context = EGL_NO_CONTEXT; + EGLSurface surface = EGL_NO_SURFACE; + EGLConfig config = nullptr; + + int surfaceWidth = 0; + int surfaceHeight = 0; + + // Extension function pointers + PFNEGLCREATESYNCKHRPROC eglCreateSyncKHR = nullptr; + PFNEGLWAITSYNCKHRPROC eglWaitSyncKHR = nullptr; + PFNEGLDESTROYSYNCKHRPROC eglDestroySyncKHR = nullptr; + PFNEGLGETNATIVECLIENTBUFFERANDROIDPROC eglGetNativeClientBufferANDROID = nullptr; + PFNEGLCREATEIMAGEKHRPROC eglCreateImageKHR = nullptr; + PFNEGLDESTROYIMAGEKHRPROC eglDestroyImageKHR = nullptr; + PFNGLEGLIMAGETARGETTEXTURE2DOESPROC glEGLImageTargetTexture2DOES = nullptr; + PFNEGLPRESENTATIONTIMEANDROIDPROC eglPresentationTimeANDROID = nullptr; + + bool LoadExtensions(); +}; diff --git a/app/src/main/cpp/jni_bridge.cpp b/app/src/main/cpp/jni_bridge.cpp new file mode 100644 index 0000000..56de371 --- /dev/null +++ b/app/src/main/cpp/jni_bridge.cpp @@ -0,0 +1,162 @@ +#include "streaming_engine.h" + +#include +#include +#include + +#define TAG "LckJniBridge" +#define LOGI(...) __android_log_print(ANDROID_LOG_INFO, TAG, __VA_ARGS__) +#define LOGE(...) __android_log_print(ANDROID_LOG_ERROR, TAG, __VA_ARGS__) + +static JavaVM* gJavaVM = nullptr; + +// Cache for callback method IDs +static jmethodID gOnStatsMethod = nullptr; +static jmethodID gOnErrorMethod = nullptr; +static jmethodID gOnBufferReleasedMethod = nullptr; + +JNIEXPORT jint JNI_OnLoad(JavaVM* vm, void* reserved) { + gJavaVM = vm; + return JNI_VERSION_1_6; +} + +extern "C" { + +JNIEXPORT jlong JNICALL +Java_com_omixlab_lckcontrol_streaming_NativeStreamingEngine_nativeCreate( + JNIEnv* env, jobject thiz, + jint width, jint height, + jint videoBitrate, jint audioBitrate, + jint sampleRate, jint channels, + jint keyframeInterval) { + + auto* engine = new StreamingEngine(); + engine->Configure(width, height, videoBitrate, audioBitrate, + sampleRate, channels, keyframeInterval); + + // Set up callbacks that call back into Kotlin + jobject globalRef = env->NewGlobalRef(thiz); + + // Cache method IDs + jclass cls = env->GetObjectClass(thiz); + gOnStatsMethod = env->GetMethodID(cls, "onNativeStats", "(JJII)V"); + gOnErrorMethod = env->GetMethodID(cls, "onNativeError", "(ILjava/lang/String;)V"); + gOnBufferReleasedMethod = env->GetMethodID(cls, "onNativeBufferReleased", "(I)V"); + + engine->SetStatsCallback([globalRef](const StreamingStats& stats) { + JNIEnv* env; + if (gJavaVM->GetEnv(reinterpret_cast(&env), JNI_VERSION_1_6) != JNI_OK) { + if (gJavaVM->AttachCurrentThread(&env, nullptr) != JNI_OK) return; + } + if (gOnStatsMethod) { + env->CallVoidMethod(globalRef, gOnStatsMethod, + (jlong)stats.videoBitrate, (jlong)stats.audioBitrate, + (jint)stats.fps, (jint)stats.droppedFrames); + } + }); + + engine->SetErrorCallback([globalRef](int code, const std::string& message) { + JNIEnv* env; + if (gJavaVM->GetEnv(reinterpret_cast(&env), JNI_VERSION_1_6) != JNI_OK) { + if (gJavaVM->AttachCurrentThread(&env, nullptr) != JNI_OK) return; + } + if (gOnErrorMethod) { + jstring msg = env->NewStringUTF(message.c_str()); + env->CallVoidMethod(globalRef, gOnErrorMethod, (jint)code, msg); + env->DeleteLocalRef(msg); + } + }); + + engine->SetBufferReleasedCallback([globalRef](int bufferIndex) { + JNIEnv* env; + if (gJavaVM->GetEnv(reinterpret_cast(&env), JNI_VERSION_1_6) != JNI_OK) { + if (gJavaVM->AttachCurrentThread(&env, nullptr) != JNI_OK) return; + } + if (gOnBufferReleasedMethod) { + env->CallVoidMethod(globalRef, gOnBufferReleasedMethod, (jint)bufferIndex); + } + }); + + LOGI("Native engine created: %dx%d", width, height); + return reinterpret_cast(engine); +} + +JNIEXPORT jint JNICALL +Java_com_omixlab_lckcontrol_streaming_NativeStreamingEngine_nativeAddDestination( + JNIEnv* env, jobject thiz, jlong ptr, jstring rtmpUrl) { + auto* engine = reinterpret_cast(ptr); + if (!engine) return -1; + + const char* url = env->GetStringUTFChars(rtmpUrl, nullptr); + int index = engine->AddDestination(url); + env->ReleaseStringUTFChars(rtmpUrl, url); + return index; +} + +JNIEXPORT jboolean JNICALL +Java_com_omixlab_lckcontrol_streaming_NativeStreamingEngine_nativeStart( + JNIEnv* env, jobject thiz, jlong ptr) { + auto* engine = reinterpret_cast(ptr); + if (!engine) return JNI_FALSE; + return engine->Start() ? JNI_TRUE : JNI_FALSE; +} + +JNIEXPORT void JNICALL +Java_com_omixlab_lckcontrol_streaming_NativeStreamingEngine_nativeSubmitVideoFrame( + JNIEnv* env, jobject thiz, jlong ptr, + jobject hardwareBuffer, jlong timestampNs, jint fenceFd) { + auto* engine = reinterpret_cast(ptr); + if (!engine) return; + + AHardwareBuffer* buffer = AHardwareBuffer_fromHardwareBuffer(env, hardwareBuffer); + if (!buffer) { + LOGE("Failed to get AHardwareBuffer from Java HardwareBuffer"); + return; + } + + engine->SubmitVideoFrame(buffer, timestampNs, fenceFd); +} + +JNIEXPORT void JNICALL +Java_com_omixlab_lckcontrol_streaming_NativeStreamingEngine_nativeSubmitAudioFrame( + JNIEnv* env, jobject thiz, jlong ptr, + jbyteArray pcmData, jlong timestampNs) { + auto* engine = reinterpret_cast(ptr); + if (!engine) return; + + jsize len = env->GetArrayLength(pcmData); + jbyte* data = env->GetByteArrayElements(pcmData, nullptr); + + engine->SubmitAudioFrame(reinterpret_cast(data), len, timestampNs); + + env->ReleaseByteArrayElements(pcmData, data, JNI_ABORT); +} + +JNIEXPORT void JNICALL +Java_com_omixlab_lckcontrol_streaming_NativeStreamingEngine_nativeStop( + JNIEnv* env, jobject thiz, jlong ptr) { + auto* engine = reinterpret_cast(ptr); + if (!engine) return; + engine->Stop(); +} + +JNIEXPORT void JNICALL +Java_com_omixlab_lckcontrol_streaming_NativeStreamingEngine_nativeDestroy( + JNIEnv* env, jobject thiz, jlong ptr) { + auto* engine = reinterpret_cast(ptr); + if (engine) { + engine->Stop(); + delete engine; + LOGI("Native engine destroyed"); + } +} + +JNIEXPORT jboolean JNICALL +Java_com_omixlab_lckcontrol_streaming_NativeStreamingEngine_nativeIsRunning( + JNIEnv* env, jobject thiz, jlong ptr) { + auto* engine = reinterpret_cast(ptr); + if (!engine) return JNI_FALSE; + return engine->IsRunning() ? JNI_TRUE : JNI_FALSE; +} + +} // extern "C" diff --git a/app/src/main/cpp/rtmp_client.cpp b/app/src/main/cpp/rtmp_client.cpp new file mode 100644 index 0000000..89a5dbd --- /dev/null +++ b/app/src/main/cpp/rtmp_client.cpp @@ -0,0 +1,177 @@ +#include "rtmp_client.h" + +#include +#include + +extern "C" { +#include +#include +} + +#define TAG "LckRtmpClient" +#define LOGI(...) __android_log_print(ANDROID_LOG_INFO, TAG, __VA_ARGS__) +#define LOGW(...) __android_log_print(ANDROID_LOG_WARN, TAG, __VA_ARGS__) +#define LOGE(...) __android_log_print(ANDROID_LOG_ERROR, TAG, __VA_ARGS__) + +RtmpClient::RtmpClient() {} + +RtmpClient::~RtmpClient() { + Disconnect(); +} + +bool RtmpClient::Connect(const std::string& rtmpUrl) { + if (connected) { + LOGW("Already connected, disconnecting first"); + Disconnect(); + } + + RTMP_LogSetLevel(RTMP_LOGWARNING); + + rtmpContext = RTMP_Alloc(); + if (!rtmpContext) { + LOGE("RTMP_Alloc failed"); + return false; + } + + RTMP_Init(rtmpContext); + + // RTMP_SetupURL needs a mutable char* + std::vector urlBuffer(rtmpUrl.begin(), rtmpUrl.end()); + urlBuffer.push_back('\0'); + + if (!RTMP_SetupURL(rtmpContext, urlBuffer.data())) { + LOGE("RTMP_SetupURL failed"); + RTMP_Free(rtmpContext); + rtmpContext = nullptr; + return false; + } + + RTMP_EnableWrite(rtmpContext); + + if (!RTMP_Connect(rtmpContext, nullptr)) { + LOGE("RTMP_Connect failed"); + RTMP_Free(rtmpContext); + rtmpContext = nullptr; + return false; + } + + if (!RTMP_ConnectStream(rtmpContext, 0)) { + LOGE("RTMP_ConnectStream failed"); + RTMP_Close(rtmpContext); + RTMP_Free(rtmpContext); + rtmpContext = nullptr; + return false; + } + + connected = true; + LOGI("RTMP connected"); + return true; +} + +void RtmpClient::Disconnect() { + if (rtmpContext) { + RTMP_Close(rtmpContext); + RTMP_Free(rtmpContext); + rtmpContext = nullptr; + LOGI("RTMP disconnected"); + } + connected = false; +} + +bool RtmpClient::IsConnected() const { + return connected && rtmpContext && RTMP_IsConnected(rtmpContext); +} + +bool RtmpClient::SendRtmpPacket(uint8_t packetType, uint32_t timestampMs, const uint8_t* data, uint32_t size) { + if (!IsConnected()) + return false; + + RTMPPacket pkt; + RTMPPacket_Alloc(&pkt, size); + pkt.m_packetType = packetType; + pkt.m_nChannel = (packetType == RTMP_PACKET_TYPE_VIDEO) ? 0x06 : 0x07; + pkt.m_headerType = RTMP_PACKET_SIZE_LARGE; + pkt.m_nTimeStamp = timestampMs; + pkt.m_hasAbsTimestamp = 1; + pkt.m_nInfoField2 = rtmpContext->m_stream_id; + pkt.m_nBodySize = size; + memcpy(pkt.m_body, data, size); + + int ret = RTMP_SendPacket(rtmpContext, &pkt, 0); + RTMPPacket_Free(&pkt); + + if (!ret) { + LOGW("RTMP_SendPacket failed (type=%d, size=%u)", packetType, size); + connected = false; + } + + return ret != 0; +} + +bool RtmpClient::SendAvcSequenceHeader(const uint8_t* extraData, uint32_t extraDataSize) { + // FLV video tag: keyframe(1) + AVC(7) = 0x17, AVC sequence header = 0x00, composition time = 0 + uint32_t bodySize = 5 + extraDataSize; + std::vector body(bodySize); + body[0] = 0x17; // keyframe + AVC + body[1] = 0x00; // AVC sequence header + body[2] = 0x00; // composition time + body[3] = 0x00; + body[4] = 0x00; + memcpy(body.data() + 5, extraData, extraDataSize); + + return SendRtmpPacket(RTMP_PACKET_TYPE_VIDEO, 0, body.data(), bodySize); +} + +void RtmpClient::BuildAudioSpecificConfig(uint8_t outConfig[2], uint32_t sampleRate, uint32_t numChannels) { + static const uint32_t sampleRateTable[] = { + 96000, 88200, 64000, 48000, 44100, 32000, + 24000, 22050, 16000, 12000, 11025, 8000, 7350 + }; + + uint8_t freqIndex = 4; // default 44100 + for (uint8_t i = 0; i < 13; ++i) { + if (sampleRateTable[i] == sampleRate) { + freqIndex = i; + break; + } + } + + uint8_t channelConfig = static_cast(numChannels < 1 ? 1 : (numChannels > 7 ? 7 : numChannels)); + + // Pack: AAAAA FFFF CCCC 000 + outConfig[0] = (2 << 3) | (freqIndex >> 1); + outConfig[1] = ((freqIndex & 1) << 7) | (channelConfig << 3); +} + +bool RtmpClient::SendAacSequenceHeader(uint32_t sampleRate, uint32_t numChannels) { + // FLV audio tag: AAC(10) + 44100(3) + 16-bit(1) + stereo(1) = 0xAF, AAC sequence header = 0x00 + uint8_t body[4]; + body[0] = 0xAF; + body[1] = 0x00; + BuildAudioSpecificConfig(body + 2, sampleRate, numChannels); + + return SendRtmpPacket(RTMP_PACKET_TYPE_AUDIO, 0, body, sizeof(body)); +} + +bool RtmpClient::SendVideoPacket(const uint8_t* data, uint32_t size, uint32_t timestampMs, bool isKeyframe) { + uint32_t bodySize = 5 + size; + std::vector body(bodySize); + body[0] = isKeyframe ? 0x17 : 0x27; + body[1] = 0x01; // AVC NALU + body[2] = 0x00; // composition time offset + body[3] = 0x00; + body[4] = 0x00; + memcpy(body.data() + 5, data, size); + + return SendRtmpPacket(RTMP_PACKET_TYPE_VIDEO, timestampMs, body.data(), bodySize); +} + +bool RtmpClient::SendAudioPacket(const uint8_t* data, uint32_t size, uint32_t timestampMs) { + uint32_t bodySize = 2 + size; + std::vector body(bodySize); + body[0] = 0xAF; + body[1] = 0x01; // AAC raw + memcpy(body.data() + 2, data, size); + + return SendRtmpPacket(RTMP_PACKET_TYPE_AUDIO, timestampMs, body.data(), bodySize); +} diff --git a/app/src/main/cpp/rtmp_client.h b/app/src/main/cpp/rtmp_client.h new file mode 100644 index 0000000..615e1bc --- /dev/null +++ b/app/src/main/cpp/rtmp_client.h @@ -0,0 +1,34 @@ +#pragma once + +#include +#include +#include + +struct RTMP; + +/** + * Low-level librtmp wrapper for RTMP streaming. + * Ported from FLCKRtmpClient (UE5 LCKStreaming plugin). + * All methods should be called from the same thread (encoder thread). + */ +class RtmpClient { +public: + RtmpClient(); + ~RtmpClient(); + + bool Connect(const std::string& rtmpUrl); + void Disconnect(); + bool IsConnected() const; + + bool SendAvcSequenceHeader(const uint8_t* extraData, uint32_t extraDataSize); + bool SendAacSequenceHeader(uint32_t sampleRate, uint32_t numChannels); + bool SendVideoPacket(const uint8_t* data, uint32_t size, uint32_t timestampMs, bool isKeyframe); + bool SendAudioPacket(const uint8_t* data, uint32_t size, uint32_t timestampMs); + +private: + bool SendRtmpPacket(uint8_t packetType, uint32_t timestampMs, const uint8_t* data, uint32_t size); + static void BuildAudioSpecificConfig(uint8_t outConfig[2], uint32_t sampleRate, uint32_t numChannels); + + RTMP* rtmpContext = nullptr; + bool connected = false; +}; diff --git a/app/src/main/cpp/rtmp_sink.cpp b/app/src/main/cpp/rtmp_sink.cpp new file mode 100644 index 0000000..e07748f --- /dev/null +++ b/app/src/main/cpp/rtmp_sink.cpp @@ -0,0 +1,278 @@ +#include "rtmp_sink.h" + +#include +#include +#include + +#define TAG "LckRtmpSink" +#define LOGI(...) __android_log_print(ANDROID_LOG_INFO, TAG, __VA_ARGS__) +#define LOGW(...) __android_log_print(ANDROID_LOG_WARN, TAG, __VA_ARGS__) +#define LOGE(...) __android_log_print(ANDROID_LOG_ERROR, TAG, __VA_ARGS__) + +RtmpSink::RtmpSink() {} + +RtmpSink::~RtmpSink() { + if (isOpen) { + Close(); + } +} + +void RtmpSink::SetRtmpUrl(const std::string& url) { + rtmpUrl = url; +} + +bool RtmpSink::Open(uint32_t width, uint32_t height, uint32_t framerate, + uint32_t sampleRate, uint32_t numChannels) { + if (rtmpUrl.empty()) { + LOGE("RTMP URL not set"); + return false; + } + + storedSampleRate = sampleRate; + storedNumChannels = numChannels; + + if (!rtmpClient.Connect(rtmpUrl)) { + LOGE("Failed to connect RTMP"); + return false; + } + + isOpen = true; + videoHeaderSent = false; + audioHeaderSent = false; + LOGI("RTMP sink opened: %dx%d@%dfps, %dHz %dch", + width, height, framerate, sampleRate, numChannels); + return true; +} + +void RtmpSink::OnVideoFormatReady(const uint8_t* extraData, uint32_t extraDataSize) { + if (!isOpen) return; + + if (extraData && extraDataSize > 0) { + // Check if already AVCC format (starts with version byte 0x01) + if (extraDataSize > 4 && extraData[0] == 0x01) { + if (rtmpClient.SendAvcSequenceHeader(extraData, extraDataSize)) { + videoHeaderSent = true; + LOGI("Sent AVC sequence header (AVCC, %u bytes)", extraDataSize); + } + } else { + // Annex-B format - extract and convert + TryExtractAndSendSequenceHeader(extraData, extraDataSize); + } + } +} + +void RtmpSink::OnAudioFormatReady(uint32_t sampleRate, uint32_t numChannels) { + if (!isOpen) return; + + storedSampleRate = sampleRate; + storedNumChannels = numChannels; + + if (rtmpClient.SendAacSequenceHeader(sampleRate, numChannels)) { + audioHeaderSent = true; + LOGI("Sent AAC sequence header (%dHz, %dch)", sampleRate, numChannels); + } +} + +void RtmpSink::SendVideoPacket(const uint8_t* data, uint32_t size, + int64_t timestampMs, bool isKeyframe) { + if (!isOpen || !rtmpClient.IsConnected()) return; + + // If we haven't sent the video sequence header yet and this is a keyframe, + // try to extract SPS/PPS from it + if (!videoHeaderSent && isKeyframe) { + TryExtractAndSendSequenceHeader(data, size); + } + + if (!videoHeaderSent) return; + + // Send AAC sequence header on first video packet if not sent yet + if (!audioHeaderSent) { + if (rtmpClient.SendAacSequenceHeader(storedSampleRate, storedNumChannels)) { + audioHeaderSent = true; + LOGI("Sent AAC sequence header (deferred, %dHz, %dch)", + storedSampleRate, storedNumChannels); + } + } + + uint32_t ts = static_cast(std::max(timestampMs, 0)); + + // Convert Annex-B to AVCC for RTMP/FLV + std::vector avccData = ConvertAnnexBToAvcc(data, size); + if (!avccData.empty()) { + rtmpClient.SendVideoPacket(avccData.data(), static_cast(avccData.size()), + ts, isKeyframe); + } +} + +void RtmpSink::SendAudioPacket(const uint8_t* data, uint32_t size, int64_t timestampMs) { + if (!isOpen || !rtmpClient.IsConnected()) return; + if (!audioHeaderSent || !videoHeaderSent) return; + + uint32_t ts = static_cast(std::max(timestampMs, 0)); + rtmpClient.SendAudioPacket(data, size, ts); +} + +void RtmpSink::Close() { + if (isOpen) { + rtmpClient.Disconnect(); + isOpen = false; + videoHeaderSent = false; + audioHeaderSent = false; + LOGI("RTMP sink closed"); + } +} + +bool RtmpSink::IsOpen() const { + return isOpen; +} + +bool RtmpSink::TryExtractAndSendSequenceHeader(const uint8_t* data, uint32_t size) { + // Parse Annex-B bitstream to find SPS and PPS NALUs + const uint8_t* sps = nullptr; + uint32_t spsSize = 0; + const uint8_t* pps = nullptr; + uint32_t ppsSize = 0; + + const uint8_t* end = data + size; + + auto findStartCode = [](const uint8_t* p, const uint8_t* end) -> const uint8_t* { + while (p + 3 <= end) { + if (p[0] == 0 && p[1] == 0) { + if (p[2] == 1) return p + 3; + if (p + 3 < end && p[2] == 0 && p[3] == 1) return p + 4; + } + p++; + } + return nullptr; + }; + + const uint8_t* pos = findStartCode(data, end); + while (pos && pos < end) { + uint8_t currentNaluType = pos[0] & 0x1F; + const uint8_t* currentNaluStart = pos; + + const uint8_t* nextStart = findStartCode(pos, end); + const uint8_t* naluEnd; + if (nextStart) { + naluEnd = nextStart - 3; + if (naluEnd > data && *(naluEnd - 1) == 0) naluEnd--; + } else { + naluEnd = end; + } + + uint32_t naluSize = static_cast(naluEnd - currentNaluStart); + + if (currentNaluType == 7 && !sps) { // SPS + sps = currentNaluStart; + spsSize = naluSize; + } else if (currentNaluType == 8 && !pps) { // PPS + pps = currentNaluStart; + ppsSize = naluSize; + } + + if (sps && pps) break; + pos = nextStart; + } + + if (sps && spsSize > 0 && pps && ppsSize > 0) { + std::vector avcc = BuildAvccFromAnnexB(sps, spsSize, pps, ppsSize); + if (rtmpClient.SendAvcSequenceHeader(avcc.data(), static_cast(avcc.size()))) { + videoHeaderSent = true; + LOGI("Sent AVC sequence header (extracted SPS=%u PPS=%u)", spsSize, ppsSize); + return true; + } else { + LOGE("SendAvcSequenceHeader failed (SPS=%u PPS=%u)", spsSize, ppsSize); + } + } + + return false; +} + +std::vector RtmpSink::ConvertAnnexBToAvcc(const uint8_t* data, uint32_t size) { + std::vector result; + result.reserve(size); + + auto findStartCode = [](const uint8_t* p, const uint8_t* end, int& startCodeLen) -> const uint8_t* { + while (p + 3 <= end) { + if (p[0] == 0 && p[1] == 0) { + if (p + 3 < end && p[2] == 0 && p[3] == 1) { + startCodeLen = 4; + return p; + } + if (p[2] == 1) { + startCodeLen = 3; + return p; + } + } + p++; + } + return nullptr; + }; + + const uint8_t* pos = data; + const uint8_t* end = data + size; + + int startCodeLen = 0; + const uint8_t* startCode = findStartCode(pos, end, startCodeLen); + if (!startCode) { + // No start codes found - pass through + result.insert(result.end(), data, data + size); + return result; + } + + while (startCode) { + const uint8_t* naluStart = startCode + startCodeLen; + if (naluStart >= end) break; + + int nextStartCodeLen = 0; + const uint8_t* nextStartCode = findStartCode(naluStart, end, nextStartCodeLen); + uint32_t naluSize = nextStartCode + ? static_cast(nextStartCode - naluStart) + : static_cast(end - naluStart); + + if (naluSize > 0) { + uint8_t naluType = naluStart[0] & 0x1F; + // Skip SPS (7), PPS (8), AUD (9) + if (naluType != 7 && naluType != 8 && naluType != 9) { + result.push_back(static_cast(naluSize >> 24)); + result.push_back(static_cast(naluSize >> 16)); + result.push_back(static_cast(naluSize >> 8)); + result.push_back(static_cast(naluSize & 0xFF)); + result.insert(result.end(), naluStart, naluStart + naluSize); + } + } + + startCode = nextStartCode; + startCodeLen = nextStartCodeLen; + } + + return result; +} + +std::vector RtmpSink::BuildAvccFromAnnexB(const uint8_t* sps, uint32_t spsSize, + const uint8_t* pps, uint32_t ppsSize) { + // AVCDecoderConfigurationRecord + std::vector record; + record.reserve(11 + spsSize + ppsSize); + + record.push_back(0x01); // configurationVersion + record.push_back(spsSize > 1 ? sps[1] : 0x42); // AVCProfileIndication + record.push_back(spsSize > 2 ? sps[2] : 0x00); // profile_compatibility + record.push_back(spsSize > 3 ? sps[3] : 0x1E); // AVCLevelIndication + record.push_back(0xFF); // lengthSizeMinusOne = 3 (4 bytes) + record.push_back(0xE1); // numOfSequenceParameterSets = 1 + + // SPS length (big-endian) + record.push_back(static_cast(spsSize >> 8)); + record.push_back(static_cast(spsSize & 0xFF)); + record.insert(record.end(), sps, sps + spsSize); + + record.push_back(0x01); // numOfPictureParameterSets = 1 + + // PPS length (big-endian) + record.push_back(static_cast(ppsSize >> 8)); + record.push_back(static_cast(ppsSize & 0xFF)); + record.insert(record.end(), pps, pps + ppsSize); + + return record; +} diff --git a/app/src/main/cpp/rtmp_sink.h b/app/src/main/cpp/rtmp_sink.h new file mode 100644 index 0000000..3534f40 --- /dev/null +++ b/app/src/main/cpp/rtmp_sink.h @@ -0,0 +1,43 @@ +#pragma once + +#include "rtmp_client.h" +#include +#include +#include + +/** + * RTMP sink that bridges encoded packets to an RTMP endpoint. + * Ported from FLCKRtmpSink (UE5 LCKStreaming plugin). + * Handles Annex-B to AVCC conversion, sequence headers, and FLV framing. + */ +class RtmpSink { +public: + RtmpSink(); + ~RtmpSink(); + + void SetRtmpUrl(const std::string& url); + + bool Open(uint32_t width, uint32_t height, uint32_t framerate, + uint32_t sampleRate, uint32_t numChannels); + void OnVideoFormatReady(const uint8_t* extraData, uint32_t extraDataSize); + void OnAudioFormatReady(uint32_t sampleRate, uint32_t numChannels); + void SendVideoPacket(const uint8_t* data, uint32_t size, + int64_t timestampMs, bool isKeyframe); + void SendAudioPacket(const uint8_t* data, uint32_t size, int64_t timestampMs); + void Close(); + bool IsOpen() const; + +private: + bool TryExtractAndSendSequenceHeader(const uint8_t* data, uint32_t size); + static std::vector ConvertAnnexBToAvcc(const uint8_t* data, uint32_t size); + static std::vector BuildAvccFromAnnexB(const uint8_t* sps, uint32_t spsSize, + const uint8_t* pps, uint32_t ppsSize); + + RtmpClient rtmpClient; + std::string rtmpUrl; + bool isOpen = false; + bool videoHeaderSent = false; + bool audioHeaderSent = false; + uint32_t storedSampleRate = 48000; + uint32_t storedNumChannels = 2; +}; diff --git a/app/src/main/cpp/streaming_engine.cpp b/app/src/main/cpp/streaming_engine.cpp new file mode 100644 index 0000000..cae0db9 --- /dev/null +++ b/app/src/main/cpp/streaming_engine.cpp @@ -0,0 +1,587 @@ +#include "streaming_engine.h" + +#include +#include +#include +#include +#include +#include + +#define TAG "LckStreamingEngine" +#define LOGI(...) __android_log_print(ANDROID_LOG_INFO, TAG, __VA_ARGS__) +#define LOGW(...) __android_log_print(ANDROID_LOG_WARN, TAG, __VA_ARGS__) +#define LOGE(...) __android_log_print(ANDROID_LOG_ERROR, TAG, __VA_ARGS__) + +// Shader source for blitting OES texture to framebuffer +static const char* BLIT_VERTEX_SHADER = R"(#version 300 es +layout(location = 0) in vec2 aPos; +layout(location = 1) in vec2 aTexCoord; +out vec2 vTexCoord; +void main() { + gl_Position = vec4(aPos, 0.0, 1.0); + vTexCoord = aTexCoord; +} +)"; + +static const char* BLIT_FRAGMENT_SHADER = R"(#version 300 es +#extension GL_OES_EGL_image_external_essl3 : require +precision mediump float; +in vec2 vTexCoord; +out vec4 fragColor; +uniform samplerExternalOES uTexture; +void main() { + fragColor = texture(uTexture, vTexCoord); +} +)"; + +static GLuint CompileShader(GLenum type, const char* source) { + GLuint shader = glCreateShader(type); + glShaderSource(shader, 1, &source, nullptr); + glCompileShader(shader); + + GLint status; + glGetShaderiv(shader, GL_COMPILE_STATUS, &status); + if (!status) { + char log[512]; + glGetShaderInfoLog(shader, sizeof(log), nullptr, log); + LOGE("Shader compile error: %s", log); + glDeleteShader(shader); + return 0; + } + return shader; +} + +StreamingEngine::StreamingEngine() {} + +StreamingEngine::~StreamingEngine() { + Stop(); + for (auto* sink : sinks) { + delete sink; + } + sinks.clear(); +} + +bool StreamingEngine::Configure(int w, int h, int vBitrate, int aBitrate, + int sr, int ch, int kfi) { + width = w; + height = h; + videoBitrate = vBitrate; + audioBitrate = aBitrate; + sampleRate = sr; + channels = ch; + keyframeInterval = kfi; + return true; +} + +int StreamingEngine::AddDestination(const std::string& rtmpUrl) { + auto* sink = new RtmpSink(); + sink->SetRtmpUrl(rtmpUrl); + sinks.push_back(sink); + return static_cast(sinks.size() - 1); +} + +bool StreamingEngine::InitVideoEncoder() { + videoEncoder = AMediaCodec_createEncoderByType("video/avc"); + if (!videoEncoder) { + LOGE("Failed to create video encoder"); + return false; + } + + AMediaFormat* format = AMediaFormat_new(); + AMediaFormat_setString(format, AMEDIAFORMAT_KEY_MIME, "video/avc"); + AMediaFormat_setInt32(format, AMEDIAFORMAT_KEY_WIDTH, width); + AMediaFormat_setInt32(format, AMEDIAFORMAT_KEY_HEIGHT, height); + AMediaFormat_setInt32(format, AMEDIAFORMAT_KEY_BIT_RATE, videoBitrate); + AMediaFormat_setInt32(format, AMEDIAFORMAT_KEY_FRAME_RATE, framerate); + AMediaFormat_setInt32(format, AMEDIAFORMAT_KEY_I_FRAME_INTERVAL, keyframeInterval); + AMediaFormat_setInt32(format, AMEDIAFORMAT_KEY_COLOR_FORMAT, 0x7F000789); // COLOR_FormatSurface + AMediaFormat_setInt32(format, "profile", 8); // AVCProfileHigh + AMediaFormat_setInt32(format, "level", 2048); // AVCLevel42 + AMediaFormat_setInt32(format, "bitrate-mode", 2); // CBR + + media_status_t status = AMediaCodec_configure(videoEncoder, format, nullptr, nullptr, + AMEDIACODEC_CONFIGURE_FLAG_ENCODE); + AMediaFormat_delete(format); + + if (status != AMEDIA_OK) { + LOGE("Video encoder configure failed: %d", status); + AMediaCodec_delete(videoEncoder); + videoEncoder = nullptr; + return false; + } + + status = AMediaCodec_createInputSurface(videoEncoder, &encoderSurface); + if (status != AMEDIA_OK || !encoderSurface) { + LOGE("Failed to create encoder input surface: %d", status); + AMediaCodec_delete(videoEncoder); + videoEncoder = nullptr; + return false; + } + + status = AMediaCodec_start(videoEncoder); + if (status != AMEDIA_OK) { + LOGE("Video encoder start failed: %d", status); + ANativeWindow_release(encoderSurface); + encoderSurface = nullptr; + AMediaCodec_delete(videoEncoder); + videoEncoder = nullptr; + return false; + } + + LOGI("Video encoder started: %dx%d @ %d bps", width, height, videoBitrate); + return true; +} + +bool StreamingEngine::InitAudioEncoder() { + audioEncoder = AMediaCodec_createEncoderByType("audio/mp4a-latm"); + if (!audioEncoder) { + LOGE("Failed to create audio encoder"); + return false; + } + + AMediaFormat* format = AMediaFormat_new(); + AMediaFormat_setString(format, AMEDIAFORMAT_KEY_MIME, "audio/mp4a-latm"); + AMediaFormat_setInt32(format, AMEDIAFORMAT_KEY_AAC_PROFILE, 2); // AAC-LC + AMediaFormat_setInt32(format, AMEDIAFORMAT_KEY_BIT_RATE, audioBitrate); + AMediaFormat_setInt32(format, AMEDIAFORMAT_KEY_SAMPLE_RATE, sampleRate); + AMediaFormat_setInt32(format, AMEDIAFORMAT_KEY_CHANNEL_COUNT, channels); + + media_status_t status = AMediaCodec_configure(audioEncoder, format, nullptr, nullptr, + AMEDIACODEC_CONFIGURE_FLAG_ENCODE); + AMediaFormat_delete(format); + + if (status != AMEDIA_OK) { + LOGE("Audio encoder configure failed: %d", status); + AMediaCodec_delete(audioEncoder); + audioEncoder = nullptr; + return false; + } + + status = AMediaCodec_start(audioEncoder); + if (status != AMEDIA_OK) { + LOGE("Audio encoder start failed: %d", status); + AMediaCodec_delete(audioEncoder); + audioEncoder = nullptr; + return false; + } + + LOGI("Audio encoder started: %dHz %dch @ %d bps", sampleRate, channels, audioBitrate); + return true; +} + +bool StreamingEngine::InitBlitResources() { + GLuint vs = CompileShader(GL_VERTEX_SHADER, BLIT_VERTEX_SHADER); + GLuint fs = CompileShader(GL_FRAGMENT_SHADER, BLIT_FRAGMENT_SHADER); + if (!vs || !fs) return false; + + blitProgram = glCreateProgram(); + glAttachShader(blitProgram, vs); + glAttachShader(blitProgram, fs); + glLinkProgram(blitProgram); + glDeleteShader(vs); + glDeleteShader(fs); + + GLint linkStatus; + glGetProgramiv(blitProgram, GL_LINK_STATUS, &linkStatus); + if (!linkStatus) { + LOGE("Blit program link failed"); + glDeleteProgram(blitProgram); + blitProgram = 0; + return false; + } + + // Full-screen quad: pos(x,y) + texcoord(u,v) + float quad[] = { + -1.0f, -1.0f, 0.0f, 0.0f, + 1.0f, -1.0f, 1.0f, 0.0f, + -1.0f, 1.0f, 0.0f, 1.0f, + 1.0f, 1.0f, 1.0f, 1.0f, + }; + + glGenVertexArrays(1, &blitVao); + glGenBuffers(1, &blitVbo); + glBindVertexArray(blitVao); + glBindBuffer(GL_ARRAY_BUFFER, blitVbo); + glBufferData(GL_ARRAY_BUFFER, sizeof(quad), quad, GL_STATIC_DRAW); + glVertexAttribPointer(0, 2, GL_FLOAT, GL_FALSE, 4 * sizeof(float), (void*)0); + glEnableVertexAttribArray(0); + glVertexAttribPointer(1, 2, GL_FLOAT, GL_FALSE, 4 * sizeof(float), (void*)(2 * sizeof(float))); + glEnableVertexAttribArray(1); + glBindVertexArray(0); + + return true; +} + +void StreamingEngine::ReleaseBlitResources() { + if (blitVao) { glDeleteVertexArrays(1, &blitVao); blitVao = 0; } + if (blitVbo) { glDeleteBuffers(1, &blitVbo); blitVbo = 0; } + if (blitProgram) { glDeleteProgram(blitProgram); blitProgram = 0; } +} + +bool StreamingEngine::Start() { + if (running.load()) return true; + + if (width <= 0 || height <= 0) { + LOGE("Invalid dimensions: %dx%d", width, height); + return false; + } + + if (sinks.empty()) { + LOGE("No destinations configured"); + return false; + } + + running.store(true); + firstVideoFrame = true; + startTimestampNs = 0; + statsVideoBytes = 0; + statsAudioBytes = 0; + statsFrameCount = 0; + statsLastUpdateNs = 0; + + encoderThread = std::thread(&StreamingEngine::EncoderThreadFunc, this); + return true; +} + +void StreamingEngine::EncoderThreadFunc() { + LOGI("Encoder thread started"); + + // Init EGL + if (!eglContext.Init()) { + LOGE("EGL init failed"); + running.store(false); + if (errorCallback) errorCallback(1, "EGL initialization failed"); + return; + } + + // Init video encoder (creates input surface) + if (!InitVideoEncoder()) { + LOGE("Video encoder init failed"); + eglContext.Release(); + running.store(false); + if (errorCallback) errorCallback(2, "Video encoder initialization failed"); + return; + } + + // Create EGL window surface from encoder input surface + if (!eglContext.CreateWindowSurface(encoderSurface)) { + LOGE("EGL window surface creation failed"); + AMediaCodec_stop(videoEncoder); + AMediaCodec_delete(videoEncoder); + videoEncoder = nullptr; + ANativeWindow_release(encoderSurface); + encoderSurface = nullptr; + eglContext.Release(); + running.store(false); + if (errorCallback) errorCallback(3, "EGL window surface creation failed"); + return; + } + + if (!eglContext.MakeCurrent()) { + LOGE("EGL make current failed"); + running.store(false); + if (errorCallback) errorCallback(4, "EGL make current failed"); + return; + } + + // Init blit resources + if (!InitBlitResources()) { + LOGE("Blit resources init failed"); + running.store(false); + if (errorCallback) errorCallback(5, "Blit resources initialization failed"); + return; + } + + // Init audio encoder + if (!InitAudioEncoder()) { + LOGW("Audio encoder init failed, continuing without audio"); + } + + // Open RTMP sinks + for (auto* sink : sinks) { + if (!sink->Open(width, height, framerate, sampleRate, channels)) { + LOGE("Failed to open RTMP sink"); + if (errorCallback) errorCallback(6, "RTMP connection failed"); + } + } + + LOGI("Streaming engine fully initialized"); + + // Main encoder loop + while (running.load()) { + // Process video frames + { + std::lock_guard lock(videoMutex); + for (auto& frame : videoQueue) { + ProcessVideoFrame(frame); + } + videoQueue.clear(); + } + + // Process audio frames + { + std::lock_guard lock(audioMutex); + for (auto& frame : audioQueue) { + ProcessAudioFrame(frame); + } + audioQueue.clear(); + } + + // Drain encoders + DrainVideoEncoder(); + if (audioEncoder) { + DrainAudioEncoder(); + } + + // Don't spin-wait + std::this_thread::sleep_for(std::chrono::milliseconds(1)); + } + + // Cleanup + LOGI("Encoder thread shutting down"); + + ReleaseBlitResources(); + + for (auto* sink : sinks) { + sink->Close(); + } + + if (videoEncoder) { + AMediaCodec_stop(videoEncoder); + AMediaCodec_delete(videoEncoder); + videoEncoder = nullptr; + } + if (encoderSurface) { + ANativeWindow_release(encoderSurface); + encoderSurface = nullptr; + } + if (audioEncoder) { + AMediaCodec_stop(audioEncoder); + AMediaCodec_delete(audioEncoder); + audioEncoder = nullptr; + } + + eglContext.Release(); + LOGI("Encoder thread stopped"); +} + +void StreamingEngine::ProcessVideoFrame(const VideoFrame& frame) { + if (!frame.buffer) return; + + if (firstVideoFrame) { + startTimestampNs = frame.timestampNs; + firstVideoFrame = false; + } + + // Wait on GPU fence + eglContext.WaitFence(frame.fenceFd); + + // Import HardwareBuffer as GL texture + GLuint texture = eglContext.ImportHardwareBuffer(frame.buffer); + if (texture == 0) { + LOGW("Failed to import HardwareBuffer as texture"); + return; + } + + // Blit to encoder surface + BlitToEncoder(texture, frame.timestampNs); + + // Clean up texture + glDeleteTextures(1, &texture); +} + +void StreamingEngine::BlitToEncoder(GLuint srcTexture, int64_t timestampNs) { + glViewport(0, 0, width, height); + + glUseProgram(blitProgram); + glActiveTexture(GL_TEXTURE0); + glBindTexture(GL_TEXTURE_EXTERNAL_OES, srcTexture); + glUniform1i(glGetUniformLocation(blitProgram, "uTexture"), 0); + + glBindVertexArray(blitVao); + glDrawArrays(GL_TRIANGLE_STRIP, 0, 4); + glBindVertexArray(0); + + eglContext.SetPresentationTime(timestampNs); + eglContext.SwapBuffers(); +} + +void StreamingEngine::ProcessAudioFrame(const AudioFrame& frame) { + if (!audioEncoder || frame.pcmData.empty()) return; + + ssize_t inputIndex = AMediaCodec_dequeueInputBuffer(audioEncoder, 0); + if (inputIndex < 0) { + LOGW("No audio input buffer available"); + return; + } + + size_t bufferSize; + uint8_t* inputBuffer = AMediaCodec_getInputBuffer(audioEncoder, inputIndex, &bufferSize); + if (!inputBuffer) return; + + size_t copySize = std::min(frame.pcmData.size(), bufferSize); + memcpy(inputBuffer, frame.pcmData.data(), copySize); + + int64_t relativeTs = frame.timestampNs - startTimestampNs; + AMediaCodec_queueInputBuffer(audioEncoder, inputIndex, 0, copySize, + relativeTs / 1000, 0); +} + +void StreamingEngine::DrainVideoEncoder() { + if (!videoEncoder) return; + + AMediaCodecBufferInfo info; + ssize_t outputIndex; + + while ((outputIndex = AMediaCodec_dequeueOutputBuffer(videoEncoder, &info, 0)) >= 0) { + if (info.size > 0) { + size_t outSize; + uint8_t* outputData = AMediaCodec_getOutputBuffer(videoEncoder, outputIndex, &outSize); + if (outputData) { + bool isKeyframe = (info.flags & AMEDIACODEC_BUFFER_FLAG_KEY_FRAME) != 0; + int64_t timestampMs = info.presentationTimeUs / 1000; + + for (auto* sink : sinks) { + sink->SendVideoPacket(outputData + info.offset, info.size, + timestampMs, isKeyframe); + } + + std::lock_guard lock(statsMutex); + statsVideoBytes += info.size; + statsFrameCount++; + } + } + + if (info.flags & AMEDIACODEC_BUFFER_FLAG_CODEC_CONFIG) { + // Sequence header (SPS/PPS) — forward to sinks + size_t outSize; + uint8_t* configData = AMediaCodec_getOutputBuffer(videoEncoder, outputIndex, &outSize); + if (configData) { + for (auto* sink : sinks) { + sink->OnVideoFormatReady(configData + info.offset, info.size); + } + } + } + + AMediaCodec_releaseOutputBuffer(videoEncoder, outputIndex, false); + + UpdateStats(); + } + + if (outputIndex == AMEDIACODEC_INFO_OUTPUT_FORMAT_CHANGED) { + AMediaFormat* newFormat = AMediaCodec_getOutputFormat(videoEncoder); + if (newFormat) { + LOGI("Video encoder output format changed"); + AMediaFormat_delete(newFormat); + } + } +} + +void StreamingEngine::DrainAudioEncoder() { + if (!audioEncoder) return; + + AMediaCodecBufferInfo info; + ssize_t outputIndex; + + while ((outputIndex = AMediaCodec_dequeueOutputBuffer(audioEncoder, &info, 0)) >= 0) { + if (info.size > 0 && !(info.flags & AMEDIACODEC_BUFFER_FLAG_CODEC_CONFIG)) { + size_t outSize; + uint8_t* outputData = AMediaCodec_getOutputBuffer(audioEncoder, outputIndex, &outSize); + if (outputData) { + int64_t timestampMs = info.presentationTimeUs / 1000; + + for (auto* sink : sinks) { + sink->SendAudioPacket(outputData + info.offset, info.size, timestampMs); + } + + std::lock_guard lock(statsMutex); + statsAudioBytes += info.size; + } + } + + if (info.flags & AMEDIACODEC_BUFFER_FLAG_CODEC_CONFIG) { + // AAC config — sinks handle audio format via Open() + } + + AMediaCodec_releaseOutputBuffer(audioEncoder, outputIndex, false); + } +} + +void StreamingEngine::UpdateStats() { + auto now = std::chrono::steady_clock::now().time_since_epoch(); + int64_t nowNs = std::chrono::duration_cast(now).count(); + + std::lock_guard lock(statsMutex); + + if (statsLastUpdateNs == 0) { + statsLastUpdateNs = nowNs; + return; + } + + int64_t elapsedNs = nowNs - statsLastUpdateNs; + if (elapsedNs >= 1000000000LL) { // Every second + double elapsedSec = elapsedNs / 1000000000.0; + currentStats.videoBitrate = static_cast(statsVideoBytes * 8 / elapsedSec); + currentStats.audioBitrate = static_cast(statsAudioBytes * 8 / elapsedSec); + currentStats.fps = static_cast(statsFrameCount / elapsedSec); + + statsVideoBytes = 0; + statsAudioBytes = 0; + statsFrameCount = 0; + statsLastUpdateNs = nowNs; + + if (statsCallback) { + statsCallback(currentStats); + } + } +} + +void StreamingEngine::SubmitVideoFrame(AHardwareBuffer* buffer, int64_t timestampNs, int fenceFd) { + if (!running.load()) { + if (fenceFd >= 0) close(fenceFd); + return; + } + + VideoFrame frame; + frame.buffer = buffer; + frame.timestampNs = timestampNs; + frame.fenceFd = fenceFd; + + std::lock_guard lock(videoMutex); + videoQueue.push_back(frame); +} + +void StreamingEngine::SubmitAudioFrame(const uint8_t* pcmData, size_t pcmSize, int64_t timestampNs) { + if (!running.load()) return; + + AudioFrame frame; + frame.pcmData.assign(pcmData, pcmData + pcmSize); + frame.timestampNs = timestampNs; + + std::lock_guard lock(audioMutex); + audioQueue.push_back(std::move(frame)); +} + +void StreamingEngine::Stop() { + if (!running.load()) return; + + LOGI("Stopping streaming engine"); + running.store(false); + + if (encoderThread.joinable()) { + encoderThread.join(); + } + LOGI("Streaming engine stopped"); +} + +void StreamingEngine::SetStatsCallback(StatsCallback callback) { + statsCallback = std::move(callback); +} + +void StreamingEngine::SetErrorCallback(ErrorCallback callback) { + errorCallback = std::move(callback); +} + +void StreamingEngine::SetBufferReleasedCallback(BufferReleasedCallback callback) { + bufferReleasedCallback = std::move(callback); +} diff --git a/app/src/main/cpp/streaming_engine.h b/app/src/main/cpp/streaming_engine.h new file mode 100644 index 0000000..0574023 --- /dev/null +++ b/app/src/main/cpp/streaming_engine.h @@ -0,0 +1,150 @@ +#pragma once + +#include "egl_context.h" +#include "rtmp_sink.h" + +#include +#include +#include +#include + +#include +#include +#include +#include +#include +#include +#include + +struct VideoFrame { + AHardwareBuffer* buffer; + int64_t timestampNs; + int fenceFd; // -1 if no fence +}; + +struct AudioFrame { + std::vector pcmData; + int64_t timestampNs; +}; + +struct StreamingStats { + int64_t videoBitrate = 0; + int64_t audioBitrate = 0; + int fps = 0; + int droppedFrames = 0; +}; + +/** + * Streaming engine: imports HardwareBuffers via EGL, encodes with AMediaCodec, + * and streams via RTMP to one or more destinations. + * + * All encoding happens in native code (zero-copy pipeline). + */ +class StreamingEngine { +public: + using StatsCallback = std::function; + using ErrorCallback = std::function; + using BufferReleasedCallback = std::function; + + StreamingEngine(); + ~StreamingEngine(); + + /** Configure the engine. Must be called before Start(). */ + bool Configure(int width, int height, int videoBitrate, int audioBitrate, + int sampleRate, int channels, int keyframeInterval); + + /** Add an RTMP destination. Returns destination index. */ + int AddDestination(const std::string& rtmpUrl); + + /** Start encoding and streaming. */ + bool Start(); + + /** Submit a video frame from HardwareBuffer. Non-blocking. */ + void SubmitVideoFrame(AHardwareBuffer* buffer, int64_t timestampNs, int fenceFd); + + /** Submit audio PCM data. Non-blocking. */ + void SubmitAudioFrame(const uint8_t* pcmData, size_t pcmSize, int64_t timestampNs); + + /** Stop encoding and streaming. Blocks until clean shutdown. */ + void Stop(); + + /** Set callbacks. */ + void SetStatsCallback(StatsCallback callback); + void SetErrorCallback(ErrorCallback callback); + void SetBufferReleasedCallback(BufferReleasedCallback callback); + + bool IsRunning() const { return running.load(); } + +private: + // Encoder thread + void EncoderThreadFunc(); + void ProcessVideoFrame(const VideoFrame& frame); + void ProcessAudioFrame(const AudioFrame& frame); + void DrainVideoEncoder(); + void DrainAudioEncoder(); + void UpdateStats(); + + // Blit HardwareBuffer texture to encoder surface + void BlitToEncoder(GLuint srcTexture, int64_t timestampNs); + + // Config + int width = 0; + int height = 0; + int videoBitrate = 6000000; + int audioBitrate = 128000; + int sampleRate = 48000; + int channels = 2; + int keyframeInterval = 2; + int framerate = 30; + + // EGL + EglContext eglContext; + + // Blit resources + GLuint blitProgram = 0; + GLuint blitVao = 0; + GLuint blitVbo = 0; + + // Video encoder + AMediaCodec* videoEncoder = nullptr; + ANativeWindow* encoderSurface = nullptr; + + // Audio encoder + AMediaCodec* audioEncoder = nullptr; + + // RTMP sinks (one per destination) + std::vector sinks; + + // Threading + std::thread encoderThread; + std::atomic running{false}; + + // Frame queues (protected by mutex) + std::mutex videoMutex; + std::vector videoQueue; + + std::mutex audioMutex; + std::vector audioQueue; + + // Stats + std::mutex statsMutex; + StreamingStats currentStats; + int64_t statsVideoBytes = 0; + int64_t statsAudioBytes = 0; + int statsFrameCount = 0; + int64_t statsLastUpdateNs = 0; + + // Start timestamp for relative timing + int64_t startTimestampNs = 0; + bool firstVideoFrame = true; + + // Callbacks + StatsCallback statsCallback; + ErrorCallback errorCallback; + BufferReleasedCallback bufferReleasedCallback; + + bool InitVideoEncoder(); + bool InitAudioEncoder(); + bool InitBlitResources(); + void ReleaseBlitResources(); +}; diff --git a/app/src/main/cpp/third_party/librtmp/include/librtmp/amf.h b/app/src/main/cpp/third_party/librtmp/include/librtmp/amf.h new file mode 100644 index 0000000..5de414b --- /dev/null +++ b/app/src/main/cpp/third_party/librtmp/include/librtmp/amf.h @@ -0,0 +1,164 @@ +#ifndef __AMF_H__ +#define __AMF_H__ +/* + * Copyright (C) 2005-2008 Team XBMC + * http://www.xbmc.org + * Copyright (C) 2008-2009 Andrej Stepanchuk + * Copyright (C) 2009-2010 Howard Chu + * + * This file is part of librtmp. + * + * librtmp is free software; you can redistribute it and/or modify + * it under the terms of the GNU Lesser General Public License as + * published by the Free Software Foundation; either version 2.1, + * or (at your option) any later version. + * + * librtmp is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU Lesser General Public License + * along with librtmp see the file COPYING. If not, write to + * the Free Software Foundation, Inc., 51 Franklin Street, Fifth Floor, + * Boston, MA 02110-1301, USA. + * http://www.gnu.org/copyleft/lgpl.html + */ + +#include + +#ifndef TRUE +#define TRUE 1 +#define FALSE 0 +#endif + +#ifdef __cplusplus +extern "C" +{ +#endif + + typedef enum + { AMF_NUMBER = 0, AMF_BOOLEAN, AMF_STRING, AMF_OBJECT, + AMF_MOVIECLIP, /* reserved, not used */ + AMF_NULL, AMF_UNDEFINED, AMF_REFERENCE, AMF_ECMA_ARRAY, AMF_OBJECT_END, + AMF_STRICT_ARRAY, AMF_DATE, AMF_LONG_STRING, AMF_UNSUPPORTED, + AMF_RECORDSET, /* reserved, not used */ + AMF_XML_DOC, AMF_TYPED_OBJECT, + AMF_AVMPLUS, /* switch to AMF3 */ + AMF_INVALID = 0xff + } AMFDataType; + + typedef enum + { AMF3_UNDEFINED = 0, AMF3_NULL, AMF3_FALSE, AMF3_TRUE, + AMF3_INTEGER, AMF3_DOUBLE, AMF3_STRING, AMF3_XML_DOC, AMF3_DATE, + AMF3_ARRAY, AMF3_OBJECT, AMF3_XML, AMF3_BYTE_ARRAY + } AMF3DataType; + + typedef struct AVal + { + char *av_val; + int av_len; + } AVal; +#define AVC(str) {str,sizeof(str)-1} +#define AVMATCH(a1,a2) ((a1)->av_len == (a2)->av_len && !memcmp((a1)->av_val,(a2)->av_val,(a1)->av_len)) + + struct AMFObjectProperty; + + typedef struct AMFObject + { + int o_num; + struct AMFObjectProperty *o_props; + } AMFObject; + + typedef struct AMFObjectProperty + { + AVal p_name; + AMFDataType p_type; + union + { + double p_number; + AVal p_aval; + AMFObject p_object; + } p_vu; + int16_t p_UTCoffset; + } AMFObjectProperty; + + char *AMF_EncodeString(char *output, char *outend, const AVal * str); + char *AMF_EncodeNumber(char *output, char *outend, double dVal); + char *AMF_EncodeInt16(char *output, char *outend, short nVal); + char *AMF_EncodeInt24(char *output, char *outend, int nVal); + char *AMF_EncodeInt32(char *output, char *outend, int nVal); + char *AMF_EncodeBoolean(char *output, char *outend, int bVal); + + /* Shortcuts for AMFProp_Encode */ + char *AMF_EncodeNamedString(char *output, char *outend, const AVal * name, const AVal * value); + char *AMF_EncodeNamedNumber(char *output, char *outend, const AVal * name, double dVal); + char *AMF_EncodeNamedBoolean(char *output, char *outend, const AVal * name, int bVal); + + unsigned short AMF_DecodeInt16(const char *data); + unsigned int AMF_DecodeInt24(const char *data); + unsigned int AMF_DecodeInt32(const char *data); + void AMF_DecodeString(const char *data, AVal * str); + void AMF_DecodeLongString(const char *data, AVal * str); + int AMF_DecodeBoolean(const char *data); + double AMF_DecodeNumber(const char *data); + + char *AMF_Encode(AMFObject * obj, char *pBuffer, char *pBufEnd); + char *AMF_EncodeEcmaArray(AMFObject *obj, char *pBuffer, char *pBufEnd); + char *AMF_EncodeArray(AMFObject *obj, char *pBuffer, char *pBufEnd); + + int AMF_Decode(AMFObject * obj, const char *pBuffer, int nSize, + int bDecodeName); + int AMF_DecodeArray(AMFObject * obj, const char *pBuffer, int nSize, + int nArrayLen, int bDecodeName); + int AMF3_Decode(AMFObject * obj, const char *pBuffer, int nSize, + int bDecodeName); + void AMF_Dump(AMFObject * obj); + void AMF_Reset(AMFObject * obj); + + void AMF_AddProp(AMFObject * obj, const AMFObjectProperty * prop); + int AMF_CountProp(AMFObject * obj); + AMFObjectProperty *AMF_GetProp(AMFObject * obj, const AVal * name, + int nIndex); + + AMFDataType AMFProp_GetType(AMFObjectProperty * prop); + void AMFProp_SetNumber(AMFObjectProperty * prop, double dval); + void AMFProp_SetBoolean(AMFObjectProperty * prop, int bflag); + void AMFProp_SetString(AMFObjectProperty * prop, AVal * str); + void AMFProp_SetObject(AMFObjectProperty * prop, AMFObject * obj); + + void AMFProp_GetName(AMFObjectProperty * prop, AVal * name); + void AMFProp_SetName(AMFObjectProperty * prop, AVal * name); + double AMFProp_GetNumber(AMFObjectProperty * prop); + int AMFProp_GetBoolean(AMFObjectProperty * prop); + void AMFProp_GetString(AMFObjectProperty * prop, AVal * str); + void AMFProp_GetObject(AMFObjectProperty * prop, AMFObject * obj); + + int AMFProp_IsValid(AMFObjectProperty * prop); + + char *AMFProp_Encode(AMFObjectProperty * prop, char *pBuffer, char *pBufEnd); + int AMF3Prop_Decode(AMFObjectProperty * prop, const char *pBuffer, + int nSize, int bDecodeName); + int AMFProp_Decode(AMFObjectProperty * prop, const char *pBuffer, + int nSize, int bDecodeName); + + void AMFProp_Dump(AMFObjectProperty * prop); + void AMFProp_Reset(AMFObjectProperty * prop); + + typedef struct AMF3ClassDef + { + AVal cd_name; + char cd_externalizable; + char cd_dynamic; + int cd_num; + AVal *cd_props; + } AMF3ClassDef; + + void AMF3CD_AddProp(AMF3ClassDef * cd, AVal * prop); + AVal *AMF3CD_GetProp(AMF3ClassDef * cd, int idx); + +#ifdef __cplusplus +} +#endif + +#endif /* __AMF_H__ */ diff --git a/app/src/main/cpp/third_party/librtmp/include/librtmp/bytes.h b/app/src/main/cpp/third_party/librtmp/include/librtmp/bytes.h new file mode 100644 index 0000000..8c6e80d --- /dev/null +++ b/app/src/main/cpp/third_party/librtmp/include/librtmp/bytes.h @@ -0,0 +1,91 @@ +/* + * Copyright (C) 2005-2008 Team XBMC + * http://www.xbmc.org + * Copyright (C) 2008-2009 Andrej Stepanchuk + * Copyright (C) 2009-2010 Howard Chu + * + * This file is part of librtmp. + * + * librtmp is free software; you can redistribute it and/or modify + * it under the terms of the GNU Lesser General Public License as + * published by the Free Software Foundation; either version 2.1, + * or (at your option) any later version. + * + * librtmp is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU Lesser General Public License + * along with librtmp see the file COPYING. If not, write to + * the Free Software Foundation, Inc., 51 Franklin Street, Fifth Floor, + * Boston, MA 02110-1301, USA. + * http://www.gnu.org/copyleft/lgpl.html + */ + +#ifndef __BYTES_H__ +#define __BYTES_H__ + +#include + +#ifdef _WIN32 +/* Windows is little endian only */ +#define __LITTLE_ENDIAN 1234 +#define __BIG_ENDIAN 4321 +#define __BYTE_ORDER __LITTLE_ENDIAN +#define __FLOAT_WORD_ORDER __BYTE_ORDER + +typedef unsigned char uint8_t; + +#else /* !_WIN32 */ + +#include + +#if defined(BYTE_ORDER) && !defined(__BYTE_ORDER) +#define __BYTE_ORDER BYTE_ORDER +#endif + +#if defined(BIG_ENDIAN) && !defined(__BIG_ENDIAN) +#define __BIG_ENDIAN BIG_ENDIAN +#endif + +#if defined(LITTLE_ENDIAN) && !defined(__LITTLE_ENDIAN) +#define __LITTLE_ENDIAN LITTLE_ENDIAN +#endif + +#endif /* !_WIN32 */ + +/* define default endianness */ +#ifndef __LITTLE_ENDIAN +#define __LITTLE_ENDIAN 1234 +#endif + +#ifndef __BIG_ENDIAN +#define __BIG_ENDIAN 4321 +#endif + +#ifndef __BYTE_ORDER +#warning "Byte order not defined on your system, assuming little endian!" +#define __BYTE_ORDER __LITTLE_ENDIAN +#endif + +/* ok, we assume to have the same float word order and byte order if float word order is not defined */ +#ifndef __FLOAT_WORD_ORDER +#warning "Float word order not defined, assuming the same as byte order!" +#define __FLOAT_WORD_ORDER __BYTE_ORDER +#endif + +#if !defined(__BYTE_ORDER) || !defined(__FLOAT_WORD_ORDER) +#error "Undefined byte or float word order!" +#endif + +#if __FLOAT_WORD_ORDER != __BIG_ENDIAN && __FLOAT_WORD_ORDER != __LITTLE_ENDIAN +#error "Unknown/unsupported float word order!" +#endif + +#if __BYTE_ORDER != __BIG_ENDIAN && __BYTE_ORDER != __LITTLE_ENDIAN +#error "Unknown/unsupported byte order!" +#endif + +#endif + diff --git a/app/src/main/cpp/third_party/librtmp/include/librtmp/dh.h b/app/src/main/cpp/third_party/librtmp/include/librtmp/dh.h new file mode 100644 index 0000000..fa2c74f --- /dev/null +++ b/app/src/main/cpp/third_party/librtmp/include/librtmp/dh.h @@ -0,0 +1,402 @@ +/* RTMPDump - Diffie-Hellmann Key Exchange + * Copyright (C) 2009 Andrej Stepanchuk + * Copyright (C) 2009-2010 Howard Chu + * + * This file is part of librtmp. + * + * librtmp is free software; you can redistribute it and/or modify + * it under the terms of the GNU Lesser General Public License as + * published by the Free Software Foundation; either version 2.1, + * or (at your option) any later version. + * + * librtmp is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU Lesser General Public License + * along with librtmp see the file COPYING. If not, write to + * the Free Software Foundation, Inc., 51 Franklin Street, Fifth Floor, + * Boston, MA 02110-1301, USA. + * http://www.gnu.org/copyleft/lgpl.html + */ + +#include +#include +#include +#include +#include + +#ifdef USE_POLARSSL +#include +typedef mpi * MP_t; +#define MP_new(m) m = malloc(sizeof(mpi)); mpi_init(m) +#define MP_set_w(mpi, w) mpi_lset(mpi, w) +#define MP_cmp(u, v) mpi_cmp_mpi(u, v) +#define MP_set(u, v) mpi_copy(u, v) +#define MP_sub_w(mpi, w) mpi_sub_int(mpi, mpi, w) +#define MP_cmp_1(mpi) mpi_cmp_int(mpi, 1) +#define MP_modexp(r, y, q, p) mpi_exp_mod(r, y, q, p, NULL) +#define MP_free(mpi) mpi_free(mpi); free(mpi) +#define MP_gethex(u, hex, res) MP_new(u); res = mpi_read_string(u, 16, hex) == 0 +#define MP_bytes(u) mpi_size(u) +#define MP_setbin(u,buf,len) mpi_write_binary(u,buf,len) +#define MP_getbin(u,buf,len) MP_new(u); mpi_read_binary(u,buf,len) +#define MP_setpg(dh, p, g) dh->p = p; dh->g = g +#define MP_setlength(dh, l) dh->length = l +#define MP_getp(dh) dh->p +#define MP_getpubkey(dh) dh->pub_key + +typedef struct MDH { + MP_t p; + MP_t g; + MP_t pub_key; + MP_t priv_key; + long length; + dhm_context ctx; +} MDH; + +#define MDH_new() calloc(1,sizeof(MDH)) +#define MDH_free(vp) {MDH *_dh = vp; dhm_free(&_dh->ctx); MP_free(_dh->p); MP_free(_dh->g); MP_free(_dh->pub_key); MP_free(_dh->priv_key); free(_dh);} + +static int MDH_generate_key(MDH *dh) +{ + unsigned char out[2]; + MP_set(&dh->ctx.P, dh->p); + MP_set(&dh->ctx.G, dh->g); + dh->ctx.len = 128; + dhm_make_public(&dh->ctx, 1024, out, 1, havege_random, &RTMP_TLS_ctx->hs); + MP_new(dh->pub_key); + MP_new(dh->priv_key); + MP_set(dh->pub_key, &dh->ctx.GX); + MP_set(dh->priv_key, &dh->ctx.X); + return 1; +} + +static int MDH_compute_key(uint8_t *secret, size_t len, MP_t pub, MDH *dh) +{ + MP_set(&dh->ctx.GY, pub); + dhm_calc_secret(&dh->ctx, secret, &len); + return 0; +} + +#elif defined(USE_GNUTLS) +#include +#include +#include +typedef mpz_ptr MP_t; +#define MP_new(m) m = malloc(sizeof(*m)); mpz_init2(m, 1) +#define MP_set_w(mpi, w) mpz_set_ui(mpi, w) +#define MP_cmp(u, v) mpz_cmp(u, v) +#define MP_set(u, v) mpz_set(u, v) +#define MP_sub_w(mpi, w) mpz_sub_ui(mpi, mpi, w) +#define MP_cmp_1(mpi) mpz_cmp_ui(mpi, 1) +#define MP_modexp(r, y, q, p) mpz_powm(r, y, q, p) +#define MP_free(mpi) mpz_clear(mpi); free(mpi) +#define MP_gethex(u, hex, res) u = malloc(sizeof(*u)); mpz_init2(u, 1); res = (mpz_set_str(u, hex, 16) == 0) +#define MP_bytes(u) (mpz_sizeinbase(u, 2) + 7) / 8 +#define MP_setbin(u,buf,len) nettle_mpz_get_str_256(len,buf,u) +#define MP_getbin(u,buf,len) u = malloc(sizeof(*u)); mpz_init2(u, 1); nettle_mpz_set_str_256_u(u,len,buf) +#define MP_setpg(dh, p, g) dh->p = p; dh->g = g +#define MP_setlength(dh, l) dh->length = l +#define MP_getp(dh) dh->p +#define MP_getpubkey(dh) dh->pub_key + +typedef struct MDH { + MP_t p; + MP_t g; + MP_t pub_key; + MP_t priv_key; + long length; +} MDH; + +#define MDH_new() calloc(1,sizeof(MDH)) +#define MDH_free(dh) do {MP_free(((MDH*)(dh))->p); MP_free(((MDH*)(dh))->g); MP_free(((MDH*)(dh))->pub_key); MP_free(((MDH*)(dh))->priv_key); free(dh);} while(0) + +static int MDH_generate_key(MDH *dh) +{ + int num_bytes; + uint32_t seed; + gmp_randstate_t rs; + + num_bytes = (mpz_sizeinbase(dh->p, 2) + 7) / 8 - 1; + if (num_bytes <= 0 || num_bytes > 18000) + return 0; + + dh->priv_key = calloc(1, sizeof(*dh->priv_key)); + if (!dh->priv_key) + return 0; + mpz_init2(dh->priv_key, 1); + gnutls_rnd(GNUTLS_RND_RANDOM, &seed, sizeof(seed)); + gmp_randinit_mt(rs); + gmp_randseed_ui(rs, seed); + mpz_urandomb(dh->priv_key, rs, num_bytes); + gmp_randclear(rs); + + dh->pub_key = calloc(1, sizeof(*dh->pub_key)); + if (!dh->pub_key) + return 0; + mpz_init2(dh->pub_key, 1); + if (!dh->pub_key) { + mpz_clear(dh->priv_key); + free(dh->priv_key); + return 0; + } + + mpz_powm(dh->pub_key, dh->g, dh->priv_key, dh->p); + + return 1; +} + +static int MDH_compute_key(uint8_t *secret, size_t len, MP_t pub, MDH *dh) +{ + mpz_ptr k; + int num_bytes; + + num_bytes = (mpz_sizeinbase(dh->p, 2) + 7) / 8; + if (num_bytes <= 0 || num_bytes > 18000) + return -1; + + k = calloc(1, sizeof(*k)); + if (!k) + return -1; + mpz_init2(k, 1); + + mpz_powm(k, pub, dh->priv_key, dh->p); + nettle_mpz_get_str_256(len, secret, k); + mpz_clear(k); + free(k); + + /* return the length of the shared secret key like DH_compute_key */ + return len; +} + +#else /* USE_OPENSSL */ +#include +#include + +typedef BIGNUM * MP_t; +#define MP_new(m) m = BN_new() +#define MP_set_w(mpi, w) BN_set_word(mpi, w) +#define MP_cmp(u, v) BN_cmp(u, v) +#define MP_set(u, v) BN_copy(u, v) +#define MP_sub_w(mpi, w) BN_sub_word(mpi, w) +#define MP_cmp_1(mpi) BN_cmp(mpi, BN_value_one()) +#define MP_modexp(r, y, q, p) do {BN_CTX *ctx = BN_CTX_new(); BN_mod_exp(r, y, q, p, ctx); BN_CTX_free(ctx);} while(0) +#define MP_free(mpi) BN_free(mpi) +#define MP_gethex(u, hex, res) res = BN_hex2bn(&u, hex) +#define MP_bytes(u) BN_num_bytes(u) +#define MP_setbin(u,buf,len) BN_bn2bin(u,buf) +#define MP_getbin(u,buf,len) u = BN_bin2bn(buf,len,0) + +#define MDH DH +#define MDH_new() DH_new() +#define MDH_free(dh) DH_free(dh) +#define MDH_generate_key(dh) DH_generate_key(dh) +#define MDH_compute_key(secret, seclen, pub, dh) DH_compute_key(secret, pub, dh) + +#if OPENSSL_VERSION_NUMBER >= 0x10100000 +#define MP_setpg(dh, p, g) DH_set0_pqg(dh, p, NULL, g) +#define MP_setlength(dh, l) DH_set_length(dh, l) +#define MP_getp(dh) DH_get0_p(dh) +#define MP_getpubkey(dh) DH_get0_pub_key(dh) +#else +#define MP_setpg(dh, p, g) dh->p = p; dh->g = g +#define MP_setlength(dh, l) dh->length = l +#define MP_getp(dh) dh->p +#define MP_getpubkey(dh) dh->pub_key +#endif +#endif + +#include "log.h" +#include "dhgroups.h" + +/* RFC 2631, Section 2.1.5, http://www.ietf.org/rfc/rfc2631.txt */ +static int +isValidPublicKey(MP_t y, MP_t p, MP_t q) +{ + int ret = TRUE; + MP_t bn; + assert(y); + + MP_new(bn); + assert(bn); + + /* y must lie in [2,p-1] */ + MP_set_w(bn, 1); + if (MP_cmp(y, bn) < 0) + { + RTMP_Log(RTMP_LOGERROR, "DH public key must be at least 2"); + ret = FALSE; + goto failed; + } + + /* bn = p-2 */ + MP_set(bn, p); + MP_sub_w(bn, 1); + if (MP_cmp(y, bn) > 0) + { + RTMP_Log(RTMP_LOGERROR, "DH public key must be at most p-2"); + ret = FALSE; + goto failed; + } + + /* Verify with Sophie-Germain prime + * + * This is a nice test to make sure the public key position is calculated + * correctly. This test will fail in about 50% of the cases if applied to + * random data. + */ + if (q) + { + /* y must fulfill y^q mod p = 1 */ + MP_modexp(bn, y, q, p); + + if (MP_cmp_1(bn) != 0) + { + RTMP_Log(RTMP_LOGWARNING, "DH public key does not fulfill y^q mod p = 1"); + } + } + +failed: + MP_free(bn); + return ret; +} + +static MDH * +DHInit(int nKeyBits) +{ + size_t res; + MDH *dh = MDH_new(); + MP_t g, p; + + if (!dh) + goto failed; + + MP_new(g); + + if (!g) + goto failed; + + MP_gethex(p, P1024, res); /* prime P1024, see dhgroups.h */ + if (!res) + { + goto failed; + } + + MP_set_w(g, 2); /* base 2 */ + MP_setpg(dh, p, g); + + MP_setlength(dh, nKeyBits); + return dh; + +failed: + if (dh) + MDH_free(dh); + + return 0; +} + +static int +DHGenerateKey(MDH *dh) +{ + MP_t q1; + size_t res; + if (!dh) + return 0; + + MP_gethex(q1, Q1024, res); + assert(res); + + do + { + if (MDH_generate_key(dh)) + { + MP_t key = (MP_t)MP_getpubkey(dh); + MP_t p = (MP_t)MP_getp(dh); + res = isValidPublicKey(key, p, q1); + } + else + { +#if !defined(OPENSSL_VERSION_NUMBER) || OPENSSL_VERSION_NUMBER < 0x10100000 + MP_free(dh->pub_key); + MP_free(dh->priv_key); + dh->pub_key = dh->priv_key = 0; +#endif + res = 0; + break; + } + } while (!res); + MP_free(q1); + return res; +} + +/* fill pubkey with the public key in BIG ENDIAN order + * 00 00 00 00 00 x1 x2 x3 ..... + */ + +static int +DHGetPublicKey(MDH *dh, uint8_t *pubkey, size_t nPubkeyLen) +{ + int len; + MP_t pub_key; + if (!dh || !(pub_key = (MP_t)MP_getpubkey(dh))) + return 0; + + len = MP_bytes(pub_key); + if (len <= 0 || len > (int) nPubkeyLen) + return 0; + + memset(pubkey, 0, nPubkeyLen); + MP_setbin(pub_key, pubkey + (nPubkeyLen - len), len); + return 1; +} + +#if 0 /* unused */ +static int +DHGetPrivateKey(MDH *dh, uint8_t *privkey, size_t nPrivkeyLen) +{ + if (!dh || !dh->priv_key) + return 0; + + int len = MP_bytes(dh->priv_key); + if (len <= 0 || len > (int) nPrivkeyLen) + return 0; + + memset(privkey, 0, nPrivkeyLen); + MP_setbin(dh->priv_key, privkey + (nPrivkeyLen - len), len); + return 1; +} +#endif + +/* computes the shared secret key from the private MDH value and the + * other party's public key (pubkey) + */ +static int +DHComputeSharedSecretKey(MDH *dh, uint8_t *pubkey, size_t nPubkeyLen, + uint8_t *secret) +{ + MP_t q1 = NULL, pubkeyBn = NULL; + size_t len; + int res; + + if (!dh || !secret || nPubkeyLen >= INT_MAX) + return -1; + + MP_getbin(pubkeyBn, pubkey, nPubkeyLen); + if (!pubkeyBn) + return -1; + + MP_gethex(q1, Q1024, len); + assert(len); + + if (isValidPublicKey(pubkeyBn, (MP_t)MP_getp(dh), q1)) + res = MDH_compute_key(secret, nPubkeyLen, pubkeyBn, dh); + else + res = -1; + + MP_free(q1); + MP_free(pubkeyBn); + + return res; +} diff --git a/app/src/main/cpp/third_party/librtmp/include/librtmp/dhgroups.h b/app/src/main/cpp/third_party/librtmp/include/librtmp/dhgroups.h new file mode 100644 index 0000000..2db3989 --- /dev/null +++ b/app/src/main/cpp/third_party/librtmp/include/librtmp/dhgroups.h @@ -0,0 +1,199 @@ +/* librtmp - Diffie-Hellmann Key Exchange + * Copyright (C) 2009 Andrej Stepanchuk + * + * This file is part of librtmp. + * + * librtmp is free software; you can redistribute it and/or modify + * it under the terms of the GNU Lesser General Public License as + * published by the Free Software Foundation; either version 2.1, + * or (at your option) any later version. + * + * librtmp is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU Lesser General Public License + * along with librtmp see the file COPYING. If not, write to + * the Free Software Foundation, Inc., 51 Franklin Street, Fifth Floor, + * Boston, MA 02110-1301, USA. + * http://www.gnu.org/copyleft/lgpl.html + */ + +/* from RFC 3526, see http://www.ietf.org/rfc/rfc3526.txt */ + +/* 2^768 - 2 ^704 - 1 + 2^64 * { [2^638 pi] + 149686 } */ +#define P768 \ + "FFFFFFFFFFFFFFFFC90FDAA22168C234C4C6628B80DC1CD1" \ + "29024E088A67CC74020BBEA63B139B22514A08798E3404DD" \ + "EF9519B3CD3A431B302B0A6DF25F14374FE1356D6D51C245" \ + "E485B576625E7EC6F44C42E9A63A3620FFFFFFFFFFFFFFFF" + +/* 2^1024 - 2^960 - 1 + 2^64 * { [2^894 pi] + 129093 } */ +#define P1024 \ + "FFFFFFFFFFFFFFFFC90FDAA22168C234C4C6628B80DC1CD1" \ + "29024E088A67CC74020BBEA63B139B22514A08798E3404DD" \ + "EF9519B3CD3A431B302B0A6DF25F14374FE1356D6D51C245" \ + "E485B576625E7EC6F44C42E9A637ED6B0BFF5CB6F406B7ED" \ + "EE386BFB5A899FA5AE9F24117C4B1FE649286651ECE65381" \ + "FFFFFFFFFFFFFFFF" + +/* Group morder largest prime factor: */ +#define Q1024 \ + "7FFFFFFFFFFFFFFFE487ED5110B4611A62633145C06E0E68" \ + "948127044533E63A0105DF531D89CD9128A5043CC71A026E" \ + "F7CA8CD9E69D218D98158536F92F8A1BA7F09AB6B6A8E122" \ + "F242DABB312F3F637A262174D31BF6B585FFAE5B7A035BF6" \ + "F71C35FDAD44CFD2D74F9208BE258FF324943328F67329C0" \ + "FFFFFFFFFFFFFFFF" + +/* 2^1536 - 2^1472 - 1 + 2^64 * { [2^1406 pi] + 741804 } */ +#define P1536 \ + "FFFFFFFFFFFFFFFFC90FDAA22168C234C4C6628B80DC1CD1" \ + "29024E088A67CC74020BBEA63B139B22514A08798E3404DD" \ + "EF9519B3CD3A431B302B0A6DF25F14374FE1356D6D51C245" \ + "E485B576625E7EC6F44C42E9A637ED6B0BFF5CB6F406B7ED" \ + "EE386BFB5A899FA5AE9F24117C4B1FE649286651ECE45B3D" \ + "C2007CB8A163BF0598DA48361C55D39A69163FA8FD24CF5F" \ + "83655D23DCA3AD961C62F356208552BB9ED529077096966D" \ + "670C354E4ABC9804F1746C08CA237327FFFFFFFFFFFFFFFF" + +/* 2^2048 - 2^1984 - 1 + 2^64 * { [2^1918 pi] + 124476 } */ +#define P2048 \ + "FFFFFFFFFFFFFFFFC90FDAA22168C234C4C6628B80DC1CD1" \ + "29024E088A67CC74020BBEA63B139B22514A08798E3404DD" \ + "EF9519B3CD3A431B302B0A6DF25F14374FE1356D6D51C245" \ + "E485B576625E7EC6F44C42E9A637ED6B0BFF5CB6F406B7ED" \ + "EE386BFB5A899FA5AE9F24117C4B1FE649286651ECE45B3D" \ + "C2007CB8A163BF0598DA48361C55D39A69163FA8FD24CF5F" \ + "83655D23DCA3AD961C62F356208552BB9ED529077096966D" \ + "670C354E4ABC9804F1746C08CA18217C32905E462E36CE3B" \ + "E39E772C180E86039B2783A2EC07A28FB5C55DF06F4C52C9" \ + "DE2BCBF6955817183995497CEA956AE515D2261898FA0510" \ + "15728E5A8AACAA68FFFFFFFFFFFFFFFF" + +/* 2^3072 - 2^3008 - 1 + 2^64 * { [2^2942 pi] + 1690314 } */ +#define P3072 \ + "FFFFFFFFFFFFFFFFC90FDAA22168C234C4C6628B80DC1CD1" \ + "29024E088A67CC74020BBEA63B139B22514A08798E3404DD" \ + "EF9519B3CD3A431B302B0A6DF25F14374FE1356D6D51C245" \ + "E485B576625E7EC6F44C42E9A637ED6B0BFF5CB6F406B7ED" \ + "EE386BFB5A899FA5AE9F24117C4B1FE649286651ECE45B3D" \ + "C2007CB8A163BF0598DA48361C55D39A69163FA8FD24CF5F" \ + "83655D23DCA3AD961C62F356208552BB9ED529077096966D" \ + "670C354E4ABC9804F1746C08CA18217C32905E462E36CE3B" \ + "E39E772C180E86039B2783A2EC07A28FB5C55DF06F4C52C9" \ + "DE2BCBF6955817183995497CEA956AE515D2261898FA0510" \ + "15728E5A8AAAC42DAD33170D04507A33A85521ABDF1CBA64" \ + "ECFB850458DBEF0A8AEA71575D060C7DB3970F85A6E1E4C7" \ + "ABF5AE8CDB0933D71E8C94E04A25619DCEE3D2261AD2EE6B" \ + "F12FFA06D98A0864D87602733EC86A64521F2B18177B200C" \ + "BBE117577A615D6C770988C0BAD946E208E24FA074E5AB31" \ + "43DB5BFCE0FD108E4B82D120A93AD2CAFFFFFFFFFFFFFFFF" + +/* 2^4096 - 2^4032 - 1 + 2^64 * { [2^3966 pi] + 240904 } */ +#define P4096 \ + "FFFFFFFFFFFFFFFFC90FDAA22168C234C4C6628B80DC1CD1" \ + "29024E088A67CC74020BBEA63B139B22514A08798E3404DD" \ + "EF9519B3CD3A431B302B0A6DF25F14374FE1356D6D51C245" \ + "E485B576625E7EC6F44C42E9A637ED6B0BFF5CB6F406B7ED" \ + "EE386BFB5A899FA5AE9F24117C4B1FE649286651ECE45B3D" \ + "C2007CB8A163BF0598DA48361C55D39A69163FA8FD24CF5F" \ + "83655D23DCA3AD961C62F356208552BB9ED529077096966D" \ + "670C354E4ABC9804F1746C08CA18217C32905E462E36CE3B" \ + "E39E772C180E86039B2783A2EC07A28FB5C55DF06F4C52C9" \ + "DE2BCBF6955817183995497CEA956AE515D2261898FA0510" \ + "15728E5A8AAAC42DAD33170D04507A33A85521ABDF1CBA64" \ + "ECFB850458DBEF0A8AEA71575D060C7DB3970F85A6E1E4C7" \ + "ABF5AE8CDB0933D71E8C94E04A25619DCEE3D2261AD2EE6B" \ + "F12FFA06D98A0864D87602733EC86A64521F2B18177B200C" \ + "BBE117577A615D6C770988C0BAD946E208E24FA074E5AB31" \ + "43DB5BFCE0FD108E4B82D120A92108011A723C12A787E6D7" \ + "88719A10BDBA5B2699C327186AF4E23C1A946834B6150BDA" \ + "2583E9CA2AD44CE8DBBBC2DB04DE8EF92E8EFC141FBECAA6" \ + "287C59474E6BC05D99B2964FA090C3A2233BA186515BE7ED" \ + "1F612970CEE2D7AFB81BDD762170481CD0069127D5B05AA9" \ + "93B4EA988D8FDDC186FFB7DC90A6C08F4DF435C934063199" \ + "FFFFFFFFFFFFFFFF" + +/* 2^6144 - 2^6080 - 1 + 2^64 * { [2^6014 pi] + 929484 } */ +#define P6144 \ + "FFFFFFFFFFFFFFFFC90FDAA22168C234C4C6628B80DC1CD1" \ + "29024E088A67CC74020BBEA63B139B22514A08798E3404DD" \ + "EF9519B3CD3A431B302B0A6DF25F14374FE1356D6D51C245" \ + "E485B576625E7EC6F44C42E9A637ED6B0BFF5CB6F406B7ED" \ + "EE386BFB5A899FA5AE9F24117C4B1FE649286651ECE45B3D" \ + "C2007CB8A163BF0598DA48361C55D39A69163FA8FD24CF5F" \ + "83655D23DCA3AD961C62F356208552BB9ED529077096966D" \ + "670C354E4ABC9804F1746C08CA18217C32905E462E36CE3B" \ + "E39E772C180E86039B2783A2EC07A28FB5C55DF06F4C52C9" \ + "DE2BCBF6955817183995497CEA956AE515D2261898FA0510" \ + "15728E5A8AAAC42DAD33170D04507A33A85521ABDF1CBA64" \ + "ECFB850458DBEF0A8AEA71575D060C7DB3970F85A6E1E4C7" \ + "ABF5AE8CDB0933D71E8C94E04A25619DCEE3D2261AD2EE6B" \ + "F12FFA06D98A0864D87602733EC86A64521F2B18177B200C" \ + "BBE117577A615D6C770988C0BAD946E208E24FA074E5AB31" \ + "43DB5BFCE0FD108E4B82D120A92108011A723C12A787E6D7" \ + "88719A10BDBA5B2699C327186AF4E23C1A946834B6150BDA" \ + "2583E9CA2AD44CE8DBBBC2DB04DE8EF92E8EFC141FBECAA6" \ + "287C59474E6BC05D99B2964FA090C3A2233BA186515BE7ED" \ + "1F612970CEE2D7AFB81BDD762170481CD0069127D5B05AA9" \ + "93B4EA988D8FDDC186FFB7DC90A6C08F4DF435C934028492" \ + "36C3FAB4D27C7026C1D4DCB2602646DEC9751E763DBA37BD" \ + "F8FF9406AD9E530EE5DB382F413001AEB06A53ED9027D831" \ + "179727B0865A8918DA3EDBEBCF9B14ED44CE6CBACED4BB1B" \ + "DB7F1447E6CC254B332051512BD7AF426FB8F401378CD2BF" \ + "5983CA01C64B92ECF032EA15D1721D03F482D7CE6E74FEF6" \ + "D55E702F46980C82B5A84031900B1C9E59E7C97FBEC7E8F3" \ + "23A97A7E36CC88BE0F1D45B7FF585AC54BD407B22B4154AA" \ + "CC8F6D7EBF48E1D814CC5ED20F8037E0A79715EEF29BE328" \ + "06A1D58BB7C5DA76F550AA3D8A1FBFF0EB19CCB1A313D55C" \ + "DA56C9EC2EF29632387FE8D76E3C0468043E8F663F4860EE" \ + "12BF2D5B0B7474D6E694F91E6DCC4024FFFFFFFFFFFFFFFF" + +/* 2^8192 - 2^8128 - 1 + 2^64 * { [2^8062 pi] + 4743158 } */ +#define P8192 \ + "FFFFFFFFFFFFFFFFC90FDAA22168C234C4C6628B80DC1CD1" \ + "29024E088A67CC74020BBEA63B139B22514A08798E3404DD" \ + "EF9519B3CD3A431B302B0A6DF25F14374FE1356D6D51C245" \ + "E485B576625E7EC6F44C42E9A637ED6B0BFF5CB6F406B7ED" \ + "EE386BFB5A899FA5AE9F24117C4B1FE649286651ECE45B3D" \ + "C2007CB8A163BF0598DA48361C55D39A69163FA8FD24CF5F" \ + "83655D23DCA3AD961C62F356208552BB9ED529077096966D" \ + "670C354E4ABC9804F1746C08CA18217C32905E462E36CE3B" \ + "E39E772C180E86039B2783A2EC07A28FB5C55DF06F4C52C9" \ + "DE2BCBF6955817183995497CEA956AE515D2261898FA0510" \ + "15728E5A8AAAC42DAD33170D04507A33A85521ABDF1CBA64" \ + "ECFB850458DBEF0A8AEA71575D060C7DB3970F85A6E1E4C7" \ + "ABF5AE8CDB0933D71E8C94E04A25619DCEE3D2261AD2EE6B" \ + "F12FFA06D98A0864D87602733EC86A64521F2B18177B200C" \ + "BBE117577A615D6C770988C0BAD946E208E24FA074E5AB31" \ + "43DB5BFCE0FD108E4B82D120A92108011A723C12A787E6D7" \ + "88719A10BDBA5B2699C327186AF4E23C1A946834B6150BDA" \ + "2583E9CA2AD44CE8DBBBC2DB04DE8EF92E8EFC141FBECAA6" \ + "287C59474E6BC05D99B2964FA090C3A2233BA186515BE7ED" \ + "1F612970CEE2D7AFB81BDD762170481CD0069127D5B05AA9" \ + "93B4EA988D8FDDC186FFB7DC90A6C08F4DF435C934028492" \ + "36C3FAB4D27C7026C1D4DCB2602646DEC9751E763DBA37BD" \ + "F8FF9406AD9E530EE5DB382F413001AEB06A53ED9027D831" \ + "179727B0865A8918DA3EDBEBCF9B14ED44CE6CBACED4BB1B" \ + "DB7F1447E6CC254B332051512BD7AF426FB8F401378CD2BF" \ + "5983CA01C64B92ECF032EA15D1721D03F482D7CE6E74FEF6" \ + "D55E702F46980C82B5A84031900B1C9E59E7C97FBEC7E8F3" \ + "23A97A7E36CC88BE0F1D45B7FF585AC54BD407B22B4154AA" \ + "CC8F6D7EBF48E1D814CC5ED20F8037E0A79715EEF29BE328" \ + "06A1D58BB7C5DA76F550AA3D8A1FBFF0EB19CCB1A313D55C" \ + "DA56C9EC2EF29632387FE8D76E3C0468043E8F663F4860EE" \ + "12BF2D5B0B7474D6E694F91E6DBE115974A3926F12FEE5E4" \ + "38777CB6A932DF8CD8BEC4D073B931BA3BC832B68D9DD300" \ + "741FA7BF8AFC47ED2576F6936BA424663AAB639C5AE4F568" \ + "3423B4742BF1C978238F16CBE39D652DE3FDB8BEFC848AD9" \ + "22222E04A4037C0713EB57A81A23F0C73473FC646CEA306B" \ + "4BCBC8862F8385DDFA9D4B7FA2C087E879683303ED5BDD3A" \ + "062B3CF5B3A278A66D2A13F83F44F82DDF310EE074AB6A36" \ + "4597E899A0255DC164F31CC50846851DF9AB48195DED7EA1" \ + "B1D510BD7EE74D73FAF36BC31ECFA268359046F4EB879F92" \ + "4009438B481C6CD7889A002ED5EE382BC9190DA6FC026E47" \ + "9558E4475677E9AA9E3050E2765694DFC81F56E880B96E71" \ + "60C980DD98EDD3DFFFFFFFFFFFFFFFFF" + diff --git a/app/src/main/cpp/third_party/librtmp/include/librtmp/handshake.h b/app/src/main/cpp/third_party/librtmp/include/librtmp/handshake.h new file mode 100644 index 0000000..ac38c88 --- /dev/null +++ b/app/src/main/cpp/third_party/librtmp/include/librtmp/handshake.h @@ -0,0 +1,1426 @@ +/* + * Copyright (C) 2008-2009 Andrej Stepanchuk + * Copyright (C) 2009-2010 Howard Chu + * Copyright (C) 2010 2a665470ced7adb7156fcef47f8199a6371c117b8a79e399a2771e0b36384090 + * + * This file is part of librtmp. + * + * librtmp is free software; you can redistribute it and/or modify + * it under the terms of the GNU Lesser General Public License as + * published by the Free Software Foundation; either version 2.1, + * or (at your option) any later version. + * + * librtmp is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU Lesser General Public License + * along with librtmp see the file COPYING. If not, write to + * the Free Software Foundation, Inc., 51 Franklin Street, Fifth Floor, + * Boston, MA 02110-1301, USA. + * http://www.gnu.org/copyleft/lgpl.html + */ + +/* This file is #included in rtmp.c, it is not meant to be compiled alone */ + +#ifdef USE_POLARSSL +#include +#include +#ifndef SHA256_DIGEST_LENGTH +#define SHA256_DIGEST_LENGTH 32 +#endif +#define HMAC_CTX sha2_context +#define HMAC_setup(ctx, key, len) sha2_hmac_starts(&ctx, (unsigned char *)key, len, 0) +#define HMAC_crunch(ctx, buf, len) sha2_hmac_update(&ctx, buf, len) +#define HMAC_finish(ctx, dig, dlen) dlen = SHA256_DIGEST_LENGTH; sha2_hmac_finish(&ctx, dig) + +typedef arc4_context * RC4_handle; +#define RC4_alloc(h) *h = malloc(sizeof(arc4_context)) +#define RC4_setkey(h,l,k) arc4_setup(h,k,l) +#define RC4_encrypt(h,l,d) arc4_crypt(h,l,(unsigned char *)d,(unsigned char *)d) +#define RC4_encrypt2(h,l,s,d) arc4_crypt(h,l,(unsigned char *)s,(unsigned char *)d) +#define RC4_free(h) free(h) + +#elif defined(USE_GNUTLS) +#include +#include +#ifndef SHA256_DIGEST_LENGTH +#define SHA256_DIGEST_LENGTH 32 +#endif +#undef HMAC_CTX +#define HMAC_CTX struct hmac_sha256_ctx +#define HMAC_setup(ctx, key, len) hmac_sha256_set_key(&ctx, len, key) +#define HMAC_crunch(ctx, buf, len) hmac_sha256_update(&ctx, len, buf) +#define HMAC_finish(ctx, dig, dlen) dlen = SHA256_DIGEST_LENGTH; hmac_sha256_digest(&ctx, SHA256_DIGEST_LENGTH, dig) +#define HMAC_close(ctx) + +typedef struct arcfour_ctx* RC4_handle; +#define RC4_alloc(h) *h = malloc(sizeof(struct arcfour_ctx)) +#define RC4_setkey(h,l,k) arcfour_set_key(h, l, k) +#define RC4_encrypt(h,l,d) arcfour_crypt(h,l,(uint8_t *)d,(uint8_t *)d) +#define RC4_encrypt2(h,l,s,d) arcfour_crypt(h,l,(uint8_t *)d,(uint8_t *)s) +#define RC4_free(h) free(h) + +#else /* USE_OPENSSL */ +#include +#include +#include +#if OPENSSL_VERSION_NUMBER < 0x0090800 || !defined(SHA256_DIGEST_LENGTH) +#error Your OpenSSL is too old, need 0.9.8 or newer with SHA256 +#endif +#if OPENSSL_VERSION_NUMBER >= 0x10100000 +#define HMAC_CTX HMAC_CTX * +#define HMAC_setup(ctx, key, len) ctx = HMAC_CTX_new(); HMAC_Init_ex(ctx, key, len, EVP_sha256(), 0) +#define HMAC_crunch(ctx, buf, len) HMAC_Update(ctx, buf, len) +#define HMAC_finish(ctx, dig, dlen) HMAC_Final(ctx, dig, &dlen); HMAC_CTX_free(ctx) +#else +#define HMAC_setup(ctx, key, len) HMAC_CTX_init(&ctx); HMAC_Init_ex(&ctx, key, len, EVP_sha256(), 0) +#define HMAC_crunch(ctx, buf, len) HMAC_Update(&ctx, buf, len) +#define HMAC_finish(ctx, dig, dlen) HMAC_Final(&ctx, dig, &dlen); HMAC_CTX_cleanup(&ctx) +#endif + +typedef RC4_KEY * RC4_handle; +#define RC4_alloc(h) *h = malloc(sizeof(RC4_KEY)) +#define RC4_setkey(h,l,k) RC4_set_key(h,l,k) +#define RC4_encrypt(h,l,d) RC4(h,l,(uint8_t *)d,(uint8_t *)d) +#define RC4_encrypt2(h,l,s,d) RC4(h,l,(uint8_t *)s,(uint8_t *)d) +#define RC4_free(h) free(h) +#endif + +#define FP10 + +#include "dh.h" + +static const uint8_t GenuineFMSKey[] = { + 0x47, 0x65, 0x6e, 0x75, 0x69, 0x6e, 0x65, 0x20, 0x41, 0x64, 0x6f, 0x62, + 0x65, 0x20, 0x46, 0x6c, + 0x61, 0x73, 0x68, 0x20, 0x4d, 0x65, 0x64, 0x69, 0x61, 0x20, 0x53, 0x65, + 0x72, 0x76, 0x65, 0x72, + 0x20, 0x30, 0x30, 0x31, /* Genuine Adobe Flash Media Server 001 */ + + 0xf0, 0xee, 0xc2, 0x4a, 0x80, 0x68, 0xbe, 0xe8, 0x2e, 0x00, 0xd0, 0xd1, + 0x02, 0x9e, 0x7e, 0x57, 0x6e, 0xec, 0x5d, 0x2d, 0x29, 0x80, 0x6f, 0xab, + 0x93, 0xb8, 0xe6, 0x36, + 0xcf, 0xeb, 0x31, 0xae +}; /* 68 */ + +static const uint8_t GenuineFPKey[] = { + 0x47, 0x65, 0x6E, 0x75, 0x69, 0x6E, 0x65, 0x20, 0x41, 0x64, 0x6F, 0x62, + 0x65, 0x20, 0x46, 0x6C, + 0x61, 0x73, 0x68, 0x20, 0x50, 0x6C, 0x61, 0x79, 0x65, 0x72, 0x20, 0x30, + 0x30, 0x31, /* Genuine Adobe Flash Player 001 */ + 0xF0, 0xEE, + 0xC2, 0x4A, 0x80, 0x68, 0xBE, 0xE8, 0x2E, 0x00, 0xD0, 0xD1, 0x02, 0x9E, + 0x7E, 0x57, 0x6E, 0xEC, + 0x5D, 0x2D, 0x29, 0x80, 0x6F, 0xAB, 0x93, 0xB8, 0xE6, 0x36, 0xCF, 0xEB, + 0x31, 0xAE +}; /* 62 */ + +static void InitRC4Encryption + (uint8_t * secretKey, + uint8_t * pubKeyIn, + uint8_t * pubKeyOut, RC4_handle *rc4keyIn, RC4_handle *rc4keyOut) +{ + uint8_t digest[SHA256_DIGEST_LENGTH]; + unsigned int digestLen = 0; + HMAC_CTX ctx; + + RC4_alloc(rc4keyIn); + RC4_alloc(rc4keyOut); + + HMAC_setup(ctx, secretKey, 128); + HMAC_crunch(ctx, pubKeyIn, 128); + HMAC_finish(ctx, digest, digestLen); + + RTMP_Log(RTMP_LOGDEBUG, "RC4 Out Key: "); + RTMP_LogHex(RTMP_LOGDEBUG, digest, 16); + + RC4_setkey(*rc4keyOut, 16, digest); + + HMAC_setup(ctx, secretKey, 128); + HMAC_crunch(ctx, pubKeyOut, 128); + HMAC_finish(ctx, digest, digestLen); + + RTMP_Log(RTMP_LOGDEBUG, "RC4 In Key: "); + RTMP_LogHex(RTMP_LOGDEBUG, digest, 16); + + RC4_setkey(*rc4keyIn, 16, digest); +} + +typedef unsigned int (getoff)(uint8_t *buf, unsigned int len); + +static unsigned int +GetDHOffset2(uint8_t *handshake, unsigned int len) +{ + unsigned int offset = 0; + uint8_t *ptr = handshake + 768; + unsigned int res; + + assert(RTMP_SIG_SIZE <= len); + + offset += (*ptr); + ptr++; + offset += (*ptr); + ptr++; + offset += (*ptr); + ptr++; + offset += (*ptr); + + res = (offset % 632) + 8; + + if (res + 128 > 767) + { + RTMP_Log(RTMP_LOGERROR, + "%s: Couldn't calculate correct DH offset (got %d), exiting!", + __FUNCTION__, res); + exit(1); + } + return res; +} + +static unsigned int +GetDigestOffset2(uint8_t *handshake, unsigned int len) +{ + unsigned int offset = 0; + uint8_t *ptr = handshake + 772; + unsigned int res; + + offset += (*ptr); + ptr++; + offset += (*ptr); + ptr++; + offset += (*ptr); + ptr++; + offset += (*ptr); + + res = (offset % 728) + 776; + + if (res + 32 > 1535) + { + RTMP_Log(RTMP_LOGERROR, + "%s: Couldn't calculate correct digest offset (got %d), exiting", + __FUNCTION__, res); + exit(1); + } + return res; +} + +static unsigned int +GetDHOffset1(uint8_t *handshake, unsigned int len) +{ + unsigned int offset = 0; + uint8_t *ptr = handshake + 1532; + unsigned int res; + + assert(RTMP_SIG_SIZE <= len); + + offset += (*ptr); + ptr++; + offset += (*ptr); + ptr++; + offset += (*ptr); + ptr++; + offset += (*ptr); + + res = (offset % 632) + 772; + + if (res + 128 > 1531) + { + RTMP_Log(RTMP_LOGERROR, "%s: Couldn't calculate DH offset (got %d), exiting!", + __FUNCTION__, res); + exit(1); + } + + return res; +} + +static unsigned int +GetDigestOffset1(uint8_t *handshake, unsigned int len) +{ + unsigned int offset = 0; + uint8_t *ptr = handshake + 8; + unsigned int res; + + assert(12 <= len); + + offset += (*ptr); + ptr++; + offset += (*ptr); + ptr++; + offset += (*ptr); + ptr++; + offset += (*ptr); + + res = (offset % 728) + 12; + + if (res + 32 > 771) + { + RTMP_Log(RTMP_LOGERROR, + "%s: Couldn't calculate digest offset (got %d), exiting!", + __FUNCTION__, res); + exit(1); + } + + return res; +} + +static getoff *digoff[] = {GetDigestOffset1, GetDigestOffset2}; +static getoff *dhoff[] = {GetDHOffset1, GetDHOffset2}; + +static void +HMACsha256(const uint8_t *message, size_t messageLen, const uint8_t *key, + size_t keylen, uint8_t *digest) +{ + unsigned int digestLen; + HMAC_CTX ctx; + + HMAC_setup(ctx, key, keylen); + HMAC_crunch(ctx, message, messageLen); + HMAC_finish(ctx, digest, digestLen); + + assert(digestLen == 32); +} + +static void +CalculateDigest(unsigned int digestPos, uint8_t *handshakeMessage, + const uint8_t *key, size_t keyLen, uint8_t *digest) +{ + const int messageLen = RTMP_SIG_SIZE - SHA256_DIGEST_LENGTH; + uint8_t message[RTMP_SIG_SIZE - SHA256_DIGEST_LENGTH]; + + memcpy(message, handshakeMessage, digestPos); + memcpy(message + digestPos, + &handshakeMessage[digestPos + SHA256_DIGEST_LENGTH], + messageLen - digestPos); + + HMACsha256(message, messageLen, key, keyLen, digest); +} + +static int +VerifyDigest(unsigned int digestPos, uint8_t *handshakeMessage, const uint8_t *key, + size_t keyLen) +{ + uint8_t calcDigest[SHA256_DIGEST_LENGTH]; + + CalculateDigest(digestPos, handshakeMessage, key, keyLen, calcDigest); + + return memcmp(&handshakeMessage[digestPos], calcDigest, + SHA256_DIGEST_LENGTH) == 0; +} + +/* handshake + * + * Type = [1 bytes] plain: 0x03, encrypted: 0x06, 0x08, 0x09 + * -------------------------------------------------------------------- [1536 bytes] + * Uptime = [4 bytes] big endian unsigned number, uptime + * Version = [4 bytes] each byte represents a version number, e.g. 9.0.124.0 + * ... + * + */ + +static const uint32_t rtmpe8_keys[16][4] = { + {0xbff034b2, 0x11d9081f, 0xccdfb795, 0x748de732}, + {0x086a5eb6, 0x1743090e, 0x6ef05ab8, 0xfe5a39e2}, + {0x7b10956f, 0x76ce0521, 0x2388a73a, 0x440149a1}, + {0xa943f317, 0xebf11bb2, 0xa691a5ee, 0x17f36339}, + {0x7a30e00a, 0xb529e22c, 0xa087aea5, 0xc0cb79ac}, + {0xbdce0c23, 0x2febdeff, 0x1cfaae16, 0x1123239d}, + {0x55dd3f7b, 0x77e7e62e, 0x9bb8c499, 0xc9481ee4}, + {0x407bb6b4, 0x71e89136, 0xa7aebf55, 0xca33b839}, + {0xfcf6bdc3, 0xb63c3697, 0x7ce4f825, 0x04d959b2}, + {0x28e091fd, 0x41954c4c, 0x7fb7db00, 0xe3a066f8}, + {0x57845b76, 0x4f251b03, 0x46d45bcd, 0xa2c30d29}, + {0x0acceef8, 0xda55b546, 0x03473452, 0x5863713b}, + {0xb82075dc, 0xa75f1fee, 0xd84268e8, 0xa72a44cc}, + {0x07cf6e9e, 0xa16d7b25, 0x9fa7ae6c, 0xd92f5629}, + {0xfeb1eae4, 0x8c8c3ce1, 0x4e0064a7, 0x6a387c2a}, + {0x893a9427, 0xcc3013a2, 0xf106385b, 0xa829f927} +}; + +/* RTMPE type 8 uses XTEA on the regular signature + * http://en.wikipedia.org/wiki/XTEA + */ +static void rtmpe8_sig(uint8_t *in, uint8_t *out, int keyid) +{ + unsigned int i, num_rounds = 32; + uint32_t v0, v1, sum=0, delta=0x9E3779B9; + uint32_t const *k; + + v0 = in[0] | (in[1] << 8) | (in[2] << 16) | (in[3] << 24); + v1 = in[4] | (in[5] << 8) | (in[6] << 16) | (in[7] << 24); + k = rtmpe8_keys[keyid]; + + for (i=0; i < num_rounds; i++) { + v0 += (((v1 << 4) ^ (v1 >> 5)) + v1) ^ (sum + k[sum & 3]); + sum += delta; + v1 += (((v0 << 4) ^ (v0 >> 5)) + v0) ^ (sum + k[(sum>>11) & 3]); + } + + out[0] = v0; v0 >>= 8; + out[1] = v0; v0 >>= 8; + out[2] = v0; v0 >>= 8; + out[3] = v0; + + out[4] = v1; v1 >>= 8; + out[5] = v1; v1 >>= 8; + out[6] = v1; v1 >>= 8; + out[7] = v1; +} + +/* RTMPE type 9 uses Blowfish on the regular signature + * http://en.wikipedia.org/wiki/Blowfish_(cipher) + */ +#define BF_ROUNDS 16 +typedef struct bf_key { + uint32_t s[4][256]; + uint32_t p[BF_ROUNDS+2]; +} bf_key; + +static const uint32_t bf_sinit[][256] = { + + /* S-Box 0 */ + { 0xd1310ba6, 0x98dfb5ac, 0x2ffd72db, 0xd01adfb7, 0xb8e1afed, 0x6a267e96, + 0xba7c9045, 0xf12c7f99, 0x24a19947, 0xb3916cf7, 0x0801f2e2, 0x858efc16, + 0x636920d8, 0x71574e69, 0xa458fea3, 0xf4933d7e, 0x0d95748f, 0x728eb658, + 0x718bcd58, 0x82154aee, 0x7b54a41d, 0xc25a59b5, 0x9c30d539, 0x2af26013, + 0xc5d1b023, 0x286085f0, 0xca417918, 0xb8db38ef, 0x8e79dcb0, 0x603a180e, + 0x6c9e0e8b, 0xb01e8a3e, 0xd71577c1, 0xbd314b27, 0x78af2fda, 0x55605c60, + 0xe65525f3, 0xaa55ab94, 0x57489862, 0x63e81440, 0x55ca396a, 0x2aab10b6, + 0xb4cc5c34, 0x1141e8ce, 0xa15486af, 0x7c72e993, 0xb3ee1411, 0x636fbc2a, + 0x2ba9c55d, 0x741831f6, 0xce5c3e16, 0x9b87931e, 0xafd6ba33, 0x6c24cf5c, + 0x7a325381, 0x28958677, 0x3b8f4898, 0x6b4bb9af, 0xc4bfe81b, 0x66282193, + 0x61d809cc, 0xfb21a991, 0x487cac60, 0x5dec8032, 0xef845d5d, 0xe98575b1, + 0xdc262302, 0xeb651b88, 0x23893e81, 0xd396acc5, 0x0f6d6ff3, 0x83f44239, + 0x2e0b4482, 0xa4842004, 0x69c8f04a, 0x9e1f9b5e, 0x21c66842, 0xf6e96c9a, + 0x670c9c61, 0xabd388f0, 0x6a51a0d2, 0xd8542f68, 0x960fa728, 0xab5133a3, + 0x6eef0b6c, 0x137a3be4, 0xba3bf050, 0x7efb2a98, 0xa1f1651d, 0x39af0176, + 0x66ca593e, 0x82430e88, 0x8cee8619, 0x456f9fb4, 0x7d84a5c3, 0x3b8b5ebe, + 0xe06f75d8, 0x85c12073, 0x401a449f, 0x56c16aa6, 0x4ed3aa62, 0x363f7706, + 0x1bfedf72, 0x429b023d, 0x37d0d724, 0xd00a1248, 0xdb0fead3, 0x49f1c09b, + 0x075372c9, 0x80991b7b, 0x25d479d8, 0xf6e8def7, 0xe3fe501a, 0xb6794c3b, + 0x976ce0bd, 0x04c006ba, 0xc1a94fb6, 0x409f60c4, 0x5e5c9ec2, 0x196a2463, + 0x68fb6faf, 0x3e6c53b5, 0x1339b2eb, 0x3b52ec6f, 0x6dfc511f, 0x9b30952c, + 0xcc814544, 0xaf5ebd09, 0xbee3d004, 0xde334afd, 0x660f2807, 0x192e4bb3, + 0xc0cba857, 0x45c8740f, 0xd20b5f39, 0xb9d3fbdb, 0x5579c0bd, 0x1a60320a, + 0xd6a100c6, 0x402c7279, 0x679f25fe, 0xfb1fa3cc, 0x8ea5e9f8, 0xdb3222f8, + 0x3c7516df, 0xfd616b15, 0x2f501ec8, 0xad0552ab, 0x323db5fa, 0xfd238760, + 0x53317b48, 0x3e00df82, 0x9e5c57bb, 0xca6f8ca0, 0x1a87562e, 0xdf1769db, + 0xd542a8f6, 0x287effc3, 0xac6732c6, 0x8c4f5573, 0x695b27b0, 0xbbca58c8, + 0xe1ffa35d, 0xb8f011a0, 0x10fa3d98, 0xfd2183b8, 0x4afcb56c, 0x2dd1d35b, + 0x9a53e479, 0xb6f84565, 0xd28e49bc, 0x4bfb9790, 0xe1ddf2da, 0xa4cb7e33, + 0x62fb1341, 0xcee4c6e8, 0xef20cada, 0x36774c01, 0xd07e9efe, 0x2bf11fb4, + 0x95dbda4d, 0xae909198, 0xeaad8e71, 0x6b93d5a0, 0xd08ed1d0, 0xafc725e0, + 0x8e3c5b2f, 0x8e7594b7, 0x8ff6e2fb, 0xf2122b64, 0x8888b812, 0x900df01c, + 0x4fad5ea0, 0x688fc31c, 0xd1cff191, 0xb3a8c1ad, 0x2f2f2218, 0xbe0e1777, + 0xea752dfe, 0x8b021fa1, 0xe5a0cc0f, 0xb56f74e8, 0x18acf3d6, 0xce89e299, + 0xb4a84fe0, 0xfd13e0b7, 0x7cc43b81, 0xd2ada8d9, 0x165fa266, 0x80957705, + 0x93cc7314, 0x211a1477, 0xe6ad2065, 0x77b5fa86, 0xc75442f5, 0xfb9d35cf, + 0xebcdaf0c, 0x7b3e89a0, 0xd6411bd3, 0xae1e7e49, 0x00250e2d, 0x2071b35e, + 0x226800bb, 0x57b8e0af, 0x2464369b, 0xf009b91e, 0x5563911d, 0x59dfa6aa, + 0x78c14389, 0xd95a537f, 0x207d5ba2, 0x02e5b9c5, 0x83260376, 0x6295cfa9, + 0x11c81968, 0x4e734a41, 0xb3472dca, 0x7b14a94a, 0x1b510052, 0x9a532915, + 0xd60f573f, 0xbc9bc6e4, 0x2b60a476, 0x81e67400, 0x08ba6fb5, 0x571be91f, + 0xf296ec6b, 0x2a0dd915, 0xb6636521, 0xe7b9f9b6, 0xff34052e, 0xc5855664, + 0x53b02d5d, 0xa99f8fa1, 0x08ba4799, 0x6e85076a, }, + + /* S-Box 1 */ + { 0x4b7a70e9, 0xb5b32944, 0xdb75092e, 0xc4192623, 0xad6ea6b0, 0x49a7df7d, + 0x9cee60b8, 0x8fedb266, 0xecaa8c71, 0x699a17ff, 0x5664526c, 0xc2b19ee1, + 0x193602a5, 0x75094c29, 0xa0591340, 0xe4183a3e, 0x3f54989a, 0x5b429d65, + 0x6b8fe4d6, 0x99f73fd6, 0xa1d29c07, 0xefe830f5, 0x4d2d38e6, 0xf0255dc1, + 0x4cdd2086, 0x8470eb26, 0x6382e9c6, 0x021ecc5e, 0x09686b3f, 0x3ebaefc9, + 0x3c971814, 0x6b6a70a1, 0x687f3584, 0x52a0e286, 0xb79c5305, 0xaa500737, + 0x3e07841c, 0x7fdeae5c, 0x8e7d44ec, 0x5716f2b8, 0xb03ada37, 0xf0500c0d, + 0xf01c1f04, 0x0200b3ff, 0xae0cf51a, 0x3cb574b2, 0x25837a58, 0xdc0921bd, + 0xd19113f9, 0x7ca92ff6, 0x94324773, 0x22f54701, 0x3ae5e581, 0x37c2dadc, + 0xc8b57634, 0x9af3dda7, 0xa9446146, 0x0fd0030e, 0xecc8c73e, 0xa4751e41, + 0xe238cd99, 0x3bea0e2f, 0x3280bba1, 0x183eb331, 0x4e548b38, 0x4f6db908, + 0x6f420d03, 0xf60a04bf, 0x2cb81290, 0x24977c79, 0x5679b072, 0xbcaf89af, + 0xde9a771f, 0xd9930810, 0xb38bae12, 0xdccf3f2e, 0x5512721f, 0x2e6b7124, + 0x501adde6, 0x9f84cd87, 0x7a584718, 0x7408da17, 0xbc9f9abc, 0xe94b7d8c, + 0xec7aec3a, 0xdb851dfa, 0x63094366, 0xc464c3d2, 0xef1c1847, 0x3215d908, + 0xdd433b37, 0x24c2ba16, 0x12a14d43, 0x2a65c451, 0x50940002, 0x133ae4dd, + 0x71dff89e, 0x10314e55, 0x81ac77d6, 0x5f11199b, 0x043556f1, 0xd7a3c76b, + 0x3c11183b, 0x5924a509, 0xf28fe6ed, 0x97f1fbfa, 0x9ebabf2c, 0x1e153c6e, + 0x86e34570, 0xeae96fb1, 0x860e5e0a, 0x5a3e2ab3, 0x771fe71c, 0x4e3d06fa, + 0x2965dcb9, 0x99e71d0f, 0x803e89d6, 0x5266c825, 0x2e4cc978, 0x9c10b36a, + 0xc6150eba, 0x94e2ea78, 0xa5fc3c53, 0x1e0a2df4, 0xf2f74ea7, 0x361d2b3d, + 0x1939260f, 0x19c27960, 0x5223a708, 0xf71312b6, 0xebadfe6e, 0xeac31f66, + 0xe3bc4595, 0xa67bc883, 0xb17f37d1, 0x018cff28, 0xc332ddef, 0xbe6c5aa5, + 0x65582185, 0x68ab9802, 0xeecea50f, 0xdb2f953b, 0x2aef7dad, 0x5b6e2f84, + 0x1521b628, 0x29076170, 0xecdd4775, 0x619f1510, 0x13cca830, 0xeb61bd96, + 0x0334fe1e, 0xaa0363cf, 0xb5735c90, 0x4c70a239, 0xd59e9e0b, 0xcbaade14, + 0xeecc86bc, 0x60622ca7, 0x9cab5cab, 0xb2f3846e, 0x648b1eaf, 0x19bdf0ca, + 0xa02369b9, 0x655abb50, 0x40685a32, 0x3c2ab4b3, 0x319ee9d5, 0xc021b8f7, + 0x9b540b19, 0x875fa099, 0x95f7997e, 0x623d7da8, 0xf837889a, 0x97e32d77, + 0x11ed935f, 0x16681281, 0x0e358829, 0xc7e61fd6, 0x96dedfa1, 0x7858ba99, + 0x57f584a5, 0x1b227263, 0x9b83c3ff, 0x1ac24696, 0xcdb30aeb, 0x532e3054, + 0x8fd948e4, 0x6dbc3128, 0x58ebf2ef, 0x34c6ffea, 0xfe28ed61, 0xee7c3c73, + 0x5d4a14d9, 0xe864b7e3, 0x42105d14, 0x203e13e0, 0x45eee2b6, 0xa3aaabea, + 0xdb6c4f15, 0xfacb4fd0, 0xc742f442, 0xef6abbb5, 0x654f3b1d, 0x41cd2105, + 0xd81e799e, 0x86854dc7, 0xe44b476a, 0x3d816250, 0xcf62a1f2, 0x5b8d2646, + 0xfc8883a0, 0xc1c7b6a3, 0x7f1524c3, 0x69cb7492, 0x47848a0b, 0x5692b285, + 0x095bbf00, 0xad19489d, 0x1462b174, 0x23820e00, 0x58428d2a, 0x0c55f5ea, + 0x1dadf43e, 0x233f7061, 0x3372f092, 0x8d937e41, 0xd65fecf1, 0x6c223bdb, + 0x7cde3759, 0xcbee7460, 0x4085f2a7, 0xce77326e, 0xa6078084, 0x19f8509e, + 0xe8efd855, 0x61d99735, 0xa969a7aa, 0xc50c06c2, 0x5a04abfc, 0x800bcadc, + 0x9e447a2e, 0xc3453484, 0xfdd56705, 0x0e1e9ec9, 0xdb73dbd3, 0x105588cd, + 0x675fda79, 0xe3674340, 0xc5c43465, 0x713e38d8, 0x3d28f89e, 0xf16dff20, + 0x153e21e7, 0x8fb03d4a, 0xe6e39f2b, 0xdb83adf7, }, + + /* S-Box 2 */ + { 0xe93d5a68, 0x948140f7, 0xf64c261c, 0x94692934, 0x411520f7, 0x7602d4f7, + 0xbcf46b2e, 0xd4a20068, 0xd4082471, 0x3320f46a, 0x43b7d4b7, 0x500061af, + 0x1e39f62e, 0x97244546, 0x14214f74, 0xbf8b8840, 0x4d95fc1d, 0x96b591af, + 0x70f4ddd3, 0x66a02f45, 0xbfbc09ec, 0x03bd9785, 0x7fac6dd0, 0x31cb8504, + 0x96eb27b3, 0x55fd3941, 0xda2547e6, 0xabca0a9a, 0x28507825, 0x530429f4, + 0x0a2c86da, 0xe9b66dfb, 0x68dc1462, 0xd7486900, 0x680ec0a4, 0x27a18dee, + 0x4f3ffea2, 0xe887ad8c, 0xb58ce006, 0x7af4d6b6, 0xaace1e7c, 0xd3375fec, + 0xce78a399, 0x406b2a42, 0x20fe9e35, 0xd9f385b9, 0xee39d7ab, 0x3b124e8b, + 0x1dc9faf7, 0x4b6d1856, 0x26a36631, 0xeae397b2, 0x3a6efa74, 0xdd5b4332, + 0x6841e7f7, 0xca7820fb, 0xfb0af54e, 0xd8feb397, 0x454056ac, 0xba489527, + 0x55533a3a, 0x20838d87, 0xfe6ba9b7, 0xd096954b, 0x55a867bc, 0xa1159a58, + 0xcca92963, 0x99e1db33, 0xa62a4a56, 0x3f3125f9, 0x5ef47e1c, 0x9029317c, + 0xfdf8e802, 0x04272f70, 0x80bb155c, 0x05282ce3, 0x95c11548, 0xe4c66d22, + 0x48c1133f, 0xc70f86dc, 0x07f9c9ee, 0x41041f0f, 0x404779a4, 0x5d886e17, + 0x325f51eb, 0xd59bc0d1, 0xf2bcc18f, 0x41113564, 0x257b7834, 0x602a9c60, + 0xdff8e8a3, 0x1f636c1b, 0x0e12b4c2, 0x02e1329e, 0xaf664fd1, 0xcad18115, + 0x6b2395e0, 0x333e92e1, 0x3b240b62, 0xeebeb922, 0x85b2a20e, 0xe6ba0d99, + 0xde720c8c, 0x2da2f728, 0xd0127845, 0x95b794fd, 0x647d0862, 0xe7ccf5f0, + 0x5449a36f, 0x877d48fa, 0xc39dfd27, 0xf33e8d1e, 0x0a476341, 0x992eff74, + 0x3a6f6eab, 0xf4f8fd37, 0xa812dc60, 0xa1ebddf8, 0x991be14c, 0xdb6e6b0d, + 0xc67b5510, 0x6d672c37, 0x2765d43b, 0xdcd0e804, 0xf1290dc7, 0xcc00ffa3, + 0xb5390f92, 0x690fed0b, 0x667b9ffb, 0xcedb7d9c, 0xa091cf0b, 0xd9155ea3, + 0xbb132f88, 0x515bad24, 0x7b9479bf, 0x763bd6eb, 0x37392eb3, 0xcc115979, + 0x8026e297, 0xf42e312d, 0x6842ada7, 0xc66a2b3b, 0x12754ccc, 0x782ef11c, + 0x6a124237, 0xb79251e7, 0x06a1bbe6, 0x4bfb6350, 0x1a6b1018, 0x11caedfa, + 0x3d25bdd8, 0xe2e1c3c9, 0x44421659, 0x0a121386, 0xd90cec6e, 0xd5abea2a, + 0x64af674e, 0xda86a85f, 0xbebfe988, 0x64e4c3fe, 0x9dbc8057, 0xf0f7c086, + 0x60787bf8, 0x6003604d, 0xd1fd8346, 0xf6381fb0, 0x7745ae04, 0xd736fccc, + 0x83426b33, 0xf01eab71, 0xb0804187, 0x3c005e5f, 0x77a057be, 0xbde8ae24, + 0x55464299, 0xbf582e61, 0x4e58f48f, 0xf2ddfda2, 0xf474ef38, 0x8789bdc2, + 0x5366f9c3, 0xc8b38e74, 0xb475f255, 0x46fcd9b9, 0x7aeb2661, 0x8b1ddf84, + 0x846a0e79, 0x915f95e2, 0x466e598e, 0x20b45770, 0x8cd55591, 0xc902de4c, + 0xb90bace1, 0xbb8205d0, 0x11a86248, 0x7574a99e, 0xb77f19b6, 0xe0a9dc09, + 0x662d09a1, 0xc4324633, 0xe85a1f02, 0x09f0be8c, 0x4a99a025, 0x1d6efe10, + 0x1ab93d1d, 0x0ba5a4df, 0xa186f20f, 0x2868f169, 0xdcb7da83, 0x573906fe, + 0xa1e2ce9b, 0x4fcd7f52, 0x50115e01, 0xa70683fa, 0xa002b5c4, 0x0de6d027, + 0x9af88c27, 0x773f8641, 0xc3604c06, 0x61a806b5, 0xf0177a28, 0xc0f586e0, + 0x006058aa, 0x30dc7d62, 0x11e69ed7, 0x2338ea63, 0x53c2dd94, 0xc2c21634, + 0xbbcbee56, 0x90bcb6de, 0xebfc7da1, 0xce591d76, 0x6f05e409, 0x4b7c0188, + 0x39720a3d, 0x7c927c24, 0x86e3725f, 0x724d9db9, 0x1ac15bb4, 0xd39eb8fc, + 0xed545578, 0x08fca5b5, 0xd83d7cd3, 0x4dad0fc4, 0x1e50ef5e, 0xb161e6f8, + 0xa28514d9, 0x6c51133c, 0x6fd5c7e7, 0x56e14ec4, 0x362abfce, 0xddc6c837, + 0xd79a3234, 0x92638212, 0x670efa8e, 0x406000e0, }, + + /* S-Box 3 */ + { 0x3a39ce37, 0xd3faf5cf, 0xabc27737, 0x5ac52d1b, 0x5cb0679e, 0x4fa33742, + 0xd3822740, 0x99bc9bbe, 0xd5118e9d, 0xbf0f7315, 0xd62d1c7e, 0xc700c47b, + 0xb78c1b6b, 0x21a19045, 0xb26eb1be, 0x6a366eb4, 0x5748ab2f, 0xbc946e79, + 0xc6a376d2, 0x6549c2c8, 0x530ff8ee, 0x468dde7d, 0xd5730a1d, 0x4cd04dc6, + 0x2939bbdb, 0xa9ba4650, 0xac9526e8, 0xbe5ee304, 0xa1fad5f0, 0x6a2d519a, + 0x63ef8ce2, 0x9a86ee22, 0xc089c2b8, 0x43242ef6, 0xa51e03aa, 0x9cf2d0a4, + 0x83c061ba, 0x9be96a4d, 0x8fe51550, 0xba645bd6, 0x2826a2f9, 0xa73a3ae1, + 0x4ba99586, 0xef5562e9, 0xc72fefd3, 0xf752f7da, 0x3f046f69, 0x77fa0a59, + 0x80e4a915, 0x87b08601, 0x9b09e6ad, 0x3b3ee593, 0xe990fd5a, 0x9e34d797, + 0x2cf0b7d9, 0x022b8b51, 0x96d5ac3a, 0x017da67d, 0xd1cf3ed6, 0x7c7d2d28, + 0x1f9f25cf, 0xadf2b89b, 0x5ad6b472, 0x5a88f54c, 0xe029ac71, 0xe019a5e6, + 0x47b0acfd, 0xed93fa9b, 0xe8d3c48d, 0x283b57cc, 0xf8d56629, 0x79132e28, + 0x785f0191, 0xed756055, 0xf7960e44, 0xe3d35e8c, 0x15056dd4, 0x88f46dba, + 0x03a16125, 0x0564f0bd, 0xc3eb9e15, 0x3c9057a2, 0x97271aec, 0xa93a072a, + 0x1b3f6d9b, 0x1e6321f5, 0xf59c66fb, 0x26dcf319, 0x7533d928, 0xb155fdf5, + 0x03563482, 0x8aba3cbb, 0x28517711, 0xc20ad9f8, 0xabcc5167, 0xccad925f, + 0x4de81751, 0x3830dc8e, 0x379d5862, 0x9320f991, 0xea7a90c2, 0xfb3e7bce, + 0x5121ce64, 0x774fbe32, 0xa8b6e37e, 0xc3293d46, 0x48de5369, 0x6413e680, + 0xa2ae0810, 0xdd6db224, 0x69852dfd, 0x09072166, 0xb39a460a, 0x6445c0dd, + 0x586cdecf, 0x1c20c8ae, 0x5bbef7dd, 0x1b588d40, 0xccd2017f, 0x6bb4e3bb, + 0xdda26a7e, 0x3a59ff45, 0x3e350a44, 0xbcb4cdd5, 0x72eacea8, 0xfa6484bb, + 0x8d6612ae, 0xbf3c6f47, 0xd29be463, 0x542f5d9e, 0xaec2771b, 0xf64e6370, + 0x740e0d8d, 0xe75b1357, 0xf8721671, 0xaf537d5d, 0x4040cb08, 0x4eb4e2cc, + 0x34d2466a, 0x0115af84, 0xe1b00428, 0x95983a1d, 0x06b89fb4, 0xce6ea048, + 0x6f3f3b82, 0x3520ab82, 0x011a1d4b, 0x277227f8, 0x611560b1, 0xe7933fdc, + 0xbb3a792b, 0x344525bd, 0xa08839e1, 0x51ce794b, 0x2f32c9b7, 0xa01fbac9, + 0xe01cc87e, 0xbcc7d1f6, 0xcf0111c3, 0xa1e8aac7, 0x1a908749, 0xd44fbd9a, + 0xd0dadecb, 0xd50ada38, 0x0339c32a, 0xc6913667, 0x8df9317c, 0xe0b12b4f, + 0xf79e59b7, 0x43f5bb3a, 0xf2d519ff, 0x27d9459c, 0xbf97222c, 0x15e6fc2a, + 0x0f91fc71, 0x9b941525, 0xfae59361, 0xceb69ceb, 0xc2a86459, 0x12baa8d1, + 0xb6c1075e, 0xe3056a0c, 0x10d25065, 0xcb03a442, 0xe0ec6e0e, 0x1698db3b, + 0x4c98a0be, 0x3278e964, 0x9f1f9532, 0xe0d392df, 0xd3a0342b, 0x8971f21e, + 0x1b0a7441, 0x4ba3348c, 0xc5be7120, 0xc37632d8, 0xdf359f8d, 0x9b992f2e, + 0xe60b6f47, 0x0fe3f11d, 0xe54cda54, 0x1edad891, 0xce6279cf, 0xcd3e7e6f, + 0x1618b166, 0xfd2c1d05, 0x848fd2c5, 0xf6fb2299, 0xf523f357, 0xa6327623, + 0x93a83531, 0x56cccd02, 0xacf08162, 0x5a75ebb5, 0x6e163697, 0x88d273cc, + 0xde966292, 0x81b949d0, 0x4c50901b, 0x71c65614, 0xe6c6c7bd, 0x327a140a, + 0x45e1d006, 0xc3f27b9a, 0xc9aa53fd, 0x62a80f00, 0xbb25bfe2, 0x35bdd2f6, + 0x71126905, 0xb2040222, 0xb6cbcf7c, 0xcd769c2b, 0x53113ec0, 0x1640e3d3, + 0x38abbd60, 0x2547adf0, 0xba38209c, 0xf746ce76, 0x77afa1c5, 0x20756060, + 0x85cbfe4e, 0x8ae88dd8, 0x7aaaf9b0, 0x4cf9aa7e, 0x1948c25c, 0x02fb8a8c, + 0x01c36ae4, 0xd6ebe1f9, 0x90d4f869, 0xa65cdea0, 0x3f09252d, 0xc208e69f, + 0xb74e6132, 0xce77e25b, 0x578fdfe3, 0x3ac372e6, }, +}; + +static const uint32_t bf_pinit[] = { + /* P-Box */ + 0x243f6a88, 0x85a308d3, 0x13198a2e, 0x03707344, 0xa4093822, 0x299f31d0, + 0x082efa98, 0xec4e6c89, 0x452821e6, 0x38d01377, 0xbe5466cf, 0x34e90c6c, + 0xc0ac29b7, 0xc97c50dd, 0x3f84d5b5, 0xb5470917, 0x9216d5d9, 0x8979fb1b, +}; + +#define KEYBYTES 24 + +static const unsigned char rtmpe9_keys[16][KEYBYTES] = { + { 0x79, 0x34, 0x77, 0x4c, 0x67, 0xd1, 0x38, 0x3a, 0xdf, 0xb3, 0x56, 0xbe, + 0x8b, 0x7b, 0xd0, 0x24, 0x38, 0xe0, 0x73, 0x58, 0x41, 0x5d, 0x69, 0x67, }, + { 0x46, 0xf6, 0xb4, 0xcc, 0x01, 0x93, 0xe3, 0xa1, 0x9e, 0x7d, 0x3c, 0x65, + 0x55, 0x86, 0xfd, 0x09, 0x8f, 0xf7, 0xb3, 0xc4, 0x6f, 0x41, 0xca, 0x5c, }, + { 0x1a, 0xe7, 0xe2, 0xf3, 0xf9, 0x14, 0x79, 0x94, 0xc0, 0xd3, 0x97, 0x43, + 0x08, 0x7b, 0xb3, 0x84, 0x43, 0x2f, 0x9d, 0x84, 0x3f, 0x21, 0x01, 0x9b, }, + { 0xd3, 0xe3, 0x54, 0xb0, 0xf7, 0x1d, 0xf6, 0x2b, 0x5a, 0x43, 0x4d, 0x04, + 0x83, 0x64, 0x3e, 0x0d, 0x59, 0x2f, 0x61, 0xcb, 0xb1, 0x6a, 0x59, 0x0d, }, + { 0xc8, 0xc1, 0xe9, 0xb8, 0x16, 0x56, 0x99, 0x21, 0x7b, 0x5b, 0x36, 0xb7, + 0xb5, 0x9b, 0xdf, 0x06, 0x49, 0x2c, 0x97, 0xf5, 0x95, 0x48, 0x85, 0x7e, }, + { 0xeb, 0xe5, 0xe6, 0x2e, 0xa4, 0xba, 0xd4, 0x2c, 0xf2, 0x16, 0xe0, 0x8f, + 0x66, 0x23, 0xa9, 0x43, 0x41, 0xce, 0x38, 0x14, 0x84, 0x95, 0x00, 0x53, }, + { 0x66, 0xdb, 0x90, 0xf0, 0x3b, 0x4f, 0xf5, 0x6f, 0xe4, 0x9c, 0x20, 0x89, + 0x35, 0x5e, 0xd2, 0xb2, 0xc3, 0x9e, 0x9f, 0x7f, 0x63, 0xb2, 0x28, 0x81, }, + { 0xbb, 0x20, 0xac, 0xed, 0x2a, 0x04, 0x6a, 0x19, 0x94, 0x98, 0x9b, 0xc8, + 0xff, 0xcd, 0x93, 0xef, 0xc6, 0x0d, 0x56, 0xa7, 0xeb, 0x13, 0xd9, 0x30, }, + { 0xbc, 0xf2, 0x43, 0x82, 0x09, 0x40, 0x8a, 0x87, 0x25, 0x43, 0x6d, 0xe6, + 0xbb, 0xa4, 0xb9, 0x44, 0x58, 0x3f, 0x21, 0x7c, 0x99, 0xbb, 0x3f, 0x24, }, + { 0xec, 0x1a, 0xaa, 0xcd, 0xce, 0xbd, 0x53, 0x11, 0xd2, 0xfb, 0x83, 0xb6, + 0xc3, 0xba, 0xab, 0x4f, 0x62, 0x79, 0xe8, 0x65, 0xa9, 0x92, 0x28, 0x76, }, + { 0xc6, 0x0c, 0x30, 0x03, 0x91, 0x18, 0x2d, 0x7b, 0x79, 0xda, 0xe1, 0xd5, + 0x64, 0x77, 0x9a, 0x12, 0xc5, 0xb1, 0xd7, 0x91, 0x4f, 0x96, 0x4c, 0xa3, }, + { 0xd7, 0x7c, 0x2a, 0xbf, 0xa6, 0xe7, 0x85, 0x7c, 0x45, 0xad, 0xff, 0x12, + 0x94, 0xd8, 0xde, 0xa4, 0x5c, 0x3d, 0x79, 0xa4, 0x44, 0x02, 0x5d, 0x22, }, + { 0x16, 0x19, 0x0d, 0x81, 0x6a, 0x4c, 0xc7, 0xf8, 0xb8, 0xf9, 0x4e, 0xcd, + 0x2c, 0x9e, 0x90, 0x84, 0xb2, 0x08, 0x25, 0x60, 0xe1, 0x1e, 0xae, 0x18, }, + { 0xe9, 0x7c, 0x58, 0x26, 0x1b, 0x51, 0x9e, 0x49, 0x82, 0x60, 0x61, 0xfc, + 0xa0, 0xa0, 0x1b, 0xcd, 0xf5, 0x05, 0xd6, 0xa6, 0x6d, 0x07, 0x88, 0xa3, }, + { 0x2b, 0x97, 0x11, 0x8b, 0xd9, 0x4e, 0xd9, 0xdf, 0x20, 0xe3, 0x9c, 0x10, + 0xe6, 0xa1, 0x35, 0x21, 0x11, 0xf9, 0x13, 0x0d, 0x0b, 0x24, 0x65, 0xb2, }, + { 0x53, 0x6a, 0x4c, 0x54, 0xac, 0x8b, 0x9b, 0xb8, 0x97, 0x29, 0xfc, 0x60, + 0x2c, 0x5b, 0x3a, 0x85, 0x68, 0xb5, 0xaa, 0x6a, 0x44, 0xcd, 0x3f, 0xa7, }, +}; + +#define BF_ENC(X,S) \ + (((S[0][X>>24] + S[1][X>>16 & 0xff]) ^ S[2][(X>>8) & 0xff]) + S[3][X & 0xff]) + +static void bf_enc(uint32_t *x, bf_key *key) +{ + uint32_t Xl; + uint32_t Xr; + uint32_t temp; + int i; + + Xl = x[0]; + Xr = x[1]; + + for (i = 0; i < BF_ROUNDS; ++i) { + Xl ^= key->p[i]; + Xr ^= BF_ENC(Xl,key->s); + + temp = Xl; + Xl = Xr; + Xr = temp; + } + + Xl ^= key->p[BF_ROUNDS]; + Xr ^= key->p[BF_ROUNDS + 1]; + + x[0] = Xr; + x[1] = Xl; +} + +static void bf_setkey(const unsigned char *kp, int keybytes, bf_key *key) +{ + int i; + int j; + int k; + uint32_t data; + uint32_t d[2]; + + memcpy(key->p, bf_pinit, sizeof(key->p)); + memcpy(key->s, bf_sinit, sizeof(key->s)); + + j = 0; + for (i = 0; i < BF_ROUNDS + 2; ++i) { + data = 0x00000000; + for (k = 0; k < 4; ++k) { + data = (data << 8) | kp[j]; + j = j + 1; + if (j >= keybytes) { + j = 0; + } + } + key->p[i] ^= data; + } + + d[0] = 0x00000000; + d[1] = 0x00000000; + + for (i = 0; i < BF_ROUNDS + 2; i += 2) { + bf_enc(d, key); + + key->p[i] = d[0]; + key->p[i + 1] = d[1]; + } + + for (i = 0; i < 4; ++i) { + for (j = 0; j < 256; j += 2) { + + bf_enc(d, key); + + key->s[i][j] = d[0]; + key->s[i][j + 1] = d[1]; + } + } +} + +static void rtmpe9_sig(uint8_t *in, uint8_t *out, int keyid) +{ + uint32_t d[2]; + bf_key key; + + bf_setkey(rtmpe9_keys[keyid], KEYBYTES, &key); + + /* input is little-endian */ + d[0] = in[0] | (in[1] << 8) | (in[2] << 16) | (in[3] << 24); + d[1] = in[4] | (in[5] << 8) | (in[6] << 16) | (in[7] << 24); + bf_enc(d, &key); + out[0] = d[0] & 0xff; + out[1] = (d[0] >> 8) & 0xff; + out[2] = (d[0] >> 16) & 0xff; + out[3] = (d[0] >> 24) & 0xff; + out[4] = d[1] & 0xff; + out[5] = (d[1] >> 8) & 0xff; + out[6] = (d[1] >> 16) & 0xff; + out[7] = (d[1] >> 24) & 0xff; +} + +static int +HandShake(RTMP * r, int FP9HandShake) +{ + int i, offalg = 0; + int dhposClient = 0; + int digestPosClient = 0; + int encrypted = r->Link.protocol & RTMP_FEATURE_ENC; + + RC4_handle keyIn = 0; + RC4_handle keyOut = 0; + + int32_t *ip; + uint32_t uptime; + + uint8_t clientbuf[RTMP_SIG_SIZE + 4], *clientsig=clientbuf+4; + uint8_t serversig[RTMP_SIG_SIZE], client2[RTMP_SIG_SIZE], *reply; + uint8_t type; + getoff *getdh = NULL, *getdig = NULL; + + if (encrypted || r->Link.SWFSize) + FP9HandShake = TRUE; + else + FP9HandShake = FALSE; + + r->Link.rc4keyIn = r->Link.rc4keyOut = 0; + + if (encrypted) + { + clientsig[-1] = 0x06; /* 0x08 is RTMPE as well */ + offalg = 1; + } + else + clientsig[-1] = 0x03; + + uptime = htonl(RTMP_GetTime()); + memcpy(clientsig, &uptime, 4); + + if (FP9HandShake) + { + /* set version to at least 9.0.115.0 */ + if (encrypted) + { + clientsig[4] = 128; + clientsig[6] = 3; + } + else + { + clientsig[4] = 10; + clientsig[6] = 45; + } + clientsig[5] = 0; + clientsig[7] = 2; + + RTMP_Log(RTMP_LOGDEBUG, "%s: Client type: %02X", __FUNCTION__, clientsig[-1]); + getdig = digoff[offalg]; + getdh = dhoff[offalg]; + } + else + { + memset(&clientsig[4], 0, 4); + } + + /* generate random data */ +#ifdef _DEBUG + memset(clientsig+8, 0, RTMP_SIG_SIZE-8); +#else + ip = (int32_t *)(clientsig+8); + for (i = 2; i < RTMP_SIG_SIZE/4; i++) + *ip++ = rand(); +#endif + + /* set handshake digest */ + if (FP9HandShake) + { + if (encrypted) + { + /* generate Diffie-Hellmann parameters */ + r->Link.dh = DHInit(1024); + if (!r->Link.dh) + { + RTMP_Log(RTMP_LOGERROR, "%s: Couldn't initialize Diffie-Hellmann!", + __FUNCTION__); + return FALSE; + } + + dhposClient = getdh(clientsig, RTMP_SIG_SIZE); + RTMP_Log(RTMP_LOGDEBUG, "%s: DH pubkey position: %d", __FUNCTION__, dhposClient); + + if (!DHGenerateKey(r->Link.dh)) + { + RTMP_Log(RTMP_LOGERROR, "%s: Couldn't generate Diffie-Hellmann public key!", + __FUNCTION__); + return FALSE; + } + + if (!DHGetPublicKey(r->Link.dh, &clientsig[dhposClient], 128)) + { + RTMP_Log(RTMP_LOGERROR, "%s: Couldn't write public key!", __FUNCTION__); + return FALSE; + } + } + + digestPosClient = getdig(clientsig, RTMP_SIG_SIZE); /* reuse this value in verification */ + RTMP_Log(RTMP_LOGDEBUG, "%s: Client digest offset: %d", __FUNCTION__, + digestPosClient); + + CalculateDigest(digestPosClient, clientsig, GenuineFPKey, 30, + &clientsig[digestPosClient]); + + RTMP_Log(RTMP_LOGDEBUG, "%s: Initial client digest: ", __FUNCTION__); + RTMP_LogHex(RTMP_LOGDEBUG, clientsig + digestPosClient, + SHA256_DIGEST_LENGTH); + } + +#ifdef _DEBUG + RTMP_Log(RTMP_LOGDEBUG, "Clientsig: "); + RTMP_LogHex(RTMP_LOGDEBUG, clientsig, RTMP_SIG_SIZE); +#endif + + if (!WriteN(r, (char *)clientsig-1, RTMP_SIG_SIZE + 1)) + return FALSE; + + if (ReadN(r, (char *)&type, 1) != 1) /* 0x03 or 0x06 */ + return FALSE; + + RTMP_Log(RTMP_LOGDEBUG, "%s: Type Answer : %02X", __FUNCTION__, type); + + if (type != clientsig[-1]) + RTMP_Log(RTMP_LOGWARNING, "%s: Type mismatch: client sent %d, server answered %d", + __FUNCTION__, clientsig[-1], type); + + if (ReadN(r, (char *)serversig, RTMP_SIG_SIZE) != RTMP_SIG_SIZE) + return FALSE; + + /* decode server response */ + memcpy(&uptime, serversig, 4); + uptime = ntohl(uptime); + + RTMP_Log(RTMP_LOGDEBUG, "%s: Server Uptime : %d", __FUNCTION__, uptime); + RTMP_Log(RTMP_LOGDEBUG, "%s: FMS Version : %d.%d.%d.%d", __FUNCTION__, serversig[4], + serversig[5], serversig[6], serversig[7]); + + if (FP9HandShake && type == 3 && !serversig[4]) + FP9HandShake = FALSE; + +#ifdef _DEBUG + RTMP_Log(RTMP_LOGDEBUG, "Server signature:"); + RTMP_LogHex(RTMP_LOGDEBUG, serversig, RTMP_SIG_SIZE); +#endif + + if (FP9HandShake) + { + uint8_t digestResp[SHA256_DIGEST_LENGTH]; + uint8_t *signatureResp = NULL; + + /* we have to use this signature now to find the correct algorithms for getting the digest and DH positions */ + int digestPosServer = getdig(serversig, RTMP_SIG_SIZE); + + if (!VerifyDigest(digestPosServer, serversig, GenuineFMSKey, 36)) + { + RTMP_Log(RTMP_LOGWARNING, "Trying different position for server digest!"); + offalg ^= 1; + getdig = digoff[offalg]; + getdh = dhoff[offalg]; + digestPosServer = getdig(serversig, RTMP_SIG_SIZE); + + if (!VerifyDigest(digestPosServer, serversig, GenuineFMSKey, 36)) + { + RTMP_Log(RTMP_LOGERROR, "Couldn't verify the server digest"); /* continuing anyway will probably fail */ + return FALSE; + } + } + + /* generate SWFVerification token (SHA256 HMAC hash of decompressed SWF, key are the last 32 bytes of the server handshake) */ + if (r->Link.SWFSize) + { + const char swfVerify[] = { 0x01, 0x01 }; + char *vend = r->Link.SWFVerificationResponse+sizeof(r->Link.SWFVerificationResponse); + + memcpy(r->Link.SWFVerificationResponse, swfVerify, 2); + AMF_EncodeInt32(&r->Link.SWFVerificationResponse[2], vend, r->Link.SWFSize); + AMF_EncodeInt32(&r->Link.SWFVerificationResponse[6], vend, r->Link.SWFSize); + HMACsha256(r->Link.SWFHash, SHA256_DIGEST_LENGTH, + &serversig[RTMP_SIG_SIZE - SHA256_DIGEST_LENGTH], + SHA256_DIGEST_LENGTH, + (uint8_t *)&r->Link.SWFVerificationResponse[10]); + } + + /* do Diffie-Hellmann Key exchange for encrypted RTMP */ + if (encrypted) + { + /* compute secret key */ + uint8_t secretKey[128] = { 0 }; + int len, dhposServer; + + dhposServer = getdh(serversig, RTMP_SIG_SIZE); + RTMP_Log(RTMP_LOGDEBUG, "%s: Server DH public key offset: %d", __FUNCTION__, + dhposServer); + len = DHComputeSharedSecretKey(r->Link.dh, &serversig[dhposServer], + 128, secretKey); + if (len < 0) + { + RTMP_Log(RTMP_LOGDEBUG, "%s: Wrong secret key position!", __FUNCTION__); + return FALSE; + } + + RTMP_Log(RTMP_LOGDEBUG, "%s: Secret key: ", __FUNCTION__); + RTMP_LogHex(RTMP_LOGDEBUG, secretKey, 128); + + InitRC4Encryption(secretKey, + (uint8_t *) & serversig[dhposServer], + (uint8_t *) & clientsig[dhposClient], + &keyIn, &keyOut); + } + + + reply = client2; +#ifdef _DEBUG + memset(reply, 0xff, RTMP_SIG_SIZE); +#else + ip = (int32_t *)reply; + for (i = 0; i < RTMP_SIG_SIZE/4; i++) + *ip++ = rand(); +#endif + /* calculate response now */ + signatureResp = reply+RTMP_SIG_SIZE-SHA256_DIGEST_LENGTH; + + HMACsha256(&serversig[digestPosServer], SHA256_DIGEST_LENGTH, + GenuineFPKey, sizeof(GenuineFPKey), digestResp); + HMACsha256(reply, RTMP_SIG_SIZE - SHA256_DIGEST_LENGTH, digestResp, + SHA256_DIGEST_LENGTH, signatureResp); + + /* some info output */ + RTMP_Log(RTMP_LOGDEBUG, + "%s: Calculated digest key from secure key and server digest: ", + __FUNCTION__); + RTMP_LogHex(RTMP_LOGDEBUG, digestResp, SHA256_DIGEST_LENGTH); + +#ifdef FP10 + if (type == 8 ) + { + uint8_t *dptr = digestResp; + uint8_t *sig = signatureResp; + /* encrypt signatureResp */ + for (i=0; iLink.rc4keyIn = keyIn; + r->Link.rc4keyOut = keyOut; + + + /* update the keystreams */ + if (r->Link.rc4keyIn) + { + RC4_encrypt(r->Link.rc4keyIn, RTMP_SIG_SIZE, (uint8_t *) buff); + } + + if (r->Link.rc4keyOut) + { + RC4_encrypt(r->Link.rc4keyOut, RTMP_SIG_SIZE, (uint8_t *) buff); + } + } + } + else + { + if (memcmp(serversig, clientsig, RTMP_SIG_SIZE) != 0) + { + RTMP_Log(RTMP_LOGWARNING, "%s: client signature does not match!", + __FUNCTION__); + } + } + + RTMP_Log(RTMP_LOGDEBUG, "%s: Handshaking finished....", __FUNCTION__); + return TRUE; +} + +static int +SHandShake(RTMP * r) +{ + int i, offalg = 0; + int dhposServer = 0; + int digestPosServer = 0; + RC4_handle keyIn = 0; + RC4_handle keyOut = 0; + int FP9HandShake = FALSE; + int encrypted; + int32_t *ip; + + uint8_t clientsig[RTMP_SIG_SIZE]; + uint8_t serverbuf[RTMP_SIG_SIZE + 4], *serversig = serverbuf+4; + uint8_t type; + uint32_t uptime; + getoff *getdh = NULL, *getdig = NULL; + + if (ReadN(r, (char *)&type, 1) != 1) /* 0x03 or 0x06 */ + return FALSE; + + if (ReadN(r, (char *)clientsig, RTMP_SIG_SIZE) != RTMP_SIG_SIZE) + return FALSE; + + RTMP_Log(RTMP_LOGDEBUG, "%s: Type Requested : %02X", __FUNCTION__, type); + RTMP_LogHex(RTMP_LOGDEBUG2, clientsig, RTMP_SIG_SIZE); + + if (type == 3) + { + encrypted = FALSE; + } + else if (type == 6 || type == 8) + { + offalg = 1; + encrypted = TRUE; + FP9HandShake = TRUE; + r->Link.protocol |= RTMP_FEATURE_ENC; + /* use FP10 if client is capable */ + if (clientsig[4] == 128) + type = 8; + } + else + { + RTMP_Log(RTMP_LOGERROR, "%s: Unknown version %02x", + __FUNCTION__, type); + return FALSE; + } + + if (!FP9HandShake && clientsig[4]) + FP9HandShake = TRUE; + + serversig[-1] = type; + + r->Link.rc4keyIn = r->Link.rc4keyOut = 0; + + uptime = htonl(RTMP_GetTime()); + memcpy(serversig, &uptime, 4); + + if (FP9HandShake) + { + /* Server version */ + serversig[4] = 3; + serversig[5] = 5; + serversig[6] = 1; + serversig[7] = 1; + + getdig = digoff[offalg]; + getdh = dhoff[offalg]; + } + else + { + memset(&serversig[4], 0, 4); + } + + /* generate random data */ +#ifdef _DEBUG + memset(serversig+8, 0, RTMP_SIG_SIZE-8); +#else + ip = (int32_t *)(serversig+8); + for (i = 2; i < RTMP_SIG_SIZE/4; i++) + *ip++ = rand(); +#endif + + /* set handshake digest */ + if (FP9HandShake) + { + if (encrypted) + { + /* generate Diffie-Hellmann parameters */ + r->Link.dh = DHInit(1024); + if (!r->Link.dh) + { + RTMP_Log(RTMP_LOGERROR, "%s: Couldn't initialize Diffie-Hellmann!", + __FUNCTION__); + return FALSE; + } + + dhposServer = getdh(serversig, RTMP_SIG_SIZE); + RTMP_Log(RTMP_LOGDEBUG, "%s: DH pubkey position: %d", __FUNCTION__, dhposServer); + + if (!DHGenerateKey(r->Link.dh)) + { + RTMP_Log(RTMP_LOGERROR, "%s: Couldn't generate Diffie-Hellmann public key!", + __FUNCTION__); + return FALSE; + } + + if (!DHGetPublicKey + (r->Link.dh, (uint8_t *) &serversig[dhposServer], 128)) + { + RTMP_Log(RTMP_LOGERROR, "%s: Couldn't write public key!", __FUNCTION__); + return FALSE; + } + } + + digestPosServer = getdig(serversig, RTMP_SIG_SIZE); /* reuse this value in verification */ + RTMP_Log(RTMP_LOGDEBUG, "%s: Server digest offset: %d", __FUNCTION__, + digestPosServer); + + CalculateDigest(digestPosServer, serversig, GenuineFMSKey, 36, + &serversig[digestPosServer]); + + RTMP_Log(RTMP_LOGDEBUG, "%s: Initial server digest: ", __FUNCTION__); + RTMP_LogHex(RTMP_LOGDEBUG, serversig + digestPosServer, + SHA256_DIGEST_LENGTH); + } + + RTMP_Log(RTMP_LOGDEBUG2, "Serversig: "); + RTMP_LogHex(RTMP_LOGDEBUG2, serversig, RTMP_SIG_SIZE); + + if (!WriteN(r, (char *)serversig-1, RTMP_SIG_SIZE + 1)) + return FALSE; + + /* decode client response */ + memcpy(&uptime, clientsig, 4); + uptime = ntohl(uptime); + + RTMP_Log(RTMP_LOGDEBUG, "%s: Client Uptime : %d", __FUNCTION__, uptime); + RTMP_Log(RTMP_LOGDEBUG, "%s: Player Version: %d.%d.%d.%d", __FUNCTION__, clientsig[4], + clientsig[5], clientsig[6], clientsig[7]); + + if (FP9HandShake) + { + uint8_t digestResp[SHA256_DIGEST_LENGTH]; + uint8_t *signatureResp = NULL; + + /* we have to use this signature now to find the correct algorithms for getting the digest and DH positions */ + int digestPosClient = getdig(clientsig, RTMP_SIG_SIZE); + + if (!VerifyDigest(digestPosClient, clientsig, GenuineFPKey, 30)) + { + RTMP_Log(RTMP_LOGWARNING, "Trying different position for client digest!"); + offalg ^= 1; + getdig = digoff[offalg]; + getdh = dhoff[offalg]; + + digestPosClient = getdig(clientsig, RTMP_SIG_SIZE); + + if (!VerifyDigest(digestPosClient, clientsig, GenuineFPKey, 30)) + { + RTMP_Log(RTMP_LOGERROR, "Couldn't verify the client digest"); /* continuing anyway will probably fail */ + return FALSE; + } + } + + /* generate SWFVerification token (SHA256 HMAC hash of decompressed SWF, key are the last 32 bytes of the server handshake) */ + if (r->Link.SWFSize) + { + const char swfVerify[] = { 0x01, 0x01 }; + char *vend = r->Link.SWFVerificationResponse+sizeof(r->Link.SWFVerificationResponse); + + memcpy(r->Link.SWFVerificationResponse, swfVerify, 2); + AMF_EncodeInt32(&r->Link.SWFVerificationResponse[2], vend, r->Link.SWFSize); + AMF_EncodeInt32(&r->Link.SWFVerificationResponse[6], vend, r->Link.SWFSize); + HMACsha256(r->Link.SWFHash, SHA256_DIGEST_LENGTH, + &serversig[RTMP_SIG_SIZE - SHA256_DIGEST_LENGTH], + SHA256_DIGEST_LENGTH, + (uint8_t *)&r->Link.SWFVerificationResponse[10]); + } + + /* do Diffie-Hellmann Key exchange for encrypted RTMP */ + if (encrypted) + { + int dhposClient, len; + /* compute secret key */ + uint8_t secretKey[128] = { 0 }; + + dhposClient = getdh(clientsig, RTMP_SIG_SIZE); + RTMP_Log(RTMP_LOGDEBUG, "%s: Client DH public key offset: %d", __FUNCTION__, + dhposClient); + len = + DHComputeSharedSecretKey(r->Link.dh, + (uint8_t *) &clientsig[dhposClient], 128, + secretKey); + if (len < 0) + { + RTMP_Log(RTMP_LOGDEBUG, "%s: Wrong secret key position!", __FUNCTION__); + return FALSE; + } + + RTMP_Log(RTMP_LOGDEBUG, "%s: Secret key: ", __FUNCTION__); + RTMP_LogHex(RTMP_LOGDEBUG, secretKey, 128); + + InitRC4Encryption(secretKey, + (uint8_t *) &clientsig[dhposClient], + (uint8_t *) &serversig[dhposServer], + &keyIn, &keyOut); + } + + + /* calculate response now */ + signatureResp = clientsig+RTMP_SIG_SIZE-SHA256_DIGEST_LENGTH; + + HMACsha256(&clientsig[digestPosClient], SHA256_DIGEST_LENGTH, + GenuineFMSKey, sizeof(GenuineFMSKey), digestResp); + HMACsha256(clientsig, RTMP_SIG_SIZE - SHA256_DIGEST_LENGTH, digestResp, + SHA256_DIGEST_LENGTH, signatureResp); +#ifdef FP10 + if (type == 8 ) + { + uint8_t *dptr = digestResp; + uint8_t *sig = signatureResp; + /* encrypt signatureResp */ + for (i=0; iLink.rc4keyIn = keyIn; + r->Link.rc4keyOut = keyOut; + + /* update the keystreams */ + if (r->Link.rc4keyIn) + { + RC4_encrypt(r->Link.rc4keyIn, RTMP_SIG_SIZE, (uint8_t *) buff); + } + + if (r->Link.rc4keyOut) + { + RC4_encrypt(r->Link.rc4keyOut, RTMP_SIG_SIZE, (uint8_t *) buff); + } + } + } + else + { + if (memcmp(serversig, clientsig, RTMP_SIG_SIZE) != 0) + { + RTMP_Log(RTMP_LOGWARNING, "%s: client signature does not match!", + __FUNCTION__); + } + } + + RTMP_Log(RTMP_LOGDEBUG, "%s: Handshaking finished....", __FUNCTION__); + return TRUE; +} diff --git a/app/src/main/cpp/third_party/librtmp/include/librtmp/http.h b/app/src/main/cpp/third_party/librtmp/include/librtmp/http.h new file mode 100644 index 0000000..cf3d903 --- /dev/null +++ b/app/src/main/cpp/third_party/librtmp/include/librtmp/http.h @@ -0,0 +1,47 @@ +#ifndef __RTMP_HTTP_H__ +#define __RTMP_HTTP_H__ +/* + * Copyright (C) 2010 Howard Chu + * Copyright (C) 2010 Antti Ajanki + * + * This file is part of librtmp. + * + * librtmp is free software; you can redistribute it and/or modify + * it under the terms of the GNU Lesser General Public License as + * published by the Free Software Foundation; either version 2.1, + * or (at your option) any later version. + * + * librtmp is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU Lesser General Public License + * along with librtmp see the file COPYING. If not, write to + * the Free Software Foundation, Inc., 51 Franklin Street, Fifth Floor, + * Boston, MA 02110-1301, USA. + * http://www.gnu.org/copyleft/lgpl.html + */ + +typedef enum { + HTTPRES_OK, /* result OK */ + HTTPRES_OK_NOT_MODIFIED, /* not modified since last request */ + HTTPRES_NOT_FOUND, /* not found */ + HTTPRES_BAD_REQUEST, /* client error */ + HTTPRES_SERVER_ERROR, /* server reported an error */ + HTTPRES_REDIRECTED, /* resource has been moved */ + HTTPRES_LOST_CONNECTION /* connection lost while waiting for data */ +} HTTPResult; + +struct HTTP_ctx { + char *date; + int size; + int status; + void *data; +}; + +typedef size_t (HTTP_read_callback)(void *ptr, size_t size, size_t nmemb, void *stream); + +HTTPResult HTTP_get(struct HTTP_ctx *http, const char *url, HTTP_read_callback *cb); + +#endif diff --git a/app/src/main/cpp/third_party/librtmp/include/librtmp/log.h b/app/src/main/cpp/third_party/librtmp/include/librtmp/log.h new file mode 100644 index 0000000..2adb111 --- /dev/null +++ b/app/src/main/cpp/third_party/librtmp/include/librtmp/log.h @@ -0,0 +1,69 @@ +/* + * Copyright (C) 2008-2009 Andrej Stepanchuk + * Copyright (C) 2009-2010 Howard Chu + * + * This file is part of librtmp. + * + * librtmp is free software; you can redistribute it and/or modify + * it under the terms of the GNU Lesser General Public License as + * published by the Free Software Foundation; either version 2.1, + * or (at your option) any later version. + * + * librtmp is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU Lesser General Public License + * along with librtmp see the file COPYING. If not, write to + * the Free Software Foundation, Inc., 51 Franklin Street, Fifth Floor, + * Boston, MA 02110-1301, USA. + * http://www.gnu.org/copyleft/lgpl.html + */ + +#ifndef __RTMP_LOG_H__ +#define __RTMP_LOG_H__ + +#include +#include +#include + +#ifdef __cplusplus +extern "C" { +#endif +/* Enable this to get full debugging output */ +/* #define _DEBUG */ + +#ifdef _DEBUG +#undef NODEBUG +#endif + +typedef enum +{ RTMP_LOGCRIT=0, RTMP_LOGERROR, RTMP_LOGWARNING, RTMP_LOGINFO, + RTMP_LOGDEBUG, RTMP_LOGDEBUG2, RTMP_LOGALL +} RTMP_LogLevel; + +extern RTMP_LogLevel RTMP_debuglevel; + +typedef void (RTMP_LogCallback)(int level, const char *fmt, va_list); +void RTMP_LogSetCallback(RTMP_LogCallback *cb); +void RTMP_LogSetOutput(FILE *file); +#ifdef __GNUC__ +void RTMP_LogPrintf(const char *format, ...) __attribute__ ((__format__ (__printf__, 1, 2))); +void RTMP_LogStatus(const char *format, ...) __attribute__ ((__format__ (__printf__, 1, 2))); +void RTMP_Log(int level, const char *format, ...) __attribute__ ((__format__ (__printf__, 2, 3))); +#else +void RTMP_LogPrintf(const char *format, ...); +void RTMP_LogStatus(const char *format, ...); +void RTMP_Log(int level, const char *format, ...); +#endif +void RTMP_LogHex(int level, const uint8_t *data, unsigned long len); +void RTMP_LogHexString(int level, const uint8_t *data, unsigned long len); +void RTMP_LogSetLevel(RTMP_LogLevel lvl); +RTMP_LogLevel RTMP_LogGetLevel(void); + +#ifdef __cplusplus +} +#endif + +#endif diff --git a/app/src/main/cpp/third_party/librtmp/include/librtmp/rtmp.h b/app/src/main/cpp/third_party/librtmp/include/librtmp/rtmp.h new file mode 100644 index 0000000..6d7dd89 --- /dev/null +++ b/app/src/main/cpp/third_party/librtmp/include/librtmp/rtmp.h @@ -0,0 +1,378 @@ +#ifndef __RTMP_H__ +#define __RTMP_H__ +/* + * Copyright (C) 2005-2008 Team XBMC + * http://www.xbmc.org + * Copyright (C) 2008-2009 Andrej Stepanchuk + * Copyright (C) 2009-2010 Howard Chu + * + * This file is part of librtmp. + * + * librtmp is free software; you can redistribute it and/or modify + * it under the terms of the GNU Lesser General Public License as + * published by the Free Software Foundation; either version 2.1, + * or (at your option) any later version. + * + * librtmp is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU Lesser General Public License + * along with librtmp see the file COPYING. If not, write to + * the Free Software Foundation, Inc., 51 Franklin Street, Fifth Floor, + * Boston, MA 02110-1301, USA. + * http://www.gnu.org/copyleft/lgpl.html + */ + +#if !defined(NO_CRYPTO) && !defined(CRYPTO) +#define CRYPTO +#endif + +#include +#include +#include + +#include "amf.h" + +#ifdef __cplusplus +extern "C" +{ +#endif + +#define RTMP_LIB_VERSION 0x020300 /* 2.3 */ + +#define RTMP_FEATURE_HTTP 0x01 +#define RTMP_FEATURE_ENC 0x02 +#define RTMP_FEATURE_SSL 0x04 +#define RTMP_FEATURE_MFP 0x08 /* not yet supported */ +#define RTMP_FEATURE_WRITE 0x10 /* publish, not play */ +#define RTMP_FEATURE_HTTP2 0x20 /* server-side rtmpt */ + +#define RTMP_PROTOCOL_UNDEFINED -1 +#define RTMP_PROTOCOL_RTMP 0 +#define RTMP_PROTOCOL_RTMPE RTMP_FEATURE_ENC +#define RTMP_PROTOCOL_RTMPT RTMP_FEATURE_HTTP +#define RTMP_PROTOCOL_RTMPS RTMP_FEATURE_SSL +#define RTMP_PROTOCOL_RTMPTE (RTMP_FEATURE_HTTP|RTMP_FEATURE_ENC) +#define RTMP_PROTOCOL_RTMPTS (RTMP_FEATURE_HTTP|RTMP_FEATURE_SSL) +#define RTMP_PROTOCOL_RTMFP RTMP_FEATURE_MFP + +#define RTMP_DEFAULT_CHUNKSIZE 128 + +/* needs to fit largest number of bytes recv() may return */ +#define RTMP_BUFFER_CACHE_SIZE (16*1024) + +#define RTMP_CHANNELS 65600 + + extern const char RTMPProtocolStringsLower[][7]; + extern const AVal RTMP_DefaultFlashVer; + extern int RTMP_ctrlC; + + uint32_t RTMP_GetTime(void); + +/* RTMP_PACKET_TYPE_... 0x00 */ +#define RTMP_PACKET_TYPE_CHUNK_SIZE 0x01 +/* RTMP_PACKET_TYPE_... 0x02 */ +#define RTMP_PACKET_TYPE_BYTES_READ_REPORT 0x03 +#define RTMP_PACKET_TYPE_CONTROL 0x04 +#define RTMP_PACKET_TYPE_SERVER_BW 0x05 +#define RTMP_PACKET_TYPE_CLIENT_BW 0x06 +/* RTMP_PACKET_TYPE_... 0x07 */ +#define RTMP_PACKET_TYPE_AUDIO 0x08 +#define RTMP_PACKET_TYPE_VIDEO 0x09 +/* RTMP_PACKET_TYPE_... 0x0A */ +/* RTMP_PACKET_TYPE_... 0x0B */ +/* RTMP_PACKET_TYPE_... 0x0C */ +/* RTMP_PACKET_TYPE_... 0x0D */ +/* RTMP_PACKET_TYPE_... 0x0E */ +#define RTMP_PACKET_TYPE_FLEX_STREAM_SEND 0x0F +#define RTMP_PACKET_TYPE_FLEX_SHARED_OBJECT 0x10 +#define RTMP_PACKET_TYPE_FLEX_MESSAGE 0x11 +#define RTMP_PACKET_TYPE_INFO 0x12 +#define RTMP_PACKET_TYPE_SHARED_OBJECT 0x13 +#define RTMP_PACKET_TYPE_INVOKE 0x14 +/* RTMP_PACKET_TYPE_... 0x15 */ +#define RTMP_PACKET_TYPE_FLASH_VIDEO 0x16 + +#define RTMP_MAX_HEADER_SIZE 18 + +#define RTMP_PACKET_SIZE_LARGE 0 +#define RTMP_PACKET_SIZE_MEDIUM 1 +#define RTMP_PACKET_SIZE_SMALL 2 +#define RTMP_PACKET_SIZE_MINIMUM 3 + + typedef struct RTMPChunk + { + int c_headerSize; + int c_chunkSize; + char *c_chunk; + char c_header[RTMP_MAX_HEADER_SIZE]; + } RTMPChunk; + + typedef struct RTMPPacket + { + uint8_t m_headerType; + uint8_t m_packetType; + uint8_t m_hasAbsTimestamp; /* timestamp absolute or relative? */ + int m_nChannel; + uint32_t m_nTimeStamp; /* timestamp */ + int32_t m_nInfoField2; /* last 4 bytes in a long header */ + uint32_t m_nBodySize; + uint32_t m_nBytesRead; + RTMPChunk *m_chunk; + char *m_body; + } RTMPPacket; + + typedef struct RTMPSockBuf + { + int sb_socket; + int sb_size; /* number of unprocessed bytes in buffer */ + char *sb_start; /* pointer into sb_pBuffer of next byte to process */ + char sb_buf[RTMP_BUFFER_CACHE_SIZE]; /* data read from socket */ + int sb_timedout; + void *sb_ssl; + } RTMPSockBuf; + + void RTMPPacket_Reset(RTMPPacket *p); + void RTMPPacket_Dump(RTMPPacket *p); + int RTMPPacket_Alloc(RTMPPacket *p, uint32_t nSize); + void RTMPPacket_Free(RTMPPacket *p); + +#define RTMPPacket_IsReady(a) ((a)->m_nBytesRead == (a)->m_nBodySize) + + typedef struct RTMP_LNK + { + AVal hostname; + AVal sockshost; + + AVal playpath0; /* parsed from URL */ + AVal playpath; /* passed in explicitly */ + AVal tcUrl; + AVal swfUrl; + AVal pageUrl; + AVal app; + AVal auth; + AVal flashVer; + AVal subscribepath; + AVal usherToken; + AVal token; + AVal pubUser; + AVal pubPasswd; + AMFObject extras; + int edepth; + + int seekTime; + int stopTime; + +#define RTMP_LF_AUTH 0x0001 /* using auth param */ +#define RTMP_LF_LIVE 0x0002 /* stream is live */ +#define RTMP_LF_SWFV 0x0004 /* do SWF verification */ +#define RTMP_LF_PLST 0x0008 /* send playlist before play */ +#define RTMP_LF_BUFX 0x0010 /* toggle stream on BufferEmpty msg */ +#define RTMP_LF_FTCU 0x0020 /* free tcUrl on close */ +#define RTMP_LF_FAPU 0x0040 /* free app on close */ + int lFlags; + + int swfAge; + + int protocol; + int timeout; /* connection timeout in seconds */ + + int pFlags; /* unused, but kept to avoid breaking ABI */ + + unsigned short socksport; + unsigned short port; + +#ifdef CRYPTO +#define RTMP_SWF_HASHLEN 32 + void *dh; /* for encryption */ + void *rc4keyIn; + void *rc4keyOut; + + uint32_t SWFSize; + uint8_t SWFHash[RTMP_SWF_HASHLEN]; + char SWFVerificationResponse[RTMP_SWF_HASHLEN+10]; +#endif + } RTMP_LNK; + + /* state for read() wrapper */ + typedef struct RTMP_READ + { + char *buf; + char *bufpos; + unsigned int buflen; + uint32_t timestamp; + uint8_t dataType; + uint8_t flags; +#define RTMP_READ_HEADER 0x01 +#define RTMP_READ_RESUME 0x02 +#define RTMP_READ_NO_IGNORE 0x04 +#define RTMP_READ_GOTKF 0x08 +#define RTMP_READ_GOTFLVK 0x10 +#define RTMP_READ_SEEKING 0x20 + int8_t status; +#define RTMP_READ_COMPLETE -3 +#define RTMP_READ_ERROR -2 +#define RTMP_READ_EOF -1 +#define RTMP_READ_IGNORE 0 + + /* if bResume == TRUE */ + uint8_t initialFrameType; + uint32_t nResumeTS; + char *metaHeader; + char *initialFrame; + uint32_t nMetaHeaderSize; + uint32_t nInitialFrameSize; + uint32_t nIgnoredFrameCounter; + uint32_t nIgnoredFlvFrameCounter; + } RTMP_READ; + + typedef struct RTMP_METHOD + { + AVal name; + int num; + } RTMP_METHOD; + + typedef struct RTMP + { + int m_inChunkSize; + int m_outChunkSize; + int m_nBWCheckCounter; + int m_nBytesIn; + int m_nBytesInSent; + int m_nBufferMS; + int m_stream_id; /* returned in _result from createStream */ + int m_mediaChannel; + uint32_t m_mediaStamp; + uint32_t m_pauseStamp; + int m_pausing; + int m_nServerBW; + int m_nClientBW; + uint8_t m_nClientBW2; + uint8_t m_bPlaying; + uint8_t m_bSendEncoding; + uint8_t m_bSendCounter; + + int m_numInvokes; + int m_numCalls; + RTMP_METHOD *m_methodCalls; /* remote method calls queue */ + + int m_channelsAllocatedIn; + int m_channelsAllocatedOut; + RTMPPacket **m_vecChannelsIn; + RTMPPacket **m_vecChannelsOut; + int *m_channelTimestamp; /* abs timestamp of last packet */ + + double m_fAudioCodecs; /* audioCodecs for the connect packet */ + double m_fVideoCodecs; /* videoCodecs for the connect packet */ + double m_fEncoding; /* AMF0 or AMF3 */ + + double m_fDuration; /* duration of stream in seconds */ + + int m_msgCounter; /* RTMPT stuff */ + int m_polling; + int m_resplen; + int m_unackd; + AVal m_clientID; + + RTMP_READ m_read; + RTMPPacket m_write; + RTMPSockBuf m_sb; + RTMP_LNK Link; + } RTMP; + + int RTMP_ParseURL(const char *url, int *protocol, AVal *host, + unsigned int *port, AVal *playpath, AVal *app); + + void RTMP_ParsePlaypath(AVal *in, AVal *out); + void RTMP_SetBufferMS(RTMP *r, int size); + void RTMP_UpdateBufferMS(RTMP *r); + + int RTMP_SetOpt(RTMP *r, const AVal *opt, AVal *arg); + int RTMP_SetupURL(RTMP *r, char *url); + void RTMP_SetupStream(RTMP *r, int protocol, + AVal *hostname, + unsigned int port, + AVal *sockshost, + AVal *playpath, + AVal *tcUrl, + AVal *swfUrl, + AVal *pageUrl, + AVal *app, + AVal *auth, + AVal *swfSHA256Hash, + uint32_t swfSize, + AVal *flashVer, + AVal *subscribepath, + AVal *usherToken, + int dStart, + int dStop, int bLiveStream, long int timeout); + + int RTMP_Connect(RTMP *r, RTMPPacket *cp); + struct sockaddr; + int RTMP_Connect0(RTMP *r, struct sockaddr *svc); + int RTMP_Connect1(RTMP *r, RTMPPacket *cp); + int RTMP_Serve(RTMP *r); + int RTMP_TLS_Accept(RTMP *r, void *ctx); + + int RTMP_ReadPacket(RTMP *r, RTMPPacket *packet); + int RTMP_SendPacket(RTMP *r, RTMPPacket *packet, int queue); + int RTMP_SendChunk(RTMP *r, RTMPChunk *chunk); + int RTMP_IsConnected(RTMP *r); + int RTMP_Socket(RTMP *r); + int RTMP_IsTimedout(RTMP *r); + double RTMP_GetDuration(RTMP *r); + int RTMP_ToggleStream(RTMP *r); + + int RTMP_ConnectStream(RTMP *r, int seekTime); + int RTMP_ReconnectStream(RTMP *r, int seekTime); + void RTMP_DeleteStream(RTMP *r); + int RTMP_GetNextMediaPacket(RTMP *r, RTMPPacket *packet); + int RTMP_ClientPacket(RTMP *r, RTMPPacket *packet); + + void RTMP_Init(RTMP *r); + void RTMP_Close(RTMP *r); + RTMP *RTMP_Alloc(void); + void RTMP_Free(RTMP *r); + void RTMP_EnableWrite(RTMP *r); + + void *RTMP_TLS_AllocServerContext(const char* cert, const char* key); + void RTMP_TLS_FreeServerContext(void *ctx); + + int RTMP_LibVersion(void); + void RTMP_UserInterrupt(void); /* user typed Ctrl-C */ + + int RTMP_SendCtrl(RTMP *r, short nType, unsigned int nObject, + unsigned int nTime); + + /* caller probably doesn't know current timestamp, should + * just use RTMP_Pause instead + */ + int RTMP_SendPause(RTMP *r, int DoPause, int dTime); + int RTMP_Pause(RTMP *r, int DoPause); + + int RTMP_FindFirstMatchingProperty(AMFObject *obj, const AVal *name, + AMFObjectProperty * p); + + int RTMPSockBuf_Fill(RTMPSockBuf *sb); + int RTMPSockBuf_Send(RTMPSockBuf *sb, const char *buf, int len); + int RTMPSockBuf_Close(RTMPSockBuf *sb); + + int RTMP_SendCreateStream(RTMP *r); + int RTMP_SendSeek(RTMP *r, int dTime); + int RTMP_SendServerBW(RTMP *r); + int RTMP_SendClientBW(RTMP *r); + void RTMP_DropRequest(RTMP *r, int i, int freeit); + int RTMP_Read(RTMP *r, char *buf, int size); + int RTMP_Write(RTMP *r, const char *buf, int size); + +/* hashswf.c */ + int RTMP_HashSWF(const char *url, unsigned int *size, unsigned char *hash, + int age); + +#ifdef __cplusplus +}; +#endif + +#endif diff --git a/app/src/main/cpp/third_party/librtmp/include/librtmp/rtmp_sys.h b/app/src/main/cpp/third_party/librtmp/include/librtmp/rtmp_sys.h new file mode 100644 index 0000000..543e126 --- /dev/null +++ b/app/src/main/cpp/third_party/librtmp/include/librtmp/rtmp_sys.h @@ -0,0 +1,141 @@ +#ifndef __RTMP_SYS_H__ +#define __RTMP_SYS_H__ +/* + * Copyright (C) 2010 Howard Chu + * + * This file is part of librtmp. + * + * librtmp is free software; you can redistribute it and/or modify + * it under the terms of the GNU Lesser General Public License as + * published by the Free Software Foundation; either version 2.1, + * or (at your option) any later version. + * + * librtmp is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU Lesser General Public License + * along with librtmp see the file COPYING. If not, write to + * the Free Software Foundation, Inc., 51 Franklin Street, Fifth Floor, + * Boston, MA 02110-1301, USA. + * http://www.gnu.org/copyleft/lgpl.html + */ + +#ifdef _WIN32 + +#include +#include + +#ifdef _MSC_VER /* MSVC */ +#if _MSC_VER < 1900 +#define snprintf _snprintf +#define vsnprintf _vsnprintf +#endif +#define strcasecmp _stricmp +#define strncasecmp _strnicmp +#endif + +#define GetSockError() WSAGetLastError() +#define SetSockError(e) WSASetLastError(e) +#define setsockopt(a,b,c,d,e) (setsockopt)(a,b,c,(const char *)d,(int)e) +#define EWOULDBLOCK WSAETIMEDOUT /* we don't use nonblocking, but we do use timeouts */ +#define sleep(n) Sleep(n*1000) +#define msleep(n) Sleep(n) +#define SET_RCVTIMEO(tv,s) int tv = s*1000 +#else /* !_WIN32 */ +#include +#include +#include +#include +#include +#include +#include +#include +#define GetSockError() errno +#define SetSockError(e) errno = e +#undef closesocket +#define closesocket(s) close(s) +#define msleep(n) usleep(n*1000) +#define SET_RCVTIMEO(tv,s) struct timeval tv = {s,0} +#endif + +#include "rtmp.h" + +#ifdef USE_POLARSSL +#include +#include +#include +#include +#if POLARSSL_VERSION_NUMBER < 0x01010000 +#define havege_random havege_rand +#endif +#if POLARSSL_VERSION_NUMBER >= 0x01020000 +#define SSL_SET_SESSION(S,resume,timeout,ctx) ssl_set_session(S,ctx) +#else +#define SSL_SET_SESSION(S,resume,timeout,ctx) ssl_set_session(S,resume,timeout,ctx) +#endif +typedef struct tls_ctx { + havege_state hs; + ssl_session ssn; +} tls_ctx; +typedef struct tls_server_ctx { + havege_state *hs; + x509_cert cert; + rsa_context key; + ssl_session ssn; + const char *dhm_P, *dhm_G; +} tls_server_ctx; + +#define TLS_CTX tls_ctx * +#define TLS_client(ctx,s) s = malloc(sizeof(ssl_context)); ssl_init(s);\ + ssl_set_endpoint(s, SSL_IS_CLIENT); ssl_set_authmode(s, SSL_VERIFY_NONE);\ + ssl_set_rng(s, havege_random, &ctx->hs);\ + ssl_set_ciphersuites(s, ssl_default_ciphersuites);\ + SSL_SET_SESSION(s, 1, 600, &ctx->ssn) +#define TLS_server(ctx,s) s = malloc(sizeof(ssl_context)); ssl_init(s);\ + ssl_set_endpoint(s, SSL_IS_SERVER); ssl_set_authmode(s, SSL_VERIFY_NONE);\ + ssl_set_rng(s, havege_random, ((tls_server_ctx*)ctx)->hs);\ + ssl_set_ciphersuites(s, ssl_default_ciphersuites);\ + SSL_SET_SESSION(s, 1, 600, &((tls_server_ctx*)ctx)->ssn);\ + ssl_set_own_cert(s, &((tls_server_ctx*)ctx)->cert, &((tls_server_ctx*)ctx)->key);\ + ssl_set_dh_param(s, ((tls_server_ctx*)ctx)->dhm_P, ((tls_server_ctx*)ctx)->dhm_G) +#define TLS_setfd(s,fd) ssl_set_bio(s, net_recv, &fd, net_send, &fd) +#define TLS_connect(s) ssl_handshake(s) +#define TLS_accept(s) ssl_handshake(s) +#define TLS_read(s,b,l) ssl_read(s,(unsigned char *)b,l) +#define TLS_write(s,b,l) ssl_write(s,(unsigned char *)b,l) +#define TLS_shutdown(s) ssl_close_notify(s) +#define TLS_close(s) ssl_free(s); free(s) + +#elif defined(USE_GNUTLS) +#include +typedef struct tls_ctx { + gnutls_certificate_credentials_t cred; + gnutls_priority_t prios; +} tls_ctx; +#define TLS_CTX tls_ctx * +#define TLS_client(ctx,s) gnutls_init((gnutls_session_t *)(&s), GNUTLS_CLIENT); gnutls_priority_set(s, ctx->prios); gnutls_credentials_set(s, GNUTLS_CRD_CERTIFICATE, ctx->cred) +#define TLS_server(ctx,s) gnutls_init((gnutls_session_t *)(&s), GNUTLS_SERVER); gnutls_priority_set_direct(s, "NORMAL", NULL); gnutls_credentials_set(s, GNUTLS_CRD_CERTIFICATE, ctx) +#define TLS_setfd(s,fd) gnutls_transport_set_ptr(s, (gnutls_transport_ptr_t)(long)fd) +#define TLS_connect(s) gnutls_handshake(s) +#define TLS_accept(s) gnutls_handshake(s) +#define TLS_read(s,b,l) gnutls_record_recv(s,b,l) +#define TLS_write(s,b,l) gnutls_record_send(s,b,l) +#define TLS_shutdown(s) gnutls_bye(s, GNUTLS_SHUT_RDWR) +#define TLS_close(s) gnutls_deinit(s) + +#else /* USE_OPENSSL */ +#define TLS_CTX SSL_CTX * +#define TLS_client(ctx,s) s = SSL_new(ctx) +#define TLS_server(ctx,s) s = SSL_new(ctx) +#define TLS_setfd(s,fd) SSL_set_fd(s,fd) +#define TLS_connect(s) SSL_connect(s) +#define TLS_accept(s) SSL_accept(s) +#define TLS_read(s,b,l) SSL_read(s,b,l) +#define TLS_write(s,b,l) SSL_write(s,b,l) +#define TLS_shutdown(s) SSL_shutdown(s) +#define TLS_close(s) SSL_free(s) + +#endif +#endif diff --git a/app/src/main/java/com/omixlab/lckcontrol/data/local/LckDatabase.kt b/app/src/main/java/com/omixlab/lckcontrol/data/local/LckDatabase.kt index fe65515..90c2ca2 100644 --- a/app/src/main/java/com/omixlab/lckcontrol/data/local/LckDatabase.kt +++ b/app/src/main/java/com/omixlab/lckcontrol/data/local/LckDatabase.kt @@ -16,7 +16,7 @@ import com.omixlab.lckcontrol.data.local.entity.StreamPlanEntity StreamPlanEntity::class, StreamDestinationEntity::class, ], - version = 3, + version = 5, exportSchema = false, ) abstract class LckDatabase : RoomDatabase() { @@ -96,5 +96,18 @@ abstract class LckDatabase : RoomDatabase() { db.execSQL("ALTER TABLE stream_destinations ADD COLUMN linkedAccountId TEXT NOT NULL DEFAULT ''") } } + + val MIGRATION_3_4 = object : Migration(3, 4) { + override fun migrate(db: SupportSQLiteDatabase) { + db.execSQL("ALTER TABLE stream_plans ADD COLUMN executionMode TEXT NOT NULL DEFAULT 'IN_GAME'") + } + } + + val MIGRATION_4_5 = object : Migration(4, 5) { + override fun migrate(db: SupportSQLiteDatabase) { + db.execSQL("ALTER TABLE linked_accounts ADD COLUMN isEnabled INTEGER NOT NULL DEFAULT 1") + db.execSQL("ALTER TABLE stream_plans ADD COLUMN gameId TEXT NOT NULL DEFAULT ''") + } + } } } diff --git a/app/src/main/java/com/omixlab/lckcontrol/data/local/dao/LinkedAccountDao.kt b/app/src/main/java/com/omixlab/lckcontrol/data/local/dao/LinkedAccountDao.kt index 0b02ea2..941ee90 100644 --- a/app/src/main/java/com/omixlab/lckcontrol/data/local/dao/LinkedAccountDao.kt +++ b/app/src/main/java/com/omixlab/lckcontrol/data/local/dao/LinkedAccountDao.kt @@ -33,4 +33,7 @@ interface LinkedAccountDao { @Query("DELETE FROM linked_accounts WHERE serviceId = :serviceId") suspend fun deleteByService(serviceId: String) + + @Query("UPDATE linked_accounts SET isEnabled = :isEnabled WHERE id = :id") + suspend fun setEnabled(id: String, isEnabled: Boolean) } diff --git a/app/src/main/java/com/omixlab/lckcontrol/data/local/entity/LinkedAccountEntity.kt b/app/src/main/java/com/omixlab/lckcontrol/data/local/entity/LinkedAccountEntity.kt index 95d69e1..77d9f67 100644 --- a/app/src/main/java/com/omixlab/lckcontrol/data/local/entity/LinkedAccountEntity.kt +++ b/app/src/main/java/com/omixlab/lckcontrol/data/local/entity/LinkedAccountEntity.kt @@ -10,4 +10,5 @@ data class LinkedAccountEntity( val displayName: String, val accountId: String, val avatarUrl: String? = null, + val isEnabled: Boolean = true, ) diff --git a/app/src/main/java/com/omixlab/lckcontrol/data/local/entity/StreamPlanEntity.kt b/app/src/main/java/com/omixlab/lckcontrol/data/local/entity/StreamPlanEntity.kt index 3bebbe5..db4ed98 100644 --- a/app/src/main/java/com/omixlab/lckcontrol/data/local/entity/StreamPlanEntity.kt +++ b/app/src/main/java/com/omixlab/lckcontrol/data/local/entity/StreamPlanEntity.kt @@ -8,5 +8,7 @@ data class StreamPlanEntity( @PrimaryKey val planId: String, val name: String, val status: String = "DRAFT", + val executionMode: String = "IN_GAME", + val gameId: String = "", val createdAt: Long = System.currentTimeMillis(), ) diff --git a/app/src/main/java/com/omixlab/lckcontrol/data/remote/ApiModels.kt b/app/src/main/java/com/omixlab/lckcontrol/data/remote/ApiModels.kt index 8f09273..9dbf780 100644 --- a/app/src/main/java/com/omixlab/lckcontrol/data/remote/ApiModels.kt +++ b/app/src/main/java/com/omixlab/lckcontrol/data/remote/ApiModels.kt @@ -67,6 +67,8 @@ data class LinkedAccountResponse( @JsonClass(generateAdapter = true) data class CreateStreamPlanRequest( val name: String, + val executionMode: String? = null, + val gameId: String? = null, val destinations: List, ) @@ -85,6 +87,8 @@ data class StreamPlanResponse( val id: String, val name: String, val status: String, + val executionMode: String? = null, + val gameId: String? = null, val createdAt: String, val updatedAt: String, val destinations: List, diff --git a/app/src/main/java/com/omixlab/lckcontrol/data/repository/AccountRepository.kt b/app/src/main/java/com/omixlab/lckcontrol/data/repository/AccountRepository.kt index 427ccd3..3940fbd 100644 --- a/app/src/main/java/com/omixlab/lckcontrol/data/repository/AccountRepository.kt +++ b/app/src/main/java/com/omixlab/lckcontrol/data/repository/AccountRepository.kt @@ -26,6 +26,8 @@ class AccountRepository @Inject constructor( /** Fetch accounts from backend and sync to Room cache */ suspend fun syncAccounts() { val remote = apiService.getLinkedAccounts() + // Read local entities to preserve isEnabled across sync + val localMap = accountDao.getAll().associateBy { it.id } val entities = remote.map { account -> LinkedAccountEntity( id = account.id, @@ -33,12 +35,12 @@ class AccountRepository @Inject constructor( displayName = account.displayName, accountId = account.accountId, avatarUrl = account.avatarUrl, + isEnabled = localMap[account.id]?.isEnabled ?: true, ) } - // Get current local accounts to detect removals - val local = accountDao.getAll() + // Detect removals val remoteIds = entities.map { it.id }.toSet() - for (localAccount in local) { + for (localAccount in localMap.values) { if (localAccount.id !in remoteIds) { accountDao.deleteById(localAccount.id) } @@ -48,6 +50,10 @@ class AccountRepository @Inject constructor( } } + suspend fun setAccountEnabled(id: String, enabled: Boolean) { + accountDao.setEnabled(id, enabled) + } + /** Get YouTube OAuth URL from backend (for Custom Tabs) */ suspend fun getYouTubeAuthUrl(): String { val response = apiService.getYouTubeAuthUrl() @@ -85,5 +91,6 @@ class AccountRepository @Inject constructor( accountId = accountId, avatarUrl = avatarUrl, isAuthenticated = true, // Backend manages auth state + isEnabled = isEnabled, ) } diff --git a/app/src/main/java/com/omixlab/lckcontrol/data/repository/StreamPlanRepository.kt b/app/src/main/java/com/omixlab/lckcontrol/data/repository/StreamPlanRepository.kt index dd4ca81..d5eaf04 100644 --- a/app/src/main/java/com/omixlab/lckcontrol/data/repository/StreamPlanRepository.kt +++ b/app/src/main/java/com/omixlab/lckcontrol/data/repository/StreamPlanRepository.kt @@ -42,9 +42,16 @@ class StreamPlanRepository @Inject constructor( } /** Create plan via backend and cache locally */ - suspend fun createPlan(name: String, destinations: List): StreamPlan { + suspend fun createPlan( + name: String, + destinations: List, + executionMode: String = "IN_GAME", + gameId: String = "", + ): StreamPlan { val request = CreateStreamPlanRequest( name = name, + executionMode = executionMode, + gameId = gameId.ifBlank { null }, destinations = destinations.map { dest -> CreateDestinationRequest( linkedAccountId = dest.linkedAccountId, @@ -96,7 +103,13 @@ class StreamPlanRepository @Inject constructor( } private suspend fun cacheRemotePlan(remote: StreamPlanResponse) { - val planEntity = StreamPlanEntity(planId = remote.id, name = remote.name, status = remote.status) + val planEntity = StreamPlanEntity( + planId = remote.id, + name = remote.name, + status = remote.status, + executionMode = remote.executionMode ?: "IN_GAME", + gameId = remote.gameId ?: "", + ) val destEntities = remote.destinations.map { d -> StreamDestinationEntity( id = d.id, @@ -121,6 +134,8 @@ class StreamPlanRepository @Inject constructor( planId = plan.planId, name = plan.name, status = plan.status, + executionMode = plan.executionMode, + gameId = plan.gameId, destinations = destinations.map { it.toStreamDestination() }, ) diff --git a/app/src/main/java/com/omixlab/lckcontrol/di/DatabaseModule.kt b/app/src/main/java/com/omixlab/lckcontrol/di/DatabaseModule.kt index c79581e..1e9e316 100644 --- a/app/src/main/java/com/omixlab/lckcontrol/di/DatabaseModule.kt +++ b/app/src/main/java/com/omixlab/lckcontrol/di/DatabaseModule.kt @@ -20,7 +20,7 @@ object DatabaseModule { @Singleton fun provideDatabase(@ApplicationContext context: Context): LckDatabase = Room.databaseBuilder(context, LckDatabase::class.java, "lck_control.db") - .addMigrations(LckDatabase.MIGRATION_1_2, LckDatabase.MIGRATION_2_3) + .addMigrations(LckDatabase.MIGRATION_1_2, LckDatabase.MIGRATION_2_3, LckDatabase.MIGRATION_3_4, LckDatabase.MIGRATION_4_5) .build() @Provides diff --git a/app/src/main/java/com/omixlab/lckcontrol/service/LckControlService.kt b/app/src/main/java/com/omixlab/lckcontrol/service/LckControlService.kt index 825a921..636490e 100644 --- a/app/src/main/java/com/omixlab/lckcontrol/service/LckControlService.kt +++ b/app/src/main/java/com/omixlab/lckcontrol/service/LckControlService.kt @@ -26,6 +26,9 @@ import com.omixlab.lckcontrol.shared.LinkedAccount import com.omixlab.lckcontrol.shared.StreamDestination import com.omixlab.lckcontrol.shared.StreamPlan import com.omixlab.lckcontrol.shared.StreamPlanConfig +import com.omixlab.lckcontrol.shared.StreamingConfig +import com.omixlab.lckcontrol.streaming.StreamingManager +import com.omixlab.lckcontrol.streaming.StreamingState import dagger.hilt.android.AndroidEntryPoint import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers @@ -48,15 +51,18 @@ class LckControlService : Service() { private const val NOTIFICATION_ID = 1 private const val QUEST_APP_ID = "25653777174321448" private const val TOKEN_REFRESH_INTERVAL_MS = 60_000L + private const val ACTION_BIND_STREAMING = "com.omixlab.lckcontrol.BIND_STREAMING" } @Inject lateinit var accountRepository: AccountRepository @Inject lateinit var streamPlanRepository: StreamPlanRepository @Inject lateinit var tokenStore: TokenStore @Inject lateinit var apiService: LckApiService + @Inject lateinit var streamingManager: StreamingManager private val serviceScope = CoroutineScope(SupervisorJob() + Dispatchers.Main) private val clientTracker = ClientTracker() + private var streamingServiceImpl: LckStreamingServiceImpl? = null private val callbacks = object : RemoteCallbackList() { override fun onCallbackDied(callback: ILckControlCallback, cookie: Any?) { val uid = cookie as? Int ?: return @@ -95,13 +101,20 @@ class LckControlService : Service() { // ── Stream plans ──────────────────────────────────── override fun createStreamPlan(config: StreamPlanConfig): StreamPlan = runBlocking { - val plan = streamPlanRepository.createPlan(config.name, config.destinations) + val plan = streamPlanRepository.createPlan( + name = config.name, + destinations = config.destinations, + executionMode = config.executionMode, + gameId = config.gameId, + ) broadcastPlansChanged() plan } override fun createDefaultPlan(clientName: String): StreamPlan = runBlocking { - val accounts = accountRepository.getAccounts() + val accounts = accountRepository.getAccounts().filter { it.isEnabled } + val gameId = clientTracker.getAll() + .find { it.clientName == clientName }?.packageName ?: "" val destinations = accounts.map { account -> StreamDestination( service = account.serviceId, @@ -110,7 +123,11 @@ class LckControlService : Service() { privacyStatus = "unlisted", ) } - val plan = streamPlanRepository.createPlan("$clientName Stream", destinations) + val plan = streamPlanRepository.createPlan( + name = "$clientName Stream", + destinations = destinations, + gameId = gameId, + ) broadcastPlansChanged() plan } @@ -137,6 +154,17 @@ class LckControlService : Service() { try { streamPlanRepository.startPlan(planId) val updated = streamPlanRepository.getPlan(planId) + + // If APP_STREAMING mode, start the streaming engine + if (updated?.executionMode == "APP_STREAMING") { + streamingManager.startStreaming( + plan = updated, + config = StreamingConfig(), + width = 1920, + height = 1080, + ) + } + if (updated != null) broadcastPlanUpdated(updated) true } catch (_: Exception) { false } @@ -147,6 +175,11 @@ class LckControlService : Service() { if (plan.status == "ENDED") return@runBlocking true if (plan.status != "LIVE" && plan.status != "READY") return@runBlocking false try { + // Stop streaming engine if running + if (plan.executionMode == "APP_STREAMING") { + streamingManager.stopStreaming() + } + streamPlanRepository.endPlan(planId) val updated = streamPlanRepository.getPlan(planId) if (updated != null) broadcastPlanUpdated(updated) @@ -222,11 +255,37 @@ class LckControlService : Service() { } } } + + // Forward streaming state changes to AIDL callbacks + serviceScope.launch { + streamingManager.state.collect { state -> + streamingServiceImpl?.broadcastStateChanged(state) + } + } + serviceScope.launch { + streamingManager.stats.collect { stats -> + streamingServiceImpl?.broadcastStats( + stats.videoBitrate, stats.audioBitrate, stats.fps, stats.droppedFrames, + ) + } + } } - override fun onBind(intent: Intent?): IBinder = binder + override fun onBind(intent: Intent?): IBinder? { + return when (intent?.action) { + ACTION_BIND_STREAMING -> { + if (streamingServiceImpl == null) { + streamingServiceImpl = LckStreamingServiceImpl(streamingManager) + } + streamingServiceImpl!!.asBinder() + } + else -> binder + } + } override fun onDestroy() { + streamingManager.stopStreaming() + streamingServiceImpl?.kill() serviceScope.cancel() callbacks.kill() super.onDestroy() diff --git a/app/src/main/java/com/omixlab/lckcontrol/service/LckStreamingServiceImpl.kt b/app/src/main/java/com/omixlab/lckcontrol/service/LckStreamingServiceImpl.kt new file mode 100644 index 0000000..af701dc --- /dev/null +++ b/app/src/main/java/com/omixlab/lckcontrol/service/LckStreamingServiceImpl.kt @@ -0,0 +1,138 @@ +package com.omixlab.lckcontrol.service + +import android.hardware.HardwareBuffer +import android.os.ParcelFileDescriptor +import android.os.RemoteCallbackList +import android.util.Log +import com.omixlab.lckcontrol.shared.ILckStreamingCallback +import com.omixlab.lckcontrol.shared.ILckStreamingService +import com.omixlab.lckcontrol.streaming.StreamingManager +import com.omixlab.lckcontrol.streaming.StreamingState + +/** + * AIDL implementation for ILckStreamingService. + * Bridges AIDL IPC calls to the StreamingManager. + * Frame submission methods are one-way for non-blocking game render thread. + */ +class LckStreamingServiceImpl( + private val streamingManager: StreamingManager, +) : ILckStreamingService.Stub() { + + companion object { + private const val TAG = "LckStreamingServiceImpl" + } + + private val callbacks = RemoteCallbackList() + + init { + // Forward state changes to AIDL callbacks + // Note: state observation requires coroutine scope — delegated to LckControlService + } + + override fun registerTexturePool( + buffers: Array, + width: Int, + height: Int, + format: Int, + ) { + Log.d(TAG, "registerTexturePool: ${buffers.size} buffers, ${width}x$height") + streamingManager.registerTexturePool(buffers, width, height, format) + } + + override fun unregisterTexturePool() { + Log.d(TAG, "unregisterTexturePool") + streamingManager.unregisterTexturePool() + } + + override fun submitVideoFrame( + bufferIndex: Int, + timestampNs: Long, + gpuFence: ParcelFileDescriptor?, + ) { + val fenceFd = gpuFence?.detachFd() ?: -1 + streamingManager.submitVideoFrame(bufferIndex, timestampNs, fenceFd) + } + + override fun submitAudioFrame( + pcmData: ByteArray, + timestampNs: Long, + sampleRate: Int, + channels: Int, + bitsPerSample: Int, + ) { + streamingManager.submitAudioFrame(pcmData, timestampNs) + } + + override fun isStreaming(): Boolean { + return streamingManager.isStreaming() + } + + override fun registerStreamingCallback(callback: ILckStreamingCallback) { + callbacks.register(callback) + } + + override fun unregisterStreamingCallback(callback: ILckStreamingCallback) { + callbacks.unregister(callback) + } + + // ── Broadcast helpers (called from LckControlService coroutine scope) ── + + fun broadcastStateChanged(state: StreamingState) { + val stateStr = state.name + val count = callbacks.beginBroadcast() + try { + for (i in 0 until count) { + try { + callbacks.getBroadcastItem(i).onStreamingStateChanged(stateStr) + } catch (_: Exception) {} + } + } finally { + callbacks.finishBroadcast() + } + } + + fun broadcastStats(videoBitrate: Long, audioBitrate: Long, fps: Int, droppedFrames: Int) { + val count = callbacks.beginBroadcast() + try { + for (i in 0 until count) { + try { + callbacks.getBroadcastItem(i).onStreamingStats( + videoBitrate, audioBitrate, fps, droppedFrames, + ) + } catch (_: Exception) {} + } + } finally { + callbacks.finishBroadcast() + } + } + + fun broadcastError(code: Int, message: String) { + val count = callbacks.beginBroadcast() + try { + for (i in 0 until count) { + try { + callbacks.getBroadcastItem(i).onStreamingError(code, message) + } catch (_: Exception) {} + } + } finally { + callbacks.finishBroadcast() + } + } + + fun broadcastBufferReleased(bufferIndex: Int) { + val count = callbacks.beginBroadcast() + try { + for (i in 0 until count) { + try { + callbacks.getBroadcastItem(i).onBufferReleased(bufferIndex) + } catch (_: Exception) {} + } + } finally { + callbacks.finishBroadcast() + } + } + + fun kill() { + callbacks.kill() + } +} diff --git a/app/src/main/java/com/omixlab/lckcontrol/streaming/NativeStreamingEngine.kt b/app/src/main/java/com/omixlab/lckcontrol/streaming/NativeStreamingEngine.kt new file mode 100644 index 0000000..433b627 --- /dev/null +++ b/app/src/main/java/com/omixlab/lckcontrol/streaming/NativeStreamingEngine.kt @@ -0,0 +1,112 @@ +package com.omixlab.lckcontrol.streaming + +import android.hardware.HardwareBuffer +import android.util.Log + +/** + * Thin JNI wrapper around the C++ StreamingEngine. + * All encoding, muxing, and RTMP streaming happens in native code (zero-copy pipeline). + */ +class NativeStreamingEngine { + + companion object { + private const val TAG = "NativeStreamingEngine" + + init { + System.loadLibrary("lck_streaming") + } + } + + private var nativePtr: Long = 0 + + var onStats: ((StreamingStats) -> Unit)? = null + var onError: ((Int, String) -> Unit)? = null + var onBufferReleased: ((Int) -> Unit)? = null + + fun create( + width: Int, + height: Int, + videoBitrate: Int, + audioBitrate: Int, + sampleRate: Int, + channels: Int, + keyframeInterval: Int, + ) { + if (nativePtr != 0L) { + Log.w(TAG, "Engine already created, destroying first") + destroy() + } + nativePtr = nativeCreate(width, height, videoBitrate, audioBitrate, + sampleRate, channels, keyframeInterval) + } + + fun addDestination(rtmpUrl: String): Int { + check(nativePtr != 0L) { "Engine not created" } + return nativeAddDestination(nativePtr, rtmpUrl) + } + + fun start(): Boolean { + check(nativePtr != 0L) { "Engine not created" } + return nativeStart(nativePtr) + } + + fun submitVideoFrame(hardwareBuffer: HardwareBuffer, timestampNs: Long, fenceFd: Int) { + if (nativePtr == 0L) return + nativeSubmitVideoFrame(nativePtr, hardwareBuffer, timestampNs, fenceFd) + } + + fun submitAudioFrame(pcmData: ByteArray, timestampNs: Long) { + if (nativePtr == 0L) return + nativeSubmitAudioFrame(nativePtr, pcmData, timestampNs) + } + + fun stop() { + if (nativePtr == 0L) return + nativeStop(nativePtr) + } + + fun destroy() { + if (nativePtr != 0L) { + nativeDestroy(nativePtr) + nativePtr = 0 + } + } + + fun isRunning(): Boolean { + if (nativePtr == 0L) return false + return nativeIsRunning(nativePtr) + } + + // Called from native code (JNI callbacks) + @Suppress("unused") + private fun onNativeStats(videoBitrate: Long, audioBitrate: Long, fps: Int, droppedFrames: Int) { + onStats?.invoke(StreamingStats(videoBitrate, audioBitrate, fps, droppedFrames)) + } + + @Suppress("unused") + private fun onNativeError(code: Int, message: String) { + Log.e(TAG, "Native error $code: $message") + onError?.invoke(code, message) + } + + @Suppress("unused") + private fun onNativeBufferReleased(bufferIndex: Int) { + onBufferReleased?.invoke(bufferIndex) + } + + // Native methods + private external fun nativeCreate( + width: Int, height: Int, + videoBitrate: Int, audioBitrate: Int, + sampleRate: Int, channels: Int, + keyframeInterval: Int, + ): Long + + private external fun nativeAddDestination(ptr: Long, rtmpUrl: String): Int + private external fun nativeStart(ptr: Long): Boolean + private external fun nativeSubmitVideoFrame(ptr: Long, hardwareBuffer: HardwareBuffer, timestampNs: Long, fenceFd: Int) + private external fun nativeSubmitAudioFrame(ptr: Long, pcmData: ByteArray, timestampNs: Long) + private external fun nativeStop(ptr: Long) + private external fun nativeDestroy(ptr: Long) + private external fun nativeIsRunning(ptr: Long): Boolean +} diff --git a/app/src/main/java/com/omixlab/lckcontrol/streaming/StreamingManager.kt b/app/src/main/java/com/omixlab/lckcontrol/streaming/StreamingManager.kt new file mode 100644 index 0000000..048c881 --- /dev/null +++ b/app/src/main/java/com/omixlab/lckcontrol/streaming/StreamingManager.kt @@ -0,0 +1,156 @@ +package com.omixlab.lckcontrol.streaming + +import android.hardware.HardwareBuffer +import android.util.Log +import com.omixlab.lckcontrol.shared.StreamPlan +import com.omixlab.lckcontrol.shared.StreamingConfig +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import javax.inject.Inject +import javax.inject.Singleton + +enum class StreamingState { + IDLE, STARTING, LIVE, STOPPING, ERROR +} + +/** + * High-level streaming lifecycle manager. + * Bridges stream plan configuration to the native streaming engine. + * Stream keys and RTMP URLs stay within the app process — never exposed via AIDL. + */ +@Singleton +class StreamingManager @Inject constructor() { + + companion object { + private const val TAG = "StreamingManager" + } + + private var engine: NativeStreamingEngine? = null + private var texturePoolBuffers: Array? = null + + private val _state = MutableStateFlow(StreamingState.IDLE) + val state: StateFlow = _state.asStateFlow() + + private val _stats = MutableStateFlow(StreamingStats()) + val stats: StateFlow = _stats.asStateFlow() + + private val _error = MutableStateFlow(null) + val error: StateFlow = _error.asStateFlow() + + /** + * Start streaming for a plan with APP_STREAMING execution mode. + * RTMP URLs are constructed internally from the plan's destinations. + */ + fun startStreaming(plan: StreamPlan, config: StreamingConfig, width: Int, height: Int) { + if (_state.value != StreamingState.IDLE) { + Log.w(TAG, "Cannot start streaming, current state: ${_state.value}") + return + } + + val destinations = plan.destinations.filter { + it.rtmpUrl.isNotBlank() && it.streamKey.isNotBlank() + } + + if (destinations.isEmpty()) { + _error.value = "No destinations with RTMP credentials" + _state.value = StreamingState.ERROR + return + } + + _state.value = StreamingState.STARTING + _error.value = null + + try { + val eng = NativeStreamingEngine() + eng.create( + width = width, + height = height, + videoBitrate = config.videoBitrate, + audioBitrate = config.audioBitrate, + sampleRate = config.audioSampleRate, + channels = config.audioChannels, + keyframeInterval = config.keyFrameInterval, + ) + + // Add RTMP destinations — stream keys stay in-process + for (dest in destinations) { + val fullUrl = "${dest.rtmpUrl}/${dest.streamKey}" + eng.addDestination(fullUrl) + Log.d(TAG, "Added destination: ${dest.service}") + } + + eng.onStats = { stats -> + _stats.value = stats + } + + eng.onError = { code, message -> + Log.e(TAG, "Streaming error $code: $message") + _error.value = message + _state.value = StreamingState.ERROR + } + + if (eng.start()) { + engine = eng + _state.value = StreamingState.LIVE + Log.i(TAG, "Streaming started with ${destinations.size} destinations") + } else { + eng.destroy() + _error.value = "Failed to start streaming engine" + _state.value = StreamingState.ERROR + } + } catch (e: Exception) { + Log.e(TAG, "Failed to start streaming", e) + _error.value = e.message ?: "Unknown error" + _state.value = StreamingState.ERROR + } + } + + /** + * Register texture pool buffers from the game. + * Buffers are stored for reference — the native engine receives individual + * buffers via submitVideoFrame. + */ + fun registerTexturePool(buffers: Array, width: Int, height: Int, format: Int) { + texturePoolBuffers = buffers + Log.d(TAG, "Texture pool registered: ${buffers.size} buffers, ${width}x${height}") + } + + fun unregisterTexturePool() { + texturePoolBuffers = null + Log.d(TAG, "Texture pool unregistered") + } + + /** Forward a video frame from the game to the native engine. */ + fun submitVideoFrame(bufferIndex: Int, timestampNs: Long, fenceFd: Int) { + val buffers = texturePoolBuffers ?: return + if (bufferIndex < 0 || bufferIndex >= buffers.size) return + engine?.submitVideoFrame(buffers[bufferIndex], timestampNs, fenceFd) + } + + /** Forward audio PCM from the game to the native engine. */ + fun submitAudioFrame(pcmData: ByteArray, timestampNs: Long) { + engine?.submitAudioFrame(pcmData, timestampNs) + } + + /** Stop streaming and release all resources. */ + fun stopStreaming() { + if (_state.value != StreamingState.LIVE && _state.value != StreamingState.ERROR) { + return + } + + _state.value = StreamingState.STOPPING + + engine?.let { eng -> + eng.stop() + eng.destroy() + } + engine = null + + _state.value = StreamingState.IDLE + _stats.value = StreamingStats() + Log.i(TAG, "Streaming stopped") + } + + fun isStreaming(): Boolean = _state.value == StreamingState.LIVE +} diff --git a/app/src/main/java/com/omixlab/lckcontrol/streaming/StreamingStats.kt b/app/src/main/java/com/omixlab/lckcontrol/streaming/StreamingStats.kt new file mode 100644 index 0000000..b7df02f --- /dev/null +++ b/app/src/main/java/com/omixlab/lckcontrol/streaming/StreamingStats.kt @@ -0,0 +1,8 @@ +package com.omixlab.lckcontrol.streaming + +data class StreamingStats( + val videoBitrate: Long = 0, + val audioBitrate: Long = 0, + val fps: Int = 0, + val droppedFrames: Int = 0, +) diff --git a/app/src/main/java/com/omixlab/lckcontrol/ui/accounts/AccountsScreen.kt b/app/src/main/java/com/omixlab/lckcontrol/ui/accounts/AccountsScreen.kt index 1271ff2..a2f7cf2 100644 --- a/app/src/main/java/com/omixlab/lckcontrol/ui/accounts/AccountsScreen.kt +++ b/app/src/main/java/com/omixlab/lckcontrol/ui/accounts/AccountsScreen.kt @@ -23,6 +23,7 @@ import androidx.compose.material3.OutlinedButton import androidx.compose.material3.Scaffold import androidx.compose.material3.SnackbarHost import androidx.compose.material3.SnackbarHostState +import androidx.compose.material3.Switch import androidx.compose.material3.Text import androidx.compose.material3.TopAppBar import androidx.compose.runtime.Composable @@ -80,6 +81,10 @@ fun AccountsScreen( Text(account.displayName, style = MaterialTheme.typography.titleSmall) Text(account.serviceId, style = MaterialTheme.typography.bodySmall) } + Switch( + checked = account.isEnabled, + onCheckedChange = { viewModel.toggleAccountEnabled(account.id, it) }, + ) IconButton(onClick = { viewModel.unlinkAccount(account.id) }) { Icon(Icons.Default.LinkOff, contentDescription = "Unlink") } diff --git a/app/src/main/java/com/omixlab/lckcontrol/ui/accounts/AccountsViewModel.kt b/app/src/main/java/com/omixlab/lckcontrol/ui/accounts/AccountsViewModel.kt index d604ea1..cbf086f 100644 --- a/app/src/main/java/com/omixlab/lckcontrol/ui/accounts/AccountsViewModel.kt +++ b/app/src/main/java/com/omixlab/lckcontrol/ui/accounts/AccountsViewModel.kt @@ -65,6 +65,16 @@ class AccountsViewModel @Inject constructor( } } + fun toggleAccountEnabled(accountId: String, enabled: Boolean) { + viewModelScope.launch { + try { + accountRepository.setAccountEnabled(accountId, enabled) + } catch (e: Exception) { + _linkError.value = e.message ?: "Failed to update account" + } + } + } + fun unlinkAccount(accountId: String) { viewModelScope.launch { try { diff --git a/app/src/main/java/com/omixlab/lckcontrol/ui/dashboard/DashboardScreen.kt b/app/src/main/java/com/omixlab/lckcontrol/ui/dashboard/DashboardScreen.kt index cca7e40..8a803be 100644 --- a/app/src/main/java/com/omixlab/lckcontrol/ui/dashboard/DashboardScreen.kt +++ b/app/src/main/java/com/omixlab/lckcontrol/ui/dashboard/DashboardScreen.kt @@ -9,6 +9,7 @@ import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.width import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.items @@ -41,8 +42,8 @@ fun DashboardScreen( onNavigateToPlan: (String) -> Unit, viewModel: DashboardViewModel = hiltViewModel(), ) { - val accounts by viewModel.accounts.collectAsStateWithLifecycle() val plans by viewModel.plans.collectAsStateWithLifecycle() + val backendHealthy by viewModel.backendHealthy.collectAsStateWithLifecycle() Scaffold( topBar = { @@ -63,35 +64,28 @@ fun DashboardScreen( ) { item { Spacer(Modifier.height(8.dp)) - Text("Linked Accounts", style = MaterialTheme.typography.titleMedium) + Text("Server Status", style = MaterialTheme.typography.titleMedium) Spacer(Modifier.height(4.dp)) - } - - if (accounts.isEmpty()) { - item { - Card( - modifier = Modifier.fillMaxWidth(), - colors = CardDefaults.cardColors( - containerColor = MaterialTheme.colorScheme.surfaceVariant, - ), + ElevatedCard(modifier = Modifier.fillMaxWidth()) { + Row( + modifier = Modifier.padding(16.dp), + verticalAlignment = Alignment.CenterVertically, ) { - Text( - "No accounts linked yet. Go to Accounts to get started.", - modifier = Modifier.padding(16.dp), - style = MaterialTheme.typography.bodyMedium, + val (color, label) = when (backendHealthy) { + true -> MaterialTheme.colorScheme.primary to "Connected" + false -> MaterialTheme.colorScheme.error to "Unreachable" + null -> MaterialTheme.colorScheme.outline to "Checking..." + } + Icon( + Icons.Default.Circle, + contentDescription = label, + tint = color, + modifier = Modifier.size(12.dp), ) - } - } - } else { - item { - Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) { - accounts.forEach { account -> - ElevatedCard { - Column(modifier = Modifier.padding(12.dp)) { - Text(account.displayName, style = MaterialTheme.typography.labelLarge) - Text(account.serviceId, style = MaterialTheme.typography.bodySmall) - } - } + Spacer(Modifier.width(12.dp)) + Column { + Text("Backend", style = MaterialTheme.typography.titleSmall) + Text(label, style = MaterialTheme.typography.bodySmall, color = color) } } } diff --git a/app/src/main/java/com/omixlab/lckcontrol/ui/dashboard/DashboardViewModel.kt b/app/src/main/java/com/omixlab/lckcontrol/ui/dashboard/DashboardViewModel.kt index f04be70..3302530 100644 --- a/app/src/main/java/com/omixlab/lckcontrol/ui/dashboard/DashboardViewModel.kt +++ b/app/src/main/java/com/omixlab/lckcontrol/ui/dashboard/DashboardViewModel.kt @@ -2,32 +2,45 @@ package com.omixlab.lckcontrol.ui.dashboard import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope -import com.omixlab.lckcontrol.data.repository.AccountRepository +import com.omixlab.lckcontrol.data.remote.LckApiService import com.omixlab.lckcontrol.data.repository.StreamPlanRepository -import com.omixlab.lckcontrol.shared.LinkedAccount import com.omixlab.lckcontrol.shared.StreamPlan import dagger.hilt.android.lifecycle.HiltViewModel +import kotlinx.coroutines.delay +import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.SharingStarted import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.stateIn import kotlinx.coroutines.launch import javax.inject.Inject @HiltViewModel class DashboardViewModel @Inject constructor( - accountRepository: AccountRepository, private val streamPlanRepository: StreamPlanRepository, + private val apiService: LckApiService, ) : ViewModel() { - val accounts: StateFlow> = accountRepository.observeAccounts() - .stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), emptyList()) - val plans: StateFlow> = streamPlanRepository.observePlans() .stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), emptyList()) + private val _backendHealthy = MutableStateFlow(null) + val backendHealthy: StateFlow = _backendHealthy.asStateFlow() + init { viewModelScope.launch { try { streamPlanRepository.syncPlans() } catch (_: Exception) {} } + viewModelScope.launch { + while (true) { + _backendHealthy.value = try { + apiService.healthCheck() + true + } catch (_: Exception) { + false + } + delay(5_000) + } + } } } diff --git a/app/src/main/java/com/omixlab/lckcontrol/ui/navigation/AppNavigation.kt b/app/src/main/java/com/omixlab/lckcontrol/ui/navigation/AppNavigation.kt index 517bbb5..97100e3 100644 --- a/app/src/main/java/com/omixlab/lckcontrol/ui/navigation/AppNavigation.kt +++ b/app/src/main/java/com/omixlab/lckcontrol/ui/navigation/AppNavigation.kt @@ -1,15 +1,11 @@ package com.omixlab.lckcontrol.ui.navigation -import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.layout.size import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.filled.Circle import androidx.compose.material.icons.filled.Dashboard import androidx.compose.material.icons.filled.Devices import androidx.compose.material.icons.filled.Person import androidx.compose.material3.Icon -import androidx.compose.material3.MaterialTheme import androidx.compose.material3.NavigationBar import androidx.compose.material3.NavigationBarItem import androidx.compose.material3.Scaffold @@ -17,13 +13,8 @@ import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue -import androidx.compose.runtime.mutableStateOf -import androidx.compose.runtime.remember -import androidx.compose.runtime.setValue -import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.vector.ImageVector -import androidx.compose.ui.unit.dp import androidx.navigation.NavGraph.Companion.findStartDestination import androidx.navigation.NavType import androidx.navigation.compose.NavHost @@ -39,7 +30,6 @@ import com.omixlab.lckcontrol.ui.dashboard.DashboardScreen import com.omixlab.lckcontrol.ui.login.LoginScreen import com.omixlab.lckcontrol.ui.plans.CreatePlanScreen import com.omixlab.lckcontrol.ui.plans.PlanDetailScreen -import kotlinx.coroutines.delay private data class BottomNavItem( val screen: Screen, @@ -62,22 +52,6 @@ fun AppNavigation(tokenStore: TokenStore, apiService: LckApiService) { val showBottomBar = currentRoute in bottomNavItems.map { it.screen.route } val startDestination = if (tokenStore.isLoggedIn()) Screen.Dashboard.route else Screen.Login.route - // Backend health state - var backendHealthy by remember { mutableStateOf(null) } - - // Poll backend health every 5 seconds - LaunchedEffect(Unit) { - while (true) { - backendHealthy = try { - apiService.healthCheck() - true - } catch (_: Exception) { - false - } - delay(5_000) - } - } - // Session validation on app open — if we think we're logged in, verify it LaunchedEffect(Unit) { if (tokenStore.isLoggedIn()) { @@ -101,24 +75,7 @@ fun AppNavigation(tokenStore: TokenStore, apiService: LckApiService) { bottomNavItems.forEach { item -> NavigationBarItem( icon = { - if (item.screen == Screen.Dashboard && backendHealthy != null) { - Box { - Icon(item.icon, contentDescription = item.label) - Icon( - Icons.Default.Circle, - contentDescription = if (backendHealthy == true) "Backend healthy" else "Backend unreachable", - tint = if (backendHealthy == true) - MaterialTheme.colorScheme.primary - else - MaterialTheme.colorScheme.error, - modifier = Modifier - .size(8.dp) - .align(Alignment.TopEnd), - ) - } - } else { - Icon(item.icon, contentDescription = item.label) - } + Icon(item.icon, contentDescription = item.label) }, label = { Text(item.label) }, selected = currentRoute == item.screen.route, diff --git a/app/src/main/java/com/omixlab/lckcontrol/ui/plans/CreatePlanScreen.kt b/app/src/main/java/com/omixlab/lckcontrol/ui/plans/CreatePlanScreen.kt index 468249b..e0dc308 100644 --- a/app/src/main/java/com/omixlab/lckcontrol/ui/plans/CreatePlanScreen.kt +++ b/app/src/main/java/com/omixlab/lckcontrol/ui/plans/CreatePlanScreen.kt @@ -20,6 +20,7 @@ import androidx.compose.material3.ElevatedCard import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.ExposedDropdownMenuBox import androidx.compose.material3.ExposedDropdownMenuDefaults +import androidx.compose.material3.FilterChip import androidx.compose.material3.Icon import androidx.compose.material3.IconButton import androidx.compose.material3.MaterialTheme @@ -51,6 +52,8 @@ fun CreatePlanScreen( viewModel: CreatePlanViewModel = hiltViewModel(), ) { val planName by viewModel.planName.collectAsStateWithLifecycle() + val executionMode by viewModel.executionMode.collectAsStateWithLifecycle() + val gameId by viewModel.gameId.collectAsStateWithLifecycle() val destinations by viewModel.destinations.collectAsStateWithLifecycle() val linkedAccounts by viewModel.linkedAccounts.collectAsStateWithLifecycle() val isCreating by viewModel.isCreating.collectAsStateWithLifecycle() @@ -95,6 +98,46 @@ fun CreatePlanScreen( ) } + item { + Spacer(Modifier.height(8.dp)) + Text("Execution Mode", style = MaterialTheme.typography.titleMedium) + Spacer(Modifier.height(4.dp)) + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.spacedBy(8.dp), + ) { + FilterChip( + selected = executionMode == "IN_GAME", + onClick = { viewModel.setExecutionMode("IN_GAME") }, + label = { Text("In-Game") }, + ) + FilterChip( + selected = executionMode == "APP_STREAMING", + onClick = { viewModel.setExecutionMode("APP_STREAMING") }, + label = { Text("App Streaming") }, + ) + } + if (executionMode == "APP_STREAMING") { + Spacer(Modifier.height(4.dp)) + Text( + "The app encodes and streams. Stream keys stay secure.", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + } + + item { + OutlinedTextField( + value = gameId, + onValueChange = viewModel::setGameId, + label = { Text("Game Package ID") }, + placeholder = { Text("com.example.game") }, + modifier = Modifier.fillMaxWidth(), + singleLine = true, + ) + } + item { Spacer(Modifier.height(8.dp)) Row( diff --git a/app/src/main/java/com/omixlab/lckcontrol/ui/plans/CreatePlanViewModel.kt b/app/src/main/java/com/omixlab/lckcontrol/ui/plans/CreatePlanViewModel.kt index 47da63d..de7a0bd 100644 --- a/app/src/main/java/com/omixlab/lckcontrol/ui/plans/CreatePlanViewModel.kt +++ b/app/src/main/java/com/omixlab/lckcontrol/ui/plans/CreatePlanViewModel.kt @@ -37,6 +37,12 @@ class CreatePlanViewModel @Inject constructor( private val _planName = MutableStateFlow("") val planName: StateFlow = _planName.asStateFlow() + private val _executionMode = MutableStateFlow("IN_GAME") + val executionMode: StateFlow = _executionMode.asStateFlow() + + private val _gameId = MutableStateFlow("") + val gameId: StateFlow = _gameId.asStateFlow() + private val _destinations = MutableStateFlow>(emptyList()) val destinations: StateFlow> = _destinations.asStateFlow() @@ -50,6 +56,14 @@ class CreatePlanViewModel @Inject constructor( _planName.value = name } + fun setExecutionMode(mode: String) { + _executionMode.value = mode + } + + fun setGameId(gameId: String) { + _gameId.value = gameId + } + fun addDestination() { _destinations.value = _destinations.value + DestinationInput() } @@ -100,7 +114,7 @@ class CreatePlanViewModel @Inject constructor( tags = input.tags.split(",").map { it.trim() }.filter { it.isNotBlank() }, ) } - val plan = streamPlanRepository.createPlan(name, streamDests) + val plan = streamPlanRepository.createPlan(name, streamDests, _executionMode.value, _gameId.value) onCreated(plan.planId) } catch (e: Exception) { _error.value = e.message ?: "Failed to create plan" diff --git a/app/src/main/java/com/omixlab/lckcontrol/ui/plans/PlanDetailScreen.kt b/app/src/main/java/com/omixlab/lckcontrol/ui/plans/PlanDetailScreen.kt index 185dad5..7c1a6eb 100644 --- a/app/src/main/java/com/omixlab/lckcontrol/ui/plans/PlanDetailScreen.kt +++ b/app/src/main/java/com/omixlab/lckcontrol/ui/plans/PlanDetailScreen.kt @@ -40,6 +40,7 @@ import androidx.compose.ui.unit.dp import androidx.hilt.navigation.compose.hiltViewModel import androidx.lifecycle.compose.collectAsStateWithLifecycle import com.omixlab.lckcontrol.shared.StreamDestination +import com.omixlab.lckcontrol.streaming.StreamingState @OptIn(ExperimentalMaterial3Api::class) @Composable @@ -51,6 +52,8 @@ fun PlanDetailScreen( val plan by viewModel.plan.collectAsStateWithLifecycle() val isLoading by viewModel.isLoading.collectAsStateWithLifecycle() val error by viewModel.error.collectAsStateWithLifecycle() + val streamingState by viewModel.streamingState.collectAsStateWithLifecycle() + val streamingStats by viewModel.streamingStats.collectAsStateWithLifecycle() val snackbarHostState = remember { SnackbarHostState() } LaunchedEffect(error) { @@ -122,6 +125,45 @@ fun PlanDetailScreen( } } + // Execution mode + item { + ElevatedCard(modifier = Modifier.fillMaxWidth()) { + Column(modifier = Modifier.padding(16.dp)) { + Text("Execution Mode", style = MaterialTheme.typography.labelMedium) + Spacer(Modifier.height(4.dp)) + Text( + when (currentPlan.executionMode) { + "APP_STREAMING" -> "App Streaming" + else -> "In-Game" + }, + style = MaterialTheme.typography.bodyMedium, + ) + } + } + } + + // Game ID + if (currentPlan.gameId.isNotBlank()) { + item { + ElevatedCard(modifier = Modifier.fillMaxWidth()) { + Column(modifier = Modifier.padding(16.dp)) { + Text("Game", style = MaterialTheme.typography.labelMedium) + Spacer(Modifier.height(4.dp)) + Text(currentPlan.gameId, style = MaterialTheme.typography.bodyMedium) + } + } + } + } + + // Streaming stats (only for APP_STREAMING + LIVE) + if (currentPlan.executionMode == "APP_STREAMING" && + currentPlan.status == "LIVE" && + streamingState == StreamingState.LIVE) { + item { + StreamingStatsCard(stats = streamingStats) + } + } + // Action buttons item { when (currentPlan.status) { diff --git a/app/src/main/java/com/omixlab/lckcontrol/ui/plans/PlanDetailViewModel.kt b/app/src/main/java/com/omixlab/lckcontrol/ui/plans/PlanDetailViewModel.kt index 4fe4634..a439d10 100644 --- a/app/src/main/java/com/omixlab/lckcontrol/ui/plans/PlanDetailViewModel.kt +++ b/app/src/main/java/com/omixlab/lckcontrol/ui/plans/PlanDetailViewModel.kt @@ -5,6 +5,9 @@ import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import com.omixlab.lckcontrol.data.repository.StreamPlanRepository import com.omixlab.lckcontrol.shared.StreamPlan +import com.omixlab.lckcontrol.streaming.StreamingManager +import com.omixlab.lckcontrol.streaming.StreamingState +import com.omixlab.lckcontrol.streaming.StreamingStats import dagger.hilt.android.lifecycle.HiltViewModel import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.SharingStarted @@ -18,6 +21,7 @@ import javax.inject.Inject class PlanDetailViewModel @Inject constructor( savedStateHandle: SavedStateHandle, private val streamPlanRepository: StreamPlanRepository, + private val streamingManager: StreamingManager, ) : ViewModel() { private val planId: String = savedStateHandle["planId"] ?: "" @@ -32,6 +36,9 @@ class PlanDetailViewModel @Inject constructor( } } + val streamingState: StateFlow = streamingManager.state + val streamingStats: StateFlow = streamingManager.stats + private val _isLoading = MutableStateFlow(false) val isLoading: StateFlow = _isLoading.asStateFlow() diff --git a/app/src/main/java/com/omixlab/lckcontrol/ui/plans/StreamingStatsCard.kt b/app/src/main/java/com/omixlab/lckcontrol/ui/plans/StreamingStatsCard.kt new file mode 100644 index 0000000..c66b848 --- /dev/null +++ b/app/src/main/java/com/omixlab/lckcontrol/ui/plans/StreamingStatsCard.kt @@ -0,0 +1,52 @@ +package com.omixlab.lckcontrol.ui.plans + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.material3.ElevatedCard +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.unit.dp +import com.omixlab.lckcontrol.streaming.StreamingStats + +@Composable +fun StreamingStatsCard(stats: StreamingStats) { + ElevatedCard(modifier = Modifier.fillMaxWidth()) { + Column( + modifier = Modifier.padding(16.dp), + verticalArrangement = Arrangement.spacedBy(8.dp), + ) { + Text("Streaming Stats", style = MaterialTheme.typography.titleSmall) + + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween, + ) { + StatItem("Video", formatBitrate(stats.videoBitrate)) + StatItem("Audio", formatBitrate(stats.audioBitrate)) + StatItem("FPS", "${stats.fps}") + StatItem("Dropped", "${stats.droppedFrames}") + } + } + } +} + +@Composable +private fun StatItem(label: String, value: String) { + Column { + Text(label, style = MaterialTheme.typography.labelSmall) + Text(value, style = MaterialTheme.typography.bodyMedium) + } +} + +private fun formatBitrate(bps: Long): String { + return when { + bps >= 1_000_000 -> "%.1f Mbps".format(bps / 1_000_000.0) + bps >= 1_000 -> "%.0f kbps".format(bps / 1_000.0) + else -> "$bps bps" + } +} diff --git a/app/src/main/jniLibs/arm64-v8a/libcrypto.so b/app/src/main/jniLibs/arm64-v8a/libcrypto.so new file mode 100644 index 0000000..61f9ed5 Binary files /dev/null and b/app/src/main/jniLibs/arm64-v8a/libcrypto.so differ diff --git a/app/src/main/jniLibs/arm64-v8a/librtmp.so b/app/src/main/jniLibs/arm64-v8a/librtmp.so new file mode 100644 index 0000000..a6aa0a4 Binary files /dev/null and b/app/src/main/jniLibs/arm64-v8a/librtmp.so differ diff --git a/app/src/main/jniLibs/arm64-v8a/libssl.so b/app/src/main/jniLibs/arm64-v8a/libssl.so new file mode 100644 index 0000000..1f78127 Binary files /dev/null and b/app/src/main/jniLibs/arm64-v8a/libssl.so differ diff --git a/app/src/main/jniLibs/arm64-v8a/libz.so b/app/src/main/jniLibs/arm64-v8a/libz.so new file mode 100644 index 0000000..25fda83 Binary files /dev/null and b/app/src/main/jniLibs/arm64-v8a/libz.so differ diff --git a/docs/hub-vs-control-comparison.html b/docs/hub-vs-control-comparison.html new file mode 100644 index 0000000..c573a5f --- /dev/null +++ b/docs/hub-vs-control-comparison.html @@ -0,0 +1,7236 @@ + + + + + LIV Control Center (Hub) vs LCK Control (Companion App) + + + + + + + + + + + + + +

LIV Control Center (Hub) vs LCK Control (Companion App)

+

Comprehensive Architecture Comparison & Unification Strategy

+
+

1. Executive Summary

+

LIV has two parallel applications for managing game streaming on Quest:

+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
Hub (liv-control-center)Control (lck-control)
StageProduction (Quest Store, low reviews)Prototype (new architecture)
StackRust + Tauri + Leptos (WASM)Kotlin + Jetpack Compose + Hilt
StreamingApp captures screen & encodesGame encodes directly from render pipeline
CommunicationAsync via backend serverSynchronous IPC via AIDL
DestinationsSingle targetMulti-destination
UE5 PluginLCKStreaming (HTTP/JSON-RPC)LCKControl (AIDL/JNI)
+
+

2. High-Level Architecture

+

2.1 Hub Architecture

+
graph TB
+    subgraph Quest Headset
+        subgraph "Hub App (Tauri + Leptos WASM)"
+            UI_H["Leptos UI<br/>(WASM)"]
+            Core_H["Rust Core<br/>(Tauri Backend)"]
+            Encoder_H["MediaCodec<br/>H.264 + AAC"]
+            RTMP_H["minirtmp<br/>(RTMP Client)"]
+            Capture["ScreenCaptureService<br/>(MediaProjection)"]
+        end
+        subgraph "UE5 Game"
+            Plugin_S["LCKStreaming Plugin"]
+            API_Client["HTTP/JSON-RPC Client"]
+        end
+    end
+
+    subgraph "Cloud Server"
+        Backend_H["Hub Backend<br/>(api.obi.gg)"]
+    end
+
+    subgraph "Streaming Platforms"
+        YT["YouTube Live"]
+        TW["Twitch"]
+    end
+
+    UI_H <-->|Tauri IPC| Core_H
+    Core_H -->|JSON-RPC 2.0<br/>HTTPS + Cert Pinning| Backend_H
+    Plugin_S -->|JSON-RPC 2.0<br/>HTTPS| Backend_H
+    Backend_H -->|"Device Pairing<br/>(async polling)"| Plugin_S
+    Core_H --> Capture
+    Capture --> Encoder_H
+    Encoder_H --> RTMP_H
+    RTMP_H -->|RTMP| YT
+    RTMP_H -->|RTMP| TW
+
+    style Backend_H fill:#f96,stroke:#333
+    style Capture fill:#ff9,stroke:#333
+    style Encoder_H fill:#ff9,stroke:#333
+

Key: The Hub app captures the screen, encodes it, and streams. The game and hub communicate indirectly through the backend server.

+

2.2 Control App Architecture

+
graph TB
+    subgraph Quest Headset
+        subgraph "Control App (Kotlin + Compose)"
+            UI_C["Compose UI"]
+            VM["ViewModels + Repos"]
+            Service["LckControlService<br/>(Foreground + AIDL)"]
+            DB["Room DB<br/>(Local Cache)"]
+        end
+        subgraph "UE5 Game"
+            Plugin_C["LCKControl Plugin"]
+            JNI["JNI Bridge"]
+            SDK["lck-control-sdk<br/>(AAR)"]
+            Encoder_C["LCK Encoder<br/>(H.264 + AAC)"]
+            RTMP_C1["RTMP Sink 1"]
+            RTMP_C2["RTMP Sink 2"]
+            RTMP_CN["RTMP Sink N"]
+        end
+    end
+
+    subgraph "Self-Hosted Server"
+        Backend_C["Control Backend<br/>(Node.js + Fastify)"]
+        SQLite["SQLite DB"]
+    end
+
+    subgraph "Streaming Platforms"
+        YT2["YouTube Live"]
+        TW2["Twitch"]
+        Manual["Custom RTMP"]
+    end
+
+    UI_C <--> VM
+    VM <-->|REST API<br/>JWT Auth| Backend_C
+    VM <--> DB
+    VM <--> Service
+
+    Plugin_C --> JNI
+    JNI --> SDK
+    SDK <-->|"AIDL IPC<br/>(Bound Service)"| Service
+
+    Backend_C <--> SQLite
+    Backend_C -->|"OAuth + RTMP<br/>Resolution"| YT2
+    Backend_C -->|"OAuth + RTMP<br/>Resolution"| TW2
+
+    Encoder_C --> RTMP_C1
+    Encoder_C --> RTMP_C2
+    Encoder_C --> RTMP_CN
+    RTMP_C1 -->|RTMP| YT2
+    RTMP_C2 -->|RTMP| TW2
+    RTMP_CN -->|RTMP| Manual
+
+    style Service fill:#9f9,stroke:#333
+    style SDK fill:#9f9,stroke:#333
+    style Encoder_C fill:#9cf,stroke:#333
+

Key: The game encodes directly from its render pipeline and streams to multiple destinations. The companion app provides stream configuration via direct IPC.

+
+

3. Communication Model Comparison

+

3.1 Hub: Server-Mediated Async Communication

+
sequenceDiagram
+    participant Game as UE5 Game<br/>(LCKStreaming)
+    participant Server as Hub Backend<br/>(api.obi.gg)
+    participant Hub as Hub App<br/>(Tauri)
+    participant Platform as YouTube/Twitch
+
+    Note over Game,Hub: Device Pairing (one-time)
+    Game->>Server: create_device_login_attempt()
+    Server-->>Game: 6-digit pairing code
+    Game->>Game: Display code to user
+    Hub->>Server: pair_device(code)
+    Server-->>Hub: Device paired
+
+    Note over Game,Hub: Stream Setup
+    Hub->>Server: get_user_profile()
+    Server-->>Hub: Streaming target + RTMP URL
+    Hub->>Hub: Start screen capture
+    Hub->>Hub: Encode H.264 + AAC
+    Hub->>Platform: RTMP stream
+
+    Note over Game,Server: Game has no direct<br/>connection to Hub
+    Game->>Server: Poll for updates (2.5s)
+    Server-->>Game: Current state
+

3.2 Control: Direct IPC Communication

+
sequenceDiagram
+    participant Game as UE5 Game<br/>(LCKControl + JNI)
+    participant App as Control App<br/>(AIDL Service)
+    participant Server as Control Backend<br/>(Fastify)
+    participant Platform as YouTube/Twitch
+
+    Note over Game,App: Service Binding (direct)
+    Game->>App: bindService() via AIDL
+    App-->>Game: ILckControlService binder
+    Game->>App: registerAsClient("MyGame", pkg)
+    App-->>Game: clientId
+
+    Note over Game,Platform: Stream Lifecycle
+    Game->>App: getStreamPlans()
+    App-->>Game: List<StreamPlan>
+    Game->>App: prepareStreamPlan(planId)
+    App->>Server: POST /streams/plans/{id}/prepare
+    Server->>Platform: Create broadcast + get RTMP URLs
+    Platform-->>Server: RTMP URLs + stream keys
+    Server-->>App: PrepareResponse
+    App-->>Game: StreamPlan (with RTMP data)
+
+    Game->>Game: Encode from render pipeline
+    Game->>Platform: RTMP stream (dest 1)
+    Game->>Platform: RTMP stream (dest 2)
+
+    Game->>App: startStreamPlan(planId)
+    App->>Server: POST /streams/plans/{id}/start
+    Server->>Platform: Transition broadcast to LIVE
+
+

4. Technology Stack Comparison

+

4.1 Application Layer

+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
ComponentHubControl
LanguageRust (95%) + Kotlin (JNI)Kotlin (100%)
UI FrameworkLeptos 0.8.2 (Rust WASM)Jetpack Compose (2024.09 BOM)
App FrameworkTauri v2.6.2Native Android
StylingTailwindCSS v4Material Design 3
State MgmtLeptos reactive signalsStateFlow + collectAsStateWithLifecycle
DINone (manual wiring)Hilt 2.59.2
NavigationLeptos RouterCompose Navigation 2.8.4
Local StoragePlatform credential storeRoom 2.8.4 + EncryptedSharedPreferences
HTTP Clientreqwest (rustls TLS)Retrofit 2.11.0 + OkHttp 4.12.0
JSONserde_jsonMoshi 1.15.1
Auth SDKMeta Horizon Platform SDK 77.0.1Meta Horizon Platform SDK 77.0.1
Crash ReportingSentry (Android SDK bridge)None
+

4.2 Backend Layer

+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
ComponentHub BackendControl Backend
HostingCloud (api.obi.gg)Self-hosted (Docker on NAS, port 3100)
ProtocolJSON-RPC 2.0REST (JSON)
StackUnknown (external)Node.js 20 + Fastify 5 + TypeScript 5.7
DatabaseUnknownSQLite (Prisma 6.4 ORM)
AuthJWT (via JSON-RPC response headers)JWT HS256 (jose 6.0)
Token SecurityUnknownAES-256-GCM encryption + SHA256 hashing
OAuthServer handles YouTube/TwitchServer handles YouTube/Twitch
Rate LimitingUnknown100 req/min (Fastify plugin)
DeploymentManaged cloudDocker + docker-compose
+

4.3 UE5 Plugin Layer

+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
ComponentLCKStreaming (Hub)LCKControl (Companion)
CommunicationHTTP/JSON-RPC 2.0AIDL via JNI
TransportHTTPS (cross-network)Local IPC (same device)
Auth FlowDevice code (6-digit) + pollingDirect service binding
Token StorageEncryptedSharedPreferencesNone (companion owns tokens)
RTMP Sinks1 (single destination)N (multi-destination)
Blocking ModelAsync HTTP callbacksSynchronous JNI calls
Platform SupportCross-platform capableAndroid only
LatencyNetwork round-trip (100ms+)IPC (~1ms)
Offline CapableNo (requires server)Partial (companion has local cache)
+
+

5. Streaming Architecture Deep Dive

+

5.1 Hub: Screen Capture + Re-Encoding

+
graph LR
+    subgraph "UE5 Game Process"
+        Render["Game Renderer<br/>(GPU)"]
+    end
+
+    subgraph "Android OS"
+        FB["Framebuffer /<br/>Display Compositor"]
+        MP["MediaProjection<br/>(Screen Capture API)"]
+    end
+
+    subgraph "Hub App Process"
+        VD["VirtualDisplay"]
+        MC_V["MediaCodec<br/>(H.264 Encoder)"]
+        MC_A["MediaCodec<br/>(AAC Encoder)"]
+        AR["AudioRecord<br/>(System Audio)"]
+        MR["minirtmp<br/>(RTMP Client)"]
+    end
+
+    subgraph "CDN"
+        RTMP["YouTube / Twitch<br/>RTMP Ingest"]
+    end
+
+    Render --> FB
+    FB --> MP
+    MP --> VD
+    VD --> MC_V
+    AR --> MC_A
+    MC_V --> MR
+    MC_A --> MR
+    MR --> RTMP
+
+    style FB fill:#fbb,stroke:#333
+    style MP fill:#fbb,stroke:#333
+

Problems:

+
    +
  • Extra GPU copy through display compositor
  • +
  • Re-encoding already rendered frames (quality loss)
  • +
  • Higher latency (capture → encode → send)
  • +
  • Higher battery/thermal impact (two encoding passes)
  • +
  • Captures UI overlays, notifications, system bars
  • +
  • Resolution limited to display resolution
  • +
+

5.2 Control: Direct Render Pipeline Encoding

+
graph LR
+    subgraph "UE5 Game Process"
+        Render["Game Renderer<br/>(GPU)"]
+        SCC["SceneCaptureComponent2D<br/>(Render Target)"]
+        ENC["LCK Encoder<br/>(H.264 + AAC)"]
+        S1["RTMP Sink 1<br/>(YouTube)"]
+        S2["RTMP Sink 2<br/>(Twitch)"]
+        S3["RTMP Sink 3<br/>(Custom)"]
+    end
+
+    subgraph "CDN"
+        YT["YouTube RTMP"]
+        TW["Twitch RTMP"]
+        CU["Custom RTMP"]
+    end
+
+    Render --> SCC
+    SCC --> ENC
+    ENC --> S1
+    ENC --> S2
+    ENC --> S3
+    S1 --> YT
+    S2 --> TW
+    S3 --> CU
+
+    style SCC fill:#bfb,stroke:#333
+    style ENC fill:#bfb,stroke:#333
+

Advantages:

+
    +
  • Direct GPU texture access (no compositor overhead)
  • +
  • Single encode pass (game scene only, no UI clutter)
  • +
  • Lower latency
  • +
  • Lower battery/thermal impact
  • +
  • Configurable resolution independent of display
  • +
  • Multi-destination from single encode
  • +
  • Clean game footage (no system overlays)
  • +
+
+

6. Feature Comparison Matrix

+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
FeatureHubControlNotes
Meta/Quest LoginYesYesBoth use Horizon Platform SDK 77.0.1
YouTube OAuthYesYesBoth server-side token exchange
Twitch OAuthYesYesBoth server-side token exchange
Multi-Destination StreamingNo (1)Yes (N)Major difference
Stream PlansNoYesControl has full lifecycle management
Direct Game EncodingNoYesControl encodes from render pipeline
Screen Capture StreamingYesNoHub captures and re-encodes
Custom RTMP TargetsYesYesBoth support manual RTMP
Game Client ManagementYes (pairing)Yes (AIDL)Different mechanisms
IGDB Game DatabaseYesNoHub has game cover art
WatermarkYesNoHub has overlay support
Subscription ModelYesNoHub has paid tier
Sentry Crash ReportingYesNoHub has telemetry
Certificate PinningYesNoHub has SPKI pinning
Offline CachingNoYesControl has Room DB
Background Token RefreshUnknownYesControl backend has scheduler
CI/CD PipelineYes (Jenkins)Partial (deploy.ps1)Hub has full CI
Desktop SupportYesNoTauri supports desktop
Cross-PlatformYes (Desktop + Android)No (Android only)Hub has wider reach
+
+

7. Pros and Cons

+

7.1 Hub (liv-control-center)

+

Pros

+
    +
  • Cross-platform: Tauri supports Desktop + Android, one codebase
  • +
  • Self-contained streaming: No dependency on game integration
  • +
  • Works with any game: Screen capture works regardless of game engine support
  • +
  • Production infrastructure: Jenkins CI/CD, Sentry, cloud backend
  • +
  • Rich features: IGDB, watermarks, subscription model
  • +
  • Rust performance: Memory-safe, low-level control over encoding
  • +
+

Cons

+
    +
  • Screen capture quality: Re-encoding degrades quality, captures overlays
  • +
  • Higher resource usage: Extra GPU copy + encode pass drains battery faster
  • +
  • Single destination: Can only stream to one platform at a time
  • +
  • Complex stack: Rust + WASM + Tauri + Kotlin JNI is hard to maintain
  • +
  • Server dependency: All communication goes through cloud backend
  • +
  • Latency: Network round-trips for game communication (polling every 2.5s)
  • +
  • Low store reviews: Users experiencing issues (reason for this analysis)
  • +
  • Niche UI framework: Leptos (WASM) has small ecosystem vs Compose
  • +
  • No stream plans: Simple streaming model without plan lifecycle
  • +
+

7.2 Control App (lck-control)

+

Pros

+
    +
  • Direct render pipeline: Game encodes from GPU, best possible quality
  • +
  • Multi-destination: Stream to YouTube + Twitch + custom simultaneously
  • +
  • Low latency IPC: AIDL communication in ~1ms vs 100ms+ network calls
  • +
  • Stream plans: Full lifecycle (DRAFT → READY → LIVE → ENDED)
  • +
  • Clean architecture: Standard Android stack (Compose, Hilt, Room, Retrofit)
  • +
  • Own backend: Full control over API, auth, token management
  • +
  • SDK module: Clean AAR for UE5 consumption via JNI
  • +
  • Lower resource usage: No screen capture or re-encoding overhead
  • +
  • Maintainable: Kotlin + Compose is mainstream Android with large ecosystem
  • +
  • Offline caching: Room DB + encrypted token store
  • +
+

Cons

+
    +
  • Android only: No desktop support
  • +
  • Requires game integration: Game must use LCKControl plugin (not universal)
  • +
  • Prototype stage: Not production-ready yet
  • +
  • Self-hosted backend: Requires infrastructure management (Docker on NAS)
  • +
  • No CI/CD: Manual builds via PowerShell script
  • +
  • No crash reporting: No Sentry or equivalent
  • +
  • No subscription model: No monetization built in
  • +
  • No IGDB integration: No game metadata/artwork
  • +
  • Blocking IPC: Synchronous JNI calls could cause ANRs if slow
  • +
+
+

8. UE5 Plugin Comparison

+

8.1 LCKStreaming Plugin (uses Hub)

+
stateDiagram-v2
+    [*] --> Idle
+    Idle --> LoggingIn: StartLogin()
+    LoggingIn --> WaitingForCode: create_device_login_attempt
+    WaitingForCode --> Polling: Display 6-digit code
+    Polling --> Authenticated: check_device_login_attempt<br/>(every 2.5s)
+    Polling --> Polling: Not yet paired
+    Authenticated --> FetchingProfile: get_user_profile
+    FetchingProfile --> Ready: Got RTMP target
+    Ready --> Streaming: StartStreaming()
+    Streaming --> Ready: StopStreaming()
+    Ready --> Idle: Logout()
+
+    note right of Polling
+        User must manually enter
+        code in Hub app or website
+    end note
+

Architecture:

+
    +
  • ULCKStreamingSubsystem — GameInstance subsystem, owns API client + RTMP sink
  • +
  • FLCKStreamingApiClient — HTTP client, JSON-RPC 2.0, cert pinning
  • +
  • FLCKRtmpSink / FLCKRtmpClient — Single RTMP connection via librtmp
  • +
  • Auth token stored in platform credential store
  • +
  • Single streaming target resolved by backend
  • +
+

8.2 LCKControl Plugin (uses Companion App)

+
stateDiagram-v2
+    [*] --> Disconnected
+    Disconnected --> Connecting: ConnectToCompanionApp()
+    Connecting --> Connected: AIDL service bound<br/>(poll every 1s)
+    Connected --> HasPlans: GetStreamPlans()
+    HasPlans --> Prepared: PrepareStreamPlan(planId)<br/>→ RTMP URLs resolved
+    Prepared --> Streaming: StartStreamPlan(planId)<br/>+ Attach N RTMP sinks
+    Streaming --> Prepared: EndStreamPlan(planId)
+    Connected --> Disconnected: DisconnectFromCompanionApp()
+
+    note right of Connected
+        Direct AIDL binding,
+        no pairing code needed
+    end note
+
+    note right of Streaming
+        Multiple RTMP sinks active
+        simultaneously
+    end note
+

Architecture:

+
    +
  • ULCKControlSubsystem — GameInstance subsystem, owns JNI bridge + multiple RTMP sinks
  • +
  • LCKControlAndroid.cpp — ~700 lines of JNI bindings to LckControlClient (AAR)
  • +
  • Multiple FLCKRtmpSink instances — one per stream destination
  • +
  • No token management — companion app handles all auth
  • +
  • Full stream plan lifecycle control
  • +
+

8.3 Shared Infrastructure (LCK Base Plugin)

+

Both plugins share:

+
    +
  • ILCKStreamingFeature — Common interface (StartLogin, StartStreaming, StopStreaming, etc.)
  • +
  • ILCKEncoderFactory — Encoder creation
  • +
  • ULCKRecorderSubsystem — Encoder lifecycle management
  • +
  • FLCKRtmpSink / FLCKRtmpClient — RTMP transport layer
  • +
  • H.264 + AAC encoding via platform-specific backends (NVCodec, MediaCodec)
  • +
+
+

9. Backend Comparison

+

9.1 Hub Backend (api.obi.gg)

+
graph TB
+    subgraph "Cloud (Managed)"
+        API_H["Hub Backend API"]
+        DB_H["Database<br/>(Unknown)"]
+        IGDB["IGDB API<br/>(Game Metadata)"]
+    end
+
+    Hub["Hub App"] -->|"JSON-RPC 2.0<br/>POST /api/rpc"| API_H
+    Game_S["LCKStreaming<br/>Plugin"] -->|"JSON-RPC 2.0<br/>POST /api/rpc"| API_H
+    API_H --> DB_H
+    API_H --> IGDB
+
+    style API_H fill:#f96,stroke:#333
+

Known RPC Methods:

+
    +
  • LoginUser, RefreshUser — Auth
  • +
  • ListMyStreamingTargets, CreateStreamingTarget, UpdateStreamingTarget, DeleteStreamingTarget — Targets
  • +
  • PairDevice, UnpairDevice, GetConnectedGames — Device management
  • +
  • StartStreaming, StopStreaming — Stream events
  • +
  • SearchIgdbGames — Game metadata
  • +
  • CreateOauthConnectIntent, GetOauthConnectIntent — OAuth
  • +
+

9.2 Control Backend (lck-control-backend)

+
graph TB
+    subgraph "Self-Hosted (Docker on NAS)"
+        API_C["Fastify 5 API<br/>(TypeScript)"]
+        Prisma["Prisma 6.4 ORM"]
+        SQLite["SQLite DB"]
+        Scheduler["Token Refresh<br/>Scheduler (10min)"]
+    end
+
+    App["Control App"] -->|"REST API<br/>JWT Bearer Auth"| API_C
+    API_C --> Prisma --> SQLite
+    Scheduler --> API_C
+
+    API_C -->|OAuth| Google["Google OAuth"]
+    API_C -->|OAuth| Twitch_API["Twitch OAuth"]
+    API_C -->|Nonce Validate| Meta_Graph["Meta Graph API"]
+    API_C -->|Live API| YT_API["YouTube Live API"]
+    API_C -->|Helix API| TW_API["Twitch Helix API"]
+
+    style API_C fill:#9cf,stroke:#333
+

REST Endpoints:

+ + + + + + + + + + + + + + + + + + + + + + + + + +
GroupEndpoints
AuthPOST /auth/meta/callback, POST /auth/refresh, GET /auth/me, POST /auth/logout
ProvidersGET /providers/accounts, GET /providers/{yt|tw}/auth-url, POST /providers/{yt|tw}/callback, DELETE /providers/:serviceId
StreamsGET /streams/plans, POST /streams/plans, GET /streams/plans/:id, DELETE /streams/plans/:id
LifecyclePOST /streams/plans/:id/prepare, POST /streams/plans/:id/start, POST /streams/plans/:id/end
+
+

10. Data Flow Comparison

+

10.1 Hub: Centralized Server Model

+
graph LR
+    subgraph "Data Ownership"
+        direction TB
+        Server_H["Hub Backend<br/>(owns ALL data)"]
+    end
+
+    Hub_App["Hub App<br/>(thin client)"] <-->|"All state via<br/>JSON-RPC"| Server_H
+    Game_H["UE5 Game<br/>(paired device)"] <-->|"All state via<br/>JSON-RPC"| Server_H
+    YT_H["YouTube API"] <--> Server_H
+    TW_H["Twitch API"] <--> Server_H
+
+    style Server_H fill:#f96,stroke:#333
+
    +
  • Single source of truth: Backend server
  • +
  • No local cache: App relies on network for all state
  • +
  • Game is decoupled: Only communicates with server, never with app
  • +
  • Offline = broken: Cannot function without server connectivity
  • +
+

10.2 Control: Distributed Ownership Model

+
graph LR
+    subgraph "Data Ownership"
+        direction TB
+        Server_C["Control Backend<br/>(tokens, plans,<br/>OAuth)"]
+        App_C["Control App<br/>(local cache,<br/>session tokens)"]
+        Game_C["UE5 Game<br/>(RTMP streams)"]
+    end
+
+    App_C <-->|REST API| Server_C
+    Game_C <-->|"AIDL IPC<br/>(stream plans,<br/>RTMP config)"| App_C
+    Server_C <--> YT_C["YouTube API"]
+    Server_C <--> TW_C["Twitch API"]
+    Game_C -->|"RTMP<br/>(direct)"| CDN["YouTube / Twitch<br/>RTMP Ingest"]
+
+    style App_C fill:#9f9,stroke:#333
+    style Game_C fill:#9cf,stroke:#333
+
    +
  • Distributed state: Backend (tokens, plans), App (cache, session), Game (streams)
  • +
  • Local caching: Room DB provides offline access to plans and accounts
  • +
  • Game is tightly coupled: Direct IPC with companion app
  • +
  • Partial offline: Can view cached plans without network
  • +
+
+

11. Unification Strategy

+ +

The Control architecture is fundamentally superior for game streaming because:

+
    +
  1. Direct encode > screen capture — Quality, performance, and battery life
  2. +
  3. Multi-destination > single target — Key user-facing feature
  4. +
  5. IPC > server polling — Reliability and responsiveness
  6. +
  7. Stream plans > ad-hoc streaming — Better UX for recurring setups
  8. +
  9. Standard Android stack > Rust/WASM — Easier maintenance and hiring
  10. +
+

11.2 Migration Roadmap

+
gantt
+    title Unification Roadmap
+    dateFormat YYYY-MM-DD
+    axisFormat %b %Y
+
+    section Phase 1: Production Readiness
+    CI/CD pipeline (Jenkins/GH Actions)        :p1a, 2026-03-01, 14d
+    Sentry crash reporting                      :p1b, 2026-03-01, 7d
+    Backend deploy to cloud                     :p1c, 2026-03-08, 7d
+    Certificate pinning (OkHttp)                :p1d, 2026-03-08, 3d
+
+    section Phase 2: Feature Parity
+    IGDB game metadata integration              :p2a, 2026-03-15, 7d
+    Watermark / overlay support in encoder      :p2b, 2026-03-15, 10d
+    Subscription model + paywall                :p2c, 2026-03-22, 14d
+
+    section Phase 3: Hub Migration
+    Add fallback screen-capture mode            :p3a, 2026-04-05, 14d
+    Port device pairing (for non-integrated games) :p3b, 2026-04-05, 10d
+    Migrate Hub users to Control                :p3c, 2026-04-19, 14d
+    Deprecate Hub app                           :p3d, 2026-05-03, 7d
+
+    section Phase 4: Polish
+    Desktop companion (optional)                :p4a, 2026-05-10, 21d
+    Advanced stream analytics                   :p4b, 2026-05-10, 14d
+    Store listing + marketing                   :p4c, 2026-05-24, 7d
+

11.3 What to Keep from Each

+
graph TB
+    subgraph "Unified App"
+        direction TB
+        A["Control App Architecture<br/>(Kotlin + Compose + Hilt)"]
+        B["Control Backend<br/>(Fastify + Prisma + SQLite)"]
+        C["LCKControl Plugin<br/>(AIDL + multi-destination)"]
+        D["Stream Plan System<br/>(DRAFT → READY → LIVE → ENDED)"]
+    end
+
+    subgraph "Adopt from Hub"
+        E["Sentry Crash Reporting"]
+        F["IGDB Game Database"]
+        G["Certificate Pinning"]
+        H["Jenkins CI/CD"]
+        I["Watermark Renderer"]
+        J["Screen Capture Fallback"]
+    end
+
+    subgraph "Discard"
+        K["Rust/Tauri/Leptos Stack"]
+        L["JSON-RPC 2.0 Protocol"]
+        M["minirtmp (Rust RTMP)"]
+        N["Device Code Pairing<br/>(replaced by AIDL)"]
+        O["Single-Destination Limit"]
+    end
+
+    E --> A
+    F --> B
+    G --> A
+    H --> A
+    I --> C
+    J --> A
+
+    style A fill:#9f9,stroke:#333
+    style B fill:#9cf,stroke:#333
+    style C fill:#9f9,stroke:#333
+    style D fill:#9f9,stroke:#333
+    style K fill:#fbb,stroke:#333
+    style L fill:#fbb,stroke:#333
+    style M fill:#fbb,stroke:#333
+    style N fill:#fbb,stroke:#333
+    style O fill:#fbb,stroke:#333
+

11.4 Hybrid Mode: Screen Capture Fallback

+

To maintain the Hub's "works with any game" advantage, add a fallback path:

+
graph TB
+    Start["Game Launches"] --> Check{"LCKControl Plugin<br/>integrated?"}
+    Check -->|Yes| AIDL["AIDL IPC Path<br/>(direct encode,<br/>multi-destination)"]
+    Check -->|No| Capture["Screen Capture Path<br/>(MediaProjection,<br/>single destination)"]
+    AIDL --> Stream["Stream to Platforms"]
+    Capture --> Stream
+
+    style AIDL fill:#9f9,stroke:#333
+    style Capture fill:#ff9,stroke:#333
+

This gives the unified app both modes:

+
    +
  • Primary: Direct encoding via AIDL (high quality, multi-destination)
  • +
  • Fallback: Screen capture for games without plugin integration (compatibility)
  • +
+
+

12. Risk Assessment

+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
RiskImpactMitigation
Hub users lose access during migrationHighRun both apps in parallel during transition, provide migration guide
AIDL only works on Android (no desktop)MediumScreen capture fallback for desktop; evaluate PCVR needs later
Self-hosted backend scalabilityMediumMove to managed cloud (Railway, Fly.io) before store launch
Synchronous JNI blocking causes ANRMediumAdd timeout handling, move to async callback pattern
No subscription model in ControlLowImplement before store launch using existing Hub billing logic
Losing crash telemetryLowAdd Sentry SDK early in Phase 1
+
+

13. Summary Decision Matrix

+
quadrantChart
+    title Streaming Quality vs Maintenance Complexity
+    x-axis Low Maintenance --> High Maintenance
+    y-axis Low Quality --> High Quality
+    quadrant-1 Ideal
+    quadrant-2 Overengineered
+    quadrant-3 Avoid
+    quadrant-4 Quick & Dirty
+
+    Control App: [0.35, 0.85]
+    Hub App: [0.75, 0.45]
+    Unified - Recommended: [0.45, 0.90]
+

Recommendation: The Control App architecture with adopted Hub features provides the best path forward — higher streaming quality with a more maintainable stack. The Hub's Rust/Tauri/Leptos stack adds significant complexity without proportional benefits for an Android-focused product.

+
+

Document generated 2026-02-26. Based on analysis of liv-control-center, lck-control, lck-control-backend, and LCKGame codebases.

+ + + + + + \ No newline at end of file diff --git a/docs/hub-vs-control-comparison.md b/docs/hub-vs-control-comparison.md new file mode 100644 index 0000000..9cfa06b --- /dev/null +++ b/docs/hub-vs-control-comparison.md @@ -0,0 +1,745 @@ +# LIV Control Center (Hub) vs LCK Control (Companion App) + +## Comprehensive Architecture Comparison & Unification Strategy + +--- + +## 1. Executive Summary + +LIV has two parallel applications for managing game streaming on Quest: + +| | **Hub** (`liv-control-center`) | **Control** (`lck-control`) | +|---|---|---| +| **Stage** | Production (Quest Store, low reviews) | Prototype (new architecture) | +| **Stack** | Rust + Tauri + Leptos (WASM) | Kotlin + Jetpack Compose + Hilt | +| **Streaming** | App captures screen & encodes | Game encodes directly from render pipeline | +| **Communication** | Async via backend server | Synchronous IPC via AIDL | +| **Destinations** | Single target | Multi-destination | +| **UE5 Plugin** | `LCKStreaming` (HTTP/JSON-RPC) | `LCKControl` (AIDL/JNI) | + +--- + +## 2. High-Level Architecture + +### 2.1 Hub Architecture + +```mermaid +graph TB + subgraph Quest Headset + subgraph "Hub App (Tauri + Leptos WASM)" + UI_H["Leptos UI
(WASM)"] + Core_H["Rust Core
(Tauri Backend)"] + Encoder_H["MediaCodec
H.264 + AAC"] + RTMP_H["minirtmp
(RTMP Client)"] + Capture["ScreenCaptureService
(MediaProjection)"] + end + subgraph "UE5 Game" + Plugin_S["LCKStreaming Plugin"] + API_Client["HTTP/JSON-RPC Client"] + end + end + + subgraph "Cloud Server" + Backend_H["Hub Backend
(api.obi.gg)"] + end + + subgraph "Streaming Platforms" + YT["YouTube Live"] + TW["Twitch"] + end + + UI_H <-->|Tauri IPC| Core_H + Core_H -->|JSON-RPC 2.0
HTTPS + Cert Pinning| Backend_H + Plugin_S -->|JSON-RPC 2.0
HTTPS| Backend_H + Backend_H -->|"Device Pairing
(async polling)"| Plugin_S + Core_H --> Capture + Capture --> Encoder_H + Encoder_H --> RTMP_H + RTMP_H -->|RTMP| YT + RTMP_H -->|RTMP| TW + + style Backend_H fill:#f96,stroke:#333 + style Capture fill:#ff9,stroke:#333 + style Encoder_H fill:#ff9,stroke:#333 +``` + +**Key: The Hub app captures the screen, encodes it, and streams. The game and hub communicate indirectly through the backend server.** + +### 2.2 Control App Architecture + +```mermaid +graph TB + subgraph Quest Headset + subgraph "Control App (Kotlin + Compose)" + UI_C["Compose UI"] + VM["ViewModels + Repos"] + Service["LckControlService
(Foreground + AIDL)"] + DB["Room DB
(Local Cache)"] + end + subgraph "UE5 Game" + Plugin_C["LCKControl Plugin"] + JNI["JNI Bridge"] + SDK["lck-control-sdk
(AAR)"] + Encoder_C["LCK Encoder
(H.264 + AAC)"] + RTMP_C1["RTMP Sink 1"] + RTMP_C2["RTMP Sink 2"] + RTMP_CN["RTMP Sink N"] + end + end + + subgraph "Self-Hosted Server" + Backend_C["Control Backend
(Node.js + Fastify)"] + SQLite["SQLite DB"] + end + + subgraph "Streaming Platforms" + YT2["YouTube Live"] + TW2["Twitch"] + Manual["Custom RTMP"] + end + + UI_C <--> VM + VM <-->|REST API
JWT Auth| Backend_C + VM <--> DB + VM <--> Service + + Plugin_C --> JNI + JNI --> SDK + SDK <-->|"AIDL IPC
(Bound Service)"| Service + + Backend_C <--> SQLite + Backend_C -->|"OAuth + RTMP
Resolution"| YT2 + Backend_C -->|"OAuth + RTMP
Resolution"| TW2 + + Encoder_C --> RTMP_C1 + Encoder_C --> RTMP_C2 + Encoder_C --> RTMP_CN + RTMP_C1 -->|RTMP| YT2 + RTMP_C2 -->|RTMP| TW2 + RTMP_CN -->|RTMP| Manual + + style Service fill:#9f9,stroke:#333 + style SDK fill:#9f9,stroke:#333 + style Encoder_C fill:#9cf,stroke:#333 +``` + +**Key: The game encodes directly from its render pipeline and streams to multiple destinations. The companion app provides stream configuration via direct IPC.** + +--- + +## 3. Communication Model Comparison + +### 3.1 Hub: Server-Mediated Async Communication + +```mermaid +sequenceDiagram + participant Game as UE5 Game
(LCKStreaming) + participant Server as Hub Backend
(api.obi.gg) + participant Hub as Hub App
(Tauri) + participant Platform as YouTube/Twitch + + Note over Game,Hub: Device Pairing (one-time) + Game->>Server: create_device_login_attempt() + Server-->>Game: 6-digit pairing code + Game->>Game: Display code to user + Hub->>Server: pair_device(code) + Server-->>Hub: Device paired + + Note over Game,Hub: Stream Setup + Hub->>Server: get_user_profile() + Server-->>Hub: Streaming target + RTMP URL + Hub->>Hub: Start screen capture + Hub->>Hub: Encode H.264 + AAC + Hub->>Platform: RTMP stream + + Note over Game,Server: Game has no direct
connection to Hub + Game->>Server: Poll for updates (2.5s) + Server-->>Game: Current state +``` + +### 3.2 Control: Direct IPC Communication + +```mermaid +sequenceDiagram + participant Game as UE5 Game
(LCKControl + JNI) + participant App as Control App
(AIDL Service) + participant Server as Control Backend
(Fastify) + participant Platform as YouTube/Twitch + + Note over Game,App: Service Binding (direct) + Game->>App: bindService() via AIDL + App-->>Game: ILckControlService binder + Game->>App: registerAsClient("MyGame", pkg) + App-->>Game: clientId + + Note over Game,Platform: Stream Lifecycle + Game->>App: getStreamPlans() + App-->>Game: List + Game->>App: prepareStreamPlan(planId) + App->>Server: POST /streams/plans/{id}/prepare + Server->>Platform: Create broadcast + get RTMP URLs + Platform-->>Server: RTMP URLs + stream keys + Server-->>App: PrepareResponse + App-->>Game: StreamPlan (with RTMP data) + + Game->>Game: Encode from render pipeline + Game->>Platform: RTMP stream (dest 1) + Game->>Platform: RTMP stream (dest 2) + + Game->>App: startStreamPlan(planId) + App->>Server: POST /streams/plans/{id}/start + Server->>Platform: Transition broadcast to LIVE +``` + +--- + +## 4. Technology Stack Comparison + +### 4.1 Application Layer + +| Component | Hub | Control | +|-----------|-----|---------| +| **Language** | Rust (95%) + Kotlin (JNI) | Kotlin (100%) | +| **UI Framework** | Leptos 0.8.2 (Rust WASM) | Jetpack Compose (2024.09 BOM) | +| **App Framework** | Tauri v2.6.2 | Native Android | +| **Styling** | TailwindCSS v4 | Material Design 3 | +| **State Mgmt** | Leptos reactive signals | StateFlow + collectAsStateWithLifecycle | +| **DI** | None (manual wiring) | Hilt 2.59.2 | +| **Navigation** | Leptos Router | Compose Navigation 2.8.4 | +| **Local Storage** | Platform credential store | Room 2.8.4 + EncryptedSharedPreferences | +| **HTTP Client** | reqwest (rustls TLS) | Retrofit 2.11.0 + OkHttp 4.12.0 | +| **JSON** | serde_json | Moshi 1.15.1 | +| **Auth SDK** | Meta Horizon Platform SDK 77.0.1 | Meta Horizon Platform SDK 77.0.1 | +| **Crash Reporting** | Sentry (Android SDK bridge) | None | + +### 4.2 Backend Layer + +| Component | Hub Backend | Control Backend | +|-----------|-------------|-----------------| +| **Hosting** | Cloud (`api.obi.gg`) | Self-hosted (Docker on NAS, port 3100) | +| **Protocol** | JSON-RPC 2.0 | REST (JSON) | +| **Stack** | Unknown (external) | Node.js 20 + Fastify 5 + TypeScript 5.7 | +| **Database** | Unknown | SQLite (Prisma 6.4 ORM) | +| **Auth** | JWT (via JSON-RPC response headers) | JWT HS256 (jose 6.0) | +| **Token Security** | Unknown | AES-256-GCM encryption + SHA256 hashing | +| **OAuth** | Server handles YouTube/Twitch | Server handles YouTube/Twitch | +| **Rate Limiting** | Unknown | 100 req/min (Fastify plugin) | +| **Deployment** | Managed cloud | Docker + docker-compose | + +### 4.3 UE5 Plugin Layer + +| Component | LCKStreaming (Hub) | LCKControl (Companion) | +|-----------|-------------------|----------------------| +| **Communication** | HTTP/JSON-RPC 2.0 | AIDL via JNI | +| **Transport** | HTTPS (cross-network) | Local IPC (same device) | +| **Auth Flow** | Device code (6-digit) + polling | Direct service binding | +| **Token Storage** | EncryptedSharedPreferences | None (companion owns tokens) | +| **RTMP Sinks** | 1 (single destination) | N (multi-destination) | +| **Blocking Model** | Async HTTP callbacks | Synchronous JNI calls | +| **Platform Support** | Cross-platform capable | Android only | +| **Latency** | Network round-trip (100ms+) | IPC (~1ms) | +| **Offline Capable** | No (requires server) | Partial (companion has local cache) | + +--- + +## 5. Streaming Architecture Deep Dive + +### 5.1 Hub: Screen Capture + Re-Encoding + +```mermaid +graph LR + subgraph "UE5 Game Process" + Render["Game Renderer
(GPU)"] + end + + subgraph "Android OS" + FB["Framebuffer /
Display Compositor"] + MP["MediaProjection
(Screen Capture API)"] + end + + subgraph "Hub App Process" + VD["VirtualDisplay"] + MC_V["MediaCodec
(H.264 Encoder)"] + MC_A["MediaCodec
(AAC Encoder)"] + AR["AudioRecord
(System Audio)"] + MR["minirtmp
(RTMP Client)"] + end + + subgraph "CDN" + RTMP["YouTube / Twitch
RTMP Ingest"] + end + + Render --> FB + FB --> MP + MP --> VD + VD --> MC_V + AR --> MC_A + MC_V --> MR + MC_A --> MR + MR --> RTMP + + style FB fill:#fbb,stroke:#333 + style MP fill:#fbb,stroke:#333 +``` + +**Problems:** +- Extra GPU copy through display compositor +- Re-encoding already rendered frames (quality loss) +- Higher latency (capture → encode → send) +- Higher battery/thermal impact (two encoding passes) +- Captures UI overlays, notifications, system bars +- Resolution limited to display resolution + +### 5.2 Control: Direct Render Pipeline Encoding + +```mermaid +graph LR + subgraph "UE5 Game Process" + Render["Game Renderer
(GPU)"] + SCC["SceneCaptureComponent2D
(Render Target)"] + ENC["LCK Encoder
(H.264 + AAC)"] + S1["RTMP Sink 1
(YouTube)"] + S2["RTMP Sink 2
(Twitch)"] + S3["RTMP Sink 3
(Custom)"] + end + + subgraph "CDN" + YT["YouTube RTMP"] + TW["Twitch RTMP"] + CU["Custom RTMP"] + end + + Render --> SCC + SCC --> ENC + ENC --> S1 + ENC --> S2 + ENC --> S3 + S1 --> YT + S2 --> TW + S3 --> CU + + style SCC fill:#bfb,stroke:#333 + style ENC fill:#bfb,stroke:#333 +``` + +**Advantages:** +- Direct GPU texture access (no compositor overhead) +- Single encode pass (game scene only, no UI clutter) +- Lower latency +- Lower battery/thermal impact +- Configurable resolution independent of display +- Multi-destination from single encode +- Clean game footage (no system overlays) + +--- + +## 6. Feature Comparison Matrix + +| Feature | Hub | Control | Notes | +|---------|:---:|:-------:|-------| +| **Meta/Quest Login** | Yes | Yes | Both use Horizon Platform SDK 77.0.1 | +| **YouTube OAuth** | Yes | Yes | Both server-side token exchange | +| **Twitch OAuth** | Yes | Yes | Both server-side token exchange | +| **Multi-Destination Streaming** | No (1) | Yes (N) | Major difference | +| **Stream Plans** | No | Yes | Control has full lifecycle management | +| **Direct Game Encoding** | No | Yes | Control encodes from render pipeline | +| **Screen Capture Streaming** | Yes | No | Hub captures and re-encodes | +| **Custom RTMP Targets** | Yes | Yes | Both support manual RTMP | +| **Game Client Management** | Yes (pairing) | Yes (AIDL) | Different mechanisms | +| **IGDB Game Database** | Yes | No | Hub has game cover art | +| **Watermark** | Yes | No | Hub has overlay support | +| **Subscription Model** | Yes | No | Hub has paid tier | +| **Sentry Crash Reporting** | Yes | No | Hub has telemetry | +| **Certificate Pinning** | Yes | No | Hub has SPKI pinning | +| **Offline Caching** | No | Yes | Control has Room DB | +| **Background Token Refresh** | Unknown | Yes | Control backend has scheduler | +| **CI/CD Pipeline** | Yes (Jenkins) | Partial (deploy.ps1) | Hub has full CI | +| **Desktop Support** | Yes | No | Tauri supports desktop | +| **Cross-Platform** | Yes (Desktop + Android) | No (Android only) | Hub has wider reach | + +--- + +## 7. Pros and Cons + +### 7.1 Hub (liv-control-center) + +#### Pros +- **Cross-platform**: Tauri supports Desktop + Android, one codebase +- **Self-contained streaming**: No dependency on game integration +- **Works with any game**: Screen capture works regardless of game engine support +- **Production infrastructure**: Jenkins CI/CD, Sentry, cloud backend +- **Rich features**: IGDB, watermarks, subscription model +- **Rust performance**: Memory-safe, low-level control over encoding + +#### Cons +- **Screen capture quality**: Re-encoding degrades quality, captures overlays +- **Higher resource usage**: Extra GPU copy + encode pass drains battery faster +- **Single destination**: Can only stream to one platform at a time +- **Complex stack**: Rust + WASM + Tauri + Kotlin JNI is hard to maintain +- **Server dependency**: All communication goes through cloud backend +- **Latency**: Network round-trips for game communication (polling every 2.5s) +- **Low store reviews**: Users experiencing issues (reason for this analysis) +- **Niche UI framework**: Leptos (WASM) has small ecosystem vs Compose +- **No stream plans**: Simple streaming model without plan lifecycle + +### 7.2 Control App (lck-control) + +#### Pros +- **Direct render pipeline**: Game encodes from GPU, best possible quality +- **Multi-destination**: Stream to YouTube + Twitch + custom simultaneously +- **Low latency IPC**: AIDL communication in ~1ms vs 100ms+ network calls +- **Stream plans**: Full lifecycle (DRAFT → READY → LIVE → ENDED) +- **Clean architecture**: Standard Android stack (Compose, Hilt, Room, Retrofit) +- **Own backend**: Full control over API, auth, token management +- **SDK module**: Clean AAR for UE5 consumption via JNI +- **Lower resource usage**: No screen capture or re-encoding overhead +- **Maintainable**: Kotlin + Compose is mainstream Android with large ecosystem +- **Offline caching**: Room DB + encrypted token store + +#### Cons +- **Android only**: No desktop support +- **Requires game integration**: Game must use LCKControl plugin (not universal) +- **Prototype stage**: Not production-ready yet +- **Self-hosted backend**: Requires infrastructure management (Docker on NAS) +- **No CI/CD**: Manual builds via PowerShell script +- **No crash reporting**: No Sentry or equivalent +- **No subscription model**: No monetization built in +- **No IGDB integration**: No game metadata/artwork +- **Blocking IPC**: Synchronous JNI calls could cause ANRs if slow + +--- + +## 8. UE5 Plugin Comparison + +### 8.1 LCKStreaming Plugin (uses Hub) + +```mermaid +stateDiagram-v2 + [*] --> Idle + Idle --> LoggingIn: StartLogin() + LoggingIn --> WaitingForCode: create_device_login_attempt + WaitingForCode --> Polling: Display 6-digit code + Polling --> Authenticated: check_device_login_attempt
(every 2.5s) + Polling --> Polling: Not yet paired + Authenticated --> FetchingProfile: get_user_profile + FetchingProfile --> Ready: Got RTMP target + Ready --> Streaming: StartStreaming() + Streaming --> Ready: StopStreaming() + Ready --> Idle: Logout() + + note right of Polling + User must manually enter + code in Hub app or website + end note +``` + +**Architecture:** +- `ULCKStreamingSubsystem` — GameInstance subsystem, owns API client + RTMP sink +- `FLCKStreamingApiClient` — HTTP client, JSON-RPC 2.0, cert pinning +- `FLCKRtmpSink` / `FLCKRtmpClient` — Single RTMP connection via librtmp +- Auth token stored in platform credential store +- Single streaming target resolved by backend + +### 8.2 LCKControl Plugin (uses Companion App) + +```mermaid +stateDiagram-v2 + [*] --> Disconnected + Disconnected --> Connecting: ConnectToCompanionApp() + Connecting --> Connected: AIDL service bound
(poll every 1s) + Connected --> HasPlans: GetStreamPlans() + HasPlans --> Prepared: PrepareStreamPlan(planId)
→ RTMP URLs resolved + Prepared --> Streaming: StartStreamPlan(planId)
+ Attach N RTMP sinks + Streaming --> Prepared: EndStreamPlan(planId) + Connected --> Disconnected: DisconnectFromCompanionApp() + + note right of Connected + Direct AIDL binding, + no pairing code needed + end note + + note right of Streaming + Multiple RTMP sinks active + simultaneously + end note +``` + +**Architecture:** +- `ULCKControlSubsystem` — GameInstance subsystem, owns JNI bridge + multiple RTMP sinks +- `LCKControlAndroid.cpp` — ~700 lines of JNI bindings to `LckControlClient` (AAR) +- Multiple `FLCKRtmpSink` instances — one per stream destination +- No token management — companion app handles all auth +- Full stream plan lifecycle control + +### 8.3 Shared Infrastructure (LCK Base Plugin) + +Both plugins share: +- `ILCKStreamingFeature` — Common interface (StartLogin, StartStreaming, StopStreaming, etc.) +- `ILCKEncoderFactory` — Encoder creation +- `ULCKRecorderSubsystem` — Encoder lifecycle management +- `FLCKRtmpSink` / `FLCKRtmpClient` — RTMP transport layer +- H.264 + AAC encoding via platform-specific backends (NVCodec, MediaCodec) + +--- + +## 9. Backend Comparison + +### 9.1 Hub Backend (`api.obi.gg`) + +```mermaid +graph TB + subgraph "Cloud (Managed)" + API_H["Hub Backend API"] + DB_H["Database
(Unknown)"] + IGDB["IGDB API
(Game Metadata)"] + end + + Hub["Hub App"] -->|"JSON-RPC 2.0
POST /api/rpc"| API_H + Game_S["LCKStreaming
Plugin"] -->|"JSON-RPC 2.0
POST /api/rpc"| API_H + API_H --> DB_H + API_H --> IGDB + + style API_H fill:#f96,stroke:#333 +``` + +**Known RPC Methods:** +- `LoginUser`, `RefreshUser` — Auth +- `ListMyStreamingTargets`, `CreateStreamingTarget`, `UpdateStreamingTarget`, `DeleteStreamingTarget` — Targets +- `PairDevice`, `UnpairDevice`, `GetConnectedGames` — Device management +- `StartStreaming`, `StopStreaming` — Stream events +- `SearchIgdbGames` — Game metadata +- `CreateOauthConnectIntent`, `GetOauthConnectIntent` — OAuth + +### 9.2 Control Backend (`lck-control-backend`) + +```mermaid +graph TB + subgraph "Self-Hosted (Docker on NAS)" + API_C["Fastify 5 API
(TypeScript)"] + Prisma["Prisma 6.4 ORM"] + SQLite["SQLite DB"] + Scheduler["Token Refresh
Scheduler (10min)"] + end + + App["Control App"] -->|"REST API
JWT Bearer Auth"| API_C + API_C --> Prisma --> SQLite + Scheduler --> API_C + + API_C -->|OAuth| Google["Google OAuth"] + API_C -->|OAuth| Twitch_API["Twitch OAuth"] + API_C -->|Nonce Validate| Meta_Graph["Meta Graph API"] + API_C -->|Live API| YT_API["YouTube Live API"] + API_C -->|Helix API| TW_API["Twitch Helix API"] + + style API_C fill:#9cf,stroke:#333 +``` + +**REST Endpoints:** +| Group | Endpoints | +|-------|-----------| +| Auth | `POST /auth/meta/callback`, `POST /auth/refresh`, `GET /auth/me`, `POST /auth/logout` | +| Providers | `GET /providers/accounts`, `GET /providers/{yt\|tw}/auth-url`, `POST /providers/{yt\|tw}/callback`, `DELETE /providers/:serviceId` | +| Streams | `GET /streams/plans`, `POST /streams/plans`, `GET /streams/plans/:id`, `DELETE /streams/plans/:id` | +| Lifecycle | `POST /streams/plans/:id/prepare`, `POST /streams/plans/:id/start`, `POST /streams/plans/:id/end` | + +--- + +## 10. Data Flow Comparison + +### 10.1 Hub: Centralized Server Model + +```mermaid +graph LR + subgraph "Data Ownership" + direction TB + Server_H["Hub Backend
(owns ALL data)"] + end + + Hub_App["Hub App
(thin client)"] <-->|"All state via
JSON-RPC"| Server_H + Game_H["UE5 Game
(paired device)"] <-->|"All state via
JSON-RPC"| Server_H + YT_H["YouTube API"] <--> Server_H + TW_H["Twitch API"] <--> Server_H + + style Server_H fill:#f96,stroke:#333 +``` + +- **Single source of truth**: Backend server +- **No local cache**: App relies on network for all state +- **Game is decoupled**: Only communicates with server, never with app +- **Offline = broken**: Cannot function without server connectivity + +### 10.2 Control: Distributed Ownership Model + +```mermaid +graph LR + subgraph "Data Ownership" + direction TB + Server_C["Control Backend
(tokens, plans,
OAuth)"] + App_C["Control App
(local cache,
session tokens)"] + Game_C["UE5 Game
(RTMP streams)"] + end + + App_C <-->|REST API| Server_C + Game_C <-->|"AIDL IPC
(stream plans,
RTMP config)"| App_C + Server_C <--> YT_C["YouTube API"] + Server_C <--> TW_C["Twitch API"] + Game_C -->|"RTMP
(direct)"| CDN["YouTube / Twitch
RTMP Ingest"] + + style App_C fill:#9f9,stroke:#333 + style Game_C fill:#9cf,stroke:#333 +``` + +- **Distributed state**: Backend (tokens, plans), App (cache, session), Game (streams) +- **Local caching**: Room DB provides offline access to plans and accounts +- **Game is tightly coupled**: Direct IPC with companion app +- **Partial offline**: Can view cached plans without network + +--- + +## 11. Unification Strategy + +### 11.1 Recommended Direction: Evolve Control App into Production + +The Control architecture is fundamentally superior for game streaming because: + +1. **Direct encode > screen capture** — Quality, performance, and battery life +2. **Multi-destination > single target** — Key user-facing feature +3. **IPC > server polling** — Reliability and responsiveness +4. **Stream plans > ad-hoc streaming** — Better UX for recurring setups +5. **Standard Android stack > Rust/WASM** — Easier maintenance and hiring + +### 11.2 Migration Roadmap + +```mermaid +gantt + title Unification Roadmap + dateFormat YYYY-MM-DD + axisFormat %b %Y + + section Phase 1: Production Readiness + CI/CD pipeline (Jenkins/GH Actions) :p1a, 2026-03-01, 14d + Sentry crash reporting :p1b, 2026-03-01, 7d + Backend deploy to cloud :p1c, 2026-03-08, 7d + Certificate pinning (OkHttp) :p1d, 2026-03-08, 3d + + section Phase 2: Feature Parity + IGDB game metadata integration :p2a, 2026-03-15, 7d + Watermark / overlay support in encoder :p2b, 2026-03-15, 10d + Subscription model + paywall :p2c, 2026-03-22, 14d + + section Phase 3: Hub Migration + Add fallback screen-capture mode :p3a, 2026-04-05, 14d + Port device pairing (for non-integrated games) :p3b, 2026-04-05, 10d + Migrate Hub users to Control :p3c, 2026-04-19, 14d + Deprecate Hub app :p3d, 2026-05-03, 7d + + section Phase 4: Polish + Desktop companion (optional) :p4a, 2026-05-10, 21d + Advanced stream analytics :p4b, 2026-05-10, 14d + Store listing + marketing :p4c, 2026-05-24, 7d +``` + +### 11.3 What to Keep from Each + +```mermaid +graph TB + subgraph "Unified App" + direction TB + A["Control App Architecture
(Kotlin + Compose + Hilt)"] + B["Control Backend
(Fastify + Prisma + SQLite)"] + C["LCKControl Plugin
(AIDL + multi-destination)"] + D["Stream Plan System
(DRAFT → READY → LIVE → ENDED)"] + end + + subgraph "Adopt from Hub" + E["Sentry Crash Reporting"] + F["IGDB Game Database"] + G["Certificate Pinning"] + H["Jenkins CI/CD"] + I["Watermark Renderer"] + J["Screen Capture Fallback"] + end + + subgraph "Discard" + K["Rust/Tauri/Leptos Stack"] + L["JSON-RPC 2.0 Protocol"] + M["minirtmp (Rust RTMP)"] + N["Device Code Pairing
(replaced by AIDL)"] + O["Single-Destination Limit"] + end + + E --> A + F --> B + G --> A + H --> A + I --> C + J --> A + + style A fill:#9f9,stroke:#333 + style B fill:#9cf,stroke:#333 + style C fill:#9f9,stroke:#333 + style D fill:#9f9,stroke:#333 + style K fill:#fbb,stroke:#333 + style L fill:#fbb,stroke:#333 + style M fill:#fbb,stroke:#333 + style N fill:#fbb,stroke:#333 + style O fill:#fbb,stroke:#333 +``` + +### 11.4 Hybrid Mode: Screen Capture Fallback + +To maintain the Hub's "works with any game" advantage, add a fallback path: + +```mermaid +graph TB + Start["Game Launches"] --> Check{"LCKControl Plugin
integrated?"} + Check -->|Yes| AIDL["AIDL IPC Path
(direct encode,
multi-destination)"] + Check -->|No| Capture["Screen Capture Path
(MediaProjection,
single destination)"] + AIDL --> Stream["Stream to Platforms"] + Capture --> Stream + + style AIDL fill:#9f9,stroke:#333 + style Capture fill:#ff9,stroke:#333 +``` + +This gives the unified app both modes: +- **Primary**: Direct encoding via AIDL (high quality, multi-destination) +- **Fallback**: Screen capture for games without plugin integration (compatibility) + +--- + +## 12. Risk Assessment + +| Risk | Impact | Mitigation | +|------|--------|------------| +| Hub users lose access during migration | High | Run both apps in parallel during transition, provide migration guide | +| AIDL only works on Android (no desktop) | Medium | Screen capture fallback for desktop; evaluate PCVR needs later | +| Self-hosted backend scalability | Medium | Move to managed cloud (Railway, Fly.io) before store launch | +| Synchronous JNI blocking causes ANR | Medium | Add timeout handling, move to async callback pattern | +| No subscription model in Control | Low | Implement before store launch using existing Hub billing logic | +| Losing crash telemetry | Low | Add Sentry SDK early in Phase 1 | + +--- + +## 13. Summary Decision Matrix + +```mermaid +quadrantChart + title Streaming Quality vs Maintenance Complexity + x-axis Low Maintenance --> High Maintenance + y-axis Low Quality --> High Quality + quadrant-1 Ideal + quadrant-2 Overengineered + quadrant-3 Avoid + quadrant-4 Quick & Dirty + + Control App: [0.35, 0.85] + Hub App: [0.75, 0.45] + Unified - Recommended: [0.45, 0.90] +``` + +**Recommendation**: The Control App architecture with adopted Hub features provides the best path forward — higher streaming quality with a more maintainable stack. The Hub's Rust/Tauri/Leptos stack adds significant complexity without proportional benefits for an Android-focused product. + +--- + +*Document generated 2026-02-26. Based on analysis of `liv-control-center`, `lck-control`, `lck-control-backend`, and `LCKGame` codebases.* diff --git a/docs/hub-vs-control-comparison.pdf b/docs/hub-vs-control-comparison.pdf new file mode 100644 index 0000000..770ab27 Binary files /dev/null and b/docs/hub-vs-control-comparison.pdf differ diff --git a/sdk/src/main/java/com/omixlab/lckcontrol/sdk/LckControlClient.kt b/sdk/src/main/java/com/omixlab/lckcontrol/sdk/LckControlClient.kt index 4f56020..1bc90ee 100644 --- a/sdk/src/main/java/com/omixlab/lckcontrol/sdk/LckControlClient.kt +++ b/sdk/src/main/java/com/omixlab/lckcontrol/sdk/LckControlClient.kt @@ -4,10 +4,14 @@ import android.content.ComponentName import android.content.Context import android.content.Intent import android.content.ServiceConnection +import android.hardware.HardwareBuffer import android.os.IBinder +import android.os.ParcelFileDescriptor import com.omixlab.lckcontrol.shared.ConnectedClientInfo import com.omixlab.lckcontrol.shared.ILckControlCallback import com.omixlab.lckcontrol.shared.ILckControlService +import com.omixlab.lckcontrol.shared.ILckStreamingCallback +import com.omixlab.lckcontrol.shared.ILckStreamingService import com.omixlab.lckcontrol.shared.LinkedAccount import com.omixlab.lckcontrol.shared.StreamPlan import com.omixlab.lckcontrol.shared.StreamPlanConfig @@ -21,9 +25,11 @@ class LckControlClient(private val context: Context) { private const val SERVICE_PACKAGE = "com.omixlab.lckcontrol" private const val SERVICE_CLASS = "$SERVICE_PACKAGE.service.LckControlService" private const val PERMISSION = "$SERVICE_PACKAGE.permission.USE_LCK_CONTROL" + private const val ACTION_BIND_STREAMING = "$SERVICE_PACKAGE.BIND_STREAMING" } private var service: ILckControlService? = null + private var streamingService: ILckStreamingService? = null private var clientId: String? = null private val _connected = MutableStateFlow(false) @@ -35,6 +41,12 @@ class LckControlClient(private val context: Context) { private val _streamPlans = MutableStateFlow>(emptyList()) val streamPlans: StateFlow> = _streamPlans.asStateFlow() + private val _streamingState = MutableStateFlow("IDLE") + val streamingState: StateFlow = _streamingState.asStateFlow() + + private val _streamingConnected = MutableStateFlow(false) + val streamingConnected: StateFlow = _streamingConnected.asStateFlow() + private val callback = object : ILckControlCallback.Stub() { override fun onStreamPlansChanged(plans: List) { _streamPlans.value = plans @@ -54,6 +66,33 @@ class LckControlClient(private val context: Context) { } } + private val streamingCallback = object : ILckStreamingCallback.Stub() { + override fun onBufferReleased(bufferIndex: Int) { + onBufferReleasedListener?.invoke(bufferIndex) + } + + override fun onStreamingStateChanged(state: String) { + _streamingState.value = state + } + + override fun onStreamingError(code: Int, message: String) { + onStreamingErrorListener?.invoke(code, message) + } + + override fun onStreamingStats(videoBitrate: Long, audioBitrate: Long, fps: Int, droppedFrames: Int) { + onStreamingStatsListener?.invoke(videoBitrate, audioBitrate, fps, droppedFrames) + } + } + + /** Listener for buffer release events (game can reuse the buffer). */ + var onBufferReleasedListener: ((Int) -> Unit)? = null + + /** Listener for streaming errors. */ + var onStreamingErrorListener: ((Int, String) -> Unit)? = null + + /** Listener for streaming stats updates. */ + var onStreamingStatsListener: ((Long, Long, Int, Int) -> Unit)? = null + private val connection = object : ServiceConnection { override fun onServiceConnected(name: ComponentName?, binder: IBinder?) { service = ILckControlService.Stub.asInterface(binder) @@ -70,6 +109,20 @@ class LckControlClient(private val context: Context) { } } + private val streamingConnection = object : ServiceConnection { + override fun onServiceConnected(name: ComponentName?, binder: IBinder?) { + streamingService = ILckStreamingService.Stub.asInterface(binder) + streamingService?.registerStreamingCallback(streamingCallback) + _streamingConnected.value = true + } + + override fun onServiceDisconnected(name: ComponentName?) { + streamingService = null + _streamingConnected.value = false + _streamingState.value = "IDLE" + } + } + fun bind(): Boolean { val intent = Intent().apply { component = ComponentName(SERVICE_PACKAGE, SERVICE_CLASS) @@ -93,6 +146,51 @@ class LckControlClient(private val context: Context) { _authenticated.value = false } + // ── Streaming service ──────────────────────────────── + + fun bindStreaming(): Boolean { + val intent = Intent(ACTION_BIND_STREAMING).apply { + component = ComponentName(SERVICE_PACKAGE, SERVICE_CLASS) + } + return context.bindService(intent, streamingConnection, Context.BIND_AUTO_CREATE) + } + + fun unbindStreaming() { + streamingService?.let { svc -> + svc.unregisterStreamingCallback(streamingCallback) + } + try { + context.unbindService(streamingConnection) + } catch (_: IllegalArgumentException) {} + streamingService = null + _streamingConnected.value = false + _streamingState.value = "IDLE" + } + + // ── Texture pool ───────────────────────────────────── + + fun registerTexturePool(buffers: Array, width: Int, height: Int, format: Int) { + streamingService?.registerTexturePool(buffers, width, height, format) + } + + fun unregisterTexturePool() { + streamingService?.unregisterTexturePool() + } + + // ── Frame submission (called from game render thread) ── + + fun submitVideoFrame(bufferIndex: Int, timestampNs: Long, gpuFenceFd: ParcelFileDescriptor?) { + streamingService?.submitVideoFrame(bufferIndex, timestampNs, gpuFenceFd) + } + + fun submitAudioFrame(pcmData: ByteArray, timestampNs: Long, sampleRate: Int, channels: Int, bitsPerSample: Int) { + streamingService?.submitAudioFrame(pcmData, timestampNs, sampleRate, channels, bitsPerSample) + } + + fun isStreaming(): Boolean { + return streamingService?.isStreaming ?: false + } + // ── Auth ──────────────────────────────────────────── fun isAuthenticated(): Boolean { diff --git a/shared/src/main/aidl/com/omixlab/lckcontrol/shared/ILckStreamingCallback.aidl b/shared/src/main/aidl/com/omixlab/lckcontrol/shared/ILckStreamingCallback.aidl new file mode 100644 index 0000000..13b672a --- /dev/null +++ b/shared/src/main/aidl/com/omixlab/lckcontrol/shared/ILckStreamingCallback.aidl @@ -0,0 +1,8 @@ +package com.omixlab.lckcontrol.shared; + +interface ILckStreamingCallback { + oneway void onBufferReleased(int bufferIndex); + oneway void onStreamingStateChanged(String state); + oneway void onStreamingError(int code, String message); + oneway void onStreamingStats(long videoBitrate, long audioBitrate, int fps, int droppedFrames); +} diff --git a/shared/src/main/aidl/com/omixlab/lckcontrol/shared/ILckStreamingService.aidl b/shared/src/main/aidl/com/omixlab/lckcontrol/shared/ILckStreamingService.aidl new file mode 100644 index 0000000..2fe8cc4 --- /dev/null +++ b/shared/src/main/aidl/com/omixlab/lckcontrol/shared/ILckStreamingService.aidl @@ -0,0 +1,22 @@ +package com.omixlab.lckcontrol.shared; + +import android.hardware.HardwareBuffer; +import android.os.ParcelFileDescriptor; +import com.omixlab.lckcontrol.shared.ILckStreamingCallback; + +interface ILckStreamingService { + // Texture pool (game allocates, app receives) + void registerTexturePool(in HardwareBuffer[] buffers, int width, int height, int format); + void unregisterTexturePool(); + + // Frame submission (game -> app, one-way for performance) + oneway void submitVideoFrame(int bufferIndex, long timestampNs, in ParcelFileDescriptor gpuFence); + oneway void submitAudioFrame(in byte[] pcmData, long timestampNs, int sampleRate, int channels, int bitsPerSample); + + // Streaming lifecycle + boolean isStreaming(); + + // Callbacks + void registerStreamingCallback(ILckStreamingCallback callback); + void unregisterStreamingCallback(ILckStreamingCallback callback); +} diff --git a/shared/src/main/aidl/com/omixlab/lckcontrol/shared/StreamingConfig.aidl b/shared/src/main/aidl/com/omixlab/lckcontrol/shared/StreamingConfig.aidl new file mode 100644 index 0000000..d690041 --- /dev/null +++ b/shared/src/main/aidl/com/omixlab/lckcontrol/shared/StreamingConfig.aidl @@ -0,0 +1,3 @@ +package com.omixlab.lckcontrol.shared; + +parcelable StreamingConfig; diff --git a/shared/src/main/java/com/omixlab/lckcontrol/shared/LinkedAccount.kt b/shared/src/main/java/com/omixlab/lckcontrol/shared/LinkedAccount.kt index 8e41cee..0f925ad 100644 --- a/shared/src/main/java/com/omixlab/lckcontrol/shared/LinkedAccount.kt +++ b/shared/src/main/java/com/omixlab/lckcontrol/shared/LinkedAccount.kt @@ -10,6 +10,7 @@ data class LinkedAccount( val accountId: String, val avatarUrl: String? = null, val isAuthenticated: Boolean = false, + val isEnabled: Boolean = true, ) : Parcelable { constructor(parcel: Parcel) : this( @@ -19,6 +20,7 @@ data class LinkedAccount( accountId = parcel.readString()!!, avatarUrl = parcel.readString(), isAuthenticated = parcel.readInt() != 0, + isEnabled = if (parcel.dataAvail() > 0) parcel.readInt() != 0 else true, ) override fun writeToParcel(parcel: Parcel, flags: Int) { @@ -28,6 +30,7 @@ data class LinkedAccount( parcel.writeString(accountId) parcel.writeString(avatarUrl) parcel.writeInt(if (isAuthenticated) 1 else 0) + parcel.writeInt(if (isEnabled) 1 else 0) } override fun describeContents(): Int = 0 diff --git a/shared/src/main/java/com/omixlab/lckcontrol/shared/StreamPlan.kt b/shared/src/main/java/com/omixlab/lckcontrol/shared/StreamPlan.kt index c5de834..c46f6eb 100644 --- a/shared/src/main/java/com/omixlab/lckcontrol/shared/StreamPlan.kt +++ b/shared/src/main/java/com/omixlab/lckcontrol/shared/StreamPlan.kt @@ -8,6 +8,8 @@ data class StreamPlan( val name: String, val status: String = "DRAFT", val destinations: List = emptyList(), + val executionMode: String = "IN_GAME", + val gameId: String = "", ) : Parcelable { constructor(parcel: Parcel) : this( @@ -15,6 +17,8 @@ data class StreamPlan( name = parcel.readString()!!, status = parcel.readString() ?: "DRAFT", destinations = parcel.createTypedArrayList(StreamDestination.CREATOR) ?: emptyList(), + executionMode = if (parcel.dataAvail() > 0) parcel.readString() ?: "IN_GAME" else "IN_GAME", + gameId = if (parcel.dataAvail() > 0) parcel.readString() ?: "" else "", ) override fun writeToParcel(parcel: Parcel, flags: Int) { @@ -22,6 +26,8 @@ data class StreamPlan( parcel.writeString(name) parcel.writeString(status) parcel.writeTypedList(destinations) + parcel.writeString(executionMode) + parcel.writeString(gameId) } override fun describeContents(): Int = 0 diff --git a/shared/src/main/java/com/omixlab/lckcontrol/shared/StreamPlanConfig.kt b/shared/src/main/java/com/omixlab/lckcontrol/shared/StreamPlanConfig.kt index 1a9f0c7..9b9ff60 100644 --- a/shared/src/main/java/com/omixlab/lckcontrol/shared/StreamPlanConfig.kt +++ b/shared/src/main/java/com/omixlab/lckcontrol/shared/StreamPlanConfig.kt @@ -6,16 +6,22 @@ import android.os.Parcelable data class StreamPlanConfig( val name: String, val destinations: List = emptyList(), + val executionMode: String = "IN_GAME", + val gameId: String = "", ) : Parcelable { constructor(parcel: Parcel) : this( name = parcel.readString()!!, destinations = parcel.createTypedArrayList(StreamDestination.CREATOR) ?: emptyList(), + executionMode = if (parcel.dataAvail() > 0) parcel.readString() ?: "IN_GAME" else "IN_GAME", + gameId = if (parcel.dataAvail() > 0) parcel.readString() ?: "" else "", ) override fun writeToParcel(parcel: Parcel, flags: Int) { parcel.writeString(name) parcel.writeTypedList(destinations) + parcel.writeString(executionMode) + parcel.writeString(gameId) } override fun describeContents(): Int = 0 diff --git a/shared/src/main/java/com/omixlab/lckcontrol/shared/StreamingConfig.kt b/shared/src/main/java/com/omixlab/lckcontrol/shared/StreamingConfig.kt new file mode 100644 index 0000000..141b718 --- /dev/null +++ b/shared/src/main/java/com/omixlab/lckcontrol/shared/StreamingConfig.kt @@ -0,0 +1,39 @@ +package com.omixlab.lckcontrol.shared + +import android.os.Parcel +import android.os.Parcelable + +data class StreamingConfig( + val videoBitrate: Int = 6_000_000, + val videoCodec: String = "h264", + val audioBitrate: Int = 128_000, + val audioSampleRate: Int = 48_000, + val audioChannels: Int = 2, + val keyFrameInterval: Int = 2, +) : Parcelable { + + constructor(parcel: Parcel) : this( + videoBitrate = parcel.readInt(), + videoCodec = parcel.readString() ?: "h264", + audioBitrate = parcel.readInt(), + audioSampleRate = parcel.readInt(), + audioChannels = parcel.readInt(), + keyFrameInterval = parcel.readInt(), + ) + + override fun writeToParcel(parcel: Parcel, flags: Int) { + parcel.writeInt(videoBitrate) + parcel.writeString(videoCodec) + parcel.writeInt(audioBitrate) + parcel.writeInt(audioSampleRate) + parcel.writeInt(audioChannels) + parcel.writeInt(keyFrameInterval) + } + + override fun describeContents(): Int = 0 + + companion object CREATOR : Parcelable.Creator { + override fun createFromParcel(parcel: Parcel) = StreamingConfig(parcel) + override fun newArray(size: Int) = arrayOfNulls(size) + } +}