diff --git a/.forgejo/workflows/build.yml b/.forgejo/workflows/build.yml deleted file mode 100644 index 1fe3c804e..000000000 --- a/.forgejo/workflows/build.yml +++ /dev/null @@ -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//. 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 diff --git a/.forgejo/workflows/gitleaks.yml b/.forgejo/workflows/gitleaks.yml deleted file mode 100644 index 10d7847f3..000000000 --- a/.forgejo/workflows/gitleaks.yml +++ /dev/null @@ -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 diff --git a/.github/changed-lines-count-labeler.yml b/.github/changed-lines-count-labeler.yml new file mode 100644 index 000000000..902f376c0 --- /dev/null +++ b/.github/changed-lines-count-labeler.yml @@ -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 diff --git a/.github/workflows/backport-pr.yml b/.github/workflows/backport-pr.yml new file mode 100644 index 000000000..1e3074064 --- /dev/null +++ b/.github/workflows/backport-pr.yml @@ -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 " >&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 }} \ No newline at end of file diff --git a/.github/workflows/build-release-apk.yml b/.github/workflows/build-release-apk.yml new file mode 100644 index 000000000..52c3aeb29 --- /dev/null +++ b/.github/workflows/build-release-apk.yml @@ -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 diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 000000000..87a3ca33c --- /dev/null +++ b/.github/workflows/ci.yml @@ -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 diff --git a/.github/workflows/image-minimizer.js b/.github/workflows/image-minimizer.js new file mode 100644 index 000000000..0a1e56c56 --- /dev/null +++ b/.github/workflows/image-minimizer.js @@ -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 = ''; + 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//.) + 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 `${g1}`; + } + + console.log(`Match '${match}' is ok/will not be modified`); + return match; + } +} diff --git a/.github/workflows/image-minimizer.yml b/.github/workflows/image-minimizer.yml new file mode 100644 index 000000000..15c2aacaf --- /dev/null +++ b/.github/workflows/image-minimizer.yml @@ -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}); diff --git a/.github/workflows/no-response.yml b/.github/workflows/no-response.yml new file mode 100644 index 000000000..b3495135f --- /dev/null +++ b/.github/workflows/no-response.yml @@ -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 diff --git a/.github/workflows/pr-labeler.yml b/.github/workflows/pr-labeler.yml new file mode 100644 index 000000000..a18daca3a --- /dev/null +++ b/.github/workflows/pr-labeler.yml @@ -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 diff --git a/buildSrc/src/main/kotlin/ProjectConfig.kt b/buildSrc/src/main/kotlin/ProjectConfig.kt index 6e09d9180..42b67dc1e 100644 --- a/buildSrc/src/main/kotlin/ProjectConfig.kt +++ b/buildSrc/src/main/kotlin/ProjectConfig.kt @@ -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" diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index f9c230243..a3f97d978 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -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" diff --git a/rust/Cargo.toml b/rust/Cargo.toml index a35ae1124..2f1ce3924 100644 --- a/rust/Cargo.toml +++ b/rust/Cargo.toml @@ -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. diff --git a/rust/strawcore/src/channel.rs b/rust/strawcore/src/channel.rs index 71cf43610..6f9554f66 100644 --- a/rust/strawcore/src/channel.rs +++ b/rust/strawcore/src/channel.rs @@ -33,8 +33,11 @@ pub async fn channel_info(input: String) -> Result 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(what: &'static str, f: F) -> Result -where - F: FnOnce() -> Result + Send + 'static, - T: Send + 'static, - E: Send + 'static, - StrawcoreError: From, -{ - 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` mapping. - Ok(Ok(inner)) => inner.map_err(StrawcoreError::from), - } -} diff --git a/rust/strawcore/src/search.rs b/rust/strawcore/src/search.rs index 726019a96..308bacdda 100644 --- a/rust/strawcore/src/search.rs +++ b/rust/strawcore/src/search.rs @@ -84,10 +84,13 @@ pub async fn search(query: String) -> Result { // 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 { pub async fn search_continuation(token: String) -> Result { 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)) } diff --git a/rust/strawcore/src/stream.rs b/rust/strawcore/src/stream.rs index 09eb19cfd..e95c313ea 100644 --- a/rust/strawcore/src/stream.rs +++ b/rust/strawcore/src/stream.rs @@ -78,10 +78,11 @@ pub async fn stream_info(input: String) -> Result { 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))) } diff --git a/strawApp/build.gradle.kts b/strawApp/build.gradle.kts index ed3b5df02..bf1bdbed2 100644 --- a/strawApp/build.gradle.kts +++ b/strawApp/build.gradle.kts @@ -66,15 +66,7 @@ configure { // 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. diff --git a/strawApp/src/main/kotlin/com/sulkta/straw/StrawActivity.kt b/strawApp/src/main/kotlin/com/sulkta/straw/StrawActivity.kt index d69e2b918..fbc39d27c 100644 --- a/strawApp/src/main/kotlin/com/sulkta/straw/StrawActivity.kt +++ b/strawApp/src/main/kotlin/com/sulkta/straw/StrawActivity.kt @@ -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(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, diff --git a/strawApp/src/main/kotlin/com/sulkta/straw/data/PlaylistsStore.kt b/strawApp/src/main/kotlin/com/sulkta/straw/data/PlaylistsStore.kt index 05fd20840..22b381868 100644 --- a/strawApp/src/main/kotlin/com/sulkta/straw/data/PlaylistsStore.kt +++ b/strawApp/src/main/kotlin/com/sulkta/straw/data/PlaylistsStore.kt @@ -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 tens–hundreds of short-string items, so these -// bound the pathological case while leaving normal use untouched. -private const val MAX_PLAYLISTS = 100 -private const val MAX_ITEMS_PER_PLAYLIST = 1000 -private const val MAX_NAME_LEN = 100 -private const val MAX_TITLE_LEN = 300 -private const val MAX_URL_LEN = 512 -private const val MAX_UPLOADER_LEN = 100 - -// Refuse to even decode a persisted blob larger than this (chars). A capped -// legitimate export is a few MB at most; anything past this is corrupt or a -// pre-fix poison blob, and we'd rather start empty than OOM decoding it. -private const val MAX_BLOB_CHARS = 8_000_000 - class PlaylistsStore(context: Context) { 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>(emptyList()) + private val _playlists = MutableStateFlow(load()) val playlists: StateFlow> = _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): 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() - val deduped = ArrayList(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(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): List = - 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, live: List): List { - val liveIds = live.mapTo(HashSet()) { it.id } - return live + loaded.filterNot { it.id in liveIds } - } - private fun load(): List = 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>(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()) } diff --git a/strawApp/src/main/kotlin/com/sulkta/straw/data/SettingsStore.kt b/strawApp/src/main/kotlin/com/sulkta/straw/data/SettingsStore.kt index 9fe25bee0..86b3643f6 100644 --- a/strawApp/src/main/kotlin/com/sulkta/straw/data/SettingsStore.kt +++ b/strawApp/src/main/kotlin/com/sulkta/straw/data/SettingsStore.kt @@ -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 = _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 = _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 = _resumePositionsCap.asStateFlow() private val _searchCacheCap = MutableStateFlow( - loadCap(KEY_CACHE_SEARCH, default = CacheCap.Tiny.value), + loadCap(KEY_CACHE_SEARCH, default = 30), ) val searchCacheCap: StateFlow = _searchCacheCap.asStateFlow() diff --git a/strawApp/src/main/kotlin/com/sulkta/straw/feature/dataimport/SettingsImport.kt b/strawApp/src/main/kotlin/com/sulkta/straw/feature/dataimport/SettingsImport.kt index 39657b91e..979c4dcbc 100644 --- a/strawApp/src/main/kotlin/com/sulkta/straw/feature/dataimport/SettingsImport.kt +++ b/strawApp/src/main/kotlin/com/sulkta/straw/feature/dataimport/SettingsImport.kt @@ -321,11 +321,8 @@ object SettingsImport { openDb(dbFile).use { db -> val playlistRows = mutableListOf>() // 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 -> diff --git a/strawApp/src/main/kotlin/com/sulkta/straw/feature/detail/VideoDetailViewModel.kt b/strawApp/src/main/kotlin/com/sulkta/straw/feature/detail/VideoDetailViewModel.kt index cb67041c4..dbc8d8e5b 100644 --- a/strawApp/src/main/kotlin/com/sulkta/straw/feature/detail/VideoDetailViewModel.kt +++ b/strawApp/src/main/kotlin/com/sulkta/straw/feature/detail/VideoDetailViewModel.kt @@ -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( diff --git a/strawApp/src/main/kotlin/com/sulkta/straw/feature/player/NowPlaying.kt b/strawApp/src/main/kotlin/com/sulkta/straw/feature/player/NowPlaying.kt index be7b6e5c9..a1f8bb619 100644 --- a/strawApp/src/main/kotlin/com/sulkta/straw/feature/player/NowPlaying.kt +++ b/strawApp/src/main/kotlin/com/sulkta/straw/feature/player/NowPlaying.kt @@ -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 } diff --git a/strawApp/src/main/kotlin/com/sulkta/straw/feature/player/PlaybackService.kt b/strawApp/src/main/kotlin/com/sulkta/straw/feature/player/PlaybackService.kt index 3df7e4ff1..8bd570751 100644 --- a/strawApp/src/main/kotlin/com/sulkta/straw/feature/player/PlaybackService.kt +++ b/strawApp/src/main/kotlin/com/sulkta/straw/feature/player/PlaybackService.kt @@ -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)) } } } diff --git a/strawApp/src/main/kotlin/com/sulkta/straw/feature/update/AppUpdateClient.kt b/strawApp/src/main/kotlin/com/sulkta/straw/feature/update/AppUpdateClient.kt index 06160d238..2800500cf 100644 --- a/strawApp/src/main/kotlin/com/sulkta/straw/feature/update/AppUpdateClient.kt +++ b/strawApp/src/main/kotlin/com/sulkta/straw/feature/update/AppUpdateClient.kt @@ -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(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() } } diff --git a/strawApp/src/main/kotlin/com/sulkta/straw/feature/update/UpdateCheckWorker.kt b/strawApp/src/main/kotlin/com/sulkta/straw/feature/update/UpdateCheckWorker.kt index 55ab485fc..758fdabdc 100644 --- a/strawApp/src/main/kotlin/com/sulkta/straw/feature/update/UpdateCheckWorker.kt +++ b/strawApp/src/main/kotlin/com/sulkta/straw/feature/update/UpdateCheckWorker.kt @@ -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) diff --git a/strawApp/src/main/kotlin/com/sulkta/straw/net/RydClient.kt b/strawApp/src/main/kotlin/com/sulkta/straw/net/RydClient.kt index e47fe8d13..400127de3 100644 --- a/strawApp/src/main/kotlin/com/sulkta/straw/net/RydClient.kt +++ b/strawApp/src/main/kotlin/com/sulkta/straw/net/RydClient.kt @@ -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, + ) + } } diff --git a/strawApp/src/main/kotlin/com/sulkta/straw/net/SponsorBlockClient.kt b/strawApp/src/main/kotlin/com/sulkta/straw/net/SponsorBlockClient.kt index 662db3eea..f15a6ef6d 100644 --- a/strawApp/src/main/kotlin/com/sulkta/straw/net/SponsorBlockClient.kt +++ b/strawApp/src/main/kotlin/com/sulkta/straw/net/SponsorBlockClient.kt @@ -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 = listOf("sponsor"), ): List = - 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, + ) + } }