snapshot of https://github.com/tauri-apps/tauri.git (dev, 2026-09-23; blobs > 131072B omitted)
Some checks failed
Audit / audit-js (push) Failing after 0s
bench / bench (map[os:ubuntu-latest runner:xvfb-run --auto-servernum target:x86_64-unknown-linux-gnu], nightly) (push) Failing after 0s
covector version or publish / run-integration-tests (ubuntu-latest) (push) Failing after 0s
Audit / audit-rust (push) Failing after 0s
test `@tauri-apps/cli` / test (ubuntu-latest) (push) Failing after 0s
test `tauri-cli` / test (map[os:ubuntu-latest target:x86_64-unknown-linux-gnu]) (push) Failing after 0s
test core / test (map[args:--all-features key:all], map[cargo:cargo command:test os:ubuntu-latest target:x86_64-unknown-linux-gnu toolchain:1.90]) (push) Failing after 0s
test core / test (map[args:--no-default-features key:no-default], map[cargo:cargo command:test os:ubuntu-latest target:x86_64-unknown-linux-gnu toolchain:1.90]) (push) Failing after 0s
test core / test (map[args:--no-default-features key:no-default], map[cargo:cross command:build os:ubuntu-latest target:aarch64-linux-android toolchain:1.90]) (push) Failing after 0s
deploy schema worker / deploy (push) Failing after 0s
lint rust / clippy (map[cargo:cargo include: os:ubuntu-latest target:x86_64-unknown-linux-gnu]) (push) Failing after 0s
lint rust / clippy (map[cargo:cross include:--package api os:ubuntu-latest target:aarch64-linux-android]) (push) Failing after 0s
supply chain health status / check rust dependencies with cargo vet (push) Failing after 0s
test `@tauri-apps/api` e2e (mobile) / android (push) Failing after 0s
test `@tauri-apps/api` e2e / test (map[name:Linux os:ubuntu-latest]) (push) Failing after 0s
test core / test (map[args:--all-features key:all], map[cargo:cross command:build os:ubuntu-latest target:aarch64-linux-android toolchain:1.90]) (push) Failing after 0s
Udeps / changes (push) Failing after 0s
Udeps / setup (push) Has been skipped
Udeps / udeps (push) Has been skipped
bench / bench (map[os:macos-latest runner: target:aarch64-apple-darwin], nightly) (push) Has been cancelled
bench / bench (map[os:windows-latest runner: target:x86_64-pc-windows-msvc], nightly) (push) Has been cancelled
covector version or publish / run-integration-tests (macos-latest) (push) Has been cancelled
covector version or publish / run-integration-tests (windows-latest) (push) Has been cancelled
covector version or publish / version-or-publish (push) Has been cancelled
test `@tauri-apps/cli` / test (macos-latest) (push) Has been cancelled
test `tauri-cli` / test (map[args:--no-default-features --features native-tls-vendored os:windows-latest restore-only:true target:aarch64-pc-windows-msvc]) (push) Has been cancelled
test `tauri-cli` / test (map[os:macos-latest target:x86_64-apple-darwin]) (push) Has been cancelled
test `tauri-cli` / test (map[os:windows-latest target:x86_64-pc-windows-msvc]) (push) Has been cancelled
test core / test (map[args:--all-features key:all], map[cargo:cargo command:build os:macos-latest target:aarch64-apple-ios toolchain:1.90]) (push) Has been cancelled
test core / test (map[args:--all-features key:all], map[cargo:cargo command:test os:macos-14 target:aarch64-apple-darwin toolchain:1.90]) (push) Has been cancelled
lint rust / clippy (map[cargo:cargo include: os:macos-latest target:aarch64-apple-darwin]) (push) Has been cancelled
lint rust / clippy (map[cargo:cargo include: os:windows-latest target:x86_64-pc-windows-msvc]) (push) Has been cancelled
lint rust / clippy (map[cargo:cargo include:--package api os:macos-latest target:aarch64-apple-ios]) (push) Has been cancelled
test `@tauri-apps/api` e2e (mobile) / ios (push) Has been cancelled
test `@tauri-apps/api` e2e / test (map[name:Windows os:windows-latest]) (push) Has been cancelled
test `@tauri-apps/api` e2e / test (map[name:macOS os:macos-latest]) (push) Has been cancelled
test `@tauri-apps/cli` / test (windows-latest) (push) Has been cancelled
test core / test (map[args:--all-features key:all], map[cargo:cargo command:test os:windows-latest target:x86_64-pc-windows-msvc toolchain:1.90]) (push) Has been cancelled
test core / test (map[args:--no-default-features key:no-default], map[cargo:cargo command:build os:macos-latest target:aarch64-apple-ios toolchain:1.90]) (push) Has been cancelled
test core / test (map[args:--no-default-features key:no-default], map[cargo:cargo command:test os:macos-14 target:aarch64-apple-darwin toolchain:1.90]) (push) Has been cancelled
test core / test (map[args:--no-default-features key:no-default], map[cargo:cargo command:test os:windows-latest target:x86_64-pc-windows-msvc toolchain:1.90]) (push) Has been cancelled
Some checks failed
Audit / audit-js (push) Failing after 0s
bench / bench (map[os:ubuntu-latest runner:xvfb-run --auto-servernum target:x86_64-unknown-linux-gnu], nightly) (push) Failing after 0s
covector version or publish / run-integration-tests (ubuntu-latest) (push) Failing after 0s
Audit / audit-rust (push) Failing after 0s
test `@tauri-apps/cli` / test (ubuntu-latest) (push) Failing after 0s
test `tauri-cli` / test (map[os:ubuntu-latest target:x86_64-unknown-linux-gnu]) (push) Failing after 0s
test core / test (map[args:--all-features key:all], map[cargo:cargo command:test os:ubuntu-latest target:x86_64-unknown-linux-gnu toolchain:1.90]) (push) Failing after 0s
test core / test (map[args:--no-default-features key:no-default], map[cargo:cargo command:test os:ubuntu-latest target:x86_64-unknown-linux-gnu toolchain:1.90]) (push) Failing after 0s
test core / test (map[args:--no-default-features key:no-default], map[cargo:cross command:build os:ubuntu-latest target:aarch64-linux-android toolchain:1.90]) (push) Failing after 0s
deploy schema worker / deploy (push) Failing after 0s
lint rust / clippy (map[cargo:cargo include: os:ubuntu-latest target:x86_64-unknown-linux-gnu]) (push) Failing after 0s
lint rust / clippy (map[cargo:cross include:--package api os:ubuntu-latest target:aarch64-linux-android]) (push) Failing after 0s
supply chain health status / check rust dependencies with cargo vet (push) Failing after 0s
test `@tauri-apps/api` e2e (mobile) / android (push) Failing after 0s
test `@tauri-apps/api` e2e / test (map[name:Linux os:ubuntu-latest]) (push) Failing after 0s
test core / test (map[args:--all-features key:all], map[cargo:cross command:build os:ubuntu-latest target:aarch64-linux-android toolchain:1.90]) (push) Failing after 0s
Udeps / changes (push) Failing after 0s
Udeps / setup (push) Has been skipped
Udeps / udeps (push) Has been skipped
bench / bench (map[os:macos-latest runner: target:aarch64-apple-darwin], nightly) (push) Has been cancelled
bench / bench (map[os:windows-latest runner: target:x86_64-pc-windows-msvc], nightly) (push) Has been cancelled
covector version or publish / run-integration-tests (macos-latest) (push) Has been cancelled
covector version or publish / run-integration-tests (windows-latest) (push) Has been cancelled
covector version or publish / version-or-publish (push) Has been cancelled
test `@tauri-apps/cli` / test (macos-latest) (push) Has been cancelled
test `tauri-cli` / test (map[args:--no-default-features --features native-tls-vendored os:windows-latest restore-only:true target:aarch64-pc-windows-msvc]) (push) Has been cancelled
test `tauri-cli` / test (map[os:macos-latest target:x86_64-apple-darwin]) (push) Has been cancelled
test `tauri-cli` / test (map[os:windows-latest target:x86_64-pc-windows-msvc]) (push) Has been cancelled
test core / test (map[args:--all-features key:all], map[cargo:cargo command:build os:macos-latest target:aarch64-apple-ios toolchain:1.90]) (push) Has been cancelled
test core / test (map[args:--all-features key:all], map[cargo:cargo command:test os:macos-14 target:aarch64-apple-darwin toolchain:1.90]) (push) Has been cancelled
lint rust / clippy (map[cargo:cargo include: os:macos-latest target:aarch64-apple-darwin]) (push) Has been cancelled
lint rust / clippy (map[cargo:cargo include: os:windows-latest target:x86_64-pc-windows-msvc]) (push) Has been cancelled
lint rust / clippy (map[cargo:cargo include:--package api os:macos-latest target:aarch64-apple-ios]) (push) Has been cancelled
test `@tauri-apps/api` e2e (mobile) / ios (push) Has been cancelled
test `@tauri-apps/api` e2e / test (map[name:Windows os:windows-latest]) (push) Has been cancelled
test `@tauri-apps/api` e2e / test (map[name:macOS os:macos-latest]) (push) Has been cancelled
test `@tauri-apps/cli` / test (windows-latest) (push) Has been cancelled
test core / test (map[args:--all-features key:all], map[cargo:cargo command:test os:windows-latest target:x86_64-pc-windows-msvc toolchain:1.90]) (push) Has been cancelled
test core / test (map[args:--no-default-features key:no-default], map[cargo:cargo command:build os:macos-latest target:aarch64-apple-ios toolchain:1.90]) (push) Has been cancelled
test core / test (map[args:--no-default-features key:no-default], map[cargo:cargo command:test os:macos-14 target:aarch64-apple-darwin toolchain:1.90]) (push) Has been cancelled
test core / test (map[args:--no-default-features key:no-default], map[cargo:cargo command:test os:windows-latest target:x86_64-pc-windows-msvc toolchain:1.90]) (push) Has been cancelled
This commit is contained in:
60
crates/tauri/mobile/android-codegen/TauriActivity.kt
Normal file
60
crates/tauri/mobile/android-codegen/TauriActivity.kt
Normal file
@@ -0,0 +1,60 @@
|
||||
// Copyright 2019-2024 Tauri Programme within The Commons Conservancy
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
/* THIS FILE IS AUTO-GENERATED. DO NOT MODIFY!! */
|
||||
|
||||
package {{package}}
|
||||
|
||||
import android.content.Intent
|
||||
import android.content.res.Configuration
|
||||
import android.os.Bundle
|
||||
import app.tauri.plugin.PluginManager
|
||||
|
||||
abstract class TauriActivity : WryActivity() {
|
||||
override val handleBackNavigation: Boolean = false
|
||||
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
super.onCreate(savedInstanceState)
|
||||
PluginManager.onCreate(this)
|
||||
}
|
||||
|
||||
fun getPluginManager(): PluginManager {
|
||||
return PluginManager
|
||||
}
|
||||
|
||||
override fun onNewIntent(intent: Intent) {
|
||||
super.onNewIntent(intent)
|
||||
PluginManager.onNewIntent(intent)
|
||||
}
|
||||
|
||||
override fun onRestart() {
|
||||
super.onRestart()
|
||||
PluginManager.onRestart(this)
|
||||
}
|
||||
|
||||
override fun onResume() {
|
||||
super.onResume()
|
||||
PluginManager.onResume(this)
|
||||
}
|
||||
|
||||
override fun onPause() {
|
||||
super.onPause()
|
||||
PluginManager.onPause(this)
|
||||
}
|
||||
|
||||
override fun onStop() {
|
||||
super.onStop()
|
||||
PluginManager.onStop(this)
|
||||
}
|
||||
|
||||
override fun onDestroy() {
|
||||
super.onDestroy()
|
||||
PluginManager.onDestroy(this)
|
||||
}
|
||||
|
||||
override fun onConfigurationChanged(newConfig: Configuration) {
|
||||
super.onConfigurationChanged(newConfig)
|
||||
PluginManager.onConfigurationChanged(newConfig)
|
||||
}
|
||||
}
|
||||
1
crates/tauri/mobile/android/.gitignore
vendored
Normal file
1
crates/tauri/mobile/android/.gitignore
vendored
Normal file
@@ -0,0 +1 @@
|
||||
/build
|
||||
48
crates/tauri/mobile/android/build.gradle.kts
Normal file
48
crates/tauri/mobile/android/build.gradle.kts
Normal file
@@ -0,0 +1,48 @@
|
||||
import org.jetbrains.kotlin.gradle.dsl.JvmTarget
|
||||
|
||||
plugins {
|
||||
id("com.android.library")
|
||||
id("org.jetbrains.kotlin.android")
|
||||
}
|
||||
|
||||
android {
|
||||
namespace = "app.tauri"
|
||||
compileSdk = 36
|
||||
|
||||
defaultConfig {
|
||||
minSdk = 21
|
||||
|
||||
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
|
||||
consumerProguardFiles("consumer-rules.pro")
|
||||
}
|
||||
|
||||
compileOptions {
|
||||
sourceCompatibility = JavaVersion.VERSION_1_8
|
||||
targetCompatibility = JavaVersion.VERSION_1_8
|
||||
}
|
||||
buildFeatures {
|
||||
buildConfig = true
|
||||
}
|
||||
}
|
||||
|
||||
kotlin {
|
||||
compilerOptions {
|
||||
jvmTarget = JvmTarget.JVM_1_8
|
||||
}
|
||||
}
|
||||
|
||||
dependencies {
|
||||
|
||||
implementation("androidx.core:core-ktx:1.7.0")
|
||||
implementation("androidx.appcompat:appcompat:1.6.0")
|
||||
implementation("com.google.android.material:material:1.7.0")
|
||||
implementation("com.fasterxml.jackson.core:jackson-databind:2.15.3")
|
||||
// (de)serializes org.json values (JSObject, JSArray) as JSON objects and arrays
|
||||
// instead of as beans; org.json itself is part of the Android platform
|
||||
implementation("com.fasterxml.jackson.datatype:jackson-datatype-json-org:2.15.3") {
|
||||
exclude(group = "org.json", module = "json")
|
||||
}
|
||||
testImplementation("junit:junit:4.13.2")
|
||||
androidTestImplementation("androidx.test.ext:junit:1.1.5")
|
||||
androidTestImplementation("androidx.test.espresso:espresso-core:3.5.1")
|
||||
}
|
||||
41
crates/tauri/mobile/android/consumer-rules.pro
Normal file
41
crates/tauri/mobile/android/consumer-rules.pro
Normal file
@@ -0,0 +1,41 @@
|
||||
-keep class app.tauri.** {
|
||||
@app.tauri.JniMethod public <methods>;
|
||||
native <methods>;
|
||||
}
|
||||
|
||||
-keep class app.tauri.plugin.JSArray {
|
||||
public <init>(...);
|
||||
}
|
||||
|
||||
-keepclassmembers class org.json.JSONArray {
|
||||
public put(...);
|
||||
}
|
||||
|
||||
-keep class app.tauri.plugin.JSObject {
|
||||
public <init>(...);
|
||||
public put(...);
|
||||
}
|
||||
|
||||
-keep @app.tauri.annotation.TauriPlugin public class * {
|
||||
@app.tauri.annotation.Command public <methods>;
|
||||
@app.tauri.annotation.PermissionCallback <methods>;
|
||||
@app.tauri.annotation.ActivityCallback <methods>;
|
||||
@app.tauri.annotation.Permission <methods>;
|
||||
public <init>(...);
|
||||
}
|
||||
|
||||
-keep @app.tauri.annotation.InvokeArg public class * {
|
||||
*;
|
||||
}
|
||||
|
||||
-keep @com.fasterxml.jackson.databind.annotation.JsonDeserialize public class * {
|
||||
*;
|
||||
}
|
||||
|
||||
-keep @com.fasterxml.jackson.databind.annotation.JsonSerialize public class * {
|
||||
*;
|
||||
}
|
||||
|
||||
-keep class * extends com.fasterxml.jackson.databind.JsonDeserializer { *; }
|
||||
|
||||
-keep class * extends com.fasterxml.jackson.databind.JsonSerializer { *; }
|
||||
@@ -0,0 +1,28 @@
|
||||
// Copyright 2019-2024 Tauri Programme within The Commons Conservancy
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package app.tauri
|
||||
|
||||
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("app.tauri.test", appContext.packageName)
|
||||
}
|
||||
}
|
||||
3
crates/tauri/mobile/android/src/main/AndroidManifest.xml
Normal file
3
crates/tauri/mobile/android/src/main/AndroidManifest.xml
Normal file
@@ -0,0 +1,3 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
</manifest>
|
||||
@@ -0,0 +1,56 @@
|
||||
// Copyright 2019-2024 Tauri Programme within The Commons Conservancy
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package app.tauri
|
||||
|
||||
import android.app.Activity
|
||||
import android.webkit.WebView
|
||||
import androidx.activity.OnBackPressedCallback
|
||||
import androidx.appcompat.app.AppCompatActivity
|
||||
import app.tauri.annotation.Command
|
||||
import app.tauri.annotation.TauriPlugin
|
||||
import app.tauri.plugin.Plugin
|
||||
import app.tauri.plugin.Invoke
|
||||
import app.tauri.plugin.JSObject
|
||||
|
||||
@TauriPlugin
|
||||
class AppPlugin(private val activity: Activity): Plugin(activity) {
|
||||
private val BACK_BUTTON_EVENT = "back-button"
|
||||
|
||||
private var webView: WebView? = null
|
||||
|
||||
override fun load(webView: WebView) {
|
||||
this.webView = webView
|
||||
}
|
||||
|
||||
init {
|
||||
val callback = object : OnBackPressedCallback(true) {
|
||||
override fun handleOnBackPressed() {
|
||||
if (!hasListener(BACK_BUTTON_EVENT)) {
|
||||
if (this@AppPlugin.webView?.canGoBack() == true) {
|
||||
this@AppPlugin.webView!!.goBack()
|
||||
} else {
|
||||
this.isEnabled = false
|
||||
this@AppPlugin.activity.onBackPressed()
|
||||
this.isEnabled = true
|
||||
}
|
||||
} else {
|
||||
val data = JSObject().apply {
|
||||
put("canGoBack", this@AppPlugin.webView?.canGoBack() ?: false)
|
||||
}
|
||||
trigger(BACK_BUTTON_EVENT, data)
|
||||
}
|
||||
}
|
||||
}
|
||||
(activity as AppCompatActivity).onBackPressedDispatcher.addCallback(activity, callback)
|
||||
}
|
||||
|
||||
// Called by the Rust side when the app exits so the activity (and any other activity
|
||||
// of the task, i.e. other windows) is closed gracefully instead of the process being killed
|
||||
@Command
|
||||
fun exit(invoke: Invoke) {
|
||||
invoke.resolve()
|
||||
activity.finishAffinity()
|
||||
}
|
||||
}
|
||||
208
crates/tauri/mobile/android/src/main/java/app/tauri/FsUtils.kt
Normal file
208
crates/tauri/mobile/android/src/main/java/app/tauri/FsUtils.kt
Normal file
@@ -0,0 +1,208 @@
|
||||
// Copyright 2019-2024 Tauri Programme within The Commons Conservancy
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package app.tauri
|
||||
|
||||
import android.content.ContentUris
|
||||
import android.content.Context
|
||||
import android.content.res.AssetManager
|
||||
import android.database.Cursor
|
||||
import android.net.Uri
|
||||
import android.os.Environment
|
||||
import android.provider.DocumentsContract
|
||||
import android.provider.MediaStore
|
||||
import android.provider.OpenableColumns
|
||||
import java.io.File
|
||||
import java.io.FileOutputStream
|
||||
import kotlin.math.min
|
||||
|
||||
internal class FsUtils {
|
||||
companion object {
|
||||
fun readAsset(assetManager: AssetManager, fileName: String): String {
|
||||
assetManager.open(fileName).bufferedReader().use {
|
||||
return it.readText()
|
||||
}
|
||||
}
|
||||
|
||||
fun getFileUrlForUri(context: Context, uri: Uri): String? {
|
||||
// DocumentProvider
|
||||
if (DocumentsContract.isDocumentUri(context, uri)) {
|
||||
// ExternalStorageProvider
|
||||
if (isExternalStorageDocument(uri)) {
|
||||
val docId: String = DocumentsContract.getDocumentId(uri)
|
||||
val split = docId.split(":".toRegex()).dropLastWhile { it.isEmpty() }
|
||||
.toTypedArray()
|
||||
val type = split[0]
|
||||
if ("primary".equals(type, ignoreCase = true)) {
|
||||
return legacyPrimaryPath(split[1])
|
||||
} else {
|
||||
val splitIndex = docId.indexOf(':', 1)
|
||||
val tag = docId.substring(0, splitIndex)
|
||||
val path = docId.substring(splitIndex + 1)
|
||||
val nonPrimaryVolume = getPathToNonPrimaryVolume(context, tag)
|
||||
if (nonPrimaryVolume != null) {
|
||||
val result = "$nonPrimaryVolume/$path"
|
||||
val file = File(result)
|
||||
return if (file.exists() && file.canRead()) {
|
||||
result
|
||||
} else null
|
||||
}
|
||||
}
|
||||
} else if (isDownloadsDocument(uri)) {
|
||||
val id: String = DocumentsContract.getDocumentId(uri)
|
||||
val contentUri: Uri = ContentUris.withAppendedId(
|
||||
Uri.parse("content://downloads/public_downloads"),
|
||||
java.lang.Long.valueOf(id)
|
||||
)
|
||||
return getDataColumn(context, contentUri, null, null)
|
||||
} else if (isMediaDocument(uri)) {
|
||||
val docId: String = DocumentsContract.getDocumentId(uri)
|
||||
val split = docId.split(":".toRegex()).dropLastWhile { it.isEmpty() }
|
||||
.toTypedArray()
|
||||
val type = split[0]
|
||||
var contentUri: Uri? = null
|
||||
when (type) {
|
||||
"image" -> {
|
||||
contentUri = MediaStore.Images.Media.EXTERNAL_CONTENT_URI
|
||||
}
|
||||
"video" -> {
|
||||
contentUri = MediaStore.Video.Media.EXTERNAL_CONTENT_URI
|
||||
}
|
||||
"audio" -> {
|
||||
contentUri = MediaStore.Audio.Media.EXTERNAL_CONTENT_URI
|
||||
}
|
||||
}
|
||||
val selection = "_id=?"
|
||||
val selectionArgs = arrayOf(split[1])
|
||||
if (contentUri != null) {
|
||||
return getDataColumn(context, contentUri, selection, selectionArgs)
|
||||
}
|
||||
}
|
||||
} else if ("content".equals(uri.scheme, ignoreCase = true)) {
|
||||
// Return the remote address
|
||||
return if (isGooglePhotosUri(uri)) uri.lastPathSegment else getDataColumn(
|
||||
context,
|
||||
uri,
|
||||
null,
|
||||
null
|
||||
)
|
||||
} else if ("file".equals(uri.scheme, ignoreCase = true)) {
|
||||
return uri.path
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the value of the data column for this Uri. This is useful for
|
||||
* MediaStore Uris, and other file-based ContentProviders.
|
||||
*
|
||||
* @param context The context.
|
||||
* @param uri The Uri to query.
|
||||
* @param selection (Optional) Filter used in the query.
|
||||
* @param selectionArgs (Optional) Selection arguments used in the query.
|
||||
* @return The value of the _data column, which is typically a file path.
|
||||
*/
|
||||
private fun getDataColumn(
|
||||
context: Context,
|
||||
uri: Uri,
|
||||
selection: String?,
|
||||
selectionArgs: Array<String>?
|
||||
): String? {
|
||||
var path: String? = null
|
||||
var cursor: Cursor? = null
|
||||
val column = "_data"
|
||||
val projection = arrayOf(column)
|
||||
try {
|
||||
cursor = context.contentResolver.query(uri, projection, selection, selectionArgs, null)
|
||||
if (cursor != null && cursor.moveToFirst()) {
|
||||
val index = cursor.getColumnIndexOrThrow(column)
|
||||
path = cursor.getString(index)
|
||||
}
|
||||
} catch (ex: IllegalArgumentException) {
|
||||
return getCopyFilePath(uri, context)
|
||||
} finally {
|
||||
cursor?.close()
|
||||
}
|
||||
return path ?: getCopyFilePath(uri, context)
|
||||
}
|
||||
|
||||
private fun getCopyFilePath(uri: Uri, context: Context): String? {
|
||||
val cursor = context.contentResolver.query(uri, null, null, null, null)!!
|
||||
val nameIndex = cursor.getColumnIndex(OpenableColumns.DISPLAY_NAME)
|
||||
cursor.moveToFirst()
|
||||
val name = cursor.getString(nameIndex)
|
||||
val file = File(context.filesDir, name)
|
||||
try {
|
||||
val inputStream = context.contentResolver.openInputStream(uri)
|
||||
val outputStream = FileOutputStream(file)
|
||||
var read: Int
|
||||
val maxBufferSize = 1024 * 1024
|
||||
val bufferSize = min(inputStream!!.available(), maxBufferSize)
|
||||
val buffers = ByteArray(bufferSize)
|
||||
while (inputStream.read(buffers).also { read = it } != -1) {
|
||||
outputStream.write(buffers, 0, read)
|
||||
}
|
||||
inputStream.close()
|
||||
outputStream.close()
|
||||
} catch (e: Exception) {
|
||||
return null
|
||||
} finally {
|
||||
cursor.close()
|
||||
}
|
||||
return file.path
|
||||
}
|
||||
|
||||
private fun legacyPrimaryPath(pathPart: String): String {
|
||||
return Environment.getExternalStorageDirectory().toString() + "/" + pathPart
|
||||
}
|
||||
|
||||
/**
|
||||
* @param uri The Uri to check.
|
||||
* @return Whether the Uri authority is ExternalStorageProvider.
|
||||
*/
|
||||
private fun isExternalStorageDocument(uri: Uri): Boolean {
|
||||
return "com.android.externalstorage.documents" == uri.authority
|
||||
}
|
||||
|
||||
/**
|
||||
* @param uri The Uri to check.
|
||||
* @return Whether the Uri authority is DownloadsProvider.
|
||||
*/
|
||||
private fun isDownloadsDocument(uri: Uri): Boolean {
|
||||
return "com.android.providers.downloads.documents" == uri.authority
|
||||
}
|
||||
|
||||
/**
|
||||
* @param uri The Uri to check.
|
||||
* @return Whether the Uri authority is MediaProvider.
|
||||
*/
|
||||
private fun isMediaDocument(uri: Uri): Boolean {
|
||||
return "com.android.providers.media.documents" == uri.authority
|
||||
}
|
||||
|
||||
/**
|
||||
* @param uri The Uri to check.
|
||||
* @return Whether the Uri authority is Google Photos.
|
||||
*/
|
||||
private fun isGooglePhotosUri(uri: Uri): Boolean {
|
||||
return "com.google.android.apps.photos.content" == uri.authority
|
||||
}
|
||||
|
||||
private fun getPathToNonPrimaryVolume(context: Context, tag: String): String? {
|
||||
val volumes = context.externalCacheDirs
|
||||
if (volumes != null) {
|
||||
for (volume in volumes) {
|
||||
if (volume != null) {
|
||||
val path = volume.absolutePath
|
||||
val index = path.indexOf(tag)
|
||||
if (index != -1) {
|
||||
return path.substring(0, index) + tag
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return null
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
// Copyright 2019-2024 Tauri Programme within The Commons Conservancy
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package app.tauri
|
||||
|
||||
@Retention(AnnotationRetention.RUNTIME)
|
||||
internal annotation class JniMethod
|
||||
@@ -0,0 +1,85 @@
|
||||
// Copyright 2019-2024 Tauri Programme within The Commons Conservancy
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package app.tauri
|
||||
|
||||
// taken from https://github.com/ionic-team/capacitor/blob/6658bca41e78239347e458175b14ca8bd5c1d6e8/android/capacitor/src/main/java/com/getcapacitor/Logger.java
|
||||
|
||||
import android.text.TextUtils;
|
||||
import android.util.Log;
|
||||
|
||||
class Logger {
|
||||
companion object {
|
||||
private const val LOG_TAG_CORE = "Tauri"
|
||||
|
||||
fun tags(vararg subtags: String): String {
|
||||
return if (subtags.isNotEmpty()) {
|
||||
LOG_TAG_CORE + "/" + TextUtils.join("/", subtags)
|
||||
} else LOG_TAG_CORE
|
||||
}
|
||||
|
||||
fun verbose(message: String) {
|
||||
verbose(LOG_TAG_CORE, message)
|
||||
}
|
||||
|
||||
fun verbose(tag: String, message: String) {
|
||||
if (!shouldLog()) {
|
||||
return
|
||||
}
|
||||
Log.v(tag, message)
|
||||
}
|
||||
|
||||
fun debug(message: String) {
|
||||
debug(LOG_TAG_CORE, message)
|
||||
}
|
||||
|
||||
fun debug(tag: String, message: String) {
|
||||
if (!shouldLog()) {
|
||||
return
|
||||
}
|
||||
Log.d(tag, message)
|
||||
}
|
||||
|
||||
fun info(message: String) {
|
||||
info(LOG_TAG_CORE, message)
|
||||
}
|
||||
|
||||
fun info(tag: String, message: String) {
|
||||
if (!shouldLog()) {
|
||||
return
|
||||
}
|
||||
Log.i(tag, message)
|
||||
}
|
||||
|
||||
fun warn(message: String) {
|
||||
warn(LOG_TAG_CORE, message)
|
||||
}
|
||||
|
||||
fun warn(tag: String, message: String) {
|
||||
if (!shouldLog()) {
|
||||
return
|
||||
}
|
||||
Log.w(tag, message)
|
||||
}
|
||||
|
||||
fun error(message: String) {
|
||||
error(LOG_TAG_CORE, message, null)
|
||||
}
|
||||
|
||||
fun error(message: String, e: Throwable?) {
|
||||
error(LOG_TAG_CORE, message, e)
|
||||
}
|
||||
|
||||
fun error(tag: String, message: String, e: Throwable?) {
|
||||
if (!shouldLog()) {
|
||||
return
|
||||
}
|
||||
Log.e(tag, message, e)
|
||||
}
|
||||
|
||||
private fun shouldLog(): Boolean {
|
||||
return BuildConfig.DEBUG
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,132 @@
|
||||
// Copyright 2019-2024 Tauri Programme within The Commons Conservancy
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package app.tauri
|
||||
|
||||
import android.app.Activity
|
||||
import android.database.Cursor
|
||||
import android.net.Uri
|
||||
import android.os.Build
|
||||
import android.os.Environment
|
||||
import android.provider.OpenableColumns
|
||||
import app.tauri.annotation.Command
|
||||
import app.tauri.annotation.InvokeArg
|
||||
import app.tauri.annotation.TauriPlugin
|
||||
import app.tauri.plugin.Plugin
|
||||
import app.tauri.plugin.Invoke
|
||||
import app.tauri.plugin.JSObject
|
||||
|
||||
const val TAURI_ASSETS_DIRECTORY_URI = "asset://localhost/"
|
||||
|
||||
@InvokeArg
|
||||
class GetFileNameFromUriArgs {
|
||||
lateinit var uri: String
|
||||
}
|
||||
|
||||
@TauriPlugin
|
||||
class PathPlugin(private val activity: Activity): Plugin(activity) {
|
||||
private fun resolvePath(invoke: Invoke, path: String?) {
|
||||
val obj = JSObject()
|
||||
obj.put("path", path)
|
||||
invoke.resolve(obj)
|
||||
}
|
||||
|
||||
@Command
|
||||
fun getFileNameFromUri(invoke: Invoke) {
|
||||
val args = invoke.parseArgs(GetFileNameFromUriArgs::class.java)
|
||||
val name = getRealNameFromURI(activity, Uri.parse(args.uri))
|
||||
val res = JSObject()
|
||||
res.put("name", name)
|
||||
invoke.resolve(res)
|
||||
}
|
||||
|
||||
@Command
|
||||
fun getAudioDir(invoke: Invoke) {
|
||||
resolvePath(invoke, activity.getExternalFilesDir(Environment.DIRECTORY_MUSIC)?.absolutePath)
|
||||
}
|
||||
|
||||
@Command
|
||||
fun getExternalCacheDir(invoke: Invoke) {
|
||||
resolvePath(invoke, activity.externalCacheDir?.absolutePath)
|
||||
}
|
||||
|
||||
@Command
|
||||
fun getConfigDir(invoke: Invoke) {
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) {
|
||||
resolvePath(invoke, activity.dataDir.absolutePath)
|
||||
} else {
|
||||
resolvePath(invoke, activity.applicationInfo.dataDir)
|
||||
}
|
||||
}
|
||||
|
||||
@Command
|
||||
fun getDataDir(invoke: Invoke) {
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) {
|
||||
resolvePath(invoke, activity.dataDir.absolutePath)
|
||||
} else {
|
||||
resolvePath(invoke, activity.applicationInfo.dataDir)
|
||||
}
|
||||
}
|
||||
|
||||
@Command
|
||||
fun getDocumentDir(invoke: Invoke) {
|
||||
resolvePath(invoke, activity.getExternalFilesDir(Environment.DIRECTORY_DOCUMENTS)?.absolutePath)
|
||||
}
|
||||
|
||||
@Command
|
||||
fun getDownloadDir(invoke: Invoke) {
|
||||
resolvePath(invoke, activity.getExternalFilesDir(Environment.DIRECTORY_DOWNLOADS)?.absolutePath)
|
||||
}
|
||||
|
||||
@Command
|
||||
fun getPictureDir(invoke: Invoke) {
|
||||
resolvePath(invoke, activity.getExternalFilesDir(Environment.DIRECTORY_PICTURES)?.absolutePath)
|
||||
}
|
||||
|
||||
@Command
|
||||
fun getPublicDir(invoke: Invoke) {
|
||||
resolvePath(invoke, activity.getExternalFilesDir(Environment.DIRECTORY_DCIM)?.absolutePath)
|
||||
}
|
||||
|
||||
@Command
|
||||
fun getVideoDir(invoke: Invoke) {
|
||||
resolvePath(invoke, activity.getExternalFilesDir(Environment.DIRECTORY_MOVIES)?.absolutePath)
|
||||
}
|
||||
|
||||
@Command
|
||||
fun getResourcesDir(invoke: Invoke) {
|
||||
resolvePath(invoke, TAURI_ASSETS_DIRECTORY_URI)
|
||||
}
|
||||
|
||||
@Command
|
||||
fun getCacheDir(invoke: Invoke) {
|
||||
resolvePath(invoke, activity.cacheDir.absolutePath)
|
||||
}
|
||||
|
||||
@Command
|
||||
fun getHomeDir(invoke: Invoke) {
|
||||
resolvePath(invoke, Environment.getExternalStorageDirectory().absolutePath)
|
||||
}
|
||||
}
|
||||
|
||||
fun getRealNameFromURI(activity: Activity, contentUri: Uri): String? {
|
||||
var cursor: Cursor? = null
|
||||
try {
|
||||
val projection = arrayOf(OpenableColumns.DISPLAY_NAME)
|
||||
cursor = activity.contentResolver.query(contentUri, projection, null, null, null)
|
||||
|
||||
cursor?.let {
|
||||
val columnIndex = it.getColumnIndex(OpenableColumns.DISPLAY_NAME)
|
||||
if (it.moveToFirst()) {
|
||||
return it.getString(columnIndex)
|
||||
}
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
Logger.error("failed to get real name from URI $e")
|
||||
} finally {
|
||||
cursor?.close()
|
||||
}
|
||||
|
||||
return null // Return null if no file name could be resolved
|
||||
}
|
||||
@@ -0,0 +1,107 @@
|
||||
// Copyright 2019-2024 Tauri Programme within The Commons Conservancy
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package app.tauri
|
||||
|
||||
// taken from https://github.com/ionic-team/capacitor/blob/6658bca41e78239347e458175b14ca8bd5c1d6e8/android/capacitor/src/main/java/com/getcapacitor/PermissionHelper.java
|
||||
|
||||
import android.content.Context;
|
||||
import android.content.pm.PackageManager;
|
||||
import android.os.Build;
|
||||
import androidx.core.app.ActivityCompat;
|
||||
import java.util.ArrayList;
|
||||
|
||||
object PermissionHelper {
|
||||
/**
|
||||
* Checks if a list of given permissions are all granted by the user
|
||||
*
|
||||
* @param permissions Permissions to check.
|
||||
* @return True if all permissions are granted, false if at least one is not.
|
||||
*/
|
||||
fun hasPermissions(context: Context?, permissions: Array<String>): Boolean {
|
||||
for (perm in permissions) {
|
||||
if (ActivityCompat.checkSelfPermission(
|
||||
context!!,
|
||||
perm
|
||||
) != PackageManager.PERMISSION_GRANTED
|
||||
) {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
/**
|
||||
* Check whether the given permission has been defined in the AndroidManifest.xml
|
||||
*
|
||||
* @param permission A permission to check.
|
||||
* @return True if the permission has been defined in the Manifest, false if not.
|
||||
*/
|
||||
fun hasDefinedPermission(context: Context, permission: String): Boolean {
|
||||
val requestedPermissions = getManifestPermissions(context)
|
||||
if (!requestedPermissions.isNullOrEmpty()) {
|
||||
val requestedPermissionsList = listOf(*requestedPermissions)
|
||||
val requestedPermissionsArrayList = ArrayList(requestedPermissionsList)
|
||||
if (requestedPermissionsArrayList.contains(permission)) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
/**
|
||||
* Check whether all of the given permissions have been defined in the AndroidManifest.xml
|
||||
* @param context the app context
|
||||
* @param permissions a list of permissions
|
||||
* @return true only if all permissions are defined in the AndroidManifest.xml
|
||||
*/
|
||||
fun hasDefinedPermissions(context: Context, permissions: Array<String>): Boolean {
|
||||
return permissions.all { hasDefinedPermission(context, it) }
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the permissions defined in AndroidManifest.xml
|
||||
*
|
||||
* @return The permissions defined in AndroidManifest.xml
|
||||
*/
|
||||
private fun getManifestPermissions(context: Context): Array<String>? {
|
||||
var requestedPermissions: Array<String>? = null
|
||||
try {
|
||||
val pm = context.packageManager
|
||||
val packageInfo = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
|
||||
pm.getPackageInfo(context.packageName, PackageManager.PackageInfoFlags.of(PackageManager.GET_PERMISSIONS.toLong()))
|
||||
} else {
|
||||
@Suppress("DEPRECATION")
|
||||
pm.getPackageInfo(context.packageName, PackageManager.GET_PERMISSIONS)
|
||||
}
|
||||
if (packageInfo != null) {
|
||||
requestedPermissions = packageInfo.requestedPermissions
|
||||
}
|
||||
} catch (_: Exception) {
|
||||
}
|
||||
return requestedPermissions
|
||||
}
|
||||
|
||||
/**
|
||||
* Given a list of permissions, return a new list with the ones not present in AndroidManifest.xml
|
||||
*
|
||||
* @param neededPermissions The permissions needed.
|
||||
* @return The permissions not present in AndroidManifest.xml
|
||||
*/
|
||||
fun getUndefinedPermissions(context: Context, neededPermissions: Array<String>): Array<String> {
|
||||
val requestedPermissions = getManifestPermissions(context)
|
||||
if (!requestedPermissions.isNullOrEmpty()) {
|
||||
val undefinedPermissions = ArrayList<String>()
|
||||
val requestedPermissionsList = listOf(*requestedPermissions)
|
||||
val requestedPermissionsArrayList = ArrayList(requestedPermissionsList)
|
||||
for (permission in neededPermissions) {
|
||||
if (!requestedPermissionsArrayList.contains(permission)) {
|
||||
undefinedPermissions.add(permission)
|
||||
}
|
||||
}
|
||||
return undefinedPermissions.toTypedArray()
|
||||
}
|
||||
return neededPermissions
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
// Copyright 2019-2024 Tauri Programme within The Commons Conservancy
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package app.tauri
|
||||
|
||||
import java.util.*
|
||||
|
||||
enum class PermissionState(private val state: String) {
|
||||
GRANTED("granted"), DENIED("denied"), PROMPT("prompt"), PROMPT_WITH_RATIONALE("prompt-with-rationale");
|
||||
|
||||
override fun toString(): String {
|
||||
return state
|
||||
}
|
||||
|
||||
companion object {
|
||||
fun byState(state: String): PermissionState {
|
||||
return valueOf(state.uppercase(Locale.ROOT).replace('-', '_'))
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
// Copyright 2019-2024 Tauri Programme within The Commons Conservancy
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package app.tauri.annotation
|
||||
|
||||
@Retention(AnnotationRetention.RUNTIME)
|
||||
@Target(AnnotationTarget.FUNCTION)
|
||||
annotation class ActivityCallback
|
||||
@@ -0,0 +1,9 @@
|
||||
// Copyright 2019-2024 Tauri Programme within The Commons Conservancy
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package app.tauri.annotation
|
||||
|
||||
@Retention(AnnotationRetention.RUNTIME)
|
||||
@Target(AnnotationTarget.CLASS)
|
||||
annotation class InvokeArg
|
||||
@@ -0,0 +1,19 @@
|
||||
// Copyright 2019-2024 Tauri Programme within The Commons Conservancy
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package app.tauri.annotation
|
||||
|
||||
@Retention(AnnotationRetention.RUNTIME)
|
||||
annotation class Permission(
|
||||
/**
|
||||
* An array of Android permission strings.
|
||||
* Eg: {Manifest.permission.ACCESS_COARSE_LOCATION}
|
||||
* or {"android.permission.ACCESS_COARSE_LOCATION"}
|
||||
*/
|
||||
val strings: Array<String> = [],
|
||||
/**
|
||||
* An optional name to use instead of the Android permission string.
|
||||
*/
|
||||
val alias: String = ""
|
||||
)
|
||||
@@ -0,0 +1,9 @@
|
||||
// Copyright 2019-2024 Tauri Programme within The Commons Conservancy
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package app.tauri.annotation
|
||||
|
||||
@Retention(AnnotationRetention.RUNTIME)
|
||||
@Target(AnnotationTarget.FUNCTION)
|
||||
annotation class PermissionCallback
|
||||
@@ -0,0 +1,8 @@
|
||||
// Copyright 2019-2024 Tauri Programme within The Commons Conservancy
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package app.tauri.annotation
|
||||
|
||||
@Retention(AnnotationRetention.RUNTIME)
|
||||
annotation class Command
|
||||
@@ -0,0 +1,19 @@
|
||||
// Copyright 2019-2024 Tauri Programme within The Commons Conservancy
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package app.tauri.annotation
|
||||
|
||||
import app.tauri.annotation.Permission
|
||||
|
||||
/**
|
||||
* Base annotation for all Plugins
|
||||
*/
|
||||
@Retention(AnnotationRetention.RUNTIME)
|
||||
annotation class TauriPlugin(
|
||||
/**
|
||||
* Permissions this plugin needs, in order to make permission requests
|
||||
* easy if the plugin only needs basic permission prompting
|
||||
*/
|
||||
val permissions: Array<Permission> = []
|
||||
)
|
||||
@@ -0,0 +1,33 @@
|
||||
// Copyright 2019-2024 Tauri Programme within The Commons Conservancy
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package app.tauri.plugin
|
||||
|
||||
import com.fasterxml.jackson.core.JsonParser
|
||||
import com.fasterxml.jackson.databind.DeserializationContext
|
||||
import com.fasterxml.jackson.databind.JsonDeserializer
|
||||
import com.fasterxml.jackson.databind.ObjectMapper
|
||||
|
||||
const val CHANNEL_PREFIX = "__CHANNEL__:"
|
||||
|
||||
internal class ChannelDeserializer(val sendChannelData: (channelId: Long, data: String) -> Unit, private val objectMapper: ObjectMapper): JsonDeserializer<Channel>() {
|
||||
override fun deserialize(
|
||||
jsonParser: JsonParser?,
|
||||
deserializationContext: DeserializationContext
|
||||
): Channel {
|
||||
val channelDef = deserializationContext.readValue(jsonParser, String::class.java)
|
||||
val callback = channelDef.substring(CHANNEL_PREFIX.length).toLongOrNull() ?: throw Error("unexpected channel value $channelDef")
|
||||
return Channel(callback, { res -> sendChannelData(callback, res) }, objectMapper)
|
||||
}
|
||||
}
|
||||
|
||||
class Channel(val id: Long, private val handler: (data: String) -> Unit, private val objectMapper: ObjectMapper) {
|
||||
fun send(data: JSObject) {
|
||||
handler(PluginResult(data).toString())
|
||||
}
|
||||
|
||||
fun sendObject(data: Any) {
|
||||
handler(objectMapper.writeValueAsString(data))
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
// Copyright 2019-2024 Tauri Programme within The Commons Conservancy
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package app.tauri.plugin
|
||||
|
||||
internal class InvalidCommandException : Exception {
|
||||
constructor(s: String?) : super(s) {}
|
||||
constructor(t: Throwable?) : super(t) {}
|
||||
constructor(s: String?, t: Throwable?) : super(s, t) {}
|
||||
}
|
||||
@@ -0,0 +1,93 @@
|
||||
// Copyright 2019-2024 Tauri Programme within The Commons Conservancy
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package app.tauri.plugin
|
||||
|
||||
import app.tauri.Logger
|
||||
import com.fasterxml.jackson.core.type.TypeReference
|
||||
import com.fasterxml.jackson.databind.ObjectMapper
|
||||
|
||||
class Invoke(
|
||||
val id: Long,
|
||||
val command: String,
|
||||
val callback: Long,
|
||||
val error: Long,
|
||||
private val sendResponse: (callback: Long, data: String) -> Unit,
|
||||
private val argsJson: String,
|
||||
private val jsonMapper: ObjectMapper
|
||||
) {
|
||||
fun getRawArgs(): String {
|
||||
return argsJson
|
||||
}
|
||||
|
||||
fun getArgs(): JSObject {
|
||||
return JSObject(argsJson)
|
||||
}
|
||||
|
||||
fun<T> parseArgs(cls: Class<T>): T {
|
||||
return jsonMapper.readValue(argsJson, cls)
|
||||
}
|
||||
|
||||
fun<T> parseArgs(ref: TypeReference<T>): T {
|
||||
return jsonMapper.readValue(argsJson, ref)
|
||||
}
|
||||
|
||||
fun resolve(data: JSObject?) {
|
||||
sendResponse(callback, PluginResult(data).toString())
|
||||
}
|
||||
|
||||
fun resolveObject(data: Any) {
|
||||
sendResponse(callback, jsonMapper.writeValueAsString(data))
|
||||
}
|
||||
|
||||
fun resolve() {
|
||||
sendResponse(callback, "null")
|
||||
}
|
||||
|
||||
fun reject(msg: String?, code: String?, ex: Exception?, data: JSObject?) {
|
||||
val errorResult = PluginResult()
|
||||
|
||||
if (ex != null) {
|
||||
Logger.error(Logger.tags("Plugin"), msg!!, ex)
|
||||
}
|
||||
|
||||
errorResult.put("message", msg)
|
||||
if (code != null) {
|
||||
errorResult.put("code", code)
|
||||
}
|
||||
if (data != null) {
|
||||
errorResult.put("data", data)
|
||||
}
|
||||
|
||||
sendResponse(error, errorResult.toString())
|
||||
}
|
||||
|
||||
fun reject(msg: String?, ex: Exception?, data: JSObject?) {
|
||||
reject(msg, null, ex, data)
|
||||
}
|
||||
|
||||
fun reject(msg: String?, code: String?, data: JSObject?) {
|
||||
reject(msg, code, null, data)
|
||||
}
|
||||
|
||||
fun reject(msg: String?, code: String?, ex: Exception?) {
|
||||
reject(msg, code, ex, null)
|
||||
}
|
||||
|
||||
fun reject(msg: String?, data: JSObject?) {
|
||||
reject(msg, null, null, data)
|
||||
}
|
||||
|
||||
fun reject(msg: String?, ex: Exception?) {
|
||||
reject(msg, null, ex, null)
|
||||
}
|
||||
|
||||
fun reject(msg: String?, code: String?) {
|
||||
reject(msg, code, null, null)
|
||||
}
|
||||
|
||||
fun reject(msg: String?) {
|
||||
reject(msg, null, null, null)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
// Copyright 2019-2024 Tauri Programme within The Commons Conservancy
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package app.tauri.plugin
|
||||
|
||||
import org.json.JSONArray
|
||||
import org.json.JSONException
|
||||
|
||||
class JSArray : JSONArray {
|
||||
constructor() : super() {}
|
||||
constructor(json: String?) : super(json) {}
|
||||
constructor(copyFrom: Collection<*>?) : super(copyFrom) {}
|
||||
constructor(array: Any?) : super(array) {}
|
||||
|
||||
@Suppress("UNCHECKED_CAST", "ASSIGNED_BUT_NEVER_ACCESSED_VARIABLE")
|
||||
@Throws(JSONException::class)
|
||||
fun <E> toList(): List<E> {
|
||||
val items: MutableList<E> = ArrayList()
|
||||
var o: Any? = null
|
||||
for (i in 0 until this.length()) {
|
||||
this.get(i).also { o = it }
|
||||
try {
|
||||
items.add(this.get(i) as E)
|
||||
} catch (ex: Exception) {
|
||||
throw JSONException("Not all items are instances of the given type")
|
||||
}
|
||||
}
|
||||
return items
|
||||
}
|
||||
|
||||
companion object {
|
||||
/**
|
||||
* Create a new JSArray without throwing a error
|
||||
*/
|
||||
fun from(array: Any?): JSArray? {
|
||||
try {
|
||||
return JSArray(array)
|
||||
} catch (ex: JSONException) {
|
||||
//
|
||||
}
|
||||
return null
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,152 @@
|
||||
// Copyright 2019-2024 Tauri Programme within The Commons Conservancy
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package app.tauri.plugin
|
||||
|
||||
import org.json.JSONException
|
||||
import org.json.JSONObject
|
||||
|
||||
class JSObject : JSONObject {
|
||||
constructor() : super()
|
||||
constructor(json: String) : super(json)
|
||||
constructor(obj: JSONObject, names: Array<String>) : super(obj, names)
|
||||
|
||||
override fun getString(key: String): String {
|
||||
return getString(key, "")!!
|
||||
}
|
||||
|
||||
fun getString(key: String, defaultValue: String?): String? {
|
||||
try {
|
||||
if (!super.isNull(key)) {
|
||||
return super.getString(key)
|
||||
}
|
||||
} catch (_: JSONException) {
|
||||
}
|
||||
return defaultValue
|
||||
}
|
||||
|
||||
fun getInteger(key: String): Int? {
|
||||
return getIntegerInternal(key, null)
|
||||
}
|
||||
|
||||
fun getInteger(key: String, defaultValue: Int): Int {
|
||||
return getIntegerInternal(key, defaultValue)!!
|
||||
}
|
||||
|
||||
private fun getIntegerInternal(key: String, defaultValue: Int?): Int? {
|
||||
try {
|
||||
return super.getInt(key)
|
||||
} catch (_: JSONException) {
|
||||
}
|
||||
return defaultValue
|
||||
}
|
||||
|
||||
override fun getBoolean(key: String): Boolean {
|
||||
return getBooleanInternal(key, false)!!
|
||||
}
|
||||
|
||||
fun getBoolean(key: String, defaultValue: Boolean?): Boolean {
|
||||
return getBooleanInternal(key, defaultValue)!!
|
||||
}
|
||||
|
||||
private fun getBooleanInternal(key: String, defaultValue: Boolean?): Boolean? {
|
||||
try {
|
||||
return super.getBoolean(key)
|
||||
} catch (_: JSONException) {
|
||||
}
|
||||
return defaultValue
|
||||
}
|
||||
|
||||
fun getJSObject(name: String): JSObject? {
|
||||
try {
|
||||
return getJSObjectInternal(name, null)
|
||||
} catch (_: JSONException) {
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
fun getJSObject(name: String, defaultValue: JSObject): JSObject {
|
||||
return getJSObjectInternal(name, defaultValue)!!
|
||||
}
|
||||
|
||||
private fun getJSObjectInternal(name: String, defaultValue: JSObject?): JSObject? {
|
||||
try {
|
||||
val obj = get(name)
|
||||
if (obj is JSONObject) {
|
||||
val keysIter = obj.keys()
|
||||
val keys: MutableList<String> = ArrayList()
|
||||
while (keysIter.hasNext()) {
|
||||
keys.add(keysIter.next())
|
||||
}
|
||||
return JSObject(obj, keys.toTypedArray())
|
||||
}
|
||||
} catch (_: JSONException) {
|
||||
}
|
||||
return defaultValue
|
||||
}
|
||||
|
||||
override fun put(key: String, value: Boolean): JSObject {
|
||||
try {
|
||||
super.put(key, value)
|
||||
} catch (_: JSONException) {
|
||||
}
|
||||
return this
|
||||
}
|
||||
|
||||
override fun put(key: String, value: Int): JSObject {
|
||||
try {
|
||||
super.put(key, value)
|
||||
} catch (_: JSONException) {
|
||||
}
|
||||
return this
|
||||
}
|
||||
|
||||
override fun put(key: String, value: Long): JSObject {
|
||||
try {
|
||||
super.put(key, value)
|
||||
} catch (_: JSONException) {
|
||||
}
|
||||
return this
|
||||
}
|
||||
|
||||
override fun put(key: String, value: Double): JSObject {
|
||||
try {
|
||||
super.put(key, value)
|
||||
} catch (_: JSONException) {
|
||||
}
|
||||
return this
|
||||
}
|
||||
|
||||
override fun put(key: String, value: Any?): JSObject {
|
||||
try {
|
||||
super.put(key, value)
|
||||
} catch (_: JSONException) {
|
||||
}
|
||||
return this
|
||||
}
|
||||
|
||||
fun put(key: String, value: String?): JSObject {
|
||||
try {
|
||||
super.put(key, value)
|
||||
} catch (_: JSONException) {
|
||||
}
|
||||
return this
|
||||
}
|
||||
|
||||
companion object {
|
||||
/**
|
||||
* Convert a pathetic JSONObject into a JSObject
|
||||
* @param obj
|
||||
*/
|
||||
@Throws(JSONException::class)
|
||||
fun fromJSONObject(obj: JSONObject): JSObject {
|
||||
val keysIter = obj.keys()
|
||||
val keys: MutableList<String> = ArrayList()
|
||||
while (keysIter.hasNext()) {
|
||||
keys.add(keysIter.next())
|
||||
}
|
||||
return JSObject(obj, keys.toTypedArray())
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,536 @@
|
||||
// Copyright 2019-2024 Tauri Programme within The Commons Conservancy
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package app.tauri.plugin
|
||||
|
||||
import android.app.Activity
|
||||
import android.content.res.Configuration
|
||||
import android.content.Intent
|
||||
import android.content.pm.PackageManager
|
||||
import android.net.Uri
|
||||
import android.webkit.WebView
|
||||
import androidx.activity.result.IntentSenderRequest
|
||||
import androidx.appcompat.app.AppCompatActivity
|
||||
import androidx.core.app.ActivityCompat
|
||||
import app.tauri.FsUtils
|
||||
import app.tauri.Logger
|
||||
import app.tauri.PermissionHelper
|
||||
import app.tauri.PermissionState
|
||||
import app.tauri.annotation.ActivityCallback
|
||||
import app.tauri.annotation.Command
|
||||
import app.tauri.annotation.InvokeArg
|
||||
import app.tauri.annotation.PermissionCallback
|
||||
import app.tauri.annotation.TauriPlugin
|
||||
import com.fasterxml.jackson.databind.ObjectMapper
|
||||
import java.util.*
|
||||
import java.util.concurrent.CopyOnWriteArrayList
|
||||
|
||||
@InvokeArg
|
||||
internal class RegisterListenerArgs {
|
||||
lateinit var event: String
|
||||
lateinit var handler: Channel
|
||||
}
|
||||
|
||||
@InvokeArg
|
||||
internal class RemoveListenerArgs {
|
||||
lateinit var event: String
|
||||
var channelId: Long = 0
|
||||
}
|
||||
|
||||
@InvokeArg internal class RequestPermissionsArgs {
|
||||
var permissions: List<String>? = null
|
||||
}
|
||||
|
||||
abstract class Plugin(private val activity: Activity) {
|
||||
var handle: PluginHandle? = null
|
||||
private val listeners: MutableMap<String, MutableList<Channel>> = mutableMapOf()
|
||||
|
||||
open fun load(webView: WebView) {}
|
||||
|
||||
fun jsonMapper(): ObjectMapper {
|
||||
return handle!!.jsonMapper
|
||||
}
|
||||
|
||||
fun<T> getConfig(cls: Class<T>): T {
|
||||
return jsonMapper().readValue(handle!!.config, cls)
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle a new intent being received by the application
|
||||
*/
|
||||
open fun onNewIntent(intent: Intent) {}
|
||||
|
||||
|
||||
/**
|
||||
* This event is called just before another activity comes into the foreground.
|
||||
*/
|
||||
open fun onPause(activity: AppCompatActivity) {}
|
||||
|
||||
/**
|
||||
* This event is called just before another activity comes into the foreground.
|
||||
*/
|
||||
@Deprecated("use onPause(activity: AppCompatActivity) instead")
|
||||
open fun onPause() {}
|
||||
|
||||
/**
|
||||
* This event is called when the user returns to the activity. It is also called on cold starts.
|
||||
*/
|
||||
open fun onResume(activity: AppCompatActivity) {}
|
||||
|
||||
/**
|
||||
* This event is called when the user returns to the activity. It is also called on cold starts.
|
||||
*/
|
||||
@Deprecated("use onResume(activity: AppCompatActivity) instead")
|
||||
open fun onResume() {}
|
||||
|
||||
/**
|
||||
* This event is called after onStop() when the current activity is being re-displayed to the user (the user has navigated back to it).
|
||||
* It will be followed by onStart() and then onResume().
|
||||
*/
|
||||
open fun onRestart(activity: AppCompatActivity) {}
|
||||
|
||||
/**
|
||||
* This event is called after onStop() when the current activity is being re-displayed to the user (the user has navigated back to it).
|
||||
* It will be followed by onStart() and then onResume().
|
||||
*/
|
||||
@Deprecated("use onRestart(activity: AppCompatActivity) instead")
|
||||
open fun onRestart() {}
|
||||
|
||||
/**
|
||||
* This event is called when the app is no longer visible to the user.
|
||||
* You will next receive either onRestart(), onDestroy(), or nothing, depending on later user activity.
|
||||
*/
|
||||
open fun onStop(activity: AppCompatActivity) {}
|
||||
|
||||
/**
|
||||
* This event is called when the app is no longer visible to the user.
|
||||
* You will next receive either onRestart(), onDestroy(), or nothing, depending on later user activity.
|
||||
*/
|
||||
@Deprecated("use onStop(activity: AppCompatActivity) instead")
|
||||
open fun onStop() {}
|
||||
|
||||
/**
|
||||
* This event is called before the activity is destroyed.
|
||||
*/
|
||||
open fun onDestroy(activity: AppCompatActivity) {}
|
||||
/**
|
||||
* This event is called before an activity is destroyed.
|
||||
*/
|
||||
@Deprecated("use onDestroy(activity: AppCompatActivity) instead")
|
||||
open fun onDestroy() {}
|
||||
|
||||
internal fun triggerOnDestroy(activity: AppCompatActivity) {
|
||||
onDestroy(activity)
|
||||
onDestroy()
|
||||
}
|
||||
|
||||
internal fun triggerOnRestart(activity: AppCompatActivity) {
|
||||
onRestart(activity)
|
||||
onRestart()
|
||||
}
|
||||
|
||||
internal fun triggerOnPause(activity: AppCompatActivity) {
|
||||
onPause(activity)
|
||||
onPause()
|
||||
}
|
||||
|
||||
internal fun triggerOnResume(activity: AppCompatActivity) {
|
||||
onResume(activity)
|
||||
onResume()
|
||||
}
|
||||
|
||||
internal fun triggerOnStop(activity: AppCompatActivity) {
|
||||
onStop(activity)
|
||||
onStop()
|
||||
}
|
||||
|
||||
/**
|
||||
* This event is called when a configuration change occurs but the app does not recreate the activity.
|
||||
*/
|
||||
open fun onConfigurationChanged(newConfig: Configuration) {}
|
||||
|
||||
/**
|
||||
* Start activity for result with the provided Intent and resolve calling the provided callback method name.
|
||||
*
|
||||
* If there is no registered activity callback for the method name passed in, the call will
|
||||
* be rejected. Make sure a valid activity result callback method is registered using the
|
||||
* [ActivityCallback] annotation.
|
||||
*
|
||||
* @param invoke the invoke object
|
||||
* @param intent the intent used to start an activity
|
||||
* @param callbackName the name of the callback to run when the launched activity is finished
|
||||
*/
|
||||
fun startActivityForResult(invoke: Invoke, intent: Intent, callbackName: String) {
|
||||
handle!!.startActivityForResult(invoke, intent, callbackName)
|
||||
}
|
||||
|
||||
/**
|
||||
* Like startActivityForResult() but taking an IntentSender to describe the activity to be started.
|
||||
*
|
||||
* If there is no registered activity callback for the method name passed in, the call will
|
||||
* be rejected. Make sure a valid activity result callback method is registered using the
|
||||
* [ActivityCallback] annotation.
|
||||
*
|
||||
* @param invoke the invoke object
|
||||
* @param intentSender the intent used to start an activity
|
||||
* @param callbackName the name of the callback to run when the launched activity is finished
|
||||
*/
|
||||
fun startIntentSenderForResult(invoke: Invoke, intentSender: IntentSenderRequest, callbackName: String) {
|
||||
handle!!.startIntentSenderForResult(invoke, intentSender, callbackName)
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the plugin log tags.
|
||||
* @param subTags
|
||||
*/
|
||||
protected fun getLogTag(vararg subTags: String): String {
|
||||
return Logger.tags(*subTags)
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets a log tag with the plugin's class name as subTag.
|
||||
*/
|
||||
protected fun getLogTag(): String {
|
||||
return Logger.tags(this.javaClass.simpleName)
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert an URI to an URL that can be loaded by the webview.
|
||||
*/
|
||||
fun assetUrl(u: Uri): String {
|
||||
var path = FsUtils.getFileUrlForUri(activity, u)
|
||||
if (path?.startsWith("file://") == true) {
|
||||
path = path.replace("file://", "")
|
||||
}
|
||||
return "asset://localhost$path"
|
||||
}
|
||||
|
||||
fun trigger(event: String, payload: JSObject) {
|
||||
val eventListeners = listeners[event]
|
||||
if (!eventListeners.isNullOrEmpty()) {
|
||||
val listeners = CopyOnWriteArrayList(eventListeners)
|
||||
for (channel in listeners) {
|
||||
channel.send(payload)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun triggerObject(event: String, payload: Any) {
|
||||
val eventListeners = listeners[event]
|
||||
if (!eventListeners.isNullOrEmpty()) {
|
||||
val listeners = CopyOnWriteArrayList(eventListeners)
|
||||
for (channel in listeners) {
|
||||
channel.sendObject(payload)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun hasListener(event: String): Boolean {
|
||||
return !listeners[event].isNullOrEmpty()
|
||||
}
|
||||
|
||||
@Command
|
||||
open fun registerListener(invoke: Invoke) {
|
||||
val args = invoke.parseArgs(RegisterListenerArgs::class.java)
|
||||
|
||||
val eventListeners = listeners[args.event]
|
||||
if (eventListeners.isNullOrEmpty()) {
|
||||
listeners[args.event] = mutableListOf(args.handler)
|
||||
} else {
|
||||
eventListeners.add(args.handler)
|
||||
}
|
||||
|
||||
invoke.resolve()
|
||||
}
|
||||
|
||||
@Command
|
||||
open fun removeListener(invoke: Invoke) {
|
||||
val args = invoke.parseArgs(RemoveListenerArgs::class.java)
|
||||
|
||||
val eventListeners = listeners[args.event]
|
||||
if (!eventListeners.isNullOrEmpty()) {
|
||||
val c = eventListeners.find { c -> c.id == args.channelId }
|
||||
if (c != null) {
|
||||
eventListeners.remove(c)
|
||||
}
|
||||
}
|
||||
|
||||
invoke.resolve()
|
||||
}
|
||||
|
||||
/**
|
||||
* Exported plugin method for checking the granted status for each permission
|
||||
* declared on the plugin. This plugin call responds with a mapping of permissions to
|
||||
* the associated granted status.
|
||||
*/
|
||||
@Command
|
||||
@PermissionCallback
|
||||
open fun checkPermissions(invoke: Invoke) {
|
||||
val permissionsResult: Map<String, PermissionState?> = getPermissionStates()
|
||||
if (permissionsResult.isEmpty()) {
|
||||
// if no permissions are defined on the plugin, resolve undefined
|
||||
invoke.resolve()
|
||||
} else {
|
||||
val permissionsResultJSON = JSObject()
|
||||
for ((key, value) in permissionsResult) {
|
||||
permissionsResultJSON.put(key, value)
|
||||
}
|
||||
invoke.resolve(permissionsResultJSON)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Exported plugin method to request all permissions for this plugin.
|
||||
* To manually request permissions within a plugin use:
|
||||
* [.requestAllPermissions], or
|
||||
* [.requestPermissionForAlias], or
|
||||
* [.requestPermissionForAliases]
|
||||
*
|
||||
* @param invoke
|
||||
*/
|
||||
@Command
|
||||
open fun requestPermissions(invoke: Invoke) {
|
||||
val annotation = handle?.annotation
|
||||
if (annotation != null) {
|
||||
// handle permission requests for plugins defined with @TauriPlugin
|
||||
var permAliases: Array<String>? = null
|
||||
val autoGrantPerms: MutableSet<String> = HashSet()
|
||||
|
||||
val args = invoke.parseArgs(RequestPermissionsArgs::class.java)
|
||||
|
||||
args.permissions?.let {
|
||||
val aliasSet: MutableSet<String> = HashSet()
|
||||
|
||||
for (perm in annotation.permissions) {
|
||||
if (it.contains(perm.alias)) {
|
||||
aliasSet.add(perm.alias)
|
||||
}
|
||||
}
|
||||
if (aliasSet.isEmpty()) {
|
||||
invoke.reject("No valid permission alias was requested of this plugin.")
|
||||
return
|
||||
} else {
|
||||
permAliases = aliasSet.toTypedArray()
|
||||
}
|
||||
} ?: run {
|
||||
val aliasSet: MutableSet<String> = HashSet()
|
||||
|
||||
for (perm in annotation.permissions) {
|
||||
// If a permission is defined with no permission strings, separate it for auto-granting.
|
||||
// Otherwise, the alias is added to the list to be requested.
|
||||
if (perm.strings.isEmpty() || perm.strings.size == 1 && perm.strings[0]
|
||||
.isEmpty()
|
||||
) {
|
||||
if (perm.alias.isNotEmpty()) {
|
||||
autoGrantPerms.add(perm.alias)
|
||||
}
|
||||
} else {
|
||||
aliasSet.add(perm.alias)
|
||||
}
|
||||
}
|
||||
permAliases = aliasSet.toTypedArray()
|
||||
}
|
||||
|
||||
permAliases?.let {
|
||||
// request permissions using provided aliases or all defined on the plugin
|
||||
requestPermissionForAliases(it, invoke, "checkPermissions")
|
||||
} ?: run {
|
||||
if (autoGrantPerms.isNotEmpty()) {
|
||||
// if the plugin only has auto-grant permissions, return all as GRANTED
|
||||
val permissionsResults = JSObject()
|
||||
for (perm in autoGrantPerms) {
|
||||
permissionsResults.put(perm, PermissionState.GRANTED.toString())
|
||||
}
|
||||
invoke.resolve(permissionsResults)
|
||||
} else {
|
||||
// no permissions are defined on the plugin, resolve undefined
|
||||
invoke.resolve()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks if the given permission alias is correctly declared in AndroidManifest.xml
|
||||
* @param alias a permission alias defined on the plugin
|
||||
* @return true only if all permissions associated with the given alias are declared in the manifest
|
||||
*/
|
||||
fun isPermissionDeclared(alias: String): Boolean {
|
||||
for (perm in handle?.annotation?.permissions.orEmpty()) {
|
||||
if (alias.equals(perm.alias, ignoreCase = true)) {
|
||||
var result = true
|
||||
for (permString in perm.strings) {
|
||||
result = result && PermissionHelper.hasDefinedPermission(activity, permString)
|
||||
}
|
||||
return result
|
||||
}
|
||||
}
|
||||
Logger.error(
|
||||
String.format(
|
||||
"isPermissionDeclared: No alias defined for %s " + "or missing @TauriPlugin annotation.",
|
||||
alias
|
||||
)
|
||||
)
|
||||
return false
|
||||
}
|
||||
|
||||
private fun permissionActivityResult(
|
||||
invoke: Invoke,
|
||||
permissionStrings: Array<String>,
|
||||
callbackName: String
|
||||
) {
|
||||
handle!!.requestPermissions(invoke, permissionStrings, callbackName)
|
||||
}
|
||||
|
||||
/**
|
||||
* Request all of the specified permissions in the TauriPlugin annotation (if any)
|
||||
*
|
||||
* If there is no registered permission callback for the Invoke passed in, the call will
|
||||
* be rejected. Make sure a valid permission callback method is registered using the
|
||||
* [PermissionCallback] annotation.
|
||||
*
|
||||
* @param invoke
|
||||
* @param callbackName the name of the callback to run when the permission request is complete
|
||||
*/
|
||||
protected fun requestAllPermissions(
|
||||
invoke: Invoke,
|
||||
callbackName: String
|
||||
) {
|
||||
val annotation = handle!!.annotation
|
||||
if (annotation != null) {
|
||||
val perms: HashSet<String> = HashSet()
|
||||
for (perm in annotation.permissions) {
|
||||
perms.addAll(perm.strings)
|
||||
}
|
||||
permissionActivityResult(invoke, perms.toArray(arrayOfNulls<String>(0)), callbackName)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Request permissions using an alias defined on the plugin.
|
||||
*
|
||||
* If there is no registered permission callback for the Invoke passed in, the call will
|
||||
* be rejected. Make sure a valid permission callback method is registered using the
|
||||
* [PermissionCallback] annotation.
|
||||
*
|
||||
* @param alias an alias defined on the plugin
|
||||
* @param invoke the invoke involved in originating the request
|
||||
* @param callbackName the name of the callback to run when the permission request is complete
|
||||
*/
|
||||
protected fun requestPermissionForAlias(
|
||||
alias: String,
|
||||
invoke: Invoke,
|
||||
callbackName: String
|
||||
) {
|
||||
requestPermissionForAliases(arrayOf(alias), invoke, callbackName)
|
||||
}
|
||||
|
||||
/**
|
||||
* Request permissions using aliases defined on the plugin.
|
||||
*
|
||||
* If there is no registered permission callback for the Invoke passed in, the call will
|
||||
* be rejected. Make sure a valid permission callback method is registered using the
|
||||
* [PermissionCallback] annotation.
|
||||
*
|
||||
* @param aliases a set of aliases defined on the plugin
|
||||
* @param invoke the invoke involved in originating the request
|
||||
* @param callbackName the name of the callback to run when the permission request is complete
|
||||
*/
|
||||
fun requestPermissionForAliases(
|
||||
aliases: Array<String>,
|
||||
invoke: Invoke,
|
||||
callbackName: String
|
||||
) {
|
||||
if (aliases.isEmpty()) {
|
||||
Logger.error("No permission alias was provided")
|
||||
return
|
||||
}
|
||||
val permissions = getPermissionStringsForAliases(aliases)
|
||||
if (permissions.isNotEmpty()) {
|
||||
permissionActivityResult(invoke, permissions, callbackName)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets the Android permission strings defined on the [TauriPlugin] annotation with
|
||||
* the provided aliases.
|
||||
*
|
||||
* @param aliases aliases for permissions defined on the plugin
|
||||
* @return Android permission strings associated with the provided aliases, if exists
|
||||
*/
|
||||
private fun getPermissionStringsForAliases(aliases: Array<String>): Array<String> {
|
||||
val perms: HashSet<String> = HashSet()
|
||||
for (perm in handle?.annotation?.permissions.orEmpty()) {
|
||||
if (aliases.contains(perm.alias)) {
|
||||
perms.addAll(perm.strings)
|
||||
}
|
||||
}
|
||||
return perms.toArray(arrayOfNulls(0))
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the permission state for the provided permission alias.
|
||||
*
|
||||
* @param alias the permission alias to get
|
||||
* @return the state of the provided permission alias or null
|
||||
*/
|
||||
fun getPermissionState(alias: String): PermissionState? {
|
||||
return getPermissionStates()[alias]
|
||||
}
|
||||
|
||||
/**
|
||||
* Helper to check all permissions defined on a plugin and see the state of each.
|
||||
*
|
||||
* @return A mapping of permission aliases to the associated granted status.
|
||||
*/
|
||||
open fun getPermissionStates(): Map<String, PermissionState> {
|
||||
val permissionsResults: MutableMap<String, PermissionState> = HashMap()
|
||||
for (perm in handle?.annotation?.permissions.orEmpty()) {
|
||||
// If a permission is defined with no permission constants, return GRANTED for it.
|
||||
// Otherwise, get its true state.
|
||||
if (perm.strings.isEmpty() || perm.strings.size == 1 && perm.strings[0].isEmpty()) {
|
||||
val key = perm.alias
|
||||
if (key.isNotEmpty()) {
|
||||
val existingResult = permissionsResults[key]
|
||||
|
||||
// auto set permission state to GRANTED if the alias is empty.
|
||||
if (existingResult == null) {
|
||||
permissionsResults[key] = PermissionState.GRANTED
|
||||
}
|
||||
}
|
||||
} else {
|
||||
for (permString in perm.strings) {
|
||||
val key = perm.alias.ifEmpty { permString }
|
||||
var permissionStatus: PermissionState
|
||||
if (ActivityCompat.checkSelfPermission(
|
||||
activity,
|
||||
permString
|
||||
) == PackageManager.PERMISSION_GRANTED
|
||||
) {
|
||||
permissionStatus = PermissionState.GRANTED
|
||||
} else {
|
||||
permissionStatus = PermissionState.PROMPT
|
||||
|
||||
// Check if there is a cached permission state for the "Never ask again" state
|
||||
val prefs =
|
||||
activity.getSharedPreferences("PluginPermStates", Activity.MODE_PRIVATE)
|
||||
val state = prefs.getString(permString, null)
|
||||
if (state != null) {
|
||||
permissionStatus = PermissionState.byState(state)
|
||||
}
|
||||
}
|
||||
val existingResult = permissionsResults[key]
|
||||
|
||||
// multiple permissions with the same alias must all be true, otherwise all false.
|
||||
if (existingResult == null || existingResult === PermissionState.GRANTED) {
|
||||
permissionsResults[key] = permissionStatus
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return permissionsResults
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,178 @@
|
||||
// Copyright 2019-2024 Tauri Programme within The Commons Conservancy
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package app.tauri.plugin
|
||||
|
||||
import android.app.Activity
|
||||
import android.content.Intent
|
||||
import android.webkit.WebView
|
||||
import androidx.activity.result.IntentSenderRequest
|
||||
import androidx.core.app.ActivityCompat
|
||||
import app.tauri.PermissionHelper
|
||||
import app.tauri.PermissionState
|
||||
import app.tauri.annotation.ActivityCallback
|
||||
import app.tauri.annotation.Command
|
||||
import app.tauri.annotation.PermissionCallback
|
||||
import app.tauri.annotation.TauriPlugin
|
||||
import com.fasterxml.jackson.databind.ObjectMapper
|
||||
import java.lang.reflect.Method
|
||||
import androidx.core.content.edit
|
||||
|
||||
class PluginHandle(
|
||||
private val manager: PluginManager,
|
||||
val name: String,
|
||||
val instance: Plugin,
|
||||
val config: String,
|
||||
val jsonMapper: ObjectMapper
|
||||
) {
|
||||
private val commands: HashMap<String, CommandData> = HashMap()
|
||||
private val permissionCallbackMethods: HashMap<String, Method> = HashMap()
|
||||
private val startActivityCallbackMethods: HashMap<String, Method> = HashMap()
|
||||
var annotation: TauriPlugin?
|
||||
var loaded = false
|
||||
|
||||
init {
|
||||
indexMethods()
|
||||
instance.handle = this
|
||||
annotation = instance.javaClass.getAnnotation(TauriPlugin::class.java)
|
||||
}
|
||||
|
||||
fun load(webView: WebView) {
|
||||
instance.load(webView)
|
||||
loaded = true
|
||||
}
|
||||
|
||||
fun startActivityForResult(invoke: Invoke, intent: Intent, callbackName: String) {
|
||||
manager.startActivityForResult(intent) { result ->
|
||||
val method = startActivityCallbackMethods[callbackName]
|
||||
if (method != null) {
|
||||
method.isAccessible = true
|
||||
method(instance, invoke, result)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun startIntentSenderForResult(
|
||||
invoke: Invoke,
|
||||
intentSender: IntentSenderRequest,
|
||||
callbackName: String
|
||||
) {
|
||||
manager.startIntentSenderForResult(intentSender) { result ->
|
||||
val method = startActivityCallbackMethods[callbackName]
|
||||
if (method != null) {
|
||||
method.isAccessible = true
|
||||
method(instance, invoke, result)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun requestPermissions(
|
||||
invoke: Invoke,
|
||||
permissions: Array<String>,
|
||||
callbackName: String
|
||||
) {
|
||||
manager.requestPermissions(permissions) { result ->
|
||||
if (validatePermissions(invoke, result)) {
|
||||
val method = permissionCallbackMethods[callbackName]
|
||||
if (method != null) {
|
||||
method.isAccessible = true
|
||||
method(instance, invoke)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Saves permission states and rejects if permissions were not correctly defined in
|
||||
* the AndroidManifest.xml file.
|
||||
*
|
||||
* @param permissions
|
||||
* @return true if permissions were saved and defined correctly, false if not
|
||||
*/
|
||||
private fun validatePermissions(
|
||||
invoke: Invoke,
|
||||
permissions: Map<String, Boolean>
|
||||
): Boolean {
|
||||
val activity = manager.activity!!
|
||||
val prefs =
|
||||
activity.getSharedPreferences("PluginPermStates", Activity.MODE_PRIVATE)
|
||||
for ((permString, isGranted) in permissions) {
|
||||
if (isGranted) {
|
||||
// Permission granted. If previously denied, remove cached state
|
||||
val state = prefs.getString(permString, null)
|
||||
if (state != null) {
|
||||
prefs.edit {
|
||||
remove(permString)
|
||||
}
|
||||
}
|
||||
} else {
|
||||
prefs.edit {
|
||||
if (ActivityCompat.shouldShowRequestPermissionRationale(
|
||||
activity,
|
||||
permString
|
||||
)
|
||||
) {
|
||||
// Permission denied, can prompt again with rationale
|
||||
putString(permString, PermissionState.PROMPT_WITH_RATIONALE.toString())
|
||||
} else {
|
||||
// Permission denied permanently, store this state for future reference
|
||||
putString(permString, PermissionState.DENIED.toString())
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
val permStrings = permissions.keys.toTypedArray()
|
||||
if (!PermissionHelper.hasDefinedPermissions(activity, permStrings)) {
|
||||
val builder = StringBuilder()
|
||||
builder.append("Missing the following permissions in AndroidManifest.xml:\n")
|
||||
val missing = PermissionHelper.getUndefinedPermissions(activity, permStrings)
|
||||
for (perm in missing) {
|
||||
builder.append(
|
||||
"""
|
||||
$perm
|
||||
|
||||
""".trimIndent()
|
||||
)
|
||||
}
|
||||
invoke.reject(builder.toString())
|
||||
return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
@Throws(
|
||||
InvalidCommandException::class,
|
||||
IllegalAccessException::class
|
||||
)
|
||||
fun invoke(invoke: Invoke) {
|
||||
val methodMeta = commands[invoke.command]
|
||||
?: throw InvalidCommandException("No command " + invoke.command + " found for plugin " + instance.javaClass.name)
|
||||
methodMeta.method.invoke(instance, invoke)
|
||||
}
|
||||
|
||||
private fun indexMethods() {
|
||||
val methods = mutableListOf<Method>()
|
||||
var pluginCursor: Class<*> = instance.javaClass
|
||||
while (pluginCursor.name != Any::class.java.name) {
|
||||
methods.addAll(listOf(*pluginCursor.declaredMethods))
|
||||
pluginCursor = pluginCursor.superclass
|
||||
}
|
||||
|
||||
for (method in methods) {
|
||||
if (method.isAnnotationPresent(Command::class.java)) {
|
||||
val command = method.getAnnotation(Command::class.java) ?: continue
|
||||
val methodMeta = CommandData(method, command)
|
||||
commands[method.name] = methodMeta
|
||||
}
|
||||
|
||||
if (method.isAnnotationPresent(ActivityCallback::class.java)) {
|
||||
startActivityCallbackMethods[method.name] = method
|
||||
}
|
||||
|
||||
if (method.isAnnotationPresent(PermissionCallback::class.java)) {
|
||||
permissionCallbackMethods[method.name] = method
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,245 @@
|
||||
// Copyright 2019-2024 Tauri Programme within The Commons Conservancy
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package app.tauri.plugin
|
||||
|
||||
import android.content.res.Configuration
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.webkit.WebView
|
||||
import androidx.activity.result.ActivityResult
|
||||
import androidx.activity.result.ActivityResultLauncher
|
||||
import androidx.activity.result.IntentSenderRequest
|
||||
import androidx.activity.result.contract.ActivityResultContracts
|
||||
import androidx.appcompat.app.AppCompatActivity
|
||||
import app.tauri.annotation.InvokeArg
|
||||
import app.tauri.FsUtils
|
||||
import app.tauri.JniMethod
|
||||
import app.tauri.Logger
|
||||
import com.fasterxml.jackson.annotation.JsonAutoDetect
|
||||
import com.fasterxml.jackson.annotation.PropertyAccessor
|
||||
import com.fasterxml.jackson.databind.DeserializationFeature
|
||||
import com.fasterxml.jackson.databind.JsonNode
|
||||
import com.fasterxml.jackson.databind.ObjectMapper
|
||||
import com.fasterxml.jackson.datatype.jsonorg.JsonOrgModule
|
||||
import com.fasterxml.jackson.databind.module.SimpleModule
|
||||
import java.lang.reflect.InvocationTargetException
|
||||
|
||||
object PluginManager {
|
||||
fun interface RequestPermissionsCallback {
|
||||
fun onResult(permissions: Map<String, Boolean>)
|
||||
}
|
||||
|
||||
fun interface ActivityResultCallback {
|
||||
fun onResult(result: ActivityResult)
|
||||
}
|
||||
|
||||
/** The result launchers belonging to one activity. */
|
||||
private class ResultLaunchers(
|
||||
val startActivityForResult: ActivityResultLauncher<Intent>,
|
||||
val startIntentSenderForResult: ActivityResultLauncher<IntentSenderRequest>,
|
||||
val requestPermissions: ActivityResultLauncher<Array<String>>
|
||||
)
|
||||
|
||||
// Insertion ordered, so the activity taken over when the current one goes away is the oldest
|
||||
// surviving one rather than an arbitrary member of a hash set.
|
||||
private val launchers: LinkedHashMap<AppCompatActivity, ResultLaunchers> = LinkedHashMap()
|
||||
var activity: AppCompatActivity? = null
|
||||
private val plugins: HashMap<String, PluginHandle> = HashMap()
|
||||
private var requestPermissionsCallback: RequestPermissionsCallback? = null
|
||||
private var startActivityForResultCallback: ActivityResultCallback? = null
|
||||
private var startIntentSenderForResultCallback: ActivityResultCallback? = null
|
||||
private var jsonMapper: ObjectMapper = ObjectMapper()
|
||||
.disable(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES)
|
||||
.enable(DeserializationFeature.FAIL_ON_NULL_FOR_PRIMITIVES)
|
||||
.setVisibility(PropertyAccessor.FIELD, JsonAutoDetect.Visibility.ANY)
|
||||
|
||||
init {
|
||||
val channelDeserializer = ChannelDeserializer({ channelId, payload ->
|
||||
sendChannelData(channelId, payload)
|
||||
}, jsonMapper)
|
||||
jsonMapper
|
||||
.registerModule(SimpleModule().addDeserializer(Channel::class.java, channelDeserializer))
|
||||
// Without it, the mapper's field visibility serializes an org.json value through
|
||||
// its private fields: a `JSArray` as `{"values":[...]}` and a `JSObject` as
|
||||
// `{"nameValuePairs":{...}}`.
|
||||
.registerModule(JsonOrgModule())
|
||||
}
|
||||
|
||||
fun onCreate(activity: AppCompatActivity) {
|
||||
// Every activity gets its own launchers, and gets them here: registerForActivityResult must
|
||||
// be called before its owner reaches STARTED, so an activity that is already running can
|
||||
// never be given launchers later.
|
||||
launchers[activity] = registerResultLaunchers(activity)
|
||||
if (this.activity == null) {
|
||||
this.activity = activity
|
||||
}
|
||||
}
|
||||
|
||||
private fun registerResultLaunchers(activity: AppCompatActivity): ResultLaunchers =
|
||||
ResultLaunchers(
|
||||
activity.registerForActivityResult(ActivityResultContracts.StartActivityForResult()
|
||||
) { result ->
|
||||
startActivityForResultCallback?.onResult(result)
|
||||
},
|
||||
|
||||
activity.registerForActivityResult(ActivityResultContracts.StartIntentSenderForResult()
|
||||
) { result ->
|
||||
startIntentSenderForResultCallback?.onResult(result)
|
||||
},
|
||||
|
||||
activity.registerForActivityResult(ActivityResultContracts.RequestMultiplePermissions()
|
||||
) { result ->
|
||||
requestPermissionsCallback?.onResult(result)
|
||||
}
|
||||
)
|
||||
|
||||
private val currentLaunchers: ResultLaunchers
|
||||
get() = launchers[activity]
|
||||
?: throw IllegalStateException("the plugin manager has no activity to launch from")
|
||||
|
||||
fun onNewIntent(intent: Intent) {
|
||||
for (plugin in plugins.values) {
|
||||
plugin.instance.onNewIntent(intent)
|
||||
}
|
||||
}
|
||||
|
||||
fun onPause(activity: AppCompatActivity) {
|
||||
for (plugin in plugins.values) {
|
||||
plugin.instance.triggerOnPause(activity)
|
||||
}
|
||||
}
|
||||
|
||||
fun onResume(activity: AppCompatActivity) {
|
||||
for (plugin in plugins.values) {
|
||||
plugin.instance.triggerOnResume(activity)
|
||||
}
|
||||
}
|
||||
|
||||
fun onRestart(activity: AppCompatActivity) {
|
||||
for (plugin in plugins.values) {
|
||||
plugin.instance.triggerOnRestart(activity)
|
||||
}
|
||||
}
|
||||
|
||||
fun onStop(activity: AppCompatActivity) {
|
||||
for (plugin in plugins.values) {
|
||||
plugin.instance.triggerOnStop(activity)
|
||||
}
|
||||
}
|
||||
|
||||
fun onDestroy(activity: AppCompatActivity) {
|
||||
for (plugin in plugins.values) {
|
||||
plugin.instance.triggerOnDestroy(activity)
|
||||
}
|
||||
|
||||
launchers.remove(activity)
|
||||
if (this.activity == activity) {
|
||||
// Whatever is left already holds its own launchers, registered when it was created. Moving
|
||||
// this activity's launchers over instead would mean registering against an activity that is
|
||||
// already running, which registerForActivityResult rejects with an IllegalStateException.
|
||||
this.activity = launchers.keys.firstOrNull()
|
||||
}
|
||||
}
|
||||
|
||||
fun onConfigurationChanged(newConfig: Configuration) {
|
||||
for (plugin in plugins.values) {
|
||||
plugin.instance.onConfigurationChanged(newConfig)
|
||||
}
|
||||
}
|
||||
|
||||
fun startActivityForResult(intent: Intent, callback: ActivityResultCallback) {
|
||||
startActivityForResultCallback = callback
|
||||
currentLaunchers.startActivityForResult.launch(intent)
|
||||
}
|
||||
|
||||
fun startIntentSenderForResult(intent: IntentSenderRequest, callback: ActivityResultCallback) {
|
||||
startIntentSenderForResultCallback = callback
|
||||
currentLaunchers.startIntentSenderForResult.launch(intent)
|
||||
}
|
||||
|
||||
fun requestPermissions(
|
||||
permissionStrings: Array<String>,
|
||||
callback: RequestPermissionsCallback
|
||||
) {
|
||||
requestPermissionsCallback = callback
|
||||
currentLaunchers.requestPermissions.launch(permissionStrings)
|
||||
}
|
||||
|
||||
@JniMethod
|
||||
fun onWebViewCreated(webView: WebView) {
|
||||
for ((_, plugin) in plugins) {
|
||||
if (!plugin.loaded) {
|
||||
plugin.load(webView)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@JniMethod
|
||||
fun load(webView: WebView?, name: String, plugin: Plugin, config: String) {
|
||||
val handle = PluginHandle(this, name, plugin, config, jsonMapper)
|
||||
plugins[name] = handle
|
||||
if (webView != null) {
|
||||
plugin.load(webView)
|
||||
}
|
||||
}
|
||||
|
||||
@JniMethod
|
||||
fun runCommand(id: Int, pluginId: String, command: String, data: String) {
|
||||
val successId = 0L
|
||||
val errorId = 1L
|
||||
val invoke = Invoke(id.toLong(), command, successId, errorId, { fn, result ->
|
||||
var success: String? = null
|
||||
var error: String? = null
|
||||
if (fn == successId) {
|
||||
success = result
|
||||
} else {
|
||||
error = result
|
||||
}
|
||||
handlePluginResponse(id, success, error)
|
||||
}, data, jsonMapper)
|
||||
|
||||
dispatchPluginMessage(invoke, pluginId)
|
||||
}
|
||||
|
||||
private fun dispatchPluginMessage(invoke: Invoke, pluginId: String) {
|
||||
Logger.verbose(
|
||||
Logger.tags("Plugin"),
|
||||
"Tauri plugin: pluginId: $pluginId, command: ${invoke.command}"
|
||||
)
|
||||
|
||||
try {
|
||||
val plugin = plugins[pluginId]
|
||||
if (plugin == null) {
|
||||
invoke.reject("Plugin $pluginId not initialized")
|
||||
} else {
|
||||
plugins[pluginId]?.invoke(invoke)
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
var exception: Throwable = e
|
||||
if (exception.message?.isEmpty() != false) {
|
||||
if (e is InvocationTargetException) {
|
||||
exception = e.targetException
|
||||
}
|
||||
}
|
||||
invoke.reject(if (exception.message?.isEmpty() != false) { exception.toString() } else { exception.message })
|
||||
}
|
||||
}
|
||||
|
||||
fun<T> loadConfig(context: Context, plugin: String, cls: Class<T>): T {
|
||||
val tauriConfigJson = FsUtils.readAsset(context.assets, "tauri.conf.json")
|
||||
val mapper = ObjectMapper()
|
||||
.configure(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES, false)
|
||||
val config = mapper.readValue(tauriConfigJson, Config::class.java)
|
||||
return mapper.readValue(config.plugins[plugin].toString(), cls)
|
||||
}
|
||||
|
||||
private external fun handlePluginResponse(id: Int, success: String?, error: String?)
|
||||
private external fun sendChannelData(id: Long, data: String)
|
||||
}
|
||||
|
||||
@InvokeArg
|
||||
internal class Config {
|
||||
lateinit var plugins: Map<String, JsonNode>
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
// Copyright 2019-2024 Tauri Programme within The Commons Conservancy
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package app.tauri.plugin
|
||||
|
||||
import app.tauri.annotation.Command
|
||||
import java.lang.reflect.Method
|
||||
|
||||
class CommandData(
|
||||
val method: Method, methodDecorator: Command
|
||||
) {
|
||||
|
||||
// The name of the method
|
||||
val name: String = method.name
|
||||
}
|
||||
@@ -0,0 +1,67 @@
|
||||
// Copyright 2019-2024 Tauri Programme within The Commons Conservancy
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package app.tauri.plugin
|
||||
|
||||
import android.annotation.SuppressLint
|
||||
import app.tauri.Logger
|
||||
import java.text.DateFormat
|
||||
import java.text.SimpleDateFormat
|
||||
import java.util.*
|
||||
|
||||
class PluginResult @JvmOverloads constructor(json: JSObject? = JSObject()) {
|
||||
private val json: JSObject
|
||||
|
||||
init {
|
||||
this.json = json ?: JSObject()
|
||||
}
|
||||
|
||||
fun put(name: String, value: Boolean): PluginResult {
|
||||
return jsonPut(name, value)
|
||||
}
|
||||
|
||||
fun put(name: String, value: Double): PluginResult {
|
||||
return jsonPut(name, value)
|
||||
}
|
||||
|
||||
fun put(name: String, value: Int): PluginResult {
|
||||
return jsonPut(name, value)
|
||||
}
|
||||
|
||||
fun put(name: String, value: Long): PluginResult {
|
||||
return jsonPut(name, value)
|
||||
}
|
||||
|
||||
/**
|
||||
* Format a date as an ISO string
|
||||
*/
|
||||
@SuppressLint("SimpleDateFormat")
|
||||
fun put(name: String, value: Date): PluginResult {
|
||||
val tz: TimeZone = TimeZone.getTimeZone("UTC")
|
||||
val df: DateFormat = SimpleDateFormat("yyyy-MM-dd'T'HH:mm'Z'")
|
||||
df.timeZone = tz
|
||||
return jsonPut(name, df.format(value))
|
||||
}
|
||||
|
||||
fun put(name: String, value: Any?): PluginResult {
|
||||
return jsonPut(name, value)
|
||||
}
|
||||
|
||||
fun put(name: String, value: PluginResult): PluginResult {
|
||||
return jsonPut(name, value.json)
|
||||
}
|
||||
|
||||
private fun jsonPut(name: String, value: Any?): PluginResult {
|
||||
try {
|
||||
json.put(name, value)
|
||||
} catch (ex: Exception) {
|
||||
Logger.error(Logger.tags("Plugin"), "", ex)
|
||||
}
|
||||
return this
|
||||
}
|
||||
|
||||
override fun toString(): String {
|
||||
return json.toString()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
// Copyright 2019-2024 Tauri Programme within The Commons Conservancy
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package app.tauri
|
||||
|
||||
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)
|
||||
}
|
||||
}
|
||||
10
crates/tauri/mobile/ios-api/.gitignore
vendored
Normal file
10
crates/tauri/mobile/ios-api/.gitignore
vendored
Normal file
@@ -0,0 +1,10 @@
|
||||
.DS_Store
|
||||
/.build
|
||||
/Packages
|
||||
/*.xcodeproj
|
||||
xcuserdata/
|
||||
DerivedData/
|
||||
.swiftpm/config/registries.json
|
||||
.swiftpm/xcode/package.xcworkspace/contents.xcworkspacedata
|
||||
.netrc
|
||||
Package.resolved
|
||||
40
crates/tauri/mobile/ios-api/Package.swift
Normal file
40
crates/tauri/mobile/ios-api/Package.swift
Normal file
@@ -0,0 +1,40 @@
|
||||
// swift-tools-version:5.3
|
||||
// Copyright 2019-2024 Tauri Programme within The Commons Conservancy
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
import PackageDescription
|
||||
|
||||
let package = Package(
|
||||
name: "Tauri",
|
||||
platforms: [
|
||||
.macOS(.v10_13),
|
||||
.iOS(.v11),
|
||||
],
|
||||
products: [
|
||||
// Products define the executables and libraries a package produces, and make them visible to other packages.
|
||||
.library(
|
||||
name: "Tauri",
|
||||
type: .static,
|
||||
targets: ["Tauri"])
|
||||
],
|
||||
dependencies: [
|
||||
// Dependencies declare other packages that this package depends on.
|
||||
.package(name: "SwiftRs", url: "https://github.com/Brendonovich/swift-rs", from: "1.0.0")
|
||||
],
|
||||
targets: [
|
||||
// Targets are the basic building blocks of a package. A target can define a module or a test suite.
|
||||
// Targets can depend on other targets in this package, and on products in packages this package depends on.
|
||||
.target(
|
||||
name: "Tauri",
|
||||
dependencies: [
|
||||
.byName(name: "SwiftRs")
|
||||
],
|
||||
path: "Sources"
|
||||
),
|
||||
.testTarget(
|
||||
name: "TauriTests",
|
||||
dependencies: ["Tauri"]
|
||||
),
|
||||
]
|
||||
)
|
||||
3
crates/tauri/mobile/ios-api/README.md
Normal file
3
crates/tauri/mobile/ios-api/README.md
Normal file
@@ -0,0 +1,3 @@
|
||||
# Tauri
|
||||
|
||||
Tauri iOS API.
|
||||
65
crates/tauri/mobile/ios-api/Sources/Tauri/Channel.swift
Normal file
65
crates/tauri/mobile/ios-api/Sources/Tauri/Channel.swift
Normal file
@@ -0,0 +1,65 @@
|
||||
// Copyright 2019-2024 Tauri Programme within The Commons Conservancy
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
import Foundation
|
||||
|
||||
let CHANNEL_PREFIX = "__CHANNEL__:"
|
||||
let channelDataKey = CodingUserInfoKey(rawValue: "sendChannelData")!
|
||||
|
||||
public class Channel: Decodable {
|
||||
public let id: UInt64
|
||||
let handler: (UInt64, String) -> Void
|
||||
|
||||
public required init(from decoder: Decoder) throws {
|
||||
let container = try decoder.singleValueContainer()
|
||||
let channelDef = try container.decode(String.self)
|
||||
|
||||
let components = channelDef.components(separatedBy: CHANNEL_PREFIX)
|
||||
if components.count < 2 {
|
||||
throw DecodingError.dataCorruptedError(
|
||||
in: container,
|
||||
debugDescription: "Invalid channel definition from \(channelDef)"
|
||||
)
|
||||
|
||||
}
|
||||
guard let channelId = UInt64(components[1]) else {
|
||||
throw DecodingError.dataCorruptedError(
|
||||
in: container,
|
||||
debugDescription: "Invalid channel ID from \(channelDef)"
|
||||
)
|
||||
}
|
||||
|
||||
guard let handler = decoder.userInfo[channelDataKey] as? (UInt64, String) -> Void else {
|
||||
throw DecodingError.dataCorruptedError(
|
||||
in: container,
|
||||
debugDescription: "missing userInfo for Channel handler. This is a Tauri issue"
|
||||
)
|
||||
}
|
||||
|
||||
self.id = channelId
|
||||
self.handler = handler
|
||||
}
|
||||
|
||||
func serialize(_ data: JsonValue) -> String {
|
||||
do {
|
||||
return try data.jsonRepresentation() ?? "\"Failed to serialize payload\""
|
||||
} catch {
|
||||
return "\"\(error)\""
|
||||
}
|
||||
}
|
||||
|
||||
public func send(_ data: JsonObject) {
|
||||
send(.dictionary(data))
|
||||
}
|
||||
|
||||
public func send(_ data: JsonValue) {
|
||||
handler(id, serialize(data))
|
||||
}
|
||||
|
||||
public func send<T: Encodable>(_ data: T) throws {
|
||||
let json = try JSONEncoder().encode(data)
|
||||
handler(id, String(decoding: json, as: UTF8.self))
|
||||
}
|
||||
|
||||
}
|
||||
118
crates/tauri/mobile/ios-api/Sources/Tauri/Invoke.swift
Normal file
118
crates/tauri/mobile/ios-api/Sources/Tauri/Invoke.swift
Normal file
@@ -0,0 +1,118 @@
|
||||
// Copyright 2019-2024 Tauri Programme within The Commons Conservancy
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
import Foundation
|
||||
import UIKit
|
||||
|
||||
@objc public class Invoke: NSObject {
|
||||
public let command: String
|
||||
let callback: UInt64
|
||||
let error: UInt64
|
||||
let data: String
|
||||
let sendResponse: (UInt64, String?) -> Void
|
||||
let sendChannelData: (UInt64, String) -> Void
|
||||
|
||||
public init(
|
||||
command: String, callback: UInt64, error: UInt64,
|
||||
sendResponse: @escaping (UInt64, String?) -> Void,
|
||||
sendChannelData: @escaping (UInt64, String) -> Void, data: String
|
||||
) {
|
||||
self.command = command
|
||||
self.callback = callback
|
||||
self.error = error
|
||||
self.data = data
|
||||
self.sendResponse = sendResponse
|
||||
self.sendChannelData = sendChannelData
|
||||
}
|
||||
|
||||
public func getRawArgs() -> String {
|
||||
return self.data
|
||||
}
|
||||
|
||||
public func getArgs() throws -> JSObject {
|
||||
let jsonData = self.data.data(using: .utf8)!
|
||||
let data = try JSONSerialization.jsonObject(with: jsonData, options: [])
|
||||
return JSTypes.coerceDictionaryToJSObject(
|
||||
(data as! NSDictionary), formattingDatesAsStrings: true)!
|
||||
}
|
||||
|
||||
public func parseArgs<T: Decodable>(_ type: T.Type) throws -> T {
|
||||
let jsonData = self.data.data(using: .utf8)!
|
||||
let decoder = JSONDecoder()
|
||||
decoder.userInfo[channelDataKey] = sendChannelData
|
||||
return try decoder.decode(type, from: jsonData)
|
||||
}
|
||||
|
||||
func serialize(_ data: JsonValue) -> String {
|
||||
do {
|
||||
return try data.jsonRepresentation() ?? "\"Failed to serialize payload\""
|
||||
} catch {
|
||||
return "\"\(error)\""
|
||||
}
|
||||
}
|
||||
|
||||
public func resolve() {
|
||||
sendResponse(callback, nil)
|
||||
}
|
||||
|
||||
public func resolve(_ data: JsonObject) {
|
||||
resolve(.dictionary(data))
|
||||
}
|
||||
|
||||
public func resolve(_ data: JsonValue) {
|
||||
sendResponse(callback, serialize(data))
|
||||
}
|
||||
|
||||
public func resolve<T: Encodable>(_ data: T) {
|
||||
do {
|
||||
let json = try JSONEncoder().encode(data)
|
||||
sendResponse(callback, String(decoding: json, as: UTF8.self))
|
||||
} catch {
|
||||
sendResponse(self.error, "\"\(error)\"")
|
||||
}
|
||||
}
|
||||
|
||||
public func reject(
|
||||
_ message: String, code: String? = nil, error: Error? = nil, data: JsonValue? = nil
|
||||
) {
|
||||
let payload: NSMutableDictionary = [
|
||||
"message": message
|
||||
]
|
||||
|
||||
if let code = code {
|
||||
payload["code"] = code
|
||||
}
|
||||
|
||||
if let error = error {
|
||||
payload["error"] = error
|
||||
}
|
||||
|
||||
if let data = data {
|
||||
switch data {
|
||||
case .dictionary(let dict):
|
||||
for entry in dict {
|
||||
payload[entry.key] = entry.value
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
sendResponse(self.error, serialize(.dictionary(payload as! JsonObject)))
|
||||
}
|
||||
|
||||
public func unimplemented() {
|
||||
unimplemented("not implemented")
|
||||
}
|
||||
|
||||
public func unimplemented(_ message: String) {
|
||||
reject(message)
|
||||
}
|
||||
|
||||
public func unavailable() {
|
||||
unavailable("not available")
|
||||
}
|
||||
|
||||
public func unavailable(_ message: String) {
|
||||
reject(message)
|
||||
}
|
||||
}
|
||||
123
crates/tauri/mobile/ios-api/Sources/Tauri/JSTypes.swift
Normal file
123
crates/tauri/mobile/ios-api/Sources/Tauri/JSTypes.swift
Normal file
@@ -0,0 +1,123 @@
|
||||
// Copyright 2019-2024 Tauri Programme within The Commons Conservancy
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
import Foundation
|
||||
|
||||
// declare our empty protocol, and conformance, for typing
|
||||
public protocol JSValue {}
|
||||
extension String: JSValue {}
|
||||
extension Bool: JSValue {}
|
||||
extension Int: JSValue {}
|
||||
extension Float: JSValue {}
|
||||
extension Double: JSValue {}
|
||||
extension NSNumber: JSValue {}
|
||||
extension NSNull: JSValue {}
|
||||
extension Array: JSValue {}
|
||||
extension Date: JSValue {}
|
||||
extension Dictionary: JSValue where Key == String, Value == JSValue {}
|
||||
|
||||
// convenience aliases
|
||||
public typealias JSObject = [String: JSValue]
|
||||
public typealias JSArray = [JSValue]
|
||||
|
||||
extension Dictionary where Key == String, Value == JSValue {
|
||||
public func getValue(_ key: String) -> JSValue? {
|
||||
return self[key]
|
||||
}
|
||||
|
||||
public func getString(_ key: String) -> String? {
|
||||
return self[key] as? String
|
||||
}
|
||||
|
||||
public func getBool(_ key: String) -> Bool? {
|
||||
return self[key] as? Bool
|
||||
}
|
||||
|
||||
public func getInt(_ key: String) -> Int? {
|
||||
return self[key] as? Int
|
||||
}
|
||||
|
||||
public func getFloat(_ key: String) -> Float? {
|
||||
if let floatValue = self[key] as? Float {
|
||||
return floatValue
|
||||
} else if let doubleValue = self[key] as? Double {
|
||||
return Float(doubleValue)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
public func getDouble(_ key: String) -> Double? {
|
||||
return self[key] as? Double
|
||||
}
|
||||
|
||||
public func getArray(_ key: String) -> JSArray? {
|
||||
return self[key] as? JSArray
|
||||
}
|
||||
|
||||
public func getObject(_ key: String) -> JSObject? {
|
||||
return self[key] as? JSObject
|
||||
}
|
||||
}
|
||||
|
||||
/*
|
||||
Simply casting objects from foundation class clusters (such as __NSArrayM)
|
||||
doesn't work with the JSValue protocol and will always fail. So we need to
|
||||
recursively and explicitly convert each value in the dictionary.
|
||||
*/
|
||||
public enum JSTypes {}
|
||||
extension JSTypes {
|
||||
public static func coerceDictionaryToJSObject(
|
||||
_ dictionary: NSDictionary?, formattingDatesAsStrings: Bool = false
|
||||
) -> JSObject? {
|
||||
return coerceToJSValue(dictionary, formattingDates: formattingDatesAsStrings) as? JSObject
|
||||
}
|
||||
|
||||
public static func coerceDictionaryToJSObject(
|
||||
_ dictionary: [AnyHashable: Any]?, formattingDatesAsStrings: Bool = false
|
||||
) -> JSObject? {
|
||||
return coerceToJSValue(dictionary, formattingDates: formattingDatesAsStrings) as? JSObject
|
||||
}
|
||||
}
|
||||
|
||||
private let dateStringFormatter = ISO8601DateFormatter()
|
||||
|
||||
// We need a large switch statement because we have a lot of types.
|
||||
// swiftlint:disable:next cyclomatic_complexity
|
||||
private func coerceToJSValue(_ value: Any?, formattingDates: Bool) -> JSValue? {
|
||||
guard let value = value else {
|
||||
return nil
|
||||
}
|
||||
switch value {
|
||||
case let stringValue as String:
|
||||
return stringValue
|
||||
case let numberValue as NSNumber:
|
||||
return numberValue
|
||||
case let boolValue as Bool:
|
||||
return boolValue
|
||||
case let intValue as Int:
|
||||
return intValue
|
||||
case let floatValue as Float:
|
||||
return floatValue
|
||||
case let doubleValue as Double:
|
||||
return doubleValue
|
||||
case let dateValue as Date:
|
||||
if formattingDates {
|
||||
return dateStringFormatter.string(from: dateValue)
|
||||
}
|
||||
return dateValue
|
||||
case let nullValue as NSNull:
|
||||
return nullValue
|
||||
case let arrayValue as NSArray:
|
||||
return arrayValue.compactMap { coerceToJSValue($0, formattingDates: formattingDates) }
|
||||
case let dictionaryValue as NSDictionary:
|
||||
let keys = dictionaryValue.allKeys.compactMap { $0 as? String }
|
||||
var result: JSObject = [:]
|
||||
for key in keys {
|
||||
result[key] = coerceToJSValue(dictionaryValue[key], formattingDates: formattingDates)
|
||||
}
|
||||
return result
|
||||
default:
|
||||
return nil
|
||||
}
|
||||
}
|
||||
58
crates/tauri/mobile/ios-api/Sources/Tauri/JsonValue.swift
Normal file
58
crates/tauri/mobile/ios-api/Sources/Tauri/JsonValue.swift
Normal file
@@ -0,0 +1,58 @@
|
||||
// Copyright 2019-2024 Tauri Programme within The Commons Conservancy
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
import Foundation
|
||||
|
||||
public typealias JsonObject = [String: Any?]
|
||||
|
||||
public enum JsonValue {
|
||||
case dictionary(JsonObject)
|
||||
|
||||
enum SerializationError: Error {
|
||||
case invalidObject
|
||||
}
|
||||
|
||||
public func jsonRepresentation(includingFields: JsonObject? = nil) throws -> String? {
|
||||
switch self {
|
||||
case .dictionary(var dictionary):
|
||||
if let fields = includingFields {
|
||||
dictionary.merge(fields) { (current, _) in current }
|
||||
}
|
||||
dictionary = prepare(dictionary: dictionary)
|
||||
guard JSONSerialization.isValidJSONObject(dictionary) else {
|
||||
throw SerializationError.invalidObject
|
||||
}
|
||||
let data = try JSONSerialization.data(withJSONObject: dictionary, options: [])
|
||||
return String(data: data, encoding: .utf8)
|
||||
}
|
||||
}
|
||||
|
||||
private static let formatter = ISO8601DateFormatter()
|
||||
|
||||
private func prepare(dictionary: JsonObject) -> JsonObject {
|
||||
return dictionary.mapValues { (value) -> Any in
|
||||
if let date = value as? Date {
|
||||
return JsonValue.formatter.string(from: date)
|
||||
} else if let aDictionary = value as? JsonObject {
|
||||
return prepare(dictionary: aDictionary)
|
||||
} else if let anArray = value as? [Any] {
|
||||
return prepare(array: anArray)
|
||||
}
|
||||
return value
|
||||
}
|
||||
}
|
||||
|
||||
private func prepare(array: [Any]) -> [Any] {
|
||||
return array.map { (value) -> Any in
|
||||
if let date = value as? Date {
|
||||
return JsonValue.formatter.string(from: date)
|
||||
} else if let aDictionary = value as? JsonObject {
|
||||
return prepare(dictionary: aDictionary)
|
||||
} else if let anArray = value as? [Any] {
|
||||
return prepare(array: anArray)
|
||||
}
|
||||
return value
|
||||
}
|
||||
}
|
||||
}
|
||||
139
crates/tauri/mobile/ios-api/Sources/Tauri/Logger.swift
Normal file
139
crates/tauri/mobile/ios-api/Sources/Tauri/Logger.swift
Normal file
@@ -0,0 +1,139 @@
|
||||
// Copyright 2019-2024 Tauri Programme within The Commons Conservancy
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
import os.log
|
||||
import UIKit
|
||||
import Foundation
|
||||
|
||||
class StdoutRedirector {
|
||||
private var originalStdout: Int32 = -1
|
||||
private var originalStderr: Int32 = -1
|
||||
private var stdoutPipe: [Int32] = [-1, -1]
|
||||
private var stderrPipe: [Int32] = [-1, -1]
|
||||
private var stdoutReadSource: DispatchSourceRead?
|
||||
private var stderrReadSource: DispatchSourceRead?
|
||||
|
||||
func start() {
|
||||
originalStdout = dup(STDOUT_FILENO)
|
||||
originalStderr = dup(STDERR_FILENO)
|
||||
|
||||
guard Darwin.pipe(&stdoutPipe) == 0,
|
||||
Darwin.pipe(&stderrPipe) == 0 else {
|
||||
Logger.error("Failed to create stdout/stderr pipes")
|
||||
return
|
||||
}
|
||||
|
||||
dup2(stdoutPipe[1], STDOUT_FILENO)
|
||||
dup2(stderrPipe[1], STDERR_FILENO)
|
||||
close(stdoutPipe[1])
|
||||
close(stderrPipe[1])
|
||||
|
||||
stdoutReadSource = createReader(
|
||||
readPipe: stdoutPipe[0],
|
||||
writeToOriginal: originalStdout,
|
||||
label: "stdout"
|
||||
)
|
||||
|
||||
stderrReadSource = createReader(
|
||||
readPipe: stderrPipe[0],
|
||||
writeToOriginal: originalStderr,
|
||||
label: "stderr"
|
||||
)
|
||||
}
|
||||
|
||||
private func createReader(
|
||||
readPipe: Int32,
|
||||
writeToOriginal: Int32,
|
||||
label: String
|
||||
) -> DispatchSourceRead {
|
||||
let source = DispatchSource.makeReadSource(
|
||||
fileDescriptor: readPipe,
|
||||
queue: .global(qos: .utility)
|
||||
)
|
||||
|
||||
source.setEventHandler {
|
||||
let bufferSize = 4096
|
||||
var buffer = [UInt8](repeating: 0, count: bufferSize)
|
||||
let bytesRead = read(readPipe, &buffer, bufferSize)
|
||||
|
||||
if bytesRead > 0 {
|
||||
let output = String(
|
||||
bytes: buffer[0..<bytesRead],
|
||||
encoding: .utf8
|
||||
) ?? ""
|
||||
|
||||
let trimmed = output.trimmingCharacters(in: .newlines)
|
||||
if !trimmed.isEmpty {
|
||||
// we're sending stderr to oslog, so we need to avoid recursive calls
|
||||
if trimmed.hasPrefix("OSLOG-") {
|
||||
// make sure the system can parse the oslogs
|
||||
write(writeToOriginal, &buffer, bytesRead)
|
||||
} else {
|
||||
Logger.info("[\(label)] \(trimmed)")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
source.setCancelHandler {
|
||||
close(readPipe)
|
||||
}
|
||||
|
||||
source.resume()
|
||||
return source
|
||||
}
|
||||
}
|
||||
|
||||
/// Wrapper class for os_log function
|
||||
public class Logger {
|
||||
private static var _enabled = false
|
||||
public static var enabled: Bool {
|
||||
get {
|
||||
#if DEBUG
|
||||
return true
|
||||
#else
|
||||
return _enabled
|
||||
#endif
|
||||
}
|
||||
set {
|
||||
Logger._enabled = newValue
|
||||
}
|
||||
}
|
||||
|
||||
static func log(_ items: [Any], category: String, type: OSLogType) {
|
||||
if Logger.enabled {
|
||||
var message = ""
|
||||
let last = items.count - 1
|
||||
for (index, item) in items.enumerated() {
|
||||
message += "\(item)"
|
||||
if index != last {
|
||||
message += " "
|
||||
}
|
||||
}
|
||||
|
||||
let log = OSLog(subsystem: Bundle.main.bundleIdentifier ?? "-", category: category)
|
||||
os_log("%{public}@", log: log, type: type, String(message.prefix(4068)))
|
||||
}
|
||||
}
|
||||
|
||||
public static func debug(_ items: Any..., category: String = "app") {
|
||||
#if DEBUG
|
||||
Logger.log(items, category: category, type: OSLogType.default)
|
||||
#else
|
||||
Logger.log(items, category: category, type: OSLogType.debug)
|
||||
#endif
|
||||
}
|
||||
|
||||
public static func info(_ items: Any..., category: String = "app") {
|
||||
#if DEBUG
|
||||
Logger.log(items, category: category, type: OSLogType.default)
|
||||
#else
|
||||
Logger.log(items, category: category, type: OSLogType.info)
|
||||
#endif
|
||||
}
|
||||
|
||||
public static func error(_ items: Any..., category: String = "app") {
|
||||
Logger.log(items, category: category, type: OSLogType.error)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,82 @@
|
||||
// Copyright 2019-2024 Tauri Programme within The Commons Conservancy
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
import WebKit
|
||||
import os.log
|
||||
|
||||
struct RegisterListenerArgs: Decodable {
|
||||
let event: String
|
||||
let handler: Channel
|
||||
}
|
||||
|
||||
struct RemoveListenerArgs: Decodable {
|
||||
let event: String
|
||||
let channelId: UInt64
|
||||
}
|
||||
|
||||
open class Plugin: NSObject {
|
||||
public let manager: PluginManager = PluginManager.shared
|
||||
var config: String = "{}"
|
||||
private var listeners = [String: [Channel]]()
|
||||
|
||||
internal func setConfig(_ config: String) {
|
||||
self.config = config
|
||||
}
|
||||
|
||||
public func parseConfig<T: Decodable>(_ type: T.Type) throws -> T {
|
||||
let jsonData = self.config.data(using: .utf8)!
|
||||
let decoder = JSONDecoder()
|
||||
return try decoder.decode(type, from: jsonData)
|
||||
}
|
||||
|
||||
@objc open func load(webview: WKWebView) {}
|
||||
|
||||
@objc open func checkPermissions(_ invoke: Invoke) {
|
||||
invoke.resolve()
|
||||
}
|
||||
|
||||
@objc open func requestPermissions(_ invoke: Invoke) {
|
||||
invoke.resolve()
|
||||
}
|
||||
|
||||
public func trigger(_ event: String, data: JSObject) {
|
||||
if let eventListeners = listeners[event] {
|
||||
for channel in eventListeners {
|
||||
channel.send(data)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public func trigger<T: Encodable>(_ event: String, data: T) throws {
|
||||
if let eventListeners = listeners[event] {
|
||||
for channel in eventListeners {
|
||||
try channel.send(data)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@objc func registerListener(_ invoke: Invoke) throws {
|
||||
let args = try invoke.parseArgs(RegisterListenerArgs.self)
|
||||
|
||||
if var eventListeners = listeners[args.event] {
|
||||
eventListeners.append(args.handler)
|
||||
listeners[args.event] = eventListeners
|
||||
} else {
|
||||
listeners[args.event] = [args.handler]
|
||||
}
|
||||
|
||||
invoke.resolve()
|
||||
}
|
||||
|
||||
@objc func removeListener(_ invoke: Invoke) throws {
|
||||
let args = try invoke.parseArgs(RemoveListenerArgs.self)
|
||||
|
||||
if let eventListeners = listeners[args.event] {
|
||||
|
||||
listeners[args.event] = eventListeners.filter { $0.id != args.channelId }
|
||||
}
|
||||
|
||||
invoke.resolve()
|
||||
}
|
||||
}
|
||||
157
crates/tauri/mobile/ios-api/Sources/Tauri/Tauri.swift
Normal file
157
crates/tauri/mobile/ios-api/Sources/Tauri/Tauri.swift
Normal file
@@ -0,0 +1,157 @@
|
||||
// Copyright 2019-2024 Tauri Programme within The Commons Conservancy
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
import Foundation
|
||||
import SwiftRs
|
||||
import UIKit
|
||||
import WebKit
|
||||
import os.log
|
||||
|
||||
class PluginHandle {
|
||||
var instance: Plugin
|
||||
var loaded = false
|
||||
|
||||
init(plugin: Plugin) {
|
||||
instance = plugin
|
||||
}
|
||||
}
|
||||
|
||||
public class PluginManager {
|
||||
static let shared: PluginManager = PluginManager()
|
||||
public var viewController: UIViewController?
|
||||
var plugins: [String: PluginHandle] = [:]
|
||||
var ipcDispatchQueue = DispatchQueue(label: "ipc")
|
||||
public var isSimEnvironment: Bool {
|
||||
#if targetEnvironment(simulator)
|
||||
return true
|
||||
#else
|
||||
return false
|
||||
#endif
|
||||
}
|
||||
|
||||
public func assetUrl(fromLocalURL url: URL?) -> URL? {
|
||||
guard let inputURL = url else {
|
||||
return nil
|
||||
}
|
||||
|
||||
return URL(string: "asset://localhost")!.appendingPathComponent(inputURL.path)
|
||||
}
|
||||
|
||||
func onWebviewCreated(_ webview: WKWebView) {
|
||||
for (_, handle) in plugins {
|
||||
if !handle.loaded {
|
||||
handle.instance.load(webview: webview)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func load<P: Plugin>(name: String, plugin: P, config: String, webview: WKWebView?) {
|
||||
plugin.setConfig(config)
|
||||
let handle = PluginHandle(plugin: plugin)
|
||||
if let webview = webview {
|
||||
handle.instance.load(webview: webview)
|
||||
handle.loaded = true
|
||||
}
|
||||
plugins[name] = handle
|
||||
}
|
||||
|
||||
func invoke(name: String, invoke: Invoke) {
|
||||
if let plugin = plugins[name] {
|
||||
ipcDispatchQueue.async {
|
||||
let selectorWithCompletionHandler = Selector(("\(invoke.command):completionHandler:"))
|
||||
let selectorWithThrows = Selector(("\(invoke.command):error:"))
|
||||
|
||||
if plugin.instance.responds(to: selectorWithCompletionHandler) {
|
||||
let completion: @convention(block) (NSError?) -> Void = { error in
|
||||
if let error = error {
|
||||
invoke.reject("\(error)")
|
||||
}
|
||||
}
|
||||
|
||||
let blockObj: AnyObject = unsafeBitCast(completion, to: AnyObject.self)
|
||||
let imp = plugin.instance.method(for: selectorWithCompletionHandler)
|
||||
|
||||
typealias Fn = @convention(c) (AnyObject, Selector, Invoke, AnyObject) -> Void
|
||||
let fn = unsafeBitCast(imp, to: Fn.self)
|
||||
fn(plugin.instance, selectorWithCompletionHandler, invoke, blockObj)
|
||||
} else if plugin.instance.responds(to: selectorWithThrows) {
|
||||
var error: NSError? = nil
|
||||
withUnsafeMutablePointer(to: &error) {
|
||||
let methodIMP: IMP! = plugin.instance.method(for: selectorWithThrows)
|
||||
unsafeBitCast(
|
||||
methodIMP, to: (@convention(c) (Any?, Selector, Invoke, OpaquePointer) -> Void).self)(
|
||||
plugin.instance, selectorWithThrows, invoke, OpaquePointer($0))
|
||||
}
|
||||
if let error = error {
|
||||
invoke.reject("\(error)")
|
||||
// TODO: app crashes without this leak
|
||||
let _ = Unmanaged.passRetained(error)
|
||||
}
|
||||
} else {
|
||||
let selector = Selector(("\(invoke.command):"))
|
||||
if plugin.instance.responds(to: selector) {
|
||||
plugin.instance.perform(selector, with: invoke)
|
||||
} else {
|
||||
invoke.reject("No command \(invoke.command) found for plugin \(name)")
|
||||
}
|
||||
}
|
||||
}
|
||||
} else {
|
||||
invoke.reject("Plugin \(name) not initialized")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
extension PluginManager: NSCopying {
|
||||
public func copy(with zone: NSZone? = nil) -> Any {
|
||||
return self
|
||||
}
|
||||
}
|
||||
|
||||
private var stdoutRedirector: StdoutRedirector?
|
||||
|
||||
@_cdecl("log_stdout")
|
||||
func logStdout() {
|
||||
stdoutRedirector = StdoutRedirector()
|
||||
stdoutRedirector!.start()
|
||||
}
|
||||
|
||||
@_cdecl("register_plugin")
|
||||
func registerPlugin(name: SRString, plugin: NSObject, config: SRString, webview: WKWebView?) {
|
||||
PluginManager.shared.load(
|
||||
name: name.toString(),
|
||||
plugin: plugin as! Plugin,
|
||||
config: config.toString(),
|
||||
webview: webview
|
||||
)
|
||||
}
|
||||
|
||||
@_cdecl("on_webview_created")
|
||||
func onWebviewCreated(webview: WKWebView, viewController: UIViewController) {
|
||||
PluginManager.shared.viewController = viewController
|
||||
PluginManager.shared.onWebviewCreated(webview)
|
||||
}
|
||||
|
||||
@_cdecl("run_plugin_command")
|
||||
func runCommand(
|
||||
id: Int,
|
||||
name: SRString,
|
||||
command: SRString,
|
||||
data: SRString,
|
||||
callback: @escaping @convention(c) (Int, Bool, UnsafePointer<CChar>) -> Void,
|
||||
sendChannelData: @escaping @convention(c) (UInt64, UnsafePointer<CChar>) -> Void
|
||||
) {
|
||||
let callbackId: UInt64 = 0
|
||||
let errorId: UInt64 = 1
|
||||
let invoke = Invoke(
|
||||
command: command.toString(), callback: callbackId, error: errorId,
|
||||
sendResponse: { (fn: UInt64, payload: String?) -> Void in
|
||||
let success = fn == callbackId
|
||||
callback(id, success, payload ?? "null")
|
||||
},
|
||||
sendChannelData: { (id: UInt64, payload: String) -> Void in
|
||||
sendChannelData(id, payload)
|
||||
}, data: data.toString())
|
||||
PluginManager.shared.invoke(name: name.toString(), invoke: invoke)
|
||||
}
|
||||
15
crates/tauri/mobile/ios-api/Sources/Tauri/UiUtils.swift
Normal file
15
crates/tauri/mobile/ios-api/Sources/Tauri/UiUtils.swift
Normal file
@@ -0,0 +1,15 @@
|
||||
// Copyright 2019-2024 Tauri Programme within The Commons Conservancy
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
import UIKit
|
||||
|
||||
public class UIUtils {
|
||||
public static func centerPopover(rootViewController: UIViewController?, popoverController: UIViewController) {
|
||||
if let viewController = rootViewController {
|
||||
popoverController.popoverPresentationController?.sourceRect = CGRect(x: viewController.view.center.x, y: viewController.view.center.y, width: 0, height: 0)
|
||||
popoverController.popoverPresentationController?.sourceView = viewController.view
|
||||
popoverController.popoverPresentationController?.permittedArrowDirections = UIPopoverArrowDirection.up
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
// Copyright 2019-2024 Tauri Programme within The Commons Conservancy
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
import XCTest
|
||||
@testable import Tauri
|
||||
|
||||
final class TauriTests: XCTestCase {
|
||||
func testExample() throws {
|
||||
// This is an example of a functional test case.
|
||||
// Use XCTAssert and related functions to verify your tests produce the correct
|
||||
// results.
|
||||
XCTAssertEqual(Tauri().text, "Hello, World!")
|
||||
}
|
||||
}
|
||||
5
crates/tauri/mobile/proguard-tauri.pro
Normal file
5
crates/tauri/mobile/proguard-tauri.pro
Normal file
@@ -0,0 +1,5 @@
|
||||
# THIS IS AN AUTOGENERATED FILE. DO NOT EDIT THIS FILE DIRECTLY.
|
||||
|
||||
-keep class $PACKAGE.TauriActivity {
|
||||
public app.tauri.plugin.PluginManager getPluginManager();
|
||||
}
|
||||
Reference in New Issue
Block a user