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()
+}