diff --git a/app/src/main/java/app/grapheneos/camera/ktx/InputStream.kt b/app/src/main/java/app/grapheneos/camera/ktx/InputStream.kt
new file mode 100644
index 00000000..95582246
--- /dev/null
+++ b/app/src/main/java/app/grapheneos/camera/ktx/InputStream.kt
@@ -0,0 +1,20 @@
+package app.grapheneos.camera.ktx
+
+import java.io.IOException
+import java.io.InputStream
+import java.io.OutputStream
+
+private const val DEFAULT_BUFFER_SIZE = 0x2000
+
+// TODO: Replace calls with transferTo when minSdk becomes 33 or above
+@Throws(IOException::class)
+fun InputStream.transfer(out: OutputStream): Long {
+ var transferred: Long = 0
+ val buffer = ByteArray(DEFAULT_BUFFER_SIZE)
+ var read: Int
+ while ((this.read(buffer, 0, DEFAULT_BUFFER_SIZE).also { read = it }) >= 0) {
+ out.write(buffer, 0, read)
+ transferred += read.toLong()
+ }
+ return transferred
+}
\ No newline at end of file
diff --git a/app/src/main/java/app/grapheneos/camera/ui/activities/CaptureActivity.kt b/app/src/main/java/app/grapheneos/camera/ui/activities/CaptureActivity.kt
index 00a2d768..9d6e6245 100644
--- a/app/src/main/java/app/grapheneos/camera/ui/activities/CaptureActivity.kt
+++ b/app/src/main/java/app/grapheneos/camera/ui/activities/CaptureActivity.kt
@@ -4,16 +4,23 @@ import android.content.Intent
import android.graphics.Bitmap
import android.graphics.Bitmap.CompressFormat
import android.graphics.BitmapFactory
+import android.graphics.ImageDecoder
import android.graphics.Matrix
import android.net.Uri
import android.os.Bundle
import android.os.Handler
import android.os.Looper
import android.provider.MediaStore
+import android.util.Log
import android.view.View
import android.view.ViewGroup.MarginLayoutParams
+import android.view.animation.Animation
+import android.view.animation.LinearInterpolator
+import android.view.animation.RotateAnimation
import android.widget.ImageButton
import android.widget.ImageView
+import androidx.activity.result.PickVisualMediaRequest
+import androidx.activity.result.contract.ActivityResultContracts
import androidx.camera.core.ImageCapture
import androidx.camera.core.ImageCaptureException
import androidx.camera.core.ImageProxy
@@ -21,12 +28,15 @@ import androidx.core.content.ContextCompat
import app.grapheneos.camera.R
import app.grapheneos.camera.util.getParcelableExtra
import java.io.ByteArrayOutputStream
-import java.lang.Exception
import java.nio.ByteBuffer
+import androidx.core.graphics.scale
+import app.grapheneos.camera.ktx.transfer
+import kotlin.Exception
open class CaptureActivity : MainActivity() {
companion object {
+ private const val TAG = "CaptureActivity"
private const val CAPTURE_BUTTON_APPEARANCE_DELAY = 1000L
}
@@ -34,10 +44,23 @@ open class CaptureActivity : MainActivity() {
lateinit var bitmap: Bitmap
private lateinit var retakeIcon: ImageView
+ private lateinit var whiteOptionCircle: ImageView
+ protected lateinit var selectImageIcon: ImageView
private lateinit var flipCameraContent: ImageView
+
+ protected var isPreviewShown = false
+
lateinit var confirmButton: ImageButton
+ private val imagePicker = registerForActivityResult(ActivityResultContracts.PickVisualMedia()) { uri ->
+ if (uri != null) {
+ confirmPickedImage(uri)
+ } else {
+ showMessage(R.string.no_image_selected)
+ }
+ }
+
fun isOutputUriAvailable(): Boolean {
return ::outputUri.isInitialized
}
@@ -46,6 +69,8 @@ open class CaptureActivity : MainActivity() {
super.onCreate(savedInstanceState)
retakeIcon = findViewById(R.id.retake_icon)
+ selectImageIcon = findViewById(R.id.select_image_icon)
+ whiteOptionCircle = findViewById(R.id.white_option_circle)
flipCameraContent = findViewById(R.id.flip_camera_icon_content)
confirmButton = findViewById(R.id.confirm_button)
@@ -54,10 +79,22 @@ open class CaptureActivity : MainActivity() {
outputUri = it
}
+ imagePreview.visibility = View.GONE
+ whiteOptionCircle.visibility = View.GONE
+ selectImageIcon.visibility = View.VISIBLE
+
+ thirdCircle.setOnClickListener {
+ imagePicker.launch(PickVisualMediaRequest(ActivityResultContracts.PickVisualMedia.ImageOnly))
+ }
+
// Disable capture button for a while (to avoid picture capture)
captureButton.isEnabled = false
captureButton.alpha = 0f
+ // Disable capture button for a while (to avoid picture capture)
+ thirdCircle.isEnabled = false
+ thirdOption.alpha = 0f
+
// Enable the capture button after a while
Handler(Looper.getMainLooper()).postDelayed({
@@ -68,6 +105,13 @@ open class CaptureActivity : MainActivity() {
captureButton.isEnabled = true
}
+ thirdOption.animate()
+ .alpha(1f)
+ .setDuration(300)
+ .withEndAction {
+ thirdCircle.isEnabled = true
+ }
+
}, CAPTURE_BUTTON_APPEARANCE_DELAY)
// Remove the modes tab layout as we do not want the user to be able to switch to
@@ -93,9 +137,6 @@ open class CaptureActivity : MainActivity() {
finish()
}
- // Remove the third option/circle from the UI
- thirdOption.visibility = View.INVISIBLE
-
captureButton.setOnClickListener {
if (timerDuration == 0) {
takePicture()
@@ -108,12 +149,40 @@ open class CaptureActivity : MainActivity() {
}
}
- retakeIcon.setOnClickListener {
- hidePreview()
+ flipCameraCircle.setOnClickListener {
+ if (isPreviewShown) {
+ hidePreview()
+ return@setOnClickListener
+ }
+
+ if (videoCapturer.isRecording) {
+ videoCapturer.isPaused = !videoCapturer.isPaused
+ return@setOnClickListener
+ }
+
+ val rotation: Float = if (flipCameraIcon.rotation < 180) {
+ 180f
+ } else {
+ 360f
+ }
+
+ val rotate = RotateAnimation(
+ 0F,
+ rotation,
+ Animation.RELATIVE_TO_SELF,
+ 0.5f,
+ Animation.RELATIVE_TO_SELF,
+ 0.5f
+ )
+ rotate.duration = 400
+ rotate.interpolator = LinearInterpolator()
+
+ it.startAnimation(rotate)
+ camConfig.toggleCameraSelector()
}
confirmButton.setOnClickListener {
- confirmImage()
+ confirmCapturedImage()
}
// Display the activity
@@ -155,6 +224,8 @@ open class CaptureActivity : MainActivity() {
open fun showPreview() {
+ isPreviewShown = true
+
camConfig.cameraProvider?.unbindAll()
mainOverlay.setImageBitmap(bitmap)
@@ -169,9 +240,14 @@ open class CaptureActivity : MainActivity() {
confirmButton.visibility = View.VISIBLE
previewView.visibility = View.INVISIBLE
+
+ thirdOption.visibility = View.INVISIBLE
}
open fun hidePreview() {
+
+ isPreviewShown = false
+
camConfig.startCamera(true)
settingsIcon.visibility = View.VISIBLE
@@ -183,9 +259,55 @@ open class CaptureActivity : MainActivity() {
confirmButton.visibility = View.INVISIBLE
previewView.visibility = View.VISIBLE
+
+ thirdOption.visibility = View.VISIBLE
+ }
+
+ private fun confirmPickedImage(uri: Uri) {
+
+ val resultIntent = Intent("inline-data")
+
+ if (::outputUri.isInitialized) {
+
+ try {
+ val fis = contentResolver.openInputStream(uri)
+ if (fis != null) {
+ fis.use {
+ val fos = contentResolver.openOutputStream(outputUri)
+ if (fos != null) {
+ fos.use {
+ fis.transfer(fos)
+ setResult(RESULT_OK)
+ finish()
+ }
+ } else {
+ showMessage(R.string.unexpected_error_occurred)
+ Log.e(TAG, "Output URI's output stream found null")
+ }
+ }
+ } else {
+ showMessage(R.string.unexpected_error_occurred)
+ Log.e(TAG, "Chosen Image URI's input stream found null")
+ }
+ } catch (e: Exception) {
+ showMessage(R.string.unexpected_error_occurred)
+ Log.e(TAG, "Error occurred while writing back image to output URI", e)
+ }
+ } else {
+ try {
+ bitmap = ImageDecoder.decodeBitmap(ImageDecoder.createSource(contentResolver, uri))
+ bitmap = resizeImage(bitmap)
+ resultIntent.putExtra("data", bitmap)
+ setResult(RESULT_OK, resultIntent)
+ finish()
+ } catch (e: Exception) {
+ showMessage(R.string.unexpected_error_occurred)
+ Log.e(TAG, "Error while sending bitmap to caller activity", e)
+ }
+ }
}
- private fun confirmImage() {
+ private fun confirmCapturedImage() {
val resultIntent = Intent("inline-data")
@@ -234,16 +356,17 @@ open class CaptureActivity : MainActivity() {
private fun resizeImage(image: Bitmap): Bitmap {
+ // If within supported 1 megabyte size
+ if (image.byteCount <= 0x100000)
+ return image
+
val width = image.width
val height = image.height
val scaleWidth = width / 10
val scaleHeight = height / 10
- if (image.byteCount <= 1000000)
- return image
-
- return Bitmap.createScaledBitmap(image, scaleWidth, scaleHeight, false)
+ return image.scale(scaleWidth, scaleHeight, false)
}
private fun Bitmap.rotate(degrees: Float): Bitmap {
diff --git a/app/src/main/java/app/grapheneos/camera/ui/activities/MainActivity.kt b/app/src/main/java/app/grapheneos/camera/ui/activities/MainActivity.kt
index 6895accc..644a63f2 100644
--- a/app/src/main/java/app/grapheneos/camera/ui/activities/MainActivity.kt
+++ b/app/src/main/java/app/grapheneos/camera/ui/activities/MainActivity.kt
@@ -193,6 +193,8 @@ open class MainActivity : AppCompatActivity(),
lateinit var gCircleFrame: FrameLayout
+ lateinit var flipCameraIcon: ImageView
+
private lateinit var gAngleTextView: TextView
private lateinit var gCircle: LinearLayout
@@ -712,7 +714,6 @@ open class MainActivity : AppCompatActivity(),
return@setOnClickListener
}
- val flipCameraIcon: ImageView = binding.flipCameraIcon
val rotation: Float = if (flipCameraIcon.rotation < 180) {
180f
} else {
@@ -786,6 +787,8 @@ open class MainActivity : AppCompatActivity(),
}
}
+ flipCameraIcon = binding.flipCameraIcon
+
cancelButtonView = binding.cancelButton
// cancelButtonView.setOnClickListener(object : View.OnClickListener {
//
diff --git a/app/src/main/java/app/grapheneos/camera/ui/activities/VideoCaptureActivity.kt b/app/src/main/java/app/grapheneos/camera/ui/activities/VideoCaptureActivity.kt
index da57086c..2186d346 100644
--- a/app/src/main/java/app/grapheneos/camera/ui/activities/VideoCaptureActivity.kt
+++ b/app/src/main/java/app/grapheneos/camera/ui/activities/VideoCaptureActivity.kt
@@ -6,6 +6,8 @@ import android.os.Bundle
import android.provider.MediaStore.EXTRA_OUTPUT
import android.view.View
import android.widget.ImageView
+import androidx.activity.result.PickVisualMediaRequest
+import androidx.activity.result.contract.ActivityResultContracts
import app.grapheneos.camera.R
class VideoCaptureActivity : CaptureActivity() {
@@ -15,6 +17,14 @@ class VideoCaptureActivity : CaptureActivity() {
private var savedUri: Uri? = null
+ private val videoPicker = registerForActivityResult(ActivityResultContracts.PickVisualMedia()) { uri ->
+ if (uri != null) {
+ confirmSelectedVideo(uri)
+ } else {
+ showMessage(R.string.no_video_selected)
+ }
+ }
+
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
@@ -23,6 +33,19 @@ class VideoCaptureActivity : CaptureActivity() {
captureButton.setImageResource(R.drawable.recording)
+ thirdCircle.setOnClickListener {
+ if (isPreviewShown) {
+ val i = Intent(
+ this@VideoCaptureActivity,
+ VideoPlayer::class.java
+ )
+ i.putExtra("videoUri", savedUri)
+ startActivity(i)
+ } else {
+ videoPicker.launch(PickVisualMediaRequest(ActivityResultContracts.PickVisualMedia.VideoOnly))
+ }
+ }
+
captureButton.setOnClickListener OnClickListener@{
if (videoCapturer.isRecording) {
videoCapturer.stopRecording()
@@ -31,21 +54,12 @@ class VideoCaptureActivity : CaptureActivity() {
}
}
- playPreview.setOnClickListener {
- val i = Intent(
- this@VideoCaptureActivity,
- VideoPlayer::class.java
- )
- i.putExtra("videoUri", savedUri)
- startActivity(i)
- }
-
imagePreview.visibility = View.GONE
whiteOptionCircle.visibility = View.GONE
- playPreview.visibility = View.VISIBLE
+ // playPreview.visibility = View.VISIBLE
confirmButton.setOnClickListener {
- confirmVideo()
+ confirmCapturedVideo()
}
}
@@ -64,9 +78,12 @@ class VideoCaptureActivity : CaptureActivity() {
override fun showPreview() {
super.showPreview()
thirdOption.visibility = View.VISIBLE
+ selectImageIcon.visibility = View.GONE
+ playPreview.visibility = View.VISIBLE
+ thirdCircle.setImageResource(R.drawable.option_circle)
}
- private fun confirmVideo() {
+ private fun confirmCapturedVideo() {
if (savedUri == null) {
setResult(RESULT_CANCELED)
} else {
@@ -82,8 +99,18 @@ class VideoCaptureActivity : CaptureActivity() {
finish()
}
+ private fun confirmSelectedVideo(uri: Uri) {
+ val resultIntent = Intent()
+ resultIntent.data = uri
+ resultIntent.flags = Intent.FLAG_GRANT_READ_URI_PERMISSION
+ resultIntent.putExtra(EXTRA_OUTPUT, uri)
+ setResult(RESULT_OK, resultIntent)
+ finish()
+ }
+
override fun hidePreview() {
super.hidePreview()
- thirdOption.visibility = View.INVISIBLE
+ selectImageIcon.visibility = View.VISIBLE
+ playPreview.visibility = View.GONE
}
}
\ No newline at end of file
diff --git a/app/src/main/res/drawable/image_search.xml b/app/src/main/res/drawable/image_search.xml
new file mode 100644
index 00000000..382508bb
--- /dev/null
+++ b/app/src/main/res/drawable/image_search.xml
@@ -0,0 +1,5 @@
+
+
+
+
+
diff --git a/app/src/main/res/layout/activity_main.xml b/app/src/main/res/layout/activity_main.xml
index bb02b81c..5c14acb7 100644
--- a/app/src/main/res/layout/activity_main.xml
+++ b/app/src/main/res/layout/activity_main.xml
@@ -365,6 +365,15 @@
android:layout_gravity="center"
android:contentDescription="@string/play_preview"/>
+
+
diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml
index 9aa6ae1e..14221c83 100644
--- a/app/src/main/res/values/strings.xml
+++ b/app/src/main/res/values/strings.xml
@@ -193,4 +193,9 @@
The video\'s audio recording has been muted
The video\'s audio recording has been unmuted
+
+ Select media from picker
+ No image selected from picker
+ No video selected from picker
+ An unexpected error occurred