diff --git a/camera/.eslintignore b/camera/.eslintignore new file mode 100644 index 000000000..9d0b71a3c --- /dev/null +++ b/camera/.eslintignore @@ -0,0 +1,2 @@ +build +dist diff --git a/camera/.gitignore b/camera/.gitignore new file mode 100644 index 000000000..70ccbf713 --- /dev/null +++ b/camera/.gitignore @@ -0,0 +1,61 @@ +# node files +dist +node_modules + +# iOS files +Pods +Podfile.lock +Build +xcuserdata + +# macOS files +.DS_Store + + + +# Based on Android gitignore template: https://github.com/github/gitignore/blob/HEAD/Android.gitignore + +# Built application files +*.apk +*.ap_ + +# Files for the ART/Dalvik VM +*.dex + +# Java class files +*.class + +# Generated files +bin +gen +out + +# Gradle files +.gradle +build + +# Local configuration file (sdk path, etc) +local.properties + +# Proguard folder generated by Eclipse +proguard + +# Log Files +*.log + +# Android Studio Navigation editor temp files +.navigation + +# Android Studio captures folder +captures + +# IntelliJ +*.iml +.idea + +# Keystore files +# Uncomment the following line if you do not want to check your keystore files in. +#*.jks + +# External native build folder generated in Android Studio 2.2 and later +.externalNativeBuild diff --git a/camera/.prettierignore b/camera/.prettierignore new file mode 100644 index 000000000..9d0b71a3c --- /dev/null +++ b/camera/.prettierignore @@ -0,0 +1,2 @@ +build +dist diff --git a/camera/CapacitorCamera.podspec b/camera/CapacitorCamera.podspec new file mode 100644 index 000000000..0d686c8b5 --- /dev/null +++ b/camera/CapacitorCamera.podspec @@ -0,0 +1,17 @@ +require 'json' + +package = JSON.parse(File.read(File.join(__dir__, 'package.json'))) + +Pod::Spec.new do |s| + s.name = 'CapacitorCamera' + s.version = package['version'] + s.summary = package['description'] + s.license = package['license'] + s.homepage = package['repository']['url'] + s.author = package['author'] + s.source = { :git => package['repository']['url'], :tag => s.version.to_s } + s.source_files = 'ios/Plugin/**/*.{swift,h,m,c,cc,mm,cpp}' + s.ios.deployment_target = '11.0' + s.dependency 'Capacitor' + s.swift_version = '5.1' +end diff --git a/camera/LICENSE b/camera/LICENSE new file mode 100644 index 000000000..e73c9ca0d --- /dev/null +++ b/camera/LICENSE @@ -0,0 +1,23 @@ +Copyright 2020-present Ionic +https://ionic.io + +MIT License + +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/camera/README.md b/camera/README.md new file mode 100644 index 000000000..739db9f5f --- /dev/null +++ b/camera/README.md @@ -0,0 +1,165 @@ +# @capacitor/camera + +The Camera API provides the ability to take a photo with the camera or choose an existing one from the photo album. + +## Install + +```bash +npm install @capacitor/camera +npx cap sync +``` + +## API + + + +* [`getPhoto(...)`](#getphoto) +* [`checkPermissions()`](#checkpermissions) +* [`requestPermissions(...)`](#requestpermissions) +* [Interfaces](#interfaces) +* [Type Aliases](#type-aliases) + + + + + + +### getPhoto(...) + +```typescript +getPhoto(options: CameraOptions) => Promise +``` + +Prompt the user to pick a photo from an album, or take a new photo +with the camera. + +| Param | Type | +| ------------- | ------------------------------------------------------- | +| **`options`** | CameraOptions | + +**Returns:** Promise<CameraPhoto> + +**Since:** 1.0.0 + +-------------------- + + +### checkPermissions() + +```typescript +checkPermissions() => Promise +``` + +Check camera and photo album permissions + +**Returns:** Promise<CameraPermissionStatus> + +**Since:** 1.0.0 + +-------------------- + + +### requestPermissions(...) + +```typescript +requestPermissions(permissions?: CameraPluginPermissions | undefined) => Promise +``` + +Request camera and photo album permissions + +| Param | Type | +| ----------------- | --------------------------------------------------------------------------- | +| **`permissions`** | CameraPluginPermissions | + +**Returns:** Promise<CameraPermissionStatus> + +**Since:** 1.0.0 + +-------------------- + + +### Interfaces + + +#### CameraPhoto + +| Prop | Type | Description | Since | +| ------------------ | ------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ----- | +| **`base64String`** | string | The base64 encoded string representation of the image, if using CameraResultType.Base64. | 1.0.0 | +| **`dataUrl`** | string | The url starting with 'data:image/jpeg;base64,' and the base64 encoded string representation of the image, if using CameraResultType.DataUrl. | 1.0.0 | +| **`path`** | string | If using CameraResultType.Uri, the path will contain a full, platform-specific file URL that can be read later using the Filsystem API. | 1.0.0 | +| **`webPath`** | string | webPath returns a path that can be used to set the src attribute of an image for efficient loading and rendering. | 1.0.0 | +| **`exif`** | any | Exif data, if any, retrieved from the image | 1.0.0 | +| **`format`** | string | The format of the image, ex: jpeg, png, gif. iOS and Android only support jpeg. Web supports jpeg and png. gif is only supported if using file input. | 1.0.0 | + + +#### CameraOptions + +| Prop | Type | Description | Default | Since | +| ------------------------- | ------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ----------------------------------- | ----- | +| **`quality`** | number | The quality of image to return as JPEG, from 0-100 | | 1.0.0 | +| **`allowEditing`** | boolean | Whether to allow the user to crop or make small edits (platform specific) | | 1.0.0 | +| **`resultType`** | CameraResultType | How the data should be returned. Currently, only 'Base64', 'DataUrl' or 'Uri' is supported | | 1.0.0 | +| **`saveToGallery`** | boolean | Whether to save the photo to the gallery. If the photo was picked from the gallery, it will only be saved if edited. | : false | 1.0.0 | +| **`width`** | number | The width of the saved image | | 1.0.0 | +| **`height`** | number | The height of the saved image | | 1.0.0 | +| **`preserveAspectRatio`** | boolean | Whether to preserve the aspect ratio of the image. If this flag is true, the width and height will be used as max values and the aspect ratio will be preserved. This is only relevant when both a width and height are passed. When only width or height is provided the aspect ratio is always preserved (and this option is a no-op). A future major version will change this behavior to be default, and may also remove this option altogether. | : false | 1.0.0 | +| **`correctOrientation`** | boolean | Whether to automatically rotate the image "up" to correct for orientation in portrait mode | : true | 1.0.0 | +| **`source`** | CameraSource | The source to get the photo from. By default this prompts the user to select either the photo album or take a photo. | : CameraSource.prompt | 1.0.0 | +| **`direction`** | CameraDirection | iOS and Web only: The camera direction. | : CameraDirection.rear | 1.0.0 | +| **`presentationStyle`** | 'fullscreen' \| 'popover' | iOS only: The presentation style of the Camera. | : 'fullscreen' | 1.0.0 | +| **`webUseInput`** | boolean | Web only: Whether to use the PWA Element experience or file input. The default is to use PWA Elements if installed and fall back to file input. To always use file input, set this to `true`. Learn more about PWA Elements: https://capacitorjs.com/docs/pwa-elements | | 1.0.0 | +| **`promptLabelHeader`** | string | Text value to use when displaying the prompt. iOS only: The title of the action sheet. | : 'Photo' | 1.0.0 | +| **`promptLabelCancel`** | string | Text value to use when displaying the prompt. iOS only: The label of the 'cancel' button. | : 'Cancel' | 1.0.0 | +| **`promptLabelPhoto`** | string | Text value to use when displaying the prompt. The label of the button to select a saved image. | : 'From Photos' | 1.0.0 | +| **`promptLabelPicture`** | string | Text value to use when displaying the prompt. The label of the button to open the camera. | : 'Take Picture' | 1.0.0 | + + +#### CameraPermissionStatus + +| Prop | Type | +| ------------ | ----------------------------------------------------------------------- | +| **`camera`** | CameraPermissionState | +| **`photos`** | CameraPermissionState | + + +#### CameraPluginPermissions + +| Prop | Type | +| ----------------- | ----------------------------------- | +| **`permissions`** | CameraPermissionType[] | + + +### Type Aliases + + +#### CameraResultType + +'uri' | 'base64' | 'dataUrl' + + +#### CameraSource + +'prompt' | 'camera' | 'photos' + + +#### CameraDirection + +'rear' | 'front' + + +#### CameraPermissionState + +PermissionState | 'limited' + + +#### PermissionState + +'prompt' | 'prompt-with-rationale' | 'granted' | 'denied' + + +#### CameraPermissionType + +'camera' | 'photos' + + diff --git a/camera/android/.gitignore b/camera/android/.gitignore new file mode 100644 index 000000000..796b96d1c --- /dev/null +++ b/camera/android/.gitignore @@ -0,0 +1 @@ +/build diff --git a/camera/android/build.gradle b/camera/android/build.gradle new file mode 100644 index 000000000..825e35bfa --- /dev/null +++ b/camera/android/build.gradle @@ -0,0 +1,62 @@ +ext { + junitVersion = project.hasProperty('junitVersion') ? rootProject.ext.junitVersion : '4.12' + androidxAppCompatVersion = project.hasProperty('androidxAppCompatVersion') ? rootProject.ext.androidxAppCompatVersion : '1.2.0' + androidxEspressoCoreVersion = project.hasProperty('androidxEspressoCoreVersion') ? rootProject.ext.androidxEspressoCoreVersion : '3.2.0' + androidxExifInterfaceVersion = project.hasProperty('androidxExifInterfaceVersion') ? rootProject.ext.androidxExifInterfaceVersion : '1.2.0' + androidxJunitVersion = project.hasProperty('androidxJunitVersion') ? rootProject.ext.androidxJunitVersion : '1.1.1' + androidxMaterialVersion = project.hasProperty('androidxMaterialVersion') ? rootProject.ext.androidxMaterialVersion : '1.1.0-rc02' +} + +buildscript { + repositories { + google() + jcenter() + } + dependencies { + classpath 'com.android.tools.build:gradle:4.1.1' + } +} + +apply plugin: 'com.android.library' + +android { + compileSdkVersion project.hasProperty('compileSdkVersion') ? rootProject.ext.compileSdkVersion : 29 + defaultConfig { + minSdkVersion project.hasProperty('minSdkVersion') ? rootProject.ext.minSdkVersion : 21 + targetSdkVersion project.hasProperty('targetSdkVersion') ? rootProject.ext.targetSdkVersion : 29 + versionCode 1 + versionName "1.0" + testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner" + } + buildTypes { + release { + minifyEnabled false + proguardFiles getDefaultProguardFile('proguard-android.txt'), 'proguard-rules.pro' + } + } + lintOptions { + abortOnError false + } + compileOptions { + sourceCompatibility JavaVersion.VERSION_1_8 + targetCompatibility JavaVersion.VERSION_1_8 + } +} + +repositories { + google() + jcenter() + mavenCentral() +} + + +dependencies { + implementation fileTree(dir: 'libs', include: ['*.jar']) + implementation project(':capacitor-android') + implementation "androidx.exifinterface:exifinterface:$androidxExifInterfaceVersion" + implementation "androidx.appcompat:appcompat:$androidxAppCompatVersion" + implementation "com.google.android.material:material:$androidxMaterialVersion" + testImplementation "junit:junit:$junitVersion" + androidTestImplementation "androidx.test.ext:junit:$androidxJunitVersion" + androidTestImplementation "androidx.test.espresso:espresso-core:$androidxEspressoCoreVersion" +} diff --git a/camera/android/gradle.properties b/camera/android/gradle.properties new file mode 100644 index 000000000..0566c221d --- /dev/null +++ b/camera/android/gradle.properties @@ -0,0 +1,24 @@ +# Project-wide Gradle settings. + +# IDE (e.g. Android Studio) users: +# Gradle settings configured through the IDE *will override* +# any settings specified in this file. + +# For more details on how to configure your build environment visit +# http://www.gradle.org/docs/current/userguide/build_environment.html + +# Specifies the JVM arguments used for the daemon process. +# The setting is particularly useful for tweaking memory settings. +org.gradle.jvmargs=-Xmx1536m + +# When configured, Gradle will run in incubating parallel mode. +# This option should only be used with decoupled projects. More details, visit +# http://www.gradle.org/docs/current/userguide/multi_project_builds.html#sec:decoupled_projects +# org.gradle.parallel=true + +# AndroidX package structure to make it clearer which packages are bundled with the +# Android operating system, and which are packaged with your app's APK +# https://developer.android.com/topic/libraries/support-library/androidx-rn +android.useAndroidX=true +# Automatically convert third-party libraries to use AndroidX +android.enableJetifier=true diff --git a/camera/android/gradle/wrapper/gradle-wrapper.jar b/camera/android/gradle/wrapper/gradle-wrapper.jar new file mode 100644 index 000000000..62d4c0535 Binary files /dev/null and b/camera/android/gradle/wrapper/gradle-wrapper.jar differ diff --git a/camera/android/gradle/wrapper/gradle-wrapper.properties b/camera/android/gradle/wrapper/gradle-wrapper.properties new file mode 100644 index 000000000..186b71557 --- /dev/null +++ b/camera/android/gradle/wrapper/gradle-wrapper.properties @@ -0,0 +1,5 @@ +distributionBase=GRADLE_USER_HOME +distributionPath=wrapper/dists +distributionUrl=https\://services.gradle.org/distributions/gradle-6.5-all.zip +zipStoreBase=GRADLE_USER_HOME +zipStorePath=wrapper/dists diff --git a/camera/android/gradlew b/camera/android/gradlew new file mode 100755 index 000000000..fbd7c5158 --- /dev/null +++ b/camera/android/gradlew @@ -0,0 +1,185 @@ +#!/usr/bin/env sh + +# +# Copyright 2015 the original author or authors. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# https://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# + +############################################################################## +## +## Gradle start up script for UN*X +## +############################################################################## + +# Attempt to set APP_HOME +# Resolve links: $0 may be a link +PRG="$0" +# Need this for relative symlinks. +while [ -h "$PRG" ] ; do + ls=`ls -ld "$PRG"` + link=`expr "$ls" : '.*-> \(.*\)$'` + if expr "$link" : '/.*' > /dev/null; then + PRG="$link" + else + PRG=`dirname "$PRG"`"/$link" + fi +done +SAVED="`pwd`" +cd "`dirname \"$PRG\"`/" >/dev/null +APP_HOME="`pwd -P`" +cd "$SAVED" >/dev/null + +APP_NAME="Gradle" +APP_BASE_NAME=`basename "$0"` + +# Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. +DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"' + +# Use the maximum available, or set MAX_FD != -1 to use that value. +MAX_FD="maximum" + +warn () { + echo "$*" +} + +die () { + echo + echo "$*" + echo + exit 1 +} + +# OS specific support (must be 'true' or 'false'). +cygwin=false +msys=false +darwin=false +nonstop=false +case "`uname`" in + CYGWIN* ) + cygwin=true + ;; + Darwin* ) + darwin=true + ;; + MINGW* ) + msys=true + ;; + NONSTOP* ) + nonstop=true + ;; +esac + +CLASSPATH=$APP_HOME/gradle/wrapper/gradle-wrapper.jar + + +# Determine the Java command to use to start the JVM. +if [ -n "$JAVA_HOME" ] ; then + if [ -x "$JAVA_HOME/jre/sh/java" ] ; then + # IBM's JDK on AIX uses strange locations for the executables + JAVACMD="$JAVA_HOME/jre/sh/java" + else + JAVACMD="$JAVA_HOME/bin/java" + fi + if [ ! -x "$JAVACMD" ] ; then + die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME + +Please set the JAVA_HOME variable in your environment to match the +location of your Java installation." + fi +else + JAVACMD="java" + which java >/dev/null 2>&1 || die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. + +Please set the JAVA_HOME variable in your environment to match the +location of your Java installation." +fi + +# Increase the maximum file descriptors if we can. +if [ "$cygwin" = "false" -a "$darwin" = "false" -a "$nonstop" = "false" ] ; then + MAX_FD_LIMIT=`ulimit -H -n` + if [ $? -eq 0 ] ; then + if [ "$MAX_FD" = "maximum" -o "$MAX_FD" = "max" ] ; then + MAX_FD="$MAX_FD_LIMIT" + fi + ulimit -n $MAX_FD + if [ $? -ne 0 ] ; then + warn "Could not set maximum file descriptor limit: $MAX_FD" + fi + else + warn "Could not query maximum file descriptor limit: $MAX_FD_LIMIT" + fi +fi + +# For Darwin, add options to specify how the application appears in the dock +if $darwin; then + GRADLE_OPTS="$GRADLE_OPTS \"-Xdock:name=$APP_NAME\" \"-Xdock:icon=$APP_HOME/media/gradle.icns\"" +fi + +# For Cygwin or MSYS, switch paths to Windows format before running java +if [ "$cygwin" = "true" -o "$msys" = "true" ] ; then + APP_HOME=`cygpath --path --mixed "$APP_HOME"` + CLASSPATH=`cygpath --path --mixed "$CLASSPATH"` + + JAVACMD=`cygpath --unix "$JAVACMD"` + + # We build the pattern for arguments to be converted via cygpath + ROOTDIRSRAW=`find -L / -maxdepth 1 -mindepth 1 -type d 2>/dev/null` + SEP="" + for dir in $ROOTDIRSRAW ; do + ROOTDIRS="$ROOTDIRS$SEP$dir" + SEP="|" + done + OURCYGPATTERN="(^($ROOTDIRS))" + # Add a user-defined pattern to the cygpath arguments + if [ "$GRADLE_CYGPATTERN" != "" ] ; then + OURCYGPATTERN="$OURCYGPATTERN|($GRADLE_CYGPATTERN)" + fi + # Now convert the arguments - kludge to limit ourselves to /bin/sh + i=0 + for arg in "$@" ; do + CHECK=`echo "$arg"|egrep -c "$OURCYGPATTERN" -` + CHECK2=`echo "$arg"|egrep -c "^-"` ### Determine if an option + + if [ $CHECK -ne 0 ] && [ $CHECK2 -eq 0 ] ; then ### Added a condition + eval `echo args$i`=`cygpath --path --ignore --mixed "$arg"` + else + eval `echo args$i`="\"$arg\"" + fi + i=`expr $i + 1` + done + case $i in + 0) set -- ;; + 1) set -- "$args0" ;; + 2) set -- "$args0" "$args1" ;; + 3) set -- "$args0" "$args1" "$args2" ;; + 4) set -- "$args0" "$args1" "$args2" "$args3" ;; + 5) set -- "$args0" "$args1" "$args2" "$args3" "$args4" ;; + 6) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" ;; + 7) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" ;; + 8) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" "$args7" ;; + 9) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" "$args7" "$args8" ;; + esac +fi + +# Escape application args +save () { + for i do printf %s\\n "$i" | sed "s/'/'\\\\''/g;1s/^/'/;\$s/\$/' \\\\/" ; done + echo " " +} +APP_ARGS=`save "$@"` + +# Collect all arguments for the java command, following the shell quoting and substitution rules +eval set -- $DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS "\"-Dorg.gradle.appname=$APP_BASE_NAME\"" -classpath "\"$CLASSPATH\"" org.gradle.wrapper.GradleWrapperMain "$APP_ARGS" + +exec "$JAVACMD" "$@" diff --git a/camera/android/gradlew.bat b/camera/android/gradlew.bat new file mode 100644 index 000000000..a9f778a7a --- /dev/null +++ b/camera/android/gradlew.bat @@ -0,0 +1,104 @@ +@rem +@rem Copyright 2015 the original author or authors. +@rem +@rem Licensed under the Apache License, Version 2.0 (the "License"); +@rem you may not use this file except in compliance with the License. +@rem You may obtain a copy of the License at +@rem +@rem https://www.apache.org/licenses/LICENSE-2.0 +@rem +@rem Unless required by applicable law or agreed to in writing, software +@rem distributed under the License is distributed on an "AS IS" BASIS, +@rem WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +@rem See the License for the specific language governing permissions and +@rem limitations under the License. +@rem + +@if "%DEBUG%" == "" @echo off +@rem ########################################################################## +@rem +@rem Gradle startup script for Windows +@rem +@rem ########################################################################## + +@rem Set local scope for the variables with windows NT shell +if "%OS%"=="Windows_NT" setlocal + +set DIRNAME=%~dp0 +if "%DIRNAME%" == "" set DIRNAME=. +set APP_BASE_NAME=%~n0 +set APP_HOME=%DIRNAME% + +@rem Resolve any "." and ".." in APP_HOME to make it shorter. +for %%i in ("%APP_HOME%") do set APP_HOME=%%~fi + +@rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. +set DEFAULT_JVM_OPTS="-Xmx64m" "-Xms64m" + +@rem Find java.exe +if defined JAVA_HOME goto findJavaFromJavaHome + +set JAVA_EXE=java.exe +%JAVA_EXE% -version >NUL 2>&1 +if "%ERRORLEVEL%" == "0" goto init + +echo. +echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. +echo. +echo Please set the JAVA_HOME variable in your environment to match the +echo location of your Java installation. + +goto fail + +:findJavaFromJavaHome +set JAVA_HOME=%JAVA_HOME:"=% +set JAVA_EXE=%JAVA_HOME%/bin/java.exe + +if exist "%JAVA_EXE%" goto init + +echo. +echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% +echo. +echo Please set the JAVA_HOME variable in your environment to match the +echo location of your Java installation. + +goto fail + +:init +@rem Get command-line arguments, handling Windows variants + +if not "%OS%" == "Windows_NT" goto win9xME_args + +:win9xME_args +@rem Slurp the command line arguments. +set CMD_LINE_ARGS= +set _SKIP=2 + +:win9xME_args_slurp +if "x%~1" == "x" goto execute + +set CMD_LINE_ARGS=%* + +:execute +@rem Setup the command line + +set CLASSPATH=%APP_HOME%\gradle\wrapper\gradle-wrapper.jar + + +@rem Execute Gradle +"%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" org.gradle.wrapper.GradleWrapperMain %CMD_LINE_ARGS% + +:end +@rem End local scope for the variables with windows NT shell +if "%ERRORLEVEL%"=="0" goto mainEnd + +:fail +rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of +rem the _cmd.exe /c_ return code! +if not "" == "%GRADLE_EXIT_CONSOLE%" exit 1 +exit /b 1 + +:mainEnd +if "%OS%"=="Windows_NT" endlocal + +:omega diff --git a/camera/android/proguard-rules.pro b/camera/android/proguard-rules.pro new file mode 100644 index 000000000..f1b424510 --- /dev/null +++ b/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 diff --git a/camera/android/settings.gradle b/camera/android/settings.gradle new file mode 100644 index 000000000..1e5b8431f --- /dev/null +++ b/camera/android/settings.gradle @@ -0,0 +1,2 @@ +include ':capacitor-android' +project(':capacitor-android').projectDir = new File('../node_modules/@capacitor/android/capacitor') \ No newline at end of file diff --git a/camera/android/src/androidTest/java/com/getcapacitor/android/ExampleInstrumentedTest.java b/camera/android/src/androidTest/java/com/getcapacitor/android/ExampleInstrumentedTest.java new file mode 100644 index 000000000..58020e16c --- /dev/null +++ b/camera/android/src/androidTest/java/com/getcapacitor/android/ExampleInstrumentedTest.java @@ -0,0 +1,26 @@ +package com.getcapacitor.android; + +import static org.junit.Assert.*; + +import android.content.Context; +import androidx.test.ext.junit.runners.AndroidJUnit4; +import androidx.test.platform.app.InstrumentationRegistry; +import org.junit.Test; +import org.junit.runner.RunWith; + +/** + * Instrumented test, which will execute on an Android device. + * + * @see Testing documentation + */ +@RunWith(AndroidJUnit4.class) +public class ExampleInstrumentedTest { + + @Test + public void useAppContext() throws Exception { + // Context of the app under test. + Context appContext = InstrumentationRegistry.getInstrumentation().getTargetContext(); + + assertEquals("com.getcapacitor.android", appContext.getPackageName()); + } +} diff --git a/camera/android/src/main/AndroidManifest.xml b/camera/android/src/main/AndroidManifest.xml new file mode 100644 index 000000000..d71b42f37 --- /dev/null +++ b/camera/android/src/main/AndroidManifest.xml @@ -0,0 +1,3 @@ + + diff --git a/camera/android/src/main/java/com/capacitorjs/plugins/camera/CameraBottomSheetDialogFragment.java b/camera/android/src/main/java/com/capacitorjs/plugins/camera/CameraBottomSheetDialogFragment.java new file mode 100644 index 000000000..254f1360e --- /dev/null +++ b/camera/android/src/main/java/com/capacitorjs/plugins/camera/CameraBottomSheetDialogFragment.java @@ -0,0 +1,122 @@ +package com.capacitorjs.plugins.camera; + +import android.annotation.SuppressLint; +import android.app.Dialog; +import android.content.DialogInterface; +import android.graphics.Color; +import android.view.View; +import android.view.Window; +import android.widget.LinearLayout; +import android.widget.TextView; +import androidx.annotation.NonNull; +import androidx.coordinatorlayout.widget.CoordinatorLayout; +import com.google.android.material.bottomsheet.BottomSheetBehavior; +import com.google.android.material.bottomsheet.BottomSheetDialogFragment; +import java.util.List; + +public class CameraBottomSheetDialogFragment extends BottomSheetDialogFragment { + + interface BottomSheetOnSelectedListener { + void onSelected(int index); + } + + interface BottomSheetOnCanceledListener { + void onCanceled(); + } + + private BottomSheetOnSelectedListener selectedListener; + private BottomSheetOnCanceledListener canceledListener; + private List options; + private String title; + + void setTitle(String title) { + this.title = title; + } + + void setOptions(List options, BottomSheetOnSelectedListener selectedListener, BottomSheetOnCanceledListener canceledListener) { + this.options = options; + this.selectedListener = selectedListener; + this.canceledListener = canceledListener; + } + + @Override + public void onCancel(DialogInterface dialog) { + super.onCancel(dialog); + if (canceledListener != null) { + this.canceledListener.onCanceled(); + } + } + + private BottomSheetBehavior.BottomSheetCallback mBottomSheetBehaviorCallback = new BottomSheetBehavior.BottomSheetCallback() { + @Override + public void onStateChanged(@NonNull View bottomSheet, int newState) { + if (newState == BottomSheetBehavior.STATE_HIDDEN) { + dismiss(); + } + } + + @Override + public void onSlide(@NonNull View bottomSheet, float slideOffset) {} + }; + + @Override + @SuppressLint("RestrictedApi") + public void setupDialog(Dialog dialog, int style) { + super.setupDialog(dialog, style); + + if (options == null || options.size() == 0) { + return; + } + + Window w = dialog.getWindow(); + + final float scale = getResources().getDisplayMetrics().density; + + float layoutPaddingDp16 = 16.0f; + float layoutPaddingDp12 = 12.0f; + float layoutPaddingDp8 = 8.0f; + int layoutPaddingPx16 = (int) (layoutPaddingDp16 * scale + 0.5f); + int layoutPaddingPx12 = (int) (layoutPaddingDp12 * scale + 0.5f); + int layoutPaddingPx8 = (int) (layoutPaddingDp8 * scale + 0.5f); + + CoordinatorLayout parentLayout = new CoordinatorLayout(getContext()); + + LinearLayout layout = new LinearLayout(getContext()); + layout.setOrientation(LinearLayout.VERTICAL); + layout.setPadding(layoutPaddingPx16, layoutPaddingPx16, layoutPaddingPx16, layoutPaddingPx16); + TextView ttv = new TextView(getContext()); + ttv.setTextColor(Color.parseColor("#757575")); + ttv.setPadding(layoutPaddingPx8, layoutPaddingPx8, layoutPaddingPx8, layoutPaddingPx8); + ttv.setText(title); + layout.addView(ttv); + + for (int i = 0; i < options.size(); i++) { + final int optionIndex = i; + + TextView tv = new TextView(getContext()); + tv.setTextColor(Color.parseColor("#000000")); + tv.setPadding(layoutPaddingPx12, layoutPaddingPx12, layoutPaddingPx12, layoutPaddingPx12); + tv.setText(options.get(i)); + tv.setOnClickListener( + view -> { + if (selectedListener != null) { + selectedListener.onSelected(optionIndex); + } + dismiss(); + } + ); + layout.addView(tv); + } + + parentLayout.addView(layout.getRootView()); + + dialog.setContentView(parentLayout.getRootView()); + + CoordinatorLayout.LayoutParams params = (CoordinatorLayout.LayoutParams) ((View) parentLayout.getParent()).getLayoutParams(); + CoordinatorLayout.Behavior behavior = params.getBehavior(); + + if (behavior != null && behavior instanceof BottomSheetBehavior) { + ((BottomSheetBehavior) behavior).addBottomSheetCallback(mBottomSheetBehaviorCallback); + } + } +} diff --git a/camera/android/src/main/java/com/capacitorjs/plugins/camera/CameraPlugin.java b/camera/android/src/main/java/com/capacitorjs/plugins/camera/CameraPlugin.java new file mode 100644 index 000000000..db31b918a --- /dev/null +++ b/camera/android/src/main/java/com/capacitorjs/plugins/camera/CameraPlugin.java @@ -0,0 +1,602 @@ +package com.capacitorjs.plugins.camera; + +import android.Manifest; +import android.app.Activity; +import android.content.Intent; +import android.content.pm.PackageManager; +import android.graphics.Bitmap; +import android.graphics.BitmapFactory; +import android.net.Uri; +import android.os.Bundle; +import android.provider.MediaStore; +import android.util.Base64; +import androidx.core.content.FileProvider; +import com.getcapacitor.FileUtils; +import com.getcapacitor.JSArray; +import com.getcapacitor.JSObject; +import com.getcapacitor.Logger; +import com.getcapacitor.Plugin; +import com.getcapacitor.PluginCall; +import com.getcapacitor.PluginMethod; +import com.getcapacitor.PluginRequestCodes; +import com.getcapacitor.annotation.CapacitorPlugin; +import com.getcapacitor.annotation.Permission; +import java.io.ByteArrayInputStream; +import java.io.ByteArrayOutputStream; +import java.io.File; +import java.io.FileNotFoundException; +import java.io.FileOutputStream; +import java.io.IOException; +import java.io.InputStream; +import java.util.ArrayList; +import java.util.List; +import org.json.JSONException; + +/** + * The Camera plugin makes it easy to take a photo or have the user select a photo + * from their albums. + * + * On Android, this plugin sends an intent that opens the stock Camera app. + * + * Adapted from https://developer.android.com/training/camera/photobasics.html + */ +@CapacitorPlugin( + name = "Camera", + permissions = { + @Permission(strings = { Manifest.permission.CAMERA }, alias = "camera"), + @Permission(strings = { Manifest.permission.READ_EXTERNAL_STORAGE, Manifest.permission.WRITE_EXTERNAL_STORAGE }, alias = "photos") + }, + requestCodes = { CameraPlugin.REQUEST_IMAGE_CAPTURE, CameraPlugin.REQUEST_IMAGE_PICK, CameraPlugin.REQUEST_IMAGE_EDIT }, + permissionRequestCode = CameraPlugin.CAMERA_REQUEST_PERMISSIONS +) +public class CameraPlugin extends Plugin { + + // Request codes + static final int CAMERA_REQUEST_PERMISSIONS = PluginRequestCodes.CAMERA_IMAGE_CAPTURE; + static final int REQUEST_IMAGE_CAPTURE = PluginRequestCodes.CAMERA_IMAGE_CAPTURE; + static final int REQUEST_IMAGE_PICK = PluginRequestCodes.CAMERA_IMAGE_PICK; + static final int REQUEST_IMAGE_EDIT = PluginRequestCodes.CAMERA_IMAGE_EDIT; + // Message constants + private static final String INVALID_RESULT_TYPE_ERROR = "Invalid resultType option"; + private static final String PERMISSION_DENIED_ERROR = "Unable to access camera, user denied permission request"; + private static final String NO_CAMERA_ERROR = "Device doesn't have a camera available"; + private static final String NO_CAMERA_ACTIVITY_ERROR = "Unable to resolve camera activity"; + private static final String IMAGE_FILE_SAVE_ERROR = "Unable to create photo on disk"; + private static final String IMAGE_PROCESS_NO_FILE_ERROR = "Unable to process image, file not found on disk"; + private static final String UNABLE_TO_PROCESS_IMAGE = "Unable to process image"; + private static final String IMAGE_EDIT_ERROR = "Unable to edit image"; + private static final String IMAGE_GALLERY_SAVE_ERROR = "Unable to save the image in the gallery"; + + private String imageFileSavePath; + private String imageEditedFileSavePath; + private Uri imageFileUri; + private boolean isEdited = false; + + private CameraSettings settings = new CameraSettings(); + + @PluginMethod + public void getPhoto(PluginCall call) { + isEdited = false; + + saveCall(call); + + settings = getSettings(call); + + doShow(call); + } + + private void doShow(PluginCall call) { + switch (settings.getSource()) { + case camera: + showCamera(call); + break; + case photos: + showPhotos(call); + break; + default: + showPrompt(call); + break; + } + } + + private void showPrompt(final PluginCall call) { + // We have all necessary permissions, open the camera + List options = new ArrayList<>(); + options.add(call.getString("promptLabelPhoto", "From Photos")); + options.add(call.getString("promptLabelPicture", "Take Picture")); + + final CameraBottomSheetDialogFragment fragment = new CameraBottomSheetDialogFragment(); + fragment.setTitle(call.getString("promptLabelHeader", "Photo")); + fragment.setOptions( + options, + index -> { + if (index == 0) { + settings.setSource(CameraSource.photos); + openPhotos(call); + } else if (index == 1) { + settings.setSource(CameraSource.camera); + openCamera(call); + } + }, + () -> call.reject("User cancelled photos app") + ); + fragment.show(getActivity().getSupportFragmentManager(), "capacitorModalsActionSheet"); + } + + private void showCamera(final PluginCall call) { + if (!getContext().getPackageManager().hasSystemFeature(PackageManager.FEATURE_CAMERA_ANY)) { + call.reject(NO_CAMERA_ERROR); + return; + } + openCamera(call); + } + + private void showPhotos(final PluginCall call) { + openPhotos(call); + } + + private boolean checkCameraPermissions(PluginCall call) { + // if the manifest does not contain the camera permissions key, we don't need to ask the user + boolean needCameraPerms = hasDefinedPermissions(new String[] { Manifest.permission.CAMERA }); + boolean hasCameraPerms = !needCameraPerms || hasPermission(Manifest.permission.CAMERA); + boolean hasPhotoPerms = hasPermission(Manifest.permission.WRITE_EXTERNAL_STORAGE); + + // If we want to save to the gallery, we need two permissions + if (settings.isSaveToGallery() && !(hasCameraPerms && hasPhotoPerms)) { + if (needCameraPerms) { + requestPermissions( + call, + new String[] { + Manifest.permission.CAMERA, + Manifest.permission.WRITE_EXTERNAL_STORAGE, + Manifest.permission.READ_EXTERNAL_STORAGE + }, + CAMERA_REQUEST_PERMISSIONS + ); + } else { + requestPermissions( + call, + new String[] { Manifest.permission.WRITE_EXTERNAL_STORAGE, Manifest.permission.READ_EXTERNAL_STORAGE }, + CAMERA_REQUEST_PERMISSIONS + ); + } + return false; + } + // If we don't need to save to the gallery, we can just ask for camera permissions + else if (!hasCameraPerms) { + requestPermission(call, Manifest.permission.CAMERA, CAMERA_REQUEST_PERMISSIONS); + return false; + } + return true; + } + + private boolean checkPhotosPermissions(PluginCall call) { + if (!hasPermission(Manifest.permission.READ_EXTERNAL_STORAGE)) { + requestPermission(call, Manifest.permission.READ_EXTERNAL_STORAGE, CAMERA_REQUEST_PERMISSIONS); + return false; + } + return true; + } + + private CameraSettings getSettings(PluginCall call) { + CameraSettings settings = new CameraSettings(); + settings.setResultType(getResultType(call.getString("resultType"))); + settings.setSaveToGallery(call.getBoolean("saveToGallery", CameraSettings.DEFAULT_SAVE_IMAGE_TO_GALLERY)); + settings.setAllowEditing(call.getBoolean("allowEditing", false)); + settings.setQuality(call.getInt("quality", CameraSettings.DEFAULT_QUALITY)); + settings.setWidth(call.getInt("width", 0)); + settings.setHeight(call.getInt("height", 0)); + settings.setShouldResize(settings.getWidth() > 0 || settings.getHeight() > 0); + settings.setShouldCorrectOrientation(call.getBoolean("correctOrientation", CameraSettings.DEFAULT_CORRECT_ORIENTATION)); + try { + settings.setSource(CameraSource.valueOf(call.getString("source", CameraSource.prompt.getSource()))); + } catch (IllegalArgumentException ex) { + settings.setSource(CameraSource.prompt); + } + return settings; + } + + private CameraResultType getResultType(String resultType) { + if (resultType == null) { + return null; + } + try { + return CameraResultType.valueOf(resultType.toUpperCase()); + } catch (IllegalArgumentException ex) { + Logger.debug(getLogTag(), "Invalid result type \"" + resultType + "\", defaulting to base64"); + return CameraResultType.BASE64; + } + } + + public void openCamera(final PluginCall call) { + if (checkCameraPermissions(call)) { + Intent takePictureIntent = new Intent(MediaStore.ACTION_IMAGE_CAPTURE); + if (takePictureIntent.resolveActivity(getContext().getPackageManager()) != null) { + // If we will be saving the photo, send the target file along + try { + String appId = getAppId(); + File photoFile = CameraUtils.createImageFile(getActivity()); + imageFileSavePath = photoFile.getAbsolutePath(); + // TODO: Verify provider config exists + imageFileUri = FileProvider.getUriForFile(getActivity(), appId + ".fileprovider", photoFile); + takePictureIntent.putExtra(MediaStore.EXTRA_OUTPUT, imageFileUri); + } catch (Exception ex) { + call.reject(IMAGE_FILE_SAVE_ERROR, ex); + return; + } + + startActivityForResult(call, takePictureIntent, REQUEST_IMAGE_CAPTURE); + } else { + call.reject(NO_CAMERA_ACTIVITY_ERROR); + } + } + } + + public void openPhotos(final PluginCall call) { + if (checkPhotosPermissions(call)) { + Intent intent = new Intent(Intent.ACTION_PICK); + intent.setType("image/*"); + startActivityForResult(call, intent, REQUEST_IMAGE_PICK); + } + } + + public void processCameraImage(PluginCall call) { + if (imageFileSavePath == null) { + call.reject(IMAGE_PROCESS_NO_FILE_ERROR); + return; + } + // Load the image as a Bitmap + File f = new File(imageFileSavePath); + BitmapFactory.Options bmOptions = new BitmapFactory.Options(); + Uri contentUri = Uri.fromFile(f); + Bitmap bitmap = BitmapFactory.decodeFile(imageFileSavePath, bmOptions); + + if (bitmap == null) { + call.reject("User cancelled photos app"); + return; + } + + returnResult(call, bitmap, contentUri); + } + + public void processPickedImage(PluginCall call, Intent data) { + if (data == null) { + call.reject("No image picked"); + return; + } + + Uri u = data.getData(); + + InputStream imageStream = null; + + try { + imageStream = getContext().getContentResolver().openInputStream(u); + Bitmap bitmap = BitmapFactory.decodeStream(imageStream); + + if (bitmap == null) { + call.reject("Unable to process bitmap"); + return; + } + + returnResult(call, bitmap, u); + } catch (OutOfMemoryError err) { + call.reject("Out of memory"); + } catch (FileNotFoundException ex) { + call.reject("No such image found", ex); + } finally { + if (imageStream != null) { + try { + imageStream.close(); + } catch (IOException e) { + Logger.error(getLogTag(), UNABLE_TO_PROCESS_IMAGE, e); + } + } + } + } + + /** + * Save the modified image we've created to a temporary location, so we can + * return a URI to it later + * @param bitmap + * @param contentUri + * @param is + * @return + * @throws IOException + */ + private Uri saveTemporaryImage(Bitmap bitmap, Uri contentUri, InputStream is) throws IOException { + String filename = contentUri.getLastPathSegment(); + if (!filename.contains(".jpg") && !filename.contains(".jpeg")) { + filename += "." + (new java.util.Date()).getTime() + ".jpeg"; + } + File cacheDir = getContext().getCacheDir(); + File outFile = new File(cacheDir, filename); + FileOutputStream fos = new FileOutputStream(outFile); + byte[] buffer = new byte[1024]; + int len; + while ((len = is.read(buffer)) != -1) { + fos.write(buffer, 0, len); + } + fos.close(); + return Uri.fromFile(outFile); + } + + /** + * After processing the image, return the final result back to the caller. + * @param call + * @param bitmap + * @param u + */ + private void returnResult(PluginCall call, Bitmap bitmap, Uri u) { + try { + bitmap = prepareBitmap(bitmap, u); + } catch (IOException e) { + call.reject(UNABLE_TO_PROCESS_IMAGE); + return; + } + + ExifWrapper exif = ImageUtils.getExifData(getContext(), bitmap, u); + + // Compress the final image and prepare for output to client + ByteArrayOutputStream bitmapOutputStream = new ByteArrayOutputStream(); + bitmap.compress(Bitmap.CompressFormat.JPEG, settings.getQuality(), bitmapOutputStream); + + if (settings.isAllowEditing() && !isEdited) { + editImage(call, bitmap, u, bitmapOutputStream); + return; + } + + boolean saveToGallery = call.getBoolean("saveToGallery", CameraSettings.DEFAULT_SAVE_IMAGE_TO_GALLERY); + if (saveToGallery && (imageEditedFileSavePath != null || imageFileSavePath != null)) { + try { + String fileToSavePath = imageEditedFileSavePath != null ? imageEditedFileSavePath : imageFileSavePath; + File fileToSave = new File(fileToSavePath); + MediaStore.Images.Media.insertImage(getContext().getContentResolver(), fileToSavePath, fileToSave.getName(), ""); + } catch (FileNotFoundException e) { + Logger.error(getLogTag(), IMAGE_GALLERY_SAVE_ERROR, e); + } + } + + if (settings.getResultType() == CameraResultType.BASE64) { + returnBase64(call, exif, bitmapOutputStream); + } else if (settings.getResultType() == CameraResultType.URI) { + returnFileURI(call, exif, bitmap, u, bitmapOutputStream); + } else if (settings.getResultType() == CameraResultType.DATAURL) { + returnDataUrl(call, exif, bitmapOutputStream); + } else { + call.reject(INVALID_RESULT_TYPE_ERROR); + } + + // Result returned, clear stored paths + imageFileSavePath = null; + imageFileUri = null; + imageEditedFileSavePath = null; + } + + private void returnFileURI(PluginCall call, ExifWrapper exif, Bitmap bitmap, Uri u, ByteArrayOutputStream bitmapOutputStream) { + Uri newUri = getTempImage(bitmap, u, bitmapOutputStream); + if (newUri != null) { + JSObject ret = new JSObject(); + ret.put("format", "jpeg"); + ret.put("exif", exif.toJson()); + ret.put("path", newUri.toString()); + ret.put("webPath", FileUtils.getPortablePath(getContext(), bridge.getLocalUrl(), newUri)); + call.resolve(ret); + } else { + call.reject(UNABLE_TO_PROCESS_IMAGE); + } + } + + private Uri getTempImage(Bitmap bitmap, Uri u, ByteArrayOutputStream bitmapOutputStream) { + ByteArrayInputStream bis = null; + Uri newUri = null; + try { + bis = new ByteArrayInputStream(bitmapOutputStream.toByteArray()); + newUri = saveTemporaryImage(bitmap, u, bis); + } catch (IOException ex) {} finally { + if (bis != null) { + try { + bis.close(); + } catch (IOException e) { + 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 + * @return + */ + private Bitmap prepareBitmap(Bitmap bitmap, Uri imageUri) throws IOException { + if (settings.isShouldCorrectOrientation()) { + final Bitmap newBitmap = ImageUtils.correctOrientation(getContext(), bitmap, imageUri); + bitmap = replaceBitmap(bitmap, newBitmap); + } + + if (settings.isShouldResize()) { + final Bitmap newBitmap = ImageUtils.resize(bitmap, settings.getWidth(), settings.getHeight()); + bitmap = replaceBitmap(bitmap, newBitmap); + } + + return bitmap; + } + + private Bitmap replaceBitmap(Bitmap bitmap, final Bitmap newBitmap) { + if (bitmap != newBitmap) { + bitmap.recycle(); + } + bitmap = newBitmap; + return bitmap; + } + + private void returnDataUrl(PluginCall call, ExifWrapper exif, ByteArrayOutputStream bitmapOutputStream) { + byte[] byteArray = bitmapOutputStream.toByteArray(); + String encoded = Base64.encodeToString(byteArray, Base64.NO_WRAP); + + JSObject data = new JSObject(); + data.put("format", "jpeg"); + data.put("dataUrl", "data:image/jpeg;base64," + encoded); + data.put("exif", exif.toJson()); + call.resolve(data); + } + + private void returnBase64(PluginCall call, ExifWrapper exif, ByteArrayOutputStream bitmapOutputStream) { + byte[] byteArray = bitmapOutputStream.toByteArray(); + String encoded = Base64.encodeToString(byteArray, Base64.NO_WRAP); + + JSObject data = new JSObject(); + data.put("format", "jpeg"); + data.put("base64String", encoded); + data.put("exif", exif.toJson()); + call.resolve(data); + } + + @Override + @PluginMethod + public void requestPermissions(PluginCall call) { + // 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 (hasDefinedPermissions(new String[] { Manifest.permission.CAMERA })) { + // just request normally + super.requestPermissions(call); + } else { + // the manifest does not define camera permissions, so we need to decide what to do + // first, extract the permissions being requested + JSArray providedPerms = call.getArray("permissions"); + List permsList = null; + try { + permsList = providedPerms.toList(); + } catch (JSONException e) {} + + 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 + call.resolve(getPermissionStates()); + } else { + // we need to ask about photos so request storage permissions + String[] perms = { Manifest.permission.READ_EXTERNAL_STORAGE, Manifest.permission.WRITE_EXTERNAL_STORAGE }; + requestPermissions(call, perms, CameraPlugin.CAMERA_REQUEST_PERMISSIONS); + } + } + } + + @Override + public JSObject getPermissionStates() { + JSObject permissionStates = super.getPermissionStates(); + + // If Camera is not in the manifest and therefore not required, say the permission is granted + if (!hasDefinedPermissions(new String[] { Manifest.permission.CAMERA })) { + permissionStates.put("camera", "granted"); + } + + return permissionStates; + } + + @Override + protected void onRequestPermissionsResult(PluginCall savedCall, int requestCode, String[] permissions, int[] grantResults) { + Logger.debug(getLogTag(), "handling request perms result"); + + if (savedCall.getMethodName().equals("getPhoto")) { + for (int i = 0; i < grantResults.length; i++) { + int result = grantResults[i]; + String perm = permissions[i]; + + if (result == PackageManager.PERMISSION_DENIED && perm != Manifest.permission.WRITE_EXTERNAL_STORAGE) { + Logger.debug(getLogTag(), "User denied camera permission: " + perm); + savedCall.reject(PERMISSION_DENIED_ERROR); + return; + } + } + doShow(savedCall); + } + } + + @Override + protected void handleOnActivityResult(PluginCall savedCall, int requestCode, int resultCode, Intent data) { + if (savedCall == null) { + return; + } + + settings = getSettings(savedCall); + + if (requestCode == REQUEST_IMAGE_CAPTURE) { + processCameraImage(savedCall); + } else if (requestCode == REQUEST_IMAGE_PICK) { + processPickedImage(savedCall, data); + } else if (requestCode == REQUEST_IMAGE_EDIT && resultCode == Activity.RESULT_OK) { + isEdited = true; + processPickedImage(savedCall, data); + } else if (resultCode == Activity.RESULT_CANCELED && imageFileSavePath != null) { + imageEditedFileSavePath = null; + isEdited = true; + processCameraImage(savedCall); + } + } + + private void editImage(PluginCall call, Bitmap bitmap, Uri uri, ByteArrayOutputStream bitmapOutputStream) { + Uri origPhotoUri = uri; + if (imageFileUri != null) { + origPhotoUri = imageFileUri; + } + try { + Intent editIntent = createEditIntent(origPhotoUri, false); + startActivityForResult(call, editIntent, REQUEST_IMAGE_EDIT); + } catch (SecurityException ex) { + Uri tempImage = getTempImage(bitmap, uri, bitmapOutputStream); + Intent editIntent = createEditIntent(tempImage, true); + if (editIntent != null) { + startActivityForResult(call, editIntent, REQUEST_IMAGE_EDIT); + } else { + call.reject(IMAGE_EDIT_ERROR); + } + } catch (Exception ex) { + call.reject(IMAGE_EDIT_ERROR, ex); + } + } + + private Intent createEditIntent(Uri origPhotoUri, boolean expose) { + Uri editUri = origPhotoUri; + try { + if (expose) { + editUri = + FileProvider.getUriForFile( + getActivity(), + getContext().getPackageName() + ".fileprovider", + new File(origPhotoUri.getPath()) + ); + } + Intent editIntent = new Intent(Intent.ACTION_EDIT); + editIntent.setDataAndType(editUri, "image/*"); + File editedFile = CameraUtils.createImageFile(getActivity()); + imageEditedFileSavePath = editedFile.getAbsolutePath(); + Uri editedUri = Uri.fromFile(editedFile); + editIntent.addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION); + editIntent.addFlags(Intent.FLAG_GRANT_WRITE_URI_PERMISSION); + editIntent.putExtra(MediaStore.EXTRA_OUTPUT, editedUri); + return editIntent; + } catch (Exception ex) { + return null; + } + } + + @Override + protected Bundle saveInstanceState() { + Bundle bundle = super.saveInstanceState(); + if (bundle != null) { + bundle.putString("cameraImageFileSavePath", imageFileSavePath); + } + return bundle; + } + + @Override + protected void restoreState(Bundle state) { + String storedImageFileSavePath = state.getString("cameraImageFileSavePath"); + if (storedImageFileSavePath != null) { + imageFileSavePath = storedImageFileSavePath; + } + } +} diff --git a/camera/android/src/main/java/com/capacitorjs/plugins/camera/CameraResultType.java b/camera/android/src/main/java/com/capacitorjs/plugins/camera/CameraResultType.java new file mode 100644 index 000000000..5d050590a --- /dev/null +++ b/camera/android/src/main/java/com/capacitorjs/plugins/camera/CameraResultType.java @@ -0,0 +1,17 @@ +package com.capacitorjs.plugins.camera; + +public enum CameraResultType { + BASE64("base64"), + URI("uri"), + DATAURL("dataUrl"); + + private String type; + + CameraResultType(String type) { + this.type = type; + } + + public String getType() { + return type; + } +} diff --git a/camera/android/src/main/java/com/capacitorjs/plugins/camera/CameraSettings.java b/camera/android/src/main/java/com/capacitorjs/plugins/camera/CameraSettings.java new file mode 100644 index 000000000..118f8bfd3 --- /dev/null +++ b/camera/android/src/main/java/com/capacitorjs/plugins/camera/CameraSettings.java @@ -0,0 +1,90 @@ +package com.capacitorjs.plugins.camera; + +public class CameraSettings { + + public static final int DEFAULT_QUALITY = 90; + public static final boolean DEFAULT_SAVE_IMAGE_TO_GALLERY = false; + public static final boolean DEFAULT_CORRECT_ORIENTATION = true; + + private CameraResultType resultType = CameraResultType.BASE64; + private int quality = DEFAULT_QUALITY; + private boolean shouldResize = false; + private boolean shouldCorrectOrientation = DEFAULT_CORRECT_ORIENTATION; + private boolean saveToGallery = DEFAULT_SAVE_IMAGE_TO_GALLERY; + private boolean allowEditing = false; + private int width = 0; + private int height = 0; + private CameraSource source = CameraSource.prompt; + + public CameraResultType getResultType() { + return resultType; + } + + public void setResultType(CameraResultType resultType) { + this.resultType = resultType; + } + + public int getQuality() { + return quality; + } + + public void setQuality(int quality) { + this.quality = quality; + } + + public boolean isShouldResize() { + return shouldResize; + } + + public void setShouldResize(boolean shouldResize) { + this.shouldResize = shouldResize; + } + + public boolean isShouldCorrectOrientation() { + return shouldCorrectOrientation; + } + + public void setShouldCorrectOrientation(boolean shouldCorrectOrientation) { + this.shouldCorrectOrientation = shouldCorrectOrientation; + } + + public boolean isSaveToGallery() { + return saveToGallery; + } + + public void setSaveToGallery(boolean saveToGallery) { + this.saveToGallery = saveToGallery; + } + + public boolean isAllowEditing() { + return allowEditing; + } + + public void setAllowEditing(boolean allowEditing) { + this.allowEditing = allowEditing; + } + + public int getWidth() { + return width; + } + + public void setWidth(int width) { + this.width = width; + } + + public int getHeight() { + return height; + } + + public void setHeight(int height) { + this.height = height; + } + + public CameraSource getSource() { + return source; + } + + public void setSource(CameraSource source) { + this.source = source; + } +} diff --git a/camera/android/src/main/java/com/capacitorjs/plugins/camera/CameraSource.java b/camera/android/src/main/java/com/capacitorjs/plugins/camera/CameraSource.java new file mode 100644 index 000000000..8e01e343e --- /dev/null +++ b/camera/android/src/main/java/com/capacitorjs/plugins/camera/CameraSource.java @@ -0,0 +1,17 @@ +package com.capacitorjs.plugins.camera; + +public enum CameraSource { + prompt("prompt"), + camera("camera"), + photos("photos"); + + private String source; + + CameraSource(String source) { + this.source = source; + } + + public String getSource() { + return this.source; + } +} diff --git a/camera/android/src/main/java/com/capacitorjs/plugins/camera/CameraUtils.java b/camera/android/src/main/java/com/capacitorjs/plugins/camera/CameraUtils.java new file mode 100644 index 000000000..d4d2ca069 --- /dev/null +++ b/camera/android/src/main/java/com/capacitorjs/plugins/camera/CameraUtils.java @@ -0,0 +1,34 @@ +package com.capacitorjs.plugins.camera; + +import android.app.Activity; +import android.net.Uri; +import android.os.Environment; +import androidx.core.content.FileProvider; +import com.getcapacitor.Logger; +import java.io.File; +import java.io.IOException; +import java.text.SimpleDateFormat; +import java.util.Date; + +public class CameraUtils { + + public static Uri createImageFileUri(Activity activity, String appId) throws IOException { + File photoFile = CameraUtils.createImageFile(activity); + return FileProvider.getUriForFile(activity, appId + ".fileprovider", photoFile); + } + + public static File createImageFile(Activity activity) throws IOException { + // Create an image file name + String timeStamp = new SimpleDateFormat("yyyyMMdd_HHmmss").format(new Date()); + String imageFileName = "JPEG_" + timeStamp + "_"; + File storageDir = activity.getExternalFilesDir(Environment.DIRECTORY_PICTURES); + + File image = File.createTempFile(imageFileName, /* prefix */".jpg", /* suffix */storageDir/* directory */); + + return image; + } + + protected static String getLogTag() { + return Logger.tags("CameraUtils"); + } +} diff --git a/camera/android/src/main/java/com/capacitorjs/plugins/camera/ExifWrapper.java b/camera/android/src/main/java/com/capacitorjs/plugins/camera/ExifWrapper.java new file mode 100644 index 000000000..663484f89 --- /dev/null +++ b/camera/android/src/main/java/com/capacitorjs/plugins/camera/ExifWrapper.java @@ -0,0 +1,154 @@ +package com.capacitorjs.plugins.camera; + +import static androidx.exifinterface.media.ExifInterface.*; + +import androidx.exifinterface.media.ExifInterface; +import com.getcapacitor.JSObject; + +public class ExifWrapper { + + private final ExifInterface exif; + + public ExifWrapper(ExifInterface exif) { + this.exif = exif; + } + + public JSObject toJson() { + JSObject ret = new JSObject(); + + if (this.exif == null) { + return ret; + } + + // Commented fields are for API 24. Left in to save someone the wrist damage later + + p(ret, TAG_APERTURE_VALUE); + /* + p(ret, TAG_ARTIST); + p(ret, TAG_BITS_PER_SAMPLE); + p(ret, TAG_BRIGHTNESS_VALUE); + p(ret, TAG_CFA_PATTERN); + p(ret, TAG_COLOR_SPACE); + p(ret, TAG_COMPONENTS_CONFIGURATION); + p(ret, TAG_COMPRESSED_BITS_PER_PIXEL); + p(ret, TAG_COMPRESSION); + p(ret, TAG_CONTRAST); + p(ret, TAG_COPYRIGHT); + */ + p(ret, TAG_DATETIME); + /* + p(ret, TAG_DATETIME_DIGITIZED); + p(ret, TAG_DATETIME_ORIGINAL); + p(ret, TAG_DEFAULT_CROP_SIZE); + p(ret, TAG_DEVICE_SETTING_DESCRIPTION); + p(ret, TAG_DIGITAL_ZOOM_RATIO); + p(ret, TAG_DNG_VERSION); + p(ret, TAG_EXIF_VERSION); + p(ret, TAG_EXPOSURE_BIAS_VALUE); + p(ret, TAG_EXPOSURE_INDEX); + p(ret, TAG_EXIF_VERSION); + p(ret, TAG_EXPOSURE_MODE); + p(ret, TAG_EXPOSURE_PROGRAM); + */ + p(ret, TAG_EXPOSURE_TIME); + // p(ret, TAG_F_NUMBER); + // p(ret, TAG_FILE_SOURCE); + p(ret, TAG_FLASH); + // p(ret, TAG_FLASH_ENERGY); + // p(ret, TAG_FLASHPIX_VERSION); + p(ret, TAG_FOCAL_LENGTH); + // p(ret, TAG_FOCAL_LENGTH_IN_35MM_FILM); + // p(ret, TAG_FOCAL_PLANE_RESOLUTION_UNIT); + p(ret, TAG_FOCAL_LENGTH); + // p(ret, TAG_GAIN_CONTROL); + p(ret, TAG_GPS_LATITUDE); + p(ret, TAG_GPS_LATITUDE_REF); + p(ret, TAG_GPS_LONGITUDE); + p(ret, TAG_GPS_LONGITUDE_REF); + p(ret, TAG_GPS_ALTITUDE); + p(ret, TAG_GPS_ALTITUDE_REF); + // p(ret, TAG_GPS_AREA_INFORMATION); + p(ret, TAG_GPS_DATESTAMP); + /* + API 24 + p(ret, TAG_GPS_DEST_BEARING); + p(ret, TAG_GPS_DEST_BEARING_REF); + p(ret, TAG_GPS_DEST_DISTANCE_REF); + p(ret, TAG_GPS_DEST_DISTANCE_REF); + p(ret, TAG_GPS_DEST_LATITUDE); + p(ret, TAG_GPS_DEST_LATITUDE_REF); + p(ret, TAG_GPS_DEST_LONGITUDE); + p(ret, TAG_GPS_DEST_LONGITUDE_REF); + p(ret, TAG_GPS_DIFFERENTIAL); + p(ret, TAG_GPS_DOP); + p(ret, TAG_GPS_IMG_DIRECTION); + p(ret, TAG_GPS_IMG_DIRECTION_REF); + p(ret, TAG_GPS_MAP_DATUM); + p(ret, TAG_GPS_MEASURE_MODE); + */ + p(ret, TAG_GPS_PROCESSING_METHOD); + /* + API 24 + p(ret, TAG_GPS_SATELLITES); + p(ret, TAG_GPS_SPEED); + p(ret, TAG_GPS_SPEED_REF); + p(ret, TAG_GPS_STATUS); + */ + p(ret, TAG_GPS_TIMESTAMP); + /* + API 24 + p(ret, TAG_GPS_TRACK); + p(ret, TAG_GPS_TRACK_REF); + p(ret, TAG_GPS_VERSION_ID); + p(ret, TAG_IMAGE_DESCRIPTION); + */ + p(ret, TAG_IMAGE_LENGTH); + // p(ret, TAG_IMAGE_UNIQUE_ID); + p(ret, TAG_IMAGE_WIDTH); + p(ret, TAG_ISO_SPEED); + /* + p(ret, TAG_INTEROPERABILITY_INDEX); + p(ret, TAG_ISO_SPEED_RATINGS); + p(ret, TAG_JPEG_INTERCHANGE_FORMAT); + p(ret, TAG_JPEG_INTERCHANGE_FORMAT_LENGTH); + p(ret, TAG_LIGHT_SOURCE); + */ + p(ret, TAG_MAKE); + /* + p(ret, TAG_MAKER_NOTE); + p(ret, TAG_MAX_APERTURE_VALUE); + p(ret, TAG_METERING_MODE); + */ + p(ret, TAG_MODEL); + /* + p(ret, TAG_NEW_SUBFILE_TYPE); + p(ret, TAG_OECF); + p(ret, TAG_ORF_ASPECT_FRAME); + p(ret, TAG_ORF_PREVIEW_IMAGE_LENGTH); + p(ret, TAG_ORF_PREVIEW_IMAGE_START); + */ + p(ret, TAG_ORIENTATION); + /* + p(ret, TAG_ORF_THUMBNAIL_IMAGE); + p(ret, TAG_PHOTOMETRIC_INTERPRETATION); + p(ret, TAG_PIXEL_X_DIMENSION); + p(ret, TAG_PIXEL_Y_DIMENSION); + p(ret, TAG_PLANAR_CONFIGURATION); + p(ret, TAG_PRIMARY_CHROMATICITIES); + p(ret, TAG_REFERENCE_BLACK_WHITE); + p(ret, TAG_RELATED_SOUND_FILE); + p(ret, TAG_RESOLUTION_UNIT); + p(ret, TAG_ROWS_PER_STRIP); + p(ret, TAG_RW2_ISO); + p(ret, TAG_RW2_JPG_FROM_RAW); + */ + p(ret, TAG_WHITE_BALANCE); + + return ret; + } + + public void p(JSObject o, String tag) { + String val = exif.getAttribute(tag); + o.put(tag, val); + } +} diff --git a/camera/android/src/main/java/com/capacitorjs/plugins/camera/ImageUtils.java b/camera/android/src/main/java/com/capacitorjs/plugins/camera/ImageUtils.java new file mode 100644 index 000000000..f984492d2 --- /dev/null +++ b/camera/android/src/main/java/com/capacitorjs/plugins/camera/ImageUtils.java @@ -0,0 +1,146 @@ +package com.capacitorjs.plugins.camera; + +import android.content.Context; +import android.database.Cursor; +import android.graphics.Bitmap; +import android.graphics.Matrix; +import android.net.Uri; +import android.os.Build; +import android.provider.MediaStore; +import androidx.exifinterface.media.ExifInterface; +import com.getcapacitor.FileUtils; +import com.getcapacitor.Logger; +import java.io.IOException; +import java.io.InputStream; + +public class ImageUtils { + + /** + * Resize an image to the given width and height considering the preserveAspectRatio flag. + * @param bitmap + * @param width + * @param height + * @return a new, scaled Bitmap + */ + public static Bitmap resize(Bitmap bitmap, final int width, final int height) { + return ImageUtils.resizePreservingAspectRatio(bitmap, width, height); + } + + /** + * 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 static Bitmap resizePreservingAspectRatio(Bitmap bitmap, final int desiredMaxWidth, final int desiredMaxHeight) { + int width = bitmap.getWidth(); + int height = bitmap.getHeight(); + + // 0 is treated as 'no restriction' + int maxHeight = desiredMaxHeight == 0 ? height : desiredMaxHeight; + int maxWidth = desiredMaxWidth == 0 ? width : desiredMaxWidth; + + // resize with preserved aspect ratio + float newWidth = Math.min(width, maxWidth); + float newHeight = (height * newWidth) / width; + + if (newHeight > maxHeight) { + newWidth = (width * maxHeight) / height; + newHeight = maxHeight; + } + return Bitmap.createScaledBitmap(bitmap, Math.round(newWidth), Math.round(newHeight), false); + } + + /** + * Transform an image with the given matrix + * @param bitmap + * @param matrix + * @return + */ + private static Bitmap transform(final Bitmap bitmap, final Matrix matrix) { + return Bitmap.createBitmap(bitmap, 0, 0, bitmap.getWidth(), bitmap.getHeight(), 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 + * @return + */ + public static Bitmap correctOrientation(final Context c, final Bitmap bitmap, final Uri imageUri) throws IOException { + if (Build.VERSION.SDK_INT < 24) { + return correctOrientationOlder(c, bitmap, imageUri); + } else { + final int orientation = getOrientation(c, imageUri); + + if (orientation != 0) { + Matrix matrix = new Matrix(); + matrix.postRotate(orientation); + + return transform(bitmap, matrix); + } else { + return bitmap; + } + } + } + + private static Bitmap correctOrientationOlder(final Context c, final Bitmap bitmap, final Uri imageUri) { + // TODO: To be tested on older phone using Android API < 24 + + String[] orientationColumn = { MediaStore.Images.Media.DATA, MediaStore.Images.Media.ORIENTATION }; + Cursor cur = c.getContentResolver().query(imageUri, orientationColumn, null, null, null); + int orientation = -1; + if (cur != null && cur.moveToFirst()) { + orientation = cur.getInt(cur.getColumnIndex(orientationColumn[0])); + } + Matrix matrix = new Matrix(); + + if (orientation != -1) { + matrix.postRotate(orientation); + } + + return transform(bitmap, matrix); + } + + private static int getOrientation(final Context c, final Uri imageUri) throws IOException { + int result = 0; + + try (InputStream iStream = c.getContentResolver().openInputStream(imageUri)) { + final ExifInterface exifInterface = new ExifInterface(iStream); + + final int orientation = 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; + } + + public static ExifWrapper getExifData(final Context c, final Bitmap bitmap, final Uri imageUri) { + InputStream stream = null; + try { + stream = c.getContentResolver().openInputStream(imageUri); + final ExifInterface exifInterface = new ExifInterface(stream); + + return new ExifWrapper(exifInterface); + } catch (IOException ex) { + Logger.error("Error loading exif data from image", ex); + } finally { + if (stream != null) { + try { + stream.close(); + } catch (IOException ignored) {} + } + } + return new ExifWrapper(null); + } +} diff --git a/camera/android/src/test/java/com/getcapacitor/ExampleUnitTest.java b/camera/android/src/test/java/com/getcapacitor/ExampleUnitTest.java new file mode 100644 index 000000000..a0fed0cfb --- /dev/null +++ b/camera/android/src/test/java/com/getcapacitor/ExampleUnitTest.java @@ -0,0 +1,18 @@ +package com.getcapacitor; + +import static org.junit.Assert.*; + +import org.junit.Test; + +/** + * Example local unit test, which will execute on the development machine (host). + * + * @see Testing documentation + */ +public class ExampleUnitTest { + + @Test + public void addition_isCorrect() throws Exception { + assertEquals(4, 2 + 2); + } +} diff --git a/camera/ios/Plugin.xcodeproj/project.pbxproj b/camera/ios/Plugin.xcodeproj/project.pbxproj new file mode 100644 index 000000000..fccfd5fcc --- /dev/null +++ b/camera/ios/Plugin.xcodeproj/project.pbxproj @@ -0,0 +1,573 @@ +// !$*UTF8*$! +{ + archiveVersion = 1; + classes = { + }; + objectVersion = 48; + objects = { + +/* Begin PBXBuildFile section */ + 03FC29A292ACC40490383A1F /* Pods_Plugin.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 3B2A61DA5A1F2DD4F959604D /* Pods_Plugin.framework */; }; + 20C0B05DCFC8E3958A738AF2 /* Pods_PluginTests.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = F6753A823D3815DB436415E3 /* Pods_PluginTests.framework */; }; + 50ADFF92201F53D600D50D53 /* Plugin.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 50ADFF88201F53D600D50D53 /* Plugin.framework */; }; + 50ADFF97201F53D600D50D53 /* CameraPluginTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 50ADFF96201F53D600D50D53 /* CameraPluginTests.swift */; }; + 50ADFF99201F53D600D50D53 /* CameraPlugin.h in Headers */ = {isa = PBXBuildFile; fileRef = 50ADFF8B201F53D600D50D53 /* CameraPlugin.h */; settings = {ATTRIBUTES = (Public, ); }; }; + 50ADFFA42020D75100D50D53 /* Capacitor.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 50ADFFA52020D75100D50D53 /* Capacitor.framework */; }; + 50ADFFA82020EE4F00D50D53 /* CameraPlugin.m in Sources */ = {isa = PBXBuildFile; fileRef = 50ADFFA72020EE4F00D50D53 /* CameraPlugin.m */; }; + 50E1A94820377CB70090CE1A /* CameraPlugin.swift in Sources */ = {isa = PBXBuildFile; fileRef = 50E1A94720377CB70090CE1A /* CameraPlugin.swift */; }; + 6276AAD3255B3E0E00097815 /* CameraExtensions.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6276AAD2255B3E0E00097815 /* CameraExtensions.swift */; }; + 6276AAD7255B3E1400097815 /* CameraTypes.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6276AAD6255B3E1400097815 /* CameraTypes.swift */; }; +/* End PBXBuildFile section */ + +/* Begin PBXContainerItemProxy section */ + 50ADFF93201F53D600D50D53 /* PBXContainerItemProxy */ = { + isa = PBXContainerItemProxy; + containerPortal = 50ADFF7F201F53D600D50D53 /* Project object */; + proxyType = 1; + remoteGlobalIDString = 50ADFF87201F53D600D50D53; + remoteInfo = Plugin; + }; +/* End PBXContainerItemProxy section */ + +/* Begin PBXFileReference section */ + 3B2A61DA5A1F2DD4F959604D /* Pods_Plugin.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = Pods_Plugin.framework; sourceTree = BUILT_PRODUCTS_DIR; }; + 50ADFF88201F53D600D50D53 /* Plugin.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = Plugin.framework; sourceTree = BUILT_PRODUCTS_DIR; }; + 50ADFF8B201F53D600D50D53 /* CameraPlugin.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = CameraPlugin.h; sourceTree = ""; }; + 50ADFF8C201F53D600D50D53 /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = ""; }; + 50ADFF91201F53D600D50D53 /* PluginTests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = PluginTests.xctest; sourceTree = BUILT_PRODUCTS_DIR; }; + 50ADFF96201F53D600D50D53 /* CameraPluginTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CameraPluginTests.swift; sourceTree = ""; }; + 50ADFF98201F53D600D50D53 /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = ""; }; + 50ADFFA52020D75100D50D53 /* Capacitor.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; path = Capacitor.framework; sourceTree = BUILT_PRODUCTS_DIR; }; + 50ADFFA72020EE4F00D50D53 /* CameraPlugin.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; path = CameraPlugin.m; sourceTree = ""; }; + 50E1A94720377CB70090CE1A /* CameraPlugin.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = CameraPlugin.swift; sourceTree = ""; }; + 5E23F77F099397094342571A /* Pods-Plugin.debug.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-Plugin.debug.xcconfig"; path = "Pods/Target Support Files/Pods-Plugin/Pods-Plugin.debug.xcconfig"; sourceTree = ""; }; + 6276AAD2255B3E0E00097815 /* CameraExtensions.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = CameraExtensions.swift; sourceTree = ""; }; + 6276AAD6255B3E1400097815 /* CameraTypes.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = CameraTypes.swift; sourceTree = ""; }; + 91781294A431A2A7CC6EB714 /* Pods-Plugin.release.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-Plugin.release.xcconfig"; path = "Pods/Target Support Files/Pods-Plugin/Pods-Plugin.release.xcconfig"; sourceTree = ""; }; + 96ED1B6440D6672E406C8D19 /* Pods-PluginTests.debug.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-PluginTests.debug.xcconfig"; path = "Pods/Target Support Files/Pods-PluginTests/Pods-PluginTests.debug.xcconfig"; sourceTree = ""; }; + F65BB2953ECE002E1EF3E424 /* Pods-PluginTests.release.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-PluginTests.release.xcconfig"; path = "Pods/Target Support Files/Pods-PluginTests/Pods-PluginTests.release.xcconfig"; sourceTree = ""; }; + F6753A823D3815DB436415E3 /* Pods_PluginTests.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = Pods_PluginTests.framework; sourceTree = BUILT_PRODUCTS_DIR; }; +/* End PBXFileReference section */ + +/* Begin PBXFrameworksBuildPhase section */ + 50ADFF84201F53D600D50D53 /* Frameworks */ = { + isa = PBXFrameworksBuildPhase; + buildActionMask = 2147483647; + files = ( + 50ADFFA42020D75100D50D53 /* Capacitor.framework in Frameworks */, + 03FC29A292ACC40490383A1F /* Pods_Plugin.framework in Frameworks */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; + 50ADFF8E201F53D600D50D53 /* Frameworks */ = { + isa = PBXFrameworksBuildPhase; + buildActionMask = 2147483647; + files = ( + 50ADFF92201F53D600D50D53 /* Plugin.framework in Frameworks */, + 20C0B05DCFC8E3958A738AF2 /* Pods_PluginTests.framework in Frameworks */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXFrameworksBuildPhase section */ + +/* Begin PBXGroup section */ + 50ADFF7E201F53D600D50D53 = { + isa = PBXGroup; + children = ( + 50ADFF8A201F53D600D50D53 /* Plugin */, + 50ADFF95201F53D600D50D53 /* PluginTests */, + 50ADFF89201F53D600D50D53 /* Products */, + 8C8E7744173064A9F6D438E3 /* Pods */, + A797B9EFA3DCEFEA1FBB66A9 /* Frameworks */, + ); + sourceTree = ""; + }; + 50ADFF89201F53D600D50D53 /* Products */ = { + isa = PBXGroup; + children = ( + 50ADFF88201F53D600D50D53 /* Plugin.framework */, + 50ADFF91201F53D600D50D53 /* PluginTests.xctest */, + ); + name = Products; + sourceTree = ""; + }; + 50ADFF8A201F53D600D50D53 /* Plugin */ = { + isa = PBXGroup; + children = ( + 50ADFF8B201F53D600D50D53 /* CameraPlugin.h */, + 50ADFFA72020EE4F00D50D53 /* CameraPlugin.m */, + 50E1A94720377CB70090CE1A /* CameraPlugin.swift */, + 6276AAD2255B3E0E00097815 /* CameraExtensions.swift */, + 6276AAD6255B3E1400097815 /* CameraTypes.swift */, + 50ADFF8C201F53D600D50D53 /* Info.plist */, + ); + path = Plugin; + sourceTree = ""; + }; + 50ADFF95201F53D600D50D53 /* PluginTests */ = { + isa = PBXGroup; + children = ( + 50ADFF96201F53D600D50D53 /* CameraPluginTests.swift */, + 50ADFF98201F53D600D50D53 /* Info.plist */, + ); + path = PluginTests; + sourceTree = ""; + }; + 8C8E7744173064A9F6D438E3 /* Pods */ = { + isa = PBXGroup; + children = ( + 5E23F77F099397094342571A /* Pods-Plugin.debug.xcconfig */, + 91781294A431A2A7CC6EB714 /* Pods-Plugin.release.xcconfig */, + 96ED1B6440D6672E406C8D19 /* Pods-PluginTests.debug.xcconfig */, + F65BB2953ECE002E1EF3E424 /* Pods-PluginTests.release.xcconfig */, + ); + name = Pods; + sourceTree = ""; + }; + A797B9EFA3DCEFEA1FBB66A9 /* Frameworks */ = { + isa = PBXGroup; + children = ( + 50ADFFA52020D75100D50D53 /* Capacitor.framework */, + 3B2A61DA5A1F2DD4F959604D /* Pods_Plugin.framework */, + F6753A823D3815DB436415E3 /* Pods_PluginTests.framework */, + ); + name = Frameworks; + sourceTree = ""; + }; +/* End PBXGroup section */ + +/* Begin PBXHeadersBuildPhase section */ + 50ADFF85201F53D600D50D53 /* Headers */ = { + isa = PBXHeadersBuildPhase; + buildActionMask = 2147483647; + files = ( + 50ADFF99201F53D600D50D53 /* CameraPlugin.h in Headers */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXHeadersBuildPhase section */ + +/* Begin PBXNativeTarget section */ + 50ADFF87201F53D600D50D53 /* Plugin */ = { + isa = PBXNativeTarget; + buildConfigurationList = 50ADFF9C201F53D600D50D53 /* Build configuration list for PBXNativeTarget "Plugin" */; + buildPhases = ( + AB5B3E54B4E897F32C2279DA /* [CP] Check Pods Manifest.lock */, + 50ADFF83201F53D600D50D53 /* Sources */, + 50ADFF84201F53D600D50D53 /* Frameworks */, + 50ADFF85201F53D600D50D53 /* Headers */, + 50ADFF86201F53D600D50D53 /* Resources */, + ); + buildRules = ( + ); + dependencies = ( + ); + name = Plugin; + productName = Plugin; + productReference = 50ADFF88201F53D600D50D53 /* Plugin.framework */; + productType = "com.apple.product-type.framework"; + }; + 50ADFF90201F53D600D50D53 /* PluginTests */ = { + isa = PBXNativeTarget; + buildConfigurationList = 50ADFF9F201F53D600D50D53 /* Build configuration list for PBXNativeTarget "PluginTests" */; + buildPhases = ( + 0596884F929ED6F1DE134961 /* [CP] Check Pods Manifest.lock */, + 50ADFF8D201F53D600D50D53 /* Sources */, + 50ADFF8E201F53D600D50D53 /* Frameworks */, + 50ADFF8F201F53D600D50D53 /* Resources */, + 8E97F58B69A94C6503FC9C85 /* [CP] Embed Pods Frameworks */, + ); + buildRules = ( + ); + dependencies = ( + 50ADFF94201F53D600D50D53 /* PBXTargetDependency */, + ); + name = PluginTests; + productName = PluginTests; + productReference = 50ADFF91201F53D600D50D53 /* PluginTests.xctest */; + productType = "com.apple.product-type.bundle.unit-test"; + }; +/* End PBXNativeTarget section */ + +/* Begin PBXProject section */ + 50ADFF7F201F53D600D50D53 /* Project object */ = { + isa = PBXProject; + attributes = { + LastSwiftUpdateCheck = 0920; + LastUpgradeCheck = 1160; + ORGANIZATIONNAME = "Max Lynch"; + TargetAttributes = { + 50ADFF87201F53D600D50D53 = { + CreatedOnToolsVersion = 9.2; + LastSwiftMigration = 1100; + ProvisioningStyle = Automatic; + }; + 50ADFF90201F53D600D50D53 = { + CreatedOnToolsVersion = 9.2; + LastSwiftMigration = 1100; + ProvisioningStyle = Automatic; + }; + }; + }; + buildConfigurationList = 50ADFF82201F53D600D50D53 /* Build configuration list for PBXProject "Plugin" */; + compatibilityVersion = "Xcode 8.0"; + developmentRegion = en; + hasScannedForEncodings = 0; + knownRegions = ( + en, + Base, + ); + mainGroup = 50ADFF7E201F53D600D50D53; + productRefGroup = 50ADFF89201F53D600D50D53 /* Products */; + projectDirPath = ""; + projectRoot = ""; + targets = ( + 50ADFF87201F53D600D50D53 /* Plugin */, + 50ADFF90201F53D600D50D53 /* PluginTests */, + ); + }; +/* End PBXProject section */ + +/* Begin PBXResourcesBuildPhase section */ + 50ADFF86201F53D600D50D53 /* Resources */ = { + isa = PBXResourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; + 50ADFF8F201F53D600D50D53 /* Resources */ = { + isa = PBXResourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXResourcesBuildPhase section */ + +/* Begin PBXShellScriptBuildPhase section */ + 0596884F929ED6F1DE134961 /* [CP] Check Pods Manifest.lock */ = { + isa = PBXShellScriptBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + inputPaths = ( + "${PODS_PODFILE_DIR_PATH}/Podfile.lock", + "${PODS_ROOT}/Manifest.lock", + ); + name = "[CP] Check Pods Manifest.lock"; + outputPaths = ( + "$(DERIVED_FILE_DIR)/Pods-PluginTests-checkManifestLockResult.txt", + ); + runOnlyForDeploymentPostprocessing = 0; + shellPath = /bin/sh; + shellScript = "diff \"${PODS_PODFILE_DIR_PATH}/Podfile.lock\" \"${PODS_ROOT}/Manifest.lock\" > /dev/null\nif [ $? != 0 ] ; then\n # print error to STDERR\n echo \"error: The sandbox is not in sync with the Podfile.lock. Run 'pod install' or update your CocoaPods installation.\" >&2\n exit 1\nfi\n# This output is used by Xcode 'outputs' to avoid re-running this script phase.\necho \"SUCCESS\" > \"${SCRIPT_OUTPUT_FILE_0}\"\n"; + showEnvVarsInLog = 0; + }; + 8E97F58B69A94C6503FC9C85 /* [CP] Embed Pods Frameworks */ = { + isa = PBXShellScriptBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + inputPaths = ( + "${PODS_ROOT}/Target Support Files/Pods-PluginTests/Pods-PluginTests-frameworks.sh", + "${BUILT_PRODUCTS_DIR}/Capacitor/Capacitor.framework", + "${BUILT_PRODUCTS_DIR}/CapacitorCordova/Cordova.framework", + ); + name = "[CP] Embed Pods Frameworks"; + outputPaths = ( + "${TARGET_BUILD_DIR}/${FRAMEWORKS_FOLDER_PATH}/Capacitor.framework", + "${TARGET_BUILD_DIR}/${FRAMEWORKS_FOLDER_PATH}/Cordova.framework", + ); + runOnlyForDeploymentPostprocessing = 0; + shellPath = /bin/sh; + shellScript = "\"${PODS_ROOT}/Target Support Files/Pods-PluginTests/Pods-PluginTests-frameworks.sh\"\n"; + showEnvVarsInLog = 0; + }; + AB5B3E54B4E897F32C2279DA /* [CP] Check Pods Manifest.lock */ = { + isa = PBXShellScriptBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + inputPaths = ( + "${PODS_PODFILE_DIR_PATH}/Podfile.lock", + "${PODS_ROOT}/Manifest.lock", + ); + name = "[CP] Check Pods Manifest.lock"; + outputPaths = ( + "$(DERIVED_FILE_DIR)/Pods-Plugin-checkManifestLockResult.txt", + ); + runOnlyForDeploymentPostprocessing = 0; + shellPath = /bin/sh; + shellScript = "diff \"${PODS_PODFILE_DIR_PATH}/Podfile.lock\" \"${PODS_ROOT}/Manifest.lock\" > /dev/null\nif [ $? != 0 ] ; then\n # print error to STDERR\n echo \"error: The sandbox is not in sync with the Podfile.lock. Run 'pod install' or update your CocoaPods installation.\" >&2\n exit 1\nfi\n# This output is used by Xcode 'outputs' to avoid re-running this script phase.\necho \"SUCCESS\" > \"${SCRIPT_OUTPUT_FILE_0}\"\n"; + showEnvVarsInLog = 0; + }; +/* End PBXShellScriptBuildPhase section */ + +/* Begin PBXSourcesBuildPhase section */ + 50ADFF83201F53D600D50D53 /* Sources */ = { + isa = PBXSourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + 50E1A94820377CB70090CE1A /* CameraPlugin.swift in Sources */, + 50ADFFA82020EE4F00D50D53 /* CameraPlugin.m in Sources */, + 6276AAD7255B3E1400097815 /* CameraTypes.swift in Sources */, + 6276AAD3255B3E0E00097815 /* CameraExtensions.swift in Sources */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; + 50ADFF8D201F53D600D50D53 /* Sources */ = { + isa = PBXSourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + 50ADFF97201F53D600D50D53 /* CameraPluginTests.swift in Sources */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXSourcesBuildPhase section */ + +/* Begin PBXTargetDependency section */ + 50ADFF94201F53D600D50D53 /* PBXTargetDependency */ = { + isa = PBXTargetDependency; + target = 50ADFF87201F53D600D50D53 /* Plugin */; + targetProxy = 50ADFF93201F53D600D50D53 /* PBXContainerItemProxy */; + }; +/* End PBXTargetDependency section */ + +/* Begin XCBuildConfiguration section */ + 50ADFF9A201F53D600D50D53 /* Debug */ = { + isa = XCBuildConfiguration; + buildSettings = { + ALWAYS_SEARCH_USER_PATHS = NO; + CLANG_ANALYZER_NONNULL = YES; + CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE; + CLANG_CXX_LANGUAGE_STANDARD = "gnu++14"; + CLANG_CXX_LIBRARY = "libc++"; + CLANG_ENABLE_MODULES = YES; + CLANG_ENABLE_OBJC_ARC = YES; + CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES; + CLANG_WARN_BOOL_CONVERSION = YES; + CLANG_WARN_COMMA = YES; + CLANG_WARN_CONSTANT_CONVERSION = YES; + CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES; + CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR; + CLANG_WARN_DOCUMENTATION_COMMENTS = YES; + CLANG_WARN_EMPTY_BODY = YES; + CLANG_WARN_ENUM_CONVERSION = YES; + CLANG_WARN_INFINITE_RECURSION = YES; + CLANG_WARN_INT_CONVERSION = YES; + CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES; + CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES; + CLANG_WARN_OBJC_LITERAL_CONVERSION = YES; + CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR; + CLANG_WARN_RANGE_LOOP_ANALYSIS = YES; + CLANG_WARN_STRICT_PROTOTYPES = YES; + CLANG_WARN_SUSPICIOUS_MOVE = YES; + CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE; + CLANG_WARN_UNREACHABLE_CODE = YES; + CLANG_WARN__DUPLICATE_METHOD_MATCH = YES; + CODE_SIGN_IDENTITY = "iPhone Developer"; + COPY_PHASE_STRIP = NO; + CURRENT_PROJECT_VERSION = 1; + DEBUG_INFORMATION_FORMAT = dwarf; + ENABLE_STRICT_OBJC_MSGSEND = YES; + ENABLE_TESTABILITY = YES; + FRAMEWORK_SEARCH_PATHS = ( + "\"${BUILT_PRODUCTS_DIR}/Capacitor\"", + "\"${BUILT_PRODUCTS_DIR}/CapacitorCordova\"", + ); + GCC_C_LANGUAGE_STANDARD = gnu11; + GCC_DYNAMIC_NO_PIC = NO; + GCC_NO_COMMON_BLOCKS = YES; + GCC_OPTIMIZATION_LEVEL = 0; + GCC_PREPROCESSOR_DEFINITIONS = ( + "DEBUG=1", + "$(inherited)", + ); + GCC_WARN_64_TO_32_BIT_CONVERSION = YES; + GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR; + GCC_WARN_UNDECLARED_SELECTOR = YES; + GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; + GCC_WARN_UNUSED_FUNCTION = YES; + GCC_WARN_UNUSED_VARIABLE = YES; + IPHONEOS_DEPLOYMENT_TARGET = 11.0; + MTL_ENABLE_DEBUG_INFO = YES; + ONLY_ACTIVE_ARCH = YES; + SDKROOT = iphoneos; + SWIFT_ACTIVE_COMPILATION_CONDITIONS = DEBUG; + SWIFT_OPTIMIZATION_LEVEL = "-Onone"; + VERSIONING_SYSTEM = "apple-generic"; + VERSION_INFO_PREFIX = ""; + }; + name = Debug; + }; + 50ADFF9B201F53D600D50D53 /* Release */ = { + isa = XCBuildConfiguration; + buildSettings = { + ALWAYS_SEARCH_USER_PATHS = NO; + CLANG_ANALYZER_NONNULL = YES; + CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE; + CLANG_CXX_LANGUAGE_STANDARD = "gnu++14"; + CLANG_CXX_LIBRARY = "libc++"; + CLANG_ENABLE_MODULES = YES; + CLANG_ENABLE_OBJC_ARC = YES; + CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES; + CLANG_WARN_BOOL_CONVERSION = YES; + CLANG_WARN_COMMA = YES; + CLANG_WARN_CONSTANT_CONVERSION = YES; + CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES; + CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR; + CLANG_WARN_DOCUMENTATION_COMMENTS = YES; + CLANG_WARN_EMPTY_BODY = YES; + CLANG_WARN_ENUM_CONVERSION = YES; + CLANG_WARN_INFINITE_RECURSION = YES; + CLANG_WARN_INT_CONVERSION = YES; + CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES; + CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES; + CLANG_WARN_OBJC_LITERAL_CONVERSION = YES; + CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR; + CLANG_WARN_RANGE_LOOP_ANALYSIS = YES; + CLANG_WARN_STRICT_PROTOTYPES = YES; + CLANG_WARN_SUSPICIOUS_MOVE = YES; + CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE; + CLANG_WARN_UNREACHABLE_CODE = YES; + CLANG_WARN__DUPLICATE_METHOD_MATCH = YES; + CODE_SIGN_IDENTITY = "iPhone Developer"; + COPY_PHASE_STRIP = NO; + CURRENT_PROJECT_VERSION = 1; + DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; + ENABLE_NS_ASSERTIONS = NO; + ENABLE_STRICT_OBJC_MSGSEND = YES; + FRAMEWORK_SEARCH_PATHS = ( + "\"${BUILT_PRODUCTS_DIR}/Capacitor\"", + "\"${BUILT_PRODUCTS_DIR}/CapacitorCordova\"", + ); + GCC_C_LANGUAGE_STANDARD = gnu11; + GCC_NO_COMMON_BLOCKS = YES; + GCC_WARN_64_TO_32_BIT_CONVERSION = YES; + GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR; + GCC_WARN_UNDECLARED_SELECTOR = YES; + GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; + GCC_WARN_UNUSED_FUNCTION = YES; + GCC_WARN_UNUSED_VARIABLE = YES; + IPHONEOS_DEPLOYMENT_TARGET = 11.0; + MTL_ENABLE_DEBUG_INFO = NO; + SDKROOT = iphoneos; + SWIFT_OPTIMIZATION_LEVEL = "-Owholemodule"; + VALIDATE_PRODUCT = YES; + VERSIONING_SYSTEM = "apple-generic"; + VERSION_INFO_PREFIX = ""; + }; + name = Release; + }; + 50ADFF9D201F53D600D50D53 /* Debug */ = { + isa = XCBuildConfiguration; + baseConfigurationReference = 5E23F77F099397094342571A /* Pods-Plugin.debug.xcconfig */; + buildSettings = { + CLANG_ENABLE_MODULES = YES; + CODE_SIGN_IDENTITY = ""; + CODE_SIGN_STYLE = Automatic; + DEFINES_MODULE = YES; + DYLIB_COMPATIBILITY_VERSION = 1; + DYLIB_CURRENT_VERSION = 1; + DYLIB_INSTALL_NAME_BASE = "@rpath"; + INFOPLIST_FILE = Plugin/Info.plist; + INSTALL_PATH = "$(LOCAL_LIBRARY_DIR)/Frameworks"; + IPHONEOS_DEPLOYMENT_TARGET = 11.0; + LD_RUNPATH_SEARCH_PATHS = "$(inherited) @executable_path/Frameworks @loader_path/Frameworks $(FRAMEWORK_SEARCH_PATHS)\n$(FRAMEWORK_SEARCH_PATHS)\n$(FRAMEWORK_SEARCH_PATHS)"; + ONLY_ACTIVE_ARCH = YES; + PRODUCT_BUNDLE_IDENTIFIER = com.getcapacitor.Plugin; + PRODUCT_NAME = "$(TARGET_NAME:c99extidentifier)"; + SKIP_INSTALL = YES; + SWIFT_OPTIMIZATION_LEVEL = "-Onone"; + SWIFT_VERSION = 5.0; + TARGETED_DEVICE_FAMILY = "1,2"; + }; + name = Debug; + }; + 50ADFF9E201F53D600D50D53 /* Release */ = { + isa = XCBuildConfiguration; + baseConfigurationReference = 91781294A431A2A7CC6EB714 /* Pods-Plugin.release.xcconfig */; + buildSettings = { + CLANG_ENABLE_MODULES = YES; + CODE_SIGN_IDENTITY = ""; + CODE_SIGN_STYLE = Automatic; + DEFINES_MODULE = YES; + DYLIB_COMPATIBILITY_VERSION = 1; + DYLIB_CURRENT_VERSION = 1; + DYLIB_INSTALL_NAME_BASE = "@rpath"; + INFOPLIST_FILE = Plugin/Info.plist; + INSTALL_PATH = "$(LOCAL_LIBRARY_DIR)/Frameworks"; + IPHONEOS_DEPLOYMENT_TARGET = 11.0; + LD_RUNPATH_SEARCH_PATHS = "$(inherited) @executable_path/Frameworks @loader_path/Frameworks $(FRAMEWORK_SEARCH_PATHS)"; + ONLY_ACTIVE_ARCH = NO; + PRODUCT_BUNDLE_IDENTIFIER = com.getcapacitor.Plugin; + PRODUCT_NAME = "$(TARGET_NAME:c99extidentifier)"; + SKIP_INSTALL = YES; + SWIFT_VERSION = 5.0; + TARGETED_DEVICE_FAMILY = "1,2"; + }; + name = Release; + }; + 50ADFFA0201F53D600D50D53 /* Debug */ = { + isa = XCBuildConfiguration; + baseConfigurationReference = 96ED1B6440D6672E406C8D19 /* Pods-PluginTests.debug.xcconfig */; + buildSettings = { + CODE_SIGN_STYLE = Automatic; + INFOPLIST_FILE = PluginTests/Info.plist; + LD_RUNPATH_SEARCH_PATHS = "$(inherited) @executable_path/Frameworks @loader_path/Frameworks"; + PRODUCT_BUNDLE_IDENTIFIER = com.getcapacitor.PluginTests; + PRODUCT_NAME = "$(TARGET_NAME)"; + SWIFT_VERSION = 5.0; + TARGETED_DEVICE_FAMILY = "1,2"; + }; + name = Debug; + }; + 50ADFFA1201F53D600D50D53 /* Release */ = { + isa = XCBuildConfiguration; + baseConfigurationReference = F65BB2953ECE002E1EF3E424 /* Pods-PluginTests.release.xcconfig */; + buildSettings = { + CODE_SIGN_STYLE = Automatic; + INFOPLIST_FILE = PluginTests/Info.plist; + LD_RUNPATH_SEARCH_PATHS = "$(inherited) @executable_path/Frameworks @loader_path/Frameworks"; + PRODUCT_BUNDLE_IDENTIFIER = com.getcapacitor.PluginTests; + PRODUCT_NAME = "$(TARGET_NAME)"; + SWIFT_VERSION = 5.0; + TARGETED_DEVICE_FAMILY = "1,2"; + }; + name = Release; + }; +/* End XCBuildConfiguration section */ + +/* Begin XCConfigurationList section */ + 50ADFF82201F53D600D50D53 /* Build configuration list for PBXProject "Plugin" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + 50ADFF9A201F53D600D50D53 /* Debug */, + 50ADFF9B201F53D600D50D53 /* Release */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; + 50ADFF9C201F53D600D50D53 /* Build configuration list for PBXNativeTarget "Plugin" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + 50ADFF9D201F53D600D50D53 /* Debug */, + 50ADFF9E201F53D600D50D53 /* Release */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; + 50ADFF9F201F53D600D50D53 /* Build configuration list for PBXNativeTarget "PluginTests" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + 50ADFFA0201F53D600D50D53 /* Debug */, + 50ADFFA1201F53D600D50D53 /* Release */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; +/* End XCConfigurationList section */ + }; + rootObject = 50ADFF7F201F53D600D50D53 /* Project object */; +} diff --git a/camera/ios/Plugin.xcodeproj/project.xcworkspace/contents.xcworkspacedata b/camera/ios/Plugin.xcodeproj/project.xcworkspace/contents.xcworkspacedata new file mode 100644 index 000000000..919434a62 --- /dev/null +++ b/camera/ios/Plugin.xcodeproj/project.xcworkspace/contents.xcworkspacedata @@ -0,0 +1,7 @@ + + + + + diff --git a/camera/ios/Plugin.xcodeproj/project.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist b/camera/ios/Plugin.xcodeproj/project.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist new file mode 100644 index 000000000..18d981003 --- /dev/null +++ b/camera/ios/Plugin.xcodeproj/project.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist @@ -0,0 +1,8 @@ + + + + + IDEDidComputeMac32BitWarning + + + diff --git a/camera/ios/Plugin.xcodeproj/xcshareddata/xcschemes/Plugin.xcscheme b/camera/ios/Plugin.xcodeproj/xcshareddata/xcschemes/Plugin.xcscheme new file mode 100644 index 000000000..303f2621b --- /dev/null +++ b/camera/ios/Plugin.xcodeproj/xcshareddata/xcschemes/Plugin.xcscheme @@ -0,0 +1,77 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/camera/ios/Plugin.xcodeproj/xcshareddata/xcschemes/PluginTests.xcscheme b/camera/ios/Plugin.xcodeproj/xcshareddata/xcschemes/PluginTests.xcscheme new file mode 100644 index 000000000..3d8c88d25 --- /dev/null +++ b/camera/ios/Plugin.xcodeproj/xcshareddata/xcschemes/PluginTests.xcscheme @@ -0,0 +1,68 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/camera/ios/Plugin.xcworkspace/contents.xcworkspacedata b/camera/ios/Plugin.xcworkspace/contents.xcworkspacedata new file mode 100644 index 000000000..afad624ec --- /dev/null +++ b/camera/ios/Plugin.xcworkspace/contents.xcworkspacedata @@ -0,0 +1,10 @@ + + + + + + + diff --git a/camera/ios/Plugin.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist b/camera/ios/Plugin.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist new file mode 100644 index 000000000..18d981003 --- /dev/null +++ b/camera/ios/Plugin.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist @@ -0,0 +1,8 @@ + + + + + IDEDidComputeMac32BitWarning + + + diff --git a/camera/ios/Plugin/CameraExtensions.swift b/camera/ios/Plugin/CameraExtensions.swift new file mode 100644 index 000000000..873ccb33f --- /dev/null +++ b/camera/ios/Plugin/CameraExtensions.swift @@ -0,0 +1,105 @@ +import UIKit +import Photos + +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" + } + } +} + +internal 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().requestImageData(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 + } +} + +internal 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 * targetHeight) / 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/camera/ios/Plugin/CameraPlugin.h b/camera/ios/Plugin/CameraPlugin.h new file mode 100644 index 000000000..f2bd9e0bb --- /dev/null +++ b/camera/ios/Plugin/CameraPlugin.h @@ -0,0 +1,10 @@ +#import + +//! Project version number for Plugin. +FOUNDATION_EXPORT double PluginVersionNumber; + +//! Project version string for Plugin. +FOUNDATION_EXPORT const unsigned char PluginVersionString[]; + +// In this header, you should import all the public headers of your framework using statements like #import + diff --git a/camera/ios/Plugin/CameraPlugin.m b/camera/ios/Plugin/CameraPlugin.m new file mode 100644 index 000000000..572765aa9 --- /dev/null +++ b/camera/ios/Plugin/CameraPlugin.m @@ -0,0 +1,8 @@ +#import +#import + +CAP_PLUGIN(CAPCameraPlugin, "Camera", + CAP_PLUGIN_METHOD(getPhoto, CAPPluginReturnPromise); + CAP_PLUGIN_METHOD(checkPermissions, CAPPluginReturnPromise); + CAP_PLUGIN_METHOD(requestPermissions, CAPPluginReturnPromise); +) diff --git a/camera/ios/Plugin/CameraPlugin.swift b/camera/ios/Plugin/CameraPlugin.swift new file mode 100644 index 000000000..44196472b --- /dev/null +++ b/camera/ios/Plugin/CameraPlugin.swift @@ -0,0 +1,404 @@ +import Foundation +import Capacitor +import Photos +import PhotosUI + +@objc(CAPCameraPlugin) +public class CameraPlugin: CAPPlugin { + private var call: CAPPluginCall? + private var settings = CameraSettings() + private let defaultSource = CameraSource.prompt + private let defaultDirection = CameraDirection.rear + + private var imageCounter = 0 + + @objc override public func checkPermissions(_ call: CAPPluginCall) { + 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 + } + call.resolve(result) + } + + @objc override public func requestPermissions(_ call: CAPPluginCall) { + // get the list of desired types, if passed + let typeList = call.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(call) + } + } + + @objc func getPhoto(_ call: CAPPluginCall) { + self.call = call + self.settings = cameraSettings(from: call) + + // Make sure they have all the necessary info.plist settings + if let missingUsageDescription = checkUsageDescriptions() { + CAPLog.print("⚡️ ", self.pluginId, "-", missingUsageDescription) + call.reject(missingUsageDescription) + bridge?.alert("Camera Error", "Missing required usage description. See console for more information") + return + } + + DispatchQueue.main.async { + switch self.settings.source { + case .prompt: + self.showPrompt() + case .camera: + self.showCamera() + case .photos: + 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 call: CAPPluginCall) -> CameraSettings { + var settings = CameraSettings() + settings.jpegQuality = min(abs(CGFloat(call.getFloat("quality") ?? 100.0)) / 100.0, 1.0) + settings.allowEditing = call.getBool("allowEditing") ?? false + settings.source = CameraSource(rawValue: call.getString("source") ?? defaultSource.rawValue) ?? defaultSource + settings.direction = CameraDirection(rawValue: call.getString("direction") ?? defaultDirection.rawValue) ?? defaultDirection + if let typeString = call.getString("resultType"), let type = CameraResultType(rawValue: typeString) { + settings.resultType = type + } + settings.saveToGallery = call.getBool("saveToGallery") ?? false + + // Get the new image dimensions if provided + settings.width = CGFloat(call.getInt("width") ?? 0) + settings.height = CGFloat(call.getInt("height") ?? 0) + if settings.width > 0 || settings.height > 0 { + // We resize only if a dimension was provided + settings.shouldResize = true + } + settings.shouldCorrectOrientation = call.getBool("correctOrientation") ?? true + settings.userPromptText = CameraPromptText(title: call.getString("promptLabelHeader"), + photoAction: call.getString("promptLabelPhoto"), + cameraAction: call.getString("promptLabelPicture"), + cancelAction: call.getString("promptLabelCancel")) + if let styleString = call.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.call?.reject("User cancelled photos app") + } + + public func popoverPresentationControllerDidDismissPopover(_ popoverPresentationController: UIPopoverPresentationController) { + self.call?.reject("User cancelled photos app") + } + + public func presentationControllerDidDismiss(_ presentationController: UIPresentationController) { + self.call?.reject("User cancelled photos app") + } + + public func imagePickerController(_ picker: UIImagePickerController, didFinishPickingMediaWithInfo info: [UIImagePickerController.InfoKey: Any]) { + picker.dismiss(animated: true, completion: nil) + if let processedImage = processImage(from: info) { + returnProcessedImage(processedImage) + } else { + self.call?.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.call?.reject("User cancelled photos app") + return + } + guard result.itemProvider.canLoadObject(ofClass: UIImage.self) else { + self.call?.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 let processedImage = self?.processedImage(from: image, with: asset?.imageData) { + self?.returnProcessedImage(processedImage) + return + } + } + self?.call?.reject("Error loading image") + } + } +} + +private extension CameraPlugin { + func returnProcessedImage(_ processedImage: ProcessedImage) { + guard let jpeg = processedImage.generateJPEG(with: settings.jpegQuality) else { + self.call?.reject("Unable to convert image to jpeg") + return + } + + if settings.resultType == CameraResultType.base64 { + call?.resolve([ + "base64String": jpeg.base64EncodedString(), + "exif": processedImage.exifData, + "format": "jpeg" + ]) + } else if settings.resultType == CameraResultType.dataURL { + call?.resolve([ + "dataUrl": "data:image/jpeg;base64," + jpeg.base64EncodedString(), + "exif": processedImage.exifData, + "format": "jpeg" + ]) + } else if settings.resultType == CameraResultType.uri { + guard let fileURL = try? saveTemporaryImage(jpeg), + let webURL = bridge?.portablePath(fromLocalURL: fileURL) else { + call?.reject("Unable to get portable path to file") + return + } + call?.resolve([ + "path": fileURL.path, + "exif": processedImage.exifData, + "webPath": webURL.path, + "format": "jpeg" + ]) + } + } + + 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?.call?.reject("User cancelled photos app") + })) + self.setCenteredPopover(alert) + self.bridge?.viewController?.present(alert, animated: true, completion: nil) + } + + func showCamera() { + // check if we have a camera + if (bridge?.isSimEnvironment ?? false) || !UIImagePickerController.isSourceTypeAvailable(UIImagePickerController.SourceType.camera) { + CAPLog.print("⚡️ ", self.pluginId, "-", "Camera not available in simulator") + bridge?.alert("Camera Error", "Camera not available in Simulator") + call?.reject("Camera not available while running in Simulator") + return + } + // check for permission + let authStatus = AVCaptureDevice.authorizationStatus(for: .video) + if authStatus == .restricted || authStatus == .denied { + call?.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?.call?.reject("User denied access to camera") + } + } + } + + func showPhotos() { + // check for permission + let authStatus = PHPhotoLibrary.authorizationStatus() + if authStatus == .restricted || authStatus == .denied { + call?.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?.call?.reject("User denied access to photos") + } + }) + } + } + + 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 + setCenteredPopover(picker) + } + bridge?.viewController?.present(picker, animated: true, completion: nil) + } + + func presentSystemAppropriateImagePicker() { + if #available(iOS 14, *) { + presentPhotoPicker() + } else { + presentImagePicker() + } + } + + 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 + setCenteredPopover(picker) + } + bridge?.viewController?.present(picker, animated: true, completion: nil) + } + + @available(iOS 14, *) + func presentPhotoPicker() { + var configuration = PHPickerConfiguration(photoLibrary: PHPhotoLibrary.shared()) + configuration.selectionLimit = 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 + setCenteredPopover(picker) + } + bridge?.viewController?.present(picker, animated: true, completion: nil) + } + + 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 + } + + 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 + let result = processedImage(from: image, with: metadata) + // conditionally save the image + if settings.saveToGallery && (flags.contains(.edited) == true || flags.contains(.gallery) == false) { + UIImageWriteToSavedPhotosAlbum(result.image, nil, nil, nil) + } + return result + } + + 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 + } +} diff --git a/camera/ios/Plugin/CameraTypes.swift b/camera/ios/Plugin/CameraTypes.swift new file mode 100644 index 000000000..1b7daf865 --- /dev/null +++ b/camera/ios/Plugin/CameraTypes.swift @@ -0,0 +1,141 @@ +import UIKit + +// MARK: - Public + +public enum CameraSource: String { + case prompt + case camera + case photos +} + +public enum CameraDirection: String { + case rear + case 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 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 + } +} diff --git a/camera/ios/Plugin/Info.plist b/camera/ios/Plugin/Info.plist new file mode 100644 index 000000000..1007fd9dd --- /dev/null +++ b/camera/ios/Plugin/Info.plist @@ -0,0 +1,24 @@ + + + + + CFBundleDevelopmentRegion + $(DEVELOPMENT_LANGUAGE) + CFBundleExecutable + $(EXECUTABLE_NAME) + CFBundleIdentifier + $(PRODUCT_BUNDLE_IDENTIFIER) + CFBundleInfoDictionaryVersion + 6.0 + CFBundleName + $(PRODUCT_NAME) + CFBundlePackageType + FMWK + CFBundleShortVersionString + 1.0 + CFBundleVersion + $(CURRENT_PROJECT_VERSION) + NSPrincipalClass + + + diff --git a/camera/ios/PluginTests/CameraPluginTests.swift b/camera/ios/PluginTests/CameraPluginTests.swift new file mode 100644 index 000000000..9e3487a04 --- /dev/null +++ b/camera/ios/PluginTests/CameraPluginTests.swift @@ -0,0 +1,14 @@ +import XCTest +@testable import Plugin + +class CameraTests: XCTestCase { + override func setUp() { + super.setUp() + // Put setup code here. This method is called before the invocation of each test method in the class. + } + + override func tearDown() { + // Put teardown code here. This method is called after the invocation of each test method in the class. + super.tearDown() + } +} diff --git a/camera/ios/PluginTests/Info.plist b/camera/ios/PluginTests/Info.plist new file mode 100644 index 000000000..6c40a6cd0 --- /dev/null +++ b/camera/ios/PluginTests/Info.plist @@ -0,0 +1,22 @@ + + + + + CFBundleDevelopmentRegion + $(DEVELOPMENT_LANGUAGE) + CFBundleExecutable + $(EXECUTABLE_NAME) + CFBundleIdentifier + $(PRODUCT_BUNDLE_IDENTIFIER) + CFBundleInfoDictionaryVersion + 6.0 + CFBundleName + $(PRODUCT_NAME) + CFBundlePackageType + BNDL + CFBundleShortVersionString + 1.0 + CFBundleVersion + 1 + + diff --git a/camera/ios/Podfile b/camera/ios/Podfile new file mode 100644 index 000000000..350751435 --- /dev/null +++ b/camera/ios/Podfile @@ -0,0 +1,16 @@ +platform :ios, '11.0' + +def capacitor_pods + # Comment the next line if you're not using Swift and don't want to use dynamic frameworks + use_frameworks! + pod 'Capacitor', :path => '../node_modules/@capacitor/ios' + pod 'CapacitorCordova', :path => '../node_modules/@capacitor/ios' +end + +target 'Plugin' do + capacitor_pods +end + +target 'PluginTests' do + capacitor_pods +end diff --git a/camera/package.json b/camera/package.json new file mode 100644 index 000000000..73472a537 --- /dev/null +++ b/camera/package.json @@ -0,0 +1,80 @@ +{ + "name": "@capacitor/camera", + "version": "0.0.1", + "description": "The Camera API provides the ability to take a photo with the camera or choose an existing one from the photo album.", + "main": "dist/esm/index.js", + "types": "dist/esm/index.d.ts", + "unpkg": "dist/plugin.js", + "files": [ + "android/src/main/", + "android/build.gradle", + "dist/", + "ios/Plugin/", + "CapacitorCamera.podspec" + ], + "author": "Ionic ", + "license": "MIT", + "repository": { + "type": "git", + "url": "git+https://github.com/ionic-team/capacitor-plugins.git" + }, + "bugs": { + "url": "https://github.com/ionic-team/capacitor-plugins/issues" + }, + "keywords": [ + "capacitor", + "plugin", + "native" + ], + "scripts": { + "verify": "npm run verify:ios && npm run verify:android && npm run verify:web", + "verify:ios": "cd ios && pod install && xcodebuild -workspace Plugin.xcworkspace -scheme Plugin && cd ..", + "verify:android": "cd android && ./gradlew clean build test && cd ..", + "verify:web": "npm run build", + "lint": "npm run eslint && npm run prettier -- --check && npm run swiftlint -- lint", + "fmt": "npm run eslint -- --fix && npm run prettier -- --write && npm run swiftlint -- autocorrect --format", + "eslint": "eslint . --ext ts", + "prettier": "prettier \"**/*.{css,html,ts,js,java}\"", + "swiftlint": "node-swiftlint", + "docgen": "docgen --api CameraPlugin --output-readme README.md --output-json dist/docs.json", + "build": "npm run clean && npm run docgen && tsc && rollup -c rollup.config.js", + "clean": "rimraf ./dist", + "watch": "tsc --watch", + "prepublishOnly": "npm run build" + }, + "devDependencies": { + "@capacitor/android": "^3.0.0-alpha.10", + "@capacitor/core": "^3.0.0-alpha.9", + "@capacitor/docgen": "0.0.14", + "@capacitor/ios": "^3.0.0-alpha.9", + "@ionic/eslint-config": "^0.3.0", + "@ionic/prettier-config": "~1.0.1", + "@ionic/swiftlint-config": "^1.1.2", + "eslint": "^7.11.0", + "prettier": "~2.2.0", + "prettier-plugin-java": "~1.0.0", + "rimraf": "^3.0.0", + "rollup": "^2.29.0", + "swiftlint": "^1.0.1", + "typescript": "~4.0.3" + }, + "peerDependencies": { + "@capacitor/core": "^3.0.0-alpha.9" + }, + "prettier": "@ionic/prettier-config", + "swiftlint": "@ionic/swiftlint-config", + "eslintConfig": { + "extends": "@ionic/eslint-config/recommended" + }, + "capacitor": { + "ios": { + "src": "ios" + }, + "android": { + "src": "android" + } + }, + "publishConfig": { + "access": "public" + } +} diff --git a/camera/rollup.config.js b/camera/rollup.config.js new file mode 100644 index 000000000..9f8048847 --- /dev/null +++ b/camera/rollup.config.js @@ -0,0 +1,14 @@ +export default { + input: 'dist/esm/index.js', + output: { + file: 'dist/plugin.js', + format: 'iife', + name: 'capacitorCamera', + globals: { + '@capacitor/core': 'capacitorExports', + }, + sourcemap: true, + inlineDynamicImports: true, + }, + external: ['@capacitor/core'], +}; diff --git a/camera/src/definitions.ts b/camera/src/definitions.ts new file mode 100644 index 000000000..7c5906662 --- /dev/null +++ b/camera/src/definitions.ts @@ -0,0 +1,224 @@ +import type { PermissionState } from '@capacitor/core'; + +export type CameraPermissionState = PermissionState | 'limited'; + +export type CameraPermissionType = 'camera' | 'photos'; + +export interface CameraPermissionStatus { + camera: CameraPermissionState; + photos: CameraPermissionState; +} + +export interface CameraPluginPermissions { + permissions: CameraPermissionType[]; +} + +export interface CameraPlugin { + /** + * Prompt the user to pick a photo from an album, or take a new photo + * with the camera. + * + * @since 1.0.0 + */ + getPhoto(options: CameraOptions): Promise; + + /** + * Check camera and photo album permissions + * + * @since 1.0.0 + */ + checkPermissions(): Promise; + + /** + * Request camera and photo album permissions + * + * @since 1.0.0 + */ + requestPermissions( + permissions?: CameraPluginPermissions, + ): Promise; +} + +export interface CameraOptions { + /** + * The quality of image to return as JPEG, from 0-100 + * + * @since 1.0.0 + */ + quality?: number; + /** + * Whether to allow the user to crop or make small edits (platform specific) + * + * @since 1.0.0 + */ + allowEditing?: boolean; + /** + * How the data should be returned. Currently, only 'Base64', 'DataUrl' or 'Uri' is supported + * + * @since 1.0.0 + */ + resultType: CameraResultType; + /** + * Whether to save the photo to the gallery. + * If the photo was picked from the gallery, it will only be saved if edited. + * @default: false + * + * @since 1.0.0 + */ + saveToGallery?: boolean; + /** + * The width of the saved image + * + * @since 1.0.0 + */ + width?: number; + /** + * The height of the saved image + * + * @since 1.0.0 + */ + height?: number; + /** + * Whether to preserve the aspect ratio of the image. + * If this flag is true, the width and height will be used as max values + * and the aspect ratio will be preserved. This is only relevant when + * both a width and height are passed. When only width or height is provided + * the aspect ratio is always preserved (and this option is a no-op). + * + * A future major version will change this behavior to be default, + * and may also remove this option altogether. + * @default: false + * + * @since 1.0.0 + */ + preserveAspectRatio?: boolean; + /** + * Whether to automatically rotate the image "up" to correct for orientation + * in portrait mode + * @default: true + * + * @since 1.0.0 + */ + correctOrientation?: boolean; + /** + * The source to get the photo from. By default this prompts the user to select + * either the photo album or take a photo. + * @default: CameraSource.prompt + * + * @since 1.0.0 + */ + source?: CameraSource; + /** + * iOS and Web only: The camera direction. + * @default: CameraDirection.rear + * + * @since 1.0.0 + */ + direction?: CameraDirection; + + /** + * iOS only: The presentation style of the Camera. + * @default: 'fullscreen' + * + * @since 1.0.0 + */ + presentationStyle?: 'fullscreen' | 'popover'; + + /** + * Web only: Whether to use the PWA Element experience or file input. The + * default is to use PWA Elements if installed and fall back to file input. + * To always use file input, set this to `true`. + * + * Learn more about PWA Elements: https://capacitorjs.com/docs/pwa-elements + * + * @since 1.0.0 + */ + webUseInput?: boolean; + + /** + * Text value to use when displaying the prompt. + * iOS only: The title of the action sheet. + * @default: 'Photo' + * + * @since 1.0.0 + * + */ + promptLabelHeader?: string; + + /** + * Text value to use when displaying the prompt. + * iOS only: The label of the 'cancel' button. + * @default: 'Cancel' + * + * @since 1.0.0 + */ + promptLabelCancel?: string; + + /** + * Text value to use when displaying the prompt. + * The label of the button to select a saved image. + * @default: 'From Photos' + * + * @since 1.0.0 + */ + promptLabelPhoto?: string; + + /** + * Text value to use when displaying the prompt. + * The label of the button to open the camera. + * @default: 'Take Picture' + * + * @since 1.0.0 + */ + promptLabelPicture?: string; +} + +export interface CameraPhoto { + /** + * The base64 encoded string representation of the image, if using CameraResultType.Base64. + * + * @since 1.0.0 + */ + base64String?: string; + /** + * The url starting with 'data:image/jpeg;base64,' and the base64 encoded string representation of the image, if using CameraResultType.DataUrl. + * + * @since 1.0.0 + */ + dataUrl?: string; + /** + * If using CameraResultType.Uri, the path will contain a full, + * platform-specific file URL that can be read later using the Filsystem API. + * + * @since 1.0.0 + */ + path?: string; + /** + * webPath returns a path that can be used to set the src attribute of an image for efficient + * loading and rendering. + * + * @since 1.0.0 + */ + webPath?: string; + /** + * Exif data, if any, retrieved from the image + * + * @since 1.0.0 + */ + exif?: any; + /** + * The format of the image, ex: jpeg, png, gif. + * + * iOS and Android only support jpeg. + * Web supports jpeg and png. gif is only supported if using file input. + * + * @since 1.0.0 + */ + format: string; +} + +export type CameraSource = 'prompt' | 'camera' | 'photos'; + +export type CameraDirection = 'rear' | 'front'; + +export type CameraResultType = 'uri' | 'base64' | 'dataUrl'; diff --git a/camera/src/index.ts b/camera/src/index.ts new file mode 100644 index 000000000..a9978ddef --- /dev/null +++ b/camera/src/index.ts @@ -0,0 +1,23 @@ +import { registerPlugin } from '@capacitor/core'; + +import type { CameraPlugin } from './definitions'; +import { + CameraOptions, + CameraDirection, + CameraPhoto, + CameraResultType, + CameraSource, +} from './definitions'; + +const Camera = registerPlugin('Camera', { + web: () => import('./web').then(m => new m.CameraWeb()), +}); + +export { + Camera, + CameraOptions, + CameraDirection, + CameraPhoto, + CameraResultType, + CameraSource, +}; diff --git a/camera/src/web.ts b/camera/src/web.ts new file mode 100644 index 000000000..b74df8e9c --- /dev/null +++ b/camera/src/web.ts @@ -0,0 +1,183 @@ +import { WebPlugin, CapacitorException } from '@capacitor/core'; + +import type { + CameraPlugin, + CameraPhoto, + CameraOptions, + CameraPermissionStatus, +} from './definitions'; + +export class CameraWeb extends WebPlugin implements CameraPlugin { + async getPhoto(options: CameraOptions): Promise { + // eslint-disable-next-line no-async-promise-executor + return new Promise(async (resolve, reject) => { + if (options.webUseInput) { + this.fileInputExperience(options, resolve); + } else { + if (customElements.get('pwa-camera-modal')) { + const cameraModal: any = document.createElement('pwa-camera-modal'); + document.body.appendChild(cameraModal); + try { + await cameraModal.componentOnReady(); + cameraModal.addEventListener('onPhoto', async (e: any) => { + const photo = e.detail; + + if (photo === null) { + reject(new CapacitorException('User cancelled photos app')); + } else if (photo instanceof Error) { + reject(photo); + } else { + resolve(await this._getCameraPhoto(photo, options)); + } + + cameraModal.dismiss(); + document.body.removeChild(cameraModal); + }); + + cameraModal.present(); + } catch (e) { + this.fileInputExperience(options, resolve); + } + } else { + console.error( + `Unable to load PWA Element 'pwa-camera-modal'. See the docs: https://capacitorjs.com/docs/pwa-elements.`, + ); + this.fileInputExperience(options, resolve); + } + } + }); + } + + private fileInputExperience(options: CameraOptions, resolve: any) { + let input = document.querySelector( + '#_capacitor-camera-input', + ) as HTMLInputElement; + + const cleanup = () => { + input.parentNode?.removeChild(input); + }; + + if (!input) { + input = document.createElement('input') as HTMLInputElement; + input.id = '_capacitor-camera-input'; + input.type = 'file'; + document.body.appendChild(input); + } + + input.accept = 'image/*'; + (input as any).capture = true; + + if (options.source === 'photos' || options.source === 'prompt') { + input.removeAttribute('capture'); + } else if (options.direction === 'front') { + (input as any).capture = 'user'; + } else if (options.direction === 'rear') { + (input as any).capture = 'environment'; + } + + input.addEventListener('change', (_e: any) => { + const file = input.files![0]; + let format = 'jpeg'; + + if (file.type === 'image/png') { + format = 'png'; + } else if (file.type === 'image/gif') { + format = 'gif'; + } + + if (options.resultType === 'dataUrl' || options.resultType === 'base64') { + const reader = new FileReader(); + + reader.addEventListener('load', () => { + if (options.resultType === 'dataUrl') { + resolve({ + dataUrl: reader.result, + format, + } as CameraPhoto); + } else if (options.resultType === 'base64') { + const b64 = (reader.result as string).split(',')[1]; + resolve({ + base64String: b64, + format, + } as CameraPhoto); + } + + cleanup(); + }); + + reader.readAsDataURL(file); + } else { + resolve({ + webPath: URL.createObjectURL(file), + format: format, + }); + cleanup(); + } + }); + + input.click(); + } + + private _getCameraPhoto(photo: Blob, options: CameraOptions) { + return new Promise((resolve, reject) => { + const reader = new FileReader(); + const format = photo.type.split('/')[1]; + if (options.resultType === 'uri') { + resolve({ + webPath: URL.createObjectURL(photo), + format: format, + }); + } else { + reader.readAsDataURL(photo); + reader.onloadend = () => { + const r = reader.result as string; + if (options.resultType === 'dataUrl') { + resolve({ + dataUrl: r, + format: format, + }); + } else { + resolve({ + base64String: r.split(',')[1], + format: format, + }); + } + }; + reader.onerror = e => { + reject(e); + }; + } + }); + } + + async checkPermissions(): Promise { + if (typeof navigator === 'undefined' || !navigator.permissions) { + throw this.unavailable('Permissions API not available in this browser'); + } + + try { + // https://developer.mozilla.org/en-US/docs/Web/API/Permissions/query + // the specific permissions that are supported varies among browsers that implement the + // permissions API, so we need a try/catch in case 'camera' is invalid + const permission = await window.navigator.permissions.query({ + name: 'camera', + }); + return { + camera: permission.state, + photos: 'granted', + }; + } catch { + throw this.unavailable( + 'Camera permissions are not available in this browser', + ); + } + } + + async requestPermissions(): Promise { + throw this.unimplemented('Not implemented on web.'); + } +} + +const Camera = new CameraWeb(); + +export { Camera }; diff --git a/camera/tsconfig.json b/camera/tsconfig.json new file mode 100644 index 000000000..3bb999d96 --- /dev/null +++ b/camera/tsconfig.json @@ -0,0 +1,19 @@ +{ + "compilerOptions": { + "allowUnreachableCode": false, + "declaration": true, + "esModuleInterop": true, + "lib": ["dom"], + "module": "esnext", + "moduleResolution": "node", + "noFallthroughCasesInSwitch": true, + "noUnusedLocals": true, + "noUnusedParameters": true, + "outDir": "dist/esm", + "pretty": true, + "sourceMap": true, + "strict": true, + "target": "es2017" + }, + "files": ["src/index.ts"] +} diff --git a/lerna.json b/lerna.json index 1baa3893f..e7925c906 100644 --- a/lerna.json +++ b/lerna.json @@ -4,6 +4,7 @@ "app", "app-launcher", "browser", + "camera", "clipboard", "device", "dialog",