Aperture: Do not store photos captured via other apps

When another app uses an Intent to capture a photo, keep it in memory
rather than saving it to disk.

Note: Videos are intentionally not affected by this change. This
matches stock behavior.

Co-authored-by: LuK1337 <priv.luk@gmail.com>
Change-Id: I19469728d419490a8ba9268e4407e7c4babfd577
diff --git a/app/Android.bp b/app/Android.bp
index 61f8f33..104eb36 100644
--- a/app/Android.bp
+++ b/app/Android.bp
@@ -19,6 +19,7 @@
         "androidx.core_core-ktx",
         "androidx.appcompat_appcompat",
         "androidx-constraintlayout_constraintlayout",
+        "androidx.exifinterface_exifinterface",
         "androidx.lifecycle_lifecycle-viewmodel-ktx",
         "androidx.preference_preference",
         "Aperture_com.google.android.material_material",
diff --git a/app/build.gradle.kts b/app/build.gradle.kts
index 4006e75..04e9414 100644
--- a/app/build.gradle.kts
+++ b/app/build.gradle.kts
@@ -54,6 +54,7 @@
     implementation("androidx.core:core-ktx:1.9.0")
     implementation("androidx.appcompat:appcompat:1.6.0")
     implementation("androidx.constraintlayout:constraintlayout:2.1.4")
+    implementation("androidx.exifinterface:exifinterface:1.3.6")
     implementation("androidx.lifecycle:lifecycle-viewmodel-ktx:2.5.1")
     implementation("androidx.preference:preference:1.2.0")
     implementation("com.google.android.material:material:1.9.0-alpha01")
diff --git a/app/src/main/java/org/lineageos/aperture/BitmapExt.kt b/app/src/main/java/org/lineageos/aperture/BitmapExt.kt
index 79f5612..7724815 100644
--- a/app/src/main/java/org/lineageos/aperture/BitmapExt.kt
+++ b/app/src/main/java/org/lineageos/aperture/BitmapExt.kt
@@ -1,11 +1,14 @@
 /*
- * SPDX-FileCopyrightText: 2022 The LineageOS Project
+ * SPDX-FileCopyrightText: 2022-2023 The LineageOS Project
  * SPDX-License-Identifier: Apache-2.0
  */
 
 package org.lineageos.aperture
 
 import android.graphics.Bitmap
