From 6bbc4826920506943bd7a286ff94eceba9b34251 Mon Sep 17 00:00:00 2001 From: Andrey Epin Date: Thu, 8 Feb 2024 13:07:14 -0800 Subject: Shareousel selection tracker component A building block for the payload toggling functionality. A component to track items selection and their relative order. The order is specified by the set of the initially selected items and can be overriden by the order on items in the cursor (in case the cursor and the set of initilly selected items is not in sync, which is not expected). Bug: 302691505 Test: IntentResolver-tests-unit Change-Id: I092d9b678c7fbcdc8303e04de0cacbb9d125fa5f --- .../contentpreview/CursorUriReader.kt | 2 +- .../contentpreview/SelectionTracker.kt | 175 +++++++++++++++++++++ 2 files changed, 176 insertions(+), 1 deletion(-) create mode 100644 java/src/com/android/intentresolver/contentpreview/SelectionTracker.kt (limited to 'java/src') diff --git a/java/src/com/android/intentresolver/contentpreview/CursorUriReader.kt b/java/src/com/android/intentresolver/contentpreview/CursorUriReader.kt index 30495b8b..91983635 100644 --- a/java/src/com/android/intentresolver/contentpreview/CursorUriReader.kt +++ b/java/src/com/android/intentresolver/contentpreview/CursorUriReader.kt @@ -74,7 +74,7 @@ class CursorUriReader( return SparseArray() } val result = SparseArray(leftPos - startPos) - for (pos in startPos ..< leftPos) { + for (pos in startPos until leftPos) { cursor .getString(0) ?.let(Uri::parse) diff --git a/java/src/com/android/intentresolver/contentpreview/SelectionTracker.kt b/java/src/com/android/intentresolver/contentpreview/SelectionTracker.kt new file mode 100644 index 00000000..4ce006ec --- /dev/null +++ b/java/src/com/android/intentresolver/contentpreview/SelectionTracker.kt @@ -0,0 +1,175 @@ +/* + * Copyright 2024 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.android.intentresolver.contentpreview + +import android.net.Uri +import android.util.SparseArray +import android.util.SparseIntArray +import androidx.core.util.containsKey +import androidx.core.util.isNotEmpty + +/** + * Tracks selected items (including those that has not been read frm the cursor) and their relative + * order. + */ +class SelectionTracker( + selectedItems: List, + private val focusedItemIdx: Int, + private val cursorCount: Int, + private val getUri: Item.() -> Uri, +) { + /** Contains selected items keys. */ + private val selections = SparseArray(selectedItems.size) + + /** + * A set of initially selected items that has not yet been observed by the lazy read of the + * cursor and thus has unknown key (cursor position). Initially, all [selectedItems] are put in + * this map with items at the index less than [focusedItemIdx] with negative keys (to the left + * of all cursor items) and items at the index more or equal to [focusedItemIdx] with keys more + * or equal to [cursorCount] (to the right of all cursor items) in their relative order. Upon + * reading the cursor, [onEndItemsAdded]/[onStartItemsAdded], all pending items from that + * collection in the corresponding direction get their key assigned and gets removed from the + * map. Items that were missing from the cursor get removed from the map by + * [getPendingItems] + [onStartItemsAdded]/[onEndItemsAdded] combination. + */ + private val pendingKeys = HashMap() + + init { + selectedItems.forEachIndexed { i, item -> + // all items before focusedItemIdx gets "positioned" before all the cursor items + // and all the reset after all the cursor items in their relative order. + // Also see the comments to pendingKeys property. + val key = + if (i < focusedItemIdx) { + i - focusedItemIdx + } else { + i + cursorCount - focusedItemIdx + } + selections.append(key, item) + pendingKeys.getOrPut(item.getUri()) { SparseIntArray(1) }.append(key, key) + } + } + + /** Update selections based on the set of items read from the end of the cursor */ + fun onEndItemsAdded(items: SparseArray) { + for (i in 0 until items.size()) { + val item = items.valueAt(i) + pendingKeys[item.getUri()] + // if only one pending (unmatched) item with this URI is left, removed this URI + ?.also { + if (it.size() <= 1) { + pendingKeys.remove(item.getUri()) + } + } + // a safeguard, we should not observe empty arrays at this point + ?.takeIf { it.isNotEmpty() } + // pick a matching pending items from the right side + ?.let { pendingUriPositions -> + val key = items.keyAt(i) + val insertPos = + pendingUriPositions + .findBestKeyPosition(key) + .coerceIn(0, pendingUriPositions.size() - 1) + // select next pending item from the right, if not such item exists then + // the data is inconsistent and we pick the closes one from the left + val keyPlaceholder = pendingUriPositions.keyAt(insertPos) + pendingUriPositions.removeAt(insertPos) + selections.remove(keyPlaceholder) + selections[key] = item + } + } + } + + /** Update selections based on the set of items read from the head of the cursor */ + fun onStartItemsAdded(items: SparseArray) { + for (i in (items.size() - 1) downTo 0) { + val item = items.valueAt(i) + pendingKeys[item.getUri()] + // if only one pending (unmatched) item with this URI is left, removed this URI + ?.also { + if (it.size() <= 1) { + pendingKeys.remove(item.getUri()) + } + } + // a safeguard, we should not observe empty arrays at this point + ?.takeIf { it.isNotEmpty() } + // pick a matching pending items from the left side + ?.let { pendingUriPositions -> + val key = items.keyAt(i) + val insertPos = + pendingUriPositions + .findBestKeyPosition(key) + .coerceIn(1, pendingUriPositions.size()) + // select next pending item from the left, if not such item exists then + // the data is inconsistent and we pick the closes one from the right + val keyPlaceholder = pendingUriPositions.keyAt(insertPos - 1) + pendingUriPositions.removeAt(insertPos - 1) + selections.remove(keyPlaceholder) + selections[key] = item + } + } + } + + /** Updated selection status for the given item */ + fun setItemSelection(key: Int, item: Item, isSelected: Boolean): Boolean { + val idx = selections.indexOfKey(key) + if (isSelected && idx < 0) { + selections[key] = item + return true + } + if (!isSelected && idx >= 0) { + selections.removeAt(idx) + return true + } + return false + } + + /** Return selection status for the given item */ + fun isItemSelected(key: Int): Boolean = selections.containsKey(key) + + fun getSelection(): List = + buildList(selections.size()) { + for (i in 0 until selections.size()) { + add(selections.valueAt(i)) + } + } + + /** Return all selected items that has not yet been read from the cursor */ + fun getPendingItems(): List = + if (pendingKeys.isEmpty()) { + emptyList() + } else { + buildList { + for (i in 0 until selections.size()) { + val item = selections.valueAt(i) ?: continue + if (isPending(item, selections.keyAt(i))) { + add(item) + } + } + } + } + + private fun isPending(item: Item, key: Int): Boolean { + val keys = pendingKeys[item.getUri()] ?: return false + return keys.containsKey(key) + } + + private fun SparseIntArray.findBestKeyPosition(key: Int): Int = + // undocumented, but indexOfKey behaves in the same was as + // java.util.Collections#binarySearch() + indexOfKey(key).let { if (it < 0) it.inv() else it } +} -- cgit v1.2.3-59-g8ed1b From abc82dc47cbc5e878493b17e5479044c185ed88d Mon Sep 17 00:00:00 2001 From: Andrey Epin Date: Thu, 8 Feb 2024 18:18:57 -0800 Subject: Add PayloadToggleInteractor implementation Bug: 302691505 Test: IntentResolver-tests-unit Change-Id: I0d568a6c30781e81a65b33e8e8ae46e3def23bb9 --- .../contentpreview/CursorUriReader.kt | 6 +- .../contentpreview/PayloadToggleInteractor.kt | 357 +++++++++++++++++++-- .../contentpreview/PreviewViewModel.kt | 2 +- .../ui/composable/ShareouselComposable.kt | 3 +- .../shareousel/ui/viewmodel/ShareouselViewModel.kt | 4 +- .../contentpreview/PayloadToggleInteractorTest.kt | 95 ++++++ 6 files changed, 441 insertions(+), 26 deletions(-) create mode 100644 tests/unit/src/com/android/intentresolver/contentpreview/PayloadToggleInteractorTest.kt (limited to 'java/src') 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>(emptyMap()) // TODO: implement - private val selectedKeys = MutableStateFlow>(emptySet()) +class PayloadToggleInteractor( + // must use single-thread dispatcher (or we should enforce it with a lock) + private val scope: CoroutineScope, + private val initiallySharedUris: List, + private val focusedUriIdx: Int, + private val mimeTypeClassifier: MimeTypeClassifier, + private val cursorReaderProvider: suspend () -> CursorReader, + private val uriMetadataReader: (Uri) -> FileInfo, + private val targetIntentModifier: (List) -> Intent, + private val selectionCallback: (Intent) -> CallbackResult?, +) { + private var cursorDataRef = CompletableDeferred() + private val records = LinkedList() + private val prevPageLoadingGate = AtomicBoolean(true) + private val nextPageLoadingGate = AtomicBoolean(true) + private val notifySelectionJobRef = AtomicReference() + private val emptyState = + State( + emptyList(), + hasMoreItemsBefore = false, + hasMoreItemsAfter = false, + allowSelectionChange = false + ) + + private val stateFlowSource = MutableStateFlow(emptyState) + + val customActions = + MutableSharedFlow>(replay = 1, onBufferOverflow = DROP_LATEST) + + val stateFlow: Flow + get() = stateFlowSource.filter { it !== emptyState } + + val targetPosition: Flow = stateFlow.map { it.targetPos } + val previewKeys: Flow> = stateFlow.map { it.items } + + fun getKey(item: Any): Int = (item as Item).key + + fun selected(key: Item): Flow = (key as Record).isSelected - val targetPosition: Flow = flowOf(0) // TODO: implement - val previewKeys: Flow> = flowOf(emptyList()) // TODO: implement + fun previewUri(key: Item): Flow = 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 = 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 = 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()) { 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(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.toRecords(): SparseArray { + val items = SparseArray(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(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, + 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?) + private data class CursorData( + val reader: CursorReader, + val selectionTracker: SelectionTracker, + ) + 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 = interactor.previewUri(key) - val selected: Flow = interactor.selected(key) + val previewUri: Flow + get() = interactor.previewUri(item) + val selected: Flow + 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>, val centerIndex: Flow, 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) }, ) } diff --git a/tests/unit/src/com/android/intentresolver/contentpreview/PayloadToggleInteractorTest.kt b/tests/unit/src/com/android/intentresolver/contentpreview/PayloadToggleInteractorTest.kt new file mode 100644 index 00000000..472c2ba4 --- /dev/null +++ b/tests/unit/src/com/android/intentresolver/contentpreview/PayloadToggleInteractorTest.kt @@ -0,0 +1,95 @@ +/* + * Copyright 2024 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.android.intentresolver.contentpreview + +import android.content.Intent +import android.database.Cursor +import android.database.MatrixCursor +import android.net.Uri +import com.google.common.truth.Truth.assertThat +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.test.TestCoroutineScheduler +import kotlinx.coroutines.test.TestScope +import kotlinx.coroutines.test.runTest +import org.junit.Test + +class PayloadToggleInteractorTest { + private val scheduler = TestCoroutineScheduler() + private val testScope = TestScope(scheduler) + + @Test + fun initialState() = + testScope.runTest { + val cursorReader = CursorUriReader(createCursor(10), 2, 2) { true } + val testSubject = + PayloadToggleInteractor( + scope = testScope.backgroundScope, + initiallySharedUris = listOf(makeUri(0), makeUri(2), makeUri(5)), + focusedUriIdx = 1, + mimeTypeClassifier = DefaultMimeTypeClassifier, + cursorReaderProvider = { cursorReader }, + uriMetadataReader = { uri -> + FileInfo.Builder(uri) + .withMimeType("image/png") + .withPreviewUri(uri) + .build() + }, + selectionCallback = { null }, + targetIntentModifier = { Intent(Intent.ACTION_SEND) }, + ) + .apply { start() } + + scheduler.runCurrent() + + testSubject.stateFlow.first().let { initialState -> + assertThat(initialState.items).hasSize(4) + assertThat(initialState.items.map { it.uri }) + .containsExactly(*Array(4, ::makeUri)) + .inOrder() + assertThat(initialState.hasMoreItemsBefore).isFalse() + assertThat(initialState.hasMoreItemsAfter).isTrue() + assertThat(initialState.allowSelectionChange).isTrue() + } + + testSubject.loadMoreNextItems() + // this one is expected to be deduplicated + testSubject.loadMoreNextItems() + scheduler.runCurrent() + + testSubject.stateFlow.first().let { state -> + assertThat(state.items.map { it.uri }) + .containsExactly(*Array(6, ::makeUri)) + .inOrder() + assertThat(state.hasMoreItemsBefore).isFalse() + assertThat(state.hasMoreItemsAfter).isTrue() + assertThat(state.allowSelectionChange).isTrue() + assertThat(state.items.map { testSubject.selected(it).first() }) + .containsExactly(true, false, true, false, false, true) + .inOrder() + } + } +} + +private fun createCursor(count: Int): Cursor { + return MatrixCursor(arrayOf("uri")).apply { + for (i in 0 until count) { + addRow(arrayOf(makeUri(i))) + } + } +} + +private fun makeUri(id: Int) = Uri.parse("content://org.pkg.app/img-$id.png") -- cgit v1.2.3-59-g8ed1b