Files
readest/apps/readest-app/src-tauri/plugins/tauri-plugin-native-bridge/src/mobile.rs
T
Huang Xin 7185dca1a2 feat(reader): add save/share button to image gallery toolbar (#4680)
* feat(reader): add save/share button to image gallery toolbar

Add a button to the top-right toolbar of the fullscreen image viewer
that saves the currently viewed image to the device. It uses the native
or web Share flow where available (iOS/Android/macOS, navigator.share)
and falls back to a save dialog or browser download otherwise, reusing
the existing export path via appService.saveFile.

The button icon and label reflect the active flow (share vs save).
Adds dataUrlToBytes/imageExtensionFromMime helpers, unit and component
tests, and translations for the new strings across all locales.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix(share): write shareable file to a Temp subdirectory to avoid 0-byte share

On Android, Tauri's Temp dir is the app cache dir, and the sharekit plugin
copies the shared file to <cacheDir>/<name> before firing the share intent.
When saveFile wrote the shareable file to the Temp root, that copy became a
copy onto itself whose output stream truncated the source to 0 bytes, so the
shared image (and any shared export) arrived as a 0 KB file. Write the file
to a Temp subdirectory instead so the plugin's copy has a distinct source.

Verified on a Xiaomi device: sharing a file in the Temp root truncated it to
0 bytes, while sharing from the subdirectory produced a real, non-empty copy.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* feat(reader): save image to system gallery on Android

The Android share sheet cannot save an image to a file (no file manager
registers as an ACTION_SEND target), so the Save Image button now writes
the image straight into the system photo gallery via MediaStore. It lands
in Pictures/Readest, visible in Gallery and the Files app, with no picker
and no storage permission on Android 10+.

Adds a save_image_to_gallery command to the native-bridge plugin (Rust +
Kotlin MediaStore insert) and an appService.saveImageToGallery method. On
Android the Save button uses it; iOS/macOS/desktop/web keep the existing
share/export flow, and the button label/icon reflect the actual action.

Also includes local agent memory notes that were staged alongside.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-20 06:28:08 +02:00

315 lines
9.1 KiB
Rust

use serde::de::DeserializeOwned;
use tauri::{
plugin::{PluginApi, PluginHandle},
AppHandle, Runtime,
};
use crate::models::*;
#[cfg(target_os = "ios")]
tauri::ios_plugin_binding!(init_plugin_native_bridge);
// initializes the Kotlin or Swift plugin classes
pub fn init<R: Runtime, C: DeserializeOwned>(
_app: &AppHandle<R>,
api: PluginApi<R, C>,
) -> crate::Result<NativeBridge<R>> {
#[cfg(target_os = "android")]
let handle = api.register_android_plugin("com.readest.native_bridge", "NativeBridgePlugin")?;
#[cfg(target_os = "ios")]
let handle = api.register_ios_plugin(init_plugin_native_bridge)?;
Ok(NativeBridge(handle))
}
/// Access to the native-bridge APIs.
pub struct NativeBridge<R: Runtime>(PluginHandle<R>);
impl<R: Runtime> NativeBridge<R> {
pub fn auth_with_safari(&self, payload: AuthRequest) -> crate::Result<AuthResponse> {
self.0
.run_mobile_plugin("auth_with_safari", payload)
.map_err(Into::into)
}
}
impl<R: Runtime> NativeBridge<R> {
pub fn auth_with_custom_tab(&self, payload: AuthRequest) -> crate::Result<AuthResponse> {
self.0
.run_mobile_plugin("auth_with_custom_tab", payload)
.map_err(Into::into)
}
}
impl<R: Runtime> NativeBridge<R> {
pub fn copy_uri_to_path(&self, payload: CopyURIRequest) -> crate::Result<CopyURIResponse> {
self.0
.run_mobile_plugin("copy_uri_to_path", payload)
.map_err(Into::into)
}
}
impl<R: Runtime> NativeBridge<R> {
pub fn save_image_to_gallery(
&self,
payload: SaveImageToGalleryRequest,
) -> crate::Result<SaveImageToGalleryResponse> {
self.0
.run_mobile_plugin("save_image_to_gallery", payload)
.map_err(Into::into)
}
}
impl<R: Runtime> NativeBridge<R> {
pub fn use_background_audio(&self, payload: UseBackgroundAudioRequest) -> crate::Result<()> {
self.0
.run_mobile_plugin("use_background_audio", payload)
.map_err(Into::into)
}
}
impl<R: Runtime> NativeBridge<R> {
pub fn install_package(
&self,
payload: InstallPackageRequest,
) -> crate::Result<InstallPackageResponse> {
self.0
.run_mobile_plugin("install_package", payload)
.map_err(Into::into)
}
}
impl<R: Runtime> NativeBridge<R> {
pub fn set_system_ui_visibility(
&self,
payload: SetSystemUIVisibilityRequest,
) -> crate::Result<SetSystemUIVisibilityResponse> {
self.0
.run_mobile_plugin("set_system_ui_visibility", payload)
.map_err(Into::into)
}
}
impl<R: Runtime> NativeBridge<R> {
pub fn get_status_bar_height(&self) -> crate::Result<GetStatusBarHeightResponse> {
self.0
.run_mobile_plugin("get_status_bar_height", ())
.map_err(Into::into)
}
}
impl<R: Runtime> NativeBridge<R> {
pub fn get_sys_fonts_list(&self) -> crate::Result<GetSysFontsListResponse> {
self.0
.run_mobile_plugin("get_sys_fonts_list", ())
.map_err(Into::into)
}
}
impl<R: Runtime> NativeBridge<R> {
pub fn intercept_keys(&self, payload: InterceptKeysRequest) -> crate::Result<()> {
self.0
.run_mobile_plugin("intercept_keys", payload)
.map_err(Into::into)
}
}
impl<R: Runtime> NativeBridge<R> {
pub fn lock_screen_orientation(
&self,
payload: LockScreenOrientationRequest,
) -> crate::Result<()> {
self.0
.run_mobile_plugin("lock_screen_orientation", payload)
.map_err(Into::into)
}
}
impl<R: Runtime> NativeBridge<R> {
pub fn iap_is_available(&self) -> crate::Result<IAPIsAvailableResponse> {
self.0
.run_mobile_plugin("iap_is_available", ())
.map_err(Into::into)
}
}
impl<R: Runtime> NativeBridge<R> {
pub fn iap_initialize(
&self,
payload: IAPInitializeRequest,
) -> crate::Result<IAPInitializeResponse> {
self.0
.run_mobile_plugin("iap_initialize", payload)
.map_err(Into::into)
}
}
impl<R: Runtime> NativeBridge<R> {
pub fn iap_fetch_products(
&self,
payload: IAPFetchProductsRequest,
) -> crate::Result<IAPFetchProductsResponse> {
self.0
.run_mobile_plugin("iap_fetch_products", payload)
.map_err(Into::into)
}
}
impl<R: Runtime> NativeBridge<R> {
pub fn iap_purchase_product(
&self,
payload: IAPPurchaseProductRequest,
) -> crate::Result<IAPPurchaseProductResponse> {
self.0
.run_mobile_plugin("iap_purchase_product", payload)
.map_err(Into::into)
}
}
impl<R: Runtime> NativeBridge<R> {
pub fn iap_restore_purchases(&self) -> crate::Result<IAPRestorePurchasesResponse> {
self.0
.run_mobile_plugin("iap_restore_purchases", ())
.map_err(Into::into)
}
}
impl<R: Runtime> NativeBridge<R> {
pub fn get_system_color_scheme(&self) -> crate::Result<GetSystemColorSchemeResponse> {
self.0
.run_mobile_plugin("get_system_color_scheme", ())
.map_err(Into::into)
}
}
impl<R: Runtime> NativeBridge<R> {
pub fn get_safe_area_insets(&self) -> crate::Result<GetSafeAreaInsetsResponse> {
self.0
.run_mobile_plugin("get_safe_area_insets", ())
.map_err(Into::into)
}
}
impl<R: Runtime> NativeBridge<R> {
pub fn get_screen_brightness(&self) -> crate::Result<GetScreenBrightnessResponse> {
self.0
.run_mobile_plugin("get_screen_brightness", ())
.map_err(Into::into)
}
}
impl<R: Runtime> NativeBridge<R> {
pub fn set_screen_brightness(
&self,
payload: SetScreenBrightnessRequest,
) -> crate::Result<SetScreenBrightnessResponse> {
self.0
.run_mobile_plugin("set_screen_brightness", payload)
.map_err(Into::into)
}
}
impl<R: Runtime> NativeBridge<R> {
pub fn get_external_sdcard_path(&self) -> crate::Result<GetExternalSDCardPathResponse> {
self.0
.run_mobile_plugin("get_external_sdcard_path", ())
.map_err(Into::into)
}
}
impl<R: Runtime> NativeBridge<R> {
pub fn open_external_url(
&self,
payload: OpenExternalUrlRequest,
) -> crate::Result<OpenExternalUrlResponse> {
self.0
.run_mobile_plugin("open_external_url", payload)
.map_err(Into::into)
}
}
impl<R: Runtime> NativeBridge<R> {
pub fn show_lookup_popover(
&self,
payload: ShowLookupPopoverRequest,
) -> crate::Result<ShowLookupPopoverResponse> {
self.0
.run_mobile_plugin("show_lookup_popover", payload)
.map_err(Into::into)
}
}
impl<R: Runtime> NativeBridge<R> {
pub fn select_directory(&self) -> crate::Result<SelectDirectoryResponse> {
self.0
.run_mobile_plugin("select_directory", ())
.map_err(Into::into)
}
}
impl<R: Runtime> NativeBridge<R> {
pub fn get_storefront_region_code(&self) -> crate::Result<GetStorefrontRegionCodeResponse> {
self.0
.run_mobile_plugin("get_storefront_region_code", ())
.map_err(Into::into)
}
}
impl<R: Runtime> NativeBridge<R> {
pub fn request_manage_storage_permission(
&self,
) -> crate::Result<RequestManageStoragePermissionResponse> {
self.0
.run_mobile_plugin("request_manage_storage_permission", ())
.map_err(Into::into)
}
}
impl<R: Runtime> NativeBridge<R> {
pub fn set_sync_passphrase(
&self,
payload: SetSyncPassphraseRequest,
) -> crate::Result<SyncPassphraseResponse> {
self.0
.run_mobile_plugin("set_sync_passphrase", payload)
.map_err(Into::into)
}
}
impl<R: Runtime> NativeBridge<R> {
pub fn get_sync_passphrase(&self) -> crate::Result<GetSyncPassphraseResponse> {
self.0
.run_mobile_plugin("get_sync_passphrase", ())
.map_err(Into::into)
}
}
impl<R: Runtime> NativeBridge<R> {
pub fn clear_sync_passphrase(&self) -> crate::Result<SyncPassphraseResponse> {
self.0
.run_mobile_plugin("clear_sync_passphrase", ())
.map_err(Into::into)
}
}
impl<R: Runtime> NativeBridge<R> {
pub fn is_sync_keychain_available(&self) -> crate::Result<SyncKeychainAvailableResponse> {
self.0
.run_mobile_plugin("is_sync_keychain_available", ())
.map_err(Into::into)
}
}
impl<R: Runtime> NativeBridge<R> {
/// Open a full-screen `WKWebView` / `WebView` over the main app,
/// navigate to `payload.url` with a real Chrome UA, wait for load
/// + settle, then return `document.documentElement.outerHTML`. The
/// overlay UX (loading spinner, theme-matched backdrop, localized
/// labels) is driven by the same fields the desktop `clip_url`
/// command consumes — see `clip_url.rs` for the canonical struct.
pub fn clip_url(&self, payload: ClipUrlRequest) -> crate::Result<ClipUrlResponse> {
self.0
.run_mobile_plugin("clip_url", payload)
.map_err(Into::into)
}
}