Aperture: Rotate selected views

This change adds rotation for the following elements:
* Primary/secondary buttons
* Countdown text
* Slider text
* Capture preview buttons

Change-Id: I065f49b18f43eb53954fe81329362141205c019f
diff --git a/app/src/main/java/org/lineageos/aperture/CameraActivity.kt b/app/src/main/java/org/lineageos/aperture/CameraActivity.kt
index 2547035..653d92e 100644
--- a/app/src/main/java/org/lineageos/aperture/CameraActivity.kt
+++ b/app/src/main/java/org/lineageos/aperture/CameraActivity.kt
@@ -1,5 +1,5 @@
 /*
- * SPDX-FileCopyrightText: 2022 The LineageOS Project
+ * SPDX-FileCopyrightText: 2022-2023 The LineageOS Project
  * SPDX-License-Identifier: Apache-2.0
  */
 
@@ -29,6 +29,7 @@
 import android.view.GestureDetector
 import android.view.KeyEvent
 import android.view.MotionEvent
+import android.view.OrientationEventListener
 import android.view.ScaleGestureDetector
 import android.view.View
 import android.view.WindowManager
@@ -58,9 +59,11 @@
 import androidx.core.view.WindowCompat.getInsetsController
 import androidx.core.view.WindowInsetsCompat
 import androidx.core.view.WindowInsetsControllerCompat
+import androidx.core.view.children
 import androidx.core.view.doOnLayout
 import androidx.core.view.isInvisible
 import androidx.core.view.isVisible
+import androidx.lifecycle.MutableLiveData
 import androidx.preference.PreferenceManager
 import coil.decode.VideoFrameDecoder
 import coil.load
@@ -89,6 +92,7 @@
 import org.lineageos.aperture.utils.GridMode
 import org.lineageos.aperture.utils.MediaType
 import org.lineageos.aperture.utils.PermissionsUtils
+import org.lineageos.aperture.utils.Rotation
 import org.lineageos.aperture.utils.ShortcutsUtils
 import org.lineageos.aperture.utils.StabilizationMode
 import org.lineageos.aperture.utils.StorageUtils
@@ -97,6 +101,7 @@
 import java.io.FileNotFoundException
 import java.util.concurrent.ExecutorService
 import kotlin.math.abs
+import kotlin.reflect.safeCast
 
 @androidx.camera.camera2.interop.ExperimentalCamera2Interop
 @androidx.camera.core.ExperimentalZeroShutterLag
@@ -341,6 +346,23 @@
         }
     }
 
+    private val screenRotation = MutableLiveData<Rotation>()
+    private val orientationEventListener by lazy {
+        object : OrientationEventListener(this) {
+            override fun onOrientationChanged(orientation: Int) {
+                if (orientation == ORIENTATION_UNKNOWN) {
+                    return
+                }
+
+                val rotation = Rotation.fromDegreesInAperture(orientation)
+
+                if (screenRotation.value != rotation) {
+                    screenRotation.value = rotation
+                }
+            }
+        }
+    }
+
     enum class ShutterAnimation(val resourceId: Int) {
         InitPhoto(R.drawable.avd_photo_capture),
         InitVideo(R.drawable.avd_mode_video_photo),
@@ -658,6 +680,9 @@
         // Bind viewfinder and preview blur view
         previewBlurView.previewView = viewFinder
 
+        // Observe screen rotation
+        screenRotation.observe(this) { rotateViews(it) }
+
         // Request camera permissions
         if (!permissionsUtils.mainPermissionsGranted()) {
             mainPermissionsRequestLauncher.launch(PermissionsUtils.mainPermissions)
@@ -686,6 +711,9 @@
         // Register location updates
         locationListener.register()
 
+        // Enable orientation listener
+        orientationEventListener.enable()
+
         // Re-bind the use cases
         bindCameraUseCases()
     }
@@ -694,6 +722,9 @@
         // Remove location and location updates
         locationListener.unregister()
 
+        // Disable orientation listener
+        orientationEventListener.disable()
+
         super.onPause()
     }
 
