diff options
| author | 2024-03-06 12:45:02 -0500 | |
|---|---|---|
| committer | 2024-03-07 14:59:54 -0500 | |
| commit | 280fd53ff42834beba56a4eff56e5e7802b250ee (patch) | |
| tree | b9e0a1db1422184639939fef3ff2b4cdb90b1794 /java/src | |
| parent | e53d019e8c14c7d17c99b6d75843cdb03fb81d81 (diff) | |
PayloadToggle domain layer preview data population
This change introduces the logic by which Shareousel is initialized with
previews, as well as how additional previews can be loaded if requested
by the UI.
Initially, previews are taken from the EXTRA_STREAM contents of the
sharing intent. Simultaneously, a connection to the
sharing-application's cursor is established and then queried; the
initial data loaded from the cursor then replaces the initial content.
When the UI requests, additional data from the cursor is loaded into
memory; the loaded data is always contiguous, and so requests can only
be made to load the data immediately before or after whatever is already
loaded.
Once an end of the cursor has been reached (either first or last row),
any elements in the intitial selection set (EXTRA_STREAM) that have not
yet appeared in the Cursor are appended, *if* those elements would have
appeared in that direction relative to the "focused index" of the set.
In order to limit memory usage, we limit the amount of data cached in
memory from the Cursor; as additional data is loaded in one direction,
data from the other direction can be evicted from the cache.
Bug: 302691505
Flag: ACONFIG android.service.chooser.chooser_payload_toggling DEVELOPMENT
Test: atest IntentResolver-tests-unit
Change-Id: I0ba7bffc10b4369d55a14aec626ca2c22f95dbb7
Diffstat (limited to 'java/src')
18 files changed, 1074 insertions, 9 deletions
diff --git a/java/src/com/android/intentresolver/contentpreview/PreviewViewModel.kt b/java/src/com/android/intentresolver/contentpreview/PreviewViewModel.kt index d694c6ff..2468bb57 100644 --- a/java/src/com/android/intentresolver/contentpreview/PreviewViewModel.kt +++ b/java/src/com/android/intentresolver/contentpreview/PreviewViewModel.kt @@ -114,7 +114,7 @@ class PreviewViewModel( chooserIntent ) }, - UriMetadataReader(contentResolver, DefaultMimeTypeClassifier), + UriMetadataReaderImpl(contentResolver, DefaultMimeTypeClassifier)::getMetadata, TargetIntentModifier(targetIntent, getUri = { uri }, getMimeType = { mimeType }), SelectionChangeCallback(contentProviderUri, chooserIntent, contentResolver) ) diff --git a/java/src/com/android/intentresolver/contentpreview/UriMetadataReader.kt b/java/src/com/android/intentresolver/contentpreview/UriMetadataReader.kt index 45515e25..b5361889 100644 --- a/java/src/com/android/intentresolver/contentpreview/UriMetadataReader.kt +++ b/java/src/com/android/intentresolver/contentpreview/UriMetadataReader.kt @@ -20,12 +20,24 @@ import android.content.ContentInterface import android.media.MediaMetadata import android.net.Uri import android.provider.DocumentsContract +import dagger.Binds +import dagger.Module +import dagger.Provides +import dagger.hilt.InstallIn +import dagger.hilt.components.SingletonComponent +import javax.inject.Inject -class UriMetadataReader( +fun interface UriMetadataReader { + fun getMetadata(uri: Uri): FileInfo +} + +class UriMetadataReaderImpl +@Inject +constructor( private val contentResolver: ContentInterface, private val typeClassifier: MimeTypeClassifier, -) : (Uri) -> FileInfo { - fun getMetadata(uri: Uri): FileInfo { +) : UriMetadataReader { + override fun getMetadata(uri: Uri): FileInfo { val builder = FileInfo.Builder(uri) val mimeType = contentResolver.getTypeSafe(uri) builder.withMimeType(mimeType) @@ -44,8 +56,6 @@ class UriMetadataReader( return builder.build() } - override fun invoke(uri: Uri): FileInfo = getMetadata(uri) - private fun ContentInterface.supportsImageType(uri: Uri): Boolean = getStreamTypesSafe(uri).firstOrNull { typeClassifier.isImageType(it) } != null @@ -64,3 +74,14 @@ class UriMetadataReader( } } } + +@Module +@InstallIn(SingletonComponent::class) +interface UriMetadataReaderModule { + + @Binds fun bind(impl: UriMetadataReaderImpl): UriMetadataReader + + companion object { + @Provides fun classifier(): MimeTypeClassifier = DefaultMimeTypeClassifier + } +} diff --git a/java/src/com/android/intentresolver/contentpreview/payloadtoggle/domain/cursor/CursorResolver.kt b/java/src/com/android/intentresolver/contentpreview/payloadtoggle/domain/cursor/CursorResolver.kt new file mode 100644 index 00000000..3aa0d567 --- /dev/null +++ b/java/src/com/android/intentresolver/contentpreview/payloadtoggle/domain/cursor/CursorResolver.kt @@ -0,0 +1,24 @@ +/* + * 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.domain.cursor + +import com.android.intentresolver.util.cursor.CursorView + +/** Asynchronously retrieves a [CursorView]. */ +fun interface CursorResolver<out T> { + suspend fun getCursor(): CursorView<T>? +} diff --git a/java/src/com/android/intentresolver/contentpreview/payloadtoggle/domain/cursor/PayloadToggleCursorResolver.kt b/java/src/com/android/intentresolver/contentpreview/payloadtoggle/domain/cursor/PayloadToggleCursorResolver.kt new file mode 100644 index 00000000..286891d1 --- /dev/null +++ b/java/src/com/android/intentresolver/contentpreview/payloadtoggle/domain/cursor/PayloadToggleCursorResolver.kt @@ -0,0 +1,69 @@ +/* + * 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.domain.cursor + +import android.content.ContentResolver +import android.content.Intent +import android.net.Uri +import android.service.chooser.AdditionalContentContract.Columns.URI +import com.android.intentresolver.inject.AdditionalContent +import com.android.intentresolver.inject.ChooserIntent +import com.android.intentresolver.util.Bundle +import com.android.intentresolver.util.cursor.CursorView +import com.android.intentresolver.util.cursor.viewBy +import com.android.intentresolver.util.withCancellationSignal +import dagger.Binds +import dagger.Module +import dagger.hilt.InstallIn +import dagger.hilt.android.components.ViewModelComponent +import javax.inject.Inject +import javax.inject.Qualifier + +/** [CursorResolver] for the [CursorView] underpinning Shareousel. */ +class PayloadToggleCursorResolver +@Inject +constructor( + private val contentResolver: ContentResolver, + @AdditionalContent private val cursorUri: Uri, + @ChooserIntent private val chooserIntent: Intent, +) : CursorResolver<Uri?> { + override suspend fun getCursor(): CursorView<Uri?>? = withCancellationSignal { signal -> + runCatching { + contentResolver.query( + cursorUri, + arrayOf(URI), + Bundle { putParcelable(Intent.EXTRA_INTENT, chooserIntent) }, + signal, + ) + } + .getOrNull() + ?.viewBy { + getString(0)?.let(Uri::parse)?.takeIf { it.authority != cursorUri.authority } + } + } + + @Module + @InstallIn(ViewModelComponent::class) + interface Binding { + @Binds + @PayloadToggle + fun bind(cursorResolver: PayloadToggleCursorResolver): CursorResolver<Uri?> + } +} + +/** [CursorResolver] for the [CursorView] underpinning Shareousel. */ +@Qualifier @MustBeDocumented @Retention(AnnotationRetention.RUNTIME) annotation class PayloadToggle diff --git a/java/src/com/android/intentresolver/contentpreview/payloadtoggle/domain/interactor/CursorPreviewsInteractor.kt b/java/src/com/android/intentresolver/contentpreview/payloadtoggle/domain/interactor/CursorPreviewsInteractor.kt new file mode 100644 index 00000000..f642f420 --- /dev/null +++ b/java/src/com/android/intentresolver/contentpreview/payloadtoggle/domain/interactor/CursorPreviewsInteractor.kt @@ -0,0 +1,294 @@ +/* + * 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.domain.interactor + +import android.net.Uri +import android.service.chooser.AdditionalContentContract.CursorExtraKeys.POSITION +import com.android.intentresolver.contentpreview.UriMetadataReader +import com.android.intentresolver.contentpreview.payloadtoggle.domain.model.LoadDirection +import com.android.intentresolver.contentpreview.payloadtoggle.domain.model.LoadedWindow +import com.android.intentresolver.contentpreview.payloadtoggle.domain.model.expandWindowLeft +import com.android.intentresolver.contentpreview.payloadtoggle.domain.model.expandWindowRight +import com.android.intentresolver.contentpreview.payloadtoggle.domain.model.numLoadedPages +import com.android.intentresolver.contentpreview.payloadtoggle.domain.model.shiftWindowLeft +import com.android.intentresolver.contentpreview.payloadtoggle.domain.model.shiftWindowRight +import com.android.intentresolver.contentpreview.payloadtoggle.shared.model.PreviewModel +import com.android.intentresolver.inject.FocusedItemIndex +import com.android.intentresolver.util.cursor.CursorView +import com.android.intentresolver.util.cursor.PagedCursor +import com.android.intentresolver.util.cursor.get +import com.android.intentresolver.util.cursor.paged +import com.android.intentresolver.util.mapParallel +import dagger.Module +import dagger.Provides +import dagger.hilt.InstallIn +import dagger.hilt.components.SingletonComponent +import java.util.concurrent.ConcurrentHashMap +import javax.inject.Inject +import javax.inject.Qualifier +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.filterNotNull +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.flow.mapLatest + +/** Queries data from a remote cursor, and caches it locally for presentation in Shareousel. */ +class CursorPreviewsInteractor +@Inject +constructor( + private val interactor: SetCursorPreviewsInteractor, + @FocusedItemIndex private val focusedItemIdx: Int, + private val uriMetadataReader: UriMetadataReader, + @PageSize private val pageSize: Int, + @MaxLoadedPages private val maxLoadedPages: Int, +) { + + init { + check(pageSize > 0) { "pageSize must be greater than zero" } + } + + /** Start reading data from [uriCursor], and listen for requests to load more. */ + suspend fun launch(uriCursor: CursorView<Uri?>, initialPreviews: Iterable<PreviewModel>) { + // Unclaimed values from the initial selection set. Entries will be removed as the cursor is + // read, and any still present are inserted at the start / end of the cursor when it is + // reached by the user. + val unclaimedRecords: MutableUnclaimedMap = + initialPreviews + .asSequence() + .mapIndexed { i, m -> Pair(m.uri, Pair(i, m)) } + .toMap(ConcurrentHashMap()) + val pagedCursor: PagedCursor<Uri?> = uriCursor.paged(pageSize) + val startPosition = uriCursor.extras?.getInt(POSITION, 0) ?: 0 + val state = readInitialState(pagedCursor, startPosition, unclaimedRecords) + processLoadRequests(state, pagedCursor, unclaimedRecords) + } + + /** Loop forever, processing any loading requests from the UI and updating local cache. */ + private suspend fun processLoadRequests( + initialState: CursorWindow, + pagedCursor: PagedCursor<Uri?>, + unclaimedRecords: MutableUnclaimedMap, + ) { + var state = initialState + while (true) { + // Design note: in order to prevent load requests from the UI when it was displaying a + // previously-published dataset being accidentally associated with a recently-published + // one, we generate a new Flow of load requests for each dataset and only listen to + // those. + val loadingState: Flow<LoadDirection?> = + interactor.setPreviews( + previewsByKey = state.merged.values.toSet(), + startIndex = 0, // TODO: actually track this as the window changes? + hasMoreLeft = state.hasMoreLeft, + hasMoreRight = state.hasMoreRight, + ) + state = loadingState.handleOneLoadRequest(state, pagedCursor, unclaimedRecords) + } + } + + /** + * Suspends until a single loading request has been handled, returning the new [CursorWindow] + * with the loaded data incorporated. + */ + private suspend fun Flow<LoadDirection?>.handleOneLoadRequest( + state: CursorWindow, + pagedCursor: PagedCursor<Uri?>, + unclaimedRecords: MutableUnclaimedMap, + ): CursorWindow = + mapLatest { loadDirection -> + loadDirection?.let { + when (loadDirection) { + LoadDirection.Left -> state.loadMoreLeft(pagedCursor, unclaimedRecords) + LoadDirection.Right -> state.loadMoreRight(pagedCursor, unclaimedRecords) + } + } + } + .filterNotNull() + .first() + + /** + * Returns the initial [CursorWindow], with a single page loaded that contains the given + * [startPosition]. + */ + private suspend fun readInitialState( + cursor: PagedCursor<Uri?>, + startPosition: Int, + unclaimedRecords: MutableUnclaimedMap, + ): CursorWindow { + val startPageIdx = startPosition / pageSize + val hasMoreLeft = startPageIdx > 0 + val hasMoreRight = startPageIdx < cursor.count - 1 + val page: PreviewMap = buildMap { + if (!hasMoreLeft) { + // First read the initial page; this might claim some unclaimed Uris + val page = + cursor.getPageUris(startPageIdx)?.toPage(mutableMapOf(), unclaimedRecords) + // Now that unclaimed Uris are up-to-date, add them first. + putAllUnclaimedLeft(unclaimedRecords) + // Then add the loaded page + page?.let(::putAll) + } else { + cursor.getPageUris(startPageIdx)?.toPage(this, unclaimedRecords) + } + // Finally, add the remainder of the unclaimed Uris. + if (!hasMoreRight) { + putAllUnclaimedRight(unclaimedRecords) + } + } + return CursorWindow( + firstLoadedPageNum = startPageIdx, + lastLoadedPageNum = startPageIdx, + pages = listOf(page.keys), + merged = page, + hasMoreLeft = hasMoreLeft, + hasMoreRight = hasMoreRight, + ) + } + + private suspend fun CursorWindow.loadMoreRight( + cursor: PagedCursor<Uri?>, + unclaimedRecords: MutableUnclaimedMap, + ): CursorWindow { + val pageNum = lastLoadedPageNum + 1 + val hasMoreRight = pageNum < cursor.count - 1 + val newPage: PreviewMap = buildMap { + readAndPutPage(this@loadMoreRight, cursor, pageNum, unclaimedRecords) + if (!hasMoreRight) { + putAllUnclaimedRight(unclaimedRecords) + } + } + return if (numLoadedPages < maxLoadedPages) { + expandWindowRight(newPage, hasMoreRight) + } else { + shiftWindowRight(newPage, hasMoreRight) + } + } + + private suspend fun CursorWindow.loadMoreLeft( + cursor: PagedCursor<Uri?>, + unclaimedRecords: MutableUnclaimedMap, + ): CursorWindow { + val pageNum = firstLoadedPageNum - 1 + val hasMoreLeft = pageNum > 0 + val newPage: PreviewMap = buildMap { + if (!hasMoreLeft) { + // First read the page; this might claim some unclaimed Uris + val page = readPage(this@loadMoreLeft, cursor, pageNum, unclaimedRecords) + // Now that unclaimed URIs are up-to-date, add them first + putAllUnclaimedLeft(unclaimedRecords) + // Then add the loaded page + putAll(page) + } else { + readAndPutPage(this@loadMoreLeft, cursor, pageNum, unclaimedRecords) + } + } + return if (numLoadedPages < maxLoadedPages) { + expandWindowLeft(newPage, hasMoreLeft) + } else { + shiftWindowLeft(newPage, hasMoreLeft) + } + } + + private suspend fun readPage( + state: CursorWindow, + pagedCursor: PagedCursor<Uri?>, + pageNum: Int, + unclaimedRecords: MutableUnclaimedMap, + ): PreviewMap = + mutableMapOf<Uri, PreviewModel>() + .readAndPutPage(state, pagedCursor, pageNum, unclaimedRecords) + + private suspend fun <M : MutablePreviewMap> M.readAndPutPage( + state: CursorWindow, + pagedCursor: PagedCursor<Uri?>, + pageNum: Int, + unclaimedRecords: MutableUnclaimedMap, + ): M = + pagedCursor + .getPageUris(pageNum) // TODO: what do we do if the load fails? + ?.filter { it !in state.merged } + ?.toPage(this, unclaimedRecords) + ?: this + + private suspend fun <M : MutablePreviewMap> Sequence<Uri>.toPage( + destination: M, + unclaimedRecords: MutableUnclaimedMap, + ): M = + // Restrict parallelism so as to not overload the metadata reader; anecdotally, too + // many parallel queries causes failures. + mapParallel(parallelism = 4) { uri -> createPreviewModel(uri, unclaimedRecords) } + .associateByTo(destination) { it.uri } + + private fun createPreviewModel(uri: Uri, unclaimedRecords: MutableUnclaimedMap): PreviewModel = + unclaimedRecords.remove(uri)?.second + ?: PreviewModel( + uri = uri, + mimeType = uriMetadataReader.getMetadata(uri).mimeType, + ) + + private fun <M : MutablePreviewMap> M.putAllUnclaimedRight(unclaimed: UnclaimedMap): M = + putAllUnclaimedWhere(unclaimed) { it >= focusedItemIdx } + + private fun <M : MutablePreviewMap> M.putAllUnclaimedLeft(unclaimed: UnclaimedMap): M = + putAllUnclaimedWhere(unclaimed) { it < focusedItemIdx } +} + +private typealias CursorWindow = LoadedWindow<Uri, PreviewModel> + +/** + * Values from the initial selection set that have not yet appeared within the Cursor. These values + * are appended to the start/end of the cursor dataset, depending on their position relative to the + * initially focused value. + */ +private typealias UnclaimedMap = Map<Uri, Pair<Int, PreviewModel>> + +/** Mutable version of [UnclaimedMap]. */ +private typealias MutableUnclaimedMap = MutableMap<Uri, Pair<Int, PreviewModel>> + +private typealias MutablePreviewMap = MutableMap<Uri, PreviewModel> + +private typealias PreviewMap = Map<Uri, PreviewModel> + +private fun <M : MutablePreviewMap> M.putAllUnclaimedWhere( + unclaimedRecords: UnclaimedMap, + predicate: (Int) -> Boolean, +): M = + unclaimedRecords + .asSequence() + .filter { predicate(it.value.first) } + .map { it.key to it.value.second } + .toMap(this) + +private fun PagedCursor<Uri?>.getPageUris(pageNum: Int): Sequence<Uri>? = + get(pageNum)?.filterNotNull() + +@Qualifier @MustBeDocumented @Retention(AnnotationRetention.RUNTIME) annotation class PageSize + +@Qualifier +@MustBeDocumented +@Retention(AnnotationRetention.RUNTIME) +annotation class MaxLoadedPages + +@Module +@InstallIn(SingletonComponent::class) +object ShareouselConstants { + @Provides @PageSize fun pageSize(): Int = 16 + + @Provides @MaxLoadedPages fun maxLoadedPages(): Int = 3 +} diff --git a/java/src/com/android/intentresolver/contentpreview/payloadtoggle/domain/interactor/FetchPreviewsInteractor.kt b/java/src/com/android/intentresolver/contentpreview/payloadtoggle/domain/interactor/FetchPreviewsInteractor.kt new file mode 100644 index 00000000..032692cd --- /dev/null +++ b/java/src/com/android/intentresolver/contentpreview/payloadtoggle/domain/interactor/FetchPreviewsInteractor.kt @@ -0,0 +1,65 @@ +/* + * 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.domain.interactor + +import android.net.Uri +import com.android.intentresolver.contentpreview.UriMetadataReader +import com.android.intentresolver.contentpreview.payloadtoggle.data.repository.PreviewSelectionsRepository +import com.android.intentresolver.contentpreview.payloadtoggle.domain.cursor.CursorResolver +import com.android.intentresolver.contentpreview.payloadtoggle.domain.cursor.PayloadToggle +import com.android.intentresolver.contentpreview.payloadtoggle.shared.model.PreviewModel +import com.android.intentresolver.inject.ContentUris +import com.android.intentresolver.inject.FocusedItemIndex +import com.android.intentresolver.util.mapParallel +import javax.inject.Inject +import kotlinx.coroutines.async +import kotlinx.coroutines.coroutineScope + +/** Populates the data displayed in Shareousel. */ +class FetchPreviewsInteractor +@Inject +constructor( + private val setCursorPreviews: SetCursorPreviewsInteractor, + private val selectionRepository: PreviewSelectionsRepository, + private val cursorInteractor: CursorPreviewsInteractor, + @FocusedItemIndex private val focusedItemIdx: Int, + @ContentUris private val selectedItems: List<@JvmSuppressWildcards Uri>, + private val uriMetadataReader: UriMetadataReader, + @PayloadToggle private val cursorResolver: CursorResolver<@JvmSuppressWildcards Uri?>, +) { + suspend fun launch() = coroutineScope { + val cursor = async { cursorResolver.getCursor() } + val initialPreviewMap: Set<PreviewModel> = getInitialPreviews() + selectionRepository.selections.value = initialPreviewMap + setCursorPreviews.setPreviews( + previewsByKey = initialPreviewMap, + startIndex = focusedItemIdx, + hasMoreLeft = false, + hasMoreRight = false, + ) + cursorInteractor.launch(cursor.await() ?: return@coroutineScope, initialPreviewMap) + } + + private suspend fun getInitialPreviews(): Set<PreviewModel> = + selectedItems + // Restrict parallelism so as to not overload the metadata reader; anecdotally, too + // many parallel queries causes failures. + .mapParallel(parallelism = 4) { uri -> + PreviewModel(uri = uri, mimeType = uriMetadataReader.getMetadata(uri).mimeType) + } + .toSet() +} diff --git a/java/src/com/android/intentresolver/contentpreview/payloadtoggle/domain/interactor/SetCursorPreviewsInteractor.kt b/java/src/com/android/intentresolver/contentpreview/payloadtoggle/domain/interactor/SetCursorPreviewsInteractor.kt new file mode 100644 index 00000000..21a599fa --- /dev/null +++ b/java/src/com/android/intentresolver/contentpreview/payloadtoggle/domain/interactor/SetCursorPreviewsInteractor.kt @@ -0,0 +1,59 @@ +/* + * 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.domain.interactor + +import com.android.intentresolver.contentpreview.payloadtoggle.data.repository.CursorPreviewsRepository +import com.android.intentresolver.contentpreview.payloadtoggle.domain.model.LoadDirection +import com.android.intentresolver.contentpreview.payloadtoggle.shared.model.PreviewModel +import com.android.intentresolver.contentpreview.payloadtoggle.shared.model.PreviewsModel +import javax.inject.Inject +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.asStateFlow + +/** Updates [CursorPreviewsRepository] with new previews. */ +class SetCursorPreviewsInteractor +@Inject +constructor(private val previewsRepo: CursorPreviewsRepository) { + /** Stores new [previewsByKey], and returns a flow of load requests triggered by Shareousel. */ + fun setPreviews( + previewsByKey: Set<PreviewModel>, + startIndex: Int, + hasMoreLeft: Boolean, + hasMoreRight: Boolean, + ): Flow<LoadDirection?> { + val loadingState = MutableStateFlow<LoadDirection?>(null) + previewsRepo.previewsModel.value = + PreviewsModel( + previewModels = previewsByKey, + startIdx = startIndex, + loadMoreLeft = + if (hasMoreLeft) { + ({ loadingState.value = LoadDirection.Left }) + } else { + null + }, + loadMoreRight = + if (hasMoreRight) { + ({ loadingState.value = LoadDirection.Right }) + } else { + null + }, + ) + return loadingState.asStateFlow() + } +} diff --git a/java/src/com/android/intentresolver/contentpreview/payloadtoggle/domain/model/LoadDirection.kt b/java/src/com/android/intentresolver/contentpreview/payloadtoggle/domain/model/LoadDirection.kt new file mode 100644 index 00000000..23510f15 --- /dev/null +++ b/java/src/com/android/intentresolver/contentpreview/payloadtoggle/domain/model/LoadDirection.kt @@ -0,0 +1,23 @@ +/* + * 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.domain.model + +/** Specifies which side of the dataset is being loaded. */ +enum class LoadDirection { + Left, + Right, +} diff --git a/java/src/com/android/intentresolver/contentpreview/payloadtoggle/domain/model/LoadedWindow.kt b/java/src/com/android/intentresolver/contentpreview/payloadtoggle/domain/model/LoadedWindow.kt new file mode 100644 index 00000000..e2e69852 --- /dev/null +++ b/java/src/com/android/intentresolver/contentpreview/payloadtoggle/domain/model/LoadedWindow.kt @@ -0,0 +1,102 @@ +/* + * 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.domain.model + +/** A window of data loaded from a cursor. */ +data class LoadedWindow<K, V>( + /** First cursor page index loaded within this window. */ + val firstLoadedPageNum: Int, + /** Last cursor page index loaded within this window. */ + val lastLoadedPageNum: Int, + /** Keys of cursor data within this window, grouped by loaded page. */ + val pages: List<Set<K>>, + /** Merged set of all cursor data within this window. */ + val merged: Map<K, V>, + /** Is there more data to the left of this window? */ + val hasMoreLeft: Boolean, + /** Is there more data to the right of this window? */ + val hasMoreRight: Boolean, +) + +/** Number of loaded pages stored within this [LoadedWindow]. */ +val LoadedWindow<*, *>.numLoadedPages: Int + get() = (lastLoadedPageNum - firstLoadedPageNum) + 1 + +/** Inserts [newPage] to the right, and removes the leftmost page from the window. */ +fun <K, V> LoadedWindow<K, V>.shiftWindowRight( + newPage: Map<K, V>, + hasMore: Boolean, +): LoadedWindow<K, V> = + LoadedWindow( + firstLoadedPageNum = firstLoadedPageNum + 1, + lastLoadedPageNum = lastLoadedPageNum + 1, + pages = pages.drop(1) + listOf(newPage.keys), + merged = + buildMap { + putAll(merged) + pages.first().forEach(::remove) + putAll(newPage) + }, + hasMoreLeft = true, + hasMoreRight = hasMore, + ) + +/** Inserts [newPage] to the right, increasing the size of the window to accommodate it. */ +fun <K, V> LoadedWindow<K, V>.expandWindowRight( + newPage: Map<K, V>, + hasMore: Boolean, +): LoadedWindow<K, V> = + LoadedWindow( + firstLoadedPageNum = firstLoadedPageNum, + lastLoadedPageNum = lastLoadedPageNum + 1, + pages = pages + listOf(newPage.keys), + merged = merged + newPage, + hasMoreLeft = hasMoreLeft, + hasMoreRight = hasMore, + ) + +/** Inserts [newPage] to the left, and removes the rightmost page from the window. */ +fun <K, V> LoadedWindow<K, V>.shiftWindowLeft( + newPage: Map<K, V>, + hasMore: Boolean, +): LoadedWindow<K, V> = + LoadedWindow( + firstLoadedPageNum = firstLoadedPageNum - 1, + lastLoadedPageNum = lastLoadedPageNum - 1, + pages = listOf(newPage.keys) + pages.dropLast(1), + merged = + buildMap { + putAll(newPage) + putAll(merged - pages.last()) + }, + hasMoreLeft = hasMore, + hasMoreRight = true, + ) + +/** Inserts [newPage] to the left, increasing the size olf the window to accommodate it. */ +fun <K, V> LoadedWindow<K, V>.expandWindowLeft( + newPage: Map<K, V>, + hasMore: Boolean, +): LoadedWindow<K, V> = + LoadedWindow( + firstLoadedPageNum = firstLoadedPageNum - 1, + lastLoadedPageNum = lastLoadedPageNum, + pages = listOf(newPage.keys) + pages, + merged = newPage + merged, + hasMoreLeft = hasMore, + hasMoreRight = hasMoreRight, + ) diff --git a/java/src/com/android/intentresolver/inject/ActivityModelModule.kt b/java/src/com/android/intentresolver/inject/ActivityModelModule.kt index 9a8b8768..c08c7f4c 100644 --- a/java/src/com/android/intentresolver/inject/ActivityModelModule.kt +++ b/java/src/com/android/intentresolver/inject/ActivityModelModule.kt @@ -17,8 +17,10 @@ package com.android.intentresolver.inject import android.content.Intent +import android.net.Uri import android.service.chooser.ChooserAction import androidx.lifecycle.SavedStateHandle +import com.android.intentresolver.util.ownedByCurrentUser import com.android.intentresolver.v2.ui.model.ActivityModel import com.android.intentresolver.v2.ui.model.ChooserRequest import com.android.intentresolver.v2.ui.viewmodel.readChooserRequest @@ -41,6 +43,10 @@ object ActivityModelModule { } @Provides + @ChooserIntent + fun chooserIntent(activityModel: ActivityModel): Intent = activityModel.intent + + @Provides @ViewModelScoped fun provideChooserRequest( activityModel: ActivityModel, @@ -57,6 +63,57 @@ object ActivityModelModule { requireNotNull((chooserReq as? Valid)?.value?.chooserActions) { "no chooser actions available" } + + @Provides + @ViewModelScoped + @ContentUris + fun selectedUris(chooserRequest: ValidationResult<ChooserRequest>): List<Uri> = + requireNotNull((chooserRequest as? Valid)?.value?.targetIntent?.contentUris?.toList()) { + "no selected uris available" + } + + @Provides + @FocusedItemIndex + fun focusedItemIndex(chooserReq: ValidationResult<ChooserRequest>): Int = + requireNotNull((chooserReq as? Valid)?.value?.focusedItemPosition) { + "no focused item position available" + } + + @Provides + @AdditionalContent + fun additionalContentUri(chooserReq: ValidationResult<ChooserRequest>): Uri = + requireNotNull((chooserReq as? Valid)?.value?.additionalContentUri) { + "no additional content uri available" + } } +@Qualifier +@MustBeDocumented +@Retention(AnnotationRetention.RUNTIME) +annotation class FocusedItemIndex + +@Qualifier +@MustBeDocumented +@Retention(AnnotationRetention.RUNTIME) +annotation class AdditionalContent + +@Qualifier @MustBeDocumented @Retention(AnnotationRetention.RUNTIME) annotation class ChooserIntent + +@Qualifier @MustBeDocumented @Retention(AnnotationRetention.RUNTIME) annotation class ContentUris + @Qualifier @MustBeDocumented @Retention(AnnotationRetention.RUNTIME) annotation class TargetIntent + +private val Intent.contentUris: Sequence<Uri> + get() = sequence { + if (Intent.ACTION_SEND == action) { + getParcelableExtra(Intent.EXTRA_STREAM, Uri::class.java) + ?.takeIf { it.ownedByCurrentUser } + ?.let { yield(it) } + } else { + getParcelableArrayListExtra(Intent.EXTRA_STREAM, Uri::class.java)?.forEach { uri -> + if (uri.ownedByCurrentUser) { + yield(uri) + } + } + } + } diff --git a/java/src/com/android/intentresolver/inject/SystemServices.kt b/java/src/com/android/intentresolver/inject/SystemServices.kt index 32894d43..4762f4a1 100644 --- a/java/src/com/android/intentresolver/inject/SystemServices.kt +++ b/java/src/com/android/intentresolver/inject/SystemServices.kt @@ -18,12 +18,15 @@ package com.android.intentresolver.inject import android.app.ActivityManager import android.app.admin.DevicePolicyManager import android.content.ClipboardManager +import android.content.ContentInterface +import android.content.ContentResolver import android.content.Context import android.content.pm.LauncherApps import android.content.pm.ShortcutManager import android.os.UserManager import android.view.WindowManager import androidx.core.content.getSystemService +import dagger.Binds import dagger.Module import dagger.Provides import dagger.hilt.InstallIn @@ -52,9 +55,13 @@ class ClipboardManagerModule { @Module @InstallIn(SingletonComponent::class) -class ContentResolverModule { - @Provides - fun contentResolver(@ApplicationContext ctx: Context) = requireNotNull(ctx.contentResolver) +interface ContentResolverModule { + @Binds fun bindContentInterface(cr: ContentResolver): ContentInterface + + companion object { + @Provides + fun contentResolver(@ApplicationContext ctx: Context) = requireNotNull(ctx.contentResolver) + } } @Module diff --git a/java/src/com/android/intentresolver/util/BundleUtils.kt b/java/src/com/android/intentresolver/util/BundleUtils.kt new file mode 100644 index 00000000..da06afef --- /dev/null +++ b/java/src/com/android/intentresolver/util/BundleUtils.kt @@ -0,0 +1,22 @@ +/* + * 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.util + +import android.os.Bundle + +/** Shorthand for `Bundle().apply { ... } */ +inline fun Bundle(block: Bundle.() -> Unit): Bundle = Bundle().apply(block) diff --git a/java/src/com/android/intentresolver/util/CancellationSignalUtils.kt b/java/src/com/android/intentresolver/util/CancellationSignalUtils.kt new file mode 100644 index 00000000..e89cb5ca --- /dev/null +++ b/java/src/com/android/intentresolver/util/CancellationSignalUtils.kt @@ -0,0 +1,41 @@ +/* + * 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.util + +import android.os.CancellationSignal +import kotlinx.coroutines.CoroutineStart +import kotlinx.coroutines.awaitCancellation +import kotlinx.coroutines.coroutineScope +import kotlinx.coroutines.launch + +/** + * Invokes [block] with a [CancellationSignal] that is bound to this coroutine's lifetime; if this + * coroutine is cancelled, then [CancellationSignal.cancel] is promptly invoked. + */ +suspend fun <R> withCancellationSignal(block: suspend (signal: CancellationSignal) -> R): R = + coroutineScope { + val signal = CancellationSignal() + val signalJob = + launch(start = CoroutineStart.UNDISPATCHED) { + try { + awaitCancellation() + } finally { + signal.cancel() + } + } + block(signal).also { signalJob.cancel() } + } diff --git a/java/src/com/android/intentresolver/util/ParallelIteration.kt b/java/src/com/android/intentresolver/util/ParallelIteration.kt new file mode 100644 index 00000000..70c46c47 --- /dev/null +++ b/java/src/com/android/intentresolver/util/ParallelIteration.kt @@ -0,0 +1,50 @@ +/* + * 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.util + +import kotlinx.coroutines.async +import kotlinx.coroutines.awaitAll +import kotlinx.coroutines.coroutineScope +import kotlinx.coroutines.sync.withPermit +import kotlinx.coroutines.yield + +/** Like [Iterable.map] but executes each [block] invocation in a separate coroutine. */ +suspend fun <A, B> Iterable<A>.mapParallel( + parallelism: Int? = null, + block: suspend (A) -> B, +): List<B> = + parallelism?.let { permits -> + withSemaphore(permits = permits) { mapParallel { withPermit { block(it) } } } + } + ?: mapParallel(block) + +/** Like [Iterable.map] but executes each [block] invocation in a separate coroutine. */ +suspend fun <A, B> Sequence<A>.mapParallel( + parallelism: Int? = null, + block: suspend (A) -> B, +): List<B> = asIterable().mapParallel(parallelism, block) + +private suspend fun <A, B> Iterable<A>.mapParallel(block: suspend (A) -> B): List<B> = + coroutineScope { + map { + async { + yield() + block(it) + } + } + .awaitAll() + } diff --git a/java/src/com/android/intentresolver/util/SyncUtils.kt b/java/src/com/android/intentresolver/util/SyncUtils.kt new file mode 100644 index 00000000..eaebc6ea --- /dev/null +++ b/java/src/com/android/intentresolver/util/SyncUtils.kt @@ -0,0 +1,33 @@ +/* + * 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.util + +import kotlinx.coroutines.sync.Mutex +import kotlinx.coroutines.sync.Semaphore + +/** + * Constructs a [Semaphore] for usage within [block], useful for launching a lot of work in parallel + * that needs some synchronization. + */ +inline fun <R> withSemaphore(permits: Int, block: Semaphore.() -> R): R = + Semaphore(permits).run(block) + +/** + * Constructs a [Mutex] for usage within [block], useful for launching a lot of work in parallel + * that needs some synchronization. + */ +inline fun <R> withMutex(block: Mutex.() -> R): R = Mutex().run(block) diff --git a/java/src/com/android/intentresolver/util/cursor/CursorView.kt b/java/src/com/android/intentresolver/util/cursor/CursorView.kt new file mode 100644 index 00000000..eca7d335 --- /dev/null +++ b/java/src/com/android/intentresolver/util/cursor/CursorView.kt @@ -0,0 +1,59 @@ +/* + * 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.util.cursor + +import android.database.Cursor + +/** A [Cursor] that holds values of [E] for each row. */ +interface CursorView<out E> : Cursor { + /** + * Reads the current row from this [CursorView]. A result of `null` indicates that the row could + * not be read / value could not be produced. + */ + fun readRow(): E? +} + +/** + * Returns a [CursorView] from the given [Cursor], and a function [readRow] used to produce the + * value for a single row. + */ +fun <E> Cursor.viewBy(readRow: Cursor.() -> E): CursorView<E> = + object : CursorView<E>, Cursor by this@viewBy { + override fun readRow(): E? = immobilized().readRow() + } + +/** Returns a [CursorView] that begins (index 0) at [newStartIndex] of the given cursor. */ +fun <E> CursorView<E>.startAt(newStartIndex: Int): CursorView<E> = + object : CursorView<E>, Cursor by (this@startAt as Cursor).startAt(newStartIndex) { + override fun readRow(): E? = this@startAt.readRow() + } + +/** Returns a [CursorView] that is truncated to contain only [count] elements. */ +fun <E> CursorView<E>.limit(count: Int): CursorView<E> = + object : CursorView<E>, Cursor by (this@limit as Cursor).limit(count) { + override fun readRow(): E? = this@limit.readRow() + } + +/** Retrieves a single row at index [idx] from the [CursorView]. */ +operator fun <E> CursorView<E>.get(idx: Int): E? = if (moveToPosition(idx)) readRow() else null + +/** Returns a [Sequence] that iterates over the [CursorView] returning each row. */ +fun <E> CursorView<E>.asSequence(): Sequence<E?> = sequence { + for (i in 0 until count) { + yield(get(i)) + } +} diff --git a/java/src/com/android/intentresolver/util/cursor/Cursors.kt b/java/src/com/android/intentresolver/util/cursor/Cursors.kt new file mode 100644 index 00000000..ce768f3b --- /dev/null +++ b/java/src/com/android/intentresolver/util/cursor/Cursors.kt @@ -0,0 +1,87 @@ +/* + * 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.util.cursor + +import android.database.Cursor +import android.database.CursorWrapper + +/** Returns a Cursor that is truncated to contain only [count] elements. */ +fun Cursor.limit(count: Int): Cursor = + object : CursorWrapper(this) { + override fun getCount(): Int = minOf(count, super.getCount()) + + override fun getPosition(): Int = super.getPosition().coerceAtMost(count) + + override fun moveToLast(): Boolean = super.moveToPosition(getCount() - 1) + + override fun isFirst(): Boolean = getCount() != 0 && super.isFirst() + + override fun isLast(): Boolean = getCount() != 0 && super.getPosition() == getCount() - 1 + + override fun isAfterLast(): Boolean = getCount() == 0 || super.getPosition() >= getCount() + + override fun isBeforeFirst(): Boolean = getCount() == 0 || super.isBeforeFirst() + + override fun moveToNext(): Boolean = super.moveToNext() && position < getCount() + + override fun moveToPosition(position: Int): Boolean = + super.moveToPosition(position) && position < getCount() + } + +/** Returns a Cursor that begins (index 0) at [newStartIndex] of the given Cursor. */ +fun Cursor.startAt(newStartIndex: Int): Cursor = + object : CursorWrapper(this) { + override fun getCount(): Int = (super.getCount() - newStartIndex).coerceAtLeast(0) + + override fun getPosition(): Int = (super.getPosition() - newStartIndex).coerceAtLeast(-1) + + override fun moveToFirst(): Boolean = super.moveToPosition(newStartIndex) + + override fun moveToNext(): Boolean = super.moveToNext() && position < count + + override fun moveToPrevious(): Boolean = super.moveToPrevious() && position >= 0 + + override fun moveToPosition(position: Int): Boolean = + super.moveToPosition(position + newStartIndex) && position >= 0 + + override fun isFirst(): Boolean = count != 0 && super.getPosition() == newStartIndex + + override fun isLast(): Boolean = count != 0 && super.isLast() + + override fun isBeforeFirst(): Boolean = count == 0 || super.getPosition() < newStartIndex + + override fun isAfterLast(): Boolean = count == 0 || super.isAfterLast() + } + +/** Returns a read-only non-movable view into the given Cursor. */ +fun Cursor.immobilized(): Cursor = + object : CursorWrapper(this) { + private val unsupported: Nothing + get() = error("unsupported") + + override fun moveToFirst(): Boolean = unsupported + + override fun moveToLast(): Boolean = unsupported + + override fun move(offset: Int): Boolean = unsupported + + override fun moveToPosition(position: Int): Boolean = unsupported + + override fun moveToNext(): Boolean = unsupported + + override fun moveToPrevious(): Boolean = unsupported + } diff --git a/java/src/com/android/intentresolver/util/cursor/PagedCursor.kt b/java/src/com/android/intentresolver/util/cursor/PagedCursor.kt new file mode 100644 index 00000000..6e4318dc --- /dev/null +++ b/java/src/com/android/intentresolver/util/cursor/PagedCursor.kt @@ -0,0 +1,52 @@ +/* + * 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.util.cursor + +import android.database.Cursor + +/** A [CursorView] that produces chunks/pages from an underlying cursor. */ +interface PagedCursor<out E> : CursorView<Sequence<E?>> { + /** The configured size of each page produced by this cursor. */ + val pageSize: Int +} + +/** Returns a [PagedCursor] that produces pages of data from the given [CursorView]. */ +fun <E> CursorView<E>.paged(pageSize: Int): PagedCursor<E> = + object : PagedCursor<E>, Cursor by this@paged { + + init { + check(pageSize > 0) { "pageSize must be greater than 0" } + } + + override val pageSize: Int = pageSize + + override fun getCount(): Int = + this@paged.count.let { it / pageSize + minOf(1, it % pageSize) } + + override fun getPosition(): Int = + (this@paged.position / pageSize).let { if (this@paged.position < 0) it - 1 else it } + + override fun moveToNext(): Boolean = moveToPosition(position + 1) + + override fun moveToPrevious(): Boolean = moveToPosition(position - 1) + + override fun moveToPosition(position: Int): Boolean = + this@paged.moveToPosition(position * pageSize) + + override fun readRow(): Sequence<E?> = + this@paged.startAt(position * pageSize).limit(pageSize).asSequence() + } |