1 /*
<lambda>null2 * Copyright (C) 2024 The Android Open Source Project
3 *
4 * Licensed under the Apache License, Version 2.0 (the "License");
5 * you may not use this file except in compliance with the License.
6 * You may obtain a copy of the License at
7 *
8 * http://www.apache.org/licenses/LICENSE-2.0
9 *
10 * Unless required by applicable law or agreed to in writing, software
11 * distributed under the License is distributed on an "AS IS" BASIS,
12 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13 * See the License for the specific language governing permissions and
14 * limitations under the License.
15 */
16
17 @file:OptIn(ExperimentalCoroutinesApi::class)
18
19 package com.android.intentresolver.contentpreview.payloadtoggle.domain.interactor
20
21 import android.net.Uri
22 import android.service.chooser.AdditionalContentContract.CursorExtraKeys.POSITION
23 import android.util.Log
24 import com.android.intentresolver.contentpreview.UriMetadataReader
25 import com.android.intentresolver.contentpreview.payloadtoggle.domain.model.CursorRow
26 import com.android.intentresolver.contentpreview.payloadtoggle.domain.model.LoadDirection
27 import com.android.intentresolver.contentpreview.payloadtoggle.domain.model.LoadedWindow
28 import com.android.intentresolver.contentpreview.payloadtoggle.domain.model.expandWindowLeft
29 import com.android.intentresolver.contentpreview.payloadtoggle.domain.model.expandWindowRight
30 import com.android.intentresolver.contentpreview.payloadtoggle.domain.model.numLoadedPages
31 import com.android.intentresolver.contentpreview.payloadtoggle.domain.model.shiftWindowLeft
32 import com.android.intentresolver.contentpreview.payloadtoggle.domain.model.shiftWindowRight
33 import com.android.intentresolver.contentpreview.payloadtoggle.shared.model.PreviewKey
34 import com.android.intentresolver.contentpreview.payloadtoggle.shared.model.PreviewModel
35 import com.android.intentresolver.inject.FocusedItemIndex
36 import com.android.intentresolver.util.cursor.CursorView
37 import com.android.intentresolver.util.cursor.PagedCursor
38 import com.android.intentresolver.util.cursor.get
39 import com.android.intentresolver.util.cursor.paged
40 import com.android.intentresolver.util.mapParallel
41 import dagger.Module
42 import dagger.Provides
43 import dagger.hilt.InstallIn
44 import dagger.hilt.components.SingletonComponent
45 import java.util.concurrent.ConcurrentHashMap
46 import javax.inject.Inject
47 import javax.inject.Qualifier
48 import kotlin.math.max
49 import kotlin.math.min
50 import kotlinx.coroutines.ExperimentalCoroutinesApi
51 import kotlinx.coroutines.flow.Flow
52 import kotlinx.coroutines.flow.filterNotNull
53 import kotlinx.coroutines.flow.first
54 import kotlinx.coroutines.flow.mapLatest
55
56 private const val TAG = "CursorPreviewsIntr"
57
58 /** Queries data from a remote cursor, and caches it locally for presentation in Shareousel. */
59 class CursorPreviewsInteractor
60 @Inject
61 constructor(
62 private val interactor: SetCursorPreviewsInteractor,
63 private val selectionInteractor: SelectionInteractor,
64 @FocusedItemIndex private val focusedItemIdx: Int,
65 private val uriMetadataReader: UriMetadataReader,
66 @PageSize private val pageSize: Int,
67 @MaxLoadedPages private val maxLoadedPages: Int,
68 ) {
69
70 init {
71 check(pageSize > 0) { "pageSize must be greater than zero" }
72 }
73
74 /** Start reading data from [uriCursor], and listen for requests to load more. */
75 suspend fun launch(uriCursor: CursorView<CursorRow?>, initialPreviews: Iterable<PreviewModel>) {
76 // Unclaimed values from the initial selection set. Entries will be removed as the cursor is
77 // read, and any still present are inserted at the start / end of the cursor when it is
78 // reached by the user.
79 val unclaimedRecords: MutableUnclaimedMap =
80 initialPreviews
81 .asSequence()
82 .mapIndexed { i, m -> Pair(m.uri, Pair(i, m)) }
83 .toMap(ConcurrentHashMap())
84 val pagedCursor: PagedCursor<CursorRow?> = uriCursor.paged(pageSize)
85 val startPosition = uriCursor.extras?.getInt(POSITION, 0) ?: 0
86
87 val state =
88 loadToMaxPages(
89 startPosition = startPosition,
90 initialState = readInitialState(startPosition, pagedCursor, unclaimedRecords),
91 pagedCursor = pagedCursor,
92 unclaimedRecords = unclaimedRecords,
93 )
94 processLoadRequests(startPosition, state, pagedCursor, unclaimedRecords)
95 }
96
97 private suspend fun loadToMaxPages(
98 startPosition: Int,
99 initialState: CursorWindow,
100 pagedCursor: PagedCursor<CursorRow?>,
101 unclaimedRecords: MutableUnclaimedMap,
102 ): CursorWindow {
103 var state = initialState
104 val startPageNum = state.firstLoadedPageNum
105 while ((state.hasMoreLeft || state.hasMoreRight) && state.numLoadedPages < maxLoadedPages) {
106 val (leftTriggerIndex, rightTriggerIndex) = state.triggerIndices()
107 interactor.setPreviews(
108 previews = state.merged.values.toList(),
109 startIndex = state.startIndex,
110 hasMoreLeft = state.hasMoreLeft,
111 hasMoreRight = state.hasMoreRight,
112 leftTriggerIndex = leftTriggerIndex,
113 rightTriggerIndex = rightTriggerIndex,
114 )
115 val loadedLeft = startPageNum - state.firstLoadedPageNum
116 val loadedRight = state.lastLoadedPageNum - startPageNum
117 state =
118 when {
119 state.hasMoreLeft && loadedLeft < loadedRight ->
120 state.loadMoreLeft(startPosition, pagedCursor, unclaimedRecords)
121 state.hasMoreRight ->
122 state.loadMoreRight(startPosition, pagedCursor, unclaimedRecords)
123 else -> state.loadMoreLeft(startPosition, pagedCursor, unclaimedRecords)
124 }
125 }
126 return state
127 }
128
129 /** Loop forever, processing any loading requests from the UI and updating local cache. */
130 private suspend fun processLoadRequests(
131 startPosition: Int,
132 initialState: CursorWindow,
133 pagedCursor: PagedCursor<CursorRow?>,
134 unclaimedRecords: MutableUnclaimedMap,
135 ) {
136 var state = initialState
137 while (true) {
138 val (leftTriggerIndex, rightTriggerIndex) = state.triggerIndices()
139
140 // Design note: in order to prevent load requests from the UI when it was displaying a
141 // previously-published dataset being accidentally associated with a recently-published
142 // one, we generate a new Flow of load requests for each dataset and only listen to
143 // those.
144 val loadingState: Flow<LoadDirection?> =
145 interactor.setPreviews(
146 previews = state.merged.values.toList(),
147 startIndex = state.startIndex,
148 hasMoreLeft = state.hasMoreLeft,
149 hasMoreRight = state.hasMoreRight,
150 leftTriggerIndex = leftTriggerIndex,
151 rightTriggerIndex = rightTriggerIndex,
152 )
153 state =
154 loadingState.handleOneLoadRequest(
155 startPosition,
156 state,
157 pagedCursor,
158 unclaimedRecords,
159 )
160 }
161 }
162
163 /**
164 * Suspends until a single loading request has been handled, returning the new [CursorWindow]
165 * with the loaded data incorporated.
166 */
167 private suspend fun Flow<LoadDirection?>.handleOneLoadRequest(
168 startPosition: Int,
169 state: CursorWindow,
170 pagedCursor: PagedCursor<CursorRow?>,
171 unclaimedRecords: MutableUnclaimedMap,
172 ): CursorWindow =
173 mapLatest { loadDirection ->
174 loadDirection?.let {
175 when (loadDirection) {
176 LoadDirection.Left ->
177 state.loadMoreLeft(startPosition, pagedCursor, unclaimedRecords)
178 LoadDirection.Right ->
179 state.loadMoreRight(startPosition, pagedCursor, unclaimedRecords)
180 }
181 }
182 }
183 .filterNotNull()
184 .first()
185
186 /**
187 * Returns the initial [CursorWindow], with a single page loaded that contains the
188 * [startPosition].
189 */
190 private suspend fun readInitialState(
191 startPosition: Int,
192 cursor: PagedCursor<CursorRow?>,
193 unclaimedRecords: MutableUnclaimedMap,
194 ): CursorWindow {
195 val startPageIdx = startPosition / pageSize
196 val hasMoreLeft = startPageIdx > 0
197 val hasMoreRight = startPageIdx < cursor.count - 1
198 val page: PreviewMap = buildMap {
199 if (!hasMoreLeft) {
200 // First read the initial page; this might claim some unclaimed Uris
201 val page =
202 cursor
203 .getPageRows(startPageIdx)
204 ?.toPage(startPosition, mutableMapOf(), unclaimedRecords)
205 // Now that unclaimed Uris are up-to-date, add them first.
206 putAllUnclaimedLeft(unclaimedRecords)
207 // Then add the loaded page
208 page?.let(::putAll)
209 } else {
210 cursor.getPageRows(startPageIdx)?.toPage(startPosition, this, unclaimedRecords)
211 }
212 // Finally, add the remainder of the unclaimed Uris.
213 if (!hasMoreRight) {
214 putAllUnclaimedRight(unclaimedRecords)
215 }
216 }
217 return CursorWindow(
218 startIndex = startPosition % pageSize,
219 firstLoadedPageNum = startPageIdx,
220 lastLoadedPageNum = startPageIdx,
221 pages = listOf(page.keys),
222 merged = page,
223 hasMoreLeft = hasMoreLeft,
224 hasMoreRight = hasMoreRight,
225 )
226 }
227
228 private suspend fun CursorWindow.loadMoreRight(
229 startPosition: Int,
230 cursor: PagedCursor<CursorRow?>,
231 unclaimedRecords: MutableUnclaimedMap,
232 ): CursorWindow {
233 val pageNum = lastLoadedPageNum + 1
234 val hasMoreRight = pageNum < cursor.count - 1
235 val newPage: PreviewMap = buildMap {
236 readAndPutPage(startPosition, this@loadMoreRight, cursor, pageNum, unclaimedRecords)
237 if (!hasMoreRight) {
238 putAllUnclaimedRight(unclaimedRecords)
239 }
240 }
241 return if (numLoadedPages < maxLoadedPages) {
242 expandWindowRight(newPage, hasMoreRight)
243 } else {
244 shiftWindowRight(newPage, hasMoreRight)
245 }
246 }
247
248 private suspend fun CursorWindow.loadMoreLeft(
249 startPosition: Int,
250 cursor: PagedCursor<CursorRow?>,
251 unclaimedRecords: MutableUnclaimedMap,
252 ): CursorWindow {
253 val pageNum = firstLoadedPageNum - 1
254 val hasMoreLeft = pageNum > 0
255 val newPage: PreviewMap = buildMap {
256 if (!hasMoreLeft) {
257 // First read the page; this might claim some unclaimed Uris
258 val page =
259 readPage(startPosition, this@loadMoreLeft, cursor, pageNum, unclaimedRecords)
260 // Now that unclaimed URIs are up-to-date, add them first
261 putAllUnclaimedLeft(unclaimedRecords)
262 // Then add the loaded page
263 putAll(page)
264 } else {
265 readAndPutPage(startPosition, this@loadMoreLeft, cursor, pageNum, unclaimedRecords)
266 }
267 }
268 return if (numLoadedPages < maxLoadedPages) {
269 expandWindowLeft(newPage, hasMoreLeft)
270 } else {
271 shiftWindowLeft(newPage, hasMoreLeft)
272 }
273 }
274
275 private fun CursorWindow.triggerIndices(): Pair<Int, Int> {
276 val totalIndices = numLoadedPages * pageSize
277 val midIndex = totalIndices / 2
278 val halfPage = pageSize / 2
279 return max(midIndex - halfPage, 0) to min(midIndex + halfPage, totalIndices - 1)
280 }
281
282 private suspend fun readPage(
283 startPosition: Int,
284 state: CursorWindow,
285 pagedCursor: PagedCursor<CursorRow?>,
286 pageNum: Int,
287 unclaimedRecords: MutableUnclaimedMap,
288 ): PreviewMap =
289 mutableMapOf<PreviewKey, PreviewModel>()
290 .readAndPutPage(startPosition, state, pagedCursor, pageNum, unclaimedRecords)
291
292 private suspend fun <M : MutablePreviewMap> M.readAndPutPage(
293 startPosition: Int,
294 state: CursorWindow,
295 pagedCursor: PagedCursor<CursorRow?>,
296 pageNum: Int,
297 unclaimedRecords: MutableUnclaimedMap,
298 ): M =
299 pagedCursor
300 .getPageRows(pageNum) // TODO: what do we do if the load fails?
301 ?.filter { PreviewKey.final(it.position - startPosition) !in state.merged }
302 ?.toPage(startPosition, this, unclaimedRecords) ?: this
303
304 private suspend fun <M : MutablePreviewMap> Sequence<CursorRow>.toPage(
305 startPosition: Int,
306 destination: M,
307 unclaimedRecords: MutableUnclaimedMap,
308 ): M =
309 // Restrict parallelism so as to not overload the metadata reader; anecdotally, too
310 // many parallel queries causes failures.
311 mapParallel(parallelism = 4) { row ->
312 createPreviewModel(startPosition, row, unclaimedRecords)
313 }
314 .associateByTo(destination) { it.key }
315
316 private fun createPreviewModel(
317 startPosition: Int,
318 row: CursorRow,
319 unclaimedRecords: MutableUnclaimedMap,
320 ): PreviewModel =
321 uriMetadataReader
322 .getMetadata(row.uri)
323 .let { metadata ->
324 val size =
325 row.previewSize
326 ?: metadata.previewUri?.let { uriMetadataReader.readPreviewSize(it) }
327 PreviewModel(
328 key = PreviewKey.final(row.position - startPosition),
329 uri = row.uri,
330 previewUri = metadata.previewUri,
331 mimeType = metadata.mimeType,
332 aspectRatio = size.aspectRatioOrDefault(1f),
333 order = row.position,
334 )
335 }
336 .also { updated ->
337 if (unclaimedRecords.remove(row.uri) != null) {
338 // unclaimedRecords contains initially shared (and thus selected) items with
339 // unknown cursor position. Update selection records when any of those items is
340 // encountered in the cursor to maintain proper selection order should other
341 // items also be selected.
342 selectionInteractor.updateSelection(updated)
343 }
344 }
345
346 private fun <M : MutablePreviewMap> M.putAllUnclaimedRight(unclaimed: UnclaimedMap): M =
347 putAllUnclaimedWhere(unclaimed) { it >= focusedItemIdx }
348
349 private fun <M : MutablePreviewMap> M.putAllUnclaimedLeft(unclaimed: UnclaimedMap): M =
350 putAllUnclaimedWhere(unclaimed) { it < focusedItemIdx }
351 }
352
353 private typealias CursorWindow = LoadedWindow<PreviewKey, PreviewModel>
354
355 /**
356 * Values from the initial selection set that have not yet appeared within the Cursor. These values
357 * are appended to the start/end of the cursor dataset, depending on their position relative to the
358 * initially focused value.
359 */
360 private typealias UnclaimedMap = Map<Uri, Pair<Int, PreviewModel>>
361
362 /** Mutable version of [UnclaimedMap]. */
363 private typealias MutableUnclaimedMap = MutableMap<Uri, Pair<Int, PreviewModel>>
364
365 private typealias UnkeyedMap = Map<Uri, PreviewModel>
366
367 private typealias MutableUnkeyedMap = MutableMap<Uri, PreviewModel>
368
369 private typealias MutablePreviewMap = MutableMap<PreviewKey, PreviewModel>
370
371 private typealias PreviewMap = Map<PreviewKey, PreviewModel>
372
putAllUnclaimedWherenull373 private fun <M : MutablePreviewMap> M.putAllUnclaimedWhere(
374 unclaimedRecords: UnclaimedMap,
375 predicate: (Int) -> Boolean,
376 ): M =
377 unclaimedRecords
378 .asSequence()
379 .filter { predicate(it.value.first) }
valuenull380 .map { (_, value) -> value.second.key to value.second }
381 .toMap(this)
382
getPageRowsnull383 private fun PagedCursor<CursorRow?>.getPageRows(pageNum: Int): Sequence<CursorRow>? =
384 runCatching { get(pageNum) }
<lambda>null385 .onFailure { Log.e(TAG, "Failed to read additional content cursor page #$pageNum", it) }
386 .getOrNull()
387 ?.asSafeSequence()
388 ?.filterNotNull()
389
asSafeSequencenull390 private fun <T> Sequence<T>.asSafeSequence(): Sequence<T> {
391 return if (this is SafeSequence) this else SafeSequence(this)
392 }
393
394 private class SafeSequence<T>(private val sequence: Sequence<T>) : Sequence<T> {
iteratornull395 override fun iterator(): Iterator<T> =
396 sequence.iterator().let { if (it is SafeIterator) it else SafeIterator(it) }
397 }
398
<lambda>null399 private class SafeIterator<T>(private val iterator: Iterator<T>) : Iterator<T> by iterator {
400 override fun hasNext(): Boolean {
401 return runCatching { iterator.hasNext() }
402 .onFailure { Log.e(TAG, "Failed to read cursor", it) }
403 .getOrDefault(false)
404 }
405 }
406
407 @Qualifier @MustBeDocumented @Retention(AnnotationRetention.RUNTIME) annotation class PageSize
408
409 @Qualifier
410 @MustBeDocumented
411 @Retention(AnnotationRetention.RUNTIME)
412 annotation class MaxLoadedPages
413
414 @Module
415 @InstallIn(SingletonComponent::class)
416 object ShareouselConstants {
pageSizenull417 @Provides @PageSize fun pageSize(): Int = 16
418
419 @Provides @MaxLoadedPages fun maxLoadedPages(): Int = 8
420 }
421