feat: select directory to save last book cover image (#2521)

This commit is contained in:
Huang Xin 2025-11-24 00:00:49 +08:00 committed by GitHub
parent c792c18e01
commit 8f22a5c570
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
44 changed files with 306 additions and 39 deletions

View file

@ -8,6 +8,7 @@ import android.util.Log
import android.os.Build
import android.os.Environment
import android.provider.Settings
import android.provider.DocumentsContract
import android.view.View
import android.view.KeyEvent
import android.view.WindowInsets
@ -25,6 +26,8 @@ import androidx.core.content.FileProvider
import androidx.core.content.ContextCompat
import androidx.core.view.WindowInsetsCompat
import androidx.core.view.WindowInsetsControllerCompat
import androidx.activity.result.ActivityResultLauncher
import androidx.activity.result.contract.ActivityResultContracts
import androidx.browser.customtabs.CustomTabsIntent
import app.tauri.annotation.Command
import app.tauri.annotation.InvokeArg
@ -128,11 +131,16 @@ class NativeBridgePlugin(private val activity: Activity): Plugin(activity) {
}
companion object {
var pendingInvoke: Invoke? = null
private const val REQUEST_MANAGE_STORAGE = 1001
private const val FOLDER_PICKER_REQUEST_CODE = 1002
var pendingInvoke: Invoke? = null
var pendingFolderPickerInvoke: Invoke? = null
private var instance: NativeBridgePlugin? = null
fun getInstance(): NativeBridgePlugin? = instance
}
override fun load(webView: WebView) {
instance = this
super.load(webView)
handleIntent(activity.intent)
}
@ -682,4 +690,87 @@ class NativeBridgePlugin(private val activity: Activity): Plugin(activity) {
invoke.reject("Failed to open URL: ${e.message}")
}
}
@Command
fun select_directory(invoke: Invoke) {
pendingFolderPickerInvoke = invoke
try {
val intent = Intent(Intent.ACTION_OPEN_DOCUMENT_TREE)
activity.startActivityForResult(intent, FOLDER_PICKER_REQUEST_CODE)
} catch (e: Exception) {
val result = JSObject()
result.put("cancelled", true)
result.put("uri", null)
result.put("path", null)
result.put("error", e.message)
invoke.resolve(result)
pendingFolderPickerInvoke = null
}
}
fun handleActivityResult(requestCode: Int, resultCode: Int, data: Intent?) {
if (requestCode == FOLDER_PICKER_REQUEST_CODE) {
val invoke = pendingFolderPickerInvoke
if (invoke != null) {
handleDirectorySelected(data?.data, invoke)
pendingFolderPickerInvoke = null
}
}
}
private fun handleDirectorySelected(uri: Uri?, invoke: Invoke) {
val result = JSObject()
if (uri == null) {
result.put("cancelled", true)
result.put("uri", null)
result.put("path", null)
} else {
try {
val flags = Intent.FLAG_GRANT_READ_URI_PERMISSION or
Intent.FLAG_GRANT_WRITE_URI_PERMISSION
activity.contentResolver.takePersistableUriPermission(uri, flags)
result.put("cancelled", false)
result.put("uri", uri.toString())
result.put("path", extractPathFromUri(uri))
} catch (e: SecurityException) {
result.put("cancelled", true)
result.put("uri", uri.toString())
result.put("path", extractPathFromUri(uri))
result.put("error", "Permission error: ${e.message}")
} catch (e: Exception) {
result.put("cancelled", true)
result.put("uri", null)
result.put("path", null)
result.put("error", "Error: ${e.message}")
}
}
invoke.resolve(result)
pendingInvoke = null
}
private fun extractPathFromUri(uri: Uri): String? {
val path = uri.path ?: return null
return try {
when {
DocumentsContract.isTreeUri(uri) -> {
val treeDocId = DocumentsContract.getTreeDocumentId(uri)
val split = treeDocId.split(":")
if (split[0].equals("primary", ignoreCase = true)) {
if (split.size > 1) {
Environment.getExternalStorageDirectory().path + "/" + split[1]
} else {
Environment.getExternalStorageDirectory().path
}
} else {
"/storage/${split[0]}/" + (if (split.size > 1) split[1] else "")
}
}
else -> null
}
} catch (e: Exception) {
path
}
}
}

View file

