diff --git a/apps/readest-app/.claude/memory/mobile-reading-widgets.md b/apps/readest-app/.claude/memory/mobile-reading-widgets.md index 124db307..0f56b2aa 100644 --- a/apps/readest-app/.claude/memory/mobile-reading-widgets.md +++ b/apps/readest-app/.claude/memory/mobile-reading-widgets.md @@ -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 `` (and ``) 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. diff --git a/apps/readest-app/src-tauri/plugins/tauri-plugin-native-bridge/android/src/androidTest/java/ReadingWidgetStoreTest.kt b/apps/readest-app/src-tauri/plugins/tauri-plugin-native-bridge/android/src/androidTest/java/ReadingWidgetStoreTest.kt new file mode 100644 index 00000000..0cea1d90 --- /dev/null +++ b/apps/readest-app/src-tauri/plugins/tauri-plugin-native-bridge/android/src/androidTest/java/ReadingWidgetStoreTest.kt @@ -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() + } + } +} diff --git a/apps/readest-app/src-tauri/plugins/tauri-plugin-native-bridge/android/src/main/java/ReadingWidgetStore.kt b/apps/readest-app/src-tauri/plugins/tauri-plugin-native-bridge/android/src/main/java/ReadingWidgetStore.kt index 5291d7e0..3312f984 100644 --- a/apps/readest-app/src-tauri/plugins/tauri-plugin-native-bridge/android/src/main/java/ReadingWidgetStore.kt +++ b/apps/readest-app/src-tauri/plugins/tauri-plugin-native-bridge/android/src/main/java/ReadingWidgetStore.kt @@ -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) diff --git a/fastlane/README.md b/fastlane/README.md index e247113a..a35cb290 100644 --- a/fastlane/README.md +++ b/fastlane/README.md @@ -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