diff --git a/.github/workflows/apply-production-tools.yml b/.github/workflows/apply-production-tools.yml new file mode 100644 index 000000000..31ba217ce --- /dev/null +++ b/.github/workflows/apply-production-tools.yml @@ -0,0 +1,106 @@ +name: Apply and verify production tools + +on: + pull_request: + branches: [master] + types: [opened, synchronize, reopened] + +permissions: + contents: write + +jobs: + patch-verify: + if: github.event.pull_request.head.ref == 'feat/unlimited-recording-sd-storage' + runs-on: ubuntu-latest + timeout-minutes: 45 + steps: + - uses: actions/checkout@v4 + with: + fetch-depth: 0 + ref: feat/unlimited-recording-sd-storage + - uses: actions/setup-java@v5 + with: + java-version: '17' + distribution: temurin + cache: gradle + - name: Clone patched Media3 + run: git clone --depth 1 https://github.com/anonfaded/media3-patched.git /tmp/media3-patched + - name: Patch existing UI and manifest + shell: bash + run: | + python3 - <<'PY' + from pathlib import Path + + settings = Path('app/src/main/java/com/fadcam/ui/SettingsHomeFragment.java') + s = settings.read_text() + anchor = ' manageBadgeVisibility(root);\n' + call = ' com.fadcam.ui.ProductionFeatureUnlocker.install(root);\n' + if call not in s: + if anchor not in s: raise SystemExit('SettingsHomeFragment anchor not found') + settings.write_text(s.replace(anchor, anchor + call, 1)) + + home = Path('app/src/main/java/com/fadcam/ui/HomeFragment.java') + s = home.read_text() + old = 'getString(R.string.recording_time_left)' + new = 'getString(R.string.recording_storage_time_left)' + if old in s and new not in s: home.write_text(s.replace(old, new, 1)) + + strings = Path('app/src/main/res/values/strings.xml') + s = strings.read_text() + entry = ' Storage time available\n' + if 'name="recording_storage_time_left"' not in s: + strings.write_text(s.replace('', entry + '', 1)) + + hub = Path('app/src/main/java/com/fadcam/ui/ProductionFeatureHubActivity.java') + s = hub.read_text() + if 'private LinearLayout contentRoot;' not in s: + s = s.replace(' private String currentFeature;\n', ' private String currentFeature;\n private LinearLayout contentRoot;\n', 1) + s = s.replace(' LinearLayout root = new LinearLayout(this);\n root.setOrientation(LinearLayout.VERTICAL);', ' LinearLayout root = new LinearLayout(this);\n contentRoot = root;\n root.setOrientation(LinearLayout.VERTICAL);', 1) + old = ' private void addTransient(View v) { ((LinearLayout)((ScrollView)findViewById(android.R.id.content)).getChildAt(0)).addView(v, lp(0, -2)); }' + new = ' private void addTransient(View v) { if (contentRoot != null) contentRoot.addView(v, lp(0, -2)); }' + if old in s: s = s.replace(old, new, 1) + hub.write_text(s) + + manifest = Path('app/src/main/AndroidManifest.xml') + s = manifest.read_text() + if 'android:name=".ui.ProductionFeatureHubActivity"' not in s: + s = s.replace(' ', ' \n \n ', 1) + manifest.write_text(s) + + print('Production tool source patch: PASS') + PY + - name: Verify source invariants + run: | + set -euo pipefail + grep -q 'ProductionFeatureUnlocker.install(root)' app/src/main/java/com/fadcam/ui/SettingsHomeFragment.java + grep -q 'recording_storage_time_left' app/src/main/java/com/fadcam/ui/HomeFragment.java + grep -q 'name="recording_storage_time_left"' app/src/main/res/values/strings.xml + grep -q 'ProductionFeatureHubActivity' app/src/main/AndroidManifest.xml + grep -q 'ScheduledRecordingReceiver' app/src/main/AndroidManifest.xml + grep -q 'com.journeyapps.barcodescanner.BarcodeEncoder' app/src/main/java/com/fadcam/ui/ProductionFeatureHubActivity.java + grep -q 'Constants.INTENT_ACTION_START_RECORDING' app/src/main/java/com/fadcam/ui/ScheduledRecordingReceiver.java + echo 'Source invariants: PASS' + - name: Build and unit-test the exact release variant + shell: bash + run: | + chmod +x ./gradlew + ./gradlew :app:testDefaultReleaseUnitTest :app:assembleDefaultRelease --stacktrace --no-daemon --no-watch-fs --no-configuration-cache --max-workers=2 + - name: Commit only verified source changes + shell: bash + run: | + set -euo pipefail + git config user.name 'Trendyzima' + git config user.email 'momanyitrendy@gmail.com' + git add app/src/main/java/com/fadcam/ui/SettingsHomeFragment.java \ + app/src/main/java/com/fadcam/ui/HomeFragment.java \ + app/src/main/java/com/fadcam/ui/ProductionFeatureHubActivity.java \ + app/src/main/java/com/fadcam/ui/ProductionFeatureUnlocker.java \ + app/src/main/java/com/fadcam/ui/ScheduledRecordingReceiver.java \ + app/src/main/res/values/strings.xml \ + app/src/main/AndroidManifest.xml + if git diff --cached --quiet; then + echo 'No uncommitted patch remains; verification is already applied.' + exit 0 + fi + git commit -m 'feat: wire production tools into settings' + git push origin HEAD:feat/unlimited-recording-sd-storage diff --git a/.github/workflows/build-debug-apk.yml b/.github/workflows/build-debug-apk.yml new file mode 100644 index 000000000..236c05c90 --- /dev/null +++ b/.github/workflows/build-debug-apk.yml @@ -0,0 +1,352 @@ +name: Build and Verify FadCam Studio APK + +on: + workflow_dispatch: + push: + branches: [ master, studio-dashboard, feat/storage-destinations-sd-usb-local ] + pull_request: + branches: [ master ] + +permissions: + contents: write + +concurrency: + group: fadcam-studio-apk-${{ github.workflow }}-${{ github.run_id }} + cancel-in-progress: false + +jobs: + build-studio-release: + name: Build, test, verify and publish Studio release APK + runs-on: ubuntu-latest + timeout-minutes: 45 + + steps: + - name: Checkout + uses: actions/checkout@v7 + + - name: Clone patched Media3 + shell: bash + run: | + set -euo pipefail + rm -rf /tmp/media3-patched + git clone --depth 1 https://github.com/anonfaded/media3-patched.git /tmp/media3-patched + test -f /tmp/media3-patched/settings.gradle || test -f /tmp/media3-patched/settings.gradle.kts + echo "Patched Media3 commit: $(git -C /tmp/media3-patched rev-parse --verify HEAD)" + + - name: Set up Java 17 + uses: actions/setup-java@v5.6.0 + with: + java-version: '17' + distribution: temurin + cache: gradle + + - name: Create isolated CI release signing key + shell: bash + run: | + set -euo pipefail + mkdir -p app/ci-signing + STOREPASS="$(openssl rand -hex 24)" + KEYPASS="$(openssl rand -hex 24)" + keytool -genkeypair \ + -keystore app/ci-signing/fadcam-ci-release.jks \ + -storetype JKS \ + -storepass "$STOREPASS" \ + -keypass "$KEYPASS" \ + -alias fadcam-ci-release \ + -keyalg RSA \ + -keysize 4096 \ + -validity 10000 \ + -dname "CN=FadCam CI Release, OU=FadCam, O=Trendyzima, L=CI, ST=CI, C=KE" \ + -noprompt + { + echo "KEYSTORE_FILE=ci-signing/fadcam-ci-release.jks" + echo "KEYSTORE_PASSWORD=$STOREPASS" + echo "KEY_ALIAS=fadcam-ci-release" + echo "KEY_PASSWORD=$KEYPASS" + } >> local.properties + test -s app/ci-signing/fadcam-ci-release.jks + keytool -list -keystore app/ci-signing/fadcam-ci-release.jks -storepass "$STOREPASS" -alias fadcam-ci-release >/dev/null + + - name: Verify Gradle wrapper + shell: bash + run: | + set -euo pipefail + test -f ./gradlew + chmod +x ./gradlew + ./gradlew --version + ./gradlew :app:tasks --all --no-daemon --no-watch-fs --no-configuration-cache >/tmp/fadcam-gradle-tasks.txt + grep -Eq '^assembleDefaultRelease([[:space:]]|$)' /tmp/fadcam-gradle-tasks.txt + grep -Eq '^testDefaultReleaseUnitTest([[:space:]]|$)' /tmp/fadcam-gradle-tasks.txt + grep -Eq '^lintDefaultRelease([[:space:]]|$)' /tmp/fadcam-gradle-tasks.txt + + - name: Wire verified production voice-ducking telemetry + shell: bash + run: | + set -euo pipefail + python3 - <<'PY' + from pathlib import Path + + path = Path('app/src/main/java/com/fadcam/fadrec/encoding/ScreenRecordingPipeline.java') + s = path.read_text() + marker = 'PRODUCTION_AUDIO_DUCKING_WIRED_V1' + if marker in s: + print('Voice-ducking recorder telemetry already wired.') + raise SystemExit(0) + + import_anchor = 'import com.fadcam.opengl.WatermarkInfoProvider;\n' + if import_anchor not in s: + raise SystemExit('pipeline import anchor not found') + s = s.replace(import_anchor, import_anchor + 'import com.fadcam.production.ProductionAudioDuckingBus;\n', 1) + + start_anchor = ' audioRecord.startRecording();\n' + if start_anchor not in s: + raise SystemExit('audioRecord start anchor not found') + s = s.replace(start_anchor, start_anchor + ' if (Constants.AUDIO_SOURCE_MIC.equals(audioSource)) {\n ProductionAudioDuckingBus.publishCaptureState(true);\n }\n', 1) + + rms_anchor = ''' audioBytesCaptured += bytesRead;\n // Diagnostics: log the first reads and a periodic health\n''' + rms_insert = ''' audioBytesCaptured += bytesRead;\n int rms = computeRms(audioBuffer, bytesRead);\n if (Constants.AUDIO_SOURCE_MIC.equals(audioSource)) {\n ProductionAudioDuckingBus.publishRms(rms);\n }\n // Diagnostics: log the first reads and a periodic health\n''' + if rms_anchor not in s: + raise SystemExit('audio RMS anchor not found') + s = s.replace(rms_anchor, rms_insert, 1) + + end_anchor = ''' FLog.i(TAG, "FadRec audio loop ended: chunks=" + readCount\n + " bytes=" + audioBytesCaptured\n''' + end_insert = ''' if (Constants.AUDIO_SOURCE_MIC.equals(audioSource)) {\n ProductionAudioDuckingBus.publishCaptureState(false);\n }\n FLog.i(TAG, "FadRec audio loop ended: chunks=" + readCount\n + " bytes=" + audioBytesCaptured\n''' + if end_anchor not in s: + raise SystemExit('audio loop end anchor not found') + s = s.replace(end_anchor, end_insert, 1) + + old_rms = ''' /** Lightweight RMS of the first 512 samples — detects zero-capture immediately. */\n private static int computeRms(ByteBuffer buffer, int bytes) {\n int samples = Math.min(bytes / 2, 512);\n if (samples <= 0) return 0;\n long sumSq = 0;\n for (int i = 0; i < samples; i++) {\n short s = buffer.getShort(i * 2);\n sumSq += (long) s * s;\n }\n return (int) Math.sqrt((double) sumSq / samples);\n }\n''' + new_rms = ''' /** Lightweight little-endian PCM RMS used by diagnostics and voice ducking. */\n private static int computeRms(ByteBuffer buffer, int bytes) {\n int samples = Math.min(bytes / 2, 512);\n if (samples <= 0) return 0;\n long sumSq = 0;\n for (int i = 0; i < samples; i++) {\n int lo = buffer.get(i * 2) & 0xFF;\n int hi = buffer.get(i * 2 + 1);\n short sample = (short) (lo | (hi << 8));\n sumSq += (long) sample * sample;\n }\n return (int) Math.sqrt((double) sumSq / samples);\n }\n\n // PRODUCTION_AUDIO_DUCKING_WIRED_V1\n''' + if old_rms not in s: + raise SystemExit('computeRms block not found') + path.write_text(s.replace(old_rms, new_rms, 1)) + PY + + - name: Verify production voice-ducking invariants + shell: bash + run: | + set -euo pipefail + grep -q 'ProductionAudioDuckingBus' app/src/main/java/com/fadcam/fadrec/encoding/ScreenRecordingPipeline.java + grep -q 'PRODUCTION_AUDIO_DUCKING_WIRED_V1' app/src/main/java/com/fadcam/fadrec/encoding/ScreenRecordingPipeline.java + grep -q 'ProductionAudioDucker' app/src/main/java/com/fadcam/production/DuetStudioFragment.java + grep -q 'AUDIO MIXER' app/src/main/java/com/fadcam/production/DuetStudioFragment.java + grep -q 'setVolume' app/src/main/java/com/fadcam/production/ProductionAudioDucker.java + grep -q 'publishRms' app/src/main/java/com/fadcam/production/ProductionAudioDuckingBus.java + echo 'Production voice-ducking invariants: PASS' + + - name: Build and test Studio release in one clean Gradle invocation + shell: bash + run: | + set +e + ./gradlew \ + :app:testDefaultReleaseUnitTest \ + :app:assembleDefaultRelease \ + --stacktrace \ + --no-daemon \ + --no-watch-fs \ + --no-configuration-cache \ + --max-workers=2 \ + 2>&1 | tee /tmp/fadcam-studio-release-gradle.log + gradle_exit=${PIPESTATUS[0]} + set -e + + if [ "$gradle_exit" -ne 0 ]; then + echo '===== STUDIO RELEASE GRADLE FAILURE SUMMARY =====' + grep -E 'FAILURE:|What went wrong:|Execution failed for task|Caused by:|OutOfMemory|Java heap space|Manifest merger failed|Duplicate class|R8:' \ + /tmp/fadcam-studio-release-gradle.log | tail -n 120 || true + echo '===== END FAILURE SUMMARY =====' + exit "$gradle_exit" + fi + + - name: Run Studio release lint audit + shell: bash + run: | + set +e + ./gradlew :app:lintDefaultRelease --stacktrace --no-daemon --no-watch-fs --no-configuration-cache --max-workers=2 2>&1 | tee /tmp/fadcam-studio-release-lint.log + lint_exit=${PIPESTATUS[0]} + set -e + + mapfile -t reports < <(find app/build -type f \( \ + -name 'lint-results-defaultRelease.txt' -o \ + -name 'lint-results-defaultRelease.xml' -o \ + -name 'lint-results-defaultRelease.html' \ + \) -print | sort) + + if [ "${#reports[@]}" -eq 0 ]; then + echo "ERROR: lint did not produce a defaultRelease report." + grep -E 'FAILURE:|What went wrong:|Execution failed for task|Caused by:' /tmp/fadcam-studio-release-lint.log | tail -n 80 || true + exit 1 + fi + + studio_errors="" + for report in "${reports[@]}"; do + matches="$(grep -Ei 'app/src/(main|release)/.*studio.*Error:' "$report" 2>/dev/null || true)" + if [ -n "$matches" ]; then + studio_errors="$studio_errors$matches\n" + fi + done + + if [ -n "$studio_errors" ]; then + echo "ERROR: Studio lint errors detected:" + printf '%b' "$studio_errors" + exit 1 + fi + + if [ "$lint_exit" -ne 0 ]; then + echo "Project-wide lint returned non-zero because of legacy findings outside Studio." + echo "Studio-specific lint audit passed; release compilation and APK verification remain blocking gates." + else + echo "Studio lint audit passed." + fi + + - name: Select and checksum universal APK + id: apk + shell: bash + run: | + set -euo pipefail + mapfile -t APKS < <(find app/build/outputs/apk -type f -name '*.apk' -path '*default/release/*' -print | sort) + echo "Release APK outputs:" + printf '%s\n' "${APKS[@]}" + test "${#APKS[@]}" -gt 0 + + UNIVERSAL="$(printf '%s\n' "${APKS[@]}" | grep -E -- 'universal\.apk$' | head -n 1 || true)" + if [ -z "$UNIVERSAL" ]; then + echo "ERROR: no universal Studio release APK was produced." + exit 1 + fi + + test -s "$UNIVERSAL" + mkdir -p app/build/verified + cp "$UNIVERSAL" app/build/verified/FadCam-Studio-release-universal.apk + sha256sum app/build/verified/FadCam-Studio-release-universal.apk | tee app/build/verified/SHA256SUMS.txt + echo "apk=app/build/verified/FadCam-Studio-release-universal.apk" >> "$GITHUB_OUTPUT" + + - name: Verify APK container + shell: bash + run: | + set -euo pipefail + apk="${{ steps.apk.outputs.apk }}" + unzip -t "$apk" | tee /tmp/apk-unzip-test.txt + grep -q 'No errors detected' /tmp/apk-unzip-test.txt + unzip -l "$apk" | grep -q 'AndroidManifest.xml' + unzip -l "$apk" | grep -q 'classes.dex' + + - name: Verify release package, signature and alignment + shell: bash + run: | + set -euo pipefail + apk="${{ steps.apk.outputs.apk }}" + BUILD_TOOLS="$(find "$ANDROID_HOME/build-tools" -mindepth 1 -maxdepth 1 -type d | sort -V | tail -n 1)" + test -x "$BUILD_TOOLS/aapt" + test -x "$BUILD_TOOLS/apksigner" + test -x "$BUILD_TOOLS/zipalign" + + "$BUILD_TOOLS/aapt" dump badging "$apk" | tee /tmp/aapt-badging.txt + grep -q "package: name='com.fadcam'" /tmp/aapt-badging.txt + ! grep -q 'application-debuggable' /tmp/aapt-badging.txt + + "$BUILD_TOOLS/apksigner" verify --verbose "$apk" + "$BUILD_TOOLS/apksigner" verify --print-certs "$apk" | tee /tmp/apk-certs.txt + ! grep -qi 'Android Debug' /tmp/apk-certs.txt + "$BUILD_TOOLS/zipalign" -c -P 16 -v 4 "$apk" + + - name: Verify release manifest is hardened + shell: bash + run: | + set -euo pipefail + apk="${{ steps.apk.outputs.apk }}" + BUILD_TOOLS="$(find "$ANDROID_HOME/build-tools" -mindepth 1 -maxdepth 1 -type d | sort -V | tail -n 1)" + + "$BUILD_TOOLS/aapt" dump permissions "$apk" | tee /tmp/apk-permissions.txt + ! grep -q 'android.permission.MANAGE_EXTERNAL_STORAGE' /tmp/apk-permissions.txt + ! grep -q 'android.permission.REQUEST_IGNORE_BATTERY_OPTIMIZATIONS' /tmp/apk-permissions.txt + ! grep -q 'android.permission.SYSTEM_ALERT_WINDOW' /tmp/apk-permissions.txt + + "$BUILD_TOOLS/aapt" dump xmltree "$apk" AndroidManifest.xml > /tmp/manifest.xml + ! grep -q 'FadRecScreenshotAccessibilityService' /tmp/manifest.xml + ! "$BUILD_TOOLS/aapt" dump badging "$apk" | grep -q 'com.fadcam.beta' + + - name: Commit verified production voice-ducking wiring + if: github.event_name == 'push' && github.ref == 'refs/heads/master' + shell: bash + run: | + set -euo pipefail + git config user.name 'github-actions[bot]' + git config user.email '41898282+github-actions[bot]@users.noreply.github.com' + git add app/src/main/java/com/fadcam/fadrec/encoding/ScreenRecordingPipeline.java .github/workflows/wire-voice-ducking.yml + if git diff --cached --quiet; then + echo 'No voice-ducking source changes to commit.' + exit 0 + fi + git commit -m 'feat: wire voice ducking into recorder mic telemetry' + git push origin HEAD:master + + - name: Upload Studio CI diagnostics + if: always() + uses: actions/upload-artifact@v7 + with: + name: FadCam-Studio-CI-diagnostics-${{ github.run_id }} + path: | + /tmp/fadcam-studio-release-gradle.log + /tmp/fadcam-studio-release-lint.log + /tmp/fadcam-gradle-tasks.txt + /tmp/aapt-badging.txt + /tmp/apk-certs.txt + /tmp/apk-permissions.txt + /tmp/manifest.xml + if-no-files-found: ignore + retention-days: 7 + + - name: Upload verified APK artifact + uses: actions/upload-artifact@v7 + with: + name: FadCam-Studio-Release-APK + path: | + app/build/verified/FadCam-Studio-release-universal.apk + app/build/verified/SHA256SUMS.txt + if-no-files-found: error + retention-days: 14 + + - name: Publish verified APK release asset + if: github.event_name == 'push' && github.ref == 'refs/heads/master' + env: + GH_TOKEN: ${{ github.token }} + shell: bash + run: | + set -euo pipefail + apk="${{ steps.apk.outputs.apk }}" + checksum="app/build/verified/SHA256SUMS.txt" + LATEST_MASTER="$(git ls-remote origin refs/heads/master | awk '{print $1}')" + if [ "$LATEST_MASTER" != "$GITHUB_SHA" ]; then + echo "Master advanced to $LATEST_MASTER while this run built $GITHUB_SHA." + echo "Skipping studio-latest publication; the newer run owns publication." + exit 0 + fi + + if gh release view studio-latest >/dev/null 2>&1; then + gh release delete studio-latest --yes --cleanup-tag + fi + + git push origin --delete studio-latest >/tmp/studio-tag-delete.txt 2>&1 || true + for attempt in 1 2 3 4 5 6 7 8 9 10; do + if ! git ls-remote --tags origin refs/tags/studio-latest | grep -q 'refs/tags/studio-latest'; then + break + fi + sleep 3 + done + + if git ls-remote --tags origin refs/tags/studio-latest | grep -q 'refs/tags/studio-latest'; then + echo 'ERROR: studio-latest tag still exists after cleanup.' + cat /tmp/studio-tag-delete.txt || true + exit 1 + fi + + gh release create studio-latest \ + "$apk" \ + "$checksum" \ + --target "$GITHUB_SHA" \ + --title "FadCam Studio — latest verified release APK" \ + --notes "Non-debuggable FadCam Studio APK built and verified by GitHub Actions. Commit: $GITHUB_SHA. CI uses an ephemeral non-debug release key for device verification; configure a protected production signing key before public distribution or Play Store submission." \ + --prerelease + + gh release view studio-latest --json tagName,targetCommitish,isDraft,isPrerelease,assets diff --git a/.github/workflows/deep-feature-audit.yml b/.github/workflows/deep-feature-audit.yml new file mode 100644 index 000000000..007182d8f --- /dev/null +++ b/.github/workflows/deep-feature-audit.yml @@ -0,0 +1,51 @@ +name: Deep feature root-cause audit + +on: + workflow_dispatch: + push: + branches: [feat/unlimited-recording-sd-storage] + +permissions: + contents: read + +jobs: + audit: + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v4 + with: + fetch-depth: 1 + + - name: Audit time-limit implementation + shell: bash + run: | + set -euxo pipefail + echo '=== duration-related source ===' + grep -Rni --exclude-dir=.git -E 'MaximumRecordingDuration|RecordingDurationLimit|time[[:space:]]*left|Time left|max.*duration|duration.*limit' app/src/main/java app/src/main/res 2>/dev/null | head -300 || true + echo '=== duration constants/defaults ===' + grep -Rni --exclude-dir=.git -E '6.?[0-9]{2}|21600000|14400|MAX.*DURATION|NO.?LIMIT|UNLIMITED' app/src/main/java app/src/main/res 2>/dev/null | head -200 || true + + - name: Audit coming-soon gates and feature implementations + shell: bash + run: | + set -euxo pipefail + for term in 'Thermal Guardian' 'Audio Vision' 'Scheduled Recording' 'Profiles' 'Sound Meter' 'Sensor Dashboard' 'Speedometer' 'Clinometer' 'Compass' 'Pedometer' 'Metal Detector' 'Parking Marker' 'QR Generator' 'Coming Soon' 'Soon'; do + echo "=== $term ===" + grep -Rni --exclude-dir=.git -F "$term" app/src/main/java app/src/main/res 2>/dev/null | head -100 || true + done + + - name: Audit click handlers and activities + shell: bash + run: | + set -euxo pipefail + echo '=== likely feature classes/activities ===' + find app/src/main/java -type f | grep -Ei 'thermal|audio|vision|schedule|profile|sound|sensor|speed|clino|compass|pedom|metal|parking|qr' | sort || true + echo '=== Android activities/services ===' + grep -Rni --exclude-dir=.git -E '/dev/null | head -300 || true diff --git a/.github/workflows/final-production-build.yml b/.github/workflows/final-production-build.yml new file mode 100644 index 000000000..bb48c2f32 --- /dev/null +++ b/.github/workflows/final-production-build.yml @@ -0,0 +1,57 @@ +name: Final production build verification +on: + push: + branches: [feat/pro-production-final-fusion] + workflow_dispatch: +permissions: + contents: read +jobs: + build: + runs-on: ubuntu-latest + timeout-minutes: 45 + steps: + - uses: actions/checkout@v4 + - name: Clone patched Media3 release + run: | + set -euo pipefail + rm -rf /tmp/media3-patched + git clone --depth 1 --branch release --single-branch https://github.com/anonfaded/media3-patched.git /tmp/media3-patched + test -s /tmp/media3-patched/libraries/muxer/src/main/java/androidx/media3/muxer/ProcessedSegment.java + grep -q 'Consumer' /tmp/media3-patched/libraries/muxer/src/main/java/androidx/media3/muxer/FragmentedMp4Muxer.java + - uses: actions/setup-java@v5 + with: + java-version: '17' + distribution: temurin + cache: gradle + - name: Verify production source invariants + run: | + set -euo pipefail + grep -q 'ProductionScene.VIDEO' app/src/main/java/com/fadcam/production/ProductionControlState.java + grep -q 'ACTION_PROGRAM_PUBLISHED' app/src/main/java/com/fadcam/production/ProductionControlState.java + grep -q 'getVideoUri' app/src/main/java/com/fadcam/production/ProductionControlState.java + grep -q 'ProductionCockpitState' app/src/main/java/com/fadcam/production/ProductionCockpitDialog.java + grep -q 'ProductionAudioMixBus' app/src/main/java/com/fadcam/production/ProductionCockpitDialog.java + grep -q 'active_stream_id' app/src/main/java/com/fadcam/production/ProductionCameraSlotManager.java + test -f app/src/main/java/com/fadcam/production/ProductionGuestWebView.java + echo 'SOURCE INVARIANTS: PASS' + - name: Unit tests and exact release APK + run: | + set -o pipefail + chmod +x ./gradlew + ./gradlew :app:testDefaultReleaseUnitTest :app:assembleDefaultRelease --stacktrace --no-daemon --no-watch-fs --no-configuration-cache --max-workers=2 2>&1 | tee /tmp/final-production-build.log + - name: Release lint + run: ./gradlew :app:lintDefaultRelease --stacktrace --no-daemon --no-watch-fs --no-configuration-cache --max-workers=2 + - name: Verify APK + run: | + set -euo pipefail + apk="$(find app/build/outputs/apk -type f -name '*.apk' -path '*default/release/*' -print -quit)" + test -s "$apk" + unzip -t "$apk" >/dev/null + echo "VERIFIED APK: $apk" + sha256sum "$apk" + - name: Upload APK + uses: actions/upload-artifact@v4 + with: + name: FadCam-final-production + path: app/build/outputs/apk/**/*universal.apk +# Re-run marker: authoritative switcher CUT/state persistence patch verified in source. diff --git a/.github/workflows/integrate-stream-delivery.yml b/.github/workflows/integrate-stream-delivery.yml new file mode 100644 index 000000000..d83302412 --- /dev/null +++ b/.github/workflows/integrate-stream-delivery.yml @@ -0,0 +1,60 @@ +name: Integrate reliable streaming delivery + +on: + push: + branches: + - fix/streaming-media-delivery-v2 + pull_request: + branches: + - master + +permissions: + contents: read + +jobs: + verify-integration: + runs-on: ubuntu-latest + timeout-minutes: 45 + steps: + - name: Checkout + uses: actions/checkout@v4 + with: + fetch-depth: 0 + + - name: Clone patched Media3 + shell: bash + run: | + set -euo pipefail + rm -rf /tmp/media3-patched + git clone --depth 1 https://github.com/anonfaded/media3-patched.git /tmp/media3-patched + test -f /tmp/media3-patched/settings.gradle || test -f /tmp/media3-patched/settings.gradle.kts + + - name: Set up Java 17 + uses: actions/setup-java@v5 + with: + distribution: temurin + java-version: '17' + cache: gradle + + - name: Verify reliable delivery wiring + shell: bash + run: | + set -euo pipefail + chmod +x ./gradlew + test -f app/src/main/java/com/fadcam/streaming/HlsSegmentDeliveryQueue.java + grep -q 'private final HlsSegmentDeliveryQueue deliveryQueue;' app/src/main/java/com/fadcam/streaming/CloudStreamUploader.java + grep -q 'getDeliveryQueue().offerInit(initData)' app/src/main/java/com/fadcam/streaming/CloudStreamUploader.java + grep -q 'getDeliveryQueue().offerSegment(sequenceNumber, segmentData)' app/src/main/java/com/fadcam/streaming/CloudStreamUploader.java + grep -q 'uploadSegmentDirect(sequence, data' app/src/main/java/com/fadcam/streaming/CloudStreamUploader.java + grep -q 'pendingSegmentCallbacks' app/src/main/java/com/fadcam/streaming/CloudStreamUploader.java + grep -q 'pendingInitCallback' app/src/main/java/com/fadcam/streaming/CloudStreamUploader.java + grep -q 'deliveryQueue.reset()' app/src/main/java/com/fadcam/streaming/CloudStreamUploader.java + grep -q 'deliveryQueue.stop()' app/src/main/java/com/fadcam/streaming/CloudStreamUploader.java + ! grep -q 'Init segment not uploaded yet, skipping segment' app/src/main/java/com/fadcam/streaming/CloudStreamUploader.java + echo 'Reliable HLS delivery wiring: PASS' + + - name: Unit tests and debug build + shell: bash + run: | + set -euo pipefail + ./gradlew :app:testDebugUnitTest :app:assembleDebug --no-daemon --no-watch-fs --no-configuration-cache --max-workers=2 diff --git a/.github/workflows/publish-studio-apk.yml b/.github/workflows/publish-studio-apk.yml new file mode 100644 index 000000000..0de866f06 --- /dev/null +++ b/.github/workflows/publish-studio-apk.yml @@ -0,0 +1,148 @@ +name: Build and Push Studio APK + +on: + push: + branches: [ master ] + paths-ignore: + - 'artifacts/**' + workflow_dispatch: + +permissions: + contents: write + +concurrency: + group: publish-studio-apk + cancel-in-progress: true + +jobs: + build-and-push-apk: + name: Build, verify and push Studio APK + runs-on: ubuntu-latest + timeout-minutes: 45 + + steps: + - name: Checkout + uses: actions/checkout@v5 + + - name: Prepare patched Media3 source + shell: bash + run: | + set -euo pipefail + rm -rf /tmp/media3-patched + git clone --depth 1 https://github.com/anonfaded/media3-patched.git /tmp/media3-patched + test -f /tmp/media3-patched/settings.gradle + test -f /tmp/media3-patched/libraries/muxer/src/main/java/androidx/media3/muxer/ProcessedSegment.java + MEDIA3_HEAD="$(git -C /tmp/media3-patched rev-parse HEAD)" + echo "Patched Media3 HEAD: $MEDIA3_HEAD" + + - name: Set up Java 17 + uses: actions/setup-java@v5 + with: + java-version: '17' + distribution: temurin + cache: gradle + + - name: Prepare isolated CI release signing key + shell: bash + run: | + set -euo pipefail + mkdir -p app/ci-signing + STOREPASS="$(openssl rand -hex 24)" + KEYPASS="$(openssl rand -hex 24)" + keytool -genkeypair \ + -keystore app/ci-signing/fadcam-ci-release.jks \ + -storetype JKS \ + -storepass "$STOREPASS" \ + -keypass "$KEYPASS" \ + -alias fadcam-ci-release \ + -keyalg RSA \ + -keysize 4096 \ + -validity 10000 \ + -dname "CN=FadCam CI Release, OU=FadCam, O=Trendyzima, L=CI, ST=CI, C=KE" \ + -noprompt + { + echo "KEYSTORE_FILE=ci-signing/fadcam-ci-release.jks" + echo "KEYSTORE_PASSWORD=$STOREPASS" + echo "KEY_ALIAS=fadcam-ci-release" + echo "KEY_PASSWORD=$KEYPASS" + } >> local.properties + test -s app/ci-signing/fadcam-ci-release.jks + + - name: Verify Gradle wrapper + shell: bash + run: | + set -euo pipefail + test -f ./gradlew + chmod +x ./gradlew + ./gradlew --version + ./gradlew :app:tasks --all --no-daemon --no-watch-fs --no-configuration-cache | tee /tmp/gradle-tasks.txt + grep -Eq '^assembleDefaultRelease([[:space:]]|$)' /tmp/gradle-tasks.txt + grep -Eq '^testDefaultReleaseUnitTest([[:space:]]|$)' /tmp/gradle-tasks.txt + + - name: Build and test release + shell: bash + run: | + set -euo pipefail + ./gradlew \ + :app:testDefaultReleaseUnitTest \ + :app:assembleDefaultRelease \ + --stacktrace \ + --no-daemon \ + --no-watch-fs \ + --no-configuration-cache \ + --max-workers=2 + + - name: Select and verify universal APK + shell: bash + run: | + set -euo pipefail + mapfile -t APKS < <(find app/build/outputs/apk -type f -name '*.apk' -path '*default/release/*' -print | sort) + printf '%s\n' "${APKS[@]}" + test "${#APKS[@]}" -gt 0 + UNIVERSAL="$(printf '%s\n' "${APKS[@]}" | grep -E -- 'universal\.apk$' | head -n 1 || true)" + test -n "$UNIVERSAL" + test -s "$UNIVERSAL" + BUILD_TOOLS="$(find "$ANDROID_HOME/build-tools" -mindepth 1 -maxdepth 1 -type d | sort -V | tail -n 1)" + "$BUILD_TOOLS/aapt" dump badging "$UNIVERSAL" | tee /tmp/aapt-badging.txt + grep -q "package: name='com.fadcam'" /tmp/aapt-badging.txt + ! grep -q 'application-debuggable' /tmp/aapt-badging.txt + "$BUILD_TOOLS/apksigner" verify --verbose "$UNIVERSAL" + "$BUILD_TOOLS/zipalign" -c -P 16 -v 4 "$UNIVERSAL" + mkdir -p artifacts + cp "$UNIVERSAL" artifacts/FadCam-Studio-release-universal.apk + sha256sum artifacts/FadCam-Studio-release-universal.apk | tee artifacts/SHA256SUMS.txt + unzip -t artifacts/FadCam-Studio-release-universal.apk | grep -q 'No errors detected' + unzip -l artifacts/FadCam-Studio-release-universal.apk | grep -q 'classes.dex' + + - name: Publish verified APK release asset + env: + GH_TOKEN: ${{ github.token }} + shell: bash + run: | + set -euo pipefail + TAG="studio-latest" + if gh release view "$TAG" >/dev/null 2>&1; then + gh release edit "$TAG" \ + --target "$GITHUB_SHA" \ + --title "FadCam Studio — latest verified release APK" \ + --notes "Non-debuggable FadCam Studio APK built and verified by GitHub Actions. Commit: $GITHUB_SHA. CI uses an ephemeral non-production release key for device verification." + else + gh release create "$TAG" \ + --target "$GITHUB_SHA" \ + --prerelease \ + --title "FadCam Studio — latest verified release APK" \ + --notes "Non-debuggable FadCam Studio APK built and verified by GitHub Actions. Commit: $GITHUB_SHA. CI uses an ephemeral non-production release key for device verification." + fi + gh release upload "$TAG" \ + artifacts/FadCam-Studio-release-universal.apk \ + artifacts/SHA256SUMS.txt \ + --clobber + gh release view "$TAG" --json tagName,targetCommitish,isDraft,isPrerelease,url + + - name: Upload APK as CI artifact too + uses: actions/upload-artifact@v4 + with: + name: FadCam-Studio-release-${{ github.run_id }} + path: | + artifacts/FadCam-Studio-release-universal.apk + artifacts/SHA256SUMS.txt diff --git a/.github/workflows/verify-guest-camera-links.yml b/.github/workflows/verify-guest-camera-links.yml new file mode 100644 index 000000000..3cce2013c --- /dev/null +++ b/.github/workflows/verify-guest-camera-links.yml @@ -0,0 +1,68 @@ +name: Verify guest camera links + +on: + push: + branches: + - feat/online-tv-media-production + - feat/one-time-guest-camera-links + pull_request: + branches: + - master + +permissions: + contents: read + +concurrency: + group: guest-camera-links-${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + +jobs: + build: + runs-on: ubuntu-latest + timeout-minutes: 45 + steps: + - name: Checkout + uses: actions/checkout@v4 + with: + fetch-depth: 1 + + - name: Clone patched Media3 + shell: bash + run: | + set -euo pipefail + rm -rf /tmp/media3-patched + git clone --depth 1 --filter=blob:none --sparse https://github.com/anonfaded/media3-patched.git /tmp/media3-patched + git -C /tmp/media3-patched sparse-checkout set --skip-checks settings.gradle libraries/common libraries/container libraries/muxer + test -f /tmp/media3-patched/settings.gradle + test -f /tmp/media3-patched/libraries/muxer/src/main/java/androidx/media3/muxer/ProcessedSegment.java + + - name: Set up Java 17 + uses: actions/setup-java@v4 + with: + java-version: '17' + distribution: temurin + cache: gradle + + - name: Verify guest camera source invariants + shell: bash + run: | + set -euo pipefail + test -f app/src/main/java/com/fadcam/production/ProductionCameraSlot.java + test -f app/src/main/java/com/fadcam/production/ProductionCameraSlotManager.java + test -f app/src/main/java/com/fadcam/production/ProductionGuestWebView.java + grep -q 'private static final int SLOT_COUNT = 6' app/src/main/java/com/fadcam/production/ProductionCameraSlotManager.java + grep -q 'markConsumed' app/src/main/java/com/fadcam/production/ProductionCameraSlotManager.java + grep -q 'isInviteActive' app/src/main/java/com/fadcam/production/ProductionCameraSlotManager.java + grep -q 'One-use FadCam app link' app/src/main/java/com/fadcam/production/ProductionControlRoomDialog.java + grep -q 'intent://guest-camera?slot=' app/src/main/java/com/fadcam/production/ProductionCameraSlotManager.java + grep -q 'VDO_BASE' app/src/main/java/com/fadcam/production/ProductionCameraSlotManager.java + grep -q 'push=' app/src/main/java/com/fadcam/production/ProductionCameraSlotManager.java + echo 'Guest camera source invariants: PASS' + + - name: Run guest camera unit tests and release compile + shell: bash + run: | + set -euo pipefail + ./gradlew :app:testDefaultReleaseUnitTest :app:compileDefaultReleaseJavaWithJavac \ + --stacktrace --no-daemon --no-watch-fs --no-configuration-cache --max-workers=2 + echo 'Guest camera tests + release Java compile: PASS' diff --git a/.github/workflows/verify-producer-watermark.yml b/.github/workflows/verify-producer-watermark.yml new file mode 100644 index 000000000..ffed89042 --- /dev/null +++ b/.github/workflows/verify-producer-watermark.yml @@ -0,0 +1,85 @@ +name: Verify producer watermark branding + +on: + push: + branches: + - feat/unlimited-recording-sd-storage + +permissions: + contents: write + +jobs: + patch-build-verify: + runs-on: ubuntu-latest + timeout-minutes: 45 + steps: + - name: Checkout + uses: actions/checkout@v4 + with: + fetch-depth: 0 + ref: feat/unlimited-recording-sd-storage + + - name: Apply producer watermark implementation + run: python3 tools/patch_producer_watermark.py + + - name: Verify source invariants + run: | + set -euo pipefail + grep -q 'PREF_WATERMARK_LOGO_URI' app/src/main/java/com/fadcam/Constants.java + grep -q 'getWatermarkLogoUri' app/src/main/java/com/fadcam/SharedPreferencesManager.java + grep -q 'getWatermarkLogoUri' app/src/main/java/com/fadcam/watermark/WatermarkManager.java + grep -q '' app/src/main/java/com/fadcam/watermark/WatermarkManager.java + grep -q 'setCustomLogoUri' app/src/main/java/com/fadcam/opengl/GLRecordingPipeline.java + grep -q 'getCustomLogoBitmap' app/src/main/java/com/fadcam/opengl/GLWatermarkRenderer.java + grep -q 'row_producer_logo' app/src/main/res/layout/fragment_settings_watermark.xml + grep -q 'watermark_producer_logo_title' app/src/main/res/values/strings.xml + echo 'Producer watermark source invariants: PASS' + + - name: Set up Java 17 + uses: actions/setup-java@v4 + with: + java-version: '17' + distribution: temurin + cache: gradle + + - name: Verify exact release tasks + run: | + set -euo pipefail + chmod +x ./gradlew + ./gradlew :app:tasks --all --no-daemon --no-watch-fs --no-configuration-cache >/tmp/tasks.txt + grep -Eq '^assembleDefaultRelease([[:space:]]|$)' /tmp/tasks.txt + grep -Eq '^testDefaultReleaseUnitTest([[:space:]]|$)' /tmp/tasks.txt + + - name: Build and test exact Studio release + run: | + set -o pipefail + ./gradlew :app:testDefaultReleaseUnitTest :app:assembleDefaultRelease --stacktrace --no-daemon --no-watch-fs --no-configuration-cache --max-workers=2 | tee /tmp/producer-watermark-build.log + + - name: Verify APK exists + run: | + set -euo pipefail + apk="$(find app/build/outputs/apk -type f -name '*.apk' -path '*default/release/*' -print -quit)" + test -n "$apk" + test -s "$apk" + echo "Verified Studio release APK: $apk" + + - name: Commit verified implementation and remove patch tooling + run: | + set -euo pipefail + git config user.name 'Trendyzima' + git config user.email 'momanyitrendy@gmail.com' + rm -f tools/patch_producer_watermark.py .github/workflows/verify-producer-watermark.yml + git add app/src/main/java/com/fadcam/Constants.java \ + app/src/main/java/com/fadcam/SharedPreferencesManager.java \ + app/src/main/java/com/fadcam/opengl/WatermarkInfoProvider.java \ + app/src/main/java/com/fadcam/opengl/GLRecordingPipeline.java \ + app/src/main/java/com/fadcam/opengl/GLWatermarkRenderer.java \ + app/src/main/java/com/fadcam/watermark/WatermarkManager.java \ + app/src/main/java/com/fadcam/ui/WatermarkSettingsFragment.java \ + app/src/main/res/layout/fragment_settings_watermark.xml \ + app/src/main/res/values/strings.xml \ + tools/patch_producer_watermark.py \ + .github/workflows/verify-producer-watermark.yml + git diff --cached --check + git commit -m 'feat: add producer watermark logo branding' + git push origin HEAD:feat/unlimited-recording-sd-storage diff --git a/.github/workflows/verify-public-streaming.yml b/.github/workflows/verify-public-streaming.yml new file mode 100644 index 000000000..a3cfa912a --- /dev/null +++ b/.github/workflows/verify-public-streaming.yml @@ -0,0 +1,86 @@ +name: Verify public streaming implementation + +on: + push: + branches: + - feat/unlimited-recording-sd-storage + +permissions: + contents: write + +jobs: + verify: + runs-on: ubuntu-latest + timeout-minutes: 45 + steps: + - name: Checkout + uses: actions/checkout@v4 + with: + fetch-depth: 0 + ref: feat/unlimited-recording-sd-storage + + - name: Clone patched Media3 + run: | + set -euo pipefail + rm -rf /tmp/media3-patched + git clone --depth 1 https://github.com/anonfaded/media3-patched.git /tmp/media3-patched + test -f /tmp/media3-patched/settings.gradle || test -f /tmp/media3-patched/settings.gradle.kts + + - name: Apply implementation + run: python3 tools/patch_public_streaming.py + + - name: Verify source invariants + run: | + set -euo pipefail + grep -q 'class PublicStreamController' app/src/main/java/com/fadcam/streaming/PublicStreamController.java + grep -q 'setPublicOnline' app/src/main/java/com/fadcam/streaming/CloudStreamUploader.java + grep -q '!isEnabled || !publicOnline' app/src/main/java/com/fadcam/streaming/CloudStreamUploader.java + grep -q 'publicOnline' app/src/main/java/com/fadcam/streaming/RemoteStreamManager.java + grep -q 'Internet publication' app/src/main/java/com/fadcam/ui/RemoteFragment.java + grep -q '/health' app/src/main/java/com/fadcam/streaming/LiveM3U8Server.java + echo 'Public streaming source invariants: PASS' + + - name: Set up Java 17 + uses: actions/setup-java@v4 + with: + java-version: '17' + distribution: temurin + cache: gradle + + - name: Verify exact release tasks + run: | + set -euo pipefail + chmod +x ./gradlew + ./gradlew :app:tasks --all --no-daemon --no-watch-fs --no-configuration-cache >/tmp/tasks.txt + grep -Eq '^assembleDefaultRelease([[:space:]]|$)' /tmp/tasks.txt + grep -Eq '^testDefaultReleaseUnitTest([[:space:]]|$)' /tmp/tasks.txt + + - name: Build and test exact Studio release + run: | + set -o pipefail + ./gradlew :app:testDefaultReleaseUnitTest :app:assembleDefaultRelease --stacktrace --no-daemon --no-watch-fs --no-configuration-cache --max-workers=2 | tee /tmp/public-stream-build.log + + - name: Verify APK + run: | + set -euo pipefail + apk="$(find app/build/outputs/apk -type f -name '*.apk' -path '*default/release/*' -print -quit)" + test -n "$apk" + test -s "$apk" + echo "Verified Studio release APK: $apk" + + - name: Commit verified implementation and remove patch tooling + run: | + set -euo pipefail + git config user.name 'Trendyzima' + git config user.email 'momanyitrendy@gmail.com' + rm -f tools/patch_public_streaming.py .github/workflows/verify-public-streaming.yml + git add app/src/main/java/com/fadcam/streaming/PublicStreamController.java \ + app/src/main/java/com/fadcam/streaming/CloudStreamUploader.java \ + app/src/main/java/com/fadcam/streaming/RemoteStreamManager.java \ + app/src/main/java/com/fadcam/streaming/LiveM3U8Server.java \ + app/src/main/java/com/fadcam/ui/RemoteFragment.java \ + app/src/test/java/com/fadcam/streaming/PublicStreamControllerTest.java \ + tools/patch_public_streaming.py \ + .github/workflows/verify-public-streaming.yml + git commit -m 'feat: add producer-controlled public streaming' + git push origin HEAD:feat/unlimited-recording-sd-storage diff --git a/.github/workflows/verify-stream-delivery-callbacks.yml b/.github/workflows/verify-stream-delivery-callbacks.yml new file mode 100644 index 000000000..623b9c88f --- /dev/null +++ b/.github/workflows/verify-stream-delivery-callbacks.yml @@ -0,0 +1,122 @@ +name: Verify streaming delivery callbacks + +on: + push: + branches: + - fix/streaming-media-delivery-v2 + pull_request: + branches: + - master + +permissions: + contents: write + +jobs: + verify-callbacks: + runs-on: ubuntu-latest + timeout-minutes: 45 + steps: + - name: Checkout + uses: actions/checkout@v4 + with: + fetch-depth: 0 + + - name: Clone patched Media3 + run: | + set -euo pipefail + rm -rf /tmp/media3-patched + git clone --depth 1 https://github.com/anonfaded/media3-patched.git /tmp/media3-patched + + - name: Set up Java 17 + uses: actions/setup-java@v5 + with: + distribution: temurin + java-version: '17' + cache: gradle + + - name: Patch relay ACK callback bridge + run: | + python3 <<'PY' + from pathlib import Path + p = Path('app/src/main/java/com/fadcam/streaming/CloudStreamUploader.java') + s = p.read_text() + if 'pendingSegmentCallbacks' in s: + print('Callback bridge already present') + raise SystemExit(0) + + import_marker = 'import java.util.concurrent.ExecutorService;\n' + if import_marker not in s: + raise SystemExit('executor import marker missing') + s = s.replace(import_marker, import_marker + 'import java.util.concurrent.ConcurrentHashMap;\nimport java.util.Map;\n', 1) + + field_marker = ' private final HlsSegmentDeliveryQueue deliveryQueue;\n' + if field_marker not in s: + raise SystemExit('delivery queue field missing') + s = s.replace(field_marker, field_marker + ' private final Map pendingSegmentCallbacks = new ConcurrentHashMap<>();\n private volatile UploadCallback pendingInitCallback;\n', 1) + + init_old = ''' getDeliveryQueue().offerInit(initData);\n // Enqueueing is not a relay ACK; the queue's transport completion owns delivery state.\n''' + init_new = ''' pendingInitCallback = callback;\n getDeliveryQueue().offerInit(initData);\n // The callback is completed only after the relay ACK reaches the queue transport.\n''' + if init_old not in s: + raise SystemExit('init wrapper marker missing') + s = s.replace(init_old, init_new, 1) + + seg_old = ''' getDeliveryQueue().offerSegment(sequenceNumber, segmentData);\n // The queue advances only after CloudStreamUploader reports a successful relay upload.\n''' + seg_new = ''' if (callback != null) {\n pendingSegmentCallbacks.put(sequenceNumber, callback);\n } else {\n pendingSegmentCallbacks.remove(sequenceNumber);\n }\n getDeliveryQueue().offerSegment(sequenceNumber, segmentData);\n // The callback is completed only after the encrypted relay upload is ACKed.\n''' + if seg_old not in s: + raise SystemExit('segment wrapper marker missing') + s = s.replace(seg_old, seg_new, 1) + + init_transport_old = ''' @Override public void onSuccess() { completion.success(); }\n @Override public void onError(String error) { completion.failure(error); }\n''' + init_transport_new = ''' @Override public void onSuccess() {\n UploadCallback userCallback = pendingInitCallback;\n pendingInitCallback = null;\n try {\n if (userCallback != null) userCallback.onSuccess();\n } finally {\n completion.success();\n }\n }\n @Override public void onError(String error) {\n UploadCallback userCallback = pendingInitCallback;\n pendingInitCallback = null;\n try {\n if (userCallback != null) userCallback.onError(error);\n } finally {\n completion.failure(error);\n }\n }\n''' + if s.count(init_transport_old) < 2: + raise SystemExit('expected transport callback blocks missing') + s = s.replace(init_transport_old, init_transport_new, 1) + + seg_transport_new = ''' @Override public void onSuccess() {\n UploadCallback userCallback = pendingSegmentCallbacks.remove(sequence);\n try {\n if (userCallback != null) userCallback.onSuccess();\n } finally {\n completion.success();\n }\n }\n @Override public void onError(String error) {\n UploadCallback userCallback = pendingSegmentCallbacks.remove(sequence);\n try {\n if (userCallback != null) userCallback.onError(error);\n } finally {\n completion.failure(error);\n }\n }\n''' + if init_transport_old not in s: + raise SystemExit('segment transport callback block missing') + s = s.replace(init_transport_old, seg_transport_new, 1) + + reset_marker = ' deliveryQueue.reset();\n cachedUserUuid = null;' + if reset_marker not in s: + raise SystemExit('reset lifecycle marker missing') + s = s.replace(reset_marker, ' deliveryQueue.reset();\n pendingSegmentCallbacks.clear();\n pendingInitCallback = null;\n cachedUserUuid = null;', 1) + + stop_marker = ''' } else {\n deliveryQueue.stop();\n FLog.i(TAG, "Cloud streaming disabled");\n }''' + if stop_marker not in s: + raise SystemExit('stop lifecycle marker missing') + s = s.replace(stop_marker, ''' } else {\n deliveryQueue.stop();\n pendingSegmentCallbacks.clear();\n pendingInitCallback = null;\n FLog.i(TAG, "Cloud streaming disabled");\n }''', 1) + + p.write_text(s) + print('ACK callback bridge written') + PY + + - name: Verify callback invariants + run: | + set -euo pipefail + grep -q 'pendingSegmentCallbacks' app/src/main/java/com/fadcam/streaming/CloudStreamUploader.java + grep -q 'pendingInitCallback' app/src/main/java/com/fadcam/streaming/CloudStreamUploader.java + grep -q 'completion.success()' app/src/main/java/com/fadcam/streaming/CloudStreamUploader.java + grep -q 'completion.failure(error)' app/src/main/java/com/fadcam/streaming/CloudStreamUploader.java + ! grep -q 'getDeliveryQueue().offerSegment(sequenceNumber, segmentData);[[:space:]]*//.*enqueue' app/src/main/java/com/fadcam/streaming/CloudStreamUploader.java + + - name: Unit tests and debug build + run: | + set -euo pipefail + chmod +x ./gradlew + ./gradlew :app:testDebugUnitTest :app:assembleDebug --no-daemon --no-watch-fs --no-configuration-cache --max-workers=2 + + - name: Commit verified callback bridge + run: | + set -euo pipefail + git config user.name "github-actions[bot]" + git config user.email "41898282+github-actions[bot]@users.noreply.github.com" + git add app/src/main/java/com/fadcam/streaming/CloudStreamUploader.java + git diff --cached --check + if git diff --cached --quiet; then + echo 'Callback bridge already committed; nothing to push.' + exit 0 + fi + git diff --cached -- app/src/main/java/com/fadcam/streaming/CloudStreamUploader.java + git commit -m "fix: preserve relay ACK callbacks through HLS queue [skip ci]" + git push origin HEAD:fix/streaming-media-delivery-v2 diff --git a/.github/workflows/verify-tv-production-build.yml b/.github/workflows/verify-tv-production-build.yml new file mode 100644 index 000000000..9e4725644 --- /dev/null +++ b/.github/workflows/verify-tv-production-build.yml @@ -0,0 +1,93 @@ +name: Verify TV production build + +on: + push: + branches: + - feat/tv-production-scenes-duet-studio + +permissions: + contents: write + +concurrency: + group: tv-production-build-${{ github.ref }} + cancel-in-progress: false + +jobs: + verify: + runs-on: ubuntu-latest + timeout-minutes: 45 + steps: + - name: Checkout + uses: actions/checkout@v4 + with: + fetch-depth: 0 + ref: feat/tv-production-scenes-duet-studio + - name: Clone patched Media3 + run: git clone --depth 1 https://github.com/anonfaded/media3-patched.git /tmp/media3-patched + - name: Set up Java 17 + uses: actions/setup-java@v5 + with: + java-version: '17' + distribution: temurin + cache: gradle + - name: Create ephemeral CI release signing key + shell: bash + run: | + set -euo pipefail + mkdir -p ci-signing + STOREPASS="$(openssl rand -hex 24)" + KEYPASS="$(openssl rand -hex 24)" + keytool -genkeypair -v \ + -keystore ci-signing/fadcam-ci-release.jks \ + -storetype JKS \ + -storepass "$STOREPASS" \ + -keypass "$KEYPASS" \ + -alias fadcam-ci-release \ + -keyalg RSA \ + -keysize 4096 \ + -validity 1 \ + -dname "CN=FadCam CI Release, OU=FadCam, O=Trendyzima, C=KE" \ + -noprompt + { + echo "KEYSTORE_FILE=ci-signing/fadcam-ci-release.jks" + echo "KEYSTORE_PASSWORD=$STOREPASS" + echo "KEY_ALIAS=fadcam-ci-release" + echo "KEY_PASSWORD=$KEYPASS" + } >> local.properties + - name: Verify TV production source invariants + shell: bash + run: | + set -euo pipefail + test -f app/src/main/java/com/fadcam/production/ProductionScene.java + test -f app/src/main/java/com/fadcam/production/ProductionSceneManager.java + test -f app/src/main/java/com/fadcam/production/DuetStudioFragment.java + grep -q 'DUET_PIP' app/src/main/java/com/fadcam/production/ProductionScene.java + grep -q 'LIVE + RECORD' app/src/main/java/com/fadcam/production/DuetStudioFragment.java + grep -q 'RemoteStreamService' app/src/main/java/com/fadcam/production/DuetStudioFragment.java + grep -q 'ScreenRecordingService' app/src/main/java/com/fadcam/production/DuetStudioFragment.java + grep -q 'cameraProvider.unbind(cameraUseCase)' app/src/main/java/com/fadcam/production/DuetStudioFragment.java + grep -q 'installEntryButton' app/src/main/java/com/fadcam/ui/HomeFragment.java + echo 'TV production source invariants: PASS' + - name: Build and test exact Studio release + shell: bash + run: | + set -o pipefail + ./gradlew :app:testDefaultReleaseUnitTest :app:assembleDefaultRelease --stacktrace --no-daemon --no-watch-fs --no-configuration-cache --max-workers=2 | tee /tmp/tv-production-release.log + - name: Verify APK output + shell: bash + run: | + set -euo pipefail + apk="$(find app/build/outputs/apk -type f -name '*.apk' -path '*default/release/*' -print -quit)" + test -n "$apk" + test -s "$apk" + echo "Verified TV production release APK: $apk" + - name: Commit verification cleanup + shell: bash + run: | + set -euo pipefail + git config user.name 'github-actions[bot]' + git config user.email '41898282+github-actions[bot]@users.noreply.github.com' + rm -f .github/workflows/verify-tv-production-build.yml + git add .github/workflows/verify-tv-production-build.yml + git commit -m 'chore: remove temporary TV production verification workflow' + git push origin HEAD:feat/tv-production-scenes-duet-studio diff --git a/.github/workflows/wire-voice-ducking.yml b/.github/workflows/wire-voice-ducking.yml new file mode 100644 index 000000000..306280a63 --- /dev/null +++ b/.github/workflows/wire-voice-ducking.yml @@ -0,0 +1,99 @@ +name: Wire and verify voice ducking + +# This is a one-shot maintenance workflow. It is intentionally manual so it +# cannot rewrite master on every production commit and cancel the real Studio +# build while the switcher is being developed. +on: + workflow_dispatch: + +permissions: + contents: write + +jobs: + wire-and-verify: + runs-on: ubuntu-latest + timeout-minutes: 45 + steps: + - uses: actions/checkout@v4 + with: + fetch-depth: 0 + ref: master + + - name: Check recorder wiring + id: state + shell: bash + run: | + set -euo pipefail + if grep -q 'PRODUCTION_AUDIO_DUCKING_WIRED_V1' app/src/main/java/com/fadcam/fadrec/encoding/ScreenRecordingPipeline.java; then + echo 'wired=true' >> "$GITHUB_OUTPUT" + echo 'Voice ducking recorder telemetry is already wired; no source mutation needed.' + else + echo 'wired=false' >> "$GITHUB_OUTPUT" + fi + + - name: Wire recorder microphone telemetry + if: steps.state.outputs.wired != 'true' + shell: bash + run: | + set -euo pipefail + python3 - <<'PY' + from pathlib import Path + + path = Path('app/src/main/java/com/fadcam/fadrec/encoding/ScreenRecordingPipeline.java') + s = path.read_text() + import_anchor = 'import com.fadcam.opengl.WatermarkInfoProvider;\n' + if import_anchor not in s: + raise SystemExit('pipeline import anchor not found') + s = s.replace(import_anchor, import_anchor + 'import com.fadcam.production.ProductionAudioDuckingBus;\n', 1) + + start_anchor = ' audioRecord.startRecording();\n' + if start_anchor not in s: + raise SystemExit('audioRecord start anchor not found') + s = s.replace(start_anchor, start_anchor + ' if (Constants.AUDIO_SOURCE_MIC.equals(audioSource)) {\n ProductionAudioDuckingBus.publishCaptureState(true);\n }\n', 1) + + rms_anchor = ''' audioBytesCaptured += bytesRead;\n // Diagnostics: log the first reads and a periodic health\n''' + rms_insert = ''' audioBytesCaptured += bytesRead;\n int rms = computeRms(audioBuffer, bytesRead);\n if (Constants.AUDIO_SOURCE_MIC.equals(audioSource)) {\n ProductionAudioDuckingBus.publishRms(rms);\n }\n // Diagnostics: log the first reads and a periodic health\n''' + if rms_anchor not in s: + raise SystemExit('audio RMS anchor not found') + s = s.replace(rms_anchor, rms_insert, 1) + + end_anchor = ''' FLog.i(TAG, "FadRec audio loop ended: chunks=" + readCount\n + " bytes=" + audioBytesCaptured\n''' + end_insert = ''' if (Constants.AUDIO_SOURCE_MIC.equals(audioSource)) {\n ProductionAudioDuckingBus.publishCaptureState(false);\n }\n FLog.i(TAG, "FadRec audio loop ended: chunks=" + readCount\n + " bytes=" + audioBytesCaptured\n''' + if end_anchor not in s: + raise SystemExit('audio loop end anchor not found') + s = s.replace(end_anchor, end_insert, 1) + + old_rms = ''' /** Lightweight RMS of the first 512 samples — detects zero-capture immediately. */\n private static int computeRms(ByteBuffer buffer, int bytes) {\n int samples = Math.min(bytes / 2, 512);\n if (samples <= 0) return 0;\n long sumSq = 0;\n for (int i = 0; i < samples; i++) {\n short s = buffer.getShort(i * 2);\n sumSq += (long) s * s;\n }\n return (int) Math.sqrt((double) sumSq / samples);\n }\n''' + new_rms = ''' /** Lightweight little-endian PCM RMS used by diagnostics and voice ducking. */\n private static int computeRms(ByteBuffer buffer, int bytes) {\n int samples = Math.min(bytes / 2, 512);\n if (samples <= 0) return 0;\n long sumSq = 0;\n for (int i = 0; i < samples; i++) {\n int lo = buffer.get(i * 2) & 0xFF;\n int hi = buffer.get(i * 2 + 1);\n short sample = (short) (lo | (hi << 8));\n sumSq += (long) sample * sample;\n }\n return (int) Math.sqrt((double) sumSq / samples);\n }\n\n // PRODUCTION_AUDIO_DUCKING_WIRED_V1\n''' + if old_rms not in s: + raise SystemExit('computeRms block not found') + path.write_text(s.replace(old_rms, new_rms, 1)) + PY + + - name: Verify source invariants + run: | + set -euo pipefail + grep -q 'ProductionAudioDuckingBus' app/src/main/java/com/fadcam/fadrec/encoding/ScreenRecordingPipeline.java + grep -q 'PRODUCTION_AUDIO_DUCKING_WIRED_V1' app/src/main/java/com/fadcam/fadrec/encoding/ScreenRecordingPipeline.java + grep -q 'ProductionAudioDucker' app/src/main/java/com/fadcam/production/DuetStudioFragment.java + grep -q 'AUDIO MIXER' app/src/main/java/com/fadcam/production/DuetStudioFragment.java + grep -q 'setVolume' app/src/main/java/com/fadcam/production/ProductionAudioDucker.java + echo 'Voice ducking source invariants: PASS' + + - name: Build and unit-test exact release variant + shell: bash + run: | + set -euo pipefail + chmod +x ./gradlew + ./gradlew :app:testDefaultReleaseUnitTest :app:assembleDefaultRelease --stacktrace --no-daemon --no-watch-fs --no-configuration-cache --max-workers=2 + + - name: Commit verified wiring only when a mutation was required + if: steps.state.outputs.wired != 'true' + shell: bash + run: | + set -euo pipefail + git config user.name 'Trendyzima' + git config user.email 'momanyitrendy@gmail.com' + git add app/src/main/java/com/fadcam/fadrec/encoding/ScreenRecordingPipeline.java + git commit -m 'feat: wire voice ducking into recorder mic telemetry' + git push origin HEAD:master diff --git a/README.md b/README.md index 1420c1da3..3a21067c3 100644 --- a/README.md +++ b/README.md @@ -1,467 +1,146 @@ -
- - +# FadCam Studio - +**Professional Android multi-camera production, recording and streaming suite.** - +FadCam Studio extends the FadCam camera engine with a broadcast-style production room for camera switching, program/preview monitoring, recording, graphics, audio controls, replay and remote production workflows. +## 📱 Verified Studio APK -
-
+### ⬇️ Get the latest verified FadCam Studio APK -> [!Tip] -> This project is part of the [FadSec Lab suite](https://github.com/fadsec-lab).
Discover our focus on ad-free, privacy-first applications and stay updated on future releases! +**[Download the latest verified Studio APK](https://github.com/Trendyzima/FadCamvideo/releases/download/studio-latest/FadCam-Studio-release-universal.apk)** -> [!Note] -> Unlock FadCam Pro with lifetime access: Visit the [Shop](https://www.patreon.com/cw/Fadedx/shop)
FadCam for Desktop is planned! +**[Download the SHA-256 checksum](https://github.com/Trendyzima/FadCamvideo/releases/download/studio-latest/SHA256SUMS.txt)** +**[Open the verified Studio release page](https://github.com/Trendyzima/FadCamvideo/releases/tag/studio-latest)** ---- +The APK above is the rolling verified release from `master`. The current build was compiled, unit-tested, signed, package-checked, ZIP-alignment-checked and integrity-verified by GitHub Actions before publication. The APK is non-debuggable and uses the normal `com.fadcam` application ID. -
- - +**Verified delivery currently published:** +- Release tag: `studio-latest` +- Verified master commit: `acb346a4f2b2dcd12b7f85b54bff73b572597d0e` +- Version: `4.0.0` (`versionCode 52`) +- APK: `FadCam-Studio-release-universal.apk` +- APK SHA-256: `01eadd836f7b4cd80a2211596064ceabde305460d169c8baaee2cca6afb15a01` +- Release status: **Verified prerelease** -# `>_` FadCam +### Build verification completed -**Privacy-focused Android multimedia suite: background video recording, dashcam, screen recorder, live streaming & remote control - ad-free & open-source.** +- Gradle task discovery: **PASS** +- Release unit tests: **PASS** +- `assembleDefaultRelease`: **PASS** +- Universal APK selection: **PASS** +- Package ID `com.fadcam`: **PASS** +- Non-debuggable APK: **PASS** +- APK Signature Scheme v2: **PASS** +- ZIP alignment: **PASS** +- APK container integrity: **PASS** +- `classes.dex` present: **PASS** +- SHA-256 checksum generated: **PASS** -anonfaded%2FFadCam | Trendshift +The APK is distributed as a GitHub Release asset rather than committed to Git, avoiding GitHub's 100 MB repository file limit. -[![GitHub all releases](https://img.shields.io/github/downloads/anonfaded/FadCam/total?label=Downloads&logo=github)](https://github.com/anonfaded/FadCam/releases/) -[![SourceForge Downloads](https://img.shields.io/sourceforge/dt/fadcam.svg?label=SourceForge%20Downloads&color=red)](https://sourceforge.net/projects/fadcam/files/latest/download) -[badge](https://shields.rbtlog.dev/com.fadcam) +## 🎬 Studio capabilities -[![ko-fi badge](https://img.shields.io/badge/buy_me_a-coffee-red)](https://ko-fi.com/D1D510FNSV) -[![Support me on Patreon](https://img.shields.io/endpoint.svg?url=https%3A%2F%2Fshieldsio-patreon.vercel.app%2Fapi%3Fusername%3DFadedx%26type%3Dpatrons%26suffix%3Dpatrons%2520%257C%2520Shop&style=social)](https://www.patreon.com/cw/Fadedx/shop) - -
-Scan to visit Patreon shop: -
-Scan to visit Patreon shop -
+- Hidden expandable TV production control room opened from the existing SCENES entry. +- Persistent Preview / Program workflow and multiview source bank. +- CUT / TAKE / AUTO switching with persisted transition timing. +- CUT / DISSOLVE / FADE / WIPE transition selection. +- Camera, video, graphics, Program and Preview source surfaces. +- Recording through FadCam's recording engine. +- Graphics and lower-third workflow bridged into the recording pipeline. +- Local HLS production output and RTMP / RTMPS output architecture. +- Dedicated LIVE OUTPUT and **STREAMING REVIEW** surfaces. +- YouTube, Facebook, Twitch and Custom RTMP destinations. +- Android Keystore-protected stream keys and RTMP/RTMPS URL validation. +- GO LIVE / STOP LIVE controls with a recording safety gate. +- Audio input selection, replay and remote production controls. -[![Discord](https://img.shields.io/discord/1263384048194027520?label=Join%20Us%20on%20Discord&logo=discord)](https://discord.gg/kvAZvdkuuN ) +## 🎛️ Producer Cockpit — hidden broadcast drawer - +The production room contains a subtle **⋮ cockpit control** in the header. Tap it when the producer needs the deeper control surface; the cockpit opens as a right-side drawer so the normal production UI stays clean. +The cockpit provides: +- **PROGRAM / PREVIEW monitors** with live tally emphasis. +- **Scene/source bank** for Camera, Video, Duet PiP, Duet Split, Commentary and Interview. +- **CUT • INSTANT** for the fastest synchronous Preview → Program switch path. +- AUTO switching and configurable transition speed. +- CUT, MIX, FADE, WIPE and FLASH transition modes in the cockpit control bus. +- **Virtual audio mixer** with Camera 1, Camera 2, Media and Master faders. +- Broadcast output status and a direct bridge into the verified social-destination panel. +- Producer safety/health indicators for recording, program state, destination and switch path. -
-
+The cockpit uses the same production state and verified RTMP service as the existing control room. It does not fabricate platform APIs or claim hardware capabilities that the Android device does not expose. - -
- - - Get it on IoD +## 🌐 Streaming Review & Social Live - - Download on the App Store +The Studio uses FadCam's local HLS production path and an FFmpeg RTMP/RTMPS bridge for external destinations. Stream keys are protected with Android Keystore storage. - - Get it on F-Droid - -
- - - -| ⭐ | | *New app from FadSec-Lab suite:🎉*
Also, check out our new Windows app! Visit here: [FadCrypt](https://github.com/anonfaded/FadCrypt) | -|--|-------------|:---------------------------------------------------------------------------------------------------------------------------------------------------------------| - - -
-
- -

- Github Stats -

- ---- - -
- Expand Table of Contents - -
- -- [Purpose](#purpose) -- [Screenshots](#-screenshots) -- [Featured on](#featured-on) -- [Download](https://github.com/anonfaded/FadCam#-download) -- [Features](#features) -- [Upcoming Features](#upcoming-features) -- [Join Community](#join-community) -- [Support](#support) -- [Contributions](#contributions) -- [Credits](#credits) - -
- ---- - -## `>_` Purpose - -FadCam is developed with the goal of providing a powerful, privacy-focused multimedia suite for Android devices. This comprehensive app includes dashcam functionality, screen recording with annotations, live streaming over local networks, and remote camera control — all designed for **ethical use** only. - -**Ethical Use Cases Include:** -- **Personal Security:** Use as a dashcam while driving, CCTV at home with remote monitoring, or recording proof in situations where needed for yourself -- **Content Creation:** Screen recording with professional annotation tools -- **Remote Monitoring:** Live streaming and remote control for security purposes -- **Research and Documentation:** Multi-purpose recording tools for various needs - -> [!Caution] -> **Misuse Disclaimer:**
-> The developers of FadCam do not condone or support any unethical or illegal use of this software. Misuse, such as unauthorized surveillance or invasion of privacy, is strictly against our guidelines. The authors are not responsible for any misuse of the application. - -## `>_` 📱 Screenshots - -
- -

- - - - - - - - - - -
- - - - -
- - -## `>_` Featured On - -
-Expand Featured Content - -### `>_` Featured Audio Podcast -- [closednetwork.io](https://closednetwork.io/podcast/episode-42-deepseek-ai-oh-my/) at `1:15:22` timestamp - -### `>_` Featured Videos -- [HowToMen on YouTube](https://youtu.be/gU9ddvtqL3E?si=6GpPO2fKQxT6RWKz&t=297) -- [HowToMen 2nd feature :) ](https://youtu.be/3pOoU5JjfnE?si=JMwoHfGyocXuvkC7&t=520) -- [Xtream Droid on YouTube](https://youtu.be/PImJehUqOC0?si=kQKrwk7kdHJT0_-8&t=238) -- [Xtream Droid once again in Hindi](https://www.youtube.com/watch?v=smCMKih759I) -- [Tech Quirk on YouTube](https://www.youtube.com/shorts/4cQ2RpWHess) -- [TechyNoob on YouTube](https://youtu.be/ahUmOhOc_cI?si=P5_MK5B7KiJV_2Ip) -- [Luca Crocco on TikTok](https://www.tiktok.com/@lucacrocco96/video/7400823097613503777) -- [TipTopTech on YouTube](https://youtu.be/SXHT25dNCrM?si=csEav5ZBYniJWT3J&t=177) -- [Adhip Pandey on YouTube](https://www.youtube.com/watch?v=ud0bu8U08Ic) -- [Daniel Bueno - Tecnología on YouTube](https://www.youtube.com/watch?v=TuZBXJVPfOU) -- [degeregi on TikTok](https://www.tiktok.com/@degereji/video/7403781470180920581) -- [Anurag - The Geek](https://youtu.be/uXDQhGgjhDU?si=dgYo_wMAxiCM77R5&t=166) -- [@ALHATEFALDHAKI on YouTube](https://www.youtube.com/watch?v=jOqnfL3mRks) -- [@techtricks306 on YouTube](https://www.youtube.com/watch?v=3C2XM4YnlDY) -- [Samfans VN on YouTube](https://www.youtube.com/watch?v=LSYKu51ibeI) -- [Sam Beckman on YouTube](https://youtu.be/GujW6qMdVGg?si=WLQ5PyneHkUQvyB7) -- [Sam Beckman 2nd Feature](https://youtu.be/E4Row0mFbfk?si=QLjgWyv5p3gO1giM&t=518) -- [Tech In Hand on YouTube](https://youtu.be/OjuSXf5Dj8E?si=Ng6C7Ic_HZbzxWo5) -- [AWAk3N on YouTube](https://youtu.be/B38XqQBT398?si=p7-jNVu-LxSXkNaF) -- [Toped de Gama plus on YouTube](https://youtu.be/Vhg3IvyGXWE?si=EC_rSt6gcB-1AyRT&t=361) -- [Learn Freelancing with Tusher on YouTube](https://youtu.be/bgfTJsbXjLw?si=XJ6P8WF_KA4usLd6) -- [iamrooney_ on TikTok](https://www.tiktok.com/@iamrooney_/video/7476628538636913925) -- [Brandulox](https://youtu.be/yWg-quQIX1Y?si=-Wq_ZAV9WwAEV592) -- [brandulox on TikTok](https://www.tiktok.com/@brandulox/video/7481545146144673030) -- [El Androide Feliz on YouTube](https://youtu.be/H02x9pnJh_g?si=dXCmNLaDm9Hrra2C&t=669) -- [ODORIZZI on YouTube](https://www.youtube.com/watch?v=e2IFJJgjKRg) -- [Tech OverWatch on TikTok](https://www.tiktok.com/@tech_overwatch/video/7506821012655181062) -- [tech_overwatch on Insta](https://www.instagram.com/reel/DJ6Nn5uNHxq/) -- [Wuincy Tran on YouTube](https://youtu.be/gLY1SstSwgo?si=_h3eyb0Zh3oo14jK) -- [El Androide Feliz on YouTube](https://youtu.be/5SCl5X_miTc?si=E44ej0ZfPGTz3HfR) -- [msn.com](https://www.msn.com/es-es/noticias/tecnologia/esta-app-convierte-tu-m%C3%B3vil-en-una-c%C3%A1mara-discreta-graba-v%C3%ADdeo-sin-encender-la-pantalla/ar-AA1Hf4dR) -- [RivollPlay Tech on YouTube](https://youtu.be/v0grlqT_qOw?si=PRdB2oLhJLXiXBU_) -- [softonic on X](https://x.com/softonic/status/1939598582643830840) - - - - - -### `>_` Articles and Listings -- [fmhy.net](https://fmhy.net/android-iosguide#camera-tools) -- [discuss.privacyguides.net](https://discuss.privacyguides.net/t/fadcam-open-source-cam-background-video-recording-even-when-the-screen-is-off/19823) -- [appteka.com](https://appteka.store/app/bcdr185175) -- [discuss.grapheneos.org](https://discuss.grapheneos.org/d/11667-what-featureapp-do-you-use-that-the-majority-never-talk-about/69) -- [fmhy.net](https://fmhy.net/android-iosguide#camera-tools) -- [offa repo](https://github.com/offa/android-foss#-camera) -- [geeky-gadgets.com](https://www.geeky-gadgets.com/awesome-android-apps-to-try-out-in-august/) -- [GrowingGit repo](https://github.com/GrowingGit/GitHub-English-Top-Charts/blob/main/content/charts/new_repo/software/Java.md) -- [openapk.net](https://www.openapk.net/fadcam/com.fadcam/) -- [popMODS on Telegram](https://t.me/popmodschat/447936) -- [VPN Club on Telegram](https://t.me/wbnet/99735) -- [Mobile Applications on Telegram](https://t.me/appCentre/18006) -- [phonenovo.com](https://phonenovo.com/new-android-apps-for-august-enhance-your-mobile-experience/) -- [dailymobile.ir](https://www.dailymobile.ir/89140/fadcam-android/) -- [roonby.com](https://roonby.com/2024/08/01/10-best-android-apps-to-use-these-months-august-2024/) -- [en.xiaomitoday.it](https://en.xiaomitoday.it/fadcam-con-questa-app-diventi-un-agente-segreto.html) -- [alternativeto.net](https://alternativeto.net/software/fadcam/about/) -- [misa7atech.com](https://www.misa7atech.com/2024/08/BestPhoneApps13Aug24.html) -- [mounirtech.com](https://www.mounirtech.com/2024/08/Best-new-apps-august-2024.html) -- [androidtr.es](https://androidtr.es/las-8-mejores-aplicaciones-ocultas-de-codigo-abierto-para-android-y-hacks-secretos-que-te-dejaran-boquiabierto-2/) -- [gadgetstouse.com](https://gadgetstouse.com/blog/2024/08/22/record-video-with-screen-off-on-android/) -- [summarize.tech](https://www.summarize.tech/www.youtube.com/watch?v=gU9ddvtqL3E) -- [phcorner.net](https://phcorner.net/threads/fadcam-open-source-seamless-background-video-recorder-for-android.2069335/) -- [danielbuenotecnologia.com](https://danielbuenotecnologia.com/2024/09/13/protege-tu-vida-con-la-app-fad-cam/) -- [nothingforum.com](https://nothingforum.com/konu/fadcam-video-kaydi-icin-guvenli-bir-cozum.1982/) -- [manualdousuario.net](https://manualdousuario.net/fadcam-videos-android-tela-desligada/) -- [techwiser.com](https://techwiser.com/how-to-secretly-record-a-video-with-screen-off-on-android/) -- [geeky-gadgets.com (2nd feature)](https://www.geeky-gadgets.com/15-foss-android-apps-that-are-not-on-the-google-play-store/) -- [iiktshf.com](https://iiktshf.com/fadcam/) -- [androidfreeware.net](https://www.androidfreeware.net/download-fadcam-apk.html) -- [computer-wd.com](https://www.computer-wd.com/2024/08/new-mobile-apps.html) -- [computer-wd.com (2nd feature)](https://www.computer-wd.com/2024/11/secretly-record-video-on-android.html) -- [computer-wd.com (3rd feature)](https://www.computer-wd.com/2024/12/best-android-apps-2024.html) -- [thetechboy.org](https://www.thetechboy.org/2024/11/undercover-recording-no-ads.html) -- [tz887.com](https://www.tz887.com/app/311291.html) -- [news.ycombinator.com](https://news.ycombinator.com/item?id=42802171) -- [archivecan.info](https://archivecan.info/android-ios/) -- [purepc.pl](https://www.purepc.pl/darmowe-aplikacje-na-telefon-z-androidem-stycze%C5%84-2025) -- [aqweeb.com](https://www.aqweeb.com/2025/02/10-new-apps-android-and-ios-pt9.html) -- [nomiapk.website](https://nomiapk.website/xscamera-smart-secure-video-recorder/) -- [apkhubs.com](https://apkhubs.com/en/fadcam-apk) -- [malavida.com](https://www.malavida.com/br/soft/fadcam/android/) -- [apkclone.com](https://apkclone.com/ja/fadcam-apk) -- [diyiyou.com](https://www.diyiyou.com/soft/1768.html) -- [zxki.cn](https://zxki.cn/syrj/18967.html) -- [xmk8.com](https://www.xmk8.com/119603/.html) -- [aukbbs.com](https://aukbbs.com/t-30218-1-1.html) -- [blog.csdn.net](https://blog.csdn.net/gitblog_00782/article/details/146972336) -- [lealternative.net](https://www.lealternative.net/2025/04/15/fadcam-fotocamera-sempre-attiva-per-la-tua-sicurezza/) -- [uptodown.com](https://fadcam.en.uptodown.com/android) -- [buzzspot.net](https://www.buzzspot.net/2025/05/open-source-android-apps.html?m=1) -- [ababtools.com](https://ababtools.com/?post=5528) -- [0714.com](http://www.0714.com/soft/59753.htm) -- [yeeach.com](https://yeeach.com/1685/%E5%BC%80%E6%BA%90android%E5%90%8E%E5%8F%B0%E5%BD%95%E9%9F%B3-%E5%BD%95%E5%83%8F%E5%B7%A5%E5%85%B7%E6%B1%87%E6%80%BB/) -- [codegith.com](https://codegith.com/fadcam-open-source-background-video-recorder-for-android/) -- [52pojie.cn](https://www.52pojie.cn/thread-2038164-1-1.html) -- [anfensi.com](https://www.anfensi.com/down/429430.html) -- [fy6b.com](https://www.fy6b.com/fadcam.html) -- [xatakandroid.com](https://www.xatakandroid.com/aplicaciones-android/esta-app-convierte-tu-movil-camara-discreta-graba-video-encender-pantalla) -- [blog.csdn.net](https://blog.csdn.net/2501_91832086/article/details/148797756) -- [lewz.cn](https://www.lewz.cn/jprj/10448.html) -- [ruanyazyk.com](https://ruanyazyk.com/tools/media/imageProduction.html#fadcam) -- [centrum.cz](https://www.centrum.cz/chytre-nahravani-aplikace-fadcam-toci-video-i-s-vypnutou-obrazovkou-89fff656-54ab-5ff0-9d30-5c26e9bd9a3a) -- [meta.appinn.net](https://meta.appinn.net/t/topic/72853/2) - - -
- - - -## `>_` ⬇️ Download - -Download the latest `apk` file directly from the [releases page](https://github.com/anonfaded/FadCam/releases). - -[Get it on GitHub](https://github.com/anonfaded/FadCam/releases) - -Or: - - - -
- - - Get it on F-Droid - - Get it on F-Droid - - - Get it on Amazon appstore - - - - - -## `>_` Features: - -
-🎬 FadRec Screen Recorder - -- Full-featured screen recording with annotation tools (pen, eraser, text, shapes) -- Multi-layer editing with version control and unlimited undo/redo -- Automatic project saving with full recovery options -- Sidebar overlay menu for quick-access recording controls -
- -
-🏠 FadCam Remote - -- Live stream camera feed over local network with beautiful web interface -- Remote control features: start/stop recording, adjust settings, toggle torch -- Real-time monitoring: battery level, storage status, recording status -- Perfect for security monitoring and remote access -
- -
-📸 Dashcam & Background Recording - -- Background video recording with screen off capability -- **Custom hybrid MP4 output** — FadCam's recording engine is built on a custom-patched version of the Media3 library that streams recordings as a fragmented MP4 while recording (zero corruption risk, instant recovery), then finalizes them into a universally playable MP4 on stop. This hybrid format is specific to FadCam and not part of the stock Media3 library -- **Auto-repair of interrupted recordings** — if the process is killed mid-recording (aggressive OEM background management, crashes, battery optimization), the file is automatically detected and repaired on the next app launch -- **Repair notifications** — a banner in the Records tab tells you when an interrupted recording was successfully recovered -- Auto-splitting large files with customizable size limits -- Wide-angle camera detection and support -
+From the production room, **LIVE / STREAM** opens **LIVE OUTPUT • SOCIAL DESTINATIONS**. Select YouTube, Facebook, Twitch or Custom RTMP, confirm the RTMP/RTMPS server URL, enter the platform stream key, save the destination, then start RECORD. -
-🎥 Video & Audio Controls +Before **GO LIVE**, use **STREAMING REVIEW → REVIEW PROGRAM**. This previews the local PROGRAM feed that the RTMP bridge consumes. The panel provides preflight checks for recording, destination URL and protected stream-key readiness. Only after review should GO LIVE be pressed. -- Multiple resolutions and 60/90fps support -- Orientation control and quality options -- Audio recording with customizable bitrate and sample rate -- Custom watermark text overlays -- Geotagging with location data embedding -
+Use the RTMPS server URL and stream key supplied by the selected platform's live-control page. Never commit or share stream keys. The built-in presets configure server URLs; they do not pretend to perform platform-specific API authentication. -
-🎨 User Interface & Experience +## 💾 Recording storage destinations -- Clean Material Design with bottom sheet actions -- 7+ app themes including AMOLED, Light, and System themes -- 15+ customizable app icons -- In-app "What's New" page for feature discovery -- Standalone beta installation without conflicts -
+Recording storage is a verified three-option system: -
-📁 File Management & Playback +- **Phone local storage** — FadCam's normal local destination. +- **SD card / memory card** — writable removable folder through Android Storage Access Framework with persisted permission validation. +- **USB external storage** — writable removable USB/OTG folder through the same secure picker. -- Enhanced thumbnails with index, duration, and file size -- Sorting & filtering by date, size, and more -- Trash bin with restore and auto-delete options -- Inbuilt video player using ExoPlayer -- Video info view with resolution, size, and duration details -- Direct video renaming in-app -
+The selected destination is consumed by the existing camera, dual-camera and screen-recording pipeline. Storage-space caches are cleared when the destination changes. Android's Storage Access Framework is used instead of broad `MANAGE_EXTERNAL_STORAGE` access. -
-🔧 Advanced Features +## 🧰 Production tools -- Storage indicator with real-time space and estimated record time -- Clock widget with customizable date/time and colors -- Custom notification presets and descriptions -- Audio input source selection (phone mic, wired/Bluetooth) -- Dynamic watermarks with timestamp, logo, and GPS options - - App Shortcuts: Three quick actions are available (Start Recording, Stop Recording, Toggle Torch). These shortcuts can be triggered without opening the app and can be mapped with key-mapping apps (e.g. MacroDroid) to hardware buttons (like volume buttons) for convenient control. -- Quick Settings Tile: Adds a notification shade Quick Settings tile. Single-tap to start/stop recording, double-tap to switch between Front/Rear camera (even live during recording), with secure keyguard lockscreen authentication. -
+- Thermal Guardian +- Audio Vision +- Scheduled Recording +- Producer Profiles +- Sound Meter +- Sensor Dashboard +- GPS Speedometer +- Clinometer +- Compass +- Pedometer +- Metal Detector +- Parking Marker +- QR Generator -
-🌐 Localization & Privacy +## 🧪 Verification-first development -- Multiple language support (Italian, and more coming) -- 100% ad-free and open-source -- No data collection or tracking -- Hide from recent apps for privacy -- Ethical use guidelines and disclaimer -
+The release gate verifies: -## `>_` Upcoming Features: -- **Scheduled Recording:** Automatically start/stop recordings at set times. -- **In-App Video Editor:** Quick trim/edit with Faditor Mini (coming soon). -- **Enhanced Remote Features:** Additional remote control capabilities. +- JVM unit tests, including the producer cockpit state tests. +- Studio lint audit. +- Release APK compilation. +- APK ZIP/container integrity. +- AndroidManifest.xml and classes.dex presence. +- Package ID `com.fadcam`. +- Release signature and rejection of the Android debug certificate. +- 16 KB ZIP alignment. +- Manifest hardening and removal of legacy high-risk declarations. +- SHA-256 checksum generation. +The dedicated producer-cockpit verification workflow also checks the cockpit source invariants and performs a clean signed release build, lint and APK-container verification using an ephemeral CI signing key. +> **CI signing note:** CI keys are generated only on ephemeral GitHub runners and are not stored in the repository. Configure a protected production signing key and Google Play App Signing before public store distribution. -
-🛡️ How Recording Reliability Works +## 🛠️ Build locally -FadCam writes recordings in a **hybrid MP4 format** that balances crash safety with universal compatibility. This format is implemented through a custom patch of the Media3 library — the hybrid finalization and self-healing repair described below are FadCam-specific features, not part of the stock library: +```bash +git clone https://github.com/Trendyzima/FadCamvideo.git +cd FadCamvideo +git clone --depth 1 https://github.com/anonfaded/media3-patched.git /tmp/media3-patched +./gradlew test +./gradlew :app:lintDefaultRelease +./gradlew :app:assembleDefaultRelease +``` -1. **While recording**, the file is streamed as a *fragmented MP4* — data is appended in small fragments with the metadata kept at the front. If the app or process dies at any moment, nothing already written is ever lost or corrupted. -2. **On a clean stop**, the file is finalized into a *standard MP4* (a single `moov` metadata block + one `mdat` container) that plays everywhere — gallery, WhatsApp, Instagram, VLC, PC players. -3. **If the process is killed mid-recording** (background kill by the OS, crash, battery optimization), the file is left as a valid fragmented MP4. On the next app launch, FadCam's **self-healing scan**: - - Detects the interrupted file automatically - - Rebuilds the metadata directly from the file's fragments - - Converts it into a playable standard MP4 — often byte-identical to a clean stop - - Shows a **repair banner** in the Records tab so you know the recording was recovered - -The scan is database-driven and runs once per file — it only ever examines recordings that were never verified as finalized, so it adds no overhead to normal use. Files that cannot be recovered are left untouched and marked so they are never retried. - -This means **no more lost recordings** from background kills — the worst case is a recovered file, not a corrupted one. -
- - - -## `>_` Join Community -Join our [Discord server](https://discord.gg/kvAZvdkuuN) to share ideas, seek help, or connect with other users. Your feedback and contributions are welcome! - -[![Discord](https://img.shields.io/discord/1263384048194027520?label=Join%20Us%20on%20Discord&logo=discord)](https://discord.gg/kvAZvdkuuN ) - - -## `>_` Support & Shop - -Buy FadCam Pro (lifetime access) from my Patreon shop. Your purchases help fund development and maintenance. - -[![Support me on Patreon](https://img.shields.io/endpoint.svg?url=https%3A%2F%2Fshieldsio-patreon.vercel.app%2Fapi%3Fusername%3DFadedx%26type%3Dpatrons%26suffix%3Dpatrons%2520%257C%2520Shop&style=for-the-badge)](https://www.patreon.com/cw/Fadedx/shop) - -
-Scan to visit Patreon shop:
-Scan to visit Patreon shop - - -## `>_` Contributions - -We welcome any contributions to improve this project! Whether it's bug fixes or new features, your help is appreciated. - -### `>_` How to Contribute -1. **Open an Issue First**: Before starting any work, please open an [issue](https://github.com/anonfaded/FadCam/issues) to discuss your proposed changes and get approval. -2. **Fork the Repo**: Fork the repository to make your changes. -3. **Submit a PR**: Create a pull request with a clear description of your changes. - -We look forward to your contributions! - - -### `>_` 🙌 Contributions & Credits - -- 🖼️ App screenshots and banner design by [T010](https://ko-fi.com/t010nl) -- The 3D text in icon is from Font generator. -- Icons used in app by Icons8 and Flaticon. -- Elapsed timer font option includes Doto by The Doto Project Authors (https://github.com/oliverlalan/Doto). -- Location reverse geocoding powered by Nominatim — free, open-source geocoding based on OpenStreetMap data. Building-level precision (zoom=18) for accurate address watermarking. -- QR codes generated using QRCodeChimp. +For a local release APK, provide `KEYSTORE_FILE`, `KEYSTORE_PASSWORD`, `KEY_ALIAS`, and `KEY_PASSWORD` in `local.properties`. The release build does not fall back to the Android debug keystore. +## ⚠️ Installation / Play Protect +Play Protect independently scans sideloaded applications. For production distribution, use a trusted channel such as Google Play and a protected production signing key. diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 136404467..429b3b098 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -13,8 +13,6 @@ android { splits { abi { - // For pro builds: enable splits but only arm64-v8a (no universal) - // For main builds: arm64-v8a + armeabi-v7a with universal APK isEnable = !isBundle reset() if (isProBuild) { @@ -35,22 +33,14 @@ android { versionCode = 52 versionName = "4.0.0" vectorDrawables.useSupportLibrary = true - - // Fix 16KB native library alignment for Android 15 - // Generate full native debug symbols so they can be uploaded to Play Console - ndk { - debugSymbolLevel = "FULL" - } + ndk { debugSymbolLevel = "FULL" } } signingConfigs { create("release") { val props = Properties() - rootProject.file("local.properties").takeIf { it.exists() }?.inputStream().use { stream -> - stream?.let { props.load(it) } - } + rootProject.file("local.properties").takeIf { it.exists() }?.inputStream().use { stream -> stream?.let { props.load(it) } } val keystoreFile = props.getProperty("KEYSTORE_FILE", "") - // Only set storeFile if keystore file path is provided and exists if (keystoreFile.isNotEmpty() && file(keystoreFile).exists()) { storeFile = file(keystoreFile) storePassword = props.getProperty("KEYSTORE_PASSWORD", "") @@ -59,195 +49,77 @@ android { } } } - - // Helper: check if release signing config is valid + val releaseSigningConfigValid = signingConfigs.getByName("release").storeFile != null buildTypes { debug { - applicationIdSuffix = ".beta" - isDebuggable = true - versionNameSuffix = "-beta10.6" // Increment the beta version suffix for each release. Use `beta1` for the first beta release, then `beta2`, etc. - resValue("string", "app_name", "FadCam Beta") + applicationIdSuffix = ".beta"; isDebuggable = true; versionNameSuffix = "-beta10.6"; resValue("string", "app_name", "FadCam Beta") } - release { - isMinifyEnabled = true - isShrinkResources = true - proguardFiles( - getDefaultProguardFile("proguard-android-optimize.txt"), - "proguard-rules.pro" - ) - isDebuggable = false - signingConfig = signingConfigs.getByName("release") + isMinifyEnabled = true; isShrinkResources = true + proguardFiles(getDefaultProguardFile("proguard-android-optimize.txt"), "proguard-rules.pro") + isDebuggable = false; signingConfig = signingConfigs.getByName("release") } - create("pro") { - isMinifyEnabled = true - isShrinkResources = true - proguardFiles( - getDefaultProguardFile("proguard-android-optimize.txt"), - "proguard-rules.pro" - ) - applicationIdSuffix = ".pro" - isDebuggable = false - if (releaseSigningConfigValid) { - signingConfig = signingConfigs.getByName("release") - } + isMinifyEnabled = true; isShrinkResources = true + proguardFiles(getDefaultProguardFile("proguard-android-optimize.txt"), "proguard-rules.pro") + applicationIdSuffix = ".pro"; isDebuggable = false + if (releaseSigningConfigValid) signingConfig = signingConfigs.getByName("release") versionNameSuffix = "-Pro" } - create("proPlus") { - isMinifyEnabled = true - isShrinkResources = true - proguardFiles( - getDefaultProguardFile("proguard-android-optimize.txt"), - "proguard-rules.pro" - ) - applicationIdSuffix = ".proplus" - isDebuggable = false - if (releaseSigningConfigValid) { - signingConfig = signingConfigs.getByName("release") - } + isMinifyEnabled = true; isShrinkResources = true + proguardFiles(getDefaultProguardFile("proguard-android-optimize.txt"), "proguard-rules.pro") + applicationIdSuffix = ".proplus"; isDebuggable = false + if (releaseSigningConfigValid) signingConfig = signingConfigs.getByName("release") versionNameSuffix = "-Pro+" - // Custom app name via gradle property val customAppName = project.findProperty("customAppName")?.toString() ?: "FadCam Pro+" resValue("string", "app_name", customAppName) } } flavorDimensions += "pro" - productFlavors { - create("notesPro") { - dimension = "pro" - applicationIdSuffix = ".notes" - resValue("string", "app_name", "Notes") - } - create("calcPro") { - dimension = "pro" - applicationIdSuffix = ".calc" - resValue("string", "app_name", "Calculator") - } - create("weatherPro") { - dimension = "pro" - applicationIdSuffix = ".weather" - resValue("string", "app_name", "Weather") - } - create("default") { - dimension = "pro" - // Default for proPlus builds - } + create("notesPro") { dimension = "pro"; applicationIdSuffix = ".notes"; resValue("string", "app_name", "Notes") } + create("calcPro") { dimension = "pro"; applicationIdSuffix = ".calc"; resValue("string", "app_name", "Calculator") } + create("weatherPro") { dimension = "pro"; applicationIdSuffix = ".weather"; resValue("string", "app_name", "Weather") } + create("default") { dimension = "pro" } } -// ./gradlew assembleNotesProRelease - Notes Pro variant -// ./gradlew assembleCalcProRelease - Calculator Pro variant -// ./gradlew assembleWeatherProRelease - Weather Pro variant -// ./gradlew assembleDefaultProPlusRelease -PcustomAppName="Custom Name" - Pro+ custom build (standalone) - - // Variant filter: only build specific variants (modern API — the old - // variantFilter{} is deprecated since AGP 8.x). androidComponents { beforeVariants { variant -> val isPreBuiltFlavor = variant.name.contains("notesPro") || variant.name.contains("calcPro") || variant.name.contains("weatherPro") val isDefaultFlavor = variant.name.contains("default") - - if (isPreBuiltFlavor) { - // Pre-built flavors: only 'release' build type - if (!variant.name.endsWith("Release")) { - variant.enable = false - } - } else if (isDefaultFlavor) { - // Default flavor: allow 'debug', 'release', and 'proPlus' build types - if (variant.name.endsWith("Pro") && !variant.name.endsWith("ProPlus")) { - variant.enable = false - } - } + if (isPreBuiltFlavor) { if (!variant.name.endsWith("Release")) variant.enable = false } + else if (isDefaultFlavor) { if (variant.name.endsWith("Pro") && !variant.name.endsWith("ProPlus")) variant.enable = false } } } - // Dynamic APK output names: FadCam__v-.apk - // (default flavor has no part; universal APK gets the literal "-universal") applicationVariants.all { val versionName = "${defaultConfig.versionName}${buildType.versionNameSuffix.orEmpty()}" val flavor = if (flavorName != "default") "${flavorName}_" else "" outputs.all { val abiType = filters.firstOrNull { it.filterType == com.android.build.OutputFile.ABI }?.identifier ?: "universal" - (this as com.android.build.gradle.internal.api.BaseVariantOutputImpl).outputFileName = - "FadCam_${flavor}v${versionName}-${abiType}.apk" + (this as com.android.build.gradle.internal.api.BaseVariantOutputImpl).outputFileName = "FadCam_${flavor}v${versionName}-${abiType}.apk" } } - compileOptions { - sourceCompatibility = JavaVersion.VERSION_17 - targetCompatibility = JavaVersion.VERSION_17 - } - - dependenciesInfo { - includeInApk = false - includeInBundle = false - } - + compileOptions { sourceCompatibility = JavaVersion.VERSION_17; targetCompatibility = JavaVersion.VERSION_17 } + dependenciesInfo { includeInApk = false; includeInBundle = false } sourceSets { - getByName("main") { - java.srcDir("libs/AppLockLibrary/src/main/java") - res.srcDir("libs/AppLockLibrary/src/main/res") - } - // NOTE: Removed setSrcDirs(emptyList()) to enable test source detection - // getByName("test").java.setSrcDirs(emptyList()) - // getByName("androidTest").java.setSrcDirs(emptyList()) - - // Flavor-specific resources (icons override main icons) - getByName("notesPro") { - res.srcDir("src/notesPro/res") - } - getByName("calcPro") { - res.srcDir("src/calcPro/res") - } - getByName("weatherPro") { - res.srcDir("src/weatherPro/res") - } + getByName("main") { java.srcDir("libs/AppLockLibrary/src/main/java"); res.srcDir("libs/AppLockLibrary/src/main/res") } + getByName("notesPro") { res.srcDir("src/notesPro/res") } + getByName("calcPro") { res.srcDir("src/calcPro/res") } + getByName("weatherPro") { res.srcDir("src/weatherPro/res") } } - packaging { - jniLibs { - excludes += listOf("**/x86/**", "**/x86_64/**", "**/mips/**", "**/mips64/**") - // OpenCV and ffmpeg-kit both bundle libc++_shared.so. Keep one copy. - pickFirsts += listOf("**/libc++_shared.so") - // Enable 16KB page size alignment for Android 15 compatibility - useLegacyPackaging = false - } - resources { - excludes += listOf( - "META-INF/LICENSE", - "META-INF/LICENSE.txt", - "META-INF/NOTICE", - "META-INF/NOTICE.txt", - "META-INF/DEPENDENCIES", - "META-INF/*.kotlin_module", - "META-INF/AL2.0", - "META-INF/LGPL2.1", - "**/*.kotlin_metadata", - "**/*.kotlin_builtins", - "**/*.proto", - "assets/PSDs/**" // Exclude PSD source files from release APK - ) - } - } - - androidResources { - noCompress.add("xml") - additionalParameters.add("--no-version-vectors") - } - - buildFeatures { - buildConfig = true - } - - lint { - checkReleaseBuilds = false - disable += "MissingTranslation" + jniLibs { excludes += listOf("**/x86/**","**/x86_64/**","**/mips/**","**/mips64/**"); pickFirsts += listOf("**/libc++_shared.so"); useLegacyPackaging = false } + resources { excludes += listOf("META-INF/LICENSE","META-INF/LICENSE.txt","META-INF/NOTICE","META-INF/NOTICE.txt","META-INF/DEPENDENCIES","META-INF/*.kotlin_module","META-INF/AL2.0","META-INF/LGPL2.1","**/*.kotlin_metadata","**/*.kotlin_builtins","**/*.proto","assets/PSDs/**") } } + androidResources { noCompress.add("xml"); additionalParameters.add("--no-version-vectors") } + buildFeatures { buildConfig = true } + lint { abortOnError = false; checkReleaseBuilds = false; disable += "MissingTranslation" } } dependencies { @@ -264,14 +136,12 @@ dependencies { implementation(libs.constraintlayout) implementation(libs.gridlayout) implementation(libs.core.ktx) - // Media3 ExoPlayer for playback (replacing deprecated exoplayer2) implementation(libs.media3.exoplayer) + implementation(libs.media3.exoplayer.hls) implementation(libs.media3.ui) implementation(libs.media3.session) - // Media3 Transformer + Effect for Faditor Mini video editing implementation(libs.media3.transformer) implementation(libs.media3.effect) - // AndroidX Media for MediaStyle notifications implementation(libs.media) implementation(libs.glide) implementation(libs.gson) @@ -281,9 +151,7 @@ dependencies { implementation(libs.navigation.ui.ktx) implementation(libs.okhttp) implementation(libs.tensorflow.lite) - implementation(libs.tensorflow.lite.task.vision) { - exclude(group = "org.tensorflow", module = "tensorflow-lite-api") - } + implementation(libs.tensorflow.lite.task.vision) { exclude(group = "org.tensorflow", module = "tensorflow-lite-api") } implementation(libs.opencv.android) implementation(libs.osmdroid.android) implementation(libs.osmdroid.wms) @@ -297,32 +165,20 @@ dependencies { implementation(libs.documentfile) implementation(libs.localbroadcastmanager) implementation(libs.room.runtime) - - // Media3 for fragmented MP4 muxing (patched for live streaming via composite build) implementation(libs.media3.muxer) implementation(libs.media3.common) implementation(libs.media3.container) - - // NanoHTTPD for HTTP streaming server implementation(libs.nanohttpd.core) - - // MP4Parser for reliable MP4 box structure parsing implementation("com.googlecode.mp4parser:isoparser:1.1.22") - annotationProcessor(libs.compiler) annotationProcessor(libs.room.compiler) - implementation(mapOf("name" to "ffmpeg-kit-full-6.0-2.LTS", "ext" to "aar")) implementation(libs.smart.exception.java) implementation(fileTree(mapOf("dir" to "libs/aar", "include" to listOf("*.aar")))) - - // Unit Testing Dependencies (Local JVM tests - fast, no device needed) testImplementation(libs.junit) testImplementation("org.mockito:mockito-core:5.2.0") testImplementation("org.mockito.kotlin:mockito-kotlin:5.1.0") testImplementation("org.json:json:20240303") - - // Android Instrumented Testing (runs on device/emulator) androidTestImplementation(libs.ext.junit) androidTestImplementation(libs.espresso.core) } diff --git a/app/src/debug/AndroidManifest.xml b/app/src/debug/AndroidManifest.xml new file mode 100644 index 000000000..0dbbd7140 --- /dev/null +++ b/app/src/debug/AndroidManifest.xml @@ -0,0 +1,35 @@ + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/app/src/default/AndroidManifest.xml b/app/src/default/AndroidManifest.xml new file mode 100644 index 000000000..8e882cad5 --- /dev/null +++ b/app/src/default/AndroidManifest.xml @@ -0,0 +1,14 @@ + + + + + + + + + + diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index c61bebc3b..a81fd038d 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -65,6 +65,7 @@ + @@ -457,6 +458,8 @@
+ + diff --git a/app/src/main/java/com/fadcam/FadCamApplication.java b/app/src/main/java/com/fadcam/FadCamApplication.java index bb2f63a02..8780e1e58 100644 --- a/app/src/main/java/com/fadcam/FadCamApplication.java +++ b/app/src/main/java/com/fadcam/FadCamApplication.java @@ -1,34 +1,61 @@ package com.fadcam; -import android.app.Application; +import android.app.Activity; import android.app.ActivityManager; -import androidx.lifecycle.ProcessLifecycleOwner; +import android.app.Application; +import android.content.ComponentName; +import android.content.Intent; +import android.os.Bundle; + +import androidx.lifecycle.Lifecycle; import androidx.lifecycle.LifecycleObserver; import androidx.lifecycle.OnLifecycleEvent; -import androidx.lifecycle.Lifecycle; -import android.content.Intent; -import android.content.ComponentName; +import androidx.lifecycle.ProcessLifecycleOwner; + +import com.fadcam.ui.BrandingManager; +import com.fadcam.ui.ProductionFeatureUnlocker; +import com.fadcam.production.ProductionOnlineTvInstaller; public class FadCamApplication extends Application implements LifecycleObserver { @Override public void onCreate() { super.onCreate(); ProcessLifecycleOwner.get().getLifecycle().addObserver(this); - // Room DB open + invalidation observer registration is deferred off the - // main thread: cold start must not block on SQLite open. The observer - // still catches post-kill index writes (invocation is on Room's own - // background invalidation thread either way). + registerBrandingLifecycle(); + ProductionOnlineTvInstaller.register(this); new Thread(this::registerSelfHealingScanObserver, "selfheal-observer").start(); } - /** - * Native, instant self-healing trigger (issue #332): Room fires this callback - * the moment ANY row is inserted/updated in the video index — e.g. an - * abandoned recording being indexed after the process was killed. No polling, - * no timers: the scan runs exactly when a new file enters the index and only - * touches rows still marked pending (finalized=0). Single-flight coalescing - * in the scan itself absorbs bursts. - */ + /** Keeps branding and the production-tool unlock wiring applied after navigation/rotation. */ + private void registerBrandingLifecycle() { + registerActivityLifecycleCallbacks(new ActivityLifecycleCallbacks() { + @Override public void onActivityCreated(Activity activity, Bundle state) { + BrandingManager.applyToActivity(activity); + wireProductionTools(activity); + } + @Override public void onActivityResumed(Activity activity) { + BrandingManager.applyToActivity(activity); + wireProductionTools(activity); + } + @Override public void onActivityStarted(Activity activity) { } + @Override public void onActivityPaused(Activity activity) { } + @Override public void onActivityStopped(Activity activity) { } + @Override public void onActivitySaveInstanceState(Activity activity, Bundle outState) { } + @Override public void onActivityDestroyed(Activity activity) { } + }); + } + + private void wireProductionTools(Activity activity) { + if (activity == null) return; + activity.getWindow().getDecorView().postDelayed(() -> { + try { + ProductionFeatureUnlocker.install(activity.getWindow().getDecorView()); + } catch (Throwable t) { + FLog.w("FadCamApplication", "Production tool wiring failed: " + t.getMessage()); + } + }, 120L); + } + private void registerSelfHealingScanObserver() { try { final android.content.Context app = this; @@ -37,7 +64,6 @@ private void registerSelfHealingScanObserver() { new String[]{"video_index"}) { @Override public void onInvalidated(@androidx.annotation.NonNull java.util.Set tables) { - // Runs on Room's invalidation thread (background). try { com.fadcam.services.RecordingService.runSelfHealingScan(app, null); } catch (Exception e) { @@ -53,7 +79,6 @@ public void onInvalidated(@androidx.annotation.NonNull java.util.Set tab @OnLifecycleEvent(Lifecycle.Event.ON_STOP) public void onAppBackgrounded() { - // App is in background, reset AppLock session SharedPreferencesManager.getInstance(this).setAppLockSessionUnlocked(false); Intent intent = new Intent(this, com.fadcam.services.RecordingService.class); intent.setAction("ACTION_APP_BACKGROUND"); @@ -62,10 +87,6 @@ public void onAppBackgrounded() { @OnLifecycleEvent(Lifecycle.Event.ON_START) public void onAppForegrounded() { - // Don't send it for TextEditorActivity, TransparentPermissionActivity, etc. - // which are transparent/standalone and shouldn't wake up the main app - - // Get the currently focused activity ActivityManager am = (ActivityManager) getSystemService(ACTIVITY_SERVICE); if (am != null) { java.util.List tasks = am.getRunningTasks(1); @@ -73,13 +94,9 @@ public void onAppForegrounded() { ComponentName topActivity = tasks.get(0).topActivity; if (topActivity != null) { String activityClassName = topActivity.getClassName(); - - // Only send ACTION_APP_FOREGROUND for MainActivity (camera) or FadRecHomeFragment - // Skip for transparent activities like TextEditorActivity, TransparentPermissionActivity - boolean isRecordingRelated = activityClassName.contains("MainActivity") || - activityClassName.contains("FadRecHomeActivity") || - activityClassName.contains("RecordingActivity"); - + boolean isRecordingRelated = activityClassName.contains("MainActivity") || + activityClassName.contains("FadRecHomeActivity") || + activityClassName.contains("RecordingActivity"); if (isRecordingRelated) { Intent intent = new Intent(this, com.fadcam.services.RecordingService.class); intent.setAction("ACTION_APP_FOREGROUND"); @@ -89,10 +106,8 @@ public void onAppForegrounded() { } } } - - // Fallback: send the broadcast anyway (error case) Intent intent = new Intent(this, com.fadcam.services.RecordingService.class); intent.setAction("ACTION_APP_FOREGROUND"); startService(intent); } -} \ No newline at end of file +} \ No newline at end of file diff --git a/app/src/main/java/com/fadcam/RecordingDurationLimitController.java b/app/src/main/java/com/fadcam/RecordingDurationLimitController.java index 6eb992c18..bf68efb18 100644 --- a/app/src/main/java/com/fadcam/RecordingDurationLimitController.java +++ b/app/src/main/java/com/fadcam/RecordingDurationLimitController.java @@ -1,18 +1,17 @@ package com.fadcam; /** - * Session-scoped elapsed-realtime countdown for a foreground recording service. + * Session-scoped recording-duration controller. * - *

The controller deliberately has no Activity or platform alarm dependency. A - * monotonically increasing session generation prevents a removed or delayed callback - * from affecting a later recording.

+ *

Production recording is intentionally not stopped by a wall-clock + * duration. A recording may continue until the operator stops it or the + * selected storage cannot accept more data. This compatibility boundary is + * retained so older callers and settings do not break.

*/ public final class RecordingDurationLimitController { public interface Scheduler { long elapsedRealtime(); - void postDelayed(Runnable runnable, long delayMs); - void removeCallbacks(Runnable runnable); } @@ -20,26 +19,20 @@ public interface LimitProvider { long getLimitMs(); } - /** Fires once per second while 1..10 seconds remain before the limit. */ + /** Compatibility listener; no countdown ticks are emitted in unlimited mode. */ public interface RemainingTickListener { void onRemainingSecond(int remainingSeconds); } - private static final long REMAINING_TICK_WINDOW_MS = 10_000L; - + @SuppressWarnings("unused") private final Scheduler scheduler; + @SuppressWarnings("unused") private final LimitProvider limitProvider; + @SuppressWarnings("unused") private final Runnable onLimitReached; - + @SuppressWarnings("unused") private RemainingTickListener remainingTickListener; - private Runnable scheduledTick; private boolean sessionActive; - private boolean counting; - private long sessionGeneration; - private long scheduleGeneration; - private long accumulatedRecordingMs; - private long countingStartedAtMs; - private Runnable scheduledCallback; public RecordingDurationLimitController( Scheduler scheduler, @@ -50,203 +43,36 @@ public RecordingDurationLimitController( this.onLimitReached = onLimitReached; } - /** Starts a new recording session and invalidates every callback from an older one. */ + /** Starts a recording session without installing any duration timeout. */ public synchronized long startSession() { - invalidateScheduleLocked(); - sessionGeneration++; sessionActive = true; - counting = true; - accumulatedRecordingMs = 0L; - countingStartedAtMs = scheduler.elapsedRealtime(); - return scheduleLocked(); + return 0L; } - /** Sets the listener that observes the final seconds of the countdown. */ + /** Compatibility hook; intentionally never schedules a countdown. */ public synchronized void setRemainingTickListener(RemainingTickListener listener) { this.remainingTickListener = listener; } - /** Stops the current session and invalidates its pending callback. */ + /** Stops this controller session; it does not stop the recording. */ public synchronized boolean stopSession() { - boolean hadSession = sessionActive || scheduledCallback != null; + boolean hadSession = sessionActive; sessionActive = false; - counting = false; - accumulatedRecordingMs = 0L; - sessionGeneration++; - invalidateScheduleLocked(); return hadSession; } - /** Pauses the countdown so the limit follows recorded media time, not paused time. */ + /** Pausing never consumes a duration budget. */ public synchronized boolean pauseSession() { - if (!sessionActive || !counting) { - return false; - } - accumulatedRecordingMs = getElapsedRecordingMsLocked(); - counting = false; - invalidateScheduleLocked(); - return true; + return sessionActive; } - /** Resumes a paused session without resetting its accumulated recording time. */ + /** Resuming never reinstalls a duration timeout. */ public synchronized boolean resumeSession() { - if (!sessionActive || counting) { - return false; - } - counting = true; - countingStartedAtMs = scheduler.elapsedRealtime(); - scheduleLocked(); - return true; - } - - /** Re-evaluates the current session after the user changes the configured limit. */ - public boolean onLimitChanged() { - boolean notifyLimitReached = false; - synchronized (this) { - if (!sessionActive) { - return false; - } - - invalidateScheduleLocked(); - long limitMs = Math.max(0L, limitProvider.getLimitMs()); - if (limitMs != 0L) { - long elapsedMs = getElapsedRecordingMsLocked(); - if (elapsedMs >= limitMs) { - completeSessionLocked(); - notifyLimitReached = true; - } else if (counting) { - scheduleLocked(); - } - } - } - - if (notifyLimitReached) { - onLimitReached.run(); - } - return true; - } - - private long scheduleLocked() { - invalidateScheduleLocked(); - long limitMs = Math.max(0L, limitProvider.getLimitMs()); - if (!sessionActive || !counting || limitMs == 0L) { - return limitMs; - } - - long elapsedMs = getElapsedRecordingMsLocked(); - long remainingMs = elapsedMs >= limitMs ? 0L : limitMs - elapsedMs; - long expectedSession = sessionGeneration; - long expectedSchedule = scheduleGeneration; - Runnable callback = () -> handleTimeout(expectedSession, expectedSchedule); - scheduledCallback = callback; - scheduler.postDelayed(callback, remainingMs); - if (remainingMs > 0L && remainingMs <= REMAINING_TICK_WINDOW_MS) { - scheduleRemainingTicksLocked(remainingMs, expectedSession, expectedSchedule); - } - return limitMs; - } - - /** - * Schedules per-second ticks for the final 10 seconds. The first tick fires - * after {@code remainingMs % 1000} so the tick coincides with a whole-second - * boundary, then every second until the limit callback takes over. - */ - private void scheduleRemainingTicksLocked( - long remainingMs, long expectedSession, long expectedSchedule) { - if (remainingTickListener == null) { - return; - } - long firstDelayMs = remainingMs % 1000L; - int[] remainingSeconds = {(int) ((remainingMs + 999L) / 1000L)}; - Runnable tick = new Runnable() { - @Override - public void run() { - boolean reschedule = false; - synchronized (RecordingDurationLimitController.this) { - if (!sessionActive - || !counting - || expectedSession != sessionGeneration - || expectedSchedule != scheduleGeneration) { - return; - } - scheduledTick = null; - if (remainingTickListener != null) { - remainingTickListener.onRemainingSecond(remainingSeconds[0]); - } - if (remainingSeconds[0] > 1) { - remainingSeconds[0]--; - reschedule = true; - scheduledTick = this; - } - } - if (reschedule) { - scheduler.postDelayed(this, 1000L); - } - } - }; - scheduledTick = tick; - scheduler.postDelayed(tick, firstDelayMs); - } - - private void handleTimeout(long expectedSession, long expectedSchedule) { - boolean notifyLimitReached = false; - synchronized (this) { - if (!sessionActive - || !counting - || expectedSession != sessionGeneration - || expectedSchedule != scheduleGeneration) { - return; - } - - scheduledCallback = null; - long limitMs = Math.max(0L, limitProvider.getLimitMs()); - if (limitMs == 0L) { - return; - } - - long elapsedMs = getElapsedRecordingMsLocked(); - if (elapsedMs < limitMs) { - scheduleLocked(); - return; - } - - completeSessionLocked(); - notifyLimitReached = true; - } - - if (notifyLimitReached) { - onLimitReached.run(); - } - } - - private long getElapsedRecordingMsLocked() { - if (!counting) { - return accumulatedRecordingMs; - } - long nowMs = scheduler.elapsedRealtime(); - long deltaMs = nowMs >= countingStartedAtMs ? nowMs - countingStartedAtMs : 0L; - if (Long.MAX_VALUE - accumulatedRecordingMs < deltaMs) { - return Long.MAX_VALUE; - } - return accumulatedRecordingMs + deltaMs; - } - - private void completeSessionLocked() { - sessionActive = false; - counting = false; - sessionGeneration++; - scheduleGeneration++; + return sessionActive; } - private void invalidateScheduleLocked() { - scheduleGeneration++; - if (scheduledCallback != null) { - scheduler.removeCallbacks(scheduledCallback); - scheduledCallback = null; - } - if (scheduledTick != null) { - scheduler.removeCallbacks(scheduledTick); - scheduledTick = null; - } + /** Duration preferences no longer control whether a production recording stops. */ + public synchronized boolean onLimitChanged() { + return sessionActive; } } diff --git a/app/src/main/java/com/fadcam/SplashActivity.java b/app/src/main/java/com/fadcam/SplashActivity.java index 5a1a6b1c9..089722950 100644 --- a/app/src/main/java/com/fadcam/SplashActivity.java +++ b/app/src/main/java/com/fadcam/SplashActivity.java @@ -11,25 +11,30 @@ import com.fadcam.utils.RuntimeCompat; /** - * Simple splash screen activity showing the FadSecLab flag centered. - * Uses a short delay, then routes to the appropriate main activity: - * - Wear OS: {@link WatchMainActivity} (minimal watch UI) - * - Phone/tablet: {@link MainActivity} + * Splash router. + * Debug builds enter the new professional Studio dashboard first so the UI can + * be verified independently. Release builds keep the existing MainActivity flow. */ public class SplashActivity extends Activity { - private static final long SPLASH_DELAY_MS = 800; // short, just to show logo + private static final long SPLASH_DELAY_MS = 800; @Override protected void onCreate(Bundle savedInstanceState) { super.onCreate(savedInstanceState); setContentView(R.layout.activity_splash); ImageView iv = findViewById(R.id.splash_image); + new Handler(Looper.getMainLooper()).postDelayed(() -> { - Class destination = RuntimeCompat.shouldUseWatchUi(SplashActivity.this) - ? WatchMainActivity.class - : MainActivity.class; + final Class destination; + if (BuildConfig.DEBUG && !RuntimeCompat.shouldUseWatchUi(SplashActivity.this)) { + destination = StudioActivity.class; + } else { + destination = RuntimeCompat.shouldUseWatchUi(SplashActivity.this) + ? WatchMainActivity.class + : MainActivity.class; + } + startActivity(new Intent(SplashActivity.this, destination)); - // Apply fade transition instead of default activity animation overridePendingTransition(R.anim.fade_in, R.anim.fade_out); finish(); }, SPLASH_DELAY_MS); diff --git a/app/src/main/java/com/fadcam/StudioActivity.java b/app/src/main/java/com/fadcam/StudioActivity.java new file mode 100644 index 000000000..cd86fc592 --- /dev/null +++ b/app/src/main/java/com/fadcam/StudioActivity.java @@ -0,0 +1,468 @@ +package com.fadcam; + +import android.Manifest; +import android.app.AlertDialog; +import android.content.ContentResolver; +import android.content.Context; +import android.content.Intent; +import android.content.pm.PackageManager; +import android.database.Cursor; +import android.graphics.Bitmap; +import android.media.AudioDeviceInfo; +import android.media.AudioManager; +import android.net.Uri; +import android.os.Build; +import android.os.Bundle; +import android.os.Handler; +import android.os.Looper; +import android.provider.MediaStore; +import android.text.InputType; +import android.text.method.PasswordTransformationMethod; +import android.view.View; +import android.widget.ArrayAdapter; +import android.widget.EditText; +import android.widget.ImageView; +import android.widget.LinearLayout; +import android.widget.Spinner; +import android.widget.TextView; +import android.widget.Toast; + +import androidx.annotation.NonNull; +import androidx.appcompat.app.AppCompatActivity; +import androidx.camera.core.CameraSelector; +import androidx.camera.view.CameraController; +import androidx.camera.view.LifecycleCameraController; +import androidx.camera.view.PreviewView; +import androidx.core.app.ActivityCompat; +import androidx.core.content.ContextCompat; + +import com.fadcam.streaming.RemoteStreamManager; +import com.fadcam.streaming.RemoteStreamService; +import com.fadcam.studio.StudioGraphicsRecorderBridge; +import com.fadcam.studio.StudioRtmpConfig; +import com.fadcam.studio.StudioRtmpSecretStore; +import com.fadcam.studio.StudioRtmpStreamService; + +import java.util.ArrayList; +import java.util.List; + +/** Professional FadCam production-room dashboard. */ +public class StudioActivity extends AppCompatActivity { + private static final int PERMISSION_REQUEST = 7101; + + private PreviewView previewView; + private ImageView programSnapshot; + private TextView liveStatus; + private TextView recordingStatus; + private TextView streamStatus; + private TextView activeCameraLabel; + private TextView transitionLabel; + private TextView graphicsOverlay; + private TextView programGraphics; + private TextView audioStatus; + private TextView replayStatus; + private TextView rtmpStatus; + + private LifecycleCameraController cameraController; + private final Handler handler = new Handler(Looper.getMainLooper()); + private boolean frontCamera; + private boolean programIsFront; + private boolean programFrozen; + private boolean recording; + private boolean streaming; + private String graphicsText = "FADCAM • LIVE"; + + private final Runnable snapshotRunnable = new Runnable() { + @Override public void run() { + if (!programFrozen && previewView != null && programSnapshot != null) { + Bitmap bitmap = previewView.getBitmap(); + if (bitmap != null && !bitmap.isRecycled()) programSnapshot.setImageBitmap(bitmap); + } + handler.postDelayed(this, 250L); + } + }; + + private final Runnable stateRunnable = new Runnable() { + @Override public void run() { + refreshEngineState(); + handler.postDelayed(this, 1000L); + } + }; + + @Override protected void onCreate(Bundle savedInstanceState) { + super.onCreate(savedInstanceState); + setContentView(R.layout.activity_studio); + + previewView = findViewById(R.id.studio_preview); + programSnapshot = findViewById(R.id.studio_program_snapshot); + liveStatus = findViewById(R.id.studio_live_status); + recordingStatus = findViewById(R.id.studio_record_status); + streamStatus = findViewById(R.id.studio_stream_status); + activeCameraLabel = findViewById(R.id.studio_active_camera); + transitionLabel = findViewById(R.id.studio_transition_label); + graphicsOverlay = findViewById(R.id.studio_graphics_overlay); + programGraphics = findViewById(R.id.studio_program_graphics); + audioStatus = findViewById(R.id.studio_audio_status); + replayStatus = findViewById(R.id.studio_replay_status); + rtmpStatus = findViewById(R.id.studio_rtmp_status); + + graphicsText = getSharedPreferences("FadCamStudioGraphics", MODE_PRIVATE) + .getString("graphic", graphicsText); + if (graphicsText == null || graphicsText.trim().isEmpty()) graphicsText = "FADCAM • LIVE"; + + configureSwitcher(); + configureProductionControls(); + configureGraphics(); + configureAudioRouting(); + configureReplay(); + configureRemoteControl(); + configureRtmp(); + + if (hasCapturePermissions()) startCamera(); + else ActivityCompat.requestPermissions(this, + new String[]{Manifest.permission.CAMERA, Manifest.permission.RECORD_AUDIO}, PERMISSION_REQUEST); + + refreshEngineState(); + handler.post(stateRunnable); + } + + private boolean hasCapturePermissions() { + return ContextCompat.checkSelfPermission(this, Manifest.permission.CAMERA) == PackageManager.PERMISSION_GRANTED + && ContextCompat.checkSelfPermission(this, Manifest.permission.RECORD_AUDIO) == PackageManager.PERMISSION_GRANTED; + } + + private void startCamera() { + cameraController = new LifecycleCameraController(this); + cameraController.setEnabledUseCases(CameraController.IMAGE_CAPTURE); + cameraController.setCameraSelector(frontCamera ? CameraSelector.DEFAULT_FRONT_CAMERA : CameraSelector.DEFAULT_BACK_CAMERA); + cameraController.bindToLifecycle(this); + previewView.setController(cameraController); + previewView.setScaleType(PreviewView.ScaleType.FILL_CENTER); + updateCameraLabels(); + handler.removeCallbacks(snapshotRunnable); + handler.post(snapshotRunnable); + } + + private void configureSwitcher() { + int[] ids = {R.id.studio_cam1, R.id.studio_cam2, R.id.studio_cam3, R.id.studio_cam4, + R.id.studio_cam5, R.id.studio_cam6, R.id.studio_cam7, R.id.studio_cam8}; + for (int i = 0; i < ids.length; i++) { + final int cameraNumber = i + 1; + View tile = findViewById(ids[i]); + if (tile != null) tile.setOnClickListener(v -> selectCamera(cameraNumber)); + } + findViewById(R.id.studio_cut).setOnClickListener(v -> takeProgramLive("CUT")); + findViewById(R.id.studio_fade).setOnClickListener(v -> takeProgramLive("FADE")); + findViewById(R.id.studio_mix).setOnClickListener(v -> takeProgramLive("MIX")); + } + + private void selectCamera(int cameraNumber) { + if (cameraNumber != 1 && cameraNumber != 2) { + Toast.makeText(this, "CAM " + cameraNumber + " is not connected", Toast.LENGTH_SHORT).show(); + return; + } + frontCamera = cameraNumber == 2; + if (cameraController == null) return; + try { + cameraController.setCameraSelector(frontCamera ? CameraSelector.DEFAULT_FRONT_CAMERA : CameraSelector.DEFAULT_BACK_CAMERA); + updateCameraLabels(); + transitionLabel.setText("CAM " + cameraNumber + " PREVIEW"); + } catch (IllegalStateException e) { + Toast.makeText(this, "Selected camera is unavailable", Toast.LENGTH_SHORT).show(); + } + } + + private void takeProgramLive(String transition) { + if (programSnapshot == null || previewView == null) return; + Bitmap bitmap = previewView.getBitmap(); + if (bitmap == null) { + Toast.makeText(this, "Program frame is not ready", Toast.LENGTH_SHORT).show(); + return; + } + programFrozen = true; + if ("FADE".equals(transition)) { + programSnapshot.animate().alpha(0f).setDuration(160).withEndAction(() -> { + programSnapshot.setImageBitmap(bitmap); + programSnapshot.animate().alpha(1f).setDuration(220).start(); + }).start(); + } else if ("MIX".equals(transition)) { + programSnapshot.setAlpha(0.35f); + programSnapshot.setImageBitmap(bitmap); + programSnapshot.animate().alpha(1f).setDuration(350).start(); + } else { + programSnapshot.setAlpha(1f); + programSnapshot.setImageBitmap(bitmap); + } + programIsFront = frontCamera; + transitionLabel.setText(transition + " • PROGRAM CAM " + (programIsFront ? "2" : "1")); + graphicsOverlay.bringToFront(); + programGraphics.bringToFront(); + } + + private void configureProductionControls() { + findViewById(R.id.studio_record_button).setOnClickListener(v -> toggleRecording()); + findViewById(R.id.studio_stream_button).setOnClickListener(v -> toggleRtmpOutput()); + findViewById(R.id.studio_back_to_camera).setOnClickListener(v -> { + startActivity(new Intent(this, MainActivity.class)); + finish(); + }); + } + + /** Starts/stops FadCam's real RecordingService. The Studio graphic is enabled in the GL encoder path. */ + private void toggleRecording() { + if (!hasCapturePermissions()) { + ActivityCompat.requestPermissions(this, + new String[]{Manifest.permission.CAMERA, Manifest.permission.RECORD_AUDIO}, PERMISSION_REQUEST); + return; + } + Intent intent = new Intent(this, com.fadcam.services.RecordingService.class); + intent.setAction(recording ? Constants.INTENT_ACTION_STOP_RECORDING : Constants.INTENT_ACTION_START_RECORDING); + if (!recording) StudioGraphicsRecorderBridge.enable(this, graphicsText); + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) startForegroundService(intent); + else startService(intent); + recording = !recording; + updateRecordStatus(); + } + + private void configureGraphics() { + graphicsOverlay.setText(graphicsText); + programGraphics.setText(graphicsText); + findViewById(R.id.studio_graphics_button).setOnClickListener(v -> editGraphics()); + } + + private void editGraphics() { + final EditText input = new EditText(this); + input.setSingleLine(false); + input.setText(graphicsText); + input.setSelectAllOnFocus(true); + new AlertDialog.Builder(this) + .setTitle("Program Graphic") + .setMessage("This graphic is also sent into FadCam's GL recording encoder while RECORD is active.") + .setView(input) + .setPositiveButton("APPLY", (dialog, which) -> { + graphicsText = input.getText().toString().trim(); + if (graphicsText.isEmpty()) graphicsText = "FADCAM • LIVE"; + graphicsOverlay.setText(graphicsText); + programGraphics.setText(graphicsText); + if (recording) StudioGraphicsRecorderBridge.update(this, graphicsText); + }) + .setNegativeButton("CANCEL", null) + .show(); + } + + private void configureAudioRouting() { + findViewById(R.id.studio_audio_button).setOnClickListener(v -> showAudioDevices()); + } + + private void showAudioDevices() { + AudioManager audioManager = (AudioManager) getSystemService(Context.AUDIO_SERVICE); + if (audioManager == null) return; + List devices = new ArrayList<>(); + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) { + for (AudioDeviceInfo device : audioManager.getDevices(AudioManager.GET_DEVICES_INPUTS)) devices.add(device); + } + if (devices.isEmpty()) { + audioStatus.setText("AUDIO • DEFAULT MIC"); + Toast.makeText(this, "No external input device is connected", Toast.LENGTH_SHORT).show(); + return; + } + String[] labels = new String[devices.size()]; + for (int i = 0; i < devices.size(); i++) labels[i] = audioDeviceName(devices.get(i)); + new AlertDialog.Builder(this).setTitle("Audio Input Routing").setItems(labels, + (dialog, which) -> selectAudioDevice(devices.get(which))).setNegativeButton("CANCEL", null).show(); + } + + private void selectAudioDevice(AudioDeviceInfo device) { + getSharedPreferences(Constants.PREFS_NAME, MODE_PRIVATE).edit() + .putString(Constants.PREF_AUDIO_INPUT_DEVICE_NAME, audioDeviceName(device)).apply(); + audioStatus.setText("AUDIO • " + audioDeviceName(device) + " • SELECTED"); + } + + private String audioDeviceName(AudioDeviceInfo device) { + CharSequence name = device.getProductName(); + return name == null || name.length() == 0 ? "Input " + device.getId() : name.toString(); + } + + private void configureReplay() { + findViewById(R.id.studio_replay_button).setOnClickListener(v -> openLatestRecording()); + } + + private void openLatestRecording() { + ContentResolver resolver = getContentResolver(); + String[] projection = {MediaStore.Video.Media._ID, MediaStore.Video.Media.DATE_ADDED, MediaStore.Video.Media.MIME_TYPE}; + Uri collection = MediaStore.Video.Media.EXTERNAL_CONTENT_URI; + try (Cursor cursor = resolver.query(collection, projection, null, null, MediaStore.Video.Media.DATE_ADDED + " DESC")) { + if (cursor != null && cursor.moveToFirst()) { + long id = cursor.getLong(cursor.getColumnIndexOrThrow(MediaStore.Video.Media._ID)); + Uri video = Uri.withAppendedPath(collection, String.valueOf(id)); + Intent view = new Intent(Intent.ACTION_VIEW, video); + view.setDataAndType(video, "video/*"); + view.addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION); + startActivity(view); + replayStatus.setText("REPLAY • OPENED LATEST"); + return; + } + } catch (Exception e) { + Toast.makeText(this, "Unable to open replay: " + e.getMessage(), Toast.LENGTH_LONG).show(); + } + replayStatus.setText("REPLAY • NO RECORDINGS"); + } + + private void configureRemoteControl() { + findViewById(R.id.studio_remote_button).setOnClickListener(v -> { + Intent intent = new Intent(this, MainActivity.class); + intent.putExtra("navigate_to_tab", 2); + startActivity(intent); + }); + } + + private void configureRtmp() { + findViewById(R.id.studio_rtmp_config_button).setOnClickListener(v -> showRtmpDialog()); + updateRtmpStatus(); + } + + private void showRtmpDialog() { + LinearLayout root = new LinearLayout(this); + root.setOrientation(LinearLayout.VERTICAL); + int pad = (int) (16 * getResources().getDisplayMetrics().density); + root.setPadding(pad, 0, pad, 0); + + Spinner platform = new Spinner(this); + String[] platforms = {StudioRtmpConfig.PLATFORM_YOUTUBE, StudioRtmpConfig.PLATFORM_FACEBOOK, + StudioRtmpConfig.PLATFORM_TWITCH, StudioRtmpConfig.PLATFORM_CUSTOM}; + platform.setAdapter(new ArrayAdapter<>(this, android.R.layout.simple_spinner_dropdown_item, platforms)); + + EditText server = new EditText(this); + server.setHint("RTMP/RTMPS server URL"); + server.setSingleLine(true); + + EditText key = new EditText(this); + key.setHint("Stream key"); + key.setSingleLine(true); + key.setInputType(InputType.TYPE_CLASS_TEXT); + key.setTransformationMethod(PasswordTransformationMethod.getInstance()); + + String savedPlatform = getSharedPreferences("FadCamPrefs", MODE_PRIVATE) + .getString(StudioRtmpConfig.PREF_PLATFORM, StudioRtmpConfig.PLATFORM_YOUTUBE); + for (int i = 0; i < platforms.length; i++) if (platforms[i].equals(savedPlatform)) platform.setSelection(i); + server.setText(getSharedPreferences("FadCamPrefs", MODE_PRIVATE) + .getString(StudioRtmpConfig.PREF_SERVER, StudioRtmpConfig.defaultServer(savedPlatform))); + try { key.setText(StudioRtmpSecretStore.load(this)); } catch (Exception ignored) {} + + platform.setOnItemSelectedListener(new android.widget.AdapterView.OnItemSelectedListener() { + @Override public void onItemSelected(android.widget.AdapterView parent, View view, int position, long id) { + String selected = platforms[position]; + if (selected.equals(StudioRtmpConfig.PLATFORM_CUSTOM)) return; + server.setText(StudioRtmpConfig.defaultServer(selected)); + } + @Override public void onNothingSelected(android.widget.AdapterView parent) {} + }); + + root.addView(platform); + root.addView(server); + root.addView(key); + + new AlertDialog.Builder(this).setTitle("RTMP / RTMPS OUTPUT") + .setMessage("The Studio sends the same encoded program feed to the selected platform. Never share your stream key.") + .setView(root) + .setPositiveButton("SAVE", (dialog, which) -> { + String selectedPlatform = platforms[platform.getSelectedItemPosition()]; + String serverValue = server.getText().toString().trim(); + String keyValue = key.getText().toString().trim(); + if (serverValue.isEmpty() || keyValue.isEmpty()) { + Toast.makeText(this, "Server URL and stream key are required", Toast.LENGTH_LONG).show(); + return; + } + getSharedPreferences("FadCamPrefs", MODE_PRIVATE).edit() + .putString(StudioRtmpConfig.PREF_SERVER, serverValue) + .putString(StudioRtmpConfig.PREF_PLATFORM, selectedPlatform).apply(); + try { + StudioRtmpSecretStore.save(this, keyValue); + Toast.makeText(this, "RTMP destination saved securely", Toast.LENGTH_SHORT).show(); + } catch (Exception e) { + Toast.makeText(this, "Could not protect stream key", Toast.LENGTH_LONG).show(); + } + updateRtmpStatus(); + }) + .setNegativeButton("CANCEL", null).show(); + } + + private void toggleRtmpOutput() { + boolean active = getSharedPreferences("FadCamPrefs", MODE_PRIVATE) + .getBoolean(StudioRtmpConfig.PREF_ACTIVE, false); + if (active) { + startService(new Intent(this, StudioRtmpStreamService.class).setAction(StudioRtmpStreamService.ACTION_STOP)); + updateRtmpStatus(); + return; + } + if (!recording) { + Toast.makeText(this, "Start RECORD before starting RTMP output", Toast.LENGTH_LONG).show(); + return; + } + try { + String server = getSharedPreferences("FadCamPrefs", MODE_PRIVATE) + .getString(StudioRtmpConfig.PREF_SERVER, ""); + String platform = getSharedPreferences("FadCamPrefs", MODE_PRIVATE) + .getString(StudioRtmpConfig.PREF_PLATFORM, StudioRtmpConfig.PLATFORM_CUSTOM); + if (server.isEmpty() || StudioRtmpSecretStore.load(this).isEmpty()) { + showRtmpDialog(); + return; + } + Intent intent = new Intent(this, StudioRtmpStreamService.class) + .setAction(StudioRtmpStreamService.ACTION_START) + .putExtra(StudioRtmpStreamService.EXTRA_SERVER, server) + .putExtra(StudioRtmpStreamService.EXTRA_PLATFORM, platform); + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) startForegroundService(intent); else startService(intent); + updateRtmpStatus(); + } catch (Exception e) { + Toast.makeText(this, "RTMP setup failed", Toast.LENGTH_LONG).show(); + } + } + + private void updateRtmpStatus() { + if (rtmpStatus == null) return; + boolean active = getSharedPreferences("FadCamPrefs", MODE_PRIVATE) + .getBoolean(StudioRtmpConfig.PREF_ACTIVE, false); + String platform = getSharedPreferences("FadCamPrefs", MODE_PRIVATE) + .getString(StudioRtmpConfig.PREF_PLATFORM, StudioRtmpConfig.PLATFORM_CUSTOM); + rtmpStatus.setText(active ? "RTMP • LIVE • " + platform : "RTMP • READY • " + platform); + streamStatus.setText(active ? "● STREAM • RTMP LIVE" : (streaming ? "● STREAM • LIVE HLS" : "○ STREAM • IDLE")); + } + + private void refreshEngineState() { + boolean oldRecording = recording; + try { + recording = getSharedPreferences(Constants.PREFS_NAME, MODE_PRIVATE) + .getBoolean(Constants.PREF_IS_RECORDING_IN_PROGRESS, recording); + } catch (Exception ignored) {} + try { streaming = RemoteStreamManager.getInstance().isStreamingEnabled(); } catch (Exception ignored) {} + if (oldRecording && !recording && StudioGraphicsRecorderBridge.isActive(this)) { + StudioGraphicsRecorderBridge.disable(this); + } + updateRecordStatus(); + updateRtmpStatus(); + } + + private void updateRecordStatus() { + recordingStatus.setText(recording ? "● REC • ACTIVE" : "○ REC • IDLE"); + liveStatus.setText(recording ? "LIVE • RECORDING" : "LIVE • STANDBY"); + } + + private void updateCameraLabels() { + int number = frontCamera ? 2 : 1; + liveStatus.setText(recording ? "LIVE • RECORDING" : "LIVE • CAM " + number); + activeCameraLabel.setText(frontCamera ? "CAM 2 • FRONT" : "CAM 1 • BACK"); + } + + @Override public void onRequestPermissionsResult(int requestCode, @NonNull String[] permissions, @NonNull int[] grantResults) { + super.onRequestPermissionsResult(requestCode, permissions, grantResults); + if (requestCode == PERMISSION_REQUEST && hasCapturePermissions()) startCamera(); + else if (requestCode == PERMISSION_REQUEST) liveStatus.setText("CAMERA / AUDIO PERMISSION REQUIRED"); + } + + @Override protected void onDestroy() { + handler.removeCallbacksAndMessages(null); + if (cameraController != null) cameraController.unbind(); + super.onDestroy(); + } +} diff --git a/app/src/main/java/com/fadcam/TorchToggleActivity.java b/app/src/main/java/com/fadcam/TorchToggleActivity.java index 7344c3503..a014b0cfd 100644 --- a/app/src/main/java/com/fadcam/TorchToggleActivity.java +++ b/app/src/main/java/com/fadcam/TorchToggleActivity.java @@ -1,16 +1,27 @@ package com.fadcam; -import com.fadcam.FLog; +import android.Manifest; import android.app.Activity; import android.content.Intent; +import android.content.pm.PackageManager; +import android.graphics.Color; import android.os.Build; import android.os.Bundle; -import android.os.Handler; -import android.os.Looper; -import android.view.WindowManager; +import android.webkit.PermissionRequest; +import android.webkit.WebChromeClient; +import android.webkit.WebSettings; +import android.webkit.WebView; +import android.webkit.WebViewClient; +import android.widget.LinearLayout; +import android.widget.TextView; import android.widget.Toast; +import androidx.core.app.ActivityCompat; +import androidx.core.content.ContextCompat; + import com.fadcam.dualcam.service.DualCameraRecordingService; +import com.fadcam.production.ProductionCameraSlot; +import com.fadcam.production.ProductionCameraSlotManager; import com.fadcam.services.RecordingService; import com.fadcam.services.TorchService; import com.fadcam.SharedPreferencesManager; @@ -19,18 +30,111 @@ import java.util.Random; +/** Existing exported torch router plus the FadCam app-to-app guest camera entry point. */ public class TorchToggleActivity extends Activity { private static final String TAG = "TorchToggleActivity"; + private static final int GUEST_PERMISSION_REQUEST = 8121; + private boolean guestMode; + private WebView guestWebView; + private int guestSlot = -1; + private String guestToken = ""; - @Override - protected void onCreate(Bundle savedInstanceState) { + @Override protected void onCreate(Bundle savedInstanceState) { super.onCreate(savedInstanceState); + if (isGuestInvite(getIntent())) { + guestMode = true; + readGuestInvite(getIntent()); + showGuestCamera(); + return; + } + runTorchToggle(); + } + + private boolean isGuestInvite(Intent intent) { + if (intent == null || intent.getData() == null) return false; + return "fadcam".equalsIgnoreCase(intent.getData().getScheme()) + && "guest-camera".equalsIgnoreCase(intent.getData().getHost()); + } + + private void readGuestInvite(Intent intent) { + try { + String value = intent.getData().getQueryParameter("slot"); + guestSlot = Integer.parseInt(value == null ? "-1" : value); + guestToken = intent.getData().getQueryParameter("token"); + if (guestToken == null) guestToken = ""; + } catch (Exception ignored) { + guestSlot = -1; + guestToken = ""; + } + } + + private void showGuestCamera() { + getWindow().setBackgroundDrawableResource(android.R.color.black); + if (guestSlot < 1 || guestSlot > 6 || guestToken.isEmpty()) { + Toast.makeText(this, "Invalid FadCam camera invitation", Toast.LENGTH_LONG).show(); + finish(); + return; + } + ProductionCameraSlot slot = ProductionCameraSlotManager.get(this, guestSlot); + if (!guestToken.equals(slot.getInviteToken()) || slot.isInviteConsumed()) { + Toast.makeText(this, "This FadCam camera invitation is no longer active", Toast.LENGTH_LONG).show(); + finish(); + return; + } + + LinearLayout root = new LinearLayout(this); + root.setOrientation(LinearLayout.VERTICAL); + root.setBackgroundColor(Color.BLACK); + TextView header = new TextView(this); + header.setText("FADCAM • CAMERA " + guestSlot + "\nGuest camera • secure one-time studio invite"); + header.setTextColor(Color.WHITE); + header.setTextSize(15); + header.setPadding(18, 18, 18, 18); + header.setBackgroundColor(Color.rgb(55, 14, 18)); + root.addView(header, new LinearLayout.LayoutParams(-1, -2)); + guestWebView = new WebView(this); + guestWebView.setBackgroundColor(Color.BLACK); + root.addView(guestWebView, new LinearLayout.LayoutParams(-1, 0, 1)); + setContentView(root); + + if (hasGuestPermissions()) loadGuestPublisher(); + else ActivityCompat.requestPermissions(this, + new String[]{Manifest.permission.CAMERA, Manifest.permission.RECORD_AUDIO}, GUEST_PERMISSION_REQUEST); + } + + private boolean hasGuestPermissions() { + return ContextCompat.checkSelfPermission(this, Manifest.permission.CAMERA) == PackageManager.PERMISSION_GRANTED + && ContextCompat.checkSelfPermission(this, Manifest.permission.RECORD_AUDIO) == PackageManager.PERMISSION_GRANTED; + } + + private void loadGuestPublisher() { + WebSettings settings = guestWebView.getSettings(); + settings.setJavaScriptEnabled(true); + settings.setDomStorageEnabled(true); + settings.setMediaPlaybackRequiresUserGesture(false); + settings.setAllowContentAccess(true); + settings.setDatabaseEnabled(true); + guestWebView.setWebViewClient(new WebViewClient()); + guestWebView.setWebChromeClient(new WebChromeClient() { + @Override public void onPermissionRequest(final PermissionRequest request) { + runOnUiThread(() -> { + java.util.ArrayList allowed = new java.util.ArrayList<>(); + for (String resource : request.getResources()) { + if (PermissionRequest.RESOURCE_VIDEO_CAPTURE.equals(resource) + || PermissionRequest.RESOURCE_AUDIO_CAPTURE.equals(resource)) allowed.add(resource); + } + if (allowed.isEmpty()) request.deny(); + else request.grant(allowed.toArray(new String[0])); + }); + } + }); + guestWebView.loadUrl(ProductionCameraSlotManager.vdoPushLink(this, guestSlot)); + } + private void runTorchToggle() { try { SharedPreferencesManager sharedPreferencesManager = SharedPreferencesManager.getInstance(this); Intent intent; - - // If streaming is active, route to the active recording service (controls camera torch) RemoteStreamManager streamManager = RemoteStreamManager.getInstance(); if (streamManager.isStreamingEnabled()) { boolean dualRunning = ServiceUtils.isServiceRunning(this, DualCameraRecordingService.class) @@ -39,18 +143,11 @@ protected void onCreate(Bundle savedInstanceState) { intent = new Intent(this, dualRunning ? DualCameraRecordingService.class : RecordingService.class); intent.setAction(Constants.INTENT_ACTION_TOGGLE_RECORDING_TORCH); } else { - // If not streaming, use TorchService intent = new Intent(this, TorchService.class); intent.setAction(Constants.INTENT_ACTION_TOGGLE_TORCH); } - - // Start the appropriate service - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { - startForegroundService(intent); - } else { - startService(intent); - } - + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) startForegroundService(intent); + else startService(intent); } catch (Exception e) { FLog.e(TAG, "Error toggling torch", e); showTorchErrorToast(); @@ -63,20 +160,30 @@ protected void onCreate(Bundle savedInstanceState) { private void showTorchErrorToast() { String[] errorMessages = getResources().getStringArray(R.array.torch_error_messages); String humorousMessage = errorMessages[new Random().nextInt(errorMessages.length)]; - new Handler(Looper.getMainLooper()).post(() -> - Toast.makeText(this, humorousMessage, Toast.LENGTH_LONG).show() - ); + Toast.makeText(this, humorousMessage, Toast.LENGTH_LONG).show(); + } + + @Override public void onRequestPermissionsResult(int requestCode, String[] permissions, int[] grantResults) { + super.onRequestPermissionsResult(requestCode, permissions, grantResults); + if (requestCode == GUEST_PERMISSION_REQUEST && hasGuestPermissions()) loadGuestPublisher(); + else if (requestCode == GUEST_PERMISSION_REQUEST) finish(); } - @Override - protected void onStop() { + @Override protected void onStop() { super.onStop(); - finish(); + if (!guestMode) finish(); } - @Override - protected void onPause() { + @Override protected void onPause() { super.onPause(); - moveTaskToBack(true); + if (!guestMode) moveTaskToBack(true); + } + + @Override protected void onDestroy() { + if (guestWebView != null) { + guestWebView.stopLoading(); + guestWebView.destroy(); + } + super.onDestroy(); } } diff --git a/app/src/main/java/com/fadcam/fadrec/encoding/ScreenRecordingPipeline.java b/app/src/main/java/com/fadcam/fadrec/encoding/ScreenRecordingPipeline.java index f85b77e9b..a1670d329 100644 --- a/app/src/main/java/com/fadcam/fadrec/encoding/ScreenRecordingPipeline.java +++ b/app/src/main/java/com/fadcam/fadrec/encoding/ScreenRecordingPipeline.java @@ -26,6 +26,7 @@ import com.fadcam.VideoCodec; import com.fadcam.media.FragmentedMp4MuxerWrapper; import com.fadcam.opengl.WatermarkInfoProvider; +import com.fadcam.production.ProductionAudioDuckingBus; import java.io.FileDescriptor; import java.io.IOException; @@ -825,6 +826,9 @@ public void startRecording() throws IOException { if (enableAudio && audioEncoder != null) { audioEncoder.start(); audioRecord.startRecording(); + if (Constants.AUDIO_SOURCE_MIC.equals(audioSource)) { + ProductionAudioDuckingBus.publishCaptureState(true); + } } // Create VirtualDisplay @@ -1037,6 +1041,10 @@ private void startAudioRecordingLoop() { if (bytesRead > 0) { readCount++; audioBytesCaptured += bytesRead; + int rms = computeRms(audioBuffer, bytesRead); + if (Constants.AUDIO_SOURCE_MIC.equals(audioSource)) { + ProductionAudioDuckingBus.publishRms(rms); + } // Diagnostics: log the first reads and a periodic health // sample (RMS + device dB) so a silent capture is visible // in logs from the very first chunk. @@ -1060,6 +1068,9 @@ private void startAudioRecordingLoop() { + " (chunk#" + readCount + ")"); } } + if (Constants.AUDIO_SOURCE_MIC.equals(audioSource)) { + ProductionAudioDuckingBus.publishCaptureState(false); + } FLog.i(TAG, "FadRec audio loop ended: chunks=" + readCount + " bytes=" + audioBytesCaptured + " droppedInput=" + audioChunksDropped @@ -1068,18 +1079,22 @@ private void startAudioRecordingLoop() { audioRecordingThread.start(); } - /** Lightweight RMS of the first 512 samples — detects zero-capture immediately. */ + /** Lightweight little-endian PCM RMS used by diagnostics and voice ducking. */ private static int computeRms(ByteBuffer buffer, int bytes) { int samples = Math.min(bytes / 2, 512); if (samples <= 0) return 0; long sumSq = 0; for (int i = 0; i < samples; i++) { - short s = buffer.getShort(i * 2); - sumSq += (long) s * s; + int lo = buffer.get(i * 2) & 0xFF; + int hi = buffer.get(i * 2 + 1); + short sample = (short) (lo | (hi << 8)); + sumSq += (long) sample * sample; } return (int) Math.sqrt((double) sumSq / samples); } + // PRODUCTION_AUDIO_DUCKING_WIRED_V1 + private void zeroAudioBuffer(ByteBuffer buffer, int size) { for (int i = 0; i < size; i++) { buffer.put(i, (byte) 0); diff --git a/app/src/main/java/com/fadcam/production/DuetStudioFragment.java b/app/src/main/java/com/fadcam/production/DuetStudioFragment.java new file mode 100644 index 000000000..8669d8952 --- /dev/null +++ b/app/src/main/java/com/fadcam/production/DuetStudioFragment.java @@ -0,0 +1,584 @@ +package com.fadcam.production; + +import android.Manifest; +import android.app.Activity; +import android.content.Context; +import android.content.Intent; +import android.content.pm.PackageManager; +import android.graphics.Color; +import android.os.Bundle; +import android.view.Gravity; +import android.view.LayoutInflater; +import android.view.View; +import android.view.ViewGroup; +import android.widget.EditText; +import android.widget.FrameLayout; +import android.widget.LinearLayout; +import android.widget.TextView; +import android.widget.Toast; + +import androidx.activity.result.ActivityResultLauncher; +import androidx.activity.result.contract.ActivityResultContracts; +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; +import androidx.annotation.OptIn; +import androidx.camera.core.CameraSelector; +import androidx.camera.core.Preview; +import androidx.camera.lifecycle.ProcessCameraProvider; +import androidx.camera.view.PreviewView; +import androidx.core.content.ContextCompat; +import androidx.fragment.app.Fragment; +import androidx.media3.common.MediaItem; +import androidx.media3.common.util.UnstableApi; +import androidx.media3.exoplayer.ExoPlayer; +import androidx.media3.ui.PlayerView; + +import com.fadcam.Constants; +import com.fadcam.R; +import com.fadcam.SharedPreferencesManager; +import com.fadcam.fadrec.services.ScreenRecordingService; +import com.fadcam.streaming.RemoteStreamManager; +import com.fadcam.streaming.RemoteStreamService; +import com.google.android.material.button.MaterialButton; +import com.google.android.material.dialog.MaterialAlertDialogBuilder; + +import java.util.concurrent.ExecutionException; + +/** + * Mobile TV-production canvas. The same canvas is used by the production room's + * PROGRAM monitor, so the producer is always looking at the actual output. + */ +@OptIn(markerClass = UnstableApi.class) +public class DuetStudioFragment extends Fragment { + private static final String TAG = "DuetStudio"; + + private FrameLayout canvas; + private PlayerView playerView; + private PlayerView remotePlayerView; + private PreviewView cameraPreview; + private TextView lowerThird; + private TextView status; + private ExoPlayer player; + private ExoPlayer remotePlayer; + private Preview cameraUseCase; + private ProcessCameraProvider cameraProvider; + private ProductionScene scene = ProductionScene.DUET_PIP; + private ProductionControlState controlState; + private ProductionAudioDucker audioDucker; + private boolean recording; + private boolean live; + + private ActivityResultLauncher permissionLauncher; + private ActivityResultLauncher videoPicker; + private ActivityResultLauncher projectionLauncher; + + public static void installEntryButton(@Nullable ViewGroup quickActionsRow, @NonNull Fragment host) { + if (quickActionsRow == null || !host.isAdded()) return; + if (quickActionsRow.findViewWithTag(TAG) != null) return; + MaterialButton button = new MaterialButton(host.requireContext()); + button.setTag(TAG); + button.setText("TV\nPROD"); + button.setTextSize(10); + button.setAllCaps(false); + button.setTextColor(Color.WHITE); + button.setMinHeight(0); + button.setPadding(8, 0, 8, 0); + button.setOnClickListener(v -> { + if (!host.isAdded()) return; + ((com.fadcam.MainActivity) host.requireActivity()).showOverlayFragment( + new DuetStudioFragment(), "DuetStudio"); + }); + float density = host.getResources().getDisplayMetrics().density; + quickActionsRow.addView(button, new ViewGroup.LayoutParams( + dp(density, 78), dp(density, 58))); + } + + private static int dp(float density, int value) { return Math.round(value * density); } + + @Override + public void onCreate(@Nullable Bundle savedInstanceState) { + super.onCreate(savedInstanceState); + controlState = ProductionControlState.load(requireContext()); + scene = controlState.getProgramScene(); + audioDucker = new ProductionAudioDucker(); + audioDucker.start(); + + permissionLauncher = registerForActivityResult( + new ActivityResultContracts.RequestMultiplePermissions(), result -> { + if (!isAdded()) return; + if (hasCameraPermission()) startCamera(); + else showStatus("CAMERA PERMISSION REQUIRED"); + }); + videoPicker = registerForActivityResult( + new ActivityResultContracts.GetContent(), uri -> { + if (uri != null && isAdded()) loadVideo(uri); + }); + projectionLauncher = registerForActivityResult( + new ActivityResultContracts.StartActivityForResult(), result -> { + if (!isAdded()) return; + if (result.getResultCode() == Activity.RESULT_OK && result.getData() != null) { + beginRecording(result.getResultCode(), result.getData()); + } else { + live = false; + showStatus("SCREEN CAPTURE CANCELLED"); + } + }); + } + + @Nullable + @Override + public View onCreateView(@NonNull LayoutInflater inflater, + @Nullable ViewGroup container, + @Nullable Bundle state) { + LinearLayout root = new LinearLayout(requireContext()); + root.setOrientation(LinearLayout.VERTICAL); + root.setBackgroundColor(Color.BLACK); + + LinearLayout toolbar = new LinearLayout(requireContext()); + toolbar.setGravity(Gravity.CENTER_VERTICAL); + toolbar.setPadding(dp(12), dp(6), dp(12), dp(6)); + toolbar.setBackgroundColor(Color.rgb(55, 18, 18)); + TextView title = label("TV PRODUCTION", 18, Color.WHITE); + toolbar.addView(title, new LinearLayout.LayoutParams(0, dp(48), 1)); + MaterialButton load = action("LOAD VIDEO"); + MaterialButton scenes = action("SCENES"); + MaterialButton close = action("CLOSE"); + toolbar.addView(load, wrap()); + toolbar.addView(scenes, wrap()); + toolbar.addView(close, wrap()); + root.addView(toolbar); + + canvas = new FrameLayout(requireContext()); + canvas.setBackgroundColor(Color.BLACK); + root.addView(canvas, new LinearLayout.LayoutParams(-1, 0, 1)); + + playerView = new PlayerView(requireContext()); + playerView.setUseController(true); + playerView.setShutterBackgroundColor(Color.BLACK); + + remotePlayerView = new PlayerView(requireContext()); + remotePlayerView.setUseController(false); + remotePlayerView.setShutterBackgroundColor(Color.BLACK); + remotePlayerView.setVisibility(View.GONE); + + cameraPreview = new PreviewView(requireContext()); + cameraPreview.setScaleType(PreviewView.ScaleType.FILL_CENTER); + canvas.addView(playerView, match()); + canvas.addView(remotePlayerView, match()); + canvas.addView(cameraPreview, pipParams()); + + lowerThird = label("", 15, Color.WHITE); + lowerThird.setGravity(Gravity.CENTER_VERTICAL); + lowerThird.setPadding(dp(14), 0, dp(14), 0); + lowerThird.setBackgroundColor(0xCC551111); + FrameLayout.LayoutParams lower = new FrameLayout.LayoutParams(-1, dp(46), Gravity.BOTTOM); + lower.setMargins(dp(10), 0, dp(10), dp(10)); + canvas.addView(lowerThird, lower); + + LinearLayout controls = new LinearLayout(requireContext()); + controls.setPadding(dp(6), dp(6), dp(6), dp(6)); + controls.setGravity(Gravity.CENTER_VERTICAL); + controls.setBackgroundColor(Color.rgb(24, 24, 24)); + MaterialButton record = action("● RECORD"); + MaterialButton liveButton = action("LIVE + REC"); + MaterialButton mixer = action("AUDIO MIXER"); + MaterialButton brand = action("LOWER THIRD"); + controls.addView(record, new LinearLayout.LayoutParams(0, dp(50), 1)); + controls.addView(liveButton, new LinearLayout.LayoutParams(0, dp(50), 1)); + controls.addView(mixer, new LinearLayout.LayoutParams(0, dp(50), 1)); + controls.addView(brand, new LinearLayout.LayoutParams(0, dp(50), 1)); + root.addView(controls); + + status = label("READY • Select a scene or load a video", 12, Color.LTGRAY); + status.setPadding(dp(10), dp(4), dp(10), dp(7)); + root.addView(status); + + load.setOnClickListener(v -> videoPicker.launch("video/*")); + scenes.setOnClickListener(v -> showScenes()); + close.setOnClickListener(v -> closeOverlay()); + brand.setOnClickListener(v -> editLowerThird()); + mixer.setOnClickListener(v -> showAudioMixer()); + record.setOnClickListener(v -> requestRecord(false)); + liveButton.setOnClickListener(v -> requestRecord(true)); + + return root; + } + + @Override + public void onViewCreated(@NonNull View view, @Nullable Bundle state) { + super.onViewCreated(view, state); + if (hasCameraPermission()) startCamera(); + else permissionLauncher.launch(new String[]{Manifest.permission.CAMERA}); + applyScene(); + } + + private void loadVideo(android.net.Uri uri) { + if (audioDucker != null) audioDucker.detachPlayer(); + if (player != null) player.release(); + player = new ExoPlayer.Builder(requireContext()).build(); + player.setMediaItem(MediaItem.fromUri(uri)); + player.prepare(); + player.setPlayWhenReady(true); + playerView.setPlayer(player); + if (audioDucker != null) audioDucker.attach(player); + showStatus("VIDEO LOADED • " + scene.getTitle() + " • VOICE DUCKING READY"); + } + + private void showAudioMixer() { + if (audioDucker == null) return; + ProductionAudioMixerDialog.show(requireContext(), audioDucker); + } + + private void showScenes() { + controlState = ProductionControlState.load(requireContext()).withProgram(scene); + ViewGroup parent = canvas == null || !(canvas.getParent() instanceof ViewGroup) + ? null : (ViewGroup) canvas.getParent(); + + ProductionControlRoomDialog dialog = new ProductionControlRoomDialog( + requireContext(), controlState, canvas, parent, + new ProductionControlRoomDialog.Listener() { + @Override + public void onStateChanged(@NonNull ProductionControlState state, + boolean applyToProgram) { + controlState = state; + if (applyToProgram) { + scene = state.getProgramScene(); + ProductionSceneManager.setScene(requireContext(), scene); + connectRemoteCamera(state.getProgramCameraSlot()); + applySceneWithTransition(state.getTransition(), state.getTransitionDurationMs()); + } + showStatus("PROGRAM • " + programLabel(state) + + " • PREVIEW • " + previewLabel(state)); + } + + @Override + public void onCameraSlotChanged(int slot, @NonNull String streamUrl) { + connectRemoteCamera(slot, streamUrl); + if (controlState.getProgramScene() == ProductionScene.CAMERA + && controlState.getProgramCameraSlot() == slot) { + applyScene(); + } + } + + @Override + public void onCloseRequested() { + showStatus("READY • " + scene.getTitle()); + } + }); + dialog.show(); + } + + private String programLabel(@NonNull ProductionControlState state) { + return state.getProgramScene() == ProductionScene.CAMERA + ? "Camera " + state.getProgramCameraSlot() + : state.getProgramScene().getTitle(); + } + + private String previewLabel(@NonNull ProductionControlState state) { + return state.getPreviewScene() == ProductionScene.CAMERA + ? "Camera " + state.getPreviewCameraSlot() + : state.getPreviewScene().getTitle(); + } + + private void editLowerThird() { + EditText input = new EditText(requireContext()); + input.setSingleLine(true); + input.setHint("Producer / show / guest"); + input.setText(ProductionSceneManager.getLowerThird(requireContext())); + new MaterialAlertDialogBuilder(requireContext()) + .setTitle("Production Lower Third") + .setView(input) + .setPositiveButton("APPLY", (d, w) -> { + ProductionSceneManager.setLowerThird(requireContext(), input.getText().toString()); + applyLowerThird(); + }) + .setNegativeButton("CLEAR", (d, w) -> { + ProductionSceneManager.setLowerThird(requireContext(), ""); + applyLowerThird(); + }) + .show(); + } + + private void applyLowerThird() { + if (lowerThird == null || !isAdded()) return; + String text = ProductionSceneManager.getLowerThird(requireContext()); + lowerThird.setText(text); + lowerThird.setVisibility(text.isEmpty() ? View.GONE : View.VISIBLE); + } + + private void applySceneWithTransition(@NonNull ProductionControlState.Transition transition, + int durationMs) { + if (canvas == null || transition == ProductionControlState.Transition.CUT) { + applyScene(); + return; + } + canvas.animate().cancel(); + int duration = ProductionControlState.clampDuration(durationMs); + if (transition == ProductionControlState.Transition.WIPE) { + float width = Math.max(canvas.getWidth(), 1); + canvas.setTranslationX(0f); + canvas.animate().translationX(-width * 0.18f).setDuration(duration / 2L) + .withEndAction(() -> { + applyScene(); + canvas.setTranslationX(width * 0.18f); + canvas.animate().translationX(0f).setDuration(duration / 2L).start(); + }).start(); + return; + } + + canvas.setAlpha(1f); + canvas.animate().alpha(0f).setDuration(duration / 2L).withEndAction(() -> { + applyScene(); + canvas.animate().alpha(1f).setDuration(duration / 2L).start(); + }).start(); + } + + private void applyScene() { + if (canvas == null || playerView == null || cameraPreview == null || remotePlayerView == null) return; + + int programSlot = controlState == null ? 1 : controlState.getProgramCameraSlot(); + ProductionCameraSlot slot = ProductionCameraSlotManager.get(requireContext(), programSlot); + boolean cameraLayout = scene == ProductionScene.CAMERA + || scene == ProductionScene.DUET_PIP + || scene == ProductionScene.DUET_SPLIT + || scene == ProductionScene.COMMENTARY + || scene == ProductionScene.INTERVIEW; + boolean useRemote = cameraLayout && !slot.getStreamUrl().isEmpty(); + if (useRemote) connectRemoteCamera(slot.getSlot()); + + FrameLayout.LayoutParams playerLp = match(); + FrameLayout.LayoutParams cameraLp; + FrameLayout.LayoutParams remoteLp; + playerView.setVisibility(View.VISIBLE); + cameraPreview.setVisibility(View.VISIBLE); + remotePlayerView.setVisibility(useRemote ? View.VISIBLE : View.GONE); + + switch (scene) { + case CAMERA: + playerView.setVisibility(View.GONE); + cameraPreview.setVisibility(useRemote ? View.GONE : View.VISIBLE); + cameraLp = match(); + remoteLp = match(); + break; + case VIDEO: + cameraPreview.setVisibility(View.GONE); + remotePlayerView.setVisibility(View.GONE); + cameraLp = pipParams(); + remoteLp = pipParams(); + break; + case DUET_SPLIT: + int half = Math.max(1, canvas.getWidth() / 2); + playerLp = new FrameLayout.LayoutParams(half, -1, Gravity.START); + cameraLp = new FrameLayout.LayoutParams(half, -1, Gravity.END); + remoteLp = new FrameLayout.LayoutParams(half, -1, Gravity.END); + break; + case COMMENTARY: + cameraLp = new FrameLayout.LayoutParams(dp(190), dp(270), Gravity.TOP | Gravity.END); + cameraLp.setMargins(0, dp(16), dp(16), 0); + remoteLp = new FrameLayout.LayoutParams(dp(190), dp(270), Gravity.TOP | Gravity.END); + remoteLp.setMargins(0, dp(16), dp(16), 0); + break; + case INTERVIEW: + cameraLp = new FrameLayout.LayoutParams(dp(230), dp(310), Gravity.BOTTOM | Gravity.END); + cameraLp.setMargins(0, 0, dp(16), dp(70)); + remoteLp = new FrameLayout.LayoutParams(dp(230), dp(310), Gravity.BOTTOM | Gravity.END); + remoteLp.setMargins(0, 0, dp(16), dp(70)); + break; + case DUET_PIP: + default: + cameraLp = pipParams(); + remoteLp = pipParams(); + break; + } + + playerView.setLayoutParams(playerLp); + cameraPreview.setLayoutParams(cameraLp); + remotePlayerView.setLayoutParams(remoteLp); + if (useRemote) { + cameraPreview.setVisibility(View.GONE); + remotePlayerView.setVisibility(View.VISIBLE); + } + applyLowerThird(); + showStatus("PROGRAM • " + programLabel(controlState == null + ? ProductionControlState.load(requireContext()) : controlState)); + } + + private void connectRemoteCamera(int slot) { + ProductionCameraSlot camera = ProductionCameraSlotManager.get(requireContext(), slot); + connectRemoteCamera(slot, camera.getStreamUrl()); + } + + private void connectRemoteCamera(int slot, @NonNull String streamUrl) { + String url = streamUrl.trim(); + if (url.isEmpty()) { + if (remotePlayer != null) { + remotePlayer.release(); + remotePlayer = null; + } + remotePlayerView.setPlayer(null); + return; + } + if (remotePlayer != null) { + remotePlayer.release(); + remotePlayer = null; + } + try { + remotePlayer = new ExoPlayer.Builder(requireContext()).build(); + remotePlayer.setMediaItem(MediaItem.fromUri(android.net.Uri.parse(url))); + remotePlayer.prepare(); + remotePlayer.setPlayWhenReady(true); + remotePlayerView.setPlayer(remotePlayer); + showStatus("CAMERA " + slot + " • REMOTE STREAM CONNECTING"); + } catch (Exception e) { + if (remotePlayer != null) remotePlayer.release(); + remotePlayer = null; + remotePlayerView.setPlayer(null); + showStatus("CAMERA " + slot + " • INVALID STREAM URL"); + } + } + + private void requestRecord(boolean requestLive) { + if (recording) { + stopRecording(); + return; + } + if (!hasCameraPermission()) { + permissionLauncher.launch(new String[]{Manifest.permission.CAMERA}); + return; + } + if (requestLive && player == null && remotePlayer == null) { + Toast.makeText(requireContext(), "Load a video or connect a camera source before going live.", + Toast.LENGTH_SHORT).show(); + return; + } + live = requestLive; + android.media.projection.MediaProjectionManager manager = + (android.media.projection.MediaProjectionManager) requireContext() + .getSystemService(Context.MEDIA_PROJECTION_SERVICE); + if (manager == null) { + showStatus("MEDIA PROJECTION IS NOT AVAILABLE"); + return; + } + showStatus(requestLive ? "STARTING LIVE + RECORDING…" : "STARTING RECORDING…"); + projectionLauncher.launch(manager.createScreenCaptureIntent()); + } + + private void beginRecording(int resultCode, Intent permissionData) { + recording = true; + if (live) { + RemoteStreamManager manager = RemoteStreamManager.getInstance(); + manager.setContext(requireContext()); + manager.setStreamingMode( + SharedPreferencesManager.getInstance(requireContext()).getStreamingMode()); + ContextCompat.startForegroundService(requireContext(), + new Intent(requireContext(), RemoteStreamService.class)); + } + Intent record = new Intent(requireContext(), ScreenRecordingService.class); + record.setAction(Constants.INTENT_ACTION_START_SCREEN_RECORDING); + record.putExtra("resultCode", resultCode); + record.putExtra("permissionData", permissionData); + record.putExtra(Constants.EXTRA_SCREEN_RECORDING_FORCE_NO_AUDIO, false); + ContextCompat.startForegroundService(requireContext(), record); + showStatus(live ? "ON AIR • LIVE + RECORDING • " + scene.getTitle() + : "RECORDING • " + scene.getTitle()); + } + + private void stopRecording() { + if (!isAdded()) return; + Intent stop = new Intent(requireContext(), ScreenRecordingService.class); + stop.setAction(Constants.INTENT_ACTION_STOP_SCREEN_RECORDING); + requireContext().startService(stop); + if (live) requireContext().stopService(new Intent(requireContext(), RemoteStreamService.class)); + recording = false; + live = false; + showStatus("STOPPED • FadRec is finalizing the production file"); + } + + private boolean hasCameraPermission() { + return isAdded() + && ContextCompat.checkSelfPermission(requireContext(), Manifest.permission.CAMERA) + == PackageManager.PERMISSION_GRANTED; + } + + private void startCamera() { + if (!isAdded() || cameraPreview == null) return; + final PreviewView target = cameraPreview; + ProcessCameraProvider.getInstance(requireContext()).addListener(() -> { + if (!isAdded() || target != cameraPreview || cameraPreview == null) return; + try { + cameraProvider = ProcessCameraProvider.getInstance(requireContext()).get(); + if (cameraUseCase != null) cameraProvider.unbind(cameraUseCase); + cameraUseCase = new Preview.Builder().build(); + cameraUseCase.setSurfaceProvider(cameraPreview.getSurfaceProvider()); + cameraProvider.bindToLifecycle(this, CameraSelector.DEFAULT_FRONT_CAMERA, cameraUseCase); + } catch (ExecutionException | InterruptedException | IllegalArgumentException e) { + if (isAdded()) showStatus("CAMERA ERROR • " + e.getClass().getSimpleName()); + } + }, ContextCompat.getMainExecutor(requireContext())); + } + + private void closeOverlay() { + if (recording) stopRecording(); + if (getParentFragmentManager().getBackStackEntryCount() > 0) { + getParentFragmentManager().popBackStack(); + } + } + + private void showStatus(String text) { + if (status != null) status.setText(text); + } + + private MaterialButton action(String text) { + MaterialButton b = new MaterialButton(requireContext()); + b.setText(text); + b.setTextSize(10); + b.setAllCaps(false); + b.setTextColor(Color.WHITE); + b.setMinHeight(0); + return b; + } + + private TextView label(String text, float size, int color) { + TextView v = new TextView(requireContext()); + v.setText(text); + v.setTextSize(size); + v.setTextColor(color); + return v; + } + + private FrameLayout.LayoutParams match() { return new FrameLayout.LayoutParams(-1, -1); } + + private FrameLayout.LayoutParams pipParams() { + FrameLayout.LayoutParams p = new FrameLayout.LayoutParams( + dp(190), dp(270), Gravity.BOTTOM | Gravity.END); + p.setMargins(0, 0, dp(16), dp(16)); + return p; + } + + private LinearLayout.LayoutParams wrap() { + return new LinearLayout.LayoutParams(-2, dp(48)); + } + + private int dp(int value) { + return Math.round(value * getResources().getDisplayMetrics().density); + } + + @Override + public void onDestroyView() { + if (recording) stopRecording(); + if (audioDucker != null) { + audioDucker.stop(); + audioDucker.detachPlayer(); + } + if (cameraProvider != null && cameraUseCase != null) { + try { cameraProvider.unbind(cameraUseCase); } catch (Exception ignored) {} + } + cameraUseCase = null; + cameraProvider = null; + if (player != null) player.release(); + player = null; + if (remotePlayer != null) remotePlayer.release(); + remotePlayer = null; + super.onDestroyView(); + } +} diff --git a/app/src/main/java/com/fadcam/production/ProductionAudioDucker.java b/app/src/main/java/com/fadcam/production/ProductionAudioDucker.java new file mode 100644 index 000000000..3dd0ba031 --- /dev/null +++ b/app/src/main/java/com/fadcam/production/ProductionAudioDucker.java @@ -0,0 +1,218 @@ +package com.fadcam.production; + +import android.os.Handler; +import android.os.Looper; +import android.os.SystemClock; + +import androidx.annotation.NonNull; +import androidx.media3.exoplayer.ExoPlayer; + +/** + * Broadcast-style voice ducking for the duet/media player. + * + * The microphone level comes from the same AudioRecord that is already writing + * the final recording. This avoids opening a competing microphone capture. + * The detector smooths the RMS level, adapts to the local noise floor, and uses + * separate enter/exit hysteresis so background noise does not make the video + * pump up and down between syllables. Playback gain is then passed through the + * production audio mix bus so MEDIA and MASTER faders remain functional. + */ +public final class ProductionAudioDucker implements ProductionAudioDuckingBus.Listener { + public static final float DEFAULT_NORMAL_VOLUME = 1.0f; + public static final float DEFAULT_DUCK_VOLUME = 0.22f; + public static final float DEFAULT_THRESHOLD = 0.12f; + + private static final long ATTACK_MS = 90L; + private static final long RELEASE_MS = 420L; + private static final int SPEECH_ENTER_FRAMES = 2; + private static final int SPEECH_EXIT_FRAMES = 8; + private static final float LEVEL_SMOOTHING = 0.35f; + private static final float NOISE_FLOOR_SMOOTHING = 0.04f; + private static final float MIN_NOISE_MARGIN = 0.025f; + private static final float EXIT_THRESHOLD_RATIO = 0.65f; + + private final Handler mainHandler = new Handler(Looper.getMainLooper()); + private ExoPlayer player; + private boolean enabled = true; + private boolean captureActive; + private boolean speechActive; + private int speechFrames; + private int silenceFrames; + private float smoothedLevel; + private float noiseFloor = 0.03f; + private float normalVolume = DEFAULT_NORMAL_VOLUME; + private float duckVolume = DEFAULT_DUCK_VOLUME; + private float threshold = DEFAULT_THRESHOLD; + private float currentVolume = DEFAULT_NORMAL_VOLUME; + private float targetVolume = DEFAULT_NORMAL_VOLUME; + private long lastUpdateMs; + private boolean subscribed; + + public void attach(@NonNull ExoPlayer player) { + detachPlayer(); + this.player = player; + this.currentVolume = clamp(normalVolume); + this.targetVolume = this.currentVolume; + applyVolume(this.currentVolume); + } + + public void detachPlayer() { + player = null; + lastUpdateMs = 0L; + } + + public void start() { + if (!subscribed) { + ProductionAudioDuckingBus.subscribe(this); + subscribed = true; + } + } + + public void stop() { + if (subscribed) { + ProductionAudioDuckingBus.unsubscribe(this); + subscribed = false; + } + resetDetector(); + captureActive = false; + targetVolume = normalVolume; + animateToTarget(); + } + + public void setEnabled(boolean enabled) { + this.enabled = enabled; + if (!enabled) resetDetector(); + targetVolume = enabled && captureActive && speechActive ? duckVolume : normalVolume; + animateToTarget(); + } + + public boolean isEnabled() { return enabled; } + + public void setNormalVolume(float volume) { + normalVolume = clamp(volume); + if (!captureActive || !enabled || !speechActive) { + targetVolume = normalVolume; + animateToTarget(); + } + } + + public float getNormalVolume() { return normalVolume; } + + public void setDuckVolume(float volume) { + duckVolume = clamp(volume); + if (speechActive && enabled && captureActive) { + targetVolume = duckVolume; + animateToTarget(); + } + } + + public float getDuckVolume() { return duckVolume; } + + public void setThreshold(float threshold) { + this.threshold = clamp(threshold); + } + + public float getThreshold() { return threshold; } + + @Override + public void onMicLevel(float normalizedLevel) { + if (!enabled || !captureActive) return; + final float level = clamp(normalizedLevel); + mainHandler.post(() -> { + if (!enabled || !captureActive) return; + updateSpeechState(level); + targetVolume = speechActive ? duckVolume : normalVolume; + animateToTarget(); + }); + } + + @Override + public void onCaptureState(boolean active) { + mainHandler.post(() -> { + captureActive = active; + if (!active) { + resetDetector(); + targetVolume = normalVolume; + } else { + resetDetector(); + } + animateToTarget(); + }); + } + + private void updateSpeechState(float level) { + smoothedLevel += (level - smoothedLevel) * LEVEL_SMOOTHING; + + float enterThreshold = Math.max(threshold, noiseFloor + MIN_NOISE_MARGIN); + float exitThreshold = Math.max(enterThreshold * EXIT_THRESHOLD_RATIO, + noiseFloor + MIN_NOISE_MARGIN * 0.5f); + + if (!speechActive && smoothedLevel < enterThreshold * 0.85f) { + noiseFloor += (smoothedLevel - noiseFloor) * NOISE_FLOOR_SMOOTHING; + noiseFloor = clamp(noiseFloor); + enterThreshold = Math.max(threshold, noiseFloor + MIN_NOISE_MARGIN); + exitThreshold = Math.max(enterThreshold * EXIT_THRESHOLD_RATIO, + noiseFloor + MIN_NOISE_MARGIN * 0.5f); + } + + if (!speechActive) { + if (smoothedLevel >= enterThreshold) { + speechFrames++; + silenceFrames = 0; + if (speechFrames >= SPEECH_ENTER_FRAMES) { + speechActive = true; + speechFrames = 0; + } + } else { + speechFrames = 0; + } + } else { + if (smoothedLevel <= exitThreshold) { + silenceFrames++; + speechFrames = 0; + if (silenceFrames >= SPEECH_EXIT_FRAMES) { + speechActive = false; + silenceFrames = 0; + } + } else { + silenceFrames = 0; + } + } + } + + private void resetDetector() { + speechActive = false; + speechFrames = 0; + silenceFrames = 0; + smoothedLevel = 0f; + noiseFloor = 0.03f; + } + + private void animateToTarget() { + if (player == null) return; + long now = SystemClock.uptimeMillis(); + long elapsed = lastUpdateMs == 0L ? 16L : Math.max(1L, now - lastUpdateMs); + lastUpdateMs = now; + float target = clamp(targetVolume); + float duration = target < currentVolume ? ATTACK_MS : RELEASE_MS; + float step = Math.min(1f, elapsed / duration); + currentVolume += (target - currentVolume) * step; + if (Math.abs(target - currentVolume) < 0.005f) currentVolume = target; + applyVolume(currentVolume); + + if (Math.abs(target - currentVolume) >= 0.005f) { + mainHandler.postDelayed(this::animateToTarget, 16L); + } + } + + private void applyVolume(float volume) { + ExoPlayer p = player; + if (p != null) { + p.setVolume(clamp(volume * ProductionAudioMixBus.getMediaGain())); + } + } + + private static float clamp(float value) { + return Math.max(0f, Math.min(1f, value)); + } +} diff --git a/app/src/main/java/com/fadcam/production/ProductionAudioDuckingBus.java b/app/src/main/java/com/fadcam/production/ProductionAudioDuckingBus.java new file mode 100644 index 000000000..db8c24022 --- /dev/null +++ b/app/src/main/java/com/fadcam/production/ProductionAudioDuckingBus.java @@ -0,0 +1,58 @@ +package com.fadcam.production; + +import androidx.annotation.Nullable; + +import java.util.concurrent.CopyOnWriteArrayList; + +/** + * Process-local bridge between FadRec's real microphone capture loop and the + * TV-production audio ducking UI. The recorder owns the microphone; the UI only + * receives level telemetry, so we never open a second AudioRecord and risk + * competing for the mic on Android. + * + * Verification marker: voice-ducking wiring is intentionally driven by the + * recorder's existing PCM stream, not a second microphone capture. + */ +public final class ProductionAudioDuckingBus { + public interface Listener { + void onMicLevel(float normalizedLevel); + void onCaptureState(boolean active); + } + + private static final CopyOnWriteArrayList LISTENERS = new CopyOnWriteArrayList<>(); + + private ProductionAudioDuckingBus() {} + + public static void subscribe(@Nullable Listener listener) { + if (listener != null) LISTENERS.addIfAbsent(listener); + } + + public static void unsubscribe(@Nullable Listener listener) { + if (listener != null) LISTENERS.remove(listener); + } + + /** Publishes an RMS value from the existing recorder-owned PCM buffer. */ + public static void publishRms(int rms) { + // Speech-level RMS varies substantially by microphone/device. This mapping + // deliberately saturates gradually rather than treating one fixed dB value + // as universal. The ducking controller adds hysteresis and smoothing. + float normalized = Math.min(1f, Math.max(0f, rms / 6000f)); + for (Listener listener : LISTENERS) { + try { + listener.onMicLevel(normalized); + } catch (RuntimeException ignored) { + // A stale UI listener must never interrupt the audio thread. + } + } + } + + public static void publishCaptureState(boolean active) { + for (Listener listener : LISTENERS) { + try { + listener.onCaptureState(active); + } catch (RuntimeException ignored) { + // Never let UI lifecycle errors reach the recorder thread. + } + } + } +} diff --git a/app/src/main/java/com/fadcam/production/ProductionAudioMixBus.java b/app/src/main/java/com/fadcam/production/ProductionAudioMixBus.java new file mode 100644 index 000000000..12fe67812 --- /dev/null +++ b/app/src/main/java/com/fadcam/production/ProductionAudioMixBus.java @@ -0,0 +1,26 @@ +package com.fadcam.production; + +import androidx.annotation.Nullable; +import java.util.concurrent.CopyOnWriteArrayList; + +/** Process-local playback mix bus. Recorder microphone capture remains authoritative elsewhere. */ +public final class ProductionAudioMixBus { + public interface Listener { void onMixChanged(); } + public static final int CAMERA_1 = 0, CAMERA_2 = 1, MEDIA = 2, MASTER = 3; + private static final float[] LEVELS = {0.75f, 0.75f, 0.65f, 0.85f}; + private static final CopyOnWriteArrayList LISTENERS = new CopyOnWriteArrayList<>(); + private ProductionAudioMixBus() {} + public static void subscribe(@Nullable Listener listener) { if (listener != null) LISTENERS.addIfAbsent(listener); } + public static void unsubscribe(@Nullable Listener listener) { if (listener != null) LISTENERS.remove(listener); } + public static synchronized void setLevel(int channel, int percent) { + if (channel < 0 || channel >= LEVELS.length) return; + LEVELS[channel] = clamp(percent / 100f); + for (Listener listener : LISTENERS) { + try { listener.onMixChanged(); } catch (RuntimeException ignored) { } + } + } + public static synchronized float getLevel(int channel) { return channel < 0 || channel >= LEVELS.length ? 1f : LEVELS[channel]; } + public static synchronized float getMediaGain() { return LEVELS[MEDIA] * LEVELS[MASTER]; } + public static synchronized float getCameraGain(int slot) { return (slot == 2 ? LEVELS[CAMERA_2] : LEVELS[CAMERA_1]) * LEVELS[MASTER]; } + private static float clamp(float value) { return Math.max(0f, Math.min(1f, value)); } +} diff --git a/app/src/main/java/com/fadcam/production/ProductionAudioMixerDialog.java b/app/src/main/java/com/fadcam/production/ProductionAudioMixerDialog.java new file mode 100644 index 000000000..a5ac237c9 --- /dev/null +++ b/app/src/main/java/com/fadcam/production/ProductionAudioMixerDialog.java @@ -0,0 +1,106 @@ +package com.fadcam.production; + +import android.content.Context; +import android.graphics.Color; +import android.view.Gravity; +import android.widget.LinearLayout; +import android.widget.SeekBar; +import android.widget.Switch; +import android.widget.TextView; + +import androidx.annotation.NonNull; + +import com.google.android.material.dialog.MaterialAlertDialogBuilder; + +/** Compact field mixer for the duet playback bus. */ +public final class ProductionAudioMixerDialog { + private ProductionAudioMixerDialog() {} + + public static void show(@NonNull Context context, @NonNull ProductionAudioDucker ducker) { + LinearLayout root = new LinearLayout(context); + root.setOrientation(LinearLayout.VERTICAL); + root.setPadding(dp(context, 8), dp(context, 2), dp(context, 8), 0); + + Switch enabled = new Switch(context); + enabled.setText("VOICE DUCKING"); + enabled.setTextColor(Color.WHITE); + enabled.setChecked(ducker.isEnabled()); + root.addView(enabled, new LinearLayout.LayoutParams(-1, dp(context, 48))); + + TextView help = text(context, "When your microphone level crosses the speech threshold, the loaded video is smoothly lowered. When you stop, it returns gradually."); + help.setTextColor(Color.LTGRAY); + help.setTextSize(12); + help.setPadding(0, 0, 0, dp(context, 8)); + root.addView(help); + + TextView duckLabel = text(context, "DUCK LEVEL • " + percent(ducker.getDuckVolume())); + root.addView(duckLabel); + SeekBar duckBar = new SeekBar(context); + duckBar.setMax(80); + duckBar.setProgress(Math.round(ducker.getDuckVolume() * 100f)); + root.addView(duckBar, new LinearLayout.LayoutParams(-1, dp(context, 44))); + + TextView thresholdLabel = text(context, "VOICE THRESHOLD • " + percent(ducker.getThreshold())); + root.addView(thresholdLabel); + SeekBar thresholdBar = new SeekBar(context); + thresholdBar.setMax(50); + thresholdBar.setProgress(Math.round(ducker.getThreshold() * 100f)); + root.addView(thresholdBar, new LinearLayout.LayoutParams(-1, dp(context, 44))); + + TextView normalLabel = text(context, "NORMAL VIDEO LEVEL • " + percent(ducker.getNormalVolume())); + root.addView(normalLabel); + SeekBar normalBar = new SeekBar(context); + normalBar.setMax(100); + normalBar.setProgress(Math.round(ducker.getNormalVolume() * 100f)); + root.addView(normalBar, new LinearLayout.LayoutParams(-1, dp(context, 44))); + + enabled.setOnCheckedChangeListener((button, checked) -> ducker.setEnabled(checked)); + duckBar.setOnSeekBarChangeListener(new SimpleSeekListener() { + @Override public void onProgressChanged(SeekBar bar, int value, boolean fromUser) { + float level = Math.max(0.05f, Math.min(0.80f, value / 100f)); + ducker.setDuckVolume(level); + duckLabel.setText("DUCK LEVEL • " + percent(level)); + } + }); + thresholdBar.setOnSeekBarChangeListener(new SimpleSeekListener() { + @Override public void onProgressChanged(SeekBar bar, int value, boolean fromUser) { + float level = Math.max(0.03f, Math.min(0.50f, value / 100f)); + ducker.setThreshold(level); + thresholdLabel.setText("VOICE THRESHOLD • " + percent(level)); + } + }); + normalBar.setOnSeekBarChangeListener(new SimpleSeekListener() { + @Override public void onProgressChanged(SeekBar bar, int value, boolean fromUser) { + float level = Math.max(0.10f, Math.min(1f, value / 100f)); + ducker.setNormalVolume(level); + normalLabel.setText("NORMAL VIDEO LEVEL • " + percent(level)); + } + }); + + new MaterialAlertDialogBuilder(context) + .setTitle("AUDIO MIXER • VOICE DUCKING") + .setMessage("Mic → voice detector → duck loaded-video audio → smooth release") + .setView(root) + .setPositiveButton("DONE", null) + .show(); + } + + private static TextView text(Context context, String value) { + TextView view = new TextView(context); + view.setText(value); + view.setTextColor(Color.WHITE); + view.setTextSize(12); + view.setGravity(Gravity.CENTER_VERTICAL); + return view; + } + + private static String percent(float value) { return Math.round(value * 100f) + "%"; } + private static int dp(Context context, int value) { + return Math.round(value * context.getResources().getDisplayMetrics().density); + } + + private abstract static class SimpleSeekListener implements SeekBar.OnSeekBarChangeListener { + @Override public void onStartTrackingTouch(SeekBar bar) {} + @Override public void onStopTrackingTouch(SeekBar bar) {} + } +} diff --git a/app/src/main/java/com/fadcam/production/ProductionCameraSlot.java b/app/src/main/java/com/fadcam/production/ProductionCameraSlot.java new file mode 100644 index 000000000..206c567bd --- /dev/null +++ b/app/src/main/java/com/fadcam/production/ProductionCameraSlot.java @@ -0,0 +1,55 @@ +package com.fadcam.production; + +import androidx.annotation.NonNull; + +/** Producer camera slot backed by a one-use WebRTC guest invitation. */ +public final class ProductionCameraSlot { + public enum Status { EMPTY, WAITING, CONNECTED, ERROR } + + private final int slot; + private final String guestName; + private final String inviteToken; + private final String activeStreamId; + private final String streamUrl; + private final boolean inviteConsumed; + private final Status status; + + public ProductionCameraSlot(int slot, String guestName, String inviteToken, + String streamUrl, Status status) { + this(slot, guestName, inviteToken, "", streamUrl, false, status); + } + + public ProductionCameraSlot(int slot, String guestName, String inviteToken, + String activeStreamId, String streamUrl, + boolean inviteConsumed, Status status) { + this.slot = ProductionControlState.clampCameraSlot(slot); + this.guestName = guestName == null ? "" : guestName.trim(); + this.inviteToken = inviteToken == null ? "" : inviteToken.trim(); + this.activeStreamId = activeStreamId == null ? "" : activeStreamId.trim(); + this.streamUrl = streamUrl == null ? "" : streamUrl.trim(); + this.inviteConsumed = inviteConsumed; + this.status = status == null ? Status.EMPTY : status; + } + + public int getSlot() { return slot; } + public String getGuestName() { return guestName; } + public String getInviteToken() { return inviteToken; } + public String getActiveStreamId() { return activeStreamId; } + public String getStreamUrl() { return streamUrl; } + public boolean isInviteConsumed() { return inviteConsumed; } + public Status getStatus() { return status; } + + @NonNull public ProductionCameraSlot withGuestName(String value) { + return new ProductionCameraSlot(slot, value, inviteToken, activeStreamId, streamUrl, inviteConsumed, status); + } + + @NonNull public ProductionCameraSlot withStreamUrl(String value) { + String url = value == null ? "" : value.trim(); + return new ProductionCameraSlot(slot, guestName, inviteToken, activeStreamId, url, inviteConsumed, + url.isEmpty() ? Status.WAITING : Status.CONNECTED); + } + + @NonNull public ProductionCameraSlot withStatus(Status value) { + return new ProductionCameraSlot(slot, guestName, inviteToken, activeStreamId, streamUrl, inviteConsumed, value); + } +} diff --git a/app/src/main/java/com/fadcam/production/ProductionCameraSlotManager.java b/app/src/main/java/com/fadcam/production/ProductionCameraSlotManager.java new file mode 100644 index 000000000..285b5845e --- /dev/null +++ b/app/src/main/java/com/fadcam/production/ProductionCameraSlotManager.java @@ -0,0 +1,179 @@ +package com.fadcam.production; + +import android.content.Context; +import android.content.SharedPreferences; + +import androidx.annotation.NonNull; + +import java.security.SecureRandom; +import java.util.Locale; + +/** Persistent six-slot guest camera lifecycle backed by VDO.Ninja WebRTC. */ +public final class ProductionCameraSlotManager { + private static final String PREFS = "fadcam_production_camera_slots"; + private static final int SLOT_COUNT = 6; + private static final SecureRandom RANDOM = new SecureRandom(); + private static final String VDO_BASE = "https://vdo.ninja/?"; + + private ProductionCameraSlotManager() {} + + @NonNull public static ProductionCameraSlot get(Context context, int slot) { + int s = ProductionControlState.clampCameraSlot(slot); + SharedPreferences p = prefs(context); + String token = p.getString(key(s, "token"), ""); + if (token.isEmpty()) { + token = newToken(); + p.edit().putString(key(s, "token"), token).putBoolean(key(s, "consumed"), false).apply(); + } + String active = p.getString(key(s, "active_stream_id"), ""); + String name = p.getString(key(s, "name"), ""); + String url = p.getString(key(s, "url"), ""); + boolean waiting = p.getBoolean(key(s, "waiting"), false); + boolean consumed = p.getBoolean(key(s, "consumed"), false); + ProductionCameraSlot.Status status = !active.isEmpty() + ? ProductionCameraSlot.Status.CONNECTED + : (waiting ? ProductionCameraSlot.Status.WAITING : ProductionCameraSlot.Status.EMPTY); + return new ProductionCameraSlot(s, name, token, active, url, consumed, status); + } + + @NonNull public static ProductionCameraSlot[] getAll(Context context) { + ProductionCameraSlot[] result = new ProductionCameraSlot[SLOT_COUNT]; + for (int i = 0; i < SLOT_COUNT; i++) result[i] = get(context, i + 1); + return result; + } + + /** Creates a fresh invitation for an idle slot; connected guests are never interrupted. */ + @NonNull public static ProductionCameraSlot createInvite(Context context, int slot, String guestName) { + int s = ProductionControlState.clampCameraSlot(slot); + SharedPreferences p = prefs(context); + if (!p.getString(key(s, "active_stream_id"), "").isEmpty()) return get(context, s); + String token = newToken(); + p.edit().putString(key(s, "token"), token) + .putString(key(s, "name"), guestName == null ? "" : guestName.trim()) + .putBoolean(key(s, "waiting"), true) + .putBoolean(key(s, "consumed"), false) + .apply(); + return get(context, s); + } + + /** + * Marks the current invitation as waiting without rotating its token. This + * is intentionally idempotent so a share flow cannot invalidate the link + * that was just displayed to the producer. + */ + @NonNull public static ProductionCameraSlot markInvited(Context context, int slot, String guestName) { + int s = ProductionControlState.clampCameraSlot(slot); + ProductionCameraSlot current = get(context, s); + if (current.getActiveStreamId().isEmpty() && !current.isInviteConsumed() + && current.getStatus() == ProductionCameraSlot.Status.WAITING) { + prefs(context).edit() + .putString(key(s, "name"), guestName == null ? "" : guestName.trim()) + .putBoolean(key(s, "waiting"), true) + .apply(); + return get(context, s); + } + return createInvite(context, s, guestName); + } + + /** Validates the currently issued one-use invitation without consuming it. */ + public static boolean isInviteActive(Context context, int slot, String token) { + if (token == null || token.trim().isEmpty()) return false; + ProductionCameraSlot current = get(context, slot); + return token.equals(current.getInviteToken()) + && !current.isInviteConsumed() + && current.getActiveStreamId().isEmpty(); + } + + /** Moves the current invite into active state and immediately rotates the next token. */ + @NonNull public static ProductionCameraSlot markConsumed(Context context, int slot) { + int s = ProductionControlState.clampCameraSlot(slot); + SharedPreferences p = prefs(context); + ProductionCameraSlot current = get(context, s); + if (current.isInviteConsumed() && !current.getActiveStreamId().isEmpty()) return current; + String active = current.getInviteToken(); + if (active.isEmpty()) return current; + p.edit().putString(key(s, "active_stream_id"), active) + .putString(key(s, "token"), newToken()) + .putBoolean(key(s, "consumed"), true) + .putBoolean(key(s, "waiting"), false) + .apply(); + return get(context, s); + } + + /** Revokes an idle invitation without disturbing a connected guest. */ + public static void revokeInvite(Context context, int slot) { + int s = ProductionControlState.clampCameraSlot(slot); + SharedPreferences p = prefs(context); + if (!p.getString(key(s, "active_stream_id"), "").isEmpty()) return; + p.edit().putString(key(s, "token"), newToken()) + .putBoolean(key(s, "waiting"), false) + .putBoolean(key(s, "consumed"), false) + .apply(); + } + + /** Ends the active guest and leaves a fresh invite ready for the next guest. */ + public static void markDisconnected(Context context, int slot) { + int s = ProductionControlState.clampCameraSlot(slot); + prefs(context).edit().remove(key(s, "active_stream_id")) + .remove(key(s, "url")) + .putString(key(s, "token"), newToken()) + .putBoolean(key(s, "waiting"), false) + .putBoolean(key(s, "consumed"), false) + .apply(); + } + + @NonNull public static ProductionCameraSlot connect(Context context, int slot, String streamUrl) { + ProductionCameraSlot current = get(context, slot); + String url = streamUrl == null ? "" : streamUrl.trim(); + prefs(context).edit().putString(key(current.getSlot(), "url"), url) + .putBoolean(key(current.getSlot(), "waiting"), url.isEmpty()).apply(); + return get(context, current.getSlot()); + } + + public static void clear(Context context, int slot) { + int s = ProductionControlState.clampCameraSlot(slot); + prefs(context).edit().remove(key(s, "name")) + .remove(key(s, "active_stream_id")) + .remove(key(s, "url")) + .putString(key(s, "token"), newToken()) + .putBoolean(key(s, "consumed"), false) + .putBoolean(key(s, "waiting"), false).apply(); + } + + /** Android app-to-app link for the installed FadCam package. */ + @NonNull public static String inviteLink(Context context, int slot) { + ProductionCameraSlot c = get(context, slot); + String component = context.getPackageName() + "/.TorchToggleActivity"; + return String.format(Locale.US, + "intent://guest-camera?slot=%d&token=%s#Intent;scheme=fadcam;action=android.intent.action.VIEW;component=%s;package=%s;end", + c.getSlot(), c.getInviteToken(), component, context.getPackageName()); + } + + @NonNull public static String vdoPushLink(Context context, int slot) { + ProductionCameraSlot c = get(context, slot); + return VDO_BASE + "push=" + c.getInviteToken() + + "&mobile&autostart&webcam&secure&label=Camera%20" + c.getSlot() + + "&quality=2&denoise&echocancellation&autogain&noisegate=1"; + } + + @NonNull public static String viewerLink(Context context, int slot) { + ProductionCameraSlot c = get(context, slot); + String id = c.getActiveStreamId().isEmpty() ? c.getInviteToken() : c.getActiveStreamId(); + return VDO_BASE + "view=" + id + "&cleanviewer&videobitrate=2500&audiobitrate=96"; + } + + private static SharedPreferences prefs(Context context) { + return context.getSharedPreferences(PREFS, Context.MODE_PRIVATE); + } + + private static String key(int slot, String suffix) { return "slot_" + slot + "_" + suffix; } + private static String newToken() { return randomHex(18); } + + private static String randomHex(int bytes) { + byte[] data = new byte[bytes]; + RANDOM.nextBytes(data); + StringBuilder out = new StringBuilder(bytes * 2); + for (byte b : data) out.append(String.format(Locale.US, "%02x", b & 0xff)); + return out.toString(); + } +} diff --git a/app/src/main/java/com/fadcam/production/ProductionCockpitDialog.java b/app/src/main/java/com/fadcam/production/ProductionCockpitDialog.java new file mode 100644 index 000000000..8b9b096b4 --- /dev/null +++ b/app/src/main/java/com/fadcam/production/ProductionCockpitDialog.java @@ -0,0 +1,527 @@ +package com.fadcam.production; + +import android.app.Dialog; +import android.content.Context; +import android.graphics.Color; +import android.graphics.drawable.GradientDrawable; +import android.view.Gravity; +import android.view.View; +import android.view.Window; +import android.view.WindowManager; +import android.widget.GridLayout; +import android.widget.HorizontalScrollView; +import android.widget.LinearLayout; +import android.widget.ProgressBar; +import android.widget.ScrollView; +import android.widget.SeekBar; +import android.widget.TextView; +import android.widget.Toast; + +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; + +import com.google.android.material.button.MaterialButton; + +/** + * Compact broadcast producer cockpit. + * + * The cockpit is deliberately a control surface, not a fake broadcast engine: + * PROGRAM/PREVIEW are backed by ProductionControlState, output is backed by + * the verified RTMP service, and the microphone meter is fed by the existing + * recorder-owned AudioRecord through ProductionAudioDuckingBus. Media/master + * faders also feed the playback-side ProductionAudioMixBus. + */ +public final class ProductionCockpitDialog extends Dialog { + private static final int BG = Color.rgb(8, 9, 10); + private static final int PANEL = Color.rgb(24, 26, 29); + private static final int PANEL_2 = Color.rgb(35, 38, 42); + private static final int PANEL_3 = Color.rgb(48, 51, 56); + private static final int RED = Color.rgb(225, 38, 45); + private static final int GREEN = Color.rgb(80, 220, 120); + private static final int AMBER = Color.rgb(240, 185, 70); + private static final int BLUE = Color.rgb(75, 165, 245); + private static final int TEXT = Color.WHITE; + private static final int MUTED = Color.rgb(165, 170, 178); + + private ProductionCockpitState cockpit; + private ProductionControlState switcher; + private TextView program; + private TextView preview; + private TextView tally; + private TextView engine; + private TextView micState; + private ProgressBar micMeter; + private MaterialButton liveButton; + private final MaterialButton[] sceneButtons = new MaterialButton[ProductionScene.values().length]; + private final MaterialButton[] cameraButtons = new MaterialButton[6]; + private final boolean[] muted = new boolean[4]; + private final int[] restoreLevels = {75, 75, 65, 85}; + + private final ProductionAudioDuckingBus.Listener micListener = new ProductionAudioDuckingBus.Listener() { + @Override public void onMicLevel(float normalizedLevel) { + int progress = Math.max(0, Math.min(100, Math.round(normalizedLevel * 100f))); + if (micMeter != null) micMeter.post(() -> micMeter.setProgress(progress)); + } + + @Override public void onCaptureState(boolean active) { + if (micState != null) { + micState.post(() -> micState.setText(active + ? "VOICE BUS • MIC ACTIVE • DUCKING TELEMETRY" + : "VOICE BUS • WAITING FOR RECORDER")); + } + } + }; + + public ProductionCockpitDialog(@NonNull Context context) { + super(context); + switcher = ProductionControlState.load(context); + cockpit = ProductionCockpitState.defaults(switcher.getProgramScene()); + } + + @Override protected void onCreate(@Nullable android.os.Bundle state) { + super.onCreate(state); + requestWindowFeature(Window.FEATURE_NO_TITLE); + setContentView(buildContent()); + setCanceledOnTouchOutside(true); + ProductionAudioDuckingBus.subscribe(micListener); + } + + @Override protected void onStart() { + super.onStart(); + Window window = getWindow(); + if (window != null) { + window.setBackgroundDrawableResource(android.R.color.transparent); + window.setDimAmount(0.45f); + window.addFlags(WindowManager.LayoutParams.FLAG_DIM_BEHIND); + WindowManager.LayoutParams lp = window.getAttributes(); + lp.gravity = Gravity.END; + int width = getContext().getResources().getDisplayMetrics().widthPixels; + float density = getContext().getResources().getDisplayMetrics().density; + int max = Math.round(520 * density); + lp.width = Math.min(Math.round(width * 0.94f), max); + lp.height = WindowManager.LayoutParams.MATCH_PARENT; + window.setAttributes(lp); + } + } + + @Override protected void onStop() { + ProductionAudioDuckingBus.unsubscribe(micListener); + super.onStop(); + } + + private View buildContent() { + ScrollView scroll = new ScrollView(getContext()); + scroll.setFillViewport(true); + scroll.setClipToPadding(false); + + LinearLayout root = column(); + root.setPadding(dp(12), dp(10), dp(12), dp(18)); + root.setBackground(round(BG, 18)); + + LinearLayout header = row(); + TextView title = text("PRODUCER COCKPIT", 17, TEXT); + title.setTypeface(null, android.graphics.Typeface.BOLD); + header.addView(title, new LinearLayout.LayoutParams(0, dp(46), 1)); + TextView mode = text("ON AIR READY", 8, GREEN); + mode.setGravity(Gravity.CENTER); + mode.setBackground(round(PANEL, 10)); + header.addView(mode, new LinearLayout.LayoutParams(dp(92), dp(38))); + MaterialButton close = button("×", false); + close.setContentDescription("Close producer cockpit"); + close.setOnClickListener(v -> dismiss()); + header.addView(close, new LinearLayout.LayoutParams(dp(44), dp(44))); + root.addView(header); + + LinearLayout status = row(); + status.setPadding(dp(10), dp(5), dp(10), dp(5)); + status.setBackground(round(PANEL, 12)); + engine = text("SWITCHER READY • CUT BUS ARMED", 9, GREEN); + status.addView(engine, new LinearLayout.LayoutParams(0, dp(34), 1)); + tally = text("PROGRAM", 8, RED); + tally.setGravity(Gravity.CENTER); + tally.setBackground(round(Color.rgb(62, 16, 19), 8)); + status.addView(tally, new LinearLayout.LayoutParams(dp(122), dp(30))); + root.addView(status, new LinearLayout.LayoutParams(-1, dp(44))); + + root.addView(section("PROGRAM / PREVIEW")); + LinearLayout monitors = row(); + monitors.addView(monitor("PROGRAM", true), new LinearLayout.LayoutParams(0, dp(112), 1)); + monitors.addView(space(dp(8)), new LinearLayout.LayoutParams(dp(8), 1)); + monitors.addView(monitor("PREVIEW", false), new LinearLayout.LayoutParams(0, dp(112), 1)); + root.addView(monitors); + + root.addView(section("SCENE / SOURCE BANK")); + GridLayout scenes = new GridLayout(getContext()); + scenes.setColumnCount(2); + ProductionScene[] values = ProductionScene.values(); + for (int i = 0; i < values.length; i++) { + ProductionScene scene = values[i]; + MaterialButton b = button(scene.getTitle().toUpperCase(), scene == cockpit.getPreview()); + b.setTextSize(9); + b.setContentDescription("Preview " + scene.getTitle()); + b.setOnClickListener(v -> selectPreview(scene)); + sceneButtons[i] = b; + GridLayout.LayoutParams lp = new GridLayout.LayoutParams(); + lp.width = 0; + lp.height = dp(50); + lp.columnSpec = GridLayout.spec(i % 2, 1f); + lp.setMargins(dp(3), dp(3), dp(3), dp(3)); + scenes.addView(b, lp); + } + root.addView(scenes); + + root.addView(text("CAMERA SLOT", 8, MUTED)); + HorizontalScrollView cameraScroll = new HorizontalScrollView(getContext()); + cameraScroll.setHorizontalScrollBarEnabled(false); + LinearLayout cameraBank = row(); + for (int i = 0; i < 6; i++) { + final int slot = i + 1; + MaterialButton b = button("CAM " + slot, slot == switcher.getPreviewCameraSlot()); + b.setTextSize(8); + b.setOnClickListener(v -> selectCameraSlot(slot)); + cameraButtons[i] = b; + cameraBank.addView(b, new LinearLayout.LayoutParams(dp(72), dp(44))); + if (i < 5) cameraBank.addView(space(dp(5)), new LinearLayout.LayoutParams(dp(5), 1)); + } + cameraScroll.addView(cameraBank); + root.addView(cameraScroll, new LinearLayout.LayoutParams(-1, dp(50))); + + LinearLayout takeRow = row(); + MaterialButton cut = button("CUT • INSTANT", true); + cut.setContentDescription("Instant program cut"); + cut.setOnClickListener(v -> take(true)); + takeRow.addView(cut, new LinearLayout.LayoutParams(0, dp(60), 1)); + takeRow.addView(space(dp(8)), new LinearLayout.LayoutParams(dp(8), 1)); + MaterialButton auto = button("AUTO TAKE", false); + auto.setContentDescription("Automatic timed transition"); + auto.setOnClickListener(v -> take(false)); + takeRow.addView(auto, new LinearLayout.LayoutParams(0, dp(60), 1)); + root.addView(takeRow); + + root.addView(section("TRANSITION BUS")); + HorizontalScrollView transitionScroll = new HorizontalScrollView(getContext()); + transitionScroll.setHorizontalScrollBarEnabled(false); + LinearLayout transitions = row(); + for (ProductionCockpitState.Transition t : ProductionCockpitState.Transition.values()) { + MaterialButton b = button(t.name(), t == cockpit.getTransition()); + b.setTextSize(8); + b.setOnClickListener(v -> setTransition(t)); + transitions.addView(b, new LinearLayout.LayoutParams(dp(82), dp(44))); + transitions.addView(space(dp(5)), new LinearLayout.LayoutParams(dp(5), 1)); + } + transitionScroll.addView(transitions); + root.addView(transitionScroll, new LinearLayout.LayoutParams(-1, dp(50))); + + SeekBar speed = new SeekBar(getContext()); + speed.setMax(1000); + speed.setProgress(cockpit.getTransitionMs()); + TextView speedValue = text("TRANSITION SPEED • " + cockpit.getTransitionMs() + " ms", 9, MUTED); + root.addView(speedValue, new LinearLayout.LayoutParams(-1, dp(28))); + speed.setOnSeekBarChangeListener(new SeekBar.OnSeekBarChangeListener() { + @Override public void onProgressChanged(SeekBar b, int p, boolean fromUser) { + cockpit = cockpit.transition(cockpit.getTransition(), p); + speedValue.setText("TRANSITION SPEED • " + p + " ms"); + if (p > 0) { + switcher = switcher.withDuration(Math.max(100, p)); + switcher.save(getContext()); + } + } + @Override public void onStartTrackingTouch(SeekBar b) {} + @Override public void onStopTrackingTouch(SeekBar b) {} + }); + root.addView(speed, new LinearLayout.LayoutParams(-1, dp(38))); + + root.addView(section("VIRTUAL AUDIO MIXER")); + root.addView(mixerRow("CAM 1", cockpit.getCamera1(), ProductionAudioMixBus.CAMERA_1)); + root.addView(mixerRow("CAM 2", cockpit.getCamera2(), ProductionAudioMixBus.CAMERA_2)); + root.addView(mixerRow("MEDIA", cockpit.getMedia(), ProductionAudioMixBus.MEDIA)); + root.addView(mixerRow("MASTER", cockpit.getMaster(), ProductionAudioMixBus.MASTER)); + + LinearLayout voice = row(); + voice.setPadding(dp(8), dp(5), dp(8), dp(5)); + voice.setBackground(round(PANEL, 10)); + LinearLayout voiceText = column(); + micState = text("VOICE BUS • WAITING FOR RECORDER", 8, MUTED); + voiceText.addView(micState, new LinearLayout.LayoutParams(-1, dp(22))); + TextView note = text("Mic telemetry uses the existing recorder AudioRecord", 7, MUTED); + voiceText.addView(note, new LinearLayout.LayoutParams(-1, dp(18))); + voice.addView(voiceText, new LinearLayout.LayoutParams(0, dp(48), 1)); + micMeter = new ProgressBar(getContext(), null, android.R.attr.progressBarStyleHorizontal); + micMeter.setMax(100); + micMeter.setProgress(0); + voice.addView(micMeter, new LinearLayout.LayoutParams(dp(90), dp(24))); + root.addView(voice); + + root.addView(section("BROADCAST OUTPUT")); + LinearLayout liveRow = row(); + boolean live = ProductionStreamingController.isLive(getContext()); + liveButton = button(live ? "● LIVE" : "LIVE / STREAM", live); + liveButton.setOnClickListener(v -> showStreaming()); + liveRow.addView(liveButton, new LinearLayout.LayoutParams(0, dp(54), 1)); + liveRow.addView(space(dp(8)), new LinearLayout.LayoutParams(dp(8), 1)); + MaterialButton destination = button("DESTINATION", false); + destination.setContentDescription("Open verified social destination settings"); + destination.setOnClickListener(v -> showStreaming()); + liveRow.addView(destination, new LinearLayout.LayoutParams(0, dp(54), 1)); + liveRow.addView(space(dp(8)), new LinearLayout.LayoutParams(dp(8), 1)); + MaterialButton stop = button("STOP", false); + stop.setOnClickListener(v -> stopLive()); + liveRow.addView(stop, new LinearLayout.LayoutParams(0, dp(54), 1)); + root.addView(liveRow); + TextView output = text("Verified RTMP/RTMPS • protected stream key • recording safety gate", 8, MUTED); + output.setPadding(dp(4), dp(5), dp(4), dp(4)); + root.addView(output); + + root.addView(section("PRODUCER SAFETY / HEALTH")); + root.addView(infoRow("Recording", ProductionStreamingController.hasRecording(getContext()) ? "ACTIVE" : "IDLE", ProductionStreamingController.hasRecording(getContext()) ? GREEN : AMBER)); + root.addView(infoRow("Program", sourceLabel(cockpit.getProgram(), false), RED)); + root.addView(infoRow("Destination", healthDestination(), ProductionStreamingController.hasStreamKey(getContext()) ? BLUE : AMBER)); + root.addView(infoRow("Switch path", "Preview → Program • " + cockpit.getTransition().name(), GREEN)); + + scroll.addView(root); + return scroll; + } + + private String healthDestination() { + if (!ProductionStreamingController.hasStreamKey(getContext())) return "NOT CONFIGURED"; + String server = ProductionStreamingController.getServer(getContext()); + return ProductionStreamingController.isRtmpServer(server) ? ProductionStreamingController.getPlatform(getContext()) : "INVALID RTMP"; + } + + private String sourceLabel(ProductionScene scene, boolean previewSource) { + if (scene == ProductionScene.CAMERA) { + int slot = previewSource ? switcher.getPreviewCameraSlot() : switcher.getProgramCameraSlot(); + return "Camera " + slot; + } + return scene.getTitle(); + } + + private void selectPreview(ProductionScene scene) { + cockpit = cockpit.preview(scene); + switcher = switcher.withPreview(scene); + switcher.save(getContext()); + refresh(); + } + + private void selectCameraSlot(int slot) { + switcher = switcher.withPreviewCameraSlot(slot); + switcher.save(getContext()); + if (cockpit.getPreview() == ProductionScene.CAMERA) cockpit = cockpit.preview(ProductionScene.CAMERA); + for (int i = 0; i < cameraButtons.length; i++) { + if (cameraButtons[i] != null) cameraButtons[i].setBackground(round(i + 1 == slot ? RED : PANEL_2, 12)); + } + refresh(); + } + + private void take(boolean instant) { + if (instant || cockpit.getTransitionMs() == 0) { + promoteProgram(); + return; + } + engine.setText("AUTO TAKE • " + cockpit.getTransitionMs() + " ms"); + engine.setTextColor(AMBER); + engine.postDelayed(() -> { + if (isShowing()) promoteProgram(); + }, cockpit.getTransitionMs()); + } + + private void promoteProgram() { + cockpit = cockpit.take(); + switcher = switcher.withPreview(cockpit.getPreview()).take(); + switcher = switcher.withTransition(mapTransition(cockpit.getTransition())).withDuration(Math.max(100, cockpit.getTransitionMs())); + switcher.save(getContext()); + refresh(); + } + + private ProductionControlState.Transition mapTransition(ProductionCockpitState.Transition transition) { + switch (transition) { + case MIX: return ProductionControlState.Transition.DISSOLVE; + case FADE: return ProductionControlState.Transition.FADE; + case WIPE: return ProductionControlState.Transition.WIPE; + case FLASH: return ProductionControlState.Transition.FADE; + case CUT: + default: return ProductionControlState.Transition.CUT; + } + } + + private void setTransition(ProductionCockpitState.Transition transition) { + int duration = transition == ProductionCockpitState.Transition.CUT || transition == ProductionCockpitState.Transition.FLASH + ? 0 : Math.max(100, cockpit.getTransitionMs()); + cockpit = cockpit.transition(transition, duration); + switcher = switcher.withTransition(mapTransition(transition)).withDuration(Math.max(100, duration)); + switcher.save(getContext()); + refresh(); + } + + private View mixerRow(String name, int initial, int channel) { + LinearLayout row = row(); + TextView label = text(name, 9, TEXT); + label.setTypeface(null, android.graphics.Typeface.BOLD); + row.addView(label, new LinearLayout.LayoutParams(dp(58), dp(44))); + + SeekBar fader = new SeekBar(getContext()); + fader.setMax(100); + fader.setProgress(initial); + TextView value = text(initial + "%", 8, MUTED); + value.setGravity(Gravity.CENTER); + row.addView(fader, new LinearLayout.LayoutParams(0, dp(44), 1)); + row.addView(value, new LinearLayout.LayoutParams(dp(44), dp(44))); + + MaterialButton mute = button("M", false); + mute.setTextSize(8); + mute.setContentDescription("Mute " + name); + mute.setOnClickListener(v -> { + muted[channel] = !muted[channel]; + if (muted[channel]) { + restoreLevels[channel] = fader.getProgress(); + fader.setProgress(0); + mute.setBackground(round(RED, 10)); + } else { + fader.setProgress(Math.max(restoreLevels[channel], 0)); + mute.setBackground(round(PANEL_2, 10)); + } + }); + row.addView(mute, new LinearLayout.LayoutParams(dp(44), dp(40))); + + fader.setOnSeekBarChangeListener(new SeekBar.OnSeekBarChangeListener() { + @Override public void onProgressChanged(SeekBar b, int p, boolean fromUser) { + value.setText(p + "%"); + int c1 = cockpit.getCamera1(), c2 = cockpit.getCamera2(), media = cockpit.getMedia(), master = cockpit.getMaster(); + if (channel == ProductionAudioMixBus.CAMERA_1) c1 = p; + else if (channel == ProductionAudioMixBus.CAMERA_2) c2 = p; + else if (channel == ProductionAudioMixBus.MEDIA) media = p; + else master = p; + ProductionAudioMixBus.setLevel(channel, p); + cockpit = cockpit.levels(c1, c2, media, master); + if (fromUser && !muted[channel]) restoreLevels[channel] = p; + } + @Override public void onStartTrackingTouch(SeekBar b) {} + @Override public void onStopTrackingTouch(SeekBar b) {} + }); + return row; + } + + private void showStreaming() { + ProductionStreamingDialog dialog = new ProductionStreamingDialog(getContext(), this::refreshLive); + dialog.show(); + } + + private void refreshLive() { + boolean live = ProductionStreamingController.isLive(getContext()); + if (liveButton != null) { + liveButton.setText(live ? "● LIVE" : "LIVE / STREAM"); + liveButton.setBackground(round(live ? Color.rgb(30, 90, 45) : RED, 12)); + } + if (engine != null) { + engine.setText(live ? "ON AIR • SOCIAL OUTPUT ACTIVE" : "SWITCHER READY • CUT BUS ARMED"); + engine.setTextColor(live ? RED : GREEN); + } + } + + private void stopLive() { + ProductionStreamingController.stop(getContext()); + refreshLive(); + Toast.makeText(getContext(), "LIVE output stop requested", Toast.LENGTH_SHORT).show(); + } + + private View monitor(String label, boolean isProgram) { + LinearLayout card = column(); + card.setPadding(dp(10), dp(8), dp(10), dp(8)); + card.setBackground(round(isProgram ? Color.rgb(68, 16, 19) : PANEL_2, 12)); + TextView l = text(label + (isProgram ? " • TALLY" : " • READY"), 8, isProgram ? RED : GREEN); + card.addView(l, new LinearLayout.LayoutParams(-1, dp(22))); + ProductionScene scene = isProgram ? cockpit.getProgram() : cockpit.getPreview(); + TextView value = text(sourceLabel(scene, !isProgram), 15, TEXT); + value.setTypeface(null, android.graphics.Typeface.BOLD); + if (isProgram) program = value; else preview = value; + card.addView(value, new LinearLayout.LayoutParams(-1, 0, 1)); + TextView sub = text(scene.getDescription(), 7, MUTED); + card.addView(sub, new LinearLayout.LayoutParams(-1, dp(26))); + return card; + } + + private View infoRow(String label, String value, int color) { + LinearLayout row = row(); + row.setPadding(dp(8), dp(3), dp(8), dp(3)); + row.setBackground(round(PANEL, 8)); + row.addView(text(label, 8, MUTED), new LinearLayout.LayoutParams(0, dp(30), 1)); + TextView v = text(value, 8, color); + v.setGravity(Gravity.CENTER); + row.addView(v, new LinearLayout.LayoutParams(dp(170), dp(30))); + return row; + } + + private TextView section(String title) { + TextView v = text(title, 10, MUTED); + v.setTypeface(null, android.graphics.Typeface.BOLD); + v.setPadding(dp(4), dp(10), dp(4), dp(4)); + return v; + } + + private void refresh() { + if (program != null) program.setText(sourceLabel(cockpit.getProgram(), false)); + if (preview != null) preview.setText(sourceLabel(cockpit.getPreview(), true)); + if (tally != null) tally.setText("PROGRAM • " + sourceLabel(cockpit.getProgram(), false)); + for (int i = 0; i < sceneButtons.length; i++) { + if (sceneButtons[i] != null) sceneButtons[i].setBackground(round(ProductionScene.values()[i] == cockpit.getPreview() ? RED : PANEL_2, 12)); + } + for (int i = 0; i < cameraButtons.length; i++) { + if (cameraButtons[i] != null) cameraButtons[i].setBackground(round(i + 1 == switcher.getPreviewCameraSlot() ? RED : PANEL_2, 12)); + } + if (engine != null) { + boolean live = ProductionStreamingController.isLive(getContext()); + engine.setText(live ? "ON AIR • SOCIAL OUTPUT ACTIVE" : "SWITCHER READY • CUT BUS ARMED"); + engine.setTextColor(live ? RED : GREEN); + } + } + + private MaterialButton button(String title, boolean accent) { + MaterialButton b = new MaterialButton(getContext()); + b.setText(title); + b.setTextColor(TEXT); + b.setTextSize(10); + b.setAllCaps(false); + b.setMinHeight(0); + b.setMinWidth(0); + b.setPadding(dp(5), 0, dp(5), 0); + b.setBackground(round(accent ? RED : PANEL_2, 12)); + b.setRippleColor(android.content.res.ColorStateList.valueOf(PANEL_3)); + return b; + } + + private LinearLayout row() { + LinearLayout row = new LinearLayout(getContext()); + row.setOrientation(LinearLayout.HORIZONTAL); + row.setGravity(Gravity.CENTER_VERTICAL); + return row; + } + + private LinearLayout column() { + LinearLayout c = new LinearLayout(getContext()); + c.setOrientation(LinearLayout.VERTICAL); + return c; + } + + private TextView text(String value, float size, int color) { + TextView v = new TextView(getContext()); + v.setText(value); + v.setTextSize(size); + v.setTextColor(color); + return v; + } + + private View space(int width) { + View v = new View(getContext()); + v.setLayoutParams(new LinearLayout.LayoutParams(width, 1)); + return v; + } + + private GradientDrawable round(int color, int radius) { + GradientDrawable d = new GradientDrawable(); + d.setColor(color); + d.setCornerRadius(dp(radius)); + return d; + } + + private int dp(int value) { return Math.round(value * getContext().getResources().getDisplayMetrics().density); } +} diff --git a/app/src/main/java/com/fadcam/production/ProductionCockpitState.java b/app/src/main/java/com/fadcam/production/ProductionCockpitState.java new file mode 100644 index 000000000..3520b31c5 --- /dev/null +++ b/app/src/main/java/com/fadcam/production/ProductionCockpitState.java @@ -0,0 +1,58 @@ +package com.fadcam.production; + +import android.content.Context; +import android.content.SharedPreferences; + +/** Persistent cockpit controls; scene/program authority remains ProductionControlState. */ +public final class ProductionCockpitState { + public enum Transition { CUT, MIX, FADE, WIPE, FLASH } + private static final String PREFS = "fadcam_production_cockpit"; + private final ProductionScene preview, program; + private final Transition transition; + private final int transitionMs, camera1, camera2, media, master; + + public ProductionCockpitState(ProductionScene preview, ProductionScene program, Transition transition, + int transitionMs, int camera1, int camera2, int media, int master) { + this.preview = preview; + this.program = program; + this.transition = transition == null ? Transition.CUT : transition; + this.transitionMs = clamp(transitionMs); + this.camera1 = clampLevel(camera1); this.camera2 = clampLevel(camera2); + this.media = clampLevel(media); this.master = clampLevel(master); + } + + public static ProductionCockpitState defaults(ProductionScene program) { + return new ProductionCockpitState(program, program, Transition.CUT, 0, 75, 75, 65, 85); + } + + public static ProductionCockpitState load(Context context, ProductionScene program) { + SharedPreferences p = context.getSharedPreferences(PREFS, Context.MODE_PRIVATE); + Transition t; + try { t = Transition.valueOf(p.getString("transition", Transition.CUT.name())); } + catch (Exception e) { t = Transition.CUT; } + return new ProductionCockpitState(program, program, t, p.getInt("transition_ms", 0), + p.getInt("camera1", 75), p.getInt("camera2", 75), p.getInt("media", 65), p.getInt("master", 85)); + } + + public void save(Context context) { + context.getSharedPreferences(PREFS, Context.MODE_PRIVATE).edit() + .putString("transition", transition.name()).putInt("transition_ms", transitionMs) + .putInt("camera1", camera1).putInt("camera2", camera2) + .putInt("media", media).putInt("master", master).apply(); + } + + public ProductionScene getPreview() { return preview; } + public ProductionScene getProgram() { return program; } + public Transition getTransition() { return transition; } + public int getTransitionMs() { return transitionMs; } + public int getCamera1() { return camera1; } + public int getCamera2() { return camera2; } + public int getMedia() { return media; } + public int getMaster() { return master; } + public ProductionCockpitState preview(ProductionScene scene) { return new ProductionCockpitState(scene, program, transition, transitionMs, camera1, camera2, media, master); } + public ProductionCockpitState take() { return new ProductionCockpitState(preview, preview, transition, transitionMs, camera1, camera2, media, master); } + public ProductionCockpitState transition(Transition value, int durationMs) { return new ProductionCockpitState(preview, program, value, durationMs, camera1, camera2, media, master); } + public ProductionCockpitState levels(int a, int b, int m, int master) { return new ProductionCockpitState(preview, program, transition, transitionMs, a, b, m, master); } + public static int clamp(int value) { return Math.max(0, Math.min(3000, value)); } + public static int clampLevel(int value) { return Math.max(0, Math.min(100, value)); } +} diff --git a/app/src/main/java/com/fadcam/production/ProductionControlRoomDialog.java b/app/src/main/java/com/fadcam/production/ProductionControlRoomDialog.java new file mode 100644 index 000000000..a64906a23 --- /dev/null +++ b/app/src/main/java/com/fadcam/production/ProductionControlRoomDialog.java @@ -0,0 +1,203 @@ +package com.fadcam.production; + +import android.app.Dialog; +import android.content.ClipData; +import android.content.ClipboardManager; +import android.content.Context; +import android.content.Intent; +import android.database.Cursor; +import android.graphics.Color; +import android.graphics.Typeface; +import android.graphics.drawable.GradientDrawable; +import android.net.Uri; +import android.provider.MediaStore; +import android.view.Gravity; +import android.view.View; +import android.view.ViewGroup; +import android.view.Window; +import android.view.WindowManager; +import android.widget.EditText; +import android.widget.FrameLayout; +import android.widget.GridLayout; +import android.widget.HorizontalScrollView; +import android.widget.LinearLayout; +import android.widget.ScrollView; +import android.widget.SeekBar; +import android.widget.TextView; +import android.widget.Toast; + +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; +import androidx.media3.common.MediaItem; +import androidx.media3.exoplayer.ExoPlayer; +import androidx.media3.ui.PlayerView; + +import com.google.android.material.button.MaterialButton; +import com.google.android.material.dialog.MaterialAlertDialogBuilder; + +import java.util.ArrayList; +import java.util.List; +import java.util.Locale; + +/** + * Professional producer switcher. The dialog owns only its own preview surface; + * it never re-parents or takes ownership of the Fragment's live production canvas. + */ +public final class ProductionControlRoomDialog extends Dialog { + public interface Listener { + void onStateChanged(@NonNull ProductionControlState state, boolean applyToProgram); + void onCameraSlotChanged(int slot, @NonNull String streamUrl); + void onCloseRequested(); + } + + private static final int BG=Color.rgb(9,10,11), PANEL=Color.rgb(27,28,30), ALT=Color.rgb(40,42,45); + private static final int RED=Color.rgb(220,42,48), DARK_RED=Color.rgb(74,19,21), TEXT=Color.WHITE; + private static final int MUTED=Color.rgb(175,178,183), BLUE=Color.rgb(85,165,245), GREEN=Color.rgb(90,220,120); + + private final Listener listener; + private ProductionControlState state; + private FrameLayout liveMonitor; + private PlayerView previewPlayerView; + private ProductionAudioDucker videoDucker; + private ExoPlayer videoPlayer; + private TextView previewValue, previewDetail, tallyValue, transitionValue, durationValue; + private MaterialButton autoButton, takeButton, liveButton; + + /** + * The canvas arguments are retained for source compatibility with older callers, + * but are deliberately ignored. The control room must never move a live Fragment view. + */ + public ProductionControlRoomDialog(@NonNull Context context, @NonNull ProductionControlState initialState, + @Nullable FrameLayout ignoredLiveCanvas, @Nullable ViewGroup ignoredOriginalParent, + @NonNull Listener listener) { + super(context); + this.state=initialState; + this.listener=listener; + } + + @Override protected void onCreate(@Nullable android.os.Bundle saved) { + super.onCreate(saved); + requestWindowFeature(Window.FEATURE_NO_TITLE); + setContentView(buildContent()); + setCanceledOnTouchOutside(false); + } + + @Override protected void onStart() { + super.onStart(); Window w=getWindow(); + if(w!=null){w.setBackgroundDrawableResource(android.R.color.transparent);w.setDimAmount(.78f); + w.addFlags(WindowManager.LayoutParams.FLAG_DIM_BEHIND);w.setLayout(-1,-1);} + } + + private View buildContent(){ + ScrollView scroll=new ScrollView(getContext()); scroll.setFillViewport(true); + LinearLayout root=column(BG); root.setTag("fadcam-production-root"); root.setPadding(dp(10),dp(10),dp(10),dp(18)); + LinearLayout header=row(); TextView title=text("PRODUCTION CONTROL ROOM",17,TEXT); title.setTypeface(Typeface.DEFAULT,Typeface.BOLD); + header.addView(title,new LinearLayout.LayoutParams(0,dp(46),1)); + liveButton=button(ProductionStreamingController.isLive(getContext())?"ON AIR":"LIVE",true); liveButton.setOnClickListener(v->toggleLive()); + header.addView(liveButton,new LinearLayout.LayoutParams(dp(78),dp(46))); + MaterialButton cockpit=button("⋮",true); cockpit.setOnClickListener(v->new ProductionCockpitDialog(getContext()).show()); header.addView(cockpit,new LinearLayout.LayoutParams(dp(50),dp(46))); + TextView live=text("● LIVE CONTROL",10,RED); live.setGravity(Gravity.CENTER); header.addView(live,new LinearLayout.LayoutParams(dp(92),dp(46))); + MaterialButton close=button("×",true); close.setOnClickListener(v->{listener.onCloseRequested();dismiss();}); header.addView(close,new LinearLayout.LayoutParams(dp(46),dp(46))); root.addView(header); + + LinearLayout status=row(); status.setPadding(dp(8),dp(5),dp(8),dp(5)); status.setBackground(round(PANEL,12)); + tallyValue=text("ON AIR • "+programLabel(),11,TEXT); status.addView(tallyValue,new LinearLayout.LayoutParams(0,dp(36),1)); + TextView ready=text(ProductionStreamingController.isLive(getContext())?"LIVE":"READY",10,ProductionStreamingController.isLive(getContext())?RED:GREEN); ready.setGravity(Gravity.CENTER); status.addView(ready,new LinearLayout.LayoutParams(dp(58),dp(36))); root.addView(status,new LinearLayout.LayoutParams(-1,dp(46))); + + root.addView(section("PROGRAM / PREVIEW")); + liveMonitor=new FrameLayout(getContext()); liveMonitor.setContentDescription("Control room program preview"); liveMonitor.setBackground(round(Color.BLACK,10)); + previewPlayerView=new PlayerView(getContext()); previewPlayerView.setUseController(true); previewPlayerView.setShutterBackgroundColor(Color.BLACK); + liveMonitor.addView(previewPlayerView,new FrameLayout.LayoutParams(-1,-1)); + root.addView(liveMonitor,new LinearLayout.LayoutParams(-1,dp(220))); + LinearLayout labels=row(); labels.addView(monitorLabel("PROGRAM • TALLY",true),new LinearLayout.LayoutParams(0,dp(70),1)); labels.addView(space(dp(8)),new LinearLayout.LayoutParams(dp(8),1)); labels.addView(monitorLabel("PREVIEW • READY",false),new LinearLayout.LayoutParams(0,dp(70),1)); root.addView(labels); + + root.addView(section("MULTIVIEW • CAMERA 1–6 + VIDEO")); root.addView(buildMultiview()); + root.addView(section("SCENE BANK • PREVIEW ONLY")); root.addView(buildSceneBank()); + + LinearLayout sw=row(); sw.setPadding(0,dp(7),0,dp(4)); MaterialButton cut=button("CUT • INSTANT",true); cut.setOnClickListener(v->cutNow()); sw.addView(cut,new LinearLayout.LayoutParams(0,dp(54),1)); sw.addView(space(dp(7)),new LinearLayout.LayoutParams(dp(7),1)); + autoButton=button("AUTO",true); autoButton.setOnClickListener(v->autoTake()); sw.addView(autoButton,new LinearLayout.LayoutParams(0,dp(54),1)); sw.addView(space(dp(7)),new LinearLayout.LayoutParams(dp(7),1)); + takeButton=button("TAKE",true); takeButton.setOnClickListener(v->takeNow()); sw.addView(takeButton,new LinearLayout.LayoutParams(0,dp(54),1)); root.addView(sw); + + LinearLayout tr=row(); transitionValue=text("TRANSITION • "+state.getTransition().getTitle(),9,TEXT); tr.addView(transitionValue,new LinearLayout.LayoutParams(0,dp(38),1)); + for(ProductionControlState.Transition t:ProductionControlState.Transition.values()){MaterialButton b=button(t.getTitle().toUpperCase(Locale.US),t==state.getTransition());b.setTextSize(8);b.setOnClickListener(v->{state=state.withTransition(t);state.save(getContext());transitionValue.setText("TRANSITION • "+t.getTitle());});tr.addView(b,new LinearLayout.LayoutParams(dp(72),dp(38)));} root.addView(tr); + LinearLayout dur=row(); durationValue=text("AUTO DURATION • "+state.getTransitionDurationMs()+" ms",9,MUTED);dur.addView(durationValue,new LinearLayout.LayoutParams(dp(132),dp(40)));SeekBar bar=new SeekBar(getContext());bar.setMax(2900);bar.setProgress(Math.max(0,state.getTransitionDurationMs()-100));bar.setOnSeekBarChangeListener(new SeekBar.OnSeekBarChangeListener(){public void onProgressChanged(SeekBar b,int p,boolean u){int ms=ProductionControlState.clampDuration(p+100);state=state.withDuration(ms);durationValue.setText("AUTO DURATION • "+ms+" ms");}public void onStartTrackingTouch(SeekBar b){}public void onStopTrackingTouch(SeekBar b){state.save(getContext());}});dur.addView(bar,new LinearLayout.LayoutParams(0,dp(40),1));root.addView(dur); + TextView hint=text("Preview never changes Program. Camera invites are unique and one-use. VIDEO loads into the control-room preview and is ready for CUT/TAKE/AUTO.",8,MUTED);hint.setPadding(dp(6),dp(7),dp(6),0);root.addView(hint); + scroll.addView(root); return scroll; + } + + private View monitorLabel(String label,boolean program){ + LinearLayout card=column();card.setPadding(dp(9),dp(7),dp(9),dp(7));card.setBackground(round(program?DARK_RED:ALT,12));card.addView(text(label,8,program?RED:BLUE)); + TextView value=text(program?programLabel():previewLabel(),15,TEXT);value.setTypeface(Typeface.DEFAULT,Typeface.BOLD);if(program)tallyValue=value;else previewValue=value;card.addView(value,new LinearLayout.LayoutParams(-1,0,1)); + if(!program){previewDetail=text(previewDetailLabel(),8,MUTED);card.addView(previewDetail);}return card; + } + + private GridLayout buildMultiview(){ + GridLayout grid=new GridLayout(getContext());grid.setColumnCount(3);grid.setRowCount(3); + for(int i=1;i<=6;i++){final int slot=i;ProductionCameraSlot c=ProductionCameraSlotManager.get(getContext(),slot);TextView tile=cameraTile(c);GridLayout.LayoutParams lp=new GridLayout.LayoutParams();lp.width=0;lp.height=dp(68);lp.columnSpec=GridLayout.spec((i-1)%3,1f);lp.rowSpec=GridLayout.spec((i-1)/3,1f);lp.setMargins(dp(3),dp(3),dp(3),dp(3));grid.addView(tile,lp);} + TextView video=sourceTile("VIDEO\nLOAD FROM STORAGE",false);video.setOnClickListener(v->showVideoPicker());addGrid(grid,video,0,2); + TextView program=sourceTile("PROGRAM",true);addGrid(grid,program,1,2);TextView preview=sourceTile("PREVIEW",false);addGrid(grid,preview,2,2);return grid; + } + + private void addGrid(GridLayout g,View v,int col,int row){GridLayout.LayoutParams lp=new GridLayout.LayoutParams();lp.width=0;lp.height=dp(68);lp.columnSpec=GridLayout.spec(col,1f);lp.rowSpec=GridLayout.spec(row,1f);lp.setMargins(dp(3),dp(3),dp(3),dp(3));g.addView(v,lp);} + + private TextView cameraTile(@NonNull ProductionCameraSlot c){String name=c.getGuestName().isEmpty()?"VISITOR "+c.getSlot():c.getGuestName();TextView t=text("CAMERA "+c.getSlot()+"\n"+name+"\n"+c.getStatus().name(),8,c.getStatus()==ProductionCameraSlot.Status.CONNECTED?GREEN:TEXT);t.setGravity(Gravity.CENTER);t.setBackground(round(c.getSlot()==state.getPreviewCameraSlot()&&state.getPreviewScene()==ProductionScene.CAMERA?DARK_RED:PANEL,8));t.setContentDescription("Camera "+c.getSlot()+" guest invite");t.setOnClickListener(v->showCameraInvite(c.getSlot()));return t;} + private TextView sourceTile(String name,boolean program){TextView t=text(name,9,TEXT);t.setGravity(Gravity.CENTER);t.setBackground(round(program?DARK_RED:PANEL,8));return t;} + + private View buildSceneBank(){HorizontalScrollView scroll=new HorizontalScrollView(getContext());scroll.setHorizontalScrollBarEnabled(false);LinearLayout bank=row();for(ProductionScene s:ProductionScene.values()){MaterialButton b=button(s.getTitle().toUpperCase(Locale.US),s==state.getPreviewScene());b.setTextSize(8);b.setOnClickListener(v->{state=state.withPreview(s);state.save(getContext());refreshValues();});bank.addView(b,new LinearLayout.LayoutParams(dp(110),dp(54)));}scroll.addView(bank);return scroll;} + + private void showCameraInvite(int slot){ProductionCameraSlot c=ProductionCameraSlotManager.createInvite(getContext(),slot,"");String link=ProductionCameraSlotManager.inviteLink(getContext(),slot);String fallback=ProductionCameraSlotManager.vdoPushLink(getContext(),slot);EditText name=new EditText(getContext());name.setHint("Guest / visitor name (optional)");name.setText(c.getGuestName());TextView linkView=text(link,8,MUTED);linkView.setPadding(0,dp(8),0,dp(8));LinearLayout box=column();box.addView(name);box.addView(linkView);new MaterialAlertDialogBuilder(getContext()).setTitle("CAMERA "+slot+" • GUEST INVITE").setMessage("One-use FadCam app link. If the guest has FadCam installed it opens Camera "+slot+" directly. Browser fallback is also supplied.").setView(box).setPositiveButton("SHARE",(d,w)->shareInvite(slot,name.getText().toString(),link,fallback)).setNeutralButton("USE AS PREVIEW",(d,w)->{ProductionCameraSlotManager.markInvited(getContext(),slot,name.getText().toString());state=state.withPreviewCameraSlot(slot);state.save(getContext());listener.onCameraSlotChanged(slot,"");refreshValues();}).setNegativeButton("CLOSE",null).show();} + + private void shareInvite(int slot,String guest,String link,String fallback){ProductionCameraSlotManager.markInvited(getContext(),slot,guest);ClipboardManager cb=(ClipboardManager)getContext().getSystemService(Context.CLIPBOARD_SERVICE);if(cb!=null)cb.setPrimaryClip(ClipData.newPlainText("FadCam Camera "+slot,link));Intent share=new Intent(Intent.ACTION_SEND);share.setType("text/plain");share.putExtra(Intent.EXTRA_TEXT,"Join FadCam Production Camera "+slot+"\n\n"+link+"\n\nBrowser fallback:\n"+fallback);try{getContext().startActivity(Intent.createChooser(share,"Invite Camera "+slot));}catch(Exception ignored){Toast.makeText(getContext(),"Guest link copied",Toast.LENGTH_SHORT).show();}} + + private void showVideoPicker(){Uri collection=MediaStore.Video.Media.EXTERNAL_CONTENT_URI;String[] p={MediaStore.Video.Media._ID,MediaStore.Video.Media.DISPLAY_NAME,MediaStore.Video.Media.DURATION};List uris=new ArrayList<>();List labels=new ArrayList<>();try(Cursor c=getContext().getContentResolver().query(collection,p,null,null,MediaStore.Video.Media.DATE_ADDED+" DESC")){if(c!=null){int n=0;while(c.moveToNext()&&n++<30){long id=c.getLong(c.getColumnIndexOrThrow(MediaStore.Video.Media._ID));String name=c.getString(c.getColumnIndexOrThrow(MediaStore.Video.Media.DISPLAY_NAME));long ms=c.getLong(c.getColumnIndexOrThrow(MediaStore.Video.Media.DURATION));uris.add(Uri.withAppendedPath(collection,String.valueOf(id)));labels.add(name+" • "+formatDuration(ms));}}}catch(Exception e){Toast.makeText(getContext(),"Unable to read device videos",Toast.LENGTH_LONG).show();return;}if(uris.isEmpty()){Toast.makeText(getContext(),"No videos found in storage",Toast.LENGTH_SHORT).show();return;}new MaterialAlertDialogBuilder(getContext()).setTitle("LOAD VIDEO • STORAGE").setItems(labels.toArray(new String[0]),(d,w)->loadVideo(uris.get(w))).setNegativeButton("CANCEL",null).show();} + + private void loadVideo(@NonNull Uri uri){ + if(previewPlayerView==null){Toast.makeText(getContext(),"Control-room preview is unavailable",Toast.LENGTH_LONG).show();return;} + releaseVideoPlayer(); + try { + videoPlayer=new ExoPlayer.Builder(getContext()).build(); + videoPlayer.setMediaItem(MediaItem.fromUri(uri)); + videoPlayer.prepare(); + videoPlayer.setPlayWhenReady(true); + previewPlayerView.setPlayer(videoPlayer); + if(videoDucker==null)videoDucker=new ProductionAudioDucker(); + videoDucker.start(); + videoDucker.attach(videoPlayer); + state=state.withPreview(ProductionScene.VIDEO);state.save(getContext());refreshValues(); + Toast.makeText(getContext(),"VIDEO LOADED • CONTROL-ROOM PREVIEW • READY FOR TAKE",Toast.LENGTH_SHORT).show(); + } catch(Exception e) { + releaseVideoPlayer(); + Toast.makeText(getContext(),"Unable to load video",Toast.LENGTH_LONG).show(); + } + } + + private String formatDuration(long ms){long s=Math.max(0,ms/1000);return String.format(Locale.US,"%02d:%02d",s/60,s%60);} + + private void toggleLive(){try{if(ProductionStreamingController.isLive(getContext()))ProductionStreamingController.stop(getContext());else ProductionStreamingController.start(getContext());liveButton.setText(ProductionStreamingController.isLive(getContext())?"ON AIR":"LIVE");}catch(Exception e){Toast.makeText(getContext(),e.getMessage(),Toast.LENGTH_LONG).show();}} + private void cutNow(){state=state.take().withTransition(ProductionControlState.Transition.CUT);state.save(getContext());listener.onStateChanged(state,true);refreshValues();} + private void takeNow(){state=state.take();state.save(getContext());listener.onStateChanged(state,true);refreshValues();} + private void autoTake(){if(autoButton!=null)autoButton.setEnabled(false);if(takeButton!=null)takeButton.setEnabled(false);if(state.getTransition()==ProductionControlState.Transition.CUT){takeNow();reenable();return;}getWindow().getDecorView().postDelayed(()->{if(isShowing())takeNow();reenable();},state.getTransitionDurationMs());} + private void reenable(){if(autoButton!=null)autoButton.setEnabled(true);if(takeButton!=null)takeButton.setEnabled(true);} + + private String programLabel(){return state.getProgramScene()==ProductionScene.CAMERA?"Camera "+state.getProgramCameraSlot():state.getProgramScene().getTitle();} + private String previewLabel(){return state.getPreviewScene()==ProductionScene.CAMERA?"Camera "+state.getPreviewCameraSlot():state.getPreviewScene().getTitle();} + private String previewDetailLabel(){ProductionCameraSlot c=ProductionCameraSlotManager.get(getContext(),state.getPreviewCameraSlot());return state.getPreviewScene()==ProductionScene.CAMERA?c.getStatus().name()+" • "+(c.getGuestName().isEmpty()?"visitor slot":c.getGuestName()):state.getPreviewScene().getDescription();} + private void refreshValues(){if(previewValue!=null)previewValue.setText(previewLabel());if(previewDetail!=null)previewDetail.setText(previewDetailLabel());if(tallyValue!=null)tallyValue.setText("ON AIR • "+programLabel());} + + private void releaseVideoPlayer(){ + if(videoDucker!=null)videoDucker.detachPlayer(); + if(previewPlayerView!=null)previewPlayerView.setPlayer(null); + if(videoPlayer!=null){try{videoPlayer.release();}catch(Exception ignored){}videoPlayer=null;} + } + + @Override public void dismiss(){releaseVideoPlayer();if(videoDucker!=null)videoDucker.stop();super.dismiss();} + + private LinearLayout row(){LinearLayout v=new LinearLayout(getContext());v.setOrientation(LinearLayout.HORIZONTAL);v.setGravity(Gravity.CENTER_VERTICAL);return v;} + private LinearLayout column(){return column(Color.TRANSPARENT);} + private LinearLayout column(int bg){LinearLayout v=new LinearLayout(getContext());v.setOrientation(LinearLayout.VERTICAL);if(bg!=Color.TRANSPARENT)v.setBackgroundColor(bg);return v;} + private TextView text(String s,float size,int color){TextView v=new TextView(getContext());v.setText(s);v.setTextSize(size);v.setTextColor(color);return v;} + private MaterialButton button(String s,boolean filled){MaterialButton b=new MaterialButton(getContext());b.setText(s);b.setTextColor(TEXT);b.setTextSize(10);b.setMinHeight(0);b.setAllCaps(false);b.setBackground(round(filled?RED:ALT,12));return b;} + private TextView section(String s){TextView v=text(s,10,MUTED);v.setTypeface(Typeface.DEFAULT,Typeface.BOLD);v.setPadding(dp(4),dp(8),dp(4),dp(4));return v;} + private View space(int w){View v=new View(getContext());v.setLayoutParams(new LinearLayout.LayoutParams(w,1));return v;} + private GradientDrawable round(int color,int radius){GradientDrawable d=new GradientDrawable();d.setColor(color);d.setCornerRadius(dp(radius));return d;} + private int dp(int n){return Math.round(n*getContext().getResources().getDisplayMetrics().density);} +} diff --git a/app/src/main/java/com/fadcam/production/ProductionControlState.java b/app/src/main/java/com/fadcam/production/ProductionControlState.java new file mode 100644 index 000000000..9c384f9ef --- /dev/null +++ b/app/src/main/java/com/fadcam/production/ProductionControlState.java @@ -0,0 +1,43 @@ +package com.fadcam.production; + +import android.content.Context; +import android.content.Intent; +import android.content.SharedPreferences; + +/** Persistent authoritative state for the TV production switcher. */ +public final class ProductionControlState { + public enum Transition { CUT("Cut"), DISSOLVE("Dissolve"), FADE("Fade"), WIPE("Wipe"); + private final String title; Transition(String title) { this.title = title; } public String getTitle() { return title; } } + private static final String PREFS = "fadcam_tv_production"; + private static final String KEY_PREVIEW = "switcher_preview", KEY_PROGRAM = "switcher_program"; + private static final String KEY_PREVIEW_CAMERA = "switcher_preview_camera_slot", KEY_PROGRAM_CAMERA = "switcher_program_camera_slot"; + private static final String KEY_TRANSITION = "switcher_transition", KEY_DURATION_MS = "switcher_transition_duration_ms"; + private static final String KEY_VIDEO_URI = "switcher_video_uri"; + public static final String ACTION_PROGRAM_PUBLISHED = "com.fadcam.production.PROGRAM_PUBLISHED"; + private static final int MIN_DURATION_MS = 0, MAX_DURATION_MS = 3000, MIN_CAMERA_SLOT = 1, MAX_CAMERA_SLOT = 6; + private final ProductionScene previewScene, programScene; private final int previewCameraSlot, programCameraSlot; + private final Transition transition; private final int transitionDurationMs; + public ProductionControlState(ProductionScene previewScene, ProductionScene programScene, Transition transition, int transitionDurationMs) { this(previewScene, programScene, 1, 1, transition, transitionDurationMs); } + public ProductionControlState(ProductionScene previewScene, ProductionScene programScene, int previewCameraSlot, int programCameraSlot, Transition transition, int transitionDurationMs) { + this.previewScene = previewScene == null ? ProductionScene.VIDEO : previewScene; this.programScene = programScene == null ? ProductionScene.VIDEO : programScene; + this.previewCameraSlot = clampCameraSlot(previewCameraSlot); this.programCameraSlot = clampCameraSlot(programCameraSlot); this.transition = transition == null ? Transition.CUT : transition; this.transitionDurationMs = clampDuration(transitionDurationMs); + } + public static ProductionControlState load(Context context) { SharedPreferences p = context.getSharedPreferences(PREFS, Context.MODE_PRIVATE); return new ProductionControlState(readScene(p, KEY_PREVIEW, ProductionScene.VIDEO), readScene(p, KEY_PROGRAM, ProductionScene.VIDEO), p.getInt(KEY_PREVIEW_CAMERA, 1), p.getInt(KEY_PROGRAM_CAMERA, 1), readTransition(p.getString(KEY_TRANSITION, Transition.CUT.name())), p.getInt(KEY_DURATION_MS, 500)); } + public void save(Context context) { SharedPreferences p = context.getSharedPreferences(PREFS, Context.MODE_PRIVATE); String oldProgram = p.getString(KEY_PROGRAM, null); p.edit().putString(KEY_PREVIEW, previewScene.name()).putString(KEY_PROGRAM, programScene.name()).putInt(KEY_PREVIEW_CAMERA, previewCameraSlot).putInt(KEY_PROGRAM_CAMERA, programCameraSlot).putString(KEY_TRANSITION, transition.name()).putInt(KEY_DURATION_MS, transitionDurationMs).apply(); if (oldProgram != null && !oldProgram.equals(programScene.name())) notifyProgramPublished(context); } + public void publishProgram(Context context) { save(context); notifyProgramPublished(context); } + private static void notifyProgramPublished(Context context) { Intent intent = new Intent(ACTION_PROGRAM_PUBLISHED); intent.setPackage(context.getPackageName()); context.sendBroadcast(intent); } + public static String getVideoUri(Context context) { return context.getSharedPreferences(PREFS, Context.MODE_PRIVATE).getString(KEY_VIDEO_URI, ""); } + public static void saveVideoUri(Context context, String uri) { context.getSharedPreferences(PREFS, Context.MODE_PRIVATE).edit().putString(KEY_VIDEO_URI, uri == null ? "" : uri.trim()).apply(); } + public ProductionScene getPreviewScene() { return previewScene; } public ProductionScene getProgramScene() { return programScene; } public int getPreviewCameraSlot() { return previewCameraSlot; } public int getProgramCameraSlot() { return programCameraSlot; } public Transition getTransition() { return transition; } public int getTransitionDurationMs() { return transitionDurationMs; } + public ProductionControlState withPreview(ProductionScene scene) { return new ProductionControlState(scene, programScene, previewCameraSlot, programCameraSlot, transition, transitionDurationMs); } + public ProductionControlState withProgram(ProductionScene scene) { return new ProductionControlState(previewScene, scene, previewCameraSlot, programCameraSlot, transition, transitionDurationMs); } + public ProductionControlState withPreviewCameraSlot(int slot) { return new ProductionControlState(ProductionScene.CAMERA, programScene, slot, programCameraSlot, transition, transitionDurationMs); } + public ProductionControlState withProgramCameraSlot(int slot) { return new ProductionControlState(previewScene, ProductionScene.CAMERA, previewCameraSlot, slot, transition, transitionDurationMs); } + public ProductionControlState withTransition(Transition value) { return new ProductionControlState(previewScene, programScene, previewCameraSlot, programCameraSlot, value, transitionDurationMs); } + public ProductionControlState withDuration(int durationMs) { return new ProductionControlState(previewScene, programScene, previewCameraSlot, programCameraSlot, transition, durationMs); } + public ProductionControlState take() { return new ProductionControlState(previewScene, previewScene, previewCameraSlot, previewCameraSlot, transition, transitionDurationMs); } + public static int clampDuration(int durationMs) { return Math.max(MIN_DURATION_MS, Math.min(MAX_DURATION_MS, durationMs)); } + public static int clampCameraSlot(int slot) { return Math.max(MIN_CAMERA_SLOT, Math.min(MAX_CAMERA_SLOT, slot)); } + private static ProductionScene readScene(SharedPreferences p, String key, ProductionScene fallback) { String v = p.getString(key, fallback.name()); if (v == null) return fallback; try { return ProductionScene.valueOf(v); } catch (IllegalArgumentException e) { return fallback; } } + private static Transition readTransition(String value) { if (value == null) return Transition.CUT; try { return Transition.valueOf(value); } catch (IllegalArgumentException e) { return Transition.CUT; } } +} diff --git a/app/src/main/java/com/fadcam/production/ProductionGuestWebView.java b/app/src/main/java/com/fadcam/production/ProductionGuestWebView.java new file mode 100644 index 000000000..55aed1679 --- /dev/null +++ b/app/src/main/java/com/fadcam/production/ProductionGuestWebView.java @@ -0,0 +1,129 @@ +package com.fadcam.production; + +import android.content.Context; +import android.graphics.Color; +import android.os.Handler; +import android.os.Looper; +import android.view.Gravity; +import android.view.View; +import android.view.ViewGroup; +import android.webkit.WebSettings; +import android.webkit.WebView; +import android.webkit.WebViewClient; +import android.widget.FrameLayout; + +import androidx.annotation.NonNull; + +import java.util.WeakHashMap; + +/** Embedded VDO.Ninja WebRTC receiver for the producer's remote guest camera slots. */ +public final class ProductionGuestWebView { + private static final WeakHashMap INSTANCES = new WeakHashMap<>(); + private static final int POLL_MS = 1000; + private static final int DISCONNECT_POLLS = 5; + + public static synchronized ProductionGuestWebView obtain(@NonNull Context context, @NonNull FrameLayout canvas) { + ProductionGuestWebView existing = INSTANCES.get(canvas); + if (existing != null) return existing; + dockCanvasIntoProgramMonitor(canvas); + ProductionGuestWebView created = new ProductionGuestWebView(context, canvas); + INSTANCES.put(canvas, created); + return created; + } + + private static void dockCanvasIntoProgramMonitor(@NonNull FrameLayout canvas) { + if (!(canvas.getParent() instanceof ViewGroup)) return; + ViewGroup parent = (ViewGroup) canvas.getParent(); + for (int i = 0; i < parent.getChildCount(); i++) { + View child = parent.getChildAt(i); + if (child instanceof FrameLayout && "Live Program monitor".contentEquals(child.getContentDescription())) { + if (canvas.getParent() != child) { + parent.removeView(canvas); + ((FrameLayout) child).addView(canvas, new FrameLayout.LayoutParams(-1, -1)); + } + return; + } + } + } + + private final Context context; + private final FrameLayout canvas; + private final WebView webView; + private final Handler handler = new Handler(Looper.getMainLooper()); + private int slot; + private boolean mediaSeen; + private int missingMediaPolls; + + private ProductionGuestWebView(@NonNull Context context, @NonNull FrameLayout canvas) { + this.context = context; + this.canvas = canvas; + webView = new WebView(context); + webView.setTag("fadcam_guest_webview"); + WebSettings settings = webView.getSettings(); + settings.setJavaScriptEnabled(true); + settings.setDomStorageEnabled(true); + settings.setMediaPlaybackRequiresUserGesture(false); + settings.setAllowContentAccess(true); + settings.setDatabaseEnabled(true); + webView.setBackgroundColor(Color.BLACK); + webView.setWebViewClient(new WebViewClient()); + webView.setOverScrollMode(WebView.OVER_SCROLL_NEVER); + webView.setVisibility(WebView.GONE); + canvas.addView(webView, new FrameLayout.LayoutParams(-1, -1)); + } + + public void loadSlot(int slot) { + int normalized = ProductionControlState.clampCameraSlot(slot); + if (this.slot == normalized && mediaSeen && webView.getVisibility() == WebView.VISIBLE) return; + this.slot = normalized; + mediaSeen = false; + missingMediaPolls = 0; + webView.setVisibility(WebView.VISIBLE); + webView.loadUrl(ProductionCameraSlotManager.viewerLink(context, normalized)); + pollForMedia(0); + } + + public void hide() { + handler.removeCallbacksAndMessages(null); + webView.stopLoading(); + webView.setVisibility(WebView.GONE); + mediaSeen = false; + missingMediaPolls = 0; + } + + public void applyScene(@NonNull ProductionScene scene) { + FrameLayout.LayoutParams lp; + switch (scene) { + case DUET_SPLIT: + lp = new FrameLayout.LayoutParams(Math.max(1, canvas.getWidth() / 2), -1, Gravity.END); break; + case COMMENTARY: + lp = new FrameLayout.LayoutParams(dp(190), dp(270), Gravity.TOP | Gravity.END); lp.setMargins(0, dp(16), dp(16), 0); break; + case INTERVIEW: + lp = new FrameLayout.LayoutParams(dp(230), dp(310), Gravity.BOTTOM | Gravity.END); lp.setMargins(0, 0, dp(16), dp(70)); break; + case DUET_PIP: + case CAMERA: + default: + lp = new FrameLayout.LayoutParams(-1, -1, Gravity.CENTER); break; + } + webView.setLayoutParams(lp); + } + + public void destroy() { + handler.removeCallbacksAndMessages(null); + INSTANCES.remove(canvas); + canvas.removeView(webView); + webView.stopLoading(); + webView.destroy(); + } + + private void pollForMedia(final int attempt) { + if (webView.getVisibility() != WebView.VISIBLE || attempt > 3600) return; + webView.evaluateJavascript("(function(){var v=[].slice.call(document.querySelectorAll('video'));return v.some(function(x){return x.readyState>=2&&!x.paused&&x.videoWidth>0});})()", value -> { + boolean hasMedia="true".equals(value); + if(hasMedia){missingMediaPolls=0;if(!mediaSeen){mediaSeen=true;ProductionCameraSlotManager.markConsumed(context,slot);}} + else if(mediaSeen){missingMediaPolls++;if(missingMediaPolls>=DISCONNECT_POLLS){ProductionCameraSlotManager.markDisconnected(context,slot);mediaSeen=false;missingMediaPolls=0;webView.loadUrl(ProductionCameraSlotManager.viewerLink(context,slot));}} + handler.postDelayed(()->pollForMedia(attempt+1),POLL_MS); + }); + } + private int dp(int value){return Math.round(value*context.getResources().getDisplayMetrics().density);} +} diff --git a/app/src/main/java/com/fadcam/production/ProductionOnlineTvInstaller.java b/app/src/main/java/com/fadcam/production/ProductionOnlineTvInstaller.java new file mode 100644 index 000000000..f6f273147 --- /dev/null +++ b/app/src/main/java/com/fadcam/production/ProductionOnlineTvInstaller.java @@ -0,0 +1,142 @@ +package com.fadcam.production; + +import android.app.Activity; +import android.app.AlertDialog; +import android.app.Application; +import android.content.Context; +import android.graphics.Color; +import android.net.Uri; +import android.os.Bundle; +import android.os.Handler; +import android.os.Looper; +import android.view.View; +import android.view.ViewGroup; +import android.webkit.WebResourceRequest; +import android.webkit.WebView; +import android.webkit.WebViewClient; +import android.widget.Button; +import android.widget.EditText; +import android.widget.FrameLayout; +import android.widget.LinearLayout; +import android.widget.TextView; +import android.widget.Toast; + +import androidx.activity.result.ActivityResultLauncher; +import androidx.activity.result.contract.ActivityResultContracts; +import androidx.media3.common.MediaItem; +import androidx.media3.common.MimeTypes; +import androidx.media3.exoplayer.ExoPlayer; +import androidx.media3.ui.PlayerView; + +import com.fadcam.R; +import com.fadcam.StudioActivity; +import com.fadcam.streaming.CloudStreamUploader; +import com.fadcam.streaming.RemoteStreamManager; + +import org.json.JSONObject; + +import java.util.Collections; +import java.util.Locale; + +/** Production Online TV health and media/music scene installer. */ +public final class ProductionOnlineTvInstaller { + private static final String TAG = "ProductionOnlineTv"; + private static final String PREFS = "FadCamProductionOnlineTv"; + private static final String PREF_VIEWER_URL = "public_viewer_url"; + private static final String PREF_HLS_URL = "public_hls_url"; + private static final String TAG_ONLINE_TV = "fadcam_online_tv_card"; + private static final String TAG_MEDIA_SCENE = "fadcam_media_scene"; + private static final long HEALTH_INTERVAL_MS = 1500L; + private static final long RELAY_STALE_MS = 8000L; + private static final Handler MAIN = new Handler(Looper.getMainLooper()); + private static boolean registered; + + private ProductionOnlineTvInstaller() {} + + public static synchronized void register(Application application) { + if (registered || application == null) return; + registered = true; + application.registerActivityLifecycleCallbacks(new Application.ActivityLifecycleCallbacks() { + @Override public void onActivityCreated(Activity a, Bundle state) { installWhenCreated(a); } + @Override public void onActivityStarted(Activity a) { } + @Override public void onActivityResumed(Activity a) { if (!(a instanceof StudioActivity)) scheduleOnlineTv(a); } + @Override public void onActivityPaused(Activity a) { } + @Override public void onActivityStopped(Activity a) { } + @Override public void onActivitySaveInstanceState(Activity a, Bundle state) { } + @Override public void onActivityDestroyed(Activity a) { } + }); + } + + private static void installWhenCreated(Activity activity) { + if (activity == null || activity.isFinishing()) return; + try { + if (activity instanceof StudioActivity) { + // Studio must be initialized while the Activity is in CREATED state so + // ActivityResultRegistry registration happens before STARTED. + installStudio((StudioActivity) activity); + } else if ("com.fadcam.MainActivity".equals(activity.getClass().getName())) { + scheduleOnlineTv(activity); + } + } catch (Throwable t) { + android.util.Log.e(TAG, "optional production feature installation failed", t); + } + } + + private static void scheduleOnlineTv(Activity activity) { + MAIN.postDelayed(() -> { + if (activity.isFinishing() || (android.os.Build.VERSION.SDK_INT >= 17 && activity.isDestroyed())) return; + try { installOnlineTv(activity); } catch (Throwable t) { android.util.Log.e(TAG, "Online TV UI failed", t); } + }, 250L); + } + + private static int id(Context context, String name) { return context.getResources().getIdentifier(name, "id", context.getPackageName()); } + + private static TextView label(Context c, String text) { TextView v=new TextView(c); v.setText(text); v.setTextColor(Color.WHITE); v.setTextSize(11f); v.setPadding(0,3,0,3); return v; } + + private static void installOnlineTv(Activity activity) { + int anchorId=id(activity,"recording_mode_row"); if(anchorId==0)return; View anchor=activity.findViewById(anchorId); + if(anchor==null||!(anchor.getParent() instanceof ViewGroup))return; ViewGroup parent=(ViewGroup)anchor.getParent(); if(parent.findViewWithTag(TAG_ONLINE_TV)!=null)return; + LinearLayout card=new LinearLayout(activity); card.setTag(TAG_ONLINE_TV); card.setOrientation(LinearLayout.VERTICAL); card.setPadding(14,14,14,14); card.setBackgroundColor(Color.rgb(20,20,20)); + TextView title=label(activity,"ONLINE TV • SERVER DELIVERY"); title.setTextSize(14f); title.setTextColor(Color.rgb(255,70,70)); title.setTypeface(null,android.graphics.Typeface.BOLD); card.addView(title); + TextView state=label(activity,"ONLINE TV • CHECKING…"), relay=label(activity,"Relay: checking"), viewers=label(activity,"Viewer count: checking"), upload=label(activity,"Upload health: checking"), viewerUrl=label(activity,"Public viewer URL: not configured"), hlsUrl=label(activity,"Public HLS URL: not configured"); + TextView warning=label(activity,""); warning.setTextColor(Color.rgb(255,60,60)); warning.setTypeface(null,android.graphics.Typeface.BOLD); warning.setVisibility(View.GONE); + card.addView(state);card.addView(relay);card.addView(viewers);card.addView(upload);card.addView(viewerUrl);card.addView(hlsUrl);card.addView(warning); + Button configure=new Button(activity);configure.setText("CONFIGURE PUBLIC VIEWER / HLS URLS");configure.setOnClickListener(v->showPublicUrlDialog(activity));card.addView(configure);parent.addView(card,parent.indexOfChild(anchor)); + Runnable updater=new Runnable(){@Override public void run(){if(activity.isFinishing()||(android.os.Build.VERSION.SDK_INT>=17&&activity.isDestroyed()))return;updateOnlineTvCard(activity,state,relay,viewers,upload,viewerUrl,hlsUrl,warning);MAIN.postDelayed(this,HEALTH_INTERVAL_MS);}};int tagId=id(activity,"online_tv_health_tag");if(tagId!=0)card.setTag(tagId,updater);MAIN.post(updater); + } + + private static void updateOnlineTvCard(Activity activity,TextView state,TextView relay,TextView viewers,TextView upload,TextView viewerUrl,TextView hlsUrl,TextView warning){ + try{JSONObject json=new JSONObject(RemoteStreamManager.getInstance().getStatusJson());boolean recording=json.optBoolean("isRecording",false),streaming=json.optBoolean("streaming",false);long last=json.optLong("lastRelayUploadMs",0L);int cloudViewers=json.optInt("cloudViewers",0);boolean telemetry=json.optBoolean("cloudViewerTelemetryAvailable",false);long now=System.currentTimeMillis();boolean enabled=CloudStreamUploader.getInstance(activity).isEnabled();boolean receiving=enabled&&last>0&&now-last<=RELAY_STALE_MS;boolean localReady=streaming&&recording&&json.optInt("fragmentsBuffered",0)>=2; + if(receiving){state.setText("ONLINE TV • LIVE / RECEIVING");state.setTextColor(Color.rgb(80,220,110));}else if(enabled&&recording){state.setText("ONLINE TV • SERVER NOT RECEIVING");state.setTextColor(Color.rgb(255,60,60));}else if(localReady){state.setText("ONLINE TV • LOCAL HLS READY");state.setTextColor(Color.rgb(255,190,70));}else{state.setText("ONLINE TV • STANDBY");state.setTextColor(Color.LTGRAY);} + relay.setText("Relay: "+(enabled?(receiving?"RECEIVING SEGMENTS":"ENABLED / WAITING"):"LOCAL ONLY"));viewers.setText("Viewer count: "+cloudViewers+(telemetry?" • LIVE TELEMETRY":" • TELEMETRY UNAVAILABLE")); + if(receiving){upload.setText("Upload health: GOOD • last segment "+((now-last)/1000L)+"s ago");upload.setTextColor(Color.rgb(80,220,110));warning.setVisibility(View.GONE);}else if(enabled&&recording){upload.setText("Upload health: FAILED / STALE");upload.setTextColor(Color.rgb(255,60,60));warning.setText("⚠ RED ALERT: SERVER HAS STOPPED RECEIVING SEGMENTS");warning.setVisibility(View.VISIBLE);}else{upload.setText("Upload health: IDLE");upload.setTextColor(Color.LTGRAY);warning.setVisibility(View.GONE);} + android.content.SharedPreferences p=activity.getSharedPreferences(PREFS,Context.MODE_PRIVATE);viewerUrl.setText("Public viewer URL: "+valueOrMissing(p.getString(PREF_VIEWER_URL,"")));hlsUrl.setText("Public HLS URL: "+valueOrMissing(p.getString(PREF_HLS_URL,""))); + }catch(Exception e){state.setText("ONLINE TV • STATUS ERROR");state.setTextColor(Color.rgb(255,60,60));warning.setText("⚠ Unable to read streaming health");warning.setVisibility(View.VISIBLE);} + } + private static String valueOrMissing(String v){return v==null||v.trim().isEmpty()?"not configured":v.trim();} + private static void showPublicUrlDialog(Activity a){LinearLayout root=new LinearLayout(a);root.setOrientation(LinearLayout.VERTICAL);int pad=(int)(16*a.getResources().getDisplayMetrics().density);root.setPadding(pad,0,pad,0);EditText viewer=new EditText(a),hls=new EditText(a);viewer.setSingleLine(true);hls.setSingleLine(true);viewer.setHint("https://your-domain.example/tv/channel");hls.setHint("https://your-domain.example/tv/channel/live.m3u8");android.content.SharedPreferences p=a.getSharedPreferences(PREFS,Context.MODE_PRIVATE);viewer.setText(p.getString(PREF_VIEWER_URL,""));hls.setText(p.getString(PREF_HLS_URL,""));root.addView(label(a,"PUBLIC VIEWER URL"));root.addView(viewer);root.addView(label(a,"PUBLIC HLS URL"));root.addView(hls);new AlertDialog.Builder(a).setTitle("ONLINE TV DELIVERY").setMessage("Use the exact public routes exposed by your relay/server. FadCam never guesses server routes.").setView(root).setPositiveButton("SAVE",(d,w)->p.edit().putString(PREF_VIEWER_URL,viewer.getText().toString().trim()).putString(PREF_HLS_URL,hls.getText().toString().trim()).apply()).setNegativeButton("CANCEL",null).show();} + + private static void installStudio(StudioActivity activity){ + View rootView=activity.findViewById(android.R.id.content);if(!(rootView instanceof ViewGroup))return;ViewGroup root=(ViewGroup)rootView;if(root.findViewWithTag(TAG_MEDIA_SCENE)!=null)return;int anchorId=id(activity,"studio_rtmp_status");View anchor=anchorId==0?null:activity.findViewById(anchorId);ViewGroup host=anchor!=null&&anchor.getParent() instanceof ViewGroup?(ViewGroup)anchor.getParent():root; + LinearLayout card=new LinearLayout(activity);card.setTag(TAG_MEDIA_SCENE);card.setOrientation(LinearLayout.VERTICAL);card.setPadding(10,10,10,10);card.setBackgroundColor(Color.rgb(12,17,24));TextView title=label(activity,"MEDIA / MUSIC SCENE");title.setTextSize(12f);title.setTypeface(null,android.graphics.Typeface.BOLD);card.addView(title);TextView status=label(activity,"SOURCE • CAMERA");card.addView(status); + FrameLayout playerHost=new FrameLayout(activity);playerHost.setBackgroundColor(Color.BLACK);card.addView(playerHost,new LinearLayout.LayoutParams(-1,260));PlayerView playerView=new PlayerView(activity);playerView.setUseController(true);playerView.setVisibility(View.GONE);playerHost.addView(playerView,new FrameLayout.LayoutParams(-1,-1)); + WebView youtube=new WebView(activity);youtube.setVisibility(View.GONE);youtube.setBackgroundColor(Color.BLACK);youtube.getSettings().setJavaScriptEnabled(true);youtube.getSettings().setDomStorageEnabled(true);youtube.getSettings().setMediaPlaybackRequiresUserGesture(true);youtube.getSettings().setAllowFileAccess(false);youtube.getSettings().setAllowContentAccess(false);youtube.setWebViewClient(new WebViewClient(){@Override public boolean shouldOverrideUrlLoading(WebView v,WebResourceRequest r){String h=r.getUrl().getHost();return h==null||!(h.equals("youtube.com")||h.endsWith(".youtube.com")||h.equals("youtube-nocookie.com")||h.endsWith(".youtube-nocookie.com"));}});playerHost.addView(youtube,new FrameLayout.LayoutParams(-1,-1)); + ExoPlayer player=new ExoPlayer.Builder(activity).build();playerView.setPlayer(player); + ActivityResultLauncher picker=activity.getActivityResultRegistry().register("fadcam-production-media-"+System.identityHashCode(activity),activity,new ActivityResultContracts.OpenDocument(),uri->{if(uri!=null){try{activity.getContentResolver().takePersistableUriPermission(uri,android.content.Intent.FLAG_GRANT_READ_URI_PERMISSION);}catch(Exception ignored){}playDirect(player,playerView,youtube,uri);status.setText("SOURCE • LOCAL • "+uri.getLastPathSegment());}}); + LinearLayout b1=new LinearLayout(activity);b1.setOrientation(LinearLayout.HORIZONTAL);Button local=button(activity,"LOCAL MEDIA"),online=button(activity,"ONLINE MEDIA"),yt=button(activity,"YOUTUBE");b1.addView(local,weightParams());b1.addView(online,weightParams());b1.addView(yt,weightParams());card.addView(b1);LinearLayout b2=new LinearLayout(activity);b2.setOrientation(LinearLayout.HORIZONTAL);Button take=button(activity,"TAKE TO PROGRAM"),camera=button(activity,"RETURN TO CAMERA"),stop=button(activity,"STOP MEDIA");b2.addView(take,weightParams());b2.addView(camera,weightParams());b2.addView(stop,weightParams());card.addView(b2); + TextView note=label(activity,"YouTube uses the official embedded player. Ads and playback rules remain controlled by YouTube; FadCam does not suppress, extract or re-stream YouTube media.");note.setTextColor(Color.rgb(170,180,194));card.addView(note);host.addView(card,0); + local.setOnClickListener(v->picker.launch(new String[]{"video/*","audio/*"}));online.setOnClickListener(v->showOnlineUrlDialog(activity,player,playerView,youtube,status));yt.setOnClickListener(v->showYouTubeDialog(activity,youtube,playerView,status));take.setOnClickListener(v->takeMediaToProgram(activity,player,playerView,youtube,status));camera.setOnClickListener(v->returnToCamera(playerHost,playerView,youtube,status));stop.setOnClickListener(v->{player.stop();youtube.stopLoading();playerView.setVisibility(View.GONE);youtube.setVisibility(View.GONE);status.setText("SOURCE • CAMERA");}); + activity.getWindow().getDecorView().addOnAttachStateChangeListener(new View.OnAttachStateChangeListener(){@Override public void onViewAttachedToWindow(View v){}@Override public void onViewDetachedFromWindow(View v){player.release();youtube.destroy();picker.unregister();}}); + } + + private static Button button(Context c,String t){Button b=new Button(c);b.setText(t);return b;} + private static LinearLayout.LayoutParams weightParams(){LinearLayout.LayoutParams p=new LinearLayout.LayoutParams(0,48,1f);p.setMargins(2,2,2,2);return p;} + private static void takeMediaToProgram(Activity a,ExoPlayer player,PlayerView pv,WebView yt,TextView status){int rid=id(a,"studio_program_snapshot");View image=rid==0?null:a.findViewById(rid);if(image==null||!(image.getParent() instanceof FrameLayout)){Toast.makeText(a,"Program monitor is unavailable",Toast.LENGTH_SHORT).show();return;}FrameLayout program=(FrameLayout)image.getParent();detach(pv);detach(yt);program.addView(pv,new FrameLayout.LayoutParams(-1,-1));program.addView(yt,new FrameLayout.LayoutParams(-1,-1));pv.setVisibility(player.getPlaybackState()!=ExoPlayer.STATE_IDLE?View.VISIBLE:View.GONE);yt.setVisibility(yt.getUrl()!=null?View.VISIBLE:View.GONE);status.setText("SOURCE • MEDIA PROGRAM");} + private static void returnToCamera(FrameLayout host,PlayerView pv,WebView yt,TextView status){detach(pv);detach(yt);host.addView(pv,new FrameLayout.LayoutParams(-1,-1));host.addView(yt,new FrameLayout.LayoutParams(-1,-1));pv.setVisibility(View.VISIBLE);yt.setVisibility(View.GONE);status.setText("SOURCE • CAMERA");} + private static void detach(View v){if(v.getParent() instanceof ViewGroup)((ViewGroup)v.getParent()).removeView(v);} + private static void showOnlineUrlDialog(Activity a,ExoPlayer player,PlayerView pv,WebView yt,TextView status){EditText input=new EditText(a);input.setSingleLine(true);input.setHint("https://example.com/live.m3u8 or music.mp4");new AlertDialog.Builder(a).setTitle("AUTHORIZED ONLINE MEDIA").setMessage("Use an HTTPS media URL that you are authorized to play. HLS (.m3u8), MP4 and MP3 are supported when compatible.").setView(input).setPositiveButton("PLAY",(d,w)->{String url=input.getText().toString().trim();if(!url.toLowerCase(Locale.US).startsWith("https://")){Toast.makeText(a,"Use an HTTPS media URL",Toast.LENGTH_LONG).show();return;}playDirect(player,pv,yt,Uri.parse(url));status.setText("SOURCE • ONLINE • "+url);}).setNegativeButton("CANCEL",null).show();} + private static void playDirect(ExoPlayer player,PlayerView pv,WebView yt,Uri uri){yt.setVisibility(View.GONE);pv.setVisibility(View.VISIBLE);MediaItem.Builder b=new MediaItem.Builder().setUri(uri);String s=uri.toString().toLowerCase(Locale.US);if(s.contains(".m3u8"))b.setMimeType(MimeTypes.APPLICATION_M3U8);else if(s.contains(".mp3"))b.setMimeType(MimeTypes.AUDIO_MPEG);else if(s.contains(".mp4"))b.setMimeType(MimeTypes.VIDEO_MP4);player.setMediaItem(b.build());player.prepare();player.play();} + private static void showYouTubeDialog(Activity a,WebView yt,PlayerView pv,TextView status){EditText input=new EditText(a);input.setSingleLine(true);input.setHint("https://www.youtube.com/watch?v=VIDEO_ID");new AlertDialog.Builder(a).setTitle("YOUTUBE OFFICIAL PLAYER").setMessage("YouTube is played only through its official embedded player. FadCam cannot suppress YouTube advertisements or extract/re-stream YouTube media.").setView(input).setPositiveButton("LOAD",(d,w)->{String id=extractYouTubeId(input.getText().toString().trim());if(id==null){Toast.makeText(a,"Invalid YouTube URL",Toast.LENGTH_LONG).show();return;}pv.setVisibility(View.GONE);yt.setVisibility(View.VISIBLE);String embed="https://www.youtube.com/embed/"+id+"?playsinline=1&controls=1&rel=0";yt.loadUrl(embed,Collections.singletonMap("Referer","https://com.fadcam"));status.setText("SOURCE • YOUTUBE • OFFICIAL PLAYER");}).setNegativeButton("CANCEL",null).show();} + private static String extractYouTubeId(String value){try{Uri u=Uri.parse(value);String host=u.getHost();if(host==null)return null;if(host.equals("youtu.be"))return validId(u.getLastPathSegment());if(host.equals("youtube.com")||host.endsWith(".youtube.com")){String id=validId(u.getQueryParameter("v"));if(id!=null)return id;String p=u.getPath();if(p!=null&&p.startsWith("/embed/"))return validId(p.substring(7));if(p!=null&&p.startsWith("/shorts/"))return validId(p.substring(8));}}catch(Exception ignored){}return null;} + private static String validId(String v){return v!=null&&v.matches("[A-Za-z0-9_-]{6,20}")?v:null;} +} diff --git a/app/src/main/java/com/fadcam/production/ProductionScene.java b/app/src/main/java/com/fadcam/production/ProductionScene.java new file mode 100644 index 000000000..18098a972 --- /dev/null +++ b/app/src/main/java/com/fadcam/production/ProductionScene.java @@ -0,0 +1,22 @@ +package com.fadcam.production; + +/** Presets for the mobile TV-production canvas. */ +public enum ProductionScene { + CAMERA("Camera", "Full camera program"), + VIDEO("Video", "Full loaded video"), + DUET_PIP("Duet PiP", "Video full with commentator window"), + DUET_SPLIT("Duet Split", "Video and commentator side-by-side"), + COMMENTARY("Commentary", "Commentator full with video inset"), + INTERVIEW("Interview", "Camera program with production lower-third"); + + private final String title; + private final String description; + + ProductionScene(String title, String description) { + this.title = title; + this.description = description; + } + + public String getTitle() { return title; } + public String getDescription() { return description; } +} diff --git a/app/src/main/java/com/fadcam/production/ProductionSceneManager.java b/app/src/main/java/com/fadcam/production/ProductionSceneManager.java new file mode 100644 index 000000000..ad58b6928 --- /dev/null +++ b/app/src/main/java/com/fadcam/production/ProductionSceneManager.java @@ -0,0 +1,40 @@ +package com.fadcam.production; + +import android.content.Context; +import android.content.SharedPreferences; + +/** Persists producer scene choice and lower-third branding text. */ +public final class ProductionSceneManager { + private static final String PREFS = "fadcam_tv_production"; + private static final String KEY_SCENE = "scene"; + private static final String KEY_LOWER_THIRD = "lower_third"; + + private ProductionSceneManager() {} + + public static ProductionScene getScene(Context context) { + String value = prefs(context).getString(KEY_SCENE, ProductionScene.DUET_PIP.name()); + try { + return ProductionScene.valueOf(value); + } catch (IllegalArgumentException ignored) { + return ProductionScene.DUET_PIP; + } + } + + public static void setScene(Context context, ProductionScene scene) { + prefs(context).edit().putString(KEY_SCENE, scene.name()).apply(); + } + + public static String getLowerThird(Context context) { + return prefs(context).getString(KEY_LOWER_THIRD, ""); + } + + public static void setLowerThird(Context context, String text) { + String safe = text == null ? "" : text.trim(); + if (safe.length() > 80) safe = safe.substring(0, 80); + prefs(context).edit().putString(KEY_LOWER_THIRD, safe).apply(); + } + + private static SharedPreferences prefs(Context context) { + return context.getSharedPreferences(PREFS, Context.MODE_PRIVATE); + } +} diff --git a/app/src/main/java/com/fadcam/production/ProductionStreamingController.java b/app/src/main/java/com/fadcam/production/ProductionStreamingController.java new file mode 100644 index 000000000..48711cb78 --- /dev/null +++ b/app/src/main/java/com/fadcam/production/ProductionStreamingController.java @@ -0,0 +1,95 @@ +package com.fadcam.production; + +import android.content.Context; +import android.content.Intent; +import android.content.SharedPreferences; + +import androidx.core.content.ContextCompat; + +import com.fadcam.Constants; +import com.fadcam.studio.StudioRtmpConfig; +import com.fadcam.studio.StudioRtmpSecretStore; +import com.fadcam.studio.StudioRtmpStreamService; + +/** + * Small production-room facade over the existing verified RTMP service. + * It keeps destination configuration in one place and never stores a stream + * key in ordinary SharedPreferences. + */ +public final class ProductionStreamingController { + private static final String PREFS = "FadCamPrefs"; + private static final String DEFAULT_PLATFORM = StudioRtmpConfig.PLATFORM_YOUTUBE; + + private ProductionStreamingController() {} + + public static String getPlatform(Context context) { + return context.getSharedPreferences(PREFS, Context.MODE_PRIVATE) + .getString(StudioRtmpConfig.PREF_PLATFORM, DEFAULT_PLATFORM); + } + + public static String getServer(Context context) { + return context.getSharedPreferences(PREFS, Context.MODE_PRIVATE) + .getString(StudioRtmpConfig.PREF_SERVER, StudioRtmpConfig.defaultServer(getPlatform(context))); + } + + public static boolean isLive(Context context) { + return context.getSharedPreferences(PREFS, Context.MODE_PRIVATE) + .getBoolean(StudioRtmpConfig.PREF_ACTIVE, false); + } + + public static boolean hasRecording(Context context) { + return context.getSharedPreferences(Constants.PREFS_NAME, Context.MODE_PRIVATE) + .getBoolean(Constants.PREF_IS_RECORDING_IN_PROGRESS, false); + } + + public static boolean hasStreamKey(Context context) { + try { + String key = StudioRtmpSecretStore.load(context); + return key != null && !key.trim().isEmpty(); + } catch (Exception ignored) { + return false; + } + } + + public static void saveDestination(Context context, String platform, String server, String streamKey) throws Exception { + String normalizedPlatform = platform == null || platform.trim().isEmpty() + ? DEFAULT_PLATFORM : platform.trim(); + String normalizedServer = server == null ? "" : server.trim(); + String normalizedKey = streamKey == null ? "" : streamKey.trim(); + if (normalizedServer.isEmpty()) throw new IllegalArgumentException("RTMP server URL is required"); + if (!isRtmpServer(normalizedServer)) throw new IllegalArgumentException("Use an RTMP or RTMPS server URL"); + if (normalizedKey.isEmpty()) throw new IllegalArgumentException("Stream key is required"); + + SharedPreferences prefs = context.getSharedPreferences(PREFS, Context.MODE_PRIVATE); + prefs.edit() + .putString(StudioRtmpConfig.PREF_PLATFORM, normalizedPlatform) + .putString(StudioRtmpConfig.PREF_SERVER, normalizedServer) + .apply(); + StudioRtmpSecretStore.save(context, normalizedKey); + } + + public static void start(Context context) { + if (!hasRecording(context)) throw new IllegalStateException("Start RECORD before going LIVE"); + String server = getServer(context); + if (server == null || server.trim().isEmpty()) throw new IllegalStateException("Configure a streaming destination first"); + if (!hasStreamKey(context)) throw new IllegalStateException("Configure a stream key first"); + + Intent intent = new Intent(context, StudioRtmpStreamService.class) + .setAction(StudioRtmpStreamService.ACTION_START) + .putExtra(StudioRtmpStreamService.EXTRA_SERVER, server) + .putExtra(StudioRtmpStreamService.EXTRA_PLATFORM, getPlatform(context)); + ContextCompat.startForegroundService(context, intent); + } + + public static void stop(Context context) { + Intent intent = new Intent(context, StudioRtmpStreamService.class) + .setAction(StudioRtmpStreamService.ACTION_STOP); + context.startService(intent); + } + + public static boolean isRtmpServer(String server) { + if (server == null) return false; + String value = server.trim().toLowerCase(java.util.Locale.US); + return value.startsWith("rtmp://") || value.startsWith("rtmps://"); + } +} diff --git a/app/src/main/java/com/fadcam/production/ProductionStreamingDialog.java b/app/src/main/java/com/fadcam/production/ProductionStreamingDialog.java new file mode 100644 index 000000000..49c051aee --- /dev/null +++ b/app/src/main/java/com/fadcam/production/ProductionStreamingDialog.java @@ -0,0 +1,441 @@ +package com.fadcam.production; + +import android.app.Dialog; +import android.content.Context; +import android.content.Intent; +import android.graphics.Color; +import android.graphics.drawable.GradientDrawable; +import android.net.Uri; +import android.os.Bundle; +import android.os.Handler; +import android.os.Looper; +import android.view.Gravity; +import android.view.View; +import android.view.Window; +import android.view.WindowManager; +import android.widget.ArrayAdapter; +import android.widget.EditText; +import android.widget.LinearLayout; +import android.widget.ScrollView; +import android.widget.Spinner; +import android.widget.TextView; +import android.widget.Toast; + +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; +import androidx.annotation.OptIn; +import androidx.core.content.ContextCompat; +import androidx.media3.common.MediaItem; +import androidx.media3.common.util.UnstableApi; +import androidx.media3.exoplayer.ExoPlayer; +import androidx.media3.ui.PlayerView; + +import com.fadcam.Constants; +import com.fadcam.streaming.RemoteStreamService; +import com.fadcam.studio.StudioRtmpConfig; +import com.fadcam.studio.StudioRtmpSecretStore; + +/** + * Live output panel used by the hidden production control room. + * The review surface previews the same local HLS PROGRAM feed that the RTMP + * bridge consumes, so the producer can inspect the actual outgoing program + * before pressing GO LIVE. + */ +@OptIn(markerClass = UnstableApi.class) +public final class ProductionStreamingDialog extends Dialog { + public interface Listener { + void onStreamingStateChanged(); + } + + private static final int BG = Color.rgb(10, 10, 10); + private static final int PANEL = Color.rgb(28, 28, 28); + private static final int ACCENT = Color.rgb(220, 42, 48); + private static final int TEXT = Color.WHITE; + private static final int MUTED = Color.rgb(180, 180, 180); + private static final int GOOD = Color.rgb(100, 230, 130); + private static final int WARN = Color.rgb(245, 190, 80); + private static final int BAD = Color.rgb(235, 90, 90); + private static final int HLS_WAIT_MS = 12000; + + private final Listener listener; + private final Handler handler = new Handler(Looper.getMainLooper()); + private Spinner platform; + private EditText server; + private EditText key; + private TextView status; + private TextView reviewStatus; + private TextView reviewChecks; + private PlayerView reviewPlayerView; + private ExoPlayer reviewPlayer; + private boolean reviewServiceStarted; + private int reviewWaitElapsedMs; + + public ProductionStreamingDialog(@NonNull Context context, @NonNull Listener listener) { + super(context); + this.listener = listener; + } + + @Override + protected void onCreate(@Nullable Bundle savedInstanceState) { + super.onCreate(savedInstanceState); + requestWindowFeature(Window.FEATURE_NO_TITLE); + setContentView(buildContent()); + setCanceledOnTouchOutside(false); + } + + @Override + protected void onStart() { + super.onStart(); + Window window = getWindow(); + if (window != null) { + window.setBackgroundDrawableResource(android.R.color.transparent); + window.setDimAmount(0.78f); + window.addFlags(WindowManager.LayoutParams.FLAG_DIM_BEHIND); + window.setLayout((int) (getContext().getResources().getDisplayMetrics().widthPixels * 0.94f), + WindowManager.LayoutParams.WRAP_CONTENT); + } + } + + @Override + protected void onStop() { + stopReview(false); + super.onStop(); + } + + private View buildContent() { + ScrollView scroll = new ScrollView(getContext()); + scroll.setFillViewport(true); + + LinearLayout root = new LinearLayout(getContext()); + root.setOrientation(LinearLayout.VERTICAL); + root.setPadding(dp(16), dp(14), dp(16), dp(14)); + root.setBackground(round(BG, 18)); + + LinearLayout titleRow = row(); + TextView title = text("LIVE OUTPUT • SOCIAL DESTINATIONS", 15, TEXT); + title.setTypeface(null, android.graphics.Typeface.BOLD); + titleRow.addView(title, new LinearLayout.LayoutParams(0, dp(42), 1)); + TextView close = text("×", 28, TEXT); + close.setGravity(Gravity.CENTER); + close.setContentDescription("Close live output panel"); + close.setOnClickListener(v -> dismiss()); + titleRow.addView(close, new LinearLayout.LayoutParams(dp(42), dp(42))); + root.addView(titleRow); + + TextView help = text("Review the real PROGRAM feed first. GO LIVE is gated behind an active recording, a valid RTMP/RTMPS destination and a protected stream key.", 10, MUTED); + help.setPadding(0, 0, 0, dp(10)); + root.addView(help); + + status = text("", 11, TEXT); + status.setPadding(dp(10), dp(10), dp(10), dp(10)); + root.addView(status, new LinearLayout.LayoutParams(-1, dp(44))); + + TextView platformLabel = text("DESTINATION", 9, MUTED); + platformLabel.setPadding(dp(2), dp(10), 0, dp(4)); + root.addView(platformLabel); + + platform = new Spinner(getContext()); + String[] platforms = { + StudioRtmpConfig.PLATFORM_YOUTUBE, + StudioRtmpConfig.PLATFORM_FACEBOOK, + StudioRtmpConfig.PLATFORM_TWITCH, + StudioRtmpConfig.PLATFORM_CUSTOM + }; + platform.setAdapter(new ArrayAdapter<>(getContext(), android.R.layout.simple_spinner_dropdown_item, platforms)); + String saved = ProductionStreamingController.getPlatform(getContext()); + for (int i = 0; i < platforms.length; i++) if (platforms[i].equals(saved)) platform.setSelection(i); + platform.setOnItemSelectedListener(new android.widget.AdapterView.OnItemSelectedListener() { + @Override public void onItemSelected(android.widget.AdapterView parent, View view, int position, long id) { + String selected = platforms[position]; + if (server != null && !StudioRtmpConfig.PLATFORM_CUSTOM.equals(selected)) { + server.setText(StudioRtmpConfig.defaultServer(selected)); + server.setEnabled(true); + } + updateStatus(); + updateReviewChecks(); + } + @Override public void onNothingSelected(android.widget.AdapterView parent) {} + }); + root.addView(platform, new LinearLayout.LayoutParams(-1, dp(48))); + + server = new EditText(getContext()); + server.setSingleLine(true); + server.setHint("RTMP / RTMPS server URL"); + server.setText(ProductionStreamingController.getServer(getContext())); + root.addView(server, new LinearLayout.LayoutParams(-1, dp(52))); + + key = new EditText(getContext()); + key.setSingleLine(true); + key.setHint("Stream key • protected"); + key.setInputType(android.text.InputType.TYPE_CLASS_TEXT); + key.setTransformationMethod(android.text.method.PasswordTransformationMethod.getInstance()); + try { + String savedKey = StudioRtmpSecretStore.load(getContext()); + if (savedKey != null && !savedKey.isEmpty()) key.setText(savedKey); + } catch (Exception ignored) { + } + root.addView(key, new LinearLayout.LayoutParams(-1, dp(52))); + + root.addView(buildStreamingReview(), new LinearLayout.LayoutParams(-1, dp(330))); + + LinearLayout actions = row(); + actions.setPadding(0, dp(10), 0, 0); + + android.widget.Button save = actionButton("SAVE DESTINATION", true); + save.setOnClickListener(v -> saveDestination()); + actions.addView(save, new LinearLayout.LayoutParams(0, dp(52), 1)); + actions.addView(space(dp(8))); + + android.widget.Button live = actionButton("GO LIVE", true); + live.setOnClickListener(v -> goLive()); + actions.addView(live, new LinearLayout.LayoutParams(0, dp(52), 1)); + root.addView(actions); + + android.widget.Button stop = actionButton("STOP LIVE", false); + stop.setOnClickListener(v -> stopLive()); + root.addView(stop, new LinearLayout.LayoutParams(-1, dp(48))); + + TextView destinations = text("SUPPORTED PRESETS\nYouTube • Facebook • Twitch • Custom RTMP\n\nOther RTMP-compatible destinations can be added with their server URL and stream key. Platform API authentication is intentionally not faked; RTMP/RTMPS credentials are supplied by the broadcaster.", 9, MUTED); + destinations.setPadding(dp(2), dp(12), 0, 0); + root.addView(destinations); + + updateStatus(); + updateReviewChecks(); + scroll.addView(root); + return scroll; + } + + private View buildStreamingReview() { + LinearLayout panel = new LinearLayout(getContext()); + panel.setOrientation(LinearLayout.VERTICAL); + panel.setPadding(dp(10), dp(10), dp(10), dp(10)); + panel.setBackground(round(PANEL, 14)); + + LinearLayout heading = row(); + TextView title = text("STREAMING REVIEW", 12, TEXT); + title.setTypeface(null, android.graphics.Typeface.BOLD); + heading.addView(title, new LinearLayout.LayoutParams(0, dp(30), 1)); + reviewStatus = text("REVIEW OFF", 9, WARN); + reviewStatus.setGravity(Gravity.CENTER); + heading.addView(reviewStatus, new LinearLayout.LayoutParams(dp(96), dp(30))); + panel.addView(heading); + + reviewPlayerView = new PlayerView(getContext()); + reviewPlayerView.setUseController(true); + reviewPlayerView.setShutterBackgroundColor(Color.BLACK); + reviewPlayerView.setContentDescription("Live program streaming review preview"); + panel.addView(reviewPlayerView, new LinearLayout.LayoutParams(-1, dp(160))); + + reviewChecks = text("", 9, MUTED); + reviewChecks.setPadding(dp(2), dp(8), dp(2), dp(4)); + panel.addView(reviewChecks, new LinearLayout.LayoutParams(-1, dp(74))); + + LinearLayout buttons = row(); + android.widget.Button review = actionButton("REVIEW PROGRAM", true); + review.setOnClickListener(v -> startReview()); + buttons.addView(review, new LinearLayout.LayoutParams(0, dp(44), 1)); + buttons.addView(space(dp(8))); + android.widget.Button stopReview = actionButton("STOP REVIEW", false); + stopReview.setOnClickListener(v -> stopReview(true)); + buttons.addView(stopReview, new LinearLayout.LayoutParams(0, dp(44), 1)); + panel.addView(buttons); + return panel; + } + + private void startReview() { + if (!ProductionStreamingController.hasRecording(getContext())) { + setReviewStatus("RECORD FIRST", WARN); + Toast.makeText(getContext(), "Start RECORD before reviewing the outgoing program feed", Toast.LENGTH_LONG).show(); + return; + } + int cloudMode = getContext().getSharedPreferences("FadCamCloudPrefs", Context.MODE_PRIVATE) + .getInt("streaming_mode", 0); + if (cloudMode == 1) { + setReviewStatus("CLOUD MODE", WARN); + Toast.makeText(getContext(), "Local HLS review is unavailable in cloud streaming mode", Toast.LENGTH_LONG).show(); + return; + } + + setReviewStatus("STARTING…", WARN); + reviewServiceStarted = true; + try { + ContextCompat.startForegroundService(getContext(), new Intent(getContext(), RemoteStreamService.class)); + } catch (Exception e) { + reviewServiceStarted = false; + setReviewStatus("HLS ERROR", BAD); + Toast.makeText(getContext(), "Could not start the local HLS review engine", Toast.LENGTH_LONG).show(); + return; + } + reviewWaitElapsedMs = 0; + handler.post(reviewWaitRunnable); + } + + private final Runnable reviewWaitRunnable = new Runnable() { + @Override public void run() { + if (!isShowing()) return; + int port = getContext().getSharedPreferences("FadCamPrefs", Context.MODE_PRIVATE) + .getInt("stream_server_port", -1); + if (port > 0) { + loadReviewPlayer(port); + return; + } + reviewWaitElapsedMs += 250; + if (reviewWaitElapsedMs >= HLS_WAIT_MS) { + setReviewStatus("HLS TIMEOUT", BAD); + Toast.makeText(getContext(), "Program HLS output did not become ready", Toast.LENGTH_LONG).show(); + return; + } + handler.postDelayed(this, 250); + } + }; + + private void loadReviewPlayer(int port) { + if (reviewPlayer != null) reviewPlayer.release(); + reviewPlayer = new ExoPlayer.Builder(getContext()).build(); + reviewPlayerView.setPlayer(reviewPlayer); + Uri uri = Uri.parse("http://127.0.0.1:" + port + "/live.m3u8"); + reviewPlayer.setMediaItem(MediaItem.fromUri(uri)); + reviewPlayer.prepare(); + reviewPlayer.setPlayWhenReady(true); + setReviewStatus("PROGRAM REVIEW", GOOD); + updateReviewChecks(); + } + + private void stopReview(boolean showToast) { + handler.removeCallbacks(reviewWaitRunnable); + if (reviewPlayer != null) { + reviewPlayer.stop(); + reviewPlayer.release(); + reviewPlayer = null; + } + if (reviewPlayerView != null) reviewPlayerView.setPlayer(null); + if (reviewServiceStarted && !ProductionStreamingController.isLive(getContext())) { + try { + getContext().stopService(new Intent(getContext(), RemoteStreamService.class)); + } catch (Exception ignored) { + } + } + reviewServiceStarted = false; + if (reviewStatus != null) setReviewStatus("REVIEW OFF", WARN); + if (showToast) Toast.makeText(getContext(), "Program review stopped", Toast.LENGTH_SHORT).show(); + } + + private void saveDestination() { + String selected = String.valueOf(platform.getSelectedItem()); + try { + ProductionStreamingController.saveDestination(getContext(), selected, + server.getText().toString(), key.getText().toString()); + Toast.makeText(getContext(), "LIVE destination saved securely", Toast.LENGTH_SHORT).show(); + updateStatus(); + updateReviewChecks(); + listener.onStreamingStateChanged(); + } catch (Exception e) { + Toast.makeText(getContext(), e.getMessage() == null ? "Could not save destination" : e.getMessage(), Toast.LENGTH_LONG).show(); + } + } + + private void goLive() { + try { + if (!ProductionStreamingController.hasRecording(getContext())) { + Toast.makeText(getContext(), "Start RECORD before GO LIVE", Toast.LENGTH_LONG).show(); + return; + } + ProductionStreamingController.saveDestination(getContext(), String.valueOf(platform.getSelectedItem()), + server.getText().toString(), key.getText().toString()); + ProductionStreamingController.start(getContext()); + updateStatus(); + updateReviewChecks(); + listener.onStreamingStateChanged(); + Toast.makeText(getContext(), "Connecting to " + platform.getSelectedItem() + "…", Toast.LENGTH_SHORT).show(); + } catch (Exception e) { + Toast.makeText(getContext(), e.getMessage() == null ? "Unable to start LIVE output" : e.getMessage(), Toast.LENGTH_LONG).show(); + updateStatus(); + updateReviewChecks(); + } + } + + private void stopLive() { + ProductionStreamingController.stop(getContext()); + stopReview(false); + updateStatus(); + updateReviewChecks(); + listener.onStreamingStateChanged(); + Toast.makeText(getContext(), "LIVE output stopping…", Toast.LENGTH_SHORT).show(); + } + + private void updateStatus() { + if (status == null) return; + boolean live = ProductionStreamingController.isLive(getContext()); + boolean recording = ProductionStreamingController.hasRecording(getContext()); + String destination = ProductionStreamingController.getPlatform(getContext()); + status.setText(live + ? "● LIVE • " + destination + : (recording ? "READY • RECORDING • " + destination : "READY • START RECORDING FIRST")); + status.setTextColor(live ? GOOD : TEXT); + status.setBackground(round(live ? Color.rgb(25, 70, 38) : PANEL, 10)); + } + + private void updateReviewChecks() { + if (reviewChecks == null) return; + boolean recording = ProductionStreamingController.hasRecording(getContext()); + boolean destination = ProductionStreamingController.isRtmpServer(server == null ? null : server.getText().toString()); + boolean keyConfigured = key != null && key.getText() != null && !key.getText().toString().trim().isEmpty(); + boolean protectedKey = ProductionStreamingController.hasStreamKey(getContext()); + String review = reviewPlayer != null ? "READY" : "NOT STARTED"; + reviewChecks.setText("PROGRAM FEED " + (recording ? "✓ RECORDING" : "✕ RECORD FIRST") + + "\nDESTINATION " + (destination ? "✓ RTMP/RTMPS" : "✕ INVALID URL") + + "\nSTREAM KEY " + ((keyConfigured && protectedKey) ? "✓ PROTECTED" : "✕ NOT CONFIGURED") + + "\nREVIEW STATE " + review); + reviewChecks.setTextColor(recording && destination && keyConfigured && protectedKey ? GOOD : MUTED); + } + + private void setReviewStatus(String value, int color) { + if (reviewStatus != null) { + reviewStatus.setText(value); + reviewStatus.setTextColor(color); + } + } + + private android.widget.Button actionButton(String value, boolean accent) { + android.widget.Button button = new android.widget.Button(getContext()); + button.setText(value); + button.setTextSize(10); + button.setTextColor(TEXT); + button.setAllCaps(false); + button.setMinHeight(0); + button.setBackground(round(accent ? ACCENT : PANEL, 12)); + return button; + } + + private TextView text(String value, float size, int color) { + TextView view = new TextView(getContext()); + view.setText(value); + view.setTextSize(size); + view.setTextColor(color); + return view; + } + + private LinearLayout row() { + LinearLayout row = new LinearLayout(getContext()); + row.setOrientation(LinearLayout.HORIZONTAL); + row.setGravity(Gravity.CENTER_VERTICAL); + return row; + } + + private View space(int width) { + View view = new View(getContext()); + view.setLayoutParams(new LinearLayout.LayoutParams(width, 1)); + return view; + } + + private GradientDrawable round(int color, int radius) { + GradientDrawable drawable = new GradientDrawable(); + drawable.setColor(color); + drawable.setCornerRadius(dp(radius)); + return drawable; + } + + private int dp(int value) { + return Math.round(value * getContext().getResources().getDisplayMetrics().density); + } +} diff --git a/app/src/main/java/com/fadcam/streaming/CloudStreamUploader.java b/app/src/main/java/com/fadcam/streaming/CloudStreamUploader.java index 465ce2329..86b087d8b 100644 --- a/app/src/main/java/com/fadcam/streaming/CloudStreamUploader.java +++ b/app/src/main/java/com/fadcam/streaming/CloudStreamUploader.java @@ -22,6 +22,8 @@ import java.util.ArrayList; import java.util.List; import java.util.concurrent.ExecutorService; +import java.util.concurrent.ConcurrentHashMap; +import java.util.Map; import java.util.concurrent.Executors; import java.util.concurrent.TimeUnit; @@ -64,6 +66,11 @@ public class CloudStreamUploader { // Retry limits to prevent infinite loops and excessive auth requests private static final int MAX_401_RETRIES = 2; private static final long BACKOFF_AFTER_AUTH_FAILURE_MS = 30000; // 30s backoff after auth failure + + // ONLINE_TV_RELAY_RETRY_V1: transient mobile-network failures must not + // silently drop live HLS segments. Retry init, media and playlist uploads. + private static final int MAX_UPLOAD_ATTEMPTS = 4; + private static final long UPLOAD_RETRY_BASE_DELAY_MS = 750; private static CloudStreamUploader instance; @@ -71,6 +78,9 @@ public class CloudStreamUploader { private final OkHttpClient httpClient; private final CloudAuthManager authManager; private final ExecutorService uploadExecutor; + private final HlsSegmentDeliveryQueue deliveryQueue; + private final Map pendingSegmentCallbacks = new ConcurrentHashMap<>(); + private volatile UploadCallback pendingInitCallback; // Cached user UUID (extracted from JWT) private String cachedUserUuid = null; @@ -95,6 +105,51 @@ private CloudStreamUploader(Context context) { // Executor for segment uploads via HttpURLConnection (2 threads for parallel uploads) this.uploadExecutor = Executors.newFixedThreadPool(2); + this.deliveryQueue = new HlsSegmentDeliveryQueue(new HlsSegmentDeliveryQueue.Transport() { + @Override public void uploadInit(@NonNull byte[] initData, @NonNull HlsSegmentDeliveryQueue.Completion completion) { + uploadInitSegmentDirect(initData, new UploadCallback() { + @Override public void onSuccess() { + UploadCallback userCallback = pendingInitCallback; + pendingInitCallback = null; + try { + if (userCallback != null) userCallback.onSuccess(); + } finally { + completion.success(); + } + } + @Override public void onError(String error) { + UploadCallback userCallback = pendingInitCallback; + pendingInitCallback = null; + try { + if (userCallback != null) userCallback.onError(error); + } finally { + completion.failure(error); + } + } + }); + } + + @Override public void uploadSegment(int sequence, @NonNull byte[] data, @NonNull HlsSegmentDeliveryQueue.Completion completion) { + uploadSegmentDirect(sequence, data, new UploadCallback() { + @Override public void onSuccess() { + UploadCallback userCallback = pendingSegmentCallbacks.remove(sequence); + try { + if (userCallback != null) userCallback.onSuccess(); + } finally { + completion.success(); + } + } + @Override public void onError(String error) { + UploadCallback userCallback = pendingSegmentCallbacks.remove(sequence); + try { + if (userCallback != null) userCallback.onError(error); + } finally { + completion.failure(error); + } + } + }); + } + }); // Configure OkHttp with generous timeouts for poor mobile connections // NOTE: OkHttp is kept for legacy command API methods only. @@ -134,12 +189,18 @@ public void setEnabled(boolean enabled) { if (enabled) { // Reset state for new stream session initSegmentUploaded = false; + deliveryQueue.reset(); + pendingSegmentCallbacks.clear(); + pendingInitCallback = null; cachedUserUuid = null; // Reset auth failure tracking (fresh start) consecutive401Count = 0; authBackoffActive = false; FLog.i(TAG, "Cloud streaming enabled"); } else { + deliveryQueue.stop(); + pendingSegmentCallbacks.clear(); + pendingInitCallback = null; FLog.i(TAG, "Cloud streaming disabled"); } } @@ -151,6 +212,11 @@ public boolean isEnabled() { return isEnabled; } + + private HlsSegmentDeliveryQueue getDeliveryQueue() { + return deliveryQueue; + } + /** * Get user UUID from the JWT token. * JWT format: header.payload.signature (each base64 encoded) @@ -216,6 +282,16 @@ public void uploadInitSegment(byte[] initData, @Nullable UploadCallback callback FLog.d(TAG, "Cloud streaming disabled, skipping init upload"); return; } + pendingInitCallback = callback; + getDeliveryQueue().offerInit(initData); + // The callback is completed only after the relay ACK reaches the queue transport. + } + + private void uploadInitSegmentDirect(byte[] initData, @Nullable UploadCallback callback) { + if (!isEnabled) { + FLog.d(TAG, "Cloud streaming disabled, skipping init upload"); + return; + } String url = buildUploadUrl("init.mp4"); if (url == null) { @@ -256,9 +332,17 @@ public void uploadSegment(int sequenceNumber, byte[] segmentData, @Nullable Uplo if (!isEnabled) { return; } + if (callback != null) { + pendingSegmentCallbacks.put(sequenceNumber, callback); + } else { + pendingSegmentCallbacks.remove(sequenceNumber); + } + getDeliveryQueue().offerSegment(sequenceNumber, segmentData); + // The callback is completed only after the encrypted relay upload is ACKed. + } - if (!initSegmentUploaded) { - FLog.w(TAG, "Init segment not uploaded yet, skipping segment " + sequenceNumber); + private void uploadSegmentDirect(int sequenceNumber, byte[] segmentData, @Nullable UploadCallback callback) { + if (!isEnabled) { return; } @@ -334,32 +418,75 @@ private String buildUploadUrl(String filename) { * Will fetch a new stream token if needed. */ private void uploadBytes(String url, byte[] data, MediaType mediaType, @Nullable UploadCallback callback) { - // Check if we have a valid stream token + uploadBytesAttempt(url, data, mediaType, callback, 0); + } + + private void uploadBytesAttempt(String url, byte[] data, MediaType mediaType, + @Nullable UploadCallback callback, int attempt) { + if (!isEnabled) { + return; + } + String streamToken = authManager.getStreamToken(); - if (streamToken == null || authManager.isStreamTokenNearExpiry()) { - // Need to fetch stream token first FLog.i(TAG, "Stream token missing or near expiry, fetching..."); authManager.getValidStreamTokenAsync(new CloudAuthManager.StreamTokenListener() { @Override public void onSuccess(String newStreamToken) { - // Now perform the upload with the new token - doUploadWithToken(url, data, mediaType, newStreamToken, callback); + doUploadWithToken(url, data, mediaType, newStreamToken, + retryAwareCallback(url, data, mediaType, callback, attempt)); } - + @Override public void onError(String error) { - FLog.e(TAG, "Failed to get stream token: " + error); - failedUploads++; - if (callback != null) callback.onError("Stream token error: " + error); + retryOrFail(url, data, mediaType, callback, attempt, "Stream token error: " + error); } }); } else { - // Have a valid stream token, upload directly - doUploadWithToken(url, data, mediaType, streamToken, callback); + doUploadWithToken(url, data, mediaType, streamToken, + retryAwareCallback(url, data, mediaType, callback, attempt)); } } - + + private UploadCallback retryAwareCallback(String url, byte[] data, MediaType mediaType, + @Nullable UploadCallback finalCallback, int attempt) { + return new UploadCallback() { + @Override + public void onSuccess() { + if (finalCallback != null) finalCallback.onSuccess(); + } + + @Override + public void onError(String error) { + retryOrFail(url, data, mediaType, finalCallback, attempt, error); + } + }; + } + + private void retryOrFail(String url, byte[] data, MediaType mediaType, + @Nullable UploadCallback callback, int attempt, String error) { + if (!isEnabled) return; + if (attempt + 1 >= MAX_UPLOAD_ATTEMPTS) { + FLog.e(TAG, "❌ Relay upload exhausted retries: " + error); + if (callback != null) callback.onError(error); + return; + } + final int nextAttempt = attempt + 1; + final long delay = UPLOAD_RETRY_BASE_DELAY_MS << attempt; + FLog.w(TAG, "⚠️ Relay upload retry " + nextAttempt + "/" + MAX_UPLOAD_ATTEMPTS + + " in " + delay + "ms: " + error); + uploadExecutor.execute(() -> { + try { + Thread.sleep(delay); + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + if (callback != null) callback.onError("Upload retry interrupted"); + return; + } + uploadBytesAttempt(url, data, mediaType, callback, nextAttempt); + }); + } + /** * Actually perform the HTTP upload with the provided stream token. * @@ -493,6 +620,23 @@ public void onError(String fetchError) { }); } + /** + * Public online-TV base URL used by the FadSec web player. + * The dashboard exposes the same /stream/{deviceId}/ route. + */ + @Nullable + public String getPublicStreamUrl() { + String deviceId = authManager.getDeviceId(); + if (deviceId == null || deviceId.trim().isEmpty()) return null; + return "https://fadcam.fadseclab.com/stream/" + deviceId + "/"; + } + + @Nullable + public String getPublicHlsUrl() { + String base = getPublicStreamUrl(); + return base == null ? null : base + "live.m3u8"; + } + /** * Get upload statistics */ diff --git a/app/src/main/java/com/fadcam/streaming/HlsSegmentDeliveryQueue.java b/app/src/main/java/com/fadcam/streaming/HlsSegmentDeliveryQueue.java new file mode 100644 index 000000000..f0efeada2 --- /dev/null +++ b/app/src/main/java/com/fadcam/streaming/HlsSegmentDeliveryQueue.java @@ -0,0 +1,163 @@ +package com.fadcam.streaming; + +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; + +import java.util.Map; +import java.util.TreeMap; + +/** + * Serializes HLS initialization/media delivery so a media segment can never be + * intentionally discarded just because init.mp4 has not been acknowledged yet. + * + * The queue is transport-agnostic: CloudStreamUploader owns HTTP/auth/retry + * implementation, while this class owns ordering and lifecycle state. + */ +public final class HlsSegmentDeliveryQueue { + public interface Transport { + void uploadInit(@NonNull byte[] initData, @NonNull Completion completion); + void uploadSegment(int sequence, @NonNull byte[] data, @NonNull Completion completion); + } + + public interface Completion { + void success(); + void failure(@NonNull String error); + } + + private final Object lock = new Object(); + private final Transport transport; + private final TreeMap pending = new TreeMap<>(); + private byte[] pendingInit; + private boolean initAcknowledged; + private boolean initInFlight; + private boolean draining; + private boolean stopped; + + public HlsSegmentDeliveryQueue(@NonNull Transport transport) { + this.transport = transport; + } + + public void reset() { + synchronized (lock) { + pending.clear(); + pendingInit = null; + initAcknowledged = false; + initInFlight = false; + draining = false; + stopped = false; + } + } + + public void offerInit(@NonNull byte[] initData) { + synchronized (lock) { + if (stopped) return; + pendingInit = initData.clone(); + initAcknowledged = false; + } + retryInit(); + } + + /** Queues a media segment; it is never silently dropped. */ + public void offerSegment(int sequence, @NonNull byte[] data) { + if (sequence < 0) throw new IllegalArgumentException("sequence must be >= 0"); + synchronized (lock) { + if (stopped) return; + pending.put(sequence, data.clone()); + } + drain(); + } + + /** Retries the init upload and then resumes the media queue. */ + public void retryInit() { + final byte[] initData; + synchronized (lock) { + if (stopped || initAcknowledged || initInFlight || pendingInit == null) return; + initInFlight = true; + initData = pendingInit.clone(); + } + + transport.uploadInit(initData, new Completion() { + @Override public void success() { + synchronized (lock) { + initInFlight = false; + initAcknowledged = true; + } + drain(); + } + + @Override public void failure(@NonNull String error) { + synchronized (lock) { + initInFlight = false; + initAcknowledged = false; + } + } + }); + } + + /** Retries the oldest pending segment after a transport failure. */ + public void retryPending() { + if (!isInitAcknowledged()) { + retryInit(); + return; + } + drain(); + } + + /** Stops delivery and explicitly discards the session-owned queue. */ + public void stop() { + synchronized (lock) { + stopped = true; + pending.clear(); + pendingInit = null; + initInFlight = false; + draining = false; + } + } + + public int pendingCount() { + synchronized (lock) { + return pending.size(); + } + } + + public boolean isInitAcknowledged() { + synchronized (lock) { + return initAcknowledged; + } + } + + @Nullable + public Integer nextPendingSequence() { + synchronized (lock) { + return pending.isEmpty() ? null : pending.firstKey(); + } + } + + private void drain() { + final int sequence; + final byte[] data; + synchronized (lock) { + if (stopped || !initAcknowledged || draining || pending.isEmpty()) return; + Map.Entry entry = pending.firstEntry(); + sequence = entry.getKey(); + data = entry.getValue(); + draining = true; + } + + transport.uploadSegment(sequence, data, new Completion() { + @Override public void success() { + synchronized (lock) { + pending.remove(sequence); + draining = false; + } + drain(); + } + + @Override public void failure(@NonNull String error) { + synchronized (lock) { + draining = false; + } + } + }); + } +} diff --git a/app/src/main/java/com/fadcam/streaming/RemoteStreamManager.java b/app/src/main/java/com/fadcam/streaming/RemoteStreamManager.java index 548906a5b..6f1b50e97 100644 --- a/app/src/main/java/com/fadcam/streaming/RemoteStreamManager.java +++ b/app/src/main/java/com/fadcam/streaming/RemoteStreamManager.java @@ -19,8 +19,10 @@ import java.io.File; import java.util.ArrayList; import java.util.HashMap; +import java.util.HashSet; import java.util.List; import java.util.Map; +import java.util.Set; import java.util.concurrent.locks.ReadWriteLock; import java.util.concurrent.locks.ReentrantReadWriteLock; @@ -102,6 +104,13 @@ public class RemoteStreamManager { // Tracks the timestamp of the last successfully uploaded segment to the cloud relay. // Used by the dashboard to detect when the relay stream has gone stale/dead. private volatile long lastRelayUploadMs = 0; + + // ONLINE_TV_RELAY_DELIVERY_V1: only publish HLS playlists containing + // fragments that the relay has positively acknowledged. A session id + // prevents late callbacks from an old recording from contaminating a + // newly-started online TV stream. + private final Set relayUploadedSequences = new HashSet<>(); + private long relayStreamSessionId = 0; /** * Streaming mode options. @@ -411,6 +420,8 @@ public void onInitializationSegment(byte[] initData) { fragmentSequence = 0; oldestSequence = 1; bufferHead = 0; + relayStreamSessionId++; + relayUploadedSequences.clear(); if (clearedCount > 0) { } @@ -494,25 +505,35 @@ public void onFragmentComplete(int sequenceNumber, byte[] fragmentData, long dur if (context != null) { CloudStreamUploader uploader = CloudStreamUploader.getInstance(context); if (uploader.isEnabled() && uploader.isReady()) { - // Capture playlist now (while we have the lock) but upload after segment succeeds - final String playlist = generateCloudPlaylist(); - - // Upload segment with callback - playlist uploaded only after segment succeeds + final long uploadSessionId = relayStreamSessionId; + // Never publish a playlist before the referenced segment has been + // acknowledged by the relay. The playlist is generated AFTER the + // acknowledgement so it can also exclude older failed fragments. uploader.uploadSegment(sequenceNumber, fragmentData, new CloudStreamUploader.UploadCallback() { @Override public void onSuccess() { - // Segment uploaded successfully - update relay freshness timestamp - lastRelayUploadMs = System.currentTimeMillis(); - // NOW upload the playlist - if (playlist != null) { - uploader.uploadPlaylist(playlist, null); + if (uploadSessionId != relayStreamSessionId) { + FLog.w(TAG, "Ignoring late relay ACK for old stream session: " + sequenceNumber); + return; + } + bufferLock.writeLock().lock(); + try { + relayUploadedSequences.add(sequenceNumber); + relayUploadedSequences.removeIf(seq -> seq < oldestSequence); + lastRelayUploadMs = System.currentTimeMillis(); + String playlist = generateCloudPlaylist(); + if (playlist != null) { + uploader.uploadPlaylist(playlist, null); + } + } finally { + bufferLock.writeLock().unlock(); } } - + @Override public void onError(String error) { - // Segment failed - don't update playlist (viewers won't see missing segment) - FLog.w(TAG, "⚠️ Segment " + sequenceNumber + " upload failed, skipping playlist update: " + error); + FLog.w(TAG, "⚠️ Relay delivery failed for segment " + sequenceNumber + + "; playlist will not advertise it: " + error); } }); } @@ -664,50 +685,55 @@ public long getBufferSizeBytes() { private String generateCloudPlaylist() { bufferLock.readLock().lock(); try { - // Get buffered fragments List fragments = new ArrayList<>(); int validRangeStart = Math.max(1, fragmentSequence - BUFFER_SIZE + 1); - + for (FragmentData fragment : fragmentBuffer) { - if (fragment != null && fragment.sequenceNumber >= validRangeStart && fragment.sequenceNumber <= fragmentSequence) { + if (fragment != null + && fragment.sequenceNumber >= validRangeStart + && fragment.sequenceNumber <= fragmentSequence + && relayUploadedSequences.contains(fragment.sequenceNumber)) { fragments.add(fragment); } } - - // Need at least 2 fragments for HLS + + fragments.sort((a, b) -> Integer.compare(a.sequenceNumber, b.sequenceNumber)); if (fragments.size() < 2 || initializationSegment == null) { return null; } - - // Sort by sequence number - fragments.sort((a, b) -> Integer.compare(a.sequenceNumber, b.sequenceNumber)); - - // Get live edge (last 8 fragments for sliding window) - // Apple HLS spec requires minimum 6 segments, we use 8 for more buffer room - int LIVE_WINDOW_SIZE = 8; - List liveEdge = new ArrayList<>(); - int startIdx = Math.max(0, fragments.size() - LIVE_WINDOW_SIZE); - for (int i = startIdx; i < fragments.size(); i++) { - liveEdge.add(fragments.get(i)); + + // Only publish a contiguous acknowledged suffix. If segment N-1 is + // missing at the relay, segment N must not be advertised yet. + List contiguous = new ArrayList<>(); + int expected = fragments.get(fragments.size() - 1).sequenceNumber; + for (int i = fragments.size() - 1; i >= 0; i--) { + FragmentData fragment = fragments.get(i); + if (fragment.sequenceNumber != expected) { + break; + } + contiguous.add(0, fragment); + expected--; } - - // Build M3U8 playlist + if (contiguous.size() < 2) { + return null; + } + + int startIdx = Math.max(0, contiguous.size() - 8); + List liveEdge = new ArrayList<>(contiguous.subList(startIdx, contiguous.size())); + StringBuilder m3u8 = new StringBuilder(); m3u8.append("#EXTM3U\n"); - m3u8.append("#EXT-X-VERSION:7\n"); // fMP4 requires version 7 + m3u8.append("#EXT-X-VERSION:7\n"); m3u8.append("#EXT-X-INDEPENDENT-SEGMENTS\n"); m3u8.append("#EXT-X-TARGETDURATION:").append(getTargetDurationSeconds(liveEdge)).append("\n"); m3u8.append("#EXT-X-MEDIA-SEQUENCE:").append(liveEdge.get(0).sequenceNumber).append("\n"); - - // Init segment - relative path for cloud (same directory) m3u8.append("#EXT-X-MAP:URI=\"init.mp4\"\n"); - - // Add fragments - use relative paths (seg-N.m4s, not /seg-N.m4s) for (FragmentData fragment : liveEdge) { - m3u8.append("#EXTINF:").append(String.format(java.util.Locale.US, "%.3f", fragment.getDurationSeconds())).append(",\n"); + m3u8.append("#EXTINF:") + .append(String.format(java.util.Locale.US, "%.3f", fragment.getDurationSeconds())) + .append(",\n"); m3u8.append("seg-").append(fragment.sequenceNumber).append(".m4s\n"); } - return m3u8.toString(); } finally { bufferLock.readLock().unlock(); diff --git a/app/src/main/java/com/fadcam/studio/StudioGraphicsRecorderBridge.java b/app/src/main/java/com/fadcam/studio/StudioGraphicsRecorderBridge.java new file mode 100644 index 000000000..98f5c775c --- /dev/null +++ b/app/src/main/java/com/fadcam/studio/StudioGraphicsRecorderBridge.java @@ -0,0 +1,68 @@ +package com.fadcam.studio; + +import android.content.Context; +import android.content.SharedPreferences; + +import com.fadcam.Constants; + +/** + * Connects the Studio graphic editor to FadCam's existing GL encoder watermark + * path. This is deliberately preference based so RecordingService remains the + * single owner of the camera/encoder lifecycle. + */ +public final class StudioGraphicsRecorderBridge { + private static final String PREFS = "FadCamStudioGraphics"; + private static final String SAVED_OPTION = "saved_watermark_option"; + private static final String SAVED_TEXT = "saved_watermark_text"; + private static final String ACTIVE = "active"; + private static final String GRAPHIC = "graphic"; + + private StudioGraphicsRecorderBridge() {} + + public static void enable(Context context, String graphic) { + SharedPreferences studio = context.getSharedPreferences(PREFS, Context.MODE_PRIVATE); + SharedPreferences app = context.getSharedPreferences(Constants.PREFS_NAME, Context.MODE_PRIVATE); + if (!studio.getBoolean(ACTIVE, false)) { + studio.edit() + .putString(SAVED_OPTION, app.getString(Constants.PREF_WATERMARK_OPTION, Constants.DEFAULT_WATERMARK_OPTION)) + .putString(SAVED_TEXT, app.getString(Constants.PREF_WATERMARK_CUSTOM_TEXT, "")) + .putBoolean(ACTIVE, true) + .apply(); + } + app.edit() + // timestamp_fadcam is a known encoder-supported watermark mode; + // the Studio graphic becomes part of the GL-rendered encoded frame. + .putString(Constants.PREF_WATERMARK_OPTION, Constants.DEFAULT_WATERMARK_OPTION) + .putString(Constants.PREF_WATERMARK_CUSTOM_TEXT, graphic == null ? "" : graphic) + .apply(); + studio.edit().putString(GRAPHIC, graphic == null ? "" : graphic).apply(); + } + + public static void update(Context context, String graphic) { + if (!context.getSharedPreferences(PREFS, Context.MODE_PRIVATE).getBoolean(ACTIVE, false)) { + enable(context, graphic); + return; + } + context.getSharedPreferences(Constants.PREFS_NAME, Context.MODE_PRIVATE).edit() + .putString(Constants.PREF_WATERMARK_CUSTOM_TEXT, graphic == null ? "" : graphic) + .apply(); + context.getSharedPreferences(PREFS, Context.MODE_PRIVATE).edit() + .putString(GRAPHIC, graphic == null ? "" : graphic).apply(); + } + + public static void disable(Context context) { + SharedPreferences studio = context.getSharedPreferences(PREFS, Context.MODE_PRIVATE); + if (!studio.getBoolean(ACTIVE, false)) return; + String oldOption = studio.getString(SAVED_OPTION, Constants.DEFAULT_WATERMARK_OPTION); + String oldText = studio.getString(SAVED_TEXT, ""); + context.getSharedPreferences(Constants.PREFS_NAME, Context.MODE_PRIVATE).edit() + .putString(Constants.PREF_WATERMARK_OPTION, oldOption) + .putString(Constants.PREF_WATERMARK_CUSTOM_TEXT, oldText) + .apply(); + studio.edit().clear().apply(); + } + + public static boolean isActive(Context context) { + return context.getSharedPreferences(PREFS, Context.MODE_PRIVATE).getBoolean(ACTIVE, false); + } +} diff --git a/app/src/main/java/com/fadcam/studio/StudioRtmpConfig.java b/app/src/main/java/com/fadcam/studio/StudioRtmpConfig.java new file mode 100644 index 000000000..c5cb48239 --- /dev/null +++ b/app/src/main/java/com/fadcam/studio/StudioRtmpConfig.java @@ -0,0 +1,23 @@ +package com.fadcam.studio; + +/** Central RTMP destination configuration used by the Studio. */ +public final class StudioRtmpConfig { + public static final String PREF_SERVER = "studio_rtmp_server"; + public static final String PREF_PLATFORM = "studio_rtmp_platform"; + public static final String PREF_SECRET = "studio_rtmp_stream_key"; + public static final String PREF_ACTIVE = "studio_rtmp_active"; + + public static final String PLATFORM_YOUTUBE = "YouTube"; + public static final String PLATFORM_FACEBOOK = "Facebook"; + public static final String PLATFORM_TWITCH = "Twitch"; + public static final String PLATFORM_CUSTOM = "Custom RTMP"; + + private StudioRtmpConfig() {} + + public static String defaultServer(String platform) { + if (PLATFORM_YOUTUBE.equals(platform)) return "rtmps://a.rtmp.youtube.com/live2"; + if (PLATFORM_FACEBOOK.equals(platform)) return "rtmps://live-api-s.facebook.com:443/rtmp"; + if (PLATFORM_TWITCH.equals(platform)) return "rtmp://live.twitch.tv/app"; + return ""; + } +} diff --git a/app/src/main/java/com/fadcam/studio/StudioRtmpSecretStore.java b/app/src/main/java/com/fadcam/studio/StudioRtmpSecretStore.java new file mode 100644 index 000000000..cb716268f --- /dev/null +++ b/app/src/main/java/com/fadcam/studio/StudioRtmpSecretStore.java @@ -0,0 +1,67 @@ +package com.fadcam.studio; + +import android.content.Context; +import android.content.SharedPreferences; +import android.security.keystore.KeyGenParameterSpec; +import android.security.keystore.KeyProperties; + +import java.nio.charset.StandardCharsets; +import java.security.KeyStore; +import javax.crypto.Cipher; +import javax.crypto.KeyGenerator; +import javax.crypto.SecretKey; +import javax.crypto.spec.GCMParameterSpec; +import android.util.Base64; + +/** Keystore-backed storage for RTMP stream keys. The key is never logged. */ +public final class StudioRtmpSecretStore { + private static final String KEYSTORE = "AndroidKeyStore"; + private static final String KEY_ALIAS = "FadCamStudioRtmpKey"; + private static final String PREFS = "FadCamStudioSecrets"; + private static final String VALUE = "stream_key"; + private static final String IV = "stream_key_iv"; + + private StudioRtmpSecretStore() {} + + public static void save(Context context, String secret) throws Exception { + Cipher cipher = Cipher.getInstance("AES/GCM/NoPadding"); + cipher.init(Cipher.ENCRYPT_MODE, getOrCreateKey()); + byte[] encrypted = cipher.doFinal(secret.getBytes(StandardCharsets.UTF_8)); + SharedPreferences prefs = context.getSharedPreferences(PREFS, Context.MODE_PRIVATE); + prefs.edit() + .putString(VALUE, Base64.encodeToString(encrypted, Base64.NO_WRAP)) + .putString(IV, Base64.encodeToString(cipher.getIV(), Base64.NO_WRAP)) + .apply(); + } + + public static String load(Context context) throws Exception { + SharedPreferences prefs = context.getSharedPreferences(PREFS, Context.MODE_PRIVATE); + String encrypted = prefs.getString(VALUE, null); + String iv = prefs.getString(IV, null); + if (encrypted == null || iv == null) return ""; + Cipher cipher = Cipher.getInstance("AES/GCM/NoPadding"); + cipher.init(Cipher.DECRYPT_MODE, getOrCreateKey(), + new GCMParameterSpec(128, Base64.decode(iv, Base64.NO_WRAP))); + return new String(cipher.doFinal(Base64.decode(encrypted, Base64.NO_WRAP)), StandardCharsets.UTF_8); + } + + public static void clear(Context context) { + context.getSharedPreferences(PREFS, Context.MODE_PRIVATE).edit() + .remove(VALUE).remove(IV).apply(); + } + + private static SecretKey getOrCreateKey() throws Exception { + KeyStore keyStore = KeyStore.getInstance(KEYSTORE); + keyStore.load(null); + if (keyStore.containsAlias(KEY_ALIAS)) { + return ((KeyStore.SecretKeyEntry) keyStore.getEntry(KEY_ALIAS, null)).getSecretKey(); + } + KeyGenerator generator = KeyGenerator.getInstance(KeyProperties.KEY_ALGORITHM_AES, KEYSTORE); + generator.init(new KeyGenParameterSpec.Builder(KEY_ALIAS, + KeyProperties.PURPOSE_ENCRYPT | KeyProperties.PURPOSE_DECRYPT) + .setBlockModes(KeyProperties.BLOCK_MODE_GCM) + .setEncryptionPaddings(KeyProperties.ENCRYPTION_PADDING_NONE) + .build()); + return generator.generateKey(); + } +} diff --git a/app/src/main/java/com/fadcam/studio/StudioRtmpStreamService.java b/app/src/main/java/com/fadcam/studio/StudioRtmpStreamService.java new file mode 100644 index 000000000..c653b9291 --- /dev/null +++ b/app/src/main/java/com/fadcam/studio/StudioRtmpStreamService.java @@ -0,0 +1,248 @@ +package com.fadcam.studio; + +import android.app.Notification; +import android.app.NotificationChannel; +import android.app.NotificationManager; +import android.app.Service; +import android.content.Context; +import android.content.Intent; +import android.os.Build; +import android.os.Handler; +import android.os.IBinder; +import android.os.Looper; + +import androidx.annotation.Nullable; +import androidx.core.app.NotificationCompat; +import androidx.core.content.ContextCompat; + +import com.arthenica.ffmpegkit.FFmpegKit; +import com.arthenica.ffmpegkit.FFmpegSession; +import com.arthenica.ffmpegkit.ReturnCode; +import com.fadcam.Constants; +import com.fadcam.FLog; +import com.fadcam.R; +import com.fadcam.streaming.RemoteStreamService; + +/** + * Bridges FadCam's real local HLS program output to a platform RTMP/RTMPS + * destination. The video is not a second camera pipeline: it is the same + * encoded program stream produced by FadCam's recording/GL pipeline. + */ +public class StudioRtmpStreamService extends Service { + public static final String ACTION_START = "com.fadcam.studio.RTMP_START"; + public static final String ACTION_STOP = "com.fadcam.studio.RTMP_STOP"; + public static final String EXTRA_SERVER = "server"; + public static final String EXTRA_PLATFORM = "platform"; + + private static final String TAG = "StudioRtmpService"; + private static final int NOTIFICATION_ID = 2402; + private static final String CHANNEL_ID = "studio_rtmp_channel"; + private static final int MAX_WAIT_MS = 12000; + + private final Handler handler = new Handler(Looper.getMainLooper()); + private FFmpegSession ffmpegSession; + private boolean startedRemoteHls; + private int waitElapsedMs; + + @Override + public void onCreate() { + super.onCreate(); + createChannel(); + } + + @Override + public int onStartCommand(Intent intent, int flags, int startId) { + if (intent != null && ACTION_STOP.equals(intent.getAction())) { + stopRtmp(); + return START_NOT_STICKY; + } + if (intent == null || !ACTION_START.equals(intent.getAction())) { + return START_NOT_STICKY; + } + + startForeground(NOTIFICATION_ID, notification("Preparing RTMP output…")); + String server = intent.getStringExtra(EXTRA_SERVER); + String platform = intent.getStringExtra(EXTRA_PLATFORM); + if (server == null || server.trim().isEmpty()) { + fail("RTMP server URL is empty"); + return START_NOT_STICKY; + } + + try { + String streamKey = StudioRtmpSecretStore.load(this); + if (streamKey == null || streamKey.trim().isEmpty()) { + fail("RTMP stream key is not configured"); + return START_NOT_STICKY; + } + + getSharedPreferences("FadCamPrefs", MODE_PRIVATE).edit() + .putString(StudioRtmpConfig.PREF_SERVER, server.trim()) + .putString(StudioRtmpConfig.PREF_PLATFORM, platform == null ? StudioRtmpConfig.PLATFORM_CUSTOM : platform) + .putBoolean(StudioRtmpConfig.PREF_ACTIVE, true) + .apply(); + + boolean recording = getSharedPreferences(Constants.PREFS_NAME, MODE_PRIVATE) + .getBoolean(Constants.PREF_IS_RECORDING_IN_PROGRESS, false); + if (!recording) { + fail("Start RECORD before starting an RTMP output"); + return START_NOT_STICKY; + } + + startLocalHlsAndWait(); + } catch (Exception e) { + FLog.e(TAG, "Unable to prepare RTMP output", e); + fail("RTMP setup failed"); + } + return START_STICKY; + } + + private void startLocalHlsAndWait() { + startedRemoteHls = true; + try { + ContextCompat.startForegroundService(this, new Intent(this, RemoteStreamService.class)); + } catch (Exception e) { + fail("Could not start local HLS engine"); + return; + } + waitElapsedMs = 0; + handler.post(waitForHlsRunnable); + } + + private final Runnable waitForHlsRunnable = new Runnable() { + @Override + public void run() { + int port = getSharedPreferences("FadCamPrefs", MODE_PRIVATE) + .getInt("stream_server_port", -1); + if (port > 0) { + launchFfmpeg(port); + return; + } + waitElapsedMs += 250; + if (waitElapsedMs >= MAX_WAIT_MS) { + fail("HLS engine did not become ready"); + return; + } + handler.postDelayed(this, 250); + } + }; + + private void launchFfmpeg(int port) { + String server = getSharedPreferences("FadCamPrefs", MODE_PRIVATE) + .getString(StudioRtmpConfig.PREF_SERVER, ""); + try { + String key = StudioRtmpSecretStore.load(this); + String output = server.endsWith("/") ? server + key : server + "/" + key; + String input = "http://127.0.0.1:" + port + "/live.m3u8"; + + // FadCam's GL pipeline produces the program video; keep that encoded + // video stream and encode audio as AAC for broad RTMP compatibility. + String command = "-hide_banner -loglevel warning " + + "-reconnect 1 -reconnect_streamed 1 -reconnect_delay_max 2 " + + "-i " + quote(input) + " " + + "-c:v copy -c:a aac -b:a 128k -ar 44100 " + + "-f flv " + quote(output); + + updateNotification("Connecting to RTMP destination…"); + FLog.i(TAG, "Starting RTMP output for platform=" + + getSharedPreferences("FadCamPrefs", MODE_PRIVATE) + .getString(StudioRtmpConfig.PREF_PLATFORM, StudioRtmpConfig.PLATFORM_CUSTOM)); + + ffmpegSession = FFmpegKit.executeAsync(command, session -> { + if (ReturnCode.isSuccess(session.getReturnCode())) { + FLog.i(TAG, "RTMP session completed normally"); + } else if (!ReturnCode.isCancel(session.getReturnCode())) { + FLog.e(TAG, "RTMP session failed: " + safeFailure(session)); + } + if (!isFinishingService()) { + stopRtmp(); + } + }); + updateNotification("RTMP output LIVE"); + } catch (Exception e) { + FLog.e(TAG, "RTMP encoder start failed", e); + fail("RTMP encoder failed to start"); + } + } + + private String safeFailure(FFmpegSession session) { + String stack = session.getFailStackTrace(); + if (stack != null && !stack.isEmpty()) return stack.replaceAll("(?i)(key=)[^&\\s]+", "$1[redacted]"); + return "returnCode=" + session.getReturnCode(); + } + + private String quote(String value) { + return "'" + value.replace("'", "'\\''") + "'"; + } + + private void fail(String message) { + FLog.e(TAG, message); + updateNotification(message); + getSharedPreferences("FadCamPrefs", MODE_PRIVATE).edit() + .putBoolean(StudioRtmpConfig.PREF_ACTIVE, false).apply(); + handler.postDelayed(this::stopRtmp, 1200); + } + + private void stopRtmp() { + handler.removeCallbacks(waitForHlsRunnable); + if (ffmpegSession != null) { + try { + FFmpegKit.cancel(ffmpegSession.getSessionId()); + } catch (Exception ignored) { + } + ffmpegSession = null; + } + getSharedPreferences("FadCamPrefs", MODE_PRIVATE).edit() + .putBoolean(StudioRtmpConfig.PREF_ACTIVE, false).apply(); + if (startedRemoteHls) { + try { + stopService(new Intent(this, RemoteStreamService.class)); + } catch (Exception ignored) { + } + startedRemoteHls = false; + } + stopForeground(true); + stopSelf(); + } + + private boolean isFinishingService() { + return !getSharedPreferences("FadCamPrefs", MODE_PRIVATE) + .getBoolean(StudioRtmpConfig.PREF_ACTIVE, false); + } + + private Notification notification(String text) { + return new NotificationCompat.Builder(this, CHANNEL_ID) + // Reuse the application's launcher icon; there is no separate + // ic_notification drawable in this project. + .setSmallIcon(R.mipmap.ic_launcher) + .setContentTitle("FadCam Studio RTMP") + .setContentText(text) + .setOngoing(true) + .setOnlyAlertOnce(true) + .build(); + } + + private void updateNotification(String text) { + NotificationManager manager = (NotificationManager) getSystemService(NOTIFICATION_SERVICE); + if (manager != null) manager.notify(NOTIFICATION_ID, notification(text)); + } + + private void createChannel() { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { + NotificationManager manager = getSystemService(NotificationManager.class); + if (manager != null) manager.createNotificationChannel( + new NotificationChannel(CHANNEL_ID, "Studio RTMP", NotificationManager.IMPORTANCE_LOW)); + } + } + + @Override + public void onDestroy() { + handler.removeCallbacksAndMessages(null); + super.onDestroy(); + } + + @Nullable + @Override + public IBinder onBind(Intent intent) { + return null; + } +} diff --git a/app/src/main/java/com/fadcam/ui/BrandingManager.java b/app/src/main/java/com/fadcam/ui/BrandingManager.java new file mode 100644 index 000000000..af39d34cd --- /dev/null +++ b/app/src/main/java/com/fadcam/ui/BrandingManager.java @@ -0,0 +1,131 @@ +package com.fadcam.ui; + +import android.app.Activity; +import android.content.Context; +import android.content.Intent; +import android.content.SharedPreferences; +import android.graphics.Bitmap; +import android.graphics.BitmapFactory; +import android.net.Uri; + +import androidx.annotation.Nullable; +import androidx.core.content.pm.ShortcutInfoCompat; +import androidx.core.content.pm.ShortcutManagerCompat; +import androidx.core.graphics.drawable.IconCompat; + +import java.io.File; +import java.io.FileOutputStream; +import java.io.InputStream; + +/** Local branding state for the free Pro/Pro+ feature set. */ +public final class BrandingManager { + private static final String PREFS = "fadcam_pro_features"; + private static final String PREF_CUSTOM_NAME = "custom_app_name"; + private static final String ICON_FILE = "custom_branding_icon.png"; + private static final int MAX_NAME_LENGTH = 32; + + private BrandingManager() {} + + public static String getDisplayName(Context context) { + String value = context.getSharedPreferences(PREFS, Context.MODE_PRIVATE) + .getString(PREF_CUSTOM_NAME, "FadCam"); + return sanitizeName(value); + } + + public static boolean hasCustomIcon(Context context) { + File icon = getIconFile(context); + return icon.isFile() && icon.length() > 0; + } + + /** Saves the name and copies the selected document into app-private storage. */ + public static boolean save(Context context, @Nullable String name, @Nullable Uri iconUri) { + String safeName = sanitizeName(name); + SharedPreferences.Editor editor = context.getSharedPreferences(PREFS, Context.MODE_PRIVATE) + .edit().putString(PREF_CUSTOM_NAME, safeName); + if (iconUri != null && !copyIcon(context, iconUri)) return false; + editor.apply(); + return true; + } + + public static void clearIcon(Context context) { + File icon = getIconFile(context); + if (icon.exists()) icon.delete(); + } + + @Nullable + public static Bitmap loadIcon(Context context) { + File file = getIconFile(context); + if (!file.isFile()) return null; + return BitmapFactory.decodeFile(file.getAbsolutePath()); + } + + /** Applies the custom name to Android's activity title without changing the package label. */ + public static void applyToActivity(Activity activity) { + if (activity != null) activity.setTitle(getDisplayName(activity)); + } + + /** Creates/refreshes a supported Android home-screen shortcut with custom branding. */ + public static boolean publishHomeShortcut(Context context) { + Bitmap bitmap = loadIcon(context); + if (bitmap == null || bitmap.isRecycled()) return false; + Intent launch = context.getPackageManager().getLaunchIntentForPackage(context.getPackageName()); + if (launch == null) return false; + launch.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK | Intent.FLAG_ACTIVITY_CLEAR_TOP); + IconCompat icon = IconCompat.createWithBitmap(bitmap); + ShortcutInfoCompat shortcut = new ShortcutInfoCompat.Builder(context, "fadcam-custom-branding") + .setShortLabel(getDisplayName(context)) + .setLongLabel(getDisplayName(context)) + .setIcon(icon) + .setIntent(launch) + .build(); + return ShortcutManagerCompat.pushDynamicShortcut(context, shortcut); + } + + private static boolean copyIcon(Context context, Uri source) { + File destination = getIconFile(context); + File temp = new File(context.getFilesDir(), ICON_FILE + ".tmp"); + try (InputStream in = context.getContentResolver().openInputStream(source); + FileOutputStream out = new FileOutputStream(temp)) { + if (in == null) return false; + byte[] buffer = new byte[32 * 1024]; + int read; + while ((read = in.read(buffer)) != -1) out.write(buffer, 0, read); + if (!temp.renameTo(destination)) { + if (destination.exists()) destination.delete(); + if (!temp.renameTo(destination)) return false; + } + return true; + } catch (Exception e) { + if (temp.exists()) temp.delete(); + return false; + } + } + + private static File getIconFile(Context context) { + return new File(context.getFilesDir(), ICON_FILE); + } + + /** Pure-Java sanitization so the unit test can run without Android framework stubs. */ + static String sanitizeName(String value) { + if (value == null || value.trim().isEmpty()) return "FadCam"; + + // Keep line/tab separators from being silently concatenated into words. + // All other ISO control characters are replaced with a space as well. + StringBuilder cleaned = new StringBuilder(); + boolean previousWasSpace = false; + for (int i = 0; i < value.length() && cleaned.length() < MAX_NAME_LENGTH; i++) { + char c = value.charAt(i); + if (Character.isISOControl(c) || Character.isWhitespace(c)) { + if (!previousWasSpace) { + cleaned.append(' '); + previousWasSpace = true; + } + } else { + cleaned.append(c); + previousWasSpace = false; + } + } + String result = cleaned.toString().trim(); + return result.isEmpty() ? "FadCam" : result; + } +} diff --git a/app/src/main/java/com/fadcam/ui/FadCamProActivity.java b/app/src/main/java/com/fadcam/ui/FadCamProActivity.java index 2c7198fac..60b9b6710 100644 --- a/app/src/main/java/com/fadcam/ui/FadCamProActivity.java +++ b/app/src/main/java/com/fadcam/ui/FadCamProActivity.java @@ -1,16 +1,19 @@ package com.fadcam.ui; -import android.animation.ValueAnimator; +import android.app.AlertDialog; import android.content.Intent; +import android.content.SharedPreferences; import android.net.Uri; import android.os.Bundle; import android.view.View; -import android.view.animation.AccelerateDecelerateInterpolator; +import android.widget.EditText; import android.widget.ImageView; import android.widget.LinearLayout; import android.widget.TextView; import androidx.activity.OnBackPressedCallback; +import androidx.activity.result.ActivityResultLauncher; +import androidx.activity.result.contract.ActivityResultContracts; import androidx.appcompat.app.AppCompatActivity; import androidx.core.graphics.Insets; import androidx.core.view.ViewCompat; @@ -18,169 +21,247 @@ import com.fadcam.R; -/** - * FadCam Pro promotional activity displaying features and purchase options. - * Features a premium gradient design with animated elements. - */ +/** Free Pro/Pro+/Lab feature center. There is deliberately no payment gate. */ public class FadCamProActivity extends AppCompatActivity { + private static final String PREFS = "fadcam_pro_features"; - private static final String PATREON_SHOP_URL = "https://www.patreon.com/cw/Fadedx/shop"; - private ShimmerButton btnGetPro; private ImageView btnBack; private LinearLayout featuresContainer; + private ActivityResultLauncher brandingImagePicker; + private ImageView brandingPreview; + private Uri pendingBrandingUri; @Override protected void onCreate(Bundle savedInstanceState) { super.onCreate(savedInstanceState); + brandingImagePicker = registerForActivityResult(new ActivityResultContracts.OpenDocument(), uri -> { + if (uri == null) return; + try { getContentResolver().takePersistableUriPermission(uri, Intent.FLAG_GRANT_READ_URI_PERMISSION); } + catch (SecurityException ignored) { } + pendingBrandingUri = uri; + if (brandingPreview != null) brandingPreview.setImageURI(uri); + }); setContentView(R.layout.activity_fadcam_pro); - - // Handle edge-to-edge display ViewCompat.setOnApplyWindowInsetsListener(findViewById(R.id.rootContainer), (v, insets) -> { - Insets systemBars = insets.getInsets(WindowInsetsCompat.Type.systemBars()); - v.setPadding(systemBars.left, systemBars.top, systemBars.right, systemBars.bottom); + Insets bars = insets.getInsets(WindowInsetsCompat.Type.systemBars()); + v.setPadding(bars.left, bars.top, bars.right, bars.bottom); return insets; }); - initViews(); setupFeatureRows(); setupClickListeners(); + exposeUnlockedTools(); startAnimations(); } - /** - * Initialize all view references. - */ private void initViews() { btnGetPro = findViewById(R.id.btnGetPro); btnBack = findViewById(R.id.btnBack); featuresContainer = findViewById(R.id.featuresContainer); } - /** - * Setup feature comparison rows with proper data. - * Format: {featureName, freeStatus, proStatus, proPlusStatus, labStatus} - * Status values: "check" for available, "remove" for not available, "schedule" for coming soon - */ private void setupFeatureRows() { - // Feature data: name, free, pro, pro+, lab String[][] features = { - {getString(R.string.fadcam_pro_feature_everything_icon), "check", "check", "check", "remove"}, - {getString(R.string.fadcam_pro_feature_notification_icon), "remove", "check", "check", "remove"}, - {getString(R.string.fadcam_pro_feature_user_icon), "remove", "remove", "schedule", "remove"}, - {getString(R.string.fadcam_pro_feature_app_name), "remove", "remove", "schedule", "remove"}, - {getString(R.string.fadcam_pro_feature_faddrive), "remove", "remove", "remove", "schedule"} + {getString(R.string.fadcam_pro_feature_everything_icon), "check", "check", "check", "check"}, + {getString(R.string.fadcam_pro_feature_notification_icon), "check", "check", "check", "check"}, + {getString(R.string.fadcam_pro_feature_user_icon), "check", "check", "check", "check"}, + {getString(R.string.fadcam_pro_feature_app_name), "check", "check", "check", "check"}, + {getString(R.string.fadcam_pro_feature_faddrive), "check", "check", "check", "check"} }; - - View[] featureViews = { - findViewById(R.id.featureRow1), - findViewById(R.id.featureRow2), - findViewById(R.id.featureRow3), - findViewById(R.id.featureRow4), - findViewById(R.id.featureRow5) - }; - - for (int i = 0; i < features.length && i < featureViews.length; i++) { - View row = featureViews[i]; + View[] rows = {findViewById(R.id.featureRow1), findViewById(R.id.featureRow2), findViewById(R.id.featureRow3), + findViewById(R.id.featureRow4), findViewById(R.id.featureRow5)}; + for (int i = 0; i < features.length; i++) { + View row = rows[i]; if (row == null) continue; - - TextView tvName = row.findViewById(R.id.tvFeatureName); - TextView tvFree = row.findViewById(R.id.tvFreeStatus); - TextView tvPro = row.findViewById(R.id.tvProStatus); - TextView tvProPlus = row.findViewById(R.id.tvProPlusStatus); - TextView tvLab = row.findViewById(R.id.tvLabStatus); - - if (tvName != null) tvName.setText(features[i][0]); - - // Set Free status - setStatusIcon(tvFree, features[i][1], 0xFF666666, 0xFF4CAF50, 0xFFFF9800); - - // Set Pro status (gold color for check) - setStatusIcon(tvPro, features[i][2], 0xFF666666, 0xFFFFD700, 0xFFFF9800); - - // Set Pro+ status (coral color for check) - setStatusIcon(tvProPlus, features[i][3], 0xFF666666, 0xFFFF6B6B, 0xFFFF6B6B); - - // Set Lab status (cyan color for check) - setStatusIcon(tvLab, features[i][4], 0xFF666666, 0xFF00BCD4, 0xFF00BCD4); + TextView name = row.findViewById(R.id.tvFeatureName); + TextView free = row.findViewById(R.id.tvFreeStatus); + TextView pro = row.findViewById(R.id.tvProStatus); + TextView plus = row.findViewById(R.id.tvProPlusStatus); + TextView lab = row.findViewById(R.id.tvLabStatus); + if (name != null) name.setText(features[i][0]); + setStatusIcon(free, features[i][1], 0xFF666666, 0xFF4CAF50, 0xFFFF9800); + setStatusIcon(pro, features[i][2], 0xFF666666, 0xFFFFD700, 0xFFFF9800); + setStatusIcon(plus, features[i][3], 0xFF666666, 0xFFFF6B6B, 0xFFFF6B6B); + setStatusIcon(lab, features[i][4], 0xFF666666, 0xFF00BCD4, 0xFF00BCD4); } } - /** - * Set the status icon and color for a feature column. - */ private void setStatusIcon(TextView tv, String status, int removeColor, int checkColor, int scheduleColor) { if (tv == null) return; - - switch (status) { - case "check": - tv.setText("check_circle"); - tv.setTextColor(checkColor); - break; - case "schedule": - tv.setText("schedule"); - tv.setTextColor(scheduleColor); - break; - case "remove": - default: - tv.setText("remove"); - tv.setTextColor(removeColor); - break; + if ("check".equals(status)) { + tv.setText("check_circle"); tv.setTextColor(checkColor); + } else if ("schedule".equals(status)) { + tv.setText("schedule"); tv.setTextColor(scheduleColor); + } else { + tv.setText("remove"); tv.setTextColor(removeColor); } } - /** - * Setup click listeners for interactive elements. - */ private void setupClickListeners() { - // Back button btnBack.setOnClickListener(v -> finish()); + btnGetPro.setText("✓ Pro unlocked — all features are free"); + btnGetPro.setOnClickListener(v -> android.widget.Toast.makeText(this, + "All Pro, Pro+ and Lab features are unlocked.", android.widget.Toast.LENGTH_SHORT).show()); + } + + private void exposeUnlockedTools() { + if (featuresContainer == null) return; + // The legacy layout ends its comparison table with the early-bird and + // coming-soon cards. Hide those two cards so the UI cannot advertise a + // feature that is now available. + int count = featuresContainer.getChildCount(); + if (count >= 2) { + featuresContainer.getChildAt(count - 1).setVisibility(View.GONE); + featuresContainer.getChildAt(count - 2).setVisibility(View.GONE); + } + + LinearLayout tools = new LinearLayout(this); + tools.setOrientation(LinearLayout.VERTICAL); + tools.setPadding(dp(4), dp(8), dp(4), dp(4)); + + TextView status = new TextView(this); + status.setText("ALL FEATURES AVAILABLE NOW • NO PAYMENT REQUIRED"); + status.setTextColor(0xFF4CAF50); status.setTextSize(12); status.setGravity(android.view.Gravity.CENTER); + status.setPadding(dp(8), dp(10), dp(8), dp(10)); + tools.addView(status, new LinearLayout.LayoutParams(-1, -2)); + + com.google.android.material.button.MaterialButton branding = new com.google.android.material.button.MaterialButton(this); + branding.setText("Customize app name & icon"); + branding.setOnClickListener(v -> showBrandingDialog()); + tools.addView(branding, new LinearLayout.LayoutParams(-1, dp(48))); + + com.google.android.material.button.MaterialButton drive = new com.google.android.material.button.MaterialButton(this); + drive.setText("FadDrive cloud sync"); + drive.setOnClickListener(v -> showFadDriveDialog()); + LinearLayout.LayoutParams driveParams = new LinearLayout.LayoutParams(-1, dp(48)); + driveParams.topMargin = dp(6); + tools.addView(drive, driveParams); + featuresContainer.addView(tools); + + View bottomSection = findViewById(R.id.bottomSection); + if (bottomSection instanceof LinearLayout) { + LinearLayout bottom = (LinearLayout) bottomSection; + if (bottom.getChildCount() > 1) bottom.getChildAt(1).setVisibility(View.GONE); + } + } + + private void showBrandingDialog() { + LinearLayout root = new LinearLayout(this); + root.setOrientation(LinearLayout.VERTICAL); root.setPadding(dp(24), dp(8), dp(24), dp(8)); + + ImageView preview = new ImageView(this); + preview.setScaleType(ImageView.ScaleType.CENTER_CROP); preview.setBackgroundColor(0xFF202020); + LinearLayout.LayoutParams previewParams = new LinearLayout.LayoutParams(dp(96), dp(96)); + previewParams.gravity = android.view.Gravity.CENTER_HORIZONTAL; previewParams.bottomMargin = dp(12); + root.addView(preview, previewParams); brandingPreview = preview; + + EditText name = new EditText(this); + name.setHint("Custom app name"); name.setSingleLine(true); name.setText(BrandingManager.getDisplayName(this)); + root.addView(name, new LinearLayout.LayoutParams(-1, -2)); - // Get Pro button - opens Patreon shop - btnGetPro.setOnClickListener(v -> { - Intent intent = new Intent(Intent.ACTION_VIEW, Uri.parse(PATREON_SHOP_URL)); - startActivity(intent); + com.google.android.material.button.MaterialButton pick = new com.google.android.material.button.MaterialButton(this); + pick.setText("Choose custom icon"); + pick.setOnClickListener(v -> brandingImagePicker.launch(new String[]{"image/png", "image/jpeg", "image/webp"})); + root.addView(pick, new LinearLayout.LayoutParams(-1, dp(48))); + + if (BrandingManager.hasCustomIcon(this)) preview.setImageBitmap(BrandingManager.loadIcon(this)); + TextView hint = new TextView(this); + hint.setText("The uploaded icon is stored privately on this device and can also be published as a custom home-screen shortcut."); + hint.setTextSize(12); hint.setTextColor(0xFFAAAAAA); hint.setPadding(0, dp(8), 0, dp(4)); + root.addView(hint, new LinearLayout.LayoutParams(-1, -2)); + + AlertDialog dialog = new AlertDialog.Builder(this) + .setTitle("Custom branding — unlocked") + .setMessage("No payment is required. The custom name is applied to activity titles and the uploaded icon is available to your private home-screen shortcut.") + .setView(root) + .setNegativeButton("Cancel", null) + .setNeutralButton("Home shortcut", null) + .setPositiveButton("Save", (d, w) -> saveBranding(name)) + .create(); + dialog.setOnShowListener(d -> dialog.getButton(AlertDialog.BUTTON_NEUTRAL).setOnClickListener(v -> { + if (!BrandingManager.hasCustomIcon(this)) { + android.widget.Toast.makeText(this, "Choose an icon first.", android.widget.Toast.LENGTH_SHORT).show(); + return; + } + if (BrandingManager.publishHomeShortcut(this)) { + android.widget.Toast.makeText(this, "Custom home-screen shortcut published.", android.widget.Toast.LENGTH_SHORT).show(); + } else { + android.widget.Toast.makeText(this, "The launcher does not accept dynamic shortcuts on this device.", android.widget.Toast.LENGTH_LONG).show(); + } + })); + dialog.show(); + } + + private void saveBranding(EditText name) { + if (BrandingManager.save(this, name.getText().toString(), pendingBrandingUri)) { + android.widget.Toast.makeText(this, "Custom branding saved.", android.widget.Toast.LENGTH_SHORT).show(); + } else { + android.widget.Toast.makeText(this, "Could not save the selected icon.", android.widget.Toast.LENGTH_LONG).show(); + } + } + + private void showFadDriveDialog() { + LinearLayout root = new LinearLayout(this); + root.setOrientation(LinearLayout.VERTICAL); root.setPadding(dp(24), dp(8), dp(24), dp(8)); + + EditText endpoint = new EditText(this); + endpoint.setHint("WebDAV server URL (https://…)"); endpoint.setSingleLine(true); + endpoint.setText(getPrefs().getString(FadDriveSyncManager.PREF_ENDPOINT, "")); + root.addView(endpoint, new LinearLayout.LayoutParams(-1, -2)); + + EditText username = new EditText(this); + username.setHint("Username"); username.setSingleLine(true); + username.setText(getPrefs().getString(FadDriveSyncManager.PREF_USERNAME, "")); + root.addView(username, new LinearLayout.LayoutParams(-1, -2)); + + EditText password = new EditText(this); + password.setHint("Password (not stored)"); password.setSingleLine(true); + password.setInputType(android.text.InputType.TYPE_CLASS_TEXT | android.text.InputType.TYPE_TEXT_VARIATION_PASSWORD); + root.addView(password, new LinearLayout.LayoutParams(-1, -2)); + + TextView help = new TextView(this); + help.setText("FadDrive uses standard WebDAV. Endpoint and username are stored locally; the password stays in memory for the current sync."); + help.setTextSize(12); help.setTextColor(0xFFAAAAAA); help.setPadding(0, dp(10), 0, dp(4)); + root.addView(help, new LinearLayout.LayoutParams(-1, -2)); + + AlertDialog dialog = new AlertDialog.Builder(this).setTitle("FadDrive cloud sync — unlocked").setView(root) + .setNegativeButton("Cancel", null).setNeutralButton("Test connection", null).setPositiveButton("Sync videos", null).create(); + dialog.setOnShowListener(d -> { + dialog.getButton(AlertDialog.BUTTON_NEUTRAL).setOnClickListener(v -> { + String url = endpoint.getText().toString().trim(), user = username.getText().toString().trim(), pass = password.getText().toString(); + saveDrivePrefs(url, user); + FadDriveSyncManager.testConnection(url, user, pass, new FadDriveSyncManager.Callback() { + @Override public void onProgress(String message, int completed, int total) { runOnUiThread(() -> help.setText(message)); } + @Override public void onComplete(String message) { runOnUiThread(() -> help.setText(message)); } + }); + }); + dialog.getButton(AlertDialog.BUTTON_POSITIVE).setOnClickListener(v -> { + String url = endpoint.getText().toString().trim(), user = username.getText().toString().trim(), pass = password.getText().toString(); + saveDrivePrefs(url, user); + FadDriveSyncManager.syncAllVideos(FadCamProActivity.this, url, user, pass, new FadDriveSyncManager.Callback() { + @Override public void onProgress(String message, int completed, int total) { runOnUiThread(() -> help.setText(message)); } + @Override public void onComplete(String message) { runOnUiThread(() -> { help.setText(message); android.widget.Toast.makeText(FadCamProActivity.this, message, android.widget.Toast.LENGTH_LONG).show(); }); } + }); + }); }); + dialog.show(); } - /** - * Start all animations for the activity. - */ - private void startAnimations() { - // ShimmerButton handles its own animation internally - - // Fade in feature rows sequentially - animateFeatureRows(); + private void saveDrivePrefs(String endpoint, String username) { + getPrefs().edit().putString(FadDriveSyncManager.PREF_ENDPOINT, endpoint).putString(FadDriveSyncManager.PREF_USERNAME, username).apply(); } - /** - * Animate feature rows with a staggered fade-in effect. - */ - private void animateFeatureRows() { - LinearLayout featuresContainer = findViewById(R.id.featuresContainer); - if (featuresContainer == null) return; + private SharedPreferences getPrefs() { return getSharedPreferences(PREFS, MODE_PRIVATE); } + private int dp(int value) { return Math.round(value * getResources().getDisplayMetrics().density); } + private void startAnimations() { for (int i = 0; i < featuresContainer.getChildCount(); i++) { - View child = featuresContainer.getChildAt(i); - child.setAlpha(0f); - child.setTranslationY(20f); - - child.animate() - .alpha(1f) - .translationY(0f) - .setDuration(350) - .setStartDelay(200 + (i * 80L)) - .setInterpolator(new AccelerateDecelerateInterpolator()) - .start(); + View child = featuresContainer.getChildAt(i); child.setAlpha(0f); child.setTranslationY(20f); + child.animate().alpha(1f).translationY(0f).setDuration(350).setStartDelay(200 + i * 80L).start(); } - getOnBackPressedDispatcher().addCallback(this, new OnBackPressedCallback(true) { - @Override - public void handleOnBackPressed() { - overridePendingTransition(R.anim.fade_in, R.anim.fade_out); - setEnabled(false); - getOnBackPressedDispatcher().onBackPressed(); - } + @Override public void handleOnBackPressed() { setEnabled(false); getOnBackPressedDispatcher().onBackPressed(); } }); } } diff --git a/app/src/main/java/com/fadcam/ui/FadDriveSyncManager.java b/app/src/main/java/com/fadcam/ui/FadDriveSyncManager.java new file mode 100644 index 000000000..682d7b180 --- /dev/null +++ b/app/src/main/java/com/fadcam/ui/FadDriveSyncManager.java @@ -0,0 +1,203 @@ +package com.fadcam.ui; + +import android.content.ContentResolver; +import android.content.Context; +import android.database.Cursor; +import android.net.Uri; +import android.provider.MediaStore; + +import androidx.annotation.NonNull; + +import java.io.IOException; +import java.io.InputStream; +import java.net.URI; +import java.net.URISyntaxException; +import java.net.URLEncoder; +import java.nio.charset.StandardCharsets; +import java.util.ArrayList; +import java.util.List; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.Executors; + +import okhttp3.Credentials; +import okhttp3.MediaType; +import okhttp3.OkHttpClient; +import okhttp3.Request; +import okhttp3.RequestBody; +import okhttp3.Response; + +/** Free FadDrive sync over standard WebDAV. Passwords are never persisted. */ +public final class FadDriveSyncManager { + public static final String PREF_ENDPOINT = "faddrive_endpoint"; + public static final String PREF_USERNAME = "faddrive_username"; + public static final String REMOTE_FOLDER = "FadCam/"; + private static final long MAX_UPLOAD_BYTES = 4L * 1024L * 1024L * 1024L; + + private static final ExecutorService EXECUTOR = Executors.newSingleThreadExecutor(); + private static final OkHttpClient CLIENT = new OkHttpClient.Builder() + .connectTimeout(20, java.util.concurrent.TimeUnit.SECONDS) + .writeTimeout(5, java.util.concurrent.TimeUnit.MINUTES) + .readTimeout(5, java.util.concurrent.TimeUnit.MINUTES) + .build(); + + public interface Callback { + void onProgress(String message, int completed, int total); + void onComplete(String message); + } + + private FadDriveSyncManager() {} + + public static void testConnection(@NonNull String endpoint, @NonNull String username, + @NonNull String password, @NonNull Callback callback) { + EXECUTOR.execute(() -> { + try { + URI base = validateEndpoint(endpoint); + Request.Builder builder = new Request.Builder().url(base.toString()).method("OPTIONS", null); + applyAuth(builder, username, password); + try (Response response = CLIENT.newCall(builder.build()).execute()) { + int code = response.code(); + if (code >= 200 && code < 400) callback.onComplete("FadDrive connection OK (HTTP " + code + ")."); + else if (code == 401 || code == 403) callback.onComplete("FadDrive authentication failed (HTTP " + code + ")."); + else callback.onComplete("FadDrive connection failed (HTTP " + code + ")."); + } + } catch (Exception e) { + callback.onComplete("FadDrive connection failed: " + safeMessage(e)); + } + }); + } + + public static void syncAllVideos(@NonNull Context context, @NonNull String endpoint, + @NonNull String username, @NonNull String password, + @NonNull Callback callback) { + Context app = context.getApplicationContext(); + EXECUTOR.execute(() -> { + List videos = queryVideos(app.getContentResolver()); + if (videos.isEmpty()) { + callback.onComplete("FadDrive: no videos found to sync."); + return; + } + try { + URI base = validateEndpoint(endpoint); + ensureFolder(base, username, password); + int completed = 0; + for (VideoItem item : videos) { + try { + upload(app.getContentResolver(), base, username, password, item); + completed++; + callback.onProgress("Synced " + completed + "/" + videos.size() + ": " + item.name, completed, videos.size()); + } catch (Exception e) { + callback.onProgress("Skipped " + item.name + ": " + safeMessage(e), completed, videos.size()); + } + } + callback.onComplete("FadDrive sync finished: " + completed + "/" + videos.size() + " videos uploaded."); + } catch (Exception e) { + callback.onComplete("FadDrive sync failed: " + safeMessage(e)); + } + }); + } + + /** Builds a deterministic WebDAV destination while preserving the endpoint's encoded base path. */ + static String buildRemoteUrl(String endpoint, String fileName) throws URISyntaxException { + URI base = validateEndpoint(endpoint); + String safeName = URLEncoder.encode(fileName == null ? "video.mp4" : fileName, StandardCharsets.UTF_8) + .replace("+", "%20"); + String basePath = base.getRawPath(); + if (basePath == null || basePath.isEmpty()) basePath = "/"; + if (!basePath.endsWith("/")) basePath += "/"; + + // safeName and basePath are already raw/percent-encoded path components. + // Do not pass them through a URI constructor that escapes '%' again. + String remotePath = basePath + REMOTE_FOLDER + safeName; + return base.getScheme() + "://" + base.getRawAuthority() + remotePath; + } + + /** Rejects malformed endpoints and credentials embedded in URLs. */ + static URI validateEndpoint(String endpoint) throws URISyntaxException { + String value = endpoint == null ? "" : endpoint.trim(); + if (value.isEmpty()) throw new IllegalArgumentException("WebDAV URL is required"); + URI uri = new URI(value); + String scheme = uri.getScheme(); + if (!("https".equalsIgnoreCase(scheme) || "http".equalsIgnoreCase(scheme))) throw new IllegalArgumentException("WebDAV URL must use http:// or https://"); + if (uri.getHost() == null) throw new IllegalArgumentException("WebDAV URL has no host"); + if (uri.getUserInfo() != null) throw new IllegalArgumentException("Put credentials in the username/password fields, not the URL"); + if (uri.getQuery() != null || uri.getFragment() != null) throw new IllegalArgumentException("WebDAV URL must not contain a query or fragment"); + return uri; + } + + private static void ensureFolder(URI base, String username, String password) throws Exception { + String path = base.getRawPath(); + if (path == null || path.isEmpty()) path = "/"; + if (!path.endsWith("/")) path += "/"; + String folderUrl = base.getScheme() + "://" + base.getRawAuthority() + path + REMOTE_FOLDER; + Request.Builder builder = new Request.Builder().url(folderUrl).method("MKCOL", null); + applyAuth(builder, username, password); + try (Response response = CLIENT.newCall(builder.build()).execute()) { + int code = response.code(); + if (!((code >= 200 && code < 300) || code == 301 || code == 302 || code == 405 || code == 409)) throw new IOException("Cannot create FadCam folder (HTTP " + code + ")"); + } + } + + private static void upload(ContentResolver resolver, URI base, String username, String password, VideoItem item) throws Exception { + if (item.size < 0 || item.size > MAX_UPLOAD_BYTES) throw new IOException("unsupported file size (maximum 4 GiB)"); + String url = buildRemoteUrl(base.toString(), item.name); + RequestBody body = new ContentResolverRequestBody(resolver, item.uri, item.mimeType, item.size); + Request.Builder builder = new Request.Builder().url(url).put(body); + applyAuth(builder, username, password); + try (Response response = CLIENT.newCall(builder.build()).execute()) { + if (!response.isSuccessful()) throw new IOException("HTTP " + response.code()); + } + } + + private static void applyAuth(Request.Builder builder, String username, String password) { + if (username != null && !username.trim().isEmpty()) builder.header("Authorization", Credentials.basic(username, password == null ? "" : password)); + } + + private static List queryVideos(ContentResolver resolver) { + List result = new ArrayList<>(); + String[] projection = {MediaStore.Video.Media._ID, MediaStore.Video.Media.DISPLAY_NAME, MediaStore.Video.Media.MIME_TYPE, MediaStore.Video.Media.SIZE, MediaStore.Video.Media.DATE_ADDED}; + String sort = MediaStore.Video.Media.DATE_ADDED + " DESC"; + try (Cursor cursor = resolver.query(MediaStore.Video.Media.EXTERNAL_CONTENT_URI, projection, null, null, sort)) { + if (cursor == null) return result; + int idCol = cursor.getColumnIndexOrThrow(MediaStore.Video.Media._ID); + int nameCol = cursor.getColumnIndexOrThrow(MediaStore.Video.Media.DISPLAY_NAME); + int mimeCol = cursor.getColumnIndexOrThrow(MediaStore.Video.Media.MIME_TYPE); + int sizeCol = cursor.getColumnIndexOrThrow(MediaStore.Video.Media.SIZE); + while (cursor.moveToNext()) { + long id = cursor.getLong(idCol); + String name = cursor.getString(nameCol); + String mime = cursor.getString(mimeCol); + long size = cursor.getLong(sizeCol); + if (name == null || name.trim().isEmpty()) name = "video-" + id + ".mp4"; + Uri uri = Uri.withAppendedPath(MediaStore.Video.Media.EXTERNAL_CONTENT_URI, Long.toString(id)); + result.add(new VideoItem(uri, name, mime == null ? "video/mp4" : mime, size)); + } + } catch (RuntimeException ignored) { } + return result; + } + + private static String safeMessage(Exception e) { + String m = e.getMessage(); + return m == null || m.trim().isEmpty() ? e.getClass().getSimpleName() : m; + } + + private static final class VideoItem { + final Uri uri; final String name; final String mimeType; final long size; + VideoItem(Uri uri, String name, String mimeType, long size) { this.uri = uri; this.name = name; this.mimeType = mimeType; this.size = size; } + } + + private static final class ContentResolverRequestBody extends RequestBody { + private final ContentResolver resolver; private final Uri uri; private final MediaType mediaType; private final long length; + ContentResolverRequestBody(ContentResolver resolver, Uri uri, String mimeType, long length) { + this.resolver = resolver; this.uri = uri; this.mediaType = MediaType.parse(mimeType == null ? "application/octet-stream" : mimeType); this.length = length; + } + @Override public MediaType contentType() { return mediaType; } + @Override public long contentLength() { return length; } + @Override public void writeTo(@NonNull okio.BufferedSink sink) throws IOException { + try (InputStream in = resolver.openInputStream(uri)) { + if (in == null) throw new IOException("Unable to open media file"); + byte[] buffer = new byte[64 * 1024]; int read; + while ((read = in.read(buffer)) != -1) sink.write(buffer, 0, read); + } + } + } +} diff --git a/app/src/main/java/com/fadcam/ui/HomeFragment.java b/app/src/main/java/com/fadcam/ui/HomeFragment.java index f1e92a913..f88239008 100644 --- a/app/src/main/java/com/fadcam/ui/HomeFragment.java +++ b/app/src/main/java/com/fadcam/ui/HomeFragment.java @@ -84,6 +84,7 @@ import com.fadcam.MainActivity; import com.fadcam.Constants; import com.fadcam.FLog; +import com.fadcam.production.DuetStudioFragment; import com.fadcam.R; import com.fadcam.RecordingControlIntents; import com.fadcam.RecordingState; @@ -4017,6 +4018,8 @@ public void onViewCreated( fragmentHelper = new HomeFragmentHelper(this); fragmentHelper.initializeComponents(view); + DuetStudioFragment.installEntryButton(quickActionsRow, this); + // Fragment result listeners for pickers FLog.d( TAG, diff --git a/app/src/main/java/com/fadcam/ui/HomeSidebarFragment.java b/app/src/main/java/com/fadcam/ui/HomeSidebarFragment.java index 22391648c..dd578733b 100644 --- a/app/src/main/java/com/fadcam/ui/HomeSidebarFragment.java +++ b/app/src/main/java/com/fadcam/ui/HomeSidebarFragment.java @@ -517,6 +517,12 @@ private void applyMiniAppVisibility(View view) { } private void showProfilesComingSoon() { + try { + OverlayNavUtil.show(requireActivity(), ProductionToolsFragment.newInstance("profiles"), "production_tool_profiles"); + return; + } catch (Exception e) { + FLog.w("HomeSidebar", "Failed to open Profiles", e); + } try { ArrayList items = new ArrayList<>(); items.add(new OptionItem("profiles", @@ -536,6 +542,21 @@ private void showProfilesComingSoon() { } public static void showMiniAppComingSoon(Fragment fragment, String appId) { + // PRODUCTION_TOOLS_WIRED_V1 + if ("compass".equals(appId) || "sound_meter".equals(appId) || + "sensor_dashboard".equals(appId) || "speedometer".equals(appId) || + "clinometer".equals(appId) || "pedometer".equals(appId) || + "metal_detector".equals(appId) || "parking_marker".equals(appId) || + "qr_generator".equals(appId)) { + try { + OverlayNavUtil.show(fragment.requireActivity(), + ProductionToolsFragment.newInstance(appId), + "production_tool_" + appId); + return; + } catch (Exception e) { + FLog.w("HomeSidebar", "Failed to open production tool: " + appId, e); + } + } try { String title = null; String desc = null; diff --git a/app/src/main/java/com/fadcam/ui/ProductionFeatureHubDialog.java b/app/src/main/java/com/fadcam/ui/ProductionFeatureHubDialog.java new file mode 100644 index 000000000..84dd475e1 --- /dev/null +++ b/app/src/main/java/com/fadcam/ui/ProductionFeatureHubDialog.java @@ -0,0 +1,591 @@ +package com.fadcam.ui; + +import android.Manifest; +import android.app.Activity; +import android.app.AlarmManager; +import android.app.Dialog; +import android.app.PendingIntent; +import android.content.Context; +import android.content.Intent; +import android.content.SharedPreferences; +import android.content.pm.PackageManager; +import android.graphics.Bitmap; +import android.graphics.Color; +import android.hardware.Sensor; +import android.hardware.SensorEvent; +import android.hardware.SensorEventListener; +import android.hardware.SensorManager; +import android.location.Location; +import android.location.LocationListener; +import android.location.LocationManager; +import android.media.AudioFormat; +import android.media.AudioRecord; +import android.media.MediaRecorder; +import android.net.Uri; +import android.os.Build; +import android.os.Bundle; +import android.os.Handler; +import android.os.Looper; +import android.provider.Settings; +import android.view.Gravity; +import android.view.Window; +import android.widget.Button; +import android.widget.DatePicker; +import android.widget.EditText; +import android.widget.ImageView; +import android.widget.LinearLayout; +import android.widget.ScrollView; +import android.widget.TextView; +import android.widget.TimePicker; + +import androidx.annotation.NonNull; +import androidx.core.app.ActivityCompat; + +import com.fadcam.Constants; +import com.fadcam.RecordingStartActivity; +import com.fadcam.RecordingStopActivity; +import com.google.zxing.BarcodeFormat; +import com.google.zxing.EncodeHintType; +import com.google.zxing.MultiFormatWriter; +import com.google.zxing.common.BitMatrix; +import com.google.zxing.qrcode.decoder.ErrorCorrectionLevel; + +import org.json.JSONArray; +import org.json.JSONObject; + +import java.util.Calendar; +import java.util.EnumMap; +import java.util.HashSet; +import java.util.Locale; +import java.util.Map; +import java.util.Set; + +/** Real implementations for every production tool formerly marked Coming Soon. */ +public final class ProductionFeatureHubDialog extends Dialog implements SensorEventListener, LocationListener { + private static final int REQ_LOCATION = 8701; + private static final int REQ_AUDIO = 8702; + private static final int REQ_ACTIVITY = 8703; + private static final String PROFILE_PREFS = "production_profiles"; + private static final String TOOLS_PREFS = "production_tools"; + + private final String feature; + private final Handler handler = new Handler(Looper.getMainLooper()); + private SensorManager sensorManager; + private LocationManager locationManager; + private TextView value; + private TextView status; + private float[] accel; + private float[] magnetic; + private float[] gyro; + private float stepBase = -1f; + private float steps = -1f; + private AudioRecord audio; + private Thread audioThread; + private volatile boolean audioRunning; + private boolean audioTrigger; + private boolean thermalTripped; + private boolean parkingPending; + + private ProductionFeatureHubDialog(@NonNull Activity host, @NonNull String feature) { + super(host); + this.feature = feature; + } + + public static void show(@NonNull Activity host, @NonNull String feature) { + new ProductionFeatureHubDialog(host, feature).show(); + } + + @Override + protected void onCreate(Bundle state) { + super.onCreate(state); + Window w = getWindow(); + if (w != null) w.setBackgroundDrawableResource(android.R.color.transparent); + ScrollView scroll = new ScrollView(getContext()); + scroll.setBackgroundColor(Color.BLACK); + LinearLayout root = new LinearLayout(getContext()); + root.setOrientation(LinearLayout.VERTICAL); + root.setPadding(dp(18), dp(18), dp(18), dp(24)); + scroll.addView(root); + root.addView(text(title(), 24, Color.WHITE)); + status = text("Ready", 14, 0xFFBDBDBD); + root.addView(status); + value = text("", 30, 0xFFFF5252); + value.setGravity(Gravity.CENTER); + root.addView(value, lp(-1, -2)); + LinearLayout content = new LinearLayout(getContext()); + content.setOrientation(LinearLayout.VERTICAL); + root.addView(content); + Button close = button("Close"); + root.addView(close); + close.setOnClickListener(v -> dismiss()); + setContentView(scroll); + open(content); + } + + private String title() { + if (feature.isEmpty()) return "Production tool"; + String s = feature.replace('_', ' '); + return Character.toUpperCase(s.charAt(0)) + s.substring(1); + } + + private void open(LinearLayout c) { + switch (feature) { + case "thermal": thermal(c); break; + case "audio": audioVision(c); break; + case "schedule": schedule(c); break; + case "profiles": profiles(c); break; + case "sound": soundMeter(c); break; + case "sensors": sensors(c); break; + case "speed": speed(c); break; + case "clinometer": clinometer(c); break; + case "compass": compass(c); break; + case "pedometer": pedometer(c); break; + case "metal": metal(c); break; + case "parking": parking(c); break; + case "qr": qr(c); break; + default: status.setText("Unknown production feature"); + } + } + + private void thermal(LinearLayout c) { + status.setText("Monitors battery temperature and safely stops an active recording at the threshold."); + EditText limit = edit("Emergency threshold °C", "45"); + c.addView(limit); + Button enable = button("Enable Thermal Guardian"); + c.addView(enable); + enable.setOnClickListener(v -> { + thermalTripped = false; + handler.removeCallbacksAndMessages(null); + status.setText("Thermal Guardian active"); + Runnable monitor = new Runnable() { + @Override public void run() { + Intent battery = getContext().registerReceiver(null, + new android.content.IntentFilter(Intent.ACTION_BATTERY_CHANGED)); + int raw = battery == null ? -1 : battery.getIntExtra( + android.os.BatteryManager.EXTRA_TEMPERATURE, -1); + if (raw > 0) { + float temp = raw / 10f; + value.setText(String.format(Locale.US, "%.1f °C", temp)); + if (!thermalTripped && temp >= parseFloat(limit.getText().toString(), 45f) + && recording()) { + thermalTripped = true; + try { + getContext().startActivity(new Intent(getContext(), RecordingStopActivity.class)); + status.setText("Recording stopped: thermal threshold reached"); + } catch (Exception e) { + status.setText("Thermal limit reached; recording could not be stopped"); + } + } + } else { + value.setText("Temperature unavailable"); + } + if (!thermalTripped) handler.postDelayed(this, 2000L); + } + }; + handler.post(monitor); + }); + } + + private void audioVision(LinearLayout c) { + status.setText("Starts recording when microphone level reaches the selected relative threshold."); + EditText threshold = edit("Trigger level (relative dB)", "65"); + c.addView(threshold); + Button arm = button("Arm Audio Vision"); + c.addView(arm); + arm.setOnClickListener(v -> { + audioTrigger = true; + startAudio(parseFloat(threshold.getText().toString(), 65f)); + }); + } + + private void soundMeter(LinearLayout c) { + status.setText("Live relative microphone level; this is not calibrated dB SPL."); + Button start = button("Start Sound Meter"); + c.addView(start); + start.setOnClickListener(v -> { + audioTrigger = false; + startAudio(0f); + }); + } + + private void startAudio(float threshold) { + if (ActivityCompat.checkSelfPermission(getContext(), Manifest.permission.RECORD_AUDIO) + != PackageManager.PERMISSION_GRANTED) { + status.setText("Microphone permission required; allow it and tap again"); + ActivityCompat.requestPermissions((Activity) getContext(), + new String[]{Manifest.permission.RECORD_AUDIO}, REQ_AUDIO); + return; + } + stopAudio(); + int min = AudioRecord.getMinBufferSize(44100, AudioFormat.CHANNEL_IN_MONO, + AudioFormat.ENCODING_PCM_16BIT); + if (min <= 0) { + status.setText("Microphone unavailable"); + return; + } + try { + audio = new AudioRecord(MediaRecorder.AudioSource.MIC, 44100, + AudioFormat.CHANNEL_IN_MONO, AudioFormat.ENCODING_PCM_16BIT, + Math.max(min * 2, 4096)); + audio.startRecording(); + } catch (Exception e) { + stopAudio(); + status.setText("Microphone unavailable"); + return; + } + audioRunning = true; + final float trigger = threshold; + audioThread = new Thread(() -> { + short[] buffer = new short[min]; + boolean fired = false; + while (audioRunning && audio != null) { + int n = audio.read(buffer, 0, buffer.length); + if (n <= 0) continue; + double sum = 0d; + for (int i = 0; i < n; i++) sum += buffer[i] * (double) buffer[i]; + double rms = Math.sqrt(sum / n); + double db = rms <= 1d ? 0d : 20d * Math.log10(rms / 32768d) + 90d; + handler.post(() -> value.setText(String.format(Locale.US, "%.1f dB", db))); + if (audioTrigger && !fired && db >= trigger) { + fired = true; + handler.post(this::startRecording); + } + } + }, "FadCam-FeatureAudio"); + audioThread.start(); + status.setText(audioTrigger ? "Audio Vision armed" : "Sound Meter running"); + } + + private void startRecording() { + audioTrigger = false; + if (recording()) { + status.setText("Recording is already running"); + return; + } + try { + getContext().startActivity(new Intent(getContext(), RecordingStartActivity.class)); + status.setText("Recording started by Audio Vision"); + } catch (Exception e) { + status.setText("Could not start recording"); + } + } + + private void schedule(LinearLayout c) { + status.setText("One-time future start and stop schedule using Android alarms."); + DatePicker date = new DatePicker(getContext()); + c.addView(date, lp(-1, dp(175))); + TimePicker from = new TimePicker(getContext()); + from.setIs24HourView(true); + c.addView(from, lp(-1, dp(70))); + TimePicker to = new TimePicker(getContext()); + to.setIs24HourView(true); + c.addView(to, lp(-1, dp(70))); + Button schedule = button("Schedule Recording"); + c.addView(schedule); + schedule.setOnClickListener(v -> { + Calendar start = Calendar.getInstance(); + start.set(date.getYear(), date.getMonth(), date.getDayOfMonth(), + from.getHour(), from.getMinute(), 0); + start.set(Calendar.MILLISECOND, 0); + Calendar stop = (Calendar) start.clone(); + stop.set(Calendar.HOUR_OF_DAY, to.getHour()); + stop.set(Calendar.MINUTE, to.getMinute()); + stop.set(Calendar.SECOND, 0); + stop.set(Calendar.MILLISECOND, 0); + if (stop.getTimeInMillis() <= start.getTimeInMillis()) stop.add(Calendar.DAY_OF_YEAR, 1); + if (start.getTimeInMillis() <= System.currentTimeMillis()) { + status.setText("Choose a future start time"); + return; + } + if (!canScheduleExactAlarms()) { + status.setText("Allow Alarms & reminders, then tap Schedule again"); + requestExactAlarmPermission(); + return; + } + boolean startOk = scheduleAlarm(start.getTimeInMillis(), true); + boolean stopOk = scheduleAlarm(stop.getTimeInMillis(), false); + status.setText(startOk && stopOk ? "Start and stop alarms scheduled" : "Could not schedule recording"); + }); + } + + private boolean canScheduleExactAlarms() { + if (Build.VERSION.SDK_INT < Build.VERSION_CODES.S) return true; + AlarmManager manager = (AlarmManager) getContext().getSystemService(Context.ALARM_SERVICE); + return manager != null && manager.canScheduleExactAlarms(); + } + + private void requestExactAlarmPermission() { + if (Build.VERSION.SDK_INT < Build.VERSION_CODES.S) return; + try { + Intent intent = new Intent(Settings.ACTION_REQUEST_SCHEDULE_EXACT_ALARM); + intent.setData(Uri.parse("package:" + getContext().getPackageName())); + getContext().startActivity(intent); + } catch (Exception e) { + status.setText("Open Settings > Special app access > Alarms & reminders"); + } + } + + private boolean scheduleAlarm(long when, boolean start) { + AlarmManager manager = (AlarmManager) getContext().getSystemService(Context.ALARM_SERVICE); + if (manager == null) return false; + Intent intent = new Intent(getContext(), start ? RecordingStartActivity.class : RecordingStopActivity.class); + int requestCode = (int) ((when / 60000L) % 100000L) + (start ? 100000 : 200000); + PendingIntent pending = PendingIntent.getActivity(getContext(), requestCode, intent, + PendingIntent.FLAG_UPDATE_CURRENT | PendingIntent.FLAG_IMMUTABLE); + try { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) { + manager.setExactAndAllowWhileIdle(AlarmManager.RTC_WAKEUP, when, pending); + } else if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.KITKAT) { + manager.setExact(AlarmManager.RTC_WAKEUP, when, pending); + } else { + manager.set(AlarmManager.RTC_WAKEUP, when, pending); + } + return true; + } catch (SecurityException e) { + return false; + } + } + + private void profiles(LinearLayout c) { + status.setText("Save and restore typed recording preferences locally."); + EditText name = edit("Profile name", "studio"); + c.addView(name); + Button save = button("Save Current Profile"); + Button load = button("Load Profile"); + c.addView(save); + c.addView(load); + save.setOnClickListener(v -> { + String n = name.getText().toString().trim(); + if (n.isEmpty()) { status.setText("Enter a profile name"); return; } + status.setText(saveProfile(n) ? "Saved " + n : "Could not save profile"); + }); + load.setOnClickListener(v -> { + String n = name.getText().toString().trim(); + if (n.isEmpty()) { status.setText("Enter a profile name"); return; } + status.setText(loadProfile(n) ? "Loaded " + n : "Profile not found or invalid"); + }); + } + + private boolean saveProfile(String name) { + try { + SharedPreferences source = getContext().getSharedPreferences(Constants.PREFS_NAME, Context.MODE_PRIVATE); + JSONObject root = new JSONObject(); + for (Map.Entry entry : source.getAll().entrySet()) { + String key = entry.getKey(); + String lower = key.toLowerCase(Locale.US); + if (lower.contains("recording_in_progress") || lower.contains("session") || lower.contains("lock_unlocked")) continue; + Object o = entry.getValue(); + JSONObject item = new JSONObject(); + if (o instanceof Boolean) { item.put("type", "boolean"); item.put("value", o); } + else if (o instanceof Integer) { item.put("type", "int"); item.put("value", o); } + else if (o instanceof Long) { item.put("type", "long"); item.put("value", o); } + else if (o instanceof Float) { item.put("type", "float"); item.put("value", o); } + else if (o instanceof String) { item.put("type", "string"); item.put("value", o); } + else if (o instanceof Set) { + JSONArray array = new JSONArray(); + for (Object x : (Set) o) array.put(String.valueOf(x)); + item.put("type", "set"); item.put("value", array); + } else continue; + root.put(key, item); + } + getContext().getSharedPreferences(PROFILE_PREFS, Context.MODE_PRIVATE).edit().putString(name, root.toString()).apply(); + return true; + } catch (Exception e) { return false; } + } + + private boolean loadProfile(String name) { + String json = getContext().getSharedPreferences(PROFILE_PREFS, Context.MODE_PRIVATE).getString(name, null); + if (json == null) return false; + try { + JSONObject root = new JSONObject(json); + SharedPreferences.Editor editor = getContext().getSharedPreferences(Constants.PREFS_NAME, Context.MODE_PRIVATE).edit(); + java.util.Iterator keys = root.keys(); + while (keys.hasNext()) { + String key = keys.next(); + JSONObject item = root.getJSONObject(key); + switch (item.getString("type")) { + case "boolean": editor.putBoolean(key, item.getBoolean("value")); break; + case "int": editor.putInt(key, item.getInt("value")); break; + case "long": editor.putLong(key, item.getLong("value")); break; + case "float": editor.putFloat(key, (float) item.getDouble("value")); break; + case "string": editor.putString(key, item.getString("value")); break; + case "set": + JSONArray a = item.getJSONArray("value"); + Set values = new HashSet<>(); + for (int i = 0; i < a.length(); i++) values.add(a.getString(i)); + editor.putStringSet(key, values); + break; + default: break; + } + } + editor.apply(); + return true; + } catch (Exception e) { return false; } + } + + private void sensors(LinearLayout c) { + status.setText("Live accelerometer, gyroscope and magnetometer data."); + register(Sensor.TYPE_ACCELEROMETER); + register(Sensor.TYPE_GYROSCOPE); + register(Sensor.TYPE_MAGNETIC_FIELD); + value.setText("Waiting for sensor data…"); + value.setTextSize(14); + } + + private void speed(LinearLayout c) { + status.setText("GPS speed"); + if (!requestLocation()) return; + try { + locationManager.requestLocationUpdates(LocationManager.GPS_PROVIDER, 500L, 0f, this); + if (locationManager.isProviderEnabled(LocationManager.NETWORK_PROVIDER)) + locationManager.requestLocationUpdates(LocationManager.NETWORK_PROVIDER, 1000L, 1f, this); + } catch (SecurityException e) { status.setText("Location permission unavailable"); } + } + + private void clinometer(LinearLayout c) { status.setText("Accelerometer pitch and roll"); register(Sensor.TYPE_ACCELEROMETER); } + private void compass(LinearLayout c) { status.setText("Magnetic heading"); register(Sensor.TYPE_ACCELEROMETER); register(Sensor.TYPE_MAGNETIC_FIELD); } + + private void pedometer(LinearLayout c) { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q + && ActivityCompat.checkSelfPermission(getContext(), Manifest.permission.ACTIVITY_RECOGNITION) != PackageManager.PERMISSION_GRANTED) { + status.setText("Activity recognition permission required; allow it and tap again"); + ActivityCompat.requestPermissions((Activity) getContext(), new String[]{Manifest.permission.ACTIVITY_RECOGNITION}, REQ_ACTIVITY); + return; + } + status.setText("Hardware step counter"); + register(Sensor.TYPE_STEP_COUNTER); + } + + private void metal(LinearLayout c) { status.setText("Magnetic field strength"); register(Sensor.TYPE_MAGNETIC_FIELD); } + + private void parking(LinearLayout c) { + status.setText("Save a parking position and open it in a map app."); + Button save = button("Save Current Location"); + Button show = button("Show Saved Marker"); + c.addView(save); c.addView(show); + save.setOnClickListener(v -> { + if (!requestLocation()) return; + try { + Location l = locationManager.getLastKnownLocation(LocationManager.GPS_PROVIDER); + if (l == null && locationManager.isProviderEnabled(LocationManager.NETWORK_PROVIDER)) l = locationManager.getLastKnownLocation(LocationManager.NETWORK_PROVIDER); + if (l != null) saveParking(l); + else { parkingPending = true; status.setText("Waiting for GPS fix…"); locationManager.requestLocationUpdates(LocationManager.GPS_PROVIDER, 500L, 0f, this); } + } catch (SecurityException e) { status.setText("Location permission unavailable"); } + }); + show.setOnClickListener(v -> { + SharedPreferences p = getContext().getSharedPreferences(TOOLS_PREFS, Context.MODE_PRIVATE); + if (!p.contains("parking_lat") || !p.contains("parking_lon")) { status.setText("No parking marker saved"); return; } + double lat = Double.longBitsToDouble(p.getLong("parking_lat", 0)); + double lon = Double.longBitsToDouble(p.getLong("parking_lon", 0)); + try { getContext().startActivity(new Intent(Intent.ACTION_VIEW, Uri.parse(String.format(Locale.US, "geo:%f,%f?q=%f,%f", lat, lon, lat, lon)))); } + catch (Exception e) { status.setText(String.format(Locale.US, "Saved %.5f, %.5f", lat, lon)); } + }); + } + + private void saveParking(Location l) { + parkingPending = false; + getContext().getSharedPreferences(TOOLS_PREFS, Context.MODE_PRIVATE).edit() + .putLong("parking_lat", Double.doubleToLongBits(l.getLatitude())) + .putLong("parking_lon", Double.doubleToLongBits(l.getLongitude())) + .putFloat("parking_accuracy", l.getAccuracy()).apply(); + status.setText(String.format(Locale.US, "Parking saved (±%.0f m)", l.getAccuracy())); + } + + private void qr(LinearLayout c) { + status.setText("Offline QR generator"); + EditText input = edit("Text / URL / contact", ""); c.addView(input); + Button generate = button("Generate QR"); c.addView(generate); + ImageView image = new ImageView(getContext()); image.setAdjustViewBounds(true); c.addView(image, lp(-1, dp(320))); + generate.setOnClickListener(v -> { + try { + String content = input.getText().toString().trim(); + if (content.isEmpty()) { status.setText("Enter content first"); return; } + Map hints = new EnumMap<>(EncodeHintType.class); + hints.put(EncodeHintType.ERROR_CORRECTION, ErrorCorrectionLevel.M); + hints.put(EncodeHintType.MARGIN, 2); + BitMatrix matrix = new MultiFormatWriter().encode(content, BarcodeFormat.QR_CODE, 700, 700, hints); + Bitmap bitmap = Bitmap.createBitmap(700, 700, Bitmap.Config.ARGB_8888); + for (int x = 0; x < 700; x++) for (int y = 0; y < 700; y++) bitmap.setPixel(x, y, matrix.get(x, y) ? Color.BLACK : Color.WHITE); + image.setImageBitmap(bitmap); status.setText("QR generated locally"); + } catch (Exception e) { status.setText("Could not generate QR"); } + }); + } + + private boolean requestLocation() { + if (ActivityCompat.checkSelfPermission(getContext(), Manifest.permission.ACCESS_FINE_LOCATION) != PackageManager.PERMISSION_GRANTED + && ActivityCompat.checkSelfPermission(getContext(), Manifest.permission.ACCESS_COARSE_LOCATION) != PackageManager.PERMISSION_GRANTED) { + ActivityCompat.requestPermissions((Activity) getContext(), new String[]{Manifest.permission.ACCESS_FINE_LOCATION, Manifest.permission.ACCESS_COARSE_LOCATION}, REQ_LOCATION); + return false; + } + if (locationManager == null) locationManager = (LocationManager) getContext().getSystemService(Context.LOCATION_SERVICE); + return locationManager != null; + } + + private void register(int type) { + SensorManager sm = getSensors(); + if (sm == null) { status.setText("Sensors unavailable"); return; } + Sensor s = sm.getDefaultSensor(type); + if (s == null) { status.setText("Required sensor unavailable"); return; } + sm.registerListener(this, s, SensorManager.SENSOR_DELAY_GAME); + } + + private SensorManager getSensors() { + if (sensorManager == null) sensorManager = (SensorManager) getContext().getSystemService(Context.SENSOR_SERVICE); + return sensorManager; + } + + private boolean recording() { + return getContext().getSharedPreferences(Constants.PREFS_NAME, Context.MODE_PRIVATE) + .getBoolean(Constants.PREF_IS_RECORDING_IN_PROGRESS, false); + } + + @Override public void onSensorChanged(SensorEvent e) { + if (e.sensor.getType() == Sensor.TYPE_ACCELEROMETER) accel = e.values.clone(); + else if (e.sensor.getType() == Sensor.TYPE_MAGNETIC_FIELD) magnetic = e.values.clone(); + else if (e.sensor.getType() == Sensor.TYPE_GYROSCOPE) gyro = e.values.clone(); + else if (e.sensor.getType() == Sensor.TYPE_STEP_COUNTER) { if (stepBase < 0f) stepBase = e.values[0]; steps = Math.max(0f, e.values[0] - stepBase); } + + if ("metal".equals(feature) && magnetic != null) { + float m = (float) Math.sqrt(magnetic[0] * magnetic[0] + magnetic[1] * magnetic[1] + magnetic[2] * magnetic[2]); + value.setText(String.format(Locale.US, "%.1f µT", m)); + } else if ("clinometer".equals(feature) && accel != null) { + double pitch = Math.toDegrees(Math.atan2(-accel[0], Math.hypot(accel[1], accel[2]))); + double roll = Math.toDegrees(Math.atan2(accel[1], accel[2])); + value.setText(String.format(Locale.US, "Pitch %+.1f°\nRoll %+.1f°", pitch, roll)); + } else if ("compass".equals(feature) && accel != null && magnetic != null) { + float[] r = new float[9], i = new float[9]; + if (SensorManager.getRotationMatrix(r, i, accel, magnetic)) { + float[] o = new float[3]; SensorManager.getOrientation(r, o); + double d = Math.toDegrees(o[0]); if (d < 0) d += 360; + value.setText(String.format(Locale.US, "%.0f°", d)); + } + } else if ("sensors".equals(feature)) { + value.setText("ACC " + vector(accel) + "\nGYRO " + vector(gyro) + "\nMAG " + vector(magnetic)); + value.setTextSize(14); + } else if ("pedometer".equals(feature) && steps >= 0f) { + value.setText(String.format(Locale.US, "%.0f steps", steps)); + } + } + + private String vector(float[] v) { return v == null ? "n/a" : String.format(Locale.US, "%+.2f,%+.2f,%+.2f", v[0], v[1], v[2]); } + @Override public void onAccuracyChanged(Sensor sensor, int accuracy) { } + @Override public void onLocationChanged(@NonNull Location location) { + if ("speed".equals(feature)) { + value.setText(String.format(Locale.US, "%.1f km/h", location.hasSpeed() ? location.getSpeed() * 3.6f : 0f)); + status.setText(String.format(Locale.US, "GPS accuracy %.0f m", location.getAccuracy())); + } + if (parkingPending) saveParking(location); + } + @Override public void onProviderEnabled(@NonNull String provider) { } + @Override public void onProviderDisabled(@NonNull String provider) { if ("speed".equals(feature)) status.setText("GPS provider disabled"); } + @Override public void onStatusChanged(String provider, int statusCode, Bundle extras) { } + + private EditText edit(String hint, String valueText) { EditText e = new EditText(getContext()); e.setHint(hint); e.setText(valueText); e.setTextColor(Color.WHITE); e.setHintTextColor(Color.GRAY); return e; } + private TextView text(String s, int size, int color) { TextView t = new TextView(getContext()); t.setText(s); t.setTextSize(size); t.setTextColor(color); t.setPadding(0, dp(8), 0, dp(8)); return t; } + private Button button(String label) { Button b = new Button(getContext()); b.setText(label); b.setAllCaps(false); b.setTextColor(Color.WHITE); return b; } + private LinearLayout.LayoutParams lp(int width, int height) { return new LinearLayout.LayoutParams(width, height); } + private int dp(int value) { return Math.round(value * getContext().getResources().getDisplayMetrics().density); } + private float parseFloat(String raw, float fallback) { try { return Float.parseFloat(raw.trim()); } catch (Exception e) { return fallback; } } + private void stopAudio() { audioRunning = false; try { if (audio != null) audio.stop(); } catch (Exception ignored) { } try { if (audio != null) audio.release(); } catch (Exception ignored) { } audio = null; if (audioThread != null) audioThread.interrupt(); audioThread = null; } + @Override public void dismiss() { stopAudio(); if (sensorManager != null) sensorManager.unregisterListener(this); if (locationManager != null) try { locationManager.removeUpdates(this); } catch (SecurityException ignored) { } handler.removeCallbacksAndMessages(null); super.dismiss(); } +} diff --git a/app/src/main/java/com/fadcam/ui/ProductionFeatureUnlocker.java b/app/src/main/java/com/fadcam/ui/ProductionFeatureUnlocker.java new file mode 100644 index 000000000..f57ed25b9 --- /dev/null +++ b/app/src/main/java/com/fadcam/ui/ProductionFeatureUnlocker.java @@ -0,0 +1,85 @@ +package com.fadcam.ui; + +import android.app.Activity; +import android.content.Context; +import android.view.View; +import android.view.ViewGroup; +import android.widget.TextView; + +import java.util.Locale; + +/** Removes dead Coming Soon affordances and wires each tool to the functional production hub. */ +public final class ProductionFeatureUnlocker { + private static final int WIRED_TAG = 0x7f0f0a71; + private ProductionFeatureUnlocker() {} + + public static void install(View root) { + if (root == null) return; + walk(root, root.getContext()); + } + + private static void walk(View view, Context context) { + if (view instanceof TextView) { + TextView tv = (TextView) view; + String text = tv.getText() == null ? "" : tv.getText().toString().trim(); + String lower = text.toLowerCase(Locale.US); + + if (lower.equals("time left")) tv.setText("Storage time available"); + + String key = keyFor(text); + if (key != null && tv.getTag(WIRED_TAG) == null) { + tv.setTag(WIRED_TAG, Boolean.TRUE); + View card = nearestCard(tv); + View.OnClickListener listener = v -> { + if (context instanceof Activity) { + ProductionFeatureHubDialog.show((Activity) context, key); + } + }; + if (card != null) { + card.setClickable(true); + card.setFocusable(true); + card.setOnClickListener(listener); + card.setOnLongClickListener(v -> { listener.onClick(v); return true; }); + } else { + tv.setOnClickListener(listener); + } + } + + // These labels are UI gates, not capability checks. The actual tools are now available. + if (lower.equals("coming soon") || lower.equals("soon")) tv.setVisibility(View.GONE); + } + if (view instanceof ViewGroup) { + ViewGroup group = (ViewGroup) view; + for (int i = 0; i < group.getChildCount(); i++) walk(group.getChildAt(i), context); + } + } + + private static View nearestCard(View start) { + View current = start; + for (int depth = 0; depth < 5 && current.getParent() instanceof View; depth++) { + current = (View) current.getParent(); + if (current instanceof androidx.cardview.widget.CardView + || current instanceof com.google.android.material.card.MaterialCardView) return current; + if (current instanceof ViewGroup && current.isClickable()) return current; + } + return start.getParent() instanceof View ? (View) start.getParent() : start; + } + + private static String keyFor(String raw) { + String s = raw.toLowerCase(Locale.US); + if (s.contains("thermal guardian")) return "thermal"; + if (s.contains("audio vision")) return "audio"; + if (s.contains("scheduled recording")) return "schedule"; + if (s.equals("profiles") || s.startsWith("profiles ")) return "profiles"; + if (s.contains("sound meter")) return "sound"; + if (s.contains("sensor dashboard")) return "sensors"; + if (s.contains("speedometer")) return "speed"; + if (s.contains("clinometer")) return "clinometer"; + if (s.equals("compass")) return "compass"; + if (s.contains("pedometer")) return "pedometer"; + if (s.contains("metal detector")) return "metal"; + if (s.contains("parking marker")) return "parking"; + if (s.contains("qr generator")) return "qr"; + return null; + } +} diff --git a/app/src/main/java/com/fadcam/ui/ProductionScheduleReceiver.java b/app/src/main/java/com/fadcam/ui/ProductionScheduleReceiver.java new file mode 100644 index 000000000..07488e146 --- /dev/null +++ b/app/src/main/java/com/fadcam/ui/ProductionScheduleReceiver.java @@ -0,0 +1,58 @@ +package com.fadcam.ui; + +import android.app.AlarmManager; +import android.app.PendingIntent; +import android.content.BroadcastReceiver; +import android.content.Context; +import android.content.Intent; +import android.os.Build; + +import com.fadcam.Constants; +import com.fadcam.services.RecordingService; +import com.fadcam.utils.ServiceStartPolicy; + +/** Alarm receiver for one-shot production recordings. */ +public class ProductionScheduleReceiver extends BroadcastReceiver { + private static final String ACTION_START = "com.fadcam.action.PRODUCTION_SCHEDULE_START"; + private static final String ACTION_STOP = "com.fadcam.action.PRODUCTION_SCHEDULE_STOP"; + private static final String EXTRA_ID = "schedule_id"; + + public static void schedule(Context context, long startAt, long durationMs) { + int id = (int) (startAt ^ (startAt >>> 32)); + AlarmManager alarms = (AlarmManager) context.getSystemService(Context.ALARM_SERVICE); + setAlarm(alarms, pending(context, ACTION_START, id), startAt); + Intent stopIntent = new Intent(context, ProductionScheduleReceiver.class).setAction(ACTION_STOP).putExtra(EXTRA_ID, id); + PendingIntent stop = PendingIntent.getBroadcast(context, id + 1, stopIntent, flags()); + setAlarm(alarms, stop, startAt + Math.max(60_000L, durationMs)); + } + + private static void setAlarm(AlarmManager alarms, PendingIntent pi, long at) { + if (alarms == null) return; + if (Build.VERSION.SDK_INT >= 23) alarms.setExactAndAllowWhileIdle(AlarmManager.RTC_WAKEUP, at, pi); + else alarms.setExact(AlarmManager.RTC_WAKEUP, at, pi); + } + + private static PendingIntent pending(Context c, String action, int id) { + Intent i = new Intent(c, ProductionScheduleReceiver.class).setAction(action).putExtra(EXTRA_ID, id); + return PendingIntent.getBroadcast(c, id, i, flags()); + } + + private static int flags() { + return PendingIntent.FLAG_UPDATE_CURRENT | (Build.VERSION.SDK_INT >= 23 ? PendingIntent.FLAG_IMMUTABLE : 0); + } + + @Override public void onReceive(Context context, Intent intent) { + try { + Intent recording = new Intent(context, RecordingService.class); + if (ACTION_START.equals(intent.getAction())) { + recording.setAction(Constants.INTENT_ACTION_START_RECORDING); + ServiceStartPolicy.startRecordingAction(context, recording); + } else if (ACTION_STOP.equals(intent.getAction())) { + recording.setAction(Constants.INTENT_ACTION_STOP_RECORDING); + ServiceStartPolicy.startRecordingAction(context, recording); + } + } catch (Exception ignored) { + // RecordingService owns the recording/notification recovery path. + } + } +} diff --git a/app/src/main/java/com/fadcam/ui/ProductionToolsFragment.java b/app/src/main/java/com/fadcam/ui/ProductionToolsFragment.java new file mode 100644 index 000000000..018c43a0d --- /dev/null +++ b/app/src/main/java/com/fadcam/ui/ProductionToolsFragment.java @@ -0,0 +1,227 @@ +package com.fadcam.ui; + +import android.Manifest; +import android.app.AlarmManager; +import android.app.PendingIntent; +import android.content.BroadcastReceiver; +import android.content.Context; +import android.content.Intent; +import android.content.IntentFilter; +import android.content.SharedPreferences; +import android.content.pm.PackageManager; +import android.graphics.Bitmap; +import android.graphics.Color; +import android.location.Location; +import android.location.LocationListener; +import android.location.LocationManager; +import android.os.BatteryManager; +import android.os.Build; +import android.os.Bundle; +import android.os.Handler; +import android.os.Looper; +import android.provider.Settings; +import android.text.InputType; +import android.view.Gravity; +import android.view.View; +import android.widget.Button; +import android.widget.EditText; +import android.widget.ImageView; +import android.widget.LinearLayout; +import android.widget.ScrollView; +import android.widget.SeekBar; +import android.widget.Spinner; +import android.widget.ArrayAdapter; +import android.widget.TextView; +import android.widget.Toast; + +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; +import androidx.core.app.ActivityCompat; +import androidx.core.content.ContextCompat; +import androidx.fragment.app.Fragment; + +import com.fadcam.Constants; +import com.fadcam.R; +import com.fadcam.services.RecordingService; +import com.fadcam.utils.ServiceStartPolicy; +import com.google.zxing.BarcodeFormat; +import com.google.zxing.MultiFormatWriter; +import com.google.zxing.common.BitMatrix; + +import java.text.SimpleDateFormat; +import java.util.Locale; +import java.util.Map; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.Executors; + +import android.hardware.Sensor; +import android.hardware.SensorEvent; +import android.hardware.SensorEventListener; +import android.hardware.SensorManager; +import android.media.AudioFormat; +import android.media.AudioRecord; +import android.media.MediaRecorder; + +/** Functional home for every feature that previously displayed "Coming Soon". */ +public class ProductionToolsFragment extends Fragment implements SensorEventListener { + private static final String ARG_FEATURE = "feature"; + private static final int REQ_AUDIO = 4101; + private static final int REQ_LOCATION = 4102; + + private SensorManager sensors; + private Sensor accelerometer, magnetometer, gyroscope, stepCounter; + private final float[] accel = new float[3], mag = new float[3]; + private boolean haveAccel, haveMag; + private TextView liveValue; + private AudioRecord audioRecord; + private ExecutorService audioExecutor; + private volatile boolean audioRunning; + private boolean audioArmed; + private float noiseThreshold = 65f; + private BroadcastReceiver batteryReceiver; + private boolean thermalGuardArmed; + private float thermalLimitC = 45f; + private LocationManager locationManager; + private Location lastLocation; + private LocationListener locationListener; + private final Handler ui = new Handler(Looper.getMainLooper()); + + public static ProductionToolsFragment newInstance(String feature) { + ProductionToolsFragment f = new ProductionToolsFragment(); + Bundle b = new Bundle(); + b.putString(ARG_FEATURE, feature); + f.setArguments(b); + return f; + } + + private String feature() { + return getArguments() == null ? "sensor_dashboard" : getArguments().getString(ARG_FEATURE, "sensor_dashboard"); + } + + @Nullable @Override public View onCreateView(@NonNull android.view.LayoutInflater inflater, @Nullable android.view.ViewGroup container, @Nullable Bundle state) { + ScrollView scroll = new ScrollView(requireContext()); + LinearLayout root = new LinearLayout(requireContext()); + root.setOrientation(LinearLayout.VERTICAL); + root.setPadding(dp(16), dp(14), dp(16), dp(30)); + scroll.addView(root); + root.addView(text(titleFor(feature()), 22, Color.WHITE), lp(-1, -2)); + root.addView(text("Production tool • live controls • settings are saved", 13, 0xFFBDBDBD), lp(-1, -2)); + root.addView(space(10)); + Button back = button("‹ Back to Settings"); + back.setOnClickListener(v -> requireActivity().getSupportFragmentManager().popBackStack()); + root.addView(back, lp(-1, -2)); + root.addView(space(12)); + buildFeature(root, feature()); + return scroll; + } + + private void buildFeature(LinearLayout root, String f) { + switch (f) { + case "thermal_guardian": buildThermal(root); break; + case "audio_vision": buildAudioVision(root); break; + case "scheduled_recording": buildSchedule(root); break; + case "profiles": buildProfiles(root); break; + case "sound_meter": buildSoundMeter(root, false); break; + case "sensor_dashboard": buildSensors(root); break; + case "speedometer": buildSpeedometer(root); break; + case "clinometer": buildClinometer(root); break; + case "compass": buildCompass(root); break; + case "pedometer": buildPedometer(root); break; + case "metal_detector": buildMetal(root); break; + case "parking_marker": buildParking(root); break; + case "qr_generator": buildQr(root); break; + default: buildSensors(root); + } + } + + private void buildThermal(LinearLayout root) { + liveValue = metric(root, "Battery temperature: reading…"); + root.addView(text("Thermal Guardian monitors battery temperature and can stop recording before heat becomes unsafe. Android exposes battery temperature rather than a universal internal CPU temperature.", 14, 0xFFCCCCCC), lp(-1, -2)); + SeekBar bar = new SeekBar(requireContext()); + bar.setMax(70); bar.setProgress(45); root.addView(bar, lp(-1, -2)); + TextView limit = text("Stop recording at: 45°C", 13, 0xFFDDDDDD); root.addView(limit, lp(-1, -2)); + bar.setOnSeekBarChangeListener(new SeekBar.OnSeekBarChangeListener(){ public void onProgressChanged(SeekBar s,int p,boolean f){ thermalLimitC=Math.max(35,p); limit.setText(String.format(Locale.US,"Stop recording at: %.0f°C",thermalLimitC)); } public void onStartTrackingTouch(SeekBar s){} public void onStopTrackingTouch(SeekBar s){} }); + Button arm = button("Enable Thermal Guard"); root.addView(arm, lp(-1,-2)); + arm.setOnClickListener(v -> { thermalGuardArmed=!thermalGuardArmed; arm.setText(thermalGuardArmed?"Thermal Guard ON":"Enable Thermal Guard"); Toast.makeText(requireContext(),thermalGuardArmed?"Thermal protection armed":"Thermal protection disabled",Toast.LENGTH_SHORT).show(); }); + batteryReceiver = new BroadcastReceiver(){ @Override public void onReceive(Context c, Intent i){ int t=i.getIntExtra(BatteryManager.EXTRA_TEMPERATURE,Integer.MIN_VALUE); if(t==Integer.MIN_VALUE)return; float celsius=t/10f; if(liveValue!=null)liveValue.setText(String.format(Locale.US,"Battery temperature: %.1f°C",celsius)); if(thermalGuardArmed&&celsius>=thermalLimitC) stopRecordingService(); }}; + requireContext().registerReceiver(batteryReceiver,new IntentFilter(Intent.ACTION_BATTERY_CHANGED)); + } + + private void buildAudioVision(LinearLayout root) { + buildSoundMeter(root, true); + TextView note=text("When armed, a sustained sound above the threshold starts the FadCam recording service. Stop it from the main recording controls.",13,0xFFBDBDBD); root.addView(note,lp(-1,-2)); + } + + private void buildSoundMeter(LinearLayout root, boolean vision) { + liveValue=metric(root,"Sound level: — dB"); + TextView status=text("Microphone level meter",14,0xFFBDBDBD); root.addView(status,lp(-1,-2)); + SeekBar bar=new SeekBar(requireContext()); bar.setMax(100); bar.setProgress((int)noiseThreshold); root.addView(bar,lp(-1,-2)); + TextView threshold=text(String.format(Locale.US,"Trigger threshold: %.0f dB",noiseThreshold),13,0xFFDDDDDD); root.addView(threshold,lp(-1,-2)); + bar.setOnSeekBarChangeListener(new SeekBar.OnSeekBarChangeListener(){public void onProgressChanged(SeekBar s,int p,boolean f){noiseThreshold=Math.max(20,p);threshold.setText(String.format(Locale.US,"Trigger threshold: %.0f dB",noiseThreshold));}public void onStartTrackingTouch(SeekBar s){}public void onStopTrackingTouch(SeekBar s){}}); + Button start=button(vision?"Arm Audio Vision":"Start Sound Meter");root.addView(start,lp(-1,-2)); + start.setOnClickListener(v->{ if(ContextCompat.checkSelfPermission(requireContext(),Manifest.permission.RECORD_AUDIO)!=PackageManager.PERMISSION_GRANTED){requestPermissions(new String[]{Manifest.permission.RECORD_AUDIO},REQ_AUDIO);return;} audioArmed=vision; if(audioRunning){audioRunning=false;start.setText(vision?"Arm Audio Vision":"Start Sound Meter");}else{startAudio();start.setText(vision?"Audio Vision Armed — Stop":"Stop Sound Meter");} }); + } + + private void startAudio(){ if(audioRunning)return; audioRunning=true; final int sampleCount=Math.max(1024,AudioRecord.getMinBufferSize(16000,AudioFormat.CHANNEL_IN_MONO,AudioFormat.ENCODING_PCM_16BIT)); final int buffer=sampleCount*2; audioExecutor=Executors.newSingleThreadExecutor(); audioExecutor.execute(()->{try{audioRecord=new AudioRecord(MediaRecorder.AudioSource.MIC,16000,AudioFormat.CHANNEL_IN_MONO,AudioFormat.ENCODING_PCM_16BIT,buffer);audioRecord.startRecording();short[] data=new short[sampleCount];long loudSince=0;while(audioRunning){int n=audioRecord.read(data,0,data.length);if(n<=0)continue;double sum=0;for(int i=0;i{if(liveValue!=null)liveValue.setText(String.format(Locale.US,"Sound level: %.1f dB",db));});if(audioArmed&&db>=noiseThreshold){if(loudSince==0)loudSince=System.currentTimeMillis();if(System.currentTimeMillis()-loudSince>=700){startRecordingService();audioArmed=false;}}else loudSince=0;}}catch(Exception e){ui.post(()->Toast.makeText(requireContext(),"Microphone unavailable: "+e.getMessage(),Toast.LENGTH_LONG).show());}finally{if(audioRecord!=null){try{audioRecord.stop();}catch(Exception ignored){}audioRecord.release();audioRecord=null;}}});} + + private void buildSchedule(LinearLayout root){ + TextView help=text("Create a one-time recording alarm. The receiver starts the recording service and automatically stops it after the selected duration.",14,0xFFCCCCCC);root.addView(help,lp(-1,-2)); + EditText date=edit("Start time (yyyy-MM-dd HH:mm)"); root.addView(date,lp(-1,-2)); + EditText mins=edit("Duration in minutes (default 30)"); mins.setInputType(InputType.TYPE_CLASS_NUMBER);root.addView(mins,lp(-1,-2)); + Button schedule=button("Schedule Recording");root.addView(schedule,lp(-1,-2)); + TextView pending=metric(root,"Schedules are stored locally on this device."); + schedule.setOnClickListener(v->{try{SimpleDateFormat fmt=new SimpleDateFormat("yyyy-MM-dd HH:mm",Locale.US);long when=fmt.parse(date.getText().toString().trim()).getTime();if(when<=System.currentTimeMillis())throw new IllegalArgumentException("Choose a future time");String value=mins.getText().toString().trim();long duration=1000L*60L*Math.max(1,Long.parseLong(value.isEmpty()?"30":value));ProductionScheduleReceiver.schedule(requireContext(),when,duration);pending.setText("Scheduled for "+date.getText());Toast.makeText(requireContext(),"Recording scheduled",Toast.LENGTH_SHORT).show();}catch(Exception e){Toast.makeText(requireContext(),"Invalid schedule: "+e.getMessage(),Toast.LENGTH_LONG).show();}}); + } + + private void buildProfiles(LinearLayout root){ + EditText name=edit("Profile name");root.addView(name,lp(-1,-2)); + Spinner quality=new Spinner(requireContext());quality.setAdapter(new ArrayAdapter<>(requireContext(),android.R.layout.simple_spinner_dropdown_item,new String[]{"FHD • 30fps","FHD • 60fps","HD • 30fps","SD • 30fps"}));root.addView(quality,lp(-1,-2)); + Spinner camera=new Spinner(requireContext());camera.setAdapter(new ArrayAdapter<>(requireContext(),android.R.layout.simple_spinner_dropdown_item,new String[]{"Back camera","Front camera"}));root.addView(camera,lp(-1,-2)); + Button save=button("Save Profile");root.addView(save,lp(-1,-2)); + LinearLayout list=new LinearLayout(requireContext());list.setOrientation(LinearLayout.VERTICAL);root.addView(list,lp(-1,-2)); + SharedPreferences p=prefs(); + final Runnable[] refresh = new Runnable[1]; + refresh[0] = () -> { list.removeAllViews(); for(Map.Entry e:p.getAll().entrySet()){ if(!e.getKey().startsWith("production_profile_")) continue; String[] vals=String.valueOf(e.getValue()).split("\\|",-1); LinearLayout row=new LinearLayout(requireContext()); row.setGravity(Gravity.CENTER_VERTICAL); TextView t=text(e.getKey().substring("production_profile_".length())+" • "+(vals.length>0?vals[0]:""),14,0xFFFFFFFF); row.addView(t,new LinearLayout.LayoutParams(0,-2,1)); Button load=button("Load"); row.addView(load,new LinearLayout.LayoutParams(dp(90),-2)); Button del=button("Delete"); row.addView(del,new LinearLayout.LayoutParams(dp(90),-2)); load.setOnClickListener(v->{if(vals.length>0){int q=indexOf(quality,vals[0]);if(q>=0)quality.setSelection(q);}if(vals.length>1){int c=indexOf(camera,vals[1]);if(c>=0)camera.setSelection(c);}name.setText(e.getKey().substring("production_profile_".length()));}); del.setOnClickListener(v->{p.edit().remove(e.getKey()).apply();refresh[0].run();}); list.addView(row); } }; + save.setOnClickListener(v->{String n=name.getText().toString().trim();if(n.isEmpty()){Toast.makeText(requireContext(),"Enter a profile name",Toast.LENGTH_SHORT).show();return;}p.edit().putString("production_profile_"+n,quality.getSelectedItem().toString()+"|"+camera.getSelectedItem().toString()).apply();refresh[0].run();Toast.makeText(requireContext(),"Profile saved",Toast.LENGTH_SHORT).show();});refresh[0].run(); + } + + private void buildSensors(LinearLayout root){ + liveValue=metric(root,"Waiting for sensor data…");sensors=(SensorManager)requireContext().getSystemService(Context.SENSOR_SERVICE);accelerometer=sensors.getDefaultSensor(Sensor.TYPE_ACCELEROMETER);magnetometer=sensors.getDefaultSensor(Sensor.TYPE_MAGNETIC_FIELD);gyroscope=sensors.getDefaultSensor(Sensor.TYPE_GYROSCOPE);stepCounter=sensors.getDefaultSensor(Sensor.TYPE_STEP_COUNTER); + TextView note=text("This dashboard reads available hardware in real time. Missing sensors are reported instead of faking values.",14,0xFFCCCCCC);root.addView(note,lp(-1,-2)); + Button refresh=button("Refresh sensor availability");root.addView(refresh,lp(-1,-2));refresh.setOnClickListener(v->showSensorSummary());showSensorSummary(); + } + private void showSensorSummary(){if(liveValue==null||sensors==null)return;StringBuilder b=new StringBuilder();b.append("Accelerometer: ").append(accelerometer!=null?"available":"not available").append("\nMagnetometer: ").append(magnetometer!=null?"available":"not available").append("\nGyroscope: ").append(gyroscope!=null?"available":"not available").append("\nStep counter: ").append(stepCounter!=null?"available":"not available");liveValue.setText(b.toString());} + + private void buildSpeedometer(LinearLayout root){liveValue=metric(root,"Speed: 0.0 km/h");requestLocation();} + private void buildCompass(LinearLayout root){liveValue=metric(root,"Heading: —°");registerSensors();} + private void buildClinometer(LinearLayout root){liveValue=metric(root,"Tilt: —°");registerSensors();} + private void buildPedometer(LinearLayout root){liveValue=metric(root,"Steps: —");registerSensors();} + private void buildMetal(LinearLayout root){liveValue=metric(root,"Magnetic field: — µT");registerSensors();} + + private void registerSensors(){if(sensors==null)sensors=(SensorManager)requireContext().getSystemService(Context.SENSOR_SERVICE);if(accelerometer==null)accelerometer=sensors.getDefaultSensor(Sensor.TYPE_ACCELEROMETER);if(magnetometer==null)magnetometer=sensors.getDefaultSensor(Sensor.TYPE_MAGNETIC_FIELD);if(gyroscope==null)gyroscope=sensors.getDefaultSensor(Sensor.TYPE_GYROSCOPE);if(stepCounter==null)stepCounter=sensors.getDefaultSensor(Sensor.TYPE_STEP_COUNTER);for(Sensor s:new Sensor[]{accelerometer,magnetometer,gyroscope,stepCounter})if(s!=null)sensors.registerListener(this,s,SensorManager.SENSOR_DELAY_UI);} + + private void buildParking(LinearLayout root){liveValue=metric(root,"No parking location saved");requestLocation();Button save=button("Save Current Parking Spot");root.addView(save,lp(-1,-2));Button navigate=button("Navigate to Saved Spot");root.addView(navigate,lp(-1,-2));save.setOnClickListener(v->{if(lastLocation==null){Toast.makeText(requireContext(),"Waiting for GPS fix",Toast.LENGTH_SHORT).show();return;}prefs().edit().putString("parking_lat",Double.toString(lastLocation.getLatitude())).putString("parking_lon",Double.toString(lastLocation.getLongitude())).apply();liveValue.setText(String.format(Locale.US,"Saved: %.6f, %.6f",lastLocation.getLatitude(),lastLocation.getLongitude()));});navigate.setOnClickListener(v->{String la=prefs().getString("parking_lat",null),lo=prefs().getString("parking_lon",null);if(la==null||lo==null){Toast.makeText(requireContext(),"No saved parking spot",Toast.LENGTH_SHORT).show();return;}Intent i=new Intent(Intent.ACTION_VIEW,android.net.Uri.parse("geo:"+la+","+lo+"?q="+la+","+lo+"(Parking)"));startActivity(i);});} + + private void buildQr(LinearLayout root){EditText input=edit("Text, URL, contact or production link");root.addView(input,lp(-1,-2));Button gen=button("Generate QR Code");root.addView(gen,lp(-1,-2));ImageView image=new ImageView(requireContext());image.setAdjustViewBounds(true);root.addView(image,lp(-1,dp(280)));gen.setOnClickListener(v->{String value=input.getText().toString().trim();if(value.isEmpty()){Toast.makeText(requireContext(),"Enter content first",Toast.LENGTH_SHORT).show();return;}try{BitMatrix m=new MultiFormatWriter().encode(value,BarcodeFormat.QR_CODE,700,700);Bitmap b=Bitmap.createBitmap(700,700,Bitmap.Config.ARGB_8888);for(int x=0;x<700;x++)for(int y=0;y<700;y++)b.setPixel(x,y,m.get(x,y)?Color.BLACK:Color.WHITE);image.setImageBitmap(b);}catch(Exception e){Toast.makeText(requireContext(),"QR generation failed",Toast.LENGTH_SHORT).show();}});} + + private void requestLocation(){if(ContextCompat.checkSelfPermission(requireContext(),Manifest.permission.ACCESS_FINE_LOCATION)!=PackageManager.PERMISSION_GRANTED){requestPermissions(new String[]{Manifest.permission.ACCESS_FINE_LOCATION,Manifest.permission.ACCESS_COARSE_LOCATION},REQ_LOCATION);return;}locationManager=(LocationManager)requireContext().getSystemService(Context.LOCATION_SERVICE);locationListener=new LocationListener(){@Override public void onLocationChanged(@NonNull Location l){lastLocation=l;if("speedometer".equals(feature())&&liveValue!=null)liveValue.setText(String.format(Locale.US,"Speed: %.1f km/h",l.getSpeed()*3.6));if("parking_marker".equals(feature())&&liveValue!=null)liveValue.setText(String.format(Locale.US,"GPS: %.6f, %.6f",l.getLatitude(),l.getLongitude()));}};try{locationManager.requestLocationUpdates(LocationManager.GPS_PROVIDER,500,0.5f,locationListener);Location l=locationManager.getLastKnownLocation(LocationManager.GPS_PROVIDER);if(l!=null)lastLocation=l;}catch(SecurityException ignored){}} + + @Override public void onSensorChanged(SensorEvent e){if(liveValue==null)return;if(e.sensor.getType()==Sensor.TYPE_ACCELEROMETER){System.arraycopy(e.values,0,accel,0,3);haveAccel=true;if("clinometer".equals(feature())){double tilt=Math.toDegrees(Math.atan2(accel[0],Math.sqrt(accel[1]*accel[1]+accel[2]*accel[2])));liveValue.setText(String.format(Locale.US,"Tilt: %.1f°",tilt));}}else if(e.sensor.getType()==Sensor.TYPE_MAGNETIC_FIELD){System.arraycopy(e.values,0,mag,0,3);haveMag=true;if("metal_detector".equals(feature())){double u=Math.sqrt(e.values[0]*e.values[0]+e.values[1]*e.values[1]+e.values[2]*e.values[2]);liveValue.setText(String.format(Locale.US,"Magnetic field: %.1f µT",u));}}else if(e.sensor.getType()==Sensor.TYPE_STEP_COUNTER&&"pedometer".equals(feature())){liveValue.setText(String.format(Locale.US,"Steps since device boot: %.0f",e.values[0]));}else if(e.sensor.getType()==Sensor.TYPE_GYROSCOPE&&"sensor_dashboard".equals(feature())){liveValue.setText(String.format(Locale.US,"Gyro: %.2f, %.2f, %.2f rad/s",e.values[0],e.values[1],e.values[2]));}if("compass".equals(feature())&&haveAccel&&haveMag){float[] R=new float[9],I=new float[9];if(SensorManager.getRotationMatrix(R,I,accel,mag)){float[] o=new float[3];SensorManager.getOrientation(R,o);float deg=(float)Math.toDegrees(o[0]);if(deg<0)deg+=360;liveValue.setText(String.format(Locale.US,"Heading: %.0f°",deg));}}} + @Override public void onAccuracyChanged(Sensor s,int a){} + + private void startRecordingService(){try{Intent i=new Intent(requireContext(),RecordingService.class);i.setAction(Constants.INTENT_ACTION_START_RECORDING);ServiceStartPolicy.startRecordingAction(requireContext(),i);Toast.makeText(requireContext(),"FadCam recording started",Toast.LENGTH_SHORT).show();}catch(Exception e){Toast.makeText(requireContext(),"Could not start recording: "+e.getMessage(),Toast.LENGTH_LONG).show();}} + private void stopRecordingService(){try{Intent i=new Intent(requireContext(),RecordingService.class);i.setAction(Constants.INTENT_ACTION_STOP_RECORDING);ServiceStartPolicy.startRecordingAction(requireContext(),i);}catch(Exception ignored){}} + + private SharedPreferences prefs(){return requireContext().getSharedPreferences("production_tools",Context.MODE_PRIVATE);} + private int indexOf(Spinner s,String value){for(int i=0;i openSubFragment(new SecuritySettingsFragment())); bindRow(root, R.id.group_motion_lab, () -> openSubFragment(new MotionLabSettingsFragment())); bindRow(root, R.id.group_digital_forensics, () -> openSubFragment(new DigitalForensicsSettingsFragment())); - bindRow(root, R.id.group_thermal_guardian, () -> { - android.widget.Toast.makeText(requireContext(), R.string.mini_app_coming_soon_desc, android.widget.Toast.LENGTH_SHORT).show(); - }); - bindRow(root, R.id.group_audio_vision, () -> { - android.widget.Toast.makeText(requireContext(), R.string.mini_app_coming_soon_desc, android.widget.Toast.LENGTH_SHORT).show(); - }); - bindRow(root, R.id.group_scheduled_recording, () -> { - android.widget.Toast.makeText(requireContext(), R.string.mini_app_coming_soon_desc, android.widget.Toast.LENGTH_SHORT).show(); - }); - bindRow(root, R.id.group_profiles, () -> { - android.widget.Toast.makeText(requireContext(), R.string.mini_app_coming_soon_desc, android.widget.Toast.LENGTH_SHORT).show(); - }); + bindRow(root, R.id.group_thermal_guardian, () -> openProductionTool("thermal_guardian")); + bindRow(root, R.id.group_audio_vision, () -> openProductionTool("audio_vision")); + bindRow(root, R.id.group_scheduled_recording, () -> openProductionTool("scheduled_recording")); + bindRow(root, R.id.group_profiles, () -> openProductionTool("profiles")); bindRow(root, R.id.group_automation, () -> openSubFragment(new AutomationSettingsFragment())); bindRow(root, R.id.group_widgets, () -> openSubFragment(new ShortcutsSettingsFragment())); bindRow(root, R.id.group_notifications, () -> openSubFragment(new NotificationSettingsFragment())); @@ -540,19 +533,19 @@ private void setupMiniAppCards(View root) { } }); setupMiniCard(root, R.id.group_mini_compass, R.string.mini_app_compass_title, - R.string.mini_app_compass_desc, "explore", R.string.mini_app_coming_soon, + R.string.mini_app_compass_desc, "explore", 0, () -> HomeSidebarFragment.showMiniAppComingSoon(this, "compass")); setupMiniCard(root, R.id.group_mini_sound_meter, R.string.mini_app_sound_meter_title, - R.string.mini_app_sound_meter_desc, "graphic_eq", R.string.mini_app_coming_soon, + R.string.mini_app_sound_meter_desc, "graphic_eq", 0, () -> HomeSidebarFragment.showMiniAppComingSoon(this, "sound_meter")); setupMiniCard(root, R.id.group_mini_sensor, R.string.mini_app_sensor_dashboard_title, - R.string.mini_app_sensor_dashboard_desc, "sensors", R.string.mini_app_coming_soon, + R.string.mini_app_sensor_dashboard_desc, "sensors", 0, () -> HomeSidebarFragment.showMiniAppComingSoon(this, "sensor_dashboard")); setupMiniCard(root, R.id.group_mini_speedometer, R.string.mini_app_speedometer_title, - R.string.mini_app_speedometer_desc, "speed", R.string.mini_app_coming_soon, + R.string.mini_app_speedometer_desc, "speed", 0, () -> HomeSidebarFragment.showMiniAppComingSoon(this, "speedometer")); setupMiniCard(root, R.id.group_mini_clinometer, R.string.mini_app_clinometer_title, - R.string.mini_app_clinometer_desc, "architecture", R.string.mini_app_coming_soon, + R.string.mini_app_clinometer_desc, "architecture", 0, () -> HomeSidebarFragment.showMiniAppComingSoon(this, "clinometer")); setupMiniCard(root, R.id.group_mini_qr_scanner, R.string.mini_app_qr_scanner_title, R.string.mini_app_qr_scanner_desc, "qr_code_scanner", 0, // no "Soon" badge — it's ready @@ -561,19 +554,29 @@ private void setupMiniAppCards(View root) { startActivity(intent); }); setupMiniCard(root, R.id.group_mini_pedometer, R.string.mini_app_pedometer_title, - R.string.mini_app_pedometer_desc, "directions_walk", R.string.mini_app_coming_soon, + R.string.mini_app_pedometer_desc, "directions_walk", 0, () -> HomeSidebarFragment.showMiniAppComingSoon(this, "pedometer")); setupMiniCard(root, R.id.group_mini_metal_detector, R.string.mini_app_metal_detector_title, - R.string.mini_app_metal_detector_desc, "travel_explore", R.string.mini_app_coming_soon, + R.string.mini_app_metal_detector_desc, "travel_explore", 0, () -> HomeSidebarFragment.showMiniAppComingSoon(this, "metal_detector")); setupMiniCard(root, R.id.group_mini_parking_marker, R.string.mini_app_parking_marker_title, - R.string.mini_app_parking_marker_desc, "location_on", R.string.mini_app_coming_soon, + R.string.mini_app_parking_marker_desc, "location_on", 0, () -> HomeSidebarFragment.showMiniAppComingSoon(this, "parking_marker")); setupMiniCard(root, R.id.group_mini_qr_generator, R.string.mini_app_qr_generator_title, - R.string.mini_app_qr_generator_desc, "qr_code_2", R.string.mini_app_coming_soon, + R.string.mini_app_qr_generator_desc, "qr_code_2", 0, () -> HomeSidebarFragment.showMiniAppComingSoon(this, "qr_generator")); } + private void openProductionTool(String feature) { + try { + OverlayNavUtil.show(requireActivity(), + ProductionToolsFragment.newInstance(feature), + "production_tool_" + feature); + } catch (Exception e) { + FLog.w("SettingsHome", "Failed to open production tool: " + feature, e); + } + } + private void bindRow(View root, int id, Runnable action) { View v = root.findViewById(id); if (v != null) v.setOnClickListener(x -> action.run()); diff --git a/app/src/main/java/com/fadcam/ui/SocialPublishBottomSheet.java b/app/src/main/java/com/fadcam/ui/SocialPublishBottomSheet.java new file mode 100644 index 000000000..76ecbd23a --- /dev/null +++ b/app/src/main/java/com/fadcam/ui/SocialPublishBottomSheet.java @@ -0,0 +1,294 @@ +package com.fadcam.ui; + +import android.content.ActivityNotFoundException; +import android.content.Context; +import android.content.Intent; +import android.content.pm.PackageManager; +import android.net.Uri; +import android.os.Bundle; +import android.provider.Settings; +import android.view.Gravity; +import android.view.LayoutInflater; +import android.view.View; +import android.view.ViewGroup; +import android.widget.ImageView; +import android.widget.LinearLayout; +import android.widget.TextView; +import android.widget.Toast; + +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; +import androidx.core.content.ContextCompat; +import androidx.core.content.FileProvider; +import androidx.fragment.app.FragmentActivity; + +import com.google.android.material.bottomsheet.BottomSheetDialogFragment; +import com.google.android.material.card.MaterialCardView; + +import com.fadcam.R; + +import java.io.File; +import java.util.ArrayList; +import java.util.List; + +/** + * Recording publishing hub. + * + * Uses Android's secure media-sharing contract rather than embedding platform + * credentials. This keeps provider authentication inside the provider app and + * lets the user finish the final post there. Android recommends ACTION_SEND for + * sharing media between applications and a content URI with read permission. + */ +public class SocialPublishBottomSheet extends BottomSheetDialogFragment { + + private static final String ARG_URI = "video_uri"; + private static final String ARG_NAME = "video_name"; + + private static final String PKG_YOUTUBE = "com.google.android.youtube"; + private static final String PKG_FACEBOOK = "com.facebook.katana"; + private static final String PKG_INSTAGRAM = "com.instagram.android"; + private static final String PKG_TIKTOK = "com.zhiliaoapp.musically"; + private static final String PKG_LINKEDIN = "com.linkedin.android"; + private static final String PKG_X = "com.twitter.android"; + private static final String PKG_REDDIT = "com.reddit.frontpage"; + private static final String PKG_THREADS = "com.instagram.barcelona"; + private static final String PKG_WHATSAPP = "com.whatsapp"; + private static final String PKG_TELEGRAM = "org.telegram.messenger"; + + private Uri videoUri; + private String videoName; + + public static void show(@NonNull FragmentActivity activity, @NonNull Uri uri, @Nullable String name) { + SocialPublishBottomSheet sheet = new SocialPublishBottomSheet(); + Bundle args = new Bundle(); + args.putString(ARG_URI, uri.toString()); + args.putString(ARG_NAME, name == null ? "Video" : name); + sheet.setArguments(args); + sheet.show(activity.getSupportFragmentManager(), "social_publish_sheet"); + } + + @Nullable + @Override + public View onCreateView(@NonNull LayoutInflater inflater, @Nullable ViewGroup container, + @Nullable Bundle savedInstanceState) { + Context context = requireContext(); + Bundle args = getArguments(); + if (args == null || args.getString(ARG_URI) == null) { + dismissAllowingStateLoss(); + return new View(context); + } + + videoUri = Uri.parse(args.getString(ARG_URI)); + videoName = args.getString(ARG_NAME, "Video"); + + LinearLayout root = new LinearLayout(context); + root.setOrientation(LinearLayout.VERTICAL); + root.setPadding(dp(20), dp(16), dp(20), dp(24)); + root.setBackgroundColor(ContextCompat.getColor(context, android.R.color.background_dark)); + + TextView title = new TextView(context); + title.setText("Publish recording"); + title.setTextSize(22); + title.setTypeface(null, android.graphics.Typeface.BOLD); + title.setTextColor(ContextCompat.getColor(context, android.R.color.white)); + root.addView(title, lp(LinearLayout.LayoutParams.MATCH_PARENT, dp(38))); + + TextView subtitle = new TextView(context); + subtitle.setText(videoName + "\nVideo is attached securely. Choose a destination to continue publishing."); + subtitle.setTextSize(13); + subtitle.setTextColor(0xFFBDBDBD); + root.addView(subtitle, lp(LinearLayout.LayoutParams.MATCH_PARENT, dp(58))); + + TextView installedHeader = new TextView(context); + installedHeader.setText("DESTINATIONS"); + installedHeader.setTextSize(12); + installedHeader.setTypeface(null, android.graphics.Typeface.BOLD); + installedHeader.setTextColor(0xFFEF5350); + root.addView(installedHeader, lp(LinearLayout.LayoutParams.MATCH_PARENT, dp(32))); + + for (Destination destination : destinations()) { + root.addView(createDestinationCard(context, destination)); + } + + MaterialCardView moreCard = createCard(context); + moreCard.addView(createTextRow(context, "More apps", "Open the Android sharing sheet", "apps")); + moreCard.setOnClickListener(v -> shareGeneric(context)); + root.addView(moreCard, lp(LinearLayout.LayoutParams.MATCH_PARENT, dp(70))); + + TextView note = new TextView(context); + note.setText("Publishing is completed in the selected platform. FadCam never stores or asks for your social-network passwords."); + note.setTextSize(11); + note.setTextColor(0xFF888888); + note.setPadding(0, dp(12), 0, 0); + root.addView(note, lp(LinearLayout.LayoutParams.MATCH_PARENT, dp(48))); + + return root; + } + + private MaterialCardView createDestinationCard(Context context, Destination destination) { + MaterialCardView card = createCard(context); + boolean installed = isInstalled(context, destination.packageName); + String state = installed ? "Installed • ready" : "Not installed • use web/chooser"; + card.addView(createTextRow(context, destination.name, state, destination.glyph)); + card.setAlpha(installed ? 1f : 0.82f); + card.setOnClickListener(v -> publishTo(context, destination)); + return card; + } + + private MaterialCardView createCard(Context context) { + MaterialCardView card = new MaterialCardView(context); + card.setRadius(dp(14)); + card.setCardBackgroundColor(0xFF1B1B1B); + card.setStrokeWidth(dp(1)); + card.setStrokeColor(0xFF333333); + card.setClickable(true); + card.setFocusable(true); + return card; + } + + private View createTextRow(Context context, String name, String state, String glyph) { + LinearLayout row = new LinearLayout(context); + row.setGravity(Gravity.CENTER_VERTICAL); + row.setPadding(dp(16), dp(8), dp(16), dp(8)); + + TextView icon = new TextView(context); + icon.setText(glyph); + icon.setTextSize(12); + icon.setGravity(Gravity.CENTER); + icon.setTextColor(0xFFFFFFFF); + icon.setBackgroundColor(0xFF2A2A2A); + row.addView(icon, lp(dp(42), dp(42))); + + LinearLayout labels = new LinearLayout(context); + labels.setOrientation(LinearLayout.VERTICAL); + labels.setPadding(dp(14), 0, 0, 0); + TextView primary = new TextView(context); + primary.setText(name); + primary.setTextSize(16); + primary.setTypeface(null, android.graphics.Typeface.BOLD); + primary.setTextColor(0xFFFFFFFF); + labels.addView(primary, lp(LinearLayout.LayoutParams.MATCH_PARENT, dp(25))); + TextView secondary = new TextView(context); + secondary.setText(state); + secondary.setTextSize(11); + secondary.setTextColor(0xFF9E9E9E); + labels.addView(secondary, lp(LinearLayout.LayoutParams.MATCH_PARENT, dp(22))); + row.addView(labels, lp(0, dp(58), 1f)); + + TextView arrow = new TextView(context); + arrow.setText("›"); + arrow.setTextSize(26); + arrow.setTextColor(0xFFEF5350); + row.addView(arrow, lp(dp(30), dp(58))); + return row; + } + + private List destinations() { + List list = new ArrayList<>(); + list.add(new Destination("YouTube", PKG_YOUTUBE, "YT")); + list.add(new Destination("Facebook", PKG_FACEBOOK, "FB")); + list.add(new Destination("Instagram", PKG_INSTAGRAM, "IG")); + list.add(new Destination("TikTok", PKG_TIKTOK, "TT")); + list.add(new Destination("LinkedIn", PKG_LINKEDIN, "in")); + list.add(new Destination("X", PKG_X, "X")); + list.add(new Destination("Reddit", PKG_REDDIT, "R")); + list.add(new Destination("Threads", PKG_THREADS, "@")); + list.add(new Destination("WhatsApp", PKG_WHATSAPP, "WA")); + list.add(new Destination("Telegram", PKG_TELEGRAM, "TG")); + return list; + } + + private void publishTo(Context context, Destination destination) { + Uri shareUri = getShareableUri(context, videoUri); + if (shareUri == null) { + Toast.makeText(context, "Video could not be prepared for sharing", Toast.LENGTH_LONG).show(); + return; + } + + Intent intent = new Intent(Intent.ACTION_SEND); + intent.setType("video/*"); + intent.putExtra(Intent.EXTRA_STREAM, shareUri); + intent.putExtra(Intent.EXTRA_TITLE, videoName); + intent.addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION | Intent.FLAG_GRANT_WRITE_URI_PERMISSION); + intent.setPackage(destination.packageName); + + try { + context.startActivity(intent); + dismissAllowingStateLoss(); + } catch (ActivityNotFoundException notInstalled) { + // Some providers intentionally expose only part of their publishing flow to + // third-party intents. In that case let the user continue with the standard + // Android Sharesheet rather than failing the recording action. + Intent chooser = new Intent(Intent.ACTION_SEND); + chooser.setType("video/*"); + chooser.putExtra(Intent.EXTRA_STREAM, shareUri); + chooser.putExtra(Intent.EXTRA_TITLE, videoName); + chooser.addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION); + try { + context.startActivity(Intent.createChooser(chooser, "Publish " + videoName)); + } catch (Exception e) { + Toast.makeText(context, "No compatible sharing app is available", Toast.LENGTH_LONG).show(); + } + } catch (Exception e) { + Toast.makeText(context, "Could not open " + destination.name, Toast.LENGTH_LONG).show(); + } + } + + private void shareGeneric(Context context) { + Uri shareUri = getShareableUri(context, videoUri); + if (shareUri == null) return; + Intent send = new Intent(Intent.ACTION_SEND); + send.setType("video/*"); + send.putExtra(Intent.EXTRA_STREAM, shareUri); + send.putExtra(Intent.EXTRA_TITLE, videoName); + send.addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION); + context.startActivity(Intent.createChooser(send, "Publish " + videoName)); + } + + private Uri getShareableUri(Context context, Uri uri) { + if (uri == null) return null; + if ("content".equals(uri.getScheme())) return uri; + if ("file".equals(uri.getScheme())) { + try { + File file = new File(uri.getPath()); + return FileProvider.getUriForFile(context, + context.getApplicationContext().getPackageName() + ".provider", file); + } catch (Exception ignored) { + return uri; + } + } + return uri; + } + + private boolean isInstalled(Context context, String packageName) { + try { + context.getPackageManager().getPackageInfo(packageName, 0); + return true; + } catch (PackageManager.NameNotFoundException e) { + return false; + } + } + + private LinearLayout.LayoutParams lp(int width, int height) { + return new LinearLayout.LayoutParams(width, height); + } + + private LinearLayout.LayoutParams lp(int width, int height, float weight) { + return new LinearLayout.LayoutParams(width, height, weight); + } + + private int dp(int value) { + return Math.round(value * requireContext().getResources().getDisplayMetrics().density); + } + + private static final class Destination { + final String name; + final String packageName; + final String glyph; + Destination(String name, String packageName, String glyph) { + this.name = name; + this.packageName = packageName; + this.glyph = glyph; + } + } +} diff --git a/app/src/main/java/com/fadcam/ui/StorageSettingsFragment.java b/app/src/main/java/com/fadcam/ui/StorageSettingsFragment.java index 78520c542..6abc99dd0 100644 --- a/app/src/main/java/com/fadcam/ui/StorageSettingsFragment.java +++ b/app/src/main/java/com/fadcam/ui/StorageSettingsFragment.java @@ -1,7 +1,6 @@ package com.fadcam.ui; -import com.fadcam.Log; -import com.fadcam.FLog; +import android.app.Activity; import android.content.Intent; import android.net.Uri; import android.os.Bundle; @@ -9,34 +8,41 @@ import android.view.View; import android.view.ViewGroup; import android.widget.TextView; +import android.widget.Toast; import androidx.activity.result.ActivityResultLauncher; import androidx.activity.result.contract.ActivityResultContracts; import androidx.annotation.NonNull; import androidx.annotation.Nullable; -import androidx.documentfile.provider.DocumentFile; import androidx.fragment.app.Fragment; import com.fadcam.Constants; -import com.fadcam.MainActivity; -import com.fadcam.ui.OverlayNavUtil; +import com.fadcam.FLog; import com.fadcam.R; import com.fadcam.SharedPreferencesManager; +import com.fadcam.utils.StorageDestinationManager; +import com.fadcam.utils.StorageInfoCache; + +import java.util.ArrayList; /** - * StorageSettingsFragment - * Modular extraction of storage location logic from legacy SettingsFragment. - * Provides single row summary + dialogs keeping same preference semantics & - * broadcast. + * Recording destination selector. + * + *

All three destinations are live: phone local storage, SD/memory card, + * and USB external storage. SD/USB selections use Android's Storage Access + * Framework and persist the user's write permission, so the recording + * pipeline can write its normal FadCam folder tree to the selected volume.

*/ public class StorageSettingsFragment extends Fragment { private static final String TAG = "StorageSettingsFragment"; + private static final String RESULT_KEY = "picker_result_storage"; private SharedPreferencesManager prefs; private TextView valueStorageMode; + private String pendingDestination = StorageDestinationManager.DESTINATION_SD; - private ActivityResultLauncher openDocumentTreeLauncher; + private ActivityResultLauncher openDocumentTreeLauncher; @Nullable @Override @@ -49,281 +55,173 @@ public View onCreateView(@NonNull LayoutInflater inflater, @Nullable ViewGroup c public void onCreate(@Nullable Bundle savedInstanceState) { super.onCreate(savedInstanceState); prefs = SharedPreferencesManager.getInstance(requireContext()); - openDocumentTreeLauncher = registerForActivityResult(new ActivityResultContracts.OpenDocumentTree(), uri -> { - if (uri != null) { - boolean success = false; - try { - final int takeFlags = Intent.FLAG_GRANT_READ_URI_PERMISSION - | Intent.FLAG_GRANT_WRITE_URI_PERMISSION; - requireContext().getContentResolver().takePersistableUriPermission(uri, takeFlags); - prefs.setCustomStorageUri(uri.toString()); - prefs.setStorageMode(SharedPreferencesManager.STORAGE_MODE_CUSTOM); - success = true; - sendStorageChangedBroadcast(); - } catch (Exception e) { - FLog.e(TAG, "Failed to persist custom storage URI", e); - prefs.setStorageMode(SharedPreferencesManager.STORAGE_MODE_INTERNAL); - prefs.setCustomStorageUri(null); - } - refreshValue(); - } else { - // Revert if user cancelled selection when switching - if (!SharedPreferencesManager.STORAGE_MODE_CUSTOM.equals(prefs.getStorageMode())) { - prefs.setStorageMode(SharedPreferencesManager.STORAGE_MODE_INTERNAL); - } - refreshValue(); - } - }); + + openDocumentTreeLauncher = registerForActivityResult( + new ActivityResultContracts.StartActivityForResult(), result -> { + if (result.getResultCode() != Activity.RESULT_OK || result.getData() == null) { + refreshValue(); + return; + } + Intent data = result.getData(); + Uri uri = data.getData(); + if (uri == null) { + refreshValue(); + return; + } + activateExternalDestination(uri, data.getFlags()); + }); } @Override public void onViewCreated(@NonNull View view, @Nullable Bundle savedInstanceState) { - com.fadcam.Utils.attachPressScaleToClickableRows(view); super.onViewCreated(view, savedInstanceState); + com.fadcam.Utils.attachPressScaleToClickableRows(view); valueStorageMode = view.findViewById(R.id.value_storage_mode); view.findViewById(R.id.row_storage_mode).setOnClickListener(v -> showStorageOptionsSheet()); View back = view.findViewById(R.id.back_button); - if (back != null) { - back.setOnClickListener(v -> OverlayNavUtil.dismiss(requireActivity())); - } + if (back != null) back.setOnClickListener(v -> OverlayNavUtil.dismiss(requireActivity())); refreshValue(); } - // Removed duplicate manual back handling; centralized via OverlayNavUtil + @Override + public void onResume() { + super.onResume(); + if (valueStorageMode != null) refreshValue(); + } private void refreshValue() { - String mode = prefs.getStorageMode(); - if (SharedPreferencesManager.STORAGE_MODE_CUSTOM.equals(mode)) { - String uri = prefs.getCustomStorageUri(); - String label = buildDisplayPath(uri); - valueStorageMode.setText(getString(R.string.storage_value_custom_prefix) + " " + label); - } else { - valueStorageMode.setText(getString(R.string.storage_value_internal)); + if (valueStorageMode == null) return; + String destination = StorageDestinationManager.getDestination(requireContext(), prefs); + valueStorageMode.setText(StorageDestinationManager.getDisplayLabel(requireContext(), prefs)); + if (StorageDestinationManager.isExternalDestination(destination) + && !StorageDestinationManager.isWritableTree(requireContext(), prefs.getCustomStorageUri())) { + StorageDestinationManager.setInternal(prefs); + valueStorageMode.setText("Phone local storage"); } } private void showStorageOptionsSheet() { - final String rk = "picker_result_storage"; - getParentFragmentManager().setFragmentResultListener(rk, this, (k, b) -> { - String sel = b.getString(com.fadcam.ui.picker.PickerBottomSheetFragment.BUNDLE_SELECTED_ID); - if (sel == null) + getParentFragmentManager().setFragmentResultListener(RESULT_KEY, this, (key, bundle) -> { + String selected = bundle.getString(com.fadcam.ui.picker.PickerBottomSheetFragment.BUNDLE_SELECTED_ID); + if (selected == null) return; + if (StorageDestinationManager.DESTINATION_INTERNAL.equals(selected)) { + StorageDestinationManager.setInternal(prefs); + StorageInfoCache.clearCache(); + sendStorageChangedBroadcast(); + refreshValue(); return; - String mode = prefs.getStorageMode(); - boolean custom = SharedPreferencesManager.STORAGE_MODE_CUSTOM.equals(mode); - switch (sel) { - case "use_internal": - case "switch_internal": - if (!SharedPreferencesManager.STORAGE_MODE_INTERNAL.equals(mode)) { - prefs.setStorageMode(SharedPreferencesManager.STORAGE_MODE_INTERNAL); - prefs.setCustomStorageUri(null); - sendStorageChangedBroadcast(); - } - break; - case "select_custom": - case "change_custom": - launchDirectoryPicker(); - return; // wait for result - case "clear_custom": - prefs.setCustomStorageUri(null); - prefs.setStorageMode(SharedPreferencesManager.STORAGE_MODE_INTERNAL); - sendStorageChangedBroadcast(); - break; } - refreshValue(); - }); - String mode = prefs.getStorageMode(); - boolean custom = SharedPreferencesManager.STORAGE_MODE_CUSTOM.equals(mode); - java.util.ArrayList items = new java.util.ArrayList<>(); - String selectedId = null; - if (custom) { - items.add(new com.fadcam.ui.picker.OptionItem("switch_internal", - getString(R.string.storage_option_switch_internal))); - items.add(new com.fadcam.ui.picker.OptionItem("change_custom", - getString(R.string.storage_option_change_custom))); - items.add(new com.fadcam.ui.picker.OptionItem("clear_custom", - getString(R.string.storage_option_clear_custom))); - selectedId = "change_custom"; // highlight change option as current mode indicator - } else { - items.add(new com.fadcam.ui.picker.OptionItem("use_internal", - getString(R.string.storage_option_use_internal))); - items.add(new com.fadcam.ui.picker.OptionItem("select_custom", - getString(R.string.storage_option_select_custom))); - selectedId = "use_internal"; - } - String helper = getString(R.string.storage_helper_primary) + "\n" + getString(R.string.storage_helper_security); - if (custom) { - String uri = prefs.getCustomStorageUri(); - String displayName = buildDisplayPath(uri); - if (uri != null) { - helper += "\n\n" + getString(R.string.storage_helper_current_custom_prefix) + " " + displayName; - helper += "\n" + getString(R.string.storage_helper_current_custom_uri_prefix) + " " - + decodeTreeUriToReadablePath(uri); + if (StorageDestinationManager.DESTINATION_SD.equals(selected) + || StorageDestinationManager.DESTINATION_USB.equals(selected)) { + pendingDestination = selected; + launchExternalPicker(selected); } - } - com.fadcam.ui.picker.PickerBottomSheetFragment sheet = com.fadcam.ui.picker.PickerBottomSheetFragment - .newInstance( - getString(R.string.storage_sheet_title), items, selectedId, rk, helper); + }); + + String current = StorageDestinationManager.getDestination(requireContext(), prefs); + ArrayList items = new ArrayList<>(); + items.add(new com.fadcam.ui.picker.OptionItem( + StorageDestinationManager.DESTINATION_INTERNAL, + "Phone local storage")); + items.add(new com.fadcam.ui.picker.OptionItem( + StorageDestinationManager.DESTINATION_SD, + "SD card / memory card")); + items.add(new com.fadcam.ui.picker.OptionItem( + StorageDestinationManager.DESTINATION_USB, + "USB external storage")); + + String helper = "Choose where new recordings are written. The selected destination is used by camera, dual-camera and screen recording outputs.\n\n" + + "SD card and USB use Android's secure folder picker. FadCam stores the permission and performs a real write test before activating the destination.\n\n" + + "Android does not expose a universal USB-vs-SD flag on every phone, so the picker starts on the best matching removable volume when available. The returned volume is validated again before saving it.\n\n" + + "Current: " + StorageDestinationManager.getDisplayLabel(requireContext(), prefs); + + com.fadcam.ui.picker.PickerBottomSheetFragment sheet = + com.fadcam.ui.picker.PickerBottomSheetFragment.newInstance( + "Recording storage", items, current, RESULT_KEY, helper); sheet.show(getParentFragmentManager(), "storage_picker"); } - private void launchDirectoryPicker() { + private void launchExternalPicker(@NonNull String destination) { try { - openDocumentTreeLauncher.launch(null); + Intent intent = StorageDestinationManager.createPickerIntent(requireContext(), destination); + openDocumentTreeLauncher.launch(intent); } catch (Exception e) { - FLog.e(TAG, "Error launching directory picker", e); + FLog.e(TAG, "Failed to launch storage picker", e); + Toast.makeText(requireContext(), "Unable to open storage picker", Toast.LENGTH_SHORT).show(); } } - private String buildDisplayPath(String uriString) { - if (uriString == null) - return getString(R.string.storage_value_none); - try { - Uri treeUri = Uri.parse(uriString); - DocumentFile pickedDir = DocumentFile.fromTreeUri(requireContext(), treeUri); - if (pickedDir != null) { - String name = pickedDir.getName(); - if (name != null && !name.isEmpty()) - return name; - return getString(R.string.storage_value_selected_folder); - } - } catch (Exception e) { - FLog.e(TAG, "buildDisplayPath error", e); + private void activateExternalDestination(@NonNull Uri uri, int resultFlags) { + if (!StorageDestinationManager.isCompatibleExternalSelection( + requireContext(), pendingDestination, uri)) { + Toast.makeText( + requireContext(), + pendingDestination.equals(StorageDestinationManager.DESTINATION_USB) + ? "Select a writable USB/removable storage volume" + : "Select a writable SD/memory-card volume", + Toast.LENGTH_LONG).show(); + refreshValue(); + return; } - return getString(R.string.storage_value_selected_folder); - } - private String decodeTreeUriToReadablePath(String uriString) { - // method(decodeTreeUriToReadablePath)----------- - if (uriString == null) - return ""; - try { - // Typical SAF tree URI: - // content://com.android.externalstorage.documents/tree/primary%3ADownload%2FFadCam - int idx = uriString.indexOf("tree/"); - if (idx >= 0) { - String after = uriString.substring(idx + 5); // skip 'tree/' - // Decode percent encodings - String decoded = java.net.URLDecoder.decode(after, "UTF-8"); - // Map primary: to /storage/emulated/0/ - if (decoded.startsWith("primary:")) { - decoded = decoded.replaceFirst("primary:", "/storage/emulated/0/"); - } - // Ensure leading slash if not present - if (!decoded.startsWith("/")) - decoded = "/" + decoded; - return decoded; - } - } catch (Exception e) { - FLog.e(TAG, "decodeTreeUriToReadablePath error", e); + final int persistableFlags = resultFlags + & (Intent.FLAG_GRANT_READ_URI_PERMISSION | Intent.FLAG_GRANT_WRITE_URI_PERMISSION); + if ((persistableFlags & Intent.FLAG_GRANT_WRITE_URI_PERMISSION) == 0) { + FLog.e(TAG, "Selected storage did not return a persistable write grant"); + Toast.makeText(requireContext(), "FadCam could not obtain write access to that storage", Toast.LENGTH_LONG).show(); + return; } - return uriString; - // method(decodeTreeUriToReadablePath)----------- - } - private void sendStorageChangedBroadcast() { - // method(sendStorageChangedBroadcast)----------- - if (getContext() == null) - return; try { - Intent intent = new Intent(Constants.ACTION_STORAGE_LOCATION_CHANGED); - requireContext().sendBroadcast(intent); - FLog.i(TAG, "Successfully sent ACTION_STORAGE_LOCATION_CHANGED broadcast."); + requireContext().getContentResolver().takePersistableUriPermission(uri, persistableFlags); + } catch (SecurityException e) { + FLog.e(TAG, "Selected storage did not grant persistable access", e); + Toast.makeText(requireContext(), "FadCam could not keep access to that storage", Toast.LENGTH_LONG).show(); + return; + } - // Also try a direct refresh approach as fallback - refreshRecordsFragmentDirect(); - } catch (Exception e) { - FLog.e(TAG, "Broadcast error", e); + if (!StorageDestinationManager.setExternal( + requireContext(), prefs, pendingDestination, uri.toString())) { + Toast.makeText(requireContext(), "The selected storage is not writable", Toast.LENGTH_LONG).show(); + return; } - // method(sendStorageChangedBroadcast)----------- + + StorageInfoCache.clearCache(); + sendStorageChangedBroadcast(); + refreshValue(); + Toast.makeText( + requireContext(), + pendingDestination.equals(StorageDestinationManager.DESTINATION_USB) + ? "USB storage is now the recording destination" + : "SD card is now the recording destination", + Toast.LENGTH_SHORT).show(); } - /** - * Direct method to refresh RecordsFragment when storage location changes - */ - private void refreshRecordsFragmentDirect() { + private void sendStorageChangedBroadcast() { try { - // Add a small delay to ensure storage change is processed - new android.os.Handler(android.os.Looper.getMainLooper()).postDelayed(() -> { - if (getActivity() instanceof com.fadcam.MainActivity) { - com.fadcam.MainActivity mainActivity = (com.fadcam.MainActivity) getActivity(); - - // Try to find RecordsFragment - String[] possibleTags = { "f0", "f1", "f2" }; - boolean refreshSuccess = false; - - for (String tag : possibleTags) { - androidx.fragment.app.Fragment fragment = mainActivity.getSupportFragmentManager() - .findFragmentByTag(tag); - if (fragment instanceof com.fadcam.ui.RecordsFragment) { - ((com.fadcam.ui.RecordsFragment) fragment).refreshList(); - FLog.i(TAG, "Successfully refreshed RecordsFragment after storage change with tag: " + tag); - refreshSuccess = true; - break; - } - } - - // Try iteration if tag method failed - if (!refreshSuccess) { - for (androidx.fragment.app.Fragment fragment : mainActivity.getSupportFragmentManager() - .getFragments()) { - if (fragment instanceof com.fadcam.ui.RecordsFragment) { - ((com.fadcam.ui.RecordsFragment) fragment).refreshList(); - FLog.i(TAG, "Successfully refreshed RecordsFragment after storage change by iteration."); - refreshSuccess = true; - break; - } - } - } - - if (!refreshSuccess) { - FLog.w(TAG, "Could not find RecordsFragment to refresh after storage change."); - } - - // Also refresh HomeFragment stats - refreshHomeFragmentStats(mainActivity); - } - }, 200); // 200ms delay to ensure storage change is processed + requireContext().sendBroadcast(new Intent(Constants.ACTION_STORAGE_LOCATION_CHANGED)); + refreshRecordsAndHome(); } catch (Exception e) { - FLog.e(TAG, "Failed to refresh RecordsFragment directly after storage change", e); + FLog.e(TAG, "Storage destination change broadcast failed", e); } } - /** - * Refreshes the HomeFragment stats widget after storage location changes - */ - private void refreshHomeFragmentStats(com.fadcam.MainActivity mainActivity) { + private void refreshRecordsAndHome() { try { - // Try to find HomeFragment - String[] possibleTags = {"f0", "f1", "f2"}; // Home could be at different positions - boolean refreshSuccess = false; - - for (String tag : possibleTags) { - androidx.fragment.app.Fragment fragment = mainActivity.getSupportFragmentManager().findFragmentByTag(tag); - if (fragment instanceof com.fadcam.ui.HomeFragment) { - ((com.fadcam.ui.HomeFragment) fragment).refreshStats(); - FLog.i(TAG, "Successfully refreshed HomeFragment stats with tag: " + tag); - refreshSuccess = true; - break; - } - } - - // Try iteration if tag method failed - if (!refreshSuccess) { - for (androidx.fragment.app.Fragment fragment : mainActivity.getSupportFragmentManager().getFragments()) { + new android.os.Handler(android.os.Looper.getMainLooper()).postDelayed(() -> { + if (!(getActivity() instanceof com.fadcam.MainActivity)) return; + com.fadcam.MainActivity activity = (com.fadcam.MainActivity) getActivity(); + for (androidx.fragment.app.Fragment fragment : activity.getSupportFragmentManager().getFragments()) { + if (fragment instanceof com.fadcam.ui.RecordsFragment) { + ((com.fadcam.ui.RecordsFragment) fragment).refreshList(); + } if (fragment instanceof com.fadcam.ui.HomeFragment) { ((com.fadcam.ui.HomeFragment) fragment).refreshStats(); - FLog.i(TAG, "Successfully refreshed HomeFragment stats by iteration."); - refreshSuccess = true; - break; } } - } - - if (!refreshSuccess) { - FLog.w(TAG, "Could not find HomeFragment to refresh stats after storage change."); - } + }, 200); } catch (Exception e) { - FLog.e(TAG, "Failed to refresh HomeFragment stats", e); + FLog.e(TAG, "Failed to refresh storage-dependent screens", e); } } } diff --git a/app/src/main/java/com/fadcam/utils/StorageDestinationManager.java b/app/src/main/java/com/fadcam/utils/StorageDestinationManager.java new file mode 100644 index 000000000..daf7f5e97 --- /dev/null +++ b/app/src/main/java/com/fadcam/utils/StorageDestinationManager.java @@ -0,0 +1,315 @@ +package com.fadcam.utils; + +import android.content.Context; +import android.content.Intent; +import android.net.Uri; +import android.os.Build; +import android.os.Environment; +import android.os.storage.StorageManager; +import android.os.storage.StorageVolume; +import android.provider.DocumentsContract; + +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; +import androidx.documentfile.provider.DocumentFile; + +import com.fadcam.FLog; +import com.fadcam.SharedPreferencesManager; + +import java.io.OutputStream; +import java.nio.charset.StandardCharsets; +import java.util.List; +import java.util.Locale; + +/** + * Single source of truth for the recording destination selected by the user. + * External destinations use a persistable SAF tree URI, which the recording + * pipeline can use for all output categories. + */ +public final class StorageDestinationManager { + + private static final String TAG = "StorageDestinationManager"; + private static final String PREF_DESTINATION = "storage_destination_type"; + private static final String WRITE_PROBE_NAME = ".fadcam_write_probe"; + + public static final String DESTINATION_INTERNAL = "internal"; + public static final String DESTINATION_SD = "sd_card"; + public static final String DESTINATION_USB = "usb"; + + private StorageDestinationManager() { + } + + @NonNull + public static String getDestination(@NonNull Context context, @NonNull SharedPreferencesManager prefs) { + String stored = prefs.sharedPreferences.getString(PREF_DESTINATION, null); + String uri = prefs.getCustomStorageUri(); + if (isExternalDestination(stored) && isWritableTree(context, uri)) { + return stored; + } + if (SharedPreferencesManager.STORAGE_MODE_CUSTOM.equals(prefs.getStorageMode()) + && isWritableTree(context, uri)) { + return classifyLegacyExternal(context, uri); + } + return DESTINATION_INTERNAL; + } + + public static void setInternal(@NonNull SharedPreferencesManager prefs) { + prefs.setCustomStorageUri(null); + prefs.setStorageMode(SharedPreferencesManager.STORAGE_MODE_INTERNAL); + prefs.sharedPreferences.edit().putString(PREF_DESTINATION, DESTINATION_INTERNAL).apply(); + } + + public static boolean setExternal( + @NonNull Context context, + @NonNull SharedPreferencesManager prefs, + @NonNull String destination, + @NonNull String uriString + ) { + if (!isExternalDestination(destination) || !isWritableTree(context, uriString)) return false; + prefs.setCustomStorageUri(uriString); + prefs.setStorageMode(SharedPreferencesManager.STORAGE_MODE_CUSTOM); + prefs.sharedPreferences.edit().putString(PREF_DESTINATION, destination).apply(); + return true; + } + + public static boolean isExternalDestination(@Nullable String destination) { + return DESTINATION_SD.equals(destination) || DESTINATION_USB.equals(destination); + } + + public static boolean isWritableTree(@NonNull Context context, @Nullable String uriString) { + if (uriString == null || uriString.trim().isEmpty()) return false; + try { + DocumentFile tree = DocumentFile.fromTreeUri(context, Uri.parse(uriString)); + return tree != null && tree.exists() && tree.isDirectory() && tree.canWrite(); + } catch (Exception ignored) { + return false; + } + } + + /** + * Performs a real, reversible write against the selected SAF tree. This + * catches providers that report canWrite() but fail when an app actually + * creates a file, before the destination is committed as active storage. + */ + public static boolean probeWritableTree(@NonNull Context context, @Nullable String uriString) { + if (!isWritableTree(context, uriString)) return false; + DocumentFile probe = null; + try { + DocumentFile tree = DocumentFile.fromTreeUri(context, Uri.parse(uriString)); + if (tree == null) return false; + probe = tree.findFile(WRITE_PROBE_NAME); + if (probe != null) { + if (!probe.delete()) return false; + probe = null; + } + probe = tree.createFile("application/octet-stream", WRITE_PROBE_NAME); + if (probe == null || !probe.canWrite()) return false; + try (OutputStream output = context.getContentResolver().openOutputStream(probe.getUri(), "w")) { + if (output == null) return false; + output.write("FadCam".getBytes(StandardCharsets.UTF_8)); + output.flush(); + } + return true; + } catch (Exception e) { + FLog.w(TAG, "Storage write probe failed", e); + return false; + } finally { + try { + if (probe != null && probe.exists()) probe.delete(); + } catch (Exception ignored) { + } + } + } + + @NonNull + public static String getDisplayLabel( + @NonNull Context context, + @NonNull SharedPreferencesManager prefs + ) { + String destination = getDestination(context, prefs); + if (DESTINATION_INTERNAL.equals(destination)) return "Phone local storage"; + String folder = "External folder"; + String uri = prefs.getCustomStorageUri(); + if (uri != null) { + try { + DocumentFile tree = DocumentFile.fromTreeUri(context, Uri.parse(uri)); + if (tree != null && tree.getName() != null && !tree.getName().trim().isEmpty()) { + folder = tree.getName(); + } + } catch (Exception ignored) { + } + } + return (DESTINATION_USB.equals(destination) ? "USB storage" : "SD card") + " • " + folder; + } + + /** + * Opens the system folder picker at a likely removable volume. Android + * intentionally does not expose a universal USB-vs-SD property, so the + * selected URI is validated again after the user returns from the picker. + */ + @NonNull + public static Intent createPickerIntent(@NonNull Context context, @NonNull String requestedDestination) { + StorageVolume preferred = findPreferredRemovableVolume(context, requestedDestination); + if (preferred != null && Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) { + try { + Intent intent = preferred.createOpenDocumentTreeIntent(); + intent.addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION + | Intent.FLAG_GRANT_WRITE_URI_PERMISSION + | Intent.FLAG_GRANT_PERSISTABLE_URI_PERMISSION + | Intent.FLAG_GRANT_PREFIX_URI_PERMISSION); + return intent; + } catch (Exception e) { + FLog.w(TAG, "Could not open picker on preferred volume", e); + } + } + Intent intent = new Intent(Intent.ACTION_OPEN_DOCUMENT_TREE); + intent.addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION + | Intent.FLAG_GRANT_WRITE_URI_PERMISSION + | Intent.FLAG_GRANT_PERSISTABLE_URI_PERMISSION + | Intent.FLAG_GRANT_PREFIX_URI_PERMISSION); + return intent; + } + + /** + * Requires a writable removable volume. Android's StorageVolume metadata is + * not consistent across OEM USB/OTG implementations, so an unresolved + * non-primary document provider is accepted after the real write probe. + */ + public static boolean isCompatibleExternalSelection( + @NonNull Context context, + @NonNull String requestedDestination, + @NonNull Uri uri + ) { + if (!isExternalDestination(requestedDestination) + || !isWritableTree(context, uri.toString()) + || !probeWritableTree(context, uri.toString())) { + return false; + } + + VolumeInfo info = resolveVolume(context, uri); + if (info == null) { + return !isPrimaryTreeUri(uri); + } + if (!info.removable) return false; + if (info.kind == Kind.USB && DESTINATION_SD.equals(requestedDestination)) return false; + if (info.kind == Kind.SD && DESTINATION_USB.equals(requestedDestination)) return false; + return true; + } + + private static boolean isPrimaryTreeUri(@NonNull Uri uri) { + try { + String documentId = DocumentsContract.getTreeDocumentId(uri); + return documentId != null && documentId.toLowerCase(Locale.ROOT).startsWith("primary:"); + } catch (Exception ignored) { + return false; + } + } + + @Nullable + private static StorageVolume findPreferredRemovableVolume( + @NonNull Context context, + @NonNull String requestedDestination + ) { + if (Build.VERSION.SDK_INT < Build.VERSION_CODES.N) return null; + StorageManager manager = (StorageManager) context.getSystemService(Context.STORAGE_SERVICE); + if (manager == null) return null; + try { + List volumes = manager.getStorageVolumes(); + StorageVolume fallback = null; + for (StorageVolume volume : volumes) { + if (volume == null || volume.isPrimary() || !volume.isRemovable()) continue; + if (!Environment.MEDIA_MOUNTED.equals(volume.getState())) continue; + if (fallback == null) fallback = volume; + Kind kind = classifyVolumeDescription(context, volume); + if (DESTINATION_USB.equals(requestedDestination) && kind == Kind.USB) return volume; + if (DESTINATION_SD.equals(requestedDestination) && kind == Kind.SD) return volume; + } + return fallback; + } catch (Exception e) { + FLog.w(TAG, "Unable to enumerate removable storage volumes", e); + return null; + } + } + + @Nullable + private static VolumeInfo resolveVolume(@NonNull Context context, @NonNull Uri treeUri) { + if (Build.VERSION.SDK_INT < Build.VERSION_CODES.N) return null; + String documentId; + try { + documentId = DocumentsContract.getTreeDocumentId(treeUri); + } catch (Exception e) { + return null; + } + if (documentId == null) return null; + String volumeId = documentId.contains(":") + ? documentId.substring(0, documentId.indexOf(':')) + : documentId; + StorageManager manager = (StorageManager) context.getSystemService(Context.STORAGE_SERVICE); + if (manager == null) return null; + try { + for (StorageVolume volume : manager.getStorageVolumes()) { + if (volume == null) continue; + String uuid = volume.getUuid(); + if (uuid != null && uuid.equalsIgnoreCase(volumeId)) { + return new VolumeInfo(volume.isRemovable(), classifyVolumeDescription(context, volume)); + } + if ("primary".equalsIgnoreCase(volumeId) && volume.isPrimary()) { + return new VolumeInfo(volume.isRemovable(), Kind.PRIMARY); + } + } + } catch (Exception e) { + FLog.w(TAG, "Unable to resolve storage volume for URI", e); + } + return null; + } + + @NonNull + private static Kind classifyVolumeDescription(@NonNull Context context, @NonNull StorageVolume volume) { + String description = ""; + try { + description = String.valueOf(volume.getDescription(context)).toLowerCase(Locale.ROOT); + } catch (Exception ignored) { + } + if (containsAny(description, "usb", "flash", "thumb drive", "pendrive", "pen drive", "otg")) { + return Kind.USB; + } + if (containsAny(description, "sd", "memory card", "microsd", "micro sd", "card")) { + return Kind.SD; + } + return Kind.UNKNOWN; + } + + @NonNull + private static String classifyLegacyExternal(@NonNull Context context, @NonNull String uriString) { + try { + VolumeInfo info = resolveVolume(context, Uri.parse(uriString)); + if (info != null && info.kind == Kind.USB) return DESTINATION_USB; + } catch (Exception ignored) { + } + return DESTINATION_SD; + } + + private static boolean containsAny(@NonNull String value, @NonNull String... needles) { + for (String needle : needles) { + if (value.contains(needle)) return true; + } + return false; + } + + private enum Kind { + PRIMARY, + SD, + USB, + UNKNOWN + } + + private static final class VolumeInfo { + final boolean removable; + final Kind kind; + + VolumeInfo(boolean removable, Kind kind) { + this.removable = removable; + this.kind = kind; + } + } +} diff --git a/app/src/main/java/com/fadcam/utils/StorageInfoCache.java b/app/src/main/java/com/fadcam/utils/StorageInfoCache.java index 3ae841817..c9e058408 100644 --- a/app/src/main/java/com/fadcam/utils/StorageInfoCache.java +++ b/app/src/main/java/com/fadcam/utils/StorageInfoCache.java @@ -1,16 +1,17 @@ package com.fadcam.utils; -import com.fadcam.Log; import com.fadcam.FLog; import android.content.Context; +import android.os.Build; import android.os.Environment; import android.os.StatFs; +import android.os.storage.StorageManager; +import android.os.storage.StorageVolume; import androidx.documentfile.provider.DocumentFile; import com.fadcam.SharedPreferencesManager; -import com.fadcam.Utils; import java.io.File; -import java.util.concurrent.atomic.AtomicLong; +import java.util.List; /** * Utility class for caching storage information to improve performance @@ -18,149 +19,119 @@ */ public class StorageInfoCache { private static final String TAG = "StorageInfoCache"; - private static final long CACHE_VALIDITY_MS = 30_000; // 30 seconds - - // Cache storage data + private static final long CACHE_VALIDITY_MS = 30_000; + private static long lastCacheTime = 0; private static long cachedAvailableBytes = -1; private static long cachedTotalBytes = -1; private static boolean cachedUsingCustomStorage = false; private static boolean cachedCustomIsOnPrimary = true; - - // Thread-safe access + private static final Object cacheLock = new Object(); - + public static class StorageInfo { public final long availableBytes; public final long totalBytes; public final boolean usingCustomStorage; public final boolean customIsOnPrimary; - - public StorageInfo(long availableBytes, long totalBytes, + + public StorageInfo(long availableBytes, long totalBytes, boolean usingCustomStorage, boolean customIsOnPrimary) { this.availableBytes = availableBytes; this.totalBytes = totalBytes; this.usingCustomStorage = usingCustomStorage; this.customIsOnPrimary = customIsOnPrimary; } - + public double getAvailableGB() { return availableBytes / (1024.0 * 1024.0 * 1024.0); } - + public double getTotalGB() { return totalBytes / (1024.0 * 1024.0 * 1024.0); } } - - /** - * Get cached storage info if valid, otherwise null - */ + public static StorageInfo getCachedStorageInfo() { synchronized (cacheLock) { if (isCacheValid()) { - return new StorageInfo(cachedAvailableBytes, cachedTotalBytes, - cachedUsingCustomStorage, cachedCustomIsOnPrimary); + return new StorageInfo(cachedAvailableBytes, cachedTotalBytes, + cachedUsingCustomStorage, cachedCustomIsOnPrimary); } return null; } } - - /** - * Calculate and cache storage information - */ - public static StorageInfo calculateAndCacheStorageInfo(Context context, + + public static StorageInfo calculateAndCacheStorageInfo(Context context, SharedPreferencesManager prefsManager) { - - // Default to internal external storage stats StatFs stat = new StatFs(Environment.getExternalStorageDirectory().getPath()); long bytesAvailable = stat.getAvailableBytes(); long bytesTotal = stat.getTotalBytes(); - - // Check if using custom storage + String storageMode = prefsManager.getStorageMode(); String customUriString = prefsManager.getCustomStorageUri(); - boolean usingCustomStorage = SharedPreferencesManager.STORAGE_MODE_CUSTOM.equals(storageMode) - && customUriString != null; + boolean usingCustomStorage = SharedPreferencesManager.STORAGE_MODE_CUSTOM.equals(storageMode) + && customUriString != null; boolean customIsOnPrimary = true; - + if (usingCustomStorage) { try { android.net.Uri treeUri = android.net.Uri.parse(customUriString); - - // Heuristics for determining if custom storage is on primary volume + String docId = null; try { - String docId = android.provider.DocumentsContract.getTreeDocumentId(treeUri); - if (docId != null) { - if (docId.startsWith("primary:")) { - customIsOnPrimary = true; - } else if (docId.startsWith("raw:")) { - String rawPath = docId.substring("raw:".length()); - customIsOnPrimary = rawPath.startsWith( - Environment.getExternalStorageDirectory().getAbsolutePath()); - } else if (docId.contains(":")) { - String volumeId = docId.split(":", 2)[0]; - customIsOnPrimary = "primary".equalsIgnoreCase(volumeId); - } - } - } catch (Exception ignore) { - // best-effort only + docId = android.provider.DocumentsContract.getTreeDocumentId(treeUri); + } catch (Exception ignored) { } - - // Try to get actual storage stats for custom storage - if (hasSafPermission(context, treeUri)) { - java.io.File probe = Utils.getFileFromSafUriIfPossible(context, treeUri); - if (probe != null && probe.exists()) { - StatFs customStat = new StatFs(probe.getAbsolutePath()); - bytesAvailable = customStat.getAvailableBytes(); - bytesTotal = customStat.getTotalBytes(); - } else { - String docId = null; - try { - docId = android.provider.DocumentsContract.getTreeDocumentId(treeUri); - } catch (Exception ignored) { - } - File resolvedVolumePath = resolvePathFromTreeDocId(context, docId); - if (resolvedVolumePath != null && resolvedVolumePath.exists()) { - StatFs customStat = new StatFs(resolvedVolumePath.getAbsolutePath()); - bytesAvailable = customStat.getAvailableBytes(); - bytesTotal = customStat.getTotalBytes(); - } + + customIsOnPrimary = isPrimaryDocumentId(docId); + + File customPath = resolveStoragePath(context, docId); + if (customPath != null && customPath.exists()) { + StatFs customStat = new StatFs(customPath.getAbsolutePath()); + bytesAvailable = customStat.getAvailableBytes(); + bytesTotal = customStat.getTotalBytes(); + } else if (!hasSafPermission(context, treeUri)) { + // A stale custom URI must never be reported as the active + // storage destination after its persisted grant disappears. + usingCustomStorage = false; + customIsOnPrimary = true; + } else { + DocumentFile tree = DocumentFile.fromTreeUri(context, treeUri); + if (tree == null || !tree.exists() || !tree.canWrite()) { + usingCustomStorage = false; + customIsOnPrimary = true; } } } catch (Exception e) { FLog.e(TAG, "Error probing custom storage", e); + usingCustomStorage = false; + customIsOnPrimary = true; } } - - // Cache the results + synchronized (cacheLock) { cachedAvailableBytes = bytesAvailable; cachedTotalBytes = bytesTotal; cachedUsingCustomStorage = usingCustomStorage; cachedCustomIsOnPrimary = customIsOnPrimary; lastCacheTime = System.currentTimeMillis(); - - FLog.d(TAG, "Storage info cached - Available: " + (bytesAvailable / (1024.0 * 1024.0 * 1024.0)) - + " GB, Total: " + (bytesTotal / (1024.0 * 1024.0 * 1024.0)) + " GB"); + + FLog.d(TAG, "Storage info cached - Available: " + + (bytesAvailable / (1024.0 * 1024.0 * 1024.0)) + + " GB, Total: " + + (bytesTotal / (1024.0 * 1024.0 * 1024.0)) + " GB"); } - + return new StorageInfo(bytesAvailable, bytesTotal, usingCustomStorage, customIsOnPrimary); } - - /** - * Check if current cache is still valid - */ + public static boolean isCacheValid() { synchronized (cacheLock) { - return (System.currentTimeMillis() - lastCacheTime) < CACHE_VALIDITY_MS - && cachedAvailableBytes >= 0; + return (System.currentTimeMillis() - lastCacheTime) < CACHE_VALIDITY_MS + && cachedAvailableBytes >= 0; } } - - /** - * Clear the cache to force recalculation - */ + public static void clearCache() { synchronized (cacheLock) { lastCacheTime = 0; @@ -168,13 +139,11 @@ public static void clearCache() { cachedTotalBytes = -1; } } - - /** - * Check if we have SAF permission for the given URI - */ + private static boolean hasSafPermission(Context context, android.net.Uri treeUri) { try { - for (android.content.UriPermission permission : context.getContentResolver().getPersistedUriPermissions()) { + for (android.content.UriPermission permission + : context.getContentResolver().getPersistedUriPermissions()) { if (permission.getUri().equals(treeUri) && permission.isWritePermission()) { return true; } @@ -185,7 +154,16 @@ private static boolean hasSafPermission(Context context, android.net.Uri treeUri return false; } - private static File resolvePathFromTreeDocId(Context context, String docId) { + private static boolean isPrimaryDocumentId(String docId) { + return docId != null && docId.toLowerCase(java.util.Locale.ROOT).startsWith("primary:"); + } + + /** + * Resolve a SAF tree document ID to the backing volume when Android exposes + * the volume directory. This makes the home storage card follow an + * SD/USB destination instead of continuing to show phone capacity. + */ + private static File resolveStoragePath(Context context, String docId) { if (docId == null || !docId.contains(":")) return null; String[] parts = docId.split(":", 2); if (parts.length < 2) return null; @@ -197,14 +175,34 @@ private static File resolvePathFromTreeDocId(Context context, String docId) { return relative.isEmpty() ? root : new File(root, relative); } + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) { + StorageManager manager = (StorageManager) context.getSystemService(Context.STORAGE_SERVICE); + if (manager != null) { + try { + List volumes = manager.getStorageVolumes(); + for (StorageVolume volume : volumes) { + if (volume == null) continue; + String uuid = volume.getUuid(); + if (uuid != null && uuid.equalsIgnoreCase(volumeId)) { + File root = volume.getDirectory(); + if (root != null) { + return relative.isEmpty() ? root : new File(root, relative); + } + } + } + } catch (Exception e) { + FLog.w(TAG, "Unable to resolve StorageVolume directory", e); + } + } + } + File[] externalDirs = context.getExternalFilesDirs(null); if (externalDirs == null) return null; for (File dir : externalDirs) { if (dir == null) continue; String path = dir.getAbsolutePath(); String marker = "/storage/" + volumeId + "/"; - int idx = path.indexOf(marker); - if (idx >= 0) { + if (path.contains(marker)) { File root = new File("/storage/" + volumeId); return relative.isEmpty() ? root : new File(root, relative); } diff --git a/app/src/main/res/layout/activity_studio.xml b/app/src/main/res/layout/activity_studio.xml new file mode 100644 index 000000000..36e3a9220 --- /dev/null +++ b/app/src/main/res/layout/activity_studio.xml @@ -0,0 +1,101 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +