mirror of
https://github.com/tauri-apps/plugins-workspace.git
synced 2026-04-23 11:36:13 +02:00
feat(camera): add plugin for Android and iOS
This commit is contained in:
+24
@@ -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>
|
||||
+102
@@ -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)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user