diff --git a/app/build.gradle.kts b/app/build.gradle.kts
index 84d51b3f5..c2a5e6052 100644
--- a/app/build.gradle.kts
+++ b/app/build.gradle.kts
@@ -11,6 +11,12 @@ if (useKeystoreProperties) {
plugins {
id("com.android.application")
kotlin("android")
+ // The following plugin should have the same version as
+ // org.jetbrains.kotlin.android defined at project level
+ // gradle
+ id("org.jetbrains.kotlin.plugin.compose") version "2.0.10"
+ id("kotlin-parcelize")
+ id("org.jetbrains.kotlin.plugin.serialization")
}
java {
@@ -82,6 +88,11 @@ android {
buildFeatures {
viewBinding = true
buildConfig = true
+
+ compose = true
+ composeOptions {
+ kotlinCompilerExtensionVersion = "1.5.15"
+ }
}
androidResources {
@@ -104,4 +115,25 @@ dependencies {
implementation("androidx.camera:camera-extensions:$cameraVersion")
implementation("com.google.zxing:core:3.5.3")
+
+ val composeBom = platform("androidx.compose:compose-bom:2024.08.00")
+ implementation(composeBom)
+
+ implementation("androidx.compose.material3:material3:1.2.1")
+ implementation("androidx.navigation:navigation-compose:2.7.7")
+ implementation("androidx.compose.ui:ui-tooling-preview")
+ implementation("androidx.compose.material:material-icons-extended:1.7.2")
+
+ implementation("androidx.media3:media3-ui:1.4.0")
+ implementation("androidx.media3:media3-exoplayer:1.4.0")
+
+ implementation("androidx.lifecycle:lifecycle-runtime-compose:2.8.4")
+
+ implementation("androidx.navigation:navigation-compose:2.8.0")
+
+ implementation("org.jetbrains.kotlinx:kotlinx-serialization-json:1.7.1")
+
+ implementation("me.saket.telephoto:sub-sampling-image:0.13.0")
+ implementation("io.coil-kt.coil3:coil-compose-core:3.0.0-alpha10")
+ implementation("io.coil-kt.coil3:coil-video:3.0.0-alpha10")
}
diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml
index 36985190b..0678585dd 100644
--- a/app/src/main/AndroidManifest.xml
+++ b/app/src/main/AndroidManifest.xml
@@ -29,7 +29,6 @@
android:label="@string/app_name"
android:supportsRtl="true"
android:theme="@style/Theme.App"
- android:enableOnBackInvokedCallback="true"
tools:ignore="UnusedAttribute">
@@ -168,14 +167,14 @@
diff --git a/app/src/main/java/app/grapheneos/camera/CapturedItems.kt b/app/src/main/java/app/grapheneos/camera/CapturedItems.kt
index 440e992fd..dfb6a0d0d 100644
--- a/app/src/main/java/app/grapheneos/camera/CapturedItems.kt
+++ b/app/src/main/java/app/grapheneos/camera/CapturedItems.kt
@@ -1,6 +1,5 @@
package app.grapheneos.camera
-import android.annotation.SuppressLint
import android.content.ContentResolver
import android.content.ContentUris
import android.content.Context
@@ -13,8 +12,12 @@ import android.provider.DocumentsContract
import android.provider.MediaStore
import android.util.Log
import app.grapheneos.camera.CamConfig.SettingValues
+import app.grapheneos.camera.ui.composable.screen.serializer.CapturedItemSerializer
import app.grapheneos.camera.util.edit
import kotlin.jvm.Throws
+import kotlinx.parcelize.Parceler
+import kotlinx.parcelize.Parcelize
+import kotlinx.serialization.Serializable
typealias ItemType = Int
const val ITEM_TYPE_IMAGE: ItemType = 0
@@ -22,6 +25,8 @@ const val ITEM_TYPE_VIDEO: ItemType = 1
const val IMAGE_NAME_PREFIX = "IMG_"
const val VIDEO_NAME_PREFIX = "VID_"
+@Serializable(with = CapturedItemSerializer::class)
+@Parcelize
class CapturedItem(
val type: ItemType,
val dateString: String,
@@ -45,12 +50,6 @@ class CapturedItem(
return "$prefix$dateString"
}
- override fun writeToParcel(dest: Parcel, flags: Int) {
- dest.writeByte(type.toByte())
- dest.writeString(dateString)
- uri.writeToParcel(dest, 0)
- }
-
override fun hashCode(): Int {
return dateString.hashCode()
}
@@ -62,18 +61,31 @@ class CapturedItem(
return dateString == other.dateString
}
- companion object {
- @JvmField
- val CREATOR = object : Parcelable.Creator {
- @SuppressLint("ParcelClassLoader")
- override fun createFromParcel(source: Parcel): CapturedItem {
- val type = source.readByte().toInt()
- val dateString = source.readString()!!
- val uri = Uri.CREATOR.createFromParcel(source)
- return CapturedItem(type, dateString, uri)
- }
+ companion object : Parceler {
+ override fun CapturedItem.write(dest: Parcel, flags: Int) {
+ dest.writeByte(type.toByte())
+ dest.writeString(dateString)
+ uri.writeToParcel(dest, 0)
+ }
- override fun newArray(size: Int) = arrayOfNulls(size)
+ override fun create(source: Parcel): CapturedItem {
+ val type = source.readByte().toInt()
+ val dateString = source.readString()!!
+ val uri = Uri.CREATOR.createFromParcel(source)
+ return CapturedItem(type, dateString, uri)
+ }
+ }
+
+ fun delete(context: Context) : Boolean {
+ try {
+ return if (uri.authority == MediaStore.AUTHORITY) {
+ context.contentResolver.delete(uri, null, null) > 0
+ } else {
+ DocumentsContract.deleteDocument(context.contentResolver, uri)
+ }
+ } catch (e : Exception) {
+ e.printStackTrace()
+ return false
}
}
diff --git a/app/src/main/java/app/grapheneos/camera/GSlideTransformer.kt b/app/src/main/java/app/grapheneos/camera/GSlideTransformer.kt
deleted file mode 100644
index 696593fad..000000000
--- a/app/src/main/java/app/grapheneos/camera/GSlideTransformer.kt
+++ /dev/null
@@ -1,47 +0,0 @@
-package app.grapheneos.camera
-
-import android.view.View
-import androidx.viewpager2.widget.ViewPager2
-import kotlin.math.abs
-
-class GSlideTransformer : ViewPager2.PageTransformer {
-
- companion object {
- private const val MIN_SCALE = 0.75f
- }
-
- override fun transformPage(view: View, position: Float) {
- view.apply {
- val pageWidth = width
- when {
- position < -1 -> { // [-Infinity,-1)
- // This page is way off-screen to the left.
-// alpha = 0f
- }
- position <= 0 -> { // [-1,0]
- // Use the default slide transition when moving to the left page
- alpha = 1f
- translationX = 0f
- scaleX = 1f
- scaleY = 1f
- }
- position <= 1 -> { // (0,1]
- // Fade the page out.
- alpha = 1 - position
-
- // Counteract the default slide transition
- translationX = pageWidth * -position
-
- // Scale the page down (between MIN_SCALE and 1)
- val scaleFactor = (MIN_SCALE + (1 - MIN_SCALE) * (1 - abs(position)))
- scaleX = scaleFactor
- scaleY = scaleFactor
- }
- else -> { // (1,+Infinity]
- // This page is way off-screen to the right.
- alpha = 0f
- }
- }
- }
- }
-}
\ No newline at end of file
diff --git a/app/src/main/java/app/grapheneos/camera/GallerySliderAdapter.kt b/app/src/main/java/app/grapheneos/camera/GallerySliderAdapter.kt
deleted file mode 100644
index 25c8a1e62..000000000
--- a/app/src/main/java/app/grapheneos/camera/GallerySliderAdapter.kt
+++ /dev/null
@@ -1,153 +0,0 @@
-package app.grapheneos.camera
-
-import android.content.Intent
-import android.graphics.Bitmap
-import android.graphics.ImageDecoder
-import android.view.LayoutInflater
-import android.view.View
-import android.view.ViewGroup
-import android.widget.ImageView
-import androidx.recyclerview.widget.RecyclerView
-import app.grapheneos.camera.capturer.getVideoThumbnail
-import app.grapheneos.camera.databinding.GallerySlideBinding
-import app.grapheneos.camera.ui.ZoomableImageView
-import app.grapheneos.camera.ui.activities.InAppGallery
-import app.grapheneos.camera.ui.activities.VideoPlayer
-import app.grapheneos.camera.ui.fragment.GallerySlide
-import app.grapheneos.camera.util.executeIfAlive
-import kotlin.math.max
-
-class GallerySliderAdapter(
- private val gActivity: InAppGallery,
- val items: ArrayList
-) : RecyclerView.Adapter() {
-
- var atLeastOneBindViewHolderCall = false
-
- private val layoutInflater: LayoutInflater = LayoutInflater.from(gActivity)
-
- override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): GallerySlide {
- return GallerySlide(GallerySlideBinding.inflate(layoutInflater, parent, false))
- }
-
- override fun getItemId(position: Int): Long {
- return items[position].hashCode().toLong()
- }
-
- override fun onBindViewHolder(holder: GallerySlide, position: Int) {
- val mediaPreview: ZoomableImageView = holder.binding.slidePreview
-// Log.d("GallerySliderAdapter", "postiion $position, preview ${System.identityHashCode(mediaPreview)}")
- val playButton: ImageView = holder.binding.playButton
- val item = items[position]
-
- mediaPreview.setGalleryActivity(gActivity)
- mediaPreview.disableZooming()
- mediaPreview.setOnClickListener(null)
- mediaPreview.visibility = View.INVISIBLE
- mediaPreview.setImageBitmap(null)
-
- val placeholderText = holder.binding.placeholderText.root
- if (atLeastOneBindViewHolderCall) {
- placeholderText.visibility = View.VISIBLE
- placeholderText.setText("…")
- }
- atLeastOneBindViewHolderCall = true
-
- playButton.visibility = View.GONE
-
- holder.currentPostion = position
-
- gActivity.asyncImageLoader.executeIfAlive {
- val bitmap: Bitmap? = try {
- if (item.type == ITEM_TYPE_VIDEO) {
- getVideoThumbnail(gActivity, item.uri)
- } else {
- val source = ImageDecoder.createSource(gActivity.contentResolver, item.uri)
- ImageDecoder.decodeBitmap(source, ImageDownscaler)
- }
- } catch (e: Exception) { null }
-
- gActivity.mainExecutor.execute {
- if (holder.currentPostion == position) {
- if (bitmap != null) {
- placeholderText.visibility = View.GONE
- mediaPreview.visibility = View.VISIBLE
- mediaPreview.setImageBitmap(bitmap)
-
- if (item.type == ITEM_TYPE_VIDEO) {
- playButton.visibility = View.VISIBLE
- } else if (item.type == ITEM_TYPE_IMAGE) {
- mediaPreview.enableZooming()
- }
-
- mediaPreview.setOnClickListener {
- val curItem = getCurrentItem()
- if (curItem.type == ITEM_TYPE_VIDEO) {
- val intent = Intent(gActivity, VideoPlayer::class.java)
- intent.putExtra(VideoPlayer.VIDEO_URI, curItem.uri)
- intent.putExtra(VideoPlayer.IN_SECURE_MODE, gActivity.isSecureMode)
-
- gActivity.startActivity(intent)
- }
- }
- } else {
- mediaPreview.visibility = View.INVISIBLE
-
- val resId = if (item.type == ITEM_TYPE_IMAGE) {
- R.string.inaccessible_image
- } else { R.string.inaccessible_video }
-
- placeholderText.visibility = View.VISIBLE
- placeholderText.setText(gActivity.getString(resId, item.dateString))
- }
- } else {
- bitmap?.recycle()
- }
- }
- }
- }
-
- fun removeItem(item: CapturedItem) {
- removeChildAt(items.indexOf(item))
- }
-
- private fun removeChildAt(index: Int) {
- items.removeAt(index)
-
- // Close gallery if no files are present
- if (items.isEmpty()) {
- gActivity.showMessage(
- gActivity.getString(R.string.existing_no_image)
- )
- gActivity.finish()
- }
-
- notifyItemRemoved(index)
- }
-
- fun getCurrentItem(): CapturedItem {
- return items[gActivity.gallerySlider.currentItem]
- }
-
- override fun getItemCount(): Int {
- return items.size
- }
-}
-
-object ImageDownscaler : ImageDecoder.OnHeaderDecodedListener {
- override fun onHeaderDecoded(decoder: ImageDecoder,
- info: ImageDecoder.ImageInfo, source: ImageDecoder.Source) {
- val size = info.size
- val w = size.width
- val h = size.height
- // limit the max size of the bitmap to avoid bumping into bitmap size limit
- // (100 MB)
- val largerSide = max(w, h)
- val maxSide = 4500
-
- if (largerSide > maxSide) {
- val ratio = maxSide.toDouble() / largerSide
- decoder.setTargetSize((ratio * w).toInt(), (ratio * h).toInt())
- }
- }
-}
diff --git a/app/src/main/java/app/grapheneos/camera/ktx/Context.kt b/app/src/main/java/app/grapheneos/camera/ktx/Context.kt
new file mode 100644
index 000000000..66ab7cc09
--- /dev/null
+++ b/app/src/main/java/app/grapheneos/camera/ktx/Context.kt
@@ -0,0 +1,18 @@
+package app.grapheneos.camera.ktx
+
+import android.app.Activity
+import android.app.KeyguardManager
+import android.content.Context
+
+fun Context.isDeviceLocked() : Boolean {
+ val keyguardManager: KeyguardManager = getSystemService(Context.KEYGUARD_SERVICE) as KeyguardManager
+ return keyguardManager.isKeyguardLocked
+}
+
+fun Context.requestDeviceUnlock() {
+ assert(this is Activity) {
+ "Please ensure that requestDeviceUnlock() is called by an activity context"
+ }
+ val keyguardManager: KeyguardManager = getSystemService(Context.KEYGUARD_SERVICE) as KeyguardManager
+ keyguardManager.requestDismissKeyguard(this as Activity, null)
+}
\ No newline at end of file
diff --git a/app/src/main/java/app/grapheneos/camera/ktx/Dp.kt b/app/src/main/java/app/grapheneos/camera/ktx/Dp.kt
new file mode 100644
index 000000000..1509cb9cf
--- /dev/null
+++ b/app/src/main/java/app/grapheneos/camera/ktx/Dp.kt
@@ -0,0 +1,11 @@
+package app.grapheneos.camera.ktx
+
+import androidx.compose.runtime.Composable
+import androidx.compose.ui.platform.LocalDensity
+import androidx.compose.ui.unit.Dp
+
+@Composable
+fun Int.pxToDp() = with(LocalDensity.current) { this@pxToDp.toDp() }
+
+@Composable
+fun Dp.toPx() = with(LocalDensity.current) { this@toPx.toPx() }
\ No newline at end of file
diff --git a/app/src/main/java/app/grapheneos/camera/ktx/LazyGridScope.kt b/app/src/main/java/app/grapheneos/camera/ktx/LazyGridScope.kt
new file mode 100644
index 000000000..0e180d5bf
--- /dev/null
+++ b/app/src/main/java/app/grapheneos/camera/ktx/LazyGridScope.kt
@@ -0,0 +1,13 @@
+package app.grapheneos.camera.ktx
+
+import androidx.compose.foundation.lazy.grid.GridItemSpan
+import androidx.compose.foundation.lazy.grid.LazyGridItemScope
+import androidx.compose.foundation.lazy.grid.LazyGridScope
+import androidx.compose.runtime.Composable
+
+// header alternative in LazyGrid similar to LazyColumn
+fun LazyGridScope.header(
+ content: @Composable LazyGridItemScope.() -> Unit
+) {
+ item(span = { GridItemSpan(this.maxLineSpan) }, content = content)
+}
\ No newline at end of file
diff --git a/app/src/main/java/app/grapheneos/camera/ktx/NavHostController.kt b/app/src/main/java/app/grapheneos/camera/ktx/NavHostController.kt
new file mode 100644
index 000000000..1140f18a3
--- /dev/null
+++ b/app/src/main/java/app/grapheneos/camera/ktx/NavHostController.kt
@@ -0,0 +1,9 @@
+package app.grapheneos.camera.ktx
+
+import androidx.navigation.NavHostController
+
+fun NavHostController.popBackStack(
+ onBackStackEmpty: () -> Unit
+) {
+ if (!popBackStack()) onBackStackEmpty()
+}
\ No newline at end of file
diff --git a/app/src/main/java/app/grapheneos/camera/ktx/SnackbarHostState.kt b/app/src/main/java/app/grapheneos/camera/ktx/SnackbarHostState.kt
new file mode 100644
index 000000000..adeff6b26
--- /dev/null
+++ b/app/src/main/java/app/grapheneos/camera/ktx/SnackbarHostState.kt
@@ -0,0 +1,24 @@
+package app.grapheneos.camera.ktx
+
+import androidx.compose.material3.SnackbarDuration
+import androidx.compose.material3.SnackbarHostState
+
+suspend fun SnackbarHostState.showOrReplaceSnackbar(
+ message: String,
+ actionLabel: String? = null,
+ withDismissAction: Boolean = false,
+ duration: SnackbarDuration =
+ if (actionLabel == null) SnackbarDuration.Short else SnackbarDuration.Indefinite
+) {
+ dismissSnackBarIfVisible()
+ showSnackbar(
+ message = message,
+ actionLabel = actionLabel,
+ withDismissAction = withDismissAction,
+ duration = duration
+ )
+}
+
+fun SnackbarHostState.dismissSnackBarIfVisible() {
+ currentSnackbarData?.dismiss()
+}
\ No newline at end of file
diff --git a/app/src/main/java/app/grapheneos/camera/ui/ZoomableImageView.kt b/app/src/main/java/app/grapheneos/camera/ui/ZoomableImageView.kt
deleted file mode 100644
index cfe4077d4..000000000
--- a/app/src/main/java/app/grapheneos/camera/ui/ZoomableImageView.kt
+++ /dev/null
@@ -1,383 +0,0 @@
-package app.grapheneos.camera.ui
-
-import android.animation.Animator
-import android.animation.Animator.AnimatorListener
-import android.animation.ValueAnimator
-import android.annotation.SuppressLint
-import android.content.Context
-import android.graphics.Matrix
-
-import android.view.ScaleGestureDetector
-
-import android.view.MotionEvent
-
-import android.graphics.PointF
-import android.os.Handler
-import android.os.Looper
-import android.util.AttributeSet
-import android.view.ScaleGestureDetector.SimpleOnScaleGestureListener
-
-import androidx.appcompat.widget.AppCompatImageView
-import app.grapheneos.camera.R
-import app.grapheneos.camera.ui.activities.InAppGallery
-import kotlin.math.abs
-
-class ZoomableImageView @JvmOverloads constructor(
- context: Context, attrs: AttributeSet? = null
-) : AppCompatImageView(context, attrs) {
-
- lateinit var mMatrix: Matrix
-
- private var mode = NONE
-
- private var last = PointF()
- private var start = PointF()
- private var minScale = 1f
- private var maxScale = 3f
- private var m: FloatArray = FloatArray(9)
- private var viewWidth = 0
- private var viewHeight = 0
- private var saveScale = 1f
- private var origWidth = 0f
- private var origHeight = 0f
- private var oldMeasuredWidth = 0
- private var oldMeasuredHeight = 0
- private var mScaleDetector: ScaleGestureDetector? = null
-
- private var isZoomingDisabled = true
-
- private var singleClickHandler = Handler(Looper.getMainLooper())
- private var singleClickRunnable = Runnable {
- onSingleClick()
- }
-
- private var scaleAnimator : ValueAnimator? = null
-
- lateinit var gActivity: InAppGallery
-
- init {
- sharedConstructing()
- }
-
- private val currentInstance: ZoomableImageView
- get() {
- return gActivity.gallerySlider.getChildAt(0)
- .findViewById(R.id.slide_preview)
- }
-
- fun setGalleryActivity(gActivity: InAppGallery) {
- this.gActivity = gActivity
- }
-
- fun enableZooming() {
- isZoomingDisabled = false
- }
-
- fun disableZooming() {
- isZoomingDisabled = true
- }
-
- @SuppressLint("ClickableViewAccessibility")
- private fun sharedConstructing() {
- super.setClickable(true)
- mScaleDetector = ScaleGestureDetector(context, ScaleListener())
- mMatrix = Matrix()
- imageMatrix = mMatrix
- scaleType = ScaleType.MATRIX
-
- setOnTouchListener { _, event ->
-
- currentInstance.mScaleDetector!!.onTouchEvent(event)
-
- if (currentInstance.isZoomingDisabled) {
- if (event.action == MotionEvent.ACTION_UP) {
- this.onClickEvent(event)
- return@setOnTouchListener performClick()
- }
- return@setOnTouchListener false
- }
-
- val curr = PointF(event.x, event.y)
-
- when (event.action) {
- MotionEvent.ACTION_DOWN -> {
- currentInstance.last.set(curr)
- currentInstance.start.set(currentInstance.last)
- currentInstance.mode = DRAG
- }
- MotionEvent.ACTION_MOVE -> if (currentInstance.mode == DRAG) {
- val deltaX = curr.x - currentInstance.last.x
- val deltaY = curr.y - currentInstance.last.y
-
- val fixTransX = currentInstance.getFixDragTrans(
- deltaX, currentInstance.viewWidth.toFloat(),
- currentInstance.origWidth
- * currentInstance.saveScale
- )
-
- val fixTransY = currentInstance.getFixDragTrans(
- deltaY, currentInstance.viewHeight.toFloat(),
- currentInstance.origHeight * currentInstance.saveScale
- )
-
- currentInstance.mMatrix.postTranslate(fixTransX, fixTransY)
- currentInstance.fixTrans()
- currentInstance.last[curr.x] = curr.y
- }
- MotionEvent.ACTION_UP -> {
- currentInstance.mode = NONE
- val xDiff = abs(curr.x - currentInstance.start.x).toInt()
- val yDiff = abs(curr.y - currentInstance.start.y).toInt()
- if (xDiff < CLICK && yDiff < CLICK) {
- currentInstance.onClickEvent(event)
- performClick()
- }
- }
- MotionEvent.ACTION_POINTER_UP -> currentInstance.mode = NONE
- }
- currentInstance.imageMatrix = currentInstance.mMatrix
- currentInstance.invalidate()
- true
- }
- }
-
- private var lastClickTimestampMs : Long = 0L
-
- private fun onClickEvent(event : MotionEvent) {
- if ((event.eventTime - lastClickTimestampMs) <= DOUBLE_TAP_DELAY) {
- singleClickHandler.removeCallbacks(singleClickRunnable)
- onDoubleClick(event)
- } else {
- singleClickHandler.postDelayed(singleClickRunnable, DOUBLE_TAP_DELAY)
- }
- lastClickTimestampMs = event.eventTime
- }
-
- private fun onSingleClick() {
- gActivity.toggleUIState()
- }
-
- private fun onDoubleClick(event: MotionEvent) {
- // The user hasn't zoomed in
- if (saveScale != 1f) {
- scaleAnimator?.cancel()
- scaleAnimator = ValueAnimator.ofFloat(saveScale, 1f)
- scaleAnimator?.duration = SCALE_ANIMATION_DURATION
- scaleAnimator?.addUpdateListener {
- scaleImageTo(width / 2f, height / 2f, it.animatedValue as Float)
- }
-
- scaleAnimator?.addListener(object: AnimatorListener {
-
- var isAnimationCancelled = false
-
- override fun onAnimationStart(animation: Animator) {}
-
- override fun onAnimationEnd(animation: Animator) {
- if (!isAnimationCancelled)
- gActivity.gallerySlider.isUserInputEnabled = true
- }
-
- override fun onAnimationCancel(animation: Animator) {
- isAnimationCancelled = true
- }
-
- override fun onAnimationRepeat(animation: Animator) {}
-
- })
-
- scaleAnimator?.start()
- // Use value animator to animate from saveScale to 1f
- } else {
- // Something similar here
- scaleAnimator?.cancel()
- scaleAnimator = ValueAnimator.ofFloat(1f, DOUBLE_TAP_SCALE)
- scaleAnimator?.duration = SCALE_ANIMATION_DURATION
- scaleAnimator?.addUpdateListener {
- scaleImageTo(event.x, event.y, it.animatedValue as Float)
- }
- scaleAnimator?.start()
- }
- }
-
- private fun scaleImageTo(pointX: Float, pointY: Float, newScale: Float) {
- val scaleFactor = newScale / saveScale
- scaleImageBy(pointX, pointY, scaleFactor)
- }
-
- private fun scaleImageBy(pointX: Float, pointY: Float, scaleFactor: Float) {
-
- val origScale = saveScale
- var sFactor = scaleFactor
- saveScale *= sFactor
-
- if (saveScale > maxScale) {
- saveScale = maxScale
- sFactor = maxScale / origScale
- } else if (saveScale < minScale) {
- saveScale = minScale
- sFactor = minScale / origScale
- }
-
- if (origWidth * saveScale <= viewWidth
- || origHeight * saveScale <= viewHeight
- ) mMatrix.postScale(
- sFactor, sFactor, viewWidth / 2f,
- viewHeight / 2f
- ) else mMatrix.postScale(
- sFactor, sFactor,
- pointX, pointY
- )
-
- fixTrans()
-
- if (saveScale == 1f) {
- moveOutOfZoomMode()
- } else {
- moveIntoZoomMode()
- }
-
- currentInstance.imageMatrix = currentInstance.mMatrix
- currentInstance.invalidate()
- }
-
- private inner class ScaleListener : SimpleOnScaleGestureListener() {
- override fun onScaleBegin(detector: ScaleGestureDetector): Boolean {
- mode = ZOOM
-
- // Cancel ongoing scaling animation (for e.g. on double tap)
- scaleAnimator?.cancel()
-
- if (isZoomingDisabled) {
- gActivity.showUI()
- } else {
- gActivity.gallerySlider.isUserInputEnabled = false
- }
-
- return true
- }
-
- override fun onScale(detector: ScaleGestureDetector): Boolean {
- if (isZoomingDisabled) return true
- scaleImageBy(detector.focusX, detector.focusY, detector.scaleFactor)
- return true
- }
-
- override fun onScaleEnd(detector: ScaleGestureDetector) {
- super.onScaleEnd(detector)
- if (saveScale == 1f) {
- gActivity.gallerySlider.isUserInputEnabled = true
- }
- }
- }
-
- private var isInZoomMode = false
-
- fun moveIntoZoomMode() {
-
- if (isInZoomMode) return
-
- isInZoomMode = true
-
- gActivity.let {
- it.hideUI()
- it.gallerySlider.isUserInputEnabled = false
- }
- }
-
- fun moveOutOfZoomMode() {
-
- if (!isInZoomMode) return
-
- isInZoomMode = false
-
- gActivity.showUI()
- gActivity.vibrateDevice()
- }
-
- fun fixTrans() {
-
- mMatrix.getValues(m)
- val transX = m[Matrix.MTRANS_X]
- val transY = m[Matrix.MTRANS_Y]
- val fixTransX = getFixTrans(transX, viewWidth.toFloat(), origWidth * saveScale)
- val fixTransY = getFixTrans(
- transY, viewHeight.toFloat(), origHeight
- * saveScale
- )
- if (fixTransX != 0f || fixTransY != 0f) mMatrix.postTranslate(fixTransX, fixTransY)
- }
-
- private fun getFixTrans(trans: Float, viewSize: Float, contentSize: Float): Float {
-
- val minTrans: Float
- val maxTrans: Float
- if (contentSize <= viewSize) {
- minTrans = 0f
- maxTrans = viewSize - contentSize
- } else {
- minTrans = viewSize - contentSize
- maxTrans = 0f
- }
- if (trans < minTrans) return -trans + minTrans
- return if (trans > maxTrans) -trans + maxTrans else 0f
- }
-
- private fun getFixDragTrans(delta: Float, viewSize: Float, contentSize: Float): Float {
- return if (contentSize <= viewSize) {
- 0f
- } else delta
- }
-
- override fun onMeasure(widthMeasureSpec: Int, heightMeasureSpec: Int) {
- super.onMeasure(widthMeasureSpec, heightMeasureSpec)
-
- viewWidth = MeasureSpec.getSize(widthMeasureSpec)
- viewHeight = MeasureSpec.getSize(heightMeasureSpec)
-
- // Rescales image on rotation
- if (oldMeasuredHeight == viewWidth &&
- oldMeasuredHeight == viewHeight || viewWidth == 0 ||
- viewHeight == 0
- ) return
-
- oldMeasuredHeight = viewHeight
- oldMeasuredWidth = viewWidth
-
- if (saveScale == 1f) {
- // Fit to screen.
- val scale: Float
- if (drawable?.intrinsicWidth ?: 0 == 0 || drawable?.intrinsicHeight ?: 0 == 0) return
- val bmWidth = drawable.intrinsicWidth
- val bmHeight = drawable.intrinsicHeight
-// Log.d("bmSize", "bmWidth: $bmWidth bmHeight : $bmHeight")
- val scaleX = viewWidth.toFloat() / bmWidth.toFloat()
- val scaleY = viewHeight.toFloat() / bmHeight.toFloat()
- scale = scaleX.coerceAtMost(scaleY)
- mMatrix.setScale(scale, scale)
-
- // Center the image
- var redundantYSpace = viewHeight.toFloat() - scale * bmHeight.toFloat()
- var redundantXSpace = viewWidth.toFloat() - scale * bmWidth.toFloat()
- redundantYSpace /= 2f
- redundantXSpace /= 2f
- mMatrix.postTranslate(redundantXSpace, redundantYSpace)
- origWidth = viewWidth - 2 * redundantXSpace
- origHeight = viewHeight - 2 * redundantYSpace
- imageMatrix = mMatrix
- }
- fixTrans()
- }
-
- companion object {
- const val NONE = 0
- const val DRAG = 1
- const val ZOOM = 2
- const val CLICK = 3
-
- private const val DOUBLE_TAP_DELAY = 200L
- private const val DOUBLE_TAP_SCALE = 1.75F
-
- private const val SCALE_ANIMATION_DURATION = 300L
- }
-}
diff --git a/app/src/main/java/app/grapheneos/camera/ui/activities/InAppGallery.kt b/app/src/main/java/app/grapheneos/camera/ui/activities/InAppGallery.kt
index 01a70bf98..bce0cf2c1 100644
--- a/app/src/main/java/app/grapheneos/camera/ui/activities/InAppGallery.kt
+++ b/app/src/main/java/app/grapheneos/camera/ui/activities/InAppGallery.kt
@@ -1,443 +1,53 @@
package app.grapheneos.camera.ui.activities
-import android.animation.ArgbEvaluator
-import android.animation.ValueAnimator
-import android.annotation.SuppressLint
-import android.content.ActivityNotFoundException
+import android.content.BroadcastReceiver
import android.content.Context
import android.content.Intent
-import android.graphics.Color
-import android.graphics.drawable.ColorDrawable
-import android.media.MediaMetadataRetriever
-import android.net.Uri
+import android.content.IntentFilter
import android.os.Bundle
-import android.os.Handler
-import android.os.VibrationEffect
-import android.os.Vibrator
-import android.provider.DocumentsContract
-import android.provider.MediaStore
-import android.provider.MediaStore.MediaColumns
-import android.provider.OpenableColumns
-import android.util.Log
-import android.view.Menu
-import android.view.MenuItem
-import android.view.View
-import android.widget.RelativeLayout
-import android.widget.Toast
-import androidx.activity.enableEdgeToEdge
+
+import androidx.activity.compose.setContent
import androidx.appcompat.app.AppCompatActivity
-import androidx.core.content.ContextCompat
-import androidx.core.os.BundleCompat
-import androidx.core.view.ViewCompat
+import androidx.lifecycle.lifecycleScope
+import androidx.activity.enableEdgeToEdge
+import app.grapheneos.camera.CapturedItem
import androidx.core.view.WindowCompat
-import androidx.core.view.WindowInsetsCompat
import androidx.core.view.WindowInsetsControllerCompat
-import androidx.viewpager2.widget.ViewPager2
-import androidxc.exifinterface.media.ExifInterface
import app.grapheneos.camera.AutoFinishOnSleep
-import app.grapheneos.camera.CapturedItem
-import app.grapheneos.camera.CapturedItems
-import app.grapheneos.camera.GSlideTransformer
-import app.grapheneos.camera.GallerySliderAdapter
-import app.grapheneos.camera.ITEM_TYPE_VIDEO
-import app.grapheneos.camera.R
-import app.grapheneos.camera.databinding.GalleryBinding
+import androidx.core.view.WindowInsetsCompat
+
+import app.grapheneos.camera.ui.composable.CameraApp
+import app.grapheneos.camera.ui.composable.screen.routes.GalleryRoute
+import app.grapheneos.camera.ui.composable.screen.viewmodel.CapturedItemsRepository
+
import app.grapheneos.camera.util.getParcelableArrayListExtra
import app.grapheneos.camera.util.getParcelableExtra
-import app.grapheneos.camera.util.storageLocationToUiString
-import com.google.android.material.dialog.MaterialAlertDialogBuilder
-import com.google.android.material.snackbar.Snackbar
-import java.text.SimpleDateFormat
-import java.util.Date
-import java.util.Locale
-import java.util.TimeZone
-import java.util.concurrent.Executors
-import kotlin.properties.Delegates
class InAppGallery : AppCompatActivity() {
- lateinit var binding: GalleryBinding
- lateinit var gallerySlider: ViewPager2
- var gallerySliderAdapter: GallerySliderAdapter? = null
-
- val asyncLoaderOfCapturedItems = Executors.newSingleThreadExecutor()
- val asyncImageLoader = Executors.newSingleThreadExecutor()
-
- private lateinit var snackBar: Snackbar
- private var ogColor by Delegates.notNull()
-
var isSecureMode = false
private set
- private lateinit var rootView: View
-
- private val autoFinisher = AutoFinishOnSleep(this)
-
- private var lastViewedMediaItem : CapturedItem? = null
-
- private lateinit var windowInsetsController: WindowInsetsControllerCompat
-
companion object {
const val INTENT_KEY_SECURE_MODE = "is_secure_mode"
const val INTENT_KEY_VIDEO_ONLY_MODE = "video_only_mode"
const val INTENT_KEY_LIST_OF_SECURE_MODE_CAPTURED_ITEMS = "secure_mode_items"
const val INTENT_KEY_LAST_CAPTURED_ITEM = "last_captured_item"
-
- const val LAST_VIEWED_ITEM_KEY = "LAST_VIEWED_ITEM_KEY"
-
- @SuppressLint("SimpleDateFormat")
- fun convertTime(time: Long, showTimeZone: Boolean = true): String {
- val date = Date(time)
- val format = SimpleDateFormat(
- if (showTimeZone) {
- "yyyy-MM-dd HH:mm:ss z"
- } else {
- "yyyy-MM-dd HH:mm:ss"
- }
- )
- format.timeZone = TimeZone.getDefault()
- return format.format(date)
- }
-
- fun convertTimeForVideo(time: String): String {
- val dateFormat = SimpleDateFormat("yyyyMMdd'T'HHmmss.SSS'Z'", Locale.US)
- dateFormat.timeZone = TimeZone.getTimeZone("UTC")
- val parsedDate = dateFormat.parse(time)
- return convertTime(parsedDate?.time ?: 0)
- }
-
- fun convertTimeForPhoto(time: String, offset: String? = null): String {
-
- val timestamp = if (offset != null) {
- "$time $offset"
- } else {
- time
- }
-
- val dateFormat = SimpleDateFormat(
- if (offset == null) {
- "yyyy:MM:dd HH:mm:ss"
- } else {
- "yyyy:MM:dd HH:mm:ss Z"
- }, Locale.US
- )
-
- if (offset == null) {
- dateFormat.timeZone = TimeZone.getDefault()
- }
- val parsedDate = dateFormat.parse(timestamp)
- return convertTime(parsedDate?.time ?: 0, offset != null)
- }
-
- fun getRelativePath(ctx: Context, uri: Uri, path: String?, fileName: String): String {
- if (path == null) {
- return storageLocationToUiString(ctx, uri.toString())
- }
-
- return "${ctx.getString(R.string.main_storage)}/$path$fileName"
- }
- }
-
- private fun getCurrentItem(): CapturedItem {
- return gallerySliderAdapter!!.getCurrentItem()
- }
-
- override fun onSupportNavigateUp(): Boolean {
- finish()
- return true
- }
-
- override fun onCreateOptionsMenu(menu: Menu): Boolean {
- menuInflater.inflate(R.menu.gallery, menu)
- return super.onCreateOptionsMenu(menu)
- }
-
- override fun onOptionsItemSelected(item: MenuItem): Boolean {
-
- return when (item.itemId) {
- R.id.edit_icon -> {
- editCurrentMedia()
- true
- }
- R.id.edit_with -> {
- editCurrentMedia(withDefault = false)
- true
- }
- R.id.delete_icon -> {
- deleteCurrentMedia()
- true
- }
-
- R.id.info -> {
- showCurrentMediaDetails()
- true
- }
-
- R.id.share_icon -> {
- shareCurrentMedia()
- true
- }
-
- else -> super.onOptionsItemSelected(item)
- }
}
- private fun editCurrentMedia(withDefault: Boolean = true) {
- if (isSecureMode) {
- showMessage(getString(R.string.edit_not_allowed))
- return
- }
-
- val curItem = getCurrentItem()
-
- val editIntent = Intent(Intent.ACTION_EDIT).apply {
- setDataAndType(curItem.uri, curItem.mimeType())
- putExtra(Intent.EXTRA_STREAM, curItem.uri)
- addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION)
- }
-
- if (withDefault) {
- try {
- startActivity(editIntent)
- } catch (ignored: ActivityNotFoundException) {
- showMessage(getString(R.string.no_editor_app_error))
- }
- } else {
- val chooser = Intent.createChooser(editIntent, getString(R.string.edit_image)).apply {
- putExtra(Intent.EXTRA_AUTO_LAUNCH_SINGLE_CHOICE, false)
- }
- startActivity(chooser)
- }
- }
-
- private fun deleteCurrentMedia() {
- val curItem = getCurrentItem()
-
- MaterialAlertDialogBuilder(this)
- .setTitle(R.string.delete_title)
- .setMessage(getString(R.string.delete_description, curItem.uiName()))
- .setPositiveButton(R.string.delete) { _, _ ->
- var res = false
-
- val uri = curItem.uri
- try {
- if (uri.authority == MediaStore.AUTHORITY) {
- res = contentResolver.delete(uri, null, null) > 0
- } else {
- res = DocumentsContract.deleteDocument(contentResolver, uri)
- }
- } catch (e: Exception) {
- e.printStackTrace()
- }
-
- if (res) {
- showMessage(getString(R.string.deleted_successfully))
- gallerySliderAdapter!!.removeItem(curItem)
- } else {
- showMessage(getString(R.string.deleting_unexpected_error))
- }
- }
- .setNegativeButton(R.string.cancel, null)
- .show()
-
- }
-
- private fun showCurrentMediaDetails() {
- val curItem = getCurrentItem()
-
- var relativePath: String? = null
- var fileName: String? = null
- var size: Long = 0
-
- var dateAdded: String? = null
- var dateModified: String? = null
-
- try {
- // note that the first column (RELATIVE_PATH) is undefined for SAF Uris
- val projection = arrayOf(MediaColumns.RELATIVE_PATH, OpenableColumns.DISPLAY_NAME, OpenableColumns.SIZE)
-
- contentResolver.query(curItem.uri, projection, null,null)?.use {
- if (it.moveToFirst()) {
- relativePath = it.getString(0)
- fileName = it.getString(1)
- size = it.getLong(2)
- }
- }
-
- if (fileName == null) {
- showMessage(getString(R.string.unable_to_obtain_file_details))
- return
- }
-
- if (curItem.type == ITEM_TYPE_VIDEO) {
- MediaMetadataRetriever().use {
- it.setDataSource(this, curItem.uri)
- dateAdded = convertTimeForVideo(it.extractMetadata(MediaMetadataRetriever.METADATA_KEY_DATE)!!)
- dateModified = dateAdded
- }
- } else {
- contentResolver.openInputStream(curItem.uri)?.use { stream ->
- val eInterface = ExifInterface(stream)
-
- val offset = eInterface.getAttribute(ExifInterface.TAG_OFFSET_TIME)
-
- if (eInterface.hasAttribute(ExifInterface.TAG_DATETIME_ORIGINAL)) {
- dateAdded = convertTimeForPhoto(
- eInterface.getAttribute(ExifInterface.TAG_DATETIME_ORIGINAL)!!,
- offset
- )
- }
-
- if (eInterface.hasAttribute(ExifInterface.TAG_DATETIME)) {
- dateModified = convertTimeForPhoto(
- eInterface.getAttribute(ExifInterface.TAG_DATETIME)!!,
- offset
- )
- }
- }
- }
- } catch (e: Exception) {
- Log.d("showCurrentMediaDetails", "unable to obtain file details", e)
- showMessage(getString(R.string.unable_to_obtain_file_details))
- return
- }
-
-
- val alertDialog = MaterialAlertDialogBuilder(this)
- .setTitle(getString(R.string.file_details))
-
- val detailsBuilder = StringBuilder()
-
- detailsBuilder.append("\n", getString(R.string.file_name_generic), "\n")
- detailsBuilder.append(fileName)
- detailsBuilder.append("\n\n")
-
- detailsBuilder.append(getString(R.string.file_path), "\n")
- detailsBuilder.append(getRelativePath(this, curItem.uri, relativePath, fileName!!))
- detailsBuilder.append("\n\n")
-
- detailsBuilder.append(getString(R.string.file_size), "\n")
- if (size == 0L) {
- detailsBuilder.append(getString(R.string.loading_generic))
- } else {
- detailsBuilder.append(
- String.format(
- "%.2f",
- (size / (1000f * 1000f))
- )
- )
- detailsBuilder.append(" MB")
- }
-
- detailsBuilder.append("\n\n")
-
- detailsBuilder.append(getString(R.string.file_created_on), "\n")
- if (dateAdded == null) {
- detailsBuilder.append(getString(R.string.not_found_generic))
- } else {
- detailsBuilder.append(dateAdded)
- }
-
- detailsBuilder.append("\n\n")
-
- detailsBuilder.append(getString(R.string.last_modified_on), "\n")
- if (dateModified == null) {
- detailsBuilder.append(getString(R.string.not_found_generic))
- } else {
- detailsBuilder.append(dateModified)
- }
-
- alertDialog.setMessage(detailsBuilder)
-
- alertDialog.setPositiveButton(getString(R.string.ok), null)
-
-
- alertDialog.show()
- }
-
- private fun animateBackgroundToBlack() {
-
- val cBgColor = (rootView.background as ColorDrawable).color
-
- if (cBgColor == Color.BLACK) {
- return
- }
-
- val bgColorAnim = ValueAnimator.ofObject(
- ArgbEvaluator(),
- ogColor,
- Color.BLACK
- )
- bgColorAnim.duration = 300
- bgColorAnim.addUpdateListener { animator ->
- val color = animator.animatedValue as Int
- rootView.setBackgroundColor(color)
- }
- bgColorAnim.start()
- }
-
- private fun animateBackgroundToOriginal() {
-
- val cBgColor = (rootView.background as ColorDrawable).color
-
- if (cBgColor == ogColor) {
- return
- }
-
- val bgColorAnim = ValueAnimator.ofObject(
- ArgbEvaluator(),
- Color.BLACK,
- ogColor,
- )
- bgColorAnim.duration = 300
- bgColorAnim.addUpdateListener { animator ->
- val color = animator.animatedValue as Int
- this.rootView.setBackgroundColor(color)
- }
- bgColorAnim.start()
- }
-
- private fun animateShadeToTransparent() {
- if (binding.shade.alpha == 0f) {
- return
- }
-
- binding.shade.animate().apply {
- duration = 300
- alpha(0f)
- }
- }
+ private val autoFinisher = AutoFinishOnSleep(this)
+
+ lateinit var capturedItemsRepository : CapturedItemsRepository
- private fun animateShadeToOriginal() {
- if (binding.shade.alpha == 1f) {
- return
- }
+ private lateinit var windowInsetsController: WindowInsetsControllerCompat
- binding.shade.animate().apply {
- duration = 300
- alpha(1f)
+ private val deviceUnlockStateListener = object : BroadcastReceiver() {
+ override fun onReceive(context: Context?, intent: Intent?) {
+ capturedItemsRepository.loadCapturedItems()
}
}
- private fun shareCurrentMedia() {
- if (isSecureMode) {
- showMessage(getString(R.string.sharing_not_allowed))
- return
- }
-
- val curItem = getCurrentItem()
-
- val share = Intent(Intent.ACTION_SEND)
- share.putExtra(Intent.EXTRA_STREAM, curItem.uri)
- share.setDataAndType(curItem.uri, curItem.mimeType())
- share.flags = Intent.FLAG_GRANT_READ_URI_PERMISSION
-
- startActivity(Intent.createChooser(share, getString(R.string.share_image)))
- }
-
override fun onCreate(savedInstanceState: Bundle?) {
- isSecureMode = intent.getBooleanExtra(INTENT_KEY_SECURE_MODE, false)
-
super.onCreate(savedInstanceState)
enableEdgeToEdge()
windowInsetsController = WindowCompat.getInsetsController(window, window.decorView).apply {
@@ -446,221 +56,62 @@ class InAppGallery : AppCompatActivity() {
show(WindowInsetsCompat.Type.systemBars())
}
+ isSecureMode = intent.getBooleanExtra(INTENT_KEY_SECURE_MODE, false)
+ val showVideosOnly = intent.getBooleanExtra(INTENT_KEY_VIDEO_ONLY_MODE, false)
+
if (isSecureMode) {
setShowWhenLocked(true)
setTurnScreenOn(true)
autoFinisher.start()
}
- ogColor = ContextCompat.getColor(this, R.color.system_neutral1_900)
- binding = GalleryBinding.inflate(layoutInflater)
- setContentView(binding.root)
-
- supportActionBar?.let {
- it.setDisplayShowTitleEnabled(false)
- it.setDisplayHomeAsUpEnabled(true)
- }
-
- rootView = binding.rootView
- rootView.setOnClickListener {
- if (gallerySliderAdapter != null) {
- toggleUIState()
- }
- }
-
- gallerySlider = binding.gallerySlider
- snackBar = Snackbar.make(binding.snackbarAnchor, "", Snackbar.LENGTH_LONG)
- gallerySlider.setPageTransformer(GSlideTransformer())
- ViewCompat.setOnApplyWindowInsetsListener(binding.snackbarAnchor) { view, insets ->
- val systemBars =
- insets.getInsetsIgnoringVisibility(WindowInsetsCompat.Type.systemBars())
- view.y = -systemBars.bottom.toFloat()
- snackBar.setAnchorView(view)
- insets
- }
-
- if (savedInstanceState != null) {
- lastViewedMediaItem = BundleCompat.getParcelable(savedInstanceState, LAST_VIEWED_ITEM_KEY, CapturedItem::class.java)
- }
-
- val intent = this.intent
-
- val showVideosOnly = intent.getBooleanExtra(INTENT_KEY_VIDEO_ONLY_MODE, false)
- val listOfSecureModeCapturedItems = getParcelableArrayListExtra(
- intent, INTENT_KEY_LIST_OF_SECURE_MODE_CAPTURED_ITEMS)
-
- asyncLoaderOfCapturedItems.execute {
- val unprocessedItems: List = try {
- CapturedItems.get(this)
- } catch (e: InterruptedException) {
- // activity was destroyed and exectutor.shutdownNow() was called, which interrupts
- // executor threads
- return@execute
- }
- val setOfSecureModeCapturedItems = listOfSecureModeCapturedItems?.toHashSet()
- val items = ArrayList(unprocessedItems.size)
-
- unprocessedItems.forEach { item ->
- if (showVideosOnly) {
- if (item.type != ITEM_TYPE_VIDEO) {
- return@forEach
- }
- }
-
- setOfSecureModeCapturedItems?.let {
- if (!it.contains(item)) {
- return@forEach
- }
- }
-
- items.add(item)
- }
- items.sortByDescending { it.dateString }
-
- mainExecutor.execute { asyncResultReady(items) }
- }
-
- if (lastViewedMediaItem == null) {
- val lastCapturedItem = getParcelableExtra(intent, INTENT_KEY_LAST_CAPTURED_ITEM)
+ val mediaItems = getParcelableArrayListExtra(
+ intent,
+ INTENT_KEY_LIST_OF_SECURE_MODE_CAPTURED_ITEMS
+ )
- if (lastCapturedItem != null) {
- val list = ArrayList()
- list.add(lastCapturedItem)
- GallerySliderAdapter(this, list).let {
- gallerySliderAdapter = it
- gallerySlider.adapter = it
- }
- } else {
- Handler(mainLooper).postDelayed({
- if (gallerySliderAdapter == null) {
- binding.placeholderText.root.visibility = View.VISIBLE
- }
- }, 500)
- }
- }
+ val lastCapturedItem = getParcelableExtra(
+ intent,
+ INTENT_KEY_LAST_CAPTURED_ITEM
+ )
- supportActionBar?.setBackgroundDrawable(null)
+ // Initial and start loading captured items in background
+ capturedItemsRepository = CapturedItemsRepository(
+ context = this,
+ scope = lifecycleScope,
+ mediaItems = mediaItems,
+ showVideosOnly = showVideosOnly,
+ placeholderItem = lastCapturedItem,
+ )
- ViewCompat.setOnApplyWindowInsetsListener(binding.shade) { view, insets ->
- val systemBars = insets.getInsetsIgnoringVisibility(WindowInsetsCompat.Type.systemBars())
- val actionBarHeight = resources.getDimensionPixelSize(R.dimen.action_bar_height)
- view.layoutParams =
- RelativeLayout.LayoutParams(
- RelativeLayout.LayoutParams.MATCH_PARENT,
- systemBars.top + actionBarHeight
- )
- view.background = ContextCompat.getDrawable(this@InAppGallery, R.drawable.shade)
- insets
+ setContent {
+ CameraApp(
+ initialRoute = GalleryRoute(),
+ onExitAction = this::finish,
+ )
}
}
- override fun onResume() {
- super.onResume()
- showUI()
- }
-
- fun asyncResultReady(items: ArrayList) {
- if (isDestroyed) {
- return
- }
-
- if (items.isEmpty()) {
- Toast.makeText(applicationContext, R.string.empty_gallery, Toast.LENGTH_SHORT).show()
- finish()
- return
- }
+ override fun onStart() {
+ super.onStart()
- var capturedItemPosition = 0
-
- if (lastViewedMediaItem != null) {
- for (i in 0..
- // this check is needed to avoid showing preloaded item twice (it's not guaranteed
- // that it'll be first in the list)
- if (index > 50 || item != preloadedItem) {
- adapterItems.add(item)
- }
- }
- existingAdapter.notifyItemRangeInserted(1, items.size - 1)
- }
- gallerySlider.setCurrentItem(capturedItemPosition, false)
- showUI()
- }
-
- fun toggleUIState() {
- supportActionBar?.let {
- if (it.isShowing) {
- hideUI()
- } else {
- showUI()
- }
- }
- }
-
- fun showUI() {
- supportActionBar?.let {
- it.show()
- animateBackgroundToOriginal()
- }
- animateShadeToOriginal()
- windowInsetsController.show(WindowInsetsCompat.Type.systemBars())
- }
-
- fun hideUI() {
- supportActionBar?.let {
- it.hide()
- animateBackgroundToBlack()
- }
- animateShadeToTransparent()
- windowInsetsController.hide(WindowInsetsCompat.Type.systemBars())
+ )
}
override fun onDestroy() {
super.onDestroy()
- asyncLoaderOfCapturedItems.shutdownNow()
- asyncImageLoader.shutdownNow()
if (isSecureMode) {
autoFinisher.stop()
}
}
- fun vibrateDevice() {
- val vibrator = getSystemService(Vibrator::class.java)
- vibrator?.vibrate(VibrationEffect.createPredefined(VibrationEffect.EFFECT_TICK))
- }
-
- fun showMessage(msg: String) {
- snackBar.setText(msg)
- snackBar.show()
- }
-
- override fun onSaveInstanceState(outState: Bundle) {
- super.onSaveInstanceState(outState)
-
- gallerySliderAdapter?.let {
- outState.putParcelable(LAST_VIEWED_ITEM_KEY, it.items[gallerySlider.currentItem])
- }
+ override fun onStop() {
+ super.onStop()
+ unregisterReceiver(deviceUnlockStateListener)
}
-}
+}
\ No newline at end of file
diff --git a/app/src/main/java/app/grapheneos/camera/ui/activities/VideoPlayer.kt b/app/src/main/java/app/grapheneos/camera/ui/activities/VideoPlayer.kt
index b9f94bb7a..1ffc06afe 100644
--- a/app/src/main/java/app/grapheneos/camera/ui/activities/VideoPlayer.kt
+++ b/app/src/main/java/app/grapheneos/camera/ui/activities/VideoPlayer.kt
@@ -1,27 +1,19 @@
package app.grapheneos.camera.ui.activities
-import android.graphics.drawable.ColorDrawable
-import android.media.AudioManager
-import android.media.MediaMetadataRetriever
import android.net.Uri
import android.os.Bundle
-import android.util.Log
-import android.widget.FrameLayout
-import android.widget.MediaController
-import android.widget.RelativeLayout
+
+import androidx.activity.compose.setContent
import androidx.activity.enableEdgeToEdge
import androidx.appcompat.app.AppCompatActivity
-import androidx.core.content.ContextCompat
-import androidx.core.view.ViewCompat
+
+import app.grapheneos.camera.ui.composable.screen.ui.VideoPlayerScreen
import androidx.core.view.WindowCompat
import androidx.core.view.WindowInsetsCompat
-import androidx.lifecycle.Lifecycle
+
import app.grapheneos.camera.AutoFinishOnSleep
-import app.grapheneos.camera.R
-import app.grapheneos.camera.databinding.VideoPlayerBinding
-import app.grapheneos.camera.util.getParcelableExtra
-import kotlin.concurrent.thread
+import app.grapheneos.camera.util.getParcelableExtra
class VideoPlayer : AppCompatActivity() {
@@ -31,8 +23,6 @@ class VideoPlayer : AppCompatActivity() {
const val VIDEO_URI = "videoUri"
}
- private lateinit var binding: VideoPlayerBinding
-
private val autoFinisher = AutoFinishOnSleep(this)
private var isSecureMode = false
@@ -56,123 +46,13 @@ class VideoPlayer : AppCompatActivity() {
autoFinisher.start()
}
- binding = VideoPlayerBinding.inflate(layoutInflater)
- setContentView(binding.root)
-
- supportActionBar?.let {
- it.setBackgroundDrawable(ColorDrawable(ContextCompat.getColor(this, R.color.appbar)))
- it.setDisplayShowTitleEnabled(false)
- it.setDisplayHomeAsUpEnabled(true)
- }
-
val uri = getParcelableExtra(intent, VIDEO_URI)!!
- val videoView = binding.videoPlayer
-
- val mediaController = object : MediaController(this) {
- override fun show() {
- super.show()
- showActionBar()
- windowInsetsController.show(WindowInsetsCompat.Type.systemBars())
- }
-
- override fun hide() {
- super.hide()
- hideActionBar()
- windowInsetsController.hide(WindowInsetsCompat.Type.systemBars())
- }
- }
-
- supportActionBar?.setBackgroundDrawable(null)
-
- ViewCompat.setOnApplyWindowInsetsListener(binding.shade) { view, insets ->
- val systemBars = insets.getInsetsIgnoringVisibility(WindowInsetsCompat.Type.systemBars())
- val actionBarHeight = resources.getDimensionPixelSize(R.dimen.action_bar_height)
- view.layoutParams =
- FrameLayout.LayoutParams(
- RelativeLayout.LayoutParams.MATCH_PARENT,
- systemBars.top + actionBarHeight
- )
- view.background = ContextCompat.getDrawable(this@VideoPlayer, R.drawable.shade)
- insets
- }
-
- thread {
- var hasAudio = true
- try {
- MediaMetadataRetriever().use {
- it.setDataSource(this, uri)
- hasAudio = it.extractMetadata(MediaMetadataRetriever.METADATA_KEY_HAS_AUDIO) != null
- }
- } catch (e: Exception) {
- Log.d(TAG, "", e)
- }
-
- mainExecutor.execute {
- val lifecycleState = lifecycle.currentState
-
- if (lifecycleState == Lifecycle.State.DESTROYED) {
- return@execute
- }
-
- val audioFocus = if (hasAudio) AudioManager.AUDIOFOCUS_GAIN else AudioManager.AUDIOFOCUS_NONE
- videoView.setAudioFocusRequest(audioFocus)
-
- videoView.setOnPreparedListener { _ ->
- videoView.setMediaController(mediaController)
-
- if (lifecycleState == Lifecycle.State.RESUMED) {
- videoView.start()
- }
-
- showActionBar()
- mediaController.show(0)
- }
-
- videoView.setVideoURI(uri)
- }
- }
- }
-
- override fun onSupportNavigateUp(): Boolean {
- finish()
- return true
- }
-
- override fun onResume() {
- super.onResume()
- showActionBar()
- }
-
- private fun hideActionBar() {
- supportActionBar?.hide()
- animateShadeToTransparent()
- }
-
- private fun showActionBar() {
- supportActionBar?.show()
- animateShadeToOriginal()
- }
-
- private fun animateShadeToTransparent() {
- if (binding.shade.alpha == 0f) {
- return
- }
-
- binding.shade.animate().apply {
- duration = 300
- alpha(0f)
- }
- }
-
- private fun animateShadeToOriginal() {
- if (binding.shade.alpha == 1f) {
- return
- }
-
- binding.shade.animate().apply {
- duration = 300
- alpha(1f)
+ setContent {
+ VideoPlayerScreen(
+ mediaUri = uri,
+ onExitAction = this::finish
+ )
}
}
diff --git a/app/src/main/java/app/grapheneos/camera/ui/composable/CameraApp.kt b/app/src/main/java/app/grapheneos/camera/ui/composable/CameraApp.kt
new file mode 100644
index 000000000..690e7f53e
--- /dev/null
+++ b/app/src/main/java/app/grapheneos/camera/ui/composable/CameraApp.kt
@@ -0,0 +1,104 @@
+package app.grapheneos.camera.ui.composable
+
+import androidx.compose.animation.EnterTransition
+import androidx.compose.animation.ExitTransition
+import androidx.compose.material3.MaterialTheme
+import androidx.compose.runtime.Composable
+
+import androidx.navigation.compose.NavHost
+import androidx.navigation.compose.composable
+import androidx.navigation.compose.rememberNavController
+import androidx.navigation.toRoute
+import app.grapheneos.camera.CapturedItem
+import app.grapheneos.camera.ITEM_TYPE_VIDEO
+import app.grapheneos.camera.ktx.popBackStack
+import app.grapheneos.camera.ui.composable.screen.ui.ExtendedGalleryScreen
+import app.grapheneos.camera.ui.composable.model.VideoUri
+import app.grapheneos.camera.ui.composable.screen.routes.ExtendedGalleryRoute
+
+import app.grapheneos.camera.ui.composable.screen.routes.GalleryRoute
+import app.grapheneos.camera.ui.composable.screen.routes.VideoPlayerRoute
+
+import app.grapheneos.camera.ui.composable.screen.ui.GalleryScreen
+import app.grapheneos.camera.ui.composable.screen.ui.VideoPlayerScreen
+import app.grapheneos.camera.ui.composable.theme.appColorScheme
+import app.grapheneos.camera.ui.composable.theme.appTypography
+
+@Composable
+fun CameraApp(
+ initialRoute: Any,
+ onExitAction : () -> Unit,
+) {
+ val navController = rememberNavController()
+
+ val defaultBackAction = {
+ // Go to previous screen if present, else perform exit action
+ navController.popBackStack(onBackStackEmpty = onExitAction)
+ }
+
+ MaterialTheme(
+ colorScheme = appColorScheme(),
+ typography = appTypography(),
+ ) {
+ NavHost(
+ navController = navController,
+ startDestination = initialRoute,
+ enterTransition = {
+ EnterTransition.None
+ },
+ exitTransition = {
+ ExitTransition.None
+ }
+ ) {
+ composable(typeMap = GalleryRoute.typeMap) { backStackEntry ->
+
+ val args = backStackEntry.toRoute()
+
+ GalleryScreen(
+ focusItem = args.focusItem,
+ showVideoPlayerAction = { capturedItem ->
+ navController.navigate(
+ VideoPlayerRoute(videoUri = VideoUri(capturedItem.uri))
+ )
+ },
+ showExtendedGalleryAction = {
+ navController.navigate(ExtendedGalleryRoute)
+ },
+ onExitAction = defaultBackAction,
+ )
+ }
+
+ composable {
+ ExtendedGalleryScreen(
+ showMediaItemAction = { capturedItem ->
+ if (capturedItem.type == ITEM_TYPE_VIDEO) {
+ navController.navigate(
+ VideoPlayerRoute(
+ videoUri = VideoUri(capturedItem.uri)
+ )
+ )
+ } else {
+ navController.navigate(
+ GalleryRoute(
+ focusItem = capturedItem
+ )
+ )
+ }
+
+ },
+ onExitAction = defaultBackAction
+ )
+ }
+
+ composable(typeMap = VideoPlayerRoute.typeMap) {
+ val args = it.toRoute()
+
+ VideoPlayerScreen(
+ mediaUri = args.videoUri.uri,
+ onExitAction = defaultBackAction,
+ )
+ }
+ }
+ }
+
+}
\ No newline at end of file
diff --git a/app/src/main/java/app/grapheneos/camera/ui/composable/component/SnackBarMessageHandler.kt b/app/src/main/java/app/grapheneos/camera/ui/composable/component/SnackBarMessageHandler.kt
new file mode 100644
index 000000000..1ca980c74
--- /dev/null
+++ b/app/src/main/java/app/grapheneos/camera/ui/composable/component/SnackBarMessageHandler.kt
@@ -0,0 +1,23 @@
+package app.grapheneos.camera.ui.composable.component
+
+import androidx.compose.material3.SnackbarHostState
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.LaunchedEffect
+import app.grapheneos.camera.ktx.dismissSnackBarIfVisible
+import app.grapheneos.camera.ktx.showOrReplaceSnackbar
+import app.grapheneos.camera.ui.composable.model.NoDataSnackBarMessage
+import app.grapheneos.camera.ui.composable.model.SnackBarMessage
+
+@Composable
+fun SnackBarMessageHandler(
+ snackBarHostState: SnackbarHostState,
+ snackBarMessage: SnackBarMessage
+) {
+ LaunchedEffect(snackBarMessage) {
+ if (snackBarMessage == NoDataSnackBarMessage) {
+ snackBarHostState.dismissSnackBarIfVisible()
+ } else {
+ snackBarHostState.showOrReplaceSnackbar(snackBarMessage.message)
+ }
+ }
+}
\ No newline at end of file
diff --git a/app/src/main/java/app/grapheneos/camera/ui/composable/component/dialog/CommonAlertDialog.kt b/app/src/main/java/app/grapheneos/camera/ui/composable/component/dialog/CommonAlertDialog.kt
new file mode 100644
index 000000000..80b90e6d2
--- /dev/null
+++ b/app/src/main/java/app/grapheneos/camera/ui/composable/component/dialog/CommonAlertDialog.kt
@@ -0,0 +1,91 @@
+package app.grapheneos.camera.ui.composable.component.dialog
+
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.Row
+import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.rememberScrollState
+import androidx.compose.foundation.shape.RoundedCornerShape
+import androidx.compose.foundation.verticalScroll
+import androidx.compose.material3.AlertDialog
+import androidx.compose.material3.MaterialTheme
+import androidx.compose.material3.Surface
+import androidx.compose.material3.Text
+import androidx.compose.material3.TextButton
+
+import androidx.compose.runtime.Composable
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.res.stringResource
+import androidx.compose.ui.text.font.FontWeight
+import androidx.compose.ui.unit.dp
+import androidx.compose.ui.window.Dialog
+import app.grapheneos.camera.R
+
+@Composable
+fun CommonAlertDialog(
+ titleText: String? = null,
+ message: String? = null,
+ confirmButtonText: String = stringResource(R.string.ok),
+ cancelButtonText: String = stringResource(R.string.cancel),
+ confirmationCallback: () -> Unit = {},
+ dismissCallback: () -> Unit = {},
+) = Dialog(
+ content = {
+ Surface(
+ shape = RoundedCornerShape(24.dp),
+ ) {
+ Column(
+ modifier = Modifier
+ .verticalScroll(rememberScrollState())
+ .padding(
+ start = 24.dp,
+ end = 12.dp,
+ top = 20.dp,
+ bottom = 10.dp
+ )
+ ) {
+ if (titleText != null) {
+ Text(
+ text = titleText,
+ style = MaterialTheme.typography.titleLarge,
+ fontWeight = FontWeight.Medium,
+ )
+ }
+
+ if (message != null) {
+ Text(
+ modifier = Modifier.padding(
+ top = 12.dp,
+ bottom = 6.dp,
+ end = 12.dp,
+ ),
+ style = MaterialTheme.typography.bodyLarge,
+ text = message
+ )
+ }
+
+ Row (
+ modifier = Modifier
+ .align(Alignment.End)
+ .padding(top = 4.dp)
+ ) {
+ TextButton(onClick = dismissCallback) {
+ Text(cancelButtonText)
+ }
+
+ TextButton(onClick = {
+ confirmationCallback()
+ dismissCallback()
+ }) {
+ Text(confirmButtonText)
+ }
+
+
+ }
+ }
+ }
+
+ },
+
+ onDismissRequest = dismissCallback
+)
\ No newline at end of file
diff --git a/app/src/main/java/app/grapheneos/camera/ui/composable/component/dialog/CommonInfoDialog.kt b/app/src/main/java/app/grapheneos/camera/ui/composable/component/dialog/CommonInfoDialog.kt
new file mode 100644
index 000000000..b5e7d232b
--- /dev/null
+++ b/app/src/main/java/app/grapheneos/camera/ui/composable/component/dialog/CommonInfoDialog.kt
@@ -0,0 +1,81 @@
+package app.grapheneos.camera.ui.composable.component.dialog
+
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.Row
+import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.rememberScrollState
+import androidx.compose.foundation.shape.RoundedCornerShape
+import androidx.compose.foundation.verticalScroll
+import androidx.compose.material3.MaterialTheme
+import androidx.compose.material3.Surface
+import androidx.compose.material3.Text
+import androidx.compose.material3.TextButton
+
+import androidx.compose.runtime.Composable
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.res.stringResource
+import androidx.compose.ui.unit.dp
+import androidx.compose.ui.window.Dialog
+import app.grapheneos.camera.R
+
+@Composable
+fun CommonInfoDialog(
+ titleText: String? = null,
+ message: String? = null,
+ dismissText: String = stringResource(R.string.ok),
+ dismissCallback: () -> Unit = {},
+) {
+ Dialog(
+ onDismissRequest = dismissCallback,
+
+ content = {
+ Surface(
+ shape = RoundedCornerShape(24.dp)
+ ) {
+ Column(
+ modifier = Modifier
+ .padding(
+ start = 24.dp,
+ top = 20.dp,
+ bottom = 8.dp
+ )
+ ) {
+ if (titleText != null) {
+ Text(
+ text = titleText,
+ style = MaterialTheme.typography.titleLarge,
+ modifier = Modifier
+ .padding(bottom = 20.dp)
+ )
+ }
+
+ if (message != null) {
+ Row (
+ modifier = Modifier
+ .verticalScroll(rememberScrollState())
+ ) {
+ Text(
+ text = message
+ )
+
+ }
+ }
+
+
+
+ TextButton(
+ onClick = dismissCallback,
+ modifier = Modifier
+ .align(alignment = Alignment.End)
+ .padding(end = 12.dp)
+ ) {
+ Text(text = dismissText)
+ }
+
+ }
+ }
+
+ }
+ )
+}
\ No newline at end of file
diff --git a/app/src/main/java/app/grapheneos/camera/ui/composable/component/dialog/FileDeletionDialog.kt b/app/src/main/java/app/grapheneos/camera/ui/composable/component/dialog/FileDeletionDialog.kt
new file mode 100644
index 000000000..3ac16b6df
--- /dev/null
+++ b/app/src/main/java/app/grapheneos/camera/ui/composable/component/dialog/FileDeletionDialog.kt
@@ -0,0 +1,30 @@
+package app.grapheneos.camera.ui.composable.component.dialog
+
+import androidx.compose.runtime.Composable
+
+import androidx.compose.ui.res.stringResource
+
+import app.grapheneos.camera.CapturedItem
+import app.grapheneos.camera.R
+
+
+@Composable
+fun FileDeletionDialog(
+ deletionItem : CapturedItem?,
+ onDeleteAction: (item: CapturedItem) -> Unit,
+ dismissHandler: () -> Unit
+) {
+ if (deletionItem != null) {
+ CommonAlertDialog(
+ titleText = stringResource(id = R.string.delete_title),
+ message = stringResource(
+ id = R.string.delete_description,
+ deletionItem.uiName(),
+ ),
+ confirmationCallback = {
+ onDeleteAction(deletionItem)
+ },
+ dismissCallback = dismissHandler,
+ )
+ }
+}
\ No newline at end of file
diff --git a/app/src/main/java/app/grapheneos/camera/ui/composable/component/dialog/MediaInfoDialog.kt b/app/src/main/java/app/grapheneos/camera/ui/composable/component/dialog/MediaInfoDialog.kt
new file mode 100644
index 000000000..ac45e1281
--- /dev/null
+++ b/app/src/main/java/app/grapheneos/camera/ui/composable/component/dialog/MediaInfoDialog.kt
@@ -0,0 +1,36 @@
+package app.grapheneos.camera.ui.composable.component.dialog
+
+import androidx.compose.runtime.Composable
+
+import androidx.compose.ui.res.stringResource
+import app.grapheneos.camera.R
+import app.grapheneos.camera.ui.composable.model.MediaItemDetails
+
+@Composable
+fun MediaInfoDialog(
+ mediaItemDetails: MediaItemDetails?,
+ dismissCallback : () -> Unit,
+) {
+ if (mediaItemDetails != null) {
+ CommonInfoDialog(
+ titleText = stringResource(R.string.file_details),
+ message = """
+ ${stringResource(R.string.file_name_generic)}
+ ${mediaItemDetails.fileName ?: stringResource(id = R.string.not_found_generic)}
+
+ ${stringResource(R.string.file_path)}
+ ${mediaItemDetails.filePath ?: stringResource(id = R.string.not_found_generic)}
+
+ ${stringResource(R.string.file_size)}
+ ${mediaItemDetails.size ?: stringResource(id = R.string.not_found_generic)}
+
+ ${stringResource(R.string.file_created_on)}
+ ${mediaItemDetails.dateAdded ?: stringResource(id = R.string.not_found_generic)}
+
+ ${stringResource(R.string.last_modified_on)}
+ ${mediaItemDetails.dateModified ?: stringResource(id = R.string.not_found_generic)}
+ """.trimIndent(),
+ dismissCallback = dismissCallback
+ )
+ }
+}
\ No newline at end of file
diff --git a/app/src/main/java/app/grapheneos/camera/ui/composable/component/dialog/MultipleFileDeletionDialog.kt b/app/src/main/java/app/grapheneos/camera/ui/composable/component/dialog/MultipleFileDeletionDialog.kt
new file mode 100644
index 000000000..445a1dcf8
--- /dev/null
+++ b/app/src/main/java/app/grapheneos/camera/ui/composable/component/dialog/MultipleFileDeletionDialog.kt
@@ -0,0 +1,18 @@
+package app.grapheneos.camera.ui.composable.component.dialog
+
+import androidx.compose.runtime.Composable
+import androidx.compose.ui.res.stringResource
+import app.grapheneos.camera.R
+
+@Composable
+fun MultipleFileDeletionDialog(
+ deletionItemsCount: Int,
+ onDeleteConfirmationAction: () -> Unit,
+ dismissCallback: () -> Unit
+) = CommonAlertDialog(
+ titleText = stringResource(R.string.delete_title),
+ confirmButtonText = stringResource(R.string.delete),
+ message = stringResource(R.string.multiple_deletion_message, deletionItemsCount),
+ confirmationCallback = onDeleteConfirmationAction,
+ dismissCallback = dismissCallback
+)
\ No newline at end of file
diff --git a/app/src/main/java/app/grapheneos/camera/ui/composable/component/mediapreview/MediaPreview.kt b/app/src/main/java/app/grapheneos/camera/ui/composable/component/mediapreview/MediaPreview.kt
new file mode 100644
index 000000000..eeb0fcac7
--- /dev/null
+++ b/app/src/main/java/app/grapheneos/camera/ui/composable/component/mediapreview/MediaPreview.kt
@@ -0,0 +1,108 @@
+package app.grapheneos.camera.ui.composable.component.mediapreview
+
+import androidx.compose.foundation.Image
+import androidx.compose.foundation.background
+import androidx.compose.foundation.border
+import androidx.compose.foundation.layout.Box
+import androidx.compose.foundation.layout.fillMaxSize
+import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.layout.requiredSize
+import androidx.compose.foundation.shape.CircleShape
+import androidx.compose.material3.Icon
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.remember
+
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.graphics.Color
+import androidx.compose.ui.layout.ContentScale
+import androidx.compose.ui.platform.LocalContext
+import androidx.compose.ui.res.painterResource
+import androidx.compose.ui.res.stringResource
+import androidx.compose.ui.unit.dp
+import androidx.lifecycle.compose.LifecycleResumeEffect
+
+import app.grapheneos.camera.CapturedItem
+import app.grapheneos.camera.ITEM_TYPE_VIDEO
+import app.grapheneos.camera.R
+
+import coil3.ImageLoader
+
+import coil3.compose.rememberAsyncImagePainter
+import coil3.request.ImageRequest
+import coil3.video.VideoFrameDecoder
+
+private const val TAG = "MediaPreview"
+
+@Composable
+fun MediaPreview(
+ capturedItem: CapturedItem,
+ modifier: Modifier = Modifier
+) {
+ val context = LocalContext.current
+
+ val imageLoader = remember(context, capturedItem) {
+ ImageLoader.Builder(context)
+ .components {
+ if (capturedItem.type == ITEM_TYPE_VIDEO) {
+ add(VideoFrameDecoder.Factory())
+ }
+ }
+ .build()
+ }
+
+ val imageRequest = remember(capturedItem) {
+ ImageRequest.Builder(context)
+ .data(capturedItem.uri)
+ .build()
+ }
+
+ val imagePainter = rememberAsyncImagePainter(
+ model = imageRequest,
+ imageLoader = imageLoader,
+ )
+
+ // Update image on resume
+ // Use case: When user edits the original image and returns back to the app
+ LifecycleResumeEffect(Unit) {
+ imagePainter.restart()
+ onPauseOrDispose {}
+ }
+
+ MediaPreviewStateShowcase(
+ painter = imagePainter,
+ capturedItem = capturedItem,
+ mediaPreviewLoaderType = MediaPreviewLoaderType.THREE_DOTS,
+ mediaPreviewErrorType = MediaPreviewErrorType.INFO_MESSAGE
+ )
+
+ Box(contentAlignment = Alignment.Center) {
+ Image(
+ painter = imagePainter,
+ modifier = modifier.fillMaxSize(),
+ contentScale = ContentScale.Fit,
+ contentDescription = stringResource(R.string.preview)
+ )
+
+ if (capturedItem.type == ITEM_TYPE_VIDEO) {
+ Icon(
+ painter = painterResource(R.drawable.play),
+ tint = Color.White,
+ contentDescription = stringResource(R.string.play_video),
+ modifier = modifier
+ .background(
+ color = Color(0x99000000),
+ shape = CircleShape,
+ )
+ .border(
+ width = 0.5.dp,
+ color = Color(0x50ffffff),
+ shape = CircleShape,
+ )
+ .requiredSize(56.dp)
+ .padding(12.dp)
+ .align(Alignment.Center)
+ )
+ }
+ }
+}
\ No newline at end of file
diff --git a/app/src/main/java/app/grapheneos/camera/ui/composable/component/mediapreview/MediaPreviewStateShowcase.kt b/app/src/main/java/app/grapheneos/camera/ui/composable/component/mediapreview/MediaPreviewStateShowcase.kt
new file mode 100644
index 000000000..461758829
--- /dev/null
+++ b/app/src/main/java/app/grapheneos/camera/ui/composable/component/mediapreview/MediaPreviewStateShowcase.kt
@@ -0,0 +1,98 @@
+package app.grapheneos.camera.ui.composable.component.mediapreview
+
+import androidx.compose.foundation.background
+import androidx.compose.foundation.layout.Box
+import androidx.compose.foundation.layout.aspectRatio
+import androidx.compose.foundation.layout.fillMaxSize
+import androidx.compose.foundation.layout.wrapContentHeight
+import androidx.compose.material.icons.Icons
+import androidx.compose.material.icons.filled.ErrorOutline
+import androidx.compose.material.icons.outlined.ErrorOutline
+import androidx.compose.material3.Icon
+import androidx.compose.material3.Text
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.getValue
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.graphics.Color
+import androidx.compose.ui.res.stringResource
+import androidx.compose.ui.text.style.TextAlign
+import androidx.compose.ui.unit.sp
+import androidx.lifecycle.compose.collectAsStateWithLifecycle
+import app.grapheneos.camera.CapturedItem
+import app.grapheneos.camera.R
+import coil3.compose.AsyncImagePainter
+
+private const val TAG = "MediaPreviewStateShowcase"
+
+enum class MediaPreviewLoaderType {
+ THREE_DOTS,
+ SQUARE_BOX,
+}
+
+enum class MediaPreviewErrorType {
+ INFO_MESSAGE,
+ ERROR_ICON,
+}
+
+@Composable
+fun MediaPreviewStateShowcase(
+ painter: AsyncImagePainter,
+ capturedItem: CapturedItem,
+ mediaPreviewLoaderType : MediaPreviewLoaderType = MediaPreviewLoaderType.THREE_DOTS,
+ mediaPreviewErrorType: MediaPreviewErrorType = MediaPreviewErrorType.INFO_MESSAGE,
+) {
+ val imageState by painter.state.collectAsStateWithLifecycle()
+
+ if (imageState is AsyncImagePainter.State.Empty || imageState is AsyncImagePainter.State.Loading) {
+
+ if (mediaPreviewLoaderType == MediaPreviewLoaderType.THREE_DOTS) {
+ Text(
+ text = stringResource(R.string.three_dots),
+ color = Color.White,
+ textAlign = TextAlign.Center,
+ modifier = Modifier
+ .fillMaxSize()
+ .wrapContentHeight(align = Alignment.CenterVertically),
+ )
+ } else {
+ Box(
+ modifier = Modifier
+ .background(Color.Gray)
+ .aspectRatio(1f)
+ .fillMaxSize()
+ )
+ }
+
+
+
+
+ } else if (imageState is AsyncImagePainter.State.Error) {
+ if (mediaPreviewErrorType == MediaPreviewErrorType.INFO_MESSAGE) {
+ Text(
+ text = stringResource(R.string.load_media_failure_message, capturedItem.uiName()),
+ color = Color.Gray,
+ fontSize = 20.sp,
+ lineHeight = 24.sp,
+ textAlign = TextAlign.Center,
+ modifier = Modifier
+ .fillMaxSize()
+ .wrapContentHeight(align = Alignment.CenterVertically)
+ )
+ } else {
+ Box(
+ contentAlignment = Alignment.Center,
+ modifier = Modifier
+ .wrapContentHeight(align = Alignment.CenterVertically)
+ .aspectRatio(1f)
+ .fillMaxSize(),
+ ) {
+ Icon(
+ Icons.Outlined.ErrorOutline,
+ contentDescription = stringResource(R.string.error),
+ )
+ }
+
+ }
+ }
+}
\ No newline at end of file
diff --git a/app/src/main/java/app/grapheneos/camera/ui/composable/component/mediapreview/SquareMediaPreview.kt b/app/src/main/java/app/grapheneos/camera/ui/composable/component/mediapreview/SquareMediaPreview.kt
new file mode 100644
index 000000000..d7dfa335c
--- /dev/null
+++ b/app/src/main/java/app/grapheneos/camera/ui/composable/component/mediapreview/SquareMediaPreview.kt
@@ -0,0 +1,122 @@
+package app.grapheneos.camera.ui.composable.component.mediapreview
+
+import androidx.compose.foundation.ExperimentalFoundationApi
+import androidx.compose.foundation.Image
+import androidx.compose.foundation.background
+import androidx.compose.foundation.border
+import androidx.compose.foundation.combinedClickable
+import androidx.compose.foundation.layout.Box
+import androidx.compose.foundation.layout.aspectRatio
+import androidx.compose.foundation.layout.fillMaxSize
+import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.layout.requiredSize
+import androidx.compose.foundation.shape.CircleShape
+import androidx.compose.material3.Icon
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.remember
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.graphics.Color
+import androidx.compose.ui.hapticfeedback.HapticFeedbackType
+import androidx.compose.ui.layout.ContentScale
+import androidx.compose.ui.platform.LocalContext
+import androidx.compose.ui.platform.LocalHapticFeedback
+import androidx.compose.ui.res.painterResource
+import androidx.compose.ui.res.stringResource
+import androidx.compose.ui.unit.dp
+import androidx.constraintlayout.motion.widget.MotionScene.Transition.TransitionOnClick
+import app.grapheneos.camera.CapturedItem
+import app.grapheneos.camera.ITEM_TYPE_VIDEO
+import app.grapheneos.camera.R
+import app.grapheneos.camera.ktx.toPx
+import coil3.ImageLoader
+import coil3.compose.rememberAsyncImagePainter
+import coil3.request.ImageRequest
+import coil3.video.VideoFrameDecoder
+
+private const val TAG = "SquareMediaPreview"
+
+val SQUARE_MEDIA_PREVIEW_SIZE = 80.dp
+
+@OptIn(ExperimentalFoundationApi::class)
+@Composable
+fun SquareMediaPreview(
+ capturedItem: CapturedItem,
+ modifier: Modifier = Modifier,
+ onClick: () -> Unit = {},
+ onLongClick: () -> Unit = {},
+ imageSize: Int = SQUARE_MEDIA_PREVIEW_SIZE.toPx().toInt(),
+) {
+ val context = LocalContext.current
+
+ val haptic = LocalHapticFeedback.current
+
+ val imageLoader = remember(context, capturedItem) {
+ ImageLoader.Builder(context)
+ .components {
+ if (capturedItem.type == ITEM_TYPE_VIDEO) {
+ add(VideoFrameDecoder.Factory())
+ }
+ }
+ .build()
+ }
+
+ val imageRequest = remember(capturedItem) {
+ ImageRequest.Builder(context)
+ .data(capturedItem.uri)
+ .size(imageSize)
+ .build()
+ }
+
+ val imagePainter = rememberAsyncImagePainter(
+ model = imageRequest,
+ imageLoader = imageLoader,
+ )
+
+ MediaPreviewStateShowcase(
+ painter = imagePainter,
+ capturedItem = capturedItem,
+ mediaPreviewLoaderType = MediaPreviewLoaderType.SQUARE_BOX,
+ mediaPreviewErrorType = MediaPreviewErrorType.ERROR_ICON,
+ )
+
+ Box(
+ modifier = Modifier.combinedClickable(
+ onClick = onClick,
+ onLongClick = {
+ haptic.performHapticFeedback(HapticFeedbackType.LongPress)
+ onLongClick()
+ }
+ )
+ ) {
+ Image(
+ painter = imagePainter,
+ modifier = modifier
+ .aspectRatio(1f)
+ .fillMaxSize(),
+ contentScale = ContentScale.Crop,
+ contentDescription = stringResource(R.string.preview)
+ )
+
+ if (capturedItem.type == ITEM_TYPE_VIDEO) {
+ Icon(
+ painter = painterResource(R.drawable.play),
+ tint = Color.White,
+ contentDescription = stringResource(R.string.play_video),
+ modifier = modifier
+ .background(
+ color = Color(0x99000000),
+ shape = CircleShape
+ )
+ .border(
+ width = 0.5.dp,
+ color = Color(0x50ffffff),
+ shape = CircleShape
+ )
+ .requiredSize(48.dp)
+ .padding(12.dp)
+ .align(Alignment.Center)
+ )
+ }
+ }
+}
\ No newline at end of file
diff --git a/app/src/main/java/app/grapheneos/camera/ui/composable/component/tooltip/QuickTooltip.kt b/app/src/main/java/app/grapheneos/camera/ui/composable/component/tooltip/QuickTooltip.kt
new file mode 100644
index 000000000..84c9c431a
--- /dev/null
+++ b/app/src/main/java/app/grapheneos/camera/ui/composable/component/tooltip/QuickTooltip.kt
@@ -0,0 +1,104 @@
+package app.grapheneos.camera.ui.composable.component.tooltip
+
+import androidx.compose.foundation.layout.padding
+import androidx.compose.material3.ExperimentalMaterial3Api
+import androidx.compose.material3.PlainTooltip
+import androidx.compose.material3.Text
+import androidx.compose.material3.TooltipBox
+import androidx.compose.material3.rememberTooltipState
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.LaunchedEffect
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.graphics.Color
+import androidx.compose.ui.hapticfeedback.HapticFeedbackType
+import androidx.compose.ui.platform.LocalHapticFeedback
+import androidx.compose.ui.unit.IntOffset
+import androidx.compose.ui.unit.IntRect
+import androidx.compose.ui.unit.IntSize
+import androidx.compose.ui.unit.LayoutDirection
+import androidx.compose.ui.unit.dp
+import androidx.compose.ui.unit.sp
+import androidx.compose.ui.window.PopupPositionProvider
+import app.grapheneos.camera.ktx.toPx
+import kotlin.math.absoluteValue
+
+private const val TAG = "QuickTooltip"
+
+enum class QuickTooltipVerticalDirection {
+ TOP,
+ BOTTOM,
+}
+
+@OptIn(ExperimentalMaterial3Api::class)
+@Composable
+fun QuickTooltip(
+ message: String,
+ tooltipAnchorVerticalSpacing: Float = 16.dp.toPx(),
+ defaultDirection: QuickTooltipVerticalDirection = QuickTooltipVerticalDirection.BOTTOM,
+ content: @Composable () -> Unit,
+) {
+ val hapticFeedback = LocalHapticFeedback.current
+
+ val tooltipState = rememberTooltipState()
+
+ LaunchedEffect(tooltipState.isVisible) {
+ if (tooltipState.isVisible) {
+ hapticFeedback.performHapticFeedback(
+ HapticFeedbackType.LongPress
+ )
+ }
+ }
+
+ TooltipBox (
+ positionProvider = object : PopupPositionProvider {
+ override fun calculatePosition(
+ anchorBounds: IntRect,
+ windowSize: IntSize,
+ layoutDirection: LayoutDirection,
+ popupContentSize: IntSize
+ ): IntOffset {
+
+ val anchorPopWidthDiff = anchorBounds.width - popupContentSize.width
+
+ val xOffset = if (anchorPopWidthDiff >= 0) {
+ anchorBounds.left + anchorPopWidthDiff / 2
+ } else if (
+ (windowSize.width - anchorBounds.right >= anchorPopWidthDiff.absoluteValue / 2)
+ ) {
+ anchorBounds.left - anchorPopWidthDiff.absoluteValue / 2
+ } else {
+ anchorBounds.right - popupContentSize.width
+ }.toInt()
+
+ var yOffset : Float
+
+ if (defaultDirection == QuickTooltipVerticalDirection.TOP) {
+ yOffset = anchorBounds.top - popupContentSize.height - tooltipAnchorVerticalSpacing
+ if (yOffset < 0)
+ yOffset = anchorBounds.bottom + tooltipAnchorVerticalSpacing
+ } else {
+ yOffset = anchorBounds.bottom + tooltipAnchorVerticalSpacing
+ }
+
+ return IntOffset(xOffset, yOffset.toInt())
+ }
+ },
+
+ tooltip = {
+ PlainTooltip {
+ Text(
+ message,
+ fontSize = 14.sp,
+ modifier = Modifier
+ .padding(
+ vertical = 4.dp,
+ horizontal = 2.dp,
+ )
+ )
+ }
+
+ },
+ state = tooltipState,
+ content = content
+ )
+}
\ No newline at end of file
diff --git a/app/src/main/java/app/grapheneos/camera/ui/composable/component/topbar/TopBarActions.kt b/app/src/main/java/app/grapheneos/camera/ui/composable/component/topbar/TopBarActions.kt
new file mode 100644
index 000000000..ae98ea5f2
--- /dev/null
+++ b/app/src/main/java/app/grapheneos/camera/ui/composable/component/topbar/TopBarActions.kt
@@ -0,0 +1,169 @@
+package app.grapheneos.camera.ui.composable.component.topbar
+
+import androidx.compose.foundation.layout.Arrangement
+import androidx.compose.foundation.layout.Box
+
+import androidx.compose.foundation.layout.Row
+import androidx.compose.foundation.layout.fillMaxWidth
+
+import androidx.compose.foundation.layout.widthIn
+import androidx.compose.material.icons.Icons
+import androidx.compose.material.icons.filled.MoreVert
+
+import androidx.compose.material3.DropdownMenu
+import androidx.compose.material3.DropdownMenuItem
+
+import androidx.compose.material3.Icon
+import androidx.compose.material3.IconButton
+import androidx.compose.material3.MaterialTheme
+import androidx.compose.material3.Text
+
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.getValue
+import androidx.compose.runtime.mutableStateOf
+import androidx.compose.runtime.remember
+import androidx.compose.runtime.setValue
+import androidx.compose.ui.Alignment
+
+import androidx.compose.ui.Modifier
+
+import androidx.compose.ui.graphics.vector.ImageVector
+import androidx.compose.ui.layout.onGloballyPositioned
+import androidx.compose.ui.text.font.FontWeight
+
+import androidx.compose.ui.unit.IntSize
+
+import androidx.compose.ui.unit.dp
+import app.grapheneos.camera.ktx.toPx
+import app.grapheneos.camera.ui.composable.component.tooltip.QuickTooltip
+
+data class TopBarAction(
+ val id : Any,
+ val title: String,
+ val icon: ImageVector,
+ val alwaysInMoreOptions : Boolean = false
+)
+
+private const val TAG = "TopBarActions"
+
+// 40.0.dp was taken from an constant internal to the material library
+// IconButtonTokens.StateLayerSize used by IconButton's code internally
+private val SPACE_PER_ICON_BUTTON = 40.dp
+
+@Composable
+fun TopBarActions(
+ actions : List,
+ onActionClicked: (id: Any) -> Unit,
+ modifier: Modifier = Modifier,
+) {
+
+ var size by remember {
+ mutableStateOf(IntSize.Zero)
+ }
+
+ var dropDownMenuExpanded by remember {
+ mutableStateOf(false)
+ }
+
+ Box (
+ modifier = Modifier
+ .then(modifier)
+ .onGloballyPositioned {
+ size = it.size
+ },
+ contentAlignment = Alignment.CenterEnd
+ ) {
+ if (size == IntSize.Zero) return@Box
+
+ val width = size.width
+
+ // Subtract 1 for the more options icon
+ val maxVisibleActions = (width / SPACE_PER_ICON_BUTTON.toPx()).toInt() - 1
+
+ // Ensure there is space to at least have the more options icon
+ assert(maxVisibleActions >= 0) {
+ "Please ensure that TopBarActions gets a width of 40.dp at least"
+ }
+
+ val visibleActions = arrayListOf()
+ val moreOptionsActions = arrayListOf()
+
+ for (action in actions) {
+ if (action.alwaysInMoreOptions || visibleActions.size >= maxVisibleActions) {
+ moreOptionsActions.add(action)
+ } else {
+ visibleActions.add(action)
+ }
+ }
+
+ Row (horizontalArrangement = Arrangement.End) {
+ for (visibleAction in visibleActions) {
+ QuickTooltip(
+ message = visibleAction.title
+ ) {
+ IconButton(onClick = {
+ onActionClicked(visibleAction.id)
+ }) {
+ Icon(visibleAction.icon, visibleAction.title)
+ }
+ }
+
+ }
+
+ Box {
+ if (moreOptionsActions.isNotEmpty()) {
+ QuickTooltip(
+ message = "More Options"
+ ) {
+ IconButton(onClick = {
+ dropDownMenuExpanded = !dropDownMenuExpanded
+ }) {
+ Icon(
+ Icons.Filled.MoreVert,
+ "More Options"
+ )
+ }
+ }
+
+ DropdownMenu(
+ expanded = dropDownMenuExpanded,
+ onDismissRequest = {
+ dropDownMenuExpanded = false
+ },
+ modifier = Modifier
+ .fillMaxWidth(.5f)
+ .widthIn(max = 200.dp)
+ ) {
+ for (moreOptionsAction in moreOptionsActions) {
+ DropdownMenuItem(
+ text = {
+ Text(
+ text = moreOptionsAction.title,
+ style = MaterialTheme.typography.titleMedium.copy(
+ fontWeight = FontWeight.Normal,
+ )
+ )
+ },
+
+ leadingIcon = {
+ Icon(
+ imageVector = moreOptionsAction.icon,
+ contentDescription = moreOptionsAction.title,
+ )
+ },
+
+ onClick = {
+ onActionClicked(moreOptionsAction.id)
+ dropDownMenuExpanded = false
+ },
+ )
+ }
+ }
+ }
+ }
+ }
+
+
+ }
+
+}
\ No newline at end of file
diff --git a/app/src/main/java/app/grapheneos/camera/ui/composable/component/topbar/extendedgallery/ExtendedGalleryTopBar.kt b/app/src/main/java/app/grapheneos/camera/ui/composable/component/topbar/extendedgallery/ExtendedGalleryTopBar.kt
new file mode 100644
index 000000000..1ae923908
--- /dev/null
+++ b/app/src/main/java/app/grapheneos/camera/ui/composable/component/topbar/extendedgallery/ExtendedGalleryTopBar.kt
@@ -0,0 +1,124 @@
+package app.grapheneos.camera.ui.composable.component.topbar.extendedgallery
+
+import androidx.compose.foundation.layout.fillMaxWidth
+
+import androidx.compose.material.icons.Icons
+import androidx.compose.material.icons.automirrored.filled.ArrowBack
+import androidx.compose.material.icons.filled.Clear
+
+import androidx.compose.material3.ExperimentalMaterial3Api
+import androidx.compose.material3.Icon
+import androidx.compose.material3.IconButton
+import androidx.compose.material3.MaterialTheme
+import androidx.compose.material3.Surface
+import androidx.compose.material3.Text
+
+import androidx.compose.material3.TopAppBar
+import androidx.compose.material3.TopAppBarColors
+import androidx.compose.runtime.Composable
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.res.stringResource
+
+import androidx.compose.ui.text.font.FontWeight
+import androidx.compose.ui.text.style.TextOverflow
+import androidx.compose.ui.unit.dp
+
+import app.grapheneos.camera.CapturedItem
+import app.grapheneos.camera.R
+import app.grapheneos.camera.ui.composable.component.topbar.TopBarActions
+
+private const val TAG = "ExtendedGalleryTopBar"
+
+@OptIn(ExperimentalMaterial3Api::class)
+@Composable
+fun ExtendedGalleryTopBar(
+ selectModeEnabled: Boolean,
+ secureMode: Boolean = false,
+ selectedItems: List,
+ onEnableSelectMode: () -> Unit = {},
+ onDisableSelectMode: () -> Unit = {},
+ onAllItemsSelectAction : () -> Unit = {},
+ onDeleteItemsAction: () -> Unit = {},
+ onSharedItemsAction: () -> Unit = {},
+ onDeviceUnlockRequest: () -> Unit = {},
+ onExitAction: () -> Unit = {},
+) {
+ Surface(
+ shadowElevation = 4.dp
+ ) {
+ TopAppBar(
+ title = {
+ if (selectModeEnabled) {
+ Text(
+ stringResource(R.string.selected_items_template, selectedItems.size),
+ style = MaterialTheme.typography.titleLarge.copy(fontWeight = FontWeight.Normal),
+ overflow = TextOverflow.Ellipsis
+ )
+ }
+ },
+
+ colors = TopAppBarColors(
+ containerColor = MaterialTheme.colorScheme.background,
+ scrolledContainerColor = MaterialTheme.colorScheme.background,
+ titleContentColor = MaterialTheme.colorScheme.onBackground,
+ navigationIconContentColor = MaterialTheme.colorScheme.onBackground,
+ actionIconContentColor = MaterialTheme.colorScheme.onBackground,
+ ),
+
+ navigationIcon = {
+ if (selectModeEnabled) {
+ IconButton(onClick = onDisableSelectMode) {
+ Icon(
+ Icons.Default.Clear,
+ contentDescription = null
+ )
+ }
+ } else {
+ IconButton(onClick = onExitAction) {
+ Icon(
+ Icons.AutoMirrored.Filled.ArrowBack,
+ contentDescription = null
+ )
+ }
+ }
+ },
+
+ actions = {
+ TopBarActions(
+ actions = if (selectModeEnabled) {
+ selectionModeActions()
+ } else {
+ defaultActions(secureMode = secureMode)
+ },
+
+ onActionClicked = { action ->
+ when (action) {
+ ExtendedGalleryActions.SELECT_MEDIA_ACTION -> {
+ onEnableSelectMode()
+ }
+
+ ExtendedGalleryActions.SELECT_ALL_ITEMS_ACTION -> {
+ onAllItemsSelectAction()
+ }
+
+ ExtendedGalleryActions.SHARE_SELECTED_ITEMS_ACTION -> {
+ onSharedItemsAction()
+ }
+
+ ExtendedGalleryActions.DELETE_SELECTED_ITEMS_ACTION -> {
+ onDeleteItemsAction()
+ }
+
+ ExtendedGalleryActions.UNLOCK_DEVICE_ACTION -> {
+ onDeviceUnlockRequest()
+ }
+ }
+ },
+
+ modifier = Modifier
+ .fillMaxWidth(0.4f)
+ )
+ },
+ )
+ }
+}
\ No newline at end of file
diff --git a/app/src/main/java/app/grapheneos/camera/ui/composable/component/topbar/extendedgallery/ExtendedGalleryTopBarActions.kt b/app/src/main/java/app/grapheneos/camera/ui/composable/component/topbar/extendedgallery/ExtendedGalleryTopBarActions.kt
new file mode 100644
index 000000000..7ec3f4bf7
--- /dev/null
+++ b/app/src/main/java/app/grapheneos/camera/ui/composable/component/topbar/extendedgallery/ExtendedGalleryTopBarActions.kt
@@ -0,0 +1,75 @@
+package app.grapheneos.camera.ui.composable.component.topbar.extendedgallery
+
+import androidx.compose.material.icons.Icons
+import androidx.compose.material.icons.filled.Check
+import androidx.compose.material.icons.filled.Delete
+import androidx.compose.material.icons.filled.LockOpen
+import androidx.compose.material.icons.filled.SelectAll
+import androidx.compose.material.icons.filled.Share
+import androidx.compose.runtime.Composable
+import androidx.compose.ui.platform.LocalContext
+import androidx.compose.ui.res.stringResource
+import androidx.media3.common.util.EGLSurfaceTexture.SecureMode
+import app.grapheneos.camera.R
+import app.grapheneos.camera.ktx.isDeviceLocked
+import app.grapheneos.camera.ui.composable.component.topbar.TopBarAction
+
+private const val TAG = "ExtendedGalleryActions"
+
+enum class ExtendedGalleryActions {
+ SELECT_MEDIA_ACTION,
+ SHARE_SELECTED_ITEMS_ACTION,
+ DELETE_SELECTED_ITEMS_ACTION,
+ SELECT_ALL_ITEMS_ACTION,
+ UNLOCK_DEVICE_ACTION,
+}
+
+@Composable
+fun defaultActions(
+ secureMode: Boolean = false
+) : List {
+
+ return buildList {
+ if (secureMode) {
+ add(
+ TopBarAction(
+ id = ExtendedGalleryActions.UNLOCK_DEVICE_ACTION,
+ title = stringResource(R.string.unlock_device),
+ icon = Icons.Default.LockOpen
+ )
+ )
+ }
+
+ add(
+ TopBarAction(
+ id = ExtendedGalleryActions.SELECT_MEDIA_ACTION,
+ title = stringResource(R.string.select_media),
+ icon = Icons.Default.Check,
+ alwaysInMoreOptions = true
+ )
+ )
+ }
+
+
+}
+
+@Composable
+fun selectionModeActions() : List {
+ return listOf(
+ TopBarAction(
+ id = ExtendedGalleryActions.SELECT_ALL_ITEMS_ACTION,
+ title = stringResource(R.string.select_media),
+ icon = Icons.Default.SelectAll,
+ ),
+ TopBarAction(
+ id = ExtendedGalleryActions.DELETE_SELECTED_ITEMS_ACTION,
+ title = stringResource(R.string.delete_items),
+ icon = Icons.Default.Delete,
+ ),
+ TopBarAction(
+ id = ExtendedGalleryActions.SHARE_SELECTED_ITEMS_ACTION,
+ title = stringResource(R.string.share_items),
+ icon = Icons.Default.Share,
+ ),
+ )
+}
diff --git a/app/src/main/java/app/grapheneos/camera/ui/composable/component/topbar/gallery/GalleryTopBar.kt b/app/src/main/java/app/grapheneos/camera/ui/composable/component/topbar/gallery/GalleryTopBar.kt
new file mode 100644
index 000000000..b3c48e026
--- /dev/null
+++ b/app/src/main/java/app/grapheneos/camera/ui/composable/component/topbar/gallery/GalleryTopBar.kt
@@ -0,0 +1,107 @@
+package app.grapheneos.camera.ui.composable.component.topbar.gallery
+
+import androidx.compose.animation.AnimatedVisibility
+import androidx.compose.animation.core.EaseIn
+import androidx.compose.animation.core.tween
+import androidx.compose.animation.slideInVertically
+import androidx.compose.animation.slideOutVertically
+import androidx.compose.foundation.layout.fillMaxWidth
+
+import androidx.compose.material.icons.Icons
+import androidx.compose.material.icons.automirrored.filled.ArrowBack
+
+import androidx.compose.material3.ExperimentalMaterial3Api
+import androidx.compose.material3.Icon
+import androidx.compose.material3.IconButton
+import androidx.compose.material3.MaterialTheme
+
+import androidx.compose.material3.TopAppBar
+import androidx.compose.material3.TopAppBarColors
+import androidx.compose.runtime.Composable
+
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.graphics.Color
+import androidx.compose.ui.res.stringResource
+import app.grapheneos.camera.R
+
+import app.grapheneos.camera.ui.composable.component.topbar.TopBarActions
+import app.grapheneos.camera.ui.composable.theme.AppColor
+
+private const val TAG = "GalleryTopBar"
+
+@OptIn(ExperimentalMaterial3Api::class)
+@Composable
+fun GalleryTopBar(
+ visible: Boolean,
+ onCloseAction: () -> Unit,
+ onEditAction: (chooseApp: Boolean, modifyOriginal: Boolean) -> Unit,
+ onDeleteAction: () -> Unit,
+ onInfoAction: () -> Unit,
+ onShareAction: () -> Unit
+) {
+
+ AnimatedVisibility(
+ visible = visible,
+
+ enter = slideInVertically(
+ initialOffsetY = { height -> -height },
+ animationSpec = tween(durationMillis = 300, easing = EaseIn),
+ ),
+ exit = slideOutVertically(
+ targetOffsetY = { height -> -height },
+ animationSpec = tween(durationMillis = 300, easing = EaseIn),
+ ),
+ ) {
+ TopAppBar(
+ title = {},
+
+ colors = TopAppBarColors(
+ containerColor = AppColor.AppBarColor,
+ scrolledContainerColor = AppColor.AppBarColor,
+ titleContentColor = MaterialTheme.colorScheme.onPrimary,
+ navigationIconContentColor = Color.White,
+ actionIconContentColor = Color.White,
+ ),
+
+ navigationIcon = {
+ IconButton(onClick = onCloseAction) {
+ Icon(
+ imageVector = Icons.AutoMirrored.Default.ArrowBack,
+ contentDescription = stringResource(R.string.back)
+ )
+ }
+ },
+
+ actions = {
+ TopBarActions(
+ actions = galleryTopBarActions(),
+ onActionClicked = { action ->
+ when (action) {
+ GalleryActions.EDIT_MEDIA -> {
+ onEditAction(false, false)
+ }
+ GalleryActions.DELETE_MEDIA -> {
+ onDeleteAction()
+ }
+ GalleryActions.SHOW_MEDIA_INFO -> {
+ onInfoAction()
+ }
+ GalleryActions.SHARE_MEDIA -> {
+ onShareAction()
+ }
+ GalleryActions.EDIT_MEDIA_WITH_APP -> {
+ onEditAction(true, false)
+ }
+ GalleryActions.EDIT_MEDIA_IN_PLACE -> {
+ onEditAction(true, true)
+ }
+ }
+ },
+ // To avoid overlapping leading back arrow and some spacing
+ modifier = Modifier
+ .fillMaxWidth(0.7f)
+ )
+ },
+ )
+ }
+}
\ No newline at end of file
diff --git a/app/src/main/java/app/grapheneos/camera/ui/composable/component/topbar/gallery/GalleryTopBarActions.kt b/app/src/main/java/app/grapheneos/camera/ui/composable/component/topbar/gallery/GalleryTopBarActions.kt
new file mode 100644
index 000000000..da908cd7b
--- /dev/null
+++ b/app/src/main/java/app/grapheneos/camera/ui/composable/component/topbar/gallery/GalleryTopBarActions.kt
@@ -0,0 +1,60 @@
+package app.grapheneos.camera.ui.composable.component.topbar.gallery
+
+import androidx.compose.material.icons.Icons
+import androidx.compose.material.icons.filled.Delete
+import androidx.compose.material.icons.filled.Edit
+import androidx.compose.material.icons.filled.Info
+import androidx.compose.material.icons.filled.Share
+import androidx.compose.runtime.Composable
+import androidx.compose.ui.res.stringResource
+import app.grapheneos.camera.R
+import app.grapheneos.camera.ui.composable.component.topbar.TopBarAction
+
+private const val TAG = "GalleryActions"
+
+enum class GalleryActions {
+ EDIT_MEDIA,
+ DELETE_MEDIA,
+ SHOW_MEDIA_INFO,
+ SHARE_MEDIA,
+ EDIT_MEDIA_WITH_APP,
+ EDIT_MEDIA_IN_PLACE,
+}
+
+@Composable
+fun galleryTopBarActions() : List {
+ return listOf(
+ TopBarAction(
+ id = GalleryActions.EDIT_MEDIA,
+ title = stringResource(R.string.edit_a_copy),
+ icon = Icons.Filled.Edit
+ ),
+ TopBarAction(
+ id = GalleryActions.DELETE_MEDIA,
+ title = stringResource(R.string.delete_media),
+ icon = Icons.Filled.Delete
+ ),
+ TopBarAction(
+ id = GalleryActions.SHOW_MEDIA_INFO,
+ title = stringResource(R.string.show_media_info),
+ icon = Icons.Filled.Info
+ ),
+ TopBarAction(
+ id = GalleryActions.SHARE_MEDIA,
+ title = stringResource(R.string.share_media),
+ icon = Icons.Filled.Share
+ ),
+ TopBarAction(
+ id = GalleryActions.EDIT_MEDIA_WITH_APP,
+ title = stringResource(R.string.edit_a_copy_with),
+ icon = Icons.Filled.Edit,
+ alwaysInMoreOptions = true
+ ),
+ TopBarAction(
+ id = GalleryActions.EDIT_MEDIA_IN_PLACE,
+ title = stringResource(R.string.edit_original_image),
+ icon = Icons.Filled.Edit,
+ alwaysInMoreOptions = true
+ ),
+ )
+}
\ No newline at end of file
diff --git a/app/src/main/java/app/grapheneos/camera/ui/composable/model/MediaItemDetails.kt b/app/src/main/java/app/grapheneos/camera/ui/composable/model/MediaItemDetails.kt
new file mode 100644
index 000000000..9d3e36154
--- /dev/null
+++ b/app/src/main/java/app/grapheneos/camera/ui/composable/model/MediaItemDetails.kt
@@ -0,0 +1,155 @@
+package app.grapheneos.camera.ui.composable.model
+
+import android.annotation.SuppressLint
+import android.content.Context
+import android.media.MediaMetadataRetriever
+import android.net.Uri
+import android.provider.MediaStore.MediaColumns
+import android.provider.OpenableColumns
+import androidx.compose.runtime.MutableState
+import androidx.compose.runtime.mutableStateOf
+import androidx.compose.runtime.saveable.Saver
+import androidxc.exifinterface.media.ExifInterface
+import app.grapheneos.camera.CapturedItem
+import app.grapheneos.camera.ITEM_TYPE_VIDEO
+import app.grapheneos.camera.R
+import app.grapheneos.camera.util.storageLocationToUiString
+import java.text.SimpleDateFormat
+import java.util.Date
+import java.util.Locale
+import java.util.TimeZone
+
+data class MediaItemDetails(
+ val filePath: String?,
+ val fileName: String?,
+ val size: String?,
+ var dateAdded: String?,
+ var dateModified: String?,
+) {
+
+ companion object {
+ val Saver = Saver, Array>(
+ save = {
+ val item = it.value ?: return@Saver arrayOf()
+ arrayOf(item.filePath, item.fileName, item.size, item.dateAdded, item.dateModified)
+ },
+ restore = {
+ if (it.isEmpty()) return@Saver null
+ mutableStateOf(MediaItemDetails(it[0], it[1], it[2], it[3], it[4]))
+ },
+ )
+
+ fun forCapturedItem(context: Context, item: CapturedItem) : MediaItemDetails {
+
+ var relativePath: String? = null
+ var fileName: String? = null
+ var size: String? = null
+
+ var dateAdded: String? = null
+ var dateModified: String? = null
+
+ val projection = arrayOf(MediaColumns.RELATIVE_PATH, OpenableColumns.DISPLAY_NAME, OpenableColumns.SIZE)
+
+ context.contentResolver.query(item.uri, projection, null,null)?.use {
+ if (it.moveToFirst()) {
+ fileName = it.getString(1)
+
+ if (fileName == null) {
+ throw Exception("File name not found for file")
+ }
+
+ relativePath = getRelativePath(context, item.uri, it.getString(0), fileName!!)
+ size = String.format(Locale.ROOT, "%.2f MB", (it.getLong(2) / (1000f * 1000f)))
+ }
+ }
+
+ if (item.type == ITEM_TYPE_VIDEO) {
+ MediaMetadataRetriever().use {
+ it.setDataSource(context, item.uri)
+ dateAdded = convertTimeForVideo(it.extractMetadata(MediaMetadataRetriever.METADATA_KEY_DATE)!!)
+ dateModified = dateAdded
+
+ }
+ } else {
+ context.contentResolver.openInputStream(item.uri)?.use { stream ->
+ val eInterface = ExifInterface(stream)
+
+ val offset = eInterface.getAttribute(ExifInterface.TAG_OFFSET_TIME)
+
+ if (eInterface.hasAttribute(ExifInterface.TAG_DATETIME_ORIGINAL)) {
+ dateAdded = convertTimeForPhoto(
+ eInterface.getAttribute(ExifInterface.TAG_DATETIME_ORIGINAL)!!,
+ offset
+ )
+ }
+
+ if (eInterface.hasAttribute(ExifInterface.TAG_DATETIME)) {
+ dateModified = convertTimeForPhoto(
+ eInterface.getAttribute(ExifInterface.TAG_DATETIME)!!,
+ offset
+ )
+ }
+ }
+ }
+
+ return MediaItemDetails(
+ relativePath,
+ fileName,
+ size,
+ dateAdded,
+ dateModified,
+ )
+ }
+
+ @SuppressLint("SimpleDateFormat")
+ fun convertTime(time: Long, showTimeZone: Boolean = true): String {
+ val date = Date(time)
+ val format = SimpleDateFormat(
+ if (showTimeZone) {
+ "yyyy-MM-dd HH:mm:ss z"
+ } else {
+ "yyyy-MM-dd HH:mm:ss"
+ }
+ )
+ format.timeZone = TimeZone.getDefault()
+ return format.format(date)
+ }
+
+ private fun convertTimeForVideo(time: String): String {
+ val dateFormat = SimpleDateFormat("yyyyMMdd'T'HHmmss.SSS'Z'", Locale.US)
+ dateFormat.timeZone = TimeZone.getTimeZone("UTC")
+ val parsedDate = dateFormat.parse(time)
+ return convertTime(parsedDate?.time ?: 0)
+ }
+
+ private fun convertTimeForPhoto(time: String, offset: String? = null): String {
+ val timestamp = if (offset != null) {
+ "$time $offset"
+ } else {
+ time
+ }
+
+ val dateFormat = SimpleDateFormat(
+ if (offset == null) {
+ "yyyy:MM:dd HH:mm:ss"
+ } else {
+ "yyyy:MM:dd HH:mm:ss Z"
+ }, Locale.US
+ )
+
+ if (offset == null) {
+ dateFormat.timeZone = TimeZone.getDefault()
+ }
+ val parsedDate = dateFormat.parse(timestamp)
+ return convertTime(parsedDate?.time ?: 0, offset != null)
+ }
+
+ private fun getRelativePath(ctx: Context, uri: Uri, path: String?, fileName: String): String {
+ if (path == null) {
+ return storageLocationToUiString(ctx, uri.toString())
+ }
+
+ return "${ctx.getString(R.string.main_storage)}/$path$fileName"
+ }
+ }
+}
\ No newline at end of file
diff --git a/app/src/main/java/app/grapheneos/camera/ui/composable/model/SnackBarMessage.kt b/app/src/main/java/app/grapheneos/camera/ui/composable/model/SnackBarMessage.kt
new file mode 100644
index 000000000..3653f9906
--- /dev/null
+++ b/app/src/main/java/app/grapheneos/camera/ui/composable/model/SnackBarMessage.kt
@@ -0,0 +1,10 @@
+package app.grapheneos.camera.ui.composable.model
+
+import java.util.UUID
+
+data class SnackBarMessage(
+ val message: String,
+ val id: UUID = UUID.randomUUID()
+)
+
+val NoDataSnackBarMessage = SnackBarMessage("")
diff --git a/app/src/main/java/app/grapheneos/camera/ui/composable/model/VideoUri.kt b/app/src/main/java/app/grapheneos/camera/ui/composable/model/VideoUri.kt
new file mode 100644
index 000000000..f4fc93b06
--- /dev/null
+++ b/app/src/main/java/app/grapheneos/camera/ui/composable/model/VideoUri.kt
@@ -0,0 +1,33 @@
+package app.grapheneos.camera.ui.composable.model
+
+import android.net.Uri
+import android.os.Parcel
+import android.os.Parcelable
+
+import app.grapheneos.camera.ui.composable.screen.serializer.VideoUriSerializer
+
+import kotlinx.serialization.Serializable
+
+@Serializable(with = VideoUriSerializer::class)
+data class VideoUri(
+ val uri: Uri
+) : Parcelable {
+
+ override fun writeToParcel(dest: Parcel, flags: Int) {
+ uri.writeToParcel(dest, flags)
+ }
+
+ override fun describeContents(): Int = 0
+
+ companion object {
+ @JvmField
+ val CREATOR = object : Parcelable.Creator {
+ override fun createFromParcel(source: Parcel): VideoUri {
+ val uri = Uri.CREATOR.createFromParcel(source)
+ return VideoUri(uri)
+ }
+
+ override fun newArray(size: Int) = arrayOfNulls(size)
+ }
+ }
+}
\ No newline at end of file
diff --git a/app/src/main/java/app/grapheneos/camera/ui/composable/screen/navtype/CapturedItemListNavType.kt b/app/src/main/java/app/grapheneos/camera/ui/composable/screen/navtype/CapturedItemListNavType.kt
new file mode 100644
index 000000000..b59a1ad9f
--- /dev/null
+++ b/app/src/main/java/app/grapheneos/camera/ui/composable/screen/navtype/CapturedItemListNavType.kt
@@ -0,0 +1,31 @@
+package app.grapheneos.camera.ui.composable.screen.navtype
+
+import android.net.Uri
+import android.os.Bundle
+import androidx.core.os.BundleCompat
+import androidx.navigation.NavType
+import app.grapheneos.camera.CapturedItem
+import app.grapheneos.camera.ui.composable.screen.serializer.CapturedItemSerializer
+import kotlinx.serialization.json.Json
+
+object CapturedItemListNavType : NavType?>(isNullableAllowed = true) {
+ override fun get(bundle: Bundle, key: String): List? {
+ return BundleCompat.getParcelableArrayList(bundle, key, CapturedItem::class.java)
+ }
+
+ override fun parseValue(value: String): List? {
+ if (value.isEmpty()) return null
+ return Json.decodeFromString(CapturedItemSerializer.ListSerializer, Uri.decode(value))
+ }
+
+ override fun serializeAsValue(value: List?): String {
+ if (value == null) return ""
+ return Uri.encode(Json.encodeToString(CapturedItemSerializer.ListSerializer, value))
+ }
+
+ override fun put(bundle: Bundle, key: String, value: List?) {
+ if (value != null)
+ bundle.putParcelableArrayList(key, ArrayList(value))
+ }
+
+}
\ No newline at end of file
diff --git a/app/src/main/java/app/grapheneos/camera/ui/composable/screen/navtype/CapturedItemNavType.kt b/app/src/main/java/app/grapheneos/camera/ui/composable/screen/navtype/CapturedItemNavType.kt
new file mode 100644
index 000000000..7603461a4
--- /dev/null
+++ b/app/src/main/java/app/grapheneos/camera/ui/composable/screen/navtype/CapturedItemNavType.kt
@@ -0,0 +1,31 @@
+package app.grapheneos.camera.ui.composable.screen.navtype
+
+import android.net.Uri
+import android.os.Bundle
+import androidx.core.os.BundleCompat
+
+import androidx.navigation.NavType
+import app.grapheneos.camera.CapturedItem
+import app.grapheneos.camera.ui.composable.screen.serializer.CapturedItemSerializer
+import kotlinx.serialization.json.Json
+
+object CapturedItemNavType : NavType(isNullableAllowed = true) {
+ override fun get(bundle: Bundle, key: String): CapturedItem? {
+ val item = BundleCompat.getParcelable(bundle, key, CapturedItem::class.java)
+ return item
+ }
+
+ override fun parseValue(value: String): CapturedItem? {
+ if (value.isEmpty()) return null
+ return Json.decodeFromString(CapturedItemSerializer, Uri.decode(value))
+ }
+
+ override fun serializeAsValue(value: CapturedItem?): String {
+ if (value == null) return ""
+ return Uri.encode(Json.encodeToString(CapturedItemSerializer, value))
+ }
+
+ override fun put(bundle: Bundle, key: String, value: CapturedItem?) {
+ bundle.putParcelable(key, value)
+ }
+}
\ No newline at end of file
diff --git a/app/src/main/java/app/grapheneos/camera/ui/composable/screen/navtype/VideoUriNavType.kt b/app/src/main/java/app/grapheneos/camera/ui/composable/screen/navtype/VideoUriNavType.kt
new file mode 100644
index 000000000..5cde24f81
--- /dev/null
+++ b/app/src/main/java/app/grapheneos/camera/ui/composable/screen/navtype/VideoUriNavType.kt
@@ -0,0 +1,28 @@
+package app.grapheneos.camera.ui.composable.screen.navtype
+
+import android.net.Uri
+import android.os.Bundle
+import androidx.core.os.BundleCompat
+import androidx.navigation.NavType
+import app.grapheneos.camera.ui.composable.model.VideoUri
+import app.grapheneos.camera.ui.composable.screen.serializer.VideoUriSerializer
+import kotlinx.serialization.json.Json
+
+object VideoUriNavType : NavType(false) {
+
+ override fun get(bundle: Bundle, key: String): VideoUri? {
+ return BundleCompat.getParcelable(bundle, key, VideoUri::class.java)
+ }
+
+ override fun put(bundle: Bundle, key: String, value: VideoUri) {
+ bundle.putParcelable(key, value)
+ }
+
+ override fun parseValue(value: String): VideoUri {
+ return Json.decodeFromString(VideoUriSerializer, Uri.decode(value))
+ }
+
+ override fun serializeAsValue(value: VideoUri): String {
+ return Uri.encode(Json.encodeToString(VideoUriSerializer, value))
+ }
+}
\ No newline at end of file
diff --git a/app/src/main/java/app/grapheneos/camera/ui/composable/screen/routes/ExtendedGalleryRoute.kt b/app/src/main/java/app/grapheneos/camera/ui/composable/screen/routes/ExtendedGalleryRoute.kt
new file mode 100644
index 000000000..8a08e272e
--- /dev/null
+++ b/app/src/main/java/app/grapheneos/camera/ui/composable/screen/routes/ExtendedGalleryRoute.kt
@@ -0,0 +1,22 @@
+package app.grapheneos.camera.ui.composable.screen.routes
+
+import kotlinx.serialization.Serializable
+
+@Serializable
+object ExtendedGalleryRoute
+
+//import app.grapheneos.camera.CapturedItem
+//import kotlin.reflect.typeOf
+//import kotlinx.serialization.Serializable
+//import app.grapheneos.camera.ui.composable.screen.navtype.CapturedItemNavType
+
+//@Serializable
+//data class ExtendedGalleryRoute(
+// val focusItem: CapturedItem? = null
+//) {
+// companion object {
+// val typeMap = mapOf(
+// typeOf?>() to CapturedItemNavType,
+// )
+// }
+//}
\ No newline at end of file
diff --git a/app/src/main/java/app/grapheneos/camera/ui/composable/screen/routes/GalleryRoute.kt b/app/src/main/java/app/grapheneos/camera/ui/composable/screen/routes/GalleryRoute.kt
new file mode 100644
index 000000000..7758fd37b
--- /dev/null
+++ b/app/src/main/java/app/grapheneos/camera/ui/composable/screen/routes/GalleryRoute.kt
@@ -0,0 +1,20 @@
+package app.grapheneos.camera.ui.composable.screen.routes
+
+import app.grapheneos.camera.CapturedItem
+import app.grapheneos.camera.ui.composable.screen.navtype.CapturedItemNavType
+import kotlin.reflect.typeOf
+import kotlinx.serialization.Serializable
+
+//@Serializable
+//object GalleryRoute
+
+@Serializable
+data class GalleryRoute(
+ val focusItem: CapturedItem? = null
+) {
+ companion object {
+ val typeMap = mapOf(
+ typeOf() to CapturedItemNavType,
+ )
+ }
+}
\ No newline at end of file
diff --git a/app/src/main/java/app/grapheneos/camera/ui/composable/screen/routes/VideoPlayerRoute.kt b/app/src/main/java/app/grapheneos/camera/ui/composable/screen/routes/VideoPlayerRoute.kt
new file mode 100644
index 000000000..9c18a66a6
--- /dev/null
+++ b/app/src/main/java/app/grapheneos/camera/ui/composable/screen/routes/VideoPlayerRoute.kt
@@ -0,0 +1,19 @@
+package app.grapheneos.camera.ui.composable.screen.routes
+
+import app.grapheneos.camera.ui.composable.model.VideoUri
+import app.grapheneos.camera.ui.composable.screen.navtype.VideoUriNavType
+
+import kotlin.reflect.typeOf
+import kotlinx.serialization.Serializable
+
+@Serializable
+data class VideoPlayerRoute(
+ val videoUri: VideoUri,
+) {
+ companion object {
+ val typeMap = mapOf(
+ typeOf() to VideoUriNavType,
+ )
+ }
+}
+
diff --git a/app/src/main/java/app/grapheneos/camera/ui/composable/screen/serializer/CapturedItemSerializer.kt b/app/src/main/java/app/grapheneos/camera/ui/composable/screen/serializer/CapturedItemSerializer.kt
new file mode 100644
index 000000000..69e48ec0f
--- /dev/null
+++ b/app/src/main/java/app/grapheneos/camera/ui/composable/screen/serializer/CapturedItemSerializer.kt
@@ -0,0 +1,66 @@
+package app.grapheneos.camera.ui.composable.screen.serializer
+
+import android.net.Uri
+import app.grapheneos.camera.CapturedItem
+import kotlinx.serialization.KSerializer
+import kotlinx.serialization.builtins.ListSerializer
+import kotlinx.serialization.descriptors.SerialDescriptor
+import kotlinx.serialization.descriptors.buildClassSerialDescriptor
+import kotlinx.serialization.descriptors.element
+import kotlinx.serialization.encoding.CompositeDecoder
+import kotlinx.serialization.encoding.Decoder
+import kotlinx.serialization.encoding.Encoder
+import kotlinx.serialization.encoding.decodeStructure
+import kotlinx.serialization.encoding.encodeStructure
+
+object CapturedItemSerializer : KSerializer {
+
+ val ListSerializer = ListSerializer(CapturedItemSerializer)
+
+ private const val ITEM_TYPE_ELEMENT_NAME = "ITEM_TYPE"
+ private const val DATE_STRING_ELEMENT_NAME = "DATE_STRING"
+ private const val URI_ELEMENT_NAME = "URI"
+
+ override val descriptor: SerialDescriptor
+ get() = buildClassSerialDescriptor(
+ javaClass.name
+ ) {
+ element(ITEM_TYPE_ELEMENT_NAME)
+ element(DATE_STRING_ELEMENT_NAME)
+ element(URI_ELEMENT_NAME, UriSerializer.descriptor)
+ }
+
+ override fun deserialize(decoder: Decoder): CapturedItem {
+ return decoder.decodeStructure(
+ descriptor
+ ) {
+ var itemType = 0
+ var dateString = ""
+ var uri = Uri.EMPTY
+
+ while(true) {
+ when (val index = decodeElementIndex(descriptor)) {
+ 0 -> itemType = decodeIntElement(descriptor, 0)
+ 1 -> dateString = decodeStringElement(descriptor, 1)
+ 2 -> uri = decodeSerializableElement(descriptor, 2, UriSerializer)
+ CompositeDecoder.DECODE_DONE -> break
+ else -> error("Unexpected index: $index")
+ }
+ }
+
+ CapturedItem(
+ type = itemType,
+ dateString = dateString,
+ uri = uri
+ )
+ }
+ }
+
+ override fun serialize(encoder: Encoder, value: CapturedItem) {
+ encoder.encodeStructure(descriptor) {
+ encodeIntElement(descriptor, 0, value.type)
+ encodeStringElement(descriptor, 1, value.dateString)
+ encodeSerializableElement(descriptor, 2, UriSerializer, value.uri)
+ }
+ }
+}
\ No newline at end of file
diff --git a/app/src/main/java/app/grapheneos/camera/ui/composable/screen/serializer/UriSerializer.kt b/app/src/main/java/app/grapheneos/camera/ui/composable/screen/serializer/UriSerializer.kt
new file mode 100644
index 000000000..606f7b643
--- /dev/null
+++ b/app/src/main/java/app/grapheneos/camera/ui/composable/screen/serializer/UriSerializer.kt
@@ -0,0 +1,95 @@
+package app.grapheneos.camera.ui.composable.screen.serializer
+
+import android.net.Uri
+
+import kotlinx.serialization.KSerializer
+import kotlinx.serialization.descriptors.SerialDescriptor
+import kotlinx.serialization.descriptors.buildClassSerialDescriptor
+import kotlinx.serialization.descriptors.element
+import kotlinx.serialization.encoding.CompositeDecoder
+import kotlinx.serialization.encoding.Decoder
+import kotlinx.serialization.encoding.Encoder
+import kotlinx.serialization.encoding.decodeStructure
+import kotlinx.serialization.encoding.encodeStructure
+
+object UriSerializer : KSerializer {
+ override val descriptor: SerialDescriptor
+ get() =
+ buildClassSerialDescriptor(javaClass.name) {
+ element("scheme")
+ element("host")
+ element("port")
+ element("path")
+ element("query")
+ element("fragment")
+ }
+
+ override fun deserialize(decoder: Decoder): Uri {
+ var scheme: String? = null
+ var host: String? = null
+ var port = -1
+ var path: String? = null
+ var query: String? = null
+ var fragment: String? = null
+
+ decoder.decodeStructure(
+ descriptor
+ ) {
+ while (true) {
+ when (val index = decodeElementIndex(descriptor)) {
+ 0 -> scheme = decodeStringElement(descriptor, 0)
+ 1 -> host = decodeStringElement(descriptor, 1)
+ 2 -> port = decodeIntElement(descriptor, 2)
+ 3 -> path = decodeStringElement(descriptor, 3)
+ 4 -> query = decodeStringElement(descriptor, 4)
+ 5 -> fragment = decodeStringElement(descriptor, 5)
+ CompositeDecoder.DECODE_DONE -> break
+ else -> error("Unexpected error at $index")
+ }
+ }
+ }
+
+ val uri = Uri.Builder()
+ .apply {
+ if (scheme != null) scheme(scheme)
+ if (host != null) {
+ if (port != -1) authority("$host:$port")
+ else authority(host)
+ }
+ if (path != null) path(path)
+ if (query != null) query(query)
+ if (fragment != null) fragment(fragment)
+ }
+ .build()
+
+ return uri
+ }
+
+ override fun serialize(encoder: Encoder, value: Uri) {
+ encoder.encodeStructure(descriptor) {
+ value.scheme?.let {
+ encodeStringElement(descriptor, 0, it)
+ }
+
+ value.host?.let {
+ encodeStringElement(descriptor, 1, it)
+ }
+
+ if (value.port != -1) {
+ encodeIntElement(descriptor, 2, value.port)
+ }
+
+ value.path?.let {
+ encodeStringElement(descriptor, 3, it)
+ }
+
+ value.query?.let {
+ encodeStringElement(descriptor, 4, it)
+ }
+
+ value.fragment?.let {
+ encodeStringElement(descriptor,5, it)
+ }
+ }
+ }
+}
\ No newline at end of file
diff --git a/app/src/main/java/app/grapheneos/camera/ui/composable/screen/serializer/VideoPlayerRouteSerializer.kt b/app/src/main/java/app/grapheneos/camera/ui/composable/screen/serializer/VideoPlayerRouteSerializer.kt
new file mode 100644
index 000000000..32e96b5a0
--- /dev/null
+++ b/app/src/main/java/app/grapheneos/camera/ui/composable/screen/serializer/VideoPlayerRouteSerializer.kt
@@ -0,0 +1,36 @@
+package app.grapheneos.camera.ui.composable.screen.serializer
+
+import app.grapheneos.camera.ui.composable.model.VideoUri
+import kotlinx.serialization.KSerializer
+import kotlinx.serialization.descriptors.SerialDescriptor
+import kotlinx.serialization.descriptors.buildClassSerialDescriptor
+import kotlinx.serialization.encoding.CompositeDecoder
+import kotlinx.serialization.encoding.Decoder
+import kotlinx.serialization.encoding.Encoder
+import kotlinx.serialization.encoding.decodeStructure
+import kotlinx.serialization.encoding.encodeStructure
+
+object VideoUriSerializer : KSerializer {
+
+ private const val VIDEO_URI_ELEMENT_NAME = "VIDEO_URI"
+
+ override val descriptor: SerialDescriptor
+ get() = buildClassSerialDescriptor(javaClass.name) {
+ element(VIDEO_URI_ELEMENT_NAME, UriSerializer.descriptor)
+ }
+
+ override fun deserialize(decoder: Decoder): VideoUri {
+ return decoder.decodeStructure(descriptor) {
+ check(decodeElementIndex(descriptor) == 0)
+ val videoUri = decodeSerializableElement(descriptor, 0, UriSerializer)
+ check(decodeElementIndex(descriptor) == CompositeDecoder.DECODE_DONE)
+ VideoUri(videoUri)
+ }
+ }
+
+ override fun serialize(encoder: Encoder, value: VideoUri) {
+ encoder.encodeStructure(descriptor) {
+ encodeSerializableElement(descriptor, 0, UriSerializer, value.uri)
+ }
+ }
+}
\ No newline at end of file
diff --git a/app/src/main/java/app/grapheneos/camera/ui/composable/screen/ui/ExtendedGalleryScreen.kt b/app/src/main/java/app/grapheneos/camera/ui/composable/screen/ui/ExtendedGalleryScreen.kt
new file mode 100644
index 000000000..f641da1a7
--- /dev/null
+++ b/app/src/main/java/app/grapheneos/camera/ui/composable/screen/ui/ExtendedGalleryScreen.kt
@@ -0,0 +1,252 @@
+package app.grapheneos.camera.ui.composable.screen.ui
+
+import androidx.activity.compose.BackHandler
+import androidx.compose.foundation.background
+import androidx.compose.foundation.layout.Arrangement
+import androidx.compose.foundation.layout.Box
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.Row
+import androidx.compose.foundation.layout.aspectRatio
+import androidx.compose.foundation.layout.fillMaxSize
+
+import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.layout.wrapContentHeight
+import androidx.compose.foundation.lazy.grid.GridCells
+
+import androidx.compose.foundation.lazy.grid.LazyVerticalGrid
+import androidx.compose.foundation.lazy.grid.items
+
+import androidx.compose.material.icons.Icons
+import androidx.compose.material.icons.filled.Check
+import androidx.compose.material.icons.filled.CheckCircle
+import androidx.compose.material.icons.outlined.CheckCircle
+import androidx.compose.material.icons.outlined.Circle
+
+import androidx.compose.material3.Icon
+import androidx.compose.material3.IconButton
+import androidx.compose.material3.MaterialTheme
+import androidx.compose.material3.Scaffold
+import androidx.compose.material3.SnackbarHost
+import androidx.compose.material3.SnackbarHostState
+import androidx.compose.material3.Text
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.DisposableEffect
+import androidx.compose.runtime.remember
+
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.graphics.Color
+import androidx.compose.ui.platform.LocalContext
+import androidx.compose.ui.res.stringResource
+import androidx.compose.ui.text.style.TextAlign
+import androidx.compose.ui.unit.dp
+import androidx.lifecycle.viewmodel.compose.viewModel
+
+import app.grapheneos.camera.CapturedItem
+import app.grapheneos.camera.R
+import app.grapheneos.camera.ktx.header
+import app.grapheneos.camera.ktx.requestDeviceUnlock
+import app.grapheneos.camera.ui.composable.component.SnackBarMessageHandler
+import app.grapheneos.camera.ui.composable.component.dialog.MultipleFileDeletionDialog
+import app.grapheneos.camera.ui.composable.component.mediapreview.SQUARE_MEDIA_PREVIEW_SIZE
+import app.grapheneos.camera.ui.composable.component.mediapreview.SquareMediaPreview
+import app.grapheneos.camera.ui.composable.component.topbar.extendedgallery.ExtendedGalleryTopBar
+
+import app.grapheneos.camera.ui.composable.screen.viewmodel.ExtendedGalleryViewModel
+import app.grapheneos.camera.util.getHumanReadableDate
+
+@Composable
+fun ExtendedGalleryScreen(
+ showMediaItemAction: (CapturedItem) -> Unit = {},
+ onExitAction: () -> Unit = {}
+) {
+ val context = LocalContext.current
+
+ val viewModel = viewModel {
+ ExtendedGalleryViewModel(context)
+ }
+
+ val snackBarHostState = remember {
+ SnackbarHostState()
+ }
+
+ SnackBarMessageHandler(
+ snackBarHostState = snackBarHostState,
+ snackBarMessage = viewModel.snackBarMessage,
+ )
+
+ // Clear snackbar message on dispose
+ DisposableEffect(Unit) {
+ onDispose {
+ viewModel.hideSnackBar()
+ }
+ }
+
+ BackHandler {
+ if (viewModel.selectMode) {
+ viewModel.exitSelectionMode()
+ } else {
+ onExitAction()
+ }
+ }
+
+ if (viewModel.isDeletionDialogVisible) {
+ MultipleFileDeletionDialog(
+ deletionItemsCount = viewModel.selectedItems.size,
+ onDeleteConfirmationAction = {
+ viewModel.deleteSelectedItems(context, onLastItemDeletion = onExitAction)
+ },
+ dismissCallback = viewModel::dismissDeletionDialog
+ )
+ }
+
+
+ Scaffold (
+ snackbarHost = {
+ SnackbarHost(snackBarHostState)
+ },
+
+ topBar = {
+ ExtendedGalleryTopBar(
+ selectModeEnabled = viewModel.selectMode,
+ secureMode = viewModel.isSecureCapturedItemsLoaded,
+ selectedItems = viewModel.selectedItems,
+
+ onEnableSelectMode = viewModel::enterSelectionMode,
+ onDisableSelectMode = viewModel::exitSelectionMode,
+ onAllItemsSelectAction = viewModel::selectAllItems,
+ onDeleteItemsAction = {
+ viewModel.showDeletionDialog(context)
+ },
+
+ onSharedItemsAction = {
+ viewModel.shareSelectedItems(context)
+ },
+
+ onDeviceUnlockRequest = context::requestDeviceUnlock,
+
+ onExitAction = onExitAction,
+ )
+ }
+ ) { innerPadding ->
+ Column(
+ modifier = Modifier
+ .fillMaxSize()
+ .padding(innerPadding),
+ horizontalAlignment = Alignment.CenterHorizontally
+ ) {
+ if (viewModel.isLoadingCapturedItems) {
+ Text(
+ stringResource(R.string.loading_generic),
+ textAlign = TextAlign.Center,
+ modifier = Modifier
+ .fillMaxSize()
+ )
+ } else {
+ if (viewModel.hasCapturedItems) {
+ LazyVerticalGrid(
+ columns = GridCells.Adaptive(SQUARE_MEDIA_PREVIEW_SIZE),
+ horizontalArrangement = Arrangement.spacedBy(4.dp),
+ verticalArrangement = Arrangement.spacedBy(4.dp)
+ ) {
+ viewModel.groupedCapturedItems.forEach { group ->
+
+ val date = getHumanReadableDate(group.key)
+ val capturedItemsOnDate = group.value
+
+ header {
+ Row(
+ modifier = Modifier
+ .padding(
+ start = 14.dp,
+ end = 14.dp,
+ top = 16.dp,
+ bottom = 8.dp,
+ ),
+ horizontalArrangement = Arrangement.SpaceBetween,
+ verticalAlignment = Alignment.CenterVertically,
+ ) {
+ Text(
+ date,
+ style = MaterialTheme.typography.titleLarge,
+ )
+
+ IconButton(
+ onClick = {
+ viewModel.toggleGroupSelection(capturedItemsOnDate)
+ },
+ ) {
+ if (viewModel.selectMode) {
+ if (viewModel.hasSelectedAll(capturedItemsOnDate)) {
+ Icon(
+ Icons.Filled.CheckCircle,
+ contentDescription = null
+ )
+ } else {
+ Icon(
+ Icons.Outlined.Circle,
+ contentDescription = null
+ )
+ }
+ } else {
+ Icon(
+ Icons.Outlined.CheckCircle,
+ contentDescription = null
+ )
+ }
+
+ }
+ }
+ }
+
+ items(capturedItemsOnDate) {
+ capturedItem ->
+ Box {
+ SquareMediaPreview(
+ capturedItem = capturedItem,
+ onClick = {
+ if (viewModel.selectMode) {
+ viewModel.toggleSelection(capturedItem)
+ } else {
+ showMediaItemAction(capturedItem)
+ }
+ },
+ onLongClick = {
+ viewModel.toggleSelection(capturedItem)
+ }
+ )
+
+ if (viewModel.hasSelected(capturedItem)) {
+ Box(
+ contentAlignment = Alignment.Center,
+ modifier = Modifier
+ .aspectRatio(1f)
+ .fillMaxSize()
+ .background(Color.Black.copy(.7f)),
+ ) {
+ Icon(
+ Icons.Default.Check,
+ contentDescription = null,
+ tint = Color.White,
+ )
+ }
+ }
+ }
+ }
+ }
+
+ }
+ } else {
+ Text(
+ stringResource(R.string.empty_gallery),
+ textAlign = TextAlign.Center,
+ modifier = Modifier
+ .fillMaxSize()
+ .wrapContentHeight(align = Alignment.CenterVertically),
+
+ )
+ }
+ }
+ }
+ }
+}
\ No newline at end of file
diff --git a/app/src/main/java/app/grapheneos/camera/ui/composable/screen/ui/GalleryScreen.kt b/app/src/main/java/app/grapheneos/camera/ui/composable/screen/ui/GalleryScreen.kt
new file mode 100644
index 000000000..23b68b7a3
--- /dev/null
+++ b/app/src/main/java/app/grapheneos/camera/ui/composable/screen/ui/GalleryScreen.kt
@@ -0,0 +1,320 @@
+package app.grapheneos.camera.ui.composable.screen.ui
+
+import android.app.Activity
+import android.widget.Toast
+import androidx.compose.animation.AnimatedVisibility
+import androidx.compose.animation.animateColorAsState
+import androidx.compose.animation.core.EaseIn
+import androidx.compose.animation.core.tween
+import androidx.compose.animation.fadeIn
+import androidx.compose.animation.fadeOut
+import androidx.compose.foundation.clickable
+import androidx.compose.foundation.interaction.MutableInteractionSource
+import androidx.compose.foundation.layout.calculateEndPadding
+import androidx.compose.foundation.layout.calculateStartPadding
+import androidx.compose.foundation.layout.fillMaxSize
+import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.layout.wrapContentHeight
+import androidx.compose.foundation.pager.HorizontalPager
+import androidx.compose.foundation.pager.rememberPagerState
+
+import androidx.compose.foundation.shape.CircleShape
+import androidx.compose.material.icons.Icons
+import androidx.compose.material.icons.filled.GridOn
+import androidx.compose.material3.FloatingActionButton
+import androidx.compose.material3.Icon
+import androidx.compose.material3.Scaffold
+import androidx.compose.material3.SnackbarHost
+import androidx.compose.material3.SnackbarHostState
+import androidx.compose.material3.Text
+
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.DisposableEffect
+import androidx.compose.runtime.LaunchedEffect
+import androidx.compose.runtime.getValue
+
+import androidx.compose.runtime.remember
+import androidx.compose.runtime.rememberCoroutineScope
+import androidx.compose.ui.Alignment
+
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.graphics.Color
+
+import androidx.compose.ui.platform.LocalContext
+import androidx.compose.ui.platform.LocalView
+import androidx.compose.ui.res.stringResource
+import androidx.compose.ui.text.style.TextAlign
+import androidx.compose.ui.unit.LayoutDirection
+import androidx.compose.ui.unit.dp
+import androidx.compose.ui.unit.sp
+import androidx.core.view.WindowCompat
+import androidx.core.view.WindowInsetsCompat
+import androidx.core.view.WindowInsetsControllerCompat
+
+import androidx.lifecycle.viewmodel.compose.viewModel
+import app.grapheneos.camera.CapturedItem
+
+import app.grapheneos.camera.ui.composable.component.dialog.FileDeletionDialog
+import app.grapheneos.camera.ui.composable.component.mediapreview.MediaPreview
+import app.grapheneos.camera.ui.composable.component.topbar.gallery.GalleryTopBar
+import app.grapheneos.camera.ui.composable.component.dialog.MediaInfoDialog
+import app.grapheneos.camera.ui.composable.theme.AppColor
+
+import me.saket.telephoto.zoomable.rememberZoomableState
+import me.saket.telephoto.zoomable.zoomable
+
+import app.grapheneos.camera.ITEM_TYPE_IMAGE
+import app.grapheneos.camera.R
+import app.grapheneos.camera.ui.composable.component.SnackBarMessageHandler
+import app.grapheneos.camera.ui.composable.component.tooltip.QuickTooltip
+import app.grapheneos.camera.ui.composable.component.tooltip.QuickTooltipVerticalDirection
+
+import app.grapheneos.camera.ui.composable.screen.viewmodel.GalleryViewModel
+import kotlinx.coroutines.launch
+
+private const val TAG = "GalleryScreen"
+
+@Composable
+fun GalleryScreen(
+ focusItem: CapturedItem? = null,
+ showVideoPlayerAction: (CapturedItem) -> Unit = {},
+ showExtendedGalleryAction: () -> Unit = {},
+ onExitAction: () -> Unit = {},
+) {
+ val context = LocalContext.current
+
+ val window = (context as Activity).window
+
+ val insetsController = WindowCompat.getInsetsController(window, LocalView.current).apply {
+ systemBarsBehavior = WindowInsetsControllerCompat.BEHAVIOR_DEFAULT
+ }
+
+ val zoomableState = rememberZoomableState()
+
+ val coroutineScope = rememberCoroutineScope()
+
+ val snackBarHostState = remember {
+ SnackbarHostState()
+ }
+
+ val viewModel = viewModel {
+ GalleryViewModel(context)
+ }
+
+ val pagerState = rememberPagerState {
+ viewModel.capturedItems.size
+ }
+
+ SnackBarMessageHandler(
+ snackBarHostState = snackBarHostState,
+ snackBarMessage = viewModel.snackBarMessage
+ )
+
+ DisposableEffect(Unit) {
+ onDispose {
+ viewModel.hideSnackBar()
+ }
+ }
+
+ val backgroundColor by animateColorAsState(
+ label = "background_color_animation",
+ targetValue = if (viewModel.inFocusMode) Color.Black else AppColor.BackgroundColor,
+ animationSpec = tween(durationMillis = 300, easing = EaseIn),
+ )
+
+ // Ensure the focus is back to the focused item whenever capturedItems is updated
+ LaunchedEffect(viewModel.isLoadingCapturedItems, viewModel.capturedItems) {
+ if (viewModel.isLoadingCapturedItems) return@LaunchedEffect
+
+ if (!viewModel.hasCapturedItems) {
+ Toast.makeText(context, R.string.empty_gallery, Toast.LENGTH_LONG).show()
+ onExitAction()
+ }
+
+ val focusIndex = viewModel.capturedItems.indexOf(focusItem)
+ if (focusIndex != -1) {
+ pagerState.scrollToPage(focusIndex)
+ }
+ }
+
+ // Set the default/updated focus item (updated on load)
+ LaunchedEffect(focusItem) {
+ viewModel.focusItem = focusItem
+ }
+
+ // Update the current focus item when the user slides between pages
+ LaunchedEffect(pagerState.currentPage) {
+ val page = pagerState.currentPage
+ viewModel.currentPage = page
+ if (page < viewModel.capturedItems.size) {
+ viewModel.focusItem = viewModel.capturedItems[page]
+ }
+
+ }
+
+ // Update zoom and focus state based on latest zoom state
+ LaunchedEffect(zoomableState.zoomFraction) {
+ zoomableState.zoomFraction?.let { zoomFraction ->
+ val isZoomedIn = zoomFraction >= 0.01f
+ viewModel.updateZoomedState(isZoomedIn)
+ }
+ }
+
+ LaunchedEffect(viewModel.inFocusMode) {
+ if (viewModel.inFocusMode) {
+ insetsController.hide(WindowInsetsCompat.Type.systemBars())
+ } else {
+ insetsController.show(WindowInsetsCompat.Type.systemBars())
+ }
+ }
+
+ DisposableEffect(Unit) {
+ onDispose {
+ insetsController.show(WindowInsetsCompat.Type.systemBars())
+ }
+ }
+
+ // Displays media info dialog when displayedMediaItem is not null
+ MediaInfoDialog(
+ mediaItemDetails = viewModel.displayedMediaItem,
+ dismissCallback = viewModel::hideMediaInfoDialog,
+ )
+
+ // Displays item deletion dialog when deletionItem is not null
+ FileDeletionDialog(
+ deletionItem = viewModel.deletionItem,
+ onDeleteAction = { item ->
+ viewModel.deleteMediaItem(context, item, onLastItemDeletion = onExitAction)
+ },
+ dismissHandler = viewModel::hideDeletionPrompt,
+ )
+
+ Scaffold(
+ containerColor = backgroundColor,
+
+ snackbarHost = { SnackbarHost(hostState = snackBarHostState) },
+
+ floatingActionButton = {
+ if (focusItem == null) {
+ AnimatedVisibility(
+ visible = !viewModel.inFocusMode,
+ enter = fadeIn(),
+ exit = fadeOut()
+ ) {
+ QuickTooltip(
+ message = stringResource(R.string.grid_view),
+ defaultDirection = QuickTooltipVerticalDirection.TOP
+ ) {
+ FloatingActionButton(
+ onClick = {
+ coroutineScope.launch {
+ if (zoomableState.zoomFraction != 0f) {
+ zoomableState.resetZoom()
+ }
+ showExtendedGalleryAction()
+ }
+ },
+ shape = CircleShape,
+ ) {
+ Icon(
+ Icons.Default.GridOn,
+ contentDescription = stringResource(R.string.search_images),
+ )
+ }
+ }
+ }
+ }
+ },
+
+ topBar = {
+ GalleryTopBar(
+ visible = !viewModel.inFocusMode,
+ onCloseAction = onExitAction,
+
+ onEditAction = { chooseApp, modifyOriginal ->
+ viewModel.editMediaItem(context, chooseApp, modifyOriginal)
+ },
+
+ onDeleteAction = {
+ viewModel.promptItemDeletion()
+ },
+
+ onInfoAction = {
+ viewModel.displayMediaInfo(context)
+ },
+
+ onShareAction = {
+ viewModel.shareCurrentItem(context)
+ },
+ )
+ },
+
+ content = { innerPadding ->
+
+ if (viewModel.isLoadingCapturedItems) {
+ Text(
+ text = stringResource(R.string.three_dots),
+ color = Color.White,
+ fontSize = 24.sp,
+ textAlign = TextAlign.Center,
+ modifier = Modifier
+ .fillMaxSize()
+ .wrapContentHeight(align = Alignment.CenterVertically),
+ )
+ } else {
+ if (viewModel.hasCapturedItems) {
+ HorizontalPager(
+ state = pagerState,
+ userScrollEnabled = !viewModel.isZoomedIn,
+ beyondViewportPageCount = 1,
+ modifier = Modifier
+ .padding(
+ innerPadding.calculateStartPadding(LayoutDirection.Ltr),
+ // the fixed padding of 56.dp has been added to GalleryImage to
+ // avoid having a fixed black bar on the top (56dp comes from
+ // material guidelines)
+ 0.dp,
+ innerPadding.calculateEndPadding(LayoutDirection.Ltr),
+ 0.dp,
+ )
+ .fillMaxSize()
+ ) { page ->
+ val capturedItem = viewModel.capturedItems[page]
+
+ val modifier: Modifier = if (capturedItem.type == ITEM_TYPE_IMAGE) {
+ Modifier
+ .zoomable(
+ zoomableState,
+ onClick = {
+ viewModel.toggleFocusMode()
+ }
+ )
+ } else {
+ Modifier
+ .clickable(
+ indication = null,
+ interactionSource = remember { MutableInteractionSource() }
+ ) {
+ showVideoPlayerAction(capturedItem)
+ }
+ }
+
+ MediaPreview(
+ capturedItem = capturedItem,
+ modifier = modifier
+ )
+ }
+ } else {
+ Text(
+ text = stringResource(R.string.empty_gallery),
+ color = Color.White,
+ textAlign = TextAlign.Center,
+ modifier = Modifier
+ .fillMaxSize()
+ .wrapContentHeight(align = Alignment.CenterVertically),
+ )
+ }
+ }
+ },
+ )
+}
\ No newline at end of file
diff --git a/app/src/main/java/app/grapheneos/camera/ui/composable/screen/ui/VideoPlayerScreen.kt b/app/src/main/java/app/grapheneos/camera/ui/composable/screen/ui/VideoPlayerScreen.kt
new file mode 100644
index 000000000..e258aa3a8
--- /dev/null
+++ b/app/src/main/java/app/grapheneos/camera/ui/composable/screen/ui/VideoPlayerScreen.kt
@@ -0,0 +1,144 @@
+package app.grapheneos.camera.ui.composable.screen.ui
+
+import android.media.MediaMetadataRetriever
+import android.net.Uri
+import androidx.compose.foundation.layout.fillMaxSize
+import androidx.compose.foundation.layout.padding
+import androidx.compose.material.icons.Icons
+import androidx.compose.material.icons.automirrored.filled.ArrowBack
+import androidx.compose.material3.ExperimentalMaterial3Api
+import androidx.compose.material3.Icon
+import androidx.compose.material3.IconButton
+
+import androidx.compose.material3.Scaffold
+import androidx.compose.material3.TopAppBar
+import androidx.compose.material3.TopAppBarColors
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.DisposableEffect
+import androidx.compose.runtime.LaunchedEffect
+import androidx.compose.runtime.remember
+import androidx.compose.runtime.rememberCoroutineScope
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.graphics.Color
+import androidx.compose.ui.platform.LocalContext
+import androidx.compose.ui.res.stringResource
+import androidx.compose.ui.viewinterop.AndroidView
+import androidx.lifecycle.compose.LifecycleResumeEffect
+import androidx.media3.common.AudioAttributes
+import androidx.media3.common.C
+import androidx.media3.common.MediaItem
+import androidx.media3.common.util.Log
+import androidx.media3.common.util.UnstableApi
+import androidx.media3.exoplayer.ExoPlayer
+import androidx.media3.ui.PlayerView
+import app.grapheneos.camera.R
+import app.grapheneos.camera.ui.composable.theme.AppColor
+import kotlinx.coroutines.launch
+
+private const val TAG = "VideoPlayerScreen"
+
+@androidx.annotation.OptIn(UnstableApi::class)
+@OptIn(ExperimentalMaterial3Api::class)
+@Composable
+fun VideoPlayerScreen(
+ mediaUri: Uri,
+ onExitAction: () -> Unit = {},
+) {
+
+ val context = LocalContext.current
+
+ val coroutineScope = rememberCoroutineScope()
+
+ val exoPlayer = remember {
+ ExoPlayer.Builder(context).build()
+ }
+
+ // Executed once (unless the mediaUri the key here changes)
+ LaunchedEffect(mediaUri) {
+ coroutineScope.launch {
+ var hasAudio = true
+ try {
+ MediaMetadataRetriever().use {
+ it.setDataSource(context, mediaUri)
+ hasAudio = it.extractMetadata(MediaMetadataRetriever.METADATA_KEY_HAS_AUDIO) != null
+ }
+ } catch (e: Exception) {
+ Log.d(TAG, "Unable to retrieve HAS_AUDIO metadata for video", e)
+ }
+
+ // Requests for focus when audio is enabled
+ if (hasAudio) {
+ exoPlayer.setAudioAttributes(
+ AudioAttributes.Builder()
+ .setContentType(C.AUDIO_CONTENT_TYPE_MOVIE)
+ .build(),
+ true
+ )
+ }
+
+ val mediaItem = MediaItem.fromUri(mediaUri)
+ exoPlayer.setMediaItem(mediaItem)
+
+ exoPlayer.prepare()
+
+ // Auto-play only when the player first starts
+ exoPlayer.play()
+ }
+
+ }
+
+ LifecycleResumeEffect(Unit) {
+ onPauseOrDispose {
+ // Don't play when user is away from screen
+ if (exoPlayer.isPlaying)
+ exoPlayer.pause()
+ }
+ }
+
+ // Run only once, a listener set which
+ DisposableEffect(Unit) {
+ onDispose {
+ exoPlayer.release()
+ }
+ }
+
+ // Main UI
+ Scaffold (
+ topBar = {
+ TopAppBar(
+ title = {},
+ colors = TopAppBarColors(
+ containerColor = AppColor.AppBarColor,
+ scrolledContainerColor = AppColor.AppBarColor,
+ titleContentColor = Color.White,
+ navigationIconContentColor = Color.White,
+ actionIconContentColor = Color.White,
+ ),
+ navigationIcon = {
+ IconButton(onClick = onExitAction) {
+ Icon(
+ imageVector = Icons.AutoMirrored.Default.ArrowBack,
+ contentDescription = stringResource(R.string.back)
+ )
+ }
+ }
+ )
+ },
+ containerColor = AppColor.BackgroundColor,
+ content = { innerPadding ->
+ AndroidView(
+ modifier = Modifier
+ .padding(innerPadding)
+ .fillMaxSize(),
+
+ factory = { context ->
+ PlayerView(context).apply {
+ player = exoPlayer
+ controllerShowTimeoutMs = 1200
+ }
+ }
+ )
+
+ }
+ )
+}
diff --git a/app/src/main/java/app/grapheneos/camera/ui/composable/screen/viewmodel/CapturedItemsRepository.kt b/app/src/main/java/app/grapheneos/camera/ui/composable/screen/viewmodel/CapturedItemsRepository.kt
new file mode 100644
index 000000000..bc51fa0b3
--- /dev/null
+++ b/app/src/main/java/app/grapheneos/camera/ui/composable/screen/viewmodel/CapturedItemsRepository.kt
@@ -0,0 +1,132 @@
+package app.grapheneos.camera.ui.composable.screen.viewmodel
+
+import android.content.Context
+import android.util.Log
+
+import androidx.compose.runtime.mutableStateListOf
+import androidx.compose.runtime.mutableStateOf
+import androidx.compose.runtime.getValue
+import androidx.compose.runtime.setValue
+
+
+import app.grapheneos.camera.CapturedItem
+import app.grapheneos.camera.CapturedItems
+import app.grapheneos.camera.ITEM_TYPE_VIDEO
+import app.grapheneos.camera.ktx.isDeviceLocked
+import app.grapheneos.camera.ui.activities.InAppGallery
+import kotlinx.coroutines.CoroutineScope
+import kotlinx.coroutines.Dispatchers
+
+import kotlinx.coroutines.launch
+import kotlinx.coroutines.withContext
+
+private const val TAG = "CapturedItemsRepository"
+
+class CapturedItemsRepository(
+ private val context: Context,
+ private val scope: CoroutineScope,
+ private val showVideosOnly: Boolean = false,
+ private val placeholderItem : CapturedItem? = null,
+ private val mediaItems: ArrayList? = null,
+ preload : Boolean = true,
+) {
+ var isLoading by mutableStateOf(false)
+
+ val capturedItems = mutableStateListOf()
+
+ var secureModeState by mutableStateOf(null)
+ private set
+
+ init {
+ if (preload) loadCapturedItems()
+ }
+
+ fun loadCapturedItems(
+ onLoadComplete : () -> Unit = {}
+ ) {
+ // Only reload on config (i.e. secure mode states) changes
+ val secureModeState = context.isDeviceLocked()
+ if (this.secureModeState == secureModeState) {
+ onLoadComplete()
+ return
+ }
+ this.secureModeState = secureModeState
+
+ scope.launch {
+ Log.i(TAG, "loadCapturedItems is loading items 1")
+ if (isLoading) return@launch
+ isLoading = true
+
+ Log.i(TAG, "loadCapturedItems is loading items 2")
+
+ capturedItems.clear()
+
+ Log.i(TAG, "loadCapturedItems is loading items 3")
+
+ if (placeholderItem != null) {
+ capturedItems.add(placeholderItem)
+ }
+
+ Log.i(TAG, "loadCapturedItems is loading items 4")
+
+ val items : List
+
+ Log.i(TAG, "isSecureMode: $secureModeState")
+
+ withContext(Dispatchers.IO) {
+ items = if (secureModeState) {
+ mediaItems ?: arrayListOf()
+ } else {
+ CapturedItems.get(context)
+ }
+ }
+
+ Log.i(TAG, "items 1: $items")
+
+ val relevantMediaItems = if (showVideosOnly) {
+ items.filter { capturedItem -> capturedItem.type == ITEM_TYPE_VIDEO }
+ } else {
+ items
+ }
+
+ Log.i(TAG, "items 2: $items")
+
+ val finalItems = relevantMediaItems.sortedByDescending { it.dateString }
+
+ Log.i(TAG, "finalItems: $items")
+
+
+
+ capturedItems.clear()
+ capturedItems.addAll(finalItems)
+
+ isLoading = false
+
+ onLoadComplete()
+ }
+ }
+
+ suspend fun deleteItem(capturedItem: CapturedItem, context: Context) : Boolean {
+ val res = capturedItem.delete(context)
+
+ if (res) {
+ // On main thread to ensure sequential deletion to avoid concurrent access
+ // exception when a lot of multiple items are being deleted together
+ withContext(Dispatchers.Main) {
+ capturedItems.remove(capturedItem)
+ }
+ }
+
+ return res
+ }
+
+ companion object {
+ fun get(context: Context) : CapturedItemsRepository {
+ assert(context is InAppGallery) {
+ "CapturedItemsRepository only support instantiating from InAppGallery activity currently"
+ }
+
+ return (context as InAppGallery).capturedItemsRepository
+ }
+ }
+}
\ No newline at end of file
diff --git a/app/src/main/java/app/grapheneos/camera/ui/composable/screen/viewmodel/ExtendedGalleryViewModel.kt b/app/src/main/java/app/grapheneos/camera/ui/composable/screen/viewmodel/ExtendedGalleryViewModel.kt
new file mode 100644
index 000000000..6ece2b3e2
--- /dev/null
+++ b/app/src/main/java/app/grapheneos/camera/ui/composable/screen/viewmodel/ExtendedGalleryViewModel.kt
@@ -0,0 +1,229 @@
+package app.grapheneos.camera.ui.composable.screen.viewmodel
+
+import android.content.Context
+import android.content.Intent
+import androidx.compose.runtime.derivedStateOf
+import androidx.compose.runtime.getValue
+import androidx.compose.runtime.mutableStateListOf
+import androidx.compose.runtime.mutableStateOf
+import androidx.compose.runtime.setValue
+import androidx.compose.runtime.snapshots.SnapshotStateList
+import androidx.lifecycle.ViewModel
+import androidx.lifecycle.viewModelScope
+
+import app.grapheneos.camera.CapturedItem
+import app.grapheneos.camera.R
+import app.grapheneos.camera.ktx.isDeviceLocked
+import app.grapheneos.camera.ui.composable.model.NoDataSnackBarMessage
+import app.grapheneos.camera.ui.composable.model.SnackBarMessage
+import app.grapheneos.camera.util.getMimeTypeForItems
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.async
+import kotlinx.coroutines.awaitAll
+import kotlinx.coroutines.launch
+
+private const val TAG = "ExtendedGalleryViewModel"
+
+class ExtendedGalleryViewModel(context: Context) : ViewModel() {
+
+ val selectedItems = mutableStateListOf()
+
+ var selectMode by mutableStateOf(false)
+ private set
+
+ private val capturedItemsViewModel = CapturedItemsRepository.get(context)
+
+ private val capturedItems : SnapshotStateList
+ get() = capturedItemsViewModel.capturedItems
+
+ val groupedCapturedItems by derivedStateOf {
+ capturedItems.groupBy {
+ it.dateString.substringBefore('_')
+ }.toSortedMap { a, b ->
+ b.toInt() - a.toInt()
+ }
+ }
+
+ val hasCapturedItems by derivedStateOf {
+ capturedItems.isNotEmpty()
+ }
+
+ val isLoadingCapturedItems by derivedStateOf {
+ capturedItemsViewModel.isLoading
+ }
+
+ val isSecureCapturedItemsLoaded by derivedStateOf {
+ capturedItemsViewModel.secureModeState == true
+ }
+
+ var isDeletionDialogVisible by mutableStateOf(false)
+
+ var snackBarMessage by mutableStateOf(NoDataSnackBarMessage)
+ private set
+
+ fun showSnackBar(message: String) {
+ snackBarMessage = SnackBarMessage(message)
+ }
+
+ fun hideSnackBar() {
+ snackBarMessage = NoDataSnackBarMessage
+ }
+
+ fun showDeletionDialog(context: Context) {
+ viewModelScope.launch {
+ if (selectedItems.isEmpty()) {
+ showSnackBar(context.getString(R.string.select_an_item_request))
+ return@launch
+ }
+ isDeletionDialogVisible = true
+ }
+ }
+
+ fun dismissDeletionDialog() {
+ viewModelScope.launch {
+ isDeletionDialogVisible = false
+ }
+ }
+
+ fun toggleSelection(capturedItem: CapturedItem, exitModeOnNoItemSelected : Boolean = true) {
+ viewModelScope.launch {
+ enterSelectionMode()
+ if (hasSelected(capturedItem)) {
+ deselectItem(capturedItem, exitModeOnNoItemSelected)
+ } else {
+ selectItem(capturedItem, true)
+ }
+ }
+ }
+
+ fun toggleGroupSelection(capturedItems: Collection, exitModeOnNoItemSelected : Boolean = true) {
+ viewModelScope.launch {
+ if (hasSelectedAll(capturedItems)) {
+ deselectItems(capturedItems, exitModeOnNoItemSelected)
+ } else {
+ selectItems(capturedItems)
+ }
+ }
+ }
+
+ fun selectAllItems() {
+ viewModelScope.launch {
+ selectedItems.clear()
+ selectedItems.addAll(capturedItems)
+ }
+ }
+
+ // Ensures that the app is in selection mode
+ fun enterSelectionMode() {
+ viewModelScope.launch {
+ selectMode = true
+ }
+ }
+
+ // Ensures that the app is no longer in selection mode
+ fun exitSelectionMode() {
+ viewModelScope.launch {
+ selectedItems.clear()
+ selectMode = false
+ }
+ }
+
+ fun hasSelected(capturedItem: CapturedItem): Boolean {
+ return capturedItem in selectedItems
+ }
+
+ fun hasSelectedAll(capturedItems: Collection) : Boolean {
+ return selectedItems.containsAll(capturedItems)
+ }
+
+ private fun selectItem(capturedItem: CapturedItem, skipCheck : Boolean = false) {
+ viewModelScope.launch {
+ enterSelectionMode()
+ if (skipCheck || !hasSelected(capturedItem)) {
+ selectedItems.add(capturedItem)
+ }
+ }
+ }
+
+ private fun deselectItem(capturedItem: CapturedItem, exitModeOnNoItemSelected: Boolean = true) {
+ viewModelScope.launch {
+ selectedItems.remove(capturedItem)
+
+ if (exitModeOnNoItemSelected) {
+ if (selectedItems.isEmpty()) {
+ exitSelectionMode()
+ }
+ }
+ }
+ }
+
+ private fun selectItems(capturedItems: Collection) {
+ viewModelScope.launch {
+ for (capturedItem in capturedItems) selectItem(capturedItem)
+ }
+ }
+
+ private fun deselectItems(capturedItems: Collection, exitModeOnNoItemSelected : Boolean = true) {
+ viewModelScope.launch {
+ for (capturedItem in capturedItems) deselectItem(capturedItem, exitModeOnNoItemSelected)
+ }
+ }
+
+ fun shareSelectedItems(context: Context) {
+ viewModelScope.launch {
+ if (context.isDeviceLocked()) {
+ showSnackBar(context.getString(R.string.sharing_not_allowed))
+ return@launch
+ }
+
+ if (selectedItems.isEmpty()) {
+ showSnackBar(context.getString(R.string.select_an_item_request))
+ return@launch
+ }
+
+ val uris = selectedItems.mapTo(arrayListOf()) { it.uri }
+
+ val shareIntent = Intent(Intent.ACTION_SEND).apply {
+ action = Intent.ACTION_SEND_MULTIPLE
+ putParcelableArrayListExtra(
+ Intent.EXTRA_STREAM,
+ uris
+ )
+ type = getMimeTypeForItems(selectedItems)
+ flags = Intent.FLAG_GRANT_READ_URI_PERMISSION
+ }
+
+ context.startActivity(
+ Intent.createChooser(
+ shareIntent,
+ context.getString(R.string.share_image)
+ )
+ )
+ }
+ }
+
+ fun deleteSelectedItems(
+ context: Context,
+ onLastItemDeletion: () -> Unit = {},
+ ) {
+ viewModelScope.launch {
+
+ val selectedItemsSize = selectedItems.size
+
+ val failedDeletions = selectedItems.map { capturedItem -> async(Dispatchers.IO) {
+ capturedItemsViewModel.deleteItem(capturedItem, context)
+ } }.awaitAll().count { res -> !res }
+
+ exitSelectionMode()
+
+ if (!hasCapturedItems) {
+ onLastItemDeletion()
+ }
+
+ if (failedDeletions != 0) {
+ showSnackBar(context.getString(R.string.failed_multiple_deletion_message, failedDeletions, selectedItemsSize))
+ }
+ }
+ }
+}
+
diff --git a/app/src/main/java/app/grapheneos/camera/ui/composable/screen/viewmodel/GalleryViewModel.kt b/app/src/main/java/app/grapheneos/camera/ui/composable/screen/viewmodel/GalleryViewModel.kt
new file mode 100644
index 000000000..3ec070558
--- /dev/null
+++ b/app/src/main/java/app/grapheneos/camera/ui/composable/screen/viewmodel/GalleryViewModel.kt
@@ -0,0 +1,216 @@
+package app.grapheneos.camera.ui.composable.screen.viewmodel
+
+import android.content.ActivityNotFoundException
+import android.content.Context
+import android.content.Intent
+
+import android.util.Log
+import android.widget.Toast
+import androidx.compose.runtime.derivedStateOf
+import androidx.compose.runtime.getValue
+import androidx.compose.runtime.mutableStateOf
+import androidx.compose.runtime.setValue
+import androidx.compose.runtime.snapshots.SnapshotStateList
+
+import androidx.lifecycle.ViewModel
+
+import androidx.lifecycle.viewModelScope
+
+import app.grapheneos.camera.CapturedItem
+import app.grapheneos.camera.R
+import app.grapheneos.camera.ktx.isDeviceLocked
+import app.grapheneos.camera.ui.composable.model.MediaItemDetails
+import app.grapheneos.camera.ui.composable.model.NoDataSnackBarMessage
+import app.grapheneos.camera.ui.composable.model.SnackBarMessage
+import kotlinx.coroutines.launch
+
+private const val TAG = "GalleryViewModel"
+
+class GalleryViewModel(context: Context) : ViewModel() {
+
+ var focusItem by mutableStateOf(null)
+
+ var isZoomedIn by mutableStateOf(false)
+
+ var inFocusMode by mutableStateOf(false)
+
+ var displayedMediaItem by mutableStateOf(null)
+
+ var deletionItem by mutableStateOf(null)
+
+ var currentPage = 0
+
+ private val currentItem: CapturedItem
+ get() = capturedItems[currentPage]
+
+ private val capturedItemsViewModel = CapturedItemsRepository.get(context)
+
+ val capturedItems : SnapshotStateList
+ get() = capturedItemsViewModel.capturedItems
+
+ val hasCapturedItems: Boolean
+ get() = capturedItemsViewModel.capturedItems.isNotEmpty()
+
+ val isLoadingCapturedItems by derivedStateOf {
+ capturedItemsViewModel.isLoading
+ }
+
+ var snackBarMessage by mutableStateOf(NoDataSnackBarMessage)
+ private set
+
+ fun showSnackBar(message: String) {
+ snackBarMessage = SnackBarMessage(message)
+ }
+
+ fun hideSnackBar() {
+ snackBarMessage = NoDataSnackBarMessage
+ }
+
+ fun displayMediaInfo(context: Context, item: CapturedItem = currentItem) {
+ viewModelScope.launch {
+ if (!hasCapturedItems) {
+ showSnackBar(
+ context.getString(R.string.unable_to_obtain_file_details)
+ )
+ return@launch
+ }
+
+ try {
+ displayedMediaItem = MediaItemDetails.forCapturedItem(context, item)
+ } catch (e: Exception) {
+ Log.i(TAG, "Unable to obtain file details for MediaInfoDialog")
+ e.printStackTrace()
+ showSnackBar(
+ context.getString(R.string.unable_to_obtain_file_details)
+ )
+ }
+
+ }
+
+ }
+
+ fun hideMediaInfoDialog() {
+ viewModelScope.launch {
+ displayedMediaItem = null
+ }
+ }
+
+ fun promptItemDeletion(item: CapturedItem = currentItem) {
+ viewModelScope.launch {
+ deletionItem = item
+ }
+ }
+
+
+ fun deleteMediaItem(
+ context: Context,
+ item: CapturedItem,
+ onLastItemDeletion: () -> Unit,
+ ) {
+ viewModelScope.launch {
+ val result = capturedItemsViewModel.deleteItem(item, context)
+
+ if (result) {
+ if (!hasCapturedItems) {
+ Toast.makeText(context, R.string.empty_gallery, Toast.LENGTH_LONG)
+ .show()
+ onLastItemDeletion()
+ } else {
+ showSnackBar(
+ context.getString(R.string.deleted_successfully)
+ )
+ }
+ } else {
+ showSnackBar(
+ context.getString(R.string.deleting_unexpected_error)
+ )
+ }
+ }
+ }
+
+ fun hideDeletionPrompt() {
+ viewModelScope.launch {
+ deletionItem = null
+ }
+ }
+
+ fun editMediaItem(
+ context: Context,
+ chooseApp: Boolean = false,
+ modifyOriginal: Boolean = false,
+ item: CapturedItem = currentItem,
+ ) {
+ viewModelScope.launch {
+ if (context.isDeviceLocked()) {
+ showSnackBar(
+ context.getString(R.string.edit_not_allowed)
+ )
+ return@launch
+ }
+
+ val editIntent = Intent(Intent.ACTION_EDIT).apply {
+ setDataAndType(item.uri, item.mimeType())
+ putExtra(Intent.EXTRA_STREAM, item.uri)
+ if (modifyOriginal) {
+ addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION or Intent.FLAG_GRANT_WRITE_URI_PERMISSION)
+ } else {
+ addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION)
+ }
+ }
+
+ if (chooseApp) {
+ val chooser = Intent.createChooser(
+ editIntent,
+ context.getString(R.string.edit_image)
+ ).apply {
+ putExtra(Intent.EXTRA_AUTO_LAUNCH_SINGLE_CHOICE, false)
+ }
+ context.startActivity(chooser)
+ } else {
+ try {
+ context.startActivity(editIntent)
+ } catch (ignored: ActivityNotFoundException) {
+ showSnackBar(
+ context.getString(R.string.no_editor_app_error)
+ )
+ }
+ }
+ }
+ }
+
+ fun shareCurrentItem(
+ context: Context,
+ item: CapturedItem = currentItem,
+ ) {
+ viewModelScope.launch {
+ if (context.isDeviceLocked()) {
+ showSnackBar(
+ context.getString(R.string.sharing_not_allowed)
+ )
+ return@launch
+ }
+
+ val share = Intent(Intent.ACTION_SEND)
+ share.putExtra(Intent.EXTRA_STREAM, item.uri)
+ share.setDataAndType(item.uri, item.mimeType())
+ share.flags = Intent.FLAG_GRANT_READ_URI_PERMISSION
+
+ context.startActivity(
+ Intent.createChooser(share, context.getString(R.string.share_image))
+ )
+ }
+ }
+
+ fun updateZoomedState(zoomedIn: Boolean) {
+ viewModelScope.launch {
+ isZoomedIn = zoomedIn
+ inFocusMode = zoomedIn
+ }
+ }
+
+ fun toggleFocusMode() {
+ viewModelScope.launch {
+ inFocusMode = !inFocusMode
+ }
+ }
+}
\ No newline at end of file
diff --git a/app/src/main/java/app/grapheneos/camera/ui/composable/theme/AppColor.kt b/app/src/main/java/app/grapheneos/camera/ui/composable/theme/AppColor.kt
new file mode 100644
index 000000000..dcf2cfb69
--- /dev/null
+++ b/app/src/main/java/app/grapheneos/camera/ui/composable/theme/AppColor.kt
@@ -0,0 +1,38 @@
+package app.grapheneos.camera.ui.composable.theme
+
+import android.os.Build
+import androidx.compose.foundation.isSystemInDarkTheme
+import androidx.compose.material3.ColorScheme
+import androidx.compose.material3.darkColorScheme
+import androidx.compose.material3.dynamicDarkColorScheme
+import androidx.compose.material3.dynamicLightColorScheme
+import androidx.compose.material3.lightColorScheme
+import androidx.compose.runtime.Composable
+import androidx.compose.ui.graphics.Color
+import androidx.compose.ui.platform.LocalContext
+import androidx.compose.ui.res.colorResource
+import app.grapheneos.camera.R
+
+object AppColor {
+ val BackgroundColor = Color(0xff181c1f)
+ val AppBarColor = Color(0x77000000)
+}
+
+
+
+@Composable
+fun appColorScheme() : ColorScheme {
+ val dynamicColor = Build.VERSION.SDK_INT >= Build.VERSION_CODES.S
+ val darkTheme = isSystemInDarkTheme()
+
+ return when {
+ dynamicColor && darkTheme -> dynamicDarkColorScheme(LocalContext.current)
+ dynamicColor && !darkTheme -> dynamicLightColorScheme(LocalContext.current)
+ darkTheme -> darkColorScheme(
+ primaryContainer = colorResource(R.color.system_accent1_500)
+ )
+ else -> lightColorScheme(
+ primaryContainer = colorResource(R.color.system_accent1_500)
+ )
+ }
+}
\ No newline at end of file
diff --git a/app/src/main/java/app/grapheneos/camera/ui/composable/theme/Typography.kt b/app/src/main/java/app/grapheneos/camera/ui/composable/theme/Typography.kt
new file mode 100644
index 000000000..ec0d2eae6
--- /dev/null
+++ b/app/src/main/java/app/grapheneos/camera/ui/composable/theme/Typography.kt
@@ -0,0 +1,77 @@
+package app.grapheneos.camera.ui.composable.theme
+
+import androidx.compose.material3.MaterialTheme
+import androidx.compose.material3.Typography
+import androidx.compose.runtime.Composable
+import androidx.compose.ui.text.font.FontWeight
+import androidx.compose.ui.unit.sp
+
+@Composable
+fun appTypography() : Typography {
+
+ return Typography(
+ titleSmall = MaterialTheme.typography.titleSmall.copy(
+ fontWeight = FontWeight.SemiBold,
+ letterSpacing = 0.sp,
+ ),
+
+ titleMedium = MaterialTheme.typography.titleMedium.copy(
+ fontWeight = FontWeight.SemiBold,
+ letterSpacing = 0.sp,
+ ),
+
+ titleLarge = MaterialTheme.typography.titleLarge.copy(
+ fontWeight = FontWeight.SemiBold,
+ letterSpacing = 0.sp,
+ ),
+
+ bodySmall = MaterialTheme.typography.bodySmall.copy(
+ letterSpacing = 0.sp,
+ ),
+
+ bodyMedium = MaterialTheme.typography.bodyMedium.copy(
+ letterSpacing = 0.sp,
+ ),
+
+ bodyLarge = MaterialTheme.typography.bodyLarge.copy(
+ letterSpacing = 0.sp,
+ ),
+
+ labelSmall = MaterialTheme.typography.labelSmall.copy(
+ letterSpacing = 0.sp,
+ ),
+
+ labelMedium = MaterialTheme.typography.labelMedium.copy(
+ letterSpacing = 0.sp,
+ ),
+
+ labelLarge = MaterialTheme.typography.labelLarge.copy(
+ letterSpacing = 0.sp,
+ ),
+
+ displaySmall = MaterialTheme.typography.displaySmall.copy(
+ letterSpacing = 0.sp,
+ ),
+
+ displayMedium = MaterialTheme.typography.displayMedium.copy(
+ letterSpacing = 0.sp,
+ ),
+
+ displayLarge = MaterialTheme.typography.displayLarge.copy(
+ letterSpacing = 0.sp,
+ ),
+
+ headlineSmall = MaterialTheme.typography.headlineSmall.copy(
+ letterSpacing = 0.sp,
+ ),
+
+ headlineMedium = MaterialTheme.typography.headlineMedium.copy(
+ letterSpacing = 0.sp,
+ ),
+
+ headlineLarge = MaterialTheme.typography.headlineLarge.copy(
+ letterSpacing = 0.sp,
+ ),
+ )
+}
+
diff --git a/app/src/main/java/app/grapheneos/camera/ui/fragment/GallerySlide.kt b/app/src/main/java/app/grapheneos/camera/ui/fragment/GallerySlide.kt
deleted file mode 100644
index 0fb38615a..000000000
--- a/app/src/main/java/app/grapheneos/camera/ui/fragment/GallerySlide.kt
+++ /dev/null
@@ -1,8 +0,0 @@
-package app.grapheneos.camera.ui.fragment
-
-import androidx.recyclerview.widget.RecyclerView
-import app.grapheneos.camera.databinding.GallerySlideBinding
-
-class GallerySlide(val binding: GallerySlideBinding) : RecyclerView.ViewHolder(binding.root) {
- @Volatile var currentPostion = 0
-}
diff --git a/app/src/main/java/app/grapheneos/camera/util/DateTimeUtils.kt b/app/src/main/java/app/grapheneos/camera/util/DateTimeUtils.kt
new file mode 100644
index 000000000..4a153c736
--- /dev/null
+++ b/app/src/main/java/app/grapheneos/camera/util/DateTimeUtils.kt
@@ -0,0 +1,31 @@
+package app.grapheneos.camera.util
+
+import java.time.LocalDate
+import java.time.format.DateTimeFormatter
+
+fun getHumanReadableDate(
+ dateString: String,
+ inputFormat: String = "yyyyMMdd",
+ outputFormat: String = "EEE, MMM dd"
+) : String {
+ val date = LocalDate.parse(
+ dateString,
+ DateTimeFormatter.ofPattern(inputFormat)
+ )
+
+ if (date == LocalDate.now()) {
+ return "Today"
+ }
+
+ val dayDiff = LocalDate.now().toEpochDay() - date.toEpochDay()
+
+ if (dayDiff == 1L) {
+ return "Yesterday"
+ }
+
+ if (dayDiff < 7) {
+ return date.dayOfWeek.name.lowercase().replaceFirstChar(Char::uppercaseChar)
+ }
+
+ return date.format(DateTimeFormatter.ofPattern(outputFormat))
+}
\ No newline at end of file
diff --git a/app/src/main/java/app/grapheneos/camera/util/MimeUtils.kt b/app/src/main/java/app/grapheneos/camera/util/MimeUtils.kt
new file mode 100644
index 000000000..0ed084a5a
--- /dev/null
+++ b/app/src/main/java/app/grapheneos/camera/util/MimeUtils.kt
@@ -0,0 +1,32 @@
+package app.grapheneos.camera.util
+
+import app.grapheneos.camera.CapturedItem
+import app.grapheneos.camera.ITEM_TYPE_VIDEO
+
+const val VIDEO_MIME_TYPE = "video/mp4"
+const val IMAGE_MIME_TYPE = "image/jpg"
+const val GENERIC_MIME_TYPE = "*/*"
+
+fun getMimeTypeForItems(items: List) : String {
+
+ if (items.isEmpty()) return GENERIC_MIME_TYPE
+
+ var hasPhoto = false
+ var hasVideo = false
+
+ for (item in items) {
+ if (item.type == ITEM_TYPE_VIDEO) {
+ hasVideo = true
+ if (hasPhoto) return GENERIC_MIME_TYPE
+ } else {
+ hasPhoto = true
+ if (hasVideo) return GENERIC_MIME_TYPE
+ }
+ }
+
+ if (hasVideo) {
+ return VIDEO_MIME_TYPE
+ } else {
+ return IMAGE_MIME_TYPE
+ }
+}
\ No newline at end of file
diff --git a/app/src/main/res/layout/gallery.xml b/app/src/main/res/layout/gallery.xml
deleted file mode 100644
index db514febf..000000000
--- a/app/src/main/res/layout/gallery.xml
+++ /dev/null
@@ -1,31 +0,0 @@
-
-
-
-
-
-
-
-
-
-
-
-
diff --git a/app/src/main/res/layout/gallery_placeholder.xml b/app/src/main/res/layout/gallery_placeholder.xml
deleted file mode 100644
index fcd658f72..000000000
--- a/app/src/main/res/layout/gallery_placeholder.xml
+++ /dev/null
@@ -1,10 +0,0 @@
-
-
diff --git a/app/src/main/res/layout/gallery_slide.xml b/app/src/main/res/layout/gallery_slide.xml
deleted file mode 100644
index 31e09a8c2..000000000
--- a/app/src/main/res/layout/gallery_slide.xml
+++ /dev/null
@@ -1,27 +0,0 @@
-
-
-
-
-
-
-
-
-
diff --git a/app/src/main/res/layout/video_player.xml b/app/src/main/res/layout/video_player.xml
deleted file mode 100644
index e06e22d96..000000000
--- a/app/src/main/res/layout/video_player.xml
+++ /dev/null
@@ -1,20 +0,0 @@
-
-
-
-
-
-
-
-
diff --git a/app/src/main/res/menu/gallery.xml b/app/src/main/res/menu/gallery.xml
deleted file mode 100644
index b7df8f02e..000000000
--- a/app/src/main/res/menu/gallery.xml
+++ /dev/null
@@ -1,37 +0,0 @@
-
-
diff --git a/app/src/main/res/values/colors.xml b/app/src/main/res/values/colors.xml
index cf37f0342..5f23e813b 100644
--- a/app/src/main/res/values/colors.xml
+++ b/app/src/main/res/values/colors.xml
@@ -1,6 +1,7 @@
#cbe2ff
+ #1b69c5
#dde2e9
#181c1f
diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml
index 9aa6ae1eb..f1455d1f9 100644
--- a/app/src/main/res/values/strings.xml
+++ b/app/src/main/res/values/strings.xml
@@ -121,7 +121,7 @@
Cancel
Please capture a photo/video before trying to view them.
- Captured photos/videos not found
+ No captured photos/videos found
Failed to open camera due to an OS or hardware issue. Try rebooting.
Failed to load camera provider due to OS or hardware issue. Try rebooting.
@@ -188,9 +188,41 @@
Uses Zero Shutter Lag (ZSL) in Latency mode for faster capture. Certain devices may have a buggy implementation for this.
Unable to request for audio permission in between a recording
+ …
+ Search Images
+ Back
+
+ Do you wish to delete %d items?
+
+ Play Video
+ Unable to load media %s
+
+ Select Media
+ Select all items
+ %d selected
+
+ Delete items
+ Share items
+
+ Edit a copy
+ Edit a copy with
+ Delete Media
+ Show media info
+
+ Edit original image with
+
+ Unlock device
+
+ Select an item before performing any action
+ Error
+
+ Failed to delete %d/%d items
+
Tap to mute audio
Tap to unmute audio
The video\'s audio recording has been muted
The video\'s audio recording has been unmuted
+
+ Grid View
diff --git a/build.gradle.kts b/build.gradle.kts
index 2a4259bde..3bc59fdff 100644
--- a/build.gradle.kts
+++ b/build.gradle.kts
@@ -1,6 +1,7 @@
plugins {
id("com.android.application") version "8.8.1" apply false
id("org.jetbrains.kotlin.android") version "2.1.10" apply false
+ id("org.jetbrains.kotlin.plugin.serialization") version "2.0.21" apply false
}
allprojects {
diff --git a/gradle/verification-metadata.xml b/gradle/verification-metadata.xml
index ff47ed91d..1e2f7fbe6 100644
--- a/gradle/verification-metadata.xml
+++ b/gradle/verification-metadata.xml
@@ -7,85 +7,162 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
@@ -99,70 +176,157 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
@@ -171,85 +335,769 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
@@ -258,96 +1106,169 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
@@ -356,45 +1277,81 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
@@ -403,16 +1360,55 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
@@ -421,16 +1417,29 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
@@ -439,16 +1448,39 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
@@ -457,16 +1489,29 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
@@ -475,16 +1520,79 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
@@ -493,16 +1601,85 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
@@ -511,266 +1688,708 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
-
-
-
+
+
+
+
+
+
+
+
+
+
+
+
-
-
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
@@ -784,795 +2403,1444 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
-
-
+
+
-
-
+
+
-
-
-
+
+
+
-
-
+
+
-
-
-
+
+
+
-
-
+
+
+
+
+
+
+
+
+
+
+
+
+
+
-
-
-
+
+
+
-
-
+
+
-
-
-
+
+
+
-
-
+
+
-
-
-
+
+
+
-
-
+
+
-
-
+
+
-
-
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
@@ -1581,337 +3849,583 @@
+
+
+
+
-
-
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
-
-
-
+
+
+
+
-
-
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
@@ -1925,415 +4439,1387 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
-
-
-
+
+
+
-
-
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
-
-
-
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
-
-
+
+
+
-
-
-
+
+
+
+
-
-
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
@@ -2353,19 +5839,52 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
@@ -2385,59 +5904,191 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
@@ -2446,6 +6097,22 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
@@ -2456,45 +6123,100 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
@@ -2503,14 +6225,101 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
@@ -2519,6 +6328,7 @@
+
@@ -2527,92 +6337,251 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+