PixelDroid-App-Android/app/src/main/java/org/pixeldroid/app/postCreation/camera/CameraFragment.kt

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
}
}