Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e362c0985f | ||
|
|
fa4d2a4991 | ||
|
|
d5f7c5d7ac | ||
|
|
841d336cb8 | ||
|
|
ea7dfe6e1e | ||
|
|
0ed0ebf22a | ||
|
|
7e00de3ae9 | ||
|
|
ad3af474e9 | ||
|
|
088501513a | ||
|
|
c64e6fc519 | ||
|
|
5f77da93d1 | ||
|
|
6b87b6551e | ||
|
|
e3cdc2039e | ||
|
|
f695f7b192 | ||
|
|
c45113fefe | ||
|
|
b202c62641 | ||
|
|
db593e1255 | ||
|
|
41c6389cd4 | ||
|
|
bae6055123 | ||
|
|
3515eaf9b4 | ||
|
|
214eeb8aec | ||
|
|
41a2e996e1 | ||
|
|
8e1904ddee | ||
|
|
c48cbf7059 | ||
|
|
de4ed3cd2e | ||
|
|
468d680484 | ||
|
|
78eedd21d3 | ||
|
|
e7f97cc090 | ||
|
|
01d7d523aa | ||
|
|
ef310459e5 | ||
|
|
0c58b2dc55 | ||
|
|
fcda012468 | ||
|
|
a893e1859c | ||
|
|
35f7a12d7c | ||
|
|
100d7729df | ||
|
|
7677d1594c | ||
|
|
15b54e2ec6 | ||
|
|
6c14049e95 | ||
|
|
a56b9a3dfb | ||
|
|
9e8cfbc7dd | ||
|
|
7e05686a7c | ||
|
|
5ee3120a97 | ||
|
|
60cc983471 | ||
|
|
1fea9ef6e3 | ||
|
|
8ef977d890 | ||
|
|
ecc55d9b36 | ||
|
|
f5661c766e | ||
|
|
829d3745a1 | ||
|
|
71735354b9 | ||
|
|
13179f08cd | ||
|
|
eb3ed45af1 | ||
|
|
9429a76acc | ||
|
|
4eaf4423a1 | ||
|
|
c68ffe9ed0 | ||
|
|
83f4e2a263 | ||
|
|
7416327d4b | ||
|
|
316a69a1de | ||
|
|
352b88109b | ||
|
|
61344f7862 | ||
|
|
04d8070fd4 | ||
|
|
4307c49cd2 | ||
|
|
fcaa81bf3c | ||
|
|
ef183437d4 | ||
|
|
7c6eaac2d0 | ||
|
|
3b1e71b3fa | ||
|
|
2a25f21e27 | ||
|
|
e251273322 | ||
|
|
1b3c74550f | ||
|
|
c9543d4af0 | ||
|
|
12b826faff | ||
|
|
516bd69c19 | ||
|
|
005a56db03 | ||
|
|
1ae58f676b | ||
|
|
6710c87892 | ||
|
|
96fd47a160 | ||
|
|
c1cfb9609e | ||
|
|
cd6716df47 | ||
|
|
5c221468d6 | ||
|
|
77da82b648 | ||
|
|
39ddfd2bd9 | ||
|
|
0717009ade | ||
|
|
f8f366a513 | ||
|
|
4af77f4696 | ||
|
|
73f4a3e055 | ||
|
|
0959cd3040 | ||
|
|
9a6f48a4cb | ||
|
|
fb27621c75 | ||
|
|
2b3853a882 | ||
|
|
0643787fbe | ||
|
|
b7d5922b0a | ||
|
|
c7b9eec087 | ||
|
|
db7bc17c49 | ||
|
|
d1cec00b51 | ||
|
|
102ef46d00 | ||
|
|
d6b24c3e07 | ||
|
|
4cc9a35b2e | ||
|
|
40833e65ec | ||
|
|
6c03094ef1 | ||
|
|
269bb7a322 | ||
|
|
54273ab09c | ||
|
|
ef0524485a | ||
|
|
071b632c93 | ||
|
|
62330413ee | ||
|
|
aa66f9a15d | ||
|
|
7ed2049a01 | ||
|
|
a961a5a997 | ||
|
|
ad7ef112fc | ||
|
|
0c6dab01b3 | ||
|
|
3c2a353d32 | ||
|
|
54a002f9b7 | ||
|
|
fb45ff44f3 | ||
|
|
9f2e050797 | ||
|
|
09656fa4d3 | ||
|
|
b41fb4fe75 | ||
|
|
f8bfecf57d | ||
|
|
43a8fe020d | ||
|
|
e0bf66eea8 | ||
|
|
658da37b48 | ||
|
|
1d1c301f61 | ||
|
|
d98e85614a | ||
|
|
5fb167898e | ||
|
|
0283c3bfff | ||
|
|
e32fcc2190 | ||
|
|
f8cb550be7 | ||
|
|
0707d4d9b1 | ||
|
|
46dfc7f992 | ||
|
|
a1b6a89714 | ||
|
|
70cd7e8c67 | ||
|
|
5b90c4bca0 | ||
|
|
6df8aba097 | ||
|
|
a244d249bd | ||
|
|
ac9a5914a2 | ||
|
|
b7cd4e5e9c | ||
|
|
ff602f1456 | ||
|
|
517c3f57ce | ||
|
|
d253450e9d | ||
|
|
fa901be328 | ||
|
|
74cae73844 | ||
|
|
0bcd00bcb6 | ||
|
|
54a4b8e414 | ||
|
|
87c0547090 | ||
|
|
9471e30585 | ||
|
|
ea4bd21977 | ||
|
|
3168f6327f | ||
|
|
139cf83507 | ||
|
|
ddb7520ce8 | ||
|
|
abf1027b77 | ||
|
|
9d812fa3cc | ||
|
|
8c88385977 | ||
|
|
30c978719b | ||
|
|
12eb9b71b6 | ||
|
|
0cb19a4e30 | ||
|
|
991aee6c90 | ||
|
|
cbb0a01c5f | ||
|
|
d86e820a16 | ||
|
|
1bdd76d95a | ||
|
|
0a3e9d9e89 | ||
|
|
a0013323f2 | ||
|
|
d5f3e581df | ||
|
|
ec249d2ed4 | ||
|
|
d6ad6a9506 | ||
|
|
7400a10c59 | ||
|
|
dfeffad044 | ||
|
|
b0c2827eb4 |
@@ -0,0 +1,7 @@
|
||||
**
|
||||
!.maestro/
|
||||
!.maestro/jellyfin-demo/
|
||||
!.maestro/jellyfin-demo/**
|
||||
!scripts/
|
||||
!scripts/maestro_fixtures.py
|
||||
!scripts/maestro_real_jellyfin.py
|
||||
@@ -1,3 +1,17 @@
|
||||
*.sh text eol=lf
|
||||
*.Dockerfile text eol=lf
|
||||
Dockerfile text eol=lf
|
||||
*.bat text eol=lf
|
||||
|
||||
# Byte-exact generated Dart checked by scripts/check_codegen.py.
|
||||
lib/data/ducet_order.dart text eol=lf
|
||||
lib/data/hid_key_labels.dart text eol=lf
|
||||
lib/data/iso_639_data.dart text eol=lf
|
||||
lib/**/*.g.dart text eol=lf
|
||||
lib/**/*.freezed.dart text eol=lf
|
||||
|
||||
# Byte-exact wakelock inputs hashed in packages/wakelock_plus/provenance.json.
|
||||
packages/wakelock_plus/pigeons/messages.dart text eol=lf
|
||||
packages/wakelock_plus/android/src/main/kotlin/dev/fluttercommunity/plus/wakelock/WakelockPlusMessages.g.kt text eol=lf
|
||||
packages/wakelock_plus/ios/wakelock_plus/Sources/wakelock_plus/include/wakelock_plus/messages.g.h text eol=lf
|
||||
packages/wakelock_plus/ios/wakelock_plus/Sources/wakelock_plus/messages.g.m text eol=lf
|
||||
|
||||
@@ -0,0 +1,46 @@
|
||||
name: Set up Flutter from git
|
||||
description: >-
|
||||
Clone the pinned Flutter SDK from its release tag and put it on PATH, for
|
||||
runners without a published archive. Flutter ships no windows-arm64 SDK, so
|
||||
subosito/flutter-action cannot resolve the release for arm64 (no arm64 entry
|
||||
in the stable manifest) and `channel: master` would clone master HEAD, whose
|
||||
engine is not the patched revision install-patched-engine.ps1 asserts
|
||||
(4c525dac). This file is the only place that pin lives: the tag is fetched so
|
||||
the SDK reports its own version, then verified against the immutable commit
|
||||
and against the version Flutter itself reports, so a moved tag fails the job
|
||||
instead of quietly changing SDKs.
|
||||
|
||||
runs:
|
||||
using: composite
|
||||
steps:
|
||||
- name: Clone Flutter from its immutable commit
|
||||
shell: pwsh
|
||||
run: |
|
||||
$version = "3.44.0"
|
||||
$expectedCommit = "559ffa3f75e7402d65a8def9c28389a9b2e6fe42"
|
||||
$root = "$env:RUNNER_TEMP\flutter"
|
||||
git init $root
|
||||
git -C $root remote add origin https://github.com/flutter/flutter.git
|
||||
git -C $root fetch --depth 1 origin "refs/tags/${version}:refs/tags/${version}"
|
||||
git -C $root checkout --detach "refs/tags/$version"
|
||||
$actualCommit = git -C $root rev-parse HEAD
|
||||
if ($LASTEXITCODE -ne 0 -or $actualCommit -ne $expectedCommit) {
|
||||
throw "Flutter $version resolved to $actualCommit, expected $expectedCommit"
|
||||
}
|
||||
"$root\bin" | Out-File -FilePath $env:GITHUB_PATH -Append -Encoding utf8
|
||||
& "$root\bin\flutter.bat" --version
|
||||
if ($LASTEXITCODE -ne 0) {
|
||||
throw "Unable to bootstrap the Flutter SDK"
|
||||
}
|
||||
$versionOutput = & "$root\bin\flutter.bat" --version --machine
|
||||
if ($LASTEXITCODE -ne 0) {
|
||||
throw "Unable to resolve the Flutter SDK version"
|
||||
}
|
||||
$versionJson = $versionOutput -join "`n"
|
||||
if ([string]::IsNullOrWhiteSpace($versionJson)) {
|
||||
throw "Flutter did not report machine-readable version JSON"
|
||||
}
|
||||
$reportedVersion = ($versionJson | ConvertFrom-Json).frameworkVersion
|
||||
if ($reportedVersion -ne $version) {
|
||||
throw "Flutter reported version $reportedVersion, expected $version"
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
version: 2
|
||||
updates:
|
||||
- package-ecosystem: github-actions
|
||||
directory: /
|
||||
schedule:
|
||||
interval: monthly
|
||||
+195
-162
@@ -25,10 +25,11 @@ on:
|
||||
type: boolean
|
||||
|
||||
env:
|
||||
SENTRY_DART_DEFINE: ${{ github.repository == 'edde746/plezy' && '--dart-define=ENABLE_SENTRY=true' || '' }}
|
||||
GIT_COMMIT_DART_DEFINE: --dart-define=GIT_COMMIT=${{ github.sha }}
|
||||
SENTRY_ENV_DART_DEFINE: --dart-define=SENTRY_ENVIRONMENT=github
|
||||
DONATIONS_DART_DEFINE: --dart-define=ENABLE_DONATIONS=true
|
||||
# Only place this workflow names the SDK; .github/actions/setup-flutter-git pins the same release.
|
||||
FLUTTER_VERSION: "3.44.0"
|
||||
# Shared by every release build command; SENTRY_DIST stays per-platform.
|
||||
RELEASE_DART_DEFINES: --dart-define=ENABLE_UPDATE_CHECK=true ${{ github.repository == 'edde746/plezy' && '--dart-define=ENABLE_SENTRY=true' || '' }} --dart-define=GIT_COMMIT=${{ github.sha }} --dart-define=SENTRY_ENVIRONMENT=github --dart-define=ENABLE_DONATIONS=true
|
||||
TRUSTED_BUILD_CACHE_VERSION: trusted-build-v1
|
||||
LINUX_APT_PACKAGES: >
|
||||
clang cmake meson ninja-build pkg-config nasm libgtk-3-dev libevdev-dev liblzma-dev
|
||||
libstdc++-12-dev libasound2-dev libass-dev libfreetype-dev libfontconfig-dev libfribidi-dev
|
||||
@@ -39,7 +40,21 @@ env:
|
||||
ruby ruby-dev rubygems build-essential rpm libarchive-tools imagemagick libcurl4-openssl-dev
|
||||
|
||||
jobs:
|
||||
validate-trusted-ref:
|
||||
name: Validate trusted build ref
|
||||
runs-on: ubuntu-latest
|
||||
permissions: {}
|
||||
steps:
|
||||
- name: Require the protected main branch
|
||||
shell: bash
|
||||
run: |
|
||||
if [[ "$GITHUB_REF" != "refs/heads/main" ]]; then
|
||||
echo "Release builds may only run from refs/heads/main." >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
build-android:
|
||||
needs: validate-trusted-ref
|
||||
if: ${{ inputs.build_android }}
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
@@ -47,38 +62,39 @@ jobs:
|
||||
attestations: write
|
||||
contents: read
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
- name: Setup Java
|
||||
uses: actions/setup-java@v4
|
||||
uses: actions/setup-java@03ad4de0992f5dab5e18fcb136590ce7c4a0ac95 # v5
|
||||
with:
|
||||
distribution: "temurin"
|
||||
java-version: "17"
|
||||
java-version: "21"
|
||||
|
||||
- name: Setup Flutter
|
||||
uses: subosito/flutter-action@v2
|
||||
uses: subosito/flutter-action@1a449444c387b1966244ae4d4f8c696479add0b2 # v2
|
||||
with:
|
||||
channel: "stable"
|
||||
flutter-version: "3.44.0"
|
||||
flutter-version: ${{ env.FLUTTER_VERSION }}
|
||||
cache: true
|
||||
cache-key: "${{ env.TRUSTED_BUILD_CACHE_VERSION }}-flutter-:os:-:channel:-:version:-:arch:-:hash:"
|
||||
pub-cache: false
|
||||
|
||||
- name: Cache Pub dependencies
|
||||
uses: actions/cache@v4
|
||||
uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6
|
||||
with:
|
||||
path: |
|
||||
~/.pub-cache
|
||||
key: ${{ runner.os }}-${{ runner.arch }}-pub-v2-${{ hashFiles('pubspec.yaml', 'pubspec.lock') }}
|
||||
key: ${{ env.TRUSTED_BUILD_CACHE_VERSION }}-${{ runner.os }}-${{ runner.arch }}-pub-${{ hashFiles('pubspec.yaml', 'pubspec.lock') }}
|
||||
|
||||
- name: Cache Gradle
|
||||
uses: actions/cache@v4
|
||||
uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6
|
||||
with:
|
||||
path: |
|
||||
~/.gradle/caches
|
||||
~/.gradle/wrapper
|
||||
key: ${{ runner.os }}-gradle-${{ hashFiles('**/*.gradle*', '**/gradle-wrapper.properties') }}
|
||||
restore-keys: |
|
||||
${{ runner.os }}-gradle-
|
||||
key: ${{ env.TRUSTED_BUILD_CACHE_VERSION }}-${{ runner.os }}-gradle-${{ hashFiles('**/*.gradle*', '**/gradle-wrapper.properties') }}
|
||||
|
||||
- name: Install dependencies
|
||||
run: flutter pub get --enforce-lockfile --no-example
|
||||
@@ -94,7 +110,7 @@ jobs:
|
||||
EOF
|
||||
|
||||
- name: Build APKs
|
||||
run: flutter build apk --release --split-per-abi --dart-define=ENABLE_UPDATE_CHECK=true ${{ env.SENTRY_DART_DEFINE }} ${{ env.GIT_COMMIT_DART_DEFINE }} ${{ env.SENTRY_ENV_DART_DEFINE }} --dart-define=SENTRY_DIST=github-android-apk ${{ env.DONATIONS_DART_DEFINE }} --obfuscate --split-debug-info=debug-info/android-apk --extra-gen-snapshot-options=--save-obfuscation-map=debug-info/android-apk/obfuscation.map.json
|
||||
run: flutter build apk --release --split-per-abi ${{ env.RELEASE_DART_DEFINES }} --dart-define=SENTRY_DIST=github-android-apk --obfuscate --split-debug-info=debug-info/android-apk --extra-gen-snapshot-options=--save-obfuscation-map=debug-info/android-apk/obfuscation.map.json
|
||||
|
||||
- name: Upload symbols to bugs.plezy.app
|
||||
if: github.repository == 'edde746/plezy'
|
||||
@@ -114,7 +130,7 @@ jobs:
|
||||
tar -czf plezy-android-x86_64.tar.gz -C build/app/outputs/flutter-apk app-x86_64-release.apk --transform 's/app-x86_64-release.apk/plezy.apk/'
|
||||
|
||||
- name: Attest APKs
|
||||
uses: actions/attest-build-provenance@v2
|
||||
uses: actions/attest-build-provenance@0f67c3f4856b2e3261c31976d6725780e5e4c373 # v4
|
||||
with:
|
||||
subject-path: |
|
||||
plezy-android-arm64-v8a.tar.gz
|
||||
@@ -122,7 +138,7 @@ jobs:
|
||||
plezy-android-x86_64.tar.gz
|
||||
|
||||
- name: Upload APKs
|
||||
uses: actions/upload-artifact@v4
|
||||
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7
|
||||
with:
|
||||
name: android-apk
|
||||
path: |
|
||||
@@ -131,6 +147,7 @@ jobs:
|
||||
plezy-android-x86_64.tar.gz
|
||||
|
||||
build-ios:
|
||||
needs: validate-trusted-ref
|
||||
if: ${{ inputs.build_ios }}
|
||||
runs-on: macos-26
|
||||
permissions:
|
||||
@@ -138,39 +155,40 @@ jobs:
|
||||
attestations: write
|
||||
contents: read
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
- name: Setup Flutter
|
||||
uses: subosito/flutter-action@v2
|
||||
uses: subosito/flutter-action@1a449444c387b1966244ae4d4f8c696479add0b2 # v2
|
||||
with:
|
||||
channel: "stable"
|
||||
flutter-version: "3.44.0"
|
||||
flutter-version: ${{ env.FLUTTER_VERSION }}
|
||||
cache: true
|
||||
cache-key: "${{ env.TRUSTED_BUILD_CACHE_VERSION }}-flutter-:os:-:channel:-:version:-:arch:-:hash:"
|
||||
pub-cache: false
|
||||
|
||||
- name: Cache Pub dependencies
|
||||
uses: actions/cache@v4
|
||||
uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6
|
||||
with:
|
||||
path: |
|
||||
~/.pub-cache
|
||||
key: ${{ runner.os }}-${{ runner.arch }}-pub-v2-${{ hashFiles('pubspec.yaml', 'pubspec.lock') }}
|
||||
key: ${{ env.TRUSTED_BUILD_CACHE_VERSION }}-${{ runner.os }}-${{ runner.arch }}-pub-${{ hashFiles('pubspec.yaml', 'pubspec.lock') }}
|
||||
|
||||
- name: Cache CocoaPods
|
||||
uses: actions/cache@v4
|
||||
uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6
|
||||
with:
|
||||
path: |
|
||||
ios/Pods
|
||||
~/Library/Caches/CocoaPods
|
||||
~/.cocoapods
|
||||
key: ${{ runner.os }}-pods-${{ hashFiles('**/Podfile.lock') }}
|
||||
restore-keys: |
|
||||
${{ runner.os }}-pods-
|
||||
key: ${{ env.TRUSTED_BUILD_CACHE_VERSION }}-${{ runner.os }}-ios-pods-${{ hashFiles('**/Podfile.lock') }}
|
||||
|
||||
- name: Install dependencies
|
||||
run: flutter pub get --enforce-lockfile --no-example
|
||||
|
||||
- name: Build iOS (no codesign)
|
||||
run: flutter build ios --release --no-codesign --dart-define=ENABLE_UPDATE_CHECK=true ${{ env.SENTRY_DART_DEFINE }} ${{ env.GIT_COMMIT_DART_DEFINE }} ${{ env.SENTRY_ENV_DART_DEFINE }} --dart-define=SENTRY_DIST=github-ios ${{ env.DONATIONS_DART_DEFINE }} --split-debug-info=debug-info/ios
|
||||
run: flutter build ios --release --no-codesign ${{ env.RELEASE_DART_DEFINES }} --dart-define=SENTRY_DIST=github-ios --split-debug-info=debug-info/ios
|
||||
|
||||
- name: Upload symbols to bugs.plezy.app
|
||||
if: github.repository == 'edde746/plezy'
|
||||
@@ -186,17 +204,18 @@ jobs:
|
||||
zip -r plezy-ios.ipa Payload
|
||||
|
||||
- name: Attest IPA
|
||||
uses: actions/attest-build-provenance@v2
|
||||
uses: actions/attest-build-provenance@0f67c3f4856b2e3261c31976d6725780e5e4c373 # v4
|
||||
with:
|
||||
subject-path: plezy-ios.ipa
|
||||
|
||||
- name: Upload IPA
|
||||
uses: actions/upload-artifact@v4
|
||||
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7
|
||||
with:
|
||||
name: ios-ipa
|
||||
path: plezy-ios.ipa
|
||||
|
||||
build-macos:
|
||||
needs: validate-trusted-ref
|
||||
if: ${{ inputs.build_macos }}
|
||||
runs-on: macos-26
|
||||
permissions:
|
||||
@@ -204,39 +223,40 @@ jobs:
|
||||
attestations: write
|
||||
contents: read
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
- name: Setup Flutter
|
||||
uses: subosito/flutter-action@v2
|
||||
uses: subosito/flutter-action@1a449444c387b1966244ae4d4f8c696479add0b2 # v2
|
||||
with:
|
||||
channel: "stable"
|
||||
flutter-version: "3.44.0"
|
||||
flutter-version: ${{ env.FLUTTER_VERSION }}
|
||||
cache: true
|
||||
cache-key: "${{ env.TRUSTED_BUILD_CACHE_VERSION }}-flutter-:os:-:channel:-:version:-:arch:-:hash:"
|
||||
pub-cache: false
|
||||
|
||||
- name: Cache Pub dependencies
|
||||
uses: actions/cache@v4
|
||||
uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6
|
||||
with:
|
||||
path: |
|
||||
~/.pub-cache
|
||||
key: ${{ runner.os }}-${{ runner.arch }}-pub-v2-${{ hashFiles('pubspec.yaml', 'pubspec.lock') }}
|
||||
key: ${{ env.TRUSTED_BUILD_CACHE_VERSION }}-${{ runner.os }}-${{ runner.arch }}-pub-${{ hashFiles('pubspec.yaml', 'pubspec.lock') }}
|
||||
|
||||
- name: Cache CocoaPods
|
||||
uses: actions/cache@v4
|
||||
uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6
|
||||
with:
|
||||
path: |
|
||||
macos/Pods
|
||||
~/Library/Caches/CocoaPods
|
||||
~/.cocoapods
|
||||
key: ${{ runner.os }}-macos-pods-${{ hashFiles('**/Podfile.lock') }}
|
||||
restore-keys: |
|
||||
${{ runner.os }}-macos-pods-
|
||||
key: ${{ env.TRUSTED_BUILD_CACHE_VERSION }}-${{ runner.os }}-macos-pods-${{ hashFiles('**/Podfile.lock') }}
|
||||
|
||||
- name: Install dependencies
|
||||
run: flutter pub get --enforce-lockfile --no-example
|
||||
|
||||
- name: Build macOS
|
||||
run: flutter build macos --release --dart-define=ENABLE_UPDATE_CHECK=true ${{ env.SENTRY_DART_DEFINE }} ${{ env.GIT_COMMIT_DART_DEFINE }} ${{ env.SENTRY_ENV_DART_DEFINE }} --dart-define=SENTRY_DIST=github-macos ${{ env.DONATIONS_DART_DEFINE }} --split-debug-info=debug-info/macos
|
||||
run: flutter build macos --release ${{ env.RELEASE_DART_DEFINES }} --dart-define=SENTRY_DIST=github-macos --split-debug-info=debug-info/macos
|
||||
|
||||
- name: Upload symbols to bugs.plezy.app
|
||||
if: github.repository == 'edde746/plezy'
|
||||
@@ -355,7 +375,7 @@ jobs:
|
||||
echo "MACOS_DMG_SIZE=$(stat -f%z plezy-macos.dmg)" >> $GITHUB_ENV
|
||||
|
||||
- name: Attest macOS DMG
|
||||
uses: actions/attest-build-provenance@v2
|
||||
uses: actions/attest-build-provenance@0f67c3f4856b2e3261c31976d6725780e5e4c373 # v4
|
||||
with:
|
||||
subject-path: plezy-macos.dmg
|
||||
|
||||
@@ -365,7 +385,7 @@ jobs:
|
||||
echo "${{ env.MACOS_DMG_SIZE }}" > macos-dmg-size.txt
|
||||
|
||||
- name: Upload macOS DMG
|
||||
uses: actions/upload-artifact@v4
|
||||
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7
|
||||
with:
|
||||
name: macos-dmg
|
||||
path: |
|
||||
@@ -374,6 +394,7 @@ jobs:
|
||||
macos-dmg-size.txt
|
||||
|
||||
build-windows:
|
||||
needs: validate-trusted-ref
|
||||
name: Build Windows (${{ matrix.arch }})
|
||||
if: ${{ inputs.build_windows }}
|
||||
runs-on: ${{ matrix.runner }}
|
||||
@@ -394,14 +415,16 @@ jobs:
|
||||
build/windows/arm64/_deps
|
||||
build/windows/arm64/mpv-dev-arm64
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
- name: Cache Windows native dependencies
|
||||
id: windows-native-cache
|
||||
uses: actions/cache@v4
|
||||
uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6
|
||||
with:
|
||||
path: ${{ matrix.native_cache_path }}
|
||||
key: windows-native-${{ matrix.arch }}-${{ hashFiles('windows/CMakeLists.txt') }}
|
||||
key: ${{ env.TRUSTED_BUILD_CACHE_VERSION }}-windows-native-${{ matrix.arch }}-${{ hashFiles('windows/CMakeLists.txt') }}
|
||||
|
||||
- name: Install 7-Zip
|
||||
if: matrix.arch == 'arm64' && steps.windows-native-cache.outputs.cache-hit != 'true'
|
||||
@@ -410,33 +433,24 @@ jobs:
|
||||
|
||||
- name: Setup Flutter
|
||||
if: matrix.flutter_setup == 'action'
|
||||
uses: subosito/flutter-action@v2
|
||||
uses: subosito/flutter-action@1a449444c387b1966244ae4d4f8c696479add0b2 # v2
|
||||
with:
|
||||
channel: "stable"
|
||||
flutter-version: "3.44.0"
|
||||
flutter-version: ${{ env.FLUTTER_VERSION }}
|
||||
cache: true
|
||||
cache-key: "${{ env.TRUSTED_BUILD_CACHE_VERSION }}-flutter-:os:-:channel:-:version:-:arch:-:hash:"
|
||||
pub-cache: false
|
||||
|
||||
- name: Set up Flutter 3.44.0 (git tag)
|
||||
- name: Set up Flutter from its pinned commit
|
||||
if: matrix.flutter_setup == 'git'
|
||||
# Flutter publishes no windows-arm64 SDK archive, so subosito can't
|
||||
# resolve 3.44.0 for arm64: the stable manifest has no arm64 entry, and
|
||||
# `channel: master` would git-clone master HEAD (whose engine != our
|
||||
# patched 3.44.0). Clone the 3.44.0 tag directly to get engine rev
|
||||
# 4c525dac, which install-patched-engine.ps1 asserts before swapping.
|
||||
shell: pwsh
|
||||
run: |
|
||||
$root = "$env:RUNNER_TEMP\flutter"
|
||||
git clone --depth 1 --branch 3.44.0 https://github.com/flutter/flutter.git $root
|
||||
"$root\bin" | Out-File -FilePath $env:GITHUB_PATH -Append -Encoding utf8
|
||||
& "$root\bin\flutter.bat" --version
|
||||
uses: ./.github/actions/setup-flutter-git
|
||||
|
||||
- name: Cache Pub dependencies
|
||||
uses: actions/cache@v4
|
||||
uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6
|
||||
with:
|
||||
path: |
|
||||
~\AppData\Local\Pub\Cache
|
||||
key: ${{ runner.os }}-${{ runner.arch }}-pub-v2-${{ hashFiles('pubspec.yaml', 'pubspec.lock') }}
|
||||
key: ${{ env.TRUSTED_BUILD_CACHE_VERSION }}-${{ runner.os }}-${{ runner.arch }}-pub-${{ hashFiles('pubspec.yaml', 'pubspec.lock') }}
|
||||
|
||||
- name: Install dependencies
|
||||
shell: pwsh
|
||||
@@ -450,7 +464,7 @@ jobs:
|
||||
|
||||
- name: Build Windows ${{ matrix.arch }}
|
||||
shell: pwsh
|
||||
run: flutter build windows --release --dart-define=ENABLE_UPDATE_CHECK=true ${{ env.SENTRY_DART_DEFINE }} ${{ env.GIT_COMMIT_DART_DEFINE }} ${{ env.SENTRY_ENV_DART_DEFINE }} --dart-define=SENTRY_DIST=github-windows-${{ matrix.arch }} ${{ env.DONATIONS_DART_DEFINE }} --split-debug-info=debug-info/windows-${{ matrix.arch }}
|
||||
run: flutter build windows --release ${{ env.RELEASE_DART_DEFINES }} --dart-define=SENTRY_DIST=github-windows-${{ matrix.arch }} --split-debug-info=debug-info/windows-${{ matrix.arch }}
|
||||
|
||||
- name: Upload symbols to bugs.plezy.app
|
||||
if: github.repository == 'edde746/plezy'
|
||||
@@ -461,7 +475,7 @@ jobs:
|
||||
run: .\scripts\upload-symbols.ps1 windows-${{ matrix.arch }}
|
||||
|
||||
- name: Upload ${{ matrix.arch }} build
|
||||
uses: actions/upload-artifact@v4
|
||||
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7
|
||||
with:
|
||||
name: windows-${{ matrix.arch }}-build
|
||||
path: build/windows/${{ matrix.arch }}/runner/Release/
|
||||
@@ -475,19 +489,31 @@ jobs:
|
||||
attestations: write
|
||||
contents: read
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
- name: Setup Dart
|
||||
uses: dart-lang/setup-dart@v1
|
||||
- name: Setup Flutter
|
||||
uses: subosito/flutter-action@1a449444c387b1966244ae4d4f8c696479add0b2 # v2
|
||||
with:
|
||||
channel: "stable"
|
||||
flutter-version: ${{ env.FLUTTER_VERSION }}
|
||||
cache: true
|
||||
cache-key: "${{ env.TRUSTED_BUILD_CACHE_VERSION }}-flutter-:os:-:channel:-:version:-:arch:-:hash:"
|
||||
pub-cache: false
|
||||
|
||||
- name: Install dependencies
|
||||
shell: pwsh
|
||||
run: flutter pub get --enforce-lockfile --no-example
|
||||
|
||||
- name: Download x64 build
|
||||
uses: actions/download-artifact@v4
|
||||
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8
|
||||
with:
|
||||
name: windows-x64-build
|
||||
path: build-x64
|
||||
|
||||
- name: Download arm64 build
|
||||
uses: actions/download-artifact@v4
|
||||
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8
|
||||
with:
|
||||
name: windows-arm64-build
|
||||
path: build-arm64
|
||||
@@ -508,21 +534,25 @@ jobs:
|
||||
SPARKLE_PRIVATE_KEY: ${{ secrets.SPARKLE_PRIVATE_KEY }}
|
||||
shell: pwsh
|
||||
run: |
|
||||
mkdir _signer | Out-Null
|
||||
@{name="signer"; environment=@{sdk=">=3.0.0 <4.0.0"}; dependencies=@{cryptography="^2.7.0"}} | ConvertTo-Json -Depth 3 | Out-File _signer/pubspec.yaml
|
||||
Invoke-WebRequest -Uri "https://raw.githubusercontent.com/edde746/auto_updater/main/packages/auto_updater/bin/sign_update.dart" -OutFile _signer/sign.dart
|
||||
Push-Location _signer
|
||||
dart pub get
|
||||
Set-Content -Path ed25519_key.pem -Value $env:SPARKLE_PRIVATE_KEY -Encoding ascii -NoNewline
|
||||
$output = dart run sign.dart ../plezy-windows-installer.exe ed25519_key.pem
|
||||
Pop-Location
|
||||
Remove-Item _signer -Recurse -Force
|
||||
$sig = [regex]::Match($output, 'edSignature="([^"]*)"').Groups[1].Value
|
||||
Set-Content -Path win-ed-signature.txt -Value $sig -Encoding ascii -NoNewline
|
||||
Set-Content -Path win-installer-size.txt -Value (Get-Item plezy-windows-installer.exe).Length.ToString() -Encoding ascii -NoNewline
|
||||
$keyPath = Join-Path $env:RUNNER_TEMP "plezy-winsparkle-ed25519.pem"
|
||||
try {
|
||||
Set-Content -Path $keyPath -Value $env:SPARKLE_PRIVATE_KEY -Encoding ascii -NoNewline
|
||||
$output = & dart run auto_updater:sign_update plezy-windows-installer.exe $keyPath
|
||||
if ($LASTEXITCODE -ne 0) {
|
||||
throw "WinSparkle signer failed with exit code $LASTEXITCODE"
|
||||
}
|
||||
$match = [regex]::Match($output, 'edSignature="([^"]*)"')
|
||||
if (-not $match.Success) {
|
||||
throw "WinSparkle signer returned no EdDSA signature"
|
||||
}
|
||||
Set-Content -Path win-ed-signature.txt -Value $match.Groups[1].Value -Encoding ascii -NoNewline
|
||||
Set-Content -Path win-installer-size.txt -Value (Get-Item plezy-windows-installer.exe).Length.ToString() -Encoding ascii -NoNewline
|
||||
} finally {
|
||||
Remove-Item -Path $keyPath -Force -ErrorAction SilentlyContinue
|
||||
}
|
||||
|
||||
- name: Attest Windows artifacts
|
||||
uses: actions/attest-build-provenance@v2
|
||||
uses: actions/attest-build-provenance@0f67c3f4856b2e3261c31976d6725780e5e4c373 # v4
|
||||
with:
|
||||
subject-path: |
|
||||
plezy-windows-x64-portable.7z
|
||||
@@ -530,19 +560,19 @@ jobs:
|
||||
plezy-windows-installer.exe
|
||||
|
||||
- name: Upload x64 portable
|
||||
uses: actions/upload-artifact@v4
|
||||
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7
|
||||
with:
|
||||
name: windows-x64-portable
|
||||
path: plezy-windows-x64-portable.7z
|
||||
|
||||
- name: Upload arm64 portable
|
||||
uses: actions/upload-artifact@v4
|
||||
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7
|
||||
with:
|
||||
name: windows-arm64-portable
|
||||
path: plezy-windows-arm64-portable.7z
|
||||
|
||||
- name: Upload installer
|
||||
uses: actions/upload-artifact@v4
|
||||
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7
|
||||
with:
|
||||
name: windows-installer
|
||||
path: |
|
||||
@@ -551,6 +581,7 @@ jobs:
|
||||
win-installer-size.txt
|
||||
|
||||
build-linux:
|
||||
needs: validate-trusted-ref
|
||||
name: Build Linux (${{ matrix.arch }})
|
||||
if: ${{ inputs.build_linux }}
|
||||
runs-on: ${{ matrix.runner }}
|
||||
@@ -571,28 +602,31 @@ jobs:
|
||||
flutter_channel: master
|
||||
pkg_config_arch: aarch64-linux-gnu
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
- name: Setup Flutter
|
||||
uses: subosito/flutter-action@v2
|
||||
uses: subosito/flutter-action@1a449444c387b1966244ae4d4f8c696479add0b2 # v2
|
||||
with:
|
||||
channel: ${{ matrix.flutter_channel }}
|
||||
flutter-version: "3.44.0"
|
||||
flutter-version: ${{ env.FLUTTER_VERSION }}
|
||||
cache: true
|
||||
cache-key: "${{ env.TRUSTED_BUILD_CACHE_VERSION }}-flutter-:os:-:channel:-:version:-:arch:-:hash:"
|
||||
pub-cache: false
|
||||
|
||||
- name: Cache Pub dependencies
|
||||
uses: actions/cache@v4
|
||||
uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6
|
||||
with:
|
||||
path: |
|
||||
~/.pub-cache
|
||||
key: ${{ runner.os }}-${{ runner.arch }}-pub-v2-${{ hashFiles('pubspec.yaml', 'pubspec.lock') }}
|
||||
key: ${{ env.TRUSTED_BUILD_CACHE_VERSION }}-${{ runner.os }}-${{ runner.arch }}-pub-${{ hashFiles('pubspec.yaml', 'pubspec.lock') }}
|
||||
|
||||
- name: Cache APT packages
|
||||
uses: awalsh128/cache-apt-pkgs-action@latest
|
||||
uses: awalsh128/cache-apt-pkgs-action@553a35bb8ebd9fcabcb1c9451aa4c98e1b4ca8a9 # latest
|
||||
with:
|
||||
packages: ${{ env.LINUX_APT_PACKAGES }}
|
||||
version: 1.1
|
||||
version: trusted-build-v1.1
|
||||
|
||||
- name: Install Linux dependencies
|
||||
shell: bash
|
||||
@@ -613,10 +647,10 @@ jobs:
|
||||
|
||||
- name: Cache libmpv build
|
||||
id: libmpv-cache
|
||||
uses: actions/cache@v4
|
||||
uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6
|
||||
with:
|
||||
path: libmpv-prefix
|
||||
key: libmpv-${{ runner.arch }}-${{ hashFiles('linux/packaging/build-libmpv.sh') }}
|
||||
key: ${{ env.TRUSTED_BUILD_CACHE_VERSION }}-libmpv-${{ runner.arch }}-${{ hashFiles('linux/packaging/build-libmpv.sh', 'linux/packaging/native-inputs.json') }}
|
||||
|
||||
- name: Build libmpv
|
||||
if: steps.libmpv-cache.outputs.cache-hit != 'true'
|
||||
@@ -625,7 +659,7 @@ jobs:
|
||||
|
||||
- name: Install fpm
|
||||
shell: bash
|
||||
run: sudo gem install fpm
|
||||
run: sudo gem install fpm --version 1.17.0 --no-document
|
||||
|
||||
- name: Install dependencies
|
||||
shell: bash
|
||||
@@ -633,7 +667,7 @@ jobs:
|
||||
|
||||
- name: Build Linux ${{ matrix.arch }}
|
||||
shell: bash
|
||||
run: flutter build linux --release --dart-define=ENABLE_UPDATE_CHECK=true ${{ env.SENTRY_DART_DEFINE }} ${{ env.GIT_COMMIT_DART_DEFINE }} ${{ env.SENTRY_ENV_DART_DEFINE }} --dart-define=SENTRY_DIST=github-linux-${{ matrix.arch }} ${{ env.DONATIONS_DART_DEFINE }} --split-debug-info=debug-info/linux-${{ matrix.arch }}
|
||||
run: flutter build linux --release ${{ env.RELEASE_DART_DEFINES }} --dart-define=SENTRY_DIST=github-linux-${{ matrix.arch }} --split-debug-info=debug-info/linux-${{ matrix.arch }}
|
||||
env:
|
||||
PKG_CONFIG_PATH: ${{ github.workspace }}/libmpv-prefix/lib/pkgconfig:${{ github.workspace }}/libmpv-prefix/lib/${{ matrix.pkg_config_arch }}/pkgconfig
|
||||
|
||||
@@ -702,7 +736,7 @@ jobs:
|
||||
tar -czf "$GITHUB_WORKSPACE/plezy-linux-${{ matrix.arch }}.tar.gz" *
|
||||
|
||||
- name: Attest Linux ${{ matrix.arch }} artifacts
|
||||
uses: actions/attest-build-provenance@v2
|
||||
uses: actions/attest-build-provenance@0f67c3f4856b2e3261c31976d6725780e5e4c373 # v4
|
||||
with:
|
||||
subject-path: |
|
||||
plezy-linux-${{ matrix.arch }}.tar.gz
|
||||
@@ -711,7 +745,7 @@ jobs:
|
||||
plezy-linux-${{ matrix.arch }}.pkg.tar.zst
|
||||
|
||||
- name: Upload Linux ${{ matrix.arch }} artifacts
|
||||
uses: actions/upload-artifact@v4
|
||||
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7
|
||||
with:
|
||||
name: linux-${{ matrix.arch }}
|
||||
path: |
|
||||
@@ -721,75 +755,17 @@ jobs:
|
||||
plezy-linux-${{ matrix.arch }}.pkg.tar.zst
|
||||
|
||||
create-release:
|
||||
needs: [build-android, build-ios, build-macos, build-windows, package-windows, build-linux]
|
||||
if: ${{ always() && !contains(needs.*.result, 'failure') && !contains(needs.*.result, 'cancelled') && (inputs.build_android || inputs.build_ios || inputs.build_macos || inputs.build_windows || inputs.build_linux) }}
|
||||
needs: [validate-trusted-ref, build-android, build-ios, build-macos, build-windows, package-windows, build-linux]
|
||||
if: ${{ always() && needs.validate-trusted-ref.result == 'success' && !contains(needs.*.result, 'failure') && !contains(needs.*.result, 'cancelled') && inputs.build_android && inputs.build_ios && inputs.build_macos && inputs.build_windows && inputs.build_linux }}
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: write
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
|
||||
with:
|
||||
sparse-checkout: pubspec.yaml
|
||||
sparse-checkout-cone-mode: false
|
||||
|
||||
- name: Download Android artifacts
|
||||
if: ${{ inputs.build_android }}
|
||||
uses: actions/download-artifact@v4
|
||||
with:
|
||||
name: android-apk
|
||||
path: artifacts/android-apk
|
||||
|
||||
- name: Download iOS artifact
|
||||
if: ${{ inputs.build_ios }}
|
||||
uses: actions/download-artifact@v4
|
||||
with:
|
||||
name: ios-ipa
|
||||
path: artifacts/ios-ipa
|
||||
|
||||
- name: Download macOS artifact
|
||||
if: ${{ inputs.build_macos }}
|
||||
uses: actions/download-artifact@v4
|
||||
with:
|
||||
name: macos-dmg
|
||||
path: artifacts/macos-dmg
|
||||
|
||||
- name: Download Windows x64 artifact
|
||||
if: ${{ inputs.build_windows }}
|
||||
uses: actions/download-artifact@v4
|
||||
with:
|
||||
name: windows-x64-portable
|
||||
path: artifacts/windows-x64-portable
|
||||
|
||||
- name: Download Windows arm64 artifact
|
||||
if: ${{ inputs.build_windows }}
|
||||
uses: actions/download-artifact@v4
|
||||
with:
|
||||
name: windows-arm64-portable
|
||||
path: artifacts/windows-arm64-portable
|
||||
|
||||
- name: Download Windows installer artifact
|
||||
if: ${{ inputs.build_windows }}
|
||||
uses: actions/download-artifact@v4
|
||||
with:
|
||||
name: windows-installer
|
||||
path: artifacts/windows-installer
|
||||
|
||||
- name: Download Linux x64 artifacts
|
||||
if: ${{ inputs.build_linux }}
|
||||
uses: actions/download-artifact@v4
|
||||
with:
|
||||
name: linux-x64
|
||||
path: artifacts/linux-x64
|
||||
|
||||
- name: Download Linux arm64 artifacts
|
||||
if: ${{ inputs.build_linux }}
|
||||
uses: actions/download-artifact@v4
|
||||
with:
|
||||
name: linux-arm64
|
||||
path: artifacts/linux-arm64
|
||||
|
||||
- name: Display structure of downloaded files
|
||||
run: ls -R artifacts
|
||||
persist-credentials: false
|
||||
|
||||
- name: Read version from pubspec.yaml
|
||||
id: version
|
||||
@@ -799,6 +775,65 @@ jobs:
|
||||
echo "version=$VERSION" >> $GITHUB_OUTPUT
|
||||
echo "build_number=$BUILD_NUMBER" >> $GITHUB_OUTPUT
|
||||
|
||||
- name: Download Android artifacts
|
||||
if: ${{ inputs.build_android }}
|
||||
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8
|
||||
with:
|
||||
name: android-apk
|
||||
path: artifacts/android-apk
|
||||
|
||||
- name: Download iOS artifact
|
||||
if: ${{ inputs.build_ios }}
|
||||
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8
|
||||
with:
|
||||
name: ios-ipa
|
||||
path: artifacts/ios-ipa
|
||||
|
||||
- name: Download macOS artifact
|
||||
if: ${{ inputs.build_macos }}
|
||||
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8
|
||||
with:
|
||||
name: macos-dmg
|
||||
path: artifacts/macos-dmg
|
||||
|
||||
- name: Download Windows x64 artifact
|
||||
if: ${{ inputs.build_windows }}
|
||||
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8
|
||||
with:
|
||||
name: windows-x64-portable
|
||||
path: artifacts/windows-x64-portable
|
||||
|
||||
- name: Download Windows arm64 artifact
|
||||
if: ${{ inputs.build_windows }}
|
||||
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8
|
||||
with:
|
||||
name: windows-arm64-portable
|
||||
path: artifacts/windows-arm64-portable
|
||||
|
||||
- name: Download Windows installer artifact
|
||||
if: ${{ inputs.build_windows }}
|
||||
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8
|
||||
with:
|
||||
name: windows-installer
|
||||
path: artifacts/windows-installer
|
||||
|
||||
- name: Download Linux x64 artifacts
|
||||
if: ${{ inputs.build_linux }}
|
||||
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8
|
||||
with:
|
||||
name: linux-x64
|
||||
path: artifacts/linux-x64
|
||||
|
||||
- name: Download Linux arm64 artifacts
|
||||
if: ${{ inputs.build_linux }}
|
||||
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8
|
||||
with:
|
||||
name: linux-arm64
|
||||
path: artifacts/linux-arm64
|
||||
|
||||
- name: Display structure of downloaded files
|
||||
run: ls -R artifacts
|
||||
|
||||
- name: Generate appcast.xml
|
||||
run: |
|
||||
VERSION="${{ steps.version.outputs.version }}"
|
||||
@@ -899,12 +934,10 @@ jobs:
|
||||
} >> "$GITHUB_OUTPUT"
|
||||
|
||||
- name: Create Release
|
||||
uses: softprops/action-gh-release@v2
|
||||
uses: softprops/action-gh-release@3d0d9888cb7fd7b750713d6e236d1fcb99157228 # v3
|
||||
with:
|
||||
tag_name: ${{ steps.version.outputs.version }}
|
||||
files: ${{ steps.release-files.outputs.files }}
|
||||
draft: true
|
||||
prerelease: false
|
||||
generate_release_notes: true
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
+379
-31
@@ -4,11 +4,16 @@ on:
|
||||
push:
|
||||
branches:
|
||||
- main
|
||||
# Keep untrusted code on the read-only pull_request event. Never use pull_request_target here.
|
||||
pull_request:
|
||||
branches:
|
||||
- main
|
||||
workflow_dispatch:
|
||||
|
||||
env:
|
||||
# Only place this workflow names the SDK; .github/actions/setup-flutter-git pins the same release.
|
||||
FLUTTER_VERSION: "3.44.0"
|
||||
|
||||
jobs:
|
||||
analyze:
|
||||
name: Code Analysis
|
||||
@@ -17,18 +22,20 @@ jobs:
|
||||
contents: read
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v4
|
||||
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
- name: Setup Flutter
|
||||
uses: subosito/flutter-action@v2
|
||||
uses: subosito/flutter-action@1a449444c387b1966244ae4d4f8c696479add0b2 # v2
|
||||
with:
|
||||
channel: "stable"
|
||||
flutter-version: "3.44.0"
|
||||
flutter-version: ${{ env.FLUTTER_VERSION }}
|
||||
cache: true
|
||||
pub-cache: false
|
||||
|
||||
- name: Cache Pub dependencies
|
||||
uses: actions/cache@v4
|
||||
uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6
|
||||
with:
|
||||
path: |
|
||||
~/.pub-cache
|
||||
@@ -39,9 +46,9 @@ jobs:
|
||||
|
||||
flutter pub get
|
||||
|
||||
- name: Install saf_util development dependencies
|
||||
working-directory: packages/saf_util
|
||||
run: flutter pub get
|
||||
- name: Install wakelock_plus development dependencies
|
||||
working-directory: packages/wakelock_plus
|
||||
run: flutter pub get --enforce-lockfile --no-example
|
||||
|
||||
- name: Verify generated files committed
|
||||
run: scripts/codegen.sh --check
|
||||
@@ -50,11 +57,7 @@ jobs:
|
||||
run: python3 scripts/clean_translations.py --check --strict
|
||||
|
||||
- name: Verify workflow and script guards
|
||||
run: |
|
||||
python3 scripts/check_build_workflow.py
|
||||
python3 scripts/check_update_packages_workflow.py
|
||||
python3 scripts/test_pubspec_version.py
|
||||
python3 scripts/test_clean_translations.py
|
||||
run: bash scripts/ci_guard_checks.sh
|
||||
|
||||
- name: Verify formatting
|
||||
run: |
|
||||
@@ -62,6 +65,10 @@ jobs:
|
||||
[ ! -d test ] || paths+=(test)
|
||||
find "${paths[@]}" -name "*.dart" ! -name "*.g.dart" ! -name "*.freezed.dart" -type f -print0 |
|
||||
xargs -0 -r dart format --output=none --set-exit-if-changed
|
||||
|
||||
- name: Verify icon consistency
|
||||
run: dart run scripts/check_icon_consistency.dart
|
||||
|
||||
- name: Analyze code
|
||||
run: dart run scripts/check_analyzer.dart
|
||||
|
||||
@@ -96,18 +103,20 @@ jobs:
|
||||
contents: read
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v4
|
||||
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
- name: Setup Flutter
|
||||
uses: subosito/flutter-action@v2
|
||||
uses: subosito/flutter-action@1a449444c387b1966244ae4d4f8c696479add0b2 # v2
|
||||
with:
|
||||
channel: "stable"
|
||||
flutter-version: "3.44.0"
|
||||
flutter-version: ${{ env.FLUTTER_VERSION }}
|
||||
cache: true
|
||||
pub-cache: false
|
||||
|
||||
- name: Cache Pub dependencies
|
||||
uses: actions/cache@v4
|
||||
uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6
|
||||
with:
|
||||
path: |
|
||||
~/.pub-cache
|
||||
@@ -121,43 +130,57 @@ jobs:
|
||||
- name: Run tests
|
||||
run: |
|
||||
if [ -d "test" ] && [ "$(find test -name '*_test.dart' | wc -l)" -gt 0 ]; then
|
||||
flutter test
|
||||
scripts/run_tests.sh
|
||||
else
|
||||
echo "No tests found, skipping test execution"
|
||||
fi
|
||||
|
||||
- name: Install wakelock_plus test dependencies
|
||||
working-directory: packages/wakelock_plus
|
||||
run: flutter pub get --enforce-lockfile
|
||||
|
||||
- name: Run wakelock_plus VM tests
|
||||
working-directory: packages/wakelock_plus
|
||||
run: flutter test test/wakelock_plus_linux_plugin_test.dart
|
||||
|
||||
- name: Run wakelock_plus Chrome tests
|
||||
working-directory: packages/wakelock_plus
|
||||
run: flutter test --platform chrome --dart-define=WEB_PLUGIN_TESTS=true test/wakelock_plus_web_plugin_test.dart
|
||||
|
||||
android-test:
|
||||
name: Android JVM Unit Tests
|
||||
name: Android JVM and Native Tests
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: read
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v4
|
||||
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
- name: Setup Java
|
||||
uses: actions/setup-java@v4
|
||||
uses: actions/setup-java@03ad4de0992f5dab5e18fcb136590ce7c4a0ac95 # v5
|
||||
with:
|
||||
distribution: "temurin"
|
||||
java-version: "17"
|
||||
java-version: "21"
|
||||
|
||||
- name: Setup Flutter
|
||||
uses: subosito/flutter-action@v2
|
||||
uses: subosito/flutter-action@1a449444c387b1966244ae4d4f8c696479add0b2 # v2
|
||||
with:
|
||||
channel: "stable"
|
||||
flutter-version: "3.44.0"
|
||||
flutter-version: ${{ env.FLUTTER_VERSION }}
|
||||
cache: true
|
||||
pub-cache: false
|
||||
|
||||
- name: Cache Pub dependencies
|
||||
uses: actions/cache@v4
|
||||
uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6
|
||||
with:
|
||||
path: |
|
||||
~/.pub-cache
|
||||
key: ${{ runner.os }}-pub-v3-${{ hashFiles('**/pubspec.yaml', '**/pubspec.lock') }}
|
||||
|
||||
- name: Cache Gradle
|
||||
uses: actions/cache@v4
|
||||
uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6
|
||||
with:
|
||||
path: |
|
||||
~/.gradle/caches
|
||||
@@ -172,6 +195,19 @@ jobs:
|
||||
- name: Configure Android local properties
|
||||
run: printf 'flutter.sdk=%s\nsdk.dir=%s\n' "$FLUTTER_ROOT" "$ANDROID_HOME" > android/local.properties
|
||||
|
||||
- name: Configure Android host native tests
|
||||
run: |
|
||||
cmake -S android/app/src/test/cpp -B build/android-host-tests \
|
||||
-DCMAKE_BUILD_TYPE=Debug
|
||||
|
||||
- name: Build Android host native tests
|
||||
run: cmake --build build/android-host-tests --parallel 2
|
||||
|
||||
- name: Run Android host native tests
|
||||
run: |
|
||||
ctest --test-dir build/android-host-tests \
|
||||
--output-on-failure --no-tests=error
|
||||
|
||||
- name: Run Android JVM unit tests
|
||||
working-directory: android
|
||||
run: ./gradlew :app:testDebugUnitTest :saf_util:testDebugUnitTest :libass:testDebugUnitTest -x :app:compileFlutterBuildDebug --continue
|
||||
@@ -187,10 +223,12 @@ jobs:
|
||||
contents: read
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v4
|
||||
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
- name: Setup Java
|
||||
uses: actions/setup-java@v4
|
||||
uses: actions/setup-java@03ad4de0992f5dab5e18fcb136590ce7c4a0ac95 # v5
|
||||
with:
|
||||
distribution: "temurin"
|
||||
java-version: "17"
|
||||
@@ -198,6 +236,274 @@ jobs:
|
||||
- name: Verify native formatting
|
||||
run: scripts/format_native.sh --check
|
||||
|
||||
- name: Verify Linux native acquisition integrity
|
||||
run: bash linux/packaging/build-libmpv_test.sh
|
||||
|
||||
linux-native-test:
|
||||
name: Linux native reliability (${{ matrix.sanitizer }})
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: read
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
include:
|
||||
- sanitizer: address
|
||||
lifecycle_sanitizers: ON
|
||||
- sanitizer: thread
|
||||
lifecycle_sanitizers: OFF
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
- name: Setup Flutter
|
||||
uses: subosito/flutter-action@1a449444c387b1966244ae4d4f8c696479add0b2 # v2
|
||||
with:
|
||||
channel: "stable"
|
||||
flutter-version: ${{ env.FLUTTER_VERSION }}
|
||||
cache: true
|
||||
pub-cache: false
|
||||
|
||||
- name: Install Linux native test dependencies
|
||||
run: |
|
||||
sudo apt-get update
|
||||
sudo apt-get install -y --no-install-recommends \
|
||||
clang cmake ninja-build pkg-config libgtk-3-dev liblzma-dev \
|
||||
libstdc++-12-dev libmpv-dev libepoxy-dev libcurl4-openssl-dev libevdev-dev
|
||||
|
||||
- name: Prepare Flutter Linux configuration
|
||||
run: |
|
||||
flutter pub get --enforce-lockfile --no-example
|
||||
flutter build linux --debug --config-only --no-pub
|
||||
|
||||
- name: Configure Linux native reliability tests
|
||||
run: |
|
||||
cmake -S linux -B build/linux-native-${{ matrix.sanitizer }} -G Ninja \
|
||||
-DCMAKE_BUILD_TYPE=Debug \
|
||||
-DPLEZY_BUILD_MPV_PLAYER_LIFECYCLE_TESTS=ON \
|
||||
-DPLEZY_MPV_LIFECYCLE_SANITIZERS=${{ matrix.lifecycle_sanitizers }} \
|
||||
-DPLEZY_BUILD_MPV_RELIABILITY_TESTS=ON \
|
||||
-DPLEZY_MPV_RELIABILITY_SANITIZER=${{ matrix.sanitizer }}
|
||||
|
||||
- name: Build Linux native reliability tests
|
||||
run: |
|
||||
cmake --build build/linux-native-${{ matrix.sanitizer }} --parallel 2 --target \
|
||||
mpv_player_lifecycle_test \
|
||||
mpv_property_result_contract_test \
|
||||
mpv_gpu_bootstrap_test
|
||||
|
||||
- name: Run Linux native reliability tests
|
||||
run: |
|
||||
ctest --test-dir build/linux-native-${{ matrix.sanitizer }} \
|
||||
--output-on-failure --no-tests=error
|
||||
|
||||
apple-native-test:
|
||||
name: Apple native reliability (${{ matrix.platform }})
|
||||
runs-on: macos-26
|
||||
permissions:
|
||||
contents: read
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
include:
|
||||
- platform: iOS
|
||||
project_directory: ios
|
||||
workspace: ios/Runner.xcworkspace
|
||||
simulator_runtime: iOS
|
||||
simulator_platform: iOS
|
||||
static_destination: ""
|
||||
- platform: macOS
|
||||
project_directory: macos
|
||||
workspace: macos/Runner.xcworkspace
|
||||
simulator_runtime: ""
|
||||
simulator_platform: ""
|
||||
static_destination: platform=macOS
|
||||
- platform: tvOS
|
||||
project_directory: tvos
|
||||
workspace: tvos/Runner.xcworkspace
|
||||
simulator_runtime: tvOS
|
||||
simulator_platform: tvOS
|
||||
static_destination: ""
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
- name: Setup Flutter
|
||||
uses: subosito/flutter-action@1a449444c387b1966244ae4d4f8c696479add0b2 # v2
|
||||
with:
|
||||
channel: "stable"
|
||||
flutter-version: ${{ env.FLUTTER_VERSION }}
|
||||
cache: true
|
||||
pub-cache: false
|
||||
|
||||
- name: Install locked Dart dependencies
|
||||
run: flutter pub get --enforce-lockfile --no-example
|
||||
|
||||
- name: Record committed CocoaPods lockfile
|
||||
if: matrix.platform != 'tvOS'
|
||||
env:
|
||||
PODFILE_LOCK: ${{ matrix.project_directory }}/Podfile.lock
|
||||
run: |
|
||||
test -f "$PODFILE_LOCK"
|
||||
shasum -a 256 "$PODFILE_LOCK" > "$RUNNER_TEMP/plezy-podfile-lock.sha256"
|
||||
|
||||
- name: Prepare iOS Flutter build settings
|
||||
if: matrix.platform == 'iOS'
|
||||
run: flutter build ios --config-only --simulator --debug --no-pub
|
||||
|
||||
- name: Prepare macOS Flutter build settings
|
||||
if: matrix.platform == 'macOS'
|
||||
run: flutter build macos --config-only --debug --no-pub
|
||||
|
||||
- name: Prepare tvOS Flutter engine
|
||||
if: matrix.platform == 'tvOS'
|
||||
run: tvos/scripts/fetch_engine.sh
|
||||
|
||||
- name: Verify Flutter configuration preserved CocoaPods lockfile
|
||||
if: matrix.platform != 'tvOS'
|
||||
run: shasum -a 256 --check "$RUNNER_TEMP/plezy-podfile-lock.sha256"
|
||||
|
||||
- name: Install locked CocoaPods dependencies
|
||||
if: matrix.platform != 'tvOS'
|
||||
working-directory: ${{ matrix.project_directory }}
|
||||
run: pod install --deployment
|
||||
|
||||
- name: Install tvOS CocoaPods dependencies
|
||||
if: matrix.platform == 'tvOS'
|
||||
run: tvos/scripts/pod_install.sh
|
||||
|
||||
- name: Verify tvOS project wiring
|
||||
if: matrix.platform == 'tvOS'
|
||||
run: ruby tvos/scripts/test_wire_mpv.rb
|
||||
|
||||
- name: Select Apple test destination
|
||||
env:
|
||||
SIMULATOR_RUNTIME: ${{ matrix.simulator_runtime }}
|
||||
SIMULATOR_PLATFORM: ${{ matrix.simulator_platform }}
|
||||
STATIC_DESTINATION: ${{ matrix.static_destination }}
|
||||
run: |
|
||||
python3 - <<'PY'
|
||||
import json
|
||||
import os
|
||||
import subprocess
|
||||
|
||||
destination = os.environ["STATIC_DESTINATION"]
|
||||
if not destination:
|
||||
runtime_name = os.environ["SIMULATOR_RUNTIME"]
|
||||
payload = json.loads(
|
||||
subprocess.check_output(
|
||||
["xcrun", "simctl", "list", "devices", "available", "-j"],
|
||||
text=True,
|
||||
)
|
||||
)
|
||||
devices = [
|
||||
device
|
||||
for runtime, candidates in payload["devices"].items()
|
||||
if f".{runtime_name}-" in runtime
|
||||
for device in candidates
|
||||
if device.get("isAvailable", False)
|
||||
]
|
||||
if not devices:
|
||||
raise SystemExit(f"no available {runtime_name} simulator")
|
||||
destination = (
|
||||
f"platform={os.environ['SIMULATOR_PLATFORM']} Simulator,"
|
||||
f"id={devices[0]['udid']}"
|
||||
)
|
||||
with open(os.environ["GITHUB_ENV"], "a", encoding="utf-8") as output:
|
||||
output.write(f"APPLE_TEST_DESTINATION={destination}\n")
|
||||
PY
|
||||
|
||||
- name: Run Apple native reliability tests
|
||||
run: |
|
||||
xcodebuild test \
|
||||
-workspace "${{ matrix.workspace }}" \
|
||||
-scheme Runner \
|
||||
-configuration Debug \
|
||||
-destination "$APPLE_TEST_DESTINATION" \
|
||||
-disableAutomaticPackageResolution \
|
||||
CODE_SIGNING_ALLOWED=NO \
|
||||
COMPILER_INDEX_STORE_ENABLE=NO
|
||||
|
||||
windows-native-test:
|
||||
name: Windows native reliability (${{ matrix.arch }})
|
||||
runs-on: ${{ matrix.runner }}
|
||||
permissions:
|
||||
contents: read
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
include:
|
||||
- arch: x64
|
||||
runner: windows-latest
|
||||
flutter_setup: action
|
||||
- arch: arm64
|
||||
runner: windows-11-arm
|
||||
flutter_setup: git
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
- name: Install 7-Zip
|
||||
if: matrix.arch == 'arm64'
|
||||
shell: pwsh
|
||||
run: choco install 7zip -y
|
||||
|
||||
- name: Setup Flutter
|
||||
if: matrix.flutter_setup == 'action'
|
||||
uses: subosito/flutter-action@1a449444c387b1966244ae4d4f8c696479add0b2 # v2
|
||||
with:
|
||||
channel: "stable"
|
||||
flutter-version: ${{ env.FLUTTER_VERSION }}
|
||||
cache: true
|
||||
pub-cache: false
|
||||
|
||||
- name: Set up Flutter from its pinned commit
|
||||
if: matrix.flutter_setup == 'git'
|
||||
uses: ./.github/actions/setup-flutter-git
|
||||
|
||||
- name: Install locked Dart dependencies
|
||||
shell: pwsh
|
||||
run: flutter pub get --enforce-lockfile --no-example
|
||||
|
||||
- name: Install patched Flutter engine
|
||||
shell: pwsh
|
||||
run: |
|
||||
flutter precache --windows
|
||||
.\windows\tool\install-patched-engine.ps1
|
||||
|
||||
- name: Prepare Flutter Windows configuration
|
||||
shell: pwsh
|
||||
run: flutter build windows --debug --config-only --no-pub
|
||||
|
||||
- name: Configure Windows native reliability tests
|
||||
shell: pwsh
|
||||
run: |
|
||||
$buildDir = "build/windows/${{ matrix.arch }}"
|
||||
cmake -S windows -B $buildDir `
|
||||
-DPLEZY_BUILD_MPV_PROPERTY_CONTRACT_TESTS=ON `
|
||||
-DPLEZY_BUILD_DISPLAY_RECOVERY_TESTS=ON
|
||||
|
||||
- name: Build Windows native reliability tests
|
||||
shell: pwsh
|
||||
run: |
|
||||
$buildDir = "build/windows/${{ matrix.arch }}"
|
||||
cmake --build $buildDir --config Debug --parallel 2 --target `
|
||||
mpv_property_result_contract_test `
|
||||
mpv_player_property_contract_test `
|
||||
display_mode_manager_test
|
||||
|
||||
- name: Run Windows native reliability tests
|
||||
shell: pwsh
|
||||
run: |
|
||||
$buildDir = "build/windows/${{ matrix.arch }}"
|
||||
ctest --test-dir "$buildDir/runner" -C Debug --output-on-failure --no-tests=error
|
||||
|
||||
dependency-check:
|
||||
name: Dependency Validation
|
||||
runs-on: ubuntu-latest
|
||||
@@ -205,18 +511,20 @@ jobs:
|
||||
contents: read
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v4
|
||||
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
- name: Setup Flutter
|
||||
uses: subosito/flutter-action@v2
|
||||
uses: subosito/flutter-action@1a449444c387b1966244ae4d4f8c696479add0b2 # v2
|
||||
with:
|
||||
channel: "stable"
|
||||
flutter-version: "3.44.0"
|
||||
flutter-version: ${{ env.FLUTTER_VERSION }}
|
||||
cache: true
|
||||
pub-cache: false
|
||||
|
||||
- name: Cache Pub dependencies
|
||||
uses: actions/cache@v4
|
||||
uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6
|
||||
with:
|
||||
path: |
|
||||
~/.pub-cache
|
||||
@@ -227,3 +535,43 @@ jobs:
|
||||
flutter clean
|
||||
flutter pub get
|
||||
flutter pub outdated
|
||||
|
||||
server:
|
||||
name: Server checks
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: read
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
- name: Setup Go
|
||||
uses: actions/setup-go@924ae3a1cded613372ab5595356fb5720e22ba16 # v6
|
||||
with:
|
||||
go-version-file: server/go.mod
|
||||
cache-dependency-path: server/go.sum
|
||||
|
||||
- name: Run server checks
|
||||
run: scripts/ci_server_checks.sh
|
||||
|
||||
|
||||
website:
|
||||
name: Website checks
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: read
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
- name: Setup Bun
|
||||
uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2
|
||||
with:
|
||||
bun-version: "1.3.14"
|
||||
|
||||
- name: Run website checks
|
||||
run: scripts/ci_website_checks.sh
|
||||
|
||||
@@ -0,0 +1,307 @@
|
||||
name: E2E - Maestro
|
||||
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- main
|
||||
# Keep untrusted code on the read-only pull_request event. Never use pull_request_target here.
|
||||
pull_request:
|
||||
branches:
|
||||
- main
|
||||
workflow_dispatch:
|
||||
|
||||
concurrency:
|
||||
group: maestro-${{ github.workflow }}-${{ github.ref }}
|
||||
cancel-in-progress: true
|
||||
|
||||
jobs:
|
||||
android-maestro:
|
||||
name: Android Maestro suites
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 150
|
||||
permissions:
|
||||
contents: read
|
||||
env:
|
||||
FLUTTER_VERSION: "3.44.0"
|
||||
MAESTRO_VERSION: "2.6.1"
|
||||
MAESTRO_SHA256: "3440825f514f537c6a96bcf5de995780c2a4a7f83a43208fdc95d4f1fecfad3b"
|
||||
MAESTRO_CLI_NO_ANALYTICS: "1"
|
||||
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
- name: Enable KVM
|
||||
run: |
|
||||
echo 'KERNEL=="kvm", GROUP="kvm", MODE="0666", OPTIONS+="static_node=kvm"' \
|
||||
| sudo tee /etc/udev/rules.d/99-kvm4all.rules
|
||||
sudo udevadm control --reload-rules
|
||||
sudo udevadm trigger --name-match=kvm
|
||||
|
||||
- name: Setup Java
|
||||
uses: actions/setup-java@03ad4de0992f5dab5e18fcb136590ce7c4a0ac95 # v5
|
||||
with:
|
||||
distribution: temurin
|
||||
java-version: "21"
|
||||
|
||||
- name: Setup Flutter
|
||||
uses: subosito/flutter-action@1a449444c387b1966244ae4d4f8c696479add0b2 # v2
|
||||
with:
|
||||
channel: stable
|
||||
flutter-version: ${{ env.FLUTTER_VERSION }}
|
||||
cache: true
|
||||
pub-cache: false
|
||||
|
||||
- name: Cache Pub dependencies
|
||||
id: pub-cache
|
||||
uses: actions/cache/restore@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6
|
||||
with:
|
||||
path: ~/.pub-cache
|
||||
key: ${{ runner.os }}-pub-v3-${{ hashFiles('**/pubspec.yaml', '**/pubspec.lock') }}
|
||||
|
||||
- name: Cache Gradle
|
||||
id: gradle-cache
|
||||
uses: actions/cache/restore@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6
|
||||
with:
|
||||
path: |
|
||||
~/.gradle/caches
|
||||
~/.gradle/wrapper
|
||||
key: ${{ runner.os }}-gradle-e2e-${{ hashFiles('**/*.gradle*', '**/gradle-wrapper.properties') }}
|
||||
restore-keys: |
|
||||
${{ runner.os }}-gradle-e2e-
|
||||
${{ runner.os }}-gradle-
|
||||
|
||||
- name: Install dependencies
|
||||
run: flutter pub get
|
||||
|
||||
- name: Build debug APK
|
||||
run: flutter build apk --debug --dart-define=PLEZY_MAESTRO_E2E=true
|
||||
|
||||
- name: Cache Maestro CLI
|
||||
id: maestro-cache
|
||||
uses: actions/cache/restore@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6
|
||||
with:
|
||||
path: |
|
||||
~/.maestro/bin
|
||||
~/.maestro/lib
|
||||
key: ${{ runner.os }}-maestro-${{ env.MAESTRO_VERSION }}
|
||||
|
||||
- name: Install verified Maestro CLI
|
||||
if: steps.maestro-cache.outputs.cache-hit != 'true'
|
||||
run: |
|
||||
archive="$RUNNER_TEMP/maestro.zip"
|
||||
staging="$RUNNER_TEMP/maestro-install"
|
||||
curl --fail --location --retry 3 \
|
||||
--output "$archive" \
|
||||
"https://github.com/mobile-dev-inc/maestro/releases/download/cli-${MAESTRO_VERSION}/maestro.zip"
|
||||
echo "${MAESTRO_SHA256} ${archive}" | sha256sum --check -
|
||||
rm -rf "$staging" "$HOME/.maestro/bin" "$HOME/.maestro/lib"
|
||||
mkdir -p "$staging" "$HOME/.maestro"
|
||||
unzip -q "$archive" -d "$staging"
|
||||
mv "$staging/maestro/bin" "$HOME/.maestro/bin"
|
||||
mv "$staging/maestro/lib" "$HOME/.maestro/lib"
|
||||
|
||||
- name: Add Maestro to PATH
|
||||
run: |
|
||||
echo "$HOME/.maestro/bin" >> "$GITHUB_PATH"
|
||||
"$HOME/.maestro/bin/maestro" --version
|
||||
|
||||
- name: Setup Docker Buildx
|
||||
uses: docker/setup-buildx-action@bb05f3f5519dd87d3ba754cc423b652a5edd6d2c # v4
|
||||
|
||||
- name: Build deterministic Jellyfin image for pull request
|
||||
if: github.event_name == 'pull_request'
|
||||
uses: docker/build-push-action@53b7df96c91f9c12dcc8a07bcb9ccacbed38856a # v7
|
||||
with:
|
||||
context: .
|
||||
file: .maestro/jellyfin-demo/Dockerfile
|
||||
load: true
|
||||
tags: plezy-jellyfin-demo:local
|
||||
cache-from: type=gha,scope=maestro-jellyfin
|
||||
|
||||
- name: Build and cache deterministic Jellyfin image
|
||||
if: github.event_name != 'pull_request'
|
||||
uses: docker/build-push-action@53b7df96c91f9c12dcc8a07bcb9ccacbed38856a # v7
|
||||
with:
|
||||
context: .
|
||||
file: .maestro/jellyfin-demo/Dockerfile
|
||||
load: true
|
||||
tags: plezy-jellyfin-demo:local
|
||||
cache-from: type=gha,scope=maestro-jellyfin
|
||||
cache-to: type=gha,mode=max,scope=maestro-jellyfin
|
||||
|
||||
- name: Cache Android 15 AVD
|
||||
id: api35-avd-cache
|
||||
uses: actions/cache/restore@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6
|
||||
with:
|
||||
path: |
|
||||
~/.android/avd/maestro-api35.avd
|
||||
~/.android/avd/maestro-api35.ini
|
||||
key: ${{ runner.os }}-avd-v1-api35-x86_64-pixel_6
|
||||
|
||||
- name: Generate Android 15 AVD snapshot
|
||||
if: steps.api35-avd-cache.outputs.cache-hit != 'true'
|
||||
uses: reactivecircus/android-emulator-runner@a421e43855164a8197daf9d8d40fe71c6996bb0d # v2
|
||||
with:
|
||||
api-level: 35
|
||||
arch: x86_64
|
||||
profile: pixel_6
|
||||
avd-name: maestro-api35
|
||||
force-avd-creation: false
|
||||
disable-animations: false
|
||||
emulator-options: >-
|
||||
-no-window -gpu swiftshader_indirect -noaudio
|
||||
-no-boot-anim -camera-back none
|
||||
script: echo "Generated Android 15 AVD snapshot."
|
||||
|
||||
- name: Cache Android 9 AVD
|
||||
id: api28-avd-cache
|
||||
uses: actions/cache/restore@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6
|
||||
with:
|
||||
path: |
|
||||
~/.android/avd/maestro-api28.avd
|
||||
~/.android/avd/maestro-api28.ini
|
||||
key: ${{ runner.os }}-avd-v1-api28-x86-pixel_2-playstore
|
||||
|
||||
- name: Generate Android 9 AVD snapshot
|
||||
if: steps.api28-avd-cache.outputs.cache-hit != 'true'
|
||||
uses: reactivecircus/android-emulator-runner@a421e43855164a8197daf9d8d40fe71c6996bb0d # v2
|
||||
with:
|
||||
api-level: 28
|
||||
target: google_apis_playstore
|
||||
arch: x86
|
||||
profile: pixel_2
|
||||
avd-name: maestro-api28
|
||||
force-avd-creation: false
|
||||
disable-animations: false
|
||||
emulator-options: >-
|
||||
-no-window -gpu swiftshader_indirect -noaudio
|
||||
-no-boot-anim -camera-back none
|
||||
script: echo "Generated Android 9 AVD snapshot."
|
||||
|
||||
- name: Run Android 15 instrumentation
|
||||
id: api35-instrumentation
|
||||
continue-on-error: true
|
||||
uses: reactivecircus/android-emulator-runner@a421e43855164a8197daf9d8d40fe71c6996bb0d # v2
|
||||
with:
|
||||
api-level: 35
|
||||
arch: x86_64
|
||||
profile: pixel_6
|
||||
avd-name: maestro-api35-instrumentation
|
||||
force-avd-creation: false
|
||||
disable-animations: true
|
||||
emulator-options: >-
|
||||
-no-snapshot-save -no-window -gpu swiftshader_indirect -noaudio
|
||||
-no-boot-anim -camera-back none
|
||||
script: python3 scripts/run_maestro_ci.py android-15-instrumentation
|
||||
|
||||
- name: Run Android 15 suites
|
||||
id: api35
|
||||
continue-on-error: true
|
||||
uses: reactivecircus/android-emulator-runner@a421e43855164a8197daf9d8d40fe71c6996bb0d # v2
|
||||
env:
|
||||
MAESTRO_SKIP_BUILD: "1"
|
||||
MAESTRO_SKIP_JELLYFIN_BUILD: "1"
|
||||
MAESTRO_DEVICE_ID: emulator-5554
|
||||
with:
|
||||
api-level: 35
|
||||
arch: x86_64
|
||||
profile: pixel_6
|
||||
avd-name: maestro-api35
|
||||
force-avd-creation: false
|
||||
disable-animations: true
|
||||
emulator-options: >-
|
||||
-no-snapshot-save -no-window -gpu swiftshader_indirect -noaudio
|
||||
-no-boot-anim -camera-back none
|
||||
script: python3 scripts/run_maestro_ci.py android-15
|
||||
|
||||
- name: Run Android 9 legacy playback
|
||||
id: api28
|
||||
continue-on-error: true
|
||||
uses: reactivecircus/android-emulator-runner@a421e43855164a8197daf9d8d40fe71c6996bb0d # v2
|
||||
env:
|
||||
MAESTRO_SKIP_BUILD: "1"
|
||||
MAESTRO_SKIP_JELLYFIN_BUILD: "1"
|
||||
MAESTRO_DEVICE_ID: emulator-5554
|
||||
with:
|
||||
api-level: 28
|
||||
target: google_apis_playstore
|
||||
arch: x86
|
||||
profile: pixel_2
|
||||
avd-name: maestro-api28
|
||||
force-avd-creation: false
|
||||
disable-animations: true
|
||||
emulator-options: >-
|
||||
-no-snapshot-save -no-window -gpu swiftshader_indirect -noaudio
|
||||
-no-boot-anim -camera-back none
|
||||
script: python3 scripts/run_maestro_ci.py android-9
|
||||
# Fork PRs may restore trusted caches, but only trusted events publish cache entries.
|
||||
- name: Save Pub dependency cache
|
||||
if: github.event_name != 'pull_request' && steps.pub-cache.outputs.cache-hit != 'true'
|
||||
uses: actions/cache/save@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6
|
||||
with:
|
||||
path: ~/.pub-cache
|
||||
key: ${{ steps.pub-cache.outputs.cache-primary-key }}
|
||||
|
||||
- name: Save Gradle cache
|
||||
if: github.event_name != 'pull_request' && steps.gradle-cache.outputs.cache-hit != 'true'
|
||||
uses: actions/cache/save@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6
|
||||
with:
|
||||
path: |
|
||||
~/.gradle/caches
|
||||
~/.gradle/wrapper
|
||||
key: ${{ steps.gradle-cache.outputs.cache-primary-key }}
|
||||
|
||||
- name: Save Maestro CLI cache
|
||||
if: github.event_name != 'pull_request' && steps.maestro-cache.outputs.cache-hit != 'true'
|
||||
uses: actions/cache/save@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6
|
||||
with:
|
||||
path: |
|
||||
~/.maestro/bin
|
||||
~/.maestro/lib
|
||||
key: ${{ steps.maestro-cache.outputs.cache-primary-key }}
|
||||
|
||||
- name: Save Android 15 AVD cache
|
||||
if: github.event_name != 'pull_request' && steps.api35-avd-cache.outputs.cache-hit != 'true'
|
||||
uses: actions/cache/save@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6
|
||||
with:
|
||||
path: |
|
||||
~/.android/avd/maestro-api35.avd
|
||||
~/.android/avd/maestro-api35.ini
|
||||
key: ${{ steps.api35-avd-cache.outputs.cache-primary-key }}
|
||||
|
||||
- name: Save Android 9 AVD cache
|
||||
if: github.event_name != 'pull_request' && steps.api28-avd-cache.outputs.cache-hit != 'true'
|
||||
uses: actions/cache/save@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6
|
||||
with:
|
||||
path: |
|
||||
~/.android/avd/maestro-api28.avd
|
||||
~/.android/avd/maestro-api28.ini
|
||||
key: ${{ steps.api28-avd-cache.outputs.cache-primary-key }}
|
||||
|
||||
- name: Upload Maestro diagnostics
|
||||
if: always()
|
||||
uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4
|
||||
with:
|
||||
name: maestro-android-diagnostics
|
||||
path: |
|
||||
build/maestro
|
||||
build/maestro-offline
|
||||
build/maestro-real-jellyfin
|
||||
build/maestro-media
|
||||
build/maestro-tv
|
||||
build/maestro-recovery
|
||||
build/maestro-legacy
|
||||
~/.maestro/tests
|
||||
if-no-files-found: ignore
|
||||
retention-days: 7
|
||||
|
||||
- name: Report suite failures
|
||||
if: ${{ always() && (steps.api35-instrumentation.outcome != 'success' || steps.api35.outcome != 'success' || steps.api28.outcome != 'success') }}
|
||||
run: |
|
||||
echo "Android 15 instrumentation outcome: ${{ steps.api35-instrumentation.outcome }}"
|
||||
echo "Android 15 outcome: ${{ steps.api35.outcome }}"
|
||||
echo "Android 9 outcome: ${{ steps.api28.outcome }}"
|
||||
exit 1
|
||||
@@ -17,9 +17,17 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
steps:
|
||||
- name: Require the protected main branch
|
||||
run: |
|
||||
if [[ "$GITHUB_REF" != "refs/heads/main" ]]; then
|
||||
echo "Release automation may only run from refs/heads/main." >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v4
|
||||
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
|
||||
with:
|
||||
ref: main
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Validate version format
|
||||
|
||||
@@ -18,6 +18,14 @@ jobs:
|
||||
outputs:
|
||||
tag: ${{ steps.release.outputs.tag }}
|
||||
steps:
|
||||
- name: Require the default branch for manual runs
|
||||
if: github.event_name == 'workflow_dispatch'
|
||||
run: |
|
||||
if [[ "$GITHUB_REF" != "refs/heads/${{ github.event.repository.default_branch }}" ]]; then
|
||||
echo "Manual package updates may only run from the default branch." >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
- name: Resolve published release tag
|
||||
id: release
|
||||
env:
|
||||
@@ -60,7 +68,7 @@ jobs:
|
||||
env:
|
||||
RELEASE_TAG: ${{ needs.resolve-release.outputs.tag }}
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
|
||||
with:
|
||||
ref: ${{ github.event.repository.default_branch }}
|
||||
fetch-depth: 0
|
||||
@@ -95,7 +103,7 @@ jobs:
|
||||
needs: resolve-release
|
||||
runs-on: windows-latest
|
||||
steps:
|
||||
- uses: vedantmgoyal9/winget-releaser@v2
|
||||
- uses: vedantmgoyal9/winget-releaser@4ffc7888bffd451b357355dc214d43bb9f23917e # v2
|
||||
with:
|
||||
identifier: edde746.Plezy
|
||||
installers-regex: 'plezy-windows-installer\.exe$'
|
||||
@@ -110,7 +118,9 @@ jobs:
|
||||
env:
|
||||
RELEASE_TAG: ${{ needs.resolve-release.outputs.tag }}
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
|
||||
with:
|
||||
ref: ${{ github.event.repository.default_branch }}
|
||||
|
||||
- name: Download appcast.xml from release
|
||||
run: |
|
||||
|
||||
@@ -0,0 +1,15 @@
|
||||
flows:
|
||||
- "flows/*.yaml"
|
||||
testOutputDir: "build/maestro"
|
||||
executionOrder:
|
||||
continueOnFailure: false
|
||||
flowsOrder:
|
||||
- Fresh install authentication choices
|
||||
- Jellyfin onboarding reaches Home
|
||||
- Browse library and open media details
|
||||
- Search media and open a result
|
||||
- Start and exit video playback
|
||||
- Open empty downloads state
|
||||
- Manage profiles and open settings
|
||||
- Logout returns to authentication
|
||||
- Download and play a movie from Downloads
|
||||
@@ -0,0 +1,43 @@
|
||||
appId: com.edde746.plezy
|
||||
name: Fresh install authentication choices
|
||||
tags:
|
||||
- e2e
|
||||
- auth
|
||||
---
|
||||
- retry:
|
||||
maxRetries: 1
|
||||
commands:
|
||||
- launchApp:
|
||||
clearState: true
|
||||
permissions:
|
||||
all: allow
|
||||
- extendedWaitUntil:
|
||||
visible: "(?s)^(?:Sign in with Plex|Wait).*"
|
||||
timeout: 30000
|
||||
- tapOn:
|
||||
text: "Wait"
|
||||
optional: true
|
||||
- extendedWaitUntil:
|
||||
visible: "Sign in with Plex"
|
||||
timeout: 30000
|
||||
- assertVisible: "Show QR Code"
|
||||
- assertVisible: "Connect to Jellyfin"
|
||||
- tapOn: "Connect to Jellyfin"
|
||||
- extendedWaitUntil:
|
||||
visible: "Add Jellyfin server"
|
||||
timeout: 10000
|
||||
- assertVisible: "(?s)Server URLs.*"
|
||||
- assertVisible: "Find server"
|
||||
- runFlow:
|
||||
when:
|
||||
platform: iOS
|
||||
commands:
|
||||
- tapOn: "Back"
|
||||
- runFlow:
|
||||
when:
|
||||
platform: Android
|
||||
commands:
|
||||
- hideKeyboard:
|
||||
optional: true
|
||||
- back
|
||||
- assertVisible: "Sign in with Plex"
|
||||
@@ -0,0 +1,13 @@
|
||||
appId: com.edde746.plezy
|
||||
name: Jellyfin onboarding reaches Home
|
||||
tags:
|
||||
- e2e
|
||||
- onboarding
|
||||
---
|
||||
- runFlow: ../subflows/onboard_jellyfin.yaml
|
||||
- assertVisible: "(?s)^Home.*"
|
||||
- assertVisible: "(?s)^Recently Added.*"
|
||||
- assertVisible: "(?s).*(?:Alpha Archive|Bravo Beacon|Charlie Circuit|Delta Drive|Echo Engine|Foxtrot Frame|Gamma Garden|Hotel Horizon|India Index|Juliet Junction|Kilo Key|Lima Loop|Mike Matrix|November Node|Oscar Orbit|Papa Pipeline|Quebec Queue|Romeo Relay|Sierra Signal|Tango Track|Uniform Update|Victor View|Whiskey Widget|Xray XML|Yankee Yield|Zulu Zone).*"
|
||||
- assertVisible: "(?s)^Libraries.*"
|
||||
- assertVisible: "(?s)^Search.*"
|
||||
- assertVisible: "(?s)^Downloads.*"
|
||||
@@ -0,0 +1,33 @@
|
||||
appId: com.edde746.plezy
|
||||
name: Browse library and open media details
|
||||
tags:
|
||||
- e2e
|
||||
- library
|
||||
---
|
||||
- runFlow: ../subflows/ensure_onboarded.yaml
|
||||
- tapOn: "(?s)^Libraries.*"
|
||||
- tapOn: "Browse"
|
||||
- extendedWaitUntil:
|
||||
visible: "(?s).*Maestro Movies.*"
|
||||
timeout: 15000
|
||||
- extendedWaitUntil:
|
||||
visible: "(?s).*Alpha Archive.*"
|
||||
timeout: 15000
|
||||
- tapOn: "(?s).*Alpha Archive.*"
|
||||
- extendedWaitUntil:
|
||||
visible: "Overview"
|
||||
timeout: 15000
|
||||
- assertVisible: "A deterministic title for TV alphabet focus coverage."
|
||||
- assertVisible: "Play"
|
||||
- runFlow:
|
||||
when:
|
||||
platform: iOS
|
||||
commands:
|
||||
- tapOn:
|
||||
point: "6%, 9%"
|
||||
- runFlow:
|
||||
when:
|
||||
platform: Android
|
||||
commands:
|
||||
- back
|
||||
- assertVisible: "(?s).*Maestro Movies.*"
|
||||
@@ -0,0 +1,33 @@
|
||||
appId: com.edde746.plezy
|
||||
name: Search media and open a result
|
||||
tags:
|
||||
- e2e
|
||||
- search
|
||||
---
|
||||
- runFlow: ../subflows/ensure_onboarded.yaml
|
||||
- tapOn: "(?s)^Search.*"
|
||||
- extendedWaitUntil:
|
||||
visible: "Search movies, shows, music..."
|
||||
timeout: 10000
|
||||
- tapOn: "Search movies, shows, music..."
|
||||
- inputText: "Maestro"
|
||||
- runFlow:
|
||||
when:
|
||||
platform: iOS
|
||||
commands:
|
||||
- pressKey: ENTER
|
||||
- runFlow:
|
||||
when:
|
||||
platform: Android
|
||||
commands:
|
||||
- hideKeyboard
|
||||
- extendedWaitUntil:
|
||||
visible: "(?s).*Maestro Movie.*"
|
||||
timeout: 15000
|
||||
- tapOn: "(?s).*Maestro Movie.*"
|
||||
- extendedWaitUntil:
|
||||
visible: "Overview"
|
||||
timeout: 15000
|
||||
- assertVisible: "A deterministic movie used to verify Plezy's end-to-end flows."
|
||||
- back
|
||||
- assertVisible: "(?s)^Maestro Movie.*"
|
||||
@@ -0,0 +1,57 @@
|
||||
appId: com.edde746.plezy
|
||||
name: Start and exit video playback
|
||||
tags:
|
||||
- e2e
|
||||
- playback
|
||||
---
|
||||
- runFlow: ../subflows/ensure_onboarded.yaml
|
||||
- tapOn: "(?s)^Libraries.*"
|
||||
- tapOn: "Browse"
|
||||
- extendedWaitUntil:
|
||||
visible: "(?s).*Alpha Archive.*"
|
||||
timeout: 15000
|
||||
- tapOn: "(?s).*Alpha Archive.*"
|
||||
- extendedWaitUntil:
|
||||
visible: "Play"
|
||||
timeout: 15000
|
||||
- tapOn:
|
||||
text: "Play"
|
||||
waitToSettleTimeoutMs: 1000
|
||||
- runFlow:
|
||||
when:
|
||||
platform: iOS
|
||||
commands:
|
||||
- extendedWaitUntil:
|
||||
visible: "Video timeline"
|
||||
timeout: 20000
|
||||
- extendedWaitUntil:
|
||||
visible: "(?s).*Alpha Archive.*\\n0min\\n0:(?:0[1-9]|[1-5][0-9])\\n-.*"
|
||||
timeout: 10000
|
||||
- runFlow:
|
||||
when:
|
||||
platform: Android
|
||||
commands:
|
||||
- extendedWaitUntil:
|
||||
visible: "Pause"
|
||||
timeout: 20000
|
||||
- runFlow:
|
||||
when:
|
||||
platform: iOS
|
||||
commands:
|
||||
- tapOn:
|
||||
point: "90%, 5%"
|
||||
- extendedWaitUntil:
|
||||
visible: "Overview"
|
||||
timeout: 10000
|
||||
- runFlow:
|
||||
when:
|
||||
platform: Android
|
||||
commands:
|
||||
- back
|
||||
- extendedWaitUntil:
|
||||
notVisible: "Pause"
|
||||
timeout: 10000
|
||||
- back
|
||||
- extendedWaitUntil:
|
||||
visible: "Overview"
|
||||
timeout: 10000
|
||||
@@ -0,0 +1,11 @@
|
||||
appId: com.edde746.plezy
|
||||
name: Open empty downloads state
|
||||
tags:
|
||||
- e2e
|
||||
- downloads
|
||||
---
|
||||
- runFlow: ../subflows/ensure_onboarded.yaml
|
||||
- tapOn: "(?s)^Downloads.*"
|
||||
- extendedWaitUntil:
|
||||
visible: "No downloads"
|
||||
timeout: 15000
|
||||
@@ -0,0 +1,87 @@
|
||||
appId: com.edde746.plezy
|
||||
name: Manage profiles and open settings
|
||||
tags:
|
||||
- e2e
|
||||
- profiles
|
||||
- settings
|
||||
---
|
||||
- runFlow: ../subflows/ensure_onboarded.yaml
|
||||
- tapOn: "(?s)^M(?:.*Profiles.*)?$"
|
||||
- tapOn:
|
||||
text: "Profiles"
|
||||
above:
|
||||
text: "Settings"
|
||||
- extendedWaitUntil:
|
||||
visible: "Switch Profile"
|
||||
timeout: 10000
|
||||
- assertVisible: "(?s).*Maestro.*"
|
||||
- tapOn: "Add Plezy profile"
|
||||
- extendedWaitUntil:
|
||||
visible: "New profile"
|
||||
timeout: 10000
|
||||
- tapOn: "e.g. Guests, Kids, Family Room"
|
||||
- inputText: "E2E Guest"
|
||||
- runFlow:
|
||||
when:
|
||||
platform: iOS
|
||||
commands:
|
||||
- pressKey: ENTER
|
||||
- runFlow:
|
||||
when:
|
||||
platform: Android
|
||||
commands:
|
||||
- hideKeyboard
|
||||
- tapOn: "Continue"
|
||||
- extendedWaitUntil:
|
||||
visible: "Add to E2E Guest"
|
||||
timeout: 10000
|
||||
- assertVisible: "(?s)^Sign in with Plex.*"
|
||||
- assertVisible: "(?s)^Connect to Jellyfin.*"
|
||||
- runFlow:
|
||||
when:
|
||||
platform: iOS
|
||||
commands:
|
||||
- tapOn: "Back"
|
||||
- runFlow:
|
||||
when:
|
||||
platform: Android
|
||||
commands:
|
||||
- back
|
||||
- assertVisible: "Switch Profile"
|
||||
- assertVisible: "(?s).*E2E Guest.*"
|
||||
- runFlow:
|
||||
when:
|
||||
platform: iOS
|
||||
commands:
|
||||
- tapOn: "Back"
|
||||
- runFlow:
|
||||
when:
|
||||
platform: Android
|
||||
commands:
|
||||
- back
|
||||
- extendedWaitUntil:
|
||||
visible: "(?s)^Home.*"
|
||||
timeout: 10000
|
||||
- waitForAnimationToEnd:
|
||||
timeout: 5000
|
||||
- repeat:
|
||||
times: 2
|
||||
while:
|
||||
notVisible: "Settings"
|
||||
commands:
|
||||
- tapOn: "(?s)^M(?:.*Profiles.*)?$"
|
||||
- waitForAnimationToEnd:
|
||||
timeout: 3000
|
||||
- tapOn:
|
||||
text: "Settings"
|
||||
below:
|
||||
text: "Profiles"
|
||||
- extendedWaitUntil:
|
||||
visible: "(?s)^Appearance.*"
|
||||
timeout: 10000
|
||||
- assertVisible: "(?s)^Video Playback.*"
|
||||
- assertVisible: "(?s)^Connections.*"
|
||||
- runFlow:
|
||||
when:
|
||||
platform: Android
|
||||
file: ../subflows/settings_deep_checks.yaml
|
||||
@@ -0,0 +1,23 @@
|
||||
appId: com.edde746.plezy
|
||||
name: Logout returns to authentication
|
||||
tags:
|
||||
- e2e
|
||||
- auth
|
||||
---
|
||||
- runFlow: ../subflows/onboard_jellyfin.yaml
|
||||
- tapOn: "(?s)^M(?:.*Profiles.*)?$"
|
||||
- tapOn:
|
||||
text: "Log out"
|
||||
below:
|
||||
text: "Settings"
|
||||
- extendedWaitUntil:
|
||||
visible: "Are you sure you want to log out?"
|
||||
timeout: 10000
|
||||
- tapOn:
|
||||
text: "Log out"
|
||||
below:
|
||||
text: "Are you sure you want to log out?"
|
||||
- extendedWaitUntil:
|
||||
visible: "Sign in with Plex"
|
||||
timeout: 20000
|
||||
- assertVisible: "Connect to Jellyfin"
|
||||
@@ -0,0 +1,77 @@
|
||||
appId: com.edde746.plezy
|
||||
name: Download and play a movie from Downloads
|
||||
|
||||
tags:
|
||||
- e2e
|
||||
- downloads
|
||||
- playback
|
||||
---
|
||||
- runFlow: ../subflows/onboard_jellyfin.yaml
|
||||
- tapOn: "(?s)^Libraries.*"
|
||||
- tapOn: "Browse"
|
||||
- extendedWaitUntil:
|
||||
visible: "(?s).*Alpha Archive.*"
|
||||
timeout: 15000
|
||||
- tapOn: "(?s).*Alpha Archive.*"
|
||||
- extendedWaitUntil:
|
||||
visible: "Overview"
|
||||
timeout: 15000
|
||||
- tapOn: "Download"
|
||||
- runFlow:
|
||||
when:
|
||||
platform: iOS
|
||||
commands:
|
||||
- tapOn:
|
||||
point: "6%, 9%"
|
||||
- runFlow:
|
||||
when:
|
||||
platform: Android
|
||||
commands:
|
||||
- back
|
||||
- extendedWaitUntil:
|
||||
visible: "Maestro Movies"
|
||||
timeout: 10000
|
||||
- tapOn: "(?s)^Downloads.*"
|
||||
- tapOn:
|
||||
text: "Movies"
|
||||
waitToSettleTimeoutMs: 3000
|
||||
- extendedWaitUntil:
|
||||
visible: "(?s).*Alpha Archive.*"
|
||||
timeout: 60000
|
||||
- runScript:
|
||||
file: ../scripts/set_jellyfin_offline.js
|
||||
env:
|
||||
JELLYFIN_CONTROL_URL: ${JELLYFIN_CONTROL_URL}
|
||||
- waitForAnimationToEnd:
|
||||
timeout: 3000
|
||||
- tapOn: "(?s).*Alpha Archive.*"
|
||||
- extendedWaitUntil:
|
||||
visible: "Overview"
|
||||
timeout: 15000
|
||||
- tapOn:
|
||||
text: "Play"
|
||||
waitToSettleTimeoutMs: 1000
|
||||
- extendedWaitUntil:
|
||||
visible: "Pause"
|
||||
timeout: 30000
|
||||
- runFlow:
|
||||
when:
|
||||
platform: iOS
|
||||
commands:
|
||||
- tapOn:
|
||||
point: "90%, 5%"
|
||||
- extendedWaitUntil:
|
||||
visible: "Overview"
|
||||
timeout: 10000
|
||||
- runFlow:
|
||||
when:
|
||||
platform: Android
|
||||
commands:
|
||||
- back
|
||||
- extendedWaitUntil:
|
||||
notVisible: "Pause"
|
||||
timeout: 10000
|
||||
- back
|
||||
- extendedWaitUntil:
|
||||
visible: "Overview"
|
||||
timeout: 10000
|
||||
@@ -0,0 +1,56 @@
|
||||
# syntax=docker/dockerfile:1.7
|
||||
|
||||
ARG JELLYFIN_IMAGE=jellyfin/jellyfin:10.11.11@sha256:aefb67e6a7ff1debdd154a78a7bbb780fd0c873d8639210a7f6a2016ad2b35db
|
||||
|
||||
FROM ${JELLYFIN_IMAGE} AS seed
|
||||
|
||||
USER root
|
||||
RUN apt-get update \
|
||||
&& apt-get install --yes --no-install-recommends ca-certificates python3 \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
WORKDIR /opt/plezy-demo
|
||||
COPY scripts/maestro_fixtures.py scripts/maestro_real_jellyfin.py scripts/prepare_maestro_media.py ./
|
||||
COPY .maestro/jellyfin-demo/seed.sh ./seed.sh
|
||||
|
||||
ARG PLEZY_DEMO_MEDIA_BASE_URL=https://demo-files.plezy.app/media-samples/
|
||||
ARG PLEZY_DEMO_MEDIA_REVISION=2026-07-14
|
||||
ARG PLEZY_DEMO_MEDIA_DURATION=300
|
||||
RUN test -n "${PLEZY_DEMO_MEDIA_REVISION}" \
|
||||
&& python3 maestro_real_jellyfin.py download-codecs \
|
||||
--base-url "${PLEZY_DEMO_MEDIA_BASE_URL}" \
|
||||
--output-dir /tmp/plezy-codecs \
|
||||
&& PATH="/usr/lib/jellyfin-ffmpeg:${PATH}" python3 prepare_maestro_media.py \
|
||||
/tmp/plezy-codecs /tmp/plezy-codecs-prepared \
|
||||
--duration "${PLEZY_DEMO_MEDIA_DURATION}" \
|
||||
--extend av1_opus_ass_srt.mkv \
|
||||
--extend h264_eac3_multisub.mkv \
|
||||
--extend hevc10_flac_ass.mkv \
|
||||
&& python3 maestro_real_jellyfin.py prepare \
|
||||
--output-dir /media \
|
||||
--codec-source-dir /tmp/plezy-codecs-prepared \
|
||||
--include-codecs \
|
||||
&& rm -rf /tmp/plezy-codecs /tmp/plezy-codecs-prepared __pycache__ \
|
||||
&& chmod -R a=rX /media
|
||||
|
||||
RUN ./seed.sh
|
||||
|
||||
FROM ${JELLYFIN_IMAGE}
|
||||
|
||||
LABEL org.opencontainers.image.title="Plezy Jellyfin demo server" \
|
||||
org.opencontainers.image.description="Ready-to-run Jellyfin server with Plezy's deterministic codec demo catalog" \
|
||||
org.opencontainers.image.source="https://github.com/edde746/plezy"
|
||||
|
||||
COPY --from=seed /media /media
|
||||
COPY --from=seed /opt/plezy-demo/seed-config /opt/plezy-demo/seed-config
|
||||
COPY .maestro/jellyfin-demo/entrypoint.sh /usr/local/bin/plezy-demo-entrypoint
|
||||
|
||||
RUN chmod 0755 /usr/local/bin/plezy-demo-entrypoint \
|
||||
&& chmod -R a=rX /media /opt/plezy-demo/seed-config
|
||||
|
||||
ENV TZ=UTC \
|
||||
JELLYFIN_PublishedServerUrl=http://localhost:8096
|
||||
|
||||
EXPOSE 8096
|
||||
STOPSIGNAL SIGTERM
|
||||
ENTRYPOINT ["/usr/local/bin/plezy-demo-entrypoint"]
|
||||
Executable
+17
@@ -0,0 +1,17 @@
|
||||
#!/bin/sh
|
||||
set -eu
|
||||
|
||||
SEED_CONFIG=/opt/plezy-demo/seed-config
|
||||
SEED_MARKER=.plezy-demo-seed
|
||||
|
||||
if [ ! -f "/config/${SEED_MARKER}" ]; then
|
||||
existing="$(find /config -mindepth 1 -maxdepth 1 -print -quit)"
|
||||
if [ -n "${existing}" ]; then
|
||||
echo "Refusing to overwrite a non-demo Jellyfin configuration in /config." >&2
|
||||
echo "Start this image with a new or empty /config volume." >&2
|
||||
exit 1
|
||||
fi
|
||||
cp -a "${SEED_CONFIG}/." /config/
|
||||
fi
|
||||
|
||||
exec /jellyfin/jellyfin "$@"
|
||||
Executable
+46
@@ -0,0 +1,46 @@
|
||||
#!/bin/sh
|
||||
set -eu
|
||||
|
||||
SEED_ROOT=/opt/plezy-demo
|
||||
SEED_CONFIG="${SEED_ROOT}/seed-config"
|
||||
SEED_CACHE="${SEED_ROOT}/seed-cache"
|
||||
JELLYFIN_PID=""
|
||||
|
||||
stop_jellyfin() {
|
||||
if [ -n "${JELLYFIN_PID}" ] && kill -0 "${JELLYFIN_PID}" 2>/dev/null; then
|
||||
kill -TERM "${JELLYFIN_PID}"
|
||||
wait "${JELLYFIN_PID}" || true
|
||||
fi
|
||||
}
|
||||
|
||||
on_exit() {
|
||||
exit_status=$?
|
||||
if [ "${exit_status}" -ne 0 ] && [ -f "${SEED_ROOT}/seed-jellyfin.log" ]; then
|
||||
cat "${SEED_ROOT}/seed-jellyfin.log" >&2
|
||||
fi
|
||||
stop_jellyfin
|
||||
}
|
||||
trap on_exit EXIT
|
||||
trap 'exit 130' INT
|
||||
trap 'exit 143' TERM
|
||||
|
||||
mkdir -p "${SEED_CONFIG}/config" "${SEED_CONFIG}/log" "${SEED_CACHE}"
|
||||
JELLYFIN_DATA_DIR="${SEED_CONFIG}" \
|
||||
JELLYFIN_CONFIG_DIR="${SEED_CONFIG}/config" \
|
||||
JELLYFIN_LOG_DIR="${SEED_CONFIG}/log" \
|
||||
JELLYFIN_CACHE_DIR="${SEED_CACHE}" \
|
||||
XDG_CACHE_HOME="${SEED_CACHE}" \
|
||||
JELLYFIN_PublishedServerUrl="http://127.0.0.1:8096" \
|
||||
/jellyfin/jellyfin >"${SEED_ROOT}/seed-jellyfin.log" 2>&1 &
|
||||
JELLYFIN_PID=$!
|
||||
|
||||
python3 "${SEED_ROOT}/maestro_real_jellyfin.py" bootstrap \
|
||||
--url http://127.0.0.1:8096 \
|
||||
--timeout 180 \
|
||||
--include-codecs
|
||||
|
||||
stop_jellyfin
|
||||
JELLYFIN_PID=""
|
||||
rm -rf "${SEED_CONFIG}/log" "${SEED_CACHE}" "${SEED_ROOT}/seed-jellyfin.log"
|
||||
printf '%s\n' 'Plezy Jellyfin demo seed v1' >"${SEED_CONFIG}/.plezy-demo-seed"
|
||||
trap - EXIT INT TERM
|
||||
@@ -0,0 +1,12 @@
|
||||
flows:
|
||||
- "*.yaml"
|
||||
testOutputDir: "build/maestro-media"
|
||||
executionOrder:
|
||||
continueOnFailure: false
|
||||
flowsOrder:
|
||||
- Codec sample - UHD Dolby Vision TrueHD and PGS
|
||||
- Codec sample - Web Dolby Vision EAC3 Atmos and SRT
|
||||
- Codec sample - AV1 Opus ASS and SRT
|
||||
- Codec sample - H264 High10 DTS-HD and ASS
|
||||
- Codec sample - H264 EAC3 multilingual SRT
|
||||
- Codec sample - HEVC10 FLAC and ASS
|
||||
@@ -0,0 +1,28 @@
|
||||
appId: com.edde746.plezy
|
||||
name: Codec sample - UHD Dolby Vision TrueHD and PGS
|
||||
tags:
|
||||
- e2e
|
||||
- media
|
||||
- dolby-vision
|
||||
---
|
||||
- runFlow:
|
||||
file: ../subflows/open_codec_sample.yaml
|
||||
env:
|
||||
SAMPLE_TITLE: "Codec DV UHD TrueHD PGS"
|
||||
SAMPLE_OVERVIEW: "Dolby Vision profile 8 with an HDR fallback, TrueHD Atmos, AC-3, and PGS subtitles."
|
||||
- runFlow:
|
||||
file: ../subflows/switch_codec_tracks.yaml
|
||||
env:
|
||||
AUDIO_TRACK: "(?is)^English\\s+[^,]*(?:AC3|TrueHD|Surround|5\\.1|7\\.1)[^,]*$"
|
||||
SUBTITLE_TRACK: "(?is)^Spanish\\s+[^,]*(?:PGS|APPLICATION/PGS)[^,]*$"
|
||||
- back
|
||||
- waitForAnimationToEnd:
|
||||
timeout: 3000
|
||||
- runFlow:
|
||||
when:
|
||||
notVisible: "Overview"
|
||||
commands:
|
||||
- back
|
||||
- extendedWaitUntil:
|
||||
visible: "Overview"
|
||||
timeout: 10000
|
||||
@@ -0,0 +1,28 @@
|
||||
appId: com.edde746.plezy
|
||||
name: Codec sample - Web Dolby Vision EAC3 Atmos and SRT
|
||||
tags:
|
||||
- e2e
|
||||
- media
|
||||
- dolby-vision
|
||||
---
|
||||
- runFlow:
|
||||
file: ../subflows/open_codec_sample.yaml
|
||||
env:
|
||||
SAMPLE_TITLE: "Codec DV Web EAC3 SRT"
|
||||
SAMPLE_OVERVIEW: "Dolby Vision profile 8 with an HDR fallback, E-AC-3 Atmos, and multilingual SRT subtitles."
|
||||
- runFlow:
|
||||
file: ../subflows/switch_codec_tracks.yaml
|
||||
env:
|
||||
AUDIO_TRACK: "(?is)^German\\s+[^,]*(?:E-AC3|Dolby Digital Plus|Surround|5\\.1)[^,]*$"
|
||||
SUBTITLE_TRACK: "(?is)^German\\s+[^,]*(?:SRT|APPLICATION/X-SUBRIP)[^,]*$"
|
||||
- back
|
||||
- waitForAnimationToEnd:
|
||||
timeout: 3000
|
||||
- runFlow:
|
||||
when:
|
||||
notVisible: "Overview"
|
||||
commands:
|
||||
- back
|
||||
- extendedWaitUntil:
|
||||
visible: "Overview"
|
||||
timeout: 10000
|
||||
@@ -0,0 +1,28 @@
|
||||
appId: com.edde746.plezy
|
||||
name: Codec sample - AV1 Opus ASS and SRT
|
||||
tags:
|
||||
- e2e
|
||||
- media
|
||||
- av1
|
||||
---
|
||||
- runFlow:
|
||||
file: ../subflows/open_codec_sample.yaml
|
||||
env:
|
||||
SAMPLE_TITLE: "Codec AV1 Opus ASS"
|
||||
SAMPLE_OVERVIEW: "AV1 video with multilingual Opus audio, styled ASS subtitles, SRT subtitles, and embedded fonts."
|
||||
- runFlow:
|
||||
file: ../subflows/switch_codec_tracks.yaml
|
||||
env:
|
||||
AUDIO_TRACK: "(?is)^English\\s+[^,]*(?:Opus|Stereo)[^,]*$"
|
||||
SUBTITLE_TRACK: "(?is)^English\\s+[^,]*(?:ASS|TEXT/X-SSA)[^,]*$"
|
||||
- back
|
||||
- waitForAnimationToEnd:
|
||||
timeout: 3000
|
||||
- runFlow:
|
||||
when:
|
||||
notVisible: "Overview"
|
||||
commands:
|
||||
- back
|
||||
- extendedWaitUntil:
|
||||
visible: "Overview"
|
||||
timeout: 10000
|
||||
@@ -0,0 +1,28 @@
|
||||
appId: com.edde746.plezy
|
||||
name: Codec sample - H264 High10 DTS-HD and ASS
|
||||
tags:
|
||||
- e2e
|
||||
- media
|
||||
- h264-high10
|
||||
---
|
||||
- runFlow:
|
||||
file: ../subflows/open_codec_sample.yaml
|
||||
env:
|
||||
SAMPLE_TITLE: "Codec H264 High10 DTS-HD ASS"
|
||||
SAMPLE_OVERVIEW: "H.264 High 10 video with dual DTS-HD MA audio, styled ASS subtitles, and embedded fonts."
|
||||
- runFlow:
|
||||
file: ../subflows/switch_codec_tracks.yaml
|
||||
env:
|
||||
AUDIO_TRACK: "(?is)^Japanese\\s+[^,]*(?:DTS-HD MA|DTS|Surround|5\\.1)[^,]*$"
|
||||
SUBTITLE_TRACK: "(?is)^English\\s+[^,]*(?:ASS|TEXT/X-SSA)[^,]*$"
|
||||
- back
|
||||
- waitForAnimationToEnd:
|
||||
timeout: 3000
|
||||
- runFlow:
|
||||
when:
|
||||
notVisible: "Overview"
|
||||
commands:
|
||||
- back
|
||||
- extendedWaitUntil:
|
||||
visible: "Overview"
|
||||
timeout: 10000
|
||||
@@ -0,0 +1,29 @@
|
||||
appId: com.edde746.plezy
|
||||
name: Codec sample - H264 EAC3 multilingual SRT
|
||||
tags:
|
||||
- e2e
|
||||
- media
|
||||
- subtitles
|
||||
---
|
||||
- runFlow:
|
||||
file: ../subflows/open_codec_sample.yaml
|
||||
env:
|
||||
SAMPLE_TITLE: "Codec H264 EAC3 Multisub"
|
||||
SAMPLE_OVERVIEW: "H.264 video with three E-AC-3 audio tracks and a broad multilingual SRT subtitle set."
|
||||
- runFlow:
|
||||
file: ../subflows/switch_codec_tracks_deep.yaml
|
||||
env:
|
||||
AUDIO_TRACK: "(?is)^English\\s+[^,]*(?:Dolby Digital Plus|E-AC3|Surround|5\\.1)[^,]*$"
|
||||
SUBTITLE_TRACK: "(?is)^English\\s+[^,]*(?:SRT|APPLICATION/X-SUBRIP)[^,]*$"
|
||||
SHEET_MARKER: "(?is)^Japanese\\s+[^,]*(?:Dolby Digital Plus|E-AC3|Surround|5\\.1)[^,]*$"
|
||||
- back
|
||||
- waitForAnimationToEnd:
|
||||
timeout: 3000
|
||||
- runFlow:
|
||||
when:
|
||||
notVisible: "Overview"
|
||||
commands:
|
||||
- back
|
||||
- extendedWaitUntil:
|
||||
visible: "Overview"
|
||||
timeout: 10000
|
||||
@@ -0,0 +1,28 @@
|
||||
appId: com.edde746.plezy
|
||||
name: Codec sample - HEVC10 FLAC and ASS
|
||||
tags:
|
||||
- e2e
|
||||
- media
|
||||
- hevc
|
||||
---
|
||||
- runFlow:
|
||||
file: ../subflows/open_codec_sample.yaml
|
||||
env:
|
||||
SAMPLE_TITLE: "Codec HEVC10 FLAC ASS"
|
||||
SAMPLE_OVERVIEW: "HEVC Main 10 video with dual FLAC 5.1 audio, styled ASS subtitles, and embedded fonts."
|
||||
- runFlow:
|
||||
file: ../subflows/switch_codec_tracks.yaml
|
||||
env:
|
||||
AUDIO_TRACK: "(?is)^Japanese\\s+[^,]*(?:FLAC|Surround|5\\.1)[^,]*$"
|
||||
SUBTITLE_TRACK: "(?is)^English\\s+[^,]*(?:ASS|TEXT/X-SSA)[^,]*$"
|
||||
- back
|
||||
- waitForAnimationToEnd:
|
||||
timeout: 3000
|
||||
- runFlow:
|
||||
when:
|
||||
notVisible: "Overview"
|
||||
commands:
|
||||
- back
|
||||
- extendedWaitUntil:
|
||||
visible: "Overview"
|
||||
timeout: 10000
|
||||
@@ -0,0 +1,38 @@
|
||||
appId: com.edde746.plezy
|
||||
name: Real Jellyfin imports and opens codec media
|
||||
tags:
|
||||
- e2e
|
||||
- real-jellyfin
|
||||
- media
|
||||
---
|
||||
- runFlow: ../subflows/ensure_onboarded.yaml
|
||||
- tapOn: "(?s)^Search.*"
|
||||
- extendedWaitUntil:
|
||||
visible: "Search movies, shows, music..."
|
||||
timeout: 10000
|
||||
- tapOn: "Search movies, shows, music..."
|
||||
- inputText: "Codec DV UHD"
|
||||
- hideKeyboard
|
||||
- extendedWaitUntil:
|
||||
visible: "(?s).*Codec DV UHD TrueHD PGS.*"
|
||||
timeout: 15000
|
||||
- tapOn: "(?s).*Codec DV UHD TrueHD PGS.*"
|
||||
- extendedWaitUntil:
|
||||
visible: "Overview"
|
||||
timeout: 15000
|
||||
- assertVisible: "Dolby Vision profile 8 with an HDR fallback, TrueHD Atmos, AC-3, and PGS subtitles."
|
||||
- tapOn:
|
||||
text: "Play"
|
||||
waitToSettleTimeoutMs: 1000
|
||||
- extendedWaitUntil:
|
||||
notVisible: "Overview"
|
||||
timeout: 15000
|
||||
- back
|
||||
- runFlow:
|
||||
when:
|
||||
notVisible: "Overview"
|
||||
commands:
|
||||
- back
|
||||
- extendedWaitUntil:
|
||||
visible: "Overview"
|
||||
timeout: 10000
|
||||
@@ -0,0 +1,54 @@
|
||||
appId: com.edde746.plezy
|
||||
name: Jellyfin music browsing survives grouping reloads
|
||||
|
||||
tags:
|
||||
- e2e
|
||||
- real-jellyfin
|
||||
- music
|
||||
- regression
|
||||
---
|
||||
- runFlow: ../subflows/ensure_onboarded.yaml
|
||||
- assertVisible: "(?s)^Latest Albums in Maestro Music.*"
|
||||
- assertVisible: "(?s)^Regression Album.*"
|
||||
- tapOn: "(?s)^Libraries.*"
|
||||
- extendedWaitUntil:
|
||||
visible: "Maestro Movies"
|
||||
timeout: 15000
|
||||
- tapOn: "Maestro Movies"
|
||||
- extendedWaitUntil:
|
||||
visible: "Maestro Music"
|
||||
timeout: 10000
|
||||
- tapOn: "Maestro Music"
|
||||
- extendedWaitUntil:
|
||||
visible: "(?s)^Regression Album.*"
|
||||
timeout: 15000
|
||||
- assertVisible: "(?s)^Latest Albums in Maestro Music.*"
|
||||
- tapOn: "Browse"
|
||||
- runFlow:
|
||||
when:
|
||||
visible: "Retry"
|
||||
commands:
|
||||
- tapOn: "Retry"
|
||||
- extendedWaitUntil:
|
||||
visible: "Maestro Artist"
|
||||
timeout: 15000
|
||||
- assertNotVisible: "(?i).*connection timeout.*"
|
||||
- tapOn: "Library options"
|
||||
- tapOn: "(?s)^Grouping.*Artists$"
|
||||
- tapOn: "Albums"
|
||||
- extendedWaitUntil:
|
||||
visible: "(?s)^Regression Album.*"
|
||||
timeout: 15000
|
||||
- tapOn: "Library options"
|
||||
- tapOn: "(?s)^Grouping.*Albums$"
|
||||
- tapOn: "Tracks"
|
||||
- extendedWaitUntil:
|
||||
visible: "(?s)^Resilient Track.*"
|
||||
timeout: 15000
|
||||
- tapOn: "Library options"
|
||||
- tapOn: "(?s)^Grouping.*Tracks$"
|
||||
- tapOn: "Folders"
|
||||
- extendedWaitUntil:
|
||||
visible: "Maestro Artist"
|
||||
timeout: 15000
|
||||
- assertNotVisible: "(?i).*connection timeout.*"
|
||||
@@ -0,0 +1,66 @@
|
||||
appId: com.edde746.plezy
|
||||
name: Profile switching isolates Jellyfin content and libraries
|
||||
|
||||
tags:
|
||||
- e2e
|
||||
- regression
|
||||
- profiles
|
||||
---
|
||||
- runFlow: ../subflows/onboard_jellyfin.yaml
|
||||
- assertVisible: "(?s).*Zulu Zone.*"
|
||||
- tapOn: "(?s)^Libraries.*"
|
||||
- extendedWaitUntil:
|
||||
visible: "Maestro Movies"
|
||||
timeout: 15000
|
||||
- assertNotVisible: "Guest Movies"
|
||||
- tapOn: "(?s)^Home.*"
|
||||
- runFlow: ../subflows/create_guest_jellyfin_profile.yaml
|
||||
- tapOn: "(?s)^E\nE2E Guest\n.*"
|
||||
- extendedWaitUntil:
|
||||
visible: "(?s).*Guest Galaxy.*"
|
||||
timeout: 30000
|
||||
- assertNotVisible: "(?s).*Zulu Zone.*"
|
||||
- tapOn: "(?s)^Libraries.*"
|
||||
- extendedWaitUntil:
|
||||
visible: "Guest Movies"
|
||||
timeout: 15000
|
||||
- assertNotVisible: "Maestro Movies"
|
||||
- tapOn: "(?s)^Home.*"
|
||||
- extendedWaitUntil:
|
||||
visible: "(?s).*Guest Galaxy.*"
|
||||
timeout: 15000
|
||||
- tapOn: "E"
|
||||
- tapOn:
|
||||
text: "Profiles"
|
||||
above:
|
||||
text: "Settings"
|
||||
- extendedWaitUntil:
|
||||
visible: "Switch Profile"
|
||||
timeout: 10000
|
||||
- tapOn: "(?s)^M\nMaestro\n.*"
|
||||
- extendedWaitUntil:
|
||||
visible: "(?s).*Zulu Zone.*"
|
||||
timeout: 30000
|
||||
- assertNotVisible: "(?s).*Guest Galaxy.*"
|
||||
- tapOn: "(?s)^Libraries.*"
|
||||
- extendedWaitUntil:
|
||||
visible: "Maestro Movies"
|
||||
timeout: 15000
|
||||
- assertNotVisible: "Guest Movies"
|
||||
- tapOn: "(?s)^Home.*"
|
||||
- extendedWaitUntil:
|
||||
visible: "(?s).*Zulu Zone.*"
|
||||
timeout: 15000
|
||||
- tapOn: "M"
|
||||
- tapOn:
|
||||
text: "Profiles"
|
||||
above:
|
||||
text: "Settings"
|
||||
- extendedWaitUntil:
|
||||
visible: "Switch Profile"
|
||||
timeout: 10000
|
||||
- tapOn: "(?s)^E\nE2E Guest\n.*"
|
||||
- extendedWaitUntil:
|
||||
visible: "(?s).*Guest Galaxy.*"
|
||||
timeout: 30000
|
||||
- assertNotVisible: "(?s).*Zulu Zone.*"
|
||||
@@ -0,0 +1,51 @@
|
||||
appId: com.edde746.plezy
|
||||
name: Removing a Jellyfin profile connection leaves no orphaned session
|
||||
|
||||
tags:
|
||||
- e2e
|
||||
- regression
|
||||
- profiles
|
||||
- teardown
|
||||
---
|
||||
- runFlow: ../subflows/onboard_jellyfin.yaml
|
||||
- runFlow: ../subflows/create_guest_jellyfin_profile.yaml
|
||||
- tapOn:
|
||||
text: "Manage"
|
||||
index: 1
|
||||
- tapOn: "Manage"
|
||||
- extendedWaitUntil:
|
||||
visible: "Profile name"
|
||||
timeout: 10000
|
||||
- assertVisible: "(?s)^E2E Guest.*"
|
||||
- assertVisible: "(?s)^Maestro Jellyfin.*Default.*$"
|
||||
- tapOn: "Manage"
|
||||
- extendedWaitUntil:
|
||||
visible: "Remove"
|
||||
timeout: 10000
|
||||
- tapOn: "Remove"
|
||||
- extendedWaitUntil:
|
||||
visible: "^Remove connection\\?$"
|
||||
timeout: 10000
|
||||
- assertVisible: "(?s).*E2E Guest's access to Maestro Jellyfin.*"
|
||||
- tapOn: "Remove"
|
||||
- extendedWaitUntil:
|
||||
notVisible: "(?s)^Maestro Jellyfin.*"
|
||||
timeout: 15000
|
||||
- assertVisible: "Delete profile"
|
||||
- retry:
|
||||
maxRetries: 2
|
||||
commands:
|
||||
- tapOn: "Delete profile"
|
||||
- extendedWaitUntil:
|
||||
visible: "^Delete profile\\?$"
|
||||
timeout: 10000
|
||||
- tapOn: "^Delete$"
|
||||
- extendedWaitUntil:
|
||||
visible: "Switch Profile"
|
||||
timeout: 30000
|
||||
- assertNotVisible: "(?s).*E2E Guest.*"
|
||||
- assertVisible: "(?s)^M\nMaestro\nActive.*"
|
||||
- back
|
||||
- extendedWaitUntil:
|
||||
visible: "(?s)^Recently Added in Maestro Movies.*"
|
||||
timeout: 30000
|
||||
@@ -0,0 +1,77 @@
|
||||
appId: com.edde746.plezy
|
||||
name: TV alphabet rail retains focus until returning to the media grid
|
||||
|
||||
tags:
|
||||
- e2e
|
||||
- regression
|
||||
- tv
|
||||
- focus
|
||||
---
|
||||
- runFlow: ../subflows/onboard_jellyfin_tv.yaml
|
||||
- pressKey: "Remote Dpad Left"
|
||||
- pressKey: "Remote Dpad Down"
|
||||
- pressKey: "Remote Dpad Down"
|
||||
- pressKey: "Remote Dpad Center"
|
||||
- extendedWaitUntil:
|
||||
visible: "Recommended"
|
||||
timeout: 15000
|
||||
- pressKey: "Remote Dpad Right"
|
||||
- pressKey: "Remote Dpad Up"
|
||||
- pressKey: "Remote Dpad Right"
|
||||
- pressKey: "Remote Dpad Center"
|
||||
- pressKey: "Remote Dpad Up"
|
||||
- pressKey: "Remote Dpad Right"
|
||||
- pressKey: "Remote Dpad Right"
|
||||
# The grid row must be matchable before the sort sheet opens, otherwise the
|
||||
# occlusion assertion below would pass for the wrong reason.
|
||||
- extendedWaitUntil:
|
||||
visible: "(?s).*Alpha Archive.*"
|
||||
timeout: 15000
|
||||
- pressKey: "Remote Dpad Center"
|
||||
- extendedWaitUntil:
|
||||
visible: "Title"
|
||||
timeout: 10000
|
||||
# The host renders the grid, the barrier, and the sheet in one Stack and never
|
||||
# unmounts the grid, so before 8e1904dd wrapped the barrier in BlockSemantics
|
||||
# an occluded row still read as visible to accessibility and to Maestro.
|
||||
- assertNotVisible: "(?s).*Alpha Archive.*"
|
||||
- pressKey: "Remote Dpad Center"
|
||||
- pressKey: "Remote Dpad Left"
|
||||
- extendedWaitUntil:
|
||||
notVisible: "Sort By"
|
||||
timeout: 10000
|
||||
- pressKey: "Remote Dpad Down"
|
||||
- extendedWaitUntil:
|
||||
visible: "(?s).*Alpha Archive.*"
|
||||
timeout: 15000
|
||||
- repeat:
|
||||
times: 8
|
||||
commands:
|
||||
- pressKey: "Remote Dpad Right"
|
||||
- repeat:
|
||||
times: 13
|
||||
commands:
|
||||
- pressKey: "Remote Dpad Down"
|
||||
- extendedWaitUntil:
|
||||
visible: "(?s).*Mike Matrix.*"
|
||||
timeout: 15000
|
||||
- assertNotVisible: "(?s).*Alpha Archive.*"
|
||||
- repeat:
|
||||
times: 13
|
||||
commands:
|
||||
- pressKey: "Remote Dpad Down"
|
||||
- extendedWaitUntil:
|
||||
visible: "(?s).*Zulu Zone.*"
|
||||
timeout: 15000
|
||||
- assertNotVisible: "(?s).*Mike Matrix.*"
|
||||
- pressKey: "Remote Dpad Left"
|
||||
- pressKey: "Remote Dpad Center"
|
||||
- extendedWaitUntil:
|
||||
visible: "Play"
|
||||
timeout: 15000
|
||||
- assertVisible: "(?s).*Zulu Zone.*"
|
||||
- pressKey: "back"
|
||||
- extendedWaitUntil:
|
||||
visible: "Browse"
|
||||
timeout: 15000
|
||||
- assertVisible: "(?s).*Zulu Zone.*"
|
||||
@@ -0,0 +1,50 @@
|
||||
appId: com.edde746.plezy
|
||||
name: TV hardware media and back keys follow player chrome state
|
||||
|
||||
tags:
|
||||
- e2e
|
||||
- regression
|
||||
- tv
|
||||
- playback
|
||||
---
|
||||
- runFlow: ../subflows/onboard_jellyfin_tv.yaml
|
||||
- pressKey: "Remote Dpad Center"
|
||||
- waitForAnimationToEnd:
|
||||
timeout: 5000
|
||||
- runFlow:
|
||||
when:
|
||||
visible: "(?s)^(Play|Resume).*$"
|
||||
commands:
|
||||
- pressKey: "Remote Dpad Center"
|
||||
- extendedWaitUntil:
|
||||
visible: "Pause"
|
||||
timeout: 30000
|
||||
- pressKey: "back"
|
||||
- extendedWaitUntil:
|
||||
notVisible: "Pause"
|
||||
timeout: 10000
|
||||
- pressKey: "Remote Media Play Pause"
|
||||
- extendedWaitUntil:
|
||||
visible: "Play"
|
||||
timeout: 10000
|
||||
- pressKey: "Remote Media Play Pause"
|
||||
- extendedWaitUntil:
|
||||
visible: "Pause"
|
||||
timeout: 10000
|
||||
- pressKey: "back"
|
||||
- extendedWaitUntil:
|
||||
notVisible: "Pause"
|
||||
timeout: 10000
|
||||
- assertNotVisible: "Overview"
|
||||
- pressKey: "back"
|
||||
- waitForAnimationToEnd:
|
||||
timeout: 5000
|
||||
- runFlow:
|
||||
when:
|
||||
visible: "Overview"
|
||||
commands:
|
||||
- pressKey: "back"
|
||||
- extendedWaitUntil:
|
||||
visible:
|
||||
id: "tv_browse_rail_selection"
|
||||
timeout: 15000
|
||||
@@ -0,0 +1,50 @@
|
||||
appId: com.edde746.plezy
|
||||
name: TV Back dismisses Next Episode without leaving playback
|
||||
tags:
|
||||
- e2e
|
||||
- regression
|
||||
- tv
|
||||
- playback
|
||||
---
|
||||
- runFlow: ../subflows/onboard_jellyfin_tv.yaml
|
||||
- tapOn:
|
||||
point: "6%,22%"
|
||||
- tapOn: "Search"
|
||||
- extendedWaitUntil:
|
||||
visible: "Search movies, shows, music..."
|
||||
timeout: 10000
|
||||
- tapOn: "Search movies, shows, music..."
|
||||
- inputText: "Maestro Show"
|
||||
- tapOn:
|
||||
point: "87%,90%"
|
||||
- extendedWaitUntil:
|
||||
visible: "(?s)^Maestro Show, TV show, (?:watched|unwatched)$"
|
||||
timeout: 15000
|
||||
- tapOn: "(?s)^Maestro Show, TV show, (?:watched|unwatched)$"
|
||||
- waitForAnimationToEnd:
|
||||
timeout: 5000
|
||||
- tapOn: "(?s)^Play S1E1$"
|
||||
- extendedWaitUntil:
|
||||
visible: "Pause"
|
||||
timeout: 30000
|
||||
- pressKey: "Remote Media Fast Forward"
|
||||
- pressKey: "Remote Media Fast Forward"
|
||||
- extendedWaitUntil:
|
||||
visible: "Next Episode"
|
||||
timeout: 20000
|
||||
- runFlow:
|
||||
when:
|
||||
platform: iOS
|
||||
commands:
|
||||
- tapOn: "Cancel"
|
||||
- runFlow:
|
||||
when:
|
||||
platform: Android
|
||||
commands:
|
||||
- pressKey: "back"
|
||||
- assertNotVisible: "Next Episode"
|
||||
- assertNotVisible: "Overview"
|
||||
- pressKey: "Remote Dpad Up"
|
||||
- extendedWaitUntil:
|
||||
visible: "(?s).*Maestro Episode 1.*"
|
||||
timeout: 10000
|
||||
@@ -0,0 +1,36 @@
|
||||
appId: com.edde746.plezy
|
||||
name: Playback recovers after a transient stream failure
|
||||
tags:
|
||||
- e2e
|
||||
- regression
|
||||
- playback
|
||||
- recovery
|
||||
---
|
||||
- runFlow: ../subflows/onboard_jellyfin.yaml
|
||||
- tapOn: "(?s)^Libraries.*"
|
||||
# Every seeded alphabet title carries the same dateadded, so which of them the
|
||||
# "Recently Added" rail returns is not stable across scans. This flow only
|
||||
# needs some playable movie, so accept any of them rather than pinning one and
|
||||
# failing whenever the tie-break lands elsewhere.
|
||||
- extendedWaitUntil:
|
||||
visible: "(?s).*(?:Alpha Archive|Bravo Beacon|Charlie Circuit|Delta Drive|Echo Engine|Foxtrot Frame|Gamma Garden|Hotel Horizon|India Index|Juliet Junction|Kilo Key|Lima Loop|Mike Matrix|November Node|Oscar Orbit|Papa Pipeline|Quebec Queue|Romeo Relay|Sierra Signal|Tango Track|Uniform Update|Victor View|Whiskey Widget|Xray XML|Yankee Yield|Zulu Zone).*, movie, .*"
|
||||
timeout: 15000
|
||||
- tapOn: "(?s).*(?:Alpha Archive|Bravo Beacon|Charlie Circuit|Delta Drive|Echo Engine|Foxtrot Frame|Gamma Garden|Hotel Horizon|India Index|Juliet Junction|Kilo Key|Lima Loop|Mike Matrix|November Node|Oscar Orbit|Papa Pipeline|Quebec Queue|Romeo Relay|Sierra Signal|Tango Track|Uniform Update|Victor View|Whiskey Widget|Xray XML|Yankee Yield|Zulu Zone).*, movie, .*"
|
||||
- extendedWaitUntil:
|
||||
visible: "Play"
|
||||
timeout: 15000
|
||||
- tapOn:
|
||||
text: "Play"
|
||||
waitToSettleTimeoutMs: 1000
|
||||
- extendedWaitUntil:
|
||||
visible: "Pause"
|
||||
timeout: 45000
|
||||
- assertNotVisible: "Overview"
|
||||
- back
|
||||
- extendedWaitUntil:
|
||||
notVisible: "Pause"
|
||||
timeout: 10000
|
||||
- back
|
||||
- extendedWaitUntil:
|
||||
visible: "Overview"
|
||||
timeout: 15000
|
||||
@@ -0,0 +1,94 @@
|
||||
appId: com.edde746.plezy
|
||||
name: System back dismisses a hosted sheet without leaving Settings
|
||||
|
||||
tags:
|
||||
- e2e
|
||||
- regression
|
||||
- settings
|
||||
- sheets
|
||||
---
|
||||
# Guards 8e1904dd and c48cbf70 together.
|
||||
#
|
||||
# On a phone, Settings is a pushed route. Before c48cbf70 it carried no
|
||||
# OverlaySheetHost, so Manage Libraries fell back to showModalBottomSheet and a
|
||||
# single Back tore down the sheet *and* Settings. Before 8e1904dd the host
|
||||
# answered the platform pop with a TV-only dedup marker that could already be
|
||||
# spent, stranding the sheet open with no way out. Either defect fails the
|
||||
# "Manage Libraries is still there after one Back" assertion below.
|
||||
#
|
||||
# The same commit blocks semantics behind the barrier, so the occlusion
|
||||
# assertions pin that an open sheet actually hides the rows underneath.
|
||||
# Pristine state on purpose. The TV regressions in the same CI group leave
|
||||
# "Force TV mode" enabled, and this flow asserts the handheld Settings route,
|
||||
# so it must not inherit whichever layout the previous flow left behind.
|
||||
- runFlow: ../subflows/onboard_jellyfin.yaml
|
||||
- runFlow: ../subflows/open_settings.yaml
|
||||
- assertVisible: "(?s)^Services.*"
|
||||
- assertVisible: "(?s)^Video Playback.*"
|
||||
- tapOn: "(?s)^Manage Libraries.*"
|
||||
- extendedWaitUntil:
|
||||
visible: "Maestro Movies"
|
||||
timeout: 15000
|
||||
# The barrier swallows every pointer event, so the rows behind it must leave
|
||||
# the semantics tree too. Without BlockSemantics an occluded row still reads as
|
||||
# visible and a tap on it lands on the barrier instead. "Manage Libraries" is
|
||||
# excluded on purpose: it is also the sheet's own title.
|
||||
- assertNotVisible: "(?s)^Services.*"
|
||||
- assertNotVisible: "(?s)^Video Playback.*"
|
||||
- assertNotVisible: "(?s)^Appearance.*"
|
||||
- assertNotVisible: "(?s)^Connections.*"
|
||||
- back
|
||||
- waitForAnimationToEnd:
|
||||
timeout: 3000
|
||||
- assertNotVisible: "Maestro Movies"
|
||||
# One Back closed only the sheet: Settings is still the current route.
|
||||
- extendedWaitUntil:
|
||||
visible: "(?s)^Manage Libraries.*"
|
||||
timeout: 10000
|
||||
- assertVisible: "(?s)^Services.*"
|
||||
- assertVisible: "(?s)^Video Playback.*"
|
||||
- assertNotVisible: "Discover"
|
||||
# Reopening proves the host did not strand the one-shot dedup marker: a second
|
||||
# dismissal has to work exactly like the first.
|
||||
- tapOn: "(?s)^Manage Libraries.*"
|
||||
- extendedWaitUntil:
|
||||
visible: "Maestro Movies"
|
||||
timeout: 15000
|
||||
# The per-library menu is a nested page of the same sheet now that the host
|
||||
# exists, so Back returns to the library list instead of tearing the sheet down.
|
||||
- tapOn: "Library options"
|
||||
- extendedWaitUntil:
|
||||
visible: "(?s).*Refresh Metadata.*"
|
||||
timeout: 15000
|
||||
- back
|
||||
- waitForAnimationToEnd:
|
||||
timeout: 3000
|
||||
- extendedWaitUntil:
|
||||
visible: "Maestro Movies"
|
||||
timeout: 10000
|
||||
- assertNotVisible: "(?s).*Refresh Metadata.*"
|
||||
# Back at the library list, one more Back leaves the sheet entirely.
|
||||
- back
|
||||
- waitForAnimationToEnd:
|
||||
timeout: 3000
|
||||
- assertNotVisible: "Maestro Movies"
|
||||
- extendedWaitUntil:
|
||||
visible: "(?s)^Manage Libraries.*"
|
||||
timeout: 10000
|
||||
- tapOn: "(?s)^Manage Libraries.*"
|
||||
- extendedWaitUntil:
|
||||
visible: "Maestro Movies"
|
||||
timeout: 15000
|
||||
- back
|
||||
- waitForAnimationToEnd:
|
||||
timeout: 3000
|
||||
- assertNotVisible: "Maestro Movies"
|
||||
- extendedWaitUntil:
|
||||
visible: "(?s)^Manage Libraries.*"
|
||||
timeout: 10000
|
||||
# With no sheet open the host must let the route pop normally.
|
||||
- back
|
||||
- extendedWaitUntil:
|
||||
visible: "Discover"
|
||||
timeout: 15000
|
||||
- assertNotVisible: "(?s)^Manage Libraries.*"
|
||||
@@ -0,0 +1,112 @@
|
||||
appId: com.edde746.plezy
|
||||
name: An explicit track choice survives the pending automatic pass
|
||||
|
||||
tags:
|
||||
- e2e
|
||||
- regression
|
||||
- playback
|
||||
- subtitles
|
||||
---
|
||||
# Guards 468d6804. When a source advertises tracks the native list has not
|
||||
# produced yet, TrackManager arms an automatic selection pass with a 5s first
|
||||
# attempt and a 25s deadline. A user pick made inside that window used to only
|
||||
# persist the preference, leaving the pass armed, so the choice snapped back to
|
||||
# the server/profile preferred track up to ~30s later. The pick now retires the
|
||||
# pending pass, and the wait below is long enough to outlive both timers.
|
||||
#
|
||||
# Fixture: Codec H264 EAC3 Multisub advertises three E-AC-3 audio streams
|
||||
# (Hindi and Japanese flagged default, English not) and 36 SRT subtitle
|
||||
# streams, so both picks below are genuine changes away from the automatic one.
|
||||
# Pristine state on purpose, for two reasons: the TV regressions in the same
|
||||
# CI group leave "Force TV mode" enabled, and a remembered track selection
|
||||
# from an earlier run would pre-select the very rows this flow picks, making
|
||||
# every assertion below vacuous.
|
||||
- runFlow: ../subflows/onboard_jellyfin.yaml
|
||||
- runFlow:
|
||||
file: ../subflows/open_codec_sample.yaml
|
||||
env:
|
||||
SAMPLE_TITLE: "Codec H264 EAC3 Multisub"
|
||||
SAMPLE_OVERVIEW: "H.264 video with three E-AC-3 audio tracks and a broad multilingual SRT subtitle set."
|
||||
- assertVisible: "(?is)^English\\s+[^,]*(?:Dolby Digital Plus|E-AC3|Surround|5\\.1)[^,]*$"
|
||||
- tapOn:
|
||||
text: "(?is)^English\\s+[^,]*(?:Dolby Digital Plus|E-AC3|Surround|5\\.1)[^,]*$"
|
||||
- extendedWaitUntil:
|
||||
visible: "(?s)^.*Audio & Subtitles$"
|
||||
timeout: 30000
|
||||
- tapOn: "(?s)^.*Audio & Subtitles$"
|
||||
# German is the only German subtitle in the set and is never the automatic
|
||||
# pick, so a revert to the preferred language or to Off is unambiguous.
|
||||
- repeat:
|
||||
times: 12
|
||||
while:
|
||||
notVisible: "(?is)^German\\s+[^,]*(?:SRT|APPLICATION/X-SUBRIP)[^,]*$"
|
||||
commands:
|
||||
- swipe:
|
||||
start: 70%, 82%
|
||||
end: 70%, 68%
|
||||
duration: 300
|
||||
- tapOn:
|
||||
text: "(?is)^German\\s+[^,]*(?:SRT|APPLICATION/X-SUBRIP)[^,]*$"
|
||||
- extendedWaitUntil:
|
||||
visible: "(?s)^.*Audio & Subtitles$"
|
||||
timeout: 30000
|
||||
# The pending pass is wall-clock driven and a paused frame settles instantly,
|
||||
# so playback has to run for the wait below to consume real time.
|
||||
- runFlow:
|
||||
when:
|
||||
visible: "Play"
|
||||
commands:
|
||||
- tapOn: "Play"
|
||||
- extendedWaitUntil:
|
||||
visible: "Pause"
|
||||
timeout: 20000
|
||||
- repeat:
|
||||
times: 14
|
||||
commands:
|
||||
- waitForAnimationToEnd:
|
||||
timeout: 3000
|
||||
# Chrome auto-hides after 30s even in Maestro builds, so bring it back.
|
||||
- tapOn:
|
||||
point: 50%, 50%
|
||||
- extendedWaitUntil:
|
||||
visible: "(?s)^.*Audio & Subtitles$"
|
||||
timeout: 30000
|
||||
# The tracks button announces the live selection, so this catches a revert
|
||||
# without reopening the sheet.
|
||||
- assertVisible: "(?is)^English[^,]*(?:Dolby Digital Plus|E-AC3|Surround|5\\.1)[^,]*,.*Audio & Subtitles$"
|
||||
- tapOn: "(?s)^.*Audio & Subtitles$"
|
||||
- assertVisible:
|
||||
text: "(?is)^English\\s+[^,]*(?:Dolby Digital Plus|E-AC3|Surround|5\\.1)[^,]*$"
|
||||
selected: true
|
||||
- assertNotVisible:
|
||||
text: "(?is)^Japanese\\s+[^,]*(?:Dolby Digital Plus|E-AC3|Surround|5\\.1)[^,]*$"
|
||||
selected: true
|
||||
- repeat:
|
||||
times: 12
|
||||
while:
|
||||
notVisible: "(?is)^German\\s+[^,]*(?:SRT|APPLICATION/X-SUBRIP)[^,]*$"
|
||||
commands:
|
||||
- swipe:
|
||||
start: 70%, 82%
|
||||
end: 70%, 68%
|
||||
duration: 300
|
||||
- assertVisible:
|
||||
text: "(?is)^German\\s+[^,]*(?:SRT|APPLICATION/X-SUBRIP)[^,]*$"
|
||||
selected: true
|
||||
- assertNotVisible:
|
||||
text: "Off"
|
||||
selected: true
|
||||
- back
|
||||
- waitForAnimationToEnd:
|
||||
timeout: 3000
|
||||
- back
|
||||
- waitForAnimationToEnd:
|
||||
timeout: 3000
|
||||
- runFlow:
|
||||
when:
|
||||
notVisible: "Overview"
|
||||
commands:
|
||||
- back
|
||||
- extendedWaitUntil:
|
||||
visible: "Overview"
|
||||
timeout: 15000
|
||||
@@ -0,0 +1,90 @@
|
||||
appId: com.edde746.plezy
|
||||
name: Language picker offers the new locales and switching one relocalizes the app
|
||||
|
||||
tags:
|
||||
- e2e
|
||||
- regression
|
||||
- settings
|
||||
- i18n
|
||||
---
|
||||
# Guards 7677d159 and 100d7729. AppLocale is switched over exhaustively in
|
||||
# appearance_settings_screen.dart, so a missing arm is a compile error, but a
|
||||
# locale whose JSON never reached the generator ships a picker entry that falls
|
||||
# back to English. Selecting Turkish and reading root-level navigation labels
|
||||
# proves the generated locale is wired end to end.
|
||||
#
|
||||
# This flow mutates a persisted pref, and the app language decides every
|
||||
# selector after the tap, so it starts from a cleared install rather than
|
||||
# ensure_onboarded: a run that dies mid-flow must not leave the next flow
|
||||
# hunting for English strings in a Turkish tree.
|
||||
- runFlow: ../subflows/onboard_jellyfin.yaml
|
||||
- runFlow: ../subflows/open_settings.yaml
|
||||
- tapOn: "(?s)^Appearance.*"
|
||||
- extendedWaitUntil:
|
||||
visible: "(?s).*Theme.*"
|
||||
timeout: 15000
|
||||
- tapOn: "(?s)^Language.*"
|
||||
- extendedWaitUntil:
|
||||
visible: "Azərbaycanca"
|
||||
timeout: 10000
|
||||
- assertVisible: "English"
|
||||
- assertVisible: "Қазақша"
|
||||
# The dialog holds 22 endonyms and only the first 16 fit on a phone, so the two
|
||||
# locales that sort last need scrolling into view.
|
||||
- repeat:
|
||||
times: 8
|
||||
while:
|
||||
notVisible: "Türkçe"
|
||||
commands:
|
||||
- swipe:
|
||||
start: 50%, 80%
|
||||
end: 50%, 45%
|
||||
duration: 300
|
||||
- assertVisible: "Türkçe"
|
||||
- assertVisible: "Oʻzbekcha"
|
||||
- tapOn: "Türkçe"
|
||||
# Selecting a language rebuilds the app from the root route, so everything
|
||||
# below runs against the Turkish tree.
|
||||
- extendedWaitUntil:
|
||||
visible: "(?s)^Kitaplıklar.*"
|
||||
timeout: 30000
|
||||
- assertVisible: "(?s)^Ana Sayfa.*"
|
||||
- assertVisible: "(?s)^Ara.*"
|
||||
- assertVisible: "(?s)^İndirmeler.*"
|
||||
- assertVisible: "Keşfet"
|
||||
- assertNotVisible: "(?s)^Libraries.*"
|
||||
- assertNotVisible: "Discover"
|
||||
# Walk back through the Turkish tree and restore English. The pref outlives a
|
||||
# plain launchApp, so leaving the device in Turkish would break every later
|
||||
# flow in the suite.
|
||||
- repeat:
|
||||
times: 3
|
||||
while:
|
||||
notVisible: "Ayarlar"
|
||||
commands:
|
||||
- tapOn: "(?s)^M(?:.*Profiller.*)?$"
|
||||
- waitForAnimationToEnd:
|
||||
timeout: 3000
|
||||
- tapOn:
|
||||
text: "Ayarlar"
|
||||
below:
|
||||
text: "Profiller"
|
||||
- extendedWaitUntil:
|
||||
visible: "(?s)^Görünüm.*"
|
||||
timeout: 15000
|
||||
- assertVisible: "(?s)^Servisler.*"
|
||||
- assertVisible: "(?s)^Video Oynatma.*"
|
||||
- tapOn: "(?s)^Görünüm.*"
|
||||
- extendedWaitUntil:
|
||||
visible: "(?s).*Tema.*"
|
||||
timeout: 15000
|
||||
- tapOn: "(?s)^Dil.*"
|
||||
- extendedWaitUntil:
|
||||
visible: "English"
|
||||
timeout: 10000
|
||||
- tapOn: "English"
|
||||
- extendedWaitUntil:
|
||||
visible: "(?s)^Libraries.*"
|
||||
timeout: 30000
|
||||
- assertVisible: "Discover"
|
||||
- assertNotVisible: "(?s)^Kitaplıklar.*"
|
||||
@@ -0,0 +1,142 @@
|
||||
appId: com.edde746.plezy
|
||||
name: TV settings stay navigable and dismiss their overlays
|
||||
|
||||
tags:
|
||||
- e2e
|
||||
- regression
|
||||
- tv
|
||||
- settings
|
||||
---
|
||||
# Guards ef310459 and the Apple-only gates touched by 6c14049e, and pins TV
|
||||
# settings navigation generally.
|
||||
#
|
||||
# It deliberately does NOT guard the 15b54e2e density change. That commit took
|
||||
# every settings row from ~80dp to ~61dp, but the rows below the fold stay in
|
||||
# the semantics tree either way, so this flow passes identically on both sides
|
||||
# of it — measured, not assumed. A real guard would need a pixel `height`
|
||||
# assertion, and Maestro has no DPR-relative form, so pinning one here would
|
||||
# only hold for the box it was written on. Row geometry belongs to
|
||||
# test/widgets/setting_tile_test.dart.
|
||||
#
|
||||
# Every step uses D-pad keys, never taps. InputModeTracker flips to pointer
|
||||
# mode on the first touch event and the TV number spinner only builds in
|
||||
# keyboard mode, so a single tapOn anywhere above would silently swap the
|
||||
# spinner for a plain text field.
|
||||
- runFlow: ../subflows/onboard_jellyfin_tv_device.yaml
|
||||
- pressKey: "Remote Dpad Left"
|
||||
- extendedWaitUntil:
|
||||
visible: "Settings"
|
||||
timeout: 15000
|
||||
- repeat:
|
||||
times: 12
|
||||
while:
|
||||
notVisible:
|
||||
text: "Settings"
|
||||
focused: true
|
||||
commands:
|
||||
- pressKey: "Remote Dpad Down"
|
||||
- pressKey: "Remote Dpad Center"
|
||||
- extendedWaitUntil:
|
||||
visible: "(?s)^Appearance.*"
|
||||
timeout: 15000
|
||||
- assertVisible: "(?s)^Video Playback.*"
|
||||
- assertVisible: "(?s)^Manage Libraries.*"
|
||||
- assertVisible: "(?s)^Services.*"
|
||||
# The Connections card sits below the first card, so reaching it also proves
|
||||
# the list scrolled or fits — not that any particular row height applies.
|
||||
- assertVisible: "(?s)^Add connection.*"
|
||||
- assertVisible: "(?s)^Profiles.*"
|
||||
# Manage Libraries takes the TV dialog path, not the overlay-sheet path, and
|
||||
# one Back has to return to Settings rather than leave it.
|
||||
- repeat:
|
||||
times: 6
|
||||
while:
|
||||
notVisible:
|
||||
text: "(?s)^Manage Libraries.*"
|
||||
focused: true
|
||||
commands:
|
||||
- pressKey: "Remote Dpad Down"
|
||||
- pressKey: "Remote Dpad Center"
|
||||
- extendedWaitUntil:
|
||||
visible: "Maestro Movies"
|
||||
timeout: 15000
|
||||
- assertVisible: "Maestro Shows"
|
||||
- assertNotVisible: "(?s)^Services.*"
|
||||
- assertNotVisible: "(?s)^Video Playback.*"
|
||||
- pressKey: "back"
|
||||
- waitForAnimationToEnd:
|
||||
timeout: 3000
|
||||
- assertNotVisible: "Maestro Movies"
|
||||
- extendedWaitUntil:
|
||||
visible: "(?s)^Services.*"
|
||||
timeout: 15000
|
||||
- assertVisible: "(?s)^Manage Libraries.*"
|
||||
# Services keeps every hub row after losing its own density overrides.
|
||||
- repeat:
|
||||
times: 6
|
||||
while:
|
||||
notVisible:
|
||||
text: "(?s)^Services.*"
|
||||
focused: true
|
||||
commands:
|
||||
- pressKey: "Remote Dpad Down"
|
||||
- pressKey: "Remote Dpad Center"
|
||||
- extendedWaitUntil:
|
||||
visible: "(?s)^Trakt.*"
|
||||
timeout: 15000
|
||||
- assertVisible: "(?s)^MyAnimeList.*"
|
||||
- assertVisible: "(?s)^AniList.*"
|
||||
- assertVisible: "(?s)^Simkl.*"
|
||||
- assertVisible: "(?s)^Seerr.*"
|
||||
- pressKey: "back"
|
||||
- extendedWaitUntil:
|
||||
visible: "(?s)^Manage Libraries.*"
|
||||
timeout: 15000
|
||||
# Video Playback: the Atmos test screen is gated to Apple TV, and the D-pad
|
||||
# numeric dialog must expose the spinner's restored accessibility labels.
|
||||
- repeat:
|
||||
times: 8
|
||||
while:
|
||||
notVisible:
|
||||
text: "(?s)^Video Playback.*"
|
||||
focused: true
|
||||
commands:
|
||||
- pressKey: "Remote Dpad Up"
|
||||
- pressKey: "Remote Dpad Center"
|
||||
- extendedWaitUntil:
|
||||
visible: "(?s)^Player.*"
|
||||
timeout: 15000
|
||||
- assertNotVisible: "(?s)^Atmos Output Test.*"
|
||||
- repeat:
|
||||
times: 30
|
||||
while:
|
||||
notVisible:
|
||||
text: "(?s)^Small Skip Duration.*"
|
||||
focused: true
|
||||
commands:
|
||||
- pressKey: "Remote Dpad Down"
|
||||
- pressKey: "Remote Dpad Center"
|
||||
- extendedWaitUntil:
|
||||
visible: "Decrease"
|
||||
timeout: 15000
|
||||
- assertVisible: "Increase"
|
||||
- assertVisible: "(?s)^Enter duration.*"
|
||||
- pressKey: "back"
|
||||
- extendedWaitUntil:
|
||||
visible: "(?s)^Small Skip Duration.*"
|
||||
timeout: 15000
|
||||
- pressKey: "back"
|
||||
- extendedWaitUntil:
|
||||
visible: "(?s)^Manage Libraries.*"
|
||||
timeout: 15000
|
||||
# Settings is a rail tab on TV rather than a pushed route, so Back hands focus
|
||||
# back to the rail and expands it instead of leaving the screen.
|
||||
- pressKey: "back"
|
||||
- extendedWaitUntil:
|
||||
visible:
|
||||
text: "Settings"
|
||||
focused: true
|
||||
timeout: 20000
|
||||
- assertVisible: "Home"
|
||||
- assertVisible: "Libraries"
|
||||
- assertVisible: "Downloads"
|
||||
@@ -0,0 +1,11 @@
|
||||
const controlUrl = String(JELLYFIN_CONTROL_URL).trim();
|
||||
|
||||
if (controlUrl !== "" && controlUrl !== "undefined" && controlUrl !== "null") {
|
||||
const response = http.post(`${controlUrl}/__maestro/offline`, {
|
||||
headers: {"Content-Type": "application/json"},
|
||||
body: JSON.stringify({enabled: true}),
|
||||
});
|
||||
if (response.status !== 204) {
|
||||
throw new Error(`Could not put Jellyfin fixture offline: HTTP ${response.status}`);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
appId: com.edde746.plezy
|
||||
---
|
||||
- tapOn: "M"
|
||||
- tapOn:
|
||||
text: "Profiles"
|
||||
above:
|
||||
text: "Settings"
|
||||
- extendedWaitUntil:
|
||||
visible: "Switch Profile"
|
||||
timeout: 10000
|
||||
- tapOn: "Add Plezy profile"
|
||||
- extendedWaitUntil:
|
||||
visible: "New profile"
|
||||
timeout: 10000
|
||||
- tapOn: "e.g. Guests, Kids, Family Room"
|
||||
- inputText: "E2E Guest"
|
||||
- hideKeyboard
|
||||
- tapOn: "Continue"
|
||||
- extendedWaitUntil:
|
||||
visible: "Add to E2E Guest"
|
||||
timeout: 10000
|
||||
- tapOn: "(?s)^Connect to Jellyfin.*"
|
||||
- extendedWaitUntil:
|
||||
visible: "Add Jellyfin server"
|
||||
timeout: 10000
|
||||
- tapOn: "(?s)Server URLs.*"
|
||||
- inputText: ${JELLYFIN_URL}
|
||||
- hideKeyboard
|
||||
- tapOn: "Find server"
|
||||
- extendedWaitUntil:
|
||||
visible: "Maestro Jellyfin"
|
||||
timeout: 15000
|
||||
- tapOn: "Username"
|
||||
- inputText: "guest"
|
||||
- tapOn: "Password"
|
||||
- inputText: "guest"
|
||||
- hideKeyboard
|
||||
- tapOn: "Sign in"
|
||||
- extendedWaitUntil:
|
||||
visible: "Switch Profile"
|
||||
timeout: 30000
|
||||
- assertVisible: "(?s)^M\nMaestro\n.*"
|
||||
- assertVisible: "(?s)^E\nE2E Guest\n.*"
|
||||
@@ -0,0 +1,27 @@
|
||||
appId: com.edde746.plezy
|
||||
---
|
||||
# Reach a signed-in Home without paying for onboarding that already happened.
|
||||
#
|
||||
# `onboard_jellyfin.yaml` clears app state and retypes the server URL and
|
||||
# credentials, which measures ~59s per flow on a Pixel 7 and dominates every
|
||||
# flow that only needs "signed in, on Home". A plain `launchApp` keeps stored
|
||||
# data and cold-starts straight back to Home, which measures ~16s.
|
||||
#
|
||||
# The guard keeps each flow runnable on its own: when no session is stored --
|
||||
# a fresh install, or a run after the logout flow -- "Discover" is absent and
|
||||
# this falls through to the real onboarding. Flows that must prove onboarding
|
||||
# itself, or that need pristine state, keep calling onboard_jellyfin.yaml.
|
||||
- launchApp
|
||||
# Settle on a known screen first. Branching straight off `launchApp` races the
|
||||
# splash: "Discover" has not rendered yet, the guard reads it as absent, and
|
||||
# every flow pays for a full re-onboarding it did not need.
|
||||
- extendedWaitUntil:
|
||||
visible: "(?s)^(?:Discover|Sign in with Plex|Connect to Jellyfin|Wait).*"
|
||||
timeout: 30000
|
||||
- runFlow:
|
||||
when:
|
||||
notVisible: "Discover"
|
||||
file: onboard_jellyfin.yaml
|
||||
- extendedWaitUntil:
|
||||
visible: "Discover"
|
||||
timeout: 30000
|
||||
@@ -0,0 +1,50 @@
|
||||
appId: com.edde746.plezy
|
||||
---
|
||||
- retry:
|
||||
maxRetries: 1
|
||||
commands:
|
||||
- launchApp:
|
||||
clearState: true
|
||||
permissions:
|
||||
all: allow
|
||||
- extendedWaitUntil:
|
||||
visible: "(?s)^(?:Connect to Jellyfin|Wait).*"
|
||||
timeout: 30000
|
||||
# A miss on `tapOn ... optional: true` still runs Maestro's full element
|
||||
# search before giving up, which measured 3s here and 7.8s for "Sign in"
|
||||
# on every onboarding. These taps normally find nothing, so gate them on
|
||||
# a single visibility check instead.
|
||||
- runFlow:
|
||||
when:
|
||||
visible: "Wait"
|
||||
commands:
|
||||
- tapOn: "Wait"
|
||||
- extendedWaitUntil:
|
||||
visible: "Connect to Jellyfin"
|
||||
timeout: 30000
|
||||
- tapOn: "Connect to Jellyfin"
|
||||
- extendedWaitUntil:
|
||||
visible: "Add Jellyfin server"
|
||||
timeout: 10000
|
||||
- tapOn: "(?s)Server URLs.*"
|
||||
- inputText: ${JELLYFIN_URL}
|
||||
- tapOn: "Find server"
|
||||
- extendedWaitUntil:
|
||||
visible: "Maestro Jellyfin"
|
||||
timeout: 15000
|
||||
- tapOn: "Username"
|
||||
- inputText: "maestro"
|
||||
- tapOn: "Password"
|
||||
- inputText: "maestro"
|
||||
- pressKey: ENTER
|
||||
- retry:
|
||||
maxRetries: 1
|
||||
commands:
|
||||
- runFlow:
|
||||
when:
|
||||
visible: "Sign in"
|
||||
commands:
|
||||
- tapOn: "Sign in"
|
||||
- extendedWaitUntil:
|
||||
visible: "Discover"
|
||||
timeout: 30000
|
||||
@@ -0,0 +1,55 @@
|
||||
appId: com.edde746.plezy
|
||||
---
|
||||
- retry:
|
||||
maxRetries: 1
|
||||
commands:
|
||||
- launchApp:
|
||||
appId: com.edde746.plezy
|
||||
clearState: true
|
||||
permissions:
|
||||
all: allow
|
||||
- extendedWaitUntil:
|
||||
visible: "(?s)^(?:Connect to Jellyfin|Wait).*"
|
||||
timeout: 30000
|
||||
- tapOn:
|
||||
text: "Wait"
|
||||
optional: true
|
||||
- extendedWaitUntil:
|
||||
visible: "Connect to Jellyfin"
|
||||
timeout: 30000
|
||||
- tapOn: "Connect to Jellyfin"
|
||||
- extendedWaitUntil:
|
||||
visible: "Add Jellyfin server"
|
||||
timeout: 10000
|
||||
- tapOn: "(?s)Server URLs.*"
|
||||
- inputText: ${JELLYFIN_URL}
|
||||
- tapOn: "Find server"
|
||||
- extendedWaitUntil:
|
||||
visible: "Username"
|
||||
timeout: 15000
|
||||
- tapOn: "Username"
|
||||
- inputText: "maestro"
|
||||
- tapOn: "Password"
|
||||
- inputText: "maestro"
|
||||
- tapOn: "Sign in"
|
||||
- extendedWaitUntil:
|
||||
visible: "Discover"
|
||||
timeout: 30000
|
||||
- tapOn: "M"
|
||||
- tapOn:
|
||||
text: "Settings"
|
||||
below:
|
||||
text: "Profiles"
|
||||
- extendedWaitUntil:
|
||||
visible: "(?s)^Appearance.*"
|
||||
timeout: 10000
|
||||
- tapOn: "(?s)^Appearance.*"
|
||||
- swipe:
|
||||
start: 50%, 85%
|
||||
end: 50%, 25%
|
||||
duration: 500
|
||||
- tapOn: "(?s)^Force TV mode.*"
|
||||
- extendedWaitUntil:
|
||||
visible:
|
||||
id: "tv_browse_rail_selection"
|
||||
timeout: 30000
|
||||
@@ -0,0 +1,57 @@
|
||||
appId: com.edde746.plezy
|
||||
---
|
||||
# Onboarding for a device that already reports itself as a TV.
|
||||
#
|
||||
# `onboard_jellyfin_tv.yaml` exists for the phone emulator CI runs on: it
|
||||
# onboards through the handheld layout and then walks Settings to flip
|
||||
# "Force TV mode". A real Android TV box takes the TV layout from the first
|
||||
# frame, so that walk has no handheld chrome to tap and the toggle would only
|
||||
# re-assert what PlatformDetector already reports.
|
||||
- retry:
|
||||
maxRetries: 1
|
||||
commands:
|
||||
- launchApp:
|
||||
clearState: true
|
||||
permissions:
|
||||
all: allow
|
||||
- extendedWaitUntil:
|
||||
visible: "(?s)^(?:Connect to Jellyfin|Wait).*"
|
||||
timeout: 30000
|
||||
- runFlow:
|
||||
when:
|
||||
visible: "Wait"
|
||||
commands:
|
||||
- tapOn: "Wait"
|
||||
- extendedWaitUntil:
|
||||
visible: "Connect to Jellyfin"
|
||||
timeout: 30000
|
||||
- tapOn: "Connect to Jellyfin"
|
||||
- extendedWaitUntil:
|
||||
visible: "Add Jellyfin server"
|
||||
timeout: 15000
|
||||
- tapOn: "(?s)Server URLs.*"
|
||||
- inputText: ${JELLYFIN_URL}
|
||||
- tapOn: "Find server"
|
||||
# A TV-sized layout offers Quick Connect first because typing on a remote is
|
||||
# slow. The code path needs a second device to approve, so fall back to the
|
||||
# username form the phone flow already uses.
|
||||
- extendedWaitUntil:
|
||||
visible: "(?s)^(?:Username|Cancel)$"
|
||||
timeout: 30000
|
||||
- runFlow:
|
||||
when:
|
||||
notVisible: "Username"
|
||||
commands:
|
||||
- tapOn: "Cancel"
|
||||
- extendedWaitUntil:
|
||||
visible: "Username"
|
||||
timeout: 20000
|
||||
- tapOn: "Username"
|
||||
- inputText: "maestro"
|
||||
- tapOn: "Password"
|
||||
- inputText: "maestro"
|
||||
- tapOn: "Sign in"
|
||||
- extendedWaitUntil:
|
||||
visible:
|
||||
id: "tv_browse_rail_selection"
|
||||
timeout: 45000
|
||||
@@ -0,0 +1,48 @@
|
||||
appId: com.edde746.plezy
|
||||
---
|
||||
- runFlow: ensure_onboarded.yaml
|
||||
- tapOn: "(?s)^Search.*"
|
||||
- extendedWaitUntil:
|
||||
visible: "Search movies, shows, music..."
|
||||
timeout: 10000
|
||||
- tapOn: "Search movies, shows, music..."
|
||||
- inputText: "${SAMPLE_TITLE}"
|
||||
- runFlow:
|
||||
when:
|
||||
platform: iOS
|
||||
commands:
|
||||
- pressKey: ENTER
|
||||
- runFlow:
|
||||
when:
|
||||
platform: Android
|
||||
commands:
|
||||
- hideKeyboard
|
||||
# A card announces its watch state three ways, and the codec samples keep a
|
||||
# resume position on the server once any earlier flow has played them, so the
|
||||
# partial form has to be accepted for this subflow to be re-runnable against a
|
||||
# fixture container that is not thrown away between suites.
|
||||
- extendedWaitUntil:
|
||||
visible: "(?s)^${SAMPLE_TITLE}, movie, (?:watched|unwatched|[0-9]+ percent watched)$"
|
||||
timeout: 15000
|
||||
- tapOn: "(?s)^${SAMPLE_TITLE}, movie, (?:watched|unwatched|[0-9]+ percent watched)$"
|
||||
- extendedWaitUntil:
|
||||
visible: "Overview"
|
||||
timeout: 15000
|
||||
- assertVisible: "${SAMPLE_OVERVIEW}"
|
||||
- tapOn:
|
||||
text: "Play"
|
||||
waitToSettleTimeoutMs: 1000
|
||||
- extendedWaitUntil:
|
||||
notVisible: "Overview"
|
||||
timeout: 15000
|
||||
- extendedWaitUntil:
|
||||
visible: "Pause"
|
||||
timeout: 30000
|
||||
- tapOn: "Pause"
|
||||
- extendedWaitUntil:
|
||||
visible: "Play"
|
||||
timeout: 5000
|
||||
- extendedWaitUntil:
|
||||
visible: "(?s)^.*Audio & Subtitles$"
|
||||
timeout: 30000
|
||||
- tapOn: "(?s)^.*Audio & Subtitles$"
|
||||
@@ -0,0 +1,27 @@
|
||||
appId: com.edde746.plezy
|
||||
---
|
||||
# Reach Settings from a signed-in Home on a phone layout.
|
||||
#
|
||||
# The profile menu is itself an overlay sheet on the MainScreen host, so a tap
|
||||
# issued while the previous route is still settling opens nothing. The guarded
|
||||
# repeat re-opens the menu instead of failing the flow, matching the pattern
|
||||
# already used by flows/07_profiles_settings.yaml.
|
||||
- extendedWaitUntil:
|
||||
visible: "Discover"
|
||||
timeout: 30000
|
||||
- repeat:
|
||||
times: 3
|
||||
while:
|
||||
notVisible: "Settings"
|
||||
commands:
|
||||
- tapOn: "(?s)^M(?:.*Profiles.*)?$"
|
||||
- waitForAnimationToEnd:
|
||||
timeout: 3000
|
||||
- tapOn:
|
||||
text: "Settings"
|
||||
below:
|
||||
text: "Profiles"
|
||||
- extendedWaitUntil:
|
||||
visible: "(?s)^Appearance.*"
|
||||
timeout: 15000
|
||||
- assertVisible: "(?s)^Manage Libraries.*"
|
||||
@@ -0,0 +1,60 @@
|
||||
appId: com.edde746.plezy
|
||||
---
|
||||
- tapOn: "(?s)^Appearance.*"
|
||||
- extendedWaitUntil:
|
||||
visible: "(?s).*Theme.*"
|
||||
timeout: 10000
|
||||
- assertVisible: "(?s).*Library Density.*"
|
||||
- back
|
||||
- assertVisible: "(?s)^Appearance.*"
|
||||
- tapOn: "(?s)^Video Playback.*"
|
||||
- extendedWaitUntil:
|
||||
visible: "(?s)^Player.*"
|
||||
timeout: 10000
|
||||
- repeat:
|
||||
times: 4
|
||||
while:
|
||||
notVisible: "(?s)^Subtitles & Configuration.*Subtitle Styling.*"
|
||||
commands:
|
||||
- swipe:
|
||||
start: 50%, 80%
|
||||
end: 50%, 65%
|
||||
duration: 800
|
||||
- waitForAnimationToEnd:
|
||||
timeout: 1500
|
||||
- assertVisible: "(?s)^Subtitles & Configuration.*Subtitle Styling.*"
|
||||
- repeat:
|
||||
times: 4
|
||||
while:
|
||||
notVisible: "(?s)^Seek & Timing.*"
|
||||
commands:
|
||||
- swipe:
|
||||
start: 50%, 80%
|
||||
end: 50%, 65%
|
||||
duration: 800
|
||||
- waitForAnimationToEnd:
|
||||
timeout: 1500
|
||||
- assertVisible: "(?s)^Seek & Timing.*"
|
||||
- back
|
||||
- extendedWaitUntil:
|
||||
visible: "(?s)^Manage Libraries.*"
|
||||
timeout: 10000
|
||||
- tapOn: "(?s)^Manage Libraries.*"
|
||||
- extendedWaitUntil:
|
||||
visible: "Maestro Movies"
|
||||
timeout: 10000
|
||||
- back
|
||||
- extendedWaitUntil:
|
||||
visible: "(?s)^Services.*"
|
||||
timeout: 10000
|
||||
- tapOn: "(?s)^Services.*"
|
||||
- extendedWaitUntil:
|
||||
visible: "Services"
|
||||
timeout: 10000
|
||||
- assertVisible: "(?s)^Trakt.*"
|
||||
- assertVisible: "(?s)^MyAnimeList.*"
|
||||
- assertVisible: "(?s)^AniList.*"
|
||||
- assertVisible: "(?s)^Simkl.*"
|
||||
- assertVisible: "(?s)^Seerr.*"
|
||||
- back
|
||||
- assertVisible: "(?s)^Services.*"
|
||||
@@ -0,0 +1,23 @@
|
||||
appId: com.edde746.plezy
|
||||
---
|
||||
- assertVisible: "${AUDIO_TRACK}"
|
||||
- tapOn:
|
||||
text: "${AUDIO_TRACK}"
|
||||
- extendedWaitUntil:
|
||||
visible: "(?s)^.*Audio & Subtitles$"
|
||||
timeout: 30000
|
||||
- tapOn: "(?s)^.*Audio & Subtitles$"
|
||||
- assertVisible: "${SUBTITLE_TRACK}"
|
||||
- tapOn:
|
||||
text: "${SUBTITLE_TRACK}"
|
||||
- extendedWaitUntil:
|
||||
visible: "(?s)^.*Audio & Subtitles$"
|
||||
timeout: 30000
|
||||
- tapOn: "(?s)^.*Audio & Subtitles$"
|
||||
- assertVisible:
|
||||
text: "${AUDIO_TRACK}"
|
||||
selected: true
|
||||
- assertVisible:
|
||||
text: "${SUBTITLE_TRACK}"
|
||||
selected: true
|
||||
- back
|
||||
@@ -0,0 +1,41 @@
|
||||
appId: com.edde746.plezy
|
||||
---
|
||||
- assertVisible: "${AUDIO_TRACK}"
|
||||
- tapOn:
|
||||
text: "${AUDIO_TRACK}"
|
||||
- extendedWaitUntil:
|
||||
visible: "(?s)^.*Audio & Subtitles$"
|
||||
timeout: 30000
|
||||
- tapOn: "(?s)^.*Audio & Subtitles$"
|
||||
- repeat:
|
||||
times: 12
|
||||
while:
|
||||
notVisible: "${SUBTITLE_TRACK}"
|
||||
commands:
|
||||
- swipe:
|
||||
start: 70%, 82%
|
||||
end: 70%, 68%
|
||||
duration: 300
|
||||
- assertVisible: "${SUBTITLE_TRACK}"
|
||||
- tapOn:
|
||||
text: "${SUBTITLE_TRACK}"
|
||||
- extendedWaitUntil:
|
||||
visible: "(?s)^.*Audio & Subtitles$"
|
||||
timeout: 30000
|
||||
- tapOn: "(?s)^.*Audio & Subtitles$"
|
||||
- assertVisible:
|
||||
text: "${AUDIO_TRACK}"
|
||||
selected: true
|
||||
- repeat:
|
||||
times: 12
|
||||
while:
|
||||
notVisible: "${SUBTITLE_TRACK}"
|
||||
commands:
|
||||
- swipe:
|
||||
start: 70%, 82%
|
||||
end: 70%, 68%
|
||||
duration: 300
|
||||
- assertVisible:
|
||||
text: "${SUBTITLE_TRACK}"
|
||||
selected: true
|
||||
- back
|
||||
+72
-2
@@ -13,7 +13,7 @@
|
||||
- Run `dart format .` to format Dart code (note: generated files like `*.g.dart` are excluded from CI checks)
|
||||
- Run `scripts/format_native.sh --fix` to format Kotlin, Swift, C++, C, Objective-C, and native headers
|
||||
- Run `flutter analyze` before submitting to check for issues
|
||||
- Run `flutter test` if tests are available
|
||||
- Run `scripts/run_tests.sh` to run the test suite (same as `flutter test`, but scaled to your core count)
|
||||
- Test your changes thoroughly
|
||||
|
||||
### Code Quality Checks
|
||||
@@ -34,10 +34,80 @@ The project includes automated CI checks that run on all pull requests:
|
||||
- Run locally: `scripts/codegen.sh --check`
|
||||
|
||||
4. **Tests**: Runs unit and widget tests (when available)
|
||||
- Run locally: `flutter test`
|
||||
- Run locally: `scripts/run_tests.sh`
|
||||
- This is `flutter test` with `-j` set to the core count. The default is half your cores, which
|
||||
leaves most of the machine idle because the suite is dominated by per-file compilation.
|
||||
Arguments are forwarded, so `scripts/run_tests.sh test/widgets/some_test.dart` works.
|
||||
|
||||
All these checks must pass before your changes can be merged.
|
||||
|
||||
### Maestro end-to-end tests
|
||||
|
||||
Android E2E tests use [Maestro](https://maestro.mobile.dev/) against a disposable, pre-seeded Jellyfin container.
|
||||
|
||||
Prerequisites: Java 17, Flutter and Android SDK/platform tools, a running Android emulator, Docker, and the
|
||||
[Maestro CLI](https://docs.maestro.dev/getting-started/installing-maestro).
|
||||
|
||||
Run the suites from the repository root (`py -3` can replace `python3` on Windows):
|
||||
|
||||
```bash
|
||||
python3 scripts/run_maestro.py basic # Basic user flows
|
||||
python3 scripts/run_maestro.py catalog # Catalog and music flows
|
||||
python3 scripts/run_maestro.py media # Codec playback and track selection
|
||||
```
|
||||
|
||||
Run one flow with `--flow`:
|
||||
|
||||
```bash
|
||||
python3 scripts/run_maestro.py basic --flow .maestro/flows/04_search.yaml
|
||||
```
|
||||
|
||||
Use `--skip-build` to reuse the debug APK and `--skip-jellyfin-build` to reuse the Jellyfin image. Set
|
||||
`--device <adb-serial>` when multiple devices are connected; physical devices also require `--adb-reverse`.
|
||||
|
||||
Top-level flows live in `.maestro/flows/`, shared setup in `.maestro/subflows/`, and focused regressions in
|
||||
`.maestro/regression_flows/`. Automatic PR groups are declared in `scripts/run_maestro_ci.py::GROUPS`. Every top-level
|
||||
regression flow must be registered either there or in `DESTRUCTIVE_MANUAL_TARGETS`; reusable subflows are not
|
||||
independent tests. A manual-only classification must state why the flow cannot run automatically and must not be
|
||||
described as CI coverage. `.github/workflows/e2e.yml` runs only automatic targets and uploads diagnostics on failure.
|
||||
|
||||
The profile-isolation and profile-teardown regressions create and remove profile connections, so they are a destructive
|
||||
manual target rather than automatic PR coverage. Run them only against the pre-seeded Jellyfin fixture and a disposable
|
||||
emulator, using the required opt-in:
|
||||
|
||||
```bash
|
||||
python3 scripts/run_maestro_ci.py profile-regressions --disposable-emulator
|
||||
```
|
||||
|
||||
The target refuses to start without `--disposable-emulator`. Each profile flow writes to its own Jellyfin log and
|
||||
diagnostics directory under `build/maestro-profile-regressions/`.
|
||||
|
||||
### Production container image updates
|
||||
|
||||
Production images in `server/Dockerfile` and `server/docker-compose.yml` use a readable version or source-revision tag
|
||||
plus an authoritative multi-platform index digest. The adjacent `Platforms` declaration records the supported
|
||||
`linux/amd64` and `linux/arm64` variants. Never replace these references with a mutable tag or a single-platform child
|
||||
manifest.
|
||||
|
||||
Update a production image only through a reviewed change:
|
||||
|
||||
1. For the Bugs service, first record the running container's image ID, repository digest, platform, and OCI source
|
||||
revision without printing its environment. Prefer that reviewed running identity; selecting anything else is a
|
||||
service upgrade, not a routine pin refresh.
|
||||
2. Review the upstream source revision and changelog, provenance, vulnerability results, and manifest contents. Resolve
|
||||
the readable tag and digest-qualified reference independently and confirm they identify the same OCI index in two
|
||||
clean caches. The index must contain both declared platforms; provenance/attestation descriptors do not count as
|
||||
runnable platforms.
|
||||
3. Change the readable tag, full `sha256` index digest, and adjacent platform declaration together. Include the old and
|
||||
new identities, manifest/platform evidence, review findings, smoke results, and rollback notes in the change.
|
||||
4. Before changing the Bugs digest, exercise it with non-production configuration and a disposable volume. Review
|
||||
migrations, take a restorable `bugs_data` backup, then validate a cloned volume. A forward-only migration rolls back
|
||||
with the prior digest and pre-change backup, not by changing the image reference alone.
|
||||
5. Run `python3 scripts/check_container_image_pins.py`, `python3 scripts/test_check_container_image_pins.py`, and
|
||||
`(cd server && go test ./...)`. Inspect the rendered Compose configuration and rebuilt images locally without
|
||||
exposing configuration values. Do not publish or deploy from a review checkout, and never fall back to `latest` when
|
||||
a digest is unavailable.
|
||||
|
||||
## Internationalization (i18n)
|
||||
|
||||
This project uses `slang` for internationalization with JSON files.
|
||||
|
||||
+2
-2
@@ -1,6 +1,6 @@
|
||||
cask "plezy" do
|
||||
version "2.9.0"
|
||||
sha256 "d7f939bdab4421d53f49b5d8b840e5f9d005e1c48269ccc1fa53a350e7d1e65c"
|
||||
version "2.9.1"
|
||||
sha256 "8cdc0cba43d82d358193575a6a71edc58749a59081e48fe188abc5c78b4be2c6"
|
||||
|
||||
url "https://github.com/edde746/plezy/releases/download/#{version}/plezy-macos.dmg"
|
||||
name "Plezy"
|
||||
|
||||
@@ -4,8 +4,6 @@ analyzer:
|
||||
exclude:
|
||||
- "**/*.g.dart"
|
||||
- "**/*.freezed.dart"
|
||||
plugins:
|
||||
- dart_code_linter
|
||||
|
||||
linter:
|
||||
rules:
|
||||
@@ -17,6 +15,8 @@ linter:
|
||||
prefer_final_in_for_each: true
|
||||
avoid_print: true
|
||||
|
||||
# Do not register DCL as an analyzer plugin: its analyzer 10 integration crashes
|
||||
# on Linux. CI invokes the supported check-unused commands explicitly.
|
||||
dart_code_linter:
|
||||
rules-exclude:
|
||||
- "test/**"
|
||||
|
||||
+327
-48
@@ -1,5 +1,55 @@
|
||||
import java.io.FileInputStream
|
||||
import java.nio.file.Files
|
||||
import java.nio.file.StandardCopyOption
|
||||
import java.security.MessageDigest
|
||||
import java.util.Properties
|
||||
import java.util.UUID
|
||||
import org.jetbrains.kotlin.gradle.dsl.JvmTarget
|
||||
|
||||
fun verifySha256(file: File, expected: String, identity: String) {
|
||||
val digest = MessageDigest.getInstance("SHA-256")
|
||||
file.inputStream().buffered().use { input ->
|
||||
val buffer = ByteArray(DEFAULT_BUFFER_SIZE)
|
||||
while (true) {
|
||||
val count = input.read(buffer)
|
||||
if (count < 0) break
|
||||
digest.update(buffer, 0, count)
|
||||
}
|
||||
}
|
||||
val actual = digest.digest().joinToString("") {
|
||||
(it.toInt() and 0xff).toString(16).padStart(2, '0')
|
||||
}
|
||||
if (actual != expected) {
|
||||
throw GradleException("SHA-256 mismatch for $identity: expected $expected, got $actual")
|
||||
}
|
||||
}
|
||||
|
||||
fun promoteDirectory(staging: File, destination: File) {
|
||||
val backup = File(destination.parentFile, "${destination.name}.backup-${UUID.randomUUID()}")
|
||||
val hadDestination = destination.exists()
|
||||
try {
|
||||
if (hadDestination) {
|
||||
Files.move(destination.toPath(), backup.toPath(), StandardCopyOption.ATOMIC_MOVE)
|
||||
}
|
||||
try {
|
||||
Files.move(staging.toPath(), destination.toPath(), StandardCopyOption.ATOMIC_MOVE)
|
||||
} catch (promotionFailure: Exception) {
|
||||
if (hadDestination && backup.exists()) {
|
||||
try {
|
||||
Files.move(backup.toPath(), destination.toPath(), StandardCopyOption.ATOMIC_MOVE)
|
||||
} catch (restoreFailure: Exception) {
|
||||
promotionFailure.addSuppressed(restoreFailure)
|
||||
}
|
||||
}
|
||||
throw promotionFailure
|
||||
}
|
||||
if (hadDestination && backup.exists() && !backup.deleteRecursively()) {
|
||||
throw GradleException("Failed to remove obsolete native artifact backup at ${backup.absolutePath}")
|
||||
}
|
||||
} finally {
|
||||
staging.deleteRecursively()
|
||||
}
|
||||
}
|
||||
|
||||
plugins {
|
||||
id("com.android.application")
|
||||
@@ -9,23 +59,49 @@ plugins {
|
||||
}
|
||||
|
||||
val mpvVersion = "v1.0.7"
|
||||
val mpvSha256 = "d55d440e587b2a9ffb91874d93069460a987be05fe72af8394849983f0df2d7a"
|
||||
val mpvDir = layout.buildDirectory.dir("libmpv").get().asFile
|
||||
val mpvAar = "libmpv-release.aar"
|
||||
val mpvUrl = "https://github.com/edde746/libmpv-android/releases/download/$mpvVersion/$mpvAar"
|
||||
|
||||
val downloadLibmpv by tasks.registering {
|
||||
val stamp = File(mpvDir, ".version")
|
||||
outputs.upToDateWhen { stamp.exists() && stamp.readText().trim() == mpvVersion }
|
||||
val media3Version = "1.10.1"
|
||||
val mpvFfmpegVersion = "8.0.1"
|
||||
val mpvFfmpegSourceSha256 = "05ee0b03119b45c0bdb4df654b96802e909e0a752f72e4fe3794f487229e5a41"
|
||||
val mpvFfmpegSourceUrl = "https://ffmpeg.org/releases/ffmpeg-$mpvFfmpegVersion.tar.xz"
|
||||
val mpvFfmpegDevelopmentDir = File(mpvDir, "ffmpeg-development")
|
||||
|
||||
val downloadLibmpv = tasks.register("downloadLibmpv") {
|
||||
val aar = File(mpvDir, mpvAar)
|
||||
val manifest = File(mpvDir, ".manifest")
|
||||
inputs.property("version", mpvVersion)
|
||||
inputs.property("sourceUrl", mpvUrl)
|
||||
inputs.property("sha256", mpvSha256)
|
||||
outputs.files(aar, manifest)
|
||||
doLast {
|
||||
mpvDir.mkdirs()
|
||||
val url = "https://github.com/edde746/libmpv-android/releases/download/$mpvVersion/$mpvAar"
|
||||
exec { commandLine("curl", "-sfL", url, "-o", File(mpvDir, mpvAar).absolutePath) }
|
||||
stamp.writeText(mpvVersion)
|
||||
mpvDir.parentFile.mkdirs()
|
||||
val staging = File(mpvDir.parentFile, "${mpvDir.name}.staging-${UUID.randomUUID()}")
|
||||
try {
|
||||
staging.mkdirs()
|
||||
val stagedAar = File(staging, mpvAar)
|
||||
try {
|
||||
providers.exec {
|
||||
commandLine("curl", "-sfL", mpvUrl, "-o", stagedAar.absolutePath)
|
||||
}.result.get().assertNormalExitValue()
|
||||
} catch (error: Exception) {
|
||||
throw GradleException("Failed to download $mpvAar $mpvVersion", error)
|
||||
}
|
||||
verifySha256(stagedAar, mpvSha256, "$mpvAar $mpvVersion")
|
||||
File(staging, ".manifest").writeText("version=$mpvVersion\nsha256=$mpvSha256\n")
|
||||
promoteDirectory(staging, mpvDir)
|
||||
} finally {
|
||||
staging.deleteRecursively()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Extract libc++_shared.so from the libmpv AAR so the app source set can package
|
||||
// it with top merge priority (see packaging { jniLibs } and sourceSets below).
|
||||
val extractMpvLibcxx by tasks.registering {
|
||||
val extractMpvLibcxx = tasks.register("extractMpvLibcxx") {
|
||||
dependsOn(downloadLibmpv)
|
||||
val aar = File(mpvDir, mpvAar)
|
||||
val outDir = File(mpvDir, "libcxx")
|
||||
@@ -34,7 +110,7 @@ val extractMpvLibcxx by tasks.registering {
|
||||
doLast {
|
||||
outDir.deleteRecursively() // drop stale ABIs from a previous AAR version
|
||||
outDir.mkdirs()
|
||||
exec {
|
||||
providers.exec {
|
||||
commandLine(
|
||||
"unzip",
|
||||
"-q",
|
||||
@@ -44,49 +120,213 @@ val extractMpvLibcxx by tasks.registering {
|
||||
"-d",
|
||||
outDir.absolutePath
|
||||
)
|
||||
}.result.get().assertNormalExitValue()
|
||||
}
|
||||
}
|
||||
|
||||
// Build the Media3 JNI adapter against the same shared FFmpeg libraries that
|
||||
// libmpv packages. Headers are pinned to libmpv's FFmpeg version and remain
|
||||
// build-only; the APK contains one FFmpeg implementation for both players.
|
||||
val prepareMpvFfmpegDevelopment = tasks.register("prepareMpvFfmpegDevelopment") {
|
||||
dependsOn(downloadLibmpv)
|
||||
val aar = File(mpvDir, mpvAar)
|
||||
val manifest = File(mpvFfmpegDevelopmentDir, ".manifest")
|
||||
val abis = listOf("arm64-v8a", "armeabi-v7a", "x86", "x86_64")
|
||||
val libraries = listOf("avcodec", "avutil", "swresample")
|
||||
inputs.file(aar)
|
||||
inputs.property("ffmpegVersion", mpvFfmpegVersion)
|
||||
inputs.property("sourceUrl", mpvFfmpegSourceUrl)
|
||||
inputs.property("sourceSha256", mpvFfmpegSourceSha256)
|
||||
outputs.files(
|
||||
abis.flatMap { abi ->
|
||||
libraries.map { library -> File(mpvFfmpegDevelopmentDir, "native/$abi/lib$library.so") }
|
||||
}
|
||||
)
|
||||
outputs.files(
|
||||
File(mpvFfmpegDevelopmentDir, "include/libavcodec/avcodec.h"),
|
||||
File(mpvFfmpegDevelopmentDir, "include/libavutil/avconfig.h"),
|
||||
File(mpvFfmpegDevelopmentDir, "include/libswresample/swresample.h"),
|
||||
manifest
|
||||
)
|
||||
doLast {
|
||||
val staging = File(
|
||||
mpvFfmpegDevelopmentDir.parentFile,
|
||||
"${mpvFfmpegDevelopmentDir.name}.staging-${UUID.randomUUID()}"
|
||||
)
|
||||
try {
|
||||
val sourceArchive = File(staging, "ffmpeg-$mpvFfmpegVersion.tar.xz")
|
||||
val includeDir = File(staging, "include")
|
||||
val nativeDir = File(staging, "native")
|
||||
staging.mkdirs()
|
||||
try {
|
||||
providers.exec {
|
||||
commandLine("curl", "-sfL", mpvFfmpegSourceUrl, "-o", sourceArchive.absolutePath)
|
||||
}.result.get().assertNormalExitValue()
|
||||
} catch (error: Exception) {
|
||||
throw GradleException("Failed to download FFmpeg $mpvFfmpegVersion headers", error)
|
||||
}
|
||||
verifySha256(sourceArchive, mpvFfmpegSourceSha256, "FFmpeg $mpvFfmpegVersion source")
|
||||
|
||||
val extractedSource = File(staging, "source").apply { mkdirs() }
|
||||
try {
|
||||
providers.exec {
|
||||
commandLine(
|
||||
"tar",
|
||||
"-xJf",
|
||||
sourceArchive.absolutePath,
|
||||
"--strip-components=1",
|
||||
"-C",
|
||||
extractedSource.absolutePath
|
||||
)
|
||||
}.result.get().assertNormalExitValue()
|
||||
} catch (error: Exception) {
|
||||
throw GradleException("Failed to extract FFmpeg $mpvFfmpegVersion headers", error)
|
||||
}
|
||||
listOf("libavcodec", "libavutil", "libswresample").forEach { library ->
|
||||
project.copy {
|
||||
from(File(extractedSource, library)) {
|
||||
include("*.h")
|
||||
}
|
||||
into(File(includeDir, library))
|
||||
}
|
||||
}
|
||||
File(includeDir, "libavutil/avconfig.h").writeText(
|
||||
"""
|
||||
|/* Generated for Plezy's little-endian Android ABIs. */
|
||||
|#ifndef AVUTIL_AVCONFIG_H
|
||||
|#define AVUTIL_AVCONFIG_H
|
||||
|#define AV_HAVE_BIGENDIAN 0
|
||||
|#define AV_HAVE_FAST_UNALIGNED 0
|
||||
|#endif /* AVUTIL_AVCONFIG_H */
|
||||
|
|
||||
""".trimMargin()
|
||||
)
|
||||
|
||||
project.copy {
|
||||
from(zipTree(aar)) {
|
||||
include(
|
||||
"jni/*/libavcodec.so",
|
||||
"jni/*/libavutil.so",
|
||||
"jni/*/libswresample.so"
|
||||
)
|
||||
eachFile {
|
||||
path = path.removePrefix("jni/")
|
||||
}
|
||||
}
|
||||
includeEmptyDirs = false
|
||||
into(nativeDir)
|
||||
}
|
||||
|
||||
val missing = abis.flatMap { abi ->
|
||||
libraries.map { library -> File(nativeDir, "$abi/lib$library.so") }
|
||||
}.filterNot(File::isFile)
|
||||
if (missing.isNotEmpty()) {
|
||||
throw GradleException(
|
||||
"libmpv $mpvVersion is missing FFmpeg libraries: ${missing.joinToString { it.relativeTo(staging).path }}"
|
||||
)
|
||||
}
|
||||
File(staging, ".manifest").writeText(
|
||||
"mpv=$mpvVersion\nffmpeg=$mpvFfmpegVersion\nsourceSha256=$mpvFfmpegSourceSha256\n"
|
||||
)
|
||||
sourceArchive.delete()
|
||||
extractedSource.deleteRecursively()
|
||||
promoteDirectory(staging, mpvFfmpegDevelopmentDir)
|
||||
} finally {
|
||||
staging.deleteRecursively()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
val doviVersion = "2.3.1"
|
||||
val doviDir = layout.buildDirectory.dir("libdovi").get().asFile
|
||||
val doviAbis = mapOf(
|
||||
"arm64-v8a" to "aarch64-linux-android",
|
||||
"armeabi-v7a" to "armv7-linux-androideabi",
|
||||
"x86" to "i686-linux-android",
|
||||
"x86_64" to "x86_64-linux-android"
|
||||
val doviArtifacts = mapOf(
|
||||
"arm64-v8a" to Pair(
|
||||
"aarch64-linux-android",
|
||||
"9d2983fc86f2f9e6da54c3c84ba8ea3a528690619f312ff4620198071b84e9ae"
|
||||
),
|
||||
"armeabi-v7a" to Pair(
|
||||
"armv7-linux-androideabi",
|
||||
"ed6fec8bf744e41c661b97f5fc4bf1197ebe9b09a140cbde369728e790ee3a68"
|
||||
),
|
||||
"x86" to Pair(
|
||||
"i686-linux-android",
|
||||
"50f0a5606e617dff8976b9e7930a23272f4804882a35a6f0f2b2f2d3f8ed7135"
|
||||
),
|
||||
"x86_64" to Pair(
|
||||
"x86_64-linux-android",
|
||||
"eba59678f89b792f5c6f802962e237542fe8328f6aa03a0a90ee77353dac3194"
|
||||
)
|
||||
)
|
||||
val doviBaseUrl = "https://github.com/edde746/libdovi-builds/releases/download/v$doviVersion"
|
||||
|
||||
val downloadLibdovi by tasks.registering {
|
||||
val stamp = File(doviDir, ".version")
|
||||
outputs.upToDateWhen { stamp.exists() && stamp.readText().trim() == doviVersion }
|
||||
val downloadLibdovi = tasks.register("downloadLibdovi") {
|
||||
val manifest = File(doviDir, ".manifest")
|
||||
inputs.property("version", doviVersion)
|
||||
inputs.property("baseUrl", doviBaseUrl)
|
||||
doviArtifacts.forEach { (abi, artifact) ->
|
||||
inputs.property("$abi.triple", artifact.first)
|
||||
inputs.property("$abi.sha256", artifact.second)
|
||||
inputs.property("$abi.sourceUrl", "$doviBaseUrl/libdovi-${artifact.first}.tar.gz")
|
||||
}
|
||||
outputs.files(doviArtifacts.keys.map { abi -> File(doviDir, "$abi/lib/libdovi.a") } + manifest)
|
||||
doLast {
|
||||
doviDir.mkdirs()
|
||||
val baseUrl = "https://github.com/edde746/libdovi-builds/releases/download/v$doviVersion"
|
||||
doviAbis.forEach { (abi, triple) ->
|
||||
val archive = File(doviDir, "$triple.tar.gz")
|
||||
exec { commandLine("curl", "-sfL", "$baseUrl/libdovi-$triple.tar.gz", "-o", archive.absolutePath) }
|
||||
val outDir = File(doviDir, "$abi/lib")
|
||||
outDir.mkdirs()
|
||||
exec { commandLine("tar", "-xzf", archive.absolutePath, "-C", outDir.absolutePath) }
|
||||
archive.delete()
|
||||
doviDir.parentFile.mkdirs()
|
||||
val staging = File(doviDir.parentFile, "${doviDir.name}.staging-${UUID.randomUUID()}")
|
||||
try {
|
||||
staging.mkdirs()
|
||||
val downloads = File(staging, ".downloads").apply { mkdirs() }
|
||||
doviArtifacts.forEach { (abi, artifact) ->
|
||||
val (triple, expectedSha256) = artifact
|
||||
val archiveName = "libdovi-$triple.tar.gz"
|
||||
val archive = File(downloads, archiveName)
|
||||
val sourceUrl = "$doviBaseUrl/$archiveName"
|
||||
try {
|
||||
providers.exec {
|
||||
commandLine("curl", "-sfL", sourceUrl, "-o", archive.absolutePath)
|
||||
}.result.get().assertNormalExitValue()
|
||||
} catch (error: Exception) {
|
||||
throw GradleException("Failed to download $archiveName v$doviVersion", error)
|
||||
}
|
||||
verifySha256(archive, expectedSha256, "$archiveName v$doviVersion")
|
||||
|
||||
val outDir = File(staging, "$abi/lib").apply { mkdirs() }
|
||||
try {
|
||||
providers.exec {
|
||||
commandLine("tar", "-xzf", archive.absolutePath, "-C", outDir.absolutePath)
|
||||
}.result.get().assertNormalExitValue()
|
||||
} catch (error: Exception) {
|
||||
throw GradleException("Failed to extract $archiveName", error)
|
||||
}
|
||||
if (!File(outDir, "libdovi.a").isFile) {
|
||||
throw GradleException("$archiveName did not contain the expected libdovi.a")
|
||||
}
|
||||
}
|
||||
if (!downloads.deleteRecursively()) {
|
||||
throw GradleException("Failed to clean staged libdovi archives")
|
||||
}
|
||||
val manifestText = buildString {
|
||||
append("version=$doviVersion\n")
|
||||
doviArtifacts.forEach { (abi, artifact) ->
|
||||
append("$abi=${artifact.first},${artifact.second}\n")
|
||||
}
|
||||
}
|
||||
File(staging, ".manifest").writeText(manifestText)
|
||||
promoteDirectory(staging, doviDir)
|
||||
} finally {
|
||||
staging.deleteRecursively()
|
||||
}
|
||||
stamp.writeText(doviVersion)
|
||||
}
|
||||
}
|
||||
|
||||
android {
|
||||
namespace = "com.edde746.plezy"
|
||||
compileSdk = flutter.compileSdkVersion
|
||||
ndkVersion = flutter.ndkVersion
|
||||
buildToolsVersion = "36.1.0"
|
||||
ndkVersion = "29.0.14206865"
|
||||
|
||||
compileOptions {
|
||||
sourceCompatibility = JavaVersion.VERSION_11
|
||||
targetCompatibility = JavaVersion.VERSION_11
|
||||
}
|
||||
|
||||
kotlinOptions {
|
||||
jvmTarget = JavaVersion.VERSION_11.toString()
|
||||
sourceCompatibility = JavaVersion.VERSION_17
|
||||
targetCompatibility = JavaVersion.VERSION_17
|
||||
}
|
||||
|
||||
defaultConfig {
|
||||
@@ -97,12 +337,14 @@ android {
|
||||
targetSdk = flutter.targetSdkVersion
|
||||
versionCode = flutter.versionCode
|
||||
versionName = flutter.versionName
|
||||
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
|
||||
|
||||
externalNativeBuild {
|
||||
cmake {
|
||||
arguments += listOf(
|
||||
"-DDOVI_ENABLE_LIBDOVI=ON",
|
||||
"-DDOVI_LIBDOVI_PREBUILT_ROOT=${doviDir.absolutePath}"
|
||||
"-DDOVI_LIBDOVI_PREBUILT_ROOT=${doviDir.absolutePath}",
|
||||
"-DMPV_FFMPEG_ROOT=${mpvFfmpegDevelopmentDir.absolutePath}"
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -118,6 +360,7 @@ android {
|
||||
externalNativeBuild {
|
||||
cmake {
|
||||
path = file("src/main/cpp/CMakeLists.txt")
|
||||
version = "4.1.2"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -174,16 +417,52 @@ android {
|
||||
}
|
||||
}
|
||||
|
||||
// BackgroundWorkDiagnostics routes users to background_downloader's private
|
||||
// notification channel. Fail the build if an upstream ref changes that ID.
|
||||
val verifyBackgroundDownloaderNotificationChannel = tasks.register("verifyBackgroundDownloaderNotificationChannel") {
|
||||
val expectedChannelId = "background_downloader"
|
||||
val downloaderProject = rootProject.findProject(":background_downloader")
|
||||
val notificationsSource = downloaderProject?.projectDir?.resolve(
|
||||
"src/main/kotlin/com/bbflight/background_downloader/Notifications.kt"
|
||||
)
|
||||
notificationsSource?.let(inputs::file)
|
||||
doLast {
|
||||
if (notificationsSource == null) {
|
||||
logger.lifecycle("Skipping downloader channel verification: Flutter plugin project is not configured")
|
||||
return@doLast
|
||||
}
|
||||
val actualChannelId = Regex(
|
||||
"""private const val notificationChannelId\s*=\s*"([^"]+)""""
|
||||
).find(notificationsSource.readText())?.groupValues?.get(1)
|
||||
?: throw GradleException("Could not locate background_downloader's notification channel ID")
|
||||
if (actualChannelId != expectedChannelId) {
|
||||
throw GradleException(
|
||||
"background_downloader channel ID changed from $expectedChannelId to $actualChannelId; " +
|
||||
"update BackgroundWorkDiagnostics and its tests"
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
tasks.named("preBuild").configure {
|
||||
dependsOn(verifyBackgroundDownloaderNotificationChannel)
|
||||
}
|
||||
|
||||
kotlin {
|
||||
compilerOptions {
|
||||
jvmTarget.set(JvmTarget.JVM_17)
|
||||
}
|
||||
}
|
||||
|
||||
flutter {
|
||||
source = "../.."
|
||||
}
|
||||
|
||||
tasks.matching { it.name.contains("CMake") || it.name.contains("externalNative") }.configureEach {
|
||||
dependsOn(downloadLibdovi)
|
||||
dependsOn(downloadLibdovi, prepareMpvFfmpegDevelopment)
|
||||
}
|
||||
|
||||
tasks.matching { it.name.startsWith("pre") && it.name.endsWith("Build") }.configureEach {
|
||||
dependsOn(downloadLibmpv, extractMpvLibcxx)
|
||||
dependsOn(downloadLibmpv, extractMpvLibcxx, prepareMpvFfmpegDevelopment)
|
||||
}
|
||||
// Gradle snapshots jniLibs source dirs before task execution; this keeps the
|
||||
// extracted libmpv libc++ directory present during input discovery.
|
||||
@@ -193,24 +472,22 @@ tasks.matching { it.name.startsWith("merge") && it.name.endsWith("JniLibFolders"
|
||||
|
||||
dependencies {
|
||||
implementation(files(File(mpvDir, mpvAar)))
|
||||
implementation("org.jetbrains.kotlinx:kotlinx-coroutines-android:1.9.0")
|
||||
implementation("org.jetbrains.kotlinx:kotlinx-coroutines-android:1.11.0")
|
||||
|
||||
// Android TV Watch Next integration
|
||||
implementation("androidx.tvprovider:tvprovider:1.0.0")
|
||||
implementation("androidx.tvprovider:tvprovider:1.1.0")
|
||||
|
||||
// Media3 ExoPlayer for Android
|
||||
implementation("androidx.media3:media3-exoplayer:1.9.2")
|
||||
implementation("androidx.media3:media3-exoplayer-hls:1.9.2")
|
||||
implementation("androidx.media3:media3-ui:1.9.2")
|
||||
implementation("androidx.media3:media3-common:1.9.2")
|
||||
implementation("androidx.media3:media3-decoder:$media3Version")
|
||||
implementation("androidx.media3:media3-exoplayer:$media3Version")
|
||||
implementation("androidx.media3:media3-exoplayer-hls:$media3Version")
|
||||
implementation("androidx.media3:media3-ui:$media3Version")
|
||||
implementation("androidx.media3:media3-common:$media3Version")
|
||||
|
||||
// Cronet for HTTP/2 multiplexing + better connection management
|
||||
implementation("androidx.media3:media3-datasource-cronet:1.9.2")
|
||||
implementation("androidx.media3:media3-datasource-cronet:$media3Version")
|
||||
implementation("org.chromium.net:cronet-embedded:143.7445.0")
|
||||
|
||||
// FFmpeg audio decoder for unsupported codecs (ALAC, DTS, TrueHD, etc.)
|
||||
implementation("org.jellyfin.media3:media3-ffmpeg-decoder:1.9.0+1")
|
||||
|
||||
// Keeping libass in-project lets its static core share the app's native
|
||||
// packaging rules.
|
||||
implementation(project(":libass"))
|
||||
@@ -218,5 +495,7 @@ dependencies {
|
||||
testImplementation("junit:junit:4.13.2")
|
||||
// Real android.util.* implementations for tests exercising media3 classes
|
||||
// (MatroskaExtractor uses SparseArray, which is a no-op stub on plain JVM)
|
||||
testImplementation("org.robolectric:robolectric:4.15.1")
|
||||
testImplementation("org.robolectric:robolectric:4.16.1")
|
||||
androidTestImplementation("androidx.test:runner:1.7.0")
|
||||
androidTestImplementation("androidx.test.ext:junit:1.3.0")
|
||||
}
|
||||
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
+160
@@ -0,0 +1,160 @@
|
||||
/*
|
||||
* Copyright (C) 2026 Plezy contributors
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
package androidx.media3.decoder.ffmpeg;
|
||||
|
||||
import static org.junit.Assert.assertNull;
|
||||
import static org.junit.Assert.assertTrue;
|
||||
|
||||
import android.content.Context;
|
||||
import android.net.Uri;
|
||||
import android.os.Handler;
|
||||
import android.os.HandlerThread;
|
||||
import androidx.annotation.Nullable;
|
||||
import androidx.media3.common.MediaItem;
|
||||
import androidx.media3.common.PlaybackException;
|
||||
import androidx.media3.common.Player;
|
||||
import androidx.media3.datasource.DefaultDataSource;
|
||||
import androidx.media3.exoplayer.ExoPlayer;
|
||||
import androidx.media3.exoplayer.Renderer;
|
||||
import androidx.media3.exoplayer.RenderersFactory;
|
||||
import androidx.media3.exoplayer.audio.DefaultAudioSink;
|
||||
import androidx.media3.exoplayer.source.MediaSource;
|
||||
import androidx.media3.exoplayer.source.ProgressiveMediaSource;
|
||||
import androidx.media3.extractor.DefaultExtractorsFactory;
|
||||
import androidx.test.ext.junit.runners.AndroidJUnit4;
|
||||
import androidx.test.platform.app.InstrumentationRegistry;
|
||||
import java.io.File;
|
||||
import java.io.FileOutputStream;
|
||||
import java.io.InputStream;
|
||||
import java.io.OutputStream;
|
||||
import java.util.concurrent.CountDownLatch;
|
||||
import java.util.concurrent.TimeUnit;
|
||||
import java.util.concurrent.atomic.AtomicReference;
|
||||
import org.junit.Test;
|
||||
import org.junit.runner.RunWith;
|
||||
|
||||
@RunWith(AndroidJUnit4.class)
|
||||
public final class PlezyFfmpegPlaybackTest {
|
||||
private static final String[] FIXTURES = {
|
||||
"ffmpeg/stereo.flac",
|
||||
"ffmpeg/surround_5_1.flac",
|
||||
"ffmpeg/surround_7_1.flac",
|
||||
"ffmpeg/planar_5_1.m4a",
|
||||
"ffmpeg/surround_5_1_eac3.mka",
|
||||
"ffmpeg/surround_5_1_dts.mka",
|
||||
"ffmpeg/surround_5_1_truehd.mka"
|
||||
};
|
||||
|
||||
@Test
|
||||
public void sharedDecoderUsesLibmpvFfmpegAndPlaysAllFixtures() throws Exception {
|
||||
assertTrue("FFmpeg JNI library is unavailable", FfmpegLibrary.isAvailable());
|
||||
String version = FfmpegLibrary.getVersion();
|
||||
assertTrue("Expected libmpv's FFmpeg 8, got " + version, version != null && version.startsWith("Lavc62."));
|
||||
for (String fixture : FIXTURES) {
|
||||
playToEnd(fixture);
|
||||
}
|
||||
}
|
||||
|
||||
private static void playToEnd(String fixture) throws Exception {
|
||||
Context instrumentationContext = InstrumentationRegistry.getInstrumentation().getContext();
|
||||
Context context = InstrumentationRegistry.getInstrumentation().getTargetContext();
|
||||
File fixtureFile = copyFixture(instrumentationContext, context, fixture);
|
||||
HandlerThread playbackThread = new HandlerThread("ffmpeg-test-player");
|
||||
playbackThread.start();
|
||||
Handler handler = new Handler(playbackThread.getLooper());
|
||||
CountDownLatch completed = new CountDownLatch(1);
|
||||
AtomicReference<ExoPlayer> playerReference = new AtomicReference<>();
|
||||
AtomicReference<Throwable> errorReference = new AtomicReference<>();
|
||||
|
||||
handler.post(
|
||||
() -> {
|
||||
try {
|
||||
RenderersFactory renderersFactory =
|
||||
(eventHandler,
|
||||
videoRendererEventListener,
|
||||
audioRendererEventListener,
|
||||
textRendererOutput,
|
||||
metadataRendererOutput) ->
|
||||
new Renderer[] {
|
||||
new FfmpegAudioRenderer(
|
||||
eventHandler, audioRendererEventListener, new DefaultAudioSink.Builder().build())
|
||||
};
|
||||
ExoPlayer player =
|
||||
new ExoPlayer.Builder(context, renderersFactory)
|
||||
.setLooper(playbackThread.getLooper())
|
||||
.build();
|
||||
playerReference.set(player);
|
||||
player.addListener(
|
||||
new Player.Listener() {
|
||||
@Override
|
||||
public void onPlayerError(PlaybackException error) {
|
||||
errorReference.set(error);
|
||||
completed.countDown();
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onPlaybackStateChanged(@Player.State int playbackState) {
|
||||
if (playbackState == Player.STATE_ENDED) {
|
||||
completed.countDown();
|
||||
}
|
||||
}
|
||||
});
|
||||
MediaSource source =
|
||||
new ProgressiveMediaSource.Factory(
|
||||
new DefaultDataSource.Factory(context), new DefaultExtractorsFactory())
|
||||
.createMediaSource(MediaItem.fromUri(Uri.fromFile(fixtureFile)));
|
||||
player.setMediaSource(source);
|
||||
player.prepare();
|
||||
player.play();
|
||||
} catch (Throwable error) {
|
||||
errorReference.set(error);
|
||||
completed.countDown();
|
||||
}
|
||||
});
|
||||
|
||||
boolean finished = completed.await(20, TimeUnit.SECONDS);
|
||||
CountDownLatch released = new CountDownLatch(1);
|
||||
handler.post(
|
||||
() -> {
|
||||
@Nullable ExoPlayer player = playerReference.get();
|
||||
if (player != null) player.release();
|
||||
playbackThread.quitSafely();
|
||||
released.countDown();
|
||||
});
|
||||
boolean teardownFinished = released.await(5, TimeUnit.SECONDS);
|
||||
playbackThread.join(5000);
|
||||
boolean fixtureDeleted = fixtureFile.delete();
|
||||
|
||||
assertTrue("Player teardown timed out for " + fixture, teardownFinished);
|
||||
assertTrue("Playback timed out for " + fixture, finished);
|
||||
assertNull("Playback failed for " + fixture, errorReference.get());
|
||||
assertTrue("Fixture cleanup failed for " + fixture, fixtureDeleted);
|
||||
}
|
||||
|
||||
private static File copyFixture(
|
||||
Context instrumentationContext, Context targetContext, String fixture) throws Exception {
|
||||
File output = File.createTempFile("ffmpeg-fixture-", null, targetContext.getCacheDir());
|
||||
try (InputStream input = instrumentationContext.getAssets().open(fixture);
|
||||
OutputStream sink = new FileOutputStream(output)) {
|
||||
byte[] buffer = new byte[8192];
|
||||
int count;
|
||||
while ((count = input.read(buffer)) != -1) {
|
||||
sink.write(buffer, 0, count);
|
||||
}
|
||||
}
|
||||
return output;
|
||||
}
|
||||
}
|
||||
+149
@@ -0,0 +1,149 @@
|
||||
package com.edde746.plezy.exoplayer
|
||||
|
||||
import android.content.Context
|
||||
import android.net.Uri
|
||||
import android.os.Handler
|
||||
import android.os.HandlerThread
|
||||
import androidx.media3.common.MediaItem
|
||||
import androidx.media3.common.MimeTypes
|
||||
import androidx.media3.common.PlaybackException
|
||||
import androidx.media3.common.Player
|
||||
import androidx.media3.common.audio.ChannelMixingMatrix
|
||||
import androidx.media3.datasource.DefaultDataSource
|
||||
import androidx.media3.exoplayer.DefaultRenderersFactory
|
||||
import androidx.media3.exoplayer.ExoPlayer
|
||||
import androidx.media3.exoplayer.source.ProgressiveMediaSource
|
||||
import androidx.media3.extractor.DefaultExtractorsFactory
|
||||
import androidx.test.ext.junit.runners.AndroidJUnit4
|
||||
import androidx.test.platform.app.InstrumentationRegistry
|
||||
import java.io.File
|
||||
import java.util.concurrent.CountDownLatch
|
||||
import java.util.concurrent.TimeUnit
|
||||
import java.util.concurrent.atomic.AtomicBoolean
|
||||
import java.util.concurrent.atomic.AtomicReference
|
||||
import org.junit.Assert.assertNull
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Test
|
||||
import org.junit.runner.RunWith
|
||||
|
||||
@RunWith(AndroidJUnit4::class)
|
||||
class PlezyAudioModePlaybackTest {
|
||||
private enum class AudioMode {
|
||||
PASSTHROUGH_ALLOWED,
|
||||
FORCE_DECODED,
|
||||
DOWNMIX_NORMALIZED,
|
||||
DOWNMIX_UNNORMALIZED,
|
||||
NORMALIZATION
|
||||
}
|
||||
|
||||
@Test
|
||||
fun appAudioPipelinePlaysAcrossOutputModes() {
|
||||
playToEnd("ffmpeg/surround_5_1_dts.mka", AudioMode.PASSTHROUGH_ALLOWED)
|
||||
playToEnd("ffmpeg/surround_5_1_truehd.mka", AudioMode.FORCE_DECODED)
|
||||
playToEnd("ffmpeg/surround_5_1.flac", AudioMode.DOWNMIX_NORMALIZED)
|
||||
playToEnd("ffmpeg/surround_7_1.flac", AudioMode.DOWNMIX_UNNORMALIZED)
|
||||
playToEnd("ffmpeg/surround_5_1_eac3.mka", AudioMode.NORMALIZATION)
|
||||
}
|
||||
|
||||
private fun playToEnd(fixture: String, mode: AudioMode) {
|
||||
val instrumentation = InstrumentationRegistry.getInstrumentation()
|
||||
val context = instrumentation.targetContext
|
||||
val fixtureFile = copyFixture(instrumentation.context, context, fixture)
|
||||
val playbackThread = HandlerThread("plezy-audio-mode-test").apply { start() }
|
||||
val handler = Handler(playbackThread.looper)
|
||||
val completed = CountDownLatch(1)
|
||||
val playerReference = AtomicReference<ExoPlayer>()
|
||||
val errorReference = AtomicReference<Throwable>()
|
||||
val outputPolicyConsulted = AtomicBoolean(false)
|
||||
val normalizationAttachAttempted = AtomicBoolean(false)
|
||||
val normalization = AudioNormalizationEffect { _, _, _ -> }
|
||||
val downmixActive = AtomicBoolean(false)
|
||||
|
||||
handler.post {
|
||||
try {
|
||||
val factory = PlezyRenderersFactory(context).apply {
|
||||
setEnableDecoderFallback(true)
|
||||
setExtensionRendererMode(DefaultRenderersFactory.EXTENSION_RENDERER_MODE_ON)
|
||||
shouldBlockDirectAudioOutput = { format ->
|
||||
val encoded = format.sampleMimeType != null && format.sampleMimeType != MimeTypes.AUDIO_RAW
|
||||
if (encoded) outputPolicyConsulted.set(true)
|
||||
encoded && mode != AudioMode.PASSTHROUGH_ALLOWED
|
||||
}
|
||||
if (mode == AudioMode.DOWNMIX_NORMALIZED || mode == AudioMode.DOWNMIX_UNNORMALIZED) {
|
||||
val normalize = mode == AudioMode.DOWNMIX_NORMALIZED
|
||||
for (channelCount in DownmixMatrices.MIN_DOWNMIX_INPUT_CHANNELS..DownmixMatrices.MAX_DOWNMIX_INPUT_CHANNELS) {
|
||||
val coefficients = DownmixMatrices.stereoCoefficients(channelCount, centerBoostDb = 6, normalize = normalize)!!
|
||||
channelMixProcessor.putChannelMixingMatrix(ChannelMixingMatrix(channelCount, 2, coefficients))
|
||||
}
|
||||
}
|
||||
}
|
||||
val player = ExoPlayer.Builder(context, factory)
|
||||
.setLooper(playbackThread.looper)
|
||||
.build()
|
||||
playerReference.set(player)
|
||||
player.addListener(object : Player.Listener {
|
||||
override fun onAudioSessionIdChanged(audioSessionId: Int) {
|
||||
if (mode == AudioMode.NORMALIZATION) {
|
||||
normalizationAttachAttempted.set(true)
|
||||
normalization.attach(audioSessionId, channelCount = 6)
|
||||
}
|
||||
}
|
||||
|
||||
override fun onPlayerError(error: PlaybackException) {
|
||||
errorReference.set(error)
|
||||
completed.countDown()
|
||||
}
|
||||
|
||||
override fun onPlaybackStateChanged(playbackState: Int) {
|
||||
if (playbackState == Player.STATE_ENDED) {
|
||||
downmixActive.set(factory.channelMixProcessor.isActive)
|
||||
completed.countDown()
|
||||
}
|
||||
}
|
||||
})
|
||||
val source = ProgressiveMediaSource.Factory(
|
||||
DefaultDataSource.Factory(context),
|
||||
DefaultExtractorsFactory()
|
||||
).createMediaSource(MediaItem.fromUri(Uri.fromFile(fixtureFile)))
|
||||
player.setMediaSource(source)
|
||||
player.prepare()
|
||||
player.play()
|
||||
} catch (error: Throwable) {
|
||||
errorReference.set(error)
|
||||
completed.countDown()
|
||||
}
|
||||
}
|
||||
|
||||
val finished = completed.await(20, TimeUnit.SECONDS)
|
||||
val released = CountDownLatch(1)
|
||||
handler.post {
|
||||
playerReference.get()?.release()
|
||||
normalization.release()
|
||||
playbackThread.quitSafely()
|
||||
released.countDown()
|
||||
}
|
||||
val teardownFinished = released.await(5, TimeUnit.SECONDS)
|
||||
playbackThread.join(5_000)
|
||||
val fixtureDeleted = fixtureFile.delete()
|
||||
|
||||
assertTrue("Player teardown timed out for $mode / $fixture", teardownFinished)
|
||||
assertTrue("Playback timed out for $mode / $fixture", finished)
|
||||
assertNull("Playback failed for $mode / $fixture", errorReference.get())
|
||||
assertTrue("Audio output policy was not consulted for $mode / $fixture", outputPolicyConsulted.get())
|
||||
if (mode == AudioMode.DOWNMIX_NORMALIZED || mode == AudioMode.DOWNMIX_UNNORMALIZED) {
|
||||
assertTrue("Downmix processor was inactive for $mode / $fixture", downmixActive.get())
|
||||
}
|
||||
if (mode == AudioMode.NORMALIZATION) {
|
||||
assertTrue("Normalization did not receive an audio session", normalizationAttachAttempted.get())
|
||||
}
|
||||
assertTrue("Fixture cleanup failed for $mode / $fixture", fixtureDeleted)
|
||||
}
|
||||
|
||||
private fun copyFixture(instrumentationContext: Context, targetContext: Context, fixture: String): File {
|
||||
val output = File.createTempFile("audio-mode-fixture-", null, targetContext.cacheDir)
|
||||
instrumentationContext.assets.open(fixture).use { input ->
|
||||
output.outputStream().use(input::copyTo)
|
||||
}
|
||||
return output
|
||||
}
|
||||
}
|
||||
@@ -28,6 +28,7 @@
|
||||
<uses-feature android:name="android.software.leanback" android:required="false" />
|
||||
<!-- Android TV Watch Next integration -->
|
||||
<uses-permission android:name="com.android.providers.tv.permission.WRITE_EPG_DATA"/>
|
||||
<uses-permission android:name="android.permission.RECEIVE_BOOT_COMPLETED"/>
|
||||
<!-- Touchscreen not required for TV -->
|
||||
<uses-feature android:name="android.hardware.touchscreen" android:required="false" />
|
||||
|
||||
@@ -41,6 +42,7 @@
|
||||
android:banner="@drawable/tv_banner"
|
||||
android:appCategory="video"
|
||||
android:largeHeap="true"
|
||||
android:networkSecurityConfig="@xml/network_security_config"
|
||||
android:usesCleartextTraffic="true">
|
||||
<activity
|
||||
android:name=".MainActivity"
|
||||
@@ -87,6 +89,19 @@
|
||||
android:name="android.support.FILE_PROVIDER_PATHS"
|
||||
android:resource="@xml/file_provider_paths" />
|
||||
</provider>
|
||||
<provider
|
||||
android:name=".watchnext.SystemShelfArtworkProvider"
|
||||
android:authorities="com.edde746.plezy.systemshelf.artwork"
|
||||
android:exported="true"
|
||||
android:grantUriPermissions="true" />
|
||||
<receiver
|
||||
android:name=".watchnext.SystemShelfUpdateReceiver"
|
||||
android:exported="false">
|
||||
<intent-filter>
|
||||
<action android:name="android.intent.action.MY_PACKAGE_REPLACED" />
|
||||
<action android:name="android.intent.action.BOOT_COMPLETED" />
|
||||
</intent-filter>
|
||||
</receiver>
|
||||
<!-- Don't delete the meta-data below.
|
||||
This is used by the Flutter tool to generate GeneratedPluginRegistrant.java -->
|
||||
<meta-data
|
||||
@@ -117,5 +132,10 @@
|
||||
<action android:name="android.intent.action.VIEW" />
|
||||
<data android:mimeType="video/*" />
|
||||
</intent>
|
||||
<!-- Required to identify only the active HOME launcher as a shelf artwork consumer. -->
|
||||
<intent>
|
||||
<action android:name="android.intent.action.MAIN" />
|
||||
<category android:name="android.intent.category.HOME" />
|
||||
</intent>
|
||||
</queries>
|
||||
</manifest>
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
cmake_minimum_required(VERSION 3.22.1)
|
||||
project(dovi_bridge)
|
||||
project(plezy_native)
|
||||
|
||||
option(DOVI_ENABLE_LIBDOVI "Link real libdovi" ON)
|
||||
set(DOVI_LIBDOVI_PREBUILT_ROOT "" CACHE PATH "Path to prebuilt libdovi")
|
||||
set(MPV_FFMPEG_ROOT "" CACHE PATH "Path to libmpv's FFmpeg headers and shared libraries")
|
||||
|
||||
add_library(dovi_bridge SHARED dovi_bridge.cpp)
|
||||
|
||||
@@ -17,3 +18,22 @@ else()
|
||||
target_compile_definitions(dovi_bridge PRIVATE DOVI_REAL_LINKED=0)
|
||||
target_link_libraries(dovi_bridge log)
|
||||
endif()
|
||||
|
||||
foreach(ffmpeg_library avcodec avutil swresample)
|
||||
add_library(${ffmpeg_library} SHARED IMPORTED)
|
||||
set_target_properties(${ffmpeg_library} PROPERTIES
|
||||
IMPORTED_LOCATION "${MPV_FFMPEG_ROOT}/native/${ANDROID_ABI}/lib${ffmpeg_library}.so")
|
||||
endforeach()
|
||||
|
||||
add_library(ffmpegJNI SHARED
|
||||
media3_ffmpeg_decoder/ffmpeg_jni.cc)
|
||||
target_compile_features(ffmpegJNI PRIVATE cxx_std_17)
|
||||
target_include_directories(ffmpegJNI PRIVATE
|
||||
"${MPV_FFMPEG_ROOT}/include"
|
||||
"${CMAKE_CURRENT_SOURCE_DIR}/media3_ffmpeg_decoder")
|
||||
target_link_libraries(ffmpegJNI
|
||||
avcodec
|
||||
avutil
|
||||
swresample
|
||||
android
|
||||
log)
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
#include <jni.h>
|
||||
|
||||
#include <cstring>
|
||||
#include <memory>
|
||||
#include <new>
|
||||
#include <vector>
|
||||
|
||||
@@ -18,6 +19,7 @@ static constexpr jint CONVERT_FAILED = -1;
|
||||
static constexpr jint DESTINATION_TOO_SMALL = -2;
|
||||
static constexpr jint MAX_RPU_INPUT_SIZE = 8192;
|
||||
static constexpr size_t MAX_RPU_OUTPUT_SIZE = 16384;
|
||||
static constexpr int MAX_ERROR_LOG_LENGTH = 256;
|
||||
|
||||
extern "C" JNIEXPORT jint JNICALL Java_com_edde746_plezy_exoplayer_DoviBridge_nativeConvertDv7RpuToDv81(
|
||||
JNIEnv* env, jclass, jbyteArray payload, jint payload_offset, jint payload_length, jbyteArray output,
|
||||
@@ -62,60 +64,47 @@ extern "C" JNIEXPORT jint JNICALL Java_com_edde746_plezy_exoplayer_DoviBridge_na
|
||||
return CONVERT_FAILED;
|
||||
}
|
||||
|
||||
// Try dovi_parse_unspec62_nalu first (handles escaped NALs), fallback to dovi_parse_rpu
|
||||
// The input is a complete (possibly escaped) HEVC UNSPEC62 NAL. Parsing it as
|
||||
// a raw RPU after a framed-parser error can reinterpret malformed/truncated
|
||||
// NAL bytes as valid metadata.
|
||||
const auto rpu_len = static_cast<size_t>(payload_length);
|
||||
DoviRpuOpaque* rpu = dovi_parse_unspec62_nalu(scratch.data(), rpu_len);
|
||||
using RpuPtr = std::unique_ptr<DoviRpuOpaque, decltype(&dovi_rpu_free)>;
|
||||
RpuPtr rpu(dovi_parse_unspec62_nalu(scratch.data(), rpu_len), dovi_rpu_free);
|
||||
|
||||
if (rpu == nullptr) {
|
||||
return CONVERT_FAILED;
|
||||
}
|
||||
|
||||
const char* err = dovi_rpu_get_error(rpu);
|
||||
const char* err = dovi_rpu_get_error(rpu.get());
|
||||
if (err != nullptr) {
|
||||
// Fallback: try dovi_parse_rpu (raw RPU without NAL framing)
|
||||
dovi_rpu_free(rpu);
|
||||
rpu = dovi_parse_rpu(scratch.data(), rpu_len);
|
||||
if (rpu == nullptr) {
|
||||
return CONVERT_FAILED;
|
||||
}
|
||||
err = dovi_rpu_get_error(rpu);
|
||||
if (err != nullptr) {
|
||||
LOGW("RPU parse failed: %s", err);
|
||||
dovi_rpu_free(rpu);
|
||||
return CONVERT_FAILED;
|
||||
}
|
||||
LOGW("RPU NAL parse failed: %.*s", MAX_ERROR_LOG_LENGTH, err);
|
||||
return CONVERT_FAILED;
|
||||
}
|
||||
|
||||
// Mode 2 matches Kodi's P8.1 compatibility path and sets luma/chroma curves to no-op.
|
||||
int32_t ret = dovi_convert_rpu_with_mode(rpu, static_cast<uint8_t>(mode));
|
||||
int32_t ret = dovi_convert_rpu_with_mode(rpu.get(), static_cast<uint8_t>(mode));
|
||||
if (ret != 0) {
|
||||
err = dovi_rpu_get_error(rpu);
|
||||
LOGW("RPU conversion failed (mode %d): %s", mode, err ? err : "unknown");
|
||||
dovi_rpu_free(rpu);
|
||||
err = dovi_rpu_get_error(rpu.get());
|
||||
LOGW("RPU conversion failed (mode %d): %.*s", mode, MAX_ERROR_LOG_LENGTH, err ? err : "unknown");
|
||||
return CONVERT_FAILED;
|
||||
}
|
||||
|
||||
// Write back as UNSPEC62 NAL
|
||||
const DoviData* out = dovi_write_unspec62_nalu(rpu);
|
||||
using DoviDataPtr = std::unique_ptr<const DoviData, decltype(&dovi_data_free)>;
|
||||
DoviDataPtr out(dovi_write_unspec62_nalu(rpu.get()), dovi_data_free);
|
||||
if (out == nullptr || out->data == nullptr || out->len == 0) {
|
||||
err = dovi_rpu_get_error(rpu);
|
||||
LOGW("RPU write failed: %s", err ? err : "unknown");
|
||||
if (out != nullptr) dovi_data_free(out);
|
||||
dovi_rpu_free(rpu);
|
||||
err = dovi_rpu_get_error(rpu.get());
|
||||
LOGW("RPU write failed: %.*s", MAX_ERROR_LOG_LENGTH, err ? err : "unknown");
|
||||
return CONVERT_FAILED;
|
||||
}
|
||||
|
||||
if (out->len > MAX_RPU_OUTPUT_SIZE) {
|
||||
LOGW("RPU output unexpectedly large (%zu bytes), discarding", out->len);
|
||||
dovi_data_free(out);
|
||||
dovi_rpu_free(rpu);
|
||||
return CONVERT_FAILED;
|
||||
}
|
||||
|
||||
const auto writable = static_cast<size_t>(logical_output_len - output_offset);
|
||||
if (out->len > writable) {
|
||||
dovi_data_free(out);
|
||||
dovi_rpu_free(rpu);
|
||||
return DESTINATION_TOO_SMALL;
|
||||
}
|
||||
|
||||
@@ -124,9 +113,6 @@ extern "C" JNIEXPORT jint JNICALL Java_com_edde746_plezy_exoplayer_DoviBridge_na
|
||||
const bool write_failed = env->ExceptionCheck();
|
||||
const auto written = static_cast<jint>(out->len);
|
||||
|
||||
dovi_data_free(out);
|
||||
dovi_rpu_free(rpu);
|
||||
|
||||
return write_failed ? CONVERT_FAILED : written;
|
||||
#endif
|
||||
}
|
||||
|
||||
@@ -0,0 +1,48 @@
|
||||
/*
|
||||
* Copyright (C) 2026 Plezy contributors
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
#ifndef PLEZY_FFMPEG_AUDIO_BUFFER_H_
|
||||
#define PLEZY_FFMPEG_AUDIO_BUFFER_H_
|
||||
|
||||
#include <limits.h>
|
||||
#include <stdint.h>
|
||||
|
||||
namespace plezy {
|
||||
namespace ffmpeg {
|
||||
|
||||
inline bool CheckedAudioByteCount(int sample_count, int channel_count, int bytes_per_sample, int* byte_count) {
|
||||
if (sample_count < 0 || channel_count <= 0 || bytes_per_sample <= 0 || byte_count == nullptr) {
|
||||
return false;
|
||||
}
|
||||
const int64_t size = static_cast<int64_t>(sample_count) * channel_count * bytes_per_sample;
|
||||
if (size > INT_MAX) {
|
||||
return false;
|
||||
}
|
||||
*byte_count = static_cast<int>(size);
|
||||
return true;
|
||||
}
|
||||
|
||||
inline bool CheckedAddByteCount(int current_size, int additional_size, int* total_size) {
|
||||
if (current_size < 0 || additional_size < 0 || total_size == nullptr || current_size > INT_MAX - additional_size) {
|
||||
return false;
|
||||
}
|
||||
*total_size = current_size + additional_size;
|
||||
return true;
|
||||
}
|
||||
|
||||
} // namespace ffmpeg
|
||||
} // namespace plezy
|
||||
|
||||
#endif // PLEZY_FFMPEG_AUDIO_BUFFER_H_
|
||||
@@ -0,0 +1,508 @@
|
||||
/*
|
||||
* Copyright (C) 2016 The Android Open Source Project
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
#include <android/log.h>
|
||||
#include <jni.h>
|
||||
|
||||
#include "ffmpeg_audio_buffer.h"
|
||||
|
||||
extern "C" {
|
||||
#ifdef __cplusplus
|
||||
#define __STDC_CONSTANT_MACROS
|
||||
#ifdef _STDINT_H
|
||||
#undef _STDINT_H
|
||||
#endif
|
||||
#include <stdint.h>
|
||||
#endif
|
||||
#include <libavcodec/avcodec.h>
|
||||
#include <libavutil/channel_layout.h>
|
||||
#include <libavutil/error.h>
|
||||
#include <libavutil/opt.h>
|
||||
#include <libswresample/swresample.h>
|
||||
}
|
||||
|
||||
#define LOG_TAG "ffmpeg_jni"
|
||||
#define LOGE(...) ((void)__android_log_print(ANDROID_LOG_ERROR, LOG_TAG, __VA_ARGS__))
|
||||
#define LOGD(...) ((void)__android_log_print(ANDROID_LOG_DEBUG, LOG_TAG, __VA_ARGS__))
|
||||
|
||||
// clang-format 18 and 21 disagree on escaped-newline alignment in these JNI macros.
|
||||
// clang-format off
|
||||
#define LIBRARY_FUNC(RETURN_TYPE, NAME, ...) \
|
||||
extern "C" { \
|
||||
JNIEXPORT RETURN_TYPE \
|
||||
Java_androidx_media3_decoder_ffmpeg_FfmpegLibrary_##NAME(JNIEnv* env, jobject thiz, ##__VA_ARGS__); \
|
||||
} \
|
||||
JNIEXPORT RETURN_TYPE Java_androidx_media3_decoder_ffmpeg_FfmpegLibrary_##NAME( \
|
||||
JNIEnv* env, jobject thiz, ##__VA_ARGS__)
|
||||
|
||||
#define AUDIO_DECODER_FUNC(RETURN_TYPE, NAME, ...) \
|
||||
extern "C" { \
|
||||
JNIEXPORT RETURN_TYPE \
|
||||
Java_androidx_media3_decoder_ffmpeg_FfmpegAudioDecoder_##NAME(JNIEnv* env, jobject thiz, ##__VA_ARGS__); \
|
||||
} \
|
||||
JNIEXPORT RETURN_TYPE Java_androidx_media3_decoder_ffmpeg_FfmpegAudioDecoder_##NAME( \
|
||||
JNIEnv* env, jobject thiz, ##__VA_ARGS__)
|
||||
// clang-format on
|
||||
|
||||
#define ERROR_STRING_BUFFER_LENGTH 256
|
||||
|
||||
// Output format corresponding to AudioFormat.ENCODING_PCM_16BIT.
|
||||
static const AVSampleFormat OUTPUT_FORMAT_PCM_16BIT = AV_SAMPLE_FMT_S16;
|
||||
// Output format corresponding to AudioFormat.ENCODING_PCM_FLOAT.
|
||||
static const AVSampleFormat OUTPUT_FORMAT_PCM_FLOAT = AV_SAMPLE_FMT_FLT;
|
||||
|
||||
// LINT.IfChange
|
||||
static const int AUDIO_DECODER_ERROR_INVALID_DATA = -1;
|
||||
static const int AUDIO_DECODER_ERROR_OTHER = -2;
|
||||
// LINT.ThenChange(../java/androidx/media3/decoder/ffmpeg/FfmpegAudioDecoder.java)
|
||||
|
||||
namespace {
|
||||
struct ResampleState {
|
||||
SwrContext* context;
|
||||
AVChannelLayout input_channel_layout;
|
||||
AVSampleFormat input_sample_format;
|
||||
AVSampleFormat output_sample_format;
|
||||
int sample_rate;
|
||||
};
|
||||
} // namespace
|
||||
|
||||
static bool resampleConfigurationMatches(
|
||||
const ResampleState* state, const AVCodecContext* context, const AVFrame* frame);
|
||||
static int configureResampler(ResampleState* state, const AVCodecContext* context, const AVFrame* frame);
|
||||
static void releaseResampleState(ResampleState* state);
|
||||
|
||||
static jmethodID growOutputBufferMethod;
|
||||
|
||||
/**
|
||||
* Returns the AVCodec with the specified name, or NULL if it is not available.
|
||||
*/
|
||||
static const AVCodec* getCodecByName(JNIEnv* env, jstring codecName);
|
||||
|
||||
/**
|
||||
* Allocates and opens a new AVCodecContext for the specified codec, passing the
|
||||
* provided extraData as initialization data for the decoder if it is non-NULL.
|
||||
* Returns the created context.
|
||||
*/
|
||||
static AVCodecContext* createContext(
|
||||
JNIEnv* env, const AVCodec* codec, jbyteArray extraData, jboolean outputFloat, jint rawSampleRate,
|
||||
jint rawChannelCount);
|
||||
|
||||
namespace {
|
||||
struct GrowOutputBufferCallback {
|
||||
uint8_t* operator()(int requiredSize) const;
|
||||
|
||||
JNIEnv* env;
|
||||
jobject thiz;
|
||||
jobject decoderOutputBuffer;
|
||||
};
|
||||
} // namespace
|
||||
|
||||
/**
|
||||
* Decodes the packet into the output buffer, returning the number of bytes
|
||||
* written, or a negative AUDIO_DECODER_ERROR constant value in the case of an
|
||||
* error.
|
||||
*/
|
||||
static int decodePacket(
|
||||
AVCodecContext* context, AVPacket* packet, uint8_t* outputBuffer, int outputSize,
|
||||
GrowOutputBufferCallback growBuffer);
|
||||
|
||||
/**
|
||||
* Transforms ffmpeg AVERROR into a negative AUDIO_DECODER_ERROR constant value.
|
||||
*/
|
||||
static int transformError(int errorNumber);
|
||||
|
||||
/**
|
||||
* Outputs a log message describing the avcodec error number.
|
||||
*/
|
||||
static void logError(const char* functionName, int errorNumber);
|
||||
|
||||
/**
|
||||
* Releases the specified context.
|
||||
*/
|
||||
static void releaseContext(AVCodecContext* context);
|
||||
|
||||
JNIEXPORT jint JNI_OnLoad(JavaVM* vm, void* reserved) {
|
||||
JNIEnv* env;
|
||||
if (vm->GetEnv(reinterpret_cast<void**>(&env), JNI_VERSION_1_6) != JNI_OK) {
|
||||
LOGE("JNI_OnLoad: GetEnv failed");
|
||||
return -1;
|
||||
}
|
||||
jclass clazz = env->FindClass("androidx/media3/decoder/ffmpeg/FfmpegAudioDecoder");
|
||||
if (!clazz) {
|
||||
LOGE("JNI_OnLoad: FindClass failed");
|
||||
return -1;
|
||||
}
|
||||
growOutputBufferMethod = env->GetMethodID(
|
||||
clazz, "growOutputBuffer",
|
||||
"(Landroidx/media3/decoder/"
|
||||
"SimpleDecoderOutputBuffer;I)Ljava/nio/ByteBuffer;");
|
||||
if (!growOutputBufferMethod) {
|
||||
LOGE("JNI_OnLoad: GetMethodID failed");
|
||||
return -1;
|
||||
}
|
||||
return JNI_VERSION_1_6;
|
||||
}
|
||||
|
||||
LIBRARY_FUNC(jstring, ffmpegGetVersion) { return env->NewStringUTF(LIBAVCODEC_IDENT); }
|
||||
|
||||
LIBRARY_FUNC(jint, ffmpegGetInputBufferPaddingSize) { return (jint)AV_INPUT_BUFFER_PADDING_SIZE; }
|
||||
|
||||
LIBRARY_FUNC(jboolean, ffmpegHasDecoder, jstring codecName) { return getCodecByName(env, codecName) != nullptr; }
|
||||
|
||||
AUDIO_DECODER_FUNC(
|
||||
jlong, ffmpegInitialize, jstring codecName, jbyteArray extraData, jboolean outputFloat, jint rawSampleRate,
|
||||
jint rawChannelCount) {
|
||||
const AVCodec* codec = getCodecByName(env, codecName);
|
||||
if (!codec) {
|
||||
LOGE("Codec not found.");
|
||||
return 0L;
|
||||
}
|
||||
return (jlong)createContext(env, codec, extraData, outputFloat, rawSampleRate, rawChannelCount);
|
||||
}
|
||||
|
||||
AUDIO_DECODER_FUNC(
|
||||
jint, ffmpegDecode, jlong context, jobject inputData, jint inputSize, jobject decoderOutputBuffer,
|
||||
jobject outputData, jint outputSize) {
|
||||
if (!context) {
|
||||
LOGE("Context must be non-NULL.");
|
||||
return -1;
|
||||
}
|
||||
if (!inputData || !decoderOutputBuffer || !outputData) {
|
||||
LOGE("Input and output buffers must be non-NULL.");
|
||||
return -1;
|
||||
}
|
||||
if (inputSize < 0) {
|
||||
LOGE("Invalid input buffer size: %d.", inputSize);
|
||||
return -1;
|
||||
}
|
||||
if (outputSize < 0) {
|
||||
LOGE("Invalid output buffer length: %d", outputSize);
|
||||
return -1;
|
||||
}
|
||||
const jlong inputCapacity = env->GetDirectBufferCapacity(inputData);
|
||||
const jlong outputCapacity = env->GetDirectBufferCapacity(outputData);
|
||||
if (inputCapacity < inputSize || outputCapacity < outputSize) {
|
||||
LOGE("Buffer size exceeds direct buffer capacity.");
|
||||
return -1;
|
||||
}
|
||||
uint8_t* inputBuffer = (uint8_t*)env->GetDirectBufferAddress(inputData);
|
||||
uint8_t* outputBuffer = (uint8_t*)env->GetDirectBufferAddress(outputData);
|
||||
AVPacket* packet = av_packet_alloc();
|
||||
if (!packet) {
|
||||
LOGE("Failed to allocate packet.");
|
||||
return -1;
|
||||
}
|
||||
packet->data = inputBuffer;
|
||||
packet->size = inputSize;
|
||||
const int ret = decodePacket(
|
||||
(AVCodecContext*)context, packet, outputBuffer, outputSize,
|
||||
GrowOutputBufferCallback{env, thiz, decoderOutputBuffer});
|
||||
av_packet_free(&packet);
|
||||
return ret;
|
||||
}
|
||||
|
||||
uint8_t* GrowOutputBufferCallback::operator()(int requiredSize) const {
|
||||
jobject newOutputData = env->CallObjectMethod(thiz, growOutputBufferMethod, decoderOutputBuffer, requiredSize);
|
||||
if (env->ExceptionCheck()) {
|
||||
LOGE("growOutputBuffer() failed");
|
||||
env->ExceptionDescribe();
|
||||
return nullptr;
|
||||
}
|
||||
if (env->GetDirectBufferCapacity(newOutputData) < requiredSize) {
|
||||
LOGE("growOutputBuffer() returned an undersized or non-direct buffer.");
|
||||
return nullptr;
|
||||
}
|
||||
return static_cast<uint8_t*>(env->GetDirectBufferAddress(newOutputData));
|
||||
}
|
||||
|
||||
AUDIO_DECODER_FUNC(jint, ffmpegGetChannelCount, jlong context) {
|
||||
if (!context) {
|
||||
LOGE("Context must be non-NULL.");
|
||||
return -1;
|
||||
}
|
||||
return ((AVCodecContext*)context)->ch_layout.nb_channels;
|
||||
}
|
||||
|
||||
AUDIO_DECODER_FUNC(jint, ffmpegGetSampleRate, jlong context) {
|
||||
if (!context) {
|
||||
LOGE("Context must be non-NULL.");
|
||||
return -1;
|
||||
}
|
||||
return ((AVCodecContext*)context)->sample_rate;
|
||||
}
|
||||
|
||||
AUDIO_DECODER_FUNC(jlong, ffmpegReset, jlong jContext, jbyteArray extraData) {
|
||||
AVCodecContext* context = (AVCodecContext*)jContext;
|
||||
if (!context) {
|
||||
LOGE("Tried to reset without a context.");
|
||||
return 0L;
|
||||
}
|
||||
|
||||
AVCodecID codecId = context->codec_id;
|
||||
if (codecId == AV_CODEC_ID_TRUEHD) {
|
||||
jboolean outputFloat = (jboolean)(context->request_sample_fmt == OUTPUT_FORMAT_PCM_FLOAT);
|
||||
// Release and recreate the context if the codec is TrueHD.
|
||||
// TODO: Figure out why flushing doesn't work for this codec.
|
||||
releaseContext(context);
|
||||
const AVCodec* codec = avcodec_find_decoder(codecId);
|
||||
if (!codec) {
|
||||
LOGE("Unexpected error finding codec %d.", codecId);
|
||||
return 0L;
|
||||
}
|
||||
return (jlong)createContext(
|
||||
env, codec, extraData, outputFloat,
|
||||
/* rawSampleRate= */ -1,
|
||||
/* rawChannelCount= */ -1);
|
||||
}
|
||||
|
||||
avcodec_flush_buffers(context);
|
||||
return (jlong)context;
|
||||
}
|
||||
|
||||
AUDIO_DECODER_FUNC(void, ffmpegRelease, jlong context) {
|
||||
if (context) {
|
||||
releaseContext((AVCodecContext*)context);
|
||||
}
|
||||
}
|
||||
|
||||
static const AVCodec* getCodecByName(JNIEnv* env, jstring codecName) {
|
||||
if (!codecName) {
|
||||
return nullptr;
|
||||
}
|
||||
const char* codecNameChars = env->GetStringUTFChars(codecName, nullptr);
|
||||
const AVCodec* codec = avcodec_find_decoder_by_name(codecNameChars);
|
||||
env->ReleaseStringUTFChars(codecName, codecNameChars);
|
||||
return codec;
|
||||
}
|
||||
|
||||
static AVCodecContext* createContext(
|
||||
JNIEnv* env, const AVCodec* codec, jbyteArray extraData, jboolean outputFloat, jint rawSampleRate,
|
||||
jint rawChannelCount) {
|
||||
AVCodecContext* context = avcodec_alloc_context3(codec);
|
||||
if (!context) {
|
||||
LOGE("Failed to allocate context.");
|
||||
return nullptr;
|
||||
}
|
||||
context->request_sample_fmt = outputFloat ? OUTPUT_FORMAT_PCM_FLOAT : OUTPUT_FORMAT_PCM_16BIT;
|
||||
if (extraData) {
|
||||
jsize size = env->GetArrayLength(extraData);
|
||||
if (size > INT_MAX - AV_INPUT_BUFFER_PADDING_SIZE) {
|
||||
LOGE("Extradata is too large.");
|
||||
releaseContext(context);
|
||||
return nullptr;
|
||||
}
|
||||
context->extradata_size = size;
|
||||
context->extradata = (uint8_t*)av_mallocz(static_cast<size_t>(size) + AV_INPUT_BUFFER_PADDING_SIZE);
|
||||
if (!context->extradata) {
|
||||
LOGE("Failed to allocate extradata.");
|
||||
releaseContext(context);
|
||||
return nullptr;
|
||||
}
|
||||
env->GetByteArrayRegion(extraData, 0, size, (jbyte*)context->extradata);
|
||||
}
|
||||
if (context->codec_id == AV_CODEC_ID_PCM_MULAW || context->codec_id == AV_CODEC_ID_PCM_ALAW) {
|
||||
context->sample_rate = rawSampleRate;
|
||||
av_channel_layout_default(&context->ch_layout, rawChannelCount);
|
||||
}
|
||||
context->err_recognition = AV_EF_IGNORE_ERR;
|
||||
int result = avcodec_open2(context, codec, nullptr);
|
||||
if (result < 0) {
|
||||
logError("avcodec_open2", result);
|
||||
releaseContext(context);
|
||||
return nullptr;
|
||||
}
|
||||
return context;
|
||||
}
|
||||
|
||||
static bool resampleConfigurationMatches(
|
||||
const ResampleState* state, const AVCodecContext* context, const AVFrame* frame) {
|
||||
return state && state->context && state->input_sample_format == static_cast<AVSampleFormat>(frame->format) &&
|
||||
state->output_sample_format == context->request_sample_fmt && state->sample_rate == frame->sample_rate &&
|
||||
av_channel_layout_compare(&state->input_channel_layout, &frame->ch_layout) == 0;
|
||||
}
|
||||
|
||||
static int configureResampler(ResampleState* state, const AVCodecContext* context, const AVFrame* frame) {
|
||||
SwrContext* nextContext = nullptr;
|
||||
const AVSampleFormat inputSampleFormat = static_cast<AVSampleFormat>(frame->format);
|
||||
int result = swr_alloc_set_opts2(
|
||||
&nextContext, // ps
|
||||
&frame->ch_layout, // out_ch_layout
|
||||
context->request_sample_fmt, // out_sample_fmt
|
||||
frame->sample_rate, // out_sample_rate
|
||||
&frame->ch_layout, // in_ch_layout
|
||||
inputSampleFormat, // in_sample_fmt
|
||||
frame->sample_rate, // in_sample_rate
|
||||
0, // log_offset
|
||||
nullptr // log_ctx
|
||||
);
|
||||
if (result < 0) {
|
||||
logError("swr_alloc_set_opts2", result);
|
||||
return result;
|
||||
}
|
||||
result = swr_init(nextContext);
|
||||
if (result < 0) {
|
||||
logError("swr_init", result);
|
||||
swr_free(&nextContext);
|
||||
return result;
|
||||
}
|
||||
|
||||
AVChannelLayout nextInputChannelLayout = {};
|
||||
result = av_channel_layout_copy(&nextInputChannelLayout, &frame->ch_layout);
|
||||
if (result < 0) {
|
||||
logError("av_channel_layout_copy", result);
|
||||
swr_free(&nextContext);
|
||||
return result;
|
||||
}
|
||||
|
||||
swr_free(&state->context);
|
||||
av_channel_layout_uninit(&state->input_channel_layout);
|
||||
state->context = nextContext;
|
||||
state->input_channel_layout = nextInputChannelLayout;
|
||||
state->input_sample_format = inputSampleFormat;
|
||||
state->output_sample_format = context->request_sample_fmt;
|
||||
state->sample_rate = frame->sample_rate;
|
||||
return 0;
|
||||
}
|
||||
|
||||
static void releaseResampleState(ResampleState* state) {
|
||||
if (!state) {
|
||||
return;
|
||||
}
|
||||
swr_free(&state->context);
|
||||
av_channel_layout_uninit(&state->input_channel_layout);
|
||||
av_free(state);
|
||||
}
|
||||
|
||||
static int decodePacket(
|
||||
AVCodecContext* context, AVPacket* packet, uint8_t* outputBuffer, int outputSize,
|
||||
GrowOutputBufferCallback growBuffer) {
|
||||
int result = avcodec_send_packet(context, packet);
|
||||
if (result) {
|
||||
logError("avcodec_send_packet", result);
|
||||
return transformError(result);
|
||||
}
|
||||
|
||||
int outSize = 0;
|
||||
while (true) {
|
||||
AVFrame* frame = av_frame_alloc();
|
||||
if (!frame) {
|
||||
LOGE("Failed to allocate output frame.");
|
||||
return AUDIO_DECODER_ERROR_INVALID_DATA;
|
||||
}
|
||||
result = avcodec_receive_frame(context, frame);
|
||||
if (result) {
|
||||
av_frame_free(&frame);
|
||||
if (result == AVERROR(EAGAIN)) {
|
||||
break;
|
||||
}
|
||||
logError("avcodec_receive_frame", result);
|
||||
return transformError(result);
|
||||
}
|
||||
|
||||
const AVSampleFormat sampleFormat = static_cast<AVSampleFormat>(frame->format);
|
||||
const int channelCount = frame->ch_layout.nb_channels;
|
||||
const int sampleRate = frame->sample_rate;
|
||||
const int sampleCount = frame->nb_samples;
|
||||
if (sampleFormat == AV_SAMPLE_FMT_NONE || channelCount <= 0 || sampleRate <= 0 || sampleCount < 0 ||
|
||||
!frame->extended_data || !av_channel_layout_check(&frame->ch_layout)) {
|
||||
LOGE("Decoder returned an invalid audio frame.");
|
||||
av_frame_free(&frame);
|
||||
return AUDIO_DECODER_ERROR_INVALID_DATA;
|
||||
}
|
||||
|
||||
ResampleState* resampleState = static_cast<ResampleState*>(context->opaque);
|
||||
if (!resampleState) {
|
||||
resampleState = static_cast<ResampleState*>(av_mallocz(sizeof(ResampleState)));
|
||||
if (!resampleState) {
|
||||
LOGE("Failed to allocate resampler state.");
|
||||
av_frame_free(&frame);
|
||||
return AUDIO_DECODER_ERROR_OTHER;
|
||||
}
|
||||
context->opaque = resampleState;
|
||||
}
|
||||
if (!resampleConfigurationMatches(resampleState, context, frame)) {
|
||||
result = configureResampler(resampleState, context, frame);
|
||||
if (result < 0) {
|
||||
av_frame_free(&frame);
|
||||
return transformError(result);
|
||||
}
|
||||
}
|
||||
|
||||
const int bytesPerSample = av_get_bytes_per_sample(context->request_sample_fmt);
|
||||
const int outputSampleCapacity = swr_get_out_samples(resampleState->context, sampleCount);
|
||||
int outputByteCapacity;
|
||||
int requiredOutputSize;
|
||||
if (!plezy::ffmpeg::CheckedAudioByteCount(
|
||||
outputSampleCapacity, channelCount, bytesPerSample, &outputByteCapacity) ||
|
||||
!plezy::ffmpeg::CheckedAddByteCount(outSize, outputByteCapacity, &requiredOutputSize)) {
|
||||
LOGE("Decoded audio output size is invalid or too large.");
|
||||
av_frame_free(&frame);
|
||||
return AUDIO_DECODER_ERROR_INVALID_DATA;
|
||||
}
|
||||
if (requiredOutputSize > outputSize) {
|
||||
LOGD(
|
||||
"Output buffer size (%d) too small for output data (%d), "
|
||||
"reallocating buffer.",
|
||||
outputSize, requiredOutputSize);
|
||||
outputSize = requiredOutputSize;
|
||||
outputBuffer = growBuffer(outputSize);
|
||||
if (!outputBuffer) {
|
||||
LOGE("Failed to reallocate output buffer.");
|
||||
av_frame_free(&frame);
|
||||
return AUDIO_DECODER_ERROR_OTHER;
|
||||
}
|
||||
}
|
||||
|
||||
uint8_t* frameOutput = outputBuffer + outSize;
|
||||
uint8_t* outputPlanes[] = {frameOutput};
|
||||
result = swr_convert(
|
||||
resampleState->context, outputPlanes, outputSampleCapacity, (const uint8_t**)frame->extended_data, sampleCount);
|
||||
av_frame_free(&frame);
|
||||
if (result < 0) {
|
||||
logError("swr_convert", result);
|
||||
return AUDIO_DECODER_ERROR_INVALID_DATA;
|
||||
}
|
||||
|
||||
int writtenByteCount;
|
||||
int nextOutSize;
|
||||
if (!plezy::ffmpeg::CheckedAudioByteCount(result, channelCount, bytesPerSample, &writtenByteCount) ||
|
||||
writtenByteCount > outputByteCapacity ||
|
||||
!plezy::ffmpeg::CheckedAddByteCount(outSize, writtenByteCount, &nextOutSize)) {
|
||||
LOGE("Resampler returned an invalid output sample count.");
|
||||
return AUDIO_DECODER_ERROR_INVALID_DATA;
|
||||
}
|
||||
outSize = nextOutSize;
|
||||
}
|
||||
return outSize;
|
||||
}
|
||||
|
||||
static int transformError(int errorNumber) {
|
||||
return errorNumber == AVERROR_INVALIDDATA ? AUDIO_DECODER_ERROR_INVALID_DATA : AUDIO_DECODER_ERROR_OTHER;
|
||||
}
|
||||
|
||||
static void logError(const char* functionName, int errorNumber) {
|
||||
char buffer[ERROR_STRING_BUFFER_LENGTH];
|
||||
av_strerror(errorNumber, buffer, sizeof(buffer));
|
||||
LOGE("Error in %s: %s", functionName, buffer);
|
||||
}
|
||||
|
||||
static void releaseContext(AVCodecContext* context) {
|
||||
if (!context) {
|
||||
return;
|
||||
}
|
||||
releaseResampleState(static_cast<ResampleState*>(context->opaque));
|
||||
context->opaque = nullptr;
|
||||
avcodec_free_context(&context);
|
||||
}
|
||||
@@ -0,0 +1,294 @@
|
||||
/*
|
||||
* Copyright (C) 2016 The Android Open Source Project
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
package androidx.media3.decoder.ffmpeg;
|
||||
|
||||
import static com.google.common.base.Preconditions.checkNotNull;
|
||||
|
||||
import androidx.annotation.Nullable;
|
||||
import androidx.media3.common.C;
|
||||
import androidx.media3.common.Format;
|
||||
import androidx.media3.common.MimeTypes;
|
||||
import androidx.media3.common.util.ParsableByteArray;
|
||||
import androidx.media3.common.util.Util;
|
||||
import androidx.media3.decoder.DecoderInputBuffer;
|
||||
import androidx.media3.decoder.SimpleDecoder;
|
||||
import androidx.media3.decoder.SimpleDecoderOutputBuffer;
|
||||
import java.nio.ByteBuffer;
|
||||
import java.util.List;
|
||||
|
||||
/** Media3 audio decoder backed by the FFmpeg libraries packaged by libmpv. */
|
||||
final class FfmpegAudioDecoder
|
||||
extends SimpleDecoder<DecoderInputBuffer, SimpleDecoderOutputBuffer, FfmpegDecoderException> {
|
||||
|
||||
private static final int INITIAL_OUTPUT_BUFFER_SIZE_16BIT = 65535;
|
||||
private static final int INITIAL_OUTPUT_BUFFER_SIZE_32BIT = INITIAL_OUTPUT_BUFFER_SIZE_16BIT * 2;
|
||||
private static final int AUDIO_DECODER_ERROR_INVALID_DATA = -1;
|
||||
private static final int AUDIO_DECODER_ERROR_OTHER = -2;
|
||||
|
||||
private static final byte[] FLAC_STREAM_MARKER = {'f', 'L', 'a', 'C'};
|
||||
private static final int FLAC_METADATA_TYPE_STREAM_INFO = 0;
|
||||
private static final int FLAC_METADATA_BLOCK_HEADER_SIZE = 4;
|
||||
private static final int FLAC_STREAM_INFO_DATA_SIZE = 34;
|
||||
|
||||
private final String codecName;
|
||||
@Nullable private final byte[] extraData;
|
||||
private final @C.PcmEncoding int encoding;
|
||||
private int outputBufferSize;
|
||||
|
||||
private long nativeContext;
|
||||
private boolean hasOutputFormat;
|
||||
private volatile int channelCount;
|
||||
private volatile int sampleRate;
|
||||
|
||||
FfmpegAudioDecoder(
|
||||
Format format,
|
||||
int numInputBuffers,
|
||||
int numOutputBuffers,
|
||||
int initialInputBufferSize,
|
||||
boolean outputFloat)
|
||||
throws FfmpegDecoderException {
|
||||
super(new DecoderInputBuffer[numInputBuffers], new SimpleDecoderOutputBuffer[numOutputBuffers]);
|
||||
if (!FfmpegLibrary.isAvailable()) {
|
||||
throw new FfmpegDecoderException("Failed to load decoder native libraries.");
|
||||
}
|
||||
String mimeType = checkNotNull(format.sampleMimeType);
|
||||
codecName = checkNotNull(FfmpegLibrary.getCodecName(mimeType));
|
||||
extraData = getExtraData(mimeType, format.initializationData);
|
||||
encoding = outputFloat ? C.ENCODING_PCM_FLOAT : C.ENCODING_PCM_16BIT;
|
||||
outputBufferSize =
|
||||
outputFloat ? INITIAL_OUTPUT_BUFFER_SIZE_32BIT : INITIAL_OUTPUT_BUFFER_SIZE_16BIT;
|
||||
nativeContext =
|
||||
ffmpegInitialize(codecName, extraData, outputFloat, format.sampleRate, format.channelCount);
|
||||
if (nativeContext == 0) {
|
||||
throw new FfmpegDecoderException("Initialization failed.");
|
||||
}
|
||||
setInitialInputBufferSize(initialInputBufferSize);
|
||||
}
|
||||
|
||||
@Override
|
||||
public String getName() {
|
||||
return "ffmpeg" + FfmpegLibrary.getVersion() + "-" + codecName;
|
||||
}
|
||||
|
||||
@Override
|
||||
protected DecoderInputBuffer createInputBuffer() {
|
||||
return new DecoderInputBuffer(
|
||||
DecoderInputBuffer.BUFFER_REPLACEMENT_MODE_DIRECT,
|
||||
FfmpegLibrary.getInputBufferPaddingSize());
|
||||
}
|
||||
|
||||
@Override
|
||||
protected SimpleDecoderOutputBuffer createOutputBuffer() {
|
||||
return new SimpleDecoderOutputBuffer(this::releaseOutputBuffer);
|
||||
}
|
||||
|
||||
@Override
|
||||
protected FfmpegDecoderException createUnexpectedDecodeException(Throwable error) {
|
||||
return new FfmpegDecoderException("Unexpected decode error", error);
|
||||
}
|
||||
|
||||
@Override
|
||||
@Nullable
|
||||
protected FfmpegDecoderException decode(
|
||||
DecoderInputBuffer inputBuffer, SimpleDecoderOutputBuffer outputBuffer, boolean reset) {
|
||||
if (reset) {
|
||||
nativeContext = ffmpegReset(nativeContext, extraData);
|
||||
if (nativeContext == 0) {
|
||||
return new FfmpegDecoderException("Error resetting (see logcat).");
|
||||
}
|
||||
}
|
||||
ByteBuffer inputData = Util.castNonNull(inputBuffer.data);
|
||||
int inputSize = inputData.limit();
|
||||
ByteBuffer outputData = outputBuffer.init(inputBuffer.timeUs, outputBufferSize);
|
||||
int result =
|
||||
ffmpegDecode(
|
||||
nativeContext, inputData, inputSize, outputBuffer, outputData, outputBufferSize);
|
||||
if (result == AUDIO_DECODER_ERROR_OTHER) {
|
||||
return new FfmpegDecoderException("Error decoding (see logcat).");
|
||||
} else if (result == AUDIO_DECODER_ERROR_INVALID_DATA) {
|
||||
outputBuffer.shouldBeSkipped = true;
|
||||
return null;
|
||||
} else if (result == 0) {
|
||||
outputBuffer.shouldBeSkipped = true;
|
||||
return null;
|
||||
}
|
||||
if (!hasOutputFormat) {
|
||||
channelCount = ffmpegGetChannelCount(nativeContext);
|
||||
sampleRate = ffmpegGetSampleRate(nativeContext);
|
||||
if (sampleRate == 0 && "alac".equals(codecName)) {
|
||||
checkNotNull(extraData);
|
||||
ParsableByteArray parsableExtraData = new ParsableByteArray(extraData);
|
||||
parsableExtraData.setPosition(extraData.length - 4);
|
||||
sampleRate = parsableExtraData.readUnsignedIntToInt();
|
||||
}
|
||||
hasOutputFormat = true;
|
||||
}
|
||||
outputData = checkNotNull(outputBuffer.data);
|
||||
outputData.position(0);
|
||||
outputData.limit(result);
|
||||
return null;
|
||||
}
|
||||
|
||||
@SuppressWarnings("unused")
|
||||
private ByteBuffer growOutputBuffer(SimpleDecoderOutputBuffer outputBuffer, int requiredSize) {
|
||||
outputBufferSize = requiredSize;
|
||||
return outputBuffer.grow(requiredSize);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void release() {
|
||||
super.release();
|
||||
ffmpegRelease(nativeContext);
|
||||
nativeContext = 0;
|
||||
}
|
||||
|
||||
int getChannelCount() {
|
||||
return channelCount;
|
||||
}
|
||||
|
||||
int getSampleRate() {
|
||||
return sampleRate;
|
||||
}
|
||||
|
||||
@C.PcmEncoding
|
||||
int getEncoding() {
|
||||
return encoding;
|
||||
}
|
||||
|
||||
@Nullable
|
||||
private static byte[] getExtraData(String mimeType, List<byte[]> initializationData) {
|
||||
switch (mimeType) {
|
||||
case MimeTypes.AUDIO_AAC:
|
||||
case MimeTypes.AUDIO_OPUS:
|
||||
return initializationData.get(0);
|
||||
case MimeTypes.AUDIO_ALAC:
|
||||
return getAlacExtraData(initializationData);
|
||||
case MimeTypes.AUDIO_VORBIS:
|
||||
return getVorbisExtraData(initializationData);
|
||||
case MimeTypes.AUDIO_FLAC:
|
||||
return getFlacExtraData(initializationData);
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
private static byte[] getAlacExtraData(List<byte[]> initializationData) {
|
||||
byte[] magicCookie = initializationData.get(0);
|
||||
int alacAtomLength = 12 + magicCookie.length;
|
||||
ByteBuffer alacAtom = ByteBuffer.allocate(alacAtomLength);
|
||||
alacAtom.putInt(alacAtomLength);
|
||||
alacAtom.putInt(0x616c6163);
|
||||
alacAtom.putInt(0);
|
||||
alacAtom.put(magicCookie, 0, magicCookie.length);
|
||||
return alacAtom.array();
|
||||
}
|
||||
|
||||
private static byte[] getVorbisExtraData(List<byte[]> initializationData) {
|
||||
byte[] header0 = initializationData.get(0);
|
||||
byte[] header1 = initializationData.get(1);
|
||||
byte[] extraData = new byte[header0.length + header1.length + 6];
|
||||
extraData[0] = (byte) (header0.length >> 8);
|
||||
extraData[1] = (byte) (header0.length & 0xFF);
|
||||
System.arraycopy(header0, 0, extraData, 2, header0.length);
|
||||
extraData[header0.length + 2] = 0;
|
||||
extraData[header0.length + 3] = 0;
|
||||
extraData[header0.length + 4] = (byte) (header1.length >> 8);
|
||||
extraData[header0.length + 5] = (byte) (header1.length & 0xFF);
|
||||
System.arraycopy(header1, 0, extraData, header0.length + 6, header1.length);
|
||||
return extraData;
|
||||
}
|
||||
|
||||
@Nullable
|
||||
private static byte[] getFlacExtraData(List<byte[]> initializationData) {
|
||||
for (int i = 0; i < initializationData.size(); i++) {
|
||||
@Nullable byte[] streamInfo = extractFlacStreamInfo(initializationData.get(i));
|
||||
if (streamInfo != null) {
|
||||
return streamInfo;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
@Nullable
|
||||
private static byte[] extractFlacStreamInfo(byte[] data) {
|
||||
int offset = 0;
|
||||
if (arrayStartsWith(data, FLAC_STREAM_MARKER)) {
|
||||
offset = FLAC_STREAM_MARKER.length;
|
||||
}
|
||||
|
||||
if (data.length - offset == FLAC_STREAM_INFO_DATA_SIZE) {
|
||||
byte[] streamInfo = new byte[FLAC_STREAM_INFO_DATA_SIZE];
|
||||
System.arraycopy(data, offset, streamInfo, 0, FLAC_STREAM_INFO_DATA_SIZE);
|
||||
return streamInfo;
|
||||
}
|
||||
|
||||
if (data.length >= offset + FLAC_METADATA_BLOCK_HEADER_SIZE) {
|
||||
int type = data[offset] & 0x7F;
|
||||
int length =
|
||||
((data[offset + 1] & 0xFF) << 16)
|
||||
| ((data[offset + 2] & 0xFF) << 8)
|
||||
| (data[offset + 3] & 0xFF);
|
||||
if (type == FLAC_METADATA_TYPE_STREAM_INFO
|
||||
&& length == FLAC_STREAM_INFO_DATA_SIZE
|
||||
&& data.length >= offset + FLAC_METADATA_BLOCK_HEADER_SIZE + FLAC_STREAM_INFO_DATA_SIZE) {
|
||||
byte[] streamInfo = new byte[FLAC_STREAM_INFO_DATA_SIZE];
|
||||
System.arraycopy(
|
||||
data,
|
||||
offset + FLAC_METADATA_BLOCK_HEADER_SIZE,
|
||||
streamInfo,
|
||||
0,
|
||||
FLAC_STREAM_INFO_DATA_SIZE);
|
||||
return streamInfo;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
private static boolean arrayStartsWith(byte[] data, byte[] prefix) {
|
||||
if (data.length < prefix.length) {
|
||||
return false;
|
||||
}
|
||||
for (int i = 0; i < prefix.length; i++) {
|
||||
if (data[i] != prefix[i]) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
private native long ffmpegInitialize(
|
||||
String codecName,
|
||||
@Nullable byte[] extraData,
|
||||
boolean outputFloat,
|
||||
int rawSampleRate,
|
||||
int rawChannelCount);
|
||||
|
||||
private native int ffmpegDecode(
|
||||
long context,
|
||||
ByteBuffer inputData,
|
||||
int inputSize,
|
||||
SimpleDecoderOutputBuffer decoderOutputBuffer,
|
||||
ByteBuffer outputData,
|
||||
int outputSize);
|
||||
|
||||
private native int ffmpegGetChannelCount(long context);
|
||||
|
||||
private native int ffmpegGetSampleRate(long context);
|
||||
|
||||
private native long ffmpegReset(long context, @Nullable byte[] extraData);
|
||||
|
||||
private native void ffmpegRelease(long context);
|
||||
}
|
||||
@@ -0,0 +1,126 @@
|
||||
/*
|
||||
* Copyright (C) 2016 The Android Open Source Project
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
package androidx.media3.decoder.ffmpeg;
|
||||
|
||||
import static androidx.media3.exoplayer.audio.AudioSink.SINK_FORMAT_SUPPORTED_DIRECTLY;
|
||||
import static androidx.media3.exoplayer.audio.AudioSink.SINK_FORMAT_SUPPORTED_WITH_TRANSCODING;
|
||||
import static androidx.media3.exoplayer.audio.AudioSink.SINK_FORMAT_UNSUPPORTED;
|
||||
import static com.google.common.base.Preconditions.checkNotNull;
|
||||
|
||||
import android.os.Handler;
|
||||
import androidx.annotation.Nullable;
|
||||
import androidx.media3.common.C;
|
||||
import androidx.media3.common.Format;
|
||||
import androidx.media3.common.MimeTypes;
|
||||
import androidx.media3.common.util.TraceUtil;
|
||||
import androidx.media3.common.util.UnstableApi;
|
||||
import androidx.media3.common.util.Util;
|
||||
import androidx.media3.decoder.CryptoConfig;
|
||||
import androidx.media3.exoplayer.audio.AudioRendererEventListener;
|
||||
import androidx.media3.exoplayer.audio.AudioSink;
|
||||
import androidx.media3.exoplayer.audio.AudioSink.SinkFormatSupport;
|
||||
import androidx.media3.exoplayer.audio.DecoderAudioRenderer;
|
||||
|
||||
/** Decodes and renders audio using the FFmpeg libraries shared with libmpv. */
|
||||
@UnstableApi
|
||||
public final class FfmpegAudioRenderer extends DecoderAudioRenderer<FfmpegAudioDecoder> {
|
||||
|
||||
private static final String TAG = "FfmpegAudioRenderer";
|
||||
private static final int NUM_BUFFERS = 16;
|
||||
private static final int DEFAULT_INPUT_BUFFER_SIZE = 960 * 6;
|
||||
|
||||
public FfmpegAudioRenderer(
|
||||
@Nullable Handler eventHandler,
|
||||
@Nullable AudioRendererEventListener eventListener,
|
||||
AudioSink audioSink) {
|
||||
super(eventHandler, eventListener, audioSink);
|
||||
}
|
||||
|
||||
@Override
|
||||
public String getName() {
|
||||
return TAG;
|
||||
}
|
||||
|
||||
@Override
|
||||
protected @C.FormatSupport int supportsFormatInternal(Format format) {
|
||||
String mimeType = checkNotNull(format.sampleMimeType);
|
||||
if (!FfmpegLibrary.isAvailable() || !MimeTypes.isAudio(mimeType)) {
|
||||
return C.FORMAT_UNSUPPORTED_TYPE;
|
||||
} else if (!FfmpegLibrary.supportsFormat(mimeType)
|
||||
|| (!sinkSupportsFormat(format, C.ENCODING_PCM_16BIT)
|
||||
&& !sinkSupportsFormat(format, C.ENCODING_PCM_FLOAT))) {
|
||||
return C.FORMAT_UNSUPPORTED_SUBTYPE;
|
||||
} else if (format.cryptoType != C.CRYPTO_TYPE_NONE) {
|
||||
return C.FORMAT_UNSUPPORTED_DRM;
|
||||
} else {
|
||||
return C.FORMAT_HANDLED;
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public @AdaptiveSupport int supportsMixedMimeTypeAdaptation() {
|
||||
return ADAPTIVE_NOT_SEAMLESS;
|
||||
}
|
||||
|
||||
@Override
|
||||
protected FfmpegAudioDecoder createDecoder(Format format, @Nullable CryptoConfig cryptoConfig)
|
||||
throws FfmpegDecoderException {
|
||||
TraceUtil.beginSection("createFfmpegAudioDecoder");
|
||||
int initialInputBufferSize =
|
||||
format.maxInputSize != Format.NO_VALUE ? format.maxInputSize : DEFAULT_INPUT_BUFFER_SIZE;
|
||||
FfmpegAudioDecoder decoder =
|
||||
new FfmpegAudioDecoder(
|
||||
format, NUM_BUFFERS, NUM_BUFFERS, initialInputBufferSize, shouldOutputFloat(format));
|
||||
TraceUtil.endSection();
|
||||
return decoder;
|
||||
}
|
||||
|
||||
@Override
|
||||
protected Format getOutputFormat(FfmpegAudioDecoder decoder) {
|
||||
checkNotNull(decoder);
|
||||
return new Format.Builder()
|
||||
.setSampleMimeType(MimeTypes.AUDIO_RAW)
|
||||
.setChannelCount(decoder.getChannelCount())
|
||||
.setSampleRate(decoder.getSampleRate())
|
||||
.setPcmEncoding(decoder.getEncoding())
|
||||
.build();
|
||||
}
|
||||
|
||||
private boolean sinkSupportsFormat(Format inputFormat, @C.PcmEncoding int pcmEncoding) {
|
||||
return sinkSupportsFormat(
|
||||
Util.getPcmFormat(pcmEncoding, inputFormat.channelCount, inputFormat.sampleRate));
|
||||
}
|
||||
|
||||
private boolean shouldOutputFloat(Format inputFormat) {
|
||||
if (!sinkSupportsFormat(inputFormat, C.ENCODING_PCM_16BIT)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
@SinkFormatSupport
|
||||
int formatSupport =
|
||||
getSinkFormatSupport(
|
||||
Util.getPcmFormat(
|
||||
C.ENCODING_PCM_FLOAT, inputFormat.channelCount, inputFormat.sampleRate));
|
||||
switch (formatSupport) {
|
||||
case SINK_FORMAT_SUPPORTED_DIRECTLY:
|
||||
return !MimeTypes.AUDIO_AC3.equals(inputFormat.sampleMimeType);
|
||||
case SINK_FORMAT_UNSUPPORTED:
|
||||
case SINK_FORMAT_SUPPORTED_WITH_TRANSCODING:
|
||||
default:
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
/*
|
||||
* Copyright (C) 2016 The Android Open Source Project
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
package androidx.media3.decoder.ffmpeg;
|
||||
|
||||
import androidx.media3.decoder.DecoderException;
|
||||
|
||||
/** Thrown when an FFmpeg decoder error occurs. */
|
||||
final class FfmpegDecoderException extends DecoderException {
|
||||
|
||||
FfmpegDecoderException(String message) {
|
||||
super(message);
|
||||
}
|
||||
|
||||
FfmpegDecoderException(String message, Throwable cause) {
|
||||
super(message, cause);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,126 @@
|
||||
/*
|
||||
* Copyright (C) 2016 The Android Open Source Project
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
package androidx.media3.decoder.ffmpeg;
|
||||
|
||||
import androidx.annotation.Nullable;
|
||||
import androidx.media3.common.C;
|
||||
import androidx.media3.common.MimeTypes;
|
||||
import androidx.media3.common.util.LibraryLoader;
|
||||
import androidx.media3.common.util.Log;
|
||||
import androidx.media3.common.util.UnstableApi;
|
||||
|
||||
/** Configures and queries the FFmpeg libraries shared with libmpv. */
|
||||
@UnstableApi
|
||||
final class FfmpegLibrary {
|
||||
|
||||
private static final String TAG = "FfmpegLibrary";
|
||||
private static final LibraryLoader LOADER =
|
||||
new LibraryLoader("ffmpegJNI") {
|
||||
@Override
|
||||
protected void loadLibrary(String name) {
|
||||
System.loadLibrary(name);
|
||||
}
|
||||
};
|
||||
|
||||
@Nullable private static String version;
|
||||
private static int inputBufferPaddingSize = C.LENGTH_UNSET;
|
||||
|
||||
private FfmpegLibrary() {}
|
||||
|
||||
|
||||
/** Returns whether the JNI adapter and libmpv's FFmpeg libraries can be loaded. */
|
||||
static boolean isAvailable() {
|
||||
return LOADER.isAvailable();
|
||||
}
|
||||
|
||||
/** Returns the linked FFmpeg version. The native libraries must be available. */
|
||||
static String getVersion() {
|
||||
String cachedVersion = version;
|
||||
if (cachedVersion == null) {
|
||||
cachedVersion = ffmpegGetVersion();
|
||||
version = cachedVersion;
|
||||
}
|
||||
return cachedVersion;
|
||||
}
|
||||
|
||||
/** Returns the required FFmpeg input-buffer padding. The native libraries must be available. */
|
||||
static int getInputBufferPaddingSize() {
|
||||
if (inputBufferPaddingSize == C.LENGTH_UNSET) {
|
||||
inputBufferPaddingSize = ffmpegGetInputBufferPaddingSize();
|
||||
}
|
||||
return inputBufferPaddingSize;
|
||||
}
|
||||
|
||||
/** Returns whether the linked FFmpeg build supports the MIME type. */
|
||||
static boolean supportsFormat(String mimeType) {
|
||||
@Nullable String codecName = getCodecName(mimeType);
|
||||
if (codecName == null) {
|
||||
return false;
|
||||
}
|
||||
if (!ffmpegHasDecoder(codecName)) {
|
||||
Log.w(TAG, "No " + codecName + " decoder available in libmpv's FFmpeg build.");
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
/** Returns the FFmpeg decoder name for a supported MIME type. */
|
||||
@Nullable
|
||||
static String getCodecName(String mimeType) {
|
||||
switch (mimeType) {
|
||||
case MimeTypes.AUDIO_AAC:
|
||||
return "aac";
|
||||
case MimeTypes.AUDIO_MPEG:
|
||||
case MimeTypes.AUDIO_MPEG_L1:
|
||||
case MimeTypes.AUDIO_MPEG_L2:
|
||||
return "mp3";
|
||||
case MimeTypes.AUDIO_AC3:
|
||||
return "ac3";
|
||||
case MimeTypes.AUDIO_E_AC3:
|
||||
case MimeTypes.AUDIO_E_AC3_JOC:
|
||||
return "eac3";
|
||||
case MimeTypes.AUDIO_TRUEHD:
|
||||
return "truehd";
|
||||
case MimeTypes.AUDIO_DTS:
|
||||
case MimeTypes.AUDIO_DTS_HD:
|
||||
return "dca";
|
||||
case MimeTypes.AUDIO_VORBIS:
|
||||
return "vorbis";
|
||||
case MimeTypes.AUDIO_OPUS:
|
||||
return "opus";
|
||||
case MimeTypes.AUDIO_AMR_NB:
|
||||
return "amrnb";
|
||||
case MimeTypes.AUDIO_AMR_WB:
|
||||
return "amrwb";
|
||||
case MimeTypes.AUDIO_FLAC:
|
||||
return "flac";
|
||||
case MimeTypes.AUDIO_ALAC:
|
||||
return "alac";
|
||||
case MimeTypes.AUDIO_MLAW:
|
||||
return "pcm_mulaw";
|
||||
case MimeTypes.AUDIO_ALAW:
|
||||
return "pcm_alaw";
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
private static native String ffmpegGetVersion();
|
||||
|
||||
private static native int ffmpegGetInputBufferPaddingSize();
|
||||
|
||||
private static native boolean ffmpegHasDecoder(String codecName);
|
||||
}
|
||||
@@ -0,0 +1,338 @@
|
||||
package com.edde746.plezy
|
||||
|
||||
import android.content.Context
|
||||
import android.content.SharedPreferences
|
||||
import java.security.MessageDigest
|
||||
import java.util.concurrent.Executors
|
||||
|
||||
internal data class HistoricalExitRecord(
|
||||
val reason: Int,
|
||||
val status: Int,
|
||||
val importance: Int,
|
||||
val timestamp: Long
|
||||
)
|
||||
|
||||
internal object AndroidStartupPhases {
|
||||
const val NATIVE_ON_CREATE = "native_on_create"
|
||||
private val allowed = setOf(
|
||||
NATIVE_ON_CREATE,
|
||||
"dart_main",
|
||||
"runApp",
|
||||
"first_frame",
|
||||
"database_open_started",
|
||||
"database_ready",
|
||||
"credentials_loaded",
|
||||
"binding_started",
|
||||
"binding_settled",
|
||||
"main_screen"
|
||||
)
|
||||
|
||||
fun sanitize(raw: String?): String? = raw?.takeIf(allowed::contains)
|
||||
}
|
||||
|
||||
internal class StartupPhaseStore(
|
||||
readPhase: () -> String?,
|
||||
private val persistPhase: (String) -> Boolean
|
||||
) {
|
||||
val previousPhase: String? = AndroidStartupPhases.sanitize(readPhase())
|
||||
|
||||
@Synchronized
|
||||
fun mark(raw: String?): Boolean {
|
||||
val phase = AndroidStartupPhases.sanitize(raw) ?: return false
|
||||
return persistPhase(phase)
|
||||
}
|
||||
}
|
||||
|
||||
internal data class RuntimeDiagnosticSnapshot(
|
||||
val codecContext: String? = null,
|
||||
val channelCount: Int? = null,
|
||||
val sampleRate: Int? = null,
|
||||
val selectedDecoder: String? = null,
|
||||
val passthroughEnabled: Boolean? = null,
|
||||
val downmixEnabled: Boolean? = null,
|
||||
val normalizationEnabled: Boolean? = null,
|
||||
val uiState: String? = null
|
||||
)
|
||||
|
||||
internal object AndroidRuntimeDiagnostics {
|
||||
const val UI_STARTUP = "startup"
|
||||
const val UI_AUTHENTICATION = "authentication"
|
||||
const val UI_MAIN_SCREEN = "main_screen"
|
||||
const val UI_PLAYER = "player"
|
||||
const val UI_PLAYER_DISPOSED = "player_disposed"
|
||||
|
||||
private const val PREFERENCES_NAME = "plezy_runtime_diagnostics"
|
||||
private const val KEY_CODEC_CONTEXT = "codec_context"
|
||||
private const val KEY_CHANNEL_COUNT = "channel_count"
|
||||
private const val KEY_SAMPLE_RATE = "sample_rate"
|
||||
private const val KEY_SELECTED_DECODER = "selected_decoder"
|
||||
private const val KEY_PASSTHROUGH_ENABLED = "passthrough_enabled"
|
||||
private const val KEY_DOWNMIX_ENABLED = "downmix_enabled"
|
||||
private const val KEY_NORMALIZATION_ENABLED = "normalization_enabled"
|
||||
private const val KEY_UI_STATE = "ui_state"
|
||||
private val allowedCodecContexts = setOf(
|
||||
"audio:aac",
|
||||
"audio:ac3",
|
||||
"audio:eac3",
|
||||
"audio:dts",
|
||||
"audio:truehd",
|
||||
"audio:flac",
|
||||
"audio:pcm",
|
||||
"audio:other",
|
||||
"video:dolby_vision",
|
||||
"video:hevc",
|
||||
"video:avc",
|
||||
"video:other"
|
||||
)
|
||||
private val allowedUiStates = setOf(
|
||||
UI_STARTUP,
|
||||
UI_AUTHENTICATION,
|
||||
UI_MAIN_SCREEN,
|
||||
UI_PLAYER,
|
||||
UI_PLAYER_DISPOSED
|
||||
)
|
||||
private val decoderNamePattern = Regex("[A-Za-z0-9_.:-]{1,96}")
|
||||
private val executor by lazy {
|
||||
Executors.newSingleThreadExecutor { task ->
|
||||
Thread(task, "plezy-runtime-diagnostics").apply { isDaemon = true }
|
||||
}
|
||||
}
|
||||
|
||||
fun codecContextForMime(mimeType: String?): String? {
|
||||
val normalized = mimeType?.lowercase() ?: return null
|
||||
return when (normalized) {
|
||||
"audio/mp4a-latm" -> "audio:aac"
|
||||
"audio/ac3" -> "audio:ac3"
|
||||
"audio/eac3", "audio/eac3-joc" -> "audio:eac3"
|
||||
"audio/vnd.dts", "audio/vnd.dts.hd" -> "audio:dts"
|
||||
"audio/true-hd" -> "audio:truehd"
|
||||
"audio/flac" -> "audio:flac"
|
||||
"audio/raw" -> "audio:pcm"
|
||||
"video/dolby-vision" -> "video:dolby_vision"
|
||||
"video/hevc" -> "video:hevc"
|
||||
"video/avc" -> "video:avc"
|
||||
else -> when {
|
||||
normalized.startsWith("audio/") -> "audio:other"
|
||||
normalized.startsWith("video/") -> "video:other"
|
||||
else -> null
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun sanitizeDecoderName(raw: String?): String? {
|
||||
if (raw == null) return null
|
||||
return raw.takeIf(decoderNamePattern::matches) ?: "unknown"
|
||||
}
|
||||
|
||||
fun sanitizeUiState(raw: String?): String? = raw?.takeIf(allowedUiStates::contains)
|
||||
fun sanitize(snapshot: RuntimeDiagnosticSnapshot): RuntimeDiagnosticSnapshot = RuntimeDiagnosticSnapshot(
|
||||
codecContext = snapshot.codecContext?.takeIf(allowedCodecContexts::contains),
|
||||
channelCount = snapshot.channelCount?.takeIf { it in 1..32 },
|
||||
sampleRate = snapshot.sampleRate?.takeIf { it in 1..768_000 },
|
||||
selectedDecoder = sanitizeDecoderName(snapshot.selectedDecoder),
|
||||
passthroughEnabled = snapshot.passthroughEnabled,
|
||||
downmixEnabled = snapshot.downmixEnabled,
|
||||
normalizationEnabled = snapshot.normalizationEnabled,
|
||||
uiState = sanitizeUiState(snapshot.uiState)
|
||||
)
|
||||
|
||||
fun update(
|
||||
context: Context,
|
||||
codecContext: String? = null,
|
||||
channelCount: Int? = null,
|
||||
sampleRate: Int? = null,
|
||||
selectedDecoder: String? = null,
|
||||
passthroughEnabled: Boolean? = null,
|
||||
downmixEnabled: Boolean? = null,
|
||||
normalizationEnabled: Boolean? = null,
|
||||
uiState: String? = null
|
||||
) {
|
||||
val safeCodecContext = codecContext?.takeIf(allowedCodecContexts::contains)
|
||||
val safeChannelCount = channelCount?.takeIf { it in 1..32 }
|
||||
val safeSampleRate = sampleRate?.takeIf { it in 1..768_000 }
|
||||
val safeDecoder = sanitizeDecoderName(selectedDecoder)
|
||||
val safeUiState = sanitizeUiState(uiState)
|
||||
val applicationContext = context.applicationContext
|
||||
executor.execute {
|
||||
runCatching {
|
||||
applicationContext.getSharedPreferences(PREFERENCES_NAME, Context.MODE_PRIVATE).edit().apply {
|
||||
safeCodecContext?.let { putString(KEY_CODEC_CONTEXT, it) }
|
||||
safeChannelCount?.let { putInt(KEY_CHANNEL_COUNT, it) }
|
||||
safeSampleRate?.let { putInt(KEY_SAMPLE_RATE, it) }
|
||||
safeDecoder?.let { putString(KEY_SELECTED_DECODER, it) }
|
||||
passthroughEnabled?.let { putBoolean(KEY_PASSTHROUGH_ENABLED, it) }
|
||||
downmixEnabled?.let { putBoolean(KEY_DOWNMIX_ENABLED, it) }
|
||||
normalizationEnabled?.let { putBoolean(KEY_NORMALIZATION_ENABLED, it) }
|
||||
safeUiState?.let { putString(KEY_UI_STATE, it) }
|
||||
}.commit()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun clearPlayback(context: Context) {
|
||||
val applicationContext = context.applicationContext
|
||||
executor.execute {
|
||||
runCatching {
|
||||
applicationContext.getSharedPreferences(PREFERENCES_NAME, Context.MODE_PRIVATE).edit()
|
||||
.remove(KEY_CODEC_CONTEXT)
|
||||
.remove(KEY_CHANNEL_COUNT)
|
||||
.remove(KEY_SAMPLE_RATE)
|
||||
.remove(KEY_SELECTED_DECODER)
|
||||
.remove(KEY_PASSTHROUGH_ENABLED)
|
||||
.remove(KEY_DOWNMIX_ENABLED)
|
||||
.remove(KEY_NORMALIZATION_ENABLED)
|
||||
.putString(KEY_UI_STATE, UI_PLAYER_DISPOSED)
|
||||
.commit()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun read(context: Context): RuntimeDiagnosticSnapshot {
|
||||
val preferences = context.applicationContext.getSharedPreferences(PREFERENCES_NAME, Context.MODE_PRIVATE)
|
||||
return RuntimeDiagnosticSnapshot(
|
||||
codecContext = runCatching { preferences.getString(KEY_CODEC_CONTEXT, null) }
|
||||
.getOrNull()
|
||||
?.takeIf(allowedCodecContexts::contains),
|
||||
channelCount = runCatching { preferences.getInt(KEY_CHANNEL_COUNT, -1) }.getOrNull()?.takeIf { it in 1..32 },
|
||||
sampleRate = runCatching { preferences.getInt(KEY_SAMPLE_RATE, -1) }.getOrNull()?.takeIf { it in 1..768_000 },
|
||||
selectedDecoder = sanitizeDecoderName(runCatching { preferences.getString(KEY_SELECTED_DECODER, null) }.getOrNull()),
|
||||
passthroughEnabled = readBoolean(preferences, KEY_PASSTHROUGH_ENABLED),
|
||||
downmixEnabled = readBoolean(preferences, KEY_DOWNMIX_ENABLED),
|
||||
normalizationEnabled = readBoolean(preferences, KEY_NORMALIZATION_ENABLED),
|
||||
uiState = sanitizeUiState(runCatching { preferences.getString(KEY_UI_STATE, null) }.getOrNull())
|
||||
)
|
||||
}
|
||||
|
||||
private fun readBoolean(preferences: SharedPreferences, key: String): Boolean? {
|
||||
if (!preferences.contains(key)) return null
|
||||
return runCatching { preferences.getBoolean(key, false) }.getOrNull()
|
||||
}
|
||||
}
|
||||
|
||||
internal data class PreviousExitReport(
|
||||
val reason: String,
|
||||
val status: Int,
|
||||
val importance: Int,
|
||||
val timestamp: Long,
|
||||
val deviceModel: String,
|
||||
val apiLevel: Int,
|
||||
val abi: String,
|
||||
val lowRam: Boolean,
|
||||
val startupPhase: String?,
|
||||
val runtime: RuntimeDiagnosticSnapshot,
|
||||
val dedupeKey: String
|
||||
) {
|
||||
fun toMap(): Map<String, Any> = buildMap {
|
||||
put("reason", reason)
|
||||
put("status", status)
|
||||
put("importance", importance)
|
||||
put("timestamp", timestamp)
|
||||
put("deviceModel", deviceModel)
|
||||
put("apiLevel", apiLevel)
|
||||
put("abi", abi)
|
||||
put("lowRam", lowRam)
|
||||
startupPhase?.let { put("startupPhase", it) }
|
||||
runtime.codecContext?.let { put("codecContext", it) }
|
||||
runtime.channelCount?.let { put("channelCount", it) }
|
||||
runtime.sampleRate?.let { put("sampleRate", it) }
|
||||
runtime.selectedDecoder?.let { put("selectedDecoder", it) }
|
||||
runtime.passthroughEnabled?.let { put("passthroughEnabled", it) }
|
||||
runtime.downmixEnabled?.let { put("downmixEnabled", it) }
|
||||
runtime.normalizationEnabled?.let { put("normalizationEnabled", it) }
|
||||
runtime.uiState?.let { put("uiState", it) }
|
||||
}
|
||||
}
|
||||
|
||||
internal object AndroidExitReportMapper {
|
||||
private const val REASON_LOW_MEMORY = 3
|
||||
private const val REASON_CRASH = 4
|
||||
private const val REASON_CRASH_NATIVE = 5
|
||||
private const val REASON_ANR = 6
|
||||
private const val REASON_USER_REQUESTED = 10
|
||||
private const val REASON_USER_STOPPED = 11
|
||||
private const val MAX_DEVICE_MODEL_LENGTH = 80
|
||||
private val supportedAbis = setOf("arm64-v8a", "armeabi-v7a", "x86_64", "x86")
|
||||
|
||||
fun map(
|
||||
record: HistoricalExitRecord,
|
||||
deviceModel: String,
|
||||
apiLevel: Int,
|
||||
abi: String,
|
||||
lowRam: Boolean,
|
||||
startupPhase: String? = null,
|
||||
runtime: RuntimeDiagnosticSnapshot = RuntimeDiagnosticSnapshot()
|
||||
): PreviousExitReport {
|
||||
val dedupeKey = sha256(
|
||||
listOf(
|
||||
record.timestamp.toString(),
|
||||
record.reason.toString(),
|
||||
record.status.toString(),
|
||||
record.importance.toString()
|
||||
)
|
||||
)
|
||||
return PreviousExitReport(
|
||||
reason = mapReason(record.reason),
|
||||
status = record.status,
|
||||
importance = record.importance,
|
||||
timestamp = record.timestamp,
|
||||
deviceModel = sanitizeDeviceModel(deviceModel),
|
||||
apiLevel = apiLevel,
|
||||
abi = abi.takeIf(supportedAbis::contains) ?: "unknown",
|
||||
lowRam = lowRam,
|
||||
startupPhase = AndroidStartupPhases.sanitize(startupPhase),
|
||||
runtime = AndroidRuntimeDiagnostics.sanitize(runtime),
|
||||
dedupeKey = dedupeKey
|
||||
)
|
||||
}
|
||||
|
||||
fun mapReason(reason: Int): String = when (reason) {
|
||||
REASON_CRASH -> "crash"
|
||||
REASON_CRASH_NATIVE -> "native_crash"
|
||||
REASON_ANR -> "anr"
|
||||
REASON_LOW_MEMORY -> "low_memory"
|
||||
REASON_USER_REQUESTED, REASON_USER_STOPPED -> "user_requested"
|
||||
else -> "other"
|
||||
}
|
||||
|
||||
fun sanitizeDeviceModel(raw: String): String {
|
||||
val sanitized = buildString(raw.length.coerceAtMost(MAX_DEVICE_MODEL_LENGTH)) {
|
||||
var pendingSpace = false
|
||||
raw.forEach { character ->
|
||||
if (length >= MAX_DEVICE_MODEL_LENGTH) return@forEach
|
||||
if (character.isWhitespace() || Character.isISOControl(character)) {
|
||||
pendingSpace = isNotEmpty()
|
||||
} else {
|
||||
if (pendingSpace && length < MAX_DEVICE_MODEL_LENGTH) append(' ')
|
||||
if (length < MAX_DEVICE_MODEL_LENGTH) append(character)
|
||||
pendingSpace = false
|
||||
}
|
||||
}
|
||||
}.trim()
|
||||
return sanitized.ifEmpty { "unknown" }
|
||||
}
|
||||
|
||||
private fun sha256(fields: List<String>): String {
|
||||
val digest = MessageDigest.getInstance("SHA-256")
|
||||
fields.forEach { field ->
|
||||
digest.update(field.length.toString().toByteArray(Charsets.UTF_8))
|
||||
digest.update(':'.code.toByte())
|
||||
digest.update(field.toByteArray(Charsets.UTF_8))
|
||||
digest.update(';'.code.toByte())
|
||||
}
|
||||
return digest.digest().joinToString("") { byte ->
|
||||
(byte.toInt() and 0xff).toString(16).padStart(2, '0')
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
internal class PreviousExitReportStore(
|
||||
private val readDedupeKey: () -> String?,
|
||||
private val persistDedupeKey: (String) -> Boolean
|
||||
) {
|
||||
@Synchronized
|
||||
fun takeIfNew(report: PreviousExitReport): Map<String, Any>? {
|
||||
if (readDedupeKey() == report.dedupeKey) return null
|
||||
if (!persistDedupeKey(report.dedupeKey)) return null
|
||||
return report.toMap()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,256 @@
|
||||
package com.edde746.plezy
|
||||
|
||||
import android.app.ActivityManager
|
||||
import android.app.NotificationManager
|
||||
import android.app.usage.UsageStatsManager
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.net.ConnectivityManager
|
||||
import android.net.Uri
|
||||
import android.os.Build
|
||||
import android.os.PowerManager
|
||||
import android.provider.Settings
|
||||
|
||||
/**
|
||||
* Raw per-signal snapshot of everything the OS is willing to tell us about
|
||||
* whether our own background work will be allowed to run.
|
||||
*
|
||||
* Every field is nullable: a null means "this API level cannot answer", not
|
||||
* "unrestricted". [BackgroundWorkClassifier] treats unknown as OK, because a
|
||||
* warning we cannot substantiate is worse than no warning at all.
|
||||
*/
|
||||
internal data class BackgroundWorkSignals(
|
||||
val sdkInt: Int? = null,
|
||||
val backgroundRestricted: Boolean? = null,
|
||||
val standbyBucket: Int? = null,
|
||||
val notificationsEnabled: Boolean? = null,
|
||||
val downloadChannelBlocked: Boolean? = null,
|
||||
val dataSaverRestricted: Boolean? = null,
|
||||
val ignoringBatteryOptimizations: Boolean? = null
|
||||
)
|
||||
|
||||
/**
|
||||
* Turns [BackgroundWorkSignals] into a verdict plus ordered reasons.
|
||||
*
|
||||
* Deliberately pure and free of `Build.VERSION` reads so the whole decision
|
||||
* table is exercisable from plain JVM unit tests.
|
||||
*/
|
||||
internal object BackgroundWorkClassifier {
|
||||
const val VERDICT_OK = "ok"
|
||||
const val VERDICT_DEGRADED = "degraded"
|
||||
const val VERDICT_BLOCKED = "blocked"
|
||||
|
||||
const val REASON_BACKGROUND_RESTRICTED = "background_restricted"
|
||||
const val REASON_STANDBY_RESTRICTED = "standby_restricted"
|
||||
const val REASON_DOWNLOAD_CHANNEL_BLOCKED = "download_channel_blocked"
|
||||
const val REASON_NOTIFICATIONS_DISABLED = "notifications_disabled"
|
||||
const val REASON_DATA_SAVER = "data_saver"
|
||||
|
||||
/**
|
||||
* `STANDBY_BUCKET_RESTRICTED` (API 30) and `STANDBY_BUCKET_NEVER`. Declared
|
||||
* literally so the classifier stays compilable and testable below those API
|
||||
* levels. `RARE` (40) is intentionally absent — it is reached by ordinary
|
||||
* disuse and says nothing about a user-imposed restriction, so warning on it
|
||||
* would fire on healthy devices.
|
||||
*/
|
||||
const val BUCKET_RESTRICTED = 45
|
||||
const val BUCKET_NEVER = 50
|
||||
|
||||
private val blockingReasons = setOf(
|
||||
REASON_BACKGROUND_RESTRICTED,
|
||||
REASON_STANDBY_RESTRICTED
|
||||
)
|
||||
|
||||
/** Ordered most- to least-actionable; the UI leads with the first entry. */
|
||||
fun reasons(signals: BackgroundWorkSignals): List<String> = buildList {
|
||||
if (signals.backgroundRestricted == true) add(REASON_BACKGROUND_RESTRICTED)
|
||||
if (signals.standbyBucket != null && isRestrictedBucket(signals.standbyBucket)) add(REASON_STANDBY_RESTRICTED)
|
||||
// App-wide denial is more fundamental than a muted channel and routes to
|
||||
// a screen that also exists before the channel has been created.
|
||||
if (signals.notificationsEnabled == false) {
|
||||
add(REASON_NOTIFICATIONS_DISABLED)
|
||||
} else if (signals.downloadChannelBlocked == true) {
|
||||
add(REASON_DOWNLOAD_CHANNEL_BLOCKED)
|
||||
}
|
||||
if (signals.dataSaverRestricted == true) add(REASON_DATA_SAVER)
|
||||
}
|
||||
|
||||
fun verdict(signals: BackgroundWorkSignals, reasons: List<String> = reasons(signals)): String = when {
|
||||
reasons.any(blockingReasons::contains) -> VERDICT_BLOCKED
|
||||
signals.sdkInt != null &&
|
||||
signals.sdkInt >= Build.VERSION_CODES.TIRAMISU &&
|
||||
signals.notificationsEnabled == false -> VERDICT_BLOCKED
|
||||
reasons.isNotEmpty() -> VERDICT_DEGRADED
|
||||
else -> VERDICT_OK
|
||||
}
|
||||
|
||||
fun isRestrictedBucket(bucket: Int): Boolean = bucket == BUCKET_RESTRICTED || bucket == BUCKET_NEVER
|
||||
|
||||
fun toMap(signals: BackgroundWorkSignals): Map<String, Any?> {
|
||||
val reasons = reasons(signals)
|
||||
return mapOf(
|
||||
"verdict" to verdict(signals, reasons),
|
||||
"reasons" to reasons,
|
||||
"sdkInt" to signals.sdkInt,
|
||||
"backgroundRestricted" to signals.backgroundRestricted,
|
||||
"standbyBucket" to signals.standbyBucket,
|
||||
"notificationsEnabled" to signals.notificationsEnabled,
|
||||
"downloadChannelBlocked" to signals.downloadChannelBlocked,
|
||||
"dataSaverRestricted" to signals.dataSaverRestricted,
|
||||
"ignoringBatteryOptimizations" to signals.ignoringBatteryOptimizations
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/** Settings screen a remedy button can send the user to. */
|
||||
internal enum class BackgroundSettingsTarget(val id: String) {
|
||||
APP_DETAILS("app_details"),
|
||||
APP_NOTIFICATIONS("app_notifications"),
|
||||
NOTIFICATION_CHANNEL("notification_channel");
|
||||
|
||||
companion object {
|
||||
fun fromId(raw: String?): BackgroundSettingsTarget? = entries.firstOrNull { it.id == raw }
|
||||
}
|
||||
}
|
||||
|
||||
/** Declarative intent description, kept Intent-free so it unit-tests on the JVM. */
|
||||
internal data class SettingsIntentSpec(
|
||||
val action: String,
|
||||
val data: String? = null,
|
||||
val stringExtras: Map<String, String> = emptyMap()
|
||||
)
|
||||
|
||||
/**
|
||||
* Ordered candidate intents per target, each falling back to the app details
|
||||
* page — the one Settings screen that exists on every Android build and that
|
||||
* reaches Battery → Unrestricted on One UI, Pixel, and AOSP alike.
|
||||
*
|
||||
* Samsung's own "Background usage limits" screen lives in an unexported
|
||||
* `com.samsung.android.lool` activity that moves between One UI releases, so
|
||||
* it is deliberately not targeted; the user gets written directions instead.
|
||||
*/
|
||||
internal object BackgroundSettingsIntents {
|
||||
fun specsFor(
|
||||
target: BackgroundSettingsTarget,
|
||||
packageName: String,
|
||||
sdkInt: Int
|
||||
): List<SettingsIntentSpec> {
|
||||
val appDetails = SettingsIntentSpec(
|
||||
action = Settings.ACTION_APPLICATION_DETAILS_SETTINGS,
|
||||
data = "package:$packageName"
|
||||
)
|
||||
if (sdkInt < Build.VERSION_CODES.O) return listOf(appDetails)
|
||||
return when (target) {
|
||||
BackgroundSettingsTarget.APP_DETAILS -> listOf(appDetails)
|
||||
BackgroundSettingsTarget.APP_NOTIFICATIONS -> listOf(
|
||||
SettingsIntentSpec(
|
||||
action = Settings.ACTION_APP_NOTIFICATION_SETTINGS,
|
||||
stringExtras = mapOf(Settings.EXTRA_APP_PACKAGE to packageName)
|
||||
),
|
||||
appDetails
|
||||
)
|
||||
BackgroundSettingsTarget.NOTIFICATION_CHANNEL -> listOf(
|
||||
SettingsIntentSpec(
|
||||
action = Settings.ACTION_CHANNEL_NOTIFICATION_SETTINGS,
|
||||
stringExtras = mapOf(
|
||||
Settings.EXTRA_APP_PACKAGE to packageName,
|
||||
Settings.EXTRA_CHANNEL_ID to BackgroundWorkDiagnostics.DOWNLOAD_NOTIFICATION_CHANNEL_ID
|
||||
)
|
||||
),
|
||||
SettingsIntentSpec(
|
||||
action = Settings.ACTION_APP_NOTIFICATION_SETTINGS,
|
||||
stringExtras = mapOf(Settings.EXTRA_APP_PACKAGE to packageName)
|
||||
),
|
||||
appDetails
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Reads the OS-visible reasons background downloads may be blocked.
|
||||
*
|
||||
* Every read is for our own package and needs no permission. Notably absent is
|
||||
* `REQUEST_IGNORE_BATTERY_OPTIMIZATIONS`: it is a Play-policy-restricted
|
||||
* permission whose qualifying use cases do not include media downloading.
|
||||
* Whitelist state is reported for support diagnostics only.
|
||||
*/
|
||||
internal object BackgroundWorkDiagnostics {
|
||||
/** `background_downloader`'s channel id (`Notifications.kt`, `nId`). */
|
||||
const val DOWNLOAD_NOTIFICATION_CHANNEL_ID = "background_downloader"
|
||||
|
||||
fun read(context: Context): BackgroundWorkSignals = BackgroundWorkSignals(
|
||||
sdkInt = Build.VERSION.SDK_INT,
|
||||
backgroundRestricted = readBackgroundRestricted(context),
|
||||
standbyBucket = readStandbyBucket(context),
|
||||
notificationsEnabled = readNotificationsEnabled(context),
|
||||
downloadChannelBlocked = readDownloadChannelBlocked(context),
|
||||
dataSaverRestricted = readDataSaverRestricted(context),
|
||||
ignoringBatteryOptimizations = readIgnoringBatteryOptimizations(context)
|
||||
)
|
||||
|
||||
private fun readBackgroundRestricted(context: Context): Boolean? {
|
||||
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.P) return null
|
||||
return runCatching {
|
||||
(context.getSystemService(Context.ACTIVITY_SERVICE) as ActivityManager).isBackgroundRestricted
|
||||
}.getOrNull()
|
||||
}
|
||||
|
||||
private fun readStandbyBucket(context: Context): Int? {
|
||||
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.P) return null
|
||||
return runCatching {
|
||||
(context.getSystemService(Context.USAGE_STATS_SERVICE) as UsageStatsManager).appStandbyBucket
|
||||
}.getOrNull()
|
||||
}
|
||||
|
||||
private fun readNotificationsEnabled(context: Context): Boolean? = runCatching {
|
||||
(context.getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager).areNotificationsEnabled()
|
||||
}.getOrNull()
|
||||
|
||||
/**
|
||||
* Null until the plugin has created its channel (first download), which is
|
||||
* correct — there is nothing to warn about before then.
|
||||
*/
|
||||
private fun readDownloadChannelBlocked(context: Context): Boolean? {
|
||||
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.O) return null
|
||||
return runCatching {
|
||||
val manager = context.getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager
|
||||
val channel = manager.getNotificationChannel(DOWNLOAD_NOTIFICATION_CHANNEL_ID) ?: return null
|
||||
channel.importance == NotificationManager.IMPORTANCE_NONE
|
||||
}.getOrNull()
|
||||
}
|
||||
|
||||
private fun readDataSaverRestricted(context: Context): Boolean? {
|
||||
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.N) return null
|
||||
return runCatching {
|
||||
val connectivity = context.getSystemService(Context.CONNECTIVITY_SERVICE) as ConnectivityManager
|
||||
connectivity.restrictBackgroundStatus == ConnectivityManager.RESTRICT_BACKGROUND_STATUS_ENABLED
|
||||
}.getOrNull()
|
||||
}
|
||||
|
||||
private fun readIgnoringBatteryOptimizations(context: Context): Boolean? = runCatching {
|
||||
(context.getSystemService(Context.POWER_SERVICE) as PowerManager)
|
||||
.isIgnoringBatteryOptimizations(context.packageName)
|
||||
}.getOrNull()
|
||||
|
||||
/**
|
||||
* Launches the first candidate Settings screen that resolves. Returns false
|
||||
* when the device has none of them — some TV and Fire OS builds ship without
|
||||
* a battery settings activity entirely.
|
||||
*/
|
||||
fun openSettings(context: Context, target: BackgroundSettingsTarget): Boolean {
|
||||
for (spec in BackgroundSettingsIntents.specsFor(target, context.packageName, Build.VERSION.SDK_INT)) {
|
||||
val launched = runCatching {
|
||||
val intent = Intent(spec.action).apply {
|
||||
spec.data?.let { data = Uri.parse(it) }
|
||||
spec.stringExtras.forEach { (key, value) -> putExtra(key, value) }
|
||||
addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
|
||||
}
|
||||
context.startActivity(intent)
|
||||
true
|
||||
}.getOrDefault(false)
|
||||
if (launched) return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
package com.edde746.plezy
|
||||
|
||||
internal enum class FlutterRenderer(
|
||||
val diagnosticName: String,
|
||||
val shellArgument: String?
|
||||
) {
|
||||
SKIA("Skia", "--enable-impeller=false"),
|
||||
IMPELLER("Impeller", null)
|
||||
}
|
||||
|
||||
/** Selects the Flutter UI renderer before the engine starts. */
|
||||
internal object FlutterRendererPolicy {
|
||||
private const val ANDROID_12_API = 31
|
||||
|
||||
fun select(
|
||||
isEWaste: Boolean,
|
||||
manufacturer: String,
|
||||
isAndroidTv: Boolean,
|
||||
sdkInt: Int,
|
||||
supportsVulkan11: Boolean,
|
||||
is64Bit: Boolean
|
||||
): FlutterRenderer {
|
||||
if (isEWaste) return FlutterRenderer.SKIA
|
||||
if (manufacturer.equals("NVIDIA", ignoreCase = true)) return FlutterRenderer.SKIA
|
||||
if (manufacturer.equals("Huawei", ignoreCase = true) ||
|
||||
manufacturer.equals("HONOR", ignoreCase = true)
|
||||
) {
|
||||
return FlutterRenderer.SKIA
|
||||
}
|
||||
if (!isAndroidTv) return FlutterRenderer.IMPELLER
|
||||
if (sdkInt < ANDROID_12_API || manufacturer.equals("Amazon", ignoreCase = true) || !supportsVulkan11) {
|
||||
return FlutterRenderer.SKIA
|
||||
}
|
||||
|
||||
// 32-bit Android TV SoCs are the low-memory / low-throughput class. Skia avoids
|
||||
// Impeller/Vulkan's substantially higher raster cost on these devices.
|
||||
if (!is64Bit) return FlutterRenderer.SKIA
|
||||
|
||||
return FlutterRenderer.IMPELLER
|
||||
}
|
||||
}
|
||||
@@ -35,6 +35,9 @@ import io.flutter.embedding.android.TransparencyMode
|
||||
import io.flutter.embedding.engine.FlutterEngine
|
||||
import io.flutter.embedding.engine.FlutterShellArgs
|
||||
import io.flutter.plugin.common.MethodChannel
|
||||
import java.util.concurrent.Executors
|
||||
import java.util.concurrent.RejectedExecutionException
|
||||
import java.util.concurrent.atomic.AtomicBoolean
|
||||
import kotlin.math.roundToInt
|
||||
|
||||
class MainActivity : FlutterActivity() {
|
||||
@@ -42,12 +45,27 @@ class MainActivity : FlutterActivity() {
|
||||
companion object {
|
||||
private const val TAG = "MainActivity"
|
||||
private const val TEXT_INPUT_DIAGNOSTICS_ENABLED = false
|
||||
private const val EXIT_DIAGNOSTICS_PREFS = "plezy_exit_diagnostics"
|
||||
private const val LAST_EXIT_DEDUPE_KEY = "last_reported_exit"
|
||||
private const val LAST_STARTUP_PHASE_KEY = "last_startup_phase"
|
||||
private val startupPhaseLock = Any()
|
||||
|
||||
@Volatile private var startupPhaseInitializationAttempted = false
|
||||
|
||||
@Volatile private var startupPhaseStore: StartupPhaseStore? = null
|
||||
|
||||
@Volatile private var previousRuntimeDiagnostics = RuntimeDiagnosticSnapshot()
|
||||
private val exitDiagnosticsExecutor by lazy {
|
||||
Executors.newSingleThreadExecutor { runnable ->
|
||||
Thread(runnable, "plezy-exit-diagnostics").apply { isDaemon = true }
|
||||
}
|
||||
}
|
||||
|
||||
// Mirrors DevicePerformance._lowMemThresholdBytes (2252 MiB): nominal
|
||||
// "2GB" devices report totalMem slightly above 2 GiB after carve-outs.
|
||||
private const val LOW_MEM_THRESHOLD_BYTES = 2252L shl 20
|
||||
|
||||
var usingSkia = false
|
||||
private var selectedFlutterRenderer = FlutterRenderer.IMPELLER
|
||||
}
|
||||
|
||||
private val PIP_CHANNEL = "com.plezy/pip"
|
||||
@@ -63,6 +81,7 @@ class MainActivity : FlutterActivity() {
|
||||
private var flutterSurfaceReconnectPending = false
|
||||
private var activityStarted = false
|
||||
private val externalPlayerChannel = ExternalPlayerChannel(this)
|
||||
private val exitDiagnosticsRequested = AtomicBoolean(false)
|
||||
|
||||
private inline fun logTextInputDiag(message: () -> String) {
|
||||
if (TEXT_INPUT_DIAGNOSTICS_ENABLED) {
|
||||
@@ -184,6 +203,135 @@ class MainActivity : FlutterActivity() {
|
||||
)
|
||||
}
|
||||
|
||||
private fun initializeStartupPhaseStore() {
|
||||
var shouldMarkNativeOnCreate = false
|
||||
synchronized(startupPhaseLock) {
|
||||
if (startupPhaseInitializationAttempted) return
|
||||
startupPhaseInitializationAttempted = true
|
||||
try {
|
||||
previousRuntimeDiagnostics = AndroidRuntimeDiagnostics.read(this)
|
||||
val preferences = getSharedPreferences(EXIT_DIAGNOSTICS_PREFS, Context.MODE_PRIVATE)
|
||||
startupPhaseStore = StartupPhaseStore(
|
||||
readPhase = { preferences.getString(LAST_STARTUP_PHASE_KEY, null) },
|
||||
persistPhase = { phase ->
|
||||
preferences.edit().putString(LAST_STARTUP_PHASE_KEY, phase).commit()
|
||||
}
|
||||
)
|
||||
shouldMarkNativeOnCreate = true
|
||||
} catch (_: Throwable) {
|
||||
Log.w(TAG, "Startup phase persistence unavailable")
|
||||
}
|
||||
}
|
||||
if (shouldMarkNativeOnCreate) {
|
||||
queueStartupPhase(AndroidStartupPhases.NATIVE_ON_CREATE)
|
||||
}
|
||||
}
|
||||
|
||||
private fun queueStartupPhase(raw: String?, result: MethodChannel.Result? = null) {
|
||||
val phase = AndroidStartupPhases.sanitize(raw)
|
||||
if (phase == null) {
|
||||
result?.let { completeStartupPhase(it, false) }
|
||||
return
|
||||
}
|
||||
AndroidRuntimeDiagnostics.update(this, uiState = uiStateForStartupPhase(phase))
|
||||
try {
|
||||
exitDiagnosticsExecutor.execute {
|
||||
val persisted = try {
|
||||
startupPhaseStore?.mark(phase) == true
|
||||
} catch (_: Throwable) {
|
||||
Log.w(TAG, "Startup phase update failed")
|
||||
false
|
||||
}
|
||||
result?.let { reply ->
|
||||
runOnUiThread { completeStartupPhase(reply, persisted) }
|
||||
}
|
||||
}
|
||||
} catch (_: Throwable) {
|
||||
Log.w(TAG, "Startup phase update could not start")
|
||||
result?.let { completeStartupPhase(it, false) }
|
||||
}
|
||||
}
|
||||
|
||||
private fun uiStateForStartupPhase(phase: String): String = when (phase) {
|
||||
"credentials_loaded", "binding_started", "binding_settled" -> AndroidRuntimeDiagnostics.UI_AUTHENTICATION
|
||||
"main_screen" -> AndroidRuntimeDiagnostics.UI_MAIN_SCREEN
|
||||
else -> AndroidRuntimeDiagnostics.UI_STARTUP
|
||||
}
|
||||
|
||||
private fun completeStartupPhase(result: MethodChannel.Result, persisted: Boolean) {
|
||||
try {
|
||||
result.success(persisted)
|
||||
} catch (_: Throwable) {
|
||||
Log.w(TAG, "Startup phase reply failed")
|
||||
}
|
||||
}
|
||||
|
||||
private fun handlePreviousExit(result: MethodChannel.Result) {
|
||||
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.R) {
|
||||
completePreviousExit(result, null)
|
||||
return
|
||||
}
|
||||
if (!exitDiagnosticsRequested.compareAndSet(false, true)) {
|
||||
completePreviousExit(result, null)
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
exitDiagnosticsExecutor.execute {
|
||||
val report = try {
|
||||
readPreviousExit()
|
||||
} catch (_: Throwable) {
|
||||
Log.w(TAG, "Previous exit diagnostics failed")
|
||||
null
|
||||
}
|
||||
runOnUiThread { completePreviousExit(result, report) }
|
||||
}
|
||||
} catch (_: RejectedExecutionException) {
|
||||
completePreviousExit(result, null)
|
||||
} catch (_: Throwable) {
|
||||
Log.w(TAG, "Previous exit diagnostics could not start")
|
||||
completePreviousExit(result, null)
|
||||
}
|
||||
}
|
||||
|
||||
private fun completePreviousExit(result: MethodChannel.Result, report: Map<String, Any>?) {
|
||||
try {
|
||||
result.success(report)
|
||||
} catch (_: Throwable) {
|
||||
Log.w(TAG, "Previous exit diagnostics reply failed")
|
||||
}
|
||||
}
|
||||
|
||||
@RequiresApi(Build.VERSION_CODES.R)
|
||||
private fun readPreviousExit(): Map<String, Any>? {
|
||||
val activityManager = getSystemService(Context.ACTIVITY_SERVICE) as ActivityManager
|
||||
val exitInfo = activityManager
|
||||
.getHistoricalProcessExitReasons(packageName, 0, 1)
|
||||
.firstOrNull()
|
||||
?: return null
|
||||
val report = AndroidExitReportMapper.map(
|
||||
record = HistoricalExitRecord(
|
||||
reason = exitInfo.reason,
|
||||
status = exitInfo.status,
|
||||
importance = exitInfo.importance,
|
||||
timestamp = exitInfo.timestamp
|
||||
),
|
||||
deviceModel = Build.MODEL,
|
||||
apiLevel = Build.VERSION.SDK_INT,
|
||||
abi = Build.SUPPORTED_ABIS.firstOrNull() ?: "unknown",
|
||||
lowRam = activityManager.isLowRamDevice,
|
||||
startupPhase = startupPhaseStore?.previousPhase,
|
||||
runtime = previousRuntimeDiagnostics
|
||||
)
|
||||
val preferences = getSharedPreferences(EXIT_DIAGNOSTICS_PREFS, Context.MODE_PRIVATE)
|
||||
return PreviousExitReportStore(
|
||||
readDedupeKey = { preferences.getString(LAST_EXIT_DEDUPE_KEY, null) },
|
||||
persistDedupeKey = { key ->
|
||||
preferences.edit().putString(LAST_EXIT_DEDUPE_KEY, key).commit()
|
||||
}
|
||||
).takeIfNew(report)
|
||||
}
|
||||
|
||||
/**
|
||||
* Same triple DevicePerformance uses for the reduced tier on the Dart
|
||||
* side — keep the two in sync. Evaluated here too because engine shell
|
||||
@@ -208,6 +356,8 @@ class MainActivity : FlutterActivity() {
|
||||
}
|
||||
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
// Snapshot the previous process phase before this launch can overwrite it.
|
||||
initializeStartupPhaseStore()
|
||||
// Apply persisted theme color to the window background before anything
|
||||
// else renders. This prevents a white flash between the native splash
|
||||
// screen and Flutter's first frame for non-default themes (e.g. OLED).
|
||||
@@ -319,43 +469,38 @@ class MainActivity : FlutterActivity() {
|
||||
|
||||
override fun getFlutterShellArgs(): FlutterShellArgs {
|
||||
val args = super.getFlutterShellArgs()
|
||||
usingSkia = shouldDisableImpeller()
|
||||
if (usingSkia) args.add("--enable-impeller=false")
|
||||
selectedFlutterRenderer = selectFlutterRenderer()
|
||||
selectedFlutterRenderer.shellArgument?.let { args.add(it) }
|
||||
if (isLowRamClass()) {
|
||||
// Bound the memory pools Dart can't reach: Skia's GPU resource cache
|
||||
// is sized from the surface area (hundreds of MB on a 4K-composited
|
||||
// TV) and the Dart old gen defaults to a large fraction of physical
|
||||
// RAM. Both drive LMK kills on 2GB boxes (#1349).
|
||||
if (usingSkia) args.add("--resource-cache-max-bytes-threshold=50331648")
|
||||
if (selectedFlutterRenderer == FlutterRenderer.SKIA) {
|
||||
args.add("--resource-cache-max-bytes-threshold=50331648")
|
||||
}
|
||||
args.add("--old-gen-heap-size=256")
|
||||
Log.i(TAG, "Low-RAM device: capped engine caches (skia=$usingSkia, oldGen=256MB)")
|
||||
Log.i(
|
||||
TAG,
|
||||
"Low-RAM device: capped engine caches " +
|
||||
"(renderer=${selectedFlutterRenderer.diagnosticName}, oldGen=256MB)"
|
||||
)
|
||||
}
|
||||
return args
|
||||
}
|
||||
|
||||
private fun shouldDisableImpeller(): Boolean {
|
||||
if (DeviceQuirks.isEWaste) return true
|
||||
// NVIDIA Tegra (Shield TV)
|
||||
if (Build.MANUFACTURER.equals("NVIDIA", ignoreCase = true)) return true
|
||||
// Huawei/HONOR Kirin SoCs use Mali GPUs
|
||||
if (Build.MANUFACTURER.equals("Huawei", ignoreCase = true) ||
|
||||
Build.MANUFACTURER.equals("HONOR", ignoreCase = true)
|
||||
) {
|
||||
return true
|
||||
}
|
||||
if (isAndroidTvDevice()) return !tvSupportsImpeller()
|
||||
return false
|
||||
}
|
||||
|
||||
// Impeller froze API 30 Fire TV hardware (#749) and Flutter's Vulkan → GLES
|
||||
// fallback still miscompiles gradients/SVGs, so only TV devices on Android 12+
|
||||
// with a Vulkan 1.1 driver leave the Skia path.
|
||||
private fun tvSupportsImpeller(): Boolean {
|
||||
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.S) return false
|
||||
// Fire OS reports modern API levels on GPUs whose drivers can't back it up
|
||||
if (Build.MANUFACTURER.equals("Amazon", ignoreCase = true)) return false
|
||||
private fun selectFlutterRenderer(): FlutterRenderer {
|
||||
val isAndroidTv = isAndroidTvDevice()
|
||||
val vulkan11 = 0x401000 // FEATURE_VULKAN_HARDWARE_VERSION encodes 1.1.0 as 0x401000
|
||||
return packageManager.hasSystemFeature(PackageManager.FEATURE_VULKAN_HARDWARE_VERSION, vulkan11)
|
||||
return FlutterRendererPolicy.select(
|
||||
isEWaste = DeviceQuirks.isEWaste,
|
||||
manufacturer = Build.MANUFACTURER,
|
||||
isAndroidTv = isAndroidTv,
|
||||
sdkInt = Build.VERSION.SDK_INT,
|
||||
supportsVulkan11 = isAndroidTv &&
|
||||
packageManager.hasSystemFeature(PackageManager.FEATURE_VULKAN_HARDWARE_VERSION, vulkan11),
|
||||
is64Bit = Process.is64Bit()
|
||||
)
|
||||
}
|
||||
|
||||
override fun getRenderMode(): RenderMode {
|
||||
@@ -428,6 +573,24 @@ class MainActivity : FlutterActivity() {
|
||||
"getTvDetection" -> result.success(getAndroidTvDetection())
|
||||
"getDeviceName" -> result.success(getDeviceName())
|
||||
"getPerformanceSignals" -> result.success(getPerformanceSignals())
|
||||
"getBackgroundWorkSignals" -> result.success(
|
||||
BackgroundWorkClassifier.toMap(BackgroundWorkDiagnostics.read(this))
|
||||
)
|
||||
"openBackgroundSettings" -> {
|
||||
val target = BackgroundSettingsTarget.fromId(call.arguments as? String)
|
||||
result.success(target != null && BackgroundWorkDiagnostics.openSettings(this, target))
|
||||
}
|
||||
"getPreviousExit" -> handlePreviousExit(result)
|
||||
"setStartupPhase" -> queueStartupPhase(call.arguments as? String, result)
|
||||
"setRuntimeUiState" -> {
|
||||
val uiState = AndroidRuntimeDiagnostics.sanitizeUiState(call.arguments as? String)
|
||||
if (uiState == null) {
|
||||
result.success(false)
|
||||
} else {
|
||||
AndroidRuntimeDiagnostics.update(this, uiState = uiState)
|
||||
result.success(true)
|
||||
}
|
||||
}
|
||||
else -> result.notImplemented()
|
||||
}
|
||||
}
|
||||
@@ -467,7 +630,7 @@ class MainActivity : FlutterActivity() {
|
||||
// Splash screen theme: persist user's chosen theme for next launch (API 31+)
|
||||
MethodChannel(flutterEngine.dartExecutor.binaryMessenger, THEME_CHANNEL).setMethodCallHandler { call, result ->
|
||||
when (call.method) {
|
||||
"getRenderer" -> result.success(if (usingSkia) "Skia" else "Impeller")
|
||||
"getRenderer" -> result.success(selectedFlutterRenderer.diagnosticName)
|
||||
"setSplashTheme" -> {
|
||||
val mode = call.argument<String>("mode")
|
||||
|
||||
|
||||
+73
-17
@@ -3,15 +3,25 @@ package com.edde746.plezy.exoplayer
|
||||
import androidx.media3.common.C
|
||||
import androidx.media3.common.DataReader
|
||||
import androidx.media3.common.Format
|
||||
import androidx.media3.common.ParserException
|
||||
import androidx.media3.common.util.ParsableByteArray
|
||||
import androidx.media3.extractor.TrackOutput
|
||||
import java.io.EOFException
|
||||
|
||||
/** Shared whole-sample buffering protocol for TrackOutput transforms. */
|
||||
/**
|
||||
* Buffers transformed [TrackOutput] data until metadata identifies the exact sample range.
|
||||
*
|
||||
* Media3 may append multiple samples before reporting metadata, and [TrackOutput.sampleMetadata]
|
||||
* identifies a sample relative to all appended bytes using `size` and `offset`. Before calling
|
||||
* [transformSample], this class compacts only that sample to `inputBuffer[0 until inputLength]`.
|
||||
* Bytes after the sample remain buffered for later metadata; unreferenced bytes before it are
|
||||
* discarded. Extractor decorators must call [resetBufferedData] when a seek abandons pending data.
|
||||
*/
|
||||
abstract class BufferedTransformingTrackOutput(
|
||||
protected val delegate: TrackOutput,
|
||||
initialBufferSize: Int,
|
||||
initialReadBufferSize: Int = initialBufferSize
|
||||
initialReadBufferSize: Int = initialBufferSize,
|
||||
private val maxBufferedSampleBytes: Int = Int.MAX_VALUE
|
||||
) : TrackOutput {
|
||||
protected var inputBuffer = ByteArray(initialBufferSize)
|
||||
private set
|
||||
@@ -24,8 +34,19 @@ abstract class BufferedTransformingTrackOutput(
|
||||
protected abstract val transformEnabled: Boolean
|
||||
protected abstract val transformedBuffer: ByteArray
|
||||
|
||||
/** Returns transformed length, or a negative value to drop the sample. */
|
||||
/**
|
||||
* Transforms the exact sample in `inputBuffer[0 until inputLength]`.
|
||||
*
|
||||
* Implementations must treat [inputBuffer] as read-only. Returns the number of bytes available
|
||||
* from [transformedBuffer], or a negative value to drop the sample.
|
||||
*/
|
||||
protected abstract fun transformSample(inputLength: Int, flags: Int): Int
|
||||
|
||||
init {
|
||||
require(initialBufferSize > 0)
|
||||
require(initialReadBufferSize > 0)
|
||||
require(maxBufferedSampleBytes >= initialBufferSize)
|
||||
}
|
||||
open override fun format(format: Format) = delegate.format(format)
|
||||
|
||||
override fun sampleData(
|
||||
@@ -39,8 +60,10 @@ abstract class BufferedTransformingTrackOutput(
|
||||
}
|
||||
|
||||
buffering = true
|
||||
if (readBuffer.size < length) readBuffer = ByteArray(length)
|
||||
val bytesRead = input.read(readBuffer, 0, length)
|
||||
val remainingCapacity = maxBufferedSampleBytes - inputLength
|
||||
if (length < 0 || remainingCapacity <= 0) throw sampleTooLarge()
|
||||
val requested = minOf(length, remainingCapacity, readBuffer.size)
|
||||
val bytesRead = input.read(readBuffer, 0, requested)
|
||||
if (bytesRead == C.RESULT_END_OF_INPUT && !allowEndOfInput) throw EOFException()
|
||||
if (bytesRead > 0) appendInput(readBuffer, bytesRead)
|
||||
return bytesRead
|
||||
@@ -53,7 +76,7 @@ abstract class BufferedTransformingTrackOutput(
|
||||
}
|
||||
|
||||
buffering = true
|
||||
ensureInputCapacity(inputLength + length)
|
||||
ensureInputCapacity(length)
|
||||
data.readBytes(inputBuffer, inputLength, length)
|
||||
inputLength += length
|
||||
}
|
||||
@@ -70,26 +93,59 @@ abstract class BufferedTransformingTrackOutput(
|
||||
return
|
||||
}
|
||||
|
||||
buffering = false
|
||||
val sourceLength = inputLength
|
||||
inputLength = 0
|
||||
val transformedLength = transformSample(sourceLength, flags)
|
||||
if (transformedLength < 0) return
|
||||
// Offset can describe data for one or more later samples already appended by the extractor.
|
||||
if (size < 0 || offset < 0 || size > inputLength - offset) {
|
||||
resetBufferedData()
|
||||
return
|
||||
}
|
||||
|
||||
outputParsable.reset(transformedBuffer, transformedLength)
|
||||
delegate.sampleData(outputParsable, transformedLength, TrackOutput.SAMPLE_DATA_PART_MAIN)
|
||||
delegate.sampleMetadata(timeUs, flags, transformedLength, 0, cryptoData)
|
||||
val sampleEnd = inputLength - offset
|
||||
val sampleStart = sampleEnd - size
|
||||
if (sampleStart > 0) {
|
||||
System.arraycopy(inputBuffer, sampleStart, inputBuffer, 0, size)
|
||||
}
|
||||
|
||||
try {
|
||||
val transformedLength = transformSample(size, flags)
|
||||
if (transformedLength < 0) return
|
||||
|
||||
outputParsable.reset(transformedBuffer, transformedLength)
|
||||
delegate.sampleData(outputParsable, transformedLength, TrackOutput.SAMPLE_DATA_PART_MAIN)
|
||||
delegate.sampleMetadata(timeUs, flags, transformedLength, 0, cryptoData)
|
||||
} finally {
|
||||
if (offset > 0) {
|
||||
System.arraycopy(inputBuffer, sampleEnd, inputBuffer, 0, offset)
|
||||
}
|
||||
inputLength = offset
|
||||
buffering = offset > 0
|
||||
}
|
||||
}
|
||||
|
||||
/** Drops sample bytes buffered before an extractor seek while retaining allocated storage. */
|
||||
internal fun resetBufferedData() {
|
||||
inputLength = 0
|
||||
buffering = false
|
||||
}
|
||||
|
||||
private fun appendInput(source: ByteArray, length: Int) {
|
||||
ensureInputCapacity(inputLength + length)
|
||||
ensureInputCapacity(length)
|
||||
System.arraycopy(source, 0, inputBuffer, inputLength, length)
|
||||
inputLength += length
|
||||
}
|
||||
|
||||
private fun ensureInputCapacity(needed: Int) {
|
||||
private fun ensureInputCapacity(additionalBytes: Int) {
|
||||
if (additionalBytes < 0 || additionalBytes > maxBufferedSampleBytes - inputLength) {
|
||||
throw sampleTooLarge()
|
||||
}
|
||||
val needed = inputLength + additionalBytes
|
||||
if (inputBuffer.size < needed) {
|
||||
inputBuffer = inputBuffer.copyOf(maxOf(needed, inputBuffer.size * 2))
|
||||
val doubledSize = minOf(maxBufferedSampleBytes.toLong(), inputBuffer.size.toLong() * 2).toInt()
|
||||
inputBuffer = inputBuffer.copyOf(maxOf(needed, doubledSize))
|
||||
}
|
||||
}
|
||||
|
||||
private fun sampleTooLarge(): ParserException = ParserException.createForMalformedContainer(
|
||||
"Buffered sample exceeds the maximum size of $maxBufferedSampleBytes bytes",
|
||||
null
|
||||
)
|
||||
}
|
||||
|
||||
+25
-34
@@ -138,28 +138,29 @@ class DoviConvertingTrackOutput(
|
||||
|
||||
override fun transformSample(inputLength: Int, flags: Int): Int {
|
||||
val processStartNs = System.nanoTime()
|
||||
outputIsProcessed = if ((flags and C.BUFFER_FLAG_ENCRYPTED) != 0) {
|
||||
if ((flags and C.BUFFER_FLAG_ENCRYPTED) != 0) {
|
||||
outputIsProcessed = false
|
||||
if (!loggedEncryptedSupplementalPassthrough && (flags and C.BUFFER_FLAG_HAS_SUPPLEMENTAL_DATA) != 0) {
|
||||
loggedEncryptedSupplementalPassthrough = true
|
||||
logWarn("Encrypted supplemental sample encountered, passing raw sample")
|
||||
}
|
||||
false
|
||||
} else {
|
||||
try {
|
||||
processSampleData(flags, inputLength)
|
||||
true
|
||||
} catch (e: Exception) {
|
||||
logError("NAL processing failed, passing raw sample", e)
|
||||
false
|
||||
}
|
||||
}
|
||||
val transformedLength = if (outputIsProcessed) outputLen else inputLength
|
||||
if (outputIsProcessed) {
|
||||
recordSampleProcessing((System.nanoTime() - processStartNs) / 1_000L)
|
||||
return if (inputLength == 0) -1 else inputLength
|
||||
}
|
||||
|
||||
// Skip empty samples (all NALs were DV layers) — don't confuse the decoder.
|
||||
return if (transformedLength == 0) -1 else transformedLength
|
||||
return try {
|
||||
if (!processSampleData(flags, inputLength)) {
|
||||
outputIsProcessed = true
|
||||
-1
|
||||
} else {
|
||||
outputIsProcessed = true
|
||||
recordSampleProcessing((System.nanoTime() - processStartNs) / 1_000L)
|
||||
if (outputLen == 0) -1 else outputLen
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
outputIsProcessed = false
|
||||
logError("NAL processing failed, passing raw sample", e)
|
||||
if (inputLength == 0) -1 else inputLength
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -169,23 +170,21 @@ class DoviConvertingTrackOutput(
|
||||
* [4-byte big-endian main sample size][main sample][supplemental data].
|
||||
* Only the main sample contains video NALs and should be rewritten.
|
||||
*/
|
||||
private fun processSampleData(flags: Int, dataLen: Int) {
|
||||
private fun processSampleData(flags: Int, dataLen: Int): Boolean {
|
||||
if ((flags and C.BUFFER_FLAG_HAS_SUPPLEMENTAL_DATA) == 0) {
|
||||
processNalUnits(0, dataLen)
|
||||
return
|
||||
return true
|
||||
}
|
||||
|
||||
if (dataLen < 4) {
|
||||
logWarn("Bad supplemental sample: ${dataLen}B is too small")
|
||||
copyRawSample(dataLen)
|
||||
return
|
||||
logWarn("Bad supplemental sample: ${dataLen}B is too small; dropping sample")
|
||||
return false
|
||||
}
|
||||
|
||||
val mainSampleLen = readInt32BE(inputBuffer, 0)
|
||||
if (mainSampleLen < 0 || mainSampleLen > dataLen - 4) {
|
||||
logWarn("Bad supplemental sample: mainLen=$mainSampleLen total=$dataLen")
|
||||
copyRawSample(dataLen)
|
||||
return
|
||||
logWarn("Bad supplemental sample: mainLen=$mainSampleLen total=$dataLen; dropping sample")
|
||||
return false
|
||||
}
|
||||
|
||||
val supplementalLen = dataLen - 4 - mainSampleLen
|
||||
@@ -200,10 +199,7 @@ class DoviConvertingTrackOutput(
|
||||
processNalUnits(4, mainSampleLen)
|
||||
|
||||
val processedMainLen = outputLen
|
||||
if (processedMainLen == 0) {
|
||||
outputLen = 0
|
||||
return
|
||||
}
|
||||
if (processedMainLen == 0) return true
|
||||
|
||||
ensureOutputCapacity(4 + processedMainLen + supplementalLen)
|
||||
System.arraycopy(outputBuf, 0, outputBuf, 4, processedMainLen)
|
||||
@@ -219,6 +215,7 @@ class DoviConvertingTrackOutput(
|
||||
"supplemental=${supplementalLen}B, total=${dataLen}B -> ${outputLen}B"
|
||||
)
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -610,12 +607,6 @@ class DoviConvertingTrackOutput(
|
||||
((buf[offset + 2].toInt() and 0xFF) shl 8) or
|
||||
(buf[offset + 3].toInt() and 0xFF)
|
||||
|
||||
private fun copyRawSample(dataLen: Int) {
|
||||
ensureOutputCapacity(dataLen)
|
||||
System.arraycopy(inputBuffer, 0, outputBuf, 0, dataLen)
|
||||
outputLen = dataLen
|
||||
}
|
||||
|
||||
private fun formatBytes(data: ByteArray, offset: Int, length: Int): String {
|
||||
val end = minOf(data.size, offset + length)
|
||||
if (offset >= end) return ""
|
||||
|
||||
@@ -19,12 +19,19 @@ class DoviExtractorOutputWrapper(
|
||||
private val emitLog: ((String, String, String) -> Unit)?,
|
||||
private val onVideoTrackWrapped: (DoviConvertingTrackOutput) -> Unit
|
||||
) : ExtractorOutput {
|
||||
private val trackOutputs = mutableListOf<DoviConvertingTrackOutput>()
|
||||
|
||||
fun resetTracks() {
|
||||
trackOutputs.forEach { it.resetBufferedData() }
|
||||
}
|
||||
|
||||
override fun track(id: Int, type: Int): TrackOutput {
|
||||
val original = delegate.track(id, type)
|
||||
if (type == C.TRACK_TYPE_VIDEO) {
|
||||
val wrapper = DoviConvertingTrackOutput(original, dvMode, emitLog)
|
||||
onVideoTrackWrapped(wrapper)
|
||||
return wrapper
|
||||
return DoviConvertingTrackOutput(original, dvMode, emitLog).also {
|
||||
trackOutputs.add(it)
|
||||
onVideoTrackWrapped(it)
|
||||
}
|
||||
}
|
||||
return original
|
||||
}
|
||||
@@ -46,16 +53,22 @@ class DoviExtractorWrapper(
|
||||
|
||||
@Volatile var doviTrackOutput: DoviConvertingTrackOutput? = null
|
||||
private set
|
||||
private var outputWrapper: DoviExtractorOutputWrapper? = null
|
||||
|
||||
override fun sniff(input: ExtractorInput): Boolean = delegate.sniff(input)
|
||||
|
||||
override fun init(output: ExtractorOutput) {
|
||||
delegate.init(DoviExtractorOutputWrapper(output, dvMode, emitLog) { doviTrackOutput = it })
|
||||
val wrapper = DoviExtractorOutputWrapper(output, dvMode, emitLog) { doviTrackOutput = it }
|
||||
outputWrapper = wrapper
|
||||
delegate.init(wrapper)
|
||||
}
|
||||
|
||||
override fun read(input: ExtractorInput, seekPosition: PositionHolder): Int = delegate.read(input, seekPosition)
|
||||
|
||||
override fun seek(position: Long, timeUs: Long) = delegate.seek(position, timeUs)
|
||||
override fun seek(position: Long, timeUs: Long) {
|
||||
outputWrapper?.resetTracks()
|
||||
delegate.seek(position, timeUs)
|
||||
}
|
||||
|
||||
override fun release() = delegate.release()
|
||||
}
|
||||
|
||||
@@ -17,6 +17,7 @@ object DownmixMatrices {
|
||||
const val MIN_DOWNMIX_INPUT_CHANNELS = 3
|
||||
const val MAX_DOWNMIX_INPUT_CHANNELS = 8
|
||||
const val MAX_CENTER_BOOST_DB = 12
|
||||
const val MAX_MIXING_CHANNELS = 12
|
||||
|
||||
const val SURROUND_GAIN = 0.70710678f // -3 dB
|
||||
private const val BACK_CENTER_GAIN = 0.5f // SURROUND_GAIN split across both outputs
|
||||
@@ -65,6 +66,15 @@ object DownmixMatrices {
|
||||
return flat
|
||||
}
|
||||
|
||||
fun identityCoefficients(channelCount: Int): FloatArray {
|
||||
require(channelCount in 1..MAX_MIXING_CHANNELS)
|
||||
return FloatArray(channelCount * channelCount).apply {
|
||||
for (channel in 0 until channelCount) {
|
||||
this[channel * channelCount + channel] = 1f
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun fl() = floatArrayOf(1f, 0f)
|
||||
private fun fr() = floatArrayOf(0f, 1f)
|
||||
private fun left(gain: Float) = floatArrayOf(gain, 0f)
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
+3
-50
@@ -1,6 +1,5 @@
|
||||
package com.edde746.plezy.exoplayer
|
||||
|
||||
import android.util.Log
|
||||
import androidx.media3.extractor.ExtractorOutput
|
||||
import androidx.media3.extractor.SeekMap
|
||||
import androidx.media3.extractor.TrackOutput
|
||||
@@ -24,10 +23,9 @@ internal val matroskaExtractorOutputField by lazy {
|
||||
private const val WAVE_FORMAT_MPEG_LOAS = 0x1602
|
||||
|
||||
/**
|
||||
* Returns whether a track is LOAS/LATM AAC muxed as A_MS/ACM — ffmpeg's (and
|
||||
* therefore Plex's) fallback mapping for aac_latm, which Matroska has no native
|
||||
* codec ID for. The WAVEFORMATEX wFormatTag is the first 2 bytes (LE) of
|
||||
* CodecPrivate.
|
||||
* Returns whether a track is LOAS/LATM AAC muxed as A_MS/ACM — ffmpeg's
|
||||
* fallback mapping for aac_latm, which Matroska has no native codec ID for.
|
||||
* The WAVEFORMATEX wFormatTag is the first 2 bytes (LE) of CodecPrivate.
|
||||
*/
|
||||
fun isLoasAcmTrack(codecId: String?, codecPrivate: ByteArray?): Boolean = codecId == CODEC_ID_ACM &&
|
||||
codecPrivate != null &&
|
||||
@@ -65,48 +63,3 @@ class LatmExtractorOutputWrapper(
|
||||
override fun endTracks() = delegate.endTracks()
|
||||
override fun seekMap(seekMap: SeekMap) = delegate.seekMap(seekMap)
|
||||
}
|
||||
|
||||
/**
|
||||
* MatroskaExtractor with LOAS/LATM AAC support, used for Plex Live TV MKV
|
||||
* streams (which bypass the ASS/DV extractor chain). VOD playback gets the
|
||||
* same LATM handling via ZlibMatroskaExtractor.
|
||||
*/
|
||||
class LatmMatroskaExtractor(flags: Int) : MatroskaExtractor(flags) {
|
||||
|
||||
companion object {
|
||||
private const val TAG = "LatmMkvExtractor"
|
||||
private const val ID_SEGMENT = 0x18538067
|
||||
private const val ID_TRACK_ENTRY = 0xAE
|
||||
}
|
||||
|
||||
private var latmWrapper: LatmExtractorOutputWrapper? = null
|
||||
|
||||
override fun startMasterElement(id: Int, contentPosition: Long, contentSize: Long) {
|
||||
super.startMasterElement(id, contentPosition, contentSize)
|
||||
|
||||
// init() is final, so install the wrapping output when the Segment starts —
|
||||
// before any TrackEntry can create a track through it.
|
||||
if (id == ID_SEGMENT && latmWrapper == null) {
|
||||
val currentOutput = matroskaExtractorOutputField.get(this) as ExtractorOutput
|
||||
val wrapper = LatmExtractorOutputWrapper(currentOutput)
|
||||
latmWrapper = wrapper
|
||||
matroskaExtractorOutputField.set(this, wrapper)
|
||||
}
|
||||
}
|
||||
|
||||
override fun endMasterElement(id: Int) {
|
||||
if (id == ID_TRACK_ENTRY) {
|
||||
val track = getCurrentTrack(id)
|
||||
if (isLoasAcmTrack(track.codecId, track.codecPrivate)) {
|
||||
Log.i(TAG, "Track ${track.number} is LOAS/LATM AAC, unwrapping to raw AAC")
|
||||
latmWrapper?.markNextTrackLatm()
|
||||
}
|
||||
}
|
||||
super.endMasterElement(id)
|
||||
}
|
||||
|
||||
override fun seek(position: Long, timeUs: Long) {
|
||||
latmWrapper?.resetTracks()
|
||||
super.seek(position, timeUs)
|
||||
}
|
||||
}
|
||||
@@ -51,10 +51,21 @@ class PlezyRenderersFactory(context: Context) : DefaultRenderersFactory(context)
|
||||
* "No mixing matrix for input channel count". ExoPlayerCore swaps in
|
||||
* downmix matrices via [DownmixMatrices] when the setting is enabled.
|
||||
*/
|
||||
val channelMixProcessor = ChannelMixingAudioProcessor().apply {
|
||||
for (count in 1..12) putChannelMixingMatrix(ChannelMixingMatrix.create(count, count))
|
||||
private val identityChannelMixingMatrices = Array(DownmixMatrices.MAX_MIXING_CHANNELS) { index ->
|
||||
val channelCount = index + 1
|
||||
ChannelMixingMatrix(
|
||||
channelCount,
|
||||
channelCount,
|
||||
DownmixMatrices.identityCoefficients(channelCount)
|
||||
)
|
||||
}
|
||||
|
||||
val channelMixProcessor = ChannelMixingAudioProcessor().apply {
|
||||
for (matrix in identityChannelMixingMatrices) putChannelMixingMatrix(matrix)
|
||||
}
|
||||
|
||||
fun identityChannelMixingMatrix(channelCount: Int): ChannelMixingMatrix = identityChannelMixingMatrices[channelCount - 1]
|
||||
|
||||
/** Returns whether direct encoded output should be hidden so decoded PCM output can be selected. */
|
||||
var shouldBlockDirectAudioOutput: ((Format) -> Boolean)? = null
|
||||
|
||||
@@ -75,9 +86,9 @@ class PlezyRenderersFactory(context: Context) : DefaultRenderersFactory(context)
|
||||
allowedVideoJoiningTimeMs: Long,
|
||||
out: ArrayList<Renderer>
|
||||
) {
|
||||
// Let super build the full list (it also appends extension renderers reflectively,
|
||||
// e.g. the jellyfin ffmpeg artifact's video renderer), then swap the stock
|
||||
// MediaCodecVideoRenderer for the DV-sanitizing variant at the same index.
|
||||
// Let super build the full list (including optional extension renderers),
|
||||
// then swap the stock MediaCodecVideoRenderer for the DV-sanitizing variant
|
||||
// at the same index.
|
||||
super.buildVideoRenderers(
|
||||
context,
|
||||
extensionRendererMode,
|
||||
|
||||
@@ -0,0 +1,40 @@
|
||||
package com.edde746.plezy.exoplayer
|
||||
|
||||
import androidx.media3.common.PlaybackException
|
||||
|
||||
/**
|
||||
* Keeps runtime decoder recovery separate from genuine format incompatibility.
|
||||
*
|
||||
* A video format that has already rendered a frame is known to be supported. A
|
||||
* later decoder failure can therefore be retried in ExoPlayer before the
|
||||
* session falls back to mpv. Cold-start failures still use the normal fallback,
|
||||
* while consecutive and per-media caps prevent reload loops.
|
||||
*/
|
||||
internal object VideoDecoderRecoveryPolicy {
|
||||
const val MAX_CONSECUTIVE_ATTEMPTS = 1
|
||||
const val MAX_ATTEMPTS_PER_MEDIA = 3
|
||||
const val HEALTHY_PLAYBACK_PROGRESS_MS = 3000L
|
||||
|
||||
fun isTransientVideoDecoderError(errorCode: Int, isVideoRenderer: Boolean): Boolean = isVideoRenderer &&
|
||||
(
|
||||
errorCode == PlaybackException.ERROR_CODE_DECODER_INIT_FAILED ||
|
||||
errorCode == PlaybackException.ERROR_CODE_DECODING_FAILED
|
||||
)
|
||||
|
||||
fun canRetryRuntimeFailure(
|
||||
hasRenderedVideoFrame: Boolean,
|
||||
consecutiveAttempts: Int,
|
||||
totalAttempts: Int
|
||||
): Boolean = hasRenderedVideoFrame &&
|
||||
consecutiveAttempts < MAX_CONSECUTIVE_ATTEMPTS &&
|
||||
totalAttempts < MAX_ATTEMPTS_PER_MEDIA
|
||||
|
||||
fun hasSustainedPlayback(
|
||||
hasRenderedFrame: Boolean,
|
||||
isPlaying: Boolean,
|
||||
recoveryPositionMs: Long,
|
||||
currentPositionMs: Long
|
||||
): Boolean = hasRenderedFrame &&
|
||||
isPlaying &&
|
||||
currentPositionMs - recoveryPositionMs >= HEALTHY_PLAYBACK_PROGRESS_MS
|
||||
}
|
||||
+50
-22
@@ -1,6 +1,6 @@
|
||||
package com.edde746.plezy.exoplayer
|
||||
|
||||
import android.util.Log
|
||||
import androidx.media3.common.ParserException
|
||||
import androidx.media3.extractor.TrackOutput
|
||||
import java.util.zip.DataFormatException
|
||||
import java.util.zip.Inflater
|
||||
@@ -14,18 +14,27 @@ import java.util.zip.Inflater
|
||||
*/
|
||||
class ZlibInflatingTrackOutput(
|
||||
delegate: TrackOutput
|
||||
) : BufferedTransformingTrackOutput(delegate, INITIAL_BUFFER_SIZE, INFLATE_CHUNK) {
|
||||
) : BufferedTransformingTrackOutput(
|
||||
delegate,
|
||||
INITIAL_BUFFER_SIZE,
|
||||
INFLATE_CHUNK,
|
||||
MAX_COMPRESSED_SAMPLE_SIZE
|
||||
) {
|
||||
|
||||
companion object {
|
||||
private const val TAG = "ZlibTrackOutput"
|
||||
private const val INITIAL_BUFFER_SIZE = 256 * 1024
|
||||
private const val INFLATE_CHUNK = 64 * 1024
|
||||
private const val MAX_COMPRESSED_SAMPLE_SIZE = 16 * 1024 * 1024
|
||||
private const val MAX_INFLATED_SAMPLE_SIZE = 16 * 1024 * 1024
|
||||
private const val MAX_COMPRESSION_RATIO = 1024L
|
||||
private const val MIN_RATIO_ALLOWANCE = 1024L * 1024
|
||||
}
|
||||
|
||||
var active = false
|
||||
|
||||
private val inflater = Inflater()
|
||||
private var inflateBuf = ByteArray(INITIAL_BUFFER_SIZE)
|
||||
private val overflowProbe = ByteArray(1)
|
||||
|
||||
override val transformEnabled: Boolean
|
||||
get() = active
|
||||
@@ -33,31 +42,50 @@ class ZlibInflatingTrackOutput(
|
||||
override val transformedBuffer: ByteArray
|
||||
get() = inflateBuf
|
||||
|
||||
override fun transformSample(inputLength: Int, flags: Int): Int = try {
|
||||
override fun transformSample(inputLength: Int, flags: Int): Int {
|
||||
inflater.reset()
|
||||
inflater.setInput(inputBuffer, 0, inputLength)
|
||||
var written = 0
|
||||
while (!inflater.finished()) {
|
||||
if (written == inflateBuf.size) growInflateBuf()
|
||||
val count = inflater.inflate(inflateBuf, written, inflateBuf.size - written)
|
||||
if (count == 0 && !inflater.finished()) break
|
||||
written += count
|
||||
}
|
||||
written
|
||||
} catch (e: DataFormatException) {
|
||||
Log.e(TAG, "Zlib inflate failed (${inputLength}B), passing raw", e)
|
||||
ensureInflateCapacity(inputLength)
|
||||
System.arraycopy(inputBuffer, 0, inflateBuf, 0, inputLength)
|
||||
inputLength
|
||||
}
|
||||
val ratioBound = maxOf(MIN_RATIO_ALLOWANCE, inputLength.toLong() * MAX_COMPRESSION_RATIO)
|
||||
|
||||
private fun ensureInflateCapacity(needed: Int) {
|
||||
if (inflateBuf.size < needed) {
|
||||
inflateBuf = ByteArray(maxOf(needed, inflateBuf.size * 2))
|
||||
try {
|
||||
while (true) {
|
||||
if (written == inflateBuf.size) {
|
||||
if (inflateBuf.size < MAX_INFLATED_SAMPLE_SIZE) {
|
||||
val nextSize = minOf(MAX_INFLATED_SAMPLE_SIZE, inflateBuf.size * 2)
|
||||
inflateBuf = inflateBuf.copyOf(nextSize)
|
||||
} else {
|
||||
val overflow = inflater.inflate(overflowProbe, 0, 1)
|
||||
if (overflow > 0) {
|
||||
throw malformed("Inflated sample exceeds the maximum size")
|
||||
}
|
||||
if (inflater.finished()) return written
|
||||
throw stalledInflate()
|
||||
}
|
||||
}
|
||||
|
||||
val count = inflater.inflate(inflateBuf, written, inflateBuf.size - written)
|
||||
written += count
|
||||
if (written.toLong() > ratioBound) {
|
||||
throw malformed("Inflated sample exceeds the maximum compression ratio")
|
||||
}
|
||||
if (inflater.finished()) return written
|
||||
if (count == 0) throw stalledInflate()
|
||||
}
|
||||
} catch (_: DataFormatException) {
|
||||
// Preserve playback for corrupt subtitle blocks; the raw sample remains bounded
|
||||
// by MAX_COMPRESSED_SAMPLE_SIZE and matches the pre-hardening fallback.
|
||||
if (inflateBuf.size < inputLength) inflateBuf = inflateBuf.copyOf(inputLength)
|
||||
inputBuffer.copyInto(inflateBuf, endIndex = inputLength)
|
||||
return inputLength
|
||||
}
|
||||
}
|
||||
|
||||
private fun growInflateBuf() {
|
||||
inflateBuf = inflateBuf.copyOf(inflateBuf.size * 2)
|
||||
private fun stalledInflate(): ParserException = when {
|
||||
inflater.needsDictionary() -> malformed("Zlib-compressed sample requires a dictionary")
|
||||
inflater.needsInput() -> malformed("Truncated zlib-compressed sample")
|
||||
else -> malformed("Zlib decompressor made no progress")
|
||||
}
|
||||
|
||||
private fun malformed(message: String, cause: Throwable? = null): ParserException = ParserException.createForMalformedContainer(message, cause)
|
||||
}
|
||||
|
||||
@@ -21,8 +21,7 @@ import com.edde746.plezy.libass.media.extractor.AssMatroskaExtractor
|
||||
*
|
||||
* LOAS/LATM AAC as A_MS/ACM — media3 sets audio/x-unknown for non-PCM ACM
|
||||
* tracks (silent playback). Detected tracks are wrapped with LatmTrackOutput,
|
||||
* which unwraps LOAS frames to raw AAC (see LatmMatroskaExtractor for the
|
||||
* Live TV counterpart).
|
||||
* which unwraps LOAS frames to raw AAC for direct-playing Matroska files.
|
||||
*/
|
||||
class ZlibMatroskaExtractor(
|
||||
subtitleParserFactory: SubtitleParser.Factory,
|
||||
@@ -103,6 +102,7 @@ class ZlibMatroskaExtractor(
|
||||
|
||||
override fun seek(position: Long, timeUs: Long) {
|
||||
latmOutput?.resetTracks()
|
||||
zlibOutput?.resetTracks()
|
||||
super.seek(position, timeUs)
|
||||
}
|
||||
|
||||
@@ -116,18 +116,24 @@ class ZlibMatroskaExtractor(
|
||||
) : ExtractorOutput {
|
||||
|
||||
private var lastCreatedWrapper: ZlibInflatingTrackOutput? = null
|
||||
private val trackOutputs = mutableListOf<ZlibInflatingTrackOutput>()
|
||||
|
||||
override fun track(id: Int, type: Int): TrackOutput {
|
||||
val original = delegate.track(id, type)
|
||||
val wrapper = ZlibInflatingTrackOutput(original)
|
||||
lastCreatedWrapper = wrapper
|
||||
return wrapper
|
||||
return ZlibInflatingTrackOutput(original).also {
|
||||
trackOutputs.add(it)
|
||||
lastCreatedWrapper = it
|
||||
}
|
||||
}
|
||||
|
||||
fun activateLast() {
|
||||
lastCreatedWrapper?.active = true
|
||||
}
|
||||
|
||||
fun resetTracks() {
|
||||
trackOutputs.forEach { it.resetBufferedData() }
|
||||
}
|
||||
|
||||
override fun endTracks() = delegate.endTracks()
|
||||
override fun seekMap(seekMap: SeekMap) = delegate.seekMap(seekMap)
|
||||
}
|
||||
|
||||
@@ -19,6 +19,7 @@ import com.edde746.plezy.shared.AudioFocusManager
|
||||
import com.edde746.plezy.shared.FrameRateManager
|
||||
import com.edde746.plezy.shared.PlayerDelegate
|
||||
import com.edde746.plezy.shared.PlayerSurfaceHost
|
||||
import com.edde746.plezy.shared.SurfacePlayerCore
|
||||
import dev.jdtech.mpv.*
|
||||
import kotlinx.coroutines.*
|
||||
import kotlinx.coroutines.sync.Mutex
|
||||
@@ -35,10 +36,20 @@ import kotlinx.coroutines.sync.withLock
|
||||
* configured before init to never open a video output (`vid=no`,
|
||||
* `force-window=no`, `audio-display=no`, plus `gapless-audio=weak`).
|
||||
*/
|
||||
class MpvPlayerCore(
|
||||
class MpvPlayerCore private constructor(
|
||||
private val context: Context,
|
||||
private val audioOnly: Boolean = false
|
||||
) : SurfaceHolder.Callback {
|
||||
private val audioOnly: Boolean,
|
||||
private val propertyWriterOverride: (suspend (String, String) -> Unit)?,
|
||||
initializedForTesting: Boolean
|
||||
) : SurfaceHolder.Callback,
|
||||
SurfacePlayerCore {
|
||||
constructor(context: Context, audioOnly: Boolean = false) : this(context, audioOnly, null, false)
|
||||
|
||||
internal constructor(
|
||||
context: Context,
|
||||
audioOnly: Boolean,
|
||||
propertyWriter: (suspend (String, String) -> Unit)?
|
||||
) : this(context, audioOnly, propertyWriter, true)
|
||||
|
||||
companion object {
|
||||
private const val TAG = "MpvPlayerCore"
|
||||
@@ -71,6 +82,10 @@ class MpvPlayerCore(
|
||||
var isInitialized: Boolean = false
|
||||
private set
|
||||
|
||||
init {
|
||||
if (initializedForTesting) isInitialized = true
|
||||
}
|
||||
|
||||
@Volatile private var player: MpvPlayer? = null
|
||||
private var scope = CoroutineScope(SupervisorJob() + Dispatchers.Main)
|
||||
private val endFileDiagnostics = MpvEndFileDiagnostics()
|
||||
@@ -100,8 +115,12 @@ class MpvPlayerCore(
|
||||
|
||||
@Volatile private var cachedPaused: Boolean = true
|
||||
|
||||
@Volatile private var desiredPaused: Boolean = true
|
||||
|
||||
@Volatile private var pausedForSurfaceLoss: Boolean = false
|
||||
|
||||
@Volatile private var pausedForAudioFocusLoss: Boolean = false
|
||||
|
||||
@Volatile private var hasAttachedSurface: Boolean = false
|
||||
|
||||
@Volatile private var attachedToPlaceholder: Boolean = false
|
||||
@@ -112,6 +131,16 @@ class MpvPlayerCore(
|
||||
|
||||
@Volatile private var resumeBlockedByPublicPause: Boolean = false
|
||||
|
||||
private data class PublicPauseIntent(
|
||||
val generation: Long,
|
||||
val previousBlocked: Boolean,
|
||||
val previousDesiredPaused: Boolean
|
||||
)
|
||||
|
||||
private val publicPauseIntentLock = Any()
|
||||
private var publicPauseIntentGeneration = 0L
|
||||
private val publicPauseWriteMutex = Mutex()
|
||||
|
||||
@Volatile private var videoOutputEpoch: Long = 0L
|
||||
private val videoOutputMutex = Mutex()
|
||||
private var pendingVideoOutputDisableJob: Job? = null
|
||||
@@ -186,14 +215,19 @@ class MpvPlayerCore(
|
||||
disposing = false
|
||||
endFileDiagnostics.onStartFile()
|
||||
cachedPaused = true
|
||||
desiredPaused = true
|
||||
pausedForSurfaceLoss = false
|
||||
pausedForAudioFocusLoss = false
|
||||
pendingSurface = null
|
||||
attachedSurface = null
|
||||
attachedToPlaceholder = false
|
||||
hasAttachedSurface = false
|
||||
videoOutputRestoring = false
|
||||
deferredResumeRequested = false
|
||||
resumeBlockedByPublicPause = false
|
||||
synchronized(publicPauseIntentLock) {
|
||||
publicPauseIntentGeneration += 1L
|
||||
resumeBlockedByPublicPause = false
|
||||
}
|
||||
videoOutputEpoch = 0L
|
||||
pendingVideoOutputDisableJob?.cancel()
|
||||
pendingVideoOutputDisableJob = null
|
||||
@@ -210,18 +244,12 @@ class MpvPlayerCore(
|
||||
handler = handler,
|
||||
contentType = if (audioOnly) AudioAttributes.CONTENT_TYPE_MUSIC else AudioAttributes.CONTENT_TYPE_MOVIE,
|
||||
onPause = {
|
||||
scope.launch {
|
||||
try {
|
||||
player?.setProperty("pause", true)
|
||||
} catch (e: Exception) {
|
||||
Log.w(TAG, "Failed to pause on focus loss", e)
|
||||
}
|
||||
}
|
||||
pauseForAudioFocusLoss()
|
||||
},
|
||||
onResume = {
|
||||
requestAutoResume("audio focus gain")
|
||||
resumeAfterAudioFocusGain("audio focus gain")
|
||||
},
|
||||
isPaused = { cachedPaused }
|
||||
isPaused = { desiredPaused }
|
||||
)
|
||||
if (!audioOnly) {
|
||||
frameRateManager = FrameRateManager(
|
||||
@@ -334,7 +362,10 @@ class MpvPlayerCore(
|
||||
is MpvEvent.EndFile -> {
|
||||
delegate?.onEvent("end-file", endFileDiagnostics.onEndFile(event))
|
||||
}
|
||||
is MpvEvent.StartFile -> endFileDiagnostics.onStartFile()
|
||||
is MpvEvent.StartFile -> {
|
||||
endFileDiagnostics.onStartFile()
|
||||
delegate?.onEvent("start-file", null)
|
||||
}
|
||||
is MpvEvent.FileLoaded -> delegate?.onEvent("file-loaded", null)
|
||||
is MpvEvent.PlaybackRestart -> delegate?.onEvent("playback-restart", null)
|
||||
else -> {}
|
||||
@@ -376,9 +407,15 @@ class MpvPlayerCore(
|
||||
|
||||
// Audio Focus
|
||||
|
||||
fun requestAudioFocus(): Boolean = audioFocusManager?.requestAudioFocus() ?: false
|
||||
override fun requestAudioFocus(): Boolean {
|
||||
val granted = audioFocusManager?.requestAudioFocus() ?: false
|
||||
if (granted && pausedForAudioFocusLoss) {
|
||||
resumeAfterAudioFocusGain("audio focus request granted")
|
||||
}
|
||||
return granted
|
||||
}
|
||||
|
||||
fun abandonAudioFocus() {
|
||||
override fun abandonAudioFocus() {
|
||||
audioFocusManager?.abandonAudioFocus()
|
||||
}
|
||||
|
||||
@@ -571,23 +608,25 @@ class MpvPlayerCore(
|
||||
Log.d(TAG, "Skipping stale MPV placeholder attach ($reason, epoch=$epoch)")
|
||||
return@withLock
|
||||
}
|
||||
val wasPaused = try {
|
||||
p.getFlag("pause") == true
|
||||
} catch (e: Exception) {
|
||||
cachedPaused
|
||||
}
|
||||
if (!wasPaused) {
|
||||
try {
|
||||
p.setProperty("pause", true)
|
||||
cachedPaused = true
|
||||
pausedForSurfaceLoss = true
|
||||
Log.d(TAG, "Paused MPV for surface loss ($reason, epoch=$epoch)")
|
||||
publicPauseWriteMutex.withLock {
|
||||
val wasPaused = try {
|
||||
p.getFlag("pause") == true
|
||||
} catch (e: Exception) {
|
||||
pausedForSurfaceLoss = false
|
||||
Log.w(TAG, "Failed to pause MPV before placeholder attach ($reason)", e)
|
||||
cachedPaused
|
||||
}
|
||||
if (!wasPaused) {
|
||||
try {
|
||||
p.setProperty("pause", true)
|
||||
cachedPaused = true
|
||||
pausedForSurfaceLoss = true
|
||||
Log.d(TAG, "Paused MPV for surface loss ($reason, epoch=$epoch)")
|
||||
} catch (e: Exception) {
|
||||
pausedForSurfaceLoss = false
|
||||
Log.w(TAG, "Failed to pause MPV before placeholder attach ($reason)", e)
|
||||
}
|
||||
} else {
|
||||
pausedForSurfaceLoss = false
|
||||
}
|
||||
} else {
|
||||
pausedForSurfaceLoss = false
|
||||
}
|
||||
val surface = placeholderSurface?.takeIf { it.isValid } ?: run {
|
||||
Log.w(TAG, "No valid MPV placeholder surface available for $reason")
|
||||
@@ -638,29 +677,106 @@ class MpvPlayerCore(
|
||||
else -> null
|
||||
}
|
||||
|
||||
private fun pauseForAudioFocusLoss() {
|
||||
val shouldPause = synchronized(publicPauseIntentLock) {
|
||||
(!desiredPaused).also { pausedForAudioFocusLoss = it }
|
||||
}
|
||||
if (!shouldPause) {
|
||||
Log.d(TAG, "Skipping audio-focus pause because playback is already desirably paused")
|
||||
return
|
||||
}
|
||||
|
||||
scope.launch(mpvWriteDispatcher, start = CoroutineStart.ATOMIC) {
|
||||
try {
|
||||
publicPauseWriteMutex.withLock {
|
||||
if (!pausedForAudioFocusLoss || disposing) return@withLock
|
||||
writeProperty("pause", "yes")
|
||||
cachedPaused = true
|
||||
}
|
||||
} catch (error: CancellationException) {
|
||||
Log.d(TAG, "Canceled audio-focus pause")
|
||||
} catch (error: Exception) {
|
||||
Log.w(TAG, "Failed to pause on focus loss", error)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun resumeAfterAudioFocusGain(reason: String) {
|
||||
val shouldResume = synchronized(publicPauseIntentLock) {
|
||||
if (!pausedForAudioFocusLoss) {
|
||||
false
|
||||
} else {
|
||||
pausedForAudioFocusLoss = false
|
||||
true
|
||||
}
|
||||
}
|
||||
if (shouldResume) requestAutoResume(reason)
|
||||
}
|
||||
|
||||
private fun rollbackFailedPublicPauseIntent(intent: PublicPauseIntent) {
|
||||
synchronized(publicPauseIntentLock) {
|
||||
if (publicPauseIntentGeneration == intent.generation) {
|
||||
resumeBlockedByPublicPause = intent.previousBlocked
|
||||
desiredPaused = intent.previousDesiredPaused
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun completePublicResumeNoOp(onComplete: ((Result<Unit>) -> Unit)?) {
|
||||
runOnMain {
|
||||
val completion: Result<Unit> = if (disposing || !isInitialized || !scope.isActive) {
|
||||
Result.failure(CancellationException("MPV core unavailable"))
|
||||
} else {
|
||||
Result.success(Unit)
|
||||
}
|
||||
onComplete?.invoke(completion)
|
||||
}
|
||||
}
|
||||
|
||||
private fun requestAutoResume(reason: String) {
|
||||
val p = player ?: return
|
||||
val p = player
|
||||
if (p == null && propertyWriterOverride == null) return
|
||||
if (disposing) return
|
||||
|
||||
if (resumeBlockedByPublicPause) {
|
||||
deferredResumeRequested = false
|
||||
Log.d(TAG, "Skipping auto-resume after $reason because playback is explicitly paused")
|
||||
return
|
||||
val intentGeneration = synchronized(publicPauseIntentLock) {
|
||||
if (resumeBlockedByPublicPause) {
|
||||
deferredResumeRequested = false
|
||||
Log.d(TAG, "Skipping auto-resume after $reason because playback is explicitly paused")
|
||||
return
|
||||
}
|
||||
|
||||
if (!hasReadyVideoOutput()) {
|
||||
deferredResumeRequested = true
|
||||
Log.d(TAG, "Deferring auto-resume after $reason until video output is ready")
|
||||
return
|
||||
}
|
||||
publicPauseIntentGeneration
|
||||
}
|
||||
|
||||
if (!hasReadyVideoOutput()) {
|
||||
deferredResumeRequested = true
|
||||
Log.d(TAG, "Deferring auto-resume after $reason until video output is ready")
|
||||
return
|
||||
}
|
||||
|
||||
scope.launch {
|
||||
scope.launch(mpvWriteDispatcher) {
|
||||
try {
|
||||
if (p.getFlag("pause") == true) {
|
||||
Log.d(TAG, "Auto-resuming playback after $reason")
|
||||
p.setProperty("pause", false)
|
||||
} else {
|
||||
Log.d(TAG, "Skipping auto-resume after $reason because playback is already running")
|
||||
publicPauseWriteMutex.withLock {
|
||||
val shouldResume = synchronized(publicPauseIntentLock) {
|
||||
!pausedForAudioFocusLoss &&
|
||||
!resumeBlockedByPublicPause &&
|
||||
publicPauseIntentGeneration == intentGeneration
|
||||
}
|
||||
if (!shouldResume) {
|
||||
Log.d(TAG, "Skipping stale auto-resume after $reason")
|
||||
return@withLock
|
||||
}
|
||||
val isPaused = p?.getFlag("pause") ?: cachedPaused
|
||||
if (isPaused) {
|
||||
Log.d(TAG, "Auto-resuming playback after $reason")
|
||||
if (p != null) {
|
||||
p.setProperty("pause", false)
|
||||
} else {
|
||||
writeProperty("pause", "no")
|
||||
}
|
||||
cachedPaused = false
|
||||
} else {
|
||||
Log.d(TAG, "Skipping auto-resume after $reason because playback is already running")
|
||||
}
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
Log.w(TAG, "Failed to resume after $reason", e)
|
||||
@@ -669,64 +785,199 @@ class MpvPlayerCore(
|
||||
}
|
||||
|
||||
private suspend fun applyDeferredResumeIfNeeded(p: MpvPlayer, reason: String) {
|
||||
if (!deferredResumeRequested) return
|
||||
|
||||
if (resumeBlockedByPublicPause) {
|
||||
deferredResumeRequested = false
|
||||
Log.d(TAG, "Dropping deferred auto-resume after $reason because playback is explicitly paused")
|
||||
return
|
||||
publicPauseWriteMutex.withLock {
|
||||
val shouldResume = synchronized(publicPauseIntentLock) {
|
||||
if (!deferredResumeRequested) {
|
||||
false
|
||||
} else if (pausedForAudioFocusLoss) {
|
||||
Log.d(TAG, "Keeping deferred auto-resume pending after $reason until audio focus returns")
|
||||
false
|
||||
} else if (resumeBlockedByPublicPause) {
|
||||
deferredResumeRequested = false
|
||||
Log.d(TAG, "Dropping deferred auto-resume after $reason because playback is explicitly paused")
|
||||
false
|
||||
} else {
|
||||
deferredResumeRequested = false
|
||||
true
|
||||
}
|
||||
}
|
||||
if (!shouldResume) return@withLock
|
||||
if (p.getFlag("pause") == true) {
|
||||
Log.d(TAG, "Applying deferred auto-resume after $reason")
|
||||
p.setProperty("pause", false)
|
||||
cachedPaused = false
|
||||
} else {
|
||||
Log.d(TAG, "Skipping deferred auto-resume after $reason because playback is already running")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
deferredResumeRequested = false
|
||||
if (p.getFlag("pause") == true) {
|
||||
Log.d(TAG, "Applying deferred auto-resume after $reason")
|
||||
p.setProperty("pause", false)
|
||||
private suspend fun writeProperty(name: String, value: String) {
|
||||
val writer = propertyWriterOverride
|
||||
if (writer != null) {
|
||||
writer(name, value)
|
||||
} else {
|
||||
Log.d(TAG, "Skipping deferred auto-resume after $reason because playback is already running")
|
||||
val currentPlayer = player ?: throw CancellationException("MPV player unavailable")
|
||||
currentPlayer.setProperty(name, value)
|
||||
}
|
||||
}
|
||||
|
||||
// Public API
|
||||
/**
|
||||
* Atomically records the public pause intent applied by the next loadfile
|
||||
* operation. The load owns the native state transition, so this deliberately
|
||||
* does not enqueue a second pause property write.
|
||||
*/
|
||||
fun setPauseIntentForLoad(paused: Boolean) {
|
||||
if (!isInitialized || disposing || !scope.isActive) return
|
||||
|
||||
fun setProperty(name: String, value: String, onComplete: ((Boolean) -> Unit)? = null) {
|
||||
if (!isInitialized || disposing || !scope.isActive) {
|
||||
onComplete?.invoke(false)
|
||||
return
|
||||
}
|
||||
if (name == "pause") {
|
||||
val paused = normalizePauseValue(value)
|
||||
if (paused == true) {
|
||||
synchronized(publicPauseIntentLock) {
|
||||
publicPauseIntentGeneration += 1L
|
||||
desiredPaused = paused
|
||||
resumeBlockedByPublicPause = paused
|
||||
if (paused) {
|
||||
cachedPaused = true
|
||||
pausedForSurfaceLoss = false
|
||||
resumeBlockedByPublicPause = true
|
||||
pausedForAudioFocusLoss = false
|
||||
deferredResumeRequested = false
|
||||
Log.d(TAG, "Public pause state updated: paused=true")
|
||||
} else if (paused == false) {
|
||||
resumeBlockedByPublicPause = false
|
||||
if (!hasReadyVideoOutput()) {
|
||||
deferredResumeRequested = true
|
||||
Log.d(TAG, "Deferring public resume until video output is ready")
|
||||
onComplete?.invoke(true)
|
||||
return
|
||||
}
|
||||
} else if (!pausedForSurfaceLoss && !pausedForAudioFocusLoss && !deferredResumeRequested) {
|
||||
cachedPaused = false
|
||||
pausedForSurfaceLoss = false
|
||||
Log.d(TAG, "Public pause state updated: paused=false")
|
||||
}
|
||||
}
|
||||
scope.launch(mpvWriteDispatcher) {
|
||||
var success = false
|
||||
try {
|
||||
player?.setProperty(name, value)
|
||||
success = true
|
||||
} catch (e: Exception) {
|
||||
Log.w(TAG, "setProperty($name) failed", e)
|
||||
} finally {
|
||||
withContext(NonCancellable + Dispatchers.Main) {
|
||||
onComplete?.invoke(success)
|
||||
Log.d(TAG, "Load pause intent updated: paused=$paused")
|
||||
}
|
||||
|
||||
fun setProperty(name: String, value: String, onComplete: ((Result<Unit>) -> Unit)? = null) {
|
||||
if (!isInitialized || disposing || !scope.isActive) {
|
||||
onComplete?.invoke(Result.failure(CancellationException("MPV core unavailable")))
|
||||
return
|
||||
}
|
||||
|
||||
val paused = if (name == "pause") normalizePauseValue(value) else null
|
||||
val pauseIntent = paused?.let {
|
||||
synchronized(publicPauseIntentLock) {
|
||||
PublicPauseIntent(
|
||||
generation = ++publicPauseIntentGeneration,
|
||||
previousBlocked = resumeBlockedByPublicPause,
|
||||
previousDesiredPaused = desiredPaused
|
||||
).also {
|
||||
resumeBlockedByPublicPause = paused
|
||||
desiredPaused = paused
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (paused == false && pauseIntent != null) {
|
||||
val shouldReclaimAudioFocus = synchronized(publicPauseIntentLock) {
|
||||
publicPauseIntentGeneration == pauseIntent.generation && pausedForAudioFocusLoss
|
||||
}
|
||||
if (shouldReclaimAudioFocus) {
|
||||
val focusGranted = audioFocusManager?.requestAudioFocus() == true
|
||||
if (!focusGranted) {
|
||||
Log.w(TAG, "Audio focus request denied; keeping public resume pending")
|
||||
completePublicResumeNoOp(onComplete)
|
||||
return
|
||||
}
|
||||
|
||||
synchronized(publicPauseIntentLock) {
|
||||
if (publicPauseIntentGeneration == pauseIntent.generation && pausedForAudioFocusLoss) {
|
||||
pausedForAudioFocusLoss = false
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (paused == false && pauseIntent != null && !hasReadyVideoOutput()) {
|
||||
runOnMain {
|
||||
if (!isInitialized || disposing || !scope.isActive) {
|
||||
onComplete?.invoke(Result.failure(CancellationException("MPV core unavailable")))
|
||||
return@runOnMain
|
||||
}
|
||||
var deferredForSurface = false
|
||||
val interruptedAgain = synchronized(publicPauseIntentLock) {
|
||||
if (publicPauseIntentGeneration != pauseIntent.generation) {
|
||||
false
|
||||
} else if (pausedForAudioFocusLoss) {
|
||||
true
|
||||
} else {
|
||||
deferredResumeRequested = true
|
||||
deferredForSurface = true
|
||||
false
|
||||
}
|
||||
}
|
||||
if (interruptedAgain) {
|
||||
Log.d(TAG, "Public resume deferred by a newer audio-focus loss")
|
||||
onComplete?.invoke(Result.success(Unit))
|
||||
} else {
|
||||
if (deferredForSurface) {
|
||||
Log.d(TAG, "Deferring public resume until video output is ready")
|
||||
}
|
||||
onComplete?.invoke(Result.success(Unit))
|
||||
}
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
scope.launch(mpvWriteDispatcher, start = CoroutineStart.ATOMIC) {
|
||||
var interruptedBeforeWrite = false
|
||||
val writeResult = try {
|
||||
if (pauseIntent == null) {
|
||||
writeProperty(name, value)
|
||||
} else {
|
||||
publicPauseWriteMutex.withLock {
|
||||
val shouldWrite = synchronized(publicPauseIntentLock) {
|
||||
val isCurrent = publicPauseIntentGeneration == pauseIntent.generation
|
||||
if (isCurrent && paused == false && pausedForAudioFocusLoss) {
|
||||
interruptedBeforeWrite = true
|
||||
false
|
||||
} else {
|
||||
isCurrent
|
||||
}
|
||||
}
|
||||
if (shouldWrite) writeProperty(name, value)
|
||||
}
|
||||
}
|
||||
if (interruptedBeforeWrite) {
|
||||
Log.d(TAG, "Public resume write skipped after a newer audio-focus loss")
|
||||
}
|
||||
Result.success(Unit)
|
||||
} catch (error: CancellationException) {
|
||||
Result.failure(error)
|
||||
} catch (error: Exception) {
|
||||
Log.w(TAG, "MPV property write failed")
|
||||
Result.failure(error)
|
||||
}
|
||||
|
||||
if (writeResult.isFailure && pauseIntent != null) {
|
||||
rollbackFailedPublicPauseIntent(pauseIntent)
|
||||
}
|
||||
|
||||
withContext(NonCancellable + Dispatchers.Main) {
|
||||
val completion = if (disposing || !isInitialized) {
|
||||
Result.failure(CancellationException("MPV core unavailable"))
|
||||
} else {
|
||||
writeResult
|
||||
}
|
||||
val isCurrent = pauseIntent == null ||
|
||||
synchronized(publicPauseIntentLock) {
|
||||
publicPauseIntentGeneration == pauseIntent.generation
|
||||
}
|
||||
if (isCurrent && completion.isSuccess && !interruptedBeforeWrite) {
|
||||
if (paused == true) {
|
||||
cachedPaused = true
|
||||
pausedForSurfaceLoss = false
|
||||
deferredResumeRequested = false
|
||||
Log.d(TAG, "Public pause state updated: paused=true")
|
||||
} else if (paused == false) {
|
||||
cachedPaused = false
|
||||
pausedForSurfaceLoss = false
|
||||
deferredResumeRequested = false
|
||||
Log.d(TAG, "Public pause state updated: paused=false")
|
||||
}
|
||||
}
|
||||
onComplete?.invoke(completion)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun getProperty(name: String): String? {
|
||||
@@ -847,7 +1098,7 @@ class MpvPlayerCore(
|
||||
}
|
||||
}
|
||||
|
||||
fun setVisible(visible: Boolean) {
|
||||
override fun setVisible(visible: Boolean) {
|
||||
// Audio-only: no render layer to show or hide — tolerated no-op.
|
||||
if (audioOnly || disposing) return
|
||||
runOnMain {
|
||||
@@ -872,11 +1123,11 @@ class MpvPlayerCore(
|
||||
}
|
||||
}
|
||||
|
||||
fun onPipModeChanged(isInPipMode: Boolean) {
|
||||
override fun onPipModeChanged(isInPipMode: Boolean) {
|
||||
// MPV handles aspect ratio internally via its own surface management
|
||||
}
|
||||
|
||||
fun updateFrame() {
|
||||
override fun updateFrame() {
|
||||
// Audio-only: no surface to refresh — tolerated no-op.
|
||||
if (audioOnly || disposing) return
|
||||
runOnMain {
|
||||
@@ -911,7 +1162,7 @@ class MpvPlayerCore(
|
||||
|
||||
// Frame Rate Matching
|
||||
|
||||
fun setVideoFrameRate(
|
||||
override fun setVideoFrameRate(
|
||||
fps: Float,
|
||||
videoDurationMs: Long,
|
||||
extraDelayMs: Long,
|
||||
@@ -933,7 +1184,7 @@ class MpvPlayerCore(
|
||||
}
|
||||
}
|
||||
|
||||
fun clearVideoFrameRate() {
|
||||
override fun clearVideoFrameRate() {
|
||||
frameRateManager?.clearVideoFrameRate()
|
||||
}
|
||||
|
||||
@@ -1003,10 +1254,15 @@ class MpvPlayerCore(
|
||||
placeholderImageReader?.close()
|
||||
placeholderImageReader = null
|
||||
pausedForSurfaceLoss = false
|
||||
pausedForAudioFocusLoss = false
|
||||
attachedToPlaceholder = false
|
||||
videoOutputRestoring = false
|
||||
deferredResumeRequested = false
|
||||
resumeBlockedByPublicPause = false
|
||||
synchronized(publicPauseIntentLock) {
|
||||
publicPauseIntentGeneration += 1L
|
||||
resumeBlockedByPublicPause = false
|
||||
desiredPaused = true
|
||||
}
|
||||
videoOutputEpoch = 0L
|
||||
pendingVideoOutputDisableJob = null
|
||||
isInitialized = false
|
||||
|
||||
@@ -12,6 +12,24 @@ import io.flutter.embedding.engine.plugins.activity.ActivityPluginBinding
|
||||
import io.flutter.plugin.common.EventChannel
|
||||
import io.flutter.plugin.common.MethodCall
|
||||
import io.flutter.plugin.common.MethodChannel
|
||||
import java.util.concurrent.CancellationException
|
||||
|
||||
internal fun completeMpvPropertyResult(
|
||||
result: MethodChannel.Result,
|
||||
outcome: Result<Unit>,
|
||||
successValue: Any? = null
|
||||
) {
|
||||
val failure = outcome.exceptionOrNull()
|
||||
when {
|
||||
failure == null -> result.success(successValue)
|
||||
failure is CancellationException -> completeMpvPropertyNotInitialized(result)
|
||||
else -> result.error("SET_PROPERTY_FAILED", "MPV property write was rejected", null)
|
||||
}
|
||||
}
|
||||
|
||||
internal fun completeMpvPropertyNotInitialized(result: MethodChannel.Result) {
|
||||
result.error("NOT_INITIALIZED", "Player not initialized", null)
|
||||
}
|
||||
|
||||
/**
|
||||
* Channel plumbing for [MpvPlayerCore]. The default instance is the video
|
||||
@@ -53,6 +71,8 @@ open class MpvPlayerPlugin(
|
||||
private val pendingInitResults = mutableListOf<MethodChannel.Result>()
|
||||
|
||||
@Volatile private var isInitializing = false
|
||||
private var initAttemptCounter = 0
|
||||
private var activeInitAttempt: Int? = null
|
||||
|
||||
// FlutterPlugin
|
||||
|
||||
@@ -62,22 +82,26 @@ open class MpvPlayerPlugin(
|
||||
}
|
||||
|
||||
override fun onDetachedFromEngine(binding: FlutterPlugin.FlutterPluginBinding) {
|
||||
channels.detach()
|
||||
if (audioOnly) {
|
||||
// The audio core is not activity-bound; engine detach is its terminal
|
||||
// native lifecycle event (mirrors the video core's activity detach).
|
||||
disposeCoreForTeardown()
|
||||
}
|
||||
// Engine detach is terminal for both video and audio plugin instances.
|
||||
// Dispose before detaching channels so no native work can publish into a
|
||||
// dead messenger.
|
||||
disposeCoreForTeardown()
|
||||
activity = null
|
||||
activityBinding = null
|
||||
applicationContext = null
|
||||
channels.detach()
|
||||
}
|
||||
|
||||
private fun takeCoreForTeardown(): MpvPlayerCore? {
|
||||
++sessionGeneration
|
||||
val core = playerCore
|
||||
playerCore = null
|
||||
cancelPendingInits()
|
||||
return core
|
||||
}
|
||||
|
||||
private fun disposeCoreForTeardown() {
|
||||
++sessionGeneration
|
||||
playerCore?.dispose()
|
||||
playerCore = null
|
||||
// Any in-flight init callback would never fire (its scope is cancelled
|
||||
// by dispose), so close out queued callers explicitly.
|
||||
completePendingInits(success = false)
|
||||
takeCoreForTeardown()?.dispose()
|
||||
}
|
||||
|
||||
// ActivityAware
|
||||
@@ -106,6 +130,12 @@ open class MpvPlayerPlugin(
|
||||
}
|
||||
|
||||
override fun onDetachedFromActivityForConfigChanges() {
|
||||
// The video core owns views and window services from this Activity. Plezy
|
||||
// does not retain the engine across configuration recreation, so there is
|
||||
// no Activity-transfer contract under which that core may survive.
|
||||
if (!audioOnly) {
|
||||
disposeCoreForTeardown()
|
||||
}
|
||||
activity = null
|
||||
activityBinding = null
|
||||
Log.d(tag, "Detached from activity for config changes")
|
||||
@@ -169,16 +199,29 @@ open class MpvPlayerPlugin(
|
||||
// call's outcome instead of disposing the in-flight core. The Dart
|
||||
// side memoizes too, but this is defense in depth for any direct
|
||||
// `invoke('initialize')` that bypasses _ensureInitialized.
|
||||
synchronized(pendingInitResults) {
|
||||
val attempt = synchronized(pendingInitResults) {
|
||||
pendingInitResults += result
|
||||
if (isInitializing) {
|
||||
Log.d(tag, "Init already in flight, queuing caller")
|
||||
return
|
||||
null
|
||||
} else {
|
||||
isInitializing = true
|
||||
(++initAttemptCounter).also { activeInitAttempt = it }
|
||||
}
|
||||
isInitializing = true
|
||||
}
|
||||
if (attempt == null) {
|
||||
Log.d(tag, "Init already in flight, queuing caller")
|
||||
return
|
||||
}
|
||||
|
||||
runOnMain {
|
||||
if (!isCurrentInitAttempt(attempt) ||
|
||||
(!audioOnly && activity !== coreContext) ||
|
||||
(audioOnly && applicationContext !== coreContext)
|
||||
) {
|
||||
completePendingInits(attempt, success = false)
|
||||
return@runOnMain
|
||||
}
|
||||
|
||||
val gen: Int
|
||||
val core: MpvPlayerCore
|
||||
try {
|
||||
@@ -198,28 +241,58 @@ open class MpvPlayerPlugin(
|
||||
playerCore = core
|
||||
} catch (e: Exception) {
|
||||
Log.e(tag, "Failed to initialize: ${e.message}", e)
|
||||
completePendingInits(success = false, errorMessage = e.message)
|
||||
completePendingInits(attempt, success = false, errorMessage = e.message)
|
||||
return@runOnMain
|
||||
}
|
||||
|
||||
core.initialize { success ->
|
||||
val stale = gen != sessionGeneration || playerCore !== core
|
||||
if (stale) {
|
||||
Log.d(tag, "Stale init callback (gen=$gen, current=$sessionGeneration)")
|
||||
val stale = gen != sessionGeneration ||
|
||||
playerCore !== core ||
|
||||
!isCurrentInitAttempt(attempt)
|
||||
if (stale || !success) {
|
||||
if (playerCore === core) playerCore = null
|
||||
core.dispose()
|
||||
if (stale) {
|
||||
Log.d(tag, "Stale init callback (gen=$gen, current=$sessionGeneration)")
|
||||
} else {
|
||||
Log.d(tag, "Initialized: false")
|
||||
}
|
||||
} else {
|
||||
// Start hidden - now safe because setVisible operates on the container,
|
||||
// not the SurfaceView directly (matching ExoPlayer's approach).
|
||||
// No-op on the audio-only core, which has no render layer.
|
||||
core.setVisible(false)
|
||||
Log.d(tag, "Initialized: $success")
|
||||
Log.d(tag, "Initialized: true")
|
||||
}
|
||||
completePendingInits(success = !stale && success)
|
||||
completePendingInits(attempt, success = !stale && success)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun completePendingInits(success: Boolean, errorMessage: String? = null) {
|
||||
private fun isCurrentInitAttempt(attempt: Int): Boolean = synchronized(pendingInitResults) {
|
||||
isInitializing && activeInitAttempt == attempt
|
||||
}
|
||||
|
||||
private fun cancelPendingInits() {
|
||||
val pending = synchronized(pendingInitResults) {
|
||||
++initAttemptCounter
|
||||
activeInitAttempt = null
|
||||
isInitializing = false
|
||||
val copy = pendingInitResults.toList()
|
||||
pendingInitResults.clear()
|
||||
copy
|
||||
}
|
||||
pending.forEach { it.success(false) }
|
||||
}
|
||||
|
||||
internal fun completePendingInits(
|
||||
attempt: Int,
|
||||
success: Boolean,
|
||||
errorMessage: String? = null
|
||||
) {
|
||||
val pending = synchronized(pendingInitResults) {
|
||||
if (activeInitAttempt != attempt) return
|
||||
activeInitAttempt = null
|
||||
isInitializing = false
|
||||
val copy = pendingInitResults.toList()
|
||||
pendingInitResults.clear()
|
||||
@@ -236,14 +309,7 @@ open class MpvPlayerPlugin(
|
||||
|
||||
private fun handleDispose(result: MethodChannel.Result) {
|
||||
runOnMain {
|
||||
val core = playerCore
|
||||
++sessionGeneration
|
||||
playerCore = null
|
||||
|
||||
// Any in-flight init callback is cancelled with the scope, so
|
||||
// close out queued callers here instead of leaking them.
|
||||
completePendingInits(success = false)
|
||||
|
||||
val core = takeCoreForTeardown()
|
||||
core?.dispose {
|
||||
Log.d(tag, "Disposed")
|
||||
result.success(null)
|
||||
@@ -261,13 +327,16 @@ open class MpvPlayerPlugin(
|
||||
}
|
||||
|
||||
val core = playerCore
|
||||
if (core == null) {
|
||||
result.success(null)
|
||||
if (core?.isInitialized != true) {
|
||||
completeMpvPropertyNotInitialized(result)
|
||||
return
|
||||
}
|
||||
|
||||
core.setProperty(name, value) {
|
||||
result.success(null)
|
||||
core.setProperty(name, value) { outcome ->
|
||||
if (outcome.isFailure && outcome.exceptionOrNull() !is CancellationException) {
|
||||
Log.w(tag, "MPV rejected property '$name'; keeping the previous value")
|
||||
}
|
||||
completeMpvPropertyResult(result, outcome)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -14,25 +14,28 @@ internal class PlayerChannelBinding(
|
||||
private val streamHandler: EventChannel.StreamHandler,
|
||||
private val logTag: String
|
||||
) {
|
||||
private lateinit var methodChannel: MethodChannel
|
||||
private lateinit var eventChannel: EventChannel
|
||||
private var methodChannel: MethodChannel? = null
|
||||
private var eventChannel: EventChannel? = null
|
||||
private var eventSink: EventChannel.EventSink? = null
|
||||
|
||||
val mainHandler = Handler(Looper.getMainLooper())
|
||||
|
||||
fun attach(binding: FlutterPlugin.FlutterPluginBinding) {
|
||||
methodChannel = MethodChannel(binding.binaryMessenger, channelBase)
|
||||
methodChannel.setMethodCallHandler(methodCallHandler)
|
||||
|
||||
eventChannel = EventChannel(binding.binaryMessenger, "$channelBase/events")
|
||||
eventChannel.setStreamHandler(streamHandler)
|
||||
methodChannel = MethodChannel(binding.binaryMessenger, channelBase).also {
|
||||
it.setMethodCallHandler(methodCallHandler)
|
||||
}
|
||||
|
||||
eventChannel = EventChannel(binding.binaryMessenger, "$channelBase/events").also {
|
||||
it.setStreamHandler(streamHandler)
|
||||
}
|
||||
Log.d(logTag, "Attached to engine")
|
||||
}
|
||||
|
||||
fun detach() {
|
||||
methodChannel.setMethodCallHandler(null)
|
||||
eventChannel.setStreamHandler(null)
|
||||
methodChannel?.setMethodCallHandler(null)
|
||||
eventChannel?.setStreamHandler(null)
|
||||
methodChannel = null
|
||||
eventChannel = null
|
||||
eventSink = null
|
||||
Log.d(logTag, "Detached from engine")
|
||||
}
|
||||
|
||||
@@ -0,0 +1,27 @@
|
||||
package com.edde746.plezy.shared
|
||||
|
||||
/**
|
||||
* Surface and display concerns that the ExoPlayer and mpv cores implement
|
||||
* identically, so a plugin holding either one dispatches without branching
|
||||
* on which backend is active.
|
||||
*
|
||||
* Only backend-independent members belong here: playback control
|
||||
* (play/seek/track selection) stays off this interface because mpv drives it
|
||||
* through properties and commands where ExoPlayer uses direct method calls.
|
||||
*/
|
||||
interface SurfacePlayerCore {
|
||||
fun setVisible(visible: Boolean)
|
||||
fun updateFrame()
|
||||
fun onPipModeChanged(isInPipMode: Boolean)
|
||||
fun requestAudioFocus(): Boolean
|
||||
fun abandonAudioFocus()
|
||||
fun clearVideoFrameRate()
|
||||
fun setVideoFrameRate(
|
||||
fps: Float,
|
||||
videoDurationMs: Long,
|
||||
extraDelayMs: Long,
|
||||
videoWidth: Int,
|
||||
videoHeight: Int,
|
||||
onComplete: (switched: Boolean) -> Unit
|
||||
)
|
||||
}
|
||||
+522
@@ -0,0 +1,522 @@
|
||||
package com.edde746.plezy.watchnext
|
||||
|
||||
import android.content.ContentProvider
|
||||
import android.content.ContentValues
|
||||
import android.database.Cursor
|
||||
import android.graphics.BitmapFactory
|
||||
import android.net.Uri
|
||||
import android.os.Binder
|
||||
import android.os.ParcelFileDescriptor
|
||||
import android.os.Process
|
||||
import android.system.Os
|
||||
import java.io.ByteArrayOutputStream
|
||||
import java.io.File
|
||||
import java.io.FileNotFoundException
|
||||
import java.io.RandomAccessFile
|
||||
import java.net.HttpURLConnection
|
||||
import java.net.URL
|
||||
import java.security.MessageDigest
|
||||
import java.util.concurrent.Executors
|
||||
import java.util.concurrent.ScheduledExecutorService
|
||||
import java.util.concurrent.TimeUnit
|
||||
|
||||
class SystemShelfArtworkProvider : ContentProvider() {
|
||||
companion object {
|
||||
const val AUTHORITY = "com.edde746.plezy.systemshelf.artwork"
|
||||
}
|
||||
|
||||
override fun onCreate(): Boolean = context != null
|
||||
|
||||
override fun openFile(uri: Uri, mode: String): ParcelFileDescriptor {
|
||||
if (mode != "r") throw FileNotFoundException("Read-only artwork")
|
||||
val appContext = context ?: throw FileNotFoundException("Provider unavailable")
|
||||
val callingUid = Binder.getCallingUid()
|
||||
if (callingUid != Process.myUid()) {
|
||||
val homeIntent = android.content.Intent(android.content.Intent.ACTION_MAIN)
|
||||
.addCategory(android.content.Intent.CATEGORY_HOME)
|
||||
val homePackage = appContext.packageManager
|
||||
.resolveActivity(homeIntent, android.content.pm.PackageManager.MATCH_DEFAULT_ONLY)
|
||||
?.activityInfo
|
||||
?.packageName
|
||||
val callerPackages = appContext.packageManager.getPackagesForUid(callingUid)
|
||||
if (homePackage == null || callerPackages == null || homePackage !in callerPackages) {
|
||||
throw FileNotFoundException("Artwork caller is not the active HOME launcher")
|
||||
}
|
||||
}
|
||||
val file = SystemShelfArtworkStore(appContext.cacheDir).resolve(uri)
|
||||
?: throw FileNotFoundException("Unknown artwork")
|
||||
return ParcelFileDescriptor.open(file, ParcelFileDescriptor.MODE_READ_ONLY)
|
||||
}
|
||||
|
||||
override fun getType(uri: Uri): String? = if (uri.authority == AUTHORITY) "image/*" else null
|
||||
override fun query(
|
||||
uri: Uri,
|
||||
projection: Array<out String>?,
|
||||
selection: String?,
|
||||
selectionArgs: Array<out String>?,
|
||||
sortOrder: String?
|
||||
): Cursor? = null
|
||||
override fun insert(uri: Uri, values: ContentValues?): Uri? = null
|
||||
override fun update(uri: Uri, values: ContentValues?, selection: String?, selectionArgs: Array<out String>?): Int = 0
|
||||
override fun delete(uri: Uri, selection: String?, selectionArgs: Array<out String>?): Int = 0
|
||||
}
|
||||
|
||||
internal class SystemShelfSyncSession(
|
||||
internal val ownership: SystemShelfLifecycle.Ownership,
|
||||
durationMillis: Long,
|
||||
private val nanoTime: () -> Long = System::nanoTime,
|
||||
internal val budget: SystemShelfArtworkStore.Budget = SystemShelfArtworkStore.Budget()
|
||||
) {
|
||||
private val deadlineNanos = nanoTime() + TimeUnit.MILLISECONDS.toNanos(durationMillis)
|
||||
|
||||
fun isExpired(): Boolean = nanoTime() >= deadlineNanos
|
||||
|
||||
fun isActive(): Boolean = !isExpired() && SystemShelfLifecycle.isCurrent(ownership)
|
||||
|
||||
fun remainingNanos(): Long = (deadlineNanos - nanoTime()).coerceAtLeast(0)
|
||||
|
||||
fun commitIfActive(block: () -> Boolean): Boolean = SystemShelfLifecycle.whileCurrent(ownership) {
|
||||
if (isExpired()) false else block()
|
||||
} ?: false
|
||||
}
|
||||
|
||||
internal class SystemShelfArtworkStore(private val cacheDir: File) {
|
||||
companion object {
|
||||
const val MAX_IMAGE_BYTES = 2 * 1024 * 1024
|
||||
const val MAX_SYNC_BYTES = 8 * 1024 * 1024
|
||||
const val MAX_ITEMS = 20
|
||||
const val MAX_SYNC_DURATION_MS = 10_000L
|
||||
const val CONNECT_TIMEOUT_MS = 2_500
|
||||
const val READ_TIMEOUT_MS = 2_500
|
||||
private val opaquePart = Regex("^[a-f0-9]{64}$")
|
||||
private val artworkKey = Regex("^(?:[a-f0-9]{32}|[a-f0-9]{64})\\.art$")
|
||||
private val deadlineAborter: ScheduledExecutorService =
|
||||
Executors.newSingleThreadScheduledExecutor { runnable ->
|
||||
Thread(runnable, "system-shelf-deadline").apply { isDaemon = true }
|
||||
}
|
||||
}
|
||||
|
||||
data class Materialized(val uri: Uri, val file: File)
|
||||
|
||||
sealed class Prepared {
|
||||
abstract val materialized: Materialized
|
||||
|
||||
data class Existing(override val materialized: Materialized) : Prepared()
|
||||
|
||||
data class Staged(
|
||||
override val materialized: Materialized,
|
||||
val stagingFile: File
|
||||
) : Prepared()
|
||||
}
|
||||
|
||||
data class Publication(val materialized: Materialized, val newlyPublished: Boolean)
|
||||
|
||||
class Budget(var remaining: Int = MAX_SYNC_BYTES) {
|
||||
var consumed: Long = 0
|
||||
private set
|
||||
|
||||
fun charge(bytes: Int): Boolean {
|
||||
if (bytes <= 0) return true
|
||||
consumed += bytes
|
||||
if (bytes > remaining) {
|
||||
remaining = 0
|
||||
return false
|
||||
}
|
||||
remaining -= bytes
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
private val root: File get() = File(cacheDir, "system_shelf_artwork")
|
||||
|
||||
fun prepare(ownerId: String, source: String, session: SystemShelfSyncSession): Prepared? {
|
||||
if (ownerId.isBlank()) return null
|
||||
val url = runCatching { URL(source) }.getOrNull() ?: return null
|
||||
if (url.protocol != "https" && url.protocol != "http") return null
|
||||
if (!session.isActive()) return null
|
||||
|
||||
val ownerKey = sha256(ownerId)
|
||||
val key = sha256(ownerId + "\u0000" + source) + ".art"
|
||||
val uri = contentUri(ownerKey, key)
|
||||
val destination = File(File(root, ownerKey), key)
|
||||
val materialized = Materialized(uri, destination)
|
||||
resolveOwned(ownerId, uri)?.let { existing ->
|
||||
return Prepared.Existing(materialized.copy(file = existing))
|
||||
}
|
||||
if (session.budget.remaining <= 0) return null
|
||||
|
||||
val remainingNanos = session.remainingNanos()
|
||||
if (remainingNanos <= 0) return null
|
||||
val connection = runCatching { url.openConnection() as? HttpURLConnection }.getOrNull()
|
||||
?: return null
|
||||
val abort = deadlineAborter.schedule(
|
||||
{ connection.disconnect() },
|
||||
remainingNanos,
|
||||
TimeUnit.NANOSECONDS
|
||||
)
|
||||
var stagingFile: File? = null
|
||||
var retainStagingFile = false
|
||||
return try {
|
||||
val remainingMillis = TimeUnit.NANOSECONDS.toMillis(remainingNanos)
|
||||
.coerceIn(1, Int.MAX_VALUE.toLong())
|
||||
.toInt()
|
||||
connection.instanceFollowRedirects = true
|
||||
connection.connectTimeout = minOf(CONNECT_TIMEOUT_MS, remainingMillis)
|
||||
connection.readTimeout = minOf(READ_TIMEOUT_MS, remainingMillis)
|
||||
connection.useCaches = false
|
||||
connection.setRequestProperty("Accept", "image/*")
|
||||
val status = connection.responseCode
|
||||
if (!session.isActive() || status !in 200..299) return null
|
||||
if (connection.url.protocol != "https" && connection.url.protocol != "http") return null
|
||||
if (!connection.contentType.orEmpty().substringBefore(';').trim().startsWith("image/")) return null
|
||||
val contentLength = connection.contentLengthLong
|
||||
val cap = minOf(MAX_IMAGE_BYTES, session.budget.remaining)
|
||||
if (contentLength > cap) return null
|
||||
val bytes = connection.inputStream.use { input ->
|
||||
val output = ByteArrayOutputStream(
|
||||
minOf(if (contentLength > 0) contentLength.toInt() else 32 * 1024, cap)
|
||||
)
|
||||
val buffer = ByteArray(16 * 1024)
|
||||
var total = 0
|
||||
while (true) {
|
||||
if (!session.isActive()) return null
|
||||
val remaining = minOf(MAX_IMAGE_BYTES - total, session.budget.remaining)
|
||||
if (remaining <= 0) {
|
||||
if (contentLength >= 0 && total.toLong() == contentLength) break
|
||||
return null
|
||||
}
|
||||
val read = input.read(buffer, 0, minOf(buffer.size, remaining))
|
||||
if (read < 0) break
|
||||
if (!session.budget.charge(read)) return null
|
||||
total += read
|
||||
output.write(buffer, 0, read)
|
||||
}
|
||||
output.toByteArray()
|
||||
}
|
||||
if (!session.isActive() || !isSupportedImage(bytes)) return null
|
||||
|
||||
val directory = destination.parentFile ?: return null
|
||||
if (!directory.mkdirs() && !directory.isDirectory) return null
|
||||
val staged = File.createTempFile(".$key.", ".tmp", directory)
|
||||
stagingFile = staged
|
||||
staged.outputStream().use { output ->
|
||||
output.write(bytes)
|
||||
output.flush()
|
||||
output.fd.sync()
|
||||
}
|
||||
if (!isSupportedImage(staged) || !session.isActive()) return null
|
||||
retainStagingFile = true
|
||||
Prepared.Staged(materialized, staged)
|
||||
} catch (_: Exception) {
|
||||
null
|
||||
} finally {
|
||||
if (!retainStagingFile) stagingFile?.delete()
|
||||
abort.cancel(false)
|
||||
connection.disconnect()
|
||||
}
|
||||
}
|
||||
|
||||
fun publish(prepared: Prepared): Publication? {
|
||||
val expected = confinedCandidate(prepared.materialized.uri) ?: return null
|
||||
val recorded = runCatching { prepared.materialized.file.canonicalFile }.getOrNull() ?: return null
|
||||
if (recorded != expected) return null
|
||||
|
||||
resolve(prepared.materialized.uri)?.let { existing ->
|
||||
if (prepared is Prepared.Staged && !deleteStagingFile(prepared.stagingFile, expected)) {
|
||||
return null
|
||||
}
|
||||
return Publication(prepared.materialized.copy(file = existing), newlyPublished = false)
|
||||
}
|
||||
if (prepared !is Prepared.Staged) return null
|
||||
val staged = confinedStagingFile(prepared.stagingFile, expected) ?: return null
|
||||
if (!isSupportedImage(staged)) return null
|
||||
|
||||
val renamed = try {
|
||||
Os.rename(staged.absolutePath, expected.absolutePath)
|
||||
!staged.exists() || staged.renameTo(expected)
|
||||
} catch (_: Exception) {
|
||||
runCatching { staged.renameTo(expected) }.getOrDefault(false)
|
||||
}
|
||||
if (!renamed) return null
|
||||
return if (!isSupportedImage(expected)) {
|
||||
expected.delete()
|
||||
null
|
||||
} else {
|
||||
Publication(prepared.materialized.copy(file = expected), newlyPublished = true)
|
||||
}
|
||||
}
|
||||
|
||||
fun discard(prepared: Iterable<Prepared>) {
|
||||
prepared.forEach { candidate ->
|
||||
if (candidate is Prepared.Staged) {
|
||||
deleteStagingFile(candidate.stagingFile, candidate.materialized.file)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun contentUri(ownerKey: String, key: String): Uri = Uri.Builder()
|
||||
.scheme("content")
|
||||
.authority(SystemShelfArtworkProvider.AUTHORITY)
|
||||
.appendPath("art")
|
||||
.appendPath(ownerKey)
|
||||
.appendPath(key)
|
||||
.build()
|
||||
|
||||
fun resolve(uri: Uri): File? {
|
||||
val candidate = confinedCandidate(uri) ?: return null
|
||||
return candidate.takeIf(::isSupportedImage)
|
||||
}
|
||||
|
||||
fun resolveOwned(ownerId: String, uri: Uri): File? {
|
||||
if (ownerId.isBlank() || uri.pathSegments.getOrNull(1) != sha256(ownerId)) return null
|
||||
return resolve(uri)
|
||||
}
|
||||
|
||||
fun deleteExcept(keep: Set<File>) {
|
||||
val canonicalKeep = keep.mapNotNullTo(HashSet()) {
|
||||
runCatching { it.canonicalFile }.getOrNull()
|
||||
}
|
||||
val canonicalRoot = runCatching { root.canonicalFile }.getOrNull() ?: return
|
||||
for (ownerDirectory in root.listFiles().orEmpty()) {
|
||||
for (file in ownerDirectory.listFiles().orEmpty()) {
|
||||
val candidate = confinedCacheFile(file, canonicalRoot) ?: continue
|
||||
if (candidate !in canonicalKeep) candidate.delete()
|
||||
}
|
||||
}
|
||||
removeEmptyDirectories()
|
||||
}
|
||||
|
||||
fun delete(files: Set<File>) {
|
||||
val canonicalRoot = runCatching { root.canonicalFile }.getOrNull() ?: return
|
||||
files.forEach { file ->
|
||||
confinedCacheFile(file, canonicalRoot)?.delete()
|
||||
}
|
||||
removeEmptyDirectories()
|
||||
}
|
||||
|
||||
fun deleteAll(): Boolean = !root.exists() || root.deleteRecursively()
|
||||
|
||||
private fun confinedCandidate(uri: Uri): File? {
|
||||
if (uri.scheme != "content" || uri.authority != SystemShelfArtworkProvider.AUTHORITY) return null
|
||||
val segments = uri.pathSegments
|
||||
if (segments.size != 3 || segments[0] != "art") return null
|
||||
val owner = segments[1]
|
||||
val key = segments[2]
|
||||
if (!opaquePart.matches(owner) || !artworkKey.matches(key)) return null
|
||||
|
||||
return runCatching {
|
||||
val canonicalRoot = root.canonicalFile
|
||||
val ownerDirectory = File(canonicalRoot, owner).canonicalFile
|
||||
if (ownerDirectory.parentFile != canonicalRoot || ownerDirectory.name != owner) return null
|
||||
val candidate = File(ownerDirectory, key).canonicalFile
|
||||
if (candidate.parentFile != ownerDirectory || candidate.name != key) return null
|
||||
candidate
|
||||
}.getOrNull()
|
||||
}
|
||||
|
||||
private fun confinedCacheFile(file: File, canonicalRoot: File): File? {
|
||||
return runCatching {
|
||||
val originalDirectory = file.parentFile ?: return null
|
||||
val directory = originalDirectory.canonicalFile
|
||||
if (
|
||||
directory.parentFile != canonicalRoot ||
|
||||
directory.name != originalDirectory.name
|
||||
) {
|
||||
return null
|
||||
}
|
||||
val candidate = file.canonicalFile
|
||||
if (candidate.parentFile != directory || candidate.name != file.name) return null
|
||||
candidate
|
||||
}.getOrNull()
|
||||
}
|
||||
|
||||
private fun confinedStagingFile(stagingFile: File, destination: File): File? {
|
||||
val staged = runCatching { stagingFile.canonicalFile }.getOrNull() ?: return null
|
||||
val canonicalDestination = runCatching { destination.canonicalFile }.getOrNull() ?: return null
|
||||
val directory = canonicalDestination.parentFile ?: return null
|
||||
if (
|
||||
staged == canonicalDestination ||
|
||||
staged.parentFile != directory ||
|
||||
!staged.name.startsWith(".${canonicalDestination.name}.") ||
|
||||
!staged.name.endsWith(".tmp") ||
|
||||
!staged.isFile
|
||||
) {
|
||||
return null
|
||||
}
|
||||
return staged
|
||||
}
|
||||
|
||||
private fun deleteStagingFile(stagingFile: File, destination: File): Boolean {
|
||||
if (!stagingFile.exists()) return true
|
||||
return confinedStagingFile(stagingFile, destination)?.delete() == true
|
||||
}
|
||||
|
||||
private fun removeEmptyDirectories() {
|
||||
root.listFiles()?.forEach { directory ->
|
||||
if (directory.listFiles().isNullOrEmpty()) directory.delete()
|
||||
}
|
||||
}
|
||||
|
||||
private enum class ImageFormat {
|
||||
PNG,
|
||||
JPEG,
|
||||
GIF,
|
||||
WEBP
|
||||
}
|
||||
|
||||
private fun isSupportedImage(file: File): Boolean {
|
||||
val length = file.length()
|
||||
if (!file.isFile || length !in 1L..MAX_IMAGE_BYTES.toLong()) return false
|
||||
val header = ByteArray(12)
|
||||
val headerSize = runCatching {
|
||||
file.inputStream().use { input ->
|
||||
var total = 0
|
||||
while (total < header.size) {
|
||||
val read = input.read(header, total, header.size - total)
|
||||
if (read <= 0) break
|
||||
total += read
|
||||
}
|
||||
total
|
||||
}
|
||||
}.getOrNull() ?: return false
|
||||
val format = imageFormat(header, headerSize) ?: return false
|
||||
if (!hasCompleteContainer(file, format, header, length)) return false
|
||||
|
||||
val options = BitmapFactory.Options().apply { inJustDecodeBounds = true }
|
||||
return runCatching {
|
||||
BitmapFactory.decodeFile(file.absolutePath, options)
|
||||
hasSupportedDimensions(options)
|
||||
}.getOrDefault(false)
|
||||
}
|
||||
|
||||
private fun isSupportedImage(bytes: ByteArray): Boolean {
|
||||
val format = imageFormat(bytes, bytes.size) ?: return false
|
||||
if (!hasCompleteContainer(bytes, format)) return false
|
||||
val options = BitmapFactory.Options().apply { inJustDecodeBounds = true }
|
||||
return runCatching {
|
||||
BitmapFactory.decodeByteArray(bytes, 0, bytes.size, options)
|
||||
hasSupportedDimensions(options)
|
||||
}.getOrDefault(false)
|
||||
}
|
||||
|
||||
private fun imageFormat(bytes: ByteArray, size: Int): ImageFormat? {
|
||||
if (
|
||||
size >= 8 &&
|
||||
bytes[0] == 0x89.toByte() &&
|
||||
bytes[1] == 0x50.toByte() &&
|
||||
bytes[2] == 0x4e.toByte() &&
|
||||
bytes[3] == 0x47.toByte() &&
|
||||
bytes[4] == 0x0d.toByte() &&
|
||||
bytes[5] == 0x0a.toByte() &&
|
||||
bytes[6] == 0x1a.toByte() &&
|
||||
bytes[7] == 0x0a.toByte()
|
||||
) {
|
||||
return ImageFormat.PNG
|
||||
}
|
||||
if (
|
||||
size >= 3 &&
|
||||
bytes[0] == 0xff.toByte() &&
|
||||
bytes[1] == 0xd8.toByte() &&
|
||||
bytes[2] == 0xff.toByte()
|
||||
) {
|
||||
return ImageFormat.JPEG
|
||||
}
|
||||
if (
|
||||
size >= 6 &&
|
||||
bytes[0] == 0x47.toByte() &&
|
||||
bytes[1] == 0x49.toByte() &&
|
||||
bytes[2] == 0x46.toByte() &&
|
||||
bytes[3] == 0x38.toByte() &&
|
||||
(bytes[4] == 0x37.toByte() || bytes[4] == 0x39.toByte()) &&
|
||||
bytes[5] == 0x61.toByte()
|
||||
) {
|
||||
return ImageFormat.GIF
|
||||
}
|
||||
if (
|
||||
size >= 12 &&
|
||||
bytes[0] == 0x52.toByte() &&
|
||||
bytes[1] == 0x49.toByte() &&
|
||||
bytes[2] == 0x46.toByte() &&
|
||||
bytes[3] == 0x46.toByte() &&
|
||||
bytes[8] == 0x57.toByte() &&
|
||||
bytes[9] == 0x45.toByte() &&
|
||||
bytes[10] == 0x42.toByte() &&
|
||||
bytes[11] == 0x50.toByte()
|
||||
) {
|
||||
return ImageFormat.WEBP
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
private fun hasCompleteContainer(
|
||||
file: File,
|
||||
format: ImageFormat,
|
||||
header: ByteArray,
|
||||
length: Long
|
||||
): Boolean = runCatching {
|
||||
if (format == ImageFormat.WEBP) return@runCatching webpLength(header) == length
|
||||
RandomAccessFile(file, "r").use { input ->
|
||||
when (format) {
|
||||
ImageFormat.PNG -> {
|
||||
if (length < 12) return@use false
|
||||
input.seek(length - 12)
|
||||
input.readInt() == 0 &&
|
||||
input.readInt() == 0x49454e44 &&
|
||||
input.readInt() == 0xae426082.toInt()
|
||||
}
|
||||
ImageFormat.JPEG -> {
|
||||
if (length < 2) return@use false
|
||||
input.seek(length - 2)
|
||||
input.readUnsignedByte() == 0xff && input.readUnsignedByte() == 0xd9
|
||||
}
|
||||
ImageFormat.GIF -> {
|
||||
input.seek(length - 1)
|
||||
input.readUnsignedByte() == 0x3b
|
||||
}
|
||||
ImageFormat.WEBP -> false
|
||||
}
|
||||
}
|
||||
}.getOrDefault(false)
|
||||
|
||||
private fun hasCompleteContainer(bytes: ByteArray, format: ImageFormat): Boolean {
|
||||
val size = bytes.size
|
||||
return when (format) {
|
||||
ImageFormat.PNG ->
|
||||
size >= 12 &&
|
||||
bytes[size - 8] == 0x49.toByte() &&
|
||||
bytes[size - 7] == 0x45.toByte() &&
|
||||
bytes[size - 6] == 0x4e.toByte() &&
|
||||
bytes[size - 5] == 0x44.toByte() &&
|
||||
bytes[size - 4] == 0xae.toByte() &&
|
||||
bytes[size - 3] == 0x42.toByte() &&
|
||||
bytes[size - 2] == 0x60.toByte() &&
|
||||
bytes[size - 1] == 0x82.toByte()
|
||||
ImageFormat.JPEG ->
|
||||
size >= 2 &&
|
||||
bytes[size - 2] == 0xff.toByte() &&
|
||||
bytes[size - 1] == 0xd9.toByte()
|
||||
ImageFormat.GIF -> size >= 1 && bytes[size - 1] == 0x3b.toByte()
|
||||
ImageFormat.WEBP -> webpLength(bytes) == size.toLong()
|
||||
}
|
||||
}
|
||||
|
||||
private fun webpLength(header: ByteArray): Long {
|
||||
val riffSize = (header[4].toLong() and 0xff) or
|
||||
((header[5].toLong() and 0xff) shl 8) or
|
||||
((header[6].toLong() and 0xff) shl 16) or
|
||||
((header[7].toLong() and 0xff) shl 24)
|
||||
return riffSize + 8
|
||||
}
|
||||
|
||||
private fun hasSupportedDimensions(options: BitmapFactory.Options): Boolean {
|
||||
val width = options.outWidth
|
||||
val height = options.outHeight
|
||||
return width in 1..4096 &&
|
||||
height in 1..4096 &&
|
||||
width.toLong() * height <= 16_777_216L
|
||||
}
|
||||
|
||||
private fun sha256(value: String): String = MessageDigest.getInstance("SHA-256")
|
||||
.digest(value.toByteArray(Charsets.UTF_8))
|
||||
.joinToString("") { byte -> "%02x".format(byte) }
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
package com.edde746.plezy.watchnext
|
||||
|
||||
import android.content.BroadcastReceiver
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import java.util.concurrent.Executor
|
||||
import java.util.concurrent.ExecutorService
|
||||
import java.util.concurrent.Executors
|
||||
|
||||
/** Migrates versioned shelf state after updates and restores volatile launcher grants after boot. */
|
||||
class SystemShelfUpdateReceiver private constructor(
|
||||
private val executor: Executor,
|
||||
private val ownsExecutor: Boolean
|
||||
) : BroadcastReceiver() {
|
||||
constructor() : this(Executors.newSingleThreadExecutor(), true)
|
||||
internal constructor(executor: Executor) : this(executor, false)
|
||||
|
||||
override fun onReceive(context: Context, intent: Intent) {
|
||||
val action = intent.action
|
||||
if (action != Intent.ACTION_MY_PACKAGE_REPLACED && action != Intent.ACTION_BOOT_COMPLETED) return
|
||||
val pending = goAsync()
|
||||
executor.execute {
|
||||
try {
|
||||
val provider = WatchNextProvider.forMaintenance(context.applicationContext)
|
||||
if (action == Intent.ACTION_MY_PACKAGE_REPLACED) {
|
||||
provider.migrateShelfSchema()
|
||||
} else {
|
||||
provider.restoreReadGrants()
|
||||
}
|
||||
} finally {
|
||||
pending?.finish()
|
||||
if (ownsExecutor) (executor as ExecutorService).shutdown()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -10,60 +10,98 @@ import androidx.tvprovider.media.tv.TvContractCompat
|
||||
import io.flutter.embedding.engine.plugins.FlutterPlugin
|
||||
import io.flutter.plugin.common.MethodCall
|
||||
import io.flutter.plugin.common.MethodChannel
|
||||
import java.util.concurrent.ExecutorService
|
||||
import java.util.concurrent.Executors
|
||||
import java.util.concurrent.atomic.AtomicBoolean
|
||||
|
||||
/**
|
||||
* Flutter plugin for Android TV Watch Next integration.
|
||||
* Syncs Plex "On Deck" content to the Android TV launcher's Watch Next row.
|
||||
*/
|
||||
class WatchNextPlugin :
|
||||
class WatchNextPlugin() :
|
||||
FlutterPlugin,
|
||||
MethodChannel.MethodCallHandler {
|
||||
internal constructor(executorFactory: () -> ExecutorService) : this() {
|
||||
this.executorFactory = executorFactory
|
||||
}
|
||||
|
||||
companion object {
|
||||
private const val TAG = "WatchNextPlugin"
|
||||
private const val METHOD_CHANNEL = "com.plezy/watch_next"
|
||||
|
||||
private const val SCHEMA_VERSION = 2
|
||||
private var pendingDeepLink: String? = null
|
||||
|
||||
/**
|
||||
* Parse a Watch Next deep link intent.
|
||||
* Returns the content ID if this was a Watch Next intent, null otherwise.
|
||||
*/
|
||||
fun handleIntent(intent: Intent?): String? {
|
||||
val data = intent?.data ?: return null
|
||||
if (data.scheme == "plezy" && data.authority == "play") {
|
||||
return data.getQueryParameter("content_id")
|
||||
return if (data.scheme == "plezy" && data.authority == "play") {
|
||||
data.getQueryParameter("content_id")
|
||||
} else {
|
||||
null
|
||||
}
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
private var executorFactory: () -> ExecutorService = { Executors.newSingleThreadExecutor() }
|
||||
|
||||
private class EngineSession(val context: Context) {
|
||||
private val closed = AtomicBoolean(false)
|
||||
var lease: SystemShelfLifecycle.Lease? = null
|
||||
var provider: WatchNextProvider? = null
|
||||
|
||||
fun close() {
|
||||
closed.set(true)
|
||||
}
|
||||
|
||||
fun isOpen(): Boolean = !closed.get()
|
||||
}
|
||||
|
||||
private lateinit var methodChannel: MethodChannel
|
||||
private var applicationContext: Context? = null
|
||||
private var watchNextProvider: WatchNextProvider? = null
|
||||
private val ioExecutor by lazy { Executors.newSingleThreadExecutor() }
|
||||
private var engineSession: EngineSession? = null
|
||||
private var ioExecutor: ExecutorService? = null
|
||||
private val mainHandler = Handler(Looper.getMainLooper())
|
||||
|
||||
override fun onAttachedToEngine(binding: FlutterPlugin.FlutterPluginBinding) {
|
||||
val executor = executorFactory()
|
||||
val session = EngineSession(binding.applicationContext)
|
||||
ioExecutor = executor
|
||||
engineSession = session
|
||||
applicationContext = binding.applicationContext
|
||||
watchNextProvider = WatchNextProvider(binding.applicationContext)
|
||||
methodChannel = MethodChannel(binding.binaryMessenger, METHOD_CHANNEL)
|
||||
methodChannel.setMethodCallHandler(this)
|
||||
executor.execute {
|
||||
val lease = SystemShelfLifecycle.acquireIf(session::isOpen) ?: return@execute
|
||||
session.lease = lease
|
||||
if (session.isOpen()) {
|
||||
session.provider = WatchNextProvider(session.context, lease)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override fun onDetachedFromEngine(binding: FlutterPlugin.FlutterPluginBinding) {
|
||||
methodChannel.setMethodCallHandler(null)
|
||||
val session = engineSession
|
||||
val executor = ioExecutor
|
||||
session?.close()
|
||||
engineSession = null
|
||||
ioExecutor = null
|
||||
applicationContext = null
|
||||
watchNextProvider = null
|
||||
ioExecutor.shutdown()
|
||||
if (session != null && executor != null) {
|
||||
try {
|
||||
executor.execute {
|
||||
session.lease?.let(SystemShelfLifecycle::invalidate)
|
||||
session.lease = null
|
||||
session.provider = null
|
||||
}
|
||||
} catch (_: java.util.concurrent.RejectedExecutionException) {
|
||||
Log.e(TAG, "System shelf lifecycle executor rejected detach")
|
||||
} finally {
|
||||
executor.shutdown()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override fun onMethodCall(call: MethodCall, result: MethodChannel.Result) {
|
||||
when (call.method) {
|
||||
"isSupported" -> handleIsSupported(result)
|
||||
"sync" -> handleSync(call, result)
|
||||
"clear" -> handleClear(result)
|
||||
"clear" -> handleClear(call, result)
|
||||
"remove" -> handleRemove(call, result)
|
||||
"getInitialDeepLink" -> handleGetInitialDeepLink(result)
|
||||
else -> result.notImplemented()
|
||||
@@ -72,66 +110,78 @@ class WatchNextPlugin :
|
||||
|
||||
private fun handleIsSupported(result: MethodChannel.Result) {
|
||||
val context = applicationContext
|
||||
if (context == null) {
|
||||
result.success(false)
|
||||
return
|
||||
}
|
||||
result.success(context.packageManager.hasSystemFeature(PackageManager.FEATURE_LEANBACK))
|
||||
result.success(context?.packageManager?.hasSystemFeature(PackageManager.FEATURE_LEANBACK) == true)
|
||||
}
|
||||
|
||||
private fun ownerArguments(call: MethodCall): Pair<String, Long>? {
|
||||
if (call.argument<Number>("schemaVersion")?.toInt() != SCHEMA_VERSION) return null
|
||||
val owner = call.argument<String>("ownerId")?.takeIf(String::isNotBlank) ?: return null
|
||||
val generation = call.argument<Number>("generation")?.toLong()?.takeIf { it > 0 } ?: return null
|
||||
return owner to generation
|
||||
}
|
||||
|
||||
private fun handleSync(call: MethodCall, result: MethodChannel.Result) {
|
||||
val provider = watchNextProvider
|
||||
if (provider == null) {
|
||||
result.error("NOT_INITIALIZED", "WatchNextProvider not initialized", null)
|
||||
return
|
||||
}
|
||||
|
||||
val session = engineSession ?: return result.error("NOT_INITIALIZED", "Provider unavailable", null)
|
||||
val executor = ioExecutor ?: return result.error("NOT_INITIALIZED", "Provider unavailable", null)
|
||||
val (owner, generation) = ownerArguments(call)
|
||||
?: return result.error("INVALID_ARGS", "Invalid shelf envelope", null)
|
||||
val itemsData = call.argument<List<Map<String, Any?>>>("items")
|
||||
if (itemsData == null) {
|
||||
result.error("INVALID_ARGS", "Missing 'items' argument", null)
|
||||
return
|
||||
?: return result.error("INVALID_ARGS", "Missing items", null)
|
||||
if (itemsData.size > SystemShelfArtworkStore.MAX_ITEMS) {
|
||||
return result.error("INVALID_ARGS", "Too many items", null)
|
||||
}
|
||||
val items = itemsData.mapNotNull(::parseWatchNextItem)
|
||||
executeOnIo(executor, result) {
|
||||
if (!session.isOpen()) return@executeOnIo false
|
||||
val provider = session.provider ?: return@executeOnIo false
|
||||
val ownership = provider.claimOwnership(owner, generation) ?: return@executeOnIo false
|
||||
if (!session.isOpen()) return@executeOnIo false
|
||||
provider.syncWatchNextPrograms(owner, generation, items, ownership, session::isOpen)
|
||||
}
|
||||
|
||||
val items = itemsData.mapNotNull { parseWatchNextItem(it) }
|
||||
executeOnIo(result) { provider.syncWatchNextPrograms(items) }
|
||||
}
|
||||
|
||||
private fun handleClear(result: MethodChannel.Result) {
|
||||
val provider = watchNextProvider
|
||||
if (provider == null) {
|
||||
result.error("NOT_INITIALIZED", "WatchNextProvider not initialized", null)
|
||||
return
|
||||
private fun handleClear(call: MethodCall, result: MethodChannel.Result) {
|
||||
val session = engineSession ?: return result.error("NOT_INITIALIZED", "Provider unavailable", null)
|
||||
val executor = ioExecutor ?: return result.error("NOT_INITIALIZED", "Provider unavailable", null)
|
||||
val (owner, generation) = ownerArguments(call)
|
||||
?: return result.error("INVALID_ARGS", "Invalid shelf envelope", null)
|
||||
executeOnIo(executor, result) {
|
||||
if (!session.isOpen()) return@executeOnIo false
|
||||
val provider = session.provider ?: return@executeOnIo false
|
||||
val ownership = provider.claimOwnership(owner, generation) ?: return@executeOnIo false
|
||||
if (!session.isOpen()) return@executeOnIo false
|
||||
provider.clearAll(owner, generation, ownership, session::isOpen)
|
||||
}
|
||||
executeOnIo(result) { provider.clearAll() }
|
||||
}
|
||||
|
||||
private fun handleRemove(call: MethodCall, result: MethodChannel.Result) {
|
||||
val provider = watchNextProvider
|
||||
if (provider == null) {
|
||||
result.error("NOT_INITIALIZED", "WatchNextProvider not initialized", null)
|
||||
return
|
||||
}
|
||||
|
||||
val session = engineSession ?: return result.error("NOT_INITIALIZED", "Provider unavailable", null)
|
||||
val executor = ioExecutor ?: return result.error("NOT_INITIALIZED", "Provider unavailable", null)
|
||||
val (owner, generation) = ownerArguments(call)
|
||||
?: return result.error("INVALID_ARGS", "Invalid shelf envelope", null)
|
||||
val contentId = call.argument<String>("contentId")
|
||||
if (contentId == null) {
|
||||
result.error("INVALID_ARGS", "Missing 'contentId' argument", null)
|
||||
return
|
||||
?: return result.error("INVALID_ARGS", "Missing contentId", null)
|
||||
executeOnIo(executor, result) {
|
||||
if (!session.isOpen()) return@executeOnIo false
|
||||
val provider = session.provider ?: return@executeOnIo false
|
||||
val ownership = provider.claimOwnership(owner, generation) ?: return@executeOnIo false
|
||||
if (!session.isOpen()) return@executeOnIo false
|
||||
provider.removeItem(owner, generation, contentId, ownership, session::isOpen)
|
||||
}
|
||||
executeOnIo(result) { provider.removeItem(contentId) }
|
||||
}
|
||||
|
||||
private fun executeOnIo(result: MethodChannel.Result, block: () -> Any?) {
|
||||
private fun executeOnIo(executor: ExecutorService, result: MethodChannel.Result, block: () -> Any?) {
|
||||
try {
|
||||
ioExecutor.execute {
|
||||
executor.execute {
|
||||
try {
|
||||
val value = block()
|
||||
mainHandler.post { result.success(value) }
|
||||
} catch (e: Exception) {
|
||||
Log.e(TAG, "IO operation failed: ${e.message}", e)
|
||||
mainHandler.post { result.error("IO_ERROR", e.message, null) }
|
||||
} catch (_: Exception) {
|
||||
Log.e(TAG, "System shelf IO operation failed")
|
||||
mainHandler.post { result.error("IO_ERROR", "System shelf operation failed", null) }
|
||||
}
|
||||
}
|
||||
} catch (e: java.util.concurrent.RejectedExecutionException) {
|
||||
} catch (_: java.util.concurrent.RejectedExecutionException) {
|
||||
result.error("SHUTDOWN", "Plugin is shutting down", null)
|
||||
}
|
||||
}
|
||||
@@ -143,22 +193,18 @@ class WatchNextPlugin :
|
||||
}
|
||||
|
||||
private fun parseWatchNextItem(data: Map<String, Any?>): WatchNextProvider.WatchNextItem? {
|
||||
val contentId = data["contentId"] as? String ?: return null
|
||||
val contentId = (data["contentId"] as? String)?.takeIf(String::isNotBlank) ?: return null
|
||||
val title = data["title"] as? String ?: return null
|
||||
|
||||
val typeString = data["type"] as? String ?: "movie"
|
||||
val type = when (typeString.lowercase()) {
|
||||
val type = when ((data["type"] as? String)?.lowercase()) {
|
||||
"episode" -> TvContractCompat.WatchNextPrograms.TYPE_TV_EPISODE
|
||||
"movie" -> TvContractCompat.WatchNextPrograms.TYPE_MOVIE
|
||||
else -> TvContractCompat.WatchNextPrograms.TYPE_MOVIE
|
||||
}
|
||||
|
||||
return WatchNextProvider.WatchNextItem(
|
||||
contentId = contentId,
|
||||
title = title,
|
||||
episodeTitle = data["episodeTitle"] as? String,
|
||||
description = data["description"] as? String,
|
||||
posterUri = data["posterUri"] as? String,
|
||||
posterSourceUri = data["posterSourceUri"] as? String,
|
||||
type = type,
|
||||
duration = (data["duration"] as? Number)?.toLong() ?: 0L,
|
||||
lastPlaybackPosition = (data["lastPlaybackPosition"] as? Number)?.toLong() ?: 0L,
|
||||
@@ -169,16 +215,12 @@ class WatchNextPlugin :
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Store a deep link content ID for delivery to Flutter.
|
||||
* Called from MainActivity on intent receipt.
|
||||
*/
|
||||
fun notifyDeepLink(contentId: String) {
|
||||
pendingDeepLink = contentId
|
||||
try {
|
||||
methodChannel.invokeMethod("onWatchNextTap", mapOf("contentId" to contentId))
|
||||
} catch (e: Exception) {
|
||||
Log.d(TAG, "Method channel not ready, stored as pending deep link")
|
||||
} catch (_: Exception) {
|
||||
Log.d(TAG, "Method channel not ready; deep link retained")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,19 +3,103 @@ package com.edde746.plezy.watchnext
|
||||
import android.content.ContentProviderOperation
|
||||
import android.content.ContentUris
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.content.pm.PackageManager
|
||||
import android.database.Cursor
|
||||
import android.net.Uri
|
||||
import android.os.Build
|
||||
import android.util.Log
|
||||
import androidx.tvprovider.media.tv.TvContractCompat
|
||||
import androidx.tvprovider.media.tv.WatchNextProgram
|
||||
|
||||
/**
|
||||
* Wraps Android TvProvider API for Watch Next row integration.
|
||||
* Manages WatchNextProgram entries for Plex "On Deck" content.
|
||||
*/
|
||||
class WatchNextProvider(private val context: Context) {
|
||||
internal object SystemShelfLifecycle {
|
||||
data class Lease internal constructor(internal val token: Long)
|
||||
data class Ownership internal constructor(
|
||||
internal val engineToken: Long,
|
||||
internal val claimToken: Long
|
||||
)
|
||||
|
||||
private val lock = Any()
|
||||
private val operationLock = Any()
|
||||
private var token = 0L
|
||||
private var claimToken = 0L
|
||||
private var currentOwner = ""
|
||||
private var currentGeneration = 0L
|
||||
|
||||
fun acquire(): Lease = acquireIf { true }!!
|
||||
|
||||
fun acquireIf(isActive: () -> Boolean): Lease? = synchronized(operationLock) {
|
||||
if (!isActive()) return@synchronized null
|
||||
synchronized(lock) {
|
||||
token += 1
|
||||
claimToken += 1
|
||||
currentOwner = ""
|
||||
currentGeneration = 0
|
||||
Lease(token)
|
||||
}
|
||||
}
|
||||
|
||||
fun invalidate(lease: Lease) {
|
||||
synchronized(operationLock) {
|
||||
synchronized(lock) {
|
||||
if (token == lease.token) {
|
||||
token += 1
|
||||
claimToken += 1
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun claim(lease: Lease, ownerId: String, generation: Long): Ownership? = synchronized(operationLock) {
|
||||
synchronized(lock) {
|
||||
if (
|
||||
token != lease.token ||
|
||||
ownerId.isBlank() ||
|
||||
generation <= 0 ||
|
||||
generation < currentGeneration ||
|
||||
generation == currentGeneration &&
|
||||
currentOwner.isNotEmpty() &&
|
||||
currentOwner != ownerId
|
||||
) {
|
||||
null
|
||||
} else {
|
||||
currentOwner = ownerId
|
||||
currentGeneration = generation
|
||||
claimToken += 1
|
||||
Ownership(token, claimToken)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun isCurrent(ownership: Ownership): Boolean = synchronized(lock) {
|
||||
token == ownership.engineToken && claimToken == ownership.claimToken
|
||||
}
|
||||
|
||||
fun <T> whileCurrent(ownership: Ownership, block: () -> T): T? = synchronized(operationLock) {
|
||||
if (!isCurrent(ownership)) return@synchronized null
|
||||
val result = block()
|
||||
if (isCurrent(ownership)) result else null
|
||||
}
|
||||
|
||||
fun <T> exclusive(block: () -> T): T = synchronized(operationLock, block)
|
||||
}
|
||||
|
||||
/** Owns Plezy's durable Android TV Watch Next rows and their local artwork. */
|
||||
class WatchNextProvider internal constructor(
|
||||
private val context: Context,
|
||||
private val lifecycleLease: SystemShelfLifecycle.Lease?,
|
||||
private val syncDurationMillis: Long = SystemShelfArtworkStore.MAX_SYNC_DURATION_MS
|
||||
) {
|
||||
constructor(context: Context) : this(context, SystemShelfLifecycle.acquire())
|
||||
companion object {
|
||||
private const val TAG = "WatchNextProvider"
|
||||
private const val PREFS = "system_shelf_state"
|
||||
private const val GRANTED_URIS = "granted_uris"
|
||||
private const val GRANTED_PACKAGES = "granted_packages"
|
||||
internal const val SHELF_SCHEMA_VERSION = 1
|
||||
private const val SHELF_SCHEMA_VERSION_KEY = "shelf_schema_version"
|
||||
|
||||
internal fun forMaintenance(context: Context) = WatchNextProvider(context, null)
|
||||
}
|
||||
|
||||
data class WatchNextItem(
|
||||
@@ -23,7 +107,7 @@ class WatchNextProvider(private val context: Context) {
|
||||
val title: String,
|
||||
val episodeTitle: String?,
|
||||
val description: String?,
|
||||
val posterUri: String?,
|
||||
val posterSourceUri: String?,
|
||||
val type: Int,
|
||||
val duration: Long,
|
||||
val lastPlaybackPosition: Long,
|
||||
@@ -33,132 +117,447 @@ class WatchNextProvider(private val context: Context) {
|
||||
val episodeNumber: Int?
|
||||
)
|
||||
|
||||
/**
|
||||
* Sync items to Watch Next row.
|
||||
* Uses applyBatch to delete + insert in a single transaction so the
|
||||
* launcher receives one content-change notification with the full set.
|
||||
*/
|
||||
fun syncWatchNextPrograms(items: List<WatchNextItem>): Boolean = try {
|
||||
val ops = ArrayList<ContentProviderOperation>()
|
||||
internal data class PreparedWatchNextItem(val metadata: WatchNextItem, val localPosterUri: Uri?)
|
||||
|
||||
ops.add(
|
||||
ContentProviderOperation.newDelete(
|
||||
TvContractCompat.WatchNextPrograms.CONTENT_URI
|
||||
).build()
|
||||
private data class CommittedRow(
|
||||
val id: Long,
|
||||
val contentId: String,
|
||||
val posterUri: Uri?
|
||||
)
|
||||
|
||||
private val prefs = context.getSharedPreferences(PREFS, Context.MODE_PRIVATE)
|
||||
private val artwork = SystemShelfArtworkStore(context.cacheDir)
|
||||
|
||||
internal fun claimOwnership(ownerId: String, generation: Long): SystemShelfLifecycle.Ownership? = lifecycleLease?.let { SystemShelfLifecycle.claim(it, ownerId, generation) }
|
||||
|
||||
internal fun syncWatchNextPrograms(
|
||||
ownerId: String,
|
||||
generation: Long,
|
||||
items: List<WatchNextItem>,
|
||||
ownership: SystemShelfLifecycle.Ownership? = null,
|
||||
isOperationActive: () -> Boolean = { true }
|
||||
): Boolean {
|
||||
if (items.size > SystemShelfArtworkStore.MAX_ITEMS) return false
|
||||
val operationOwnership = ownership ?: claimOwnership(ownerId, generation) ?: return false
|
||||
if (!isOperationActive()) return false
|
||||
val session = SystemShelfSyncSession(
|
||||
operationOwnership,
|
||||
syncDurationMillis
|
||||
)
|
||||
|
||||
for (item in items) {
|
||||
val program = buildProgram(item)
|
||||
ops.add(
|
||||
ContentProviderOperation.newInsert(
|
||||
TvContractCompat.WatchNextPrograms.CONTENT_URI
|
||||
).withValues(program.toContentValues()).build()
|
||||
)
|
||||
val previousArtwork = snapshotCommittedArtwork(ownerId) ?: return false
|
||||
if (!isOperationActive() || !session.isActive()) return false
|
||||
val oldUris = prefs.getStringSet(GRANTED_URIS, emptySet()).orEmpty().mapNotNull(Uri::parse).toSet()
|
||||
val oldPackages = storedPackages()
|
||||
val oldSchemaVersion = prefs.getInt(SHELF_SCHEMA_VERSION_KEY, 0)
|
||||
val preparedArtwork = LinkedHashSet<SystemShelfArtworkStore.Prepared>()
|
||||
val preparedBySource = HashMap<String, SystemShelfArtworkStore.Prepared?>()
|
||||
try {
|
||||
val preparedItems = items.map { item ->
|
||||
val source = item.posterSourceUri
|
||||
val localArtwork = if (source == null) {
|
||||
null
|
||||
} else {
|
||||
val candidate = if (preparedBySource.containsKey(source)) {
|
||||
preparedBySource[source]
|
||||
} else {
|
||||
artwork.prepare(ownerId, source, session).also { prepared ->
|
||||
preparedBySource[source] = prepared
|
||||
prepared?.let(preparedArtwork::add)
|
||||
}
|
||||
}
|
||||
candidate?.materialized ?: previousArtwork[item.contentId]
|
||||
}
|
||||
PreparedWatchNextItem(item, localArtwork?.uri)
|
||||
}
|
||||
val newUris = preparedItems.mapNotNullTo(LinkedHashSet()) { it.localPosterUri }
|
||||
val newPackages = consumerPackages()
|
||||
if (!isOperationActive() || !session.isActive()) return false
|
||||
|
||||
return SystemShelfLifecycle.whileCurrent(session.ownership) {
|
||||
if (!isOperationActive() || session.isExpired()) return@whileCurrent false
|
||||
val publishedFiles = LinkedHashSet<java.io.File>()
|
||||
preparedArtwork.forEach { candidate ->
|
||||
val publication = artwork.publish(candidate)
|
||||
if (publication == null) {
|
||||
artwork.delete(publishedFiles)
|
||||
return@whileCurrent false
|
||||
}
|
||||
if (publication.newlyPublished) publishedFiles += publication.materialized.file
|
||||
}
|
||||
|
||||
val referencedFiles = LinkedHashSet<java.io.File>()
|
||||
preparedItems.forEach { item ->
|
||||
val posterUri = item.localPosterUri ?: return@forEach
|
||||
val file = artwork.resolveOwned(ownerId, posterUri)
|
||||
if (file == null) {
|
||||
artwork.delete(publishedFiles)
|
||||
return@whileCurrent false
|
||||
}
|
||||
referencedFiles += file
|
||||
}
|
||||
if (session.isExpired()) {
|
||||
artwork.delete(publishedFiles)
|
||||
return@whileCurrent false
|
||||
}
|
||||
|
||||
grantReadAccess(newUris, newPackages)
|
||||
if (session.isExpired()) {
|
||||
reconcileReadAccess(newUris, newPackages, oldUris, oldPackages)
|
||||
artwork.delete(publishedFiles)
|
||||
return@whileCurrent false
|
||||
}
|
||||
val preferencesCommitted = prefs.edit()
|
||||
.putStringSet(GRANTED_URIS, newUris.mapTo(LinkedHashSet(), Uri::toString))
|
||||
.putStringSet(GRANTED_PACKAGES, newPackages)
|
||||
.putInt(SHELF_SCHEMA_VERSION_KEY, SHELF_SCHEMA_VERSION)
|
||||
.commit()
|
||||
if (!preferencesCommitted) {
|
||||
reconcileReadAccess(newUris, newPackages, oldUris, oldPackages)
|
||||
artwork.delete(publishedFiles)
|
||||
return@whileCurrent false
|
||||
}
|
||||
if (session.isExpired() || !replaceRows(preparedItems)) {
|
||||
val rollback = prefs.edit()
|
||||
.putStringSet(GRANTED_URIS, oldUris.mapTo(LinkedHashSet(), Uri::toString))
|
||||
.putStringSet(GRANTED_PACKAGES, oldPackages)
|
||||
if (oldSchemaVersion > 0) {
|
||||
rollback.putInt(SHELF_SCHEMA_VERSION_KEY, oldSchemaVersion)
|
||||
} else {
|
||||
rollback.remove(SHELF_SCHEMA_VERSION_KEY)
|
||||
}
|
||||
rollback.commit()
|
||||
reconcileReadAccess(newUris, newPackages, oldUris, oldPackages)
|
||||
artwork.delete(publishedFiles)
|
||||
return@whileCurrent false
|
||||
}
|
||||
|
||||
reconcileReadAccess(oldUris, oldPackages, newUris, newPackages)
|
||||
artwork.deleteExcept(referencedFiles)
|
||||
true
|
||||
} ?: false
|
||||
} finally {
|
||||
artwork.discard(preparedArtwork)
|
||||
}
|
||||
|
||||
context.contentResolver.applyBatch(TvContractCompat.AUTHORITY, ops)
|
||||
Log.d(TAG, "Synced ${items.size} Watch Next entries")
|
||||
true
|
||||
} catch (e: Exception) {
|
||||
Log.e(TAG, "Failed to sync Watch Next programs", e)
|
||||
false
|
||||
}
|
||||
|
||||
fun clearAll(): Boolean = try {
|
||||
context.contentResolver.delete(
|
||||
TvContractCompat.WatchNextPrograms.CONTENT_URI,
|
||||
null,
|
||||
null
|
||||
)
|
||||
true
|
||||
} catch (e: Exception) {
|
||||
Log.e(TAG, "Failed to clear Watch Next entries", e)
|
||||
false
|
||||
/** Deletes rows first, then revokes grants and owned files. */
|
||||
internal fun clearAll(
|
||||
ownerId: String,
|
||||
generation: Long,
|
||||
ownership: SystemShelfLifecycle.Ownership? = null,
|
||||
isOperationActive: () -> Boolean = { true }
|
||||
): Boolean {
|
||||
val operationOwnership = ownership ?: claimOwnership(ownerId, generation) ?: return false
|
||||
return SystemShelfLifecycle.whileCurrent(operationOwnership) {
|
||||
if (!isOperationActive()) return@whileCurrent false
|
||||
val rowsCleared = deleteRows()
|
||||
if (!rowsCleared) return@whileCurrent false
|
||||
val uris = storedUris()
|
||||
val packages = storedPackages()
|
||||
reconcileReadAccess(uris, packages, emptySet(), emptySet())
|
||||
artwork.deleteAll()
|
||||
prefs.edit()
|
||||
.remove(GRANTED_URIS)
|
||||
.remove(GRANTED_PACKAGES)
|
||||
.putInt(SHELF_SCHEMA_VERSION_KEY, SHELF_SCHEMA_VERSION)
|
||||
.commit()
|
||||
true
|
||||
} ?: false
|
||||
}
|
||||
|
||||
fun removeItem(contentId: String): Boolean {
|
||||
/** Removes only data from a shelf schema older than the current on-device contract. */
|
||||
fun migrateShelfSchema(): Boolean = SystemShelfLifecycle.exclusive {
|
||||
if (prefs.getInt(SHELF_SCHEMA_VERSION_KEY, 0) >= SHELF_SCHEMA_VERSION) {
|
||||
return@exclusive restoreReadGrantsOwned()
|
||||
}
|
||||
val rowsCleared = deleteRows()
|
||||
if (!rowsCleared) return@exclusive false
|
||||
val uris = storedUris()
|
||||
val packages = storedPackages()
|
||||
reconcileReadAccess(uris, packages, emptySet(), emptySet())
|
||||
artwork.deleteAll()
|
||||
prefs.edit()
|
||||
.clear()
|
||||
.putInt(SHELF_SCHEMA_VERSION_KEY, SHELF_SCHEMA_VERSION)
|
||||
.commit()
|
||||
}
|
||||
|
||||
/** Re-establishes reboot-volatile grants only for persisted, confined artwork files. */
|
||||
fun restoreReadGrants(): Boolean = SystemShelfLifecycle.exclusive {
|
||||
restoreReadGrantsOwned()
|
||||
}
|
||||
|
||||
private fun restoreReadGrantsOwned(): Boolean {
|
||||
val previousUris = storedUris()
|
||||
val previousPackages = storedPackages()
|
||||
val validUris = previousUris.filterTo(LinkedHashSet()) { artwork.resolve(it) != null }
|
||||
val currentPackages = consumerPackages()
|
||||
reconcileReadAccess(previousUris, previousPackages, validUris, currentPackages)
|
||||
return prefs.edit()
|
||||
.putStringSet(GRANTED_URIS, validUris.mapTo(LinkedHashSet(), Uri::toString))
|
||||
.putStringSet(GRANTED_PACKAGES, currentPackages)
|
||||
.commit()
|
||||
}
|
||||
|
||||
private fun snapshotCommittedArtwork(
|
||||
ownerId: String
|
||||
): Map<String, SystemShelfArtworkStore.Materialized>? {
|
||||
val rows = queryCommittedRows() ?: return null
|
||||
val snapshot = LinkedHashMap<String, SystemShelfArtworkStore.Materialized>()
|
||||
rows.forEach { row ->
|
||||
val uri = row.posterUri ?: return@forEach
|
||||
val file = artwork.resolveOwned(ownerId, uri) ?: return@forEach
|
||||
if (row.contentId !in snapshot) {
|
||||
snapshot[row.contentId] = SystemShelfArtworkStore.Materialized(uri, file)
|
||||
}
|
||||
}
|
||||
return snapshot
|
||||
}
|
||||
|
||||
private fun queryCommittedRows(): List<CommittedRow>? {
|
||||
return try {
|
||||
val cursor = context.contentResolver.query(
|
||||
TvContractCompat.WatchNextPrograms.CONTENT_URI,
|
||||
arrayOf(
|
||||
TvContractCompat.WatchNextPrograms._ID,
|
||||
TvContractCompat.WatchNextPrograms.COLUMN_INTERNAL_PROVIDER_ID
|
||||
TvContractCompat.WatchNextPrograms.COLUMN_INTERNAL_PROVIDER_ID,
|
||||
TvContractCompat.WatchNextPrograms.COLUMN_INTERNAL_PROVIDER_DATA,
|
||||
TvContractCompat.WatchNextPrograms.COLUMN_INTENT_URI,
|
||||
TvContractCompat.PreviewPrograms.COLUMN_POSTER_ART_URI
|
||||
),
|
||||
null,
|
||||
null,
|
||||
null
|
||||
)
|
||||
|
||||
cursor?.use {
|
||||
) ?: return null
|
||||
cursor.use {
|
||||
val idIndex = it.getColumnIndex(TvContractCompat.WatchNextPrograms._ID)
|
||||
val providerIdIndex = it.getColumnIndex(TvContractCompat.WatchNextPrograms.COLUMN_INTERNAL_PROVIDER_ID)
|
||||
|
||||
if (idIndex < 0 || providerIdIndex < 0) return false
|
||||
|
||||
while (it.moveToNext()) {
|
||||
if (it.getString(providerIdIndex) == contentId) {
|
||||
val id = it.getLong(idIndex)
|
||||
val deleteUri = ContentUris.withAppendedId(
|
||||
TvContractCompat.WatchNextPrograms.CONTENT_URI,
|
||||
id
|
||||
)
|
||||
context.contentResolver.delete(deleteUri, null, null)
|
||||
return true
|
||||
}
|
||||
val providerIdIndex = it.getColumnIndex(
|
||||
TvContractCompat.WatchNextPrograms.COLUMN_INTERNAL_PROVIDER_ID
|
||||
)
|
||||
val providerDataIndex = it.getColumnIndex(
|
||||
TvContractCompat.WatchNextPrograms.COLUMN_INTERNAL_PROVIDER_DATA
|
||||
)
|
||||
val intentIndex = it.getColumnIndex(
|
||||
TvContractCompat.WatchNextPrograms.COLUMN_INTENT_URI
|
||||
)
|
||||
val posterIndex = it.getColumnIndex(TvContractCompat.PreviewPrograms.COLUMN_POSTER_ART_URI)
|
||||
if (
|
||||
idIndex < 0 ||
|
||||
posterIndex < 0 ||
|
||||
providerIdIndex < 0 &&
|
||||
providerDataIndex < 0 &&
|
||||
intentIndex < 0
|
||||
) {
|
||||
return null
|
||||
}
|
||||
val rows = ArrayList<CommittedRow>(it.count)
|
||||
while (it.moveToNext()) {
|
||||
val providerId = cursorString(it, providerIdIndex)
|
||||
?.takeIf(String::isNotBlank)
|
||||
val providerData = cursorString(it, providerDataIndex)
|
||||
?.takeIf(String::isNotBlank)
|
||||
val contentId = providerId
|
||||
?: providerData
|
||||
?: contentIdFromIntent(cursorString(it, intentIndex))
|
||||
?: continue
|
||||
val posterUri = it.getString(posterIndex)
|
||||
?.takeIf(String::isNotBlank)
|
||||
?.let(Uri::parse)
|
||||
rows += CommittedRow(it.getLong(idIndex), contentId, posterUri)
|
||||
}
|
||||
rows
|
||||
}
|
||||
false
|
||||
} catch (e: Exception) {
|
||||
Log.e(TAG, "Failed to remove Watch Next item: $contentId", e)
|
||||
} catch (_: Exception) {
|
||||
Log.e(TAG, "Failed to query committed Watch Next programs")
|
||||
null
|
||||
}
|
||||
}
|
||||
|
||||
private fun cursorString(cursor: Cursor, index: Int): String? {
|
||||
if (index < 0 || cursor.isNull(index)) return null
|
||||
return if (cursor.getType(index) == Cursor.FIELD_TYPE_BLOB) {
|
||||
cursor.getBlob(index)?.toString(Charsets.UTF_8)
|
||||
} else {
|
||||
cursor.getString(index)
|
||||
}
|
||||
}
|
||||
|
||||
private fun contentIdFromIntent(value: String?): String? {
|
||||
val uri = value?.let { runCatching { Uri.parse(it) }.getOrNull() } ?: return null
|
||||
if (uri.scheme != "plezy" || uri.authority != "play") return null
|
||||
return uri.getQueryParameter("content_id")?.takeIf(String::isNotBlank)
|
||||
}
|
||||
|
||||
internal fun removeItem(
|
||||
ownerId: String,
|
||||
generation: Long,
|
||||
contentId: String,
|
||||
ownership: SystemShelfLifecycle.Ownership? = null,
|
||||
isOperationActive: () -> Boolean = { true }
|
||||
): Boolean {
|
||||
val operationOwnership = ownership ?: claimOwnership(ownerId, generation) ?: return false
|
||||
return SystemShelfLifecycle.whileCurrent(operationOwnership) {
|
||||
if (!isOperationActive()) return@whileCurrent false
|
||||
removeItemOwned(ownerId, contentId)
|
||||
} ?: false
|
||||
}
|
||||
|
||||
private fun removeItemOwned(ownerId: String, contentId: String): Boolean {
|
||||
return try {
|
||||
val rows = queryCommittedRows() ?: return false
|
||||
val target = rows.firstOrNull { it.contentId == contentId } ?: return false
|
||||
val deleteUri = ContentUris.withAppendedId(
|
||||
TvContractCompat.WatchNextPrograms.CONTENT_URI,
|
||||
target.id
|
||||
)
|
||||
if (context.contentResolver.delete(deleteUri, null, null) <= 0) return false
|
||||
|
||||
val poster = target.posterUri ?: return true
|
||||
if (rows.any { it.id != target.id && it.posterUri == poster }) return true
|
||||
revokeReadAccess(setOf(poster))
|
||||
val remaining = prefs.getStringSet(GRANTED_URIS, emptySet()).orEmpty() - poster.toString()
|
||||
prefs.edit().putStringSet(GRANTED_URIS, remaining).commit()
|
||||
artwork.resolveOwned(ownerId, poster)?.let { artwork.delete(setOf(it)) }
|
||||
true
|
||||
} catch (_: Exception) {
|
||||
Log.e(TAG, "Failed to remove Watch Next item")
|
||||
false
|
||||
}
|
||||
}
|
||||
|
||||
private fun buildProgram(item: WatchNextItem): WatchNextProgram {
|
||||
val watchNextType = if (item.lastPlaybackPosition > 0) {
|
||||
private fun replaceRows(items: List<PreparedWatchNextItem>): Boolean = try {
|
||||
val operations = ArrayList<ContentProviderOperation>(items.size + 1)
|
||||
operations += ContentProviderOperation.newDelete(TvContractCompat.WatchNextPrograms.CONTENT_URI).build()
|
||||
items.forEach { item ->
|
||||
val values = buildProgram(item).toContentValues().apply {
|
||||
put(
|
||||
TvContractCompat.WatchNextPrograms.COLUMN_INTERNAL_PROVIDER_DATA,
|
||||
item.metadata.contentId.toByteArray(Charsets.UTF_8)
|
||||
)
|
||||
}
|
||||
operations += ContentProviderOperation.newInsert(TvContractCompat.WatchNextPrograms.CONTENT_URI)
|
||||
.withValues(values)
|
||||
.build()
|
||||
}
|
||||
context.contentResolver.applyBatch(TvContractCompat.AUTHORITY, operations)
|
||||
true
|
||||
} catch (_: Exception) {
|
||||
Log.e(TAG, "Failed to sync Watch Next programs")
|
||||
false
|
||||
}
|
||||
|
||||
private fun deleteRows(): Boolean = try {
|
||||
context.contentResolver.delete(TvContractCompat.WatchNextPrograms.CONTENT_URI, null, null)
|
||||
true
|
||||
} catch (_: Exception) {
|
||||
Log.e(TAG, "Failed to clear Watch Next entries")
|
||||
false
|
||||
}
|
||||
|
||||
internal fun consumerPackages(): Set<String> {
|
||||
val homeIntent = Intent(Intent.ACTION_MAIN).addCategory(Intent.CATEGORY_HOME)
|
||||
val packageManager = context.packageManager
|
||||
val selectedHome = packageManager.resolveActivity(homeIntent, PackageManager.MATCH_DEFAULT_ONLY)
|
||||
?.activityInfo ?: return emptySet()
|
||||
val packageName = selectedHome.packageName?.takeIf(String::isNotBlank) ?: return emptySet()
|
||||
val activityName = selectedHome.name?.takeIf(String::isNotBlank) ?: return emptySet()
|
||||
val isHomeHandler = packageManager.queryIntentActivities(homeIntent, PackageManager.MATCH_ALL).any { candidate ->
|
||||
candidate.activityInfo?.let { it.packageName == packageName && it.name == activityName } == true
|
||||
}
|
||||
return if (isHomeHandler) setOf(packageName) else emptySet()
|
||||
}
|
||||
|
||||
private fun reconcileReadAccess(
|
||||
previousUris: Set<Uri>,
|
||||
previousPackages: Set<String>,
|
||||
currentUris: Set<Uri>,
|
||||
currentPackages: Set<String>
|
||||
) {
|
||||
val flags = Intent.FLAG_GRANT_READ_URI_PERMISSION
|
||||
if (previousPackages.isEmpty() && previousUris.isNotEmpty()) {
|
||||
previousUris.forEach { uri -> runCatching { context.revokeUriPermission(uri, flags) } }
|
||||
} else {
|
||||
previousPackages.forEach { packageName ->
|
||||
previousUris.forEach { uri ->
|
||||
if (packageName !in currentPackages || uri !in currentUris) {
|
||||
revokeReadAccess(packageName, uri, flags)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
grantReadAccess(currentUris, currentPackages)
|
||||
}
|
||||
|
||||
private fun grantReadAccess(uris: Set<Uri>, packages: Set<String>) {
|
||||
val flags = Intent.FLAG_GRANT_READ_URI_PERMISSION
|
||||
packages.forEach { packageName ->
|
||||
uris.forEach { uri ->
|
||||
runCatching { context.grantUriPermission(packageName, uri, flags) }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun revokeReadAccess(uris: Set<Uri>) {
|
||||
val flags = Intent.FLAG_GRANT_READ_URI_PERMISSION
|
||||
val packages = storedPackages()
|
||||
if (packages.isEmpty()) {
|
||||
uris.forEach { uri -> runCatching { context.revokeUriPermission(uri, flags) } }
|
||||
return
|
||||
}
|
||||
packages.forEach { packageName ->
|
||||
uris.forEach { uri -> revokeReadAccess(packageName, uri, flags) }
|
||||
}
|
||||
}
|
||||
|
||||
private fun revokeReadAccess(packageName: String, uri: Uri, flags: Int) {
|
||||
runCatching {
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
|
||||
context.revokeUriPermission(packageName, uri, flags)
|
||||
} else {
|
||||
context.revokeUriPermission(uri, flags)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun storedUris(): Set<Uri> = prefs.getStringSet(GRANTED_URIS, emptySet()).orEmpty().mapNotNullTo(LinkedHashSet(), Uri::parse)
|
||||
|
||||
private fun storedPackages(): Set<String> = prefs.getStringSet(GRANTED_PACKAGES, emptySet()).orEmpty()
|
||||
|
||||
internal fun buildProgram(item: PreparedWatchNextItem): WatchNextProgram {
|
||||
val metadata = item.metadata
|
||||
val watchNextType = if (metadata.lastPlaybackPosition > 0) {
|
||||
TvContractCompat.WatchNextPrograms.WATCH_NEXT_TYPE_CONTINUE
|
||||
} else {
|
||||
TvContractCompat.WatchNextPrograms.WATCH_NEXT_TYPE_NEXT
|
||||
}
|
||||
|
||||
val builder = WatchNextProgram.Builder()
|
||||
.setType(item.type)
|
||||
.setType(metadata.type)
|
||||
.setWatchNextType(watchNextType)
|
||||
.setTitle(item.title)
|
||||
.setInternalProviderId(item.contentId)
|
||||
.setLastEngagementTimeUtcMillis(item.lastEngagementTime)
|
||||
.setTitle(metadata.title)
|
||||
.setInternalProviderId(metadata.contentId)
|
||||
.setLastEngagementTimeUtcMillis(metadata.lastEngagementTime)
|
||||
|
||||
item.description?.let { builder.setDescription(it) }
|
||||
|
||||
item.posterUri?.let { uri ->
|
||||
try {
|
||||
builder.setPosterArtUri(Uri.parse(uri))
|
||||
builder.setPosterArtAspectRatio(TvContractCompat.PreviewPrograms.ASPECT_RATIO_16_9)
|
||||
} catch (e: Exception) {
|
||||
Log.w(TAG, "Failed to parse poster URI: $uri", e)
|
||||
metadata.description?.let(builder::setDescription)
|
||||
item.localPosterUri?.let { uri ->
|
||||
builder.setPosterArtUri(uri)
|
||||
builder.setPosterArtAspectRatio(TvContractCompat.PreviewPrograms.ASPECT_RATIO_16_9)
|
||||
}
|
||||
if (metadata.duration > 0) {
|
||||
builder.setDurationMillis(metadata.duration.coerceAtMost(Int.MAX_VALUE.toLong()).toInt())
|
||||
if (metadata.lastPlaybackPosition > 0) {
|
||||
builder.setLastPlaybackPositionMillis(metadata.lastPlaybackPosition.coerceAtMost(Int.MAX_VALUE.toLong()).toInt())
|
||||
}
|
||||
}
|
||||
|
||||
if (item.duration > 0) {
|
||||
builder.setDurationMillis(item.duration.toInt())
|
||||
if (item.lastPlaybackPosition > 0) {
|
||||
builder.setLastPlaybackPositionMillis(item.lastPlaybackPosition.toInt())
|
||||
}
|
||||
if (metadata.type == TvContractCompat.WatchNextPrograms.TYPE_TV_EPISODE) {
|
||||
metadata.episodeTitle?.let(builder::setEpisodeTitle)
|
||||
metadata.seasonNumber?.let(builder::setSeasonNumber)
|
||||
metadata.episodeNumber?.let(builder::setEpisodeNumber)
|
||||
}
|
||||
|
||||
if (item.type == TvContractCompat.WatchNextPrograms.TYPE_TV_EPISODE) {
|
||||
item.episodeTitle?.let { builder.setEpisodeTitle(it) }
|
||||
item.seasonNumber?.let { builder.setSeasonNumber(it) }
|
||||
item.episodeNumber?.let { builder.setEpisodeNumber(it) }
|
||||
}
|
||||
|
||||
val intentUri = Uri.Builder()
|
||||
.scheme("plezy")
|
||||
.authority("play")
|
||||
.appendQueryParameter("content_id", item.contentId)
|
||||
.build()
|
||||
builder.setIntentUri(intentUri)
|
||||
|
||||
builder.setIntentUri(
|
||||
Uri.Builder().scheme("plezy").authority("play")
|
||||
.appendQueryParameter("content_id", metadata.contentId).build()
|
||||
)
|
||||
return builder.build()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,57 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<network-security-config>
|
||||
<!-- Fallback for user-entered hosts only: a self-hosted Plex/Jellyfin/Seerr
|
||||
server, or a custom Watch Together relay, may be plain HTTP or fronted
|
||||
by a private CA the device owner installed. Their hostnames are not
|
||||
knowable at build time, so they can only be covered here. -->
|
||||
<base-config cleartextTrafficPermitted="true">
|
||||
<trust-anchors>
|
||||
<certificates src="system" />
|
||||
<certificates src="user" />
|
||||
</trust-anchors>
|
||||
</base-config>
|
||||
|
||||
<!-- First-party / fixed endpoints. These carry account tokens, OAuth
|
||||
codes, crash payloads and update metadata, and by construction never
|
||||
live behind a private CA. System anchors only, i.e. the pre-2.9.1
|
||||
behaviour. Every hard-coded https:// host in lib/ must appear here.
|
||||
Pinned by test/android/network_security_config_test.dart. -->
|
||||
<domain-config>
|
||||
<!-- plex.tv, clients.plex.tv, app.plex.tv, discover.provider.plex.tv,
|
||||
epg.provider.plex.tv (lib/services/plex_auth_service.dart:53-54,167,
|
||||
lib/services/plex_discover_client.dart:57,
|
||||
lib/services/plex_client/parts/live_tv.dart:3).
|
||||
Does NOT match *.plex.direct — user PMS keeps base-config. -->
|
||||
<domain includeSubdomains="true">plex.tv</domain>
|
||||
<!-- ice.plezy.app: relay + /auth OAuth proxy + /posters
|
||||
(lib/watch_together/services/watch_together_relay_endpoint.dart:8,
|
||||
lib/services/trackers/oauth_proxy_client.dart:22,
|
||||
lib/services/discord_rpc_service.dart:55);
|
||||
bugs.plezy.app: Sentry DSN (lib/main.dart:90). -->
|
||||
<domain includeSubdomains="true">plezy.app</domain>
|
||||
<!-- api.trakt.tv incl. /oauth/device/*, /oauth/token
|
||||
(lib/services/trakt/trakt_constants.dart:16,21-24). -->
|
||||
<domain includeSubdomains="true">trakt.tv</domain>
|
||||
<!-- api.myanimelist.net + myanimelist.net/v1/oauth2/token
|
||||
(lib/services/trackers/mal/mal_constants.dart:19-20). -->
|
||||
<domain includeSubdomains="true">myanimelist.net</domain>
|
||||
<!-- graphql.anilist.co (lib/services/trackers/anilist/anilist_constants.dart:9). -->
|
||||
<domain includeSubdomains="true">anilist.co</domain>
|
||||
<!-- api.simkl.com + simkl.com/pin
|
||||
(lib/services/trackers/simkl/simkl_constants.dart:21,33). -->
|
||||
<domain includeSubdomains="true">simkl.com</domain>
|
||||
<!-- data.simkl.in (lib/services/trackers/simkl/simkl_constants.dart:22). -->
|
||||
<domain includeSubdomains="true">simkl.in</domain>
|
||||
<!-- cdn.jsdelivr.net: appcast (lib/services/update_service.dart:18) and
|
||||
anime-list mappings (anime_lists_mapping_store.dart:37,
|
||||
fribb_mapping_store.dart:49). -->
|
||||
<domain includeSubdomains="true">jsdelivr.net</domain>
|
||||
<!-- Release check (lib/services/update_service.dart:161). -->
|
||||
<domain includeSubdomains="true">api.github.com</domain>
|
||||
<!-- TMDB artwork (lib/services/catalog/seerr_catalog_source.dart:163). -->
|
||||
<domain includeSubdomains="true">image.tmdb.org</domain>
|
||||
<trust-anchors>
|
||||
<certificates src="system" />
|
||||
</trust-anchors>
|
||||
</domain-config>
|
||||
</network-security-config>
|
||||
@@ -0,0 +1,15 @@
|
||||
cmake_minimum_required(VERSION 3.22.1)
|
||||
project(dovi_bridge_test LANGUAGES CXX)
|
||||
|
||||
enable_testing()
|
||||
|
||||
add_executable(dovi_bridge_test dovi_bridge_test.cpp)
|
||||
target_compile_features(dovi_bridge_test PRIVATE cxx_std_17)
|
||||
target_include_directories(dovi_bridge_test PRIVATE fakes)
|
||||
|
||||
add_test(NAME dovi_bridge_test COMMAND dovi_bridge_test)
|
||||
|
||||
add_executable(ffmpeg_audio_buffer_test ffmpeg_audio_buffer_test.cpp)
|
||||
target_compile_features(ffmpeg_audio_buffer_test PRIVATE cxx_std_17)
|
||||
|
||||
add_test(NAME ffmpeg_audio_buffer_test COMMAND ffmpeg_audio_buffer_test)
|
||||
@@ -0,0 +1,259 @@
|
||||
#include <cstdarg>
|
||||
#include <cstdio>
|
||||
#include <string>
|
||||
#include <vector>
|
||||
|
||||
#define DOVI_REAL_LINKED 1
|
||||
#include "../../main/cpp/dovi_bridge.cpp"
|
||||
|
||||
struct DoviRpuOpaque {
|
||||
std::string error;
|
||||
};
|
||||
|
||||
enum class ParseResult { kValid, kError, kNull };
|
||||
enum class WriteResult { kValid, kNull, kEmpty, kOversized };
|
||||
|
||||
static ParseResult parse_result;
|
||||
static WriteResult write_result;
|
||||
static int conversion_result;
|
||||
static std::string parser_error;
|
||||
static std::string last_log;
|
||||
static std::vector<uint8_t> last_unspec_input;
|
||||
static std::vector<uint8_t> encoded_output;
|
||||
static int unspec_parse_calls;
|
||||
static int raw_parse_calls;
|
||||
static int conversion_calls;
|
||||
static int write_calls;
|
||||
static int rpu_free_calls;
|
||||
static int data_free_calls;
|
||||
|
||||
extern "C" int __android_log_print(int, const char*, const char* format, ...) {
|
||||
char buffer[2048];
|
||||
va_list args;
|
||||
va_start(args, format);
|
||||
std::vsnprintf(buffer, sizeof(buffer), format, args);
|
||||
va_end(args);
|
||||
last_log = buffer;
|
||||
return static_cast<int>(last_log.size());
|
||||
}
|
||||
|
||||
extern "C" DoviRpuOpaque* dovi_parse_unspec62_nalu(const uint8_t* data, size_t len) {
|
||||
++unspec_parse_calls;
|
||||
last_unspec_input.assign(data, data + len);
|
||||
if (parse_result == ParseResult::kNull) return nullptr;
|
||||
return new DoviRpuOpaque{parse_result == ParseResult::kError ? parser_error : ""};
|
||||
}
|
||||
|
||||
// A raw parse deliberately succeeds so the malformed-NAL test detects any
|
||||
// regression that retries framed input through dovi_parse_rpu.
|
||||
extern "C" DoviRpuOpaque* dovi_parse_rpu(const uint8_t*, size_t) {
|
||||
++raw_parse_calls;
|
||||
return new DoviRpuOpaque{};
|
||||
}
|
||||
|
||||
extern "C" const char* dovi_rpu_get_error(const DoviRpuOpaque* rpu) {
|
||||
return rpu->error.empty() ? nullptr : rpu->error.c_str();
|
||||
}
|
||||
|
||||
extern "C" void dovi_rpu_free(DoviRpuOpaque* rpu) {
|
||||
++rpu_free_calls;
|
||||
delete rpu;
|
||||
}
|
||||
|
||||
extern "C" int32_t dovi_convert_rpu_with_mode(DoviRpuOpaque* rpu, uint8_t) {
|
||||
++conversion_calls;
|
||||
if (conversion_result != 0) rpu->error = "conversion error";
|
||||
return conversion_result;
|
||||
}
|
||||
|
||||
extern "C" const DoviData* dovi_write_unspec62_nalu(DoviRpuOpaque*) {
|
||||
++write_calls;
|
||||
if (write_result == WriteResult::kNull) return nullptr;
|
||||
if (write_result == WriteResult::kEmpty) return new DoviData{nullptr, 0};
|
||||
|
||||
const size_t len = write_result == WriteResult::kOversized ? MAX_RPU_OUTPUT_SIZE + 1 : encoded_output.size();
|
||||
auto* bytes = new uint8_t[len];
|
||||
for (size_t i = 0; i < len; ++i) {
|
||||
bytes[i] = write_result == WriteResult::kOversized ? 0 : encoded_output[i];
|
||||
}
|
||||
return new DoviData{bytes, len};
|
||||
}
|
||||
|
||||
extern "C" void dovi_data_free(const DoviData* data) {
|
||||
++data_free_calls;
|
||||
delete[] data->data;
|
||||
delete data;
|
||||
}
|
||||
|
||||
static void resetFakes() {
|
||||
parse_result = ParseResult::kValid;
|
||||
write_result = WriteResult::kValid;
|
||||
conversion_result = 0;
|
||||
parser_error.clear();
|
||||
last_log.clear();
|
||||
last_unspec_input.clear();
|
||||
encoded_output = {0x7c, 0x01, 0x19, 0x08, 0x55};
|
||||
unspec_parse_calls = 0;
|
||||
raw_parse_calls = 0;
|
||||
conversion_calls = 0;
|
||||
write_calls = 0;
|
||||
rpu_free_calls = 0;
|
||||
data_free_calls = 0;
|
||||
}
|
||||
|
||||
static _jbyteArray byteArray(const std::vector<uint8_t>& bytes) {
|
||||
_jbyteArray array;
|
||||
array.bytes.reserve(bytes.size());
|
||||
for (uint8_t byte : bytes) array.bytes.push_back(static_cast<jbyte>(byte));
|
||||
return array;
|
||||
}
|
||||
|
||||
static jint convert(
|
||||
JNIEnv& env, _jbyteArray& payload, _jbyteArray& output, jint output_offset = 0, jint output_capacity = -1) {
|
||||
if (output_capacity < 0) output_capacity = static_cast<jint>(output.bytes.size());
|
||||
return Java_com_edde746_plezy_exoplayer_DoviBridge_nativeConvertDv7RpuToDv81(
|
||||
&env, nullptr, &payload, 0, static_cast<jint>(payload.bytes.size()), &output, output_offset, output_capacity, 2);
|
||||
}
|
||||
|
||||
#define CHECK(condition) \
|
||||
do { \
|
||||
if (!(condition)) { \
|
||||
std::fprintf(stderr, "%s:%d: check failed: %s\n", __FILE__, __LINE__, #condition); \
|
||||
return false; \
|
||||
} \
|
||||
} while (false)
|
||||
|
||||
static bool validUnspec62NalConvertsAndFreesAllocations() {
|
||||
resetFakes();
|
||||
const std::vector<uint8_t> framed_nal = {0x7c, 0x01, 0x19, 0x08, 0x22};
|
||||
auto payload = byteArray(framed_nal);
|
||||
auto output = byteArray(std::vector<uint8_t>(16, 0));
|
||||
JNIEnv env;
|
||||
|
||||
const jint result = convert(env, payload, output, 2);
|
||||
|
||||
CHECK(result == static_cast<jint>(encoded_output.size()));
|
||||
CHECK(last_unspec_input == framed_nal);
|
||||
CHECK(raw_parse_calls == 0);
|
||||
CHECK(unspec_parse_calls == 1);
|
||||
CHECK(conversion_calls == 1);
|
||||
CHECK(write_calls == 1);
|
||||
CHECK(rpu_free_calls == 1);
|
||||
CHECK(data_free_calls == 1);
|
||||
for (size_t i = 0; i < encoded_output.size(); ++i) {
|
||||
CHECK(static_cast<uint8_t>(output.bytes[i + 2]) == encoded_output[i]);
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
static bool malformedTruncatedNalFailsWithoutRawFallback() {
|
||||
resetFakes();
|
||||
parse_result = ParseResult::kError;
|
||||
parser_error.assign(2048, 'x');
|
||||
auto payload = byteArray({0x7c, 0x01, 0x19});
|
||||
auto output = byteArray(std::vector<uint8_t>(16, 0));
|
||||
JNIEnv env;
|
||||
|
||||
const jint result = convert(env, payload, output);
|
||||
|
||||
CHECK(result == CONVERT_FAILED);
|
||||
CHECK(unspec_parse_calls == 1);
|
||||
CHECK(raw_parse_calls == 0);
|
||||
CHECK(conversion_calls == 0);
|
||||
CHECK(write_calls == 0);
|
||||
CHECK(rpu_free_calls == 1);
|
||||
CHECK(data_free_calls == 0);
|
||||
const std::string prefix = "RPU NAL parse failed: ";
|
||||
CHECK(last_log == prefix + std::string(MAX_ERROR_LOG_LENGTH, 'x'));
|
||||
return true;
|
||||
}
|
||||
|
||||
static bool nullParserResultFailsWithoutFreeingNonexistentAllocation() {
|
||||
resetFakes();
|
||||
parse_result = ParseResult::kNull;
|
||||
auto payload = byteArray({0x7c, 0x01});
|
||||
auto output = byteArray(std::vector<uint8_t>(16, 0));
|
||||
JNIEnv env;
|
||||
|
||||
CHECK(convert(env, payload, output) == CONVERT_FAILED);
|
||||
CHECK(raw_parse_calls == 0);
|
||||
CHECK(rpu_free_calls == 0);
|
||||
CHECK(data_free_calls == 0);
|
||||
return true;
|
||||
}
|
||||
|
||||
static bool conversionFailureFreesRpu() {
|
||||
resetFakes();
|
||||
conversion_result = -1;
|
||||
auto payload = byteArray({0x7c, 0x01, 0x19, 0x08});
|
||||
auto output = byteArray(std::vector<uint8_t>(16, 0));
|
||||
JNIEnv env;
|
||||
|
||||
CHECK(convert(env, payload, output) == CONVERT_FAILED);
|
||||
CHECK(rpu_free_calls == 1);
|
||||
CHECK(data_free_calls == 0);
|
||||
return true;
|
||||
}
|
||||
|
||||
static bool unusableWriterResultFreesBothAllocations() {
|
||||
resetFakes();
|
||||
write_result = WriteResult::kEmpty;
|
||||
auto payload = byteArray({0x7c, 0x01, 0x19, 0x08});
|
||||
auto output = byteArray(std::vector<uint8_t>(16, 0));
|
||||
JNIEnv env;
|
||||
|
||||
CHECK(convert(env, payload, output) == CONVERT_FAILED);
|
||||
CHECK(rpu_free_calls == 1);
|
||||
CHECK(data_free_calls == 1);
|
||||
return true;
|
||||
}
|
||||
|
||||
static bool destinationTooSmallFreesBothAllocations() {
|
||||
resetFakes();
|
||||
auto payload = byteArray({0x7c, 0x01, 0x19, 0x08});
|
||||
auto output = byteArray(std::vector<uint8_t>(4, 0));
|
||||
JNIEnv env;
|
||||
|
||||
CHECK(convert(env, payload, output) == DESTINATION_TOO_SMALL);
|
||||
CHECK(rpu_free_calls == 1);
|
||||
CHECK(data_free_calls == 1);
|
||||
return true;
|
||||
}
|
||||
|
||||
static bool jniWriteFailureFreesBothAllocations() {
|
||||
resetFakes();
|
||||
auto payload = byteArray({0x7c, 0x01, 0x19, 0x08});
|
||||
auto output = byteArray(std::vector<uint8_t>(16, 0));
|
||||
JNIEnv env;
|
||||
env.fail_next_write = true;
|
||||
|
||||
CHECK(convert(env, payload, output) == CONVERT_FAILED);
|
||||
CHECK(rpu_free_calls == 1);
|
||||
CHECK(data_free_calls == 1);
|
||||
return true;
|
||||
}
|
||||
|
||||
int main() {
|
||||
struct TestCase {
|
||||
const char* name;
|
||||
bool (*run)();
|
||||
};
|
||||
const TestCase tests[] = {
|
||||
{"valid conversion", validUnspec62NalConvertsAndFreesAllocations},
|
||||
{"malformed truncated NAL", malformedTruncatedNalFailsWithoutRawFallback},
|
||||
{"null parser result", nullParserResultFailsWithoutFreeingNonexistentAllocation},
|
||||
{"conversion failure", conversionFailureFreesRpu},
|
||||
{"unusable writer result", unusableWriterResultFreesBothAllocations},
|
||||
{"destination too small", destinationTooSmallFreesBothAllocations},
|
||||
{"JNI write failure", jniWriteFailureFreesBothAllocations},
|
||||
};
|
||||
|
||||
for (const TestCase& test : tests) {
|
||||
if (!test.run()) {
|
||||
std::fprintf(stderr, "FAILED: %s\n", test.name);
|
||||
return 1;
|
||||
}
|
||||
}
|
||||
std::printf("Passed %zu dovi_bridge tests\n", sizeof(tests) / sizeof(tests[0]));
|
||||
return 0;
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user