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 com.android.intentresolver.contentpreview.UriMetadataReader
24 import com.android.intentresolver.contentpreview.payloadtoggle.domain.model.CursorRow
25 import com.android.intentresolver.contentpreview.payloadtoggle.domain.model.LoadDirection
26 import com.android.intentresolver.contentpreview.payloadtoggle.domain.model.LoadedWindow
27 import com.android.intentresolver.contentpreview.payloadtoggle.domain.model.expandWindowLeft
28 import com.android.intentresolver.contentpreview.payloadtoggle.domain.model.expandWindowRight
29 import com.android.intentresolver.contentpreview.payloadtoggle.domain.model.numLoadedPages
30 import com.android.intentresolver.contentpreview.payloadtoggle.domain.model.shiftWindowLeft
31 import com.android.intentresolver.contentpreview.payloadtoggle.domain.model.shiftWindowRight
32 import com.android.intentresolver.contentpreview.payloadtoggle.shared.model.PreviewModel
33 import com.android.intentresolver.inject.FocusedItemIndex
34 import com.android.intentresolver.util.cursor.CursorView
35 import com.android.intentresolver.util.cursor.PagedCursor
36 import com.android.intentresolver.util.cursor.get
37 import com.android.intentresolver.util.cursor.paged
38 import com.android.intentresolver.util.mapParallel
39 import dagger.Module
40 import dagger.Provides
41 import dagger.hilt.InstallIn
42 import dagger.hilt.components.SingletonComponent
43 import java.util.concurrent.ConcurrentHashMap
44 import javax.inject.Inject
45 import javax.inject.Qualifier
46 import kotlin.math.max
47 import kotlin.math.min
48 import kotlinx.coroutines.ExperimentalCoroutinesApi
49 import kotlinx.coroutines.flow.Flow
50 import kotlinx.coroutines.flow.filterNotNull
51 import kotlinx.coroutines.flow.first
52 import kotlinx.coroutines.flow.mapLatest
53
54 /** Queries data from a remote cursor, and caches it locally for presentation in Shareousel. */
55 class CursorPreviewsInteractor
56 @Inject
57 constructor(
58 private val interactor: SetCursorPreviewsInteractor,
59 private val selectionInteractor: SelectionInteractor,
60 @FocusedItemIndex private val focusedItemIdx: Int,
61 private val uriMetadataReader: UriMetadataReader,
62 @PageSize private val pageSize: Int,
63 @MaxLoadedPages private val maxLoadedPages: Int,
64 ) {
65
66 init {
67 check(pageSize > 0) { "pageSize must be greater than zero" }
68 }
69
70 /** Start reading data from [uriCursor], and listen for requests to load more. */
71 suspend fun launch(uriCursor: CursorView<CursorRow?>, initialPreviews: Iterable<PreviewModel>) {
72 // Unclaimed values from the initial selection set. Entries will be removed as the cursor is
73 // read, and any still present are inserted at the start / end of the cursor when it is
74 // reached by the user.
75 val unclaimedRecords: MutableUnclaimedMap =
76 initialPreviews
77 .asSequence()
78 .mapIndexed { i, m -> Pair(m.uri, Pair(i, m)) }
79 .toMap(ConcurrentHashMap())
80 val pagedCursor: PagedCursor<CursorRow?> = uriCursor.paged(pageSize)
81 val startPosition = uriCursor.extras?.getInt(POSITION, 0) ?: 0
82 val state =
83 loadToMaxPages(
84 initialState = readInitialState(pagedCursor, startPosition, unclaimedRecords),
85 pagedCursor = pagedCursor,
86 unclaimedRecords = unclaimedRecords,
87 )
88 processLoadRequests(state, pagedCursor, unclaimedRecords)
89 }
90
91 private suspend fun loadToMaxPages(
92 initialState: CursorWindow,
93 pagedCursor: PagedCursor<CursorRow?>,
94 unclaimedRecords: MutableUnclaimedMap,
95 ): CursorWindow {
96 var state = initialState
97 val startPageNum = state.firstLoadedPageNum
98 while ((state.hasMoreLeft || state.hasMoreRight) && state.numLoadedPages < maxLoadedPages) {
99 val (leftTriggerIndex, rightTriggerIndex) = state.triggerIndices()
100 interactor.setPreviews(
101 previews = state.merged.values.toList(),
102 startIndex = startPageNum,
103 hasMoreLeft = state.hasMoreLeft,
104 hasMoreRight = state.hasMoreRight,
105 leftTriggerIndex = leftTriggerIndex,
106 rightTriggerIndex = rightTriggerIndex,
107 )
108 val loadedLeft = startPageNum - state.firstLoadedPageNum
109 val loadedRight = state.lastLoadedPageNum - startPageNum
110 state =
111 when {
112 state.hasMoreLeft && loadedLeft < loadedRight ->
113 state.loadMoreLeft(pagedCursor, unclaimedRecords)
114 state.hasMoreRight -> state.loadMoreRight(pagedCursor, unclaimedRecords)
115 else -> state.loadMoreLeft(pagedCursor, unclaimedRecords)
116 }
117 }
118 return state
119 }
120
121 /** Loop forever, processing any loading requests from the UI and updating local cache. */
122 private suspend fun processLoadRequests(
123 initialState: CursorWindow,
124 pagedCursor: PagedCursor<CursorRow?>,
125 unclaimedRecords: MutableUnclaimedMap,
126 ) {
127 var state = initialState
128 while (true) {
129 val (leftTriggerIndex, rightTriggerIndex) = state.triggerIndices()
130
131 // Design note: in order to prevent load requests from the UI when it was displaying a
132 // previously-published dataset being accidentally associated with a recently-published
133 // one, we generate a new Flow of load requests for each dataset and only listen to
134 // those.
135 val loadingState: Flow<LoadDirection?> =
136 interactor.setPreviews(
137 previews = state.merged.values.toList(),
138 startIndex = 0, // TODO: actually track this as the window changes?
139 hasMoreLeft = state.hasMoreLeft,
140 hasMoreRight = state.hasMoreRight,
141 leftTriggerIndex = leftTriggerIndex,
142 rightTriggerIndex = rightTriggerIndex,
143 )
144 state = loadingState.handleOneLoadRequest(state, pagedCursor, unclaimedRecords)
145 }
146 }
147
148 /**
149 * Suspends until a single loading request has been handled, returning the new [CursorWindow]
150 * with the loaded data incorporated.
151 */
152 private suspend fun Flow<LoadDirection?>.handleOneLoadRequest(
153 state: CursorWindow,
154 pagedCursor: PagedCursor<CursorRow?>,
155 unclaimedRecords: MutableUnclaimedMap,
156 ): CursorWindow =
157 mapLatest { loadDirection ->
158 loadDirection?.let {
159 when (loadDirection) {
160 LoadDirection.Left -> state.loadMoreLeft(pagedCursor, unclaimedRecords)
161 LoadDirection.Right -> state.loadMoreRight(pagedCursor, unclaimedRecords)
162 }
163 }
164 }
165 .filterNotNull()
166 .first()
167
168 /**
169 * Returns the initial [CursorWindow], with a single page loaded that contains the given
170 * [startPosition].
171 */
172 private suspend fun readInitialState(
173 cursor: PagedCursor<CursorRow?>,
174 startPosition: Int,
175 unclaimedRecords: MutableUnclaimedMap,
176 ): CursorWindow {
177 val startPageIdx = startPosition / pageSize
178 val hasMoreLeft = startPageIdx > 0
179 val hasMoreRight = startPageIdx < cursor.count - 1
180 val page: PreviewMap = buildMap {
181 if (!hasMoreLeft) {
182 // First read the initial page; this might claim some unclaimed Uris
183 val page =
184 cursor.getPageRows(startPageIdx)?.toPage(mutableMapOf(), unclaimedRecords)
185 // Now that unclaimed Uris are up-to-date, add them first.
186 putAllUnclaimedLeft(unclaimedRecords)
187 // Then add the loaded page
188 page?.let(::putAll)
189 } else {
190 cursor.getPageRows(startPageIdx)?.toPage(this, unclaimedRecords)
191 }
192 // Finally, add the remainder of the unclaimed Uris.
193 if (!hasMoreRight) {
194 putAllUnclaimedRight(unclaimedRecords)
195 }
196 }
197 return CursorWindow(
198 firstLoadedPageNum = startPageIdx,
199 lastLoadedPageNum = startPageIdx,
200 pages = listOf(page.keys),
201 merged = page,
202 hasMoreLeft = hasMoreLeft,
203 hasMoreRight = hasMoreRight,
204 )
205 }
206
207 private suspend fun CursorWindow.loadMoreRight(
208 cursor: PagedCursor<CursorRow?>,
209 unclaimedRecords: MutableUnclaimedMap,
210 ): CursorWindow {
211 val pageNum = lastLoadedPageNum + 1
212 val hasMoreRight = pageNum < cursor.count - 1
213 val newPage: PreviewMap = buildMap {
214 readAndPutPage(this@loadMoreRight, cursor, pageNum, unclaimedRecords)
215 if (!hasMoreRight) {
216 putAllUnclaimedRight(unclaimedRecords)
217 }
218 }
219 return if (numLoadedPages < maxLoadedPages) {
220 expandWindowRight(newPage, hasMoreRight)
221 } else {
222 shiftWindowRight(newPage, hasMoreRight)
223 }
224 }
225
226 private suspend fun CursorWindow.loadMoreLeft(
227 cursor: PagedCursor<CursorRow?>,
228 unclaimedRecords: MutableUnclaimedMap,
229 ): CursorWindow {
230 val pageNum = firstLoadedPageNum - 1
231 val hasMoreLeft = pageNum > 0
232 val newPage: PreviewMap = buildMap {
233 if (!hasMoreLeft) {
234 // First read the page; this might claim some unclaimed Uris
235 val page = readPage(this@loadMoreLeft, cursor, pageNum, unclaimedRecords)
236 // Now that unclaimed URIs are up-to-date, add them first
237 putAllUnclaimedLeft(unclaimedRecords)
238 // Then add the loaded page
239 putAll(page)
240 } else {
241 readAndPutPage(this@loadMoreLeft, cursor, pageNum, unclaimedRecords)
242 }
243 }
244 return if (numLoadedPages < maxLoadedPages) {
245 expandWindowLeft(newPage, hasMoreLeft)
246 } else {
247 shiftWindowLeft(newPage, hasMoreLeft)
248 }
249 }
250
251 private fun CursorWindow.triggerIndices(): Pair<Int, Int> {
252 val totalIndices = numLoadedPages * pageSize
253 val midIndex = totalIndices / 2
254 val halfPage = pageSize / 2
255 return max(midIndex - halfPage, 0) to min(midIndex + halfPage, totalIndices - 1)
256 }
257
258 private suspend fun readPage(
259 state: CursorWindow,
260 pagedCursor: PagedCursor<CursorRow?>,
261 pageNum: Int,
262 unclaimedRecords: MutableUnclaimedMap,
263 ): PreviewMap =
264 mutableMapOf<Uri, PreviewModel>()
265 .readAndPutPage(state, pagedCursor, pageNum, unclaimedRecords)
266
267 private suspend fun <M : MutablePreviewMap> M.readAndPutPage(
268 state: CursorWindow,
269 pagedCursor: PagedCursor<CursorRow?>,
270 pageNum: Int,
271 unclaimedRecords: MutableUnclaimedMap,
272 ): M =
273 pagedCursor
274 .getPageRows(pageNum) // TODO: what do we do if the load fails?
275 ?.filter { it.uri !in state.merged }
276 ?.toPage(this, unclaimedRecords)
277 ?: this
278
279 private suspend fun <M : MutablePreviewMap> Sequence<CursorRow>.toPage(
280 destination: M,
281 unclaimedRecords: MutableUnclaimedMap,
282 ): M =
283 // Restrict parallelism so as to not overload the metadata reader; anecdotally, too
284 // many parallel queries causes failures.
285 mapParallel(parallelism = 4) { row -> createPreviewModel(row, unclaimedRecords) }
286 .associateByTo(destination) { it.uri }
287
288 private fun createPreviewModel(
289 row: CursorRow,
290 unclaimedRecords: MutableUnclaimedMap,
291 ): PreviewModel = uriMetadataReader.getMetadata(row.uri).let { metadata ->
292 val size =
293 row.previewSize
294 ?: metadata.previewUri?.let { uriMetadataReader.readPreviewSize(it) }
295 PreviewModel(
296 uri = row.uri,
297 previewUri = metadata.previewUri,
298 mimeType = metadata.mimeType,
299 aspectRatio = size.aspectRatioOrDefault(1f),
300 order = row.position,
301 )
302 }.also { updated ->
303 if (unclaimedRecords.remove(row.uri) != null) {
304 // unclaimedRecords contains initially shared (and thus selected) items with unknown
305 // cursor position. Update selection records when any of those items is encountered
306 // in the cursor to maintain proper selection order should other items also be
307 // selected.
308 selectionInteractor.updateSelection(updated)
309 }
310 }
311
312 private fun <M : MutablePreviewMap> M.putAllUnclaimedRight(unclaimed: UnclaimedMap): M =
313 putAllUnclaimedWhere(unclaimed) { it >= focusedItemIdx }
314
315 private fun <M : MutablePreviewMap> M.putAllUnclaimedLeft(unclaimed: UnclaimedMap): M =
316 putAllUnclaimedWhere(unclaimed) { it < focusedItemIdx }
317 }
318
319 private typealias CursorWindow = LoadedWindow<Uri, PreviewModel>
320
321 /**
322 * Values from the initial selection set that have not yet appeared within the Cursor. These values
323 * are appended to the start/end of the cursor dataset, depending on their position relative to the
324 * initially focused value.
325 */
326 private typealias UnclaimedMap = Map<Uri, Pair<Int, PreviewModel>>
327
328 /** Mutable version of [UnclaimedMap]. */
329 private typealias MutableUnclaimedMap = MutableMap<Uri, Pair<Int, PreviewModel>>
330
331 private typealias MutablePreviewMap = MutableMap<Uri, PreviewModel>
332
333 private typealias PreviewMap = Map<Uri, PreviewModel>
334
putAllUnclaimedWherenull335 private fun <M : MutablePreviewMap> M.putAllUnclaimedWhere(
336 unclaimedRecords: UnclaimedMap,
337 predicate: (Int) -> Boolean,
338 ): M =
339 unclaimedRecords
340 .asSequence()
341 .filter { predicate(it.value.first) }
<lambda>null342 .map { it.key to it.value.second }
343 .toMap(this)
344
getPageRowsnull345 private fun PagedCursor<CursorRow?>.getPageRows(pageNum: Int): Sequence<CursorRow>? =
346 get(pageNum)?.filterNotNull()
347
348 @Qualifier @MustBeDocumented @Retention(AnnotationRetention.RUNTIME) annotation class PageSize
349
350 @Qualifier
351 @MustBeDocumented
352 @Retention(AnnotationRetention.RUNTIME)
353 annotation class MaxLoadedPages
354
355 @Module
356 @InstallIn(SingletonComponent::class)
357 object ShareouselConstants {
358 @Provides @PageSize fun pageSize(): Int = 16
359
360 @Provides @MaxLoadedPages fun maxLoadedPages(): Int = 8
361 }
362