From 8335f7852e23779cc56e34c5f2cfba5edd5ca74e Mon Sep 17 00:00:00 2001 From: springmin Date: Fri, 30 Oct 2026 15:33:42 +0800 Subject: [PATCH] ohos: pass the original picker name through the attachment bridge Carry the staged path and display name together from the ArkTS picker through the NAPI bridge. Keep the cache name random and only use the decoded original name as attachment metadata. --- platform/ohos/mail/src/bridge.rs | 41 ++++++++++++------- platform/ohos/mail/src/documents.rs | 24 ++++++----- .../ohos/shell/page-templates/Index.mail.ets | 33 +++++++++++---- 3 files changed, 65 insertions(+), 33 deletions(-) diff --git a/platform/ohos/mail/src/bridge.rs b/platform/ohos/mail/src/bridge.rs index 1ef14ef8..92c8a21e 100644 --- a/platform/ohos/mail/src/bridge.rs +++ b/platform/ohos/mail/src/bridge.rs @@ -37,7 +37,7 @@ static NEXT_REQUEST: AtomicI64 = AtomicI64::new(1); /// One open picker: where its result goes, and the hold that keeps the Slint /// thread from presenting while the picker covers the window. struct Pending { - sender: oneshot::Sender>, + sender: oneshot::Sender>, _hold: PresentationHold, } @@ -105,17 +105,17 @@ pub(super) unsafe extern "C" fn set_bridge( } } -/// `submitBridgeResult(requestId, path, error)` implementation, exported for -/// the page's asynchronous flows. +/// `submitBridgeResult(requestId, path, name, error)` implementation, exported +/// for the page's asynchronous flows. pub(super) unsafe extern "C" fn submit_bridge_result( env: napi_env, info: ffi::napi_callback_info, ) -> napi_value { - // SAFETY: live `env`/`info` from the NAPI runtime; `argv` holds the three + // SAFETY: live `env`/`info` from the NAPI runtime; `argv` holds the four // expected arguments. unsafe { - let mut argc: usize = 3; - let mut argv: [napi_value; 3] = [core::ptr::null_mut(); 3]; + let mut argc: usize = 4; + let mut argv: [napi_value; 4] = [core::ptr::null_mut(); 4]; let rc = ffi::napi_get_cb_info( env, info, @@ -134,17 +134,22 @@ pub(super) unsafe extern "C" fn submit_bridge_result( } else { String::new() }; - let error = if argc > 2 { + let name = if argc > 2 { read_string(env, argv[2]).unwrap_or_default() } else { String::new() }; + let error = if argc > 3 { + read_string(env, argv[3]).unwrap_or_default() + } else { + String::new() + }; let request = pending().lock().ok().and_then(|mut map| map.remove(&id)); let accepted = match request { Some(Pending { sender, .. }) => { // Empty path + empty error means "the user cancelled". let outcome = if !path.is_empty() || error.is_empty() { - Ok(path) + Ok((path, name)) } else { Err(error) }; @@ -180,7 +185,7 @@ pub(super) fn open_url(url: &str) -> Result<(), String> { /// Registers a picker request. Its entry holds presentation until the page /// reports the result or the request is dropped, whichever thread awaits it. -fn register_picker() -> Result<(i64, oneshot::Receiver>), String> { +fn register_picker() -> Result<(i64, oneshot::Receiver>), String> { let id = NEXT_REQUEST.fetch_add(1, Ordering::SeqCst); let (sender, receiver) = oneshot::channel(); let request = Pending { @@ -194,14 +199,20 @@ fn register_picker() -> Result<(i64, oneshot::Receiver>), Ok((id, receiver)) } +/// One picked document: the private staged path and its original display name. +pub(super) struct PickedFile { + pub(super) path: String, + pub(super) name: String, +} + /// Run the system file picker for one document. `Ok(None)` = user cancelled. -pub(super) async fn pick_open() -> Result, String> { +pub(super) async fn pick_open() -> Result, String> { let (id, receiver) = register_picker()?; let guard = PendingGuard(id); call_closure(&PICK_OPEN, "pickOpen", id)?; - let path = await_result(receiver).await?; + let (path, name) = await_result(receiver).await?; drop(guard); - Ok((!path.is_empty()).then_some(path)) + Ok((!path.is_empty()).then_some(PickedFile { path, name })) } /// Ask the system file picker for a destination and copy `source_path` there. @@ -218,7 +229,7 @@ pub(super) async fn pick_save( name: suggested_name, }; call_closure(&PICK_SAVE, "pickSave", request)?; - let path = await_result(receiver).await?; + let (path, _name) = await_result(receiver).await?; drop(guard); Ok((!path.is_empty()).then_some(path)) } @@ -239,8 +250,8 @@ struct SaveRequest { } async fn await_result( - receiver: oneshot::Receiver>, -) -> Result { + receiver: oneshot::Receiver>, +) -> Result<(String, String), String> { match tokio::time::timeout(Duration::from_secs(600), receiver).await { Ok(Ok(result)) => result, Ok(Err(_)) => Err("the file picker was closed".to_owned()), diff --git a/platform/ohos/mail/src/documents.rs b/platform/ohos/mail/src/documents.rs index e5778583..c156ee11 100644 --- a/platform/ohos/mail/src/documents.rs +++ b/platform/ohos/mail/src/documents.rs @@ -16,19 +16,23 @@ pub(super) struct OhosDocuments; impl DocumentProvider for OhosDocuments { fn import(&self) -> DocumentFuture> { Box::pin(async { - let Some(path) = super::bridge::pick_open().await? else { + let Some(file) = super::bridge::pick_open().await? else { return Ok(None); }; // The page staged a copy in the app cache; deleting it once the - // core is done mirrors the Android provider. - let staged = PathBuf::from(path); - Ok(Some(ImportedDocument::temporary(staged, |path| { - if let Err(error) = std::fs::remove_file(&path) { - slint_ohos_backend::ohos_info!( - "mail: could not remove the imported copy: {error}" - ); - } - }))) + // core is done mirrors the Android provider. The display name is + // metadata only; the staged path remains the filesystem input. + let staged = PathBuf::from(file.path); + Ok(Some( + ImportedDocument::temporary(staged, |path| { + if let Err(error) = std::fs::remove_file(&path) { + slint_ohos_backend::ohos_info!( + "mail: could not remove the imported copy: {error}" + ); + } + }) + .with_name(file.name), + )) }) } diff --git a/platform/ohos/shell/page-templates/Index.mail.ets b/platform/ohos/shell/page-templates/Index.mail.ets index 84144384..bb2a1297 100644 --- a/platform/ohos/shell/page-templates/Index.mail.ets +++ b/platform/ohos/shell/page-templates/Index.mail.ets @@ -32,10 +32,15 @@ interface ImeCursor { height: number; } +interface StagedFile { + path: string; + name: string; +} + // The exports of the native XComponent module (`onLoad` hands them over). interface FlectarNativeModule { setBridge?: (bridge: FlectarBridge) => void; - submitBridgeResult?: (requestId: number, path: string, error: string) => boolean; + submitBridgeResult?: (requestId: number, path: string, name: string, error: string) => boolean; imeCommit?: (text: string) => void; imeDelete?: (count: number) => void; imeFunction?: (key: number) => void; @@ -207,8 +212,9 @@ struct Index { this.installClipboardListener(); } - private reportBridgeResult(requestId: number, path: string, error: string): boolean { - return this.nativeModule?.submitBridgeResult?.(requestId, path, error) ?? false; + private reportBridgeResult( + requestId: number, path: string, error: string, name: string = ''): boolean { + return this.nativeModule?.submitBridgeResult?.(requestId, path, name, error) ?? false; } // The Slint thread must never read the pasteboard: a cross-device clipboard @@ -284,10 +290,10 @@ struct Index { } const staged = await this.stageCopy(context, uris[0]); hilog.info(0x0000, LOG_TAG, 'picker imported a document (%{public}d picked)', uris.length); - if (!this.reportBridgeResult(requestId, staged, '')) { + if (!this.reportBridgeResult(requestId, staged.path, '', staged.name)) { // The native request timed out or was cancelled while the picker was // open; do not leave the staged copy behind. - fileIo.unlink(staged).catch((error: BusinessError): void => { + fileIo.unlink(staged.path).catch((error: BusinessError): void => { hilog.error(0x0000, LOG_TAG, 'could not remove the orphaned import: %{public}s', error.message); }); @@ -615,7 +621,7 @@ struct Index { * @throws { BusinessError } when the picked file cannot be opened or copied; * the caller reports it through the bridge result. */ - private async stageCopy(context: common.UIAbilityContext, uri: string): Promise { + private async stageCopy(context: common.UIAbilityContext, uri: string): Promise { const directory = `${context.cacheDir}/imports`; try { await fileIo.mkdir(directory, true); @@ -635,7 +641,18 @@ struct Index { // Never reuse the picked file name (or a predictable timestamp) in the // cache: it can leak the user's document name and collide with another // staged import. Keep only a short extension for content detection. - const raw = uri.split('/').pop() ?? ''; + // The display name is a separate field; decode the URI basename so a + // Chinese or spaced filename is not shown as `%E8%AE%BE...`. + let raw = uri.split('/').pop() ?? ''; + const query = raw.search(/[?#]/); + if (query >= 0) { + raw = raw.substring(0, query); + } + try { + raw = decodeURIComponent(raw); + } catch (error) { + // Keep the encoded name rather than failing the attachment import. + } const dot = raw.lastIndexOf('.'); const extension = dot > 0 ? raw.substring(dot).replace(/[^A-Za-z0-9.]/g, '').substring(0, 12) @@ -650,7 +667,7 @@ struct Index { } finally { await fileIo.close(target); } - return destination; + return { path: destination, name: raw }; } finally { await fileIo.close(source); }