Compare commits

...

3 Commits

Author SHA1 Message Date
Huang Xin 01bc015985 release: version 0.11.17 (hotfix for an Android crash) (#4852) 2026-06-29 04:21:28 +02:00
Huang Xin 781a297993 ci(release): attest release and nightly build artifacts (#4851)
Add actions/attest-build-provenance to both build workflows so every
binary is attested in the same job that builds it, the only point
where provenance meaningfully proves an artifact was built from source
rather than uploaded by hand.

release.yml (build-tauri): grant id-token and attestations write
permissions, then attest the desktop bundles via the tauri-action
artifactPaths output, the Android apks, and the Windows portable exe.

nightly.yml (build): same permissions plus one step attesting the
staged nightly-out binaries. Nightlies ship via download.readest.com,
but gh attestation verify is digest based so it verifies them too.

Verify a download with:
  gh attestation verify <file> --repo readest/readest

Closes #4848

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-29 04:14:21 +02:00
Huang Xin a23427ccc6 fix(widget): avoid recycling aliased source bitmap for 2:3 covers (#4850)
Bitmap.createBitmap returns the same immutable instance when the
center-crop covers the whole source, which happens for covers that
decode to exactly 2:3. writeThumbnail then recycled that instance
before createScaledBitmap used it, crashing with "cannot use a
recycled source in createBitmap". Guard the recycle the same way the
scaled vs cropped case is already guarded, and add an instrumented
regression test.

Also bundles a pending widget debugging note and a regenerated
fastlane README that were staged in the working tree.

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-29 04:13:17 +02:00
10 changed files with 109 additions and 13 deletions
+18
View File
@@ -40,6 +40,12 @@ jobs:
build:
needs: compute-version
permissions:
contents: read
# Required by actions/attest-build-provenance: id-token mints the Sigstore
# OIDC identity, attestations writes the provenance to the repo's store.
id-token: write
attestations: write
strategy:
fail-fast: false
matrix:
@@ -381,6 +387,18 @@ jobs:
;;
esac
# Attest the distributable binaries staged in nightly-out (apks, AppImage,
# app.tar.gz, setup/portable exe). gh attestation verify is digest-based,
# so it verifies these against readest/readest even though they ship via
# download.readest.com rather than a GitHub release. The .sig updater
# signatures are excluded — they are not binaries users run.
- name: attest nightly binaries
uses: actions/attest-build-provenance@0f67c3f4856b2e3261c31976d6725780e5e4c373 # v4.1.1
with:
subject-path: |
nightly-out/Readest*
!nightly-out/*.sig
- name: upload artifacts + fragment to R2
shell: bash
run: |
+32
View File
@@ -122,6 +122,10 @@ jobs:
needs: get-release
permissions:
contents: write
# Required by actions/attest-build-provenance: id-token mints the Sigstore
# OIDC identity, attestations writes the provenance to the repo's store.
id-token: write
attestations: write
strategy:
fail-fast: false
matrix:
@@ -267,6 +271,14 @@ jobs:
gh release upload ${{ needs.get-release.outputs.release_tag }} $universial_apk.sig --clobber
gh release upload ${{ needs.get-release.outputs.release_tag }} $arm64_apk.sig --clobber
- name: attest Android apks
if: matrix.config.release == 'android'
uses: actions/attest-build-provenance@0f67c3f4856b2e3261c31976d6725780e5e4c373 # v4.1.1
with:
subject-path: |
apps/readest-app/Readest_${{ needs.get-release.outputs.release_version }}_universal.apk
apps/readest-app/Readest_${{ needs.get-release.outputs.release_version }}_arm64.apk
- name: download and update latest.json for Android release
if: matrix.config.release == 'android'
env:
@@ -309,6 +321,7 @@ jobs:
run: cargo install tauri-cli --git https://github.com/tauri-apps/tauri --branch feat/truly-portable-appimage --force
- uses: tauri-apps/tauri-action@84b9d35b5fc46c1e45415bdb6144030364f7ebc5 # v0
id: tauri
if: matrix.config.release != 'android'
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
@@ -332,6 +345,16 @@ jobs:
releaseBody: ${{ needs.get-release.outputs.release_note }}
args: ${{ matrix.config.args || '' }}
# Attest the freshly built desktop bundles (installers, AppImage, dmg,
# updater archives + their .sig). tauri-action reports their on-disk paths
# as a JSON array; fromJSON('"\n"') yields a real newline to join them into
# the newline-delimited list subject-path expects.
- name: attest desktop bundles
if: matrix.config.release != 'android' && steps.tauri.outputs.artifactPaths != ''
uses: actions/attest-build-provenance@0f67c3f4856b2e3261c31976d6725780e5e4c373 # v4.1.1
with:
subject-path: ${{ join(fromJSON(steps.tauri.outputs.artifactPaths), fromJSON('"\n"')) }}
- name: upload release notes to GitHub release
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
@@ -384,6 +407,15 @@ jobs:
echo "Uploading signature to GitHub release"
gh release upload ${{ needs.get-release.outputs.release_tag }} $bin_file.sig --clobber
# The portable rebuild above is not produced by tauri-action, so it is not
# covered by the "attest desktop bundles" step; attest it here. Exactly one
# portable exe is staged at the workspace root per Windows leg.
- name: attest Windows portable binary
if: matrix.config.os == 'windows-latest'
uses: actions/attest-build-provenance@0f67c3f4856b2e3261c31976d6725780e5e4c373 # v4.1.1
with:
subject-path: Readest_*-portable.exe
- name: download and update latest.json for Windows portable release
if: matrix.config.os == 'windows-latest'
env:
@@ -14,6 +14,7 @@ Durable, non-obvious gotchas (each cost a debugging round):
- **iOS widget missing from gallery = stale `.xcodeproj`.** `gen/apple/project.yml` defines the `ReadestWidget` target, but **Tauri's iOS build does NOT re-run xcodegen**, so a newly-added target is silently omitted from the build. Fix: `cd src-tauri/gen/apple && xcodegen generate`. Also: iOS builds from the **MAIN repo** `/Users/chrox/dev/readest` (complete gen/apple), NOT the `pnpm worktree:new` worktree (its gen/apple is incomplete — missing `Sources/`, `Assets.xcassets`, `Externals`, `LaunchScreen.storyboard` — so xcodegen fails there).
- **Android RemoteViews allow only @RemoteView widgets.** Plain `<View>` (and `<Space>`) is NOT allowed → launcher inflate fails → "Can't load widget". Use an empty `FrameLayout` for spacers. Covers: badge + progress bar are **baked into the bitmap** (Canvas in `writeThumbnail`) because RemoteViews can't clip/overlay reliably; shown via `fitCenter`. Responsive sizing by grid cells: `n = (minWidthDp + 30) / 70` (Android cell formula); one book per column, cap 3.
- **Background TTS progress freeze.** `book.progress` (libraryStore) AND `readerProgressStore` are both written by the same `setProgress`, inside `commitRelocate`**`requestAnimationFrame`**, which Android pauses for a backgrounded WebView → both freeze during background TTS. No store-only fix (page-based progress needs rendering). Fix: in `FoliateViewer.progressRelocateHandler`, commit synchronously when `document.visibilityState === 'hidden'` (relocate still fires; only the rAF commit was deferred). Confirmed working on device.
- **Android crash: "cannot use a recycled source in createBitmap" (exact-2:3 covers).** In `ReadingWidgetStore.writeThumbnail`, `Bitmap.createBitmap(src, x, y, w, h)` returns the SAME instance when the crop covers the whole *immutable* source (`decodeFile` bitmaps are immutable) — which happens when the cover decodes to exactly 2:3 (height==width*3/2), making the center-crop a no-op. The old code then did `bitmap.recycle()`, recycling `cropped` too, so the next `createScaledBitmap(cropped, …)` threw. Fix: `if (cropped !== bitmap) bitmap.recycle()` — mirror the `if (scaled !== cropped) cropped.recycle()` guard already 4 lines below. Trace was R8-obfuscated + ran inside `update_reading_widget`'s `pluginScope.launch { withContext(Dispatchers.IO) }`, surfacing as `FATAL EXCEPTION: main` with a `Dispatchers.Main` cancelled-coroutine suppressed frame. **iOS is unaffected**`ReadingWidgetWriter.writeThumbnail` uses ARC-managed immutable `UIImage` + `UIGraphicsImageRenderer`, no manual recycle/aliasing.
- **iOS TTS controls deferred** — interactive widget buttons need iOS 17 App Intents; widget min target is iOS 15 (15/16 widgets can only deep-link, no buttons). Android uses `MediaButtonReceiver.buildMediaButtonPendingIntent` (any version). Follow-up only.
- **`.superpowers/` is NOT gitignored** in this repo → a subagent's `git add` can sweep SDD scratch (`*-report.md`) into a commit; check `git ls-files '.superpowers/*'` before squashing/pushing.
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@readest/readest-app",
"version": "0.11.16",
"version": "0.11.17",
"private": true,
"type": "module",
"scripts": {
+1 -1
View File
@@ -1,6 +1,6 @@
{
"releases": {
"0.11.16": {
"0.11.17": {
"date": "2026-06-29",
"notes": [
"Sync: Added WebDAV and Google Drive sync for your books and reading data",
@@ -0,0 +1,49 @@
package com.readest.native_bridge
import android.graphics.Bitmap
import android.graphics.BitmapFactory
import androidx.test.ext.junit.runners.AndroidJUnit4
import androidx.test.platform.app.InstrumentationRegistry
import org.junit.Assert.assertNotNull
import org.junit.Assert.assertTrue
import org.junit.Test
import org.junit.runner.RunWith
import java.io.File
/**
* Instrumented test (runs on an Android device/emulator).
*
* Regression for the widget cover crash: a cover that decodes to exactly 2:3
* makes the center-crop a no-op, so Bitmap.createBitmap returns the SAME
* immutable instance as the source. The pre-fix code recycled the source right
* after, then passed the now-recycled bitmap to createScaledBitmap, throwing
* "cannot use a recycled source in createBitmap" and killing the app.
*/
@RunWith(AndroidJUnit4::class)
class ReadingWidgetStoreTest {
@Test
fun writeThumbnail_exact2to3Cover_doesNotCrash() {
val ctx = InstrumentationRegistry.getInstrumentation().targetContext
// 240x360 is exactly 2:3 and small enough to skip downsampling, so the
// decoded cover hits the createBitmap same-instance path.
val src = Bitmap.createBitmap(240, 360, Bitmap.Config.ARGB_8888)
val srcFile = File(ctx.cacheDir, "widget-cover-2x3.png")
srcFile.outputStream().use { src.compress(Bitmap.CompressFormat.PNG, 100, it) }
src.recycle()
val hash = "regression2x3"
try {
// Pre-fix: throws IllegalArgumentException. Post-fix: writes the PNG.
ReadingWidgetStore.writeThumbnail(ctx, hash, srcFile.absolutePath, 42)
val out = File(ReadingWidgetStore.coversDir(ctx), "$hash.png")
assertTrue("thumbnail should be written", out.exists())
val decoded = BitmapFactory.decodeFile(out.absolutePath)
assertNotNull("thumbnail should decode to a valid bitmap", decoded)
out.delete()
} finally {
srcFile.delete()
}
}
}
@@ -57,7 +57,11 @@ object ReadingWidgetStore {
val cropX = (srcW - cropW) / 2
val cropY = (srcH - cropH) / 2
val cropped = Bitmap.createBitmap(bitmap, cropX, cropY, cropW, cropH)
bitmap.recycle()
// createBitmap returns the SAME instance when the crop covers the whole
// (immutable) source — i.e. covers already at exactly 2:3. Recycling here
// would recycle `cropped` too and crash createScaledBitmap below with
// "cannot use a recycled source". Mirror the scaled !== cropped guard.
if (cropped !== bitmap) bitmap.recycle()
// Scale the cropped bitmap to the target size.
val scaled = Bitmap.createScaledBitmap(cropped, THUMB_WIDTH, THUMB_HEIGHT, true)
+1 -1
View File
@@ -70,7 +70,7 @@
</supports>
<releases>
<release version="0.11.16" date="2026-06-29">
<release version="0.11.17" date="2026-06-29">
<description>
<ul>
<li>Sync: Added WebDAV and Google Drive sync for your books and reading data</li>
+1 -1
View File
@@ -1 +1 @@
509767206c4727e8f7a60d2b94879129f0edfa0bbbbcea34a748391b1113a073 ../../data/metainfo/appdata.xml
c0fae8cab8e75871573d679d28bf0c1b778ef9e8f7c0b42da678396d1749fd96 ../../data/metainfo/appdata.xml
-8
View File
@@ -13,14 +13,6 @@ For _fastlane_ installation instructions, see [Installing _fastlane_](https://do
# Available Actions
### verify_paths
```sh
[bundle exec] fastlane verify_paths
```
### release_ios
```sh