summaryrefslogtreecommitdiff
path: root/java/src
diff options
context:
space:
mode:
author Andrey Epin <ayepin@google.com> 2024-02-08 18:18:57 -0800
committer Andrey Epin <ayepin@google.com> 2024-02-09 11:11:39 -0800
commitabc82dc47cbc5e878493b17e5479044c185ed88d (patch)
treef449701938e495b8d7abc51e020352314a9ad577 /java/src
parent6bbc4826920506943bd7a286ff94eceba9b34251 (diff)
Add PayloadToggleInteractor implementation
Bug: 302691505 Test: IntentResolver-tests-unit Change-Id: I0d568a6c30781e81a65b33e8e8ae46e3def23bb9
Diffstat (limited to 'java/src')
-rw-r--r--java/src/com/android/intentresolver/contentpreview/CursorUriReader.kt6
-rw-r--r--java/src/com/android/intentresolver/contentpreview/PayloadToggleInteractor.kt357
-rw-r--r--java/src/com/android/intentresolver/contentpreview/PreviewViewModel.kt2
-rw-r--r--java/src/com/android/intentresolver/contentpreview/shareousel/ui/composable/ShareouselComposable.kt3
-rw-r--r--java/src/com/android/intentresolver/contentpreview/shareousel/ui/viewmodel/ShareouselViewModel.kt4
5 files changed, 346 insertions, 26 deletions
diff --git a/java/src/com/android/intentresolver/contentpreview/CursorUriReader.kt b/java/src/com/android/intentresolver/contentpreview/CursorUriReader.kt
index 91983635..dbf27a88 100644
--- a/java/src/com/android/intentresolver/contentpreview/CursorUriReader.kt
+++ b/java/src/com/android/intentresolver/contentpreview/CursorUriReader.kt
@@ -34,9 +34,11 @@ class CursorUriReader(
private val predicate: (Uri) -> Boolean,
) : PayloadToggleInteractor.CursorReader {
override val count = cursor.count
- // the first position of the next unread page on the right
+ // Unread ranges are:
+ // - left: [0, leftPos);
+ // - right: [rightPos, count)
+ // i.e. read range is: [leftPos, rightPos)
private var rightPos = startPos.coerceIn(0, count)
- // the first position of the next from the leftmost unread page on the left
private var leftPos = rightPos
override val hasMoreBefore
diff --git a/java/src/com/android/intentresolver/contentpreview/PayloadToggleInteractor.kt b/java/src/com/android/intentresolver/contentpreview/PayloadToggleInteractor.kt
index ca868226..3393dcfc 100644
--- a/java/src/com/android/intentresolver/contentpreview/PayloadToggleInteractor.kt
+++ b/java/src/com/android/intentresolver/contentpreview/PayloadToggleInteractor.kt
@@ -16,44 +16,357 @@
package com.android.intentresolver.contentpreview
+import android.content.Intent
import android.net.Uri
import android.service.chooser.ChooserAction
+import android.util.Log
import android.util.SparseArray
import java.io.Closeable
+import java.util.LinkedList
+import java.util.concurrent.atomic.AtomicBoolean
+import java.util.concurrent.atomic.AtomicReference
+import kotlinx.coroutines.CompletableDeferred
+import kotlinx.coroutines.CoroutineScope
+import kotlinx.coroutines.Job
+import kotlinx.coroutines.awaitCancellation
+import kotlinx.coroutines.channels.BufferOverflow.DROP_LATEST
import kotlinx.coroutines.flow.Flow
+import kotlinx.coroutines.flow.MutableSharedFlow
import kotlinx.coroutines.flow.MutableStateFlow
-import kotlinx.coroutines.flow.flowOf
+import kotlinx.coroutines.flow.filter
+import kotlinx.coroutines.flow.flow
import kotlinx.coroutines.flow.map
-import kotlinx.coroutines.flow.update
+import kotlinx.coroutines.launch
-class PayloadToggleInteractor {
+private const val TAG = "PayloadToggleInteractor"
- private val storage = MutableStateFlow<Map<Any, Item>>(emptyMap()) // TODO: implement
- private val selectedKeys = MutableStateFlow<Set<Any>>(emptySet())
+class PayloadToggleInteractor(
+ // must use single-thread dispatcher (or we should enforce it with a lock)
+ private val scope: CoroutineScope,
+ private val initiallySharedUris: List<Uri>,
+ private val focusedUriIdx: Int,
+ private val mimeTypeClassifier: MimeTypeClassifier,
+ private val cursorReaderProvider: suspend () -> CursorReader,
+ private val uriMetadataReader: (Uri) -> FileInfo,
+ private val targetIntentModifier: (List<Item>) -> Intent,
+ private val selectionCallback: (Intent) -> CallbackResult?,
+) {
+ private var cursorDataRef = CompletableDeferred<CursorData?>()
+ private val records = LinkedList<Record>()
+ private val prevPageLoadingGate = AtomicBoolean(true)
+ private val nextPageLoadingGate = AtomicBoolean(true)
+ private val notifySelectionJobRef = AtomicReference<Job?>()
+ private val emptyState =
+ State(
+ emptyList(),
+ hasMoreItemsBefore = false,
+ hasMoreItemsAfter = false,
+ allowSelectionChange = false
+ )
+
+ private val stateFlowSource = MutableStateFlow(emptyState)
+
+ val customActions =
+ MutableSharedFlow<List<ChooserAction>>(replay = 1, onBufferOverflow = DROP_LATEST)
+
+ val stateFlow: Flow<State>
+ get() = stateFlowSource.filter { it !== emptyState }
+
+ val targetPosition: Flow<Int> = stateFlow.map { it.targetPos }
+ val previewKeys: Flow<List<Item>> = stateFlow.map { it.items }
+
+ fun getKey(item: Any): Int = (item as Item).key
+
+ fun selected(key: Item): Flow<Boolean> = (key as Record).isSelected
- val targetPosition: Flow<Int> = flowOf(0) // TODO: implement
- val previewKeys: Flow<List<Any>> = flowOf(emptyList()) // TODO: implement
+ fun previewUri(key: Item): Flow<Uri?> = flow { emit(key.previewUri) }
- fun setSelected(key: Any, isSelected: Boolean) {
- if (isSelected) {
- selectedKeys.update { it + key }
+ fun previewInteractor(key: Any): PayloadTogglePreviewInteractor {
+ val state = stateFlowSource.value
+ if (state === emptyState) {
+ Log.wtf(TAG, "Requesting item preview before any item has been published")
} else {
- selectedKeys.update { it - key }
+ if (state.hasMoreItemsBefore && key === state.items.firstOrNull()) {
+ loadMorePreviousItems()
+ }
+ if (state.hasMoreItemsAfter && key == state.items.lastOrNull()) {
+ loadMoreNextItems()
+ }
}
+ return PayloadTogglePreviewInteractor(key as Item, this)
}
- fun selected(key: Any): Flow<Boolean> = previewKeys.map { key in it }
+ init {
+ scope
+ .launch { awaitCancellation() }
+ .invokeOnCompletion {
+ cursorDataRef.cancel()
+ runCatching {
+ if (cursorDataRef.isCompleted && !cursorDataRef.isCancelled) {
+ cursorDataRef.getCompleted()
+ } else {
+ null
+ }
+ }
+ .getOrNull()
+ ?.reader
+ ?.close()
+ }
+ }
- fun previewInteractor(key: Any) = PayloadTogglePreviewInteractor(key, this)
+ fun start() {
+ scope.launch {
+ publishInitialState()
+ val cursorReader = cursorReaderProvider()
+ val selectedItems =
+ initiallySharedUris.map { uri ->
+ val fileInfo = uriMetadataReader(uri)
+ Record(
+ 0, // artificial key for the pending record, it should not be used anywhere
+ uri,
+ fileInfo.previewUri,
+ fileInfo.mimeType,
+ )
+ }
+ val cursorData =
+ CursorData(
+ cursorReader,
+ SelectionTracker(selectedItems, focusedUriIdx, cursorReader.count) { uri },
+ )
+ if (cursorDataRef.complete(cursorData)) {
+ doLoadMorePreviousItems()
+ val startPos = records.size
+ doLoadMoreNextItems()
+ prevPageLoadingGate.set(false)
+ nextPageLoadingGate.set(false)
+ publishSnapshot(startPos)
+ } else {
+ cursorReader.close()
+ }
+ }
+ }
- fun previewUri(key: Any): Flow<Uri?> = storage.map { it[key]?.previewUri }
+ private suspend fun publishInitialState() {
+ stateFlowSource.emit(
+ State(
+ if (0 <= focusedUriIdx && focusedUriIdx < initiallySharedUris.size) {
+ val fileInfo = uriMetadataReader(initiallySharedUris[focusedUriIdx])
+ listOf(
+ Record(
+ // a unique key that won't appear anywhere after more items are loaded
+ -initiallySharedUris.size - 1,
+ initiallySharedUris[focusedUriIdx],
+ fileInfo.previewUri,
+ fileInfo.mimeType,
+ fileInfo.mimeType?.mimeTypeToItemType() ?: ItemType.File,
+ ),
+ )
+ } else {
+ emptyList()
+ },
+ hasMoreItemsBefore = true,
+ hasMoreItemsAfter = true,
+ allowSelectionChange = false,
+ )
+ )
+ }
+
+ fun loadMorePreviousItems() {
+ invokeAsyncIfNotRunning(prevPageLoadingGate) {
+ doLoadMorePreviousItems()
+ publishSnapshot()
+ }
+ }
+
+ fun loadMoreNextItems() {
+ invokeAsyncIfNotRunning(nextPageLoadingGate) {
+ doLoadMoreNextItems()
+ publishSnapshot()
+ }
+ }
+
+ fun setSelected(item: Item, isSelected: Boolean) {
+ val record = item as Record
+ record.isSelected.value = isSelected
+ scope.launch {
+ val (_, selectionTracker) = waitForCursorData() ?: return@launch
+ selectionTracker.setItemSelection(record.key, record, isSelected)
+ val targetIntent = targetIntentModifier(selectionTracker.getSelection())
+
+ val newJob = scope.launch { notifySelectionChanged(targetIntent) }
+ notifySelectionJobRef.getAndSet(newJob)?.cancel()
+ }
+ }
+
+ private fun invokeAsyncIfNotRunning(guardingFlag: AtomicBoolean, block: suspend () -> Unit) {
+ if (guardingFlag.compareAndSet(false, true)) {
+ scope.launch { block() }.invokeOnCompletion { guardingFlag.set(false) }
+ }
+ }
+
+ private suspend fun doLoadMorePreviousItems() {
+ val (reader, selectionTracker) = waitForCursorData() ?: return
+ if (!reader.hasMoreBefore) return
+
+ val newItems = reader.readPageBefore().toRecords()
+ selectionTracker.onStartItemsAdded(newItems)
+ for (i in newItems.size() - 1 downTo 0) {
+ records.add(
+ 0,
+ (newItems.valueAt(i) as Record).apply {
+ isSelected.value = selectionTracker.isItemSelected(key)
+ }
+ )
+ }
+ if (!reader.hasMoreBefore && !reader.hasMoreAfter) {
+ val pendingItems = selectionTracker.getPendingItems()
+ val newRecords =
+ pendingItems.foldIndexed(SparseArray<Item>()) { idx, acc, item ->
+ assert(item is Record) { "Unexpected pending item type: ${item.javaClass}" }
+ val rec = item as Record
+ val key = idx - pendingItems.size
+ acc.append(
+ key,
+ Record(
+ key,
+ rec.uri,
+ rec.previewUri,
+ rec.mimeType,
+ rec.mimeType?.mimeTypeToItemType() ?: ItemType.File
+ )
+ )
+ acc
+ }
- private data class Item(
- val previewUri: Uri?,
+ selectionTracker.onStartItemsAdded(newRecords)
+ for (i in (newRecords.size() - 1) downTo 0) {
+ records.add(0, (newRecords.valueAt(i) as Record).apply { isSelected.value = true })
+ }
+ }
+ }
+
+ private suspend fun doLoadMoreNextItems() {
+ val (reader, selectionTracker) = waitForCursorData() ?: return
+ if (!reader.hasMoreAfter) return
+
+ val newItems = reader.readPageAfter().toRecords()
+ selectionTracker.onEndItemsAdded(newItems)
+ for (i in 0 until newItems.size()) {
+ val key = newItems.keyAt(i)
+ records.add(
+ (newItems.valueAt(i) as Record).apply {
+ isSelected.value = selectionTracker.isItemSelected(key)
+ }
+ )
+ }
+ if (!reader.hasMoreBefore && !reader.hasMoreAfter) {
+ val items =
+ selectionTracker.getPendingItems().let { items ->
+ items.foldIndexed(SparseArray<Item>(items.size)) { i, acc, item ->
+ val key = reader.count + i
+ val record = item as Record
+ acc.append(
+ key,
+ Record(key, record.uri, record.previewUri, record.mimeType, record.type)
+ )
+ acc
+ }
+ }
+ selectionTracker.onEndItemsAdded(items)
+ for (i in 0 until items.size()) {
+ records.add((items.valueAt(i) as Record).apply { isSelected.value = true })
+ }
+ }
+ }
+
+ private fun SparseArray<Uri>.toRecords(): SparseArray<Item> {
+ val items = SparseArray<Item>(size())
+ for (i in 0 until size()) {
+ val key = keyAt(i)
+ val uri = valueAt(i)
+ val fileInfo = uriMetadataReader(uri)
+ items.append(
+ key,
+ Record(
+ key,
+ uri,
+ fileInfo.previewUri,
+ fileInfo.mimeType,
+ fileInfo.mimeType?.mimeTypeToItemType() ?: ItemType.File
+ )
+ )
+ }
+ return items
+ }
+
+ private suspend fun waitForCursorData() = cursorDataRef.await()
+
+ private fun notifySelectionChanged(targetIntent: Intent) {
+ selectionCallback(targetIntent)?.customActions?.let { customActions.tryEmit(it) }
+ }
+
+ private suspend fun publishSnapshot(startPos: Int = -1) {
+ val (reader, _) = waitForCursorData() ?: return
+ // TODO: publish a view into the list as it can only grow on each side thus a view won't be
+ // invalidated
+ val items = ArrayList<Item>(records)
+ stateFlowSource.emit(
+ State(
+ items,
+ reader.hasMoreBefore,
+ reader.hasMoreAfter,
+ allowSelectionChange = true,
+ targetPos = startPos,
+ )
+ )
+ }
+
+ private fun String.mimeTypeToItemType(): ItemType =
+ when {
+ mimeTypeClassifier.isImageType(this) -> ItemType.Image
+ mimeTypeClassifier.isVideoType(this) -> ItemType.Video
+ else -> ItemType.File
+ }
+
+ class State(
+ val items: List<Item>,
+ val hasMoreItemsBefore: Boolean,
+ val hasMoreItemsAfter: Boolean,
+ val allowSelectionChange: Boolean,
+ val targetPos: Int = -1,
)
+ sealed interface Item {
+ val key: Int
+ val uri: Uri
+ val previewUri: Uri?
+ val mimeType: String?
+ val type: ItemType
+ }
+
+ enum class ItemType {
+ Image,
+ Video,
+ File,
+ }
+
+ private class Record(
+ override val key: Int,
+ override val uri: Uri,
+ override val previewUri: Uri? = uri,
+ override val mimeType: String?,
+ override val type: ItemType = ItemType.Image,
+ ) : Item {
+ val isSelected = MutableStateFlow(false)
+ }
+
data class CallbackResult(val customActions: List<ChooserAction>?)
+ private data class CursorData(
+ val reader: CursorReader,
+ val selectionTracker: SelectionTracker<Item>,
+ )
+
interface CursorReader : Closeable {
val count: Int
val hasMoreBefore: Boolean
@@ -66,13 +379,17 @@ class PayloadToggleInteractor {
}
class PayloadTogglePreviewInteractor(
- private val key: Any,
+ private val item: PayloadToggleInteractor.Item,
private val interactor: PayloadToggleInteractor,
) {
fun setSelected(selected: Boolean) {
- interactor.setSelected(key, selected)
+ interactor.setSelected(item, selected)
}
- val previewUri: Flow<Uri?> = interactor.previewUri(key)
- val selected: Flow<Boolean> = interactor.selected(key)
+ val previewUri: Flow<Uri?>
+ get() = interactor.previewUri(item)
+ val selected: Flow<Boolean>
+ get() = interactor.selected(item)
+ val key
+ get() = item.key
}
diff --git a/java/src/com/android/intentresolver/contentpreview/PreviewViewModel.kt b/java/src/com/android/intentresolver/contentpreview/PreviewViewModel.kt
index d855ea16..77cf0ac9 100644
--- a/java/src/com/android/intentresolver/contentpreview/PreviewViewModel.kt
+++ b/java/src/com/android/intentresolver/contentpreview/PreviewViewModel.kt
@@ -65,7 +65,7 @@ constructor(
}
override val payloadToggleInteractor: PayloadToggleInteractor? by lazy {
- PayloadToggleInteractor()
+ null // TODO: initialize PayloadToggleInteractor()
}
companion object {
diff --git a/java/src/com/android/intentresolver/contentpreview/shareousel/ui/composable/ShareouselComposable.kt b/java/src/com/android/intentresolver/contentpreview/shareousel/ui/composable/ShareouselComposable.kt
index c83c10b0..f636966e 100644
--- a/java/src/com/android/intentresolver/contentpreview/shareousel/ui/composable/ShareouselComposable.kt
+++ b/java/src/com/android/intentresolver/contentpreview/shareousel/ui/composable/ShareouselComposable.kt
@@ -15,7 +15,6 @@
*/
package com.android.intentresolver.contentpreview.shareousel.ui.composable
-import android.os.Parcelable
import androidx.compose.foundation.Image
import androidx.compose.foundation.border
import androidx.compose.foundation.clickable
@@ -64,7 +63,7 @@ fun Shareousel(viewModel: ShareouselViewModel) {
Modifier.fillMaxWidth()
.height(dimensionResource(R.dimen.chooser_preview_image_height_tall))
) {
- items(previewKeys, key = { (it as? Parcelable) ?: Unit }) { key ->
+ items(previewKeys, key = viewModel.previewRowKey) { key ->
ShareouselCard(viewModel.previewForKey(key))
}
}
diff --git a/java/src/com/android/intentresolver/contentpreview/shareousel/ui/viewmodel/ShareouselViewModel.kt b/java/src/com/android/intentresolver/contentpreview/shareousel/ui/viewmodel/ShareouselViewModel.kt
index 4592ea6d..ff22a6fd 100644
--- a/java/src/com/android/intentresolver/contentpreview/shareousel/ui/viewmodel/ShareouselViewModel.kt
+++ b/java/src/com/android/intentresolver/contentpreview/shareousel/ui/viewmodel/ShareouselViewModel.kt
@@ -29,6 +29,7 @@ data class ShareouselViewModel(
val actions: Flow<List<ActionChipViewModel>>,
val centerIndex: Flow<Int>,
val previewForKey: (key: Any) -> ShareouselImageViewModel,
+ val previewRowKey: (Any) -> Any
)
data class ActionChipViewModel(val label: String, val icon: ComposeIcon?, val onClick: () -> Unit)
@@ -56,6 +57,7 @@ fun PayloadToggleInteractor.toShareouselViewModel(imageLoader: ImageLoader): Sha
setSelected = { isSelected -> previewInteractor.setSelected(isSelected) },
onActionClick = {},
)
- }
+ },
+ previewRowKey = { getKey(it) },
)
}