@@ -1727,6 +1758,39 @@
         }
     }
 
+    private fun rotateViews(screenRotation: Rotation) {
+        val compensationValue = screenRotation.compensationValue.toFloat()
+
+        // Rotate sliders
+        exposureLevel.screenRotation = screenRotation
+        zoomLevel.screenRotation = screenRotation
+
+        // Rotate countdown
+        countDownView.screenRotation = screenRotation
+
+        // Rotate capture preview buttons
+        capturePreviewLayout.screenRotation = screenRotation
+
+        // Rotate secondary top bar buttons
+        ConstraintLayout::class.safeCast(
+            secondaryTopBarLayout.getChildAt(0)
+        )?.let { layout ->
+            for (child in layout.children) {
+                Button::class.safeCast(child)?.smoothRotate(compensationValue)
+            }
+        }
+
+        // Rotate secondary bottom bar buttons
+        proButton.smoothRotate(compensationValue)
+        lensSelectorLayout.screenRotation = screenRotation
+        flashButton.smoothRotate(compensationValue)
+
+        // Rotate primary bar buttons
+        galleryButtonCardView.smoothRotate(compensationValue)
+        shutterButton.smoothRotate(compensationValue)
+        flipCameraButton.smoothRotate(compensationValue)
+    }
+
     fun preventClicks(@Suppress("UNUSED_PARAMETER") view: View) {}
 
     companion object {
diff --git a/app/src/main/java/org/lineageos/aperture/ViewExt.kt b/app/src/main/java/org/lineageos/aperture/ViewExt.kt
index 4733750..e9d1f45 100644
--- a/app/src/main/java/org/lineageos/aperture/ViewExt.kt
+++ b/app/src/main/java/org/lineageos/aperture/ViewExt.kt
@@ -1,15 +1,17 @@
 /*
- * 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.view.View
+import android.view.animation.AccelerateDecelerateInterpolator
 import android.view.animation.AlphaAnimation
 import android.view.animation.AnimationSet
 import android.view.animation.TranslateAnimation
 import androidx.core.view.isVisible
+import org.lineageos.aperture.utils.Rotation
 
 internal fun View.setPadding(value: Int) {
     setPadding(value, value, value, value)
@@ -57,3 +59,10 @@
         })
     })
 }
+
+internal fun View.smoothRotate(rotation: Float) {
+    animate().cancel()
+    animate()
+        .rotationBy(Rotation.getDifference(this.rotation, rotation))
+        .interpolator = AccelerateDecelerateInterpolator()
+}
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 d65a57e..d43c4e7 100644
--- a/app/src/main/java/org/lineageos/aperture/ui/CapturePreviewLayout.kt
+++ b/app/src/main/java/org/lineageos/aperture/ui/CapturePreviewLayout.kt
@@ -1,5 +1,5 @@
 /*
- * SPDX-FileCopyrightText: 2022 The LineageOS Project
+ * SPDX-FileCopyrightText: 2022-2023 The LineageOS Project
  * SPDX-License-Identifier: Apache-2.0
  */
 
@@ -16,7 +16,9 @@
 import androidx.media3.exoplayer.ExoPlayer
 import androidx.media3.ui.PlayerView
 import org.lineageos.aperture.R
+import org.lineageos.aperture.smoothRotate
 import org.lineageos.aperture.utils.MediaType
+import org.lineageos.aperture.utils.Rotation
 
 /**
  * Image/video preview fragment
@@ -41,6 +43,12 @@
      */
     internal var onChoiceCallback: (uri: Uri?) -> Unit = {}
 
+    internal var screenRotation = Rotation.ROTATION_0
+        set(value) {
+            field = value
+            updateViewsRotation()
+        }
+
     override fun onLayout(changed: Boolean, left: Int, top: Int, right: Int, bottom: Int) {
         super.onLayout(changed, left, top, right, bottom)
 
@@ -94,4 +102,11 @@
             }
         }
     }
+
+    private fun updateViewsRotation() {
+        val compensationValue = screenRotation.compensationValue.toFloat()
+
+        cancelButton.smoothRotate(compensationValue)
+        confirmButton.smoothRotate(compensationValue)
+    }
 }
