Aperture: Initial captured media flow

Co-authored-by: Luca Stefani <luca.stefani.ge1@gmail.com>
Change-Id: I7bf7097aa7f65c898120ec5dba517094b071c437
diff --git a/app/src/main/java/org/lineageos/aperture/ext/ContentResolver.kt b/app/src/main/java/org/lineageos/aperture/ext/ContentResolver.kt
new file mode 100644
index 0000000..b709b6c
--- /dev/null
+++ b/app/src/main/java/org/lineageos/aperture/ext/ContentResolver.kt
@@ -0,0 +1,68 @@
+/*
+ * SPDX-FileCopyrightText: 2023 The LineageOS Project
+ * SPDX-License-Identifier: Apache-2.0
+ */
+
+package org.lineageos.aperture.ext
+
+import android.content.ContentResolver
+import android.database.ContentObserver
+import android.net.Uri
+import android.os.Bundle
+import android.os.CancellationSignal
+import android.os.Handler
+import android.os.Looper
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.channels.awaitClose
+import kotlinx.coroutines.flow.callbackFlow
+import kotlinx.coroutines.flow.conflate
+import kotlinx.coroutines.launch
+import kotlinx.coroutines.sync.Mutex
+import kotlinx.coroutines.sync.withLock
+
+fun ContentResolver.queryFlow(
+    uri: Uri,
+    projection: Array<String>? = null,
+    queryArgs: Bundle? = Bundle(),
+) = callbackFlow {
+    // Each query will have its own cancellationSignal.
+    // Before running any new query the old cancellationSignal must be cancelled
+    // to ensure the currently running query gets interrupted so that we don't
+    // send data across the channel if we know we received a newer set of data.
+    var cancellationSignal = CancellationSignal()
+    // ContentObserver.onChange can be called concurrently so make sure
+    // access to the cancellationSignal is synchronized.
+    val mutex = Mutex()
+
+    val observer = object : ContentObserver(Handler(Looper.getMainLooper())) {
+        override fun onChange(selfChange: Boolean) {
+            launch(Dispatchers.IO) {
+                mutex.withLock {
+                    cancellationSignal.cancel()
+                    cancellationSignal = CancellationSignal()
+                }
+                runCatching {
+                    trySend(query(uri, projection, queryArgs, cancellationSignal))
+                }
+            }
+        }
+    }
+
+    registerContentObserver(uri, true, observer)
+
+    // The first set of values must always be generated and cannot (shouldn't) be cancelled.
+    launch(Dispatchers.IO) {
+        runCatching {
+            trySend(
+                query(uri, projection, queryArgs, null)
+            )
+        }
+    }
+
+    awaitClose {
+        // Stop receiving content changes.
+        unregisterContentObserver(observer)
+        // Cancel any possibly running query.
+        cancellationSignal.cancel()
+    }
+}.conflate()
diff --git a/app/src/main/java/org/lineageos/aperture/ext/Cursor.kt b/app/src/main/java/org/lineageos/aperture/ext/Cursor.kt
new file mode 100644
index 0000000..32da8ae
--- /dev/null
+++ b/app/src/main/java/org/lineageos/aperture/ext/Cursor.kt
@@ -0,0 +1,28 @@
+/*
+ * SPDX-FileCopyrightText: 2023 The LineageOS Project
+ * SPDX-License-Identifier: Apache-2.0
+ */
+
+package org.lineageos.aperture.ext
+
+import android.database.Cursor
+
+fun <T> Cursor?.mapEachRow(
+    projection: Array<String>,
+    mapping: (Cursor, Array<Int>) -> T,
+) = this?.use { cursor ->
+    if (!cursor.moveToFirst()) {
+        return@use emptyList<T>()
+    }
+
+    val indexCache = projection.map { column ->
+        cursor.getColumnIndexOrThrow(column)
+    }.toTypedArray()
+
+    val data = mutableListOf<T>()
+    do {
+        data.add(mapping(cursor, indexCache))
+    } while (cursor.moveToNext())
+
+    data.toList()
+} ?: emptyList()
diff --git a/app/src/main/java/org/lineageos/aperture/ext/Flow.kt b/app/src/main/java/org/lineageos/aperture/ext/Flow.kt
new file mode 100644
index 0000000..ac42e88
--- /dev/null
+++ b/app/src/main/java/org/lineageos/aperture/ext/Flow.kt
@@ -0,0 +1,15 @@
+/*
+ * SPDX-FileCopyrightText: 2023 The LineageOS Project
+ * SPDX-License-Identifier: Apache-2.0
+ */
+
+package org.lineageos.aperture.ext
+
+import android.database.Cursor
+import kotlinx.coroutines.flow.Flow
+import kotlinx.coroutines.flow.map
+
+fun <T> Flow<Cursor?>.mapEachRow(
+    projection: Array<String>,
+    mapping: (Cursor, Array<Int>) -> T,
+) = map { it.mapEachRow(projection, mapping) }
diff --git a/app/src/main/java/org/lineageos/aperture/flow/CapturedMediaFlow.kt b/app/src/main/java/org/lineageos/aperture/flow/CapturedMediaFlow.kt
new file mode 100644
index 0000000..d36859a
--- /dev/null
+++ b/app/src/main/java/org/lineageos/aperture/flow/CapturedMediaFlow.kt
@@ -0,0 +1,70 @@
+/*
+ * SPDX-FileCopyrightText: 2024 The LineageOS Project
+ * SPDX-License-Identifier: Apache-2.0
+ */
+
+package org.lineageos.aperture.flow
+
+import android.content.ContentResolver
+import android.content.ContentUris
+import android.content.Context
+import android.net.Uri
+import android.os.Build
+import android.provider.MediaStore
+import androidx.core.os.bundleOf
+import org.lineageos.aperture.ext.*
+import org.lineageos.aperture.query.*
+
+class CapturedMediaFlow(private val context: Context) : QueryFlow<Uri> {
+    override fun flowCursor() = context.contentResolver.queryFlow(
+        MediaStore.Files.getContentUri(
+            if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
+                MediaStore.VOLUME_EXTERNAL
+            } else {
+                // ¯\_(ツ)_/¯
+                "external"
+            }
+        ),
+        arrayOf(
+            MediaStore.Files.FileColumns._ID,
+            MediaStore.Files.FileColumns.MEDIA_TYPE,
+        ),
+        bundleOf(
+            ContentResolver.QUERY_ARG_SQL_SELECTION to listOfNotNull(
+                MediaStore.Files.FileColumns.MEDIA_TYPE `in` listOf(
+                    MediaStore.Files.FileColumns.MEDIA_TYPE_IMAGE,
+                    MediaStore.Files.FileColumns.MEDIA_TYPE_VIDEO,
+                ),
+                MediaStore.Files.FileColumns.OWNER_PACKAGE_NAME eq Query.ARG,
+            ).join(Query::and)?.build(),
+            ContentResolver.QUERY_ARG_SQL_SELECTION_ARGS to arrayOf(
+                context.packageName,
+            ),
+            ContentResolver.QUERY_ARG_SQL_SORT_ORDER to
+                    "${MediaStore.Files.FileColumns.DATE_ADDED} DESC",
+        )
+    )
+
+    override fun flowData() = flowCursor().mapEachRow(
+        arrayOf(
+            MediaStore.Files.FileColumns._ID,
+            MediaStore.Files.FileColumns.MEDIA_TYPE,
+        )
+    ) { it, indexCache ->
+        var i = 0
+
+        val id = it.getLong(indexCache[i++])
+
+        val externalContentUri = when (val mediaType = it.getInt(indexCache[i++])) {
+            MediaStore.Files.FileColumns.MEDIA_TYPE_IMAGE ->
+                MediaStore.Images.Media.EXTERNAL_CONTENT_URI
+
+            MediaStore.Files.FileColumns.MEDIA_TYPE_VIDEO ->
+                MediaStore.Video.Media.EXTERNAL_CONTENT_URI
+
+            else -> throw Exception("Invalid media type: $mediaType")
+        }
+
+        return@mapEachRow ContentUris.withAppendedId(externalContentUri, id)
+    }
+}
diff --git a/app/src/main/java/org/lineageos/aperture/flow/QueryFlow.kt b/app/src/main/java/org/lineageos/aperture/flow/QueryFlow.kt
new file mode 100644
index 0000000..28e7d2a
--- /dev/null
+++ b/app/src/main/java/org/lineageos/aperture/flow/QueryFlow.kt
@@ -0,0 +1,21 @@
+/*
+ * SPDX-FileCopyrightText: 2023-2024 The LineageOS Project
+ * SPDX-License-Identifier: Apache-2.0
+ */
+
+package org.lineageos.aperture.flow
+
+import android.database.Cursor
+import kotlinx.coroutines.flow.Flow
+
+interface QueryFlow<T> {
+    /**
+     * A flow of the data specified by the query
+     */
+    fun flowData(): Flow<List<T>>
+
+    /**
+     * A flow of the cursor specified by the query
+     */
+    fun flowCursor(): Flow<Cursor?>
+}
diff --git a/app/src/main/java/org/lineageos/aperture/query/Query.kt b/app/src/main/java/org/lineageos/aperture/query/Query.kt
new file mode 100644
index 0000000..846e57f
--- /dev/null
+++ b/app/src/main/java/org/lineageos/aperture/query/Query.kt
@@ -0,0 +1,45 @@
+/*
+ * SPDX-FileCopyrightText: 2023-2024 The LineageOS Project
+ * SPDX-License-Identifier: Apache-2.0
+ */
+
+package org.lineageos.aperture.query
+
+typealias Column = String
+
+sealed interface Node {
+    fun build(): String = when (this) {
+        is Eq -> "${lhs.build()} = ${rhs.build()}"
+        is Or -> "(${lhs.build()}) OR (${rhs.build()})"
+        is And -> "(${lhs.build()}) AND (${rhs.build()})"
+        is Literal<*> -> "$`val`"
+        is In<*> -> "$value IN (${values.joinToString(", ")})"
+        is Not -> "NOT (${node.build()})"
+    }
+}
+
+private class Eq(val lhs: Node, val rhs: Node) : Node
+private class Or(val lhs: Node, val rhs: Node) : Node
+private class And(val lhs: Node, val rhs: Node) : Node
+private class Literal<T>(val `val`: T) : Node
+private class In<T>(val value: T, val values: Collection<T>) : Node
+private class Not(val node: Node) : Node
+
+class Query(val root: Node) {
+    fun build() = root.build()
+
+    companion object {
+        const val ARG = "?"
+    }
+}
+
+infix fun Query.or(other: Query) = Query(Or(this.root, other.root))
+infix fun Query.and(other: Query) = Query(And(this.root, other.root))
+infix fun Query.eq(other: Query) = Query(Eq(this.root, other.root))
+infix fun <T> Column.eq(other: T) = Query(Literal(this)) eq Query(Literal(other))
+infix fun <T> Column.`in`(values: Collection<T>) = Query(In(this, values))
+operator fun Query.not() = Query(Not(root))
+
+fun Iterable<Query>.join(
+    func: Query.(other: Query) -> Query,
+) = reduceOrNull(func)
diff --git a/app/src/main/java/org/lineageos/aperture/repository/MediaRepository.kt b/app/src/main/java/org/lineageos/aperture/repository/MediaRepository.kt
new file mode 100644
index 0000000..383add1
--- /dev/null
+++ b/app/src/main/java/org/lineageos/aperture/repository/MediaRepository.kt
@@ -0,0 +1,15 @@
+/*
+ * SPDX-FileCopyrightText: 2024 The LineageOS Project
+ * SPDX-License-Identifier: Apache-2.0
+ */
+
+package org.lineageos.aperture.repository
+
+import android.content.Context
+import org.lineageos.aperture.flow.CapturedMediaFlow
+
+object MediaRepository {
+    fun capturedMedia(
+        context: Context,
+    ) = CapturedMediaFlow(context).flowData()
+}