From 5fa8ce1a18e82561617b78e6e17690cc0bdafd21 Mon Sep 17 00:00:00 2001 From: mikoto2000 Date: Fri, 26 Jul 2024 10:59:12 +0000 Subject: [PATCH 1/2] Implemented android save dialog. --- .../android/src/main/java/DialogPlugin.kt | 47 +++++++++++++++++++ plugins/dialog/src/commands.rs | 4 +- plugins/dialog/src/lib.rs | 8 ++-- plugins/dialog/src/mobile.rs | 23 +++++++++ 4 files changed, 77 insertions(+), 5 deletions(-) diff --git a/plugins/dialog/android/src/main/java/DialogPlugin.kt b/plugins/dialog/android/src/main/java/DialogPlugin.kt index 73eacfb66c..6cc15179eb 100644 --- a/plugins/dialog/android/src/main/java/DialogPlugin.kt +++ b/plugins/dialog/android/src/main/java/DialogPlugin.kt @@ -41,6 +41,11 @@ class MessageOptions { var cancelButtonLabel: String? = null } +@InvokeArg +class SaveFileDialogOptions { + var title: String = "" +} + @TauriPlugin class DialogPlugin(private val activity: Activity): Plugin(activity) { var filePickerOptions: FilePickerOptions? = null @@ -204,4 +209,46 @@ class DialogPlugin(private val activity: Activity): Plugin(activity) { dialog.show() } } + + @Command + fun saveFileDialog(invoke: Invoke) { + try { + val args = invoke.parseArgs(SaveFileDialogOptions::class.java) + + val intent = Intent(Intent.ACTION_CREATE_DOCUMENT) + intent.addCategory(Intent.CATEGORY_OPENABLE) + intent.setType("text/plain") + intent.putExtra(Intent.EXTRA_TITLE, args.title) + startActivityForResult(invoke, intent, "saveFileDialogResult") + } catch (ex: Exception) { + val message = ex.message ?: "Failed to pick save file" + Logger.error(message) + invoke.reject(message) + } + } + + @ActivityCallback + fun saveFileDialogResult(invoke: Invoke, result: ActivityResult) { + try { + when (result.resultCode) { + Activity.RESULT_OK -> { + val callResult = JSObject() + val intent: Intent? = result.data + if (intent != null) { + val uri = intent.getData() + if (uri != null) { + callResult.put("file", uri.toString()) + } + } + invoke.resolve(callResult) + } + Activity.RESULT_CANCELED -> invoke.reject("File picker cancelled") + else -> invoke.reject("Failed to pick files") + } + } catch (ex: java.lang.Exception) { + val message = ex.message ?: "Failed to read file pick result" + Logger.error(message) + invoke.reject(message) + } + } } \ No newline at end of file diff --git a/plugins/dialog/src/commands.rs b/plugins/dialog/src/commands.rs index 0d4a0de9ac..0fb40cc2e7 100644 --- a/plugins/dialog/src/commands.rs +++ b/plugins/dialog/src/commands.rs @@ -193,9 +193,9 @@ pub(crate) async fn save( dialog: State<'_, Dialog>, options: SaveDialogOptions, ) -> Result> { - #[cfg(mobile)] + #[cfg(any(target_os = "ios"))] return Err(crate::Error::FileSaveDialogNotImplemented); - #[cfg(desktop)] + #[cfg(any(desktop, target_os = "android"))] { let mut dialog_builder = dialog.file(); #[cfg(any(windows, target_os = "macos"))] diff --git a/plugins/dialog/src/lib.rs b/plugins/dialog/src/lib.rs index bb1b9882c1..feac2ead41 100644 --- a/plugins/dialog/src/lib.rs +++ b/plugins/dialog/src/lib.rs @@ -17,8 +17,10 @@ use tauri::{ Manager, Runtime, }; +#[cfg(any(desktop, target_os = "ios"))] +use std::fs; + use std::{ - fs, path::{Path, PathBuf}, sync::mpsc::sync_channel, }; @@ -471,7 +473,6 @@ impl FileDialogBuilder { /// }) /// }) /// ``` - #[cfg(desktop)] pub fn save_file) + Send + 'static>(self, f: F) { save_file(self, f) } @@ -572,14 +573,15 @@ impl FileDialogBuilder { /// // the file path is `None` if the user closed the dialog /// } /// ``` - #[cfg(desktop)] pub fn blocking_save_file(self) -> Option { blocking_fn!(self, save_file) } + } // taken from deno source code: https://github.com/denoland/deno/blob/ffffa2f7c44bd26aec5ae1957e0534487d099f48/runtime/ops/fs.rs#L913 #[inline] +#[allow(unused)] fn to_msec(maybe_time: std::result::Result) -> Option { match maybe_time { Ok(time) => { diff --git a/plugins/dialog/src/mobile.rs b/plugins/dialog/src/mobile.rs index 289cbb7e3b..6672dc5e4b 100644 --- a/plugins/dialog/src/mobile.rs +++ b/plugins/dialog/src/mobile.rs @@ -1,6 +1,7 @@ // Copyright 2019-2023 Tauri Programme within The Commons Conservancy // SPDX-License-Identifier: Apache-2.0 // SPDX-License-Identifier: MIT +use std::path::PathBuf; use serde::{de::DeserializeOwned, Deserialize}; use tauri::{ @@ -49,6 +50,11 @@ struct FilePickerResponse { files: Vec, } +#[derive(Debug, Deserialize)] +struct SaveFileResponse { + file: PathBuf, +} + pub fn pick_file) + Send + 'static>( dialog: FileDialogBuilder, f: F, @@ -83,6 +89,23 @@ pub fn pick_files>) + Send + 'sta }); } +pub fn save_file) + Send + 'static>( + dialog: FileDialogBuilder, + f: F, +) { + std::thread::spawn(move || { + let res = dialog + .dialog + .0 + .run_mobile_plugin::("saveFileDialog", dialog.payload(true)); + if let Ok(response) = res { + f(Some(response.file)) + } else { + f(None) + } + }); +} + #[derive(Debug, Deserialize)] struct ShowMessageDialogResponse { #[allow(dead_code)] From df6d162866b34773c77f1f42f14d31e7ef969fed Mon Sep 17 00:00:00 2001 From: mikoto2000 Date: Fri, 26 Jul 2024 23:28:03 +0000 Subject: [PATCH 2/2] Implemented writeTextFile on Android. --- plugins/dialog/src/lib.rs | 1 - plugins/fs/android/.gitignore | 2 + plugins/fs/android/build.gradle.kts | 45 ++++++++++++++ plugins/fs/android/proguard-rules.pro | 21 +++++++ plugins/fs/android/settings.gradle | 31 ++++++++++ .../java/ExampleInstrumentedTest.kt | 24 ++++++++ .../fs/android/src/main/AndroidManifest.xml | 3 + plugins/fs/android/src/main/java/FsPlugin.kt | 39 +++++++++++++ .../android/src/test/java/ExampleUnitTest.kt | 17 ++++++ plugins/fs/build.rs | 1 + plugins/fs/src/commands.rs | 35 ++++++++--- plugins/fs/src/lib.rs | 21 +++++++ plugins/fs/src/mobile.rs | 58 +++++++++++++++++++ plugins/fs/src/models.rs | 15 +++++ 14 files changed, 303 insertions(+), 10 deletions(-) create mode 100644 plugins/fs/android/.gitignore create mode 100644 plugins/fs/android/build.gradle.kts create mode 100644 plugins/fs/android/proguard-rules.pro create mode 100644 plugins/fs/android/settings.gradle create mode 100644 plugins/fs/android/src/androidTest/java/ExampleInstrumentedTest.kt create mode 100644 plugins/fs/android/src/main/AndroidManifest.xml create mode 100644 plugins/fs/android/src/main/java/FsPlugin.kt create mode 100644 plugins/fs/android/src/test/java/ExampleUnitTest.kt create mode 100644 plugins/fs/src/mobile.rs create mode 100644 plugins/fs/src/models.rs diff --git a/plugins/dialog/src/lib.rs b/plugins/dialog/src/lib.rs index feac2ead41..cb7ef69f5d 100644 --- a/plugins/dialog/src/lib.rs +++ b/plugins/dialog/src/lib.rs @@ -576,7 +576,6 @@ impl FileDialogBuilder { pub fn blocking_save_file(self) -> Option { blocking_fn!(self, save_file) } - } // taken from deno source code: https://github.com/denoland/deno/blob/ffffa2f7c44bd26aec5ae1957e0534487d099f48/runtime/ops/fs.rs#L913 diff --git a/plugins/fs/android/.gitignore b/plugins/fs/android/.gitignore new file mode 100644 index 0000000000..c0f21ec2fd --- /dev/null +++ b/plugins/fs/android/.gitignore @@ -0,0 +1,2 @@ +/build +/.tauri diff --git a/plugins/fs/android/build.gradle.kts b/plugins/fs/android/build.gradle.kts new file mode 100644 index 0000000000..575040aaaf --- /dev/null +++ b/plugins/fs/android/build.gradle.kts @@ -0,0 +1,45 @@ +plugins { + id("com.android.library") + id("org.jetbrains.kotlin.android") +} + +android { + namespace = "com.plugin.fs" + compileSdk = 34 + + defaultConfig { + minSdk = 21 + targetSdk = 34 + + testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" + consumerProguardFiles("consumer-rules.pro") + } + + buildTypes { + release { + isMinifyEnabled = false + proguardFiles( + getDefaultProguardFile("proguard-android-optimize.txt"), + "proguard-rules.pro" + ) + } + } + compileOptions { + sourceCompatibility = JavaVersion.VERSION_1_8 + targetCompatibility = JavaVersion.VERSION_1_8 + } + kotlinOptions { + jvmTarget = "1.8" + } +} + +dependencies { + + implementation("androidx.core:core-ktx:1.9.0") + implementation("androidx.appcompat:appcompat:1.6.0") + implementation("com.google.android.material:material:1.7.0") + testImplementation("junit:junit:4.13.2") + androidTestImplementation("androidx.test.ext:junit:1.1.5") + androidTestImplementation("androidx.test.espresso:espresso-core:3.5.1") + implementation(project(":tauri-android")) +} diff --git a/plugins/fs/android/proguard-rules.pro b/plugins/fs/android/proguard-rules.pro new file mode 100644 index 0000000000..481bb43481 --- /dev/null +++ b/plugins/fs/android/proguard-rules.pro @@ -0,0 +1,21 @@ +# Add project specific ProGuard rules here. +# You can control the set of applied configuration files using the +# proguardFiles setting in build.gradle. +# +# For more details, see +# http://developer.android.com/guide/developing/tools/proguard.html + +# If your project uses WebView with JS, uncomment the following +# and specify the fully qualified class name to the JavaScript interface +# class: +#-keepclassmembers class fqcn.of.javascript.interface.for.webview { +# public *; +#} + +# Uncomment this to preserve the line number information for +# debugging stack traces. +#-keepattributes SourceFile,LineNumberTable + +# If you keep the line number information, uncomment this to +# hide the original source file name. +#-renamesourcefileattribute SourceFile \ No newline at end of file diff --git a/plugins/fs/android/settings.gradle b/plugins/fs/android/settings.gradle new file mode 100644 index 0000000000..d7782a40dd --- /dev/null +++ b/plugins/fs/android/settings.gradle @@ -0,0 +1,31 @@ +pluginManagement { + repositories { + mavenCentral() + gradlePluginPortal() + google() + } + resolutionStrategy { + eachPlugin { + switch (requested.id.id) { + case "com.android.library": + useVersion("8.0.2") + break + case "org.jetbrains.kotlin.android": + useVersion("1.8.20") + break + } + } + } +} + +dependencyResolutionManagement { + repositoriesMode.set(RepositoriesMode.FAIL_ON_PROJECT_REPOS) + repositories { + mavenCentral() + google() + + } +} + +include ':tauri-android' +project(':tauri-android').projectDir = new File('./.tauri/tauri-api') diff --git a/plugins/fs/android/src/androidTest/java/ExampleInstrumentedTest.kt b/plugins/fs/android/src/androidTest/java/ExampleInstrumentedTest.kt new file mode 100644 index 0000000000..a662762756 --- /dev/null +++ b/plugins/fs/android/src/androidTest/java/ExampleInstrumentedTest.kt @@ -0,0 +1,24 @@ +package com.plugin.fs + +import androidx.test.platform.app.InstrumentationRegistry +import androidx.test.ext.junit.runners.AndroidJUnit4 + +import org.junit.Test +import org.junit.runner.RunWith + +import org.junit.Assert.* + +/** + * Instrumented test, which will execute on an Android device. + * + * See [testing documentation](http://d.android.com/tools/testing). + */ +@RunWith(AndroidJUnit4::class) +class ExampleInstrumentedTest { + @Test + fun useAppContext() { + // Context of the app under test. + val appContext = InstrumentationRegistry.getInstrumentation().targetContext + assertEquals("com.plugin.fs", appContext.packageName) + } +} diff --git a/plugins/fs/android/src/main/AndroidManifest.xml b/plugins/fs/android/src/main/AndroidManifest.xml new file mode 100644 index 0000000000..9a40236b94 --- /dev/null +++ b/plugins/fs/android/src/main/AndroidManifest.xml @@ -0,0 +1,3 @@ + + + diff --git a/plugins/fs/android/src/main/java/FsPlugin.kt b/plugins/fs/android/src/main/java/FsPlugin.kt new file mode 100644 index 0000000000..e224036e29 --- /dev/null +++ b/plugins/fs/android/src/main/java/FsPlugin.kt @@ -0,0 +1,39 @@ +package com.plugin.fs + +import android.app.Activity +import android.net.Uri +import android.util.Log +import app.tauri.annotation.Command +import app.tauri.annotation.InvokeArg +import app.tauri.annotation.TauriPlugin +import app.tauri.plugin.JSObject +import app.tauri.plugin.Plugin +import app.tauri.plugin.Invoke + +@InvokeArg +class WriteTextFileArgs { + val uri: String = "" + val content: String = "" +} + +@TauriPlugin +class FsPlugin(private val activity: Activity): Plugin(activity) { + @Command + fun writeTextFile(invoke: Invoke) { + val args = invoke.parseArgs(WriteTextFileArgs::class.java) + val uri = Uri.parse(args.uri) + val content = args.content + + if(uri != null){ + activity.getContentResolver().openOutputStream(uri).use { ost -> + if(ost != null && content != null){ + ost.write(content.toByteArray()); + } + } + } + + val ret = JSObject() + invoke.resolve(ret) + } +} + diff --git a/plugins/fs/android/src/test/java/ExampleUnitTest.kt b/plugins/fs/android/src/test/java/ExampleUnitTest.kt new file mode 100644 index 0000000000..78136f5333 --- /dev/null +++ b/plugins/fs/android/src/test/java/ExampleUnitTest.kt @@ -0,0 +1,17 @@ +package com.plugin.fs + +import org.junit.Test + +import org.junit.Assert.* + +/** + * Example local unit test, which will execute on the development machine (host). + * + * See [testing documentation](http://d.android.com/tools/testing). + */ +class ExampleUnitTest { + @Test + fun addition_isCorrect() { + assertEquals(4, 2 + 2) + } +} diff --git a/plugins/fs/build.rs b/plugins/fs/build.rs index cf650aaad2..b1c1bf1f9d 100644 --- a/plugins/fs/build.rs +++ b/plugins/fs/build.rs @@ -190,5 +190,6 @@ permissions = [ tauri_plugin::Builder::new(COMMANDS) .global_api_script_path("./api-iife.js") .global_scope_schema(schemars::schema_for!(FsScopeEntry)) + .android_path("android") .build(); } diff --git a/plugins/fs/src/commands.rs b/plugins/fs/src/commands.rs index 81b9b8e724..37199ae15f 100644 --- a/plugins/fs/src/commands.rs +++ b/plugins/fs/src/commands.rs @@ -9,7 +9,7 @@ use tauri::{ ipc::{CommandScope, GlobalScope}, path::{BaseDirectory, SafePathBuf}, utils::config::FsScope, - Manager, Resource, ResourceId, Runtime, Webview, + AppHandle, Manager, Resource, ResourceId, Runtime, Webview, }; use std::{ @@ -22,6 +22,9 @@ use std::{ use crate::{scope::Entry, Error, FsExt}; +#[cfg(target_os = "android")] +use crate::models::WriteTextFilePayload; + #[derive(Debug, thiserror::Error)] pub enum CommandError { #[error(transparent)] @@ -782,21 +785,35 @@ pub async fn write_file( #[tauri::command] pub async fn write_text_file( + #[allow(unused)] + app: AppHandle, + #[allow(unused)] webview: Webview, + #[allow(unused)] global_scope: GlobalScope, + #[allow(unused)] command_scope: CommandScope, path: SafePathBuf, data: String, + #[allow(unused)] options: Option, ) -> CommandResult<()> { - write_file_inner( - webview, - &global_scope, - &command_scope, - path, - data.as_bytes(), - options, - ) + #[cfg(any(desktop, target_os = "ios"))] + { + write_file_inner( + webview, + &global_scope, + &command_scope, + path, + data.as_bytes(), + options, + ) + } + #[cfg(target_os = "android")] + { + app.fs().write_text_file(WriteTextFilePayload{uri: path.display().to_string(), content: data}).unwrap(); + Ok(()) + } } #[tauri::command] diff --git a/plugins/fs/src/lib.rs b/plugins/fs/src/lib.rs index a81deb8cf9..d75383d24d 100644 --- a/plugins/fs/src/lib.rs +++ b/plugins/fs/src/lib.rs @@ -21,9 +21,16 @@ use tauri::{ mod commands; mod config; mod error; +#[cfg(target_os = "android")] +mod mobile; mod scope; #[cfg(feature = "watch")] mod watcher; +#[cfg(target_os = "android")] +mod models; + +#[cfg(target_os = "android")] +use mobile::Fs; pub use error::Error; pub use scope::{Event as ScopeEvent, Scope}; @@ -55,6 +62,8 @@ impl ScopeObject for scope::Entry { pub trait FsExt { fn fs_scope(&self) -> &Scope; fn try_fs_scope(&self) -> Option<&Scope>; + #[cfg(target_os = "android")] + fn fs(&self) -> &Fs; } impl> FsExt for T { @@ -65,6 +74,11 @@ impl> FsExt for T { fn try_fs_scope(&self) -> Option<&Scope> { self.try_state::().map(|s| s.inner()) } + + #[cfg(target_os = "android")] + fn fs(&self) -> &Fs { + self.state::>().inner() + } } pub fn init() -> TauriPlugin> { @@ -104,6 +118,13 @@ pub fn init() -> TauriPlugin> { .config() .as_ref() .and_then(|c| c.require_literal_leading_dot); + + #[cfg(target_os = "android")] + { + let fs = mobile::init(app, api)?; + app.manage(fs); + } + app.manage(scope); Ok(()) }) diff --git a/plugins/fs/src/mobile.rs b/plugins/fs/src/mobile.rs new file mode 100644 index 0000000000..81fdf35b28 --- /dev/null +++ b/plugins/fs/src/mobile.rs @@ -0,0 +1,58 @@ +use serde::de::DeserializeOwned; +use tauri::{ + plugin::{PluginApi, PluginHandle}, + AppHandle, Runtime, +}; + +#[cfg(target_os = "android")] +use crate::models::{WriteTextFilePayload, WriteTextFileResponse}; + +#[cfg(target_os = "android")] +use crate::Error::Tauri; + +#[cfg(target_os = "android")] +const PLUGIN_IDENTIFIER: &str = "com.plugin.fs"; + +#[cfg(target_os = "ios")] +tauri::ios_plugin_binding!(init_plugin_fs); + +// initializes the Kotlin or Swift plugin classes +pub fn init( + _app: &AppHandle, + api: PluginApi, +) -> crate::Result> { + #[cfg(target_os = "android")] + let handle = api.register_android_plugin(PLUGIN_IDENTIFIER, "FsPlugin").unwrap(); + #[cfg(target_os = "ios")] + let handle = api.register_ios_plugin(init_plugin_android-intent-send)?; + Ok(Fs(handle)) +} + +/// Access to the android-intent-send APIs. +pub struct Fs(PluginHandle); + +impl Fs { + pub fn write_text_file(&self, payload: WriteTextFilePayload) -> crate::Result { + #[cfg(target_os = "android")] + { + let result = self + .0 + .run_mobile_plugin::("writeTextFile", payload); + match result { + Ok(_) => Ok(WriteTextFileResponse{error: None}), + Err(_) => Err(Tauri(tauri::Error::InvokeKey)), + } + } + #[cfg(any(desktop, target_os = "ios"))] + { + write_file_inner( + webview, + &global_scope, + &command_scope, + path, + data.as_bytes(), + options, + ) + } + } +} diff --git a/plugins/fs/src/models.rs b/plugins/fs/src/models.rs new file mode 100644 index 0000000000..3bb3987ac6 --- /dev/null +++ b/plugins/fs/src/models.rs @@ -0,0 +1,15 @@ +use serde::{Deserialize, Serialize}; + +#[derive(Debug, Clone, Default, Deserialize, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct WriteTextFilePayload { + pub uri: String, + pub content: String, +} + +#[derive(Debug, Clone, Default, Deserialize, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct WriteTextFileResponse { + pub error: Option, +} +