diff --git a/.github/CODEX_REVIEW_REQUEST.md b/.github/CODEX_REVIEW_REQUEST.md deleted file mode 100644 index 36820b0..0000000 --- a/.github/CODEX_REVIEW_REQUEST.md +++ /dev/null @@ -1,5 +0,0 @@ -# Codex Review Request - -This pull request exists to request an automated Codex review of the OpenStream repository. - -@codex please review this repository and suggest improvements. diff --git a/.github/workflows/android.yml b/.github/workflows/android.yml index 5044cc8..631dfbe 100644 --- a/.github/workflows/android.yml +++ b/.github/workflows/android.yml @@ -11,12 +11,19 @@ permissions: jobs: build: - name: Build debug APK + name: Test, lint, and build debug APK runs-on: ubuntu-latest steps: - name: Check out repository uses: actions/checkout@v4 + with: + persist-credentials: false + + - name: Set up Python + uses: actions/setup-python@v5 + with: + python-version: "3.12" - name: Set up JDK uses: actions/setup-java@v4 @@ -35,23 +42,31 @@ jobs: run: | echo "code=$(git log -1 --format=%ct)" >> "$GITHUB_OUTPUT" - - name: Build debug-signed APK with native SRT enabled + - name: Run repository tests + run: | + python -m pip install --disable-pip-version-check pytest + python -m pytest -q + + - name: Run Android unit tests and lint, then build APK working-directory: android env: OPENSTREAM_VERSION_NAME: 2.0.0-beta.${{ steps.version.outputs.code }} OPENSTREAM_VERSION_CODE: ${{ steps.version.outputs.code }} run: | chmod +x ./gradlew - ./gradlew --no-daemon :app:assembleDebug + ./gradlew --no-daemon :app:testDebugUnitTest :app:lintDebug :app:assembleDebug - name: Normalize Android build assets run: | mkdir -p dist cp android/app/build/outputs/apk/debug/app-debug.apk dist/openstream-android.apk + (cd dist && sha256sum openstream-android.apk > openstream-android.apk.sha256) + APK_SHA256="$(cut -d ' ' -f 1 dist/openstream-android.apk.sha256)" printf 'debug-signed-beta\n' > dist/android-signing.txt - printf '{\n "versionName": "2.0.0-beta.%s",\n "versionCode": %s,\n "apkAsset": "openstream-android.apk"\n}\n' \ + printf '{\n "versionName": "2.0.0-beta.%s",\n "versionCode": %s,\n "apkAsset": "openstream-android.apk",\n "apkSha256": "%s"\n}\n' \ "${{ steps.version.outputs.code }}" \ "${{ steps.version.outputs.code }}" \ + "$APK_SHA256" \ > dist/openstream-android-update.json - name: Upload APK @@ -60,137 +75,7 @@ jobs: name: openstream-android-debug-apk path: | dist/openstream-android.apk + dist/openstream-android.apk.sha256 dist/android-signing.txt dist/openstream-android-update.json if-no-files-found: error - - publish-android-update: - name: Publish Android update - runs-on: ubuntu-latest - if: github.event_name == 'push' && github.ref == 'refs/heads/main' - concurrency: - group: android-latest-publish - cancel-in-progress: false - permissions: - contents: write - - steps: - - name: Check out repository - uses: actions/checkout@v4 - with: - persist-credentials: false - - - name: Set up JDK - uses: actions/setup-java@v4 - with: - distribution: temurin - java-version: "17" - - - name: Set up Android SDK - uses: android-actions/setup-android@v3 - - - name: Install Android build packages - run: sdkmanager "platforms;android-35" "build-tools;35.0.0" "cmake;3.22.1" "ndk;27.0.12077973" - - - name: Compute Android version metadata - id: version - run: | - echo "code=$(git log -1 --format=%ct)" >> "$GITHUB_OUTPUT" - - - name: Check Android signing inputs - id: signing - env: - OPENSTREAM_RELEASE_KEYSTORE_BASE64: ${{ secrets.OPENSTREAM_RELEASE_KEYSTORE_BASE64 }} - OPENSTREAM_RELEASE_STORE_PASSWORD: ${{ secrets.OPENSTREAM_RELEASE_STORE_PASSWORD }} - OPENSTREAM_RELEASE_KEY_ALIAS: ${{ secrets.OPENSTREAM_RELEASE_KEY_ALIAS }} - OPENSTREAM_RELEASE_KEY_PASSWORD: ${{ secrets.OPENSTREAM_RELEASE_KEY_PASSWORD }} - run: | - if [ -n "$OPENSTREAM_RELEASE_KEYSTORE_BASE64" ] && - [ -n "$OPENSTREAM_RELEASE_STORE_PASSWORD" ] && - [ -n "$OPENSTREAM_RELEASE_KEY_ALIAS" ] && - [ -n "$OPENSTREAM_RELEASE_KEY_PASSWORD" ]; then - echo "has_signing=true" >> "$GITHUB_OUTPUT" - else - echo "has_signing=false" >> "$GITHUB_OUTPUT" - echo "::warning::Android release signing secrets are incomplete; publishing debug-signed beta APK." - fi - - - name: Decode Android signing key - if: steps.signing.outputs.has_signing == 'true' - env: - OPENSTREAM_RELEASE_KEYSTORE_BASE64: ${{ secrets.OPENSTREAM_RELEASE_KEYSTORE_BASE64 }} - run: | - printf '%s' "$OPENSTREAM_RELEASE_KEYSTORE_BASE64" | base64 --decode > android/app/openstream-release.keystore - - - name: Build signed APK with native SRT enabled - if: steps.signing.outputs.has_signing == 'true' - working-directory: android - env: - OPENSTREAM_RELEASE_KEYSTORE: ${{ github.workspace }}/android/app/openstream-release.keystore - OPENSTREAM_RELEASE_STORE_PASSWORD: ${{ secrets.OPENSTREAM_RELEASE_STORE_PASSWORD }} - OPENSTREAM_RELEASE_KEY_ALIAS: ${{ secrets.OPENSTREAM_RELEASE_KEY_ALIAS }} - OPENSTREAM_RELEASE_KEY_PASSWORD: ${{ secrets.OPENSTREAM_RELEASE_KEY_PASSWORD }} - OPENSTREAM_VERSION_NAME: 2.0.0-beta.${{ steps.version.outputs.code }} - OPENSTREAM_VERSION_CODE: ${{ steps.version.outputs.code }} - run: | - chmod +x ./gradlew - ./gradlew --no-daemon :app:assembleRelease - - - name: Build debug-signed APK with native SRT enabled - if: steps.signing.outputs.has_signing != 'true' - working-directory: android - env: - OPENSTREAM_VERSION_NAME: 2.0.0-beta.${{ steps.version.outputs.code }} - OPENSTREAM_VERSION_CODE: ${{ steps.version.outputs.code }} - run: | - chmod +x ./gradlew - ./gradlew --no-daemon :app:assembleDebug - - - name: Normalize Android update assets - env: - HAS_SIGNING: ${{ steps.signing.outputs.has_signing }} - run: | - mkdir -p dist - if [ "$HAS_SIGNING" = "true" ]; then - cp android/app/build/outputs/apk/release/app-release.apk dist/openstream-android.apk - printf 'signed-release\n' > dist/android-signing.txt - else - cp android/app/build/outputs/apk/debug/app-debug.apk dist/openstream-android.apk - printf 'debug-signed-beta\n' > dist/android-signing.txt - fi - printf '{\n "versionName": "2.0.0-beta.%s",\n "versionCode": %s,\n "apkAsset": "openstream-android.apk"\n}\n' \ - "${{ steps.version.outputs.code }}" \ - "${{ steps.version.outputs.code }}" \ - > dist/openstream-android-update.json - - - name: Publish Android update release - env: - GH_TOKEN: ${{ github.token }} - RELEASE_TAG: android-latest - run: | - if gh release view "$RELEASE_TAG" >/dev/null 2>&1; then - gh release upload "$RELEASE_TAG" \ - dist/openstream-android.apk \ - dist/openstream-android-update.json \ - --clobber - gh release edit "$RELEASE_TAG" \ - --target "$GITHUB_SHA" \ - --title "OpenStream Android 2.0.0-beta.${{ steps.version.outputs.code }}" \ - --notes "Automated Android update from main after PR merge." \ - --latest=false - else - gh release create "$RELEASE_TAG" \ - dist/openstream-android.apk \ - dist/openstream-android-update.json \ - --target "$GITHUB_SHA" \ - --title "OpenStream Android 2.0.0-beta.${{ steps.version.outputs.code }}" \ - --notes "Automated Android update from main after PR merge." \ - --latest=false - fi - LATEST_TAG="$(gh release view --json tagName -q .tagName 2>/dev/null || true)" - if [ -n "$LATEST_TAG" ] && [ "$LATEST_TAG" != "$RELEASE_TAG" ]; then - gh release upload "$LATEST_TAG" \ - dist/openstream-android.apk \ - dist/openstream-android-update.json \ - --clobber - fi diff --git a/.github/workflows/obs-plugin-windows.yml b/.github/workflows/obs-plugin-windows.yml index 158dfde..8dd184e 100644 --- a/.github/workflows/obs-plugin-windows.yml +++ b/.github/workflows/obs-plugin-windows.yml @@ -6,6 +6,9 @@ on: pull_request: workflow_dispatch: +permissions: + contents: read + jobs: build: name: Build Windows x64 plugin @@ -14,6 +17,8 @@ jobs: steps: - name: Check out repository uses: actions/checkout@v4 + with: + persist-credentials: false - name: Install OBS Studio shell: powershell diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 4f8ce9d..280e1c6 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -12,7 +12,7 @@ on: type: string permissions: - contents: write + contents: read jobs: android: @@ -22,6 +22,13 @@ jobs: steps: - name: Check out repository uses: actions/checkout@v4 + with: + persist-credentials: false + + - name: Set up Python + uses: actions/setup-python@v5 + with: + python-version: "3.12" - name: Set up JDK uses: actions/setup-java@v4 @@ -35,38 +42,41 @@ jobs: - name: Install Android build packages run: sdkmanager "platforms;android-35" "build-tools;35.0.0" "cmake;3.22.1" "ndk;27.0.12077973" + - name: Run repository tests and Android lint + run: | + python -m pip install --disable-pip-version-check pytest + python -m pytest -q + cd android + chmod +x ./gradlew + ./gradlew --no-daemon :app:testDebugUnitTest :app:lintDebug + - name: Compute Android version metadata id: version run: | echo "code=$(git log -1 --format=%ct)" >> "$GITHUB_OUTPUT" - - name: Check Android signing inputs - id: signing + - name: Require Android signing inputs env: OPENSTREAM_RELEASE_KEYSTORE_BASE64: ${{ secrets.OPENSTREAM_RELEASE_KEYSTORE_BASE64 }} OPENSTREAM_RELEASE_STORE_PASSWORD: ${{ secrets.OPENSTREAM_RELEASE_STORE_PASSWORD }} OPENSTREAM_RELEASE_KEY_ALIAS: ${{ secrets.OPENSTREAM_RELEASE_KEY_ALIAS }} OPENSTREAM_RELEASE_KEY_PASSWORD: ${{ secrets.OPENSTREAM_RELEASE_KEY_PASSWORD }} run: | - if [ -n "$OPENSTREAM_RELEASE_KEYSTORE_BASE64" ] && - [ -n "$OPENSTREAM_RELEASE_STORE_PASSWORD" ] && - [ -n "$OPENSTREAM_RELEASE_KEY_ALIAS" ] && - [ -n "$OPENSTREAM_RELEASE_KEY_PASSWORD" ]; then - echo "has_signing=true" >> "$GITHUB_OUTPUT" - else - echo "has_signing=false" >> "$GITHUB_OUTPUT" - echo "::warning::Android release signing secrets are incomplete; publishing debug-signed beta APK." - fi + test -n "$OPENSTREAM_RELEASE_KEYSTORE_BASE64" && + test -n "$OPENSTREAM_RELEASE_STORE_PASSWORD" && + test -n "$OPENSTREAM_RELEASE_KEY_ALIAS" && + test -n "$OPENSTREAM_RELEASE_KEY_PASSWORD" || { + echo "::error::Public releases require all Android signing secrets." + exit 1 + } - name: Decode Android signing key - if: steps.signing.outputs.has_signing == 'true' env: OPENSTREAM_RELEASE_KEYSTORE_BASE64: ${{ secrets.OPENSTREAM_RELEASE_KEYSTORE_BASE64 }} run: | printf '%s' "$OPENSTREAM_RELEASE_KEYSTORE_BASE64" | base64 --decode > android/app/openstream-release.keystore - name: Build signed release APK with native SRT enabled - if: steps.signing.outputs.has_signing == 'true' working-directory: android env: OPENSTREAM_RELEASE_KEYSTORE: ${{ github.workspace }}/android/app/openstream-release.keystore @@ -79,31 +89,21 @@ jobs: chmod +x ./gradlew ./gradlew --no-daemon :app:assembleRelease - - name: Build debug-signed beta APK with native SRT enabled - if: steps.signing.outputs.has_signing != 'true' - working-directory: android - env: - OPENSTREAM_VERSION_NAME: ${{ inputs.tag || github.ref_name }} - OPENSTREAM_VERSION_CODE: ${{ steps.version.outputs.code }} - run: | - chmod +x ./gradlew - ./gradlew --no-daemon :app:assembleDebug - - name: Normalize Android APK artifact + env: + RELEASE_VERSION_NAME: ${{ inputs.tag || github.ref_name }} + RELEASE_VERSION_CODE: ${{ steps.version.outputs.code }} run: | mkdir -p dist - if [ -f android/app/build/outputs/apk/release/app-release.apk ]; then - cp android/app/build/outputs/apk/release/app-release.apk dist/openstream-android.apk - printf 'signed-release\n' > dist/android-signing.txt - else - cp android/app/build/outputs/apk/debug/app-debug.apk dist/openstream-android.apk - printf 'debug-signed-beta\n' > dist/android-signing.txt - fi - VERSION_NAME="${{ inputs.tag || github.ref_name }}" + cp android/app/build/outputs/apk/release/app-release.apk dist/openstream-android.apk + (cd dist && sha256sum openstream-android.apk > openstream-android.apk.sha256) + APK_SHA256="$(cut -d ' ' -f 1 dist/openstream-android.apk.sha256)" + VERSION_NAME="$RELEASE_VERSION_NAME" VERSION_NAME="${VERSION_NAME#v}" - printf '{\n "versionName": "%s",\n "versionCode": %s,\n "apkAsset": "openstream-android.apk"\n}\n' \ + printf '{\n "versionName": "%s",\n "versionCode": %s,\n "apkAsset": "openstream-android.apk",\n "apkSha256": "%s"\n}\n' \ "$VERSION_NAME" \ - "${{ steps.version.outputs.code }}" \ + "$RELEASE_VERSION_CODE" \ + "$APK_SHA256" \ > dist/openstream-android-update.json - name: Upload APK artifact @@ -112,7 +112,7 @@ jobs: name: openstream-android-apk path: | dist/openstream-android.apk - dist/android-signing.txt + dist/openstream-android.apk.sha256 dist/openstream-android-update.json if-no-files-found: error @@ -123,6 +123,8 @@ jobs: steps: - name: Check out repository uses: actions/checkout@v4 + with: + persist-credentials: false - name: Install OBS Studio shell: powershell @@ -167,10 +169,14 @@ jobs: name: Publish GitHub release runs-on: ubuntu-latest needs: [android, obs-plugin] + permissions: + contents: write steps: - name: Check out repository uses: actions/checkout@v4 + with: + persist-credentials: false - name: Download build artifacts uses: actions/download-artifact@v4 @@ -181,6 +187,7 @@ jobs: - name: Verify release assets run: | test -f dist/openstream-android.apk + test -f dist/openstream-android.apk.sha256 test -f dist/openstream-android-update.json test -f dist/openstream-obs-plugin-installer-windows-x64.exe test -f dist/openstream-obs-windows-x64.zip @@ -193,6 +200,7 @@ jobs: TAG_NAME="${INPUT_TAG:-${GITHUB_REF_NAME}}" gh release create "$TAG_NAME" \ dist/openstream-android.apk \ + dist/openstream-android.apk.sha256 \ dist/openstream-android-update.json \ dist/openstream-obs-plugin-installer-windows-x64.exe \ dist/openstream-obs-windows-x64.zip \ diff --git a/AGENTS.md b/AGENTS.md index c82a0a7..4e7f56a 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -2,5 +2,5 @@ ## Completion Workflow -After completing a task, create a pull request on GitHub for the finished changes and tag `@codex` in the PR description so the GitHub Codex client can review the code before merge. +After completing a task, create a pull request on GitHub for the finished changes. diff --git a/android/app/build.gradle.kts b/android/app/build.gradle.kts index 3004e95..0697633 100644 --- a/android/app/build.gradle.kts +++ b/android/app/build.gradle.kts @@ -107,3 +107,7 @@ kotlin { jvmTarget.set(JvmTarget.JVM_17) } } + +dependencies { + testImplementation("junit:junit:4.13.2") +} diff --git a/android/app/src/main/cpp/openstream_srt.cpp b/android/app/src/main/cpp/openstream_srt.cpp index 16acd97..010f3ee 100644 --- a/android/app/src/main/cpp/openstream_srt.cpp +++ b/android/app/src/main/cpp/openstream_srt.cpp @@ -4,6 +4,7 @@ #include #include +#include #include #include #include @@ -418,6 +419,7 @@ class MpegTsMuxer { struct SrtUrl { std::string host; std::string port; + uint16_t portNumber = 0; int latencyMs = 120; }; @@ -438,6 +440,18 @@ std::optional parseSrtUrl(const std::string &url) { SrtUrl parsed; parsed.host = authority.substr(0, colon); parsed.port = authority.substr(colon + 1); + if (parsed.host.size() >= 2 && parsed.host.front() == '[' && parsed.host.back() == ']') { + parsed.host = parsed.host.substr(1, parsed.host.size() - 2); + } + unsigned int portNumber = 0; + const auto portResult = std::from_chars( + parsed.port.data(), parsed.port.data() + parsed.port.size(), portNumber); + if (portResult.ec != std::errc{} || + portResult.ptr != parsed.port.data() + parsed.port.size() || + portNumber == 0 || portNumber > 65535) { + return std::nullopt; + } + parsed.portNumber = static_cast(portNumber); if (queryStart != std::string::npos) { const std::string query = url.substr(queryStart + 1); @@ -447,7 +461,14 @@ std::optional parseSrtUrl(const std::string &url) { const std::string part = query.substr(offset, amp == std::string::npos ? std::string::npos : amp - offset); constexpr const char *latencyKey = "latency="; if (part.rfind(latencyKey, 0) == 0) { - parsed.latencyMs = std::max(20, std::min(2000, std::atoi(part.c_str() + std::strlen(latencyKey)))); + const std::string value = part.substr(std::strlen(latencyKey)); + int latency = 0; + const auto latencyResult = + std::from_chars(value.data(), value.data() + value.size(), latency); + if (latencyResult.ec == std::errc{} && + latencyResult.ptr == value.data() + value.size()) { + parsed.latencyMs = std::clamp(latency, 80, 200); + } } if (amp == std::string::npos) { break; @@ -563,7 +584,7 @@ class NativeSender { sockaddr_in address{}; address.sin_family = AF_INET; - address.sin_port = htons(static_cast(std::atoi(parsed->port.c_str()))); + address.sin_port = htons(parsed->portNumber); address.sin_addr.s_addr = INADDR_ANY; if (srt_bind(listenerSocket, reinterpret_cast(&address), sizeof(address)) == SRT_ERROR) { @@ -600,6 +621,10 @@ class NativeSender { bool send(const std::vector &bytes) { #if OPENSTREAM_HAVE_LIBSRT + // Closing an SRT socket or tearing down libsrt while another codec thread + // is inside srt_sendmsg is unsafe. Serialize the whole send with teardown, + // not just the socket-handle lookup. + std::lock_guard ioLock(ioMutex_); const SRTSOCKET socket = currentSocket(); if (socket == SRT_INVALID_SOCK) { return false; @@ -632,6 +657,7 @@ class NativeSender { void disconnect() { #if OPENSTREAM_HAVE_LIBSRT + std::lock_guard ioLock(ioMutex_); const SRTSOCKET listenerSocket = takeListenerSocket(); const SRTSOCKET socket = takeSocket(); if (listenerSocket != SRT_INVALID_SOCK) { @@ -664,10 +690,16 @@ class NativeSender { } void closeSocket(SRTSOCKET socket) { - (void)socket; - const SRTSOCKET current = takeSocket(); - if (current != SRT_INVALID_SOCK) { - srt_close(current); + bool ownsSocket = false; + { + std::lock_guard lock(socketMutex_); + if (socket_ == socket) { + socket_ = SRT_INVALID_SOCK; + ownsSocket = true; + } + } + if (ownsSocket) { + srt_close(socket); } } @@ -684,14 +716,21 @@ class NativeSender { } void closeListenerSocket(SRTSOCKET socket) { - (void)socket; - const SRTSOCKET current = takeListenerSocket(); - if (current != SRT_INVALID_SOCK) { - srt_close(current); + bool ownsSocket = false; + { + std::lock_guard lock(socketMutex_); + if (listener_socket_ == socket) { + listener_socket_ = SRT_INVALID_SOCK; + ownsSocket = true; + } + } + if (ownsSocket) { + srt_close(socket); } } mutable std::mutex socketMutex_; + std::mutex ioMutex_; SRTSOCKET socket_ = SRT_INVALID_SOCK; SRTSOCKET listener_socket_ = SRT_INVALID_SOCK; #endif @@ -732,7 +771,6 @@ Java_dev_openstream_app_stream_SrtNativeBridge_connect( return JNI_FALSE; } - g_state.sender.disconnect(); { std::lock_guard lock(g_state.mediaMutex); g_state.muxer.emplace(*codec); @@ -741,6 +779,7 @@ Java_dev_openstream_app_stream_SrtNativeBridge_connect( g_state.audioCodecConfig.clear(); g_state.connected = false; } + g_state.sender.disconnect(); const bool connected = g_state.sender.connect(urlString); { std::lock_guard lock(g_state.mediaMutex); @@ -774,7 +813,6 @@ Java_dev_openstream_app_stream_SrtNativeBridge_listen( return JNI_FALSE; } - g_state.sender.disconnect(); { std::lock_guard lock(g_state.mediaMutex); g_state.muxer.emplace(*codec); @@ -783,6 +821,7 @@ Java_dev_openstream_app_stream_SrtNativeBridge_listen( g_state.audioCodecConfig.clear(); g_state.connected = false; } + g_state.sender.disconnect(); const bool connected = g_state.sender.listen(urlString); { std::lock_guard lock(g_state.mediaMutex); @@ -805,41 +844,41 @@ Java_dev_openstream_app_stream_SrtNativeBridge_sendVideo( std::vector bytes(static_cast(size)); env->GetByteArrayRegion(data, 0, size, reinterpret_cast(bytes.data())); - std::vector ts; - { - std::lock_guard lock(g_state.mediaMutex); - if (!g_state.connected || !g_state.muxer) { - return JNI_FALSE; - } + std::lock_guard lock(g_state.mediaMutex); + if (!g_state.connected || !g_state.muxer) { + return JNI_FALSE; + } - std::vector annexB = normalizeAnnexB(bytes.data(), bytes.size()); - if ((flags & kMediaCodecBufferFlagCodecConfig) != 0) { - g_state.codecConfig = std::move(annexB); - logInfo("Stored codec config for keyframe pre-roll"); - return JNI_TRUE; - } + std::vector annexB = normalizeAnnexB(bytes.data(), bytes.size()); + if ((flags & kMediaCodecBufferFlagCodecConfig) != 0) { + g_state.codecConfig = std::move(annexB); + logInfo("Stored codec config for keyframe pre-roll"); + return JNI_TRUE; + } - const bool keyFrame = (flags & kMediaCodecBufferFlagKeyFrame) != 0; - if (keyFrame && !g_state.codecConfig.empty()) { - std::vector withConfig = g_state.codecConfig; - withConfig.insert(withConfig.end(), annexB.begin(), annexB.end()); - annexB = std::move(withConfig); - } + const bool keyFrame = (flags & kMediaCodecBufferFlagKeyFrame) != 0; + if (keyFrame && !g_state.codecConfig.empty()) { + std::vector withConfig = g_state.codecConfig; + withConfig.insert(withConfig.end(), annexB.begin(), annexB.end()); + annexB = std::move(withConfig); + } - ts = g_state.muxer->muxAccessUnit(annexB, static_cast(presentation_time_us), keyFrame); + const std::vector ts = + g_state.muxer->muxAccessUnit(annexB, static_cast(presentation_time_us), keyFrame); + const bool sent = g_state.sender.send(ts); + if (!sent) { + g_state.connected = false; } - return g_state.sender.send(ts) ? JNI_TRUE : JNI_FALSE; + return sent ? JNI_TRUE : JNI_FALSE; } extern "C" JNIEXPORT void JNICALL Java_dev_openstream_app_stream_SrtNativeBridge_disconnect(JNIEnv *, jobject) { - { - std::lock_guard lock(g_state.mediaMutex); - g_state.muxer.reset(); - g_state.codecConfig.clear(); - g_state.audioCodecConfig.clear(); - g_state.connected = false; - } + std::lock_guard lock(g_state.mediaMutex); + g_state.muxer.reset(); + g_state.codecConfig.clear(); + g_state.audioCodecConfig.clear(); + g_state.connected = false; g_state.sender.disconnect(); logInfo("SRT bridge disconnected"); } @@ -855,21 +894,22 @@ Java_dev_openstream_app_stream_SrtNativeBridge_sendAudio( std::vector bytes(static_cast(size)); env->GetByteArrayRegion(data, 0, size, reinterpret_cast(bytes.data())); - std::vector ts; - { - std::lock_guard lock(g_state.mediaMutex); - if (!g_state.connected || !g_state.muxer) { - return JNI_FALSE; - } + std::lock_guard lock(g_state.mediaMutex); + if (!g_state.connected || !g_state.muxer) { + return JNI_FALSE; + } - if ((flags & kMediaCodecBufferFlagCodecConfig) != 0) { - g_state.audioCodecConfig = std::move(bytes); - logInfo("Stored audio codec config (ASC)"); - return JNI_TRUE; - } + if ((flags & kMediaCodecBufferFlagCodecConfig) != 0) { + g_state.audioCodecConfig = std::move(bytes); + logInfo("Stored audio codec config (ASC)"); + return JNI_TRUE; + } - ts = g_state.muxer->muxAudioAccessUnit( - bytes, g_state.audioCodecConfig, static_cast(presentation_time_us)); + const std::vector ts = g_state.muxer->muxAudioAccessUnit( + bytes, g_state.audioCodecConfig, static_cast(presentation_time_us)); + const bool sent = g_state.sender.send(ts); + if (!sent) { + g_state.connected = false; } - return g_state.sender.send(ts) ? JNI_TRUE : JNI_FALSE; + return sent ? JNI_TRUE : JNI_FALSE; } diff --git a/android/app/src/main/java/dev/openstream/app/MainActivity.kt b/android/app/src/main/java/dev/openstream/app/MainActivity.kt index 5e0c8d7..06abe26 100644 --- a/android/app/src/main/java/dev/openstream/app/MainActivity.kt +++ b/android/app/src/main/java/dev/openstream/app/MainActivity.kt @@ -22,7 +22,6 @@ import android.view.View import android.view.ViewGroup import android.view.WindowInsets import android.view.WindowManager -import android.widget.EditText import android.widget.FrameLayout import android.widget.LinearLayout import android.widget.TextView @@ -47,12 +46,7 @@ class MainActivity : Activity() { private lateinit var previewContainer: FrameLayout private lateinit var statusText: TextView private lateinit var statusDetail: TextView - private lateinit var connectionInfo: TextView - private lateinit var manualContainer: LinearLayout private lateinit var obsSlotList: LinearLayout - private lateinit var inputObsHost: EditText - private lateinit var inputObsPort: EditText - private lateinit var inputLatency: EditText private lateinit var lensSelectorRow: LinearLayout private lateinit var liveBadge: View private lateinit var liveDot: View @@ -63,14 +57,10 @@ class MainActivity : Activity() { private lateinit var btnTorch: TextView private lateinit var btnFlipCamera: TextView private lateinit var btnSettings: TextView - private lateinit var btnManualConnect: TextView private lateinit var btnStop: TextView private lateinit var screenOffOverlay: View private lateinit var identifyOverlay: TextView private lateinit var bottomControls: LinearLayout - private lateinit var portLabel: TextView - private lateinit var btnPortUp: TextView - private lateinit var btnPortDown: TextView // ── Core components ── private lateinit var camera: Camera2Controller @@ -85,12 +75,15 @@ class MainActivity : Activity() { private val streamConfig = StreamConfig.Default1080p60 private val mainHandler = Handler(Looper.getMainLooper()) - private var activeTargetName: String? = null + @Volatile private var activeTargetName: String? = null @Volatile private var phoneServerRunning = false @Volatile private var phoneConnected = false @Volatile private var reservedBy: String? = null @Volatile private var reservedSlotLabel: String? = null - private var listenerThread: Thread? = null + @Volatile private var listenerThread: Thread? = null + @Volatile private var pendingListenerStart = false + @Volatile private var listenerGeneration = 0L + @Volatile private var activityStarted = false private var keepScreenOn = false private var displayOff = false private var originalBrightness = -1f @@ -102,6 +95,8 @@ class MainActivity : Activity() { private var liveDotAnimator: ObjectAnimator? = null private var currentPort: Int = ConnectionTarget.DEFAULT_PORT private var releaseReservationRunnable: Runnable? = null + private var lensRestartRunnable: Runnable? = null + private var pendingConnectAfterSettings = false private var currentDevices: List = emptyList() private var activeStreamBitrate: Int = streamConfig.bitrate @@ -123,6 +118,11 @@ class MainActivity : Activity() { bindViews() setupGestureDetector() + currentPort = getSharedPreferences(SettingsActivity.PREFS_NAME, MODE_PRIVATE) + .getInt(SettingsActivity.KEY_LISTENING_PORT, ConnectionTarget.DEFAULT_PORT) + .takeIf { it in 1024..65535 } + ?: ConnectionTarget.DEFAULT_PORT + streamClient = SrtStreamClient() telemetry = TelemetrySampler(this) appUpdater = AppUpdater(this) @@ -143,9 +143,10 @@ class MainActivity : Activity() { ) encoder = createVideoEncoder(activeStreamBitrate) audioEncoder = MediaCodecAudioEncoder( - sampleRate = 44100, - channelCount = 1, - bitrate = 128_000, + context = this, + sampleRate = streamConfig.audioSampleRate, + channelCount = streamConfig.audioChannelCount, + bitrate = streamConfig.audioBitrate, onEncodedAccessUnit = { accessUnit -> streamClient.sendAudioAccessUnit(accessUnit) }, @@ -189,12 +190,16 @@ class MainActivity : Activity() { // Fix stretched preview: adjust SurfaceView to maintain camera aspect ratio adjustPreviewAspectRatio(width, height) } - override fun surfaceDestroyed(holder: SurfaceHolder) = stopPhoneServer() + override fun surfaceDestroyed(holder: SurfaceHolder) { + // Close the camera before encoder teardown tries to rebuild a + // preview-only session against this now-invalid surface. + camera.stop() + stopPhoneServer(clearReservation = false, updateStatus = false) + } }) setupButtons() setupNavBarInsets() - renderConnectionInfo() handlePairingIntent(intent) showVersionInstalledDialogOnce() mainHandler.postDelayed({ appUpdater.checkForUpdates() }, UPDATE_CHECK_DELAY_MS) @@ -202,9 +207,11 @@ class MainActivity : Activity() { override fun onStart() { super.onStart() + activityStarted = true phoneAdvertiser.start() obsDiscoveryClient.start() controlServer.start() + startPreviewIfAllowed() startPhoneServerIfAllowed() } @@ -217,11 +224,17 @@ class MainActivity : Activity() { if (savedPort != currentPort && savedPort in 1024..65535) { changePort(savedPort) } + if (pendingConnectAfterSettings) { + pendingConnectAfterSettings = false + startStream(connectionTargetFromSettings()) + } } override fun onStop() { - stopPhoneServer() + activityStarted = false + cancelLensRestart() camera.stop() + stopPhoneServer(clearReservation = false, updateStatus = false) obsDiscoveryClient.stop() phoneAdvertiser.stop() controlServer.stop() @@ -230,7 +243,9 @@ class MainActivity : Activity() { } override fun onDestroy() { - appUpdater.unregister() + clearReservation() + mainHandler.removeCallbacksAndMessages(null) + appUpdater.dispose() super.onDestroy() } @@ -240,13 +255,25 @@ class MainActivity : Activity() { grantResults: IntArray, ) { super.onRequestPermissionsResult(requestCode, permissions, grantResults) - if (requestCode == 100 && grantResults.all { it == PackageManager.PERMISSION_GRANTED }) { + if (requestCode == 100 && + checkSelfPermission(Manifest.permission.CAMERA) == PackageManager.PERMISSION_GRANTED + ) { initializeLenses() startPreviewIfAllowed() startPhoneServerIfAllowed() } } + @Deprecated("Uses the platform Activity result API to avoid an AndroidX dependency") + override fun onActivityResult(requestCode: Int, resultCode: Int, data: Intent?) { + super.onActivityResult(requestCode, resultCode, data) + if (requestCode == SETTINGS_REQUEST_CODE && resultCode == RESULT_OK && + data?.getBooleanExtra(SettingsActivity.EXTRA_CONNECT_AFTER_SAVE, false) == true + ) { + pendingConnectAfterSettings = true + } + } + override fun onNewIntent(intent: Intent) { super.onNewIntent(intent) setIntent(intent) @@ -265,12 +292,7 @@ class MainActivity : Activity() { previewContainer = findViewById(R.id.previewContainer) statusText = findViewById(R.id.statusText) statusDetail = findViewById(R.id.statusDetail) - connectionInfo = findViewById(R.id.connectionInfo) - manualContainer = findViewById(R.id.manualContainer) obsSlotList = findViewById(R.id.obsSlotList) - inputObsHost = findViewById(R.id.inputObsHost) - inputObsPort = findViewById(R.id.inputObsPort) - inputLatency = findViewById(R.id.inputLatency) lensSelectorRow = findViewById(R.id.lensSelectorRow) liveBadge = findViewById(R.id.liveBadge) liveDot = findViewById(R.id.liveDot) @@ -281,14 +303,10 @@ class MainActivity : Activity() { btnTorch = findViewById(R.id.btnTorch) btnFlipCamera = findViewById(R.id.btnFlipCamera) btnSettings = findViewById(R.id.btnSettings) - btnManualConnect = findViewById(R.id.btnManualConnect) btnStop = findViewById(R.id.btnStop) screenOffOverlay = findViewById(R.id.screenOffOverlay) identifyOverlay = findViewById(R.id.identifyOverlay) bottomControls = findViewById(R.id.bottomControls) - portLabel = findViewById(R.id.portLabel) - btnPortUp = findViewById(R.id.btnPortUp) - btnPortDown = findViewById(R.id.btnPortDown) } private fun setupButtons() { @@ -298,15 +316,14 @@ class MainActivity : Activity() { btnFlipCamera.setOnClickListener { flipCamera() } btnSettings.setOnClickListener { val intent = Intent(this, SettingsActivity::class.java) - startActivity(intent) + @Suppress("DEPRECATION") + startActivityForResult(intent, SETTINGS_REQUEST_CODE) + } + btnStop.setOnClickListener { + stopPhoneServer(clearReservation = false) + startPreviewIfAllowed() + startPhoneServerIfAllowed() } - btnManualConnect.setOnClickListener { startStream(connectionTargetFromManualFields()) } - btnStop.setOnClickListener { stopPhoneServer() } - - // Port selector buttons - portLabel.text = currentPort.toString() - btnPortUp.setOnClickListener { changePort(currentPort + 1) } - btnPortDown.setOnClickListener { changePort(currentPort - 1) } // Tap the screen-off overlay to re-enable display screenOffOverlay.setOnClickListener { toggleDisplayOff() } @@ -410,7 +427,10 @@ class MainActivity : Activity() { camera.switchLens(lens) // If we were streaming, re-create the encoder and re-attach after the camera settles if (wasStreaming) { - mainHandler.postDelayed({ + cancelLensRestart() + val restart = Runnable { + lensRestartRunnable = null + if (!activityStarted || activeTargetName == null) return@Runnable runCatching { encoder.start() camera.startStreaming(encoder.inputSurface()) @@ -419,7 +439,9 @@ class MainActivity : Activity() { statusText.text = "Encoder error" statusDetail.text = e.message ?: "Unknown" } - }, 500) + } + lensRestartRunnable = restart + mainHandler.postDelayed(restart, LENS_RESTART_DELAY_MS) } buildLensButtons() } @@ -433,6 +455,11 @@ class MainActivity : Activity() { selectLens(target) } + private fun cancelLensRestart() { + lensRestartRunnable?.let(mainHandler::removeCallbacks) + lensRestartRunnable = null + } + // ─────────────────────────── Keep screen on ─────────────────────────── private fun toggleKeepScreenOn() { @@ -481,17 +508,6 @@ class MainActivity : Activity() { // ─────────────────────────── Connection ─────────────────────────── - private fun renderConnectionInfo() { - connectionInfo.text = getString( - R.string.info_listener, - currentPort, - streamConfig.width, - streamConfig.height, - streamConfig.fps, - streamConfig.bitrateMbps, - ) - } - private fun renderObsSlots(devices: List) { obsSlotList.removeAllViews() if (devices.isEmpty()) { @@ -602,7 +618,9 @@ class MainActivity : Activity() { } private fun startStream(target: ConnectionTarget) { - stopStream(updateStatus = false) + // Caller mode and listener mode share one native SRT transport. Fully + // stop the listener before opening a manual caller connection. + stopPhoneServer(clearReservation = true, updateStatus = false) useStreamBitrate(target.bitrateMbps) statusText.text = "Connecting…" statusDetail.text = "${currentLens.displayName} → ${target.name}" @@ -615,9 +633,7 @@ class MainActivity : Activity() { fps = streamConfig.fps, ) encoder.start() - runCatching { audioEncoder.start() }.onFailure { e -> - Log.w("OpenStream", "Audio encoder start failed", e) - } + startAudioIfAllowed() camera.startStreaming(encoder.inputSurface()) activeTargetName = target.name mainHandler.removeCallbacks(statsTicker) @@ -625,86 +641,152 @@ class MainActivity : Activity() { showLiveState(target.name) }.onFailure { error -> stopStream() + startPreviewIfAllowed() + startPhoneServerIfAllowed() statusText.text = "Connection failed" statusDetail.text = error.message ?: "Unknown error" } } + private fun startAudioIfAllowed() { + if (checkSelfPermission(Manifest.permission.RECORD_AUDIO) != PackageManager.PERMISSION_GRANTED) { + Log.i("OpenStream", "Microphone permission not granted; streaming video without audio") + return + } + runCatching { audioEncoder.start() }.onFailure { e -> + Log.w("OpenStream", "Audio encoder start failed; continuing video-only", e) + } + } + private fun startPhoneServerIfAllowed() { if (phoneServerRunning) return + if (listenerThread?.isAlive == true) { + pendingListenerStart = true + Log.w("OpenStream", "Previous SRT listener is still stopping; not starting another") + return + } if (checkSelfPermission(Manifest.permission.CAMERA) != PackageManager.PERMISSION_GRANTED) return if (!cameraPreview.holder.surface.isValid) return + pendingListenerStart = false + val generation = listenerGeneration + 1 + listenerGeneration = generation phoneServerRunning = true phoneConnected = false activeTargetName = null statusText.text = getString(R.string.status_ready) + statusText.setTextColor(getColor(R.color.os_text_primary)) statusDetail.text = getString(R.string.status_waiting) btnStop.visibility = View.GONE - listenerThread = Thread({ - while (phoneServerRunning) { - val listenUrl = "srt://0.0.0.0:${currentPort}?mode=listener&latency=${streamConfig.latencyMs}" - runCatching { - streamClient.listen( - url = listenUrl, - codecMime = encoder.codecName, - width = streamConfig.width, - height = streamConfig.height, - fps = streamConfig.fps, - ) - if (!phoneServerRunning) return@runCatching - phoneConnected = true - cancelReservationRelease() - val liveTargetName = reservedSlotLabel ?: "OBS" - activeTargetName = liveTargetName - runOnUiThread { - showLiveState(liveTargetName) - mainHandler.removeCallbacks(statsTicker) - mainHandler.post(statsTicker) + val thread = Thread({ + try { + while (isListenerActive(generation)) { + val listenUrl = "srt://0.0.0.0:${currentPort}?mode=listener&latency=${streamConfig.latencyMs}" + val listenResult = runCatching { + streamClient.listen( + url = listenUrl, + codecMime = encoder.codecName, + width = streamConfig.width, + height = streamConfig.height, + fps = streamConfig.fps, + ) + if (!isListenerActive(generation)) { + streamClient.disconnect() + return@runCatching + } + phoneConnected = true + cancelReservationRelease() + val liveTargetName = reservedSlotLabel ?: "OBS" + activeTargetName = liveTargetName + runOnUiThread { + if (!isListenerActive(generation)) return@runOnUiThread + showLiveState(liveTargetName) + mainHandler.removeCallbacks(statsTicker) + mainHandler.post(statsTicker) + } + encoder.start() + startAudioIfAllowed() + camera.startStreaming(encoder.inputSurface()) + while (isListenerActive(generation) && phoneConnected) { + Thread.sleep(LISTENER_POLL_MS) + } } - encoder.start() - runCatching { audioEncoder.start() }.onFailure { e -> - Log.w("OpenStream", "Audio encoder start failed", e) - } - camera.startStreaming(encoder.inputSurface()) - while (phoneServerRunning && phoneConnected) { - Thread.sleep(250) + + if (listenResult.isFailure && isListenerActive(generation)) { + val error = listenResult.exceptionOrNull() + runOnUiThread { + if (!isListenerActive(generation)) return@runOnUiThread + statusText.text = "Listener error" + statusDetail.text = error?.message ?: "Unknown" + } + try { + Thread.sleep(LISTENER_RETRY_MS) + } catch (_: InterruptedException) { + Thread.currentThread().interrupt() + } } - }.onFailure { error -> - runOnUiThread { - statusText.text = "Listener error" - statusDetail.text = error.message ?: "Unknown" + + stopActiveEncoding(updateStatus = false) + phoneConnected = false + activeTargetName = null + if (isListenerActive(generation)) { + scheduleReservationRelease() + runOnUiThread { + if (!isListenerActive(generation)) return@runOnUiThread + hideLiveState() + statusText.text = getString(R.string.status_ready) + statusDetail.text = reservedSlotLabel?.let { "Holding $it for reconnect" } + ?: getString(R.string.status_waiting) + } } - Thread.sleep(750) } - stopActiveEncoding(updateStatus = false) - phoneConnected = false - scheduleReservationRelease() - activeTargetName = null - if (phoneServerRunning) { - runOnUiThread { - hideLiveState() - statusText.text = getString(R.string.status_ready) - statusDetail.text = reservedSlotLabel?.let { "Holding $it for reconnect" } - ?: getString(R.string.status_waiting) + } finally { + if (listenerThread === Thread.currentThread()) { + listenerThread = null + mainHandler.post { + if (pendingListenerStart) { + pendingListenerStart = false + startPhoneServerIfAllowed() + } } } } }, "OpenStreamPhoneSrtListener").apply { isDaemon = true - start() } + listenerThread = thread + thread.start() + } + + private fun isListenerActive(generation: Long): Boolean { + return phoneServerRunning && listenerGeneration == generation } - private fun stopPhoneServer() { + private fun stopPhoneServer( + clearReservation: Boolean = true, + updateStatus: Boolean = true, + ) { + pendingListenerStart = false + listenerGeneration += 1 phoneServerRunning = false phoneConnected = false - clearReservation() + if (clearReservation) clearReservation() activeTargetName = null mainHandler.removeCallbacks(statsTicker) streamClient.disconnect() - stopActiveEncoding() + val thread = listenerThread + thread?.interrupt() + if (thread != null && thread !== Thread.currentThread()) { + runCatching { thread.join(LISTENER_STOP_TIMEOUT_MS) } + .onFailure { Thread.currentThread().interrupt() } + } + if (thread?.isAlive == true) { + Log.w("OpenStream", "SRT listener did not stop within ${LISTENER_STOP_TIMEOUT_MS}ms") + } else if (listenerThread === thread) { + listenerThread = null + } + stopActiveEncoding(updateStatus) hideLiveState() btnStop.visibility = View.GONE } @@ -719,6 +801,7 @@ class MainActivity : Activity() { } private fun stopActiveEncoding(updateStatus: Boolean = true) { + cancelLensRestart() camera.stopStreaming() encoder.stop() audioEncoder.stop() @@ -797,6 +880,7 @@ class MainActivity : Activity() { btnStop.visibility = View.VISIBLE startLiveDotAnimation() statusText.text = getString(R.string.status_streaming, targetName) + statusText.setTextColor(getColor(R.color.os_text_primary)) statusDetail.text = "${streamConfig.width}×${streamConfig.height}@${streamConfig.fps} · ${activeStreamBitrate / 1_000_000} Mbps" } @@ -910,28 +994,17 @@ class MainActivity : Activity() { dialog.show() } - private fun connectionTargetFromManualFields(): ConnectionTarget { - // Try loading from SharedPreferences first (set via SettingsActivity), - // fall back to inline fields if settings are empty. + private fun connectionTargetFromSettings(): ConnectionTarget { val settingsPrefs = getSharedPreferences(SettingsActivity.PREFS_NAME, MODE_PRIVATE) - val settingsHost = settingsPrefs.getString(SettingsActivity.KEY_OBS_HOST, "")?.trim().orEmpty() - val settingsPort = settingsPrefs.getInt(SettingsActivity.KEY_OBS_PORT, 0) - val settingsLatency = settingsPrefs.getInt(SettingsActivity.KEY_LATENCY, 0) - - val host = settingsHost.ifBlank { - inputObsHost.text.toString().ifBlank { ConnectionTarget.DEFAULT_HOST }.trim() - } - val port = if (settingsPort > 0) settingsPort else { - inputObsPort.text.toString().toIntOrNull() ?: ConnectionTarget.DEFAULT_PORT - } - val latencyMs = if (settingsLatency > 0) settingsLatency else { - inputLatency.text.toString().toIntOrNull() ?: ConnectionTarget.DEFAULT_LATENCY_MS - } + val host = settingsPrefs.getString(SettingsActivity.KEY_OBS_HOST, ConnectionTarget.DEFAULT_HOST) + ?.trim().orEmpty().ifBlank { ConnectionTarget.DEFAULT_HOST } + val port = settingsPrefs.getInt(SettingsActivity.KEY_OBS_PORT, ConnectionTarget.DEFAULT_PORT) + val latencyMs = settingsPrefs.getInt(SettingsActivity.KEY_LATENCY, ConnectionTarget.DEFAULT_LATENCY_MS) return ConnectionTarget( name = ConnectionTarget.DEFAULT_NAME, host = host, port = port.coerceIn(1, 65535), - latencyMs = latencyMs.coerceIn(80, 2000), + latencyMs = latencyMs.coerceIn(80, 200), ) } @@ -974,10 +1047,8 @@ class MainActivity : Activity() { val clamped = newPort.coerceIn(1024, 65535) if (clamped == currentPort) return currentPort = clamped - portLabel.text = currentPort.toString() - renderConnectionInfo() // Restart the phone server on the new port - stopPhoneServer() + stopPhoneServer(clearReservation = false) // Re-create the advertiser with new port phoneAdvertiser.stop() phoneAdvertiser = PhoneDiscoveryAdvertiser( @@ -1051,6 +1122,11 @@ class MainActivity : Activity() { private const val RECONNECT_RESERVATION_MS = 45_000L private const val IDENTIFY_OVERLAY_MS = 3_000L private const val UPDATE_CHECK_DELAY_MS = 2_500L + private const val LENS_RESTART_DELAY_MS = 500L + private const val LISTENER_POLL_MS = 250L + private const val LISTENER_RETRY_MS = 750L + private const val LISTENER_STOP_TIMEOUT_MS = 2_000L + private const val SETTINGS_REQUEST_CODE = 200 private const val APP_PREFS_NAME = "openstream_app" private const val PREF_LAST_VERSION_DIALOG = "last_version_dialog" private val REQUIRED_PERMISSIONS = arrayOf( diff --git a/android/app/src/main/java/dev/openstream/app/SettingsActivity.kt b/android/app/src/main/java/dev/openstream/app/SettingsActivity.kt index 7d3198c..711aa97 100644 --- a/android/app/src/main/java/dev/openstream/app/SettingsActivity.kt +++ b/android/app/src/main/java/dev/openstream/app/SettingsActivity.kt @@ -1,6 +1,7 @@ package dev.openstream.app import android.app.Activity +import android.content.Intent import android.os.Build import android.os.Bundle import android.view.WindowInsets @@ -17,6 +18,7 @@ class SettingsActivity : Activity() { private lateinit var inputLatency: EditText private lateinit var inputListeningPort: EditText private lateinit var btnSave: TextView + private lateinit var btnSaveAndConnect: TextView private lateinit var btnBack: TextView private lateinit var btnCheckUpdates: TextView private lateinit var versionInfo: TextView @@ -31,6 +33,7 @@ class SettingsActivity : Activity() { inputLatency = findViewById(R.id.settingsLatency) inputListeningPort = findViewById(R.id.settingsListeningPort) btnSave = findViewById(R.id.btnSaveSettings) + btnSaveAndConnect = findViewById(R.id.btnSaveAndConnect) btnBack = findViewById(R.id.btnBackSettings) btnCheckUpdates = findViewById(R.id.btnCheckUpdates) versionInfo = findViewById(R.id.settingsVersionInfo) @@ -41,7 +44,8 @@ class SettingsActivity : Activity() { loadSettings() showVersionInfo() - btnSave.setOnClickListener { saveSettings() } + btnSave.setOnClickListener { saveSettings(connectAfterSave = false) } + btnSaveAndConnect.setOnClickListener { saveSettings(connectAfterSave = true) } btnBack.setOnClickListener { finish() } btnCheckUpdates.setOnClickListener { appUpdater.checkForUpdates(showAlreadyCurrent = true) @@ -54,7 +58,7 @@ class SettingsActivity : Activity() { } override fun onDestroy() { - appUpdater.unregister() + appUpdater.dispose() super.onDestroy() } @@ -69,24 +73,72 @@ class SettingsActivity : Activity() { inputListeningPort.setText(listenPort.toString()) } - private fun saveSettings() { + private fun saveSettings(connectAfterSave: Boolean) { + clearValidationErrors() val host = inputObsHost.text.toString().trim() - val port = inputObsPort.text.toString().toIntOrNull() ?: ConnectionTarget.DEFAULT_PORT - val latency = inputLatency.text.toString().toIntOrNull() ?: ConnectionTarget.DEFAULT_LATENCY_MS - val listenPort = inputListeningPort.text.toString().toIntOrNull() ?: ConnectionTarget.DEFAULT_PORT + if (!SettingsValidator.isValidHost(host, required = connectAfterSave)) { + inputObsHost.error = "Enter a valid OBS host or IP address" + inputObsHost.requestFocus() + return + } + val port = validatedNumber( + input = inputObsPort, + defaultValue = ConnectionTarget.DEFAULT_PORT, + validRange = 1..65535, + label = "OBS port", + ) ?: return + val latency = validatedNumber( + input = inputLatency, + defaultValue = ConnectionTarget.DEFAULT_LATENCY_MS, + validRange = 80..200, + label = "Latency", + ) ?: return + val listenPort = validatedNumber( + input = inputListeningPort, + defaultValue = ConnectionTarget.DEFAULT_PORT, + validRange = 1024..65535, + label = "Listening port", + ) ?: return getSharedPreferences(PREFS_NAME, MODE_PRIVATE).edit() .putString(KEY_OBS_HOST, host) - .putInt(KEY_OBS_PORT, port.coerceIn(1, 65535)) - .putInt(KEY_LATENCY, latency.coerceIn(20, 2000)) - .putInt(KEY_LISTENING_PORT, listenPort.coerceIn(1024, 65535)) + .putInt(KEY_OBS_PORT, port) + .putInt(KEY_LATENCY, latency) + .putInt(KEY_LISTENING_PORT, listenPort) .apply() Toast.makeText(this, "Settings saved", Toast.LENGTH_SHORT).show() - setResult(RESULT_OK) + setResult( + RESULT_OK, + Intent().putExtra(EXTRA_CONNECT_AFTER_SAVE, connectAfterSave), + ) finish() } + private fun validatedNumber( + input: EditText, + defaultValue: Int, + validRange: IntRange, + label: String, + ): Int? { + val raw = input.text.toString().trim() + if (raw.isBlank()) return defaultValue + val value = SettingsValidator.parseNumber(raw, defaultValue, validRange) + if (value == null) { + input.error = "$label must be between ${validRange.first} and ${validRange.last}" + input.requestFocus() + return null + } + return value + } + + private fun clearValidationErrors() { + inputObsHost.error = null + inputObsPort.error = null + inputLatency.error = null + inputListeningPort.error = null + } + private fun showVersionInfo() { runCatching { val info = packageManager.getPackageInfo(packageName, 0) @@ -106,5 +158,6 @@ class SettingsActivity : Activity() { const val KEY_OBS_PORT = "obs_port" const val KEY_LATENCY = "latency_ms" const val KEY_LISTENING_PORT = "listening_port" + const val EXTRA_CONNECT_AFTER_SAVE = "connect_after_save" } } diff --git a/android/app/src/main/java/dev/openstream/app/SettingsValidator.kt b/android/app/src/main/java/dev/openstream/app/SettingsValidator.kt new file mode 100644 index 0000000..50b970d --- /dev/null +++ b/android/app/src/main/java/dev/openstream/app/SettingsValidator.kt @@ -0,0 +1,19 @@ +package dev.openstream.app + +/** Pure settings validation shared by the UI and local unit tests. */ +object SettingsValidator { + private val hostname = Regex("^(?=.{1,253}\\.?$)(?:[A-Za-z0-9](?:[A-Za-z0-9-]{0,61}[A-Za-z0-9])?\\.)*[A-Za-z0-9](?:[A-Za-z0-9-]{0,61}[A-Za-z0-9])?\\.?$") + private val ipv4 = Regex("^(?:25[0-5]|2[0-4]\\d|1?\\d?\\d)(?:\\.(?:25[0-5]|2[0-4]\\d|1?\\d?\\d)){3}$") + private val bracketedIpv6 = Regex("^\\[[0-9A-Fa-f:.]+]$") + + fun isValidHost(host: String, required: Boolean): Boolean { + if (host.isBlank()) return !required + if (host.all { it.isDigit() || it == '.' }) return ipv4.matches(host) + return hostname.matches(host) || (bracketedIpv6.matches(host) && ':' in host) + } + + fun parseNumber(raw: String, defaultValue: Int, validRange: IntRange): Int? { + if (raw.isBlank()) return defaultValue + return raw.toIntOrNull()?.takeIf { it in validRange } + } +} diff --git a/android/app/src/main/java/dev/openstream/app/control/CameraControlServer.kt b/android/app/src/main/java/dev/openstream/app/control/CameraControlServer.kt index a62bd42..4d1c2a6 100644 --- a/android/app/src/main/java/dev/openstream/app/control/CameraControlServer.kt +++ b/android/app/src/main/java/dev/openstream/app/control/CameraControlServer.kt @@ -4,8 +4,8 @@ import android.util.Log import dev.openstream.app.camera.Camera2Controller import dev.openstream.app.camera.CameraLens import org.json.JSONObject -import java.io.BufferedReader -import java.io.InputStreamReader +import java.io.BufferedInputStream +import java.io.OutputStreamWriter import java.io.PrintWriter import java.net.ServerSocket import java.net.Socket @@ -80,11 +80,11 @@ class CameraControlServer( private fun handleClient(client: Socket) { try { client.soTimeout = 5000 - val reader = BufferedReader(InputStreamReader(client.getInputStream())) - val writer = PrintWriter(client.getOutputStream(), true) + val input = BufferedInputStream(client.getInputStream()) + val writer = PrintWriter(OutputStreamWriter(client.getOutputStream(), Charsets.UTF_8), false) // Parse request line - val requestLine = reader.readLine() ?: return + val requestLine = readAsciiLine(input, MAX_REQUEST_LINE_BYTES) ?: return val parts = requestLine.split(" ") if (parts.size < 2) { sendResponse(writer, 400, """{"error":"bad request"}""") @@ -95,19 +95,39 @@ class CameraControlServer( // Read headers to get Content-Length var contentLength = 0 - var line = reader.readLine() + var headerBytes = 0 + var line = readAsciiLine(input, MAX_HEADER_LINE_BYTES) while (line != null && line.isNotEmpty()) { + headerBytes += line.toByteArray(Charsets.US_ASCII).size + 2 + if (headerBytes > MAX_HEADER_BYTES) { + sendResponse(writer, 413, """{"error":"headers too large"}""") + return + } if (line.startsWith("Content-Length:", ignoreCase = true)) { - contentLength = line.substringAfter(":").trim().toIntOrNull() ?: 0 + contentLength = line.substringAfter(":").trim().toIntOrNull() ?: -1 } - line = reader.readLine() + line = readAsciiLine(input, MAX_HEADER_LINE_BYTES) + } + if (line == null || contentLength !in 0..MAX_BODY_BYTES) { + sendResponse(writer, if (contentLength > MAX_BODY_BYTES) 413 else 400, + if (contentLength > MAX_BODY_BYTES) """{"error":"request too large"}""" + else """{"error":"bad request"}""") + return } // Read body if present val body = if (contentLength > 0) { - val chars = CharArray(contentLength) - reader.read(chars, 0, contentLength) - String(chars) + val bytes = ByteArray(contentLength) + var offset = 0 + while (offset < bytes.size) { + val read = input.read(bytes, offset, bytes.size - offset) + if (read < 0) { + sendResponse(writer, 400, """{"error":"incomplete request"}""") + return + } + offset += read + } + String(bytes, Charsets.UTF_8) } else "" // Route request @@ -138,20 +158,37 @@ class CameraControlServer( 200 -> "OK" 400 -> "Bad Request" 404 -> "Not Found" + 413 -> "Payload Too Large" else -> "Error" } + val bodyBytes = body.toByteArray(Charsets.UTF_8) writer.print("HTTP/1.1 $code $status\r\n") writer.print("Content-Type: application/json\r\n") writer.print("Access-Control-Allow-Origin: *\r\n") writer.print("Access-Control-Allow-Methods: GET, POST, OPTIONS\r\n") writer.print("Access-Control-Allow-Headers: Content-Type\r\n") - writer.print("Content-Length: ${body.length}\r\n") + writer.print("Content-Length: ${bodyBytes.size}\r\n") writer.print("Connection: close\r\n") writer.print("\r\n") writer.print(body) writer.flush() } + /** Reads a CRLF-delimited HTTP line without decoding body bytes as characters. */ + private fun readAsciiLine(input: BufferedInputStream, maxBytes: Int): String? { + val bytes = ArrayList(minOf(maxBytes, 256)) + while (bytes.size <= maxBytes) { + val value = input.read() + if (value < 0) return null + if (value == '\n'.code) { + if (bytes.lastOrNull() == '\r'.code.toByte()) bytes.removeAt(bytes.lastIndex) + return bytes.toByteArray().toString(Charsets.US_ASCII) + } + bytes.add(value.toByte()) + } + return null + } + private fun handleStatus(): String { val camera = cameraProvider() val json = JSONObject() @@ -227,5 +264,9 @@ class CameraControlServer( companion object { private const val TAG = "OpenStreamControl" const val CONTROL_PORT = 9001 + private const val MAX_REQUEST_LINE_BYTES = 2_048 + private const val MAX_HEADER_LINE_BYTES = 2_048 + private const val MAX_HEADER_BYTES = 8_192 + private const val MAX_BODY_BYTES = 8_192 } } diff --git a/android/app/src/main/java/dev/openstream/app/discovery/ObsDiscoveryClient.kt b/android/app/src/main/java/dev/openstream/app/discovery/ObsDiscoveryClient.kt index e2a2bbf..f4511e9 100644 --- a/android/app/src/main/java/dev/openstream/app/discovery/ObsDiscoveryClient.kt +++ b/android/app/src/main/java/dev/openstream/app/discovery/ObsDiscoveryClient.kt @@ -160,7 +160,7 @@ object ObsDiscoveryProtocol { name = json.optString("name", "OpenStream Phone Link").ifBlank { "OpenStream Phone Link" }, host = host, port = port, - latencyMs = json.optInt("latencyMs", 120).coerceIn(20, 2000), + latencyMs = json.optInt("latencyMs", 120).coerceIn(80, 200), bitrateMbps = json.optInt("bitrateMbps", 12).coerceIn(1, 200), instanceId = json.optString("instanceId", "$packetHost:$port"), sourceInstanceId = json.optString("sourceInstanceId", json.optString("instanceId", "$packetHost:$port")), diff --git a/android/app/src/main/java/dev/openstream/app/encoder/MediaCodecAudioEncoder.kt b/android/app/src/main/java/dev/openstream/app/encoder/MediaCodecAudioEncoder.kt index 5ad3ea5..b798f28 100644 --- a/android/app/src/main/java/dev/openstream/app/encoder/MediaCodecAudioEncoder.kt +++ b/android/app/src/main/java/dev/openstream/app/encoder/MediaCodecAudioEncoder.kt @@ -1,5 +1,9 @@ package dev.openstream.app.encoder +import android.Manifest +import android.annotation.SuppressLint +import android.content.Context +import android.content.pm.PackageManager import android.media.AudioFormat import android.media.AudioRecord import android.media.MediaCodec @@ -17,17 +21,22 @@ import kotlin.math.max * Encoded access units are delivered via [onEncodedAccessUnit] for muxing into MPEG-TS. */ class MediaCodecAudioEncoder( + context: Context, private val sampleRate: Int = 48_000, private val channelCount: Int = 1, private val bitrate: Int = 192_000, private val onEncodedAccessUnit: (EncodedAccessUnit) -> Unit, ) { + private val context = context.applicationContext private var codec: MediaCodec? = null private var audioRecord: AudioRecord? = null private var captureThread: Thread? = null @Volatile private var running = false fun start() { + check(context.checkSelfPermission(Manifest.permission.RECORD_AUDIO) == PackageManager.PERMISSION_GRANTED) { + "Microphone permission is not granted" + } // If already running, stop first to allow clean restart if (codec != null) { stop() @@ -176,6 +185,7 @@ class MediaCodecAudioEncoder( return bytes.takeIf { it.isNotEmpty() } } + @SuppressLint("MissingPermission") private fun createRecorder(channelConfig: Int, bufferSize: Int): AudioRecord { val sources = buildList { if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) { diff --git a/android/app/src/main/java/dev/openstream/app/stream/SrtStreamClient.kt b/android/app/src/main/java/dev/openstream/app/stream/SrtStreamClient.kt index 2f19d3e..a37ac3a 100644 --- a/android/app/src/main/java/dev/openstream/app/stream/SrtStreamClient.kt +++ b/android/app/src/main/java/dev/openstream/app/stream/SrtStreamClient.kt @@ -15,7 +15,10 @@ data class StreamStats( } class SrtStreamClient { - private var connected = false + @Volatile private var connected = false + private val sessionGeneration = AtomicLong() + private val operationLock = Any() + private val stateLock = Any() val stats: StreamStats get() = StreamStats( accessUnitsSent = accessUnitsSent.get(), @@ -33,16 +36,20 @@ class SrtStreamClient { fun connect(url: String, codecMime: String, width: Int, height: Int, fps: Int) { require(url.startsWith("srt://")) { "OpenStream V1 expects an SRT URL" } - check(SrtNativeBridge.connect(url, codecMime, width, height, fps)) { "Native SRT bridge failed to connect" } - resetStats() - connected = true + synchronized(operationLock) { + establishSession("connection") { + SrtNativeBridge.connect(url, codecMime, width, height, fps) + } + } } fun listen(url: String, codecMime: String, width: Int, height: Int, fps: Int) { require(url.startsWith("srt://")) { "OpenStream V2 expects an SRT URL" } - check(SrtNativeBridge.listen(url, codecMime, width, height, fps)) { "Native SRT bridge failed to listen" } - resetStats() - connected = true + synchronized(operationLock) { + establishSession("listener") { + SrtNativeBridge.listen(url, codecMime, width, height, fps) + } + } } fun sendVideoAccessUnit(accessUnit: EncodedAccessUnit): Boolean { @@ -72,10 +79,36 @@ class SrtStreamClient { } fun disconnect() { - if (connected) { + synchronized(stateLock) { + sessionGeneration.incrementAndGet() + connected = false + // listen() blocks in native accept before connected becomes true. This + // must not take operationLock so lifecycle stop can cancel that accept. SrtNativeBridge.disconnect() } - connected = false + } + + private inline fun establishSession(operationName: String, nativeOperation: () -> Boolean) { + val generation = synchronized(stateLock) { + sessionGeneration.incrementAndGet() + } + val didConnect = nativeOperation() + val cancelled = synchronized(stateLock) { + if (generation != sessionGeneration.get()) { + true + } else { + check(didConnect) { "Native SRT bridge failed to $operationName" } + resetStats() + connected = true + false + } + } + if (cancelled) { + // operationLock is still held, so this cleanup cannot tear down a + // subsequently started connect/listen operation. + if (didConnect) SrtNativeBridge.disconnect() + error("SRT $operationName was cancelled") + } } private fun resetStats() { diff --git a/android/app/src/main/java/dev/openstream/app/stream/StreamConfig.kt b/android/app/src/main/java/dev/openstream/app/stream/StreamConfig.kt index 60cb017..0306123 100644 --- a/android/app/src/main/java/dev/openstream/app/stream/StreamConfig.kt +++ b/android/app/src/main/java/dev/openstream/app/stream/StreamConfig.kt @@ -10,6 +10,9 @@ data class StreamConfig( val keyframeIntervalSeconds: Int, val latencyMs: Int, val codecPreference: CodecPreference, + val audioSampleRate: Int, + val audioChannelCount: Int, + val audioBitrate: Int, ) { val bitrateMbps: Int get() = bitrate / 1_000_000 @@ -23,6 +26,9 @@ data class StreamConfig( keyframeIntervalSeconds = 1, latencyMs = 120, codecPreference = CodecPreference.PreferHevc, + audioSampleRate = 48_000, + audioChannelCount = 1, + audioBitrate = 192_000, ) val Fallback720p60 = StreamConfig( @@ -33,6 +39,9 @@ data class StreamConfig( keyframeIntervalSeconds = 1, latencyMs = 120, codecPreference = CodecPreference.PreferHevc, + audioSampleRate = 48_000, + audioChannelCount = 1, + audioBitrate = 192_000, ) } } diff --git a/android/app/src/main/java/dev/openstream/app/update/AppUpdater.kt b/android/app/src/main/java/dev/openstream/app/update/AppUpdater.kt index 5efb02c..f795cac 100644 --- a/android/app/src/main/java/dev/openstream/app/update/AppUpdater.kt +++ b/android/app/src/main/java/dev/openstream/app/update/AppUpdater.kt @@ -19,7 +19,10 @@ import dev.openstream.app.R import org.json.JSONObject import java.net.HttpURLConnection import java.net.URL +import java.security.MessageDigest +import java.util.concurrent.RejectedExecutionException import java.util.concurrent.Executors +import java.util.concurrent.atomic.AtomicBoolean class AppUpdater( private val activity: Activity, @@ -27,7 +30,10 @@ class AppUpdater( private val executor = Executors.newSingleThreadExecutor() private val downloadManager = activity.getSystemService(DownloadManager::class.java) private var pendingDownloadId: Long = NO_DOWNLOAD + private var pendingRelease: ReleaseUpdate? = null private var registered = false + private var verifyingDownloadId: Long = NO_DOWNLOAD + private val disposed = AtomicBoolean(false) private val downloadReceiver = object : BroadcastReceiver() { override fun onReceive(context: Context, intent: Intent) { @@ -42,7 +48,7 @@ class AppUpdater( } fun register() { - if (registered) return + if (disposed.get() || registered) return val filter = IntentFilter(DownloadManager.ACTION_DOWNLOAD_COMPLETE) if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { activity.registerReceiver(downloadReceiver, filter, Context.RECEIVER_NOT_EXPORTED) @@ -59,10 +65,17 @@ class AppUpdater( registered = false } + fun dispose() { + if (!disposed.compareAndSet(false, true)) return + unregister() + executor.shutdownNow() + } + fun checkForUpdates(showAlreadyCurrent: Boolean = false) { - executor.execute { + if (disposed.get()) return + submitUpdateWork { val result = runCatching { fetchLatestRelease() } - activity.runOnUiThread { + runWhenActivityIsActive { result .onSuccess { release -> if (release.isNewerThan(currentVersionName(), currentVersionCode())) { @@ -82,7 +95,7 @@ class AppUpdater( } fun resumePendingInstallIfAllowed() { - if (pendingDownloadId == NO_DOWNLOAD) return + if (disposed.get() || pendingDownloadId == NO_DOWNLOAD) return if (canRequestPackageInstall()) { installDownloadedApk() } @@ -108,6 +121,10 @@ class AppUpdater( name = json.optString("name"), versionCode = metadata?.optLong("versionCode")?.takeIf { it > 0 }, apkUrl = apkUrl ?: error("Release asset $ANDROID_APK_ASSET was not found"), + apkSha256 = metadata?.optString("apkSha256") + ?.lowercase() + ?.takeIf { it.matches(SHA256_HEX) } + ?: error("Release metadata did not contain a valid APK SHA-256 digest"), ) } @@ -128,6 +145,7 @@ class AppUpdater( private fun downloadApk(release: ReleaseUpdate) { + if (disposed.get()) return val request = DownloadManager.Request(Uri.parse(release.apkUrl)) .setTitle("OpenStream ${release.displayVersion}") .setDescription("Downloading OpenStream update") @@ -141,6 +159,7 @@ class AppUpdater( .setAllowedOverMetered(true) .setAllowedOverRoaming(false) + pendingRelease = release pendingDownloadId = downloadManager.enqueue(request) Toast.makeText(activity, "Downloading update", Toast.LENGTH_SHORT).show() } @@ -172,15 +191,41 @@ class AppUpdater( private fun installDownloadedApk() { if (!isSuccessfulDownload()) { Toast.makeText(activity, "Update download failed", Toast.LENGTH_LONG).show() + pendingDownloadId = NO_DOWNLOAD + pendingRelease = null + return + } + + val downloadId = pendingDownloadId + val release = pendingRelease ?: run { + showVerificationFailure(downloadId) return } + if (verifyingDownloadId == downloadId) return + verifyingDownloadId = downloadId + + submitUpdateWork { + val verified = hasExpectedApkDigest(downloadId, release.apkSha256) + runWhenActivityIsActive { + if (verifyingDownloadId != downloadId) return@runWhenActivityIsActive + verifyingDownloadId = NO_DOWNLOAD + if (pendingDownloadId != downloadId) return@runWhenActivityIsActive + if (!verified) { + showVerificationFailure(downloadId) + return@runWhenActivityIsActive + } + requestPackageInstall(downloadId) + } + } + } + private fun requestPackageInstall(downloadId: Long) { if (!canRequestPackageInstall()) { showInstallPermissionPrompt() return } - val apkUri = downloadManager.getUriForDownloadedFile(pendingDownloadId) + val apkUri = downloadManager.getUriForDownloadedFile(downloadId) if (apkUri == null) { Toast.makeText(activity, "Downloaded APK was not found", Toast.LENGTH_LONG).show() return @@ -193,6 +238,18 @@ class AppUpdater( activity.startActivity(installIntent) } + private fun showVerificationFailure(downloadId: Long) { + Toast.makeText(activity, "Update verification failed", Toast.LENGTH_LONG).show() + if (downloadId != NO_DOWNLOAD) { + runCatching { downloadManager.remove(downloadId) } + .onFailure { error -> Log.w(TAG, "Could not delete unverified update", error) } + } + if (pendingDownloadId == downloadId) { + pendingDownloadId = NO_DOWNLOAD + pendingRelease = null + } + } + private fun isSuccessfulDownload(): Boolean { val query = DownloadManager.Query().setFilterById(pendingDownloadId) downloadManager.query(query)?.use { cursor -> @@ -203,6 +260,39 @@ class AppUpdater( return false } + private fun hasExpectedApkDigest(downloadId: Long, expected: String): Boolean { + val apkUri = downloadManager.getUriForDownloadedFile(downloadId) ?: return false + val actual = runCatching { + activity.contentResolver.openInputStream(apkUri)?.use { input -> + val digest = MessageDigest.getInstance("SHA-256") + val buffer = ByteArray(DEFAULT_BUFFER_SIZE) + while (true) { + val read = input.read(buffer) + if (read < 0) break + digest.update(buffer, 0, read) + } + digest.digest().joinToString("") { byte -> "%02x".format(byte) } + } + }.getOrNull() + return actual != null && actual.equals(expected, ignoreCase = true) + } + + private fun submitUpdateWork(work: () -> Unit) { + if (disposed.get()) return + try { + executor.execute(work) + } catch (_: RejectedExecutionException) { + // dispose() may race a delayed UI callback or broadcast receiver. + } + } + + private fun runWhenActivityIsActive(action: () -> Unit) { + activity.runOnUiThread { + if (disposed.get() || activity.isFinishing || activity.isDestroyed) return@runOnUiThread + action() + } + } + private fun showInstallPermissionPrompt() { val dialog = Dialog(activity, R.style.MinimalDialogTheme) dialog.setContentView(R.layout.dialog_custom_permission) @@ -248,6 +338,7 @@ class AppUpdater( val name: String, val versionCode: Long?, val apkUrl: String, + val apkSha256: String, ) { val displayVersion: String = tagName.ifBlank { name }.removePrefix("v") @@ -262,10 +353,11 @@ class AppUpdater( companion object { private const val TAG = "OpenStreamUpdater" private const val NO_DOWNLOAD = -1L - private const val RELEASE_API_URL = "https://api.github.com/repos/YashasVM/OpenStream/releases/tags/android-latest" + private const val RELEASE_API_URL = "https://api.github.com/repos/YashasVM/OpenStream/releases/latest" private const val ANDROID_APK_ASSET = "openstream-android.apk" private const val ANDROID_UPDATE_METADATA_ASSET = "openstream-android-update.json" private const val APK_MIME_TYPE = "application/vnd.android.package-archive" + private val SHA256_HEX = Regex("^[0-9a-f]{64}$") private fun compareVersions(candidate: String, current: String): Int { val candidateParts = VersionParts.parse(candidate) diff --git a/android/app/src/main/res/layout/activity_main.xml b/android/app/src/main/res/layout/activity_main.xml index 5335f8b..5b47e9c 100644 --- a/android/app/src/main/res/layout/activity_main.xml +++ b/android/app/src/main/res/layout/activity_main.xml @@ -18,16 +18,6 @@ android:layout_height="match_parent" android:layout_gravity="center" /> - - - - - - - - - - - + - - - - - - + android:layout_height="match_parent" + android:background="@color/os_black" + android:contentDescription="@string/screen_off_overlay_description" + android:visibility="gone" + android:clickable="true" + android:focusable="true" /> diff --git a/android/app/src/main/res/layout/activity_settings.xml b/android/app/src/main/res/layout/activity_settings.xml index 91f1150..3d49a93 100644 --- a/android/app/src/main/res/layout/activity_settings.xml +++ b/android/app/src/main/res/layout/activity_settings.xml @@ -16,16 +16,18 @@ @@ -42,12 +44,15 @@ android:paddingStart="16dp" android:paddingEnd="16dp" android:singleLine="true" + android:inputType="textUri" android:layout_marginBottom="24dp" /> @@ -68,9 +73,11 @@ android:layout_marginBottom="24dp" /> @@ -91,9 +98,11 @@ android:layout_marginBottom="24dp" /> @@ -114,39 +123,56 @@ android:layout_marginBottom="40dp" /> - - - +