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:
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 {
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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()
|
||||
|
||||
Reference in new issue
Block a user