diff --git a/AnkiDroid/src/main/java/com/ichi2/anki/AnkiActivity.kt b/AnkiDroid/src/main/java/com/ichi2/anki/AnkiActivity.kt index df68b219cd73..0f7db9269966 100644 --- a/AnkiDroid/src/main/java/com/ichi2/anki/AnkiActivity.kt +++ b/AnkiDroid/src/main/java/com/ichi2/anki/AnkiActivity.kt @@ -49,7 +49,6 @@ import androidx.core.net.toUri import androidx.fragment.app.Fragment import androidx.lifecycle.Lifecycle import androidx.lifecycle.lifecycleScope -import androidx.lifecycle.repeatOnLifecycle import androidx.viewbinding.ViewBinding import com.google.android.material.color.MaterialColors import com.google.android.material.snackbar.Snackbar @@ -62,7 +61,6 @@ import com.ichi2.anki.android.input.ShortcutGroup import com.ichi2.anki.android.input.ShortcutGroupProvider import com.ichi2.anki.android.input.shortcut import com.ichi2.anki.common.annotations.LegacyNotifications -import com.ichi2.anki.common.utils.android.isRobolectric import com.ichi2.anki.common.utils.annotation.KotlinCleanup import com.ichi2.anki.dialogs.AsyncDialogFragment import com.ichi2.anki.dialogs.DatabaseErrorDialog @@ -88,12 +86,8 @@ import com.ichi2.compat.customtabs.CustomTabsFallback import com.ichi2.compat.customtabs.CustomTabsHelper import com.ichi2.themes.Themes import com.ichi2.utils.AdaptionUtil -import com.ichi2.utils.HandlerUtils import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.flow.Flow -import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.launch -import kotlinx.coroutines.runBlocking import kotlinx.coroutines.withContext import timber.log.Timber import java.io.File @@ -698,43 +692,6 @@ open class AnkiActivity( return false } - // TODO: Move this to an extension method once we have context parameters - protected fun Flow.launchCollectionInLifecycleScope(block: suspend (T) -> Unit) { - lifecycleScope.launch { - lifecycle.repeatOnLifecycle(Lifecycle.State.RESUMED) { - this@launchCollectionInLifecycleScope.collect { - if (isRobolectric) { - // hack: lifecycleScope/runOnUiThread do not handle our - // test dispatcher overriding both IO and Main - // in tests, waitForAsyncTasksToComplete may be required. - HandlerUtils.postOnNewHandler { runBlocking { block(it) } } - } else { - block(it) - } - } - } - } - } - - // see above: - protected fun StateFlow.launchCollectionInLifecycleScope(block: suspend (T) -> Unit) { - lifecycleScope.launch { - var lastValue: T? = null - lifecycle.repeatOnLifecycle(Lifecycle.State.RESUMED) { - this@launchCollectionInLifecycleScope.collect { - // on re-resume, an unchanged value will be emitted for a StateFlow - if (lastValue == value) return@collect - lastValue = value - if (isRobolectric) { - HandlerUtils.postOnNewHandler { runBlocking { block(it) } } - } else { - block(it) - } - } - } - } - } - override val shortcuts get(): ShortcutGroup? = null diff --git a/AnkiDroid/src/main/java/com/ichi2/anki/CardBrowser.kt b/AnkiDroid/src/main/java/com/ichi2/anki/CardBrowser.kt index bfe2859100d3..e2d9a3e533e6 100644 --- a/AnkiDroid/src/main/java/com/ichi2/anki/CardBrowser.kt +++ b/AnkiDroid/src/main/java/com/ichi2/anki/CardBrowser.kt @@ -98,6 +98,7 @@ import com.ichi2.anki.settings.Prefs import com.ichi2.anki.snackbar.showSnackbar import com.ichi2.anki.ui.ResizablePaneManager import com.ichi2.anki.ui.internationalization.toSentenceCase +import com.ichi2.anki.utils.ext.launchCollectionInLifecycleScope import com.ichi2.anki.utils.ext.showDialogFragment import com.ichi2.ui.CardBrowserSearchView import com.ichi2.utils.AndroidUiUtils.hideKeyboard diff --git a/AnkiDroid/src/main/java/com/ichi2/anki/DeckPicker.kt b/AnkiDroid/src/main/java/com/ichi2/anki/DeckPicker.kt index 6e51e6a72df0..4cccbc431ffd 100644 --- a/AnkiDroid/src/main/java/com/ichi2/anki/DeckPicker.kt +++ b/AnkiDroid/src/main/java/com/ichi2/anki/DeckPicker.kt @@ -171,6 +171,7 @@ import com.ichi2.anki.utils.Destination import com.ichi2.anki.utils.ShortcutUtils import com.ichi2.anki.utils.ext.dismissAllDialogFragments import com.ichi2.anki.utils.ext.getSizeOfBitmapFromCollection +import com.ichi2.anki.utils.ext.launchCollectionInLifecycleScope import com.ichi2.anki.utils.ext.setFragmentResultListener import com.ichi2.anki.utils.ext.showDialogFragment import com.ichi2.anki.utils.runWithOOMCheck diff --git a/AnkiDroid/src/main/java/com/ichi2/anki/browser/CardBrowserFragment.kt b/AnkiDroid/src/main/java/com/ichi2/anki/browser/CardBrowserFragment.kt index f62303471261..54dd7736e45a 100644 --- a/AnkiDroid/src/main/java/com/ichi2/anki/browser/CardBrowserFragment.kt +++ b/AnkiDroid/src/main/java/com/ichi2/anki/browser/CardBrowserFragment.kt @@ -38,7 +38,6 @@ import androidx.fragment.app.activityViewModels import androidx.fragment.app.viewModels import androidx.lifecycle.Lifecycle import androidx.lifecycle.lifecycleScope -import androidx.lifecycle.repeatOnLifecycle import androidx.recyclerview.widget.DividerItemDecoration import androidx.recyclerview.widget.LinearLayoutManager import androidx.recyclerview.widget.RecyclerView @@ -70,7 +69,6 @@ import com.ichi2.anki.browser.RepositionCardFragment.Companion.REQUEST_REPOSITIO import com.ichi2.anki.browser.RepositionCardsRequest.ContainsNonNewCardsError import com.ichi2.anki.browser.RepositionCardsRequest.RepositionData import com.ichi2.anki.common.annotations.NeedsTest -import com.ichi2.anki.common.utils.android.isRobolectric import com.ichi2.anki.common.utils.annotation.KotlinCleanup import com.ichi2.anki.dialogs.BrowserOptionsDialog import com.ichi2.anki.dialogs.CardBrowserOrderDialog @@ -98,17 +96,15 @@ import com.ichi2.anki.ui.attachFastScroller import com.ichi2.anki.undoAndShowSnackbar import com.ichi2.anki.utils.ext.getCurrentDialogFragment import com.ichi2.anki.utils.ext.ifNotZero +import com.ichi2.anki.utils.ext.launchCollectionInLifecycleScope import com.ichi2.anki.utils.ext.setFragmentResultListener import com.ichi2.anki.utils.ext.showDialogFragment import com.ichi2.anki.utils.ext.visibleItemPositions import com.ichi2.anki.utils.showDialogFragmentImpl import com.ichi2.anki.withProgress -import com.ichi2.utils.HandlerUtils import com.ichi2.utils.TagsUtil.getUpdatedTags import kotlinx.coroutines.Job -import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.launch -import kotlinx.coroutines.runBlocking import net.ankiweb.rsdroid.Translations import timber.log.Timber @@ -954,21 +950,6 @@ class CardBrowserFragment : private fun requireCardBrowserActivity(): CardBrowser = requireActivity() as CardBrowser - // TODO: Move this to an extension method once we have context parameters - private fun Flow.launchCollectionInLifecycleScope(block: suspend (T) -> Unit) { - lifecycleScope.launch { - lifecycle.repeatOnLifecycle(Lifecycle.State.RESUMED) { - this@launchCollectionInLifecycleScope.collect { - if (isRobolectric) { - HandlerUtils.postOnNewHandler { runBlocking { block(it) } } - } else { - block(it) - } - } - } - } - } - /** * Updates the tags of selected/checked notes and saves them to the disk * @param selectedTags list of checked tags diff --git a/AnkiDroid/src/main/java/com/ichi2/anki/pages/ImageOcclusion.kt b/AnkiDroid/src/main/java/com/ichi2/anki/pages/ImageOcclusion.kt index 063f8ac6101e..4ed0752b53da 100644 --- a/AnkiDroid/src/main/java/com/ichi2/anki/pages/ImageOcclusion.kt +++ b/AnkiDroid/src/main/java/com/ichi2/anki/pages/ImageOcclusion.kt @@ -27,14 +27,10 @@ import androidx.core.os.BundleCompat import androidx.core.os.bundleOf import androidx.core.view.isVisible import androidx.fragment.app.viewModels -import androidx.lifecycle.Lifecycle -import androidx.lifecycle.lifecycleScope -import androidx.lifecycle.repeatOnLifecycle import com.google.android.material.appbar.MaterialToolbar import com.ichi2.anki.R import com.ichi2.anki.SingleFragmentActivity import com.ichi2.anki.common.annotations.NeedsTest -import com.ichi2.anki.common.utils.android.isRobolectric import com.ichi2.anki.dialogs.DeckSelectionDialog import com.ichi2.anki.dialogs.DiscardChangesDialog import com.ichi2.anki.model.SelectableDeck @@ -42,12 +38,8 @@ import com.ichi2.anki.pages.viewmodel.ImageOcclusionArgs import com.ichi2.anki.pages.viewmodel.ImageOcclusionViewModel import com.ichi2.anki.pages.viewmodel.ImageOcclusionViewModel.Companion.IO_ARGS_KEY import com.ichi2.anki.startDeckSelection -import com.ichi2.utils.HandlerUtils -import kotlinx.coroutines.flow.Flow -import kotlinx.coroutines.launch -import kotlinx.coroutines.runBlocking +import com.ichi2.anki.utils.ext.launchCollectionInLifecycleScope import timber.log.Timber -import java.lang.IllegalArgumentException /** * Page provided by the backend, for a user to add or edit an image occlusion (IO) note @@ -154,21 +146,6 @@ class ImageOcclusion : } } - // TODO: Move this to an extension method once we have context parameters - private fun Flow.launchCollectionInLifecycleScope(block: suspend (T) -> Unit) { - lifecycleScope.launch { - lifecycle.repeatOnLifecycle(Lifecycle.State.RESUMED) { - this@launchCollectionInLifecycleScope.collect { - if (isRobolectric) { - HandlerUtils.postOnNewHandler { runBlocking { block(it) } } - } else { - block(it) - } - } - } - } - } - companion object { /** * @param args arguments for either adding or editing a note diff --git a/AnkiDroid/src/main/java/com/ichi2/anki/utils/ext/Flow.kt b/AnkiDroid/src/main/java/com/ichi2/anki/utils/ext/Flow.kt index 24c65c0bd006..bf55fc20ce33 100644 --- a/AnkiDroid/src/main/java/com/ichi2/anki/utils/ext/Flow.kt +++ b/AnkiDroid/src/main/java/com/ichi2/anki/utils/ext/Flow.kt @@ -15,12 +15,21 @@ */ package com.ichi2.anki.utils.ext +import androidx.fragment.app.Fragment +import androidx.lifecycle.Lifecycle +import androidx.lifecycle.lifecycleScope +import androidx.lifecycle.repeatOnLifecycle +import com.ichi2.anki.AnkiActivity +import com.ichi2.anki.common.utils.android.isRobolectric +import com.ichi2.utils.HandlerUtils import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Job import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.FlowCollector +import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.collectLatest import kotlinx.coroutines.launch +import kotlinx.coroutines.runBlocking fun Flow.collectLatestIn( scope: CoroutineScope, @@ -37,3 +46,55 @@ fun Flow.collectIn( scope.launch { collect(collector) } + +context(fragment: Fragment) +fun Flow.launchCollectionInLifecycleScope(block: suspend (T) -> Unit) { + fragment.lifecycleScope.launch { + fragment.lifecycle.repeatOnLifecycle(Lifecycle.State.STARTED) { + this@launchCollectionInLifecycleScope.collect { + if (isRobolectric) { + HandlerUtils.postOnNewHandler { runBlocking { block(it) } } + } else { + block(it) + } + } + } + } +} + +context(activity: AnkiActivity) +fun Flow.launchCollectionInLifecycleScope(block: suspend (T) -> Unit) { + activity.lifecycleScope.launch { + activity.lifecycle.repeatOnLifecycle(Lifecycle.State.STARTED) { + this@launchCollectionInLifecycleScope.collect { + if (isRobolectric) { + // hack: lifecycleScope/runOnUiThread do not handle our + // test dispatcher overriding both IO and Main + // in tests, waitForAsyncTasksToComplete may be required. + HandlerUtils.postOnNewHandler { runBlocking { block(it) } } + } else { + block(it) + } + } + } + } +} + +context(activity: AnkiActivity) +fun StateFlow.launchCollectionInLifecycleScope(block: suspend (T) -> Unit) { + activity.lifecycleScope.launch { + var lastValue: T? = null + activity.lifecycle.repeatOnLifecycle(Lifecycle.State.STARTED) { + this@launchCollectionInLifecycleScope.collect { + // on re-resume, an unchanged value will be emitted for a StateFlow + if (lastValue == value) return@collect + lastValue = value + if (isRobolectric) { + HandlerUtils.postOnNewHandler { runBlocking { block(it) } } + } else { + block(it) + } + } + } + } +} diff --git a/AnkiDroid/src/test/java/com/ichi2/anki/CardBrowserTest.kt b/AnkiDroid/src/test/java/com/ichi2/anki/CardBrowserTest.kt index 108f5120bf6b..2a0fc2b35bf3 100644 --- a/AnkiDroid/src/test/java/com/ichi2/anki/CardBrowserTest.kt +++ b/AnkiDroid/src/test/java/com/ichi2/anki/CardBrowserTest.kt @@ -798,7 +798,11 @@ class CardBrowserTest : RobolectricTest() { // Kill and restart the activity and ensure that display order is preserved val outBundle = Bundle() cardBrowserController.saveInstanceState(outBundle) - cardBrowserController.pause().stop().destroy() + cardBrowserController.pause().stop() + // fix Robolectric bug with launchCollectionInLifecycleScope + // method running after onStart without context + advanceRobolectricLooper() + cardBrowserController.destroy() cardBrowserController = Robolectric .buildActivity(CardBrowser::class.java)