From c9f0a62be4c34b82845023ef571d12f7e5c912b0 Mon Sep 17 00:00:00 2001 From: HKLHaoBin Date: Sat, 1 Aug 2026 17:27:00 +0800 Subject: [PATCH 1/2] fix(android): use app filesDir for persistence and SAF log export Honor devices reject writes under /data/local/tmp, which caused PreferencesStore to fall back to openless_prefs_fallback.json and settings saves to fail. Initialize JNI storage roots before Coordinator and file logging, route logs to files/logs/openless.log, and export via ContentResolver for SAF content:// URIs. --- .../kotlin/OpenLessAndroidPreferences.kt | 4 +- .../android/kotlin/OpenLessContentWriter.kt | 37 ++++ openless-all/app/package-lock.json | 34 +-- openless-all/app/package.json | 2 +- .../app/scripts/copy-android-scaffolding.mjs | 1 + openless-all/app/src-tauri/Cargo.lock | 4 +- openless-all/app/src-tauri/Cargo.toml | 2 +- openless-all/app/src-tauri/src/android/jni.rs | 32 +++ .../app/src-tauri/src/commands/misc.rs | 50 ++++- openless-all/app/src-tauri/src/coordinator.rs | 20 +- openless-all/app/src-tauri/src/lib.rs | 34 ++- .../app/src-tauri/src/mobile_runtime.rs | 22 +- .../src/persistence/android_storage.rs | 198 ++++++++++++++++++ .../src-tauri/src/persistence/correction.rs | 4 +- .../src-tauri/src/persistence/dictionary.rs | 4 +- .../app/src-tauri/src/persistence/history.rs | 7 +- .../app/src-tauri/src/persistence/mod.rs | 40 +++- .../src-tauri/src/persistence/preferences.rs | 5 +- .../src-tauri/src/persistence/style_pack.rs | 8 +- openless-all/app/src/lib/ipc/utils.ts | 17 +- .../src/pages/settings/DebugToolsSection.tsx | 3 +- 21 files changed, 445 insertions(+), 83 deletions(-) create mode 100644 openless-all/app/android/kotlin/OpenLessContentWriter.kt create mode 100644 openless-all/app/src-tauri/src/persistence/android_storage.rs diff --git a/openless-all/app/android/kotlin/OpenLessAndroidPreferences.kt b/openless-all/app/android/kotlin/OpenLessAndroidPreferences.kt index 1b06e9c89..e98d5c08f 100644 --- a/openless-all/app/android/kotlin/OpenLessAndroidPreferences.kt +++ b/openless-all/app/android/kotlin/OpenLessAndroidPreferences.kt @@ -101,12 +101,14 @@ object OpenLessAndroidPreferences { private fun preferenceFiles(context: Context): List { val files = mutableListOf() + // Prefer app-private filesDir (Rust data_dir); never rely on /data/local/tmp. + files += File(File(context.filesDir, APP_DIR), PREFERENCES_FILE) val envDir = System.getenv("TAURI_ANDROID_APP_DATA_DIR") if (!envDir.isNullOrBlank()) { files += File(File(envDir), APP_DIR).resolve(PREFERENCES_FILE) } + // Legacy probe only — some older builds may have written under cacheDir. files += File(File(context.cacheDir, APP_DIR), PREFERENCES_FILE) - files += File(File(context.filesDir, APP_DIR), PREFERENCES_FILE) return files } } diff --git a/openless-all/app/android/kotlin/OpenLessContentWriter.kt b/openless-all/app/android/kotlin/OpenLessContentWriter.kt new file mode 100644 index 000000000..9f619ac23 --- /dev/null +++ b/openless-all/app/android/kotlin/OpenLessContentWriter.kt @@ -0,0 +1,37 @@ +package com.openless.app + +import android.content.Context +import android.net.Uri +import android.util.Log +import androidx.annotation.Keep + +/** + * Writes bytes to a SAF content:// URI via ContentResolver. + * + * Prefer this over tauri-plugin-fs for exports: fs detaches the FD early and + * some providers finalize a 0-byte file before Rust finishes writing. + */ +@Keep +object OpenLessContentWriter { + private const val TAG = "OpenLessContentWriter" + + @Keep + @JvmStatic + fun writeBytes(context: Context, uriString: String, bytes: ByteArray): Boolean { + return try { + val uri = Uri.parse(uriString) + context.contentResolver.openOutputStream(uri)?.use { output -> + output.write(bytes) + output.flush() + } ?: run { + Log.w(TAG, "openOutputStream returned null for $uriString") + return false + } + Log.i(TAG, "wrote ${bytes.size} bytes to $uriString") + true + } catch (error: Throwable) { + Log.e(TAG, "failed to write $uriString", error) + false + } + } +} diff --git a/openless-all/app/package-lock.json b/openless-all/app/package-lock.json index 9d43a18b2..365185ceb 100644 --- a/openless-all/app/package-lock.json +++ b/openless-all/app/package-lock.json @@ -14,7 +14,7 @@ "@tailwindcss/vite": "^4.3.2", "@tauri-apps/api": "^2.1.1", "@tauri-apps/plugin-autostart": "^2.5.1", - "@tauri-apps/plugin-dialog": "^2.7.1", + "@tauri-apps/plugin-dialog": "^2.7.2", "@tauri-apps/plugin-shell": "^2.3.5", "@tauri-apps/plugin-updater": "^2.10.1", "@types/dompurify": "^3.0.5", @@ -2233,9 +2233,9 @@ } }, "node_modules/@tauri-apps/plugin-dialog": { - "version": "2.7.1", - "resolved": "https://registry.npmjs.org/@tauri-apps/plugin-dialog/-/plugin-dialog-2.7.1.tgz", - "integrity": "sha512-OK1UBXYt+ojcmxMktzzuyonYIFta8CmAASpX+CA+DTGK24KlHjhYI6x2iOJ/TjZF4N7/ACK1oFmEOjIY9IhzOQ==", + "version": "2.7.2", + "resolved": "https://registry.npmjs.org/@tauri-apps/plugin-dialog/-/plugin-dialog-2.7.2.tgz", + "integrity": "sha512-pX0IGm1I3I6wc+zeKYcq1GSqogK6okCNX5fOdaNU5ab1AjGS6l1E5wFNjEb7meg7ZFSp0JUs+0jQGQNyOvLrsg==", "license": "MIT OR Apache-2.0", "dependencies": { "@tauri-apps/api": "^2.11.0" @@ -6237,7 +6237,6 @@ "cpu": [ "ppc64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -6254,7 +6253,6 @@ "cpu": [ "arm" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -6271,7 +6269,6 @@ "cpu": [ "arm64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -6288,7 +6285,6 @@ "cpu": [ "x64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -6305,7 +6301,6 @@ "cpu": [ "arm64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -6322,7 +6317,6 @@ "cpu": [ "x64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -6339,7 +6333,6 @@ "cpu": [ "arm64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -6356,7 +6349,6 @@ "cpu": [ "x64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -6373,7 +6365,6 @@ "cpu": [ "arm" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -6390,7 +6381,6 @@ "cpu": [ "arm64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -6407,7 +6397,6 @@ "cpu": [ "ia32" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -6424,7 +6413,6 @@ "cpu": [ "loong64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -6441,7 +6429,6 @@ "cpu": [ "mips64el" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -6458,7 +6445,6 @@ "cpu": [ "ppc64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -6475,7 +6461,6 @@ "cpu": [ "riscv64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -6492,7 +6477,6 @@ "cpu": [ "s390x" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -6509,7 +6493,6 @@ "cpu": [ "x64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -6526,7 +6509,6 @@ "cpu": [ "arm64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -6543,7 +6525,6 @@ "cpu": [ "x64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -6560,7 +6541,6 @@ "cpu": [ "arm64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -6577,7 +6557,6 @@ "cpu": [ "x64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -6594,7 +6573,6 @@ "cpu": [ "arm64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -6611,7 +6589,6 @@ "cpu": [ "x64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -6628,7 +6605,6 @@ "cpu": [ "arm64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -6645,7 +6621,6 @@ "cpu": [ "ia32" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -6662,7 +6637,6 @@ "cpu": [ "x64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ diff --git a/openless-all/app/package.json b/openless-all/app/package.json index 144dff099..49af3b8c8 100644 --- a/openless-all/app/package.json +++ b/openless-all/app/package.json @@ -35,7 +35,7 @@ "@tailwindcss/vite": "^4.3.2", "@tauri-apps/api": "^2.1.1", "@tauri-apps/plugin-autostart": "^2.5.1", - "@tauri-apps/plugin-dialog": "^2.7.1", + "@tauri-apps/plugin-dialog": "^2.7.2", "@tauri-apps/plugin-shell": "^2.3.5", "@tauri-apps/plugin-updater": "^2.10.1", "@types/dompurify": "^3.0.5", diff --git a/openless-all/app/scripts/copy-android-scaffolding.mjs b/openless-all/app/scripts/copy-android-scaffolding.mjs index b4633c0b6..942d0767b 100644 --- a/openless-all/app/scripts/copy-android-scaffolding.mjs +++ b/openless-all/app/scripts/copy-android-scaffolding.mjs @@ -34,6 +34,7 @@ const KOTLIN_FILES = [ 'OpenLessAccessibilityCommandReceiver.kt', 'OverlayPermissionActivity.kt', 'OpenLessUpdateInstaller.kt', + 'OpenLessContentWriter.kt', ]; const KOTLIN_TEST_FILES = ['OpenLessCredentialCipherTest.kt']; diff --git a/openless-all/app/src-tauri/Cargo.lock b/openless-all/app/src-tauri/Cargo.lock index 995bd6174..8bb50d272 100644 --- a/openless-all/app/src-tauri/Cargo.lock +++ b/openless-all/app/src-tauri/Cargo.lock @@ -5868,9 +5868,9 @@ dependencies = [ [[package]] name = "tauri-plugin-dialog" -version = "2.7.1" +version = "2.7.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "65981abb771e74e571a38196c3baa11c459379164791eba0e67abc1a5fac9884" +checksum = "b2d3c1dbe38037e7f590cdf2492594d5ceebe031e7bc7e827509b22a999d2940" dependencies = [ "log", "raw-window-handle", diff --git a/openless-all/app/src-tauri/Cargo.toml b/openless-all/app/src-tauri/Cargo.toml index f25b039aa..9bdb014d3 100644 --- a/openless-all/app/src-tauri/Cargo.toml +++ b/openless-all/app/src-tauri/Cargo.toml @@ -23,7 +23,7 @@ cc = "1.1" tauri = { version = "~2.11", features = ["macos-private-api"] } # 锁 >=2.3.5 修复 GHSA-c9pr-q8gx-3mgp:tauri-plugin-shell `open` 端点作用域校验绕过(CRITICAL,issue #668)。 tauri-plugin-shell = "2.3.5" -tauri-plugin-dialog = "2" +tauri-plugin-dialog = "2.7.2" serde = { version = "1", features = ["derive"] } serde_json = "1" # OpenRouter ASR 把音频以标准 base64(带 padding)放进 JSON body(issue #582)。 diff --git a/openless-all/app/src-tauri/src/android/jni.rs b/openless-all/app/src-tauri/src/android/jni.rs index 369ed79d7..ce04f5e4f 100644 --- a/openless-all/app/src-tauri/src/android/jni.rs +++ b/openless-all/app/src-tauri/src/android/jni.rs @@ -925,4 +925,36 @@ pub mod android { &[JValue::Object(context), JValue::Object(path_obj)], ) } + + /// Write `bytes` to a SAF `content://` URI via Kotlin ContentResolver. + pub fn write_content_uri(uri: &str, bytes: &[u8]) -> Result<(), String> { + with_android_env(|env, context| { + let class = load_context_class(env, context, "com.openless.app.OpenLessContentWriter")?; + let uri_obj = jobject_str(env, uri)?; + let bytes_array = env + .byte_array_from_slice(bytes) + .map_err(|error| format!("create byte array for content URI write: {error}"))?; + let bytes_obj = JObject::from(bytes_array); + let ok = env + .call_static_method( + class, + "writeBytes", + "(Landroid/content/Context;Ljava/lang/String;[B)Z", + &[ + JValue::Object(context), + JValue::Object(&uri_obj), + JValue::Object(&bytes_obj), + ], + ) + .and_then(|value| value.z()) + .map_err(|error| { + format!("call OpenLessContentWriter.writeBytes: {error}") + })?; + if ok { + Ok(()) + } else { + Err(format!("写入 content URI 失败:{uri}")) + } + }) + } } diff --git a/openless-all/app/src-tauri/src/commands/misc.rs b/openless-all/app/src-tauri/src/commands/misc.rs index 83506a6aa..e484f8c53 100644 --- a/openless-all/app/src-tauri/src/commands/misc.rs +++ b/openless-all/app/src-tauri/src/commands/misc.rs @@ -156,15 +156,53 @@ pub async fn stop_microphone_level_monitor(app: AppHandle) { /// 把当前会话的 openless.log 复制到用户选择的位置(前端用 plugin-dialog 拿 target_path)。 /// 路径来自 lib::log_dir_path() —— mac: ~/Library/Logs/OpenLess/openless.log, /// windows: %LOCALAPPDATA%\OpenLess\Logs\openless.log。 +/// +/// Android 上 dialog 返回 `content://` URI,不能用 `std::fs::copy`;走 JNI +/// ContentResolver 写入,避免 tauri-plugin-fs detachFd 导致 0 字节文件。 #[tauri::command] pub fn export_error_log(target_path: String) -> Result<(), String> { - let src = crate::log_dir_path().join("openless.log"); - if !src.exists() { - return Err(format!("日志文件不存在:{}", src.display())); + let src = resolve_openless_log_path()?; + + #[cfg(target_os = "android")] + { + if target_path.starts_with("content://") { + let bytes = std::fs::read(&src).map_err(|e| format!("读取日志失败:{e}"))?; + return crate::android::jni::android::write_content_uri(&target_path, &bytes) + .map_err(|e| format!("复制日志失败:{e}")); + } + let path = target_path + .strip_prefix("file://") + .unwrap_or(target_path.as_str()); + return std::fs::copy(&src, std::path::Path::new(path)) + .map(|_| ()) + .map_err(|e| format!("复制日志失败:{e}")); + } + + #[cfg(not(target_os = "android"))] + { + std::fs::copy(&src, std::path::Path::new(&target_path)) + .map(|_| ()) + .map_err(|e| format!("复制日志失败:{e}")) + } +} + +fn resolve_openless_log_path() -> Result { + let mut candidates = Vec::new(); + #[cfg(target_os = "android")] + { + candidates.extend(crate::persistence::android_openless_log_candidates()); + } + candidates.push(crate::log_dir_path().join("openless.log")); + + if let Some(src) = candidates.iter().find(|path| path.exists()) { + return Ok(src.clone()); } - std::fs::copy(&src, std::path::Path::new(&target_path)) - .map(|_| ()) - .map_err(|e| format!("复制日志失败:{}", e)) + let tried = candidates + .iter() + .map(|p| p.display().to_string()) + .collect::>() + .join(", "); + Err(format!("日志文件不存在(已尝试:{tried})")) } // ─────────────────────────── unused but exported (silences dead_code) ─────────────────────────── diff --git a/openless-all/app/src-tauri/src/coordinator.rs b/openless-all/app/src-tauri/src/coordinator.rs index 14102e09d..644a9e90a 100644 --- a/openless-all/app/src-tauri/src/coordinator.rs +++ b/openless-all/app/src-tauri/src/coordinator.rs @@ -628,23 +628,33 @@ impl Coordinator { #[cfg(not(target_os = "windows"))] { let history = HistoryStore::new().unwrap_or_else(|e| { - log::error!("[coord] HistoryStore init failed: {e}; 降级为空历史记录"); + log::error!( + "[coord] HistoryStore init failed: {e}; 降级为空历史记录 (Android 禁止 /data/local/tmp)" + ); HistoryStore::new_fallback() }); let prefs = PreferencesStore::new().unwrap_or_else(|e| { - log::error!("[coord] PreferencesStore init failed: {e}; 降级为默认偏好设置"); + log::error!( + "[coord] PreferencesStore init failed: {e}; 降级为默认偏好设置 (Android 禁止 /data/local/tmp)" + ); PreferencesStore::new_fallback() }); let style_packs = StylePackStore::new(&prefs).unwrap_or_else(|e| { - log::error!("[coord] StylePackStore init failed: {e}; 降级为空样式包列表"); + log::error!( + "[coord] StylePackStore init failed: {e}; 降级为空样式包列表 (Android 禁止 /data/local/tmp)" + ); StylePackStore::new_fallback() }); let vocab = DictionaryStore::new().unwrap_or_else(|e| { - log::error!("[coord] DictionaryStore init failed: {e}; 降级为空词库"); + log::error!( + "[coord] DictionaryStore init failed: {e}; 降级为空词库 (Android 禁止 /data/local/tmp)" + ); DictionaryStore::new_fallback() }); let correction_rules = CorrectionRuleStore::new().unwrap_or_else(|e| { - log::error!("[coord] CorrectionRuleStore init failed: {e}; 降级为空纠错规则"); + log::error!( + "[coord] CorrectionRuleStore init failed: {e}; 降级为空纠错规则 (Android 禁止 /data/local/tmp)" + ); CorrectionRuleStore::new_fallback() }); diff --git a/openless-all/app/src-tauri/src/lib.rs b/openless-all/app/src-tauri/src/lib.rs index 18f61a3f5..2ac89376b 100644 --- a/openless-all/app/src-tauri/src/lib.rs +++ b/openless-all/app/src-tauri/src/lib.rs @@ -1279,13 +1279,18 @@ fn reset_tcc_service_for_restart(service: &str, reason: &str) { } /// 把日志同时写到 stderr + ~/Library/Logs/OpenLess/openless.log(match Swift `Log.swift`)。 -fn init_file_logger() { +pub(crate) fn init_file_logger() { use simplelog::{ ColorChoice, CombinedLogger, ConfigBuilder, LevelFilter, TermLogger, TerminalMode, WriteLogger, }; let log_dir = log_dir_path(); - let _ = std::fs::create_dir_all(&log_dir); + if let Err(e) = std::fs::create_dir_all(&log_dir) { + eprintln!( + "[logger] WARN create log dir failed path={}: {e}", + log_dir.display() + ); + } let log_file = log_dir.join("openless.log"); if let Err(e) = rotate_log_if_too_large(&log_file) { eprintln!("[logger] WARN 日志轮转失败: {e}"); @@ -1297,12 +1302,21 @@ fn init_file_logger() { TerminalMode::Mixed, ColorChoice::Auto, )]; - if let Ok(file) = std::fs::OpenOptions::new() + match std::fs::OpenOptions::new() .create(true) .append(true) .open(&log_file) { - loggers.push(WriteLogger::new(LevelFilter::Info, config, file)); + Ok(file) => { + loggers.push(WriteLogger::new(LevelFilter::Info, config, file)); + eprintln!("[logger] file logger ready path={}", log_file.display()); + } + Err(e) => { + eprintln!( + "[logger] ERROR open log file failed path={}: {e}", + log_file.display() + ); + } } let _ = CombinedLogger::init(loggers); } @@ -1354,11 +1368,17 @@ pub fn log_dir_path() -> std::path::PathBuf { } #[cfg(target_os = "android")] { - if let Ok(dir) = std::env::var("TAURI_ANDROID_APP_DATA_DIR") { - return std::path::PathBuf::from(dir).join("logs"); + // Prefer cached JNI filesDir/logs; never use /data/local/tmp. + if let Ok(dir) = crate::persistence::android_log_dir() { + return dir; } + eprintln!("[logger] ERROR android_log_dir unavailable; file logging disabled"); + return std::path::PathBuf::from("/__openless_android_log_uninitialized__"); + } + #[cfg(not(target_os = "android"))] + { + std::env::temp_dir().join("OpenLess") } - std::env::temp_dir().join("OpenLess") } pub(crate) fn show_main_window(app: &AppHandle) { diff --git a/openless-all/app/src-tauri/src/mobile_runtime.rs b/openless-all/app/src-tauri/src/mobile_runtime.rs index 178b7614a..165bef180 100644 --- a/openless-all/app/src-tauri/src/mobile_runtime.rs +++ b/openless-all/app/src-tauri/src/mobile_runtime.rs @@ -7,17 +7,24 @@ use tauri::{AppHandle, Manager, RunEvent}; use crate::coordinator::Coordinator; pub fn run() { - let coordinator = Arc::new(Coordinator::new()); - let builder = tauri::Builder::default() .plugin(tauri_plugin_shell::init()) .plugin(tauri_plugin_dialog::init()); #[cfg(any(target_os = "android", target_os = "ios"))] let builder = builder.plugin(tauri_plugin_fs::init()); + // Coordinator is created inside setup (after Android storage roots are ready). + // Managing state in setup is supported by Tauri 2 and avoids constructing + // PreferencesStore against /data/local/tmp before JNI Context exists. builder - .manage(coordinator.clone()) - .setup(move |app| { + .setup(|app| { + #[cfg(target_os = "android")] + { + if let Err(error) = crate::persistence::init_android_storage_roots() { + eprintln!("[android-storage] ERROR init failed: {error:#}"); + } + } + crate::init_file_logger(); log::info!("=== OpenLess mobile 启动 ==="); initialize_android_ndk_context_for_audio(); @@ -29,6 +36,8 @@ pub fn run() { let _ = qa.hide(); } + let coordinator = Arc::new(Coordinator::new()); + app.manage(coordinator.clone()); coordinator.bind_app(app.handle().clone()); #[cfg(target_os = "android")] { @@ -42,8 +51,9 @@ pub fn run() { .expect("error while building tauri mobile application") .run(|app, event| match event { RunEvent::Exit => { - let coordinator = app.state::>(); - coordinator.stop_hotkey_listener(); + if let Some(coordinator) = app.try_state::>() { + coordinator.stop_hotkey_listener(); + } } _ => {} }); diff --git a/openless-all/app/src-tauri/src/persistence/android_storage.rs b/openless-all/app/src-tauri/src/persistence/android_storage.rs new file mode 100644 index 000000000..480173e73 --- /dev/null +++ b/openless-all/app/src-tauri/src/persistence/android_storage.rs @@ -0,0 +1,198 @@ +//! Android app-private storage roots. +//! +//! Honor / Huawei devices reject writes under `/data/local/tmp`. Persistence and +//! file logging must use `Context.getFilesDir()` (or Tauri's +//! `TAURI_ANDROID_APP_DATA_DIR`) and never fall back to `std::env::temp_dir()`. + +use std::path::{Path, PathBuf}; +use std::sync::OnceLock; +use std::thread; +use std::time::Duration; + +use anyhow::{bail, Context, Result}; + +static ANDROID_APP_FILES_DIR: OnceLock = OnceLock::new(); +static ANDROID_DATA_DIR: OnceLock = OnceLock::new(); +static ANDROID_LOG_DIR: OnceLock = OnceLock::new(); + +const INIT_ATTEMPTS: usize = 5; +const INIT_RETRY_DELAY: Duration = Duration::from_millis(40); + +/// Pure resolution used by runtime and unit tests. Prefers JNI `filesDir`, then env. +/// Never returns a temp-dir path. +pub(crate) fn resolve_android_app_files_dir( + jni_path: Option<&str>, + env_path: Option<&str>, +) -> Result { + if let Some(path) = jni_path.map(str::trim).filter(|p| !p.is_empty()) { + return Ok(PathBuf::from(path)); + } + if let Some(path) = env_path.map(str::trim).filter(|p| !p.is_empty()) { + return Ok(PathBuf::from(path)); + } + bail!("Android app files dir unavailable (JNI getFilesDir + TAURI_ANDROID_APP_DATA_DIR)") +} + +#[cfg(target_os = "android")] +fn probe_jni_files_dir() -> Option { + crate::android::jni::android::app_files_dir().ok() +} + +#[cfg(not(target_os = "android"))] +fn probe_jni_files_dir() -> Option { + None +} + +fn env_app_data_dir() -> Option { + std::env::var("TAURI_ANDROID_APP_DATA_DIR").ok() +} + +/// Resolve and cache `filesDir`, `{filesDir}/OpenLess`, and `{filesDir}/logs`. +/// Safe to call multiple times; subsequent calls are no-ops once initialized. +pub fn init_android_storage_roots() -> Result<()> { + if ANDROID_DATA_DIR.get().is_some() { + return Ok(()); + } + + let mut last_err = None; + let mut files_dir = None; + for attempt in 0..INIT_ATTEMPTS { + match resolve_android_app_files_dir( + probe_jni_files_dir().as_deref(), + env_app_data_dir().as_deref(), + ) { + Ok(path) => { + files_dir = Some(path); + break; + } + Err(error) => { + last_err = Some(error); + if attempt + 1 < INIT_ATTEMPTS { + thread::sleep(INIT_RETRY_DELAY); + } + } + } + } + + let files_dir = files_dir.ok_or_else(|| { + last_err.unwrap_or_else(|| { + anyhow::anyhow!("Android app files dir unavailable after {INIT_ATTEMPTS} attempts") + }) + })?; + + let data_dir = files_dir.join("OpenLess"); + let log_dir = files_dir.join("logs"); + std::fs::create_dir_all(&data_dir) + .with_context(|| format!("create Android data dir {}", data_dir.display()))?; + std::fs::create_dir_all(&log_dir) + .with_context(|| format!("create Android log dir {}", log_dir.display()))?; + + let _ = ANDROID_APP_FILES_DIR.set(files_dir.clone()); + let _ = ANDROID_DATA_DIR.set(data_dir.clone()); + let _ = ANDROID_LOG_DIR.set(log_dir.clone()); + + eprintln!( + "[android-storage] roots ready filesDir={} dataDir={} logDir={}", + files_dir.display(), + data_dir.display(), + log_dir.display() + ); + Ok(()) +} + +pub(crate) fn android_app_files_dir() -> Result { + if let Some(dir) = ANDROID_APP_FILES_DIR.get() { + return Ok(dir.clone()); + } + let path = resolve_android_app_files_dir( + probe_jni_files_dir().as_deref(), + env_app_data_dir().as_deref(), + )?; + let _ = ANDROID_APP_FILES_DIR.set(path.clone()); + Ok(path) +} + +pub(crate) fn android_data_dir() -> Result { + if let Some(dir) = ANDROID_DATA_DIR.get() { + return Ok(dir.clone()); + } + let files = android_app_files_dir()?; + let data = files.join("OpenLess"); + std::fs::create_dir_all(&data) + .with_context(|| format!("create Android data dir {}", data.display()))?; + let _ = ANDROID_DATA_DIR.set(data.clone()); + Ok(data) +} + +pub(crate) fn android_log_dir() -> Result { + if let Some(dir) = ANDROID_LOG_DIR.get() { + return Ok(dir.clone()); + } + let files = android_app_files_dir()?; + let logs = files.join("logs"); + std::fs::create_dir_all(&logs) + .with_context(|| format!("create Android log dir {}", logs.display()))?; + let _ = ANDROID_LOG_DIR.set(logs.clone()); + Ok(logs) +} + +/// Candidate paths for `openless.log` (export + ADB dump). +pub(crate) fn android_openless_log_candidates() -> Vec { + let mut candidates = Vec::new(); + if let Ok(files) = android_app_files_dir() { + candidates.push(files.join("logs").join("openless.log")); + candidates.push(files.join("openless.log")); + if let Some(parent) = files.parent() { + candidates.push(parent.join("logs").join("openless.log")); + } + } + if let Ok(logs) = android_log_dir() { + let path = logs.join("openless.log"); + if !candidates.iter().any(|c| c == &path) { + candidates.push(path); + } + } + candidates +} + +pub(crate) fn is_memory_only_path(path: &Path) -> bool { + path.as_os_str().is_empty() +} + +#[cfg(test)] +mod tests { + use super::resolve_android_app_files_dir; + use std::path::PathBuf; + + #[test] + fn prefers_jni_files_dir_over_env() { + let path = resolve_android_app_files_dir( + Some("/data/user/0/com.openless.app/files"), + Some("/data/local/tmp/wrong"), + ) + .expect("resolve"); + assert_eq!( + path, + PathBuf::from("/data/user/0/com.openless.app/files") + ); + } + + #[test] + fn falls_back_to_env_when_jni_missing() { + let path = + resolve_android_app_files_dir(None, Some("/data/user/0/com.openless.app/files")) + .expect("resolve"); + assert_eq!( + path, + PathBuf::from("/data/user/0/com.openless.app/files") + ); + } + + #[test] + fn rejects_empty_sources_without_temp_fallback() { + let err = resolve_android_app_files_dir(Some(""), Some(" ")).unwrap_err(); + let msg = err.to_string(); + assert!(msg.contains("unavailable"), "{msg}"); + assert!(!msg.to_ascii_lowercase().contains("temp"), "{msg}"); + } +} diff --git a/openless-all/app/src-tauri/src/persistence/correction.rs b/openless-all/app/src-tauri/src/persistence/correction.rs index 16a9e8060..b1f629b95 100644 --- a/openless-all/app/src-tauri/src/persistence/correction.rs +++ b/openless-all/app/src-tauri/src/persistence/correction.rs @@ -29,10 +29,10 @@ impl CorrectionRuleStore { }) } - /// 降级实例:data_dir 不可用时使用临时路径,读写会安静地失败或返回空。 + /// 降级实例:data_dir 不可用时使用临时路径(桌面)或空 path(Android 内存态)。 pub(crate) fn new_fallback() -> Self { Self { - path: std::env::temp_dir().join("openless_correction_rules_fallback.json"), + path: super::fallback_store_path("openless_correction_rules_fallback.json"), lock: Mutex::new(()), } } diff --git a/openless-all/app/src-tauri/src/persistence/dictionary.rs b/openless-all/app/src-tauri/src/persistence/dictionary.rs index 2cec8a23b..05af1570f 100644 --- a/openless-all/app/src-tauri/src/persistence/dictionary.rs +++ b/openless-all/app/src-tauri/src/persistence/dictionary.rs @@ -32,10 +32,10 @@ impl DictionaryStore { }) } - /// 降级实例:data_dir 不可用时使用临时路径,读写会安静地失败或返回空。 + /// 降级实例:data_dir 不可用时使用临时路径(桌面)或空 path(Android 内存态)。 pub(crate) fn new_fallback() -> Self { Self { - path: std::env::temp_dir().join("openless_vocab_fallback.json"), + path: super::fallback_store_path("openless_vocab_fallback.json"), lock: Mutex::new(()), } } diff --git a/openless-all/app/src-tauri/src/persistence/history.rs b/openless-all/app/src-tauri/src/persistence/history.rs index 0e50792fe..8c6c30c02 100644 --- a/openless-all/app/src-tauri/src/persistence/history.rs +++ b/openless-all/app/src-tauri/src/persistence/history.rs @@ -26,12 +26,11 @@ impl HistoryStore { }) } - /// 在 data_dir 不可用时构造一个降级实例(指向临时目录)。 - /// 该实例在运行期间读写会安静地失败或返回空,不会 panic, - /// 也不会影响正常启动路径。 + /// 在 data_dir 不可用时构造一个降级实例。 + /// Android 使用空 path(内存态),禁止落 `/data/local/tmp`。 pub(crate) fn new_fallback() -> Self { Self { - path: std::env::temp_dir().join("openless_history_fallback.json"), + path: super::fallback_store_path("openless_history_fallback.json"), lock: Mutex::new(()), } } diff --git a/openless-all/app/src-tauri/src/persistence/mod.rs b/openless-all/app/src-tauri/src/persistence/mod.rs index 833e471f3..f758bfb7e 100644 --- a/openless-all/app/src-tauri/src/persistence/mod.rs +++ b/openless-all/app/src-tauri/src/persistence/mod.rs @@ -6,6 +6,7 @@ //! - macOS: `~/Library/Application Support/OpenLess` //! - Windows: `%APPDATA%\OpenLess` //! - Linux: `$XDG_DATA_HOME/OpenLess` or `~/.local/share/OpenLess` +//! - Android: `{Context.getFilesDir()}/OpenLess` (never `/data/local/tmp`) //! //! Credential storage policy: provider credentials are stored in the OS //! credential vault (macOS Keychain, Windows Credential Manager, Linux keyring). @@ -20,13 +21,15 @@ use std::fs; use std::path::{Path, PathBuf}; -use anyhow::{Context, Result}; +use anyhow::{bail, Context, Result}; use serde::Deserialize; use uuid::Uuid; mod activity; #[cfg(any(target_os = "android", test))] mod android_credentials; +#[cfg(any(target_os = "android", test))] +mod android_storage; mod correction; mod credentials; mod dictionary; @@ -46,6 +49,13 @@ pub use preferences::*; pub use style_pack::*; pub(crate) use style_pack_archive::STYLE_PACK_ARCHIVE_MAX_COMPRESSED_BYTES; +#[cfg(target_os = "android")] +pub use android_storage::init_android_storage_roots; +#[cfg(target_os = "android")] +pub(crate) use android_storage::{android_log_dir, android_openless_log_candidates}; +#[cfg(any(target_os = "android", test))] +use android_storage::is_memory_only_path; + const HISTORY_CAP: usize = 200; const PREFERENCES_FILE: &str = "preferences.json"; @@ -81,10 +91,22 @@ fn data_dir() -> Result { #[cfg(target_os = "android")] { - if let Ok(dir) = std::env::var("TAURI_ANDROID_APP_DATA_DIR") { - return Ok(PathBuf::from(dir).join("OpenLess")); - } - Ok(std::env::temp_dir().join("OpenLess")) + // Never use std::env::temp_dir() (/data/local/tmp) — apps cannot write there. + android_storage::android_data_dir() + } +} + +/// Fallback store path when `data_dir()` is unavailable. +/// Android: empty path → memory-only (writes refused); never `/data/local/tmp`. +fn fallback_store_path(file_name: &str) -> PathBuf { + #[cfg(target_os = "android")] + { + let _ = file_name; + PathBuf::new() + } + #[cfg(not(target_os = "android"))] + { + std::env::temp_dir().join(file_name) } } @@ -97,6 +119,14 @@ fn ensure_dir(dir: &Path) -> Result<()> { /// target path. The unique suffix lets concurrent writers each own their own /// tmp file, so a parallel rename never finds its source already taken. fn atomic_write(path: &Path, contents: &[u8]) -> Result<()> { + #[cfg(any(target_os = "android", test))] + if is_memory_only_path(path) { + bail!("atomic write refused: empty path (memory-only store; Android data_dir unavailable)"); + } + #[cfg(not(any(target_os = "android", test)))] + if path.as_os_str().is_empty() { + bail!("atomic write refused: empty path (memory-only store)"); + } if let Some(parent) = path.parent() { ensure_dir(parent)?; } diff --git a/openless-all/app/src-tauri/src/persistence/preferences.rs b/openless-all/app/src-tauri/src/persistence/preferences.rs index 33485277e..8b2f30795 100644 --- a/openless-all/app/src-tauri/src/persistence/preferences.rs +++ b/openless-all/app/src-tauri/src/persistence/preferences.rs @@ -128,10 +128,11 @@ impl PreferencesStore { }) } - /// 降级实例:data_dir 不可用时使用默认配置,写操作会安静地失败。 + /// 降级实例:data_dir 不可用时使用默认配置。 + /// Android 使用空 path(内存态,写盘明确失败),禁止落 `/data/local/tmp`。 pub(crate) fn new_fallback() -> Self { Self { - path: std::env::temp_dir().join("openless_prefs_fallback.json"), + path: super::fallback_store_path("openless_prefs_fallback.json"), state: Mutex::new(UserPreferences::default()), } } diff --git a/openless-all/app/src-tauri/src/persistence/style_pack.rs b/openless-all/app/src-tauri/src/persistence/style_pack.rs index 4e4e281bf..a914848e9 100644 --- a/openless-all/app/src-tauri/src/persistence/style_pack.rs +++ b/openless-all/app/src-tauri/src/persistence/style_pack.rs @@ -82,12 +82,12 @@ impl StylePackStore { }) } - /// 降级实例:data_dir 不可用时使用临时路径和空列表,写操作会安静地失败。 + /// 降级实例:data_dir 不可用时使用空列表。 + /// Android 使用空 path(内存态),禁止落 `/data/local/tmp`。 pub(crate) fn new_fallback() -> Self { - let tmp = std::env::temp_dir(); Self { - path: tmp.join("openless_style_packs_fallback.json"), - asset_root: tmp.join("openless_style_pack_assets_fallback"), + path: super::fallback_store_path("openless_style_packs_fallback.json"), + asset_root: super::fallback_store_path("openless_style_pack_assets_fallback"), state: Mutex::new(Vec::new()), } } diff --git a/openless-all/app/src/lib/ipc/utils.ts b/openless-all/app/src/lib/ipc/utils.ts index 233d17336..ffc084342 100644 --- a/openless-all/app/src/lib/ipc/utils.ts +++ b/openless-all/app/src/lib/ipc/utils.ts @@ -27,6 +27,9 @@ export async function openExternal(url: string): Promise { /** * 让用户选 save 路径并把当前会话日志(openless.log)复制过去。 * 浏览器开发模式下走 mock 不实际写盘。返回最终 save 的绝对路径,取消选择则返回 null。 + * + * Android:省略 filters——部分 ROM 上 CREATE_DOCUMENT + EXTRA_MIME_TYPES 不稳定; + * 文件名已带 .log,足够标识类型。 */ export async function exportErrorLog( suggestedFileName: string, @@ -35,10 +38,16 @@ export async function exportErrorLog( return `~/Downloads/${suggestedFileName}` } const { save } = await import("@tauri-apps/plugin-dialog") - const target = await save({ - defaultPath: suggestedFileName, - filters: [{ name: "Log", extensions: ["log", "txt"] }], - }) + const isAndroid = + typeof navigator !== "undefined" && /Android/i.test(navigator.userAgent || "") + const target = await save( + isAndroid + ? { defaultPath: suggestedFileName } + : { + defaultPath: suggestedFileName, + filters: [{ name: "Log", extensions: ["log", "txt"] }], + }, + ) if (!target) return null await invokeOrMock( "export_error_log", diff --git a/openless-all/app/src/pages/settings/DebugToolsSection.tsx b/openless-all/app/src/pages/settings/DebugToolsSection.tsx index c7bbc4203..0bd32f9dd 100644 --- a/openless-all/app/src/pages/settings/DebugToolsSection.tsx +++ b/openless-all/app/src/pages/settings/DebugToolsSection.tsx @@ -97,10 +97,11 @@ export function DebugToolsSection() { )} {exportStatus === 'err' && ( {t('modal.about.exportFailed')} + {exportMessage ? `:${exportMessage}` : ''} )} From 10b3c024d465305f7da736506ecbb3ce94c6dba9 Mon Sep 17 00:00:00 2001 From: HKLHaoBin Date: Sat, 1 Aug 2026 22:07:38 +0800 Subject: [PATCH 2/2] fix(android): guard StylePack empty asset_root and Android-only coord logs Reject persisting style pack icons when asset_root is empty to avoid writing under cwd; no-op cleanup on empty root. Limit Coordinator persistence degrade log suffix to Android only. Add unit test. Co-authored-by: Cursor --- openless-all/app/src-tauri/src/coordinator.rs | 15 +++++--- .../src-tauri/src/persistence/style_pack.rs | 3 ++ .../src/persistence/style_pack_archive.rs | 35 +++++++++++++++++++ 3 files changed, 48 insertions(+), 5 deletions(-) diff --git a/openless-all/app/src-tauri/src/coordinator.rs b/openless-all/app/src-tauri/src/coordinator.rs index 644a9e90a..17416c9a8 100644 --- a/openless-all/app/src-tauri/src/coordinator.rs +++ b/openless-all/app/src-tauri/src/coordinator.rs @@ -627,33 +627,38 @@ impl Coordinator { #[cfg(not(target_os = "windows"))] { + #[cfg(target_os = "android")] + const PERSIST_DEGRADE_SUFFIX: &str = " (Android 禁止 /data/local/tmp)"; + #[cfg(not(target_os = "android"))] + const PERSIST_DEGRADE_SUFFIX: &str = ""; + let history = HistoryStore::new().unwrap_or_else(|e| { log::error!( - "[coord] HistoryStore init failed: {e}; 降级为空历史记录 (Android 禁止 /data/local/tmp)" + "[coord] HistoryStore init failed: {e}; 降级为空历史记录{PERSIST_DEGRADE_SUFFIX}" ); HistoryStore::new_fallback() }); let prefs = PreferencesStore::new().unwrap_or_else(|e| { log::error!( - "[coord] PreferencesStore init failed: {e}; 降级为默认偏好设置 (Android 禁止 /data/local/tmp)" + "[coord] PreferencesStore init failed: {e}; 降级为默认偏好设置{PERSIST_DEGRADE_SUFFIX}" ); PreferencesStore::new_fallback() }); let style_packs = StylePackStore::new(&prefs).unwrap_or_else(|e| { log::error!( - "[coord] StylePackStore init failed: {e}; 降级为空样式包列表 (Android 禁止 /data/local/tmp)" + "[coord] StylePackStore init failed: {e}; 降级为空样式包列表{PERSIST_DEGRADE_SUFFIX}" ); StylePackStore::new_fallback() }); let vocab = DictionaryStore::new().unwrap_or_else(|e| { log::error!( - "[coord] DictionaryStore init failed: {e}; 降级为空词库 (Android 禁止 /data/local/tmp)" + "[coord] DictionaryStore init failed: {e}; 降级为空词库{PERSIST_DEGRADE_SUFFIX}" ); DictionaryStore::new_fallback() }); let correction_rules = CorrectionRuleStore::new().unwrap_or_else(|e| { log::error!( - "[coord] CorrectionRuleStore init failed: {e}; 降级为空纠错规则 (Android 禁止 /data/local/tmp)" + "[coord] CorrectionRuleStore init failed: {e}; 降级为空纠错规则{PERSIST_DEGRADE_SUFFIX}" ); CorrectionRuleStore::new_fallback() }); diff --git a/openless-all/app/src-tauri/src/persistence/style_pack.rs b/openless-all/app/src-tauri/src/persistence/style_pack.rs index a914848e9..480e8a5ae 100644 --- a/openless-all/app/src-tauri/src/persistence/style_pack.rs +++ b/openless-all/app/src-tauri/src/persistence/style_pack.rs @@ -781,6 +781,9 @@ fn sanitize_style_pack_id(requested_id: &str) -> String { } fn remove_style_pack_assets(asset_root: &Path, pack: &StylePack) { + if asset_root.as_os_str().is_empty() { + return; + } if let Some(icon_path) = pack.icon_path.as_deref() { let path = Path::new(icon_path); let _ = fs::remove_file(path); diff --git a/openless-all/app/src-tauri/src/persistence/style_pack_archive.rs b/openless-all/app/src-tauri/src/persistence/style_pack_archive.rs index 2fdbcc0c7..efacccae1 100644 --- a/openless-all/app/src-tauri/src/persistence/style_pack_archive.rs +++ b/openless-all/app/src-tauri/src/persistence/style_pack_archive.rs @@ -778,6 +778,9 @@ pub(super) fn persist_style_pack_icon( pack_id: &str, icon: StylePackIcon, ) -> Result { + if asset_root.as_os_str().is_empty() { + bail!("style pack asset root unavailable (memory-only store)"); + } let target_dir = asset_root.join(pack_id); ensure_dir(&target_dir)?; let target_path = target_dir.join(format!("icon.{}", icon.extension)); @@ -790,6 +793,9 @@ pub(super) fn persist_style_pack_icon( } pub(super) fn cleanup_style_pack_asset_dir(asset_root: &Path, pack_id: &str) { + if asset_root.as_os_str().is_empty() { + return; + } let _ = fs::remove_dir_all(asset_root.join(pack_id)); } @@ -859,4 +865,33 @@ mod tests { png.extend_from_slice(data); png.extend_from_slice(&[0; 4]); } + + #[test] + fn persist_style_pack_icon_rejects_empty_asset_root() { + use std::fs; + use std::path::PathBuf; + + use super::persist_style_pack_icon; + use super::StylePackIcon; + + let pack_id = "fallback-pack-id"; + let cwd = std::env::current_dir().expect("cwd"); + let relative_dir = cwd.join(pack_id); + let _ = fs::remove_dir_all(&relative_dir); + + let icon = StylePackIcon { + extension: "png".to_string(), + bytes: vec![0x89, 0x50, 0x4e, 0x47], + }; + let err = persist_style_pack_icon(&PathBuf::new(), pack_id, icon).unwrap_err(); + assert!( + format!("{err:#}").contains("memory-only store"), + "{err:#}" + ); + assert!( + !relative_dir.exists(), + "empty asset_root must not create {} under cwd", + relative_dir.display() + ); + } }