summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
-rw-r--r--java/src/com/android/intentresolver/ChooserActivity.java2
-rw-r--r--java/src/com/android/intentresolver/contentpreview/BasePreviewViewModel.kt3
-rw-r--r--java/src/com/android/intentresolver/contentpreview/CursorUriReader.kt147
-rw-r--r--java/src/com/android/intentresolver/contentpreview/HeadlineGeneratorImpl.kt18
-rw-r--r--java/src/com/android/intentresolver/contentpreview/PayloadToggleInteractor.kt372
-rw-r--r--java/src/com/android/intentresolver/contentpreview/PreviewViewModel.kt51
-rw-r--r--java/src/com/android/intentresolver/contentpreview/SelectionTracker.kt175
-rw-r--r--java/src/com/android/intentresolver/contentpreview/ShareouselContentPreviewUi.kt107
-rw-r--r--java/src/com/android/intentresolver/contentpreview/payloadtoggle/app/viewmodel/ShareouselContentPreviewViewModel.kt46
-rw-r--r--java/src/com/android/intentresolver/contentpreview/payloadtoggle/ui/composable/ComposeIconComposable.kt (renamed from java/src/com/android/intentresolver/contentpreview/shareousel/ui/composable/ComposeIconComposable.kt)12
-rw-r--r--java/src/com/android/intentresolver/contentpreview/payloadtoggle/ui/composable/ShareouselCardComposable.kt (renamed from java/src/com/android/intentresolver/contentpreview/shareousel/ui/composable/ShareouselCardComposable.kt)8
-rw-r--r--java/src/com/android/intentresolver/contentpreview/payloadtoggle/ui/composable/ShareouselComposable.kt (renamed from java/src/com/android/intentresolver/contentpreview/shareousel/ui/composable/ShareouselComposable.kt)132
-rw-r--r--java/src/com/android/intentresolver/contentpreview/payloadtoggle/ui/viewmodel/ActionChipViewModel.kt (renamed from java/src/com/android/intentresolver/contentpreview/MutableActionFactory.kt)24
-rw-r--r--java/src/com/android/intentresolver/contentpreview/payloadtoggle/ui/viewmodel/ShareouselPreviewViewModel.kt39
-rw-r--r--java/src/com/android/intentresolver/contentpreview/payloadtoggle/ui/viewmodel/ShareouselViewModel.kt141
-rw-r--r--java/src/com/android/intentresolver/contentpreview/shareousel/ui/viewmodel/ShareouselViewModel.kt90
-rw-r--r--java/src/com/android/intentresolver/inject/Qualifiers.kt5
-rw-r--r--java/src/com/android/intentresolver/inject/ViewModelCoroutineScopeModule.kt42
-rw-r--r--java/src/com/android/intentresolver/v2/ChooserActivity.java23
-rw-r--r--java/src/com/android/intentresolver/v2/ChooserMutableActionFactory.kt54
-rw-r--r--tests/activity/src/com/android/intentresolver/logging/TestEventLogModule.kt8
-rw-r--r--tests/shared/src/com/android/intentresolver/MockitoKotlinHelpers.kt66
-rw-r--r--tests/shared/src/com/android/intentresolver/TestContentPreviewViewModel.kt14
-rw-r--r--tests/unit/src/com/android/intentresolver/contentpreview/CursorUriReaderTest.kt125
-rw-r--r--tests/unit/src/com/android/intentresolver/contentpreview/PayloadToggleInteractorTest.kt162
-rw-r--r--tests/unit/src/com/android/intentresolver/contentpreview/PreviewViewModelTest.kt81
-rw-r--r--tests/unit/src/com/android/intentresolver/contentpreview/SelectionTrackerTest.kt330
-rw-r--r--tests/unit/src/com/android/intentresolver/contentpreview/payloadtoggle/ui/viewmodel/ShareouselViewModelTest.kt243
-rw-r--r--tests/unit/src/com/android/intentresolver/v2/ChooserMutableActionFactoryTest.kt139
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)
- }
- }
- }
-}