@ -19,6 +19,7 @@ const COMMANDS: &[&str] = &[
"set_screen_brightness",
"get_external_sdcard_path",
"open_external_url",
"select_directory",
"request_manage_storage_permission",
"check_permissions",
"request_permissions",

View file

@ -0,0 +1,13 @@
# Automatically generated - DO NOT EDIT!
"$schema" = "../../schemas/schema.json"
[[permission]]
identifier = "allow-select-directory"
description = "Enables the select_directory command without any pre-configured scope."
commands.allow = ["select_directory"]
[[permission]]
identifier = "deny-select-directory"
description = "Denies the select_directory command without any pre-configured scope."
commands.deny = ["select_directory"]

View file

@ -24,6 +24,7 @@ Default permissions for the plugin
- `allow-set-screen-brightness`
- `allow-get-external-sdcard-path`
- `allow-open-external-url`
- `allow-select-directory`
- `allow-request-manage-storage-permission`
- `allow-check-permissions`
- `allow-request-permissions`
@ -666,6 +667,32 @@ Denies the request_permissions command without any pre-configured scope.
<tr>
<td>
`native-bridge:allow-select-directory`
</td>
<td>
Enables the select_directory command without any pre-configured scope.
</td>
</tr>
<tr>
<td>
`native-bridge:deny-select-directory`
</td>
<td>
Denies the select_directory command without any pre-configured scope.
</td>
</tr>
<tr>
<td>
`native-bridge:allow-set-screen-brightness`
</td>

View file

@ -21,6 +21,7 @@ permissions = [
"allow-set-screen-brightness",
"allow-get-external-sdcard-path",
"allow-open-external-url",
"allow-select-directory",
"allow-request-manage-storage-permission",
"allow-check-permissions",
"allow-request-permissions",

View file

@ -582,6 +582,18 @@
"const": "deny-request-permissions",
"markdownDescription": "Denies the request_permissions command without any pre-configured scope."
},
{
"description": "Enables the select_directory command without any pre-configured scope.",
"type": "string",
"const": "allow-select-directory",
"markdownDescription": "Enables the select_directory command without any pre-configured scope."
},
{
"description": "Denies the select_directory command without any pre-configured scope.",
"type": "string",
"const": "deny-select-directory",
"markdownDescription": "Denies the select_directory command without any pre-configured scope."
},
{
"description": "Enables the set_screen_brightness command without any pre-configured scope.",
"type": "string",
@ -619,10 +631,10 @@
"markdownDescription": "Denies the use_background_audio command without any pre-configured scope."
},
{
"description": "Default permissions for the plugin\n#### This default permission set includes:\n\n- `allow-auth-with-safari`\n- `allow-auth-with-custom-tab`\n- `allow-copy-uri-to-path`\n- `allow-use-background-audio`\n- `allow-install-package`\n- `allow-set-system-ui-visibility`\n- `allow-get-status-bar-height`\n- `allow-get-sys-fonts-list`\n- `allow-intercept-keys`\n- `allow-lock-screen-orientation`\n- `allow-iap-initialize`\n- `allow-iap-fetch-products`\n- `allow-iap-purchase-product`\n- `allow-iap-restore-purchases`\n- `allow-get-system-color-scheme`\n- `allow-get-safe-area-insets`\n- `allow-get-screen-brightness`\n- `allow-set-screen-brightness`\n- `allow-get-external-sdcard-path`\n- `allow-open-external-url`\n- `allow-request-manage-storage-permission`\n- `allow-check-permissions`\n- `allow-request-permissions`\n- `allow-checkPermissions`\n- `allow-requestPermissions`",
"description": "Default permissions for the plugin\n#### This default permission set includes:\n\n- `allow-auth-with-safari`\n- `allow-auth-with-custom-tab`\n- `allow-copy-uri-to-path`\n- `allow-use-background-audio`\n- `allow-install-package`\n- `allow-set-system-ui-visibility`\n- `allow-get-status-bar-height`\n- `allow-get-sys-fonts-list`\n- `allow-intercept-keys`\n- `allow-lock-screen-orientation`\n- `allow-iap-initialize`\n- `allow-iap-fetch-products`\n- `allow-iap-purchase-product`\n- `allow-iap-restore-purchases`\n- `allow-get-system-color-scheme`\n- `allow-get-safe-area-insets`\n- `allow-get-screen-brightness`\n- `allow-set-screen-brightness`\n- `allow-get-external-sdcard-path`\n- `allow-open-external-url`\n- `allow-select-directory`\n- `allow-request-manage-storage-permission`\n- `allow-check-permissions`\n- `allow-request-permissions`\n- `allow-checkPermissions`\n- `allow-requestPermissions`",
"type": "string",
"const": "default",
"markdownDescription": "Default permissions for the plugin\n#### This default permission set includes:\n\n- `allow-auth-with-safari`\n- `allow-auth-with-custom-tab`\n- `allow-copy-uri-to-path`\n- `allow-use-background-audio`\n- `allow-install-package`\n- `allow-set-system-ui-visibility`\n- `allow-get-status-bar-height`\n- `allow-get-sys-fonts-list`\n- `allow-intercept-keys`\n- `allow-lock-screen-orientation`\n- `allow-iap-initialize`\n- `allow-iap-fetch-products`\n- `allow-iap-purchase-product`\n- `allow-iap-restore-purchases`\n- `allow-get-system-color-scheme`\n- `allow-get-safe-area-insets`\n- `allow-get-screen-brightness`\n- `allow-set-screen-brightness`\n- `allow-get-external-sdcard-path`\n- `allow-open-external-url`\n- `allow-request-manage-storage-permission`\n- `allow-check-permissions`\n- `allow-request-permissions`\n- `allow-checkPermissions`\n- `allow-requestPermissions`"
"markdownDescription": "Default permissions for the plugin\n#### This default permission set includes:\n\n- `allow-auth-with-safari`\n- `allow-auth-with-custom-tab`\n- `allow-copy-uri-to-path`\n- `allow-use-background-audio`\n- `allow-install-package`\n- `allow-set-system-ui-visibility`\n- `allow-get-status-bar-height`\n- `allow-get-sys-fonts-list`\n- `allow-intercept-keys`\n- `allow-lock-screen-orientation`\n- `allow-iap-initialize`\n- `allow-iap-fetch-products`\n- `allow-iap-purchase-product`\n- `allow-iap-restore-purchases`\n- `allow-get-system-color-scheme`\n- `allow-get-safe-area-insets`\n- `allow-get-screen-brightness`\n- `allow-set-screen-brightness`\n- `allow-get-external-sdcard-path`\n- `allow-open-external-url`\n- `allow-select-directory`\n- `allow-request-manage-storage-permission`\n- `allow-check-permissions`\n- `allow-request-permissions`\n- `allow-checkPermissions`\n- `allow-requestPermissions`"
}
]
}

