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} + Selected + {/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