Compare commits

..

1 commit

Author SHA1 Message Date
1b8baee7a0 Update core to v1.19.0
Some checks failed
PR size labeler / Automatically labelling pull requests based on the changed lines count (pull_request_target) Failing after 2s
Image Minimizer / try-minimize (pull_request) Failing after 1s
2026-07-04 06:51:31 +00:00
29 changed files with 633 additions and 688 deletions

View file

@ -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

View file

@ -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
View 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
View 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
View 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
View 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
View 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) ![ALT_TEXT](https://*.githubusercontent.com/<number>/<variousHexStringsAnd->.<fileExtension>)
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
View 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
View 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
View 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

View file

@ -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"

View file

@ -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"

View file

@ -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.

View file

@ -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))
} }

View file

@ -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),
}
}

View file

@ -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))
} }

View file

@ -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)))
} }

View file

@ -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.

View file

@ -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,

View file

@ -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 tenshundreds 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())
} }

View file

@ -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()

View file

@ -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 ->

View file

@ -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(

View file

@ -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
} }

View file

@ -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))
} }
} }
} }

View file

@ -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 }
} }
} }

View file

@ -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)

View file

@ -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()
} }

View file

@ -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())
} }