ohos: request the pasteboard ACL permission for paste

Reading the system pasteboard requires ohos.permission.READ_PASTEBOARD
(system_basic, user_grant). Declare it with a localized reason, resolve the
reasonId while packing the hap (the packing tool normally does this), and let
the page ask for the permission when a paste is denied; a toast explains the
denial.

The hap now only installs with an ACL-enabled signing profile that lists the
permission; the README documents the requirement.
This commit is contained in:
springmin committed 2026-10-23 08:42:43 +08:00
1 parent 81d76c3427
commit 0784cf06d3
10 files changed
+166 -9

No files matched your search

+6
View File
@@ -156,6 +156,12 @@ handled (and asserted) in the script, do not "simplify" them away:
from the sync engine is published through `@kit.NotificationKit` when the
in-app notifications setting is on. A refusal is final at the system level;
the user can re-enable notifications in the system settings.
- **Clipboard**: copying needs no permission, but pasting reads the system
pasteboard and therefore requires `ohos.permission.READ_PASTEBOARD`, an ACL
(restricted) permission. The module declares it and the page requests it on
the first denied paste. The signing profile must list the permission in
`acls.allowed-acls`; a hap built without such a profile fails to install
with "grant request permissions failed".
- **Data**: the application keeps its data under the standard bundle data
path. Import/export goes through the system document picker via the host
`DocumentProvider`, and credentials live in the system Asset Store, never
+16
View File
@@ -7,6 +7,16 @@
use core::ffi::{CStr, c_int};
use std::ffi::CString;
use std::sync::OnceLock;
/// Hook installed by the shell to ask for `ohos.permission.READ_PASTEBOARD`
/// after a denied read. Tests and hosts without a bridge leave it unset.
static PERMISSION_HOOK: OnceLock<fn()> = OnceLock::new();
/// Install the hook that requests the pasteboard permission from the user.
pub fn set_permission_hook(hook: fn()) {
let _ = PERMISSION_HOOK.set(hook);
}
use crate::ffi;
@@ -28,6 +38,12 @@ pub fn read_text() -> Option<String> {
// denial (e.g. 201: missing READ_PASTEBOARD).
if status != 0 {
crate::log::error(&format!("clipboard: read failed (status {status})"));
// 201 is "permission denied": let the shell ask the user once.
if status == 201
&& let Some(hook) = PERMISSION_HOOK.get()
{
(hook)();
}
}
} else {
let plain = ffi::OH_UdsPlainText_Create();
+14
View File
@@ -28,6 +28,7 @@ static PICK_SAVE: AtomicUsize = AtomicUsize::new(0);
static SHOW_IME: AtomicUsize = AtomicUsize::new(0);
static HIDE_IME: AtomicUsize = AtomicUsize::new(0);
static NOTIFY: AtomicUsize = AtomicUsize::new(0);
static REQUEST_PASTEBOARD: AtomicUsize = AtomicUsize::new(0);
/// Request ids for the callback-based picker flows.
static NEXT_REQUEST: AtomicI64 = AtomicI64::new(1);
@@ -80,6 +81,13 @@ pub(super) unsafe extern "C" fn set_bridge(
install_property(env, argv[0], b"showIme\0", &SHOW_IME, call_zero_arg);
install_property(env, argv[0], b"hideIme\0", &HIDE_IME, call_zero_arg);
install_property(env, argv[0], b"notify\0", &NOTIFY, call_notify);
install_property(
env,
argv[0],
b"requestPasteboard\0",
&REQUEST_PASTEBOARD,
call_zero_arg,
);
undefined(env)
}
}
@@ -209,6 +217,12 @@ pub(super) fn hide_ime() {
let _ = call_closure(&HIDE_IME, "hideIme", ());
}
/// Ask the page to request the pasteboard permission (called from the
/// clipboard fallback on a denied read).
pub(super) fn request_pasteboard() {
let _ = call_closure(&REQUEST_PASTEBOARD, "requestPasteboard", ());
}
/// Publish a system notification through the ArkTS hook.
pub(super) fn notify(title: &str, body: &str) {
let request = NotifyRequest {
+2
View File
@@ -260,6 +260,8 @@ fn run_mail() -> std::result::Result<(), Box<dyn std::error::Error + Send + Sync
Arc::new(documents::OhosDocuments);
// Soft-keyboard control for Slint's editable fields.
slint_ohos_backend::ime::set_hooks(bridge::show_ime, bridge::hide_ime);
// A denied paste asks for the pasteboard ACL permission once (see clipboard.rs).
slint_ohos_backend::clipboard::set_permission_hook(bridge::request_pasteboard);
let mut platform =
flectar_mail::PlatformContext::app_private(internal, cache, documents, credentials, oauth);
platform.external_urls = flectar_mail::ExternalUrlOpener::new(bridge::open_url);
+12 -3
View File
@@ -256,9 +256,10 @@ cp -r "$RES_OUT/resources" "$STAGE/resources"
# requestPermissions: without ohos.permission.INTERNET every socket operation
# (loopback OAuth listener, IMAP/SMTP) fails with EPERM, which surfaces as
# "OAuth failed: io error: Operation not permitted (os error 1)".
# READ_PASTEBOARD is deliberately absent: this device's installer rejects it
# for normal apps (ACL-only, "grant request permissions failed"), so clipboard
# reads stay unavailable; clipboard writes need no permission.
# READ_PASTEBOARD is declared by the module: pasting needs it, but the hap
# only installs with an ACL-enabled profile that lists the permission
# (otherwise the installer reports "grant request permissions failed").
# Copying needs no permission.
# SDK coordinates: the resource compiler only emits the V2 `resources.index`
# the device can load for HarmonyOS targets; DevEco-built haps carry these
# exact values.
@@ -286,6 +287,14 @@ module.setdefault("libIsolation", True)
permissions = module.setdefault("requestPermissions", [])
if not any(entry.get("name") == "ohos.permission.INTERNET" for entry in permissions):
permissions.append({"name": "ohos.permission.INTERNET"})
# The packing tool resolves `$string:` reasons through the resource table; the
# permission dialog needs the numeric reasonId.
for entry in permissions:
reason = entry.get("reason")
if isinstance(reason, str) and reason.startswith("$string:"):
key = "string:" + reason[len("$string:"):]
if key in ids:
entry["reasonId"] = ids[key]
app = manifest["app"]
app["bundleName"] = bundle_name
app["compileSdkVersion"] = "6.1.0.105"
@@ -1,4 +1,4 @@
import { common, Want } from '@kit.AbilityKit';
import { abilityAccessCtrl, common, PermissionRequestResult, Permissions, Want } from '@kit.AbilityKit';
import { BusinessError } from '@kit.BasicServicesKit';
import { fileIo, picker } from '@kit.CoreFileKit';
import { inputMethod } from '@kit.IMEKit';
@@ -17,6 +17,7 @@ interface FlectarBridge {
showIme: () => void;
hideIme: () => void;
notify: (title: string, body: string) => void;
requestPasteboard: () => void;
}
// The exports of the native XComponent module (`onLoad` hands them over).
@@ -33,6 +34,7 @@ interface FlectarNativeModule {
struct Index {
private nativeModule: FlectarNativeModule | undefined = undefined;
private imeAttached: boolean = false;
private pasteboardPermissionRequested: boolean = false;
aboutToAppear(): void {
this.probeResources();
@@ -120,6 +122,9 @@ struct Index {
notify: (title: string, body: string): void => {
this.notify(title, body);
},
requestPasteboard: (): void => {
this.requestPasteboardPermission();
},
});
}
@@ -300,6 +305,46 @@ struct Index {
}
}
// The clipboard is readable only with the ACL permission
// ohos.permission.READ_PASTEBOARD. Native code asks for it after the first
// denied paste; granting takes effect for the next paste.
private requestPasteboardPermission(): void {
if (this.pasteboardPermissionRequested) {
return;
}
this.pasteboardPermissionRequested = true;
try {
const context = this.getUIContext().getHostContext() as common.UIAbilityContext;
const manager = abilityAccessCtrl.createAtManager();
const permissions: Permissions[] = ['ohos.permission.READ_PASTEBOARD'];
manager.requestPermissionsFromUser(context, permissions)
.then((result: PermissionRequestResult): void => {
const granted = result.authResults.length > 0 && result.authResults[0] === 0;
hilog.info(0x0000, NOTIFICATION_TAG, `pasteboard permission granted: ${granted}`);
if (!granted) {
this.showPasteboardNotice();
}
})
.catch((error: BusinessError): void => {
hilog.error(0x0000, NOTIFICATION_TAG,
'pasteboard permission request failed: %{public}d', error.code);
});
} catch (error) {
hilog.error(0x0000, NOTIFICATION_TAG, 'pasteboard permission request failed');
}
}
private showPasteboardNotice(): void {
try {
this.getUIContext().getPromptAction().showToast({
message: 'Clipboard access is off; enable it in Settings to paste.',
duration: 3000,
});
} catch (error) {
hilog.error(0x0000, NOTIFICATION_TAG, 'pasteboard notice failed');
}
}
// Copy a picked (URI-granted) file into the app cache so native code never
// touches provider URIs.
/**
@@ -24,8 +24,16 @@
skills: [{ entities: ['entity.system.home'], actions: ['ohos.want.action.home'] }],
},
],
// Socket access for IMAP/SMTP/OAuth — the only permission the port needs.
// The packaging step in scripts/build-hap.sh re-asserts this list (deduped).
requestPermissions: [{ name: 'ohos.permission.INTERNET' }],
// Socket access for IMAP/SMTP/OAuth, plus the ACL permission that lets
// the app read the clipboard for paste. The packaging step in
// scripts/build-hap.sh re-asserts this list (deduped).
requestPermissions: [
{ name: 'ohos.permission.INTERNET' },
{
name: 'ohos.permission.READ_PASTEBOARD',
reason: '$string:pasteboard_permission_reason',
usedScene: { abilities: ['EntryAbility'], when: 'inuse' },
},
],
},
}
@@ -2,6 +2,10 @@
"string": [
{ "name": "module_desc", "value": "Flectar Mail for HarmonyOS" },
{ "name": "EntryAbility_desc", "value": "Flectar Mail for HarmonyOS" },
{ "name": "EntryAbility_label", "value": "Flectar Mail for HarmonyOS" }
{ "name": "EntryAbility_label", "value": "Flectar Mail for HarmonyOS" },
{
"name": "pasteboard_permission_reason",
"value": "Used to paste the copied text into the message body, subject and recipient fields."
}
]
}
@@ -0,0 +1,8 @@
{
"string": [
{
"name": "pasteboard_permission_reason",
"value": "用于将剪贴板中的文本粘贴到邮件正文、主题和收件人输入框。"
}
]
}
@@ -1,4 +1,4 @@
import { common, Want } from '@kit.AbilityKit';
import { abilityAccessCtrl, common, PermissionRequestResult, Permissions, Want } from '@kit.AbilityKit';
import { BusinessError } from '@kit.BasicServicesKit';
import { fileIo, picker } from '@kit.CoreFileKit';
import { inputMethod } from '@kit.IMEKit';
@@ -17,6 +17,7 @@ interface FlectarBridge {
showIme: () => void;
hideIme: () => void;
notify: (title: string, body: string) => void;
requestPasteboard: () => void;
}
// The exports of the native XComponent module (`onLoad` hands them over).
@@ -33,6 +34,7 @@ interface FlectarNativeModule {
struct Index {
private nativeModule: FlectarNativeModule | undefined = undefined;
private imeAttached: boolean = false;
private pasteboardPermissionRequested: boolean = false;
aboutToAppear(): void {
this.probeResources();
@@ -120,6 +122,9 @@ struct Index {
notify: (title: string, body: string): void => {
this.notify(title, body);
},
requestPasteboard: (): void => {
this.requestPasteboardPermission();
},
});
}
@@ -300,6 +305,46 @@ struct Index {
}
}
// The clipboard is readable only with the ACL permission
// ohos.permission.READ_PASTEBOARD. Native code asks for it after the first
// denied paste; granting takes effect for the next paste.
private requestPasteboardPermission(): void {
if (this.pasteboardPermissionRequested) {
return;
}
this.pasteboardPermissionRequested = true;
try {
const context = this.getUIContext().getHostContext() as common.UIAbilityContext;
const manager = abilityAccessCtrl.createAtManager();
const permissions: Permissions[] = ['ohos.permission.READ_PASTEBOARD'];
manager.requestPermissionsFromUser(context, permissions)
.then((result: PermissionRequestResult): void => {
const granted = result.authResults.length > 0 && result.authResults[0] === 0;
hilog.info(0x0000, NOTIFICATION_TAG, `pasteboard permission granted: ${granted}`);
if (!granted) {
this.showPasteboardNotice();
}
})
.catch((error: BusinessError): void => {
hilog.error(0x0000, NOTIFICATION_TAG,
'pasteboard permission request failed: %{public}d', error.code);
});
} catch (error) {
hilog.error(0x0000, NOTIFICATION_TAG, 'pasteboard permission request failed');
}
}
private showPasteboardNotice(): void {
try {
this.getUIContext().getPromptAction().showToast({
message: 'Clipboard access is off; enable it in Settings to paste.',
duration: 3000,
});
} catch (error) {
hilog.error(0x0000, NOTIFICATION_TAG, 'pasteboard notice failed');
}
}
// Copy a picked (URI-granted) file into the app cache so native code never
// touches provider URIs.
/**