diff --git a/.scripts/ci/check-license-header.js b/.scripts/ci/check-license-header.js
index a322957d..d23d1415 100644
--- a/.scripts/ci/check-license-header.js
+++ b/.scripts/ci/check-license-header.js
@@ -27,7 +27,7 @@ const ignore = [
async function checkFile(file) {
if (
extensions.some((e) => file.endsWith(e)) &&
- !ignore.some((i) => file.endsWith(i))
+ !ignore.some((i) => file.includes(i))
) {
const fileStream = fs.createReadStream(file);
const rl = readline.createInterface({
diff --git a/Cargo.lock b/Cargo.lock
index 110a10f4..22029c06 100644
--- a/Cargo.lock
+++ b/Cargo.lock
@@ -239,6 +239,7 @@ dependencies = [
"tauri-build",
"tauri-plugin-app",
"tauri-plugin-barcode-scanner",
+ "tauri-plugin-camera",
"tauri-plugin-cli",
"tauri-plugin-clipboard-manager",
"tauri-plugin-dialog",
@@ -5691,6 +5692,17 @@ dependencies = [
"thiserror",
]
+[[package]]
+name = "tauri-plugin-camera"
+version = "1.0.0"
+dependencies = [
+ "serde",
+ "serde_json",
+ "tauri",
+ "tauri-build",
+ "thiserror",
+]
+
[[package]]
name = "tauri-plugin-cli"
version = "2.0.0-alpha.2"
diff --git a/examples/api/package.json b/examples/api/package.json
index 8457e313..3dff6d30 100644
--- a/examples/api/package.json
+++ b/examples/api/package.json
@@ -12,6 +12,7 @@
"@tauri-apps/api": "2.0.0-alpha.8",
"@tauri-apps/plugin-app": "2.0.0-alpha.1",
"@tauri-apps/plugin-barcode-scanner": "2.0.0-alpha.0",
+ "@tauri-apps/plugin-camera": "1.0.0",
"@tauri-apps/plugin-cli": "2.0.0-alpha.1",
"@tauri-apps/plugin-clipboard-manager": "2.0.0-alpha.1",
"@tauri-apps/plugin-dialog": "2.0.0-alpha.1",
diff --git a/examples/api/src-tauri/Cargo.toml b/examples/api/src-tauri/Cargo.toml
index 8fb867f5..a659ab68 100644
--- a/examples/api/src-tauri/Cargo.toml
+++ b/examples/api/src-tauri/Cargo.toml
@@ -48,6 +48,7 @@ tauri-plugin-updater = { path = "../../../plugins/updater", version = "2.0.0-alp
[target."cfg(any(target_os = \"android\", target_os = \"ios\"))".dependencies]
tauri-plugin-barcode-scanner = { path = "../../../plugins/barcode-scanner/", version = "2.0.0-alpha.0" }
+tauri-plugin-camera = { path = "../../../plugins/camera/", version = "1.0.0" }
[target."cfg(target_os = \"windows\")".dependencies]
window-shadows = "0.2"
diff --git a/examples/api/src-tauri/src/lib.rs b/examples/api/src-tauri/src/lib.rs
index 2e04967a..5559f7ef 100644
--- a/examples/api/src-tauri/src/lib.rs
+++ b/examples/api/src-tauri/src/lib.rs
@@ -56,6 +56,11 @@ pub fn run() {
app.handle().plugin(tauri_plugin_barcode_scanner::init())?;
}
+ #[cfg(mobile)]
+ {
+ app.handle().plugin(tauri_plugin_camera::init())?;
+ }
+
let mut window_builder = WindowBuilder::new(app, "main", WindowUrl::default());
#[cfg(desktop)]
{
diff --git a/examples/api/src/App.svelte b/examples/api/src/App.svelte
index 9bf1574b..e37ac9c9 100644
--- a/examples/api/src/App.svelte
+++ b/examples/api/src/App.svelte
@@ -18,6 +18,7 @@
import Clipboard from "./views/Clipboard.svelte";
import WebRTC from "./views/WebRTC.svelte";
import Scanner from "./views/Scanner.svelte";
+ import Camera from "./views/Camera.svelte";
import App from "./views/App.svelte";
import { onMount } from "svelte";
@@ -119,7 +120,12 @@
component: Scanner,
icon: "i-ph-scan",
},
- ];
+ isMobile && {
+ label: 'Camera',
+ component: Camera,
+ icon: 'i-codicon-clippy'
+ },
+ ]
let selected = views[0];
function select(view) {
diff --git a/examples/api/src/views/Camera.svelte b/examples/api/src/views/Camera.svelte
new file mode 100644
index 00000000..b05a19ef
--- /dev/null
+++ b/examples/api/src/views/Camera.svelte
@@ -0,0 +1,54 @@
+
+
+
+ {#if imageSrc}
+

+ {/if}
+
+
+
+
+
+
+
diff --git a/plugins/camera/.gitignore b/plugins/camera/.gitignore
new file mode 100644
index 00000000..3683c6c5
--- /dev/null
+++ b/plugins/camera/.gitignore
@@ -0,0 +1,5 @@
+/target
+/Cargo.lock
+
+!dist-js
+.tauri
\ No newline at end of file
diff --git a/plugins/camera/Cargo.toml b/plugins/camera/Cargo.toml
new file mode 100644
index 00000000..36d2ba83
--- /dev/null
+++ b/plugins/camera/Cargo.toml
@@ -0,0 +1,18 @@
+[package]
+name = "tauri-plugin-camera"
+version = "1.0.0"
+description = "Ask the user take a photo with the camera or select an image from the gallery."
+authors.workspace = true
+license.workspace = true
+edition.workspace = true
+rust-version.workspace = true
+links = "tauri-plugin-camera"
+
+[dependencies]
+serde.workspace = true
+serde_json.workspace = true
+tauri.workspace = true
+thiserror.workspace = true
+
+[build-dependencies]
+tauri-build.workspace = true
diff --git a/plugins/camera/LICENSE.spdx b/plugins/camera/LICENSE.spdx
new file mode 100644
index 00000000..cdd0df5a
--- /dev/null
+++ b/plugins/camera/LICENSE.spdx
@@ -0,0 +1,20 @@
+SPDXVersion: SPDX-2.1
+DataLicense: CC0-1.0
+PackageName: tauri
+DataFormat: SPDXRef-1
+PackageSupplier: Organization: The Tauri Programme in the Commons Conservancy
+PackageHomePage: https://tauri.app
+PackageLicenseDeclared: Apache-2.0
+PackageLicenseDeclared: MIT
+PackageCopyrightText: 2019-2022, The Tauri Programme in the Commons Conservancy
+PackageSummary: Tauri is a rust project that enables developers to make secure
+and small desktop applications using a web frontend.
+
+PackageComment: The package includes the following libraries; see
+Relationship information.
+
+Created: 2019-05-20T09:00:00Z
+PackageDownloadLocation: git://github.com/tauri-apps/tauri
+PackageDownloadLocation: git+https://github.com/tauri-apps/tauri.git
+PackageDownloadLocation: git+ssh://github.com/tauri-apps/tauri.git
+Creator: Person: Daniel Thompson-Yvetot
\ No newline at end of file
diff --git a/plugins/camera/LICENSE_APACHE-2.0 b/plugins/camera/LICENSE_APACHE-2.0
new file mode 100644
index 00000000..4947287f
--- /dev/null
+++ b/plugins/camera/LICENSE_APACHE-2.0
@@ -0,0 +1,177 @@
+
+ Apache License
+ Version 2.0, January 2004
+ http://www.apache.org/licenses/
+
+ TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
+
+ 1. Definitions.
+
+ "License" shall mean the terms and conditions for use, reproduction,
+ and distribution as defined by Sections 1 through 9 of this document.
+
+ "Licensor" shall mean the copyright owner or entity authorized by
+ the copyright owner that is granting the License.
+
+ "Legal Entity" shall mean the union of the acting entity and all
+ other entities that control, are controlled by, or are under common
+ control with that entity. For the purposes of this definition,
+ "control" means (i) the power, direct or indirect, to cause the
+ direction or management of such entity, whether by contract or
+ otherwise, or (ii) ownership of fifty percent (50%) or more of the
+ outstanding shares, or (iii) beneficial ownership of such entity.
+
+ "You" (or "Your") shall mean an individual or Legal Entity
+ exercising permissions granted by this License.
+
+ "Source" form shall mean the preferred form for making modifications,
+ including but not limited to software source code, documentation
+ source, and configuration files.
+
+ "Object" form shall mean any form resulting from mechanical
+ transformation or translation of a Source form, including but
+ not limited to compiled object code, generated documentation,
+ and conversions to other media types.
+
+ "Work" shall mean the work of authorship, whether in Source or
+ Object form, made available under the License, as indicated by a
+ copyright notice that is included in or attached to the work
+ (an example is provided in the Appendix below).
+
+ "Derivative Works" shall mean any work, whether in Source or Object
+ form, that is based on (or derived from) the Work and for which the
+ editorial revisions, annotations, elaborations, or other modifications
+ represent, as a whole, an original work of authorship. For the purposes
+ of this License, Derivative Works shall not include works that remain
+ separable from, or merely link (or bind by name) to the interfaces of,
+ the Work and Derivative Works thereof.
+
+ "Contribution" shall mean any work of authorship, including
+ the original version of the Work and any modifications or additions
+ to that Work or Derivative Works thereof, that is intentionally
+ submitted to Licensor for inclusion in the Work by the copyright owner
+ or by an individual or Legal Entity authorized to submit on behalf of
+ the copyright owner. For the purposes of this definition, "submitted"
+ means any form of electronic, verbal, or written communication sent
+ to the Licensor or its representatives, including but not limited to
+ communication on electronic mailing lists, source code control systems,
+ and issue tracking systems that are managed by, or on behalf of, the
+ Licensor for the purpose of discussing and improving the Work, but
+ excluding communication that is conspicuously marked or otherwise
+ designated in writing by the copyright owner as "Not a Contribution."
+
+ "Contributor" shall mean Licensor and any individual or Legal Entity
+ on behalf of whom a Contribution has been received by Licensor and
+ subsequently incorporated within the Work.
+
+ 2. Grant of Copyright License. Subject to the terms and conditions of
+ this License, each Contributor hereby grants to You a perpetual,
+ worldwide, non-exclusive, no-charge, royalty-free, irrevocable
+ copyright license to reproduce, prepare Derivative Works of,
+ publicly display, publicly perform, sublicense, and distribute the
+ Work and such Derivative Works in Source or Object form.
+
+ 3. Grant of Patent License. Subject to the terms and conditions of
+ this License, each Contributor hereby grants to You a perpetual,
+ worldwide, non-exclusive, no-charge, royalty-free, irrevocable
+ (except as stated in this section) patent license to make, have made,
+ use, offer to sell, sell, import, and otherwise transfer the Work,
+ where such license applies only to those patent claims licensable
+ by such Contributor that are necessarily infringed by their
+ Contribution(s) alone or by combination of their Contribution(s)
+ with the Work to which such Contribution(s) was submitted. If You
+ institute patent litigation against any entity (including a
+ cross-claim or counterclaim in a lawsuit) alleging that the Work
+ or a Contribution incorporated within the Work constitutes direct
+ or contributory patent infringement, then any patent licenses
+ granted to You under this License for that Work shall terminate
+ as of the date such litigation is filed.
+
+ 4. Redistribution. You may reproduce and distribute copies of the
+ Work or Derivative Works thereof in any medium, with or without
+ modifications, and in Source or Object form, provided that You
+ meet the following conditions:
+
+ (a) You must give any other recipients of the Work or
+ Derivative Works a copy of this License; and
+
+ (b) You must cause any modified files to carry prominent notices
+ stating that You changed the files; and
+
+ (c) You must retain, in the Source form of any Derivative Works
+ that You distribute, all copyright, patent, trademark, and
+ attribution notices from the Source form of the Work,
+ excluding those notices that do not pertain to any part of
+ the Derivative Works; and
+
+ (d) If the Work includes a "NOTICE" text file as part of its
+ distribution, then any Derivative Works that You distribute must
+ include a readable copy of the attribution notices contained
+ within such NOTICE file, excluding those notices that do not
+ pertain to any part of the Derivative Works, in at least one
+ of the following places: within a NOTICE text file distributed
+ as part of the Derivative Works; within the Source form or
+ documentation, if provided along with the Derivative Works; or,
+ within a display generated by the Derivative Works, if and
+ wherever such third-party notices normally appear. The contents
+ of the NOTICE file are for informational purposes only and
+ do not modify the License. You may add Your own attribution
+ notices within Derivative Works that You distribute, alongside
+ or as an addendum to the NOTICE text from the Work, provided
+ that such additional attribution notices cannot be construed
+ as modifying the License.
+
+ You may add Your own copyright statement to Your modifications and
+ may provide additional or different license terms and conditions
+ for use, reproduction, or distribution of Your modifications, or
+ for any such Derivative Works as a whole, provided Your use,
+ reproduction, and distribution of the Work otherwise complies with
+ the conditions stated in this License.
+
+ 5. Submission of Contributions. Unless You explicitly state otherwise,
+ any Contribution intentionally submitted for inclusion in the Work
+ by You to the Licensor shall be under the terms and conditions of
+ this License, without any additional terms or conditions.
+ Notwithstanding the above, nothing herein shall supersede or modify
+ the terms of any separate license agreement you may have executed
+ with Licensor regarding such Contributions.
+
+ 6. Trademarks. This License does not grant permission to use the trade
+ names, trademarks, service marks, or product names of the Licensor,
+ except as required for reasonable and customary use in describing the
+ origin of the Work and reproducing the content of the NOTICE file.
+
+ 7. Disclaimer of Warranty. Unless required by applicable law or
+ agreed to in writing, Licensor provides the Work (and each
+ Contributor provides its Contributions) on an "AS IS" BASIS,
+ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
+ implied, including, without limitation, any warranties or conditions
+ of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
+ PARTICULAR PURPOSE. You are solely responsible for determining the
+ appropriateness of using or redistributing the Work and assume any
+ risks associated with Your exercise of permissions under this License.
+
+ 8. Limitation of Liability. In no event and under no legal theory,
+ whether in tort (including negligence), contract, or otherwise,
+ unless required by applicable law (such as deliberate and grossly
+ negligent acts) or agreed to in writing, shall any Contributor be
+ liable to You for damages, including any direct, indirect, special,
+ incidental, or consequential damages of any character arising as a
+ result of this License or out of the use or inability to use the
+ Work (including but not limited to damages for loss of goodwill,
+ work stoppage, computer failure or malfunction, or any and all
+ other commercial damages or losses), even if such Contributor
+ has been advised of the possibility of such damages.
+
+ 9. Accepting Warranty or Additional Liability. While redistributing
+ the Work or Derivative Works thereof, You may choose to offer,
+ and charge a fee for, acceptance of support, warranty, indemnity,
+ or other liability obligations and/or rights consistent with this
+ License. However, in accepting such obligations, You may act only
+ on Your own behalf and on Your sole responsibility, not on behalf
+ of any other Contributor, and only if You agree to indemnify,
+ defend, and hold each Contributor harmless for any liability
+ incurred by, or claims asserted against, such Contributor by reason
+ of your accepting any such warranty or additional liability.
+
+ END OF TERMS AND CONDITIONS
\ No newline at end of file
diff --git a/plugins/camera/LICENSE_MIT b/plugins/camera/LICENSE_MIT
new file mode 100644
index 00000000..4d754725
--- /dev/null
+++ b/plugins/camera/LICENSE_MIT
@@ -0,0 +1,21 @@
+MIT License
+
+Copyright (c) 2017 - Present Tauri Apps Contributors
+
+Permission is hereby granted, free of charge, to any person obtaining a copy
+of this software and associated documentation files (the "Software"), to deal
+in the Software without restriction, including without limitation the rights
+to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+copies of the Software, and to permit persons to whom the Software is
+furnished to do so, subject to the following conditions:
+
+The above copyright notice and this permission notice shall be included in all
+copies or substantial portions of the Software.
+
+THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
+SOFTWARE.
\ No newline at end of file
diff --git a/plugins/camera/README.md b/plugins/camera/README.md
new file mode 100644
index 00000000..2466f739
--- /dev/null
+++ b/plugins/camera/README.md
@@ -0,0 +1,84 @@
+# Camera Plugin
+
+Prompt the user to take a photo using the camera or pick an image from the gallery. Mobile only.
+
+## Install
+
+There are three general methods of installation that we can recommend.
+
+1. Use crates.io and npm (easiest, and requires you to trust that our publishing pipeline worked)
+2. Pull sources directly from Github using git tags / revision hashes (most secure)
+3. Git submodule install this repo in your tauri project and then use file protocol to ingest the source (most secure, but inconvenient to use)
+
+Install the Core plugin by adding the following to your `Cargo.toml` file:
+
+```toml
+[dependencies]
+tauri-plugin-camera = { git = "https://github.com/tauri-apps/plugins-workspace", branch = "feat/camera" }
+```
+
+You can install the JavaScript Guest bindings using your preferred JavaScript package manager:
+
+> Note: Since most JavaScript package managers are unable to install packages from git monorepos we provide read-only mirrors of each plugin. This makes installation option 2 more ergonomic to use.
+
+```sh
+npm install 'https://gitpkg.now.sh/tauri-apps/plugins-workspace/plugins/camera?feat/camera'
+# or
+yarn add 'https://gitpkg.now.sh/tauri-apps/plugins-workspace/plugins/camera?feat/camera'
+```
+
+**NOT AVAILABLE YET, WILL BE READY WHEN WE MERGE THE BRANCH:**
+
+```sh
+pnpm add https://github.com/tauri-apps/tauri-plugin-camera
+# or
+npm add https://github.com/tauri-apps/tauri-plugin-camera
+# or
+yarn add https://github.com/tauri-apps/tauri-plugin-camera
+```
+
+## Usage
+
+Register the core plugin with Tauri:
+
+`src-tauri/src/lib.rs`
+
+```rust
+#[cfg_attr(mobile, tauri::mobile_entry_point)]
+pub fn run() {
+ tauri::Builder::default()
+ .plugin(tauri_plugin_camera::init())
+ .run(tauri::generate_context!())
+ .expect("error while running tauri application");
+}
+
+```
+
+Afterwards all the plugin's APIs are available through the JavaScript guest bindings:
+
+```javascript
+import { getPhoto } from "tauri-plugin-camera-api";
+const image = await getPhoto();
+```
+
+### Android
+
+Add the following permissions on the `src-tauri/gen/android/$(APPNAME)/app/src/main/AndroidManifest.xml` file:
+
+```xml
+
+
+```
+
+### iOS
+
+Configure the following properties on `src-tauri/gen/apple/$(APPNAME)_iOS/Info.plist`:
+
+```xml
+NSCameraUsageDescription
+Description for the camera usage here
+NSPhotoLibraryAddUsageDescription
+Description for the library add usage here
+NSPhotoLibraryUsageDescription
+Description for the library usage here
+```
diff --git a/plugins/camera/android/.gitignore b/plugins/camera/android/.gitignore
new file mode 100644
index 00000000..c0f21ec2
--- /dev/null
+++ b/plugins/camera/android/.gitignore
@@ -0,0 +1,2 @@
+/build
+/.tauri
diff --git a/plugins/camera/android/build.gradle.kts b/plugins/camera/android/build.gradle.kts
new file mode 100644
index 00000000..1b536c19
--- /dev/null
+++ b/plugins/camera/android/build.gradle.kts
@@ -0,0 +1,46 @@
+plugins {
+ id("com.android.library")
+ id("org.jetbrains.kotlin.android")
+}
+
+android {
+ namespace = "app.tauri.camera"
+ compileSdk = 32
+
+ defaultConfig {
+ minSdk = 24
+ targetSdk = 32
+
+ testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
+ consumerProguardFiles("consumer-rules.pro")
+ }
+
+ buildTypes {
+ release {
+ isMinifyEnabled = false
+ proguardFiles(
+ getDefaultProguardFile("proguard-android-optimize.txt"),
+ "proguard-rules.pro"
+ )
+ }
+ }
+ compileOptions {
+ sourceCompatibility = JavaVersion.VERSION_1_8
+ targetCompatibility = JavaVersion.VERSION_1_8
+ }
+ kotlinOptions {
+ jvmTarget = "1.8"
+ }
+}
+
+dependencies {
+
+ implementation("androidx.core:core-ktx:1.9.0")
+ implementation("androidx.appcompat:appcompat:1.6.0")
+ implementation("com.google.android.material:material:1.7.0")
+ testImplementation("junit:junit:4.13.2")
+ androidTestImplementation("androidx.test.ext:junit:1.1.5")
+ androidTestImplementation("androidx.test.espresso:espresso-core:3.5.1")
+ implementation("androidx.exifinterface:exifinterface:1.3.3")
+ implementation(project(":tauri-android"))
+}
diff --git a/plugins/camera/android/proguard-rules.pro b/plugins/camera/android/proguard-rules.pro
new file mode 100644
index 00000000..481bb434
--- /dev/null
+++ b/plugins/camera/android/proguard-rules.pro
@@ -0,0 +1,21 @@
+# Add project specific ProGuard rules here.
+# You can control the set of applied configuration files using the
+# proguardFiles setting in build.gradle.
+#
+# For more details, see
+# http://developer.android.com/guide/developing/tools/proguard.html
+
+# If your project uses WebView with JS, uncomment the following
+# and specify the fully qualified class name to the JavaScript interface
+# class:
+#-keepclassmembers class fqcn.of.javascript.interface.for.webview {
+# public *;
+#}
+
+# Uncomment this to preserve the line number information for
+# debugging stack traces.
+#-keepattributes SourceFile,LineNumberTable
+
+# If you keep the line number information, uncomment this to
+# hide the original source file name.
+#-renamesourcefileattribute SourceFile
\ No newline at end of file
diff --git a/plugins/camera/android/settings.gradle b/plugins/camera/android/settings.gradle
new file mode 100644
index 00000000..14a752e4
--- /dev/null
+++ b/plugins/camera/android/settings.gradle
@@ -0,0 +1,2 @@
+include ':tauri-android'
+project(':tauri-android').projectDir = new File('./.tauri/tauri-api')
diff --git a/plugins/camera/android/src/androidTest/java/app/tauri/camera/ExampleInstrumentedTest.kt b/plugins/camera/android/src/androidTest/java/app/tauri/camera/ExampleInstrumentedTest.kt
new file mode 100644
index 00000000..a2767ffe
--- /dev/null
+++ b/plugins/camera/android/src/androidTest/java/app/tauri/camera/ExampleInstrumentedTest.kt
@@ -0,0 +1,28 @@
+// Copyright 2019-2023 Tauri Programme within The Commons Conservancy
+// SPDX-License-Identifier: Apache-2.0
+// SPDX-License-Identifier: MIT
+
+package app.tauri.camera
+
+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.camera", appContext.packageName)
+ }
+}
diff --git a/plugins/camera/android/src/main/AndroidManifest.xml b/plugins/camera/android/src/main/AndroidManifest.xml
new file mode 100644
index 00000000..2da9cbd5
--- /dev/null
+++ b/plugins/camera/android/src/main/AndroidManifest.xml
@@ -0,0 +1,8 @@
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/plugins/camera/android/src/main/java/CameraBottomSheetDialogFragment.kt b/plugins/camera/android/src/main/java/CameraBottomSheetDialogFragment.kt
new file mode 100644
index 00000000..cfed64fd
--- /dev/null
+++ b/plugins/camera/android/src/main/java/CameraBottomSheetDialogFragment.kt
@@ -0,0 +1,106 @@
+// Copyright 2019-2023 Tauri Programme within The Commons Conservancy
+// SPDX-License-Identifier: Apache-2.0
+// SPDX-License-Identifier: MIT
+
+package app.tauri.camera
+
+import android.annotation.SuppressLint
+import android.app.Dialog
+import android.content.DialogInterface
+import android.graphics.Color
+import android.view.View
+import android.widget.LinearLayout
+import android.widget.TextView
+import androidx.coordinatorlayout.widget.CoordinatorLayout
+import com.google.android.material.bottomsheet.BottomSheetBehavior
+import com.google.android.material.bottomsheet.BottomSheetBehavior.BottomSheetCallback
+import com.google.android.material.bottomsheet.BottomSheetDialogFragment
+
+class CameraBottomSheetDialogFragment : BottomSheetDialogFragment() {
+ fun interface BottomSheetOnSelectedListener {
+ fun onSelected(index: Int)
+ }
+
+ fun interface BottomSheetOnCanceledListener {
+ fun onCanceled()
+ }
+
+ private var selectedListener: BottomSheetOnSelectedListener? = null
+ private var canceledListener: BottomSheetOnCanceledListener? = null
+ private var options: List? = null
+ private var title: String? = null
+ fun setTitle(title: String?) {
+ this.title = title
+ }
+
+ fun setOptions(
+ options: List?,
+ selectedListener: BottomSheetOnSelectedListener,
+ canceledListener: BottomSheetOnCanceledListener
+ ) {
+ this.options = options
+ this.selectedListener = selectedListener
+ this.canceledListener = canceledListener
+ }
+
+ override fun onCancel(dialog: DialogInterface) {
+ super.onCancel(dialog)
+ if (canceledListener != null) {
+ canceledListener!!.onCanceled()
+ }
+ }
+
+ private val mBottomSheetBehaviorCallback: BottomSheetCallback = object : BottomSheetCallback() {
+ override fun onStateChanged(bottomSheet: View, newState: Int) {
+ if (newState == BottomSheetBehavior.STATE_HIDDEN) {
+ dismiss()
+ }
+ }
+
+ override fun onSlide(bottomSheet: View, slideOffset: Float) {}
+ }
+
+ @SuppressLint("RestrictedApi")
+ override fun setupDialog(dialog: Dialog, style: Int) {
+ super.setupDialog(dialog, style)
+ if (options == null || options!!.size == 0) {
+ return
+ }
+ val scale = resources.displayMetrics.density
+ val layoutPaddingDp16 = 16.0f
+ val layoutPaddingDp12 = 12.0f
+ val layoutPaddingDp8 = 8.0f
+ val layoutPaddingPx16 = (layoutPaddingDp16 * scale + 0.5f).toInt()
+ val layoutPaddingPx12 = (layoutPaddingDp12 * scale + 0.5f).toInt()
+ val layoutPaddingPx8 = (layoutPaddingDp8 * scale + 0.5f).toInt()
+ val parentLayout = CoordinatorLayout(requireContext())
+ val layout = LinearLayout(context)
+ layout.orientation = LinearLayout.VERTICAL
+ layout.setPadding(layoutPaddingPx16, layoutPaddingPx16, layoutPaddingPx16, layoutPaddingPx16)
+ val ttv = TextView(context)
+ ttv.setTextColor(Color.parseColor("#757575"))
+ ttv.setPadding(layoutPaddingPx8, layoutPaddingPx8, layoutPaddingPx8, layoutPaddingPx8)
+ ttv.text = title
+ layout.addView(ttv)
+ for (i in options!!.indices) {
+ val tv = TextView(context)
+ tv.setTextColor(Color.parseColor("#000000"))
+ tv.setPadding(layoutPaddingPx12, layoutPaddingPx12, layoutPaddingPx12, layoutPaddingPx12)
+ tv.text = options!![i]
+ tv.setOnClickListener {
+ if (selectedListener != null) {
+ selectedListener!!.onSelected(i)
+ }
+ dismiss()
+ }
+ layout.addView(tv)
+ }
+ parentLayout.addView(layout.rootView)
+ dialog.setContentView(parentLayout.rootView)
+ val params = (parentLayout.parent as View).layoutParams as CoordinatorLayout.LayoutParams
+ val behavior = params.behavior
+ if (behavior != null && behavior is BottomSheetBehavior<*>) {
+ behavior.addBottomSheetCallback(mBottomSheetBehaviorCallback)
+ }
+ }
+}
diff --git a/plugins/camera/android/src/main/java/CameraPlugin.kt b/plugins/camera/android/src/main/java/CameraPlugin.kt
new file mode 100644
index 00000000..51db4007
--- /dev/null
+++ b/plugins/camera/android/src/main/java/CameraPlugin.kt
@@ -0,0 +1,808 @@
+// Copyright 2019-2023 Tauri Programme within The Commons Conservancy
+// SPDX-License-Identifier: Apache-2.0
+// SPDX-License-Identifier: MIT
+
+package app.tauri.camera
+
+import android.Manifest
+import android.app.Activity
+import android.content.*
+import android.content.pm.PackageManager
+import android.content.pm.ResolveInfo
+import android.graphics.Bitmap
+import android.graphics.BitmapFactory
+import android.net.Uri
+import android.os.Build
+import android.os.Bundle
+import android.os.Environment
+import android.os.Parcelable
+import android.provider.MediaStore
+import android.util.Base64
+import androidx.activity.result.ActivityResult
+import androidx.appcompat.app.AppCompatActivity
+import androidx.core.content.FileProvider
+import androidx.exifinterface.media.ExifInterface.*
+import app.tauri.*
+import app.tauri.annotation.*
+import app.tauri.plugin.*
+import org.json.JSONException
+import java.io.*
+import java.util.*
+import java.util.concurrent.Executor
+import java.util.concurrent.Executors
+
+enum class CameraSource(val source: String) {
+ PROMPT("PROMPT"), CAMERA("CAMERA"), PHOTOS("PHOTOS");
+}
+
+enum class CameraResultType(val type: String) {
+ BASE64("base64"), URI("uri"), DATAURL("dataUrl");
+}
+
+class CameraSettings {
+ var resultType: CameraResultType = CameraResultType.BASE64
+ var quality = DEFAULT_QUALITY
+ var isShouldResize = false
+ var isShouldCorrectOrientation = DEFAULT_CORRECT_ORIENTATION
+ var isSaveToGallery = DEFAULT_SAVE_IMAGE_TO_GALLERY
+ var isAllowEditing = false
+ var width = 0
+ var height = 0
+ var source: CameraSource = CameraSource.PROMPT
+
+ companion object {
+ const val DEFAULT_QUALITY = 90
+ const val DEFAULT_SAVE_IMAGE_TO_GALLERY = false
+ const val DEFAULT_CORRECT_ORIENTATION = true
+ }
+}
+
+private const val CAMERA = "camera"
+private const val PHOTOS = "photos"
+
+private const val INVALID_RESULT_TYPE_ERROR = "Invalid resultType option"
+private const val PERMISSION_DENIED_ERROR_CAMERA = "User denied access to camera"
+private const val PERMISSION_DENIED_ERROR_PHOTOS = "User denied access to photos"
+private const val NO_CAMERA_ERROR = "Device doesn't have a camera available"
+private const val NO_CAMERA_ACTIVITY_ERROR = "Unable to resolve camera activity"
+private const val NO_PHOTO_ACTIVITY_ERROR = "Unable to resolve photo activity"
+private const val IMAGE_FILE_SAVE_ERROR = "Unable to create photo on disk"
+private const val IMAGE_PROCESS_NO_FILE_ERROR = "Unable to process image, file not found on disk"
+private const val UNABLE_TO_PROCESS_IMAGE = "Unable to process image"
+private const val IMAGE_EDIT_ERROR = "Unable to edit image"
+private const val IMAGE_GALLERY_SAVE_ERROR = "Unable to save the image in the gallery"
+
+@TauriPlugin(
+ permissions = [
+ Permission(strings = [Manifest.permission.CAMERA], alias = "camera"),
+ Permission(
+ strings = [Manifest.permission.READ_EXTERNAL_STORAGE, Manifest.permission.WRITE_EXTERNAL_STORAGE],
+ alias = "photos"
+ )]
+)
+class CameraPlugin(private val activity: Activity): Plugin(activity) {
+ private var imageFileSavePath: String? = null
+ private var imageEditedFileSavePath: String? = null
+ private var imageFileUri: Uri? = null
+ private var imagePickedContentUri: Uri? = null
+ private var isEdited = false
+ private var isFirstRequest = true
+ private var isSaved = false
+
+ private var settings: CameraSettings = CameraSettings()
+
+ @Command
+ fun getPhoto(invoke: Invoke) {
+ isEdited = false
+ settings = getSettings(invoke)
+ doShow(invoke)
+ }
+
+ @Command
+ fun pickImages(invoke: Invoke) {
+ settings = getSettings(invoke)
+ openPhotos(invoke, multiple = true, skipPermission = false)
+ }
+
+ @Command
+ fun pickLimitedLibraryPhotos(invoke: Invoke) {
+ invoke.reject("not supported on android")
+ }
+
+ @Command
+ fun getLimitedLibraryPhotos(invoke: Invoke) {
+ invoke.reject("not supported on android")
+ }
+
+ private fun doShow(invoke: Invoke) {
+ when (settings.source) {
+ CameraSource.CAMERA -> showCamera(invoke)
+ CameraSource.PHOTOS -> showPhotos(invoke)
+ else -> showPrompt(invoke)
+ }
+ }
+
+ private fun showPrompt(invoke: Invoke) {
+ // We have all necessary permissions, open the camera
+ val options: MutableList = ArrayList()
+ options.add(invoke.getString("promptLabelPhoto", "From Photos"))
+ options.add(invoke.getString("promptLabelPicture", "Take Picture"))
+ val fragment = CameraBottomSheetDialogFragment()
+ fragment.setTitle(invoke.getString("promptLabelHeader", "Photo"))
+ fragment.setOptions(
+ options,
+ { index: Int ->
+ if (index == 0) {
+ settings.source = CameraSource.PHOTOS
+ openPhotos(invoke)
+ } else if (index == 1) {
+ settings.source = CameraSource.CAMERA
+ openCamera(invoke)
+ }
+ },
+ { invoke.reject("User cancelled photos app") })
+ fragment.show((activity as AppCompatActivity).supportFragmentManager, "capacitorModalsActionSheet")
+ }
+
+ private fun showCamera(invoke: Invoke) {
+ if (!activity.packageManager.hasSystemFeature(PackageManager.FEATURE_CAMERA_ANY)) {
+ invoke.reject(NO_CAMERA_ERROR)
+ return
+ }
+ openCamera(invoke)
+ }
+
+ private fun showPhotos(invoke: Invoke) {
+ openPhotos(invoke)
+ }
+
+ private fun checkCameraPermissions(invoke: Invoke): Boolean {
+ // if the manifest does not contain the camera permissions key, we don't need to ask the user
+ val needCameraPerms = isPermissionDeclared(CAMERA)
+ val hasCameraPerms = !needCameraPerms || getPermissionState(CAMERA) === PermissionState.GRANTED
+ val hasPhotoPerms = getPermissionState(PHOTOS) === PermissionState.GRANTED
+
+ // If we want to save to the gallery, we need two permissions
+ if (settings.isSaveToGallery && !(hasCameraPerms && hasPhotoPerms) && isFirstRequest) {
+ isFirstRequest = false
+ val aliases = if (needCameraPerms) {
+ arrayOf(CAMERA, PHOTOS)
+ } else {
+ arrayOf(PHOTOS)
+ }
+ requestPermissionForAliases(aliases, invoke, "cameraPermissionsCallback")
+ return false
+ } else if (!hasCameraPerms) {
+ requestPermissionForAlias(CAMERA, invoke, "cameraPermissionsCallback")
+ return false
+ }
+ return true
+ }
+
+ private fun checkPhotosPermissions(invoke: Invoke): Boolean {
+ if (getPermissionState(PHOTOS) !== PermissionState.GRANTED) {
+ requestPermissionForAlias(PHOTOS, invoke, "cameraPermissionsCallback")
+ return false
+ }
+ return true
+ }
+
+ /**
+ * Completes the plugin invoke after a camera permission request
+ *
+ * @see .getPhoto
+ * @param invoke the plugin invoke
+ */
+ @PermissionCallback
+ private fun cameraPermissionsCallback(invoke: Invoke) {
+ if (invoke.command == "pickImages") {
+ openPhotos(invoke, multiple = true, skipPermission = true)
+ } else {
+ if (settings.source === CameraSource.CAMERA && getPermissionState(CAMERA) !== PermissionState.GRANTED) {
+ Logger.debug(
+ getLogTag(),
+ "User denied camera permission: " + getPermissionState(CAMERA).toString()
+ )
+ invoke.reject(PERMISSION_DENIED_ERROR_CAMERA)
+ return
+ } else if (settings.source === CameraSource.PHOTOS && getPermissionState(PHOTOS) !== PermissionState.GRANTED) {
+ Logger.debug(
+ getLogTag(),
+ "User denied photos permission: " + getPermissionState(PHOTOS).toString()
+ )
+ invoke.reject(PERMISSION_DENIED_ERROR_PHOTOS)
+ return
+ }
+ doShow(invoke)
+ }
+ }
+
+ private fun getSettings(invoke: Invoke): CameraSettings {
+ val settings = CameraSettings()
+ val resultType = getResultType(invoke.getString("resultType"))
+ if (resultType != null) {
+ settings.resultType = resultType
+ }
+ settings.isSaveToGallery =
+ invoke.getBoolean(
+ "saveToGallery",
+ CameraSettings.DEFAULT_SAVE_IMAGE_TO_GALLERY
+ )
+ settings.isAllowEditing = invoke.getBoolean("allowEditing", false)
+ settings.quality = invoke.getInt("quality", CameraSettings.DEFAULT_QUALITY)
+ settings.width = invoke.getInt("width", 0)
+ settings.height = invoke.getInt("height", 0)
+ settings.isShouldResize = settings.width > 0 || settings.height > 0
+ settings.isShouldCorrectOrientation =
+ invoke.getBoolean(
+ "correctOrientation",
+ CameraSettings.DEFAULT_CORRECT_ORIENTATION
+ )
+
+ try {
+ settings.source =
+ CameraSource.valueOf(
+ invoke.getString(
+ "source",
+ CameraSource.PROMPT.source
+ )
+ )
+
+ } catch (ex: IllegalArgumentException) {
+ settings.source = CameraSource.PROMPT
+ }
+ return settings
+ }
+
+ private fun getResultType(resultType: String?): CameraResultType? {
+ return if (resultType == null) {
+ null
+ } else try {
+ CameraResultType.valueOf(resultType.uppercase(Locale.ROOT))
+ } catch (ex: IllegalArgumentException) {
+ Logger.debug(getLogTag(), "Invalid result type \"$resultType\", defaulting to base64")
+ CameraResultType.BASE64
+ }
+ }
+
+ private fun openCamera(invoke: Invoke) {
+ if (checkCameraPermissions(invoke)) {
+ val takePictureIntent = Intent(MediaStore.ACTION_IMAGE_CAPTURE)
+ if (takePictureIntent.resolveActivity(activity.packageManager) != null) {
+ // If we will be saving the photo, send the target file along
+ try {
+ val appId: String = activity.packageName
+ val photoFile: File = CameraUtils.createImageFile(activity)
+ imageFileSavePath = photoFile.absolutePath
+ // TODO: Verify provider config exists
+ imageFileUri = FileProvider.getUriForFile(
+ activity,
+ "$appId.fileprovider", photoFile
+ )
+ takePictureIntent.putExtra(MediaStore.EXTRA_OUTPUT, imageFileUri)
+ } catch (ex: Exception) {
+ invoke.reject(IMAGE_FILE_SAVE_ERROR, ex)
+ return
+ }
+ startActivityForResult(invoke, takePictureIntent, "processCameraImage")
+ } else {
+ invoke.reject(NO_CAMERA_ACTIVITY_ERROR)
+ }
+ }
+ }
+
+ private fun openPhotos(invoke: Invoke) {
+ openPhotos(invoke, multiple = false, skipPermission = false)
+ }
+
+ private fun openPhotos(invoke: Invoke, multiple: Boolean, skipPermission: Boolean) {
+ if (skipPermission || checkPhotosPermissions(invoke)) {
+ val intent = Intent(Intent.ACTION_PICK)
+ intent.putExtra(Intent.EXTRA_ALLOW_MULTIPLE, multiple)
+ intent.type = "image/*"
+ try {
+ if (multiple) {
+ intent.putExtra("multi-pick", true)
+ intent.putExtra(Intent.EXTRA_MIME_TYPES, arrayOf("image/*"))
+ startActivityForResult(invoke, intent, "processPickedImages")
+ } else {
+ startActivityForResult(invoke, intent, "processPickedImage")
+ }
+ } catch (ex: ActivityNotFoundException) {
+ invoke.reject(NO_PHOTO_ACTIVITY_ERROR)
+ }
+ }
+ }
+
+ @ActivityCallback
+ fun processCameraImage(invoke: Invoke, result: ActivityResult?) {
+ settings = getSettings(invoke)
+ if (imageFileSavePath == null) {
+ invoke.reject(IMAGE_PROCESS_NO_FILE_ERROR)
+ return
+ }
+ // Load the image as a Bitmap
+ val f = File(imageFileSavePath!!)
+ val bmOptions: BitmapFactory.Options = BitmapFactory.Options()
+ val contentUri: Uri = Uri.fromFile(f)
+ val bitmap = BitmapFactory.decodeFile(imageFileSavePath, bmOptions)
+ if (bitmap == null) {
+ invoke.reject("cancelled")
+ } else {
+ returnResult(invoke, bitmap, contentUri)
+ }
+ }
+
+ @ActivityCallback
+ fun processPickedImage(invoke: Invoke, result: ActivityResult) {
+ settings = getSettings(invoke)
+ val data: Intent? = result.data
+ if (data == null) {
+ invoke.reject("No image picked")
+ return
+ }
+ val u: Uri = data.data!!
+ imagePickedContentUri = u
+ processPickedImage(u, invoke)
+ }
+
+ @ActivityCallback
+ fun processPickedImages(invoke: Invoke, result: ActivityResult) {
+ val data: Intent? = result.data
+ if (data != null) {
+ val executor: Executor = Executors.newSingleThreadExecutor()
+ executor.execute {
+ val ret = JSObject()
+ val photos = JSArray()
+ if (data.clipData != null) {
+ val count: Int = data.clipData!!.itemCount
+ for (i in 0 until count) {
+ val imageUri: Uri = data.clipData!!.getItemAt(i).uri
+ val processResult = processPickedImages(imageUri)
+ if (processResult.getString("error").isNotEmpty()
+ ) {
+ invoke.reject(processResult.getString("error"))
+ return@execute
+ } else {
+ photos.put(processResult)
+ }
+ }
+ } else if (data.data != null) {
+ val imageUri: Uri = data.data!!
+ val processResult = processPickedImages(imageUri)
+ if (processResult.getString("error").isNotEmpty()
+ ) {
+ invoke.reject(processResult.getString("error"))
+ return@execute
+ } else {
+ photos.put(processResult)
+ }
+ } else if (data.extras != null) {
+ val bundle: Bundle = data.extras!!
+ if (bundle.keySet().contains("selectedItems")) {
+ val fileUris: ArrayList? = bundle.getParcelableArrayList("selectedItems")
+ if (fileUris != null) {
+ for (fileUri in fileUris) {
+ if (fileUri is Uri) {
+ val imageUri: Uri = fileUri
+ try {
+ val processResult = processPickedImages(imageUri)
+ if (processResult.getString("error").isNotEmpty()
+ ) {
+ invoke.reject(processResult.getString("error"))
+ return@execute
+ } else {
+ photos.put(processResult)
+ }
+ } catch (ex: SecurityException) {
+ invoke.reject("SecurityException")
+ }
+ }
+ }
+ }
+ }
+ }
+ ret.put("photos", photos)
+ invoke.resolve(ret)
+ }
+ } else {
+ invoke.reject("No images picked")
+ }
+ }
+
+ private fun processPickedImage(imageUri: Uri, invoke: Invoke) {
+ var imageStream: InputStream? = null
+ try {
+ imageStream = activity.contentResolver.openInputStream(imageUri)
+ val bitmap = BitmapFactory.decodeStream(imageStream)
+ if (bitmap == null) {
+ invoke.reject("Unable to process bitmap")
+ return
+ }
+ returnResult(invoke, bitmap, imageUri)
+ } catch (err: OutOfMemoryError) {
+ invoke.reject("Out of memory")
+ } catch (ex: FileNotFoundException) {
+ invoke.reject("No such image found", ex)
+ } finally {
+ if (imageStream != null) {
+ try {
+ imageStream.close()
+ } catch (e: IOException) {
+ Logger.error(getLogTag(), UNABLE_TO_PROCESS_IMAGE, e)
+ }
+ }
+ }
+ }
+
+ private fun processPickedImages(imageUri: Uri): JSObject {
+ var imageStream: InputStream? = null
+ val ret = JSObject()
+ try {
+ imageStream = activity.contentResolver.openInputStream(imageUri)
+ var bitmap = BitmapFactory.decodeStream(imageStream)
+ if (bitmap == null) {
+ ret.put("error", "Unable to process bitmap")
+ return ret
+ }
+ val exif: ExifWrapper = ImageUtils.getExifData(activity, bitmap, imageUri)
+ bitmap = try {
+ prepareBitmap(bitmap, imageUri, exif)
+ } catch (e: IOException) {
+ ret.put("error", UNABLE_TO_PROCESS_IMAGE)
+ return ret
+ }
+ // Compress the final image and prepare for output to client
+ val bitmapOutputStream = ByteArrayOutputStream()
+ bitmap.compress(Bitmap.CompressFormat.JPEG, settings.quality, bitmapOutputStream)
+ val newUri: Uri? = getTempImage(imageUri, bitmapOutputStream)
+ exif.copyExif(newUri?.path)
+ if (newUri != null) {
+ ret.put("format", "jpeg")
+ ret.put("exif", exif.toJson())
+ ret.put("data", newUri.toString())
+ ret.put("assetUrl", assetUrl(newUri))
+ } else {
+ ret.put("error", UNABLE_TO_PROCESS_IMAGE)
+ }
+ return ret
+ } catch (err: OutOfMemoryError) {
+ ret.put("error", "Out of memory")
+ } catch (ex: FileNotFoundException) {
+ ret.put("error", "No such image found")
+ Logger.error(getLogTag(), "No such image found", ex)
+ } finally {
+ if (imageStream != null) {
+ try {
+ imageStream.close()
+ } catch (e: IOException) {
+ Logger.error(getLogTag(), UNABLE_TO_PROCESS_IMAGE, e)
+ }
+ }
+ }
+ return ret
+ }
+
+ @ActivityCallback
+ private fun processEditedImage(invoke: Invoke, result: ActivityResult) {
+ isEdited = true
+ settings = getSettings(invoke)
+ if (result.resultCode == Activity.RESULT_CANCELED) {
+ // User cancelled the edit operation, if this file was picked from photos,
+ // process the original picked image, otherwise process it as a camera photo
+ if (imagePickedContentUri != null) {
+ processPickedImage(imagePickedContentUri!!, invoke)
+ } else {
+ processCameraImage(invoke, result)
+ }
+ } else {
+ processPickedImage(invoke, result)
+ }
+ }
+
+ @Throws(IOException::class)
+ private fun saveImage(uri: Uri, input: InputStream): Uri? {
+ var outFile = if (uri.scheme.equals("content")) {
+ getTempFile(uri)
+ } else {
+ uri.path?.let { File(it) }
+ }
+ try {
+ writePhoto(outFile!!, input)
+ } catch (ex: FileNotFoundException) {
+ // Some gallery apps return read only file url, create a temporary file for modifications
+ outFile = getTempFile(uri)
+ writePhoto(outFile, input)
+ }
+ return Uri.fromFile(outFile)
+ }
+
+ @Throws(IOException::class)
+ private fun writePhoto(outFile: File, input: InputStream) {
+ val fos = FileOutputStream(outFile)
+ val buffer = ByteArray(1024)
+ var len: Int
+ while (input.read(buffer).also { len = it } != -1) {
+ fos.write(buffer, 0, len)
+ }
+ fos.close()
+ }
+
+ private fun getTempFile(uri: Uri): File {
+ var filename: String = Uri.parse(Uri.decode(uri.toString())).lastPathSegment!!
+ if (!filename.contains(".jpg") && !filename.contains(".jpeg")) {
+ filename += "." + Date().time + ".jpeg"
+ }
+ val cacheDir = activity.cacheDir
+ return File(cacheDir, filename)
+ }
+
+ /**
+ * After processing the image, return the final result back to the invokeer.
+ * @param invoke
+ * @param bitmap
+ * @param u
+ */
+ private fun returnResult(invoke: Invoke, bitmap: Bitmap, u: Uri) {
+ val exif: ExifWrapper = ImageUtils.getExifData(activity, bitmap, u)
+ val preparedBitmap = try {
+ prepareBitmap(bitmap, u, exif)
+ } catch (e: IOException) {
+ invoke.reject(UNABLE_TO_PROCESS_IMAGE)
+ return
+ }
+ // Compress the final image and prepare for output to client
+ val bitmapOutputStream = ByteArrayOutputStream()
+ preparedBitmap.compress(Bitmap.CompressFormat.JPEG, settings.quality, bitmapOutputStream)
+ if (settings.isAllowEditing && !isEdited) {
+ editImage(invoke, u, bitmapOutputStream)
+ return
+ }
+ val saveToGallery: Boolean =
+ invoke.getBoolean("saveToGallery", CameraSettings.DEFAULT_SAVE_IMAGE_TO_GALLERY)
+ if (saveToGallery && (imageEditedFileSavePath != null || imageFileSavePath != null)) {
+ isSaved = true
+ try {
+ val fileToSavePath =
+ if (imageEditedFileSavePath != null) imageEditedFileSavePath!! else imageFileSavePath!!
+ val fileToSave = File(fileToSavePath)
+ if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
+ val resolver: ContentResolver = activity.contentResolver
+ val values = ContentValues()
+ values.put(MediaStore.MediaColumns.DISPLAY_NAME, fileToSave.name)
+ values.put(MediaStore.MediaColumns.MIME_TYPE, "image/jpeg")
+ values.put(MediaStore.MediaColumns.RELATIVE_PATH, Environment.DIRECTORY_DCIM)
+ val contentUri: Uri = MediaStore.Images.Media.EXTERNAL_CONTENT_URI
+ val uri: Uri = resolver.insert(contentUri, values)
+ ?: throw IOException("Failed to create new MediaStore record.")
+ val stream: OutputStream = resolver.openOutputStream(uri)
+ ?: throw IOException("Failed to open output stream.")
+ val inserted: Boolean =
+ preparedBitmap.compress(Bitmap.CompressFormat.JPEG, settings.quality, stream)
+ if (!inserted) {
+ isSaved = false
+ }
+ } else {
+ val inserted = MediaStore.Images.Media.insertImage(
+ activity.contentResolver,
+ fileToSavePath,
+ fileToSave.name,
+ ""
+ )
+ if (inserted == null) {
+ isSaved = false
+ }
+ }
+ } catch (e: FileNotFoundException) {
+ isSaved = false
+ Logger.error(getLogTag(), IMAGE_GALLERY_SAVE_ERROR, e)
+ } catch (e: IOException) {
+ isSaved = false
+ Logger.error(getLogTag(), IMAGE_GALLERY_SAVE_ERROR, e)
+ }
+ }
+ if (settings.resultType === CameraResultType.BASE64) {
+ returnBase64(invoke, exif, bitmapOutputStream)
+ } else if (settings.resultType === CameraResultType.URI) {
+ returnFileURI(invoke, exif, bitmap, u, bitmapOutputStream)
+ } else if (settings.resultType === CameraResultType.DATAURL) {
+ returnDataUrl(invoke, exif, bitmapOutputStream)
+ } else {
+ invoke.reject(INVALID_RESULT_TYPE_ERROR)
+ }
+ // Result returned, clear stored paths and images
+ if (settings.resultType !== CameraResultType.URI) {
+ deleteImageFile()
+ }
+ imageFileSavePath = null
+ imageFileUri = null
+ imagePickedContentUri = null
+ imageEditedFileSavePath = null
+ }
+
+ private fun deleteImageFile() {
+ if (imageFileSavePath != null && !settings.isSaveToGallery) {
+ val photoFile = File(imageFileSavePath!!)
+ if (photoFile.exists()) {
+ photoFile.delete()
+ }
+ }
+ }
+
+ private fun returnFileURI(
+ invoke: Invoke,
+ exif: ExifWrapper,
+ bitmap: Bitmap,
+ u: Uri,
+ bitmapOutputStream: ByteArrayOutputStream
+ ) {
+ val newUri: Uri? = getTempImage(u, bitmapOutputStream)
+ exif.copyExif(newUri?.path)
+ if (newUri != null) {
+ val ret = JSObject()
+ ret.put("format", "jpeg")
+ ret.put("exif", exif.toJson())
+ ret.put("data", newUri.toString())
+ ret.put("assetUrl", assetUrl(newUri))
+ ret.put("saved", isSaved)
+ invoke.resolve(ret)
+ } else {
+ invoke.reject(UNABLE_TO_PROCESS_IMAGE)
+ }
+ }
+
+ private fun getTempImage(u: Uri, bitmapOutputStream: ByteArrayOutputStream): Uri? {
+ var bis: ByteArrayInputStream? = null
+ var newUri: Uri? = null
+ try {
+ bis = ByteArrayInputStream(bitmapOutputStream.toByteArray())
+ newUri = saveImage(u, bis)
+ } catch (_: IOException) {
+ } finally {
+ if (bis != null) {
+ try {
+ bis.close()
+ } catch (e: IOException) {
+ Logger.error(getLogTag(), UNABLE_TO_PROCESS_IMAGE, e)
+ }
+ }
+ }
+ return newUri
+ }
+
+ /**
+ * Apply our standard processing of the bitmap, returning a new one and
+ * recycling the old one in the process
+ * @param bitmap
+ * @param imageUri
+ * @param exif
+ * @return
+ */
+ @Throws(IOException::class)
+ private fun prepareBitmap(bitmap: Bitmap, imageUri: Uri, exif: ExifWrapper): Bitmap {
+ var preparedBitmap: Bitmap = bitmap
+ if (settings.isShouldCorrectOrientation) {
+ val newBitmap: Bitmap = ImageUtils.correctOrientation(activity, preparedBitmap, imageUri, exif)
+ preparedBitmap = replaceBitmap(preparedBitmap, newBitmap)
+ }
+ if (settings.isShouldResize) {
+ val newBitmap: Bitmap = ImageUtils.resize(preparedBitmap, settings.width, settings.height)
+ preparedBitmap = replaceBitmap(preparedBitmap, newBitmap)
+ }
+ return preparedBitmap
+ }
+
+ private fun replaceBitmap(bitmap: Bitmap, newBitmap: Bitmap): Bitmap {
+ if (bitmap !== newBitmap) {
+ bitmap.recycle()
+ }
+ return newBitmap
+ }
+
+ private fun returnDataUrl(
+ invoke: Invoke,
+ exif: ExifWrapper,
+ bitmapOutputStream: ByteArrayOutputStream
+ ) {
+ val byteArray: ByteArray = bitmapOutputStream.toByteArray()
+ val encoded: String = Base64.encodeToString(byteArray, Base64.NO_WRAP)
+ val data = JSObject()
+ data.put("format", "jpeg")
+ data.put("data", "data:image/jpeg;base64,$encoded")
+ data.put("exif", exif.toJson())
+ invoke.resolve(data)
+ }
+
+ private fun returnBase64(
+ invoke: Invoke,
+ exif: ExifWrapper,
+ bitmapOutputStream: ByteArrayOutputStream
+ ) {
+ val byteArray: ByteArray = bitmapOutputStream.toByteArray()
+ val encoded: String = Base64.encodeToString(byteArray, Base64.NO_WRAP)
+ val data = JSObject()
+ data.put("format", "jpeg")
+ data.put("data", encoded)
+ data.put("exif", exif.toJson())
+ invoke.resolve(data)
+ }
+
+ @Command
+ override fun requestPermissions(invoke: Invoke) {
+ // If the camera permission is defined in the manifest, then we have to prompt the user
+ // or else we will get a security exception when trying to present the camera. If, however,
+ // it is not defined in the manifest then we don't need to prompt and it will just work.
+ if (isPermissionDeclared(CAMERA)) {
+ // just request normally
+ super.requestPermissions(invoke)
+ } else {
+ // the manifest does not define camera permissions, so we need to decide what to do
+ // first, extract the permissions being requested
+ val providedPerms = invoke.getArray("permissions", JSArray())
+ var permsList: List? = null
+ try {
+ permsList = providedPerms.toList()
+ } catch (_: JSONException) {
+ }
+ if (permsList != null && permsList.size == 1 && permsList.contains(CAMERA)) {
+ // the only thing being asked for was the camera so we can just return the current state
+ checkPermissions(invoke)
+ } else {
+ // we need to ask about photos so request storage permissions
+ requestPermissionForAlias(PHOTOS, invoke, "checkPermissions")
+ }
+ }
+ }
+
+ override fun getPermissionStates(): Map {
+ val permissionStates = super.getPermissionStates() as MutableMap
+
+ // If Camera is not in the manifest and therefore not required, say the permission is granted
+ if (!isPermissionDeclared(CAMERA)) {
+ permissionStates[CAMERA] = PermissionState.GRANTED
+ }
+ return permissionStates
+ }
+
+ private fun editImage(invoke: Invoke, uri: Uri, bitmapOutputStream: ByteArrayOutputStream) {
+ try {
+ val tempImage = getTempImage(uri, bitmapOutputStream)
+ val editIntent = createEditIntent(tempImage)
+ if (editIntent != null) {
+ startActivityForResult(invoke, editIntent, "processEditedImage")
+ } else {
+ invoke.reject(IMAGE_EDIT_ERROR)
+ }
+ } catch (ex: Exception) {
+ invoke.reject(IMAGE_EDIT_ERROR, ex)
+ }
+ }
+
+ private fun createEditIntent(origPhotoUri: Uri?): Intent? {
+ return try {
+ val editFile = origPhotoUri?.path?.let { File(it) }
+ val editUri: Uri = FileProvider.getUriForFile(
+ activity,
+ activity.packageName + ".fileprovider",
+ editFile!!
+ )
+ val editIntent = Intent(Intent.ACTION_EDIT)
+ editIntent.setDataAndType(editUri, "image/*")
+ imageEditedFileSavePath = editFile.absolutePath
+ val flags: Int =
+ Intent.FLAG_GRANT_READ_URI_PERMISSION or Intent.FLAG_GRANT_WRITE_URI_PERMISSION
+ editIntent.addFlags(flags)
+ editIntent.putExtra(MediaStore.EXTRA_OUTPUT, editUri)
+ val resInfoList: List = activity
+ .packageManager
+ .queryIntentActivities(editIntent, PackageManager.MATCH_DEFAULT_ONLY)
+ for (resolveInfo in resInfoList) {
+ val packageName: String = resolveInfo.activityInfo.packageName
+ activity.grantUriPermission(packageName, editUri, flags)
+ }
+ editIntent
+ } catch (ex: Exception) {
+ null
+ }
+ }
+}
diff --git a/plugins/camera/android/src/main/java/CameraUtils.kt b/plugins/camera/android/src/main/java/CameraUtils.kt
new file mode 100644
index 00000000..30eb9295
--- /dev/null
+++ b/plugins/camera/android/src/main/java/CameraUtils.kt
@@ -0,0 +1,43 @@
+// Copyright 2019-2023 Tauri Programme within The Commons Conservancy
+// SPDX-License-Identifier: Apache-2.0
+// SPDX-License-Identifier: MIT
+
+package app.tauri.camera
+
+import android.app.Activity
+import android.net.Uri
+import android.os.Environment
+import androidx.core.content.FileProvider
+import app.tauri.Logger
+import java.io.File
+import java.io.IOException
+import java.text.SimpleDateFormat
+import java.util.*
+
+object CameraUtils {
+ @Throws(IOException::class)
+ fun createImageFileUri(activity: Activity, appId: String): Uri {
+ val photoFile = createImageFile(activity)
+ return FileProvider.getUriForFile(
+ activity,
+ "$appId.fileprovider", photoFile
+ )
+ }
+
+ @Throws(IOException::class)
+ fun createImageFile(activity: Activity): File {
+ // Create an image file name
+ val timeStamp: String = SimpleDateFormat("yyyyMMdd_HHmmss").format(Date())
+ val imageFileName = "JPEG_" + timeStamp + "_"
+ val storageDir =
+ activity.getExternalFilesDir(Environment.DIRECTORY_PICTURES)
+ return File.createTempFile(
+ imageFileName, /* prefix */
+ ".jpg", /* suffix */
+ storageDir /* directory */
+ )
+ }
+
+ internal val logTag: String
+ internal get() = Logger.tags("CameraUtils")
+}
diff --git a/plugins/camera/android/src/main/java/ExifWrapper.kt b/plugins/camera/android/src/main/java/ExifWrapper.kt
new file mode 100644
index 00000000..9f9d41ac
--- /dev/null
+++ b/plugins/camera/android/src/main/java/ExifWrapper.kt
@@ -0,0 +1,202 @@
+// Copyright 2019-2023 Tauri Programme within The Commons Conservancy
+// SPDX-License-Identifier: Apache-2.0
+// SPDX-License-Identifier: MIT
+
+package app.tauri.camera
+
+import androidx.exifinterface.media.ExifInterface.*
+import androidx.exifinterface.media.ExifInterface
+import app.tauri.plugin.JSObject
+
+class ExifWrapper(private val exif: ExifInterface?) {
+ private val attributes = arrayOf(
+ TAG_APERTURE_VALUE,
+ TAG_ARTIST,
+ TAG_BITS_PER_SAMPLE,
+ TAG_BODY_SERIAL_NUMBER,
+ TAG_BRIGHTNESS_VALUE,
+ TAG_CAMERA_OWNER_NAME,
+ TAG_CFA_PATTERN,
+ TAG_COLOR_SPACE,
+ TAG_COMPONENTS_CONFIGURATION,
+ TAG_COMPRESSED_BITS_PER_PIXEL,
+ TAG_COMPRESSION,
+ TAG_CONTRAST,
+ TAG_COPYRIGHT,
+ TAG_CUSTOM_RENDERED,
+ TAG_DATETIME,
+ TAG_DATETIME_DIGITIZED,
+ TAG_DATETIME_ORIGINAL,
+ TAG_DEFAULT_CROP_SIZE,
+ TAG_DEVICE_SETTING_DESCRIPTION,
+ TAG_DIGITAL_ZOOM_RATIO,
+ TAG_DNG_VERSION,
+ TAG_EXIF_VERSION,
+ TAG_EXPOSURE_BIAS_VALUE,
+ TAG_EXPOSURE_INDEX,
+ TAG_EXPOSURE_MODE,
+ TAG_EXPOSURE_PROGRAM,
+ TAG_EXPOSURE_TIME,
+ TAG_FILE_SOURCE,
+ TAG_FLASH,
+ TAG_FLASHPIX_VERSION,
+ TAG_FLASH_ENERGY,
+ TAG_FOCAL_LENGTH,
+ TAG_FOCAL_LENGTH_IN_35MM_FILM,
+ TAG_FOCAL_PLANE_RESOLUTION_UNIT,
+ TAG_FOCAL_PLANE_X_RESOLUTION,
+ TAG_FOCAL_PLANE_Y_RESOLUTION,
+ TAG_F_NUMBER,
+ TAG_GAIN_CONTROL,
+ TAG_GAMMA,
+ TAG_GPS_ALTITUDE,
+ TAG_GPS_ALTITUDE_REF,
+ TAG_GPS_AREA_INFORMATION,
+ TAG_GPS_DATESTAMP,
+ TAG_GPS_DEST_BEARING,
+ TAG_GPS_DEST_BEARING_REF,
+ TAG_GPS_DEST_DISTANCE,
+ TAG_GPS_DEST_DISTANCE_REF,
+ TAG_GPS_DEST_LATITUDE,
+ TAG_GPS_DEST_LATITUDE_REF,
+ TAG_GPS_DEST_LONGITUDE,
+ TAG_GPS_DEST_LONGITUDE_REF,
+ TAG_GPS_DIFFERENTIAL,
+ TAG_GPS_DOP,
+ TAG_GPS_H_POSITIONING_ERROR,
+ TAG_GPS_IMG_DIRECTION,
+ TAG_GPS_IMG_DIRECTION_REF,
+ TAG_GPS_LATITUDE,
+ TAG_GPS_LATITUDE_REF,
+ TAG_GPS_LONGITUDE,
+ TAG_GPS_LONGITUDE_REF,
+ TAG_GPS_MAP_DATUM,
+ TAG_GPS_MEASURE_MODE,
+ TAG_GPS_PROCESSING_METHOD,
+ TAG_GPS_SATELLITES,
+ TAG_GPS_SPEED,
+ TAG_GPS_SPEED_REF,
+ TAG_GPS_STATUS,
+ TAG_GPS_TIMESTAMP,
+ TAG_GPS_TRACK,
+ TAG_GPS_TRACK_REF,
+ TAG_GPS_VERSION_ID,
+ TAG_IMAGE_DESCRIPTION,
+ TAG_IMAGE_LENGTH,
+ TAG_IMAGE_UNIQUE_ID,
+ TAG_IMAGE_WIDTH,
+ TAG_INTEROPERABILITY_INDEX,
+ TAG_ISO_SPEED,
+ TAG_ISO_SPEED_LATITUDE_YYY,
+ TAG_ISO_SPEED_LATITUDE_ZZZ,
+ TAG_JPEG_INTERCHANGE_FORMAT,
+ TAG_JPEG_INTERCHANGE_FORMAT_LENGTH,
+ TAG_LENS_MAKE,
+ TAG_LENS_MODEL,
+ TAG_LENS_SERIAL_NUMBER,
+ TAG_LENS_SPECIFICATION,
+ TAG_LIGHT_SOURCE,
+ TAG_MAKE,
+ TAG_MAKER_NOTE,
+ TAG_MAX_APERTURE_VALUE,
+ TAG_METERING_MODE,
+ TAG_MODEL,
+ TAG_NEW_SUBFILE_TYPE,
+ TAG_OECF,
+ TAG_OFFSET_TIME,
+ TAG_OFFSET_TIME_DIGITIZED,
+ TAG_OFFSET_TIME_ORIGINAL,
+ TAG_ORF_ASPECT_FRAME,
+ TAG_ORF_PREVIEW_IMAGE_LENGTH,
+ TAG_ORF_PREVIEW_IMAGE_START,
+ TAG_ORF_THUMBNAIL_IMAGE,
+ TAG_ORIENTATION,
+ TAG_PHOTOGRAPHIC_SENSITIVITY,
+ TAG_PHOTOMETRIC_INTERPRETATION,
+ TAG_PIXEL_X_DIMENSION,
+ TAG_PIXEL_Y_DIMENSION,
+ TAG_PLANAR_CONFIGURATION,
+ TAG_PRIMARY_CHROMATICITIES,
+ TAG_RECOMMENDED_EXPOSURE_INDEX,
+ TAG_REFERENCE_BLACK_WHITE,
+ TAG_RELATED_SOUND_FILE,
+ TAG_RESOLUTION_UNIT,
+ TAG_ROWS_PER_STRIP,
+ TAG_RW2_ISO,
+ TAG_RW2_JPG_FROM_RAW,
+ TAG_RW2_SENSOR_BOTTOM_BORDER,
+ TAG_RW2_SENSOR_LEFT_BORDER,
+ TAG_RW2_SENSOR_RIGHT_BORDER,
+ TAG_RW2_SENSOR_TOP_BORDER,
+ TAG_SAMPLES_PER_PIXEL,
+ TAG_SATURATION,
+ TAG_SCENE_CAPTURE_TYPE,
+ TAG_SCENE_TYPE,
+ TAG_SENSING_METHOD,
+ TAG_SENSITIVITY_TYPE,
+ TAG_SHARPNESS,
+ TAG_SHUTTER_SPEED_VALUE,
+ TAG_SOFTWARE,
+ TAG_SPATIAL_FREQUENCY_RESPONSE,
+ TAG_SPECTRAL_SENSITIVITY,
+ TAG_STANDARD_OUTPUT_SENSITIVITY,
+ TAG_STRIP_BYTE_COUNTS,
+ TAG_STRIP_OFFSETS,
+ TAG_SUBFILE_TYPE,
+ TAG_SUBJECT_AREA,
+ TAG_SUBJECT_DISTANCE,
+ TAG_SUBJECT_DISTANCE_RANGE,
+ TAG_SUBJECT_LOCATION,
+ TAG_SUBSEC_TIME,
+ TAG_SUBSEC_TIME_DIGITIZED,
+ TAG_SUBSEC_TIME_ORIGINAL,
+ TAG_THUMBNAIL_IMAGE_LENGTH,
+ TAG_THUMBNAIL_IMAGE_WIDTH,
+ TAG_TRANSFER_FUNCTION,
+ TAG_USER_COMMENT,
+ TAG_WHITE_BALANCE,
+ TAG_WHITE_POINT,
+ TAG_XMP,
+ TAG_X_RESOLUTION,
+ TAG_Y_CB_CR_COEFFICIENTS,
+ TAG_Y_CB_CR_POSITIONING,
+ TAG_Y_CB_CR_SUB_SAMPLING,
+ TAG_Y_RESOLUTION
+ )
+
+ fun toJson(): JSObject {
+ val ret = JSObject()
+ if (exif == null) {
+ return ret
+ }
+ for (i in attributes.indices) {
+ p(ret, attributes[i])
+ }
+ return ret
+ }
+
+ fun p(o: JSObject, tag: String?) {
+ val value = exif!!.getAttribute(tag!!)
+ o.put(tag, value)
+ }
+
+ fun copyExif(destFile: String?) {
+ try {
+ val destExif = ExifInterface(
+ destFile!!
+ )
+ for (i in attributes.indices) {
+ val value = exif!!.getAttribute(attributes[i])
+ if (value != null) {
+ destExif.setAttribute(attributes[i], value)
+ }
+ }
+ destExif.saveAttributes()
+ } catch (_: java.lang.Exception) {
+ }
+ }
+
+ fun resetOrientation() {
+ exif!!.resetOrientation()
+ }
+}
diff --git a/plugins/camera/android/src/main/java/ImageUtils.kt b/plugins/camera/android/src/main/java/ImageUtils.kt
new file mode 100644
index 00000000..3522dd34
--- /dev/null
+++ b/plugins/camera/android/src/main/java/ImageUtils.kt
@@ -0,0 +1,130 @@
+// Copyright 2019-2023 Tauri Programme within The Commons Conservancy
+// SPDX-License-Identifier: Apache-2.0
+// SPDX-License-Identifier: MIT
+
+package app.tauri.camera
+
+import android.content.Context
+import android.graphics.Bitmap
+import android.graphics.Matrix
+import android.net.Uri
+import androidx.exifinterface.media.ExifInterface
+import app.tauri.Logger
+import java.io.IOException
+import java.io.InputStream
+import java.lang.Integer.min
+import kotlin.math.roundToInt
+
+object ImageUtils {
+ /**
+ * Resize an image to the given max width and max height. Constraint can be put
+ * on one dimension, or both. Resize will always preserve aspect ratio.
+ * @param bitmap
+ * @param desiredMaxWidth
+ * @param desiredMaxHeight
+ * @return a new, scaled Bitmap
+ */
+ fun resize(bitmap: Bitmap, desiredMaxWidth: Int, desiredMaxHeight: Int): Bitmap {
+ return resizePreservingAspectRatio(bitmap, desiredMaxWidth, desiredMaxHeight)
+ }
+
+ /**
+ * Resize an image to the given max width and max height. Constraint can be put
+ * on one dimension, or both. Resize will always preserve aspect ratio.
+ * @param bitmap
+ * @param desiredMaxWidth
+ * @param desiredMaxHeight
+ * @return a new, scaled Bitmap
+ */
+ private fun resizePreservingAspectRatio(
+ bitmap: Bitmap,
+ desiredMaxWidth: Int,
+ desiredMaxHeight: Int
+ ): Bitmap {
+ val width = bitmap.width
+ val height = bitmap.height
+
+ // 0 is treated as 'no restriction'
+ val maxHeight = if (desiredMaxHeight == 0) height else desiredMaxHeight
+ val maxWidth = if (desiredMaxWidth == 0) width else desiredMaxWidth
+
+ // resize with preserved aspect ratio
+ var newWidth = min(width, maxWidth).toFloat()
+ var newHeight = height * newWidth / width
+ if (newHeight > maxHeight) {
+ newWidth = (width * maxHeight / height).toFloat()
+ newHeight = maxHeight.toFloat()
+ }
+ return Bitmap.createScaledBitmap(bitmap, newWidth.roundToInt(), newHeight.roundToInt(), false)
+ }
+
+ /**
+ * Transform an image with the given matrix
+ * @param bitmap
+ * @param matrix
+ * @return
+ */
+ private fun transform(bitmap: Bitmap, matrix: Matrix): Bitmap {
+ return Bitmap.createBitmap(bitmap, 0, 0, bitmap.width, bitmap.height, matrix, true)
+ }
+
+ /**
+ * Correct the orientation of an image by reading its exif information and rotating
+ * the appropriate amount for portrait mode
+ * @param bitmap
+ * @param imageUri
+ * @param exif
+ * @return
+ */
+ @Throws(IOException::class)
+ fun correctOrientation(c: Context, bitmap: Bitmap, imageUri: Uri, exif: ExifWrapper): Bitmap {
+ val orientation = getOrientation(c, imageUri)
+ return if (orientation != 0) {
+ val matrix = Matrix()
+ matrix.postRotate(orientation.toFloat())
+ exif.resetOrientation()
+ transform(bitmap, matrix)
+ } else {
+ bitmap
+ }
+ }
+
+ @Throws(IOException::class)
+ private fun getOrientation(c: Context, imageUri: Uri): Int {
+ var result = 0
+ c.getContentResolver().openInputStream(imageUri).use { iStream ->
+ val exifInterface = ExifInterface(iStream!!)
+ val orientation: Int = exifInterface.getAttributeInt(
+ ExifInterface.TAG_ORIENTATION,
+ ExifInterface.ORIENTATION_NORMAL
+ )
+ if (orientation == ExifInterface.ORIENTATION_ROTATE_90) {
+ result = 90
+ } else if (orientation == ExifInterface.ORIENTATION_ROTATE_180) {
+ result = 180
+ } else if (orientation == ExifInterface.ORIENTATION_ROTATE_270) {
+ result = 270
+ }
+ }
+ return result
+ }
+
+ fun getExifData(c: Context, bitmap: Bitmap?, imageUri: Uri): ExifWrapper {
+ var stream: InputStream? = null
+ try {
+ stream = c.getContentResolver().openInputStream(imageUri)
+ val exifInterface = ExifInterface(stream!!)
+ return ExifWrapper(exifInterface)
+ } catch (ex: IOException) {
+ Logger.error("Error loading exif data from image", ex)
+ } finally {
+ if (stream != null) {
+ try {
+ stream.close()
+ } catch (ignored: IOException) {
+ }
+ }
+ }
+ return ExifWrapper(null)
+ }
+}
diff --git a/plugins/camera/android/src/test/java/app/tauri/camera/ExampleUnitTest.kt b/plugins/camera/android/src/test/java/app/tauri/camera/ExampleUnitTest.kt
new file mode 100644
index 00000000..97f9ae03
--- /dev/null
+++ b/plugins/camera/android/src/test/java/app/tauri/camera/ExampleUnitTest.kt
@@ -0,0 +1,21 @@
+// Copyright 2019-2023 Tauri Programme within The Commons Conservancy
+// SPDX-License-Identifier: Apache-2.0
+// SPDX-License-Identifier: MIT
+
+package app.tauri.camera
+
+import org.junit.Test
+
+import org.junit.Assert.*
+
+/**
+ * Example local unit test, which will execute on the development machine (host).
+ *
+ * See [testing documentation](http://d.android.com/tools/testing).
+ */
+class ExampleUnitTest {
+ @Test
+ fun addition_isCorrect() {
+ assertEquals(4, 2 + 2)
+ }
+}
diff --git a/plugins/camera/build.rs b/plugins/camera/build.rs
new file mode 100644
index 00000000..743096a6
--- /dev/null
+++ b/plugins/camera/build.rs
@@ -0,0 +1,16 @@
+// Copyright 2019-2023 Tauri Programme within The Commons Conservancy
+// SPDX-License-Identifier: Apache-2.0
+// SPDX-License-Identifier: MIT
+
+use std::process::exit;
+
+fn main() {
+ if let Err(error) = tauri_build::mobile::PluginBuilder::new()
+ .android_path("android")
+ .ios_path("ios")
+ .run()
+ {
+ println!("{error:#}");
+ exit(1);
+ }
+}
diff --git a/plugins/camera/dist-js/index.d.ts b/plugins/camera/dist-js/index.d.ts
new file mode 100644
index 00000000..3a966e14
--- /dev/null
+++ b/plugins/camera/dist-js/index.d.ts
@@ -0,0 +1,42 @@
+// Copyright 2019-2023 Tauri Programme within The Commons Conservancy
+// SPDX-License-Identifier: Apache-2.0
+// SPDX-License-Identifier: MIT
+
+export declare enum Source {
+ Prompt = "PROMPT",
+ Camera = "CAMERA",
+ Photos = "PHOTOS"
+}
+export declare enum ResultType {
+ Uri = "uri",
+ Base64 = "base64",
+ DataUrl = "dataUrl"
+}
+export declare enum CameraDirection {
+ Rear = "REAR",
+ Front = "FRONT"
+}
+export interface ImageOptions {
+ quality?: number;
+ allowEditing?: boolean;
+ resultType?: ResultType;
+ saveToGallery?: boolean;
+ width?: number;
+ height?: number;
+ correctOrientation?: boolean;
+ source?: Source;
+ direction?: CameraDirection;
+ presentationStyle?: "fullscreen" | "popover";
+ promptLabelHeader?: string;
+ promptLabelCancel?: string;
+ promptLabelPhoto?: string;
+ promptLabelPicture?: string;
+}
+export interface Image {
+ data: string;
+ assetUrl?: string;
+ format: string;
+ saved: boolean;
+ exif: unknown;
+}
+export declare function getPhoto(options?: ImageOptions): Promise;
diff --git a/plugins/camera/dist-js/index.js b/plugins/camera/dist-js/index.js
new file mode 100644
index 00000000..e69de29b
diff --git a/plugins/camera/dist-js/index.min.js b/plugins/camera/dist-js/index.min.js
new file mode 100644
index 00000000..8c23b5e4
--- /dev/null
+++ b/plugins/camera/dist-js/index.min.js
@@ -0,0 +1,30 @@
+var f$1=Object.defineProperty;var g=(a,b)=>{for(var c in b)f$1(a,c,{get:b[c],enumerable:!0});};var e=(a,b,c)=>{if(!b.has(a))throw TypeError("Cannot "+c)};var h$1=(a,b,c)=>(e(a,b,"read from private field"),c?c.call(a):b.get(a)),i$1=(a,b,c)=>{if(b.has(a))throw TypeError("Cannot add the same private member more than once");b instanceof WeakSet?b.add(a):b.set(a,c);},j=(a,b,c,d)=>(e(a,b,"write to private field"),d?d.call(a,c):b.set(a,c),c);
+
+var h={};g(h,{Channel:()=>o,PluginListener:()=>a,addPluginListener:()=>m,convertFileSrc:()=>y,invoke:()=>u,transformCallback:()=>s});function f(){return window.crypto.getRandomValues(new Uint32Array(1))[0]}function s(n,e=!1){let t=f(),r=`_${t}`;return Object.defineProperty(window,r,{value:c=>(e&&Reflect.deleteProperty(window,r),n?.(c)),writable:!1,configurable:!0}),t}var i,o=class{constructor(){this.__TAURI_CHANNEL_MARKER__=!0;i$1(this,i,()=>{});this.id=s(e=>{h$1(this,i).call(this,e);});}set onmessage(e){j(this,i,e);}get onmessage(){return h$1(this,i)}toJSON(){return `__CHANNEL__:${this.id}`}};i=new WeakMap;var a=class{constructor(e,t,r){this.plugin=e,this.event=t,this.channelId=r;}async unregister(){return u(`plugin:${this.plugin}|remove_listener`,{event:this.event,channelId:this.channelId})}};async function m(n,e,t){let r=new o;return r.onmessage=t,u(`plugin:${n}|register_listener`,{event:e,handler:r}).then(()=>new a(n,e,r.id))}async function u(n,e={},t){return new Promise((r,c)=>{let g=s(d=>{r(d),Reflect.deleteProperty(window,`_${_}`);},!0),_=s(d=>{c(d),Reflect.deleteProperty(window,`_${g}`);},!0);window.__TAURI_IPC__({cmd:n,callback:g,error:_,payload:e,options:t});})}function y(n,e="asset"){return window.__TAURI__.convertFileSrc(n,e)}
+
+// Copyright 2019-2023 Tauri Programme within The Commons Conservancy
+// SPDX-License-Identifier: Apache-2.0
+// SPDX-License-Identifier: MIT
+var Source;
+(function (Source) {
+ Source["Prompt"] = "PROMPT";
+ Source["Camera"] = "CAMERA";
+ Source["Photos"] = "PHOTOS";
+})(Source || (Source = {}));
+var ResultType;
+(function (ResultType) {
+ ResultType["Uri"] = "uri";
+ ResultType["Base64"] = "base64";
+ ResultType["DataUrl"] = "dataUrl";
+})(ResultType || (ResultType = {}));
+var CameraDirection;
+(function (CameraDirection) {
+ CameraDirection["Rear"] = "REAR";
+ CameraDirection["Front"] = "FRONT";
+})(CameraDirection || (CameraDirection = {}));
+async function getPhoto(options) {
+ return await u("plugin:camera|getPhoto", { ...options });
+}
+
+export { CameraDirection, ResultType, Source, getPhoto };
+//# sourceMappingURL=index.min.js.map
diff --git a/plugins/camera/dist-js/index.min.js.map b/plugins/camera/dist-js/index.min.js.map
new file mode 100644
index 00000000..1022c00d
--- /dev/null
+++ b/plugins/camera/dist-js/index.min.js.map
@@ -0,0 +1 @@
+{"version":3,"file":"index.min.js","sources":["../../../node_modules/.pnpm/@tauri-apps+api@2.0.0-alpha.8/node_modules/@tauri-apps/api/chunk-WVIOQTRJ.js","../../../node_modules/.pnpm/@tauri-apps+api@2.0.0-alpha.8/node_modules/@tauri-apps/api/chunk-VMGITBBQ.js","../guest-js/index.ts"],"sourcesContent":["var f=Object.defineProperty;var g=(a,b)=>{for(var c in b)f(a,c,{get:b[c],enumerable:!0})};var e=(a,b,c)=>{if(!b.has(a))throw TypeError(\"Cannot \"+c)};var h=(a,b,c)=>(e(a,b,\"read from private field\"),c?c.call(a):b.get(a)),i=(a,b,c)=>{if(b.has(a))throw TypeError(\"Cannot add the same private member more than once\");b instanceof WeakSet?b.add(a):b.set(a,c)},j=(a,b,c,d)=>(e(a,b,\"write to private field\"),d?d.call(a,c):b.set(a,c),c);export{g as a,h as b,i as c,j as d};\n","import{a as w,b as l,c as p,d as v}from\"./chunk-WVIOQTRJ.js\";var h={};w(h,{Channel:()=>o,PluginListener:()=>a,addPluginListener:()=>m,convertFileSrc:()=>y,invoke:()=>u,transformCallback:()=>s});function f(){return window.crypto.getRandomValues(new Uint32Array(1))[0]}function s(n,e=!1){let t=f(),r=`_${t}`;return Object.defineProperty(window,r,{value:c=>(e&&Reflect.deleteProperty(window,r),n?.(c)),writable:!1,configurable:!0}),t}var i,o=class{constructor(){this.__TAURI_CHANNEL_MARKER__=!0;p(this,i,()=>{});this.id=s(e=>{l(this,i).call(this,e)})}set onmessage(e){v(this,i,e)}get onmessage(){return l(this,i)}toJSON(){return`__CHANNEL__:${this.id}`}};i=new WeakMap;var a=class{constructor(e,t,r){this.plugin=e,this.event=t,this.channelId=r}async unregister(){return u(`plugin:${this.plugin}|remove_listener`,{event:this.event,channelId:this.channelId})}};async function m(n,e,t){let r=new o;return r.onmessage=t,u(`plugin:${n}|register_listener`,{event:e,handler:r}).then(()=>new a(n,e,r.id))}async function u(n,e={},t){return new Promise((r,c)=>{let g=s(d=>{r(d),Reflect.deleteProperty(window,`_${_}`)},!0),_=s(d=>{c(d),Reflect.deleteProperty(window,`_${g}`)},!0);window.__TAURI_IPC__({cmd:n,callback:g,error:_,payload:e,options:t})})}function y(n,e=\"asset\"){return window.__TAURI__.convertFileSrc(n,e)}export{s as a,o as b,a as c,m as d,u as e,y as f,h as g};\n",null],"names":["f","h","i","w","p","l","v","invoke"],"mappings":"AAAA,IAAIA,GAAC,CAAC,MAAM,CAAC,cAAc,CAAC,IAAI,CAAC,CAAC,CAAC,CAAC,CAAC,CAAC,GAAG,CAAC,IAAI,IAAI,CAAC,IAAI,CAAC,CAACA,GAAC,CAAC,CAAC,CAAC,CAAC,CAAC,CAAC,GAAG,CAAC,CAAC,CAAC,CAAC,CAAC,CAAC,UAAU,CAAC,CAAC,CAAC,CAAC,EAAC,CAAC,CAAC,IAAI,CAAC,CAAC,CAAC,CAAC,CAAC,CAAC,CAAC,CAAC,GAAG,CAAC,GAAG,CAAC,CAAC,CAAC,GAAG,CAAC,CAAC,CAAC,CAAC,MAAM,SAAS,CAAC,SAAS,CAAC,CAAC,CAAC,CAAC,CAAC,IAAIC,GAAC,CAAC,CAAC,CAAC,CAAC,CAAC,CAAC,CAAC,IAAI,CAAC,CAAC,CAAC,CAAC,CAAC,CAAC,yBAAyB,CAAC,CAAC,CAAC,CAAC,CAAC,CAAC,IAAI,CAAC,CAAC,CAAC,CAAC,CAAC,CAAC,GAAG,CAAC,CAAC,CAAC,CAAC,CAACC,GAAC,CAAC,CAAC,CAAC,CAAC,CAAC,CAAC,CAAC,GAAG,CAAC,GAAG,CAAC,CAAC,GAAG,CAAC,CAAC,CAAC,CAAC,MAAM,SAAS,CAAC,mDAAmD,CAAC,CAAC,CAAC,YAAY,OAAO,CAAC,CAAC,CAAC,GAAG,CAAC,CAAC,CAAC,CAAC,CAAC,CAAC,GAAG,CAAC,CAAC,CAAC,CAAC,EAAC,CAAC,CAAC,CAAC,CAAC,CAAC,CAAC,CAAC,CAAC,CAAC,CAAC,CAAC,CAAC,IAAI,CAAC,CAAC,CAAC,CAAC,CAAC,CAAC,wBAAwB,CAAC,CAAC,CAAC,CAAC,CAAC,CAAC,IAAI,CAAC,CAAC,CAAC,CAAC,CAAC,CAAC,CAAC,CAAC,GAAG,CAAC,CAAC,CAAC,CAAC,CAAC,CAAC,CAAC,CAAC;;ACA/W,IAAI,CAAC,CAAC,EAAE,CAACC,CAAC,CAAC,CAAC,CAAC,CAAC,OAAO,CAAC,IAAI,CAAC,CAAC,cAAc,CAAC,IAAI,CAAC,CAAC,iBAAiB,CAAC,IAAI,CAAC,CAAC,cAAc,CAAC,IAAI,CAAC,CAAC,MAAM,CAAC,IAAI,CAAC,CAAC,iBAAiB,CAAC,IAAI,CAAC,CAAC,CAAC,CAAC,SAAS,CAAC,EAAE,CAAC,OAAO,MAAM,CAAC,MAAM,CAAC,eAAe,CAAC,IAAI,WAAW,CAAC,CAAC,CAAC,CAAC,CAAC,CAAC,CAAC,CAAC,SAAS,CAAC,CAAC,CAAC,CAAC,CAAC,CAAC,CAAC,CAAC,CAAC,CAAC,IAAI,CAAC,CAAC,CAAC,EAAE,CAAC,CAAC,CAAC,CAAC,CAAC,EAAE,CAAC,CAAC,CAAC,CAAC,OAAO,MAAM,CAAC,cAAc,CAAC,MAAM,CAAC,CAAC,CAAC,CAAC,KAAK,CAAC,CAAC,GAAG,CAAC,EAAE,OAAO,CAAC,cAAc,CAAC,MAAM,CAAC,CAAC,CAAC,CAAC,CAAC,GAAG,CAAC,CAAC,CAAC,CAAC,QAAQ,CAAC,CAAC,CAAC,CAAC,YAAY,CAAC,CAAC,CAAC,CAAC,CAAC,CAAC,CAAC,CAAC,IAAI,CAAC,CAAC,CAAC,CAAC,KAAK,CAAC,WAAW,EAAE,CAAC,IAAI,CAAC,wBAAwB,CAAC,CAAC,CAAC,CAACC,GAAC,CAAC,IAAI,CAAC,CAAC,CAAC,IAAI,EAAE,CAAC,CAAC,IAAI,CAAC,EAAE,CAAC,CAAC,CAAC,CAAC,EAAE,CAACC,GAAC,CAAC,IAAI,CAAC,CAAC,CAAC,CAAC,IAAI,CAAC,IAAI,CAAC,CAAC,EAAC,CAAC,EAAC,CAAC,IAAI,SAAS,CAAC,CAAC,CAAC,CAACC,CAAC,CAAC,IAAI,CAAC,CAAC,CAAC,CAAC,EAAC,CAAC,IAAI,SAAS,EAAE,CAAC,OAAOD,GAAC,CAAC,IAAI,CAAC,CAAC,CAAC,CAAC,MAAM,EAAE,CAAC,OAAM,CAAC,YAAY,EAAE,IAAI,CAAC,EAAE,CAAC,CAAC,CAAC,CAAC,CAAC,CAAC,CAAC,IAAI,OAAO,CAAC,IAAI,CAAC,CAAC,KAAK,CAAC,WAAW,CAAC,CAAC,CAAC,CAAC,CAAC,CAAC,CAAC,CAAC,IAAI,CAAC,MAAM,CAAC,CAAC,CAAC,IAAI,CAAC,KAAK,CAAC,CAAC,CAAC,IAAI,CAAC,SAAS,CAAC,EAAC,CAAC,MAAM,UAAU,EAAE,CAAC,OAAO,CAAC,CAAC,CAAC,OAAO,EAAE,IAAI,CAAC,MAAM,CAAC,gBAAgB,CAAC,CAAC,CAAC,KAAK,CAAC,IAAI,CAAC,KAAK,CAAC,SAAS,CAAC,IAAI,CAAC,SAAS,CAAC,CAAC,CAAC,CAAC,CAAC,eAAe,CAAC,CAAC,CAAC,CAAC,CAAC,CAAC,CAAC,CAAC,CAAC,IAAI,CAAC,CAAC,IAAI,CAAC,CAAC,OAAO,CAAC,CAAC,SAAS,CAAC,CAAC,CAAC,CAAC,CAAC,CAAC,OAAO,EAAE,CAAC,CAAC,kBAAkB,CAAC,CAAC,CAAC,KAAK,CAAC,CAAC,CAAC,OAAO,CAAC,CAAC,CAAC,CAAC,CAAC,IAAI,CAAC,IAAI,IAAI,CAAC,CAAC,CAAC,CAAC,CAAC,CAAC,CAAC,CAAC,EAAE,CAAC,CAAC,CAAC,eAAe,CAAC,CAAC,CAAC,CAAC,CAAC,CAAC,EAAE,CAAC,CAAC,CAAC,CAAC,OAAO,IAAI,OAAO,CAAC,CAAC,CAAC,CAAC,CAAC,GAAG,CAAC,IAAI,CAAC,CAAC,CAAC,CAAC,CAAC,EAAE,CAAC,CAAC,CAAC,CAAC,CAAC,CAAC,OAAO,CAAC,cAAc,CAAC,MAAM,CAAC,CAAC,CAAC,EAAE,CAAC,CAAC,CAAC,EAAC,CAAC,CAAC,CAAC,CAAC,CAAC,CAAC,CAAC,CAAC,CAAC,CAAC,CAAC,EAAE,CAAC,CAAC,CAAC,CAAC,CAAC,CAAC,OAAO,CAAC,cAAc,CAAC,MAAM,CAAC,CAAC,CAAC,EAAE,CAAC,CAAC,CAAC,EAAC,CAAC,CAAC,CAAC,CAAC,CAAC,CAAC,MAAM,CAAC,aAAa,CAAC,CAAC,GAAG,CAAC,CAAC,CAAC,QAAQ,CAAC,CAAC,CAAC,KAAK,CAAC,CAAC,CAAC,OAAO,CAAC,CAAC,CAAC,OAAO,CAAC,CAAC,CAAC,EAAC,CAAC,CAAC,CAAC,SAAS,CAAC,CAAC,CAAC,CAAC,CAAC,CAAC,OAAO,CAAC,CAAC,OAAO,MAAM,CAAC,SAAS,CAAC,cAAc,CAAC,CAAC,CAAC,CAAC,CAAC;;ACAxxC;AACA;AACA;IAGY,OAIX;AAJD,CAAA,UAAY,MAAM,EAAA;AAChB,IAAA,MAAA,CAAA,QAAA,CAAA,GAAA,QAAiB,CAAA;AACjB,IAAA,MAAA,CAAA,QAAA,CAAA,GAAA,QAAiB,CAAA;AACjB,IAAA,MAAA,CAAA,QAAA,CAAA,GAAA,QAAiB,CAAA;AACnB,CAAC,EAJW,MAAM,KAAN,MAAM,GAIjB,EAAA,CAAA,CAAA,CAAA;IAEW,WAIX;AAJD,CAAA,UAAY,UAAU,EAAA;AACpB,IAAA,UAAA,CAAA,KAAA,CAAA,GAAA,KAAW,CAAA;AACX,IAAA,UAAA,CAAA,QAAA,CAAA,GAAA,QAAiB,CAAA;AACjB,IAAA,UAAA,CAAA,SAAA,CAAA,GAAA,SAAmB,CAAA;AACrB,CAAC,EAJW,UAAU,KAAV,UAAU,GAIrB,EAAA,CAAA,CAAA,CAAA;IAEW,gBAGX;AAHD,CAAA,UAAY,eAAe,EAAA;AACzB,IAAA,eAAA,CAAA,MAAA,CAAA,GAAA,MAAa,CAAA;AACb,IAAA,eAAA,CAAA,OAAA,CAAA,GAAA,OAAe,CAAA;AACjB,CAAC,EAHW,eAAe,KAAf,eAAe,GAG1B,EAAA,CAAA,CAAA,CAAA;AA2BM,eAAe,QAAQ,CAAC,OAAsB,EAAA;IACnD,OAAO,MAAME,CAAM,CAAC,wBAAwB,EAAE,EAAE,GAAG,OAAO,EAAE,CAAC,CAAC;AAChE;;;;","x_google_ignoreList":[0,1]}
\ No newline at end of file
diff --git a/plugins/camera/dist-js/index.mjs b/plugins/camera/dist-js/index.mjs
new file mode 100644
index 00000000..76bf6220
--- /dev/null
+++ b/plugins/camera/dist-js/index.mjs
@@ -0,0 +1,28 @@
+import { invoke } from '@tauri-apps/api/tauri';
+
+// Copyright 2019-2023 Tauri Programme within The Commons Conservancy
+// SPDX-License-Identifier: Apache-2.0
+// SPDX-License-Identifier: MIT
+var Source;
+(function (Source) {
+ Source["Prompt"] = "PROMPT";
+ Source["Camera"] = "CAMERA";
+ Source["Photos"] = "PHOTOS";
+})(Source || (Source = {}));
+var ResultType;
+(function (ResultType) {
+ ResultType["Uri"] = "uri";
+ ResultType["Base64"] = "base64";
+ ResultType["DataUrl"] = "dataUrl";
+})(ResultType || (ResultType = {}));
+var CameraDirection;
+(function (CameraDirection) {
+ CameraDirection["Rear"] = "REAR";
+ CameraDirection["Front"] = "FRONT";
+})(CameraDirection || (CameraDirection = {}));
+async function getPhoto(options) {
+ return await invoke("plugin:camera|getPhoto", { ...options });
+}
+
+export { CameraDirection, ResultType, Source, getPhoto };
+//# sourceMappingURL=index.mjs.map
diff --git a/plugins/camera/dist-js/index.mjs.map b/plugins/camera/dist-js/index.mjs.map
new file mode 100644
index 00000000..88e017ec
--- /dev/null
+++ b/plugins/camera/dist-js/index.mjs.map
@@ -0,0 +1 @@
+{"version":3,"file":"index.mjs","sources":["../guest-js/index.ts"],"sourcesContent":[null],"names":[],"mappings":";;AAAA;AACA;AACA;IAGY,OAIX;AAJD,CAAA,UAAY,MAAM,EAAA;AAChB,IAAA,MAAA,CAAA,QAAA,CAAA,GAAA,QAAiB,CAAA;AACjB,IAAA,MAAA,CAAA,QAAA,CAAA,GAAA,QAAiB,CAAA;AACjB,IAAA,MAAA,CAAA,QAAA,CAAA,GAAA,QAAiB,CAAA;AACnB,CAAC,EAJW,MAAM,KAAN,MAAM,GAIjB,EAAA,CAAA,CAAA,CAAA;IAEW,WAIX;AAJD,CAAA,UAAY,UAAU,EAAA;AACpB,IAAA,UAAA,CAAA,KAAA,CAAA,GAAA,KAAW,CAAA;AACX,IAAA,UAAA,CAAA,QAAA,CAAA,GAAA,QAAiB,CAAA;AACjB,IAAA,UAAA,CAAA,SAAA,CAAA,GAAA,SAAmB,CAAA;AACrB,CAAC,EAJW,UAAU,KAAV,UAAU,GAIrB,EAAA,CAAA,CAAA,CAAA;IAEW,gBAGX;AAHD,CAAA,UAAY,eAAe,EAAA;AACzB,IAAA,eAAA,CAAA,MAAA,CAAA,GAAA,MAAa,CAAA;AACb,IAAA,eAAA,CAAA,OAAA,CAAA,GAAA,OAAe,CAAA;AACjB,CAAC,EAHW,eAAe,KAAf,eAAe,GAG1B,EAAA,CAAA,CAAA,CAAA;AA2BM,eAAe,QAAQ,CAAC,OAAsB,EAAA;IACnD,OAAO,MAAM,MAAM,CAAC,wBAAwB,EAAE,EAAE,GAAG,OAAO,EAAE,CAAC,CAAC;AAChE;;;;"}
\ No newline at end of file
diff --git a/plugins/camera/guest-js/index.ts b/plugins/camera/guest-js/index.ts
new file mode 100644
index 00000000..86ea1d97
--- /dev/null
+++ b/plugins/camera/guest-js/index.ts
@@ -0,0 +1,50 @@
+// Copyright 2019-2023 Tauri Programme within The Commons Conservancy
+// SPDX-License-Identifier: Apache-2.0
+// SPDX-License-Identifier: MIT
+import { invoke } from "@tauri-apps/api/tauri";
+
+export enum Source {
+ Prompt = "PROMPT",
+ Camera = "CAMERA",
+ Photos = "PHOTOS",
+}
+
+export enum ResultType {
+ Uri = "uri",
+ Base64 = "base64",
+ DataUrl = "dataUrl",
+}
+
+export enum CameraDirection {
+ Rear = "REAR",
+ Front = "FRONT",
+}
+
+export interface ImageOptions {
+ quality?: number;
+ allowEditing?: boolean;
+ resultType?: ResultType;
+ saveToGallery?: boolean;
+ width?: number;
+ height?: number;
+ correctOrientation?: boolean;
+ source?: Source;
+ direction?: CameraDirection;
+ presentationStyle?: "fullscreen" | "popover";
+ promptLabelHeader?: string;
+ promptLabelCancel?: string;
+ promptLabelPhoto?: string;
+ promptLabelPicture?: string;
+}
+
+export interface Image {
+ data: string;
+ assetUrl?: string;
+ format: string;
+ saved: boolean;
+ exif: unknown;
+}
+
+export async function getPhoto(options?: ImageOptions): Promise {
+ return await invoke("plugin:camera|getPhoto", { ...options });
+}
diff --git a/plugins/camera/ios/.gitignore b/plugins/camera/ios/.gitignore
new file mode 100644
index 00000000..5011d482
--- /dev/null
+++ b/plugins/camera/ios/.gitignore
@@ -0,0 +1,11 @@
+.DS_Store
+/.build
+/Packages
+/*.xcodeproj
+xcuserdata/
+DerivedData/
+.swiftpm/config/registries.json
+.swiftpm/xcode/package.xcworkspace/contents.xcworkspacedata
+.netrc
+Package.resolved
+/.tauri
diff --git a/plugins/camera/ios/Package.swift b/plugins/camera/ios/Package.swift
new file mode 100644
index 00000000..a5e0dcc4
--- /dev/null
+++ b/plugins/camera/ios/Package.swift
@@ -0,0 +1,34 @@
+// swift-tools-version:5.3
+// Copyright 2019-2023 Tauri Programme within The Commons Conservancy
+// SPDX-License-Identifier: Apache-2.0
+// SPDX-License-Identifier: MIT
+// The swift-tools-version declares the minimum version of Swift required to build this package.
+
+import PackageDescription
+
+let package = Package(
+ name: "tauri-plugin-camera",
+ platforms: [
+ .iOS(.v11)
+ ],
+ products: [
+ // Products define the executables and libraries a package produces, and make them visible to other packages.
+ .library(
+ name: "tauri-plugin-camera",
+ type: .static,
+ targets: ["tauri-plugin-camera"])
+ ],
+ dependencies: [
+ .package(name: "Tauri", path: "../.tauri/tauri-api")
+ ],
+ 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-plugin-camera",
+ dependencies: [
+ .byName(name: "Tauri")
+ ],
+ path: "Sources")
+ ]
+)
diff --git a/plugins/camera/ios/README.md b/plugins/camera/ios/README.md
new file mode 100644
index 00000000..5293ed5b
--- /dev/null
+++ b/plugins/camera/ios/README.md
@@ -0,0 +1,3 @@
+# Tauri Plugin camera
+
+A description of this package.
diff --git a/plugins/camera/ios/Sources/CameraExtensions.swift b/plugins/camera/ios/Sources/CameraExtensions.swift
new file mode 100644
index 00000000..5390dc05
--- /dev/null
+++ b/plugins/camera/ios/Sources/CameraExtensions.swift
@@ -0,0 +1,112 @@
+// Copyright 2019-2023 Tauri Programme within The Commons Conservancy
+// SPDX-License-Identifier: Apache-2.0
+// SPDX-License-Identifier: MIT
+
+import Photos
+import UIKit
+
+internal protocol CameraAuthorizationState {
+ var authorizationState: String { get }
+}
+
+extension AVAuthorizationStatus: CameraAuthorizationState {
+ var authorizationState: String {
+ switch self {
+ case .denied, .restricted:
+ return "denied"
+ case .authorized:
+ return "granted"
+ case .notDetermined:
+ fallthrough
+ @unknown default:
+ return "prompt"
+ }
+ }
+}
+
+extension PHAuthorizationStatus: CameraAuthorizationState {
+ var authorizationState: String {
+ switch self {
+ case .denied, .restricted:
+ return "denied"
+ case .authorized:
+ return "granted"
+ #if swift(>=5.3)
+ // poor proxy for Xcode 12/iOS 14, should be removed once building with Xcode 12 is required
+ case .limited:
+ return "limited"
+ #endif
+ case .notDetermined:
+ fallthrough
+ @unknown default:
+ return "prompt"
+ }
+ }
+}
+
+extension PHAsset {
+ /**
+ Retrieves the image metadata for the asset.
+ */
+ var imageData: [String: Any] {
+ let options = PHImageRequestOptions()
+ options.isSynchronous = true
+ options.resizeMode = .none
+ options.isNetworkAccessAllowed = false
+ options.version = .current
+
+ var result: [String: Any] = [:]
+ _ = PHCachingImageManager().requestImageDataAndOrientation(for: self, options: options) {
+ (data, _, _, _) in
+ if let data = data as NSData? {
+ let options = [kCGImageSourceShouldCache as String: kCFBooleanFalse] as CFDictionary
+ if let imgSrc = CGImageSourceCreateWithData(data, options),
+ let metadata = CGImageSourceCopyPropertiesAtIndex(imgSrc, 0, options) as? [String: Any]
+ {
+ result = metadata
+ }
+ }
+ }
+ return result
+ }
+}
+
+extension UIImage {
+ /**
+ Generates a new image from the existing one, implicitly resetting any orientation.
+ Dimensions greater than 0 will resize the image while preserving the aspect ratio.
+ */
+ func reformat(to size: CGSize? = nil) -> UIImage {
+ let imageHeight = self.size.height
+ let imageWidth = self.size.width
+ // determine the max dimensions, 0 is treated as 'no restriction'
+ var maxWidth: CGFloat
+ if let size = size, size.width > 0 {
+ maxWidth = size.width
+ } else {
+ maxWidth = imageWidth
+ }
+ let maxHeight: CGFloat
+ if let size = size, size.height > 0 {
+ maxHeight = size.height
+ } else {
+ maxHeight = imageHeight
+ }
+ // adjust to preserve aspect ratio
+ var targetWidth = min(imageWidth, maxWidth)
+ var targetHeight = (imageHeight * targetWidth) / imageWidth
+ if targetHeight > maxHeight {
+ targetWidth = (imageWidth * maxHeight) / imageHeight
+ targetHeight = maxHeight
+ }
+ // generate the new image and return
+ let format: UIGraphicsImageRendererFormat = UIGraphicsImageRendererFormat.default()
+ format.scale = 1.0
+ format.opaque = false
+ let renderer = UIGraphicsImageRenderer(
+ size: CGSize(width: targetWidth, height: targetHeight), format: format)
+ return renderer.image { (_) in
+ self.draw(in: CGRect(origin: .zero, size: CGSize(width: targetWidth, height: targetHeight)))
+ }
+ }
+}
diff --git a/plugins/camera/ios/Sources/CameraPlugin.swift b/plugins/camera/ios/Sources/CameraPlugin.swift
new file mode 100644
index 00000000..555d135a
--- /dev/null
+++ b/plugins/camera/ios/Sources/CameraPlugin.swift
@@ -0,0 +1,624 @@
+// Copyright 2019-2023 Tauri Programme within The Commons Conservancy
+// SPDX-License-Identifier: Apache-2.0
+// SPDX-License-Identifier: MIT
+
+import Photos
+import PhotosUI
+import Tauri
+import UIKit
+import WebKit
+
+public class CameraPlugin: Plugin {
+ private var invoke: Invoke?
+ private var settings = CameraSettings()
+ private let defaultSource = CameraSource.prompt
+ private let defaultDirection = CameraDirection.rear
+ private var multiple = false
+
+ private var imageCounter = 0
+
+ @objc override public func checkPermissions(_ invoke: Invoke) {
+ var result: [String: Any] = [:]
+ for permission in CameraPermissionType.allCases {
+ let state: String
+ switch permission {
+ case .camera:
+ state = AVCaptureDevice.authorizationStatus(for: .video).authorizationState
+ case .photos:
+ if #available(iOS 14, *) {
+ state = PHPhotoLibrary.authorizationStatus(for: .readWrite).authorizationState
+ } else {
+ state = PHPhotoLibrary.authorizationStatus().authorizationState
+ }
+ }
+ result[permission.rawValue] = state
+ }
+ invoke.resolve(result)
+ }
+
+ @objc override public func requestPermissions(_ invoke: Invoke) {
+ // get the list of desired types, if passed
+ let typeList =
+ invoke.getArray("permissions", String.self)?.compactMap({ (type) -> CameraPermissionType? in
+ return CameraPermissionType(rawValue: type)
+ }) ?? []
+ // otherwise check everything
+ let permissions: [CameraPermissionType] =
+ (typeList.count > 0) ? typeList : CameraPermissionType.allCases
+ // request the permissions
+ let group = DispatchGroup()
+ for permission in permissions {
+ switch permission {
+ case .camera:
+ group.enter()
+ AVCaptureDevice.requestAccess(for: .video) { _ in
+ group.leave()
+ }
+ case .photos:
+ group.enter()
+ if #available(iOS 14, *) {
+ PHPhotoLibrary.requestAuthorization(for: .readWrite) { (_) in
+ group.leave()
+ }
+ } else {
+ PHPhotoLibrary.requestAuthorization({ (_) in
+ group.leave()
+ })
+ }
+ }
+ }
+ group.notify(queue: DispatchQueue.main) { [weak self] in
+ self?.checkPermissions(invoke)
+ }
+ }
+
+ @objc func pickLimitedLibraryPhotos(_ invoke: Invoke) {
+ if #available(iOS 14, *) {
+ PHPhotoLibrary.requestAuthorization(for: .readWrite) { (granted) in
+ if granted == .limited {
+ if let viewController = self.manager.viewController {
+ if #available(iOS 15, *) {
+ PHPhotoLibrary.shared().presentLimitedLibraryPicker(from: viewController) { _ in
+ self.getLimitedLibraryPhotos(invoke)
+ }
+ } else {
+ PHPhotoLibrary.shared().presentLimitedLibraryPicker(from: viewController)
+ invoke.resolve([
+ "photos": []
+ ])
+ }
+ }
+ } else {
+ invoke.resolve([
+ "photos": []
+ ])
+ }
+ }
+ } else {
+ invoke.unavailable("Not available on iOS 13")
+ }
+ }
+
+ @objc func getLimitedLibraryPhotos(_ invoke: Invoke) {
+ if #available(iOS 14, *) {
+ PHPhotoLibrary.requestAuthorization(for: .readWrite) { (granted) in
+ if granted == .limited {
+
+ self.invoke = invoke
+
+ DispatchQueue.global(qos: .utility).async {
+ let assets = PHAsset.fetchAssets(with: .image, options: nil)
+ var processedImages: [ProcessedImage] = []
+
+ let imageManager = PHImageManager.default()
+ let options = PHImageRequestOptions()
+ options.deliveryMode = .highQualityFormat
+
+ let group = DispatchGroup()
+
+ for index in 0...(assets.count - 1) {
+ let asset = assets.object(at: index)
+ let fullSize = CGSize(width: asset.pixelWidth, height: asset.pixelHeight)
+
+ group.enter()
+ imageManager.requestImage(
+ for: asset, targetSize: fullSize, contentMode: .default, options: options
+ ) { image, _ in
+ guard let image = image else {
+ group.leave()
+ return
+ }
+ processedImages.append(self.processedImage(from: image, with: asset.imageData))
+ group.leave()
+ }
+ }
+
+ group.notify(queue: .global(qos: .utility)) { [weak self] in
+ self?.returnImages(processedImages)
+ }
+ }
+ } else {
+ invoke.resolve([
+ "photos": []
+ ])
+ }
+ }
+ } else {
+ invoke.unavailable("Not available on iOS 13")
+ }
+ }
+
+ @objc func getPhoto(_ invoke: Invoke) {
+ self.multiple = false
+ self.invoke = invoke
+ self.settings = cameraSettings(from: invoke)
+
+ // Make sure they have all the necessary info.plist settings
+ if let missingUsageDescription = checkUsageDescriptions() {
+ Logger.error("[PLUGIN]", "Camera", "-", missingUsageDescription)
+ invoke.reject(missingUsageDescription)
+ return
+ }
+
+ DispatchQueue.main.async {
+ switch self.settings.source {
+ case .prompt:
+ self.showPrompt()
+ case .camera:
+ self.showCamera()
+ case .photos:
+ self.showPhotos()
+ }
+ }
+ }
+
+ @objc func pickImages(_ invoke: Invoke) {
+ self.multiple = true
+ self.invoke = invoke
+ self.settings = cameraSettings(from: invoke)
+ DispatchQueue.main.async {
+ self.showPhotos()
+ }
+ }
+
+ private func checkUsageDescriptions() -> String? {
+ if let dict = Bundle.main.infoDictionary {
+ for key in CameraPropertyListKeys.allCases where dict[key.rawValue] == nil {
+ return key.missingMessage
+ }
+ }
+ return nil
+ }
+
+ private func cameraSettings(from invoke: Invoke) -> CameraSettings {
+ var settings = CameraSettings()
+ settings.jpegQuality = min(abs(CGFloat(invoke.getFloat("quality") ?? 100.0)) / 100.0, 1.0)
+ settings.allowEditing = invoke.getBool("allowEditing") ?? false
+ settings.source =
+ CameraSource(rawValue: invoke.getString("source") ?? defaultSource.rawValue) ?? defaultSource
+ settings.direction =
+ CameraDirection(rawValue: invoke.getString("direction") ?? defaultDirection.rawValue)
+ ?? defaultDirection
+ if let typeString = invoke.getString("resultType"),
+ let type = CameraResultType(rawValue: typeString)
+ {
+ settings.resultType = type
+ }
+ settings.saveToGallery = invoke.getBool("saveToGallery") ?? false
+
+ // Get the new image dimensions if provided
+ settings.width = CGFloat(invoke.getInt("width") ?? 0)
+ settings.height = CGFloat(invoke.getInt("height") ?? 0)
+ if settings.width > 0 || settings.height > 0 {
+ // We resize only if a dimension was provided
+ settings.shouldResize = true
+ }
+ settings.shouldCorrectOrientation = invoke.getBool("correctOrientation") ?? true
+ settings.userPromptText = CameraPromptText(
+ title: invoke.getString("promptLabelHeader"),
+ photoAction: invoke.getString("promptLabelPhoto"),
+ cameraAction: invoke.getString("promptLabelPicture"),
+ cancelAction: invoke.getString("promptLabelCancel"))
+ if let styleString = invoke.getString("presentationStyle"), styleString == "popover" {
+ settings.presentationStyle = .popover
+ } else {
+ settings.presentationStyle = .fullScreen
+ }
+
+ return settings
+ }
+}
+
+// public delegate methods
+extension CameraPlugin: UIImagePickerControllerDelegate, UINavigationControllerDelegate,
+ UIPopoverPresentationControllerDelegate
+{
+ public func imagePickerControllerDidCancel(_ picker: UIImagePickerController) {
+ picker.dismiss(animated: true)
+ self.invoke?.reject("User cancelled photos app")
+ }
+
+ public func popoverPresentationControllerDidDismissPopover(
+ _ popoverPresentationController: UIPopoverPresentationController
+ ) {
+ self.invoke?.reject("User cancelled photos app")
+ }
+
+ public func presentationControllerDidDismiss(_ presentationController: UIPresentationController) {
+ self.invoke?.reject("User cancelled photos app")
+ }
+
+ public func imagePickerController(
+ _ picker: UIImagePickerController,
+ didFinishPickingMediaWithInfo info: [UIImagePickerController.InfoKey: Any]
+ ) {
+ picker.dismiss(animated: true) {
+ if let processedImage = self.processImage(from: info) {
+ self.returnProcessedImage(processedImage)
+ } else {
+ self.invoke?.reject("Error processing image")
+ }
+ }
+ }
+}
+
+@available(iOS 14, *)
+extension CameraPlugin: PHPickerViewControllerDelegate {
+ public func picker(_ picker: PHPickerViewController, didFinishPicking results: [PHPickerResult]) {
+ picker.dismiss(animated: true, completion: nil)
+ guard let result = results.first else {
+ self.invoke?.reject("User cancelled photos app")
+ return
+ }
+ if multiple {
+ var images: [ProcessedImage] = []
+ var processedCount = 0
+ for img in results {
+ guard img.itemProvider.canLoadObject(ofClass: UIImage.self) else {
+ self.invoke?.reject("Error loading image")
+ return
+ }
+ // extract the image
+ img.itemProvider.loadObject(ofClass: UIImage.self) { [weak self] (reading, _) in
+ if let image = reading as? UIImage {
+ var asset: PHAsset?
+ if let assetId = img.assetIdentifier {
+ asset = PHAsset.fetchAssets(withLocalIdentifiers: [assetId], options: nil).firstObject
+ }
+ if let processedImage = self?.processedImage(from: image, with: asset?.imageData) {
+ images.append(processedImage)
+ }
+ processedCount += 1
+ if processedCount == results.count {
+ self?.returnImages(images)
+ }
+ } else {
+ self?.invoke?.reject("Error loading image")
+ }
+ }
+ }
+
+ } else {
+ guard result.itemProvider.canLoadObject(ofClass: UIImage.self) else {
+ self.invoke?.reject("Error loading image")
+ return
+ }
+ // extract the image
+ result.itemProvider.loadObject(ofClass: UIImage.self) { [weak self] (reading, _) in
+ if let image = reading as? UIImage {
+ var asset: PHAsset?
+ if let assetId = result.assetIdentifier {
+ asset = PHAsset.fetchAssets(withLocalIdentifiers: [assetId], options: nil).firstObject
+ }
+ if var processedImage = self?.processedImage(from: image, with: asset?.imageData) {
+ processedImage.flags = .gallery
+ self?.returnProcessedImage(processedImage)
+ return
+ }
+ }
+ self?.invoke?.reject("Error loading image")
+ }
+ }
+ }
+}
+
+extension CameraPlugin {
+ fileprivate func returnImage(_ processedImage: ProcessedImage, isSaved: Bool) {
+ guard let jpeg = processedImage.generateJPEG(with: settings.jpegQuality) else {
+ self.invoke?.reject("Unable to convert image to jpeg")
+ return
+ }
+
+ if settings.resultType == CameraResultType.uri || multiple {
+ guard let fileURL = try? saveTemporaryImage(jpeg),
+ let webURL = manager.assetUrl(fromLocalURL: fileURL)
+ else {
+ invoke?.reject("Unable to get asset URL to file")
+ return
+ }
+ if self.multiple {
+ invoke?.resolve([
+ "photos": [
+ [
+ "data": fileURL.absoluteString,
+ "exif": processedImage.exifData,
+ "assetUrl": webURL.absoluteString,
+ "format": "jpeg",
+ ]
+ ]
+ ])
+ return
+ }
+ invoke?.resolve([
+ "data": fileURL.absoluteString,
+ "exif": processedImage.exifData,
+ "assetUrl": webURL.absoluteString,
+ "format": "jpeg",
+ "saved": isSaved,
+ ])
+ } else if settings.resultType == CameraResultType.base64 {
+ self.invoke?.resolve([
+ "data": jpeg.base64EncodedString(),
+ "exif": processedImage.exifData,
+ "format": "jpeg",
+ "saved": isSaved,
+ ])
+ } else if settings.resultType == CameraResultType.dataURL {
+ invoke?.resolve([
+ "data": "data:image/jpeg;base64," + jpeg.base64EncodedString(),
+ "exif": processedImage.exifData,
+ "format": "jpeg",
+ "saved": isSaved,
+ ])
+ }
+ }
+
+ fileprivate func returnImages(_ processedImages: [ProcessedImage]) {
+ var photos: [JsonObject] = []
+ for processedImage in processedImages {
+ guard let jpeg = processedImage.generateJPEG(with: settings.jpegQuality) else {
+ self.invoke?.reject("Unable to convert image to jpeg")
+ return
+ }
+
+ guard let fileURL = try? saveTemporaryImage(jpeg),
+ let webURL = manager.assetUrl(fromLocalURL: fileURL)
+ else {
+ invoke?.reject("Unable to get asset URL to file")
+ return
+ }
+
+ photos.append([
+ "path": fileURL.absoluteString,
+ "exif": processedImage.exifData,
+ "assetUrl": webURL.absoluteString,
+ "format": "jpeg",
+ ])
+ }
+ invoke?.resolve([
+ "photos": photos
+ ])
+ }
+
+ fileprivate func returnProcessedImage(_ processedImage: ProcessedImage) {
+ // conditionally save the image
+ if settings.saveToGallery
+ && (processedImage.flags.contains(.edited) == true
+ || processedImage.flags.contains(.gallery) == false)
+ {
+ _ = ImageSaver(image: processedImage.image) { error in
+ var isSaved = false
+ if error == nil {
+ isSaved = true
+ }
+ self.returnImage(processedImage, isSaved: isSaved)
+ }
+ } else {
+ self.returnImage(processedImage, isSaved: false)
+ }
+ }
+
+ fileprivate func showPrompt() {
+ // Build the action sheet
+ let alert = UIAlertController(
+ title: settings.userPromptText.title, message: nil,
+ preferredStyle: UIAlertController.Style.actionSheet)
+ alert.addAction(
+ UIAlertAction(
+ title: settings.userPromptText.photoAction, style: .default,
+ handler: { [weak self] (_: UIAlertAction) in
+ self?.showPhotos()
+ }))
+
+ alert.addAction(
+ UIAlertAction(
+ title: settings.userPromptText.cameraAction, style: .default,
+ handler: { [weak self] (_: UIAlertAction) in
+ self?.showCamera()
+ }))
+
+ alert.addAction(
+ UIAlertAction(
+ title: settings.userPromptText.cancelAction, style: .cancel,
+ handler: { [weak self] (_: UIAlertAction) in
+ self?.invoke?.reject("User cancelled photos app prompt")
+ }))
+ UIUtils.centerPopover(rootViewController: manager.viewController, popoverController: alert)
+ self.manager.viewController?.present(alert, animated: true, completion: nil)
+ }
+
+ fileprivate func showCamera() {
+ // check if we have a camera
+ if manager.isSimEnvironment
+ || !UIImagePickerController.isSourceTypeAvailable(UIImagePickerController.SourceType.camera)
+ {
+ Logger.error("[PLUGIN]", "Camera", "-", "Camera not available in simulator")
+ invoke?.reject("Camera not available while running in Simulator")
+ return
+ }
+ // check for permission
+ let authStatus = AVCaptureDevice.authorizationStatus(for: .video)
+ if authStatus == .restricted || authStatus == .denied {
+ invoke?.reject("User denied access to camera")
+ return
+ }
+ // we either already have permission or can prompt
+ AVCaptureDevice.requestAccess(for: .video) { [weak self] granted in
+ if granted {
+ DispatchQueue.main.async {
+ self?.presentCameraPicker()
+ }
+ } else {
+ self?.invoke?.reject("User denied access to camera")
+ }
+ }
+ }
+
+ fileprivate func showPhotos() {
+ // check for permission
+ let authStatus = PHPhotoLibrary.authorizationStatus()
+ if authStatus == .restricted || authStatus == .denied {
+ invoke?.reject("User denied access to photos")
+ return
+ }
+ // we either already have permission or can prompt
+ if authStatus == .authorized {
+ presentSystemAppropriateImagePicker()
+ } else {
+ PHPhotoLibrary.requestAuthorization({ [weak self] (status) in
+ if status == PHAuthorizationStatus.authorized {
+ DispatchQueue.main.async { [weak self] in
+ self?.presentSystemAppropriateImagePicker()
+ }
+ } else {
+ self?.invoke?.reject("User denied access to photos")
+ }
+ })
+ }
+ }
+
+ fileprivate func presentCameraPicker() {
+ let picker = UIImagePickerController()
+ picker.delegate = self
+ picker.allowsEditing = self.settings.allowEditing
+ // select the input
+ picker.sourceType = .camera
+ if settings.direction == .rear, UIImagePickerController.isCameraDeviceAvailable(.rear) {
+ picker.cameraDevice = .rear
+ } else if settings.direction == .front, UIImagePickerController.isCameraDeviceAvailable(.front)
+ {
+ picker.cameraDevice = .front
+ }
+ // present
+ picker.modalPresentationStyle = settings.presentationStyle
+ if settings.presentationStyle == .popover {
+ picker.popoverPresentationController?.delegate = self
+ UIUtils.centerPopover(rootViewController: manager.viewController, popoverController: picker)
+ }
+ manager.viewController?.present(picker, animated: true, completion: nil)
+ }
+
+ fileprivate func presentSystemAppropriateImagePicker() {
+ if #available(iOS 14, *) {
+ presentPhotoPicker()
+ } else {
+ presentImagePicker()
+ }
+ }
+
+ fileprivate func presentImagePicker() {
+ let picker = UIImagePickerController()
+ picker.delegate = self
+ picker.allowsEditing = self.settings.allowEditing
+ // select the input
+ picker.sourceType = .photoLibrary
+ // present
+ picker.modalPresentationStyle = settings.presentationStyle
+ if settings.presentationStyle == .popover {
+ picker.popoverPresentationController?.delegate = self
+ UIUtils.centerPopover(rootViewController: manager.viewController, popoverController: picker)
+ }
+ manager.viewController?.present(picker, animated: true, completion: nil)
+ }
+
+ @available(iOS 14, *)
+ fileprivate func presentPhotoPicker() {
+ var configuration = PHPickerConfiguration(photoLibrary: PHPhotoLibrary.shared())
+ configuration.selectionLimit = self.multiple ? (self.invoke?.getInt("limit") ?? 0) : 1
+ configuration.filter = .images
+ let picker = PHPickerViewController(configuration: configuration)
+ picker.delegate = self
+ // present
+ picker.modalPresentationStyle = settings.presentationStyle
+ if settings.presentationStyle == .popover {
+ picker.popoverPresentationController?.delegate = self
+ UIUtils.centerPopover(rootViewController: manager.viewController, popoverController: picker)
+ }
+ manager.viewController?.present(picker, animated: true, completion: nil)
+ }
+
+ fileprivate func saveTemporaryImage(_ data: Data) throws -> URL {
+ var url: URL
+ repeat {
+ imageCounter += 1
+ url = URL(fileURLWithPath: NSTemporaryDirectory()).appendingPathComponent(
+ "photo-\(imageCounter).jpg")
+ } while FileManager.default.fileExists(atPath: url.path)
+
+ try data.write(to: url, options: .atomic)
+ return url
+ }
+
+ fileprivate func processImage(from info: [UIImagePickerController.InfoKey: Any])
+ -> ProcessedImage?
+ {
+ var selectedImage: UIImage?
+ var flags: PhotoFlags = []
+ // get the image
+ if let edited = info[UIImagePickerController.InfoKey.editedImage] as? UIImage {
+ selectedImage = edited // use the edited version
+ flags = flags.union([.edited])
+ } else if let original = info[UIImagePickerController.InfoKey.originalImage] as? UIImage {
+ selectedImage = original // use the original version
+ }
+ guard let image = selectedImage else {
+ return nil
+ }
+ var metadata: [String: Any] = [:]
+ // get the image's metadata from the picker or from the photo album
+ if let photoMetadata = info[UIImagePickerController.InfoKey.mediaMetadata] as? [String: Any] {
+ metadata = photoMetadata
+ } else {
+ flags = flags.union([.gallery])
+ }
+ if let asset = info[UIImagePickerController.InfoKey.phAsset] as? PHAsset {
+ metadata = asset.imageData
+ }
+ // get the result
+ var result = processedImage(from: image, with: metadata)
+ result.flags = flags
+ return result
+ }
+
+ fileprivate func processedImage(from image: UIImage, with metadata: [String: Any]?)
+ -> ProcessedImage
+ {
+ var result = ProcessedImage(image: image, metadata: metadata ?? [:])
+ // resizing the image only makes sense if we have real values to which to constrain it
+ if settings.shouldResize, settings.width > 0 || settings.height > 0 {
+ result.image = result.image.reformat(
+ to: CGSize(width: settings.width, height: settings.height))
+ result.overwriteMetadataOrientation(to: 1)
+ } else if settings.shouldCorrectOrientation {
+ // resizing implicitly reformats the image so this is only needed if we aren't resizing
+ result.image = result.image.reformat()
+ result.overwriteMetadataOrientation(to: 1)
+ }
+ return result
+ }
+}
+
+@_cdecl("init_plugin_camera")
+func initPlugin() -> Plugin {
+ return CameraPlugin()
+}
diff --git a/plugins/camera/ios/Sources/CameraTypes.swift b/plugins/camera/ios/Sources/CameraTypes.swift
new file mode 100644
index 00000000..d3601def
--- /dev/null
+++ b/plugins/camera/ios/Sources/CameraTypes.swift
@@ -0,0 +1,146 @@
+// Copyright 2019-2023 Tauri Programme within The Commons Conservancy
+// SPDX-License-Identifier: Apache-2.0
+// SPDX-License-Identifier: MIT
+
+import UIKit
+
+// MARK: - Public
+
+public enum CameraSource: String {
+ case prompt = "PROMPT"
+ case camera = "CAMERA"
+ case photos = "PHOTOS"
+}
+
+public enum CameraDirection: String {
+ case rear = "REAR"
+ case front = "FRONT"
+}
+
+public enum CameraResultType: String {
+ case base64
+ case uri
+ case dataURL = "dataUrl"
+}
+
+struct CameraPromptText {
+ let title: String
+ let photoAction: String
+ let cameraAction: String
+ let cancelAction: String
+
+ init(title: String? = nil, photoAction: String? = nil, cameraAction: String? = nil, cancelAction: String? = nil) {
+ self.title = title ?? "Photo"
+ self.photoAction = photoAction ?? "From Photos"
+ self.cameraAction = cameraAction ?? "Take Picture"
+ self.cancelAction = cancelAction ?? "Cancel"
+ }
+}
+
+public struct CameraSettings {
+ var source: CameraSource = CameraSource.prompt
+ var direction: CameraDirection = CameraDirection.rear
+ var resultType = CameraResultType.base64
+ var userPromptText = CameraPromptText()
+ var jpegQuality: CGFloat = 1.0
+ var width: CGFloat = 0
+ var height: CGFloat = 0
+ var allowEditing = false
+ var shouldResize = false
+ var shouldCorrectOrientation = true
+ var saveToGallery = false
+ var presentationStyle = UIModalPresentationStyle.fullScreen
+}
+
+public struct CameraResult {
+ let image: UIImage?
+ let metadata: [AnyHashable: Any]
+}
+
+// MARK: - Internal
+
+internal enum CameraPermissionType: String, CaseIterable {
+ case camera
+ case photos
+}
+
+internal enum CameraPropertyListKeys: String, CaseIterable {
+ case photoLibraryAddUsage = "NSPhotoLibraryAddUsageDescription"
+ case photoLibraryUsage = "NSPhotoLibraryUsageDescription"
+ case cameraUsage = "NSCameraUsageDescription"
+
+ var link: String {
+ switch self {
+ case .photoLibraryAddUsage:
+ return "https://developer.apple.com/library/content/documentation/General/Reference/InfoPlistKeyReference/Articles/CocoaKeys.html#//apple_ref/doc/uid/TP40009251-SW73"
+ case .photoLibraryUsage:
+ return "https://developer.apple.com/library/content/documentation/General/Reference/InfoPlistKeyReference/Articles/CocoaKeys.html#//apple_ref/doc/uid/TP40009251-SW17"
+ case .cameraUsage:
+ return "https://developer.apple.com/library/content/documentation/General/Reference/InfoPlistKeyReference/Articles/CocoaKeys.html#//apple_ref/doc/uid/TP40009251-SW24"
+ }
+ }
+
+ var missingMessage: String {
+ return "You are missing \(self.rawValue) in your Info.plist file." +
+ " Camera will not function without it. Learn more: \(self.link)"
+ }
+}
+
+internal struct PhotoFlags: OptionSet {
+ let rawValue: Int
+
+ static let edited = PhotoFlags(rawValue: 1 << 0)
+ static let gallery = PhotoFlags(rawValue: 1 << 1)
+
+ static let all: PhotoFlags = [.edited, .gallery]
+}
+
+internal struct ProcessedImage {
+ var image: UIImage
+ var metadata: [String: Any]
+ var flags: PhotoFlags = []
+
+ var exifData: [String: Any] {
+ var exifData = metadata["{Exif}"] as? [String: Any]
+ exifData?["Orientation"] = metadata["Orientation"]
+ exifData?["GPS"] = metadata["{GPS}"]
+ return exifData ?? [:]
+ }
+
+ mutating func overwriteMetadataOrientation(to orientation: Int) {
+ replaceDictionaryOrientation(atNode: &metadata, to: orientation)
+ }
+
+ func replaceDictionaryOrientation(atNode node: inout [String: Any], to orientation: Int) {
+ for key in node.keys {
+ if key == "Orientation", (node[key] as? Int) != nil {
+ node[key] = orientation
+ } else if var child = node[key] as? [String: Any] {
+ replaceDictionaryOrientation(atNode: &child, to: orientation)
+ node[key] = child
+ }
+ }
+ }
+
+ func generateJPEG(with quality: CGFloat) -> Data? {
+ // convert the UIImage to a jpeg
+ guard let data = self.image.jpegData(compressionQuality: quality) else {
+ return nil
+ }
+ // define our jpeg data as an image source and get its type
+ guard let source = CGImageSourceCreateWithData(data as CFData, nil), let type = CGImageSourceGetType(source) else {
+ return data
+ }
+ // allocate an output buffer and create the destination to receive the new data
+ guard let output = NSMutableData(capacity: data.count), let destination = CGImageDestinationCreateWithData(output, type, 1, nil) else {
+ return data
+ }
+ // pipe the source into the destination while overwriting the metadata, this encodes the metadata information into the image
+ CGImageDestinationAddImageFromSource(destination, source, 0, self.metadata as CFDictionary)
+ // finish
+ guard CGImageDestinationFinalize(destination) else {
+ return data
+ }
+ return output as Data
+ }
+}
\ No newline at end of file
diff --git a/plugins/camera/ios/Sources/ImageSaver.swift b/plugins/camera/ios/Sources/ImageSaver.swift
new file mode 100644
index 00000000..7d967087
--- /dev/null
+++ b/plugins/camera/ios/Sources/ImageSaver.swift
@@ -0,0 +1,24 @@
+// Copyright 2019-2023 Tauri Programme within The Commons Conservancy
+// SPDX-License-Identifier: Apache-2.0
+// SPDX-License-Identifier: MIT
+
+import UIKit
+
+class ImageSaver: NSObject {
+
+ var onResult: ((Error?) -> Void) = {_ in }
+
+ init(image: UIImage, onResult:@escaping ((Error?) -> Void)) {
+ self.onResult = onResult
+ super.init()
+ UIImageWriteToSavedPhotosAlbum(image, self, #selector(saveResult), nil)
+ }
+
+ @objc func saveResult(_ image: UIImage, didFinishSavingWithError error: Error?, contextInfo: UnsafeRawPointer) {
+ if let error = error {
+ onResult(error)
+ } else {
+ onResult(nil)
+ }
+ }
+}
\ No newline at end of file
diff --git a/plugins/camera/ios/Tests/PluginTests/PluginTests.swift b/plugins/camera/ios/Tests/PluginTests/PluginTests.swift
new file mode 100644
index 00000000..651e1f08
--- /dev/null
+++ b/plugins/camera/ios/Tests/PluginTests/PluginTests.swift
@@ -0,0 +1,13 @@
+
+// Copyright 2019-2023 Tauri Programme within The Commons Conservancy
+// SPDX-License-Identifier: Apache-2.0
+// SPDX-License-Identifier: MIT
+
+import XCTest
+@testable import ExamplePlugin
+
+final class ExamplePluginTests: XCTestCase {
+ func testExample() throws {
+ let plugin = ExamplePlugin()
+ }
+}
diff --git a/plugins/camera/package.json b/plugins/camera/package.json
new file mode 100644
index 00000000..ceef3bf1
--- /dev/null
+++ b/plugins/camera/package.json
@@ -0,0 +1,33 @@
+{
+ "name": "@tauri-apps/plugin-camera",
+ "version": "1.0.0",
+ "description": "Ask the user take a photo with the camera or select an image from the gallery.",
+ "license": "MIT or APACHE-2.0",
+ "authors": [
+ "Tauri Programme within The Commons Conservancy"
+ ],
+ "type": "module",
+ "browser": "dist-js/index.min.js",
+ "module": "dist-js/index.mjs",
+ "types": "dist-js/index.d.ts",
+ "exports": {
+ "import": "./dist-js/index.mjs",
+ "types": "./dist-js/index.d.ts",
+ "browser": "./dist-js/index.min.js"
+ },
+ "scripts": {
+ "build": "rollup -c"
+ },
+ "files": [
+ "dist-js",
+ "!dist-js/**/*.map",
+ "README.md",
+ "LICENSE"
+ ],
+ "devDependencies": {
+ "tslib": "^2.4.1"
+ },
+ "dependencies": {
+ "@tauri-apps/api": "2.0.0-alpha.8"
+ }
+}
diff --git a/plugins/camera/rollup.config.mjs b/plugins/camera/rollup.config.mjs
new file mode 100644
index 00000000..99a3dd31
--- /dev/null
+++ b/plugins/camera/rollup.config.mjs
@@ -0,0 +1,11 @@
+import { readFileSync } from "fs";
+
+import { createConfig } from "../../shared/rollup.config.mjs";
+
+export default createConfig({
+ input: "guest-js/index.ts",
+ pkg: JSON.parse(
+ readFileSync(new URL("./package.json", import.meta.url), "utf8"),
+ ),
+ external: [/^@tauri-apps\/api/],
+});
diff --git a/plugins/camera/src/api-iife.js b/plugins/camera/src/api-iife.js
new file mode 100644
index 00000000..3bc7b724
--- /dev/null
+++ b/plugins/camera/src/api-iife.js
@@ -0,0 +1 @@
+if("__TAURI__"in window){var __TAURI_CAMERA__=function(e){"use strict";var t=Object.defineProperty,r=(e,t,r)=>{if(!t.has(e))throw TypeError("Cannot "+r)},n=(e,t,n)=>(r(e,t,"read from private field"),n?n.call(e):t.get(e)),i=(e,t,n,i)=>(r(e,t,"write to private field"),i?i.call(e,n):t.set(e,n),n);function a(e,t=!1){let r=window.crypto.getRandomValues(new Uint32Array(1))[0],n=`_${r}`;return Object.defineProperty(window,n,{value:r=>(t&&Reflect.deleteProperty(window,n),e?.(r)),writable:!1,configurable:!0}),r}((e,r)=>{for(var n in r)t(e,n,{get:r[n],enumerable:!0})})({},{Channel:()=>s,PluginListener:()=>d,addPluginListener:()=>u,convertFileSrc:()=>w,invoke:()=>h,transformCallback:()=>a});var o,s=class{constructor(){this.__TAURI_CHANNEL_MARKER__=!0,((e,t,r)=>{if(t.has(e))throw TypeError("Cannot add the same private member more than once");t instanceof WeakSet?t.add(e):t.set(e,r)})(this,o,(()=>{})),this.id=a((e=>{n(this,o).call(this,e)}))}set onmessage(e){i(this,o,e)}get onmessage(){return n(this,o)}toJSON(){return`__CHANNEL__:${this.id}`}};o=new WeakMap;var l,c,_,d=class{constructor(e,t,r){this.plugin=e,this.event=t,this.channelId=r}async unregister(){return h(`plugin:${this.plugin}|remove_listener`,{event:this.event,channelId:this.channelId})}};async function u(e,t,r){let n=new s;return n.onmessage=r,h(`plugin:${e}|register_listener`,{event:t,handler:n}).then((()=>new d(e,t,n.id)))}async function h(e,t={},r){return new Promise(((n,i)=>{let o=a((e=>{n(e),Reflect.deleteProperty(window,`_${s}`)}),!0),s=a((e=>{i(e),Reflect.deleteProperty(window,`_${o}`)}),!0);window.__TAURI_IPC__({cmd:e,callback:o,error:s,payload:t,options:r})}))}function w(e,t="asset"){return window.__TAURI__.convertFileSrc(e,t)}return e.Source=void 0,(l=e.Source||(e.Source={})).Prompt="PROMPT",l.Camera="CAMERA",l.Photos="PHOTOS",e.ResultType=void 0,(c=e.ResultType||(e.ResultType={})).Uri="uri",c.Base64="base64",c.DataUrl="dataUrl",e.CameraDirection=void 0,(_=e.CameraDirection||(e.CameraDirection={})).Rear="REAR",_.Front="FRONT",e.getPhoto=async function(e){return await h("plugin:camera|getPhoto",{...e})},e}({});Object.defineProperty(window.__TAURI__,"camera",{value:__TAURI_CAMERA__})}
diff --git a/plugins/camera/src/error.rs b/plugins/camera/src/error.rs
new file mode 100644
index 00000000..53aaca67
--- /dev/null
+++ b/plugins/camera/src/error.rs
@@ -0,0 +1,24 @@
+// Copyright 2019-2023 Tauri Programme within The Commons Conservancy
+// SPDX-License-Identifier: Apache-2.0
+// SPDX-License-Identifier: MIT
+
+use serde::{ser::Serializer, Serialize};
+
+pub type Result = std::result::Result;
+
+#[derive(Debug, thiserror::Error)]
+pub enum Error {
+ #[error(transparent)]
+ Io(#[from] std::io::Error),
+ #[error(transparent)]
+ PluginInvoke(#[from] tauri::plugin::mobile::PluginInvokeError),
+}
+
+impl Serialize for Error {
+ fn serialize(&self, serializer: S) -> std::result::Result
+ where
+ S: Serializer,
+ {
+ serializer.serialize_str(self.to_string().as_ref())
+ }
+}
diff --git a/plugins/camera/src/lib.rs b/plugins/camera/src/lib.rs
new file mode 100644
index 00000000..c4a5e0c7
--- /dev/null
+++ b/plugins/camera/src/lib.rs
@@ -0,0 +1,57 @@
+// Copyright 2019-2023 Tauri Programme within The Commons Conservancy
+// SPDX-License-Identifier: Apache-2.0
+// SPDX-License-Identifier: MIT
+
+#![cfg(mobile)]
+
+use tauri::{
+ plugin::{Builder, PluginHandle, TauriPlugin},
+ Manager, Runtime,
+};
+
+pub use models::*;
+mod error;
+pub mod models;
+pub use error::*;
+
+#[cfg(target_os = "android")]
+const PLUGIN_IDENTIFIER: &str = "app.tauri.camera";
+
+#[cfg(target_os = "ios")]
+tauri::ios_plugin_binding!(init_plugin_camera);
+
+/// A helper class to access the mobile camera APIs.
+pub struct Camera(PluginHandle);
+
+impl Camera {
+ pub fn get_photo(&self, options: ImageOptions) -> Result {
+ self.0
+ .run_mobile_plugin("getPhoto", options)
+ .map_err(Into::into)
+ }
+}
+
+/// Extensions to [`tauri::App`], [`tauri::AppHandle`] and [`tauri::Window`] to access the camera APIs.
+pub trait CameraExt {
+ fn camera(&self) -> &Camera;
+}
+
+impl> CameraExt for T {
+ fn camera(&self) -> &Camera {
+ self.state::>().inner()
+ }
+}
+
+/// Initializes the plugin.
+pub fn init() -> TauriPlugin {
+ Builder::new("camera")
+ .setup(|app, api| {
+ #[cfg(target_os = "android")]
+ let handle = api.register_android_plugin(PLUGIN_IDENTIFIER, "CameraPlugin")?;
+ #[cfg(target_os = "ios")]
+ let handle = api.register_ios_plugin(init_plugin_camera)?;
+ app.manage(Camera(handle));
+ Ok(())
+ })
+ .build()
+}
diff --git a/plugins/camera/src/models.rs b/plugins/camera/src/models.rs
new file mode 100644
index 00000000..6366dec3
--- /dev/null
+++ b/plugins/camera/src/models.rs
@@ -0,0 +1,38 @@
+// Copyright 2019-2023 Tauri Programme within The Commons Conservancy
+// SPDX-License-Identifier: Apache-2.0
+// SPDX-License-Identifier: MIT
+
+use serde::{Deserialize, Serialize};
+
+#[derive(Debug, Default, Serialize)]
+#[serde(rename_all = "camelCase")]
+pub struct ImageOptions {
+ pub quality: Option,
+ #[serde(default)]
+ pub allow_editing: bool,
+ pub result_type: Option,
+ #[serde(default)]
+ pub save_to_gallery: bool,
+ pub width: Option,
+ pub height: Option,
+ #[serde(default)]
+ pub correct_orientation: bool,
+ pub source: Option,
+ pub direction: Option,
+ pub presentation_style: Option,
+ pub prompt_label_header: Option,
+ pub prompt_label_cancel: Option,
+ pub prompt_label_photo: Option,
+ pub prompt_label_picture: Option,
+}
+
+#[derive(Debug, Clone, Default, Deserialize)]
+#[serde(rename_all = "camelCase")]
+pub struct Image {
+ pub data: String,
+ pub asset_url: Option,
+ pub format: String,
+ #[serde(default)]
+ pub saved: bool,
+ pub exif: serde_json::Value,
+}
diff --git a/plugins/camera/tsconfig.json b/plugins/camera/tsconfig.json
new file mode 100644
index 00000000..5098169a
--- /dev/null
+++ b/plugins/camera/tsconfig.json
@@ -0,0 +1,4 @@
+{
+ "extends": "../../tsconfig.base.json",
+ "include": ["guest-js/*.ts"]
+}
diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml
index cc5ad9e1..3e8ca574 100644
--- a/pnpm-lock.yaml
+++ b/pnpm-lock.yaml
@@ -72,6 +72,9 @@ importers:
'@tauri-apps/plugin-barcode-scanner':
specifier: 2.0.0-alpha.0
version: link:../../plugins/barcode-scanner
+ '@tauri-apps/plugin-camera':
+ specifier: 1.0.0
+ version: link:../../plugins/camera
'@tauri-apps/plugin-cli':
specifier: 2.0.0-alpha.1
version: link:../../plugins/cli
@@ -135,7 +138,7 @@ importers:
version: 3.59.1
unocss:
specifier: ^0.53.1
- version: 0.53.1(postcss@8.4.26)(vite@4.4.4)
+ version: 0.53.1(postcss@8.4.26)(rollup@3.26.3)(vite@4.4.4)
vite:
specifier: ^4.3.9
version: 4.4.4
@@ -180,6 +183,16 @@ importers:
specifier: ^2.5.0
version: 2.6.0
+ plugins/camera:
+ dependencies:
+ '@tauri-apps/api':
+ specifier: 2.0.0-alpha.8
+ version: 2.0.0-alpha.8
+ devDependencies:
+ tslib:
+ specifier: ^2.4.1
+ version: 2.6.0
+
plugins/cli:
dependencies:
'@tauri-apps/api':
@@ -592,7 +605,7 @@ packages:
peerDependencies:
mocha: ^10.0.0
dependencies:
- effection: 2.0.7
+ effection: 2.0.7(mocha@10.2.0)
mocha: 10.2.0
dev: true
@@ -1037,7 +1050,7 @@ packages:
engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0}
dependencies:
ajv: 6.12.6
- debug: 4.3.4
+ debug: 4.3.4(supports-color@8.1.1)
espree: 9.6.1
globals: 13.20.0
ignore: 5.2.4
@@ -1059,7 +1072,7 @@ packages:
engines: {node: '>=10.10.0'}
dependencies:
'@humanwhocodes/object-schema': 1.2.1
- debug: 4.3.4
+ debug: 4.3.4(supports-color@8.1.1)
minimatch: 3.1.2
transitivePeerDependencies:
- supports-color
@@ -1100,7 +1113,7 @@ packages:
'@antfu/install-pkg': 0.1.1
'@antfu/utils': 0.7.5
'@iconify/types': 2.0.0
- debug: 4.3.4
+ debug: 4.3.4(supports-color@8.1.1)
kolorist: 1.8.0
local-pkg: 0.4.3
transitivePeerDependencies:
@@ -1225,20 +1238,6 @@ packages:
typescript: 5.1.6
dev: true
- /@rollup/pluginutils@5.0.2:
- resolution: {integrity: sha512-pTd9rIsP92h+B6wWwFbW8RkZv4hiR/xKsqre4SIuAOaOEQRxi0lqLke9k2/7WegC85GgUs9pjmOjCUi3In4vwA==}
- engines: {node: '>=14.0.0'}
- peerDependencies:
- rollup: ^1.20.0||^2.0.0||^3.0.0
- peerDependenciesMeta:
- rollup:
- optional: true
- dependencies:
- '@types/estree': 1.0.1
- estree-walker: 2.0.2
- picomatch: 2.3.1
- dev: true
-
/@rollup/pluginutils@5.0.2(rollup@3.26.3):
resolution: {integrity: sha512-pTd9rIsP92h+B6wWwFbW8RkZv4hiR/xKsqre4SIuAOaOEQRxi0lqLke9k2/7WegC85GgUs9pjmOjCUi3In4vwA==}
engines: {node: '>=14.0.0'}
@@ -1298,7 +1297,7 @@ packages:
vite: ^4.0.0
dependencies:
'@sveltejs/vite-plugin-svelte': 2.4.1(svelte@3.59.1)(vite@4.4.4)
- debug: 4.3.4
+ debug: 4.3.4(supports-color@8.1.1)
svelte: 3.59.1
vite: 4.4.4
transitivePeerDependencies:
@@ -1314,7 +1313,7 @@ packages:
vite: ^4.0.0
dependencies:
'@sveltejs/vite-plugin-svelte': 2.4.2(svelte@4.0.5)(vite@4.4.4)
- debug: 4.3.4
+ debug: 4.3.4(supports-color@8.1.1)
svelte: 4.0.5
vite: 4.4.4
transitivePeerDependencies:
@@ -1329,7 +1328,7 @@ packages:
vite: ^4.0.0
dependencies:
'@sveltejs/vite-plugin-svelte-inspector': 1.0.3(@sveltejs/vite-plugin-svelte@2.4.1)(svelte@3.59.1)(vite@4.4.4)
- debug: 4.3.4
+ debug: 4.3.4(supports-color@8.1.1)
deepmerge: 4.3.1
kleur: 4.1.5
magic-string: 0.30.1
@@ -1349,7 +1348,7 @@ packages:
vite: ^4.0.0
dependencies:
'@sveltejs/vite-plugin-svelte-inspector': 1.0.3(@sveltejs/vite-plugin-svelte@2.4.2)(svelte@4.0.5)(vite@4.4.4)
- debug: 4.3.4
+ debug: 4.3.4(supports-color@8.1.1)
deepmerge: 4.3.1
kleur: 4.1.5
magic-string: 0.30.1
@@ -1537,7 +1536,7 @@ packages:
'@typescript-eslint/type-utils': 6.1.0(eslint@8.45.0)(typescript@5.1.6)
'@typescript-eslint/utils': 6.1.0(eslint@8.45.0)(typescript@5.1.6)
'@typescript-eslint/visitor-keys': 6.1.0
- debug: 4.3.4
+ debug: 4.3.4(supports-color@8.1.1)
eslint: 8.45.0
graphemer: 1.4.0
ignore: 5.2.4
@@ -1563,7 +1562,7 @@ packages:
'@typescript-eslint/scope-manager': 5.62.0
'@typescript-eslint/types': 5.62.0
'@typescript-eslint/typescript-estree': 5.62.0(typescript@5.1.6)
- debug: 4.3.4
+ debug: 4.3.4(supports-color@8.1.1)
eslint: 8.45.0
typescript: 5.1.6
transitivePeerDependencies:
@@ -1584,7 +1583,7 @@ packages:
'@typescript-eslint/types': 6.1.0
'@typescript-eslint/typescript-estree': 6.1.0(typescript@5.1.6)
'@typescript-eslint/visitor-keys': 6.1.0
- debug: 4.3.4
+ debug: 4.3.4(supports-color@8.1.1)
eslint: 8.45.0
typescript: 5.1.6
transitivePeerDependencies:
@@ -1619,7 +1618,7 @@ packages:
dependencies:
'@typescript-eslint/typescript-estree': 6.1.0(typescript@5.1.6)
'@typescript-eslint/utils': 6.1.0(eslint@8.45.0)(typescript@5.1.6)
- debug: 4.3.4
+ debug: 4.3.4(supports-color@8.1.1)
eslint: 8.45.0
ts-api-utils: 1.0.1(typescript@5.1.6)
typescript: 5.1.6
@@ -1648,7 +1647,7 @@ packages:
dependencies:
'@typescript-eslint/types': 5.62.0
'@typescript-eslint/visitor-keys': 5.62.0
- debug: 4.3.4
+ debug: 4.3.4(supports-color@8.1.1)
globby: 11.1.0
is-glob: 4.0.3
semver: 7.5.4
@@ -1669,7 +1668,7 @@ packages:
dependencies:
'@typescript-eslint/types': 6.1.0
'@typescript-eslint/visitor-keys': 6.1.0
- debug: 4.3.4
+ debug: 4.3.4(supports-color@8.1.1)
globby: 11.1.0
is-glob: 4.0.3
semver: 7.5.4
@@ -1714,24 +1713,24 @@ packages:
eslint-visitor-keys: 3.4.1
dev: true
- /@unocss/astro@0.53.1(vite@4.4.4):
+ /@unocss/astro@0.53.1(rollup@3.26.3)(vite@4.4.4):
resolution: {integrity: sha512-dvPH2buCL0qvWXFfQFUeB8kbbJsliN0ib2Am5/1r4XyOwCiCvfwc3UuQpsi0xJs/WO9QgIxLWxakxVj3DeAuAQ==}
dependencies:
'@unocss/core': 0.53.1
'@unocss/reset': 0.53.1
- '@unocss/vite': 0.53.1(vite@4.4.4)
+ '@unocss/vite': 0.53.1(rollup@3.26.3)(vite@4.4.4)
transitivePeerDependencies:
- rollup
- vite
dev: true
- /@unocss/cli@0.53.1:
+ /@unocss/cli@0.53.1(rollup@3.26.3):
resolution: {integrity: sha512-K2r8eBtwv1oQ6KcDLb3KyIDaApVle3zbckZmd7W402/IRIJSKScLjxWHtEJpnYEyuxD5MlQpfRZLZgmWWVMOsg==}
engines: {node: '>=14'}
hasBin: true
dependencies:
'@ampproject/remapping': 2.2.1
- '@rollup/pluginutils': 5.0.2
+ '@rollup/pluginutils': 5.0.2(rollup@3.26.3)
'@unocss/config': 0.53.1
'@unocss/core': 0.53.1
'@unocss/preset-uno': 0.53.1
@@ -1887,13 +1886,13 @@ packages:
'@unocss/core': 0.53.1
dev: true
- /@unocss/vite@0.53.1(vite@4.4.4):
+ /@unocss/vite@0.53.1(rollup@3.26.3)(vite@4.4.4):
resolution: {integrity: sha512-/N/rjiFyj1ejK1ZQIv9N/NMsNE6i2/V8ISwYhbGxLpc3Sca4jeVjZPsx5cg5DN9Ddas2BRH3YhLhdh8rPUPzxQ==}
peerDependencies:
vite: ^2.9.0 || ^3.0.0-0 || ^4.0.0
dependencies:
'@ampproject/remapping': 2.2.1
- '@rollup/pluginutils': 5.0.2
+ '@rollup/pluginutils': 5.0.2(rollup@3.26.3)
'@unocss/config': 0.53.1
'@unocss/core': 0.53.1
'@unocss/inspector': 0.53.1
@@ -2359,18 +2358,6 @@ packages:
ms: 2.1.3
dev: true
- /debug@4.3.4:
- resolution: {integrity: sha512-PRWFHuSU3eDtQJPvnNY7Jcket1j0t5OuOsFzPPzsekD52Zl8qUfFIPEiswXqIvHWGVHOgX+7G/vCNNhehwxfkQ==}
- engines: {node: '>=6.0'}
- peerDependencies:
- supports-color: '*'
- peerDependenciesMeta:
- supports-color:
- optional: true
- dependencies:
- ms: 2.1.2
- dev: true
-
/debug@4.3.4(supports-color@8.1.1):
resolution: {integrity: sha512-PRWFHuSU3eDtQJPvnNY7Jcket1j0t5OuOsFzPPzsekD52Zl8qUfFIPEiswXqIvHWGVHOgX+7G/vCNNhehwxfkQ==}
engines: {node: '>=6.0'}
@@ -2478,18 +2465,6 @@ packages:
resolution: {integrity: sha512-jtD6YG370ZCIi/9GTaJKQxWTZD045+4R4hTk/x1UyoqadyJ9x9CgSi1RlVDQF8U2sxLLSnFkCaMihqljHIWgMg==}
dev: true
- /effection@2.0.7:
- resolution: {integrity: sha512-I9ndFvtByvHbvOHwMp1NM7vlLDT0RBOu1YlIfBece46VASSot0oPnAfoGdc1YKoQShQLjigvHZ6OqZYUAxUcXg==}
- dependencies:
- '@effection/channel': 2.0.5
- '@effection/core': 2.2.2
- '@effection/events': 2.0.5
- '@effection/fetch': 2.0.6(mocha@10.2.0)
- '@effection/main': 2.1.2
- '@effection/stream': 2.0.5
- '@effection/subscription': 2.0.5
- dev: true
-
/effection@2.0.7(mocha@10.2.0):
resolution: {integrity: sha512-I9ndFvtByvHbvOHwMp1NM7vlLDT0RBOu1YlIfBece46VASSot0oPnAfoGdc1YKoQShQLjigvHZ6OqZYUAxUcXg==}
dependencies:
@@ -2846,7 +2821,7 @@ packages:
ajv: 6.12.6
chalk: 4.1.2
cross-spawn: 7.0.3
- debug: 4.3.4
+ debug: 4.3.4(supports-color@8.1.1)
doctrine: 3.0.0
escape-string-regexp: 4.0.0
eslint-scope: 7.2.1
@@ -3750,7 +3725,7 @@ packages:
/micromark@2.11.4:
resolution: {integrity: sha512-+WoovN/ppKolQOFIAajxi7Lu9kInbPxFuTBVEavFcL8eAfVstoc5MocPmqBeAdBOJV00uaVjegzH4+MA0DN/uA==}
dependencies:
- debug: 4.3.4
+ debug: 4.3.4(supports-color@8.1.1)
parse-entities: 2.0.0
transitivePeerDependencies:
- supports-color
@@ -4866,7 +4841,7 @@ packages:
'@types/unist': 2.0.7
dev: true
- /unocss@0.53.1(postcss@8.4.26)(vite@4.4.4):
+ /unocss@0.53.1(postcss@8.4.26)(rollup@3.26.3)(vite@4.4.4):
resolution: {integrity: sha512-0lRblA8hX7VUu5dywbcStzm590Iz5ahSJGsMNKNH3+u9C7AfJcKT8epxjkIkJWQBNJLD5vsao4SuuhLWB7eMQQ==}
engines: {node: '>=14'}
peerDependencies:
@@ -4875,8 +4850,8 @@ packages:
'@unocss/webpack':
optional: true
dependencies:
- '@unocss/astro': 0.53.1(vite@4.4.4)
- '@unocss/cli': 0.53.1
+ '@unocss/astro': 0.53.1(rollup@3.26.3)(vite@4.4.4)
+ '@unocss/cli': 0.53.1(rollup@3.26.3)
'@unocss/core': 0.53.1
'@unocss/extractor-arbitrary-variants': 0.53.1
'@unocss/postcss': 0.53.1(postcss@8.4.26)
@@ -4894,7 +4869,7 @@ packages:
'@unocss/transformer-compile-class': 0.53.1
'@unocss/transformer-directives': 0.53.1
'@unocss/transformer-variant-group': 0.53.1
- '@unocss/vite': 0.53.1(vite@4.4.4)
+ '@unocss/vite': 0.53.1(rollup@3.26.3)(vite@4.4.4)
transitivePeerDependencies:
- postcss
- rollup