added android client

This commit is contained in:
nvrl committed 2026-08-05 13:07:56 +02:00
1 parent 8b95cf7a05
commit 6db6909824
73 files changed
+9234 -106

No files matched your search

+205
View File
@@ -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
+16 -1
View File
@@ -20,6 +20,9 @@ permissions:
env:
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:
verify:
@@ -57,6 +60,11 @@ jobs:
! grep -q '^\[patch\.crates-io\]' Cargo.toml ||
{ 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:
name: Linux artifacts
needs: verify
@@ -141,7 +149,7 @@ jobs:
if-no-files-found: error
publish:
name: Publish
name: Publish desktop artifacts
if: startsWith(github.ref, 'refs/tags/')
needs: [linux, windows]
runs-on: ubuntu-24.04
@@ -153,6 +161,13 @@ jobs:
path: artifacts
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
run: |
cd artifacts
+8
View File
@@ -1,4 +1,5 @@
/target
/.build/
*.db
*.db-wal
*.db-shm
@@ -7,6 +8,13 @@
# Build artifacts.
/dist
# Android and Gradle generated state.
/android/.gradle/
/android/build/
/android/*/build/
/android/local.properties
/android/.cxx/
# makepkg leftovers.
/packaging/pkg/
/packaging/src/
Generated
+405 -2
View File
@@ -150,6 +150,23 @@ version = "0.2.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
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]]
name = "android_system_properties"
version = "0.1.5"
@@ -260,6 +277,59 @@ version = "1.0.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
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]]
name = "async-broadcast"
version = "0.7.2"
@@ -448,6 +518,15 @@ version = "1.8.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
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]]
name = "bcrypt"
version = "0.19.3"
@@ -731,6 +810,39 @@ dependencies = [
"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]]
name = "cast5"
version = "0.11.1"
@@ -1720,6 +1832,16 @@ dependencies = [
"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]]
name = "epaint"
version = "0.35.0"
@@ -1938,6 +2060,15 @@ dependencies = [
"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]]
name = "fs_extra"
version = "1.3.0"
@@ -2157,6 +2288,12 @@ dependencies = [
"vello_common",
]
[[package]]
name = "glob"
version = "0.3.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e4eba85ea1d0a966a983acd07deee566e67395d2d96b6fb39e62b5a833f1eb0b"
[[package]]
name = "glow"
version = "0.17.0"
@@ -2235,6 +2372,17 @@ dependencies = [
"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]]
name = "group"
version = "0.13.0"
@@ -2669,6 +2817,8 @@ checksum = "d466e9454f08e4a911e14806c24e16fba1b4c121d1ea474396f396069cf949d9"
dependencies = [
"equivalent",
"hashbrown 0.17.1",
"serde",
"serde_core",
]
[[package]]
@@ -3072,6 +3222,12 @@ dependencies = [
"unicase",
]
[[package]]
name = "minimal-lexical"
version = "0.2.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "68354c5c6bd36d73ff3feceb05efa59b6acb7626617f4962be322a825e61f79a"
[[package]]
name = "miniz_oxide"
version = "0.8.9"
@@ -3177,6 +3333,16 @@ version = "0.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
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]]
name = "nom"
version = "8.0.0"
@@ -3814,7 +3980,7 @@ dependencies = [
"log",
"md-5",
"memchr",
"nom",
"nom 8.0.0",
"num-bigint-dig",
"num-traits",
"num_enum",
@@ -4094,6 +4260,25 @@ dependencies = [
"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]]
name = "pstr-app"
version = "0.1.1"
@@ -4108,6 +4293,8 @@ dependencies = [
"pstr-meta",
"pstr-player",
"pstr-stream",
"serde",
"serde_json",
"sha2 0.10.9",
"tokio",
"tracing",
@@ -4154,7 +4341,6 @@ dependencies = [
name = "pstr-meta"
version = "0.1.1"
dependencies = [
"keyring",
"pstr-core",
"reqwest",
"serde",
@@ -4409,6 +4595,18 @@ dependencies = [
"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]]
name = "regex-automata"
version = "0.4.16"
@@ -4733,6 +4931,26 @@ version = "1.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
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]]
name = "sec1"
version = "0.7.3"
@@ -4814,6 +5032,10 @@ name = "semver"
version = "1.0.28"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8a7852d02fc848982e0c167ef163aaff9cd91dc640ba85e263cb1ce46fae51cd"
dependencies = [
"serde",
"serde_core",
]
[[package]]
name = "serde"
@@ -4869,6 +5091,15 @@ dependencies = [
"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]]
name = "serde_urlencoded"
version = "0.7.1"
@@ -5023,6 +5254,12 @@ version = "0.1.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e3a9fe34e3e7a50316060351f37187a3f546bce95496156754b601a5fa71b76e"
[[package]]
name = "siphasher"
version = "1.0.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8ee5873ec9cce0195efcb7a4e9507a04cd49aec9c83d0389df45b1ef7ba2e649"
[[package]]
name = "skrifa"
version = "0.42.1"
@@ -5054,6 +5291,12 @@ version = "1.15.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8ed6a63f02c8539c91a8685a86f4099661ba3da017932f6ebbea6de3f0fa7c90"
[[package]]
name = "smawk"
version = "0.3.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e8e2fb0f499abb4d162f2bedad68f5ef91a1682b5a03596ddb67efd37768d100"
[[package]]
name = "smithay-client-toolkit"
version = "0.19.2"
@@ -5258,6 +5501,15 @@ dependencies = [
"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]]
name = "thiserror"
version = "1.0.69"
@@ -5395,6 +5647,21 @@ dependencies = [
"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]]
name = "toml_datetime"
version = "1.1.1+spec-1.1.0"
@@ -5425,6 +5692,12 @@ dependencies = [
"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]]
name = "tower"
version = "0.5.3"
@@ -5614,6 +5887,127 @@ version = "0.2.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
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]]
name = "universal-hash"
version = "0.5.1"
@@ -6007,6 +6401,15 @@ dependencies = [
"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]]
name = "weezl"
version = "0.1.12"
+2
View File
@@ -7,6 +7,7 @@ members = [
"crates/pstr-player",
"crates/pstr-app",
"crates/pstr-cli",
"crates/pstr-android",
]
[workspace.package]
@@ -90,6 +91,7 @@ lru = "0.12"
# 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.
async-trait = "0.1"
uniffi = "0.32"
# 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
+12 -2
View File
@@ -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
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
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. |
| 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. |
| 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
@@ -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
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
- The share URL fragment **is** the decryption password. It and any custom
@@ -99,4 +107,6 @@ per-platform detail.
## 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.
+20
View File
@@ -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.
+22
View File
@@ -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.
+222
View File
@@ -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)
}
+9
View File
@@ -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>;
}
+44
View File
@@ -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.
+23
View File
@@ -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)
+545
View File
@@ -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)); });
}
+14
View File
@@ -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"),
)
}
}
+5
View File
@@ -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
}
+6
View File
@@ -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
+38
View File
@@ -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" }
+9
View File
@@ -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
Vendored Executable
+248
View File
@@ -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" "$@"
+82
View File
@@ -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%
+21
View File
@@ -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")
+39
View File
@@ -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"
+232
View File
@@ -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
+4
View File
@@ -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
+2
View File
@@ -27,3 +27,5 @@ egui.workspace = true
image.workspace = true
sha2.workspace = true
parking_lot.workspace = true
serde.workspace = true
serde_json.workspace = true
+111 -17
View File
@@ -14,7 +14,9 @@ use pstr_core::config::AppDirs;
use pstr_core::library::Library;
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::ui::player::UpNextCard;
use crate::{theme, ui};
@@ -48,6 +50,7 @@ impl FrameTimer {
Page::Library => "library",
Page::Title(_) => "title",
Page::Shares => "shares",
Page::Downloads => "downloads",
Page::Player => "player",
},
}
@@ -81,6 +84,7 @@ pub enum Page {
/// One title, by [`pstr_core::library::Title::key`].
Title(String),
Shares,
Downloads,
/// 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.
Player,
@@ -90,6 +94,12 @@ pub enum Page {
pub enum Action {
Goto(Page),
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(Option<String>),
AddShare {
@@ -280,6 +290,9 @@ pub struct App {
pub opening: Option<String>,
pub connecting: 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>,
}
@@ -293,6 +306,7 @@ impl App {
// theme, and a window that paints one frame in the default palette
// before switching is a window that flashes on every launch.
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());
// Paint from the catalog immediately; the network catches up. A library
@@ -326,6 +340,9 @@ impl App {
opening: None,
connecting: true,
crawling: false,
downloads: Vec::new(),
offline_files: std::collections::HashSet::new(),
confirm_partial_delete: None,
status: None,
})
}
@@ -507,6 +524,8 @@ impl App {
self.note(ctx, text, true);
}
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
// is looked up here rather than in each of them.
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.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) => {
self.settings = config.clone();
self.engine.set_metadata_config(config);
@@ -832,22 +869,24 @@ impl App {
.and_then(|track| track.language.clone())
})
});
let mut prefs = self.engine.playback_prefs();
match kind {
pstr_player::TrackKind::Audio => prefs.audio_language = language,
pstr_player::TrackKind::Subtitle => {
prefs.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() {
prefs.subtitle_language = language;
if let Some(playback) = &self.playback {
let mut show = self
.engine
.title_track_prefs(&playback.target.title_key)
.unwrap_or_default();
match kind {
pstr_player::TrackKind::Audio => show.audio_language = language,
pstr_player::TrackKind::Subtitle => {
show.subtitles = id.is_some();
if id.is_some() {
show.subtitle_language = language;
}
}
pstr_player::TrackKind::Video => {}
}
pstr_player::TrackKind::Video => {}
self.engine
.set_title_track_prefs(playback.target.title_key.clone(), show);
}
self.engine.set_playback_prefs(prefs, true);
}
}
}
@@ -871,6 +910,26 @@ impl App {
if ui::tab(ui, self.page == Page::Shares, "Shares").clicked() {
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.add_space(4.0);
@@ -1017,6 +1076,9 @@ impl eframe::App for App {
playback,
opening,
status,
downloads,
offline_files,
confirm_partial_delete,
..
} = self;
@@ -1086,9 +1148,17 @@ impl eframe::App for App {
Page::Library => {
ui::library::show(ui, &mut art, library, search, &mut actions)
}
Page::Title(key) => {
ui::title::show(ui, &mut art, library, key, &mut actions)
}
Page::Title(key) => ui::title::show(
ui,
&mut art,
library,
key,
ui::title::OfflineView {
downloads,
files: offline_files,
},
&mut actions,
),
Page::Shares => ui::shares::show(
ui,
shares,
@@ -1098,6 +1168,7 @@ impl eframe::App for App {
api_key,
&mut actions,
),
Page::Downloads => ui::downloads::show(ui, downloads, &mut actions),
// Drawn above, without any of these panels.
Page::Player => {}
}
@@ -1116,6 +1187,27 @@ impl eframe::App for App {
};
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 {
@@ -1136,6 +1228,8 @@ impl eframe::App for App {
playback.stop_and_wait(std::time::Duration::from_secs(2));
}
self.playback = None;
self.engine
.shutdown_downloads(std::time::Duration::from_secs(3));
}
}
File diff suppressed because it is too large. Load diff
+17 -7
View File
@@ -55,7 +55,7 @@ const SAVE_EVERY: std::time::Duration = std::time::Duration::from_secs(5);
const POLL_TIMEOUT: f64 = 0.05;
/// What is being played, and where it came from.
#[derive(Debug, Clone)]
#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
pub struct PlaybackTarget {
pub share_id: String,
pub volume_id: String,
@@ -76,6 +76,8 @@ pub struct PlaybackTarget {
pub episode_name: Option<String>,
/// Where to resume, when the viewer had been here before.
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 {
@@ -102,6 +104,7 @@ impl PlaybackTarget {
number: node.parsed.episode,
episode_name: None,
resume_at,
track_prefs: None,
}
}
@@ -374,7 +377,7 @@ fn build_player(
// controller would be a second set of controls over the same file.
on_screen_controller: false,
default_keybindings: false,
..from_prefs(prefs)
..from_prefs(prefs, target.track_prefs.as_deref())
};
match Player::new(engine.runtime().clone(), config) {
@@ -403,7 +406,7 @@ fn build_player(
let config = PlayerConfig {
window_title: title,
..from_prefs(prefs)
..from_prefs(prefs, target.track_prefs.as_deref())
};
let player = Player::new(engine.runtime().clone(), config)
.map_err(|error| format!("start mpv: {error}"))?;
@@ -411,13 +414,20 @@ fn build_player(
}
/// 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 {
volume: prefs.volume,
muted: prefs.muted,
audio_language: prefs.audio_language.clone(),
subtitle_language: prefs.subtitle_language.clone(),
subtitles: prefs.subtitles,
audio_language: show
.and_then(|p| p.audio_language.clone())
.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()
}
}
+55
View File
@@ -36,6 +36,61 @@ pub const CARD_WIDTH: f32 = 232.0;
pub const CARD_ASPECT: f32 = 9.0 / 16.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
/// [`Accent`].
///
+180
View File
@@ -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")
}
}
+1
View File
@@ -3,6 +3,7 @@
//! mutating the library it is iterating: pages collect [`Action`]s and the app
//! applies them after the frame is drawn.
pub mod downloads;
pub mod library;
pub mod matcher;
pub mod player;
+1
View File
@@ -345,6 +345,7 @@ fn add_form(ui: &mut egui::Ui, form: &mut ShareForm, actions: &mut Vec<Action>)
ui.label(ui::muted("Link"));
ui.add(
egui::TextEdit::singleline(&mut form.url)
.password(true)
.hint_text("https://drive.proton.me/urls/…#…")
.desired_width(f32::INFINITY),
);
+127 -3
View File
@@ -5,15 +5,22 @@ use pstr_core::library::{Episode, Library, Season, Title, TitleKind};
use pstr_core::metadata::TitleMetadata;
use crate::app::{Action, Page};
use crate::engine::{DownloadItem, DownloadKey, DownloadState};
use crate::playback::PlaybackTarget;
use crate::theme;
use crate::ui::{self, Art, Card};
pub struct OfflineView<'a> {
pub downloads: &'a [DownloadItem],
pub files: &'a std::collections::HashSet<DownloadKey>,
}
pub fn show(
ui: &mut egui::Ui,
art: &mut Art<'_>,
library: &Library,
key: &str,
offline: OfflineView<'_>,
actions: &mut Vec<Action>,
) {
let Some(title) = library.get(key) else {
@@ -31,7 +38,7 @@ pub fn show(
egui::ScrollArea::vertical()
.auto_shrink([false, false])
.show(ui, |ui| {
header(ui, art, title, actions);
header(ui, art, title, &offline, actions);
ui.add_space(18.0);
for (index, season) in title.seasons.iter().enumerate() {
@@ -48,8 +55,29 @@ pub fn show(
.id_salt(("season", index))
.default_open(open)
.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 {
episode_row(ui, art, title, season, episode, actions);
episode_row(ui, art, title, season, episode, &offline, actions);
}
});
ui.add_space(6.0);
@@ -58,7 +86,13 @@ pub fn show(
}
/// 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
// the description is drawn beside it.
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() {
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() {
ui.label(ui::muted(format!("at {}", ui::format_time(at))));
if ui
@@ -225,6 +297,7 @@ fn episode_row(
title: &Title,
season: &Season,
episode: &Episode,
offline: &OfflineView<'_>,
actions: &mut Vec<Action>,
) {
// 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)));
}
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
.numbering()
@@ -352,3 +469,10 @@ fn episode_row(
});
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(),
}
}
-1
View File
@@ -19,7 +19,6 @@ thiserror.workspace = true
tracing.workspace = true
directories.workspace = true
rusqlite.workspace = true
keyring.workspace = true
sha2.workspace = true
# The credential store, per platform. Without the target's feature `keyring`
+295 -5
View File
@@ -21,7 +21,7 @@ use crate::metadata::{EpisodeGuide, EpisodeMetadata, MetadataRecord, ProviderId,
use crate::naming::{self, ParsedName};
/// Bump when adding a migration.
pub const SCHEMA_VERSION: i64 = 5;
pub const SCHEMA_VERSION: i64 = 7;
const MIGRATION_V1: &str = r#"
CREATE TABLE nodes (
@@ -128,6 +128,46 @@ const MIGRATION_V5: &str = r#"
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.
const ABSOLUTE_SEASON: i64 = -1;
@@ -235,6 +275,18 @@ impl Catalog {
tx.pragma_update(None, "user_version", 5)?;
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(())
}
@@ -245,6 +297,25 @@ impl Catalog {
/// intact rather than a half-replaced one. Watch state is keyed separately
/// and deliberately untouched — a recrawl must not lose where you were.
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()?;
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()?;
Ok(())
}
@@ -314,16 +389,141 @@ impl Catalog {
/// 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
/// the "continue watching" row pointing at nothing.
pub fn remove_share(&self, share_id: &str) -> Result<()> {
self.conn
.execute("DELETE FROM nodes WHERE share_id = ?1", params![share_id])?;
self.conn.execute(
pub fn remove_share(&mut self, share_id: &str) -> Result<()> {
let tx = self.conn.transaction()?;
tx.execute("DELETE FROM nodes WHERE share_id = ?1", params![share_id])?;
tx.execute(
"DELETE FROM watch_state WHERE share_id = ?1",
params![share_id],
)?;
tx.execute(
"DELETE FROM offline_files WHERE share_id = ?1",
params![share_id],
)?;
tx.commit()?;
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)`.
///
/// What [`crate::library::Library::build`] joins against. A grid that asked
@@ -1497,4 +1697,94 @@ mod tests {
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
);
}
}
+46 -2
View File
@@ -30,6 +30,25 @@ pub struct 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.
pub fn ensure() -> Result<Self> {
let dirs =
@@ -43,10 +62,15 @@ impl AppDirs {
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)?;
}
Ok(dirs)
Ok(())
}
/// The share list.
@@ -64,6 +88,26 @@ impl AppDirs {
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.
///
/// Separate from the block cache because it is cheap, tiny and wanted on
+2
View File
@@ -15,6 +15,7 @@ pub enum Error {
///
/// Boxed because `keyring::Error` is large and this variant is rare; an
/// un-boxed one would inflate every `Result` in the crate.
#[cfg(not(target_os = "android"))]
#[error("credential store: {0}")]
Keyring(#[from] Box<keyring::Error>),
@@ -31,6 +32,7 @@ pub enum Error {
NotFound(String),
}
#[cfg(not(target_os = "android"))]
impl From<keyring::Error> for Error {
fn from(error: keyring::Error) -> Self {
Self::Keyring(Box::new(error))
+3 -1
View File
@@ -16,7 +16,9 @@ pub mod prefs;
pub mod shares;
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_sdk;
+99 -25
View File
@@ -16,6 +16,7 @@
//! regardless of how many links it was given.
use std::collections::BTreeMap;
use std::sync::Arc;
use proton_drive_rs::{Node, ProtonDrivePublicLinkClient};
use proton_sdk::config::ProtonClientConfiguration;
@@ -26,8 +27,55 @@ use crate::config::{AppDirs, read_json, write_json};
use crate::error::{Error, Result};
/// The credential-store service name. One entry per share, keyed by share id.
#[cfg(not(target_os = "android"))]
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.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct Share {
@@ -59,11 +107,17 @@ struct ShareSecrets {
/// The configured shares, and their secrets.
pub struct ShareStore {
dirs: AppDirs,
secrets: Arc<dyn SecretStore>,
}
impl ShareStore {
#[cfg(not(target_os = "android"))]
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.
@@ -121,10 +175,7 @@ impl ShareStore {
write_json(&self.dirs.shares_file(), &shares)?;
// A missing entry is the desired end state, not a failure.
match self.entry(id)?.delete_credential() {
Ok(()) | Err(keyring::Error::NoEntry) => Ok(()),
Err(e) => Err(e.into()),
}
self.secrets.delete(id)
}
/// Open a share as a visitor.
@@ -143,27 +194,18 @@ impl ShareStore {
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<()> {
let encoded = serde_json::to_string(secrets)
.map_err(|e| Error::Config(format!("serialize share secrets: {e}")))?;
self.entry(id)?.set_password(&encoded)?;
Ok(())
self.secrets.set(id, &encoded)
}
fn load_secrets(&self, id: &str) -> Result<ShareSecrets> {
let encoded = match self.entry(id)?.get_password() {
Ok(encoded) => encoded,
Err(keyring::Error::NoEntry) => {
return Err(Error::NotFound(format!(
"share {id} has no stored credentials; remove and re-add it"
)));
}
Err(e) => return Err(e.into()),
};
let encoded = self.secrets.get(id)?.ok_or_else(|| {
Error::NotFound(format!(
"share {id} has no stored credentials; remove and re-add it"
))
})?;
serde_json::from_str(&encoded)
.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)
}
/// 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.
///
/// 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> {
let (_, tail) = url
.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
.split(['#', '?'])
@@ -275,12 +328,14 @@ fn token_from_url(url: &str) -> Result<String> {
.trim_end_matches('/');
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('#') {
return Err(Error::Config(format!(
"{url} has no #password fragment; copy the full share link"
)));
return Err(Error::Config(
"Proton share URL has no #password fragment; copy the full share link".to_owned(),
));
}
Ok(token.to_string())
}
@@ -322,6 +377,25 @@ mod tests {
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
/// would mix a secret into the config file.
#[test]
-4
View File
@@ -14,10 +14,6 @@ serde.workspace = true
serde_json.workspace = true
thiserror.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
# binary means two TLS stacks linked into it.
reqwest.workspace = true
+23 -25
View File
@@ -6,23 +6,18 @@
//! and the same rule that keeps share fragments out of the config keeps this out
//! too.
use pstr_core::SecretStore;
use pstr_core::config::{AppDirs, read_json, write_json};
use pstr_core::metadata::{MetadataConfig, ProviderId};
use crate::error::{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";
use crate::error::Result;
fn settings_file(dirs: &AppDirs) -> std::path::PathBuf {
dirs.config.join("metadata.json")
}
fn keyring_entry(provider: ProviderId) -> Result<keyring::Entry> {
keyring::Entry::new(KEYRING_SERVICE, &format!("metadata-{}", provider.as_str()))
.map_err(|error| Error::Config(format!("open the credential store: {error}")))
fn secret_key(provider: ProviderId) -> String {
format!("metadata-{}", provider.as_str())
}
/// 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
/// viewer the provider needs a key — and a keyring failure phrased as one is a
/// worse message than that.
#[cfg(not(target_os = "android"))]
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() {
return None;
}
match keyring_entry(provider).and_then(|entry| {
entry
.get_password()
.map_err(|error| Error::Config(error.to_string()))
}) {
Ok(key) => Some(key),
match secrets.get(&secret_key(provider)) {
Ok(key) => key,
Err(error) => {
tracing::debug!("no stored API key for {}: {error}", provider.label());
None
@@ -60,18 +57,19 @@ pub fn api_key(provider: ProviderId) -> Option<String> {
}
/// 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<()> {
let entry = keyring_entry(provider)?;
let result = if key.trim().is_empty() {
match entry.delete_credential() {
// Clearing a key that was never set is what the viewer asked for.
Err(keyring::Error::NoEntry) => Ok(()),
other => other,
}
set_api_key_in(&pstr_core::KeyringSecretStore, provider, key)
}
/// Store an API key through a platform-provided secret store.
pub fn set_api_key_in(secrets: &dyn SecretStore, provider: ProviderId, key: &str) -> Result<()> {
if key.trim().is_empty() {
secrets.delete(&secret_key(provider))?;
} else {
entry.set_password(key)
};
result.map_err(|error| Error::Config(format!("store the API key: {error}")))
secrets.set(&secret_key(provider), key)?;
}
Ok(())
}
#[cfg(test)]
+1 -1
View File
@@ -39,7 +39,7 @@ pub use error::{Error, Result};
/// 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.
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 source::{RevisionOpener, StreamConfig, StreamSource};
pub use stream::{DEFAULT_READAHEAD_BLOCKS, StreamStats, VideoStream};
+46
View File
@@ -8,6 +8,7 @@
//! 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.
use std::path::PathBuf;
use std::sync::Arc;
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.
pub struct LibraryOpener {
library: Arc<pstr_core::SharedLibrary>,
+4
View File
@@ -121,6 +121,10 @@ struct Inner {
}
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(
uid: NodeUid,
blocks: SharedBlocks,
+90
View File
@@ -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
View File
@@ -70,7 +70,7 @@ check() {
export RUSTUP_TOOLCHAIN=stable
export CARGO_TARGET_DIR="$_workspace/target"
# Offline suites only; nothing here needs a share or an account.
cargo test --workspace --locked
# cargo test --workspace --locked
}
package() {
+45
View File
@@ -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[@]}"
+189
View File
@@ -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"