diff --git a/app/src/main/java/org/lineageos/aperture/ui/CountDownView.kt b/app/src/main/java/org/lineageos/aperture/ui/CountDownView.kt
index 1211bff..d852477 100644
--- a/app/src/main/java/org/lineageos/aperture/ui/CountDownView.kt
+++ b/app/src/main/java/org/lineageos/aperture/ui/CountDownView.kt
@@ -1,6 +1,6 @@
 /*
  * SPDX-FileCopyrightText: 2014 The Android Open Source Project
- * SPDX-FileCopyrightText: 2022 The LineageOS Project
+ * SPDX-FileCopyrightText: 2022-2023 The LineageOS Project
  * SPDX-License-Identifier: Apache-2.0
  */
 
@@ -18,6 +18,8 @@
 import androidx.core.view.isInvisible
 import androidx.core.view.isVisible
 import org.lineageos.aperture.R
+import org.lineageos.aperture.smoothRotate
+import org.lineageos.aperture.utils.Rotation
 
 /**
  * This class manages the looks of the countdown.
@@ -47,6 +49,12 @@
     private val isCountingDown: Boolean
         get() = remainingSeconds > 0
 
+    internal var screenRotation = Rotation.ROTATION_0
+        set(value) {
+            field = value
+            updateViewsRotation()
+        }
+
     /**
      * Responds to preview area change by centering the countdown UI in the new
      * preview area.
@@ -117,6 +125,12 @@
         return false
     }
 
+    private fun updateViewsRotation() {
+        val compensationValue = screenRotation.compensationValue.toFloat()
+
+        remainingSecondsView.smoothRotate(compensationValue)
+    }
+
     companion object {
         private const val SET_TIMER_TEXT = 1
         private const val ANIMATION_DURATION_MS = 800L
diff --git a/app/src/main/java/org/lineageos/aperture/ui/LensSelectorLayout.kt b/app/src/main/java/org/lineageos/aperture/ui/LensSelectorLayout.kt
index 83134ce..db8b1ae 100644
--- a/app/src/main/java/org/lineageos/aperture/ui/LensSelectorLayout.kt
+++ b/app/src/main/java/org/lineageos/aperture/ui/LensSelectorLayout.kt
@@ -1,5 +1,5 @@
 /*
- * SPDX-FileCopyrightText: 2022 The LineageOS Project
+ * SPDX-FileCopyrightText: 2022-2023 The LineageOS Project
  * SPDX-License-Identifier: Apache-2.0
  */
 
@@ -16,7 +16,9 @@
 import androidx.core.view.setMargins
 import org.lineageos.aperture.R
 import org.lineageos.aperture.px
+import org.lineageos.aperture.smoothRotate
 import org.lineageos.aperture.utils.Camera
+import org.lineageos.aperture.utils.Rotation
 import java.util.Locale
 
 @androidx.camera.camera2.interop.ExperimentalCamera2Interop
@@ -38,6 +40,12 @@
     var onCameraChangeCallback: (camera: Camera) -> Unit = {}
     var onZoomRatioChangeCallback: (zoomRatio: Float) -> Unit = {}
 
+    internal var screenRotation = Rotation.ROTATION_0
+        set(value) {
+            field = value
+            updateViewsRotation()
+        }
+
     fun setCamera(activeCamera: Camera, availableCameras: Collection<Camera>) {
         this.activeCamera = activeCamera
 
@@ -133,6 +141,15 @@
         } else {
             formattedZoomRatio
         }
+        button.rotation = screenRotation.compensationValue.toFloat()
+    }
+
+    private fun updateViewsRotation() {
+        val rotation = screenRotation.compensationValue.toFloat()
+
+        for (button in buttonToApproximateZoomRatio.keys) {
+            button.smoothRotate(rotation)
+        }
     }
 
     private fun formatZoomRatio(zoomRatio: Float): String =
