feat(camera): add plugin for Android and iOS

This commit is contained in:
Lucas Nogueira
2023-02-17 17:38:04 -03:00
parent 22f987bf24
commit 2f3dde35b3
105 changed files with 8317 additions and 0 deletions
@@ -0,0 +1,24 @@
package app.tauri.camera
import androidx.test.platform.app.InstrumentationRegistry
import androidx.test.ext.junit.runners.AndroidJUnit4
import org.junit.Test
import org.junit.runner.RunWith
import org.junit.Assert.*
/**
* Instrumented test, which will execute on an Android device.
*
* See [testing documentation](http://d.android.com/tools/testing).
*/
@RunWith(AndroidJUnit4::class)
class ExampleInstrumentedTest {
@Test
fun useAppContext() {
// Context of the app under test.
val appContext = InstrumentationRegistry.getInstrumentation().targetContext
assertEquals("app.tauri.camera", appContext.packageName)
}
}
@@ -0,0 +1,4 @@
<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
</manifest>
@@ -0,0 +1,102 @@
package app.tauri.camera
import android.annotation.SuppressLint
import android.app.Dialog
import android.content.DialogInterface
import android.graphics.Color
import android.view.View
import android.widget.LinearLayout
import android.widget.TextView
import androidx.coordinatorlayout.widget.CoordinatorLayout
import com.google.android.material.bottomsheet.BottomSheetBehavior
import com.google.android.material.bottomsheet.BottomSheetBehavior.BottomSheetCallback
import com.google.android.material.bottomsheet.BottomSheetDialogFragment
class CameraBottomSheetDialogFragment : BottomSheetDialogFragment() {
fun interface BottomSheetOnSelectedListener {
fun onSelected(index: Int)
}
fun interface BottomSheetOnCanceledListener {
fun onCanceled()
}
private var selectedListener: BottomSheetOnSelectedListener? = null
private var canceledListener: BottomSheetOnCanceledListener? = null
private var options: List<String>? = null
private var title: String? = null
fun setTitle(title: String?) {
this.title = title
}
fun setOptions(
options: List<String>?,
selectedListener: BottomSheetOnSelectedListener,
canceledListener: BottomSheetOnCanceledListener
) {
this.options = options
this.selectedListener = selectedListener
this.canceledListener = canceledListener
}
override fun onCancel(dialog: DialogInterface) {
super.onCancel(dialog)
if (canceledListener != null) {
canceledListener!!.onCanceled()
}
}
private val mBottomSheetBehaviorCallback: BottomSheetCallback = object : BottomSheetCallback() {
override fun onStateChanged(bottomSheet: View, newState: Int) {
if (newState == BottomSheetBehavior.STATE_HIDDEN) {
dismiss()
}
}
override fun onSlide(bottomSheet: View, slideOffset: Float) {}
}
@SuppressLint("RestrictedApi")
override fun setupDialog(dialog: Dialog, style: Int) {
super.setupDialog(dialog, style)
if (options == null || options!!.size == 0) {
return
}
val scale = resources.displayMetrics.density
val layoutPaddingDp16 = 16.0f
val layoutPaddingDp12 = 12.0f
val layoutPaddingDp8 = 8.0f
val layoutPaddingPx16 = (layoutPaddingDp16 * scale + 0.5f).toInt()
val layoutPaddingPx12 = (layoutPaddingDp12 * scale + 0.5f).toInt()
val layoutPaddingPx8 = (layoutPaddingDp8 * scale + 0.5f).toInt()
val parentLayout = CoordinatorLayout(requireContext())
val layout = LinearLayout(context)
layout.orientation = LinearLayout.VERTICAL
layout.setPadding(layoutPaddingPx16, layoutPaddingPx16, layoutPaddingPx16, layoutPaddingPx16)
val ttv = TextView(context)
ttv.setTextColor(Color.parseColor("#757575"))
ttv.setPadding(layoutPaddingPx8, layoutPaddingPx8, layoutPaddingPx8, layoutPaddingPx8)
ttv.text = title
layout.addView(ttv)
for (i in options!!.indices) {
val tv = TextView(context)
tv.setTextColor(Color.parseColor("#000000"))
tv.setPadding(layoutPaddingPx12, layoutPaddingPx12, layoutPaddingPx12, layoutPaddingPx12)
tv.text = options!![i]
tv.setOnClickListener {
if (selectedListener != null) {
selectedListener!!.onSelected(i)
}
dismiss()
}
layout.addView(tv)
}
parentLayout.addView(layout.rootView)
dialog.setContentView(parentLayout.rootView)
val params = (parentLayout.parent as View).layoutParams as CoordinatorLayout.LayoutParams
val behavior = params.behavior
if (behavior != null && behavior is BottomSheetBehavior<*>) {
behavior.addBottomSheetCallback(mBottomSheetBehaviorCallback)
}
}
}
@@ -0,0 +1,827 @@
package app.tauri.camera
import android.Manifest
import android.app.Activity
import android.content.*
import android.content.pm.PackageManager
import android.content.pm.ResolveInfo
import android.graphics.Bitmap
import android.graphics.BitmapFactory
import android.net.Uri
import android.os.Build
import android.os.Bundle
import android.os.Environment
import android.os.Parcelable
import android.provider.MediaStore
import android.util.Base64
import androidx.activity.result.ActivityResult
import androidx.appcompat.app.AppCompatActivity
import androidx.core.content.FileProvider
import androidx.exifinterface.media.ExifInterface.*
import app.tauri.*
import app.tauri.annotation.*
import app.tauri.plugin.*
import org.json.JSONException
import java.io.*
import java.util.*
import java.util.concurrent.Executor
import java.util.concurrent.Executors
enum class CameraSource(val source: String) {
PROMPT("PROMPT"), CAMERA("CAMERA"), PHOTOS("PHOTOS");
}
enum class CameraResultType(val type: String) {
BASE64("base64"), URI("uri"), DATAURL("dataUrl");
}
class CameraSettings {
var resultType: CameraResultType = CameraResultType.BASE64
var quality = DEFAULT_QUALITY
var isShouldResize = false
var isShouldCorrectOrientation = DEFAULT_CORRECT_ORIENTATION
var isSaveToGallery = DEFAULT_SAVE_IMAGE_TO_GALLERY
var isAllowEditing = false
var width = 0
var height = 0
var source: CameraSource = CameraSource.PROMPT
companion object {
const val DEFAULT_QUALITY = 90
const val DEFAULT_SAVE_IMAGE_TO_GALLERY = false
const val DEFAULT_CORRECT_ORIENTATION = true
}
}
@TauriPlugin(
permissions = [
Permission(strings = [Manifest.permission.CAMERA], alias = "camera"),
Permission(
strings = [Manifest.permission.READ_EXTERNAL_STORAGE, Manifest.permission.WRITE_EXTERNAL_STORAGE],
alias = "photos"
)]
)
class CameraPlugin(private val activity: Activity): Plugin(activity) {
// Permission alias constants
val CAMERA = "camera"
val PHOTOS = "photos"
// Message constants
private val INVALID_RESULT_TYPE_ERROR = "Invalid resultType option"
private val PERMISSION_DENIED_ERROR_CAMERA = "User denied access to camera"
private val PERMISSION_DENIED_ERROR_PHOTOS = "User denied access to photos"
private val NO_CAMERA_ERROR = "Device doesn't have a camera available"
private val NO_CAMERA_ACTIVITY_ERROR = "Unable to resolve camera activity"
private val NO_PHOTO_ACTIVITY_ERROR = "Unable to resolve photo activity"
private val IMAGE_FILE_SAVE_ERROR = "Unable to create photo on disk"
private val IMAGE_PROCESS_NO_FILE_ERROR = "Unable to process image, file not found on disk"
private val UNABLE_TO_PROCESS_IMAGE = "Unable to process image"
private val IMAGE_EDIT_ERROR = "Unable to edit image"
private val IMAGE_GALLERY_SAVE_ERROR = "Unable to save the image in the gallery"
private var imageFileSavePath: String? = null
private var imageEditedFileSavePath: String? = null
private var imageFileUri: Uri? = null
private var imagePickedContentUri: Uri? = null
private var isEdited = false
private var isFirstRequest = true
private var isSaved = false
private var settings: CameraSettings = CameraSettings()
@PluginMethod
fun getPhoto(invoke: Invoke) {
isEdited = false
settings = getSettings(invoke)
doShow(invoke)
}
@PluginMethod
fun pickImages(invoke: Invoke) {
settings = getSettings(invoke)
openPhotos(invoke, multiple = true, skipPermission = false)
}
@PluginMethod
fun pickLimitedLibraryPhotos(invoke: Invoke) {
invoke.reject("not supported on android")
}
@PluginMethod
fun getLimitedLibraryPhotos(invoke: Invoke) {
invoke.reject("not supported on android")
}
private fun doShow(invoke: Invoke) {
when (settings.source) {
CameraSource.CAMERA -> showCamera(invoke)
CameraSource.PHOTOS -> showPhotos(invoke)
else -> showPrompt(invoke)
}
}
private fun showPrompt(invoke: Invoke) {
// We have all necessary permissions, open the camera
val options: MutableList<String> = ArrayList()
options.add(invoke.getString("promptLabelPhoto", "From Photos"))
options.add(invoke.getString("promptLabelPicture", "Take Picture"))
val fragment = CameraBottomSheetDialogFragment()
fragment.setTitle(invoke.getString("promptLabelHeader", "Photo"))
fragment.setOptions(
options,
{ index: Int ->
if (index == 0) {
settings.source = CameraSource.PHOTOS
openPhotos(invoke)
} else if (index == 1) {
settings.source = CameraSource.CAMERA
openCamera(invoke)
}
},
{ invoke.reject("User cancelled photos app") })
fragment.show((activity as AppCompatActivity).supportFragmentManager, "capacitorModalsActionSheet")
}
private fun showCamera(invoke: Invoke) {
if (!activity.packageManager.hasSystemFeature(PackageManager.FEATURE_CAMERA_ANY)) {
invoke.reject(NO_CAMERA_ERROR)
return
}
openCamera(invoke)
}
private fun showPhotos(invoke: Invoke) {
openPhotos(invoke)
}
private fun checkCameraPermissions(invoke: Invoke): Boolean {
// if the manifest does not contain the camera permissions key, we don't need to ask the user
val needCameraPerms = isPermissionDeclared(CAMERA)
val hasCameraPerms = !needCameraPerms || getPermissionState(CAMERA) === PermissionState.GRANTED
val hasPhotoPerms = getPermissionState(PHOTOS) === PermissionState.GRANTED
// If we want to save to the gallery, we need two permissions
if (settings.isSaveToGallery && !(hasCameraPerms && hasPhotoPerms) && isFirstRequest) {
isFirstRequest = false
val aliases = if (needCameraPerms) {
arrayOf(CAMERA, PHOTOS)
} else {
arrayOf(PHOTOS)
}
requestPermissionForAliases(aliases, invoke, "cameraPermissionsCallback")
return false
} else if (!hasCameraPerms) {
requestPermissionForAlias(CAMERA, invoke, "cameraPermissionsCallback")
return false
}
return true
}
private fun checkPhotosPermissions(invoke: Invoke): Boolean {
if (getPermissionState(PHOTOS) !== PermissionState.GRANTED) {
requestPermissionForAlias(PHOTOS, invoke, "cameraPermissionsCallback")
return false
}
return true
}
/**
* Completes the plugin invoke after a camera permission request
*
* @see .getPhoto
* @param invoke the plugin invoke
*/
@PermissionCallback
private fun cameraPermissionsCallback(invoke: Invoke) {
// TODO: invoke.methodName()
val methodName = "pickImages"
if (methodName == "pickImages") {
openPhotos(invoke, multiple = true, skipPermission = true)
} else {
if (settings.source === CameraSource.CAMERA && getPermissionState(CAMERA) !== PermissionState.GRANTED) {
Logger.debug(
getLogTag(),
"User denied camera permission: " + getPermissionState(CAMERA).toString()
)
invoke.reject(PERMISSION_DENIED_ERROR_CAMERA)
return
} else if (settings.source === CameraSource.PHOTOS && getPermissionState(PHOTOS) !== PermissionState.GRANTED) {
Logger.debug(
getLogTag(),
"User denied photos permission: " + getPermissionState(PHOTOS).toString()
)
invoke.reject(PERMISSION_DENIED_ERROR_PHOTOS)
return
}
doShow(invoke)
}
}
private fun getSettings(invoke: Invoke): CameraSettings {
val settings = CameraSettings()
val resultType = getResultType(invoke.getString("resultType"))
if (resultType != null) {
settings.resultType = resultType
}
settings.isSaveToGallery =
invoke.getBoolean(
"saveToGallery",
CameraSettings.DEFAULT_SAVE_IMAGE_TO_GALLERY
)
settings.isAllowEditing = invoke.getBoolean("allowEditing", false)
settings.quality = invoke.getInt("quality", CameraSettings.DEFAULT_QUALITY)
settings.width = invoke.getInt("width", 0)
settings.height = invoke.getInt("height", 0)
settings.isShouldResize = settings.width > 0 || settings.height > 0
settings.isShouldCorrectOrientation =
invoke.getBoolean(
"correctOrientation",
CameraSettings.DEFAULT_CORRECT_ORIENTATION
)
try {
settings.source =
CameraSource.valueOf(
invoke.getString(
"source",
CameraSource.PROMPT.source
)
)
} catch (ex: IllegalArgumentException) {
settings.source = CameraSource.PROMPT
}
return settings
}
private fun getResultType(resultType: String?): CameraResultType? {
return if (resultType == null) {
null
} else try {
CameraResultType.valueOf(resultType.uppercase(Locale.ROOT))
} catch (ex: IllegalArgumentException) {
Logger.debug(getLogTag(), "Invalid result type \"$resultType\", defaulting to base64")
CameraResultType.BASE64
}
}
private fun openCamera(invoke: Invoke) {
if (checkCameraPermissions(invoke)) {
val takePictureIntent = Intent(MediaStore.ACTION_IMAGE_CAPTURE)
if (takePictureIntent.resolveActivity(activity.packageManager) != null) {
// If we will be saving the photo, send the target file along
try {
val appId: String = activity.packageName
val photoFile: File = CameraUtils.createImageFile(activity)
imageFileSavePath = photoFile.absolutePath
// TODO: Verify provider config exists
imageFileUri = FileProvider.getUriForFile(
activity,
"$appId.fileprovider", photoFile
)
takePictureIntent.putExtra(MediaStore.EXTRA_OUTPUT, imageFileUri)
} catch (ex: Exception) {
invoke.reject(IMAGE_FILE_SAVE_ERROR, ex)
return
}
startActivityForResult(invoke, takePictureIntent, "processCameraImage")
} else {
invoke.reject(NO_CAMERA_ACTIVITY_ERROR)
}
}
}
private fun openPhotos(invoke: Invoke) {
openPhotos(invoke, multiple = false, skipPermission = false)
}
private fun openPhotos(invoke: Invoke, multiple: Boolean, skipPermission: Boolean) {
if (skipPermission || checkPhotosPermissions(invoke)) {
val intent = Intent(Intent.ACTION_PICK)
intent.putExtra(Intent.EXTRA_ALLOW_MULTIPLE, multiple)
intent.setType("image/*")
try {
if (multiple) {
intent.putExtra("multi-pick", multiple)
intent.putExtra(Intent.EXTRA_MIME_TYPES, arrayOf("image/*"))
startActivityForResult(invoke, intent, "processPickedImages")
} else {
startActivityForResult(invoke, intent, "processPickedImage")
}
} catch (ex: ActivityNotFoundException) {
invoke.reject(NO_PHOTO_ACTIVITY_ERROR)
}
}
}
@ActivityCallback
fun processCameraImage(invoke: Invoke, result: ActivityResult?) {
settings = getSettings(invoke)
if (imageFileSavePath == null) {
invoke.reject(IMAGE_PROCESS_NO_FILE_ERROR)
return
}
// Load the image as a Bitmap
val f = File(imageFileSavePath!!)
val bmOptions: BitmapFactory.Options = BitmapFactory.Options()
val contentUri: Uri = Uri.fromFile(f)
val bitmap: Bitmap = BitmapFactory.decodeFile(imageFileSavePath, bmOptions)
returnResult(invoke, bitmap, contentUri)
}
@ActivityCallback
fun processPickedImage(invoke: Invoke, result: ActivityResult) {
settings = getSettings(invoke)
val data: Intent? = result.data
if (data == null) {
invoke.reject("No image picked")
return
}
val u: Uri = data.data!!
imagePickedContentUri = u
processPickedImage(u, invoke)
}
@ActivityCallback
fun processPickedImages(invoke: Invoke, result: ActivityResult) {
val data: Intent? = result.data
if (data != null) {
val executor: Executor = Executors.newSingleThreadExecutor()
executor.execute {
val ret = JSObject()
val photos = JSArray()
if (data.clipData != null) {
val count: Int = data.clipData!!.itemCount
for (i in 0 until count) {
val imageUri: Uri = data.clipData!!.getItemAt(i).uri
val processResult = processPickedImages(imageUri)
if (processResult.getString("error").isNotEmpty()
) {
invoke.reject(processResult.getString("error"))
return@execute
} else {
photos.put(processResult)
}
}
} else if (data.data != null) {
val imageUri: Uri = data.data!!
val processResult = processPickedImages(imageUri)
if (processResult.getString("error").isNotEmpty()
) {
invoke.reject(processResult.getString("error"))
return@execute
} else {
photos.put(processResult)
}
} else if (data.extras != null) {
val bundle: Bundle = data.extras!!
if (bundle.keySet().contains("selectedItems")) {
val fileUris: ArrayList<Parcelable>? = bundle.getParcelableArrayList("selectedItems")
if (fileUris != null) {
for (fileUri in fileUris) {
if (fileUri is Uri) {
val imageUri: Uri = fileUri
try {
val processResult = processPickedImages(imageUri)
if (processResult.getString("error").isNotEmpty()
) {
invoke.reject(processResult.getString("error"))
return@execute
} else {
photos.put(processResult)
}
} catch (ex: SecurityException) {
invoke.reject("SecurityException")
}
}
}
}
}
}
ret.put("photos", photos)
invoke.resolve(ret)
}
} else {
invoke.reject("No images picked")
}
}
private fun processPickedImage(imageUri: Uri, invoke: Invoke) {
var imageStream: InputStream? = null
try {
imageStream = activity.contentResolver.openInputStream(imageUri)
val bitmap = BitmapFactory.decodeStream(imageStream)
if (bitmap == null) {
invoke.reject("Unable to process bitmap")
return
}
returnResult(invoke, bitmap, imageUri)
} catch (err: OutOfMemoryError) {
invoke.reject("Out of memory")
} catch (ex: FileNotFoundException) {
invoke.reject("No such image found", ex)
} finally {
if (imageStream != null) {
try {
imageStream.close()
} catch (e: IOException) {
Logger.error(getLogTag(), UNABLE_TO_PROCESS_IMAGE, e)
}
}
}
}
private fun processPickedImages(imageUri: Uri): JSObject {
var imageStream: InputStream? = null
val ret = JSObject()
try {
imageStream = activity.contentResolver.openInputStream(imageUri)
var bitmap = BitmapFactory.decodeStream(imageStream)
if (bitmap == null) {
ret.put("error", "Unable to process bitmap")
return ret
}
val exif: ExifWrapper = ImageUtils.getExifData(activity, bitmap, imageUri)
bitmap = try {
prepareBitmap(bitmap, imageUri, exif)
} catch (e: IOException) {
ret.put("error", UNABLE_TO_PROCESS_IMAGE)
return ret
}
// Compress the final image and prepare for output to client
val bitmapOutputStream = ByteArrayOutputStream()
bitmap.compress(Bitmap.CompressFormat.JPEG, settings.quality, bitmapOutputStream)
val newUri: Uri? = getTempImage(imageUri, bitmapOutputStream)
exif.copyExif(newUri?.path)
if (newUri != null) {
ret.put("format", "jpeg")
ret.put("exif", exif.toJson())
ret.put("data", newUri.toString())
ret.put("assetUrl", assetUrl(newUri))
} else {
ret.put("error", UNABLE_TO_PROCESS_IMAGE)
}
return ret
} catch (err: OutOfMemoryError) {
ret.put("error", "Out of memory")
} catch (ex: FileNotFoundException) {
ret.put("error", "No such image found")
Logger.error(getLogTag(), "No such image found", ex)
} finally {
if (imageStream != null) {
try {
imageStream.close()
} catch (e: IOException) {
Logger.error(getLogTag(), UNABLE_TO_PROCESS_IMAGE, e)
}
}
}
return ret
}
@ActivityCallback
private fun processEditedImage(invoke: Invoke, result: ActivityResult) {
isEdited = true
settings = getSettings(invoke)
if (result.resultCode == Activity.RESULT_CANCELED) {
// User cancelled the edit operation, if this file was picked from photos,
// process the original picked image, otherwise process it as a camera photo
if (imagePickedContentUri != null) {
processPickedImage(imagePickedContentUri!!, invoke)
} else {
processCameraImage(invoke, result)
}
} else {
processPickedImage(invoke, result)
}
}
/**
* Save the modified image on the same path,
* or on a temporary location if it's a content url
* @param uri
* @param is
* @return
* @throws IOException
*/
@Throws(IOException::class)
private fun saveImage(uri: Uri, input: InputStream): Uri? {
var outFile = if (uri.scheme.equals("content")) {
getTempFile(uri)
} else {
uri.path?.let { File(it) }
}
try {
writePhoto(outFile!!, input)
} catch (ex: FileNotFoundException) {
// Some gallery apps return read only file url, create a temporary file for modifications
outFile = getTempFile(uri)
writePhoto(outFile, input)
}
return Uri.fromFile(outFile)
}
@Throws(IOException::class)
private fun writePhoto(outFile: File, input: InputStream) {
val fos = FileOutputStream(outFile)
val buffer = ByteArray(1024)
var len: Int
while (input.read(buffer).also { len = it } != -1) {
fos.write(buffer, 0, len)
}
fos.close()
}
private fun getTempFile(uri: Uri): File {
var filename: String = Uri.parse(Uri.decode(uri.toString())).lastPathSegment!!
if (!filename.contains(".jpg") && !filename.contains(".jpeg")) {
filename += "." + Date().time + ".jpeg"
}
val cacheDir: File = activity.getCacheDir()
return File(cacheDir, filename)
}
/**
* After processing the image, return the final result back to the invokeer.
* @param invoke
* @param bitmap
* @param u
*/
private fun returnResult(invoke: Invoke, bitmap: Bitmap, u: Uri) {
val exif: ExifWrapper = ImageUtils.getExifData(activity, bitmap, u)
val preparedBitmap = try {
prepareBitmap(bitmap, u, exif)
} catch (e: IOException) {
invoke.reject(UNABLE_TO_PROCESS_IMAGE)
return
}
// Compress the final image and prepare for output to client
val bitmapOutputStream = ByteArrayOutputStream()
preparedBitmap.compress(Bitmap.CompressFormat.JPEG, settings.quality, bitmapOutputStream)
if (settings.isAllowEditing && !isEdited) {
editImage(invoke, u, bitmapOutputStream)
return
}
val saveToGallery: Boolean =
invoke.getBoolean("saveToGallery", CameraSettings.DEFAULT_SAVE_IMAGE_TO_GALLERY)
if (saveToGallery && (imageEditedFileSavePath != null || imageFileSavePath != null)) {
isSaved = true
try {
val fileToSavePath =
if (imageEditedFileSavePath != null) imageEditedFileSavePath!! else imageFileSavePath!!
val fileToSave = File(fileToSavePath)
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
val resolver: ContentResolver = activity.contentResolver
val values = ContentValues()
values.put(MediaStore.MediaColumns.DISPLAY_NAME, fileToSave.name)
values.put(MediaStore.MediaColumns.MIME_TYPE, "image/jpeg")
values.put(MediaStore.MediaColumns.RELATIVE_PATH, Environment.DIRECTORY_DCIM)
val contentUri: Uri = MediaStore.Images.Media.EXTERNAL_CONTENT_URI
val uri: Uri = resolver.insert(contentUri, values)
?: throw IOException("Failed to create new MediaStore record.")
val stream: OutputStream = resolver.openOutputStream(uri)
?: throw IOException("Failed to open output stream.")
val inserted: Boolean =
preparedBitmap.compress(Bitmap.CompressFormat.JPEG, settings.quality, stream)
if (!inserted) {
isSaved = false
}
} else {
val inserted = MediaStore.Images.Media.insertImage(
activity.contentResolver,
fileToSavePath,
fileToSave.name,
""
)
if (inserted == null) {
isSaved = false
}
}
} catch (e: FileNotFoundException) {
isSaved = false
Logger.error(getLogTag(), IMAGE_GALLERY_SAVE_ERROR, e)
} catch (e: IOException) {
isSaved = false
Logger.error(getLogTag(), IMAGE_GALLERY_SAVE_ERROR, e)
}
}
if (settings.resultType === CameraResultType.BASE64) {
returnBase64(invoke, exif, bitmapOutputStream)
} else if (settings.resultType === CameraResultType.URI) {
returnFileURI(invoke, exif, bitmap, u, bitmapOutputStream)
} else if (settings.resultType === CameraResultType.DATAURL) {
returnDataUrl(invoke, exif, bitmapOutputStream)
} else {
invoke.reject(INVALID_RESULT_TYPE_ERROR)
}
// Result returned, clear stored paths and images
if (settings.resultType !== CameraResultType.URI) {
deleteImageFile()
}
imageFileSavePath = null
imageFileUri = null
imagePickedContentUri = null
imageEditedFileSavePath = null
}
private fun deleteImageFile() {
if (imageFileSavePath != null && !settings.isSaveToGallery) {
val photoFile = File(imageFileSavePath!!)
if (photoFile.exists()) {
photoFile.delete()
}
}
}
private fun returnFileURI(
invoke: Invoke,
exif: ExifWrapper,
bitmap: Bitmap,
u: Uri,
bitmapOutputStream: ByteArrayOutputStream
) {
val newUri: Uri? = getTempImage(u, bitmapOutputStream)
exif.copyExif(newUri?.path)
if (newUri != null) {
val ret = JSObject()
ret.put("format", "jpeg")
ret.put("exif", exif.toJson())
ret.put("data", newUri.toString())
ret.put("assetUrl", assetUrl(newUri))
ret.put("saved", isSaved)
invoke.resolve(ret)
} else {
invoke.reject(UNABLE_TO_PROCESS_IMAGE)
}
}
private fun getTempImage(u: Uri, bitmapOutputStream: ByteArrayOutputStream): Uri? {
var bis: ByteArrayInputStream? = null
var newUri: Uri? = null
try {
bis = ByteArrayInputStream(bitmapOutputStream.toByteArray())
newUri = saveImage(u, bis)
} catch (_: IOException) {
} finally {
if (bis != null) {
try {
bis.close()
} catch (e: IOException) {
Logger.error(getLogTag(), UNABLE_TO_PROCESS_IMAGE, e)
}
}
}
return newUri
}
/**
* Apply our standard processing of the bitmap, returning a new one and
* recycling the old one in the process
* @param bitmap
* @param imageUri
* @param exif
* @return
*/
@Throws(IOException::class)
private fun prepareBitmap(bitmap: Bitmap, imageUri: Uri, exif: ExifWrapper): Bitmap {
var preparedBitmap: Bitmap = bitmap
if (settings.isShouldCorrectOrientation) {
val newBitmap: Bitmap = ImageUtils.correctOrientation(activity, preparedBitmap, imageUri, exif)
preparedBitmap = replaceBitmap(preparedBitmap, newBitmap)
}
if (settings.isShouldResize) {
val newBitmap: Bitmap = ImageUtils.resize(preparedBitmap, settings.width, settings.height)
preparedBitmap = replaceBitmap(preparedBitmap, newBitmap)
}
return preparedBitmap
}
private fun replaceBitmap(bitmap: Bitmap, newBitmap: Bitmap): Bitmap {
if (bitmap !== newBitmap) {
bitmap.recycle()
}
return newBitmap
}
private fun returnDataUrl(
invoke: Invoke,
exif: ExifWrapper,
bitmapOutputStream: ByteArrayOutputStream
) {
val byteArray: ByteArray = bitmapOutputStream.toByteArray()
val encoded: String = Base64.encodeToString(byteArray, Base64.NO_WRAP)
val data = JSObject()
data.put("format", "jpeg")
data.put("data", "data:image/jpeg;base64,$encoded")
data.put("exif", exif.toJson())
invoke.resolve(data)
}
private fun returnBase64(
invoke: Invoke,
exif: ExifWrapper,
bitmapOutputStream: ByteArrayOutputStream
) {
val byteArray: ByteArray = bitmapOutputStream.toByteArray()
val encoded: String = Base64.encodeToString(byteArray, Base64.NO_WRAP)
val data = JSObject()
data.put("format", "jpeg")
data.put("data", encoded)
data.put("exif", exif.toJson())
invoke.resolve(data)
}
@PluginMethod
override fun requestPermissions(invoke: Invoke) {
// If the camera permission is defined in the manifest, then we have to prompt the user
// or else we will get a security exception when trying to present the camera. If, however,
// it is not defined in the manifest then we don't need to prompt and it will just work.
if (isPermissionDeclared(CAMERA)) {
// just request normally
super.requestPermissions(invoke)
} else {
// the manifest does not define camera permissions, so we need to decide what to do
// first, extract the permissions being requested
val providedPerms = invoke.getArray("permissions", JSArray())
var permsList: List<String>? = null
try {
permsList = providedPerms.toList()
} catch (_: JSONException) {
}
if (permsList != null && permsList.size == 1 && permsList.contains(CAMERA)) {
// the only thing being asked for was the camera so we can just return the current state
checkPermissions(invoke)
} else {
// we need to ask about photos so request storage permissions
requestPermissionForAlias(PHOTOS, invoke, "checkPermissions")
}
}
}
override fun getPermissionStates(): Map<String, PermissionState> {
val permissionStates = super.getPermissionStates() as MutableMap
// If Camera is not in the manifest and therefore not required, say the permission is granted
if (!isPermissionDeclared(CAMERA)) {
permissionStates[CAMERA] = PermissionState.GRANTED
}
return permissionStates
}
private fun editImage(invoke: Invoke, uri: Uri, bitmapOutputStream: ByteArrayOutputStream) {
try {
val tempImage = getTempImage(uri, bitmapOutputStream)
val editIntent = createEditIntent(tempImage)
if (editIntent != null) {
startActivityForResult(invoke, editIntent, "processEditedImage")
} else {
invoke.reject(IMAGE_EDIT_ERROR)
}
} catch (ex: Exception) {
invoke.reject(IMAGE_EDIT_ERROR, ex)
}
}
private fun createEditIntent(origPhotoUri: Uri?): Intent? {
return try {
val editFile = origPhotoUri?.path?.let { File(it) }
val editUri: Uri = FileProvider.getUriForFile(
activity,
activity.packageName + ".fileprovider",
editFile!!
)
val editIntent = Intent(Intent.ACTION_EDIT)
editIntent.setDataAndType(editUri, "image/*")
imageEditedFileSavePath = editFile.absolutePath
val flags: Int =
Intent.FLAG_GRANT_READ_URI_PERMISSION or Intent.FLAG_GRANT_WRITE_URI_PERMISSION
editIntent.addFlags(flags)
editIntent.putExtra(MediaStore.EXTRA_OUTPUT, editUri)
val resInfoList: List<ResolveInfo> = activity
.packageManager
.queryIntentActivities(editIntent, PackageManager.MATCH_DEFAULT_ONLY)
for (resolveInfo in resInfoList) {
val packageName: String = resolveInfo.activityInfo.packageName
activity.grantUriPermission(packageName, editUri, flags)
}
editIntent
} catch (ex: Exception) {
null
}
}
/*protected fun saveInstanceState(): Bundle? {
val bundle: Bundle = super.saveInstanceState()
if (bundle != null) {
bundle.putString("cameraImageFileSavePath", imageFileSavePath)
}
return bundle
}
protected fun restoreState(state: Bundle) {
val storedImageFileSavePath: String = state.getString("cameraImageFileSavePath")
if (storedImageFileSavePath != null) {
imageFileSavePath = storedImageFileSavePath
}
}*/
}
@@ -0,0 +1,39 @@
package app.tauri.camera
import android.app.Activity
import android.net.Uri
import android.os.Environment
import androidx.core.content.FileProvider
import app.tauri.Logger
import java.io.File
import java.io.IOException
import java.text.SimpleDateFormat
import java.util.*
object CameraUtils {
@Throws(IOException::class)
fun createImageFileUri(activity: Activity, appId: String): Uri {
val photoFile = createImageFile(activity)
return FileProvider.getUriForFile(
activity,
"$appId.fileprovider", photoFile
)
}
@Throws(IOException::class)
fun createImageFile(activity: Activity): File {
// Create an image file name
val timeStamp: String = SimpleDateFormat("yyyyMMdd_HHmmss").format(Date())
val imageFileName = "JPEG_" + timeStamp + "_"
val storageDir =
activity.getExternalFilesDir(Environment.DIRECTORY_PICTURES)
return File.createTempFile(
imageFileName, /* prefix */
".jpg", /* suffix */
storageDir /* directory */
)
}
internal val logTag: String
internal get() = Logger.tags("CameraUtils")
}
@@ -0,0 +1,198 @@
package app.tauri.camera
import androidx.exifinterface.media.ExifInterface.*
import androidx.exifinterface.media.ExifInterface
import app.tauri.plugin.JSObject
class ExifWrapper(private val exif: ExifInterface?) {
private val attributes = arrayOf(
TAG_APERTURE_VALUE,
TAG_ARTIST,
TAG_BITS_PER_SAMPLE,
TAG_BODY_SERIAL_NUMBER,
TAG_BRIGHTNESS_VALUE,
TAG_CAMERA_OWNER_NAME,
TAG_CFA_PATTERN,
TAG_COLOR_SPACE,
TAG_COMPONENTS_CONFIGURATION,
TAG_COMPRESSED_BITS_PER_PIXEL,
TAG_COMPRESSION,
TAG_CONTRAST,
TAG_COPYRIGHT,
TAG_CUSTOM_RENDERED,
TAG_DATETIME,
TAG_DATETIME_DIGITIZED,
TAG_DATETIME_ORIGINAL,
TAG_DEFAULT_CROP_SIZE,
TAG_DEVICE_SETTING_DESCRIPTION,
TAG_DIGITAL_ZOOM_RATIO,
TAG_DNG_VERSION,
TAG_EXIF_VERSION,
TAG_EXPOSURE_BIAS_VALUE,
TAG_EXPOSURE_INDEX,
TAG_EXPOSURE_MODE,
TAG_EXPOSURE_PROGRAM,
TAG_EXPOSURE_TIME,
TAG_FILE_SOURCE,
TAG_FLASH,
TAG_FLASHPIX_VERSION,
TAG_FLASH_ENERGY,
TAG_FOCAL_LENGTH,
TAG_FOCAL_LENGTH_IN_35MM_FILM,
TAG_FOCAL_PLANE_RESOLUTION_UNIT,
TAG_FOCAL_PLANE_X_RESOLUTION,
TAG_FOCAL_PLANE_Y_RESOLUTION,
TAG_F_NUMBER,
TAG_GAIN_CONTROL,
TAG_GAMMA,
TAG_GPS_ALTITUDE,
TAG_GPS_ALTITUDE_REF,
TAG_GPS_AREA_INFORMATION,
TAG_GPS_DATESTAMP,
TAG_GPS_DEST_BEARING,
TAG_GPS_DEST_BEARING_REF,
TAG_GPS_DEST_DISTANCE,
TAG_GPS_DEST_DISTANCE_REF,
TAG_GPS_DEST_LATITUDE,
TAG_GPS_DEST_LATITUDE_REF,
TAG_GPS_DEST_LONGITUDE,
TAG_GPS_DEST_LONGITUDE_REF,
TAG_GPS_DIFFERENTIAL,
TAG_GPS_DOP,
TAG_GPS_H_POSITIONING_ERROR,
TAG_GPS_IMG_DIRECTION,
TAG_GPS_IMG_DIRECTION_REF,
TAG_GPS_LATITUDE,
TAG_GPS_LATITUDE_REF,
TAG_GPS_LONGITUDE,
TAG_GPS_LONGITUDE_REF,
TAG_GPS_MAP_DATUM,
TAG_GPS_MEASURE_MODE,
TAG_GPS_PROCESSING_METHOD,
TAG_GPS_SATELLITES,
TAG_GPS_SPEED,
TAG_GPS_SPEED_REF,
TAG_GPS_STATUS,
TAG_GPS_TIMESTAMP,
TAG_GPS_TRACK,
TAG_GPS_TRACK_REF,
TAG_GPS_VERSION_ID,
TAG_IMAGE_DESCRIPTION,
TAG_IMAGE_LENGTH,
TAG_IMAGE_UNIQUE_ID,
TAG_IMAGE_WIDTH,
TAG_INTEROPERABILITY_INDEX,
TAG_ISO_SPEED,
TAG_ISO_SPEED_LATITUDE_YYY,
TAG_ISO_SPEED_LATITUDE_ZZZ,
TAG_JPEG_INTERCHANGE_FORMAT,
TAG_JPEG_INTERCHANGE_FORMAT_LENGTH,
TAG_LENS_MAKE,
TAG_LENS_MODEL,
TAG_LENS_SERIAL_NUMBER,
TAG_LENS_SPECIFICATION,
TAG_LIGHT_SOURCE,
TAG_MAKE,
TAG_MAKER_NOTE,
TAG_MAX_APERTURE_VALUE,
TAG_METERING_MODE,
TAG_MODEL,
TAG_NEW_SUBFILE_TYPE,
TAG_OECF,
TAG_OFFSET_TIME,
TAG_OFFSET_TIME_DIGITIZED,
TAG_OFFSET_TIME_ORIGINAL,
TAG_ORF_ASPECT_FRAME,
TAG_ORF_PREVIEW_IMAGE_LENGTH,
TAG_ORF_PREVIEW_IMAGE_START,
TAG_ORF_THUMBNAIL_IMAGE,
TAG_ORIENTATION,
TAG_PHOTOGRAPHIC_SENSITIVITY,
TAG_PHOTOMETRIC_INTERPRETATION,
TAG_PIXEL_X_DIMENSION,
TAG_PIXEL_Y_DIMENSION,
TAG_PLANAR_CONFIGURATION,
TAG_PRIMARY_CHROMATICITIES,
TAG_RECOMMENDED_EXPOSURE_INDEX,
TAG_REFERENCE_BLACK_WHITE,
TAG_RELATED_SOUND_FILE,
TAG_RESOLUTION_UNIT,
TAG_ROWS_PER_STRIP,
TAG_RW2_ISO,
TAG_RW2_JPG_FROM_RAW,
TAG_RW2_SENSOR_BOTTOM_BORDER,
TAG_RW2_SENSOR_LEFT_BORDER,
TAG_RW2_SENSOR_RIGHT_BORDER,
TAG_RW2_SENSOR_TOP_BORDER,
TAG_SAMPLES_PER_PIXEL,
TAG_SATURATION,
TAG_SCENE_CAPTURE_TYPE,
TAG_SCENE_TYPE,
TAG_SENSING_METHOD,
TAG_SENSITIVITY_TYPE,
TAG_SHARPNESS,
TAG_SHUTTER_SPEED_VALUE,
TAG_SOFTWARE,
TAG_SPATIAL_FREQUENCY_RESPONSE,
TAG_SPECTRAL_SENSITIVITY,
TAG_STANDARD_OUTPUT_SENSITIVITY,
TAG_STRIP_BYTE_COUNTS,
TAG_STRIP_OFFSETS,
TAG_SUBFILE_TYPE,
TAG_SUBJECT_AREA,
TAG_SUBJECT_DISTANCE,
TAG_SUBJECT_DISTANCE_RANGE,
TAG_SUBJECT_LOCATION,
TAG_SUBSEC_TIME,
TAG_SUBSEC_TIME_DIGITIZED,
TAG_SUBSEC_TIME_ORIGINAL,
TAG_THUMBNAIL_IMAGE_LENGTH,
TAG_THUMBNAIL_IMAGE_WIDTH,
TAG_TRANSFER_FUNCTION,
TAG_USER_COMMENT,
TAG_WHITE_BALANCE,
TAG_WHITE_POINT,
TAG_XMP,
TAG_X_RESOLUTION,
TAG_Y_CB_CR_COEFFICIENTS,
TAG_Y_CB_CR_POSITIONING,
TAG_Y_CB_CR_SUB_SAMPLING,
TAG_Y_RESOLUTION
)
fun toJson(): JSObject {
val ret = JSObject()
if (exif == null) {
return ret
}
for (i in attributes.indices) {
p(ret, attributes[i])
}
return ret
}
fun p(o: JSObject, tag: String?) {
val value = exif!!.getAttribute(tag!!)
o.put(tag, value)
}
fun copyExif(destFile: String?) {
try {
val destExif = ExifInterface(
destFile!!
)
for (i in attributes.indices) {
val value = exif!!.getAttribute(attributes[i])
if (value != null) {
destExif.setAttribute(attributes[i], value)
}
}
destExif.saveAttributes()
} catch (_: java.lang.Exception) {
}
}
fun resetOrientation() {
exif!!.resetOrientation()
}
}
@@ -0,0 +1,126 @@
package app.tauri.camera
import android.content.Context
import android.graphics.Bitmap
import android.graphics.Matrix
import android.net.Uri
import androidx.exifinterface.media.ExifInterface
import app.tauri.Logger
import java.io.IOException
import java.io.InputStream
import java.lang.Integer.min
import kotlin.math.roundToInt
object ImageUtils {
/**
* Resize an image to the given max width and max height. Constraint can be put
* on one dimension, or both. Resize will always preserve aspect ratio.
* @param bitmap
* @param desiredMaxWidth
* @param desiredMaxHeight
* @return a new, scaled Bitmap
*/
fun resize(bitmap: Bitmap, desiredMaxWidth: Int, desiredMaxHeight: Int): Bitmap {
return resizePreservingAspectRatio(bitmap, desiredMaxWidth, desiredMaxHeight)
}
/**
* Resize an image to the given max width and max height. Constraint can be put
* on one dimension, or both. Resize will always preserve aspect ratio.
* @param bitmap
* @param desiredMaxWidth
* @param desiredMaxHeight
* @return a new, scaled Bitmap
*/
private fun resizePreservingAspectRatio(
bitmap: Bitmap,
desiredMaxWidth: Int,
desiredMaxHeight: Int
): Bitmap {
val width = bitmap.width
val height = bitmap.height
// 0 is treated as 'no restriction'
val maxHeight = if (desiredMaxHeight == 0) height else desiredMaxHeight
val maxWidth = if (desiredMaxWidth == 0) width else desiredMaxWidth
// resize with preserved aspect ratio
var newWidth = min(width, maxWidth).toFloat()
var newHeight = height * newWidth / width
if (newHeight > maxHeight) {
newWidth = (width * maxHeight / height).toFloat()
newHeight = maxHeight.toFloat()
}
return Bitmap.createScaledBitmap(bitmap, newWidth.roundToInt(), newHeight.roundToInt(), false)
}
/**
* Transform an image with the given matrix
* @param bitmap
* @param matrix
* @return
*/
private fun transform(bitmap: Bitmap, matrix: Matrix): Bitmap {
return Bitmap.createBitmap(bitmap, 0, 0, bitmap.width, bitmap.height, matrix, true)
}
/**
* Correct the orientation of an image by reading its exif information and rotating
* the appropriate amount for portrait mode
* @param bitmap
* @param imageUri
* @param exif
* @return
*/
@Throws(IOException::class)
fun correctOrientation(c: Context, bitmap: Bitmap, imageUri: Uri, exif: ExifWrapper): Bitmap {
val orientation = getOrientation(c, imageUri)
return if (orientation != 0) {
val matrix = Matrix()
matrix.postRotate(orientation.toFloat())
exif.resetOrientation()
transform(bitmap, matrix)
} else {
bitmap
}
}
@Throws(IOException::class)
private fun getOrientation(c: Context, imageUri: Uri): Int {
var result = 0
c.getContentResolver().openInputStream(imageUri).use { iStream ->
val exifInterface = ExifInterface(iStream!!)
val orientation: Int = exifInterface.getAttributeInt(
ExifInterface.TAG_ORIENTATION,
ExifInterface.ORIENTATION_NORMAL
)
if (orientation == ExifInterface.ORIENTATION_ROTATE_90) {
result = 90
} else if (orientation == ExifInterface.ORIENTATION_ROTATE_180) {
result = 180
} else if (orientation == ExifInterface.ORIENTATION_ROTATE_270) {
result = 270
}
}
return result
}
fun getExifData(c: Context, bitmap: Bitmap?, imageUri: Uri): ExifWrapper {
var stream: InputStream? = null
try {
stream = c.getContentResolver().openInputStream(imageUri)
val exifInterface = ExifInterface(stream!!)
return ExifWrapper(exifInterface)
} catch (ex: IOException) {
Logger.error("Error loading exif data from image", ex)
} finally {
if (stream != null) {
try {
stream.close()
} catch (ignored: IOException) {
}
}
}
return ExifWrapper(null)
}
}
@@ -0,0 +1,17 @@
package app.tauri.camera
import org.junit.Test
import org.junit.Assert.*
/**
* Example local unit test, which will execute on the development machine (host).
*
* See [testing documentation](http://d.android.com/tools/testing).
*/
class ExampleUnitTest {
@Test
fun addition_isCorrect() {
assertEquals(4, 2 + 2)
}
}