diff --git a/app/src/main/kotlin/org/cru/godtools/ui/dashboard/DashboardPresenter.kt b/app/src/main/kotlin/org/cru/godtools/ui/dashboard/DashboardPresenter.kt index 675a47149f..1c3351e92e 100644 --- a/app/src/main/kotlin/org/cru/godtools/ui/dashboard/DashboardPresenter.kt +++ b/app/src/main/kotlin/org/cru/godtools/ui/dashboard/DashboardPresenter.kt @@ -20,13 +20,13 @@ import dagger.hilt.components.SingletonComponent import kotlinx.coroutines.coroutineScope import kotlinx.coroutines.launch import org.ccci.gto.android.common.sync.SyncTracker +import org.ccci.gto.android.common.sync.rememberSyncTask +import org.ccci.gto.android.common.sync.rememberSyncTaskRegistry import org.cru.godtools.base.ui.circuit.screen.dashboard.DashboardScreen import org.cru.godtools.base.ui.circuit.screen.dashboard.page.DashboardPage import org.cru.godtools.base.ui.circuit.screen.dashboard.page.HomeScreen import org.cru.godtools.sync.GodToolsSyncService import org.cru.godtools.ui.dashboard.DashboardPresenter.UiState -import org.cru.godtools.ui.dashboard.SyncTaskRegistry.Companion.rememberSyncRegistry -import org.cru.godtools.ui.dashboard.SyncTaskRegistry.Companion.syncTaskRegistry import org.cru.godtools.ui.drawer.DrawerMenuPresenter import org.cru.godtools.ui.drawer.DrawerMenuScreen @@ -53,16 +53,8 @@ class DashboardPresenter @AssistedInject internal constructor( @Composable override fun present(): UiState { - val syncRegistry = rememberSyncRegistry() - DisposableEffect(syncRegistry) { - circuitContext.syncTaskRegistry = syncRegistry - val id = syncRegistry.registerSyncTask { syncData(it) } - - onDispose { - circuitContext.syncTaskRegistry = null - syncRegistry.unregisterSyncTask(id) - } - } + val syncRegistry = circuitContext.rememberSyncTaskRegistry() + syncRegistry.rememberSyncTask { syncData(it) } return UiState( drawerState = drawerMenuPresenter.present(), diff --git a/app/src/main/kotlin/org/cru/godtools/ui/dashboard/SyncTaskRegistry.kt b/app/src/main/kotlin/org/cru/godtools/ui/dashboard/SyncTaskRegistry.kt deleted file mode 100644 index 5a039a2126..0000000000 --- a/app/src/main/kotlin/org/cru/godtools/ui/dashboard/SyncTaskRegistry.kt +++ /dev/null @@ -1,41 +0,0 @@ -package org.cru.godtools.ui.dashboard - -import androidx.compose.runtime.Composable -import androidx.compose.runtime.remember -import com.slack.circuit.runtime.CircuitContext -import kotlin.uuid.ExperimentalUuidApi -import kotlin.uuid.Uuid -import org.ccci.gto.android.common.sync.SyncTracker -import org.ccci.gto.android.common.sync.rememberSyncTracker - -internal class SyncTaskRegistry(val syncTracker: SyncTracker) { - private val tasks = mutableMapOf Unit>() - - @OptIn(ExperimentalUuidApi::class) - fun registerSyncTask(task: SyncTracker.(force: Boolean) -> Unit): String { - val id = Uuid.generateV7().toString() - synchronized(tasks) { tasks[id] = task } - syncTracker.task(false) - return id - } - - fun unregisterSyncTask(id: String) { - synchronized(tasks) { tasks.remove(id) } - } - - fun triggerSyncTasks(force: Boolean = false) { - synchronized(tasks) { tasks.values.toList() }.forEach { syncTracker.it(force) } - } - - companion object { - internal var CircuitContext.syncTaskRegistry: SyncTaskRegistry? - get() = tag() ?: parent?.syncTaskRegistry - set(value) = putTag(value) - - @Composable - internal fun rememberSyncRegistry(): SyncTaskRegistry { - val syncTracker = rememberSyncTracker() - return remember(syncTracker) { SyncTaskRegistry(syncTracker) } - } - } -} diff --git a/app/src/main/kotlin/org/cru/godtools/ui/dashboard/lessons/LessonsPresenter.kt b/app/src/main/kotlin/org/cru/godtools/ui/dashboard/lessons/LessonsPresenter.kt index 43cc0e05bf..94ac6eb4d6 100644 --- a/app/src/main/kotlin/org/cru/godtools/ui/dashboard/lessons/LessonsPresenter.kt +++ b/app/src/main/kotlin/org/cru/godtools/ui/dashboard/lessons/LessonsPresenter.kt @@ -2,7 +2,6 @@ package org.cru.godtools.ui.dashboard.lessons import android.content.Context import androidx.compose.runtime.Composable -import androidx.compose.runtime.DisposableEffect import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue @@ -39,6 +38,7 @@ import kotlinx.coroutines.flow.map import org.ccci.gto.android.common.dagger.coroutines.DispatcherType import org.ccci.gto.android.common.dagger.coroutines.DispatcherType.Type.IO import org.ccci.gto.android.common.sync.SyncTracker +import org.ccci.gto.android.common.sync.rememberSyncTask import org.cru.godtools.analytics.model.OpenAnalyticsActionEvent import org.cru.godtools.analytics.model.OpenAnalyticsActionEvent.Companion.ACTION_OPEN_LESSON import org.cru.godtools.analytics.model.OpenAnalyticsActionEvent.Companion.SOURCE_LESSONS @@ -51,7 +51,6 @@ import org.cru.godtools.db.repository.TranslationsRepository import org.cru.godtools.model.Language import org.cru.godtools.model.Language.Companion.filterByDisplayAndNativeName import org.cru.godtools.sync.GodToolsSyncService -import org.cru.godtools.ui.dashboard.SyncTaskRegistry.Companion.syncTaskRegistry import org.cru.godtools.ui.dashboard.filters.FilterMenu import org.cru.godtools.ui.dashboard.lessons.LessonsPresenter.UiState import org.cru.godtools.ui.settings.country.CountrySettingsScreen @@ -219,12 +218,7 @@ class LessonsPresenter @AssistedInject internal constructor( @Composable private fun RegisterSyncTask(locale: Locale) { - val syncRegistry = circuitContext.syncTaskRegistry - DisposableEffect(syncRegistry, locale) { - if (syncRegistry == null) return@DisposableEffect onDispose { } - val id = syncRegistry.registerSyncTask { force -> syncData(locale, force) } - onDispose { syncRegistry.unregisterSyncTask(id) } - } + circuitContext.rememberSyncTask(locale) { syncData(locale, it) } } private fun SyncTracker.syncData(locale: Locale, force: Boolean = false) = launchSync { diff --git a/app/src/main/kotlin/org/cru/godtools/ui/dashboard/tools/ToolsPresenter.kt b/app/src/main/kotlin/org/cru/godtools/ui/dashboard/tools/ToolsPresenter.kt index 1d698fe95f..e8bd0af3ab 100644 --- a/app/src/main/kotlin/org/cru/godtools/ui/dashboard/tools/ToolsPresenter.kt +++ b/app/src/main/kotlin/org/cru/godtools/ui/dashboard/tools/ToolsPresenter.kt @@ -1,7 +1,6 @@ package org.cru.godtools.ui.dashboard.tools import androidx.compose.runtime.Composable -import androidx.compose.runtime.DisposableEffect import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue import androidx.compose.runtime.key @@ -26,6 +25,7 @@ import kotlinx.coroutines.coroutineScope import kotlinx.coroutines.flow.first import kotlinx.coroutines.launch import org.ccci.gto.android.common.sync.SyncTracker +import org.ccci.gto.android.common.sync.rememberSyncTask import org.cru.godtools.analytics.model.OpenAnalyticsActionEvent import org.cru.godtools.analytics.model.OpenAnalyticsActionEvent.Companion.ACTION_OPEN_TOOL_DETAILS import org.cru.godtools.analytics.model.OpenAnalyticsActionEvent.Companion.SOURCE_ALL_TOOLS @@ -39,7 +39,6 @@ import org.cru.godtools.sync.GodToolsSyncService import org.cru.godtools.ui.banner.Banner import org.cru.godtools.ui.banner.BannerPresenter import org.cru.godtools.ui.banner.favoritetools.FavoriteToolsBannerPresenter -import org.cru.godtools.ui.dashboard.SyncTaskRegistry.Companion.syncTaskRegistry import org.cru.godtools.ui.dashboard.tools.ToolFiltersStateProducer.Filters import org.cru.godtools.ui.dashboard.tools.ToolsPresenter.UiState import org.cru.godtools.ui.dashboard.tools.ToolsPresenter.UiState.Mode @@ -95,7 +94,8 @@ class ToolsPresenter @AssistedInject internal constructor( val filters = toolFiltersStateProducer.produce(mode) val selectedLocale by rememberUpdatedState(filters.languageFilter.selectedItem?.code) - RegisterSyncTask(selectedLocale) + val appLocale by settings.appLanguageFlow.collectAsState() + RegisterSyncTask(selectedLocale ?: appLocale) val featuredTools = rememberFeaturedTools( mode = mode, @@ -128,13 +128,8 @@ class ToolsPresenter @AssistedInject internal constructor( } @Composable - private fun RegisterSyncTask(selectedLocale: Locale?) { - val syncRegistry = circuitContext.syncTaskRegistry - DisposableEffect(syncRegistry, selectedLocale) { - if (syncRegistry == null) return@DisposableEffect onDispose { } - val id = syncRegistry.registerSyncTask { force -> syncData(selectedLocale ?: settings.appLanguage, force) } - onDispose { syncRegistry.unregisterSyncTask(id) } - } + private fun RegisterSyncTask(locale: Locale) { + circuitContext.rememberSyncTask(locale) { syncData(locale, it) } } @Composable diff --git a/app/src/test/kotlin/org/cru/godtools/ui/dashboard/DashboardPresenterTest.kt b/app/src/test/kotlin/org/cru/godtools/ui/dashboard/DashboardPresenterTest.kt index d1e0b781d8..90a330fe9b 100644 --- a/app/src/test/kotlin/org/cru/godtools/ui/dashboard/DashboardPresenterTest.kt +++ b/app/src/test/kotlin/org/cru/godtools/ui/dashboard/DashboardPresenterTest.kt @@ -24,6 +24,7 @@ import kotlinx.coroutines.sync.Mutex import kotlinx.coroutines.sync.withLock import kotlinx.coroutines.test.runTest import org.ccci.gto.android.common.androidx.compose.ui.platform.AndroidUiDispatcherUtil +import org.ccci.gto.android.common.sync.SyncTaskRegistry import org.ccci.gto.support.turbine.awaitItemMatching import org.cru.godtools.base.ui.circuit.screen.dashboard.DashboardScreen import org.cru.godtools.base.ui.circuit.screen.dashboard.page.HomeScreen @@ -31,7 +32,6 @@ import org.cru.godtools.base.ui.circuit.screen.dashboard.page.LessonsScreen import org.cru.godtools.sync.GodToolsSyncService import org.cru.godtools.ui.dashboard.DashboardPresenter.UiEvent import org.cru.godtools.ui.dashboard.DashboardPresenter.UiState -import org.cru.godtools.ui.dashboard.SyncTaskRegistry.Companion.syncTaskRegistry import org.cru.godtools.ui.drawer.DrawerMenuPresenter import org.cru.godtools.ui.drawer.DrawerMenuScreen import org.cru.godtools.ui.tooldetails.ToolDetailsScreen @@ -152,12 +152,12 @@ class DashboardPresenterTest { // region SideEffect - SyncTaskRegistry @Test fun `SideEffect - SyncTaskRegistry - set on CircuitContext while presenter is active`() = runTest { - assertNull(circuitContext.syncTaskRegistry) + assertNull(circuitContext.tag()) presenter.test { awaitInitialState() - assertNotNull(circuitContext.syncTaskRegistry) + assertNotNull(circuitContext.tag()) } - assertNull(circuitContext.syncTaskRegistry) + assertNull(circuitContext.tag()) } // endregion SideEffect - SyncTaskRegistry diff --git a/app/src/test/kotlin/org/cru/godtools/ui/dashboard/SyncTaskRegistryTest.kt b/app/src/test/kotlin/org/cru/godtools/ui/dashboard/SyncTaskRegistryTest.kt deleted file mode 100644 index d86033329c..0000000000 --- a/app/src/test/kotlin/org/cru/godtools/ui/dashboard/SyncTaskRegistryTest.kt +++ /dev/null @@ -1,121 +0,0 @@ -package org.cru.godtools.ui.dashboard - -import com.slack.circuit.runtime.CircuitContext -import com.slack.circuit.runtime.InternalCircuitApi -import kotlin.test.Test -import kotlin.test.assertEquals -import kotlin.test.assertNotEquals -import kotlin.test.assertNull -import kotlin.test.assertSame -import kotlinx.coroutines.test.TestScope -import org.ccci.gto.android.common.sync.SyncTracker -import org.cru.godtools.ui.dashboard.SyncTaskRegistry.Companion.syncTaskRegistry - -@OptIn(InternalCircuitApi::class) -class SyncTaskRegistryTest { - private val syncTracker = SyncTracker(TestScope()) - private val registry = SyncTaskRegistry(syncTracker) - - // region registerSyncTask() - @Test - fun `registerSyncTask - returns unique IDs`() { - val id1 = registry.registerSyncTask {} - val id2 = registry.registerSyncTask {} - assertNotEquals(id1, id2) - } - - @Test - fun `registerSyncTask - immediately executes task with force = false`() { - val calls = mutableListOf() - registry.registerSyncTask { force -> calls += force } - assertEquals(listOf(false), calls) - } - // endregion registerSyncTask() - - // region unregisterSyncTask() - @Test - fun `unregisterSyncTask - task not called after unregister`() { - val calls = mutableListOf() - val id = registry.registerSyncTask { force -> calls += force } - calls.clear() - - registry.unregisterSyncTask(id) - registry.triggerSyncTasks() - assertEquals(emptyList(), calls) - } - - @Test - fun `unregisterSyncTask - unknown id is safe`() { - registry.unregisterSyncTask("unknown-id") - } - // endregion unregisterSyncTask() - - // region triggerSyncTasks() - @Test - fun `triggerSyncTasks - calls all registered tasks`() { - val calls1 = mutableListOf() - val calls2 = mutableListOf() - registry.registerSyncTask { force -> calls1 += force } - registry.registerSyncTask { force -> calls2 += force } - calls1.clear() - calls2.clear() - - registry.triggerSyncTasks() - assertEquals(1, calls1.size) - assertEquals(1, calls2.size) - } - - @Test - fun `triggerSyncTasks - passes force = false by default`() { - val calls = mutableListOf() - registry.registerSyncTask { force -> calls += force } - calls.clear() - - registry.triggerSyncTasks() - assertEquals(listOf(false), calls) - } - - @Test - fun `triggerSyncTasks - passes force = true when specified`() { - val calls = mutableListOf() - registry.registerSyncTask { force -> calls += force } - calls.clear() - - registry.triggerSyncTasks(force = true) - assertEquals(listOf(true), calls) - } - // endregion triggerSyncTasks() - - // region CircuitContext.syncTaskRegistry - @Test - fun `syncTaskRegistry - null by default`() { - val context = CircuitContext(null) - assertNull(context.syncTaskRegistry) - } - - @Test - fun `syncTaskRegistry - returns set value`() { - val context = CircuitContext(null) - context.syncTaskRegistry = registry - assertSame(registry, context.syncTaskRegistry) - } - - @Test - fun `syncTaskRegistry - traverses parent context`() { - val parent = CircuitContext(null) - parent.syncTaskRegistry = registry - val child = CircuitContext(parent) - assertSame(registry, child.syncTaskRegistry) - } - - @Test - fun `syncTaskRegistry - child value takes precedence over parent`() { - val parentRegistry = SyncTaskRegistry(syncTracker) - val parent = CircuitContext(null) - parent.syncTaskRegistry = parentRegistry - val child = CircuitContext(parent) - child.syncTaskRegistry = registry - assertSame(registry, child.syncTaskRegistry) - } - // endregion CircuitContext.syncTaskRegistry -} diff --git a/app/src/test/kotlin/org/cru/godtools/ui/dashboard/lessons/LessonsPresenterTest.kt b/app/src/test/kotlin/org/cru/godtools/ui/dashboard/lessons/LessonsPresenterTest.kt index 436444db51..9c84b9d6bc 100644 --- a/app/src/test/kotlin/org/cru/godtools/ui/dashboard/lessons/LessonsPresenterTest.kt +++ b/app/src/test/kotlin/org/cru/godtools/ui/dashboard/lessons/LessonsPresenterTest.kt @@ -40,6 +40,7 @@ import kotlinx.coroutines.test.TestScope import kotlinx.coroutines.test.UnconfinedTestDispatcher import kotlinx.coroutines.test.runTest import org.ccci.gto.android.common.androidx.compose.ui.platform.AndroidUiDispatcherUtil +import org.ccci.gto.android.common.sync.SyncTaskRegistry import org.ccci.gto.android.common.sync.SyncTracker import org.ccci.gto.android.common.util.content.equalsIntent import org.ccci.gto.support.turbine.awaitItemMatching @@ -58,8 +59,6 @@ import org.cru.godtools.model.Translation import org.cru.godtools.model.randomTool import org.cru.godtools.model.randomTranslation import org.cru.godtools.sync.GodToolsSyncService -import org.cru.godtools.ui.dashboard.SyncTaskRegistry -import org.cru.godtools.ui.dashboard.SyncTaskRegistry.Companion.syncTaskRegistry import org.cru.godtools.ui.dashboard.filters.FilterMenu import org.cru.godtools.ui.dashboard.lessons.LessonsPresenter.UiEvent import org.cru.godtools.ui.dashboard.lessons.LessonsPresenter.UiState @@ -85,10 +84,6 @@ class LessonsPresenterTest { private var isPersonalizationEnabled = true private val testScope = TestScope() - @OptIn(InternalCircuitApi::class) - private val circuitContext = CircuitContext(null).apply { - syncTaskRegistry = SyncTaskRegistry(SyncTracker(testScope.backgroundScope)) - } private val context: Context = ApplicationProvider.getApplicationContext() private val eventBus: EventBus = mockk(relaxUnitFun = true) private val remoteConfig: FirebaseRemoteConfig = mockk { @@ -105,6 +100,7 @@ class LessonsPresenterTest { private val syncService: GodToolsSyncService = mockk { coEvery { syncToolOrder(any(), any(), any()) } coAnswers { toolOrderSync.receive() } } + private val syncTaskRegistry = SyncTaskRegistry(SyncTracker(testScope.backgroundScope)) private val lessonsFlowProducer: LessonsFlowProducer = mockk { every { getFlow(any(), any()) } returns flowOf(emptyList()) every { getFlow(any(), Locale.ENGLISH) } returns enLessonsFlow @@ -117,6 +113,8 @@ class LessonsPresenterTest { } private val backStack = SaveableBackStack(LessonsScreen) + @OptIn(InternalCircuitApi::class) + private val circuitContext = CircuitContext(null).apply { putTag(syncTaskRegistry) } private val navigator = FakeNavigator(backStack) private val presenter = LessonsPresenter( @@ -475,7 +473,7 @@ class LessonsPresenterTest { toolOrderSync.send(true) coVerify { syncService.syncToolOrder(Locale.ENGLISH, "US", false) } - circuitContext.syncTaskRegistry!!.triggerSyncTasks(force = true) + syncTaskRegistry.triggerSyncTasks(force = true) toolOrderSync.send(true) coVerify { syncService.syncToolOrder(Locale.ENGLISH, "US", true) } } diff --git a/app/src/testDebug/kotlin/org/cru/godtools/ui/dashboard/tools/ToolsPresenterTest.kt b/app/src/testDebug/kotlin/org/cru/godtools/ui/dashboard/tools/ToolsPresenterTest.kt index 04178013e5..9ce5b546c3 100644 --- a/app/src/testDebug/kotlin/org/cru/godtools/ui/dashboard/tools/ToolsPresenterTest.kt +++ b/app/src/testDebug/kotlin/org/cru/godtools/ui/dashboard/tools/ToolsPresenterTest.kt @@ -28,6 +28,7 @@ import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.test.TestScope import kotlinx.coroutines.test.runTest import org.ccci.gto.android.common.androidx.compose.ui.platform.AndroidUiDispatcherUtil +import org.ccci.gto.android.common.sync.SyncTaskRegistry import org.ccci.gto.android.common.sync.SyncTracker import org.ccci.gto.support.turbine.awaitItemMatching import org.cru.godtools.base.CONFIG_UI_DASHBOARD_PERSONALIZATION_ENABLED @@ -39,8 +40,6 @@ import org.cru.godtools.model.randomTool import org.cru.godtools.sync.GodToolsSyncService import org.cru.godtools.ui.banner.FakeBannerPresenter import org.cru.godtools.ui.banner.favoritetools.FavoriteToolsBannerPresenter -import org.cru.godtools.ui.dashboard.SyncTaskRegistry -import org.cru.godtools.ui.dashboard.SyncTaskRegistry.Companion.syncTaskRegistry import org.cru.godtools.ui.dashboard.filters.FilterMenu import org.cru.godtools.ui.dashboard.tools.ToolsPresenter.UiEvent import org.cru.godtools.ui.dashboard.tools.ToolsPresenter.UiState @@ -51,24 +50,23 @@ import org.cru.godtools.ui.tools.ToolCardPresenter import org.junit.runner.RunWith import org.robolectric.annotation.Config -@Suppress("UnusedFlow") @RunWith(AndroidJUnit4::class) @Config(application = Application::class) @OptIn(ExperimentalCoroutinesApi::class) class ToolsPresenterTest { private var isPersonalizationEnabled = false + private val appLocaleFlow = MutableStateFlow(Locale.ENGLISH) private val countryFlow = MutableStateFlow("US") private val featuredToolsFlow = MutableStateFlow(emptyList()) private val filteredToolsFlow = MutableStateFlow(emptyList()) private val toolOrderSync = Channel() private val testScope = TestScope() + private val syncTaskRegistry = SyncTaskRegistry(SyncTracker(testScope.backgroundScope)) @OptIn(InternalCircuitApi::class) - private val circuitContext = CircuitContext(null).apply { - syncTaskRegistry = SyncTaskRegistry(SyncTracker(testScope.backgroundScope)) - } + private val circuitContext = CircuitContext(null).apply { putTag(syncTaskRegistry) } private val settings: Settings = mockk { - every { appLanguage } returns Locale.ENGLISH + every { appLanguageFlow } returns appLocaleFlow every { getCountrySettingFlow() } returns countryFlow } private val syncService: GodToolsSyncService = mockk { @@ -313,7 +311,7 @@ class ToolsPresenterTest { toolOrderSync.send(true) coVerify { syncService.syncToolOrder(Locale.ENGLISH, "US", false) } - circuitContext.syncTaskRegistry!!.triggerSyncTasks(force = true) + syncTaskRegistry.triggerSyncTasks(force = true) toolOrderSync.send(true) coVerify { syncService.syncToolOrder(Locale.ENGLISH, "US", true) } }