Compare commits
1 commit
main
...
renovate/c
| Author | SHA1 | Date | |
|---|---|---|---|
| 1b8baee7a0 |
29 changed files with 633 additions and 688 deletions
|
|
@ -1,138 +0,0 @@
|
||||||
# Straw APK — build on Forgejo Actions, and (gated on a green build) publish to
|
|
||||||
# fdroid.sulkta.com so the in-app updater picks it up.
|
|
||||||
#
|
|
||||||
# Runs in the dedicated git.sulkta.com/sulkta-infra/straw-build image (Android
|
|
||||||
# SDK/NDK + Rust + cargo-ndk, see ci/Dockerfile). The signing key comes from
|
|
||||||
# the STRAW_SIGNING_KEYSTORE_B64 secret (the same androiddebugkey the whole
|
|
||||||
# series is signed with — vaulted as "Sulkta — Straw fdroid signing keystore").
|
|
||||||
#
|
|
||||||
# Publish is ROOTLESS (#444): the signed APK is handed to a forced-command
|
|
||||||
# script on the Lucy host (key STRAW_FDROID_LUCY_KEY, pinned to that one script
|
|
||||||
# via restrict,command=). The host does the fdroid index re-sign + the Rackham
|
|
||||||
# rsync — the signing keystore never enters this CI container, and this job no
|
|
||||||
# longer needs the host docker socket. The host script re-verifies the APK
|
|
||||||
# signer before publishing.
|
|
||||||
name: build-apk
|
|
||||||
|
|
||||||
on:
|
|
||||||
push:
|
|
||||||
branches: [main]
|
|
||||||
paths:
|
|
||||||
- 'strawApp/**'
|
|
||||||
- 'rust/**'
|
|
||||||
- 'buildSrc/**'
|
|
||||||
- 'gradle/**'
|
|
||||||
- 'gradlew'
|
|
||||||
- '*.gradle.kts'
|
|
||||||
- 'gradle.properties'
|
|
||||||
- 'ci/Dockerfile'
|
|
||||||
- '.forgejo/workflows/build.yml'
|
|
||||||
workflow_dispatch:
|
|
||||||
|
|
||||||
jobs:
|
|
||||||
build-and-publish:
|
|
||||||
runs-on: lucy-rust
|
|
||||||
# Run every step with bash, not the runner's default dash — the steps use
|
|
||||||
# `set -euo pipefail` (pipefail is a bashism; dash errors "Illegal option").
|
|
||||||
# The straw-build image ships /usr/bin/bash.
|
|
||||||
defaults:
|
|
||||||
run:
|
|
||||||
shell: bash
|
|
||||||
container:
|
|
||||||
image: git.sulkta.com/sulkta-infra/straw-build:latest
|
|
||||||
steps:
|
|
||||||
# We clone with plain git instead of actions/checkout@v4: that action is
|
|
||||||
# a Node action, and the straw-build job container ships the Android +
|
|
||||||
# Rust toolchain but NOT node — so checkout@v4 dies with
|
|
||||||
# `exec: "node": not found`. git is in the image, both repos are public,
|
|
||||||
# and a shell clone also sidesteps the runner's flaky data.forgejo.org
|
|
||||||
# action fetch. strawcore must be a SIBLING of straw because
|
|
||||||
# rust/strawcore depends on it via `path = "../../../strawcore"`.
|
|
||||||
- name: Checkout straw + strawcore (sibling, no JS actions)
|
|
||||||
run: |
|
|
||||||
set -euo pipefail
|
|
||||||
git clone https://git.sulkta.com/Sulkta-OSS/straw.git straw
|
|
||||||
git -C straw checkout --detach "${{ github.sha }}"
|
|
||||||
git clone --depth 1 https://git.sulkta.com/Sulkta-OSS/strawcore.git strawcore
|
|
||||||
echo "straw: $(git -C straw rev-parse --short HEAD)"
|
|
||||||
echo "strawcore: $(git -C strawcore rev-parse --short HEAD)"
|
|
||||||
|
|
||||||
- name: Decode signing keystore
|
|
||||||
env:
|
|
||||||
KS_B64: ${{ secrets.STRAW_SIGNING_KEYSTORE_B64 }}
|
|
||||||
run: echo "$KS_B64" | base64 -d > "$GITHUB_WORKSPACE/straw.keystore"
|
|
||||||
|
|
||||||
- name: Assemble debug APK
|
|
||||||
working-directory: straw
|
|
||||||
env:
|
|
||||||
STRAW_KEYSTORE_FILE: ${{ github.workspace }}/straw.keystore
|
|
||||||
STRAW_KEYSTORE_PASS: android
|
|
||||||
STRAW_KEY_ALIAS: androiddebugkey
|
|
||||||
STRAW_KEY_PASS: android
|
|
||||||
# Keep the 4-ABI cross-compile off the container rootfs.
|
|
||||||
CARGO_TARGET_DIR: ${{ github.workspace }}/cargo-target
|
|
||||||
# ionice idle class + low nice so the build yields disk/CPU to the
|
|
||||||
# live DBs on Lucy — a 5GB build I/O-starved the MAS Postgres
|
|
||||||
# checkpoint and dropped Matrix for ~2min on 2026-06-19.
|
|
||||||
run: ionice -c3 nice -n 19 ./gradlew :strawApp:assembleDebug --no-daemon --stacktrace
|
|
||||||
|
|
||||||
- name: Verify signer + stage APK
|
|
||||||
working-directory: straw
|
|
||||||
run: |
|
|
||||||
set -euo pipefail
|
|
||||||
VC=$(grep STRAW_VERSION_CODE buildSrc/src/main/kotlin/ProjectConfig.kt | grep -o '[0-9]\+')
|
|
||||||
APK=$(ls strawApp/build/outputs/apk/debug/*.apk | head -1)
|
|
||||||
NAME="com.sulkta.straw.debug_${VC}.apk"
|
|
||||||
cp "$APK" "$GITHUB_WORKSPACE/$NAME"
|
|
||||||
echo "Built vc=$VC -> $NAME"
|
|
||||||
# The whole series is signed with SHA-1 bb9ca96b...; fail loudly if a
|
|
||||||
# build ever produces a different signer (would break in-place updates).
|
|
||||||
# apksigner lives under build-tools/<ver>/. AGP (compileSdk 36) needs
|
|
||||||
# build-tools 36; older straw-build images pre-installed only 34.0.0 and
|
|
||||||
# AGP auto-fetched 36 at build time, so this sort -V | tail -1 resolves to
|
|
||||||
# 36.0.0's apksigner. (ci/Dockerfile now pre-installs 36 too — see there.)
|
|
||||||
# apksigner is a shell wrapper that needs `java` on PATH; the image
|
|
||||||
# sets JAVA_HOME but doesn't put its bin on PATH for run steps (gradle
|
|
||||||
# uses JAVA_HOME directly, so the build itself is fine).
|
|
||||||
export PATH="$JAVA_HOME/bin:$PATH"
|
|
||||||
APKSIGNER=$(ls "$ANDROID_HOME"/build-tools/*/apksigner | sort -V | tail -1)
|
|
||||||
FP=$("$APKSIGNER" verify --print-certs "$APK" | grep -i 'SHA-1' | grep -o '[0-9a-f]\{40\}')
|
|
||||||
echo "signer SHA-1: $FP"
|
|
||||||
if [ "$FP" != "bb9ca96b10ebbc1ac48e037a21f350415d18915f" ]; then
|
|
||||||
echo "::error::APK signer $FP != canonical key — refusing to publish"; exit 1
|
|
||||||
fi
|
|
||||||
echo "STRAW_VC=$VC" >> "$GITHUB_ENV"
|
|
||||||
echo "STRAW_APK=$NAME" >> "$GITHUB_ENV"
|
|
||||||
|
|
||||||
# ---- Publish (rootless, no docker socket): hand the signed APK to the
|
|
||||||
# Lucy host forced-command. The host re-verifies the signer, re-signs
|
|
||||||
# the fdroid index (keystore stays on Lucy), and rsyncs to Rackham. ----
|
|
||||||
- name: Publish to fdroid via Lucy host forced-command
|
|
||||||
if: github.event_name == 'push' && github.ref == 'refs/heads/main'
|
|
||||||
env:
|
|
||||||
LUCY_KEY: ${{ secrets.STRAW_FDROID_LUCY_KEY }}
|
|
||||||
# Publish target + its host-key are Forgejo secrets, NOT literals, so
|
|
||||||
# this public workflow carries no infra topology — only references.
|
|
||||||
PUBLISH_TARGET: ${{ secrets.STRAW_PUBLISH_TARGET }}
|
|
||||||
LUCY_HOSTKEY: ${{ secrets.STRAW_LUCY_HOSTKEY }}
|
|
||||||
run: |
|
|
||||||
set -euo pipefail
|
|
||||||
install -d -m700 "$HOME/.ssh"
|
|
||||||
printf '%s\n' "$LUCY_KEY" > "$HOME/.ssh/id_lucy"
|
|
||||||
chmod 600 "$HOME/.ssh/id_lucy"
|
|
||||||
# Pin the publish host key (no TOFU) — from a secret, not a literal.
|
|
||||||
printf '%s\n' "$LUCY_HOSTKEY" > "$HOME/.ssh/known_hosts_lucy"
|
|
||||||
# Hand off the signed APK on stdin. Host exit codes: 0 = published,
|
|
||||||
# 3 = already published (benign no-op on a same-commit re-run),
|
|
||||||
# anything else = real failure.
|
|
||||||
set +e
|
|
||||||
ssh -i "$HOME/.ssh/id_lucy" -o IdentitiesOnly=yes -o BatchMode=yes \
|
|
||||||
-o StrictHostKeyChecking=yes -o UserKnownHostsFile="$HOME/.ssh/known_hosts_lucy" \
|
|
||||||
"$PUBLISH_TARGET" "publish ${STRAW_APK}" < "$GITHUB_WORKSPACE/${STRAW_APK}"
|
|
||||||
rc=$?
|
|
||||||
set -e
|
|
||||||
case "$rc" in
|
|
||||||
0) echo "Published vc=${STRAW_VC} to fdroid.sulkta.com (rootless — no docker socket)";;
|
|
||||||
3) echo "vc=${STRAW_VC} already published — nothing to do";;
|
|
||||||
*) echo "::error::fdroid publish failed (rc=$rc)"; exit "$rc";;
|
|
||||||
esac
|
|
||||||
|
|
@ -1,40 +0,0 @@
|
||||||
# .forgejo/workflows/gitleaks.yml
|
|
||||||
#
|
|
||||||
# Sulkta canonical gitleaks workflow. Drop a copy into every public repo at
|
|
||||||
# `.forgejo/workflows/gitleaks.yml` after the Forgejo act_runner is registered
|
|
||||||
# (task #295).
|
|
||||||
#
|
|
||||||
# Pairs with the pre-receive hook installed on every bare repo — that one is
|
|
||||||
# the strict enforcement layer (rejects the push); this one provides the
|
|
||||||
# per-PR red ✗ that branch-protection rules can require before merge.
|
|
||||||
#
|
|
||||||
# Layer 1 (this workflow): visible per-PR status, can be a required check.
|
|
||||||
# Layer 2 (pre-receive hook): strict enforcement at the server.
|
|
||||||
# Layer 3 (johnny5 cron sweep): nightly full-history sweep across all repos.
|
|
||||||
|
|
||||||
name: gitleaks
|
|
||||||
|
|
||||||
on:
|
|
||||||
push:
|
|
||||||
pull_request:
|
|
||||||
|
|
||||||
jobs:
|
|
||||||
scan:
|
|
||||||
runs-on: ubuntu-latest
|
|
||||||
steps:
|
|
||||||
- uses: actions/checkout@v4
|
|
||||||
with:
|
|
||||||
# Full history — gitleaks needs depth to scan a commit range.
|
|
||||||
fetch-depth: 0
|
|
||||||
|
|
||||||
- name: install gitleaks
|
|
||||||
run: |
|
|
||||||
curl -sSL -o gl.tar.gz \
|
|
||||||
https://github.com/gitleaks/gitleaks/releases/download/v8.21.2/gitleaks_8.21.2_linux_x64.tar.gz
|
|
||||||
tar xzf gl.tar.gz gitleaks
|
|
||||||
chmod +x gitleaks
|
|
||||||
./gitleaks version
|
|
||||||
|
|
||||||
- name: scan
|
|
||||||
run: |
|
|
||||||
./gitleaks detect --source . --no-banner --redact --verbose
|
|
||||||
17
.github/changed-lines-count-labeler.yml
vendored
Normal file
17
.github/changed-lines-count-labeler.yml
vendored
Normal file
|
|
@ -0,0 +1,17 @@
|
||||||
|
# Add 'size/small' label to any changes with less than 50 lines
|
||||||
|
size/small:
|
||||||
|
max: 49
|
||||||
|
|
||||||
|
# Add 'size/medium' label to any changes between 50 and 249 lines
|
||||||
|
size/medium:
|
||||||
|
min: 50
|
||||||
|
max: 249
|
||||||
|
|
||||||
|
# Add 'size/large' label to any changes between 250 and 749 lines
|
||||||
|
size/large:
|
||||||
|
min: 250
|
||||||
|
max: 749
|
||||||
|
|
||||||
|
# Add 'size/giant' label to any changes for more than 749 lines
|
||||||
|
size/giant:
|
||||||
|
min: 750
|
||||||
48
.github/workflows/backport-pr.yml
vendored
Normal file
48
.github/workflows/backport-pr.yml
vendored
Normal file
|
|
@ -0,0 +1,48 @@
|
||||||
|
name: Backport merged pull request
|
||||||
|
on:
|
||||||
|
issue_comment:
|
||||||
|
types: [created]
|
||||||
|
permissions:
|
||||||
|
contents: write # for comment creation on original PR
|
||||||
|
pull-requests: write
|
||||||
|
jobs:
|
||||||
|
backport:
|
||||||
|
name: Backport pull request
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
|
||||||
|
# Only run when the comment starts with the `/backport` command on a PR and
|
||||||
|
# the commenter has write access to the repository. We do not want to allow
|
||||||
|
# everybody to trigger backports and create branches in our repository.
|
||||||
|
if: >
|
||||||
|
github.event.issue.pull_request &&
|
||||||
|
startsWith(github.event.comment.body, '/backport ') &&
|
||||||
|
(
|
||||||
|
github.event.comment.author_association == 'OWNER' ||
|
||||||
|
github.event.comment.author_association == 'COLLABORATOR' ||
|
||||||
|
github.event.comment.author_association == 'MEMBER'
|
||||||
|
)
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v6
|
||||||
|
- name: Get backport metadata
|
||||||
|
# the target branch is the first argument after `/backport`
|
||||||
|
env:
|
||||||
|
COMMENT_BODY: ${{ github.event.comment.body }}
|
||||||
|
run: |
|
||||||
|
set -euo pipefail
|
||||||
|
body="$COMMENT_BODY"
|
||||||
|
|
||||||
|
line=${body%%$'\n'*} # Get the first line
|
||||||
|
if [[ $line =~ ^/backport[[:space:]]+([^[:space:]]+) ]]; then
|
||||||
|
echo "BACKPORT_TARGET=${BASH_REMATCH[1]}" >> "$GITHUB_ENV"
|
||||||
|
else
|
||||||
|
echo "Usage: /backport <target-branch>" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
- name: Create backport pull request
|
||||||
|
uses: korthout/backport-action@v4
|
||||||
|
with:
|
||||||
|
add_labels: 'backport'
|
||||||
|
copy_labels_pattern: '.*'
|
||||||
|
label_pattern: ''
|
||||||
|
target_branches: ${{ env.BACKPORT_TARGET }}
|
||||||
38
.github/workflows/build-release-apk.yml
vendored
Normal file
38
.github/workflows/build-release-apk.yml
vendored
Normal file
|
|
@ -0,0 +1,38 @@
|
||||||
|
name: "Build unsigned release APK on master"
|
||||||
|
|
||||||
|
on:
|
||||||
|
workflow_dispatch:
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
release:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v6
|
||||||
|
with:
|
||||||
|
ref: 'master'
|
||||||
|
|
||||||
|
- uses: actions/setup-java@v5
|
||||||
|
with:
|
||||||
|
distribution: 'temurin'
|
||||||
|
java-version: '21'
|
||||||
|
cache: 'gradle'
|
||||||
|
|
||||||
|
- name: "Build release APK"
|
||||||
|
run: ./gradlew assembleRelease --stacktrace
|
||||||
|
|
||||||
|
- name: "Rename APK"
|
||||||
|
run: |
|
||||||
|
VERSION_NAME="$(jq -r ".elements[0].versionName" "app/build/outputs/apk/release/output-metadata.json")"
|
||||||
|
echo "Version name: $VERSION_NAME" >> "$GITHUB_STEP_SUMMARY"
|
||||||
|
echo '```json' >> "$GITHUB_STEP_SUMMARY"
|
||||||
|
cat "app/build/outputs/apk/release/output-metadata.json" >> "$GITHUB_STEP_SUMMARY"
|
||||||
|
echo >> "$GITHUB_STEP_SUMMARY"
|
||||||
|
echo '```' >> "$GITHUB_STEP_SUMMARY"
|
||||||
|
# assume there is only one APK in that folder
|
||||||
|
mv app/build/outputs/apk/release/*.apk "app/build/outputs/apk/release/NewPipe_v$VERSION_NAME.apk"
|
||||||
|
|
||||||
|
- name: "Upload APK"
|
||||||
|
uses: actions/upload-artifact@v7
|
||||||
|
with:
|
||||||
|
name: app
|
||||||
|
path: app/build/outputs/apk/release/*.apk
|
||||||
143
.github/workflows/ci.yml
vendored
Normal file
143
.github/workflows/ci.yml
vendored
Normal file
|
|
@ -0,0 +1,143 @@
|
||||||
|
name: CI
|
||||||
|
|
||||||
|
on:
|
||||||
|
workflow_dispatch:
|
||||||
|
pull_request:
|
||||||
|
branches:
|
||||||
|
- dev
|
||||||
|
- master
|
||||||
|
- refactor
|
||||||
|
- release**
|
||||||
|
paths-ignore:
|
||||||
|
- 'README.md'
|
||||||
|
- 'doc/**'
|
||||||
|
- 'fastlane/**'
|
||||||
|
- 'assets/**'
|
||||||
|
- '.github/**/*.md'
|
||||||
|
- '.github/FUNDING.yml'
|
||||||
|
- '.github/ISSUE_TEMPLATE/**'
|
||||||
|
push:
|
||||||
|
branches:
|
||||||
|
- dev
|
||||||
|
- master
|
||||||
|
paths-ignore:
|
||||||
|
- 'README.md'
|
||||||
|
- 'doc/**'
|
||||||
|
- 'fastlane/**'
|
||||||
|
- 'assets/**'
|
||||||
|
- '.github/**/*.md'
|
||||||
|
- '.github/FUNDING.yml'
|
||||||
|
- '.github/ISSUE_TEMPLATE/**'
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
build-and-test-jvm:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
|
||||||
|
permissions:
|
||||||
|
contents: read
|
||||||
|
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v6
|
||||||
|
- uses: gradle/actions/wrapper-validation@v6
|
||||||
|
|
||||||
|
- name: create and checkout branch
|
||||||
|
# push events already checked out the branch
|
||||||
|
if: github.event_name == 'pull_request'
|
||||||
|
env:
|
||||||
|
BRANCH: ${{ github.head_ref }}
|
||||||
|
run: git checkout -B "$BRANCH"
|
||||||
|
|
||||||
|
- name: set up JDK
|
||||||
|
uses: actions/setup-java@v5
|
||||||
|
with:
|
||||||
|
java-version: 21
|
||||||
|
distribution: "temurin"
|
||||||
|
cache: 'gradle'
|
||||||
|
|
||||||
|
- name: Build debug APK and run jvm tests
|
||||||
|
run: ./gradlew assembleDebug lintDebug testDebugUnitTest --stacktrace -DskipFormatKtlint
|
||||||
|
|
||||||
|
- name: Upload APK
|
||||||
|
uses: actions/upload-artifact@v7
|
||||||
|
with:
|
||||||
|
name: app
|
||||||
|
path: app/build/outputs/apk/debug/*.apk
|
||||||
|
|
||||||
|
test-android:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
timeout-minutes: 20
|
||||||
|
strategy:
|
||||||
|
matrix:
|
||||||
|
include:
|
||||||
|
- api-level: 23
|
||||||
|
target: default
|
||||||
|
arch: x86
|
||||||
|
- api-level: 35
|
||||||
|
target: default
|
||||||
|
arch: x86_64
|
||||||
|
|
||||||
|
permissions:
|
||||||
|
contents: read
|
||||||
|
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v6
|
||||||
|
|
||||||
|
- name: Enable KVM
|
||||||
|
run: |
|
||||||
|
echo 'KERNEL=="kvm", GROUP="kvm", MODE="0666", OPTIONS+="static_node=kvm"' | sudo tee /etc/udev/rules.d/99-kvm4all.rules
|
||||||
|
sudo udevadm control --reload-rules
|
||||||
|
sudo udevadm trigger --name-match=kvm
|
||||||
|
|
||||||
|
- name: set up JDK
|
||||||
|
uses: actions/setup-java@v5
|
||||||
|
with:
|
||||||
|
java-version: 21
|
||||||
|
distribution: "temurin"
|
||||||
|
cache: 'gradle'
|
||||||
|
|
||||||
|
- name: Run android tests
|
||||||
|
uses: reactivecircus/android-emulator-runner@v2
|
||||||
|
with:
|
||||||
|
api-level: ${{ matrix.api-level }}
|
||||||
|
target: ${{ matrix.target }}
|
||||||
|
arch: ${{ matrix.arch }}
|
||||||
|
script: ./gradlew connectedCheck --stacktrace
|
||||||
|
|
||||||
|
- name: Upload test report when tests fail # because the printed out stacktrace (console) is too short, see also #7553
|
||||||
|
uses: actions/upload-artifact@v7
|
||||||
|
if: failure()
|
||||||
|
with:
|
||||||
|
name: android-test-report-api${{ matrix.api-level }}
|
||||||
|
path: app/build/reports/androidTests/connected/**
|
||||||
|
|
||||||
|
sonar:
|
||||||
|
if: ${{ false }} # the key has expired and needs to be regenerated by the sonar admins
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
|
||||||
|
permissions:
|
||||||
|
contents: read
|
||||||
|
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v6
|
||||||
|
with:
|
||||||
|
fetch-depth: 0 # Shallow clones should be disabled for a better relevancy of analysis
|
||||||
|
|
||||||
|
- name: Set up JDK
|
||||||
|
uses: actions/setup-java@v5
|
||||||
|
with:
|
||||||
|
java-version: 21
|
||||||
|
distribution: "temurin"
|
||||||
|
cache: 'gradle'
|
||||||
|
|
||||||
|
- name: Cache SonarCloud packages
|
||||||
|
uses: actions/cache@v5
|
||||||
|
with:
|
||||||
|
path: ~/.sonar/cache
|
||||||
|
key: ${{ runner.os }}-sonar
|
||||||
|
restore-keys: ${{ runner.os }}-sonar
|
||||||
|
|
||||||
|
- name: Build and analyze
|
||||||
|
env:
|
||||||
|
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} # Needed to get PR information, if any
|
||||||
|
SONAR_TOKEN: ${{ secrets.SONAR_TOKEN }}
|
||||||
|
run: ./gradlew build sonar --info
|
||||||
147
.github/workflows/image-minimizer.js
vendored
Normal file
147
.github/workflows/image-minimizer.js
vendored
Normal file
|
|
@ -0,0 +1,147 @@
|
||||||
|
/*
|
||||||
|
* Script for minimizing big images (jpg,gif,png) when they are uploaded to GitHub and not edited otherwise
|
||||||
|
*/
|
||||||
|
module.exports = async ({github, context}) => {
|
||||||
|
const IGNORE_KEY = '<!-- IGNORE IMAGE MINIFY -->';
|
||||||
|
const IGNORE_ALT_NAME_END = 'ignoreImageMinify';
|
||||||
|
// Targeted maximum height
|
||||||
|
const IMG_MAX_HEIGHT_PX = 600;
|
||||||
|
// maximum width of GitHub issues/comments
|
||||||
|
const IMG_MAX_WIDTH_PX = 800;
|
||||||
|
// all images that have a lower aspect ratio (-> have a smaller width) than this will be minimized
|
||||||
|
const MIN_ASPECT_RATIO = IMG_MAX_WIDTH_PX / IMG_MAX_HEIGHT_PX
|
||||||
|
|
||||||
|
// Get the body of the image
|
||||||
|
let initialBody = null;
|
||||||
|
if (context.eventName == 'issue_comment') {
|
||||||
|
initialBody = context.payload.comment.body;
|
||||||
|
} else if (context.eventName == 'issues') {
|
||||||
|
initialBody = context.payload.issue.body;
|
||||||
|
} else if (context.eventName == 'pull_request') {
|
||||||
|
initialBody = context.payload.pull_request.body;
|
||||||
|
} else {
|
||||||
|
console.log('Aborting: No body found');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
console.log(`Found body: \n${initialBody}\n`);
|
||||||
|
|
||||||
|
// Check if we should ignore the currently processing element
|
||||||
|
if (initialBody.includes(IGNORE_KEY)) {
|
||||||
|
console.log('Ignoring: Body contains IGNORE_KEY');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Regex for finding images (simple variant) 
|
||||||
|
const REGEX_USER_CONTENT_IMAGE_LOOKUP = /\!\[([^\]]*)\]\((https:\/\/[-a-z0-9]+\.githubusercontent\.com\/\d+\/[-0-9a-f]{32,512}\.(jpg|gif|png))\)/gm;
|
||||||
|
const REGEX_ASSETS_IMAGE_LOOKUP = /\!\[([^\]]*)\]\((https:\/\/github\.com\/(?:user-attachments\/assets|[-\w\d]+\/[-\w\d]+\/assets\/\d+)\/[\-0-9a-f]{32,512})\)/gm;
|
||||||
|
|
||||||
|
// Check if we found something
|
||||||
|
let foundSimpleImages = REGEX_USER_CONTENT_IMAGE_LOOKUP.test(initialBody)
|
||||||
|
|| REGEX_ASSETS_IMAGE_LOOKUP.test(initialBody);
|
||||||
|
if (!foundSimpleImages) {
|
||||||
|
console.log('Found no simple images to process');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log('Found at least one simple image to process');
|
||||||
|
|
||||||
|
// Require the probe lib for getting the image dimensions
|
||||||
|
const probe = require('probe-image-size');
|
||||||
|
|
||||||
|
var wasMatchModified = false;
|
||||||
|
|
||||||
|
// Try to find and replace the images with minimized ones
|
||||||
|
let newBody = await replaceAsync(initialBody, REGEX_USER_CONTENT_IMAGE_LOOKUP, minimizeAsync);
|
||||||
|
newBody = await replaceAsync(newBody, REGEX_ASSETS_IMAGE_LOOKUP, minimizeAsync);
|
||||||
|
|
||||||
|
if (!wasMatchModified) {
|
||||||
|
console.log('Nothing was modified. Skipping update');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update the corresponding element
|
||||||
|
if (context.eventName == 'issue_comment') {
|
||||||
|
console.log('Updating comment with id', context.payload.comment.id);
|
||||||
|
await github.rest.issues.updateComment({
|
||||||
|
comment_id: context.payload.comment.id,
|
||||||
|
owner: context.repo.owner,
|
||||||
|
repo: context.repo.repo,
|
||||||
|
body: newBody
|
||||||
|
})
|
||||||
|
} else if (context.eventName == 'issues') {
|
||||||
|
console.log('Updating issue', context.payload.issue.number);
|
||||||
|
await github.rest.issues.update({
|
||||||
|
issue_number: context.payload.issue.number,
|
||||||
|
owner: context.repo.owner,
|
||||||
|
repo: context.repo.repo,
|
||||||
|
body: newBody
|
||||||
|
});
|
||||||
|
} else if (context.eventName == 'pull_request') {
|
||||||
|
console.log('Updating pull request', context.payload.pull_request.number);
|
||||||
|
await github.rest.pulls.update({
|
||||||
|
pull_number: context.payload.pull_request.number,
|
||||||
|
owner: context.repo.owner,
|
||||||
|
repo: context.repo.repo,
|
||||||
|
body: newBody
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Async replace function from https://stackoverflow.com/a/48032528
|
||||||
|
async function replaceAsync(str, regex, asyncFn) {
|
||||||
|
const promises = [];
|
||||||
|
str.replace(regex, (match, ...args) => {
|
||||||
|
const promise = asyncFn(match, ...args);
|
||||||
|
promises.push(promise);
|
||||||
|
});
|
||||||
|
const data = await Promise.all(promises);
|
||||||
|
return str.replace(regex, () => data.shift());
|
||||||
|
}
|
||||||
|
|
||||||
|
async function minimizeAsync(match, g1, g2) {
|
||||||
|
console.log(`Found match '${match}'`);
|
||||||
|
|
||||||
|
if (g1.endsWith(IGNORE_ALT_NAME_END)) {
|
||||||
|
console.log(`Ignoring match '${match}': IGNORE_ALT_NAME_END`);
|
||||||
|
return match;
|
||||||
|
}
|
||||||
|
|
||||||
|
let probeAspectRatio = 0;
|
||||||
|
let shouldModify = false;
|
||||||
|
try {
|
||||||
|
console.log(`Probing ${g2}`);
|
||||||
|
let probeResult = await probe(g2);
|
||||||
|
if (probeResult == null) {
|
||||||
|
throw 'No probeResult';
|
||||||
|
}
|
||||||
|
if (probeResult.hUnits != 'px') {
|
||||||
|
throw `Unexpected probeResult.hUnits (expected px but got ${probeResult.hUnits})`;
|
||||||
|
}
|
||||||
|
if (probeResult.height <= 0) {
|
||||||
|
throw `Unexpected probeResult.height (height is invalid: ${probeResult.height})`;
|
||||||
|
}
|
||||||
|
if (probeResult.wUnits != 'px') {
|
||||||
|
throw `Unexpected probeResult.wUnits (expected px but got ${probeResult.wUnits})`;
|
||||||
|
}
|
||||||
|
if (probeResult.width <= 0) {
|
||||||
|
throw `Unexpected probeResult.width (width is invalid: ${probeResult.width})`;
|
||||||
|
}
|
||||||
|
console.log(`Probing resulted in ${probeResult.width}x${probeResult.height}px`);
|
||||||
|
|
||||||
|
probeAspectRatio = probeResult.width / probeResult.height;
|
||||||
|
shouldModify = probeResult.height > IMG_MAX_HEIGHT_PX && probeAspectRatio < MIN_ASPECT_RATIO;
|
||||||
|
} catch(e) {
|
||||||
|
console.log('Probing failed:', e);
|
||||||
|
// Immediately abort
|
||||||
|
return match;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (shouldModify) {
|
||||||
|
wasMatchModified = true;
|
||||||
|
console.log(`Modifying match '${match}'`);
|
||||||
|
return `<img alt="${g1}" src="${g2}" width=${Math.min(600, Math.floor(IMG_MAX_HEIGHT_PX * probeAspectRatio))} />`;
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log(`Match '${match}' is ok/will not be modified`);
|
||||||
|
return match;
|
||||||
|
}
|
||||||
|
}
|
||||||
35
.github/workflows/image-minimizer.yml
vendored
Normal file
35
.github/workflows/image-minimizer.yml
vendored
Normal file
|
|
@ -0,0 +1,35 @@
|
||||||
|
name: Image Minimizer
|
||||||
|
|
||||||
|
on:
|
||||||
|
issue_comment:
|
||||||
|
types: [created, edited]
|
||||||
|
issues:
|
||||||
|
types: [opened, edited]
|
||||||
|
pull_request:
|
||||||
|
types: [opened, edited]
|
||||||
|
|
||||||
|
permissions:
|
||||||
|
issues: write
|
||||||
|
pull-requests: write
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
try-minimize:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v6
|
||||||
|
|
||||||
|
- uses: actions/setup-node@v6
|
||||||
|
with:
|
||||||
|
node-version: 16
|
||||||
|
|
||||||
|
- name: Install probe-image-size
|
||||||
|
run: npm i probe-image-size@7.2.3 --ignore-scripts
|
||||||
|
|
||||||
|
- name: Minimize simple images
|
||||||
|
uses: actions/github-script@v9
|
||||||
|
timeout-minutes: 3
|
||||||
|
with:
|
||||||
|
script: |
|
||||||
|
const script = require('.github/workflows/image-minimizer.js');
|
||||||
|
await script({github, context});
|
||||||
24
.github/workflows/no-response.yml
vendored
Normal file
24
.github/workflows/no-response.yml
vendored
Normal file
|
|
@ -0,0 +1,24 @@
|
||||||
|
name: No Response
|
||||||
|
|
||||||
|
# Both `issue_comment` and `scheduled` event types are required for this Action
|
||||||
|
# to work properly.
|
||||||
|
on:
|
||||||
|
issue_comment:
|
||||||
|
types: [created]
|
||||||
|
schedule:
|
||||||
|
# Run daily at midnight.
|
||||||
|
- cron: '0 0 * * *'
|
||||||
|
|
||||||
|
permissions:
|
||||||
|
issues: write
|
||||||
|
pull-requests: write
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
noResponse:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- uses: lee-dohm/no-response@v0.5.0
|
||||||
|
with:
|
||||||
|
token: ${{ github.token }}
|
||||||
|
daysUntilClose: 14
|
||||||
|
responseRequiredLabel: waiting for author
|
||||||
18
.github/workflows/pr-labeler.yml
vendored
Normal file
18
.github/workflows/pr-labeler.yml
vendored
Normal file
|
|
@ -0,0 +1,18 @@
|
||||||
|
name: "PR size labeler"
|
||||||
|
on: [pull_request_target]
|
||||||
|
permissions:
|
||||||
|
contents: read
|
||||||
|
pull-requests: write
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
changed-lines-count-labeler:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
name: Automatically labelling pull requests based on the changed lines count
|
||||||
|
permissions:
|
||||||
|
pull-requests: write
|
||||||
|
steps:
|
||||||
|
- name: Set a label
|
||||||
|
uses: TeamNewPipe/changed-lines-count-labeler@main
|
||||||
|
with:
|
||||||
|
repo-token: ${{ secrets.GITHUB_TOKEN }}
|
||||||
|
configuration-path: .github/changed-lines-count-labeler.yml
|
||||||
|
|
@ -9,53 +9,6 @@ const val STRAW_SDK_TARGET = 35
|
||||||
|
|
||||||
// Sulkta fork — Straw
|
// Sulkta fork — Straw
|
||||||
//
|
//
|
||||||
// vc=91 / 0.1.0-CY — reliability + security fix sprint (straw audit 2026-07-04):
|
|
||||||
// * The in-app updater no longer self-bricks on a routine cert renewal. It
|
|
||||||
// used to pin the fdroid.sulkta.com leaf SPKI + the LE "E7" intermediate,
|
|
||||||
// but the leaf rotates every ~90-day renewal and LE rotates intermediates
|
|
||||||
// from a pool (they explicitly say don't pin them), so a normal renewal was
|
|
||||||
// GUARANTEED to miss both pins — silently killing the ONLY update path,
|
|
||||||
// with the fix reachable only through the channel the break just killed.
|
|
||||||
// Dropped pinning: default system-CA validation still blocks a MITM, and a
|
|
||||||
// swapped APK can't install over Straw anyway (the package installer
|
|
||||||
// verifies it against our signing key). Also: the check now distinguishes
|
|
||||||
// "couldn't reach the index" from "up to date" (no more "checked just now /
|
|
||||||
// up to date" while actually blind), added a 30s call timeout + a bounded
|
|
||||||
// index read, guarded the API-26 NotificationChannel (was a crash on 7.x),
|
|
||||||
// and setOnlyAlertOnce so a pending update doesn't re-nag every tick.
|
|
||||||
// * Android 13+ update/media notifications actually appear now:
|
|
||||||
// POST_NOTIFICATIONS was manifest-declared but never requested at runtime,
|
|
||||||
// so nm.notify() was a silent no-op on 13+. Requested once on cold start.
|
|
||||||
// * The published APK is no longer debuggable (isDebuggable=false on the
|
|
||||||
// shipped debug variant) — closes an ADB/USB `run-as` dump of watch/search
|
|
||||||
// history + the subscription list. No package-id cutover; updates in place.
|
|
||||||
// * Crash fixes: (a) opening a video whose channel repeats a videoId in its
|
|
||||||
// first ~20 entries hard-crashed Compose on a duplicate LazyColumn key —
|
|
||||||
// moreFromChannel + related now distinctBy url; (b) in-app back died
|
|
||||||
// permanently after one back-to-Home on Android 12+ (moveTaskToBack doesn't
|
|
||||||
// destroy the activity, so the disabled callback never re-enabled) — the
|
|
||||||
// callback is now driven by live nav depth instead of disable-and-redispatch;
|
|
||||||
// (c) the local-playlists store was uncapped, so a hostile NewPipe import
|
|
||||||
// could persist a huge blob that ANR'd every cold start — added per-store
|
|
||||||
// caps (playlists / items / string length) + off-main hydration like
|
|
||||||
// ResumePositionsStore; (d) a SponsorBlock fetch that resolves after you
|
|
||||||
// skip to another video no longer hijacks the now-playing item (staleness
|
|
||||||
// fence).
|
|
||||||
// * Cache-size caps that first-launch defaulted to "Unlimited" (100k rows,
|
|
||||||
// because nearest() was exact-match-or-Unlimited over a set the defaults
|
|
||||||
// weren't in) now snap up to the nearest finite cap.
|
|
||||||
// * The RYD + SponsorBlock FFI shims wrap the uniffi call, so a core panic
|
|
||||||
// (now catchable — see below) is swallowed to null/empty per their contract
|
|
||||||
// instead of crashing video-detail load.
|
|
||||||
// * Rust FFI wrapper: release profile is panic="unwind" (was "abort" — abort
|
|
||||||
// defeated UniFFI's catch_unwind + tokio's spawn_blocking JoinError capture,
|
|
||||||
// turning ANY panic in the extractor core into a whole-app SIGABRT), and
|
|
||||||
// every blocking extractor call is wrapped in a 60s wall-clock timeout so a
|
|
||||||
// wedged JS/HTTP path unblocks the caller instead of spinning forever and
|
|
||||||
// piling detached threads toward tokio's 512 blocking-pool cap. Pairs with
|
|
||||||
// the strawcore-core reliability fix (bounded rquickjs 64MiB/2MiB/5s +
|
|
||||||
// invalidate-on-failure player.js self-heal).
|
|
||||||
//
|
|
||||||
// vc=90 / 0.1.0-CX — cold-start hydration + status-bar bleed fix + RYD FFI slim:
|
// vc=90 / 0.1.0-CX — cold-start hydration + status-bar bleed fix + RYD FFI slim:
|
||||||
// * ResumePositionsStore + EnrichmentStore no longer JSON-decode on the main
|
// * ResumePositionsStore + EnrichmentStore no longer JSON-decode on the main
|
||||||
// thread in Application.onCreate. Resume was the heaviest cold-start cost in
|
// thread in Application.onCreate. Resume was the heaviest cold-start cost in
|
||||||
|
|
@ -359,6 +312,6 @@ const val STRAW_SDK_TARGET = 35
|
||||||
// vc=19 / 0.1.0-AE — rust pipeline cutover. Extraction via
|
// vc=19 / 0.1.0-AE — rust pipeline cutover. Extraction via
|
||||||
// strawcore-core (Sulkta-OSS/strawcore) via the UniFFI wrapper; no
|
// strawcore-core (Sulkta-OSS/strawcore) via the UniFFI wrapper; no
|
||||||
// NewPipeExtractor in the runtime path.
|
// NewPipeExtractor in the runtime path.
|
||||||
const val STRAW_VERSION_CODE = 91
|
const val STRAW_VERSION_CODE = 90
|
||||||
const val STRAW_VERSION_NAME = "0.1.0-CY"
|
const val STRAW_VERSION_NAME = "0.1.0-CX"
|
||||||
const val STRAW_APPLICATION_ID = "com.sulkta.straw"
|
const val STRAW_APPLICATION_ID = "com.sulkta.straw"
|
||||||
|
|
|
||||||
|
|
@ -15,9 +15,9 @@ bridge = "v2.0.2"
|
||||||
cardview = "1.0.0"
|
cardview = "1.0.0"
|
||||||
checkstyle = "13.4.2"
|
checkstyle = "13.4.2"
|
||||||
coil = "3.4.0"
|
coil = "3.4.0"
|
||||||
compose = "1.11.4"
|
compose = "1.11.3"
|
||||||
constraintlayout = "2.2.1"
|
constraintlayout = "2.2.1"
|
||||||
core = "1.18.0"
|
core = "1.19.0"
|
||||||
coroutines = "1.11.0"
|
coroutines = "1.11.0"
|
||||||
desugar = "2.1.5"
|
desugar = "2.1.5"
|
||||||
documentfile = "1.1.0"
|
documentfile = "1.1.0"
|
||||||
|
|
|
||||||
|
|
@ -21,14 +21,7 @@ repository = "https://git.sulkta.com/Sulkta-OSS/straw"
|
||||||
strip = true
|
strip = true
|
||||||
lto = "thin"
|
lto = "thin"
|
||||||
codegen-units = 1
|
codegen-units = 1
|
||||||
# unwind (NOT abort): panic=abort defeats UniFFI's catch_unwind AND tokio's
|
panic = "abort"
|
||||||
# spawn_blocking JoinError capture, turning ANY panic in the extractor core
|
|
||||||
# (which chews weekly-rotating, attacker-influenced YouTube payloads) into an
|
|
||||||
# instant whole-app SIGABRT. unwind restores both safety nets so a core panic
|
|
||||||
# surfaces as a caught StrawcoreException instead of crashing the app. The
|
|
||||||
# unwind-table size cost is a rounding error against the 4-ABI jniLibs payload.
|
|
||||||
# (straw + FFI-wrapper audits, 2026-07-04.)
|
|
||||||
panic = "unwind"
|
|
||||||
opt-level = "z"
|
opt-level = "z"
|
||||||
|
|
||||||
# `url` crate for video-id extraction in stream.rs.
|
# `url` crate for video-id extraction in stream.rs.
|
||||||
|
|
|
||||||
|
|
@ -33,8 +33,11 @@ pub async fn channel_info(input: String) -> Result<ChannelInfo, StrawcoreError>
|
||||||
log::info!("strawcore::channel_info input_len={}", input.len());
|
log::info!("strawcore::channel_info input_len={}", input.len());
|
||||||
crate::runtime::ensure_initialized();
|
crate::runtime::ensure_initialized();
|
||||||
let identifier = resolve_channel_identifier(&input)?;
|
let identifier = resolve_channel_identifier(&input)?;
|
||||||
let core = crate::runtime::run_extract("channel_info", move || core_channel_info(identifier))
|
let core = tokio::task::spawn_blocking(move || core_channel_info(identifier))
|
||||||
.await?;
|
.await
|
||||||
|
.map_err(|e| StrawcoreError::Extractor {
|
||||||
|
msg: format!("join: {e}"),
|
||||||
|
})??;
|
||||||
Ok(map_channel(core))
|
Ok(map_channel(core))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -60,10 +63,11 @@ pub async fn channel_videos_continuation(token: String) -> Result<Page, Strawcor
|
||||||
token.len()
|
token.len()
|
||||||
);
|
);
|
||||||
crate::runtime::ensure_initialized();
|
crate::runtime::ensure_initialized();
|
||||||
let page = crate::runtime::run_extract("channel_videos_continuation", move || {
|
let page = tokio::task::spawn_blocking(move || core_channel_continuation(&token))
|
||||||
core_channel_continuation(&token)
|
.await
|
||||||
})
|
.map_err(|e| StrawcoreError::Extractor {
|
||||||
.await?;
|
msg: format!("join: {e}"),
|
||||||
|
})??;
|
||||||
Ok(page_from_core(page))
|
Ok(page_from_core(page))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -17,14 +17,12 @@
|
||||||
|
|
||||||
use std::sync::atomic::{AtomicBool, AtomicU64, Ordering};
|
use std::sync::atomic::{AtomicBool, AtomicU64, Ordering};
|
||||||
use std::sync::{Arc, Mutex};
|
use std::sync::{Arc, Mutex};
|
||||||
use std::time::{Duration, SystemTime, UNIX_EPOCH};
|
use std::time::{SystemTime, UNIX_EPOCH};
|
||||||
|
|
||||||
use strawcore_core::downloader::ReqwestDownloader;
|
use strawcore_core::downloader::ReqwestDownloader;
|
||||||
use strawcore_core::localization::{ContentCountry, Localization};
|
use strawcore_core::localization::{ContentCountry, Localization};
|
||||||
use strawcore_core::newpipe::NewPipe;
|
use strawcore_core::newpipe::NewPipe;
|
||||||
|
|
||||||
use crate::error::StrawcoreError;
|
|
||||||
|
|
||||||
static INITIALIZED: AtomicBool = AtomicBool::new(false);
|
static INITIALIZED: AtomicBool = AtomicBool::new(false);
|
||||||
static LAST_ATTEMPT_MS: AtomicU64 = AtomicU64::new(0);
|
static LAST_ATTEMPT_MS: AtomicU64 = AtomicU64::new(0);
|
||||||
// Guards the actual init attempt so concurrent calls don't all try
|
// Guards the actual init attempt so concurrent calls don't all try
|
||||||
|
|
@ -96,47 +94,3 @@ pub fn ensure_initialized() {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Wall-clock ceiling on a single blocking extractor call. Core bounds each
|
|
||||||
/// HTTP request to 30s and (since the 2026-07-04 reliability fix) the JS
|
|
||||||
/// deobfuscation runtime to a 5s interrupt, so a legitimate multi-round-trip
|
|
||||||
/// extraction on a slow connection stays comfortably under this; anything
|
|
||||||
/// past it is a wedge (a pathological player.js, a black-holed CDN) and we'd
|
|
||||||
/// rather surface a timeout to the Kotlin caller than spin forever.
|
|
||||||
const EXTRACT_TIMEOUT: Duration = Duration::from_secs(60);
|
|
||||||
|
|
||||||
/// Run a blocking strawcore-core call on the tokio blocking pool, bounded by
|
|
||||||
/// [`EXTRACT_TIMEOUT`]. Replaces the raw `spawn_blocking(..).await??` pattern
|
|
||||||
/// at every FFI entry point so a hung extraction unblocks the Kotlin suspend
|
|
||||||
/// instead of (a) spinning the spinner forever and (b) piling detached
|
|
||||||
/// blocking threads toward tokio's 512-thread cap, after which *all*
|
|
||||||
/// spawn_blocking-based FFI calls would queue behind the wedged ones for the
|
|
||||||
/// process lifetime.
|
|
||||||
///
|
|
||||||
/// Caveat (documented, not a bug): `spawn_blocking` tasks are not abortable,
|
|
||||||
/// so on timeout the detached thread keeps running until the core call
|
|
||||||
/// returns on its own — core's per-request (30s) + JS-interrupt (5s) bounds
|
|
||||||
/// cap how long that is. The win here is unblocking the *caller* and bounding
|
|
||||||
/// retry pileup, not reclaiming the thread instantly.
|
|
||||||
pub(crate) async fn run_extract<T, E, F>(what: &'static str, f: F) -> Result<T, StrawcoreError>
|
|
||||||
where
|
|
||||||
F: FnOnce() -> Result<T, E> + Send + 'static,
|
|
||||||
T: Send + 'static,
|
|
||||||
E: Send + 'static,
|
|
||||||
StrawcoreError: From<E>,
|
|
||||||
{
|
|
||||||
match tokio::time::timeout(EXTRACT_TIMEOUT, tokio::task::spawn_blocking(f)).await {
|
|
||||||
// Timed out waiting on the blocking task.
|
|
||||||
Err(_elapsed) => Err(StrawcoreError::Extractor {
|
|
||||||
msg: format!("timeout: {what} exceeded {}s", EXTRACT_TIMEOUT.as_secs()),
|
|
||||||
}),
|
|
||||||
// Blocking task finished within the deadline but the join failed —
|
|
||||||
// panic (only under a non-abort profile) or cancellation.
|
|
||||||
Ok(Err(join)) => Err(StrawcoreError::Extractor {
|
|
||||||
msg: format!("join: {join}"),
|
|
||||||
}),
|
|
||||||
// Blocking task returned; propagate its own error via the existing
|
|
||||||
// `From<ExtractionError>` mapping.
|
|
||||||
Ok(Ok(inner)) => inner.map_err(StrawcoreError::from),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
|
||||||
|
|
@ -84,10 +84,13 @@ pub async fn search(query: String) -> Result<Page, StrawcoreError> {
|
||||||
// the hot entry points. Now every extractor entry re-asserts
|
// the hot entry points. Now every extractor entry re-asserts
|
||||||
// — cheap when INITIALIZED is true (single Acquire load).
|
// — cheap when INITIALIZED is true (single Acquire load).
|
||||||
crate::runtime::ensure_initialized();
|
crate::runtime::ensure_initialized();
|
||||||
let result = crate::runtime::run_extract("search", move || {
|
let result = tokio::task::spawn_blocking(move || {
|
||||||
search_extractor::search(&query, SearchFilter::Videos)
|
search_extractor::search(&query, SearchFilter::Videos)
|
||||||
})
|
})
|
||||||
.await?;
|
.await
|
||||||
|
.map_err(|e| StrawcoreError::Extractor {
|
||||||
|
msg: format!("join: {e}"),
|
||||||
|
})??;
|
||||||
// Page 1 carries the first continuation token (None once YT stops
|
// Page 1 carries the first continuation token (None once YT stops
|
||||||
// handing them out). The Kotlin SearchViewModel passes it back to
|
// handing them out). The Kotlin SearchViewModel passes it back to
|
||||||
// `search_continuation` on scroll.
|
// `search_continuation` on scroll.
|
||||||
|
|
@ -103,9 +106,10 @@ pub async fn search(query: String) -> Result<Page, StrawcoreError> {
|
||||||
pub async fn search_continuation(token: String) -> Result<Page, StrawcoreError> {
|
pub async fn search_continuation(token: String) -> Result<Page, StrawcoreError> {
|
||||||
log::info!("strawcore::search_continuation token_len={}", token.len());
|
log::info!("strawcore::search_continuation token_len={}", token.len());
|
||||||
crate::runtime::ensure_initialized();
|
crate::runtime::ensure_initialized();
|
||||||
let page = crate::runtime::run_extract("search_continuation", move || {
|
let page = tokio::task::spawn_blocking(move || search_extractor::search_continuation(&token))
|
||||||
search_extractor::search_continuation(&token)
|
.await
|
||||||
})
|
.map_err(|e| StrawcoreError::Extractor {
|
||||||
.await?;
|
msg: format!("join: {e}"),
|
||||||
|
})??;
|
||||||
Ok(page_from_core(page))
|
Ok(page_from_core(page))
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -78,10 +78,11 @@ pub async fn stream_info(input: String) -> Result<StreamInfo, StrawcoreError> {
|
||||||
crate::runtime::ensure_initialized();
|
crate::runtime::ensure_initialized();
|
||||||
let video_id = resolve_video_id(&input)?;
|
let video_id = resolve_video_id(&input)?;
|
||||||
let video_id_for_call = video_id.clone();
|
let video_id_for_call = video_id.clone();
|
||||||
let core = crate::runtime::run_extract("stream_info", move || {
|
let core = tokio::task::spawn_blocking(move || core_stream_info(&video_id_for_call))
|
||||||
core_stream_info(&video_id_for_call)
|
.await
|
||||||
})
|
.map_err(|e| StrawcoreError::Extractor {
|
||||||
.await?;
|
msg: format!("join: {e}"),
|
||||||
|
})??;
|
||||||
Ok(map_stream_info(video_id, core))
|
Ok(map_stream_info(video_id, core))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -94,10 +95,11 @@ pub async fn stream_metadata(input: String) -> Result<(i64, i64), StrawcoreError
|
||||||
log::info!("strawcore::stream_metadata input_len={}", input.len());
|
log::info!("strawcore::stream_metadata input_len={}", input.len());
|
||||||
crate::runtime::ensure_initialized();
|
crate::runtime::ensure_initialized();
|
||||||
let video_id = resolve_video_id(&input)?;
|
let video_id = resolve_video_id(&input)?;
|
||||||
let core = crate::runtime::run_extract("stream_metadata", move || {
|
let core = tokio::task::spawn_blocking(move || core_stream_metadata(&video_id))
|
||||||
core_stream_metadata(&video_id)
|
.await
|
||||||
})
|
.map_err(|e| StrawcoreError::Extractor {
|
||||||
.await?;
|
msg: format!("join: {e}"),
|
||||||
|
})??;
|
||||||
Ok((clamp_nonneg(core.view_count), core.duration_seconds.max(0)))
|
Ok((clamp_nonneg(core.view_count), core.duration_seconds.max(0)))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -66,15 +66,7 @@ configure<ApplicationExtension> {
|
||||||
// strawApp/proguard-rules.pro cover UniFFI + JNA +
|
// strawApp/proguard-rules.pro cover UniFFI + JNA +
|
||||||
// kotlinx-serialization companions.
|
// kotlinx-serialization companions.
|
||||||
debug {
|
debug {
|
||||||
// NOT debuggable, even though this is the `debug` buildType: the
|
isDebuggable = true
|
||||||
// debug variant is what we PUBLISH to fdroid.sulkta.com, and a
|
|
||||||
// shipped debuggable APK lets any ADB/USB attacker `run-as` the
|
|
||||||
// package and dump watch/search history + the full subscription
|
|
||||||
// list — defeating allowBackup=false / dataExtractionRules on a
|
|
||||||
// privacy-focused client. Flipping this needs no package-id
|
|
||||||
// cutover: signing + the `.debug` suffix below are unchanged, so
|
|
||||||
// in-place fdroid updates keep working.
|
|
||||||
isDebuggable = false
|
|
||||||
// Keep the `.debug` applicationId suffix (package stays
|
// Keep the `.debug` applicationId suffix (package stays
|
||||||
// com.sulkta.straw.debug) so in-place fdroid updates + the in-app
|
// com.sulkta.straw.debug) so in-place fdroid updates + the in-app
|
||||||
// auto-updater keep working and nobody loses their subs/history.
|
// auto-updater keep working and nobody loses their subs/history.
|
||||||
|
|
|
||||||
|
|
@ -5,16 +5,12 @@
|
||||||
|
|
||||||
package com.sulkta.straw
|
package com.sulkta.straw
|
||||||
|
|
||||||
import android.Manifest
|
|
||||||
import android.content.Intent
|
import android.content.Intent
|
||||||
import android.content.pm.PackageManager
|
|
||||||
import android.os.Build
|
|
||||||
import android.os.Bundle
|
import android.os.Bundle
|
||||||
import androidx.activity.ComponentActivity
|
import androidx.activity.ComponentActivity
|
||||||
import androidx.activity.OnBackPressedCallback
|
import androidx.activity.OnBackPressedCallback
|
||||||
import androidx.activity.compose.setContent
|
import androidx.activity.compose.setContent
|
||||||
import androidx.activity.enableEdgeToEdge
|
import androidx.activity.enableEdgeToEdge
|
||||||
import androidx.activity.result.contract.ActivityResultContracts
|
|
||||||
import androidx.compose.foundation.isSystemInDarkTheme
|
import androidx.compose.foundation.isSystemInDarkTheme
|
||||||
import androidx.compose.foundation.layout.Box
|
import androidx.compose.foundation.layout.Box
|
||||||
import androidx.compose.foundation.layout.fillMaxSize
|
import androidx.compose.foundation.layout.fillMaxSize
|
||||||
|
|
@ -24,7 +20,6 @@ import androidx.compose.runtime.Composable
|
||||||
import androidx.compose.runtime.CompositionLocalProvider
|
import androidx.compose.runtime.CompositionLocalProvider
|
||||||
import androidx.compose.runtime.DisposableEffect
|
import androidx.compose.runtime.DisposableEffect
|
||||||
import androidx.compose.runtime.LaunchedEffect
|
import androidx.compose.runtime.LaunchedEffect
|
||||||
import androidx.compose.runtime.SideEffect
|
|
||||||
import androidx.compose.runtime.collectAsState
|
import androidx.compose.runtime.collectAsState
|
||||||
import androidx.compose.runtime.getValue
|
import androidx.compose.runtime.getValue
|
||||||
import androidx.compose.runtime.mutableStateOf
|
import androidx.compose.runtime.mutableStateOf
|
||||||
|
|
@ -32,7 +27,6 @@ import androidx.compose.runtime.remember
|
||||||
import androidx.compose.runtime.setValue
|
import androidx.compose.runtime.setValue
|
||||||
import androidx.compose.ui.Alignment
|
import androidx.compose.ui.Alignment
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
import androidx.core.content.ContextCompat
|
|
||||||
import androidx.media3.common.util.UnstableApi
|
import androidx.media3.common.util.UnstableApi
|
||||||
import com.sulkta.straw.data.Settings
|
import com.sulkta.straw.data.Settings
|
||||||
import com.sulkta.straw.data.ThemeMode
|
import com.sulkta.straw.data.ThemeMode
|
||||||
|
|
@ -68,23 +62,10 @@ class StrawActivity : ComponentActivity() {
|
||||||
*/
|
*/
|
||||||
private val pendingDeepLink = MutableStateFlow<String?>(null)
|
private val pendingDeepLink = MutableStateFlow<String?>(null)
|
||||||
|
|
||||||
/**
|
|
||||||
* Android 13+ gates every `notify()` (media controls + the
|
|
||||||
* update-available notice) behind a runtime POST_NOTIFICATIONS grant.
|
|
||||||
* Registered here (before the activity is STARTED, as the API requires)
|
|
||||||
* and launched once on cold start. The result is intentionally ignored:
|
|
||||||
* Android won't re-prompt after a permanent choice, and both playback
|
|
||||||
* and the update surface degrade gracefully without it (the passive
|
|
||||||
* Settings "Update available" text still shows).
|
|
||||||
*/
|
|
||||||
private val notificationPermissionLauncher =
|
|
||||||
registerForActivityResult(ActivityResultContracts.RequestPermission()) { }
|
|
||||||
|
|
||||||
@OptIn(UnstableApi::class)
|
@OptIn(UnstableApi::class)
|
||||||
override fun onCreate(savedInstanceState: Bundle?) {
|
override fun onCreate(savedInstanceState: Bundle?) {
|
||||||
enableEdgeToEdge()
|
enableEdgeToEdge()
|
||||||
super.onCreate(savedInstanceState)
|
super.onCreate(savedInstanceState)
|
||||||
maybeRequestNotificationPermission()
|
|
||||||
|
|
||||||
val startUrl = pickYouTubeUrl(intent)
|
val startUrl = pickYouTubeUrl(intent)
|
||||||
|
|
||||||
|
|
@ -119,39 +100,25 @@ class StrawActivity : ComponentActivity() {
|
||||||
expanded = true
|
expanded = true
|
||||||
}
|
}
|
||||||
|
|
||||||
// Intercept back ONLY when there's an in-app action to take:
|
DisposableEffect(nav) {
|
||||||
// collapse an expanded player, or pop the browse stack. At
|
val cb = object : OnBackPressedCallback(true) {
|
||||||
// the browse root with nothing open the callback is disabled
|
|
||||||
// so the system handles back natively (moveTaskToBack on
|
|
||||||
// A12+). The previous version disabled the callback and
|
|
||||||
// re-dispatched to the system at root — but on A12+
|
|
||||||
// moveTaskToBack does NOT destroy the activity, so the
|
|
||||||
// composition (and the now-disabled callback) survived and
|
|
||||||
// DisposableEffect(nav) never re-ran to re-enable it, killing
|
|
||||||
// all in-app back permanently after one back-to-Home.
|
|
||||||
// Driving isEnabled from live nav/expanded state fixes that.
|
|
||||||
val canGoBack = expanded || nav.stack.size > 1
|
|
||||||
val backCallback = remember {
|
|
||||||
object : OnBackPressedCallback(canGoBack) {
|
|
||||||
override fun handleOnBackPressed() {
|
override fun handleOnBackPressed() {
|
||||||
// The true-fullscreen Player is a nav screen, so
|
// Back order: the true-fullscreen Player pops
|
||||||
// it pops via the stack branch; an expanded
|
// first; an expanded player collapses to the
|
||||||
// (non-fullscreen) player collapses first.
|
// minibar; otherwise pop the browse stack (or
|
||||||
|
// exit at root).
|
||||||
if (nav.current !is Screen.Player && expanded) {
|
if (nav.current !is Screen.Player && expanded) {
|
||||||
expanded = false
|
expanded = false
|
||||||
} else {
|
return
|
||||||
// Guaranteed poppable: enabled only when
|
}
|
||||||
// expanded || stack.size > 1, and the
|
if (!nav.pop()) {
|
||||||
// expanded case is handled above.
|
isEnabled = false
|
||||||
nav.pop()
|
this@StrawActivity.onBackPressedDispatcher.onBackPressed()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
onBackPressedDispatcher.addCallback(cb)
|
||||||
SideEffect { backCallback.isEnabled = canGoBack }
|
onDispose { cb.remove() }
|
||||||
DisposableEffect(onBackPressedDispatcher) {
|
|
||||||
onBackPressedDispatcher.addCallback(backCallback)
|
|
||||||
onDispose { backCallback.remove() }
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Open the deep-linked video into the expandable player on
|
// Open the deep-linked video into the expandable player on
|
||||||
|
|
@ -206,18 +173,6 @@ class StrawActivity : ComponentActivity() {
|
||||||
pickYouTubeUrl(intent)?.let { pendingDeepLink.value = it }
|
pickYouTubeUrl(intent)?.let { pendingDeepLink.value = it }
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Ask for POST_NOTIFICATIONS on API 33+ if it isn't already granted. */
|
|
||||||
private fun maybeRequestNotificationPermission() {
|
|
||||||
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.TIRAMISU) return
|
|
||||||
val granted = ContextCompat.checkSelfPermission(
|
|
||||||
this,
|
|
||||||
Manifest.permission.POST_NOTIFICATIONS,
|
|
||||||
) == PackageManager.PERMISSION_GRANTED
|
|
||||||
if (!granted) {
|
|
||||||
notificationPermissionLauncher.launch(Manifest.permission.POST_NOTIFICATIONS)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
private fun ScreenContent(
|
private fun ScreenContent(
|
||||||
nav: Navigator,
|
nav: Navigator,
|
||||||
|
|
|
||||||
|
|
@ -20,7 +20,6 @@ import android.content.SharedPreferences
|
||||||
import kotlinx.coroutines.flow.MutableStateFlow
|
import kotlinx.coroutines.flow.MutableStateFlow
|
||||||
import kotlinx.coroutines.flow.StateFlow
|
import kotlinx.coroutines.flow.StateFlow
|
||||||
import kotlinx.coroutines.flow.asStateFlow
|
import kotlinx.coroutines.flow.asStateFlow
|
||||||
import kotlinx.coroutines.flow.update
|
|
||||||
import kotlinx.coroutines.flow.updateAndGet
|
import kotlinx.coroutines.flow.updateAndGet
|
||||||
import kotlinx.serialization.Serializable
|
import kotlinx.serialization.Serializable
|
||||||
import kotlinx.serialization.json.Json
|
import kotlinx.serialization.json.Json
|
||||||
|
|
@ -46,24 +45,6 @@ data class Playlist(
|
||||||
private const val PREFS = "straw_playlists"
|
private const val PREFS = "straw_playlists"
|
||||||
private const val KEY = "playlists_v1"
|
private const val KEY = "playlists_v1"
|
||||||
|
|
||||||
// Caps so a hostile/oversized NewPipe import (SettingsImport permitted up to
|
|
||||||
// 256 playlists × 5000 items with no length bound) can't persist a
|
|
||||||
// hundreds-of-MB blob that then re-decodes on every cold start → ANR/OOM
|
|
||||||
// crash-loop recoverable only by clearing app data. Realistic use is a
|
|
||||||
// handful of playlists with tens–hundreds of short-string items, so these
|
|
||||||
// bound the pathological case while leaving normal use untouched.
|
|
||||||
private const val MAX_PLAYLISTS = 100
|
|
||||||
private const val MAX_ITEMS_PER_PLAYLIST = 1000
|
|
||||||
private const val MAX_NAME_LEN = 100
|
|
||||||
private const val MAX_TITLE_LEN = 300
|
|
||||||
private const val MAX_URL_LEN = 512
|
|
||||||
private const val MAX_UPLOADER_LEN = 100
|
|
||||||
|
|
||||||
// Refuse to even decode a persisted blob larger than this (chars). A capped
|
|
||||||
// legitimate export is a few MB at most; anything past this is corrupt or a
|
|
||||||
// pre-fix poison blob, and we'd rather start empty than OOM decoding it.
|
|
||||||
private const val MAX_BLOB_CHARS = 8_000_000
|
|
||||||
|
|
||||||
class PlaylistsStore(context: Context) {
|
class PlaylistsStore(context: Context) {
|
||||||
private val sp: SharedPreferences = context.getSharedPreferences(PREFS, Context.MODE_PRIVATE)
|
private val sp: SharedPreferences = context.getSharedPreferences(PREFS, Context.MODE_PRIVATE)
|
||||||
private val json = Json { ignoreUnknownKeys = true }
|
private val json = Json { ignoreUnknownKeys = true }
|
||||||
|
|
@ -72,43 +53,17 @@ class PlaylistsStore(context: Context) {
|
||||||
// list so disk converges to the latest in-memory state.
|
// list so disk converges to the latest in-memory state.
|
||||||
private val writer = PrefsWriter(sp)
|
private val writer = PrefsWriter(sp)
|
||||||
|
|
||||||
// Seed empty + hydrate OFF the main thread (mirrors ResumePositionsStore).
|
private val _playlists = MutableStateFlow(load())
|
||||||
// The old MutableStateFlow(load()) decoded the whole playlists blob
|
|
||||||
// synchronously in the constructor, which runs in StrawApp.onCreate on
|
|
||||||
// Main — a large (even capped) blob blocked cold start. The hydrate shares
|
|
||||||
// the PrefsWriter FIFO dispatcher (enqueued before this store is published)
|
|
||||||
// so it always runs before any write's persist, and mutations that arrive
|
|
||||||
// before it finishes defer themselves (see the `hydrated` guard below).
|
|
||||||
private val _playlists = MutableStateFlow<List<Playlist>>(emptyList())
|
|
||||||
val playlists: StateFlow<List<Playlist>> = _playlists.asStateFlow()
|
val playlists: StateFlow<List<Playlist>> = _playlists.asStateFlow()
|
||||||
|
|
||||||
@Volatile private var hydrated = false
|
|
||||||
|
|
||||||
init {
|
|
||||||
writer.serial {
|
|
||||||
try {
|
|
||||||
val loaded = load()
|
|
||||||
if (loaded.isNotEmpty()) {
|
|
||||||
_playlists.update { live ->
|
|
||||||
// Deferral keeps `live` empty at hydrate time, so this
|
|
||||||
// is normally just `loaded`; merge-by-id (live wins) is
|
|
||||||
// the belt-and-suspenders path if a create slipped in.
|
|
||||||
if (live.isEmpty()) loaded else capPlaylists(mergeById(loaded, live))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} finally {
|
|
||||||
hydrated = true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fun create(name: String): Playlist {
|
fun create(name: String): Playlist {
|
||||||
val pl = Playlist(
|
val pl = Playlist(
|
||||||
id = UUID.randomUUID().toString(),
|
id = UUID.randomUUID().toString(),
|
||||||
name = clampName(name).ifBlank { "Untitled" },
|
name = name.trim().ifBlank { "Untitled" },
|
||||||
createdAt = System.currentTimeMillis(),
|
createdAt = System.currentTimeMillis(),
|
||||||
)
|
)
|
||||||
insert(pl)
|
_playlists.updateAndGet { it + pl }
|
||||||
|
persist()
|
||||||
return pl
|
return pl
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -121,42 +76,32 @@ class PlaylistsStore(context: Context) {
|
||||||
*/
|
*/
|
||||||
fun importPlaylist(name: String, items: List<PlaylistItem>): Playlist {
|
fun importPlaylist(name: String, items: List<PlaylistItem>): Playlist {
|
||||||
val stampNow = System.currentTimeMillis()
|
val stampNow = System.currentTimeMillis()
|
||||||
// Dedup within the import, clamp string lengths, stamp addedAt once,
|
// Dedup within the import + stamp addedAt once.
|
||||||
// and stop at the per-playlist item cap.
|
|
||||||
val seen = HashSet<String>()
|
val seen = HashSet<String>()
|
||||||
val deduped = ArrayList<PlaylistItem>(minOf(items.size, MAX_ITEMS_PER_PLAYLIST))
|
val deduped = ArrayList<PlaylistItem>(items.size)
|
||||||
for (raw in items) {
|
for (it in items) {
|
||||||
if (raw.streamUrl.isBlank()) continue
|
if (it.streamUrl.isBlank()) continue
|
||||||
val clamped = clampItem(raw).let { if (it.addedAt == 0L) it.copy(addedAt = stampNow) else it }
|
if (!seen.add(it.streamUrl)) continue
|
||||||
if (!seen.add(clamped.streamUrl)) continue
|
deduped.add(it.copy(addedAt = if (it.addedAt == 0L) stampNow else it.addedAt))
|
||||||
deduped.add(clamped)
|
|
||||||
if (deduped.size >= MAX_ITEMS_PER_PLAYLIST) break
|
|
||||||
}
|
}
|
||||||
val pl = Playlist(
|
val pl = Playlist(
|
||||||
id = UUID.randomUUID().toString(),
|
id = UUID.randomUUID().toString(),
|
||||||
name = clampName(name).ifBlank { "Untitled" },
|
name = name.trim().ifBlank { "Untitled" },
|
||||||
createdAt = stampNow,
|
createdAt = stampNow,
|
||||||
items = deduped,
|
items = deduped,
|
||||||
)
|
)
|
||||||
insert(pl)
|
_playlists.updateAndGet { it + pl }
|
||||||
|
persist()
|
||||||
return pl
|
return pl
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun insert(pl: Playlist) {
|
|
||||||
if (!hydrated) { writer.serial { insert(pl) }; return }
|
|
||||||
_playlists.updateAndGet { capPlaylists(it + pl) }
|
|
||||||
persist()
|
|
||||||
}
|
|
||||||
|
|
||||||
fun delete(id: String) {
|
fun delete(id: String) {
|
||||||
if (!hydrated) { writer.serial { delete(id) }; return }
|
|
||||||
_playlists.updateAndGet { cur -> cur.filterNot { it.id == id } }
|
_playlists.updateAndGet { cur -> cur.filterNot { it.id == id } }
|
||||||
persist()
|
persist()
|
||||||
}
|
}
|
||||||
|
|
||||||
fun rename(id: String, newName: String) {
|
fun rename(id: String, newName: String) {
|
||||||
val trimmed = clampName(newName).ifBlank { return }
|
val trimmed = newName.trim().ifBlank { return }
|
||||||
if (!hydrated) { writer.serial { rename(id, newName) }; return }
|
|
||||||
_playlists.updateAndGet { cur ->
|
_playlists.updateAndGet { cur ->
|
||||||
cur.map { if (it.id == id) it.copy(name = trimmed) else it }
|
cur.map { if (it.id == id) it.copy(name = trimmed) else it }
|
||||||
}
|
}
|
||||||
|
|
@ -164,14 +109,11 @@ class PlaylistsStore(context: Context) {
|
||||||
}
|
}
|
||||||
|
|
||||||
fun addItem(playlistId: String, item: PlaylistItem) {
|
fun addItem(playlistId: String, item: PlaylistItem) {
|
||||||
val stamped = clampItem(item).copy(addedAt = System.currentTimeMillis())
|
val stamped = item.copy(addedAt = System.currentTimeMillis())
|
||||||
if (stamped.streamUrl.isBlank()) return
|
|
||||||
if (!hydrated) { writer.serial { addItem(playlistId, item) }; return }
|
|
||||||
_playlists.updateAndGet { cur ->
|
_playlists.updateAndGet { cur ->
|
||||||
cur.map { pl ->
|
cur.map { pl ->
|
||||||
if (pl.id != playlistId) pl
|
if (pl.id != playlistId) pl
|
||||||
else if (pl.items.any { it.streamUrl == stamped.streamUrl }) pl
|
else if (pl.items.any { it.streamUrl == stamped.streamUrl }) pl
|
||||||
else if (pl.items.size >= MAX_ITEMS_PER_PLAYLIST) pl
|
|
||||||
else pl.copy(items = pl.items + stamped)
|
else pl.copy(items = pl.items + stamped)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -179,7 +121,6 @@ class PlaylistsStore(context: Context) {
|
||||||
}
|
}
|
||||||
|
|
||||||
fun removeItem(playlistId: String, streamUrl: String) {
|
fun removeItem(playlistId: String, streamUrl: String) {
|
||||||
if (!hydrated) { writer.serial { removeItem(playlistId, streamUrl) }; return }
|
|
||||||
_playlists.updateAndGet { cur ->
|
_playlists.updateAndGet { cur ->
|
||||||
cur.map { pl ->
|
cur.map { pl ->
|
||||||
if (pl.id != playlistId) pl
|
if (pl.id != playlistId) pl
|
||||||
|
|
@ -195,37 +136,9 @@ class PlaylistsStore(context: Context) {
|
||||||
writer.write { putString(KEY, json.encodeToString(_playlists.value)) }
|
writer.write { putString(KEY, json.encodeToString(_playlists.value)) }
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun clampName(s: String): String = s.trim().take(MAX_NAME_LEN)
|
|
||||||
|
|
||||||
private fun clampItem(item: PlaylistItem): PlaylistItem = item.copy(
|
|
||||||
streamUrl = item.streamUrl.take(MAX_URL_LEN),
|
|
||||||
title = item.title.take(MAX_TITLE_LEN),
|
|
||||||
thumbnail = item.thumbnail?.take(MAX_URL_LEN),
|
|
||||||
uploader = item.uploader.take(MAX_UPLOADER_LEN),
|
|
||||||
)
|
|
||||||
|
|
||||||
/** Keep the newest MAX_PLAYLISTS (new ones are appended). */
|
|
||||||
private fun capPlaylists(list: List<Playlist>): List<Playlist> =
|
|
||||||
if (list.size <= MAX_PLAYLISTS) list else list.takeLast(MAX_PLAYLISTS)
|
|
||||||
|
|
||||||
/** live entries win on id collision; loaded's remainder is appended. */
|
|
||||||
private fun mergeById(loaded: List<Playlist>, live: List<Playlist>): List<Playlist> {
|
|
||||||
val liveIds = live.mapTo(HashSet()) { it.id }
|
|
||||||
return live + loaded.filterNot { it.id in liveIds }
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun load(): List<Playlist> = runCatching {
|
private fun load(): List<Playlist> = runCatching {
|
||||||
val s = sp.getString(KEY, null) ?: return emptyList()
|
val s = sp.getString(KEY, null) ?: return emptyList()
|
||||||
// Don't decode an implausibly huge (corrupt/hostile/legacy-poison) blob.
|
|
||||||
if (s.length > MAX_BLOB_CHARS) return emptyList()
|
|
||||||
json.decodeFromString<List<Playlist>>(s)
|
json.decodeFromString<List<Playlist>>(s)
|
||||||
.takeLast(MAX_PLAYLISTS)
|
|
||||||
.map { pl ->
|
|
||||||
pl.copy(
|
|
||||||
name = clampName(pl.name),
|
|
||||||
items = pl.items.take(MAX_ITEMS_PER_PLAYLIST).map { clampItem(it) },
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}.getOrDefault(emptyList())
|
}.getOrDefault(emptyList())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -83,19 +83,8 @@ enum class CacheCap(val label: String, val value: Int) {
|
||||||
Unlimited("Unlimited", Int.MAX_VALUE);
|
Unlimited("Unlimited", Int.MAX_VALUE);
|
||||||
|
|
||||||
companion object {
|
companion object {
|
||||||
/**
|
|
||||||
* Resolve a stored/raw cap value to an enum. Snaps UP to the
|
|
||||||
* smallest offered cap that is >= target, so a value that isn't an
|
|
||||||
* exact enum member (a legacy default like 20/30/500, or a stored
|
|
||||||
* value from a build with different granularity) resolves to a real
|
|
||||||
* FINITE cap that covers it — not silently to Unlimited. Only a
|
|
||||||
* target larger than every finite cap resolves to Unlimited (which
|
|
||||||
* is exactly what a stored Int.MAX_VALUE "Unlimited" selection is).
|
|
||||||
* The prior exact-match-or-Unlimited version turned every non-enum
|
|
||||||
* default into an effective no-cap (100k rows) on fresh installs.
|
|
||||||
*/
|
|
||||||
fun nearest(target: Int): CacheCap =
|
fun nearest(target: Int): CacheCap =
|
||||||
entries.filter { it.value >= target }.minByOrNull { it.value } ?: Unlimited
|
entries.firstOrNull { it.value == target } ?: Unlimited
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -288,29 +277,26 @@ class SettingsStore(context: Context) {
|
||||||
* takes effect immediately (next write trims to the new cap; reads
|
* takes effect immediately (next write trims to the new cap; reads
|
||||||
* are unbounded since they're already in memory).
|
* are unbounded since they're already in memory).
|
||||||
*
|
*
|
||||||
* Defaults are real enum values (not the old 20/30/500 non-enum
|
* Defaults match the earlier hardcoded constants so first-launch
|
||||||
* literals, which — because nearest() used to be exact-match-or-Unlimited
|
* behavior is unchanged from prior versions.
|
||||||
* — silently resolved to Unlimited on every fresh install and made the
|
|
||||||
* effective cap the hard ceiling). nearest() now snaps up, so even a
|
|
||||||
* legacy stored non-enum value lands on a finite cap.
|
|
||||||
*/
|
*/
|
||||||
private val _historyWatchesCap = MutableStateFlow(
|
private val _historyWatchesCap = MutableStateFlow(
|
||||||
CacheCap.nearest(sp.getInt(KEY_CACHE_HISTORY_WATCHES, CacheCap.Tiny.value)),
|
CacheCap.nearest(sp.getInt(KEY_CACHE_HISTORY_WATCHES, 50)),
|
||||||
)
|
)
|
||||||
val historyWatchesCap: StateFlow<CacheCap> = _historyWatchesCap.asStateFlow()
|
val historyWatchesCap: StateFlow<CacheCap> = _historyWatchesCap.asStateFlow()
|
||||||
|
|
||||||
private val _historySearchesCap = MutableStateFlow(
|
private val _historySearchesCap = MutableStateFlow(
|
||||||
loadCap(KEY_CACHE_HISTORY_SEARCHES, default = CacheCap.Tiny.value),
|
loadCap(KEY_CACHE_HISTORY_SEARCHES, default = 20),
|
||||||
)
|
)
|
||||||
val historySearchesCap: StateFlow<CacheCap> = _historySearchesCap.asStateFlow()
|
val historySearchesCap: StateFlow<CacheCap> = _historySearchesCap.asStateFlow()
|
||||||
|
|
||||||
private val _resumePositionsCap = MutableStateFlow(
|
private val _resumePositionsCap = MutableStateFlow(
|
||||||
loadCap(KEY_CACHE_RESUME_POSITIONS, default = CacheCap.Medium.value),
|
loadCap(KEY_CACHE_RESUME_POSITIONS, default = 500),
|
||||||
)
|
)
|
||||||
val resumePositionsCap: StateFlow<CacheCap> = _resumePositionsCap.asStateFlow()
|
val resumePositionsCap: StateFlow<CacheCap> = _resumePositionsCap.asStateFlow()
|
||||||
|
|
||||||
private val _searchCacheCap = MutableStateFlow(
|
private val _searchCacheCap = MutableStateFlow(
|
||||||
loadCap(KEY_CACHE_SEARCH, default = CacheCap.Tiny.value),
|
loadCap(KEY_CACHE_SEARCH, default = 30),
|
||||||
)
|
)
|
||||||
val searchCacheCap: StateFlow<CacheCap> = _searchCacheCap.asStateFlow()
|
val searchCacheCap: StateFlow<CacheCap> = _searchCacheCap.asStateFlow()
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -321,11 +321,8 @@ object SettingsImport {
|
||||||
openDb(dbFile).use { db ->
|
openDb(dbFile).use { db ->
|
||||||
val playlistRows = mutableListOf<Pair<Long, String>>()
|
val playlistRows = mutableListOf<Pair<Long, String>>()
|
||||||
// Hard caps so a malicious export with millions of rows
|
// Hard caps so a malicious export with millions of rows
|
||||||
// doesn't walk an unbounded cursor into memory. Matched to
|
// doesn't walk an unbounded cursor into memory.
|
||||||
// PlaylistsStore's MAX_PLAYLISTS/MAX_ITEMS_PER_PLAYLIST (the store
|
db.rawQuery("SELECT uid, name FROM playlists LIMIT 256", null).use { c ->
|
||||||
// is the real enforcement point; this just avoids materializing
|
|
||||||
// rows it would drop anyway).
|
|
||||||
db.rawQuery("SELECT uid, name FROM playlists LIMIT 100", null).use { c ->
|
|
||||||
while (c.moveToNext()) {
|
while (c.moveToNext()) {
|
||||||
val uid = c.getLong(0)
|
val uid = c.getLong(0)
|
||||||
val name = c.getString(1) ?: "Untitled"
|
val name = c.getString(1) ?: "Untitled"
|
||||||
|
|
@ -341,7 +338,7 @@ object SettingsImport {
|
||||||
JOIN streams s ON s.uid = j.stream_id
|
JOIN streams s ON s.uid = j.stream_id
|
||||||
WHERE j.playlist_id = ?
|
WHERE j.playlist_id = ?
|
||||||
ORDER BY j.join_index
|
ORDER BY j.join_index
|
||||||
LIMIT 1000
|
LIMIT 5000
|
||||||
""".trimIndent(),
|
""".trimIndent(),
|
||||||
arrayOf(uid.toString()),
|
arrayOf(uid.toString()),
|
||||||
).use { c ->
|
).use { c ->
|
||||||
|
|
|
||||||
|
|
@ -197,13 +197,7 @@ class VideoDetailViewModel : ViewModel() {
|
||||||
rydDeferred.await() to sbDeferred.await()
|
rydDeferred.await() to sbDeferred.await()
|
||||||
}
|
}
|
||||||
|
|
||||||
// Dedup by url: the related list is keyed "rel:"+url in the
|
val related = info.related.map { r ->
|
||||||
// LazyColumn, so a duplicate would hard-crash Compose. Empty
|
|
||||||
// today (core returns no related), but arm the path now so it
|
|
||||||
// can't regress the moment core starts populating it.
|
|
||||||
val related = info.related
|
|
||||||
.distinctBy { it.url }
|
|
||||||
.map { r ->
|
|
||||||
StreamItem(
|
StreamItem(
|
||||||
url = r.url,
|
url = r.url,
|
||||||
title = r.title.ifBlank { "(no title)" },
|
title = r.title.ifBlank { "(no title)" },
|
||||||
|
|
@ -268,13 +262,6 @@ class VideoDetailViewModel : ViewModel() {
|
||||||
subscriberCount = ch.subscriberCount,
|
subscriberCount = ch.subscriberCount,
|
||||||
videos = ch.videos
|
videos = ch.videos
|
||||||
.filter { it.url != streamUrl }
|
.filter { it.url != streamUrl }
|
||||||
// A repeated videoId within the first 20
|
|
||||||
// channel-tab entries would produce a
|
|
||||||
// duplicate LazyColumn key ("mfc:"+url) and
|
|
||||||
// hard-crash Compose on opening the video.
|
|
||||||
// Core doesn't dedup channel videos, so gate
|
|
||||||
// it here (ChannelViewModel does the same).
|
|
||||||
.distinctBy { it.url }
|
|
||||||
.take(20)
|
.take(20)
|
||||||
.map { v ->
|
.map { v ->
|
||||||
StreamItem(
|
StreamItem(
|
||||||
|
|
|
||||||
|
|
@ -75,26 +75,6 @@ object NowPlaying {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Attach richer data (e.g. late-arriving SponsorBlock segments) to the
|
|
||||||
* currently-playing item ONLY if it is still `item`'s streamUrl. Unlike
|
|
||||||
* [claim], this NEVER replaces a *different* current — so an async fetch
|
|
||||||
* that resolves after the user skipped to another video can't hijack
|
|
||||||
* NowPlaying back to the old one (which would apply stale segments,
|
|
||||||
* flip the collapsed-follow video, and pollute the resume store). No-op
|
|
||||||
* if playback has moved on. Race-free: only ever CAS-swaps a same-url
|
|
||||||
* item, so a concurrent writer flipping `current` to a new url makes the
|
|
||||||
* next loop's guard bail instead of clobbering it.
|
|
||||||
*/
|
|
||||||
fun refreshIfCurrent(item: NowPlayingItem) {
|
|
||||||
while (true) {
|
|
||||||
val cur = _current.value ?: return
|
|
||||||
if (cur.streamUrl != item.streamUrl) return
|
|
||||||
if (cur == item) return
|
|
||||||
if (_current.compareAndSet(cur, item)) return
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fun clear() {
|
fun clear() {
|
||||||
_current.value = null
|
_current.value = null
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -258,12 +258,7 @@ class PlaybackService : MediaSessionService() {
|
||||||
SponsorBlockClient.fetch(videoId, cats)
|
SponsorBlockClient.fetch(videoId, cats)
|
||||||
}
|
}
|
||||||
if (segments.isNotEmpty()) {
|
if (segments.isNotEmpty()) {
|
||||||
// Staleness fence: this SB fetch is a network RTT during
|
NowPlaying.claim(item.copy(segments = segments))
|
||||||
// which the player may have skipped to another track.
|
|
||||||
// refreshIfCurrent only attaches the segments if `item` is
|
|
||||||
// STILL the playing video — it will not hijack NowPlaying
|
|
||||||
// back to this (now-previous) one the way claim() would.
|
|
||||||
NowPlaying.refreshIfCurrent(item.copy(segments = segments))
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -21,10 +21,12 @@ import kotlinx.coroutines.Dispatchers
|
||||||
import kotlinx.coroutines.withContext
|
import kotlinx.coroutines.withContext
|
||||||
import kotlinx.serialization.Serializable
|
import kotlinx.serialization.Serializable
|
||||||
import kotlinx.serialization.json.Json
|
import kotlinx.serialization.json.Json
|
||||||
|
import okhttp3.CertificatePinner
|
||||||
import okhttp3.OkHttpClient
|
import okhttp3.OkHttpClient
|
||||||
import okhttp3.Request
|
import okhttp3.Request
|
||||||
import java.util.concurrent.TimeUnit
|
import java.util.concurrent.TimeUnit
|
||||||
|
|
||||||
|
private const val INDEX_HOST = "fdroid.sulkta.com"
|
||||||
private const val INDEX_URL = "https://fdroid.sulkta.com/fdroid/repo/index-v2.json"
|
private const val INDEX_URL = "https://fdroid.sulkta.com/fdroid/repo/index-v2.json"
|
||||||
private const val REPO_BASE = "https://fdroid.sulkta.com/fdroid/repo"
|
private const val REPO_BASE = "https://fdroid.sulkta.com/fdroid/repo"
|
||||||
|
|
||||||
|
|
@ -44,56 +46,38 @@ private val APK_NAME_RE = Regex("""^/[A-Za-z0-9._-]+\.apk$""")
|
||||||
*/
|
*/
|
||||||
private const val MAX_PLAUSIBLE_VC = 10_000_000L
|
private const val MAX_PLAUSIBLE_VC = 10_000_000L
|
||||||
|
|
||||||
/**
|
|
||||||
* Hard cap on the index body we'll buffer. The straw repo's index-v2.json
|
|
||||||
* is a few KB; 8 MiB is unreachable organically but stops a ballooned or
|
|
||||||
* hostile body from OOMing the update check.
|
|
||||||
*/
|
|
||||||
private const val MAX_INDEX_BYTES = 8L * 1024 * 1024
|
|
||||||
|
|
||||||
data class UpdateInfo(
|
data class UpdateInfo(
|
||||||
val versionCode: Long,
|
val versionCode: Long,
|
||||||
val versionName: String,
|
val versionName: String,
|
||||||
val apkUrl: String,
|
val apkUrl: String,
|
||||||
)
|
)
|
||||||
|
|
||||||
/**
|
|
||||||
* Outcome of an index check. Distinguishes "couldn't reach/parse the index"
|
|
||||||
* (indeterminate — must NOT be reported as up-to-date) from "reached it and
|
|
||||||
* there is genuinely nothing newer".
|
|
||||||
*/
|
|
||||||
sealed interface UpdateResult {
|
|
||||||
/** Reached + parsed the highest published entry for this package. */
|
|
||||||
data class Found(val info: UpdateInfo) : UpdateResult
|
|
||||||
|
|
||||||
/** Reached + parsed, but no usable published entry exists. */
|
|
||||||
object None : UpdateResult
|
|
||||||
|
|
||||||
/** Could not reach or parse the index — retry without lying "up to date". */
|
|
||||||
object Failed : UpdateResult
|
|
||||||
}
|
|
||||||
|
|
||||||
object AppUpdateClient {
|
object AppUpdateClient {
|
||||||
// No certificate pinning — deliberately. We used to pin the
|
/**
|
||||||
// fdroid.sulkta.com leaf SPKI plus the Let's Encrypt "E7" intermediate,
|
* Pin two Subject-Public-Key-Info SHA-256 hashes against
|
||||||
// but that inverted the risk: the leaf rotates on every ~90-day certbot
|
* fdroid.sulkta.com so an off-tree CA misissue can't ship the
|
||||||
// renewal and LE rotates issuing intermediates from a pool (and
|
* user an attacker-signed index.
|
||||||
// explicitly documents "don't pin our intermediates"), so a routine
|
*
|
||||||
// renewal was guaranteed to miss both pins — silently bricking the ONLY
|
* - sha256/8ofd... — current leaf SPKI. Rotates every ~90 days
|
||||||
// in-app update path, with the fix reachable only THROUGH the channel the
|
* with each Let's Encrypt renewal; an app update before the
|
||||||
// break just killed. The threat pinning defended (a CA misissue serving a
|
* next rotation refreshes this pin.
|
||||||
// forged index) is already backstopped: a swapped APK cannot install over
|
* - sha256/y7xV... — Let's Encrypt E7 intermediate SPKI. Stable
|
||||||
// Straw because the Android package installer verifies it against our
|
* for years; serves as the rotation-safety pin while we push
|
||||||
// signing key (bb9ca96b…), and the index only names an APK URL. Default
|
* a new leaf hash.
|
||||||
// system-CA validation still blocks a plain MITM. So we drop the
|
*
|
||||||
// self-brick and keep the real integrity control (the signature gate).
|
* When the leaf pin no longer matches (post-rotation), OkHttp
|
||||||
|
* still accepts the chain because the E7 intermediate pin
|
||||||
|
* matches. The next app release rolls the leaf forward.
|
||||||
|
*/
|
||||||
|
private val pinner: CertificatePinner = CertificatePinner.Builder()
|
||||||
|
.add(INDEX_HOST, "sha256/8ofdiPS6TAiUx9zb2O7Qa9IKZQ3D2i+18teKCrz/MqA=")
|
||||||
|
.add(INDEX_HOST, "sha256/y7xVm0TVJNahMr2sZydE2jQH8SquXV9yLF9seROHHHU=")
|
||||||
|
.build()
|
||||||
|
|
||||||
private val http: OkHttpClient = OkHttpClient.Builder()
|
private val http: OkHttpClient = OkHttpClient.Builder()
|
||||||
.connectTimeout(15, TimeUnit.SECONDS)
|
.connectTimeout(15, TimeUnit.SECONDS)
|
||||||
.readTimeout(15, TimeUnit.SECONDS)
|
.readTimeout(15, TimeUnit.SECONDS)
|
||||||
// Absolute ceiling on the whole call (connect + write + read +
|
.certificatePinner(pinner)
|
||||||
// redirects) so a slow-drip server can't keep the request — and the
|
|
||||||
// WorkManager job behind it — alive indefinitely.
|
|
||||||
.callTimeout(30, TimeUnit.SECONDS)
|
|
||||||
.build()
|
.build()
|
||||||
private val json = Json { ignoreUnknownKeys = true }
|
private val json = Json { ignoreUnknownKeys = true }
|
||||||
|
|
||||||
|
|
@ -102,27 +86,19 @@ object AppUpdateClient {
|
||||||
* for THIS app's package. Returns null on network/parse failure (the
|
* for THIS app's package. Returns null on network/parse failure (the
|
||||||
* caller treats null as "no update available, try again later").
|
* caller treats null as "no update available, try again later").
|
||||||
*/
|
*/
|
||||||
suspend fun fetchLatest(): UpdateResult = withContext(Dispatchers.IO) {
|
suspend fun fetchLatest(): UpdateInfo? = withContext(Dispatchers.IO) {
|
||||||
runCatchingCancellable {
|
runCatchingCancellable {
|
||||||
val req = Request.Builder().url(INDEX_URL).build()
|
val req = Request.Builder().url(INDEX_URL).build()
|
||||||
val raw = http.newCall(req).execute().use { resp ->
|
val raw = http.newCall(req).execute().use { resp ->
|
||||||
if (!resp.isSuccessful) return@runCatchingCancellable UpdateResult.Failed
|
if (!resp.isSuccessful) return@runCatchingCancellable null
|
||||||
// Bounded read: request() buffers up to the cap without
|
|
||||||
// consuming it, so a within-cap body still string()s in full,
|
|
||||||
// but an attacker-/misconfig-ballooned index is refused
|
|
||||||
// instead of OOMing the process.
|
|
||||||
if (resp.body.source().request(MAX_INDEX_BYTES + 1)) {
|
|
||||||
strawLogW("StrawUpdate") { "index exceeds $MAX_INDEX_BYTES-byte cap — refusing" }
|
|
||||||
return@runCatchingCancellable UpdateResult.Failed
|
|
||||||
}
|
|
||||||
resp.body.string()
|
resp.body.string()
|
||||||
}
|
}
|
||||||
val index = json.decodeFromString<FdroidIndex>(raw)
|
val index = json.decodeFromString<FdroidIndex>(raw)
|
||||||
val pkg = index.packages[BuildConfig.APPLICATION_ID]
|
val pkg = index.packages[BuildConfig.APPLICATION_ID]
|
||||||
?: return@runCatchingCancellable UpdateResult.None
|
?: return@runCatchingCancellable null
|
||||||
val best = pkg.versions.values
|
val best = pkg.versions.values
|
||||||
.maxByOrNull { it.manifest.versionCode }
|
.maxByOrNull { it.manifest.versionCode }
|
||||||
?: return@runCatchingCancellable UpdateResult.None
|
?: return@runCatchingCancellable null
|
||||||
// Reject implausible versionCodes outright — see
|
// Reject implausible versionCodes outright — see
|
||||||
// MAX_PLAUSIBLE_VC.
|
// MAX_PLAUSIBLE_VC.
|
||||||
if (best.manifest.versionCode <= 0 ||
|
if (best.manifest.versionCode <= 0 ||
|
||||||
|
|
@ -130,7 +106,7 @@ object AppUpdateClient {
|
||||||
strawLogW("StrawUpdate") {
|
strawLogW("StrawUpdate") {
|
||||||
"rejecting implausible versionCode=${best.manifest.versionCode}"
|
"rejecting implausible versionCode=${best.manifest.versionCode}"
|
||||||
}
|
}
|
||||||
return@runCatchingCancellable UpdateResult.None
|
return@runCatchingCancellable null
|
||||||
}
|
}
|
||||||
// Strict APK-basename match before we hand this off to
|
// Strict APK-basename match before we hand this off to
|
||||||
// ACTION_VIEW. Anything else gets logged + dropped.
|
// ACTION_VIEW. Anything else gets logged + dropped.
|
||||||
|
|
@ -139,16 +115,14 @@ object AppUpdateClient {
|
||||||
strawLogW("StrawUpdate") {
|
strawLogW("StrawUpdate") {
|
||||||
"rejecting unsafe file.name=${fileName.take(80)}"
|
"rejecting unsafe file.name=${fileName.take(80)}"
|
||||||
}
|
}
|
||||||
return@runCatchingCancellable UpdateResult.None
|
return@runCatchingCancellable null
|
||||||
}
|
}
|
||||||
UpdateResult.Found(
|
|
||||||
UpdateInfo(
|
UpdateInfo(
|
||||||
versionCode = best.manifest.versionCode,
|
versionCode = best.manifest.versionCode,
|
||||||
versionName = best.manifest.versionName.orEmpty(),
|
versionName = best.manifest.versionName.orEmpty(),
|
||||||
apkUrl = "$REPO_BASE$fileName",
|
apkUrl = "$REPO_BASE$fileName",
|
||||||
)
|
)
|
||||||
)
|
}.getOrNull()
|
||||||
}.getOrElse { UpdateResult.Failed }
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -24,7 +24,6 @@ import android.app.PendingIntent
|
||||||
import android.content.Context
|
import android.content.Context
|
||||||
import android.content.Intent
|
import android.content.Intent
|
||||||
import android.net.Uri
|
import android.net.Uri
|
||||||
import android.os.Build
|
|
||||||
import androidx.core.app.NotificationCompat
|
import androidx.core.app.NotificationCompat
|
||||||
import androidx.work.CoroutineWorker
|
import androidx.work.CoroutineWorker
|
||||||
import androidx.work.WorkerParameters
|
import androidx.work.WorkerParameters
|
||||||
|
|
@ -37,37 +36,23 @@ import com.sulkta.straw.util.strawLogI
|
||||||
* Touched by both the scheduled worker AND the "Check now" Settings
|
* Touched by both the scheduled worker AND the "Check now" Settings
|
||||||
* button so behavior stays identical regardless of trigger.
|
* button so behavior stays identical regardless of trigger.
|
||||||
*/
|
*/
|
||||||
suspend fun runUpdateCheck(context: Context): UpdateInfo? =
|
suspend fun runUpdateCheck(context: Context): UpdateInfo? {
|
||||||
when (val result = AppUpdateClient.fetchLatest()) {
|
val info = AppUpdateClient.fetchLatest()
|
||||||
// Indeterminate — do NOT advance the last-check timestamp, or a
|
|
||||||
// permanently-failing checker would read "checked just now / up to
|
|
||||||
// date" while it's actually blind. Retry on the next tick.
|
|
||||||
is UpdateResult.Failed -> {
|
|
||||||
strawLogI("update", "check: network/parse failure, will retry (timestamp not advanced)")
|
|
||||||
null
|
|
||||||
}
|
|
||||||
// Reached the repo; it has nothing usable for this package.
|
|
||||||
is UpdateResult.None -> {
|
|
||||||
Settings.get().setLastUpdateCheck(System.currentTimeMillis())
|
Settings.get().setLastUpdateCheck(System.currentTimeMillis())
|
||||||
Settings.get().setLatestKnownVersion(0L, "")
|
if (info == null) {
|
||||||
strawLogI("update", "check: no published build for this package")
|
strawLogI("update", "check: network/parse failure, will retry")
|
||||||
null
|
return null
|
||||||
}
|
}
|
||||||
is UpdateResult.Found -> {
|
|
||||||
val info = result.info
|
|
||||||
Settings.get().setLastUpdateCheck(System.currentTimeMillis())
|
|
||||||
if (info.versionCode <= BuildConfig.VERSION_CODE) {
|
if (info.versionCode <= BuildConfig.VERSION_CODE) {
|
||||||
strawLogI("update", "check: up to date (latest=${info.versionCode})")
|
strawLogI("update", "check: up to date (latest=${info.versionCode})")
|
||||||
Settings.get().setLatestKnownVersion(0L, "")
|
Settings.get().setLatestKnownVersion(0L, "")
|
||||||
null
|
return null
|
||||||
} else {
|
}
|
||||||
strawLogI("update", "check: ${BuildConfig.VERSION_CODE} → ${info.versionCode} available")
|
strawLogI("update", "check: ${BuildConfig.VERSION_CODE} → ${info.versionCode} available")
|
||||||
Settings.get().setLatestKnownVersion(info.versionCode, info.versionName)
|
Settings.get().setLatestKnownVersion(info.versionCode, info.versionName)
|
||||||
postUpdateNotification(context, info)
|
postUpdateNotification(context, info)
|
||||||
info
|
return info
|
||||||
}
|
}
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
class UpdateCheckWorker(
|
class UpdateCheckWorker(
|
||||||
context: Context,
|
context: Context,
|
||||||
|
|
@ -89,10 +74,6 @@ private const val NOTIF_ID = 23
|
||||||
|
|
||||||
private fun postUpdateNotification(context: Context, info: UpdateInfo) {
|
private fun postUpdateNotification(context: Context, info: UpdateInfo) {
|
||||||
val nm = context.getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager
|
val nm = context.getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager
|
||||||
// NotificationChannel is API 26+; minSdk is 24, so guard it or a 7.x
|
|
||||||
// device crashes (NoClassDefFoundError) the moment an update is found.
|
|
||||||
// Pre-O, NotificationCompat.Builder ignores the channel id and posts fine.
|
|
||||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
|
|
||||||
val channel = NotificationChannel(
|
val channel = NotificationChannel(
|
||||||
NOTIF_CHANNEL_ID,
|
NOTIF_CHANNEL_ID,
|
||||||
"Straw updates",
|
"Straw updates",
|
||||||
|
|
@ -101,7 +82,6 @@ private fun postUpdateNotification(context: Context, info: UpdateInfo) {
|
||||||
description = "Notifies when a newer Straw build is on fdroid.sulkta.com."
|
description = "Notifies when a newer Straw build is on fdroid.sulkta.com."
|
||||||
}
|
}
|
||||||
nm.createNotificationChannel(channel)
|
nm.createNotificationChannel(channel)
|
||||||
}
|
|
||||||
|
|
||||||
// ACTION_VIEW on the APK URL — Chrome / system browser fetches it
|
// ACTION_VIEW on the APK URL — Chrome / system browser fetches it
|
||||||
// via DownloadManager and the user taps it to install. No
|
// via DownloadManager and the user taps it to install. No
|
||||||
|
|
@ -122,10 +102,6 @@ private fun postUpdateNotification(context: Context, info: UpdateInfo) {
|
||||||
.setContentTitle("Straw $name available")
|
.setContentTitle("Straw $name available")
|
||||||
.setContentText("Tap to download from fdroid.sulkta.com.")
|
.setContentText("Tap to download from fdroid.sulkta.com.")
|
||||||
.setAutoCancel(true)
|
.setAutoCancel(true)
|
||||||
// Only sound/vibrate the first time we surface a given version — the
|
|
||||||
// check re-runs on every cadence tick and would otherwise re-nag for
|
|
||||||
// the same pending update until the user installs it.
|
|
||||||
.setOnlyAlertOnce(true)
|
|
||||||
.setContentIntent(pending)
|
.setContentIntent(pending)
|
||||||
.build()
|
.build()
|
||||||
nm.notify(NOTIF_ID, notif)
|
nm.notify(NOTIF_ID, notif)
|
||||||
|
|
|
||||||
|
|
@ -10,7 +10,6 @@
|
||||||
|
|
||||||
package com.sulkta.straw.net
|
package com.sulkta.straw.net
|
||||||
|
|
||||||
import com.sulkta.straw.util.runCatchingCancellable
|
|
||||||
import kotlinx.serialization.Serializable
|
import kotlinx.serialization.Serializable
|
||||||
|
|
||||||
@Serializable
|
@Serializable
|
||||||
|
|
@ -24,12 +23,8 @@ object RydClient {
|
||||||
/** Suspends on the Rust async runtime; call from a coroutine. Returns
|
/** Suspends on the Rust async runtime; call from a coroutine. Returns
|
||||||
* null on any failure (transport / non-2xx / bad JSON), same contract
|
* null on any failure (transport / non-2xx / bad JSON), same contract
|
||||||
* the old blocking client had. Only likes/dislikes are carried — RYD's
|
* the old blocking client had. Only likes/dislikes are carried — RYD's
|
||||||
* rating/viewCount were dead, redundant overlay data (audit #2 L-9).
|
* rating/viewCount were dead, redundant overlay data (audit #2 L-9). */
|
||||||
* The uniffi call is wrapped: a core panic now unwinds to a UniFFI
|
|
||||||
* InternalException (panic=unwind), which this shim's "null on failure"
|
|
||||||
* contract must swallow rather than crash the detail screen. */
|
|
||||||
suspend fun fetch(videoId: String): RydVotes? =
|
suspend fun fetch(videoId: String): RydVotes? =
|
||||||
runCatchingCancellable {
|
|
||||||
uniffi.strawcore.fetchRydVotes(videoId)?.let { v ->
|
uniffi.strawcore.fetchRydVotes(videoId)?.let { v ->
|
||||||
RydVotes(
|
RydVotes(
|
||||||
id = v.id,
|
id = v.id,
|
||||||
|
|
@ -37,5 +32,4 @@ object RydClient {
|
||||||
dislikes = v.dislikes,
|
dislikes = v.dislikes,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}.getOrNull()
|
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -12,7 +12,6 @@
|
||||||
|
|
||||||
package com.sulkta.straw.net
|
package com.sulkta.straw.net
|
||||||
|
|
||||||
import com.sulkta.straw.util.runCatchingCancellable
|
|
||||||
import kotlinx.serialization.Serializable
|
import kotlinx.serialization.Serializable
|
||||||
|
|
||||||
@Serializable
|
@Serializable
|
||||||
|
|
@ -29,15 +28,11 @@ data class SbSegment(
|
||||||
object SponsorBlockClient {
|
object SponsorBlockClient {
|
||||||
/** Suspends on the Rust async runtime; call from a coroutine. Returns
|
/** Suspends on the Rust async runtime; call from a coroutine. Returns
|
||||||
* an empty list on any failure, same contract the old blocking client
|
* an empty list on any failure, same contract the old blocking client
|
||||||
* had. `categories` are the SB category keys to request. The uniffi
|
* had. `categories` are the SB category keys to request. */
|
||||||
* call is wrapped so a core panic (now unwinding to a UniFFI
|
|
||||||
* InternalException under panic=unwind) is swallowed to an empty list
|
|
||||||
* rather than crashing video-detail load. */
|
|
||||||
suspend fun fetch(
|
suspend fun fetch(
|
||||||
videoId: String,
|
videoId: String,
|
||||||
categories: List<String> = listOf("sponsor"),
|
categories: List<String> = listOf("sponsor"),
|
||||||
): List<SbSegment> =
|
): List<SbSegment> =
|
||||||
runCatchingCancellable {
|
|
||||||
uniffi.strawcore.fetchSponsorSegments(videoId, categories).map { s ->
|
uniffi.strawcore.fetchSponsorSegments(videoId, categories).map { s ->
|
||||||
SbSegment(
|
SbSegment(
|
||||||
UUID = s.uuid,
|
UUID = s.uuid,
|
||||||
|
|
@ -46,5 +41,4 @@ object SponsorBlockClient {
|
||||||
actionType = s.actionType,
|
actionType = s.actionType,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}.getOrDefault(emptyList())
|
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue