453 lines
17 KiB
Kotlin
453 lines
17 KiB
Kotlin
package org.pixeldroid.app.postCreation.camera
|
|
|
|
import android.Manifest
|
|
import android.app.Activity
|
|
import android.content.ClipData
|
|
import android.content.ContentUris
|
|
import android.content.Intent
|
|
import android.content.pm.PackageManager
|
|
import android.content.res.Configuration
|
|
import android.graphics.Color
|
|
import android.graphics.drawable.ColorDrawable
|
|
import android.net.Uri
|
|
import android.os.Bundle
|
|
import android.provider.MediaStore
|
|
import android.util.DisplayMetrics
|
|
import android.util.Log
|
|
import android.view.LayoutInflater
|
|
import android.view.View
|
|
import android.view.ViewGroup
|
|
import android.widget.ImageButton
|
|
import androidx.activity.result.contract.ActivityResultContracts
|
|
import androidx.camera.core.*
|
|
import androidx.camera.core.ImageCapture.Metadata
|
|
import androidx.camera.lifecycle.ProcessCameraProvider
|
|
import androidx.camera.view.PreviewView
|
|
import androidx.constraintlayout.widget.ConstraintLayout
|
|
import androidx.core.app.ActivityCompat
|
|
import androidx.core.content.ContextCompat
|
|
import androidx.core.net.toUri
|
|
import androidx.core.view.setPadding
|
|
import androidx.fragment.app.Fragment
|
|
import androidx.lifecycle.lifecycleScope
|
|
import com.bumptech.glide.Glide
|
|
import com.bumptech.glide.request.RequestOptions
|
|
import org.pixeldroid.app.R
|
|
import org.pixeldroid.app.postCreation.PostCreationActivity
|
|
import kotlinx.coroutines.Dispatchers
|
|
import kotlinx.coroutines.launch
|
|
import java.io.File
|
|
import java.util.concurrent.ExecutorService
|
|
import java.util.concurrent.Executors
|
|
import kotlin.math.abs
|
|
import kotlin.math.max
|
|
import kotlin.math.min
|
|
import kotlin.properties.Delegates
|
|
|
|
// This is an arbitrary number we are using to keep track of the permission
|
|
// request. Where an app has multiple context for requesting permission,
|
|
// this can help differentiate the different contexts.
|
|
private const val REQUEST_CODE_PERMISSIONS = 10
|
|
|
|
private const val ANIMATION_FAST_MILLIS = 50L
|
|
private const val ANIMATION_SLOW_MILLIS = 100L
|
|
|
|
private val REQUIRED_PERMISSIONS = arrayOf(
|
|
Manifest.permission.CAMERA,
|
|
Manifest.permission.READ_EXTERNAL_STORAGE
|
|
)
|
|
|
|
/**
|
|
* Camera fragment
|
|
*/
|
|
class CameraFragment : Fragment() {
|
|
|
|
private lateinit var container: ConstraintLayout
|
|
private lateinit var viewFinder: PreviewView
|
|
|
|
private val cameraLifecycleOwner = CameraLifecycleOwner()
|
|
|
|
private var displayId: Int = -1
|
|
private var lensFacing: Int = CameraSelector.LENS_FACING_BACK
|
|
private var preview: Preview? = null
|
|
private var imageCapture: ImageCapture? = null
|
|
private var camera: Camera? = null
|
|
|
|
private var inActivity by Delegates.notNull<Boolean>()
|
|
|
|
/** Blocking camera operations are performed using this executor */
|
|
private lateinit var cameraExecutor: ExecutorService
|
|
|
|
override fun onResume() {
|
|
super.onResume()
|
|
// Make sure that all permissions are still present on resume,
|
|
// since they could have been removed while away.
|
|
if (!allPermissionsGranted()) {
|
|
ActivityCompat.requestPermissions(
|
|
requireActivity(),
|
|
REQUIRED_PERMISSIONS,
|
|
REQUEST_CODE_PERMISSIONS
|
|
)
|
|
} else {
|
|
// Build UI controls
|
|
updateCameraUi()
|
|
}
|
|
cameraLifecycleOwner.resume()
|
|
}
|
|
/**
|
|
* Check if all permission specified in the manifest have been granted
|
|
*/
|
|
private fun allPermissionsGranted() = REQUIRED_PERMISSIONS.all {
|
|
ContextCompat.checkSelfPermission(
|
|
requireContext(), it
|
|
) == PackageManager.PERMISSION_GRANTED
|
|
}
|
|
|
|
override fun onDestroyView() {
|
|
super.onDestroyView()
|
|
|
|
// Shut down our background executor
|
|
cameraExecutor.shutdown()
|
|
}
|
|
|
|
override fun onCreateView(
|
|
inflater: LayoutInflater,
|
|
container: ViewGroup?,
|
|
savedInstanceState: Bundle?
|
|
): View? {
|
|
inActivity = arguments?.getBoolean("CameraActivity") ?: false
|
|
|
|
return inflater.inflate(R.layout.fragment_camera, container, false)
|
|
}
|
|
|
|
private fun setGalleryThumbnail(uri: Uri) {
|
|
// Reference of the view that holds the gallery thumbnail
|
|
val thumbnail = container.findViewById<ImageButton>(R.id.photo_view_button)
|
|
|
|
// Run the operations in the view's thread
|
|
thumbnail.post {
|
|
|
|
// Remove thumbnail padding
|
|
thumbnail.setPadding(10)
|
|
|
|
// Load thumbnail into circular button using Glide
|
|
Glide.with(thumbnail)
|
|
.load(uri)
|
|
.apply(RequestOptions.circleCropTransform())
|
|
.into(thumbnail)
|
|
}
|
|
}
|
|
|
|
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
|
|
super.onViewCreated(view, savedInstanceState)
|
|
container = view as ConstraintLayout
|
|
viewFinder = container.findViewById(R.id.view_finder)
|
|
|
|
// Initialize our background executor
|
|
cameraExecutor = Executors.newSingleThreadExecutor()
|
|
|
|
bindCameraUseCases()
|
|
|
|
// Every time the orientation of device changes, update rotation for use cases
|
|
|
|
// Wait for the views to be properly laid out
|
|
viewFinder.post {
|
|
|
|
// Keep track of the display in which this view is attached
|
|
displayId = viewFinder.display?.displayId ?: -1
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Inflate camera controls and update the UI manually upon config changes to avoid removing
|
|
* and re-adding the view finder from the view hierarchy; this provides a seamless rotation
|
|
* transition on devices that support it.
|
|
*
|
|
* NOTE: The flag is supported starting in Android 8 but there still is a small flash on the
|
|
* screen for devices that run Android 9 or below.
|
|
*/
|
|
override fun onConfigurationChanged(newConfig: Configuration) {
|
|
super.onConfigurationChanged(newConfig)
|
|
updateCameraUi()
|
|
}
|
|
|
|
/** Declare and bind preview, capture and analysis use cases */
|
|
private fun bindCameraUseCases() {
|
|
|
|
// Get screen metrics used to setup camera for full screen resolution
|
|
val metrics = DisplayMetrics().also { viewFinder.display?.getRealMetrics(it) }
|
|
|
|
val screenAspectRatio = aspectRatio(metrics.widthPixels, metrics.heightPixels)
|
|
|
|
val rotation = viewFinder.display?.rotation ?: 0
|
|
|
|
// Bind the CameraProvider to the LifeCycleOwner
|
|
val cameraSelector = CameraSelector.Builder().requireLensFacing(lensFacing).build()
|
|
val cameraProviderFuture = ProcessCameraProvider.getInstance(requireContext())
|
|
cameraProviderFuture.addListener({
|
|
|
|
// CameraProvider
|
|
val cameraProvider: ProcessCameraProvider = cameraProviderFuture.get()
|
|
|
|
|
|
// Preview
|
|
preview = Preview.Builder()
|
|
// We request aspect ratio but no resolution
|
|
.setTargetAspectRatio(screenAspectRatio)
|
|
// Set initial target rotation
|
|
.setTargetRotation(rotation)
|
|
.build()
|
|
|
|
// ImageCapture
|
|
imageCapture = ImageCapture.Builder()
|
|
.setCaptureMode(ImageCapture.CAPTURE_MODE_MAXIMIZE_QUALITY)
|
|
// We request aspect ratio but no resolution to match preview config, but letting
|
|
// CameraX optimize for whatever specific resolution best fits our use cases
|
|
.setTargetAspectRatio(screenAspectRatio)
|
|
// Set initial target rotation, we will have to call this again if rotation changes
|
|
// during the lifecycle of this use case
|
|
.setTargetRotation(rotation)
|
|
.build()
|
|
|
|
// Must unbind the use-cases before rebinding them
|
|
cameraProvider.unbindAll()
|
|
|
|
try {
|
|
// A variable number of use-cases can be passed here -
|
|
// camera provides access to CameraControl & CameraInfo
|
|
camera = cameraProvider.bindToLifecycle(
|
|
cameraLifecycleOwner, cameraSelector, preview, imageCapture
|
|
)
|
|
|
|
// Attach the viewfinder's surface provider to preview use case
|
|
preview?.setSurfaceProvider(viewFinder.surfaceProvider)
|
|
} catch (exc: Exception) {
|
|
Log.e(TAG, "Use case binding failed", exc)
|
|
}
|
|
}, ContextCompat.getMainExecutor(requireContext()))
|
|
}
|
|
|
|
override fun onPause() {
|
|
super.onPause()
|
|
cameraLifecycleOwner.pause()
|
|
}
|
|
|
|
override fun onDestroy() {
|
|
super.onDestroy()
|
|
cameraLifecycleOwner.destroy()
|
|
}
|
|
|
|
override fun onStop() {
|
|
super.onStop()
|
|
cameraLifecycleOwner.stop()
|
|
}
|
|
|
|
override fun onStart() {
|
|
super.onStart()
|
|
cameraLifecycleOwner.start()
|
|
}
|
|
|
|
|
|
/**
|
|
* setTargetAspectRatio requires enum value of
|
|
* [androidx.camera.core.AspectRatio]. Currently it has values of 4:3 & 16:9.
|
|
*
|
|
* Detecting the most suitable ratio for dimensions provided in @params by counting absolute
|
|
* of preview ratio to one of the provided values.
|
|
*
|
|
* @param width - preview width
|
|
* @param height - preview height
|
|
* @return suitable aspect ratio
|
|
*/
|
|
private fun aspectRatio(width: Int, height: Int): Int {
|
|
val previewRatio = max(width, height).toDouble() / min(width, height)
|
|
if (abs(previewRatio - RATIO_4_3_VALUE) <= abs(previewRatio - RATIO_16_9_VALUE)) {
|
|
return AspectRatio.RATIO_4_3
|
|
}
|
|
return AspectRatio.RATIO_16_9
|
|
}
|
|
|
|
/** Method used to re-draw the camera UI controls, called every time configuration changes. */
|
|
private fun updateCameraUi() {
|
|
|
|
// Remove previous UI if any
|
|
container.findViewById<ConstraintLayout>(R.id.camera_ui_container)?.let {
|
|
container.removeView(it)
|
|
}
|
|
|
|
// Inflate a new view containing all UI for controlling the camera
|
|
val controls = View.inflate(requireContext(), R.layout.camera_ui_container, container)
|
|
|
|
// In the background, load latest photo taken (if any) for gallery thumbnail
|
|
lifecycleScope.launch(Dispatchers.IO) {
|
|
// Find the last picture
|
|
val projection = arrayOf(
|
|
MediaStore.Images.ImageColumns._ID,
|
|
if (android.os.Build.VERSION.SDK_INT >= android.os.Build.VERSION_CODES.Q) MediaStore.Images.ImageColumns.DATE_TAKEN
|
|
else MediaStore.Images.ImageColumns.DATE_MODIFIED,
|
|
)
|
|
val cursor = requireContext().contentResolver
|
|
.query(
|
|
MediaStore.Images.Media.EXTERNAL_CONTENT_URI, projection, null,
|
|
null,
|
|
(if (android.os.Build.VERSION.SDK_INT >= android.os.Build.VERSION_CODES.Q) MediaStore.Images.ImageColumns.DATE_TAKEN
|
|
else MediaStore.Images.ImageColumns.DATE_MODIFIED) + " DESC"
|
|
)
|
|
if (cursor != null && cursor.moveToFirst()) {
|
|
val url = ContentUris.withAppendedId(
|
|
MediaStore.Images.Media.EXTERNAL_CONTENT_URI,
|
|
cursor.getLong(0)
|
|
)
|
|
setGalleryThumbnail(url)
|
|
cursor.close()
|
|
}
|
|
}
|
|
|
|
setupImageCapture(controls)
|
|
|
|
setupFlipCameras(controls)
|
|
|
|
setupUploadImage(controls)
|
|
}
|
|
|
|
private val uploadImageResultContract = registerForActivityResult(ActivityResultContracts.StartActivityForResult()) { result ->
|
|
val data: Intent? = result.data
|
|
if (result.resultCode == Activity.RESULT_OK && data != null) {
|
|
val images: ArrayList<String> = ArrayList()
|
|
val clipData = data.clipData
|
|
if (clipData != null) {
|
|
val count = clipData.itemCount
|
|
for (i in 0 until count) {
|
|
val imageUri: String = clipData.getItemAt(i).uri.toString()
|
|
images.add(imageUri)
|
|
}
|
|
startAlbumCreation(images)
|
|
} else if (data.data != null) {
|
|
images.add(data.data!!.toString())
|
|
startAlbumCreation(images)
|
|
}
|
|
}
|
|
}
|
|
|
|
private fun setupUploadImage(controls: View) {
|
|
// Listener for button used to view the most recent photo
|
|
controls.findViewById<ImageButton>(R.id.photo_view_button)?.setOnClickListener {
|
|
Intent().apply {
|
|
type = "image/*"
|
|
action = Intent.ACTION_GET_CONTENT
|
|
addCategory(Intent.CATEGORY_OPENABLE)
|
|
putExtra(Intent.EXTRA_LOCAL_ONLY, true)
|
|
putExtra(Intent.EXTRA_ALLOW_MULTIPLE, true)
|
|
uploadImageResultContract.launch(
|
|
Intent.createChooser(this, null)
|
|
)
|
|
}
|
|
}
|
|
}
|
|
|
|
private fun setupFlipCameras(controls: View) {
|
|
// Listener for button used to switch cameras
|
|
controls.findViewById<ImageButton>(R.id.camera_switch_button)?.setOnClickListener {
|
|
lensFacing = if (CameraSelector.LENS_FACING_FRONT == lensFacing) {
|
|
CameraSelector.LENS_FACING_BACK
|
|
} else {
|
|
CameraSelector.LENS_FACING_FRONT
|
|
}
|
|
// Re-bind use cases to update selected camera, being careful about permissions.
|
|
if (!allPermissionsGranted()) {
|
|
ActivityCompat.requestPermissions(
|
|
requireActivity(),
|
|
REQUIRED_PERMISSIONS,
|
|
REQUEST_CODE_PERMISSIONS
|
|
)
|
|
} else {
|
|
bindCameraUseCases()
|
|
}
|
|
}
|
|
}
|
|
|
|
private fun setupImageCapture(controls: View) {
|
|
// Listener for button used to capture photo
|
|
controls.findViewById<ImageButton>(R.id.camera_capture_button)?.setOnClickListener {
|
|
|
|
// Get a stable reference of the modifiable image capture use case
|
|
imageCapture?.let { imageCapture ->
|
|
|
|
// Create output file to hold the image
|
|
val photoFile = File.createTempFile(
|
|
"cachedPhoto", ".png", context?.cacheDir
|
|
)
|
|
|
|
// Setup image capture metadata
|
|
val metadata = Metadata().apply {
|
|
|
|
// Mirror image when using the front camera
|
|
isReversedHorizontal = lensFacing == CameraSelector.LENS_FACING_FRONT
|
|
}
|
|
|
|
// Create output options object which contains file + metadata
|
|
val outputOptions = ImageCapture.OutputFileOptions.Builder(photoFile)
|
|
.setMetadata(metadata)
|
|
.build()
|
|
|
|
// Setup image capture listener which is triggered after photo has been taken
|
|
imageCapture.takePicture(
|
|
outputOptions, cameraExecutor, object : ImageCapture.OnImageSavedCallback {
|
|
override fun onError(exc: ImageCaptureException) {
|
|
Log.e(TAG, "Photo capture failed: ${exc.message}", exc)
|
|
}
|
|
|
|
override fun onImageSaved(output: ImageCapture.OutputFileResults) {
|
|
val savedUri = output.savedUri ?: Uri.fromFile(photoFile)
|
|
val uri: ArrayList<String> = ArrayList()
|
|
uri.add(savedUri.toString())
|
|
startAlbumCreation(uri)
|
|
}
|
|
})
|
|
|
|
// Display flash animation to indicate that photo was captured
|
|
container.postDelayed({
|
|
container.foreground = ColorDrawable(Color.WHITE)
|
|
container.postDelayed(
|
|
{ container.foreground = null }, ANIMATION_FAST_MILLIS
|
|
)
|
|
}, ANIMATION_SLOW_MILLIS)
|
|
|
|
}
|
|
}
|
|
}
|
|
|
|
private fun startAlbumCreation(uris: ArrayList<String>) {
|
|
|
|
val intent = Intent(requireActivity(), PostCreationActivity::class.java)
|
|
.apply {
|
|
uris.forEach{
|
|
//Why are we using ClipData here? Because the FLAG_GRANT_READ_URI_PERMISSION
|
|
//needs to be applied to the URIs, and this flag flag only applies to the
|
|
//Intent's data and any URIs specified in its ClipData.
|
|
if(clipData == null){
|
|
clipData = ClipData("", emptyArray(), ClipData.Item(it.toUri()))
|
|
} else {
|
|
clipData!!.addItem(ClipData.Item(it.toUri()))
|
|
}
|
|
}
|
|
addFlags(Intent.FLAG_ACTIVITY_REORDER_TO_FRONT)
|
|
addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION)
|
|
}
|
|
|
|
if(inActivity){
|
|
requireActivity().setResult(Activity.RESULT_OK, intent)
|
|
requireActivity().finish()
|
|
} else {
|
|
startActivity(intent)
|
|
}
|
|
}
|
|
|
|
companion object {
|
|
|
|
private const val TAG = "CameraFragment"
|
|
private const val RATIO_4_3_VALUE = 4.0 / 3.0
|
|
private const val RATIO_16_9_VALUE = 16.0 / 9.0
|
|
|
|
}
|
|
} |