+import androidx.core.graphics.scale
+import org.lineageos.aperture.utils.ExifUtils.Transform
+import kotlin.math.min
 
 /**
  * Stack Blur v1.0 from
@@ -259,3 +262,37 @@
 
     return bitmap
 }
+
+internal fun Bitmap.transform(transform: Transform): Bitmap {
+    if (transform == Transform.DEFAULT) {
+        // nothing more to do
+        return this
+    }
+    return Bitmap.createBitmap(this, 0, 0, width, height, transform.toMatrix(), true)
+}
+
+internal fun Bitmap.scale(maxSideLen: Int): Bitmap {
+    val aspectRatio = width.toFloat() / height
+    val newWidth: Int
+    val newHeight: Int
+    if (aspectRatio > 1) {
+        newWidth = min(width, maxSideLen)
+        newHeight = if (newWidth == width) {
+            height
+        } else {
+            (newWidth.toFloat() / aspectRatio).toInt()
+        }
+    } else {
+        newHeight = min(height, maxSideLen)
+        newWidth = if (newHeight == height) {
+            width
+        } else {
+            (newHeight * aspectRatio).toInt()
+        }
+    }
+    return if (width == newWidth) {
+        this
+    } else {
+        scale(newWidth, newHeight)
+    }
+}
diff --git a/app/src/main/java/org/lineageos/aperture/CameraActivity.kt b/app/src/main/java/org/lineageos/aperture/CameraActivity.kt
index 6a062f2..c20f6e5 100644
--- a/app/src/main/java/org/lineageos/aperture/CameraActivity.kt
+++ b/app/src/main/java/org/lineageos/aperture/CameraActivity.kt
@@ -10,6 +10,7 @@
 import android.app.KeyguardManager
 import android.content.Intent
 import android.content.pm.ActivityInfo
+import android.graphics.BitmapFactory
 import android.graphics.Color
 import android.graphics.Rect
 import android.graphics.drawable.AnimatedVectorDrawable
@@ -89,6 +90,7 @@
 import org.lineageos.aperture.utils.CameraMode
 import org.lineageos.aperture.utils.CameraSoundsUtils
 import org.lineageos.aperture.utils.CameraState
+import org.lineageos.aperture.utils.ExifUtils
 import org.lineageos.aperture.utils.FlashMode
 import org.lineageos.aperture.utils.Framerate
 import org.lineageos.aperture.utils.GoogleLensUtils
@@ -101,7 +103,10 @@
 import org.lineageos.aperture.utils.TimeUtils
 import org.lineageos.aperture.utils.TimerMode
 import org.lineageos.aperture.utils.VideoStabilizationMode
+import java.io.ByteArrayInputStream
+import java.io.ByteArrayOutputStream
 import java.io.FileNotFoundException
+import java.io.InputStream
 import java.util.concurrent.ExecutorService
 import kotlin.math.abs
 import kotlin.reflect.safeCast
@@ -699,11 +704,14 @@
         }
 
         // Set capture preview callback
-        capturePreviewLayout.onChoiceCallback = { uri ->
-            uri?.let {
-                sendIntentResultAndExit(it)
-            } ?: run {
-                capturePreviewLayout.isVisible = false
+        capturePreviewLayout.onChoiceCallback = { input ->
+            when (input) {
+                null -> {
+                    capturePreviewLayout.isVisible = false
+                }
+                is InputStream,
+                is Uri -> sendIntentResultAndExit(input)
+                else -> throw Exception("Invalid input")
             }
         }
 
@@ -850,12 +858,19 @@
         cameraState = CameraState.TAKING_PHOTO
         shutterButton.isEnabled = false
 
+        val photoOutputStream = if (singleCaptureMode) {
+            ByteArrayOutputStream(SINGLE_CAPTURE_PHOTO_BUFFER_INITIAL_SIZE_BYTES)
+        } else {
+            null
+        }
+
         // Create output options object which contains file + metadata
         val outputOptions = StorageUtils.getPhotoMediaStoreOutputOptions(
             contentResolver,
             ImageCapture.Metadata().apply {
                 location = this@CameraActivity.location
-            }
+            },
+            photoOutputStream
         )
 
         // Set up image capture listener, which is triggered after photo has
@@ -888,6 +903,11 @@
                         output.savedUri?.let {
                             openCapturePreview(it, MediaType.PHOTO)
                         }
+                        photoOutputStream?.use {
+                            openCapturePreview(
+                                ByteArrayInputStream(photoOutputStream.toByteArray())
+                            )
+                        }
                     }
                 }
             }
@@ -1716,7 +1736,14 @@
 
     private fun openCapturePreview(uri: Uri, mediaType: MediaType) {
         runOnUiThread {
-            capturePreviewLayout.updateUri(uri, mediaType)
+            capturePreviewLayout.updateSource(uri, mediaType)
+            capturePreviewLayout.isVisible = true
+        }
+    }
+
+    private fun openCapturePreview(photoInputStream: InputStream) {
+        runOnUiThread {
+            capturePreviewLayout.updateSource(photoInputStream)
             capturePreviewLayout.isVisible = true
         }
     }
@@ -1725,7 +1752,7 @@
      * When the user took a photo or a video and confirmed it, its URI gets sent back to the
      * app that sent the intent and closes the camera.
      */
