diff options
29 files changed, 724 insertions, 1935 deletions
diff --git a/java/src/com/android/intentresolver/ChooserActivity.java b/java/src/com/android/intentresolver/ChooserActivity.java index 039fad56..e36e9df3 100644 --- a/java/src/com/android/intentresolver/ChooserActivity.java +++ b/java/src/com/android/intentresolver/ChooserActivity.java @@ -305,9 +305,7 @@ public class ChooserActivity extends Hilt_ChooserActivity implements .get(BasePreviewViewModel.class); previewViewModel.init( mChooserRequest.getTargetIntent(), - getIntent(), /*additionalContentUri = */ null, - /*focusedItemIdx = */ 0, /*isPayloadTogglingEnabled = */ false); mChooserContentPreviewUi = new ChooserContentPreviewUi( getCoroutineScope(getLifecycle()), diff --git a/java/src/com/android/intentresolver/contentpreview/BasePreviewViewModel.kt b/java/src/com/android/intentresolver/contentpreview/BasePreviewViewModel.kt index 21c909ea..dc36e584 100644 --- a/java/src/com/android/intentresolver/contentpreview/BasePreviewViewModel.kt +++ b/java/src/com/android/intentresolver/contentpreview/BasePreviewViewModel.kt @@ -25,14 +25,11 @@ import androidx.lifecycle.ViewModel abstract class BasePreviewViewModel : ViewModel() { @get:MainThread abstract val previewDataProvider: PreviewDataProvider @get:MainThread abstract val imageLoader: ImageLoader - abstract val payloadToggleInteractor: PayloadToggleInteractor? @MainThread abstract fun init( targetIntent: Intent, - chooserIntent: Intent, additionalContentUri: Uri?, - focusedItemIdx: Int, isPayloadTogglingEnabled: Boolean, ) } diff --git a/java/src/com/android/intentresolver/contentpreview/CursorUriReader.kt b/java/src/com/android/intentresolver/contentpreview/CursorUriReader.kt deleted file mode 100644 index 6a12f56c..00000000 --- a/java/src/com/android/intentresolver/contentpreview/CursorUriReader.kt +++ /dev/null @@ -1,147 +0,0 @@ -/* - * 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.ContentInterface -import android.content.Intent -import android.database.Cursor -import android.database.MatrixCursor -import android.net.Uri -import android.os.Bundle -import android.os.CancellationSignal -import android.service.chooser.AdditionalContentContract.Columns -import android.service.chooser.AdditionalContentContract.CursorExtraKeys -import android.util.Log -import android.util.SparseArray -import kotlinx.coroutines.CancellationException -import kotlinx.coroutines.coroutineScope - -private const val TAG = ContentPreviewUi.TAG - -/** - * A bi-directional cursor reader. Reads URI from the [cursor] starting from the given [startPos], - * filters items by [predicate]. - */ -class CursorUriReader( - private val cursor: Cursor, - startPos: Int, - private val pageSize: Int, - private val predicate: (Uri) -> Boolean, -) : PayloadToggleInteractor.CursorReader { - override val count = cursor.count - // Unread ranges are: - // - left: [0, leftPos); - // - right: [rightPos, count) - // i.e. read range is: [leftPos, rightPos) - private var rightPos = startPos.coerceIn(0, count) - private var leftPos = rightPos - - override val hasMoreBefore - get() = leftPos > 0 - - override val hasMoreAfter - get() = rightPos < count - - override fun readPageAfter(): SparseArray<Uri> { - if (!hasMoreAfter) return SparseArray() - if (!cursor.moveToPosition(rightPos)) { - rightPos = count - Log.w(TAG, "Failed to move the cursor to position $rightPos, stop reading the cursor") - return SparseArray() - } - val result = SparseArray<Uri>(pageSize) - do { - cursor - .getString(0) - ?.let(Uri::parse) - ?.takeIf { predicate(it) } - ?.let { uri -> result.append(rightPos, uri) } - rightPos++ - } while (result.size() < pageSize && cursor.moveToNext()) - maybeCloseCursor() - return result - } - - override fun readPageBefore(): SparseArray<Uri> { - if (!hasMoreBefore) return SparseArray() - val startPos = maxOf(0, leftPos - pageSize) - if (!cursor.moveToPosition(startPos)) { - leftPos = 0 - Log.w(TAG, "Failed to move the cursor to position $startPos, stop reading cursor") - return SparseArray() - } - val result = SparseArray<Uri>(leftPos - startPos) - for (pos in startPos until leftPos) { - cursor - .getString(0) - ?.let(Uri::parse) - ?.takeIf { predicate(it) } - ?.let { uri -> result.append(pos, uri) } - if (!cursor.moveToNext()) break - } - leftPos = startPos - maybeCloseCursor() - return result - } - - private fun maybeCloseCursor() { - if (!hasMoreBefore && !hasMoreAfter) { - close() - } - } - - override fun close() { - cursor.close() - } - - companion object { - suspend fun createCursorReader( - contentResolver: ContentInterface, - uri: Uri, - chooserIntent: Intent - ): CursorUriReader { - val cancellationSignal = CancellationSignal() - val cursor = - try { - coroutineScope { - runCatching { - contentResolver.query( - uri, - arrayOf(Columns.URI), - Bundle().apply { - putParcelable(Intent.EXTRA_INTENT, chooserIntent) - }, - cancellationSignal - ) - } - .getOrNull() - ?: MatrixCursor(arrayOf(Columns.URI)) - } - } catch (e: CancellationException) { - cancellationSignal.cancel() - throw e - } - return CursorUriReader( - cursor, - cursor.extras?.getInt(CursorExtraKeys.POSITION, 0) ?: 0, - 128, - ) { - it.authority != uri.authority - } - } - } -} diff --git a/java/src/com/android/intentresolver/contentpreview/HeadlineGeneratorImpl.kt b/java/src/com/android/intentresolver/contentpreview/HeadlineGeneratorImpl.kt index 6e126822..e92d9bc6 100644 --- a/java/src/com/android/intentresolver/contentpreview/HeadlineGeneratorImpl.kt +++ b/java/src/com/android/intentresolver/contentpreview/HeadlineGeneratorImpl.kt @@ -20,6 +20,12 @@ import android.content.Context import android.util.PluralsMessageFormatter import androidx.annotation.StringRes import com.android.intentresolver.R +import dagger.Binds +import dagger.Module +import dagger.hilt.InstallIn +import dagger.hilt.android.qualifiers.ApplicationContext +import dagger.hilt.components.SingletonComponent +import javax.inject.Inject private const val PLURALS_COUNT = "count" @@ -27,7 +33,11 @@ private const val PLURALS_COUNT = "count" * HeadlineGenerator generates the text to show at the top of the sharesheet as a brief description * of the content being shared. */ -class HeadlineGeneratorImpl(private val context: Context) : HeadlineGenerator { +class HeadlineGeneratorImpl +@Inject +constructor( + @ApplicationContext private val context: Context, +) : HeadlineGenerator { override fun getTextHeadline(text: CharSequence): String { return context.getString( getTemplateResource(text, R.string.sharing_link, R.string.sharing_text) @@ -100,3 +110,9 @@ class HeadlineGeneratorImpl(private val context: Context) : HeadlineGenerator { return if (text.toString().isHttpUri()) linkResource else nonLinkResource } } + +@Module +@InstallIn(SingletonComponent::class) +interface HeadlineGeneratorModule { + @Binds fun bind(impl: HeadlineGeneratorImpl): HeadlineGenerator +} diff --git a/java/src/com/android/intentresolver/contentpreview/PayloadToggleInteractor.kt b/java/src/com/android/intentresolver/contentpreview/PayloadToggleInteractor.kt deleted file mode 100644 index cc82c0a9..00000000 --- a/java/src/com/android/intentresolver/contentpreview/PayloadToggleInteractor.kt +++ /dev/null @@ -1,372 +0,0 @@ -/* - * 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.net.Uri -import android.service.chooser.ChooserAction -import android.util.Log -import android.util.SparseArray -import com.android.intentresolver.contentpreview.payloadtoggle.domain.update.SelectionChangeCallback.ShareouselUpdate -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.ExperimentalCoroutinesApi -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.filter -import kotlinx.coroutines.flow.flow -import kotlinx.coroutines.flow.map -import kotlinx.coroutines.launch - -private const val TAG = "PayloadToggleInteractor" - -@OptIn(ExperimentalCoroutinesApi::class) -class PayloadToggleInteractor( - // TODO: a single-thread dispatcher is currently expected. iterate on the synchronization logic. - 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: suspend (Intent) -> ShareouselUpdate?, -) { - 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 - - fun previewUri(key: Item): Flow<Uri?> = flow { emit(key.previewUri) } - - 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 { - if (state.hasMoreItemsBefore && key === state.items.firstOrNull()) { - loadMorePreviousItems() - } - if (state.hasMoreItemsAfter && key == state.items.lastOrNull()) { - loadMoreNextItems() - } - } - return PayloadTogglePreviewInteractor(key as Item, this) - } - - init { - scope - .launch { awaitCancellation() } - .invokeOnCompletion { - cursorDataRef.cancel() - runCatching { - if (cursorDataRef.isCompleted && !cursorDataRef.isCancelled) { - cursorDataRef.getCompleted() - } else { - null - } - } - .getOrNull() - ?.reader - ?.close() - } - } - - fun start() { - scope.launch { - 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 loadMorePreviousItems() { - invokeAsyncIfNotRunning(prevPageLoadingGate) { - doLoadMorePreviousItems() - publishSnapshot() - } - } - - fun loadMoreNextItems() { - invokeAsyncIfNotRunning(nextPageLoadingGate) { - doLoadMoreNextItems() - publishSnapshot() - } - } - - fun setSelected(item: Item, isSelected: Boolean) { - val record = item as Record - scope.launch { - val (_, selectionTracker) = waitForCursorData() ?: return@launch - if (selectionTracker.setItemSelection(record.key, record, isSelected)) { - val targetIntent = targetIntentModifier(selectionTracker.getSelection()) - val newJob = scope.launch { notifySelectionChanged(targetIntent) } - notifySelectionJobRef.getAndSet(newJob)?.cancel() - record.isSelected.value = selectionTracker.isItemSelected(record.key) - } - } - } - - 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().toItems() - 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 - } - - 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().toItems() - 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>.toItems(): 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 suspend 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) - } - - private data class CursorData( - val reader: CursorReader, - val selectionTracker: SelectionTracker<Item>, - ) - - interface CursorReader : Closeable { - val count: Int - val hasMoreBefore: Boolean - val hasMoreAfter: Boolean - - fun readPageAfter(): SparseArray<Uri> - - fun readPageBefore(): SparseArray<Uri> - } -} - -class PayloadTogglePreviewInteractor( - private val item: PayloadToggleInteractor.Item, - private val interactor: PayloadToggleInteractor, -) { - fun setSelected(selected: Boolean) { - interactor.setSelected(item, selected) - } - - 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 f79f0525..6a729945 100644 --- a/java/src/com/android/intentresolver/contentpreview/PreviewViewModel.kt +++ b/java/src/com/android/intentresolver/contentpreview/PreviewViewModel.kt @@ -27,13 +27,9 @@ import androidx.lifecycle.ViewModelProvider.AndroidViewModelFactory.Companion.AP import androidx.lifecycle.viewModelScope import androidx.lifecycle.viewmodel.CreationExtras import com.android.intentresolver.R -import com.android.intentresolver.contentpreview.payloadtoggle.domain.intent.TargetIntentModifierImpl -import com.android.intentresolver.contentpreview.payloadtoggle.domain.update.SelectionChangeCallbackImpl import com.android.intentresolver.inject.Background -import java.util.concurrent.Executors import kotlinx.coroutines.CoroutineDispatcher import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.asCoroutineDispatcher import kotlinx.coroutines.plus /** A view model for the preview logic */ @@ -44,9 +40,7 @@ class PreviewViewModel( @Background private val dispatcher: CoroutineDispatcher = Dispatchers.IO, ) : BasePreviewViewModel() { private var targetIntent: Intent? = null - private var chooserIntent: Intent? = null private var additionalContentUri: Uri? = null - private var focusedItemIdx: Int = 0 private var isPayloadTogglingEnabled = false override val previewDataProvider by lazy { @@ -69,64 +63,19 @@ class PreviewViewModel( ) } - override val payloadToggleInteractor: PayloadToggleInteractor? by lazy { - val targetIntent = requireNotNull(targetIntent) { "Not initialized" } - // TODO: replace with flags injection - if (!isPayloadTogglingEnabled) return@lazy null - createPayloadToggleInteractor( - additionalContentUri ?: return@lazy null, - targetIntent, - chooserIntent ?: return@lazy null, - ) - .apply { start() } - } - // TODO: make the view model injectable and inject these dependencies instead @MainThread override fun init( targetIntent: Intent, - chooserIntent: Intent, additionalContentUri: Uri?, - focusedItemIdx: Int, isPayloadTogglingEnabled: Boolean, ) { if (this.targetIntent != null) return this.targetIntent = targetIntent - this.chooserIntent = chooserIntent this.additionalContentUri = additionalContentUri - this.focusedItemIdx = focusedItemIdx this.isPayloadTogglingEnabled = isPayloadTogglingEnabled } - private fun createPayloadToggleInteractor( - contentProviderUri: Uri, - targetIntent: Intent, - chooserIntent: Intent, - ): PayloadToggleInteractor { - return PayloadToggleInteractor( - // TODO: update PayloadToggleInteractor to support multiple threads - viewModelScope + Executors.newSingleThreadScheduledExecutor().asCoroutineDispatcher(), - previewDataProvider.uris, - maxOf(0, minOf(focusedItemIdx, previewDataProvider.uriCount - 1)), - DefaultMimeTypeClassifier, - { - CursorUriReader.createCursorReader( - contentResolver, - contentProviderUri, - chooserIntent - ) - }, - UriMetadataReaderImpl(contentResolver, DefaultMimeTypeClassifier)::getMetadata, - TargetIntentModifierImpl<PayloadToggleInteractor.Item>( - targetIntent, - getUri = { uri }, - getMimeType = { mimeType }, - )::onSelectionChanged, - SelectionChangeCallbackImpl(contentProviderUri, chooserIntent, contentResolver):: - onSelectionChanged, - ) - } - companion object { val Factory: ViewModelProvider.Factory = object : ViewModelProvider.Factory { diff --git a/java/src/com/android/intentresolver/contentpreview/SelectionTracker.kt b/java/src/com/android/intentresolver/contentpreview/SelectionTracker.kt deleted file mode 100644 index c9431731..00000000 --- a/java/src/com/android/intentresolver/contentpreview/SelectionTracker.kt +++ /dev/null @@ -1,175 +0,0 @@ -/* - * 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<Item>( - selectedItems: List<Item>, - private val focusedItemIdx: Int, - private val cursorCount: Int, - private val getUri: Item.() -> Uri, -) { - /** Contains selected items keys. */ - private val selections = SparseArray<Item>(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<Uri, SparseIntArray>() - - 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<Item>) { - 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<Item>) { - 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.size() > 1) { - 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<Item> = - 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<Item> = - 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 } -} diff --git a/java/src/com/android/intentresolver/contentpreview/ShareouselContentPreviewUi.kt b/java/src/com/android/intentresolver/contentpreview/ShareouselContentPreviewUi.kt index 82c09986..80f7c25a 100644 --- a/java/src/com/android/intentresolver/contentpreview/ShareouselContentPreviewUi.kt +++ b/java/src/com/android/intentresolver/contentpreview/ShareouselContentPreviewUi.kt @@ -22,27 +22,18 @@ import android.view.ViewGroup import android.widget.TextView import androidx.annotation.VisibleForTesting import androidx.compose.foundation.isSystemInDarkTheme -import androidx.compose.foundation.layout.Spacer -import androidx.compose.foundation.layout.height import androidx.compose.material3.MaterialTheme import androidx.compose.material3.dynamicDarkColorScheme import androidx.compose.material3.dynamicLightColorScheme import androidx.compose.runtime.LaunchedEffect -import androidx.compose.runtime.getValue -import androidx.compose.runtime.mutableStateOf -import androidx.compose.runtime.remember -import androidx.compose.runtime.setValue -import androidx.compose.ui.Modifier import androidx.compose.ui.platform.ComposeView import androidx.compose.ui.platform.LocalContext -import androidx.compose.ui.res.dimensionResource -import androidx.lifecycle.viewModelScope import androidx.lifecycle.viewmodel.compose.viewModel import com.android.intentresolver.R import com.android.intentresolver.contentpreview.ChooserContentPreviewUi.ActionFactory -import com.android.intentresolver.contentpreview.shareousel.ui.composable.Shareousel -import com.android.intentresolver.contentpreview.shareousel.ui.viewmodel.ShareouselViewModel -import com.android.intentresolver.contentpreview.shareousel.ui.viewmodel.toShareouselViewModel +import com.android.intentresolver.contentpreview.payloadtoggle.app.viewmodel.ShareouselContentPreviewViewModel +import com.android.intentresolver.contentpreview.payloadtoggle.ui.composable.Shareousel +import com.android.intentresolver.contentpreview.payloadtoggle.ui.viewmodel.ShareouselViewModel @VisibleForTesting(otherwise = VisibleForTesting.PACKAGE_PRIVATE) class ShareouselContentPreviewUi( @@ -56,76 +47,48 @@ class ShareouselContentPreviewUi( layoutInflater: LayoutInflater, parent: ViewGroup, headlineViewParent: View?, - ): ViewGroup { - return displayInternal(parent, headlineViewParent).also { layout -> + ): ViewGroup = + displayInternal(parent, headlineViewParent).also { layout -> displayModifyShareAction(headlineViewParent ?: layout, actionFactory) } - } - private fun displayInternal( - parent: ViewGroup, - headlineViewParent: View?, - ): ViewGroup { + private fun displayInternal(parent: ViewGroup, headlineViewParent: View?): ViewGroup { if (headlineViewParent != null) { inflateHeadline(headlineViewParent) } - val composeView = - ComposeView(parent.context).apply { - setContent { - val vm: BasePreviewViewModel = viewModel() - val interactor = - requireNotNull(vm.payloadToggleInteractor) { "Should not be null" } + return ComposeView(parent.context).apply { + setContent { + val vm: ShareouselContentPreviewViewModel = viewModel() + val viewModel: ShareouselViewModel = vm.viewModel - var viewModel by remember { mutableStateOf<ShareouselViewModel?>(null) } - LaunchedEffect(Unit) { - viewModel = - interactor.toShareouselViewModel( - vm.imageLoader, - actionFactory, - vm.viewModelScope - ) - } + headlineViewParent?.let { + LaunchedEffect(viewModel) { bindHeadline(viewModel, headlineViewParent) } + } - headlineViewParent?.let { - viewModel?.let { viewModel -> - LaunchedEffect(viewModel) { - viewModel.headline.collect { headline -> - headlineViewParent - .findViewById<TextView>(R.id.headline) - ?.apply { - if (headline.isNotBlank()) { - text = headline - visibility = View.VISIBLE - } else { - visibility = View.GONE - } - } - } - } - } - } + MaterialTheme( + colorScheme = + if (isSystemInDarkTheme()) { + dynamicDarkColorScheme(LocalContext.current) + } else { + dynamicLightColorScheme(LocalContext.current) + }, + ) { + Shareousel(viewModel) + } + } + } + } - viewModel?.let { viewModel -> - MaterialTheme( - colorScheme = - if (isSystemInDarkTheme()) { - dynamicDarkColorScheme(LocalContext.current) - } else { - dynamicLightColorScheme(LocalContext.current) - }, - ) { - Shareousel(viewModel = viewModel) - } - } - ?: run { - Spacer( - Modifier.height( - dimensionResource(R.dimen.chooser_preview_image_height_tall) - ) - ) - } + private suspend fun bindHeadline(viewModel: ShareouselViewModel, headlineViewParent: View) { + viewModel.headline.collect { headline -> + headlineViewParent.findViewById<TextView>(R.id.headline)?.apply { + if (headline.isNotBlank()) { + text = headline + visibility = View.VISIBLE + } else { + visibility = View.GONE } } - return composeView + } } } diff --git a/java/src/com/android/intentresolver/contentpreview/payloadtoggle/app/viewmodel/ShareouselContentPreviewViewModel.kt b/java/src/com/android/intentresolver/contentpreview/payloadtoggle/app/viewmodel/ShareouselContentPreviewViewModel.kt new file mode 100644 index 00000000..479f0ec8 --- /dev/null +++ b/java/src/com/android/intentresolver/contentpreview/payloadtoggle/app/viewmodel/ShareouselContentPreviewViewModel.kt @@ -0,0 +1,46 @@ +/* + * Copyright (C) 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.payloadtoggle.app.viewmodel + +import androidx.lifecycle.ViewModel +import com.android.intentresolver.contentpreview.payloadtoggle.domain.interactor.FetchPreviewsInteractor +import com.android.intentresolver.contentpreview.payloadtoggle.domain.interactor.UpdateTargetIntentInteractor +import com.android.intentresolver.contentpreview.payloadtoggle.ui.viewmodel.ShareouselViewModel +import com.android.intentresolver.inject.Background +import com.android.intentresolver.inject.ViewModelOwned +import dagger.hilt.android.lifecycle.HiltViewModel +import javax.inject.Inject +import kotlinx.coroutines.CoroutineDispatcher +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.launch + +/** View-model for [com.android.intentresolver.contentpreview.ShareouselContentPreviewUi]. */ +@HiltViewModel +class ShareouselContentPreviewViewModel +@Inject +constructor( + val viewModel: ShareouselViewModel, + updateTargetIntentInteractor: UpdateTargetIntentInteractor, + fetchPreviewsInteractor: FetchPreviewsInteractor, + @Background private val bgDispatcher: CoroutineDispatcher, + @ViewModelOwned private val scope: CoroutineScope, +) : ViewModel() { + init { + scope.launch(bgDispatcher) { updateTargetIntentInteractor.launch() } + scope.launch(bgDispatcher) { fetchPreviewsInteractor.launch() } + } +} diff --git a/java/src/com/android/intentresolver/contentpreview/shareousel/ui/composable/ComposeIconComposable.kt b/java/src/com/android/intentresolver/contentpreview/payloadtoggle/ui/composable/ComposeIconComposable.kt index 87fb7618..38138225 100644 --- a/java/src/com/android/intentresolver/contentpreview/shareousel/ui/composable/ComposeIconComposable.kt +++ b/java/src/com/android/intentresolver/contentpreview/payloadtoggle/ui/composable/ComposeIconComposable.kt @@ -13,7 +13,7 @@ * See the License for the specific language governing permissions and * limitations under the License. */ -package com.android.intentresolver.contentpreview.shareousel.ui.composable +package com.android.intentresolver.contentpreview.payloadtoggle.ui.composable import android.content.Context import android.content.ContextWrapper @@ -21,6 +21,7 @@ import android.content.res.Resources import androidx.compose.foundation.Image import androidx.compose.runtime.Composable import androidx.compose.runtime.CompositionLocalProvider +import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.asImageBitmap import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.res.painterResource @@ -30,10 +31,11 @@ import com.android.intentresolver.icon.ComposeIcon import com.android.intentresolver.icon.ResourceIcon @Composable -fun Image(icon: ComposeIcon) { +fun Image(icon: ComposeIcon, modifier: Modifier = Modifier) { when (icon) { - is AdaptiveIcon -> Image(icon.wrapped) - is BitmapIcon -> Image(icon.bitmap.asImageBitmap(), contentDescription = null) + is AdaptiveIcon -> Image(icon.wrapped, modifier) + is BitmapIcon -> + Image(icon.bitmap.asImageBitmap(), contentDescription = null, modifier = modifier) is ResourceIcon -> { val localContext = LocalContext.current val wrappedContext: Context = @@ -41,7 +43,7 @@ fun Image(icon: ComposeIcon) { override fun getResources(): Resources = icon.res } CompositionLocalProvider(LocalContext provides wrappedContext) { - Image(painterResource(icon.resId), contentDescription = null) + Image(painterResource(icon.resId), contentDescription = null, modifier = modifier) } } } diff --git a/java/src/com/android/intentresolver/contentpreview/shareousel/ui/composable/ShareouselCardComposable.kt b/java/src/com/android/intentresolver/contentpreview/payloadtoggle/ui/composable/ShareouselCardComposable.kt index dc96e3c1..f33558c7 100644 --- a/java/src/com/android/intentresolver/contentpreview/shareousel/ui/composable/ShareouselCardComposable.kt +++ b/java/src/com/android/intentresolver/contentpreview/payloadtoggle/ui/composable/ShareouselCardComposable.kt @@ -13,7 +13,7 @@ * See the License for the specific language governing permissions and * limitations under the License. */ -package com.android.intentresolver.contentpreview.shareousel.ui.composable +package com.android.intentresolver.contentpreview.payloadtoggle.ui.composable import androidx.compose.foundation.background import androidx.compose.foundation.border @@ -33,10 +33,12 @@ import androidx.compose.ui.graphics.Color import androidx.compose.ui.res.painterResource import androidx.compose.ui.unit.dp import com.android.intentresolver.R +import com.android.intentresolver.contentpreview.payloadtoggle.ui.viewmodel.ContentType @Composable fun ShareouselCard( image: @Composable () -> Unit, + contentType: ContentType, selected: Boolean, modifier: Modifier = Modifier, ) { @@ -45,7 +47,9 @@ fun ShareouselCard( val topButtonPadding = 12.dp Box(modifier = Modifier.padding(topButtonPadding).matchParentSize()) { SelectionIcon(selected, modifier = Modifier.align(Alignment.TopStart)) - AnimationIcon(modifier = Modifier.align(Alignment.TopEnd)) + if (contentType == ContentType.Video) { + AnimationIcon(modifier = Modifier.align(Alignment.TopEnd)) + } } } } diff --git a/java/src/com/android/intentresolver/contentpreview/shareousel/ui/composable/ShareouselComposable.kt b/java/src/com/android/intentresolver/contentpreview/payloadtoggle/ui/composable/ShareouselComposable.kt index 0b3cdd83..feb6f3a8 100644 --- a/java/src/com/android/intentresolver/contentpreview/shareousel/ui/composable/ShareouselComposable.kt +++ b/java/src/com/android/intentresolver/contentpreview/payloadtoggle/ui/composable/ShareouselComposable.kt @@ -13,7 +13,7 @@ * See the License for the specific language governing permissions and * limitations under the License. */ -package com.android.intentresolver.contentpreview.shareousel.ui.composable +package com.android.intentresolver.contentpreview.payloadtoggle.ui.composable import androidx.compose.foundation.Image import androidx.compose.foundation.background @@ -24,10 +24,14 @@ import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.aspectRatio +import androidx.compose.foundation.layout.fillMaxHeight import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size import androidx.compose.foundation.lazy.LazyRow import androidx.compose.foundation.lazy.items +import androidx.compose.foundation.lazy.itemsIndexed import androidx.compose.foundation.lazy.rememberLazyListState import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material3.AssistChip @@ -35,65 +39,78 @@ import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue +import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.RectangleShape import androidx.compose.ui.graphics.asImageBitmap import androidx.compose.ui.layout.ContentScale import androidx.compose.ui.res.dimensionResource import androidx.compose.ui.unit.dp import androidx.lifecycle.compose.collectAsStateWithLifecycle import com.android.intentresolver.R -import com.android.intentresolver.contentpreview.shareousel.ui.viewmodel.ShareouselImageViewModel -import com.android.intentresolver.contentpreview.shareousel.ui.viewmodel.ShareouselViewModel +import com.android.intentresolver.contentpreview.payloadtoggle.shared.model.PreviewsModel +import com.android.intentresolver.contentpreview.payloadtoggle.ui.viewmodel.ContentType +import com.android.intentresolver.contentpreview.payloadtoggle.ui.viewmodel.ShareouselPreviewViewModel +import com.android.intentresolver.contentpreview.payloadtoggle.ui.viewmodel.ShareouselViewModel +import kotlinx.coroutines.launch @Composable fun Shareousel(viewModel: ShareouselViewModel) { - val centerIdx = viewModel.centerIndex.value - val carouselState = rememberLazyListState(initialFirstVisibleItemIndex = centerIdx) - val previewKeys by viewModel.previewKeys.collectAsStateWithLifecycle() - Column(modifier = Modifier.background(MaterialTheme.colorScheme.surfaceContainer)) { - // TODO: item needs to be centered, check out ScalingLazyColumn impl or see if - // HorizontalPager works for our use-case - LazyRow( - state = carouselState, - horizontalArrangement = Arrangement.spacedBy(4.dp), - modifier = - Modifier.fillMaxWidth() - .height(dimensionResource(R.dimen.chooser_preview_image_height_tall)) - ) { - items(previewKeys, key = viewModel.previewRowKey) { key -> - ShareouselCard(viewModel.previewForKey(key)) - } - } - Spacer(modifier = Modifier.height(8.dp)) + val keySet = viewModel.previews.collectAsStateWithLifecycle(null).value + if (keySet != null) { + Shareousel(viewModel, keySet) + } else { + Spacer( + Modifier.height(dimensionResource(R.dimen.chooser_preview_image_height_tall) + 64.dp) + ) + } +} - val actions by viewModel.actions.collectAsStateWithLifecycle(initialValue = emptyList()) - LazyRow( - horizontalArrangement = Arrangement.spacedBy(4.dp), - ) { - items(actions) { actionViewModel -> - ShareouselAction( - label = actionViewModel.label, - onClick = actionViewModel.onClick, - ) { - actionViewModel.icon?.let { Image(it) } - } - } - } +@Composable +private fun Shareousel(viewModel: ShareouselViewModel, keySet: PreviewsModel) { + Column( + modifier = + Modifier.background(MaterialTheme.colorScheme.surfaceContainer) + .padding(vertical = 16.dp), + ) { + PreviewCarousel(keySet, viewModel) + Spacer(Modifier.height(16.dp)) + ActionCarousel(viewModel) } } -private const val MIN_ASPECT_RATIO = 0.4f -private const val MAX_ASPECT_RATIO = 2.5f +@Composable +private fun PreviewCarousel( + previews: PreviewsModel, + viewModel: ShareouselViewModel, +) { + val centerIdx = previews.startIdx + val carouselState = rememberLazyListState(initialFirstVisibleItemIndex = centerIdx) + // TODO: start item needs to be centered, check out ScalingLazyColumn impl or see if + // HorizontalPager works for our use-case + LazyRow( + state = carouselState, + horizontalArrangement = Arrangement.spacedBy(4.dp), + modifier = + Modifier.fillMaxWidth() + .height(dimensionResource(R.dimen.chooser_preview_image_height_tall)) + ) { + items(previews.previewModels.toList(), key = { it.uri }) { model -> + ShareouselCard(viewModel.preview(model)) + } + } +} @Composable -private fun ShareouselCard(viewModel: ShareouselImageViewModel) { +private fun ShareouselCard(viewModel: ShareouselPreviewViewModel) { val bitmap by viewModel.bitmap.collectAsStateWithLifecycle(initialValue = null) val selected by viewModel.isSelected.collectAsStateWithLifecycle(initialValue = false) - val contentDescription by - viewModel.contentDescription.collectAsStateWithLifecycle(initialValue = null) + val contentType by + viewModel.contentType.collectAsStateWithLifecycle(initialValue = ContentType.Image) val borderColor = MaterialTheme.colorScheme.primary - + val scope = rememberCoroutineScope() ShareouselCard( image = { bitmap?.let { bitmap -> @@ -103,31 +120,55 @@ private fun ShareouselCard(viewModel: ShareouselImageViewModel) { .coerceIn(MIN_ASPECT_RATIO, MAX_ASPECT_RATIO) Image( bitmap = bitmap.asImageBitmap(), - contentDescription = contentDescription, + contentDescription = null, contentScale = ContentScale.Crop, modifier = Modifier.aspectRatio(aspectRatio), ) } ?: run { // TODO: look at ScrollableImagePreviewView.setLoading() - Box(modifier = Modifier.aspectRatio(2f / 5f)) + Box( + modifier = + Modifier.fillMaxHeight() + .aspectRatio(2f / 5f) + .border(1.dp, Color.Red, RectangleShape) + ) } }, + contentType = contentType, selected = selected, modifier = Modifier.thenIf(selected) { Modifier.border( width = 4.dp, color = borderColor, - shape = RoundedCornerShape(size = 12.dp) + shape = RoundedCornerShape(size = 12.dp), ) } .clip(RoundedCornerShape(size = 12.dp)) - .clickable { viewModel.setSelected(!selected) }, + .clickable { scope.launch { viewModel.setSelected(!selected) } }, ) } @Composable +private fun ActionCarousel(viewModel: ShareouselViewModel) { + val actions by viewModel.actions.collectAsStateWithLifecycle(initialValue = emptyList()) + LazyRow( + horizontalArrangement = Arrangement.spacedBy(4.dp), + modifier = Modifier.height(32.dp), + ) { + itemsIndexed(actions) { idx, actionViewModel -> + ShareouselAction( + label = actionViewModel.label, + onClick = { actionViewModel.onClicked() }, + ) { + actionViewModel.icon?.let { Image(icon = it, modifier = Modifier.size(16.dp)) } + } + } + } +} + +@Composable private fun ShareouselAction( label: String, onClick: () -> Unit, @@ -144,3 +185,6 @@ private fun ShareouselAction( inline fun Modifier.thenIf(condition: Boolean, crossinline factory: () -> Modifier): Modifier = if (condition) this.then(factory()) else this + +private const val MIN_ASPECT_RATIO = 0.4f +private const val MAX_ASPECT_RATIO = 2.5f diff --git a/java/src/com/android/intentresolver/contentpreview/MutableActionFactory.kt b/java/src/com/android/intentresolver/contentpreview/payloadtoggle/ui/viewmodel/ActionChipViewModel.kt index 1cc1a6a6..728c573b 100644 --- a/java/src/com/android/intentresolver/contentpreview/MutableActionFactory.kt +++ b/java/src/com/android/intentresolver/contentpreview/payloadtoggle/ui/viewmodel/ActionChipViewModel.kt @@ -1,5 +1,5 @@ /* - * Copyright 2024 The Android Open Source Project + * Copyright (C) 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. @@ -14,16 +14,16 @@ * limitations under the License. */ -package com.android.intentresolver.contentpreview +package com.android.intentresolver.contentpreview.payloadtoggle.ui.viewmodel -import android.service.chooser.ChooserAction -import com.android.intentresolver.widget.ActionRow -import kotlinx.coroutines.flow.Flow +import com.android.intentresolver.icon.ComposeIcon -interface MutableActionFactory { - /** A flow of custom actions */ - val customActionsFlow: Flow<List<ActionRow.Action>> - - /** Update custom actions */ - fun updateCustomActions(actions: List<ChooserAction>) -} +/** An action chip presented to the user underneath Shareousel. */ +data class ActionChipViewModel( + /** Text label. */ + val label: String, + /** Optional icon, displayed next to the text label. */ + val icon: ComposeIcon?, + /** Handles user clicks on this action in the UI. */ + val onClicked: () -> Unit, +) diff --git a/java/src/com/android/intentresolver/contentpreview/payloadtoggle/ui/viewmodel/ShareouselPreviewViewModel.kt b/java/src/com/android/intentresolver/contentpreview/payloadtoggle/ui/viewmodel/ShareouselPreviewViewModel.kt new file mode 100644 index 00000000..a245b3e3 --- /dev/null +++ b/java/src/com/android/intentresolver/contentpreview/payloadtoggle/ui/viewmodel/ShareouselPreviewViewModel.kt @@ -0,0 +1,39 @@ +/* + * Copyright (C) 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.payloadtoggle.ui.viewmodel + +import android.graphics.Bitmap +import kotlinx.coroutines.flow.Flow + +/** An individual preview within Shareousel. */ +data class ShareouselPreviewViewModel( + /** Image to be shared. */ + val bitmap: Flow<Bitmap?>, + /** Type of data to be shared. */ + val contentType: Flow<ContentType>, + /** Whether this preview has been selected by the user. */ + val isSelected: Flow<Boolean>, + /** Sets whether this preview has been selected by the user. */ + val setSelected: suspend (Boolean) -> Unit, +) + +/** Type of the content being previewed. */ +enum class ContentType { + Image, + Video, + Other +} diff --git a/java/src/com/android/intentresolver/contentpreview/payloadtoggle/ui/viewmodel/ShareouselViewModel.kt b/java/src/com/android/intentresolver/contentpreview/payloadtoggle/ui/viewmodel/ShareouselViewModel.kt new file mode 100644 index 00000000..6eccaffa --- /dev/null +++ b/java/src/com/android/intentresolver/contentpreview/payloadtoggle/ui/viewmodel/ShareouselViewModel.kt @@ -0,0 +1,141 @@ +/* + * Copyright (C) 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.payloadtoggle.ui.viewmodel + +import android.content.Context +import com.android.intentresolver.R +import com.android.intentresolver.contentpreview.HeadlineGenerator +import com.android.intentresolver.contentpreview.ImageLoader +import com.android.intentresolver.contentpreview.ImagePreviewImageLoader +import com.android.intentresolver.contentpreview.payloadtoggle.domain.cursor.PayloadToggle +import com.android.intentresolver.contentpreview.payloadtoggle.domain.interactor.CustomActionsInteractor +import com.android.intentresolver.contentpreview.payloadtoggle.domain.interactor.SelectablePreviewsInteractor +import com.android.intentresolver.contentpreview.payloadtoggle.domain.interactor.SelectionInteractor +import com.android.intentresolver.contentpreview.payloadtoggle.shared.model.PreviewModel +import com.android.intentresolver.contentpreview.payloadtoggle.shared.model.PreviewsModel +import com.android.intentresolver.inject.Background +import com.android.intentresolver.inject.ViewModelOwned +import dagger.Module +import dagger.Provides +import dagger.hilt.InstallIn +import dagger.hilt.android.components.ViewModelComponent +import dagger.hilt.android.qualifiers.ApplicationContext +import kotlinx.coroutines.CoroutineDispatcher +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.SharingStarted +import kotlinx.coroutines.flow.flow +import kotlinx.coroutines.flow.flowOf +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.flow.stateIn +import kotlinx.coroutines.plus + +/** A dynamic carousel of selectable previews within share sheet. */ +data class ShareouselViewModel( + /** Text displayed at the top of the share sheet when Shareousel is present. */ + val headline: Flow<String>, + /** + * Previews which are available for presentation within Shareousel. Use [preview] to create a + * [ShareouselPreviewViewModel] for a given [PreviewModel]. + */ + val previews: Flow<PreviewsModel?>, + /** List of action chips presented underneath Shareousel. */ + val actions: Flow<List<ActionChipViewModel>>, + /** Creates a [ShareouselPreviewViewModel] for a [PreviewModel] present in [previews]. */ + val preview: (key: PreviewModel) -> ShareouselPreviewViewModel, +) + +@Module +@InstallIn(ViewModelComponent::class) +object ShareouselViewModelModule { + @Provides + fun create( + interactor: SelectablePreviewsInteractor, + @PayloadToggle imageLoader: ImageLoader, + actionsInteractor: CustomActionsInteractor, + headlineGenerator: HeadlineGenerator, + selectionInteractor: SelectionInteractor, + // TODO: remove if possible + @ViewModelOwned scope: CoroutineScope, + ): ShareouselViewModel { + val keySet = + interactor.previews.stateIn( + scope, + SharingStarted.Eagerly, + initialValue = null, + ) + return ShareouselViewModel( + headline = + selectionInteractor.amountSelected.map { numItems -> + val contentType = ContentType.Image // TODO: convert from metadata + when (contentType) { + ContentType.Other -> headlineGenerator.getFilesHeadline(numItems) + ContentType.Image -> headlineGenerator.getImagesHeadline(numItems) + ContentType.Video -> headlineGenerator.getVideosHeadline(numItems) + } + }, + previews = keySet, + actions = + actionsInteractor.customActions.map { actions -> + actions.mapIndexedNotNull { i, model -> + val icon = model.icon + val label = model.label + if (icon == null && label.isBlank()) { + null + } else { + ActionChipViewModel( + label = label.toString(), + icon = model.icon, + onClicked = { model.performAction(i) }, + ) + } + } + }, + preview = { key -> + keySet.value?.maybeLoad(key) + val previewInteractor = interactor.preview(key) + ShareouselPreviewViewModel( + bitmap = flow { emit(imageLoader(key.uri)) }, + contentType = flowOf(ContentType.Image), // TODO: convert from metadata + isSelected = previewInteractor.isSelected, + setSelected = previewInteractor::setSelected, + ) + }, + ) + } + + @Provides + @PayloadToggle + fun imageLoader( + @ViewModelOwned viewModelScope: CoroutineScope, + @Background coroutineDispatcher: CoroutineDispatcher, + @ApplicationContext context: Context, + ): ImageLoader = + ImagePreviewImageLoader( + viewModelScope + coroutineDispatcher, + thumbnailSize = + context.resources.getDimensionPixelSize(R.dimen.chooser_preview_image_max_dimen), + context.contentResolver, + cacheSize = 16, + ) +} + +private fun PreviewsModel.maybeLoad(key: PreviewModel) { + when (key) { + previewModels.firstOrNull() -> loadMoreLeft?.invoke() + previewModels.lastOrNull() -> loadMoreRight?.invoke() + } +} 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 deleted file mode 100644 index 18ee2539..00000000 --- a/java/src/com/android/intentresolver/contentpreview/shareousel/ui/viewmodel/ShareouselViewModel.kt +++ /dev/null @@ -1,90 +0,0 @@ -/* - * Copyright (C) 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.shareousel.ui.viewmodel - -import android.graphics.Bitmap -import androidx.core.graphics.drawable.toBitmap -import com.android.intentresolver.contentpreview.ChooserContentPreviewUi.ActionFactory -import com.android.intentresolver.contentpreview.ImageLoader -import com.android.intentresolver.contentpreview.MutableActionFactory -import com.android.intentresolver.contentpreview.PayloadToggleInteractor -import com.android.intentresolver.icon.BitmapIcon -import com.android.intentresolver.icon.ComposeIcon -import com.android.intentresolver.widget.ActionRow.Action -import kotlinx.coroutines.CoroutineScope -import kotlinx.coroutines.flow.Flow -import kotlinx.coroutines.flow.MutableStateFlow -import kotlinx.coroutines.flow.StateFlow -import kotlinx.coroutines.flow.flow -import kotlinx.coroutines.flow.map -import kotlinx.coroutines.flow.stateIn - -data class ShareouselViewModel( - val headline: Flow<String>, - val previewKeys: StateFlow<List<Any>>, - val actions: Flow<List<ActionChipViewModel>>, - val centerIndex: StateFlow<Int>, - val previewForKey: (key: Any) -> ShareouselImageViewModel, - val previewRowKey: (Any) -> Any -) - -data class ActionChipViewModel(val label: String, val icon: ComposeIcon?, val onClick: () -> Unit) - -data class ShareouselImageViewModel( - val bitmap: Flow<Bitmap?>, - val contentDescription: Flow<String>, - val isSelected: Flow<Boolean>, - val setSelected: (Boolean) -> Unit, -) - -suspend fun PayloadToggleInteractor.toShareouselViewModel( - imageLoader: ImageLoader, - actionFactory: ActionFactory, - scope: CoroutineScope, -): ShareouselViewModel { - return ShareouselViewModel( - headline = MutableStateFlow("Shareousel"), - previewKeys = previewKeys.stateIn(scope), - actions = - if (actionFactory is MutableActionFactory) { - actionFactory.customActionsFlow.map { actions -> - actions.map { it.toActionChipViewModel() } - } - } else { - flow { - emit(actionFactory.createCustomActions().map { it.toActionChipViewModel() }) - } - }, - centerIndex = targetPosition.stateIn(scope), - previewForKey = { key -> - val previewInteractor = previewInteractor(key) - ShareouselImageViewModel( - bitmap = previewInteractor.previewUri.map { uri -> uri?.let { imageLoader(uri) } }, - contentDescription = MutableStateFlow(""), - isSelected = previewInteractor.selected, - setSelected = { isSelected -> previewInteractor.setSelected(isSelected) }, - ) - }, - previewRowKey = { getKey(it) }, - ) -} - -private fun Action.toActionChipViewModel() = - ActionChipViewModel( - label?.toString() ?: "", - icon?.let { BitmapIcon(it.toBitmap()) }, - onClick = { onClicked.run() } - ) diff --git a/java/src/com/android/intentresolver/inject/Qualifiers.kt b/java/src/com/android/intentresolver/inject/Qualifiers.kt index 157e8f76..f267328b 100644 --- a/java/src/com/android/intentresolver/inject/Qualifiers.kt +++ b/java/src/com/android/intentresolver/inject/Qualifiers.kt @@ -23,6 +23,11 @@ import javax.inject.Qualifier @Qualifier @MustBeDocumented @Retention(AnnotationRetention.RUNTIME) +annotation class ViewModelOwned + +@Qualifier +@MustBeDocumented +@Retention(AnnotationRetention.RUNTIME) annotation class ApplicationOwned @Qualifier diff --git a/java/src/com/android/intentresolver/inject/ViewModelCoroutineScopeModule.kt b/java/src/com/android/intentresolver/inject/ViewModelCoroutineScopeModule.kt new file mode 100644 index 00000000..4dda2653 --- /dev/null +++ b/java/src/com/android/intentresolver/inject/ViewModelCoroutineScopeModule.kt @@ -0,0 +1,42 @@ +/* + * Copyright (C) 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.inject + +import dagger.Module +import dagger.Provides +import dagger.hilt.InstallIn +import dagger.hilt.android.ViewModelLifecycle +import dagger.hilt.android.components.ViewModelComponent +import dagger.hilt.android.scopes.ViewModelScoped +import kotlin.coroutines.CoroutineContext +import kotlin.coroutines.EmptyCoroutineContext +import kotlinx.coroutines.CoroutineDispatcher +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.cancel + +@Module +@InstallIn(ViewModelComponent::class) +object ViewModelCoroutineScopeModule { + @Provides + @ViewModelScoped + @ViewModelOwned + fun viewModelScope(@Main dispatcher: CoroutineDispatcher, lifecycle: ViewModelLifecycle) = + lifecycle.asCoroutineScope(dispatcher) +} + +fun ViewModelLifecycle.asCoroutineScope(context: CoroutineContext = EmptyCoroutineContext) = + CoroutineScope(context).also { addOnClearedListener { it.cancel() } } diff --git a/java/src/com/android/intentresolver/v2/ChooserActivity.java b/java/src/com/android/intentresolver/v2/ChooserActivity.java index a95caddc..9a5ec173 100644 --- a/java/src/com/android/intentresolver/v2/ChooserActivity.java +++ b/java/src/com/android/intentresolver/v2/ChooserActivity.java @@ -29,7 +29,6 @@ import static android.view.WindowManager.LayoutParams.SYSTEM_FLAG_HIDE_NON_SYSTE import static androidx.lifecycle.LifecycleKt.getCoroutineScope; -import static com.android.intentresolver.contentpreview.ContentPreviewType.CONTENT_PREVIEW_PAYLOAD_SELECTION; import static com.android.intentresolver.v2.ext.CreationExtrasExtKt.addDefaultArgs; import static com.android.intentresolver.v2.profiles.MultiProfilePagerAdapter.PROFILE_PERSONAL; import static com.android.intentresolver.v2.profiles.MultiProfilePagerAdapter.PROFILE_WORK; @@ -118,7 +117,6 @@ import com.android.intentresolver.chooser.TargetInfo; import com.android.intentresolver.contentpreview.BasePreviewViewModel; import com.android.intentresolver.contentpreview.ChooserContentPreviewUi; import com.android.intentresolver.contentpreview.HeadlineGeneratorImpl; -import com.android.intentresolver.contentpreview.PayloadToggleInteractor; import com.android.intentresolver.contentpreview.PreviewViewModel; import com.android.intentresolver.emptystate.CompositeEmptyStateProvider; import com.android.intentresolver.emptystate.CrossProfileIntentsChecker; @@ -608,33 +606,14 @@ public class ChooserActivity extends Hilt_ChooserActivity implements .get(BasePreviewViewModel.class); previewViewModel.init( mRequest.getTargetIntent(), - mViewModel.getActivityModel().getIntent(), mRequest.getAdditionalContentUri(), - mRequest.getFocusedItemPosition(), mChooserServiceFeatureFlags.chooserPayloadToggling()); - ChooserActionFactory chooserActionFactory = createChooserActionFactory(); - ChooserContentPreviewUi.ActionFactory actionFactory = chooserActionFactory; - if (previewViewModel.getPreviewDataProvider().getPreviewType() - == CONTENT_PREVIEW_PAYLOAD_SELECTION - && mChooserServiceFeatureFlags.chooserPayloadToggling()) { - PayloadToggleInteractor payloadToggleInteractor = - previewViewModel.getPayloadToggleInteractor(); - if (payloadToggleInteractor != null) { - ChooserMutableActionFactory mutableActionFactory = - new ChooserMutableActionFactory(chooserActionFactory); - actionFactory = mutableActionFactory; - JavaFlowHelper.collect( - getCoroutineScope(getLifecycle()), - payloadToggleInteractor.getCustomActions(), - mutableActionFactory::updateCustomActions); - } - } mChooserContentPreviewUi = new ChooserContentPreviewUi( getCoroutineScope(getLifecycle()), previewViewModel.getPreviewDataProvider(), mRequest.getTargetIntent(), previewViewModel.getImageLoader(), - actionFactory, + createChooserActionFactory(), mEnterTransitionAnimationDelegate, new HeadlineGeneratorImpl(this), mRequest.getContentTypeHint(), diff --git a/java/src/com/android/intentresolver/v2/ChooserMutableActionFactory.kt b/java/src/com/android/intentresolver/v2/ChooserMutableActionFactory.kt deleted file mode 100644 index 2f8ccf77..00000000 --- a/java/src/com/android/intentresolver/v2/ChooserMutableActionFactory.kt +++ /dev/null @@ -1,54 +0,0 @@ -/* - * 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.v2 - -import android.service.chooser.ChooserAction -import com.android.intentresolver.contentpreview.ChooserContentPreviewUi -import com.android.intentresolver.contentpreview.MutableActionFactory -import com.android.intentresolver.widget.ActionRow -import kotlinx.coroutines.flow.Flow -import kotlinx.coroutines.flow.MutableStateFlow - -/** A wrapper around [ChooserActionFactory] that provides observable custom actions */ -class ChooserMutableActionFactory( - private val actionFactory: ChooserActionFactory, -) : MutableActionFactory, ChooserContentPreviewUi.ActionFactory by actionFactory { - private val customActions = - MutableStateFlow<List<ActionRow.Action>>(actionFactory.createCustomActions()) - - override val customActionsFlow: Flow<List<ActionRow.Action>> - get() = customActions - - override fun updateCustomActions(actions: List<ChooserAction>) { - customActions.tryEmit(mapChooserActions(actions)) - } - - override fun createCustomActions(): List<ActionRow.Action> = customActions.value - - private fun mapChooserActions(chooserActions: List<ChooserAction>): List<ActionRow.Action> = - buildList(chooserActions.size) { - chooserActions.forEachIndexed { i, chooserAction -> - val actionRow = - actionFactory.createCustomAction(chooserAction) { - actionFactory.logCustomAction(i) - } - if (actionRow != null) { - add(actionRow) - } - } - } -} diff --git a/tests/activity/src/com/android/intentresolver/logging/TestEventLogModule.kt b/tests/activity/src/com/android/intentresolver/logging/TestEventLogModule.kt index cd808af4..d1dea7c3 100644 --- a/tests/activity/src/com/android/intentresolver/logging/TestEventLogModule.kt +++ b/tests/activity/src/com/android/intentresolver/logging/TestEventLogModule.kt @@ -21,16 +21,16 @@ import com.android.internal.logging.InstanceIdSequence import dagger.Binds import dagger.Module import dagger.Provides -import dagger.hilt.android.components.ActivityComponent -import dagger.hilt.android.scopes.ActivityScoped +import dagger.hilt.android.components.ActivityRetainedComponent +import dagger.hilt.android.scopes.ActivityRetainedScoped import dagger.hilt.testing.TestInstallIn /** Binds a [FakeEventLog] as [EventLog] in tests. */ @Module -@TestInstallIn(components = [ActivityComponent::class], replaces = [EventLogModule::class]) +@TestInstallIn(components = [ActivityRetainedComponent::class], replaces = [EventLogModule::class]) interface TestEventLogModule { - @Binds @ActivityScoped fun fakeEventLog(impl: FakeEventLog): EventLog + @Binds @ActivityRetainedScoped fun fakeEventLog(impl: FakeEventLog): EventLog companion object { @Provides diff --git a/tests/shared/src/com/android/intentresolver/MockitoKotlinHelpers.kt b/tests/shared/src/com/android/intentresolver/MockitoKotlinHelpers.kt index db9fbd93..b7b97d6f 100644 --- a/tests/shared/src/com/android/intentresolver/MockitoKotlinHelpers.kt +++ b/tests/shared/src/com/android/intentresolver/MockitoKotlinHelpers.kt @@ -14,14 +14,16 @@ * limitations under the License. */ +@file:Suppress("NOTHING_TO_INLINE") + package com.android.intentresolver /** * Kotlin versions of popular mockito methods that can return null in situations when Kotlin expects * a non-null value. Kotlin will throw an IllegalStateException when this takes place ("x must not * be null"). To fix this, we can use methods that modify the return type to be nullable. This - * causes Kotlin to skip the null checks. - * Cloned from frameworks/base/packages/SystemUI/tests/utils/src/com/android/systemui/util/mockito/KotlinMockitoHelpers.kt + * causes Kotlin to skip the null checks. Cloned from + * frameworks/base/packages/SystemUI/tests/utils/src/com/android/systemui/util/mockito/KotlinMockitoHelpers.kt */ import org.mockito.ArgumentCaptor import org.mockito.ArgumentMatcher @@ -33,42 +35,49 @@ import org.mockito.stubbing.OngoingStubbing import org.mockito.stubbing.Stubber /** - * Returns Mockito.eq() as nullable type to avoid java.lang.IllegalStateException when - * null is returned. + * Returns Mockito.eq() as nullable type to avoid java.lang.IllegalStateException when null is + * returned. * * Generic T is nullable because implicitly bounded by Any?. */ -fun <T> eq(obj: T): T = Mockito.eq<T>(obj) +inline fun <T> eq(obj: T): T = Mockito.eq<T>(obj) ?: obj /** - * Returns Mockito.any() as nullable type to avoid java.lang.IllegalStateException when - * null is returned. + * Returns Mockito.same() as nullable type to avoid java.lang.IllegalStateException when null is + * returned. * * Generic T is nullable because implicitly bounded by Any?. */ -fun <T> any(type: Class<T>): T = Mockito.any<T>(type) -inline fun <reified T> any(): T = any(T::class.java) +inline fun <T> same(obj: T): T = Mockito.same<T>(obj) ?: obj /** - * Returns Mockito.argThat() as nullable type to avoid java.lang.IllegalStateException when - * null is returned. + * Returns Mockito.any() as nullable type to avoid java.lang.IllegalStateException when null is + * returned. * * Generic T is nullable because implicitly bounded by Any?. */ -fun <T> argThat(matcher: ArgumentMatcher<T>): T = Mockito.argThat(matcher) +inline fun <T> any(type: Class<T>): T = Mockito.any<T>(type) + +inline fun <reified T> any(): T = any(T::class.java) /** - * Kotlin type-inferred version of Mockito.nullable() + * Returns Mockito.argThat() as nullable type to avoid java.lang.IllegalStateException when null is + * returned. + * + * Generic T is nullable because implicitly bounded by Any?. */ +inline fun <T> argThat(matcher: ArgumentMatcher<T>): T = Mockito.argThat(matcher) + +/** Kotlin type-inferred version of Mockito.nullable() */ inline fun <reified T> nullable(): T? = Mockito.nullable(T::class.java) /** - * Returns ArgumentCaptor.capture() as nullable type to avoid java.lang.IllegalStateException - * when null is returned. + * Returns ArgumentCaptor.capture() as nullable type to avoid java.lang.IllegalStateException when + * null is returned. * * Generic T is nullable because implicitly bounded by Any?. */ -fun <T> capture(argumentCaptor: ArgumentCaptor<T>): T = argumentCaptor.capture() +inline fun <T> capture(argumentCaptor: ArgumentCaptor<T>): T = argumentCaptor.capture() /** * Helper function for creating an argumentCaptor in kotlin. @@ -90,17 +99,18 @@ inline fun <reified T : Any> mock( apply: T.() -> Unit = {} ): T = Mockito.mock(T::class.java, mockSettings).apply(apply) +/** Matches any array of type T. */ +inline fun <reified T : Any?> anyArray(): Array<T> = Mockito.any(Array<T>::class.java) ?: arrayOf() + /** * Helper function for stubbing methods without the need to use backticks. * * @see Mockito.when */ -fun <T> whenever(methodCall: T): OngoingStubbing<T> = Mockito.`when`(methodCall) +inline fun <T> whenever(methodCall: T): OngoingStubbing<T> = Mockito.`when`(methodCall) -/** - * Helper function for stubbing methods without the need to use backticks. - */ -fun <T> Stubber.whenever(mock: T): T = `when`(mock) +/** Helper function for stubbing methods without the need to use backticks. */ +inline fun <T> Stubber.whenever(mock: T): T = `when`(mock) /** * A kotlin implemented wrapper of [ArgumentCaptor] which prevents the following exception when @@ -128,13 +138,12 @@ inline fun <reified T : Any> kotlinArgumentCaptor(): KotlinArgumentCaptor<T> = /** * Helper function for creating and using a single-use ArgumentCaptor in kotlin. * - * val captor = argumentCaptor<Foo>() - * verify(...).someMethod(captor.capture()) - * val captured = captor.value + * val captor = argumentCaptor<Foo>() verify(...).someMethod(captor.capture()) val captured = + * captor.value * * becomes: * - * val captured = withArgCaptor<Foo> { verify(...).someMethod(capture()) } + * val captured = withArgCaptor<Foo> { verify(...).someMethod(capture()) } * * NOTE: this uses the KotlinArgumentCaptor to avoid the NullPointerException. */ @@ -144,13 +153,12 @@ inline fun <reified T : Any> withArgCaptor(block: KotlinArgumentCaptor<T>.() -> /** * Variant of [withArgCaptor] for capturing multiple arguments. * - * val captor = argumentCaptor<Foo>() - * verify(...).someMethod(captor.capture()) - * val captured: List<Foo> = captor.allValues + * val captor = argumentCaptor<Foo>() verify(...).someMethod(captor.capture()) val captured: + * List<Foo> = captor.allValues * * becomes: * - * val capturedList = captureMany<Foo> { verify(...).someMethod(capture()) } + * val capturedList = captureMany<Foo> { verify(...).someMethod(capture()) } */ inline fun <reified T : Any> captureMany(block: KotlinArgumentCaptor<T>.() -> Unit): List<T> = kotlinArgumentCaptor<T>().apply { block() }.allValues diff --git a/tests/shared/src/com/android/intentresolver/TestContentPreviewViewModel.kt b/tests/shared/src/com/android/intentresolver/TestContentPreviewViewModel.kt index b352f360..8f246424 100644 --- a/tests/shared/src/com/android/intentresolver/TestContentPreviewViewModel.kt +++ b/tests/shared/src/com/android/intentresolver/TestContentPreviewViewModel.kt @@ -23,7 +23,6 @@ import androidx.lifecycle.ViewModelProvider import androidx.lifecycle.viewmodel.CreationExtras import com.android.intentresolver.contentpreview.BasePreviewViewModel import com.android.intentresolver.contentpreview.ImageLoader -import com.android.intentresolver.contentpreview.PayloadToggleInteractor /** A test content preview model that supports image loader override. */ class TestContentPreviewViewModel( @@ -34,23 +33,12 @@ class TestContentPreviewViewModel( override val previewDataProvider get() = viewModel.previewDataProvider - override val payloadToggleInteractor: PayloadToggleInteractor? - get() = viewModel.payloadToggleInteractor - override fun init( targetIntent: Intent, - chooserIntent: Intent, additionalContentUri: Uri?, - focusedItemIdx: Int, isPayloadTogglingEnabled: Boolean, ) { - viewModel.init( - targetIntent, - chooserIntent, - additionalContentUri, - focusedItemIdx, - isPayloadTogglingEnabled - ) + viewModel.init(targetIntent, additionalContentUri, isPayloadTogglingEnabled) } companion object { diff --git a/tests/unit/src/com/android/intentresolver/contentpreview/CursorUriReaderTest.kt b/tests/unit/src/com/android/intentresolver/contentpreview/CursorUriReaderTest.kt deleted file mode 100644 index cd1c503a..00000000 --- a/tests/unit/src/com/android/intentresolver/contentpreview/CursorUriReaderTest.kt +++ /dev/null @@ -1,125 +0,0 @@ -/* - * 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.ContentInterface -import android.content.Intent -import android.database.MatrixCursor -import android.net.Uri -import android.util.SparseArray -import com.android.intentresolver.any -import com.android.intentresolver.anyOrNull -import com.android.intentresolver.mock -import com.android.intentresolver.whenever -import com.google.common.truth.Truth.assertThat -import com.google.common.truth.Truth.assertWithMessage -import kotlinx.coroutines.test.TestScope -import kotlinx.coroutines.test.runTest -import org.junit.Test - -class CursorUriReaderTest { - private val scope = TestScope() - - @Test - fun readEmptyCursor() { - val testSubject = - CursorUriReader( - cursor = MatrixCursor(arrayOf("uri")), - startPos = 0, - pageSize = 128, - ) { - true - } - - assertThat(testSubject.hasMoreBefore).isFalse() - assertThat(testSubject.hasMoreAfter).isFalse() - assertThat(testSubject.count).isEqualTo(0) - assertThat(testSubject.readPageBefore().size()).isEqualTo(0) - assertThat(testSubject.readPageAfter().size()).isEqualTo(0) - } - - @Test - fun readCursorFromTheMiddle() { - val count = 3 - val testSubject = - CursorUriReader( - cursor = - MatrixCursor(arrayOf("uri")).apply { - for (i in 1..count) { - addRow(arrayOf(createUri(i))) - } - }, - startPos = 1, - pageSize = 2, - ) { - true - } - - assertThat(testSubject.hasMoreBefore).isTrue() - assertThat(testSubject.hasMoreAfter).isTrue() - assertThat(testSubject.count).isEqualTo(3) - - testSubject.readPageBefore().let { page -> - assertThat(testSubject.hasMoreBefore).isFalse() - assertThat(testSubject.hasMoreAfter).isTrue() - assertThat(page.size()).isEqualTo(1) - assertThat(page.keyAt(0)).isEqualTo(0) - assertThat(page.valueAt(0)).isEqualTo(createUri(1)) - } - - testSubject.readPageAfter().let { page -> - assertThat(testSubject.hasMoreBefore).isFalse() - assertThat(testSubject.hasMoreAfter).isFalse() - assertThat(page.size()).isEqualTo(2) - assertThat(page.getKeys()).asList().containsExactly(1, 2).inOrder() - assertThat(page.getValues()) - .asList() - .containsExactly(createUri(2), createUri(3)) - .inOrder() - } - } - - // TODO: add tests with filtered-out items - // TODO: add tests with a failing cursor - - @Test - fun testFailingQueryCall_emptyCursorCreated() = - scope.runTest { - val contentResolver = - mock<ContentInterface> { - whenever(query(any(), any(), anyOrNull(), any())) - .thenThrow(SecurityException("Test exception")) - } - val cursorReader = - CursorUriReader.createCursorReader( - contentResolver, - Uri.parse("content://auth"), - Intent(Intent.ACTION_CHOOSER) - ) - - assertWithMessage("Empty cursor reader is expected") - .that(cursorReader.count) - .isEqualTo(0) - } -} - -private fun createUri(id: Int) = Uri.parse("content://org.pkg/$id") - -private fun <T> SparseArray<T>.getKeys(): IntArray = IntArray(size()) { i -> keyAt(i) } - -private inline fun <reified T> SparseArray<T>.getValues(): Array<T> = - Array(size()) { i -> valueAt(i) } diff --git a/tests/unit/src/com/android/intentresolver/contentpreview/PayloadToggleInteractorTest.kt b/tests/unit/src/com/android/intentresolver/contentpreview/PayloadToggleInteractorTest.kt deleted file mode 100644 index 25c27468..00000000 --- a/tests/unit/src/com/android/intentresolver/contentpreview/PayloadToggleInteractorTest.kt +++ /dev/null @@ -1,162 +0,0 @@ -/* - * 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.assertWithMessage -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 -> - assertWithMessage("Two pages (2 items each) are expected to be initially read") - .that(initialState.items) - .hasSize(4) - assertWithMessage("Unexpected cursor values") - .that(initialState.items.map { it.uri }) - .containsExactly(*Array<Uri>(4, ::makeUri)) - .inOrder() - assertWithMessage("No more items are expected to the left") - .that(initialState.hasMoreItemsBefore) - .isFalse() - assertWithMessage("No more items are expected to the right") - .that(initialState.hasMoreItemsAfter) - .isTrue() - assertWithMessage("Selections should no be disabled") - .that(initialState.allowSelectionChange) - .isTrue() - } - - testSubject.loadMoreNextItems() - // this one is expected to be deduplicated - testSubject.loadMoreNextItems() - scheduler.runCurrent() - - testSubject.stateFlow.first().let { state -> - assertWithMessage("Unexpected cursor values") - .that(state.items.map { it.uri }) - .containsExactly(*Array(6, ::makeUri)) - .inOrder() - assertWithMessage("No more items are expected to the left") - .that(state.hasMoreItemsBefore) - .isFalse() - assertWithMessage("No more items are expected to the right") - .that(state.hasMoreItemsAfter) - .isTrue() - assertWithMessage("Selections should no be disabled") - .that(state.allowSelectionChange) - .isTrue() - assertWithMessage("Wrong selected items") - .that(state.items.map { testSubject.selected(it).first() }) - .containsExactly(true, false, true, false, false, true) - .inOrder() - } - } - - @Test - fun testItemsSelection() = - testScope.runTest { - val cursorReader = CursorUriReader(createCursor(10), 2, 2) { true } - val testSubject = - PayloadToggleInteractor( - scope = testScope.backgroundScope, - initiallySharedUris = listOf(makeUri(0)), - 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() - val items = testSubject.stateFlow.first().items - assertWithMessage("An initially selected item should be selected") - .that(testSubject.selected(items[0]).first()) - .isTrue() - assertWithMessage("An item that was not initially selected should not be selected") - .that(testSubject.selected(items[1]).first()) - .isFalse() - - testSubject.setSelected(items[0], false) - scheduler.runCurrent() - assertWithMessage("The only selected item can not be unselected") - .that(testSubject.selected(items[0]).first()) - .isTrue() - - testSubject.setSelected(items[1], true) - scheduler.runCurrent() - assertWithMessage("An item selection status should be published") - .that(testSubject.selected(items[1]).first()) - .isTrue() - - testSubject.setSelected(items[0], false) - scheduler.runCurrent() - assertWithMessage("An item can be unselected when there's another selected item") - .that(testSubject.selected(items[0]).first()) - .isFalse() - } -} - -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") diff --git a/tests/unit/src/com/android/intentresolver/contentpreview/PreviewViewModelTest.kt b/tests/unit/src/com/android/intentresolver/contentpreview/PreviewViewModelTest.kt deleted file mode 100644 index 1a59a930..00000000 --- a/tests/unit/src/com/android/intentresolver/contentpreview/PreviewViewModelTest.kt +++ /dev/null @@ -1,81 +0,0 @@ -/* - * 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.net.Uri -import androidx.test.ext.junit.runners.AndroidJUnit4 -import androidx.test.platform.app.InstrumentationRegistry -import com.google.common.truth.Truth.assertThat -import kotlinx.coroutines.ExperimentalCoroutinesApi -import kotlinx.coroutines.test.UnconfinedTestDispatcher -import org.junit.Test -import org.junit.runner.RunWith - -@RunWith(AndroidJUnit4::class) -class PreviewViewModelTest { - @OptIn(ExperimentalCoroutinesApi::class) private val dispatcher = UnconfinedTestDispatcher() - - private val context - get() = InstrumentationRegistry.getInstrumentation().targetContext - - private val targetIntent = Intent(Intent.ACTION_SEND) - private val chooserIntent = Intent.createChooser(targetIntent, null) - private val additionalContentUri = Uri.parse("content://org.pkg.content") - - @Test - fun featureFlagDisabled_noPayloadToggleInteractorCreated() { - val testSubject = - PreviewViewModel(context.contentResolver, 200, dispatcher).apply { - init( - targetIntent, - chooserIntent, - additionalContentUri, - focusedItemIdx = 0, - isPayloadTogglingEnabled = false - ) - } - - assertThat(testSubject.payloadToggleInteractor).isNull() - } - - @Test - fun noAdditionalContentUri_noPayloadToggleInteractorCreated() { - val testSubject = - PreviewViewModel(context.contentResolver, 200, dispatcher).apply { - init( - targetIntent, - chooserIntent, - additionalContentUri = null, - focusedItemIdx = 0, - true - ) - } - - assertThat(testSubject.payloadToggleInteractor).isNull() - } - - @Test - fun flagEnabledAndAdditionalContentUriProvided_createPayloadToggleInteractor() { - val testSubject = - PreviewViewModel(context.contentResolver, 200, dispatcher).apply { - init(targetIntent, chooserIntent, additionalContentUri, focusedItemIdx = 0, true) - } - - assertThat(testSubject.payloadToggleInteractor).isNotNull() - } -} diff --git a/tests/unit/src/com/android/intentresolver/contentpreview/SelectionTrackerTest.kt b/tests/unit/src/com/android/intentresolver/contentpreview/SelectionTrackerTest.kt deleted file mode 100644 index 6ba18466..00000000 --- a/tests/unit/src/com/android/intentresolver/contentpreview/SelectionTrackerTest.kt +++ /dev/null @@ -1,330 +0,0 @@ -/* - * 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 com.google.common.truth.Truth.assertThat -import org.junit.Test - -class SelectionTrackerTest { - @Test - fun noSelectedItems() { - val testSubject = SelectionTracker<Uri>(emptyList(), 0, 10) { this } - - val items = - (1..5).fold(SparseArray<Uri>(5)) { acc, i -> - acc.apply { append(i * 2, makeUri(i * 2)) } - } - testSubject.onEndItemsAdded(items) - - assertThat(testSubject.getSelection()).isEmpty() - } - - @Test - fun testNoItems() { - val u1 = makeUri(1) - val u2 = makeUri(2) - val u3 = makeUri(3) - val testSubject = SelectionTracker(listOf(u1, u2, u3), 1, 0) { this } - - assertThat(testSubject.getSelection()).containsExactly(u1, u2, u3).inOrder() - } - - @Test - fun focusedItemInPlaceAllItemsOnTheRight_selectionsInTheInitialOrder() { - val u1 = makeUri(1) - val u2 = makeUri(2) - val u3 = makeUri(3) - val count = 7 - val testSubject = SelectionTracker(listOf(u1, u2, u3), 0, count) { this } - - testSubject.onEndItemsAdded( - SparseArray<Uri>(3).apply { - append(1, u1) - append(2, makeUri(4)) - append(3, makeUri(5)) - } - ) - assertThat(testSubject.getSelection()).containsExactly(u1, u2, u3).inOrder() - testSubject.onEndItemsAdded( - SparseArray<Uri>(3).apply { - append(3, makeUri(6)) - append(4, u2) - append(5, u3) - } - ) - assertThat(testSubject.getSelection()).containsExactly(u1, u2, u3).inOrder() - } - - @Test - fun focusedItemInPlaceElementsOnBothSides_selectionsInTheInitialOrder() { - val u1 = makeUri(1) - val u2 = makeUri(2) - val u3 = makeUri(3) - val count = 10 - val testSubject = SelectionTracker(listOf(u1, u2, u3), 1, count) { this } - - testSubject.onEndItemsAdded( - SparseArray<Uri>(3).apply { - append(4, u2) - append(5, makeUri(4)) - append(6, makeUri(5)) - } - ) - assertThat(testSubject.getSelection()).containsExactly(u1, u2, u3).inOrder() - - testSubject.onStartItemsAdded( - SparseArray<Uri>(3).apply { - append(1, makeUri(6)) - append(2, u1) - append(3, makeUri(7)) - } - ) - assertThat(testSubject.getSelection()).containsExactly(u1, u2, u3).inOrder() - - testSubject.onEndItemsAdded(SparseArray<Uri>(3).apply { append(8, u3) }) - assertThat(testSubject.getSelection()).containsExactly(u1, u2, u3).inOrder() - } - - @Test - fun focusedItemInPlaceAllItemsOnTheLeft_selectionsInTheInitialOrder() { - val u1 = makeUri(1) - val u2 = makeUri(2) - val u3 = makeUri(3) - val count = 7 - val testSubject = SelectionTracker(listOf(u1, u2, u3), 2, count) { this } - - testSubject.onEndItemsAdded(SparseArray<Uri>(3).apply { append(6, u3) }) - - assertThat(testSubject.getSelection()).containsExactly(u1, u2, u3).inOrder() - - testSubject.onStartItemsAdded( - SparseArray<Uri>(3).apply { - append(3, makeUri(4)) - append(4, u2) - append(5, makeUri(5)) - } - ) - assertThat(testSubject.getSelection()).containsExactly(u1, u2, u3).inOrder() - - testSubject.onStartItemsAdded( - SparseArray<Uri>(3).apply { - append(1, u1) - append(2, makeUri(6)) - } - ) - assertThat(testSubject.getSelection()).containsExactly(u1, u2, u3).inOrder() - } - - @Test - fun focusedItemInPlaceDuplicatesOnBothSides_selectionsInTheInitialOrder() { - val u1 = makeUri(1) - val u2 = makeUri(2) - val u3 = makeUri(3) - val count = 5 - val testSubject = SelectionTracker(listOf(u1, u2, u1), 1, count) { this } - - testSubject.onEndItemsAdded(SparseArray<Uri>(3).apply { append(2, u2) }) - assertThat(testSubject.getSelection()).containsExactly(u1, u2, u1).inOrder() - - testSubject.onStartItemsAdded( - SparseArray<Uri>(3).apply { - append(0, u1) - append(1, u3) - } - ) - assertThat(testSubject.getSelection()).containsExactly(u1, u2, u1).inOrder() - - testSubject.onStartItemsAdded( - SparseArray<Uri>(3).apply { - append(3, u1) - append(4, u3) - } - ) - assertThat(testSubject.getSelection()).containsExactly(u1, u2, u1).inOrder() - } - - @Test - fun focusedItemInPlaceDuplicatesOnTheRight_selectionsInTheInitialOrder() { - val u1 = makeUri(1) - val u2 = makeUri(2) - val count = 4 - val testSubject = SelectionTracker(listOf(u1, u2), 0, count) { this } - - testSubject.onEndItemsAdded(SparseArray<Uri>(1).apply { append(0, u1) }) - assertThat(testSubject.getSelection()).containsExactly(u1, u2).inOrder() - - testSubject.onEndItemsAdded( - SparseArray<Uri>(3).apply { - append(1, u2) - append(2, u1) - append(3, u2) - } - ) - assertThat(testSubject.getSelection()).containsExactly(u1, u2).inOrder() - } - - @Test - fun focusedItemInPlaceDuplicatesOnTheLeft_selectionsInTheInitialOrder() { - val u1 = makeUri(1) - val u2 = makeUri(2) - val count = 4 - val testSubject = SelectionTracker(listOf(u1, u2), 1, count) { this } - - testSubject.onEndItemsAdded(SparseArray<Uri>(1).apply { append(3, u2) }) - assertThat(testSubject.getSelection()).containsExactly(u1, u2).inOrder() - - testSubject.onStartItemsAdded( - SparseArray<Uri>(3).apply { - append(0, u1) - append(1, u2) - append(2, u1) - } - ) - assertThat(testSubject.getSelection()).containsExactly(u1, u2).inOrder() - } - - @Test - fun differentItemsOrder_selectionsInTheCursorOrder() { - val u1 = makeUri(1) - val u2 = makeUri(2) - val u3 = makeUri(3) - val u4 = makeUri(3) - val count = 10 - val testSubject = SelectionTracker(listOf(u1, u2, u3, u4), 2, count) { this } - - testSubject.onEndItemsAdded( - SparseArray<Uri>(3).apply { - append(4, makeUri(5)) - append(5, u1) - append(6, makeUri(6)) - } - ) - testSubject.onStartItemsAdded( - SparseArray<Uri>(3).apply { - append(2, makeUri(7)) - append(3, u4) - } - ) - testSubject.onEndItemsAdded( - SparseArray<Uri>(3).apply { - append(7, u3) - append(8, makeUri(8)) - } - ) - testSubject.onStartItemsAdded( - SparseArray<Uri>(3).apply { - append(0, makeUri(9)) - append(1, u2) - } - ) - assertThat(testSubject.getSelection()).containsExactly(u2, u4, u1, u3).inOrder() - } - - @Test - fun testPendingItems() { - val u1 = makeUri(1) - val u2 = makeUri(2) - val u3 = makeUri(3) - val u4 = makeUri(4) - val u5 = makeUri(5) - - val testSubject = SelectionTracker(listOf(u1, u2, u3, u4, u5), 2, 5) { this } - - testSubject.onEndItemsAdded( - SparseArray<Uri>(2).apply { - append(2, u3) - append(3, u4) - } - ) - testSubject.onStartItemsAdded(SparseArray<Uri>(2).apply { append(1, u2) }) - - assertThat(testSubject.getPendingItems()).containsExactly(u1, u5).inOrder() - } - - @Test - fun testItemSelection() { - val u1 = makeUri(1) - val u2 = makeUri(2) - val u3 = makeUri(3) - val u4 = makeUri(4) - val u5 = makeUri(5) - - val testSubject = SelectionTracker(listOf(u1, u2, u3, u4, u5), 2, 10) { this } - - testSubject.onEndItemsAdded( - SparseArray<Uri>(2).apply { - append(2, u3) - append(3, u4) - } - ) - assertThat(testSubject.getSelection()).containsExactly(u1, u2, u3, u4, u5).inOrder() - - assertThat(testSubject.setItemSelection(2, u3, false)).isTrue() - assertThat(testSubject.setItemSelection(3, u4, true)).isFalse() - assertThat(testSubject.getSelection()).containsExactly(u1, u2, u4, u5).inOrder() - - testSubject.onEndItemsAdded( - SparseArray<Uri>(1).apply { - append(4, u5) - append(5, u3) - } - ) - testSubject.onStartItemsAdded( - SparseArray<Uri>(2).apply { - append(0, u1) - append(1, u2) - } - ) - assertThat(testSubject.getSelection()).containsExactly(u1, u2, u4, u5).inOrder() - - assertThat(testSubject.setItemSelection(2, u3, true)).isTrue() - assertThat(testSubject.getSelection()).containsExactly(u1, u2, u3, u4, u5).inOrder() - assertThat(testSubject.setItemSelection(5, u3, true)).isTrue() - assertThat(testSubject.getSelection()).containsExactly(u1, u2, u3, u4, u5, u3).inOrder() - } - - @Test - fun testItemSelectionWithDuplicates() { - val u1 = makeUri(1) - val u2 = makeUri(2) - - val testSubject = SelectionTracker(listOf(u1, u2, u1), 1, 3) { this } - testSubject.onEndItemsAdded( - SparseArray<Uri>(2).apply { - append(1, u2) - append(2, u1) - } - ) - - assertThat(testSubject.getPendingItems()).containsExactly(u1) - } - - @Test - fun testUnselectOnlySelectedItem_itemRemainsSelected() { - val u1 = makeUri(1) - - val testSubject = SelectionTracker(listOf(u1), 0, 1) { this } - testSubject.onEndItemsAdded(SparseArray<Uri>(1).apply { append(0, u1) }) - assertThat(testSubject.isItemSelected(0)).isTrue() - assertThat(testSubject.setItemSelection(0, u1, false)).isFalse() - assertThat(testSubject.isItemSelected(0)).isTrue() - } -} - -private fun makeUri(id: Int) = Uri.parse("content://org.pkg.app/img-$id.png") diff --git a/tests/unit/src/com/android/intentresolver/contentpreview/payloadtoggle/ui/viewmodel/ShareouselViewModelTest.kt b/tests/unit/src/com/android/intentresolver/contentpreview/payloadtoggle/ui/viewmodel/ShareouselViewModelTest.kt new file mode 100644 index 00000000..854e0319 --- /dev/null +++ b/tests/unit/src/com/android/intentresolver/contentpreview/payloadtoggle/ui/viewmodel/ShareouselViewModelTest.kt @@ -0,0 +1,243 @@ +/* + * Copyright (C) 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. + */ + +@file:OptIn(ExperimentalCoroutinesApi::class) + +package com.android.intentresolver.contentpreview.payloadtoggle.ui.viewmodel + +import android.app.Activity +import android.content.ContentResolver +import android.content.Intent +import android.content.pm.PackageManager +import android.graphics.Bitmap +import android.graphics.drawable.Icon +import android.net.Uri +import com.android.intentresolver.FakeImageLoader +import com.android.intentresolver.contentpreview.HeadlineGenerator +import com.android.intentresolver.contentpreview.payloadtoggle.data.model.CustomActionModel +import com.android.intentresolver.contentpreview.payloadtoggle.data.repository.ActivityResultRepository +import com.android.intentresolver.contentpreview.payloadtoggle.data.repository.CursorPreviewsRepository +import com.android.intentresolver.contentpreview.payloadtoggle.data.repository.PreviewSelectionsRepository +import com.android.intentresolver.contentpreview.payloadtoggle.data.repository.TargetIntentRepository +import com.android.intentresolver.contentpreview.payloadtoggle.domain.interactor.CustomActionsInteractor +import com.android.intentresolver.contentpreview.payloadtoggle.domain.interactor.SelectablePreviewsInteractor +import com.android.intentresolver.contentpreview.payloadtoggle.domain.interactor.SelectionInteractor +import com.android.intentresolver.contentpreview.payloadtoggle.shared.model.PreviewModel +import com.android.intentresolver.contentpreview.payloadtoggle.shared.model.PreviewsModel +import com.android.intentresolver.icon.BitmapIcon +import com.android.intentresolver.logging.FakeEventLog +import com.android.intentresolver.mock +import com.android.intentresolver.util.comparingElementsUsingTransform +import com.android.internal.logging.InstanceId +import com.google.common.truth.Truth.assertThat +import com.google.common.truth.Truth.assertWithMessage +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.test.StandardTestDispatcher +import kotlinx.coroutines.test.TestScope +import kotlinx.coroutines.test.runCurrent +import kotlinx.coroutines.test.runTest +import org.junit.Test + +class ShareouselViewModelTest { + + class Dependencies { + val testDispatcher = StandardTestDispatcher() + val testScope = TestScope(testDispatcher) + val previewsRepository = CursorPreviewsRepository() + val selectionRepository = + PreviewSelectionsRepository().apply { + selections.value = + setOf(PreviewModel(Uri.fromParts("scheme", "ssp", "fragment"), null)) + } + val activityResultRepository = ActivityResultRepository() + val contentResolver = mock<ContentResolver> {} + val packageManager = mock<PackageManager> {} + val eventLog = FakeEventLog(instanceId = InstanceId.fakeInstanceId(1)) + val targetIntentRepo = + TargetIntentRepository( + initialIntent = Intent(), + initialActions = listOf(), + ) + val underTest = + ShareouselViewModelModule.create( + interactor = + SelectablePreviewsInteractor( + previewsRepo = previewsRepository, + selectionRepo = selectionRepository + ), + imageLoader = + FakeImageLoader( + initialBitmaps = + mapOf( + Uri.fromParts("scheme1", "ssp1", "fragment1") to + Bitmap.createBitmap(100, 100, Bitmap.Config.ALPHA_8) + ) + ), + actionsInteractor = + CustomActionsInteractor( + activityResultRepo = activityResultRepository, + bgDispatcher = testDispatcher, + contentResolver = contentResolver, + eventLog = eventLog, + packageManager = packageManager, + targetIntentRepo = targetIntentRepo, + ), + headlineGenerator = + object : HeadlineGenerator { + override fun getImagesHeadline(count: Int): String = "IMAGES: $count" + + override fun getTextHeadline(text: CharSequence): String = + error("not supported") + + override fun getAlbumHeadline(): String = error("not supported") + + override fun getImagesWithTextHeadline( + text: CharSequence, + count: Int + ): String = error("not supported") + + override fun getVideosWithTextHeadline( + text: CharSequence, + count: Int + ): String = error("not supported") + + override fun getFilesWithTextHeadline( + text: CharSequence, + count: Int + ): String = error("not supported") + + override fun getVideosHeadline(count: Int): String = error("not supported") + + override fun getFilesHeadline(count: Int): String = error("not supported") + }, + selectionInteractor = + SelectionInteractor( + selectionRepo = selectionRepository, + ), + scope = testScope.backgroundScope, + ) + } + + private inline fun runTestWithDeps( + crossinline block: suspend TestScope.(Dependencies) -> Unit, + ): Unit = + Dependencies().run { + testScope.runTest { + runCurrent() + block(this@run) + } + } + + @Test + fun headline() = runTestWithDeps { deps -> + with(deps) { + assertThat(underTest.headline.first()).isEqualTo("IMAGES: 1") + selectionRepository.selections.value = + setOf( + PreviewModel( + Uri.fromParts("scheme", "ssp", "fragment"), + null, + ), + PreviewModel( + Uri.fromParts("scheme1", "ssp1", "fragment1"), + null, + ) + ) + runCurrent() + assertThat(underTest.headline.first()).isEqualTo("IMAGES: 2") + } + } + + @Test + fun previews() = runTestWithDeps { deps -> + with(deps) { + previewsRepository.previewsModel.value = + PreviewsModel( + previewModels = + setOf( + PreviewModel( + Uri.fromParts("scheme", "ssp", "fragment"), + null, + ), + PreviewModel( + Uri.fromParts("scheme1", "ssp1", "fragment1"), + null, + ) + ), + startIdx = 1, + loadMoreLeft = null, + loadMoreRight = null, + ) + runCurrent() + + assertWithMessage("previewsKeys is null").that(underTest.previews.first()).isNotNull() + assertThat(underTest.previews.first()!!.previewModels) + .comparingElementsUsingTransform("has uri of") { it: PreviewModel -> it.uri } + .containsExactly( + Uri.fromParts("scheme", "ssp", "fragment"), + Uri.fromParts("scheme1", "ssp1", "fragment1"), + ) + .inOrder() + + val previewVm = + underTest.preview(PreviewModel(Uri.fromParts("scheme1", "ssp1", "fragment1"), null)) + + assertWithMessage("preview bitmap is null").that(previewVm.bitmap.first()).isNotNull() + assertThat(previewVm.isSelected.first()).isFalse() + + previewVm.setSelected(true) + + assertThat(selectionRepository.selections.value) + .comparingElementsUsingTransform("has uri of") { model: PreviewModel -> model.uri } + .contains(Uri.fromParts("scheme1", "ssp1", "fragment1")) + } + } + + @Test + fun actions() = runTestWithDeps { deps -> + with(deps) { + assertThat(underTest.actions.first()).isEmpty() + + val bitmap = Bitmap.createBitmap(100, 100, Bitmap.Config.ALPHA_8) + val icon = Icon.createWithBitmap(bitmap) + var actionSent = false + targetIntentRepo.customActions.value = + listOf(CustomActionModel("label1", icon) { actionSent = true }) + runCurrent() + + assertThat(underTest.actions.first()) + .comparingElementsUsingTransform("has a label of") { vm: ActionChipViewModel -> + vm.label + } + .containsExactly("label1") + .inOrder() + assertThat(underTest.actions.first()) + .comparingElementsUsingTransform("has an icon of") { vm: ActionChipViewModel -> + vm.icon + } + .containsExactly(BitmapIcon(icon.bitmap)) + .inOrder() + + underTest.actions.first()[0].onClicked() + + assertThat(actionSent).isTrue() + assertThat(eventLog.customActionSelected) + .isEqualTo(FakeEventLog.CustomActionSelected(0)) + assertThat(activityResultRepository.activityResult.value).isEqualTo(Activity.RESULT_OK) + } + } +} diff --git a/tests/unit/src/com/android/intentresolver/v2/ChooserMutableActionFactoryTest.kt b/tests/unit/src/com/android/intentresolver/v2/ChooserMutableActionFactoryTest.kt deleted file mode 100644 index ec2b807d..00000000 --- a/tests/unit/src/com/android/intentresolver/v2/ChooserMutableActionFactoryTest.kt +++ /dev/null @@ -1,139 +0,0 @@ -/* - * 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.v2 - -import android.app.PendingIntent -import android.content.Intent -import android.content.res.Resources -import android.graphics.drawable.Icon -import android.service.chooser.ChooserAction -import androidx.test.ext.junit.runners.AndroidJUnit4 -import androidx.test.platform.app.InstrumentationRegistry -import com.android.intentresolver.ChooserRequestParameters -import com.android.intentresolver.logging.EventLog -import com.android.intentresolver.mock -import com.android.intentresolver.whenever -import com.google.common.collect.ImmutableList -import com.google.common.truth.Truth.assertWithMessage -import java.util.Optional -import java.util.function.Consumer -import kotlinx.coroutines.flow.first -import kotlinx.coroutines.test.TestScope -import kotlinx.coroutines.test.runTest -import org.junit.Test -import org.junit.runner.RunWith - -@RunWith(AndroidJUnit4::class) -class ChooserMutableActionFactoryTest { - private val context - get() = InstrumentationRegistry.getInstrumentation().context - - private val logger = mock<EventLog>() - private val testAction = "com.android.intentresolver.testaction" - private val resultConsumer = - object : Consumer<Int> { - var latestReturn = Integer.MIN_VALUE - - override fun accept(resultCode: Int) { - latestReturn = resultCode - } - } - - private val scope = TestScope() - - @Test - fun testInitialValue() = - scope.runTest { - val actions = createChooserActions(2) - val actionFactory = createFactory(actions) - val testSubject = ChooserMutableActionFactory(actionFactory) - - val createdActions = testSubject.createCustomActions() - val observedActions = testSubject.customActionsFlow.first() - - assertWithMessage("Unexpected actions") - .that(createdActions.map { it.label }) - .containsExactlyElementsIn(actions.map { it.label }) - .inOrder() - assertWithMessage("Initially created and initially observed actions should be the same") - .that(createdActions) - .containsExactlyElementsIn(observedActions) - .inOrder() - } - - @Test - fun testUpdateActions_newActionsPublished() = - scope.runTest { - val initialActions = createChooserActions(2) - val updatedActions = createChooserActions(3) - val actionFactory = createFactory(initialActions) - val testSubject = ChooserMutableActionFactory(actionFactory) - - testSubject.updateCustomActions(updatedActions) - val observedActions = testSubject.customActionsFlow.first() - - assertWithMessage("Unexpected updated actions") - .that(observedActions.map { it.label }) - .containsAtLeastElementsIn(updatedActions.map { it.label }) - .inOrder() - } - - private fun createFactory(actions: List<ChooserAction>): ChooserActionFactory { - val targetIntent = Intent() - val chooserRequest = mock<ChooserRequestParameters>() - whenever(chooserRequest.targetIntent).thenReturn(targetIntent) - whenever(chooserRequest.chooserActions).thenReturn(ImmutableList.copyOf(actions)) - - return ChooserActionFactory( - /* context = */ context, - /* targetIntent = */ chooserRequest.targetIntent, - /* referrerPackageName = */ chooserRequest.referrerPackageName, - /* chooserActions = */ chooserRequest.chooserActions, - /* modifyShareAction = */ chooserRequest.modifyShareAction, - /* imageEditor = */ Optional.empty(), - /* log = */ logger, - /* onUpdateSharedTextIsExcluded = */ {}, - /* firstVisibleImageQuery = */ { null }, - /* activityStarter = */ mock(), - /* shareResultSender = */ null, - /* finishCallback = */ resultConsumer, - mock() - ) - } - - private fun createChooserActions(count: Int): List<ChooserAction> { - return buildList(count) { - for (i in 1..count) { - val testPendingIntent = - PendingIntent.getBroadcast( - context, - i, - Intent(testAction), - PendingIntent.FLAG_IMMUTABLE - ) - val action = - ChooserAction.Builder( - Icon.createWithResource("", Resources.ID_NULL), - "Label $i", - testPendingIntent - ) - .build() - add(action) - } - } - } -} |