The iOS share extension is a web-article URL clipper, but its activation
rule enabled NSExtensionActivationSupportsText. A .txt file is
public.plain-text (conforms to public.text), so that key made the
URL-only extension activate for plain-text files it cannot handle: the
share sheet hung instead of the file taking the main app's
CFBundleDocumentTypes "Copy to Readest" import path, which handles txt
fine (like EPUB and PDF, which never matched the extension).
Drop NSExtensionActivationSupportsText so the extension activates only
for web URLs. Shared .txt files now route to the working document-open
import path; sharing a web page URL from Safari or Chrome still works.
Add a regression guard asserting project.yml (the xcodegen source of
truth for the generated, skip-worktree Info.plist) never re-enables text
activation.
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Report crashes and unhandled errors from every layer to one Sentry project via a
single build-time SENTRY_DSN (empty => disabled, so local and fork builds do not
report):
- JS/WebView + Rust panics via tauri-plugin-sentry (rustls transport; minidump
handler desktop-only).
- Android native (JVM/NDK/ANR) via sentry-android 8.47.0 with manifest auto-init
(excludes the discontinued lifecycle-common-java8 transitive).
- iOS native via sentry-cocoa started from a tracked SentrySupport/+load bootstrap
that reads the DSN from a Rust readest_sentry_dsn() FFI (no generated-file edits).
- SENTRY_DSN resolved at build time from the environment, then .env.local, then
.env (build.rs bakes it via cargo:rustc-env; build.gradle.kts for Android). CI
passes the SENTRY_DSN secret through the existing .env.local step.
Crashes + errors only: traces sample rate 0, no session replay, no PII.
Symbolication (source maps / ProGuard / dSYM upload) is a follow-up.
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Readest now shows up in the Android Auto launcher as a media app and
projects the TTS media session so playback can be controlled from the
car display (play/pause, previous/next sentence).
- Declare the com.google.android.gms.car.application meta-data and the
automotive_app_desc media capability that Android Auto requires to
list the app
- Make MediaPlaybackService safe to bind for browsing: audio focus, the
silent keep-alive player, and the foreground notification no longer
start in onCreate but on an explicit ACTIVATE_SESSION command, so a
car client connecting to browse does not steal audio focus or post a
phantom playing notification
- Deactivate the session with an in-process call instead of
stopService, which would neither run onDestroy nor clear the
foreground state while a media browser keeps the service bound
- Serve the current book as a playable browse item (cover downscaled to
stay under the binder transaction limit) and handle
onPlayFromMediaId/onPlayFromSearch
- Honor the foreground service contract when MediaButtonReceiver
cold-starts the service with no active session
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The App Store export re-sign (xcodebuild -exportArchive, automatic signing)
stripped com.apple.security.application-groups from the ReadestWidget and
ShareExtension binaries because both targets set
CODE_SIGN_ALLOW_ENTITLEMENTS_MODIFICATION: YES. The provisioning profiles and
source entitlements both grant the group, but the signed extension binaries did
not, so the widget read an empty snapshot from the shared App Group container
and showed only the placeholder book icon. Dev builds were unaffected.
Remove the flag from both extension targets (the main app already ships the
group correctly without it) so signing uses the exact CODE_SIGN_ENTITLEMENTS
content. Add scripts/verify-ios-appstore-entitlements.sh and run it from
release-ios-appstore.sh before upload so a stripped App Group fails the release
instead of shipping a dead widget.
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* fix(sync): never toast third-party cloud-sync errors; log to console only
The reader's per-book auto-sync surfaced an "Cloud sync authentication failed.
Reconnect in Settings." toast on any AUTH_FAILED (e.g. an expired web Google
Drive token), interrupting reading. Background sync failures shouldn't pop a
toast — drop it and console.warn every sync error instead (the AUTH_FAILED
branch only chose toast-vs-console, so it collapses to a plain log). Removes the
now-unused authFailedToast + useTranslation/FileSyncError imports.
Manual "Sync now" (FileSyncForm) still reports its result — it's a deliberate,
foreground action. Native cloud sync (useBooksSync) is unaffected.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* feat(sync): surface an expired cloud-sync session in the reader + Settings
With sync-error toasts silenced, an expired third-party session (e.g. the
short-lived web Google Drive token) had no UI indicator. Surface it without the
old per-failure error toast:
- Reader: a single top-right `hint` ("Google Drive session expired. Reconnect in
Settings.") — the same affordance as the native "Reading Progress Synced"
hint. De-duplicated via a per-instance ref so it shows once, not on every
page-turn sync; reset on a successful sync / provider switch (web reconnect
reloads anyway).
- Settings → Google Drive: Disconnect swaps to Reconnect when the session is
expired, and "Sync now" is disabled (FileSyncForm gains a `syncNowDisabled`
prop) so a sync that would just fail isn't offered. No hint text in Settings.
- webTokenStore.hasValidWebDriveToken() backs the web detection (the token lives
in sessionStorage; native auto-refreshes so it doesn't apply there).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Add a resizable home-screen widget on iOS and Android showing recent
in-progress books with cover, reading progress, and tap-to-open.
- One responsive widget: Android resizable 1x1 to 4x3 (one book per
column, up to 3); iOS Small/Medium/Large families. Covers are cropped,
rounded, with a percent badge and a progress bar (baked into the bitmap
on Android, SwiftUI overlays on iOS).
- TTS controls (previous, play-pause, next) appear in 2+ row sizes when
TTS is active, wired to the existing media session. Reading progress
stays live during background TTS via a fraction computed from the baked
offline locations.
- Publishes a snapshot plus downsized cover thumbnails to the iOS App
Group and Android SharedPreferences through a new update_reading_widget
native-bridge command; refresh is debounced and driven by library and
progress changes, TTS, and app backgrounding.
- Tapping a cover opens readest://book/{hash}, switching the reader in
place when one is already open.
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* feat(sync): Google Drive sign-in on Android (Custom Tab OAuth)
Add the Android OAuth runner so Drive can be connected on Android, reusing the
same provider / token store / connect flow as desktop.
- oauthAndroid.ts: runAndroidOAuth wires the DI OAuth flow to a Chrome Custom
Tab via the existing authWithCustomTab native bridge (keeps the Tauri Activity
foregrounded so the in-flight redirect survives). Headless-unit-tested.
- googleDriveConnect: dispatch the platform runner by OS (Android -> Custom Tab,
desktop -> system browser deep link).
- IntegrationsPanel: show the Google Drive provider row on Android too.
- Native (device-verification pending — no Android toolchain in CI):
NativeBridgePlugin.kt handleIntent now also resolves the reverse-DNS
com.googleusercontent.apps.<id>:/oauthredirect redirect through the same
pending invoke as the Supabase callback; a matching BROWSABLE intent-filter
added to AndroidManifest.xml (mirrors the tauri.conf.json deep-link scheme).
Full suite 6475 green; lint + format clean. The native sign-in needs on-device
Android verification before this ships.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* feat(sync): Google Drive sign-in on iOS (ASWebAuthenticationSession OAuth)
Add the iOS OAuth runner so the Drive provider connects on iPhone/iPad,
mirroring the Android Custom Tab flow.
- oauthIos.ts: runIosOAuth drives the shared PKCE flow through
authWithSafari, keyed to the client-id-derived reverse-DNS callback
scheme so the web-auth session intercepts the redirect.
- nativeAuth.ts: AuthRequest gains an optional callbackScheme; the
Supabase login keeps the native "readest" default.
- googleDriveConnect.ts: resolveOAuthRunner dispatches ios to runIosOAuth.
- IntegrationsPanel.tsx: show the Google Drive cloud-sync row on iOS.
Native (device-verify pending, no iOS toolchain in CI):
- auth_with_safari honors args.callbackScheme (default "readest").
- Info-ios.plist registers the reverse-DNS scheme in CFBundleURLTypes,
mirroring the AndroidManifest gdrive-oauth filter.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Android 13+ recolors the adaptive icon's `<monochrome>` layer with a
wallpaper-derived tint when the user enables themed icons. Support was added
in #2122/#2153 (the `ic_launcher_monochrome.png` assets) but #2353 ("fixed
launcher icon size") rewrote the committed adaptive icon to inset the
foreground 22% and silently dropped the `<monochrome>` layer, so themed icons
stopped working in shipped builds.
Restore it by re-adding a `<monochrome>` layer (same 22% inset as the
foreground) and shipping the monochrome mipmaps. The CI/release flow
regenerates `gen/android` (`tauri android init` + `tauri icon`) then
`git checkout .` to restore tracked customizations; `tauri icon` does not emit
a monochrome layer, so the mipmaps are force-committed under `gen/` like the
other customized resources.
The monochrome artwork is redesigned: Android tints the layer via SRC_IN
(alpha only), which flattened the old desaturated-logo asset into a solid
blob. A narrow vertical center gap now splits the open book into two pages
with a visible spine while keeping the bookmark, so the mark keeps its
character when themed.
Verified on a Pixel 9 Pro emulator (Android 36) with Themed Icons enabled, and
guarded by src/__tests__/android/themed-icon.test.ts.
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Backports the Android ANR/stability fixes from the julianshen fork,
adapted to upstream — notably preserving the cold-start shared-intent
replay queue and the #4559 dictionary-dispatch logic, neither of which
the fork kept.
- NativeBridgePlugin: run blocking @Command I/O (copy_uri_to_path,
install_package, get_sys_fonts_list, and show_lookup_popover's
queryIntentActivities) on Dispatchers.IO via a Main-dispatched
pluginScope; startActivity hops back to Main; resolves are
isActive-guarded; onDestroy cancels the scope and clears the static
instance; the system-font scan is cached (@Volatile). The cold-start
shared-intent queue (emitOrQueue / registerListener) is left intact.
- MediaPlaybackService: unmarshal the artwork Bitmap off the main thread
(serviceScope + Dispatchers.Default), isActive-guarded; cancel the
scope in onDestroy.
- ClipUrlController: hold the Activity via WeakReference and check
isFinishing/isDestroyed before presenting, to avoid leaking the
Activity/WebView during the up-to-30s clip window.
- MainActivity (#3297): on Android 14+ the window can gain focus before
the WebView paints its first frame, leaving a blank screen. Force one
repaint when both the window has focus and the WebView exists
(whichever happens last).
Kotlin-only; not exercised by the JS/Rust test suites. Verified via
ktlint parse + a release `tauri android build` and on-device smoke test
(Xiaomi). The touch-event throttle and intent-handling rewrite from the
fork are intentionally NOT backported (they dropped touchmove forwarding
and the cold-start queue).
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* fix(reader): turn automatically when highlighting across pages (closes#1354)
* refact: Refactor time retrieval to use Date.now()
* fix(reader): rework auto page-turn as a corner-dwell gesture
Rework the initial #1354 implementation into a deliberate corner-dwell
gesture that works across platforms, and fix popup positioning for
cross-page selections.
Trigger:
- While a text selection is active, hold any engagement signal — the
pointer (web/desktop/iOS), the Android native touchmove, or the
selection caret — inside a screen corner for 500ms to turn one page:
bottom-right goes to the next page, top-left to the previous.
- One turn per engagement: a signal must leave the corner and return to
turn another page, so the user controls it one page at a time.
- The corner is a quarter-ellipse of radius 15% of each axis, measured
against the reading frame (the <foliate-view> rect) inset by the page
content margins, so the zone lands on the text — not the margin/footer
or a sidebar — and the pointer can actually reach it.
Per-platform signals:
- web/desktop/iOS: the iframe pointermove, mapped to window coordinates
via the iframe element's on-screen rect.
- Android: the selection caret (the only signal during a native handle
drag, where the handles live in a separate window so their touches
never reach the Activity) plus a throttled (~10/s) native touchmove
added in MainActivity.dispatchTouchEvent for content drags.
Android scroll-pin (#873): an active selection pins the container scroll,
which reverted the turn; suspend the pin during the turn and re-anchor it
to the page we land on.
Popup positioning: getPosition decided which selection end was on-screen
using window bounds, so a cross-page selection's off-screen start (which
maps behind the sidebar but inside the window) read "in view" and pinned
the popup off the visible page. Test visibility against the reading frame
instead, and for a multi-page selection anchor to the last on-screen line.
Also: logical view.prev()/next() (RTL-correct); skip in scrolled mode;
pass contentInsets down to the annotator.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
---------
Co-authored-by: Huang Xin <chrox.huang@gmail.com>
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* feat(send): iOS share-extension picker + App Group queue + reliable host launch
Rework the iOS Share Extension to a Zotero-style sheet: URL preview row +
library group picker + "Save & Open". Queues each save into the shared
App Group container and best-effort launches the host app via the
Chrome-style responder-chain trick (IMP cast against
`openURL:options:completionHandler:`). The host plugin drains the queue
on `applicationDidBecomeActive`, so if the launch ever fails the article
still ingests next time Readest is opened.
A `WKScriptMessageHandler` named `readestShareBridge` lets the JS hook
post `{type:'ready'}` on mount, fixing the cold-start race when the
extension wakes the app before the React side has loaded.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* feat(send): cover generator, favicon fetcher, share-extension polish, locale sync
Builds on the previous commit (iOS share-extension picker + App Group queue +
reliable host launch) with three further additions to the send-to-Readest
pipeline:
* **Cover generator** — `services/send/conversion/coverGenerator.ts` renders a
deterministic cover image into clipped EPUBs (favicon + page title + host).
Hooked into `buildEpub.ts` so every clip path (desktop, mobile, browser
extension) produces the same cover for the same source.
* **Favicon fetcher** — `services/send/conversion/faviconFetcher.ts` resolves
the best-available site icon (Open Graph image → apple-touch-icon →
/favicon.ico), with size + format normalization. Feeds the cover generator.
* **Unified page conversion** — `convertToEpub({kind:'page', ...})` replaces
the older `convertPageToEpub(html, url)` so the share extension, /send page,
and browser extension share one entry point. Test: `send-convert-page-unified`.
* **Share-extension project.yml comment** — clarifies why the ShareExtension
target carries no `.lproj` files (system bar buttons + JS-supplied "Default"
label, no per-locale strings to wire).
* **Locale sync** — 33 translation.json files updated with new "Default",
"Saving article…", and cover-generator strings extracted by i18next-scanner.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Users can now tap "Share → Readest" in Safari, Chrome, or any other
browser on iOS / Android and the article URL flows through the same
clip-and-import pipeline the in-app "From Web URL" entry uses.
Android
`MainActivity.handleIncomingIntent` already routed file shares via
`ACTION_SEND` + `EXTRA_STREAM`. Extend it to also pick up URL shares
via `ACTION_SEND` + `EXTRA_TEXT`: parse the first http(s) token out of
the text payload and dispatch it on the existing `shared-intent` event
channel. No new event channel needed — `useAppUrlIngress` already
listens and re-broadcasts as `app-incoming-url`.
The existing `<intent-filter>` for `ACTION_SEND` with `*/*` MIME type
already accepts `text/plain` from browsers — no manifest change
required.
iOS
`gen/apple/` gains a new ShareExtension target. The extension's
`ShareViewController` extracts a URL from `NSExtensionContext.inputItems`
(prefers `public.url`, falls back to first http(s) token in
`public.plain-text`) and forwards it to the main app as
`readest://clip?url=<encoded>` via the responder-chain `openURL:`
selector — the standard share-extension trick used by Pocket,
Instapaper, Matter, etc.
`project.yml` adds the ShareExtension target and switches the main app's
Info.plist / entitlements references to `INFOPLIST_FILE` /
`CODE_SIGN_ENTITLEMENTS` build settings instead of xcodegen's `info:` /
`entitlements:` blocks. That way the hand-tuned `Readest_iOS/Info.plist`
(CFBundleDocumentTypes, UTExportedTypeDeclarations, locales,
CFBundleURLTypes for readest://, applesignin, associated-domains for
Universal Links) is treated as an opaque input — xcodegen won't
regenerate it.
JS
New `useClipUrlIngress` hook subscribes to `app-incoming-url`,
unwraps `readest://clip?url=<encoded>` into the inner URL (the iOS
forwarding path), filters out file URIs and annotation deep links,
and runs each remaining http(s) URL through `clip_url` →
`convertToEpubWithWorker` → `ingestFile` — the same path `/send` uses.
Mounted alongside `useOpenWithBooks` and `useOpenAnnotationLink` in
both `app/library/page.tsx` and `app/reader/page.tsx` so shares
arriving while the user is reading still process.
Notes
- The PR targets `feat/send-clip-mobile` (PR #4252) since the share
pipeline depends on `clip_url` being available on mobile.
- iOS Share Extension built locally via xcodegen; the regenerated
pbxproj is tracked because gen/apple is gitignored.
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* feat(reader): add custom hardware-button page turning (#4139)
Lets users bind hardware remote keys (media keys, D-pad/arrow keys) to
previous/next page via a learn-mode capture UI in reader settings — an
accessibility feature for page-turner remotes.
- New global hardwarePageTurner system setting (enabled + key bindings).
- hardwareKeys.ts: key normalization, matching, and page-turn resolution.
- deviceStore: reference-counted media-key interception + learn mode.
- usePagination: flips pages from bound media keys (native bridge) and
D-pad/keyboard keys (DOM keydown), scoped to the active book and
suppressed while the toolbar is visible.
- Page Turner settings section on all platforms; web/desktop bind keys
via DOM keydown only, native media-key interception stays mobile-only.
- Android: intercept media + learn-mode keys in dispatchKeyEvent.
- iOS: forward media keys via MPRemoteCommandCenter.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* chore(i18n): add and translate hardware page turner strings (#4139)
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* feat(reader): refine hardware page turner (#4139)
- Handle book-iframe key events (iframe-keydown messages) so custom
bindings work as soon as a book is open, not only after the settings
panel has been shown.
- Add Previous/Next Section bindings alongside the page bindings.
- Rename the hardwareKeys util to keybinding.
- Wire the Page Turner section into the settings Reset action.
- Drop the focus ring on the capture buttons; BoxedList gains an
optional description.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* chore(i18n): translate page turner section and key strings (#4139)
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* feat(annotations): preview mode for deep-link landings
When the reader opens at a deep-link CFI (e.g. clicking an exported
highlight from Obsidian), the position should not be persisted as the
user's reading progress until they actually start reading. Otherwise
the deep-link visit overwrites their last-read position and propagates
that across all sync targets.
Adds a per-book `previewMode` flag in the reader store that:
- Is set to true in FoliateViewer when the URL's `?cfi=` overrides the
saved last-position.
- Is cleared on the first user-initiated relocate (page turn / scroll),
reusing the existing reason filter in `docRelocateHandler`.
- Gates the auto progress writers:
- useProgressAutoSave — skip local config persist
- useProgressSync — skip auto-push and skip the remote-progress
view.goTo (so cloud pull doesn't yank the
user away from the previewed annotation)
- useKOSync — skip auto-push (manual pushes still respected)
Hardcover sync and Discord presence are unaffected: hardcover only
fires on explicit user button press, and Discord presence carries no
position information.
Also picks up the regenerated AndroidManifest.xml change from the
existing tauri.conf.json deep-link config (registers readest:// scheme
on Android so the smart landing page's intent:// launch resolves).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* fix(annotations): jump in place when target book is already open
When an annotation deep link arrives while the user is already in the
reader (most common case on mobile App Links), navigateToReader was
pushing the same /reader path with a different cfi query param. The
reader's init useEffect has [] deps, so it doesn't re-run, and
FoliateViewer doesn't re-read the cfi — the view stayed put.
Detect a mounted view for the target book hash by walking
viewStates and matching the hash prefix on the bookKey. If found,
call view.goTo(cfi) directly and set previewMode so the existing
gates fire. Falls back to navigateToReader when no view is open.
Also adds a console.log on each parsed deep link to make this path
easier to debug from device logs in the future.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
When the Android native TTS engine is paused or stopped but not shut down,
it holds resources and drains battery. This adds a 30-minute idle timer that
automatically shuts down the TextToSpeech engine and MediaPlaybackService
after inactivity. The engine transparently re-initializes on next use.
Also adds missing androidx.lifecycle:lifecycle-process dependency to fix
ProcessLifecycleOwner build error.
Closes#3713
Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>