diff options
| author | 2024-02-09 21:11:00 +0000 | |
|---|---|---|
| committer | 2024-02-09 21:11:00 +0000 | |
| commit | 765812d898c6df766e3417291d9d827a7114c0a0 (patch) | |
| tree | b74607ae944dfdf9fb0d5366ee7191311011435e /java/src | |
| parent | 246d008771742e4627a9512447e58dcee8f9ef38 (diff) | |
| parent | abc82dc47cbc5e878493b17e5479044c185ed88d (diff) | |
Merge changes I0d568a6c,I092d9b67 into main
* changes:
  Add PayloadToggleInteractor implementation
  Shareousel selection tracker component
Diffstat (limited to 'java/src')
6 files changed, 522 insertions, 27 deletions
| diff --git a/java/src/com/android/intentresolver/contentpreview/CursorUriReader.kt b/java/src/com/android/intentresolver/contentpreview/CursorUriReader.kt index 30495b8b..dbf27a88 100644 --- a/java/src/com/android/intentresolver/contentpreview/CursorUriReader.kt +++ b/java/src/com/android/intentresolver/contentpreview/CursorUriReader.kt @@ -34,9 +34,11 @@ class CursorUriReader(      private val predicate: (Uri) -> Boolean,  ) : PayloadToggleInteractor.CursorReader {      override val count = cursor.count -    // the first position of the next unread page on the right +    // Unread ranges are: +    // - left: [0, leftPos); +    // - right: [rightPos, count) +    // i.e. read range is: [leftPos, rightPos)      private var rightPos = startPos.coerceIn(0, count) -    // the first position of the next from the leftmost unread page on the left      private var leftPos = rightPos      override val hasMoreBefore @@ -74,7 +76,7 @@ class CursorUriReader(              return SparseArray()          }          val result = SparseArray<Uri>(leftPos - startPos) -        for (pos in startPos ..< leftPos) { +        for (pos in startPos until leftPos) {              cursor                  .getString(0)                  ?.let(Uri::parse) diff --git a/java/src/com/android/intentresolver/contentpreview/PayloadToggleInteractor.kt b/java/src/com/android/intentresolver/contentpreview/PayloadToggleInteractor.kt index ca868226..3393dcfc 100644 --- a/java/src/com/android/intentresolver/contentpreview/PayloadToggleInteractor.kt +++ b/java/src/com/android/intentresolver/contentpreview/PayloadToggleInteractor.kt @@ -16,44 +16,357 @@  package com.android.intentresolver.contentpreview +import android.content.Intent  import android.net.Uri  import android.service.chooser.ChooserAction +import android.util.Log  import android.util.SparseArray  import java.io.Closeable +import java.util.LinkedList +import java.util.concurrent.atomic.AtomicBoolean +import java.util.concurrent.atomic.AtomicReference +import kotlinx.coroutines.CompletableDeferred +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Job +import kotlinx.coroutines.awaitCancellation +import kotlinx.coroutines.channels.BufferOverflow.DROP_LATEST  import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.MutableSharedFlow  import kotlinx.coroutines.flow.MutableStateFlow -import kotlinx.coroutines.flow.flowOf +import kotlinx.coroutines.flow.filter +import kotlinx.coroutines.flow.flow  import kotlinx.coroutines.flow.map -import kotlinx.coroutines.flow.update +import kotlinx.coroutines.launch -class PayloadToggleInteractor { +private const val TAG = "PayloadToggleInteractor" -    private val storage = MutableStateFlow<Map<Any, Item>>(emptyMap()) // TODO: implement -    private val selectedKeys = MutableStateFlow<Set<Any>>(emptySet()) +class PayloadToggleInteractor( +    // must use single-thread dispatcher (or we should enforce it with a lock) +    private val scope: CoroutineScope, +    private val initiallySharedUris: List<Uri>, +    private val focusedUriIdx: Int, +    private val mimeTypeClassifier: MimeTypeClassifier, +    private val cursorReaderProvider: suspend () -> CursorReader, +    private val uriMetadataReader: (Uri) -> FileInfo, +    private val targetIntentModifier: (List<Item>) -> Intent, +    private val selectionCallback: (Intent) -> CallbackResult?, +) { +    private var cursorDataRef = CompletableDeferred<CursorData?>() +    private val records = LinkedList<Record>() +    private val prevPageLoadingGate = AtomicBoolean(true) +    private val nextPageLoadingGate = AtomicBoolean(true) +    private val notifySelectionJobRef = AtomicReference<Job?>() +    private val emptyState = +        State( +            emptyList(), +            hasMoreItemsBefore = false, +            hasMoreItemsAfter = false, +            allowSelectionChange = false +        ) + +    private val stateFlowSource = MutableStateFlow(emptyState) + +    val customActions = +        MutableSharedFlow<List<ChooserAction>>(replay = 1, onBufferOverflow = DROP_LATEST) + +    val stateFlow: Flow<State> +        get() = stateFlowSource.filter { it !== emptyState } + +    val targetPosition: Flow<Int> = stateFlow.map { it.targetPos } +    val previewKeys: Flow<List<Item>> = stateFlow.map { it.items } + +    fun getKey(item: Any): Int = (item as Item).key + +    fun selected(key: Item): Flow<Boolean> = (key as Record).isSelected -    val targetPosition: Flow<Int> = flowOf(0) // TODO: implement -    val previewKeys: Flow<List<Any>> = flowOf(emptyList()) // TODO: implement +    fun previewUri(key: Item): Flow<Uri?> = flow { emit(key.previewUri) } -    fun setSelected(key: Any, isSelected: Boolean) { -        if (isSelected) { -            selectedKeys.update { it + key } +    fun previewInteractor(key: Any): PayloadTogglePreviewInteractor { +        val state = stateFlowSource.value +        if (state === emptyState) { +            Log.wtf(TAG, "Requesting item preview before any item has been published")          } else { -            selectedKeys.update { it - key } +            if (state.hasMoreItemsBefore && key === state.items.firstOrNull()) { +                loadMorePreviousItems() +            } +            if (state.hasMoreItemsAfter && key == state.items.lastOrNull()) { +                loadMoreNextItems() +            }          } +        return PayloadTogglePreviewInteractor(key as Item, this)      } -    fun selected(key: Any): Flow<Boolean> = previewKeys.map { key in it } +    init { +        scope +            .launch { awaitCancellation() } +            .invokeOnCompletion { +                cursorDataRef.cancel() +                runCatching { +                        if (cursorDataRef.isCompleted && !cursorDataRef.isCancelled) { +                            cursorDataRef.getCompleted() +                        } else { +                            null +                        } +                    } +                    .getOrNull() +                    ?.reader +                    ?.close() +            } +    } -    fun previewInteractor(key: Any) = PayloadTogglePreviewInteractor(key, this) +    fun start() { +        scope.launch { +            publishInitialState() +            val cursorReader = cursorReaderProvider() +            val selectedItems = +                initiallySharedUris.map { uri -> +                    val fileInfo = uriMetadataReader(uri) +                    Record( +                        0, // artificial key for the pending record, it should not be used anywhere +                        uri, +                        fileInfo.previewUri, +                        fileInfo.mimeType, +                    ) +                } +            val cursorData = +                CursorData( +                    cursorReader, +                    SelectionTracker(selectedItems, focusedUriIdx, cursorReader.count) { uri }, +                ) +            if (cursorDataRef.complete(cursorData)) { +                doLoadMorePreviousItems() +                val startPos = records.size +                doLoadMoreNextItems() +                prevPageLoadingGate.set(false) +                nextPageLoadingGate.set(false) +                publishSnapshot(startPos) +            } else { +                cursorReader.close() +            } +        } +    } -    fun previewUri(key: Any): Flow<Uri?> = storage.map { it[key]?.previewUri } +    private suspend fun publishInitialState() { +        stateFlowSource.emit( +            State( +                if (0 <= focusedUriIdx && focusedUriIdx < initiallySharedUris.size) { +                    val fileInfo = uriMetadataReader(initiallySharedUris[focusedUriIdx]) +                    listOf( +                        Record( +                            // a unique key that won't appear anywhere after more items are loaded +                            -initiallySharedUris.size - 1, +                            initiallySharedUris[focusedUriIdx], +                            fileInfo.previewUri, +                            fileInfo.mimeType, +                            fileInfo.mimeType?.mimeTypeToItemType() ?: ItemType.File, +                        ), +                    ) +                } else { +                    emptyList() +                }, +                hasMoreItemsBefore = true, +                hasMoreItemsAfter = true, +                allowSelectionChange = false, +            ) +        ) +    } + +    fun loadMorePreviousItems() { +        invokeAsyncIfNotRunning(prevPageLoadingGate) { +            doLoadMorePreviousItems() +            publishSnapshot() +        } +    } + +    fun loadMoreNextItems() { +        invokeAsyncIfNotRunning(nextPageLoadingGate) { +            doLoadMoreNextItems() +            publishSnapshot() +        } +    } + +    fun setSelected(item: Item, isSelected: Boolean) { +        val record = item as Record +        record.isSelected.value = isSelected +        scope.launch { +            val (_, selectionTracker) = waitForCursorData() ?: return@launch +            selectionTracker.setItemSelection(record.key, record, isSelected) +            val targetIntent = targetIntentModifier(selectionTracker.getSelection()) + +            val newJob = scope.launch { notifySelectionChanged(targetIntent) } +            notifySelectionJobRef.getAndSet(newJob)?.cancel() +        } +    } + +    private fun invokeAsyncIfNotRunning(guardingFlag: AtomicBoolean, block: suspend () -> Unit) { +        if (guardingFlag.compareAndSet(false, true)) { +            scope.launch { block() }.invokeOnCompletion { guardingFlag.set(false) } +        } +    } + +    private suspend fun doLoadMorePreviousItems() { +        val (reader, selectionTracker) = waitForCursorData() ?: return +        if (!reader.hasMoreBefore) return + +        val newItems = reader.readPageBefore().toRecords() +        selectionTracker.onStartItemsAdded(newItems) +        for (i in newItems.size() - 1 downTo 0) { +            records.add( +                0, +                (newItems.valueAt(i) as Record).apply { +                    isSelected.value = selectionTracker.isItemSelected(key) +                } +            ) +        } +        if (!reader.hasMoreBefore && !reader.hasMoreAfter) { +            val pendingItems = selectionTracker.getPendingItems() +            val newRecords = +                pendingItems.foldIndexed(SparseArray<Item>()) { idx, acc, item -> +                    assert(item is Record) { "Unexpected pending item type: ${item.javaClass}" } +                    val rec = item as Record +                    val key = idx - pendingItems.size +                    acc.append( +                        key, +                        Record( +                            key, +                            rec.uri, +                            rec.previewUri, +                            rec.mimeType, +                            rec.mimeType?.mimeTypeToItemType() ?: ItemType.File +                        ) +                    ) +                    acc +                } -    private data class Item( -        val previewUri: Uri?, +            selectionTracker.onStartItemsAdded(newRecords) +            for (i in (newRecords.size() - 1) downTo 0) { +                records.add(0, (newRecords.valueAt(i) as Record).apply { isSelected.value = true }) +            } +        } +    } + +    private suspend fun doLoadMoreNextItems() { +        val (reader, selectionTracker) = waitForCursorData() ?: return +        if (!reader.hasMoreAfter) return + +        val newItems = reader.readPageAfter().toRecords() +        selectionTracker.onEndItemsAdded(newItems) +        for (i in 0 until newItems.size()) { +            val key = newItems.keyAt(i) +            records.add( +                (newItems.valueAt(i) as Record).apply { +                    isSelected.value = selectionTracker.isItemSelected(key) +                } +            ) +        } +        if (!reader.hasMoreBefore && !reader.hasMoreAfter) { +            val items = +                selectionTracker.getPendingItems().let { items -> +                    items.foldIndexed(SparseArray<Item>(items.size)) { i, acc, item -> +                        val key = reader.count + i +                        val record = item as Record +                        acc.append( +                            key, +                            Record(key, record.uri, record.previewUri, record.mimeType, record.type) +                        ) +                        acc +                    } +                } +            selectionTracker.onEndItemsAdded(items) +            for (i in 0 until items.size()) { +                records.add((items.valueAt(i) as Record).apply { isSelected.value = true }) +            } +        } +    } + +    private fun SparseArray<Uri>.toRecords(): SparseArray<Item> { +        val items = SparseArray<Item>(size()) +        for (i in 0 until size()) { +            val key = keyAt(i) +            val uri = valueAt(i) +            val fileInfo = uriMetadataReader(uri) +            items.append( +                key, +                Record( +                    key, +                    uri, +                    fileInfo.previewUri, +                    fileInfo.mimeType, +                    fileInfo.mimeType?.mimeTypeToItemType() ?: ItemType.File +                ) +            ) +        } +        return items +    } + +    private suspend fun waitForCursorData() = cursorDataRef.await() + +    private fun notifySelectionChanged(targetIntent: Intent) { +        selectionCallback(targetIntent)?.customActions?.let { customActions.tryEmit(it) } +    } + +    private suspend fun publishSnapshot(startPos: Int = -1) { +        val (reader, _) = waitForCursorData() ?: return +        // TODO: publish a view into the list as it can only grow on each side thus a view won't be +        // invalidated +        val items = ArrayList<Item>(records) +        stateFlowSource.emit( +            State( +                items, +                reader.hasMoreBefore, +                reader.hasMoreAfter, +                allowSelectionChange = true, +                targetPos = startPos, +            ) +        ) +    } + +    private fun String.mimeTypeToItemType(): ItemType = +        when { +            mimeTypeClassifier.isImageType(this) -> ItemType.Image +            mimeTypeClassifier.isVideoType(this) -> ItemType.Video +            else -> ItemType.File +        } + +    class State( +        val items: List<Item>, +        val hasMoreItemsBefore: Boolean, +        val hasMoreItemsAfter: Boolean, +        val allowSelectionChange: Boolean, +        val targetPos: Int = -1,      ) +    sealed interface Item { +        val key: Int +        val uri: Uri +        val previewUri: Uri? +        val mimeType: String? +        val type: ItemType +    } + +    enum class ItemType { +        Image, +        Video, +        File, +    } + +    private class Record( +        override val key: Int, +        override val uri: Uri, +        override val previewUri: Uri? = uri, +        override val mimeType: String?, +        override val type: ItemType = ItemType.Image, +    ) : Item { +        val isSelected = MutableStateFlow(false) +    } +      data class CallbackResult(val customActions: List<ChooserAction>?) +    private data class CursorData( +        val reader: CursorReader, +        val selectionTracker: SelectionTracker<Item>, +    ) +      interface CursorReader : Closeable {          val count: Int          val hasMoreBefore: Boolean @@ -66,13 +379,17 @@ class PayloadToggleInteractor {  }  class PayloadTogglePreviewInteractor( -    private val key: Any, +    private val item: PayloadToggleInteractor.Item,      private val interactor: PayloadToggleInteractor,  ) {      fun setSelected(selected: Boolean) { -        interactor.setSelected(key, selected) +        interactor.setSelected(item, selected)      } -    val previewUri: Flow<Uri?> = interactor.previewUri(key) -    val selected: Flow<Boolean> = interactor.selected(key) +    val previewUri: Flow<Uri?> +        get() = interactor.previewUri(item) +    val selected: Flow<Boolean> +        get() = interactor.selected(item) +    val key +        get() = item.key  } diff --git a/java/src/com/android/intentresolver/contentpreview/PreviewViewModel.kt b/java/src/com/android/intentresolver/contentpreview/PreviewViewModel.kt index d855ea16..77cf0ac9 100644 --- a/java/src/com/android/intentresolver/contentpreview/PreviewViewModel.kt +++ b/java/src/com/android/intentresolver/contentpreview/PreviewViewModel.kt @@ -65,7 +65,7 @@ constructor(      }      override val payloadToggleInteractor: PayloadToggleInteractor? by lazy { -        PayloadToggleInteractor() +        null // TODO: initialize PayloadToggleInteractor()      }      companion object { diff --git a/java/src/com/android/intentresolver/contentpreview/SelectionTracker.kt b/java/src/com/android/intentresolver/contentpreview/SelectionTracker.kt new file mode 100644 index 00000000..4ce006ec --- /dev/null +++ b/java/src/com/android/intentresolver/contentpreview/SelectionTracker.kt @@ -0,0 +1,175 @@ +/* + * Copyright 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 + +import android.net.Uri +import android.util.SparseArray +import android.util.SparseIntArray +import androidx.core.util.containsKey +import androidx.core.util.isNotEmpty + +/** + * Tracks selected items (including those that has not been read frm the cursor) and their relative + * order. + */ +class SelectionTracker<Item>( +    selectedItems: List<Item>, +    private val focusedItemIdx: Int, +    private val cursorCount: Int, +    private val getUri: Item.() -> Uri, +) { +    /** Contains selected items keys. */ +    private val selections = SparseArray<Item>(selectedItems.size) + +    /** +     * A set of initially selected items that has not yet been observed by the lazy read of the +     * cursor and thus has unknown key (cursor position). Initially, all [selectedItems] are put in +     * this map with items at the index less than [focusedItemIdx] with negative keys (to the left +     * of all cursor items) and items at the index more or equal to [focusedItemIdx] with keys more +     * or equal to [cursorCount] (to the right of all cursor items) in their relative order. Upon +     * reading the cursor, [onEndItemsAdded]/[onStartItemsAdded], all pending items from that +     * collection in the corresponding direction get their key assigned and gets removed from the +     * map. Items that were missing from the cursor get removed from the map by +     * [getPendingItems] + [onStartItemsAdded]/[onEndItemsAdded] combination. +     */ +    private val pendingKeys = HashMap<Uri, SparseIntArray>() + +    init { +        selectedItems.forEachIndexed { i, item -> +            // all items before focusedItemIdx gets "positioned" before all the cursor items +            // and all the reset after all the cursor items in their relative order. +            // Also see the comments to pendingKeys property. +            val key = +                if (i < focusedItemIdx) { +                    i - focusedItemIdx +                } else { +                    i + cursorCount - focusedItemIdx +                } +            selections.append(key, item) +            pendingKeys.getOrPut(item.getUri()) { SparseIntArray(1) }.append(key, key) +        } +    } + +    /** Update selections based on the set of items read from the end of the cursor */ +    fun onEndItemsAdded(items: SparseArray<Item>) { +        for (i in 0 until items.size()) { +            val item = items.valueAt(i) +            pendingKeys[item.getUri()] +                // if only one pending (unmatched) item with this URI is left, removed this URI +                ?.also { +                    if (it.size() <= 1) { +                        pendingKeys.remove(item.getUri()) +                    } +                } +                // a safeguard, we should not observe empty arrays at this point +                ?.takeIf { it.isNotEmpty() } +                // pick a matching pending items from the right side +                ?.let { pendingUriPositions -> +                    val key = items.keyAt(i) +                    val insertPos = +                        pendingUriPositions +                            .findBestKeyPosition(key) +                            .coerceIn(0, pendingUriPositions.size() - 1) +                    // select next pending item from the right, if not such item exists then +                    // the data is inconsistent and we pick the closes one from the left +                    val keyPlaceholder = pendingUriPositions.keyAt(insertPos) +                    pendingUriPositions.removeAt(insertPos) +                    selections.remove(keyPlaceholder) +                    selections[key] = item +                } +        } +    } + +    /** Update selections based on the set of items read from the head of the cursor */ +    fun onStartItemsAdded(items: SparseArray<Item>) { +        for (i in (items.size() - 1) downTo 0) { +            val item = items.valueAt(i) +            pendingKeys[item.getUri()] +                // if only one pending (unmatched) item with this URI is left, removed this URI +                ?.also { +                    if (it.size() <= 1) { +                        pendingKeys.remove(item.getUri()) +                    } +                } +                // a safeguard, we should not observe empty arrays at this point +                ?.takeIf { it.isNotEmpty() } +                // pick a matching pending items from the left side +                ?.let { pendingUriPositions -> +                    val key = items.keyAt(i) +                    val insertPos = +                        pendingUriPositions +                            .findBestKeyPosition(key) +                            .coerceIn(1, pendingUriPositions.size()) +                    // select next pending item from the left, if not such item exists then +                    // the data is inconsistent and we pick the closes one from the right +                    val keyPlaceholder = pendingUriPositions.keyAt(insertPos - 1) +                    pendingUriPositions.removeAt(insertPos - 1) +                    selections.remove(keyPlaceholder) +                    selections[key] = item +                } +        } +    } + +    /** Updated selection status for the given item */ +    fun setItemSelection(key: Int, item: Item, isSelected: Boolean): Boolean { +        val idx = selections.indexOfKey(key) +        if (isSelected && idx < 0) { +            selections[key] = item +            return true +        } +        if (!isSelected && idx >= 0) { +            selections.removeAt(idx) +            return true +        } +        return false +    } + +    /** Return selection status for the given item */ +    fun isItemSelected(key: Int): Boolean = selections.containsKey(key) + +    fun getSelection(): List<Item> = +        buildList(selections.size()) { +            for (i in 0 until selections.size()) { +                add(selections.valueAt(i)) +            } +        } + +    /** Return all selected items that has not yet been read from the cursor */ +    fun getPendingItems(): List<Item> = +        if (pendingKeys.isEmpty()) { +            emptyList() +        } else { +            buildList { +                for (i in 0 until selections.size()) { +                    val item = selections.valueAt(i) ?: continue +                    if (isPending(item, selections.keyAt(i))) { +                        add(item) +                    } +                } +            } +        } + +    private fun isPending(item: Item, key: Int): Boolean { +        val keys = pendingKeys[item.getUri()] ?: return false +        return keys.containsKey(key) +    } + +    private fun SparseIntArray.findBestKeyPosition(key: Int): Int = +        // undocumented, but indexOfKey behaves in the same was as +        // java.util.Collections#binarySearch() +        indexOfKey(key).let { if (it < 0) it.inv() else it } +} diff --git a/java/src/com/android/intentresolver/contentpreview/shareousel/ui/composable/ShareouselComposable.kt b/java/src/com/android/intentresolver/contentpreview/shareousel/ui/composable/ShareouselComposable.kt index c83c10b0..f636966e 100644 --- a/java/src/com/android/intentresolver/contentpreview/shareousel/ui/composable/ShareouselComposable.kt +++ b/java/src/com/android/intentresolver/contentpreview/shareousel/ui/composable/ShareouselComposable.kt @@ -15,7 +15,6 @@   */  package com.android.intentresolver.contentpreview.shareousel.ui.composable -import android.os.Parcelable  import androidx.compose.foundation.Image  import androidx.compose.foundation.border  import androidx.compose.foundation.clickable @@ -64,7 +63,7 @@ fun Shareousel(viewModel: ShareouselViewModel) {                  Modifier.fillMaxWidth()                      .height(dimensionResource(R.dimen.chooser_preview_image_height_tall))          ) { -            items(previewKeys, key = { (it as? Parcelable) ?: Unit }) { key -> +            items(previewKeys, key = viewModel.previewRowKey) { key ->                  ShareouselCard(viewModel.previewForKey(key))              }          } diff --git a/java/src/com/android/intentresolver/contentpreview/shareousel/ui/viewmodel/ShareouselViewModel.kt b/java/src/com/android/intentresolver/contentpreview/shareousel/ui/viewmodel/ShareouselViewModel.kt index 4592ea6d..ff22a6fd 100644 --- a/java/src/com/android/intentresolver/contentpreview/shareousel/ui/viewmodel/ShareouselViewModel.kt +++ b/java/src/com/android/intentresolver/contentpreview/shareousel/ui/viewmodel/ShareouselViewModel.kt @@ -29,6 +29,7 @@ data class ShareouselViewModel(      val actions: Flow<List<ActionChipViewModel>>,      val centerIndex: Flow<Int>,      val previewForKey: (key: Any) -> ShareouselImageViewModel, +    val previewRowKey: (Any) -> Any  )  data class ActionChipViewModel(val label: String, val icon: ComposeIcon?, val onClick: () -> Unit) @@ -56,6 +57,7 @@ fun PayloadToggleInteractor.toShareouselViewModel(imageLoader: ImageLoader): Sha                  setSelected = { isSelected -> previewInteractor.setSelected(isSelected) },                  onActionClick = {},              ) -        } +        }, +        previewRowKey = { getKey(it) },      )  } |