diff options
| author | 2024-02-08 18:18:57 -0800 | |
|---|---|---|
| committer | 2024-02-09 11:11:39 -0800 | |
| commit | abc82dc47cbc5e878493b17e5479044c185ed88d (patch) | |
| tree | f449701938e495b8d7abc51e020352314a9ad577 /java/src | |
| parent | 6bbc4826920506943bd7a286ff94eceba9b34251 (diff) | |
Add PayloadToggleInteractor implementation
Bug: 302691505
Test: IntentResolver-tests-unit
Change-Id: I0d568a6c30781e81a65b33e8e8ae46e3def23bb9
Diffstat (limited to 'java/src')
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) }, ) } |