-    private fun sendIntentResultAndExit(uri: Uri) {
+    private fun sendIntentResultAndExit(input: Any) {
         // The user confirmed the choice
         var outputUri: Uri? = null
         if (intent.extras?.containsKey(MediaStore.EXTRA_OUTPUT) == true) {
@@ -1739,9 +1766,15 @@
 
         outputUri?.let {
             try {
-                contentResolver.openInputStream(uri).use { inputStream ->
-                    contentResolver.openOutputStream(it).use { outputStream ->
-                        inputStream!!.copyTo(outputStream!!)
+                contentResolver.openOutputStream(it).use { outputStream ->
+                    when (input) {
+                        is InputStream -> input.use {
+                            input.copyTo(outputStream!!)
+                        }
+                        is Uri -> contentResolver.openInputStream(input).use { inputStream ->
+                            inputStream!!.copyTo(outputStream!!)
+                        }
+                        else -> throw IllegalStateException("Input is not Uri or InputStream")
                     }
                 }
 
@@ -1751,9 +1784,25 @@
                 setResult(RESULT_CANCELED)
             }
         } ?: setResult(RESULT_OK, Intent().apply {
-            data = uri
-            flags = Intent.FLAG_GRANT_READ_URI_PERMISSION
-            putExtra(MediaStore.EXTRA_OUTPUT, uri)
+            when (input) {
+                is InputStream -> {
+                    // No output URI provided, so return the photo inline as a downscaled Bitmap.
+                    action = "inline-data"
+                    val transform = ExifUtils.getTransform(input)
+                    val bitmap = input.use { BitmapFactory.decodeStream(input) }
+                    val scaledAndRotatedBitmap = bitmap.scale(
+                        SINGLE_CAPTURE_INLINE_MAX_SIDE_LEN_PIXELS
+                    ).transform(transform)
+                    putExtra("data", scaledAndRotatedBitmap)
+                }
+                is Uri -> {
+                    // We saved the media (video), so return the URI that we saved.
+                    data = input
+                    flags = Intent.FLAG_GRANT_READ_URI_PERMISSION
+                    putExtra(MediaStore.EXTRA_OUTPUT, input)
+                }
+                else -> throw IllegalStateException("Input is not Uri or InputStream")
+            }
         })
 
         finish()
@@ -1837,6 +1886,16 @@
         private const val MSG_HIDE_EXPOSURE_SLIDER = 2
         private const val MSG_ON_PINCH_TO_ZOOM = 3
 
+        private const val SINGLE_CAPTURE_PHOTO_BUFFER_INITIAL_SIZE_BYTES = 8 * 1024 * 1024 // 8 MiB
+
+        // We need to return something small enough so as not to overwhelm Binder. 1MB is the
+        // per-process limit across all transactions. Camera2 sets a max pixel count of 51200.
+        // We set a max side length of 256, for a max pixel count of 65536. Even at 4 bytes per
+        // pixel, this is only 256K, well within the limits. (Note: It's not clear if any modern
+        // app expects a photo to be returned inline, rather than providing an output URI.)
+        // https://developer.android.com/guide/components/activities/parcelables-and-bundles#sdbp
+        private const val SINGLE_CAPTURE_INLINE_MAX_SIDE_LEN_PIXELS = 256
+
         private val EXPOSURE_LEVEL_FORMATTER = DecimalFormat("+#;-#")
     }
 }
diff --git a/app/src/main/java/org/lineageos/aperture/ui/CapturePreviewLayout.kt b/app/src/main/java/org/lineageos/aperture/ui/CapturePreviewLayout.kt
index d43c4e7..f761254 100644
--- a/app/src/main/java/org/lineageos/aperture/ui/CapturePreviewLayout.kt
+++ b/app/src/main/java/org/lineageos/aperture/ui/CapturePreviewLayout.kt
@@ -6,6 +6,7 @@
 package org.lineageos.aperture.ui
 
 import android.content.Context
+import android.graphics.BitmapFactory
 import android.net.Uri
 import android.util.AttributeSet
 import android.widget.ImageButton
@@ -17,8 +18,10 @@
 import androidx.media3.ui.PlayerView
 import org.lineageos.aperture.R
 import org.lineageos.aperture.smoothRotate
+import org.lineageos.aperture.utils.ExifUtils
 import org.lineageos.aperture.utils.MediaType
 import org.lineageos.aperture.utils.Rotation
+import java.io.InputStream
 
 /**
  * Image/video preview fragment
@@ -27,7 +30,8 @@
 class CapturePreviewLayout(context: Context, attrs: AttributeSet?) : ConstraintLayout(
     context, attrs
 ) {
-    private lateinit var uri: Uri
+    private var uri: Uri? = null
+    private var photoInputStream: InputStream? = null
     private lateinit var mediaType: MediaType
 
     private var exoPlayer: ExoPlayer? = null
@@ -38,10 +42,10 @@
     private val videoView by lazy { findViewById<PlayerView>(R.id.videoView) }
 
     /**
-     * URI is null == canceled
-     * URI is not null == confirmed
+     * input is null == canceled
+     * input is not null == confirmed
      */
-    internal var onChoiceCallback: (uri: Uri?) -> Unit = {}
+    internal var onChoiceCallback: (input: Any?) -> Unit = {}
 
     internal var screenRotation = Rotation.ROTATION_0
         set(value) {
@@ -58,12 +62,13 @@
         }
         confirmButton.setOnClickListener {
             stopPreview()
-            onChoiceCallback(uri)
+            onChoiceCallback(uri ?: photoInputStream)
         }
     }
 
-    internal fun updateUri(uri: Uri, mediaType: MediaType) {
+    internal fun updateSource(uri: Uri, mediaType: MediaType) {
         this.uri = uri
+        this.photoInputStream = null
         this.mediaType = mediaType
 
         imageView.isVisible = mediaType == MediaType.PHOTO
@@ -72,10 +77,36 @@
         startPreview()
     }
 
+    internal fun updateSource(photoInputStream: InputStream) {
+        this.uri = null
+        this.photoInputStream = photoInputStream
+        this.mediaType = MediaType.PHOTO
+
+        imageView.isVisible = true
+        videoView.isVisible = false
+
+        startPreview()
+    }
+
     private fun startPreview() {
+        assert((uri == null) != (photoInputStream == null)) {
+            "Expected uri or photoInputStream, not both."
+        }
         when (mediaType) {
             MediaType.PHOTO -> {
-                imageView.setImageURI(uri)
+                if (uri != null) {
+                    imageView.rotation = 0f
+                    imageView.setImageURI(uri)
+                } else {
+                    val inputStream = photoInputStream!!
+                    val transform = ExifUtils.getTransform(inputStream)
+                    inputStream.mark(Int.MAX_VALUE)
+                    val bitmap = BitmapFactory.decodeStream(inputStream)
+                    inputStream.reset()
+                    imageView.rotation =
+                        transform.rotation.offset.toFloat() - screenRotation.offset
+                    imageView.setImageBitmap(bitmap)
+                }
             }
             MediaType.VIDEO -> {
                 exoPlayer = ExoPlayer.Builder(context)
@@ -83,7 +114,7 @@
                     .also {
                         videoView.player = it
 
-                        it.setMediaItem(MediaItem.fromUri(uri))
+                        it.setMediaItem(MediaItem.fromUri(uri!!))
 
                         it.playWhenReady = true
                         it.seekTo(0)
diff --git a/app/src/main/java/org/lineageos/aperture/utils/ExifUtils.kt b/app/src/main/java/org/lineageos/aperture/utils/ExifUtils.kt
new file mode 100644
index 0000000..4e6cf07
--- /dev/null
+++ b/app/src/main/java/org/lineageos/aperture/utils/ExifUtils.kt
@@ -0,0 +1,57 @@
+/*
+ * SPDX-FileCopyrightText: 2023 The LineageOS Project
+ * SPDX-License-Identifier: Apache-2.0
+ */
+
+package org.lineageos.aperture.utils
+
+import android.graphics.Matrix
+import androidx.exifinterface.media.ExifInterface
+import java.io.InputStream
+
+class ExifUtils {
+    data class Transform(val rotation: Rotation, val mirror: Boolean) {
+        fun toMatrix(): Matrix {
+            return Matrix().apply {
+                if (mirror) {
+                    postScale(-1f, 1f)
+                }
+                postRotate(rotation.offset.toFloat())
+            }
+        }
+
+        companion object {
+            val DEFAULT = Transform(Rotation.ROTATION_0, false)
+        }
+    }
+
+    companion object {
+        private val orientationMap = mapOf(
+            ExifInterface.ORIENTATION_UNDEFINED to Transform.DEFAULT,
+            ExifInterface.ORIENTATION_NORMAL to Transform.DEFAULT,
+            ExifInterface.ORIENTATION_ROTATE_90 to Transform(Rotation.ROTATION_90, false),
+            ExifInterface.ORIENTATION_ROTATE_180 to Transform(Rotation.ROTATION_180, false),
+            ExifInterface.ORIENTATION_ROTATE_270 to Transform(Rotation.ROTATION_270, false),
+            ExifInterface.ORIENTATION_FLIP_HORIZONTAL to Transform(Rotation.ROTATION_0, true),
+            ExifInterface.ORIENTATION_FLIP_VERTICAL to Transform(Rotation.ROTATION_180, true),
+            ExifInterface.ORIENTATION_TRANSPOSE to Transform(Rotation.ROTATION_270, true),
+            ExifInterface.ORIENTATION_TRANSVERSE to Transform(Rotation.ROTATION_90, true),
+        )
+
+        private fun getOrientation(inputStream: InputStream): Int {
+            inputStream.mark(Int.MAX_VALUE)
+            val orientation =
+                ExifInterface(inputStream).getAttributeInt(ExifInterface.TAG_ORIENTATION, 0)
+            inputStream.reset()
+            return orientation
+        }
+
+        private fun orientationToTransform(exifOrientation: Int): Transform {
+            return orientationMap.getOrDefault(exifOrientation, Transform.DEFAULT)
+        }
+
+        fun getTransform(inputStream: InputStream): Transform {
+            return orientationToTransform(getOrientation(inputStream))
+        }
+    }
+}
diff --git a/app/src/main/java/org/lineageos/aperture/utils/StorageUtils.kt b/app/src/main/java/org/lineageos/aperture/utils/StorageUtils.kt
index 7636b2b..362df75 100644
--- a/app/src/main/java/org/lineageos/aperture/utils/StorageUtils.kt
+++ b/app/src/main/java/org/lineageos/aperture/utils/StorageUtils.kt
@@ -1,5 +1,5 @@
 /*
- * SPDX-FileCopyrightText: 2022 The LineageOS Project
+ * SPDX-FileCopyrightText: 2022-2023 The LineageOS Project
  * SPDX-License-Identifier: Apache-2.0
  */
 
@@ -13,6 +13,8 @@
 import android.provider.MediaStore
 import androidx.camera.core.ImageCapture
 import androidx.camera.video.MediaStoreOutputOptions
+import java.io.ByteArrayOutputStream
+import java.io.OutputStream
 import java.text.SimpleDateFormat
 import java.util.Locale
 
@@ -25,7 +27,8 @@
      */
     fun getPhotoMediaStoreOutputOptions(
         contentResolver: ContentResolver,
-        metadata: ImageCapture.Metadata
+        metadata: ImageCapture.Metadata,
+        outputStream: OutputStream? = null
     ): ImageCapture.OutputFileOptions {
         val contentValues = ContentValues().apply {
             put(MediaStore.MediaColumns.DISPLAY_NAME, getCurrentTimeString())
@@ -35,11 +38,15 @@
             }
         }
 
-        return ImageCapture.OutputFileOptions
-            .Builder(
+        val outputFileOptions = if (outputStream != null) {
+            ImageCapture.OutputFileOptions.Builder(outputStream)
+        } else {
+            ImageCapture.OutputFileOptions.Builder(
                 contentResolver, MediaStore.Images.Media.EXTERNAL_CONTENT_URI,
                 contentValues
             )
+        }
+        return outputFileOptions
             .setMetadata(metadata)
             .build()
     }