fix: audit batch 2 — correctness bugs + Android robustness (#696)

7 medium findings: corrupt-timestamp context truncation, voice_agent flag timing, Volcengine/Bailian ASR cancel-on-error, graceful store-init fallback (no startup panic), Android OVERLAY_VISIBLE desync (Rust+Kotlin onDestroy), JNI null-check, accessibility clipboard restore. All-platform CI green; 446 tests pass.
This commit is contained in:
TRIP authored and GitHub committed 2026-06-17 11:39:55 +08:00
1 parent 121bb11447
commit 5aed1e46ed
9 files changed
+231 -21

No files matched your search

@@ -39,4 +39,6 @@ object OpenLessNative {
@JvmStatic external fun nativeIsOverlayVisible(): Boolean
@JvmStatic external fun nativeNotifyOverlayPermissionChanged(context: android.content.Context)
@JvmStatic external fun nativeNotifyOverlayDestroyed()
}
@@ -93,6 +93,8 @@ class OpenLessOverlayService : Service(), OpenLessOverlayBridge.OverlayStateList
if (instance === this) {
instance = null
}
// 系统杀死前台服务时也会走到这里:同步原生 OVERLAY_VISIBLE=false,避免状态永久残留为 true。
runCatching { OpenLessNative.nativeNotifyOverlayDestroyed() }
super.onDestroy()
}
@@ -29,15 +29,38 @@ fn try_accessibility(inserter: &TextInserter, text: &str) -> Option<InsertStatus
log::info!("[android-insert] accessibility service not enabled");
return None;
}
// 保存粘贴前的剪贴板内容,粘贴完成后还原,避免静默覆盖用户剪贴板。
let previous_clip: Option<String> =
crate::android::jni::android::with_android_env(|env, context| {
Ok(crate::android::jni::android::get_primary_clip_text(env, context))
})
.ok()
.flatten();
if !matches!(inserter.copy_fallback(text), InsertStatus::CopiedFallback) {
return None;
}
if crate::android::accessibility::paste_via_accessibility() {
let result = if crate::android::accessibility::paste_via_accessibility() {
Some(InsertStatus::Inserted)
} else {
log::warn!("[android-insert] accessibility paste failed; text remains on clipboard");
Some(InsertStatus::CopiedFallback)
};
// 还原用户原有剪贴板内容(仅当粘贴成功时还原;失败时用户需要自己处理)。
if matches!(result, Some(InsertStatus::Inserted)) {
if let Some(prev) = previous_clip {
if let Err(e) =
crate::android::jni::android::with_android_env(|env, context| {
crate::android::jni::android::set_primary_clip_text(env, context, &prev)
})
{
log::warn!("[android-insert] failed to restore clipboard: {e}");
}
}
}
result
}
fn clipboard_fallback(inserter: &TextInserter, text: &str) -> InsertStatus {
+78 -1
View File
@@ -17,7 +17,13 @@ pub mod android {
let mut env = vm
.attach_current_thread()
.map_err(|error| format!("attach Android thread: {error}"))?;
let context = unsafe { JObject::from_raw(android_context.context() as jni::sys::jobject) };
let raw_context = android_context.context() as jni::sys::jobject;
if raw_context.is_null() {
return Err("Android context not yet initialized".to_string());
}
// SAFETY: raw_context is non-null and points to a valid Android Context object
// provided by tao/Tauri; the reference lifetime is valid for the duration of `f`.
let context = unsafe { JObject::from_raw(raw_context) };
f(&mut env, &context)
}
@@ -313,6 +319,77 @@ pub mod android {
.map_err(|error| format!("read SDK_INT: {error}"))
}
/// 读取剪贴板当前的第一条纯文本内容,用于在粘贴后还原。
/// 失败或剪贴板为空时返回 None(不返回错误,避免阻塞主流程)。
pub fn get_primary_clip_text(
env: &mut JNIEnv,
context: &JObject,
) -> Option<String> {
let clipboard_name = jobject_str(env, "clipboard").ok()?;
let clipboard = env
.call_method(
context,
"getSystemService",
"(Ljava/lang/String;)Ljava/lang/Object;",
&[JValue::Object(&clipboard_name)],
)
.and_then(|value| value.l())
.ok()?;
let clip = env
.call_method(
&clipboard,
"getPrimaryClip",
"()Landroid/content/ClipData;",
&[],
)
.and_then(|value| value.l())
.ok()?;
if clip.is_null() {
return None;
}
let item = env
.call_method(
&clip,
"getItemAt",
"(I)Landroid/content/ClipData$Item;",
&[JValue::Int(0)],
)
.and_then(|value| value.l())
.ok()?;
if item.is_null() {
return None;
}
let text_val = env
.call_method(
&item,
"getText",
"()Ljava/lang/CharSequence;",
&[],
)
.and_then(|value| value.l())
.ok()?;
if text_val.is_null() {
return None;
}
let text_str = env
.call_method(&text_val, "toString", "()Ljava/lang/String;", &[])
.and_then(|value| value.l())
.ok()?;
let jstr = JString::from(text_str);
env.get_string(&jstr)
.map(|s| s.to_string_lossy().into_owned())
.ok()
}
/// 将指定文本写回剪贴板,用于 accessibility 粘贴后还原用户原有内容。
pub fn set_primary_clip_text(
env: &mut JNIEnv,
context: &JObject,
text: &str,
) -> Result<(), String> {
copy_to_clipboard(env, context, text).map(|_| ())
}
pub fn copy_to_clipboard(
env: &mut JNIEnv,
context: &JObject,
@@ -128,6 +128,14 @@ pub fn is_overlay_visible() -> bool {
OVERLAY_VISIBLE.load(std::sync::atomic::Ordering::SeqCst)
}
/// Kotlin overlay service 的 onDestroy() 调用此函数,以便在 OS 杀死服务时
/// 同步清除 OVERLAY_VISIBLE 标志,避免 refresh_overlay_if_visible() 向死亡
/// 服务发送无效命令。
pub fn notify_overlay_destroyed() {
OVERLAY_VISIBLE.store(false, std::sync::atomic::Ordering::SeqCst);
log::info!("[android-native] overlay service destroyed — OVERLAY_VISIBLE reset");
}
pub fn overlay_trigger_mode_name() -> &'static str {
let Some(coordinator) = COORDINATOR.get() else {
return "background";
@@ -393,4 +401,14 @@ mod jni_exports {
});
}
}
/// 供 Kotlin overlay service 的 onDestroy() 调用,将 OVERLAY_VISIBLE 清除。
/// 解决 OS 杀死服务时 Rust 端状态永久失同步的问题。
#[no_mangle]
pub unsafe extern "system" fn Java_com_openless_app_OpenLessNative_nativeNotifyOverlayDestroyed(
_env: *mut JNIEnv,
_class: JClass,
) {
notify_overlay_destroyed();
}
}
+38 -14
View File
@@ -91,8 +91,8 @@ pub(super) fn qa_event_target() -> &'static str {
#[cfg(test)]
use dictation::dictation_error_code;
use dictation::{
begin_session, cancel_session, end_session, handle_pressed_edge, handle_released_edge,
request_stop_during_starting,
begin_session, begin_session_as, cancel_session, end_session, handle_pressed_edge,
handle_released_edge, request_stop_during_starting,
};
#[cfg(any(debug_assertions, test))]
use dictation::{handle_pressed, handle_released};
@@ -362,13 +362,25 @@ impl Coordinator {
#[cfg(not(target_os = "windows"))]
{
let history = HistoryStore::new().unwrap_or_else(|e| {
log::error!("[coord] HistoryStore init failed: {e}; falling back to empty");
HistoryStore::new().expect("history store init")
log::error!("[coord] HistoryStore init failed: {e}; 降级为空历史记录");
HistoryStore::new_fallback()
});
let prefs = PreferencesStore::new().unwrap_or_else(|e| {
log::error!("[coord] PreferencesStore init failed: {e}; 降级为默认偏好设置");
PreferencesStore::new_fallback()
});
let style_packs = StylePackStore::new(&prefs).unwrap_or_else(|e| {
log::error!("[coord] StylePackStore init failed: {e}; 降级为空样式包列表");
StylePackStore::new_fallback()
});
let vocab = DictionaryStore::new().unwrap_or_else(|e| {
log::error!("[coord] DictionaryStore init failed: {e}; 降级为空词库");
DictionaryStore::new_fallback()
});
let correction_rules = CorrectionRuleStore::new().unwrap_or_else(|e| {
log::error!("[coord] CorrectionRuleStore init failed: {e}; 降级为空纠错规则");
CorrectionRuleStore::new_fallback()
});
let prefs = PreferencesStore::new().expect("preferences store init");
let style_packs = StylePackStore::new(&prefs).expect("style pack store init");
let vocab = DictionaryStore::new().expect("dictionary store init");
let correction_rules = CorrectionRuleStore::new().expect("correction rule store init");
Self {
inner: Arc::new(Inner {
@@ -440,13 +452,25 @@ impl Coordinator {
sherpa_onnx_runtime: Arc<SherpaOnnxRuntime>,
) -> Self {
let history = HistoryStore::new().unwrap_or_else(|e| {
log::error!("[coord] HistoryStore init failed: {e}; falling back to empty");
HistoryStore::new().expect("history store init")
log::error!("[coord] HistoryStore init failed: {e}; 降级为空历史记录");
HistoryStore::new_fallback()
});
let prefs = PreferencesStore::new().unwrap_or_else(|e| {
log::error!("[coord] PreferencesStore init failed: {e}; 降级为默认偏好设置");
PreferencesStore::new_fallback()
});
let style_packs = StylePackStore::new(&prefs).unwrap_or_else(|e| {
log::error!("[coord] StylePackStore init failed: {e}; 降级为空样式包列表");
StylePackStore::new_fallback()
});
let vocab = DictionaryStore::new().unwrap_or_else(|e| {
log::error!("[coord] DictionaryStore init failed: {e}; 降级为空词库");
DictionaryStore::new_fallback()
});
let correction_rules = CorrectionRuleStore::new().unwrap_or_else(|e| {
log::error!("[coord] CorrectionRuleStore init failed: {e}; 降级为空纠错规则");
CorrectionRuleStore::new_fallback()
});
let prefs = PreferencesStore::new().expect("preferences store init");
let style_packs = StylePackStore::new(&prefs).expect("style pack store init");
let vocab = DictionaryStore::new().expect("dictionary store init");
let correction_rules = CorrectionRuleStore::new().expect("correction rule store init");
Self {
inner: Arc::new(Inner {
@@ -999,6 +999,15 @@ pub(super) fn request_stop_during_starting(inner: &Arc<Inner>, reason: &str) {
}
pub(super) async fn begin_session(inner: &Arc<Inner>) -> Result<(), String> {
begin_session_as(inner, false).await
}
/// begin_session 的带参版本,voice_agent=true 时在 Starting 阶段就标记好,
/// 防止 finish_starting_session 处理 pending_stop 时丢失标志。
pub(super) async fn begin_session_as(
inner: &Arc<Inner>,
voice_agent: bool,
) -> Result<(), String> {
let current_session_id = {
let mut state = inner.state.lock();
let Some(session_id) =
@@ -1006,6 +1015,9 @@ pub(super) async fn begin_session(inner: &Arc<Inner>) -> Result<(), String> {
else {
return Ok(());
};
if voice_agent {
state.voice_agent = true;
}
if let Some(label) = state.front_app.as_deref() {
log::info!("[coord] front_app captured: {label}");
}
@@ -1635,6 +1647,8 @@ pub(super) async fn end_session(inner: &Arc<Inner>) -> Result<(), String> {
Ok(Ok(r)) => r,
Ok(Err(e)) => {
log::error!("[coord] await final failed: {e}");
// 关闭 WebSocket 连接,避免流式 ASR 资源泄漏
asr.cancel();
emit_capsule(
inner,
CapsuleState::Error,
@@ -1762,6 +1776,8 @@ pub(super) async fn end_session(inner: &Arc<Inner>) -> Result<(), String> {
Ok(Ok(r)) => r,
Ok(Err(e)) => {
log::error!("[coord] Bailian await final failed: {e}");
// 关闭 WebSocket 连接,避免流式 ASR 资源泄漏
asr.cancel();
emit_capsule(
inner,
CapsuleState::Error,
@@ -403,16 +403,18 @@ pub(super) async fn handle_less_computer_pressed(inner: &Arc<Inner>) {
return;
}
if begin_session(inner).await.is_err() {
// voice_agent=true 在 Starting 阶段就写入 state,防止 finish_starting_session
// 处理 pending_stop 时(快速松手 race)丢失标志,导致意外走普通听写路径。
if begin_session_as(inner, true).await.is_err() {
return;
}
let started = {
let mut state = inner.state.lock();
let state = inner.state.lock();
// voice_agent 已在 begin_session_as 内设置;这里只检查阶段是否推进成功。
if matches!(
state.phase,
SessionPhase::Starting | SessionPhase::Listening
SessionPhase::Starting | SessionPhase::Listening | SessionPhase::Processing
) {
state.voice_agent = true;
log::info!(
"[less-computer] voice session started (session={:?})",
state.session_id
+47 -1
View File
@@ -1165,6 +1165,16 @@ impl HistoryStore {
})
}
/// 在 data_dir 不可用时构造一个降级实例(指向临时目录)。
/// 该实例在运行期间读写会安静地失败或返回空,不会 panic,
/// 也不会影响正常启动路径。
pub(crate) fn new_fallback() -> Self {
Self {
path: std::env::temp_dir().join("openless_history_fallback.json"),
lock: Mutex::new(()),
}
}
pub fn list(&self) -> Result<Vec<DictationSession>> {
let _guard = self.lock.lock();
self.read_locked()
@@ -1213,12 +1223,14 @@ impl HistoryStore {
let sessions = self.read_locked()?;
let cutoff = chrono::Utc::now() - chrono::Duration::minutes(i64::from(minutes));
// sessions 是 newest-first,超出窗口的会话之后的都更老,take_while 即可。
// unwrap_or(true):时间戳解析失败时保留该条目,与 append_with_retention 的保守策略一致;
// 避免单条坏记录截断整个上下文窗口。
let filtered: Vec<DictationSession> = sessions
.into_iter()
.take_while(|s| {
chrono::DateTime::parse_from_rfc3339(&s.created_at)
.map(|t| t.with_timezone(&chrono::Utc) >= cutoff)
.unwrap_or(false)
.unwrap_or(true)
})
.collect();
Ok(filtered)
@@ -1291,6 +1303,14 @@ impl PreferencesStore {
})
}
/// 降级实例:data_dir 不可用时使用默认配置,写操作会安静地失败。
pub(crate) fn new_fallback() -> Self {
Self {
path: std::env::temp_dir().join("openless_prefs_fallback.json"),
state: Mutex::new(UserPreferences::default()),
}
}
pub fn get(&self) -> UserPreferences {
self.state.lock().clone()
}
@@ -1394,6 +1414,16 @@ impl StylePackStore {
})
}
/// 降级实例:data_dir 不可用时使用临时路径和空列表,写操作会安静地失败。
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"),
state: Mutex::new(Vec::new()),
}
}
pub fn list(&self) -> Result<Vec<StylePack>> {
Ok(self.state.lock().clone())
}
@@ -2156,6 +2186,14 @@ impl DictionaryStore {
})
}
/// 降级实例:data_dir 不可用时使用临时路径,读写会安静地失败或返回空。
pub(crate) fn new_fallback() -> Self {
Self {
path: std::env::temp_dir().join("openless_vocab_fallback.json"),
lock: Mutex::new(()),
}
}
pub fn list(&self) -> Result<Vec<DictionaryEntry>> {
let _guard = self.lock.lock();
self.read_locked()
@@ -2300,6 +2338,14 @@ impl CorrectionRuleStore {
})
}
/// 降级实例:data_dir 不可用时使用临时路径,读写会安静地失败或返回空。
pub(crate) fn new_fallback() -> Self {
Self {
path: std::env::temp_dir().join("openless_correction_rules_fallback.json"),
lock: Mutex::new(()),
}
}
pub fn list(&self) -> Result<Vec<CorrectionRule>> {
let _guard = self.lock.lock();
self.read_locked()