View file

@ -157,6 +157,13 @@ pub(crate) async fn open_external_url<R: Runtime>(
app.native_bridge().open_external_url(payload)
}
#[command]
pub(crate) async fn select_directory<R: Runtime>(
app: AppHandle<R>,
) -> Result<SelectDirectoryResponse> {
app.native_bridge().select_directory()
}
#[command]
pub(crate) async fn request_manage_storage_permission<R: Runtime>(
app: AppHandle<R>,

View file

@ -129,6 +129,10 @@ impl<R: Runtime> NativeBridge<R> {
Err(crate::Error::UnsupportedPlatformError)
}
pub fn select_directory(&self) -> crate::Result<SelectDirectoryResponse> {
Err(crate::Error::UnsupportedPlatformError)
}
pub fn request_manage_storage_permission(
&self,
) -> crate::Result<RequestManageStoragePermissionResponse> {

View file

@ -57,6 +57,7 @@ pub fn init<R: Runtime>() -> TauriPlugin<R> {
commands::set_screen_brightness,
commands::get_external_sdcard_path,
commands::open_external_url,
commands::select_directory,
commands::request_manage_storage_permission,
])
.setup(|app, api| {

View file

@ -208,6 +208,14 @@ impl<R: Runtime> NativeBridge<R> {
}
}
impl<R: Runtime> NativeBridge<R> {
pub fn select_directory(&self) -> crate::Result<SelectDirectoryResponse> {
self.0
.run_mobile_plugin("select_directory", ())
.map_err(Into::into)
}
}
impl<R: Runtime> NativeBridge<R> {
pub fn request_manage_storage_permission(
&self,

View file

@ -215,3 +215,12 @@ pub struct OpenExternalUrlResponse {
pub success: bool,
pub error: Option<String>,
}
#[derive(Debug, Deserialize, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct SelectDirectoryResponse {
pub cancelled: Option<bool>,
pub uri: Option<String>,
pub path: Option<String>,
pub error: Option<String>,
}