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
//
// 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:
// * ResumePositionsStore + EnrichmentStore no longer JSON-decode on the main
// 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
// strawcore-core (Sulkta-OSS/strawcore) via the UniFFI wrapper; no
// NewPipeExtractor in the runtime path.
const val STRAW_VERSION_CODE = 91
const val STRAW_VERSION_NAME = "0.1.0-CY"
const val STRAW_VERSION_CODE = 90
const val STRAW_VERSION_NAME = "0.1.0-CX"
const val STRAW_APPLICATION_ID = "com.sulkta.straw"

View file

@ -15,9 +15,9 @@ bridge = "v2.0.2"
cardview = "1.0.0"
checkstyle = "13.4.2"
coil = "3.4.0"
compose = "1.11.4"
compose = "1.11.3"
constraintlayout = "2.2.1"
core = "1.18.0"
core = "1.19.0"
coroutines = "1.11.0"
desugar = "2.1.5"
documentfile = "1.1.0"

View file

@ -21,14 +21,7 @@ repository = "https://git.sulkta.com/Sulkta-OSS/straw"
strip = true
lto = "thin"
codegen-units = 1
# unwind (NOT abort): panic=abort defeats UniFFI's catch_unwind AND tokio's
# 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"
panic = "abort"
opt-level = "z"
# `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());
crate::runtime::ensure_initialized();
let identifier = resolve_channel_identifier(&input)?;
let core = crate::runtime::run_extract("channel_info", move || core_channel_info(identifier))
.await?;
let core = tokio::task::spawn_blocking(move || core_channel_info(identifier))
.await
.map_err(|e| StrawcoreError::Extractor {
msg: format!("join: {e}"),
})??;
Ok(map_channel(core))
}
@ -60,10 +63,11 @@ pub async fn channel_videos_continuation(token: String) -> Result<Page, Strawcor
token.len()
);
crate::runtime::ensure_initialized();
let page = crate::runtime::run_extract("channel_videos_continuation", move || {
core_channel_continuation(&token)
})
.await?;
let page = tokio::task::spawn_blocking(move || core_channel_continuation(&token))
.await
.map_err(|e| StrawcoreError::Extractor {
msg: format!("join: {e}"),
})??;
Ok(page_from_core(page))
}

View file

