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