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