From 65c63b2973bc5031191bd7530e91d9345b6e6680 Mon Sep 17 00:00:00 2001 From: YashasVM Date: Tue, 4 Aug 2026 23:07:07 +0530 Subject: [PATCH 1/2] restore legacy Android and OBS codebase --- .github/workflows/v4-windows-foundation.yml | 47 - .gitignore | 4 - CMakeLists.txt | 27 - CMakePresets.json | 69 -- CMakeUserPresets.json.example | 22 - android/app/build.gradle.kts | 4 +- android/app/src/main/AndroidManifest.xml | 9 +- android/app/src/main/cpp/openstream_srt.cpp | 47 +- .../java/dev/openstream/app/MainActivity.kt | 944 ++---------------- .../dev/openstream/app/OpenStreamUiState.kt | 12 - .../dev/openstream/app/SettingsActivity.kt | 26 +- .../dev/openstream/app/audio/AudioLevel.kt | 50 - .../app/camera/Camera2Controller.kt | 713 ++++--------- .../dev/openstream/app/camera/CameraLens.kt | 110 +- .../dev/openstream/app/camera/CameraModels.kt | 192 ---- .../openstream/app/camera/CameraStateStore.kt | 272 ----- .../app/camera/FocusCoordinateMapper.kt | 50 - .../app/control/CameraControlServer.kt | 489 +++------ .../app/control/PairingTokenStore.kt | 77 -- .../app/encoder/MediaCodecAudioEncoder.kt | 25 +- .../app/encoder/MediaCodecVideoEncoder.kt | 11 - .../app/monitoring/AudioLevelMeterView.kt | 118 --- .../app/monitoring/MonitoringOverlayView.kt | 118 --- .../app/monitoring/ZebraAnalyzer.kt | 21 - .../app/service/OpenStreamCameraService.kt | 189 ---- .../openstream/app/stream/SrtStreamClient.kt | 26 +- .../app/telemetry/TelemetryFormatter.kt | 41 - .../app/telemetry/TelemetrySampler.kt | 57 +- .../dev/openstream/app/update/AppUpdater.kt | 259 +---- .../main/res/drawable/bg_camera_palette.xml | 6 - .../main/res/drawable/bg_focus_reticle.xml | 6 - .../app/src/main/res/drawable/bg_hud_chip.xml | 6 - .../app/src/main/res/layout/activity_main.xml | 401 ++++---- .../src/main/res/layout/activity_settings.xml | 335 +++---- .../res/layout/dialog_custom_permission.xml | 17 +- .../main/res/layout/dialog_custom_update.xml | 85 +- android/app/src/main/res/values/colors.xml | 12 +- android/app/src/main/res/values/strings.xml | 57 +- android/app/src/main/res/values/styles.xml | 83 -- .../app/audio/Pcm16AudioLevelTest.kt | 30 - .../app/camera/CameraLensDiscoveryTest.kt | 54 - .../app/camera/CameraStateStoreTest.kt | 107 -- .../app/camera/FocusCoordinateMapperTest.kt | 48 - .../app/monitoring/ZebraAnalyzerTest.kt | 37 - .../app/telemetry/TelemetryFormatterTest.kt | 69 -- cmake/bootstrap-vcpkg.ps1 | 47 - cmake/tests/foundation_smoke.cpp | 10 - cmake/verify-engine-isolation.ps1 | 24 - cmake/write-third-party-notices.ps1 | 65 -- docs/architecture.md | 239 +++-- docs/benchmark-results-template.csv | 3 - docs/benchmark-run-template.json | 17 - docs/evidence/v4-00-media-smoke.md | 35 - docs/pro-camera-implementation-status.md | 74 -- docs/protocol.md | 421 ++++---- docs/v4-execution-plan.md | 642 ------------ docs/v4-gpu-pipeline.md | 37 - docs/v4-master-plan.md | 61 -- docs/v4-performance-budget.md | 41 - docs/v4-phase0-status.md | 28 - docs/v4-recording-and-recovery.md | 31 - docs/v4-timestamp-model.md | 29 - docs/v4-transport-benchmark.md | 64 -- docs/windows-build.md | 49 - pytest.ini | 2 - tests/test_repo_contract.py | 225 +---- tests/test_v4_phase0_tools.py | 119 --- tools/network-impairment/README.md | 3 - tools/network-impairment/impair.py | 95 -- tools/recording-validator/README.md | 3 - tools/recording-validator/validate.py | 101 -- tools/stream-simulator/README.md | 10 - tools/stream-simulator/generate-media.ps1 | 34 - tools/stream-simulator/generate.py | 66 -- vcpkg.json | 14 - 75 files changed, 1244 insertions(+), 6727 deletions(-) delete mode 100644 .github/workflows/v4-windows-foundation.yml delete mode 100644 CMakeLists.txt delete mode 100644 CMakePresets.json delete mode 100644 CMakeUserPresets.json.example delete mode 100644 android/app/src/main/java/dev/openstream/app/OpenStreamUiState.kt delete mode 100644 android/app/src/main/java/dev/openstream/app/audio/AudioLevel.kt delete mode 100644 android/app/src/main/java/dev/openstream/app/camera/CameraModels.kt delete mode 100644 android/app/src/main/java/dev/openstream/app/camera/CameraStateStore.kt delete mode 100644 android/app/src/main/java/dev/openstream/app/camera/FocusCoordinateMapper.kt delete mode 100644 android/app/src/main/java/dev/openstream/app/control/PairingTokenStore.kt delete mode 100644 android/app/src/main/java/dev/openstream/app/monitoring/AudioLevelMeterView.kt delete mode 100644 android/app/src/main/java/dev/openstream/app/monitoring/MonitoringOverlayView.kt delete mode 100644 android/app/src/main/java/dev/openstream/app/monitoring/ZebraAnalyzer.kt delete mode 100644 android/app/src/main/java/dev/openstream/app/service/OpenStreamCameraService.kt delete mode 100644 android/app/src/main/java/dev/openstream/app/telemetry/TelemetryFormatter.kt delete mode 100644 android/app/src/main/res/drawable/bg_camera_palette.xml delete mode 100644 android/app/src/main/res/drawable/bg_focus_reticle.xml delete mode 100644 android/app/src/main/res/drawable/bg_hud_chip.xml delete mode 100644 android/app/src/test/java/dev/openstream/app/audio/Pcm16AudioLevelTest.kt delete mode 100644 android/app/src/test/java/dev/openstream/app/camera/CameraLensDiscoveryTest.kt delete mode 100644 android/app/src/test/java/dev/openstream/app/camera/CameraStateStoreTest.kt delete mode 100644 android/app/src/test/java/dev/openstream/app/camera/FocusCoordinateMapperTest.kt delete mode 100644 android/app/src/test/java/dev/openstream/app/monitoring/ZebraAnalyzerTest.kt delete mode 100644 android/app/src/test/java/dev/openstream/app/telemetry/TelemetryFormatterTest.kt delete mode 100644 cmake/bootstrap-vcpkg.ps1 delete mode 100644 cmake/tests/foundation_smoke.cpp delete mode 100644 cmake/verify-engine-isolation.ps1 delete mode 100644 cmake/write-third-party-notices.ps1 delete mode 100644 docs/benchmark-results-template.csv delete mode 100644 docs/benchmark-run-template.json delete mode 100644 docs/evidence/v4-00-media-smoke.md delete mode 100644 docs/pro-camera-implementation-status.md delete mode 100644 docs/v4-execution-plan.md delete mode 100644 docs/v4-gpu-pipeline.md delete mode 100644 docs/v4-master-plan.md delete mode 100644 docs/v4-performance-budget.md delete mode 100644 docs/v4-phase0-status.md delete mode 100644 docs/v4-recording-and-recovery.md delete mode 100644 docs/v4-timestamp-model.md delete mode 100644 docs/v4-transport-benchmark.md delete mode 100644 docs/windows-build.md delete mode 100644 pytest.ini delete mode 100644 tests/test_v4_phase0_tools.py delete mode 100644 tools/network-impairment/README.md delete mode 100644 tools/network-impairment/impair.py delete mode 100644 tools/recording-validator/README.md delete mode 100644 tools/recording-validator/validate.py delete mode 100644 tools/stream-simulator/README.md delete mode 100644 tools/stream-simulator/generate-media.ps1 delete mode 100644 tools/stream-simulator/generate.py delete mode 100644 vcpkg.json diff --git a/.github/workflows/v4-windows-foundation.yml b/.github/workflows/v4-windows-foundation.yml deleted file mode 100644 index 56a485d..0000000 --- a/.github/workflows/v4-windows-foundation.yml +++ /dev/null @@ -1,47 +0,0 @@ -name: V4 Windows Foundation - -on: - push: - branches: [main] - pull_request: - workflow_dispatch: - -permissions: - contents: read - -jobs: - build: - name: Configure, build, and test Windows x64 - runs-on: windows-2022 - - steps: - - name: Check out repository - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4 - with: - persist-credentials: false - - - name: Bootstrap pinned vcpkg - shell: powershell - run: | - $root = & cmake/bootstrap-vcpkg.ps1 | Select-Object -Last 1 - "VCPKG_ROOT=$root" | Out-File -FilePath $env:GITHUB_ENV -Encoding utf8 -Append - - - name: Configure - run: cmake --preset windows-x64-release - - - name: Build - run: cmake --build --preset windows-x64-release - - - name: Test - run: ctest --preset windows-x64-release - - - name: Emit dependency licenses and hashes - shell: powershell - run: cmake/write-third-party-notices.ps1 -InstalledDirectory out/vcpkg_installed/windows-x64-release - - - name: Upload dependency provenance - uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4 - with: - name: v4-windows-dependency-provenance - path: out/provenance - if-no-files-found: error diff --git a/.gitignore b/.gitignore index 82aa241..931d49c 100644 --- a/.gitignore +++ b/.gitignore @@ -14,9 +14,7 @@ android/build/ android/app/build/ android/app/.cxx/ -build/ out/ -CMakeUserPresets.json __pycache__/ *.py[cod] @@ -43,5 +41,3 @@ artifacts-local/ website/node_modules/ website/dist/ aqtinstall.log -/.phase0-smoke/ -/.phase0-smoke-*.log diff --git a/CMakeLists.txt b/CMakeLists.txt deleted file mode 100644 index 43060c9..0000000 --- a/CMakeLists.txt +++ /dev/null @@ -1,27 +0,0 @@ -cmake_minimum_required(VERSION 3.30) - -project(OpenStream VERSION 4.0.0 LANGUAGES CXX) - -if(NOT WIN32 OR NOT CMAKE_SIZEOF_VOID_P EQUAL 8) - message(FATAL_ERROR "OpenStream V4 currently supports Windows x64 only") -endif() - -set(CMAKE_CXX_EXTENSIONS OFF) -set(CMAKE_MSVC_RUNTIME_LIBRARY "MultiThreaded$<$:Debug>DLL") - -include(CTest) -find_package(nlohmann_json 3.12.0 CONFIG REQUIRED) - -add_executable(openstream_foundation_smoke cmake/tests/foundation_smoke.cpp) -target_compile_features(openstream_foundation_smoke PRIVATE cxx_std_20) -target_compile_definitions(openstream_foundation_smoke PRIVATE NOMINMAX WIN32_LEAN_AND_MEAN) -target_compile_options(openstream_foundation_smoke PRIVATE /permissive- /W4 /WX /utf-8) -target_link_libraries(openstream_foundation_smoke PRIVATE nlohmann_json::nlohmann_json) - -add_test(NAME windows_foundation_smoke COMMAND openstream_foundation_smoke) -add_test( - NAME engine_has_no_obs_dependency - COMMAND powershell -NoProfile -ExecutionPolicy Bypass - -File "${CMAKE_SOURCE_DIR}/cmake/verify-engine-isolation.ps1" - -RepositoryRoot "${CMAKE_SOURCE_DIR}" -) diff --git a/CMakePresets.json b/CMakePresets.json deleted file mode 100644 index 3c7c628..0000000 --- a/CMakePresets.json +++ /dev/null @@ -1,69 +0,0 @@ -{ - "version": 6, - "cmakeMinimumRequired": { - "major": 3, - "minor": 30, - "patch": 0 - }, - "configurePresets": [ - { - "name": "windows-x64-base", - "hidden": true, - "generator": "Visual Studio 17 2022", - "architecture": "x64", - "environment": { - "TrackFileAccess": "false" - }, - "cacheVariables": { - "CMAKE_TOOLCHAIN_FILE": { - "type": "FILEPATH", - "value": "$env{VCPKG_ROOT}/scripts/buildsystems/vcpkg.cmake" - }, - "VCPKG_TARGET_TRIPLET": "x64-windows", - "VCPKG_INSTALLED_DIR": "${sourceDir}/out/vcpkg_installed/${presetName}" - } - }, - { - "name": "windows-x64-debug", - "displayName": "Windows x64 Debug", - "inherits": "windows-x64-base", - "binaryDir": "${sourceDir}/out/build/${presetName}" - }, - { - "name": "windows-x64-release", - "displayName": "Windows x64 Release", - "inherits": "windows-x64-base", - "binaryDir": "${sourceDir}/out/build/${presetName}" - } - ], - "buildPresets": [ - { - "name": "windows-x64-debug", - "configurePreset": "windows-x64-debug", - "configuration": "Debug" - }, - { - "name": "windows-x64-release", - "configurePreset": "windows-x64-release", - "configuration": "Release" - } - ], - "testPresets": [ - { - "name": "windows-x64-debug", - "configurePreset": "windows-x64-debug", - "configuration": "Debug", - "output": { - "outputOnFailure": true - } - }, - { - "name": "windows-x64-release", - "configurePreset": "windows-x64-release", - "configuration": "Release", - "output": { - "outputOnFailure": true - } - } - ] -} diff --git a/CMakeUserPresets.json.example b/CMakeUserPresets.json.example deleted file mode 100644 index c209d49..0000000 --- a/CMakeUserPresets.json.example +++ /dev/null @@ -1,22 +0,0 @@ -{ - "version": 6, - "include": [ - "CMakePresets.json" - ], - "configurePresets": [ - { - "name": "local-windows-x64-debug", - "inherits": "windows-x64-debug", - "environment": { - "VCPKG_ROOT": "${sourceDir}/out/vcpkg" - } - }, - { - "name": "local-windows-x64-release", - "inherits": "windows-x64-release", - "environment": { - "VCPKG_ROOT": "${sourceDir}/out/vcpkg" - } - } - ] -} diff --git a/android/app/build.gradle.kts b/android/app/build.gradle.kts index db22f9a..0697633 100644 --- a/android/app/build.gradle.kts +++ b/android/app/build.gradle.kts @@ -17,11 +17,11 @@ val hasReleaseSigning = listOf( ).all { !it.isNullOrBlank() } val openStreamVersionName = providers.gradleProperty("openstream.versionName") .orElse(providers.environmentVariable("OPENSTREAM_VERSION_NAME")) - .orElse("2.1.1-beta") + .orElse("2.0.0-beta") .map { it.removePrefix("v") } val openStreamVersionCode = providers.gradleProperty("openstream.versionCode") .orElse(providers.environmentVariable("OPENSTREAM_VERSION_CODE")) - .orElse("24") + .orElse("21") .map { it.toInt() } android { diff --git a/android/app/src/main/AndroidManifest.xml b/android/app/src/main/AndroidManifest.xml index e460d81..2480232 100644 --- a/android/app/src/main/AndroidManifest.xml +++ b/android/app/src/main/AndroidManifest.xml @@ -7,8 +7,6 @@ - - @@ -25,7 +23,7 @@ android:name=".MainActivity" android:exported="true" android:launchMode="singleTop" - android:screenOrientation="sensorLandscape" + android:screenOrientation="portrait" android:configChanges="orientation|screenSize"> @@ -47,10 +45,5 @@ android:exported="false" android:screenOrientation="portrait" android:configChanges="orientation|screenSize" /> - diff --git a/android/app/src/main/cpp/openstream_srt.cpp b/android/app/src/main/cpp/openstream_srt.cpp index a4134fe..010f3ee 100644 --- a/android/app/src/main/cpp/openstream_srt.cpp +++ b/android/app/src/main/cpp/openstream_srt.cpp @@ -482,7 +482,7 @@ std::optional parseSrtUrl(const std::string &url) { class NativeSender { public: - bool connect(const std::string &url, const std::string &passphrase) { + bool connect(const std::string &url) { #if OPENSTREAM_HAVE_LIBSRT disconnect(); const auto parsed = parseSrtUrl(url); @@ -514,10 +514,6 @@ class NativeSender { const int latency = parsed->latencyMs; srt_setsockopt(socket, 0, SRTO_LATENCY, &latency, sizeof latency); srt_setsockopt(socket, 0, SRTO_PEERLATENCY, &latency, sizeof latency); - if (!configureEncryption(socket, passphrase)) { - closeSocket(socket); - return false; - } addrinfo hints{}; hints.ai_family = AF_UNSPEC; @@ -547,13 +543,12 @@ class NativeSender { return true; #else (void)url; - (void)passphrase; logError("openstream_srt was built without libsrt. Rebuild with OPENSTREAM_ENABLE_LIBSRT=ON."); return false; #endif } - bool listen(const std::string &url, const std::string &passphrase) { + bool listen(const std::string &url) { #if OPENSTREAM_HAVE_LIBSRT disconnect(); const auto parsed = parseSrtUrl(url); @@ -586,10 +581,6 @@ class NativeSender { srt_setsockopt(listenerSocket, 0, SRTO_SNDTIMEO, &sendTimeoutMs, sizeof sendTimeoutMs); srt_setsockopt(listenerSocket, 0, SRTO_LATENCY, &latency, sizeof latency); srt_setsockopt(listenerSocket, 0, SRTO_PEERLATENCY, &latency, sizeof latency); - if (!configureEncryption(listenerSocket, passphrase)) { - closeListenerSocket(listenerSocket); - return false; - } sockaddr_in address{}; address.sin_family = AF_INET; @@ -623,7 +614,6 @@ class NativeSender { return true; #else (void)url; - (void)passphrase; logError("openstream_srt was built without libsrt. Rebuild with OPENSTREAM_ENABLE_LIBSRT=ON."); return false; #endif @@ -682,23 +672,6 @@ class NativeSender { private: #if OPENSTREAM_HAVE_LIBSRT - bool configureEncryption(SRTSOCKET socket, const std::string &passphrase) { - if (passphrase.empty()) return true; - if (passphrase.size() < 10 || passphrase.size() > 79) { - logError("SRT passphrase must contain 10 to 79 characters"); - return false; - } - int keyLength = 32; - if (srt_setsockopt(socket, 0, SRTO_PBKEYLEN, &keyLength, sizeof keyLength) == SRT_ERROR || - srt_setsockopt(socket, 0, SRTO_PASSPHRASE, passphrase.data(), - static_cast(passphrase.size())) == SRT_ERROR) { - __android_log_print(ANDROID_LOG_ERROR, kTag, "Could not enable SRT encryption: %s", - srt_getlasterror_str()); - return false; - } - return true; - } - SRTSOCKET currentSocket() const { std::lock_guard lock(socketMutex_); return socket_; @@ -784,17 +757,13 @@ Java_dev_openstream_app_stream_SrtNativeBridge_connect( jstring codec_mime, jint, jint, - jint, - jstring passphrase) { + jint) { const char *rawUrl = env->GetStringUTFChars(url, nullptr); const char *rawCodec = env->GetStringUTFChars(codec_mime, nullptr); const std::string urlString(rawUrl); const std::string codecString(rawCodec); - const char *rawPassphrase = passphrase ? env->GetStringUTFChars(passphrase, nullptr) : nullptr; - const std::string passphraseString = rawPassphrase ? rawPassphrase : ""; env->ReleaseStringUTFChars(url, rawUrl); env->ReleaseStringUTFChars(codec_mime, rawCodec); - if (rawPassphrase) env->ReleaseStringUTFChars(passphrase, rawPassphrase); const auto codec = parseCodec(codecString); if (!codec) { @@ -811,7 +780,7 @@ Java_dev_openstream_app_stream_SrtNativeBridge_connect( g_state.connected = false; } g_state.sender.disconnect(); - const bool connected = g_state.sender.connect(urlString, passphraseString); + const bool connected = g_state.sender.connect(urlString); { std::lock_guard lock(g_state.mediaMutex); g_state.connected = connected; @@ -830,17 +799,13 @@ Java_dev_openstream_app_stream_SrtNativeBridge_listen( jstring codec_mime, jint, jint, - jint, - jstring passphrase) { + jint) { const char *rawUrl = env->GetStringUTFChars(url, nullptr); const char *rawCodec = env->GetStringUTFChars(codec_mime, nullptr); const std::string urlString(rawUrl); const std::string codecString(rawCodec); - const char *rawPassphrase = passphrase ? env->GetStringUTFChars(passphrase, nullptr) : nullptr; - const std::string passphraseString = rawPassphrase ? rawPassphrase : ""; env->ReleaseStringUTFChars(url, rawUrl); env->ReleaseStringUTFChars(codec_mime, rawCodec); - if (rawPassphrase) env->ReleaseStringUTFChars(passphrase, rawPassphrase); const auto codec = parseCodec(codecString); if (!codec) { @@ -857,7 +822,7 @@ Java_dev_openstream_app_stream_SrtNativeBridge_listen( g_state.connected = false; } g_state.sender.disconnect(); - const bool connected = g_state.sender.listen(urlString, passphraseString); + const bool connected = g_state.sender.listen(urlString); { std::lock_guard lock(g_state.mediaMutex); g_state.connected = connected; 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 d7d334a..06abe26 100644 --- a/android/app/src/main/java/dev/openstream/app/MainActivity.kt +++ b/android/app/src/main/java/dev/openstream/app/MainActivity.kt @@ -4,27 +4,19 @@ import android.Manifest import android.animation.ObjectAnimator import android.animation.ValueAnimator import android.app.Activity -import android.content.ComponentName -import android.content.Context import android.content.Intent -import android.content.ServiceConnection import android.content.pm.PackageManager -import android.graphics.Bitmap import android.graphics.Typeface import android.net.Uri import android.os.Build import android.os.Bundle import android.os.Handler import android.os.Looper -import android.os.IBinder import android.util.Log import android.view.Gravity -import android.view.GestureDetector import android.view.MotionEvent -import android.view.PixelCopy import android.view.ScaleGestureDetector import android.view.SurfaceHolder -import android.view.Surface import android.view.SurfaceView import android.view.View import android.view.ViewGroup @@ -32,41 +24,20 @@ import android.view.WindowInsets import android.view.WindowManager import android.widget.FrameLayout import android.widget.LinearLayout -import android.widget.SeekBar import android.widget.TextView -import dev.openstream.app.camera.AuthorityMode -import dev.openstream.app.camera.CameraActor import dev.openstream.app.camera.Camera2Controller -import dev.openstream.app.camera.CameraControlResult import dev.openstream.app.camera.CameraLens -import dev.openstream.app.camera.CameraSettingsPatch -import dev.openstream.app.camera.CameraState -import dev.openstream.app.camera.ExposureMode -import dev.openstream.app.camera.FocusActionMode -import dev.openstream.app.camera.FocusMode -import dev.openstream.app.camera.StabilizationMode -import dev.openstream.app.camera.WhiteBalanceMode import dev.openstream.app.control.CameraControlServer -import dev.openstream.app.control.PairingTokenStore import dev.openstream.app.discovery.DiscoveredObsDevice import dev.openstream.app.discovery.ObsDiscoveryClient import dev.openstream.app.discovery.PhoneDiscoveryAdvertiser import dev.openstream.app.encoder.MediaCodecAudioEncoder import dev.openstream.app.encoder.MediaCodecVideoEncoder -import dev.openstream.app.monitoring.FrameGuideMode -import dev.openstream.app.monitoring.AudioLevelMeterView -import dev.openstream.app.monitoring.MonitoringOverlayView -import dev.openstream.app.monitoring.ZebraAnalyzer import dev.openstream.app.stream.ConnectionTarget import dev.openstream.app.stream.StreamConfig import dev.openstream.app.stream.SrtStreamClient -import dev.openstream.app.service.OpenStreamCameraService import dev.openstream.app.telemetry.TelemetrySampler -import dev.openstream.app.telemetry.TelemetryFormatter import dev.openstream.app.update.AppUpdater -import kotlin.math.exp -import kotlin.math.ln -import kotlin.math.roundToInt class MainActivity : Activity() { @@ -81,52 +52,15 @@ class MainActivity : Activity() { private lateinit var liveDot: View private lateinit var streamInfoChip: TextView private lateinit var zoomLabel: TextView + private lateinit var btnKeepScreenOn: TextView private lateinit var btnScreenOff: TextView private lateinit var btnTorch: TextView + private lateinit var btnFlipCamera: TextView private lateinit var btnSettings: TextView private lateinit var btnStop: TextView private lateinit var screenOffOverlay: View private lateinit var identifyOverlay: TextView private lateinit var bottomControls: LinearLayout - private lateinit var tallyBadge: TextView - private lateinit var authorityBadge: TextView - private lateinit var pairingCodeText: TextView - private lateinit var hudFps: TextView - private lateinit var hudShutter: TextView - private lateinit var hudIso: TextView - private lateinit var hudWb: TextView - private lateinit var hudFocus: TextView - private lateinit var hudBattery: TextView - private lateinit var hudThermal: TextView - private lateinit var hudNetwork: TextView - private lateinit var btnFrameGuides: TextView - private lateinit var btnZebra: TextView - private lateinit var monitoringOverlay: MonitoringOverlayView - private lateinit var audioLevelMeter: AudioLevelMeterView - private lateinit var btnExposurePanel: TextView - private lateinit var btnFocusPanel: TextView - private lateinit var btnColorPanel: TextView - private lateinit var btnLensPanel: TextView - private lateinit var btnArmRemote: TextView - private lateinit var focusReticle: TextView - private lateinit var controlPalette: LinearLayout - private lateinit var paletteTitle: TextView - private lateinit var paletteHelp: TextView - private lateinit var paletteModeRow: LinearLayout - private lateinit var paletteModeA: TextView - private lateinit var paletteModeB: TextView - private lateinit var paletteModeC: TextView - private lateinit var paletteSlider1Label: TextView - private lateinit var paletteSlider1Value: TextView - private lateinit var paletteSlider1: SeekBar - private lateinit var paletteSlider2Label: TextView - private lateinit var paletteSlider2Value: TextView - private lateinit var paletteSlider2: SeekBar - private lateinit var paletteSlider3Label: TextView - private lateinit var paletteSlider3Value: TextView - private lateinit var paletteSlider3: SeekBar - private lateinit var paletteAction: TextView - private lateinit var paletteClose: TextView // ── Core components ── private lateinit var camera: Camera2Controller @@ -138,42 +72,6 @@ class MainActivity : Activity() { private lateinit var obsDiscoveryClient: ObsDiscoveryClient private lateinit var controlServer: CameraControlServer private lateinit var appUpdater: AppUpdater - private lateinit var pairingTokenStore: PairingTokenStore - private var cameraStateSubscription: AutoCloseable? = null - private var cameraService: OpenStreamCameraService? = null - private var cameraServiceBound = false - @Volatile private var remoteArmed = false - - private val serviceSessionOwner = object : OpenStreamCameraService.SessionOwner { - override fun onHeadlessSurfaceAvailable(surface: Surface) { - if (::camera.isInitialized) camera.setFallbackPreviewSurface(surface) - } - - override fun onRemoteStop() { - runOnUiThread { - remoteArmed = false - stopPhoneServer(clearReservation = true) - camera.setFallbackPreviewSurface(null) - } - } - } - - private val cameraServiceConnection = object : ServiceConnection { - override fun onServiceConnected(name: ComponentName?, binder: IBinder?) { - val service = (binder as? OpenStreamCameraService.LocalBinder)?.service() ?: return - cameraService = service - cameraServiceBound = true - service.attachSession(serviceSessionOwner) - remoteArmed = remoteArmed || service.isArmed() - if (remoteArmed) service.arm() - renderRemoteArmState() - } - - override fun onServiceDisconnected(name: ComponentName?) { - cameraServiceBound = false - cameraService = null - } - } private val streamConfig = StreamConfig.Default1080p60 private val mainHandler = Handler(Looper.getMainLooper()) @@ -186,14 +84,13 @@ class MainActivity : Activity() { @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 private var torchOn = false - private var currentLens: CameraLens = CameraLens.defaultBack() - private var availableLenses: List = listOf(CameraLens.defaultBack()) + private var currentLens: CameraLens = CameraLens.Back + private var availableLenses: List = listOf(CameraLens.Back) private lateinit var scaleGestureDetector: ScaleGestureDetector - private lateinit var tapGestureDetector: GestureDetector - private var activePalette: CameraPalette? = null private var zoomHideRunnable: Runnable? = null private var liveDotAnimator: ObjectAnimator? = null private var currentPort: Int = ConnectionTarget.DEFAULT_PORT @@ -202,7 +99,6 @@ class MainActivity : Activity() { private var pendingConnectAfterSettings = false private var currentDevices: List = emptyList() private var activeStreamBitrate: Int = streamConfig.bitrate - private var uiState: OpenStreamUiState = OpenStreamUiState.Discovering private val statsTicker = object : Runnable { override fun run() { @@ -220,8 +116,6 @@ class MainActivity : Activity() { requestRuntimePermissions() setContentView(R.layout.activity_main) bindViews() - // Camera operation should not be interrupted by the device sleep timer. - window.addFlags(WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON) setupGestureDetector() currentPort = getSharedPreferences(SettingsActivity.PREFS_NAME, MODE_PRIVATE) @@ -233,7 +127,6 @@ class MainActivity : Activity() { telemetry = TelemetrySampler(this) appUpdater = AppUpdater(this) appUpdater.register() - pairingTokenStore = PairingTokenStore(this) phoneAdvertiser = PhoneDiscoveryAdvertiser( context = this, config = streamConfig, @@ -246,9 +139,6 @@ class MainActivity : Activity() { onDevicesChanged = { devices -> currentDevices = devices renderObsSlots(devices) - if (reservedBy == null && activeTargetName == null) { - renderUiState(OpenStreamUiState.Discovering) - } }, ) encoder = createVideoEncoder(activeStreamBitrate) @@ -260,18 +150,13 @@ class MainActivity : Activity() { onEncodedAccessUnit = { accessUnit -> streamClient.sendAudioAccessUnit(accessUnit) }, - onAudioLevel = { level -> runOnUiThread { audioLevelMeter.setLevel(level) } }, ) camera = Camera2Controller( context = this, previewSurfaceProvider = { cameraPreview.holder.surface }, lensProvider = { currentLens }, ) - cameraStateSubscription = camera.addStateListener { state -> - runOnUiThread { renderProfessionalState(state) } - } controlServer = CameraControlServer( - pairingTokenStore = pairingTokenStore, cameraProvider = { camera }, lensListProvider = { availableLenses }, currentLensProvider = { currentLens }, @@ -293,19 +178,10 @@ class MainActivity : Activity() { }, onRelease = { sourceInstanceId -> releaseForSource(sourceInstanceId) }, onIdentify = { label, subtitle -> runOnUiThread { showIdentifyOverlay(label, subtitle) } }, - onPaired = { runOnUiThread { - armForRemoteOperation() - refreshPairingBanner() - stopPhoneServer(clearReservation = false, updateStatus = false) - startPhoneServerIfAllowed() - } }, ) - bindCameraService() - refreshPairingBanner() cameraPreview.holder.addCallback(object : SurfaceHolder.Callback { override fun surfaceCreated(holder: SurfaceHolder) { - camera.useFallbackPreviewSurface(false) initializeLenses() startPreviewIfAllowed() startPhoneServerIfAllowed() @@ -317,12 +193,8 @@ class MainActivity : Activity() { override fun surfaceDestroyed(holder: SurfaceHolder) { // Close the camera before encoder teardown tries to rebuild a // preview-only session against this now-invalid surface. - if (remoteArmed) { - camera.useFallbackPreviewSurface(true) - } else { - camera.stop() - stopPhoneServer(clearReservation = false, updateStatus = false) - } + camera.stop() + stopPhoneServer(clearReservation = false, updateStatus = false) } }) @@ -341,8 +213,6 @@ class MainActivity : Activity() { controlServer.start() startPreviewIfAllowed() startPhoneServerIfAllowed() - mainHandler.removeCallbacks(monitoringTicker) - mainHandler.post(monitoringTicker) } override fun onResume() { @@ -362,33 +232,20 @@ class MainActivity : Activity() { override fun onStop() { activityStarted = false - mainHandler.removeCallbacks(monitoringTicker) cancelLensRestart() - if (remoteArmed) { - camera.useFallbackPreviewSurface(true) - obsDiscoveryClient.stop() - } else { - camera.stop() - stopPhoneServer(clearReservation = false, updateStatus = false) - obsDiscoveryClient.stop() - phoneAdvertiser.stop() - controlServer.stop() - stopLiveDotAnimation() - } + camera.stop() + stopPhoneServer(clearReservation = false, updateStatus = false) + obsDiscoveryClient.stop() + phoneAdvertiser.stop() + controlServer.stop() + stopLiveDotAnimation() super.onStop() } override fun onDestroy() { - if (!remoteArmed) clearReservation() + clearReservation() mainHandler.removeCallbacksAndMessages(null) - cameraStateSubscription?.close() - cameraStateSubscription = null appUpdater.dispose() - if (cameraServiceBound) { - if (!remoteArmed) cameraService?.detachSession(serviceSessionOwner) - unbindService(cameraServiceConnection) - cameraServiceBound = false - } super.onDestroy() } @@ -407,14 +264,6 @@ class MainActivity : Activity() { } } - private val monitoringTicker = object : Runnable { - override fun run() { - renderDeviceTelemetry() - if (monitoringOverlay.zebraEnabled) samplePreviewForZebras() - if (activityStarted) mainHandler.postDelayed(this, MONITORING_INTERVAL_MS) - } - } - @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) @@ -449,144 +298,35 @@ class MainActivity : Activity() { liveDot = findViewById(R.id.liveDot) streamInfoChip = findViewById(R.id.streamInfoChip) zoomLabel = findViewById(R.id.zoomLabel) + btnKeepScreenOn = findViewById(R.id.btnKeepScreenOn) btnScreenOff = findViewById(R.id.btnScreenOff) btnTorch = findViewById(R.id.btnTorch) + btnFlipCamera = findViewById(R.id.btnFlipCamera) btnSettings = findViewById(R.id.btnSettings) btnStop = findViewById(R.id.btnStop) screenOffOverlay = findViewById(R.id.screenOffOverlay) identifyOverlay = findViewById(R.id.identifyOverlay) bottomControls = findViewById(R.id.bottomControls) - tallyBadge = findViewById(R.id.tallyBadge) - authorityBadge = findViewById(R.id.authorityBadge) - pairingCodeText = findViewById(R.id.pairingCodeText) - hudFps = findViewById(R.id.hudFps) - hudShutter = findViewById(R.id.hudShutter) - hudIso = findViewById(R.id.hudIso) - hudWb = findViewById(R.id.hudWb) - hudFocus = findViewById(R.id.hudFocus) - hudBattery = findViewById(R.id.hudBattery) - hudThermal = findViewById(R.id.hudThermal) - hudNetwork = findViewById(R.id.hudNetwork) - btnFrameGuides = findViewById(R.id.btnFrameGuides) - btnZebra = findViewById(R.id.btnZebra) - monitoringOverlay = findViewById(R.id.monitoringOverlay) - audioLevelMeter = findViewById(R.id.audioLevelMeter) - btnExposurePanel = findViewById(R.id.btnExposurePanel) - btnFocusPanel = findViewById(R.id.btnFocusPanel) - btnColorPanel = findViewById(R.id.btnColorPanel) - btnLensPanel = findViewById(R.id.btnLensPanel) - btnArmRemote = findViewById(R.id.btnArmRemote) - focusReticle = findViewById(R.id.focusReticle) - controlPalette = findViewById(R.id.controlPalette) - paletteTitle = findViewById(R.id.paletteTitle) - paletteHelp = findViewById(R.id.paletteHelp) - paletteModeRow = findViewById(R.id.paletteModeRow) - paletteModeA = findViewById(R.id.paletteModeA) - paletteModeB = findViewById(R.id.paletteModeB) - paletteModeC = findViewById(R.id.paletteModeC) - paletteSlider1Label = findViewById(R.id.paletteSlider1Label) - paletteSlider1Value = findViewById(R.id.paletteSlider1Value) - paletteSlider1 = findViewById(R.id.paletteSlider1) - paletteSlider2Label = findViewById(R.id.paletteSlider2Label) - paletteSlider2Value = findViewById(R.id.paletteSlider2Value) - paletteSlider2 = findViewById(R.id.paletteSlider2) - paletteSlider3Label = findViewById(R.id.paletteSlider3Label) - paletteSlider3Value = findViewById(R.id.paletteSlider3Value) - paletteSlider3 = findViewById(R.id.paletteSlider3) - paletteAction = findViewById(R.id.paletteAction) - paletteClose = findViewById(R.id.paletteClose) } private fun setupButtons() { + btnKeepScreenOn.setOnClickListener { toggleKeepScreenOn() } btnScreenOff.setOnClickListener { toggleDisplayOff() } btnTorch.setOnClickListener { toggleTorch() } + btnFlipCamera.setOnClickListener { flipCamera() } btnSettings.setOnClickListener { val intent = Intent(this, SettingsActivity::class.java) @Suppress("DEPRECATION") startActivityForResult(intent, SETTINGS_REQUEST_CODE) } btnStop.setOnClickListener { - stopPhoneServer(clearReservation = true) - disarmRemoteOperation() + stopPhoneServer(clearReservation = false) startPreviewIfAllowed() - renderUiState(OpenStreamUiState.Stopped) - } - - btnExposurePanel.setOnClickListener { showCameraPalette(CameraPalette.Exposure) } - btnFocusPanel.setOnClickListener { showCameraPalette(CameraPalette.Focus) } - btnColorPanel.setOnClickListener { showCameraPalette(CameraPalette.Color) } - btnLensPanel.setOnClickListener { showCameraPalette(CameraPalette.Lens) } - hudFps.setOnClickListener { showCameraPalette(CameraPalette.Exposure) } - hudShutter.setOnClickListener { showCameraPalette(CameraPalette.Exposure) } - hudIso.setOnClickListener { showCameraPalette(CameraPalette.Exposure) } - hudWb.setOnClickListener { showCameraPalette(CameraPalette.Color) } - hudFocus.setOnClickListener { showCameraPalette(CameraPalette.Focus) } - btnFrameGuides.setOnClickListener { cycleFrameGuides() } - btnZebra.setOnClickListener { toggleZebras() } - paletteClose.setOnClickListener { hideCameraPalette() } - btnArmRemote.setOnClickListener { - if (remoteArmed) disarmRemoteOperation() else armForRemoteOperation() - renderRemoteArmState() - } - btnScreenOff.setOnClickListener { - if (!remoteArmed) armForRemoteOperation() - toggleDisplayOff() - renderRemoteArmState() + startPhoneServerIfAllowed() } // Tap the screen-off overlay to re-enable display screenOffOverlay.setOnClickListener { toggleDisplayOff() } - renderRemoteArmState() - restoreMonitoringPreferences() - } - - private fun restoreMonitoringPreferences() { - val preferences = getSharedPreferences(MONITORING_PREFS, MODE_PRIVATE) - monitoringOverlay.frameGuideMode = runCatching { - FrameGuideMode.valueOf( - preferences.getString(KEY_FRAME_GUIDES, FrameGuideMode.Thirds.name) - ?: FrameGuideMode.Thirds.name, - ) - }.getOrDefault(FrameGuideMode.Thirds) - monitoringOverlay.zebraEnabled = preferences.getBoolean(KEY_ZEBRA_ENABLED, false) - renderMonitoringControls() - } - - private fun cycleFrameGuides() { - monitoringOverlay.frameGuideMode = when (monitoringOverlay.frameGuideMode) { - FrameGuideMode.Off -> FrameGuideMode.Thirds - FrameGuideMode.Thirds -> FrameGuideMode.SafeArea - FrameGuideMode.SafeArea -> FrameGuideMode.Off - } - getSharedPreferences(MONITORING_PREFS, MODE_PRIVATE).edit() - .putString(KEY_FRAME_GUIDES, monitoringOverlay.frameGuideMode.name) - .apply() - renderMonitoringControls() - } - - private fun toggleZebras() { - monitoringOverlay.zebraEnabled = !monitoringOverlay.zebraEnabled - getSharedPreferences(MONITORING_PREFS, MODE_PRIVATE).edit() - .putBoolean(KEY_ZEBRA_ENABLED, monitoringOverlay.zebraEnabled) - .apply() - renderMonitoringControls() - if (monitoringOverlay.zebraEnabled) samplePreviewForZebras() - } - - private fun renderMonitoringControls() { - btnFrameGuides.text = when (monitoringOverlay.frameGuideMode) { - FrameGuideMode.Off -> "GUIDES OFF" - FrameGuideMode.Thirds -> "GUIDES 3×3" - FrameGuideMode.SafeArea -> "GUIDES SAFE" - } - btnFrameGuides.setTextColor(getColor( - if (monitoringOverlay.frameGuideMode == FrameGuideMode.Off) R.color.os_text_secondary - else R.color.os_accent, - )) - btnZebra.text = if (monitoringOverlay.zebraEnabled) "ZEBRA 95 ON" else "ZEBRA 95" - btnZebra.setTextColor(getColor( - if (monitoringOverlay.zebraEnabled) R.color.os_warning else R.color.os_text_secondary, - )) } private fun createVideoEncoder(bitrate: Int): MediaCodecVideoEncoder { @@ -626,437 +366,21 @@ class MainActivity : Activity() { return true } }) - tapGestureDetector = GestureDetector(this, object : GestureDetector.SimpleOnGestureListener() { - override fun onDown(event: MotionEvent): Boolean = true - - override fun onSingleTapConfirmed(event: MotionEvent): Boolean { - if (cameraPreview.width <= 0 || cameraPreview.height <= 0) return false - val x = (event.x / cameraPreview.width).coerceIn(0f, 1f) - val y = (event.y / cameraPreview.height).coerceIn(0f, 1f) - val result = camera.focusAt(x, y, FocusActionMode.Auto, actor = CameraActor.Camera) - renderFocusResult(result, event.x, event.y) - return true - } - }) + // Also handle pinch on the preview surface itself cameraPreview.setOnTouchListener { _, event -> scaleGestureDetector.onTouchEvent(event) - if (!scaleGestureDetector.isInProgress) tapGestureDetector.onTouchEvent(event) true } } // ─────────────────────────── Lens switching ─────────────────────────── - private fun refreshPairingBanner() { - val pairedName = pairingTokenStore.pairedSourceName() - pairingCodeText.text = if (pairedName.isNullOrBlank()) { - "PAIR ${pairingTokenStore.currentPairingCode()}" - } else { - "PAIRED · $pairedName" - } - } - - private fun renderProfessionalState(state: CameraState) { - val settings = state.settings - val telemetry = state.telemetry - hudFps.text = "FPS ${settings.fps ?: streamConfig.fps}" - hudShutter.text = "SHUTTER ${telemetry.actualShutterNs?.let(::formatShutter) ?: settings.shutterNs?.let(::formatShutter) ?: "AUTO"}" - hudIso.text = "ISO ${telemetry.actualIso ?: settings.iso ?: "AUTO"}" - hudWb.text = if (settings.whiteBalanceMode == WhiteBalanceMode.Manual) { - "WB ${settings.whiteBalanceKelvin ?: telemetry.actualWhiteBalanceKelvin ?: "MANUAL"}K" - } else { - "WB ${settings.whiteBalanceMode.wireValue.uppercase()}" - } - hudFocus.text = when (settings.focusMode) { - FocusMode.Continuous -> "AF-C" - FocusMode.Single -> "AF-S" - FocusMode.Manual -> "MF" - } - - tallyBadge.visibility = if (state.tally.program || state.tally.preview) View.VISIBLE else View.GONE - if (state.tally.program) { - tallyBadge.text = "PROGRAM" - tallyBadge.setTextColor(getColor(R.color.os_live_red)) - } else if (state.tally.preview) { - tallyBadge.text = "PREVIEW" - tallyBadge.setTextColor(getColor(R.color.os_success)) - } - - val locked = state.authority == AuthorityMode.ObsLock - authorityBadge.text = if (locked) "OBS CONTROL" else "COLLABORATIVE" - authorityBadge.setTextColor(getColor(if (locked) R.color.os_warning else R.color.os_accent)) - listOf(btnExposurePanel, btnFocusPanel, btnColorPanel, btnLensPanel).forEach { - it.alpha = if (locked) 0.45f else 1f - it.isEnabled = !locked - } - if (locked && activePalette != null) hideCameraPalette() - renderRemoteArmState() - } - - private fun showCameraPalette(palette: CameraPalette) { - if (camera.currentState().authority == AuthorityMode.ObsLock) { - statusDetail.setText(R.string.control_locked_by_obs) - return - } - if (camera.currentCapabilities() == null) return - activePalette = palette - resetPalette() - controlPalette.visibility = View.VISIBLE - controlPalette.bringToFront() - when (palette) { - CameraPalette.Exposure -> configureExposurePalette() - CameraPalette.Focus -> configureFocusPalette() - CameraPalette.Color -> configureColorPalette() - CameraPalette.Lens -> configureLensPalette() - } - } - - private fun resetPalette() { - listOf(paletteModeA, paletteModeB, paletteModeC).forEach { - it.visibility = View.VISIBLE - it.isEnabled = true - it.alpha = 1f - it.isSelected = false - it.setOnClickListener(null) - } - listOf( - paletteSlider1Label, paletteSlider1Value, paletteSlider1, - paletteSlider2Label, paletteSlider2Value, paletteSlider2, - paletteSlider3Label, paletteSlider3Value, paletteSlider3, - ).forEach { it.visibility = View.VISIBLE } - lensSelectorRow.visibility = View.GONE - paletteAction.visibility = View.VISIBLE - paletteAction.setOnClickListener(null) - } - - private fun setupModes(first: String, second: String, third: String) { - paletteModeA.text = first - paletteModeB.text = second - paletteModeC.text = third - } - - private fun selectMode(index: Int) { - listOf(paletteModeA, paletteModeB, paletteModeC).forEachIndexed { position, view -> - view.isSelected = position == index - view.setTextColor(getColor(if (position == index) R.color.os_black else R.color.os_text_primary)) - } - } - - private fun hideCameraPalette() { - activePalette = null - controlPalette.visibility = View.GONE - } - - private fun configureExposurePalette() { - val state = camera.currentState() - val capabilities = camera.currentCapabilities() ?: return - paletteTitle.text = "Exposure" - paletteHelp.text = if (capabilities.manualSensor) { - "Auto adapts continuously. Manual holds ISO and shutter for a repeatable broadcast image." - } else { - "This camera exposes automatically. Compensation is available when supported." - } - setupModes("Auto", "Manual", "Reset EV") - selectMode(if (state.settings.exposureMode == ExposureMode.Auto) 0 else 1) - paletteModeA.setOnClickListener { - applyLocalSettings(CameraSettingsPatch(exposureMode = ExposureMode.Auto)) - selectMode(0) - } - paletteModeB.isEnabled = capabilities.manualSensor - paletteModeB.alpha = if (capabilities.manualSensor) 1f else 0.35f - paletteModeB.setOnClickListener { - applyLocalSettings( - CameraSettingsPatch( - exposureMode = ExposureMode.Manual, - iso = state.telemetry.actualIso ?: capabilities.isoRange?.min, - shutterNs = state.telemetry.actualShutterNs ?: capabilities.shutterRangeNs?.min, - ), - ) - showCameraPalette(CameraPalette.Exposure) - } - paletteModeC.setOnClickListener { - applyLocalSettings(CameraSettingsPatch(exposureCompensation = 0)) - showCameraPalette(CameraPalette.Exposure) - } - - capabilities.isoRange?.takeIf { capabilities.manualSensor }?.let { range -> - configureLinearSlider( - paletteSlider1Label, paletteSlider1Value, paletteSlider1, - "ISO", range.min.toDouble(), range.max.toDouble(), - (state.settings.iso ?: state.telemetry.actualIso ?: range.min).toDouble(), - { it.roundToInt().toString() }, - ) { applyLocalSettings(CameraSettingsPatch(exposureMode = ExposureMode.Manual, iso = it.roundToInt())) } - } ?: hideSlider(paletteSlider1Label, paletteSlider1Value, paletteSlider1) - - capabilities.shutterRangeNs?.takeIf { capabilities.manualSensor }?.let { range -> - configureLogSlider( - paletteSlider2Label, paletteSlider2Value, paletteSlider2, - "Shutter", range.min.toDouble(), range.max.toDouble(), - (state.settings.shutterNs ?: state.telemetry.actualShutterNs ?: range.min).toDouble(), - { formatShutter(it.toLong()) }, - ) { applyLocalSettings(CameraSettingsPatch(exposureMode = ExposureMode.Manual, shutterNs = it.toLong())) } - } ?: hideSlider(paletteSlider2Label, paletteSlider2Value, paletteSlider2) - - capabilities.exposureCompensationRange?.let { range -> - configureLinearSlider( - paletteSlider3Label, paletteSlider3Value, paletteSlider3, - "Exposure compensation", range.min.toDouble(), range.max.toDouble(), - state.settings.exposureCompensation.toDouble(), - { String.format("%+.0f", it) }, - ) { applyLocalSettings(CameraSettingsPatch(exposureCompensation = it.roundToInt())) } - } ?: hideSlider(paletteSlider3Label, paletteSlider3Value, paletteSlider3) - paletteAction.visibility = View.GONE - } - - private fun configureFocusPalette() { - val state = camera.currentState() - val capabilities = camera.currentCapabilities() ?: return - paletteTitle.text = "Focus" - paletteHelp.text = if (capabilities.supportsTapFocus) { - "Tap anywhere on the picture to focus there. Choose manual focus for a fixed plane." - } else { - getString(R.string.tap_focus_unavailable) - } - setupModes("AF-C", "AF-S", "Manual") - selectMode(when (state.settings.focusMode) { - FocusMode.Continuous -> 0 - FocusMode.Single -> 1 - FocusMode.Manual -> 2 - }) - paletteModeA.isEnabled = FocusMode.Continuous in capabilities.focusModes - paletteModeA.setOnClickListener { applyLocalSettings(CameraSettingsPatch(focusMode = FocusMode.Continuous)); selectMode(0) } - paletteModeB.isEnabled = FocusMode.Single in capabilities.focusModes - paletteModeB.setOnClickListener { applyLocalSettings(CameraSettingsPatch(focusMode = FocusMode.Single)); selectMode(1) } - paletteModeC.isEnabled = FocusMode.Manual in capabilities.focusModes - paletteModeC.setOnClickListener { applyLocalSettings(CameraSettingsPatch(focusMode = FocusMode.Manual)); showCameraPalette(CameraPalette.Focus) } - capabilities.focusDistanceRange?.let { range -> - configureLinearSlider( - paletteSlider1Label, paletteSlider1Value, paletteSlider1, - "Focus distance", range.min.toDouble(), range.max.toDouble(), - (state.settings.focusDistanceDiopters ?: state.telemetry.actualFocusDistanceDiopters ?: range.min).toDouble(), - { if (it < 0.05) "∞" else String.format("%.2f D", it) }, - ) { applyLocalSettings(CameraSettingsPatch(focusMode = FocusMode.Manual, focusDistanceDiopters = it.toFloat())) } - } ?: hideSlider(paletteSlider1Label, paletteSlider1Value, paletteSlider1) - hideSlider(paletteSlider2Label, paletteSlider2Value, paletteSlider2) - hideSlider(paletteSlider3Label, paletteSlider3Value, paletteSlider3) - paletteAction.visibility = if (capabilities.supportsTapFocus) View.VISIBLE else View.GONE - paletteAction.text = "Focus center" - paletteAction.setOnClickListener { - renderFocusResult( - camera.focusAt(0.5f, 0.5f, FocusActionMode.Auto, actor = CameraActor.Camera), - cameraPreview.width / 2f, - cameraPreview.height / 2f, - ) - } - } - - private fun configureColorPalette() { - val state = camera.currentState() - val capabilities = camera.currentCapabilities() ?: return - paletteTitle.text = "Color" - paletteHelp.text = "Use Auto for changing light, a preset for daylight, or Manual for a matched multi-camera look." - setupModes("Auto", "Daylight", "Manual") - selectMode(when (state.settings.whiteBalanceMode) { - WhiteBalanceMode.Auto -> 0 - WhiteBalanceMode.Manual -> 2 - else -> 1 - }) - paletteModeA.setOnClickListener { applyLocalSettings(CameraSettingsPatch(whiteBalanceMode = WhiteBalanceMode.Auto)); selectMode(0) } - paletteModeB.setOnClickListener { applyLocalSettings(CameraSettingsPatch(whiteBalanceMode = WhiteBalanceMode.Daylight)); selectMode(1) } - paletteModeC.isEnabled = capabilities.manualWhiteBalance - paletteModeC.alpha = if (capabilities.manualWhiteBalance) 1f else 0.35f - paletteModeC.setOnClickListener { - applyLocalSettings(CameraSettingsPatch(whiteBalanceMode = WhiteBalanceMode.Manual, whiteBalanceKelvin = state.settings.whiteBalanceKelvin ?: 5600)) - showCameraPalette(CameraPalette.Color) - } - if (capabilities.manualWhiteBalance) { - configureLinearSlider( - paletteSlider1Label, paletteSlider1Value, paletteSlider1, - "Color temperature", 2000.0, 12000.0, - (state.settings.whiteBalanceKelvin ?: 5600).toDouble(), - { "${it.roundToInt()} K" }, - ) { applyLocalSettings(CameraSettingsPatch(whiteBalanceMode = WhiteBalanceMode.Manual, whiteBalanceKelvin = it.roundToInt())) } - configureLinearSlider( - paletteSlider2Label, paletteSlider2Value, paletteSlider2, - "Tint", -100.0, 100.0, state.settings.whiteBalanceTint.toDouble(), - { String.format("%+.0f", it) }, - ) { applyLocalSettings(CameraSettingsPatch(whiteBalanceMode = WhiteBalanceMode.Manual, whiteBalanceTint = it.roundToInt())) } - } else { - hideSlider(paletteSlider1Label, paletteSlider1Value, paletteSlider1) - hideSlider(paletteSlider2Label, paletteSlider2Value, paletteSlider2) - } - hideSlider(paletteSlider3Label, paletteSlider3Value, paletteSlider3) - paletteAction.visibility = if (capabilities.supportsAwbLock) View.VISIBLE else View.GONE - paletteAction.text = if (state.settings.whiteBalanceLock) "Unlock white balance" else "Lock white balance" - paletteAction.setOnClickListener { - applyLocalSettings(CameraSettingsPatch(whiteBalanceLock = !camera.currentState().settings.whiteBalanceLock)) - showCameraPalette(CameraPalette.Color) - } - } - - private fun configureLensPalette() { - val state = camera.currentState() - val capabilities = camera.currentCapabilities() ?: return - paletteTitle.text = "Lens & stabilization" - paletteHelp.text = "Choose a physical lens, frame with zoom, then select stabilization supported by this camera." - lensSelectorRow.visibility = View.VISIBLE - buildLensButtons() - setupModes("Off", "EIS", "OIS") - selectMode(when (state.settings.stabilizationMode) { - StabilizationMode.Off -> 0 - StabilizationMode.Video -> 1 - StabilizationMode.Optical -> 2 - }) - paletteModeA.setOnClickListener { applyLocalSettings(CameraSettingsPatch(stabilizationMode = StabilizationMode.Off)); selectMode(0) } - paletteModeB.isEnabled = StabilizationMode.Video in capabilities.stabilizationModes - paletteModeB.alpha = if (paletteModeB.isEnabled) 1f else 0.35f - paletteModeB.setOnClickListener { applyLocalSettings(CameraSettingsPatch(stabilizationMode = StabilizationMode.Video)); selectMode(1) } - paletteModeC.isEnabled = StabilizationMode.Optical in capabilities.stabilizationModes - paletteModeC.alpha = if (paletteModeC.isEnabled) 1f else 0.35f - paletteModeC.setOnClickListener { applyLocalSettings(CameraSettingsPatch(stabilizationMode = StabilizationMode.Optical)); selectMode(2) } - configureLinearSlider( - paletteSlider1Label, paletteSlider1Value, paletteSlider1, - "Zoom", capabilities.zoomRange.min.toDouble(), capabilities.zoomRange.max.toDouble(), - state.settings.zoomRatio.toDouble(), { String.format("%.1f×", it) }, - ) { applyLocalSettings(CameraSettingsPatch(zoomRatio = it.toFloat())) } - hideSlider(paletteSlider2Label, paletteSlider2Value, paletteSlider2) - hideSlider(paletteSlider3Label, paletteSlider3Value, paletteSlider3) - paletteAction.visibility = if (capabilities.supportsTorch) View.VISIBLE else View.GONE - paletteAction.text = if (state.settings.torch) "Turn torch off" else "Turn torch on" - paletteAction.setOnClickListener { - applyLocalSettings(CameraSettingsPatch(torch = !camera.currentState().settings.torch)) - showCameraPalette(CameraPalette.Lens) - } - } - - private fun hideSlider(label: TextView, value: TextView, slider: SeekBar) { - label.visibility = View.GONE - value.visibility = View.GONE - slider.visibility = View.GONE - slider.setOnSeekBarChangeListener(null) - } - - private fun configureLinearSlider( - label: TextView, - valueView: TextView, - slider: SeekBar, - labelText: String, - min: Double, - max: Double, - current: Double, - formatter: (Double) -> String, - onCommit: (Double) -> Unit, - ) { - val safeCurrent = current.coerceIn(min, max) - configureSlider(label, valueView, slider, labelText, safeCurrent, formatter, onCommit) { progress -> - min + (max - min) * progress / SLIDER_STEPS - } - slider.progress = (((safeCurrent - min) / (max - min).coerceAtLeast(0.000001)) * SLIDER_STEPS).roundToInt() - } - - private fun configureLogSlider( - label: TextView, - valueView: TextView, - slider: SeekBar, - labelText: String, - min: Double, - max: Double, - current: Double, - formatter: (Double) -> String, - onCommit: (Double) -> Unit, - ) { - val logMin = ln(min.coerceAtLeast(1.0)) - val logMax = ln(max.coerceAtLeast(min + 1.0)) - val safeCurrent = current.coerceIn(min, max) - configureSlider(label, valueView, slider, labelText, safeCurrent, formatter, onCommit) { progress -> - exp(logMin + (logMax - logMin) * progress / SLIDER_STEPS) - } - val logCurrent = ln(safeCurrent.coerceAtLeast(1.0)) - slider.progress = (((logCurrent - logMin) / (logMax - logMin).coerceAtLeast(0.000001)) * SLIDER_STEPS).roundToInt() - } - - private fun configureSlider( - label: TextView, - valueView: TextView, - slider: SeekBar, - labelText: String, - current: Double, - formatter: (Double) -> String, - onCommit: (Double) -> Unit, - fromProgress: (Int) -> Double, - ) { - label.visibility = View.VISIBLE - valueView.visibility = View.VISIBLE - slider.visibility = View.VISIBLE - label.text = labelText - valueView.text = formatter(current) - slider.max = SLIDER_STEPS - slider.setOnSeekBarChangeListener(object : SeekBar.OnSeekBarChangeListener { - override fun onProgressChanged(seekBar: SeekBar?, progress: Int, fromUser: Boolean) { - if (fromUser) valueView.text = formatter(fromProgress(progress)) - } - - override fun onStartTrackingTouch(seekBar: SeekBar?) = Unit - - override fun onStopTrackingTouch(seekBar: SeekBar?) { - onCommit(fromProgress(slider.progress)) - } - }) - } - - private fun applyLocalSettings(patch: CameraSettingsPatch) { - statusDetail.text = when (val result = camera.applySettings(patch, actor = CameraActor.Camera)) { - is CameraControlResult.Applied -> "Camera setting applied" - is CameraControlResult.Conflict -> "Setting changed elsewhere. Latest camera state loaded." - is CameraControlResult.Unsupported -> result.reason - is CameraControlResult.Invalid -> result.reason - is CameraControlResult.Locked -> getString(R.string.control_locked_by_obs) - } - } - - private fun renderFocusResult(result: CameraControlResult, x: Float, y: Float) { - when (result) { - is CameraControlResult.Applied -> { - focusReticle.x = (x - focusReticle.width / 2f).coerceIn( - 0f, - (previewContainer.width - focusReticle.width).coerceAtLeast(0).toFloat(), - ) - focusReticle.y = (y - focusReticle.height / 2f).coerceIn( - 0f, - (previewContainer.height - focusReticle.height).coerceAtLeast(0).toFloat(), - ) - focusReticle.visibility = View.VISIBLE - focusReticle.bringToFront() - mainHandler.postDelayed({ focusReticle.visibility = View.GONE }, FOCUS_RETICLE_MS) - } - is CameraControlResult.Unsupported -> statusDetail.text = result.reason - is CameraControlResult.Invalid -> statusDetail.text = result.reason - is CameraControlResult.Locked -> statusDetail.setText(R.string.control_locked_by_obs) - is CameraControlResult.Conflict -> statusDetail.text = "Focus conflicted with a newer control change" - } - } - - private fun renderRemoteArmState() { - btnArmRemote.text = getString(if (remoteArmed) R.string.remote_armed else R.string.remote_disarmed) - btnArmRemote.isSelected = remoteArmed - btnArmRemote.setTextColor(getColor(if (remoteArmed) R.color.os_black else R.color.os_text_primary)) - btnStop.visibility = if (remoteArmed || uiState is OpenStreamUiState.Live) View.VISIBLE else View.GONE - } - - private fun formatShutter(shutterNs: Long): String { - if (shutterNs <= 0L) return "AUTO" - val seconds = shutterNs / 1_000_000_000.0 - return if (seconds >= 1.0) String.format("%.1fs", seconds) else "1/${(1.0 / seconds).roundToInt()}" - } - private fun initializeLenses() { if (checkSelfPermission(Manifest.permission.CAMERA) != PackageManager.PERMISSION_GRANTED) return availableLenses = camera.availableLenses() if (currentLens !in availableLenses) { - currentLens = availableLenses.firstOrNull { it.isBackFacing && it.shortLabel == "1×" } - ?: availableLenses.firstOrNull { it.isBackFacing } - ?: availableLenses.first() + currentLens = availableLenses.firstOrNull { it.isBackFacing } ?: availableLenses.first() } buildLensButtons() } @@ -1065,22 +389,14 @@ class MainActivity : Activity() { lensSelectorRow.removeAllViews() for (lens in availableLenses) { val btn = TextView(this).apply { - text = lens.displayName - textSize = 13f + text = lens.shortLabel + textSize = 14f typeface = Typeface.create("sans-serif-medium", Typeface.NORMAL) gravity = Gravity.CENTER - layoutParams = LinearLayout.LayoutParams( - ViewGroup.LayoutParams.WRAP_CONTENT, - resources.getDimensionPixelSize(R.dimen.os_lens_btn_size), - ).apply { + val size = resources.getDimensionPixelSize(R.dimen.os_lens_btn_size) + layoutParams = LinearLayout.LayoutParams(size, size).apply { marginEnd = resources.getDimensionPixelSize(R.dimen.os_spacing_sm) } - setPadding( - resources.getDimensionPixelSize(R.dimen.os_spacing_md), - 0, - resources.getDimensionPixelSize(R.dimen.os_spacing_md), - 0, - ) setBackgroundResource(R.drawable.bg_lens_selector) isSelected = (lens == currentLens) setTextColor( @@ -1120,7 +436,8 @@ class MainActivity : Activity() { camera.startStreaming(encoder.inputSurface()) }.onFailure { e -> Log.e("OpenStream", "Failed to restart encoder after lens switch", e) - renderUiState(OpenStreamUiState.Error(e.message ?: getString(R.string.error_unknown))) + statusText.text = "Encoder error" + statusDetail.text = e.message ?: "Unknown" } } lensRestartRunnable = restart @@ -1129,6 +446,15 @@ class MainActivity : Activity() { buildLensButtons() } + private fun flipCamera() { + val target = if (currentLens.isFrontFacing) { + availableLenses.firstOrNull { it.isBackFacing } ?: return + } else { + availableLenses.firstOrNull { it.isFrontFacing } ?: return + } + selectLens(target) + } + private fun cancelLensRestart() { lensRestartRunnable?.let(mainHandler::removeCallbacks) lensRestartRunnable = null @@ -1136,6 +462,21 @@ class MainActivity : Activity() { // ─────────────────────────── Keep screen on ─────────────────────────── + private fun toggleKeepScreenOn() { + keepScreenOn = !keepScreenOn + if (keepScreenOn) { + window.addFlags(WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON) + btnKeepScreenOn.text = "STAY ✓" + btnKeepScreenOn.setBackgroundResource(R.drawable.bg_btn_accent) + btnKeepScreenOn.setTextColor(getColor(R.color.os_black)) + } else { + window.clearFlags(WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON) + btnKeepScreenOn.text = "STAY" + btnKeepScreenOn.setTextColor(getColor(R.color.os_text_secondary)) + btnKeepScreenOn.setBackgroundResource(R.drawable.bg_btn_ghost) + } + } + // ─────────────────────────── Torch ─────────────────────────── private fun toggleTorch() { @@ -1242,7 +583,8 @@ class MainActivity : Activity() { } if (reserveForSource(device.sourceInstanceId, device.displayLabel, device.bitrateMbps)) { - renderUiState(OpenStreamUiState.Reserved(device.displayLabel)) + statusText.text = "Paired to ${device.displayLabel}" + statusDetail.text = "Waiting for OBS to go live" renderObsSlots(currentDevices) } } @@ -1266,7 +608,8 @@ class MainActivity : Activity() { val slotLabel = uri.getQueryParameter("slotLabel")?.trim().orEmpty() val bitrateMbps = uri.getQueryParameter("bitrateMbps")?.toIntOrNull()?.coerceIn(1, 200) if (reserveForSource(sourceInstanceId, slotLabel, bitrateMbps)) { - renderUiState(OpenStreamUiState.Reserved(slotLabel.ifBlank { getString(R.string.obs_slot_fallback) })) + statusText.text = "Paired to ${slotLabel.ifBlank { "OBS slot" }}" + statusDetail.text = "Waiting for OBS to go live" } return } @@ -1275,12 +618,12 @@ class MainActivity : Activity() { } private fun startStream(target: ConnectionTarget) { - armForRemoteOperation() // 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) - renderUiState(OpenStreamUiState.Connecting(target.name)) + statusText.text = "Connecting…" + statusDetail.text = "${currentLens.displayName} → ${target.name}" runCatching { streamClient.connect( url = target.toSrtCallerUrl(), @@ -1300,22 +643,19 @@ class MainActivity : Activity() { stopStream() startPreviewIfAllowed() startPhoneServerIfAllowed() - renderUiState(OpenStreamUiState.Error(error.message ?: getString(R.string.error_unknown))) + 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") - audioLevelMeter.setAudioActive(false) return } - runCatching { audioEncoder.start() } - .onSuccess { audioLevelMeter.setAudioActive(true) } - .onFailure { e -> - audioLevelMeter.setAudioActive(false) - Log.w("OpenStream", "Audio encoder start failed; continuing video-only", e) - } + runCatching { audioEncoder.start() }.onFailure { e -> + Log.w("OpenStream", "Audio encoder start failed; continuing video-only", e) + } } private fun startPhoneServerIfAllowed() { @@ -1334,7 +674,10 @@ class MainActivity : Activity() { phoneServerRunning = true phoneConnected = false activeTargetName = null - renderUiState(OpenStreamUiState.Discovering) + 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 val thread = Thread({ try { @@ -1347,7 +690,6 @@ class MainActivity : Activity() { width = streamConfig.width, height = streamConfig.height, fps = streamConfig.fps, - passphrase = pairingTokenStore.streamPassphrase(), ) if (!isListenerActive(generation)) { streamClient.disconnect() @@ -1375,7 +717,8 @@ class MainActivity : Activity() { val error = listenResult.exceptionOrNull() runOnUiThread { if (!isListenerActive(generation)) return@runOnUiThread - renderUiState(OpenStreamUiState.Error(error?.message ?: getString(R.string.error_unknown))) + statusText.text = "Listener error" + statusDetail.text = error?.message ?: "Unknown" } try { Thread.sleep(LISTENER_RETRY_MS) @@ -1392,10 +735,9 @@ class MainActivity : Activity() { runOnUiThread { if (!isListenerActive(generation)) return@runOnUiThread hideLiveState() - renderUiState( - reservedSlotLabel?.let(OpenStreamUiState::Reconnecting) - ?: OpenStreamUiState.Discovering, - ) + statusText.text = getString(R.string.status_ready) + statusDetail.text = reservedSlotLabel?.let { "Holding $it for reconnect" } + ?: getString(R.string.status_waiting) } } } @@ -1463,9 +805,9 @@ class MainActivity : Activity() { camera.stopStreaming() encoder.stop() audioEncoder.stop() - audioLevelMeter.setAudioActive(false) if (updateStatus) { - renderUiState(OpenStreamUiState.Stopped) + statusText.text = getString(R.string.status_stopped) + statusDetail.text = "Camera preview remains active" } } @@ -1479,7 +821,6 @@ class MainActivity : Activity() { ): Boolean { val currentReservation = reservedBy if (phoneConnected && currentReservation != sourceInstanceId) return false - armForRemoteOperation() cancelReservationRelease() useStreamBitrate(bitrateMbps) reservedBy = sourceInstanceId @@ -1487,35 +828,6 @@ class MainActivity : Activity() { return true } - private fun bindCameraService() { - bindService( - Intent(this, OpenStreamCameraService::class.java), - cameraServiceConnection, - Context.BIND_AUTO_CREATE, - ) - } - - private fun armForRemoteOperation() { - if (remoteArmed && cameraService?.isArmed() == true) { - renderRemoteArmState() - return - } - remoteArmed = true - val intent = OpenStreamCameraService.armIntent(this) - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) startForegroundService(intent) else startService(intent) - cameraService?.attachSession(serviceSessionOwner) - cameraService?.arm() - renderRemoteArmState() - refreshPairingBanner() - } - - private fun disarmRemoteOperation() { - remoteArmed = false - camera.setFallbackPreviewSurface(null) - cameraService?.disarm() - renderRemoteArmState() - } - @Synchronized private fun releaseForSource(sourceInstanceId: String): Boolean { if (reservedBy == sourceInstanceId) { @@ -1565,8 +877,10 @@ class MainActivity : Activity() { private fun showLiveState(targetName: String) { liveBadge.visibility = View.VISIBLE + btnStop.visibility = View.VISIBLE startLiveDotAnimation() - renderUiState(OpenStreamUiState.Live(targetName)) + 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" } @@ -1576,40 +890,6 @@ class MainActivity : Activity() { stopLiveDotAnimation() } - /** Central renderer for connection status and the state-aware primary action. */ - private fun renderUiState(next: OpenStreamUiState) { - uiState = next - statusText.setTextColor(getColor(if (next is OpenStreamUiState.Error) R.color.os_warning else R.color.os_text_primary)) - renderRemoteArmState() - when (next) { - OpenStreamUiState.Discovering -> { - statusText.setText(R.string.status_ready) - statusDetail.setText(R.string.status_waiting) - } - is OpenStreamUiState.Reserved -> { - statusText.text = getString(R.string.status_paired, next.slotLabel) - statusDetail.setText(R.string.status_waiting_for_obs) - } - is OpenStreamUiState.Connecting -> { - statusText.setText(R.string.status_connecting) - statusDetail.text = getString(R.string.status_connecting_detail, currentLens.displayName, next.targetLabel) - } - is OpenStreamUiState.Live -> statusText.text = getString(R.string.status_streaming, next.targetLabel) - is OpenStreamUiState.Reconnecting -> { - statusText.setText(R.string.status_reconnecting) - statusDetail.text = getString(R.string.status_holding_slot, next.slotLabel) - } - is OpenStreamUiState.Error -> { - statusText.setText(R.string.status_connection_issue) - statusDetail.text = next.message - } - OpenStreamUiState.Stopped -> { - statusText.setText(R.string.status_stopped) - statusDetail.setText(R.string.status_preview_active) - } - } - } - private fun startLiveDotAnimation() { stopLiveDotAnimation() liveDotAnimator = ObjectAnimator.ofFloat(liveDot, "alpha", 1f, 0.3f).apply { @@ -1656,45 +936,6 @@ class MainActivity : Activity() { ) } - private fun renderDeviceTelemetry() { - val device = telemetry.sample( - streamUrl = activeTargetName ?: "local-preview", - codec = streamConfig.codecPreference.name, - width = streamConfig.width, - height = streamConfig.height, - fps = streamConfig.fps, - bitrate = activeStreamBitrate, - ) - val hud = TelemetryFormatter.forHud(device) - hudBattery.text = hud.battery - hudThermal.text = hud.thermal - hudNetwork.text = hud.network - hudBattery.setTextColor(getColor(if (hud.isBatteryLow) R.color.os_warning else R.color.os_text_secondary)) - hudThermal.setTextColor(getColor(if (hud.isThermalWarning) R.color.os_warning else R.color.os_text_secondary)) - hudNetwork.setTextColor(getColor(if (hud.isNetworkWeak) R.color.os_warning else R.color.os_text_secondary)) - } - - private fun samplePreviewForZebras() { - if (!monitoringOverlay.zebraEnabled || !cameraPreview.holder.surface.isValid) return - val bitmap = Bitmap.createBitmap(ZEBRA_SAMPLE_WIDTH, ZEBRA_SAMPLE_HEIGHT, Bitmap.Config.ARGB_8888) - try { - PixelCopy.request(cameraPreview, bitmap, { result -> - if (result == PixelCopy.SUCCESS && monitoringOverlay.zebraEnabled) { - val pixels = IntArray(bitmap.width * bitmap.height) - bitmap.getPixels(pixels, 0, bitmap.width, 0, 0, bitmap.width, bitmap.height) - monitoringOverlay.setZebraMask( - bitmap.width, - bitmap.height, - ZebraAnalyzer.analyze(pixels, ZEBRA_THRESHOLD_PERCENT), - ) - } - bitmap.recycle() - }, mainHandler) - } catch (_: IllegalArgumentException) { - bitmap.recycle() - } - } - // ─────────────────────────── Utilities ─────────────────────────── private fun startPreviewIfAllowed() { @@ -1731,17 +972,11 @@ class MainActivity : Activity() { dialog.setContentView(R.layout.dialog_custom_update) dialog.setCancelable(true) - val title = dialog.findViewById(R.id.dialogUpdateTitle) val message = dialog.findViewById(R.id.dialogUpdateMessage) - val progress = dialog.findViewById(R.id.dialogUpdateProgress) - val progressText = dialog.findViewById(R.id.dialogUpdateProgressText) val actionBtn = dialog.findViewById(R.id.dialogUpdateAction) val dismissBtn = dialog.findViewById(R.id.dialogUpdateDismiss) - title.text = "OpenStream ready" message.text = "You are running OpenStream v$versionName.\nFuture updates can be checked from Settings." - progress.visibility = View.GONE - progressText.visibility = View.GONE actionBtn.text = "GOT IT" dismissBtn.visibility = View.GONE @@ -1796,6 +1031,10 @@ class MainActivity : Activity() { params.screenBrightness = if (originalBrightness >= 0) originalBrightness else -1f window.attributes = params screenOffOverlay.visibility = View.GONE + // Restore keep-screen-on to user's toggle state + if (!keepScreenOn) { + window.clearFlags(WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON) + } btnScreenOff.text = "DISPLAY" btnScreenOff.setBackgroundResource(R.drawable.bg_btn_ghost) btnScreenOff.setTextColor(getColor(R.color.os_text_secondary)) @@ -1855,11 +1094,6 @@ class MainActivity : Activity() { lp.height = targetHeight lp.gravity = Gravity.CENTER cameraPreview.layoutParams = lp - val overlayLp = monitoringOverlay.layoutParams as FrameLayout.LayoutParams - overlayLp.width = targetWidth - overlayLp.height = targetHeight - overlayLp.gravity = Gravity.CENTER - monitoringOverlay.layoutParams = overlayLp } // ─────────────────────────── Nav bar insets (3-button nav fix) ─────────────────────────── @@ -1893,15 +1127,6 @@ class MainActivity : Activity() { 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 SLIDER_STEPS = 1_000 - private const val FOCUS_RETICLE_MS = 1_500L - private const val MONITORING_INTERVAL_MS = 2_000L - private const val ZEBRA_SAMPLE_WIDTH = 96 - private const val ZEBRA_SAMPLE_HEIGHT = 54 - private const val ZEBRA_THRESHOLD_PERCENT = 95 - private const val MONITORING_PREFS = "openstream_monitoring" - private const val KEY_FRAME_GUIDES = "frame_guides" - private const val KEY_ZEBRA_ENABLED = "zebra_enabled" private const val APP_PREFS_NAME = "openstream_app" private const val PREF_LAST_VERSION_DIALOG = "last_version_dialog" private val REQUIRED_PERMISSIONS = arrayOf( @@ -1909,11 +1134,4 @@ class MainActivity : Activity() { Manifest.permission.RECORD_AUDIO, ) } - - private enum class CameraPalette { - Exposure, - Focus, - Color, - Lens, - } } diff --git a/android/app/src/main/java/dev/openstream/app/OpenStreamUiState.kt b/android/app/src/main/java/dev/openstream/app/OpenStreamUiState.kt deleted file mode 100644 index 2d4bb98..0000000 --- a/android/app/src/main/java/dev/openstream/app/OpenStreamUiState.kt +++ /dev/null @@ -1,12 +0,0 @@ -package dev.openstream.app - -/** A single vocabulary for the user-visible camera connection lifecycle. */ -sealed interface OpenStreamUiState { - data object Discovering : OpenStreamUiState - data class Reserved(val slotLabel: String) : OpenStreamUiState - data class Connecting(val targetLabel: String) : OpenStreamUiState - data class Live(val targetLabel: String) : OpenStreamUiState - data class Reconnecting(val slotLabel: String) : OpenStreamUiState - data class Error(val message: String, val canRetry: Boolean = true) : OpenStreamUiState - data object Stopped : OpenStreamUiState -} 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 4844be4..711aa97 100644 --- a/android/app/src/main/java/dev/openstream/app/SettingsActivity.kt +++ b/android/app/src/main/java/dev/openstream/app/SettingsActivity.kt @@ -4,7 +4,6 @@ import android.app.Activity import android.content.Intent import android.os.Build import android.os.Bundle -import android.view.View import android.view.WindowInsets import android.widget.EditText import android.widget.TextView @@ -22,8 +21,6 @@ class SettingsActivity : Activity() { private lateinit var btnSaveAndConnect: TextView private lateinit var btnBack: TextView private lateinit var btnCheckUpdates: TextView - private lateinit var btnToggleAdvanced: TextView - private lateinit var advancedSettingsPanel: View private lateinit var versionInfo: TextView private lateinit var appUpdater: AppUpdater @@ -39,8 +36,6 @@ class SettingsActivity : Activity() { btnSaveAndConnect = findViewById(R.id.btnSaveAndConnect) btnBack = findViewById(R.id.btnBackSettings) btnCheckUpdates = findViewById(R.id.btnCheckUpdates) - btnToggleAdvanced = findViewById(R.id.btnToggleAdvanced) - advancedSettingsPanel = findViewById(R.id.advancedSettingsPanel) versionInfo = findViewById(R.id.settingsVersionInfo) appUpdater = AppUpdater(this) @@ -48,7 +43,6 @@ class SettingsActivity : Activity() { loadSettings() showVersionInfo() - renderAdvancedVisibility(manualSettingsInUse()) btnSave.setOnClickListener { saveSettings(connectAfterSave = false) } btnSaveAndConnect.setOnClickListener { saveSettings(connectAfterSave = true) } @@ -56,9 +50,6 @@ class SettingsActivity : Activity() { btnCheckUpdates.setOnClickListener { appUpdater.checkForUpdates(showAlreadyCurrent = true) } - btnToggleAdvanced.setOnClickListener { - renderAdvancedVisibility(advancedSettingsPanel.visibility != View.VISIBLE) - } } override fun onResume() { @@ -86,7 +77,7 @@ class SettingsActivity : Activity() { clearValidationErrors() val host = inputObsHost.text.toString().trim() if (!SettingsValidator.isValidHost(host, required = connectAfterSave)) { - inputObsHost.error = getString(R.string.error_invalid_host) + inputObsHost.error = "Enter a valid OBS host or IP address" inputObsHost.requestFocus() return } @@ -116,7 +107,7 @@ class SettingsActivity : Activity() { .putInt(KEY_LISTENING_PORT, listenPort) .apply() - Toast.makeText(this, R.string.settings_saved, Toast.LENGTH_SHORT).show() + Toast.makeText(this, "Settings saved", Toast.LENGTH_SHORT).show() setResult( RESULT_OK, Intent().putExtra(EXTRA_CONNECT_AFTER_SAVE, connectAfterSave), @@ -161,19 +152,6 @@ class SettingsActivity : Activity() { } } - private fun manualSettingsInUse(): Boolean { - val prefs = getSharedPreferences(PREFS_NAME, MODE_PRIVATE) - return !prefs.getString(KEY_OBS_HOST, "").isNullOrBlank() || - prefs.getInt(KEY_OBS_PORT, ConnectionTarget.DEFAULT_PORT) != ConnectionTarget.DEFAULT_PORT || - prefs.getInt(KEY_LATENCY, ConnectionTarget.DEFAULT_LATENCY_MS) != ConnectionTarget.DEFAULT_LATENCY_MS || - prefs.getInt(KEY_LISTENING_PORT, ConnectionTarget.DEFAULT_PORT) != ConnectionTarget.DEFAULT_PORT - } - - private fun renderAdvancedVisibility(visible: Boolean) { - advancedSettingsPanel.visibility = if (visible) View.VISIBLE else View.GONE - btnToggleAdvanced.setText(if (visible) R.string.settings_hide_advanced else R.string.settings_show_advanced) - } - companion object { const val PREFS_NAME = "openstream_settings" const val KEY_OBS_HOST = "obs_host" diff --git a/android/app/src/main/java/dev/openstream/app/audio/AudioLevel.kt b/android/app/src/main/java/dev/openstream/app/audio/AudioLevel.kt deleted file mode 100644 index 802212d..0000000 --- a/android/app/src/main/java/dev/openstream/app/audio/AudioLevel.kt +++ /dev/null @@ -1,50 +0,0 @@ -package dev.openstream.app.audio - -import kotlin.math.log10 -import kotlin.math.max -import kotlin.math.sqrt - -data class AudioLevel( - val rmsDbfs: Float, - val peakDbfs: Float, -) { - companion object { - val Silent = AudioLevel(MIN_DBFS, MIN_DBFS) - const val MIN_DBFS = -60f - } -} - -object Pcm16AudioLevel { - fun measure(pcm: ByteArray, length: Int): AudioLevel { - val usableLength = length.coerceIn(0, pcm.size) and -2 - if (usableLength < 2) return AudioLevel.Silent - - var sumSquares = 0.0 - var peak = 0 - var sampleCount = 0 - var index = 0 - while (index < usableLength) { - val sample = (pcm[index].toInt() and 0xff) or (pcm[index + 1].toInt() shl 8) - val signedSample = sample.toShort().toInt() - val magnitude = kotlin.math.abs(signedSample) - peak = max(peak, magnitude) - val normalized = signedSample / PCM16_FULL_SCALE - sumSquares += normalized * normalized - sampleCount++ - index += 2 - } - if (sampleCount == 0) return AudioLevel.Silent - return AudioLevel( - rmsDbfs = toDbfs(sqrt(sumSquares / sampleCount)), - peakDbfs = toDbfs(peak / PCM16_FULL_SCALE), - ) - } - - private fun toDbfs(amplitude: Double): Float = if (amplitude <= 0.0) { - AudioLevel.MIN_DBFS - } else { - (20.0 * log10(amplitude)).toFloat().coerceIn(AudioLevel.MIN_DBFS, 0f) - } - - private const val PCM16_FULL_SCALE = 32768.0 -} diff --git a/android/app/src/main/java/dev/openstream/app/camera/Camera2Controller.kt b/android/app/src/main/java/dev/openstream/app/camera/Camera2Controller.kt index 32f38d4..699054f 100644 --- a/android/app/src/main/java/dev/openstream/app/camera/Camera2Controller.kt +++ b/android/app/src/main/java/dev/openstream/app/camera/Camera2Controller.kt @@ -4,36 +4,29 @@ import android.annotation.SuppressLint import android.content.Context import android.graphics.Rect import android.hardware.camera2.CameraCaptureSession -import android.hardware.camera2.CameraCharacteristics import android.hardware.camera2.CameraDevice import android.hardware.camera2.CameraManager import android.hardware.camera2.CaptureRequest -import android.hardware.camera2.CaptureResult -import android.hardware.camera2.TotalCaptureResult -import android.hardware.camera2.params.ColorSpaceTransform -import android.hardware.camera2.params.MeteringRectangle -import android.hardware.camera2.params.RggbChannelVector +import android.hardware.camera2.CameraCharacteristics import android.os.Build import android.os.Handler import android.os.HandlerThread import android.util.Log -import android.util.Range -import android.util.Rational import android.view.Surface -import kotlin.math.max -import kotlin.math.min /** - * Camera2 owner for both attended and remote operation. + * Camera2Controller manages the lifecycle of a Camera2 device and its + * capture sessions. It handles: * - * Every repeating request is derived from [CameraStateStore]. This is important: a zoom, - * torch or focus command can never silently reset exposure, white balance or stabilization. + * - Opening / closing camera devices when the selected lens changes. + * - Creating preview-only or preview+encode sessions. + * - Pinch-to-zoom via crop region or CONTROL_ZOOM_RATIO. + * - Enumerating available physical lenses. */ class Camera2Controller( private val context: Context, private val previewSurfaceProvider: () -> Surface, - private val lensProvider: () -> CameraLens = { CameraLens.defaultBack() }, - private val stateStore: CameraStateStore = CameraStateStore(), + private val lensProvider: () -> CameraLens = { CameraLens.Back }, ) { private val cameraManager = context.getSystemService(CameraManager::class.java) private val thread = HandlerThread("OpenStreamCamera") @@ -43,90 +36,94 @@ class Camera2Controller( private var streamingSurface: Surface? = null private var activeCameraId: String? = null private var activeLens: CameraLens? = null - private var pendingLensZoom: Float? = null - private var characteristics: CameraCharacteristics? = null + + // Zoom state + private var currentZoomRatio = 1.0f + private var maxZoomRatio = 1.0f + private var minZoomRatio = 1.0f private var sensorRect: Rect? = null - private var sensorOrientation: Int = 0 - private var frontFacing: Boolean = false - private var focusRegion: MeteringRectangle? = null - private var lastTelemetryPublishNs = 0L - @Volatile private var fallbackPreviewSurface: Surface? = null - @Volatile private var preferFallbackPreviewSurface = false - - val zoomRatio: Float get() = stateStore.snapshot().settings.zoomRatio - val zoomRange: ClosedFloatingPointRange - get() = stateStore.capabilities()?.zoomRange?.let { it.min..it.max } ?: 1f..1f - - fun currentState(): CameraState = stateStore.snapshot() - fun currentCapabilities(): CameraCapabilities? = stateStore.capabilities() - fun addStateListener(listener: (CameraState) -> Unit): AutoCloseable = stateStore.addListener(listener) - - fun setFallbackPreviewSurface(surface: Surface?) { - fallbackPreviewSurface = surface - } + private var supportsZoomRatioKey = false - fun useFallbackPreviewSurface(enabled: Boolean) { - preferFallbackPreviewSurface = enabled - refreshPreviewSurface() - } + // Torch state + private var torchEnabled = false + + /** Zoom value as a fraction [minZoom, maxZoom]. */ + val zoomRatio: Float get() = currentZoomRatio + val zoomRange: ClosedFloatingPointRange get() = minZoomRatio..maxZoomRatio - /** Rebuild after the Activity preview surface is created or destroyed. */ - fun refreshPreviewSurface() { - if (camera != null) createSession() + companion object { + private const val TAG = "OpenStreamCamera" } + /** + * Query available lenses on this device. + * Returns only CameraLens values that have a matching physical camera. + */ fun availableLenses(): List { val result = mutableListOf() - val back = cameraManager.cameraIdList.filter { id -> - cameraManager.getCameraCharacteristics(id).get(CameraCharacteristics.LENS_FACING) == - CameraCharacteristics.LENS_FACING_BACK - } - val front = cameraManager.cameraIdList.firstOrNull { id -> - cameraManager.getCameraCharacteristics(id).get(CameraCharacteristics.LENS_FACING) == - CameraCharacteristics.LENS_FACING_FRONT + val cameraIds = cameraManager.cameraIdList + + // Collect all back-facing cameras with their focal lengths + data class CamInfo(val id: String, val focalLength: Float, val facing: Int) + val cameras = cameraIds.mapNotNull { id -> + val chars = cameraManager.getCameraCharacteristics(id) + val facing = chars.get(CameraCharacteristics.LENS_FACING) ?: return@mapNotNull null + val focalLengths = chars.get(CameraCharacteristics.LENS_INFO_AVAILABLE_FOCAL_LENGTHS) + val focal = focalLengths?.firstOrNull() ?: 0f + CamInfo(id, focal, facing) } - val logical = back.firstOrNull { id -> - cameraManager.getCameraCharacteristics(id).physicalCameraIds.isNotEmpty() + + val backCams = cameras.filter { it.facing == CameraCharacteristics.LENS_FACING_BACK } + .sortedBy { it.focalLength } + val frontCams = cameras.filter { it.facing == CameraCharacteristics.LENS_FACING_FRONT } + + if (backCams.size >= 3) { + // Device has ultrawide, wide, telephoto + result.add(CameraLens.BackUltrawide) + result.add(CameraLens.Back) + result.add(CameraLens.BackTelephoto) + } else if (backCams.size == 2) { + // Check if the shorter focal is ultrawide or the longer is telephoto + val ratio = if (backCams[0].focalLength > 0) backCams[1].focalLength / backCams[0].focalLength else 1f + if (ratio > 1.5f) { + result.add(CameraLens.Back) + result.add(CameraLens.BackTelephoto) + } else { + result.add(CameraLens.BackUltrawide) + result.add(CameraLens.Back) + } + } else if (backCams.isNotEmpty()) { + result.add(CameraLens.Back) } - if (logical != null) { - val caps = capabilitiesFor(logical) - val physicalCandidates = cameraManager.getCameraCharacteristics(logical).physicalCameraIds - .mapNotNull(::lensCandidateFor) - result += CameraLensDiscovery.rearLenses( - logicalCameraId = logical, - candidates = physicalCandidates.ifEmpty { listOfNotNull(lensCandidateFor(logical)) }, - supportsLogicalZoomRatio = caps.supportsZoomRatio, - ) - } else { - result += CameraLensDiscovery.rearLenses( - logicalCameraId = null, - candidates = back.mapNotNull(::lensCandidateFor), - supportsLogicalZoomRatio = false, - ) + + if (frontCams.isNotEmpty()) { + result.add(CameraLens.Front) } - front?.let { result += CameraLens.selfie(it) } - return result.ifEmpty { listOf(CameraLens.defaultBack()) } + + return result.ifEmpty { listOf(CameraLens.Back) } } @SuppressLint("MissingPermission") fun startPreview() { ensureThread() - val desiredLens = activeLens ?: lensProvider() + val desiredLens = lensProvider() val desiredId = selectCameraId(desiredLens) + if (camera != null && activeCameraId == desiredId) { + // Already have the right camera open, just rebuild session createSession() return } + + // Need to open a different camera closeCamera() activeLens = desiredLens activeCameraId = desiredId - pendingLensZoom = desiredLens.targetZoom + cameraManager.openCamera(desiredId, object : CameraDevice.StateCallback() { override fun onOpened(device: CameraDevice) { camera = device - loadCamera(desiredId) - pendingLensZoom?.let(::setZoom) - pendingLensZoom = null + loadZoomCapabilities(desiredId) createSession() } @@ -142,29 +139,37 @@ class Camera2Controller( }, handler) } + /** + * Switch to a different lens. This closes the current camera and opens a new one. + * If an encoding surface is active, the new camera will resume streaming. + */ fun switchLens(lens: CameraLens) { val newId = selectCameraId(lens) - val previousId = activeCameraId + if (newId == activeCameraId) return + activeLens = lens - if (newId == previousId && camera != null) { - setZoom(lens.targetZoom) - return - } activeCameraId = newId - pendingLensZoom = lens.targetZoom - focusRegion = null + currentZoomRatio = 1.0f + torchEnabled = false + closeCamera() startPreview() } fun startStreaming(encodedSurface: Surface) { streamingSurface = encodedSurface - if (camera == null) startPreview() else createSession() + if (camera == null) { + startPreview() + } else { + createSession() + } } fun stopStreaming() { streamingSurface = null - if (camera != null) createSession() + if (camera != null) { + createSession() + } } fun stop() { @@ -172,71 +177,45 @@ class Camera2Controller( streamingSurface = null } - fun applySettings( - patch: CameraSettingsPatch, - expectedRevision: Long? = null, - actor: CameraActor = CameraActor.Camera, - ): CameraControlResult { - val result = stateStore.applySettings(expectedRevision, actor, patch) - if (result is CameraControlResult.Applied) rebuildRepeatingRequest() - return result - } - - fun focusAt( - normalizedX: Float, - normalizedY: Float, - mode: FocusActionMode = FocusActionMode.Auto, - expectedRevision: Long? = null, - actor: CameraActor = CameraActor.Camera, - ): CameraControlResult { - val result = stateStore.applyFocus(expectedRevision, actor, normalizedX, normalizedY) - if (result !is CameraControlResult.Applied) return result - val active = sensorRect ?: return CameraControlResult.Unsupported("focus", "Sensor geometry is unavailable", currentState()) - val mapped = FocusCoordinateMapper.mapToMeteringRegion( - normalizedX = normalizedX, - normalizedY = normalizedY, - activeArray = active.toSensorRect(), - cropRegion = calculateCrop(currentState().settings.zoomRatio).toSensorRect(), - rotationDegrees = sensorOrientation, - mirrored = frontFacing, - ) - focusRegion = MeteringRectangle(mapped.left, mapped.top, mapped.width, mapped.height, MeteringRectangle.METERING_WEIGHT_MAX) - triggerFocus(mode) - return result - } - - fun setAuthority( - mode: AuthorityMode, - expectedRevision: Long? = null, - actor: CameraActor = CameraActor.Obs, - ): CameraControlResult = stateStore.setAuthority(expectedRevision, actor, mode) - - fun setTally(program: Boolean, preview: Boolean): CameraState = stateStore.setTally(program, preview) - + /** + * Set the zoom ratio. Clamped to the device's supported range. + * Returns the actual zoom ratio applied. + */ fun setZoom(ratio: Float): Float { - val clamped = stateStore.capabilities()?.zoomRange?.clamp(ratio) ?: ratio.coerceAtLeast(1f) - applySettings(CameraSettingsPatch(zoomRatio = clamped)) - return currentState().settings.zoomRatio + currentZoomRatio = ratio.coerceIn(minZoomRatio, maxZoomRatio) + updateZoomInSession() + return currentZoomRatio } - fun scaleZoom(scaleFactor: Float): Float = setZoom(zoomRatio * scaleFactor) + /** + * Scale zoom by a delta factor (for pinch-to-zoom). + * Returns the new zoom ratio. + */ + fun scaleZoom(scaleFactor: Float): Float { + return setZoom(currentZoomRatio * scaleFactor) + } fun setManualExposure(iso: Int, exposureTimeNs: Long) { - applySettings( - CameraSettingsPatch( - exposureMode = ExposureMode.Manual, - iso = iso, - shutterNs = exposureTimeNs, - ), - ) + // Wired in the request builder once remote controls are added. + require(iso > 0) + require(exposureTimeNs > 0) } + /** + * Enable or disable the camera torch (flashlight). + * Only works on back-facing cameras with flash hardware. + */ fun setTorch(enabled: Boolean) { - applySettings(CameraSettingsPatch(torch = enabled)) + torchEnabled = enabled + rebuildRepeatingRequest() } + // ---- Internal ---- + private fun ensureThread() { - if (!thread.isAlive) thread.start() + if (!thread.isAlive) { + thread.start() + } handler = Handler(thread.looper) } @@ -247,405 +226,147 @@ class Camera2Controller( camera = null } - private fun loadCamera(cameraId: String) { + private fun loadZoomCapabilities(cameraId: String) { val chars = cameraManager.getCameraCharacteristics(cameraId) - characteristics = chars sensorRect = chars.get(CameraCharacteristics.SENSOR_INFO_ACTIVE_ARRAY_SIZE) - sensorOrientation = chars.get(CameraCharacteristics.SENSOR_ORIENTATION) ?: 0 - frontFacing = chars.get(CameraCharacteristics.LENS_FACING) == CameraCharacteristics.LENS_FACING_FRONT - stateStore.setCapabilities(capabilitiesFor(cameraId)) - } - private fun capabilitiesFor(cameraId: String): CameraCapabilities { - val chars = cameraManager.getCameraCharacteristics(cameraId) - val requestCapabilities = chars.get(CameraCharacteristics.REQUEST_AVAILABLE_CAPABILITIES)?.toSet().orEmpty() - val afModes = chars.get(CameraCharacteristics.CONTROL_AF_AVAILABLE_MODES)?.toSet().orEmpty() - val awbModes = chars.get(CameraCharacteristics.CONTROL_AWB_AVAILABLE_MODES)?.toSet().orEmpty() - val minFocusDistance = chars.get(CameraCharacteristics.LENS_INFO_MINIMUM_FOCUS_DISTANCE) ?: 0f - val focusModes = buildSet { - if (CaptureRequest.CONTROL_AF_MODE_CONTINUOUS_VIDEO in afModes) add(FocusMode.Continuous) - if (CaptureRequest.CONTROL_AF_MODE_AUTO in afModes || CaptureRequest.CONTROL_AF_MODE_MACRO in afModes) add(FocusMode.Single) - if (minFocusDistance > 0f) add(FocusMode.Manual) - } - val whiteBalanceModes = buildSet { - if (CaptureRequest.CONTROL_AWB_MODE_AUTO in awbModes) add(WhiteBalanceMode.Auto) - if (CaptureRequest.CONTROL_AWB_MODE_DAYLIGHT in awbModes) add(WhiteBalanceMode.Daylight) - if (CaptureRequest.CONTROL_AWB_MODE_CLOUDY_DAYLIGHT in awbModes) add(WhiteBalanceMode.Cloudy) - if (CaptureRequest.CONTROL_AWB_MODE_INCANDESCENT in awbModes) add(WhiteBalanceMode.Incandescent) - if (CaptureRequest.CONTROL_AWB_MODE_FLUORESCENT in awbModes) add(WhiteBalanceMode.Fluorescent) - if (CameraCharacteristics.REQUEST_AVAILABLE_CAPABILITIES_MANUAL_POST_PROCESSING in requestCapabilities) add(WhiteBalanceMode.Manual) - } - val opticalModes = chars.get(CameraCharacteristics.LENS_INFO_AVAILABLE_OPTICAL_STABILIZATION)?.toSet().orEmpty() - val videoModes = chars.get(CameraCharacteristics.CONTROL_AVAILABLE_VIDEO_STABILIZATION_MODES)?.toSet().orEmpty() - val stabilizationModes = buildSet { - add(StabilizationMode.Off) - if (CaptureRequest.LENS_OPTICAL_STABILIZATION_MODE_ON in opticalModes) add(StabilizationMode.Optical) - if (CaptureRequest.CONTROL_VIDEO_STABILIZATION_MODE_ON in videoModes) add(StabilizationMode.Video) - } - val zoomRange = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) { - chars.get(CameraCharacteristics.CONTROL_ZOOM_RATIO_RANGE)?.let { FloatValueRange(it.lower, it.upper) } - } else null - val maxDigitalZoom = chars.get(CameraCharacteristics.SCALER_AVAILABLE_MAX_DIGITAL_ZOOM) ?: 1f - val resolvedZoomRange = zoomRange ?: FloatValueRange(1f, maxDigitalZoom.coerceAtLeast(1f)) - val isoRange = chars.get(CameraCharacteristics.SENSOR_INFO_SENSITIVITY_RANGE)?.let { IntValueRange(it.lower, it.upper) } - val exposureRange = chars.get(CameraCharacteristics.SENSOR_INFO_EXPOSURE_TIME_RANGE)?.let { LongValueRange(it.lower, it.upper) } - val compensationRange = chars.get(CameraCharacteristics.CONTROL_AE_COMPENSATION_RANGE)?.let { - if (it.lower == 0 && it.upper == 0) null else IntValueRange(it.lower, it.upper) - } - val facing = chars.get(CameraCharacteristics.LENS_FACING) - val logical = CameraCharacteristics.REQUEST_AVAILABLE_CAPABILITIES_LOGICAL_MULTI_CAMERA in requestCapabilities - val name = when (facing) { - CameraCharacteristics.LENS_FACING_FRONT -> "Front camera" - CameraCharacteristics.LENS_FACING_EXTERNAL -> "External camera" - else -> if (logical) "Back camera system" else "Back camera" + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) { + val range = chars.get(CameraCharacteristics.CONTROL_ZOOM_RATIO_RANGE) + if (range != null) { + supportsZoomRatioKey = true + minZoomRatio = range.lower + maxZoomRatio = range.upper + return + } } - return CameraCapabilities( - cameraId = cameraId, - displayName = name, - lensFacing = when (facing) { - CameraCharacteristics.LENS_FACING_FRONT -> "front" - CameraCharacteristics.LENS_FACING_EXTERNAL -> "external" - else -> "back" - }, - logicalMultiCamera = logical, - physicalCameraIds = chars.physicalCameraIds.sorted(), - manualSensor = CameraCharacteristics.REQUEST_AVAILABLE_CAPABILITIES_MANUAL_SENSOR in requestCapabilities, - manualWhiteBalance = CameraCharacteristics.REQUEST_AVAILABLE_CAPABILITIES_MANUAL_POST_PROCESSING in requestCapabilities, - supportsAwbLock = chars.get(CameraCharacteristics.CONTROL_AWB_LOCK_AVAILABLE) == true, - supportsTapFocus = focusModes.any { it == FocusMode.Single || it == FocusMode.Continuous } && - (chars.get(CameraCharacteristics.CONTROL_MAX_REGIONS_AF) ?: 0) > 0, - supportsAeRegions = (chars.get(CameraCharacteristics.CONTROL_MAX_REGIONS_AE) ?: 0) > 0, - supportsTorch = chars.get(CameraCharacteristics.FLASH_INFO_AVAILABLE) == true, - supportsZoomRatio = zoomRange != null, - isoRange = isoRange, - shutterRangeNs = exposureRange, - exposureCompensationRange = compensationRange, - focusDistanceRange = if (minFocusDistance > 0f) FloatValueRange(0f, minFocusDistance) else null, - zoomRange = resolvedZoomRange, - fpsRanges = chars.get(CameraCharacteristics.CONTROL_AE_AVAILABLE_TARGET_FPS_RANGES) - ?.map { IntValueRange(it.lower, it.upper) } - ?.distinct() - ?.sortedWith(compareBy({ it.max }, { it.min })) - .orEmpty(), - focusModes = focusModes, - whiteBalanceModes = whiteBalanceModes.ifEmpty { setOf(WhiteBalanceMode.Auto) }, - stabilizationModes = stabilizationModes, - ) + + supportsZoomRatioKey = false + val maxDigitalZoom = chars.get(CameraCharacteristics.SCALER_AVAILABLE_MAX_DIGITAL_ZOOM) ?: 1.0f + minZoomRatio = 1.0f + maxZoomRatio = maxDigitalZoom } private fun createSession() { val device = camera ?: return - val preview = resolvePreviewSurface().getOrElse { - Log.w(TAG, "Preview surface is not ready", it) - return - } + val preview = previewSurfaceProvider() val encoded = streamingSurface val surfaces = if (encoded != null) listOf(preview, encoded) else listOf(preview) session?.close() @Suppress("DEPRECATION") - device.createCaptureSession(surfaces, object : CameraCaptureSession.StateCallback() { - override fun onConfigured(captureSession: CameraCaptureSession) { - session = captureSession - rebuildRepeatingRequest() - } - - override fun onConfigureFailed(captureSession: CameraCaptureSession) { - Log.e(TAG, "Capture session configuration failed") - closeCamera() - } - }, handler) - } - - private fun rebuildRepeatingRequest() { - val device = camera ?: return - val activeSession = session ?: return - val preview = resolvePreviewSurface().getOrNull() ?: return - val encoded = streamingSurface - val template = if (encoded != null) CameraDevice.TEMPLATE_RECORD else CameraDevice.TEMPLATE_PREVIEW - runCatching { - val builder = device.createCaptureRequest(template).apply { - addTarget(preview) - if (encoded != null) addTarget(encoded) - } - applyCompleteState(builder, currentState().settings) - activeSession.setRepeatingRequest(builder.build(), captureCallback, handler) - }.onFailure { Log.w(TAG, "Failed to apply repeating camera request", it) } - } - - private fun applyCompleteState(builder: CaptureRequest.Builder, settings: CameraSettings) { - val caps = stateStore.capabilities() ?: return - builder.set(CaptureRequest.CONTROL_MODE, CaptureRequest.CONTROL_MODE_AUTO) - applyFrameRate(builder, settings.fps) - if (settings.exposureMode == ExposureMode.Manual && caps.manualSensor) { - val frameDuration = settings.fps?.let { 1_000_000_000L / it.coerceAtLeast(1) } - val shutter = caps.shutterRangeNs?.clamp(settings.shutterNs ?: DEFAULT_SHUTTER_NS) ?: DEFAULT_SHUTTER_NS - val iso = caps.isoRange?.clamp(settings.iso ?: DEFAULT_ISO) ?: DEFAULT_ISO - builder.set(CaptureRequest.CONTROL_AE_MODE, CaptureRequest.CONTROL_AE_MODE_OFF) - builder.set(CaptureRequest.SENSOR_SENSITIVITY, iso) - builder.set(CaptureRequest.SENSOR_EXPOSURE_TIME, shutter) - frameDuration?.let { builder.set(CaptureRequest.SENSOR_FRAME_DURATION, max(it, shutter)) } - } else { - builder.set(CaptureRequest.CONTROL_AE_MODE, CaptureRequest.CONTROL_AE_MODE_ON) - caps.exposureCompensationRange?.let { - builder.set(CaptureRequest.CONTROL_AE_EXPOSURE_COMPENSATION, it.clamp(settings.exposureCompensation)) - } - } - applyFocus(builder, settings) - applyWhiteBalance(builder, settings, caps) - applyZoom(builder, settings.zoomRatio, caps) - applyTorch(builder, settings.torch && caps.supportsTorch) - applyStabilization(builder, settings.stabilizationMode, caps) - } - - private fun applyFrameRate(builder: CaptureRequest.Builder, fps: Int?) { - if (fps == null) return - val ranges = characteristics?.get(CameraCharacteristics.CONTROL_AE_AVAILABLE_TARGET_FPS_RANGES).orEmpty() - val best = ranges.filter { fps in it.lower..it.upper } - .minWithOrNull(compareBy>({ it.upper - it.lower }, { kotlin.math.abs(it.upper - fps) })) - if (best != null) builder.set(CaptureRequest.CONTROL_AE_TARGET_FPS_RANGE, best) - } + device.createCaptureSession( + surfaces, + object : CameraCaptureSession.StateCallback() { + override fun onConfigured(captureSession: CameraCaptureSession) { + session = captureSession + val template = if (encoded != null) { + CameraDevice.TEMPLATE_RECORD + } else { + CameraDevice.TEMPLATE_PREVIEW + } + val request = device.createCaptureRequest(template).apply { + addTarget(preview) + if (encoded != null) { + addTarget(encoded) + } + set(CaptureRequest.CONTROL_MODE, CaptureRequest.CONTROL_MODE_AUTO) + set(CaptureRequest.CONTROL_AF_MODE, CaptureRequest.CONTROL_AF_MODE_CONTINUOUS_VIDEO) + set(CaptureRequest.CONTROL_AE_MODE, CaptureRequest.CONTROL_AE_MODE_ON) + set(CaptureRequest.CONTROL_AWB_MODE, CaptureRequest.CONTROL_AWB_MODE_AUTO) + applyZoom(this) + applyTorch(this) + }.build() + captureSession.setRepeatingRequest(request, null, handler) + } - private fun applyFocus(builder: CaptureRequest.Builder, settings: CameraSettings) { - val caps = stateStore.capabilities() ?: return - when (settings.focusMode) { - FocusMode.Continuous -> builder.set( - CaptureRequest.CONTROL_AF_MODE, - if (FocusMode.Continuous in caps.focusModes) CaptureRequest.CONTROL_AF_MODE_CONTINUOUS_VIDEO - else CaptureRequest.CONTROL_AF_MODE_OFF, - ) - FocusMode.Single -> builder.set( - CaptureRequest.CONTROL_AF_MODE, - if (FocusMode.Single in caps.focusModes) CaptureRequest.CONTROL_AF_MODE_AUTO - else CaptureRequest.CONTROL_AF_MODE_OFF, - ) - FocusMode.Manual -> { - builder.set(CaptureRequest.CONTROL_AF_MODE, CaptureRequest.CONTROL_AF_MODE_OFF) - settings.focusDistanceDiopters?.let { distance -> - caps.focusDistanceRange?.let { builder.set(CaptureRequest.LENS_FOCUS_DISTANCE, it.clamp(distance)) } + override fun onConfigureFailed(captureSession: CameraCaptureSession) { + Log.e(TAG, "Capture session configuration failed") + closeCamera() } - } - } - focusRegion?.let { region -> - if (caps.supportsTapFocus) builder.set(CaptureRequest.CONTROL_AF_REGIONS, arrayOf(region)) - if (caps.supportsAeRegions) builder.set(CaptureRequest.CONTROL_AE_REGIONS, arrayOf(region)) - } + }, + handler, + ) } - private fun applyWhiteBalance( - builder: CaptureRequest.Builder, - settings: CameraSettings, - caps: CameraCapabilities, - ) { - val mode = when (settings.whiteBalanceMode) { - WhiteBalanceMode.Auto -> CaptureRequest.CONTROL_AWB_MODE_AUTO - WhiteBalanceMode.Daylight -> CaptureRequest.CONTROL_AWB_MODE_DAYLIGHT - WhiteBalanceMode.Cloudy -> CaptureRequest.CONTROL_AWB_MODE_CLOUDY_DAYLIGHT - WhiteBalanceMode.Incandescent -> CaptureRequest.CONTROL_AWB_MODE_INCANDESCENT - WhiteBalanceMode.Fluorescent -> CaptureRequest.CONTROL_AWB_MODE_FLUORESCENT - WhiteBalanceMode.Manual -> CaptureRequest.CONTROL_AWB_MODE_OFF - } - builder.set(CaptureRequest.CONTROL_AWB_MODE, mode) - if (settings.whiteBalanceMode == WhiteBalanceMode.Manual && caps.manualWhiteBalance) { - builder.set(CaptureRequest.COLOR_CORRECTION_MODE, CaptureRequest.COLOR_CORRECTION_MODE_TRANSFORM_MATRIX) - builder.set(CaptureRequest.COLOR_CORRECTION_TRANSFORM, IDENTITY_COLOR_TRANSFORM) - builder.set( - CaptureRequest.COLOR_CORRECTION_GAINS, - kelvinToGains(settings.whiteBalanceKelvin ?: DEFAULT_KELVIN, settings.whiteBalanceTint), - ) - } else if (caps.supportsAwbLock) { - builder.set(CaptureRequest.CONTROL_AWB_LOCK, settings.whiteBalanceLock) - } - } + private fun applyZoom(builder: CaptureRequest.Builder) { + if (currentZoomRatio <= 1.0f && !supportsZoomRatioKey) return - private fun applyZoom(builder: CaptureRequest.Builder, ratio: Float, caps: CameraCapabilities) { - val value = caps.zoomRange.clamp(ratio) - if (caps.supportsZoomRatio && Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) { - builder.set(CaptureRequest.CONTROL_ZOOM_RATIO, value) + if (supportsZoomRatioKey && Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) { + builder.set(CaptureRequest.CONTROL_ZOOM_RATIO, currentZoomRatio) } else { - builder.set(CaptureRequest.SCALER_CROP_REGION, calculateCrop(value)) + // Fallback: crop region + val sensor = sensorRect ?: return + val cropWidth = (sensor.width() / currentZoomRatio).toInt() + val cropHeight = (sensor.height() / currentZoomRatio).toInt() + val left = (sensor.width() - cropWidth) / 2 + val top = (sensor.height() - cropHeight) / 2 + builder.set(CaptureRequest.SCALER_CROP_REGION, Rect(left, top, left + cropWidth, top + cropHeight)) } } - private fun applyTorch(builder: CaptureRequest.Builder, enabled: Boolean) { - builder.set( - CaptureRequest.FLASH_MODE, - if (enabled) CaptureRequest.FLASH_MODE_TORCH else CaptureRequest.FLASH_MODE_OFF, - ) - } - - private fun applyStabilization( - builder: CaptureRequest.Builder, - mode: StabilizationMode, - caps: CameraCapabilities, - ) { - val resolved = if (mode in caps.stabilizationModes) mode else StabilizationMode.Off - builder.set( - CaptureRequest.LENS_OPTICAL_STABILIZATION_MODE, - if (resolved == StabilizationMode.Optical) CaptureRequest.LENS_OPTICAL_STABILIZATION_MODE_ON - else CaptureRequest.LENS_OPTICAL_STABILIZATION_MODE_OFF, - ) - builder.set( - CaptureRequest.CONTROL_VIDEO_STABILIZATION_MODE, - if (resolved == StabilizationMode.Video) CaptureRequest.CONTROL_VIDEO_STABILIZATION_MODE_ON - else CaptureRequest.CONTROL_VIDEO_STABILIZATION_MODE_OFF, - ) + private fun applyTorch(builder: CaptureRequest.Builder) { + if (torchEnabled) { + builder.set(CaptureRequest.FLASH_MODE, CaptureRequest.FLASH_MODE_TORCH) + } } - private fun triggerFocus(mode: FocusActionMode) { + /** + * Rebuild the repeating request with current zoom + torch state. + */ + private fun rebuildRepeatingRequest() { val device = camera ?: return - val activeSession = session ?: return - val preview = resolvePreviewSurface().getOrNull() ?: return + val currentSession = session ?: return + val preview = previewSurfaceProvider() val encoded = streamingSurface val template = if (encoded != null) CameraDevice.TEMPLATE_RECORD else CameraDevice.TEMPLATE_PREVIEW + runCatching { - val builder = device.createCaptureRequest(template).apply { + val request = device.createCaptureRequest(template).apply { addTarget(preview) if (encoded != null) addTarget(encoded) - } - val settings = currentState().settings.copy(focusMode = FocusMode.Single) - applyCompleteState(builder, settings) - builder.set(CaptureRequest.CONTROL_AF_TRIGGER, CaptureRequest.CONTROL_AF_TRIGGER_START) - activeSession.capture(builder.build(), captureCallback, handler) - if (mode == FocusActionMode.Auto) { - handler.postDelayed({ rebuildRepeatingRequest() }, FOCUS_RETURN_DELAY_MS) - } - }.onFailure { Log.w(TAG, "Tap focus request failed", it) } - } - - private val captureCallback = object : CameraCaptureSession.CaptureCallback() { - override fun onCaptureCompleted( - session: CameraCaptureSession, - request: CaptureRequest, - result: TotalCaptureResult, - ) { - val now = System.nanoTime() - if (now - lastTelemetryPublishNs < TELEMETRY_INTERVAL_NS) return - lastTelemetryPublishNs = now - val requested = currentState().settings - val actualZoom = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) { - result.get(CaptureResult.CONTROL_ZOOM_RATIO) ?: requested.zoomRatio - } else requested.zoomRatio - stateStore.updateTelemetry( - CameraTelemetry( - actualIso = result.get(CaptureResult.SENSOR_SENSITIVITY), - actualShutterNs = result.get(CaptureResult.SENSOR_EXPOSURE_TIME), - actualFocusDistanceDiopters = result.get(CaptureResult.LENS_FOCUS_DISTANCE), - actualZoomRatio = actualZoom, - actualWhiteBalanceKelvin = requested.whiteBalanceKelvin - ?.takeIf { requested.whiteBalanceMode == WhiteBalanceMode.Manual }, - focusStatus = focusStatus(result.get(CaptureResult.CONTROL_AF_STATE)), - aeState = aeState(result.get(CaptureResult.CONTROL_AE_STATE)), - awbState = awbState(result.get(CaptureResult.CONTROL_AWB_STATE)), - frameNumber = result.frameNumber, - timestampNs = result.get(CaptureResult.SENSOR_TIMESTAMP) ?: now, - ), - ) + set(CaptureRequest.CONTROL_MODE, CaptureRequest.CONTROL_MODE_AUTO) + set(CaptureRequest.CONTROL_AF_MODE, CaptureRequest.CONTROL_AF_MODE_CONTINUOUS_VIDEO) + set(CaptureRequest.CONTROL_AE_MODE, CaptureRequest.CONTROL_AE_MODE_ON) + set(CaptureRequest.CONTROL_AWB_MODE, CaptureRequest.CONTROL_AWB_MODE_AUTO) + applyZoom(this) + applyTorch(this) + }.build() + currentSession.setRepeatingRequest(request, null, handler) + }.onFailure { e -> + Log.w(TAG, "Failed to rebuild repeating request", e) } } - private fun calculateCrop(zoom: Float): Rect { - val sensor = sensorRect ?: return Rect(0, 0, 1, 1) - val ratio = zoom.coerceAtLeast(1f) - val cropWidth = (sensor.width() / ratio).toInt().coerceAtLeast(1) - val cropHeight = (sensor.height() / ratio).toInt().coerceAtLeast(1) - val left = sensor.left + (sensor.width() - cropWidth) / 2 - val top = sensor.top + (sensor.height() - cropHeight) / 2 - return Rect(left, top, left + cropWidth, top + cropHeight) + private fun updateZoomInSession() { + rebuildRepeatingRequest() } private fun selectCameraId(lens: CameraLens): String { - val ids = cameraManager.cameraIdList - lens.cameraId?.takeIf { it in ids }?.let { return it } - val candidates = ids.filter { id -> - cameraManager.getCameraCharacteristics(id).get(CameraCharacteristics.LENS_FACING) == lens.facing - } - if (candidates.isEmpty()) return ids.first() - if (lens.isFrontFacing) return candidates.first() - val logical = candidates.firstOrNull { id -> + val cameraIds = cameraManager.cameraIdList + val candidates = cameraIds.filter { id -> val chars = cameraManager.getCameraCharacteristics(id) - val caps = chars.get(CameraCharacteristics.REQUEST_AVAILABLE_CAPABILITIES) ?: intArrayOf() - CameraCharacteristics.REQUEST_AVAILABLE_CAPABILITIES_LOGICAL_MULTI_CAMERA in caps + chars.get(CameraCharacteristics.LENS_FACING) == lens.facing } - return logical ?: candidates.minByOrNull { equivalentFocalLength(it) } ?: candidates.first() - } - private fun lensCandidateFor(cameraId: String): CameraLensCandidate? = runCatching { - CameraLensCandidate(cameraId, equivalentFocalLength(cameraId)) - }.getOrNull() - - private fun equivalentFocalLength(cameraId: String): Float { - val chars = cameraManager.getCameraCharacteristics(cameraId) - val focalLength = chars.get(CameraCharacteristics.LENS_INFO_AVAILABLE_FOCAL_LENGTHS) - ?.firstOrNull() - ?: return 24f - val sensorWidth = chars.get(CameraCharacteristics.SENSOR_INFO_PHYSICAL_SIZE)?.width - return if (sensorWidth != null && sensorWidth > 0f) focalLength / sensorWidth * 36f else focalLength - } - - private fun focusStatus(value: Int?): FocusStatus = when (value) { - CaptureResult.CONTROL_AF_STATE_ACTIVE_SCAN, - CaptureResult.CONTROL_AF_STATE_PASSIVE_SCAN -> FocusStatus.Scanning - CaptureResult.CONTROL_AF_STATE_FOCUSED_LOCKED -> FocusStatus.Focused - CaptureResult.CONTROL_AF_STATE_NOT_FOCUSED_LOCKED -> FocusStatus.NotFocused - CaptureResult.CONTROL_AF_STATE_PASSIVE_FOCUSED, - CaptureResult.CONTROL_AF_STATE_PASSIVE_UNFOCUSED -> FocusStatus.Passive - else -> FocusStatus.Inactive - } + if (candidates.isEmpty()) return cameraIds.first() + if (candidates.size == 1 || lens.isFrontFacing) return candidates.first() - private fun aeState(value: Int?): String = when (value) { - CaptureResult.CONTROL_AE_STATE_SEARCHING -> "searching" - CaptureResult.CONTROL_AE_STATE_CONVERGED -> "converged" - CaptureResult.CONTROL_AE_STATE_LOCKED -> "locked" - CaptureResult.CONTROL_AE_STATE_FLASH_REQUIRED -> "flash_required" - CaptureResult.CONTROL_AE_STATE_PRECAPTURE -> "precapture" - else -> "inactive" - } - - private fun awbState(value: Int?): String = when (value) { - CaptureResult.CONTROL_AWB_STATE_SEARCHING -> "searching" - CaptureResult.CONTROL_AWB_STATE_CONVERGED -> "converged" - CaptureResult.CONTROL_AWB_STATE_LOCKED -> "locked" - else -> "inactive" - } - - private fun kelvinToGains(kelvin: Int, tint: Int): RggbChannelVector { - val temperature = kelvin.coerceIn(2_000, 12_000).toFloat() - val normalized = ((temperature - 2_000f) / 10_000f).coerceIn(0f, 1f) - val tintShift = tint.coerceIn(-100, 100) / 500f - val red = (2.15f - 1.15f * normalized + tintShift).coerceIn(0.5f, 3f) - val blue = (0.8f + 1.35f * normalized - tintShift).coerceIn(0.5f, 3f) - val greenEven = (1f - tintShift / 2f).coerceIn(0.75f, 1.25f) - val greenOdd = (1f + tintShift / 2f).coerceIn(0.75f, 1.25f) - return RggbChannelVector(red, greenEven, greenOdd, blue) - } - - private fun Rect.toSensorRect() = SensorRect(left, top, right, bottom) - - private fun resolvePreviewSurface(): Result { - if (preferFallbackPreviewSurface) { - val fallback = fallbackPreviewSurface - if (fallback?.isValid == true) return Result.success(fallback) + // Multiple back cameras — pick by focal length + data class CamCandidate(val id: String, val focalLength: Float) + val sorted = candidates.map { id -> + val chars = cameraManager.getCameraCharacteristics(id) + val focal = chars.get(CameraCharacteristics.LENS_INFO_AVAILABLE_FOCAL_LENGTHS)?.firstOrNull() ?: 0f + CamCandidate(id, focal) + }.sortedBy { it.focalLength } + + return when (lens.focalHint) { + CameraLens.FocalHint.Ultrawide -> sorted.first().id + CameraLens.FocalHint.Telephoto -> sorted.last().id + CameraLens.FocalHint.Normal -> { + // Pick the middle one (main camera is typically the middle focal length) + if (sorted.size >= 3) sorted[1].id + else if (sorted.size == 2) sorted[1].id // longer focal = main on 2-cam setups + else sorted.first().id + } } - val activitySurface = runCatching { previewSurfaceProvider() }.getOrNull() - if (activitySurface?.isValid == true) return Result.success(activitySurface) - val fallback = fallbackPreviewSurface - if (fallback?.isValid == true) return Result.success(fallback) - return Result.failure(IllegalStateException("No valid camera preview surface")) - } - - companion object { - private const val TAG = "OpenStreamCamera" - private const val DEFAULT_ISO = 400 - private const val DEFAULT_SHUTTER_NS = 16_666_667L - private const val DEFAULT_KELVIN = 5_600 - private const val FOCUS_RETURN_DELAY_MS = 1_200L - private const val TELEMETRY_INTERVAL_NS = 250_000_000L - private val IDENTITY_COLOR_TRANSFORM = ColorSpaceTransform( - arrayOf( - Rational(1, 1), Rational(0, 1), Rational(0, 1), - Rational(0, 1), Rational(1, 1), Rational(0, 1), - Rational(0, 1), Rational(0, 1), Rational(1, 1), - ), - ) } } diff --git a/android/app/src/main/java/dev/openstream/app/camera/CameraLens.kt b/android/app/src/main/java/dev/openstream/app/camera/CameraLens.kt index 2641c44..11e16cf 100644 --- a/android/app/src/main/java/dev/openstream/app/camera/CameraLens.kt +++ b/android/app/src/main/java/dev/openstream/app/camera/CameraLens.kt @@ -1,105 +1,29 @@ package dev.openstream.app.camera import android.hardware.camera2.CameraCharacteristics -import kotlin.math.abs -import kotlin.math.round -/** A lens option discovered from the cameras exposed by the phone. */ -data class CameraLens( - /** The logical or standalone Camera2 ID that owns this option. */ - val cameraId: String?, +/** + * Represents available physical camera lenses. + * + * The actual availability of each lens varies by device. Camera2Controller + * queries the device's camera list and exposes only the lenses that actually + * exist on the hardware. + */ +enum class CameraLens( val displayName: String, val shortLabel: String, val facing: Int, - /** Zoom ratio to apply after this camera is selected. */ - val targetZoom: Float = 1f, + /** Approximate focal-length hint used to disambiguate multiple back cameras. */ + val focalHint: FocalHint = FocalHint.Normal, ) { + Back("Back camera", "1×", CameraCharacteristics.LENS_FACING_BACK, FocalHint.Normal), + BackUltrawide("Ultrawide", "0.5×", CameraCharacteristics.LENS_FACING_BACK, FocalHint.Ultrawide), + BackTelephoto("Telephoto", "2×", CameraCharacteristics.LENS_FACING_BACK, FocalHint.Telephoto), + Front("Front camera", "Front", CameraCharacteristics.LENS_FACING_FRONT, FocalHint.Normal), + ; + val isBackFacing: Boolean get() = facing == CameraCharacteristics.LENS_FACING_BACK val isFrontFacing: Boolean get() = facing == CameraCharacteristics.LENS_FACING_FRONT - companion object { - fun defaultBack() = CameraLens( - cameraId = null, - displayName = "1×", - shortLabel = "1×", - facing = CameraCharacteristics.LENS_FACING_BACK, - ) - - fun selfie(cameraId: String) = CameraLens( - cameraId = cameraId, - displayName = "Selfie", - // Keep the established control-protocol value for paired OBS clients. - shortLabel = "Front", - facing = CameraCharacteristics.LENS_FACING_FRONT, - ) - } -} - -/** A physical rear-camera measurement normalized to the 35 mm-equivalent field of view. */ -data class CameraLensCandidate( - val cameraId: String, - val equivalentFocalLength: Float, -) - -/** - * Converts Camera2 focal-length data into the lens shortcuts shown to the operator. - * - * Android does not publish OEM marketing labels (for example, "3x"), so the labels - * are calculated relative to the candidate closest to the standard 24 mm-equivalent - * phone main camera. Logical multi-cameras use one logical ID and request the derived - * zoom ratio, allowing the device HAL to choose the appropriate physical camera. - */ -object CameraLensDiscovery { - fun rearLenses( - logicalCameraId: String?, - candidates: List, - supportsLogicalZoomRatio: Boolean, - ): List { - val uniqueCandidates = candidates - .filter { it.equivalentFocalLength > 0f } - .distinctBy { it.cameraId } - - val selectableCandidates = when { - uniqueCandidates.isEmpty() -> listOf(CameraLensCandidate(logicalCameraId.orEmpty(), 24f)) - logicalCameraId != null && !supportsLogicalZoomRatio -> listOf(uniqueCandidates.first()) - else -> uniqueCandidates - } - val main = selectableCandidates.minByOrNull { abs(it.equivalentFocalLength - MAIN_CAMERA_EQUIVALENT_MM) } - ?: selectableCandidates.first() - - val options = selectableCandidates.map { candidate -> - val multiplier = candidate.equivalentFocalLength / main.equivalentFocalLength - val roundedMultiplier = round(multiplier * 10f) / 10f - CameraLens( - cameraId = logicalCameraId ?: candidate.cameraId, - displayName = formatMultiplier(roundedMultiplier), - shortLabel = formatMultiplier(roundedMultiplier), - facing = CameraCharacteristics.LENS_FACING_BACK, - targetZoom = if (logicalCameraId != null) multiplier else 1f, - ) - }.distinctBy { it.shortLabel } - .sortedBy { it.targetZoom.takeIf { logicalCameraId != null } ?: labelValue(it.shortLabel) } - - // A phone with one rear camera still gets a useful wide/digital pair. - return if (options.size == 1) { - val mainOption = options.single().copy( - displayName = "1×", - shortLabel = "1×", - targetZoom = 1f, - ) - listOf(mainOption, mainOption.copy(displayName = "5×", shortLabel = "5×", targetZoom = 5f)) - } else { - options - } - } - - private fun formatMultiplier(value: Float): String = if (abs(value - round(value)) < 0.05f) { - "${round(value).toInt()}×" - } else { - "${"%.1f".format(java.util.Locale.US, value)}×" - } - - private fun labelValue(label: String): Float = label.removeSuffix("×").toFloatOrNull() ?: 1f - - private const val MAIN_CAMERA_EQUIVALENT_MM = 24f + enum class FocalHint { Ultrawide, Normal, Telephoto } } diff --git a/android/app/src/main/java/dev/openstream/app/camera/CameraModels.kt b/android/app/src/main/java/dev/openstream/app/camera/CameraModels.kt deleted file mode 100644 index c4e88a4..0000000 --- a/android/app/src/main/java/dev/openstream/app/camera/CameraModels.kt +++ /dev/null @@ -1,192 +0,0 @@ -package dev.openstream.app.camera - -enum class AuthorityMode(val wireValue: String) { - Collaborative("collaborative"), - ObsLock("obs_lock"), - ; - - companion object { - fun fromWire(value: String): AuthorityMode? = entries.firstOrNull { it.wireValue == value } - } -} - -enum class CameraActor(val wireValue: String) { - Camera("camera"), - Obs("obs"), - System("system"), -} - -enum class ExposureMode(val wireValue: String) { - Auto("auto"), - Manual("manual"), - ; - - companion object { - fun fromWire(value: String): ExposureMode? = entries.firstOrNull { it.wireValue == value } - } -} - -enum class FocusMode(val wireValue: String) { - Continuous("continuous"), - Single("single"), - Manual("manual"), - ; - - companion object { - fun fromWire(value: String): FocusMode? = entries.firstOrNull { it.wireValue == value } - } -} - -enum class FocusActionMode(val wireValue: String) { - Auto("auto"), - Lock("lock"), - ; - - companion object { - fun fromWire(value: String): FocusActionMode? = entries.firstOrNull { it.wireValue == value } - } -} - -enum class WhiteBalanceMode(val wireValue: String) { - Auto("auto"), - Daylight("daylight"), - Cloudy("cloudy"), - Incandescent("incandescent"), - Fluorescent("fluorescent"), - Manual("manual"), - ; - - companion object { - fun fromWire(value: String): WhiteBalanceMode? = entries.firstOrNull { it.wireValue == value } - } -} - -enum class StabilizationMode(val wireValue: String) { - Off("off"), - Optical("optical"), - Video("video"), - ; - - companion object { - fun fromWire(value: String): StabilizationMode? = entries.firstOrNull { it.wireValue == value } - } -} - -enum class FocusStatus(val wireValue: String) { - Inactive("inactive"), - Scanning("scanning"), - Focused("focused"), - NotFocused("not_focused"), - Passive("passive"), -} - -data class IntValueRange(val min: Int, val max: Int) { - init { require(min <= max) } - fun contains(value: Int) = value in min..max - fun clamp(value: Int) = value.coerceIn(min, max) -} - -data class LongValueRange(val min: Long, val max: Long) { - init { require(min <= max) } - fun contains(value: Long) = value in min..max - fun clamp(value: Long) = value.coerceIn(min, max) -} - -data class FloatValueRange(val min: Float, val max: Float) { - init { require(min <= max) } - fun contains(value: Float) = value in min..max - fun clamp(value: Float) = value.coerceIn(min, max) -} - -data class CameraCapabilities( - val cameraId: String, - val displayName: String, - val lensFacing: String, - val logicalMultiCamera: Boolean, - val physicalCameraIds: List, - val manualSensor: Boolean, - val manualWhiteBalance: Boolean, - val supportsAwbLock: Boolean, - val supportsTapFocus: Boolean, - val supportsAeRegions: Boolean, - val supportsTorch: Boolean, - val supportsZoomRatio: Boolean, - val isoRange: IntValueRange?, - val shutterRangeNs: LongValueRange?, - val exposureCompensationRange: IntValueRange?, - val focusDistanceRange: FloatValueRange?, - val zoomRange: FloatValueRange, - val fpsRanges: List, - val focusModes: Set, - val whiteBalanceModes: Set, - val stabilizationModes: Set, -) - -data class CameraSettings( - val exposureMode: ExposureMode = ExposureMode.Auto, - val iso: Int? = null, - val shutterNs: Long? = null, - val exposureCompensation: Int = 0, - val whiteBalanceMode: WhiteBalanceMode = WhiteBalanceMode.Auto, - val whiteBalanceKelvin: Int? = null, - val whiteBalanceTint: Int = 0, - val whiteBalanceLock: Boolean = false, - val focusMode: FocusMode = FocusMode.Continuous, - val focusDistanceDiopters: Float? = null, - val zoomRatio: Float = 1f, - val torch: Boolean = false, - val stabilizationMode: StabilizationMode = StabilizationMode.Off, - val fps: Int? = null, -) - -data class CameraSettingsPatch( - val exposureMode: ExposureMode? = null, - val iso: Int? = null, - val shutterNs: Long? = null, - val exposureCompensation: Int? = null, - val whiteBalanceMode: WhiteBalanceMode? = null, - val whiteBalanceKelvin: Int? = null, - val whiteBalanceTint: Int? = null, - val whiteBalanceLock: Boolean? = null, - val focusMode: FocusMode? = null, - val focusDistanceDiopters: Float? = null, - val zoomRatio: Float? = null, - val torch: Boolean? = null, - val stabilizationMode: StabilizationMode? = null, - val fps: Int? = null, -) - -data class CameraTelemetry( - val actualIso: Int? = null, - val actualShutterNs: Long? = null, - val actualFocusDistanceDiopters: Float? = null, - val actualZoomRatio: Float = 1f, - val actualWhiteBalanceKelvin: Int? = null, - val focusStatus: FocusStatus = FocusStatus.Inactive, - val aeState: String = "inactive", - val awbState: String = "inactive", - val frameNumber: Long = 0, - val timestampNs: Long = 0, -) - -data class TallyState( - val program: Boolean = false, - val preview: Boolean = false, -) - -data class CameraState( - val revision: Long = 0, - val lastActor: CameraActor = CameraActor.System, - val authority: AuthorityMode = AuthorityMode.Collaborative, - val tally: TallyState = TallyState(), - val settings: CameraSettings = CameraSettings(), - val telemetry: CameraTelemetry = CameraTelemetry(), -) - -sealed interface CameraControlResult { - data class Applied(val state: CameraState) : CameraControlResult - data class Conflict(val state: CameraState) : CameraControlResult - data class Unsupported(val field: String, val reason: String, val state: CameraState) : CameraControlResult - data class Invalid(val field: String, val reason: String, val state: CameraState) : CameraControlResult - data class Locked(val state: CameraState) : CameraControlResult -} diff --git a/android/app/src/main/java/dev/openstream/app/camera/CameraStateStore.kt b/android/app/src/main/java/dev/openstream/app/camera/CameraStateStore.kt deleted file mode 100644 index 2d3bfc0..0000000 --- a/android/app/src/main/java/dev/openstream/app/camera/CameraStateStore.kt +++ /dev/null @@ -1,272 +0,0 @@ -package dev.openstream.app.camera - -import java.util.concurrent.CopyOnWriteArrayList - -class CameraStateStore( - initialCapabilities: CameraCapabilities? = null, - initialState: CameraState = CameraState(), -) { - private val listeners = CopyOnWriteArrayList<(CameraState) -> Unit>() - private var capabilities: CameraCapabilities? = initialCapabilities - private var state: CameraState = initialState - - @Synchronized - fun capabilities(): CameraCapabilities? = capabilities - - @Synchronized - fun snapshot(): CameraState = state - - fun addListener(listener: (CameraState) -> Unit): AutoCloseable { - listeners += listener - listener(snapshot()) - return AutoCloseable { listeners -= listener } - } - - fun setCapabilities(value: CameraCapabilities) { - val updated = synchronized(this) { - capabilities = value - val defaults = defaultsFor(value, state.settings) - state = state.copy( - revision = state.revision + 1, - lastActor = CameraActor.System, - settings = defaults, - ) - state - } - notifyListeners(updated) - } - - fun applySettings( - expectedRevision: Long?, - actor: CameraActor, - patch: CameraSettingsPatch, - ): CameraControlResult { - val result = synchronized(this) { - gate(expectedRevision, actor)?.let { return@synchronized it } - val caps = capabilities - ?: return@synchronized CameraControlResult.Unsupported("camera", "Camera is not ready", state) - validatePatch(caps, patch)?.let { return@synchronized it } - - val current = state.settings - val updatedSettings = current.copy( - exposureMode = patch.exposureMode ?: current.exposureMode, - iso = patch.iso ?: current.iso, - shutterNs = patch.shutterNs ?: current.shutterNs, - exposureCompensation = patch.exposureCompensation ?: current.exposureCompensation, - whiteBalanceMode = patch.whiteBalanceMode ?: current.whiteBalanceMode, - whiteBalanceKelvin = patch.whiteBalanceKelvin ?: current.whiteBalanceKelvin, - whiteBalanceTint = patch.whiteBalanceTint ?: current.whiteBalanceTint, - whiteBalanceLock = patch.whiteBalanceLock ?: current.whiteBalanceLock, - focusMode = patch.focusMode ?: current.focusMode, - focusDistanceDiopters = patch.focusDistanceDiopters ?: current.focusDistanceDiopters, - zoomRatio = patch.zoomRatio ?: current.zoomRatio, - torch = patch.torch ?: current.torch, - stabilizationMode = patch.stabilizationMode ?: current.stabilizationMode, - fps = patch.fps ?: current.fps, - ) - state = state.copy( - revision = state.revision + 1, - lastActor = actor, - settings = updatedSettings, - ) - CameraControlResult.Applied(state) - } - if (result is CameraControlResult.Applied) notifyListeners(result.state) - return result - } - - fun applyFocus( - expectedRevision: Long?, - actor: CameraActor, - x: Float, - y: Float, - ): CameraControlResult { - val result = synchronized(this) { - gate(expectedRevision, actor)?.let { return@synchronized it } - val caps = capabilities - ?: return@synchronized CameraControlResult.Unsupported("camera", "Camera is not ready", state) - if (!caps.supportsTapFocus) { - return@synchronized CameraControlResult.Unsupported("focus", "Tap focus is unavailable on this lens", state) - } - if (!x.isFinite() || !y.isFinite() || x !in 0f..1f || y !in 0f..1f) { - return@synchronized CameraControlResult.Invalid("focus", "Coordinates must be normalized between 0 and 1", state) - } - state = state.copy(revision = state.revision + 1, lastActor = actor) - CameraControlResult.Applied(state) - } - if (result is CameraControlResult.Applied) notifyListeners(result.state) - return result - } - - fun setAuthority( - expectedRevision: Long?, - actor: CameraActor, - mode: AuthorityMode, - ): CameraControlResult { - val result = synchronized(this) { - if (expectedRevision != null && expectedRevision != state.revision) { - return@synchronized CameraControlResult.Conflict(state) - } - if (actor == CameraActor.Camera && state.authority == AuthorityMode.ObsLock) { - return@synchronized CameraControlResult.Locked(state) - } - state = state.copy( - revision = state.revision + 1, - lastActor = actor, - authority = mode, - ) - CameraControlResult.Applied(state) - } - if (result is CameraControlResult.Applied) notifyListeners(result.state) - return result - } - - fun setTally(program: Boolean, preview: Boolean, actor: CameraActor = CameraActor.Obs): CameraState { - val updated = synchronized(this) { - val next = TallyState(program = program, preview = preview && !program) - if (next == state.tally) return@synchronized state - state = state.copy( - revision = state.revision + 1, - lastActor = actor, - tally = next, - ) - state - } - notifyListeners(updated) - return updated - } - - fun updateTelemetry(telemetry: CameraTelemetry) { - val updated = synchronized(this) { - state = state.copy(telemetry = telemetry) - state - } - notifyListeners(updated) - } - - private fun gate(expectedRevision: Long?, actor: CameraActor): CameraControlResult? { - if (expectedRevision != null && expectedRevision != state.revision) { - return CameraControlResult.Conflict(state) - } - if (actor == CameraActor.Camera && state.authority == AuthorityMode.ObsLock) { - return CameraControlResult.Locked(state) - } - return null - } - - private fun validatePatch( - caps: CameraCapabilities, - patch: CameraSettingsPatch, - ): CameraControlResult? { - if (patch.exposureMode == ExposureMode.Manual && !caps.manualSensor) { - return CameraControlResult.Unsupported("exposureMode", "Manual sensor control is unavailable", state) - } - patch.iso?.let { - val range = caps.isoRange - ?: return CameraControlResult.Unsupported("iso", "ISO control is unavailable", state) - if (!range.contains(it)) return CameraControlResult.Invalid("iso", "ISO must be ${range.min}..${range.max}", state) - } - patch.shutterNs?.let { - val range = caps.shutterRangeNs - ?: return CameraControlResult.Unsupported("shutterNs", "Shutter control is unavailable", state) - if (!range.contains(it)) { - return CameraControlResult.Invalid("shutterNs", "Shutter must be ${range.min}..${range.max} ns", state) - } - } - patch.exposureCompensation?.let { - val range = caps.exposureCompensationRange - ?: return CameraControlResult.Unsupported("exposureCompensation", "Exposure compensation is unavailable", state) - if (!range.contains(it)) { - return CameraControlResult.Invalid("exposureCompensation", "Compensation must be ${range.min}..${range.max}", state) - } - } - patch.whiteBalanceMode?.let { - if (it !in caps.whiteBalanceModes) { - return CameraControlResult.Unsupported("whiteBalanceMode", "White balance mode is unavailable", state) - } - } - patch.whiteBalanceKelvin?.let { - if (!caps.manualWhiteBalance) { - return CameraControlResult.Unsupported("whiteBalanceKelvin", "Manual white balance is unavailable", state) - } - if (it !in 2_000..12_000) { - return CameraControlResult.Invalid("whiteBalanceKelvin", "Kelvin must be 2000..12000", state) - } - } - patch.whiteBalanceTint?.let { - if (!caps.manualWhiteBalance) { - return CameraControlResult.Unsupported("whiteBalanceTint", "Manual white balance is unavailable", state) - } - if (it !in -100..100) { - return CameraControlResult.Invalid("whiteBalanceTint", "Tint must be -100..100", state) - } - } - if (patch.whiteBalanceLock == true && !caps.supportsAwbLock) { - return CameraControlResult.Unsupported("whiteBalanceLock", "White balance lock is unavailable", state) - } - patch.focusMode?.let { - if (it !in caps.focusModes) return CameraControlResult.Unsupported("focusMode", "Focus mode is unavailable", state) - } - patch.focusDistanceDiopters?.let { - val range = caps.focusDistanceRange - ?: return CameraControlResult.Unsupported("focusDistanceDiopters", "Manual focus is unavailable", state) - if (!range.contains(it)) { - return CameraControlResult.Invalid("focusDistanceDiopters", "Focus distance must be ${range.min}..${range.max}", state) - } - } - patch.zoomRatio?.let { - if (!caps.zoomRange.contains(it)) { - return CameraControlResult.Invalid("zoomRatio", "Zoom must be ${caps.zoomRange.min}..${caps.zoomRange.max}", state) - } - } - if (patch.torch == true && !caps.supportsTorch) { - return CameraControlResult.Unsupported("torch", "Torch is unavailable", state) - } - patch.stabilizationMode?.let { - if (it !in caps.stabilizationModes) { - return CameraControlResult.Unsupported("stabilizationMode", "Stabilization mode is unavailable", state) - } - } - patch.fps?.let { requested -> - if (caps.fpsRanges.none { requested in it.min..it.max }) { - return CameraControlResult.Unsupported("fps", "Frame rate is unavailable", state) - } - } - return null - } - - private fun defaultsFor(caps: CameraCapabilities, previous: CameraSettings): CameraSettings { - val focusMode = when { - previous.focusMode in caps.focusModes -> previous.focusMode - FocusMode.Continuous in caps.focusModes -> FocusMode.Continuous - FocusMode.Single in caps.focusModes -> FocusMode.Single - else -> FocusMode.Manual - } - val stabilization = when { - previous.stabilizationMode in caps.stabilizationModes -> previous.stabilizationMode - StabilizationMode.Video in caps.stabilizationModes -> StabilizationMode.Video - StabilizationMode.Optical in caps.stabilizationModes -> StabilizationMode.Optical - else -> StabilizationMode.Off - } - return previous.copy( - exposureMode = if (previous.exposureMode == ExposureMode.Manual && !caps.manualSensor) ExposureMode.Auto else previous.exposureMode, - iso = previous.iso?.let { caps.isoRange?.clamp(it) }, - shutterNs = previous.shutterNs?.let { caps.shutterRangeNs?.clamp(it) }, - exposureCompensation = caps.exposureCompensationRange?.clamp(previous.exposureCompensation) ?: 0, - whiteBalanceMode = if (previous.whiteBalanceMode in caps.whiteBalanceModes) previous.whiteBalanceMode else WhiteBalanceMode.Auto, - whiteBalanceKelvin = if (caps.manualWhiteBalance) previous.whiteBalanceKelvin else null, - whiteBalanceTint = if (caps.manualWhiteBalance) previous.whiteBalanceTint else 0, - whiteBalanceLock = previous.whiteBalanceLock && caps.supportsAwbLock, - focusMode = focusMode, - focusDistanceDiopters = previous.focusDistanceDiopters?.let { caps.focusDistanceRange?.clamp(it) }, - zoomRatio = caps.zoomRange.clamp(previous.zoomRatio), - torch = previous.torch && caps.supportsTorch, - stabilizationMode = stabilization, - fps = previous.fps?.takeIf { fps -> caps.fpsRanges.any { fps in it.min..it.max } }, - ) - } - - private fun notifyListeners(value: CameraState) { - listeners.forEach { listener -> runCatching { listener(value) } } - } -} diff --git a/android/app/src/main/java/dev/openstream/app/camera/FocusCoordinateMapper.kt b/android/app/src/main/java/dev/openstream/app/camera/FocusCoordinateMapper.kt deleted file mode 100644 index a5335c3..0000000 --- a/android/app/src/main/java/dev/openstream/app/camera/FocusCoordinateMapper.kt +++ /dev/null @@ -1,50 +0,0 @@ -package dev.openstream.app.camera - -data class SensorRect(val left: Int, val top: Int, val right: Int, val bottom: Int) { - val width: Int get() = right - left - val height: Int get() = bottom - top -} - -object FocusCoordinateMapper { - fun mapToMeteringRegion( - normalizedX: Float, - normalizedY: Float, - activeArray: SensorRect, - cropRegion: SensorRect = activeArray, - rotationDegrees: Int = 0, - mirrored: Boolean = false, - regionFraction: Float = 0.08f, - ): SensorRect { - require(normalizedX.isFinite() && normalizedY.isFinite()) - require(normalizedX in 0f..1f && normalizedY in 0f..1f) - require(rotationDegrees.mod(90) == 0) - require(regionFraction > 0f && regionFraction <= 1f) - - val displayX = if (mirrored) 1f - normalizedX else normalizedX - val displayY = normalizedY - val rotation = rotationDegrees.mod(360) - val (sensorX, sensorY) = when (rotation) { - 0 -> displayX to displayY - 90 -> displayY to (1f - displayX) - 180 -> (1f - displayX) to (1f - displayY) - 270 -> (1f - displayY) to displayX - else -> error("Rotation must be a multiple of 90") - } - - val boundedCrop = SensorRect( - left = cropRegion.left.coerceIn(activeArray.left, activeArray.right - 1), - top = cropRegion.top.coerceIn(activeArray.top, activeArray.bottom - 1), - right = cropRegion.right.coerceIn(activeArray.left + 1, activeArray.right), - bottom = cropRegion.bottom.coerceIn(activeArray.top + 1, activeArray.bottom), - ) - val centerX = boundedCrop.left + (boundedCrop.width * sensorX).toInt() - val centerY = boundedCrop.top + (boundedCrop.height * sensorY).toInt() - val halfWidth = (boundedCrop.width * regionFraction / 2f).toInt().coerceAtLeast(1) - val halfHeight = (boundedCrop.height * regionFraction / 2f).toInt().coerceAtLeast(1) - val left = (centerX - halfWidth).coerceIn(boundedCrop.left, boundedCrop.right - 2) - val top = (centerY - halfHeight).coerceIn(boundedCrop.top, boundedCrop.bottom - 2) - val right = (centerX + halfWidth).coerceIn(left + 1, boundedCrop.right) - val bottom = (centerY + halfHeight).coerceIn(top + 1, boundedCrop.bottom) - return SensorRect(left, top, right, bottom) - } -} 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 24a6c6c..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 @@ -1,22 +1,8 @@ package dev.openstream.app.control import android.util.Log -import dev.openstream.app.camera.AuthorityMode import dev.openstream.app.camera.Camera2Controller -import dev.openstream.app.camera.CameraActor -import dev.openstream.app.camera.CameraCapabilities -import dev.openstream.app.camera.CameraControlResult import dev.openstream.app.camera.CameraLens -import dev.openstream.app.camera.CameraSettings -import dev.openstream.app.camera.CameraSettingsPatch -import dev.openstream.app.camera.CameraState -import dev.openstream.app.camera.CameraTelemetry -import dev.openstream.app.camera.ExposureMode -import dev.openstream.app.camera.FocusActionMode -import dev.openstream.app.camera.FocusMode -import dev.openstream.app.camera.StabilizationMode -import dev.openstream.app.camera.WhiteBalanceMode -import org.json.JSONArray import org.json.JSONObject import java.io.BufferedInputStream import java.io.OutputStreamWriter @@ -25,9 +11,19 @@ import java.net.ServerSocket import java.net.Socket import java.util.concurrent.atomic.AtomicBoolean -/** Lightweight HTTP/JSON control plane. V2 is authenticated; legacy routes are bootstrap-only. */ +/** + * Lightweight HTTP control server that accepts camera control commands from OBS. + * Runs on port 9001 by default. Provides endpoints: + * + * - POST /zoom {"value": 2.5} + * - POST /torch {"enabled": true} + * - POST /lens {"lens": "Back"} + * - POST /reserve {"sourceInstanceId": "...", "bitrateMbps": 50} + * - POST /release {"sourceInstanceId": "..."} + * - POST /identify {"label": "CAM B", "subtitle": "Close-up"} + * - GET /status Returns current camera state + */ class CameraControlServer( - private val pairingTokenStore: PairingTokenStore, private val port: Int = CONTROL_PORT, private val cameraProvider: () -> Camera2Controller, private val lensListProvider: () -> List, @@ -38,7 +34,6 @@ class CameraControlServer( private val onReserve: (String, String, Int?) -> Boolean, private val onRelease: (String) -> Boolean, private val onIdentify: (String, String) -> Unit, - private val onPaired: () -> Unit = {}, ) { private val running = AtomicBoolean(false) private var serverSocket: ServerSocket? = null @@ -46,7 +41,10 @@ class CameraControlServer( fun start() { if (!running.compareAndSet(false, true)) return - worker = Thread(::run, "OpenStreamControlServer").apply { isDaemon = true; start() } + worker = Thread(::run, "OpenStreamControlServer").apply { + isDaemon = true + start() + } } fun stop() { @@ -60,418 +58,215 @@ class CameraControlServer( try { val socket = ServerSocket(port) serverSocket = socket - socket.soTimeout = 1_000 + socket.soTimeout = 1000 Log.i(TAG, "Camera control server listening on port $port") + while (running.get()) { val client = try { socket.accept() - } catch (_: java.net.SocketTimeoutException) { + } catch (e: java.net.SocketTimeoutException) { continue - } catch (error: java.net.SocketException) { - if (running.get()) Log.w(TAG, "Socket error", error) + } catch (e: java.net.SocketException) { + if (running.get()) Log.w(TAG, "Socket error", e) break } handleClient(client) } - } catch (error: Exception) { - Log.e(TAG, "Control server error", error) + } catch (e: Exception) { + Log.e(TAG, "Control server error", e) } } private fun handleClient(client: Socket) { try { - client.soTimeout = 5_000 + client.soTimeout = 5000 val input = BufferedInputStream(client.getInputStream()) val writer = PrintWriter(OutputStreamWriter(client.getOutputStream(), Charsets.UTF_8), false) - val requestLine = readAsciiLine(input, MAX_REQUEST_LINE_BYTES) - ?: return sendResponse(writer, HttpResponse(400, errorJson("bad_request", "Missing request line"))) + + // Parse request line + val requestLine = readAsciiLine(input, MAX_REQUEST_LINE_BYTES) ?: return val parts = requestLine.split(" ") - if (parts.size < 2) return sendResponse(writer, HttpResponse(400, errorJson("bad_request", "Malformed request line"))) + if (parts.size < 2) { + sendResponse(writer, 400, """{"error":"bad request"}""") + return + } val method = parts[0] - val path = parts[1].substringBefore('?') + val path = parts[1] + + // Read headers to get Content-Length var contentLength = 0 var headerBytes = 0 - val headers = linkedMapOf() 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) { - return sendResponse(writer, HttpResponse(413, errorJson("headers_too_large", "Headers exceed limit"))) + sendResponse(writer, 413, """{"error":"headers too large"}""") + return } - val separator = line.indexOf(':') - if (separator > 0) { - val name = line.substring(0, separator).trim().lowercase() - val value = line.substring(separator + 1).trim() - headers[name] = value - if (name == "content-length") contentLength = value.toIntOrNull() ?: -1 + if (line.startsWith("Content-Length:", ignoreCase = true)) { + contentLength = line.substringAfter(":").trim().toIntOrNull() ?: -1 } line = readAsciiLine(input, MAX_HEADER_LINE_BYTES) } if (line == null || contentLength !in 0..MAX_BODY_BYTES) { - val tooLarge = contentLength > MAX_BODY_BYTES - return sendResponse( - writer, - HttpResponse( - if (tooLarge) 413 else 400, - errorJson(if (tooLarge) "request_too_large" else "bad_request", "Invalid request body length"), - ), - ) + 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 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 + val response = when { + method == "GET" && path == "/status" -> handleStatus() + method == "POST" && path == "/zoom" -> handleZoom(body) + method == "POST" && path == "/torch" -> handleTorch(body) + method == "POST" && path == "/lens" -> handleLens(body) + method == "POST" && path == "/reserve" -> handleReserve(body) + method == "POST" && path == "/release" -> handleRelease(body) + method == "POST" && path == "/identify" -> handleIdentify(body) + method == "OPTIONS" -> """{"ok":true}""" + else -> { + sendResponse(writer, 404, """{"error":"not found"}""") + return + } } - val body = if (contentLength == 0) "" else readBody(input, contentLength) - ?: return sendResponse(writer, HttpResponse(400, errorJson("incomplete_request", "Request body is incomplete"))) - val response = route(method, path, headers, body) - sendResponse(writer, response) - } catch (error: Exception) { - Log.w(TAG, "Error handling control request", error) + sendResponse(writer, 200, response) + } catch (e: Exception) { + Log.w(TAG, "Error handling control request", e) } finally { runCatching { client.close() } } } - private fun route(method: String, path: String, headers: Map, body: String): HttpResponse { - if (method == "OPTIONS") return HttpResponse(200, JSONObject().put("ok", true).toString()) - if (method == "POST" && path == "/v2/pair") return handleV2Pair(body) - if (path.startsWith("/v2/") && !pairingTokenStore.validateBearer(headers["authorization"])) { - return HttpResponse(401, errorJson("unauthorized", "A valid bearer token is required")) - } - if (!path.startsWith("/v2/") && pairingTokenStore.hasPairedAdministrator() && - !pairingTokenStore.validateBearer(headers["authorization"]) - ) { - return HttpResponse(401, errorJson("unauthorized", "This camera is paired; authenticate legacy requests")) - } - return try { - when { - method == "GET" && path == "/v2/capabilities" -> handleV2Capabilities() - method == "GET" && path == "/v2/state" -> HttpResponse(200, stateJson(cameraProvider().currentState()).toString()) - method == "POST" && path == "/v2/settings" -> handleV2Settings(body) - method == "POST" && path == "/v2/focus" -> handleV2Focus(body) - method == "POST" && path == "/v2/authority" -> handleV2Authority(body) - method == "POST" && path == "/v2/tally" -> handleV2Tally(body) - method == "GET" && path == "/status" -> HttpResponse(200, handleStatus()) - method == "POST" && path == "/zoom" -> HttpResponse(200, handleZoom(body)) - method == "POST" && path == "/torch" -> HttpResponse(200, handleTorch(body)) - method == "POST" && path == "/lens" -> HttpResponse(200, handleLens(body)) - method == "POST" && path == "/reserve" -> HttpResponse(200, handleReserve(body)) - method == "POST" && path == "/release" -> HttpResponse(200, handleRelease(body)) - method == "POST" && path == "/identify" -> HttpResponse(200, handleIdentify(body)) - else -> HttpResponse(404, errorJson("not_found", "Endpoint not found")) - } - } catch (error: IllegalArgumentException) { - HttpResponse(400, errorJson("invalid_request", error.message ?: "Invalid request")) - } catch (error: org.json.JSONException) { - HttpResponse(400, errorJson("invalid_json", error.message ?: "Invalid JSON")) + private fun sendResponse(writer: PrintWriter, code: Int, body: String) { + val status = when (code) { + 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: ${bodyBytes.size}\r\n") + writer.print("Connection: close\r\n") + writer.print("\r\n") + writer.print(body) + writer.flush() } - private fun handleV2Pair(body: String): HttpResponse { - val json = parseObject(body) - return when (val result = pairingTokenStore.pair( - sourceInstanceId = json.optString("sourceInstanceId"), - sourceName = json.optString("sourceName"), - suppliedCode = json.optString("pairingCode").takeIf { json.has("pairingCode") }, - )) { - is PairingTokenStore.PairingResult.Paired -> { - onPaired() - HttpResponse( - 200, - JSONObject().put("ok", true).put("token", result.token).put("protocolVersion", PROTOCOL_VERSION).toString(), - ) + /** 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) } - is PairingTokenStore.PairingResult.Invalid -> HttpResponse(400, errorJson("invalid_request", result.reason)) - PairingTokenStore.PairingResult.CodeRejected -> HttpResponse(401, errorJson("pairing_code_rejected", "Pairing code is invalid or expired")) + bytes.add(value.toByte()) } + return null } - private fun handleV2Capabilities(): HttpResponse { - val caps = cameraProvider().currentCapabilities() - ?: return HttpResponse(503, errorJson("camera_not_ready", "Camera capabilities are not available yet")) - return HttpResponse(200, capabilitiesJson(caps).toString()) - } - - private fun handleV2Settings(body: String): HttpResponse { - val json = parseObject(body) - val expectedRevision = requiredLong(json, "expectedRevision") - val settings = json.optJSONObject("settings") ?: throw IllegalArgumentException("settings object is required") - val patch = CameraSettingsPatch( - exposureMode = enumValue(settings, "exposureMode", ExposureMode::fromWire), - iso = optionalInt(settings, "iso"), - shutterNs = optionalLong(settings, "shutterNs"), - exposureCompensation = optionalInt(settings, "exposureCompensation"), - whiteBalanceMode = enumValue(settings, "whiteBalanceMode", WhiteBalanceMode::fromWire), - whiteBalanceKelvin = optionalInt(settings, "whiteBalanceKelvin"), - whiteBalanceTint = optionalInt(settings, "whiteBalanceTint"), - whiteBalanceLock = optionalBoolean(settings, "whiteBalanceLock"), - focusMode = enumValue(settings, "focusMode", FocusMode::fromWire), - focusDistanceDiopters = optionalFloat(settings, "focusDistanceDiopters"), - zoomRatio = optionalFloat(settings, "zoomRatio"), - torch = optionalBoolean(settings, "torch"), - stabilizationMode = enumValue(settings, "stabilizationMode", StabilizationMode::fromWire), - fps = optionalInt(settings, "fps"), - ) - return controlResponse(cameraProvider().applySettings(patch, expectedRevision, CameraActor.Obs)) - } - - private fun handleV2Focus(body: String): HttpResponse { - val json = parseObject(body) - val expectedRevision = requiredLong(json, "expectedRevision") - val x = requiredFloat(json, "x") - val y = requiredFloat(json, "y") - val modeText = json.optString("mode", FocusActionMode.Auto.wireValue) - val mode = FocusActionMode.fromWire(modeText) ?: throw IllegalArgumentException("Unsupported focus mode: $modeText") - return controlResponse(cameraProvider().focusAt(x, y, mode, expectedRevision, CameraActor.Obs)) - } - - private fun handleV2Authority(body: String): HttpResponse { - val json = parseObject(body) - val expectedRevision = requiredLong(json, "expectedRevision") - val modeText = json.optString("mode") - val mode = AuthorityMode.fromWire(modeText) ?: throw IllegalArgumentException("Unsupported authority mode: $modeText") - return controlResponse(cameraProvider().setAuthority(mode, expectedRevision, CameraActor.Obs)) - } - - private fun handleV2Tally(body: String): HttpResponse { - val json = parseObject(body) - if (!json.has("program") || !json.has("preview")) throw IllegalArgumentException("program and preview are required") - val state = cameraProvider().setTally(json.getBoolean("program"), json.getBoolean("preview")) - return HttpResponse(200, JSONObject().put("ok", true).put("state", stateJson(state)).toString()) - } - - private fun controlResponse(result: CameraControlResult): HttpResponse = when (result) { - is CameraControlResult.Applied -> HttpResponse(200, JSONObject().put("ok", true).put("state", stateJson(result.state)).toString()) - is CameraControlResult.Conflict -> HttpResponse(409, JSONObject().put("ok", false).put("error", "revision_conflict").put("state", stateJson(result.state)).toString()) - is CameraControlResult.Unsupported -> HttpResponse(422, controlError("unsupported", result.field, result.reason, result.state)) - is CameraControlResult.Invalid -> HttpResponse(400, controlError("invalid_request", result.field, result.reason, result.state)) - is CameraControlResult.Locked -> HttpResponse(423, JSONObject().put("ok", false).put("error", "obs_locked").put("state", stateJson(result.state)).toString()) - } - - private fun controlError(code: String, field: String, reason: String, state: CameraState): String = JSONObject() - .put("ok", false).put("error", code).put("field", field).put("message", reason).put("state", stateJson(state)).toString() - - private fun capabilitiesJson(caps: CameraCapabilities): JSONObject = JSONObject() - .put("protocolVersion", PROTOCOL_VERSION) - .put("cameraId", caps.cameraId) - .put("displayName", caps.displayName) - .put("lensFacing", caps.lensFacing) - .put("logicalMultiCamera", caps.logicalMultiCamera) - .put("physicalCameraIds", JSONArray(caps.physicalCameraIds)) - .put("manualSensor", caps.manualSensor) - .put("manualWhiteBalance", caps.manualWhiteBalance) - .put("supportsAwbLock", caps.supportsAwbLock) - .put("supportsTapFocus", caps.supportsTapFocus) - .put("supportsAeRegions", caps.supportsAeRegions) - .put("supportsTorch", caps.supportsTorch) - .put("supportsZoomRatio", caps.supportsZoomRatio) - .put("isoRange", caps.isoRange?.let { rangeJson(it.min, it.max) } ?: JSONObject.NULL) - .put("shutterRangeNs", caps.shutterRangeNs?.let { rangeJson(it.min, it.max) } ?: JSONObject.NULL) - .put("exposureCompensationRange", caps.exposureCompensationRange?.let { rangeJson(it.min, it.max) } ?: JSONObject.NULL) - .put("focusDistanceRange", caps.focusDistanceRange?.let { rangeJson(it.min, it.max) } ?: JSONObject.NULL) - .put("zoomRange", rangeJson(caps.zoomRange.min, caps.zoomRange.max)) - .put("fpsRanges", JSONArray(caps.fpsRanges.map { rangeJson(it.min, it.max) })) - .put("focusModes", JSONArray(caps.focusModes.map { it.wireValue }.sorted())) - .put("whiteBalanceModes", JSONArray(caps.whiteBalanceModes.map { it.wireValue }.sorted())) - .put("stabilizationModes", JSONArray(caps.stabilizationModes.map { it.wireValue }.sorted())) - - private fun stateJson(state: CameraState): JSONObject = JSONObject() - .put("protocolVersion", PROTOCOL_VERSION) - .put("revision", state.revision) - .put("lastActor", state.lastActor.wireValue) - .put("authority", state.authority.wireValue) - .put("tally", JSONObject().put("program", state.tally.program).put("preview", state.tally.preview)) - .put("settings", settingsJson(state.settings)) - .put("telemetry", telemetryJson(state.telemetry)) - - private fun settingsJson(value: CameraSettings): JSONObject = JSONObject() - .put("exposureMode", value.exposureMode.wireValue) - .putNullable("iso", value.iso) - .putNullable("shutterNs", value.shutterNs) - .put("exposureCompensation", value.exposureCompensation) - .put("whiteBalanceMode", value.whiteBalanceMode.wireValue) - .putNullable("whiteBalanceKelvin", value.whiteBalanceKelvin) - .put("whiteBalanceTint", value.whiteBalanceTint) - .put("whiteBalanceLock", value.whiteBalanceLock) - .put("focusMode", value.focusMode.wireValue) - .putNullable("focusDistanceDiopters", value.focusDistanceDiopters) - .put("zoomRatio", value.zoomRatio.toDouble()) - .put("torch", value.torch) - .put("stabilizationMode", value.stabilizationMode.wireValue) - .putNullable("fps", value.fps) - - private fun telemetryJson(value: CameraTelemetry): JSONObject = JSONObject() - .putNullable("actualIso", value.actualIso) - .putNullable("actualShutterNs", value.actualShutterNs) - .putNullable("actualFocusDistanceDiopters", value.actualFocusDistanceDiopters) - .put("actualZoomRatio", value.actualZoomRatio.toDouble()) - .putNullable("actualWhiteBalanceKelvin", value.actualWhiteBalanceKelvin) - .put("focusStatus", value.focusStatus.wireValue) - .put("aeState", value.aeState) - .put("awbState", value.awbState) - .put("frameNumber", value.frameNumber) - .put("timestampNs", value.timestampNs) - private fun handleStatus(): String { val camera = cameraProvider() - return JSONObject() + val json = JSONObject() .put("zoom", camera.zoomRatio.toDouble()) .put("zoomMin", camera.zoomRange.start.toDouble()) .put("zoomMax", camera.zoomRange.endInclusive.toDouble()) .put("currentLens", currentLensProvider().shortLabel) .put("availableLenses", lensListProvider().map { it.shortLabel }) .put("reservedBy", reservationProvider().orEmpty()) - .put("protocolVersion", PROTOCOL_VERSION) - .put("pairingRequired", true) - .toString() + return json.toString() } private fun handleZoom(body: String): String { - val applied = cameraProvider().setZoom(parseObject(body).getDouble("value").toFloat()) - return JSONObject().put("ok", true).put("zoom", applied.toDouble()).toString() + val json = JSONObject(body) + val value = json.getDouble("value").toFloat() + val applied = cameraProvider().setZoom(value) + return """{"ok":true,"zoom":$applied}""" } private fun handleTorch(body: String): String { - val enabled = parseObject(body).getBoolean("enabled") + val json = JSONObject(body) + val enabled = json.getBoolean("enabled") onToggleTorch(enabled) - return JSONObject().put("ok", true).put("torch", enabled).toString() + return """{"ok":true,"torch":$enabled}""" } private fun handleLens(body: String): String { - val lensLabel = parseObject(body).getString("lens") + val json = JSONObject(body) + val lensLabel = json.getString("lens") val available = lensListProvider() val target = available.firstOrNull { it.shortLabel == lensLabel } - ?: return JSONObject().put("error", "lens not found").put("available", available.map { it.shortLabel }).toString() + ?: return """{"error":"lens not found","available":${available.map { "\"${it.shortLabel}\"" }}}""" onSwitchLens(target) - return JSONObject().put("ok", true).put("lens", target.shortLabel).toString() + return """{"ok":true,"lens":"${target.shortLabel}"}""" } private fun handleReserve(body: String): String { - val json = parseObject(body) + val json = JSONObject(body) val sourceInstanceId = json.optString("sourceInstanceId").trim() - if (sourceInstanceId.isEmpty()) return errorJson("missing_source", "sourceInstanceId is required") + if (sourceInstanceId.isEmpty()) return """{"error":"missing sourceInstanceId"}""" val slotLabel = json.optString("slotLabel", "") val bitrateMbps = if (json.has("bitrateMbps")) json.optInt("bitrateMbps").coerceIn(1, 200) else null val accepted = onReserve(sourceInstanceId, slotLabel, bitrateMbps) - return JSONObject().put("ok", accepted).put("busy", !accepted) - .put("reservedBy", if (accepted) sourceInstanceId else reservationProvider().orEmpty()).toString() + return if (accepted) { + JSONObject() + .put("ok", true) + .put("reservedBy", sourceInstanceId) + .toString() + } else { + JSONObject() + .put("ok", false) + .put("busy", true) + .put("reservedBy", reservationProvider().orEmpty()) + .toString() + } } private fun handleRelease(body: String): String { - val sourceInstanceId = parseObject(body).optString("sourceInstanceId").trim() - if (sourceInstanceId.isEmpty()) return errorJson("missing_source", "sourceInstanceId is required") - return JSONObject().put("ok", onRelease(sourceInstanceId)).toString() + val sourceInstanceId = JSONObject(body).optString("sourceInstanceId").trim() + if (sourceInstanceId.isEmpty()) return """{"error":"missing sourceInstanceId"}""" + val released = onRelease(sourceInstanceId) + return """{"ok":$released}""" } private fun handleIdentify(body: String): String { - val json = parseObject(body) - onIdentify(json.optString("label", "CAM").ifBlank { "CAM" }, json.optString("subtitle", "")) - return JSONObject().put("ok", true).toString() - } - - private fun parseObject(body: String): JSONObject { - if (body.isBlank()) throw IllegalArgumentException("JSON body is required") - return JSONObject(body) - } - - private fun requiredLong(json: JSONObject, name: String): Long { - if (!json.has(name)) throw IllegalArgumentException("$name is required") - return json.getLong(name) - } - - private fun requiredInt(json: JSONObject, name: String): Int { - if (!json.has(name)) throw IllegalArgumentException("$name is required") - val value = json.getDouble(name) - if (!value.isFinite() || value < Int.MIN_VALUE || value > Int.MAX_VALUE || value != value.toInt().toDouble()) { - throw IllegalArgumentException("$name must be an integer") - } - return value.toInt() - } - - private fun requiredFloat(json: JSONObject, name: String): Float { - if (!json.has(name)) throw IllegalArgumentException("$name is required") - return json.getDouble(name).toFloat() - } - - private fun requiredFiniteFloat(json: JSONObject, name: String): Float { - val value = requiredFloat(json, name) - if (!value.isFinite()) throw IllegalArgumentException("$name must be finite") - return value + val json = JSONObject(body) + val label = json.optString("label", "CAM").ifBlank { "CAM" } + val subtitle = json.optString("subtitle", "") + onIdentify(label, subtitle) + return """{"ok":true}""" } - private fun optionalInt(json: JSONObject, name: String): Int? = if (json.has(name) && !json.isNull(name)) json.getInt(name) else null - private fun optionalLong(json: JSONObject, name: String): Long? = if (json.has(name) && !json.isNull(name)) json.getLong(name) else null - private fun optionalFloat(json: JSONObject, name: String): Float? = if (json.has(name) && !json.isNull(name)) json.getDouble(name).toFloat() else null - private fun optionalBoolean(json: JSONObject, name: String): Boolean? = if (json.has(name) && !json.isNull(name)) json.getBoolean(name) else null - - private fun enumValue(json: JSONObject, name: String, parser: (String) -> T?): T? { - if (!json.has(name) || json.isNull(name)) return null - val value = json.getString(name) - return parser(value) ?: throw IllegalArgumentException("Unsupported $name: $value") - } - - private fun rangeJson(min: Number, max: Number) = JSONObject().put("min", min).put("max", max) - private fun JSONObject.putNullable(name: String, value: Any?): JSONObject = put(name, value ?: JSONObject.NULL) - - private fun errorJson(code: String, message: String): String = JSONObject() - .put("ok", false).put("error", code).put("message", message).toString() - - private fun readBody(input: BufferedInputStream, contentLength: Int): String? { - val bytes = ByteArray(contentLength) - var offset = 0 - while (offset < bytes.size) { - val read = input.read(bytes, offset, bytes.size - offset) - if (read < 0) return null - offset += read - } - return String(bytes, Charsets.UTF_8) - } - - private fun sendResponse(writer: PrintWriter, response: HttpResponse) { - val status = when (response.code) { - 200 -> "OK" - 400 -> "Bad Request" - 401 -> "Unauthorized" - 404 -> "Not Found" - 409 -> "Conflict" - 413 -> "Payload Too Large" - 422 -> "Unprocessable Content" - 423 -> "Locked" - 503 -> "Service Unavailable" - else -> "Error" - } - val bytes = response.body.toByteArray(Charsets.UTF_8) - writer.print("HTTP/1.1 ${response.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, Authorization\r\n") - if (response.code == 401) writer.print("WWW-Authenticate: Bearer\r\n") - writer.print("Content-Length: ${bytes.size}\r\n") - writer.print("Connection: close\r\n\r\n") - writer.print(response.body) - writer.flush() - } - - 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 data class HttpResponse(val code: Int, val body: String) - companion object { private const val TAG = "OpenStreamControl" const val CONTROL_PORT = 9001 - const val PROTOCOL_VERSION = 2 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 = 16_384 + private const val MAX_BODY_BYTES = 8_192 } } diff --git a/android/app/src/main/java/dev/openstream/app/control/PairingTokenStore.kt b/android/app/src/main/java/dev/openstream/app/control/PairingTokenStore.kt deleted file mode 100644 index b6da293..0000000 --- a/android/app/src/main/java/dev/openstream/app/control/PairingTokenStore.kt +++ /dev/null @@ -1,77 +0,0 @@ -package dev.openstream.app.control - -import android.content.Context -import android.util.Base64 -import java.security.MessageDigest -import java.security.SecureRandom - -class PairingTokenStore(context: Context) { - private val preferences = context.applicationContext.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE) - private val random = SecureRandom() - - @Synchronized - fun currentPairingCode(): String { - val existing = preferences.getString(KEY_PAIRING_CODE, null) - if (!existing.isNullOrBlank()) return existing - return newPairingCode().also { code -> - preferences.edit().putString(KEY_PAIRING_CODE, code).apply() - } - } - - @Synchronized - fun pair(sourceInstanceId: String, sourceName: String, suppliedCode: String?): PairingResult { - if (sourceInstanceId.isBlank()) return PairingResult.Invalid("missing sourceInstanceId") - if (sourceName.isBlank()) return PairingResult.Invalid("missing sourceName") - val code = suppliedCode?.trim().orEmpty() - if (!constantTimeEquals(code, currentPairingCode())) return PairingResult.CodeRejected - - val token = ByteArray(TOKEN_BYTES).also(random::nextBytes).let { bytes -> - Base64.encodeToString(bytes, Base64.URL_SAFE or Base64.NO_WRAP or Base64.NO_PADDING) - } - preferences.edit() - .putString(KEY_TOKEN, token) - .putString(KEY_SOURCE_ID, sourceInstanceId.trim()) - .putString(KEY_SOURCE_NAME, sourceName.trim()) - .putString(KEY_PAIRING_CODE, newPairingCode()) - .apply() - return PairingResult.Paired(token) - } - - fun validateBearer(value: String?): Boolean { - val expected = preferences.getString(KEY_TOKEN, null) ?: return false - val supplied = value?.trim()?.takeIf { it.startsWith(BEARER_PREFIX, ignoreCase = true) } - ?.substring(BEARER_PREFIX.length) - ?.trim() - ?: return false - return constantTimeEquals(supplied, expected) - } - - fun hasPairedAdministrator(): Boolean = !preferences.getString(KEY_TOKEN, null).isNullOrBlank() - - fun streamPassphrase(): String? = preferences.getString(KEY_TOKEN, null)?.takeIf { it.isNotBlank() } - - fun pairedSourceName(): String? = preferences.getString(KEY_SOURCE_NAME, null) - - private fun newPairingCode(): String = (random.nextInt(900_000) + 100_000).toString() - - private fun constantTimeEquals(a: String, b: String): Boolean = MessageDigest.isEqual( - a.toByteArray(Charsets.UTF_8), - b.toByteArray(Charsets.UTF_8), - ) - - sealed interface PairingResult { - data class Paired(val token: String) : PairingResult - data class Invalid(val reason: String) : PairingResult - data object CodeRejected : PairingResult - } - - companion object { - private const val PREFS_NAME = "openstream_control_auth" - private const val KEY_PAIRING_CODE = "pairing_code" - private const val KEY_TOKEN = "bearer_token" - private const val KEY_SOURCE_ID = "source_instance_id" - private const val KEY_SOURCE_NAME = "source_name" - private const val TOKEN_BYTES = 32 - private const val BEARER_PREFIX = "Bearer " - } -} 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 d7845c6..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 @@ -13,8 +13,6 @@ import android.media.MediaRecorder import android.os.Build import android.os.Process import android.util.Log -import dev.openstream.app.audio.AudioLevel -import dev.openstream.app.audio.Pcm16AudioLevel import java.nio.ByteBuffer import kotlin.math.max @@ -26,9 +24,8 @@ class MediaCodecAudioEncoder( context: Context, private val sampleRate: Int = 48_000, private val channelCount: Int = 1, - private val bitrate: Int = 128_000, + private val bitrate: Int = 192_000, private val onEncodedAccessUnit: (EncodedAccessUnit) -> Unit, - private val onAudioLevel: (AudioLevel) -> Unit = {}, ) { private val context = context.applicationContext private var codec: MediaCodec? = null @@ -69,9 +66,7 @@ class MediaCodecAudioEncoder( sampleRate, channelConfig, AudioFormat.ENCODING_PCM_16BIT ) check(minBufferSize > 0) { "AudioRecord does not support $sampleRate Hz / $channelCount ch PCM16" } - // A large AudioRecord queue conceals capture stalls as audible latency. - // Keep enough buffering for scheduler jitter without banking 250 ms. - val bufferSize = max(minBufferSize * 2, bytesForDurationMs(80)) + val bufferSize = max(minBufferSize * 4, bytesForDurationMs(250)) val recorder = createRecorder(channelConfig, bufferSize) audioRecord = recorder @@ -82,7 +77,6 @@ class MediaCodecAudioEncoder( Process.setThreadPriority(Process.THREAD_PRIORITY_URGENT_AUDIO) val pcmBuffer = ByteArray(bytesForDurationMs(20)) var capturedSamples = 0L - var lastLevelCallbackNs = 0L val startPresentationTimeUs = System.nanoTime() / 1000 while (running) { val bytesRead = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) { @@ -92,15 +86,7 @@ class MediaCodecAudioEncoder( recorder.read(pcmBuffer, 0, pcmBuffer.size) } if (bytesRead > 0) { - if (!running) break - val nowNs = System.nanoTime() - if (nowNs - lastLevelCallbackNs >= LEVEL_CALLBACK_INTERVAL_NS) { - lastLevelCallbackNs = nowNs - onAudioLevel(Pcm16AudioLevel.measure(pcmBuffer, bytesRead)) - } val samplesRead = bytesRead / bytesPerSampleFrame() - // Reclaim output buffers before asking the codec for another PCM input slot. - drainEncoder(encoder) val inputIndex = encoder.dequeueInputBuffer(10_000) if (inputIndex >= 0) { val inputBuffer = encoder.getInputBuffer(inputIndex) @@ -112,7 +98,6 @@ class MediaCodecAudioEncoder( encoder.queueInputBuffer(inputIndex, 0, bytesRead, presentationTimeUs, 0) } } - // Keep timestamps aligned to the audio capture clock even if MediaCodec drops a buffer. capturedSamples += samplesRead drainEncoder(encoder) } else if (bytesRead < 0) { @@ -129,12 +114,11 @@ class MediaCodecAudioEncoder( fun stop() { running = false + captureThread?.join(500) + captureThread = null val recorder = audioRecord audioRecord = null - // READ_BLOCKING must be released before waiting for the capture thread. runCatching { recorder?.stop() } - captureThread?.join(500) - captureThread = null runCatching { recorder?.release() } val encoder = codec @@ -243,6 +227,5 @@ class MediaCodecAudioEncoder( companion object { private const val TAG = "OpenStreamAudioEncoder" private const val BYTES_PER_PCM16_SAMPLE = 2 - private const val LEVEL_CALLBACK_INTERVAL_NS = 33_000_000L } } diff --git a/android/app/src/main/java/dev/openstream/app/encoder/MediaCodecVideoEncoder.kt b/android/app/src/main/java/dev/openstream/app/encoder/MediaCodecVideoEncoder.kt index c8af54e..588cf43 100644 --- a/android/app/src/main/java/dev/openstream/app/encoder/MediaCodecVideoEncoder.kt +++ b/android/app/src/main/java/dev/openstream/app/encoder/MediaCodecVideoEncoder.kt @@ -6,7 +6,6 @@ import android.media.MediaCodecList import android.media.MediaFormat import android.os.Handler import android.os.HandlerThread -import android.os.Build import android.util.Log import android.view.Surface import java.io.ByteArrayOutputStream @@ -64,19 +63,9 @@ class MediaCodecVideoEncoder( setInteger(MediaFormat.KEY_FRAME_RATE, fps) setInteger(MediaFormat.KEY_I_FRAME_INTERVAL, keyframeIntervalSeconds) setInteger(MediaFormat.KEY_BITRATE_MODE, MediaCodecInfo.EncoderCapabilities.BITRATE_MODE_CBR) - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) { - // Tell hardware encoders to provision for real-time throughput. - setInteger(MediaFormat.KEY_PRIORITY, 0) - setFloat(MediaFormat.KEY_OPERATING_RATE, fps.toFloat()) - } if (android.os.Build.VERSION.SDK_INT >= android.os.Build.VERSION_CODES.O) { setInteger(MediaFormat.KEY_LATENCY, 0) } - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) { - // B-frames add presentation reordering and are undesirable for a - // live camera whose receiver is on the same local network. - setInteger(MediaFormat.KEY_MAX_B_FRAMES, 0) - } } encoder.configure(format, null, null, MediaCodec.CONFIGURE_FLAG_ENCODE) surface = encoder.createInputSurface() diff --git a/android/app/src/main/java/dev/openstream/app/monitoring/AudioLevelMeterView.kt b/android/app/src/main/java/dev/openstream/app/monitoring/AudioLevelMeterView.kt deleted file mode 100644 index e6b3776..0000000 --- a/android/app/src/main/java/dev/openstream/app/monitoring/AudioLevelMeterView.kt +++ /dev/null @@ -1,118 +0,0 @@ -package dev.openstream.app.monitoring - -import android.content.Context -import android.graphics.Canvas -import android.graphics.Color -import android.graphics.Paint -import android.util.AttributeSet -import android.view.View -import dev.openstream.app.audio.AudioLevel -import kotlin.math.max -import kotlin.math.roundToInt - -/** Compact microphone meter that shows the actual PCM level before AAC encoding. */ -class AudioLevelMeterView @JvmOverloads constructor( - context: Context, - attrs: AttributeSet? = null, -) : View(context, attrs) { - private val density = resources.displayMetrics.density - private val labelPaint = Paint(Paint.ANTI_ALIAS_FLAG).apply { - color = Color.WHITE - textSize = 11f * density - typeface = android.graphics.Typeface.create("sans-serif-medium", android.graphics.Typeface.NORMAL) - } - private val tickPaint = Paint(labelPaint).apply { - color = Color.argb(170, 255, 255, 255) - textSize = 9f * density - } - private val inactivePaint = Paint(Paint.ANTI_ALIAS_FLAG).apply { color = Color.rgb(36, 43, 52) } - private val peakPaint = Paint(Paint.ANTI_ALIAS_FLAG).apply { color = Color.WHITE } - private val backgroundPaint = Paint(Paint.ANTI_ALIAS_FLAG).apply { color = Color.argb(210, 4, 7, 11) } - private var displayedDbfs = AudioLevel.MIN_DBFS - private var displayedPeakDbfs = AudioLevel.MIN_DBFS - private var active = false - - init { - importantForAccessibility = IMPORTANT_FOR_ACCESSIBILITY_NO - minimumHeight = (56f * density).roundToInt() - } - - fun setAudioActive(value: Boolean) { - active = value - if (!value) { - displayedDbfs = AudioLevel.MIN_DBFS - displayedPeakDbfs = AudioLevel.MIN_DBFS - } - invalidate() - } - - fun setLevel(level: AudioLevel) { - active = true - displayedDbfs = smooth(displayedDbfs, level.rmsDbfs, attack = 0.72f, release = 0.18f) - displayedPeakDbfs = max(level.peakDbfs, displayedPeakDbfs - 1.25f) - invalidate() - } - - override fun onDraw(canvas: Canvas) { - super.onDraw(canvas) - canvas.drawRoundRect(0f, 0f, width.toFloat(), height.toFloat(), 8f * density, 8f * density, backgroundPaint) - - val labelWidth = 36f * density - val meterLeft = labelWidth - val meterRight = width - 10f * density - val meterTop = 10f * density - val meterBottom = height - 20f * density - val segmentGap = 2f * density - val segmentCount = 30 - val segmentWidth = (meterRight - meterLeft - segmentGap * (segmentCount - 1)) / segmentCount - val litSegments = if (active) levelToSegment(displayedDbfs, segmentCount) else 0 - - labelPaint.color = if (active) Color.WHITE else Color.rgb(150, 158, 168) - canvas.drawText("MIC", 9f * density, meterTop + 15f * density, labelPaint) - tickPaint.color = if (active) Color.argb(190, 255, 255, 255) else Color.rgb(130, 138, 148) - canvas.drawText(if (active) "${displayedDbfs.roundToInt()} dB" else "OFF", 9f * density, meterBottom, tickPaint) - - repeat(segmentCount) { index -> - val left = meterLeft + index * (segmentWidth + segmentGap) - val paint = if (index < litSegments) colorForSegment(index, segmentCount) else inactivePaint - canvas.drawRoundRect(left, meterTop, left + segmentWidth, meterBottom, density, density, paint) - } - - if (active) { - val peakX = meterLeft + levelToRatio(displayedPeakDbfs) * (meterRight - meterLeft) - canvas.drawRect(peakX - density, meterTop - density, peakX + density, meterBottom + density, peakPaint) - } - drawTicks(canvas, meterLeft, meterRight) - } - - private fun drawTicks(canvas: Canvas, left: Float, right: Float) { - listOf(-45, -30, -20, -10, -6, -3, 0).forEach { tick -> - val x = left + levelToRatio(tick.toFloat()) * (right - left) - val label = tick.toString() - canvas.drawText(label, x - tickPaint.measureText(label) / 2f, height - 5f * density, tickPaint) - } - } - - private fun colorForSegment(index: Int, count: Int): Paint = when { - index >= count * 28 / 30 -> RED_PAINT - index >= count * 24 / 30 -> YELLOW_PAINT - else -> GREEN_PAINT - } - - private fun levelToSegment(dbfs: Float, count: Int): Int = - (levelToRatio(dbfs) * count).roundToInt().coerceIn(0, count) - - private fun levelToRatio(dbfs: Float): Float = - ((dbfs - AudioLevel.MIN_DBFS) / -AudioLevel.MIN_DBFS).coerceIn(0f, 1f) - - private fun smooth(current: Float, target: Float, attack: Float, release: Float): Float { - val factor = if (target > current) attack else release - return current + (target - current) * factor - } - - companion object { - private val GREEN_PAINT = Paint(Paint.ANTI_ALIAS_FLAG).apply { color = Color.rgb(54, 203, 103) } - private val YELLOW_PAINT = Paint(Paint.ANTI_ALIAS_FLAG).apply { color = Color.rgb(255, 204, 0) } - private val RED_PAINT = Paint(Paint.ANTI_ALIAS_FLAG).apply { color = Color.rgb(225, 61, 71) } - } -} diff --git a/android/app/src/main/java/dev/openstream/app/monitoring/MonitoringOverlayView.kt b/android/app/src/main/java/dev/openstream/app/monitoring/MonitoringOverlayView.kt deleted file mode 100644 index 78bd6cd..0000000 --- a/android/app/src/main/java/dev/openstream/app/monitoring/MonitoringOverlayView.kt +++ /dev/null @@ -1,118 +0,0 @@ -package dev.openstream.app.monitoring - -import android.content.Context -import android.graphics.Canvas -import android.graphics.Color -import android.graphics.Paint -import android.graphics.RectF -import android.util.AttributeSet -import android.view.View -import kotlin.math.ceil - -enum class FrameGuideMode { - Off, - Thirds, - SafeArea, -} - -class MonitoringOverlayView @JvmOverloads constructor( - context: Context, - attrs: AttributeSet? = null, -) : View(context, attrs) { - private val guidePaint = Paint(Paint.ANTI_ALIAS_FLAG).apply { - color = Color.argb(190, 255, 255, 255) - style = Paint.Style.STROKE - strokeWidth = resources.displayMetrics.density - } - private val guideShadowPaint = Paint(guidePaint).apply { - color = Color.argb(140, 0, 0, 0) - strokeWidth = 3f * resources.displayMetrics.density - } - private val zebraPaint = Paint().apply { - color = Color.argb(150, 255, 204, 0) - style = Paint.Style.FILL - } - - var frameGuideMode: FrameGuideMode = FrameGuideMode.Thirds - set(value) { - field = value - invalidate() - } - - var zebraEnabled: Boolean = false - set(value) { - field = value - if (!value) zebraMask = null - invalidate() - } - - private var zebraMask: BooleanArray? = null - private var maskWidth = 0 - private var maskHeight = 0 - - init { - importantForAccessibility = IMPORTANT_FOR_ACCESSIBILITY_NO - isClickable = false - isFocusable = false - } - - fun setZebraMask(width: Int, height: Int, mask: BooleanArray) { - if (width <= 0 || height <= 0 || mask.size != width * height) return - maskWidth = width - maskHeight = height - zebraMask = mask.copyOf() - invalidate() - } - - override fun onDraw(canvas: Canvas) { - super.onDraw(canvas) - if (zebraEnabled) drawZebras(canvas) - drawFrameGuides(canvas) - } - - private fun drawFrameGuides(canvas: Canvas) { - val lines = when (frameGuideMode) { - FrameGuideMode.Off -> return - FrameGuideMode.Thirds -> listOf( - floatArrayOf(width / 3f, 0f, width / 3f, height.toFloat()), - floatArrayOf(width * 2f / 3f, 0f, width * 2f / 3f, height.toFloat()), - floatArrayOf(0f, height / 3f, width.toFloat(), height / 3f), - floatArrayOf(0f, height * 2f / 3f, width.toFloat(), height * 2f / 3f), - ) - FrameGuideMode.SafeArea -> emptyList() - } - if (frameGuideMode == FrameGuideMode.SafeArea) { - val actionSafe = RectF(width * 0.05f, height * 0.05f, width * 0.95f, height * 0.95f) - val titleSafe = RectF(width * 0.10f, height * 0.10f, width * 0.90f, height * 0.90f) - listOf(actionSafe, titleSafe).forEach { rect -> - canvas.drawRect(rect, guideShadowPaint) - canvas.drawRect(rect, guidePaint) - } - return - } - lines.forEach { line -> - canvas.drawLine(line[0], line[1], line[2], line[3], guideShadowPaint) - canvas.drawLine(line[0], line[1], line[2], line[3], guidePaint) - } - } - - private fun drawZebras(canvas: Canvas) { - val mask = zebraMask ?: return - if (maskWidth == 0 || maskHeight == 0) return - val cellWidth = width.toFloat() / maskWidth - val cellHeight = height.toFloat() / maskHeight - val stripePeriod = 12f * resources.displayMetrics.density - for (y in 0 until maskHeight) { - for (x in 0 until maskWidth) { - if (!mask[y * maskWidth + x]) continue - val left = x * cellWidth - val top = y * cellHeight - val right = ceil((x + 1) * cellWidth.toDouble()).toFloat() - val bottom = ceil((y + 1) * cellHeight.toDouble()).toFloat() - if (((left + top) % stripePeriod) < stripePeriod / 2f) { - canvas.drawRect(left, top, right, bottom, zebraPaint) - } - } - } - } -} diff --git a/android/app/src/main/java/dev/openstream/app/monitoring/ZebraAnalyzer.kt b/android/app/src/main/java/dev/openstream/app/monitoring/ZebraAnalyzer.kt deleted file mode 100644 index f45f287..0000000 --- a/android/app/src/main/java/dev/openstream/app/monitoring/ZebraAnalyzer.kt +++ /dev/null @@ -1,21 +0,0 @@ -package dev.openstream.app.monitoring - -/** Pure luminance analysis used by the preview zebra overlay. */ -object ZebraAnalyzer { - fun analyze( - pixels: IntArray, - thresholdPercent: Int = 95, - ): BooleanArray { - require(thresholdPercent in 1..100) { "thresholdPercent must be between 1 and 100" } - val threshold = thresholdPercent * 255 / 100 - return BooleanArray(pixels.size) { index -> - val color = pixels[index] - val red = color shr 16 and 0xff - val green = color shr 8 and 0xff - val blue = color and 0xff - // Integer Rec. 709 luma. The coefficients sum to 256. - val luma = (54 * red + 183 * green + 19 * blue) shr 8 - luma >= threshold - } - } -} diff --git a/android/app/src/main/java/dev/openstream/app/service/OpenStreamCameraService.kt b/android/app/src/main/java/dev/openstream/app/service/OpenStreamCameraService.kt deleted file mode 100644 index 960955a..0000000 --- a/android/app/src/main/java/dev/openstream/app/service/OpenStreamCameraService.kt +++ /dev/null @@ -1,189 +0,0 @@ -package dev.openstream.app.service - -import android.app.Notification -import android.app.NotificationChannel -import android.app.NotificationManager -import android.app.PendingIntent -import android.app.Service -import android.content.Context -import android.content.Intent -import android.graphics.SurfaceTexture -import android.net.wifi.WifiManager -import android.os.Binder -import android.os.Build -import android.os.IBinder -import android.os.PowerManager -import android.view.Surface -import dev.openstream.app.MainActivity -import dev.openstream.app.R - -/** - * Lifetime owner for an explicitly armed unattended camera session. - * - * Android still requires the user to open and arm the app after process death, reboot or - * force-stop. This service deliberately uses START_NOT_STICKY and never cold-starts the camera. - */ -class OpenStreamCameraService : Service() { - private val binder = LocalBinder() - private lateinit var surfaceTexture: SurfaceTexture - private lateinit var headlessSurface: Surface - private var wakeLock: PowerManager.WakeLock? = null - private var wifiLock: WifiManager.WifiLock? = null - @Volatile private var armed = false - @Volatile private var sessionOwner: SessionOwner? = null - - override fun onCreate() { - super.onCreate() - surfaceTexture = SurfaceTexture(false).apply { setDefaultBufferSize(HEADLESS_WIDTH, HEADLESS_HEIGHT) } - headlessSurface = Surface(surfaceTexture) - createNotificationChannel() - } - - override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int { - when (intent?.action) { - ACTION_ARM -> armInternal() - ACTION_STOP -> { - sessionOwner?.onRemoteStop() - disarmInternal(stopService = true) - } - ACTION_DISARM -> disarmInternal(stopService = true) - } - return START_NOT_STICKY - } - - override fun onBind(intent: Intent?): IBinder = binder - - override fun onTaskRemoved(rootIntent: Intent?) { - // An armed session is intentionally allowed to continue when the UI task is dismissed. - if (!armed) stopSelf() - super.onTaskRemoved(rootIntent) - } - - override fun onDestroy() { - sessionOwner = null - disarmInternal(stopService = false) - headlessSurface.release() - surfaceTexture.release() - super.onDestroy() - } - - inner class LocalBinder : Binder() { - fun service(): OpenStreamCameraService = this@OpenStreamCameraService - } - - fun attachSession(owner: SessionOwner) { - sessionOwner = owner - owner.onHeadlessSurfaceAvailable(headlessSurface) - } - - fun detachSession(owner: SessionOwner) { - if (sessionOwner === owner) sessionOwner = null - } - - fun arm() = armInternal() - - fun disarm() = disarmInternal(stopService = true) - - fun isArmed(): Boolean = armed - - fun previewSurface(): Surface = headlessSurface - - private fun armInternal() { - if (armed) { - startForeground(NOTIFICATION_ID, buildNotification()) - return - } - armed = true - acquireLocks() - startForeground(NOTIFICATION_ID, buildNotification()) - } - - private fun disarmInternal(stopService: Boolean) { - armed = false - releaseLocks() - stopForeground(STOP_FOREGROUND_REMOVE) - if (stopService) stopSelf() - } - - private fun acquireLocks() { - if (wakeLock?.isHeld != true) { - wakeLock = getSystemService(PowerManager::class.java) - .newWakeLock(PowerManager.PARTIAL_WAKE_LOCK, WAKE_LOCK_TAG) - .apply { setReferenceCounted(false); acquire() } - } - if (wifiLock?.isHeld != true) { - @Suppress("DEPRECATION") - wifiLock = (applicationContext.getSystemService(Context.WIFI_SERVICE) as WifiManager) - .createWifiLock(WifiManager.WIFI_MODE_FULL_HIGH_PERF, WIFI_LOCK_TAG) - .apply { setReferenceCounted(false); acquire() } - } - } - - private fun releaseLocks() { - wakeLock?.let { if (it.isHeld) it.release() } - wifiLock?.let { if (it.isHeld) it.release() } - wakeLock = null - wifiLock = null - } - - private fun createNotificationChannel() { - if (Build.VERSION.SDK_INT < Build.VERSION_CODES.O) return - val channel = NotificationChannel( - NOTIFICATION_CHANNEL_ID, - getString(R.string.camera_service_channel_name), - NotificationManager.IMPORTANCE_LOW, - ).apply { - description = getString(R.string.camera_service_channel_description) - setShowBadge(false) - } - getSystemService(NotificationManager::class.java).createNotificationChannel(channel) - } - - private fun buildNotification(): Notification { - val showIntent = Intent(this, MainActivity::class.java).apply { - flags = Intent.FLAG_ACTIVITY_SINGLE_TOP or Intent.FLAG_ACTIVITY_CLEAR_TOP - } - val showPending = PendingIntent.getActivity( - this, - 0, - showIntent, - PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE, - ) - val stopIntent = Intent(this, OpenStreamCameraService::class.java).setAction(ACTION_STOP) - val stopPending = PendingIntent.getService( - this, - 1, - stopIntent, - PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE, - ) - return Notification.Builder(this, NOTIFICATION_CHANNEL_ID) - .setSmallIcon(R.mipmap.ic_launcher) - .setContentTitle(getString(R.string.camera_service_title)) - .setContentText(getString(R.string.camera_service_text)) - .setContentIntent(showPending) - .setOngoing(true) - .setCategory(Notification.CATEGORY_SERVICE) - .addAction(Notification.Action.Builder(null, getString(R.string.camera_service_stop), stopPending).build()) - .build() - } - - interface SessionOwner { - fun onHeadlessSurfaceAvailable(surface: Surface) - fun onRemoteStop() - } - - companion object { - const val ACTION_ARM = "dev.openstream.app.action.ARM_CAMERA" - const val ACTION_DISARM = "dev.openstream.app.action.DISARM_CAMERA" - const val ACTION_STOP = "dev.openstream.app.action.STOP_CAMERA" - const val NOTIFICATION_CHANNEL_ID = "openstream_camera_session" - const val NOTIFICATION_ID = 2001 - const val LOCAL_BINDER = "OpenStreamCameraService.LocalBinder" - private const val WAKE_LOCK_TAG = "OpenStream:CameraSession" - private const val WIFI_LOCK_TAG = "OpenStreamCameraSession" - private const val HEADLESS_WIDTH = 1_920 - private const val HEADLESS_HEIGHT = 1_080 - - fun armIntent(context: Context): Intent = Intent(context, OpenStreamCameraService::class.java).setAction(ACTION_ARM) - } -} 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 f4b91a0..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 @@ -34,34 +34,20 @@ class SrtStreamClient { private val sendFailures = AtomicLong() private val lastPresentationTimeUs = AtomicLong() - fun connect( - url: String, - codecMime: String, - width: Int, - height: Int, - fps: Int, - passphrase: String? = null, - ) { + fun connect(url: String, codecMime: String, width: Int, height: Int, fps: Int) { require(url.startsWith("srt://")) { "OpenStream V1 expects an SRT URL" } synchronized(operationLock) { establishSession("connection") { - SrtNativeBridge.connect(url, codecMime, width, height, fps, passphrase) + SrtNativeBridge.connect(url, codecMime, width, height, fps) } } } - fun listen( - url: String, - codecMime: String, - width: Int, - height: Int, - fps: Int, - passphrase: String? = null, - ) { + fun listen(url: String, codecMime: String, width: Int, height: Int, fps: Int) { require(url.startsWith("srt://")) { "OpenStream V2 expects an SRT URL" } synchronized(operationLock) { establishSession("listener") { - SrtNativeBridge.listen(url, codecMime, width, height, fps, passphrase) + SrtNativeBridge.listen(url, codecMime, width, height, fps) } } } @@ -144,8 +130,8 @@ private object SrtNativeBridge { System.loadLibrary("openstream_srt") } - external fun connect(url: String, codecMime: String, width: Int, height: Int, fps: Int, passphrase: String?): Boolean - external fun listen(url: String, codecMime: String, width: Int, height: Int, fps: Int, passphrase: String?): Boolean + external fun connect(url: String, codecMime: String, width: Int, height: Int, fps: Int): Boolean + external fun listen(url: String, codecMime: String, width: Int, height: Int, fps: Int): Boolean external fun sendVideo(data: ByteArray, presentationTimeUs: Long, flags: Int): Boolean external fun sendAudio(data: ByteArray, presentationTimeUs: Long, flags: Int): Boolean external fun disconnect() diff --git a/android/app/src/main/java/dev/openstream/app/telemetry/TelemetryFormatter.kt b/android/app/src/main/java/dev/openstream/app/telemetry/TelemetryFormatter.kt deleted file mode 100644 index db4fc3c..0000000 --- a/android/app/src/main/java/dev/openstream/app/telemetry/TelemetryFormatter.kt +++ /dev/null @@ -1,41 +0,0 @@ -package dev.openstream.app.telemetry - -data class HudTelemetry( - val battery: String, - val thermal: String, - val network: String, - val isBatteryLow: Boolean, - val isThermalWarning: Boolean, - val isNetworkWeak: Boolean, -) - -object TelemetryFormatter { - fun forHud(telemetry: DeviceTelemetry): HudTelemetry { - val signalLevel = telemetry.wifiRssi?.let(::wifiSignalLevel) - return HudTelemetry( - battery = telemetry.batteryPercent.takeIf { it in 0..100 } - ?.let { "BAT $it%" } - ?: "BAT --", - thermal = telemetry.temperatureCelsius?.let { - "${telemetry.thermalStatus} ${it.toInt()}°C" - } ?: telemetry.thermalStatus, - network = when { - telemetry.networkType == "WI-FI" && signalLevel != null -> "WI-FI $signalLevel/4" - else -> telemetry.networkType - }, - isBatteryLow = telemetry.batteryPercent in 0..15, - isThermalWarning = telemetry.thermalStatus in setOf( - "HOT", "SEVERE", "CRITICAL", "EMERGENCY", "SHUTDOWN", - ), - isNetworkWeak = telemetry.networkType == "OFFLINE" || signalLevel == 1, - ) - } - - /** Stable four-step RSSI mapping; avoids framework-version differences in calculateSignalLevel. */ - fun wifiSignalLevel(rssi: Int): Int = when { - rssi >= -55 -> 4 - rssi >= -67 -> 3 - rssi >= -75 -> 2 - else -> 1 - } -} diff --git a/android/app/src/main/java/dev/openstream/app/telemetry/TelemetrySampler.kt b/android/app/src/main/java/dev/openstream/app/telemetry/TelemetrySampler.kt index 88da06c..5a82076 100644 --- a/android/app/src/main/java/dev/openstream/app/telemetry/TelemetrySampler.kt +++ b/android/app/src/main/java/dev/openstream/app/telemetry/TelemetrySampler.kt @@ -1,13 +1,9 @@ package dev.openstream.app.telemetry import android.content.Context -import android.content.Intent -import android.content.IntentFilter -import android.net.ConnectivityManager -import android.net.NetworkCapabilities +import android.net.wifi.WifiManager import android.os.BatteryManager import android.os.Build -import android.os.PowerManager data class DeviceTelemetry( val deviceName: String, @@ -20,8 +16,6 @@ data class DeviceTelemetry( val batteryPercent: Int, val wifiRssi: Int?, val temperatureCelsius: Float?, - val thermalStatus: String, - val networkType: String, val encoderState: String, ) @@ -35,17 +29,7 @@ class TelemetrySampler(private val context: Context) { bitrate: Int, ): DeviceTelemetry { val battery = context.getSystemService(BatteryManager::class.java) - val connectivity = context.getSystemService(ConnectivityManager::class.java) - val power = context.getSystemService(PowerManager::class.java) - val batteryStatus = context.registerReceiver( - null, - IntentFilter(Intent.ACTION_BATTERY_CHANGED), - ) - val batteryPercent = battery.getIntProperty(BatteryManager.BATTERY_PROPERTY_CAPACITY) - .takeIf { it in 0..100 } - ?: -1 - val activeCapabilities = connectivity.activeNetwork - ?.let(connectivity::getNetworkCapabilities) + val wifi = context.applicationContext.getSystemService(WifiManager::class.java) return DeviceTelemetry( deviceName = "${Build.MANUFACTURER} ${Build.MODEL}", streamUrl = streamUrl, @@ -54,41 +38,10 @@ class TelemetrySampler(private val context: Context) { height = height, fps = fps, bitrate = bitrate, - batteryPercent = batteryPercent, - wifiRssi = if (activeCapabilities?.hasTransport(NetworkCapabilities.TRANSPORT_WIFI) == true) { - activeCapabilities.signalStrength.takeUnless { - it == NetworkCapabilities.SIGNAL_STRENGTH_UNSPECIFIED - } - } else { - null - }, - temperatureCelsius = batteryStatus - ?.getIntExtra(BatteryManager.EXTRA_TEMPERATURE, Int.MIN_VALUE) - ?.takeUnless { it == Int.MIN_VALUE } - ?.div(10f), - thermalStatus = thermalStatusLabel(power.currentThermalStatus), - networkType = networkType(activeCapabilities), + batteryPercent = battery.getIntProperty(BatteryManager.BATTERY_PROPERTY_CAPACITY), + wifiRssi = wifi.connectionInfo?.rssi, + temperatureCelsius = null, encoderState = "streaming", ) } - - private fun networkType(capabilities: NetworkCapabilities?): String = when { - capabilities == null -> "OFFLINE" - capabilities.hasTransport(NetworkCapabilities.TRANSPORT_WIFI) -> "WI-FI" - capabilities.hasTransport(NetworkCapabilities.TRANSPORT_CELLULAR) -> "CELL" - capabilities.hasTransport(NetworkCapabilities.TRANSPORT_ETHERNET) -> "ETHERNET" - capabilities.hasTransport(NetworkCapabilities.TRANSPORT_VPN) -> "VPN" - else -> "NETWORK" - } - - private fun thermalStatusLabel(status: Int): String = when (status) { - PowerManager.THERMAL_STATUS_NONE -> "NOMINAL" - PowerManager.THERMAL_STATUS_LIGHT -> "WARM" - PowerManager.THERMAL_STATUS_MODERATE -> "HOT" - PowerManager.THERMAL_STATUS_SEVERE -> "SEVERE" - PowerManager.THERMAL_STATUS_CRITICAL -> "CRITICAL" - PowerManager.THERMAL_STATUS_EMERGENCY -> "EMERGENCY" - PowerManager.THERMAL_STATUS_SHUTDOWN -> "SHUTDOWN" - else -> "UNKNOWN" - } } 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 98ee32f..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 @@ -10,14 +10,9 @@ import android.content.IntentFilter import android.net.Uri import android.os.Build import android.os.Environment -import android.os.Handler -import android.os.Looper import android.provider.Settings -import android.text.format.Formatter import android.util.Log import android.view.View -import android.view.WindowManager -import android.widget.ProgressBar import android.widget.TextView import android.widget.Toast import dev.openstream.app.R @@ -34,14 +29,10 @@ class AppUpdater( ) { private val executor = Executors.newSingleThreadExecutor() private val downloadManager = activity.getSystemService(DownloadManager::class.java) - private val uiHandler = Handler(Looper.getMainLooper()) - private val updatePrefs = activity.getSharedPreferences(UPDATE_PREFS_NAME, Context.MODE_PRIVATE) private var pendingDownloadId: Long = NO_DOWNLOAD private var pendingRelease: ReleaseUpdate? = null private var registered = false private var verifyingDownloadId: Long = NO_DOWNLOAD - private var updateDialog: Dialog? = null - private var downloadProgressRunnable: Runnable? = null private val disposed = AtomicBoolean(false) private val downloadReceiver = object : BroadcastReceiver() { @@ -52,7 +43,7 @@ class AppUpdater( NO_DOWNLOAD, ) if (completedId != pendingDownloadId) return - verifyDownloadedApk() + installDownloadedApk() } } @@ -66,7 +57,6 @@ class AppUpdater( activity.registerReceiver(downloadReceiver, filter) } registered = true - restorePendingDownload() } fun unregister() { @@ -78,18 +68,11 @@ class AppUpdater( fun dispose() { if (!disposed.compareAndSet(false, true)) return unregister() - stopProgressPolling() - updateDialog?.dismiss() - updateDialog = null executor.shutdownNow() } fun checkForUpdates(showAlreadyCurrent: Boolean = false) { if (disposed.get()) return - if (pendingDownloadId != NO_DOWNLOAD) { - resumePendingInstallIfAllowed() - return - } submitUpdateWork { val result = runCatching { fetchLatestRelease() } runWhenActivityIsActive { @@ -113,13 +96,8 @@ class AppUpdater( fun resumePendingInstallIfAllowed() { if (disposed.get() || pendingDownloadId == NO_DOWNLOAD) return - when (downloadSnapshot(pendingDownloadId)?.status) { - DownloadManager.STATUS_SUCCESSFUL -> verifyDownloadedApk() - DownloadManager.STATUS_PENDING, - DownloadManager.STATUS_RUNNING, - DownloadManager.STATUS_PAUSED -> pendingRelease?.let(::showDownloadProgress) - DownloadManager.STATUS_FAILED -> showDownloadFailure(pendingDownloadId) - null -> showDownloadFailure(pendingDownloadId) + if (canRequestPackageInstall()) { + installDownloadedApk() } } @@ -167,12 +145,12 @@ class AppUpdater( private fun downloadApk(release: ReleaseUpdate) { - if (disposed.get() || pendingDownloadId != NO_DOWNLOAD) return + if (disposed.get()) return val request = DownloadManager.Request(Uri.parse(release.apkUrl)) .setTitle("OpenStream ${release.displayVersion}") .setDescription("Downloading OpenStream update") .setMimeType(APK_MIME_TYPE) - .setNotificationVisibility(DownloadManager.Request.VISIBILITY_VISIBLE) + .setNotificationVisibility(DownloadManager.Request.VISIBILITY_VISIBLE_NOTIFY_COMPLETED) .setDestinationInExternalFilesDir( activity, Environment.DIRECTORY_DOWNLOADS, @@ -183,43 +161,38 @@ class AppUpdater( pendingRelease = release pendingDownloadId = downloadManager.enqueue(request) - persistPendingDownload(release, pendingDownloadId) - showDownloadProgress(release) + Toast.makeText(activity, "Downloading update", Toast.LENGTH_SHORT).show() } private fun showUpdatePrompt(release: ReleaseUpdate) { - val dialog = updateDialog ?: Dialog(activity, R.style.MinimalDialogTheme).also { updateDialog = it } + val dialog = Dialog(activity, R.style.MinimalDialogTheme) dialog.setContentView(R.layout.dialog_custom_update) dialog.setCancelable(true) - val title = dialog.findViewById(R.id.dialogUpdateTitle) val message = dialog.findViewById(R.id.dialogUpdateMessage) - val progress = dialog.findViewById(R.id.dialogUpdateProgress) - val progressText = dialog.findViewById(R.id.dialogUpdateProgressText) val actionBtn = dialog.findViewById(R.id.dialogUpdateAction) val dismissBtn = dialog.findViewById(R.id.dialogUpdateDismiss) - title.text = "Update available" - message.text = "OpenStream ${release.displayVersion} is ready to download. You can install it after the download is verified." - progress.visibility = View.GONE - progressText.visibility = View.GONE - actionBtn.visibility = View.VISIBLE - actionBtn.text = "Download" + message.text = "A new update (${release.displayVersion}) is available. Would you like to install it?" + actionBtn.text = "Install" dismissBtn.text = "Later" dismissBtn.visibility = View.VISIBLE actionBtn.setOnClickListener { + dialog.dismiss() downloadApk(release) } dismissBtn.setOnClickListener { dialog.dismiss() } - showDialog(dialog) + dialog.show() } - private fun verifyDownloadedApk() { + private fun installDownloadedApk() { if (!isSuccessfulDownload()) { - showDownloadFailure(pendingDownloadId) + Toast.makeText(activity, "Update download failed", Toast.LENGTH_LONG).show() + pendingDownloadId = NO_DOWNLOAD + pendingRelease = null return } @@ -241,178 +214,9 @@ class AppUpdater( showVerificationFailure(downloadId) return@runWhenActivityIsActive } - showInstallReadyPrompt(release, downloadId) - } - } - } - - private fun showDownloadProgress(release: ReleaseUpdate) { - val dialog = updateDialog ?: Dialog(activity, R.style.MinimalDialogTheme).also { updateDialog = it } - dialog.setContentView(R.layout.dialog_custom_update) - dialog.setCancelable(false) - dialog.findViewById(R.id.dialogUpdateTitle).text = "Downloading update" - dialog.findViewById(R.id.dialogUpdateMessage).text = - "Downloading OpenStream ${release.displayVersion}. Keep this screen open to follow progress." - dialog.findViewById(R.id.dialogUpdateProgress).apply { - visibility = View.VISIBLE - isIndeterminate = true - } - dialog.findViewById(R.id.dialogUpdateProgressText).apply { - visibility = View.VISIBLE - text = "Preparing download…" - } - dialog.findViewById(R.id.dialogUpdateAction).visibility = View.GONE - dialog.findViewById(R.id.dialogUpdateDismiss).visibility = View.GONE - showDialog(dialog) - startProgressPolling() - } - - private fun showInstallReadyPrompt(release: ReleaseUpdate, downloadId: Long) { - stopProgressPolling() - val dialog = updateDialog ?: Dialog(activity, R.style.MinimalDialogTheme).also { updateDialog = it } - dialog.setContentView(R.layout.dialog_custom_update) - dialog.setCancelable(true) - dialog.findViewById(R.id.dialogUpdateTitle).text = "Update ready" - dialog.findViewById(R.id.dialogUpdateMessage).text = - "OpenStream ${release.displayVersion} was downloaded and verified. Install it when you are ready." - dialog.findViewById(R.id.dialogUpdateProgress).visibility = View.GONE - dialog.findViewById(R.id.dialogUpdateProgressText).visibility = View.GONE - dialog.findViewById(R.id.dialogUpdateAction).apply { - visibility = View.VISIBLE - text = "Install update" - setOnClickListener { - dialog.dismiss() requestPackageInstall(downloadId) } } - dialog.findViewById(R.id.dialogUpdateDismiss).apply { - visibility = View.VISIBLE - text = "Later" - setOnClickListener { dialog.dismiss() } - } - showDialog(dialog) - } - - private fun startProgressPolling() { - stopProgressPolling() - val poll = object : Runnable { - override fun run() { - if (disposed.get() || pendingDownloadId == NO_DOWNLOAD) return - when (val snapshot = downloadSnapshot(pendingDownloadId)) { - null -> { - showDownloadFailure(pendingDownloadId) - return - } - else -> when (snapshot.status) { - DownloadManager.STATUS_SUCCESSFUL -> { - verifyDownloadedApk() - return - } - DownloadManager.STATUS_FAILED -> { - showDownloadFailure(pendingDownloadId) - return - } - else -> renderDownloadProgress(snapshot) - } - } - uiHandler.postDelayed(this, DOWNLOAD_PROGRESS_POLL_MS) - } - } - downloadProgressRunnable = poll - uiHandler.post(poll) - } - - private fun stopProgressPolling() { - downloadProgressRunnable?.let(uiHandler::removeCallbacks) - downloadProgressRunnable = null - } - - private fun renderDownloadProgress(snapshot: DownloadSnapshot) { - val dialog = updateDialog ?: return - val progress = dialog.findViewById(R.id.dialogUpdateProgress) ?: return - val progressText = dialog.findViewById(R.id.dialogUpdateProgressText) ?: return - val total = snapshot.totalBytes - progress.isIndeterminate = total <= 0L - if (total > 0L) { - progress.max = PROGRESS_MAX - progress.progress = ((snapshot.downloadedBytes * PROGRESS_MAX) / total) - .coerceIn(0L, PROGRESS_MAX.toLong()) - .toInt() - progressText.text = "${Formatter.formatFileSize(activity, snapshot.downloadedBytes)} of " + - "${Formatter.formatFileSize(activity, total)}" - } else { - progressText.text = "Downloading…" - } - } - - private fun downloadSnapshot(downloadId: Long): DownloadSnapshot? { - val query = DownloadManager.Query().setFilterById(downloadId) - return downloadManager.query(query)?.use { cursor -> - if (!cursor.moveToFirst()) return@use null - DownloadSnapshot( - status = cursor.getInt(cursor.getColumnIndexOrThrow(DownloadManager.COLUMN_STATUS)), - downloadedBytes = cursor.getLong( - cursor.getColumnIndexOrThrow(DownloadManager.COLUMN_BYTES_DOWNLOADED_SO_FAR), - ), - totalBytes = cursor.getLong( - cursor.getColumnIndexOrThrow(DownloadManager.COLUMN_TOTAL_SIZE_BYTES), - ), - ) - } - } - - private fun restorePendingDownload() { - if (pendingDownloadId != NO_DOWNLOAD) return - val downloadId = updatePrefs.getLong(PREF_DOWNLOAD_ID, NO_DOWNLOAD) - if (downloadId == NO_DOWNLOAD) return - val sha256 = updatePrefs.getString(PREF_RELEASE_SHA256, null) - if (sha256.isNullOrBlank()) { - clearPendingDownload() - return - } - val release = ReleaseUpdate( - tagName = updatePrefs.getString(PREF_RELEASE_TAG, "").orEmpty(), - name = updatePrefs.getString(PREF_RELEASE_NAME, "").orEmpty(), - versionCode = updatePrefs.getLong(PREF_RELEASE_VERSION_CODE, 0L).takeIf { it > 0L }, - apkUrl = updatePrefs.getString(PREF_RELEASE_URL, "").orEmpty(), - apkSha256 = sha256, - ) - if (!release.isNewerThan(currentVersionName(), currentVersionCode())) { - clearPendingDownload() - return - } - pendingDownloadId = downloadId - pendingRelease = release - when (downloadSnapshot(downloadId)?.status) { - DownloadManager.STATUS_PENDING, - DownloadManager.STATUS_RUNNING, - DownloadManager.STATUS_PAUSED -> showDownloadProgress(pendingRelease!!) - DownloadManager.STATUS_SUCCESSFUL -> verifyDownloadedApk() - else -> showDownloadFailure(downloadId) - } - } - - private fun persistPendingDownload(release: ReleaseUpdate, downloadId: Long) { - updatePrefs.edit() - .putLong(PREF_DOWNLOAD_ID, downloadId) - .putString(PREF_RELEASE_TAG, release.tagName) - .putString(PREF_RELEASE_NAME, release.name) - .putLong(PREF_RELEASE_VERSION_CODE, release.versionCode ?: 0L) - .putString(PREF_RELEASE_URL, release.apkUrl) - .putString(PREF_RELEASE_SHA256, release.apkSha256) - .apply() - } - - private fun clearPendingDownload() { - pendingDownloadId = NO_DOWNLOAD - pendingRelease = null - updatePrefs.edit().clear().apply() - } - - private fun showDialog(dialog: Dialog) { - if (!dialog.isShowing) dialog.show() - val width = (activity.resources.displayMetrics.widthPixels * DIALOG_WIDTH_FRACTION).toInt() - dialog.window?.setLayout(width, WindowManager.LayoutParams.WRAP_CONTENT) } private fun requestPackageInstall(downloadId: Long) { @@ -435,23 +239,14 @@ class AppUpdater( } private fun showVerificationFailure(downloadId: Long) { - showUpdateFailure(downloadId, "Update verification failed") - } - - private fun showDownloadFailure(downloadId: Long) { - showUpdateFailure(downloadId, "Update download failed") - } - - private fun showUpdateFailure(downloadId: Long, message: String) { - stopProgressPolling() - updateDialog?.dismiss() - Toast.makeText(activity, message, Toast.LENGTH_LONG).show() + 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) { - clearPendingDownload() + pendingDownloadId = NO_DOWNLOAD + pendingRelease = null } } @@ -515,7 +310,7 @@ class AppUpdater( activity.startActivity(intent) } dismissBtn.setOnClickListener { dialog.dismiss() } - showDialog(dialog) + dialog.show() } private fun canRequestPackageInstall(): Boolean { @@ -555,12 +350,6 @@ class AppUpdater( } } - private data class DownloadSnapshot( - val status: Int, - val downloadedBytes: Long, - val totalBytes: Long, - ) - companion object { private const val TAG = "OpenStreamUpdater" private const val NO_DOWNLOAD = -1L @@ -568,16 +357,6 @@ class AppUpdater( 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 const val DOWNLOAD_PROGRESS_POLL_MS = 500L - private const val PROGRESS_MAX = 1_000 - private const val DIALOG_WIDTH_FRACTION = 0.92f - private const val UPDATE_PREFS_NAME = "openstream_update" - private const val PREF_DOWNLOAD_ID = "download_id" - private const val PREF_RELEASE_TAG = "release_tag" - private const val PREF_RELEASE_NAME = "release_name" - private const val PREF_RELEASE_VERSION_CODE = "release_version_code" - private const val PREF_RELEASE_URL = "release_url" - private const val PREF_RELEASE_SHA256 = "release_sha256" private val SHA256_HEX = Regex("^[0-9a-f]{64}$") private fun compareVersions(candidate: String, current: String): Int { diff --git a/android/app/src/main/res/drawable/bg_camera_palette.xml b/android/app/src/main/res/drawable/bg_camera_palette.xml deleted file mode 100644 index 121a7fd..0000000 --- a/android/app/src/main/res/drawable/bg_camera_palette.xml +++ /dev/null @@ -1,6 +0,0 @@ - - - - - - diff --git a/android/app/src/main/res/drawable/bg_focus_reticle.xml b/android/app/src/main/res/drawable/bg_focus_reticle.xml deleted file mode 100644 index 99a6208..0000000 --- a/android/app/src/main/res/drawable/bg_focus_reticle.xml +++ /dev/null @@ -1,6 +0,0 @@ - - - - - - diff --git a/android/app/src/main/res/drawable/bg_hud_chip.xml b/android/app/src/main/res/drawable/bg_hud_chip.xml deleted file mode 100644 index 58fb54b..0000000 --- a/android/app/src/main/res/drawable/bg_hud_chip.xml +++ /dev/null @@ -1,6 +0,0 @@ - - - - - - diff --git a/android/app/src/main/res/layout/activity_main.xml b/android/app/src/main/res/layout/activity_main.xml index dd34ffd..173efb7 100644 --- a/android/app/src/main/res/layout/activity_main.xml +++ b/android/app/src/main/res/layout/activity_main.xml @@ -1,10 +1,12 @@ + + android:background="@color/os_black"> + - - - - + android:layout_gravity="center" /> + - + + android:paddingStart="@dimen/os_spacing_lg" + android:paddingEnd="@dimen/os_spacing_lg" + android:paddingTop="56dp" + android:paddingBottom="32dp"> - - + android:orientation="horizontal" + android:gravity="center_vertical"> + + android:fontFamily="sans-serif-medium" + android:layout_marginStart="8dp" /> - - + - - - + - - - - - - - - - - - - - - - - - - - - - + android:background="@drawable/bg_minimal_pill" + android:paddingStart="16dp" + android:paddingEnd="16dp" + android:paddingTop="8dp" + android:paddingBottom="8dp" + android:textColor="@color/os_text_primary" + android:textSize="14sp" + android:fontFamily="sans-serif-medium" + android:visibility="gone" /> + + + + android:background="@drawable/scrim_bottom" + android:paddingStart="@dimen/os_spacing_lg" + android:paddingEnd="@dimen/os_spacing_lg" + android:paddingTop="32dp" + android:paddingBottom="@dimen/os_spacing_xl" + android:fitsSystemWindows="true"> - + + android:orientation="vertical" + android:layout_marginBottom="@dimen/os_spacing_md"> - + - + + + + + android:scrollbars="none" + android:clipToPadding="false" + android:layout_marginBottom="@dimen/os_spacing_md"> - - - - - - + + + - - - - - + android:orientation="horizontal" + android:gravity="center_vertical" + android:layout_marginBottom="@dimen/os_spacing_lg"> + - - - - - - + + android:orientation="horizontal" + android:gravity="center_vertical"> + + android:textSize="12sp" + android:fontFamily="sans-serif-medium" + android:layout_marginEnd="@dimen/os_spacing_xs" /> + - - - - - - - - - - - - + android:textSize="12sp" + android:fontFamily="sans-serif-medium" + android:layout_marginEnd="@dimen/os_spacing_xs" /> - - - + + - - - + + - - - + + - + + + + + 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 213242f..3d49a93 100644 --- a/android/app/src/main/res/layout/activity_settings.xml +++ b/android/app/src/main/res/layout/activity_settings.xml @@ -10,190 +10,183 @@ android:layout_width="match_parent" android:layout_height="wrap_content" android:orientation="vertical" - android:paddingStart="28dp" - android:paddingTop="28dp" - android:paddingEnd="28dp" - android:paddingBottom="32dp"> + android:padding="24dp" + android:paddingTop="56dp"> - - - - -