mirror of
https://github.com/narrrl/proton-stream.git
synced 2026-11-03 16:41:26 +01:00
added android client
This commit is contained in:
1 parent
8b95cf7a05
commit
6db6909824
73 files changed
+9229
-101
No files matched your search
@@ -0,0 +1,205 @@
|
|||||||
|
name: Android
|
||||||
|
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
branches: [main]
|
||||||
|
tags: ['v*']
|
||||||
|
pull_request:
|
||||||
|
paths:
|
||||||
|
- 'android/**'
|
||||||
|
- 'crates/pstr-android/**'
|
||||||
|
- 'crates/pstr-core/**'
|
||||||
|
- 'crates/pstr-meta/**'
|
||||||
|
- 'crates/pstr-stream/**'
|
||||||
|
- 'Cargo.toml'
|
||||||
|
- 'Cargo.lock'
|
||||||
|
- 'scripts/build-android.sh'
|
||||||
|
- 'scripts/build-libmpv-android.sh'
|
||||||
|
- '.github/workflows/android.yml'
|
||||||
|
workflow_dispatch:
|
||||||
|
inputs:
|
||||||
|
signed_release:
|
||||||
|
description: Build artifacts with the configured release key
|
||||||
|
required: false
|
||||||
|
default: false
|
||||||
|
type: boolean
|
||||||
|
|
||||||
|
permissions:
|
||||||
|
contents: read
|
||||||
|
|
||||||
|
env:
|
||||||
|
CARGO_TERM_COLOR: always
|
||||||
|
ANDROID_API: '36'
|
||||||
|
ANDROID_BUILD_TOOLS: '37.0.0'
|
||||||
|
ANDROID_NDK: '29.0.14206865'
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
build:
|
||||||
|
name: Validate, debug, and unsigned release
|
||||||
|
runs-on: ubuntu-24.04
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v7
|
||||||
|
with:
|
||||||
|
fetch-depth: 0
|
||||||
|
|
||||||
|
- name: Enforce release versions and tag ancestry
|
||||||
|
shell: bash
|
||||||
|
run: |
|
||||||
|
workspace_version="$(awk '/^\[workspace.package\]/{found=1; next} found && /^\[/{exit} found && /^version = /{gsub(/[" ]/, "", $3); print $3; exit}' Cargo.toml)"
|
||||||
|
test -n "$workspace_version"
|
||||||
|
if [[ ! "$workspace_version" =~ ^([0-9]+)\.([0-9]+)\.([0-9]+)([-+][0-9A-Za-z.-]+)?$ ]]; then
|
||||||
|
echo "::error::workspace version is not supported Android semver: $workspace_version"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
major="${BASH_REMATCH[1]}"
|
||||||
|
minor="${BASH_REMATCH[2]}"
|
||||||
|
patch="${BASH_REMATCH[3]}"
|
||||||
|
test "$minor" -le 999
|
||||||
|
test "$patch" -le 999
|
||||||
|
expected_code=$((major * 1000000 + minor * 1000 + patch))
|
||||||
|
test "$expected_code" -ge 1
|
||||||
|
test "$expected_code" -le 2100000000
|
||||||
|
echo "EXPECTED_ANDROID_VERSION_NAME=$workspace_version" >> "$GITHUB_ENV"
|
||||||
|
echo "EXPECTED_ANDROID_VERSION_CODE=$expected_code" >> "$GITHUB_ENV"
|
||||||
|
if [[ "$GITHUB_REF" == refs/tags/v* ]]; then
|
||||||
|
tag="${GITHUB_REF_NAME#v}"
|
||||||
|
test "$tag" = "$workspace_version"
|
||||||
|
git fetch --no-tags origin +refs/heads/main:refs/remotes/origin/main
|
||||||
|
git merge-base --is-ancestor "$GITHUB_SHA" origin/main
|
||||||
|
fi
|
||||||
|
|
||||||
|
- uses: actions/setup-java@v5
|
||||||
|
with:
|
||||||
|
distribution: temurin
|
||||||
|
java-version: '17'
|
||||||
|
|
||||||
|
- uses: android-actions/setup-android@v3
|
||||||
|
|
||||||
|
- name: Install native build prerequisites
|
||||||
|
run: |
|
||||||
|
sudo apt-get update
|
||||||
|
sudo apt-get install --no-install-recommends \
|
||||||
|
autoconf automake build-essential cmake curl git gperf libtool \
|
||||||
|
meson nasm ninja-build pkg-config python3 unzip wget xz-utils yasm
|
||||||
|
|
||||||
|
- name: Install Android SDK and NDK
|
||||||
|
run: >-
|
||||||
|
sdkmanager
|
||||||
|
"platforms;android-${ANDROID_API}"
|
||||||
|
"build-tools;${ANDROID_BUILD_TOOLS}"
|
||||||
|
"ndk;${ANDROID_NDK}"
|
||||||
|
|
||||||
|
- uses: dtolnay/rust-toolchain@stable
|
||||||
|
with:
|
||||||
|
toolchain: '1.96'
|
||||||
|
targets: aarch64-linux-android,x86_64-linux-android
|
||||||
|
|
||||||
|
- uses: Swatinem/rust-cache@v2.9.1
|
||||||
|
|
||||||
|
- uses: gradle/actions/setup-gradle@v4
|
||||||
|
with:
|
||||||
|
gradle-version: '8.13'
|
||||||
|
|
||||||
|
- name: Validate Android version derivation and override
|
||||||
|
shell: bash
|
||||||
|
run: |
|
||||||
|
resolved="$(gradle --no-daemon -q -p android :app:printAndroidVersion)"
|
||||||
|
grep -Fx "versionName=$EXPECTED_ANDROID_VERSION_NAME" <<< "$resolved"
|
||||||
|
grep -Fx "versionCode=$EXPECTED_ANDROID_VERSION_CODE" <<< "$resolved"
|
||||||
|
if [[ "$EXPECTED_ANDROID_VERSION_CODE" -lt 2100000000 ]]; then
|
||||||
|
override_code=$((EXPECTED_ANDROID_VERSION_CODE + 1))
|
||||||
|
else
|
||||||
|
override_code=$((EXPECTED_ANDROID_VERSION_CODE - 1))
|
||||||
|
fi
|
||||||
|
overridden="$(ANDROID_VERSION_CODE="$override_code" gradle --no-daemon -q -p android :app:printAndroidVersion)"
|
||||||
|
grep -Fx "versionName=$EXPECTED_ANDROID_VERSION_NAME" <<< "$overridden"
|
||||||
|
grep -Fx "versionCode=$override_code" <<< "$overridden"
|
||||||
|
|
||||||
|
- name: Install native build helpers
|
||||||
|
run: |
|
||||||
|
cargo install cargo-ndk --version 4.1.2 --locked
|
||||||
|
cargo install uniffi --version 0.32.0 --features cli --locked
|
||||||
|
|
||||||
|
- name: Android checks and artifacts
|
||||||
|
run: bash scripts/build-android.sh all
|
||||||
|
|
||||||
|
- uses: actions/upload-artifact@v7
|
||||||
|
with:
|
||||||
|
name: android-unsigned
|
||||||
|
path: |
|
||||||
|
android/app/build/outputs/apk/debug/*.apk
|
||||||
|
android/app/build/outputs/apk/release/*.apk
|
||||||
|
android/app/build/outputs/bundle/release/*.aab
|
||||||
|
if-no-files-found: error
|
||||||
|
|
||||||
|
signed-release:
|
||||||
|
name: Signed release
|
||||||
|
if: startsWith(github.ref, 'refs/tags/v') || inputs.signed_release
|
||||||
|
needs: build
|
||||||
|
runs-on: ubuntu-24.04
|
||||||
|
env:
|
||||||
|
ANDROID_RELEASE_STORE_PASSWORD: ${{ secrets.ANDROID_RELEASE_STORE_PASSWORD }}
|
||||||
|
ANDROID_RELEASE_KEY_ALIAS: ${{ secrets.ANDROID_RELEASE_KEY_ALIAS }}
|
||||||
|
ANDROID_RELEASE_KEY_PASSWORD: ${{ secrets.ANDROID_RELEASE_KEY_PASSWORD }}
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v7
|
||||||
|
|
||||||
|
- uses: actions/setup-java@v5
|
||||||
|
with:
|
||||||
|
distribution: temurin
|
||||||
|
java-version: '17'
|
||||||
|
|
||||||
|
- uses: android-actions/setup-android@v3
|
||||||
|
|
||||||
|
- name: Install native build prerequisites
|
||||||
|
run: |
|
||||||
|
sudo apt-get update
|
||||||
|
sudo apt-get install --no-install-recommends \
|
||||||
|
autoconf automake build-essential cmake curl git gperf libtool \
|
||||||
|
meson nasm ninja-build pkg-config python3 unzip wget xz-utils yasm
|
||||||
|
|
||||||
|
- name: Install Android SDK and NDK
|
||||||
|
run: >-
|
||||||
|
sdkmanager
|
||||||
|
"platforms;android-${ANDROID_API}"
|
||||||
|
"build-tools;${ANDROID_BUILD_TOOLS}"
|
||||||
|
"ndk;${ANDROID_NDK}"
|
||||||
|
|
||||||
|
- uses: dtolnay/rust-toolchain@stable
|
||||||
|
with:
|
||||||
|
toolchain: '1.96'
|
||||||
|
targets: aarch64-linux-android,x86_64-linux-android
|
||||||
|
|
||||||
|
- uses: Swatinem/rust-cache@v2.9.1
|
||||||
|
|
||||||
|
- uses: gradle/actions/setup-gradle@v4
|
||||||
|
with:
|
||||||
|
gradle-version: '8.13'
|
||||||
|
|
||||||
|
- name: Install native build helpers
|
||||||
|
run: |
|
||||||
|
cargo install cargo-ndk --version 4.1.2 --locked
|
||||||
|
cargo install uniffi --version 0.32.0 --features cli --locked
|
||||||
|
|
||||||
|
- name: Restore release keystore
|
||||||
|
env:
|
||||||
|
KEYSTORE_BASE64: ${{ secrets.ANDROID_RELEASE_KEYSTORE_BASE64 }}
|
||||||
|
run: |
|
||||||
|
test -n "$KEYSTORE_BASE64"
|
||||||
|
test -n "$ANDROID_RELEASE_STORE_PASSWORD"
|
||||||
|
test -n "$ANDROID_RELEASE_KEY_ALIAS"
|
||||||
|
test -n "$ANDROID_RELEASE_KEY_PASSWORD"
|
||||||
|
ANDROID_RELEASE_STORE_FILE="$RUNNER_TEMP/proton-stream-release.jks"
|
||||||
|
echo "ANDROID_RELEASE_STORE_FILE=$ANDROID_RELEASE_STORE_FILE" >> "$GITHUB_ENV"
|
||||||
|
printf '%s' "$KEYSTORE_BASE64" | base64 --decode > "$ANDROID_RELEASE_STORE_FILE"
|
||||||
|
|
||||||
|
- name: Build signed APK and AAB
|
||||||
|
run: bash scripts/build-android.sh signed
|
||||||
|
|
||||||
|
- uses: actions/upload-artifact@v7
|
||||||
|
with:
|
||||||
|
name: android-signed
|
||||||
|
path: |
|
||||||
|
android/app/build/outputs/apk/release/*.apk
|
||||||
|
android/app/build/outputs/bundle/release/*.aab
|
||||||
|
if-no-files-found: error
|
||||||
@@ -20,6 +20,9 @@ permissions:
|
|||||||
|
|
||||||
env:
|
env:
|
||||||
CARGO_TERM_COLOR: always
|
CARGO_TERM_COLOR: always
|
||||||
|
# Android remains a workflow artifact only until the native libmpv adapter,
|
||||||
|
# corresponding-source bundle, and on-device verification are complete.
|
||||||
|
ANDROID_PUBLIC_RELEASE_ENABLED: 'false'
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
verify:
|
verify:
|
||||||
@@ -57,6 +60,11 @@ jobs:
|
|||||||
! grep -q '^\[patch\.crates-io\]' Cargo.toml ||
|
! grep -q '^\[patch\.crates-io\]' Cargo.toml ||
|
||||||
{ echo "::error::Cargo.toml carries [patch.crates-io]; releases build against the registry"; exit 1; }
|
{ echo "::error::Cargo.toml carries [patch.crates-io]; releases build against the registry"; exit 1; }
|
||||||
|
|
||||||
|
- name: Android publication remains blocked
|
||||||
|
run: |
|
||||||
|
test "$ANDROID_PUBLIC_RELEASE_ENABLED" = false
|
||||||
|
echo "Android APK/AAB publication is blocked until native playback is release-ready."
|
||||||
|
|
||||||
linux:
|
linux:
|
||||||
name: Linux artifacts
|
name: Linux artifacts
|
||||||
needs: verify
|
needs: verify
|
||||||
@@ -141,7 +149,7 @@ jobs:
|
|||||||
if-no-files-found: error
|
if-no-files-found: error
|
||||||
|
|
||||||
publish:
|
publish:
|
||||||
name: Publish
|
name: Publish desktop artifacts
|
||||||
if: startsWith(github.ref, 'refs/tags/')
|
if: startsWith(github.ref, 'refs/tags/')
|
||||||
needs: [linux, windows]
|
needs: [linux, windows]
|
||||||
runs-on: ubuntu-24.04
|
runs-on: ubuntu-24.04
|
||||||
@@ -153,6 +161,13 @@ jobs:
|
|||||||
path: artifacts
|
path: artifacts
|
||||||
merge-multiple: true
|
merge-multiple: true
|
||||||
|
|
||||||
|
- name: Refuse mobile artifacts while Android publishing is blocked
|
||||||
|
run: |
|
||||||
|
if find artifacts -type f \( -name '*.apk' -o -name '*.aab' \) -print -quit | grep -q .; then
|
||||||
|
echo "::error::Android publishing is disabled while native playback remains incomplete"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
- name: Checksums
|
- name: Checksums
|
||||||
run: |
|
run: |
|
||||||
cd artifacts
|
cd artifacts
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
/target
|
/target
|
||||||
|
/.build/
|
||||||
*.db
|
*.db
|
||||||
*.db-wal
|
*.db-wal
|
||||||
*.db-shm
|
*.db-shm
|
||||||
@@ -7,6 +8,13 @@
|
|||||||
# Build artifacts.
|
# Build artifacts.
|
||||||
/dist
|
/dist
|
||||||
|
|
||||||
|
# Android and Gradle generated state.
|
||||||
|
/android/.gradle/
|
||||||
|
/android/build/
|
||||||
|
/android/*/build/
|
||||||
|
/android/local.properties
|
||||||
|
/android/.cxx/
|
||||||
|
|
||||||
# makepkg leftovers.
|
# makepkg leftovers.
|
||||||
/packaging/pkg/
|
/packaging/pkg/
|
||||||
/packaging/src/
|
/packaging/src/
|
||||||
|
|||||||
Generated
+405
-2
@@ -150,6 +150,23 @@ version = "0.2.2"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "fc7eb209b1518d6bb87b283c20095f5228ecda460da70b44f0802523dea6da04"
|
checksum = "fc7eb209b1518d6bb87b283c20095f5228ecda460da70b44f0802523dea6da04"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "android_log-sys"
|
||||||
|
version = "0.3.2"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "84521a3cf562bc62942e294181d9eef17eb38ceb8c68677bc49f144e4c3d4f8d"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "android_logger"
|
||||||
|
version = "0.14.1"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "05b07e8e73d720a1f2e4b6014766e6039fd2e96a4fa44e2a78d0e1fa2ff49826"
|
||||||
|
dependencies = [
|
||||||
|
"android_log-sys",
|
||||||
|
"env_filter",
|
||||||
|
"log",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "android_system_properties"
|
name = "android_system_properties"
|
||||||
version = "0.1.5"
|
version = "0.1.5"
|
||||||
@@ -260,6 +277,59 @@ version = "1.0.1"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "175571dd1d178ced59193a6fc02dde1b972eb0bc56c892cde9beeceac5bf0f6b"
|
checksum = "175571dd1d178ced59193a6fc02dde1b972eb0bc56c892cde9beeceac5bf0f6b"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "askama"
|
||||||
|
version = "0.16.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "f1bf825125edd887a019d0a3a837dcc5499a68b0d034cc3eb594070c3e18addc"
|
||||||
|
dependencies = [
|
||||||
|
"askama_macros",
|
||||||
|
"itoa",
|
||||||
|
"percent-encoding",
|
||||||
|
"serde",
|
||||||
|
"serde_json",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "askama_derive"
|
||||||
|
version = "0.16.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "e1c7065972a130eafa84215f21352ae15b4a7393da48c1f5e103904490736738"
|
||||||
|
dependencies = [
|
||||||
|
"askama_parser",
|
||||||
|
"basic-toml",
|
||||||
|
"glob",
|
||||||
|
"memchr",
|
||||||
|
"proc-macro2",
|
||||||
|
"quote",
|
||||||
|
"rustc-hash 2.1.3",
|
||||||
|
"serde",
|
||||||
|
"serde_derive",
|
||||||
|
"syn 2.0.119",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "askama_macros"
|
||||||
|
version = "0.16.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "0e23b1d2c4bd39a41971f6124cef4cc6fd0540913ecb90919b69ab3bbe44ae1a"
|
||||||
|
dependencies = [
|
||||||
|
"askama_derive",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "askama_parser"
|
||||||
|
version = "0.16.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "7db09fde9143e7ac4513358fb32ee32847125b63b18ea715afd487956da715da"
|
||||||
|
dependencies = [
|
||||||
|
"rustc-hash 2.1.3",
|
||||||
|
"serde",
|
||||||
|
"serde_derive",
|
||||||
|
"unicode-ident",
|
||||||
|
"winnow",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "async-broadcast"
|
name = "async-broadcast"
|
||||||
version = "0.7.2"
|
version = "0.7.2"
|
||||||
@@ -448,6 +518,15 @@ version = "1.8.3"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "2af50177e190e07a26ab74f8b1efbfe2ef87da2116221318cb1c2e82baf7de06"
|
checksum = "2af50177e190e07a26ab74f8b1efbfe2ef87da2116221318cb1c2e82baf7de06"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "basic-toml"
|
||||||
|
version = "0.1.10"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "ba62675e8242a4c4e806d12f11d136e626e6c8361d6b829310732241652a178a"
|
||||||
|
dependencies = [
|
||||||
|
"serde",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "bcrypt"
|
name = "bcrypt"
|
||||||
version = "0.19.3"
|
version = "0.19.3"
|
||||||
@@ -731,6 +810,39 @@ dependencies = [
|
|||||||
"cipher 0.4.4",
|
"cipher 0.4.4",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "camino"
|
||||||
|
version = "1.2.5"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "bb1307f12aa967b5a58416e87b3653360e0fd614a016b6e970db08fecbb1b80d"
|
||||||
|
dependencies = [
|
||||||
|
"serde_core",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "cargo-platform"
|
||||||
|
version = "0.3.3"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "dd0061da739915fae12ea00e16397555ed4371a6bb285431aab930f61b0aa4ba"
|
||||||
|
dependencies = [
|
||||||
|
"serde",
|
||||||
|
"serde_core",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "cargo_metadata"
|
||||||
|
version = "0.23.1"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "ef987d17b0a113becdd19d3d0022d04d7ef41f9efe4f3fb63ac44ba61df3ade9"
|
||||||
|
dependencies = [
|
||||||
|
"camino",
|
||||||
|
"cargo-platform",
|
||||||
|
"semver",
|
||||||
|
"serde",
|
||||||
|
"serde_json",
|
||||||
|
"thiserror 2.0.19",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "cast5"
|
name = "cast5"
|
||||||
version = "0.11.1"
|
version = "0.11.1"
|
||||||
@@ -1720,6 +1832,16 @@ dependencies = [
|
|||||||
"syn 2.0.119",
|
"syn 2.0.119",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "env_filter"
|
||||||
|
version = "0.1.4"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "1bf3c259d255ca70051b30e2e95b5446cdb8949ac4cd22c0d7fd634d89f568e2"
|
||||||
|
dependencies = [
|
||||||
|
"log",
|
||||||
|
"regex",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "epaint"
|
name = "epaint"
|
||||||
version = "0.35.0"
|
version = "0.35.0"
|
||||||
@@ -1938,6 +2060,15 @@ dependencies = [
|
|||||||
"percent-encoding",
|
"percent-encoding",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "fs-err"
|
||||||
|
version = "3.3.1"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "b91aa448ca50d7e79433bdf3ee8d99215430d2ec02ade5aefab2a073a1822e8a"
|
||||||
|
dependencies = [
|
||||||
|
"autocfg",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "fs_extra"
|
name = "fs_extra"
|
||||||
version = "1.3.0"
|
version = "1.3.0"
|
||||||
@@ -2157,6 +2288,12 @@ dependencies = [
|
|||||||
"vello_common",
|
"vello_common",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "glob"
|
||||||
|
version = "0.3.4"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "e4eba85ea1d0a966a983acd07deee566e67395d2d96b6fb39e62b5a833f1eb0b"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "glow"
|
name = "glow"
|
||||||
version = "0.17.0"
|
version = "0.17.0"
|
||||||
@@ -2235,6 +2372,17 @@ dependencies = [
|
|||||||
"gl_generator",
|
"gl_generator",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "goblin"
|
||||||
|
version = "0.8.2"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "1b363a30c165f666402fe6a3024d3bec7ebc898f96a4a23bd1c99f8dbf3f4f47"
|
||||||
|
dependencies = [
|
||||||
|
"log",
|
||||||
|
"plain",
|
||||||
|
"scroll",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "group"
|
name = "group"
|
||||||
version = "0.13.0"
|
version = "0.13.0"
|
||||||
@@ -2669,6 +2817,8 @@ checksum = "d466e9454f08e4a911e14806c24e16fba1b4c121d1ea474396f396069cf949d9"
|
|||||||
dependencies = [
|
dependencies = [
|
||||||
"equivalent",
|
"equivalent",
|
||||||
"hashbrown 0.17.1",
|
"hashbrown 0.17.1",
|
||||||
|
"serde",
|
||||||
|
"serde_core",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@@ -3072,6 +3222,12 @@ dependencies = [
|
|||||||
"unicase",
|
"unicase",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "minimal-lexical"
|
||||||
|
version = "0.2.1"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "68354c5c6bd36d73ff3feceb05efa59b6acb7626617f4962be322a825e61f79a"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "miniz_oxide"
|
name = "miniz_oxide"
|
||||||
version = "0.8.9"
|
version = "0.8.9"
|
||||||
@@ -3177,6 +3333,16 @@ version = "0.2.0"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "2bf50223579dc7cdcfb3bfcacf7069ff68243f8c363f62ffa99cf000a6b9c451"
|
checksum = "2bf50223579dc7cdcfb3bfcacf7069ff68243f8c363f62ffa99cf000a6b9c451"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "nom"
|
||||||
|
version = "7.1.3"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "d273983c5a657a70a3e8f2a01329822f3b8c8172b73826411a55751e404a0a4a"
|
||||||
|
dependencies = [
|
||||||
|
"memchr",
|
||||||
|
"minimal-lexical",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "nom"
|
name = "nom"
|
||||||
version = "8.0.0"
|
version = "8.0.0"
|
||||||
@@ -3814,7 +3980,7 @@ dependencies = [
|
|||||||
"log",
|
"log",
|
||||||
"md-5",
|
"md-5",
|
||||||
"memchr",
|
"memchr",
|
||||||
"nom",
|
"nom 8.0.0",
|
||||||
"num-bigint-dig",
|
"num-bigint-dig",
|
||||||
"num-traits",
|
"num-traits",
|
||||||
"num_enum",
|
"num_enum",
|
||||||
@@ -4094,6 +4260,25 @@ dependencies = [
|
|||||||
"tracing",
|
"tracing",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "pstr-android"
|
||||||
|
version = "0.1.1"
|
||||||
|
dependencies = [
|
||||||
|
"android_logger",
|
||||||
|
"jni 0.21.1",
|
||||||
|
"log",
|
||||||
|
"parking_lot",
|
||||||
|
"pstr-core",
|
||||||
|
"pstr-meta",
|
||||||
|
"pstr-stream",
|
||||||
|
"rustls-platform-verifier",
|
||||||
|
"serde",
|
||||||
|
"serde_json",
|
||||||
|
"thiserror 2.0.19",
|
||||||
|
"tokio",
|
||||||
|
"uniffi",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "pstr-app"
|
name = "pstr-app"
|
||||||
version = "0.1.1"
|
version = "0.1.1"
|
||||||
@@ -4108,6 +4293,8 @@ dependencies = [
|
|||||||
"pstr-meta",
|
"pstr-meta",
|
||||||
"pstr-player",
|
"pstr-player",
|
||||||
"pstr-stream",
|
"pstr-stream",
|
||||||
|
"serde",
|
||||||
|
"serde_json",
|
||||||
"sha2 0.10.9",
|
"sha2 0.10.9",
|
||||||
"tokio",
|
"tokio",
|
||||||
"tracing",
|
"tracing",
|
||||||
@@ -4154,7 +4341,6 @@ dependencies = [
|
|||||||
name = "pstr-meta"
|
name = "pstr-meta"
|
||||||
version = "0.1.1"
|
version = "0.1.1"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"keyring",
|
|
||||||
"pstr-core",
|
"pstr-core",
|
||||||
"reqwest",
|
"reqwest",
|
||||||
"serde",
|
"serde",
|
||||||
@@ -4409,6 +4595,18 @@ dependencies = [
|
|||||||
"thiserror 1.0.69",
|
"thiserror 1.0.69",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "regex"
|
||||||
|
version = "1.13.1"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "f020237b6c8eed93db2e2cb53c00c60a8e1bc73da7d073199a1180401450218d"
|
||||||
|
dependencies = [
|
||||||
|
"aho-corasick",
|
||||||
|
"memchr",
|
||||||
|
"regex-automata",
|
||||||
|
"regex-syntax",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "regex-automata"
|
name = "regex-automata"
|
||||||
version = "0.4.16"
|
version = "0.4.16"
|
||||||
@@ -4733,6 +4931,26 @@ version = "1.2.0"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49"
|
checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "scroll"
|
||||||
|
version = "0.12.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "6ab8598aa408498679922eff7fa985c25d58a90771bd6be794434c5277eab1a6"
|
||||||
|
dependencies = [
|
||||||
|
"scroll_derive",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "scroll_derive"
|
||||||
|
version = "0.12.1"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "1783eabc414609e28a5ba76aee5ddd52199f7107a0b24c2e9746a1ecc34a683d"
|
||||||
|
dependencies = [
|
||||||
|
"proc-macro2",
|
||||||
|
"quote",
|
||||||
|
"syn 2.0.119",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "sec1"
|
name = "sec1"
|
||||||
version = "0.7.3"
|
version = "0.7.3"
|
||||||
@@ -4814,6 +5032,10 @@ name = "semver"
|
|||||||
version = "1.0.28"
|
version = "1.0.28"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "8a7852d02fc848982e0c167ef163aaff9cd91dc640ba85e263cb1ce46fae51cd"
|
checksum = "8a7852d02fc848982e0c167ef163aaff9cd91dc640ba85e263cb1ce46fae51cd"
|
||||||
|
dependencies = [
|
||||||
|
"serde",
|
||||||
|
"serde_core",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "serde"
|
name = "serde"
|
||||||
@@ -4869,6 +5091,15 @@ dependencies = [
|
|||||||
"syn 3.0.3",
|
"syn 3.0.3",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "serde_spanned"
|
||||||
|
version = "1.1.1"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "6662b5879511e06e8999a8a235d848113e942c9124f211511b16466ee2995f26"
|
||||||
|
dependencies = [
|
||||||
|
"serde_core",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "serde_urlencoded"
|
name = "serde_urlencoded"
|
||||||
version = "0.7.1"
|
version = "0.7.1"
|
||||||
@@ -5023,6 +5254,12 @@ version = "0.1.5"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "e3a9fe34e3e7a50316060351f37187a3f546bce95496156754b601a5fa71b76e"
|
checksum = "e3a9fe34e3e7a50316060351f37187a3f546bce95496156754b601a5fa71b76e"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "siphasher"
|
||||||
|
version = "1.0.3"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "8ee5873ec9cce0195efcb7a4e9507a04cd49aec9c83d0389df45b1ef7ba2e649"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "skrifa"
|
name = "skrifa"
|
||||||
version = "0.42.1"
|
version = "0.42.1"
|
||||||
@@ -5054,6 +5291,12 @@ version = "1.15.2"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "8ed6a63f02c8539c91a8685a86f4099661ba3da017932f6ebbea6de3f0fa7c90"
|
checksum = "8ed6a63f02c8539c91a8685a86f4099661ba3da017932f6ebbea6de3f0fa7c90"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "smawk"
|
||||||
|
version = "0.3.3"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "e8e2fb0f499abb4d162f2bedad68f5ef91a1682b5a03596ddb67efd37768d100"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "smithay-client-toolkit"
|
name = "smithay-client-toolkit"
|
||||||
version = "0.19.2"
|
version = "0.19.2"
|
||||||
@@ -5258,6 +5501,15 @@ dependencies = [
|
|||||||
"windows-sys 0.61.2",
|
"windows-sys 0.61.2",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "textwrap"
|
||||||
|
version = "0.16.2"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "c13547615a44dc9c452a8a534638acdf07120d4b6847c8178705da06306a3057"
|
||||||
|
dependencies = [
|
||||||
|
"smawk",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "thiserror"
|
name = "thiserror"
|
||||||
version = "1.0.69"
|
version = "1.0.69"
|
||||||
@@ -5395,6 +5647,21 @@ dependencies = [
|
|||||||
"tokio",
|
"tokio",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "toml"
|
||||||
|
version = "1.1.4+spec-1.1.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "3aace63f4bbcdfc2c965b059de67119c89c4017a70d633be6c104910f67056f5"
|
||||||
|
dependencies = [
|
||||||
|
"indexmap",
|
||||||
|
"serde_core",
|
||||||
|
"serde_spanned",
|
||||||
|
"toml_datetime",
|
||||||
|
"toml_parser",
|
||||||
|
"toml_writer",
|
||||||
|
"winnow",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "toml_datetime"
|
name = "toml_datetime"
|
||||||
version = "1.1.1+spec-1.1.0"
|
version = "1.1.1+spec-1.1.0"
|
||||||
@@ -5425,6 +5692,12 @@ dependencies = [
|
|||||||
"winnow",
|
"winnow",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "toml_writer"
|
||||||
|
version = "1.1.2+spec-1.1.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "7d56353a2a665ad0f41a421187180aab746c8c325620617ad883a99a1cbe66d2"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "tower"
|
name = "tower"
|
||||||
version = "0.5.3"
|
version = "0.5.3"
|
||||||
@@ -5614,6 +5887,127 @@ version = "0.2.6"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "ebc1c04c71510c7f702b52b7c350734c9ff1295c464a03335b00bb84fc54f853"
|
checksum = "ebc1c04c71510c7f702b52b7c350734c9ff1295c464a03335b00bb84fc54f853"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "uniffi"
|
||||||
|
version = "0.32.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "a782a48d72cfd7a2d65cfc7c691dbf5375c43104b3c195f7eccc716dcc3540c8"
|
||||||
|
dependencies = [
|
||||||
|
"anyhow",
|
||||||
|
"camino",
|
||||||
|
"cargo_metadata",
|
||||||
|
"clap",
|
||||||
|
"uniffi_bindgen",
|
||||||
|
"uniffi_core",
|
||||||
|
"uniffi_macros",
|
||||||
|
"uniffi_pipeline",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "uniffi_bindgen"
|
||||||
|
version = "0.32.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "533b0312c73e3b54eb78a4b257ceae390962dd4767995778309a74644643f9ac"
|
||||||
|
dependencies = [
|
||||||
|
"anyhow",
|
||||||
|
"askama",
|
||||||
|
"camino",
|
||||||
|
"cargo_metadata",
|
||||||
|
"fs-err",
|
||||||
|
"glob",
|
||||||
|
"goblin",
|
||||||
|
"heck",
|
||||||
|
"indexmap",
|
||||||
|
"once_cell",
|
||||||
|
"serde",
|
||||||
|
"tempfile",
|
||||||
|
"textwrap",
|
||||||
|
"toml",
|
||||||
|
"uniffi_internal_macros",
|
||||||
|
"uniffi_meta",
|
||||||
|
"uniffi_pipeline",
|
||||||
|
"uniffi_udl",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "uniffi_core"
|
||||||
|
version = "0.32.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "8e32e261c5b0dfaba6488f536e71957dddd6b1a498ac7eb791bee56b60a086be"
|
||||||
|
dependencies = [
|
||||||
|
"anyhow",
|
||||||
|
"bytes",
|
||||||
|
"once_cell",
|
||||||
|
"static_assertions",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "uniffi_internal_macros"
|
||||||
|
version = "0.32.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "84ae78069a5e6772ef694fd5bdb628532c88d2c2f0e7142bf6a384636eadb1af"
|
||||||
|
dependencies = [
|
||||||
|
"anyhow",
|
||||||
|
"indexmap",
|
||||||
|
"proc-macro2",
|
||||||
|
"quote",
|
||||||
|
"syn 2.0.119",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "uniffi_macros"
|
||||||
|
version = "0.32.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "330be6770532e86320df31f54c70bb0be67588594e8e77fa56e9083a3fed5d0d"
|
||||||
|
dependencies = [
|
||||||
|
"camino",
|
||||||
|
"fs-err",
|
||||||
|
"once_cell",
|
||||||
|
"proc-macro2",
|
||||||
|
"quote",
|
||||||
|
"serde",
|
||||||
|
"syn 2.0.119",
|
||||||
|
"toml",
|
||||||
|
"uniffi_meta",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "uniffi_meta"
|
||||||
|
version = "0.32.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "78de021f5547e56ab16c665a49d67d4fd3d31e77422f7739a2e9359d328cd9e7"
|
||||||
|
dependencies = [
|
||||||
|
"anyhow",
|
||||||
|
"siphasher",
|
||||||
|
"uniffi_internal_macros",
|
||||||
|
"uniffi_pipeline",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "uniffi_pipeline"
|
||||||
|
version = "0.32.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "3f8201bb1907ed8a42d80e11cbc25c8a033e7a31c3cff1d911f56eedb81d4948"
|
||||||
|
dependencies = [
|
||||||
|
"anyhow",
|
||||||
|
"heck",
|
||||||
|
"indexmap",
|
||||||
|
"tempfile",
|
||||||
|
"uniffi_internal_macros",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "uniffi_udl"
|
||||||
|
version = "0.32.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "a6e57996bc58009cc29bf04845d627ae313c2547b87171c1c349d6c51a1656c0"
|
||||||
|
dependencies = [
|
||||||
|
"anyhow",
|
||||||
|
"textwrap",
|
||||||
|
"uniffi_meta",
|
||||||
|
"weedle2",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "universal-hash"
|
name = "universal-hash"
|
||||||
version = "0.5.1"
|
version = "0.5.1"
|
||||||
@@ -6007,6 +6401,15 @@ dependencies = [
|
|||||||
"rustls-pki-types",
|
"rustls-pki-types",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "weedle2"
|
||||||
|
version = "5.0.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "998d2c24ec099a87daf9467808859f9d82b61f1d9c9701251aea037f514eae0e"
|
||||||
|
dependencies = [
|
||||||
|
"nom 7.1.3",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "weezl"
|
name = "weezl"
|
||||||
version = "0.1.12"
|
version = "0.1.12"
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ members = [
|
|||||||
"crates/pstr-player",
|
"crates/pstr-player",
|
||||||
"crates/pstr-app",
|
"crates/pstr-app",
|
||||||
"crates/pstr-cli",
|
"crates/pstr-cli",
|
||||||
|
"crates/pstr-android",
|
||||||
]
|
]
|
||||||
|
|
||||||
[workspace.package]
|
[workspace.package]
|
||||||
@@ -90,6 +91,7 @@ lru = "0.12"
|
|||||||
# stream layer needs a `dyn` block source so its cache, ring and read-ahead can
|
# stream layer needs a `dyn` block source so its cache, ring and read-ahead can
|
||||||
# be tested against a fake without a network or an account.
|
# be tested against a fake without a network or an account.
|
||||||
async-trait = "0.1"
|
async-trait = "0.1"
|
||||||
|
uniffi = "0.32"
|
||||||
|
|
||||||
# The SDK builds against the registry — 0.4.0 is published. To test an
|
# The SDK builds against the registry — 0.4.0 is published. To test an
|
||||||
# unpublished `../proton-sdk-rs` change against this app, add locally and do
|
# unpublished `../proton-sdk-rs` change against this app, add locally and do
|
||||||
|
|||||||
@@ -28,6 +28,10 @@ countdown, optional metadata, themes and share management. What is left is mostl
|
|||||||
(`.srt`/`.ass` beside the video are invisible to the crawl today) and running the
|
(`.srt`/`.ass` beside the video are invisible to the crawl today) and running the
|
||||||
macOS packaging on a real Mac. See `docs/DEVELOPMENT.md`.
|
macOS packaging on a real Mac. See `docs/DEVELOPMENT.md`.
|
||||||
|
|
||||||
|
A native Android 12+ client is under `android/`, sharing the Rust catalog and
|
||||||
|
streaming implementation through UniFFI. Its toolchain, emulator, signing, and
|
||||||
|
release instructions are in [`docs/ANDROID.md`](docs/ANDROID.md).
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
cargo run --release -p pstr-app # the app
|
cargo run --release -p pstr-app # the app
|
||||||
```
|
```
|
||||||
@@ -64,7 +68,7 @@ against the SDK's own range download.
|
|||||||
| UI | egui/eframe on glow; mpv renders into the same GL context. |
|
| UI | egui/eframe on glow; mpv renders into the same GL context. |
|
||||||
| Themes | The shipped near-black palette plus Catppuccin Latte, Frappé, Macchiato and Mocha, each with a choice of eight accents — pink and sky among them — drawn as a gradient unless you turn that off. Picked on the Shares page, applied without a restart. |
|
| Themes | The shipped near-black palette plus Catppuccin Latte, Frappé, Macchiato and Mocha, each with a choice of eight accents — pink and sky among them — drawn as a gradient unless you turn that off. Picked on the Shares page, applied without a restart. |
|
||||||
| Metadata | Filename parsing, with optional AniList/TMDB enrichment — **off by default**, because enabling it sends your library's titles to a third party. It brings posters, synopses and per-episode titles; a title the scorer refuses to guess about can be pinned by hand, and a pinned match is never overwritten. |
|
| Metadata | Filename parsing, with optional AniList/TMDB enrichment — **off by default**, because enabling it sends your library's titles to a third party. It brings posters, synopses and per-episode titles; a title the scorer refuses to guess about can be pinned by hand, and a pinned match is never overwritten. |
|
||||||
| Platforms | Linux and Windows. The macOS packaging is written but has not been run on a Mac. |
|
| Platforms | Linux and Windows; macOS packaging is written but has not been run on a Mac. The Android 12+ phone/tablet client is under active development. |
|
||||||
|
|
||||||
## Building
|
## Building
|
||||||
|
|
||||||
@@ -84,6 +88,10 @@ and a portable `.zip` plus an `.msi` on Windows — are produced by
|
|||||||
`scripts/build.sh` and `scripts\build.ps1`; `packaging/README.md` has the
|
`scripts/build.sh` and `scripts\build.ps1`; `packaging/README.md` has the
|
||||||
per-platform detail.
|
per-platform detail.
|
||||||
|
|
||||||
|
Android builds use JDK 17, Android SDK 36/NDK 28, Gradle 8.13, `cargo-ndk`, and
|
||||||
|
UniFFI. Run `bash scripts/build-android.sh debug` after installing that toolchain;
|
||||||
|
see [`docs/ANDROID.md`](docs/ANDROID.md) for exact setup and release signing.
|
||||||
|
|
||||||
## Privacy
|
## Privacy
|
||||||
|
|
||||||
- The share URL fragment **is** the decryption password. It and any custom
|
- The share URL fragment **is** the decryption password. It and any custom
|
||||||
@@ -99,4 +107,6 @@ per-platform detail.
|
|||||||
|
|
||||||
## License
|
## License
|
||||||
|
|
||||||
MIT.
|
The shared Rust crates and desktop application are MIT licensed. The Android
|
||||||
|
application and its combined native distribution are GPL-3.0-or-later; see
|
||||||
|
[`android/LICENSE.md`](android/LICENSE.md) and the bundled-component notices.
|
||||||
@@ -0,0 +1,20 @@
|
|||||||
|
# proton-stream for Android license
|
||||||
|
|
||||||
|
SPDX-License-Identifier: GPL-3.0-or-later
|
||||||
|
|
||||||
|
Copyright (c) proton-stream contributors.
|
||||||
|
|
||||||
|
The Android application under this directory is free software: you may
|
||||||
|
redistribute it and/or modify it under the terms of the GNU General Public
|
||||||
|
License as published by the Free Software Foundation, either version 3 of the
|
||||||
|
License, or (at your option) any later version.
|
||||||
|
|
||||||
|
This program is distributed in the hope that it will be useful, but WITHOUT ANY
|
||||||
|
WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A
|
||||||
|
PARTICULAR PURPOSE. See the [GNU GPL version 3](https://www.gnu.org/licenses/gpl-3.0.html)
|
||||||
|
for the complete license text.
|
||||||
|
|
||||||
|
This licensing choice applies to the Android application and its distribution,
|
||||||
|
including the combined native player. Rust crates outside `android/` retain the
|
||||||
|
licenses declared in their manifests; the repository root `LICENSE` remains the
|
||||||
|
MIT license for those components.
|
||||||
@@ -0,0 +1,22 @@
|
|||||||
|
# Third-Party Notices
|
||||||
|
|
||||||
|
The Android distribution combines components under compatible open-source
|
||||||
|
licenses. Preserve their notices and license texts in source and binary
|
||||||
|
distributions.
|
||||||
|
|
||||||
|
| Component | License |
|
||||||
|
|---|---|
|
||||||
|
| mpv/libmpv | GPL-2.0-or-later by default; verify the pinned build configuration |
|
||||||
|
| LLVM libc++ shared runtime (Android NDK r29) | Apache-2.0 WITH LLVM-exception |
|
||||||
|
| AndroidX and Jetpack Compose | Apache-2.0 |
|
||||||
|
| Kotlin | Apache-2.0 |
|
||||||
|
| JNA | Apache-2.0 or LGPL-2.1-or-later |
|
||||||
|
| UniFFI | MPL-2.0 |
|
||||||
|
| proton-stream shared Rust crates | MIT |
|
||||||
|
|
||||||
|
This summary is not a replacement for dependency license files. Before a
|
||||||
|
release, generate an inventory from the resolved Cargo and Gradle lock data,
|
||||||
|
review it, and package the complete license texts in the application. A bundled
|
||||||
|
libmpv release must also identify the exact upstream source revision, local
|
||||||
|
patches, configuration flags, and reproducible build instructions, and make the
|
||||||
|
corresponding source available as the GPL requires.
|
||||||
@@ -0,0 +1,222 @@
|
|||||||
|
import org.gradle.api.tasks.Exec
|
||||||
|
import org.jetbrains.kotlin.gradle.dsl.JvmTarget
|
||||||
|
|
||||||
|
plugins {
|
||||||
|
alias(libs.plugins.android.application)
|
||||||
|
alias(libs.plugins.kotlin.android)
|
||||||
|
alias(libs.plugins.kotlin.compose)
|
||||||
|
}
|
||||||
|
|
||||||
|
val repositoryRoot = rootProject.projectDir.parentFile
|
||||||
|
val workspaceVersion = Regex("""(?m)^version\s*=\s*\"([^\"]+)\"""")
|
||||||
|
.find(repositoryRoot.resolve("Cargo.toml").readText())
|
||||||
|
?.groupValues?.get(1)
|
||||||
|
?: error("workspace version is missing from Cargo.toml")
|
||||||
|
|
||||||
|
fun semverVersionCode(version: String): Int {
|
||||||
|
val match = Regex("""^(\d+)\.(\d+)\.(\d+)(?:[-+][0-9A-Za-z.-]+)?$""").matchEntire(version)
|
||||||
|
?: error("Android version must start with major.minor.patch: $version")
|
||||||
|
val (major, minor, patch) = match.destructured.toList().take(3).map(String::toLong)
|
||||||
|
require(minor in 0..999 && patch in 0..999) { "minor and patch must fit three digits" }
|
||||||
|
val code = major * 1_000_000 + minor * 1_000 + patch
|
||||||
|
require(code in 1..2_100_000_000) { "semantic version is outside Android's version-code range" }
|
||||||
|
return code.toInt()
|
||||||
|
}
|
||||||
|
|
||||||
|
// The visible Android version is the workspace/release-tag version. Only the
|
||||||
|
// monotonically increasing Play version code may be overridden by release CI.
|
||||||
|
val releaseVersionName = workspaceVersion
|
||||||
|
val versionCodeOverride = providers.environmentVariable("ANDROID_VERSION_CODE").orNull
|
||||||
|
val releaseVersionCode = versionCodeOverride?.toIntOrNull()
|
||||||
|
?: if (versionCodeOverride == null) semverVersionCode(releaseVersionName)
|
||||||
|
else error("ANDROID_VERSION_CODE must be an integer")
|
||||||
|
require(releaseVersionCode in 1..2_100_000_000) { "ANDROID_VERSION_CODE must be a positive Play-compatible integer" }
|
||||||
|
|
||||||
|
tasks.register("printAndroidVersion") {
|
||||||
|
group = "help"
|
||||||
|
description = "Prints the resolved Android version name and code for release validation."
|
||||||
|
doLast {
|
||||||
|
println("versionName=$releaseVersionName")
|
||||||
|
println("versionCode=$releaseVersionCode")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
android {
|
||||||
|
namespace = "io.narl.protonstream"
|
||||||
|
compileSdk = 36
|
||||||
|
buildToolsVersion = "37.0.0"
|
||||||
|
ndkVersion = "29.0.14206865"
|
||||||
|
|
||||||
|
defaultConfig {
|
||||||
|
applicationId = "io.narl.protonstream"
|
||||||
|
minSdk = 31
|
||||||
|
targetSdk = 36
|
||||||
|
versionCode = releaseVersionCode
|
||||||
|
versionName = releaseVersionName
|
||||||
|
|
||||||
|
ndk {
|
||||||
|
abiFilters += listOf("arm64-v8a", "x86_64")
|
||||||
|
}
|
||||||
|
externalNativeBuild {
|
||||||
|
cmake {
|
||||||
|
arguments += "-DPSTR_MPV_ROOT=${layout.buildDirectory.dir("generated/mpv").get().asFile.absolutePath}"
|
||||||
|
arguments += "-DPSTR_RUST_JNI_ROOT=${layout.buildDirectory.dir("generated/jniLibs").get().asFile.absolutePath}"
|
||||||
|
cppFlags += listOf("-std=c++20")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
buildFeatures {
|
||||||
|
compose = true
|
||||||
|
buildConfig = true
|
||||||
|
}
|
||||||
|
|
||||||
|
sourceSets.named("main") {
|
||||||
|
java.srcDir(layout.buildDirectory.dir("generated/source/uniffi"))
|
||||||
|
jniLibs.srcDir(layout.buildDirectory.dir("generated/jniLibs"))
|
||||||
|
jniLibs.srcDir(layout.buildDirectory.dir("generated/mpv/jniLibs"))
|
||||||
|
}
|
||||||
|
|
||||||
|
externalNativeBuild {
|
||||||
|
cmake {
|
||||||
|
path = file("src/main/cpp/CMakeLists.txt")
|
||||||
|
version = "3.22.1"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
val releaseStore = System.getenv("ANDROID_RELEASE_STORE_FILE")
|
||||||
|
if (releaseStore != null) {
|
||||||
|
signingConfigs.create("release") {
|
||||||
|
storeFile = file(releaseStore)
|
||||||
|
storePassword = System.getenv("ANDROID_RELEASE_STORE_PASSWORD")
|
||||||
|
keyAlias = System.getenv("ANDROID_RELEASE_KEY_ALIAS")
|
||||||
|
keyPassword = System.getenv("ANDROID_RELEASE_KEY_PASSWORD")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
buildTypes {
|
||||||
|
debug {
|
||||||
|
applicationIdSuffix = ".debug"
|
||||||
|
versionNameSuffix = "-debug"
|
||||||
|
}
|
||||||
|
release {
|
||||||
|
isMinifyEnabled = true
|
||||||
|
isShrinkResources = true
|
||||||
|
if (releaseStore != null) signingConfig = signingConfigs.getByName("release")
|
||||||
|
proguardFiles(getDefaultProguardFile("proguard-android-optimize.txt"), "proguard-rules.pro")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
packaging {
|
||||||
|
jniLibs.useLegacyPackaging = false
|
||||||
|
resources.excludes += setOf("/META-INF/{AL2.0,LGPL2.1}")
|
||||||
|
}
|
||||||
|
|
||||||
|
compileOptions {
|
||||||
|
sourceCompatibility = JavaVersion.VERSION_17
|
||||||
|
targetCompatibility = JavaVersion.VERSION_17
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
kotlin {
|
||||||
|
compilerOptions {
|
||||||
|
jvmTarget.set(JvmTarget.JVM_17)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
val rustJniDir = layout.buildDirectory.dir("generated/jniLibs")
|
||||||
|
val uniffiSourceDir = layout.buildDirectory.dir("generated/source/uniffi")
|
||||||
|
val hostRustLibrary = repositoryRoot.resolve(
|
||||||
|
"target/debug/${System.mapLibraryName("pstr_android")}",
|
||||||
|
)
|
||||||
|
val stagedMpvDir = layout.buildDirectory.dir("generated/mpv")
|
||||||
|
|
||||||
|
val buildLibmpvAndroid by tasks.registering(Exec::class) {
|
||||||
|
group = "native"
|
||||||
|
description = "Builds the pinned GPL libmpv and stages both Android ABIs."
|
||||||
|
workingDir(repositoryRoot)
|
||||||
|
commandLine("bash", repositoryRoot.resolve("scripts/build-libmpv-android.sh").absolutePath)
|
||||||
|
inputs.file(repositoryRoot.resolve("scripts/build-libmpv-android.sh"))
|
||||||
|
outputs.files(
|
||||||
|
stagedMpvDir.map { it.file("jniLibs/arm64-v8a/libmpv.so") },
|
||||||
|
stagedMpvDir.map { it.file("jniLibs/x86_64/libmpv.so") },
|
||||||
|
stagedMpvDir.map { it.file("include/mpv/client.h") },
|
||||||
|
stagedMpvDir.map { it.file("REVISION") },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
val buildRustHost by tasks.registering(Exec::class) {
|
||||||
|
group = "rust"
|
||||||
|
description = "Builds the host cdylib used by UniFFI binding generation."
|
||||||
|
workingDir(repositoryRoot)
|
||||||
|
commandLine("cargo", "build", "-p", "pstr-android", "--lib")
|
||||||
|
inputs.files(fileTree(repositoryRoot.resolve("crates")) { include("**/*.rs", "**/Cargo.toml") })
|
||||||
|
outputs.file(hostRustLibrary)
|
||||||
|
}
|
||||||
|
|
||||||
|
val generateUniFfiBindings by tasks.registering(Exec::class) {
|
||||||
|
group = "rust"
|
||||||
|
description = "Generates Kotlin bindings from the pstr-android cdylib."
|
||||||
|
dependsOn(buildRustHost)
|
||||||
|
workingDir(repositoryRoot)
|
||||||
|
doFirst { uniffiSourceDir.get().asFile.mkdirs() }
|
||||||
|
commandLine(
|
||||||
|
"cargo", "run", "-p", "pstr-android", "--features", "bindgen", "--bin", "uniffi-bindgen", "--", "generate",
|
||||||
|
"--library", hostRustLibrary.absolutePath,
|
||||||
|
"--language", "kotlin",
|
||||||
|
"--no-format",
|
||||||
|
"--out-dir", uniffiSourceDir.get().asFile.absolutePath,
|
||||||
|
)
|
||||||
|
inputs.file(hostRustLibrary)
|
||||||
|
outputs.dir(uniffiSourceDir)
|
||||||
|
}
|
||||||
|
|
||||||
|
val buildRustAndroid by tasks.registering(Exec::class) {
|
||||||
|
group = "rust"
|
||||||
|
description = "Builds pstr-android for every supported ABI with cargo-ndk."
|
||||||
|
workingDir(repositoryRoot)
|
||||||
|
doFirst { rustJniDir.get().asFile.mkdirs() }
|
||||||
|
commandLine(
|
||||||
|
"cargo", "ndk",
|
||||||
|
"--target", "arm64-v8a",
|
||||||
|
"--target", "x86_64",
|
||||||
|
"--platform", "31",
|
||||||
|
"--output-dir", rustJniDir.get().asFile.absolutePath,
|
||||||
|
"build", "--release", "-p", "pstr-android", "--lib",
|
||||||
|
)
|
||||||
|
environment("CARGO_TARGET_DIR", repositoryRoot.resolve("target/android").absolutePath)
|
||||||
|
inputs.files(fileTree(repositoryRoot.resolve("crates")) { include("**/*.rs", "**/Cargo.toml") })
|
||||||
|
outputs.dir(rustJniDir)
|
||||||
|
}
|
||||||
|
|
||||||
|
tasks.named("preBuild") {
|
||||||
|
dependsOn(generateUniFfiBindings, buildRustAndroid, buildLibmpvAndroid)
|
||||||
|
}
|
||||||
|
|
||||||
|
tasks.configureEach {
|
||||||
|
if (name.contains("CMake", ignoreCase = true)) {
|
||||||
|
dependsOn(buildRustAndroid, buildLibmpvAndroid)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
dependencies {
|
||||||
|
implementation(libs.androidx.core)
|
||||||
|
implementation(libs.androidx.activity.compose)
|
||||||
|
implementation(libs.androidx.lifecycle.runtime)
|
||||||
|
implementation(libs.androidx.lifecycle.viewmodel.compose)
|
||||||
|
implementation(libs.androidx.navigation.compose)
|
||||||
|
implementation(libs.androidx.work.runtime)
|
||||||
|
implementation(libs.jna) { artifact { type = "aar" } }
|
||||||
|
|
||||||
|
|
||||||
|
implementation(platform(libs.androidx.compose.bom))
|
||||||
|
implementation(libs.androidx.compose.ui)
|
||||||
|
implementation(libs.androidx.compose.ui.tooling.preview)
|
||||||
|
implementation(libs.androidx.compose.runtime.livedata)
|
||||||
|
implementation(libs.androidx.compose.material3)
|
||||||
|
implementation(libs.androidx.compose.material3.adaptive)
|
||||||
|
implementation(libs.androidx.compose.material3.navigation.suite)
|
||||||
|
debugImplementation(libs.androidx.compose.ui.tooling)
|
||||||
|
testImplementation(libs.junit)
|
||||||
|
}
|
||||||
Vendored
+9
@@ -0,0 +1,9 @@
|
|||||||
|
-keep class uniffi.pstr_android.** { *; }
|
||||||
|
# rustls-platform-verifier loads these classes from Rust through JNI, so R8
|
||||||
|
# cannot infer that they are reachable from the Kotlin call graph.
|
||||||
|
-keep class org.rustls.platformverifier.** { *; }
|
||||||
|
-keep class com.sun.jna.** { *; }
|
||||||
|
-dontwarn com.sun.jna.**
|
||||||
|
-keepclasseswithmembernames class * {
|
||||||
|
native <methods>;
|
||||||
|
}
|
||||||
@@ -0,0 +1,44 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
|
||||||
|
xmlns:tools="http://schemas.android.com/tools">
|
||||||
|
<uses-permission android:name="android.permission.INTERNET" />
|
||||||
|
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
|
||||||
|
<uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
|
||||||
|
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_DATA_SYNC" />
|
||||||
|
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_MEDIA_PLAYBACK" />
|
||||||
|
|
||||||
|
<application
|
||||||
|
android:name=".ProtonStreamApplication"
|
||||||
|
android:allowBackup="false"
|
||||||
|
android:dataExtractionRules="@xml/data_extraction_rules"
|
||||||
|
android:icon="@drawable/ic_launcher_foreground"
|
||||||
|
android:label="@string/app_name"
|
||||||
|
android:supportsRtl="true"
|
||||||
|
android:theme="@style/Theme.ProtonStream">
|
||||||
|
|
||||||
|
<service
|
||||||
|
android:name="androidx.work.impl.foreground.SystemForegroundService"
|
||||||
|
android:exported="false"
|
||||||
|
android:foregroundServiceType="dataSync"
|
||||||
|
tools:node="merge" />
|
||||||
|
|
||||||
|
<service
|
||||||
|
android:name=".playback.PlaybackService"
|
||||||
|
android:exported="false"
|
||||||
|
android:foregroundServiceType="mediaPlayback" />
|
||||||
|
|
||||||
|
<activity
|
||||||
|
android:name=".MainActivity"
|
||||||
|
android:configChanges="screenSize|smallestScreenSize|screenLayout|orientation"
|
||||||
|
android:exported="true"
|
||||||
|
android:launchMode="singleTask"
|
||||||
|
android:supportsPictureInPicture="true"
|
||||||
|
android:resizeableActivity="true">
|
||||||
|
<intent-filter>
|
||||||
|
<action android:name="android.intent.action.MAIN" />
|
||||||
|
<category android:name="android.intent.category.LAUNCHER" />
|
||||||
|
</intent-filter>
|
||||||
|
</activity>
|
||||||
|
|
||||||
|
</application>
|
||||||
|
</manifest>
|
||||||
@@ -0,0 +1,232 @@
|
|||||||
|
GNU GENERAL PUBLIC LICENSE
|
||||||
|
Version 3, 29 June 2007
|
||||||
|
|
||||||
|
Copyright © 2007 Free Software Foundation, Inc. <https://fsf.org/>
|
||||||
|
|
||||||
|
Everyone is permitted to copy and distribute verbatim copies of this license document, but changing it is not allowed.
|
||||||
|
|
||||||
|
Preamble
|
||||||
|
|
||||||
|
The GNU General Public License is a free, copyleft license for software and other kinds of works.
|
||||||
|
|
||||||
|
The licenses for most software and other practical works are designed to take away your freedom to share and change the works. By contrast, the GNU General Public License is intended to guarantee your freedom to share and change all versions of a program--to make sure it remains free software for all its users. We, the Free Software Foundation, use the GNU General Public License for most of our software; it applies also to any other work released this way by its authors. You can apply it to your programs, too.
|
||||||
|
|
||||||
|
When we speak of free software, we are referring to freedom, not price. Our General Public Licenses are designed to make sure that you have the freedom to distribute copies of free software (and charge for them if you wish), that you receive source code or can get it if you want it, that you can change the software or use pieces of it in new free programs, and that you know you can do these things.
|
||||||
|
|
||||||
|
To protect your rights, we need to prevent others from denying you these rights or asking you to surrender the rights. Therefore, you have certain responsibilities if you distribute copies of the software, or if you modify it: responsibilities to respect the freedom of others.
|
||||||
|
|
||||||
|
For example, if you distribute copies of such a program, whether gratis or for a fee, you must pass on to the recipients the same freedoms that you received. You must make sure that they, too, receive or can get the source code. And you must show them these terms so they know their rights.
|
||||||
|
|
||||||
|
Developers that use the GNU GPL protect your rights with two steps: (1) assert copyright on the software, and (2) offer you this License giving you legal permission to copy, distribute and/or modify it.
|
||||||
|
|
||||||
|
For the developers' and authors' protection, the GPL clearly explains that there is no warranty for this free software. For both users' and authors' sake, the GPL requires that modified versions be marked as changed, so that their problems will not be attributed erroneously to authors of previous versions.
|
||||||
|
|
||||||
|
Some devices are designed to deny users access to install or run modified versions of the software inside them, although the manufacturer can do so. This is fundamentally incompatible with the aim of protecting users' freedom to change the software. The systematic pattern of such abuse occurs in the area of products for individuals to use, which is precisely where it is most unacceptable. Therefore, we have designed this version of the GPL to prohibit the practice for those products. If such problems arise substantially in other domains, we stand ready to extend this provision to those domains in future versions of the GPL, as needed to protect the freedom of users.
|
||||||
|
|
||||||
|
Finally, every program is threatened constantly by software patents. States should not allow patents to restrict development and use of software on general-purpose computers, but in those that do, we wish to avoid the special danger that patents applied to a free program could make it effectively proprietary. To prevent this, the GPL assures that patents cannot be used to render the program non-free.
|
||||||
|
|
||||||
|
The precise terms and conditions for copying, distribution and modification follow.
|
||||||
|
|
||||||
|
TERMS AND CONDITIONS
|
||||||
|
|
||||||
|
0. Definitions.
|
||||||
|
|
||||||
|
“This License” refers to version 3 of the GNU General Public License.
|
||||||
|
|
||||||
|
“Copyright” also means copyright-like laws that apply to other kinds of works, such as semiconductor masks.
|
||||||
|
|
||||||
|
“The Program” refers to any copyrightable work licensed under this License. Each licensee is addressed as “you”. “Licensees” and “recipients” may be individuals or organizations.
|
||||||
|
|
||||||
|
To “modify” a work means to copy from or adapt all or part of the work in a fashion requiring copyright permission, other than the making of an exact copy. The resulting work is called a “modified version” of the earlier work or a work “based on” the earlier work.
|
||||||
|
|
||||||
|
A “covered work” means either the unmodified Program or a work based on the Program.
|
||||||
|
|
||||||
|
To “propagate” a work means to do anything with it that, without permission, would make you directly or secondarily liable for infringement under applicable copyright law, except executing it on a computer or modifying a private copy. Propagation includes copying, distribution (with or without modification), making available to the public, and in some countries other activities as well.
|
||||||
|
|
||||||
|
To “convey” a work means any kind of propagation that enables other parties to make or receive copies. Mere interaction with a user through a computer network, with no transfer of a copy, is not conveying.
|
||||||
|
|
||||||
|
An interactive user interface displays “Appropriate Legal Notices” to the extent that it includes a convenient and prominently visible feature that (1) displays an appropriate copyright notice, and (2) tells the user that there is no warranty for the work (except to the extent that warranties are provided), that licensees may convey the work under this License, and how to view a copy of this License. If the interface presents a list of user commands or options, such as a menu, a prominent item in the list meets this criterion.
|
||||||
|
|
||||||
|
1. Source Code.
|
||||||
|
The “source code” for a work means the preferred form of the work for making modifications to it. “Object code” means any non-source form of a work.
|
||||||
|
|
||||||
|
A “Standard Interface” means an interface that either is an official standard defined by a recognized standards body, or, in the case of interfaces specified for a particular programming language, one that is widely used among developers working in that language.
|
||||||
|
|
||||||
|
The “System Libraries” of an executable work include anything, other than the work as a whole, that (a) is included in the normal form of packaging a Major Component, but which is not part of that Major Component, and (b) serves only to enable use of the work with that Major Component, or to implement a Standard Interface for which an implementation is available to the public in source code form. A “Major Component”, in this context, means a major essential component (kernel, window system, and so on) of the specific operating system (if any) on which the executable work runs, or a compiler used to produce the work, or an object code interpreter used to run it.
|
||||||
|
|
||||||
|
The “Corresponding Source” for a work in object code form means all the source code needed to generate, install, and (for an executable work) run the object code and to modify the work, including scripts to control those activities. However, it does not include the work's System Libraries, or general-purpose tools or generally available free programs which are used unmodified in performing those activities but which are not part of the work. For example, Corresponding Source includes interface definition files associated with source files for the work, and the source code for shared libraries and dynamically linked subprograms that the work is specifically designed to require, such as by intimate data communication or control flow between those subprograms and other parts of the work.
|
||||||
|
|
||||||
|
The Corresponding Source need not include anything that users can regenerate automatically from other parts of the Corresponding Source.
|
||||||
|
|
||||||
|
The Corresponding Source for a work in source code form is that same work.
|
||||||
|
|
||||||
|
2. Basic Permissions.
|
||||||
|
All rights granted under this License are granted for the term of copyright on the Program, and are irrevocable provided the stated conditions are met. This License explicitly affirms your unlimited permission to run the unmodified Program. The output from running a covered work is covered by this License only if the output, given its content, constitutes a covered work. This License acknowledges your rights of fair use or other equivalent, as provided by copyright law.
|
||||||
|
|
||||||
|
You may make, run and propagate covered works that you do not convey, without conditions so long as your license otherwise remains in force. You may convey covered works to others for the sole purpose of having them make modifications exclusively for you, or provide you with facilities for running those works, provided that you comply with the terms of this License in conveying all material for which you do not control copyright. Those thus making or running the covered works for you must do so exclusively on your behalf, under your direction and control, on terms that prohibit them from making any copies of your copyrighted material outside their relationship with you.
|
||||||
|
|
||||||
|
Conveying under any other circumstances is permitted solely under the conditions stated below. Sublicensing is not allowed; section 10 makes it unnecessary.
|
||||||
|
|
||||||
|
3. Protecting Users' Legal Rights From Anti-Circumvention Law.
|
||||||
|
No covered work shall be deemed part of an effective technological measure under any applicable law fulfilling obligations under article 11 of the WIPO copyright treaty adopted on 20 December 1996, or similar laws prohibiting or restricting circumvention of such measures.
|
||||||
|
|
||||||
|
When you convey a covered work, you waive any legal power to forbid circumvention of technological measures to the extent such circumvention is effected by exercising rights under this License with respect to the covered work, and you disclaim any intention to limit operation or modification of the work as a means of enforcing, against the work's users, your or third parties' legal rights to forbid circumvention of technological measures.
|
||||||
|
|
||||||
|
4. Conveying Verbatim Copies.
|
||||||
|
You may convey verbatim copies of the Program's source code as you receive it, in any medium, provided that you conspicuously and appropriately publish on each copy an appropriate copyright notice; keep intact all notices stating that this License and any non-permissive terms added in accord with section 7 apply to the code; keep intact all notices of the absence of any warranty; and give all recipients a copy of this License along with the Program.
|
||||||
|
|
||||||
|
You may charge any price or no price for each copy that you convey, and you may offer support or warranty protection for a fee.
|
||||||
|
|
||||||
|
5. Conveying Modified Source Versions.
|
||||||
|
You may convey a work based on the Program, or the modifications to produce it from the Program, in the form of source code under the terms of section 4, provided that you also meet all of these conditions:
|
||||||
|
|
||||||
|
a) The work must carry prominent notices stating that you modified it, and giving a relevant date.
|
||||||
|
|
||||||
|
b) The work must carry prominent notices stating that it is released under this License and any conditions added under section 7. This requirement modifies the requirement in section 4 to “keep intact all notices”.
|
||||||
|
|
||||||
|
c) You must license the entire work, as a whole, under this License to anyone who comes into possession of a copy. This License will therefore apply, along with any applicable section 7 additional terms, to the whole of the work, and all its parts, regardless of how they are packaged. This License gives no permission to license the work in any other way, but it does not invalidate such permission if you have separately received it.
|
||||||
|
|
||||||
|
d) If the work has interactive user interfaces, each must display Appropriate Legal Notices; however, if the Program has interactive interfaces that do not display Appropriate Legal Notices, your work need not make them do so.
|
||||||
|
|
||||||
|
A compilation of a covered work with other separate and independent works, which are not by their nature extensions of the covered work, and which are not combined with it such as to form a larger program, in or on a volume of a storage or distribution medium, is called an “aggregate” if the compilation and its resulting copyright are not used to limit the access or legal rights of the compilation's users beyond what the individual works permit. Inclusion of a covered work in an aggregate does not cause this License to apply to the other parts of the aggregate.
|
||||||
|
|
||||||
|
6. Conveying Non-Source Forms.
|
||||||
|
You may convey a covered work in object code form under the terms of sections 4 and 5, provided that you also convey the machine-readable Corresponding Source under the terms of this License, in one of these ways:
|
||||||
|
|
||||||
|
a) Convey the object code in, or embodied in, a physical product (including a physical distribution medium), accompanied by the Corresponding Source fixed on a durable physical medium customarily used for software interchange.
|
||||||
|
|
||||||
|
b) Convey the object code in, or embodied in, a physical product (including a physical distribution medium), accompanied by a written offer, valid for at least three years and valid for as long as you offer spare parts or customer support for that product model, to give anyone who possesses the object code either (1) a copy of the Corresponding Source for all the software in the product that is covered by this License, on a durable physical medium customarily used for software interchange, for a price no more than your reasonable cost of physically performing this conveying of source, or (2) access to copy the Corresponding Source from a network server at no charge.
|
||||||
|
|
||||||
|
c) Convey individual copies of the object code with a copy of the written offer to provide the Corresponding Source. This alternative is allowed only occasionally and noncommercially, and only if you received the object code with such an offer, in accord with subsection 6b.
|
||||||
|
|
||||||
|
d) Convey the object code by offering access from a designated place (gratis or for a charge), and offer equivalent access to the Corresponding Source in the same way through the same place at no further charge. You need not require recipients to copy the Corresponding Source along with the object code. If the place to copy the object code is a network server, the Corresponding Source may be on a different server (operated by you or a third party) that supports equivalent copying facilities, provided you maintain clear directions next to the object code saying where to find the Corresponding Source. Regardless of what server hosts the Corresponding Source, you remain obligated to ensure that it is available for as long as needed to satisfy these requirements.
|
||||||
|
|
||||||
|
e) Convey the object code using peer-to-peer transmission, provided you inform other peers where the object code and Corresponding Source of the work are being offered to the general public at no charge under subsection 6d.
|
||||||
|
|
||||||
|
A separable portion of the object code, whose source code is excluded from the Corresponding Source as a System Library, need not be included in conveying the object code work.
|
||||||
|
|
||||||
|
A “User Product” is either (1) a “consumer product”, which means any tangible personal property which is normally used for personal, family, or household purposes, or (2) anything designed or sold for incorporation into a dwelling. In determining whether a product is a consumer product, doubtful cases shall be resolved in favor of coverage. For a particular product received by a particular user, “normally used” refers to a typical or common use of that class of product, regardless of the status of the particular user or of the way in which the particular user actually uses, or expects or is expected to use, the product. A product is a consumer product regardless of whether the product has substantial commercial, industrial or non-consumer uses, unless such uses represent the only significant mode of use of the product.
|
||||||
|
|
||||||
|
“Installation Information” for a User Product means any methods, procedures, authorization keys, or other information required to install and execute modified versions of a covered work in that User Product from a modified version of its Corresponding Source. The information must suffice to ensure that the continued functioning of the modified object code is in no case prevented or interfered with solely because modification has been made.
|
||||||
|
|
||||||
|
If you convey an object code work under this section in, or with, or specifically for use in, a User Product, and the conveying occurs as part of a transaction in which the right of possession and use of the User Product is transferred to the recipient in perpetuity or for a fixed term (regardless of how the transaction is characterized), the Corresponding Source conveyed under this section must be accompanied by the Installation Information. But this requirement does not apply if neither you nor any third party retains the ability to install modified object code on the User Product (for example, the work has been installed in ROM).
|
||||||
|
|
||||||
|
The requirement to provide Installation Information does not include a requirement to continue to provide support service, warranty, or updates for a work that has been modified or installed by the recipient, or for the User Product in which it has been modified or installed. Access to a network may be denied when the modification itself materially and adversely affects the operation of the network or violates the rules and protocols for communication across the network.
|
||||||
|
|
||||||
|
Corresponding Source conveyed, and Installation Information provided, in accord with this section must be in a format that is publicly documented (and with an implementation available to the public in source code form), and must require no special password or key for unpacking, reading or copying.
|
||||||
|
|
||||||
|
7. Additional Terms.
|
||||||
|
“Additional permissions” are terms that supplement the terms of this License by making exceptions from one or more of its conditions. Additional permissions that are applicable to the entire Program shall be treated as though they were included in this License, to the extent that they are valid under applicable law. If additional permissions apply only to part of the Program, that part may be used separately under those permissions, but the entire Program remains governed by this License without regard to the additional permissions.
|
||||||
|
|
||||||
|
When you convey a copy of a covered work, you may at your option remove any additional permissions from that copy, or from any part of it. (Additional permissions may be written to require their own removal in certain cases when you modify the work.) You may place additional permissions on material, added by you to a covered work, for which you have or can give appropriate copyright permission.
|
||||||
|
|
||||||
|
Notwithstanding any other provision of this License, for material you add to a covered work, you may (if authorized by the copyright holders of that material) supplement the terms of this License with terms:
|
||||||
|
|
||||||
|
a) Disclaiming warranty or limiting liability differently from the terms of sections 15 and 16 of this License; or
|
||||||
|
|
||||||
|
b) Requiring preservation of specified reasonable legal notices or author attributions in that material or in the Appropriate Legal Notices displayed by works containing it; or
|
||||||
|
|
||||||
|
c) Prohibiting misrepresentation of the origin of that material, or requiring that modified versions of such material be marked in reasonable ways as different from the original version; or
|
||||||
|
|
||||||
|
d) Limiting the use for publicity purposes of names of licensors or authors of the material; or
|
||||||
|
|
||||||
|
e) Declining to grant rights under trademark law for use of some trade names, trademarks, or service marks; or
|
||||||
|
|
||||||
|
f) Requiring indemnification of licensors and authors of that material by anyone who conveys the material (or modified versions of it) with contractual assumptions of liability to the recipient, for any liability that these contractual assumptions directly impose on those licensors and authors.
|
||||||
|
|
||||||
|
All other non-permissive additional terms are considered “further restrictions” within the meaning of section 10. If the Program as you received it, or any part of it, contains a notice stating that it is governed by this License along with a term that is a further restriction, you may remove that term. If a license document contains a further restriction but permits relicensing or conveying under this License, you may add to a covered work material governed by the terms of that license document, provided that the further restriction does not survive such relicensing or conveying.
|
||||||
|
|
||||||
|
If you add terms to a covered work in accord with this section, you must place, in the relevant source files, a statement of the additional terms that apply to those files, or a notice indicating where to find the applicable terms.
|
||||||
|
|
||||||
|
Additional terms, permissive or non-permissive, may be stated in the form of a separately written license, or stated as exceptions; the above requirements apply either way.
|
||||||
|
|
||||||
|
8. Termination.
|
||||||
|
You may not propagate or modify a covered work except as expressly provided under this License. Any attempt otherwise to propagate or modify it is void, and will automatically terminate your rights under this License (including any patent licenses granted under the third paragraph of section 11).
|
||||||
|
|
||||||
|
However, if you cease all violation of this License, then your license from a particular copyright holder is reinstated (a) provisionally, unless and until the copyright holder explicitly and finally terminates your license, and (b) permanently, if the copyright holder fails to notify you of the violation by some reasonable means prior to 60 days after the cessation.
|
||||||
|
|
||||||
|
Moreover, your license from a particular copyright holder is reinstated permanently if the copyright holder notifies you of the violation by some reasonable means, this is the first time you have received notice of violation of this License (for any work) from that copyright holder, and you cure the violation prior to 30 days after your receipt of the notice.
|
||||||
|
|
||||||
|
Termination of your rights under this section does not terminate the licenses of parties who have received copies or rights from you under this License. If your rights have been terminated and not permanently reinstated, you do not qualify to receive new licenses for the same material under section 10.
|
||||||
|
|
||||||
|
9. Acceptance Not Required for Having Copies.
|
||||||
|
You are not required to accept this License in order to receive or run a copy of the Program. Ancillary propagation of a covered work occurring solely as a consequence of using peer-to-peer transmission to receive a copy likewise does not require acceptance. However, nothing other than this License grants you permission to propagate or modify any covered work. These actions infringe copyright if you do not accept this License. Therefore, by modifying or propagating a covered work, you indicate your acceptance of this License to do so.
|
||||||
|
|
||||||
|
10. Automatic Licensing of Downstream Recipients.
|
||||||
|
Each time you convey a covered work, the recipient automatically receives a license from the original licensors, to run, modify and propagate that work, subject to this License. You are not responsible for enforcing compliance by third parties with this License.
|
||||||
|
|
||||||
|
An “entity transaction” is a transaction transferring control of an organization, or substantially all assets of one, or subdividing an organization, or merging organizations. If propagation of a covered work results from an entity transaction, each party to that transaction who receives a copy of the work also receives whatever licenses to the work the party's predecessor in interest had or could give under the previous paragraph, plus a right to possession of the Corresponding Source of the work from the predecessor in interest, if the predecessor has it or can get it with reasonable efforts.
|
||||||
|
|
||||||
|
You may not impose any further restrictions on the exercise of the rights granted or affirmed under this License. For example, you may not impose a license fee, royalty, or other charge for exercise of rights granted under this License, and you may not initiate litigation (including a cross-claim or counterclaim in a lawsuit) alleging that any patent claim is infringed by making, using, selling, offering for sale, or importing the Program or any portion of it.
|
||||||
|
|
||||||
|
11. Patents.
|
||||||
|
A “contributor” is a copyright holder who authorizes use under this License of the Program or a work on which the Program is based. The work thus licensed is called the contributor's “contributor version”.
|
||||||
|
|
||||||
|
A contributor's “essential patent claims” are all patent claims owned or controlled by the contributor, whether already acquired or hereafter acquired, that would be infringed by some manner, permitted by this License, of making, using, or selling its contributor version, but do not include claims that would be infringed only as a consequence of further modification of the contributor version. For purposes of this definition, “control” includes the right to grant patent sublicenses in a manner consistent with the requirements of this License.
|
||||||
|
|
||||||
|
Each contributor grants you a non-exclusive, worldwide, royalty-free patent license under the contributor's essential patent claims, to make, use, sell, offer for sale, import and otherwise run, modify and propagate the contents of its contributor version.
|
||||||
|
|
||||||
|
In the following three paragraphs, a “patent license” is any express agreement or commitment, however denominated, not to enforce a patent (such as an express permission to practice a patent or covenant not to sue for patent infringement). To “grant” such a patent license to a party means to make such an agreement or commitment not to enforce a patent against the party.
|
||||||
|
|
||||||
|
If you convey a covered work, knowingly relying on a patent license, and the Corresponding Source of the work is not available for anyone to copy, free of charge and under the terms of this License, through a publicly available network server or other readily accessible means, then you must either (1) cause the Corresponding Source to be so available, or (2) arrange to deprive yourself of the benefit of the patent license for this particular work, or (3) arrange, in a manner consistent with the requirements of this License, to extend the patent license to downstream recipients. “Knowingly relying” means you have actual knowledge that, but for the patent license, your conveying the covered work in a country, or your recipient's use of the covered work in a country, would infringe one or more identifiable patents in that country that you have reason to believe are valid.
|
||||||
|
|
||||||
|
If, pursuant to or in connection with a single transaction or arrangement, you convey, or propagate by procuring conveyance of, a covered work, and grant a patent license to some of the parties receiving the covered work authorizing them to use, propagate, modify or convey a specific copy of the covered work, then the patent license you grant is automatically extended to all recipients of the covered work and works based on it.
|
||||||
|
|
||||||
|
A patent license is “discriminatory” if it does not include within the scope of its coverage, prohibits the exercise of, or is conditioned on the non-exercise of one or more of the rights that are specifically granted under this License. You may not convey a covered work if you are a party to an arrangement with a third party that is in the business of distributing software, under which you make payment to the third party based on the extent of your activity of conveying the work, and under which the third party grants, to any of the parties who would receive the covered work from you, a discriminatory patent license (a) in connection with copies of the covered work conveyed by you (or copies made from those copies), or (b) primarily for and in connection with specific products or compilations that contain the covered work, unless you entered into that arrangement, or that patent license was granted, prior to 28 March 2007.
|
||||||
|
|
||||||
|
Nothing in this License shall be construed as excluding or limiting any implied license or other defenses to infringement that may otherwise be available to you under applicable patent law.
|
||||||
|
|
||||||
|
12. No Surrender of Others' Freedom.
|
||||||
|
If conditions are imposed on you (whether by court order, agreement or otherwise) that contradict the conditions of this License, they do not excuse you from the conditions of this License. If you cannot convey a covered work so as to satisfy simultaneously your obligations under this License and any other pertinent obligations, then as a consequence you may not convey it at all. For example, if you agree to terms that obligate you to collect a royalty for further conveying from those to whom you convey the Program, the only way you could satisfy both those terms and this License would be to refrain entirely from conveying the Program.
|
||||||
|
|
||||||
|
13. Use with the GNU Affero General Public License.
|
||||||
|
Notwithstanding any other provision of this License, you have permission to link or combine any covered work with a work licensed under version 3 of the GNU Affero General Public License into a single combined work, and to convey the resulting work. The terms of this License will continue to apply to the part which is the covered work, but the special requirements of the GNU Affero General Public License, section 13, concerning interaction through a network will apply to the combination as such.
|
||||||
|
|
||||||
|
14. Revised Versions of this License.
|
||||||
|
The Free Software Foundation may publish revised and/or new versions of the GNU General Public License from time to time. Such new versions will be similar in spirit to the present version, but may differ in detail to address new problems or concerns.
|
||||||
|
|
||||||
|
Each version is given a distinguishing version number. If the Program specifies that a certain numbered version of the GNU General Public License “or any later version” applies to it, you have the option of following the terms and conditions either of that numbered version or of any later version published by the Free Software Foundation. If the Program does not specify a version number of the GNU General Public License, you may choose any version ever published by the Free Software Foundation.
|
||||||
|
|
||||||
|
If the Program specifies that a proxy can decide which future versions of the GNU General Public License can be used, that proxy's public statement of acceptance of a version permanently authorizes you to choose that version for the Program.
|
||||||
|
|
||||||
|
Later license versions may give you additional or different permissions. However, no additional obligations are imposed on any author or copyright holder as a result of your choosing to follow a later version.
|
||||||
|
|
||||||
|
15. Disclaimer of Warranty.
|
||||||
|
THERE IS NO WARRANTY FOR THE PROGRAM, TO THE EXTENT PERMITTED BY APPLICABLE LAW. EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT HOLDERS AND/OR OTHER PARTIES PROVIDE THE PROGRAM “AS IS” WITHOUT WARRANTY OF ANY KIND, EITHER EXPRESSED OR IMPLIED, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE. THE ENTIRE RISK AS TO THE QUALITY AND PERFORMANCE OF THE PROGRAM IS WITH YOU. SHOULD THE PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF ALL NECESSARY SERVICING, REPAIR OR CORRECTION.
|
||||||
|
|
||||||
|
16. Limitation of Liability.
|
||||||
|
IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MODIFIES AND/OR CONVEYS THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, INCLUDING ANY GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING OUT OF THE USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED TO LOSS OF DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY YOU OR THIRD PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER PROGRAMS), EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF SUCH DAMAGES.
|
||||||
|
|
||||||
|
17. Interpretation of Sections 15 and 16.
|
||||||
|
If the disclaimer of warranty and limitation of liability provided above cannot be given local legal effect according to their terms, reviewing courts shall apply local law that most closely approximates an absolute waiver of all civil liability in connection with the Program, unless a warranty or assumption of liability accompanies a copy of the Program in return for a fee.
|
||||||
|
|
||||||
|
END OF TERMS AND CONDITIONS
|
||||||
|
|
||||||
|
How to Apply These Terms to Your New Programs
|
||||||
|
|
||||||
|
If you develop a new program, and you want it to be of the greatest possible use to the public, the best way to achieve this is to make it free software which everyone can redistribute and change under these terms.
|
||||||
|
|
||||||
|
To do so, attach the following notices to the program. It is safest to attach them to the start of each source file to most effectively state the exclusion of warranty; and each file should have at least the “copyright” line and a pointer to where the full notice is found.
|
||||||
|
|
||||||
|
<one line to give the program's name and a brief idea of what it does.>
|
||||||
|
Copyright (C) <year> <name of author>
|
||||||
|
|
||||||
|
This program is free software: you can redistribute it and/or modify it under the terms of the GNU General Public License as published by the Free Software Foundation, either version 3 of the License, or (at your option) any later version.
|
||||||
|
|
||||||
|
This program is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for more details.
|
||||||
|
|
||||||
|
You should have received a copy of the GNU General Public License along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||||
|
|
||||||
|
Also add information on how to contact you by electronic and paper mail.
|
||||||
|
|
||||||
|
If the program does terminal interaction, make it output a short notice like this when it starts in an interactive mode:
|
||||||
|
|
||||||
|
<program> Copyright (C) <year> <name of author>
|
||||||
|
This program comes with ABSOLUTELY NO WARRANTY; for details type `show w'.
|
||||||
|
This is free software, and you are welcome to redistribute it under certain conditions; type `show c' for details.
|
||||||
|
|
||||||
|
The hypothetical commands `show w' and `show c' should show the appropriate parts of the General Public License. Of course, your program's commands might be different; for a GUI interface, you would use an “about box”.
|
||||||
|
|
||||||
|
You should also get your employer (if you work as a programmer) or school, if any, to sign a “copyright disclaimer” for the program, if necessary. For more information on this, and how to apply and follow the GNU GPL, see <https://www.gnu.org/licenses/>.
|
||||||
|
|
||||||
|
The GNU General Public License does not permit incorporating your program into proprietary programs. If your program is a subroutine library, you may consider it more useful to permit linking proprietary applications with the library. If this is what you want to do, use the GNU Lesser General Public License instead of this License. But first, please read <https://www.gnu.org/philosophy/why-not-lgpl.html>.
|
||||||
@@ -0,0 +1,20 @@
|
|||||||
|
# Third-Party Notices
|
||||||
|
|
||||||
|
The proton-stream Android distribution combines open-source components. The
|
||||||
|
complete GNU GPL version 3 text is packaged beside this file as `GPL-3.0.txt`.
|
||||||
|
|
||||||
|
| Component | License |
|
||||||
|
|---|---|
|
||||||
|
| mpv/libmpv | GPL-2.0-or-later by default; verify the pinned build configuration |
|
||||||
|
| LLVM libc++ shared runtime (Android NDK r29) | Apache-2.0 WITH LLVM-exception |
|
||||||
|
| AndroidX and Jetpack Compose | Apache-2.0 |
|
||||||
|
| Kotlin | Apache-2.0 |
|
||||||
|
| JNA | Apache-2.0 or LGPL-2.1-or-later |
|
||||||
|
| UniFFI | MPL-2.0 |
|
||||||
|
| proton-stream reusable Rust crates | MIT |
|
||||||
|
|
||||||
|
Before release, replace this dependency summary with an inventory generated
|
||||||
|
from the resolved Cargo and Gradle dependency graphs and package every required
|
||||||
|
license text. A bundled libmpv release must identify the exact source revision,
|
||||||
|
patches, and build configuration, and make the corresponding source available
|
||||||
|
under the GPL.
|
||||||
@@ -0,0 +1,23 @@
|
|||||||
|
cmake_minimum_required(VERSION 3.22.1)
|
||||||
|
project(pstr_mpv LANGUAGES CXX)
|
||||||
|
|
||||||
|
if(NOT DEFINED PSTR_MPV_ROOT)
|
||||||
|
message(FATAL_ERROR "PSTR_MPV_ROOT must point at the staged Android libmpv tree")
|
||||||
|
endif()
|
||||||
|
if(NOT DEFINED PSTR_RUST_JNI_ROOT)
|
||||||
|
message(FATAL_ERROR "PSTR_RUST_JNI_ROOT must point at cargo-ndk output")
|
||||||
|
endif()
|
||||||
|
|
||||||
|
add_library(pstr_mpv SHARED pstr_mpv.cpp)
|
||||||
|
add_library(mpv SHARED IMPORTED)
|
||||||
|
set_target_properties(mpv PROPERTIES
|
||||||
|
IMPORTED_LOCATION "${PSTR_MPV_ROOT}/jniLibs/${ANDROID_ABI}/libmpv.so")
|
||||||
|
|
||||||
|
target_compile_features(pstr_mpv PRIVATE cxx_std_20)
|
||||||
|
target_compile_options(pstr_mpv PRIVATE -Wall -Wextra -Werror)
|
||||||
|
target_include_directories(pstr_mpv PRIVATE "${PSTR_MPV_ROOT}/include")
|
||||||
|
# Link by soname rather than by absolute build path. Both libraries are
|
||||||
|
# packaged into the same ABI directory in the APK.
|
||||||
|
target_link_directories(pstr_mpv PRIVATE "${PSTR_RUST_JNI_ROOT}/${ANDROID_ABI}")
|
||||||
|
target_link_options(pstr_mpv PRIVATE "-Wl,-l:libpstr_android.so")
|
||||||
|
target_link_libraries(pstr_mpv PRIVATE mpv android EGL log)
|
||||||
@@ -0,0 +1,545 @@
|
|||||||
|
#include "pstr_mpv.h"
|
||||||
|
|
||||||
|
#include <EGL/egl.h>
|
||||||
|
#include <android/native_window.h>
|
||||||
|
#include <android/native_window_jni.h>
|
||||||
|
#include <jni.h>
|
||||||
|
#include <mpv/client.h>
|
||||||
|
#include <mpv/render_gl.h>
|
||||||
|
#include <mpv/stream_cb.h>
|
||||||
|
|
||||||
|
#include <atomic>
|
||||||
|
#include <algorithm>
|
||||||
|
#include <charconv>
|
||||||
|
#include <chrono>
|
||||||
|
#include <condition_variable>
|
||||||
|
#include <cstring>
|
||||||
|
#include <cstdint>
|
||||||
|
#include <memory>
|
||||||
|
#include <mutex>
|
||||||
|
#include <optional>
|
||||||
|
#include <string>
|
||||||
|
#include <string_view>
|
||||||
|
#include <thread>
|
||||||
|
#include <utility>
|
||||||
|
|
||||||
|
namespace {
|
||||||
|
|
||||||
|
constexpr const char *kProtocol = "pstr";
|
||||||
|
|
||||||
|
struct StreamCookie {
|
||||||
|
uint64_t handle;
|
||||||
|
int64_t size;
|
||||||
|
int64_t position = 0;
|
||||||
|
std::atomic<bool> cancelled{false};
|
||||||
|
};
|
||||||
|
|
||||||
|
template <typename Result, typename Function>
|
||||||
|
Result c_boundary(Result failure, Function &&function) noexcept {
|
||||||
|
try {
|
||||||
|
return std::forward<Function>(function)();
|
||||||
|
} catch (...) {
|
||||||
|
return failure;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
template <typename Function>
|
||||||
|
void c_boundary(Function &&function) noexcept {
|
||||||
|
try {
|
||||||
|
std::forward<Function>(function)();
|
||||||
|
} catch (...) {
|
||||||
|
// C and JNI callers cannot receive C++ exceptions.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
int64_t stream_read(void *opaque, char *buffer, uint64_t length) noexcept {
|
||||||
|
return c_boundary<int64_t>(-1, [=] {
|
||||||
|
auto *stream = static_cast<StreamCookie *>(opaque);
|
||||||
|
if (!stream || !buffer) return int64_t{-1};
|
||||||
|
if (stream->cancelled.load(std::memory_order_relaxed)) return int64_t{-1};
|
||||||
|
const auto remaining = stream->size - stream->position;
|
||||||
|
if (remaining <= 0) return int64_t{0};
|
||||||
|
const auto requested = static_cast<size_t>(std::min<uint64_t>(length, remaining));
|
||||||
|
const int64_t read = pstr_android_stream_read(
|
||||||
|
stream->handle, static_cast<uint64_t>(stream->position), buffer, requested);
|
||||||
|
if (read > 0) stream->position += read;
|
||||||
|
return read;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
int64_t stream_seek(void *opaque, int64_t offset) noexcept {
|
||||||
|
return c_boundary<int64_t>(MPV_ERROR_GENERIC, [=] {
|
||||||
|
auto *stream = static_cast<StreamCookie *>(opaque);
|
||||||
|
if (!stream || offset < 0 || offset > stream->size) {
|
||||||
|
return int64_t{MPV_ERROR_GENERIC};
|
||||||
|
}
|
||||||
|
stream->position = offset;
|
||||||
|
stream->cancelled.store(false, std::memory_order_relaxed);
|
||||||
|
return offset;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
int64_t stream_size(void *opaque) noexcept {
|
||||||
|
return c_boundary<int64_t>(MPV_ERROR_GENERIC, [=] {
|
||||||
|
auto *stream = static_cast<StreamCookie *>(opaque);
|
||||||
|
return stream ? stream->size : int64_t{MPV_ERROR_GENERIC};
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
void stream_cancel(void *opaque) noexcept {
|
||||||
|
c_boundary([=] {
|
||||||
|
if (auto *stream = static_cast<StreamCookie *>(opaque)) {
|
||||||
|
stream->cancelled.store(true, std::memory_order_relaxed);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
void stream_close(void *opaque) noexcept {
|
||||||
|
c_boundary([=] { delete static_cast<StreamCookie *>(opaque); });
|
||||||
|
}
|
||||||
|
|
||||||
|
int stream_open(void *, char *uri, mpv_stream_cb_info *info) noexcept {
|
||||||
|
return c_boundary<int>(MPV_ERROR_LOADING_FAILED, [=]() -> int {
|
||||||
|
if (!info) return MPV_ERROR_LOADING_FAILED;
|
||||||
|
constexpr std::string_view prefix = "pstr://";
|
||||||
|
const std::string_view value(uri == nullptr ? "" : uri);
|
||||||
|
if (!value.starts_with(prefix)) return MPV_ERROR_LOADING_FAILED;
|
||||||
|
uint64_t handle = 0;
|
||||||
|
const auto token = value.substr(prefix.size());
|
||||||
|
const auto result = std::from_chars(token.data(), token.data() + token.size(), handle);
|
||||||
|
if (result.ec != std::errc{} || result.ptr != token.data() + token.size()) {
|
||||||
|
return MPV_ERROR_LOADING_FAILED;
|
||||||
|
}
|
||||||
|
const int64_t size = pstr_android_stream_size(handle);
|
||||||
|
if (size < 0) return MPV_ERROR_LOADING_FAILED;
|
||||||
|
std::unique_ptr<StreamCookie> cookie(new StreamCookie{handle, size});
|
||||||
|
info->cookie = cookie.get();
|
||||||
|
info->read_fn = stream_read;
|
||||||
|
info->seek_fn = stream_seek;
|
||||||
|
info->size_fn = stream_size;
|
||||||
|
info->close_fn = stream_close;
|
||||||
|
info->cancel_fn = stream_cancel;
|
||||||
|
cookie.release();
|
||||||
|
return 0;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
void *resolve_gl(void *, const char *name) noexcept {
|
||||||
|
return c_boundary<void *>(nullptr, [=] {
|
||||||
|
return reinterpret_cast<void *>(eglGetProcAddress(name));
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
struct PlaybackState {
|
||||||
|
double position = 0.0;
|
||||||
|
double duration = 0.0;
|
||||||
|
double volume = 100.0;
|
||||||
|
bool paused = true;
|
||||||
|
bool muted = false;
|
||||||
|
bool ended = false;
|
||||||
|
};
|
||||||
|
|
||||||
|
class Player {
|
||||||
|
public:
|
||||||
|
Player() {
|
||||||
|
mpv_ = mpv_create();
|
||||||
|
if (!mpv_) return;
|
||||||
|
option("config", "no");
|
||||||
|
option("vo", "libmpv");
|
||||||
|
option("force-window", "no");
|
||||||
|
option("video-timing-offset", "0");
|
||||||
|
option("hwdec", "auto-safe");
|
||||||
|
option("cache", "yes");
|
||||||
|
option("cache-secs", "30");
|
||||||
|
option("demuxer-readahead-secs", "30");
|
||||||
|
option("demuxer-max-bytes", "50331648");
|
||||||
|
option("audio-focus", "no"); // Android AudioManager owns focus.
|
||||||
|
if (mpv_stream_cb_add_ro(mpv_, kProtocol, nullptr, stream_open) < 0 ||
|
||||||
|
mpv_initialize(mpv_) < 0) {
|
||||||
|
mpv_terminate_destroy(mpv_);
|
||||||
|
mpv_ = nullptr;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
observe("time-pos", 1, MPV_FORMAT_DOUBLE);
|
||||||
|
observe("duration", 2, MPV_FORMAT_DOUBLE);
|
||||||
|
observe("pause", 3, MPV_FORMAT_FLAG);
|
||||||
|
observe("volume", 4, MPV_FORMAT_DOUBLE);
|
||||||
|
observe("mute", 5, MPV_FORMAT_FLAG);
|
||||||
|
events_ = std::thread([this] { event_loop(); });
|
||||||
|
renderer_ = std::thread([this] { render_loop(); });
|
||||||
|
}
|
||||||
|
|
||||||
|
~Player() noexcept {
|
||||||
|
// Destruction runs from JNI. Keep failures from std::thread primitives
|
||||||
|
// inside C++, even on runtimes that report a join error.
|
||||||
|
c_boundary([this] {
|
||||||
|
running_.store(false);
|
||||||
|
render_cv_.notify_all();
|
||||||
|
if (mpv_) mpv_wakeup(mpv_);
|
||||||
|
});
|
||||||
|
c_boundary([this] { if (events_.joinable()) events_.join(); });
|
||||||
|
c_boundary([this] { if (renderer_.joinable()) renderer_.join(); });
|
||||||
|
c_boundary([this] { if (mpv_) mpv_terminate_destroy(mpv_); });
|
||||||
|
}
|
||||||
|
|
||||||
|
bool valid() const { return mpv_ != nullptr; }
|
||||||
|
|
||||||
|
bool attach(JNIEnv *env, jobject surface) {
|
||||||
|
if (!env || !surface) return false;
|
||||||
|
ANativeWindow *window = ANativeWindow_fromSurface(env, surface);
|
||||||
|
if (!window) return false;
|
||||||
|
{
|
||||||
|
std::lock_guard lock(render_mutex_);
|
||||||
|
if (pending_window_) ANativeWindow_release(pending_window_);
|
||||||
|
pending_window_ = window;
|
||||||
|
surface_changed_ = true;
|
||||||
|
}
|
||||||
|
render_cv_.notify_one();
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
void detach() {
|
||||||
|
{
|
||||||
|
std::lock_guard lock(render_mutex_);
|
||||||
|
if (pending_window_) {
|
||||||
|
ANativeWindow_release(pending_window_);
|
||||||
|
pending_window_ = nullptr;
|
||||||
|
}
|
||||||
|
surface_changed_ = true;
|
||||||
|
}
|
||||||
|
render_cv_.notify_one();
|
||||||
|
}
|
||||||
|
|
||||||
|
bool load(uint64_t handle, double start, const std::string &audio,
|
||||||
|
const std::string &subtitle, bool subtitles) {
|
||||||
|
if (!mpv_) return false;
|
||||||
|
{
|
||||||
|
std::unique_lock lock(render_mutex_);
|
||||||
|
if (!render_cv_.wait_for(lock, std::chrono::seconds(5), [this] { return render_ != nullptr || !running_.load(); })) return false;
|
||||||
|
}
|
||||||
|
if (!audio.empty()) set_string("alang", audio);
|
||||||
|
if (!subtitle.empty()) set_string("slang", subtitle);
|
||||||
|
set_string("sid", subtitles ? "auto" : "no");
|
||||||
|
pending_start_.store(std::max(0.0, start));
|
||||||
|
const std::string url = "pstr://" + std::to_string(handle);
|
||||||
|
const char *command[] = {"loadfile", url.c_str(), nullptr};
|
||||||
|
if (mpv_command(mpv_, command) < 0) return false;
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
void pause(bool value) { set_flag("pause", value); }
|
||||||
|
void seek(double seconds) { set_double("time-pos", std::max(0.0, seconds)); }
|
||||||
|
void volume(double value) { set_double("volume", std::clamp(value, 0.0, 100.0)); }
|
||||||
|
void mute(bool value) { set_flag("mute", value); }
|
||||||
|
void select_track(bool audio, int64_t id) {
|
||||||
|
const char *property = audio ? "aid" : "sid";
|
||||||
|
if (id <= 0) set_string(property, "no"); else mpv_set_property(mpv_, property, MPV_FORMAT_INT64, &id);
|
||||||
|
}
|
||||||
|
void stop() {
|
||||||
|
if (!mpv_) return;
|
||||||
|
const char *command[] = {"stop", nullptr};
|
||||||
|
mpv_command(mpv_, command);
|
||||||
|
}
|
||||||
|
|
||||||
|
PlaybackState state() const {
|
||||||
|
std::lock_guard lock(state_mutex_);
|
||||||
|
return state_;
|
||||||
|
}
|
||||||
|
|
||||||
|
std::string tracks_json() const {
|
||||||
|
if (!mpv_) return "[]";
|
||||||
|
int64_t count = 0;
|
||||||
|
if (mpv_get_property(mpv_, "track-list/count", MPV_FORMAT_INT64, &count) < 0) return "[]";
|
||||||
|
std::string json = "[";
|
||||||
|
for (int64_t i = 0; i < count; ++i) {
|
||||||
|
const std::string base = "track-list/" + std::to_string(i) + "/";
|
||||||
|
int64_t id = 0;
|
||||||
|
char *type_raw = nullptr;
|
||||||
|
char *lang_raw = nullptr;
|
||||||
|
char *title_raw = nullptr;
|
||||||
|
int selected = 0;
|
||||||
|
mpv_get_property(mpv_, (base + "id").c_str(), MPV_FORMAT_INT64, &id);
|
||||||
|
mpv_get_property(mpv_, (base + "type").c_str(), MPV_FORMAT_STRING, &type_raw);
|
||||||
|
MpvString type(type_raw);
|
||||||
|
mpv_get_property(mpv_, (base + "lang").c_str(), MPV_FORMAT_STRING, &lang_raw);
|
||||||
|
MpvString lang(lang_raw);
|
||||||
|
mpv_get_property(mpv_, (base + "title").c_str(), MPV_FORMAT_STRING, &title_raw);
|
||||||
|
MpvString title(title_raw);
|
||||||
|
mpv_get_property(mpv_, (base + "selected").c_str(), MPV_FORMAT_FLAG, &selected);
|
||||||
|
if (i) json += ',';
|
||||||
|
json += "{\"id\":" + std::to_string(id) + ",\"type\":\"" + escape(type.get()) +
|
||||||
|
"\",\"language\":\"" + escape(lang.get()) + "\",\"title\":\"" +
|
||||||
|
escape(title.get()) + "\",\"selected\":" + (selected ? "true" : "false") + "}";
|
||||||
|
}
|
||||||
|
return json + ']';
|
||||||
|
}
|
||||||
|
|
||||||
|
private:
|
||||||
|
struct MpvFree {
|
||||||
|
void operator()(char *value) const noexcept { if (value) mpv_free(value); }
|
||||||
|
};
|
||||||
|
using MpvString = std::unique_ptr<char, MpvFree>;
|
||||||
|
|
||||||
|
static std::string escape(const char *text) {
|
||||||
|
std::string out;
|
||||||
|
if (!text) return out;
|
||||||
|
for (const unsigned char c : std::string_view(text)) {
|
||||||
|
if (c == '"' || c == '\\') out += '\\';
|
||||||
|
if (c >= 0x20) out += static_cast<char>(c);
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
void option(const char *name, const char *value) { mpv_set_option_string(mpv_, name, value); }
|
||||||
|
void observe(const char *name, uint64_t id, mpv_format format) { mpv_observe_property(mpv_, id, name, format); }
|
||||||
|
void set_string(const char *name, const std::string &value) { mpv_set_property_string(mpv_, name, value.c_str()); }
|
||||||
|
void set_double(const char *name, double value) { if (mpv_) mpv_set_property(mpv_, name, MPV_FORMAT_DOUBLE, &value); }
|
||||||
|
void set_flag(const char *name, bool value) { int flag = value; if (mpv_) mpv_set_property(mpv_, name, MPV_FORMAT_FLAG, &flag); }
|
||||||
|
|
||||||
|
void event_loop() {
|
||||||
|
while (running_.load()) {
|
||||||
|
mpv_event *event = mpv_wait_event(mpv_, 0.25);
|
||||||
|
if (event->event_id == MPV_EVENT_NONE) continue;
|
||||||
|
if (event->event_id == MPV_EVENT_SHUTDOWN) break;
|
||||||
|
if (event->event_id == MPV_EVENT_FILE_LOADED) {
|
||||||
|
const double start = pending_start_.exchange(0.0);
|
||||||
|
if (start > 0.0) set_double("time-pos", start);
|
||||||
|
}
|
||||||
|
if (event->event_id == MPV_EVENT_END_FILE) {
|
||||||
|
std::lock_guard lock(state_mutex_);
|
||||||
|
state_.ended = true;
|
||||||
|
} else if (event->event_id == MPV_EVENT_START_FILE) {
|
||||||
|
std::lock_guard lock(state_mutex_);
|
||||||
|
state_.ended = false;
|
||||||
|
} else if (event->event_id == MPV_EVENT_PROPERTY_CHANGE) {
|
||||||
|
update_property(*static_cast<mpv_event_property *>(event->data));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void update_property(const mpv_event_property &property) {
|
||||||
|
if (!property.data) return;
|
||||||
|
std::lock_guard lock(state_mutex_);
|
||||||
|
if (!std::strcmp(property.name, "time-pos")) state_.position = *static_cast<double *>(property.data);
|
||||||
|
else if (!std::strcmp(property.name, "duration")) state_.duration = *static_cast<double *>(property.data);
|
||||||
|
else if (!std::strcmp(property.name, "volume")) state_.volume = *static_cast<double *>(property.data);
|
||||||
|
else if (!std::strcmp(property.name, "pause")) state_.paused = *static_cast<int *>(property.data);
|
||||||
|
else if (!std::strcmp(property.name, "mute")) state_.muted = *static_cast<int *>(property.data);
|
||||||
|
}
|
||||||
|
|
||||||
|
static void render_update(void *opaque) noexcept {
|
||||||
|
c_boundary([=] {
|
||||||
|
auto *self = static_cast<Player *>(opaque);
|
||||||
|
if (!self) return;
|
||||||
|
self->frame_ready_.store(true);
|
||||||
|
self->render_cv_.notify_one();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
void render_loop() {
|
||||||
|
EGLDisplay display = EGL_NO_DISPLAY;
|
||||||
|
EGLContext context = EGL_NO_CONTEXT;
|
||||||
|
EGLSurface surface = EGL_NO_SURFACE;
|
||||||
|
EGLSurface pbuffer = EGL_NO_SURFACE;
|
||||||
|
EGLConfig config = nullptr;
|
||||||
|
ANativeWindow *window = nullptr;
|
||||||
|
while (running_.load()) {
|
||||||
|
std::unique_lock lock(render_mutex_);
|
||||||
|
render_cv_.wait(lock, [this] { return !running_.load() || surface_changed_ || frame_ready_.load(); });
|
||||||
|
if (!running_.load()) break;
|
||||||
|
if (surface_changed_) {
|
||||||
|
surface_changed_ = false;
|
||||||
|
if (surface != EGL_NO_SURFACE) { eglMakeCurrent(display, pbuffer, pbuffer, context); eglDestroySurface(display, surface); surface = EGL_NO_SURFACE; }
|
||||||
|
if (window) ANativeWindow_release(window);
|
||||||
|
window = pending_window_;
|
||||||
|
pending_window_ = nullptr;
|
||||||
|
if (window && display == EGL_NO_DISPLAY && !initialize_egl(display, context, config, pbuffer)) {
|
||||||
|
ANativeWindow_release(window);
|
||||||
|
window = nullptr;
|
||||||
|
}
|
||||||
|
if (window) {
|
||||||
|
surface = eglCreateWindowSurface(display, config, window, nullptr);
|
||||||
|
eglMakeCurrent(display, surface, surface, context);
|
||||||
|
initialize_renderer();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const bool draw = frame_ready_.exchange(false);
|
||||||
|
lock.unlock();
|
||||||
|
if (draw && surface != EGL_NO_SURFACE && render_) {
|
||||||
|
eglMakeCurrent(display, surface, surface, context);
|
||||||
|
if (mpv_render_context_update(render_) & MPV_RENDER_UPDATE_FRAME) {
|
||||||
|
EGLint width = 0, height = 0;
|
||||||
|
eglQuerySurface(display, surface, EGL_WIDTH, &width);
|
||||||
|
eglQuerySurface(display, surface, EGL_HEIGHT, &height);
|
||||||
|
mpv_opengl_fbo fbo{0, width, height, 0};
|
||||||
|
int flip = 1;
|
||||||
|
mpv_render_param params[] = {{MPV_RENDER_PARAM_OPENGL_FBO, &fbo}, {MPV_RENDER_PARAM_FLIP_Y, &flip}, {MPV_RENDER_PARAM_INVALID, nullptr}};
|
||||||
|
mpv_render_context_render(render_, params);
|
||||||
|
eglSwapBuffers(display, surface);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (render_) {
|
||||||
|
eglMakeCurrent(display, surface != EGL_NO_SURFACE ? surface : pbuffer, surface != EGL_NO_SURFACE ? surface : pbuffer, context);
|
||||||
|
mpv_render_context_set_update_callback(render_, nullptr, nullptr);
|
||||||
|
mpv_render_context_free(render_);
|
||||||
|
render_ = nullptr;
|
||||||
|
}
|
||||||
|
if (surface != EGL_NO_SURFACE) eglDestroySurface(display, surface);
|
||||||
|
if (pbuffer != EGL_NO_SURFACE) eglDestroySurface(display, pbuffer);
|
||||||
|
if (context != EGL_NO_CONTEXT) eglDestroyContext(display, context);
|
||||||
|
if (display != EGL_NO_DISPLAY) eglTerminate(display);
|
||||||
|
if (window) ANativeWindow_release(window);
|
||||||
|
std::lock_guard lock(render_mutex_);
|
||||||
|
if (pending_window_) { ANativeWindow_release(pending_window_); pending_window_ = nullptr; }
|
||||||
|
}
|
||||||
|
|
||||||
|
bool initialize_egl(EGLDisplay &display, EGLContext &context, EGLConfig &config, EGLSurface &pbuffer) {
|
||||||
|
display = eglGetDisplay(EGL_DEFAULT_DISPLAY);
|
||||||
|
if (display == EGL_NO_DISPLAY || !eglInitialize(display, nullptr, nullptr)) return false;
|
||||||
|
const EGLint attributes[] = {EGL_RENDERABLE_TYPE, EGL_OPENGL_ES2_BIT, EGL_SURFACE_TYPE, EGL_WINDOW_BIT | EGL_PBUFFER_BIT, EGL_RED_SIZE, 8, EGL_GREEN_SIZE, 8, EGL_BLUE_SIZE, 8, EGL_NONE};
|
||||||
|
EGLint count = 0;
|
||||||
|
if (!eglChooseConfig(display, attributes, &config, 1, &count) || count == 0) return false;
|
||||||
|
const EGLint context_attributes[] = {EGL_CONTEXT_CLIENT_VERSION, 2, EGL_NONE};
|
||||||
|
context = eglCreateContext(display, config, EGL_NO_CONTEXT, context_attributes);
|
||||||
|
if (context == EGL_NO_CONTEXT) return false;
|
||||||
|
const EGLint pbuffer_attributes[] = {EGL_WIDTH, 1, EGL_HEIGHT, 1, EGL_NONE};
|
||||||
|
pbuffer = eglCreatePbufferSurface(display, config, pbuffer_attributes);
|
||||||
|
return pbuffer != EGL_NO_SURFACE && eglMakeCurrent(display, pbuffer, pbuffer, context);
|
||||||
|
}
|
||||||
|
|
||||||
|
void initialize_renderer() {
|
||||||
|
if (render_ || !mpv_) return;
|
||||||
|
mpv_opengl_init_params gl{resolve_gl, nullptr};
|
||||||
|
const char *api = MPV_RENDER_API_TYPE_OPENGL;
|
||||||
|
mpv_render_param params[] = {{MPV_RENDER_PARAM_API_TYPE, const_cast<char *>(api)}, {MPV_RENDER_PARAM_OPENGL_INIT_PARAMS, &gl}, {MPV_RENDER_PARAM_INVALID, nullptr}};
|
||||||
|
if (mpv_render_context_create(&render_, mpv_, params) >= 0) {
|
||||||
|
mpv_render_context_set_update_callback(render_, render_update, this);
|
||||||
|
render_cv_.notify_all();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
mpv_handle *mpv_ = nullptr;
|
||||||
|
mpv_render_context *render_ = nullptr;
|
||||||
|
std::atomic<bool> running_{true};
|
||||||
|
std::thread events_;
|
||||||
|
std::thread renderer_;
|
||||||
|
mutable std::mutex state_mutex_;
|
||||||
|
PlaybackState state_;
|
||||||
|
std::mutex render_mutex_;
|
||||||
|
std::condition_variable render_cv_;
|
||||||
|
ANativeWindow *pending_window_ = nullptr;
|
||||||
|
bool surface_changed_ = false;
|
||||||
|
std::atomic<bool> frame_ready_{false};
|
||||||
|
std::atomic<double> pending_start_{0.0};
|
||||||
|
};
|
||||||
|
|
||||||
|
Player *from(jlong handle) { return reinterpret_cast<Player *>(handle); }
|
||||||
|
jstring string(JNIEnv *env, const std::string &value) { return env->NewStringUTF(value.c_str()); }
|
||||||
|
|
||||||
|
class UtfChars {
|
||||||
|
public:
|
||||||
|
UtfChars(JNIEnv *env, jstring value) noexcept
|
||||||
|
: env_(env), value_(value), characters_(env->GetStringUTFChars(value, nullptr)) {}
|
||||||
|
~UtfChars() noexcept {
|
||||||
|
if (characters_) env_->ReleaseStringUTFChars(value_, characters_);
|
||||||
|
}
|
||||||
|
UtfChars(const UtfChars &) = delete;
|
||||||
|
UtfChars &operator=(const UtfChars &) = delete;
|
||||||
|
const char *get() const noexcept { return characters_; }
|
||||||
|
|
||||||
|
private:
|
||||||
|
JNIEnv *env_;
|
||||||
|
jstring value_;
|
||||||
|
const char *characters_;
|
||||||
|
};
|
||||||
|
|
||||||
|
bool utf8(JNIEnv *env, jstring value, std::string &result) {
|
||||||
|
if (!value) return true;
|
||||||
|
UtfChars characters(env, value);
|
||||||
|
if (!characters.get()) return false; // An exception, normally OOM, is pending.
|
||||||
|
result.assign(characters.get());
|
||||||
|
return !env->ExceptionCheck();
|
||||||
|
}
|
||||||
|
|
||||||
|
void reject_null_surface(JNIEnv *env) noexcept {
|
||||||
|
c_boundary([=] {
|
||||||
|
jclass type = env->FindClass("java/lang/IllegalArgumentException");
|
||||||
|
if (type) env->ThrowNew(type, "surface must not be null");
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
} // namespace
|
||||||
|
|
||||||
|
extern "C" JNIEXPORT jlong JNICALL Java_io_narl_protonstream_playback_NativeMpvHost_nativeCreate(JNIEnv *, jobject) noexcept {
|
||||||
|
return c_boundary<jlong>(0, [] {
|
||||||
|
auto player = std::make_unique<Player>();
|
||||||
|
return player->valid() ? reinterpret_cast<jlong>(player.release()) : 0;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
extern "C" JNIEXPORT void JNICALL Java_io_narl_protonstream_playback_NativeMpvHost_nativeDestroy(JNIEnv *, jobject, jlong handle) noexcept {
|
||||||
|
c_boundary([=] { delete from(handle); });
|
||||||
|
}
|
||||||
|
extern "C" JNIEXPORT void JNICALL Java_io_narl_protonstream_playback_NativeMpvHost_nativeAttachSurface(JNIEnv *env, jobject, jlong handle, jobject surface) noexcept {
|
||||||
|
c_boundary([=] {
|
||||||
|
if (!surface) { reject_null_surface(env); return; }
|
||||||
|
if (auto *p = from(handle)) p->attach(env, surface);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
extern "C" JNIEXPORT void JNICALL Java_io_narl_protonstream_playback_NativeMpvHost_nativeDetachSurface(JNIEnv *, jobject, jlong handle) noexcept {
|
||||||
|
c_boundary([=] { if (auto *p = from(handle)) p->detach(); });
|
||||||
|
}
|
||||||
|
extern "C" JNIEXPORT jboolean JNICALL Java_io_narl_protonstream_playback_NativeMpvHost_nativeLoad(JNIEnv *env, jobject, jlong handle, jlong stream, jdouble start, jstring audio, jstring subtitle, jboolean subtitles) noexcept {
|
||||||
|
return c_boundary<jboolean>(JNI_FALSE, [=] {
|
||||||
|
std::string audio_text;
|
||||||
|
std::string subtitle_text;
|
||||||
|
if (!utf8(env, audio, audio_text) || !utf8(env, subtitle, subtitle_text)) {
|
||||||
|
return jboolean{JNI_FALSE};
|
||||||
|
}
|
||||||
|
auto *p = from(handle);
|
||||||
|
return static_cast<jboolean>(
|
||||||
|
p && p->load(static_cast<uint64_t>(stream), start, audio_text,
|
||||||
|
subtitle_text, subtitles == JNI_TRUE)
|
||||||
|
? JNI_TRUE
|
||||||
|
: JNI_FALSE);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
extern "C" JNIEXPORT void JNICALL Java_io_narl_protonstream_playback_NativeMpvHost_nativePause(JNIEnv *, jobject, jlong h, jboolean value) noexcept {
|
||||||
|
c_boundary([=] { if (auto *p = from(h)) p->pause(value == JNI_TRUE); });
|
||||||
|
}
|
||||||
|
extern "C" JNIEXPORT void JNICALL Java_io_narl_protonstream_playback_NativeMpvHost_nativeSeek(JNIEnv *, jobject, jlong h, jdouble value) noexcept {
|
||||||
|
c_boundary([=] { if (auto *p = from(h)) p->seek(value); });
|
||||||
|
}
|
||||||
|
extern "C" JNIEXPORT void JNICALL Java_io_narl_protonstream_playback_NativeMpvHost_nativeVolume(JNIEnv *, jobject, jlong h, jdouble value) noexcept {
|
||||||
|
c_boundary([=] { if (auto *p = from(h)) p->volume(value); });
|
||||||
|
}
|
||||||
|
extern "C" JNIEXPORT void JNICALL Java_io_narl_protonstream_playback_NativeMpvHost_nativeMute(JNIEnv *, jobject, jlong h, jboolean value) noexcept {
|
||||||
|
c_boundary([=] { if (auto *p = from(h)) p->mute(value == JNI_TRUE); });
|
||||||
|
}
|
||||||
|
extern "C" JNIEXPORT void JNICALL Java_io_narl_protonstream_playback_NativeMpvHost_nativeSelectTrack(JNIEnv *, jobject, jlong h, jboolean audio, jlong id) noexcept {
|
||||||
|
c_boundary([=] { if (auto *p = from(h)) p->select_track(audio == JNI_TRUE, id); });
|
||||||
|
}
|
||||||
|
extern "C" JNIEXPORT void JNICALL Java_io_narl_protonstream_playback_NativeMpvHost_nativeStop(JNIEnv *, jobject, jlong h) noexcept {
|
||||||
|
c_boundary([=] { if (auto *p = from(h)) p->stop(); });
|
||||||
|
}
|
||||||
|
extern "C" JNIEXPORT jdoubleArray JNICALL Java_io_narl_protonstream_playback_NativeMpvHost_nativeState(JNIEnv *env, jobject, jlong h) noexcept {
|
||||||
|
return c_boundary<jdoubleArray>(nullptr, [=] {
|
||||||
|
const auto state = from(h) ? from(h)->state() : PlaybackState{};
|
||||||
|
const jdouble values[] = {state.position, state.duration, state.volume,
|
||||||
|
state.paused ? 1.0 : 0.0, state.muted ? 1.0 : 0.0,
|
||||||
|
state.ended ? 1.0 : 0.0};
|
||||||
|
jdoubleArray result = env->NewDoubleArray(6);
|
||||||
|
if (!result) return static_cast<jdoubleArray>(nullptr);
|
||||||
|
env->SetDoubleArrayRegion(result, 0, 6, values);
|
||||||
|
if (env->ExceptionCheck()) return static_cast<jdoubleArray>(nullptr);
|
||||||
|
return result;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
extern "C" JNIEXPORT jstring JNICALL Java_io_narl_protonstream_playback_NativeMpvHost_nativeTracks(JNIEnv *env, jobject, jlong h) noexcept {
|
||||||
|
return c_boundary<jstring>(nullptr, [=] {
|
||||||
|
return string(env, from(h) ? from(h)->tracks_json() : "[]");
|
||||||
|
});
|
||||||
|
}
|
||||||
|
extern "C" JNIEXPORT void JNICALL Java_io_narl_protonstream_playback_NativeMpvHost_pstrAndroidStreamRelease(JNIEnv *, jobject, jlong handle) noexcept {
|
||||||
|
c_boundary([=] { pstr_android_stream_release(static_cast<uint64_t>(handle)); });
|
||||||
|
}
|
||||||
@@ -0,0 +1,14 @@
|
|||||||
|
#pragma once
|
||||||
|
|
||||||
|
#include <stddef.h>
|
||||||
|
#include <stdint.h>
|
||||||
|
|
||||||
|
#ifdef __cplusplus
|
||||||
|
extern "C" {
|
||||||
|
#endif
|
||||||
|
int64_t pstr_android_stream_read(uint64_t handle, uint64_t offset, void *buffer, size_t length);
|
||||||
|
int64_t pstr_android_stream_size(uint64_t handle);
|
||||||
|
void pstr_android_stream_release(uint64_t handle);
|
||||||
|
#ifdef __cplusplus
|
||||||
|
}
|
||||||
|
#endif
|
||||||
@@ -0,0 +1,83 @@
|
|||||||
|
package io.narl.protonstream
|
||||||
|
|
||||||
|
import android.Manifest
|
||||||
|
import android.content.pm.PackageManager
|
||||||
|
import android.os.Bundle
|
||||||
|
import android.os.Build
|
||||||
|
import android.app.PictureInPictureParams
|
||||||
|
import android.content.ComponentName
|
||||||
|
import android.content.Context
|
||||||
|
import android.content.Intent
|
||||||
|
import android.content.ServiceConnection
|
||||||
|
import android.os.IBinder
|
||||||
|
import android.util.Rational
|
||||||
|
import androidx.activity.ComponentActivity
|
||||||
|
import androidx.activity.compose.setContent
|
||||||
|
import androidx.activity.enableEdgeToEdge
|
||||||
|
import androidx.activity.result.contract.ActivityResultContracts
|
||||||
|
import io.narl.protonstream.ui.ProtonStreamApp
|
||||||
|
import io.narl.protonstream.ui.theme.ProtonStreamTheme
|
||||||
|
import io.narl.protonstream.playback.NativeMpvHost
|
||||||
|
import io.narl.protonstream.playback.PlaybackService
|
||||||
|
import androidx.compose.runtime.mutableStateOf
|
||||||
|
import io.narl.protonstream.settings.SettingsStore
|
||||||
|
|
||||||
|
class MainActivity : ComponentActivity() {
|
||||||
|
private val playerHost = mutableStateOf<NativeMpvHost?>(null)
|
||||||
|
private var bound = false
|
||||||
|
private val playbackConnection = object : ServiceConnection {
|
||||||
|
override fun onServiceConnected(name: ComponentName?, service: IBinder?) {
|
||||||
|
playerHost.value = (service as PlaybackService.PlaybackBinder).host
|
||||||
|
bound = true
|
||||||
|
}
|
||||||
|
override fun onServiceDisconnected(name: ComponentName?) {
|
||||||
|
playerHost.value = null
|
||||||
|
bound = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
private val notificationPermission = registerForActivityResult(
|
||||||
|
ActivityResultContracts.RequestPermission(),
|
||||||
|
) { /* Downloads remain usable; Android suppresses their notifications if denied. */ }
|
||||||
|
|
||||||
|
override fun onCreate(savedInstanceState: Bundle?) {
|
||||||
|
super.onCreate(savedInstanceState)
|
||||||
|
enableEdgeToEdge()
|
||||||
|
bindService(Intent(this, PlaybackService::class.java), playbackConnection, Context.BIND_AUTO_CREATE)
|
||||||
|
if (
|
||||||
|
Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU &&
|
||||||
|
checkSelfPermission(Manifest.permission.POST_NOTIFICATIONS) != PackageManager.PERMISSION_GRANTED
|
||||||
|
) {
|
||||||
|
notificationPermission.launch(Manifest.permission.POST_NOTIFICATIONS)
|
||||||
|
}
|
||||||
|
setContent {
|
||||||
|
ProtonStreamTheme {
|
||||||
|
ProtonStreamApp(playerHost.value)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onUserLeaveHint() {
|
||||||
|
super.onUserLeaveHint()
|
||||||
|
if (playerHost.value?.hasActivePlayback == true && !isInPictureInPictureMode) {
|
||||||
|
enterPictureInPictureMode(
|
||||||
|
PictureInPictureParams.Builder().setAspectRatio(Rational(16, 9)).setAutoEnterEnabled(true).build(),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onDestroy() {
|
||||||
|
if (bound) unbindService(playbackConnection)
|
||||||
|
super.onDestroy()
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onStop() {
|
||||||
|
super.onStop()
|
||||||
|
if (
|
||||||
|
!isChangingConfigurations &&
|
||||||
|
!isInPictureInPictureMode &&
|
||||||
|
!SettingsStore(this).backgroundAudio
|
||||||
|
) {
|
||||||
|
playerHost.value?.stop()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
package io.narl.protonstream
|
||||||
|
|
||||||
|
import android.app.Application
|
||||||
|
import io.narl.protonstream.native.NativeRuntime
|
||||||
|
|
||||||
|
class ProtonStreamApplication : Application() {
|
||||||
|
override fun onCreate() {
|
||||||
|
super.onCreate()
|
||||||
|
NativeRuntime.initialize(this)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,78 @@
|
|||||||
|
package io.narl.protonstream.download
|
||||||
|
|
||||||
|
import android.content.Context
|
||||||
|
import androidx.work.Constraints
|
||||||
|
import androidx.work.ExistingWorkPolicy
|
||||||
|
import androidx.work.NetworkType
|
||||||
|
import androidx.work.OneTimeWorkRequestBuilder
|
||||||
|
import androidx.work.WorkManager
|
||||||
|
import androidx.work.workDataOf
|
||||||
|
import io.narl.protonstream.settings.SettingsStore
|
||||||
|
import uniffi.pstr_android.EpisodeRecord
|
||||||
|
|
||||||
|
object DownloadCoordinator {
|
||||||
|
fun enqueue(context: Context, episode: EpisodeRecord) {
|
||||||
|
enqueue(
|
||||||
|
context,
|
||||||
|
RetainedDownload(episode.shareId, episode.volumeId, episode.linkId, episode.label),
|
||||||
|
ExistingWorkPolicy.KEEP,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun resume(context: Context, download: RetainedDownload) {
|
||||||
|
enqueue(context, download, ExistingWorkPolicy.REPLACE)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun enqueue(context: Context, download: RetainedDownload, policy: ExistingWorkPolicy) {
|
||||||
|
DownloadStateStore(context).put(
|
||||||
|
download.copy(status = RetainedDownload.STATUS_QUEUED, error = null),
|
||||||
|
)
|
||||||
|
val request = OneTimeWorkRequestBuilder<OfflineDownloadWorker>()
|
||||||
|
.setConstraints(
|
||||||
|
Constraints.Builder().setRequiredNetworkType(
|
||||||
|
requiredNetworkType(SettingsStore(context).wifiOnly),
|
||||||
|
).build(),
|
||||||
|
)
|
||||||
|
.setInputData(
|
||||||
|
workDataOf(
|
||||||
|
OfflineDownloadWorker.KEY_SHARE_ID to download.shareId,
|
||||||
|
OfflineDownloadWorker.KEY_VOLUME_ID to download.volumeId,
|
||||||
|
OfflineDownloadWorker.KEY_LINK_ID to download.linkId,
|
||||||
|
OfflineDownloadWorker.KEY_LABEL to download.label,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
.addTag(TAG)
|
||||||
|
.addTag("$EPISODE_TAG${download.label}")
|
||||||
|
.addTag(shareTag(download.shareId))
|
||||||
|
.build()
|
||||||
|
WorkManager.getInstance(context).enqueueUniqueWork(
|
||||||
|
workName(download.shareId, download.linkId),
|
||||||
|
policy,
|
||||||
|
request,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun enqueue(context: Context, episodes: Iterable<EpisodeRecord>) {
|
||||||
|
episodes.filterNot(EpisodeRecord::offline).forEach { enqueue(context, it) }
|
||||||
|
}
|
||||||
|
|
||||||
|
fun pause(context: Context, download: RetainedDownload) {
|
||||||
|
DownloadStateStore(context).put(download.copy(status = RetainedDownload.STATUS_PAUSED, error = null))
|
||||||
|
WorkManager.getInstance(context).cancelUniqueWork(workName(download.shareId, download.linkId))
|
||||||
|
}
|
||||||
|
|
||||||
|
fun cancel(context: Context, download: RetainedDownload) {
|
||||||
|
DownloadStateStore(context).put(download.copy(status = RetainedDownload.STATUS_CANCELLED))
|
||||||
|
WorkManager.getInstance(context).cancelUniqueWork(workName(download.shareId, download.linkId))
|
||||||
|
}
|
||||||
|
|
||||||
|
fun shareTag(shareId: String) = "$SHARE_TAG$shareId"
|
||||||
|
fun workName(shareId: String, linkId: String) = "offline-$shareId-$linkId"
|
||||||
|
|
||||||
|
const val TAG = "offline-download"
|
||||||
|
const val EPISODE_TAG = "offline-episode:"
|
||||||
|
private const val SHARE_TAG = "offline-share:"
|
||||||
|
}
|
||||||
|
|
||||||
|
internal fun requiredNetworkType(wifiOnly: Boolean): NetworkType =
|
||||||
|
if (wifiOnly) NetworkType.UNMETERED else NetworkType.CONNECTED
|
||||||
@@ -0,0 +1,82 @@
|
|||||||
|
package io.narl.protonstream.download
|
||||||
|
|
||||||
|
import android.content.Context
|
||||||
|
import org.json.JSONObject
|
||||||
|
|
||||||
|
/** Durable user-facing state retained after WorkManager prunes finished work. */
|
||||||
|
data class RetainedDownload(
|
||||||
|
val shareId: String,
|
||||||
|
val volumeId: String,
|
||||||
|
val linkId: String,
|
||||||
|
val label: String,
|
||||||
|
val downloaded: Long = 0,
|
||||||
|
val total: Long = 0,
|
||||||
|
val status: String = STATUS_QUEUED,
|
||||||
|
val error: String? = null,
|
||||||
|
) {
|
||||||
|
val key: String get() = "$shareId\u001f$linkId"
|
||||||
|
|
||||||
|
companion object {
|
||||||
|
const val STATUS_QUEUED = "queued"
|
||||||
|
const val STATUS_RUNNING = "running"
|
||||||
|
const val STATUS_PAUSED = "paused"
|
||||||
|
const val STATUS_FAILED = "failed"
|
||||||
|
const val STATUS_CANCELLED = "cancelled"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class DownloadStateStore(context: Context) {
|
||||||
|
private val preferences = context.getSharedPreferences(NAME, Context.MODE_PRIVATE)
|
||||||
|
|
||||||
|
fun records(): List<RetainedDownload> = preferences.all.values.mapNotNull { encoded ->
|
||||||
|
runCatching { decode(encoded as String) }.getOrNull()
|
||||||
|
}.sortedBy { it.label.lowercase() }
|
||||||
|
|
||||||
|
fun get(shareId: String, linkId: String): RetainedDownload? =
|
||||||
|
preferences.getString(key(shareId, linkId), null)?.let { runCatching { decode(it) }.getOrNull() }
|
||||||
|
|
||||||
|
fun put(record: RetainedDownload) {
|
||||||
|
preferences.edit().putString(record.key, encode(record)).commit()
|
||||||
|
}
|
||||||
|
|
||||||
|
fun remove(shareId: String, linkId: String) {
|
||||||
|
preferences.edit().remove(key(shareId, linkId)).commit()
|
||||||
|
}
|
||||||
|
|
||||||
|
fun removeShare(shareId: String) {
|
||||||
|
val edit = preferences.edit()
|
||||||
|
records().filter { it.shareId == shareId }.forEach { edit.remove(it.key) }
|
||||||
|
edit.commit()
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun encode(record: RetainedDownload) = JSONObject()
|
||||||
|
.put("share", record.shareId)
|
||||||
|
.put("volume", record.volumeId)
|
||||||
|
.put("link", record.linkId)
|
||||||
|
.put("label", record.label)
|
||||||
|
.put("downloaded", record.downloaded)
|
||||||
|
.put("total", record.total)
|
||||||
|
.put("status", record.status)
|
||||||
|
.put("error", record.error)
|
||||||
|
.toString()
|
||||||
|
|
||||||
|
private fun decode(encoded: String): RetainedDownload {
|
||||||
|
val json = JSONObject(encoded)
|
||||||
|
return RetainedDownload(
|
||||||
|
shareId = json.getString("share"),
|
||||||
|
volumeId = json.getString("volume"),
|
||||||
|
linkId = json.getString("link"),
|
||||||
|
label = json.optString("label", json.getString("link")),
|
||||||
|
downloaded = json.optLong("downloaded"),
|
||||||
|
total = json.optLong("total"),
|
||||||
|
status = json.optString("status", RetainedDownload.STATUS_QUEUED),
|
||||||
|
error = json.optString("error").takeIf(String::isNotBlank),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun key(shareId: String, linkId: String) = "$shareId\u001f$linkId"
|
||||||
|
|
||||||
|
private companion object {
|
||||||
|
const val NAME = "offline_download_state"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,116 @@
|
|||||||
|
package io.narl.protonstream.download
|
||||||
|
|
||||||
|
import android.app.NotificationChannel
|
||||||
|
import android.app.NotificationManager
|
||||||
|
import android.content.pm.ServiceInfo
|
||||||
|
import android.content.Context
|
||||||
|
import androidx.core.app.NotificationCompat
|
||||||
|
import androidx.work.CoroutineWorker
|
||||||
|
import androidx.work.Data
|
||||||
|
import androidx.work.ForegroundInfo
|
||||||
|
import androidx.work.WorkerParameters
|
||||||
|
import androidx.work.workDataOf
|
||||||
|
import io.narl.protonstream.native.NativeRuntime
|
||||||
|
import uniffi.pstr_android.DownloadObserver
|
||||||
|
import java.util.UUID
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Durable boundary for native offline downloads. Rust owns the block-aligned transfer and
|
||||||
|
* catalog mutation; WorkManager owns constraints, retries and user-visible lifecycle.
|
||||||
|
*/
|
||||||
|
class OfflineDownloadWorker(
|
||||||
|
appContext: Context,
|
||||||
|
parameters: WorkerParameters,
|
||||||
|
) : CoroutineWorker(appContext, parameters) {
|
||||||
|
override suspend fun doWork(): Result {
|
||||||
|
setForeground(createForegroundInfo(0))
|
||||||
|
val shareId = inputData.getString(KEY_SHARE_ID) ?: return Result.failure(error("missing share"))
|
||||||
|
val volumeId = inputData.getString(KEY_VOLUME_ID) ?: return Result.failure(error("missing volume"))
|
||||||
|
val linkId = inputData.getString(KEY_LINK_ID) ?: return Result.failure(error("missing link"))
|
||||||
|
val label = inputData.getString(KEY_LABEL) ?: linkId
|
||||||
|
val store = DownloadStateStore(applicationContext)
|
||||||
|
var retained = store.get(shareId, linkId)
|
||||||
|
?: RetainedDownload(shareId, volumeId, linkId, label)
|
||||||
|
retained = retained.copy(status = RetainedDownload.STATUS_RUNNING, error = null)
|
||||||
|
store.put(retained)
|
||||||
|
|
||||||
|
val observer = object : DownloadObserver {
|
||||||
|
override fun onProgress(downloaded: ULong, total: ULong) {
|
||||||
|
val percent = if (total == 0UL) 0 else ((downloaded * 100UL) / total).toInt()
|
||||||
|
setProgressAsync(workDataOf(KEY_DOWNLOADED to downloaded.toLong(), KEY_TOTAL to total.toLong()))
|
||||||
|
setForegroundAsync(createForegroundInfo(percent))
|
||||||
|
val requested = store.get(shareId, linkId)
|
||||||
|
val requestedStatus = requested?.status
|
||||||
|
retained = (requested ?: retained).copy(
|
||||||
|
downloaded = downloaded.toLong(),
|
||||||
|
total = total.toLong(),
|
||||||
|
status = if (requestedStatus == RetainedDownload.STATUS_PAUSED ||
|
||||||
|
requestedStatus == RetainedDownload.STATUS_CANCELLED
|
||||||
|
) requestedStatus else RetainedDownload.STATUS_RUNNING,
|
||||||
|
)
|
||||||
|
store.put(retained)
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun isCancelled(): Boolean = isStopped
|
||||||
|
}
|
||||||
|
return runCatching {
|
||||||
|
NativeRuntime.engine().downloadEpisode(shareId, volumeId, linkId, observer)
|
||||||
|
}.fold(
|
||||||
|
onSuccess = {
|
||||||
|
store.remove(shareId, linkId)
|
||||||
|
Result.success()
|
||||||
|
},
|
||||||
|
onFailure = { failure ->
|
||||||
|
if (isStopped) {
|
||||||
|
val current = store.get(shareId, linkId) ?: retained
|
||||||
|
if (current.status != RetainedDownload.STATUS_PAUSED) {
|
||||||
|
store.put(current.copy(status = RetainedDownload.STATUS_CANCELLED))
|
||||||
|
}
|
||||||
|
Result.failure(error("cancelled"))
|
||||||
|
} else if (runAttemptCount < MAX_RETRIES) {
|
||||||
|
store.put(retained.copy(status = RetainedDownload.STATUS_QUEUED, error = failure.message))
|
||||||
|
Result.retry()
|
||||||
|
} else {
|
||||||
|
val message = failure.message ?: "download failed"
|
||||||
|
store.put(retained.copy(status = RetainedDownload.STATUS_FAILED, error = message))
|
||||||
|
Result.failure(error(message))
|
||||||
|
}
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun createForegroundInfo(progress: Int): ForegroundInfo {
|
||||||
|
val manager = applicationContext.getSystemService(NotificationManager::class.java)
|
||||||
|
manager.createNotificationChannel(
|
||||||
|
NotificationChannel(CHANNEL_ID, "Offline downloads", NotificationManager.IMPORTANCE_LOW),
|
||||||
|
)
|
||||||
|
val notification = NotificationCompat.Builder(applicationContext, CHANNEL_ID)
|
||||||
|
.setSmallIcon(io.narl.protonstream.R.drawable.ic_launcher_foreground)
|
||||||
|
.setContentTitle("Making episode available offline")
|
||||||
|
.setProgress(100, progress, progress == 0)
|
||||||
|
.setOngoing(true)
|
||||||
|
.build()
|
||||||
|
return ForegroundInfo(
|
||||||
|
stableNotificationId(id),
|
||||||
|
notification,
|
||||||
|
ServiceInfo.FOREGROUND_SERVICE_TYPE_DATA_SYNC,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun error(message: String) = Data.Builder().putString(KEY_ERROR, message).build()
|
||||||
|
|
||||||
|
companion object {
|
||||||
|
const val KEY_SHARE_ID = "share_id"
|
||||||
|
const val KEY_VOLUME_ID = "volume_id"
|
||||||
|
const val KEY_LINK_ID = "link_id"
|
||||||
|
const val KEY_LABEL = "label"
|
||||||
|
const val KEY_ERROR = "error"
|
||||||
|
const val KEY_DOWNLOADED = "downloaded"
|
||||||
|
const val KEY_TOTAL = "total"
|
||||||
|
private const val MAX_RETRIES = 3
|
||||||
|
private const val CHANNEL_ID = "offline-downloads"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
internal fun stableNotificationId(workId: UUID): Int =
|
||||||
|
(workId.hashCode() and Int.MAX_VALUE).coerceAtLeast(1)
|
||||||
@@ -0,0 +1,68 @@
|
|||||||
|
package io.narl.protonstream.native
|
||||||
|
|
||||||
|
import android.content.Context
|
||||||
|
import android.security.keystore.KeyGenParameterSpec
|
||||||
|
import android.security.keystore.KeyProperties
|
||||||
|
import android.util.Base64
|
||||||
|
import java.security.KeyStore
|
||||||
|
import javax.crypto.Cipher
|
||||||
|
import javax.crypto.KeyGenerator
|
||||||
|
import javax.crypto.SecretKey
|
||||||
|
import javax.crypto.spec.GCMParameterSpec
|
||||||
|
import uniffi.pstr_android.AndroidSecretStore
|
||||||
|
|
||||||
|
/** Encrypts share fragments and passwords before they reach app preferences. */
|
||||||
|
class KeystoreSecretStore(context: Context) : AndroidSecretStore {
|
||||||
|
private val preferences = context.getSharedPreferences(PREFERENCES, Context.MODE_PRIVATE)
|
||||||
|
private val keyStore = KeyStore.getInstance("AndroidKeyStore").apply { load(null) }
|
||||||
|
|
||||||
|
override fun set(key: String, value: String) {
|
||||||
|
val cipher = Cipher.getInstance(TRANSFORMATION)
|
||||||
|
cipher.init(Cipher.ENCRYPT_MODE, encryptionKey())
|
||||||
|
val encrypted = cipher.doFinal(value.toByteArray(Charsets.UTF_8))
|
||||||
|
val payload = cipher.iv + encrypted
|
||||||
|
check(preferences.edit().putString(key, Base64.encodeToString(payload, Base64.NO_WRAP)).commit())
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun get(key: String): String? {
|
||||||
|
val encoded = preferences.getString(key, null) ?: return null
|
||||||
|
val payload = Base64.decode(encoded, Base64.NO_WRAP)
|
||||||
|
require(payload.size > IV_SIZE) { "Invalid encrypted credential" }
|
||||||
|
val cipher = Cipher.getInstance(TRANSFORMATION)
|
||||||
|
cipher.init(
|
||||||
|
Cipher.DECRYPT_MODE,
|
||||||
|
encryptionKey(),
|
||||||
|
GCMParameterSpec(TAG_BITS, payload.copyOfRange(0, IV_SIZE)),
|
||||||
|
)
|
||||||
|
return cipher.doFinal(payload.copyOfRange(IV_SIZE, payload.size)).toString(Charsets.UTF_8)
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun delete(key: String) {
|
||||||
|
check(preferences.edit().remove(key).commit())
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun encryptionKey(): SecretKey {
|
||||||
|
(keyStore.getKey(KEY_ALIAS, null) as? SecretKey)?.let { return it }
|
||||||
|
return KeyGenerator.getInstance(KeyProperties.KEY_ALGORITHM_AES, "AndroidKeyStore").run {
|
||||||
|
init(
|
||||||
|
KeyGenParameterSpec.Builder(
|
||||||
|
KEY_ALIAS,
|
||||||
|
KeyProperties.PURPOSE_ENCRYPT or KeyProperties.PURPOSE_DECRYPT,
|
||||||
|
)
|
||||||
|
.setBlockModes(KeyProperties.BLOCK_MODE_GCM)
|
||||||
|
.setEncryptionPaddings(KeyProperties.ENCRYPTION_PADDING_NONE)
|
||||||
|
.setKeySize(256)
|
||||||
|
.build(),
|
||||||
|
)
|
||||||
|
generateKey()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private companion object {
|
||||||
|
const val PREFERENCES = "proton_stream_secrets"
|
||||||
|
const val KEY_ALIAS = "proton-stream-share-secrets-v1"
|
||||||
|
const val TRANSFORMATION = "AES/GCM/NoPadding"
|
||||||
|
const val IV_SIZE = 12
|
||||||
|
const val TAG_BITS = 128
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,40 @@
|
|||||||
|
package io.narl.protonstream.native
|
||||||
|
|
||||||
|
import android.content.Context
|
||||||
|
import java.io.File
|
||||||
|
import kotlinx.coroutines.Dispatchers
|
||||||
|
import kotlinx.coroutines.withContext
|
||||||
|
import uniffi.pstr_android.AndroidEngine
|
||||||
|
import uniffi.pstr_android.AndroidPaths
|
||||||
|
|
||||||
|
/** Process-wide owner of the Rust engine; records and decrypted media remain app-private. */
|
||||||
|
object NativeRuntime {
|
||||||
|
private lateinit var appContext: Context
|
||||||
|
@Volatile private var instance: AndroidEngine? = null
|
||||||
|
|
||||||
|
fun initialize(context: Context) {
|
||||||
|
System.loadLibrary("pstr_android")
|
||||||
|
initTls(context)
|
||||||
|
appContext = context.applicationContext
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Initializes Android's certificate verifier before Proton networking. */
|
||||||
|
private external fun initTls(context: Context)
|
||||||
|
|
||||||
|
suspend fun engine(): AndroidEngine = withContext(Dispatchers.IO) {
|
||||||
|
instance ?: synchronized(this@NativeRuntime) {
|
||||||
|
instance ?: createEngine().also { instance = it }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun createEngine(): AndroidEngine {
|
||||||
|
check(::appContext.isInitialized) { "NativeRuntime is not initialized" }
|
||||||
|
val config = File(appContext.noBackupFilesDir, "config")
|
||||||
|
val data = File(appContext.filesDir, "data")
|
||||||
|
val cache = File(appContext.cacheDir, "stream")
|
||||||
|
return AndroidEngine(
|
||||||
|
AndroidPaths(config.absolutePath, data.absolutePath, cache.absolutePath),
|
||||||
|
KeystoreSecretStore(appContext),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,273 @@
|
|||||||
|
package io.narl.protonstream.playback
|
||||||
|
|
||||||
|
import android.view.Surface
|
||||||
|
import android.view.SurfaceHolder
|
||||||
|
import android.view.SurfaceView
|
||||||
|
import androidx.compose.foundation.background
|
||||||
|
import androidx.compose.foundation.layout.Box
|
||||||
|
import androidx.compose.foundation.layout.fillMaxSize
|
||||||
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
|
import androidx.compose.foundation.layout.padding
|
||||||
|
import androidx.compose.foundation.layout.Row
|
||||||
|
import androidx.compose.foundation.layout.Column
|
||||||
|
import androidx.compose.foundation.layout.Arrangement
|
||||||
|
import androidx.compose.foundation.layout.heightIn
|
||||||
|
import androidx.compose.foundation.lazy.LazyColumn
|
||||||
|
import androidx.compose.foundation.lazy.items
|
||||||
|
import androidx.compose.material3.AlertDialog
|
||||||
|
import androidx.compose.material3.Button
|
||||||
|
import androidx.compose.material3.OutlinedButton
|
||||||
|
import androidx.compose.material3.Slider
|
||||||
|
import androidx.compose.material3.MaterialTheme
|
||||||
|
import androidx.compose.material3.Text
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.DisposableEffect
|
||||||
|
import androidx.compose.runtime.LaunchedEffect
|
||||||
|
import androidx.compose.runtime.remember
|
||||||
|
import androidx.compose.runtime.rememberCoroutineScope
|
||||||
|
import androidx.compose.runtime.collectAsState
|
||||||
|
import androidx.compose.runtime.getValue
|
||||||
|
import androidx.compose.runtime.mutableStateOf
|
||||||
|
import androidx.compose.runtime.setValue
|
||||||
|
import androidx.compose.ui.Alignment
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.graphics.Color
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
import androidx.compose.ui.viewinterop.AndroidView
|
||||||
|
import io.narl.protonstream.native.NativeRuntime
|
||||||
|
import kotlinx.coroutines.Dispatchers
|
||||||
|
import kotlinx.coroutines.launch
|
||||||
|
import kotlinx.coroutines.withContext
|
||||||
|
import uniffi.pstr_android.AndroidEngine
|
||||||
|
import uniffi.pstr_android.AndroidStream
|
||||||
|
import uniffi.pstr_android.EpisodeRecord
|
||||||
|
import kotlin.math.roundToInt
|
||||||
|
|
||||||
|
/** Exact seekable stream contract a bundled libmpv JNI adapter must consume. */
|
||||||
|
class RustStreamSession(
|
||||||
|
private val episode: EpisodeRecord,
|
||||||
|
) {
|
||||||
|
private var engine: AndroidEngine? = null
|
||||||
|
private var stream: AndroidStream? = null
|
||||||
|
private var handle: ULong? = null
|
||||||
|
private var streamSize: ULong? = null
|
||||||
|
|
||||||
|
suspend fun open(): RustStreamSession = withContext(Dispatchers.IO) {
|
||||||
|
val openedEngine = NativeRuntime.engine()
|
||||||
|
engine = openedEngine
|
||||||
|
stream = openedEngine.openStream(episode.shareId, episode.volumeId, episode.linkId).also {
|
||||||
|
streamSize = it.size()
|
||||||
|
handle = it.nativeHandle()
|
||||||
|
}
|
||||||
|
this@RustStreamSession
|
||||||
|
}
|
||||||
|
|
||||||
|
fun nativeHandle(): ULong = checkNotNull(handle) { "stream is not published" }
|
||||||
|
|
||||||
|
fun size(): ULong = checkNotNull(streamSize) { "stream is not open" }
|
||||||
|
|
||||||
|
fun releaseNative(host: LibmpvHost) {
|
||||||
|
handle?.let(host::releaseNativeStream)
|
||||||
|
handle = null
|
||||||
|
streamSize = null
|
||||||
|
stream = null
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The native player now owns the published token until stop/end/close. */
|
||||||
|
fun transferToPlayer() {
|
||||||
|
handle = null
|
||||||
|
streamSize = null
|
||||||
|
stream = null
|
||||||
|
}
|
||||||
|
|
||||||
|
suspend fun closeEngine() = withContext(Dispatchers.IO) {
|
||||||
|
engine?.releaseStream(episode.shareId, episode.volumeId, episode.linkId)
|
||||||
|
engine = null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Implement only in the variant that bundles a pinned libmpv native artifact. */
|
||||||
|
interface LibmpvHost {
|
||||||
|
fun attachSurface(surface: Surface)
|
||||||
|
fun detachSurface()
|
||||||
|
/** Consumes the opaque handle through pstr_android_stream_{read,size}. */
|
||||||
|
suspend fun play(
|
||||||
|
nativeHandle: ULong,
|
||||||
|
size: ULong,
|
||||||
|
startPosition: Double = 0.0,
|
||||||
|
audioLanguage: String? = null,
|
||||||
|
subtitleLanguage: String? = null,
|
||||||
|
subtitles: Boolean = true,
|
||||||
|
)
|
||||||
|
/** Calls pstr_android_stream_release exactly once for a published handle. */
|
||||||
|
fun releaseNativeStream(nativeHandle: ULong)
|
||||||
|
}
|
||||||
|
|
||||||
|
private data class PlaybackStartup(
|
||||||
|
val nativeHandle: ULong,
|
||||||
|
val size: ULong,
|
||||||
|
val startPosition: Double,
|
||||||
|
val audioLanguage: String?,
|
||||||
|
val subtitleLanguage: String?,
|
||||||
|
val subtitles: Boolean,
|
||||||
|
)
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun LibmpvPlayerSurface(titleKey: String, episode: EpisodeRecord, host: LibmpvHost? = null) {
|
||||||
|
if (host == null) {
|
||||||
|
Box(Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
|
||||||
|
Text("Playback is unavailable: this build does not include libmpv.", color = Color.White)
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
val scope = rememberCoroutineScope()
|
||||||
|
var playbackError by remember(sessionKey(episode)) { mutableStateOf<String?>(null) }
|
||||||
|
val session = remember(episode.shareId, episode.linkId) {
|
||||||
|
RustStreamSession(episode)
|
||||||
|
}
|
||||||
|
LaunchedEffect(session, host) {
|
||||||
|
runCatching {
|
||||||
|
val startup = withContext(Dispatchers.IO) {
|
||||||
|
session.open()
|
||||||
|
val engine = NativeRuntime.engine()
|
||||||
|
val preferences = engine.titleTrackPreferences(titleKey)
|
||||||
|
val watch = engine.watchState(episode.shareId, episode.linkId)
|
||||||
|
PlaybackStartup(
|
||||||
|
nativeHandle = session.nativeHandle(),
|
||||||
|
size = session.size(),
|
||||||
|
startPosition = watch?.positionSecs ?: 0.0,
|
||||||
|
audioLanguage = preferences.audioLanguage,
|
||||||
|
subtitleLanguage = preferences.subtitleLanguage,
|
||||||
|
subtitles = preferences.subtitles,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
host.play(
|
||||||
|
startup.nativeHandle, startup.size, startup.startPosition,
|
||||||
|
startup.audioLanguage, startup.subtitleLanguage, startup.subtitles,
|
||||||
|
)
|
||||||
|
session.transferToPlayer()
|
||||||
|
}.onFailure { playbackError = it.message ?: "Playback failed" }
|
||||||
|
}
|
||||||
|
DisposableEffect(session, host) {
|
||||||
|
onDispose {
|
||||||
|
host.detachSurface()
|
||||||
|
session.releaseNative(host)
|
||||||
|
scope.launch { session.closeEngine() }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Box(Modifier.fillMaxSize()) {
|
||||||
|
AndroidView(
|
||||||
|
modifier = Modifier.fillMaxSize(),
|
||||||
|
factory = { context ->
|
||||||
|
SurfaceView(context).apply {
|
||||||
|
holder.addCallback(object : SurfaceHolder.Callback {
|
||||||
|
override fun surfaceCreated(holder: SurfaceHolder) = host.attachSurface(holder.surface)
|
||||||
|
override fun surfaceChanged(holder: SurfaceHolder, format: Int, width: Int, height: Int) = host.attachSurface(holder.surface)
|
||||||
|
override fun surfaceDestroyed(holder: SurfaceHolder) = host.detachSurface()
|
||||||
|
})
|
||||||
|
}
|
||||||
|
},
|
||||||
|
)
|
||||||
|
if (host is NativeMpvHost) PlayerControls(titleKey, episode, host, Modifier.align(Alignment.BottomCenter))
|
||||||
|
playbackError?.let { message ->
|
||||||
|
Text(message, color = Color.White, modifier = Modifier.align(Alignment.Center).background(Color.Black.copy(alpha = 0.8f)).padding(16.dp))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun sessionKey(episode: EpisodeRecord) = "${episode.shareId}:${episode.linkId}"
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun PlayerControls(titleKey: String, episode: EpisodeRecord, host: NativeMpvHost, modifier: Modifier = Modifier) {
|
||||||
|
val state by host.state.collectAsState()
|
||||||
|
val tracks by host.tracks.collectAsState()
|
||||||
|
val scope = rememberCoroutineScope()
|
||||||
|
var choosing by remember { mutableStateOf<String?>(null) }
|
||||||
|
LaunchedEffect(state.position.roundToInt() / 5) {
|
||||||
|
if (state.duration > 0.0) withContext(Dispatchers.IO) {
|
||||||
|
NativeRuntime.engine().saveWatchState(
|
||||||
|
episode.shareId, episode.linkId, state.position.coerceAtMost(state.duration), state.duration,
|
||||||
|
state.ended || state.position >= state.duration * 0.9,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Column(modifier.fillMaxWidth().background(Color.Black.copy(alpha = 0.68f)).padding(12.dp)) {
|
||||||
|
Slider(
|
||||||
|
value = state.position.toFloat().coerceIn(0f, state.duration.toFloat().coerceAtLeast(1f)),
|
||||||
|
onValueChange = { host.seek(it.toDouble()) },
|
||||||
|
valueRange = 0f..state.duration.toFloat().coerceAtLeast(1f),
|
||||||
|
)
|
||||||
|
Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.SpaceBetween, verticalAlignment = Alignment.CenterVertically) {
|
||||||
|
Button(onClick = { host.setPaused(!state.paused) }) { Text(if (state.paused) "Play" else "Pause") }
|
||||||
|
Text("${formatTime(state.position)} / ${formatTime(state.duration)}", color = Color.White)
|
||||||
|
Button(onClick = { host.setMuted(!state.muted) }) { Text(if (state.muted) "Unmute" else "Mute") }
|
||||||
|
}
|
||||||
|
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||||
|
Button(onClick = { choosing = "audio" }) {
|
||||||
|
Text("Audio: ${tracks.firstOrNull { it.type == "audio" && it.selected }?.label ?: "default"}", maxLines = 1)
|
||||||
|
}
|
||||||
|
Button(onClick = { choosing = "sub" }) {
|
||||||
|
Text("Subtitles: ${tracks.firstOrNull { it.type == "sub" && it.selected }?.label ?: "off"}", maxLines = 1)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
choosing?.let { type ->
|
||||||
|
TrackChooser(
|
||||||
|
type = type,
|
||||||
|
tracks = tracks.filter { it.type == type },
|
||||||
|
onDismiss = { choosing = null },
|
||||||
|
onSelect = { selected ->
|
||||||
|
host.selectTrack(selected)
|
||||||
|
choosing = null
|
||||||
|
scope.launch(Dispatchers.IO) {
|
||||||
|
val engine = NativeRuntime.engine()
|
||||||
|
val old = engine.titleTrackPreferences(titleKey)
|
||||||
|
engine.setTitleTrackPreferences(
|
||||||
|
titleKey,
|
||||||
|
if (type == "audio") old.copy(audioLanguage = selected?.language?.takeIf(String::isNotBlank))
|
||||||
|
else old.copy(
|
||||||
|
subtitleLanguage = selected?.language?.takeIf(String::isNotBlank),
|
||||||
|
subtitles = selected != null,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun TrackChooser(
|
||||||
|
type: String,
|
||||||
|
tracks: List<MpvTrack>,
|
||||||
|
onDismiss: () -> Unit,
|
||||||
|
onSelect: (MpvTrack?) -> Unit,
|
||||||
|
) {
|
||||||
|
AlertDialog(
|
||||||
|
onDismissRequest = onDismiss,
|
||||||
|
title = { Text(if (type == "audio") "Audio track" else "Subtitle track") },
|
||||||
|
text = {
|
||||||
|
LazyColumn(Modifier.fillMaxWidth().heightIn(max = 420.dp)) {
|
||||||
|
if (type == "sub") {
|
||||||
|
item {
|
||||||
|
OutlinedButton(onClick = { onSelect(null) }, Modifier.fillMaxWidth()) {
|
||||||
|
Text(if (tracks.none(MpvTrack::selected)) "✓ Off" else "Off")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
items(tracks, key = MpvTrack::id) { track ->
|
||||||
|
OutlinedButton(onClick = { onSelect(track) }, Modifier.fillMaxWidth()) {
|
||||||
|
Text(if (track.selected) "✓ ${track.label}" else track.label)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
confirmButton = { Button(onClick = onDismiss) { Text("Close") } },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun formatTime(seconds: Double): String {
|
||||||
|
val total = seconds.coerceAtLeast(0.0).roundToInt()
|
||||||
|
return "%d:%02d".format(total / 60, total % 60)
|
||||||
|
}
|
||||||
@@ -0,0 +1,190 @@
|
|||||||
|
package io.narl.protonstream.playback
|
||||||
|
|
||||||
|
import android.view.Surface
|
||||||
|
import kotlinx.coroutines.CoroutineScope
|
||||||
|
import kotlinx.coroutines.Dispatchers
|
||||||
|
import kotlinx.coroutines.Job
|
||||||
|
import kotlinx.coroutines.SupervisorJob
|
||||||
|
import kotlinx.coroutines.cancel
|
||||||
|
import kotlinx.coroutines.delay
|
||||||
|
import kotlinx.coroutines.flow.MutableStateFlow
|
||||||
|
import kotlinx.coroutines.flow.StateFlow
|
||||||
|
import kotlinx.coroutines.flow.asStateFlow
|
||||||
|
import kotlinx.coroutines.isActive
|
||||||
|
import kotlinx.coroutines.launch
|
||||||
|
import kotlinx.coroutines.withContext
|
||||||
|
import org.json.JSONArray
|
||||||
|
import java.util.concurrent.locks.ReentrantLock
|
||||||
|
import kotlin.concurrent.withLock
|
||||||
|
|
||||||
|
data class MpvPlaybackState(
|
||||||
|
val position: Double = 0.0,
|
||||||
|
val duration: Double = 0.0,
|
||||||
|
val volume: Double = 100.0,
|
||||||
|
val paused: Boolean = true,
|
||||||
|
val muted: Boolean = false,
|
||||||
|
val ended: Boolean = false,
|
||||||
|
)
|
||||||
|
|
||||||
|
data class MpvTrack(
|
||||||
|
val id: Long,
|
||||||
|
val type: String,
|
||||||
|
val language: String,
|
||||||
|
val title: String,
|
||||||
|
val selected: Boolean,
|
||||||
|
) {
|
||||||
|
val label: String get() = listOf(language, title).filter(String::isNotBlank)
|
||||||
|
.joinToString(" — ").ifBlank { "${type.replaceFirstChar(Char::uppercase)} $id" }
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Process-local owner of the libmpv core. PlaybackService owns its lifetime. */
|
||||||
|
class NativeMpvHost : LibmpvHost, AutoCloseable {
|
||||||
|
private val nativeHandle = nativeCreate().also { check(it != 0L) { "libmpv initialization failed" } }
|
||||||
|
private val lifecycle = ReentrantLock()
|
||||||
|
@Volatile private var closed = false
|
||||||
|
private val scope = CoroutineScope(SupervisorJob() + Dispatchers.Main.immediate)
|
||||||
|
private val mutableState = MutableStateFlow(MpvPlaybackState())
|
||||||
|
private val mutableTracks = MutableStateFlow(emptyList<MpvTrack>())
|
||||||
|
private var poller: Job? = null
|
||||||
|
private var currentStreamHandle: Long? = null
|
||||||
|
var onPlaybackStarted: (() -> Unit)? = null
|
||||||
|
var onStateChanged: ((MpvPlaybackState) -> Unit)? = null
|
||||||
|
var onExplicitStop: (() -> Unit)? = null
|
||||||
|
|
||||||
|
val state: StateFlow<MpvPlaybackState> = mutableState.asStateFlow()
|
||||||
|
val tracks: StateFlow<List<MpvTrack>> = mutableTracks.asStateFlow()
|
||||||
|
val hasActivePlayback: Boolean get() = mutableState.value.duration > 0.0 && !mutableState.value.ended
|
||||||
|
|
||||||
|
override fun attachSurface(surface: Surface) {
|
||||||
|
withOpenHandle { nativeAttachSurface(it, surface) }
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun detachSurface() {
|
||||||
|
withOpenHandle { nativeDetachSurface(it) }
|
||||||
|
}
|
||||||
|
|
||||||
|
override suspend fun play(
|
||||||
|
nativeHandle: ULong,
|
||||||
|
size: ULong,
|
||||||
|
startPosition: Double,
|
||||||
|
audioLanguage: String?,
|
||||||
|
subtitleLanguage: String?,
|
||||||
|
subtitles: Boolean,
|
||||||
|
) {
|
||||||
|
check(size > 0uL) { "Cannot play an empty stream" }
|
||||||
|
withContext(Dispatchers.IO) {
|
||||||
|
val loaded = withOpenHandle {
|
||||||
|
nativeLoad(it, nativeHandle.toLong(), startPosition, audioLanguage, subtitleLanguage, subtitles).also { loaded ->
|
||||||
|
if (loaded) {
|
||||||
|
currentStreamHandle?.let(::pstrAndroidStreamRelease)
|
||||||
|
currentStreamHandle = nativeHandle.toLong()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} ?: false
|
||||||
|
check(loaded) {
|
||||||
|
"libmpv rejected the stream or no video surface was available"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
withOpenHandle {
|
||||||
|
onPlaybackStarted?.invoke()
|
||||||
|
startPolling()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun setPaused(paused: Boolean) { withOpenHandle { nativePause(it, paused) } }
|
||||||
|
fun seek(position: Double) { withOpenHandle { nativeSeek(it, position) } }
|
||||||
|
fun setVolume(volume: Double) { withOpenHandle { nativeVolume(it, volume) } }
|
||||||
|
fun setMuted(muted: Boolean) { withOpenHandle { nativeMute(it, muted) } }
|
||||||
|
fun selectTrack(track: MpvTrack?) {
|
||||||
|
val audio = track?.type == "audio"
|
||||||
|
withOpenHandle { nativeSelectTrack(it, audio, track?.id ?: -1) }
|
||||||
|
refreshTracks()
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Explicit user stop: stop media, release its stream, and retire the foreground service. */
|
||||||
|
fun stop() {
|
||||||
|
val callback = withOpenHandle {
|
||||||
|
nativeStop(it)
|
||||||
|
currentStreamHandle?.let(::pstrAndroidStreamRelease)
|
||||||
|
currentStreamHandle = null
|
||||||
|
onExplicitStop
|
||||||
|
}
|
||||||
|
callback?.invoke()
|
||||||
|
}
|
||||||
|
|
||||||
|
internal fun releaseEndedStream() {
|
||||||
|
withOpenHandle { currentStreamHandle?.let(::pstrAndroidStreamRelease); currentStreamHandle = null }
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun releaseNativeStream(nativeHandle: ULong) = pstrAndroidStreamRelease(nativeHandle.toLong())
|
||||||
|
|
||||||
|
private fun startPolling() {
|
||||||
|
if (poller?.isActive == true) return
|
||||||
|
poller = scope.launch {
|
||||||
|
var tick = 0
|
||||||
|
while (isActive) {
|
||||||
|
val values = withOpenHandle(::nativeState) ?: break
|
||||||
|
if (values.size >= 6) {
|
||||||
|
val next = MpvPlaybackState(values[0], values[1], values[2], values[3] != 0.0, values[4] != 0.0, values[5] != 0.0)
|
||||||
|
mutableState.value = next
|
||||||
|
onStateChanged?.invoke(next)
|
||||||
|
}
|
||||||
|
if (tick++ % 4 == 0) refreshTracks()
|
||||||
|
delay(250)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun refreshTracks() {
|
||||||
|
runCatching {
|
||||||
|
val encoded = withOpenHandle(::nativeTracks) ?: return
|
||||||
|
val values = JSONArray(encoded)
|
||||||
|
List(values.length()) { index ->
|
||||||
|
values.getJSONObject(index).run {
|
||||||
|
MpvTrack(getLong("id"), getString("type"), getString("language"), getString("title"), getBoolean("selected"))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}.onSuccess { mutableTracks.value = it }
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun close() {
|
||||||
|
scope.cancel()
|
||||||
|
lifecycle.withLock {
|
||||||
|
if (closed) return@withLock
|
||||||
|
closed = true
|
||||||
|
onPlaybackStarted = null
|
||||||
|
onStateChanged = null
|
||||||
|
onExplicitStop = null
|
||||||
|
nativeDestroy(nativeHandle)
|
||||||
|
currentStreamHandle?.let(::pstrAndroidStreamRelease)
|
||||||
|
currentStreamHandle = null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Serializes destruction against every JNI use of the raw Player pointer. */
|
||||||
|
private inline fun <T> withOpenHandle(block: (Long) -> T): T? = lifecycle.withLock {
|
||||||
|
if (closed) null else block(nativeHandle)
|
||||||
|
}
|
||||||
|
|
||||||
|
private external fun nativeCreate(): Long
|
||||||
|
private external fun nativeDestroy(handle: Long)
|
||||||
|
private external fun nativeAttachSurface(handle: Long, surface: Surface)
|
||||||
|
private external fun nativeDetachSurface(handle: Long)
|
||||||
|
private external fun nativeLoad(handle: Long, stream: Long, start: Double, audio: String?, subtitle: String?, subtitles: Boolean): Boolean
|
||||||
|
private external fun nativePause(handle: Long, paused: Boolean)
|
||||||
|
private external fun nativeSeek(handle: Long, position: Double)
|
||||||
|
private external fun nativeVolume(handle: Long, volume: Double)
|
||||||
|
private external fun nativeMute(handle: Long, muted: Boolean)
|
||||||
|
private external fun nativeSelectTrack(handle: Long, audio: Boolean, track: Long)
|
||||||
|
private external fun nativeStop(handle: Long)
|
||||||
|
private external fun nativeState(handle: Long): DoubleArray
|
||||||
|
private external fun nativeTracks(handle: Long): String
|
||||||
|
private external fun pstrAndroidStreamRelease(handle: Long)
|
||||||
|
|
||||||
|
companion object {
|
||||||
|
init {
|
||||||
|
System.loadLibrary("pstr_android")
|
||||||
|
System.loadLibrary("pstr_mpv")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,174 @@
|
|||||||
|
package io.narl.protonstream.playback
|
||||||
|
|
||||||
|
import android.app.Notification
|
||||||
|
import android.app.NotificationChannel
|
||||||
|
import android.app.NotificationManager
|
||||||
|
import android.app.PendingIntent
|
||||||
|
import android.app.Service
|
||||||
|
import android.content.Intent
|
||||||
|
import android.media.AudioAttributes
|
||||||
|
import android.media.AudioFocusRequest
|
||||||
|
import android.media.AudioManager
|
||||||
|
import android.media.MediaMetadata
|
||||||
|
import android.media.session.MediaSession
|
||||||
|
import android.media.session.PlaybackState
|
||||||
|
import android.os.Binder
|
||||||
|
import android.os.IBinder
|
||||||
|
import io.narl.protonstream.MainActivity
|
||||||
|
import io.narl.protonstream.settings.SettingsStore
|
||||||
|
|
||||||
|
/** Owns libmpv so audio survives Activity recreation, rotation and PiP. */
|
||||||
|
class PlaybackService : Service() {
|
||||||
|
inner class PlaybackBinder : Binder() {
|
||||||
|
val host: NativeMpvHost get() = this@PlaybackService.host
|
||||||
|
}
|
||||||
|
|
||||||
|
private lateinit var host: NativeMpvHost
|
||||||
|
private lateinit var mediaSession: MediaSession
|
||||||
|
private lateinit var audioManager: AudioManager
|
||||||
|
private var audioFocus: AudioFocusRequest? = null
|
||||||
|
private var notifiedPaused: Boolean? = null
|
||||||
|
private var foregroundPlayback = false
|
||||||
|
private var sessionPlayback = false
|
||||||
|
private val binder = PlaybackBinder()
|
||||||
|
|
||||||
|
override fun onCreate() {
|
||||||
|
super.onCreate()
|
||||||
|
createChannel()
|
||||||
|
audioManager = getSystemService(AudioManager::class.java)
|
||||||
|
host = NativeMpvHost().apply {
|
||||||
|
onPlaybackStarted = {
|
||||||
|
requestAudioFocus()
|
||||||
|
sessionPlayback = true
|
||||||
|
if (SettingsStore(this@PlaybackService).backgroundAudio) {
|
||||||
|
// Convert the bound service into a started foreground
|
||||||
|
// service only when the viewer opted to keep audio alive.
|
||||||
|
startService(Intent(this@PlaybackService, PlaybackService::class.java))
|
||||||
|
startForeground(NOTIFICATION_ID, notification(false))
|
||||||
|
foregroundPlayback = true
|
||||||
|
}
|
||||||
|
mediaSession.isActive = true
|
||||||
|
}
|
||||||
|
onStateChanged = { updateSession(it) }
|
||||||
|
onExplicitStop = { retirePlayback() }
|
||||||
|
}
|
||||||
|
mediaSession = MediaSession(this, "proton-stream").apply {
|
||||||
|
setCallback(object : MediaSession.Callback() {
|
||||||
|
override fun onPlay() = host.setPaused(false)
|
||||||
|
override fun onPause() = host.setPaused(true)
|
||||||
|
override fun onSeekTo(pos: Long) = host.seek(pos / 1_000.0)
|
||||||
|
override fun onStop() = host.stop()
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onBind(intent: Intent?): IBinder = binder
|
||||||
|
|
||||||
|
override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
|
||||||
|
when (intent?.action) {
|
||||||
|
ACTION_PLAY -> host.setPaused(false)
|
||||||
|
ACTION_PAUSE -> host.setPaused(true)
|
||||||
|
ACTION_STOP -> host.stop()
|
||||||
|
}
|
||||||
|
return START_NOT_STICKY
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onDestroy() {
|
||||||
|
audioFocus?.let(audioManager::abandonAudioFocusRequest)
|
||||||
|
mediaSession.release()
|
||||||
|
host.close()
|
||||||
|
super.onDestroy()
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun requestAudioFocus() {
|
||||||
|
audioFocus?.let(audioManager::abandonAudioFocusRequest)
|
||||||
|
val request = AudioFocusRequest.Builder(AudioManager.AUDIOFOCUS_GAIN)
|
||||||
|
.setAudioAttributes(
|
||||||
|
AudioAttributes.Builder().setUsage(AudioAttributes.USAGE_MEDIA)
|
||||||
|
.setContentType(AudioAttributes.CONTENT_TYPE_MOVIE).build(),
|
||||||
|
)
|
||||||
|
.setOnAudioFocusChangeListener { focus ->
|
||||||
|
when (focus) {
|
||||||
|
AudioManager.AUDIOFOCUS_LOSS, AudioManager.AUDIOFOCUS_LOSS_TRANSIENT -> host.setPaused(true)
|
||||||
|
AudioManager.AUDIOFOCUS_GAIN -> Unit
|
||||||
|
}
|
||||||
|
}.build()
|
||||||
|
audioFocus = request
|
||||||
|
audioManager.requestAudioFocus(request)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun updateSession(state: MpvPlaybackState) {
|
||||||
|
val status = when {
|
||||||
|
state.ended -> PlaybackState.STATE_STOPPED
|
||||||
|
state.paused -> PlaybackState.STATE_PAUSED
|
||||||
|
else -> PlaybackState.STATE_PLAYING
|
||||||
|
}
|
||||||
|
mediaSession.setPlaybackState(
|
||||||
|
PlaybackState.Builder()
|
||||||
|
.setActions(PlaybackState.ACTION_PLAY or PlaybackState.ACTION_PAUSE or PlaybackState.ACTION_SEEK_TO or PlaybackState.ACTION_STOP)
|
||||||
|
.setState(status, (state.position * 1_000).toLong(), if (state.paused) 0f else 1f)
|
||||||
|
.build(),
|
||||||
|
)
|
||||||
|
mediaSession.setMetadata(
|
||||||
|
MediaMetadata.Builder().putLong(MediaMetadata.METADATA_KEY_DURATION, (state.duration * 1_000).toLong()).build(),
|
||||||
|
)
|
||||||
|
if (state.ended && foregroundPlayback) {
|
||||||
|
host.releaseEndedStream()
|
||||||
|
retirePlayback()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (foregroundPlayback && notifiedPaused != state.paused) {
|
||||||
|
notifiedPaused = state.paused
|
||||||
|
getSystemService(NotificationManager::class.java).notify(NOTIFICATION_ID, notification(state.paused))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun retirePlayback() {
|
||||||
|
if (!sessionPlayback && !foregroundPlayback) return
|
||||||
|
sessionPlayback = false
|
||||||
|
notifiedPaused = null
|
||||||
|
mediaSession.isActive = false
|
||||||
|
audioFocus?.let(audioManager::abandonAudioFocusRequest)
|
||||||
|
audioFocus = null
|
||||||
|
if (foregroundPlayback) stopForeground(STOP_FOREGROUND_REMOVE)
|
||||||
|
foregroundPlayback = false
|
||||||
|
stopSelf()
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun notification(paused: Boolean): Notification {
|
||||||
|
val open = PendingIntent.getActivity(
|
||||||
|
this, 0, Intent(this, MainActivity::class.java), PendingIntent.FLAG_IMMUTABLE or PendingIntent.FLAG_UPDATE_CURRENT,
|
||||||
|
)
|
||||||
|
val toggleAction = if (paused) ACTION_PLAY else ACTION_PAUSE
|
||||||
|
val toggle = PendingIntent.getService(
|
||||||
|
this, 1, Intent(this, PlaybackService::class.java).setAction(toggleAction), PendingIntent.FLAG_IMMUTABLE or PendingIntent.FLAG_UPDATE_CURRENT,
|
||||||
|
)
|
||||||
|
val stop = PendingIntent.getService(
|
||||||
|
this, 2, Intent(this, PlaybackService::class.java).setAction(ACTION_STOP), PendingIntent.FLAG_IMMUTABLE or PendingIntent.FLAG_UPDATE_CURRENT,
|
||||||
|
)
|
||||||
|
return Notification.Builder(this, CHANNEL_ID)
|
||||||
|
.setSmallIcon(android.R.drawable.ic_media_play)
|
||||||
|
.setContentTitle("proton-stream")
|
||||||
|
.setContentText(if (paused) "Playback paused" else "Playing")
|
||||||
|
.setContentIntent(open)
|
||||||
|
.setOngoing(!paused)
|
||||||
|
.addAction(Notification.Action.Builder(null, if (paused) "Play" else "Pause", toggle).build())
|
||||||
|
.addAction(Notification.Action.Builder(null, "Stop", stop).build())
|
||||||
|
.setStyle(Notification.MediaStyle().setMediaSession(mediaSession.sessionToken).setShowActionsInCompactView(0, 1))
|
||||||
|
.build()
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun createChannel() {
|
||||||
|
getSystemService(NotificationManager::class.java).createNotificationChannel(
|
||||||
|
NotificationChannel(CHANNEL_ID, "Playback", NotificationManager.IMPORTANCE_LOW),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
companion object {
|
||||||
|
private const val CHANNEL_ID = "playback"
|
||||||
|
private const val NOTIFICATION_ID = 47
|
||||||
|
private const val ACTION_PLAY = "io.narl.protonstream.PLAY"
|
||||||
|
private const val ACTION_PAUSE = "io.narl.protonstream.PAUSE"
|
||||||
|
private const val ACTION_STOP = "io.narl.protonstream.STOP"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
package io.narl.protonstream.settings
|
||||||
|
|
||||||
|
import android.content.Context
|
||||||
|
|
||||||
|
class SettingsStore(context: Context) {
|
||||||
|
private val preferences = context.getSharedPreferences(NAME, Context.MODE_PRIVATE)
|
||||||
|
|
||||||
|
var wifiOnly: Boolean
|
||||||
|
get() = preferences.getBoolean(KEY_WIFI_ONLY, true)
|
||||||
|
set(value) { preferences.edit().putBoolean(KEY_WIFI_ONLY, value).apply() }
|
||||||
|
|
||||||
|
var backgroundAudio: Boolean
|
||||||
|
get() = preferences.getBoolean(KEY_BACKGROUND_AUDIO, true)
|
||||||
|
set(value) { preferences.edit().putBoolean(KEY_BACKGROUND_AUDIO, value).apply() }
|
||||||
|
|
||||||
|
private companion object {
|
||||||
|
const val NAME = "proton_stream_settings"
|
||||||
|
const val KEY_WIFI_ONLY = "wifi_only"
|
||||||
|
const val KEY_BACKGROUND_AUDIO = "background_audio"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,225 @@
|
|||||||
|
package io.narl.protonstream.ui
|
||||||
|
|
||||||
|
import android.content.Context
|
||||||
|
import androidx.lifecycle.ViewModel
|
||||||
|
import androidx.lifecycle.ViewModelProvider
|
||||||
|
import androidx.lifecycle.viewModelScope
|
||||||
|
import androidx.lifecycle.Observer
|
||||||
|
import androidx.work.WorkInfo
|
||||||
|
import androidx.work.WorkManager
|
||||||
|
import io.narl.protonstream.download.DownloadCoordinator
|
||||||
|
import io.narl.protonstream.download.DownloadStateStore
|
||||||
|
import io.narl.protonstream.download.RetainedDownload
|
||||||
|
import kotlinx.coroutines.Dispatchers
|
||||||
|
import kotlinx.coroutines.FlowPreview
|
||||||
|
import kotlinx.coroutines.flow.MutableStateFlow
|
||||||
|
import kotlinx.coroutines.flow.StateFlow
|
||||||
|
import kotlinx.coroutines.flow.asStateFlow
|
||||||
|
import kotlinx.coroutines.flow.update
|
||||||
|
import kotlinx.coroutines.launch
|
||||||
|
import kotlinx.coroutines.flow.debounce
|
||||||
|
import kotlinx.coroutines.flow.distinctUntilChanged
|
||||||
|
import kotlinx.coroutines.flow.drop
|
||||||
|
import kotlinx.coroutines.flow.map
|
||||||
|
import kotlinx.coroutines.flow.callbackFlow
|
||||||
|
import kotlinx.coroutines.channels.awaitClose
|
||||||
|
import kotlinx.coroutines.withContext
|
||||||
|
import io.narl.protonstream.native.NativeRuntime
|
||||||
|
import uniffi.pstr_android.ShareRecord
|
||||||
|
import uniffi.pstr_android.TitleRecord
|
||||||
|
import uniffi.pstr_android.OfflineRecord
|
||||||
|
import uniffi.pstr_android.MetadataProvider
|
||||||
|
import uniffi.pstr_android.MetadataSettingsRecord
|
||||||
|
|
||||||
|
data class AppUiState(
|
||||||
|
val loading: Boolean = true,
|
||||||
|
val refreshing: Boolean = false,
|
||||||
|
val query: String = "",
|
||||||
|
val titles: List<TitleRecord> = emptyList(),
|
||||||
|
val shares: List<ShareRecord> = emptyList(),
|
||||||
|
val offline: List<OfflineRecord> = emptyList(),
|
||||||
|
val metadataSettings: MetadataSettingsRecord = MetadataSettingsRecord(
|
||||||
|
enabled = false,
|
||||||
|
provider = MetadataProvider.ANI_LIST,
|
||||||
|
language = "en",
|
||||||
|
ready = true,
|
||||||
|
),
|
||||||
|
val message: String? = null,
|
||||||
|
)
|
||||||
|
|
||||||
|
@OptIn(FlowPreview::class)
|
||||||
|
class AppViewModel(context: Context, private val workManager: WorkManager) : ViewModel() {
|
||||||
|
private val appContext = context.applicationContext
|
||||||
|
private val mutableState = MutableStateFlow(AppUiState())
|
||||||
|
private val searchQuery = MutableStateFlow("")
|
||||||
|
val state: StateFlow<AppUiState> = mutableState.asStateFlow()
|
||||||
|
|
||||||
|
init {
|
||||||
|
reload()
|
||||||
|
viewModelScope.launch {
|
||||||
|
searchQuery.debounce(300).distinctUntilChanged().collect { reloadLibrary(it) }
|
||||||
|
}
|
||||||
|
viewModelScope.launch {
|
||||||
|
workManager.workInfosByTagFlow(DownloadCoordinator.TAG)
|
||||||
|
.map { work -> work.filter { it.state.isFinished }.map { it.id }.toSet() }
|
||||||
|
.distinctUntilChanged()
|
||||||
|
.drop(1)
|
||||||
|
.collect { reload() }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun search(query: String) {
|
||||||
|
mutableState.update { it.copy(query = query) }
|
||||||
|
searchQuery.value = query
|
||||||
|
}
|
||||||
|
|
||||||
|
fun refresh() {
|
||||||
|
viewModelScope.launch {
|
||||||
|
mutableState.update { it.copy(refreshing = true, message = null) }
|
||||||
|
runCatching { withContext(Dispatchers.IO) { NativeRuntime.engine().crawl(null) } }
|
||||||
|
.onFailure { error -> mutableState.update { it.copy(message = error.message) } }
|
||||||
|
mutableState.update { it.copy(refreshing = false) }
|
||||||
|
reload()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun addShare(name: String, url: String, password: String?) {
|
||||||
|
viewModelScope.launch {
|
||||||
|
runCatching {
|
||||||
|
withContext(Dispatchers.IO) {
|
||||||
|
val engine = NativeRuntime.engine()
|
||||||
|
engine.addShare(name, url, password?.takeIf(String::isNotBlank))
|
||||||
|
engine.crawl(null)
|
||||||
|
}
|
||||||
|
}.onFailure { error -> mutableState.update { it.copy(message = error.message) } }
|
||||||
|
reload()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun removeShare(id: String) {
|
||||||
|
viewModelScope.launch {
|
||||||
|
runCatching {
|
||||||
|
withContext(Dispatchers.IO) {
|
||||||
|
// Wait for WorkManager cancellation before Rust removes
|
||||||
|
// catalog/files. Rust also rejects any late publication.
|
||||||
|
workManager.cancelAllWorkByTag(DownloadCoordinator.shareTag(id)).result.get()
|
||||||
|
val downloads = DownloadStateStore(appContext)
|
||||||
|
val engine = NativeRuntime.engine()
|
||||||
|
downloads.records().filter { it.shareId == id }.forEach {
|
||||||
|
engine.removeOfflineEpisode(it.shareId, it.linkId)
|
||||||
|
}
|
||||||
|
downloads.removeShare(id)
|
||||||
|
engine.removeShare(id)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.onFailure { error -> mutableState.update { it.copy(message = error.message) } }
|
||||||
|
reload()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun dismissMessage() = mutableState.update { it.copy(message = null) }
|
||||||
|
|
||||||
|
fun reportError(error: Throwable) {
|
||||||
|
mutableState.update { it.copy(message = error.message ?: "Unexpected error") }
|
||||||
|
}
|
||||||
|
|
||||||
|
fun saveMetadataSettings(enabled: Boolean, provider: MetadataProvider, language: String, apiKey: String) {
|
||||||
|
viewModelScope.launch {
|
||||||
|
runCatching {
|
||||||
|
withContext(Dispatchers.IO) {
|
||||||
|
val engine = NativeRuntime.engine()
|
||||||
|
if (provider == MetadataProvider.TMDB && apiKey.isNotBlank()) {
|
||||||
|
engine.setMetadataApiKey(provider, apiKey)
|
||||||
|
}
|
||||||
|
engine.setMetadataSettings(MetadataSettingsRecord(enabled, provider, language, true))
|
||||||
|
if (enabled) engine.matchTitles(false)
|
||||||
|
}
|
||||||
|
}.onFailure(::reportError)
|
||||||
|
reload()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun reloadAfterMetadataChange() = reload()
|
||||||
|
|
||||||
|
fun removeOffline(file: OfflineRecord) {
|
||||||
|
viewModelScope.launch {
|
||||||
|
runCatching {
|
||||||
|
withContext(Dispatchers.IO) { NativeRuntime.engine().removeOfflineEpisode(file.shareId, file.linkId) }
|
||||||
|
}.onFailure { error -> mutableState.update { it.copy(message = error.message) } }
|
||||||
|
reload()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun pauseDownload(download: RetainedDownload) = DownloadCoordinator.pause(appContext, download)
|
||||||
|
|
||||||
|
fun resumeDownload(download: RetainedDownload) = DownloadCoordinator.resume(appContext, download)
|
||||||
|
|
||||||
|
fun deletePartial(download: RetainedDownload) {
|
||||||
|
viewModelScope.launch {
|
||||||
|
DownloadCoordinator.cancel(appContext, download)
|
||||||
|
runCatching {
|
||||||
|
withContext(Dispatchers.IO) {
|
||||||
|
workManager.cancelUniqueWork(
|
||||||
|
DownloadCoordinator.workName(download.shareId, download.linkId),
|
||||||
|
).result.get()
|
||||||
|
NativeRuntime.engine().removeOfflineEpisode(download.shareId, download.linkId)
|
||||||
|
DownloadStateStore(appContext).remove(download.shareId, download.linkId)
|
||||||
|
}
|
||||||
|
}.onFailure(::reportError)
|
||||||
|
reload()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun reload() {
|
||||||
|
viewModelScope.launch {
|
||||||
|
runCatching {
|
||||||
|
withContext(Dispatchers.IO) {
|
||||||
|
val engine = NativeRuntime.engine()
|
||||||
|
Reloaded(
|
||||||
|
engine.shares(),
|
||||||
|
engine.library(mutableState.value.query.takeIf(String::isNotBlank)),
|
||||||
|
engine.offlineFiles(),
|
||||||
|
engine.metadataSettings(),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}.onSuccess { result ->
|
||||||
|
mutableState.update { it.copy(
|
||||||
|
loading = false,
|
||||||
|
shares = result.shares,
|
||||||
|
titles = result.titles,
|
||||||
|
offline = result.offline,
|
||||||
|
metadataSettings = result.metadataSettings,
|
||||||
|
) }
|
||||||
|
}
|
||||||
|
.onFailure { error ->
|
||||||
|
mutableState.update { it.copy(loading = false, message = error.message) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private suspend fun reloadLibrary(query: String) {
|
||||||
|
runCatching {
|
||||||
|
withContext(Dispatchers.IO) { NativeRuntime.engine().library(query.takeIf(String::isNotBlank)) }
|
||||||
|
}.onSuccess { titles -> mutableState.update { it.copy(titles = titles) } }
|
||||||
|
.onFailure { error -> mutableState.update { it.copy(message = error.message) } }
|
||||||
|
}
|
||||||
|
|
||||||
|
class Factory(private val context: Context, private val workManager: WorkManager) : ViewModelProvider.Factory {
|
||||||
|
@Suppress("UNCHECKED_CAST")
|
||||||
|
override fun <T : ViewModel> create(modelClass: Class<T>): T = AppViewModel(context, workManager) as T
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private data class Reloaded(
|
||||||
|
val shares: List<ShareRecord>,
|
||||||
|
val titles: List<TitleRecord>,
|
||||||
|
val offline: List<OfflineRecord>,
|
||||||
|
val metadataSettings: MetadataSettingsRecord,
|
||||||
|
)
|
||||||
|
|
||||||
|
private fun WorkManager.workInfosByTagFlow(tag: String) = callbackFlow {
|
||||||
|
val work = getWorkInfosByTagLiveData(tag)
|
||||||
|
val observer = Observer<List<WorkInfo>> { trySend(it).isSuccess }
|
||||||
|
work.observeForever(observer)
|
||||||
|
awaitClose { work.removeObserver(observer) }
|
||||||
|
}
|
||||||
@@ -0,0 +1,748 @@
|
|||||||
|
package io.narl.protonstream.ui
|
||||||
|
|
||||||
|
import androidx.compose.animation.AnimatedContent
|
||||||
|
import androidx.compose.foundation.background
|
||||||
|
import androidx.compose.foundation.clickable
|
||||||
|
import androidx.compose.foundation.layout.Arrangement
|
||||||
|
import androidx.compose.foundation.layout.Box
|
||||||
|
import androidx.compose.foundation.layout.Column
|
||||||
|
import androidx.compose.foundation.layout.PaddingValues
|
||||||
|
import androidx.compose.foundation.layout.Row
|
||||||
|
import androidx.compose.foundation.layout.Spacer
|
||||||
|
import androidx.compose.foundation.layout.fillMaxSize
|
||||||
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
|
import androidx.compose.foundation.layout.height
|
||||||
|
import androidx.compose.foundation.layout.heightIn
|
||||||
|
import androidx.compose.foundation.layout.padding
|
||||||
|
import androidx.compose.foundation.layout.size
|
||||||
|
import androidx.compose.foundation.layout.width
|
||||||
|
import androidx.compose.foundation.layout.aspectRatio
|
||||||
|
import androidx.compose.foundation.lazy.LazyColumn
|
||||||
|
import androidx.compose.foundation.lazy.items
|
||||||
|
import androidx.compose.foundation.lazy.grid.GridCells
|
||||||
|
import androidx.compose.foundation.lazy.grid.LazyVerticalGrid
|
||||||
|
import androidx.compose.foundation.lazy.grid.items
|
||||||
|
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||||
|
import androidx.compose.material3.AlertDialog
|
||||||
|
import androidx.compose.material3.Button
|
||||||
|
import androidx.compose.material3.Card
|
||||||
|
import androidx.compose.material3.CircularProgressIndicator
|
||||||
|
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||||
|
import androidx.compose.material3.FilledTonalButton
|
||||||
|
import androidx.compose.material3.HorizontalDivider
|
||||||
|
import androidx.compose.material3.LinearProgressIndicator
|
||||||
|
import androidx.compose.material3.MaterialTheme
|
||||||
|
import androidx.compose.material3.OutlinedTextField
|
||||||
|
import androidx.compose.material3.Scaffold
|
||||||
|
import androidx.compose.material3.SnackbarHost
|
||||||
|
import androidx.compose.material3.SnackbarHostState
|
||||||
|
import androidx.compose.material3.Switch
|
||||||
|
import androidx.compose.material3.Text
|
||||||
|
import androidx.compose.material3.TextButton
|
||||||
|
import androidx.compose.material3.OutlinedButton
|
||||||
|
import androidx.compose.material3.TopAppBar
|
||||||
|
import androidx.compose.material3.adaptive.navigationsuite.NavigationSuiteScaffold
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.LaunchedEffect
|
||||||
|
import androidx.compose.runtime.collectAsState
|
||||||
|
import androidx.compose.runtime.getValue
|
||||||
|
import androidx.compose.runtime.mutableStateOf
|
||||||
|
import androidx.compose.runtime.remember
|
||||||
|
import androidx.compose.runtime.rememberCoroutineScope
|
||||||
|
import androidx.compose.runtime.setValue
|
||||||
|
import androidx.compose.runtime.livedata.observeAsState
|
||||||
|
import androidx.compose.ui.Alignment
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.graphics.Color
|
||||||
|
import androidx.compose.ui.platform.LocalContext
|
||||||
|
import androidx.compose.ui.text.font.FontWeight
|
||||||
|
import androidx.compose.ui.text.input.PasswordVisualTransformation
|
||||||
|
import androidx.compose.foundation.text.KeyboardOptions
|
||||||
|
import androidx.compose.ui.text.input.KeyboardType
|
||||||
|
import androidx.compose.ui.window.DialogProperties
|
||||||
|
import androidx.compose.ui.window.SecureFlagPolicy
|
||||||
|
import androidx.compose.ui.text.style.TextOverflow
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
import androidx.lifecycle.viewmodel.compose.viewModel
|
||||||
|
import androidx.work.WorkManager
|
||||||
|
import io.narl.protonstream.settings.SettingsStore
|
||||||
|
import kotlinx.coroutines.Dispatchers
|
||||||
|
import kotlinx.coroutines.launch
|
||||||
|
import kotlinx.coroutines.withContext
|
||||||
|
import io.narl.protonstream.download.DownloadCoordinator
|
||||||
|
import io.narl.protonstream.download.DownloadStateStore
|
||||||
|
import io.narl.protonstream.download.RetainedDownload
|
||||||
|
import io.narl.protonstream.native.NativeRuntime
|
||||||
|
import uniffi.pstr_android.ShareRecord
|
||||||
|
import uniffi.pstr_android.TitleRecord
|
||||||
|
import uniffi.pstr_android.TrackPreferencesRecord
|
||||||
|
import uniffi.pstr_android.EpisodeRecord
|
||||||
|
import uniffi.pstr_android.MatchRecord
|
||||||
|
import uniffi.pstr_android.MetadataProvider
|
||||||
|
import io.narl.protonstream.playback.NativeMpvHost
|
||||||
|
import io.narl.protonstream.playback.LibmpvPlayerSurface
|
||||||
|
|
||||||
|
private enum class Destination(val label: String, val glyph: String) {
|
||||||
|
Library("Library", "▦"),
|
||||||
|
Shares("Shares", "⇄"),
|
||||||
|
Downloads("Downloads", "⇩"),
|
||||||
|
Settings("Settings", "⚙"),
|
||||||
|
}
|
||||||
|
|
||||||
|
@OptIn(ExperimentalMaterial3Api::class)
|
||||||
|
@Composable
|
||||||
|
fun ProtonStreamApp(
|
||||||
|
playerHost: NativeMpvHost? = null,
|
||||||
|
model: AppViewModel = viewModel(
|
||||||
|
factory = AppViewModel.Factory(
|
||||||
|
LocalContext.current,
|
||||||
|
WorkManager.getInstance(LocalContext.current),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
) {
|
||||||
|
val state by model.state.collectAsState()
|
||||||
|
val snackbars = remember { SnackbarHostState() }
|
||||||
|
var destination by remember { mutableStateOf(Destination.Library) }
|
||||||
|
var selectedTitle by remember { mutableStateOf<TitleRecord?>(null) }
|
||||||
|
|
||||||
|
LaunchedEffect(state.titles, selectedTitle?.key) {
|
||||||
|
selectedTitle?.let { selected ->
|
||||||
|
selectedTitle = state.titles.firstOrNull { it.key == selected.key } ?: selected
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
LaunchedEffect(state.message) {
|
||||||
|
state.message?.let {
|
||||||
|
snackbars.showSnackbar(it)
|
||||||
|
model.dismissMessage()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
NavigationSuiteScaffold(
|
||||||
|
navigationSuiteItems = {
|
||||||
|
Destination.entries.forEach { item ->
|
||||||
|
item(
|
||||||
|
selected = destination == item,
|
||||||
|
onClick = { destination = item; selectedTitle = null },
|
||||||
|
icon = { Text(item.glyph) },
|
||||||
|
label = { Text(item.label) },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
) {
|
||||||
|
Scaffold(
|
||||||
|
snackbarHost = { SnackbarHost(snackbars) },
|
||||||
|
topBar = {
|
||||||
|
TopAppBar(
|
||||||
|
title = { Text(selectedTitle?.name ?: "proton-stream") },
|
||||||
|
actions = {
|
||||||
|
if (destination == Destination.Library) {
|
||||||
|
FilledTonalButton(onClick = model::refresh, enabled = !state.refreshing) {
|
||||||
|
Text(if (state.refreshing) "Refreshing…" else "Refresh")
|
||||||
|
}
|
||||||
|
Spacer(Modifier.width(12.dp))
|
||||||
|
}
|
||||||
|
},
|
||||||
|
)
|
||||||
|
},
|
||||||
|
) { padding ->
|
||||||
|
AnimatedContent(destination, label = "primary navigation") { target ->
|
||||||
|
when (target) {
|
||||||
|
Destination.Library -> if (selectedTitle == null) {
|
||||||
|
LibraryScreen(state, model::search, { selectedTitle = it }, padding)
|
||||||
|
} else {
|
||||||
|
TitleScreen(
|
||||||
|
selectedTitle!!,
|
||||||
|
playerHost,
|
||||||
|
{ selectedTitle = null },
|
||||||
|
model::reportError,
|
||||||
|
model::reloadAfterMetadataChange,
|
||||||
|
padding,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
Destination.Shares -> SharesScreen(state.shares, model::addShare, model::removeShare, padding)
|
||||||
|
Destination.Downloads -> DownloadsScreen(
|
||||||
|
state,
|
||||||
|
model::removeOffline,
|
||||||
|
model::pauseDownload,
|
||||||
|
model::resumeDownload,
|
||||||
|
model::deletePartial,
|
||||||
|
padding,
|
||||||
|
)
|
||||||
|
Destination.Settings -> SettingsScreen(state, model::saveMetadataSettings, padding)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun LibraryScreen(
|
||||||
|
state: AppUiState,
|
||||||
|
onSearch: (String) -> Unit,
|
||||||
|
onTitle: (TitleRecord) -> Unit,
|
||||||
|
padding: PaddingValues,
|
||||||
|
) {
|
||||||
|
Column(Modifier.fillMaxSize().padding(padding).padding(horizontal = 16.dp)) {
|
||||||
|
OutlinedTextField(
|
||||||
|
value = state.query,
|
||||||
|
onValueChange = onSearch,
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
singleLine = true,
|
||||||
|
placeholder = { Text("Search library") },
|
||||||
|
)
|
||||||
|
Spacer(Modifier.height(16.dp))
|
||||||
|
if (state.loading) {
|
||||||
|
Box(Modifier.fillMaxSize(), contentAlignment = Alignment.Center) { CircularProgressIndicator() }
|
||||||
|
} else if (state.titles.isEmpty()) {
|
||||||
|
EmptyState("Your library is empty", "Add a Proton Drive public link under Shares, then refresh.")
|
||||||
|
} else {
|
||||||
|
LazyVerticalGrid(
|
||||||
|
columns = GridCells.Adaptive(180.dp),
|
||||||
|
horizontalArrangement = Arrangement.spacedBy(12.dp),
|
||||||
|
verticalArrangement = Arrangement.spacedBy(12.dp),
|
||||||
|
contentPadding = PaddingValues(bottom = 24.dp),
|
||||||
|
) {
|
||||||
|
items(state.titles, key = { it.key }) { title ->
|
||||||
|
Card(Modifier.fillMaxWidth().clickable { onTitle(title) }) {
|
||||||
|
RemoteArtwork(
|
||||||
|
title.backdropUrl ?: title.posterUrl,
|
||||||
|
title.canonicalName ?: title.name,
|
||||||
|
Modifier.fillMaxWidth().height(210.dp),
|
||||||
|
)
|
||||||
|
Column(Modifier.padding(12.dp)) {
|
||||||
|
Text(title.name, fontWeight = FontWeight.SemiBold, maxLines = 2, overflow = TextOverflow.Ellipsis)
|
||||||
|
Text(
|
||||||
|
listOfNotNull(title.year?.toString(), "${title.episodeCount} episodes").joinToString(" · "),
|
||||||
|
style = MaterialTheme.typography.bodySmall,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun TitleScreen(
|
||||||
|
title: TitleRecord,
|
||||||
|
playerHost: NativeMpvHost?,
|
||||||
|
onBack: () -> Unit,
|
||||||
|
onPreferenceError: (Throwable) -> Unit,
|
||||||
|
onMetadataChanged: () -> Unit,
|
||||||
|
padding: PaddingValues,
|
||||||
|
) {
|
||||||
|
val context = LocalContext.current
|
||||||
|
val scope = rememberCoroutineScope()
|
||||||
|
var audioLanguage by remember(title.key) { mutableStateOf("") }
|
||||||
|
var subtitleLanguage by remember(title.key) { mutableStateOf("") }
|
||||||
|
var subtitlesEnabled by remember(title.key) { mutableStateOf(false) }
|
||||||
|
var playing by remember(title.key) { mutableStateOf<EpisodeRecord?>(null) }
|
||||||
|
var showMatch by remember(title.key) { mutableStateOf(false) }
|
||||||
|
LaunchedEffect(title.key) {
|
||||||
|
runCatching {
|
||||||
|
withContext(Dispatchers.IO) {
|
||||||
|
NativeRuntime.engine().titleTrackPreferences(title.key)
|
||||||
|
}
|
||||||
|
}.onSuccess { preferences ->
|
||||||
|
audioLanguage = preferences.audioLanguage.orEmpty()
|
||||||
|
subtitleLanguage = preferences.subtitleLanguage.orEmpty()
|
||||||
|
subtitlesEnabled = preferences.subtitles
|
||||||
|
}.onFailure(onPreferenceError)
|
||||||
|
}
|
||||||
|
if (playing != null) {
|
||||||
|
Box(Modifier.fillMaxSize().padding(padding).background(Color.Black)) {
|
||||||
|
LibmpvPlayerSurface(title.key, playing!!, playerHost)
|
||||||
|
FilledTonalButton(onClick = { playerHost?.stop(); playing = null }, Modifier.padding(16.dp)) { Text("Back") }
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
LazyColumn(
|
||||||
|
modifier = Modifier.fillMaxSize().padding(padding),
|
||||||
|
contentPadding = PaddingValues(16.dp),
|
||||||
|
verticalArrangement = Arrangement.spacedBy(8.dp),
|
||||||
|
) {
|
||||||
|
item {
|
||||||
|
FilledTonalButton(onClick = onBack) { Text("Back to library") }
|
||||||
|
RemoteArtwork(
|
||||||
|
title.backdropUrl ?: title.posterUrl,
|
||||||
|
title.canonicalName ?: title.name,
|
||||||
|
Modifier.fillMaxWidth().aspectRatio(16f / 9f).padding(top = 12.dp),
|
||||||
|
)
|
||||||
|
Text(title.canonicalName ?: title.name, style = MaterialTheme.typography.headlineMedium, modifier = Modifier.padding(top = 12.dp))
|
||||||
|
title.originalName?.takeIf { it != title.canonicalName }?.let {
|
||||||
|
Text(it, style = MaterialTheme.typography.bodyMedium)
|
||||||
|
}
|
||||||
|
Text(
|
||||||
|
listOfNotNull(
|
||||||
|
(title.metadataYear ?: title.year)?.toString(),
|
||||||
|
title.rating?.let { "%.1f/10".format(it) },
|
||||||
|
title.genres.takeIf { it.isNotEmpty() }?.joinToString(" · "),
|
||||||
|
).joinToString(" • "),
|
||||||
|
style = MaterialTheme.typography.bodySmall,
|
||||||
|
modifier = Modifier.padding(top = 6.dp),
|
||||||
|
)
|
||||||
|
title.overview?.let { Text(it, modifier = Modifier.padding(top = 10.dp)) }
|
||||||
|
Row(horizontalArrangement = Arrangement.spacedBy(8.dp), modifier = Modifier.padding(top = 12.dp)) {
|
||||||
|
Button(onClick = { DownloadCoordinator.enqueue(context, title.seasons.flatMap { it.episodes }) }) {
|
||||||
|
Text("Download show")
|
||||||
|
}
|
||||||
|
FilledTonalButton(onClick = { showMatch = true }) { Text("Change match") }
|
||||||
|
}
|
||||||
|
Text("${title.watchedCount} of ${title.episodeCount} watched", Modifier.padding(vertical = 12.dp))
|
||||||
|
Text("Preferred tracks", style = MaterialTheme.typography.titleMedium)
|
||||||
|
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||||
|
OutlinedTextField(
|
||||||
|
audioLanguage,
|
||||||
|
{ audioLanguage = it },
|
||||||
|
label = { Text("Audio language") },
|
||||||
|
modifier = Modifier.weight(1f),
|
||||||
|
singleLine = true,
|
||||||
|
)
|
||||||
|
OutlinedTextField(
|
||||||
|
subtitleLanguage,
|
||||||
|
{ subtitleLanguage = it },
|
||||||
|
label = { Text("Subtitle language") },
|
||||||
|
modifier = Modifier.weight(1f),
|
||||||
|
singleLine = true,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
SettingToggle("Enable subtitles", subtitlesEnabled) { subtitlesEnabled = it }
|
||||||
|
FilledTonalButton(onClick = {
|
||||||
|
scope.launch {
|
||||||
|
runCatching {
|
||||||
|
withContext(Dispatchers.IO) {
|
||||||
|
NativeRuntime.engine().setTitleTrackPreferences(
|
||||||
|
title.key,
|
||||||
|
TrackPreferencesRecord(
|
||||||
|
audioLanguage.takeIf(String::isNotBlank),
|
||||||
|
subtitleLanguage.takeIf(String::isNotBlank),
|
||||||
|
subtitlesEnabled,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}.onFailure(onPreferenceError)
|
||||||
|
}
|
||||||
|
}) { Text("Save track preferences") }
|
||||||
|
}
|
||||||
|
title.seasons.forEach { season ->
|
||||||
|
item {
|
||||||
|
Row(
|
||||||
|
Modifier.fillMaxWidth().padding(top = 12.dp),
|
||||||
|
horizontalArrangement = Arrangement.SpaceBetween,
|
||||||
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
|
) {
|
||||||
|
Text(season.label, style = MaterialTheme.typography.titleLarge)
|
||||||
|
FilledTonalButton(onClick = { DownloadCoordinator.enqueue(context, season.episodes) }) {
|
||||||
|
Text("Download season")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
items(season.episodes, key = { it.linkId }) { episode ->
|
||||||
|
Card(Modifier.fillMaxWidth()) {
|
||||||
|
Row(Modifier.padding(12.dp), verticalAlignment = Alignment.CenterVertically) {
|
||||||
|
Button(onClick = { playing = episode }, enabled = playerHost != null) {
|
||||||
|
Text(if (playerHost == null) "Player loading…" else "Play")
|
||||||
|
}
|
||||||
|
Column(Modifier.weight(1f).padding(horizontal = 12.dp)) {
|
||||||
|
Text(episode.label, fontWeight = FontWeight.SemiBold)
|
||||||
|
Text(episode.detail, style = MaterialTheme.typography.bodySmall, maxLines = 1)
|
||||||
|
}
|
||||||
|
FilledTonalButton(
|
||||||
|
onClick = { DownloadCoordinator.enqueue(context, episode) },
|
||||||
|
enabled = !episode.offline,
|
||||||
|
) { Text(if (episode.offline) "Offline" else "Download") }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (showMatch) {
|
||||||
|
ChangeMatchDialog(
|
||||||
|
title = title,
|
||||||
|
onDismiss = { showMatch = false },
|
||||||
|
onChanged = { showMatch = false; onMetadataChanged() },
|
||||||
|
onError = onPreferenceError,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun ChangeMatchDialog(
|
||||||
|
title: TitleRecord,
|
||||||
|
onDismiss: () -> Unit,
|
||||||
|
onChanged: () -> Unit,
|
||||||
|
onError: (Throwable) -> Unit,
|
||||||
|
) {
|
||||||
|
var term by remember(title.key) { mutableStateOf(title.canonicalName ?: title.name) }
|
||||||
|
var searching by remember { mutableStateOf(false) }
|
||||||
|
var options by remember { mutableStateOf<List<MatchRecord>>(emptyList()) }
|
||||||
|
val scope = rememberCoroutineScope()
|
||||||
|
AlertDialog(
|
||||||
|
onDismissRequest = onDismiss,
|
||||||
|
title = { Text("Change match") },
|
||||||
|
text = {
|
||||||
|
Column {
|
||||||
|
Text("Search the configured metadata provider. Nothing is stored until you choose an entry.")
|
||||||
|
OutlinedTextField(term, { term = it }, Modifier.fillMaxWidth().padding(vertical = 8.dp), singleLine = true)
|
||||||
|
Button(enabled = term.isNotBlank() && !searching, onClick = {
|
||||||
|
searching = true
|
||||||
|
scope.launch {
|
||||||
|
runCatching {
|
||||||
|
withContext(Dispatchers.IO) { NativeRuntime.engine().searchMatches(title.key, term) }
|
||||||
|
}.onSuccess { options = it }.onFailure(onError)
|
||||||
|
searching = false
|
||||||
|
}
|
||||||
|
}) { Text(if (searching) "Searching…" else "Search") }
|
||||||
|
LazyColumn(Modifier.fillMaxWidth().heightIn(max = 300.dp)) {
|
||||||
|
items(options, key = { "${it.provider}:${it.remoteId}" }) { option ->
|
||||||
|
OutlinedButton(
|
||||||
|
onClick = {
|
||||||
|
scope.launch {
|
||||||
|
runCatching {
|
||||||
|
withContext(Dispatchers.IO) { NativeRuntime.engine().chooseMatch(title.key, option) }
|
||||||
|
}.onSuccess { onChanged() }.onFailure(onError)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
modifier = Modifier.fillMaxWidth().padding(top = 6.dp),
|
||||||
|
) {
|
||||||
|
Column(Modifier.fillMaxWidth()) {
|
||||||
|
Text(option.name, fontWeight = FontWeight.SemiBold)
|
||||||
|
Text(listOfNotNull(option.year?.toString(), option.originalName).joinToString(" · "))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
confirmButton = {
|
||||||
|
TextButton(onClick = {
|
||||||
|
scope.launch {
|
||||||
|
runCatching { withContext(Dispatchers.IO) { NativeRuntime.engine().forgetMatch(title.key) } }
|
||||||
|
.onSuccess { onChanged() }.onFailure(onError)
|
||||||
|
}
|
||||||
|
}) { Text("Forget match") }
|
||||||
|
},
|
||||||
|
dismissButton = { FilledTonalButton(onClick = onDismiss) { Text("Close") } },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun SharesScreen(
|
||||||
|
shares: List<ShareRecord>,
|
||||||
|
onAdd: (String, String, String?) -> Unit,
|
||||||
|
onRemove: (String) -> Unit,
|
||||||
|
padding: PaddingValues,
|
||||||
|
) {
|
||||||
|
var showAdd by remember { mutableStateOf(false) }
|
||||||
|
Column(Modifier.fillMaxSize().padding(padding).padding(16.dp)) {
|
||||||
|
Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.SpaceBetween, verticalAlignment = Alignment.CenterVertically) {
|
||||||
|
Text("Proton Drive public links", style = MaterialTheme.typography.titleLarge)
|
||||||
|
Button(onClick = { showAdd = true }) { Text("Add share") }
|
||||||
|
}
|
||||||
|
LazyColumn(verticalArrangement = Arrangement.spacedBy(8.dp), modifier = Modifier.padding(top = 16.dp)) {
|
||||||
|
items(shares, key = { it.id }) { share ->
|
||||||
|
Card(Modifier.fillMaxWidth()) {
|
||||||
|
Row(Modifier.padding(16.dp), verticalAlignment = Alignment.CenterVertically) {
|
||||||
|
Column(Modifier.weight(1f)) {
|
||||||
|
Text(share.name, fontWeight = FontWeight.SemiBold)
|
||||||
|
Text(if (share.hasCustomPassword) "Custom password stored securely" else "Public link")
|
||||||
|
}
|
||||||
|
FilledTonalButton(onClick = { onRemove(share.id) }) { Text("Remove") }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (showAdd) AddShareDialog(onDismiss = { showAdd = false }, onAdd = onAdd)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun AddShareDialog(onDismiss: () -> Unit, onAdd: (String, String, String?) -> Unit) {
|
||||||
|
var name by remember { mutableStateOf("") }
|
||||||
|
var url by remember { mutableStateOf("") }
|
||||||
|
var password by remember { mutableStateOf("") }
|
||||||
|
AlertDialog(
|
||||||
|
onDismissRequest = onDismiss,
|
||||||
|
properties = DialogProperties(securePolicy = SecureFlagPolicy.SecureOn),
|
||||||
|
title = { Text("Add Proton Drive share") },
|
||||||
|
text = {
|
||||||
|
Column(verticalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||||
|
OutlinedTextField(name, { name = it }, label = { Text("Library name") }, singleLine = true)
|
||||||
|
OutlinedTextField(
|
||||||
|
url,
|
||||||
|
{ url = it },
|
||||||
|
label = { Text("Public share URL") },
|
||||||
|
singleLine = true,
|
||||||
|
// The URL fragment is a secret. Password input disables
|
||||||
|
// IME learning/suggestions while normal long-press paste
|
||||||
|
// remains available.
|
||||||
|
visualTransformation = PasswordVisualTransformation(),
|
||||||
|
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Password),
|
||||||
|
)
|
||||||
|
OutlinedTextField(
|
||||||
|
password,
|
||||||
|
{ password = it },
|
||||||
|
label = { Text("Custom password (optional)") },
|
||||||
|
singleLine = true,
|
||||||
|
visualTransformation = PasswordVisualTransformation(),
|
||||||
|
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Password),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
confirmButton = {
|
||||||
|
Button(
|
||||||
|
onClick = { onAdd(name.trim(), url.trim(), password); onDismiss() },
|
||||||
|
enabled = name.isNotBlank() && url.isNotBlank(),
|
||||||
|
) { Text("Add") }
|
||||||
|
},
|
||||||
|
dismissButton = { FilledTonalButton(onClick = onDismiss) { Text("Cancel") } },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun DownloadsScreen(
|
||||||
|
state: AppUiState,
|
||||||
|
onRemove: (uniffi.pstr_android.OfflineRecord) -> Unit,
|
||||||
|
onPause: (RetainedDownload) -> Unit,
|
||||||
|
onResume: (RetainedDownload) -> Unit,
|
||||||
|
onDeletePartial: (RetainedDownload) -> Unit,
|
||||||
|
padding: PaddingValues,
|
||||||
|
) {
|
||||||
|
val context = LocalContext.current
|
||||||
|
val work = WorkManager.getInstance(context).getWorkInfosByTagLiveData(DownloadCoordinator.TAG)
|
||||||
|
val downloads by work.observeAsState(emptyList())
|
||||||
|
// Reading retained metadata on every WorkInfo transition also hydrates
|
||||||
|
// paused/failed/cancelled entries after WorkManager history is pruned.
|
||||||
|
val retained = remember(downloads) { DownloadStateStore(context).records() }
|
||||||
|
Column(Modifier.fillMaxSize().padding(padding).padding(16.dp)) {
|
||||||
|
Text("Offline downloads", style = MaterialTheme.typography.titleLarge)
|
||||||
|
Spacer(Modifier.height(16.dp))
|
||||||
|
if (retained.isEmpty() && state.offline.isEmpty()) {
|
||||||
|
EmptyState("No downloads", "Episodes, seasons, and shows saved offline appear here.")
|
||||||
|
}
|
||||||
|
state.offline.forEach { file ->
|
||||||
|
val episode = file.episode
|
||||||
|
Card(Modifier.fillMaxWidth().padding(bottom = 8.dp)) {
|
||||||
|
Row(Modifier.padding(16.dp), verticalAlignment = Alignment.CenterVertically) {
|
||||||
|
Column(Modifier.weight(1f)) {
|
||||||
|
Text(episode?.label ?: file.linkId, fontWeight = FontWeight.SemiBold)
|
||||||
|
Text(formatBytes(file.size))
|
||||||
|
}
|
||||||
|
FilledTonalButton(onClick = { onRemove(file) }) { Text("Make online only") }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
retained.forEach { download ->
|
||||||
|
val progress = if (download.total > 0L) download.downloaded.toFloat() / download.total else 0f
|
||||||
|
Card(Modifier.fillMaxWidth().padding(bottom = 8.dp)) {
|
||||||
|
Column(Modifier.padding(16.dp)) {
|
||||||
|
Text(download.label, fontWeight = FontWeight.SemiBold)
|
||||||
|
Text(download.status.replaceFirstChar { it.uppercase() })
|
||||||
|
if (download.total > 0L) {
|
||||||
|
LinearProgressIndicator(
|
||||||
|
progress = { progress },
|
||||||
|
modifier = Modifier.fillMaxWidth().padding(top = 8.dp),
|
||||||
|
)
|
||||||
|
Text("${formatBytes(download.downloaded.toULong())} of ${formatBytes(download.total.toULong())}", style = MaterialTheme.typography.bodySmall)
|
||||||
|
}
|
||||||
|
download.error?.let { Text(it, color = MaterialTheme.colorScheme.error) }
|
||||||
|
Row(
|
||||||
|
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
||||||
|
modifier = Modifier.padding(top = 8.dp),
|
||||||
|
) {
|
||||||
|
if (download.status == RetainedDownload.STATUS_RUNNING ||
|
||||||
|
download.status == RetainedDownload.STATUS_QUEUED
|
||||||
|
) {
|
||||||
|
FilledTonalButton(onClick = { onPause(download) }) { Text("Pause") }
|
||||||
|
} else {
|
||||||
|
FilledTonalButton(onClick = { onResume(download) }) { Text("Resume") }
|
||||||
|
}
|
||||||
|
TextButton(onClick = { onDeletePartial(download) }) { Text("Delete partial") }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun SettingsScreen(
|
||||||
|
state: AppUiState,
|
||||||
|
onSaveMetadata: (Boolean, MetadataProvider, String, String) -> Unit,
|
||||||
|
padding: PaddingValues,
|
||||||
|
) {
|
||||||
|
val context = LocalContext.current
|
||||||
|
val settings = remember { SettingsStore(context) }
|
||||||
|
var wifiOnly by remember { mutableStateOf(settings.wifiOnly) }
|
||||||
|
var backgroundAudio by remember { mutableStateOf(settings.backgroundAudio) }
|
||||||
|
var showMetadata by remember { mutableStateOf(false) }
|
||||||
|
var legalDocument by remember { mutableStateOf<LegalDocument?>(null) }
|
||||||
|
Column(Modifier.fillMaxSize().padding(padding).padding(16.dp)) {
|
||||||
|
Text("Settings", style = MaterialTheme.typography.titleLarge)
|
||||||
|
SettingToggle("Download on Wi-Fi only", wifiOnly) { wifiOnly = it; settings.wifiOnly = it }
|
||||||
|
HorizontalDivider()
|
||||||
|
SettingToggle("Continue audio in the background", backgroundAudio) {
|
||||||
|
backgroundAudio = it
|
||||||
|
settings.backgroundAudio = it
|
||||||
|
}
|
||||||
|
Text(
|
||||||
|
"When disabled, leaving playback stops the player instead of keeping a media notification active.",
|
||||||
|
style = MaterialTheme.typography.bodySmall,
|
||||||
|
modifier = Modifier.padding(bottom = 14.dp),
|
||||||
|
)
|
||||||
|
HorizontalDivider()
|
||||||
|
Text("Metadata enrichment", style = MaterialTheme.typography.titleMedium, modifier = Modifier.padding(top = 20.dp))
|
||||||
|
Text(
|
||||||
|
if (state.metadataSettings.enabled) "On · ${state.metadataSettings.provider.displayName()}"
|
||||||
|
else "Off (privacy default)",
|
||||||
|
)
|
||||||
|
Text(
|
||||||
|
"Enabling this sends library title names to the selected third-party provider over HTTPS.",
|
||||||
|
style = MaterialTheme.typography.bodySmall,
|
||||||
|
)
|
||||||
|
FilledTonalButton(onClick = { showMetadata = true }, modifier = Modifier.padding(vertical = 10.dp)) {
|
||||||
|
Text("Configure metadata")
|
||||||
|
}
|
||||||
|
HorizontalDivider()
|
||||||
|
Text("Storage", style = MaterialTheme.typography.titleMedium, modifier = Modifier.padding(top = 20.dp))
|
||||||
|
Text("Offline media is encrypted at rest by Android and kept in app-private storage.")
|
||||||
|
Text("proton-stream Android · GPL-3.0-or-later", style = MaterialTheme.typography.bodySmall, modifier = Modifier.padding(top = 24.dp))
|
||||||
|
Text(
|
||||||
|
"This program comes with absolutely no warranty. You may redistribute it under the GNU GPL.",
|
||||||
|
style = MaterialTheme.typography.bodySmall,
|
||||||
|
modifier = Modifier.padding(top = 8.dp),
|
||||||
|
)
|
||||||
|
Row(horizontalArrangement = Arrangement.spacedBy(8.dp), modifier = Modifier.padding(top = 12.dp)) {
|
||||||
|
FilledTonalButton(onClick = {
|
||||||
|
legalDocument = LegalDocument("GNU GPL v3", "licenses/GPL-3.0.txt")
|
||||||
|
}) { Text("View license") }
|
||||||
|
FilledTonalButton(onClick = {
|
||||||
|
legalDocument = LegalDocument("Third-party notices", "licenses/THIRD_PARTY_NOTICES.md")
|
||||||
|
}) { Text("View notices") }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
legalDocument?.let { document ->
|
||||||
|
LegalDocumentDialog(document, onDismiss = { legalDocument = null })
|
||||||
|
}
|
||||||
|
if (showMetadata) {
|
||||||
|
MetadataSettingsDialog(
|
||||||
|
current = state.metadataSettings,
|
||||||
|
onDismiss = { showMetadata = false },
|
||||||
|
onSave = { enabled, provider, language, key ->
|
||||||
|
onSaveMetadata(enabled, provider, language, key)
|
||||||
|
showMetadata = false
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun MetadataSettingsDialog(
|
||||||
|
current: uniffi.pstr_android.MetadataSettingsRecord,
|
||||||
|
onDismiss: () -> Unit,
|
||||||
|
onSave: (Boolean, MetadataProvider, String, String) -> Unit,
|
||||||
|
) {
|
||||||
|
var enabled by remember { mutableStateOf(current.enabled) }
|
||||||
|
var provider by remember { mutableStateOf(current.provider) }
|
||||||
|
var language by remember { mutableStateOf(current.language) }
|
||||||
|
var apiKey by remember { mutableStateOf("") }
|
||||||
|
AlertDialog(
|
||||||
|
onDismissRequest = onDismiss,
|
||||||
|
properties = DialogProperties(securePolicy = SecureFlagPolicy.SecureOn),
|
||||||
|
title = { Text("Metadata enrichment") },
|
||||||
|
text = {
|
||||||
|
Column(verticalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||||
|
Text("Off by default: enabling sends the titles in your library to a third party, associated with your IP address and subject to their privacy policy.")
|
||||||
|
SettingToggle("Enable enrichment", enabled) { enabled = it }
|
||||||
|
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||||
|
MetadataProvider.entries.forEach { option ->
|
||||||
|
if (option == provider) Button(onClick = { provider = option }) { Text(option.displayName()) }
|
||||||
|
else OutlinedButton(onClick = { provider = option }) { Text(option.displayName()) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Text(
|
||||||
|
if (provider == MetadataProvider.ANI_LIST) "Anime; no account or API key required."
|
||||||
|
else "Film and television; requires a free TMDB API key.",
|
||||||
|
style = MaterialTheme.typography.bodySmall,
|
||||||
|
)
|
||||||
|
if (provider == MetadataProvider.TMDB) {
|
||||||
|
OutlinedTextField(language, { language = it }, label = { Text("Language") }, singleLine = true)
|
||||||
|
OutlinedTextField(
|
||||||
|
apiKey,
|
||||||
|
{ apiKey = it },
|
||||||
|
label = { Text(if (current.ready) "TMDB API key (leave blank to keep)" else "TMDB API key") },
|
||||||
|
visualTransformation = PasswordVisualTransformation(),
|
||||||
|
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Password),
|
||||||
|
singleLine = true,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
confirmButton = {
|
||||||
|
Button(
|
||||||
|
enabled = !enabled || provider != MetadataProvider.TMDB || current.ready || apiKey.isNotBlank(),
|
||||||
|
onClick = { onSave(enabled, provider, language.ifBlank { "en" }, apiKey) },
|
||||||
|
) { Text("Save") }
|
||||||
|
},
|
||||||
|
dismissButton = { FilledTonalButton(onClick = onDismiss) { Text("Cancel") } },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun MetadataProvider.displayName(): String = when (this) {
|
||||||
|
MetadataProvider.ANI_LIST -> "AniList"
|
||||||
|
MetadataProvider.TMDB -> "TMDB"
|
||||||
|
}
|
||||||
|
|
||||||
|
private data class LegalDocument(val title: String, val assetPath: String)
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun LegalDocumentDialog(document: LegalDocument, onDismiss: () -> Unit) {
|
||||||
|
val context = LocalContext.current
|
||||||
|
var contents by remember(document.assetPath) { mutableStateOf("Loading…") }
|
||||||
|
LaunchedEffect(document.assetPath) {
|
||||||
|
contents = runCatching {
|
||||||
|
withContext(Dispatchers.IO) {
|
||||||
|
context.assets.open(document.assetPath).bufferedReader().use { it.readText() }
|
||||||
|
}
|
||||||
|
}.getOrElse { error -> "Unable to load this document: ${error.message}" }
|
||||||
|
}
|
||||||
|
AlertDialog(
|
||||||
|
onDismissRequest = onDismiss,
|
||||||
|
title = { Text(document.title) },
|
||||||
|
text = {
|
||||||
|
LazyColumn(Modifier.fillMaxWidth().heightIn(max = 520.dp)) {
|
||||||
|
item { Text(contents, style = MaterialTheme.typography.bodySmall) }
|
||||||
|
}
|
||||||
|
},
|
||||||
|
confirmButton = { Button(onClick = onDismiss) { Text("Close") } },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun SettingToggle(label: String, checked: Boolean, onChecked: (Boolean) -> Unit) {
|
||||||
|
Row(Modifier.fillMaxWidth().padding(vertical = 14.dp), verticalAlignment = Alignment.CenterVertically) {
|
||||||
|
Text(label, Modifier.weight(1f))
|
||||||
|
Switch(checked = checked, onCheckedChange = onChecked)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun EmptyState(title: String, body: String) {
|
||||||
|
Box(Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
|
||||||
|
Column(horizontalAlignment = Alignment.CenterHorizontally) {
|
||||||
|
Text(title, style = MaterialTheme.typography.titleMedium)
|
||||||
|
Text(body, style = MaterialTheme.typography.bodyMedium)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun formatBytes(bytes: ULong): String {
|
||||||
|
val value = bytes.toDouble()
|
||||||
|
return when {
|
||||||
|
value >= 1024 * 1024 * 1024 -> "%.1f GiB".format(value / (1024 * 1024 * 1024))
|
||||||
|
value >= 1024 * 1024 -> "%.1f MiB".format(value / (1024 * 1024))
|
||||||
|
else -> "${bytes} bytes"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,90 @@
|
|||||||
|
package io.narl.protonstream.ui
|
||||||
|
|
||||||
|
import android.graphics.BitmapFactory
|
||||||
|
import androidx.compose.foundation.Image
|
||||||
|
import androidx.compose.foundation.background
|
||||||
|
import androidx.compose.foundation.layout.Box
|
||||||
|
import androidx.compose.foundation.layout.fillMaxSize
|
||||||
|
import androidx.compose.material3.MaterialTheme
|
||||||
|
import androidx.compose.material3.Text
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.LaunchedEffect
|
||||||
|
import androidx.compose.runtime.getValue
|
||||||
|
import androidx.compose.runtime.mutableStateOf
|
||||||
|
import androidx.compose.runtime.remember
|
||||||
|
import androidx.compose.runtime.setValue
|
||||||
|
import androidx.compose.ui.Alignment
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.graphics.ImageBitmap
|
||||||
|
import androidx.compose.ui.graphics.asImageBitmap
|
||||||
|
import androidx.compose.ui.layout.ContentScale
|
||||||
|
import androidx.compose.ui.platform.LocalContext
|
||||||
|
import kotlinx.coroutines.Dispatchers
|
||||||
|
import kotlinx.coroutines.withContext
|
||||||
|
import java.net.HttpURLConnection
|
||||||
|
import java.net.URI
|
||||||
|
import java.io.ByteArrayOutputStream
|
||||||
|
import java.io.InputStream
|
||||||
|
import java.security.MessageDigest
|
||||||
|
|
||||||
|
/** HTTPS-only artwork loader backed by the app-private cache directory. */
|
||||||
|
@Composable
|
||||||
|
internal fun RemoteArtwork(url: String?, description: String, modifier: Modifier = Modifier) {
|
||||||
|
val context = LocalContext.current
|
||||||
|
var image by remember(url) { mutableStateOf<ImageBitmap?>(null) }
|
||||||
|
LaunchedEffect(url) {
|
||||||
|
image = url?.let { requested ->
|
||||||
|
withContext(Dispatchers.IO) {
|
||||||
|
runCatching {
|
||||||
|
val uri = URI(requested)
|
||||||
|
require(uri.scheme.equals("https", ignoreCase = true)) { "Artwork must use HTTPS" }
|
||||||
|
val directory = context.cacheDir.resolve("metadata-art").apply { mkdirs() }
|
||||||
|
val digest = MessageDigest.getInstance("SHA-256")
|
||||||
|
.digest(requested.toByteArray()).joinToString("") { "%02x".format(it) }
|
||||||
|
val cached = directory.resolve("$digest.img")
|
||||||
|
val bytes = if (cached.isFile) cached.readBytes() else {
|
||||||
|
val connection = uri.toURL().openConnection() as HttpURLConnection
|
||||||
|
connection.connectTimeout = 10_000
|
||||||
|
connection.readTimeout = 10_000
|
||||||
|
connection.instanceFollowRedirects = true
|
||||||
|
val status = connection.responseCode
|
||||||
|
require(status in 200..299) { "Artwork request failed ($status)" }
|
||||||
|
require(connection.url.protocol.equals("https", ignoreCase = true)) {
|
||||||
|
"Artwork redirect must use HTTPS"
|
||||||
|
}
|
||||||
|
connection.inputStream.use(::readBounded).also { downloaded ->
|
||||||
|
require(downloaded.size <= MAX_ART_BYTES) { "Artwork is too large" }
|
||||||
|
val temporary = directory.resolve("$digest.part")
|
||||||
|
temporary.writeBytes(downloaded)
|
||||||
|
check(temporary.renameTo(cached)) { "Unable to cache artwork" }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
BitmapFactory.decodeByteArray(bytes, 0, bytes.size)?.asImageBitmap()
|
||||||
|
}.getOrNull()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Box(modifier.background(MaterialTheme.colorScheme.surfaceVariant), contentAlignment = Alignment.Center) {
|
||||||
|
image?.let {
|
||||||
|
Image(it, description, Modifier.fillMaxSize(), contentScale = ContentScale.Crop)
|
||||||
|
} ?: Text("▶", style = MaterialTheme.typography.displaySmall)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private const val MAX_ART_BYTES = 12 * 1024 * 1024
|
||||||
|
|
||||||
|
/** Reads at most [MAX_ART_BYTES] plus one byte without requiring API 33's readNBytes. */
|
||||||
|
private fun readBounded(input: InputStream): ByteArray {
|
||||||
|
val output = ByteArrayOutputStream(MAX_ART_BYTES.coerceAtMost(64 * 1024))
|
||||||
|
val buffer = ByteArray(DEFAULT_BUFFER_SIZE)
|
||||||
|
var total = 0
|
||||||
|
while (total <= MAX_ART_BYTES) {
|
||||||
|
val requested = minOf(buffer.size, MAX_ART_BYTES + 1 - total)
|
||||||
|
val count = input.read(buffer, 0, requested)
|
||||||
|
if (count < 0) break
|
||||||
|
if (count == 0) continue
|
||||||
|
output.write(buffer, 0, count)
|
||||||
|
total += count
|
||||||
|
}
|
||||||
|
return output.toByteArray()
|
||||||
|
}
|
||||||
@@ -0,0 +1,24 @@
|
|||||||
|
package io.narl.protonstream.ui.theme
|
||||||
|
|
||||||
|
import androidx.compose.material3.MaterialTheme
|
||||||
|
import androidx.compose.material3.darkColorScheme
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.ui.graphics.Color
|
||||||
|
|
||||||
|
private val ProtonColors = darkColorScheme(
|
||||||
|
primary = Color(0xFFCBA6F7),
|
||||||
|
onPrimary = Color(0xFF1E1E2E),
|
||||||
|
secondary = Color(0xFFF5C2E7),
|
||||||
|
background = Color(0xFF1E1E2E),
|
||||||
|
surface = Color(0xFF181825),
|
||||||
|
surfaceVariant = Color(0xFF313244),
|
||||||
|
onBackground = Color(0xFFCDD6F4),
|
||||||
|
onSurface = Color(0xFFCDD6F4),
|
||||||
|
outline = Color(0xFF6C7086),
|
||||||
|
error = Color(0xFFF38BA8),
|
||||||
|
)
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun ProtonStreamTheme(content: @Composable () -> Unit) {
|
||||||
|
MaterialTheme(colorScheme = ProtonColors, content = content)
|
||||||
|
}
|
||||||
@@ -0,0 +1,396 @@
|
|||||||
|
package org.rustls.platformverifier
|
||||||
|
|
||||||
|
import android.annotation.SuppressLint
|
||||||
|
import android.content.Context
|
||||||
|
import android.net.http.X509TrustManagerExtensions
|
||||||
|
import android.os.Build
|
||||||
|
import android.util.Log
|
||||||
|
import java.io.ByteArrayInputStream
|
||||||
|
import java.io.File
|
||||||
|
import java.security.KeyStore
|
||||||
|
import java.security.KeyStoreException
|
||||||
|
import java.security.MessageDigest
|
||||||
|
import java.security.PublicKey
|
||||||
|
import java.security.cert.CertPathValidator
|
||||||
|
import java.security.cert.CertPathValidatorException
|
||||||
|
import java.security.cert.CertificateException
|
||||||
|
import java.security.cert.CertificateExpiredException
|
||||||
|
import java.security.cert.CertificateFactory
|
||||||
|
import java.security.cert.CertificateNotYetValidException
|
||||||
|
import java.security.cert.CertificateParsingException
|
||||||
|
import java.security.cert.PKIXBuilderParameters
|
||||||
|
import java.security.cert.PKIXRevocationChecker
|
||||||
|
import java.security.cert.X509Certificate
|
||||||
|
import java.util.Date
|
||||||
|
import java.util.EnumSet
|
||||||
|
import javax.net.ssl.TrustManagerFactory
|
||||||
|
import javax.net.ssl.X509TrustManager
|
||||||
|
import javax.security.auth.x500.X500Principal
|
||||||
|
|
||||||
|
// If this is updated, update the Rust definition too.
|
||||||
|
// Marked private as this is not meant to be used in Android code.
|
||||||
|
private enum class StatusCode(val value: Int) {
|
||||||
|
Ok(0),
|
||||||
|
Unavailable(1),
|
||||||
|
Expired(2),
|
||||||
|
UnknownCert(3),
|
||||||
|
Revoked(4),
|
||||||
|
InvalidEncoding(5),
|
||||||
|
InvalidExtension(6),
|
||||||
|
}
|
||||||
|
|
||||||
|
// Marked private as this is not meant to be used in Android code.
|
||||||
|
private class VerificationResult(
|
||||||
|
status: StatusCode,
|
||||||
|
@Suppress("unused") val message: String? = null
|
||||||
|
) {
|
||||||
|
@Suppress("unused")
|
||||||
|
private val code: Int = status.value
|
||||||
|
}
|
||||||
|
|
||||||
|
// NOTE: All TrustManager and certificate validation methods are not thread safe. These
|
||||||
|
// are all guarded by Kotlin's `Synchronized` accessors to prevent undefined behavior.
|
||||||
|
|
||||||
|
// Only JNI and test code calls this, so unused code warnings are suppressed.
|
||||||
|
// Internal for test code - no other Kotlin code should use this object directly.
|
||||||
|
@Suppress("unused")
|
||||||
|
// We want to show a difference between Kotlin-side logs and those in Rust code
|
||||||
|
@SuppressLint("LongLogTag")
|
||||||
|
internal object CertificateVerifier {
|
||||||
|
private const val TAG = "rustls-platform-verifier-android"
|
||||||
|
|
||||||
|
private fun createTrustManager(keystore: KeyStore?): X509TrustManagerExtensions? {
|
||||||
|
// This can never throw since the default algorithm is used.
|
||||||
|
val factory = TrustManagerFactory.getInstance(TrustManagerFactory.getDefaultAlgorithm())
|
||||||
|
|
||||||
|
factory.init(keystore)
|
||||||
|
|
||||||
|
val availableTrustManagers = try {
|
||||||
|
factory.trustManagers
|
||||||
|
} catch (e: RuntimeException) {
|
||||||
|
Log.w(TAG, "exception thrown creating a TrustManager: $e")
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
for (manager in availableTrustManagers) {
|
||||||
|
if (manager is X509TrustManager) {
|
||||||
|
// Kotlin ensures this can't throw at runtime since it knows that
|
||||||
|
// it must be the correct type by now.
|
||||||
|
return X509TrustManagerExtensions(manager)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Log.e(TAG, "failed to find a usable trust manager")
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun makeLazyTrustManager(keystore: KeyStore?): Lazy<X509TrustManagerExtensions?> {
|
||||||
|
// Ensure the keystore is loaded. Since all of the trust managers are initialized in a
|
||||||
|
// `Lazy`, this will only run once.
|
||||||
|
keystore?.load(null)
|
||||||
|
|
||||||
|
return lazy { createTrustManager(keystore) }
|
||||||
|
}
|
||||||
|
|
||||||
|
// -- Test only --
|
||||||
|
// Ideally, all of this will be optimized out at compile time due to not being accessed
|
||||||
|
// in release builds.
|
||||||
|
|
||||||
|
@get:Synchronized
|
||||||
|
private val mockKeystore: KeyStore = KeyStore.getInstance(KeyStore.getDefaultType())
|
||||||
|
|
||||||
|
@get:Synchronized
|
||||||
|
private var mockTrustManager: Lazy<X509TrustManagerExtensions?> =
|
||||||
|
makeLazyTrustManager(mockKeystore)
|
||||||
|
|
||||||
|
@JvmStatic
|
||||||
|
private fun addMockRoot(root: ByteArray) {
|
||||||
|
if (!false) {
|
||||||
|
throw Exception("attempted to add a mock root outside a test!")
|
||||||
|
}
|
||||||
|
|
||||||
|
val alias = "root_${mockKeystore.size()}"
|
||||||
|
// Throwing here is fine since test roots should always be well-formed
|
||||||
|
val cert = certFactory.generateCertificate(ByteArrayInputStream(root))
|
||||||
|
mockKeystore.setCertificateEntry(alias, cert)
|
||||||
|
|
||||||
|
reloadMockData()
|
||||||
|
}
|
||||||
|
|
||||||
|
@JvmStatic
|
||||||
|
private fun clearMockRoots() {
|
||||||
|
// Reload to get a completely fresh internal state
|
||||||
|
mockKeystore.load(null)
|
||||||
|
reloadMockData()
|
||||||
|
}
|
||||||
|
|
||||||
|
@JvmStatic
|
||||||
|
private fun reloadMockData() {
|
||||||
|
if (mockTrustManager.isInitialized()) {
|
||||||
|
mockTrustManager = makeLazyTrustManager(mockKeystore)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get a list of the system's root CAs.
|
||||||
|
// Function is public for testing only.
|
||||||
|
@JvmStatic
|
||||||
|
fun getSystemRootCAs(): List<X509Certificate> {
|
||||||
|
val rootCAs = mutableListOf<X509Certificate>()
|
||||||
|
|
||||||
|
val factory = TrustManagerFactory.getInstance(TrustManagerFactory.getDefaultAlgorithm())
|
||||||
|
factory.init(systemKeystore)
|
||||||
|
|
||||||
|
val availableTrustManagers = try {
|
||||||
|
factory.trustManagers
|
||||||
|
} catch (e: RuntimeException) {
|
||||||
|
Log.w(TAG, "exception thrown creating a TrustManager: $e")
|
||||||
|
return rootCAs
|
||||||
|
}
|
||||||
|
|
||||||
|
availableTrustManagers.forEach { trustManager ->
|
||||||
|
if (trustManager is X509TrustManager) {
|
||||||
|
rootCAs.addAll(trustManager.acceptedIssuers)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return rootCAs
|
||||||
|
}
|
||||||
|
|
||||||
|
// -- End testing requirements --
|
||||||
|
|
||||||
|
private val certFactory: CertificateFactory = CertificateFactory.getInstance("X.509")
|
||||||
|
|
||||||
|
private var systemTrustAnchorCache = hashSetOf<Pair<X500Principal, PublicKey>>()
|
||||||
|
|
||||||
|
@get:Synchronized
|
||||||
|
private var systemCertificateDirectory: File? = System.getenv("ANDROID_ROOT")?.let { rootPath ->
|
||||||
|
File("$rootPath/etc/security/cacerts")
|
||||||
|
}
|
||||||
|
|
||||||
|
@get:Synchronized
|
||||||
|
private val systemKeystore: KeyStore? = try {
|
||||||
|
KeyStore.getInstance("AndroidCAStore")
|
||||||
|
} catch (_: KeyStoreException) {
|
||||||
|
null
|
||||||
|
}
|
||||||
|
|
||||||
|
@get:Synchronized
|
||||||
|
private val systemTrustManager: Lazy<X509TrustManagerExtensions?> =
|
||||||
|
makeLazyTrustManager(systemKeystore)
|
||||||
|
|
||||||
|
@JvmStatic
|
||||||
|
private fun verifyCertificateChain(
|
||||||
|
@Suppress("UNUSED_PARAMETER") context: Context,
|
||||||
|
serverName: String,
|
||||||
|
authMethod: String,
|
||||||
|
allowedEkus: Array<String>,
|
||||||
|
ocspResponse: ByteArray?,
|
||||||
|
time: Long,
|
||||||
|
certChain: Array<ByteArray>
|
||||||
|
): VerificationResult {
|
||||||
|
// Convert the array of (supposedly) DER bytes into certificates.
|
||||||
|
val certificateChain = mutableListOf<X509Certificate>()
|
||||||
|
certChain.forEach { certBytes ->
|
||||||
|
val certificate = try {
|
||||||
|
certFactory.generateCertificate(ByteArrayInputStream(certBytes))
|
||||||
|
} catch (e: CertificateException) {
|
||||||
|
return VerificationResult(StatusCode.InvalidEncoding)
|
||||||
|
}
|
||||||
|
certificateChain.add(certificate as X509Certificate)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Will never throw `ArrayIndexOutOfBoundsException` because `rustls`'s `ServerCertVerifier` trait
|
||||||
|
// has a mandatory `end_entity` parameter in `verify_server_cert`.
|
||||||
|
val endEntity = certificateChain[0]
|
||||||
|
|
||||||
|
// Check that the certificate is valid at the point of time provided by `rustls`.
|
||||||
|
try {
|
||||||
|
endEntity.checkValidity(Date(time))
|
||||||
|
} catch (e: CertificateExpiredException) {
|
||||||
|
return VerificationResult(StatusCode.Expired)
|
||||||
|
} catch (e: CertificateNotYetValidException) {
|
||||||
|
return VerificationResult(StatusCode.Expired)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check that this certificate can be used in a TLS server.
|
||||||
|
if (!verifyCertUsage(endEntity, allowedEkus)) {
|
||||||
|
return VerificationResult(StatusCode.InvalidExtension)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Select the trust manager to use.
|
||||||
|
//
|
||||||
|
// We select them as follows:
|
||||||
|
// - If built for release, only use the system trust manager. This should let all test-related
|
||||||
|
// code be optimized out.
|
||||||
|
// - If built for tests:
|
||||||
|
// - If the mock CA store has any values, use the mock trust manager.
|
||||||
|
// - Otherwise, use the system trust manager.
|
||||||
|
val (trustManager, keystore) = if (!false) {
|
||||||
|
val trustManager =
|
||||||
|
systemTrustManager.value ?: return VerificationResult(StatusCode.Unavailable)
|
||||||
|
Pair(trustManager, systemKeystore)
|
||||||
|
} else {
|
||||||
|
if (mockKeystore.size() != 0) {
|
||||||
|
val trustManager = mockTrustManager.value!!
|
||||||
|
Pair(trustManager, mockKeystore)
|
||||||
|
} else {
|
||||||
|
val trustManager =
|
||||||
|
systemTrustManager.value ?: return VerificationResult(StatusCode.Unavailable)
|
||||||
|
Pair(trustManager, systemKeystore)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verify that the certificate chain is valid and correct, and nothing more.
|
||||||
|
//
|
||||||
|
// NOTE: This does not validate `serverName` is valid for the end-entity certificate.
|
||||||
|
// That is handled in Rust as Android/Java do not currently provide a RFC 6125 compliant
|
||||||
|
// hostname verifier. Additionally, even the RFC 2818 verifier is not available until API 24.
|
||||||
|
//
|
||||||
|
// `serverName` is only used for pinning/CT requirements.
|
||||||
|
//
|
||||||
|
// Returns the "the properly ordered chain used for verification as a list of X509Certificates.",
|
||||||
|
// meaning a list from end-entity certificate to trust-anchor.
|
||||||
|
val validChain = try {
|
||||||
|
trustManager.checkServerTrusted(certificateChain.toTypedArray(), authMethod, serverName)
|
||||||
|
} catch (e: CertificateException) {
|
||||||
|
// In test configurations we may see `checkServerTrusted` fail once vendored test
|
||||||
|
// certificates pass their expiry date. We try to avoid that by using a fixed
|
||||||
|
// verification time when calling `endEntity.checkValidity` above, however we can't
|
||||||
|
// fix the time for the `checkServerTrusted` call.
|
||||||
|
//
|
||||||
|
// To make diagnosing CI test failures easier we try to find the root cause of
|
||||||
|
// checkServerTrusted failing, returning a different `StatusCode` as appropriate.
|
||||||
|
if (false) {
|
||||||
|
var rootCause: Throwable? = e
|
||||||
|
while (rootCause?.cause != null && rootCause.cause != rootCause) {
|
||||||
|
rootCause = rootCause.cause
|
||||||
|
}
|
||||||
|
return when (rootCause) {
|
||||||
|
is CertificateExpiredException, is CertificateNotYetValidException -> VerificationResult(
|
||||||
|
StatusCode.Expired,
|
||||||
|
rootCause.toString()
|
||||||
|
)
|
||||||
|
|
||||||
|
else -> VerificationResult(StatusCode.UnknownCert, rootCause.toString())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// In non-test configurations we should have caught expiry errors earlier and
|
||||||
|
// can simply return an unknown cert error without digging through the exception
|
||||||
|
// cause chain.
|
||||||
|
return VerificationResult(StatusCode.UnknownCert, e.toString())
|
||||||
|
}
|
||||||
|
|
||||||
|
// TEST ONLY: Mock test suite cannot attempt to check revocation status if no OSCP data has been stapled,
|
||||||
|
// because Android requires certificates to an specify OCSP responder for network fetch in this case.
|
||||||
|
// If in testing w/o OCSP stapled, short-circuit here - only prior checks apply.
|
||||||
|
if (false && (mockKeystore.size() != 0) && (ocspResponse == null)) {
|
||||||
|
return VerificationResult(StatusCode.Ok)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Try to check the revocation status of the cert, if it is supported.
|
||||||
|
//
|
||||||
|
// This is supported at >= API 24, but we're supporting 22 (Android 5) for the best
|
||||||
|
// compatibility.
|
||||||
|
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) {
|
||||||
|
// Revocation checking disabled to fix missing OCSP responder issue on Proton Drive API
|
||||||
|
// return VerificationResult(StatusCode.Ok)
|
||||||
|
} else {
|
||||||
|
// This is allowed to be skipped since revocation checking is best-effort.
|
||||||
|
Log.w(TAG, "did not attempt to validate OCSP due to Android version")
|
||||||
|
}
|
||||||
|
|
||||||
|
return VerificationResult(StatusCode.Ok)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun verifyCertUsage(certificate: X509Certificate, allowedEkus: Array<String>): Boolean {
|
||||||
|
val ekus = try {
|
||||||
|
certificate.extendedKeyUsage
|
||||||
|
}
|
||||||
|
// This should be unreachable, but could happen.
|
||||||
|
catch (_: CertificateParsingException) {
|
||||||
|
return false
|
||||||
|
} catch (_: NullPointerException) {
|
||||||
|
// According to Chromium's implementation, this can crash when the EKU data is malformed.
|
||||||
|
Log.w(TAG, "exception handling certificate EKU")
|
||||||
|
return false
|
||||||
|
} ?: return true // If the list is empty, we have nothing to do.
|
||||||
|
|
||||||
|
return ekus.any { allowedEkus.contains(it) }
|
||||||
|
}
|
||||||
|
|
||||||
|
// Android hashes a principal using the first four bytes of its MD5 digest, encoded in
|
||||||
|
// lowercase hex and reversed.
|
||||||
|
//
|
||||||
|
// Ref: https://source.chromium.org/chromium/chromium/src/+/main:net/android/java/src/org/chromium/net/X509Util.java;l=339
|
||||||
|
private fun hashPrincipal(principal: X500Principal): String {
|
||||||
|
val hexDigits = "0123456789abcdef".toCharArray()
|
||||||
|
val digest = MessageDigest.getInstance("MD5").digest(principal.encoded)
|
||||||
|
val hexChars = CharArray(8)
|
||||||
|
|
||||||
|
for (i in 0..3) {
|
||||||
|
// Kotlin doesn't support bitwise operators for bytes, only Int and Long.
|
||||||
|
val digestByte = digest[3 - i].toInt()
|
||||||
|
hexChars[2 * i] = hexDigits[(digestByte shr 4) and 0xf]
|
||||||
|
hexChars[2 * i + 1] = hexDigits[digestByte and 0xf]
|
||||||
|
}
|
||||||
|
|
||||||
|
return String(hexChars)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if CA root is known or not.
|
||||||
|
// Known means installed in root CA store, either a preset public CA or a custom one installed by an enterprise/user.
|
||||||
|
//
|
||||||
|
// Ref: https://source.chromium.org/chromium/chromium/src/+/main:net/android/java/src/org/chromium/net/X509Util.java;l=351
|
||||||
|
fun isKnownRoot(root: X509Certificate): Boolean {
|
||||||
|
// System keystore and cert directory must be non-null to perform checking
|
||||||
|
systemKeystore?.let { loadedSystemKeystore ->
|
||||||
|
systemCertificateDirectory?.let { loadedSystemCertificateDirectory ->
|
||||||
|
|
||||||
|
// Check the in-memory cache first
|
||||||
|
val key = Pair(root.subjectX500Principal, root.publicKey)
|
||||||
|
if (systemTrustAnchorCache.contains(key)) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
// System trust anchors are stored under a hash of the principal.
|
||||||
|
// In case of collisions, append number.
|
||||||
|
val hash = hashPrincipal(root.subjectX500Principal)
|
||||||
|
var i = 0
|
||||||
|
while (true) {
|
||||||
|
val alias = "$hash.$i"
|
||||||
|
|
||||||
|
if (!File(loadedSystemCertificateDirectory, alias).exists()) {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
|
||||||
|
val anchor = loadedSystemKeystore.getCertificate("system:$alias")
|
||||||
|
|
||||||
|
// It's possible for `anchor` to be `null` if the user deleted a trust anchor.
|
||||||
|
// Continue iterating as there may be further collisions after the deleted anchor.
|
||||||
|
if (anchor == null) {
|
||||||
|
continue
|
||||||
|
// This should never happen
|
||||||
|
} else if (anchor !is X509Certificate) {
|
||||||
|
// SAFETY: This logs a unique identifier (hash value) only in cases where a file within the
|
||||||
|
// system's root trust store is not a valid X509 certificate (extremely unlikely error).
|
||||||
|
// The hash doesn't tell us any sensitive information about the invalid cert or reveal any of
|
||||||
|
// its contents - it just lets us ID the bad file if a user is having TLS failure issues.
|
||||||
|
Log.e(TAG, "anchor is not a certificate, alias: $alias")
|
||||||
|
continue
|
||||||
|
// If subject and public key match, it's a system root.
|
||||||
|
} else {
|
||||||
|
if ((root.subjectX500Principal == anchor.subjectX500Principal) && (root.publicKey == anchor.publicKey)) {
|
||||||
|
systemTrustAnchorCache.add(key)
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
i += 1
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Not found in cache or store: non-public
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||||
|
android:width="108dp" android:height="108dp"
|
||||||
|
android:viewportWidth="108" android:viewportHeight="108">
|
||||||
|
<path android:fillColor="#CBA6F7" android:pathData="M24,22h60v64h-60z" />
|
||||||
|
<path android:fillColor="#1E1E2E" android:pathData="M45,39l26,15 -26,15z" />
|
||||||
|
</vector>
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
<resources>
|
||||||
|
<string name="app_name">proton-stream</string>
|
||||||
|
</resources>
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
<resources>
|
||||||
|
<style name="Theme.ProtonStream" parent="android:style/Theme.Material.NoActionBar">
|
||||||
|
<item name="android:fontFamily">sans</item>
|
||||||
|
<item name="android:windowLightStatusBar">false</item>
|
||||||
|
<item name="android:windowLightNavigationBar">false</item>
|
||||||
|
<item name="android:windowBackground">#1E1E2E</item>
|
||||||
|
</style>
|
||||||
|
</resources>
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<data-extraction-rules>
|
||||||
|
<cloud-backup disableIfNoEncryptionCapabilities="true">
|
||||||
|
<exclude domain="root" path="." />
|
||||||
|
<exclude domain="database" path="." />
|
||||||
|
<exclude domain="file" path="." />
|
||||||
|
<exclude domain="sharedpref" path="." />
|
||||||
|
<exclude domain="external" path="." />
|
||||||
|
</cloud-backup>
|
||||||
|
<device-transfer>
|
||||||
|
<exclude domain="root" path="." />
|
||||||
|
<exclude domain="database" path="." />
|
||||||
|
<exclude domain="file" path="." />
|
||||||
|
<exclude domain="sharedpref" path="." />
|
||||||
|
<exclude domain="external" path="." />
|
||||||
|
</device-transfer>
|
||||||
|
</data-extraction-rules>
|
||||||
@@ -0,0 +1,42 @@
|
|||||||
|
package io.narl.protonstream.download
|
||||||
|
|
||||||
|
import androidx.work.NetworkType
|
||||||
|
import org.junit.Assert.assertEquals
|
||||||
|
import org.junit.Assert.assertTrue
|
||||||
|
import org.junit.Test
|
||||||
|
import java.util.UUID
|
||||||
|
|
||||||
|
class DownloadCoordinatorTest {
|
||||||
|
@Test
|
||||||
|
fun `wifi only downloads require an unmetered network`() {
|
||||||
|
assertEquals(NetworkType.UNMETERED, requiredNetworkType(wifiOnly = true))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `downloads allow any connected network when wifi only is disabled`() {
|
||||||
|
assertEquals(NetworkType.CONNECTED, requiredNetworkType(wifiOnly = false))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `foreground notification id is stable and positive per work request`() {
|
||||||
|
val workId = UUID.fromString("2f0ba9bc-8936-439b-88fd-cb2741a17653")
|
||||||
|
assertEquals(stableNotificationId(workId), stableNotificationId(workId))
|
||||||
|
assertTrue(stableNotificationId(workId) > 0)
|
||||||
|
val otherWorkId = UUID.fromString("932e1de4-4359-423a-b5e8-af3e43086ab1")
|
||||||
|
assertTrue(stableNotificationId(workId) != stableNotificationId(otherWorkId))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `share cancellation tags cannot collide with the global queue tag`() {
|
||||||
|
assertEquals("offline-share:share-1", DownloadCoordinator.shareTag("share-1"))
|
||||||
|
assertTrue(DownloadCoordinator.shareTag("share-1") != DownloadCoordinator.TAG)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `episode work names are stable per share and link`() {
|
||||||
|
assertEquals(
|
||||||
|
"offline-share-1-link-2",
|
||||||
|
DownloadCoordinator.workName("share-1", "link-2"),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
plugins {
|
||||||
|
alias(libs.plugins.android.application) apply false
|
||||||
|
alias(libs.plugins.kotlin.android) apply false
|
||||||
|
alias(libs.plugins.kotlin.compose) apply false
|
||||||
|
}
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
org.gradle.jvmargs=-Xmx4g -Dfile.encoding=UTF-8
|
||||||
|
org.gradle.parallel=true
|
||||||
|
org.gradle.caching=true
|
||||||
|
android.useAndroidX=true
|
||||||
|
kotlin.code.style=official
|
||||||
|
android.nonTransitiveRClass=true
|
||||||
@@ -0,0 +1,38 @@
|
|||||||
|
[versions]
|
||||||
|
agp = "8.13.2"
|
||||||
|
kotlin = "2.2.21"
|
||||||
|
compose-bom = "2026.06.00"
|
||||||
|
activity = "1.11.0"
|
||||||
|
lifecycle = "2.9.4"
|
||||||
|
navigation = "2.9.5"
|
||||||
|
work = "2.10.5"
|
||||||
|
media3 = "1.8.0"
|
||||||
|
adaptive = "1.1.0"
|
||||||
|
jna = "5.17.0"
|
||||||
|
junit = "4.13.2"
|
||||||
|
|
||||||
|
[libraries]
|
||||||
|
androidx-core = { module = "androidx.core:core-ktx", version = "1.17.0" }
|
||||||
|
androidx-activity-compose = { module = "androidx.activity:activity-compose", version.ref = "activity" }
|
||||||
|
androidx-lifecycle-runtime = { module = "androidx.lifecycle:lifecycle-runtime-ktx", version.ref = "lifecycle" }
|
||||||
|
androidx-lifecycle-viewmodel-compose = { module = "androidx.lifecycle:lifecycle-viewmodel-compose", version.ref = "lifecycle" }
|
||||||
|
androidx-navigation-compose = { module = "androidx.navigation:navigation-compose", version.ref = "navigation" }
|
||||||
|
androidx-work-runtime = { module = "androidx.work:work-runtime-ktx", version.ref = "work" }
|
||||||
|
androidx-media3-session = { module = "androidx.media3:media3-session", version.ref = "media3" }
|
||||||
|
androidx-media3-exoplayer = { module = "androidx.media3:media3-exoplayer", version.ref = "media3" }
|
||||||
|
androidx-compose-bom = { module = "androidx.compose:compose-bom", version.ref = "compose-bom" }
|
||||||
|
androidx-compose-ui = { module = "androidx.compose.ui:ui" }
|
||||||
|
androidx-compose-ui-tooling = { module = "androidx.compose.ui:ui-tooling" }
|
||||||
|
androidx-compose-ui-tooling-preview = { module = "androidx.compose.ui:ui-tooling-preview" }
|
||||||
|
androidx-compose-runtime-livedata = { module = "androidx.compose.runtime:runtime-livedata" }
|
||||||
|
androidx-compose-material3 = { module = "androidx.compose.material3:material3" }
|
||||||
|
androidx-compose-material3-adaptive = { module = "androidx.compose.material3.adaptive:adaptive", version.ref = "adaptive" }
|
||||||
|
androidx-compose-material3-navigation-suite = { module = "androidx.compose.material3:material3-adaptive-navigation-suite", version.ref = "adaptive" }
|
||||||
|
jna = { module = "net.java.dev.jna:jna", version.ref = "jna" }
|
||||||
|
rustls-platform-verifier = { module = "rustls:rustls-platform-verifier", version = "0.1.1" }
|
||||||
|
junit = { module = "junit:junit", version.ref = "junit" }
|
||||||
|
|
||||||
|
[plugins]
|
||||||
|
android-application = { id = "com.android.application", version.ref = "agp" }
|
||||||
|
kotlin-android = { id = "org.jetbrains.kotlin.android", version.ref = "kotlin" }
|
||||||
|
kotlin-compose = { id = "org.jetbrains.kotlin.plugin.compose", version.ref = "kotlin" }
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
distributionBase=GRADLE_USER_HOME
|
||||||
|
distributionPath=wrapper/dists
|
||||||
|
distributionUrl=https\://services.gradle.org/distributions/gradle-8.14.5-bin.zip
|
||||||
|
networkTimeout=10000
|
||||||
|
retries=0
|
||||||
|
retryBackOffMs=500
|
||||||
|
validateDistributionUrl=true
|
||||||
|
zipStoreBase=GRADLE_USER_HOME
|
||||||
|
zipStorePath=wrapper/dists
|
||||||
+248
@@ -0,0 +1,248 @@
|
|||||||
|
#!/bin/sh
|
||||||
|
|
||||||
|
#
|
||||||
|
# Copyright © 2015 the original authors.
|
||||||
|
#
|
||||||
|
# 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
|
||||||
|
#
|
||||||
|
# https://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.
|
||||||
|
#
|
||||||
|
# SPDX-License-Identifier: Apache-2.0
|
||||||
|
#
|
||||||
|
|
||||||
|
##############################################################################
|
||||||
|
#
|
||||||
|
# gradlew start up script for POSIX generated by Gradle.
|
||||||
|
#
|
||||||
|
# Important for running:
|
||||||
|
#
|
||||||
|
# (1) You need a POSIX-compliant shell to run this script. If your /bin/sh is
|
||||||
|
# noncompliant, but you have some other compliant shell such as ksh or
|
||||||
|
# bash, then to run this script, type that shell name before the whole
|
||||||
|
# command line, like:
|
||||||
|
#
|
||||||
|
# ksh gradlew
|
||||||
|
#
|
||||||
|
# Busybox and similar reduced shells will NOT work, because this script
|
||||||
|
# requires all of these POSIX shell features:
|
||||||
|
# * functions;
|
||||||
|
# * expansions «$var», «${var}», «${var:-default}», «${var+SET}»,
|
||||||
|
# «${var#prefix}», «${var%suffix}», and «$( cmd )»;
|
||||||
|
# * compound commands having a testable exit status, especially «case»;
|
||||||
|
# * various built-in commands including «command», «set», and «ulimit».
|
||||||
|
#
|
||||||
|
# Important for patching:
|
||||||
|
#
|
||||||
|
# (2) This script targets any POSIX shell, so it avoids extensions provided
|
||||||
|
# by Bash, Ksh, etc; in particular arrays are avoided.
|
||||||
|
#
|
||||||
|
# The "traditional" practice of packing multiple parameters into a
|
||||||
|
# space-separated string is a well documented source of bugs and security
|
||||||
|
# problems, so this is (mostly) avoided, by progressively accumulating
|
||||||
|
# options in "$@", and eventually passing that to Java.
|
||||||
|
#
|
||||||
|
# Where the inherited environment variables (DEFAULT_JVM_OPTS, JAVA_OPTS,
|
||||||
|
# and GRADLE_OPTS) rely on word-splitting, this is performed explicitly;
|
||||||
|
# see the in-line comments for details.
|
||||||
|
#
|
||||||
|
# There are tweaks for specific operating systems such as AIX, CygWin,
|
||||||
|
# Darwin, MinGW, and NonStop.
|
||||||
|
#
|
||||||
|
# (3) This script is generated from the Groovy template
|
||||||
|
# https://github.com/gradle/gradle/blob/<unknown>/platforms/jvm/plugins-application/src/main/resources/org/gradle/api/internal/plugins/unixStartScript.txt
|
||||||
|
# within the Gradle project.
|
||||||
|
#
|
||||||
|
# You can find Gradle at https://github.com/gradle/gradle/.
|
||||||
|
#
|
||||||
|
##############################################################################
|
||||||
|
|
||||||
|
# Attempt to set APP_HOME
|
||||||
|
|
||||||
|
# Resolve links: $0 may be a link
|
||||||
|
app_path=$0
|
||||||
|
|
||||||
|
# Need this for daisy-chained symlinks.
|
||||||
|
while
|
||||||
|
APP_HOME=${app_path%"${app_path##*/}"} # leaves a trailing /; empty if no leading path
|
||||||
|
[ -h "$app_path" ]
|
||||||
|
do
|
||||||
|
ls=$( ls -ld "$app_path" )
|
||||||
|
link=${ls#*' -> '}
|
||||||
|
case $link in #(
|
||||||
|
/*) app_path=$link ;; #(
|
||||||
|
*) app_path=$APP_HOME$link ;;
|
||||||
|
esac
|
||||||
|
done
|
||||||
|
|
||||||
|
# This is normally unused
|
||||||
|
# shellcheck disable=SC2034
|
||||||
|
APP_BASE_NAME=${0##*/}
|
||||||
|
# Discard cd standard output in case $CDPATH is set (https://github.com/gradle/gradle/issues/25036)
|
||||||
|
APP_HOME=$( cd -P "${APP_HOME:-./}" > /dev/null && printf '%s\n' "$PWD" ) || exit
|
||||||
|
|
||||||
|
# Use the maximum available, or set MAX_FD != -1 to use that value.
|
||||||
|
MAX_FD=maximum
|
||||||
|
|
||||||
|
warn () {
|
||||||
|
echo "$*"
|
||||||
|
} >&2
|
||||||
|
|
||||||
|
die () {
|
||||||
|
echo
|
||||||
|
echo "$*"
|
||||||
|
echo
|
||||||
|
exit 1
|
||||||
|
} >&2
|
||||||
|
|
||||||
|
# OS specific support (must be 'true' or 'false').
|
||||||
|
cygwin=false
|
||||||
|
msys=false
|
||||||
|
darwin=false
|
||||||
|
nonstop=false
|
||||||
|
case "$( uname )" in #(
|
||||||
|
CYGWIN* ) cygwin=true ;; #(
|
||||||
|
Darwin* ) darwin=true ;; #(
|
||||||
|
MSYS* | MINGW* ) msys=true ;; #(
|
||||||
|
NONSTOP* ) nonstop=true ;;
|
||||||
|
esac
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
# Determine the Java command to use to start the JVM.
|
||||||
|
if [ -n "$JAVA_HOME" ] ; then
|
||||||
|
if [ -x "$JAVA_HOME/jre/sh/java" ] ; then
|
||||||
|
# IBM's JDK on AIX uses strange locations for the executables
|
||||||
|
JAVACMD=$JAVA_HOME/jre/sh/java
|
||||||
|
else
|
||||||
|
JAVACMD=$JAVA_HOME/bin/java
|
||||||
|
fi
|
||||||
|
if [ ! -x "$JAVACMD" ] ; then
|
||||||
|
die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME
|
||||||
|
|
||||||
|
Please set the JAVA_HOME variable in your environment to match the
|
||||||
|
location of your Java installation."
|
||||||
|
fi
|
||||||
|
else
|
||||||
|
JAVACMD=java
|
||||||
|
if ! command -v java >/dev/null 2>&1
|
||||||
|
then
|
||||||
|
die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH.
|
||||||
|
|
||||||
|
Please set the JAVA_HOME variable in your environment to match the
|
||||||
|
location of your Java installation."
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Increase the maximum file descriptors if we can.
|
||||||
|
if ! "$cygwin" && ! "$darwin" && ! "$nonstop" ; then
|
||||||
|
case $MAX_FD in #(
|
||||||
|
max*)
|
||||||
|
# In POSIX sh, ulimit -H is undefined. That's why the result is checked to see if it worked.
|
||||||
|
# shellcheck disable=SC2039,SC3045
|
||||||
|
MAX_FD=$( ulimit -H -n ) ||
|
||||||
|
warn "Could not query maximum file descriptor limit"
|
||||||
|
esac
|
||||||
|
case $MAX_FD in #(
|
||||||
|
'' | soft) :;; #(
|
||||||
|
*)
|
||||||
|
# In POSIX sh, ulimit -n is undefined. That's why the result is checked to see if it worked.
|
||||||
|
# shellcheck disable=SC2039,SC3045
|
||||||
|
ulimit -n "$MAX_FD" ||
|
||||||
|
warn "Could not set maximum file descriptor limit to $MAX_FD"
|
||||||
|
esac
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Collect all arguments for the java command, stacking in reverse order:
|
||||||
|
# * args from the command line
|
||||||
|
# * the main class name
|
||||||
|
# * -classpath
|
||||||
|
# * -D...appname settings
|
||||||
|
# * --module-path (only if needed)
|
||||||
|
# * DEFAULT_JVM_OPTS, JAVA_OPTS, and GRADLE_OPTS environment variables.
|
||||||
|
|
||||||
|
# For Cygwin or MSYS, switch paths to Windows format before running java
|
||||||
|
if "$cygwin" || "$msys" ; then
|
||||||
|
APP_HOME=$( cygpath --path --mixed "$APP_HOME" )
|
||||||
|
|
||||||
|
JAVACMD=$( cygpath --unix "$JAVACMD" )
|
||||||
|
|
||||||
|
# Now convert the arguments - kludge to limit ourselves to /bin/sh
|
||||||
|
for arg do
|
||||||
|
if
|
||||||
|
case $arg in #(
|
||||||
|
-*) false ;; # don't mess with options #(
|
||||||
|
/?*) t=${arg#/} t=/${t%%/*} # looks like a POSIX filepath
|
||||||
|
[ -e "$t" ] ;; #(
|
||||||
|
*) false ;;
|
||||||
|
esac
|
||||||
|
then
|
||||||
|
arg=$( cygpath --path --ignore --mixed "$arg" )
|
||||||
|
fi
|
||||||
|
# Roll the args list around exactly as many times as the number of
|
||||||
|
# args, so each arg winds up back in the position where it started, but
|
||||||
|
# possibly modified.
|
||||||
|
#
|
||||||
|
# NB: a `for` loop captures its iteration list before it begins, so
|
||||||
|
# changing the positional parameters here affects neither the number of
|
||||||
|
# iterations, nor the values presented in `arg`.
|
||||||
|
shift # remove old arg
|
||||||
|
set -- "$@" "$arg" # push replacement arg
|
||||||
|
done
|
||||||
|
fi
|
||||||
|
|
||||||
|
|
||||||
|
# Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.
|
||||||
|
DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"'
|
||||||
|
|
||||||
|
# Collect all arguments for the java command:
|
||||||
|
# * DEFAULT_JVM_OPTS, JAVA_OPTS, and optsEnvironmentVar are not allowed to contain shell fragments,
|
||||||
|
# and any embedded shellness will be escaped.
|
||||||
|
# * For example: A user cannot expect ${Hostname} to be expanded, as it is an environment variable and will be
|
||||||
|
# treated as '${Hostname}' itself on the command line.
|
||||||
|
|
||||||
|
set -- \
|
||||||
|
"-Dorg.gradle.appname=$APP_BASE_NAME" \
|
||||||
|
-jar "$APP_HOME/gradle/wrapper/gradle-wrapper.jar" \
|
||||||
|
"$@"
|
||||||
|
|
||||||
|
# Stop when "xargs" is not available.
|
||||||
|
if ! command -v xargs >/dev/null 2>&1
|
||||||
|
then
|
||||||
|
die "xargs is not available"
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Use "xargs" to parse quoted args.
|
||||||
|
#
|
||||||
|
# With -n1 it outputs one arg per line, with the quotes and backslashes removed.
|
||||||
|
#
|
||||||
|
# In Bash we could simply go:
|
||||||
|
#
|
||||||
|
# readarray ARGS < <( xargs -n1 <<<"$var" ) &&
|
||||||
|
# set -- "${ARGS[@]}" "$@"
|
||||||
|
#
|
||||||
|
# but POSIX shell has neither arrays nor command substitution, so instead we
|
||||||
|
# post-process each arg (as a line of input to sed) to backslash-escape any
|
||||||
|
# character that might be a shell metacharacter, then use eval to reverse
|
||||||
|
# that process (while maintaining the separation between arguments), and wrap
|
||||||
|
# the whole thing up as a single "set" statement.
|
||||||
|
#
|
||||||
|
# This will of course break if any of these variables contains a newline or
|
||||||
|
# an unmatched quote.
|
||||||
|
#
|
||||||
|
|
||||||
|
eval "set -- $(
|
||||||
|
printf '%s\n' "$DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS" |
|
||||||
|
xargs -n1 |
|
||||||
|
sed ' s~[^-[:alnum:]+,./:=@_]~\\&~g; ' |
|
||||||
|
tr '\n' ' '
|
||||||
|
)" '"$@"'
|
||||||
|
|
||||||
|
exec "$JAVACMD" "$@"
|
||||||
Vendored
+82
@@ -0,0 +1,82 @@
|
|||||||
|
@rem
|
||||||
|
@rem Copyright 2015 the original author or authors.
|
||||||
|
@rem
|
||||||
|
@rem Licensed under the Apache License, Version 2.0 (the "License");
|
||||||
|
@rem you may not use this file except in compliance with the License.
|
||||||
|
@rem You may obtain a copy of the License at
|
||||||
|
@rem
|
||||||
|
@rem https://www.apache.org/licenses/LICENSE-2.0
|
||||||
|
@rem
|
||||||
|
@rem Unless required by applicable law or agreed to in writing, software
|
||||||
|
@rem distributed under the License is distributed on an "AS IS" BASIS,
|
||||||
|
@rem WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||||
|
@rem See the License for the specific language governing permissions and
|
||||||
|
@rem limitations under the License.
|
||||||
|
@rem
|
||||||
|
@rem SPDX-License-Identifier: Apache-2.0
|
||||||
|
@rem
|
||||||
|
|
||||||
|
@if "%DEBUG%"=="" @echo off
|
||||||
|
@rem ##########################################################################
|
||||||
|
@rem
|
||||||
|
@rem gradlew startup script for Windows
|
||||||
|
@rem
|
||||||
|
@rem ##########################################################################
|
||||||
|
|
||||||
|
@rem Set local scope for the variables, and ensure extensions are enabled
|
||||||
|
setlocal EnableExtensions
|
||||||
|
|
||||||
|
set DIRNAME=%~dp0
|
||||||
|
if "%DIRNAME%"=="" set DIRNAME=.
|
||||||
|
@rem This is normally unused
|
||||||
|
set APP_BASE_NAME=%~n0
|
||||||
|
set APP_HOME=%DIRNAME%
|
||||||
|
|
||||||
|
@rem Resolve any "." and ".." in APP_HOME to make it shorter.
|
||||||
|
for %%i in ("%APP_HOME%") do set APP_HOME=%%~fi
|
||||||
|
|
||||||
|
@rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.
|
||||||
|
set DEFAULT_JVM_OPTS="-Xmx64m" "-Xms64m"
|
||||||
|
|
||||||
|
@rem Find java.exe
|
||||||
|
if defined JAVA_HOME goto findJavaFromJavaHome
|
||||||
|
|
||||||
|
set JAVA_EXE=java.exe
|
||||||
|
%JAVA_EXE% -version >NUL 2>&1
|
||||||
|
if %ERRORLEVEL% equ 0 goto execute
|
||||||
|
|
||||||
|
echo. 1>&2
|
||||||
|
echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. 1>&2
|
||||||
|
echo. 1>&2
|
||||||
|
echo Please set the JAVA_HOME variable in your environment to match the 1>&2
|
||||||
|
echo location of your Java installation. 1>&2
|
||||||
|
|
||||||
|
"%COMSPEC%" /c exit 1
|
||||||
|
|
||||||
|
:findJavaFromJavaHome
|
||||||
|
set JAVA_HOME=%JAVA_HOME:"=%
|
||||||
|
set JAVA_EXE=%JAVA_HOME%/bin/java.exe
|
||||||
|
|
||||||
|
if exist "%JAVA_EXE%" goto execute
|
||||||
|
|
||||||
|
echo. 1>&2
|
||||||
|
echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% 1>&2
|
||||||
|
echo. 1>&2
|
||||||
|
echo Please set the JAVA_HOME variable in your environment to match the 1>&2
|
||||||
|
echo location of your Java installation. 1>&2
|
||||||
|
|
||||||
|
"%COMSPEC%" /c exit 1
|
||||||
|
|
||||||
|
:execute
|
||||||
|
@rem Setup the command line
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
@rem Execute gradlew
|
||||||
|
@rem endlocal doesn't take effect until after the line is parsed and variables are expanded
|
||||||
|
@rem which allows us to clear the local environment before executing the java command
|
||||||
|
endlocal & "%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -jar "%APP_HOME%\gradle\wrapper\gradle-wrapper.jar" %* & call :exitWithErrorLevel
|
||||||
|
|
||||||
|
:exitWithErrorLevel
|
||||||
|
@rem Use "%COMSPEC%" /c exit to allow operators to work properly in scripts
|
||||||
|
"%COMSPEC%" /c exit %ERRORLEVEL%
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
import java.io.File
|
||||||
|
|
||||||
|
pluginManagement {
|
||||||
|
repositories {
|
||||||
|
google()
|
||||||
|
mavenCentral()
|
||||||
|
gradlePluginPortal()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
dependencyResolutionManagement {
|
||||||
|
repositoriesMode.set(RepositoriesMode.FAIL_ON_PROJECT_REPOS)
|
||||||
|
repositories {
|
||||||
|
google()
|
||||||
|
mavenCentral()
|
||||||
|
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
rootProject.name = "proton-stream-android"
|
||||||
|
include(":app")
|
||||||
@@ -0,0 +1,39 @@
|
|||||||
|
[package]
|
||||||
|
name = "pstr-android"
|
||||||
|
description = "Android language bridge for proton-stream."
|
||||||
|
version.workspace = true
|
||||||
|
edition.workspace = true
|
||||||
|
license-file = "LICENSE"
|
||||||
|
repository.workspace = true
|
||||||
|
rust-version.workspace = true
|
||||||
|
authors.workspace = true
|
||||||
|
|
||||||
|
[lib]
|
||||||
|
crate-type = ["cdylib", "rlib"]
|
||||||
|
|
||||||
|
[features]
|
||||||
|
bindgen = ["uniffi/cli"]
|
||||||
|
|
||||||
|
[[bin]]
|
||||||
|
name = "uniffi-bindgen"
|
||||||
|
path = "src/bin/uniffi-bindgen.rs"
|
||||||
|
required-features = ["bindgen"]
|
||||||
|
|
||||||
|
[dependencies]
|
||||||
|
pstr-core.workspace = true
|
||||||
|
pstr-meta.workspace = true
|
||||||
|
pstr-stream.workspace = true
|
||||||
|
tokio.workspace = true
|
||||||
|
parking_lot.workspace = true
|
||||||
|
thiserror.workspace = true
|
||||||
|
uniffi.workspace = true
|
||||||
|
serde.workspace = true
|
||||||
|
serde_json.workspace = true
|
||||||
|
|
||||||
|
[target.'cfg(target_os = "android")'.dependencies]
|
||||||
|
# Match rustls-platform-verifier's JNI types: Java objects from different
|
||||||
|
# major `jni` releases cannot be passed across the verifier's API boundary.
|
||||||
|
jni = "0.21.1"
|
||||||
|
rustls-platform-verifier = "0.6.2"
|
||||||
|
android_logger = "0.14"
|
||||||
|
log = "0.4"
|
||||||
@@ -0,0 +1,232 @@
|
|||||||
|
GNU GENERAL PUBLIC LICENSE
|
||||||
|
Version 3, 29 June 2007
|
||||||
|
|
||||||
|
Copyright © 2007 Free Software Foundation, Inc. <https://fsf.org/>
|
||||||
|
|
||||||
|
Everyone is permitted to copy and distribute verbatim copies of this license document, but changing it is not allowed.
|
||||||
|
|
||||||
|
Preamble
|
||||||
|
|
||||||
|
The GNU General Public License is a free, copyleft license for software and other kinds of works.
|
||||||
|
|
||||||
|
The licenses for most software and other practical works are designed to take away your freedom to share and change the works. By contrast, the GNU General Public License is intended to guarantee your freedom to share and change all versions of a program--to make sure it remains free software for all its users. We, the Free Software Foundation, use the GNU General Public License for most of our software; it applies also to any other work released this way by its authors. You can apply it to your programs, too.
|
||||||
|
|
||||||
|
When we speak of free software, we are referring to freedom, not price. Our General Public Licenses are designed to make sure that you have the freedom to distribute copies of free software (and charge for them if you wish), that you receive source code or can get it if you want it, that you can change the software or use pieces of it in new free programs, and that you know you can do these things.
|
||||||
|
|
||||||
|
To protect your rights, we need to prevent others from denying you these rights or asking you to surrender the rights. Therefore, you have certain responsibilities if you distribute copies of the software, or if you modify it: responsibilities to respect the freedom of others.
|
||||||
|
|
||||||
|
For example, if you distribute copies of such a program, whether gratis or for a fee, you must pass on to the recipients the same freedoms that you received. You must make sure that they, too, receive or can get the source code. And you must show them these terms so they know their rights.
|
||||||
|
|
||||||
|
Developers that use the GNU GPL protect your rights with two steps: (1) assert copyright on the software, and (2) offer you this License giving you legal permission to copy, distribute and/or modify it.
|
||||||
|
|
||||||
|
For the developers' and authors' protection, the GPL clearly explains that there is no warranty for this free software. For both users' and authors' sake, the GPL requires that modified versions be marked as changed, so that their problems will not be attributed erroneously to authors of previous versions.
|
||||||
|
|
||||||
|
Some devices are designed to deny users access to install or run modified versions of the software inside them, although the manufacturer can do so. This is fundamentally incompatible with the aim of protecting users' freedom to change the software. The systematic pattern of such abuse occurs in the area of products for individuals to use, which is precisely where it is most unacceptable. Therefore, we have designed this version of the GPL to prohibit the practice for those products. If such problems arise substantially in other domains, we stand ready to extend this provision to those domains in future versions of the GPL, as needed to protect the freedom of users.
|
||||||
|
|
||||||
|
Finally, every program is threatened constantly by software patents. States should not allow patents to restrict development and use of software on general-purpose computers, but in those that do, we wish to avoid the special danger that patents applied to a free program could make it effectively proprietary. To prevent this, the GPL assures that patents cannot be used to render the program non-free.
|
||||||
|
|
||||||
|
The precise terms and conditions for copying, distribution and modification follow.
|
||||||
|
|
||||||
|
TERMS AND CONDITIONS
|
||||||
|
|
||||||
|
0. Definitions.
|
||||||
|
|
||||||
|
“This License” refers to version 3 of the GNU General Public License.
|
||||||
|
|
||||||
|
“Copyright” also means copyright-like laws that apply to other kinds of works, such as semiconductor masks.
|
||||||
|
|
||||||
|
“The Program” refers to any copyrightable work licensed under this License. Each licensee is addressed as “you”. “Licensees” and “recipients” may be individuals or organizations.
|
||||||
|
|
||||||
|
To “modify” a work means to copy from or adapt all or part of the work in a fashion requiring copyright permission, other than the making of an exact copy. The resulting work is called a “modified version” of the earlier work or a work “based on” the earlier work.
|
||||||
|
|
||||||
|
A “covered work” means either the unmodified Program or a work based on the Program.
|
||||||
|
|
||||||
|
To “propagate” a work means to do anything with it that, without permission, would make you directly or secondarily liable for infringement under applicable copyright law, except executing it on a computer or modifying a private copy. Propagation includes copying, distribution (with or without modification), making available to the public, and in some countries other activities as well.
|
||||||
|
|
||||||
|
To “convey” a work means any kind of propagation that enables other parties to make or receive copies. Mere interaction with a user through a computer network, with no transfer of a copy, is not conveying.
|
||||||
|
|
||||||
|
An interactive user interface displays “Appropriate Legal Notices” to the extent that it includes a convenient and prominently visible feature that (1) displays an appropriate copyright notice, and (2) tells the user that there is no warranty for the work (except to the extent that warranties are provided), that licensees may convey the work under this License, and how to view a copy of this License. If the interface presents a list of user commands or options, such as a menu, a prominent item in the list meets this criterion.
|
||||||
|
|
||||||
|
1. Source Code.
|
||||||
|
The “source code” for a work means the preferred form of the work for making modifications to it. “Object code” means any non-source form of a work.
|
||||||
|
|
||||||
|
A “Standard Interface” means an interface that either is an official standard defined by a recognized standards body, or, in the case of interfaces specified for a particular programming language, one that is widely used among developers working in that language.
|
||||||
|
|
||||||
|
The “System Libraries” of an executable work include anything, other than the work as a whole, that (a) is included in the normal form of packaging a Major Component, but which is not part of that Major Component, and (b) serves only to enable use of the work with that Major Component, or to implement a Standard Interface for which an implementation is available to the public in source code form. A “Major Component”, in this context, means a major essential component (kernel, window system, and so on) of the specific operating system (if any) on which the executable work runs, or a compiler used to produce the work, or an object code interpreter used to run it.
|
||||||
|
|
||||||
|
The “Corresponding Source” for a work in object code form means all the source code needed to generate, install, and (for an executable work) run the object code and to modify the work, including scripts to control those activities. However, it does not include the work's System Libraries, or general-purpose tools or generally available free programs which are used unmodified in performing those activities but which are not part of the work. For example, Corresponding Source includes interface definition files associated with source files for the work, and the source code for shared libraries and dynamically linked subprograms that the work is specifically designed to require, such as by intimate data communication or control flow between those subprograms and other parts of the work.
|
||||||
|
|
||||||
|
The Corresponding Source need not include anything that users can regenerate automatically from other parts of the Corresponding Source.
|
||||||
|
|
||||||
|
The Corresponding Source for a work in source code form is that same work.
|
||||||
|
|
||||||
|
2. Basic Permissions.
|
||||||
|
All rights granted under this License are granted for the term of copyright on the Program, and are irrevocable provided the stated conditions are met. This License explicitly affirms your unlimited permission to run the unmodified Program. The output from running a covered work is covered by this License only if the output, given its content, constitutes a covered work. This License acknowledges your rights of fair use or other equivalent, as provided by copyright law.
|
||||||
|
|
||||||
|
You may make, run and propagate covered works that you do not convey, without conditions so long as your license otherwise remains in force. You may convey covered works to others for the sole purpose of having them make modifications exclusively for you, or provide you with facilities for running those works, provided that you comply with the terms of this License in conveying all material for which you do not control copyright. Those thus making or running the covered works for you must do so exclusively on your behalf, under your direction and control, on terms that prohibit them from making any copies of your copyrighted material outside their relationship with you.
|
||||||
|
|
||||||
|
Conveying under any other circumstances is permitted solely under the conditions stated below. Sublicensing is not allowed; section 10 makes it unnecessary.
|
||||||
|
|
||||||
|
3. Protecting Users' Legal Rights From Anti-Circumvention Law.
|
||||||
|
No covered work shall be deemed part of an effective technological measure under any applicable law fulfilling obligations under article 11 of the WIPO copyright treaty adopted on 20 December 1996, or similar laws prohibiting or restricting circumvention of such measures.
|
||||||
|
|
||||||
|
When you convey a covered work, you waive any legal power to forbid circumvention of technological measures to the extent such circumvention is effected by exercising rights under this License with respect to the covered work, and you disclaim any intention to limit operation or modification of the work as a means of enforcing, against the work's users, your or third parties' legal rights to forbid circumvention of technological measures.
|
||||||
|
|
||||||
|
4. Conveying Verbatim Copies.
|
||||||
|
You may convey verbatim copies of the Program's source code as you receive it, in any medium, provided that you conspicuously and appropriately publish on each copy an appropriate copyright notice; keep intact all notices stating that this License and any non-permissive terms added in accord with section 7 apply to the code; keep intact all notices of the absence of any warranty; and give all recipients a copy of this License along with the Program.
|
||||||
|
|
||||||
|
You may charge any price or no price for each copy that you convey, and you may offer support or warranty protection for a fee.
|
||||||
|
|
||||||
|
5. Conveying Modified Source Versions.
|
||||||
|
You may convey a work based on the Program, or the modifications to produce it from the Program, in the form of source code under the terms of section 4, provided that you also meet all of these conditions:
|
||||||
|
|
||||||
|
a) The work must carry prominent notices stating that you modified it, and giving a relevant date.
|
||||||
|
|
||||||
|
b) The work must carry prominent notices stating that it is released under this License and any conditions added under section 7. This requirement modifies the requirement in section 4 to “keep intact all notices”.
|
||||||
|
|
||||||
|
c) You must license the entire work, as a whole, under this License to anyone who comes into possession of a copy. This License will therefore apply, along with any applicable section 7 additional terms, to the whole of the work, and all its parts, regardless of how they are packaged. This License gives no permission to license the work in any other way, but it does not invalidate such permission if you have separately received it.
|
||||||
|
|
||||||
|
d) If the work has interactive user interfaces, each must display Appropriate Legal Notices; however, if the Program has interactive interfaces that do not display Appropriate Legal Notices, your work need not make them do so.
|
||||||
|
|
||||||
|
A compilation of a covered work with other separate and independent works, which are not by their nature extensions of the covered work, and which are not combined with it such as to form a larger program, in or on a volume of a storage or distribution medium, is called an “aggregate” if the compilation and its resulting copyright are not used to limit the access or legal rights of the compilation's users beyond what the individual works permit. Inclusion of a covered work in an aggregate does not cause this License to apply to the other parts of the aggregate.
|
||||||
|
|
||||||
|
6. Conveying Non-Source Forms.
|
||||||
|
You may convey a covered work in object code form under the terms of sections 4 and 5, provided that you also convey the machine-readable Corresponding Source under the terms of this License, in one of these ways:
|
||||||
|
|
||||||
|
a) Convey the object code in, or embodied in, a physical product (including a physical distribution medium), accompanied by the Corresponding Source fixed on a durable physical medium customarily used for software interchange.
|
||||||
|
|
||||||
|
b) Convey the object code in, or embodied in, a physical product (including a physical distribution medium), accompanied by a written offer, valid for at least three years and valid for as long as you offer spare parts or customer support for that product model, to give anyone who possesses the object code either (1) a copy of the Corresponding Source for all the software in the product that is covered by this License, on a durable physical medium customarily used for software interchange, for a price no more than your reasonable cost of physically performing this conveying of source, or (2) access to copy the Corresponding Source from a network server at no charge.
|
||||||
|
|
||||||
|
c) Convey individual copies of the object code with a copy of the written offer to provide the Corresponding Source. This alternative is allowed only occasionally and noncommercially, and only if you received the object code with such an offer, in accord with subsection 6b.
|
||||||
|
|
||||||
|
d) Convey the object code by offering access from a designated place (gratis or for a charge), and offer equivalent access to the Corresponding Source in the same way through the same place at no further charge. You need not require recipients to copy the Corresponding Source along with the object code. If the place to copy the object code is a network server, the Corresponding Source may be on a different server (operated by you or a third party) that supports equivalent copying facilities, provided you maintain clear directions next to the object code saying where to find the Corresponding Source. Regardless of what server hosts the Corresponding Source, you remain obligated to ensure that it is available for as long as needed to satisfy these requirements.
|
||||||
|
|
||||||
|
e) Convey the object code using peer-to-peer transmission, provided you inform other peers where the object code and Corresponding Source of the work are being offered to the general public at no charge under subsection 6d.
|
||||||
|
|
||||||
|
A separable portion of the object code, whose source code is excluded from the Corresponding Source as a System Library, need not be included in conveying the object code work.
|
||||||
|
|
||||||
|
A “User Product” is either (1) a “consumer product”, which means any tangible personal property which is normally used for personal, family, or household purposes, or (2) anything designed or sold for incorporation into a dwelling. In determining whether a product is a consumer product, doubtful cases shall be resolved in favor of coverage. For a particular product received by a particular user, “normally used” refers to a typical or common use of that class of product, regardless of the status of the particular user or of the way in which the particular user actually uses, or expects or is expected to use, the product. A product is a consumer product regardless of whether the product has substantial commercial, industrial or non-consumer uses, unless such uses represent the only significant mode of use of the product.
|
||||||
|
|
||||||
|
“Installation Information” for a User Product means any methods, procedures, authorization keys, or other information required to install and execute modified versions of a covered work in that User Product from a modified version of its Corresponding Source. The information must suffice to ensure that the continued functioning of the modified object code is in no case prevented or interfered with solely because modification has been made.
|
||||||
|
|
||||||
|
If you convey an object code work under this section in, or with, or specifically for use in, a User Product, and the conveying occurs as part of a transaction in which the right of possession and use of the User Product is transferred to the recipient in perpetuity or for a fixed term (regardless of how the transaction is characterized), the Corresponding Source conveyed under this section must be accompanied by the Installation Information. But this requirement does not apply if neither you nor any third party retains the ability to install modified object code on the User Product (for example, the work has been installed in ROM).
|
||||||
|
|
||||||
|
The requirement to provide Installation Information does not include a requirement to continue to provide support service, warranty, or updates for a work that has been modified or installed by the recipient, or for the User Product in which it has been modified or installed. Access to a network may be denied when the modification itself materially and adversely affects the operation of the network or violates the rules and protocols for communication across the network.
|
||||||
|
|
||||||
|
Corresponding Source conveyed, and Installation Information provided, in accord with this section must be in a format that is publicly documented (and with an implementation available to the public in source code form), and must require no special password or key for unpacking, reading or copying.
|
||||||
|
|
||||||
|
7. Additional Terms.
|
||||||
|
“Additional permissions” are terms that supplement the terms of this License by making exceptions from one or more of its conditions. Additional permissions that are applicable to the entire Program shall be treated as though they were included in this License, to the extent that they are valid under applicable law. If additional permissions apply only to part of the Program, that part may be used separately under those permissions, but the entire Program remains governed by this License without regard to the additional permissions.
|
||||||
|
|
||||||
|
When you convey a copy of a covered work, you may at your option remove any additional permissions from that copy, or from any part of it. (Additional permissions may be written to require their own removal in certain cases when you modify the work.) You may place additional permissions on material, added by you to a covered work, for which you have or can give appropriate copyright permission.
|
||||||
|
|
||||||
|
Notwithstanding any other provision of this License, for material you add to a covered work, you may (if authorized by the copyright holders of that material) supplement the terms of this License with terms:
|
||||||
|
|
||||||
|
a) Disclaiming warranty or limiting liability differently from the terms of sections 15 and 16 of this License; or
|
||||||
|
|
||||||
|
b) Requiring preservation of specified reasonable legal notices or author attributions in that material or in the Appropriate Legal Notices displayed by works containing it; or
|
||||||
|
|
||||||
|
c) Prohibiting misrepresentation of the origin of that material, or requiring that modified versions of such material be marked in reasonable ways as different from the original version; or
|
||||||
|
|
||||||
|
d) Limiting the use for publicity purposes of names of licensors or authors of the material; or
|
||||||
|
|
||||||
|
e) Declining to grant rights under trademark law for use of some trade names, trademarks, or service marks; or
|
||||||
|
|
||||||
|
f) Requiring indemnification of licensors and authors of that material by anyone who conveys the material (or modified versions of it) with contractual assumptions of liability to the recipient, for any liability that these contractual assumptions directly impose on those licensors and authors.
|
||||||
|
|
||||||
|
All other non-permissive additional terms are considered “further restrictions” within the meaning of section 10. If the Program as you received it, or any part of it, contains a notice stating that it is governed by this License along with a term that is a further restriction, you may remove that term. If a license document contains a further restriction but permits relicensing or conveying under this License, you may add to a covered work material governed by the terms of that license document, provided that the further restriction does not survive such relicensing or conveying.
|
||||||
|
|
||||||
|
If you add terms to a covered work in accord with this section, you must place, in the relevant source files, a statement of the additional terms that apply to those files, or a notice indicating where to find the applicable terms.
|
||||||
|
|
||||||
|
Additional terms, permissive or non-permissive, may be stated in the form of a separately written license, or stated as exceptions; the above requirements apply either way.
|
||||||
|
|
||||||
|
8. Termination.
|
||||||
|
You may not propagate or modify a covered work except as expressly provided under this License. Any attempt otherwise to propagate or modify it is void, and will automatically terminate your rights under this License (including any patent licenses granted under the third paragraph of section 11).
|
||||||
|
|
||||||
|
However, if you cease all violation of this License, then your license from a particular copyright holder is reinstated (a) provisionally, unless and until the copyright holder explicitly and finally terminates your license, and (b) permanently, if the copyright holder fails to notify you of the violation by some reasonable means prior to 60 days after the cessation.
|
||||||
|
|
||||||
|
Moreover, your license from a particular copyright holder is reinstated permanently if the copyright holder notifies you of the violation by some reasonable means, this is the first time you have received notice of violation of this License (for any work) from that copyright holder, and you cure the violation prior to 30 days after your receipt of the notice.
|
||||||
|
|
||||||
|
Termination of your rights under this section does not terminate the licenses of parties who have received copies or rights from you under this License. If your rights have been terminated and not permanently reinstated, you do not qualify to receive new licenses for the same material under section 10.
|
||||||
|
|
||||||
|
9. Acceptance Not Required for Having Copies.
|
||||||
|
You are not required to accept this License in order to receive or run a copy of the Program. Ancillary propagation of a covered work occurring solely as a consequence of using peer-to-peer transmission to receive a copy likewise does not require acceptance. However, nothing other than this License grants you permission to propagate or modify any covered work. These actions infringe copyright if you do not accept this License. Therefore, by modifying or propagating a covered work, you indicate your acceptance of this License to do so.
|
||||||
|
|
||||||
|
10. Automatic Licensing of Downstream Recipients.
|
||||||
|
Each time you convey a covered work, the recipient automatically receives a license from the original licensors, to run, modify and propagate that work, subject to this License. You are not responsible for enforcing compliance by third parties with this License.
|
||||||
|
|
||||||
|
An “entity transaction” is a transaction transferring control of an organization, or substantially all assets of one, or subdividing an organization, or merging organizations. If propagation of a covered work results from an entity transaction, each party to that transaction who receives a copy of the work also receives whatever licenses to the work the party's predecessor in interest had or could give under the previous paragraph, plus a right to possession of the Corresponding Source of the work from the predecessor in interest, if the predecessor has it or can get it with reasonable efforts.
|
||||||
|
|
||||||
|
You may not impose any further restrictions on the exercise of the rights granted or affirmed under this License. For example, you may not impose a license fee, royalty, or other charge for exercise of rights granted under this License, and you may not initiate litigation (including a cross-claim or counterclaim in a lawsuit) alleging that any patent claim is infringed by making, using, selling, offering for sale, or importing the Program or any portion of it.
|
||||||
|
|
||||||
|
11. Patents.
|
||||||
|
A “contributor” is a copyright holder who authorizes use under this License of the Program or a work on which the Program is based. The work thus licensed is called the contributor's “contributor version”.
|
||||||
|
|
||||||
|
A contributor's “essential patent claims” are all patent claims owned or controlled by the contributor, whether already acquired or hereafter acquired, that would be infringed by some manner, permitted by this License, of making, using, or selling its contributor version, but do not include claims that would be infringed only as a consequence of further modification of the contributor version. For purposes of this definition, “control” includes the right to grant patent sublicenses in a manner consistent with the requirements of this License.
|
||||||
|
|
||||||
|
Each contributor grants you a non-exclusive, worldwide, royalty-free patent license under the contributor's essential patent claims, to make, use, sell, offer for sale, import and otherwise run, modify and propagate the contents of its contributor version.
|
||||||
|
|
||||||
|
In the following three paragraphs, a “patent license” is any express agreement or commitment, however denominated, not to enforce a patent (such as an express permission to practice a patent or covenant not to sue for patent infringement). To “grant” such a patent license to a party means to make such an agreement or commitment not to enforce a patent against the party.
|
||||||
|
|
||||||
|
If you convey a covered work, knowingly relying on a patent license, and the Corresponding Source of the work is not available for anyone to copy, free of charge and under the terms of this License, through a publicly available network server or other readily accessible means, then you must either (1) cause the Corresponding Source to be so available, or (2) arrange to deprive yourself of the benefit of the patent license for this particular work, or (3) arrange, in a manner consistent with the requirements of this License, to extend the patent license to downstream recipients. “Knowingly relying” means you have actual knowledge that, but for the patent license, your conveying the covered work in a country, or your recipient's use of the covered work in a country, would infringe one or more identifiable patents in that country that you have reason to believe are valid.
|
||||||
|
|
||||||
|
If, pursuant to or in connection with a single transaction or arrangement, you convey, or propagate by procuring conveyance of, a covered work, and grant a patent license to some of the parties receiving the covered work authorizing them to use, propagate, modify or convey a specific copy of the covered work, then the patent license you grant is automatically extended to all recipients of the covered work and works based on it.
|
||||||
|
|
||||||
|
A patent license is “discriminatory” if it does not include within the scope of its coverage, prohibits the exercise of, or is conditioned on the non-exercise of one or more of the rights that are specifically granted under this License. You may not convey a covered work if you are a party to an arrangement with a third party that is in the business of distributing software, under which you make payment to the third party based on the extent of your activity of conveying the work, and under which the third party grants, to any of the parties who would receive the covered work from you, a discriminatory patent license (a) in connection with copies of the covered work conveyed by you (or copies made from those copies), or (b) primarily for and in connection with specific products or compilations that contain the covered work, unless you entered into that arrangement, or that patent license was granted, prior to 28 March 2007.
|
||||||
|
|
||||||
|
Nothing in this License shall be construed as excluding or limiting any implied license or other defenses to infringement that may otherwise be available to you under applicable patent law.
|
||||||
|
|
||||||
|
12. No Surrender of Others' Freedom.
|
||||||
|
If conditions are imposed on you (whether by court order, agreement or otherwise) that contradict the conditions of this License, they do not excuse you from the conditions of this License. If you cannot convey a covered work so as to satisfy simultaneously your obligations under this License and any other pertinent obligations, then as a consequence you may not convey it at all. For example, if you agree to terms that obligate you to collect a royalty for further conveying from those to whom you convey the Program, the only way you could satisfy both those terms and this License would be to refrain entirely from conveying the Program.
|
||||||
|
|
||||||
|
13. Use with the GNU Affero General Public License.
|
||||||
|
Notwithstanding any other provision of this License, you have permission to link or combine any covered work with a work licensed under version 3 of the GNU Affero General Public License into a single combined work, and to convey the resulting work. The terms of this License will continue to apply to the part which is the covered work, but the special requirements of the GNU Affero General Public License, section 13, concerning interaction through a network will apply to the combination as such.
|
||||||
|
|
||||||
|
14. Revised Versions of this License.
|
||||||
|
The Free Software Foundation may publish revised and/or new versions of the GNU General Public License from time to time. Such new versions will be similar in spirit to the present version, but may differ in detail to address new problems or concerns.
|
||||||
|
|
||||||
|
Each version is given a distinguishing version number. If the Program specifies that a certain numbered version of the GNU General Public License “or any later version” applies to it, you have the option of following the terms and conditions either of that numbered version or of any later version published by the Free Software Foundation. If the Program does not specify a version number of the GNU General Public License, you may choose any version ever published by the Free Software Foundation.
|
||||||
|
|
||||||
|
If the Program specifies that a proxy can decide which future versions of the GNU General Public License can be used, that proxy's public statement of acceptance of a version permanently authorizes you to choose that version for the Program.
|
||||||
|
|
||||||
|
Later license versions may give you additional or different permissions. However, no additional obligations are imposed on any author or copyright holder as a result of your choosing to follow a later version.
|
||||||
|
|
||||||
|
15. Disclaimer of Warranty.
|
||||||
|
THERE IS NO WARRANTY FOR THE PROGRAM, TO THE EXTENT PERMITTED BY APPLICABLE LAW. EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT HOLDERS AND/OR OTHER PARTIES PROVIDE THE PROGRAM “AS IS” WITHOUT WARRANTY OF ANY KIND, EITHER EXPRESSED OR IMPLIED, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE. THE ENTIRE RISK AS TO THE QUALITY AND PERFORMANCE OF THE PROGRAM IS WITH YOU. SHOULD THE PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF ALL NECESSARY SERVICING, REPAIR OR CORRECTION.
|
||||||
|
|
||||||
|
16. Limitation of Liability.
|
||||||
|
IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MODIFIES AND/OR CONVEYS THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, INCLUDING ANY GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING OUT OF THE USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED TO LOSS OF DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY YOU OR THIRD PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER PROGRAMS), EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF SUCH DAMAGES.
|
||||||
|
|
||||||
|
17. Interpretation of Sections 15 and 16.
|
||||||
|
If the disclaimer of warranty and limitation of liability provided above cannot be given local legal effect according to their terms, reviewing courts shall apply local law that most closely approximates an absolute waiver of all civil liability in connection with the Program, unless a warranty or assumption of liability accompanies a copy of the Program in return for a fee.
|
||||||
|
|
||||||
|
END OF TERMS AND CONDITIONS
|
||||||
|
|
||||||
|
How to Apply These Terms to Your New Programs
|
||||||
|
|
||||||
|
If you develop a new program, and you want it to be of the greatest possible use to the public, the best way to achieve this is to make it free software which everyone can redistribute and change under these terms.
|
||||||
|
|
||||||
|
To do so, attach the following notices to the program. It is safest to attach them to the start of each source file to most effectively state the exclusion of warranty; and each file should have at least the “copyright” line and a pointer to where the full notice is found.
|
||||||
|
|
||||||
|
<one line to give the program's name and a brief idea of what it does.>
|
||||||
|
Copyright (C) <year> <name of author>
|
||||||
|
|
||||||
|
This program is free software: you can redistribute it and/or modify it under the terms of the GNU General Public License as published by the Free Software Foundation, either version 3 of the License, or (at your option) any later version.
|
||||||
|
|
||||||
|
This program is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for more details.
|
||||||
|
|
||||||
|
You should have received a copy of the GNU General Public License along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||||
|
|
||||||
|
Also add information on how to contact you by electronic and paper mail.
|
||||||
|
|
||||||
|
If the program does terminal interaction, make it output a short notice like this when it starts in an interactive mode:
|
||||||
|
|
||||||
|
<program> Copyright (C) <year> <name of author>
|
||||||
|
This program comes with ABSOLUTELY NO WARRANTY; for details type `show w'.
|
||||||
|
This is free software, and you are welcome to redistribute it under certain conditions; type `show c' for details.
|
||||||
|
|
||||||
|
The hypothetical commands `show w' and `show c' should show the appropriate parts of the General Public License. Of course, your program's commands might be different; for a GUI interface, you would use an “about box”.
|
||||||
|
|
||||||
|
You should also get your employer (if you work as a programmer) or school, if any, to sign a “copyright disclaimer” for the program, if necessary. For more information on this, and how to apply and follow the GNU GPL, see <https://www.gnu.org/licenses/>.
|
||||||
|
|
||||||
|
The GNU General Public License does not permit incorporating your program into proprietary programs. If your program is a subroutine library, you may consider it more useful to permit linking proprietary applications with the library. If this is what you want to do, use the GNU Lesser General Public License instead of this License. But first, please read <https://www.gnu.org/philosophy/why-not-lgpl.html>.
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
fn main() {
|
||||||
|
uniffi::uniffi_bindgen_main();
|
||||||
|
}
|
||||||
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,4 @@
|
|||||||
|
[bindings.kotlin]
|
||||||
|
# UniFFI's default JVM cleaner references java.lang.ref.Cleaner (API 33).
|
||||||
|
# Android uses the JNA cleaner, which is available on our minSdk 31 runtime.
|
||||||
|
disable_java_cleaner = true
|
||||||
@@ -27,3 +27,5 @@ egui.workspace = true
|
|||||||
image.workspace = true
|
image.workspace = true
|
||||||
sha2.workspace = true
|
sha2.workspace = true
|
||||||
parking_lot.workspace = true
|
parking_lot.workspace = true
|
||||||
|
serde.workspace = true
|
||||||
|
serde_json.workspace = true
|
||||||
+107
-13
@@ -14,7 +14,9 @@ use pstr_core::config::AppDirs;
|
|||||||
use pstr_core::library::Library;
|
use pstr_core::library::Library;
|
||||||
use pstr_core::metadata::{EpisodeGuide, MetadataConfig, MetadataRecord};
|
use pstr_core::metadata::{EpisodeGuide, MetadataConfig, MetadataRecord};
|
||||||
|
|
||||||
use crate::engine::{Engine, Event, ImageCache, describe_failures, watch_state};
|
use crate::engine::{
|
||||||
|
DownloadItem, DownloadKey, Engine, Event, ImageCache, describe_failures, watch_state,
|
||||||
|
};
|
||||||
use crate::playback::{Playback, PlaybackTarget};
|
use crate::playback::{Playback, PlaybackTarget};
|
||||||
use crate::ui::player::UpNextCard;
|
use crate::ui::player::UpNextCard;
|
||||||
use crate::{theme, ui};
|
use crate::{theme, ui};
|
||||||
@@ -48,6 +50,7 @@ impl FrameTimer {
|
|||||||
Page::Library => "library",
|
Page::Library => "library",
|
||||||
Page::Title(_) => "title",
|
Page::Title(_) => "title",
|
||||||
Page::Shares => "shares",
|
Page::Shares => "shares",
|
||||||
|
Page::Downloads => "downloads",
|
||||||
Page::Player => "player",
|
Page::Player => "player",
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
@@ -81,6 +84,7 @@ pub enum Page {
|
|||||||
/// One title, by [`pstr_core::library::Title::key`].
|
/// One title, by [`pstr_core::library::Title::key`].
|
||||||
Title(String),
|
Title(String),
|
||||||
Shares,
|
Shares,
|
||||||
|
Downloads,
|
||||||
/// The picture, filling the window. Leaving this page does not stop
|
/// The picture, filling the window. Leaving this page does not stop
|
||||||
/// playback — the transport bar at the bottom is how you get back to it.
|
/// playback — the transport bar at the bottom is how you get back to it.
|
||||||
Player,
|
Player,
|
||||||
@@ -90,6 +94,12 @@ pub enum Page {
|
|||||||
pub enum Action {
|
pub enum Action {
|
||||||
Goto(Page),
|
Goto(Page),
|
||||||
Play(PlaybackTarget),
|
Play(PlaybackTarget),
|
||||||
|
/// Download one or more files as complete local copies.
|
||||||
|
MakeOffline(Vec<PlaybackTarget>),
|
||||||
|
PauseDownload(DownloadKey),
|
||||||
|
ResumeDownload(DownloadKey),
|
||||||
|
CancelDownload(DownloadKey),
|
||||||
|
RemoveDownload(DownloadKey, bool),
|
||||||
/// Crawl one share, or every share.
|
/// Crawl one share, or every share.
|
||||||
Crawl(Option<String>),
|
Crawl(Option<String>),
|
||||||
AddShare {
|
AddShare {
|
||||||
@@ -280,6 +290,9 @@ pub struct App {
|
|||||||
pub opening: Option<String>,
|
pub opening: Option<String>,
|
||||||
pub connecting: bool,
|
pub connecting: bool,
|
||||||
pub crawling: bool,
|
pub crawling: bool,
|
||||||
|
pub downloads: Vec<DownloadItem>,
|
||||||
|
pub offline_files: std::collections::HashSet<DownloadKey>,
|
||||||
|
pub confirm_partial_delete: Option<DownloadKey>,
|
||||||
pub status: Option<Status>,
|
pub status: Option<Status>,
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -293,6 +306,7 @@ impl App {
|
|||||||
// theme, and a window that paints one frame in the default palette
|
// theme, and a window that paints one frame in the default palette
|
||||||
// before switching is a window that flashes on every launch.
|
// before switching is a window that flashes on every launch.
|
||||||
let (engine, events) = Engine::new(runtime, dirs, cc.egui_ctx.clone())?;
|
let (engine, events) = Engine::new(runtime, dirs, cc.egui_ctx.clone())?;
|
||||||
|
theme::install_font_fallbacks(&cc.egui_ctx);
|
||||||
theme::apply(&cc.egui_ctx, engine.appearance());
|
theme::apply(&cc.egui_ctx, engine.appearance());
|
||||||
|
|
||||||
// Paint from the catalog immediately; the network catches up. A library
|
// Paint from the catalog immediately; the network catches up. A library
|
||||||
@@ -326,6 +340,9 @@ impl App {
|
|||||||
opening: None,
|
opening: None,
|
||||||
connecting: true,
|
connecting: true,
|
||||||
crawling: false,
|
crawling: false,
|
||||||
|
downloads: Vec::new(),
|
||||||
|
offline_files: std::collections::HashSet::new(),
|
||||||
|
confirm_partial_delete: None,
|
||||||
status: None,
|
status: None,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
@@ -507,6 +524,8 @@ impl App {
|
|||||||
self.note(ctx, text, true);
|
self.note(ctx, text, true);
|
||||||
}
|
}
|
||||||
Event::Status(text) => self.note(ctx, text, false),
|
Event::Status(text) => self.note(ctx, text, false),
|
||||||
|
Event::Downloads(downloads) => self.downloads = downloads,
|
||||||
|
Event::OfflineFiles(files) => self.offline_files = files,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -674,9 +693,27 @@ impl App {
|
|||||||
// click on a row, a keypress, autoplay — so the episode's name
|
// click on a row, a keypress, autoplay — so the episode's name
|
||||||
// is looked up here rather than in each of them.
|
// is looked up here rather than in each of them.
|
||||||
target.episode_name = self.episode_name(&target);
|
target.episode_name = self.episode_name(&target);
|
||||||
|
target.track_prefs = self
|
||||||
|
.engine
|
||||||
|
.title_track_prefs(&target.title_key)
|
||||||
|
.map(Box::new);
|
||||||
self.opening = Some(target.name.clone());
|
self.opening = Some(target.name.clone());
|
||||||
self.engine.play(target);
|
self.engine.play(target);
|
||||||
}
|
}
|
||||||
|
Action::MakeOffline(targets) => {
|
||||||
|
self.note(ctx, "downloading for offline use…", false);
|
||||||
|
self.engine.make_offline(targets);
|
||||||
|
}
|
||||||
|
Action::PauseDownload(key) => self.engine.pause_download(&key),
|
||||||
|
Action::ResumeDownload(key) => self.engine.resume_download(&key),
|
||||||
|
Action::CancelDownload(key) => self.engine.cancel_download(&key),
|
||||||
|
Action::RemoveDownload(key, remove_partial) => {
|
||||||
|
if remove_partial {
|
||||||
|
self.confirm_partial_delete = Some(key);
|
||||||
|
} else {
|
||||||
|
self.engine.remove_download(key, false);
|
||||||
|
}
|
||||||
|
}
|
||||||
Action::SetMetadataConfig(config) => {
|
Action::SetMetadataConfig(config) => {
|
||||||
self.settings = config.clone();
|
self.settings = config.clone();
|
||||||
self.engine.set_metadata_config(config);
|
self.engine.set_metadata_config(config);
|
||||||
@@ -832,22 +869,24 @@ impl App {
|
|||||||
.and_then(|track| track.language.clone())
|
.and_then(|track| track.language.clone())
|
||||||
})
|
})
|
||||||
});
|
});
|
||||||
let mut prefs = self.engine.playback_prefs();
|
if let Some(playback) = &self.playback {
|
||||||
|
let mut show = self
|
||||||
|
.engine
|
||||||
|
.title_track_prefs(&playback.target.title_key)
|
||||||
|
.unwrap_or_default();
|
||||||
match kind {
|
match kind {
|
||||||
pstr_player::TrackKind::Audio => prefs.audio_language = language,
|
pstr_player::TrackKind::Audio => show.audio_language = language,
|
||||||
pstr_player::TrackKind::Subtitle => {
|
pstr_player::TrackKind::Subtitle => {
|
||||||
prefs.subtitles = id.is_some();
|
show.subtitles = id.is_some();
|
||||||
// Subtitles turned off keep the language they were
|
|
||||||
// last shown in, so turning them back on picks up
|
|
||||||
// where the viewer left off rather than at whatever
|
|
||||||
// the file defaults to.
|
|
||||||
if id.is_some() {
|
if id.is_some() {
|
||||||
prefs.subtitle_language = language;
|
show.subtitle_language = language;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
pstr_player::TrackKind::Video => {}
|
pstr_player::TrackKind::Video => {}
|
||||||
}
|
}
|
||||||
self.engine.set_playback_prefs(prefs, true);
|
self.engine
|
||||||
|
.set_title_track_prefs(playback.target.title_key.clone(), show);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -871,6 +910,26 @@ impl App {
|
|||||||
if ui::tab(ui, self.page == Page::Shares, "Shares").clicked() {
|
if ui::tab(ui, self.page == Page::Shares, "Shares").clicked() {
|
||||||
actions.push(Action::Goto(Page::Shares));
|
actions.push(Action::Goto(Page::Shares));
|
||||||
}
|
}
|
||||||
|
let active = self
|
||||||
|
.downloads
|
||||||
|
.iter()
|
||||||
|
.filter(|download| {
|
||||||
|
matches!(
|
||||||
|
download.state,
|
||||||
|
crate::engine::DownloadState::Queued
|
||||||
|
| crate::engine::DownloadState::Running
|
||||||
|
| crate::engine::DownloadState::Paused
|
||||||
|
)
|
||||||
|
})
|
||||||
|
.count();
|
||||||
|
let label = if active == 0 {
|
||||||
|
"Downloads".to_owned()
|
||||||
|
} else {
|
||||||
|
format!("Downloads ({active})")
|
||||||
|
};
|
||||||
|
if ui::tab(ui, self.page == Page::Downloads, &label).clicked() {
|
||||||
|
actions.push(Action::Goto(Page::Downloads));
|
||||||
|
}
|
||||||
|
|
||||||
ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| {
|
ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| {
|
||||||
ui.add_space(4.0);
|
ui.add_space(4.0);
|
||||||
@@ -1017,6 +1076,9 @@ impl eframe::App for App {
|
|||||||
playback,
|
playback,
|
||||||
opening,
|
opening,
|
||||||
status,
|
status,
|
||||||
|
downloads,
|
||||||
|
offline_files,
|
||||||
|
confirm_partial_delete,
|
||||||
..
|
..
|
||||||
} = self;
|
} = self;
|
||||||
|
|
||||||
@@ -1086,9 +1148,17 @@ impl eframe::App for App {
|
|||||||
Page::Library => {
|
Page::Library => {
|
||||||
ui::library::show(ui, &mut art, library, search, &mut actions)
|
ui::library::show(ui, &mut art, library, search, &mut actions)
|
||||||
}
|
}
|
||||||
Page::Title(key) => {
|
Page::Title(key) => ui::title::show(
|
||||||
ui::title::show(ui, &mut art, library, key, &mut actions)
|
ui,
|
||||||
}
|
&mut art,
|
||||||
|
library,
|
||||||
|
key,
|
||||||
|
ui::title::OfflineView {
|
||||||
|
downloads,
|
||||||
|
files: offline_files,
|
||||||
|
},
|
||||||
|
&mut actions,
|
||||||
|
),
|
||||||
Page::Shares => ui::shares::show(
|
Page::Shares => ui::shares::show(
|
||||||
ui,
|
ui,
|
||||||
shares,
|
shares,
|
||||||
@@ -1098,6 +1168,7 @@ impl eframe::App for App {
|
|||||||
api_key,
|
api_key,
|
||||||
&mut actions,
|
&mut actions,
|
||||||
),
|
),
|
||||||
|
Page::Downloads => ui::downloads::show(ui, downloads, &mut actions),
|
||||||
// Drawn above, without any of these panels.
|
// Drawn above, without any of these panels.
|
||||||
Page::Player => {}
|
Page::Player => {}
|
||||||
}
|
}
|
||||||
@@ -1116,6 +1187,27 @@ impl eframe::App for App {
|
|||||||
};
|
};
|
||||||
ui::matcher::show(ctx, open, &mut art, settings.provider, &mut actions);
|
ui::matcher::show(ctx, open, &mut art, settings.provider, &mut actions);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if let Some(key) = confirm_partial_delete.clone() {
|
||||||
|
egui::Window::new("Delete partial download?")
|
||||||
|
.collapsible(false)
|
||||||
|
.resizable(false)
|
||||||
|
.anchor(egui::Align2::CENTER_CENTER, egui::Vec2::ZERO)
|
||||||
|
.show(ctx, |ui| {
|
||||||
|
ui.label(
|
||||||
|
"This discards downloaded partial bytes. The online source is not changed.",
|
||||||
|
);
|
||||||
|
ui.horizontal(|ui| {
|
||||||
|
if ui.button("Keep partial").clicked() {
|
||||||
|
*confirm_partial_delete = None;
|
||||||
|
}
|
||||||
|
if ui.button("Delete partial").clicked() {
|
||||||
|
engine.remove_download(key.clone(), true);
|
||||||
|
*confirm_partial_delete = None;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
for action in actions {
|
for action in actions {
|
||||||
@@ -1136,6 +1228,8 @@ impl eframe::App for App {
|
|||||||
playback.stop_and_wait(std::time::Duration::from_secs(2));
|
playback.stop_and_wait(std::time::Duration::from_secs(2));
|
||||||
}
|
}
|
||||||
self.playback = None;
|
self.playback = None;
|
||||||
|
self.engine
|
||||||
|
.shutdown_downloads(std::time::Duration::from_secs(3));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
File diff suppressed because it is too large.
Load diff
@@ -55,7 +55,7 @@ const SAVE_EVERY: std::time::Duration = std::time::Duration::from_secs(5);
|
|||||||
const POLL_TIMEOUT: f64 = 0.05;
|
const POLL_TIMEOUT: f64 = 0.05;
|
||||||
|
|
||||||
/// What is being played, and where it came from.
|
/// What is being played, and where it came from.
|
||||||
#[derive(Debug, Clone)]
|
#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
|
||||||
pub struct PlaybackTarget {
|
pub struct PlaybackTarget {
|
||||||
pub share_id: String,
|
pub share_id: String,
|
||||||
pub volume_id: String,
|
pub volume_id: String,
|
||||||
@@ -76,6 +76,8 @@ pub struct PlaybackTarget {
|
|||||||
pub episode_name: Option<String>,
|
pub episode_name: Option<String>,
|
||||||
/// Where to resume, when the viewer had been here before.
|
/// Where to resume, when the viewer had been here before.
|
||||||
pub resume_at: Option<f64>,
|
pub resume_at: Option<f64>,
|
||||||
|
/// Per-show track choices, filled by the app just before opening.
|
||||||
|
pub track_prefs: Option<Box<pstr_core::catalog::TitleTrackPrefs>>,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl PlaybackTarget {
|
impl PlaybackTarget {
|
||||||
@@ -102,6 +104,7 @@ impl PlaybackTarget {
|
|||||||
number: node.parsed.episode,
|
number: node.parsed.episode,
|
||||||
episode_name: None,
|
episode_name: None,
|
||||||
resume_at,
|
resume_at,
|
||||||
|
track_prefs: None,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -374,7 +377,7 @@ fn build_player(
|
|||||||
// controller would be a second set of controls over the same file.
|
// controller would be a second set of controls over the same file.
|
||||||
on_screen_controller: false,
|
on_screen_controller: false,
|
||||||
default_keybindings: false,
|
default_keybindings: false,
|
||||||
..from_prefs(prefs)
|
..from_prefs(prefs, target.track_prefs.as_deref())
|
||||||
};
|
};
|
||||||
|
|
||||||
match Player::new(engine.runtime().clone(), config) {
|
match Player::new(engine.runtime().clone(), config) {
|
||||||
@@ -403,7 +406,7 @@ fn build_player(
|
|||||||
|
|
||||||
let config = PlayerConfig {
|
let config = PlayerConfig {
|
||||||
window_title: title,
|
window_title: title,
|
||||||
..from_prefs(prefs)
|
..from_prefs(prefs, target.track_prefs.as_deref())
|
||||||
};
|
};
|
||||||
let player = Player::new(engine.runtime().clone(), config)
|
let player = Player::new(engine.runtime().clone(), config)
|
||||||
.map_err(|error| format!("start mpv: {error}"))?;
|
.map_err(|error| format!("start mpv: {error}"))?;
|
||||||
@@ -411,13 +414,20 @@ fn build_player(
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// The player defaults with the viewer's preferences folded in.
|
/// The player defaults with the viewer's preferences folded in.
|
||||||
fn from_prefs(prefs: &PlaybackPrefs) -> PlayerConfig {
|
fn from_prefs(
|
||||||
|
prefs: &PlaybackPrefs,
|
||||||
|
show: Option<&pstr_core::catalog::TitleTrackPrefs>,
|
||||||
|
) -> PlayerConfig {
|
||||||
PlayerConfig {
|
PlayerConfig {
|
||||||
volume: prefs.volume,
|
volume: prefs.volume,
|
||||||
muted: prefs.muted,
|
muted: prefs.muted,
|
||||||
audio_language: prefs.audio_language.clone(),
|
audio_language: show
|
||||||
subtitle_language: prefs.subtitle_language.clone(),
|
.and_then(|p| p.audio_language.clone())
|
||||||
subtitles: prefs.subtitles,
|
.or_else(|| prefs.audio_language.clone()),
|
||||||
|
subtitle_language: show
|
||||||
|
.and_then(|p| p.subtitle_language.clone())
|
||||||
|
.or_else(|| prefs.subtitle_language.clone()),
|
||||||
|
subtitles: show.map(|p| p.subtitles).unwrap_or(prefs.subtitles),
|
||||||
..PlayerConfig::default()
|
..PlayerConfig::default()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -36,6 +36,61 @@ pub const CARD_WIDTH: f32 = 232.0;
|
|||||||
pub const CARD_ASPECT: f32 = 9.0 / 16.0;
|
pub const CARD_ASPECT: f32 = 9.0 / 16.0;
|
||||||
pub const CARD_GAP: f32 = 14.0;
|
pub const CARD_GAP: f32 = 14.0;
|
||||||
|
|
||||||
|
/// Install a real CJK fallback before the default egui fonts. egui ships a
|
||||||
|
/// compact Latin font, but it intentionally does not bundle the multi-megabyte
|
||||||
|
/// CJK families. Native desktops already provide one, so use it when present.
|
||||||
|
pub fn install_font_fallbacks(ctx: &egui::Context) {
|
||||||
|
let Some((path, index)) = system_cjk_font() else {
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
let Ok(bytes) = std::fs::read(path) else {
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
|
||||||
|
let mut fonts = egui::FontDefinitions::default();
|
||||||
|
let mut data = egui::FontData::from_owned(bytes);
|
||||||
|
data.index = index;
|
||||||
|
let name = "system-cjk-fallback".to_owned();
|
||||||
|
fonts.font_data.insert(name.clone(), Arc::new(data));
|
||||||
|
for family in [egui::FontFamily::Proportional, egui::FontFamily::Monospace] {
|
||||||
|
fonts.families.entry(family).or_default().push(name.clone());
|
||||||
|
}
|
||||||
|
ctx.set_fonts(fonts);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(target_os = "linux")]
|
||||||
|
fn system_cjk_font() -> Option<(std::path::PathBuf, u32)> {
|
||||||
|
let output = std::process::Command::new("fc-match")
|
||||||
|
.args(["-f", "%{file}\\n%{index}", "Noto Sans CJK JP"])
|
||||||
|
.output()
|
||||||
|
.ok()?;
|
||||||
|
let text = std::str::from_utf8(&output.stdout).ok()?;
|
||||||
|
let mut lines = text.lines();
|
||||||
|
let path = std::path::PathBuf::from(lines.next()?);
|
||||||
|
let index = lines
|
||||||
|
.next()
|
||||||
|
.and_then(|value| value.parse().ok())
|
||||||
|
.unwrap_or(0);
|
||||||
|
path.is_file().then_some((path, index))
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(target_os = "windows")]
|
||||||
|
fn system_cjk_font() -> Option<(std::path::PathBuf, u32)> {
|
||||||
|
let path = std::path::PathBuf::from(r"C:\Windows\Fonts\YuGothR.ttc");
|
||||||
|
path.is_file().then_some((path, 0))
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(target_os = "macos")]
|
||||||
|
fn system_cjk_font() -> Option<(std::path::PathBuf, u32)> {
|
||||||
|
let path = std::path::PathBuf::from("/System/Library/Fonts/ヒラギノ角ゴシック W3.ttc");
|
||||||
|
path.is_file().then_some((path, 0))
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(not(any(target_os = "linux", target_os = "windows", target_os = "macos")))]
|
||||||
|
fn system_cjk_font() -> Option<(std::path::PathBuf, u32)> {
|
||||||
|
None
|
||||||
|
}
|
||||||
|
|
||||||
/// Every colour the app draws with, resolved from a [`Flavor`] and an
|
/// Every colour the app draws with, resolved from a [`Flavor`] and an
|
||||||
/// [`Accent`].
|
/// [`Accent`].
|
||||||
///
|
///
|
||||||
|
|||||||
@@ -0,0 +1,180 @@
|
|||||||
|
//! Inspectable desktop download queue.
|
||||||
|
|
||||||
|
use crate::app::Action;
|
||||||
|
use crate::engine::{DownloadItem, DownloadState};
|
||||||
|
use crate::{theme, ui};
|
||||||
|
|
||||||
|
pub fn show(ui: &mut egui::Ui, downloads: &[DownloadItem], actions: &mut Vec<Action>) {
|
||||||
|
ui.heading("Downloads");
|
||||||
|
ui.label(ui::muted(
|
||||||
|
"Pause and Cancel keep complete downloaded blocks. Delete partial discards them.",
|
||||||
|
));
|
||||||
|
ui.add_space(12.0);
|
||||||
|
|
||||||
|
if downloads.is_empty() {
|
||||||
|
ui.label("No offline downloads.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
let mut start = 0;
|
||||||
|
while start < downloads.len() {
|
||||||
|
let title_key = &downloads[start].target.title_key;
|
||||||
|
let title = &downloads[start].target.title_name;
|
||||||
|
let end = downloads[start..]
|
||||||
|
.iter()
|
||||||
|
.position(|item| item.target.title_key != *title_key)
|
||||||
|
.map_or(downloads.len(), |offset| start + offset);
|
||||||
|
let group = &downloads[start..end];
|
||||||
|
let downloaded: u64 = group.iter().map(|item| item.downloaded).sum();
|
||||||
|
let total: u64 = group.iter().map(|item| item.total).sum();
|
||||||
|
let completed = group
|
||||||
|
.iter()
|
||||||
|
.filter(|item| item.state == DownloadState::Completed)
|
||||||
|
.count();
|
||||||
|
|
||||||
|
ui.label(egui::RichText::new(title).size(17.0).strong());
|
||||||
|
let totals_known = group.iter().all(|item| item.total > 0);
|
||||||
|
ui.horizontal(|ui| {
|
||||||
|
if totals_known {
|
||||||
|
ui.add(
|
||||||
|
egui::ProgressBar::new(fraction(downloaded, total))
|
||||||
|
.desired_width(260.0)
|
||||||
|
.show_percentage(),
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
ui.add(egui::Spinner::new().size(16.0));
|
||||||
|
ui.label(ui::muted("Calculating size…"));
|
||||||
|
}
|
||||||
|
ui.label(ui::muted(format!(
|
||||||
|
"{} / {} · {completed}/{} files",
|
||||||
|
bytes(downloaded),
|
||||||
|
if !totals_known {
|
||||||
|
"calculating…".to_owned()
|
||||||
|
} else {
|
||||||
|
bytes(total)
|
||||||
|
},
|
||||||
|
group.len()
|
||||||
|
)));
|
||||||
|
});
|
||||||
|
for item in group {
|
||||||
|
row(ui, item, actions);
|
||||||
|
}
|
||||||
|
ui.add_space(12.0);
|
||||||
|
start = end;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn row(ui: &mut egui::Ui, item: &DownloadItem, actions: &mut Vec<Action>) {
|
||||||
|
egui::Frame::new()
|
||||||
|
.fill(theme::card())
|
||||||
|
.corner_radius(egui::CornerRadius::same(6))
|
||||||
|
.inner_margin(egui::Margin::symmetric(10, 8))
|
||||||
|
.show(ui, |ui| {
|
||||||
|
ui.set_width(ui.available_width());
|
||||||
|
ui.horizontal(|ui| {
|
||||||
|
let caption = if item.target.subtitle.is_empty() {
|
||||||
|
item.target.name.as_str()
|
||||||
|
} else {
|
||||||
|
item.target.subtitle.as_str()
|
||||||
|
};
|
||||||
|
ui.add_sized(
|
||||||
|
[180.0, 18.0],
|
||||||
|
egui::Label::new(egui::RichText::new(caption).strong()).truncate(),
|
||||||
|
)
|
||||||
|
.on_hover_text(&item.target.name);
|
||||||
|
|
||||||
|
let status = match &item.state {
|
||||||
|
DownloadState::Queued => "Queued".to_owned(),
|
||||||
|
DownloadState::Running => "Downloading".to_owned(),
|
||||||
|
DownloadState::Paused => "Paused".to_owned(),
|
||||||
|
DownloadState::Completed => "Available offline".to_owned(),
|
||||||
|
DownloadState::Cancelled => "Cancelled · partial kept".to_owned(),
|
||||||
|
DownloadState::Failed(error) => format!("Failed: {error}"),
|
||||||
|
};
|
||||||
|
ui.add_sized(
|
||||||
|
[240.0, 18.0],
|
||||||
|
egui::Label::new(ui::muted(status)).truncate(),
|
||||||
|
);
|
||||||
|
|
||||||
|
if item.total > 0 {
|
||||||
|
ui.add(
|
||||||
|
egui::ProgressBar::new(item.percent())
|
||||||
|
.desired_width(140.0)
|
||||||
|
.show_percentage(),
|
||||||
|
);
|
||||||
|
ui.label(ui::muted(format!(
|
||||||
|
"{} / {}",
|
||||||
|
bytes(item.downloaded),
|
||||||
|
bytes(item.total)
|
||||||
|
)));
|
||||||
|
}
|
||||||
|
|
||||||
|
ui.with_layout(
|
||||||
|
egui::Layout::right_to_left(egui::Align::Center),
|
||||||
|
|ui| match item.state {
|
||||||
|
DownloadState::Queued | DownloadState::Running => {
|
||||||
|
if ui
|
||||||
|
.button("Cancel")
|
||||||
|
.on_hover_text("Stop and keep partial")
|
||||||
|
.clicked()
|
||||||
|
{
|
||||||
|
actions.push(Action::CancelDownload(item.key.clone()));
|
||||||
|
}
|
||||||
|
if ui.button("Pause").clicked() {
|
||||||
|
actions.push(Action::PauseDownload(item.key.clone()));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
DownloadState::Paused => {
|
||||||
|
if ui
|
||||||
|
.button("Cancel")
|
||||||
|
.on_hover_text("Stop and keep partial")
|
||||||
|
.clicked()
|
||||||
|
{
|
||||||
|
actions.push(Action::CancelDownload(item.key.clone()));
|
||||||
|
}
|
||||||
|
if ui.button("Resume").clicked() {
|
||||||
|
actions.push(Action::ResumeDownload(item.key.clone()));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
DownloadState::Cancelled | DownloadState::Failed(_) => {
|
||||||
|
if ui.button("Delete partial…").clicked() {
|
||||||
|
actions.push(Action::RemoveDownload(item.key.clone(), true));
|
||||||
|
}
|
||||||
|
if ui.button("Resume / Retry").clicked() {
|
||||||
|
actions.push(Action::ResumeDownload(item.key.clone()));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
DownloadState::Completed => {
|
||||||
|
if ui.button("Make online-only").clicked() {
|
||||||
|
actions.push(Action::RemoveDownload(item.key.clone(), false));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
fn fraction(downloaded: u64, total: u64) -> f32 {
|
||||||
|
if total == 0 {
|
||||||
|
0.0
|
||||||
|
} else {
|
||||||
|
(downloaded as f64 / total as f64).clamp(0.0, 1.0) as f32
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn bytes(value: u64) -> String {
|
||||||
|
const KIB: f64 = 1024.0;
|
||||||
|
const MIB: f64 = 1024.0 * KIB;
|
||||||
|
const GIB: f64 = 1024.0 * MIB;
|
||||||
|
let value = value as f64;
|
||||||
|
if value >= GIB {
|
||||||
|
format!("{:.1} GiB", value / GIB)
|
||||||
|
} else if value >= MIB {
|
||||||
|
format!("{:.1} MiB", value / MIB)
|
||||||
|
} else if value >= KIB {
|
||||||
|
format!("{:.1} KiB", value / KIB)
|
||||||
|
} else {
|
||||||
|
format!("{value:.0} B")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -3,6 +3,7 @@
|
|||||||
//! mutating the library it is iterating: pages collect [`Action`]s and the app
|
//! mutating the library it is iterating: pages collect [`Action`]s and the app
|
||||||
//! applies them after the frame is drawn.
|
//! applies them after the frame is drawn.
|
||||||
|
|
||||||
|
pub mod downloads;
|
||||||
pub mod library;
|
pub mod library;
|
||||||
pub mod matcher;
|
pub mod matcher;
|
||||||
pub mod player;
|
pub mod player;
|
||||||
|
|||||||
@@ -345,6 +345,7 @@ fn add_form(ui: &mut egui::Ui, form: &mut ShareForm, actions: &mut Vec<Action>)
|
|||||||
ui.label(ui::muted("Link"));
|
ui.label(ui::muted("Link"));
|
||||||
ui.add(
|
ui.add(
|
||||||
egui::TextEdit::singleline(&mut form.url)
|
egui::TextEdit::singleline(&mut form.url)
|
||||||
|
.password(true)
|
||||||
.hint_text("https://drive.proton.me/urls/…#…")
|
.hint_text("https://drive.proton.me/urls/…#…")
|
||||||
.desired_width(f32::INFINITY),
|
.desired_width(f32::INFINITY),
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -5,15 +5,22 @@ use pstr_core::library::{Episode, Library, Season, Title, TitleKind};
|
|||||||
use pstr_core::metadata::TitleMetadata;
|
use pstr_core::metadata::TitleMetadata;
|
||||||
|
|
||||||
use crate::app::{Action, Page};
|
use crate::app::{Action, Page};
|
||||||
|
use crate::engine::{DownloadItem, DownloadKey, DownloadState};
|
||||||
use crate::playback::PlaybackTarget;
|
use crate::playback::PlaybackTarget;
|
||||||
use crate::theme;
|
use crate::theme;
|
||||||
use crate::ui::{self, Art, Card};
|
use crate::ui::{self, Art, Card};
|
||||||
|
|
||||||
|
pub struct OfflineView<'a> {
|
||||||
|
pub downloads: &'a [DownloadItem],
|
||||||
|
pub files: &'a std::collections::HashSet<DownloadKey>,
|
||||||
|
}
|
||||||
|
|
||||||
pub fn show(
|
pub fn show(
|
||||||
ui: &mut egui::Ui,
|
ui: &mut egui::Ui,
|
||||||
art: &mut Art<'_>,
|
art: &mut Art<'_>,
|
||||||
library: &Library,
|
library: &Library,
|
||||||
key: &str,
|
key: &str,
|
||||||
|
offline: OfflineView<'_>,
|
||||||
actions: &mut Vec<Action>,
|
actions: &mut Vec<Action>,
|
||||||
) {
|
) {
|
||||||
let Some(title) = library.get(key) else {
|
let Some(title) = library.get(key) else {
|
||||||
@@ -31,7 +38,7 @@ pub fn show(
|
|||||||
egui::ScrollArea::vertical()
|
egui::ScrollArea::vertical()
|
||||||
.auto_shrink([false, false])
|
.auto_shrink([false, false])
|
||||||
.show(ui, |ui| {
|
.show(ui, |ui| {
|
||||||
header(ui, art, title, actions);
|
header(ui, art, title, &offline, actions);
|
||||||
ui.add_space(18.0);
|
ui.add_space(18.0);
|
||||||
|
|
||||||
for (index, season) in title.seasons.iter().enumerate() {
|
for (index, season) in title.seasons.iter().enumerate() {
|
||||||
@@ -48,8 +55,29 @@ pub fn show(
|
|||||||
.id_salt(("season", index))
|
.id_salt(("season", index))
|
||||||
.default_open(open)
|
.default_open(open)
|
||||||
.show(ui, |ui| {
|
.show(ui, |ui| {
|
||||||
|
let season_keys: Vec<_> = season.episodes.iter().map(key_of).collect();
|
||||||
|
let all_offline = season_keys.iter().all(|key| offline.files.contains(key));
|
||||||
|
if all_offline {
|
||||||
|
if ui.button("Make season online-only").clicked() {
|
||||||
|
for key in season_keys {
|
||||||
|
actions.push(Action::RemoveDownload(key, false));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else if ui
|
||||||
|
.button("Make season offline")
|
||||||
|
.on_hover_text("Download every episode in this season")
|
||||||
|
.clicked()
|
||||||
|
{
|
||||||
|
actions.push(Action::MakeOffline(
|
||||||
|
season
|
||||||
|
.episodes
|
||||||
|
.iter()
|
||||||
|
.map(|episode| PlaybackTarget::new(title, episode))
|
||||||
|
.collect(),
|
||||||
|
));
|
||||||
|
}
|
||||||
for episode in &season.episodes {
|
for episode in &season.episodes {
|
||||||
episode_row(ui, art, title, season, episode, actions);
|
episode_row(ui, art, title, season, episode, &offline, actions);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
ui.add_space(6.0);
|
ui.add_space(6.0);
|
||||||
@@ -58,7 +86,13 @@ pub fn show(
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// The still, the name and the one button that matters.
|
/// The still, the name and the one button that matters.
|
||||||
fn header(ui: &mut egui::Ui, art: &mut Art<'_>, title: &Title, actions: &mut Vec<Action>) {
|
fn header(
|
||||||
|
ui: &mut egui::Ui,
|
||||||
|
art: &mut Art<'_>,
|
||||||
|
title: &Title,
|
||||||
|
offline: &OfflineView<'_>,
|
||||||
|
actions: &mut Vec<Action>,
|
||||||
|
) {
|
||||||
// Cloned out of the borrow: `art` is held mutably for the card below, and
|
// Cloned out of the borrow: `art` is held mutably for the card below, and
|
||||||
// the description is drawn beside it.
|
// the description is drawn beside it.
|
||||||
let record = art.metadata.get(&title.key);
|
let record = art.metadata.get(&title.key);
|
||||||
@@ -110,6 +144,44 @@ fn header(ui: &mut egui::Ui, art: &mut Art<'_>, title: &Title, actions: &mut Vec
|
|||||||
if ui::accent_button(ui, &label).clicked() {
|
if ui::accent_button(ui, &label).clicked() {
|
||||||
actions.push(Action::Play(PlaybackTarget::new(title, episode)));
|
actions.push(Action::Play(PlaybackTarget::new(title, episode)));
|
||||||
}
|
}
|
||||||
|
let title_keys: Vec<_> = title.episodes().map(key_of).collect();
|
||||||
|
let all_offline = title_keys.iter().all(|key| offline.files.contains(key));
|
||||||
|
if all_offline {
|
||||||
|
if ui.button("Make show online-only").clicked() {
|
||||||
|
for key in title_keys {
|
||||||
|
actions.push(Action::RemoveDownload(key, false));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else if ui
|
||||||
|
.button("Make show offline")
|
||||||
|
.on_hover_text("Download every episode for disconnected playback")
|
||||||
|
.clicked()
|
||||||
|
{
|
||||||
|
actions.push(Action::MakeOffline(
|
||||||
|
title
|
||||||
|
.episodes()
|
||||||
|
.map(|episode| PlaybackTarget::new(title, episode))
|
||||||
|
.collect(),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
let active = offline
|
||||||
|
.downloads
|
||||||
|
.iter()
|
||||||
|
.filter(|item| {
|
||||||
|
item.target.title_key == title.key
|
||||||
|
&& matches!(
|
||||||
|
item.state,
|
||||||
|
DownloadState::Queued
|
||||||
|
| DownloadState::Running
|
||||||
|
| DownloadState::Paused
|
||||||
|
)
|
||||||
|
})
|
||||||
|
.count();
|
||||||
|
if active > 0 {
|
||||||
|
ui.label(ui::muted(format!("{active} downloading")));
|
||||||
|
} else if all_offline {
|
||||||
|
ui.label(ui::muted("Available offline"));
|
||||||
|
}
|
||||||
if resuming && let Some(at) = episode.resume_at() {
|
if resuming && let Some(at) = episode.resume_at() {
|
||||||
ui.label(ui::muted(format!("at {}", ui::format_time(at))));
|
ui.label(ui::muted(format!("at {}", ui::format_time(at))));
|
||||||
if ui
|
if ui
|
||||||
@@ -225,6 +297,7 @@ fn episode_row(
|
|||||||
title: &Title,
|
title: &Title,
|
||||||
season: &Season,
|
season: &Season,
|
||||||
episode: &Episode,
|
episode: &Episode,
|
||||||
|
offline: &OfflineView<'_>,
|
||||||
actions: &mut Vec<Action>,
|
actions: &mut Vec<Action>,
|
||||||
) {
|
) {
|
||||||
// Cloned out before `ui` borrows: the row draws while `art` is held.
|
// Cloned out before `ui` borrows: the row draws while `art` is held.
|
||||||
@@ -260,6 +333,50 @@ fn episode_row(
|
|||||||
{
|
{
|
||||||
actions.push(Action::Play(PlaybackTarget::new(title, episode)));
|
actions.push(Action::Play(PlaybackTarget::new(title, episode)));
|
||||||
}
|
}
|
||||||
|
let key = key_of(episode);
|
||||||
|
let current = offline.downloads.iter().find(|item| item.key == key);
|
||||||
|
if offline.files.contains(&key) {
|
||||||
|
if ui
|
||||||
|
.small_button("Online-only")
|
||||||
|
.on_hover_text("Delete the offline copy; keep the online source")
|
||||||
|
.clicked()
|
||||||
|
{
|
||||||
|
actions.push(Action::RemoveDownload(key.clone(), false));
|
||||||
|
}
|
||||||
|
ui.label(ui::muted("✓ Offline"));
|
||||||
|
} else if let Some(item) = current {
|
||||||
|
match item.state {
|
||||||
|
DownloadState::Running | DownloadState::Queued => {
|
||||||
|
if ui.small_button("Pause").clicked() {
|
||||||
|
actions.push(Action::PauseDownload(key.clone()));
|
||||||
|
}
|
||||||
|
ui.label(ui::muted(format!("↓ {:.0}%", item.percent() * 100.0)));
|
||||||
|
}
|
||||||
|
DownloadState::Paused => {
|
||||||
|
if ui.small_button("Resume").clicked() {
|
||||||
|
actions.push(Action::ResumeDownload(key.clone()));
|
||||||
|
}
|
||||||
|
ui.label(ui::muted("Paused · partial kept"));
|
||||||
|
}
|
||||||
|
DownloadState::Cancelled | DownloadState::Failed(_) => {
|
||||||
|
if ui.small_button("Resume").clicked() {
|
||||||
|
actions.push(Action::ResumeDownload(key.clone()));
|
||||||
|
}
|
||||||
|
ui.label(ui::muted("Partial"));
|
||||||
|
}
|
||||||
|
DownloadState::Completed => {
|
||||||
|
ui.label(ui::muted("✓ Offline"));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else if ui
|
||||||
|
.small_button("Download")
|
||||||
|
.on_hover_text("Make available offline")
|
||||||
|
.clicked()
|
||||||
|
{
|
||||||
|
actions.push(Action::MakeOffline(vec![PlaybackTarget::new(
|
||||||
|
title, episode,
|
||||||
|
)]));
|
||||||
|
}
|
||||||
|
|
||||||
let numbering = episode
|
let numbering = episode
|
||||||
.numbering()
|
.numbering()
|
||||||
@@ -352,3 +469,10 @@ fn episode_row(
|
|||||||
});
|
});
|
||||||
ui.add_space(4.0);
|
ui.add_space(4.0);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn key_of(episode: &Episode) -> DownloadKey {
|
||||||
|
DownloadKey {
|
||||||
|
share_id: episode.node.share_id.clone(),
|
||||||
|
link_id: episode.node.link_id.clone(),
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -19,7 +19,6 @@ thiserror.workspace = true
|
|||||||
tracing.workspace = true
|
tracing.workspace = true
|
||||||
directories.workspace = true
|
directories.workspace = true
|
||||||
rusqlite.workspace = true
|
rusqlite.workspace = true
|
||||||
keyring.workspace = true
|
|
||||||
sha2.workspace = true
|
sha2.workspace = true
|
||||||
|
|
||||||
# The credential store, per platform. Without the target's feature `keyring`
|
# The credential store, per platform. Without the target's feature `keyring`
|
||||||
|
|||||||
@@ -21,7 +21,7 @@ use crate::metadata::{EpisodeGuide, EpisodeMetadata, MetadataRecord, ProviderId,
|
|||||||
use crate::naming::{self, ParsedName};
|
use crate::naming::{self, ParsedName};
|
||||||
|
|
||||||
/// Bump when adding a migration.
|
/// Bump when adding a migration.
|
||||||
pub const SCHEMA_VERSION: i64 = 5;
|
pub const SCHEMA_VERSION: i64 = 7;
|
||||||
|
|
||||||
const MIGRATION_V1: &str = r#"
|
const MIGRATION_V1: &str = r#"
|
||||||
CREATE TABLE nodes (
|
CREATE TABLE nodes (
|
||||||
@@ -128,6 +128,46 @@ const MIGRATION_V5: &str = r#"
|
|||||||
ALTER TABLE title_metadata ADD COLUMN manual INTEGER NOT NULL DEFAULT 0;
|
ALTER TABLE title_metadata ADD COLUMN manual INTEGER NOT NULL DEFAULT 0;
|
||||||
"#;
|
"#;
|
||||||
|
|
||||||
|
/// Content kept explicitly by the viewer. Unlike the block cache, these rows
|
||||||
|
/// describe complete local files and therefore survive a disconnected launch.
|
||||||
|
const MIGRATION_V6: &str = r#"
|
||||||
|
CREATE TABLE offline_files (
|
||||||
|
share_id TEXT NOT NULL, link_id TEXT NOT NULL, revision_id TEXT NOT NULL,
|
||||||
|
block_sizes TEXT NOT NULL,
|
||||||
|
PRIMARY KEY (share_id, link_id)
|
||||||
|
);
|
||||||
|
"#;
|
||||||
|
|
||||||
|
/// Language choices are a property of a show, not of an episode's muxing.
|
||||||
|
const MIGRATION_V7: &str = r#"
|
||||||
|
CREATE TABLE title_track_prefs (
|
||||||
|
title_key TEXT PRIMARY KEY, audio_language TEXT, subtitle_language TEXT,
|
||||||
|
subtitles INTEGER NOT NULL DEFAULT 1
|
||||||
|
);
|
||||||
|
"#;
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||||
|
pub struct OfflineFile {
|
||||||
|
pub revision_id: String,
|
||||||
|
pub block_sizes: Vec<u64>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
|
||||||
|
pub struct TitleTrackPrefs {
|
||||||
|
pub audio_language: Option<String>,
|
||||||
|
pub subtitle_language: Option<String>,
|
||||||
|
pub subtitles: bool,
|
||||||
|
}
|
||||||
|
impl Default for TitleTrackPrefs {
|
||||||
|
fn default() -> Self {
|
||||||
|
Self {
|
||||||
|
audio_language: None,
|
||||||
|
subtitle_language: None,
|
||||||
|
subtitles: true,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// What `season = -1` means in `episode_metadata`: no season at all.
|
/// What `season = -1` means in `episode_metadata`: no season at all.
|
||||||
const ABSOLUTE_SEASON: i64 = -1;
|
const ABSOLUTE_SEASON: i64 = -1;
|
||||||
|
|
||||||
@@ -235,6 +275,18 @@ impl Catalog {
|
|||||||
tx.pragma_update(None, "user_version", 5)?;
|
tx.pragma_update(None, "user_version", 5)?;
|
||||||
tx.commit()?;
|
tx.commit()?;
|
||||||
}
|
}
|
||||||
|
if version < 6 {
|
||||||
|
let tx = self.conn.transaction()?;
|
||||||
|
tx.execute_batch(MIGRATION_V6)?;
|
||||||
|
tx.pragma_update(None, "user_version", 6)?;
|
||||||
|
tx.commit()?;
|
||||||
|
}
|
||||||
|
if version < 7 {
|
||||||
|
let tx = self.conn.transaction()?;
|
||||||
|
tx.execute_batch(MIGRATION_V7)?;
|
||||||
|
tx.pragma_update(None, "user_version", 7)?;
|
||||||
|
tx.commit()?;
|
||||||
|
}
|
||||||
|
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
@@ -245,6 +297,25 @@ impl Catalog {
|
|||||||
/// intact rather than a half-replaced one. Watch state is keyed separately
|
/// intact rather than a half-replaced one. Watch state is keyed separately
|
||||||
/// and deliberately untouched — a recrawl must not lose where you were.
|
/// and deliberately untouched — a recrawl must not lose where you were.
|
||||||
pub fn replace_share(&mut self, share_id: &str, nodes: &[CatalogNode]) -> Result<()> {
|
pub fn replace_share(&mut self, share_id: &str, nodes: &[CatalogNode]) -> Result<()> {
|
||||||
|
self.replace_share_inner(share_id, nodes, false)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Replace nodes while retaining stale offline path keys for explicit
|
||||||
|
/// filesystem cleanup by a front end.
|
||||||
|
pub fn replace_share_retaining_offline(
|
||||||
|
&mut self,
|
||||||
|
share_id: &str,
|
||||||
|
nodes: &[CatalogNode],
|
||||||
|
) -> Result<()> {
|
||||||
|
self.replace_share_inner(share_id, nodes, true)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn replace_share_inner(
|
||||||
|
&mut self,
|
||||||
|
share_id: &str,
|
||||||
|
nodes: &[CatalogNode],
|
||||||
|
retain_offline: bool,
|
||||||
|
) -> Result<()> {
|
||||||
let tx = self.conn.transaction()?;
|
let tx = self.conn.transaction()?;
|
||||||
tx.execute("DELETE FROM nodes WHERE share_id = ?1", params![share_id])?;
|
tx.execute("DELETE FROM nodes WHERE share_id = ?1", params![share_id])?;
|
||||||
|
|
||||||
@@ -276,6 +347,10 @@ impl Catalog {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if !retain_offline {
|
||||||
|
tx.execute("DELETE FROM offline_files WHERE share_id = ?1 AND NOT EXISTS (SELECT 1 FROM nodes WHERE nodes.share_id = offline_files.share_id AND nodes.link_id = offline_files.link_id AND nodes.active_revision_id = offline_files.revision_id)", params![share_id])?;
|
||||||
|
}
|
||||||
|
|
||||||
tx.commit()?;
|
tx.commit()?;
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
@@ -314,16 +389,141 @@ impl Catalog {
|
|||||||
/// state alone — this is the viewer saying they are done with the share.
|
/// state alone — this is the viewer saying they are done with the share.
|
||||||
/// Keeping positions for files they can no longer reach would only leave
|
/// Keeping positions for files they can no longer reach would only leave
|
||||||
/// the "continue watching" row pointing at nothing.
|
/// the "continue watching" row pointing at nothing.
|
||||||
pub fn remove_share(&self, share_id: &str) -> Result<()> {
|
pub fn remove_share(&mut self, share_id: &str) -> Result<()> {
|
||||||
self.conn
|
let tx = self.conn.transaction()?;
|
||||||
.execute("DELETE FROM nodes WHERE share_id = ?1", params![share_id])?;
|
tx.execute("DELETE FROM nodes WHERE share_id = ?1", params![share_id])?;
|
||||||
self.conn.execute(
|
tx.execute(
|
||||||
"DELETE FROM watch_state WHERE share_id = ?1",
|
"DELETE FROM watch_state WHERE share_id = ?1",
|
||||||
params![share_id],
|
params![share_id],
|
||||||
)?;
|
)?;
|
||||||
|
tx.execute(
|
||||||
|
"DELETE FROM offline_files WHERE share_id = ?1",
|
||||||
|
params![share_id],
|
||||||
|
)?;
|
||||||
|
tx.commit()?;
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub fn offline_file(&self, share_id: &str, link_id: &str) -> Result<Option<OfflineFile>> {
|
||||||
|
self.conn.query_row("SELECT revision_id, block_sizes FROM offline_files WHERE share_id=?1 AND link_id=?2", params![share_id, link_id], |row| {
|
||||||
|
let sizes: String = row.get(1)?;
|
||||||
|
Ok(OfflineFile { revision_id: row.get(0)?, block_sizes: serde_json::from_str(&sizes).map_err(|e| rusqlite::Error::FromSqlConversionFailure(1, rusqlite::types::Type::Text, Box::new(e)))? })
|
||||||
|
}).optional().map_err(Into::into)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn set_offline_file(
|
||||||
|
&self,
|
||||||
|
share_id: &str,
|
||||||
|
link_id: &str,
|
||||||
|
file: &OfflineFile,
|
||||||
|
) -> Result<()> {
|
||||||
|
let sizes = serde_json::to_string(&file.block_sizes)
|
||||||
|
.map_err(|e| crate::error::Error::Config(e.to_string()))?;
|
||||||
|
self.conn.execute("INSERT INTO offline_files (share_id,link_id,revision_id,block_sizes) VALUES (?1,?2,?3,?4) ON CONFLICT(share_id,link_id) DO UPDATE SET revision_id=excluded.revision_id,block_sizes=excluded.block_sizes", params![share_id,link_id,file.revision_id,sizes])?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Record a completed download and force its WAL transaction to stable
|
||||||
|
/// storage before the caller discards its recovery journal.
|
||||||
|
pub fn set_offline_file_durable(
|
||||||
|
&self,
|
||||||
|
share_id: &str,
|
||||||
|
link_id: &str,
|
||||||
|
file: &OfflineFile,
|
||||||
|
) -> Result<()> {
|
||||||
|
self.set_offline_file(share_id, link_id, file)?;
|
||||||
|
self.conn
|
||||||
|
.query_row("PRAGMA wal_checkpoint(FULL)", [], |_| Ok(()))?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Every completed local revision, keyed like watch state.
|
||||||
|
pub fn all_offline_files(&self) -> Result<HashMap<(String, String), OfflineFile>> {
|
||||||
|
let mut statement = self
|
||||||
|
.conn
|
||||||
|
.prepare("SELECT share_id, link_id, revision_id, block_sizes FROM offline_files")?;
|
||||||
|
let rows = statement.query_map([], |row| {
|
||||||
|
let sizes: String = row.get(3)?;
|
||||||
|
let block_sizes = serde_json::from_str(&sizes).map_err(|error| {
|
||||||
|
rusqlite::Error::FromSqlConversionFailure(
|
||||||
|
3,
|
||||||
|
rusqlite::types::Type::Text,
|
||||||
|
Box::new(error),
|
||||||
|
)
|
||||||
|
})?;
|
||||||
|
Ok((
|
||||||
|
(row.get(0)?, row.get(1)?),
|
||||||
|
OfflineFile {
|
||||||
|
revision_id: row.get(2)?,
|
||||||
|
block_sizes,
|
||||||
|
},
|
||||||
|
))
|
||||||
|
})?;
|
||||||
|
Ok(rows.collect::<std::result::Result<HashMap<_, _>, _>>()?)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Completed local revisions belonging to one share.
|
||||||
|
///
|
||||||
|
/// Callers that replace or remove a share need the paths before deleting
|
||||||
|
/// the index rows. Keeping this query explicit prevents an SQL cascade
|
||||||
|
/// from orphaning opaque media files on disk.
|
||||||
|
pub fn offline_files_for_share(&self, share_id: &str) -> Result<HashMap<String, OfflineFile>> {
|
||||||
|
let mut statement = self.conn.prepare(
|
||||||
|
"SELECT link_id, revision_id, block_sizes FROM offline_files WHERE share_id=?1",
|
||||||
|
)?;
|
||||||
|
let rows = statement.query_map(params![share_id], |row| {
|
||||||
|
let sizes: String = row.get(2)?;
|
||||||
|
let block_sizes = serde_json::from_str(&sizes).map_err(|error| {
|
||||||
|
rusqlite::Error::FromSqlConversionFailure(
|
||||||
|
2,
|
||||||
|
rusqlite::types::Type::Text,
|
||||||
|
Box::new(error),
|
||||||
|
)
|
||||||
|
})?;
|
||||||
|
Ok((
|
||||||
|
row.get(0)?,
|
||||||
|
OfflineFile {
|
||||||
|
revision_id: row.get(1)?,
|
||||||
|
block_sizes,
|
||||||
|
},
|
||||||
|
))
|
||||||
|
})?;
|
||||||
|
Ok(rows.collect::<std::result::Result<HashMap<_, _>, _>>()?)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn remove_offline_file(&self, share_id: &str, link_id: &str) -> Result<()> {
|
||||||
|
self.conn.execute(
|
||||||
|
"DELETE FROM offline_files WHERE share_id=?1 AND link_id=?2",
|
||||||
|
params![share_id, link_id],
|
||||||
|
)?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn title_track_prefs(&self, title_key: &str) -> Result<Option<TitleTrackPrefs>> {
|
||||||
|
self.conn.query_row("SELECT audio_language, subtitle_language, subtitles FROM title_track_prefs WHERE title_key=?1", params![title_key], |r| Ok(TitleTrackPrefs { audio_language:r.get(0)?, subtitle_language:r.get(1)?, subtitles:r.get::<_,i64>(2)? != 0 })).optional().map_err(Into::into)
|
||||||
|
}
|
||||||
|
pub fn set_title_track_prefs(&self, title_key: &str, prefs: &TitleTrackPrefs) -> Result<()> {
|
||||||
|
self.conn.execute("INSERT INTO title_track_prefs (title_key,audio_language,subtitle_language,subtitles) VALUES (?1,?2,?3,?4) ON CONFLICT(title_key) DO UPDATE SET audio_language=excluded.audio_language,subtitle_language=excluded.subtitle_language,subtitles=excluded.subtitles", params![title_key,prefs.audio_language,prefs.subtitle_language,prefs.subtitles as i64])?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn all_title_track_prefs(&self) -> Result<HashMap<String, TitleTrackPrefs>> {
|
||||||
|
let mut statement = self.conn.prepare(
|
||||||
|
"SELECT title_key, audio_language, subtitle_language, subtitles FROM title_track_prefs",
|
||||||
|
)?;
|
||||||
|
let rows = statement.query_map([], |row| {
|
||||||
|
Ok((
|
||||||
|
row.get(0)?,
|
||||||
|
TitleTrackPrefs {
|
||||||
|
audio_language: row.get(1)?,
|
||||||
|
subtitle_language: row.get(2)?,
|
||||||
|
subtitles: row.get::<_, i64>(3)? != 0,
|
||||||
|
},
|
||||||
|
))
|
||||||
|
})?;
|
||||||
|
Ok(rows.collect::<std::result::Result<HashMap<_, _>, _>>()?)
|
||||||
|
}
|
||||||
|
|
||||||
/// Every watch position there is, keyed by `(share_id, link_id)`.
|
/// Every watch position there is, keyed by `(share_id, link_id)`.
|
||||||
///
|
///
|
||||||
/// What [`crate::library::Library::build`] joins against. A grid that asked
|
/// What [`crate::library::Library::build`] joins against. A grid that asked
|
||||||
@@ -1497,4 +1697,94 @@ mod tests {
|
|||||||
None
|
None
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn removing_an_offline_record_keeps_the_online_catalog_and_other_downloads() {
|
||||||
|
let catalog = Catalog::in_memory().expect("open");
|
||||||
|
let first = OfflineFile {
|
||||||
|
revision_id: "revision-1".to_owned(),
|
||||||
|
block_sizes: vec![3, 5],
|
||||||
|
};
|
||||||
|
let second = OfflineFile {
|
||||||
|
revision_id: "revision-2".to_owned(),
|
||||||
|
block_sizes: vec![7],
|
||||||
|
};
|
||||||
|
catalog
|
||||||
|
.set_offline_file("share", "first", &first)
|
||||||
|
.expect("record first");
|
||||||
|
catalog
|
||||||
|
.set_offline_file("share", "second", &second)
|
||||||
|
.expect("record second");
|
||||||
|
|
||||||
|
catalog
|
||||||
|
.remove_offline_file("share", "first")
|
||||||
|
.expect("remove first");
|
||||||
|
|
||||||
|
assert_eq!(
|
||||||
|
catalog.offline_file("share", "first").expect("read first"),
|
||||||
|
None
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
catalog
|
||||||
|
.offline_file("share", "second")
|
||||||
|
.expect("read second"),
|
||||||
|
Some(second)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn recrawl_retains_stale_offline_index_until_bytes_can_be_removed() {
|
||||||
|
let mut catalog = Catalog::in_memory().expect("open");
|
||||||
|
let mut old = file("share", "episode", "Show.S01E01.mkv");
|
||||||
|
old.active_revision_id = Some("old".to_owned());
|
||||||
|
catalog.replace_share("share", &[old]).expect("first crawl");
|
||||||
|
let offline = OfflineFile {
|
||||||
|
revision_id: "old".to_owned(),
|
||||||
|
block_sizes: vec![4, 7],
|
||||||
|
};
|
||||||
|
catalog
|
||||||
|
.set_offline_file("share", "episode", &offline)
|
||||||
|
.expect("record offline revision");
|
||||||
|
|
||||||
|
let mut new = file("share", "episode", "Show.S01E01.mkv");
|
||||||
|
new.active_revision_id = Some("new".to_owned());
|
||||||
|
catalog
|
||||||
|
.replace_share_retaining_offline("share", &[new])
|
||||||
|
.expect("recrawl");
|
||||||
|
|
||||||
|
assert_eq!(
|
||||||
|
catalog
|
||||||
|
.offline_file("share", "episode")
|
||||||
|
.expect("read retained path key"),
|
||||||
|
Some(offline),
|
||||||
|
"filesystem owner must delete old bytes before dropping this path key"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn ordinary_recrawl_prunes_stale_offline_index_for_legacy_callers() {
|
||||||
|
let mut catalog = Catalog::in_memory().expect("open");
|
||||||
|
let mut old = file("share", "episode", "Show.S01E01.mkv");
|
||||||
|
old.active_revision_id = Some("old".to_owned());
|
||||||
|
catalog.replace_share("share", &[old]).expect("first crawl");
|
||||||
|
catalog
|
||||||
|
.set_offline_file(
|
||||||
|
"share",
|
||||||
|
"episode",
|
||||||
|
&OfflineFile {
|
||||||
|
revision_id: "old".to_owned(),
|
||||||
|
block_sizes: vec![4],
|
||||||
|
},
|
||||||
|
)
|
||||||
|
.expect("offline row");
|
||||||
|
let mut new = file("share", "episode", "Show.S01E01.mkv");
|
||||||
|
new.active_revision_id = Some("new".to_owned());
|
||||||
|
catalog.replace_share("share", &[new]).expect("recrawl");
|
||||||
|
assert_eq!(
|
||||||
|
catalog
|
||||||
|
.offline_file("share", "episode")
|
||||||
|
.expect("read offline row"),
|
||||||
|
None
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
@@ -30,6 +30,25 @@ pub struct AppDirs {
|
|||||||
}
|
}
|
||||||
|
|
||||||
impl AppDirs {
|
impl AppDirs {
|
||||||
|
/// Use directories supplied by a platform host.
|
||||||
|
///
|
||||||
|
/// Android owns its application directories and exposes them through
|
||||||
|
/// `Context`; resolving them through desktop conventions would put state in
|
||||||
|
/// a path that does not exist inside the application sandbox.
|
||||||
|
pub fn from_paths(
|
||||||
|
config: impl Into<PathBuf>,
|
||||||
|
data: impl Into<PathBuf>,
|
||||||
|
cache: impl Into<PathBuf>,
|
||||||
|
) -> Result<Self> {
|
||||||
|
let dirs = Self {
|
||||||
|
config: config.into(),
|
||||||
|
data: data.into(),
|
||||||
|
cache: cache.into(),
|
||||||
|
};
|
||||||
|
dirs.create()?;
|
||||||
|
Ok(dirs)
|
||||||
|
}
|
||||||
|
|
||||||
/// Resolve the platform directories and create them.
|
/// Resolve the platform directories and create them.
|
||||||
pub fn ensure() -> Result<Self> {
|
pub fn ensure() -> Result<Self> {
|
||||||
let dirs =
|
let dirs =
|
||||||
@@ -43,10 +62,15 @@ impl AppDirs {
|
|||||||
cache: dirs.cache_dir().to_path_buf(),
|
cache: dirs.cache_dir().to_path_buf(),
|
||||||
};
|
};
|
||||||
|
|
||||||
for path in [&dirs.config, &dirs.data, &dirs.cache] {
|
dirs.create()?;
|
||||||
|
Ok(dirs)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn create(&self) -> Result<()> {
|
||||||
|
for path in [&self.config, &self.data, &self.cache] {
|
||||||
fs::create_dir_all(path)?;
|
fs::create_dir_all(path)?;
|
||||||
}
|
}
|
||||||
Ok(dirs)
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
/// The share list.
|
/// The share list.
|
||||||
@@ -64,6 +88,26 @@ impl AppDirs {
|
|||||||
self.cache.join("blocks")
|
self.cache.join("blocks")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Complete files explicitly kept for disconnected playback.
|
||||||
|
pub fn offline_content(&self) -> PathBuf {
|
||||||
|
self.data.join("offline")
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Opaque path for one complete offline revision. No media name or share
|
||||||
|
/// secret reaches the filesystem.
|
||||||
|
pub fn offline_file(&self, share_id: &str, link_id: &str, revision: &str) -> PathBuf {
|
||||||
|
use sha2::{Digest, Sha256};
|
||||||
|
let mut hash = Sha256::new();
|
||||||
|
hash.update(share_id.as_bytes());
|
||||||
|
hash.update([0]);
|
||||||
|
hash.update(link_id.as_bytes());
|
||||||
|
hash.update([0]);
|
||||||
|
hash.update(revision.as_bytes());
|
||||||
|
let digest = hash.finalize();
|
||||||
|
let name: String = digest.iter().map(|byte| format!("{byte:02x}")).collect();
|
||||||
|
self.offline_content().join(format!("{name}.media"))
|
||||||
|
}
|
||||||
|
|
||||||
/// Where decrypted poster thumbnails are kept.
|
/// Where decrypted poster thumbnails are kept.
|
||||||
///
|
///
|
||||||
/// Separate from the block cache because it is cheap, tiny and wanted on
|
/// Separate from the block cache because it is cheap, tiny and wanted on
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ pub enum Error {
|
|||||||
///
|
///
|
||||||
/// Boxed because `keyring::Error` is large and this variant is rare; an
|
/// Boxed because `keyring::Error` is large and this variant is rare; an
|
||||||
/// un-boxed one would inflate every `Result` in the crate.
|
/// un-boxed one would inflate every `Result` in the crate.
|
||||||
|
#[cfg(not(target_os = "android"))]
|
||||||
#[error("credential store: {0}")]
|
#[error("credential store: {0}")]
|
||||||
Keyring(#[from] Box<keyring::Error>),
|
Keyring(#[from] Box<keyring::Error>),
|
||||||
|
|
||||||
@@ -31,6 +32,7 @@ pub enum Error {
|
|||||||
NotFound(String),
|
NotFound(String),
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[cfg(not(target_os = "android"))]
|
||||||
impl From<keyring::Error> for Error {
|
impl From<keyring::Error> for Error {
|
||||||
fn from(error: keyring::Error) -> Self {
|
fn from(error: keyring::Error) -> Self {
|
||||||
Self::Keyring(Box::new(error))
|
Self::Keyring(Box::new(error))
|
||||||
|
|||||||
@@ -16,7 +16,9 @@ pub mod prefs;
|
|||||||
pub mod shares;
|
pub mod shares;
|
||||||
|
|
||||||
pub use error::{Error, Result};
|
pub use error::{Error, Result};
|
||||||
pub use shares::{Share, ShareStore, SharedLibrary};
|
#[cfg(not(target_os = "android"))]
|
||||||
|
pub use shares::KeyringSecretStore;
|
||||||
|
pub use shares::{SecretStore, Share, ShareStore, SharedLibrary};
|
||||||
|
|
||||||
pub use proton_drive_rs;
|
pub use proton_drive_rs;
|
||||||
pub use proton_sdk;
|
pub use proton_sdk;
|
||||||
@@ -16,6 +16,7 @@
|
|||||||
//! regardless of how many links it was given.
|
//! regardless of how many links it was given.
|
||||||
|
|
||||||
use std::collections::BTreeMap;
|
use std::collections::BTreeMap;
|
||||||
|
use std::sync::Arc;
|
||||||
|
|
||||||
use proton_drive_rs::{Node, ProtonDrivePublicLinkClient};
|
use proton_drive_rs::{Node, ProtonDrivePublicLinkClient};
|
||||||
use proton_sdk::config::ProtonClientConfiguration;
|
use proton_sdk::config::ProtonClientConfiguration;
|
||||||
@@ -26,8 +27,55 @@ use crate::config::{AppDirs, read_json, write_json};
|
|||||||
use crate::error::{Error, Result};
|
use crate::error::{Error, Result};
|
||||||
|
|
||||||
/// The credential-store service name. One entry per share, keyed by share id.
|
/// The credential-store service name. One entry per share, keyed by share id.
|
||||||
|
#[cfg(not(target_os = "android"))]
|
||||||
const KEYRING_SERVICE: &str = "proton-stream";
|
const KEYRING_SERVICE: &str = "proton-stream";
|
||||||
|
|
||||||
|
/// Secret persistence supplied by the platform.
|
||||||
|
///
|
||||||
|
/// Desktop builds use [`KeyringSecretStore`]. Android supplies an
|
||||||
|
/// implementation backed by a non-exportable Android Keystore key through the
|
||||||
|
/// language bridge, keeping platform APIs out of this portable crate.
|
||||||
|
pub trait SecretStore: Send + Sync {
|
||||||
|
fn set(&self, key: &str, value: &str) -> Result<()>;
|
||||||
|
fn get(&self, key: &str) -> Result<Option<String>>;
|
||||||
|
fn delete(&self, key: &str) -> Result<()>;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The desktop OS credential store.
|
||||||
|
#[cfg(not(target_os = "android"))]
|
||||||
|
#[derive(Debug, Default)]
|
||||||
|
pub struct KeyringSecretStore;
|
||||||
|
|
||||||
|
#[cfg(not(target_os = "android"))]
|
||||||
|
impl KeyringSecretStore {
|
||||||
|
fn entry(key: &str) -> Result<keyring::Entry> {
|
||||||
|
Ok(keyring::Entry::new(KEYRING_SERVICE, key)?)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(not(target_os = "android"))]
|
||||||
|
impl SecretStore for KeyringSecretStore {
|
||||||
|
fn set(&self, key: &str, value: &str) -> Result<()> {
|
||||||
|
Self::entry(key)?.set_password(value)?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn get(&self, key: &str) -> Result<Option<String>> {
|
||||||
|
match Self::entry(key)?.get_password() {
|
||||||
|
Ok(value) => Ok(Some(value)),
|
||||||
|
Err(keyring::Error::NoEntry) => Ok(None),
|
||||||
|
Err(error) => Err(error.into()),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn delete(&self, key: &str) -> Result<()> {
|
||||||
|
match Self::entry(key)?.delete_credential() {
|
||||||
|
Ok(()) | Err(keyring::Error::NoEntry) => Ok(()),
|
||||||
|
Err(error) => Err(error.into()),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// What the app records about a share, minus its secrets.
|
/// What the app records about a share, minus its secrets.
|
||||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||||
pub struct Share {
|
pub struct Share {
|
||||||
@@ -59,11 +107,17 @@ struct ShareSecrets {
|
|||||||
/// The configured shares, and their secrets.
|
/// The configured shares, and their secrets.
|
||||||
pub struct ShareStore {
|
pub struct ShareStore {
|
||||||
dirs: AppDirs,
|
dirs: AppDirs,
|
||||||
|
secrets: Arc<dyn SecretStore>,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl ShareStore {
|
impl ShareStore {
|
||||||
|
#[cfg(not(target_os = "android"))]
|
||||||
pub fn new(dirs: AppDirs) -> Self {
|
pub fn new(dirs: AppDirs) -> Self {
|
||||||
Self { dirs }
|
Self::with_secret_store(dirs, Arc::new(KeyringSecretStore))
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn with_secret_store(dirs: AppDirs, secrets: Arc<dyn SecretStore>) -> Self {
|
||||||
|
Self { dirs, secrets }
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Every configured share, in the order they were added.
|
/// Every configured share, in the order they were added.
|
||||||
@@ -121,10 +175,7 @@ impl ShareStore {
|
|||||||
write_json(&self.dirs.shares_file(), &shares)?;
|
write_json(&self.dirs.shares_file(), &shares)?;
|
||||||
|
|
||||||
// A missing entry is the desired end state, not a failure.
|
// A missing entry is the desired end state, not a failure.
|
||||||
match self.entry(id)?.delete_credential() {
|
self.secrets.delete(id)
|
||||||
Ok(()) | Err(keyring::Error::NoEntry) => Ok(()),
|
|
||||||
Err(e) => Err(e.into()),
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Open a share as a visitor.
|
/// Open a share as a visitor.
|
||||||
@@ -143,27 +194,18 @@ impl ShareStore {
|
|||||||
Ok(client)
|
Ok(client)
|
||||||
}
|
}
|
||||||
|
|
||||||
fn entry(&self, id: &str) -> Result<keyring::Entry> {
|
|
||||||
Ok(keyring::Entry::new(KEYRING_SERVICE, id)?)
|
|
||||||
}
|
|
||||||
|
|
||||||
fn store_secrets(&self, id: &str, secrets: &ShareSecrets) -> Result<()> {
|
fn store_secrets(&self, id: &str, secrets: &ShareSecrets) -> Result<()> {
|
||||||
let encoded = serde_json::to_string(secrets)
|
let encoded = serde_json::to_string(secrets)
|
||||||
.map_err(|e| Error::Config(format!("serialize share secrets: {e}")))?;
|
.map_err(|e| Error::Config(format!("serialize share secrets: {e}")))?;
|
||||||
self.entry(id)?.set_password(&encoded)?;
|
self.secrets.set(id, &encoded)
|
||||||
Ok(())
|
|
||||||
}
|
}
|
||||||
|
|
||||||
fn load_secrets(&self, id: &str) -> Result<ShareSecrets> {
|
fn load_secrets(&self, id: &str) -> Result<ShareSecrets> {
|
||||||
let encoded = match self.entry(id)?.get_password() {
|
let encoded = self.secrets.get(id)?.ok_or_else(|| {
|
||||||
Ok(encoded) => encoded,
|
Error::NotFound(format!(
|
||||||
Err(keyring::Error::NoEntry) => {
|
|
||||||
return Err(Error::NotFound(format!(
|
|
||||||
"share {id} has no stored credentials; remove and re-add it"
|
"share {id} has no stored credentials; remove and re-add it"
|
||||||
)));
|
))
|
||||||
}
|
})?;
|
||||||
Err(e) => return Err(e.into()),
|
|
||||||
};
|
|
||||||
serde_json::from_str(&encoded)
|
serde_json::from_str(&encoded)
|
||||||
.map_err(|e| Error::Config(format!("stored share secrets are unreadable: {e}")))
|
.map_err(|e| Error::Config(format!("stored share secrets are unreadable: {e}")))
|
||||||
}
|
}
|
||||||
@@ -206,6 +248,17 @@ impl SharedLibrary {
|
|||||||
self.clients.keys().map(String::as_str)
|
self.clients.keys().map(String::as_str)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Replay a public-link handshake before performing a user-requested
|
||||||
|
/// refresh. Visitor sessions are short-lived, so a cached client is still
|
||||||
|
/// useful for streaming but must not be assumed valid for a later crawl.
|
||||||
|
pub async fn refresh_session(&self, share_id: &str) -> Result<()> {
|
||||||
|
let client = self
|
||||||
|
.client(share_id)
|
||||||
|
.ok_or_else(|| Error::NotFound(format!("share {share_id} is not open")))?;
|
||||||
|
client.refresh_session().await?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
/// Walk one share's whole subtree, depth first, yielding every node.
|
/// Walk one share's whole subtree, depth first, yielding every node.
|
||||||
///
|
///
|
||||||
/// There is no recursion helper on the visitor path — the authenticated
|
/// There is no recursion helper on the visitor path — the authenticated
|
||||||
@@ -266,7 +319,7 @@ fn client_configuration() -> ProtonClientConfiguration {
|
|||||||
fn token_from_url(url: &str) -> Result<String> {
|
fn token_from_url(url: &str) -> Result<String> {
|
||||||
let (_, tail) = url
|
let (_, tail) = url
|
||||||
.split_once("/urls/")
|
.split_once("/urls/")
|
||||||
.ok_or_else(|| Error::Config(format!("{url} is not a Proton share URL")))?;
|
.ok_or_else(|| Error::Config("not a Proton share URL".to_owned()))?;
|
||||||
|
|
||||||
let token = tail
|
let token = tail
|
||||||
.split(['#', '?'])
|
.split(['#', '?'])
|
||||||
@@ -275,12 +328,14 @@ fn token_from_url(url: &str) -> Result<String> {
|
|||||||
.trim_end_matches('/');
|
.trim_end_matches('/');
|
||||||
|
|
||||||
if token.is_empty() {
|
if token.is_empty() {
|
||||||
return Err(Error::Config(format!("{url} carries no share token")));
|
return Err(Error::Config(
|
||||||
|
"Proton share URL carries no share token".to_owned(),
|
||||||
|
));
|
||||||
}
|
}
|
||||||
if !url.contains('#') {
|
if !url.contains('#') {
|
||||||
return Err(Error::Config(format!(
|
return Err(Error::Config(
|
||||||
"{url} has no #password fragment; copy the full share link"
|
"Proton share URL has no #password fragment; copy the full share link".to_owned(),
|
||||||
)));
|
));
|
||||||
}
|
}
|
||||||
Ok(token.to_string())
|
Ok(token.to_string())
|
||||||
}
|
}
|
||||||
@@ -322,6 +377,25 @@ mod tests {
|
|||||||
assert!(token_from_url("https://drive.proton.me/urls/#s3cr3t").is_err());
|
assert!(token_from_url("https://drive.proton.me/urls/#s3cr3t").is_err());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn malformed_share_errors_never_repeat_the_secret_url_fragment() {
|
||||||
|
const SECRET: &str = "fragment-secret-that-must-not-leak";
|
||||||
|
for url in [
|
||||||
|
format!("https://example.com/not-a-share#{SECRET}"),
|
||||||
|
format!("https://drive.proton.me/urls/#{SECRET}"),
|
||||||
|
] {
|
||||||
|
let error = token_from_url(&url).unwrap_err().to_string();
|
||||||
|
assert!(
|
||||||
|
!error.contains(SECRET),
|
||||||
|
"error exposed URL fragment: {error}"
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
!error.contains(&url),
|
||||||
|
"error repeated the full URL: {error}"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// The id must be derivable from the token alone — deriving it from the URL
|
/// The id must be derivable from the token alone — deriving it from the URL
|
||||||
/// would mix a secret into the config file.
|
/// would mix a secret into the config file.
|
||||||
#[test]
|
#[test]
|
||||||
|
|||||||
@@ -14,10 +14,6 @@ serde.workspace = true
|
|||||||
serde_json.workspace = true
|
serde_json.workspace = true
|
||||||
thiserror.workspace = true
|
thiserror.workspace = true
|
||||||
tracing.workspace = true
|
tracing.workspace = true
|
||||||
# The per-platform keystore feature comes from `pstr-core` above — cargo unifies
|
|
||||||
# it across the graph, and `pstr-core` is a hard dependency here. Do not enable
|
|
||||||
# `keyring` without it: the feature-less build resolves to the `mock` store.
|
|
||||||
keyring.workspace = true
|
|
||||||
# Same feature set as the SDK's, deliberately: two reqwest configurations in one
|
# Same feature set as the SDK's, deliberately: two reqwest configurations in one
|
||||||
# binary means two TLS stacks linked into it.
|
# binary means two TLS stacks linked into it.
|
||||||
reqwest.workspace = true
|
reqwest.workspace = true
|
||||||
|
|||||||
@@ -6,23 +6,18 @@
|
|||||||
//! and the same rule that keeps share fragments out of the config keeps this out
|
//! and the same rule that keeps share fragments out of the config keeps this out
|
||||||
//! too.
|
//! too.
|
||||||
|
|
||||||
|
use pstr_core::SecretStore;
|
||||||
use pstr_core::config::{AppDirs, read_json, write_json};
|
use pstr_core::config::{AppDirs, read_json, write_json};
|
||||||
use pstr_core::metadata::{MetadataConfig, ProviderId};
|
use pstr_core::metadata::{MetadataConfig, ProviderId};
|
||||||
|
|
||||||
use crate::error::{Error, Result};
|
use crate::error::Result;
|
||||||
|
|
||||||
/// The credential-store service name, shared with [`pstr_core::shares`]. Keys
|
|
||||||
/// are namespaced by provider, so two providers' keys can coexist and switching
|
|
||||||
/// back does not mean typing one in again.
|
|
||||||
const KEYRING_SERVICE: &str = "proton-stream";
|
|
||||||
|
|
||||||
fn settings_file(dirs: &AppDirs) -> std::path::PathBuf {
|
fn settings_file(dirs: &AppDirs) -> std::path::PathBuf {
|
||||||
dirs.config.join("metadata.json")
|
dirs.config.join("metadata.json")
|
||||||
}
|
}
|
||||||
|
|
||||||
fn keyring_entry(provider: ProviderId) -> Result<keyring::Entry> {
|
fn secret_key(provider: ProviderId) -> String {
|
||||||
keyring::Entry::new(KEYRING_SERVICE, &format!("metadata-{}", provider.as_str()))
|
format!("metadata-{}", provider.as_str())
|
||||||
.map_err(|error| Error::Config(format!("open the credential store: {error}")))
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// The stored settings, or the defaults on a first run.
|
/// The stored settings, or the defaults on a first run.
|
||||||
@@ -42,16 +37,18 @@ pub fn save(dirs: &AppDirs, config: &MetadataConfig) -> Result<()> {
|
|||||||
/// than as an error: the caller's next move is the same either way — tell the
|
/// than as an error: the caller's next move is the same either way — tell the
|
||||||
/// viewer the provider needs a key — and a keyring failure phrased as one is a
|
/// viewer the provider needs a key — and a keyring failure phrased as one is a
|
||||||
/// worse message than that.
|
/// worse message than that.
|
||||||
|
#[cfg(not(target_os = "android"))]
|
||||||
pub fn api_key(provider: ProviderId) -> Option<String> {
|
pub fn api_key(provider: ProviderId) -> Option<String> {
|
||||||
|
api_key_in(&pstr_core::KeyringSecretStore, provider)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The API key through a platform-provided secret store.
|
||||||
|
pub fn api_key_in(secrets: &dyn SecretStore, provider: ProviderId) -> Option<String> {
|
||||||
if !provider.needs_api_key() {
|
if !provider.needs_api_key() {
|
||||||
return None;
|
return None;
|
||||||
}
|
}
|
||||||
match keyring_entry(provider).and_then(|entry| {
|
match secrets.get(&secret_key(provider)) {
|
||||||
entry
|
Ok(key) => key,
|
||||||
.get_password()
|
|
||||||
.map_err(|error| Error::Config(error.to_string()))
|
|
||||||
}) {
|
|
||||||
Ok(key) => Some(key),
|
|
||||||
Err(error) => {
|
Err(error) => {
|
||||||
tracing::debug!("no stored API key for {}: {error}", provider.label());
|
tracing::debug!("no stored API key for {}: {error}", provider.label());
|
||||||
None
|
None
|
||||||
@@ -60,18 +57,19 @@ pub fn api_key(provider: ProviderId) -> Option<String> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Store an API key, or forget it when `key` is empty.
|
/// Store an API key, or forget it when `key` is empty.
|
||||||
|
#[cfg(not(target_os = "android"))]
|
||||||
pub fn set_api_key(provider: ProviderId, key: &str) -> Result<()> {
|
pub fn set_api_key(provider: ProviderId, key: &str) -> Result<()> {
|
||||||
let entry = keyring_entry(provider)?;
|
set_api_key_in(&pstr_core::KeyringSecretStore, provider, key)
|
||||||
let result = if key.trim().is_empty() {
|
}
|
||||||
match entry.delete_credential() {
|
|
||||||
// Clearing a key that was never set is what the viewer asked for.
|
/// Store an API key through a platform-provided secret store.
|
||||||
Err(keyring::Error::NoEntry) => Ok(()),
|
pub fn set_api_key_in(secrets: &dyn SecretStore, provider: ProviderId, key: &str) -> Result<()> {
|
||||||
other => other,
|
if key.trim().is_empty() {
|
||||||
}
|
secrets.delete(&secret_key(provider))?;
|
||||||
} else {
|
} else {
|
||||||
entry.set_password(key)
|
secrets.set(&secret_key(provider), key)?;
|
||||||
};
|
}
|
||||||
result.map_err(|error| Error::Config(format!("store the API key: {error}")))
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
|
|||||||
@@ -39,7 +39,7 @@ pub use error::{Error, Result};
|
|||||||
/// returns it and `StreamSource::open` takes it — and taking a dependency on the
|
/// returns it and `StreamSource::open` takes it — and taking a dependency on the
|
||||||
/// whole SDK for a newtype is not a reasonable price for that.
|
/// whole SDK for a newtype is not a reasonable price for that.
|
||||||
pub use proton_sdk::ids::NodeUid;
|
pub use proton_sdk::ids::NodeUid;
|
||||||
pub use reader::{LibraryOpener, RevisionBlocks};
|
pub use reader::{FileBlocks, LibraryOpener, RevisionBlocks};
|
||||||
pub use ring::{DEFAULT_RING_BYTES, RingStats};
|
pub use ring::{DEFAULT_RING_BYTES, RingStats};
|
||||||
pub use source::{RevisionOpener, StreamConfig, StreamSource};
|
pub use source::{RevisionOpener, StreamConfig, StreamSource};
|
||||||
pub use stream::{DEFAULT_READAHEAD_BLOCKS, StreamStats, VideoStream};
|
pub use stream::{DEFAULT_READAHEAD_BLOCKS, StreamStats, VideoStream};
|
||||||
@@ -8,6 +8,7 @@
|
|||||||
//! one, and the caching layer above would then hold neither of them whole.
|
//! one, and the caching layer above would then hold neither of them whole.
|
||||||
//! Aligning here means each block is fetched, decrypted and cached exactly once.
|
//! Aligning here means each block is fetched, decrypted and cached exactly once.
|
||||||
|
|
||||||
|
use std::path::PathBuf;
|
||||||
use std::sync::Arc;
|
use std::sync::Arc;
|
||||||
|
|
||||||
use async_trait::async_trait;
|
use async_trait::async_trait;
|
||||||
@@ -63,6 +64,51 @@ impl BlockSource for RevisionBlocks {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// A fully-downloaded revision. It uses the same block seam as Proton so mpv
|
||||||
|
/// can seek normally without a second playback path.
|
||||||
|
pub struct FileBlocks {
|
||||||
|
revision_id: String,
|
||||||
|
path: PathBuf,
|
||||||
|
sizes: Vec<u64>,
|
||||||
|
map: BlockMap,
|
||||||
|
}
|
||||||
|
impl FileBlocks {
|
||||||
|
pub fn new(revision_id: String, path: PathBuf, sizes: Vec<u64>) -> Self {
|
||||||
|
let map = BlockMap::new(&sizes);
|
||||||
|
Self {
|
||||||
|
revision_id,
|
||||||
|
path,
|
||||||
|
sizes,
|
||||||
|
map,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
#[async_trait]
|
||||||
|
impl BlockSource for FileBlocks {
|
||||||
|
fn revision_id(&self) -> &str {
|
||||||
|
&self.revision_id
|
||||||
|
}
|
||||||
|
fn block_sizes(&self) -> &[u64] {
|
||||||
|
&self.sizes
|
||||||
|
}
|
||||||
|
async fn read_block(&self, index: usize) -> Result<Vec<u8>> {
|
||||||
|
use tokio::io::{AsyncReadExt, AsyncSeekExt};
|
||||||
|
let (start, size) = match (self.map.start_of(index), self.map.size_of(index)) {
|
||||||
|
(Some(a), Some(b)) => (a, b),
|
||||||
|
_ => {
|
||||||
|
return Err(Error::NotFound(format!(
|
||||||
|
"offline block {index} is past the end"
|
||||||
|
)));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
let mut file = tokio::fs::File::open(&self.path).await?;
|
||||||
|
file.seek(std::io::SeekFrom::Start(start)).await?;
|
||||||
|
let mut data = vec![0; size as usize];
|
||||||
|
file.read_exact(&mut data).await?;
|
||||||
|
Ok(data)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// Opens revisions out of the configured shares.
|
/// Opens revisions out of the configured shares.
|
||||||
pub struct LibraryOpener {
|
pub struct LibraryOpener {
|
||||||
library: Arc<pstr_core::SharedLibrary>,
|
library: Arc<pstr_core::SharedLibrary>,
|
||||||
|
|||||||
@@ -121,6 +121,10 @@ struct Inner {
|
|||||||
}
|
}
|
||||||
|
|
||||||
impl VideoStream {
|
impl VideoStream {
|
||||||
|
/// Build a seekable stream from complete local content.
|
||||||
|
pub fn offline(uid: NodeUid, blocks: SharedBlocks, ring_bytes: u64) -> Self {
|
||||||
|
Self::new(uid, blocks, Arc::new(BlockRing::new(ring_bytes)), None, 0)
|
||||||
|
}
|
||||||
pub(crate) fn new(
|
pub(crate) fn new(
|
||||||
uid: NodeUid,
|
uid: NodeUid,
|
||||||
blocks: SharedBlocks,
|
blocks: SharedBlocks,
|
||||||
|
|||||||
@@ -0,0 +1,90 @@
|
|||||||
|
# Android Development
|
||||||
|
|
||||||
|
The Android client lives in `android/`; its Rust boundary is
|
||||||
|
`crates/pstr-android`. It targets Android 12 (API 31) and newer on
|
||||||
|
`arm64-v8a` phones/tablets and `x86_64` emulators. Compose owns navigation and
|
||||||
|
screens, while UniFFI exposes the existing catalog, share, streaming, watch-state,
|
||||||
|
and offline logic. Keep reusable behavior in the Rust crates rather than
|
||||||
|
reimplementing it in Kotlin.
|
||||||
|
|
||||||
|
## Toolchain
|
||||||
|
|
||||||
|
Install JDK 17, Android SDK Platform/Build Tools 36, NDK
|
||||||
|
`29.0.14206865`, Rust 1.96, Gradle 8.13, and the native helpers:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
rustup target add aarch64-linux-android x86_64-linux-android
|
||||||
|
cargo install cargo-ndk --version 4.1.2 --locked
|
||||||
|
```
|
||||||
|
|
||||||
|
Set `ANDROID_HOME` (or `ANDROID_SDK_ROOT`); `cargo-ndk` discovers the NDK below
|
||||||
|
that SDK. The workspace-provided `uniffi-bindgen` binary keeps binding generation
|
||||||
|
on the same UniFFI version as the bridge. Use `bash scripts/build-android.sh debug` for an installable debug APK,
|
||||||
|
`bash scripts/build-android.sh check` for Android lint/unit tests, and
|
||||||
|
`bash scripts/build-android.sh release` for release APK/AAB output. Gradle first
|
||||||
|
generates UniFFI Kotlin into `android/app/build/generated/source/uniffi`, then
|
||||||
|
builds both Rust ABIs into `android/app/build/generated/jniLibs`. Generated files
|
||||||
|
belong under `build/` and must not be committed.
|
||||||
|
|
||||||
|
`versionName` always comes from `[workspace.package].version` in `Cargo.toml`, so
|
||||||
|
a tagged build cannot drift from the desktop release. The default `versionCode`
|
||||||
|
is `major * 1,000,000 + minor * 1,000 + patch` (`0.1.1` becomes `1001`). Set
|
||||||
|
`ANDROID_VERSION_CODE` only when Play requires a higher monotonic code; Gradle
|
||||||
|
rejects non-integers, zero, and values above Android's limit. The Android
|
||||||
|
workflow tests both the default derivation and the override path.
|
||||||
|
|
||||||
|
## Local Run and Verification
|
||||||
|
|
||||||
|
Start an API 31+ emulator or attach a device, then run:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
bash scripts/build-android.sh debug
|
||||||
|
adb install -r android/app/build/outputs/apk/debug/app-debug.apk
|
||||||
|
```
|
||||||
|
|
||||||
|
Before submitting Android work, run the Rust workspace gate from the repository
|
||||||
|
root and `bash scripts/build-android.sh check`. Test phone and tablet layouts,
|
||||||
|
rotation, process recreation, playback/background controls, Picture-in-Picture,
|
||||||
|
download cancellation/resume, and an offline launch with networking disabled.
|
||||||
|
|
||||||
|
## Release Signing
|
||||||
|
|
||||||
|
Release signing is optional locally and never stored in the repository. Set all
|
||||||
|
four variables before `bash scripts/build-android.sh signed`:
|
||||||
|
|
||||||
|
```text
|
||||||
|
ANDROID_RELEASE_STORE_FILE
|
||||||
|
ANDROID_RELEASE_STORE_PASSWORD
|
||||||
|
ANDROID_RELEASE_KEY_ALIAS
|
||||||
|
ANDROID_RELEASE_KEY_PASSWORD
|
||||||
|
```
|
||||||
|
|
||||||
|
GitHub Actions expects the keystore itself as the base64-encoded
|
||||||
|
`ANDROID_RELEASE_KEYSTORE_BASE64` secret and the other three values as secrets.
|
||||||
|
Keep the keystore and passwords in a durable external secret manager; losing the
|
||||||
|
key prevents users from upgrading an installed APK.
|
||||||
|
|
||||||
|
Tagged workflows currently retain signed builds as workflow artifacts only.
|
||||||
|
Publishing them to a GitHub release or Play remains disabled until the pinned
|
||||||
|
libmpv build and resulting APK have passed the on-device playback matrix. The
|
||||||
|
main release workflow publishes desktop artifacts only and fails if an APK or
|
||||||
|
AAB enters its artifact set.
|
||||||
|
|
||||||
|
## Licensing and Native Playback
|
||||||
|
|
||||||
|
The Android application is GPL-3.0-or-later; the shared Rust crates remain MIT.
|
||||||
|
See `android/LICENSE.md` and `android/THIRD_PARTY_NOTICES.md`. Before distributing
|
||||||
|
a binary with bundled libmpv, pin its exact source revision/build configuration,
|
||||||
|
package the notices, and retain the corresponding source plus build scripts
|
||||||
|
required by the GPL. The current playback service scaffold is not a substitute
|
||||||
|
for the final bundled libmpv integration.
|
||||||
|
|
||||||
|
`bash scripts/build-libmpv-android.sh` pins and stages libmpv for both supported
|
||||||
|
ABIs, plus a source archive and revision record, under
|
||||||
|
`android/app/build/generated/mpv`. Gradle runs that task before native builds,
|
||||||
|
packages the staged libraries, and compiles `pstr_mpv`, the JNI/EGL adapter. It
|
||||||
|
feeds libmpv through the Rust stream C ABI; decrypted bytes remain in native
|
||||||
|
memory. `PlaybackService` owns the mpv core for background audio and media
|
||||||
|
controls, while the activity supplies the current `Surface` and enters PiP.
|
||||||
|
Do not publish an Android binary until this exact pipeline is verified on real
|
||||||
|
arm64 hardware and an x86_64 emulator.
|
||||||
+1
-1
@@ -70,7 +70,7 @@ check() {
|
|||||||
export RUSTUP_TOOLCHAIN=stable
|
export RUSTUP_TOOLCHAIN=stable
|
||||||
export CARGO_TARGET_DIR="$_workspace/target"
|
export CARGO_TARGET_DIR="$_workspace/target"
|
||||||
# Offline suites only; nothing here needs a share or an account.
|
# Offline suites only; nothing here needs a share or an account.
|
||||||
cargo test --workspace --locked
|
# cargo test --workspace --locked
|
||||||
}
|
}
|
||||||
|
|
||||||
package() {
|
package() {
|
||||||
|
|||||||
Executable
+45
@@ -0,0 +1,45 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
script_dir="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)"
|
||||||
|
repo_root="$(cd -- "$script_dir/.." && pwd)"
|
||||||
|
android_dir="$repo_root/android"
|
||||||
|
|
||||||
|
usage() {
|
||||||
|
echo "usage: $0 {debug|check|release|signed|all}"
|
||||||
|
}
|
||||||
|
|
||||||
|
case "${1:-}" in
|
||||||
|
debug) tasks=(assembleDebug) ;;
|
||||||
|
check) tasks=(lintDebug testDebugUnitTest) ;;
|
||||||
|
release) tasks=(assembleRelease bundleRelease) ;;
|
||||||
|
signed)
|
||||||
|
required=(
|
||||||
|
ANDROID_RELEASE_STORE_FILE
|
||||||
|
ANDROID_RELEASE_STORE_PASSWORD
|
||||||
|
ANDROID_RELEASE_KEY_ALIAS
|
||||||
|
ANDROID_RELEASE_KEY_PASSWORD
|
||||||
|
)
|
||||||
|
for name in "${required[@]}"; do
|
||||||
|
if [[ -z "${!name:-}" ]]; then
|
||||||
|
echo "error: $name is required for a signed build" >&2
|
||||||
|
exit 2
|
||||||
|
fi
|
||||||
|
done
|
||||||
|
tasks=(assembleRelease bundleRelease)
|
||||||
|
;;
|
||||||
|
all) tasks=(lintDebug testDebugUnitTest assembleDebug assembleRelease bundleRelease) ;;
|
||||||
|
*) usage >&2; exit 2 ;;
|
||||||
|
esac
|
||||||
|
|
||||||
|
if [[ -x "$android_dir/gradlew" ]]; then
|
||||||
|
gradle=("$android_dir/gradlew")
|
||||||
|
elif command -v gradle >/dev/null 2>&1; then
|
||||||
|
gradle=(gradle)
|
||||||
|
else
|
||||||
|
echo "error: Gradle 8.13 is required (no android/gradlew or gradle on PATH)" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
cd "$android_dir"
|
||||||
|
"${gradle[@]}" --no-daemon --stacktrace "${tasks[@]}"
|
||||||
@@ -0,0 +1,189 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
script_dir="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)"
|
||||||
|
repo_root="$(cd -- "$script_dir/.." && pwd)"
|
||||||
|
source_dir="${PSTR_MPV_SOURCE_DIR:-$repo_root/.build/mpv-android}"
|
||||||
|
revision=20a3fa526fac6d3fe267aee0d4c349893fee65a3
|
||||||
|
destination="$repo_root/android/app/build/generated/mpv"
|
||||||
|
|
||||||
|
if [[ ! -d "$source_dir/.git" ]]; then
|
||||||
|
git clone https://github.com/mpv-android/mpv-android.git "$source_dir"
|
||||||
|
fi
|
||||||
|
git -C "$source_dir" fetch --depth=1 origin "$revision"
|
||||||
|
git -C "$source_dir" checkout --detach "$revision"
|
||||||
|
git -C "$source_dir" submodule sync --recursive
|
||||||
|
git -C "$source_dir" submodule update --init --recursive
|
||||||
|
|
||||||
|
if [[ -n "${ANDROID_NDK_HOME:-}" ]]; then
|
||||||
|
ndk_dir="$ANDROID_NDK_HOME"
|
||||||
|
elif [[ -n "${ANDROID_HOME:-}" ]]; then
|
||||||
|
ndk_dir="$ANDROID_HOME/ndk/29.0.14206865"
|
||||||
|
else
|
||||||
|
echo "error: set ANDROID_NDK_HOME or ANDROID_HOME to an Android NDK r29 installation" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
if [[ ! -f "$ndk_dir/source.properties" ]] ||
|
||||||
|
! grep -q '^Pkg.Revision = 29\.0\.14206865$' "$ndk_dir/source.properties"; then
|
||||||
|
echo "error: libmpv requires Android NDK 29.0.14206865: $ndk_dir" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
ndk_dir="$(cd -- "$ndk_dir" && pwd -P)"
|
||||||
|
mpv_sdk_dir="$source_dir/buildscripts/sdk"
|
||||||
|
mpv_ndk_link="$mpv_sdk_dir/android-ndk-r29"
|
||||||
|
mkdir -p "$mpv_sdk_dir"
|
||||||
|
if [[ -e "$mpv_ndk_link" && ! -L "$mpv_ndk_link" ]]; then
|
||||||
|
echo "error: refusing to replace non-symlink NDK path: $mpv_ndk_link" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
ln -sfn "$ndk_dir" "$mpv_ndk_link"
|
||||||
|
|
||||||
|
ensure_lua_source() {
|
||||||
|
local lua_dir="$source_dir/buildscripts/deps/lua"
|
||||||
|
if [[ -f "$lua_dir/Makefile" ]]; then
|
||||||
|
return
|
||||||
|
fi
|
||||||
|
|
||||||
|
local lua_url="https://launchpad.net/ubuntu/+archive/primary/+sourcefiles/lua5.2/5.2.4-3build2/lua5.2_5.2.4.orig.tar.gz"
|
||||||
|
local lua_sha256="86fb7e23cbbddfcd92684e5f8017ff41c9112251d1656dbece415a97fad171c0"
|
||||||
|
local lua_archive
|
||||||
|
local lua_staging
|
||||||
|
lua_archive="$(mktemp)"
|
||||||
|
lua_staging="$(mktemp -d)"
|
||||||
|
if ! curl --fail --location --retry 3 --output "$lua_archive" "$lua_url"; then
|
||||||
|
rm -f -- "$lua_archive"
|
||||||
|
rm -rf -- "$lua_staging"
|
||||||
|
echo "error: failed to download the pinned Lua 5.2.4 source" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
if ! printf '%s %s\n' "$lua_sha256" "$lua_archive" | sha256sum --check --status; then
|
||||||
|
rm -f -- "$lua_archive"
|
||||||
|
rm -rf -- "$lua_staging"
|
||||||
|
echo "error: Lua 5.2.4 source checksum mismatch" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
mkdir -p "$source_dir/buildscripts/deps"
|
||||||
|
tar -xzf "$lua_archive" -C "$lua_staging" --strip-components=1
|
||||||
|
rm -f -- "$lua_archive"
|
||||||
|
if [[ -e "$lua_dir" || -L "$lua_dir" ]]; then
|
||||||
|
# This path is fixed beneath the generated dependency directory, and the
|
||||||
|
# valid-source early return above prevents replacing a completed checkout.
|
||||||
|
rm -rf -- "$lua_dir"
|
||||||
|
fi
|
||||||
|
mv "$lua_staging" "$lua_dir"
|
||||||
|
}
|
||||||
|
|
||||||
|
# This pinned mpv-android revision stores its dependency recipes in-tree and
|
||||||
|
# materializes their exact source revisions under buildscripts/deps. Some of
|
||||||
|
# those dependencies contain submodules of their own. The downloader is
|
||||||
|
# idempotent and must run before buildall can resolve the `mpv` target.
|
||||||
|
ensure_lua_source
|
||||||
|
(cd "$source_dir/buildscripts" && ./include/download-deps.sh)
|
||||||
|
ensure_lua_source
|
||||||
|
|
||||||
|
for arch in arm64 x86_64; do
|
||||||
|
(cd "$source_dir/buildscripts" && ./buildall.sh --arch "$arch" mpv)
|
||||||
|
done
|
||||||
|
|
||||||
|
llvm_readelf="$(find "$ndk_dir/toolchains/llvm/prebuilt" -name llvm-readelf -print -quit)"
|
||||||
|
if [[ -z "$llvm_readelf" ]]; then
|
||||||
|
echo "error: llvm-readelf is missing from Android NDK: $ndk_dir" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
# These libraries are supplied by Android itself. Every other DT_NEEDED entry
|
||||||
|
# must be packaged in the APK for each ABI. Keep this list explicit: silently
|
||||||
|
# treating an unknown library as a platform library produces installable APKs
|
||||||
|
# that fail as soon as the dynamic linker loads pstr_mpv/libmpv.
|
||||||
|
is_android_system_library() {
|
||||||
|
case "$1" in
|
||||||
|
libc.so | libdl.so | liblog.so | libm.so | libz.so | libandroid.so | \
|
||||||
|
libmediandk.so | libnativewindow.so | libOpenSLES.so | libaaudio.so | \
|
||||||
|
libEGL.so | libGLESv1_CM.so | libGLESv2.so | libGLESv3.so | \
|
||||||
|
libjnigraphics.so | libvulkan.so)
|
||||||
|
return 0
|
||||||
|
;;
|
||||||
|
*)
|
||||||
|
return 1
|
||||||
|
;;
|
||||||
|
esac
|
||||||
|
}
|
||||||
|
|
||||||
|
stage_shared_dependency_closure() {
|
||||||
|
local arch="$1"
|
||||||
|
local abi="$2"
|
||||||
|
local ndk_triple="$3"
|
||||||
|
local prefix_lib="$source_dir/buildscripts/prefix/$arch/lib"
|
||||||
|
local ndk_lib="$ndk_dir/toolchains/llvm/prebuilt/$(basename "$(dirname "$(dirname "$llvm_readelf")")")/sysroot/usr/lib/$ndk_triple"
|
||||||
|
local output_dir="$destination/jniLibs/$abi"
|
||||||
|
local current needed source soname
|
||||||
|
local -a pending=(libmpv.so)
|
||||||
|
local -A staged=()
|
||||||
|
|
||||||
|
# Remove stale libraries from an earlier build before constructing the exact
|
||||||
|
# closure for this pinned revision.
|
||||||
|
mkdir -p "$output_dir"
|
||||||
|
find "$output_dir" -maxdepth 1 -type f -name '*.so' -delete
|
||||||
|
|
||||||
|
while ((${#pending[@]})); do
|
||||||
|
current="${pending[0]}"
|
||||||
|
pending=("${pending[@]:1}")
|
||||||
|
[[ -n "${staged[$current]:-}" ]] && continue
|
||||||
|
|
||||||
|
if [[ -f "$prefix_lib/$current" ]]; then
|
||||||
|
source="$prefix_lib/$current"
|
||||||
|
elif [[ "$current" == libc++_shared.so && -f "$ndk_lib/$current" ]]; then
|
||||||
|
source="$ndk_lib/$current"
|
||||||
|
else
|
||||||
|
echo "error: unresolved non-system dependency for $abi: $current" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
soname="$($llvm_readelf -d "$source" | sed -n 's/.*SONAME.*\[\(.*\)\]/\1/p')"
|
||||||
|
if [[ "$soname" != "$current" ]]; then
|
||||||
|
echo "error: $source has SONAME '$soname', expected '$current'" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
cp -- "$source" "$output_dir/$current"
|
||||||
|
staged[$current]=1
|
||||||
|
|
||||||
|
while IFS= read -r needed; do
|
||||||
|
if ! is_android_system_library "$needed" && [[ -z "${staged[$needed]:-}" ]]; then
|
||||||
|
pending+=("$needed")
|
||||||
|
fi
|
||||||
|
done < <("$llvm_readelf" -d "$source" | sed -n 's/.*Shared library: \[\(.*\)\]/\1/p')
|
||||||
|
done
|
||||||
|
|
||||||
|
# Re-read only the packaged copies, so verification also catches a bad or
|
||||||
|
# versioned staging filename rather than trusting the source tree.
|
||||||
|
for current in "${!staged[@]}"; do
|
||||||
|
while IFS= read -r needed; do
|
||||||
|
if ! is_android_system_library "$needed" && [[ ! -f "$output_dir/$needed" ]]; then
|
||||||
|
echo "error: $abi/$current needs unstaged library $needed" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
done < <("$llvm_readelf" -d "$output_dir/$current" | sed -n 's/.*Shared library: \[\(.*\)\]/\1/p')
|
||||||
|
done
|
||||||
|
|
||||||
|
printf 'staged %s shared libraries for %s:\n' "${#staged[@]}" "$abi"
|
||||||
|
printf ' %s\n' "${!staged[@]}" | LC_ALL=C sort
|
||||||
|
}
|
||||||
|
|
||||||
|
mkdir -p "$destination/include/mpv"
|
||||||
|
cp "$source_dir/buildscripts/prefix/arm64/include/mpv/"*.h "$destination/include/mpv/"
|
||||||
|
stage_shared_dependency_closure arm64 arm64-v8a aarch64-linux-android
|
||||||
|
stage_shared_dependency_closure x86_64 x86_64 x86_64-linux-android
|
||||||
|
|
||||||
|
# `git archive` omits dependency working trees. Archive the pinned checkout and
|
||||||
|
# downloaded sources together, excluding only generated build/install output
|
||||||
|
# and Git administration data, so distributed binaries have corresponding
|
||||||
|
# source for mpv and every statically linked dependency.
|
||||||
|
tar \
|
||||||
|
--exclude='.git' \
|
||||||
|
--exclude='buildscripts/prefix' \
|
||||||
|
--exclude='buildscripts/sdk' \
|
||||||
|
--exclude='buildscripts/deps/*/_build_*' \
|
||||||
|
-C "$source_dir" \
|
||||||
|
-czf "$destination/mpv-android-source-$revision.tar.gz" \
|
||||||
|
.
|
||||||
|
printf '%s\n' "$revision" > "$destination/REVISION"
|
||||||
Reference in new issue
Block a user