Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a475ad3575 | ||
|
|
da13f0547e | ||
|
|
4ca57d9449 | ||
|
|
c9925b4b09 | ||
|
|
fe69d2fae4 | ||
|
|
3fe4900337 | ||
|
|
973a8f2749 | ||
|
|
cf8ee58f6b | ||
|
|
466630a9ba | ||
|
|
a3bc9023c7 | ||
|
|
7bfebc5fd5 | ||
|
|
87ccaf26e1 | ||
|
|
fafe0c3087 | ||
|
|
f66faf6b75 | ||
|
|
cd9e8eea81 | ||
|
|
c026f81283 | ||
|
|
c6d303f269 | ||
|
|
255e463fc8 | ||
|
|
3ebfd1a57b | ||
|
|
f4c08d03c7 | ||
|
|
a7b87cdc79 | ||
|
|
70952fa7ca | ||
|
|
3ca431feca | ||
|
|
bb5a6638c4 | ||
|
|
b5ba59b41d | ||
|
|
79f0d74e1a | ||
|
|
648c35e92a | ||
|
|
ffd6e959bb | ||
|
|
69d961d7b8 | ||
|
|
238ecd8220 | ||
|
|
65a669e231 | ||
|
|
658c620c82 | ||
|
|
c1487c31d0 | ||
|
|
a8a18be248 | ||
|
|
16c2b4c9d3 | ||
|
|
2871a13ba6 | ||
|
|
6e0be9e3c8 | ||
|
|
0868c2af69 | ||
|
|
4a3a7291b8 | ||
|
|
ab7f057afe | ||
|
|
b1ea076ea3 | ||
|
|
8d615579e2 | ||
|
|
cafd969567 | ||
|
|
69981e3b45 | ||
|
|
cbe0f7166b | ||
|
|
f0402e74f9 | ||
|
|
d4e9628199 | ||
|
|
4b9cbd26bc | ||
|
|
588a7fcb09 | ||
|
|
e3e855a02d | ||
|
|
7790ae9cbb | ||
|
|
f0fe90f0fe | ||
|
|
51454b5a6e | ||
|
|
c44b04cf4a | ||
|
|
85bc975f5f | ||
|
|
4f130d1d6e | ||
|
|
fb3a97b408 | ||
|
|
04fc1f04fe | ||
|
|
aa0f4b1250 | ||
|
|
c261f88c3f | ||
|
|
bf3fa19c70 | ||
|
|
01eee78317 | ||
|
|
2546bab0a6 | ||
|
|
8d795c29e6 | ||
|
|
47c482498b | ||
|
|
31bdd75559 | ||
|
|
d43da778b6 | ||
|
|
abb3e3aaf8 | ||
|
|
5b931111b3 | ||
|
|
5c0519a226 | ||
|
|
5c15f45ace | ||
|
|
eb67df0aa0 | ||
|
|
fdf6b0359b | ||
|
|
6ddbcea429 | ||
|
|
a79e41aa8a | ||
|
|
89e55aac12 | ||
|
|
0f96cc4514 | ||
|
|
9d72acce88 | ||
|
|
d154002109 | ||
|
|
690455f7dd | ||
|
|
01c252b794 | ||
|
|
6d1943f2bd | ||
|
|
5dfb717235 | ||
|
|
3ff55a9af9 | ||
|
|
33da1563ee | ||
|
|
7994097a25 | ||
|
|
77a926cbf5 | ||
|
|
603cc0c1a9 | ||
|
|
5807493a4a | ||
|
|
a306d02cf1 | ||
|
|
c6b0457c4d | ||
|
|
c5490ab3da | ||
|
|
9e53a9bbdb | ||
|
|
ce02bbc35a | ||
|
|
0b9a9dab50 | ||
|
|
73973841c2 | ||
|
|
2f3be1d193 | ||
|
|
8e42906a7f | ||
|
|
dc70cafbb7 | ||
|
|
a28135cd51 | ||
|
|
4b9c01d52d | ||
|
|
3356602951 | ||
|
|
537217e70a | ||
|
|
ed05a1e723 | ||
|
|
da16ab0575 | ||
|
|
d9ca8f8d60 | ||
|
|
26cccd5c63 | ||
|
|
0b2813cd42 | ||
|
|
6cdab2fde1 | ||
|
|
6239780978 | ||
|
|
67cf3044f1 | ||
|
|
66be35c3d4 | ||
|
|
046cfe62b8 | ||
|
|
e3dcbec1f2 | ||
|
|
99abc8f205 | ||
|
|
3638e0e5df | ||
|
|
aef182e7e8 | ||
|
|
6853725ca0 | ||
|
|
4c80076762 | ||
|
|
52d27ec996 | ||
|
|
66eb1b49a1 | ||
|
|
22425e0bad | ||
|
|
50481e6aed | ||
|
|
bedbe0ef52 | ||
|
|
7914cf4dc1 | ||
|
|
39a7f3cc38 | ||
|
|
630f07a462 | ||
|
|
9cba54df14 | ||
|
|
4ff8ae303c | ||
|
|
6c2ec83cb1 | ||
|
|
677ab2d928 | ||
|
|
fb32d6c850 | ||
|
|
110f97caf9 | ||
|
|
0563c91b9e | ||
|
|
3b9f4b549c | ||
|
|
d5c819ee94 | ||
|
|
153859a735 | ||
|
|
1c61aadb22 | ||
|
|
8d40b211ed | ||
|
|
6229a59561 | ||
|
|
cfd654b267 | ||
|
|
0bc7cba428 | ||
|
|
65ecc099b6 | ||
|
|
65a6ae9373 | ||
|
|
2fbbd0e090 | ||
|
|
eee45bc8f8 | ||
|
|
1c8df80c27 | ||
|
|
5673a44310 | ||
|
|
c63f08ac91 | ||
|
|
f2e69dc852 | ||
|
|
8a1807c262 | ||
|
|
47b33cdb91 | ||
|
|
2d7d895dda | ||
|
|
0d33799199 | ||
|
|
2ac906cf37 | ||
|
|
cf065fc5fe | ||
|
|
55ee412a1b | ||
|
|
2275df40a8 | ||
|
|
78ffafe21a | ||
|
|
6e09129d0c | ||
|
|
476fec8c04 | ||
|
|
7af0969e38 | ||
|
|
3df0644bd8 | ||
|
|
5d079b05c9 | ||
|
|
4d44db0949 | ||
|
|
4a54af3245 | ||
|
|
7e734dbf19 | ||
|
|
e7eeaa518f | ||
|
|
39866bee8d | ||
|
|
85092fa893 | ||
|
|
2128a2d705 | ||
|
|
558446b9e4 | ||
|
|
6518aef5ee | ||
|
|
9037e6c2d5 | ||
|
|
d3325843a9 | ||
|
|
79c3f6885d | ||
|
|
a432a93cf9 | ||
|
|
935a985589 | ||
|
|
7d03736954 | ||
|
|
53a1d0afda | ||
|
|
959547cdc8 | ||
|
|
c60ffc7ab1 | ||
|
|
c5808a5e50 | ||
|
|
41d1fbf073 | ||
|
|
ff362d0d08 | ||
|
|
4027ab5fb3 | ||
|
|
c5adcddb1e | ||
|
|
8f78f5c1b1 | ||
|
|
6f28a9c377 | ||
|
|
d3c5f071fb | ||
|
|
ad24557588 | ||
|
|
4e6778e1ea | ||
|
|
dc85890a9f | ||
|
|
4ef62255c0 | ||
|
|
1a9a4624f8 | ||
|
|
cb03f96eca | ||
|
|
46bb68e3a5 | ||
|
|
f1b3ad9637 | ||
|
|
352fdf1933 | ||
|
|
887df9e998 | ||
|
|
bca999a904 | ||
|
|
359d397a69 | ||
|
|
b399f84f54 | ||
|
|
f4961fd6a8 | ||
|
|
6db6909824 |
No files matched your search
@@ -0,0 +1,331 @@
|
||||
name: Android
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [main]
|
||||
# A `v*` tag reaches this workflow through release.yml, which calls it and
|
||||
# publishes the signed APK. Triggering on the tag here as well would build
|
||||
# the release twice.
|
||||
workflow_call:
|
||||
inputs:
|
||||
signed_release:
|
||||
required: false
|
||||
default: false
|
||||
type: boolean
|
||||
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: '30.0.16248370'
|
||||
ANDROID_NDK_HOME: /usr/local/lib/android/sdk/ndk/30.0.16248370
|
||||
|
||||
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'
|
||||
|
||||
# The action's default `packages` includes `tools`, which cmdline-tools
|
||||
# 16.0 no longer offers, so sdkmanager exits 1. The SDK pieces this
|
||||
# build needs are installed explicitly below.
|
||||
- uses: android-actions/setup-android@v3
|
||||
with:
|
||||
packages: platform-tools
|
||||
log-accepted-android-sdk-licenses: false
|
||||
|
||||
- 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 libdbus-1-dev \
|
||||
libtool nasm ninja-build pkg-config python3 unzip wget xz-utils yasm
|
||||
# Not apt's meson: noble ships 1.3.2, and the fontconfig that the pinned
|
||||
# mpv-android revision builds requires >= 1.11.
|
||||
pipx install meson==1.12.1
|
||||
|
||||
- name: Install Android SDK and NDK
|
||||
run: >-
|
||||
sdkmanager
|
||||
"platforms;android-${ANDROID_API}"
|
||||
"build-tools;${ANDROID_BUILD_TOOLS}"
|
||||
"ndk;${ANDROID_NDK}"
|
||||
|
||||
- id: toolchain
|
||||
uses: dtolnay/rust-toolchain@stable
|
||||
with:
|
||||
toolchain: '1.96'
|
||||
targets: aarch64-linux-android,x86_64-linux-android
|
||||
|
||||
# The registry and the installed helpers only. rust-cache reads
|
||||
# `target/android/<triple>` as a profile directory and deletes the
|
||||
# `release` tree under it before saving, so with its target caching on
|
||||
# every run rebuilt both ABIs from scratch.
|
||||
- uses: Swatinem/rust-cache@v2.9.1
|
||||
with:
|
||||
shared-key: android-registry
|
||||
cache-targets: false
|
||||
cache-on-failure: true
|
||||
|
||||
# The target directory as is: the host build the UniFFI bindings come
|
||||
# from and both cargo-ndk ABIs. One entry for both jobs, since the signed
|
||||
# build compiles the same crates for the same targets. A new lockfile
|
||||
# starts from the last one's tree, so a version bump rebuilds only the
|
||||
# workspace crates.
|
||||
- id: target
|
||||
uses: actions/cache/restore@v6
|
||||
with:
|
||||
path: target
|
||||
key: rust-target-android-${{ steps.toolchain.outputs.cachekey }}-${{ hashFiles('Cargo.lock') }}
|
||||
restore-keys: rust-target-android-${{ steps.toolchain.outputs.cachekey }}-
|
||||
|
||||
# libmpv and its dependency tree are the slowest part of the build, and
|
||||
# depend only on the pinned revision and the build script, which stamps
|
||||
# its output with both and skips the build when the stamp matches. Built
|
||||
# and saved in steps of its own, so a later failure in Gradle does not
|
||||
# discard it the way a post-job cache save would.
|
||||
- id: libmpv
|
||||
uses: actions/cache/restore@v6
|
||||
with:
|
||||
path: android/app/build/generated/mpv
|
||||
key: libmpv-${{ hashFiles('scripts/build-libmpv-android.sh') }}
|
||||
|
||||
- name: Build libmpv
|
||||
run: bash scripts/build-libmpv-android.sh
|
||||
|
||||
- if: steps.libmpv.outputs.cache-hit != 'true'
|
||||
uses: actions/cache/save@v6
|
||||
with:
|
||||
path: android/app/build/generated/mpv
|
||||
key: ${{ steps.libmpv.outputs.cache-primary-key }}
|
||||
|
||||
- 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
|
||||
|
||||
- if: steps.target.outputs.cache-hit != 'true'
|
||||
uses: actions/cache/save@v6
|
||||
with:
|
||||
path: target
|
||||
key: ${{ steps.target.outputs.cache-primary-key }}
|
||||
|
||||
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'
|
||||
|
||||
# The action's default `packages` includes `tools`, which cmdline-tools
|
||||
# 16.0 no longer offers, so sdkmanager exits 1. The SDK pieces this
|
||||
# build needs are installed explicitly below.
|
||||
- uses: android-actions/setup-android@v3
|
||||
with:
|
||||
packages: platform-tools
|
||||
log-accepted-android-sdk-licenses: false
|
||||
|
||||
- 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 libdbus-1-dev \
|
||||
libtool nasm ninja-build pkg-config python3 unzip wget xz-utils yasm
|
||||
# Not apt's meson: noble ships 1.3.2, and the fontconfig that the pinned
|
||||
# mpv-android revision builds requires >= 1.11.
|
||||
pipx install meson==1.12.1
|
||||
|
||||
- name: Install Android SDK and NDK
|
||||
run: >-
|
||||
sdkmanager
|
||||
"platforms;android-${ANDROID_API}"
|
||||
"build-tools;${ANDROID_BUILD_TOOLS}"
|
||||
"ndk;${ANDROID_NDK}"
|
||||
|
||||
- id: toolchain
|
||||
uses: dtolnay/rust-toolchain@stable
|
||||
with:
|
||||
toolchain: '1.96'
|
||||
targets: aarch64-linux-android,x86_64-linux-android
|
||||
|
||||
# The registry and the installed helpers only. rust-cache reads
|
||||
# `target/android/<triple>` as a profile directory and deletes the
|
||||
# `release` tree under it before saving, so with its target caching on
|
||||
# every run rebuilt both ABIs from scratch.
|
||||
- uses: Swatinem/rust-cache@v2.9.1
|
||||
with:
|
||||
shared-key: android-registry
|
||||
cache-targets: false
|
||||
cache-on-failure: true
|
||||
|
||||
# The target directory as is: the host build the UniFFI bindings come
|
||||
# from and both cargo-ndk ABIs. One entry for both jobs, since the signed
|
||||
# build compiles the same crates for the same targets. A new lockfile
|
||||
# starts from the last one's tree, so a version bump rebuilds only the
|
||||
# workspace crates.
|
||||
- id: target
|
||||
uses: actions/cache/restore@v6
|
||||
with:
|
||||
path: target
|
||||
key: rust-target-android-${{ steps.toolchain.outputs.cachekey }}-${{ hashFiles('Cargo.lock') }}
|
||||
restore-keys: rust-target-android-${{ steps.toolchain.outputs.cachekey }}-
|
||||
|
||||
# libmpv and its dependency tree are the slowest part of the build, and
|
||||
# depend only on the pinned revision and the build script, which stamps
|
||||
# its output with both and skips the build when the stamp matches. Built
|
||||
# and saved in steps of its own, so a later failure in Gradle does not
|
||||
# discard it the way a post-job cache save would.
|
||||
- id: libmpv
|
||||
uses: actions/cache/restore@v6
|
||||
with:
|
||||
path: android/app/build/generated/mpv
|
||||
key: libmpv-${{ hashFiles('scripts/build-libmpv-android.sh') }}
|
||||
|
||||
- name: Build libmpv
|
||||
run: bash scripts/build-libmpv-android.sh
|
||||
|
||||
- if: steps.libmpv.outputs.cache-hit != 'true'
|
||||
uses: actions/cache/save@v6
|
||||
with:
|
||||
path: android/app/build/generated/mpv
|
||||
key: ${{ steps.libmpv.outputs.cache-primary-key }}
|
||||
|
||||
- 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
|
||||
|
||||
- if: steps.target.outputs.cache-hit != 'true'
|
||||
uses: actions/cache/save@v6
|
||||
with:
|
||||
path: target
|
||||
key: ${{ steps.target.outputs.cache-primary-key }}
|
||||
|
||||
# The APK bundles a GPL libmpv, so its corresponding source ships next to
|
||||
# it. The AAB is for Play only and stays out of the GitHub release.
|
||||
- name: Stage release files
|
||||
run: |
|
||||
version="$(sed -n 's/^version = "\([^"]*\)"/\1/p' Cargo.toml | head -n1)"
|
||||
mkdir -p dist/android
|
||||
cp android/app/build/outputs/apk/release/app-release.apk \
|
||||
"dist/android/proton-stream-$version-android.apk"
|
||||
cp android/app/build/generated/mpv/mpv-android-source-*.tar.gz dist/android/
|
||||
|
||||
- uses: actions/upload-artifact@v7
|
||||
with:
|
||||
name: android
|
||||
path: dist/android/*
|
||||
if-no-files-found: error
|
||||
|
||||
- uses: actions/upload-artifact@v7
|
||||
with:
|
||||
name: android-bundle
|
||||
path: android/app/build/outputs/bundle/release/*.aab
|
||||
if-no-files-found: error
|
||||
@@ -23,11 +23,11 @@ jobs:
|
||||
|
||||
- uses: Swatinem/rust-cache@v2.9.1
|
||||
|
||||
# libmpv for pstr-player.
|
||||
- name: Install libmpv
|
||||
# libmpv for pstr-player, WebKitGTK for the CAPTCHA window.
|
||||
- name: Install libmpv and WebKitGTK
|
||||
run: |
|
||||
sudo apt-get update
|
||||
sudo apt-get install -y libmpv-dev
|
||||
sudo apt-get install -y libmpv-dev libwebkit2gtk-4.1-dev
|
||||
|
||||
- run: cargo fmt --all -- --check
|
||||
|
||||
|
||||
@@ -0,0 +1,71 @@
|
||||
name: iOS
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [main]
|
||||
workflow_call:
|
||||
pull_request:
|
||||
paths:
|
||||
- 'ios/**'
|
||||
- 'android/app/src/main/res/font/**'
|
||||
- 'android/app/src/main/assets/licenses/**'
|
||||
- 'crates/pstr-android/**'
|
||||
- 'crates/pstr-core/**'
|
||||
- 'crates/pstr-meta/**'
|
||||
- 'crates/pstr-stream/**'
|
||||
- 'Cargo.toml'
|
||||
- 'Cargo.lock'
|
||||
- 'scripts/build-ios.sh'
|
||||
- '.github/workflows/ios.yml'
|
||||
workflow_dispatch:
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
env:
|
||||
CARGO_TERM_COLOR: always
|
||||
|
||||
jobs:
|
||||
build:
|
||||
name: Unit tests and unsigned .ipa
|
||||
runs-on: macos-15
|
||||
steps:
|
||||
- uses: actions/checkout@v7
|
||||
|
||||
- id: toolchain
|
||||
uses: dtolnay/rust-toolchain@stable
|
||||
with:
|
||||
toolchain: '1.96'
|
||||
targets: aarch64-apple-ios,aarch64-apple-ios-sim
|
||||
|
||||
- name: Restore the Rust target tree
|
||||
uses: actions/cache@v6
|
||||
with:
|
||||
path: |
|
||||
~/.cargo/registry
|
||||
~/.cargo/git
|
||||
target
|
||||
key: rust-target-ios-${{ steps.toolchain.outputs.cachekey }}-${{ hashFiles('Cargo.lock') }}
|
||||
restore-keys: rust-target-ios-${{ steps.toolchain.outputs.cachekey }}-
|
||||
|
||||
- name: Install XcodeGen
|
||||
run: brew install xcodegen
|
||||
|
||||
- name: Bindings
|
||||
run: scripts/build-ios.sh bindings
|
||||
|
||||
- name: Unit tests on the simulator
|
||||
run: scripts/build-ios.sh test
|
||||
|
||||
# SideStore and AltStore re-sign whatever they install with the viewer's
|
||||
# own Apple ID, so the release artifact is unsigned.
|
||||
- name: Unsigned .ipa
|
||||
run: |
|
||||
scripts/build-ios.sh rust
|
||||
scripts/build-ios.sh ipa
|
||||
|
||||
- uses: actions/upload-artifact@v7
|
||||
with:
|
||||
name: ios
|
||||
path: dist/*.ipa
|
||||
if-no-files-found: error
|
||||
@@ -76,7 +76,7 @@ jobs:
|
||||
- name: Install build dependencies
|
||||
run: |
|
||||
sudo apt-get update
|
||||
sudo apt-get install -y libmpv-dev rpm
|
||||
sudo apt-get install -y libmpv-dev libwebkit2gtk-4.1-dev rpm
|
||||
|
||||
- name: Gate
|
||||
run: |
|
||||
@@ -140,18 +140,49 @@ jobs:
|
||||
dist/*.msi
|
||||
if-no-files-found: error
|
||||
|
||||
# The signed APK plus the corresponding source of the libmpv it bundles. The
|
||||
# keystore and its passwords come from the ANDROID_RELEASE_* secrets.
|
||||
android:
|
||||
name: Android
|
||||
needs: verify
|
||||
uses: ./.github/workflows/android.yml
|
||||
with:
|
||||
signed_release: true
|
||||
secrets: inherit
|
||||
|
||||
# The unsigned .ipa: SideStore and AltStore re-sign it with the viewer's own
|
||||
# Apple ID when they install it.
|
||||
ios:
|
||||
name: iOS
|
||||
needs: verify
|
||||
uses: ./.github/workflows/ios.yml
|
||||
|
||||
publish:
|
||||
name: Publish
|
||||
if: startsWith(github.ref, 'refs/tags/')
|
||||
needs: [linux, windows]
|
||||
needs: [linux, windows, android, ios]
|
||||
runs-on: ubuntu-24.04
|
||||
permissions:
|
||||
contents: write
|
||||
steps:
|
||||
# By name: the Android workflow also uploads the AAB, which is for Play
|
||||
# and does not belong on the release.
|
||||
- uses: actions/download-artifact@v8
|
||||
with:
|
||||
name: linux
|
||||
path: artifacts
|
||||
- uses: actions/download-artifact@v8
|
||||
with:
|
||||
name: windows
|
||||
path: artifacts
|
||||
- uses: actions/download-artifact@v8
|
||||
with:
|
||||
name: android
|
||||
path: artifacts
|
||||
- uses: actions/download-artifact@v8
|
||||
with:
|
||||
name: ios
|
||||
path: artifacts
|
||||
merge-multiple: true
|
||||
|
||||
- name: Checksums
|
||||
run: |
|
||||
|
||||
@@ -0,0 +1,68 @@
|
||||
name: Wiki
|
||||
|
||||
# docs/ is the source of the GitHub wiki. Every change to the documentation
|
||||
# checks its links; a push to main publishes the result, so the wiki follows
|
||||
# main. Pull requests only check: nothing unmerged reaches the wiki. Run it from
|
||||
# the Actions tab to publish another ref, such as a release tag.
|
||||
on:
|
||||
push:
|
||||
branches: [main]
|
||||
paths:
|
||||
- 'docs/**'
|
||||
- 'images/**'
|
||||
- 'README.md'
|
||||
- 'scripts/publish-wiki.sh'
|
||||
- '.github/workflows/wiki.yml'
|
||||
pull_request:
|
||||
paths:
|
||||
- 'docs/**'
|
||||
- 'images/**'
|
||||
- 'README.md'
|
||||
- 'scripts/publish-wiki.sh'
|
||||
- '.github/workflows/wiki.yml'
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
ref:
|
||||
description: 'Tag or branch to publish'
|
||||
required: false
|
||||
default: 'main'
|
||||
type: string
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
check:
|
||||
name: Check documentation links
|
||||
runs-on: ubuntu-24.04
|
||||
steps:
|
||||
- uses: actions/checkout@v7
|
||||
with:
|
||||
ref: ${{ inputs.ref || github.sha }}
|
||||
|
||||
- name: Check links
|
||||
run: scripts/publish-wiki.sh --check "$RUNNER_TEMP"
|
||||
|
||||
publish:
|
||||
name: Publish to the wiki
|
||||
needs: check
|
||||
# Pull requests never publish.
|
||||
if: github.event_name != 'pull_request'
|
||||
runs-on: ubuntu-24.04
|
||||
concurrency:
|
||||
group: wiki-publish
|
||||
cancel-in-progress: false
|
||||
permissions:
|
||||
contents: write
|
||||
steps:
|
||||
- uses: actions/checkout@v7
|
||||
with:
|
||||
ref: ${{ inputs.ref || github.sha }}
|
||||
|
||||
- name: Publish
|
||||
env:
|
||||
WIKI_REMOTE: https://x-access-token:${{ github.token }}@github.com/${{ github.repository }}.wiki.git
|
||||
run: |
|
||||
git config --global user.name 'github-actions[bot]'
|
||||
git config --global user.email '41898282+github-actions[bot]@users.noreply.github.com'
|
||||
scripts/publish-wiki.sh --push "$RUNNER_TEMP"
|
||||
@@ -1,4 +1,5 @@
|
||||
/target
|
||||
/.build/
|
||||
*.db
|
||||
*.db-wal
|
||||
*.db-shm
|
||||
@@ -7,7 +8,21 @@
|
||||
# Build artifacts.
|
||||
/dist
|
||||
|
||||
# Android and Gradle generated state.
|
||||
/android/.gradle/
|
||||
/android/.kotlin/
|
||||
/android/build/
|
||||
/android/*/build/
|
||||
/android/local.properties
|
||||
/android/app/.cxx/
|
||||
|
||||
# makepkg leftovers.
|
||||
/packaging/pkg/
|
||||
/packaging/src/
|
||||
/packaging/*.pkg.tar.zst
|
||||
|
||||
# iOS generated state: XcodeGen owns the project and the Info.plist, and
|
||||
# scripts/build-ios.sh writes the bindings and DerivedData under ios/build.
|
||||
/ios/build/
|
||||
/ios/*.xcodeproj/
|
||||
/ios/ProtonStream/Info.plist
|
||||
@@ -7,10 +7,11 @@ members = [
|
||||
"crates/pstr-player",
|
||||
"crates/pstr-app",
|
||||
"crates/pstr-cli",
|
||||
"crates/pstr-android",
|
||||
]
|
||||
|
||||
[workspace.package]
|
||||
version = "0.1.1"
|
||||
version = "2.0.1"
|
||||
edition = "2024"
|
||||
license = "MIT"
|
||||
repository = "https://github.com/narrrl/proton-stream"
|
||||
@@ -27,8 +28,8 @@ authors = ["Nils Pukropp <contact@narl.io>"]
|
||||
opt-level = 2
|
||||
|
||||
[workspace.dependencies]
|
||||
proton-sdk = { version = "0.4.0" }
|
||||
proton-drive-rs = { version = "0.4.0" }
|
||||
proton-sdk = { version = "0.9.0" }
|
||||
proton-drive-rs = { version = "0.9.0" }
|
||||
|
||||
# Internal crates.
|
||||
pstr-core = { path = "crates/pstr-core" }
|
||||
@@ -50,7 +51,11 @@ anyhow = "1"
|
||||
tracing = "0.1"
|
||||
tracing-subscriber = { version = "0.3", features = ["env-filter"] }
|
||||
directories = "5"
|
||||
# Local dates for the history page. Already in the tree through the SDK.
|
||||
chrono = { version = "0.4", default-features = false, features = ["clock"] }
|
||||
sha2 = "0.10"
|
||||
# The id a watch-history file is named by. Already in the tree through the SDK.
|
||||
getrandom = "0.3"
|
||||
# `bundled` so a Windows build needs no system SQLite.
|
||||
rusqlite = { version = "0.32", features = ["bundled"] }
|
||||
# The share URL fragment and the custom password are both secrets; they go in
|
||||
@@ -68,14 +73,22 @@ keyring = { version = "3" }
|
||||
clap = { version = "4", features = ["derive"] }
|
||||
# The UI. `glow` rather than `wgpu` because step two of the player hands mpv an
|
||||
# `MPV_RENDER_API_TYPE_OPENGL` context, and that has to be the same context
|
||||
# eframe is drawing into.
|
||||
# eframe is drawing into. `accesskit` hands the widget tree to the platform's
|
||||
# screen reader (AT-SPI, UI Automation, NSAccessibility).
|
||||
eframe = { version = "0.35", default-features = false, features = [
|
||||
"accesskit",
|
||||
"default_fonts",
|
||||
"glow",
|
||||
"wayland",
|
||||
"x11",
|
||||
] }
|
||||
egui = "0.35"
|
||||
# The icons on buttons. Glyphs like ⏭ and 🔊 come out of whichever fallback
|
||||
# font has them, at its own size and baseline; an icon font is one set.
|
||||
egui-phosphor = { version = "0.13", default-features = false, features = ["regular"] }
|
||||
# Tests only: reads the bundled fonts' character maps. The version epaint
|
||||
# already builds, so it costs no extra compile.
|
||||
skrifa = { version = "0.42", default-features = false, features = ["std"] }
|
||||
# Poster thumbnails only: Proton renders them as JPEG, and a decoder that also
|
||||
# pulls in every exotic format is weight this app has no use for.
|
||||
image = { version = "0.25", default-features = false, features = ["jpeg", "png", "webp"] }
|
||||
@@ -83,13 +96,33 @@ image = { version = "0.25", default-features = false, features = ["jpeg", "png",
|
||||
# behind a poisoned lock for the rest of the session. Same reason
|
||||
# `proton-drive-linux` uses it.
|
||||
parking_lot = "0.12"
|
||||
# Media keys and the desktop's media widget: MPRIS on Linux, SMTC on Windows.
|
||||
# `dbus` rather than `zbus` because the keyring's Secret Service store already
|
||||
# links libdbus, so this adds no second D-Bus stack.
|
||||
souvlaki = { version = "0.8", default-features = false, features = ["use_dbus"] }
|
||||
# Holding the screensaver off while a film plays. mpv does that for a window of
|
||||
# its own, but the picture here is drawn into ours, and mpv has no window to
|
||||
# hold it for.
|
||||
dbus = "0.9"
|
||||
windows-sys = "0.61"
|
||||
# Toasts on Windows. Linux notifications go over the `dbus` above.
|
||||
tauri-winrt-notification = "0.8"
|
||||
# The exe's own icon resource, which is what Explorer and a taskbar pin draw.
|
||||
embed-resource = "3"
|
||||
# Proton's CAPTCHA page, which reports success by posting a message to its
|
||||
# host, so it needs a webview this app controls. Run in a child process of its
|
||||
# own (`pstr-app::verify`): tao and eframe's winit cannot share an event loop.
|
||||
# WebKitGTK on Linux, WebView2 on Windows, WKWebView on macOS.
|
||||
wry = { version = "0.57", default-features = false, features = ["os-webview"] }
|
||||
tao = { version = "0.37", default-features = false, features = ["rwh_06"] }
|
||||
rpassword = "7"
|
||||
libloading = "0.8"
|
||||
lru = "0.12"
|
||||
lru = "0.18"
|
||||
# `async fn` in traits is stable at MSRV 1.96 but is not dyn-compatible, and the
|
||||
# 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
|
||||
|
||||
@@ -0,0 +1,187 @@
|
||||
# Android plan
|
||||
|
||||
Where the Android client stands against the desktop app, and what to change to
|
||||
make it feel like a modern streaming app rather than a settings form with a
|
||||
player attached. Written 2026-10-03 from a source review and four screenshots
|
||||
(Library, Title, Shares, Settings) taken on a phone running v0.5.0.
|
||||
|
||||
Android last caught up with the desktop on 2026-08-10 (`4ef6225`); the desktop
|
||||
gained about twenty features on 2026-10-02 that Android does not have.
|
||||
|
||||
## Status (2026-10-03)
|
||||
|
||||
Done: section 0, every parity item, and every recommendation in section 2 —
|
||||
the Library, Title, Shares, Settings and Downloads redesigns, per-page app
|
||||
bars drawn edge to edge, search behind an icon, skeleton loading and
|
||||
generated placeholder art, Settings sub-pages and a language picker, a
|
||||
long-press Change match, Inter, light and tablet screenshots, the library
|
||||
and a title side by side from 840 dp, a poster-to-backdrop shared element
|
||||
transition, and predictive back on the title page.
|
||||
|
||||
Two departures from the text below. The tablet layout is a two-pane `Row`
|
||||
rather than `ListDetailPaneScaffold`, which would have added the
|
||||
`adaptive-layout` artifact for one split. Pull to refresh shows the
|
||||
indicator while a crawl runs, but not the crawl's progress.
|
||||
|
||||
Not yet verified on a device; the host gate (ktlint, unit and screenshot
|
||||
tests) passes, and Android lint runs in CI.
|
||||
|
||||
A source review on 2026-10-12 found these still short of the text below:
|
||||
|
||||
- **Title page.** The backdrop scrolls away with the back button instead of
|
||||
collapsing to a pinned bar, and the icon row has no mark-watched action.
|
||||
- **Episode rows** show the file size where runtime was asked for.
|
||||
- **Player episode list** is a bottom sheet in landscape too, not a side
|
||||
panel.
|
||||
- **Storage** has a usage bar for the streaming cache only; offline episodes
|
||||
have none.
|
||||
- **Metadata language** is still a free-text field.
|
||||
- **Touch targets.** The player's icons are 40 dp, under the 48 dp floor.
|
||||
- **Screenshots** do not cover the player, and only the library is taken at
|
||||
font scale 1.5.
|
||||
|
||||
Share rows now say how many files the last crawl found, when it ran and why it
|
||||
failed (section 2, Shares), as of the catalog's `share_crawls` table.
|
||||
|
||||
## 0. CI (done)
|
||||
|
||||
Every `Android` workflow run since August failed in `:app:buildRustHost`:
|
||||
`pstr-core` pulls `keyring` → `dbus-secret-service` → `libdbus-sys`, which needs
|
||||
`libdbus-1-dev` on the host. Because no run ever succeeded, no cache was ever
|
||||
saved, so each run started from nothing.
|
||||
|
||||
- `libdbus-1-dev` added to both jobs' apt install.
|
||||
- `Swatinem/rust-cache` shares one key between the two jobs and saves on failure.
|
||||
- `android/app/build/generated/mpv` is cached on the hash of
|
||||
`scripts/build-libmpv-android.sh`; the script writes a `STAMP` (revision plus
|
||||
its own hash) last and skips the whole libmpv build when the stamp matches.
|
||||
|
||||
Later runs also needed meson 1.12.1 from PyPI (noble's 1.3.2 is too old for
|
||||
the pinned fontconfig), libmpv built and cached in its own steps so a later
|
||||
failure keeps it, and lint's `OldTargetApi` made informational. A cached run
|
||||
restores libmpv in under a minute.
|
||||
|
||||
## 1. Desktop parity
|
||||
|
||||
Ordered by value on a phone.
|
||||
|
||||
1. **Open share links from outside the app.** The manifest has only the
|
||||
launcher intent. Add an `ACTION_VIEW` filter for `drive.proton.me/urls/*` and
|
||||
an `ACTION_SEND` (`text/plain`) filter; both open the add-share dialog with
|
||||
the link filled in. Desktop: `4a54af3`, `53a1d0a`.
|
||||
2. **Streaming cache budget.** Android runs on the fixed 4 GiB default
|
||||
(`DiskCacheConfig::DEFAULT_BUDGET_BYTES`), large for a phone. Expose
|
||||
`DiskCache::set_budget` through `pstr-android`, add a 1–16 GiB choice with a
|
||||
usage readout under Storage. Desktop: `a432a93`.
|
||||
3. **Hardware-decoding switch.** `pstr_mpv.cpp` hard-codes
|
||||
`hwdec=auto-copy-safe`; a device with green frames has no way out. A
|
||||
Settings toggle, applied from the next file.
|
||||
4. **Episode list in the player.** A bottom sheet (portrait) or side panel
|
||||
(landscape) with the title's episodes, opened on the playing season and
|
||||
scrolled to the playing episode. Desktop: `7e734db`.
|
||||
5. **Long-press menu on tiles.** Play/resume, mark watched or unwatched,
|
||||
download, change match, remove from Continue watching, in a
|
||||
`ModalBottomSheet`. Desktop: `c5808a5`.
|
||||
6. **Undo on snackbars** for marking a title and leaving Continue watching.
|
||||
`AppUiState.message` is a bare string today; it needs an action. Desktop:
|
||||
`79c3f68`.
|
||||
7. **History tab.** Needs a bridge call over the `pstr-core::library` query
|
||||
the desktop page uses. Desktop: `e7eeaa5`.
|
||||
8. **Buffered range on the seek bar** from mpv's `demuxer-cache-time`.
|
||||
9. **Up-next card** with the next episode's still and a countdown ring instead
|
||||
of "Up next in 7s" text.
|
||||
10. **Featured banner** at the top of the library. Desktop: `7d03736`.
|
||||
11. **Bundled Inter** in `ui/theme/Type.kt`, so both clients read the same.
|
||||
Desktop: `558446b`.
|
||||
|
||||
## 2. UI recommendations
|
||||
|
||||
### Everywhere
|
||||
|
||||
- **Colours stay the theme's.** The red is the Persona theme's accent and the
|
||||
card tint comes from the shared palette, so the redesign changes layout and
|
||||
button hierarchy, not colours.
|
||||
- **One button hierarchy.** At most one filled accent button per screen;
|
||||
secondary actions tonal or outlined; destructive actions behind a menu and a
|
||||
confirmation, never a filled red button in a list row.
|
||||
- **Edge-to-edge, per-screen titles.** The "proton-stream" app bar on every
|
||||
page wastes a band of height. Library uses a collapsing large title; Title
|
||||
pages draw the backdrop under the status bar; other tabs use their name.
|
||||
- **Skeletons instead of spinners** for library and title loading, and
|
||||
generated placeholder art (gradient from the accent plus the title's
|
||||
initials) instead of a black card with a play triangle (Akira).
|
||||
- **Pull-to-refresh** (`PullToRefreshBox`) replaces the top-bar refresh icon,
|
||||
with crawl progress shown in the indicator area.
|
||||
- **Split `ui/ProtonStreamApp.kt`** (1665 lines) into one file per screen
|
||||
before the work below; every item touches it.
|
||||
|
||||
### Library
|
||||
|
||||
- Poster grid (2:3, `posterUrl`) at three columns on a phone, title under the
|
||||
poster with no card body. The current 210 dp cropped backdrops fit two titles
|
||||
per screen.
|
||||
- Continue watching: 16:9 still with the progress bar laid over the bottom
|
||||
edge of the image and "S03E11 · 12 min left" under it.
|
||||
- Search becomes an icon in the app bar that expands into an M3 `SearchBar`,
|
||||
instead of a permanent field above everything.
|
||||
- Fix "1 episodes" (line ~507; the season header already pluralises).
|
||||
|
||||
### Title page
|
||||
|
||||
- Full-bleed backdrop with a gradient scrim, poster, title and meta chips
|
||||
(year, score, genres) over it, collapsing as the page scrolls.
|
||||
- Synopsis clamped to three lines with "More".
|
||||
- One full-width primary button, "Resume S03E11" or "Play S01E01", then a row
|
||||
of icon actions: download, mark watched, and a ⋮ menu holding Start over,
|
||||
Change match and More on AniList. Today five buttons of three styles wrap
|
||||
over three lines.
|
||||
- Drop the "Preferred tracks" form and its Save button from this page. The
|
||||
player already remembers tracks by name per title (`d3c5f07`); expose any
|
||||
override from the player's track chooser.
|
||||
- Season picker as a dropdown chip or tabs, not stacked accordions; a 40
|
||||
episode title is one long scroll today.
|
||||
- Episode rows: 16:9 still with progress overlay, "1 · Mother and Children"
|
||||
(provider name, else "Episode 1") rather than the raw file name
|
||||
`[Judas] Oshi no Ko - S01E01.mkv`, runtime or air date as supporting text,
|
||||
watched and download state as small trailing icons. The red `S01E01` label
|
||||
above each row goes.
|
||||
|
||||
### Shares
|
||||
|
||||
- `ListItem` rows: name, "12 titles · crawled 2 h ago" or the failure, and a ⋮
|
||||
menu with Refresh, Re-enter link and Remove (confirmed). The current row
|
||||
wraps "Custom password stored securely" over three lines beside three
|
||||
differently styled actions.
|
||||
- "Add share" as an extended FAB, which is also where the share-link intent
|
||||
from parity item 1 lands.
|
||||
|
||||
### Settings
|
||||
|
||||
- Grouped `ListItem` rows (headline, one line of supporting text, trailing
|
||||
switch), with the long explanations moved into the supporting line or an
|
||||
info dialog. Today each toggle carries a paragraph.
|
||||
- Split into sub-pages once storage and decoding settings land: Playback,
|
||||
Downloads & storage, Appearance, Metadata, About.
|
||||
- Language fields become a picker dialog over a language list, not free text.
|
||||
- Storage shows a usage bar (cache, offline episodes) above the budget choice.
|
||||
|
||||
### Robustness
|
||||
|
||||
- Screenshot tests (Roborazzi, JVM, no device) for each screen in light and
|
||||
dark, phone and tablet, at font scale 1.0 and 1.5 — the redesign changes
|
||||
every screen, and `docs/TESTING.md` has no visual layer today.
|
||||
- 48 dp minimum touch targets and content descriptions on the new icon-only
|
||||
actions.
|
||||
- Tablet: `ListDetailPaneScaffold` for library + title, since
|
||||
`NavigationSuiteScaffold` already switches to a rail there.
|
||||
- Shared-element transition from a poster to its title page, and predictive
|
||||
back.
|
||||
|
||||
## Suggested order
|
||||
|
||||
1. CI green (section 0).
|
||||
2. Split `ProtonStreamApp.kt`; add screenshot tests for the current screens.
|
||||
3. Parity 1–3 (small, phone-specific fixes).
|
||||
4. Button hierarchy and Inter — every later screen inherits them.
|
||||
5. Title page, then Library, then Shares and Settings.
|
||||
6. Parity 4–10.
|
||||
@@ -1,70 +1,145 @@
|
||||
<div align="center">
|
||||
|
||||
<img src="images/logo.svg" alt="" width="112" height="112">
|
||||
|
||||
# proton-stream
|
||||
|
||||
A Netflix-style desktop client for **Proton Drive public links**.
|
||||
**Your Proton Drive, streaming.** A Netflix-style library for the videos in your Proton Drive and
|
||||
the share links people send you, on Linux, Windows, Android, Android TV and iOS.
|
||||
|
||||
Paste a share URL and its password; get a browsable, streamable library. No
|
||||
Proton account, no server to host, no download step — episodes start playing in
|
||||
seconds and the seek bar works.
|
||||
**[proton-stream.narl.io](https://proton-stream.narl.io)**
|
||||
|
||||
Built on [`proton-sdk-rs`](https://github.com/narrrl/proton-sdk-rs), a pure-Rust
|
||||
Proton Drive SDK.
|
||||
[](https://github.com/narrrl/proton-stream/actions/workflows/ci.yml)
|
||||
[](https://github.com/narrrl/proton-stream/releases/latest)
|
||||
[](#license)
|
||||
[](https://www.rust-lang.org)
|
||||
[](https://buymeacoffee.com/narl)
|
||||
|
||||
<img src="images/desktop-home.png" alt="The library in the desktop app" width="820">
|
||||
|
||||
</div>
|
||||
|
||||
> [!IMPORTANT]
|
||||
> This is an **unofficial**, community-built client. It is not affiliated with, endorsed by, or
|
||||
> supported by Proton AG. "Proton" and "Proton Drive" are trademarks of Proton AG, used here only
|
||||
> to describe what the software talks to. It provides no content: it plays what you keep in
|
||||
> Proton Drive or what is shared with you.
|
||||
|
||||
## Features
|
||||
|
||||
**Library**
|
||||
|
||||
- **Public share links or your own Drive.** Paste a share link, with its password if it has one,
|
||||
or sign in and pick folders of your Drive. Any number of them merge into one library.
|
||||
- **Seasons and episodes from your folders.** The folder names the show, the file name gives the
|
||||
season and episode, and a file without numbers is a film.
|
||||
- **Posters, synopses and shelves**, from AniList or TMDB, **off by default**: shelves such as
|
||||
*Directed by* and *From MADHOUSE*, a franchise folded into one tile, and a hand-picked match
|
||||
that is never overwritten.
|
||||
- **Filters and sorting:** series, films, watching, not started, downloaded; by name, last watched,
|
||||
last added, air date, rating or popularity.
|
||||
|
||||
**Player**
|
||||
|
||||
- **Streams, never downloads first.** Episodes start in seconds, and a seek fetches only what it
|
||||
lands on.
|
||||
- **libmpv inside the app's own window:** HEVC 10-bit, HDR and ASS subtitles with hardware
|
||||
decoding, and no transcoding.
|
||||
- **Skip the opening and the ending** by chapter, an **Up next** countdown, and next and previous
|
||||
episode.
|
||||
- **Subtitle files beside the video** (`.srt`, `.ass`, `.ssa`, `.vtt`), and **the dialogue track
|
||||
over *Signs & Songs***. Audio and subtitle choices are remembered for each show.
|
||||
|
||||
**Everywhere**
|
||||
|
||||
- **Resume on any device.** Sign in, and your place in every episode, your shares and your
|
||||
settings sync through your own Drive.
|
||||
- **Downloads** of an episode, a season or a show, to play offline.
|
||||
- **Android 12+ and Android TV** with Material 3, background audio and new-episode
|
||||
notifications; **iOS 17+** as a SwiftUI port for SideStore and AltStore.
|
||||
- **Themes:** a near-black default, four Catppuccin flavours and Persona 5, each with nine accents.
|
||||
- **Full keyboard control** on the desktop, and the `pstr` command line.
|
||||
|
||||
<table>
|
||||
<tr>
|
||||
<td width="50%"><img src="images/desktop-library.png" alt="The whole library, filtered and sorted"></td>
|
||||
<td width="50%">
|
||||
<img src="images/android-home.png" alt="Android: home" width="24%">
|
||||
<img src="images/android-library.png" alt="Android: all titles" width="24%">
|
||||
<img src="images/android-title.png" alt="Android: a title page" width="24%">
|
||||
<img src="images/android-episodes.png" alt="Android: episodes and downloads" width="24%">
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
|
||||
## Install
|
||||
|
||||
Every [release](https://github.com/narrrl/proton-stream/releases/latest) has:
|
||||
|
||||
| Platform | Download |
|
||||
|---|---|
|
||||
| Debian, Ubuntu | `proton-stream_<version>-1_amd64.deb` |
|
||||
| Fedora | `proton-stream-<version>-1.x86_64.rpm` |
|
||||
| Other Linux | `proton-stream-<version>-x86_64-unknown-linux-gnu.tar.gz` |
|
||||
| Windows | `proton-stream-<version>-x64.msi`, or the portable `…-x86_64-windows.zip` |
|
||||
| Android, Android TV | `proton-stream-<version>-android.apk` |
|
||||
| iOS, iPadOS | `proton-stream-<version>-ios.ipa`, for SideStore or AltStore |
|
||||
|
||||
```bash
|
||||
sudo apt install ./proton-stream_*_amd64.deb # Debian, Ubuntu
|
||||
sudo dnf install ./proton-stream-*.x86_64.rpm # Fedora
|
||||
cd packaging && makepkg -si # Arch, from a checkout
|
||||
```
|
||||
|
||||
Then open **Proton Stream**, paste a share link on the **Shares** page (or press **Ctrl+V**), and
|
||||
the library fills in. [Getting started](docs/GETTING_STARTED.md) walks through it, and
|
||||
[Installation](docs/INSTALL.md) covers every platform, updating and uninstalling.
|
||||
|
||||
## Documentation
|
||||
|
||||
The [wiki](https://github.com/narrrl/proton-stream/wiki) is generated from [`docs/`](docs/) on
|
||||
every push to `main`:
|
||||
|
||||
- [Getting started](docs/GETTING_STARTED.md), [Installation](docs/INSTALL.md) and the
|
||||
[FAQ](docs/FAQ.md)
|
||||
- [User guide](docs/USER_GUIDE.md), [Account and sync](docs/ACCOUNT_AND_SYNC.md) and
|
||||
[Android and iOS](docs/MOBILE.md)
|
||||
- [Command-line reference](docs/CLI.md), [Privacy](docs/PRIVACY.md) and
|
||||
[Troubleshooting](docs/TROUBLESHOOTING.md)
|
||||
- For contributors: [Development](docs/DEVELOPMENT.md), [Architecture](docs/ARCHITECTURE.md),
|
||||
[Testing](docs/TESTING.md), [Android](docs/ANDROID.md) and [iOS](docs/IOS.md)
|
||||
|
||||
## Why it works
|
||||
|
||||
A Proton Drive file's content blocks are each a self-contained PGP packet
|
||||
decrypted under one shared session key. There is no sequential streaming session
|
||||
to resume, so seeking costs exactly the blocks the seek lands on — a 2 GB episode
|
||||
is playable without downloading 2 GB of it.
|
||||
A Proton Drive file's content blocks are each a self-contained PGP packet decrypted under one
|
||||
shared session key. There is no sequential streaming session to resume, so seeking costs exactly
|
||||
the blocks the seek lands on: a 2 GB episode plays without downloading 2 GB of it.
|
||||
|
||||
## Status
|
||||
On a 761 MiB, 26-minute 1080p HEVC episode over a real password-protected link: **2.3 s to first
|
||||
frame, about 1.5 s to resume after dragging to an arbitrary point, and 61 MiB fetched for the
|
||||
first 38 seconds of playback.** Watch it again and the disk cache serves it with **no network at
|
||||
all**: 0.3 s to first frame.
|
||||
|
||||
Early, but it is an app now: the picture lands **inside the window**, not in an
|
||||
mpv window of its own. The SDK streaming surface, the catalog, the streaming
|
||||
layer and the player are done and verified against a real share, and the app has
|
||||
a library wall, a page per title with seasons and episodes,
|
||||
resume-where-you-left-off, an overlay with a chapter-marked seek bar, a skip
|
||||
button for openings and endings, next/previous episode with an "up next"
|
||||
countdown, optional metadata, themes and share management. What is left is mostly sidecar subtitle files
|
||||
(`.srt`/`.ass` beside the video are invisible to the crawl today) and running the
|
||||
macOS packaging on a real Mac. See `docs/DEVELOPMENT.md`.
|
||||
The player is fed through an in-process mpv stream callback, not a local HTTP server, so there is
|
||||
no port another process could ask for the plaintext. [Architecture](docs/ARCHITECTURE.md) has the
|
||||
whole data flow.
|
||||
|
||||
```bash
|
||||
cargo run --release -p pstr-app # the app
|
||||
```
|
||||
Built on [`proton-sdk-rs`](https://github.com/narrrl/proton-sdk-rs), a pure-Rust Proton Drive
|
||||
SDK.
|
||||
|
||||
The `pstr` CLI does the same work headlessly, and is what tells you which layer
|
||||
is broken:
|
||||
## Privacy
|
||||
|
||||
```bash
|
||||
cargo run -p pstr-cli -- add --name anime 'https://drive.proton.me/urls/TOKEN#fragment'
|
||||
cargo run -p pstr-cli -- crawl
|
||||
cargo run -p pstr-cli -- list
|
||||
cargo run -p pstr-cli -- metadata # opt-in; off unless configured
|
||||
cargo run --release -p pstr-cli -- play --file Frieren --disk-cache
|
||||
cargo run --release -p pstr-cli -- bench --file Frieren --verify
|
||||
```
|
||||
- **No server, no telemetry.** Each app talks to Proton and to nothing else, unless you turn on
|
||||
posters and descriptions, which sends your title names to AniList or TMDB.
|
||||
- **The part of a share link after `#` is its decryption key.** It, any link password and your
|
||||
Proton session live in the system credential store (Secret Service, Credential Manager, the
|
||||
Android Keystore, the iOS Keychain), never in a config file.
|
||||
- **Sync is end-to-end encrypted.** Watch history, shares and settings travel through your own
|
||||
Drive, one file per device.
|
||||
- **The block cache and downloads hold decrypted video** in your cache and data folders. Size or
|
||||
empty the cache in **Settings → Storage**.
|
||||
|
||||
On a 761 MiB, 26-minute 1080p HEVC episode over a real password-protected link:
|
||||
**2.3 s to first frame, ~1.5 s to resume after dragging to an arbitrary point,
|
||||
61 MiB fetched for the first 38 seconds of playback.** Watch it again and the
|
||||
disk cache serves it with **no network at all** — 0.3 s to first frame.
|
||||
|
||||
`bench` measures the same file without a player, reporting cold seek latency,
|
||||
sustained throughput and peak memory, and `--verify` cross-checks a mid-file read
|
||||
against the SDK's own range download.
|
||||
|
||||
## Design
|
||||
|
||||
| | |
|
||||
|---|---|
|
||||
| Topology | Fat client. No server; each viewer's app talks to Proton directly. |
|
||||
| Auth | Public links only — but several, merged into one library. |
|
||||
| Player | Embedded libmpv, drawing into the app's own GL surface: HEVC 10-bit, HDR and ASS subtitles with hardware decode. Volume, audio-track, subtitle and chapter pickers; skip-the-opening; next/previous episode and autoplay. The language picked is preferred for the next file too. |
|
||||
| Transcoding | None. mpv demuxes and decodes natively. |
|
||||
| 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. |
|
||||
[Privacy](docs/PRIVACY.md) lists every connection and every file.
|
||||
|
||||
## Building
|
||||
|
||||
@@ -72,31 +147,26 @@ Rust 2024, MSRV 1.96.
|
||||
|
||||
```bash
|
||||
cargo build --release
|
||||
cargo run --release -p pstr-app # the app
|
||||
cargo run -p pstr-cli -- add --name anime 'URL' # the same work, headless
|
||||
```
|
||||
|
||||
`pstr-player` needs libmpv development headers (`mpv` ≥ 2.0). On Windows those
|
||||
come from the separate **mpv-dev** archive rather than mpv's player build, and
|
||||
`libmpv-2.dll` ships next to the executable. The Proton SDK crates come from
|
||||
crates.io; nothing here builds against a sibling checkout.
|
||||
`pstr-player` needs libmpv development headers (`mpv` ≥ 2.0), and on Linux `pstr-app` needs
|
||||
WebKitGTK 4.1's, for the CAPTCHA window. The Proton SDK crates come from crates.io.
|
||||
|
||||
Installable artifacts — tarball, `.deb`, `.rpm`, Arch package, `.app`/`.dmg`,
|
||||
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.
|
||||
|
||||
## Privacy
|
||||
|
||||
- The share URL fragment **is** the decryption password. It and any custom
|
||||
password live in the OS credential store — Secret Service on Linux, Credential
|
||||
Manager on Windows — never in a config file.
|
||||
- Nothing leaves your machine except requests to Proton, unless you turn metadata
|
||||
enrichment on. The player is fed through an in-process mpv stream callback, not
|
||||
a local HTTP server: there is no port for another process on the machine to ask
|
||||
for the plaintext, and mpv is handed an opaque token rather than a URL that
|
||||
would put a share secret in its log and window title.
|
||||
- The block cache holds decrypted content. It lives in your cache directory and
|
||||
can be deleted at any time the app is not running.
|
||||
- **Release artifacts:** `scripts/build.sh` (tarball, `.deb`, `.rpm`, Arch, macOS) and
|
||||
`scripts/build-windows.sh` or `scripts\build.ps1` (`.zip`, `.msi`). See
|
||||
[`packaging/README.md`](packaging/README.md).
|
||||
- **Android:** JDK 17, Android SDK 36, NDK 28, `cargo-ndk`, then
|
||||
`scripts/build-android.sh debug`. See [`docs/ANDROID.md`](docs/ANDROID.md).
|
||||
- **iOS:** macOS with Xcode and XcodeGen, then `scripts/build-ios.sh all`. See
|
||||
[`docs/IOS.md`](docs/IOS.md).
|
||||
|
||||
## License
|
||||
|
||||
MIT.
|
||||
The shared Rust crates and desktop application are MIT licensed. The Android and iOS applications
|
||||
and their combined native distributions are GPL-3.0-or-later; see
|
||||
[`android/LICENSE.md`](android/LICENSE.md), [`ios/LICENSE.md`](ios/LICENSE.md) and the bundled
|
||||
component notices. The desktop application embeds the Inter typeface, under the SIL Open Font
|
||||
License 1.1 ([`crates/pstr-app/assets/fonts/Inter-OFL.txt`](crates/pstr-app/assets/fonts/Inter-OFL.txt)),
|
||||
and the Phosphor icon font, under MIT.
|
||||
@@ -0,0 +1,29 @@
|
||||
# ktlint configuration.
|
||||
#
|
||||
# The whole standard ruleset is off, and rules are enabled one at a time. That
|
||||
# is deliberate: turning it all on reports ~540 findings on the existing tree,
|
||||
# almost all of them line-wrapping preference, and a mechanical reformat of
|
||||
# every file would bury the changes that matter in review. The rules kept here
|
||||
# are the ones whose violation is a defect or a real maintenance cost rather
|
||||
# than a matter of taste — starting with the unused imports that were the
|
||||
# evidence no gate ran at all.
|
||||
#
|
||||
# Adding a rule is cheap. Do it with the reformat it implies in its own commit.
|
||||
|
||||
[*.{kt,kts}]
|
||||
ktlint_standard = disabled
|
||||
ktlint_standard_no-unused-imports = enabled
|
||||
ktlint_standard_no-wildcard-imports = enabled
|
||||
ktlint_standard_no-semi = enabled
|
||||
ktlint_standard_no-trailing-spaces = enabled
|
||||
ktlint_standard_no-consecutive-blank-lines = enabled
|
||||
ktlint_standard_no-empty-file = enabled
|
||||
ktlint_standard_final-newline = enabled
|
||||
ktlint_standard_filename = enabled
|
||||
ktlint_standard_no-line-break-before-assignment = enabled
|
||||
ktlint_standard_no-multi-spaces = enabled
|
||||
|
||||
# Vendored from rustls-platform-verifier. Restyling it would turn the next sync
|
||||
# with upstream into a merge conflict for no benefit.
|
||||
[**/org/rustls/**.kt]
|
||||
ktlint = disabled
|
||||
@@ -0,0 +1,20 @@
|
||||
# proton-stream for Android license
|
||||
|
||||
SPDX-License-Identifier: GPL-3.0-or-later
|
||||
|
||||
Copyright (c) proton-stream contributors.
|
||||
|
||||
The Android application under this directory is free software: you may
|
||||
redistribute it and/or modify it under the terms of the GNU General Public
|
||||
License as published by the Free Software Foundation, either version 3 of the
|
||||
License, or (at your option) any later version.
|
||||
|
||||
This program is distributed in the hope that it will be useful, but WITHOUT ANY
|
||||
WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A
|
||||
PARTICULAR PURPOSE. See the [GNU GPL version 3](https://www.gnu.org/licenses/gpl-3.0.html)
|
||||
for the complete license text.
|
||||
|
||||
This licensing choice applies to the Android application and its distribution,
|
||||
including the combined native player. Rust crates outside `android/` retain the
|
||||
licenses declared in their manifests; the repository root `LICENSE` remains the
|
||||
MIT license for those components.
|
||||
@@ -0,0 +1,23 @@
|
||||
# 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 r30) | 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 |
|
||||
| Inter typeface (bundled, `Inter-OFL.txt`) | OFL-1.1 |
|
||||
| proton-stream shared Rust crates | MIT |
|
||||
|
||||
This summary is not a replacement for dependency license files. Before a
|
||||
release, generate an inventory from the resolved Cargo and Gradle lock data,
|
||||
review it, and package the complete license texts in the application. A bundled
|
||||
libmpv release must also identify the exact upstream source revision, local
|
||||
patches, configuration flags, and reproducible build instructions, and make the
|
||||
corresponding source available as the GPL requires.
|
||||
@@ -0,0 +1,311 @@
|
||||
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)
|
||||
alias(libs.plugins.ktlint)
|
||||
alias(libs.plugins.roborazzi)
|
||||
}
|
||||
|
||||
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 = "30.0.16248370"
|
||||
|
||||
defaultConfig {
|
||||
applicationId = "io.narl.protonstream"
|
||||
minSdk = 31
|
||||
targetSdk = 36
|
||||
versionCode = releaseVersionCode
|
||||
versionName = releaseVersionName
|
||||
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
|
||||
|
||||
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",
|
||||
// Applied to the shipping APK on purpose; the file explains the
|
||||
// trade-off and what each keep is repairing.
|
||||
"proguard-instrumentation.pro",
|
||||
)
|
||||
testProguardFiles("proguard-test-rules.pro")
|
||||
}
|
||||
}
|
||||
|
||||
// The minified path is what ships, and R8 breaks reflective UniFFI/JNA
|
||||
// surfaces in ways no debug run can see. CI sets this to `release` and runs
|
||||
// connectedAndroidTest against it; locally the default keeps builds fast.
|
||||
testBuildType = providers.environmentVariable("ANDROID_TEST_BUILD_TYPE").orNull ?: "debug"
|
||||
|
||||
lint {
|
||||
warningsAsErrors = true
|
||||
abortOnError = true
|
||||
checkDependencies = true
|
||||
// The baseline records what was already here when the gate went in, so
|
||||
// anything *new* fails. It is not a permanent exemption: every entry in
|
||||
// it maps to an open bug in docs/BUGS.md (the SharedPreferences
|
||||
// `commit()` calls are B20, the PiP one is B40), and the file should get
|
||||
// shorter as those land. Regenerate with `gradlew updateLintBaseline`
|
||||
// after a fix, and never to make an unrelated failure go away.
|
||||
baseline = file("lint-baseline.xml")
|
||||
// Generated UniFFI bindings are not ours to fix.
|
||||
disable += setOf("UnusedIds")
|
||||
// Dependency freshness is not a defect, and as errors these break the
|
||||
// gate whenever someone else cuts a release. Upgrades are a deliberate
|
||||
// act with their own verification; `gradlew lintDebug -Dlint...` or a
|
||||
// dependency bot is the right place to surface them, not CI.
|
||||
informational += setOf(
|
||||
"GradleDependency",
|
||||
"NewerVersionAvailable",
|
||||
"AndroidGradlePluginVersion",
|
||||
// The same for the platform: raising targetSdk opts into new
|
||||
// behaviour and needs its own pass on a device.
|
||||
"OldTargetApi",
|
||||
)
|
||||
sarifReport = true
|
||||
}
|
||||
|
||||
testOptions {
|
||||
unitTests {
|
||||
isIncludeAndroidResources = true
|
||||
isReturnDefaultValues = true
|
||||
}
|
||||
}
|
||||
|
||||
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 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)
|
||||
// No declared outputs: the script's STAMP is the up-to-date check. Gradle
|
||||
// deletes the declared outputs of a task it has no history for, which on a
|
||||
// fresh CI checkout threw away the tree just restored from the cache and
|
||||
// rebuilt libmpv from source on every run.
|
||||
}
|
||||
|
||||
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.material.icons.extended)
|
||||
implementation(libs.androidx.compose.material3.adaptive)
|
||||
implementation(libs.androidx.compose.material3.navigation.suite)
|
||||
debugImplementation(libs.androidx.compose.ui.tooling)
|
||||
|
||||
testImplementation(libs.junit)
|
||||
testImplementation(libs.robolectric)
|
||||
testImplementation(libs.androidx.test.core)
|
||||
testImplementation(libs.androidx.work.testing)
|
||||
testImplementation(platform(libs.androidx.compose.bom))
|
||||
testImplementation(libs.androidx.compose.ui.test.junit4)
|
||||
testImplementation(libs.roborazzi)
|
||||
testImplementation(libs.roborazzi.compose)
|
||||
testImplementation(libs.roborazzi.junit.rule)
|
||||
|
||||
androidTestImplementation(libs.androidx.test.junit)
|
||||
androidTestImplementation(libs.androidx.test.core)
|
||||
androidTestImplementation(libs.androidx.test.runner)
|
||||
androidTestImplementation(libs.androidx.work.testing)
|
||||
androidTestImplementation(platform(libs.androidx.compose.bom))
|
||||
androidTestImplementation(libs.androidx.compose.ui.test.junit4)
|
||||
debugImplementation(libs.androidx.compose.ui.test.manifest)
|
||||
}
|
||||
|
||||
ktlint {
|
||||
version.set("1.5.0")
|
||||
android.set(true)
|
||||
ignoreFailures.set(false)
|
||||
filter {
|
||||
// UniFFI writes these; they are regenerated on every build.
|
||||
exclude { it.file.path.contains("${File.separator}generated${File.separator}") }
|
||||
// Vendored from rustls-platform-verifier. Restyling it would make the
|
||||
// next sync with upstream a merge conflict for no benefit.
|
||||
exclude { it.file.path.contains("${File.separator}org${File.separator}rustls${File.separator}") }
|
||||
}
|
||||
}
|
||||
|
||||
// Screenshot baselines live beside the tests that draw them, so a change to how
|
||||
// a screen looks shows up in review as a changed image. `recordRoborazziDebug`
|
||||
// rewrites them; `verifyRoborazziDebug` fails on any difference.
|
||||
roborazzi {
|
||||
outputDir.set(file("src/test/screenshots"))
|
||||
}
|
||||
|
||||
// The generated UniFFI directory is a main source dir, so ktlint reads it even
|
||||
// though the filter above drops every file in it. Without this, Gradle refuses
|
||||
// the build for an undeclared dependency between the two tasks.
|
||||
tasks.matching { it.name.startsWith("runKtlintCheckOver") || it.name.startsWith("runKtlintFormatOver") }
|
||||
.configureEach { dependsOn(generateUniFfiBindings) }
|
||||
@@ -0,0 +1,268 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<issues format="6" by="lint 8.13.2" type="baseline" client="gradle" dependencies="true" name="AGP (8.13.2)" variant="all" version="8.13.2">
|
||||
|
||||
<issue
|
||||
id="AndroidGradlePluginVersion"
|
||||
message="A newer version of com.android.application than 8.13.2 is available: 9.3.1"
|
||||
errorLine1="agp = "8.13.2""
|
||||
errorLine2=" ~~~~~~~~">
|
||||
<location
|
||||
file="../gradle/libs.versions.toml"
|
||||
line="2"
|
||||
column="7"/>
|
||||
</issue>
|
||||
|
||||
<issue
|
||||
id="GradleDependency"
|
||||
message="A newer version of androidx.compose:compose-bom than 2026.06.00 is available: 2026.06.01"
|
||||
errorLine1="compose-bom = "2026.06.00""
|
||||
errorLine2=" ~~~~~~~~~~~~">
|
||||
<location
|
||||
file="../gradle/libs.versions.toml"
|
||||
line="4"
|
||||
column="15"/>
|
||||
</issue>
|
||||
|
||||
<issue
|
||||
id="GradleDependency"
|
||||
message="A newer version of androidx.activity:activity-compose than 1.11.0 is available: 1.13.0"
|
||||
errorLine1="activity = "1.11.0""
|
||||
errorLine2=" ~~~~~~~~">
|
||||
<location
|
||||
file="../gradle/libs.versions.toml"
|
||||
line="5"
|
||||
column="12"/>
|
||||
</issue>
|
||||
|
||||
<issue
|
||||
id="GradleDependency"
|
||||
message="A newer version of androidx.lifecycle:lifecycle-runtime-ktx than 2.9.4 is available: 2.11.0"
|
||||
errorLine1="lifecycle = "2.9.4""
|
||||
errorLine2=" ~~~~~~~">
|
||||
<location
|
||||
file="../gradle/libs.versions.toml"
|
||||
line="6"
|
||||
column="13"/>
|
||||
</issue>
|
||||
|
||||
<issue
|
||||
id="GradleDependency"
|
||||
message="A newer version of androidx.lifecycle:lifecycle-viewmodel-compose than 2.9.4 is available: 2.11.0"
|
||||
errorLine1="lifecycle = "2.9.4""
|
||||
errorLine2=" ~~~~~~~">
|
||||
<location
|
||||
file="../gradle/libs.versions.toml"
|
||||
line="6"
|
||||
column="13"/>
|
||||
</issue>
|
||||
|
||||
<issue
|
||||
id="GradleDependency"
|
||||
message="A newer version of androidx.navigation:navigation-compose than 2.9.5 is available: 2.9.8"
|
||||
errorLine1="navigation = "2.9.5""
|
||||
errorLine2=" ~~~~~~~">
|
||||
<location
|
||||
file="../gradle/libs.versions.toml"
|
||||
line="7"
|
||||
column="14"/>
|
||||
</issue>
|
||||
|
||||
<issue
|
||||
id="GradleDependency"
|
||||
message="A newer version of androidx.work:work-runtime-ktx than 2.10.5 is available: 2.11.2"
|
||||
errorLine1="work = "2.10.5""
|
||||
errorLine2=" ~~~~~~~~">
|
||||
<location
|
||||
file="../gradle/libs.versions.toml"
|
||||
line="8"
|
||||
column="8"/>
|
||||
</issue>
|
||||
|
||||
<issue
|
||||
id="GradleDependency"
|
||||
message="A newer version of androidx.work:work-testing than 2.10.5 is available: 2.11.2"
|
||||
errorLine1="work = "2.10.5""
|
||||
errorLine2=" ~~~~~~~~">
|
||||
<location
|
||||
file="../gradle/libs.versions.toml"
|
||||
line="8"
|
||||
column="8"/>
|
||||
</issue>
|
||||
|
||||
<issue
|
||||
id="GradleDependency"
|
||||
message="A newer version of androidx.compose.material3.adaptive:adaptive than 1.1.0 is available: 1.2.0"
|
||||
errorLine1="adaptive = "1.1.0""
|
||||
errorLine2=" ~~~~~~~">
|
||||
<location
|
||||
file="../gradle/libs.versions.toml"
|
||||
line="9"
|
||||
column="12"/>
|
||||
</issue>
|
||||
|
||||
<issue
|
||||
id="GradleDependency"
|
||||
message="A newer version of androidx.compose.material3:material3-adaptive-navigation-suite than 1.1.0 is available: 1.4.0"
|
||||
errorLine1="adaptive = "1.1.0""
|
||||
errorLine2=" ~~~~~~~">
|
||||
<location
|
||||
file="../gradle/libs.versions.toml"
|
||||
line="9"
|
||||
column="12"/>
|
||||
</issue>
|
||||
|
||||
<issue
|
||||
id="GradleDependency"
|
||||
message="A newer version of androidx.core:core-ktx than 1.17.0 is available: 1.19.0"
|
||||
errorLine1="androidx-core = { module = "androidx.core:core-ktx", version = "1.17.0" }"
|
||||
errorLine2=" ~~~~~~~~">
|
||||
<location
|
||||
file="../gradle/libs.versions.toml"
|
||||
line="19"
|
||||
column="64"/>
|
||||
</issue>
|
||||
|
||||
<issue
|
||||
id="NewerVersionAvailable"
|
||||
message="A newer version of org.jetbrains.kotlin.android than 2.2.21 is available: 2.4.10"
|
||||
errorLine1="kotlin = "2.2.21""
|
||||
errorLine2=" ~~~~~~~~">
|
||||
<location
|
||||
file="../gradle/libs.versions.toml"
|
||||
line="3"
|
||||
column="10"/>
|
||||
</issue>
|
||||
|
||||
<issue
|
||||
id="NewerVersionAvailable"
|
||||
message="A newer version of org.jetbrains.kotlin.plugin.compose than 2.2.21 is available: 2.4.10"
|
||||
errorLine1="kotlin = "2.2.21""
|
||||
errorLine2=" ~~~~~~~~">
|
||||
<location
|
||||
file="../gradle/libs.versions.toml"
|
||||
line="3"
|
||||
column="10"/>
|
||||
</issue>
|
||||
|
||||
<issue
|
||||
id="NewerVersionAvailable"
|
||||
message="A newer version of net.java.dev.jna:jna than 5.17.0 is available: 5.19.1"
|
||||
errorLine1="jna = "5.17.0""
|
||||
errorLine2=" ~~~~~~~~">
|
||||
<location
|
||||
file="../gradle/libs.versions.toml"
|
||||
line="10"
|
||||
column="7"/>
|
||||
</issue>
|
||||
|
||||
<issue
|
||||
id="NewerVersionAvailable"
|
||||
message="A newer version of org.robolectric:robolectric than 4.16 is available: 4.16.1"
|
||||
errorLine1="robolectric = "4.16""
|
||||
errorLine2=" ~~~~~~">
|
||||
<location
|
||||
file="../gradle/libs.versions.toml"
|
||||
line="12"
|
||||
column="15"/>
|
||||
</issue>
|
||||
|
||||
<issue
|
||||
id="NewerVersionAvailable"
|
||||
message="A newer version of org.jlleitschuh.gradle.ktlint than 13.1.0 is available: 14.2.0"
|
||||
errorLine1="ktlint-plugin = "13.1.0""
|
||||
errorLine2=" ~~~~~~~~">
|
||||
<location
|
||||
file="../gradle/libs.versions.toml"
|
||||
line="16"
|
||||
column="17"/>
|
||||
</issue>
|
||||
|
||||
<issue
|
||||
id="ObsoleteSdkInt"
|
||||
message="Unnecessary; `SDK_INT` is always >= 31"
|
||||
errorLine1=" if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) {"
|
||||
errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~">
|
||||
<location
|
||||
file="src/main/kotlin/org/rustls/platformverifier/CertificateVerifier.kt"
|
||||
line="294"
|
||||
column="13"/>
|
||||
</issue>
|
||||
|
||||
<issue
|
||||
id="AutoboxingStateCreation"
|
||||
message="Prefer `mutableIntStateOf` instead of `mutableStateOf`"
|
||||
errorLine1=" var remaining by remember(next?.linkId) { mutableStateOf(UP_NEXT_SECONDS) }"
|
||||
errorLine2=" ~~~~~~~~~~~~~~">
|
||||
<location
|
||||
file="src/main/java/io/narl/protonstream/playback/PlayerScreen.kt"
|
||||
line="707"
|
||||
column="47"/>
|
||||
</issue>
|
||||
|
||||
<issue
|
||||
id="UseKtx"
|
||||
message="Use the KTX extension function `SharedPreferences.edit` instead?"
|
||||
errorLine1=" check(preferences.edit().putString(key, Base64.encodeToString(payload, Base64.NO_WRAP)).commit())"
|
||||
errorLine2=" ~~~~~~~~~~~~~~~~~~">
|
||||
<location
|
||||
file="src/main/java/io/narl/protonstream/native/KeystoreSecretStore.kt"
|
||||
line="41"
|
||||
column="15"/>
|
||||
</issue>
|
||||
|
||||
<issue
|
||||
id="UseKtx"
|
||||
message="Use the KTX extension function `SharedPreferences.edit` instead?"
|
||||
errorLine1=" check(preferences.edit().remove(key).commit())"
|
||||
errorLine2=" ~~~~~~~~~~~~~~~~~~">
|
||||
<location
|
||||
file="src/main/java/io/narl/protonstream/native/KeystoreSecretStore.kt"
|
||||
line="64"
|
||||
column="15"/>
|
||||
</issue>
|
||||
|
||||
<issue
|
||||
id="UseKtx"
|
||||
message="Use the KTX extension function `SharedPreferences.edit` instead?"
|
||||
errorLine1=" set(value) { preferences.edit().putBoolean(KEY_WIFI_ONLY, value).apply() }"
|
||||
errorLine2=" ~~~~~~~~~~~~~~~~~~">
|
||||
<location
|
||||
file="src/main/java/io/narl/protonstream/settings/SettingsStore.kt"
|
||||
line="10"
|
||||
column="22"/>
|
||||
</issue>
|
||||
|
||||
<issue
|
||||
id="UseKtx"
|
||||
message="Use the KTX extension function `SharedPreferences.edit` instead?"
|
||||
errorLine1=" set(value) { preferences.edit().putBoolean(KEY_BACKGROUND_AUDIO, value).apply() }"
|
||||
errorLine2=" ~~~~~~~~~~~~~~~~~~">
|
||||
<location
|
||||
file="src/main/java/io/narl/protonstream/settings/SettingsStore.kt"
|
||||
line="14"
|
||||
column="22"/>
|
||||
</issue>
|
||||
|
||||
<issue
|
||||
id="UseKtx"
|
||||
message="Use the KTX extension function `SharedPreferences.edit` instead?"
|
||||
errorLine1=" set(value) { preferences.edit().putBoolean(KEY_AUTO_SKIP, value).apply() }"
|
||||
errorLine2=" ~~~~~~~~~~~~~~~~~~">
|
||||
<location
|
||||
file="src/main/java/io/narl/protonstream/settings/SettingsStore.kt"
|
||||
line="25"
|
||||
column="22"/>
|
||||
</issue>
|
||||
|
||||
<issue
|
||||
id="UseKtx"
|
||||
message="Use the KTX extension function `SharedPreferences.edit` instead?"
|
||||
errorLine1=" set(value) { preferences.edit().putBoolean(KEY_AUTOPLAY, value).apply() }"
|
||||
errorLine2=" ~~~~~~~~~~~~~~~~~~">
|
||||
<location
|
||||
file="src/main/java/io/narl/protonstream/settings/SettingsStore.kt"
|
||||
line="30"
|
||||
column="22"/>
|
||||
</issue>
|
||||
|
||||
</issues>
|
||||
@@ -0,0 +1,98 @@
|
||||
# Keeps that exist so the instrumentation suite can run against the *release*
|
||||
# build. They are applied to the shipping APK, which is a real trade-off worth
|
||||
# stating plainly: the tested artifact is not byte-identical to an artifact
|
||||
# built without them.
|
||||
#
|
||||
# It is the better side of the trade. The alternative is to run instrumentation
|
||||
# against `debug` only, which leaves R8 — the step that has already broken this
|
||||
# app twice, in ways no debug run can reproduce — entirely uncovered. What is
|
||||
# kept here is a few kilobytes of framework plumbing containing no application
|
||||
# logic and revealing nothing about the app.
|
||||
#
|
||||
# Why these keeps are needed at all: the instrumentation APK is minified in a
|
||||
# separate R8 run that consumes the app's mapping file, and links against the
|
||||
# app's classes at runtime. That arrangement fails in two ways.
|
||||
#
|
||||
# 1. The mapping file carries renames but not R8's parameter-permutation
|
||||
# optimisation, so a permuted method resolves to the wrong descriptor from
|
||||
# the test APK.
|
||||
# 2. A class that only the test APK reaches is unreachable from the app's call
|
||||
# graph, so R8 removes it from the app APK — and the test APK does not
|
||||
# bundle it, because it is supposed to come from the app.
|
||||
|
||||
# The Kotlin runtime is where both failures land, because androidx.test is
|
||||
# itself Kotlin and shares the app's copy of the stdlib. Keeping it wholesale
|
||||
# rather than class by class is deliberate: each individual keep only moves the
|
||||
# crash to the next facade the runner happens to touch, which is a list nobody
|
||||
# can enumerate ahead of time. Three were hit before this rule replaced them:
|
||||
#
|
||||
# NoSuchMethodError: No static method f(Ljava/lang/Object;Ljava/lang/String;)V
|
||||
# in class Lo6/k; (o6.k = kotlin.jvm.internal.Intrinsics,
|
||||
# whose parameters R8 had permuted —
|
||||
# every Kotlin class calls it on entry,
|
||||
# so nothing ran at all)
|
||||
# NoClassDefFoundError: Lkotlin/LazyKt; (androidx.test.platform.io.TestDirCalculator)
|
||||
#
|
||||
# Cost is roughly a megabyte of dex against a ~100 MB APK that is almost
|
||||
# entirely native libraries.
|
||||
-keep class kotlin.** { *; }
|
||||
|
||||
# `AndroidJUnitRunner.onCreate` traces its own startup. Nothing in the app
|
||||
# reaches androidx.tracing, so R8 dropped it and the runner could not start:
|
||||
#
|
||||
# java.lang.NoClassDefFoundError: Failed resolution of: Landroidx/tracing/Trace;
|
||||
# at androidx.test.runner.AndroidJUnitRunner.onCreate(AndroidJUnitRunner.java:307)
|
||||
-keep class androidx.tracing.** { *; }
|
||||
|
||||
# A third shape of the same problem, and the one worth understanding: the app
|
||||
# only ever touches WorkManager through its implementation, so R8 vertically
|
||||
# merged the abstract `androidx.work.WorkManager` into `WorkManagerImpl` and the
|
||||
# abstract type stopped existing. It is absent from mapping.txt entirely rather
|
||||
# than renamed. The download tests declare fields of the public types, so JUnit's
|
||||
# field scan could not even construct the test class:
|
||||
#
|
||||
# java.lang.NoClassDefFoundError: Failed resolution of: Landroidx/work/WorkManager;
|
||||
# at org.junit.runners.model.TestClass.getSortedDeclaredFields(TestClass.java:77)
|
||||
#
|
||||
# Kept package-wide because the same merge can happen to any of the public API
|
||||
# types the tests name (WorkInfo, Data, Constraints), and finding out one crash
|
||||
# at a time costs a four-minute device round trip each.
|
||||
-keep class androidx.work.** { *; }
|
||||
|
||||
# Everything above is framework plumbing. The last group is app code, and it is
|
||||
# the one keep in this file that costs real fidelity: R8 no longer optimises
|
||||
# these classes in the shipping build, so the suite no longer proves R8 handles
|
||||
# them correctly. It is a narrow loss — the surfaces R8 has actually
|
||||
# broken here are the FFI ones, and those are kept for functional reasons in
|
||||
# proguard-rules.pro and stay under test.
|
||||
#
|
||||
# The trigger was `DownloadStateStore.clear()`. It is not dead code — AppViewModel
|
||||
# calls it — but it has exactly one call site, so R8 inlined it and dropped the
|
||||
# method. Only a caller that resolves it by name, which is to say a test, notices:
|
||||
#
|
||||
# java.lang.NoSuchMethodError: No virtual method clear()V
|
||||
# in class Lio/narl/protonstream/download/c;
|
||||
#
|
||||
# Package-wide rather than class-by-class: Kotlin's top-level functions live in
|
||||
# a synthesised `...Kt` facade that no source file names, so keeping the classes
|
||||
# a test imports still leaves it missing `DownloadCoordinatorKt`.
|
||||
-keep class io.narl.protonstream.download.** { *; }
|
||||
|
||||
# Same inlining, one layer out: the `wifiOnly` setter behind the download
|
||||
# constraint.
|
||||
# java.lang.NoSuchMethodError: No virtual method setWifiOnly(Z)V in class Li5/a;
|
||||
-keep class io.narl.protonstream.settings.SettingsStore { *; }
|
||||
|
||||
# The live cases drive the player directly, so the same inlining applies to the
|
||||
# playback classes they name.
|
||||
-keep class io.narl.protonstream.playback.** { *; }
|
||||
|
||||
# `runBlocking` — the live cases drive suspending bridge calls from a JUnit
|
||||
# thread. The app itself never blocks a thread on a coroutine, so R8 drops the
|
||||
# facade that holds it:
|
||||
#
|
||||
# java.lang.NoClassDefFoundError: Failed resolution of: Lkotlinx/coroutines/BuildersKt;
|
||||
#
|
||||
# Kept narrowly rather than as `kotlinx.coroutines.**`: that library is large and
|
||||
# on the app's hot paths, and pinning all of it would cost real optimisation.
|
||||
-keep class kotlinx.coroutines.BuildersKt** { *; }
|
||||
@@ -0,0 +1,25 @@
|
||||
-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>;
|
||||
}
|
||||
|
||||
# UniFFI records are Kotlin data classes whose generic signatures and
|
||||
# annotations survive into the runtime; stripping them changes how the bindings
|
||||
# lift and lower structured types.
|
||||
-keepattributes Signature,*Annotation*,InnerClasses,EnclosingMethod
|
||||
|
||||
# JNA dispatches into callbacks reflectively by method name.
|
||||
-keepclassmembers class * implements com.sun.jna.Callback {
|
||||
<methods>;
|
||||
}
|
||||
|
||||
# The bridge calls back into these from Rust. Nothing in the Kotlin call graph
|
||||
# reaches their overrides, so R8 is free to rename or drop them.
|
||||
-keep class io.narl.protonstream.native.KeystoreSecretStore { *; }
|
||||
-keep class * implements uniffi.pstr_android.AndroidSecretStore { *; }
|
||||
-keep class * implements uniffi.pstr_android.DownloadObserver { *; }
|
||||
@@ -0,0 +1,11 @@
|
||||
# Applied only to the instrumentation APK when androidTest runs against a
|
||||
# minified build (ANDROID_TEST_BUILD_TYPE=release). The tests exist to prove the
|
||||
# *app's* R8 configuration is right, so nothing here may relax the app's rules —
|
||||
# it only keeps the test harness itself intact.
|
||||
-dontobfuscate
|
||||
-keep class io.narl.protonstream.** { *; }
|
||||
-keep class androidx.test.** { *; }
|
||||
-dontwarn androidx.test.**
|
||||
-keepclasseswithmembers class * {
|
||||
@org.junit.Test <methods>;
|
||||
}
|
||||
@@ -0,0 +1,129 @@
|
||||
package io.narl.protonstream.download
|
||||
|
||||
import android.content.Context
|
||||
import androidx.test.core.app.ApplicationProvider
|
||||
import androidx.test.ext.junit.runners.AndroidJUnit4
|
||||
import androidx.work.Configuration
|
||||
import androidx.work.NetworkType
|
||||
import androidx.work.WorkInfo
|
||||
import androidx.work.WorkManager
|
||||
import androidx.work.testing.SynchronousExecutor
|
||||
import androidx.work.testing.WorkManagerTestInitHelper
|
||||
import io.narl.protonstream.settings.SettingsStore
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertNotNull
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Before
|
||||
import org.junit.Test
|
||||
import org.junit.runner.RunWith
|
||||
|
||||
/**
|
||||
* What actually reaches WorkManager. The unit tests cover the pure helpers; this
|
||||
* covers the enqueue itself — the input data (which is persisted unencrypted, so
|
||||
* it must carry no secret), the constraints, and the unique-work identity that
|
||||
* pause and resume depend on.
|
||||
*/
|
||||
@RunWith(AndroidJUnit4::class)
|
||||
class DownloadCoordinatorInstrumentedTest {
|
||||
private lateinit var context: Context
|
||||
private lateinit var workManager: WorkManager
|
||||
|
||||
@Before
|
||||
fun setUp() {
|
||||
context = ApplicationProvider.getApplicationContext()
|
||||
WorkManagerTestInitHelper.initializeTestWorkManager(
|
||||
context,
|
||||
Configuration.Builder().setExecutor(SynchronousExecutor()).build(),
|
||||
)
|
||||
workManager = WorkManager.getInstance(context)
|
||||
workManager.cancelAllWork().result.get()
|
||||
DownloadStateStore(context).clear()
|
||||
}
|
||||
|
||||
private fun download(linkId: String = "link-1", shareId: String = "share-1") = RetainedDownload(
|
||||
shareId = shareId,
|
||||
volumeId = "volume-1",
|
||||
linkId = linkId,
|
||||
label = "S01E01.mkv",
|
||||
)
|
||||
|
||||
private fun infoFor(download: RetainedDownload): WorkInfo? =
|
||||
workManager.getWorkInfosForUniqueWork(
|
||||
DownloadCoordinator.workName(download.shareId, download.linkId),
|
||||
).get().firstOrNull()
|
||||
|
||||
@Test
|
||||
fun resumingEnqueuesUniqueWorkAndRetainsAQueuedRecord() {
|
||||
val download = download()
|
||||
DownloadCoordinator.resume(context, download)
|
||||
|
||||
assertNotNull(infoFor(download))
|
||||
assertEquals(
|
||||
RetainedDownload.STATUS_QUEUED,
|
||||
DownloadStateStore(context).get("share-1", "link-1")?.status,
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Tags are persisted in `androidx.work.workdb` unencrypted and are what the
|
||||
* UI queries and what cancel-by-share matches on. Pinning the exact set is
|
||||
* what makes an accidental addition — a fragment, a URL — visible in review.
|
||||
*/
|
||||
@Test
|
||||
fun theWorkCarriesExactlyTheExpectedTags() {
|
||||
val download = download()
|
||||
DownloadCoordinator.resume(context, download)
|
||||
|
||||
val tags = infoFor(download)?.tags.orEmpty()
|
||||
assertTrue(DownloadCoordinator.TAG in tags)
|
||||
assertTrue(DownloadCoordinator.shareTag("share-1") in tags)
|
||||
assertTrue("${DownloadCoordinator.EPISODE_TAG}S01E01.mkv" in tags)
|
||||
// The remaining tag is WorkManager's own worker class name.
|
||||
assertEquals(4, tags.size)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun theWifiOnlySettingReachesTheConstraint() {
|
||||
// Documents B29: the constraint is read at enqueue time, so work already
|
||||
// queued keeps whatever was set when it was queued. When B29 is fixed,
|
||||
// this test grows a case for flipping the toggle after enqueue.
|
||||
SettingsStore(context).wifiOnly = true
|
||||
assertEquals(NetworkType.UNMETERED, requiredNetworkType(SettingsStore(context).wifiOnly))
|
||||
SettingsStore(context).wifiOnly = false
|
||||
assertEquals(NetworkType.CONNECTED, requiredNetworkType(SettingsStore(context).wifiOnly))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun pauseCancelsTheWorkAndRecordsThePausedStatus() {
|
||||
val download = download()
|
||||
DownloadCoordinator.resume(context, download)
|
||||
DownloadCoordinator.pause(context, download)
|
||||
|
||||
assertTrue(infoFor(download)?.state?.isFinished ?: false)
|
||||
assertEquals(
|
||||
RetainedDownload.STATUS_PAUSED,
|
||||
DownloadStateStore(context).get("share-1", "link-1")?.status,
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun cancellingOneShareLeavesAnotherSharesWorkAlone() {
|
||||
val first = download(linkId = "a", shareId = "share-1")
|
||||
val second = download(linkId = "b", shareId = "share-2")
|
||||
DownloadCoordinator.resume(context, first)
|
||||
DownloadCoordinator.resume(context, second)
|
||||
|
||||
workManager.cancelAllWorkByTag(DownloadCoordinator.shareTag("share-1")).result.get()
|
||||
|
||||
assertTrue(infoFor(first)?.state?.isFinished ?: false)
|
||||
assertEquals(WorkInfo.State.ENQUEUED, infoFor(second)?.state)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun twoEpisodesOfOneShareEnqueueSeparately() {
|
||||
DownloadCoordinator.resume(context, download(linkId = "a"))
|
||||
DownloadCoordinator.resume(context, download(linkId = "b"))
|
||||
|
||||
assertEquals(2, workManager.getWorkInfosByTag(DownloadCoordinator.TAG).get().size)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,274 @@
|
||||
package io.narl.protonstream.live
|
||||
|
||||
import android.graphics.SurfaceTexture
|
||||
import android.view.Surface
|
||||
import androidx.test.ext.junit.runners.AndroidJUnit4
|
||||
import androidx.test.platform.app.InstrumentationRegistry
|
||||
import io.narl.protonstream.playback.NativeMpvHost
|
||||
import java.io.File
|
||||
import java.util.concurrent.ConcurrentHashMap
|
||||
import java.util.concurrent.atomic.AtomicBoolean
|
||||
import java.util.concurrent.atomic.AtomicLong
|
||||
import kotlinx.coroutines.runBlocking
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertNotNull
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Assume.assumeTrue
|
||||
import org.junit.BeforeClass
|
||||
import org.junit.Test
|
||||
import org.junit.runner.RunWith
|
||||
import uniffi.pstr_android.AndroidEngine
|
||||
import uniffi.pstr_android.AndroidPaths
|
||||
import uniffi.pstr_android.AndroidSecretStore
|
||||
import uniffi.pstr_android.DownloadObserver
|
||||
import uniffi.pstr_android.EpisodeRecord
|
||||
|
||||
/**
|
||||
* The cases `docs/ANDROID.md` requires that cannot be answered without a real
|
||||
* share: that a link resolves into a playable catalog, that libmpv actually
|
||||
* decodes the decrypted stream, that a cancelled download leaves a resumable
|
||||
* `.part`, and that a resume position survives a restart.
|
||||
*
|
||||
* The link is an instrumentation argument rather than a constant or a file, so
|
||||
* the secret never enters the repository:
|
||||
*
|
||||
* ```
|
||||
* -Pandroid.testInstrumentationRunnerArguments.pstr.shareUrl=...
|
||||
* ```
|
||||
*
|
||||
* Without it every test here is skipped by assumption, and
|
||||
* `scripts/android-acceptance.sh` reports the matrix as incomplete.
|
||||
*
|
||||
* Read-only against the share by construction: nothing here uploads, renames or
|
||||
* deletes anything remote. It writes only to this test's own temporary
|
||||
* directories.
|
||||
*/
|
||||
@RunWith(AndroidJUnit4::class)
|
||||
class LiveShareTest {
|
||||
@Test
|
||||
fun theShareResolvesIntoAPlayableCatalog() {
|
||||
val episodes = episodes()
|
||||
assertTrue("the crawl found no playable file in the share", episodes.isNotEmpty())
|
||||
// A file with no size cannot be streamed: the block layer needs the
|
||||
// length to place block boundaries at all.
|
||||
val sized = episodes.filter { (it.size ?: 0uL) > 0uL }
|
||||
assertTrue("no episode in the share reported a size", sized.isNotEmpty())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun openingAnEpisodeYieldsASeekableRevision() {
|
||||
val episode = playable()
|
||||
val stream = runBlocking {
|
||||
engine.openStream(episode.shareId, episode.volumeId, episode.linkId)
|
||||
}
|
||||
try {
|
||||
assertTrue("the opened revision has no length", stream.size() > 0uL)
|
||||
// Without a revision id the block cache cannot tell one revision of
|
||||
// a file from another, and a stale block would be served as fresh.
|
||||
assertTrue("the opened revision has no id", stream.revisionId().isNotBlank())
|
||||
} finally {
|
||||
runBlocking { engine.releaseStream(episode.shareId, episode.volumeId, episode.linkId) }
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The whole pipeline in one assertion: Proton block storage, decryption,
|
||||
* the Rust stream C ABI, the JNI adapter and libmpv's demuxer. If the
|
||||
* position advances, real decrypted bytes reached the decoder.
|
||||
*/
|
||||
@Test
|
||||
fun libmpvDecodesTheDecryptedStream() {
|
||||
val episode = playable()
|
||||
val texture = SurfaceTexture(0).apply { setDefaultBufferSize(1280, 720) }
|
||||
val surface = Surface(texture)
|
||||
val host = NativeMpvHost()
|
||||
val key = "${episode.shareId}:${episode.linkId}"
|
||||
try {
|
||||
host.attachSurface(surface)
|
||||
val stream = runBlocking {
|
||||
engine.openStream(episode.shareId, episode.volumeId, episode.linkId)
|
||||
}
|
||||
runBlocking { host.play(key, stream.nativeHandle(), stream.size()) }
|
||||
host.setMuted(true)
|
||||
host.setPaused(false)
|
||||
|
||||
val playing = waitFor(PLAYBACK_TIMEOUT_MS) { host.state.value.position > 0.0 }
|
||||
assertTrue(
|
||||
"libmpv never advanced past zero: ${host.state.value}",
|
||||
playing,
|
||||
)
|
||||
// One host serves every episode in turn, so a reading that does not
|
||||
// name this one would resume the wrong file.
|
||||
assertEquals(key, host.state.value.media)
|
||||
assertTrue("libmpv reported no duration", host.state.value.duration > 0.0)
|
||||
} finally {
|
||||
host.stop()
|
||||
host.detachSurface()
|
||||
host.close()
|
||||
surface.release()
|
||||
texture.release()
|
||||
runBlocking { engine.releaseStream(episode.shareId, episode.volumeId, episode.linkId) }
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Cancellation is polled between whole Proton blocks precisely so the bytes
|
||||
* already written stay usable. This checks the retained `.part` is counted
|
||||
* as partial rather than as a finished file, and that resuming continues
|
||||
* from it instead of starting over.
|
||||
*/
|
||||
@Test
|
||||
fun aCancelledDownloadLeavesAResumablePartFile() {
|
||||
val episode = playable()
|
||||
val firstRun = CancelAfter(bytes = RESUME_THRESHOLD_BYTES)
|
||||
runCatching {
|
||||
runBlocking {
|
||||
engine.downloadEpisode(episode.shareId, episode.volumeId, episode.linkId, firstRun)
|
||||
}
|
||||
}
|
||||
assumeTrue(
|
||||
"the episode finished before cancellation could be observed; it is too small for this case",
|
||||
!firstRun.completed,
|
||||
)
|
||||
assertTrue("the download reported no progress at all", firstRun.observed > 0L)
|
||||
|
||||
val retained = engine.storageUsage()
|
||||
assertTrue("a cancelled download retained no bytes", retained.partialBytes > 0uL)
|
||||
assertEquals("a cancelled download counted as a finished file", 0uL, retained.offlineCount)
|
||||
|
||||
// Resuming must pick up where the .part ended rather than at zero.
|
||||
val secondRun = CancelAfter(bytes = retained.partialBytes.toLong() + RESUME_THRESHOLD_BYTES)
|
||||
runCatching {
|
||||
runBlocking {
|
||||
engine.downloadEpisode(episode.shareId, episode.volumeId, episode.linkId, secondRun)
|
||||
}
|
||||
}
|
||||
assertTrue(
|
||||
"resuming restarted from zero: first reading was ${secondRun.firstReading}, " +
|
||||
"but ${retained.partialBytes} bytes were already retained",
|
||||
secondRun.firstReading >= retained.partialBytes.toLong(),
|
||||
)
|
||||
|
||||
runBlocking { engine.removeOfflineEpisode(episode.shareId, episode.linkId) }
|
||||
assertEquals(
|
||||
"removing the episode left partial bytes behind",
|
||||
0uL,
|
||||
engine.storageUsage().partialBytes,
|
||||
)
|
||||
}
|
||||
|
||||
/** What the viewer sees as "continue watching" after the app is killed. */
|
||||
@Test
|
||||
fun aResumePositionSurvivesReopeningTheCatalog() {
|
||||
val episode = playable()
|
||||
engine.saveWatchState(episode.shareId, episode.linkId, RESUME_POSITION, null, false)
|
||||
|
||||
val reopened = AndroidEngine(paths, secrets)
|
||||
val state = reopened.watchState(episode.shareId, episode.linkId)
|
||||
assertNotNull("the resume position did not survive a reopen", state)
|
||||
assertEquals(RESUME_POSITION, state!!.positionSecs, 0.001)
|
||||
assertEquals(false, state.watched)
|
||||
|
||||
// The catalog view is what the UI reads, so it has to agree.
|
||||
val listed = reopened.library(null)
|
||||
.flatMap { it.seasons }
|
||||
.flatMap { it.episodes }
|
||||
.first { it.linkId == episode.linkId }
|
||||
assertEquals(RESUME_POSITION, listed.resumeAt ?: -1.0, 0.001)
|
||||
}
|
||||
|
||||
/** Cancels once the download has written enough to prove a resume. */
|
||||
private class CancelAfter(private val bytes: Long) : DownloadObserver {
|
||||
private val cancelled = AtomicBoolean(false)
|
||||
private val progress = AtomicLong(0)
|
||||
private val first = AtomicLong(-1)
|
||||
|
||||
val observed: Long get() = progress.get()
|
||||
val firstReading: Long get() = first.get()
|
||||
val completed: Boolean get() = !cancelled.get()
|
||||
|
||||
override fun onProgress(downloaded: ULong, total: ULong) {
|
||||
first.compareAndSet(-1, downloaded.toLong())
|
||||
progress.set(downloaded.toLong())
|
||||
if (downloaded.toLong() >= bytes) cancelled.set(true)
|
||||
}
|
||||
|
||||
override fun isCancelled(): Boolean = cancelled.get()
|
||||
}
|
||||
|
||||
private fun episodes(): List<EpisodeRecord> =
|
||||
engine.library(null).flatMap { it.seasons }.flatMap { it.episodes }
|
||||
|
||||
private fun playable(): EpisodeRecord =
|
||||
episodes().firstOrNull { (it.size ?: 0uL) > 0uL }
|
||||
?: throw AssertionError("the share has no sized file to play")
|
||||
|
||||
private fun waitFor(timeoutMs: Long, condition: () -> Boolean): Boolean {
|
||||
val deadline = System.currentTimeMillis() + timeoutMs
|
||||
while (System.currentTimeMillis() < deadline) {
|
||||
if (condition()) return true
|
||||
Thread.sleep(200)
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
/** Stands in for Keystore, so a bridge failure cannot be blamed on crypto. */
|
||||
private class MemorySecretStore : AndroidSecretStore {
|
||||
private val entries = ConcurrentHashMap<String, String>()
|
||||
|
||||
override fun set(key: String, value: String) {
|
||||
entries[key] = value
|
||||
}
|
||||
|
||||
override fun get(key: String): String? = entries[key]
|
||||
|
||||
override fun delete(key: String) {
|
||||
entries.remove(key)
|
||||
}
|
||||
}
|
||||
|
||||
companion object {
|
||||
private const val SHARE_URL_ARGUMENT = "pstr.shareUrl"
|
||||
private const val SHARE_PASSWORD_ARGUMENT = "pstr.sharePassword"
|
||||
|
||||
/** Long enough for a cold connection, a crawl and a first block read. */
|
||||
private const val PLAYBACK_TIMEOUT_MS = 60_000L
|
||||
|
||||
/** Enough written bytes to prove a resume, small enough to stay quick. */
|
||||
private const val RESUME_THRESHOLD_BYTES = 4L * 1024 * 1024
|
||||
|
||||
private const val RESUME_POSITION = 137.5
|
||||
|
||||
private lateinit var paths: AndroidPaths
|
||||
private lateinit var secrets: AndroidSecretStore
|
||||
private lateinit var engine: AndroidEngine
|
||||
|
||||
@BeforeClass
|
||||
@JvmStatic
|
||||
fun openTheShare() {
|
||||
System.loadLibrary("pstr_android")
|
||||
val arguments = InstrumentationRegistry.getArguments()
|
||||
val url = arguments.getString(SHARE_URL_ARGUMENT)
|
||||
assumeTrue(
|
||||
"set -Pandroid.testInstrumentationRunnerArguments.$SHARE_URL_ARGUMENT to run the live cases",
|
||||
!url.isNullOrBlank(),
|
||||
)
|
||||
val password = arguments.getString(SHARE_PASSWORD_ARGUMENT)?.takeIf { it.isNotBlank() }
|
||||
|
||||
val root = File.createTempFile("pstr-live", "").let {
|
||||
it.delete()
|
||||
it.mkdirs()
|
||||
it
|
||||
}
|
||||
paths = AndroidPaths(
|
||||
File(root, "config").absolutePath,
|
||||
File(root, "data").absolutePath,
|
||||
File(root, "cache").absolutePath,
|
||||
)
|
||||
secrets = MemorySecretStore()
|
||||
engine = AndroidEngine(paths, secrets)
|
||||
engine.addShare("acceptance", url!!, password)
|
||||
runBlocking { engine.crawl(null) }
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,155 @@
|
||||
package io.narl.protonstream.native
|
||||
|
||||
import android.content.Context
|
||||
import android.util.Base64
|
||||
import androidx.test.core.app.ApplicationProvider
|
||||
import androidx.test.ext.junit.runners.AndroidJUnit4
|
||||
import java.security.KeyStore
|
||||
import org.junit.After
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertThrows
|
||||
import org.junit.Assert.assertNotEquals
|
||||
import org.junit.Assert.assertNull
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Before
|
||||
import org.junit.Test
|
||||
import org.junit.runner.RunWith
|
||||
import uniffi.pstr_android.BridgeException
|
||||
|
||||
/**
|
||||
* Runs on a device because Android Keystore has no host implementation — which
|
||||
* is why nothing pinned this before. The share fragment and any custom link
|
||||
* password are the only user secrets the app holds, and losing the key makes
|
||||
* every share unopenable, so the failure modes matter as much as the happy path.
|
||||
*/
|
||||
@RunWith(AndroidJUnit4::class)
|
||||
class KeystoreSecretStoreTest {
|
||||
private lateinit var context: Context
|
||||
private lateinit var store: KeystoreSecretStore
|
||||
|
||||
@Before
|
||||
fun setUp() {
|
||||
context = ApplicationProvider.getApplicationContext()
|
||||
clearPreferences()
|
||||
store = KeystoreSecretStore(context)
|
||||
}
|
||||
|
||||
@After
|
||||
fun tearDown() {
|
||||
clearPreferences()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun aSecretSurvivesARoundTrip() {
|
||||
store.set("share-1", "correct horse battery staple")
|
||||
assertEquals("correct horse battery staple", store.get("share-1"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun anAbsentSecretIsNullRatherThanEmpty() {
|
||||
assertNull(store.get("never-written"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun deleteRemovesTheSecret() {
|
||||
store.set("share-1", "value")
|
||||
store.delete("share-1")
|
||||
assertNull(store.get("share-1"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun aUnicodeSecretIsNotMangled() {
|
||||
val value = "パスワード・🎬・ü"
|
||||
store.set("share-1", value)
|
||||
assertEquals(value, store.get("share-1"))
|
||||
}
|
||||
|
||||
/**
|
||||
* GCM is catastrophic under IV reuse: two secrets encrypted with the same
|
||||
* key and IV leak the XOR of their plaintexts. `Cipher.init(ENCRYPT_MODE)`
|
||||
* is what generates a fresh one, so this pins that the code never pins it.
|
||||
*/
|
||||
@Test
|
||||
fun everyWriteUsesAFreshInitialisationVector() {
|
||||
store.set("share-1", "same plaintext")
|
||||
val first = rawPayload("share-1")
|
||||
store.set("share-1", "same plaintext")
|
||||
val second = rawPayload("share-1")
|
||||
|
||||
val firstIv = first.copyOfRange(0, IV_SIZE)
|
||||
val secondIv = second.copyOfRange(0, IV_SIZE)
|
||||
assertNotEquals(Base64.encodeToString(firstIv, Base64.NO_WRAP), Base64.encodeToString(secondIv, Base64.NO_WRAP))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun theStoredFormIsNotThePlaintext() {
|
||||
store.set("share-1", "correct horse battery staple")
|
||||
val stored = context.getSharedPreferences(PREFERENCES, Context.MODE_PRIVATE)
|
||||
.getString("share-1", null)
|
||||
assertTrue(stored != null && !stored.contains("correct horse"))
|
||||
}
|
||||
|
||||
/**
|
||||
* A tampered payload must not decrypt, and must fail as a [BridgeException].
|
||||
* An `AEADBadTagException` crossing the FFI has no mapping on the Rust side,
|
||||
* and an unmapped callback exception panics the calling thread (B18).
|
||||
*/
|
||||
@Test
|
||||
fun aTamperedPayloadFailsToDecrypt() {
|
||||
store.set("share-1", "value")
|
||||
val payload = rawPayload("share-1")
|
||||
payload[payload.size - 1] = (payload[payload.size - 1].toInt() xor 0x01).toByte()
|
||||
writeRawPayload("share-1", payload)
|
||||
|
||||
assertThrows(BridgeException::class.java) { store.get("share-1") }
|
||||
}
|
||||
|
||||
@Test
|
||||
fun aTruncatedPayloadIsRejectedBeforeDecryption() {
|
||||
store.set("share-1", "value")
|
||||
writeRawPayload("share-1", rawPayload("share-1").copyOfRange(0, IV_SIZE))
|
||||
|
||||
assertThrows(BridgeException::class.java) { store.get("share-1") }
|
||||
}
|
||||
|
||||
/**
|
||||
* The device-side half of B18: this is what a lockscreen change or a device
|
||||
* restore does to the key. It must fail the read rather than the process,
|
||||
* and the store must still accept a re-entered secret afterwards — which is
|
||||
* what the shares screen's "Re-enter link" depends on.
|
||||
*/
|
||||
@Test
|
||||
fun aLostKeyFailsTheReadRatherThanTheProcess() {
|
||||
store.set("share-1", "value")
|
||||
KeyStore.getInstance("AndroidKeyStore").apply { load(null) }.deleteEntry(KEY_ALIAS)
|
||||
|
||||
assertThrows(BridgeException::class.java) { store.get("share-1") }
|
||||
|
||||
// The store must still be usable for a re-entered secret.
|
||||
store.set("share-1", "re-entered")
|
||||
assertEquals("re-entered", store.get("share-1"))
|
||||
}
|
||||
|
||||
private fun rawPayload(key: String): ByteArray {
|
||||
val encoded = context.getSharedPreferences(PREFERENCES, Context.MODE_PRIVATE)
|
||||
.getString(key, null) ?: error("no payload for $key")
|
||||
return Base64.decode(encoded, Base64.NO_WRAP)
|
||||
}
|
||||
|
||||
private fun writeRawPayload(key: String, payload: ByteArray) {
|
||||
context.getSharedPreferences(PREFERENCES, Context.MODE_PRIVATE)
|
||||
.edit()
|
||||
.putString(key, Base64.encodeToString(payload, Base64.NO_WRAP))
|
||||
.commit()
|
||||
}
|
||||
|
||||
private fun clearPreferences() {
|
||||
context.getSharedPreferences(PREFERENCES, Context.MODE_PRIVATE).edit().clear().commit()
|
||||
}
|
||||
|
||||
private companion object {
|
||||
const val PREFERENCES = "proton_stream_secrets"
|
||||
const val KEY_ALIAS = "proton-stream-share-secrets-v1"
|
||||
const val IV_SIZE = 12
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,117 @@
|
||||
package io.narl.protonstream.native
|
||||
|
||||
import androidx.test.ext.junit.runners.AndroidJUnit4
|
||||
import java.io.File
|
||||
import java.util.concurrent.ConcurrentHashMap
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertNotNull
|
||||
import org.junit.Assert.assertNull
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.BeforeClass
|
||||
import org.junit.Test
|
||||
import org.junit.runner.RunWith
|
||||
import uniffi.pstr_android.AndroidEngine
|
||||
import uniffi.pstr_android.AndroidPaths
|
||||
import uniffi.pstr_android.AndroidSecretStore
|
||||
import uniffi.pstr_android.chapterPlan
|
||||
import uniffi.pstr_android.skipOffer
|
||||
|
||||
/**
|
||||
* The first thing that crosses the UniFFI boundary on a real device. It is
|
||||
* deliberately offline: it proves the ABI, the generated bindings, the callback
|
||||
* interface and the SQLite open all work, without needing a share.
|
||||
*
|
||||
* If this fails and the Rust workspace tests pass, the fault is in packaging —
|
||||
* a missing ABI in `jniLibs`, a stale binding, or an R8 rule (run it with
|
||||
* `ANDROID_TEST_BUILD_TYPE=release` to check the shipped configuration).
|
||||
*/
|
||||
@RunWith(AndroidJUnit4::class)
|
||||
class UniFfiBoundaryTest {
|
||||
/** Stands in for Keystore so a bridge failure cannot be blamed on crypto. */
|
||||
private class MemorySecretStore : AndroidSecretStore {
|
||||
val entries = ConcurrentHashMap<String, String>()
|
||||
var reads = 0
|
||||
private set
|
||||
|
||||
override fun set(key: String, value: String) {
|
||||
entries[key] = value
|
||||
}
|
||||
|
||||
override fun get(key: String): String? {
|
||||
reads += 1
|
||||
return entries[key]
|
||||
}
|
||||
|
||||
override fun delete(key: String) {
|
||||
entries.remove(key)
|
||||
}
|
||||
}
|
||||
|
||||
private fun engine(secrets: AndroidSecretStore = MemorySecretStore()): AndroidEngine {
|
||||
val root = File.createTempFile("pstr-bridge", "").let {
|
||||
it.delete()
|
||||
it.mkdirs()
|
||||
it
|
||||
}
|
||||
return AndroidEngine(
|
||||
AndroidPaths(
|
||||
File(root, "config").absolutePath,
|
||||
File(root, "data").absolutePath,
|
||||
File(root, "cache").absolutePath,
|
||||
),
|
||||
secrets,
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun theEngineOpensAgainstEmptyDirectories() {
|
||||
val engine = engine()
|
||||
assertTrue(engine.shares().isEmpty())
|
||||
assertTrue(engine.library(null).isEmpty())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun storageUsageReportsZeroOnAFreshInstall() {
|
||||
val usage = engine().storageUsage()
|
||||
assertNotNull(usage)
|
||||
assertEquals(0UL, usage.offlineBytes)
|
||||
assertEquals(0UL, usage.partialBytes)
|
||||
assertEquals(0UL, usage.offlineCount)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun metadataSettingsAreReadableAndDefaultToDisabled() {
|
||||
// Enrichment is opt-in; a default of enabled would send titles to a
|
||||
// third party before the viewer has agreed to it.
|
||||
assertEquals(false, engine().metadataSettings().enabled)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun aSecondEngineReopensTheSameCatalog() {
|
||||
val secrets = MemorySecretStore()
|
||||
engine(secrets).shares()
|
||||
// Reopening is the process-restart path; a schema or lock fault shows up
|
||||
// here rather than on a user's device.
|
||||
assertTrue(engine(secrets).shares().isEmpty())
|
||||
}
|
||||
|
||||
/**
|
||||
* Chapter classification is shared Rust that desktop already pins
|
||||
* (`docs/BUGS.md` B12). This runs the same code through the bindings, so a
|
||||
* lowering fault in the record types cannot pass unnoticed.
|
||||
*/
|
||||
@Test
|
||||
fun chapterPlanningCrossesTheBoundaryIntact() {
|
||||
val plan = chapterPlan(emptyList(), 1560.0, emptyList())
|
||||
assertNotNull(plan)
|
||||
assertNull(skipOffer(plan, 12.0))
|
||||
}
|
||||
|
||||
companion object {
|
||||
@BeforeClass
|
||||
@JvmStatic
|
||||
fun loadLibrary() {
|
||||
System.loadLibrary("pstr_android")
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,73 @@
|
||||
<?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" />
|
||||
|
||||
<!--
|
||||
Android TV: listed in its launcher, and installable without a touch
|
||||
screen. Neither is required, so phones are offered the same build.
|
||||
-->
|
||||
<uses-feature android:name="android.software.leanback" android:required="false" />
|
||||
<uses-feature android:name="android.hardware.touchscreen" android:required="false" />
|
||||
|
||||
<application
|
||||
android:name=".ProtonStreamApplication"
|
||||
android:allowBackup="false"
|
||||
android:dataExtractionRules="@xml/data_extraction_rules"
|
||||
android:enableOnBackInvokedCallback="true"
|
||||
tools:targetApi="33"
|
||||
android:icon="@mipmap/ic_launcher"
|
||||
android:banner="@drawable/tv_banner"
|
||||
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:label="@string/launcher_name"
|
||||
android:configChanges="screenSize|smallestScreenSize|screenLayout|orientation|uiMode|density|fontScale|locale|layoutDirection|keyboardHidden"
|
||||
android:exported="true"
|
||||
android:launchMode="singleTask"
|
||||
android:resizeableActivity="true">
|
||||
<intent-filter>
|
||||
<action android:name="android.intent.action.MAIN" />
|
||||
<category android:name="android.intent.category.LAUNCHER" />
|
||||
<category android:name="android.intent.category.LEANBACK_LAUNCHER" />
|
||||
</intent-filter>
|
||||
<!--
|
||||
Proton Drive public links. autoVerify="false", said explicitly:
|
||||
the domain is Proton's, so this can never be a verified App Link.
|
||||
Android only opens these here once "Open supported links" is
|
||||
allowed in the app's settings; sharing a link to the app works
|
||||
without that.
|
||||
-->
|
||||
<intent-filter android:autoVerify="false">
|
||||
<action android:name="android.intent.action.VIEW" />
|
||||
<category android:name="android.intent.category.DEFAULT" />
|
||||
<category android:name="android.intent.category.BROWSABLE" />
|
||||
<data android:scheme="https" android:host="drive.proton.me" android:pathPrefix="/urls/" />
|
||||
</intent-filter>
|
||||
<intent-filter>
|
||||
<action android:name="android.intent.action.SEND" />
|
||||
<category android:name="android.intent.category.DEFAULT" />
|
||||
<data android:mimeType="text/plain" />
|
||||
</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,92 @@
|
||||
Copyright (c) 2016 The Inter Project Authors (https://github.com/rsms/inter)
|
||||
|
||||
This Font Software is licensed under the SIL Open Font License, Version 1.1.
|
||||
This license is copied below, and is also available with a FAQ at:
|
||||
http://scripts.sil.org/OFL
|
||||
|
||||
-----------------------------------------------------------
|
||||
SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007
|
||||
-----------------------------------------------------------
|
||||
|
||||
PREAMBLE
|
||||
The goals of the Open Font License (OFL) are to stimulate worldwide
|
||||
development of collaborative font projects, to support the font creation
|
||||
efforts of academic and linguistic communities, and to provide a free and
|
||||
open framework in which fonts may be shared and improved in partnership
|
||||
with others.
|
||||
|
||||
The OFL allows the licensed fonts to be used, studied, modified and
|
||||
redistributed freely as long as they are not sold by themselves. The
|
||||
fonts, including any derivative works, can be bundled, embedded,
|
||||
redistributed and/or sold with any software provided that any reserved
|
||||
names are not used by derivative works. The fonts and derivatives,
|
||||
however, cannot be released under any other type of license. The
|
||||
requirement for fonts to remain under this license does not apply
|
||||
to any document created using the fonts or their derivatives.
|
||||
|
||||
DEFINITIONS
|
||||
"Font Software" refers to the set of files released by the Copyright
|
||||
Holder(s) under this license and clearly marked as such. This may
|
||||
include source files, build scripts and documentation.
|
||||
|
||||
"Reserved Font Name" refers to any names specified as such after the
|
||||
copyright statement(s).
|
||||
|
||||
"Original Version" refers to the collection of Font Software components as
|
||||
distributed by the Copyright Holder(s).
|
||||
|
||||
"Modified Version" refers to any derivative made by adding to, deleting,
|
||||
or substituting -- in part or in whole -- any of the components of the
|
||||
Original Version, by changing formats or by porting the Font Software to a
|
||||
new environment.
|
||||
|
||||
"Author" refers to any designer, engineer, programmer, technical
|
||||
writer or other person who contributed to the Font Software.
|
||||
|
||||
PERMISSION AND CONDITIONS
|
||||
Permission is hereby granted, free of charge, to any person obtaining
|
||||
a copy of the Font Software, to use, study, copy, merge, embed, modify,
|
||||
redistribute, and sell modified and unmodified copies of the Font
|
||||
Software, subject to the following conditions:
|
||||
|
||||
1) Neither the Font Software nor any of its individual components,
|
||||
in Original or Modified Versions, may be sold by itself.
|
||||
|
||||
2) Original or Modified Versions of the Font Software may be bundled,
|
||||
redistributed and/or sold with any software, provided that each copy
|
||||
contains the above copyright notice and this license. These can be
|
||||
included either as stand-alone text files, human-readable headers or
|
||||
in the appropriate machine-readable metadata fields within text or
|
||||
binary files as long as those fields can be easily viewed by the user.
|
||||
|
||||
3) No Modified Version of the Font Software may use the Reserved Font
|
||||
Name(s) unless explicit written permission is granted by the corresponding
|
||||
Copyright Holder. This restriction only applies to the primary font name as
|
||||
presented to the users.
|
||||
|
||||
4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font
|
||||
Software shall not be used to promote, endorse or advertise any
|
||||
Modified Version, except to acknowledge the contribution(s) of the
|
||||
Copyright Holder(s) and the Author(s) or with their explicit written
|
||||
permission.
|
||||
|
||||
5) The Font Software, modified or unmodified, in part or in whole,
|
||||
must be distributed entirely under this license, and must not be
|
||||
distributed under any other license. The requirement for fonts to
|
||||
remain under this license does not apply to any document created
|
||||
using the Font Software.
|
||||
|
||||
TERMINATION
|
||||
This license becomes null and void if any of the above conditions are
|
||||
not met.
|
||||
|
||||
DISCLAIMER
|
||||
THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
|
||||
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF
|
||||
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT
|
||||
OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE
|
||||
COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
|
||||
INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL
|
||||
DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
|
||||
FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM
|
||||
OTHER DEALINGS IN THE FONT SOFTWARE.
|
||||
@@ -0,0 +1,21 @@
|
||||
# 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 r30) | 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 |
|
||||
| Inter typeface (bundled, `Inter-OFL.txt`) | OFL-1.1 |
|
||||
| proton-stream reusable Rust crates | MIT |
|
||||
|
||||
Before release, replace this dependency summary with an inventory generated
|
||||
from the resolved Cargo and Gradle dependency graphs and package every required
|
||||
license text. A bundled libmpv release must identify the exact source revision,
|
||||
patches, and build configuration, and make the corresponding source available
|
||||
under the GPL.
|
||||
@@ -0,0 +1,29 @@
|
||||
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")
|
||||
# libmpv links FFmpeg dynamically and does not re-export it, so registering the
|
||||
# JavaVM that MediaCodec needs means linking libavcodec ourselves. It is already
|
||||
# packaged in the same ABI directory as part of libmpv's dependency closure.
|
||||
add_library(avcodec SHARED IMPORTED)
|
||||
set_target_properties(avcodec PROPERTIES
|
||||
IMPORTED_LOCATION "${PSTR_MPV_ROOT}/jniLibs/${ANDROID_ABI}/libavcodec.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 avcodec android EGL log)
|
||||
@@ -0,0 +1,17 @@
|
||||
#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);
|
||||
/// Interrupts an in-flight `pstr_android_stream_read`. A cancel with no reader
|
||||
/// waiting is dropped, so this is safe to call speculatively.
|
||||
void pstr_android_stream_cancel(uint64_t handle);
|
||||
void pstr_android_stream_release(uint64_t handle);
|
||||
#ifdef __cplusplus
|
||||
}
|
||||
#endif
|
||||
@@ -0,0 +1,22 @@
|
||||
package io.narl.protonstream
|
||||
|
||||
import android.content.Intent
|
||||
|
||||
/**
|
||||
* A Proton Drive public link, wherever in a piece of text it sits.
|
||||
*
|
||||
* The fragment is kept: it is the share's secret, and a link without it cannot
|
||||
* be opened. Matching stops at whitespace and at the quotes and brackets a
|
||||
* message app wraps a link in, which never occur in a link itself.
|
||||
*/
|
||||
private val SHARE_LINK = Regex("""https://drive\.proton\.me/urls/[^\s"'<>()\[\]]+""")
|
||||
|
||||
/** The share link an `ACTION_VIEW` URL or an `ACTION_SEND` text carries, if any. */
|
||||
internal fun shareLinkFrom(action: String?, url: String?, text: CharSequence?): String? = when (action) {
|
||||
Intent.ACTION_VIEW -> url?.let { SHARE_LINK.matchEntire(it)?.value }
|
||||
Intent.ACTION_SEND -> text?.let { SHARE_LINK.find(it)?.value }
|
||||
else -> null
|
||||
}
|
||||
|
||||
internal fun Intent.shareLink(): String? =
|
||||
shareLinkFrom(action, dataString, getCharSequenceExtra(Intent.EXTRA_TEXT))
|
||||
@@ -0,0 +1,148 @@
|
||||
package io.narl.protonstream
|
||||
|
||||
import android.Manifest
|
||||
import android.content.ComponentName
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.content.ServiceConnection
|
||||
import android.content.pm.PackageManager
|
||||
import android.os.Build
|
||||
import android.os.Bundle
|
||||
import android.os.IBinder
|
||||
import androidx.activity.ComponentActivity
|
||||
import androidx.activity.compose.setContent
|
||||
import androidx.activity.enableEdgeToEdge
|
||||
import androidx.activity.result.contract.ActivityResultContracts
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.core.graphics.drawable.toDrawable
|
||||
import androidx.core.view.WindowCompat
|
||||
import androidx.lifecycle.lifecycleScope
|
||||
import io.narl.protonstream.native.NativeRuntime
|
||||
import io.narl.protonstream.playback.NativeMpvHost
|
||||
import io.narl.protonstream.playback.PlaybackService
|
||||
import io.narl.protonstream.settings.SettingsStore
|
||||
import io.narl.protonstream.sync.NewEpisodesWorker
|
||||
import io.narl.protonstream.sync.WatchSyncWorker
|
||||
import io.narl.protonstream.ui.ProtonStreamApp
|
||||
import io.narl.protonstream.ui.theme.AppearanceState
|
||||
import io.narl.protonstream.ui.theme.ProtonStreamTheme
|
||||
import kotlinx.coroutines.flow.distinctUntilChanged
|
||||
import kotlinx.coroutines.flow.filterNotNull
|
||||
import kotlinx.coroutines.launch
|
||||
import uniffi.pstr_android.PaletteRecord
|
||||
|
||||
class MainActivity : ComponentActivity() {
|
||||
private val playerHost = mutableStateOf<NativeMpvHost?>(null)
|
||||
|
||||
/** A share link handed over by a browser or the share sheet, until the Add form takes it. */
|
||||
private val incomingShareLink = mutableStateOf<String?>(null)
|
||||
/**
|
||||
* Whether a bind is outstanding, which is what has to be unbound.
|
||||
*
|
||||
* Set when `bindService` is called rather than when the connection arrives:
|
||||
* an activity destroyed while the bind is still pending owes the system an
|
||||
* `unbindService` just the same, and `onServiceConnected` may never run.
|
||||
*/
|
||||
private var bound = false
|
||||
private val playbackConnection = object : ServiceConnection {
|
||||
override fun onServiceConnected(name: ComponentName?, service: IBinder?) {
|
||||
playerHost.value = (service as PlaybackService.PlaybackBinder).host
|
||||
}
|
||||
override fun onServiceDisconnected(name: ComponentName?) {
|
||||
playerHost.value = null
|
||||
}
|
||||
}
|
||||
|
||||
private val notificationPermission = registerForActivityResult(
|
||||
ActivityResultContracts.RequestPermission(),
|
||||
) { /* Downloads remain usable; Android suppresses their notifications if denied. */ }
|
||||
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
super.onCreate(savedInstanceState)
|
||||
// Before anything draws. The palette decides the window's own
|
||||
// background and which way round the system bars' icons go, and both
|
||||
// are wrong for a frame if they are set from a composition instead.
|
||||
// Infallible by construction — an unreadable theme file resolves to the
|
||||
// shipped default rather than throwing.
|
||||
runCatching { NativeRuntime.storedPalette() }.onSuccess(AppearanceState::seed)
|
||||
// Only on a fresh start: a recreated activity is handed the intent it
|
||||
// was launched with again, and would offer to add the same share twice.
|
||||
if (savedInstanceState == null) incomingShareLink.value = intent.shareLink()
|
||||
enableEdgeToEdge()
|
||||
// Re-applied on every change, not just at startup: picking Latte from
|
||||
// the settings page has to turn the status bar's icons dark in the same
|
||||
// frame the page behind them turns light.
|
||||
lifecycleScope.launch {
|
||||
AppearanceState.palette.filterNotNull().distinctUntilChanged().collect(::dressWindow)
|
||||
}
|
||||
bound = bindService(
|
||||
Intent(this, PlaybackService::class.java),
|
||||
playbackConnection,
|
||||
Context.BIND_AUTO_CREATE,
|
||||
)
|
||||
NewEpisodesWorker.schedule(this)
|
||||
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,
|
||||
shareLink = incomingShareLink.value,
|
||||
onShareLinkTaken = { incomingShareLink.value = null },
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The parts of the window Compose does not reach: the background behind the
|
||||
* whole activity, and the polarity of the system bars' icons.
|
||||
*
|
||||
* The background matters for the frames before and between compositions —
|
||||
* the launch frame, and a rotation. It used to be a hex literal in
|
||||
* `themes.xml`, which meant every cold start showed a colour from one
|
||||
* flavour regardless of which one was chosen.
|
||||
*
|
||||
* The icons matter because the app ships a light flavour. Pinned dark, as
|
||||
* they were, Latte drew white status-bar icons onto a near-white page.
|
||||
*/
|
||||
private fun dressWindow(palette: PaletteRecord) {
|
||||
window.setBackgroundDrawable(palette.background.toInt().toDrawable())
|
||||
WindowCompat.getInsetsController(window, window.decorView).apply {
|
||||
isAppearanceLightStatusBars = palette.light
|
||||
isAppearanceLightNavigationBars = palette.light
|
||||
}
|
||||
}
|
||||
|
||||
// singleTask: a link opened while the app is running arrives here, not in
|
||||
// a second activity.
|
||||
override fun onNewIntent(intent: Intent) {
|
||||
super.onNewIntent(intent)
|
||||
intent.shareLink()?.let { incomingShareLink.value = it }
|
||||
}
|
||||
|
||||
override fun onDestroy() {
|
||||
if (bound) {
|
||||
unbindService(playbackConnection)
|
||||
bound = false
|
||||
}
|
||||
super.onDestroy()
|
||||
}
|
||||
|
||||
override fun onStop() {
|
||||
super.onStop()
|
||||
// Where the viewer stopped is what their other devices want next, and
|
||||
// a process in the background may not live to the next timed sync.
|
||||
if (!isChangingConfigurations) WatchSyncWorker.enqueue(this)
|
||||
if (
|
||||
!isChangingConfigurations &&
|
||||
!SettingsStore(this).backgroundAudio
|
||||
) {
|
||||
playerHost.value?.stop()
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
package io.narl.protonstream
|
||||
|
||||
import android.app.Application
|
||||
import io.narl.protonstream.native.NativeRuntime
|
||||
import io.narl.protonstream.settings.SettingsStore
|
||||
|
||||
class ProtonStreamApplication : Application() {
|
||||
override fun onCreate() {
|
||||
super.onCreate()
|
||||
NativeRuntime.initialize(this)
|
||||
// SharedPreferences reads its file on first use, and the first use was
|
||||
// on the main thread — in the playback service's start callback among
|
||||
// others. Read here, off it, and every later read is from memory.
|
||||
Thread({ SettingsStore.preload(this) }, "settings-preload").start()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,175 @@
|
||||
package io.narl.protonstream.download
|
||||
|
||||
import android.content.Context
|
||||
import android.os.StatFs
|
||||
import androidx.work.Constraints
|
||||
import androidx.work.ExistingWorkPolicy
|
||||
import androidx.work.NetworkType
|
||||
import androidx.work.OneTimeWorkRequest
|
||||
import androidx.work.OneTimeWorkRequestBuilder
|
||||
import androidx.work.WorkManager
|
||||
import androidx.work.workDataOf
|
||||
import io.narl.protonstream.settings.SettingsStore
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.SupervisorJob
|
||||
import kotlinx.coroutines.launch
|
||||
import uniffi.pstr_android.EpisodeRecord
|
||||
|
||||
object DownloadCoordinator {
|
||||
/**
|
||||
* Where bulk queueing runs.
|
||||
*
|
||||
* Callers are click handlers, and "Download show" on a long series is a
|
||||
* WorkManager enqueue per episode — each of which writes to its own
|
||||
* database. On the main thread that is an ANR waiting for a long enough
|
||||
* series.
|
||||
*/
|
||||
private val queueing = CoroutineScope(SupervisorJob() + Dispatchers.IO)
|
||||
|
||||
fun enqueue(context: Context, episode: EpisodeRecord) = enqueue(context, listOf(episode))
|
||||
|
||||
fun resume(context: Context, download: RetainedDownload) {
|
||||
enqueue(context, download, ExistingWorkPolicy.REPLACE)
|
||||
}
|
||||
|
||||
private fun enqueue(context: Context, download: RetainedDownload, policy: ExistingWorkPolicy) {
|
||||
val queued = download.copy(status = RetainedDownload.STATUS_QUEUED, error = null)
|
||||
DownloadStateStore(context).put(queued)
|
||||
WorkManager.getInstance(context).enqueueUniqueWork(
|
||||
workName(download.shareId, download.linkId),
|
||||
policy,
|
||||
request(queued, SettingsStore(context).wifiOnly),
|
||||
)
|
||||
}
|
||||
|
||||
fun enqueue(context: Context, episodes: Iterable<EpisodeRecord>) {
|
||||
val wanted = episodes.filterNot(EpisodeRecord::offline).toList()
|
||||
if (wanted.isEmpty()) return
|
||||
val application = context.applicationContext
|
||||
queueing.launch {
|
||||
val wifiOnly = SettingsStore(application).wifiOnly
|
||||
val store = DownloadStateStore(application)
|
||||
val manager = WorkManager.getInstance(application)
|
||||
// Space is checked once for the whole request, against its total.
|
||||
// Queueing a season that cannot fit and discovering it episode by
|
||||
// episode at ENOSPC is how the app fills its own database's disk.
|
||||
val room = roomFor(application, wanted.sumOf { it.size?.toLong() ?: 0L })
|
||||
val downloads = wanted.map { episode ->
|
||||
RetainedDownload(
|
||||
episode.shareId,
|
||||
episode.volumeId,
|
||||
episode.linkId,
|
||||
episode.label,
|
||||
total = episode.size?.toLong() ?: 0L,
|
||||
status = if (room) RetainedDownload.STATUS_QUEUED else RetainedDownload.STATUS_FAILED,
|
||||
error = if (room) null else "Not enough free space",
|
||||
)
|
||||
}
|
||||
store.putAll(downloads)
|
||||
if (!room) return@launch
|
||||
downloads.forEach { download ->
|
||||
manager.enqueueUniqueWork(
|
||||
workName(download.shareId, download.linkId),
|
||||
ExistingWorkPolicy.KEEP,
|
||||
request(download, wifiOnly),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether [bytes] plus working room will fit where offline files are kept.
|
||||
*
|
||||
* `setRequiresStorageNotLow` only holds work back at Android's own low-storage
|
||||
* threshold, which a single episode can cross on its own. An unknown size —
|
||||
* zero — is allowed through: refusing a download because the catalog has no
|
||||
* size for it would be worse than the ENOSPC it is guarding against.
|
||||
*/
|
||||
private fun roomFor(context: Context, bytes: Long): Boolean {
|
||||
if (bytes <= 0) return true
|
||||
val available = runCatching { StatFs(context.filesDir.path).availableBytes }.getOrNull() ?: return true
|
||||
return available - bytes >= STORAGE_HEADROOM
|
||||
}
|
||||
|
||||
/**
|
||||
* Re-apply the network constraint to work that is already queued.
|
||||
*
|
||||
* Constraints are baked in at enqueue time, so turning "Wi-Fi only" on after
|
||||
* queueing a season did nothing to that season — it downloaded over cellular
|
||||
* on the way out of the house, which is the one moment the setting exists
|
||||
* for. Running work is left alone: WorkManager stops it on its own when the
|
||||
* new constraint stops being met.
|
||||
*/
|
||||
fun applyNetworkPolicy(context: Context) {
|
||||
val application = context.applicationContext
|
||||
queueing.launch {
|
||||
val wifiOnly = SettingsStore(application).wifiOnly
|
||||
val manager = WorkManager.getInstance(application)
|
||||
DownloadStateStore(application).records()
|
||||
.filter { it.status == RetainedDownload.STATUS_QUEUED || it.status == RetainedDownload.STATUS_RUNNING }
|
||||
.forEach { download ->
|
||||
manager.enqueueUniqueWork(
|
||||
workName(download.shareId, download.linkId),
|
||||
ExistingWorkPolicy.REPLACE,
|
||||
request(download, wifiOnly),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun pause(context: Context, download: RetainedDownload) =
|
||||
setStatus(context, download, RetainedDownload.STATUS_PAUSED)
|
||||
|
||||
fun cancel(context: Context, download: RetainedDownload) =
|
||||
setStatus(context, download, RetainedDownload.STATUS_CANCELLED)
|
||||
|
||||
/**
|
||||
* The status write goes before the work is cancelled, and through `update`
|
||||
* rather than `put`: the running worker is writing progress into the same
|
||||
* record, and a whole-record `put` from a screen's stale copy would both
|
||||
* lose that progress and race the worker's own read-modify-write.
|
||||
*/
|
||||
private fun setStatus(context: Context, download: RetainedDownload, status: String) {
|
||||
DownloadStateStore(context).update(download.shareId, download.linkId) { stored ->
|
||||
(stored ?: download).copy(status = status, error = null)
|
||||
}
|
||||
WorkManager.getInstance(context).cancelUniqueWork(workName(download.shareId, download.linkId))
|
||||
}
|
||||
|
||||
private fun request(download: RetainedDownload, wifiOnly: Boolean): OneTimeWorkRequest =
|
||||
OneTimeWorkRequestBuilder<OfflineDownloadWorker>()
|
||||
.setConstraints(
|
||||
Constraints.Builder()
|
||||
.setRequiredNetworkType(requiredNetworkType(wifiOnly))
|
||||
// A download that fills the disk takes the app's own
|
||||
// database down with it; the `.part` file makes waiting free.
|
||||
.setRequiresStorageNotLow(true)
|
||||
.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()
|
||||
|
||||
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:"
|
||||
|
||||
/** Free space to leave behind a download, for the catalog and the caches. */
|
||||
private const val STORAGE_HEADROOM = 256L * 1024 * 1024
|
||||
}
|
||||
|
||||
internal fun requiredNetworkType(wifiOnly: Boolean): NetworkType =
|
||||
if (wifiOnly) NetworkType.UNMETERED else NetworkType.CONNECTED
|
||||
@@ -0,0 +1,133 @@
|
||||
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,
|
||||
/** Recent transfer rate while running; 0 when unknown or not running. */
|
||||
val bytesPerSecond: Long = 0,
|
||||
) {
|
||||
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) = synchronized(WRITES) {
|
||||
edit { putString(record.key, encode(record)) }
|
||||
}
|
||||
|
||||
/**
|
||||
* Read-modify-write one record, excluding every other writer.
|
||||
*
|
||||
* The worker's progress update reads the stored record to decide whether the
|
||||
* viewer has paused, then writes it back. `pause()` is another writer, and
|
||||
* without this a `put` landing between those two halves puts `RUNNING` back
|
||||
* over `PAUSED` — after which the worker's own cancellation handler sees a
|
||||
* status that is not `PAUSED` and records `CANCELLED`. The pause is simply
|
||||
* lost, which is the visible bug.
|
||||
*
|
||||
* Returning null from [change] leaves the record alone.
|
||||
*/
|
||||
fun update(shareId: String, linkId: String, change: (RetainedDownload?) -> RetainedDownload?) =
|
||||
synchronized(WRITES) {
|
||||
change(get(shareId, linkId))?.let { edit { putString(it.key, encode(it)) } }
|
||||
}
|
||||
|
||||
/**
|
||||
* Write a whole queue in one go.
|
||||
*
|
||||
* Queueing a season is one call, not one per episode: `commit()` per record
|
||||
* on a 200-episode series was 200 synchronous fsyncs, and the callers are
|
||||
* click handlers.
|
||||
*/
|
||||
fun putAll(records: Collection<RetainedDownload>) = synchronized(WRITES) {
|
||||
edit { records.forEach { putString(it.key, encode(it)) } }
|
||||
}
|
||||
|
||||
fun remove(shareId: String, linkId: String) = synchronized(WRITES) {
|
||||
edit { remove(key(shareId, linkId)) }
|
||||
}
|
||||
|
||||
/** Forget every retained download. Callers cancel the work first. */
|
||||
fun clear() = synchronized(WRITES) { edit { clear() } }
|
||||
|
||||
fun removeShare(shareId: String) = synchronized(WRITES) {
|
||||
edit { records().filter { it.shareId == shareId }.forEach { remove(it.key) } }
|
||||
}
|
||||
|
||||
/**
|
||||
* `apply()`, not `commit()`.
|
||||
*
|
||||
* The in-memory map is updated before this returns, so a read that follows a
|
||||
* write in the same process still sees it; only the disk write is deferred,
|
||||
* and losing the newest progress reading to a kill is not worth an fsync on
|
||||
* whichever thread happened to be writing.
|
||||
*/
|
||||
private inline fun edit(block: android.content.SharedPreferences.Editor.() -> Unit) {
|
||||
preferences.edit().apply(block).apply()
|
||||
}
|
||||
|
||||
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)
|
||||
.put("rate", record.bytesPerSecond)
|
||||
.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),
|
||||
bytesPerSecond = json.optLong("rate"),
|
||||
)
|
||||
}
|
||||
|
||||
private fun key(shareId: String, linkId: String) = "$shareId\u001f$linkId"
|
||||
|
||||
private companion object {
|
||||
const val NAME = "offline_download_state"
|
||||
|
||||
/**
|
||||
* One lock for the whole process. Instances of this class are created
|
||||
* ad hoc from a `Context`, so the lock cannot live on one of them.
|
||||
*/
|
||||
val WRITES = Any()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,247 @@
|
||||
package io.narl.protonstream.download
|
||||
|
||||
import android.Manifest
|
||||
import android.app.NotificationChannel
|
||||
import android.app.NotificationManager
|
||||
import android.content.pm.PackageManager
|
||||
import android.content.pm.ServiceInfo
|
||||
import android.content.Context
|
||||
import android.os.Build
|
||||
import android.os.SystemClock
|
||||
import androidx.core.app.NotificationCompat
|
||||
import androidx.core.content.ContextCompat
|
||||
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 {
|
||||
createNotificationChannel()
|
||||
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)
|
||||
setForeground(createForegroundInfo(label, null))
|
||||
postSummary(store)
|
||||
try {
|
||||
return transfer(store, shareId, volumeId, linkId, label)
|
||||
} finally {
|
||||
postSummary(store)
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun transfer(
|
||||
store: DownloadStateStore,
|
||||
shareId: String,
|
||||
volumeId: String,
|
||||
linkId: String,
|
||||
label: String,
|
||||
): Result {
|
||||
// Queued, not running: the engine lets two downloads transfer at once
|
||||
// and holds the rest back, and only the first progress report means
|
||||
// this one has a slot.
|
||||
var retained = store.get(shareId, linkId)
|
||||
?: RetainedDownload(shareId, volumeId, linkId, label)
|
||||
retained = retained.copy(status = RetainedDownload.STATUS_QUEUED, error = null, bytesPerSecond = 0)
|
||||
store.put(retained)
|
||||
|
||||
val observer = object : DownloadObserver {
|
||||
private var reportedAt = 0L
|
||||
private var reportedPercent = -1
|
||||
private val rate = TransferRate()
|
||||
|
||||
/**
|
||||
* Called once per 4 MiB block — a dozen times a second on a fast
|
||||
* link, each one a WorkManager database write, a notification post
|
||||
* and a preferences write. Nothing the viewer can see changes that
|
||||
* often, so a report is only made when the percentage moves or a
|
||||
* second has passed. The final one is never suppressed.
|
||||
*/
|
||||
override fun onProgress(downloaded: ULong, total: ULong) {
|
||||
val percent = if (total == 0UL) 0 else ((downloaded * 100UL) / total).toInt()
|
||||
val now = SystemClock.elapsedRealtime()
|
||||
val complete = total > 0UL && downloaded >= total
|
||||
val bytesPerSecond = rate.sample(downloaded.toLong(), now)
|
||||
if (!complete && percent == reportedPercent && now - reportedAt < PROGRESS_INTERVAL_MS) {
|
||||
return
|
||||
}
|
||||
reportedAt = now
|
||||
reportedPercent = percent
|
||||
setProgressAsync(workDataOf(KEY_DOWNLOADED to downloaded.toLong(), KEY_TOTAL to total.toLong()))
|
||||
setForegroundAsync(createForegroundInfo(label, percent))
|
||||
// Under the store's lock: `pause()` is a concurrent writer, and
|
||||
// this read-modify-write used to put RUNNING back over the
|
||||
// viewer's PAUSED and then report the download as cancelled.
|
||||
store.update(shareId, linkId) { stored ->
|
||||
val current = stored ?: retained
|
||||
current.copy(
|
||||
downloaded = downloaded.toLong(),
|
||||
total = total.toLong(),
|
||||
bytesPerSecond = bytesPerSecond,
|
||||
status = when (current.status) {
|
||||
RetainedDownload.STATUS_PAUSED,
|
||||
RetainedDownload.STATUS_CANCELLED,
|
||||
-> current.status
|
||||
else -> RetainedDownload.STATUS_RUNNING
|
||||
},
|
||||
).also { retained = it }
|
||||
}
|
||||
}
|
||||
|
||||
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) {
|
||||
// A pause is also a stop. Under the lock, so the record read
|
||||
// here is the one the write lands on.
|
||||
store.update(shareId, linkId) { stored ->
|
||||
val current = stored ?: retained
|
||||
current.takeIf { it.status != RetainedDownload.STATUS_PAUSED }
|
||||
?.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))
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
/** Once per run, rather than once per 4 MiB block. */
|
||||
private fun createNotificationChannel() {
|
||||
applicationContext.getSystemService(NotificationManager::class.java).createNotificationChannel(
|
||||
NotificationChannel(CHANNEL_ID, "Offline downloads", NotificationManager.IMPORTANCE_LOW),
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* This episode's line, under [GROUP]: a season is fourteen workers, and
|
||||
* fourteen loose notifications saying the same thing named nothing.
|
||||
* [progress] is a percentage, or null while waiting for a download slot.
|
||||
*/
|
||||
private fun createForegroundInfo(label: String, progress: Int?): ForegroundInfo {
|
||||
val notification = NotificationCompat.Builder(applicationContext, CHANNEL_ID)
|
||||
.setSmallIcon(io.narl.protonstream.R.drawable.ic_launcher_foreground)
|
||||
.setContentTitle(label)
|
||||
.setContentText(if (progress == null) "Waiting to download" else "$progress %")
|
||||
.setProgress(100, progress ?: 0, progress == null || progress == 0)
|
||||
.setOngoing(true)
|
||||
.setOnlyAlertOnce(true)
|
||||
.setGroup(GROUP)
|
||||
.build()
|
||||
return ForegroundInfo(
|
||||
stableNotificationId(id),
|
||||
notification,
|
||||
ServiceInfo.FOREGROUND_SERVICE_TYPE_DATA_SYNC,
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* The one line the group folds into — "Downloading 12 episodes" — or none
|
||||
* once nothing is left to download. Posted as each worker starts and
|
||||
* ends rather than on progress, which each episode's own line carries.
|
||||
*/
|
||||
private fun postSummary(store: DownloadStateStore) {
|
||||
val manager = applicationContext.getSystemService(NotificationManager::class.java)
|
||||
val left = store.records().count {
|
||||
it.status == RetainedDownload.STATUS_QUEUED || it.status == RetainedDownload.STATUS_RUNNING
|
||||
}
|
||||
if (left == 0) {
|
||||
manager.cancel(SUMMARY_ID)
|
||||
return
|
||||
}
|
||||
val allowed = Build.VERSION.SDK_INT < Build.VERSION_CODES.TIRAMISU ||
|
||||
ContextCompat.checkSelfPermission(applicationContext, Manifest.permission.POST_NOTIFICATIONS) ==
|
||||
PackageManager.PERMISSION_GRANTED
|
||||
if (!allowed) return
|
||||
manager.notify(
|
||||
SUMMARY_ID,
|
||||
NotificationCompat.Builder(applicationContext, CHANNEL_ID)
|
||||
.setSmallIcon(io.narl.protonstream.R.drawable.ic_launcher_foreground)
|
||||
.setContentTitle(if (left == 1) "Downloading 1 episode" else "Downloading $left episodes")
|
||||
.setOnlyAlertOnce(true)
|
||||
.setGroup(GROUP)
|
||||
.setGroupSummary(true)
|
||||
.build(),
|
||||
)
|
||||
}
|
||||
|
||||
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"
|
||||
private const val GROUP = "offline-downloads"
|
||||
|
||||
/** Below [stableNotificationId]'s range, so no episode's line replaces it. */
|
||||
internal const val SUMMARY_ID = 1
|
||||
|
||||
/** Shortest gap between two identical-looking progress reports. */
|
||||
private const val PROGRESS_INTERVAL_MS = 1_000L
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Bytes per second over roughly the last few seconds.
|
||||
*
|
||||
* Progress arrives once per 4 MiB block, so on a slow link the bar moves in
|
||||
* steps seconds apart; a rate beside it is what tells a slow download from a
|
||||
* stuck one. Smoothed, because one block that took a little longer than the
|
||||
* last would otherwise halve the figure for a moment.
|
||||
*/
|
||||
internal class TransferRate {
|
||||
private var lastBytes = -1L
|
||||
private var lastAt = 0L
|
||||
private var smoothed = 0.0
|
||||
|
||||
/** Record [bytes] transferred at [now] (milliseconds) and return the rate. */
|
||||
fun sample(bytes: Long, now: Long): Long {
|
||||
if (lastBytes < 0 || bytes < lastBytes) {
|
||||
lastBytes = bytes
|
||||
lastAt = now
|
||||
return smoothed.toLong()
|
||||
}
|
||||
val elapsed = now - lastAt
|
||||
if (elapsed <= 0) return smoothed.toLong()
|
||||
val instant = (bytes - lastBytes) * 1000.0 / elapsed
|
||||
smoothed = if (smoothed > 0.0) 0.7 * smoothed + 0.3 * instant else instant
|
||||
lastBytes = bytes
|
||||
lastAt = now
|
||||
return smoothed.toLong()
|
||||
}
|
||||
}
|
||||
|
||||
internal fun stableNotificationId(workId: UUID): Int =
|
||||
(workId.hashCode() and Int.MAX_VALUE).coerceAtLeast(OfflineDownloadWorker.SUMMARY_ID + 1)
|
||||
@@ -0,0 +1,101 @@
|
||||
package io.narl.protonstream.native
|
||||
|
||||
import android.content.Context
|
||||
import android.security.keystore.KeyGenParameterSpec
|
||||
import android.security.keystore.KeyPermanentlyInvalidatedException
|
||||
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
|
||||
import uniffi.pstr_android.BridgeException
|
||||
|
||||
/**
|
||||
* Encrypts share fragments and passwords before they reach app preferences.
|
||||
*
|
||||
* Every failure here leaves as a [BridgeException]. Raw Java exceptions crossing
|
||||
* the FFI have no mapping on the Rust side, and an unmapped callback exception
|
||||
* is a panic on whichever thread was calling — including a tokio worker holding
|
||||
* a download. What the caller does about it is a separate question; being told
|
||||
* at all is this class's part.
|
||||
*/
|
||||
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) = bridging("store credential") {
|
||||
val payload = try {
|
||||
seal(value)
|
||||
} catch (invalidated: KeyPermanentlyInvalidatedException) {
|
||||
// A lockscreen change or a device restore retires the key. Nothing
|
||||
// it sealed can be read again — and nothing new can be sealed with
|
||||
// it either, so re-entering a link would fail here rather than
|
||||
// restoring access. Everything the old key held is already lost, so
|
||||
// replacing it costs nothing that is not gone.
|
||||
keyStore.deleteEntry(KEY_ALIAS)
|
||||
seal(value)
|
||||
}
|
||||
check(preferences.edit().putString(key, Base64.encodeToString(payload, Base64.NO_WRAP)).commit())
|
||||
}
|
||||
|
||||
private fun seal(value: String): ByteArray {
|
||||
val cipher = Cipher.getInstance(TRANSFORMATION)
|
||||
cipher.init(Cipher.ENCRYPT_MODE, encryptionKey())
|
||||
return cipher.iv + cipher.doFinal(value.toByteArray(Charsets.UTF_8))
|
||||
}
|
||||
|
||||
override fun get(key: String): String? = bridging("read credential") {
|
||||
val encoded = preferences.getString(key, null) ?: return@bridging 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)),
|
||||
)
|
||||
cipher.doFinal(payload.copyOfRange(IV_SIZE, payload.size)).toString(Charsets.UTF_8)
|
||||
}
|
||||
|
||||
override fun delete(key: String) = bridging("delete credential") {
|
||||
check(preferences.edit().remove(key).commit())
|
||||
}
|
||||
|
||||
private fun <T> bridging(action: String, block: () -> T): T = try {
|
||||
block()
|
||||
} catch (error: BridgeException) {
|
||||
throw error
|
||||
} catch (error: Exception) {
|
||||
// The message, never the stack trace: this reaches a snackbar, and the
|
||||
// trace is both unreadable there and a description of app internals.
|
||||
throw BridgeException.Failure("Could not $action: ${error.message ?: error::class.java.simpleName}")
|
||||
}
|
||||
|
||||
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,65 @@
|
||||
package io.narl.protonstream.native
|
||||
|
||||
import android.content.Context
|
||||
import io.narl.protonstream.settings.SettingsStore
|
||||
import java.io.File
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.withContext
|
||||
import uniffi.pstr_android.AndroidEngine
|
||||
import uniffi.pstr_android.AndroidPaths
|
||||
import uniffi.pstr_android.PaletteRecord
|
||||
|
||||
/** 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
|
||||
|
||||
/**
|
||||
* Whether Android's certificate verifier came up.
|
||||
*
|
||||
* False means every HTTPS request will fail validation, which otherwise
|
||||
* surfaces much later as an unexplained certificate error against a share.
|
||||
*/
|
||||
@Volatile var tlsReady: Boolean = false
|
||||
private set
|
||||
|
||||
fun initialize(context: Context) {
|
||||
System.loadLibrary("pstr_android")
|
||||
tlsReady = initTls(context)
|
||||
appContext = context.applicationContext
|
||||
}
|
||||
|
||||
/** Initializes Android's certificate verifier before Proton networking. */
|
||||
private external fun initTls(context: Context): Boolean
|
||||
|
||||
suspend fun engine(): AndroidEngine = withContext(Dispatchers.IO) {
|
||||
instance ?: synchronized(this@NativeRuntime) {
|
||||
instance ?: createEngine().also { instance = it }
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The stored palette, without building the engine.
|
||||
*
|
||||
* Callable from the main thread, and meant to be: it is the only palette
|
||||
* read early enough to decide what the first frame is painted in, and
|
||||
* [engine] is not — that opens SQLite, spins a Tokio runtime and unlocks a
|
||||
* Keystore key. This reads one small JSON file and resolves it.
|
||||
*/
|
||||
fun storedPalette(): PaletteRecord = uniffi.pstr_android.storedPalette(paths())
|
||||
|
||||
private fun paths(): AndroidPaths {
|
||||
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 AndroidPaths(config.absolutePath, data.absolutePath, cache.absolutePath)
|
||||
}
|
||||
|
||||
private fun createEngine(): AndroidEngine =
|
||||
AndroidEngine(paths(), KeystoreSecretStore(appContext)).apply {
|
||||
// The budget is Android's setting, so the engine starts on the
|
||||
// default and is told the chosen one before anything streams.
|
||||
setStreamCacheBudget(SettingsStore.gibToBytes(SettingsStore(appContext).cacheBudgetGib))
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,591 @@
|
||||
package io.narl.protonstream.playback
|
||||
|
||||
import android.os.SystemClock
|
||||
import android.view.Surface
|
||||
import io.narl.protonstream.ui.ThumbnailSource
|
||||
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
|
||||
import uniffi.pstr_android.ChapterPlan
|
||||
import uniffi.pstr_android.ChapterRecord
|
||||
import uniffi.pstr_android.SkipRangeRecord
|
||||
import uniffi.pstr_android.SubtitleFilesRecord
|
||||
import uniffi.pstr_android.SubtitleTrackRecord
|
||||
import uniffi.pstr_android.chapterPlan
|
||||
import uniffi.pstr_android.fullSubtitlesInstead
|
||||
|
||||
/**
|
||||
* Why the open file ended, mirroring `pstr_player::EndReason` so both clients
|
||||
* agree on what "finished" means.
|
||||
*
|
||||
* Only [Eof] is an episode watched to the end. Treating the others as one — a
|
||||
* stop, a shutdown, a failed demux — is what marks an episode that never played
|
||||
* as watched and autoplays past it.
|
||||
*/
|
||||
enum class EndReason {
|
||||
None,
|
||||
Eof,
|
||||
Stopped,
|
||||
Quit,
|
||||
Failed,
|
||||
Other,
|
||||
;
|
||||
|
||||
companion object {
|
||||
fun of(value: Double): EndReason = entries.getOrElse(value.toInt()) { Other }
|
||||
}
|
||||
}
|
||||
|
||||
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,
|
||||
/** Meaningful only while [ended]; [EndReason.None] otherwise. */
|
||||
val endReason: EndReason = EndReason.None,
|
||||
/**
|
||||
* Which episode this reading belongs to, or null before the first poll of a
|
||||
* newly loaded file. Everything that *writes* per-episode state — resume
|
||||
* position above all — must check this: one host serves every episode, and
|
||||
* a reading attributed to the wrong one resumes the next episode where the
|
||||
* last one stopped.
|
||||
*/
|
||||
val media: String? = null,
|
||||
/** The picture's display size, or zero until mpv has decoded enough to know. */
|
||||
val videoWidth: Double = 0.0,
|
||||
val videoHeight: Double = 0.0,
|
||||
/**
|
||||
* mpv has run out of buffered data and stopped to refill.
|
||||
*
|
||||
* Not the same as [paused], which is the viewer's doing. A frozen picture
|
||||
* that nothing explains is indistinguishable from a hung app, which is what
|
||||
* a network-backed stream looked like before this existed.
|
||||
*/
|
||||
val buffering: Boolean = false,
|
||||
/** How full the demuxer cache is while [buffering], 0–100. */
|
||||
val cachePercent: Double = 0.0,
|
||||
/** A seek has been issued but playback has not resumed at the new position. */
|
||||
val seeking: Boolean = false,
|
||||
/** How far the demuxer has read ahead, in seconds into the file. */
|
||||
val cachedUntil: Double = 0.0,
|
||||
) {
|
||||
/** Whether the picture is stopped for a reason the viewer did not choose. */
|
||||
val stalled: Boolean get() = (buffering || seeking) && !paused && !ended
|
||||
}
|
||||
|
||||
/**
|
||||
* What is on screen, for the surfaces that are not the player.
|
||||
*
|
||||
* The media session and its notification have to name the episode, and
|
||||
* neither can reach the composition that knows it. The player publishes this
|
||||
* once per episode and they read it.
|
||||
*/
|
||||
data class NowPlaying(
|
||||
val media: String,
|
||||
val title: String,
|
||||
val show: String?,
|
||||
val detail: String?,
|
||||
val artworkUrl: String?,
|
||||
/** The file to pull a Proton thumbnail from when the provider has no poster. */
|
||||
val artworkFile: ThumbnailSource?,
|
||||
val hasPrevious: Boolean,
|
||||
val hasNext: Boolean,
|
||||
)
|
||||
|
||||
/** A sleep timer that is running. */
|
||||
sealed interface SleepTimer {
|
||||
/** Stop when [media] ends, rather than going on to the next episode. */
|
||||
data class EndOf(val media: String) : SleepTimer
|
||||
|
||||
/** Pause at [at], on the [SystemClock.elapsedRealtime] clock. */
|
||||
data class At(val at: Long) : SleepTimer
|
||||
}
|
||||
|
||||
data class MpvTrack(
|
||||
val id: Long,
|
||||
val type: String,
|
||||
val language: String,
|
||||
val title: String,
|
||||
val selected: Boolean,
|
||||
/** The container marks it as the default choice. */
|
||||
val isDefault: Boolean = false,
|
||||
/** The container marks it as forced: on-screen text only, by convention. */
|
||||
val forced: Boolean = false,
|
||||
) {
|
||||
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 private constructor(private val nativeHandle: Long) : LibmpvHost, AutoCloseable {
|
||||
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 val mutableChapters = MutableStateFlow(ChapterPlan(emptyList(), null))
|
||||
private val mutableNowPlaying = MutableStateFlow<NowPlaying?>(null)
|
||||
private val mutableAdvancing = MutableStateFlow(false)
|
||||
private val mutableProblem = MutableStateFlow<String?>(null)
|
||||
private val mutableSleep = MutableStateFlow<SleepTimer?>(null)
|
||||
private var sleepJob: Job? = null
|
||||
private var poller: Job? = null
|
||||
/** The episode key and load generation the native player currently holds. */
|
||||
@Volatile private var currentMedia: String? = null
|
||||
@Volatile private var currentGeneration: Double = -1.0
|
||||
@Volatile private var plannedFor: Pair<Double, Long>? = null
|
||||
/** What AniSkip said about the open episode, keyed by that episode. */
|
||||
@Volatile private var skipRanges: Pair<String, List<SkipRangeRecord>>? = null
|
||||
/** The load generation whose subtitle pick has been looked at. */
|
||||
@Volatile private var subtitlesCheckedFor: Double = -1.0
|
||||
var onPlaybackStarted: (() -> Unit)? = null
|
||||
var onStateChanged: ((MpvPlaybackState) -> Unit)? = null
|
||||
var onExplicitStop: (() -> Unit)? = null
|
||||
/**
|
||||
* Where a transport outside the player sends previous/next.
|
||||
*
|
||||
* The player owns which episode is open, so the notification and the lock
|
||||
* screen cannot walk the playlist themselves. Both
|
||||
* run on the main thread, which is where the player's state lives.
|
||||
*/
|
||||
var onSkipPrevious: (() -> Unit)? = null
|
||||
var onSkipNext: (() -> Unit)? = null
|
||||
|
||||
val state: StateFlow<MpvPlaybackState> = mutableState.asStateFlow()
|
||||
val tracks: StateFlow<List<MpvTrack>> = mutableTracks.asStateFlow()
|
||||
/** The open file's chapters, already resolved into openings and endings. */
|
||||
val chapters: StateFlow<ChapterPlan> = mutableChapters.asStateFlow()
|
||||
/** The episode the player has open, for every surface that has to name it. */
|
||||
val nowPlaying: StateFlow<NowPlaying?> = mutableNowPlaying.asStateFlow()
|
||||
/**
|
||||
* True while the player is between episodes.
|
||||
*
|
||||
* The end of one episode and the start of the next are separated by a
|
||||
* network round trip — opening the next revision's stream. The foreground
|
||||
* service must not retire in that gap: with no foreground state left, the
|
||||
* `startForeground` on the other side is a background start, which API 31+
|
||||
* refuses outright.
|
||||
*/
|
||||
val advancing: StateFlow<Boolean> = mutableAdvancing.asStateFlow()
|
||||
/**
|
||||
* What mpv last complained about, until the viewer dismisses it.
|
||||
*
|
||||
* Errors mpv reports are about the *file* — an unreadable header, a codec it
|
||||
* has no decoder for — and reach nothing else: the play call has already
|
||||
* returned successfully by the time they happen.
|
||||
*/
|
||||
val problem: StateFlow<String?> = mutableProblem.asStateFlow()
|
||||
|
||||
/**
|
||||
* The sleep timer. Kept here rather than on the screen, because the
|
||||
* episode it is about keeps playing with the screen off.
|
||||
*/
|
||||
val sleep: StateFlow<SleepTimer?> = mutableSleep.asStateFlow()
|
||||
|
||||
/** Start a sleep timer, replacing any running one, or turn it off. */
|
||||
fun setSleepTimer(timer: SleepTimer?) {
|
||||
sleepJob?.cancel()
|
||||
mutableSleep.value = timer
|
||||
if (timer is SleepTimer.At) {
|
||||
sleepJob = scope.launch {
|
||||
delay((timer.at - SystemClock.elapsedRealtime()).coerceAtLeast(0))
|
||||
mutableSleep.value = null
|
||||
setPaused(true)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Dismiss the current [problem]. */
|
||||
fun clearProblem() { mutableProblem.value = null }
|
||||
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(
|
||||
media: String,
|
||||
nativeHandle: ULong,
|
||||
size: ULong,
|
||||
startPosition: Double,
|
||||
audioLanguage: String?,
|
||||
subtitleLanguage: String?,
|
||||
subtitles: Boolean,
|
||||
hardwareDecoding: Boolean,
|
||||
) {
|
||||
check(size > 0uL) { "Cannot play an empty stream" }
|
||||
try {
|
||||
withContext(Dispatchers.IO) {
|
||||
val loaded = withOpenHandle {
|
||||
nativeLoad(
|
||||
it, nativeHandle.toLong(), startPosition, audioLanguage, subtitleLanguage, subtitles,
|
||||
hardwareDecoding,
|
||||
).also { loaded ->
|
||||
if (loaded) {
|
||||
// The outgoing stream is released by mpv closing it,
|
||||
// not from here: `loadfile` is asynchronous and its
|
||||
// demuxer may still be reading. `stream_close` in
|
||||
// `pstr_mpv.cpp` owns that release.
|
||||
//
|
||||
// The outgoing file's readings describe an episode
|
||||
// that is no longer open. Retire them here as well
|
||||
// as natively, so nothing observes them even once.
|
||||
currentMedia = media
|
||||
currentGeneration = withOpenHandle(::nativeState)?.generation() ?: -1.0
|
||||
mutableState.value = MpvPlaybackState(
|
||||
position = startPosition,
|
||||
volume = mutableState.value.volume,
|
||||
muted = mutableState.value.muted,
|
||||
media = media,
|
||||
)
|
||||
mutableProblem.value = null
|
||||
mutableTracks.value = emptyList()
|
||||
mutableChapters.value = ChapterPlan(emptyList(), null)
|
||||
plannedFor = null
|
||||
skipRanges = null
|
||||
}
|
||||
}
|
||||
} ?: false
|
||||
check(loaded) {
|
||||
"libmpv rejected the stream or no video surface was available"
|
||||
}
|
||||
}
|
||||
withOpenHandle {
|
||||
onPlaybackStarted?.invoke()
|
||||
startPolling()
|
||||
}
|
||||
} finally {
|
||||
// Whether the next episode arrived or failed to, the gap the
|
||||
// foreground service was holding open is over.
|
||||
mutableAdvancing.value = false
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Declare that the player is walking to another episode.
|
||||
*
|
||||
* Called before the stream for the next one is opened, and cleared when
|
||||
* [play] returns either way — the window this covers is the one in which
|
||||
* nothing is loaded and the service would otherwise conclude playback is
|
||||
* finished.
|
||||
*/
|
||||
fun beginTransition() { mutableAdvancing.value = true }
|
||||
|
||||
/** Names the open episode for the media session and its notification. */
|
||||
fun publish(playing: NowPlaying?) { mutableNowPlaying.value = playing }
|
||||
|
||||
fun setPaused(paused: Boolean) {
|
||||
// The native side writes the flag through, so every transport can flip
|
||||
// now instead of on the next poll a quarter second from here.
|
||||
val next = withOpenHandle {
|
||||
nativePause(it, paused)
|
||||
mutableState.value.copy(paused = paused).also { state -> mutableState.value = state }
|
||||
} ?: return
|
||||
onStateChanged?.invoke(next)
|
||||
}
|
||||
|
||||
fun seek(position: Double) { withOpenHandle { nativeSeek(it, position) } }
|
||||
|
||||
/**
|
||||
* Take AniSkip's answer for [media], and plan the chapters again with it.
|
||||
* Dropped when another episode has opened since it was asked for.
|
||||
*/
|
||||
fun setSkipRanges(media: String, ranges: List<SkipRangeRecord>) {
|
||||
if (media != currentMedia) return
|
||||
skipRanges = media to ranges
|
||||
plannedFor = null
|
||||
refreshChapters(mutableState.value)
|
||||
}
|
||||
fun setVolume(volume: Double) { withOpenHandle { nativeVolume(it, volume) } }
|
||||
fun setMuted(muted: Boolean) { withOpenHandle { nativeMute(it, muted) } }
|
||||
|
||||
/** Playback rate, 1.0 being the file's own. Clamped native-side. */
|
||||
fun setSpeed(speed: Double) { withOpenHandle { nativeSpeed(it, speed) } }
|
||||
fun selectTrack(track: MpvTrack?) {
|
||||
val audio = track?.type == "audio"
|
||||
withOpenHandle { nativeSelectTrack(it, audio, track?.id ?: -1) }
|
||||
// mpv makes the switch on its own thread and lists it a moment later,
|
||||
// so a track list read back now still ticks the old track. The tick
|
||||
// moves here, and a read shortly after confirms it.
|
||||
val type = if (audio) "audio" else "sub"
|
||||
mutableTracks.value = mutableTracks.value.map {
|
||||
if (it.type == type) it.copy(selected = it.id == track?.id) else it
|
||||
}
|
||||
scope.launch(Dispatchers.Default) {
|
||||
delay(TRACK_SETTLE_MS)
|
||||
refreshTracks()
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Explicit user stop: stop media and retire the foreground service.
|
||||
*
|
||||
* The stream behind the open file is released by mpv closing it, which
|
||||
* `stop` causes; releasing it from here would race that close.
|
||||
*/
|
||||
fun stop() {
|
||||
val callback = withOpenHandle {
|
||||
nativeStop(it)
|
||||
// Nothing loaded means nothing to attribute a reading to: a save
|
||||
// issued after this must not land on the episode just stopped.
|
||||
currentMedia = null
|
||||
mutableAdvancing.value = false
|
||||
mutableChapters.value = ChapterPlan(emptyList(), null)
|
||||
mutableNowPlaying.value = null
|
||||
onExplicitStop
|
||||
}
|
||||
callback?.invoke()
|
||||
}
|
||||
|
||||
override fun releaseNativeStream(nativeHandle: ULong) = pstrAndroidStreamRelease(nativeHandle.toLong())
|
||||
|
||||
/**
|
||||
* Hand mpv the subtitle files beside [media], the episode [play] loaded.
|
||||
*
|
||||
* Native code holds them until mpv has the file loaded, since an external
|
||||
* track belongs to the file open at the time, and releases them instead
|
||||
* if another episode has been loaded since. [SubtitleFilesRecord.preferred]
|
||||
* is turned on only when the file has no subtitle of its own playing;
|
||||
* pass [subtitles] false to turn none on.
|
||||
*/
|
||||
fun addSubtitles(media: String, files: SubtitleFilesRecord, subtitles: Boolean) {
|
||||
val generation = currentGeneration.toLong()
|
||||
val added = withOpenHandle { handle ->
|
||||
files.files.forEachIndexed { index, file ->
|
||||
if (currentMedia == media) {
|
||||
val select = subtitles && files.preferred == index.toUInt()
|
||||
nativeAddSubtitle(handle, generation, file.handle.toLong(), file.title, file.language, select)
|
||||
} else {
|
||||
pstrAndroidStreamRelease(file.handle.toLong())
|
||||
}
|
||||
}
|
||||
}
|
||||
if (added == null) files.files.forEach { pstrAndroidStreamRelease(it.handle.toLong()) }
|
||||
}
|
||||
|
||||
private fun startPolling() {
|
||||
if (poller?.isActive == true) return
|
||||
// Off the main thread deliberately: `nativeState` and `nativeTracks`
|
||||
// are synchronous `mpv_get_property` calls — five per track for the
|
||||
// latter — that contend on the core lock the demuxer holds during a
|
||||
// fetch. A poll that blocks there blocks the UI thread with it.
|
||||
poller = scope.launch(Dispatchers.Default) {
|
||||
var tick = 0
|
||||
while (isActive) {
|
||||
val values = withOpenHandle(::nativeState) ?: break
|
||||
if (values.size >= FIELDS) {
|
||||
val next = MpvPlaybackState(
|
||||
values[0], values[1], values[2],
|
||||
values[3] != 0.0, values[4] != 0.0, values[5] != 0.0,
|
||||
// Only a reading of the file this host was last asked
|
||||
// to load carries its episode's name.
|
||||
media = currentMedia.takeIf { values.generation() == currentGeneration },
|
||||
videoWidth = values[WIDTH],
|
||||
videoHeight = values[HEIGHT],
|
||||
endReason = EndReason.of(values[END_REASON]),
|
||||
buffering = values[BUFFERING] != 0.0,
|
||||
cachePercent = values[CACHE_PERCENT],
|
||||
seeking = values[SEEKING] != 0.0,
|
||||
cachedUntil = values[CACHED_UNTIL],
|
||||
)
|
||||
mutableState.value = next
|
||||
// The listeners drive the media session and the player UI,
|
||||
// both of which are the main thread's.
|
||||
withContext(Dispatchers.Main) { onStateChanged?.invoke(next) }
|
||||
refreshChapters(next)
|
||||
if (values[LOADED] != 0.0 && values.generation() == currentGeneration &&
|
||||
subtitlesCheckedFor != currentGeneration
|
||||
) {
|
||||
subtitlesCheckedFor = currentGeneration
|
||||
preferFullSubtitles()
|
||||
}
|
||||
}
|
||||
withOpenHandle(::nativeTakeError)?.let { mutableProblem.value = it }
|
||||
if (tick++ % 4 == 0) refreshTracks()
|
||||
delay(250)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Re-read the chapters when there is a reason to: a different file, or a
|
||||
* duration that has only just arrived. Both change the answer — the length
|
||||
* of a chapter is what settles whether `Intro` is an opening or ten minutes
|
||||
* of story — and neither changes again after that, so this settles quickly
|
||||
* and then costs one comparison per poll.
|
||||
*/
|
||||
private fun refreshChapters(state: MpvPlaybackState) {
|
||||
val generation = currentGeneration
|
||||
val wanted = state.duration to generation.toRawBits()
|
||||
if (plannedFor == wanted) return
|
||||
runCatching {
|
||||
val encoded = withOpenHandle(::nativeChapters) ?: return
|
||||
val values = JSONArray(encoded)
|
||||
List(values.length()) { index ->
|
||||
values.getJSONObject(index).run {
|
||||
ChapterRecord(
|
||||
index = getLong("index"),
|
||||
title = optString("title").takeIf(String::isNotBlank),
|
||||
start = getDouble("start"),
|
||||
)
|
||||
}
|
||||
}
|
||||
}.onSuccess { chapters ->
|
||||
plannedFor = wanted
|
||||
val skips = skipRanges?.takeIf { it.first == currentMedia }?.second.orEmpty()
|
||||
mutableChapters.value = chapterPlan(chapters, state.duration.takeIf { it > 0.0 }, skips)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Play the dialogue rather than the signs and songs mpv picked by language
|
||||
* (`pstr_core::subtitles`). Not remembered: it is the choice mpv should
|
||||
* have made, not the viewer's.
|
||||
*/
|
||||
private fun preferFullSubtitles() {
|
||||
refreshTracks()
|
||||
val subtitles = mutableTracks.value.filter { it.type == "sub" }
|
||||
val id = fullSubtitlesInstead(
|
||||
subtitles.map {
|
||||
SubtitleTrackRecord(it.id, it.language, it.title, it.isDefault, it.forced, it.selected)
|
||||
},
|
||||
) ?: return
|
||||
subtitles.firstOrNull { it.id == id }?.let(::selectTrack)
|
||||
}
|
||||
|
||||
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"), optBoolean("default"), optBoolean("forced"),
|
||||
)
|
||||
}
|
||||
}
|
||||
}.onSuccess { mutableTracks.value = it }
|
||||
}
|
||||
|
||||
override fun close() {
|
||||
scope.cancel()
|
||||
val destroy = lifecycle.withLock {
|
||||
if (closed) return@withLock false
|
||||
closed = true
|
||||
onPlaybackStarted = null
|
||||
onStateChanged = null
|
||||
onExplicitStop = null
|
||||
onSkipPrevious = null
|
||||
onSkipNext = null
|
||||
mutableNowPlaying.value = null
|
||||
mutableAdvancing.value = false
|
||||
true
|
||||
}
|
||||
if (!destroy) return
|
||||
// `mpv_terminate_destroy` joins mpv's demuxer thread, which may be
|
||||
// parked in a block fetch over a degraded connection. This is called
|
||||
// from Service.onDestroy — on the main thread — so waiting for it there
|
||||
// is an ANR. `closed` is already set, so nothing new enters the handle;
|
||||
// the lock is only to let a call that is already inside finish.
|
||||
//
|
||||
// Terminating the core closes the open file, and closing it releases the
|
||||
// stream — after mpv's last read rather than during it.
|
||||
Thread({ lifecycle.withLock { nativeDestroy(nativeHandle) } }, "pstr-mpv-teardown").start()
|
||||
}
|
||||
|
||||
/** 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 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,
|
||||
hardwareDecoding: Boolean,
|
||||
): Boolean
|
||||
private external fun nativeAddSubtitle(
|
||||
handle: Long,
|
||||
generation: Long,
|
||||
stream: Long,
|
||||
title: String,
|
||||
language: String?,
|
||||
select: 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 nativeSpeed(handle: Long, speed: 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 nativeTakeError(handle: Long): String?
|
||||
// Nullable: `NewStringUTF` returns null when the string cannot be built,
|
||||
// and a non-null declaration turns that into an NPE that `runCatching`
|
||||
// swallows into a silently empty track list.
|
||||
private external fun nativeTracks(handle: Long): String?
|
||||
private external fun nativeChapters(handle: Long): String?
|
||||
private external fun pstrAndroidStreamRelease(handle: Long)
|
||||
|
||||
private fun DoubleArray.generation(): Double = getOrElse(GENERATION) { -1.0 }
|
||||
|
||||
companion object {
|
||||
/** Fields `nativeState` fills, in the order `pstr_mpv.cpp` writes them. */
|
||||
private const val FIELDS = 15
|
||||
private const val GENERATION = 6
|
||||
private const val WIDTH = 7
|
||||
private const val HEIGHT = 8
|
||||
private const val END_REASON = 9
|
||||
private const val BUFFERING = 10
|
||||
private const val CACHE_PERCENT = 11
|
||||
private const val SEEKING = 12
|
||||
private const val CACHED_UNTIL = 13
|
||||
private const val LOADED = 14
|
||||
|
||||
/** How long after a track switch mpv's track list is read again. */
|
||||
private const val TRACK_SETTLE_MS = 300L
|
||||
|
||||
init {
|
||||
System.loadLibrary("pstr_android")
|
||||
System.loadLibrary("pstr_mpv")
|
||||
}
|
||||
|
||||
/**
|
||||
* A host over a live libmpv core, or null if libmpv would not start.
|
||||
*
|
||||
* Null rather than an exception: the service that owns this is created
|
||||
* by the system, so a throw out of the constructor takes the process
|
||||
* with it — and the app has a graceful "this build does not include
|
||||
* libmpv" screen that a crash makes unreachable.
|
||||
*/
|
||||
fun createOrNull(): NativeMpvHost? = nativeCreate().takeIf { it != 0L }?.let(::NativeMpvHost)
|
||||
|
||||
@JvmStatic private external fun nativeCreate(): Long
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,503 @@
|
||||
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.BroadcastReceiver
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.content.IntentFilter
|
||||
import android.graphics.Bitmap
|
||||
import android.graphics.drawable.Icon
|
||||
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 android.os.SystemClock
|
||||
import androidx.core.content.ContextCompat
|
||||
import io.narl.protonstream.MainActivity
|
||||
import io.narl.protonstream.R
|
||||
import io.narl.protonstream.native.NativeRuntime
|
||||
import io.narl.protonstream.settings.SettingsStore
|
||||
import io.narl.protonstream.ui.loadArtwork
|
||||
import io.narl.protonstream.ui.loadThumbnail
|
||||
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.first
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.runBlocking
|
||||
import kotlinx.coroutines.withContext
|
||||
import kotlinx.coroutines.withTimeoutOrNull
|
||||
|
||||
/** Owns libmpv so audio survives Activity recreation and rotation. */
|
||||
class PlaybackService : Service() {
|
||||
inner class PlaybackBinder : Binder() {
|
||||
val host: NativeMpvHost? get() = this@PlaybackService.host
|
||||
}
|
||||
|
||||
/**
|
||||
* The libmpv core, or null when this build could not start one.
|
||||
*
|
||||
* Nullable rather than a `lateinit` that throws: the system creates this
|
||||
* service, so a failure in its constructor takes the process down and makes
|
||||
* the app's own "playback is unavailable" screen unreachable.
|
||||
*/
|
||||
private var host: NativeMpvHost? = null
|
||||
private lateinit var mediaSession: MediaSession
|
||||
private lateinit var audioManager: AudioManager
|
||||
private val scope = CoroutineScope(SupervisorJob() + Dispatchers.Main.immediate)
|
||||
private var audioFocus: AudioFocusRequest? = null
|
||||
/** Whether the pause on screen is this service's doing, not the viewer's. */
|
||||
private var pausedForFocus = false
|
||||
/** The volume before ducking, so the restore does not read a ducked one. */
|
||||
private var beforeDucking: Double? = null
|
||||
/**
|
||||
* Pulling the headphone jack must not continue on the speaker.
|
||||
*
|
||||
* Android broadcasts this and expects every media app to handle it; the
|
||||
* alternative is an episode playing out loud in a quiet room.
|
||||
*/
|
||||
private val becomingNoisy = object : BroadcastReceiver() {
|
||||
override fun onReceive(context: Context?, intent: Intent?) {
|
||||
if (intent?.action == AudioManager.ACTION_AUDIO_BECOMING_NOISY) host?.setPaused(true)
|
||||
}
|
||||
}
|
||||
private var foregroundPlayback = false
|
||||
private var sessionPlayback = false
|
||||
/**
|
||||
* What the posted notification already says.
|
||||
*
|
||||
* The state poll runs four times a second and almost none of those readings
|
||||
* change anything the shade shows. Re-posting on every one of them animates
|
||||
* the media control and costs a binder round trip per tick, so the
|
||||
* notification is rebuilt only when its own inputs move.
|
||||
*/
|
||||
private var notified: NotificationShape? = null
|
||||
/** The pending teardown, held so an episode transition can call it off. */
|
||||
private var retire: Job? = null
|
||||
/** The open episode's latest reading, which [track] saves. */
|
||||
private var lastReading: MpvPlaybackState? = null
|
||||
private var lastSave = 0L
|
||||
/** The episode's artwork, and the source key it was fetched for. */
|
||||
private var artwork: Pair<String, Bitmap>? = null
|
||||
private val binder = PlaybackBinder()
|
||||
|
||||
override fun onCreate() {
|
||||
super.onCreate()
|
||||
createChannel()
|
||||
ContextCompat.registerReceiver(
|
||||
this,
|
||||
becomingNoisy,
|
||||
IntentFilter(AudioManager.ACTION_AUDIO_BECOMING_NOISY),
|
||||
ContextCompat.RECEIVER_NOT_EXPORTED,
|
||||
)
|
||||
audioManager = getSystemService(AudioManager::class.java)
|
||||
host = NativeMpvHost.createOrNull()?.apply {
|
||||
onPlaybackStarted = {
|
||||
// A refusal is rare — a call in progress — but playing over it
|
||||
// is worse than not playing, and the viewer can simply press
|
||||
// play again once the call ends.
|
||||
if (!requestAudioFocus()) setPaused(true)
|
||||
sessionPlayback = true
|
||||
cancelRetire()
|
||||
if (!foregroundPlayback && SettingsStore(this@PlaybackService).backgroundAudio) {
|
||||
// Convert the bound service into a started foreground
|
||||
// service only when the viewer opted to keep audio alive.
|
||||
// Already-foreground is the ordinary case across an episode
|
||||
// transition, and re-entering costs a notification rebuild.
|
||||
foregroundPlayback = runCatching {
|
||||
startService(Intent(this@PlaybackService, PlaybackService::class.java))
|
||||
startForeground(NOTIFICATION_ID, notification(state.value, nowPlaying.value))
|
||||
}.isSuccess
|
||||
}
|
||||
mediaSession.isActive = true
|
||||
}
|
||||
onStateChanged = { updateSession(it); track(it) }
|
||||
onExplicitStop = { retirePlayback() }
|
||||
}
|
||||
mediaSession = MediaSession(this, "proton-stream").apply {
|
||||
setSessionActivity(openActivity())
|
||||
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 onSkipToNext() { host?.onSkipNext?.invoke() }
|
||||
override fun onSkipToPrevious() { host?.onSkipPrevious?.invoke() }
|
||||
})
|
||||
}
|
||||
// Artwork arrives after the episode does, and the shape of the transport
|
||||
// changes with the playlist position, so the notification follows the
|
||||
// published episode rather than being built once when playback starts.
|
||||
val core = host ?: return
|
||||
scope.launch {
|
||||
core.nowPlaying.collect { playing ->
|
||||
// Keyed by whichever source the episode has: with metadata off
|
||||
// there is no provider poster, and Proton's own thumbnail is
|
||||
// the only thing the shade can show.
|
||||
artwork = playing?.let { episode ->
|
||||
val key = episode.artworkUrl ?: episode.artworkFile?.key ?: return@let null
|
||||
artwork?.takeIf { it.first == key }
|
||||
?: (
|
||||
loadArtwork(this@PlaybackService, episode.artworkUrl)
|
||||
?: loadThumbnail(episode.artworkFile)
|
||||
)?.let { key to it }
|
||||
}
|
||||
refreshSession(core.state.value, playing)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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_PREVIOUS -> host?.onSkipPrevious?.invoke()
|
||||
ACTION_NEXT -> host?.onSkipNext?.invoke()
|
||||
ACTION_STOP -> host?.stop()
|
||||
}
|
||||
return START_NOT_STICKY
|
||||
}
|
||||
|
||||
/**
|
||||
* Swiped away from Recents. With background audio on, this service would
|
||||
* otherwise play on with no screen to return to and no autoplay, so it
|
||||
* saves where the episode got to and stops.
|
||||
*/
|
||||
override fun onTaskRemoved(rootIntent: Intent?) {
|
||||
lastReading?.let { reading ->
|
||||
// Bounded and on this thread: the process may go as soon as the
|
||||
// service stops, and a save launched into its scope would go with it.
|
||||
runBlocking { withTimeoutOrNull(SAVE_ON_EXIT_MS) { withContext(Dispatchers.IO) { save(reading) } } }
|
||||
}
|
||||
host?.stop()
|
||||
retirePlayback()
|
||||
super.onTaskRemoved(rootIntent)
|
||||
}
|
||||
|
||||
override fun onDestroy() {
|
||||
scope.cancel()
|
||||
runCatching { unregisterReceiver(becomingNoisy) }
|
||||
audioFocus?.let(audioManager::abandonAudioFocusRequest)
|
||||
mediaSession.release()
|
||||
host?.close()
|
||||
super.onDestroy()
|
||||
}
|
||||
|
||||
/**
|
||||
* Take audio focus, and honour what the system says about it afterwards.
|
||||
*
|
||||
* The four cases are genuinely different and collapsing them is what made
|
||||
* this feel broken: a phone call is transient and playback should come back
|
||||
* by itself; a navigation prompt wants the volume down, not a pause; another
|
||||
* media app taking over for good means giving the focus back rather than
|
||||
* holding it; and a refusal means not playing at all.
|
||||
*/
|
||||
private fun requestAudioFocus(): Boolean {
|
||||
audioFocus?.let(audioManager::abandonAudioFocusRequest)
|
||||
val request = AudioFocusRequest.Builder(AudioManager.AUDIOFOCUS_GAIN)
|
||||
.setAudioAttributes(
|
||||
AudioAttributes.Builder().setUsage(AudioAttributes.USAGE_MEDIA)
|
||||
.setContentType(AudioAttributes.CONTENT_TYPE_MOVIE).build(),
|
||||
)
|
||||
.setWillPauseWhenDucked(false)
|
||||
.setOnAudioFocusChangeListener { focus ->
|
||||
when (focus) {
|
||||
AudioManager.AUDIOFOCUS_LOSS -> {
|
||||
pausedForFocus = false
|
||||
audioFocus?.let(audioManager::abandonAudioFocusRequest)
|
||||
audioFocus = null
|
||||
host?.setPaused(true)
|
||||
}
|
||||
AudioManager.AUDIOFOCUS_LOSS_TRANSIENT -> {
|
||||
// Only resume what this paused: a viewer who paused
|
||||
// before the phone rang expects it still paused after.
|
||||
pausedForFocus = host?.state?.value?.paused == false
|
||||
host?.setPaused(true)
|
||||
}
|
||||
AudioManager.AUDIOFOCUS_LOSS_TRANSIENT_CAN_DUCK -> {
|
||||
host?.let { core ->
|
||||
val current = core.state.value.volume
|
||||
beforeDucking = beforeDucking ?: current
|
||||
core.setVolume(current * DUCK_FACTOR)
|
||||
}
|
||||
}
|
||||
AudioManager.AUDIOFOCUS_GAIN -> {
|
||||
beforeDucking?.let { host?.setVolume(it) }
|
||||
beforeDucking = null
|
||||
if (pausedForFocus) {
|
||||
pausedForFocus = false
|
||||
host?.setPaused(false)
|
||||
}
|
||||
}
|
||||
}
|
||||
}.build()
|
||||
audioFocus = request
|
||||
return audioManager.requestAudioFocus(request) == AudioManager.AUDIOFOCUS_REQUEST_GRANTED
|
||||
}
|
||||
|
||||
/**
|
||||
* Keep the resume position from here rather than from the player screen.
|
||||
*
|
||||
* This service outlives the activity whenever background audio is on, and a
|
||||
* position the screen alone kept stopped being saved the moment the screen
|
||||
* went. Readings are attributed by [MpvPlaybackState.media], so an episode
|
||||
* change saves the outgoing episode's last reading before the new one's.
|
||||
*/
|
||||
private fun track(state: MpvPlaybackState) {
|
||||
val media = state.media ?: return
|
||||
if (state.duration <= 0.0) return
|
||||
val previous = lastReading
|
||||
lastReading = state
|
||||
val now = SystemClock.elapsedRealtime()
|
||||
val due = when {
|
||||
previous != null && previous.media != media -> {
|
||||
scope.launch(Dispatchers.IO) { save(previous) }
|
||||
true
|
||||
}
|
||||
state.ended -> previous?.ended != true
|
||||
state.paused -> previous?.paused != true
|
||||
else -> now - lastSave >= SAVE_EVERY_MS
|
||||
}
|
||||
if (due) {
|
||||
lastSave = now
|
||||
scope.launch(Dispatchers.IO) { save(state) }
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun save(state: MpvPlaybackState) {
|
||||
val media = state.media ?: return
|
||||
// Played to the end, or near enough that what is left is credits. A
|
||||
// failure or a stop also "ends", and recording either as watched would
|
||||
// retire an episode nobody saw.
|
||||
val watched = state.endReason == EndReason.Eof || state.position >= state.duration * WATCHED_FRACTION
|
||||
runCatching {
|
||||
NativeRuntime.engine().saveWatchState(
|
||||
media.substringBeforeLast(':'),
|
||||
media.substringAfterLast(':'),
|
||||
state.position.coerceIn(0.0, state.duration),
|
||||
state.duration,
|
||||
watched,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private fun updateSession(state: MpvPlaybackState) {
|
||||
refreshSession(state, host?.nowPlaying?.value)
|
||||
if (state.ended) scheduleRetire() else cancelRetire()
|
||||
}
|
||||
|
||||
/**
|
||||
* Retire the session once playback is really over.
|
||||
*
|
||||
* "This file ended" is not "playback is over": autoplay walks to the next
|
||||
* episode, and opening its stream is a network round trip. Retiring inside
|
||||
* that gap leaves the app with no foreground state at all, so the
|
||||
* `startForeground` on the far side is a background start — which API 31+
|
||||
* refuses with `ForegroundServiceStartNotAllowedException`, and this app's
|
||||
* minSdk is 31.
|
||||
*/
|
||||
private fun scheduleRetire() {
|
||||
if (retire?.isActive == true) return
|
||||
retire = scope.launch {
|
||||
// The end of file reaches the player and this service from the same
|
||||
// poll, and the player's advance is a recomposition behind. Give it
|
||||
// that long to declare a transition before concluding there is none.
|
||||
delay(TRANSITION_SETTLE_MS)
|
||||
val host = this@PlaybackService.host ?: return@launch
|
||||
withTimeoutOrNull(TRANSITION_GRACE_MS) { host.advancing.first { !it } }
|
||||
if (host.state.value.ended) retirePlayback()
|
||||
}
|
||||
}
|
||||
|
||||
private fun cancelRetire() {
|
||||
retire?.cancel()
|
||||
retire = null
|
||||
}
|
||||
|
||||
private fun refreshSession(state: MpvPlaybackState, playing: NowPlaying?) {
|
||||
val status = when {
|
||||
state.ended -> PlaybackState.STATE_STOPPED
|
||||
state.paused -> PlaybackState.STATE_PAUSED
|
||||
else -> PlaybackState.STATE_PLAYING
|
||||
}
|
||||
var actions = PlaybackState.ACTION_PLAY or PlaybackState.ACTION_PAUSE or
|
||||
PlaybackState.ACTION_PLAY_PAUSE or PlaybackState.ACTION_SEEK_TO or PlaybackState.ACTION_STOP
|
||||
if (playing?.hasNext == true) actions = actions or PlaybackState.ACTION_SKIP_TO_NEXT
|
||||
if (playing?.hasPrevious == true) actions = actions or PlaybackState.ACTION_SKIP_TO_PREVIOUS
|
||||
mediaSession.setPlaybackState(
|
||||
PlaybackState.Builder()
|
||||
.setActions(actions)
|
||||
.setState(status, (state.position * 1_000).toLong(), if (state.paused) 0f else 1f)
|
||||
.build(),
|
||||
)
|
||||
mediaSession.setMetadata(metadata(state, playing))
|
||||
val shape = NotificationShape(
|
||||
paused = state.paused,
|
||||
media = playing?.media,
|
||||
title = playing?.title,
|
||||
subtitle = playing?.show,
|
||||
hasPrevious = playing?.hasPrevious == true,
|
||||
hasNext = playing?.hasNext == true,
|
||||
hasArtwork = artwork != null,
|
||||
)
|
||||
if (foregroundPlayback && notified != shape) {
|
||||
notified = shape
|
||||
getSystemService(NotificationManager::class.java)
|
||||
.notify(NOTIFICATION_ID, notification(state, playing))
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* What the shade, the lock screen and Android Auto read.
|
||||
*
|
||||
* The episode is the title and the show is the artist, which is the mapping
|
||||
* every media surface already lays out well: one prominent line, one
|
||||
* secondary, and the poster behind both.
|
||||
*/
|
||||
private fun metadata(state: MpvPlaybackState, playing: NowPlaying?): MediaMetadata =
|
||||
MediaMetadata.Builder()
|
||||
.putString(MediaMetadata.METADATA_KEY_TITLE, playing?.title ?: "proton-stream")
|
||||
.putString(MediaMetadata.METADATA_KEY_DISPLAY_TITLE, playing?.title ?: "proton-stream")
|
||||
.apply {
|
||||
playing?.show?.let {
|
||||
putString(MediaMetadata.METADATA_KEY_ARTIST, it)
|
||||
putString(MediaMetadata.METADATA_KEY_ALBUM, it)
|
||||
putString(MediaMetadata.METADATA_KEY_DISPLAY_SUBTITLE, it)
|
||||
}
|
||||
playing?.detail?.let {
|
||||
putString(MediaMetadata.METADATA_KEY_DISPLAY_DESCRIPTION, it)
|
||||
}
|
||||
artwork?.second?.let {
|
||||
putBitmap(MediaMetadata.METADATA_KEY_ALBUM_ART, it)
|
||||
putBitmap(MediaMetadata.METADATA_KEY_ART, it)
|
||||
putBitmap(MediaMetadata.METADATA_KEY_DISPLAY_ICON, it)
|
||||
}
|
||||
}
|
||||
.putLong(MediaMetadata.METADATA_KEY_DURATION, (state.duration * 1_000).toLong())
|
||||
.build()
|
||||
|
||||
private fun retirePlayback() {
|
||||
cancelRetire()
|
||||
if (!sessionPlayback && !foregroundPlayback) return
|
||||
sessionPlayback = false
|
||||
notified = null
|
||||
artwork = null
|
||||
mediaSession.isActive = false
|
||||
audioFocus?.let(audioManager::abandonAudioFocusRequest)
|
||||
audioFocus = null
|
||||
if (foregroundPlayback) stopForeground(STOP_FOREGROUND_REMOVE)
|
||||
foregroundPlayback = false
|
||||
stopSelf()
|
||||
}
|
||||
|
||||
private fun openActivity(): PendingIntent = PendingIntent.getActivity(
|
||||
this,
|
||||
0,
|
||||
Intent(this, MainActivity::class.java),
|
||||
PendingIntent.FLAG_IMMUTABLE or PendingIntent.FLAG_UPDATE_CURRENT,
|
||||
)
|
||||
|
||||
private fun command(requestCode: Int, action: String): PendingIntent = PendingIntent.getService(
|
||||
this,
|
||||
requestCode,
|
||||
Intent(this, PlaybackService::class.java).setAction(action),
|
||||
PendingIntent.FLAG_IMMUTABLE or PendingIntent.FLAG_UPDATE_CURRENT,
|
||||
)
|
||||
|
||||
private fun action(icon: Int, label: String, requestCode: Int, name: String): Notification.Action =
|
||||
Notification.Action.Builder(Icon.createWithResource(this, icon), label, command(requestCode, name)).build()
|
||||
|
||||
private fun notification(state: MpvPlaybackState, playing: NowPlaying?): Notification {
|
||||
val paused = state.paused
|
||||
val actions = buildList {
|
||||
if (playing?.hasPrevious == true) {
|
||||
add(action(android.R.drawable.ic_media_previous, "Previous", 3, ACTION_PREVIOUS))
|
||||
}
|
||||
add(
|
||||
if (paused) action(android.R.drawable.ic_media_play, "Play", 1, ACTION_PLAY)
|
||||
else action(android.R.drawable.ic_media_pause, "Pause", 1, ACTION_PAUSE),
|
||||
)
|
||||
if (playing?.hasNext == true) {
|
||||
add(action(android.R.drawable.ic_media_next, "Next", 4, ACTION_NEXT))
|
||||
}
|
||||
add(action(android.R.drawable.ic_menu_close_clear_cancel, "Stop", 2, ACTION_STOP))
|
||||
}
|
||||
val style = Notification.MediaStyle()
|
||||
.setMediaSession(mediaSession.sessionToken)
|
||||
// The compact view holds three: the transport as it exists for this
|
||||
// episode, so a first episode does not show a dead Previous.
|
||||
.setShowActionsInCompactView(*IntArray(minOf(3, actions.size)) { it })
|
||||
return Notification.Builder(this, CHANNEL_ID)
|
||||
.setSmallIcon(R.drawable.ic_notification_stream)
|
||||
.setContentTitle(playing?.title ?: "proton-stream")
|
||||
.setContentText(playing?.show ?: playing?.detail ?: if (paused) "Paused" else "Playing")
|
||||
.apply {
|
||||
playing?.takeIf { it.show != null }?.detail?.let { setSubText(it) }
|
||||
artwork?.second?.let { setLargeIcon(it) }
|
||||
}
|
||||
.setColor(ACCENT)
|
||||
.setColorized(true)
|
||||
.setContentIntent(openActivity())
|
||||
.setDeleteIntent(command(2, ACTION_STOP))
|
||||
.setVisibility(Notification.VISIBILITY_PUBLIC)
|
||||
.setOngoing(!paused)
|
||||
.apply { actions.forEach { addAction(it) } }
|
||||
.setStyle(style)
|
||||
.build()
|
||||
}
|
||||
|
||||
private fun createChannel() {
|
||||
getSystemService(NotificationManager::class.java).createNotificationChannel(
|
||||
NotificationChannel(CHANNEL_ID, "Playback", NotificationManager.IMPORTANCE_LOW).apply {
|
||||
description = "Transport controls for the episode being played."
|
||||
setShowBadge(false)
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
/** The inputs the posted notification is built from, and nothing else. */
|
||||
private data class NotificationShape(
|
||||
val paused: Boolean,
|
||||
val media: String?,
|
||||
val title: String?,
|
||||
val subtitle: String?,
|
||||
val hasPrevious: Boolean,
|
||||
val hasNext: Boolean,
|
||||
val hasArtwork: Boolean,
|
||||
)
|
||||
|
||||
companion object {
|
||||
private const val CHANNEL_ID = "playback"
|
||||
private const val NOTIFICATION_ID = 47
|
||||
/** How long a player gets to declare that it is changing episodes. */
|
||||
private const val TRANSITION_SETTLE_MS = 1_000L
|
||||
/** And how long that transition may then take before it is abandoned. */
|
||||
private const val TRANSITION_GRACE_MS = 30_000L
|
||||
/** How far the volume drops for a navigation prompt or a notification. */
|
||||
private const val DUCK_FACTOR = 0.3
|
||||
private const val SAVE_EVERY_MS = 5_000L
|
||||
/** How long a swipe from Recents may hold the main thread to save. */
|
||||
private const val SAVE_ON_EXIT_MS = 2_000L
|
||||
|
||||
/** Catppuccin Mocha mauve, the accent the app is themed with. */
|
||||
private const val ACCENT = 0xFFCBA6F7.toInt()
|
||||
private const val ACTION_PLAY = "io.narl.protonstream.PLAY"
|
||||
private const val ACTION_PAUSE = "io.narl.protonstream.PAUSE"
|
||||
private const val ACTION_PREVIOUS = "io.narl.protonstream.PREVIOUS"
|
||||
private const val ACTION_NEXT = "io.narl.protonstream.NEXT"
|
||||
private const val ACTION_STOP = "io.narl.protonstream.STOP"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,220 @@
|
||||
package io.narl.protonstream.playback
|
||||
|
||||
import android.app.Activity
|
||||
import android.content.Context
|
||||
import android.media.AudioManager
|
||||
import android.view.WindowManager
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.gestures.detectDragGestures
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.automirrored.filled.VolumeUp
|
||||
import androidx.compose.material.icons.filled.BrightnessMedium
|
||||
import androidx.compose.material.icons.filled.FastForward
|
||||
import androidx.compose.material.icons.filled.FastRewind
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.LinearProgressIndicator
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.geometry.Offset
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.input.pointer.pointerInput
|
||||
import androidx.compose.ui.unit.dp
|
||||
import kotlin.math.abs
|
||||
import kotlin.math.roundToInt
|
||||
|
||||
/** What a swipe on the picture is doing, for the bubble that says so. */
|
||||
sealed interface Swipe {
|
||||
/** The window's brightness, from 0 to 1. */
|
||||
data class Brightness(val level: Float) : Swipe
|
||||
|
||||
/** The device's media volume, from 0 to 1. */
|
||||
data class Volume(val level: Float) : Swipe
|
||||
|
||||
/** Where the playhead will land when the finger lifts, and where it was. */
|
||||
data class Seek(val from: Double, val to: Double) : Swipe
|
||||
}
|
||||
|
||||
/** How much of the file one swipe across the whole width is worth, in seconds. */
|
||||
private const val SEEK_SPAN_SECONDS = 90.0
|
||||
|
||||
/**
|
||||
* A finger that comes down this close to the top or bottom of the screen is
|
||||
* the system's: pulling down the shade, swiping home.
|
||||
*/
|
||||
private val SYSTEM_EDGE = 48.dp
|
||||
|
||||
/** Which way a swipe went, settled by its first movement past the touch slop. */
|
||||
private enum class Axis { Brightness, Volume, Seek, Ignored }
|
||||
|
||||
/**
|
||||
* The swipes every phone video player has taught: up and down on the left for
|
||||
* brightness, on the right for volume, and sideways to seek — shown while the
|
||||
* finger is down and only seeking when it lifts, because a seek per movement
|
||||
* would throw away every block in flight.
|
||||
*/
|
||||
fun Modifier.playerSwipes(
|
||||
enabled: Boolean,
|
||||
activity: Activity?,
|
||||
position: () -> Double,
|
||||
duration: () -> Double,
|
||||
onSwipe: (Swipe?) -> Unit,
|
||||
onSeek: (Double) -> Unit,
|
||||
): Modifier = pointerInput(enabled, activity) {
|
||||
if (!enabled) return@pointerInput
|
||||
val audio = activity?.getSystemService(Context.AUDIO_SERVICE) as? AudioManager
|
||||
val edge = SYSTEM_EDGE.toPx()
|
||||
var axis = Axis.Ignored
|
||||
var start = Offset.Zero
|
||||
var travelled = Offset.Zero
|
||||
var level = 0f
|
||||
var seekFrom = 0.0
|
||||
var seekTo = 0.0
|
||||
detectDragGestures(
|
||||
onDragStart = { offset ->
|
||||
start = offset
|
||||
travelled = Offset.Zero
|
||||
axis = if (offset.y < edge || offset.y > size.height - edge) Axis.Ignored else Axis.Seek
|
||||
},
|
||||
onDragEnd = {
|
||||
if (axis == Axis.Seek && seekTo != seekFrom) onSeek(seekTo)
|
||||
onSwipe(null)
|
||||
},
|
||||
onDragCancel = { onSwipe(null) },
|
||||
) { change, amount ->
|
||||
if (axis == Axis.Ignored) return@detectDragGestures
|
||||
change.consume()
|
||||
val first = travelled == Offset.Zero
|
||||
travelled += amount
|
||||
if (first) {
|
||||
// The first movement past the slop decides what the swipe is for.
|
||||
axis = when {
|
||||
abs(amount.x) > abs(amount.y) -> Axis.Seek
|
||||
start.x < size.width / 2 -> Axis.Brightness
|
||||
else -> Axis.Volume
|
||||
}
|
||||
level = when (axis) {
|
||||
Axis.Brightness -> activity?.brightness() ?: 0.5f
|
||||
Axis.Volume -> audio?.mediaLevel() ?: 0f
|
||||
else -> 0f
|
||||
}
|
||||
seekFrom = position()
|
||||
seekTo = seekFrom
|
||||
}
|
||||
when (axis) {
|
||||
// A swipe the whole height of the screen goes from none to all.
|
||||
Axis.Brightness -> {
|
||||
level = (level - amount.y / size.height).coerceIn(0f, 1f)
|
||||
activity?.setBrightness(level)
|
||||
onSwipe(Swipe.Brightness(level))
|
||||
}
|
||||
Axis.Volume -> {
|
||||
level = (level - amount.y / size.height).coerceIn(0f, 1f)
|
||||
audio?.setMediaLevel(level)
|
||||
onSwipe(Swipe.Volume(level))
|
||||
}
|
||||
Axis.Seek -> {
|
||||
val length = duration()
|
||||
if (length <= 0.0) return@detectDragGestures
|
||||
seekTo = (seekFrom + travelled.x / size.width * SEEK_SPAN_SECONDS).coerceIn(0.0, length)
|
||||
onSwipe(Swipe.Seek(seekFrom, seekTo))
|
||||
}
|
||||
Axis.Ignored -> {}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** The window's own brightness, or the screen's when the window has none. */
|
||||
private fun Activity.brightness(): Float {
|
||||
val own = window.attributes.screenBrightness
|
||||
if (own >= 0f) return own
|
||||
return runCatching {
|
||||
android.provider.Settings.System.getInt(contentResolver, android.provider.Settings.System.SCREEN_BRIGHTNESS) / 255f
|
||||
}.getOrDefault(0.5f)
|
||||
}
|
||||
|
||||
private fun Activity.setBrightness(level: Float) {
|
||||
window.attributes = window.attributes.apply {
|
||||
// Never quite off: a black screen under a playing film looks broken.
|
||||
screenBrightness = level.coerceAtLeast(0.01f)
|
||||
}
|
||||
}
|
||||
|
||||
/** Hand brightness back to the system, as the player is left. */
|
||||
fun Activity.releaseBrightness() {
|
||||
window.attributes = window.attributes.apply {
|
||||
screenBrightness = WindowManager.LayoutParams.BRIGHTNESS_OVERRIDE_NONE
|
||||
}
|
||||
}
|
||||
|
||||
private fun AudioManager.mediaLevel(): Float {
|
||||
val max = getStreamMaxVolume(AudioManager.STREAM_MUSIC).coerceAtLeast(1)
|
||||
return getStreamVolume(AudioManager.STREAM_MUSIC).toFloat() / max
|
||||
}
|
||||
|
||||
private fun AudioManager.setMediaLevel(level: Float) {
|
||||
val max = getStreamMaxVolume(AudioManager.STREAM_MUSIC)
|
||||
// No flags: the bubble is the feedback, and the system's own volume panel
|
||||
// over the picture would be a second one.
|
||||
setStreamVolume(AudioManager.STREAM_MUSIC, (level * max).roundToInt(), 0)
|
||||
}
|
||||
|
||||
/** The bubble in the middle of the picture while a swipe is under way. */
|
||||
@Composable
|
||||
fun SwipeBubble(swipe: Swipe, format: (Double) -> String, modifier: Modifier = Modifier) {
|
||||
Column(
|
||||
modifier
|
||||
.clip(RoundedCornerShape(20.dp))
|
||||
.background(Color.Black.copy(alpha = 0.6f))
|
||||
.padding(horizontal = 24.dp, vertical = 16.dp),
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
verticalArrangement = Arrangement.spacedBy(8.dp),
|
||||
) {
|
||||
when (swipe) {
|
||||
is Swipe.Brightness -> Level(Icons.Default.BrightnessMedium, "Brightness", swipe.level)
|
||||
is Swipe.Volume -> Level(Icons.AutoMirrored.Filled.VolumeUp, "Volume", swipe.level)
|
||||
is Swipe.Seek -> {
|
||||
val delta = swipe.to - swipe.from
|
||||
Icon(
|
||||
if (delta < 0) Icons.Default.FastRewind else Icons.Default.FastForward,
|
||||
contentDescription = null,
|
||||
tint = Color.White,
|
||||
modifier = Modifier.size(32.dp),
|
||||
)
|
||||
Text(
|
||||
(if (delta < 0) "−" else "+") + format(abs(delta)),
|
||||
color = Color.White,
|
||||
style = MaterialTheme.typography.titleMedium,
|
||||
)
|
||||
Text(format(swipe.to), color = Color.White.copy(alpha = 0.7f), style = MaterialTheme.typography.labelMedium)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun Level(icon: androidx.compose.ui.graphics.vector.ImageVector, label: String, level: Float) {
|
||||
Icon(icon, contentDescription = null, tint = Color.White, modifier = Modifier.size(32.dp))
|
||||
Text("$label ${(level * 100).roundToInt()}%", color = Color.White, style = MaterialTheme.typography.labelLarge)
|
||||
Box(Modifier.width(120.dp)) {
|
||||
LinearProgressIndicator(
|
||||
progress = { level },
|
||||
modifier = Modifier.fillMaxWidth().height(4.dp),
|
||||
color = MaterialTheme.colorScheme.primary,
|
||||
trackColor = Color.White.copy(alpha = 0.25f),
|
||||
drawStopIndicator = {},
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,103 @@
|
||||
package io.narl.protonstream.playback
|
||||
|
||||
import android.view.Surface
|
||||
import io.narl.protonstream.native.NativeRuntime
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.SupervisorJob
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.withContext
|
||||
import uniffi.pstr_android.AndroidEngine
|
||||
import uniffi.pstr_android.AndroidStream
|
||||
import uniffi.pstr_android.EpisodeRecord
|
||||
|
||||
/** 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
|
||||
}
|
||||
|
||||
/**
|
||||
* Give the episode's stream back to Rust, from a scope of this session's own.
|
||||
*
|
||||
* Deliberately not the caller's: the one moment this has to run is when the
|
||||
* player leaves the composition, which is the same moment a
|
||||
* `rememberCoroutineScope` is cancelled — so a release launched there is
|
||||
* cancelled before it starts, and the stream is held for the life of the
|
||||
* process. Once per episode played, that is every episode.
|
||||
*/
|
||||
fun close(host: LibmpvHost) {
|
||||
releaseNative(host)
|
||||
sessions.launch {
|
||||
runCatching { engine?.releaseStream(episode.shareId, episode.volumeId, episode.linkId) }
|
||||
engine = null
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Where stream teardown runs: process-lifetime, like the engine it releases to,
|
||||
* and never cancelled by a screen going away.
|
||||
*/
|
||||
private val sessions = CoroutineScope(SupervisorJob() + Dispatchers.IO)
|
||||
|
||||
/** 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}.
|
||||
*
|
||||
* [media] names the episode being loaded. One host serves every episode in
|
||||
* turn, so its readings have to say which one they describe — see
|
||||
* [MpvPlaybackState.media].
|
||||
*/
|
||||
suspend fun play(
|
||||
media: String,
|
||||
nativeHandle: ULong,
|
||||
size: ULong,
|
||||
startPosition: Double = 0.0,
|
||||
audioLanguage: String? = null,
|
||||
subtitleLanguage: String? = null,
|
||||
subtitles: Boolean = true,
|
||||
hardwareDecoding: Boolean = true,
|
||||
)
|
||||
|
||||
/** Calls pstr_android_stream_release exactly once for a published handle. */
|
||||
fun releaseNativeStream(nativeHandle: ULong)
|
||||
}
|
||||
|
||||
/** How an episode is named in watch state, work names and player readings. */
|
||||
fun mediaKey(episode: EpisodeRecord) = "${episode.shareId}:${episode.linkId}"
|
||||
@@ -0,0 +1,89 @@
|
||||
package io.narl.protonstream.settings
|
||||
|
||||
import android.content.Context
|
||||
import androidx.core.content.edit
|
||||
|
||||
/**
|
||||
* The settings that are Android's alone.
|
||||
*
|
||||
* Playback preferences — volume, languages, autoplay, auto-skip — deliberately
|
||||
* do *not* live here: they are `pstr_core::prefs`, reached over the bridge, so
|
||||
* there is one definition of them rather than one per front end.
|
||||
*/
|
||||
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() }
|
||||
|
||||
/**
|
||||
* Whether mpv may decode in hardware. Off is the way out for a device whose
|
||||
* MediaCodec decoder shows green or torn frames; it costs battery, so it is
|
||||
* on unless the viewer turns it off. Read at each load, so a change applies
|
||||
* from the next file.
|
||||
*/
|
||||
var hardwareDecoding: Boolean
|
||||
get() = preferences.getBoolean(KEY_HARDWARE_DECODING, true)
|
||||
set(value) { preferences.edit().putBoolean(KEY_HARDWARE_DECODING, value).apply() }
|
||||
|
||||
/** The streaming cache's budget, in GiB. See [CACHE_BUDGET_CHOICES]. */
|
||||
var cacheBudgetGib: Int
|
||||
get() = preferences.getInt(KEY_CACHE_BUDGET_GIB, DEFAULT_CACHE_BUDGET_GIB)
|
||||
set(value) { preferences.edit().putInt(KEY_CACHE_BUDGET_GIB, value).apply() }
|
||||
|
||||
/** How the library was last sorted, as a `LibrarySort` name; null for the default. */
|
||||
var librarySort: String?
|
||||
get() = preferences.getString(KEY_LIBRARY_SORT, null)
|
||||
set(value) { preferences.edit { putString(KEY_LIBRARY_SORT, value) } }
|
||||
|
||||
/** The library's offer of posters and synopses was turned down. */
|
||||
var metadataOfferDismissed: Boolean
|
||||
get() = preferences.getBoolean(KEY_METADATA_OFFER_DISMISSED, false)
|
||||
set(value) { preferences.edit { putBoolean(KEY_METADATA_OFFER_DISMISSED, value) } }
|
||||
|
||||
/** Which titles the library last showed, as a `LibraryFilter` name; null for all. */
|
||||
var libraryFilter: String?
|
||||
get() = preferences.getString(KEY_LIBRARY_FILTER, null)
|
||||
set(value) { preferences.edit { putString(KEY_LIBRARY_FILTER, value) } }
|
||||
|
||||
/** Whether the library folds each franchise into one tile. */
|
||||
var libraryGrouped: Boolean
|
||||
get() = preferences.getBoolean(KEY_LIBRARY_GROUPED, true)
|
||||
set(value) { preferences.edit { putBoolean(KEY_LIBRARY_GROUPED, value) } }
|
||||
|
||||
/** Whether a background crawl may say that a show gained episodes. */
|
||||
var newEpisodeNotifications: Boolean
|
||||
get() = preferences.getBoolean(KEY_NEW_EPISODE_NOTIFICATIONS, true)
|
||||
set(value) { preferences.edit { putBoolean(KEY_NEW_EPISODE_NOTIFICATIONS, value) } }
|
||||
|
||||
companion object {
|
||||
/** The desktop's range, in the steps a phone's storage is bought in. */
|
||||
val CACHE_BUDGET_CHOICES = listOf(1, 2, 4, 8, 16)
|
||||
|
||||
/** `DiskCacheConfig::DEFAULT_BUDGET_BYTES`, so an unset choice changes nothing. */
|
||||
const val DEFAULT_CACHE_BUDGET_GIB = 4
|
||||
|
||||
fun gibToBytes(gib: Int): ULong = gib.toULong() * 1024uL * 1024uL * 1024uL
|
||||
|
||||
/** Load the file into memory, so no later read waits on the disk. */
|
||||
fun preload(context: Context) {
|
||||
context.getSharedPreferences(NAME, Context.MODE_PRIVATE).all
|
||||
}
|
||||
|
||||
private const val NAME = "proton_stream_settings"
|
||||
private const val KEY_WIFI_ONLY = "wifi_only"
|
||||
private const val KEY_BACKGROUND_AUDIO = "background_audio"
|
||||
private const val KEY_HARDWARE_DECODING = "hardware_decoding"
|
||||
private const val KEY_CACHE_BUDGET_GIB = "cache_budget_gib"
|
||||
private const val KEY_LIBRARY_SORT = "library_sort"
|
||||
private const val KEY_LIBRARY_GROUPED = "library_grouped"
|
||||
private const val KEY_LIBRARY_FILTER = "library_filter"
|
||||
private const val KEY_NEW_EPISODE_NOTIFICATIONS = "new_episode_notifications"
|
||||
private const val KEY_METADATA_OFFER_DISMISSED = "metadata_offer_dismissed"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,136 @@
|
||||
package io.narl.protonstream.sync
|
||||
|
||||
import android.Manifest
|
||||
import android.app.NotificationChannel
|
||||
import android.app.NotificationManager
|
||||
import android.app.PendingIntent
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.content.pm.PackageManager
|
||||
import android.os.Build
|
||||
import androidx.core.app.NotificationCompat
|
||||
import androidx.core.app.NotificationManagerCompat
|
||||
import androidx.work.Constraints
|
||||
import androidx.work.CoroutineWorker
|
||||
import androidx.work.ExistingPeriodicWorkPolicy
|
||||
import androidx.work.NetworkType
|
||||
import androidx.work.PeriodicWorkRequestBuilder
|
||||
import androidx.work.WorkManager
|
||||
import androidx.work.WorkerParameters
|
||||
import io.narl.protonstream.MainActivity
|
||||
import io.narl.protonstream.R
|
||||
import io.narl.protonstream.native.NativeRuntime
|
||||
import io.narl.protonstream.settings.SettingsStore
|
||||
import uniffi.pstr_android.NewEpisodesRecord
|
||||
import java.util.concurrent.TimeUnit
|
||||
|
||||
/**
|
||||
* Look at the shares while the app is away, and say when a show gained episodes.
|
||||
*
|
||||
* The app crawls a share it has not looked at for six hours whenever it opens,
|
||||
* which finds a new episode only once someone opens it to look. This runs the
|
||||
* same crawl on WorkManager's schedule instead. It only reports files the
|
||||
* crawl itself found, so a share the app crawled since says nothing twice.
|
||||
*/
|
||||
class NewEpisodesWorker(
|
||||
appContext: Context,
|
||||
parameters: WorkerParameters,
|
||||
) : CoroutineWorker(appContext, parameters) {
|
||||
override suspend fun doWork(): Result {
|
||||
if (!SettingsStore(applicationContext).newEpisodeNotifications) return Result.success()
|
||||
val news = runCatching { NativeRuntime.engine().crawlForNews() }.getOrElse { return Result.retry() }
|
||||
if (news.isNotEmpty()) post(news)
|
||||
return Result.success()
|
||||
}
|
||||
|
||||
private fun post(news: List<NewEpisodesRecord>) {
|
||||
if (
|
||||
Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU &&
|
||||
applicationContext.checkSelfPermission(Manifest.permission.POST_NOTIFICATIONS) !=
|
||||
PackageManager.PERMISSION_GRANTED
|
||||
) {
|
||||
return
|
||||
}
|
||||
val manager = applicationContext.getSystemService(NotificationManager::class.java)
|
||||
manager.createNotificationChannel(
|
||||
NotificationChannel(CHANNEL_ID, "New episodes", NotificationManager.IMPORTANCE_DEFAULT).apply {
|
||||
description = "A show in your library gained episodes"
|
||||
},
|
||||
)
|
||||
val open = PendingIntent.getActivity(
|
||||
applicationContext,
|
||||
0,
|
||||
Intent(applicationContext, MainActivity::class.java),
|
||||
PendingIntent.FLAG_IMMUTABLE or PendingIntent.FLAG_UPDATE_CURRENT,
|
||||
)
|
||||
val compat = NotificationManagerCompat.from(applicationContext)
|
||||
// One per show, keyed by it, so a second crawl's news for the same show
|
||||
// replaces the first rather than stacking under it.
|
||||
news.forEach { item ->
|
||||
val notification = NotificationCompat.Builder(applicationContext, CHANNEL_ID)
|
||||
.setSmallIcon(R.drawable.ic_notification_stream)
|
||||
.setContentTitle(item.title)
|
||||
.setContentText(describe(item))
|
||||
.setContentIntent(open)
|
||||
.setGroup(GROUP)
|
||||
.setAutoCancel(true)
|
||||
.build()
|
||||
compat.notify(item.titleKey, NOTIFICATION_ID, notification)
|
||||
}
|
||||
if (news.size > 1) {
|
||||
val summary = NotificationCompat.Builder(applicationContext, CHANNEL_ID)
|
||||
.setSmallIcon(R.drawable.ic_notification_stream)
|
||||
.setContentTitle("New episodes")
|
||||
.setContentText(news.joinToString { it.title })
|
||||
.setContentIntent(open)
|
||||
.setGroup(GROUP)
|
||||
.setGroupSummary(true)
|
||||
.setAutoCancel(true)
|
||||
.build()
|
||||
compat.notify(SUMMARY_TAG, NOTIFICATION_ID, summary)
|
||||
}
|
||||
}
|
||||
|
||||
companion object {
|
||||
private const val WORK_NAME = "new-episodes"
|
||||
private const val CHANNEL_ID = "new-episodes"
|
||||
private const val GROUP = "io.narl.protonstream.NEW_EPISODES"
|
||||
private const val SUMMARY_TAG = "new-episodes-summary"
|
||||
private const val NOTIFICATION_ID = 7
|
||||
private const val INTERVAL_HOURS = 6L
|
||||
|
||||
/** What one show's notification says: "S01E04 and 2 more". */
|
||||
fun describe(news: NewEpisodesRecord): String {
|
||||
val more = news.count.toInt() - 1
|
||||
val first = news.first
|
||||
return when {
|
||||
first == null -> if (news.count == 1u) "1 new episode" else "${news.count} new episodes"
|
||||
more == 0 -> "$first is new"
|
||||
more == 1 -> "$first and 1 more are new"
|
||||
else -> "$first and $more more are new"
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Keep the periodic look scheduled, or cancel it when the viewer
|
||||
* turned it off. Called on every launch; keeping an existing schedule
|
||||
* means a launch does not push the next look back.
|
||||
*/
|
||||
fun schedule(context: Context) {
|
||||
val work = WorkManager.getInstance(context.applicationContext)
|
||||
if (!SettingsStore(context).newEpisodeNotifications) {
|
||||
work.cancelUniqueWork(WORK_NAME)
|
||||
return
|
||||
}
|
||||
val request = PeriodicWorkRequestBuilder<NewEpisodesWorker>(INTERVAL_HOURS, TimeUnit.HOURS)
|
||||
.setConstraints(
|
||||
Constraints.Builder()
|
||||
.setRequiredNetworkType(NetworkType.CONNECTED)
|
||||
.setRequiresBatteryNotLow(true)
|
||||
.build(),
|
||||
)
|
||||
.build()
|
||||
work.enqueueUniquePeriodicWork(WORK_NAME, ExistingPeriodicWorkPolicy.KEEP, request)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
package io.narl.protonstream.sync
|
||||
|
||||
import android.content.Context
|
||||
import androidx.work.BackoffPolicy
|
||||
import androidx.work.Constraints
|
||||
import androidx.work.CoroutineWorker
|
||||
import androidx.work.ExistingWorkPolicy
|
||||
import androidx.work.NetworkType
|
||||
import androidx.work.OneTimeWorkRequestBuilder
|
||||
import androidx.work.WorkManager
|
||||
import androidx.work.WorkerParameters
|
||||
import io.narl.protonstream.native.NativeRuntime
|
||||
import uniffi.pstr_android.AccountState
|
||||
import java.util.concurrent.TimeUnit
|
||||
|
||||
/**
|
||||
* One watch-history sync after the app leaves the screen.
|
||||
*
|
||||
* The model's timer only runs while the app is alive, and Android is free to
|
||||
* kill a process in the background without notice — so the position the
|
||||
* viewer stopped at would wait for the next launch to reach their other
|
||||
* devices. Handed to WorkManager instead, it goes as soon as there is a
|
||||
* network, even if the process is gone by then.
|
||||
*/
|
||||
class WatchSyncWorker(
|
||||
appContext: Context,
|
||||
parameters: WorkerParameters,
|
||||
) : CoroutineWorker(appContext, parameters) {
|
||||
override suspend fun doWork(): Result {
|
||||
val engine = NativeRuntime.engine()
|
||||
return runCatching { engine.syncWatchHistory() }.fold(
|
||||
onSuccess = { Result.success() },
|
||||
onFailure = {
|
||||
// Signed out by the engine — the session ended — is not
|
||||
// something a retry fixes; a dropped connection is.
|
||||
val signedOut = runCatching { engine.accountState() }.getOrNull() == AccountState.SignedOut
|
||||
if (signedOut || runAttemptCount >= MAX_ATTEMPTS) Result.failure() else Result.retry()
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
companion object {
|
||||
private const val WORK_NAME = "watch-history-sync"
|
||||
private const val MAX_ATTEMPTS = 3
|
||||
|
||||
/** Replacing: one pending sync covers every position saved before it runs. */
|
||||
fun enqueue(context: Context) {
|
||||
val request = OneTimeWorkRequestBuilder<WatchSyncWorker>()
|
||||
.setConstraints(Constraints.Builder().setRequiredNetworkType(NetworkType.CONNECTED).build())
|
||||
.setBackoffCriteria(BackoffPolicy.EXPONENTIAL, 30, TimeUnit.SECONDS)
|
||||
.build()
|
||||
WorkManager.getInstance(context.applicationContext)
|
||||
.enqueueUniqueWork(WORK_NAME, ExistingWorkPolicy.REPLACE, request)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,507 @@
|
||||
package io.narl.protonstream.ui
|
||||
|
||||
import android.annotation.SuppressLint
|
||||
import android.os.Handler
|
||||
import android.os.Looper
|
||||
import android.webkit.JavascriptInterface
|
||||
import android.webkit.WebResourceRequest
|
||||
import android.webkit.WebView
|
||||
import android.webkit.WebViewClient
|
||||
import androidx.activity.compose.BackHandler
|
||||
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.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.lazy.LazyColumn
|
||||
import androidx.compose.foundation.lazy.items
|
||||
import androidx.compose.foundation.text.KeyboardOptions
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.automirrored.filled.ArrowBack
|
||||
import androidx.compose.material.icons.filled.AccountCircle
|
||||
import androidx.compose.material.icons.filled.Close
|
||||
import androidx.compose.material.icons.filled.Computer
|
||||
import androidx.compose.material.icons.filled.Folder
|
||||
import androidx.compose.material.icons.filled.FolderShared
|
||||
import androidx.compose.material.icons.filled.InsertDriveFile
|
||||
import androidx.compose.material.icons.filled.Logout
|
||||
import androidx.compose.material.icons.filled.Movie
|
||||
import androidx.compose.material.icons.filled.MoreVert
|
||||
import androidx.compose.material.icons.filled.Storage
|
||||
import androidx.compose.material.icons.filled.Sync
|
||||
import androidx.compose.material3.AlertDialog
|
||||
import androidx.compose.material3.Card
|
||||
import androidx.compose.material3.CircularProgressIndicator
|
||||
import androidx.compose.material3.DropdownMenu
|
||||
import androidx.compose.material3.DropdownMenuItem
|
||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.IconButton
|
||||
import androidx.compose.material3.ListItem
|
||||
import androidx.compose.material3.ListItemDefaults
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.OutlinedTextField
|
||||
import androidx.compose.material3.Scaffold
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TopAppBar
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateListOf
|
||||
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.Color
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.text.input.KeyboardType
|
||||
import androidx.compose.ui.text.input.PasswordVisualTransformation
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.viewinterop.AndroidView
|
||||
import androidx.compose.ui.window.Dialog
|
||||
import androidx.compose.ui.window.DialogProperties
|
||||
import androidx.compose.ui.window.SecureFlagPolicy
|
||||
import io.narl.protonstream.native.NativeRuntime
|
||||
import io.narl.protonstream.ui.theme.AccentButton
|
||||
import io.narl.protonstream.ui.theme.TonalButton
|
||||
import uniffi.pstr_android.AccountState
|
||||
import uniffi.pstr_android.DriveEntryRecord
|
||||
import uniffi.pstr_android.DrivePlaceRecord
|
||||
import uniffi.pstr_android.PlaceType
|
||||
import uniffi.pstr_android.verificationToken
|
||||
|
||||
/** What the account card and its dialogs can ask the model for. */
|
||||
internal data class AccountActions(
|
||||
val signIn: (String, String, String?) -> Unit,
|
||||
val submitSecondFactor: (String) -> Unit,
|
||||
val submitMailboxPassword: (String) -> Unit,
|
||||
val cancelSignIn: () -> Unit,
|
||||
val dismissVerification: () -> Unit,
|
||||
val signOut: () -> Unit,
|
||||
val syncNow: () -> Unit,
|
||||
val addFolder: (String, String, String) -> Unit,
|
||||
val onError: (Throwable) -> Unit,
|
||||
)
|
||||
|
||||
/**
|
||||
* The Proton account, at the top of the shares page: why one would sign in,
|
||||
* or who is signed in and how watch-history sync is doing.
|
||||
*/
|
||||
@Composable
|
||||
internal fun AccountCard(
|
||||
account: AccountUiState,
|
||||
actions: AccountActions,
|
||||
onSignIn: () -> Unit,
|
||||
onBrowse: () -> Unit,
|
||||
) {
|
||||
val state = account.state ?: return
|
||||
Card(Modifier.fillMaxWidth().padding(horizontal = 16.dp, vertical = 8.dp)) {
|
||||
if (state is AccountState.SignedIn) {
|
||||
var menu by remember { mutableStateOf(false) }
|
||||
ListItem(
|
||||
headlineContent = { Text(state.username, fontWeight = FontWeight.SemiBold) },
|
||||
supportingContent = { Text(syncLine(account, System.currentTimeMillis())) },
|
||||
leadingContent = {
|
||||
Icon(Icons.Default.AccountCircle, contentDescription = null, tint = MaterialTheme.colorScheme.primary)
|
||||
},
|
||||
trailingContent = {
|
||||
Box {
|
||||
IconButton(onClick = { menu = true }) {
|
||||
Icon(Icons.Default.MoreVert, contentDescription = "Account actions")
|
||||
}
|
||||
DropdownMenu(expanded = menu, onDismissRequest = { menu = false }) {
|
||||
DropdownMenuItem(
|
||||
text = { Text("Sync now") },
|
||||
leadingIcon = { Icon(Icons.Default.Sync, contentDescription = null) },
|
||||
onClick = { menu = false; actions.syncNow() },
|
||||
)
|
||||
DropdownMenuItem(
|
||||
text = { Text("Sign out") },
|
||||
leadingIcon = { Icon(Icons.Default.Logout, contentDescription = null) },
|
||||
onClick = { menu = false; actions.signOut() },
|
||||
)
|
||||
}
|
||||
}
|
||||
},
|
||||
colors = ListItemDefaults.colors(containerColor = Color.Transparent),
|
||||
)
|
||||
TonalButton(
|
||||
onClick = onBrowse,
|
||||
modifier = Modifier.padding(start = 16.dp, end = 16.dp, bottom = 16.dp),
|
||||
) {
|
||||
Icon(Icons.Default.Folder, contentDescription = null)
|
||||
Text("Add from your Drive", Modifier.padding(start = 8.dp))
|
||||
}
|
||||
} else {
|
||||
Column(Modifier.padding(16.dp), verticalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||
Text("Proton account", style = MaterialTheme.typography.titleMedium)
|
||||
Text(
|
||||
"Optional. Signed in, your shares, settings and watch history are kept in your own " +
|
||||
"Drive, so every device shows the same library and resumes where another left off, " +
|
||||
"and folders of your Drive can join the library.",
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
TonalButton(onClick = onSignIn) { Text("Sign in") }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** "Synced 3 min ago · 2 from other devices", or why it did not. */
|
||||
internal fun syncLine(account: AccountUiState, now: Long): String {
|
||||
account.syncError?.let { return "Did not sync: $it" }
|
||||
val at = account.syncedAt ?: return "Library, settings and watch history sync through your Drive"
|
||||
val minutes = ((now - at).coerceAtLeast(0) / 60_000).toInt()
|
||||
val ago = when {
|
||||
minutes < 1 -> "just now"
|
||||
minutes < 60 -> "$minutes min ago"
|
||||
else -> "${minutes / 60} h ago"
|
||||
}
|
||||
val applied = if (account.applied > 0) " · ${account.applied} from other devices" else ""
|
||||
return "Synced $ago$applied"
|
||||
}
|
||||
|
||||
/**
|
||||
* Signing in, one step at a time: address and password, then a second-factor
|
||||
* code or the mailbox password if the account has them, with Proton's CAPTCHA
|
||||
* over the top when it asks for one.
|
||||
*/
|
||||
@Composable
|
||||
internal fun SignInDialog(account: AccountUiState, actions: AccountActions, onDismiss: () -> Unit) {
|
||||
var username by remember { mutableStateOf("") }
|
||||
var password by remember { mutableStateOf("") }
|
||||
var code by remember { mutableStateOf("") }
|
||||
var mailbox by remember { mutableStateOf("") }
|
||||
val state = account.state
|
||||
LaunchedEffect(state) {
|
||||
if (state is AccountState.SignedIn) onDismiss()
|
||||
}
|
||||
val dismiss = {
|
||||
if (state == AccountState.SecondFactor || state == AccountState.MailboxPassword) actions.cancelSignIn()
|
||||
onDismiss()
|
||||
}
|
||||
AlertDialog(
|
||||
onDismissRequest = dismiss,
|
||||
properties = DialogProperties(securePolicy = SecureFlagPolicy.SecureOn),
|
||||
title = { Text("Sign in to Proton") },
|
||||
text = {
|
||||
Column(verticalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||
when (state) {
|
||||
AccountState.SecondFactor -> {
|
||||
OutlinedTextField(
|
||||
code,
|
||||
{ code = it },
|
||||
label = { Text("Code from your authenticator app") },
|
||||
singleLine = true,
|
||||
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.NumberPassword),
|
||||
)
|
||||
Text(SECURITY_KEY_NOTE, style = MaterialTheme.typography.bodySmall)
|
||||
}
|
||||
AccountState.MailboxPassword -> {
|
||||
Text(
|
||||
"This account has a separate mailbox password. It unlocks your files.",
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
)
|
||||
PasswordField(mailbox, { mailbox = it }, "Mailbox password")
|
||||
}
|
||||
else -> {
|
||||
OutlinedTextField(
|
||||
username,
|
||||
{ username = it },
|
||||
label = { Text("Address") },
|
||||
singleLine = true,
|
||||
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Email),
|
||||
)
|
||||
PasswordField(password, { password = it }, "Password")
|
||||
}
|
||||
}
|
||||
account.error?.let { Text(it, color = MaterialTheme.colorScheme.error) }
|
||||
if (account.busy) CircularProgressIndicator(Modifier.padding(top = 4.dp))
|
||||
}
|
||||
},
|
||||
confirmButton = {
|
||||
when (state) {
|
||||
AccountState.SecondFactor -> AccentButton(
|
||||
onClick = { actions.submitSecondFactor(code.trim()) },
|
||||
enabled = code.isNotBlank() && !account.busy,
|
||||
) { Text("Continue") }
|
||||
AccountState.MailboxPassword -> AccentButton(
|
||||
onClick = { actions.submitMailboxPassword(mailbox) },
|
||||
enabled = mailbox.isNotEmpty() && !account.busy,
|
||||
) { Text("Unlock") }
|
||||
else -> AccentButton(
|
||||
onClick = { actions.signIn(username.trim(), password, null) },
|
||||
enabled = username.isNotBlank() && password.isNotEmpty() && !account.busy,
|
||||
) { Text("Sign in") }
|
||||
}
|
||||
},
|
||||
dismissButton = { TonalButton(onClick = dismiss) { Text("Cancel") } },
|
||||
)
|
||||
account.verificationUrl?.let { url ->
|
||||
VerificationDialog(
|
||||
url = url,
|
||||
onToken = { token -> actions.signIn(username.trim(), password, token) },
|
||||
onDismiss = actions.dismissVerification,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun PasswordField(value: String, onChange: (String) -> Unit, label: String) = OutlinedTextField(
|
||||
value,
|
||||
onChange,
|
||||
label = { Text(label) },
|
||||
singleLine = true,
|
||||
visualTransformation = PasswordVisualTransformation(),
|
||||
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Password),
|
||||
)
|
||||
|
||||
/**
|
||||
* Proton's hosted CAPTCHA, which it puts in front of a sign-in it does not
|
||||
* recognise — a new network, a VPN exit.
|
||||
*
|
||||
* In a WebView rather than the browser because the page reports success by
|
||||
* posting a message to its host, not by redirecting: a browser has nowhere to
|
||||
* post it back to. The page targets `window.parent`, which for a top-level
|
||||
* page is the page itself, so a listener on its own window hears it.
|
||||
*/
|
||||
@SuppressLint("SetJavaScriptEnabled") // The verification page is a script; it does not run without.
|
||||
@Composable
|
||||
private fun VerificationDialog(url: String, onToken: (String) -> Unit, onDismiss: () -> Unit) {
|
||||
Dialog(onDismissRequest = onDismiss, properties = DialogProperties(usePlatformDefaultWidth = false)) {
|
||||
Card(Modifier.fillMaxWidth().padding(16.dp).height(560.dp)) {
|
||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||
Text(
|
||||
"Confirm you are human",
|
||||
style = MaterialTheme.typography.titleMedium,
|
||||
modifier = Modifier.weight(1f).padding(start = 16.dp),
|
||||
)
|
||||
IconButton(onClick = onDismiss) { Icon(Icons.Default.Close, contentDescription = "Cancel") }
|
||||
}
|
||||
AndroidView(
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
factory = { context ->
|
||||
val main = Handler(Looper.getMainLooper())
|
||||
var answered = false
|
||||
WebView(context).apply {
|
||||
settings.javaScriptEnabled = true
|
||||
settings.domStorageEnabled = true
|
||||
addJavascriptInterface(
|
||||
object {
|
||||
@JavascriptInterface
|
||||
fun post(message: String) {
|
||||
// Every message the page posts lands here,
|
||||
// most of them its own chatter.
|
||||
val token = verificationToken(message) ?: return
|
||||
main.post {
|
||||
if (!answered) {
|
||||
answered = true
|
||||
onToken(token)
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"pstrVerification",
|
||||
)
|
||||
webViewClient = object : WebViewClient() {
|
||||
override fun onPageStarted(view: WebView, url: String?, favicon: android.graphics.Bitmap?) {
|
||||
view.evaluateJavascript(VERIFICATION_BRIDGE, null)
|
||||
}
|
||||
|
||||
override fun onPageFinished(view: WebView, url: String?) {
|
||||
view.evaluateJavascript(VERIFICATION_BRIDGE, null)
|
||||
}
|
||||
|
||||
// The bridge above is reachable by whatever the
|
||||
// frame shows, so the frame stays on Proton.
|
||||
override fun shouldOverrideUrlLoading(view: WebView, request: WebResourceRequest) =
|
||||
!isProtonPage(request.url.scheme, request.url.host)
|
||||
}
|
||||
loadUrl(url)
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Security keys need WebAuthn, which the sign-in here does not speak. An account
|
||||
* with nothing else is refused before this form (`AccountStore::sign_in`).
|
||||
*/
|
||||
private const val SECURITY_KEY_NOTE =
|
||||
"Security keys are not supported here — use the code from your authenticator app."
|
||||
|
||||
/** Installed once per page, at start and again at finish in case the first ran too early. */
|
||||
private const val VERIFICATION_BRIDGE = """
|
||||
if (!window.__pstrVerification) {
|
||||
window.__pstrVerification = true;
|
||||
window.addEventListener('message', function (event) {
|
||||
try {
|
||||
pstrVerification.post(typeof event.data === 'string' ? event.data : JSON.stringify(event.data));
|
||||
} catch (e) {}
|
||||
});
|
||||
}
|
||||
"""
|
||||
|
||||
/** Whether a page is Proton's own, over HTTPS. */
|
||||
internal fun isProtonPage(scheme: String?, host: String?): Boolean =
|
||||
scheme == "https" && host != null && (host == "proton.me" || host.endsWith(".proton.me"))
|
||||
|
||||
/**
|
||||
* Walk the account's Drive and add a folder of it to the library.
|
||||
*
|
||||
* Full screen, because a Drive is deep and a dialog's height is a few rows of
|
||||
* it. Listings are asked for here rather than through the model: they are
|
||||
* this dialog's alone, and gone when it closes.
|
||||
*/
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
internal fun DriveBrowserDialog(
|
||||
actions: AccountActions,
|
||||
onDismiss: () -> Unit,
|
||||
) {
|
||||
val trail = remember { mutableStateListOf<Pair<String, DrivePlaceRecord>>() }
|
||||
var places by remember { mutableStateOf<List<DrivePlaceRecord>?>(null) }
|
||||
var entries by remember { mutableStateOf<List<DriveEntryRecord>?>(null) }
|
||||
var error by remember { mutableStateOf<String?>(null) }
|
||||
val current = trail.lastOrNull()?.second
|
||||
|
||||
LaunchedEffect(current) {
|
||||
error = null
|
||||
if (current == null) {
|
||||
if (places == null) {
|
||||
runCatching { NativeRuntime.engine().drivePlaces() }
|
||||
.onSuccess { places = it }
|
||||
.onFailure { error = it.message }
|
||||
}
|
||||
} else {
|
||||
entries = null
|
||||
runCatching { NativeRuntime.engine().driveFolder(current.volumeId, current.linkId) }
|
||||
.onSuccess { entries = it }
|
||||
.onFailure { error = it.message }
|
||||
}
|
||||
}
|
||||
|
||||
Dialog(onDismissRequest = onDismiss, properties = DialogProperties(usePlatformDefaultWidth = false)) {
|
||||
BackHandler(enabled = trail.isNotEmpty()) { trail.removeAt(trail.lastIndex) }
|
||||
Scaffold(
|
||||
topBar = {
|
||||
TopAppBar(
|
||||
title = {
|
||||
Text(
|
||||
trail.lastOrNull()?.first ?: "Your Drive",
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
)
|
||||
},
|
||||
navigationIcon = {
|
||||
IconButton(onClick = { if (trail.isEmpty()) onDismiss() else trail.removeAt(trail.lastIndex) }) {
|
||||
Icon(
|
||||
if (trail.isEmpty()) Icons.Default.Close else Icons.AutoMirrored.Filled.ArrowBack,
|
||||
contentDescription = if (trail.isEmpty()) "Close" else "Up a folder",
|
||||
)
|
||||
}
|
||||
},
|
||||
)
|
||||
},
|
||||
bottomBar = {
|
||||
// A folder already in the library is refused by the engine,
|
||||
// with a message saying so.
|
||||
val folder = current ?: return@Scaffold
|
||||
Row(Modifier.fillMaxWidth().padding(16.dp), horizontalArrangement = Arrangement.End) {
|
||||
AccentButton(
|
||||
onClick = {
|
||||
actions.addFolder(trail.last().first, folder.volumeId, folder.linkId)
|
||||
onDismiss()
|
||||
},
|
||||
) { Text("Add this folder") }
|
||||
}
|
||||
},
|
||||
) { padding ->
|
||||
Box(Modifier.fillMaxSize().padding(padding)) {
|
||||
val loading = error == null && (if (current == null) places == null else entries == null)
|
||||
when {
|
||||
error != null -> EmptyState("Could not list this", error.orEmpty())
|
||||
loading -> CircularProgressIndicator(Modifier.align(Alignment.Center))
|
||||
current == null -> LazyColumn(contentPadding = PaddingValues(vertical = 8.dp)) {
|
||||
items(places.orEmpty(), key = { "${it.volumeId}/${it.linkId}" }) { place ->
|
||||
DriveRow(
|
||||
icon = when (place.kind) {
|
||||
PlaceType.MY_FILES -> Icons.Default.Storage
|
||||
PlaceType.DEVICE -> Icons.Default.Computer
|
||||
PlaceType.SHARED_WITH_ME -> Icons.Default.FolderShared
|
||||
},
|
||||
name = place.name,
|
||||
detail = null,
|
||||
onClick = { trail.add(place.name to place) },
|
||||
)
|
||||
}
|
||||
}
|
||||
entries.orEmpty().isEmpty() -> EmptyState("Empty folder", "Nothing to add from here.")
|
||||
else -> LazyColumn(contentPadding = PaddingValues(vertical = 8.dp)) {
|
||||
items(entries.orEmpty(), key = { "${it.volumeId}/${it.linkId}" }) { entry ->
|
||||
DriveRow(
|
||||
icon = when {
|
||||
entry.isFolder -> Icons.Default.Folder
|
||||
entry.isVideo -> Icons.Default.Movie
|
||||
else -> Icons.Default.InsertDriveFile
|
||||
},
|
||||
name = entry.name,
|
||||
detail = entry.size?.let(::formatBytes),
|
||||
onClick = if (entry.isFolder) {
|
||||
{
|
||||
trail.add(
|
||||
entry.name to DrivePlaceRecord(
|
||||
PlaceType.MY_FILES,
|
||||
entry.name,
|
||||
entry.volumeId,
|
||||
entry.linkId,
|
||||
),
|
||||
)
|
||||
}
|
||||
} else {
|
||||
null
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun DriveRow(
|
||||
icon: androidx.compose.ui.graphics.vector.ImageVector,
|
||||
name: String,
|
||||
detail: String?,
|
||||
onClick: (() -> Unit)?,
|
||||
) {
|
||||
ListItem(
|
||||
headlineContent = { Text(name, maxLines = 2, overflow = TextOverflow.Ellipsis) },
|
||||
supportingContent = detail?.let { { Text(it) } },
|
||||
leadingContent = {
|
||||
Icon(
|
||||
icon,
|
||||
contentDescription = null,
|
||||
tint = if (onClick != null) {
|
||||
MaterialTheme.colorScheme.primary
|
||||
} else {
|
||||
MaterialTheme.colorScheme.onSurfaceVariant
|
||||
},
|
||||
)
|
||||
},
|
||||
colors = ListItemDefaults.colors(containerColor = Color.Transparent),
|
||||
modifier = if (onClick != null) Modifier.clickable(onClick = onClick) else Modifier,
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,996 @@
|
||||
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 io.narl.protonstream.settings.SettingsStore
|
||||
import io.narl.protonstream.ui.theme.AppearanceState
|
||||
import uniffi.pstr_android.ArrangementRecord
|
||||
import uniffi.pstr_android.BrowseFacet
|
||||
import uniffi.pstr_android.SearchExtrasRecord
|
||||
import uniffi.pstr_android.LibraryFilter
|
||||
import uniffi.pstr_android.LibrarySort
|
||||
import uniffi.pstr_android.ShareRecord
|
||||
import uniffi.pstr_android.TitleRecord
|
||||
import uniffi.pstr_android.OfflineRecord
|
||||
import uniffi.pstr_android.MatchSummary
|
||||
import uniffi.pstr_android.MetadataProvider
|
||||
import uniffi.pstr_android.MetadataSettingsRecord
|
||||
import uniffi.pstr_android.StorageUsageRecord
|
||||
import uniffi.pstr_android.EpisodeRecord
|
||||
import uniffi.pstr_android.WatchStateRecord
|
||||
import uniffi.pstr_android.AccountState
|
||||
import uniffi.pstr_android.SignInOutcome
|
||||
import uniffi.pstr_android.SyncRecord
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.isActive
|
||||
|
||||
data class AppUiState(
|
||||
val loading: Boolean = true,
|
||||
val refreshing: Boolean = false,
|
||||
/** A crawl is running, which the banner offers to stop. */
|
||||
val crawling: Boolean = false,
|
||||
val query: String = "",
|
||||
/**
|
||||
* The whole library, whatever is searched for: the grid narrows to the
|
||||
* search through [arrangement], and a title opened from a search, or from
|
||||
* an episode it found, stays one the app can show.
|
||||
*/
|
||||
val titles: List<TitleRecord> = emptyList(),
|
||||
/** What the search found besides titles; null with no search. */
|
||||
val searchExtras: SearchExtrasRecord? = null,
|
||||
/** The last few searches on this device, newest first. */
|
||||
val recentSearches: List<String> = emptyList(),
|
||||
/**
|
||||
* The grid: [titles] ordered, each franchise folded into one tile when
|
||||
* [grouped], and the shelves above it. Null until the bridge has answered,
|
||||
* when the grid falls back to [titles] as they are.
|
||||
*/
|
||||
val arrangement: ArrangementRecord? = null,
|
||||
val filter: LibraryFilter = LibraryFilter.ALL,
|
||||
val sort: LibrarySort = LibrarySort.NAME,
|
||||
val grouped: Boolean = true,
|
||||
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 storage: StorageUsageRecord = StorageUsageRecord(0uL, 0uL, 0uL, 0uL),
|
||||
val message: String? = null,
|
||||
/**
|
||||
* What is running in the background right now, said in a banner for as
|
||||
* long as it runs: an add, a crawl, a match, a sync. Without it a
|
||||
* minute-long crawl looked like a tap that did nothing.
|
||||
*/
|
||||
val activity: String? = null,
|
||||
/** What Undo on [message] puts back, when the message offers one. */
|
||||
val undo: List<WatchSnapshot>? = null,
|
||||
val account: AccountUiState = AccountUiState(),
|
||||
)
|
||||
|
||||
/** The Proton account: where a sign-in stands, and how sync is doing. */
|
||||
data class AccountUiState(
|
||||
/** Null until the bridge has said. */
|
||||
val state: AccountState? = null,
|
||||
/** A sign-in step is out and not answered yet. */
|
||||
val busy: Boolean = false,
|
||||
/** Why the last sign-in step was refused. */
|
||||
val error: String? = null,
|
||||
/** The CAPTCHA page Proton wants solved before it accepts the sign-in. */
|
||||
val verificationUrl: String? = null,
|
||||
/** When watch history last synced, in epoch milliseconds. */
|
||||
val syncedAt: Long? = null,
|
||||
/** Positions the last sync took from other devices. */
|
||||
val applied: Int = 0,
|
||||
/** Why the last sync failed, until one succeeds. */
|
||||
val syncError: String? = null,
|
||||
)
|
||||
|
||||
/**
|
||||
* One episode's watch state as it was before a change the viewer can undo.
|
||||
* Null [state] means it had never been played.
|
||||
*/
|
||||
data class WatchSnapshot(val shareId: String, val linkId: String, val state: WatchStateRecord?)
|
||||
|
||||
@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()
|
||||
/** Launch and coming back both ask; one crawl answers. */
|
||||
private var catchingUp = false
|
||||
|
||||
/**
|
||||
* Every banner line whose work is still running, oldest first. Touched on
|
||||
* the main thread only, as everything in [viewModelScope] is.
|
||||
*/
|
||||
private val activities = mutableListOf<Pair<Long, String>>()
|
||||
private var nextActivity = 0L
|
||||
|
||||
/** Crawls running now; main thread only, like [activities]. */
|
||||
private var crawlsRunning = 0
|
||||
|
||||
/** Set by [stopCrawl], so a queue of shares stops rather than going on to the next. */
|
||||
private var crawlStopped = false
|
||||
|
||||
init {
|
||||
if (!NativeRuntime.tlsReady) {
|
||||
mutableState.update {
|
||||
it.copy(message = "Certificate verification is unavailable; Proton requests will fail")
|
||||
}
|
||||
}
|
||||
// The arrangement first, so the first grid is drawn the way the viewer
|
||||
// left it rather than A–Z and then re-sorted under them.
|
||||
viewModelScope.launch {
|
||||
val (filter, sort, grouped) = withContext(Dispatchers.IO) {
|
||||
SettingsStore(appContext).run {
|
||||
Triple(
|
||||
libraryFilter?.let { name -> LibraryFilter.entries.firstOrNull { it.name == name } },
|
||||
librarySort?.let { name -> LibrarySort.entries.firstOrNull { it.name == name } },
|
||||
libraryGrouped,
|
||||
)
|
||||
}
|
||||
}
|
||||
mutableState.update { it.copy(filter = filter ?: it.filter, sort = sort ?: it.sort, grouped = grouped) }
|
||||
reload()
|
||||
}
|
||||
catchUp()
|
||||
viewModelScope.launch {
|
||||
val account = runCatching { withContext(Dispatchers.IO) { NativeRuntime.engine().accountState() } }
|
||||
.getOrDefault(AccountState.SignedOut)
|
||||
updateAccount { it.copy(state = account) }
|
||||
// While the app is alive, on top of after sign-in and whenever the
|
||||
// player closes. Cheap when nothing changed: one folder listing,
|
||||
// and no upload.
|
||||
while (isActive) {
|
||||
syncWatchHistory()
|
||||
// A sync WatchSyncWorker ran while the app was away may have
|
||||
// brought shares this one has not crawled.
|
||||
catchUp()
|
||||
delay(SYNC_INTERVAL_MS)
|
||||
}
|
||||
}
|
||||
viewModelScope.launch {
|
||||
searchQuery.debounce(300).distinctUntilChanged().collect { reloadLibrary(it) }
|
||||
}
|
||||
viewModelScope.launch {
|
||||
runCatching { withContext(Dispatchers.IO) { NativeRuntime.engine().recentSearches() } }
|
||||
.onSuccess { recent -> mutableState.update { it.copy(recentSearches = recent) } }
|
||||
}
|
||||
viewModelScope.launch {
|
||||
workManager.workInfosByTagFlow(DownloadCoordinator.TAG)
|
||||
.map { work -> work.filter { it.state.isFinished }.map { it.id }.toSet() }
|
||||
.distinctUntilChanged()
|
||||
.drop(1)
|
||||
.collect { reload() }
|
||||
}
|
||||
}
|
||||
|
||||
/** Sort and fold the library, and keep both for the next launch. */
|
||||
fun arrange(sort: LibrarySort, grouped: Boolean) {
|
||||
mutableState.update { it.copy(sort = sort, grouped = grouped) }
|
||||
viewModelScope.launch {
|
||||
withContext(Dispatchers.IO) {
|
||||
SettingsStore(appContext).apply {
|
||||
librarySort = sort.name
|
||||
libraryGrouped = grouped
|
||||
}
|
||||
}
|
||||
reloadLibrary(mutableState.value.query)
|
||||
}
|
||||
}
|
||||
|
||||
/** Narrow the grid to some titles, and keep the choice for the next launch. */
|
||||
fun filter(filter: LibraryFilter) {
|
||||
mutableState.update { it.copy(filter = filter) }
|
||||
viewModelScope.launch {
|
||||
withContext(Dispatchers.IO) { SettingsStore(appContext).libraryFilter = filter.name }
|
||||
reloadLibrary(mutableState.value.query)
|
||||
}
|
||||
}
|
||||
|
||||
fun search(query: String) {
|
||||
mutableState.update { it.copy(query = query) }
|
||||
searchQuery.value = query
|
||||
}
|
||||
|
||||
/**
|
||||
* Keep the search being shown as one to offer again: called when a viewer
|
||||
* opens something it found. Nothing for an empty one.
|
||||
*/
|
||||
fun rememberSearch() {
|
||||
val query = mutableState.value.query.takeIf(String::isNotBlank) ?: return
|
||||
viewModelScope.launch {
|
||||
runCatching {
|
||||
withContext(Dispatchers.IO) {
|
||||
NativeRuntime.engine().run {
|
||||
rememberSearch(query)
|
||||
recentSearches()
|
||||
}
|
||||
}
|
||||
}.onSuccess { recent -> mutableState.update { it.copy(recentSearches = recent) } }
|
||||
}
|
||||
}
|
||||
|
||||
fun forgetSearches() {
|
||||
viewModelScope.launch {
|
||||
runCatching { withContext(Dispatchers.IO) { NativeRuntime.engine().forgetSearches() } }
|
||||
.onSuccess { mutableState.update { it.copy(recentSearches = emptyList()) } }
|
||||
.onFailure(::reportError)
|
||||
}
|
||||
}
|
||||
|
||||
fun refresh() {
|
||||
viewModelScope.launch {
|
||||
busy("Refreshing the library…") {
|
||||
mutableState.update { it.copy(message = null) }
|
||||
runCatching { crawlShares(null) }
|
||||
.onFailure { error -> mutableState.update { it.copy(message = error.message) } }
|
||||
}
|
||||
reloadNow()
|
||||
matchAfterCrawl()
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Look up whatever a crawl brought that has no answer yet: a new show, or
|
||||
* a new episode its stored guide cannot name. When everything already has
|
||||
* one the bridge asks nothing, so this is cheap after every crawl.
|
||||
*/
|
||||
private suspend fun matchAfterCrawl() {
|
||||
val settings = mutableState.value.metadataSettings
|
||||
if (settings.enabled && settings.ready) match(force = false, quiet = true)
|
||||
}
|
||||
|
||||
/**
|
||||
* Say [what] in the activity banner while [work] runs.
|
||||
*
|
||||
* The banner shows the newest line still running. Work overlaps — a sync
|
||||
* starts during a crawl and ends after it — so each line is removed by
|
||||
* itself rather than by putting back whatever was showing before, which
|
||||
* once brought back a finished crawl's line and spun it forever.
|
||||
*/
|
||||
private suspend fun busy(what: String, work: suspend () -> Unit) {
|
||||
val token = nextActivity++
|
||||
activities += token to what
|
||||
showActivity()
|
||||
try {
|
||||
work()
|
||||
} finally {
|
||||
activities.removeAll { it.first == token }
|
||||
showActivity()
|
||||
}
|
||||
}
|
||||
|
||||
/** Crawl one share, or all with null, with the banner offering Stop meanwhile. */
|
||||
private suspend fun crawlShares(id: String?) {
|
||||
crawlsRunning++
|
||||
mutableState.update { it.copy(crawling = true) }
|
||||
try {
|
||||
withContext(Dispatchers.IO) { NativeRuntime.engine().crawl(id) }
|
||||
} finally {
|
||||
crawlsRunning--
|
||||
mutableState.update { it.copy(crawling = crawlsRunning > 0) }
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Stop the crawls running now. Shares already read stay in the library;
|
||||
* the one being read keeps what it had.
|
||||
*/
|
||||
fun stopCrawl() {
|
||||
crawlStopped = true
|
||||
viewModelScope.launch(Dispatchers.IO) { NativeRuntime.engine().stopCrawl() }
|
||||
}
|
||||
|
||||
private fun showActivity() {
|
||||
val newest = activities.lastOrNull()?.second
|
||||
mutableState.update { it.copy(activity = newest, refreshing = newest != null) }
|
||||
}
|
||||
|
||||
/**
|
||||
* Recrawl one share rather than the whole library.
|
||||
*
|
||||
* A viewer who has just added files to one link should not have to pay for
|
||||
* a walk of every other one — and a share whose link has expired makes a
|
||||
* whole-library refresh fail, which used to leave no way to refresh the
|
||||
* shares that still work.
|
||||
*/
|
||||
fun refreshShare(id: String) {
|
||||
viewModelScope.launch {
|
||||
val name = mutableState.value.shares.firstOrNull { it.id == id }?.name ?: "the share"
|
||||
busy("Reading $name…") {
|
||||
mutableState.update { it.copy(message = null) }
|
||||
runCatching { crawlShares(id) }
|
||||
.onFailure { error -> mutableState.update { it.copy(message = error.message) } }
|
||||
}
|
||||
reloadNow()
|
||||
matchAfterCrawl()
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Run enrichment again.
|
||||
*
|
||||
* With [force] every title is looked up afresh, including ones that already
|
||||
* matched — the way out of a library where a provider's early answers were
|
||||
* wrong, or where a match was made against a provider that has since been
|
||||
* changed.
|
||||
*/
|
||||
fun matchTitles(force: Boolean) {
|
||||
viewModelScope.launch { match(force) }
|
||||
}
|
||||
|
||||
/**
|
||||
* With [quiet], a run nobody asked for says nothing when it found nothing
|
||||
* to do.
|
||||
*/
|
||||
private suspend fun match(force: Boolean, quiet: Boolean = false) {
|
||||
busy("Looking up titles…") {
|
||||
runCatching { withContext(Dispatchers.IO) { NativeRuntime.engine().matchTitles(force) } }
|
||||
.onSuccess { summary ->
|
||||
if (!quiet || !summary.isEmpty()) {
|
||||
mutableState.update { it.copy(message = describe(summary)) }
|
||||
}
|
||||
}
|
||||
.onFailure { error -> mutableState.update { it.copy(message = error.message) } }
|
||||
}
|
||||
reloadNow()
|
||||
}
|
||||
|
||||
private fun MatchSummary.isEmpty() = matched == 0u && unmatched == 0u && failed == 0u && episodes == 0u
|
||||
|
||||
/**
|
||||
* Add a link, and return why it was refused, if it was.
|
||||
*
|
||||
* The link is opened before anything is stored, so the form can stay up
|
||||
* with the reason under it and a retry is a retry — not "already in the
|
||||
* library" for a share that never opened. Only the new share is crawled:
|
||||
* another share that fails to open must not make this one look broken.
|
||||
*/
|
||||
suspend fun addShare(name: String, url: String, password: String?): String? {
|
||||
val share = runCatching {
|
||||
withContext(Dispatchers.IO) {
|
||||
NativeRuntime.engine().addShare(name, url, password?.takeIf(String::isNotBlank))
|
||||
}
|
||||
}.getOrElse { return it.message ?: "That link could not be added" }
|
||||
reload()
|
||||
viewModelScope.launch { crawlAdded(listOf(share.id), announce = true) }
|
||||
return null
|
||||
}
|
||||
|
||||
/**
|
||||
* Crawl shares that are new here — added on this device or by sync — then
|
||||
* match them when enrichment is on.
|
||||
*/
|
||||
private suspend fun crawlAdded(ids: List<String>, announce: Boolean) {
|
||||
if (ids.isEmpty()) return
|
||||
val before = mutableState.value.titles.map { it.key }.toSet()
|
||||
val failed = mutableListOf<String>()
|
||||
crawlStopped = false
|
||||
for (id in ids) {
|
||||
if (crawlStopped) break
|
||||
val name = mutableState.value.shares.firstOrNull { it.id == id }?.name ?: "the new share"
|
||||
busy("Reading $name…") {
|
||||
runCatching { crawlShares(id) }
|
||||
.onFailure { failed += "$name: ${it.message}" }
|
||||
}
|
||||
}
|
||||
reloadNow()
|
||||
// A catch-up is the app's doing, not the viewer's: a failure goes on
|
||||
// the share's row rather than in their face, and only news is said.
|
||||
if (failed.isNotEmpty() && announce) {
|
||||
mutableState.update { it.copy(message = failed.joinToString("\n")) }
|
||||
return
|
||||
}
|
||||
matchAfterCrawl()
|
||||
if (announce) {
|
||||
val added = (mutableState.value.titles.map { it.key }.toSet() - before).size
|
||||
mutableState.update { it.copy(message = if (added == 1) "1 title added" else "$added titles added") }
|
||||
} else {
|
||||
val files = mutableState.value.shares.filter { it.id in ids }.sumOf { it.crawlAdded ?: 0uL }
|
||||
if (files > 0uL) {
|
||||
mutableState.update { it.copy(message = if (files == 1uL) "1 new file" else "$files new files") }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Crawl shares due a look: ones the catalog has nothing of — brought by a
|
||||
* sync that ran in [io.narl.protonstream.sync.WatchSyncWorker], or an add
|
||||
* whose crawl never finished — and ones not crawled for six hours, so new
|
||||
* episodes turn up without a pull to refresh.
|
||||
*/
|
||||
fun catchUp() {
|
||||
if (catchingUp) return
|
||||
catchingUp = true
|
||||
viewModelScope.launch {
|
||||
try {
|
||||
val ids = runCatching { withContext(Dispatchers.IO) { NativeRuntime.engine().uncrawledShares() } }
|
||||
.getOrDefault(emptyList())
|
||||
crawlAdded(ids, announce = false)
|
||||
} finally {
|
||||
catchingUp = false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Re-supply the link behind a share whose stored secret cannot be read.
|
||||
*
|
||||
* The Keystore key is invalidated by a lockscreen change or a device
|
||||
* restore, and after that every open of every share fails — including the
|
||||
* read inside "remove this share", so the obvious way out is closed too.
|
||||
* Re-entering the link rewrites the secret and leaves the catalog and the
|
||||
* offline files alone.
|
||||
*/
|
||||
fun repairShare(id: String, url: String, password: String?) {
|
||||
viewModelScope.launch {
|
||||
runCatching {
|
||||
withContext(Dispatchers.IO) {
|
||||
val engine = NativeRuntime.engine()
|
||||
engine.repairShare(id, url, password?.takeIf(String::isNotBlank))
|
||||
engine.crawl(id)
|
||||
}
|
||||
}.onFailure(::reportError)
|
||||
reloadNow()
|
||||
matchAfterCrawl()
|
||||
}
|
||||
}
|
||||
|
||||
/** Call a share something else. The library, history and downloads stay. */
|
||||
fun renameShare(id: String, name: String) {
|
||||
viewModelScope.launch {
|
||||
runCatching { withContext(Dispatchers.IO) { NativeRuntime.engine().renameShare(id, name) } }
|
||||
.onFailure(::reportError)
|
||||
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, undo = null) }
|
||||
|
||||
private fun updateAccount(change: (AccountUiState) -> AccountUiState) =
|
||||
mutableState.update { it.copy(account = change(it.account)) }
|
||||
|
||||
/**
|
||||
* Start signing in, or start again with [verificationToken] once the
|
||||
* CAPTCHA from a first try is solved.
|
||||
*/
|
||||
fun signIn(username: String, password: String, verificationToken: String? = null) =
|
||||
signInStep { engine -> engine.signIn(username, password, verificationToken) }
|
||||
|
||||
fun submitSecondFactor(code: String) = signInStep { engine -> engine.submitSecondFactor(code) }
|
||||
|
||||
fun submitMailboxPassword(password: String) =
|
||||
signInStep(keepStepOnFailure = true) { engine -> engine.submitMailboxPassword(password) }
|
||||
|
||||
fun cancelSignIn() {
|
||||
updateAccount { AccountUiState(state = AccountState.SignedOut) }
|
||||
viewModelScope.launch { runCatching { NativeRuntime.engine().cancelSignIn() } }
|
||||
}
|
||||
|
||||
/** The CAPTCHA page closed without a token. */
|
||||
fun dismissVerification() = updateAccount { it.copy(verificationUrl = null, busy = false) }
|
||||
|
||||
private fun signInStep(
|
||||
keepStepOnFailure: Boolean = false,
|
||||
step: suspend (uniffi.pstr_android.AndroidEngine) -> SignInOutcome,
|
||||
) {
|
||||
updateAccount { it.copy(busy = true, error = null, verificationUrl = null) }
|
||||
viewModelScope.launch {
|
||||
runCatching { withContext(Dispatchers.IO) { step(NativeRuntime.engine()) } }
|
||||
.onSuccess { outcome ->
|
||||
when (outcome) {
|
||||
is SignInOutcome.Done -> {
|
||||
updateAccount {
|
||||
AccountUiState(state = AccountState.SignedIn(outcome.username))
|
||||
}
|
||||
mutableState.update { it.copy(message = "Signed in as ${outcome.username}") }
|
||||
syncWatchHistory()
|
||||
reload()
|
||||
}
|
||||
SignInOutcome.SecondFactor ->
|
||||
updateAccount { it.copy(state = AccountState.SecondFactor, busy = false) }
|
||||
SignInOutcome.MailboxPassword ->
|
||||
updateAccount { it.copy(state = AccountState.MailboxPassword, busy = false) }
|
||||
is SignInOutcome.HumanVerification ->
|
||||
updateAccount { it.copy(verificationUrl = outcome.url) }
|
||||
}
|
||||
}
|
||||
.onFailure { error ->
|
||||
updateAccount {
|
||||
it.copy(
|
||||
busy = false,
|
||||
error = error.message ?: "Sign-in failed",
|
||||
// A refused code spends the half-made session.
|
||||
state = if (keepStepOnFailure) it.state else AccountState.SignedOut,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun signOut() {
|
||||
viewModelScope.launch {
|
||||
runCatching { withContext(Dispatchers.IO) { NativeRuntime.engine().signOut() } }
|
||||
.onFailure(::reportError)
|
||||
updateAccount { AccountUiState(state = AccountState.SignedOut) }
|
||||
reload()
|
||||
}
|
||||
}
|
||||
|
||||
/** Sync now, from a tap: says so when it is done. */
|
||||
fun syncNow() {
|
||||
viewModelScope.launch {
|
||||
if (syncWatchHistory()) {
|
||||
val applied = mutableState.value.account.applied
|
||||
mutableState.update {
|
||||
it.copy(
|
||||
message = if (applied > 0) {
|
||||
"Watch history synced, $applied from other devices"
|
||||
} else {
|
||||
"Watch history is up to date"
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Pull other devices' positions in and push this one's out. Quiet: it runs
|
||||
* on a timer, and a failure is shown on the account card rather than as a
|
||||
* snackbar every five minutes of an offline evening. Returns whether it
|
||||
* synced.
|
||||
*/
|
||||
private suspend fun syncWatchHistory(): Boolean {
|
||||
if (mutableState.value.account.state !is AccountState.SignedIn) return false
|
||||
return runCatching { withContext(Dispatchers.IO) { NativeRuntime.engine().syncWatchHistory() } }
|
||||
.fold(
|
||||
onSuccess = { report ->
|
||||
if (report == null) return false
|
||||
updateAccount {
|
||||
it.copy(
|
||||
syncedAt = System.currentTimeMillis(),
|
||||
applied = report.applied.toInt(),
|
||||
syncError = null,
|
||||
)
|
||||
}
|
||||
takeSynced(report)
|
||||
true
|
||||
},
|
||||
onFailure = { error ->
|
||||
val state = runCatching { NativeRuntime.engine().accountState() }.getOrNull()
|
||||
updateAccount { it.copy(state = state ?: it.state, syncError = error.message) }
|
||||
// Signed out by the engine: the session ended, and that is
|
||||
// worth interrupting for, unlike a dropped connection.
|
||||
if (state == AccountState.SignedOut) reportError(error)
|
||||
false
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* What another device changed: its shares crawled or dropped here, its
|
||||
* settings repainted.
|
||||
*/
|
||||
private suspend fun takeSynced(report: SyncRecord) {
|
||||
if (report.sharesRemoved.isNotEmpty()) {
|
||||
withContext(Dispatchers.IO) {
|
||||
val downloads = DownloadStateStore(appContext)
|
||||
report.sharesRemoved.forEach { id ->
|
||||
workManager.cancelAllWorkByTag(DownloadCoordinator.shareTag(id)).result.get()
|
||||
downloads.removeShare(id)
|
||||
}
|
||||
}
|
||||
}
|
||||
if (report.settingsChanged) AppearanceState.reload()
|
||||
if (report.applied > 0u || report.titles > 0u || report.settingsChanged || report.listChanged ||
|
||||
report.sharesRemoved.isNotEmpty()
|
||||
) {
|
||||
reloadNow()
|
||||
}
|
||||
if (report.sharesAdded.isNotEmpty()) {
|
||||
crawlAdded(report.sharesAdded, announce = false)
|
||||
val count = report.sharesAdded.size
|
||||
mutableState.update {
|
||||
it.copy(message = if (count == 1) "A share arrived from another device" else "$count shares arrived from other devices")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** After the player closes: where it stopped is what another device wants next. */
|
||||
fun playerClosed() {
|
||||
reload()
|
||||
viewModelScope.launch { syncWatchHistory() }
|
||||
}
|
||||
|
||||
fun addAccountFolder(name: String, volumeId: String, linkId: String) {
|
||||
viewModelScope.launch {
|
||||
runCatching {
|
||||
withContext(Dispatchers.IO) { NativeRuntime.engine().addAccountFolder(name, volumeId, linkId) }
|
||||
}.onSuccess { share ->
|
||||
reload()
|
||||
crawlAdded(listOf(share.id), announce = true)
|
||||
}.onFailure(::reportError)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Every episode of a title watched or unwatched, with an Undo: a whole
|
||||
* title's positions are what this throws away, and a mis-tap on a tile's
|
||||
* menu should not cost them.
|
||||
*/
|
||||
/** Put a title on My List, or take it off. */
|
||||
fun setOnList(title: TitleRecord, on: Boolean) {
|
||||
viewModelScope.launch {
|
||||
runCatching {
|
||||
withContext(Dispatchers.IO) { NativeRuntime.engine().setOnList(title.key, on) }
|
||||
}.onFailure(::reportError)
|
||||
reload()
|
||||
}
|
||||
}
|
||||
|
||||
/** The keys of the titles most like [key], best first; none if the engine cannot say. */
|
||||
suspend fun moreLike(key: String): List<String> =
|
||||
runCatching { withContext(Dispatchers.IO) { NativeRuntime.engine().moreLike(key) } }.getOrDefault(emptyList())
|
||||
|
||||
/** The keys of every title [facet] stands for, in library order. */
|
||||
suspend fun browse(facet: BrowseFacet): List<String> =
|
||||
runCatching { withContext(Dispatchers.IO) { NativeRuntime.engine().browse(facet) } }
|
||||
.onFailure(::reportError)
|
||||
.getOrDefault(emptyList())
|
||||
|
||||
fun setTitleWatched(title: TitleRecord, watched: Boolean) =
|
||||
markEpisodes(title.seasons.flatMap { it.episodes }, watched, title.displayName)
|
||||
|
||||
/**
|
||||
* Some episodes seen or unseen — a season, or a whole title — with an
|
||||
* Undo; [what] names them in the message.
|
||||
*/
|
||||
fun markEpisodes(episodes: List<EpisodeRecord>, watched: Boolean, what: String) {
|
||||
changeWatch(
|
||||
episodes.filter { it.watched != watched },
|
||||
"$what marked ${if (watched) "watched" else "unwatched"}",
|
||||
) { engine, episode, before ->
|
||||
val duration = before?.durationSecs
|
||||
engine.saveWatchState(
|
||||
episode.shareId,
|
||||
episode.linkId,
|
||||
if (watched) duration ?: 0.0 else 0.0,
|
||||
duration,
|
||||
watched,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Off Continue watching by forgetting where the episode stopped — what the
|
||||
* desktop does — with an Undo that puts the position back.
|
||||
*/
|
||||
fun forgetPosition(title: TitleRecord, episode: EpisodeRecord) {
|
||||
changeWatch(
|
||||
listOf(episode),
|
||||
"${title.displayName} removed from Continue watching",
|
||||
) { engine, target, before ->
|
||||
engine.saveWatchState(target.shareId, target.linkId, 0.0, before?.durationSecs, false)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Off the history page: the position and the watched mark both go, which
|
||||
* is "never played", with an Undo.
|
||||
*/
|
||||
fun removeFromHistory(title: TitleRecord, episode: EpisodeRecord) {
|
||||
changeWatch(
|
||||
listOf(episode),
|
||||
"${episode.label} of ${title.displayName} removed from history",
|
||||
) { engine, target, before ->
|
||||
engine.saveWatchState(target.shareId, target.linkId, 0.0, before?.durationSecs, false)
|
||||
}
|
||||
}
|
||||
|
||||
/** Put back what the last undoable change replaced. */
|
||||
fun undo() {
|
||||
val snapshots = mutableState.value.undo ?: return
|
||||
mutableState.update { it.copy(message = null, undo = null) }
|
||||
viewModelScope.launch {
|
||||
runCatching {
|
||||
withContext(Dispatchers.IO) {
|
||||
val engine = NativeRuntime.engine()
|
||||
snapshots.forEach { snapshot ->
|
||||
engine.restoreWatchState(snapshot.shareId, snapshot.linkId, snapshot.state)
|
||||
}
|
||||
}
|
||||
}.onFailure(::reportError)
|
||||
reload()
|
||||
}
|
||||
}
|
||||
|
||||
private fun changeWatch(
|
||||
episodes: List<EpisodeRecord>,
|
||||
message: String,
|
||||
change: (uniffi.pstr_android.AndroidEngine, EpisodeRecord, WatchStateRecord?) -> Unit,
|
||||
) {
|
||||
viewModelScope.launch {
|
||||
runCatching {
|
||||
withContext(Dispatchers.IO) {
|
||||
val engine = NativeRuntime.engine()
|
||||
episodes.map { episode ->
|
||||
val before = engine.watchState(episode.shareId, episode.linkId)
|
||||
change(engine, episode, before)
|
||||
WatchSnapshot(episode.shareId, episode.linkId, before)
|
||||
}
|
||||
}
|
||||
}.onSuccess { snapshots ->
|
||||
mutableState.update { it.copy(message = message, undo = snapshots) }
|
||||
}.onFailure(::reportError)
|
||||
reload()
|
||||
}
|
||||
}
|
||||
|
||||
fun reportError(error: Throwable) {
|
||||
mutableState.update { it.copy(message = error.message ?: "Unexpected error") }
|
||||
}
|
||||
|
||||
/**
|
||||
* Store the enrichment settings, and return why not, if they were not.
|
||||
*
|
||||
* The page shows the new settings as soon as they are stored; the match
|
||||
* run that follows can take minutes and says so in the activity banner.
|
||||
*/
|
||||
suspend fun saveMetadataSettings(enabled: Boolean, provider: MetadataProvider, language: String, apiKey: String): String? {
|
||||
val stored = runCatching {
|
||||
withContext(Dispatchers.IO) {
|
||||
val engine = NativeRuntime.engine()
|
||||
// TMDB's is the only key: the provider's with TMDB chosen, and
|
||||
// film art's with AniList.
|
||||
if (apiKey.isNotBlank()) {
|
||||
engine.setMetadataApiKey(MetadataProvider.TMDB, apiKey)
|
||||
}
|
||||
engine.setMetadataSettings(MetadataSettingsRecord(enabled, provider, language, true))
|
||||
engine.metadataSettings()
|
||||
}
|
||||
}.getOrElse { return it.message ?: "The settings could not be saved" }
|
||||
mutableState.update { it.copy(metadataSettings = stored) }
|
||||
reloadNow()
|
||||
if (enabled) viewModelScope.launch { match(force = false) }
|
||||
return null
|
||||
}
|
||||
|
||||
fun reloadAfterMetadataChange() = reload()
|
||||
|
||||
/**
|
||||
* What an enrichment pass did, in one line.
|
||||
*
|
||||
* Failures are reported even when most titles matched: a run that quietly
|
||||
* dropped a third of the library is the case this counting exists for.
|
||||
*/
|
||||
private fun describe(summary: MatchSummary): String {
|
||||
if (summary.matched == 0u && summary.unmatched == 0u && summary.failed == 0u) {
|
||||
return "Everything is already matched"
|
||||
}
|
||||
val parts = mutableListOf("${summary.matched} matched")
|
||||
if (summary.unmatched > 0u) parts += "${summary.unmatched} not found"
|
||||
if (summary.episodes > 0u) parts += "${summary.episodes} episodes named"
|
||||
if (summary.failed > 0u) parts += "${summary.failed} failed"
|
||||
return parts.joinToString(", ")
|
||||
}
|
||||
|
||||
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()
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Persist where an episode was left.
|
||||
*
|
||||
* The player calls this while it is still up and once more as it leaves, so
|
||||
* it runs on the ViewModel's scope rather than the screen's — a save issued
|
||||
* on the way out must not be cancelled by the screen going away.
|
||||
*/
|
||||
fun saveProgress(episode: EpisodeRecord, position: Double, duration: Double, watched: Boolean) {
|
||||
viewModelScope.launch {
|
||||
runCatching {
|
||||
withContext(Dispatchers.IO) {
|
||||
NativeRuntime.engine().saveWatchState(
|
||||
episode.shareId,
|
||||
episode.linkId,
|
||||
position.coerceIn(0.0, duration),
|
||||
duration,
|
||||
watched,
|
||||
)
|
||||
}
|
||||
}.onFailure(::reportError)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Mark an episode seen, or unseen, without playing it.
|
||||
*
|
||||
* Unwatching rewinds, matching the desktop client: an episode marked unseen
|
||||
* that kept its position would come straight back as "resume at 19:04",
|
||||
* which is not what the viewer asked for. Marking one seen puts the position
|
||||
* at its duration where one is known, so the progress bar agrees with the
|
||||
* tick.
|
||||
*/
|
||||
/** One episode's tick, with the same Undo a season's has. */
|
||||
fun setWatched(episode: EpisodeRecord, watched: Boolean) = markEpisodes(listOf(episode), watched, episode.label)
|
||||
|
||||
fun removeAllOffline() {
|
||||
viewModelScope.launch {
|
||||
runCatching {
|
||||
withContext(Dispatchers.IO) {
|
||||
workManager.cancelAllWorkByTag(DownloadCoordinator.TAG).result.get()
|
||||
NativeRuntime.engine().removeAllOffline()
|
||||
DownloadStateStore(appContext).clear()
|
||||
}
|
||||
}.onFailure(::reportError)
|
||||
reload()
|
||||
}
|
||||
}
|
||||
|
||||
fun clearBlockCache() {
|
||||
viewModelScope.launch {
|
||||
runCatching {
|
||||
withContext(Dispatchers.IO) { NativeRuntime.engine().clearBlockCache() }
|
||||
}.onSuccess { reclaimed ->
|
||||
mutableState.update { it.copy(message = "Reclaimed ${formatBytes(reclaimed)} of cache") }
|
||||
}.onFailure(::reportError)
|
||||
reload()
|
||||
}
|
||||
}
|
||||
|
||||
private fun reload() {
|
||||
viewModelScope.launch { reloadNow() }
|
||||
}
|
||||
|
||||
/** [reload], for a caller that goes on to read what it loaded. */
|
||||
private suspend fun reloadNow() {
|
||||
runCatching {
|
||||
withContext(Dispatchers.IO) {
|
||||
val engine = NativeRuntime.engine()
|
||||
// Here rather than inside `library()`, which is read-only
|
||||
// and cached: a full reload is the one place that has
|
||||
// already paid for a walk of the offline files.
|
||||
engine.pruneOfflineFiles()
|
||||
val current = mutableState.value
|
||||
val query = current.query.takeIf(String::isNotBlank)
|
||||
Reloaded(
|
||||
engine.shares(),
|
||||
engine.library(null),
|
||||
engine.arrangement(query, current.filter, current.sort, current.grouped),
|
||||
engine.offlineFiles(),
|
||||
engine.metadataSettings(),
|
||||
engine.storageUsage(),
|
||||
)
|
||||
}
|
||||
}.onSuccess { result ->
|
||||
mutableState.update { it.copy(
|
||||
loading = false,
|
||||
shares = result.shares,
|
||||
titles = result.titles,
|
||||
arrangement = result.arrangement,
|
||||
offline = result.offline,
|
||||
metadataSettings = result.metadataSettings,
|
||||
storage = result.storage,
|
||||
) }
|
||||
}
|
||||
.onFailure { error ->
|
||||
mutableState.update { it.copy(loading = false, message = error.message) }
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun reloadLibrary(query: String) {
|
||||
val current = mutableState.value
|
||||
runCatching {
|
||||
withContext(Dispatchers.IO) {
|
||||
val engine = NativeRuntime.engine()
|
||||
val search = query.takeIf(String::isNotBlank)
|
||||
Triple(
|
||||
engine.library(null),
|
||||
engine.arrangement(search, current.filter, current.sort, current.grouped),
|
||||
search?.let(engine::searchExtras),
|
||||
)
|
||||
}
|
||||
}.onSuccess { (titles, arrangement, extras) ->
|
||||
mutableState.update { it.copy(titles = titles, arrangement = arrangement, searchExtras = extras) }
|
||||
}.onFailure { error -> mutableState.update { it.copy(message = error.message) } }
|
||||
}
|
||||
|
||||
private companion object {
|
||||
const val SYNC_INTERVAL_MS = 5 * 60 * 1000L
|
||||
}
|
||||
|
||||
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 arrangement: ArrangementRecord,
|
||||
val offline: List<OfflineRecord>,
|
||||
val metadataSettings: MetadataSettingsRecord,
|
||||
val storage: StorageUsageRecord,
|
||||
)
|
||||
|
||||
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,358 @@
|
||||
package io.narl.protonstream.ui
|
||||
|
||||
import android.net.ConnectivityManager
|
||||
import androidx.compose.animation.AnimatedVisibilityScope
|
||||
import androidx.compose.animation.ExperimentalSharedTransitionApi
|
||||
import androidx.compose.animation.SharedTransitionScope
|
||||
import androidx.compose.foundation.border
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.shape.CircleShape
|
||||
import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.PaddingValues
|
||||
import androidx.compose.foundation.layout.RowScope
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.Add
|
||||
import androidx.compose.material.icons.filled.Check
|
||||
import androidx.compose.material3.FilledTonalIconButton
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.ui.semantics.contentDescription
|
||||
import androidx.compose.ui.semantics.semantics
|
||||
import androidx.compose.material3.AlertDialog
|
||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TopAppBar
|
||||
import androidx.compose.material3.TopAppBarColors
|
||||
import androidx.compose.material3.TopAppBarDefaults
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.runtime.staticCompositionLocalOf
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.input.nestedscroll.nestedScroll
|
||||
import androidx.compose.ui.text.style.TextAlign
|
||||
import androidx.compose.ui.unit.dp
|
||||
import io.narl.protonstream.download.DownloadCoordinator
|
||||
import io.narl.protonstream.download.RetainedDownload
|
||||
import io.narl.protonstream.settings.SettingsStore
|
||||
import io.narl.protonstream.ui.theme.AccentButton
|
||||
import io.narl.protonstream.ui.theme.TonalButton
|
||||
import uniffi.pstr_android.EpisodeRecord
|
||||
|
||||
/**
|
||||
* A tab's page: its own name in an app bar, over whatever the page holds.
|
||||
*
|
||||
* One bar for the whole app said "proton-stream" on every page, which is a band
|
||||
* of height spent naming the app the viewer is already in. The bar draws under
|
||||
* the status bar itself, which is why the shell hands pages no top padding;
|
||||
* [content] gets the rest of [padding] (the navigation bar and the mini
|
||||
* transport) to apply as it scrolls.
|
||||
*/
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
internal fun TabPage(
|
||||
title: String,
|
||||
padding: PaddingValues,
|
||||
navigationIcon: @Composable () -> Unit = {},
|
||||
actions: @Composable RowScope.() -> Unit = {},
|
||||
content: @Composable (PaddingValues) -> Unit,
|
||||
) {
|
||||
// Pinned: the bar stays, and takes the container tint once the page has
|
||||
// scrolled under it, so the edge between the two is still visible.
|
||||
val scroll = TopAppBarDefaults.pinnedScrollBehavior()
|
||||
Column(Modifier.fillMaxSize().nestedScroll(scroll.nestedScrollConnection)) {
|
||||
TopAppBar(
|
||||
title = { Text(title) },
|
||||
navigationIcon = navigationIcon,
|
||||
actions = actions,
|
||||
colors = pageBarColors(),
|
||||
scrollBehavior = scroll,
|
||||
)
|
||||
Box(Modifier.weight(1f)) { content(padding) }
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The page's own background until something scrolls under the bar, then the
|
||||
* container tint. A bar in `surface` from the start read as a band across the
|
||||
* top of a page drawn in `background`.
|
||||
*/
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
internal fun pageBarColors(): TopAppBarColors = TopAppBarDefaults.topAppBarColors(
|
||||
containerColor = MaterialTheme.colorScheme.background,
|
||||
scrolledContainerColor = MaterialTheme.colorScheme.surfaceContainer,
|
||||
)
|
||||
|
||||
/**
|
||||
* The shared-element scopes the library-to-title switch runs in, or null where
|
||||
* there is none — the two-pane layout, a screenshot, a preview. Locals rather
|
||||
* than parameters, because the poster that animates is three calls deep in the
|
||||
* grid and nothing between needs to know.
|
||||
*/
|
||||
@OptIn(ExperimentalSharedTransitionApi::class)
|
||||
internal val LocalSharedTransition = staticCompositionLocalOf<SharedTransitionScope?> { null }
|
||||
internal val LocalPaneVisibility = staticCompositionLocalOf<AnimatedVisibilityScope?> { null }
|
||||
|
||||
/**
|
||||
* A title's art as one element across the switch: the poster tapped in the
|
||||
* grid grows into the backdrop of the page it opens, and shrinks back on the
|
||||
* way out, so the page is visibly *that* title's rather than a new screen.
|
||||
*/
|
||||
@OptIn(ExperimentalSharedTransitionApi::class)
|
||||
@Composable
|
||||
internal fun Modifier.sharedArt(key: String): Modifier {
|
||||
val transition = LocalSharedTransition.current ?: return this
|
||||
val visibility = LocalPaneVisibility.current ?: return this
|
||||
return with(transition) {
|
||||
this@sharedArt.sharedBounds(rememberSharedContentState("art/$key"), visibility)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
internal fun EmptyState(title: String, body: String, action: Pair<String, () -> Unit>? = null) {
|
||||
Box(Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
|
||||
Column(Modifier.padding(horizontal = 32.dp), horizontalAlignment = Alignment.CenterHorizontally) {
|
||||
Text(title, style = MaterialTheme.typography.titleMedium, textAlign = TextAlign.Center)
|
||||
Text(
|
||||
body,
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
textAlign = TextAlign.Center,
|
||||
modifier = Modifier.padding(top = 4.dp),
|
||||
)
|
||||
action?.let { (label, onClick) ->
|
||||
AccentButton(onClick = onClick, modifier = Modifier.padding(top = 16.dp)) { Text(label) }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Where Play on a title lands: what Continue watching offers
|
||||
* ([TitleRecord.continueIndex]), else the most recently played part-watched
|
||||
* episode, else the first unwatched one, else the first — the desktop's
|
||||
* `next_up`.
|
||||
*/
|
||||
internal fun nextUpIndex(playlist: List<EpisodeRecord>, continueIndex: UInt? = null): Int =
|
||||
continueIndex?.toInt()?.takeIf { it in playlist.indices }
|
||||
?: playlist.withIndex().filter { it.value.resumeAt != null }.maxByOrNull { it.value.lastPlayed }?.index
|
||||
?: playlist.indexOfFirst { !it.watched }.takeIf { it >= 0 }
|
||||
?: 0
|
||||
|
||||
/** `24 min`, `2 h 4 min`, `2 h`: a provider's running time in minutes. */
|
||||
internal fun formatRuntime(minutes: UInt): String {
|
||||
val hours = minutes / 60u
|
||||
val rest = minutes % 60u
|
||||
return when {
|
||||
hours == 0u -> "$rest min"
|
||||
rest == 0u -> "$hours h"
|
||||
else -> "$hours h $rest min"
|
||||
}
|
||||
}
|
||||
|
||||
internal 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))
|
||||
value >= 1024 -> "%.1f KiB".format(value / 1024)
|
||||
else -> "$bytes bytes"
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Download episodes, asking first when it is more than one: a season or a
|
||||
* show is tens of gigabytes, and one tap should not fill a phone. The
|
||||
* question says how many and how big. Call it in composition; it draws the
|
||||
* dialog where it is called and returns what starts the download.
|
||||
*/
|
||||
@Composable
|
||||
internal fun rememberDownload(): (List<EpisodeRecord>, String) -> Unit {
|
||||
val context = LocalContext.current
|
||||
var asking by remember { mutableStateOf<Pair<List<EpisodeRecord>, String>?>(null) }
|
||||
asking?.let { (episodes, what) ->
|
||||
val bytes = episodes.sumOf { it.size ?: 0uL }
|
||||
AlertDialog(
|
||||
onDismissRequest = { asking = null },
|
||||
title = { Text("Download ${episodes.size} episodes of $what?") },
|
||||
text = {
|
||||
Text(
|
||||
"They are kept on this device for watching without a connection" +
|
||||
(if (bytes > 0uL) " — about ${formatBytes(bytes)}" else "") +
|
||||
". Delete them from Downloads.",
|
||||
)
|
||||
},
|
||||
confirmButton = {
|
||||
AccentButton(onClick = {
|
||||
asking = null
|
||||
DownloadCoordinator.enqueue(context, episodes)
|
||||
}) { Text("Download") }
|
||||
},
|
||||
dismissButton = { TonalButton(onClick = { asking = null }) { Text("Cancel") } },
|
||||
)
|
||||
}
|
||||
return remember(context) {
|
||||
{ episodes, what ->
|
||||
val wanted = episodes.filterNot(EpisodeRecord::offline)
|
||||
if (wanted.size > 1) asking = wanted to what else DownloadCoordinator.enqueue(context, wanted)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* One line under a download that has not finished: "Running · 120.0 MiB of
|
||||
* 1.2 GiB · 2.4 MiB/s", or "Queued" while it waits for a slot — or, given a
|
||||
* [waitingFor], what it is really waiting on.
|
||||
*/
|
||||
internal fun downloadStatusLine(download: RetainedDownload, waitingFor: String? = null): String = listOfNotNull(
|
||||
waitingFor?.takeIf { download.status == RetainedDownload.STATUS_QUEUED }
|
||||
?: download.status.replaceFirstChar { it.uppercase() },
|
||||
download.total.takeIf { it > 0L && download.downloaded > 0L }?.let {
|
||||
"${formatBytes(download.downloaded.toULong())} of ${formatBytes(it.toULong())}"
|
||||
},
|
||||
download.bytesPerSecond
|
||||
.takeIf { it > 0L && download.status == RetainedDownload.STATUS_RUNNING }
|
||||
?.let { "${formatBytes(it.toULong())}/s" },
|
||||
).joinToString(" · ")
|
||||
|
||||
/**
|
||||
* Why a queued download is not running, when it is not merely a free slot:
|
||||
* WorkManager holds a Wi-Fi-only download back on mobile data without a word,
|
||||
* and "Queued" for an hour on the bus reads as stuck. Re-read whenever [key]
|
||||
* changes — the download's own updates are the clock.
|
||||
*/
|
||||
@Composable
|
||||
internal fun rememberQueueReason(key: Any?): String? {
|
||||
val context = LocalContext.current
|
||||
return remember(key) {
|
||||
val network = context.getSystemService(ConnectivityManager::class.java)
|
||||
when {
|
||||
network?.activeNetwork == null -> "Waiting for a connection"
|
||||
SettingsStore(context).wifiOnly && network.isActiveNetworkMetered -> "Waiting for Wi-Fi"
|
||||
else -> null
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The episode's own name: the provider's, else the one the filename gave it.
|
||||
* `null` rather than the filename, which is a release group's tag and the
|
||||
* episode's numbering again.
|
||||
*/
|
||||
internal fun EpisodeRecord.shownName(): String? = providerName ?: detail.takeIf { it != name && it.isNotBlank() }
|
||||
|
||||
/**
|
||||
* What an episode row is headed with: "1. Mother and Children".
|
||||
*
|
||||
* The provider's name for the episode, else the name the filename gave it,
|
||||
* else "Episode 1". The bare filename only when none of those exist — a column
|
||||
* of `[Group] Show - S01E01.mkv` is forty rows that differ in two characters.
|
||||
*/
|
||||
internal fun EpisodeRecord.heading(position: Int): String {
|
||||
val named = shownName()
|
||||
val number = number?.toInt()
|
||||
return when {
|
||||
named != null && number != null -> "$number. $named"
|
||||
named != null -> named
|
||||
number != null -> "Episode $number"
|
||||
else -> name.substringBeforeLast('.').ifBlank { "Episode ${position + 1}" }
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Something the title is filed under — a genre — as a small rounded label.
|
||||
* The desktop's `widgets::chip`.
|
||||
*/
|
||||
@Composable
|
||||
internal fun GenreChip(text: String, onClick: (() -> Unit)? = null) {
|
||||
Text(
|
||||
text,
|
||||
style = MaterialTheme.typography.labelMedium,
|
||||
color = MaterialTheme.colorScheme.onSurface,
|
||||
modifier = Modifier
|
||||
.clip(RoundedCornerShape(12.dp))
|
||||
.background(MaterialTheme.colorScheme.surfaceVariant)
|
||||
.then(if (onClick != null) Modifier.clickable(onClickLabel = "Browse $text", onClick = onClick) else Modifier)
|
||||
.padding(horizontal = 10.dp, vertical = 4.dp),
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Where a title stands — "Airing · episode 8 Sat 1 Nov", "Finished" — as a
|
||||
* [GenreChip] with a dot in front: the accent while more is coming, muted once
|
||||
* it is all out. The desktop's `widgets::status_chip`.
|
||||
*/
|
||||
@Composable
|
||||
internal fun StatusChip(text: String, live: Boolean, modifier: Modifier = Modifier) {
|
||||
val scheme = MaterialTheme.colorScheme
|
||||
Row(
|
||||
modifier
|
||||
.background(scheme.surfaceVariant, RoundedCornerShape(12.dp))
|
||||
.padding(horizontal = 10.dp, vertical = 4.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
Box(
|
||||
Modifier
|
||||
.size(6.dp)
|
||||
.background(if (live) scheme.primary else scheme.onSurfaceVariant, CircleShape),
|
||||
)
|
||||
Spacer(Modifier.width(6.dp))
|
||||
Text(text, style = MaterialTheme.typography.labelMedium, color = scheme.onSurface)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* A fact about the files rather than the title — 4K, HDR, Dual audio — as a
|
||||
* small outlined mark, the way a box prints them. Quieter than a [GenreChip].
|
||||
* The desktop's `widgets::spec_badge`.
|
||||
*/
|
||||
@Composable
|
||||
internal fun SpecBadge(text: String) {
|
||||
val ink = MaterialTheme.colorScheme.onSurfaceVariant
|
||||
Text(
|
||||
text,
|
||||
style = MaterialTheme.typography.labelSmall,
|
||||
fontWeight = androidx.compose.ui.text.font.FontWeight.SemiBold,
|
||||
color = ink,
|
||||
modifier = Modifier
|
||||
.border(1.dp, ink.copy(alpha = 0.7f), MaterialTheme.shapes.extraSmall)
|
||||
.padding(horizontal = 5.dp, vertical = 1.dp),
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* My List's button: a plus to put the title on it, a tick once it is, which
|
||||
* takes it off again. [compact] is the icon alone, for the banner's row, where
|
||||
* a third labelled button does not fit a phone.
|
||||
*/
|
||||
@Composable
|
||||
internal fun ListButton(onList: Boolean, onSet: (Boolean) -> Unit, compact: Boolean = false) {
|
||||
val icon = if (onList) Icons.Default.Check else Icons.Default.Add
|
||||
val label = if (onList) "Remove from My List" else "Add to My List"
|
||||
if (compact) {
|
||||
FilledTonalIconButton(onClick = { onSet(!onList) }, shape = MaterialTheme.shapes.small) {
|
||||
Icon(icon, contentDescription = label)
|
||||
}
|
||||
} else {
|
||||
TonalButton(onClick = { onSet(!onList) }, modifier = Modifier.semantics { contentDescription = label }) {
|
||||
Icon(icon, contentDescription = null)
|
||||
Spacer(Modifier.width(6.dp))
|
||||
Text("My List")
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,261 @@
|
||||
package io.narl.protonstream.ui
|
||||
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.PaddingValues
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.aspectRatio
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.foundation.lazy.LazyColumn
|
||||
import androidx.compose.foundation.lazy.items
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.Close
|
||||
import androidx.compose.material.icons.filled.Pause
|
||||
import androidx.compose.material.icons.filled.PlayArrow
|
||||
import androidx.compose.material.icons.outlined.Delete
|
||||
import androidx.compose.material.icons.outlined.Info
|
||||
import androidx.compose.material3.AlertDialog
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.IconButton
|
||||
import androidx.compose.material3.ListItem
|
||||
import androidx.compose.material3.ListItemDefaults
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.livedata.observeAsState
|
||||
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.draw.clip
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.work.WorkManager
|
||||
import io.narl.protonstream.download.DownloadCoordinator
|
||||
import io.narl.protonstream.download.DownloadStateStore
|
||||
import io.narl.protonstream.download.RetainedDownload
|
||||
import io.narl.protonstream.ui.theme.DangerButton
|
||||
import io.narl.protonstream.ui.theme.AccentProgress
|
||||
import io.narl.protonstream.ui.theme.TonalButton
|
||||
import uniffi.pstr_android.OfflineRecord
|
||||
import uniffi.pstr_android.SeasonRecord
|
||||
import uniffi.pstr_android.TitleRecord
|
||||
|
||||
/** One show's saved episodes, and the show itself where the library has it. */
|
||||
private data class OfflineGroup(val name: String, val title: TitleRecord?, val files: List<OfflineRecord>)
|
||||
|
||||
@Composable
|
||||
internal fun DownloadsScreen(
|
||||
state: AppUiState,
|
||||
onRemove: (OfflineRecord) -> Unit,
|
||||
onPause: (RetainedDownload) -> Unit,
|
||||
onResume: (RetainedDownload) -> Unit,
|
||||
onDeletePartial: (RetainedDownload) -> Unit,
|
||||
padding: PaddingValues,
|
||||
onPlay: (TitleRecord, Int) -> Unit = { _, _ -> },
|
||||
onOpen: (TitleRecord) -> Unit = {},
|
||||
) {
|
||||
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() }
|
||||
// Which show each saved file is from. The offline record knows its episode
|
||||
// but not its show, and the library is the only thing that does.
|
||||
val groups = remember(state.offline, state.titles) {
|
||||
val shows = state.titles.flatMap { title ->
|
||||
title.seasons.flatMap(SeasonRecord::episodes).map { "${it.shareId}/${it.linkId}" to title }
|
||||
}.toMap()
|
||||
state.offline
|
||||
.groupBy { shows["${it.shareId}/${it.linkId}"] }
|
||||
.map { (title, files) ->
|
||||
OfflineGroup(title?.displayName ?: "Not in the library", title, files)
|
||||
}
|
||||
.sortedBy { it.name }
|
||||
}
|
||||
// Deleting a saved episode asks first: it is the viewer's own choice to
|
||||
// keep it, and getting it back is a download, not a tap.
|
||||
var deleting by remember { mutableStateOf<OfflineRecord?>(null) }
|
||||
TabPage("Downloads", padding) { body ->
|
||||
if (retained.isEmpty() && state.offline.isEmpty()) {
|
||||
Box(Modifier.fillMaxSize().padding(body)) {
|
||||
EmptyState("No downloads", "Episodes, seasons and shows saved offline appear here.")
|
||||
}
|
||||
return@TabPage
|
||||
}
|
||||
LazyColumn(
|
||||
modifier = Modifier.fillMaxSize().padding(body),
|
||||
contentPadding = PaddingValues(bottom = 24.dp),
|
||||
) {
|
||||
if (state.offline.isNotEmpty()) {
|
||||
item(key = "summary") {
|
||||
Text(
|
||||
"${state.offline.size} ${if (state.offline.size == 1) "episode" else "episodes"} · " +
|
||||
"${formatBytes(state.offline.sumOf { it.size })} on this device",
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
modifier = Modifier.padding(horizontal = 16.dp, vertical = 4.dp),
|
||||
)
|
||||
}
|
||||
}
|
||||
// What is still arriving comes first: it is the part of the page
|
||||
// that changes, and the part with something to do.
|
||||
if (retained.isNotEmpty()) {
|
||||
item(key = "in-progress") { SectionHeading("In progress") }
|
||||
items(retained, key = { "partial/${it.shareId}/${it.linkId}" }) { download ->
|
||||
PartialRow(download, onPause, onResume, onDeletePartial)
|
||||
}
|
||||
}
|
||||
// Under the show they belong to, as on desktop: a saved season is
|
||||
// fourteen rows, and fourteen rows of "Episode 3" name nothing.
|
||||
groups.forEach { group ->
|
||||
item(key = "group/${group.name}") {
|
||||
SectionHeading(
|
||||
group.name,
|
||||
"${group.files.size} ${if (group.files.size == 1) "episode" else "episodes"} · " +
|
||||
formatBytes(group.files.sumOf { it.size }),
|
||||
)
|
||||
}
|
||||
items(group.files, key = { "${it.shareId}/${it.linkId}" }) { file ->
|
||||
SavedRow(file, group.title, onDelete = { deleting = file }, onPlay = onPlay, onOpen = onOpen)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
deleting?.let { file ->
|
||||
AlertDialog(
|
||||
onDismissRequest = { deleting = null },
|
||||
title = { Text("Delete this download?") },
|
||||
text = {
|
||||
Text(
|
||||
"${file.episode?.label ?: "The episode"} (${formatBytes(file.size)}) leaves this device. " +
|
||||
"It still streams, and can be downloaded again.",
|
||||
)
|
||||
},
|
||||
confirmButton = {
|
||||
DangerButton(onClick = {
|
||||
deleting = null
|
||||
onRemove(file)
|
||||
}) { Text("Delete") }
|
||||
},
|
||||
dismissButton = { TonalButton(onClick = { deleting = null }) { Text("Cancel") } },
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun SectionHeading(title: String, caption: String? = null) {
|
||||
Column(Modifier.padding(start = 16.dp, end = 16.dp, top = 20.dp, bottom = 4.dp)) {
|
||||
Text(title, style = MaterialTheme.typography.titleMedium, maxLines = 1, overflow = TextOverflow.Ellipsis)
|
||||
caption?.let {
|
||||
Text(it, style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** A saved episode: its still, what it is, its size, and a way to remove it. */
|
||||
@Composable
|
||||
private fun SavedRow(
|
||||
file: OfflineRecord,
|
||||
title: TitleRecord?,
|
||||
onDelete: () -> Unit,
|
||||
onPlay: (TitleRecord, Int) -> Unit,
|
||||
onOpen: (TitleRecord) -> Unit,
|
||||
) {
|
||||
val episode = file.episode
|
||||
// What a download is for: tapping it plays it, from where it was left,
|
||||
// without going back through the library to find it.
|
||||
val index = title?.seasons?.flatMap(SeasonRecord::episodes)?.indexOfFirst { it.linkId == file.linkId } ?: -1
|
||||
ListItem(
|
||||
modifier = if (title != null && index >= 0) {
|
||||
Modifier.clickable(onClickLabel = "Play") { onPlay(title, index) }
|
||||
} else {
|
||||
Modifier
|
||||
},
|
||||
leadingContent = {
|
||||
RemoteArtwork(
|
||||
episode?.stillUrl ?: title?.backdropUrl,
|
||||
title?.name ?: file.linkId,
|
||||
Modifier.width(96.dp).aspectRatio(16f / 9f).clip(MaterialTheme.shapes.small),
|
||||
fallback = episode?.thumbnailSource,
|
||||
labelled = episode == null,
|
||||
)
|
||||
},
|
||||
headlineContent = {
|
||||
Text(episode?.heading(0) ?: file.linkId, maxLines = 2, overflow = TextOverflow.Ellipsis)
|
||||
},
|
||||
supportingContent = {
|
||||
Text(listOfNotNull(episode?.label, formatBytes(file.size)).joinToString(" · "))
|
||||
},
|
||||
trailingContent = {
|
||||
Row {
|
||||
title?.let {
|
||||
IconButton(onClick = { onOpen(it) }) {
|
||||
Icon(Icons.Outlined.Info, contentDescription = "Open ${it.displayName}")
|
||||
}
|
||||
}
|
||||
IconButton(onClick = onDelete) {
|
||||
Icon(Icons.Outlined.Delete, contentDescription = "Delete download")
|
||||
}
|
||||
}
|
||||
},
|
||||
colors = ListItemDefaults.colors(containerColor = Color.Transparent),
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* A download not yet finished: how far it has got, why it stopped if it did,
|
||||
* and pause or resume beside cancel.
|
||||
*/
|
||||
@Composable
|
||||
private fun PartialRow(
|
||||
download: RetainedDownload,
|
||||
onPause: (RetainedDownload) -> Unit,
|
||||
onResume: (RetainedDownload) -> Unit,
|
||||
onDeletePartial: (RetainedDownload) -> Unit,
|
||||
) {
|
||||
val active = download.status == RetainedDownload.STATUS_RUNNING ||
|
||||
download.status == RetainedDownload.STATUS_QUEUED
|
||||
ListItem(
|
||||
headlineContent = { Text(download.label, maxLines = 2, overflow = TextOverflow.Ellipsis) },
|
||||
supportingContent = {
|
||||
Column {
|
||||
Text(downloadStatusLine(download, rememberQueueReason(download)))
|
||||
if (download.total > 0L) {
|
||||
AccentProgress(
|
||||
progress = { (download.downloaded.toFloat() / download.total).coerceIn(0f, 1f) },
|
||||
modifier = Modifier.fillMaxWidth().padding(top = 8.dp),
|
||||
)
|
||||
}
|
||||
download.error?.let { Text(it, color = MaterialTheme.colorScheme.error) }
|
||||
}
|
||||
},
|
||||
trailingContent = {
|
||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||
if (active) {
|
||||
IconButton(onClick = { onPause(download) }) {
|
||||
Icon(Icons.Default.Pause, contentDescription = "Pause download")
|
||||
}
|
||||
} else {
|
||||
IconButton(onClick = { onResume(download) }) {
|
||||
Icon(Icons.Default.PlayArrow, contentDescription = "Resume download")
|
||||
}
|
||||
}
|
||||
// What has arrived so far is discarded; nothing finished is.
|
||||
IconButton(onClick = { onDeletePartial(download) }) {
|
||||
Icon(Icons.Default.Close, contentDescription = "Cancel and delete what has arrived")
|
||||
}
|
||||
}
|
||||
},
|
||||
colors = ListItemDefaults.colors(containerColor = Color.Transparent),
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,183 @@
|
||||
package io.narl.protonstream.ui
|
||||
|
||||
import androidx.compose.foundation.clickable
|
||||
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.aspectRatio
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.foundation.lazy.LazyColumn
|
||||
import androidx.compose.foundation.lazy.items
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.Close
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.IconButton
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.unit.dp
|
||||
import io.narl.protonstream.ui.theme.AccentProgress
|
||||
import java.time.Instant
|
||||
import java.time.LocalDate
|
||||
import java.time.ZoneId
|
||||
import java.time.format.DateTimeFormatter
|
||||
import java.time.format.TextStyle
|
||||
import java.util.Locale
|
||||
import uniffi.pstr_android.EpisodeRecord
|
||||
import uniffi.pstr_android.SeasonRecord
|
||||
import uniffi.pstr_android.TitleRecord
|
||||
|
||||
/** One played episode, with the title it belongs to and its place in that title's playlist. */
|
||||
internal data class Played(val title: TitleRecord, val episode: EpisodeRecord, val index: Int)
|
||||
|
||||
/**
|
||||
* Every episode that has been started or finished, newest first — the
|
||||
* desktop's `Library::history`, over the records the library already holds.
|
||||
*
|
||||
* A row marked unwatched again sits at position zero, which is "never played"
|
||||
* for every purpose here, so it is left out. Progress stands in for the
|
||||
* position: it is non-zero exactly when the position is.
|
||||
*/
|
||||
internal fun history(titles: List<TitleRecord>): List<Played> =
|
||||
titles.flatMap { title ->
|
||||
title.seasons.flatMap(SeasonRecord::episodes).withIndex()
|
||||
.filter { (_, episode) -> episode.watched || (episode.progress ?: 0.0) > 0.0 }
|
||||
.map { (index, episode) -> Played(title, episode, index) }
|
||||
}.sortedByDescending { it.episode.lastPlayed }
|
||||
|
||||
/**
|
||||
* "Today", "Yesterday", a weekday within the last week, then a date — how the
|
||||
* history page heads each day.
|
||||
*/
|
||||
internal fun dayHeading(day: LocalDate, today: LocalDate, locale: Locale = Locale.getDefault()): String = when {
|
||||
day == today -> "Today"
|
||||
day == today.minusDays(1) -> "Yesterday"
|
||||
day.isAfter(today.minusDays(7)) -> day.dayOfWeek.getDisplayName(TextStyle.FULL, locale)
|
||||
day.year == today.year -> day.format(DateTimeFormatter.ofPattern("d MMMM", locale))
|
||||
else -> day.format(DateTimeFormatter.ofPattern("d MMMM yyyy", locale))
|
||||
}
|
||||
|
||||
@Composable
|
||||
internal fun HistoryScreen(
|
||||
state: AppUiState,
|
||||
onPlay: (TitleRecord, Int) -> Unit,
|
||||
onTitle: (TitleRecord) -> Unit,
|
||||
onRemove: (TitleRecord, EpisodeRecord) -> Unit,
|
||||
padding: PaddingValues,
|
||||
zone: ZoneId = remember { ZoneId.systemDefault() },
|
||||
today: LocalDate = LocalDate.now(zone),
|
||||
) {
|
||||
val days = remember(state.titles) {
|
||||
history(state.titles).groupBy { Instant.ofEpochSecond(it.episode.lastPlayed).atZone(zone).toLocalDate() }
|
||||
}
|
||||
if (days.isEmpty()) {
|
||||
TabPage("History", padding) { body ->
|
||||
Box(Modifier.fillMaxSize().padding(body)) {
|
||||
EmptyState("Nothing watched yet", "Episodes you play show up here, newest first.")
|
||||
}
|
||||
}
|
||||
return
|
||||
}
|
||||
TabPage("History", padding) { body ->
|
||||
LazyColumn(Modifier.fillMaxSize().padding(body), contentPadding = PaddingValues(bottom = 24.dp)) {
|
||||
days.forEach { (day, played) ->
|
||||
item(key = "day/$day") {
|
||||
Text(
|
||||
dayHeading(day, today),
|
||||
style = MaterialTheme.typography.titleSmall,
|
||||
color = MaterialTheme.colorScheme.primary,
|
||||
modifier = Modifier.padding(start = 16.dp, end = 16.dp, top = 20.dp, bottom = 4.dp),
|
||||
)
|
||||
}
|
||||
items(played, key = { "${it.episode.shareId}/${it.episode.linkId}" }) { entry ->
|
||||
HistoryRow(
|
||||
entry,
|
||||
onPlay = { onPlay(entry.title, entry.index) },
|
||||
onTitle = { onTitle(entry.title) },
|
||||
onRemove = { onRemove(entry.title, entry.episode) },
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The still plays the episode, the text opens its title, and the cross takes
|
||||
* it off the page — the same three targets the desktop row has.
|
||||
*/
|
||||
@Composable
|
||||
private fun HistoryRow(entry: Played, onPlay: () -> Unit, onTitle: () -> Unit, onRemove: () -> Unit) {
|
||||
val episode = entry.episode
|
||||
Row(
|
||||
Modifier.fillMaxWidth().padding(start = 16.dp, end = 4.dp, top = 6.dp, bottom = 6.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
Box(
|
||||
Modifier
|
||||
.width(128.dp)
|
||||
.aspectRatio(16f / 9f)
|
||||
.clip(MaterialTheme.shapes.small)
|
||||
.clickable(onClick = onPlay),
|
||||
) {
|
||||
RemoteArtwork(
|
||||
episode.stillUrl ?: entry.title.backdropUrl,
|
||||
episode.label,
|
||||
Modifier.fillMaxSize(),
|
||||
fallback = episode.thumbnailSource,
|
||||
labelled = false,
|
||||
)
|
||||
episode.progress?.takeIf { !episode.watched }?.let { progress ->
|
||||
AccentProgress(
|
||||
progress = { progress.toFloat().coerceIn(0f, 1f) },
|
||||
modifier = Modifier.fillMaxWidth().align(Alignment.BottomCenter),
|
||||
)
|
||||
}
|
||||
}
|
||||
Column(Modifier.weight(1f).clickable(onClick = onTitle).padding(horizontal = 12.dp, vertical = 4.dp)) {
|
||||
Text(
|
||||
entry.title.displayName,
|
||||
style = MaterialTheme.typography.titleSmall,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
)
|
||||
Text(
|
||||
episode.providerName?.let { "${episode.label} · $it" } ?: episode.label,
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
)
|
||||
Text(
|
||||
if (episode.watched) "Finished" else episode.stoppedAt(),
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
}
|
||||
IconButton(onClick = onRemove) {
|
||||
Icon(Icons.Default.Close, contentDescription = "Remove from history")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** "Stopped at 12:04 of 23:40", or as much of it as is known. */
|
||||
private fun EpisodeRecord.stoppedAt(): String {
|
||||
val at = resumeAt ?: return "Started"
|
||||
val duration = progress?.takeIf { it > 0.0 }?.let { at / it }
|
||||
return "Stopped at ${clock(at)}" + (duration?.let { " of ${clock(it)}" } ?: "")
|
||||
}
|
||||
|
||||
private fun clock(seconds: Double): String {
|
||||
val total = seconds.toInt().coerceAtLeast(0)
|
||||
val hours = total / 3_600
|
||||
return if (hours > 0) "%d:%02d:%02d".format(hours, total % 3_600 / 60, total % 60)
|
||||
else "%d:%02d".format(total / 60, total % 60)
|
||||
}
|
||||
@@ -0,0 +1,183 @@
|
||||
package io.narl.protonstream.ui
|
||||
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.heightIn
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.lazy.LazyColumn
|
||||
import androidx.compose.foundation.lazy.items
|
||||
import androidx.compose.material3.AlertDialog
|
||||
import androidx.compose.material3.HorizontalDivider
|
||||
import androidx.compose.material3.ListItem
|
||||
import androidx.compose.material3.ListItemDefaults
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.OutlinedTextField
|
||||
import androidx.compose.material3.RadioButton
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.semantics.Role
|
||||
import androidx.compose.ui.unit.dp
|
||||
import io.narl.protonstream.ui.theme.AccentButton
|
||||
import io.narl.protonstream.ui.theme.TonalButton
|
||||
|
||||
/**
|
||||
* The languages offered by name, as the three-letter tag a Matroska file
|
||||
* carries.
|
||||
*
|
||||
* The same short list the desktop names tracks from (`pstr-player`'s
|
||||
* `language_name`): what the shares this app is pointed at actually contain.
|
||||
* mpv treats the two- and three-letter forms of a language as one, so the tag
|
||||
* chosen here also matches a file that says "ja". Anything else is still one
|
||||
* typed tag away.
|
||||
*/
|
||||
internal val LANGUAGES: List<Pair<String, String>> = listOf(
|
||||
"jpn" to "Japanese",
|
||||
"eng" to "English",
|
||||
"ger" to "German",
|
||||
"fre" to "French",
|
||||
"spa" to "Spanish",
|
||||
"ita" to "Italian",
|
||||
"por" to "Portuguese",
|
||||
"dut" to "Dutch",
|
||||
"rus" to "Russian",
|
||||
"pol" to "Polish",
|
||||
"swe" to "Swedish",
|
||||
"dan" to "Danish",
|
||||
"nor" to "Norwegian",
|
||||
"fin" to "Finnish",
|
||||
"cze" to "Czech",
|
||||
"hun" to "Hungarian",
|
||||
"tur" to "Turkish",
|
||||
"ara" to "Arabic",
|
||||
"heb" to "Hebrew",
|
||||
"hin" to "Hindi",
|
||||
"kor" to "Korean",
|
||||
"chi" to "Chinese",
|
||||
"tha" to "Thai",
|
||||
"vie" to "Vietnamese",
|
||||
"ukr" to "Ukrainian",
|
||||
"rum" to "Romanian",
|
||||
"gre" to "Greek",
|
||||
"ind" to "Indonesian",
|
||||
"may" to "Malay",
|
||||
)
|
||||
|
||||
/**
|
||||
* "Japanese", for a tag in any of the forms a file or the desktop may have
|
||||
* stored — "jpn", "ja", "JA-jp" — and the tag itself for one not on the list.
|
||||
* Null is no preference.
|
||||
*/
|
||||
internal fun languageLabel(tag: String?): String {
|
||||
val value = tag?.trim()?.takeIf(String::isNotEmpty) ?: return "No preference"
|
||||
return languageOf(value)?.second ?: value
|
||||
}
|
||||
|
||||
/** The listed language a tag names, matching either ISO 639 form. */
|
||||
private fun languageOf(tag: String): Pair<String, String>? {
|
||||
val base = tag.split('-', '_').first().lowercase()
|
||||
return LANGUAGES.firstOrNull { (code, _) -> code == base || TWO_LETTER[code] == base || terminology(code) == base }
|
||||
}
|
||||
|
||||
/** ISO 639-2/T, for the handful whose bibliographic code differs. */
|
||||
private fun terminology(code: String): String? = when (code) {
|
||||
"ger" -> "deu"
|
||||
"fre" -> "fra"
|
||||
"dut" -> "nld"
|
||||
"cze" -> "ces"
|
||||
"chi" -> "zho"
|
||||
"rum" -> "ron"
|
||||
"gre" -> "ell"
|
||||
"may" -> "msa"
|
||||
else -> null
|
||||
}
|
||||
|
||||
/** ISO 639-1, the form a file or the desktop may also have stored. */
|
||||
private val TWO_LETTER = mapOf(
|
||||
"jpn" to "ja", "eng" to "en", "ger" to "de", "fre" to "fr", "spa" to "es", "ita" to "it",
|
||||
"por" to "pt", "dut" to "nl", "rus" to "ru", "pol" to "pl", "swe" to "sv", "dan" to "da",
|
||||
"nor" to "no", "fin" to "fi", "cze" to "cs", "hun" to "hu", "tur" to "tr", "ara" to "ar",
|
||||
"heb" to "he", "hin" to "hi", "kor" to "ko", "chi" to "zh", "tha" to "th", "vie" to "vi",
|
||||
"ukr" to "uk", "rum" to "ro", "gre" to "el", "ind" to "id", "may" to "ms",
|
||||
)
|
||||
|
||||
/**
|
||||
* A language preference as a row, opening a list to choose from.
|
||||
*
|
||||
* It was a free-text field asking for "jpn or eng", which is a question only
|
||||
* someone who has read a Matroska header can answer.
|
||||
*/
|
||||
@Composable
|
||||
internal fun LanguageSetting(headline: String, value: String?, onChange: (String?) -> Unit) {
|
||||
var choosing by remember { mutableStateOf(false) }
|
||||
ListItem(
|
||||
headlineContent = { Text(headline) },
|
||||
supportingContent = { Text(languageLabel(value)) },
|
||||
colors = ListItemDefaults.colors(containerColor = Color.Transparent),
|
||||
modifier = Modifier.clickable(role = Role.Button) { choosing = true },
|
||||
)
|
||||
if (choosing) {
|
||||
LanguageDialog(
|
||||
headline,
|
||||
value,
|
||||
onDismiss = { choosing = false },
|
||||
onChoose = {
|
||||
choosing = false
|
||||
onChange(it)
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun LanguageDialog(headline: String, value: String?, onDismiss: () -> Unit, onChoose: (String?) -> Unit) {
|
||||
val current = value?.let(::languageOf)?.first ?: value
|
||||
// A tag that is not on the list starts in the field, so it is shown and
|
||||
// can be edited rather than silently replaced.
|
||||
var typed by remember { mutableStateOf(current?.takeIf { tag -> LANGUAGES.none { it.first == tag } }.orEmpty()) }
|
||||
AlertDialog(
|
||||
onDismissRequest = onDismiss,
|
||||
title = { Text(headline) },
|
||||
text = {
|
||||
Column {
|
||||
LazyColumn(Modifier.fillMaxWidth().heightIn(max = 360.dp)) {
|
||||
item(key = "none") { LanguageOption("No preference", current == null) { onChoose(null) } }
|
||||
items(LANGUAGES, key = { it.first }) { (code, name) ->
|
||||
LanguageOption(name, current == code) { onChoose(code) }
|
||||
}
|
||||
}
|
||||
HorizontalDivider(Modifier.padding(vertical = 8.dp))
|
||||
OutlinedTextField(
|
||||
value = typed,
|
||||
onValueChange = { typed = it },
|
||||
label = { Text("Another tag") },
|
||||
supportingText = { Text("As it appears in the file, such as \"tgl\"") },
|
||||
singleLine = true,
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
)
|
||||
}
|
||||
},
|
||||
confirmButton = {
|
||||
AccentButton(enabled = typed.isNotBlank(), onClick = { onChoose(typed.trim().lowercase()) }) {
|
||||
Text("Use tag")
|
||||
}
|
||||
},
|
||||
dismissButton = { TonalButton(onClick = onDismiss) { Text("Cancel") } },
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun LanguageOption(name: String, selected: Boolean, onClick: () -> Unit) {
|
||||
ListItem(
|
||||
headlineContent = { Text(name, style = MaterialTheme.typography.bodyLarge) },
|
||||
leadingContent = { RadioButton(selected = selected, onClick = null) },
|
||||
colors = ListItemDefaults.colors(containerColor = Color.Transparent),
|
||||
modifier = Modifier.clickable(role = Role.RadioButton, onClick = onClick),
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,541 @@
|
||||
package io.narl.protonstream.ui
|
||||
|
||||
import androidx.compose.ui.semantics.LiveRegionMode
|
||||
import androidx.compose.ui.semantics.liveRegion
|
||||
import androidx.compose.ui.semantics.semantics
|
||||
import androidx.compose.material3.CircularProgressIndicator
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.animation.slideOutVertically
|
||||
import androidx.compose.animation.slideInVertically
|
||||
import androidx.compose.animation.fadeOut
|
||||
import androidx.compose.animation.fadeIn
|
||||
import androidx.compose.animation.AnimatedVisibility
|
||||
import androidx.activity.compose.BackHandler
|
||||
import androidx.compose.animation.AnimatedContent
|
||||
import androidx.compose.animation.ExperimentalSharedTransitionApi
|
||||
import androidx.compose.animation.SharedTransitionLayout
|
||||
import androidx.compose.runtime.CompositionLocalProvider
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.BoxWithConstraints
|
||||
import androidx.compose.material3.VerticalDivider
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.PaddingValues
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
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.layout.calculateEndPadding
|
||||
import androidx.compose.foundation.layout.calculateStartPadding
|
||||
import androidx.compose.material3.Card
|
||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Scaffold
|
||||
import androidx.compose.material3.ScaffoldDefaults
|
||||
import androidx.compose.foundation.layout.WindowInsetsSides
|
||||
import androidx.compose.foundation.layout.only
|
||||
import androidx.compose.foundation.layout.plus
|
||||
import androidx.compose.material3.SnackbarHost
|
||||
import androidx.compose.material3.SnackbarDuration
|
||||
import androidx.compose.material3.SnackbarHostState
|
||||
import androidx.compose.material3.SnackbarResult
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.IconButton
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.VideoLibrary
|
||||
import androidx.compose.material.icons.filled.Share
|
||||
import androidx.compose.material.icons.filled.Download
|
||||
import androidx.compose.material.icons.filled.History
|
||||
import androidx.compose.material.icons.filled.Settings
|
||||
import androidx.compose.material.icons.filled.Close
|
||||
import androidx.compose.material.icons.filled.Pause
|
||||
import androidx.compose.material.icons.filled.PlayArrow
|
||||
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.mutableIntStateOf
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.produceState
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.saveable.rememberSaveable
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.graphics.vector.ImageVector
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.platform.LocalLayoutDirection
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
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.ui.theme.QuietButton
|
||||
import io.narl.protonstream.ui.theme.accentNavigationColors
|
||||
import uniffi.pstr_android.BrowseFacet
|
||||
import uniffi.pstr_android.TitleRecord
|
||||
import uniffi.pstr_android.EpisodeRecord
|
||||
import uniffi.pstr_android.SeasonRecord
|
||||
import io.narl.protonstream.playback.LibmpvHost
|
||||
import io.narl.protonstream.playback.NativeMpvHost
|
||||
import io.narl.protonstream.playback.PlayerScreen
|
||||
|
||||
/** From this width the library and an open title share the window. */
|
||||
private val TWO_PANE_WIDTH = 840.dp
|
||||
|
||||
/** How much room the floating mini transport needs at the foot of a page. */
|
||||
private val MINI_TRANSPORT_INSET = 88.dp
|
||||
|
||||
/** What the player was asked to open: a title's episodes, and which one. */
|
||||
private data class PlayRequest(
|
||||
val title: TitleRecord,
|
||||
val episodes: List<EpisodeRecord>,
|
||||
val index: Int,
|
||||
)
|
||||
|
||||
private enum class Destination(val label: String, val icon: ImageVector) {
|
||||
Library("Library", Icons.Default.VideoLibrary),
|
||||
History("History", Icons.Default.History),
|
||||
Shares("Shares", Icons.Default.Share),
|
||||
Downloads("Downloads", Icons.Default.Download),
|
||||
Settings("Settings", Icons.Default.Settings),
|
||||
}
|
||||
|
||||
@OptIn(ExperimentalMaterial3Api::class, ExperimentalSharedTransitionApi::class)
|
||||
@Composable
|
||||
fun ProtonStreamApp(
|
||||
playerHost: LibmpvHost? = null,
|
||||
shareLink: String? = null,
|
||||
onShareLinkTaken: () -> Unit = {},
|
||||
model: AppViewModel = viewModel(
|
||||
factory = AppViewModel.Factory(
|
||||
LocalContext.current,
|
||||
WorkManager.getInstance(LocalContext.current),
|
||||
),
|
||||
),
|
||||
) {
|
||||
val state by model.state.collectAsState()
|
||||
val snackbars = remember { SnackbarHostState() }
|
||||
// Keys, not records: what has to survive process recreation is *which* title
|
||||
// and which episode, and a `TitleRecord` is neither parcelable nor still
|
||||
// current after the library reloads. Everything else is derived, so a reload
|
||||
// that renames a season updates the open screen rather than pinning a stale
|
||||
// copy of it.
|
||||
var destination by rememberSaveable { mutableStateOf(Destination.Library) }
|
||||
var selectedTitleKey by rememberSaveable { mutableStateOf<String?>(null) }
|
||||
var playingTitleKey by rememberSaveable { mutableStateOf<String?>(null) }
|
||||
var playingIndex by rememberSaveable { mutableIntStateOf(0) }
|
||||
// Playing, but not on screen: the episode keeps going and the mini transport
|
||||
// is what says so. Saved, because a rotation must not throw the viewer back
|
||||
// into the video they had just left.
|
||||
var playerMinimized by rememberSaveable { mutableStateOf(false) }
|
||||
var addingShare by remember { mutableStateOf(false) }
|
||||
// What a genre, studio or director chip opened, over the library pane.
|
||||
// As its two parts, which survive process recreation where the record
|
||||
// would not.
|
||||
var browseKind by rememberSaveable { mutableStateOf<String?>(null) }
|
||||
var browseName by rememberSaveable { mutableStateOf("") }
|
||||
// The title it was opened from, which is where back goes, as on the
|
||||
// desktop.
|
||||
var browseFrom by rememberSaveable { mutableStateOf<String?>(null) }
|
||||
val browsing = when (browseKind) {
|
||||
"genre" -> BrowseFacet.Genre(browseName)
|
||||
"studio" -> BrowseFacet.Studio(browseName)
|
||||
"person" -> BrowseFacet.Person(browseName)
|
||||
else -> null
|
||||
}
|
||||
val browse = { facet: BrowseFacet ->
|
||||
browseKind = when (facet) {
|
||||
is BrowseFacet.Genre -> "genre"
|
||||
is BrowseFacet.Studio -> "studio"
|
||||
is BrowseFacet.Person -> "person"
|
||||
}
|
||||
browseName = facet.label
|
||||
}
|
||||
|
||||
val selectedTitle = state.titles.firstOrNull { it.key == selectedTitleKey }
|
||||
val playing = playingTitleKey
|
||||
?.let { key -> state.titles.firstOrNull { it.key == key } }
|
||||
?.let { title ->
|
||||
val episodes = title.seasons.flatMap(SeasonRecord::episodes)
|
||||
PlayRequest(title, episodes, playingIndex.coerceIn(0, (episodes.size - 1).coerceAtLeast(0)))
|
||||
}
|
||||
|
||||
// A link opened from a browser or shared from another app: show the Add
|
||||
// form with it filled in. A playing episode keeps going in the mini
|
||||
// transport rather than covering the form.
|
||||
LaunchedEffect(shareLink) {
|
||||
if (shareLink != null) {
|
||||
destination = Destination.Shares
|
||||
selectedTitleKey = null
|
||||
if (playingTitleKey != null) playerMinimized = true
|
||||
}
|
||||
}
|
||||
LaunchedEffect(state.message) {
|
||||
state.message?.let {
|
||||
val undoable = state.undo != null
|
||||
val result = snackbars.showSnackbar(
|
||||
it,
|
||||
actionLabel = if (undoable) "Undo" else null,
|
||||
duration = if (undoable) SnackbarDuration.Long else SnackbarDuration.Short,
|
||||
)
|
||||
if (result == SnackbarResult.ActionPerformed) model.undo() else model.dismissMessage()
|
||||
}
|
||||
}
|
||||
// Read out here because `navigationSuiteItems` is not a composable scope.
|
||||
val navigation = accentNavigationColors()
|
||||
NavigationSuiteScaffold(
|
||||
navigationSuiteItems = {
|
||||
Destination.entries.forEach { item ->
|
||||
item(
|
||||
selected = destination == item,
|
||||
onClick = { destination = item; selectedTitleKey = null; browseKind = null },
|
||||
icon = { Icon(item.icon, contentDescription = item.label) },
|
||||
label = { Text(item.label) },
|
||||
colors = navigation,
|
||||
)
|
||||
}
|
||||
},
|
||||
) {
|
||||
Scaffold(
|
||||
snackbarHost = { SnackbarHost(snackbars) },
|
||||
// Each page draws its own bar under the status bar, and the title
|
||||
// page draws its backdrop there, so the top inset is theirs.
|
||||
contentWindowInsets = ScaffoldDefaults.contentWindowInsets
|
||||
.only(WindowInsetsSides.Horizontal + WindowInsetsSides.Bottom),
|
||||
) { padding ->
|
||||
// Back from a secondary tab returns to the library rather than
|
||||
// leaving the app; the library's own back is the system's, which is
|
||||
// what a top-level destination should do.
|
||||
BackHandler(enabled = destination != Destination.Library) {
|
||||
destination = Destination.Library
|
||||
}
|
||||
val minimizedHost = (playerHost as? NativeMpvHost)?.takeIf { playing != null && playerMinimized }
|
||||
// The mini transport floats over the page, so the page has to end
|
||||
// above it. Without this the last episode of a season sits under the
|
||||
// bar and cannot be scrolled clear of it.
|
||||
val direction = LocalLayoutDirection.current
|
||||
val body = if (minimizedHost == null) padding else PaddingValues(
|
||||
start = padding.calculateStartPadding(direction),
|
||||
end = padding.calculateEndPadding(direction),
|
||||
top = padding.calculateTopPadding(),
|
||||
bottom = padding.calculateBottomPadding() + MINI_TRANSPORT_INSET,
|
||||
)
|
||||
Box(Modifier.fillMaxSize()) {
|
||||
AnimatedContent(destination, label = "primary navigation") { target ->
|
||||
when (target) {
|
||||
// Side by side where there is room for both: on a tablet the
|
||||
// library stays in view while a title is open, so moving
|
||||
// between titles is one tap rather than back and in again.
|
||||
Destination.Library -> BoxWithConstraints(Modifier.fillMaxSize()) {
|
||||
val twoPane = maxWidth >= TWO_PANE_WIDTH
|
||||
val browsed by produceState(emptyList<String>(), browsing, state.titles) {
|
||||
value = browsing?.let { model.browse(it) }.orEmpty()
|
||||
}
|
||||
val libraryPane = @Composable {
|
||||
browsing?.let { facet ->
|
||||
BrowseScreen(
|
||||
facet,
|
||||
browsed.mapNotNull { key -> state.titles.firstOrNull { it.key == key } },
|
||||
onBack = {
|
||||
browseKind = null
|
||||
browseFrom?.let { selectedTitleKey = it }
|
||||
},
|
||||
onTitle = { selectedTitleKey = it.key },
|
||||
padding = body,
|
||||
)
|
||||
} ?: LibraryScreen(
|
||||
state,
|
||||
model::search,
|
||||
{ title, index ->
|
||||
// A search that led somewhere is one
|
||||
// worth offering again.
|
||||
model.rememberSearch()
|
||||
playingTitleKey = title.key
|
||||
playingIndex = index
|
||||
playerMinimized = false
|
||||
},
|
||||
{ model.rememberSearch(); selectedTitleKey = it.key },
|
||||
model::refresh,
|
||||
model::setTitleWatched,
|
||||
model::forgetPosition,
|
||||
body,
|
||||
onMatchChanged = model::reloadAfterMetadataChange,
|
||||
onError = model::reportError,
|
||||
onArrange = model::arrange,
|
||||
onAddShare = {
|
||||
destination = Destination.Shares
|
||||
addingShare = true
|
||||
},
|
||||
onOpenSettings = { destination = Destination.Settings },
|
||||
onFilter = model::filter,
|
||||
onSetOnList = model::setOnList,
|
||||
onBrowse = { facet ->
|
||||
model.rememberSearch()
|
||||
browse(facet)
|
||||
browseFrom = null
|
||||
},
|
||||
onForgetSearches = model::forgetSearches,
|
||||
)
|
||||
}
|
||||
val titlePane = @Composable { selected: TitleRecord ->
|
||||
val similar by produceState(emptyList<String>(), selected.key, state.titles) {
|
||||
value = model.moreLike(selected.key)
|
||||
}
|
||||
TitleScreen(
|
||||
selected,
|
||||
playerHost != null,
|
||||
{ _, index ->
|
||||
playingTitleKey = selected.key
|
||||
playingIndex = index
|
||||
playerMinimized = false
|
||||
},
|
||||
{ selectedTitleKey = null },
|
||||
model::reportError,
|
||||
model::reloadAfterMetadataChange,
|
||||
model::setWatched,
|
||||
body,
|
||||
onMarkEpisodes = model::markEpisodes,
|
||||
franchise = selected.franchise.mapNotNull { key ->
|
||||
state.titles.firstOrNull { it.key == key }
|
||||
},
|
||||
onOpenTitle = { selectedTitleKey = it.key },
|
||||
onSetOnList = model::setOnList,
|
||||
moreLike = similar.mapNotNull { key -> state.titles.firstOrNull { it.key == key } },
|
||||
onBrowse = { facet ->
|
||||
browse(facet)
|
||||
browseFrom = selected.key
|
||||
// On a phone the list is under the title;
|
||||
// the page it opens is the library pane's.
|
||||
if (!twoPane) selectedTitleKey = null
|
||||
},
|
||||
)
|
||||
}
|
||||
if (twoPane) {
|
||||
Row(Modifier.fillMaxSize()) {
|
||||
Box(Modifier.weight(0.45f)) { libraryPane() }
|
||||
VerticalDivider()
|
||||
Box(Modifier.weight(0.55f)) {
|
||||
selectedTitle?.let { titlePane(it) } ?: Box(Modifier.padding(body)) {
|
||||
EmptyState("Choose a title", "Its seasons and episodes open here.")
|
||||
}
|
||||
}
|
||||
}
|
||||
} else {
|
||||
SharedTransitionLayout {
|
||||
AnimatedContent(selectedTitle, contentKey = { it?.key }, label = "title") { open ->
|
||||
CompositionLocalProvider(
|
||||
LocalSharedTransition provides this@SharedTransitionLayout,
|
||||
LocalPaneVisibility provides this,
|
||||
) {
|
||||
open?.let { titlePane(it) } ?: libraryPane()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Destination.Shares -> SharesScreen(
|
||||
state.shares,
|
||||
{ name, url, password ->
|
||||
// The first share is the library: watch it fill
|
||||
// rather than stay on a form that has done its job.
|
||||
val first = state.titles.isEmpty()
|
||||
model.addShare(name, url, password).also { refusal ->
|
||||
if (refusal == null && first) destination = Destination.Library
|
||||
}
|
||||
},
|
||||
model::repairShare,
|
||||
model::refreshShare,
|
||||
model::removeShare,
|
||||
// The empty library's button opens the form as a
|
||||
// pasted link would, only with nothing in it.
|
||||
shareLink ?: "".takeIf { addingShare },
|
||||
{ addingShare = false; onShareLinkTaken() },
|
||||
body,
|
||||
account = state.account,
|
||||
accountActions = AccountActions(
|
||||
signIn = model::signIn,
|
||||
submitSecondFactor = model::submitSecondFactor,
|
||||
submitMailboxPassword = model::submitMailboxPassword,
|
||||
cancelSignIn = model::cancelSignIn,
|
||||
dismissVerification = model::dismissVerification,
|
||||
signOut = model::signOut,
|
||||
syncNow = model::syncNow,
|
||||
addFolder = model::addAccountFolder,
|
||||
onError = model::reportError,
|
||||
),
|
||||
onRename = model::renameShare,
|
||||
)
|
||||
Destination.History -> HistoryScreen(
|
||||
state,
|
||||
{ title, index ->
|
||||
playingTitleKey = title.key
|
||||
playingIndex = index
|
||||
playerMinimized = false
|
||||
},
|
||||
{ destination = Destination.Library; selectedTitleKey = it.key },
|
||||
model::removeFromHistory,
|
||||
body,
|
||||
)
|
||||
Destination.Downloads -> DownloadsScreen(
|
||||
state,
|
||||
model::removeOffline,
|
||||
model::pauseDownload,
|
||||
model::resumeDownload,
|
||||
model::deletePartial,
|
||||
body,
|
||||
onPlay = { title, index ->
|
||||
playingTitleKey = title.key
|
||||
playingIndex = index
|
||||
playerMinimized = false
|
||||
},
|
||||
onOpen = { title ->
|
||||
destination = Destination.Library
|
||||
selectedTitleKey = title.key
|
||||
},
|
||||
)
|
||||
Destination.Settings -> SettingsScreen(
|
||||
state,
|
||||
model::saveMetadataSettings,
|
||||
{ model.matchTitles(force = true) },
|
||||
model::clearBlockCache,
|
||||
model::removeAllOffline,
|
||||
body,
|
||||
onNamesChanged = model::reloadAfterMetadataChange,
|
||||
)
|
||||
}
|
||||
}
|
||||
minimizedHost?.let { core ->
|
||||
MiniTransport(
|
||||
host = core,
|
||||
onRestore = { playerMinimized = false },
|
||||
onClose = {
|
||||
playingTitleKey = null
|
||||
playerMinimized = false
|
||||
model.playerClosed()
|
||||
},
|
||||
modifier = Modifier
|
||||
.align(Alignment.BottomCenter)
|
||||
.padding(bottom = padding.calculateBottomPadding() + 8.dp),
|
||||
)
|
||||
}
|
||||
ActivityBanner(
|
||||
state.activity,
|
||||
onStop = model::stopCrawl.takeIf { state.crawling },
|
||||
modifier = Modifier
|
||||
.align(Alignment.BottomCenter)
|
||||
.padding(bottom = body.calculateBottomPadding() + 8.dp),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Drawn over the scaffold rather than instead of it. The player owns the
|
||||
// whole window while it is up — it is a screen, not a dialog over one, which
|
||||
// is what lets it draw into the cutout — but the page behind it stays
|
||||
// composed, so leaving the player finds the title page where it was rather
|
||||
// than rebuilt from the top.
|
||||
playing?.let { request ->
|
||||
PlayerScreen(
|
||||
title = request.title,
|
||||
episodes = request.episodes,
|
||||
index = request.index,
|
||||
host = playerHost,
|
||||
minimized = playerMinimized,
|
||||
onIndexChange = { playingIndex = it },
|
||||
onSaveProgress = model::saveProgress,
|
||||
onMinimize = { playerMinimized = true },
|
||||
onClose = {
|
||||
playingTitleKey = null
|
||||
playerMinimized = false
|
||||
model.playerClosed()
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The bar that says playback is still going, and gets back to it.
|
||||
*
|
||||
* Leaving the player does not stop it — the episode keeps playing, which is the
|
||||
* whole point of the background-audio setting — so there has to be something on
|
||||
* screen that says so and takes one tap to return to. Without it, the only way
|
||||
* back to a playing episode is to find it in the library again.
|
||||
*/
|
||||
@Composable
|
||||
private fun MiniTransport(
|
||||
host: NativeMpvHost,
|
||||
onRestore: () -> Unit,
|
||||
onClose: () -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
val state by host.state.collectAsState()
|
||||
val playing by host.nowPlaying.collectAsState()
|
||||
val episode = playing ?: return
|
||||
Card(modifier.fillMaxWidth().padding(horizontal = 8.dp).clickable(onClick = onRestore)) {
|
||||
Row(Modifier.padding(8.dp), verticalAlignment = Alignment.CenterVertically) {
|
||||
RemoteArtwork(
|
||||
episode.artworkUrl,
|
||||
episode.show ?: episode.title,
|
||||
Modifier.width(64.dp).aspectRatio(16f / 9f).clip(MaterialTheme.shapes.small),
|
||||
fallback = episode.artworkFile,
|
||||
)
|
||||
Column(Modifier.weight(1f).padding(horizontal = 12.dp)) {
|
||||
Text(episode.title, fontWeight = FontWeight.SemiBold, maxLines = 1, overflow = TextOverflow.Ellipsis)
|
||||
episode.show?.let {
|
||||
Text(it, style = MaterialTheme.typography.bodySmall, maxLines = 1, overflow = TextOverflow.Ellipsis)
|
||||
}
|
||||
}
|
||||
IconButton(onClick = { host.setPaused(!state.paused) }) {
|
||||
Icon(
|
||||
if (state.paused) Icons.Default.PlayArrow else Icons.Default.Pause,
|
||||
contentDescription = if (state.paused) "Play" else "Pause",
|
||||
)
|
||||
}
|
||||
IconButton(onClick = onClose) {
|
||||
Icon(Icons.Default.Close, contentDescription = "Stop playback")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* What the app is busy with, for as long as it is: a spinner and one line.
|
||||
* Not dismissible and not timed — it goes when the work does.
|
||||
*/
|
||||
@Composable
|
||||
private fun ActivityBanner(activity: String?, onStop: (() -> Unit)?, modifier: Modifier = Modifier) {
|
||||
AnimatedVisibility(
|
||||
visible = activity != null,
|
||||
modifier = modifier,
|
||||
enter = fadeIn() + slideInVertically { it },
|
||||
exit = fadeOut() + slideOutVertically { it },
|
||||
) {
|
||||
// Held through the exit, so the line does not blank before it fades.
|
||||
var shown by remember { mutableStateOf(activity.orEmpty()) }
|
||||
if (activity != null) shown = activity
|
||||
Row(
|
||||
Modifier
|
||||
.padding(horizontal = 12.dp)
|
||||
.fillMaxWidth()
|
||||
.background(MaterialTheme.colorScheme.surfaceContainerHighest, MaterialTheme.shapes.small)
|
||||
.padding(horizontal = 16.dp, vertical = 12.dp)
|
||||
.semantics { liveRegion = LiveRegionMode.Polite },
|
||||
horizontalArrangement = Arrangement.spacedBy(12.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
CircularProgressIndicator(Modifier.size(18.dp), strokeWidth = 2.dp)
|
||||
Text(shown, style = MaterialTheme.typography.bodyMedium, maxLines = 2, modifier = Modifier.weight(1f))
|
||||
// A crawl of a large share can take minutes, and is the viewer's
|
||||
// to call off; what it has read so far stays.
|
||||
onStop?.let { QuietButton(onClick = it) { Text("Stop") } }
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,567 @@
|
||||
package io.narl.protonstream.ui
|
||||
|
||||
import android.content.Context
|
||||
import android.graphics.Bitmap
|
||||
import android.graphics.BitmapFactory
|
||||
import android.util.LruCache
|
||||
import androidx.compose.foundation.Image
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.animation.Crossfade
|
||||
import androidx.compose.animation.animateColorAsState
|
||||
import androidx.compose.animation.core.LinearEasing
|
||||
import androidx.compose.animation.core.animateFloat
|
||||
import androidx.compose.animation.core.infiniteRepeatable
|
||||
import androidx.compose.animation.core.rememberInfiniteTransition
|
||||
import androidx.compose.animation.core.tween
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.BoxWithConstraints
|
||||
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.runtime.staticCompositionLocalOf
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.blur
|
||||
import androidx.compose.ui.draw.drawBehind
|
||||
import androidx.compose.ui.geometry.Offset
|
||||
import androidx.compose.ui.graphics.Brush
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.graphics.ImageBitmap
|
||||
import androidx.compose.ui.graphics.lerp
|
||||
import androidx.compose.ui.graphics.luminance
|
||||
import androidx.compose.ui.graphics.toArgb
|
||||
import androidx.compose.ui.graphics.asImageBitmap
|
||||
import androidx.compose.ui.layout.ContentScale
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.platform.LocalDensity
|
||||
import androidx.compose.ui.unit.Dp
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.core.graphics.scale
|
||||
import io.narl.protonstream.native.NativeRuntime
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.withContext
|
||||
import uniffi.pstr_android.EpisodeRecord
|
||||
import uniffi.pstr_android.TitleRecord
|
||||
import uniffi.pstr_android.moodVeil as sharedMoodVeil
|
||||
import uniffi.pstr_android.pictureMood as sharedPictureMood
|
||||
import java.net.HttpURLConnection
|
||||
import java.net.URI
|
||||
import java.io.ByteArrayOutputStream
|
||||
import java.io.File
|
||||
import java.io.InputStream
|
||||
import java.security.MessageDigest
|
||||
import java.util.Collections
|
||||
|
||||
/**
|
||||
* One file to pull Proton's own thumbnail from when no provider poster exists.
|
||||
*
|
||||
* Proton renders a thumbnail per file, so any episode of a show is a frame of
|
||||
* the right show — the desktop client picks the first for the same reason.
|
||||
*/
|
||||
data class ThumbnailSource(val shareId: String, val volumeId: String, val linkId: String) {
|
||||
val key: String get() = "$shareId/$linkId"
|
||||
}
|
||||
|
||||
/**
|
||||
* What the first episode of a title offers as a poster.
|
||||
*
|
||||
* Metadata lookups are off by default, so for a freshly crawled library this is
|
||||
* the only artwork there is.
|
||||
*/
|
||||
internal val TitleRecord.thumbnailSource: ThumbnailSource?
|
||||
get() = seasons.firstNotNullOfOrNull { season -> season.episodes.firstOrNull() }
|
||||
?.let { ThumbnailSource(it.shareId, it.volumeId, it.linkId) }
|
||||
|
||||
/** What one episode offers as a still. */
|
||||
internal val EpisodeRecord.thumbnailSource: ThumbnailSource
|
||||
get() = ThumbnailSource(shareId, volumeId, linkId)
|
||||
|
||||
/**
|
||||
* HTTPS-only artwork loader backed by the app-private cache directory.
|
||||
*
|
||||
* [fallback] is used only when there is no provider artwork or it fails to
|
||||
* load, which — with metadata off — is the common case rather than the rare
|
||||
* one. Until either answers, the box shimmers as a skeleton; when neither has
|
||||
* anything, it shows [ArtworkPlaceholder] rather than an empty card — without
|
||||
* initials when [labelled] is false, for art with the name already over it.
|
||||
*
|
||||
* Art that arrives fades in over the skeleton rather than replacing it in one
|
||||
* frame, which across a grid reads as flicker. Art already decoded is drawn
|
||||
* at once: a tile scrolled back into view has nothing to arrive.
|
||||
*/
|
||||
@Composable
|
||||
internal fun RemoteArtwork(
|
||||
url: String?,
|
||||
description: String,
|
||||
modifier: Modifier = Modifier,
|
||||
fallback: ThumbnailSource? = null,
|
||||
labelled: Boolean = true,
|
||||
maxEdge: Dp? = null,
|
||||
onMood: ((Color) -> Unit)? = null,
|
||||
) {
|
||||
val context = LocalContext.current
|
||||
val enabled = LocalArtworkLoading.current
|
||||
// Decoded at the size it is drawn: a grid tile is a few hundred pixels,
|
||||
// and decoding its poster at 1024 holds four times the memory for nothing.
|
||||
val edge = with(LocalDensity.current) { maxEdge?.roundToPx() }?.coerceIn(1, MAX_ART_EDGE) ?: MAX_ART_EDGE
|
||||
var image by remember(url, fallback) { mutableStateOf(alreadyDecoded(url, fallback, edge)) }
|
||||
LaunchedEffect(url, fallback, enabled) {
|
||||
if (!enabled) {
|
||||
image = ArtworkLoad.Missing
|
||||
return@LaunchedEffect
|
||||
}
|
||||
val bitmap = loadArtwork(context, url, edge) ?: loadThumbnail(fallback, edge)
|
||||
image = bitmap?.let { ArtworkLoad.Loaded(it.asImageBitmap()) } ?: ArtworkLoad.Missing
|
||||
if (bitmap != null && onMood != null) {
|
||||
moodOf(url ?: fallback?.key ?: return@LaunchedEffect, bitmap)?.let(onMood)
|
||||
}
|
||||
}
|
||||
Crossfade(image, modifier, animationSpec = tween(ARRIVE_MS), label = "artwork") { loaded ->
|
||||
when (loaded) {
|
||||
ArtworkLoad.Pending -> Skeleton(Modifier.fillMaxSize())
|
||||
ArtworkLoad.Missing -> ArtworkPlaceholder(description, Modifier.fillMaxSize(), labelled)
|
||||
is ArtworkLoad.Loaded -> Image(
|
||||
loaded.image,
|
||||
description,
|
||||
Modifier.fillMaxSize(),
|
||||
contentScale = ContentScale.Crop,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** What the decoded cache already holds for this art, so it skips the fade. */
|
||||
private fun alreadyDecoded(url: String?, fallback: ThumbnailSource?, edge: Int): ArtworkLoad {
|
||||
val hit = url?.let { decoded.get(sized(it, edge)) } ?: fallback?.let { decoded.get(sized(it.key, edge)) }
|
||||
return hit?.let { ArtworkLoad.Loaded(it.asImageBitmap()) } ?: ArtworkLoad.Pending
|
||||
}
|
||||
|
||||
/**
|
||||
* How long art takes to fade in over its skeleton: the desktop's
|
||||
* `motion::ARRIVE`.
|
||||
*/
|
||||
private const val ARRIVE_MS = 240
|
||||
|
||||
/** One pass of the skeleton's highlight: the desktop's `motion::SHIMMER`. */
|
||||
private const val SHIMMER_MS = 1_400
|
||||
|
||||
/**
|
||||
* A title's art across the top of a page: its backdrop, or with none, its
|
||||
* poster as a wash of its own colours — decoded tiny and blurred, so it reads as
|
||||
* the poster's light rather than a blown-up slice of it. The desktop's
|
||||
* `Fit::Ambient`. Android 11 and older cannot blur; there the tiny decode,
|
||||
* stretched, is what softens it.
|
||||
*/
|
||||
@Composable
|
||||
internal fun Backdrop(title: TitleRecord, modifier: Modifier = Modifier, onMood: ((Color) -> Unit)? = null) {
|
||||
val wash = title.backdropUrl == null && title.posterUrl != null
|
||||
RemoteArtwork(
|
||||
title.backdropUrl ?: title.posterUrl,
|
||||
title.displayName,
|
||||
if (wash) modifier.blur(WASH_BLUR) else modifier,
|
||||
fallback = title.thumbnailSource,
|
||||
labelled = false,
|
||||
maxEdge = if (wash) WASH_EDGE else null,
|
||||
onMood = onMood,
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* The colour a picture leaves behind — the shared `appearance::mood`, over the
|
||||
* picture squeezed to [MOOD_EDGE] pixels a side. Remembered per picture, so a
|
||||
* banner paging back to a title does not work it out again.
|
||||
*/
|
||||
private suspend fun moodOf(key: String, bitmap: Bitmap): Color? {
|
||||
moods.get(key)?.let { return Color(it) }
|
||||
val argb = withContext(Dispatchers.Default) {
|
||||
runCatching {
|
||||
val small = bitmap.scale(MOOD_EDGE, MOOD_EDGE)
|
||||
val pixels = IntArray(MOOD_EDGE * MOOD_EDGE)
|
||||
small.getPixels(pixels, 0, MOOD_EDGE, 0, 0, MOOD_EDGE, MOOD_EDGE)
|
||||
sharedPictureMood(pixels.map(Int::toUInt))?.toInt()
|
||||
}.getOrNull()
|
||||
} ?: return null
|
||||
moods.put(key, argb)
|
||||
return Color(argb)
|
||||
}
|
||||
|
||||
private val moods = LruCache<String, Int>(64)
|
||||
|
||||
/** A picture squeezed to this many pixels a side is still its colours, and a few hundred values to average. */
|
||||
private const val MOOD_EDGE = 24
|
||||
|
||||
/**
|
||||
* [background] tinted by a picture's [mood], for the veils over that picture —
|
||||
* the shared `appearance::mood_veil`. [background] itself until the mood is
|
||||
* known.
|
||||
*/
|
||||
@Composable
|
||||
internal fun moodVeil(mood: Color?, background: Color = MaterialTheme.colorScheme.background): Color {
|
||||
val light = background.luminance() > 0.5f
|
||||
val target = remember(mood, background) {
|
||||
mood?.let {
|
||||
runCatching {
|
||||
Color(sharedMoodVeil(background.toArgb().toUInt(), it.toArgb().toUInt(), light).toInt())
|
||||
}.getOrNull()
|
||||
} ?: background
|
||||
}
|
||||
return animateColorAsState(target, tween(ARRIVE_MS), label = "veil").value
|
||||
}
|
||||
|
||||
private val WASH_EDGE = 48.dp
|
||||
private val WASH_BLUR = 24.dp
|
||||
|
||||
/**
|
||||
* A title's logo — its name as lettering — in place of [fallback], once it has
|
||||
* loaded. [fallback] stands until then, and for good when there is none, so a
|
||||
* title without a logo looks as it always did and one with a logo only gains.
|
||||
*
|
||||
* Trimmed to what is not transparent before it is drawn: TVDB pads every clear
|
||||
* logo to a square, and the padding would otherwise decide its size and place.
|
||||
*/
|
||||
@Composable
|
||||
internal fun RemoteLogo(
|
||||
url: String?,
|
||||
description: String,
|
||||
modifier: Modifier = Modifier,
|
||||
fallback: @Composable () -> Unit,
|
||||
) {
|
||||
val context = LocalContext.current
|
||||
val enabled = LocalArtworkLoading.current
|
||||
var logo by remember(url) { mutableStateOf<ImageBitmap?>(null) }
|
||||
LaunchedEffect(url, enabled) {
|
||||
logo = if (enabled) loadLogo(context, url)?.asImageBitmap() else null
|
||||
}
|
||||
Crossfade(logo, animationSpec = tween(ARRIVE_MS), label = "logo") { loaded ->
|
||||
when (loaded) {
|
||||
null -> fallback()
|
||||
else -> Image(
|
||||
loaded,
|
||||
description,
|
||||
modifier,
|
||||
alignment = Alignment.BottomStart,
|
||||
contentScale = ContentScale.Fit,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun loadLogo(context: Context, url: String?): Bitmap? {
|
||||
val requested = url ?: return null
|
||||
val key = "logo:$requested"
|
||||
decoded.get(key)?.let { return it }
|
||||
val bitmap = loadArtwork(context, requested, LOGO_EDGE) ?: return null
|
||||
return withContext(Dispatchers.Default) { trimTransparent(bitmap) }.also { decoded.put(key, it) }
|
||||
}
|
||||
|
||||
/** [bitmap] cropped to [opaqueBounds], or itself when there is nothing to crop. */
|
||||
private fun trimTransparent(bitmap: Bitmap): Bitmap {
|
||||
if (!bitmap.hasAlpha()) return bitmap
|
||||
val pixels = IntArray(bitmap.width * bitmap.height)
|
||||
bitmap.getPixels(pixels, 0, bitmap.width, 0, 0, bitmap.width, bitmap.height)
|
||||
val (left, top, right, bottom) = opaqueBounds(pixels, bitmap.width) ?: return bitmap
|
||||
if (left == 0 && top == 0 && right == bitmap.width - 1 && bottom == bitmap.height - 1) return bitmap
|
||||
return Bitmap.createBitmap(bitmap, left, top, right - left + 1, bottom - top + 1)
|
||||
}
|
||||
|
||||
/**
|
||||
* The inclusive `[left, top, right, bottom]` of the pixels in an ARGB [pixels]
|
||||
* row-major image of [width] that are not (nearly) transparent; `null` when
|
||||
* none are. Below the threshold is anti-aliasing haze, not lettering.
|
||||
*/
|
||||
internal fun opaqueBounds(pixels: IntArray, width: Int): IntArray? {
|
||||
var left = Int.MAX_VALUE
|
||||
var top = Int.MAX_VALUE
|
||||
var right = -1
|
||||
var bottom = -1
|
||||
for (index in pixels.indices) {
|
||||
if ((pixels[index] ushr 24) <= VISIBLE_ALPHA) continue
|
||||
val x = index % width
|
||||
val y = index / width
|
||||
if (x < left) left = x
|
||||
if (x > right) right = x
|
||||
if (y < top) top = y
|
||||
if (y > bottom) bottom = y
|
||||
}
|
||||
return if (right < 0) null else intArrayOf(left, top, right, bottom)
|
||||
}
|
||||
|
||||
private const val VISIBLE_ALPHA = 8
|
||||
|
||||
/** A logo is drawn a few hundred pixels wide at most. */
|
||||
private const val LOGO_EDGE = 800
|
||||
|
||||
private sealed interface ArtworkLoad {
|
||||
data object Pending : ArtworkLoad
|
||||
data object Missing : ArtworkLoad
|
||||
data class Loaded(val image: ImageBitmap) : ArtworkLoad
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether [RemoteArtwork] fetches anything at all.
|
||||
*
|
||||
* Off in the screenshot tests: there is no bridge on the host, and whether a
|
||||
* failed fetch lands before or after the capture would decide between a
|
||||
* skeleton and a placeholder in the committed image.
|
||||
*/
|
||||
internal val LocalArtworkLoading = staticCompositionLocalOf { true }
|
||||
|
||||
/**
|
||||
* Something being loaded: the surface tint, with a soft highlight sweeping
|
||||
* across it on a diagonal.
|
||||
*
|
||||
* A spinner per tile is a grid of spinners; a shape where the poster will be
|
||||
* says what is coming and where, and settles the layout before it arrives.
|
||||
* The sweep is measured in the tile's own width, so neighbouring tiles of one
|
||||
* size shimmer in step, as one surface.
|
||||
*/
|
||||
@Composable
|
||||
internal fun Skeleton(modifier: Modifier = Modifier) {
|
||||
val sweep by rememberInfiniteTransition(label = "skeleton").animateFloat(
|
||||
initialValue = -1f,
|
||||
targetValue = 2f,
|
||||
animationSpec = infiniteRepeatable(tween(SHIMMER_MS, easing = LinearEasing)),
|
||||
label = "skeleton sweep",
|
||||
)
|
||||
val base = MaterialTheme.colorScheme.surfaceVariant
|
||||
val highlight = lerp(base, MaterialTheme.colorScheme.onSurface, 0.07f)
|
||||
Box(
|
||||
modifier.drawBehind {
|
||||
drawRect(base)
|
||||
val centre = size.width * sweep
|
||||
val half = size.width * 0.6f
|
||||
drawRect(
|
||||
Brush.linearGradient(
|
||||
listOf(Color.Transparent, highlight, Color.Transparent),
|
||||
start = Offset(centre - half, 0f),
|
||||
end = Offset(centre + half, size.height),
|
||||
),
|
||||
)
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Art for a title that has none: the accent as a gradient, and its initials.
|
||||
*
|
||||
* A black card with a play triangle on it said nothing about which title it
|
||||
* was, so a grid of unmatched titles was a grid of identical cards. Each name
|
||||
* sets where on the accent ramp its gradient starts, so neighbours differ
|
||||
* while every one stays in the theme's colours.
|
||||
*/
|
||||
@Composable
|
||||
internal fun ArtworkPlaceholder(name: String, modifier: Modifier = Modifier, labelled: Boolean = true) {
|
||||
val scheme = MaterialTheme.colorScheme
|
||||
val brush = remember(name, scheme.primary, scheme.secondary) {
|
||||
val start = lerp(scheme.primary, scheme.secondary, (name.hashCode().mod(5)) / 4f)
|
||||
Brush.linearGradient(listOf(start, lerp(start, Color.Black, 0.55f)))
|
||||
}
|
||||
BoxWithConstraints(modifier.background(brush), contentAlignment = Alignment.Center) {
|
||||
if (!labelled) return@BoxWithConstraints
|
||||
val size = with(LocalDensity.current) { (minOf(maxWidth, maxHeight) * 0.3f).toSp() }
|
||||
Text(
|
||||
initials(name),
|
||||
color = Color.White.copy(alpha = 0.9f),
|
||||
fontSize = size,
|
||||
fontWeight = FontWeight.SemiBold,
|
||||
maxLines = 1,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* "Oshi no Ko" is "OK", "Akira" is "A": the first letters of up to two
|
||||
* capitalised words, so particles and articles in a romanised name do not
|
||||
* take a slot. A name with no capitals uses its first two words.
|
||||
*/
|
||||
internal fun initials(name: String): String {
|
||||
val words = name.split(' ', '-', '_', '.', ':').filter { word -> word.firstOrNull()?.isLetterOrDigit() == true }
|
||||
val picked = words.filter { it.first().isUpperCase() || it.first().isDigit() }.ifEmpty { words }
|
||||
return picked.take(2).joinToString("") { it.first().uppercase() }
|
||||
}
|
||||
|
||||
/**
|
||||
* The same HTTPS-only fetch behind the library grid, reusable off the composition.
|
||||
*
|
||||
* The media notification needs a bitmap rather than an [ImageBitmap], and needs
|
||||
* it from a service with no composition of its own — but it wants exactly this
|
||||
* loader's protocol checks and exactly this disk cache, so the artwork the shade
|
||||
* shows costs no second download.
|
||||
*/
|
||||
internal suspend fun loadArtwork(context: Context, url: String?, edge: Int = MAX_ART_EDGE): Bitmap? = url?.let { requested ->
|
||||
decoded.get(sized(requested, edge)) ?: 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 fresh = !cached.isFile
|
||||
val bytes = if (fresh) download(uri) else cached.readBytes()
|
||||
val bitmap = requireNotNull(decodeSampled(bytes, edge)) { "Artwork is not a decodable image" }
|
||||
if (fresh) {
|
||||
// Written only once it has decoded. Caching the bytes first is
|
||||
// what turns one hostile poster into a crash on every launch
|
||||
// that survives until app data is cleared.
|
||||
val temporary = directory.resolve("$digest.part")
|
||||
temporary.writeBytes(bytes)
|
||||
check(temporary.renameTo(cached)) { "Unable to cache artwork" }
|
||||
prune(directory)
|
||||
} else {
|
||||
cached.setLastModified(System.currentTimeMillis())
|
||||
}
|
||||
bitmap
|
||||
}.getOrNull()?.also { decoded.put(sized(requested, edge), it) }
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Proton's own thumbnail for one file, decoded.
|
||||
*
|
||||
* The bridge holds the disk cache, so what is worth keeping here is the decoded
|
||||
* bitmap and the *absence* of one: a file with no thumbnail is common, and
|
||||
* without remembering that, every scroll back over the grid pays a round trip
|
||||
* per tile to be told so again. A failed call is not remembered — an unreachable
|
||||
* share or a session that has to be reopened is transient, and the next
|
||||
* composition should try again.
|
||||
*/
|
||||
internal suspend fun loadThumbnail(source: ThumbnailSource?, edge: Int = MAX_ART_EDGE): Bitmap? {
|
||||
val file = source ?: return null
|
||||
val key = file.key
|
||||
decoded.get(sized(key, edge))?.let { return it }
|
||||
if (key in missing) return null
|
||||
val fetched = withContext(Dispatchers.IO) {
|
||||
runCatching { NativeRuntime.engine().thumbnail(file.shareId, file.volumeId, file.linkId) }
|
||||
}
|
||||
val bytes = fetched.getOrElse { return null }
|
||||
val bitmap = bytes?.let { withContext(Dispatchers.Default) { decodeSampled(it, edge) } }
|
||||
if (bitmap == null) {
|
||||
missing.add(key)
|
||||
return null
|
||||
}
|
||||
decoded.put(sized(key, edge), bitmap)
|
||||
return bitmap
|
||||
}
|
||||
|
||||
private fun download(uri: URI): ByteArray {
|
||||
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"
|
||||
}
|
||||
return connection.inputStream.use(::readBounded)
|
||||
.also { require(it.size <= MAX_ART_BYTES) { "Artwork is too large" } }
|
||||
}
|
||||
|
||||
/**
|
||||
* Decode with its longest edge at most [edge] pixels.
|
||||
*
|
||||
* [MAX_ART_BYTES] bounds the *compressed* image, which says nothing about what
|
||||
* it costs decoded: a 12 MiB PNG can be 12000×12000, and that is a 576 MB
|
||||
* `ARGB_8888` allocation on a device that will not give it. The bounds pass
|
||||
* costs no allocation and settles a sample size before anything is committed.
|
||||
*
|
||||
* Sampling halves, so it stops while the image is still at least [edge]; the
|
||||
* decoder scales the rest of the way, which a poster sampled straight to under
|
||||
* its tile would show as blur.
|
||||
*/
|
||||
private fun decodeSampled(bytes: ByteArray, edge: Int): Bitmap? {
|
||||
val bounds = BitmapFactory.Options().apply { inJustDecodeBounds = true }
|
||||
BitmapFactory.decodeByteArray(bytes, 0, bytes.size, bounds)
|
||||
val longest = maxOf(bounds.outWidth, bounds.outHeight)
|
||||
if (longest <= 0) return null
|
||||
var sample = 1
|
||||
while (longest / (sample * 2) >= edge) sample *= 2
|
||||
val sampled = longest / sample
|
||||
return BitmapFactory.decodeByteArray(
|
||||
bytes,
|
||||
0,
|
||||
bytes.size,
|
||||
BitmapFactory.Options().apply {
|
||||
inSampleSize = sample
|
||||
if (sampled > edge) {
|
||||
inScaled = true
|
||||
inDensity = sampled
|
||||
inTargetDensity = edge
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
/** The decoded cache's key for one image at one size. */
|
||||
private fun sized(key: String, edge: Int) = "$key#$edge"
|
||||
|
||||
/**
|
||||
* Hold the cache directory under [MAX_ART_CACHE_BYTES], oldest first.
|
||||
*
|
||||
* `cacheDir` is reclaimable by the system, but only under storage pressure and
|
||||
* only wholesale — a poster cache that grows with every title ever browsed is
|
||||
* still the app's own doing.
|
||||
*/
|
||||
private fun prune(directory: File) {
|
||||
val files = directory.listFiles()?.sortedBy(File::lastModified) ?: return
|
||||
var total = files.sumOf(File::length)
|
||||
for (file in files) {
|
||||
if (total <= MAX_ART_CACHE_BYTES) return
|
||||
total -= file.length()
|
||||
file.delete()
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Files known to have no Proton thumbnail, so the answer is paid for once.
|
||||
*
|
||||
* Deliberately not persisted: a re-upload can add one, and the cost of being
|
||||
* wrong for a session is a placeholder that a restart clears.
|
||||
*/
|
||||
private val missing: MutableSet<String> = Collections.synchronizedSet(mutableSetOf())
|
||||
|
||||
/**
|
||||
* Decoded posters, so scrolling the grid re-reads neither disk nor decoder.
|
||||
*
|
||||
* Keyed by artwork URL or by `share/link` for a Proton thumbnail; the two
|
||||
* cannot collide, since one is always an `https:` URL.
|
||||
*
|
||||
* `remember(url)` is per-composition, and a `LazyVerticalGrid` drops and
|
||||
* recreates items as they leave the viewport — without this, every scroll back
|
||||
* is a full-resolution decode per item.
|
||||
*/
|
||||
private val decoded = object : LruCache<String, Bitmap>(
|
||||
(Runtime.getRuntime().maxMemory() / 8).coerceIn(4L * 1024 * 1024, 64L * 1024 * 1024).toInt(),
|
||||
) {
|
||||
override fun sizeOf(key: String, value: Bitmap): Int = value.byteCount
|
||||
}
|
||||
|
||||
private const val MAX_ART_BYTES = 12 * 1024 * 1024
|
||||
private const val MAX_ART_CACHE_BYTES = 48L * 1024 * 1024
|
||||
|
||||
/** Longest edge worth decoding: a poster on a phone, or a notification icon. */
|
||||
private const val MAX_ART_EDGE = 1_024
|
||||
|
||||
/** 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,671 @@
|
||||
package io.narl.protonstream.ui
|
||||
|
||||
import androidx.activity.compose.BackHandler
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.layout.ColumnScope
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.automirrored.filled.ArrowBack
|
||||
import androidx.compose.material.icons.automirrored.filled.ManageSearch
|
||||
import androidx.compose.material.icons.filled.Info
|
||||
import androidx.compose.material.icons.filled.Palette
|
||||
import androidx.compose.material.icons.filled.PlayCircle
|
||||
import androidx.compose.material.icons.filled.Storage
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.IconButton
|
||||
import androidx.compose.runtime.saveable.rememberSaveable
|
||||
import androidx.compose.ui.graphics.vector.ImageVector
|
||||
import androidx.compose.foundation.border
|
||||
import androidx.compose.foundation.background
|
||||
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.FlowRow
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
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.lazy.LazyColumn
|
||||
import androidx.compose.foundation.lazy.LazyRow
|
||||
import androidx.compose.foundation.shape.CircleShape
|
||||
import androidx.compose.foundation.lazy.items
|
||||
import androidx.compose.foundation.verticalScroll
|
||||
import androidx.compose.foundation.rememberScrollState
|
||||
import androidx.compose.foundation.lazy.grid.items
|
||||
import androidx.compose.material3.AlertDialog
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.OutlinedTextField
|
||||
import androidx.compose.material3.Switch
|
||||
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.rememberCoroutineScope
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
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.unit.dp
|
||||
import io.narl.protonstream.settings.SettingsStore
|
||||
import io.narl.protonstream.sync.NewEpisodesWorker
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.withContext
|
||||
import io.narl.protonstream.download.DownloadCoordinator
|
||||
import io.narl.protonstream.native.NativeRuntime
|
||||
import io.narl.protonstream.ui.theme.AccentButton
|
||||
import io.narl.protonstream.ui.theme.DangerButton
|
||||
import io.narl.protonstream.ui.theme.AppearanceState
|
||||
import io.narl.protonstream.ui.theme.EdgedButton
|
||||
import io.narl.protonstream.ui.theme.TonalButton
|
||||
import uniffi.pstr_android.MetadataProvider
|
||||
import uniffi.pstr_android.FlavorChoice
|
||||
import uniffi.pstr_android.AppearanceRecord
|
||||
import uniffi.pstr_android.TitleNamesChoice
|
||||
import uniffi.pstr_android.AccentChoice
|
||||
import uniffi.pstr_android.PlaybackPrefsRecord
|
||||
import androidx.compose.material3.ListItem
|
||||
import androidx.compose.material3.ListItemDefaults
|
||||
import androidx.compose.material3.FilterChip
|
||||
import androidx.compose.foundation.selection.toggleable
|
||||
import androidx.compose.ui.semantics.Role
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.height
|
||||
import io.narl.protonstream.ui.theme.AccentProgress
|
||||
|
||||
@Composable
|
||||
internal fun SettingsScreen(
|
||||
state: AppUiState,
|
||||
/** Resolves to why the settings were not stored, or null once they are. */
|
||||
onSaveMetadata: suspend (Boolean, MetadataProvider, String, String) -> String?,
|
||||
onMatchAgain: () -> Unit,
|
||||
onClearCache: () -> Unit,
|
||||
onRemoveAllOffline: () -> Unit,
|
||||
padding: PaddingValues,
|
||||
initialPage: SettingsPage? = null,
|
||||
// A parameter so the screenshot tests can pin it: read from the package,
|
||||
// every version bump changed the committed image of this page.
|
||||
version: String = appVersion(),
|
||||
/** The library's names changed, and it has to be read again to show them. */
|
||||
onNamesChanged: () -> Unit = {},
|
||||
) {
|
||||
val context = LocalContext.current
|
||||
val settings = remember { SettingsStore(context) }
|
||||
var wifiOnly by remember { mutableStateOf(settings.wifiOnly) }
|
||||
var backgroundAudio by remember { mutableStateOf(settings.backgroundAudio) }
|
||||
var newEpisodes by remember { mutableStateOf(settings.newEpisodeNotifications) }
|
||||
var hardwareDecoding by remember { mutableStateOf(settings.hardwareDecoding) }
|
||||
var cacheBudgetGib by remember { mutableStateOf(settings.cacheBudgetGib) }
|
||||
// Playback preferences are the shared store's, not this app's: they are the
|
||||
// same file the desktop client reads, so a language chosen on one is the
|
||||
// language the other starts in.
|
||||
var prefs by remember { mutableStateOf<PlaybackPrefsRecord?>(null) }
|
||||
val scope = rememberCoroutineScope()
|
||||
fun update(change: (PlaybackPrefsRecord) -> PlaybackPrefsRecord) {
|
||||
val next = change(prefs ?: return)
|
||||
prefs = next
|
||||
scope.launch(Dispatchers.IO) {
|
||||
runCatching { NativeRuntime.engine().setPlaybackPrefs(next) }
|
||||
}
|
||||
}
|
||||
LaunchedEffect(Unit) {
|
||||
runCatching { withContext(Dispatchers.IO) { NativeRuntime.engine().playbackPrefs() } }
|
||||
.onSuccess { prefs = it }
|
||||
}
|
||||
var confirmDelete by remember { mutableStateOf(false) }
|
||||
var showMetadata by remember { mutableStateOf(false) }
|
||||
var legalDocument by remember { mutableStateOf<LegalDocument?>(null) }
|
||||
// One row per area on the first page, each opening its own page: grouped
|
||||
// on one page, the storage controls sat a long scroll below playback, and
|
||||
// every setting added made the rest harder to find.
|
||||
var page by rememberSaveable { mutableStateOf(initialPage) }
|
||||
BackHandler(enabled = page != null) { page = null }
|
||||
val back: @Composable () -> Unit = {
|
||||
IconButton(onClick = { page = null }) {
|
||||
Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = "Back to settings")
|
||||
}
|
||||
}
|
||||
when (page) {
|
||||
null -> TabPage("Settings", padding) { body ->
|
||||
Column(Modifier.fillMaxSize().padding(body).verticalScroll(rememberScrollState())) {
|
||||
SettingsPage.entries.forEach { entry ->
|
||||
ListItem(
|
||||
headlineContent = { Text(entry.title) },
|
||||
supportingContent = {
|
||||
Text(
|
||||
when (entry) {
|
||||
SettingsPage.Playback -> "Autoplay, languages and decoding"
|
||||
SettingsPage.Storage -> "${formatBytes(state.storage.cacheBytes)} cached · " +
|
||||
"${state.storage.offlineCount} offline"
|
||||
SettingsPage.Appearance -> "Palette, accent and gradient"
|
||||
SettingsPage.Metadata -> if (state.metadataSettings.enabled) {
|
||||
"On · ${state.metadataSettings.provider.displayName()}"
|
||||
} else {
|
||||
"Off"
|
||||
}
|
||||
SettingsPage.About -> "Version $version · licences"
|
||||
},
|
||||
)
|
||||
},
|
||||
leadingContent = { Icon(entry.icon, contentDescription = null) },
|
||||
colors = ListItemDefaults.colors(containerColor = Color.Transparent),
|
||||
modifier = Modifier.clickable { page = entry },
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
SettingsPage.Playback -> SettingsSubPage(SettingsPage.Playback, padding, back) {
|
||||
prefs?.let { current ->
|
||||
SettingSwitch(
|
||||
"Autoplay next episode",
|
||||
"Starts the next episode when the credits begin",
|
||||
current.autoplayNext,
|
||||
) { on -> update { it.copy(autoplayNext = on) } }
|
||||
// Off is the safer default: a mis-named chapter then costs a tap on
|
||||
// Skip rather than a scene.
|
||||
SettingSwitch(
|
||||
"Skip openings and endings",
|
||||
"Uses the release's chapters; off shows a Skip button instead",
|
||||
current.autoSkip,
|
||||
) { on -> update { it.copy(autoSkip = on) } }
|
||||
SettingSwitch("Subtitles", "Shown by default where a file has them", current.subtitles) { on ->
|
||||
update { it.copy(subtitles = on) }
|
||||
}
|
||||
// Preferences, not filters: a file with nothing in the language
|
||||
// plays its own default track. A show that has been given its
|
||||
// own choice keeps it.
|
||||
LanguageSetting("Audio language", current.audioLanguage) { tag ->
|
||||
update { it.copy(audioLanguage = tag) }
|
||||
}
|
||||
LanguageSetting("Subtitle language", current.subtitleLanguage) { tag ->
|
||||
update { it.copy(subtitleLanguage = tag) }
|
||||
}
|
||||
}
|
||||
SettingSwitch(
|
||||
"Hardware decoding",
|
||||
"Turn off if video shows green or torn frames",
|
||||
hardwareDecoding,
|
||||
) {
|
||||
hardwareDecoding = it
|
||||
settings.hardwareDecoding = it
|
||||
}
|
||||
SettingSwitch(
|
||||
"Background audio",
|
||||
"Keeps playing with the app closed, from the notification",
|
||||
backgroundAudio,
|
||||
) {
|
||||
backgroundAudio = it
|
||||
settings.backgroundAudio = it
|
||||
}
|
||||
SettingSwitch(
|
||||
"New episode notifications",
|
||||
"Looks at your shares every few hours and says when a show gains episodes",
|
||||
newEpisodes,
|
||||
) {
|
||||
newEpisodes = it
|
||||
settings.newEpisodeNotifications = it
|
||||
NewEpisodesWorker.schedule(context)
|
||||
}
|
||||
}
|
||||
SettingsPage.Storage -> SettingsSubPage(SettingsPage.Storage, padding, back) {
|
||||
SettingSwitch("Download on Wi-Fi only", "Mobile data is never used for downloads", wifiOnly) {
|
||||
wifiOnly = it
|
||||
settings.wifiOnly = it
|
||||
// Constraints are baked in at enqueue time, so a queue that already
|
||||
// exists keeps the policy it was queued under until it is re-issued.
|
||||
DownloadCoordinator.applyNetworkPolicy(context)
|
||||
}
|
||||
Column(Modifier.padding(horizontal = 16.dp, vertical = 8.dp)) {
|
||||
Text("Streaming cache")
|
||||
Text(
|
||||
"${formatBytes(state.storage.cacheBytes)} of $cacheBudgetGib GiB used",
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
AccentProgress(
|
||||
progress = {
|
||||
(state.storage.cacheBytes.toDouble() / SettingsStore.gibToBytes(cacheBudgetGib).toDouble())
|
||||
.toFloat().coerceIn(0f, 1f)
|
||||
},
|
||||
modifier = Modifier.fillMaxWidth().padding(vertical = 8.dp),
|
||||
)
|
||||
// What has been watched is kept up to this much, so going back over
|
||||
// a scene costs no download. A lower choice frees the difference at
|
||||
// once. Wrapping, so five chips survive a narrow phone at a large
|
||||
// font scale.
|
||||
FlowRow(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||
SettingsStore.CACHE_BUDGET_CHOICES.forEach { gib ->
|
||||
FilterChip(
|
||||
selected = gib == cacheBudgetGib,
|
||||
onClick = {
|
||||
cacheBudgetGib = gib
|
||||
settings.cacheBudgetGib = gib
|
||||
scope.launch(Dispatchers.IO) {
|
||||
runCatching {
|
||||
NativeRuntime.engine().setStreamCacheBudget(SettingsStore.gibToBytes(gib))
|
||||
}
|
||||
}
|
||||
},
|
||||
label = { Text("$gib GiB") },
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
// The cache is rebuildable, so it goes without asking. Offline episodes
|
||||
// are a choice the viewer made, so that one asks.
|
||||
SettingAction("Clear streaming cache", "Frees ${formatBytes(state.storage.cacheBytes)}", onClick = onClearCache)
|
||||
SettingAction(
|
||||
"Delete all offline episodes",
|
||||
"${state.storage.offlineCount} " + (if (state.storage.offlineCount == 1uL) "episode" else "episodes") +
|
||||
" · ${formatBytes(state.storage.offlineBytes)}" +
|
||||
(
|
||||
state.storage.partialBytes.takeIf { it > 0uL }
|
||||
?.let { " · ${formatBytes(it)} unfinished" }
|
||||
.orEmpty()
|
||||
),
|
||||
enabled = state.storage.offlineCount > 0uL || state.storage.partialBytes > 0uL,
|
||||
onClick = { confirmDelete = true },
|
||||
)
|
||||
}
|
||||
SettingsPage.Appearance -> SettingsSubPage(SettingsPage.Appearance, padding, back) {
|
||||
Column(Modifier.padding(horizontal = 16.dp)) { AppearancePicker(onNamesChanged) }
|
||||
}
|
||||
SettingsPage.Metadata -> SettingsSubPage(SettingsPage.Metadata, padding, back) {
|
||||
SettingAction(
|
||||
"Metadata enrichment",
|
||||
if (state.metadataSettings.enabled) {
|
||||
"On · ${state.metadataSettings.provider.displayName()} · sends title names to it"
|
||||
} else {
|
||||
"Off, the privacy default"
|
||||
},
|
||||
onClick = { showMetadata = true },
|
||||
)
|
||||
// Every title looked up again, matched ones included: the way out of a
|
||||
// library the provider answered wrong, which otherwise stays wrong for
|
||||
// as long as the match is remembered.
|
||||
SettingAction(
|
||||
"Match everything again",
|
||||
"Looks every title up again, hand-picked matches excepted",
|
||||
enabled = state.metadataSettings.enabled && !state.refreshing,
|
||||
onClick = onMatchAgain,
|
||||
)
|
||||
}
|
||||
SettingsPage.About -> SettingsSubPage(SettingsPage.About, padding, back) {
|
||||
ListItem(
|
||||
headlineContent = { Text("Proton Stream") },
|
||||
supportingContent = { Text("Version $version") },
|
||||
colors = ListItemDefaults.colors(containerColor = Color.Transparent),
|
||||
)
|
||||
SettingAction("Licence", "GPL-3.0-or-later. Comes with absolutely no warranty.") {
|
||||
legalDocument = LegalDocument("GNU GPL v3", "licenses/GPL-3.0.txt")
|
||||
}
|
||||
SettingAction("Third-party notices", "libmpv, Inter and the other components bundled") {
|
||||
legalDocument = LegalDocument("Third-party notices", "licenses/THIRD_PARTY_NOTICES.md")
|
||||
}
|
||||
}
|
||||
}
|
||||
if (confirmDelete) {
|
||||
AlertDialog(
|
||||
onDismissRequest = { confirmDelete = false },
|
||||
title = { Text("Delete all offline episodes?") },
|
||||
text = {
|
||||
Text(
|
||||
"${state.storage.offlineCount} episodes (${formatBytes(state.storage.offlineBytes)}) " +
|
||||
"will be removed from this device. Watch history is kept, and anything " +
|
||||
"deleted can be downloaded again.",
|
||||
)
|
||||
},
|
||||
confirmButton = {
|
||||
DangerButton(onClick = { confirmDelete = false; onRemoveAllOffline() }) { Text("Delete") }
|
||||
},
|
||||
dismissButton = { TonalButton(onClick = { confirmDelete = false }) { Text("Cancel") } },
|
||||
)
|
||||
}
|
||||
legalDocument?.let { document ->
|
||||
LegalDocumentDialog(document, onDismiss = { legalDocument = null })
|
||||
}
|
||||
if (showMetadata) {
|
||||
MetadataSettingsDialog(
|
||||
current = state.metadataSettings,
|
||||
onDismiss = { showMetadata = false },
|
||||
onSave = onSaveMetadata,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/** The areas the first page lists, each a page of its own. */
|
||||
internal enum class SettingsPage(val title: String, val icon: ImageVector) {
|
||||
Playback("Playback", Icons.Default.PlayCircle),
|
||||
Storage("Downloads and storage", Icons.Default.Storage),
|
||||
Appearance("Appearance", Icons.Default.Palette),
|
||||
Metadata("Metadata", Icons.AutoMirrored.Filled.ManageSearch),
|
||||
About("About", Icons.Default.Info),
|
||||
}
|
||||
|
||||
/** One area's page: its name, a way back, and its rows. */
|
||||
@Composable
|
||||
private fun SettingsSubPage(
|
||||
page: SettingsPage,
|
||||
padding: PaddingValues,
|
||||
back: @Composable () -> Unit,
|
||||
rows: @Composable ColumnScope.() -> Unit,
|
||||
) {
|
||||
TabPage(page.title, padding, navigationIcon = back) { body ->
|
||||
Column(Modifier.fillMaxSize().padding(body).verticalScroll(rememberScrollState())) {
|
||||
rows()
|
||||
Spacer(Modifier.height(24.dp))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** The installed version, as the launcher and the store show it. */
|
||||
@Composable
|
||||
private fun appVersion(): String {
|
||||
val context = LocalContext.current
|
||||
return remember {
|
||||
runCatching { context.packageManager.getPackageInfo(context.packageName, 0).versionName }
|
||||
.getOrNull() ?: "unknown"
|
||||
}
|
||||
}
|
||||
|
||||
/** A switch with its one line of explanation; the whole row toggles it. */
|
||||
@Composable
|
||||
private fun SettingSwitch(headline: String, supporting: String, checked: Boolean, onChecked: (Boolean) -> Unit) {
|
||||
ListItem(
|
||||
headlineContent = { Text(headline) },
|
||||
supportingContent = { Text(supporting) },
|
||||
trailingContent = { Switch(checked = checked, onCheckedChange = null) },
|
||||
colors = ListItemDefaults.colors(containerColor = Color.Transparent),
|
||||
modifier = Modifier.toggleable(value = checked, role = Role.Switch, onValueChange = onChecked),
|
||||
)
|
||||
}
|
||||
|
||||
/** A row that does something when tapped: opens a dialog, clears a cache. */
|
||||
@Composable
|
||||
private fun SettingAction(
|
||||
headline: String,
|
||||
supporting: String,
|
||||
enabled: Boolean = true,
|
||||
onClick: () -> Unit,
|
||||
) {
|
||||
val colors = ListItemDefaults.colors(containerColor = Color.Transparent)
|
||||
ListItem(
|
||||
headlineContent = { Text(headline) },
|
||||
supportingContent = { Text(supporting) },
|
||||
colors = if (enabled) {
|
||||
colors
|
||||
} else {
|
||||
ListItemDefaults.colors(
|
||||
containerColor = Color.Transparent,
|
||||
headlineColor = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.38f),
|
||||
supportingColor = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.38f),
|
||||
)
|
||||
},
|
||||
modifier = Modifier.clickable(enabled = enabled, onClick = onClick),
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun MetadataSettingsDialog(
|
||||
current: uniffi.pstr_android.MetadataSettingsRecord,
|
||||
onDismiss: () -> Unit,
|
||||
onSave: suspend (Boolean, MetadataProvider, String, String) -> String?,
|
||||
) {
|
||||
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("") }
|
||||
var saving by remember { mutableStateOf(false) }
|
||||
var refusal by remember { mutableStateOf<String?>(null) }
|
||||
val scope = rememberCoroutineScope()
|
||||
val needsKey = enabled && provider == MetadataProvider.TMDB && !current.ready && apiKey.isBlank()
|
||||
val forgets = current.enabled && (!enabled || provider != current.provider)
|
||||
AlertDialog(
|
||||
onDismissRequest = { if (!saving) 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. With AniList, a title it has nothing for is then sent to TVmaze, and the matched ids go to ani.zip for episode names and wide artwork.")
|
||||
SettingToggle("Enable enrichment", enabled) { enabled = it }
|
||||
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||
listOf(MetadataProvider.ANI_LIST, MetadataProvider.TMDB).forEach { option ->
|
||||
if (option == provider) AccentButton(onClick = { provider = option }) { Text(option.displayName()) }
|
||||
else EdgedButton(onClick = { provider = option }) { Text(option.displayName()) }
|
||||
}
|
||||
}
|
||||
Text(
|
||||
if (provider == MetadataProvider.ANI_LIST) {
|
||||
"Anime, and television from TVmaze; no account or API key required. " +
|
||||
"With a TMDB API key, films get TMDB's backdrops and logos, asked for by id alone."
|
||||
} 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(
|
||||
when {
|
||||
provider == MetadataProvider.ANI_LIST -> "TMDB API key (optional)"
|
||||
current.ready -> "TMDB API key (leave blank to keep)"
|
||||
else -> "TMDB API key"
|
||||
},
|
||||
)
|
||||
},
|
||||
visualTransformation = PasswordVisualTransformation(),
|
||||
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Password),
|
||||
singleLine = true,
|
||||
)
|
||||
if (needsKey) Text("Enter a TMDB API key to save.", color = MaterialTheme.colorScheme.error)
|
||||
// Said before Save rather than after: what is forgotten
|
||||
// includes matches chosen by hand, which no look-up redoes.
|
||||
if (forgets) {
|
||||
Text(
|
||||
"Saving forgets every poster, description and match stored now, " +
|
||||
"including ones you chose by hand.",
|
||||
color = MaterialTheme.colorScheme.error,
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
)
|
||||
}
|
||||
refusal?.let { Text(it, color = MaterialTheme.colorScheme.error) }
|
||||
}
|
||||
},
|
||||
confirmButton = {
|
||||
AccentButton(
|
||||
enabled = !needsKey && !saving,
|
||||
onClick = {
|
||||
saving = true
|
||||
refusal = null
|
||||
scope.launch {
|
||||
refusal = onSave(enabled, provider, language.ifBlank { "en" }, apiKey)
|
||||
saving = false
|
||||
if (refusal == null) onDismiss()
|
||||
}
|
||||
},
|
||||
) { Text(if (saving) "Saving…" else "Save") }
|
||||
},
|
||||
dismissButton = { TonalButton(onClick = onDismiss, enabled = !saving) { Text("Cancel") } },
|
||||
)
|
||||
}
|
||||
|
||||
internal fun MetadataProvider.displayName(): String = when (this) {
|
||||
MetadataProvider.ANI_LIST -> "AniList"
|
||||
MetadataProvider.TMDB -> "TMDB"
|
||||
MetadataProvider.TV_MAZE -> "TVmaze"
|
||||
}
|
||||
|
||||
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 = { AccentButton(onClick = onDismiss) { Text("Close") } },
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
internal 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)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Flavour, accent and gradients — the same three the desktop client offers.
|
||||
*
|
||||
* Every colour is resolved by Rust, so a swatch here is the colour the app will
|
||||
* actually paint rather than an approximation of it, and the choice is stored in
|
||||
* the file both clients read.
|
||||
*/
|
||||
@Composable
|
||||
private fun AppearancePicker(onNamesChanged: () -> Unit) {
|
||||
val scope = rememberCoroutineScope()
|
||||
var choice by remember { mutableStateOf<AppearanceRecord?>(null) }
|
||||
var swatches by remember { mutableStateOf<Map<AccentChoice, Color>>(emptyMap()) }
|
||||
|
||||
suspend fun repaint(next: AppearanceRecord, store: Boolean) {
|
||||
runCatching {
|
||||
withContext(Dispatchers.IO) {
|
||||
val engine = NativeRuntime.engine()
|
||||
if (store) engine.setAppearance(next)
|
||||
val palette = engine.previewPalette(next)
|
||||
// Every accent as it would look in *this* flavour: a swatch row
|
||||
// that keeps Mocha's pastels while Latte is selected is a row
|
||||
// that lies about what the next tap does.
|
||||
val row = AccentChoice.entries.associateWith { accent ->
|
||||
Color(engine.previewPalette(next.copy(accent = accent)).accent.toInt())
|
||||
}
|
||||
palette to row
|
||||
}
|
||||
}.onSuccess { (palette, row) ->
|
||||
choice = next
|
||||
swatches = row
|
||||
AppearanceState.apply(palette)
|
||||
}
|
||||
}
|
||||
|
||||
LaunchedEffect(Unit) {
|
||||
runCatching { withContext(Dispatchers.IO) { NativeRuntime.engine().appearance() } }
|
||||
.onSuccess { repaint(it, store = false) }
|
||||
}
|
||||
|
||||
val current = choice ?: return
|
||||
Text("Palette", style = MaterialTheme.typography.bodyMedium, modifier = Modifier.padding(top = 8.dp))
|
||||
LazyRow(horizontalArrangement = Arrangement.spacedBy(8.dp), modifier = Modifier.padding(vertical = 8.dp)) {
|
||||
items(FlavorChoice.entries.toList(), key = { it.name }) { flavor ->
|
||||
val selected = flavor == current.flavor
|
||||
if (selected) {
|
||||
AccentButton(onClick = {}) { Text(flavor.label()) }
|
||||
} else {
|
||||
EdgedButton(onClick = {
|
||||
scope.launch { repaint(current.copy(flavor = flavor), store = true) }
|
||||
}) { Text(flavor.label()) }
|
||||
}
|
||||
}
|
||||
}
|
||||
Text("Accent", style = MaterialTheme.typography.bodyMedium)
|
||||
LazyRow(horizontalArrangement = Arrangement.spacedBy(10.dp), modifier = Modifier.padding(vertical = 8.dp)) {
|
||||
items(AccentChoice.entries.toList(), key = { it.name }) { accent ->
|
||||
val swatch = swatches[accent] ?: MaterialTheme.colorScheme.surfaceVariant
|
||||
Box(
|
||||
Modifier
|
||||
.size(36.dp)
|
||||
.clip(CircleShape)
|
||||
.background(swatch)
|
||||
.border(
|
||||
width = if (accent == current.accent) 3.dp else 1.dp,
|
||||
color = if (accent == current.accent) {
|
||||
MaterialTheme.colorScheme.onBackground
|
||||
} else {
|
||||
MaterialTheme.colorScheme.outline
|
||||
},
|
||||
shape = CircleShape,
|
||||
)
|
||||
.clickable {
|
||||
scope.launch { repaint(current.copy(accent = accent), store = true) }
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
Text("Title names", style = MaterialTheme.typography.bodyMedium)
|
||||
LazyRow(horizontalArrangement = Arrangement.spacedBy(8.dp), modifier = Modifier.padding(vertical = 8.dp)) {
|
||||
items(TitleNamesChoice.entries.toList(), key = { it.name }) { names ->
|
||||
if (names == current.names) {
|
||||
AccentButton(onClick = {}) { Text(names.label()) }
|
||||
} else {
|
||||
EdgedButton(onClick = {
|
||||
scope.launch {
|
||||
repaint(current.copy(names = names), store = true)
|
||||
onNamesChanged()
|
||||
}
|
||||
}) { Text(names.label()) }
|
||||
}
|
||||
}
|
||||
}
|
||||
Text(
|
||||
"What a matched title is called. Its other names still find it in search.",
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
modifier = Modifier.padding(bottom = 8.dp),
|
||||
)
|
||||
SettingToggle("Paint the accent as a gradient", current.gradients) { on ->
|
||||
scope.launch { repaint(current.copy(gradients = on), store = true) }
|
||||
}
|
||||
Text(
|
||||
"Off is the safer setting on a panel that bands: a slow ramp across a wide bar " +
|
||||
"shows every step it is drawn from, and flat is better than striped.",
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
modifier = Modifier.padding(bottom = 14.dp),
|
||||
)
|
||||
}
|
||||
|
||||
/** What each name setting is called. The desktop client says the same. */
|
||||
private fun TitleNamesChoice.label() = when (this) {
|
||||
TitleNamesChoice.LIBRARY -> "As in the share"
|
||||
TitleNamesChoice.ENGLISH -> "English"
|
||||
TitleNamesChoice.ROMAJI -> "Romaji"
|
||||
}
|
||||
|
||||
/** What each palette family is called. The desktop client says the same. */
|
||||
private fun FlavorChoice.label() = when (this) {
|
||||
FlavorChoice.PROTON -> "Proton"
|
||||
FlavorChoice.LATTE -> "Catppuccin Latte"
|
||||
FlavorChoice.FRAPPE -> "Catppuccin Frappé"
|
||||
FlavorChoice.MACCHIATO -> "Catppuccin Macchiato"
|
||||
FlavorChoice.MOCHA -> "Catppuccin Mocha"
|
||||
FlavorChoice.PERSONA5 -> "Persona 5"
|
||||
}
|
||||
@@ -0,0 +1,433 @@
|
||||
package io.narl.protonstream.ui
|
||||
|
||||
import kotlinx.coroutines.launch
|
||||
import androidx.compose.runtime.rememberCoroutineScope
|
||||
import androidx.compose.material3.CircularProgressIndicator
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.PaddingValues
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.lazy.LazyColumn
|
||||
import androidx.compose.foundation.lazy.items
|
||||
import androidx.compose.foundation.lazy.grid.items
|
||||
import androidx.compose.material3.AlertDialog
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.OutlinedTextField
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.IconButton
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.Refresh
|
||||
import androidx.compose.runtime.Composable
|
||||
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.text.font.FontWeight
|
||||
import androidx.compose.ui.text.input.PasswordVisualTransformation
|
||||
import androidx.compose.ui.text.input.VisualTransformation
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
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.unit.dp
|
||||
import io.narl.protonstream.ui.theme.AccentButton
|
||||
import io.narl.protonstream.ui.theme.DangerButton
|
||||
import io.narl.protonstream.ui.theme.TonalButton
|
||||
import androidx.compose.material.icons.filled.Edit
|
||||
import uniffi.pstr_android.AccountState
|
||||
import uniffi.pstr_android.ShareRecord
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.material3.ExtendedFloatingActionButton
|
||||
import androidx.compose.material3.ListItem
|
||||
import androidx.compose.material3.ListItemDefaults
|
||||
import androidx.compose.material3.DropdownMenu
|
||||
import androidx.compose.material3.DropdownMenuItem
|
||||
import androidx.compose.material.icons.filled.Add
|
||||
import androidx.compose.material.icons.filled.MoreVert
|
||||
import androidx.compose.material.icons.filled.Key
|
||||
import androidx.compose.material.icons.filled.Delete
|
||||
import androidx.compose.material.icons.filled.FolderShared
|
||||
import androidx.compose.material.icons.filled.Visibility
|
||||
import androidx.compose.material.icons.filled.VisibilityOff
|
||||
import androidx.compose.ui.graphics.Color
|
||||
|
||||
@Composable
|
||||
internal fun SharesScreen(
|
||||
shares: List<ShareRecord>,
|
||||
/** Resolves to why the link was refused, or null once it is added. */
|
||||
onAdd: suspend (String, String, String?) -> String?,
|
||||
onRepair: (String, String, String?) -> Unit,
|
||||
onRefresh: (String) -> Unit,
|
||||
onRemove: (String) -> Unit,
|
||||
incomingLink: String?,
|
||||
onIncomingLinkTaken: () -> Unit,
|
||||
padding: PaddingValues,
|
||||
account: AccountUiState = AccountUiState(),
|
||||
accountActions: AccountActions? = null,
|
||||
onRename: (String, String) -> Unit = { _, _ -> },
|
||||
) {
|
||||
var showAdd by remember { mutableStateOf(false) }
|
||||
var renaming by remember { mutableStateOf<ShareRecord?>(null) }
|
||||
var signingIn by remember { mutableStateOf(false) }
|
||||
var browsing by remember { mutableStateOf(false) }
|
||||
var repairing by remember { mutableStateOf<ShareRecord?>(null) }
|
||||
var removing by remember { mutableStateOf<ShareRecord?>(null) }
|
||||
TabPage("Shares", padding) { body ->
|
||||
Box(Modifier.fillMaxSize().padding(body)) {
|
||||
if (shares.isEmpty() && account.state == null) {
|
||||
EmptyState("No shares yet", "Add a Proton Drive public link to build your library.")
|
||||
} else {
|
||||
LazyColumn(contentPadding = PaddingValues(top = 8.dp, bottom = 96.dp)) {
|
||||
if (accountActions != null) {
|
||||
item(key = "account") {
|
||||
AccountCard(
|
||||
account,
|
||||
accountActions,
|
||||
onSignIn = { signingIn = true },
|
||||
onBrowse = { browsing = true },
|
||||
)
|
||||
}
|
||||
}
|
||||
if (shares.isEmpty()) {
|
||||
item(key = "empty") {
|
||||
Text(
|
||||
"No shares yet. Add a public link, or a folder of your Drive once signed in.",
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
modifier = Modifier.padding(16.dp),
|
||||
)
|
||||
}
|
||||
}
|
||||
items(shares, key = { it.id }) { share ->
|
||||
ShareRow(
|
||||
share = share,
|
||||
onRefresh = { onRefresh(share.id) },
|
||||
onRepair = { repairing = share },
|
||||
onRemove = { removing = share },
|
||||
onRename = { renaming = share },
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
// Where a thumb is, and the one thing this page is for. The share
|
||||
// sheet and a tapped link land in the same form.
|
||||
ExtendedFloatingActionButton(
|
||||
onClick = { showAdd = true },
|
||||
icon = { Icon(Icons.Default.Add, contentDescription = null) },
|
||||
text = { Text("Add share") },
|
||||
containerColor = MaterialTheme.colorScheme.primary,
|
||||
contentColor = MaterialTheme.colorScheme.onPrimary,
|
||||
modifier = Modifier.align(Alignment.BottomEnd).padding(16.dp),
|
||||
)
|
||||
}
|
||||
}
|
||||
if (showAdd || incomingLink != null) {
|
||||
AddShareDialog(
|
||||
initialUrl = incomingLink.orEmpty(),
|
||||
onDismiss = { showAdd = false; onIncomingLinkTaken() },
|
||||
onAdd = onAdd,
|
||||
)
|
||||
}
|
||||
if (accountActions != null && (signingIn || account.state == AccountState.SecondFactor ||
|
||||
account.state == AccountState.MailboxPassword)
|
||||
) {
|
||||
SignInDialog(account, accountActions, onDismiss = { signingIn = false })
|
||||
}
|
||||
if (accountActions != null && browsing) {
|
||||
DriveBrowserDialog(accountActions, onDismiss = { browsing = false })
|
||||
}
|
||||
repairing?.let { share ->
|
||||
RepairShareDialog(
|
||||
share = share,
|
||||
onDismiss = { repairing = null },
|
||||
onRepair = { url, password -> onRepair(share.id, url, password) },
|
||||
)
|
||||
}
|
||||
renaming?.let { share ->
|
||||
var name by remember(share.id) { mutableStateOf(share.name) }
|
||||
AlertDialog(
|
||||
onDismissRequest = { renaming = null },
|
||||
title = { Text("Rename ${share.name}") },
|
||||
text = { OutlinedTextField(name, { name = it }, label = { Text("Name") }, singleLine = true) },
|
||||
confirmButton = {
|
||||
AccentButton(enabled = name.isNotBlank(), onClick = {
|
||||
renaming = null
|
||||
onRename(share.id, name.trim())
|
||||
}) { Text("Save") }
|
||||
},
|
||||
dismissButton = { TonalButton(onClick = { renaming = null }) { Text("Cancel") } },
|
||||
)
|
||||
}
|
||||
removing?.let { share ->
|
||||
// Asked, because it cannot be undone: the link's secret and its
|
||||
// offline files go. Watch positions stay (`Catalog::remove_share`).
|
||||
AlertDialog(
|
||||
onDismissRequest = { removing = null },
|
||||
title = { Text("Remove ${share.name}?") },
|
||||
text = {
|
||||
Text(
|
||||
"Its titles leave the library and its offline episodes are deleted. " +
|
||||
"Watch progress is kept, so adding the link again picks up where you left off.",
|
||||
)
|
||||
},
|
||||
confirmButton = {
|
||||
DangerButton(onClick = { removing = null; onRemove(share.id) }) { Text("Remove") }
|
||||
},
|
||||
dismissButton = { TonalButton(onClick = { removing = null }) { Text("Cancel") } },
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* One share: its name, how it is unlocked, and its actions behind a menu.
|
||||
*
|
||||
* Three differently styled controls beside the name squeezed it into a column
|
||||
* a word wide; one icon leaves the row its width.
|
||||
*/
|
||||
/** "120 files · crawled 2 h ago", or that it has not been yet. */
|
||||
internal fun crawlLine(share: ShareRecord, now: Long = System.currentTimeMillis() / 1_000): String {
|
||||
val at = share.crawledAt ?: return "Not crawled yet"
|
||||
val files = share.crawlFiles?.toLong()?.let { if (it == 1L) "1 file" else "$it files" }
|
||||
return listOfNotNull(files, "crawled ${ago(at, now)}").joinToString(" · ")
|
||||
}
|
||||
|
||||
/** "just now", "5 min ago", "3 h ago", "2 days ago" — the desktop's `ui::ago`. */
|
||||
internal fun ago(at: Long, now: Long): String {
|
||||
val seconds = (now - at).coerceAtLeast(0)
|
||||
return when {
|
||||
seconds < 60 -> "just now"
|
||||
seconds < 3_600 -> "${seconds / 60} min ago"
|
||||
seconds < 86_400 -> "${seconds / 3_600} h ago"
|
||||
seconds < 172_800 -> "1 day ago"
|
||||
else -> "${seconds / 86_400} days ago"
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun ShareRow(
|
||||
share: ShareRecord,
|
||||
onRefresh: () -> Unit,
|
||||
onRepair: () -> Unit,
|
||||
onRemove: () -> Unit,
|
||||
onRename: () -> Unit = {},
|
||||
) {
|
||||
var menu by remember { mutableStateOf(false) }
|
||||
ListItem(
|
||||
headlineContent = { Text(share.name, fontWeight = FontWeight.SemiBold) },
|
||||
supportingContent = {
|
||||
Column {
|
||||
Text(
|
||||
when {
|
||||
share.fromAccount -> "Folder of your Drive"
|
||||
share.hasCustomPassword -> "Link and custom password"
|
||||
else -> "Public link"
|
||||
},
|
||||
)
|
||||
Text(crawlLine(share), style = MaterialTheme.typography.bodySmall)
|
||||
// Until a crawl succeeds: a link revoked upstream otherwise
|
||||
// just stops growing.
|
||||
share.crawlError?.let {
|
||||
Text(
|
||||
"Last crawl failed: $it",
|
||||
color = MaterialTheme.colorScheme.error,
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
maxLines = 2,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
)
|
||||
}
|
||||
}
|
||||
},
|
||||
leadingContent = {
|
||||
Icon(
|
||||
Icons.Default.FolderShared,
|
||||
contentDescription = null,
|
||||
tint = MaterialTheme.colorScheme.primary,
|
||||
)
|
||||
},
|
||||
trailingContent = {
|
||||
Box {
|
||||
IconButton(onClick = { menu = true }) {
|
||||
Icon(Icons.Default.MoreVert, contentDescription = "Actions for ${share.name}")
|
||||
}
|
||||
DropdownMenu(expanded = menu, onDismissRequest = { menu = false }) {
|
||||
// One share, not the library: a link that has just had files
|
||||
// added should not cost a walk of every other one.
|
||||
DropdownMenuItem(
|
||||
text = { Text("Refresh") },
|
||||
leadingIcon = { Icon(Icons.Default.Refresh, contentDescription = null) },
|
||||
onClick = { menu = false; onRefresh() },
|
||||
)
|
||||
DropdownMenuItem(
|
||||
text = { Text("Rename") },
|
||||
leadingIcon = { Icon(Icons.Default.Edit, contentDescription = null) },
|
||||
onClick = { menu = false; onRename() },
|
||||
)
|
||||
// Not a remove: a share whose secret has become unreadable
|
||||
// cannot be removed either, since removal deletes a secret
|
||||
// the store can no longer touch.
|
||||
if (!share.fromAccount) {
|
||||
DropdownMenuItem(
|
||||
text = { Text("Re-enter link") },
|
||||
leadingIcon = { Icon(Icons.Default.Key, contentDescription = null) },
|
||||
onClick = { menu = false; onRepair() },
|
||||
)
|
||||
}
|
||||
DropdownMenuItem(
|
||||
text = { Text("Remove") },
|
||||
leadingIcon = { Icon(Icons.Default.Delete, contentDescription = null) },
|
||||
onClick = { menu = false; onRemove() },
|
||||
)
|
||||
}
|
||||
}
|
||||
},
|
||||
colors = ListItemDefaults.colors(containerColor = Color.Transparent),
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Re-enter the link for a share the app can no longer decrypt its secret for.
|
||||
*
|
||||
* It has to be the *same* link — a different token is a different share, and
|
||||
* repointing this one at it would leave every catalog row and every offline file
|
||||
* describing something that is not there. Rust enforces that; this only says so.
|
||||
*/
|
||||
@Composable
|
||||
private fun RepairShareDialog(
|
||||
share: ShareRecord,
|
||||
onDismiss: () -> Unit,
|
||||
onRepair: (String, String?) -> Unit,
|
||||
) {
|
||||
var url by remember { mutableStateOf("") }
|
||||
var password by remember { mutableStateOf("") }
|
||||
AlertDialog(
|
||||
onDismissRequest = onDismiss,
|
||||
properties = DialogProperties(securePolicy = SecureFlagPolicy.SecureOn),
|
||||
title = { Text("Re-enter link for ${share.name}") },
|
||||
text = {
|
||||
Column(verticalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||
Text(
|
||||
"The stored credentials for this share cannot be read — usually after a " +
|
||||
"screen-lock change or a device restore. Entering the same link again " +
|
||||
"restores access without losing the library or anything downloaded.",
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
)
|
||||
OutlinedTextField(
|
||||
url,
|
||||
{ url = it },
|
||||
label = { Text("Public share URL") },
|
||||
singleLine = true,
|
||||
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 = {
|
||||
AccentButton(
|
||||
onClick = { onRepair(url.trim(), password); onDismiss() },
|
||||
enabled = url.isNotBlank(),
|
||||
) { Text("Restore") }
|
||||
},
|
||||
dismissButton = { TonalButton(onClick = onDismiss) { Text("Cancel") } },
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Add a link. The form stays up while the link is opened, and with the reason
|
||||
* under it when the link is refused, so fixing a typo is a retry rather than
|
||||
* starting over.
|
||||
*/
|
||||
@Composable
|
||||
private fun AddShareDialog(
|
||||
initialUrl: String,
|
||||
onDismiss: () -> Unit,
|
||||
onAdd: suspend (String, String, String?) -> String?,
|
||||
) {
|
||||
var name by remember { mutableStateOf("") }
|
||||
var url by remember(initialUrl) { mutableStateOf(initialUrl) }
|
||||
var showLink by remember { mutableStateOf(false) }
|
||||
var password by remember { mutableStateOf("") }
|
||||
var adding by remember { mutableStateOf(false) }
|
||||
var refusal by remember { mutableStateOf<String?>(null) }
|
||||
val scope = rememberCoroutineScope()
|
||||
AlertDialog(
|
||||
onDismissRequest = { if (!adding) onDismiss() },
|
||||
properties = DialogProperties(securePolicy = SecureFlagPolicy.SecureOn),
|
||||
title = { Text("Add Proton Drive share") },
|
||||
text = {
|
||||
Column(verticalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||
OutlinedTextField(
|
||||
url,
|
||||
{ url = it },
|
||||
label = { Text("Share link") },
|
||||
singleLine = true,
|
||||
// The URL fragment is a secret. Password input disables
|
||||
// IME learning/suggestions while normal long-press paste
|
||||
// remains available; the eye shows what was pasted.
|
||||
visualTransformation = if (showLink) VisualTransformation.None else PasswordVisualTransformation(),
|
||||
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Password),
|
||||
trailingIcon = {
|
||||
IconButton(onClick = { showLink = !showLink }) {
|
||||
Icon(
|
||||
if (showLink) Icons.Default.VisibilityOff else Icons.Default.Visibility,
|
||||
contentDescription = if (showLink) "Hide link" else "Show link",
|
||||
)
|
||||
}
|
||||
},
|
||||
)
|
||||
OutlinedTextField(
|
||||
name,
|
||||
{ name = it },
|
||||
label = { Text("Name (optional)") },
|
||||
supportingText = { Text("Defaults to the shared folder's name") },
|
||||
singleLine = true,
|
||||
)
|
||||
OutlinedTextField(
|
||||
password,
|
||||
{ password = it },
|
||||
label = { Text("Custom password (optional)") },
|
||||
singleLine = true,
|
||||
visualTransformation = PasswordVisualTransformation(),
|
||||
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Password),
|
||||
)
|
||||
if (adding) {
|
||||
Row(horizontalArrangement = Arrangement.spacedBy(12.dp), verticalAlignment = Alignment.CenterVertically) {
|
||||
CircularProgressIndicator(Modifier.size(18.dp), strokeWidth = 2.dp)
|
||||
Text("Opening the link…")
|
||||
}
|
||||
} else {
|
||||
refusal?.let { Text(it, color = MaterialTheme.colorScheme.error) }
|
||||
}
|
||||
}
|
||||
},
|
||||
confirmButton = {
|
||||
AccentButton(
|
||||
onClick = {
|
||||
adding = true
|
||||
refusal = null
|
||||
scope.launch {
|
||||
refusal = onAdd(name.trim(), url.trim(), password)
|
||||
adding = false
|
||||
if (refusal == null) onDismiss()
|
||||
}
|
||||
},
|
||||
enabled = url.isNotBlank() && !adding,
|
||||
) { Text(if (adding) "Adding…" else "Add") }
|
||||
},
|
||||
dismissButton = { TonalButton(onClick = onDismiss, enabled = !adding) { Text("Cancel") } },
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,274 @@
|
||||
package io.narl.protonstream.ui.theme
|
||||
|
||||
import androidx.compose.foundation.Canvas
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.RowScope
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.material3.Button
|
||||
import androidx.compose.material3.ButtonDefaults
|
||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||
import androidx.compose.material3.FilledTonalButton
|
||||
import androidx.compose.material3.LocalMinimumInteractiveComponentSize
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.NavigationBarItemDefaults
|
||||
import androidx.compose.material3.NavigationDrawerItemDefaults
|
||||
import androidx.compose.material3.NavigationRailItemDefaults
|
||||
import androidx.compose.material3.OutlinedButton
|
||||
import androidx.compose.material3.adaptive.navigationsuite.NavigationSuiteDefaults
|
||||
import androidx.compose.material3.adaptive.navigationsuite.NavigationSuiteItemColors
|
||||
import androidx.compose.material3.SliderState
|
||||
import androidx.compose.material3.TextButton
|
||||
import androidx.compose.material3.minimumInteractiveComponentSize
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.CompositionLocalProvider
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.geometry.CornerRadius
|
||||
import androidx.compose.ui.geometry.Size
|
||||
import androidx.compose.ui.graphics.Brush
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.graphics.SolidColor
|
||||
import androidx.compose.ui.unit.Dp
|
||||
import androidx.compose.ui.unit.dp
|
||||
|
||||
/**
|
||||
* The accent, as a gradient when there is one to draw.
|
||||
*
|
||||
* The desktop paints the accent as a two-hue ramp on everything that is a
|
||||
* control — the seek bar, the primary buttons, the tile progress bar — and the
|
||||
* viewer's "paint the accent as a gradient" toggle is what turns it off. That
|
||||
* toggle was already stored, already offered on this screen, and drawn nowhere
|
||||
* on Android, so the same stored setting meant two different things depending
|
||||
* on which client read it.
|
||||
*
|
||||
* Nothing here reads the toggle. It does not need to: when gradients are off,
|
||||
* `Palette::resolve` sets `accent_alt` equal to `accent`, so the ramp collapses
|
||||
* to a flat fill on its own. `primary` and `secondary` are those two colours —
|
||||
* see [schemeOf] — which is why this asks Material for them rather than
|
||||
* reaching past it for the palette.
|
||||
*/
|
||||
@Composable
|
||||
internal fun accentBrush(): Brush {
|
||||
val scheme = MaterialTheme.colorScheme
|
||||
return remember(scheme.primary, scheme.secondary) {
|
||||
Brush.horizontalGradient(listOf(scheme.primary, scheme.secondary))
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The primary button: the accent ramp, at the shared corner radius.
|
||||
*
|
||||
* Material's filled button has its own shape token (`CornerFull`) that does not
|
||||
* read `MaterialTheme.shapes`, so the radius has to be handed to it — which is
|
||||
* the whole reason the plain buttons below exist as well. They add nothing but
|
||||
* that.
|
||||
*/
|
||||
@Composable
|
||||
internal fun AccentButton(
|
||||
onClick: () -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
enabled: Boolean = true,
|
||||
content: @Composable RowScope.() -> Unit,
|
||||
) {
|
||||
val shape = MaterialTheme.shapes.small
|
||||
// The button pads itself out to the 48dp minimum touch target, and a
|
||||
// background on its modifier fills that whole target — 8dp taller than
|
||||
// the 40dp container every other button draws. So the padding is taken
|
||||
// here, outside the ramp, and the button inside is told not to add its
|
||||
// own. The box hands its size down, so a `fillMaxWidth` still reaches it.
|
||||
Box(
|
||||
modifier.minimumInteractiveComponentSize(),
|
||||
contentAlignment = Alignment.Center,
|
||||
propagateMinConstraints = true,
|
||||
) {
|
||||
CompositionLocalProvider(LocalMinimumInteractiveComponentSize provides Dp.Unspecified) {
|
||||
Button(
|
||||
onClick = onClick,
|
||||
// Only while it is live: a disabled button keeps Material's flat
|
||||
// disabled container, because a greyed control that still wears the
|
||||
// one strong colour in the window reads as pressable.
|
||||
modifier = if (enabled) Modifier.background(accentBrush(), shape) else Modifier,
|
||||
enabled = enabled,
|
||||
shape = shape,
|
||||
colors = ButtonDefaults.buttonColors(containerColor = Color.Transparent),
|
||||
content = content,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
internal fun TonalButton(
|
||||
onClick: () -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
enabled: Boolean = true,
|
||||
content: @Composable RowScope.() -> Unit,
|
||||
) = FilledTonalButton(
|
||||
onClick = onClick,
|
||||
modifier = modifier,
|
||||
enabled = enabled,
|
||||
shape = MaterialTheme.shapes.small,
|
||||
content = content,
|
||||
)
|
||||
|
||||
@Composable
|
||||
internal fun EdgedButton(
|
||||
onClick: () -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
enabled: Boolean = true,
|
||||
content: @Composable RowScope.() -> Unit,
|
||||
) = OutlinedButton(
|
||||
onClick = onClick,
|
||||
modifier = modifier,
|
||||
enabled = enabled,
|
||||
shape = MaterialTheme.shapes.small,
|
||||
content = content,
|
||||
)
|
||||
|
||||
@Composable
|
||||
internal fun QuietButton(
|
||||
onClick: () -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
enabled: Boolean = true,
|
||||
content: @Composable RowScope.() -> Unit,
|
||||
) = TextButton(
|
||||
onClick = onClick,
|
||||
modifier = modifier,
|
||||
enabled = enabled,
|
||||
shape = MaterialTheme.shapes.small,
|
||||
content = content,
|
||||
)
|
||||
|
||||
/**
|
||||
* The yes of a confirmation that deletes or forgets something.
|
||||
*
|
||||
* Not [AccentButton]: the filled accent is this app's "go on", and a dialog
|
||||
* whose most inviting button is the one that loses something reads as the
|
||||
* safe choice. Error-toned text instead, as Material's own guidance has it.
|
||||
*/
|
||||
@Composable
|
||||
internal fun DangerButton(
|
||||
onClick: () -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
enabled: Boolean = true,
|
||||
content: @Composable RowScope.() -> Unit,
|
||||
) = TextButton(
|
||||
onClick = onClick,
|
||||
modifier = modifier,
|
||||
enabled = enabled,
|
||||
shape = MaterialTheme.shapes.small,
|
||||
colors = ButtonDefaults.textButtonColors(contentColor = MaterialTheme.colorScheme.error),
|
||||
content = content,
|
||||
)
|
||||
|
||||
/**
|
||||
* How far along something is, drawn in the accent.
|
||||
*
|
||||
* Hand-drawn rather than `LinearProgressIndicator` because that one takes a
|
||||
* `Color` and not a `Brush`, and because Material 1.4 draws a gap and a stop
|
||||
* indicator either side of the bar — detail at a size where the desktop draws a
|
||||
* plain bar. [fill] is a parameter for the one bar that is deliberately not the
|
||||
* accent: a download in flight sits directly under the watch-progress bar of
|
||||
* the same row, and two ramps in the same colour stacked two pixels apart say
|
||||
* nothing.
|
||||
*/
|
||||
@Composable
|
||||
internal fun AccentProgress(
|
||||
progress: () -> Float,
|
||||
modifier: Modifier = Modifier,
|
||||
fill: Brush? = null,
|
||||
) {
|
||||
val brush = fill ?: accentBrush()
|
||||
val track = MaterialTheme.colorScheme.surfaceContainerHighest
|
||||
Canvas(modifier.fillMaxWidth().height(PROGRESS_HEIGHT.dp)) {
|
||||
val radius = CornerRadius(size.height / 2)
|
||||
drawRoundRect(color = track, cornerRadius = radius)
|
||||
val done = size.width * progress().coerceIn(0f, 1f)
|
||||
// Below one bar's width there is nothing to round, and a rounded rect
|
||||
// narrower than its own corners draws as a wrong-looking pill.
|
||||
if (done >= size.height) {
|
||||
drawRoundRect(brush = brush, size = Size(done, size.height), cornerRadius = radius)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The seek bar's track, in the accent ramp.
|
||||
*
|
||||
* Passed to `Slider`'s `track` slot, so the thumb, the gestures and the
|
||||
* accessibility semantics stay Material's — this replaces the two rectangles it
|
||||
* would have drawn and nothing else.
|
||||
*
|
||||
* The spent side is the accent and the rest is white at a third: the seek bar
|
||||
* is the one accent surface that sits over arbitrary film footage, where a
|
||||
* palette colour for the *unplayed* remainder would be a coloured line over a
|
||||
* picture rather than the absence of one.
|
||||
*/
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
internal fun AccentTrack(state: SliderState, modifier: Modifier = Modifier, buffered: Float = 0f) {
|
||||
val brush = accentBrush()
|
||||
Canvas(modifier.fillMaxWidth().height(TRACK_HEIGHT.dp)) {
|
||||
val radius = CornerRadius(size.height / 2)
|
||||
drawRoundRect(color = Color.White.copy(alpha = 0.30f), cornerRadius = radius)
|
||||
// What is already read ahead, lighter than the rest and under the
|
||||
// played part: a jump that lands in it costs no fetch.
|
||||
val ahead = size.width * buffered.coerceIn(0f, 1f)
|
||||
if (ahead >= size.height) {
|
||||
drawRoundRect(
|
||||
color = Color.White.copy(alpha = 0.55f),
|
||||
size = Size(ahead, size.height),
|
||||
cornerRadius = radius,
|
||||
)
|
||||
}
|
||||
val done = size.width * state.coercedValueAsFraction
|
||||
if (done >= size.height) {
|
||||
drawRoundRect(brush = brush, size = Size(done, size.height), cornerRadius = radius)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The navigation pill, in the accent.
|
||||
*
|
||||
* Solid rather than a ramp, and this is the one place the two clients cannot
|
||||
* agree exactly: `NavigationSuiteScaffold` takes the indicator as a `Color`,
|
||||
* with no slot to draw into, so a gradient here would mean reimplementing the
|
||||
* bar. The accent itself is still the right answer — the desktop's rule is that
|
||||
* the accent marks the thing you are on — and it is nearer to that than
|
||||
* `secondaryContainer`, which is the accent taken most of the way back to the
|
||||
* page.
|
||||
*/
|
||||
@Composable
|
||||
internal fun accentNavigationColors(): NavigationSuiteItemColors {
|
||||
val scheme = MaterialTheme.colorScheme
|
||||
return NavigationSuiteDefaults.itemColors(
|
||||
navigationBarItemColors = NavigationBarItemDefaults.colors(
|
||||
selectedIconColor = scheme.onPrimary,
|
||||
selectedTextColor = scheme.onSurface,
|
||||
indicatorColor = scheme.primary,
|
||||
),
|
||||
navigationRailItemColors = NavigationRailItemDefaults.colors(
|
||||
selectedIconColor = scheme.onPrimary,
|
||||
selectedTextColor = scheme.onSurface,
|
||||
indicatorColor = scheme.primary,
|
||||
),
|
||||
navigationDrawerItemColors = NavigationDrawerItemDefaults.colors(
|
||||
selectedContainerColor = scheme.primary,
|
||||
selectedIconColor = scheme.onPrimary,
|
||||
selectedTextColor = scheme.onPrimary,
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
/** A solid fill, for the callers of [AccentProgress] that want one colour. */
|
||||
internal fun solid(color: Color): Brush = SolidColor(color)
|
||||
|
||||
/** Thin enough to sit under a row without becoming part of it. */
|
||||
private const val PROGRESS_HEIGHT = 4f
|
||||
|
||||
/** The seek bar is dragged with a finger, so it is the thicker of the two. */
|
||||
private const val TRACK_HEIGHT = 6f
|
||||
@@ -0,0 +1,36 @@
|
||||
package io.narl.protonstream.ui.theme
|
||||
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material3.Shapes
|
||||
import androidx.compose.ui.unit.dp
|
||||
|
||||
/**
|
||||
* One corner radius, which is the desktop client's corner radius.
|
||||
*
|
||||
* `pstr-app` rounds every surface it paints to 8 — cards, buttons, the tab
|
||||
* pill, the progress bars — and only a badge over artwork is tighter, at 4.
|
||||
* Material's default ramp runs 4 / 8 / 12 / 16 / 28, so a stock Compose screen
|
||||
* puts a 28dp bottom sheet next to a 12dp card next to a fully-round button,
|
||||
* and none of those is the shape the other client draws.
|
||||
*
|
||||
* Flat rather than a ramp, therefore, with the two ends kept for the two cases
|
||||
* where a single radius is wrong: `extraSmall` for something small enough that
|
||||
* 8 would eat it, and `extraLarge` for a sheet whose corners meet the edge of
|
||||
* the screen and need to read as a lifted sheet rather than a seam.
|
||||
*/
|
||||
internal val ProtonShapes = Shapes(
|
||||
extraSmall = RoundedCornerShape(CORNER_TIGHT.dp),
|
||||
small = RoundedCornerShape(CORNER.dp),
|
||||
medium = RoundedCornerShape(CORNER.dp),
|
||||
large = RoundedCornerShape(CORNER.dp),
|
||||
extraLarge = RoundedCornerShape(CORNER_SHEET.dp),
|
||||
)
|
||||
|
||||
/** The radius `pstr-app` draws everything at. */
|
||||
internal const val CORNER = 8f
|
||||
|
||||
/** A badge over artwork — `theme::radius::BADGE` in pstr-app is the other place this number lives. */
|
||||
internal const val CORNER_TIGHT = 4f
|
||||
|
||||
/** A bottom sheet, which has no desktop counterpart. */
|
||||
internal const val CORNER_SHEET = 16f
|
||||
@@ -0,0 +1,222 @@
|
||||
package io.narl.protonstream.ui.theme
|
||||
|
||||
import androidx.compose.material3.ColorScheme
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.darkColorScheme
|
||||
import androidx.compose.material3.lightColorScheme
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.collectAsState
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import io.narl.protonstream.native.NativeRuntime
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.asStateFlow
|
||||
import kotlinx.coroutines.withContext
|
||||
import uniffi.pstr_android.PaletteRecord
|
||||
|
||||
/**
|
||||
* The palette in force, shared across every composition.
|
||||
*
|
||||
* The choice is stored by Rust and resolved by Rust — the same flavours, the
|
||||
* same accent pairings and the same contrast rule the desktop client uses — so
|
||||
* what lives here is only the resolved answer and a way to replace it when the
|
||||
* viewer picks something else.
|
||||
*/
|
||||
object AppearanceState {
|
||||
private val current = MutableStateFlow<PaletteRecord?>(null)
|
||||
val palette: StateFlow<PaletteRecord?> = current.asStateFlow()
|
||||
|
||||
/**
|
||||
* The palette read before the first frame, from
|
||||
* [io.narl.protonstream.native.NativeRuntime.storedPalette].
|
||||
*
|
||||
* Separate from [load] because it runs on the main thread during
|
||||
* `onCreate`: it is the only read that is early enough to decide what the
|
||||
* first composition is painted in, and it is cheap enough to be allowed
|
||||
* there.
|
||||
*/
|
||||
fun seed(palette: PaletteRecord) {
|
||||
current.value = palette
|
||||
}
|
||||
|
||||
/**
|
||||
* Read the stored choice through the engine. Safe to call repeatedly; the
|
||||
* last read wins.
|
||||
*
|
||||
* A no-op once [seed] has run — it resolves the same file through the same
|
||||
* Rust, so the only thing a second read can do is build the engine earlier
|
||||
* than the first screen needed it.
|
||||
*/
|
||||
suspend fun load() {
|
||||
if (current.value != null) return
|
||||
runCatching { withContext(Dispatchers.IO) { NativeRuntime.engine().palette() } }
|
||||
.onSuccess { current.value = it }
|
||||
}
|
||||
|
||||
/** Read the stored choice again, after sync rewrote it. */
|
||||
suspend fun reload() {
|
||||
runCatching { withContext(Dispatchers.IO) { NativeRuntime.engine().palette() } }
|
||||
.onSuccess { current.value = it }
|
||||
}
|
||||
|
||||
/** Repaint immediately, before the write that stores the choice lands. */
|
||||
fun apply(palette: PaletteRecord) {
|
||||
current.value = palette
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun ProtonStreamTheme(content: @Composable () -> Unit) {
|
||||
val palette by AppearanceState.palette.collectAsState()
|
||||
// Before the first read, and if it fails, the palette this app shipped
|
||||
// with — a theme file that cannot be read must never be a reason the
|
||||
// library does not open.
|
||||
LaunchedEffect(Unit) { AppearanceState.load() }
|
||||
MaterialTheme(
|
||||
colorScheme = schemeOf(palette ?: ProtonFallback),
|
||||
// The other two thirds of what the clients share. Colour was already
|
||||
// one resolved answer for both; type and shape were Material's defaults
|
||||
// here and named constants there, which is most of why the two read as
|
||||
// different apps even where the colours agreed.
|
||||
typography = ProtonTypography,
|
||||
shapes = ProtonShapes,
|
||||
content = content,
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* One resolved palette as Material's roles — **all** of them.
|
||||
*
|
||||
* Every role Material names has to be answered here, because a role left out
|
||||
* does not fall back to something neutral: it keeps the value
|
||||
* `darkColorScheme()` / `lightColorScheme()` gave it, which is Material's own
|
||||
* baseline purple-and-grey and belongs to no flavour this app ships.
|
||||
*
|
||||
* That is not hypothetical. `surfaceContainerHighest` is what `Card` fills with
|
||||
* (`FilledCardTokens.ContainerColor`), and while it was unmapped every tile in
|
||||
* the library grid, every episode row, every share and every download was drawn
|
||||
* in baseline neutral on top of a flavoured page. `inverseSurface` is the
|
||||
* snackbar — the app's only error channel — and `scrim` is the dim behind every
|
||||
* dialog.
|
||||
*
|
||||
* Material names more roles than a palette has colours, so several are answered
|
||||
* by the same one, and two (`elevated`, `dangerDim`) are derived in Rust so the
|
||||
* blend behind them is the shared one. `SchemeRolesTest` asserts that nothing
|
||||
* here is left at its baseline.
|
||||
*/
|
||||
internal fun schemeOf(palette: PaletteRecord): ColorScheme {
|
||||
val base = if (palette.light) lightColorScheme() else darkColorScheme()
|
||||
return base.copy(
|
||||
primary = color(palette.accent),
|
||||
onPrimary = color(palette.onAccent),
|
||||
primaryContainer = color(palette.accentDim),
|
||||
onPrimaryContainer = color(palette.text),
|
||||
// Readable *on* `inverseSurface`, which is the ink colour: `accentDim`
|
||||
// is the accent taken towards the page, so it inverts with the rest.
|
||||
inversePrimary = color(palette.accentDim),
|
||||
secondary = color(palette.accentAlt),
|
||||
onSecondary = color(palette.onAccent),
|
||||
// The accent taken back towards the page, not the card's hover shade.
|
||||
// `secondaryContainer` is what fills every tonal button and the
|
||||
// navigation bar's selected pill, and `cardHover` sits one step off
|
||||
// `card` — which on a dark flavour made all of them dark grey shapes on
|
||||
// a dark grey row, readable as text but not as controls.
|
||||
secondaryContainer = color(palette.accentDim),
|
||||
onSecondaryContainer = color(palette.text),
|
||||
// The palette has two hues, not three. Tertiary takes the second one —
|
||||
// the gradient partner — rather than inventing a third that no flavour
|
||||
// chose.
|
||||
tertiary = color(palette.accentAlt),
|
||||
onTertiary = color(palette.onAccent),
|
||||
tertiaryContainer = color(palette.accentDim),
|
||||
onTertiaryContainer = color(palette.text),
|
||||
background = color(palette.background),
|
||||
onBackground = color(palette.text),
|
||||
surface = color(palette.surface),
|
||||
onSurface = color(palette.text),
|
||||
surfaceVariant = color(palette.card),
|
||||
onSurfaceVariant = color(palette.muted),
|
||||
// No elevation tint. Material tints a raised surface with `primary`,
|
||||
// and the desktop draws no such tint at all — its rule is that the
|
||||
// accent is the only strong colour in the window and everything wearing
|
||||
// it is something to click. A card that is faintly accent-coloured
|
||||
// because it happens to be raised breaks that.
|
||||
surfaceTint = Color.Transparent,
|
||||
// Genuinely inverted, both ways round: on a dark flavour this is a pale
|
||||
// snackbar with dark ink, on Latte a dark one with pale ink.
|
||||
inverseSurface = color(palette.text),
|
||||
inverseOnSurface = color(palette.background),
|
||||
error = color(palette.danger),
|
||||
onError = color(palette.background),
|
||||
errorContainer = color(palette.dangerDim),
|
||||
onErrorContainer = color(palette.text),
|
||||
// Swapped from what was here before, to match what Material draws with
|
||||
// each: `outline` is a border meant to be seen (an outlined button,
|
||||
// a swatch that is not selected), `outlineVariant` is a divider meant
|
||||
// to be barely there. `border` is the fainter of the two.
|
||||
outline = color(palette.muted),
|
||||
outlineVariant = color(palette.border),
|
||||
// Black in both of Material's own schemes, and for the same reason: a
|
||||
// scrim is not a colour, it is an absence of light.
|
||||
scrim = Color.Black,
|
||||
surfaceBright = color(palette.elevated),
|
||||
surfaceDim = color(palette.background),
|
||||
// The containment ladder, in the order the palette already runs: the
|
||||
// deepest colour in the flavour at the bottom, `elevated` — one step
|
||||
// past the card's hover shade — at the top.
|
||||
surfaceContainerLowest = color(palette.sunken),
|
||||
surfaceContainerLow = color(palette.surface),
|
||||
surfaceContainer = color(palette.card),
|
||||
surfaceContainerHigh = color(palette.cardHover),
|
||||
surfaceContainerHighest = color(palette.elevated),
|
||||
// The fixed roles are Material's "the same in light and dark", which a
|
||||
// per-flavour palette has no equivalent of. They take the accent pair
|
||||
// rather than a baseline hue, so a component that reaches for one still
|
||||
// lands inside the flavour.
|
||||
primaryFixed = color(palette.accentDim),
|
||||
primaryFixedDim = color(palette.accent),
|
||||
onPrimaryFixed = color(palette.text),
|
||||
onPrimaryFixedVariant = color(palette.muted),
|
||||
secondaryFixed = color(palette.accentDim),
|
||||
secondaryFixedDim = color(palette.accentAlt),
|
||||
onSecondaryFixed = color(palette.text),
|
||||
onSecondaryFixedVariant = color(palette.muted),
|
||||
tertiaryFixed = color(palette.accentDim),
|
||||
tertiaryFixedDim = color(palette.accentAlt),
|
||||
onTertiaryFixed = color(palette.text),
|
||||
onTertiaryFixedVariant = color(palette.muted),
|
||||
)
|
||||
}
|
||||
|
||||
/** A packed `0xAARRGGBB` from the bridge, as Compose wants it. */
|
||||
private fun color(argb: UInt) = Color(argb.toInt())
|
||||
|
||||
/**
|
||||
* The shipped default, spelled out for the case where Rust cannot be asked.
|
||||
*
|
||||
* A `PaletteRecord` rather than a `ColorScheme`, so that it goes through
|
||||
* [schemeOf] like any other palette and inherits every mapping above — a
|
||||
* fallback that answered only ten roles would be the same bug in a smaller
|
||||
* place. The values are `pstr_core::appearance::Palette::PROTON`.
|
||||
*/
|
||||
internal val ProtonFallback = PaletteRecord(
|
||||
background = 0xFF0E0E12u,
|
||||
surface = 0xFF17171Du,
|
||||
sunken = 0xFF0A0A0Du,
|
||||
card = 0xFF1E1E26u,
|
||||
cardHover = 0xFF2A2A35u,
|
||||
border = 0xFF262630u,
|
||||
text = 0xFFEAEAF0u,
|
||||
muted = 0xFF8E8E9Cu,
|
||||
accent = 0xFF7D4DFFu,
|
||||
accentAlt = 0xFF3F6FE0u,
|
||||
accentDim = 0xFF5333ADu,
|
||||
onAccent = 0xFFEAEAF0u,
|
||||
danger = 0xFFE05561u,
|
||||
elevated = 0xFF333340u,
|
||||
dangerDim = 0xFF7F2F37u,
|
||||
light = false,
|
||||
)
|
||||
@@ -0,0 +1,115 @@
|
||||
package io.narl.protonstream.ui.theme
|
||||
|
||||
import androidx.compose.material3.Typography
|
||||
import androidx.compose.ui.text.TextStyle
|
||||
import androidx.compose.ui.text.font.Font
|
||||
import androidx.compose.ui.text.font.FontFamily
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import io.narl.protonstream.R
|
||||
import androidx.compose.ui.unit.sp
|
||||
|
||||
/**
|
||||
* Inter, the desktop client's face, from the same two files.
|
||||
*
|
||||
* Only Regular and SemiBold ship, as on the desktop. A `Medium` role resolves
|
||||
* to Regular by the usual weight matching rather than to a synthesised bold,
|
||||
* which is how the desktop draws it too.
|
||||
*
|
||||
* Above [ProtonTypography] on purpose: top-level properties initialise in
|
||||
* file order, and a ramp built first would capture this as null and fall back
|
||||
* to the system face without a word.
|
||||
*/
|
||||
internal val Inter = FontFamily(
|
||||
Font(R.font.inter_regular, FontWeight.Normal),
|
||||
Font(R.font.inter_semibold, FontWeight.SemiBold),
|
||||
)
|
||||
|
||||
/**
|
||||
* Inter Display Bold, the optical size drawn for large text, for the one
|
||||
* [HERO] line: the banner's name and the name at the top of a title's page.
|
||||
* The desktop's `theme::display` family, from the same file.
|
||||
*/
|
||||
internal val InterDisplay = FontFamily(Font(R.font.inter_display_bold, FontWeight.Bold))
|
||||
|
||||
/**
|
||||
* The type ramp, which is the desktop client's type ramp.
|
||||
*
|
||||
* `pstr_app::theme::Role` names nine sizes and the desktop asks for a role
|
||||
* rather than a number at every call site. Material names fifteen styles, so
|
||||
* several of them land on the same rung — that is the point. The two clients
|
||||
* were built to share one palette; sharing one scale is the other half of why
|
||||
* they should read as one app.
|
||||
*
|
||||
* The rungs, largest first: 34 / 26 / 20 / 18 / 17 / 15 / 14 / 13 / 12 / 11.
|
||||
* The top one is the hero line, in [InterDisplay] and set tighter, at
|
||||
* [HERO_LEADING]: at that size the text face's leading pushes a two-line name
|
||||
* apart.
|
||||
*
|
||||
* Where a role maps is decided by what actually draws with it here, not by
|
||||
* Material's own sizes — Material's `displayLarge` is 57sp, which is a size for
|
||||
* a phone's lock screen and not for anything in a library grid. `bodyLarge` is
|
||||
* what an unstyled `Text` gets, so it takes the desktop's body rung and the two
|
||||
* smaller body styles step down from there.
|
||||
*
|
||||
* [TypeRampTest] pins every one of these against the Rust ramp.
|
||||
*/
|
||||
internal val ProtonTypography = Typography(
|
||||
// The banner's name and the title page's name, where no logo stands in.
|
||||
displayLarge = role(HERO, FontWeight.Bold, InterDisplay, HERO_LEADING),
|
||||
displayMedium = role(DISPLAY, FontWeight.SemiBold),
|
||||
displaySmall = role(TITLE, FontWeight.SemiBold),
|
||||
// The name of the title you opened — the desktop's display line.
|
||||
headlineLarge = role(DISPLAY, FontWeight.SemiBold),
|
||||
headlineMedium = role(DISPLAY, FontWeight.SemiBold),
|
||||
// A dialog's heading — `AlertDialog` titles draw with this one.
|
||||
headlineSmall = role(HEADING, FontWeight.SemiBold),
|
||||
// A page heading: "Offline downloads", "Settings".
|
||||
titleLarge = role(TITLE, FontWeight.SemiBold),
|
||||
// A run of content under a heading: "Continue watching", "Playback".
|
||||
titleMedium = role(SECTION, FontWeight.Medium),
|
||||
// A row that groups the rows under it — a season header.
|
||||
titleSmall = role(SUBHEAD, FontWeight.Medium),
|
||||
bodyLarge = role(BODY, FontWeight.Normal),
|
||||
bodyMedium = role(LABEL, FontWeight.Normal),
|
||||
// Context rather than content: counts, sizes, the note under a field.
|
||||
bodySmall = role(CAPTION, FontWeight.Normal),
|
||||
// The text on a button.
|
||||
labelLarge = role(LABEL, FontWeight.Medium),
|
||||
labelMedium = role(CAPTION, FontWeight.Medium),
|
||||
// The smallest thing that stays legible.
|
||||
labelSmall = role(MICRO, FontWeight.Medium),
|
||||
)
|
||||
|
||||
internal const val HERO = 34f
|
||||
internal const val DISPLAY = 26f
|
||||
internal const val TITLE = 20f
|
||||
internal const val HEADING = 18f
|
||||
internal const val SECTION = 17f
|
||||
internal const val SUBHEAD = 15f
|
||||
internal const val BODY = 14f
|
||||
internal const val LABEL = 13f
|
||||
internal const val CAPTION = 12f
|
||||
internal const val MICRO = 11f
|
||||
|
||||
internal const val LEADING = 1.35f
|
||||
internal const val HERO_LEADING = 1.1f
|
||||
|
||||
/**
|
||||
* One rung as a `TextStyle`.
|
||||
*
|
||||
* Line height is derived rather than named per style: a ramp whose leading was
|
||||
* chosen nine separate times is the same problem the sizes had. 1.35 is loose
|
||||
* enough for the two-line title names in the grid and tight enough that a
|
||||
* label on a control does not float.
|
||||
*/
|
||||
private fun role(
|
||||
size: Float,
|
||||
weight: FontWeight,
|
||||
family: FontFamily = Inter,
|
||||
leading: Float = LEADING,
|
||||
) = TextStyle(
|
||||
fontSize = size.sp,
|
||||
lineHeight = (size * leading).sp,
|
||||
fontWeight = weight,
|
||||
fontFamily = family,
|
||||
)
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
After Width: | Height: | Size: 26 KiB |
@@ -0,0 +1,20 @@
|
||||
<!--
|
||||
The launcher tile: packaging/io.narl.proton-stream.svg's face gradient, on
|
||||
the full 108dp canvas because the launcher cuts its own shape out of it.
|
||||
-->
|
||||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
xmlns:aapt="http://schemas.android.com/aapt"
|
||||
android:width="108dp" android:height="108dp"
|
||||
android:viewportWidth="108" android:viewportHeight="108">
|
||||
<path android:pathData="M0,0h108v108h-108z">
|
||||
<aapt:attr name="android:fillColor">
|
||||
<gradient android:type="linear"
|
||||
android:startX="0" android:startY="0"
|
||||
android:endX="108" android:endY="108">
|
||||
<item android:offset="0" android:color="#FFA48BFF" />
|
||||
<item android:offset="0.55" android:color="#FF7552FF" />
|
||||
<item android:offset="1" android:color="#FF5B36F2" />
|
||||
</gradient>
|
||||
</aapt:attr>
|
||||
</path>
|
||||
</vector>
|
||||
@@ -0,0 +1,34 @@
|
||||
<!--
|
||||
The play mark from packaging/io.narl.proton-stream.svg, cut into the blocks a
|
||||
revision is fetched in. The paths are in the SVG's 128-unit space; the group
|
||||
maps its 104-unit tile onto the 72dp a launcher shows of a 108dp layer.
|
||||
It centres x=70, between the glyph's centroid (66) and its box (73): the
|
||||
SVG's own centre, 64, leaves it sitting visibly right in a round mask.
|
||||
-->
|
||||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
xmlns:aapt="http://schemas.android.com/aapt"
|
||||
android:width="108dp" android:height="108dp"
|
||||
android:viewportWidth="108" android:viewportHeight="108">
|
||||
<group
|
||||
android:pivotX="70" android:pivotY="62"
|
||||
android:scaleX="0.6923" android:scaleY="0.6923"
|
||||
android:translateX="-16" android:translateY="-8">
|
||||
<group android:translateY="3">
|
||||
<clip-path android:pathData="M0,0h63v128h-63zM68.5,0h10v128h-10zM84,0h44v128h-44z" />
|
||||
<path android:fillColor="#4D2E1596" android:pathData="M50,41.5v41a6,6 0,0 0,9.1 5.1l34.2,-20.5a6,6 0,0 0,0 -10.3L59.1,36.4A6,6 0,0 0,50 41.5z" />
|
||||
</group>
|
||||
<group>
|
||||
<clip-path android:pathData="M0,0h63v128h-63zM68.5,0h10v128h-10zM84,0h44v128h-44z" />
|
||||
<path android:pathData="M50,41.5v41a6,6 0,0 0,9.1 5.1l34.2,-20.5a6,6 0,0 0,0 -10.3L59.1,36.4A6,6 0,0 0,50 41.5z">
|
||||
<aapt:attr name="android:fillColor">
|
||||
<gradient android:type="linear"
|
||||
android:startX="0" android:startY="36"
|
||||
android:endX="0" android:endY="88">
|
||||
<item android:offset="0" android:color="#FFFFFFFF" />
|
||||
<item android:offset="1" android:color="#FFECE6FF" />
|
||||
</gradient>
|
||||
</aapt:attr>
|
||||
</path>
|
||||
</group>
|
||||
</group>
|
||||
</vector>
|
||||
@@ -0,0 +1,12 @@
|
||||
<!-- The foreground's glyph as one flat shape, for themed launcher icons. -->
|
||||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:width="108dp" android:height="108dp"
|
||||
android:viewportWidth="108" android:viewportHeight="108">
|
||||
<group
|
||||
android:pivotX="70" android:pivotY="62"
|
||||
android:scaleX="0.6923" android:scaleY="0.6923"
|
||||
android:translateX="-16" android:translateY="-8">
|
||||
<clip-path android:pathData="M0,0h63v128h-63zM68.5,0h10v128h-10zM84,0h44v128h-44z" />
|
||||
<path android:fillColor="#FFFFFFFF" android:pathData="M50,41.5v41a6,6 0,0 0,9.1 5.1l34.2,-20.5a6,6 0,0 0,0 -10.3L59.1,36.4A6,6 0,0 0,50 41.5z" />
|
||||
</group>
|
||||
</vector>
|
||||
@@ -0,0 +1,16 @@
|
||||
<!--
|
||||
The status-bar glyph. Android tints small icons to a single colour and drops
|
||||
everything that is not opaque, so this is the launcher's sliced play mark as
|
||||
one white silhouette, scaled from the SVG's 128-unit space onto 24dp.
|
||||
-->
|
||||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:width="24dp" android:height="24dp"
|
||||
android:viewportWidth="24" android:viewportHeight="24">
|
||||
<group
|
||||
android:pivotX="70" android:pivotY="62"
|
||||
android:scaleX="0.37" android:scaleY="0.37"
|
||||
android:translateX="-58" android:translateY="-50">
|
||||
<clip-path android:pathData="M0,0h63v128h-63zM68.5,0h10v128h-10zM84,0h44v128h-44z" />
|
||||
<path android:fillColor="#FFFFFFFF" android:pathData="M50,41.5v41a6,6 0,0 0,9.1 5.1l34.2,-20.5a6,6 0,0 0,0 -10.3L59.1,36.4A6,6 0,0 0,50 41.5z" />
|
||||
</group>
|
||||
</vector>
|
||||
@@ -0,0 +1,6 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
<background android:drawable="@drawable/ic_launcher_background" />
|
||||
<foreground android:drawable="@drawable/ic_launcher_foreground" />
|
||||
<monochrome android:drawable="@drawable/ic_launcher_monochrome" />
|
||||
</adaptive-icon>
|
||||
@@ -0,0 +1,9 @@
|
||||
<resources>
|
||||
<!--
|
||||
`pstr_core::appearance::Palette::PROTON.background` — the shipped default,
|
||||
and the only colour in this file. It is what the window is painted before
|
||||
the stored palette has been read; from then on
|
||||
MainActivity.dressWindow overrides it with the flavour in force.
|
||||
-->
|
||||
<color name="window_background">#0E0E12</color>
|
||||
</resources>
|
||||
@@ -0,0 +1,9 @@
|
||||
<resources>
|
||||
<!-- Settings, recents, notifications and the share sheet: room for the full name. -->
|
||||
<string name="app_name">Proton Stream</string>
|
||||
<!--
|
||||
Under the launcher icon, where about ten characters fit before the
|
||||
launcher cuts the label off; "proton-stream" showed as "proton-st…".
|
||||
-->
|
||||
<string name="launcher_name">Stream</string>
|
||||
</resources>
|
||||
@@ -0,0 +1,23 @@
|
||||
<resources>
|
||||
<!--
|
||||
The window before Compose owns it.
|
||||
|
||||
Everything about the *look* is decided by the stored palette and applied
|
||||
in MainActivity.dressWindow: the background colour here is only the one
|
||||
the shipped default resolves to, so that a first-ever launch — before
|
||||
there is a theme file to read — starts on the right colour rather than on
|
||||
the platform's white. The bar polarity is likewise set from the palette;
|
||||
what is left here is the dark reading, which is right for four of the five
|
||||
flavours and is corrected in the same frame for the fifth.
|
||||
|
||||
A hex literal from one specific flavour used to live here, and it was the
|
||||
reason every cold start flashed Catppuccin Mocha's base regardless of what
|
||||
the viewer had chosen.
|
||||
-->
|
||||
<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">@color/window_background</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,37 @@
|
||||
package io.narl.protonstream
|
||||
|
||||
import android.content.Intent
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertNull
|
||||
import org.junit.Test
|
||||
|
||||
class IncomingShareLinkTest {
|
||||
private val link = "https://drive.proton.me/urls/ABC123#s3cr3t"
|
||||
|
||||
@Test
|
||||
fun `a viewed share link is taken whole, fragment included`() {
|
||||
assertEquals(link, shareLinkFrom(Intent.ACTION_VIEW, link, null))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a viewed url that is not an https share link is ignored`() {
|
||||
assertNull(shareLinkFrom(Intent.ACTION_VIEW, "https://drive.proton.me/u/0/", null))
|
||||
assertNull(shareLinkFrom(Intent.ACTION_VIEW, "http://drive.proton.me/urls/ABC123#s3cr3t", null))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a shared message yields the link inside it`() {
|
||||
assertEquals(link, shareLinkFrom(Intent.ACTION_SEND, null, "Season two is up: $link enjoy"))
|
||||
assertEquals(link, shareLinkFrom(Intent.ACTION_SEND, null, "(<$link>)"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a shared message without a link yields nothing`() {
|
||||
assertNull(shareLinkFrom(Intent.ACTION_SEND, null, "no link here"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `other actions are ignored`() {
|
||||
assertNull(shareLinkFrom(Intent.ACTION_MAIN, link, link))
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
package io.narl.protonstream.download
|
||||
|
||||
import androidx.work.NetworkType
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Test
|
||||
import java.util.UUID
|
||||
|
||||
class DownloadCoordinatorTest {
|
||||
@Test
|
||||
fun `wifi only downloads require an unmetered network`() {
|
||||
assertEquals(NetworkType.UNMETERED, requiredNetworkType(wifiOnly = true))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `downloads allow any connected network when wifi only is disabled`() {
|
||||
assertEquals(NetworkType.CONNECTED, requiredNetworkType(wifiOnly = false))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `foreground notification id is stable and positive per work request`() {
|
||||
val workId = UUID.fromString("2f0ba9bc-8936-439b-88fd-cb2741a17653")
|
||||
assertEquals(stableNotificationId(workId), stableNotificationId(workId))
|
||||
assertTrue(stableNotificationId(workId) > 0)
|
||||
val otherWorkId = UUID.fromString("932e1de4-4359-423a-b5e8-af3e43086ab1")
|
||||
assertTrue(stableNotificationId(workId) != stableNotificationId(otherWorkId))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `share cancellation tags cannot collide with the global queue tag`() {
|
||||
assertEquals("offline-share:share-1", DownloadCoordinator.shareTag("share-1"))
|
||||
assertTrue(DownloadCoordinator.shareTag("share-1") != DownloadCoordinator.TAG)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `episode work names are stable per share and link`() {
|
||||
assertEquals(
|
||||
"offline-share-1-link-2",
|
||||
DownloadCoordinator.workName("share-1", "link-2"),
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,155 @@
|
||||
package io.narl.protonstream.download
|
||||
|
||||
import android.content.Context
|
||||
import androidx.test.core.app.ApplicationProvider
|
||||
import org.json.JSONObject
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertNull
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Before
|
||||
import org.junit.Test
|
||||
import org.junit.runner.RunWith
|
||||
import org.robolectric.RobolectricTestRunner
|
||||
|
||||
@RunWith(RobolectricTestRunner::class)
|
||||
class DownloadStateStoreTest {
|
||||
private lateinit var context: Context
|
||||
private lateinit var store: DownloadStateStore
|
||||
|
||||
@Before
|
||||
fun setUp() {
|
||||
context = ApplicationProvider.getApplicationContext()
|
||||
context.getSharedPreferences(PREFERENCES, Context.MODE_PRIVATE).edit().clear().commit()
|
||||
store = DownloadStateStore(context)
|
||||
}
|
||||
|
||||
private fun record(
|
||||
shareId: String = "share-1",
|
||||
linkId: String = "link-1",
|
||||
label: String = "S01E01.mkv",
|
||||
status: String = RetainedDownload.STATUS_RUNNING,
|
||||
error: String? = null,
|
||||
) = RetainedDownload(
|
||||
shareId = shareId,
|
||||
volumeId = "volume-1",
|
||||
linkId = linkId,
|
||||
label = label,
|
||||
downloaded = 4L * 1024 * 1024,
|
||||
total = 761L * 1024 * 1024,
|
||||
status = status,
|
||||
error = error,
|
||||
)
|
||||
|
||||
@Test
|
||||
fun `a record survives a round trip unchanged`() {
|
||||
val original = record()
|
||||
store.put(original)
|
||||
assertEquals(original, store.get("share-1", "link-1"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `an absent record reads as null rather than a default`() {
|
||||
assertNull(store.get("share-1", "link-1"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `an error message round trips and a null one stays null`() {
|
||||
store.put(record(error = "no space left on device"))
|
||||
assertEquals("no space left on device", store.get("share-1", "link-1")?.error)
|
||||
|
||||
store.put(record(error = null))
|
||||
assertNull(store.get("share-1", "link-1")?.error)
|
||||
}
|
||||
|
||||
/**
|
||||
* `encode` writes `error` through `JSONObject.put`, which *removes* the key
|
||||
* when the value is null. `decode` then reads `optString`, which answers ""
|
||||
* rather than null. The blank check is the only thing standing between a
|
||||
* cleared error and a download that renders as permanently failed.
|
||||
*/
|
||||
@Test
|
||||
fun `a blank error is not read as a failure message`() {
|
||||
writeRaw(
|
||||
"share-1",
|
||||
"link-1",
|
||||
baseJson().put("error", ""),
|
||||
)
|
||||
assertNull(store.get("share-1", "link-1")?.error)
|
||||
}
|
||||
|
||||
/** Rows written before `label` existed fall back to the link id. */
|
||||
@Test
|
||||
fun `a record with no label is captioned with its link id`() {
|
||||
writeRaw("share-1", "link-1", baseJson().apply { remove("label") })
|
||||
assertEquals("link-1", store.get("share-1", "link-1")?.label)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a corrupt row is skipped rather than failing the whole list`() {
|
||||
store.put(record(linkId = "link-1", label = "good.mkv"))
|
||||
context.getSharedPreferences(PREFERENCES, Context.MODE_PRIVATE)
|
||||
.edit()
|
||||
.putString("share-1\u001flink-broken", "{ not json")
|
||||
.commit()
|
||||
|
||||
val records = store.records()
|
||||
assertEquals(1, records.size)
|
||||
assertEquals("good.mkv", records.first().label)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `records are ordered by label without regard to case`() {
|
||||
store.put(record(linkId = "a", label = "beta.mkv"))
|
||||
store.put(record(linkId = "b", label = "Alpha.mkv"))
|
||||
assertEquals(listOf("Alpha.mkv", "beta.mkv"), store.records().map { it.label })
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `removing one share leaves the others alone`() {
|
||||
store.put(record(shareId = "share-1", linkId = "a"))
|
||||
store.put(record(shareId = "share-2", linkId = "b"))
|
||||
|
||||
store.removeShare("share-1")
|
||||
|
||||
assertEquals(listOf("share-2"), store.records().map { it.shareId })
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `clear forgets everything`() {
|
||||
store.put(record(linkId = "a"))
|
||||
store.put(record(linkId = "b"))
|
||||
store.clear()
|
||||
assertTrue(store.records().isEmpty())
|
||||
}
|
||||
|
||||
/**
|
||||
* The key is what `DownloadCoordinator` and `OfflineDownloadWorker` agree on
|
||||
* across processes; two shares may legitimately hold the same link id.
|
||||
*/
|
||||
@Test
|
||||
fun `the key separates share from link so ids cannot run together`() {
|
||||
store.put(record(shareId = "a", linkId = "b-c"))
|
||||
store.put(record(shareId = "a-b", linkId = "c"))
|
||||
assertEquals(2, store.records().size)
|
||||
}
|
||||
|
||||
private fun baseJson() = JSONObject()
|
||||
.put("share", "share-1")
|
||||
.put("volume", "volume-1")
|
||||
.put("link", "link-1")
|
||||
.put("label", "S01E01.mkv")
|
||||
.put("downloaded", 0)
|
||||
.put("total", 0)
|
||||
.put("status", RetainedDownload.STATUS_QUEUED)
|
||||
|
||||
private fun writeRaw(shareId: String, linkId: String, json: JSONObject) {
|
||||
context.getSharedPreferences(PREFERENCES, Context.MODE_PRIVATE)
|
||||
.edit()
|
||||
.putString("$shareId\u001f$linkId", json.toString())
|
||||
.commit()
|
||||
}
|
||||
|
||||
private companion object {
|
||||
const val PREFERENCES = "offline_download_state"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
package io.narl.protonstream.download
|
||||
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Test
|
||||
|
||||
class TransferRateTest {
|
||||
@Test
|
||||
fun `the first sample has no rate to report`() {
|
||||
assertEquals(0L, TransferRate().sample(4L * 1024 * 1024, 1_000))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a steady transfer reports its rate`() {
|
||||
val rate = TransferRate()
|
||||
rate.sample(0, 0)
|
||||
assertEquals(1_000_000L, rate.sample(1_000_000, 1_000))
|
||||
assertEquals(1_000_000L, rate.sample(2_000_000, 2_000))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `one slow block moves the rate without halving it`() {
|
||||
val rate = TransferRate()
|
||||
rate.sample(0, 0)
|
||||
rate.sample(1_000_000, 1_000)
|
||||
val after = rate.sample(2_000_000, 3_000)
|
||||
assertTrue(after in 600_000L..999_999L)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a resumed download starting below the last sample starts over`() {
|
||||
val rate = TransferRate()
|
||||
rate.sample(0, 0)
|
||||
rate.sample(1_000_000, 1_000)
|
||||
assertEquals(1_000_000L, rate.sample(0, 2_000))
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
package io.narl.protonstream.ui
|
||||
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertFalse
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Test
|
||||
|
||||
class AccountUiTest {
|
||||
@Test
|
||||
fun `the sync line says how long ago and what came in`() {
|
||||
val minute = 60_000L
|
||||
assertEquals("Library, settings and watch history sync through your Drive", syncLine(AccountUiState(), 0))
|
||||
assertEquals("Synced just now", syncLine(AccountUiState(syncedAt = 0), 30_000))
|
||||
assertEquals("Synced 5 min ago", syncLine(AccountUiState(syncedAt = 0), 5 * minute))
|
||||
assertEquals(
|
||||
"Synced 2 h ago · 3 from other devices",
|
||||
syncLine(AccountUiState(syncedAt = 0, applied = 3), 120 * minute),
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a failed sync is said in place of when it last worked`() {
|
||||
assertEquals(
|
||||
"Did not sync: offline",
|
||||
syncLine(AccountUiState(syncedAt = 0, syncError = "offline"), 0),
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `the verification page may only navigate within Proton over https`() {
|
||||
assertTrue(isProtonPage("https", "verify.proton.me"))
|
||||
assertTrue(isProtonPage("https", "proton.me"))
|
||||
assertFalse(isProtonPage("http", "verify.proton.me"))
|
||||
assertFalse(isProtonPage("https", "proton.me.example.com"))
|
||||
assertFalse(isProtonPage("https", "notproton.me"))
|
||||
assertFalse(isProtonPage("https", null))
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
package io.narl.protonstream.ui
|
||||
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Test
|
||||
|
||||
class ArtworkTest {
|
||||
@Test
|
||||
fun `initials skip the lower-case particles of a romanised name`() {
|
||||
assertEquals("OK", initials("Oshi no Ko"))
|
||||
assertEquals("AH", initials("Ano Hi Mita Hana no Namae o Bokutachi wa Mada Shiranai."))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a one-word name has one initial`() {
|
||||
assertEquals("A", initials("Akira"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a name with no capitals falls back to its first two words`() {
|
||||
assertEquals("BW", initials("blue world order"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `punctuation and separators are not words`() {
|
||||
assertEquals("S2", initials("Steins;Gate - 2"))
|
||||
assertEquals("", initials(" - "))
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
package io.narl.protonstream.ui
|
||||
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertNull
|
||||
import org.junit.Test
|
||||
import uniffi.pstr_android.EpisodeRecord
|
||||
|
||||
class EpisodeNameTest {
|
||||
private fun episode(name: String, detail: String, providerName: String? = null) = EpisodeRecord(
|
||||
shareId = "s", volumeId = "v", linkId = "l", name = name, label = "S01E01", detail = detail,
|
||||
season = 1u, number = 1u, size = null, progress = null, resumeAt = null,
|
||||
watched = false, offline = false, providerName = providerName, providerOverview = null, stillUrl = null,
|
||||
airDate = null, lastPlayed = 0,
|
||||
)
|
||||
|
||||
@Test
|
||||
fun `the player captions an episode with its name and never its filename`() {
|
||||
val file = "[Group] Show - S01E01.mkv"
|
||||
assertEquals("Dog & Chainsaw", episode(file, file, providerName = "Dog & Chainsaw").shownName())
|
||||
assertEquals("Winter Is Coming", episode(file, "Winter Is Coming").shownName())
|
||||
assertNull(episode(file, file).shownName())
|
||||
assertNull(episode(file, "").shownName())
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,59 @@
|
||||
package io.narl.protonstream.ui
|
||||
|
||||
import java.time.LocalDate
|
||||
import java.util.Locale
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Test
|
||||
import uniffi.pstr_android.EpisodeRecord
|
||||
import uniffi.pstr_android.SeasonRecord
|
||||
import uniffi.pstr_android.TitleRecord
|
||||
import uniffi.pstr_android.TitleType
|
||||
|
||||
class HistoryTest {
|
||||
private fun episode(link: String, watched: Boolean, progress: Double?, lastPlayed: Long) = EpisodeRecord(
|
||||
shareId = "s", volumeId = "v", linkId = link, name = "$link.mkv", label = link, detail = "$link.mkv",
|
||||
season = 1u, number = null, size = null, progress = progress, resumeAt = progress?.let { it * 1000 },
|
||||
watched = watched, offline = false, providerName = null, providerOverview = null, stillUrl = null,
|
||||
airDate = null, lastPlayed = lastPlayed,
|
||||
)
|
||||
|
||||
private fun title(key: String, vararg episodes: EpisodeRecord) = TitleRecord(
|
||||
key = key, name = key, year = null, kind = TitleType.SERIES, watchedCount = 0uL,
|
||||
episodeCount = episodes.size.toULong(), canonicalName = null, originalName = null, overview = null,
|
||||
metadataProvider = null, metadataId = null, metadataYear = null, metadataKind = null, posterUrl = null,
|
||||
backdropUrl = null, rating = null, genres = emptyList(), providerEpisodeCount = null, externalUrl = null,
|
||||
manualMatch = false, seasons = listOf(SeasonRecord(1u, "Season 1", episodes.toList())),
|
||||
displayName = key, wideUrl = null, formatLabel = null, seasonLabel = null, studios = emptyList(),
|
||||
directors = emptyList(), tags = emptyList(), airing = false, nextEpisode = null, nextAiringAt = null,
|
||||
franchise = emptyList(),
|
||||
continueIndex = null,
|
||||
)
|
||||
|
||||
@Test
|
||||
fun `history is every started or finished episode, newest first, across titles`() {
|
||||
val titles = listOf(
|
||||
title(
|
||||
"a",
|
||||
episode("a1", watched = true, progress = 1.0, lastPlayed = 30),
|
||||
// Marked unwatched again: a row at zero, which is not a play.
|
||||
episode("a2", watched = false, progress = 0.0, lastPlayed = 40),
|
||||
episode("a3", watched = false, progress = null, lastPlayed = 0),
|
||||
),
|
||||
title("b", episode("b1", watched = false, progress = 0.4, lastPlayed = 50)),
|
||||
)
|
||||
val played = history(titles)
|
||||
assertEquals(listOf("b1", "a1"), played.map { it.episode.linkId })
|
||||
assertEquals(0, played.last().index)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `days are headed relative to today, then by date`() {
|
||||
val today = LocalDate.of(2026, 10, 3) // a Saturday
|
||||
val english = Locale.ENGLISH
|
||||
assertEquals("Today", dayHeading(today, today, english))
|
||||
assertEquals("Yesterday", dayHeading(today.minusDays(1), today, english))
|
||||
assertEquals("Monday", dayHeading(LocalDate.of(2026, 9, 28), today, english))
|
||||
assertEquals("26 September", dayHeading(LocalDate.of(2026, 9, 26), today, english))
|
||||
assertEquals("3 October 2025", dayHeading(LocalDate.of(2025, 10, 3), today, english))
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
package io.narl.protonstream.ui
|
||||
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Test
|
||||
|
||||
class LanguagePickerTest {
|
||||
@Test
|
||||
fun `a stored tag reads as its language in either ISO form`() {
|
||||
assertEquals("Japanese", languageLabel("jpn"))
|
||||
assertEquals("Japanese", languageLabel("ja"))
|
||||
assertEquals("German", languageLabel("deu"))
|
||||
assertEquals("German", languageLabel("ger"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a region suffix and capitals do not hide the language`() {
|
||||
assertEquals("Portuguese", languageLabel("PT-br"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a tag not on the list is shown as typed, and blank is no preference`() {
|
||||
assertEquals("tgl", languageLabel("tgl"))
|
||||
assertEquals("No preference", languageLabel(null))
|
||||
assertEquals("No preference", languageLabel(" "))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `every listed language has a two-letter form`() {
|
||||
LANGUAGES.forEach { (code, name) -> assertEquals(code, name, languageLabel(code)) }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
package io.narl.protonstream.ui
|
||||
|
||||
import org.junit.Assert.assertArrayEquals
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertNull
|
||||
import org.junit.Test
|
||||
|
||||
class LogoAndRuntimeTest {
|
||||
@Test
|
||||
fun `a padded logo is bounded by its lettering and not its haze`() {
|
||||
val width = 10
|
||||
val pixels = IntArray(width * 6)
|
||||
// Lettering from (2, 1) to (6, 3), and a pixel of haze in the corner.
|
||||
for (y in 1..3) for (x in 2..6) pixels[y * width + x] = 0xFFFFFFFF.toInt()
|
||||
pixels[5 * width + 9] = 0x03FFFFFF
|
||||
assertArrayEquals(intArrayOf(2, 1, 6, 3), opaqueBounds(pixels, width))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a logo with nothing visible has no bounds`() {
|
||||
assertNull(opaqueBounds(IntArray(16), 4))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a running time reads in hours and minutes`() {
|
||||
assertEquals("24 min", formatRuntime(24u))
|
||||
assertEquals("2 h", formatRuntime(120u))
|
||||
assertEquals("2 h 4 min", formatRuntime(124u))
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,350 @@
|
||||
package io.narl.protonstream.ui
|
||||
|
||||
import androidx.compose.foundation.layout.PaddingValues
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Surface
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.CompositionLocalProvider
|
||||
import androidx.compose.ui.test.junit4.createComposeRule
|
||||
import androidx.compose.ui.platform.LocalDensity
|
||||
import androidx.compose.ui.test.onRoot
|
||||
import androidx.compose.ui.unit.Density
|
||||
import androidx.test.core.app.ApplicationProvider
|
||||
import androidx.work.testing.WorkManagerTestInitHelper
|
||||
import com.github.takahirom.roborazzi.RobolectricDeviceQualifiers
|
||||
import com.github.takahirom.roborazzi.captureRoboImage
|
||||
import io.narl.protonstream.ui.theme.AppearanceState
|
||||
import io.narl.protonstream.ui.theme.ProtonFallback
|
||||
import io.narl.protonstream.ui.theme.ProtonStreamTheme
|
||||
import java.time.LocalDate
|
||||
import java.time.ZoneOffset
|
||||
import org.junit.Before
|
||||
import org.junit.Rule
|
||||
import org.junit.Test
|
||||
import org.junit.runner.RunWith
|
||||
import org.robolectric.RobolectricTestRunner
|
||||
import org.robolectric.annotation.Config
|
||||
import org.robolectric.annotation.GraphicsMode
|
||||
import uniffi.pstr_android.ArrangementRecord
|
||||
import uniffi.pstr_android.EpisodeRecord
|
||||
import uniffi.pstr_android.MetadataProvider
|
||||
import uniffi.pstr_android.MetadataSettingsRecord
|
||||
import uniffi.pstr_android.OfflineRecord
|
||||
import uniffi.pstr_android.PaletteRecord
|
||||
import uniffi.pstr_android.SeasonRecord
|
||||
import uniffi.pstr_android.ShareRecord
|
||||
import uniffi.pstr_android.ShelfRecord
|
||||
import uniffi.pstr_android.StorageUsageRecord
|
||||
import uniffi.pstr_android.TileRecord
|
||||
import uniffi.pstr_android.CastRecord
|
||||
import uniffi.pstr_android.TitleRecord
|
||||
import uniffi.pstr_android.TitleType
|
||||
|
||||
/**
|
||||
* Every screen, drawn from fixed records, against a committed image.
|
||||
*
|
||||
* The screens are about to be redesigned one by one, and a layout regression —
|
||||
* a row that wraps, a button pushed off the edge — compiles and passes every
|
||||
* other test. Here it is a changed PNG in review. No artwork is loaded: the
|
||||
* bridge is not available on the host, so every tile shows its generated
|
||||
* placeholder, which is also what a title without metadata looks like on a
|
||||
* phone.
|
||||
*/
|
||||
@RunWith(RobolectricTestRunner::class)
|
||||
@GraphicsMode(GraphicsMode.Mode.NATIVE)
|
||||
@Config(qualifiers = RobolectricDeviceQualifiers.Pixel7)
|
||||
class ScreenshotTest {
|
||||
@get:Rule val compose = createComposeRule()
|
||||
|
||||
@Before
|
||||
fun setUp() {
|
||||
WorkManagerTestInitHelper.initializeTestWorkManager(ApplicationProvider.getApplicationContext())
|
||||
// The palette is process-wide and the sandbox outlives a test, so a
|
||||
// light capture would otherwise repaint every one after it.
|
||||
AppearanceState.apply(ProtonFallback)
|
||||
}
|
||||
|
||||
private fun captureLight(name: String, content: @Composable () -> Unit) {
|
||||
AppearanceState.apply(LATTE)
|
||||
capture(name, content)
|
||||
}
|
||||
|
||||
private fun capture(name: String, content: @Composable () -> Unit) {
|
||||
compose.setContent {
|
||||
ProtonStreamTheme {
|
||||
CompositionLocalProvider(LocalArtworkLoading provides false) {
|
||||
Surface(color = MaterialTheme.colorScheme.background) { content() }
|
||||
}
|
||||
}
|
||||
}
|
||||
compose.onRoot().captureRoboImage("src/test/screenshots/$name.png")
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `the library on a phone`() = capture("library-phone") {
|
||||
LibraryScreen(MATCHED, {}, { _, _ -> }, {}, {}, { _, _ -> }, { _, _ -> }, PaddingValues())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `the library offering posters while they are off`() = capture("library-phone-offer") {
|
||||
LibraryScreen(LIBRARY, {}, { _, _ -> }, {}, {}, { _, _ -> }, { _, _ -> }, PaddingValues())
|
||||
}
|
||||
|
||||
@Test
|
||||
@Config(qualifiers = RobolectricDeviceQualifiers.MediumTablet)
|
||||
fun `the library on a tablet`() = capture("library-tablet") {
|
||||
LibraryScreen(MATCHED, {}, { _, _ -> }, {}, {}, { _, _ -> }, { _, _ -> }, PaddingValues())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `the library in the light palette`() = captureLight("library-phone-light") {
|
||||
LibraryScreen(MATCHED, {}, { _, _ -> }, {}, {}, { _, _ -> }, { _, _ -> }, PaddingValues())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a title in the light palette`() = captureLight("title-phone-light") {
|
||||
TitleScreen(SERIES, true, { _, _ -> }, {}, {}, {}, { _, _ -> }, PaddingValues())
|
||||
}
|
||||
|
||||
@Test
|
||||
@Config(qualifiers = RobolectricDeviceQualifiers.MediumTablet)
|
||||
fun `a title on a tablet`() = capture("title-tablet") {
|
||||
TitleScreen(SERIES, true, { _, _ -> }, {}, {}, {}, { _, _ -> }, PaddingValues())
|
||||
}
|
||||
|
||||
@Test
|
||||
@Config(qualifiers = RobolectricDeviceQualifiers.MediumTablet)
|
||||
fun `the history page on a tablet`() = capture("history-tablet") {
|
||||
HistoryScreen(LIBRARY, { _, _ -> }, {}, { _, _ -> }, PaddingValues(), ZoneOffset.UTC, LocalDate.of(2026, 9, 21))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `the library at a large font scale`() = capture("library-phone-large-font") {
|
||||
// Robolectric has no font-scale qualifier; the density is what Compose
|
||||
// reads it from, so the scale is set there.
|
||||
val density = LocalDensity.current
|
||||
CompositionLocalProvider(LocalDensity provides Density(density.density, fontScale = 1.5f)) {
|
||||
LibraryScreen(MATCHED, {}, { _, _ -> }, {}, {}, { _, _ -> }, { _, _ -> }, PaddingValues())
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `an empty library`() = capture("library-empty") {
|
||||
LibraryScreen(AppUiState(loading = false), {}, { _, _ -> }, {}, {}, { _, _ -> }, { _, _ -> }, PaddingValues())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `the library while the catalog loads`() = capture("library-loading") {
|
||||
LibraryScreen(AppUiState(), {}, { _, _ -> }, {}, {}, { _, _ -> }, { _, _ -> }, PaddingValues())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a title with seasons`() = capture("title-phone") {
|
||||
TitleScreen(SERIES, true, { _, _ -> }, {}, {}, {}, { _, _ -> }, PaddingValues(), franchise = listOf(SERIES, SEQUEL))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `the shares page`() = capture("shares-phone") {
|
||||
SharesScreen(LIBRARY.shares, { _, _, _ -> null }, { _, _, _ -> }, {}, {}, null, {}, PaddingValues())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `the settings page`() = capture("settings-phone") {
|
||||
SettingsScreen(LIBRARY, { _, _, _, _ -> null }, {}, {}, {}, PaddingValues(), version = "1.0.0")
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `the playback settings`() = capture("settings-playback") {
|
||||
SettingsScreen(LIBRARY, { _, _, _, _ -> null }, {}, {}, {}, PaddingValues(), SettingsPage.Playback)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `the storage settings`() = capture("settings-storage") {
|
||||
SettingsScreen(LIBRARY, { _, _, _, _ -> null }, {}, {}, {}, PaddingValues(), SettingsPage.Storage)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `the history page`() = capture("history-phone") {
|
||||
// Fixed day and zone: "Today" and "Yesterday" are relative, and an
|
||||
// image that changes with the date is no baseline.
|
||||
HistoryScreen(LIBRARY, { _, _ -> }, {}, { _, _ -> }, PaddingValues(), ZoneOffset.UTC, LocalDate.of(2026, 9, 21))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `the downloads page with a season saved`() = capture("downloads-phone") {
|
||||
val saved = SERIES.seasons.first().episodes.take(2).map {
|
||||
OfflineRecord(it.shareId, it.linkId, "revision", 1_300_000_000uL, it)
|
||||
}
|
||||
// One title: the fixtures reuse link ids across titles, so with the
|
||||
// whole library the episodes would be filed under whichever came last.
|
||||
DownloadsScreen(LIBRARY.copy(titles = listOf(SERIES), offline = saved), {}, {}, {}, {}, PaddingValues())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `the downloads page with nothing offline`() = capture("downloads-empty") {
|
||||
DownloadsScreen(LIBRARY, {}, {}, {}, {}, PaddingValues())
|
||||
}
|
||||
|
||||
private companion object {
|
||||
/** Catppuccin Latte with the default accent, as `Palette::resolve` gives it. */
|
||||
val LATTE = PaletteRecord(
|
||||
background = 0xFFEFF1F5u,
|
||||
surface = 0xFFE6E9EFu,
|
||||
sunken = 0xFFDCE0E8u,
|
||||
card = 0xFFCCD0DAu,
|
||||
cardHover = 0xFFBCC0CCu,
|
||||
border = 0xFFBCC0CCu,
|
||||
text = 0xFF4C4F69u,
|
||||
muted = 0xFF6C6F85u,
|
||||
accent = 0xFF732FCCu,
|
||||
accentAlt = 0xFF1856D1u,
|
||||
accentDim = 0xFFC4BBE4u,
|
||||
onAccent = 0xFFEFF1F5u,
|
||||
danger = 0xFFD20F39u,
|
||||
elevated = 0xFFAAAEBDu,
|
||||
dangerDim = 0xFFE7D1D5u,
|
||||
light = true,
|
||||
)
|
||||
|
||||
fun episode(season: UInt, number: UInt, watched: Boolean = false, progress: Double? = null): EpisodeRecord {
|
||||
// `detail` is the filename unless the filename named the episode,
|
||||
// which is what the bridge sends.
|
||||
val file = "[Group] Oshi no Ko - S%02dE%02d.mkv".format(season.toInt(), number.toInt())
|
||||
return episodeRecord(season, number, file, watched, progress)
|
||||
}
|
||||
|
||||
fun episodeRecord(season: UInt, number: UInt, file: String, watched: Boolean, progress: Double?) = EpisodeRecord(
|
||||
shareId = "share",
|
||||
volumeId = "volume",
|
||||
linkId = "s${season}e$number",
|
||||
name = file,
|
||||
label = "S%02dE%02d".format(season.toInt(), number.toInt()),
|
||||
detail = file,
|
||||
season = season,
|
||||
number = number,
|
||||
size = 1_300_000_000uL,
|
||||
progress = progress,
|
||||
resumeAt = progress?.let { it * 1440 },
|
||||
watched = watched,
|
||||
offline = false,
|
||||
providerName = if (number == 1u) "Mother and Children" else null,
|
||||
providerOverview = null,
|
||||
stillUrl = null,
|
||||
airDate = null,
|
||||
// 2026-09-21 for the part-watched one, the day before for the rest.
|
||||
lastPlayed = when {
|
||||
progress != null -> 1_790_000_000L
|
||||
watched -> 1_789_900_000L - number.toLong() * 600
|
||||
else -> 0L
|
||||
},
|
||||
)
|
||||
|
||||
fun title(key: String, name: String, seasons: List<SeasonRecord>, overview: String? = null) = TitleRecord(
|
||||
key = key,
|
||||
name = name,
|
||||
year = 2023u,
|
||||
kind = TitleType.SERIES,
|
||||
watchedCount = seasons.sumOf { s -> s.episodes.count { it.watched } }.toULong(),
|
||||
episodeCount = seasons.sumOf { it.episodes.size }.toULong(),
|
||||
canonicalName = null,
|
||||
originalName = null,
|
||||
overview = overview,
|
||||
metadataProvider = null,
|
||||
metadataId = null,
|
||||
metadataYear = null,
|
||||
metadataKind = null,
|
||||
posterUrl = null,
|
||||
backdropUrl = null,
|
||||
rating = 8.4,
|
||||
genres = listOf("Drama", "Mystery"),
|
||||
providerEpisodeCount = null,
|
||||
externalUrl = null,
|
||||
manualMatch = false,
|
||||
seasons = seasons,
|
||||
displayName = name,
|
||||
wideUrl = null,
|
||||
formatLabel = null,
|
||||
seasonLabel = null,
|
||||
studios = emptyList(),
|
||||
directors = emptyList(),
|
||||
tags = emptyList(),
|
||||
airing = false,
|
||||
nextEpisode = null,
|
||||
nextAiringAt = null,
|
||||
franchise = emptyList(),
|
||||
// What the engine would pick for these fixtures: the part-watched episode.
|
||||
continueIndex = seasons.flatMap { it.episodes }.indexOfFirst { it.resumeAt != null }
|
||||
.takeIf { it >= 0 }?.toUInt(),
|
||||
)
|
||||
|
||||
val SERIES = title(
|
||||
"oshi-no-ko",
|
||||
"Oshi no Ko",
|
||||
listOf(
|
||||
SeasonRecord(1u, "Season 1", (1u..4u).map { episode(1u, it, watched = it < 3u) }),
|
||||
SeasonRecord(2u, "Season 2", (1u..3u).map { episode(2u, it, progress = if (it == 1u) 0.4 else null) }),
|
||||
),
|
||||
overview = "A doctor in a countryside clinic is reborn as the child of the idol he followed.",
|
||||
).copy(
|
||||
formatLabel = "TV",
|
||||
seasonLabel = "Spring 2023",
|
||||
studios = listOf("Doga Kobo"),
|
||||
directors = listOf("Daisuke Hiramaki"),
|
||||
tags = listOf("Idol", "Reincarnation"),
|
||||
franchise = listOf("oshi-no-ko", "oshi-no-ko-film"),
|
||||
trailerUrl = "https://www.youtube.com/watch?v=trailer",
|
||||
cast = listOf(
|
||||
CastRecord("Ai Hoshino", null, "Rie Takahashi", null),
|
||||
CastRecord("Aqua", null, "Takeo Ootsuka", null),
|
||||
),
|
||||
)
|
||||
|
||||
/** The rest of [SERIES]'s franchise, for the title page's row of it. */
|
||||
val SEQUEL = title("oshi-no-ko-film", "Oshi no Ko: The Final Act", listOf(SeasonRecord(null, "Film", listOf(episode(1u, 1u)))))
|
||||
.copy(kind = TitleType.FILM, year = 2024u, formatLabel = "Film", franchise = SERIES.franchise)
|
||||
|
||||
val FILM = title("akira", "Akira", listOf(SeasonRecord(null, "Film", listOf(episode(1u, 1u)))))
|
||||
.copy(kind = TitleType.FILM, year = 1988u)
|
||||
|
||||
/** [LIBRARY] with posters on, as the shelf above its grid implies. */
|
||||
val MATCHED get() = LIBRARY.copy(
|
||||
metadataSettings = MetadataSettingsRecord(
|
||||
enabled = true,
|
||||
provider = MetadataProvider.ANI_LIST,
|
||||
language = "en",
|
||||
ready = true,
|
||||
),
|
||||
)
|
||||
|
||||
val LIBRARY = AppUiState(
|
||||
loading = false,
|
||||
titles = listOf(
|
||||
FILM,
|
||||
SERIES,
|
||||
title("ano-hana", "Ano Hi Mita Hana no Namae o Bokutachi wa Mada Shiranai.", listOf(SeasonRecord(1u, "Season 1", (1u..11u).map { episode(1u, it) }))),
|
||||
title("bleach", "Bleach", listOf(SeasonRecord(1u, "Season 1", (1u..2u).map { episode(1u, it) }))),
|
||||
),
|
||||
// Bleach stands for one more title of its franchise, and one shelf
|
||||
// sits above the grid: what the bridge's arrangement would give.
|
||||
arrangement = ArrangementRecord(
|
||||
tiles = listOf("akira", "ano-hana", "bleach", "oshi-no-ko").map {
|
||||
TileRecord(it, if (it == "bleach") 1u else 0u)
|
||||
},
|
||||
shelves = listOf(ShelfRecord("From MAPPA", listOf("oshi-no-ko", "bleach", "akira"))),
|
||||
),
|
||||
// Crawled relative to the clock, so the row reads "2 h ago" on
|
||||
// whatever day the test runs.
|
||||
shares = listOf(
|
||||
ShareRecord(
|
||||
"anime",
|
||||
"anime",
|
||||
true,
|
||||
crawledAt = System.currentTimeMillis() / 1_000 - 2 * 3_600,
|
||||
crawlFiles = 120uL,
|
||||
),
|
||||
ShareRecord("films", "films", false, crawlError = "the link was revoked or has expired"),
|
||||
),
|
||||
storage = StorageUsageRecord(6_400_000_000uL, 3uL, 0uL, 1_100_000_000uL),
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
package io.narl.protonstream.ui
|
||||
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertNull
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Test
|
||||
import uniffi.pstr_android.TitleRecord
|
||||
import uniffi.pstr_android.TitleType
|
||||
|
||||
class StatusTest {
|
||||
private fun title(status: String?, airing: Boolean = false, next: Pair<UInt, Long>? = null) = TitleRecord(
|
||||
key = "k", name = "k", year = null, kind = TitleType.SERIES, watchedCount = 0uL,
|
||||
episodeCount = 0uL, canonicalName = null, originalName = null, overview = null,
|
||||
metadataProvider = null, metadataId = null, metadataYear = null, metadataKind = null, posterUrl = null,
|
||||
backdropUrl = null, rating = null, genres = emptyList(), providerEpisodeCount = null, externalUrl = null,
|
||||
manualMatch = false, seasons = emptyList(),
|
||||
displayName = "k", wideUrl = null, formatLabel = null, seasonLabel = null, studios = emptyList(),
|
||||
directors = emptyList(), tags = emptyList(), airing = airing, nextEpisode = next?.first,
|
||||
nextAiringAt = next?.second, franchise = emptyList(), continueIndex = null, status = status,
|
||||
)
|
||||
|
||||
@Test
|
||||
fun `the status chip names the next episode while one is due`() {
|
||||
val (text, live) = statusText(title("Airing", airing = true, next = 8u to 1_800_000_000L))!!
|
||||
assertTrue(text, text.startsWith("Airing · episode 8 "))
|
||||
assertTrue(live)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a finished title is said plainly and is not live`() {
|
||||
assertEquals("Finished" to false, statusText(title("Finished")))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a title with no status and nothing airing has no chip`() {
|
||||
assertNull(statusText(title(null)))
|
||||
assertEquals("Airing" to true, statusText(title(null, airing = true)))
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,129 @@
|
||||
package io.narl.protonstream.ui.theme
|
||||
|
||||
import androidx.compose.material3.ColorScheme
|
||||
import androidx.compose.material3.darkColorScheme
|
||||
import androidx.compose.material3.lightColorScheme
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Test
|
||||
import uniffi.pstr_android.PaletteRecord
|
||||
|
||||
/**
|
||||
* That every Material colour role is answered by the palette.
|
||||
*
|
||||
* Material names roughly fifty roles and a `ColorScheme` is built by copying a
|
||||
* baseline, so a role nobody names keeps Material's own purple-and-grey —
|
||||
* silently, and only where that particular role happens to be drawn. That is
|
||||
* how `surfaceContainerHighest` went unnoticed: nothing referred to it by name,
|
||||
* but it is what `Card` fills with, so every tile, episode row, share and
|
||||
* download in the app was baseline neutral on a flavoured page.
|
||||
*
|
||||
* The check is reflective rather than a list, because a list is exactly as
|
||||
* incomplete as the mapping it was written from — and because Material adds
|
||||
* roles between versions, and a role added tomorrow should fail this the day
|
||||
* the dependency moves.
|
||||
*/
|
||||
class SchemeRolesTest {
|
||||
/**
|
||||
* A palette whose every colour is distinct and none of which Material would
|
||||
* ever produce, so that "this role came from the palette" is decidable by
|
||||
* looking at the value.
|
||||
*/
|
||||
private fun sentinels(light: Boolean) = PaletteRecord(
|
||||
background = 0xFF010203u,
|
||||
surface = 0xFF040506u,
|
||||
sunken = 0xFF070809u,
|
||||
card = 0xFF0A0B0Cu,
|
||||
cardHover = 0xFF0D0E0Fu,
|
||||
border = 0xFF101112u,
|
||||
text = 0xFF131415u,
|
||||
muted = 0xFF161718u,
|
||||
accent = 0xFF191A1Bu,
|
||||
accentAlt = 0xFF1C1D1Eu,
|
||||
accentDim = 0xFF1F2021u,
|
||||
onAccent = 0xFF222324u,
|
||||
danger = 0xFF252627u,
|
||||
elevated = 0xFF28292Au,
|
||||
dangerDim = 0xFF2B2C2Du,
|
||||
light = light,
|
||||
)
|
||||
|
||||
/** Every role on a `ColorScheme`, read by name, whatever the version names. */
|
||||
private fun roles(scheme: ColorScheme): Map<String, Color> =
|
||||
ColorScheme::class.java.declaredMethods
|
||||
.filter { it.name.startsWith("get") && it.parameterCount == 0 && it.returnType == Long::class.java }
|
||||
.associate { method ->
|
||||
method.isAccessible = true
|
||||
// Kotlin mangles the getter of a value-class property:
|
||||
// `getPrimary-0d7_KjU`. The property is what the name says up
|
||||
// to the dash.
|
||||
method.name.removePrefix("get").substringBefore('-') to
|
||||
Color((method.invoke(scheme) as Long).toULong())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `every role is answered by the palette`() {
|
||||
for (light in listOf(false, true)) {
|
||||
val palette = sentinels(light)
|
||||
val allowed = buildSet {
|
||||
addAll(
|
||||
listOf(
|
||||
palette.background, palette.surface, palette.sunken, palette.card,
|
||||
palette.cardHover, palette.border, palette.text, palette.muted,
|
||||
palette.accent, palette.accentAlt, palette.accentDim, palette.onAccent,
|
||||
palette.danger, palette.elevated, palette.dangerDim,
|
||||
).map { Color(it.toInt()) },
|
||||
)
|
||||
// The two roles that are deliberately not a palette colour: no
|
||||
// elevation tint at all, and a scrim that is an absence of
|
||||
// light rather than a hue.
|
||||
add(Color.Transparent)
|
||||
add(Color.Black)
|
||||
}
|
||||
|
||||
val leaked = roles(schemeOf(palette)).filterValues { it !in allowed }
|
||||
assertTrue(
|
||||
"light=$light: ${leaked.keys.sorted()} still carry Material's baseline",
|
||||
leaked.isEmpty(),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The count is asserted too. A reflective sweep that found no roles at all
|
||||
* would pass the check above for the wrong reason.
|
||||
*/
|
||||
@Test
|
||||
fun `the sweep sees the whole scheme`() {
|
||||
val count = roles(darkColorScheme()).size
|
||||
assertTrue("only $count roles found; the reflective sweep is not working", count >= 40)
|
||||
assertEquals(count, roles(lightColorScheme()).size)
|
||||
}
|
||||
|
||||
/**
|
||||
* Which baseline was copied stops being observable once every role is
|
||||
* mapped — and that is the point.
|
||||
*
|
||||
* `light` still decides plenty, but it decides it in Rust, where it picks
|
||||
* the flavour's colours. By the time a palette reaches Kotlin the
|
||||
* light-or-dark question is already answered *inside* the colours, so two
|
||||
* palettes that differ only in the flag have to produce the same scheme. If
|
||||
* they ever stop doing so, something is reading `light` here rather than
|
||||
* reading the colours.
|
||||
*/
|
||||
@Test
|
||||
fun `the baseline no longer shows through`() {
|
||||
assertEquals(roles(schemeOf(sentinels(false))), roles(schemeOf(sentinels(true))))
|
||||
assertEquals(Color.Transparent, schemeOf(sentinels(false)).surfaceTint)
|
||||
}
|
||||
|
||||
/** The fallback goes through the same mapping as any other palette. */
|
||||
@Test
|
||||
fun `the shipped fallback is fully mapped too`() {
|
||||
val scheme = schemeOf(ProtonFallback)
|
||||
assertEquals(Color(ProtonFallback.elevated.toInt()), scheme.surfaceContainerHighest)
|
||||
assertEquals(Color(ProtonFallback.card.toInt()), scheme.surfaceContainer)
|
||||
assertEquals(Color(ProtonFallback.dangerDim.toInt()), scheme.errorContainer)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,111 @@
|
||||
package io.narl.protonstream.ui.theme
|
||||
|
||||
import androidx.compose.material3.Typography
|
||||
import androidx.compose.ui.text.TextStyle
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Test
|
||||
|
||||
/**
|
||||
* That the type ramp is the ramp, and the corner radius is the radius.
|
||||
*
|
||||
* The two clients share a palette because one Rust function resolves it for
|
||||
* both. Type and shape have no such bridge — they are named in
|
||||
* `pstr_app::theme::Role` and copied here — so what stops them drifting is this
|
||||
* test and the numbers in it. Change the Rust ramp and this fails; that is the
|
||||
* point of it.
|
||||
*
|
||||
* Every style is named rather than checked against a set, because "some rung"
|
||||
* is not the property that matters: `bodyMedium`'s Material default is 14sp,
|
||||
* which is a rung, so a `bodyMedium` nobody mapped would pass a looser check
|
||||
* while sitting one step off where it belongs.
|
||||
*/
|
||||
class TypeRampTest {
|
||||
/** `Role::size()`, in the order `theme.rs` declares it. */
|
||||
private val ramp = listOf(34f, 26f, 20f, 18f, 17f, 15f, 14f, 13f, 12f, 11f)
|
||||
|
||||
private val expected = mapOf(
|
||||
"displayLarge" to 34f,
|
||||
"displayMedium" to 26f,
|
||||
"displaySmall" to 20f,
|
||||
"headlineLarge" to 26f,
|
||||
"headlineMedium" to 26f,
|
||||
"headlineSmall" to 18f,
|
||||
"titleLarge" to 20f,
|
||||
"titleMedium" to 17f,
|
||||
"titleSmall" to 15f,
|
||||
"bodyLarge" to 14f,
|
||||
"bodyMedium" to 13f,
|
||||
"bodySmall" to 12f,
|
||||
"labelLarge" to 13f,
|
||||
"labelMedium" to 12f,
|
||||
"labelSmall" to 11f,
|
||||
)
|
||||
|
||||
@Test
|
||||
fun `every text style sits on the shared ramp`() {
|
||||
expected.forEach { (name, size) ->
|
||||
assertEquals(name, size, styles().getValue(name).fontSize.value, 0.001f)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Material adds type styles between versions, and one added tomorrow would
|
||||
* arrive at whatever size Material chose for it. The list above is only as
|
||||
* complete as the day it was written unless something says so.
|
||||
*/
|
||||
@Test
|
||||
fun `no text style is left at Material's own size`() {
|
||||
assertEquals(expected.keys, styles().keys)
|
||||
}
|
||||
|
||||
/**
|
||||
* Leading is derived from the size, so the only thing worth asserting is
|
||||
* that it *was* derived — a style that kept Material's line height carries
|
||||
* a number with no relation to its own size. The hero line is the one set
|
||||
* tighter.
|
||||
*/
|
||||
@Test
|
||||
fun `line height follows the size it belongs to`() {
|
||||
styles().forEach { (name, style) ->
|
||||
val leading = if (name == "displayLarge") HERO_LEADING else LEADING
|
||||
assertEquals(name, style.fontSize.value * leading, style.lineHeight.value, 0.01f)
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `the ramp has no rung the styles never reach`() {
|
||||
val drawn = styles().values.map { it.fontSize.value }.toSet()
|
||||
ramp.forEach { rung -> assertTrue("$rung is named but never drawn", rung in drawn) }
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `every surface is drawn at the desktop's radius`() {
|
||||
listOf(ProtonShapes.small, ProtonShapes.medium, ProtonShapes.large).forEach { shape ->
|
||||
// `CornerSize` resolves against a size and a density, and neither is
|
||||
// available to a unit test; `toString` is the only handle on the
|
||||
// value that does not need a composition.
|
||||
assertTrue("$shape is not radius $CORNER", shape.toString().contains("$CORNER"))
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Every text style a caller can set, read by name, whatever the version
|
||||
* names.
|
||||
*
|
||||
* Material 1.4 carries a second, `Emphasized` copy of all fifteen for its
|
||||
* expressive components. Those are `internal` — the constructor takes them
|
||||
* but Kotlin will not let this module pass them — so they keep Material's
|
||||
* own sizes and nothing here can do anything about it. Their getters are
|
||||
* name-mangled with `$material3`, which is how they are told apart from a
|
||||
* public style that genuinely went unmapped.
|
||||
*/
|
||||
private fun styles(): Map<String, TextStyle> = Typography::class.java.declaredMethods
|
||||
.filter { it.parameterCount == 0 && it.returnType == TextStyle::class.java }
|
||||
.filterNot { '$' in it.name }
|
||||
.associate { method ->
|
||||
method.isAccessible = true
|
||||
method.name.removePrefix("get").replaceFirstChar(Char::lowercaseChar) to
|
||||
method.invoke(ProtonTypography) as TextStyle
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
# Robolectric ships no Android image for API 36 yet, and defaults to the app's
|
||||
# targetSdk, so host tests would fail to start. 34 is the newest it has and is
|
||||
# well above minSdk 31; anything whose behaviour actually turns on the API level
|
||||
# belongs in androidTest, on a real device, not here.
|
||||
sdk=34
|
||||
|
||||
# ProtonStreamApplication.onCreate() calls System.loadLibrary("pstr_android"),
|
||||
# which has no host build. Host tests get a plain Application; everything that
|
||||
# needs the bridge is an instrumentation test by construction — see
|
||||
# androidTest/.../UniFfiBoundaryTest.
|
||||
application=android.app.Application
|
||||
|
After Width: | Height: | Size: 33 KiB |
|
After Width: | Height: | Size: 116 KiB |
|
After Width: | Height: | Size: 217 KiB |
|
After Width: | Height: | Size: 194 KiB |
|
After Width: | Height: | Size: 59 KiB |
|
After Width: | Height: | Size: 181 KiB |
|
After Width: | Height: | Size: 867 KiB |
|
After Width: | Height: | Size: 828 KiB |
|
After Width: | Height: | Size: 782 KiB |
|
After Width: | Height: | Size: 888 KiB |