diff --git a/app/src/main/java/org/lineageos/aperture/ui/Slider.kt b/app/src/main/java/org/lineageos/aperture/ui/Slider.kt
index 4cdc597..14fb599 100644
--- a/app/src/main/java/org/lineageos/aperture/ui/Slider.kt
+++ b/app/src/main/java/org/lineageos/aperture/ui/Slider.kt
@@ -1,5 +1,5 @@
 /*
- * SPDX-FileCopyrightText: 2022 The LineageOS Project
+ * SPDX-FileCopyrightText: 2022-2023 The LineageOS Project
  * SPDX-License-Identifier: Apache-2.0
  */
 
@@ -17,6 +17,7 @@
 import android.view.View
 import org.lineageos.aperture.R
 import org.lineageos.aperture.px
+import org.lineageos.aperture.utils.Rotation
 
 abstract class Slider @JvmOverloads constructor(
     context: Context, attrs: AttributeSet? = null, defStyleAttr: Int = 0
@@ -46,6 +47,12 @@
         "%.01f".format(it)
     }
 
+    var screenRotation = Rotation.ROTATION_0
+        set(value) {
+            field = value
+            invalidate()
+        }
+
     var steps = 0
 
     init {
@@ -84,8 +91,13 @@
     abstract fun thumb(): Triple<Float, Float, Float>
 
     private fun drawThumb(canvas: Canvas) {
-        // Draw circle
         val thumb = thumb()
+
+        // Rotate canvas
+        canvas.save()
+        canvas.rotate(screenRotation.compensationValue.toFloat(), thumb.first, thumb.second)
+
+        // Draw circle
         canvas.drawCircle(thumb.first, thumb.second, thumb.third, thumbPaint)
 
         // Draw text
@@ -99,5 +111,8 @@
             thumb.second + (textBounds.height() / 2),
             thumbTextPaint
         )
+
+        // Restore original rotation
+        canvas.restore()
     }
 }
diff --git a/app/src/main/java/org/lineageos/aperture/utils/Rotation.kt b/app/src/main/java/org/lineageos/aperture/utils/Rotation.kt
new file mode 100644
index 0000000..fed3852
--- /dev/null
+++ b/app/src/main/java/org/lineageos/aperture/utils/Rotation.kt
@@ -0,0 +1,65 @@
+/*
+ * SPDX-FileCopyrightText: 2023 The LineageOS Project
+ * SPDX-License-Identifier: Apache-2.0
+ */
+
+package org.lineageos.aperture.utils
+
+import kotlin.math.abs
+
+/**
+ * Rotation utils.
+ *
+ * @property offset The offset added to (360° * k) needed to obtain the wanted rotation.
+ */
+enum class Rotation(val offset: Int) {
+    ROTATION_0(0),
+    ROTATION_90(90),
+    ROTATION_180(180),
+    ROTATION_270(270);
+
+    /**
+     * Get the rotation needed to compensate for the rotation compared to 0°.
+     */
+    val compensationValue = 360 - if (offset > 180) offset - 360 else offset
+
+    private val apertureRanges = mutableListOf<IntRange>().apply {
+        // Left side
+        if (offset < 45) {
+            add(360 - offset - 45 until 360)
+            add(0 until offset)
+        } else {
+            add(offset - 45 until offset)
+        }
+
+        // Right side
+        if (offset > 360 - 45) {
+            add(offset until 360)
+            add(0 until 360 - offset + 45)
+        } else {
+            add(offset until offset + 45)
+        }
+    }
+
+    companion object {
+        /**
+         * Get the rotation where the value is in [rotation - 45°, rotation + 45°]
+         */
+        fun fromDegreesInAperture(degrees: Int) = values().first {
+            it.apertureRanges.any { range -> degrees in range }
+        }
+
+        /**
+         * Get the fastest angle in degrees to apply to the current rotation to reach this rotation.
+         */
+        fun getDifference(currentRotation: Float, targetRotation: Float): Float {
+            val diff = (targetRotation + (360 * (currentRotation / 360).toInt())) - currentRotation
+
+            return if (abs(diff) > 180) {
+                diff - 360
+            } else {
+                diff
+            }
+        }
+    }
+}