@ -17,14 +17,12 @@
use std::sync::atomic::{AtomicBool, AtomicU64, Ordering};
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::localization::{ContentCountry, Localization};
use strawcore_core::newpipe::NewPipe;
use crate::error::StrawcoreError;
static INITIALIZED: AtomicBool = AtomicBool::new(false);
static LAST_ATTEMPT_MS: AtomicU64 = AtomicU64::new(0);
// 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
// — cheap when INITIALIZED is true (single Acquire load).
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)
})
.await?;
.await
.map_err(|e| StrawcoreError::Extractor {
msg: format!("join: {e}"),
})??;
// Page 1 carries the first continuation token (None once YT stops
// handing them out). The Kotlin SearchViewModel passes it back to
// `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> {
log::info!("strawcore::search_continuation token_len={}", token.len());
crate::runtime::ensure_initialized();
let page = crate::runtime::run_extract("search_continuation", move || {
search_extractor::search_continuation(&token)
})
.await?;
let page = tokio::task::spawn_blocking(move || search_extractor::search_continuation(&token))
.await
.map_err(|e| StrawcoreError::Extractor {
msg: format!("join: {e}"),
})??;
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();
let video_id = resolve_video_id(&input)?;
let video_id_for_call = video_id.clone();
let core = crate::runtime::run_extract("stream_info", move || {
core_stream_info(&video_id_for_call)
})
.await?;
let core = tokio::task::spawn_blocking(move || core_stream_info(&video_id_for_call))
.await
.map_err(|e| StrawcoreError::Extractor {
msg: format!("join: {e}"),
})??;
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());
crate::runtime::ensure_initialized();
let video_id = resolve_video_id(&input)?;
let core = crate::runtime::run_extract("stream_metadata", move || {
core_stream_metadata(&video_id)
})
.await?;
let core = tokio::task::spawn_blocking(move || core_stream_metadata(&video_id))
.await
.map_err(|e| StrawcoreError::Extractor {
msg: format!("join: {e}"),
})??;
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 +
// kotlinx-serialization companions.
debug {
// NOT debuggable, even though this is the `debug` buildType: the
// 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
isDebuggable = true
// Keep the `.debug` applicationId suffix (package stays
// com.sulkta.straw.debug) so in-place fdroid updates + the in-app
// auto-updater keep working and nobody loses their subs/history.

View file

@ -5,16 +5,12 @@
package com.sulkta.straw
import android.Manifest
import android.content.Intent
import android.content.pm.PackageManager
import android.os.Build
import android.os.Bundle
import androidx.activity.ComponentActivity
import androidx.activity.OnBackPressedCallback
import androidx.activity.compose.setContent
import androidx.activity.enableEdgeToEdge
import androidx.activity.result.contract.ActivityResultContracts
import androidx.compose.foundation.isSystemInDarkTheme
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.fillMaxSize
@ -24,7 +20,6 @@ import androidx.compose.runtime.Composable
import androidx.compose.runtime.CompositionLocalProvider
import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.SideEffect
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
@ -32,7 +27,6 @@ import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.core.content.ContextCompat
import androidx.media3.common.util.UnstableApi
import com.sulkta.straw.data.Settings
import com.sulkta.straw.data.ThemeMode
@ -68,23 +62,10 @@ class StrawActivity : ComponentActivity() {
*/
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)
override fun onCreate(savedInstanceState: Bundle?) {
enableEdgeToEdge()
super.onCreate(savedInstanceState)
maybeRequestNotificationPermission()
val startUrl = pickYouTubeUrl(intent)
@ -119,39 +100,25 @@ class StrawActivity : ComponentActivity() {
expanded = true
}
// Intercept back ONLY when there's an in-app action to take:
// collapse an expanded player, or pop the browse stack. At
// 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) {
DisposableEffect(nav) {
val cb = object : OnBackPressedCallback(true) {
override fun handleOnBackPressed() {
// The true-fullscreen Player is a nav screen, so
// it pops via the stack branch; an expanded
// (non-fullscreen) player collapses first.
// Back order: the true-fullscreen Player pops
// first; an expanded player collapses to the
// minibar; otherwise pop the browse stack (or
// exit at root).
if (nav.current !is Screen.Player && expanded) {
expanded = false
} else {
// Guaranteed poppable: enabled only when
// expanded || stack.size > 1, and the
// expanded case is handled above.
nav.pop()
return
}
if (!nav.pop()) {
isEnabled = false
this@StrawActivity.onBackPressedDispatcher.onBackPressed()
}
}
}
}
SideEffect { backCallback.isEnabled = canGoBack }
DisposableEffect(onBackPressedDispatcher) {
onBackPressedDispatcher.addCallback(backCallback)
onDispose { backCallback.remove() }
onBackPressedDispatcher.addCallback(cb)
onDispose { cb.remove() }
}
// Open the deep-linked video into the expandable player on
@ -206,18 +173,6 @@ class StrawActivity : ComponentActivity() {
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
private fun ScreenContent(
nav: Navigator,

View file

@ -20,7 +20,6 @@ import android.content.SharedPreferences
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.flow.updateAndGet
import kotlinx.serialization.Serializable
import kotlinx.serialization.json.Json
@ -46,24 +45,6 @@ data class Playlist(
private const val PREFS = "straw_playlists"
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) {
private val sp: SharedPreferences = context.getSharedPreferences(PREFS, Context.MODE_PRIVATE)
private val json = Json { ignoreUnknownKeys = true }
@ -72,43 +53,17 @@ class PlaylistsStore(context: Context) {
// list so disk converges to the latest in-memory state.
private val writer = PrefsWriter(sp)
// Seed empty + hydrate OFF the main thread (mirrors ResumePositionsStore).
// 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())
private val _playlists = MutableStateFlow(load())
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 {
val pl = Playlist(
id = UUID.randomUUID().toString(),
name = clampName(name).ifBlank { "Untitled" },
name = name.trim().ifBlank { "Untitled" },
createdAt = System.currentTimeMillis(),
)
insert(pl)
_playlists.updateAndGet { it + pl }
persist()
return pl
}
@ -121,42 +76,32 @@ class PlaylistsStore(context: Context) {
*/
fun importPlaylist(name: String, items: List<PlaylistItem>): Playlist {
val stampNow = System.currentTimeMillis()
// Dedup within the import, clamp string lengths, stamp addedAt once,
// and stop at the per-playlist item cap.
// Dedup within the import + stamp addedAt once.
val seen = HashSet<String>()
val deduped = ArrayList<PlaylistItem>(minOf(items.size, MAX_ITEMS_PER_PLAYLIST))
for (raw in items) {
if (raw.streamUrl.isBlank()) continue
val clamped = clampItem(raw).let { if (it.addedAt == 0L) it.copy(addedAt = stampNow) else it }
if (!seen.add(clamped.streamUrl)) continue
deduped.add(clamped)
if (deduped.size >= MAX_ITEMS_PER_PLAYLIST) break
val deduped = ArrayList<PlaylistItem>(items.size)
for (it in items) {
if (it.streamUrl.isBlank()) continue
if (!seen.add(it.streamUrl)) continue
deduped.add(it.copy(addedAt = if (it.addedAt == 0L) stampNow else it.addedAt))
}
val pl = Playlist(
id = UUID.randomUUID().toString(),
name = clampName(name).ifBlank { "Untitled" },
name = name.trim().ifBlank { "Untitled" },
createdAt = stampNow,
items = deduped,
)
insert(pl)
_playlists.updateAndGet { it + pl }
persist()
return pl
}
private fun insert(pl: Playlist) {
if (!hydrated) { writer.serial { insert(pl) }; return }
_playlists.updateAndGet { capPlaylists(it + pl) }
persist()
}
fun delete(id: String) {
if (!hydrated) { writer.serial { delete(id) }; return }
_playlists.updateAndGet { cur -> cur.filterNot { it.id == id } }
persist()
}
fun rename(id: String, newName: String) {
val trimmed = clampName(newName).ifBlank { return }
if (!hydrated) { writer.serial { rename(id, newName) }; return }
val trimmed = newName.trim().ifBlank { return }
_playlists.updateAndGet { cur ->
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) {
val stamped = clampItem(item).copy(addedAt = System.currentTimeMillis())
if (stamped.streamUrl.isBlank()) return
if (!hydrated) { writer.serial { addItem(playlistId, item) }; return }
val stamped = item.copy(addedAt = System.currentTimeMillis())
_playlists.updateAndGet { cur ->
cur.map { pl ->
if (pl.id != playlistId) 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)
}
}
@ -179,7 +121,6 @@ class PlaylistsStore(context: Context) {
}
fun removeItem(playlistId: String, streamUrl: String) {
if (!hydrated) { writer.serial { removeItem(playlistId, streamUrl) }; return }
_playlists.updateAndGet { cur ->
cur.map { pl ->
if (pl.id != playlistId) pl
@ -195,37 +136,9 @@ class PlaylistsStore(context: Context) {
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 {
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)
.takeLast(MAX_PLAYLISTS)
.map { pl ->
pl.copy(
name = clampName(pl.name),
items = pl.items.take(MAX_ITEMS_PER_PLAYLIST).map { clampItem(it) },
)
}
}.getOrDefault(emptyList())
}

View file

@ -83,19 +83,8 @@ enum class CacheCap(val label: String, val value: Int) {
Unlimited("Unlimited", Int.MAX_VALUE);
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 =
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
* are unbounded since they're already in memory).
*
* Defaults are real enum values (not the old 20/30/500 non-enum
* literals, which because nearest() used to be exact-match-or-Unlimited
* 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.
* Defaults match the earlier hardcoded constants so first-launch
* behavior is unchanged from prior versions.
*/
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()
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()
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()
private val _searchCacheCap = MutableStateFlow(
loadCap(KEY_CACHE_SEARCH, default = CacheCap.Tiny.value),
loadCap(KEY_CACHE_SEARCH, default = 30),
)
val searchCacheCap: StateFlow<CacheCap> = _searchCacheCap.asStateFlow()

View file

@ -321,11 +321,8 @@ object SettingsImport {
openDb(dbFile).use { db ->
val playlistRows = mutableListOf<Pair<Long, String>>()
// Hard caps so a malicious export with millions of rows
// doesn't walk an unbounded cursor into memory. Matched to
// PlaylistsStore's MAX_PLAYLISTS/MAX_ITEMS_PER_PLAYLIST (the store
// 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 ->
// doesn't walk an unbounded cursor into memory.
db.rawQuery("SELECT uid, name FROM playlists LIMIT 256", null).use { c ->
while (c.moveToNext()) {
val uid = c.getLong(0)
val name = c.getString(1) ?: "Untitled"
@ -341,7 +338,7 @@ object SettingsImport {
JOIN streams s ON s.uid = j.stream_id
WHERE j.playlist_id = ?
ORDER BY j.join_index
LIMIT 1000
LIMIT 5000
""".trimIndent(),
arrayOf(uid.toString()),
).use { c ->

View file

@ -197,24 +197,18 @@ class VideoDetailViewModel : ViewModel() {
rydDeferred.await() to sbDeferred.await()
}
// Dedup by url: the related list is keyed "rel:"+url in the
// 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(
url = r.url,
title = r.title.ifBlank { "(no title)" },
uploader = r.uploader,
uploaderUrl = r.uploaderUrl,
thumbnail = r.thumbnail,
durationSeconds = r.durationSeconds,
viewCount = r.viewCount,
uploadDateRelative = r.uploadDateRelative,
)
}
val related = info.related.map { r ->
StreamItem(
url = r.url,
title = r.title.ifBlank { "(no title)" },
uploader = r.uploader,
uploaderUrl = r.uploaderUrl,
thumbnail = r.thumbnail,
durationSeconds = r.durationSeconds,
viewCount = r.viewCount,
uploadDateRelative = r.uploadDateRelative,
)
}
// More from this channel via strawcore.channelInfo — one
// Rust round-trip returns the channel's Videos tab pre-mapped.
@ -268,13 +262,6 @@ class VideoDetailViewModel : ViewModel() {
subscriberCount = ch.subscriberCount,
videos = ch.videos
.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)
.map { v ->
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() {
_current.value = null
}

View file

@ -258,12 +258,7 @@ class PlaybackService : MediaSessionService() {
SponsorBlockClient.fetch(videoId, cats)
}
if (segments.isNotEmpty()) {
// Staleness fence: this SB fetch is a network RTT during
// 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))
NowPlaying.claim(item.copy(segments = segments))
}
}
}

View file

@ -21,10 +21,12 @@ import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import kotlinx.serialization.Serializable
import kotlinx.serialization.json.Json
import okhttp3.CertificatePinner
import okhttp3.OkHttpClient
import okhttp3.Request
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 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
/**
* 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(
val versionCode: Long,
val versionName: 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 {
// No certificate pinning — deliberately. We used to pin the
// fdroid.sulkta.com leaf SPKI plus the Let's Encrypt "E7" intermediate,
// but that inverted the risk: the leaf rotates on every ~90-day certbot
// renewal and LE rotates issuing intermediates from a pool (and
// explicitly documents "don't pin our intermediates"), so a routine
// renewal was guaranteed to miss both pins — silently bricking the ONLY
// in-app update path, with the fix reachable only THROUGH the channel the
// break just killed. The threat pinning defended (a CA misissue serving a
// forged index) is already backstopped: a swapped APK cannot install over
// Straw because the Android package installer verifies it against our
// signing key (bb9ca96b…), and the index only names an APK URL. Default
// system-CA validation still blocks a plain MITM. So we drop the
// self-brick and keep the real integrity control (the signature gate).
/**
* Pin two Subject-Public-Key-Info SHA-256 hashes against
* fdroid.sulkta.com so an off-tree CA misissue can't ship the
* user an attacker-signed index.
*
* - sha256/8ofd... current leaf SPKI. Rotates every ~90 days
* with each Let's Encrypt renewal; an app update before the
* next rotation refreshes this pin.
* - sha256/y7xV... Let's Encrypt E7 intermediate SPKI. Stable
* for years; serves as the rotation-safety pin while we push
* a new leaf hash.
*
* 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()
.connectTimeout(15, TimeUnit.SECONDS)
.readTimeout(15, TimeUnit.SECONDS)
// Absolute ceiling on the whole call (connect + write + read +
// redirects) so a slow-drip server can't keep the request — and the
// WorkManager job behind it — alive indefinitely.
.callTimeout(30, TimeUnit.SECONDS)
.certificatePinner(pinner)
.build()
private val json = Json { ignoreUnknownKeys = true }
@ -102,27 +86,19 @@ object AppUpdateClient {
* for THIS app's package. Returns null on network/parse failure (the
* 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 {
val req = Request.Builder().url(INDEX_URL).build()
val raw = http.newCall(req).execute().use { resp ->
if (!resp.isSuccessful) return@runCatchingCancellable UpdateResult.Failed
// 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
}
if (!resp.isSuccessful) return@runCatchingCancellable null
resp.body.string()
}
val index = json.decodeFromString<FdroidIndex>(raw)
val pkg = index.packages[BuildConfig.APPLICATION_ID]
?: return@runCatchingCancellable UpdateResult.None
?: return@runCatchingCancellable null
val best = pkg.versions.values
.maxByOrNull { it.manifest.versionCode }
?: return@runCatchingCancellable UpdateResult.None
?: return@runCatchingCancellable null
// Reject implausible versionCodes outright — see
// MAX_PLAUSIBLE_VC.
if (best.manifest.versionCode <= 0 ||
@ -130,7 +106,7 @@ object AppUpdateClient {
strawLogW("StrawUpdate") {
"rejecting implausible versionCode=${best.manifest.versionCode}"
}
return@runCatchingCancellable UpdateResult.None
return@runCatchingCancellable null
}
// Strict APK-basename match before we hand this off to
// ACTION_VIEW. Anything else gets logged + dropped.
@ -139,16 +115,14 @@ object AppUpdateClient {
strawLogW("StrawUpdate") {
"rejecting unsafe file.name=${fileName.take(80)}"
}
return@runCatchingCancellable UpdateResult.None
return@runCatchingCancellable null
}
UpdateResult.Found(
UpdateInfo(
versionCode = best.manifest.versionCode,
versionName = best.manifest.versionName.orEmpty(),
apkUrl = "$REPO_BASE$fileName",
)
UpdateInfo(
versionCode = best.manifest.versionCode,
versionName = best.manifest.versionName.orEmpty(),
apkUrl = "$REPO_BASE$fileName",
)
}.getOrElse { UpdateResult.Failed }
}.getOrNull()
}
}

View file

@ -24,7 +24,6 @@ import android.app.PendingIntent
import android.content.Context
import android.content.Intent
import android.net.Uri
import android.os.Build
import androidx.core.app.NotificationCompat
import androidx.work.CoroutineWorker
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
* button so behavior stays identical regardless of trigger.
*/
suspend fun runUpdateCheck(context: Context): UpdateInfo? =
when (val result = 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().setLatestKnownVersion(0L, "")
strawLogI("update", "check: no published build for this package")
null
}
is UpdateResult.Found -> {
val info = result.info
Settings.get().setLastUpdateCheck(System.currentTimeMillis())
if (info.versionCode <= BuildConfig.VERSION_CODE) {
strawLogI("update", "check: up to date (latest=${info.versionCode})")
Settings.get().setLatestKnownVersion(0L, "")
null
} else {
strawLogI("update", "check: ${BuildConfig.VERSION_CODE}${info.versionCode} available")
Settings.get().setLatestKnownVersion(info.versionCode, info.versionName)
postUpdateNotification(context, info)
info
}
}
suspend fun runUpdateCheck(context: Context): UpdateInfo? {
val info = AppUpdateClient.fetchLatest()
Settings.get().setLastUpdateCheck(System.currentTimeMillis())
if (info == null) {
strawLogI("update", "check: network/parse failure, will retry")
return null
}
if (info.versionCode <= BuildConfig.VERSION_CODE) {
strawLogI("update", "check: up to date (latest=${info.versionCode})")
Settings.get().setLatestKnownVersion(0L, "")
return null
}
strawLogI("update", "check: ${BuildConfig.VERSION_CODE}${info.versionCode} available")
Settings.get().setLatestKnownVersion(info.versionCode, info.versionName)
postUpdateNotification(context, info)
return info
}
class UpdateCheckWorker(
context: Context,
@ -89,19 +74,14 @@ private const val NOTIF_ID = 23
private fun postUpdateNotification(context: Context, info: UpdateInfo) {
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(
NOTIF_CHANNEL_ID,
"Straw updates",
NotificationManager.IMPORTANCE_DEFAULT,
).apply {
description = "Notifies when a newer Straw build is on fdroid.sulkta.com."
}
nm.createNotificationChannel(channel)
val channel = NotificationChannel(
NOTIF_CHANNEL_ID,
"Straw updates",
NotificationManager.IMPORTANCE_DEFAULT,
).apply {
description = "Notifies when a newer Straw build is on fdroid.sulkta.com."
}
nm.createNotificationChannel(channel)
// ACTION_VIEW on the APK URL — Chrome / system browser fetches it
// 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")
.setContentText("Tap to download from fdroid.sulkta.com.")
.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)
.build()
nm.notify(NOTIF_ID, notif)

View file

@ -10,7 +10,6 @@
package com.sulkta.straw.net
import com.sulkta.straw.util.runCatchingCancellable
import kotlinx.serialization.Serializable
@Serializable
@ -24,18 +23,13 @@ object RydClient {
/** Suspends on the Rust async runtime; call from a coroutine. Returns
* null on any failure (transport / non-2xx / bad JSON), same contract
* the old blocking client had. Only likes/dislikes are carried RYD's
* 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. */
* rating/viewCount were dead, redundant overlay data (audit #2 L-9). */
suspend fun fetch(videoId: String): RydVotes? =
runCatchingCancellable {
uniffi.strawcore.fetchRydVotes(videoId)?.let { v ->
RydVotes(
id = v.id,
likes = v.likes,
dislikes = v.dislikes,
)
}
}.getOrNull()
uniffi.strawcore.fetchRydVotes(videoId)?.let { v ->
RydVotes(
id = v.id,
likes = v.likes,
dislikes = v.dislikes,
)
}
}

View file

@ -12,7 +12,6 @@
package com.sulkta.straw.net
import com.sulkta.straw.util.runCatchingCancellable
import kotlinx.serialization.Serializable
@Serializable
@ -29,22 +28,17 @@ data class SbSegment(
object SponsorBlockClient {
/** Suspends on the Rust async runtime; call from a coroutine. Returns
* an empty list on any failure, same contract the old blocking client
* had. `categories` are the SB category keys to request. The uniffi
* 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. */
* had. `categories` are the SB category keys to request. */
suspend fun fetch(
videoId: String,
categories: List<String> = listOf("sponsor"),
): List<SbSegment> =
runCatchingCancellable {
uniffi.strawcore.fetchSponsorSegments(videoId, categories).map { s ->
SbSegment(
UUID = s.uuid,
category = s.category,
segment = listOf(s.startSec, s.endSec),
actionType = s.actionType,
)
}
}.getOrDefault(emptyList())
uniffi.strawcore.fetchSponsorSegments(videoId, categories).map { s ->
SbSegment(
UUID = s.uuid,
category = s.category,
segment = listOf(s.startSec, s.endSec),
actionType = s.actionType,
)
}
}