From c0e045ffec35fa80fa3683d6eaa27b23018bdd38 Mon Sep 17 00:00:00 2001 From: jvsena42 Date: Wed, 24 Dec 2025 07:30:38 -0300 Subject: [PATCH 1/6] refactor: move timed sheet logic to specific classes --- .../java/to/bitkit/di/TimedSheetModule.kt | 18 ++++ .../utils/timedsheets/TimedSheetItem.kt | 12 +++ .../utils/timedsheets/TimedSheetManager.kt | 83 +++++++++++++++++++ .../utils/timedsheets/TimedSheetUtils.kt | 21 +++++ .../timedsheets/sheets/AppUpdateTimedSheet.kt | 49 +++++++++++ .../timedsheets/sheets/BackupTimedSheet.kt | 51 ++++++++++++ .../sheets/HighBalanceTimedSheet.kt | 72 ++++++++++++++++ .../sheets/NotificationsTimedSheet.kt | 49 +++++++++++ .../timedsheets/sheets/QuickPayTimedSheet.kt | 40 +++++++++ 9 files changed, 395 insertions(+) create mode 100644 app/src/main/java/to/bitkit/di/TimedSheetModule.kt create mode 100644 app/src/main/java/to/bitkit/utils/timedsheets/TimedSheetItem.kt create mode 100644 app/src/main/java/to/bitkit/utils/timedsheets/TimedSheetManager.kt create mode 100644 app/src/main/java/to/bitkit/utils/timedsheets/TimedSheetUtils.kt create mode 100644 app/src/main/java/to/bitkit/utils/timedsheets/sheets/AppUpdateTimedSheet.kt create mode 100644 app/src/main/java/to/bitkit/utils/timedsheets/sheets/BackupTimedSheet.kt create mode 100644 app/src/main/java/to/bitkit/utils/timedsheets/sheets/HighBalanceTimedSheet.kt create mode 100644 app/src/main/java/to/bitkit/utils/timedsheets/sheets/NotificationsTimedSheet.kt create mode 100644 app/src/main/java/to/bitkit/utils/timedsheets/sheets/QuickPayTimedSheet.kt diff --git a/app/src/main/java/to/bitkit/di/TimedSheetModule.kt b/app/src/main/java/to/bitkit/di/TimedSheetModule.kt new file mode 100644 index 000000000..1f218c76e --- /dev/null +++ b/app/src/main/java/to/bitkit/di/TimedSheetModule.kt @@ -0,0 +1,18 @@ +package to.bitkit.di + +import dagger.Module +import dagger.Provides +import dagger.hilt.InstallIn +import dagger.hilt.components.SingletonComponent +import kotlinx.coroutines.CoroutineScope +import to.bitkit.utils.timedsheets.TimedSheetManager + +@Module +@InstallIn(SingletonComponent::class) +object TimedSheetModule { + + @Provides + fun provideTimedSheetManagerProvider(): (CoroutineScope) -> TimedSheetManager { + return ::TimedSheetManager + } +} diff --git a/app/src/main/java/to/bitkit/utils/timedsheets/TimedSheetItem.kt b/app/src/main/java/to/bitkit/utils/timedsheets/TimedSheetItem.kt new file mode 100644 index 000000000..5961a8704 --- /dev/null +++ b/app/src/main/java/to/bitkit/utils/timedsheets/TimedSheetItem.kt @@ -0,0 +1,12 @@ +package to.bitkit.utils.timedsheets + +import to.bitkit.ui.components.TimedSheetType + +interface TimedSheetItem { + val type: TimedSheetType + val priority: Int + + suspend fun shouldShow(): Boolean + suspend fun onShown() + suspend fun onDismissed() +} diff --git a/app/src/main/java/to/bitkit/utils/timedsheets/TimedSheetManager.kt b/app/src/main/java/to/bitkit/utils/timedsheets/TimedSheetManager.kt new file mode 100644 index 000000000..f2f7dc07c --- /dev/null +++ b/app/src/main/java/to/bitkit/utils/timedsheets/TimedSheetManager.kt @@ -0,0 +1,83 @@ +package to.bitkit.utils.timedsheets + +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Job +import kotlinx.coroutines.delay +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.launch +import to.bitkit.ui.components.TimedSheetType +import to.bitkit.utils.Logger + +class TimedSheetManager(private val scope: CoroutineScope) { + private val _currentSheet = MutableStateFlow(null) + val currentSheet: StateFlow = _currentSheet.asStateFlow() + + private val registeredSheets = mutableListOf() + private var currentTimedSheet: TimedSheetItem? = null + private var checkJob: Job? = null + + fun registerSheet(sheet: TimedSheetItem) { + registeredSheets.add(sheet) + registeredSheets.sortByDescending { it.priority } + Logger.debug( + "Registered timed sheet: ${sheet.type.name} with priority: ${sheet.priority}", + context = TAG + ) + } + + fun onHomeScreenEntered() { + Logger.debug("User entered home screen, starting timer", context = TAG) + checkJob?.cancel() + checkJob = scope.launch { + delay(CHECK_DELAY_MILLIS) + checkAndShowNextSheet() + } + } + + fun onHomeScreenExited() { + Logger.debug("User exited home screen, cancelling timer", context = TAG) + checkJob?.cancel() + checkJob = null + } + + fun dismissCurrentSheet(skipQueue: Boolean = false) { + scope.launch { + currentTimedSheet?.onDismissed() + + if (skipQueue) { + Logger.debug("Clearing timed sheet queue", context = TAG) + _currentSheet.value = null + currentTimedSheet = null + } else { + checkAndShowNextSheet() + } + } + } + + private suspend fun checkAndShowNextSheet() { + for (sheet in registeredSheets.toList()) { + if (sheet.shouldShow()) { + Logger.debug( + "Showing timed sheet: ${sheet.type.name} with priority: ${sheet.priority}", + context = TAG + ) + currentTimedSheet = sheet + _currentSheet.value = sheet.type + sheet.onShown() + registeredSheets.remove(sheet) + return + } + } + + Logger.debug("No timed sheets need to be shown", context = TAG) + _currentSheet.value = null + currentTimedSheet = null + } + + companion object { + private const val TAG = "TimedSheetManager" + private const val CHECK_DELAY_MILLIS = 2000L + } +} diff --git a/app/src/main/java/to/bitkit/utils/timedsheets/TimedSheetUtils.kt b/app/src/main/java/to/bitkit/utils/timedsheets/TimedSheetUtils.kt new file mode 100644 index 000000000..3732b8413 --- /dev/null +++ b/app/src/main/java/to/bitkit/utils/timedsheets/TimedSheetUtils.kt @@ -0,0 +1,21 @@ +package to.bitkit.utils.timedsheets + +import kotlin.time.Clock +import kotlin.time.ExperimentalTime + +@OptIn(ExperimentalTime::class) +fun checkTimeout( + lastIgnoredMillis: Long, + intervalMillis: Long, + additionalCondition: Boolean = true, +): Boolean { + if (!additionalCondition) return false + + val currentTime = Clock.System.now().toEpochMilliseconds() + val isTimeOutOver = lastIgnoredMillis == 0L || + (currentTime - lastIgnoredMillis > intervalMillis) + return isTimeOutOver +} + +const val ONE_DAY_ASK_INTERVAL_MILLIS = 1000 * 60 * 60 * 24L +const val ONE_WEEK_ASK_INTERVAL_MILLIS = ONE_DAY_ASK_INTERVAL_MILLIS * 7L diff --git a/app/src/main/java/to/bitkit/utils/timedsheets/sheets/AppUpdateTimedSheet.kt b/app/src/main/java/to/bitkit/utils/timedsheets/sheets/AppUpdateTimedSheet.kt new file mode 100644 index 000000000..919dbcb50 --- /dev/null +++ b/app/src/main/java/to/bitkit/utils/timedsheets/sheets/AppUpdateTimedSheet.kt @@ -0,0 +1,49 @@ +package to.bitkit.utils.timedsheets.sheets + +import kotlinx.coroutines.CoroutineDispatcher +import kotlinx.coroutines.withContext +import to.bitkit.BuildConfig +import to.bitkit.di.BgDispatcher +import to.bitkit.services.AppUpdaterService +import to.bitkit.ui.components.TimedSheetType +import to.bitkit.utils.Logger +import to.bitkit.utils.timedsheets.TimedSheetItem +import javax.inject.Inject + +class AppUpdateTimedSheet @Inject constructor( + private val appUpdaterService: AppUpdaterService, + @BgDispatcher private val bgDispatcher: CoroutineDispatcher, +) : TimedSheetItem { + override val type = TimedSheetType.APP_UPDATE + override val priority = 5 + + override suspend fun shouldShow(): Boolean = withContext(bgDispatcher) { + try { + val androidReleaseInfo = appUpdaterService.getReleaseInfo().platforms.android + val currentBuildNumber = BuildConfig.VERSION_CODE + + if (androidReleaseInfo.buildNumber <= currentBuildNumber) return@withContext false + + if (androidReleaseInfo.isCritical) { + return@withContext false + } + + return@withContext true + } catch (e: Exception) { + Logger.warn("Failure fetching new releases", e = e, context = TAG) + return@withContext false + } + } + + override suspend fun onShown() { + Logger.debug("App update sheet shown", context = TAG) + } + + override suspend fun onDismissed() { + Logger.debug("App update sheet dismissed", context = TAG) + } + + companion object { + private const val TAG = "AppUpdateTimedSheet" + } +} diff --git a/app/src/main/java/to/bitkit/utils/timedsheets/sheets/BackupTimedSheet.kt b/app/src/main/java/to/bitkit/utils/timedsheets/sheets/BackupTimedSheet.kt new file mode 100644 index 000000000..1b9a7b6b7 --- /dev/null +++ b/app/src/main/java/to/bitkit/utils/timedsheets/sheets/BackupTimedSheet.kt @@ -0,0 +1,51 @@ +package to.bitkit.utils.timedsheets.sheets + +import kotlinx.coroutines.flow.first +import to.bitkit.data.SettingsStore +import to.bitkit.ext.nowMillis +import to.bitkit.repositories.WalletRepo +import to.bitkit.ui.components.TimedSheetType +import to.bitkit.utils.Logger +import to.bitkit.utils.timedsheets.ONE_DAY_ASK_INTERVAL_MILLIS +import to.bitkit.utils.timedsheets.TimedSheetItem +import to.bitkit.utils.timedsheets.checkTimeout +import javax.inject.Inject +import kotlin.time.ExperimentalTime + +class BackupTimedSheet @Inject constructor( + private val settingsStore: SettingsStore, + private val walletRepo: WalletRepo, +) : TimedSheetItem { + override val type = TimedSheetType.BACKUP + override val priority = 4 + + override suspend fun shouldShow(): Boolean { + val settings = settingsStore.data.first() + if (settings.backupVerified) return false + + val hasBalance = walletRepo.balanceState.value.totalSats > 0U + if (!hasBalance) return false + + return checkTimeout( + lastIgnoredMillis = settings.backupWarningIgnoredMillis, + intervalMillis = ONE_DAY_ASK_INTERVAL_MILLIS + ) + } + + override suspend fun onShown() { + Logger.debug("Backup sheet shown", context = TAG) + } + + @OptIn(ExperimentalTime::class) + override suspend fun onDismissed() { + val currentTime = nowMillis() + settingsStore.update { + it.copy(backupWarningIgnoredMillis = currentTime) + } + Logger.debug("Backup sheet dismissed", context = TAG) + } + + companion object { + private const val TAG = "BackupTimedSheet" + } +} diff --git a/app/src/main/java/to/bitkit/utils/timedsheets/sheets/HighBalanceTimedSheet.kt b/app/src/main/java/to/bitkit/utils/timedsheets/sheets/HighBalanceTimedSheet.kt new file mode 100644 index 000000000..9f3020c8a --- /dev/null +++ b/app/src/main/java/to/bitkit/utils/timedsheets/sheets/HighBalanceTimedSheet.kt @@ -0,0 +1,72 @@ +package to.bitkit.utils.timedsheets.sheets + +import kotlinx.coroutines.flow.first +import to.bitkit.data.SettingsStore +import to.bitkit.ext.nowMillis +import to.bitkit.repositories.CurrencyRepo +import to.bitkit.repositories.WalletRepo +import to.bitkit.ui.components.TimedSheetType +import to.bitkit.utils.Logger +import to.bitkit.utils.timedsheets.ONE_DAY_ASK_INTERVAL_MILLIS +import to.bitkit.utils.timedsheets.TimedSheetItem +import to.bitkit.utils.timedsheets.checkTimeout +import java.math.BigDecimal +import javax.inject.Inject +import kotlin.time.ExperimentalTime + +class HighBalanceTimedSheet @Inject constructor( + private val settingsStore: SettingsStore, + private val walletRepo: WalletRepo, + private val currencyRepo: CurrencyRepo, +) : TimedSheetItem { + override val type = TimedSheetType.HIGH_BALANCE + override val priority = 1 + + override suspend fun shouldShow(): Boolean { + val settings = settingsStore.data.first() + + val totalOnChainSats = walletRepo.balanceState.value.totalSats + val balanceUsd = satsToUsd(totalOnChainSats) ?: return false + val thresholdReached = balanceUsd > BigDecimal(BALANCE_THRESHOLD_USD) + + if (!thresholdReached) { + settingsStore.update { it.copy(balanceWarningTimes = 0) } + return false + } + + val belowMaxWarnings = settings.balanceWarningTimes < MAX_WARNINGS + + return checkTimeout( + lastIgnoredMillis = settings.balanceWarningIgnoredMillis, + intervalMillis = ONE_DAY_ASK_INTERVAL_MILLIS, + additionalCondition = belowMaxWarnings + ) + } + + override suspend fun onShown() { + Logger.debug("High balance sheet shown", context = TAG) + } + + @OptIn(ExperimentalTime::class) + override suspend fun onDismissed() { + val currentTime = nowMillis() + settingsStore.update { + it.copy( + balanceWarningTimes = it.balanceWarningTimes + 1, + balanceWarningIgnoredMillis = currentTime, + ) + } + Logger.debug("High balance sheet dismissed", context = TAG) + } + + private fun satsToUsd(sats: ULong): BigDecimal? { + val converted = currencyRepo.convertSatsToFiat(sats = sats.toLong(), currency = "USD").getOrNull() + return converted?.value + } + + companion object { + private const val TAG = "HighBalanceTimedSheet" + private const val BALANCE_THRESHOLD_USD = 500L + private const val MAX_WARNINGS = 3 + } +} diff --git a/app/src/main/java/to/bitkit/utils/timedsheets/sheets/NotificationsTimedSheet.kt b/app/src/main/java/to/bitkit/utils/timedsheets/sheets/NotificationsTimedSheet.kt new file mode 100644 index 000000000..068dcf556 --- /dev/null +++ b/app/src/main/java/to/bitkit/utils/timedsheets/sheets/NotificationsTimedSheet.kt @@ -0,0 +1,49 @@ +package to.bitkit.utils.timedsheets.sheets + +import kotlinx.coroutines.flow.first +import to.bitkit.data.SettingsStore +import to.bitkit.ext.nowMillis +import to.bitkit.repositories.WalletRepo +import to.bitkit.ui.components.TimedSheetType +import to.bitkit.utils.Logger +import to.bitkit.utils.timedsheets.ONE_WEEK_ASK_INTERVAL_MILLIS +import to.bitkit.utils.timedsheets.TimedSheetItem +import to.bitkit.utils.timedsheets.checkTimeout +import javax.inject.Inject +import kotlin.time.ExperimentalTime + +class NotificationsTimedSheet @Inject constructor( + private val settingsStore: SettingsStore, + private val walletRepo: WalletRepo, +) : TimedSheetItem { + override val type = TimedSheetType.NOTIFICATIONS + override val priority = 3 + + override suspend fun shouldShow(): Boolean { + val settings = settingsStore.data.first() + if (settings.notificationsGranted) return false + if (walletRepo.balanceState.value.totalLightningSats == 0UL) return false + + return checkTimeout( + lastIgnoredMillis = settings.notificationsIgnoredMillis, + intervalMillis = ONE_WEEK_ASK_INTERVAL_MILLIS + ) + } + + override suspend fun onShown() { + Logger.debug("Notifications sheet shown", context = TAG) + } + + @OptIn(ExperimentalTime::class) + override suspend fun onDismissed() { + val currentTime = nowMillis() + settingsStore.update { + it.copy(notificationsIgnoredMillis = currentTime) + } + Logger.debug("Notifications sheet dismissed", context = TAG) + } + + companion object { + private const val TAG = "NotificationsTimedSheet" + } +} diff --git a/app/src/main/java/to/bitkit/utils/timedsheets/sheets/QuickPayTimedSheet.kt b/app/src/main/java/to/bitkit/utils/timedsheets/sheets/QuickPayTimedSheet.kt new file mode 100644 index 000000000..0e87a7aa2 --- /dev/null +++ b/app/src/main/java/to/bitkit/utils/timedsheets/sheets/QuickPayTimedSheet.kt @@ -0,0 +1,40 @@ +package to.bitkit.utils.timedsheets.sheets + +import kotlinx.coroutines.flow.first +import to.bitkit.data.SettingsStore +import to.bitkit.repositories.WalletRepo +import to.bitkit.ui.components.TimedSheetType +import to.bitkit.utils.Logger +import to.bitkit.utils.timedsheets.TimedSheetItem +import javax.inject.Inject + +class QuickPayTimedSheet @Inject constructor( + private val settingsStore: SettingsStore, + private val walletRepo: WalletRepo, +) : TimedSheetItem { + override val type = TimedSheetType.QUICK_PAY + override val priority = 2 + + override suspend fun shouldShow(): Boolean { + val settings = settingsStore.data.first() + if (settings.quickPayIntroSeen || settings.isQuickPayEnabled) return false + + val hasLightningBalance = walletRepo.balanceState.value.totalLightningSats > 0U + return hasLightningBalance + } + + override suspend fun onShown() { + Logger.debug("QuickPay sheet shown", context = TAG) + } + + override suspend fun onDismissed() { + settingsStore.update { + it.copy(quickPayIntroSeen = true) + } + Logger.debug("QuickPay sheet dismissed", context = TAG) + } + + companion object { + private const val TAG = "QuickPayTimedSheet" + } +} From ec46cfbb2898eddeff7a372c34c9b446a143bf20 Mon Sep 17 00:00:00 2001 From: jvsena42 Date: Wed, 24 Dec 2025 08:06:03 -0300 Subject: [PATCH 2/6] test: timed sheet tests --- .../timedsheets/TimedSheetManagerTest.kt | 179 ++++++++++++ .../sheets/AppUpdateTimedSheetTest.kt | 115 ++++++++ .../sheets/BackupTimedSheetTest.kt | 123 ++++++++ .../sheets/HighBalanceTimedSheetTest.kt | 265 ++++++++++++++++++ .../sheets/NotificationsTimedSheetTest.kt | 123 ++++++++ .../sheets/QuickPayTimedSheetTest.kt | 115 ++++++++ 6 files changed, 920 insertions(+) create mode 100644 app/src/test/java/to/bitkit/utils/timedsheets/TimedSheetManagerTest.kt create mode 100644 app/src/test/java/to/bitkit/utils/timedsheets/sheets/AppUpdateTimedSheetTest.kt create mode 100644 app/src/test/java/to/bitkit/utils/timedsheets/sheets/BackupTimedSheetTest.kt create mode 100644 app/src/test/java/to/bitkit/utils/timedsheets/sheets/HighBalanceTimedSheetTest.kt create mode 100644 app/src/test/java/to/bitkit/utils/timedsheets/sheets/NotificationsTimedSheetTest.kt create mode 100644 app/src/test/java/to/bitkit/utils/timedsheets/sheets/QuickPayTimedSheetTest.kt diff --git a/app/src/test/java/to/bitkit/utils/timedsheets/TimedSheetManagerTest.kt b/app/src/test/java/to/bitkit/utils/timedsheets/TimedSheetManagerTest.kt new file mode 100644 index 000000000..a56d98c4e --- /dev/null +++ b/app/src/test/java/to/bitkit/utils/timedsheets/TimedSheetManagerTest.kt @@ -0,0 +1,179 @@ +package to.bitkit.utils.timedsheets + +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.test.TestScope +import kotlinx.coroutines.test.advanceTimeBy +import kotlinx.coroutines.test.runTest +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNull +import org.junit.Before +import org.junit.Test +import org.mockito.kotlin.doReturn +import org.mockito.kotlin.mock +import org.mockito.kotlin.verify +import org.mockito.kotlin.whenever +import to.bitkit.test.BaseUnitTest +import to.bitkit.ui.components.TimedSheetType + +@OptIn(ExperimentalCoroutinesApi::class) +class TimedSheetManagerTest : BaseUnitTest() { + private val testScope = TestScope() + private lateinit var sut: TimedSheetManager + + @Before + fun setUp() { + sut = TimedSheetManager(testScope) + } + + @Test + fun `shows highest priority eligible sheet first`() = test { + val highPrioritySheet = mock { + on { type } doReturn TimedSheetType.APP_UPDATE + on { priority } doReturn 5 + } + val lowPrioritySheet = mock { + on { type } doReturn TimedSheetType.HIGH_BALANCE + on { priority } doReturn 1 + } + + whenever(highPrioritySheet.shouldShow()).thenReturn(true) + whenever(lowPrioritySheet.shouldShow()).thenReturn(true) + + sut.registerSheet(lowPrioritySheet) + sut.registerSheet(highPrioritySheet) + + sut.onHomeScreenEntered() + testScope.advanceTimeBy(2100) + + assertEquals(TimedSheetType.APP_UPDATE, sut.currentSheet.value) + verify(highPrioritySheet).onShown() + } + + @Test + fun `does not show sheets before 2s delay`() = test { + val sheet = mock { + on { type } doReturn TimedSheetType.BACKUP + on { priority } doReturn 4 + } + whenever(sheet.shouldShow()).thenReturn(true) + + sut.registerSheet(sheet) + sut.onHomeScreenEntered() + + testScope.advanceTimeBy(1000) + assertNull(sut.currentSheet.value) + + testScope.advanceTimeBy(1100) + assertEquals(TimedSheetType.BACKUP, sut.currentSheet.value) + } + + @Test + fun `cancels check when leaving home screen`() = test { + val sheet = mock { + on { type } doReturn TimedSheetType.BACKUP + on { priority } doReturn 4 + } + whenever(sheet.shouldShow()).thenReturn(true) + + sut.registerSheet(sheet) + sut.onHomeScreenEntered() + testScope.advanceTimeBy(1000) + sut.onHomeScreenExited() + + testScope.advanceTimeBy(2000) + assertNull(sut.currentSheet.value) + } + + @Test + fun `clears queue when skipQueue is true`() = test { + val sheet1 = mock { + on { type } doReturn TimedSheetType.APP_UPDATE + on { priority } doReturn 5 + } + val sheet2 = mock { + on { type } doReturn TimedSheetType.BACKUP + on { priority } doReturn 4 + } + whenever(sheet1.shouldShow()).thenReturn(true) + whenever(sheet2.shouldShow()).thenReturn(true) + + sut.registerSheet(sheet1) + sut.registerSheet(sheet2) + sut.onHomeScreenEntered() + testScope.advanceTimeBy(2100) + + assertEquals(TimedSheetType.APP_UPDATE, sut.currentSheet.value) + + sut.dismissCurrentSheet(skipQueue = true) + testScope.advanceTimeBy(100) + + assertNull(sut.currentSheet.value) + verify(sheet1).onDismissed() + } + + @Test + fun `does not show sheets when none eligible`() = test { + val sheet = mock { + on { type } doReturn TimedSheetType.BACKUP + on { priority } doReturn 4 + } + whenever(sheet.shouldShow()).thenReturn(false) + + sut.registerSheet(sheet) + sut.onHomeScreenEntered() + testScope.advanceTimeBy(2100) + + assertNull(sut.currentSheet.value) + } + + @Test + fun `removes sheet from queue after showing`() = test { + val sheet = mock { + on { type } doReturn TimedSheetType.BACKUP + on { priority } doReturn 4 + } + whenever(sheet.shouldShow()).thenReturn(true) + + sut.registerSheet(sheet) + sut.onHomeScreenEntered() + testScope.advanceTimeBy(2100) + + assertEquals(TimedSheetType.BACKUP, sut.currentSheet.value) + + sut.dismissCurrentSheet(skipQueue = false) + testScope.advanceTimeBy(100) + + assertNull(sut.currentSheet.value) + verify(sheet).onShown() + verify(sheet).onDismissed() + } + + @Test + fun `registers sheets sorted by priority`() = test { + val sheet1 = mock { + on { type } doReturn TimedSheetType.HIGH_BALANCE + on { priority } doReturn 1 + } + val sheet2 = mock { + on { type } doReturn TimedSheetType.BACKUP + on { priority } doReturn 4 + } + val sheet3 = mock { + on { type } doReturn TimedSheetType.APP_UPDATE + on { priority } doReturn 5 + } + + whenever(sheet1.shouldShow()).thenReturn(true) + whenever(sheet2.shouldShow()).thenReturn(true) + whenever(sheet3.shouldShow()).thenReturn(true) + + sut.registerSheet(sheet1) + sut.registerSheet(sheet2) + sut.registerSheet(sheet3) + + sut.onHomeScreenEntered() + testScope.advanceTimeBy(2100) + + assertEquals(TimedSheetType.APP_UPDATE, sut.currentSheet.value) + } +} diff --git a/app/src/test/java/to/bitkit/utils/timedsheets/sheets/AppUpdateTimedSheetTest.kt b/app/src/test/java/to/bitkit/utils/timedsheets/sheets/AppUpdateTimedSheetTest.kt new file mode 100644 index 000000000..dc245821d --- /dev/null +++ b/app/src/test/java/to/bitkit/utils/timedsheets/sheets/AppUpdateTimedSheetTest.kt @@ -0,0 +1,115 @@ +package to.bitkit.utils.timedsheets.sheets + +import kotlinx.coroutines.CoroutineDispatcher +import kotlinx.coroutines.test.StandardTestDispatcher +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Before +import org.junit.Test +import org.mockito.kotlin.mock +import org.mockito.kotlin.whenever +import to.bitkit.test.BaseUnitTest +import to.bitkit.BuildConfig +import to.bitkit.data.dto.PlatformDetails +import to.bitkit.data.dto.Platforms +import to.bitkit.data.dto.ReleaseInfoDTO +import to.bitkit.services.AppUpdaterService +import to.bitkit.ui.components.TimedSheetType + +class AppUpdateTimedSheetTest : BaseUnitTest() { + private lateinit var appUpdaterService: AppUpdaterService + private lateinit var bgDispatcher: CoroutineDispatcher + private lateinit var sut: AppUpdateTimedSheet + + @Before + fun setUp() { + appUpdaterService = mock() + bgDispatcher = StandardTestDispatcher() + sut = AppUpdateTimedSheet(appUpdaterService, bgDispatcher) + } + + @Test + fun `type is APP_UPDATE`() { + assertTrue(sut.type == TimedSheetType.APP_UPDATE) + } + + @Test + fun `priority is 5`() { + assertTrue(sut.priority == 5) + } + + @Test + fun `should not show when build number is same or lower`() = test { + val releaseInfo = ReleaseInfoDTO( + platforms = Platforms( + android = PlatformDetails( + version = "1.0.0", + buildNumber = BuildConfig.VERSION_CODE, + notes = "Test release", + pubDate = "2024-01-01", + url = "https://example.com", + isCritical = false + ), + ios = null + ) + ) + whenever(appUpdaterService.getReleaseInfo()).thenReturn(releaseInfo) + + val result = sut.shouldShow() + + assertFalse(result) + } + + @Test + fun `should not show when update is critical`() = test { + val releaseInfo = ReleaseInfoDTO( + platforms = Platforms( + android = PlatformDetails( + version = "1.0.0", + buildNumber = BuildConfig.VERSION_CODE + 1, + notes = "Test release", + pubDate = "2024-01-01", + url = "https://example.com", + isCritical = true + ), + ios = null + ) + ) + whenever(appUpdaterService.getReleaseInfo()).thenReturn(releaseInfo) + + val result = sut.shouldShow() + + assertFalse(result) + } + + @Test + fun `should show when non-critical update available`() = test { + val releaseInfo = ReleaseInfoDTO( + platforms = Platforms( + android = PlatformDetails( + version = "1.0.0", + buildNumber = BuildConfig.VERSION_CODE + 1, + notes = "Test release", + pubDate = "2024-01-01", + url = "https://example.com", + isCritical = false + ), + ios = null + ) + ) + whenever(appUpdaterService.getReleaseInfo()).thenReturn(releaseInfo) + + val result = sut.shouldShow() + + assertTrue(result) + } + + @Test + fun `should not show when network error occurs`() = test { + whenever(appUpdaterService.getReleaseInfo()).thenThrow(RuntimeException("Network error")) + + val result = sut.shouldShow() + + assertFalse(result) + } +} diff --git a/app/src/test/java/to/bitkit/utils/timedsheets/sheets/BackupTimedSheetTest.kt b/app/src/test/java/to/bitkit/utils/timedsheets/sheets/BackupTimedSheetTest.kt new file mode 100644 index 000000000..f3acf6a1f --- /dev/null +++ b/app/src/test/java/to/bitkit/utils/timedsheets/sheets/BackupTimedSheetTest.kt @@ -0,0 +1,123 @@ +package to.bitkit.utils.timedsheets.sheets + +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.flowOf +import kotlinx.coroutines.runBlocking +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Before +import org.junit.Test +import org.mockito.kotlin.any +import org.mockito.kotlin.mock +import org.mockito.kotlin.verify +import org.mockito.kotlin.whenever +import to.bitkit.test.BaseUnitTest +import to.bitkit.data.SettingsData +import to.bitkit.data.SettingsStore +import to.bitkit.models.BalanceState +import to.bitkit.repositories.WalletRepo +import to.bitkit.ui.components.TimedSheetType + +class BackupTimedSheetTest : BaseUnitTest() { + private lateinit var settingsStore: SettingsStore + private lateinit var walletRepo: WalletRepo + private lateinit var sut: BackupTimedSheet + + private val defaultSettings = SettingsData() + private val settingsFlow = MutableStateFlow(defaultSettings) + + @Before + fun setUp() = runBlocking { + settingsStore = mock() + walletRepo = mock() + + whenever(settingsStore.data).thenReturn(settingsFlow) + whenever(settingsStore.update(any())).then { + val transform = it.getArgument<(SettingsData) -> SettingsData>(0) + settingsFlow.value = transform(settingsFlow.value) + flowOf(settingsFlow.value) + } + + whenever(walletRepo.balanceState).thenReturn( + MutableStateFlow(BalanceState(totalOnchainSats = 1000U)) + ) + + sut = BackupTimedSheet(settingsStore, walletRepo) + } + + @Test + fun `type is BACKUP`() { + assertTrue(sut.type == TimedSheetType.BACKUP) + } + + @Test + fun `priority is 4`() { + assertTrue(sut.priority == 4) + } + + @Test + fun `should not show when backup verified`() = test { + settingsFlow.value = defaultSettings.copy(backupVerified = true) + + val result = sut.shouldShow() + + assertFalse(result) + } + + @Test + fun `should not show when no balance`() = test { + whenever(walletRepo.balanceState).thenReturn( + MutableStateFlow(BalanceState(totalOnchainSats = 0U)) + ) + + val result = sut.shouldShow() + + assertFalse(result) + } + + @Test + fun `should not show within timeout period`() = test { + val currentTime = System.currentTimeMillis() + settingsFlow.value = defaultSettings.copy( + backupVerified = false, + backupWarningIgnoredMillis = currentTime - 1000 + ) + + val result = sut.shouldShow() + + assertFalse(result) + } + + @Test + fun `should show after timeout with balance and unverified backup`() = test { + val oldTime = System.currentTimeMillis() - (25 * 60 * 60 * 1000) + settingsFlow.value = defaultSettings.copy( + backupVerified = false, + backupWarningIgnoredMillis = oldTime + ) + + val result = sut.shouldShow() + + assertTrue(result) + } + + @Test + fun `should show when never dismissed`() = test { + settingsFlow.value = defaultSettings.copy( + backupVerified = false, + backupWarningIgnoredMillis = 0L + ) + + val result = sut.shouldShow() + + assertTrue(result) + } + + @Test + fun `updates ignored timestamp on dismissed`() = test { + sut.onDismissed() + + verify(settingsStore).update(any()) + assertTrue(settingsFlow.value.backupWarningIgnoredMillis > 0) + } +} diff --git a/app/src/test/java/to/bitkit/utils/timedsheets/sheets/HighBalanceTimedSheetTest.kt b/app/src/test/java/to/bitkit/utils/timedsheets/sheets/HighBalanceTimedSheetTest.kt new file mode 100644 index 000000000..8f8bffd9d --- /dev/null +++ b/app/src/test/java/to/bitkit/utils/timedsheets/sheets/HighBalanceTimedSheetTest.kt @@ -0,0 +1,265 @@ +package to.bitkit.utils.timedsheets.sheets + +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.flowOf +import kotlinx.coroutines.runBlocking +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Before +import org.junit.Test +import org.mockito.kotlin.any +import org.mockito.kotlin.mock +import org.mockito.kotlin.verify +import org.mockito.kotlin.whenever +import to.bitkit.test.BaseUnitTest +import to.bitkit.data.SettingsData +import to.bitkit.data.SettingsStore +import to.bitkit.models.BalanceState +import to.bitkit.models.ConvertedAmount +import java.util.Locale +import to.bitkit.repositories.CurrencyRepo +import to.bitkit.repositories.WalletRepo +import to.bitkit.ui.components.TimedSheetType +import java.math.BigDecimal + +class HighBalanceTimedSheetTest : BaseUnitTest() { + private lateinit var settingsStore: SettingsStore + private lateinit var walletRepo: WalletRepo + private lateinit var currencyRepo: CurrencyRepo + private lateinit var sut: HighBalanceTimedSheet + + private val defaultSettings = SettingsData() + private val settingsFlow = MutableStateFlow(defaultSettings) + + @Before + fun setUp() = runBlocking { + settingsStore = mock() + walletRepo = mock() + currencyRepo = mock() + + whenever(settingsStore.data).thenReturn(settingsFlow) + whenever(settingsStore.update(any())).then { + val transform = it.getArgument<(SettingsData) -> SettingsData>(0) + settingsFlow.value = transform(settingsFlow.value) + flowOf(settingsFlow.value) + } + + sut = HighBalanceTimedSheet(settingsStore, walletRepo, currencyRepo) + } + + @Test + fun `type is HIGH_BALANCE`() { + assertTrue(sut.type == TimedSheetType.HIGH_BALANCE) + } + + @Test + fun `priority is 1`() { + assertTrue(sut.priority == 1) + } + + @Test + fun `should not show when balance below threshold`() = test { + whenever(walletRepo.balanceState).thenReturn( + MutableStateFlow(BalanceState(totalOnchainSats = 10000U)) + ) + whenever(currencyRepo.convertSatsToFiat(10000L, "USD")).thenReturn( + Result.success(ConvertedAmount( + value = BigDecimal("100"), + formatted = "100.00", + symbol = "$", + currency = "USD", + flag = "", + sats = 10000L, + locale = Locale.US + )) + ) + + val result = sut.shouldShow() + + assertFalse(result) + } + + @Test + fun `should not show when USD conversion fails`() = test { + whenever(walletRepo.balanceState).thenReturn( + MutableStateFlow(BalanceState(totalOnchainSats = 100000U)) + ) + whenever(currencyRepo.convertSatsToFiat(100000L, "USD")).thenReturn( + Result.failure(Exception("Network error")) + ) + + val result = sut.shouldShow() + + assertFalse(result) + } + + @Test + fun `should not show when max warnings reached`() = test { + whenever(walletRepo.balanceState).thenReturn( + MutableStateFlow(BalanceState(totalOnchainSats = 100000U)) + ) + whenever(currencyRepo.convertSatsToFiat(100000L, "USD")).thenReturn( + Result.success(ConvertedAmount( + value = BigDecimal("600"), + formatted = "600.00", + symbol = "$", + currency = "USD", + flag = "", + sats = 100000L, + locale = Locale.US + )) + ) + settingsFlow.value = defaultSettings.copy(balanceWarningTimes = 3) + + val result = sut.shouldShow() + + assertFalse(result) + } + + @Test + fun `should not show within timeout period`() = test { + val currentTime = System.currentTimeMillis() + whenever(walletRepo.balanceState).thenReturn( + MutableStateFlow(BalanceState(totalOnchainSats = 100000U)) + ) + whenever(currencyRepo.convertSatsToFiat(100000L, "USD")).thenReturn( + Result.success(ConvertedAmount( + value = BigDecimal("600"), + formatted = "600.00", + symbol = "$", + currency = "USD", + flag = "", + sats = 100000L, + locale = Locale.US + )) + ) + settingsFlow.value = defaultSettings.copy( + balanceWarningTimes = 0, + balanceWarningIgnoredMillis = currentTime - 1000 + ) + + val result = sut.shouldShow() + + assertFalse(result) + } + + @Test + fun `should show when balance over threshold and timeout passed`() = test { + val oldTime = System.currentTimeMillis() - (25 * 60 * 60 * 1000) + whenever(walletRepo.balanceState).thenReturn( + MutableStateFlow(BalanceState(totalOnchainSats = 100000U)) + ) + whenever(currencyRepo.convertSatsToFiat(100000L, "USD")).thenReturn( + Result.success(ConvertedAmount( + value = BigDecimal("600"), + formatted = "600.00", + symbol = "$", + currency = "USD", + flag = "", + sats = 100000L, + locale = Locale.US + )) + ) + settingsFlow.value = defaultSettings.copy( + balanceWarningTimes = 0, + balanceWarningIgnoredMillis = oldTime + ) + + val result = sut.shouldShow() + + assertTrue(result) + } + + @Test + fun `should show when never dismissed and balance over threshold`() = test { + whenever(walletRepo.balanceState).thenReturn( + MutableStateFlow(BalanceState(totalOnchainSats = 100000U)) + ) + whenever(currencyRepo.convertSatsToFiat(100000L, "USD")).thenReturn( + Result.success(ConvertedAmount( + value = BigDecimal("600"), + formatted = "600.00", + symbol = "$", + currency = "USD", + flag = "", + sats = 100000L, + locale = Locale.US + )) + ) + settingsFlow.value = defaultSettings.copy( + balanceWarningTimes = 0, + balanceWarningIgnoredMillis = 0L + ) + + val result = sut.shouldShow() + + assertTrue(result) + } + + @Test + fun `resets warning times when balance drops below threshold`() = test { + whenever(walletRepo.balanceState).thenReturn( + MutableStateFlow(BalanceState(totalOnchainSats = 10000U)) + ) + whenever(currencyRepo.convertSatsToFiat(10000L, "USD")).thenReturn( + Result.success(ConvertedAmount( + value = BigDecimal("100"), + formatted = "100.00", + symbol = "$", + currency = "USD", + flag = "", + sats = 10000L, + locale = Locale.US + )) + ) + settingsFlow.value = defaultSettings.copy(balanceWarningTimes = 2) + + sut.shouldShow() + + verify(settingsStore).update(any()) + assertEquals(0, settingsFlow.value.balanceWarningTimes) + } + + @Test + fun `increments warning times and updates timestamp on dismissed`() = test { + settingsFlow.value = defaultSettings.copy(balanceWarningTimes = 1) + + sut.onDismissed() + + verify(settingsStore).update(any()) + assertEquals(2, settingsFlow.value.balanceWarningTimes) + assertTrue(settingsFlow.value.balanceWarningIgnoredMillis > 0) + } + + @Test + fun `should show up to 3 times maximum`() = test { + val oldTime = System.currentTimeMillis() - (25 * 60 * 60 * 1000) + whenever(walletRepo.balanceState).thenReturn( + MutableStateFlow(BalanceState(totalOnchainSats = 100000U)) + ) + whenever(currencyRepo.convertSatsToFiat(100000L, "USD")).thenReturn( + Result.success(ConvertedAmount( + value = BigDecimal("600"), + formatted = "600.00", + symbol = "$", + currency = "USD", + flag = "", + sats = 100000L, + locale = Locale.US + )) + ) + + settingsFlow.value = defaultSettings.copy( + balanceWarningTimes = 2, + balanceWarningIgnoredMillis = oldTime + ) + assertTrue(sut.shouldShow()) + + settingsFlow.value = defaultSettings.copy( + balanceWarningTimes = 3, + balanceWarningIgnoredMillis = oldTime + ) + assertFalse(sut.shouldShow()) + } +} diff --git a/app/src/test/java/to/bitkit/utils/timedsheets/sheets/NotificationsTimedSheetTest.kt b/app/src/test/java/to/bitkit/utils/timedsheets/sheets/NotificationsTimedSheetTest.kt new file mode 100644 index 000000000..947aaa19f --- /dev/null +++ b/app/src/test/java/to/bitkit/utils/timedsheets/sheets/NotificationsTimedSheetTest.kt @@ -0,0 +1,123 @@ +package to.bitkit.utils.timedsheets.sheets + +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.flowOf +import kotlinx.coroutines.runBlocking +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Before +import org.junit.Test +import org.mockito.kotlin.any +import org.mockito.kotlin.mock +import org.mockito.kotlin.verify +import org.mockito.kotlin.whenever +import to.bitkit.test.BaseUnitTest +import to.bitkit.data.SettingsData +import to.bitkit.data.SettingsStore +import to.bitkit.models.BalanceState +import to.bitkit.repositories.WalletRepo +import to.bitkit.ui.components.TimedSheetType + +class NotificationsTimedSheetTest : BaseUnitTest() { + private lateinit var settingsStore: SettingsStore + private lateinit var walletRepo: WalletRepo + private lateinit var sut: NotificationsTimedSheet + + private val defaultSettings = SettingsData() + private val settingsFlow = MutableStateFlow(defaultSettings) + + @Before + fun setUp() = runBlocking { + settingsStore = mock() + walletRepo = mock() + + whenever(settingsStore.data).thenReturn(settingsFlow) + whenever(settingsStore.update(any())).then { + val transform = it.getArgument<(SettingsData) -> SettingsData>(0) + settingsFlow.value = transform(settingsFlow.value) + flowOf(settingsFlow.value) + } + + whenever(walletRepo.balanceState).thenReturn( + MutableStateFlow(BalanceState(totalLightningSats = 1000UL)) + ) + + sut = NotificationsTimedSheet(settingsStore, walletRepo) + } + + @Test + fun `type is NOTIFICATIONS`() { + assertTrue(sut.type == TimedSheetType.NOTIFICATIONS) + } + + @Test + fun `priority is 3`() { + assertTrue(sut.priority == 3) + } + + @Test + fun `should not show when notifications granted`() = test { + settingsFlow.value = defaultSettings.copy(notificationsGranted = true) + + val result = sut.shouldShow() + + assertFalse(result) + } + + @Test + fun `should not show when no lightning balance`() = test { + whenever(walletRepo.balanceState).thenReturn( + MutableStateFlow(BalanceState(totalLightningSats = 0UL)) + ) + + val result = sut.shouldShow() + + assertFalse(result) + } + + @Test + fun `should not show within one week timeout`() = test { + val sixDaysAgo = System.currentTimeMillis() - (6L * 24 * 60 * 60 * 1000) + settingsFlow.value = defaultSettings.copy( + notificationsGranted = false, + notificationsIgnoredMillis = sixDaysAgo + ) + + val result = sut.shouldShow() + + assertFalse(result) + } + + @Test + fun `should show after one week timeout with lightning balance`() = test { + val eightDaysAgo = System.currentTimeMillis() - (8L * 24 * 60 * 60 * 1000) + settingsFlow.value = defaultSettings.copy( + notificationsGranted = false, + notificationsIgnoredMillis = eightDaysAgo + ) + + val result = sut.shouldShow() + + assertTrue(result) + } + + @Test + fun `should show when never dismissed`() = test { + settingsFlow.value = defaultSettings.copy( + notificationsGranted = false, + notificationsIgnoredMillis = 0L + ) + + val result = sut.shouldShow() + + assertTrue(result) + } + + @Test + fun `updates ignored timestamp on dismissed`() = test { + sut.onDismissed() + + verify(settingsStore).update(any()) + assertTrue(settingsFlow.value.notificationsIgnoredMillis > 0) + } +} diff --git a/app/src/test/java/to/bitkit/utils/timedsheets/sheets/QuickPayTimedSheetTest.kt b/app/src/test/java/to/bitkit/utils/timedsheets/sheets/QuickPayTimedSheetTest.kt new file mode 100644 index 000000000..3e3c2d995 --- /dev/null +++ b/app/src/test/java/to/bitkit/utils/timedsheets/sheets/QuickPayTimedSheetTest.kt @@ -0,0 +1,115 @@ +package to.bitkit.utils.timedsheets.sheets + +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.flowOf +import kotlinx.coroutines.runBlocking +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Before +import org.junit.Test +import org.mockito.kotlin.any +import org.mockito.kotlin.mock +import org.mockito.kotlin.verify +import org.mockito.kotlin.whenever +import to.bitkit.test.BaseUnitTest +import to.bitkit.data.SettingsData +import to.bitkit.data.SettingsStore +import to.bitkit.models.BalanceState +import to.bitkit.repositories.WalletRepo +import to.bitkit.ui.components.TimedSheetType + +class QuickPayTimedSheetTest : BaseUnitTest() { + private lateinit var settingsStore: SettingsStore + private lateinit var walletRepo: WalletRepo + private lateinit var sut: QuickPayTimedSheet + + private val defaultSettings = SettingsData() + private val settingsFlow = MutableStateFlow(defaultSettings) + + @Before + fun setUp() = runBlocking { + settingsStore = mock() + walletRepo = mock() + + whenever(settingsStore.data).thenReturn(settingsFlow) + whenever(settingsStore.update(any())).then { + val transform = it.getArgument<(SettingsData) -> SettingsData>(0) + settingsFlow.value = transform(settingsFlow.value) + flowOf(settingsFlow.value) + } + + whenever(walletRepo.balanceState).thenReturn( + MutableStateFlow(BalanceState(totalLightningSats = 1000U)) + ) + + sut = QuickPayTimedSheet(settingsStore, walletRepo) + } + + @Test + fun `type is QUICK_PAY`() { + assertTrue(sut.type == TimedSheetType.QUICK_PAY) + } + + @Test + fun `priority is 2`() { + assertTrue(sut.priority == 2) + } + + @Test + fun `should not show when intro already seen`() = test { + settingsFlow.value = defaultSettings.copy(quickPayIntroSeen = true) + + val result = sut.shouldShow() + + assertFalse(result) + } + + @Test + fun `should not show when QuickPay already enabled`() = test { + settingsFlow.value = defaultSettings.copy( + quickPayIntroSeen = false, + isQuickPayEnabled = true + ) + + val result = sut.shouldShow() + + assertFalse(result) + } + + @Test + fun `should not show when no lightning balance`() = test { + whenever(walletRepo.balanceState).thenReturn( + MutableStateFlow(BalanceState(totalLightningSats = 0U)) + ) + settingsFlow.value = defaultSettings.copy( + quickPayIntroSeen = false, + isQuickPayEnabled = false + ) + + val result = sut.shouldShow() + + assertFalse(result) + } + + @Test + fun `should show with lightning balance and intro not seen`() = test { + settingsFlow.value = defaultSettings.copy( + quickPayIntroSeen = false, + isQuickPayEnabled = false + ) + + val result = sut.shouldShow() + + assertTrue(result) + } + + @Test + fun `updates quickPayIntroSeen on dismissed`() = test { + settingsFlow.value = defaultSettings.copy(quickPayIntroSeen = false) + + sut.onDismissed() + + verify(settingsStore).update(any()) + assertTrue(settingsFlow.value.quickPayIntroSeen) + } +} From 296464664ccd366cef5484235b4a5b22f07519c1 Mon Sep 17 00:00:00 2001 From: jvsena42 Date: Wed, 24 Dec 2025 08:06:45 -0300 Subject: [PATCH 3/6] refactor: replace timed sheet logic with manager --- .../java/to/bitkit/viewmodels/AppViewModel.kt | 261 +++--------------- 1 file changed, 31 insertions(+), 230 deletions(-) diff --git a/app/src/main/java/to/bitkit/viewmodels/AppViewModel.kt b/app/src/main/java/to/bitkit/viewmodels/AppViewModel.kt index 59ce94a52..b4478c038 100644 --- a/app/src/main/java/to/bitkit/viewmodels/AppViewModel.kt +++ b/app/src/main/java/to/bitkit/viewmodels/AppViewModel.kt @@ -28,10 +28,8 @@ import dagger.hilt.android.lifecycle.HiltViewModel import dagger.hilt.android.qualifiers.ApplicationContext import kotlinx.coroutines.CoroutineDispatcher import kotlinx.coroutines.CoroutineScope -import kotlinx.coroutines.SupervisorJob import kotlinx.coroutines.async import kotlinx.coroutines.awaitAll -import kotlinx.coroutines.cancel import kotlinx.coroutines.coroutineScope import kotlinx.coroutines.delay import kotlinx.coroutines.flow.MutableSharedFlow @@ -50,7 +48,6 @@ import org.lightningdevkit.ldknode.Event import org.lightningdevkit.ldknode.PaymentId import org.lightningdevkit.ldknode.SpendableUtxo import org.lightningdevkit.ldknode.Txid -import to.bitkit.BuildConfig import to.bitkit.R import to.bitkit.data.CacheStore import to.bitkit.data.SettingsStore @@ -69,7 +66,6 @@ import to.bitkit.ext.maxSendableSat import to.bitkit.ext.maxWithdrawableSat import to.bitkit.ext.minSendableSat import to.bitkit.ext.minWithdrawableSat -import to.bitkit.ext.nowMillis import to.bitkit.ext.rawId import to.bitkit.ext.removeSpaces import to.bitkit.ext.setClipboardText @@ -97,20 +93,23 @@ import to.bitkit.repositories.LightningRepo import to.bitkit.repositories.PreActivityMetadataRepo import to.bitkit.repositories.TransferRepo import to.bitkit.repositories.WalletRepo -import to.bitkit.services.AppUpdaterService import to.bitkit.ui.Routes import to.bitkit.ui.components.Sheet -import to.bitkit.ui.components.TimedSheetType import to.bitkit.ui.shared.toast.ToastEventBus import to.bitkit.ui.shared.toast.ToastQueueManager import to.bitkit.ui.sheets.SendRoute import to.bitkit.ui.theme.TRANSITION_SCREEN_MS import to.bitkit.utils.Logger import to.bitkit.utils.jsonLogOf +import to.bitkit.utils.timedsheets.TimedSheetManager +import to.bitkit.utils.timedsheets.sheets.AppUpdateTimedSheet +import to.bitkit.utils.timedsheets.sheets.BackupTimedSheet +import to.bitkit.utils.timedsheets.sheets.HighBalanceTimedSheet +import to.bitkit.utils.timedsheets.sheets.NotificationsTimedSheet +import to.bitkit.utils.timedsheets.sheets.QuickPayTimedSheet import java.math.BigDecimal import javax.inject.Inject import kotlin.coroutines.cancellation.CancellationException -import kotlin.time.Clock import kotlin.time.ExperimentalTime @OptIn(ExperimentalTime::class) @@ -120,6 +119,7 @@ class AppViewModel @Inject constructor( connectivityRepo: ConnectivityRepo, healthRepo: HealthRepo, toastManagerProvider: @JvmSuppressWildcards (CoroutineScope) -> ToastQueueManager, + timedSheetManagerProvider: @JvmSuppressWildcards (CoroutineScope) -> TimedSheetManager, @ApplicationContext private val context: Context, @BgDispatcher private val bgDispatcher: CoroutineDispatcher, private val keychain: Keychain, @@ -131,10 +131,14 @@ class AppViewModel @Inject constructor( private val activityRepo: ActivityRepo, private val preActivityMetadataRepo: PreActivityMetadataRepo, private val blocktankRepo: BlocktankRepo, - private val appUpdaterService: AppUpdaterService, private val notifyPaymentReceivedHandler: NotifyPaymentReceivedHandler, private val cacheStore: CacheStore, private val transferRepo: TransferRepo, + private val appUpdateSheet: AppUpdateTimedSheet, + private val backupSheet: BackupTimedSheet, + private val notificationsSheet: NotificationsTimedSheet, + private val quickPaySheet: QuickPayTimedSheet, + private val highBalanceSheet: HighBalanceTimedSheet, ) : ViewModel() { val healthState = healthRepo.healthState @@ -172,9 +176,13 @@ class AppViewModel @Inject constructor( private val processedPayments = mutableSetOf() - private var timedSheetsScope: CoroutineScope? = null - private var timedSheetQueue: List = emptyList() - private var currentTimedSheet: TimedSheetType? = null + private val timedSheetManager = timedSheetManagerProvider(viewModelScope).apply { + registerSheet(appUpdateSheet) + registerSheet(backupSheet) + registerSheet(notificationsSheet) + registerSheet(quickPaySheet) + registerSheet(highBalanceSheet) + } fun setShowForgotPin(value: Boolean) { _showForgotPinSheet.value = value @@ -223,6 +231,13 @@ class AppViewModel @Inject constructor( viewModelScope.launch { lightningRepo.updateGeoBlockState() } + viewModelScope.launch { + timedSheetManager.currentSheet.collect { sheetType -> + if (sheetType != null) { + _currentSheet.update { Sheet.TimedSheet(sheetType) } + } + } + } observeLdkNodeEvents() observeSendEvents() @@ -1583,7 +1598,7 @@ class AppViewModel @Inject constructor( } fun hideSheet() { - if (currentSheet.value is Sheet.TimedSheet && currentTimedSheet != null) { + if (currentSheet.value is Sheet.TimedSheet && timedSheetManager.currentSheet.value != null) { dismissTimedSheet() } else { _currentSheet.update { null } @@ -1786,213 +1801,12 @@ class AppViewModel @Inject constructor( .replace("lnurlp:", "") } - fun checkTimedSheets() { - if (backupRepo.isRestoring.value) return - - if (currentTimedSheet != null || timedSheetQueue.isNotEmpty()) { - Logger.debug("Timed sheet already active, skipping check") - return - } - - timedSheetsScope?.cancel() - timedSheetsScope = CoroutineScope(bgDispatcher + SupervisorJob()) - timedSheetsScope?.launch { - delay(CHECK_DELAY_MILLIS) - - if (currentTimedSheet != null || timedSheetQueue.isNotEmpty()) { - Logger.debug("Timed sheet became active during delay, skipping") - return@launch - } - - val eligibleSheets = TimedSheetType.entries - .filter { shouldDisplaySheet(it) } - .sortedByDescending { it.priority } - - if (eligibleSheets.isNotEmpty()) { - Logger.debug( - "Building timed sheet queue: ${eligibleSheets.joinToString { it.name }}", - context = "Timed sheet" - ) - timedSheetQueue = eligibleSheets - currentTimedSheet = eligibleSheets.first() - showSheet(Sheet.TimedSheet(eligibleSheets.first())) - } else { - Logger.debug("No timed sheet eligible, skipping", context = "Timed sheet") - } - } - } - - fun onLeftHome() { - Logger.debug("Left home, skipping timed sheet check") - timedSheetsScope?.cancel() - timedSheetsScope = null - } - - fun dismissTimedSheet(skipQueue: Boolean = false) { - Logger.debug("dismissTimedSheet called", context = "Timed sheet") - - val currentQueue = timedSheetQueue - val currentSheet = currentTimedSheet - - if (currentQueue.isEmpty() || currentSheet == null) { - clearTimedSheets() - return - } - - viewModelScope.launch { - val currentTime = nowMillis() - - when (currentSheet) { - TimedSheetType.HIGH_BALANCE -> settingsStore.update { - it.copy( - balanceWarningTimes = it.balanceWarningTimes + 1, - balanceWarningIgnoredMillis = currentTime, - ) - } - - TimedSheetType.NOTIFICATIONS -> settingsStore.update { - it.copy(notificationsIgnoredMillis = currentTime) - } - - TimedSheetType.BACKUP -> settingsStore.update { - it.copy(backupWarningIgnoredMillis = currentTime) - } - - TimedSheetType.QUICK_PAY -> settingsStore.update { - it.copy(quickPayIntroSeen = true) - } - - TimedSheetType.APP_UPDATE -> Unit - } - } - - if (skipQueue) { - clearTimedSheets() - return - } - - val currentIndex = currentQueue.indexOf(currentSheet) - val nextIndex = currentIndex + 1 - - if (nextIndex < currentQueue.size) { - Logger.debug("Moving to next timed sheet in queue: ${currentQueue[nextIndex].name}") - currentTimedSheet = currentQueue[nextIndex] - showSheet(Sheet.TimedSheet(currentQueue[nextIndex])) - } else { - Logger.debug("Timed sheet queue exhausted") - clearTimedSheets() - } - } - - private fun clearTimedSheets() { - currentTimedSheet = null - timedSheetQueue = emptyList() - hideSheet() - } - - private suspend fun shouldDisplaySheet(sheet: TimedSheetType): Boolean = when (sheet) { - TimedSheetType.APP_UPDATE -> checkAppUpdate() - TimedSheetType.BACKUP -> checkBackupSheet() - TimedSheetType.NOTIFICATIONS -> checkNotificationSheet() - TimedSheetType.QUICK_PAY -> checkQuickPaySheet() - TimedSheetType.HIGH_BALANCE -> checkHighBalance() - } - - private suspend fun checkQuickPaySheet(): Boolean { - val settings = settingsStore.data.first() - if (settings.quickPayIntroSeen || settings.isQuickPayEnabled) return false - val shouldShow = walletRepo.balanceState.value.totalLightningSats > 0U - return shouldShow - } - - private suspend fun checkNotificationSheet(): Boolean { - val settings = settingsStore.data.first() - if (settings.notificationsGranted) return false - if (walletRepo.balanceState.value.totalLightningSats == 0UL) return false - - return checkTimeout( - lastIgnoredMillis = settings.notificationsIgnoredMillis, - intervalMillis = ONE_WEEK_ASK_INTERVAL_MILLIS - ) - } - - private suspend fun checkBackupSheet(): Boolean { - val settings = settingsStore.data.first() - if (settings.backupVerified) return false - - val hasBalance = walletRepo.balanceState.value.totalSats > 0U - if (!hasBalance) return false - - return checkTimeout( - lastIgnoredMillis = settings.backupWarningIgnoredMillis, - intervalMillis = ONE_DAY_ASK_INTERVAL_MILLIS - ) - } - - private suspend fun checkAppUpdate(): Boolean = withContext(bgDispatcher) { - try { - val androidReleaseInfo = appUpdaterService.getReleaseInfo().platforms.android - val currentBuildNumber = BuildConfig.VERSION_CODE + fun checkTimedSheets() = timedSheetManager.onHomeScreenEntered() - if (androidReleaseInfo.buildNumber <= currentBuildNumber) return@withContext false + fun onLeftHome() = timedSheetManager.onHomeScreenExited() - if (androidReleaseInfo.isCritical) { - mainScreenEffect( - MainScreenEffect.Navigate( - route = Routes.CriticalUpdate, - navOptions = navOptions { - popUpTo(0) { inclusive = true } - } - ) - ) - return@withContext false - } - - return@withContext true - } catch (e: Exception) { - Logger.warn("Failure fetching new releases", e = e) - return@withContext false - } - } - - private suspend fun checkHighBalance(): Boolean { - val settings = settingsStore.data.first() - - val totalOnChainSats = walletRepo.balanceState.value.totalSats - val balanceUsd = satsToUsd(totalOnChainSats) ?: return false - val thresholdReached = balanceUsd > BigDecimal(BALANCE_THRESHOLD_USD) - - if (!thresholdReached) { - settingsStore.update { it.copy(balanceWarningTimes = 0) } - return false - } - - val belowMaxWarnings = settings.balanceWarningTimes < MAX_WARNINGS - - return checkTimeout( - lastIgnoredMillis = settings.balanceWarningIgnoredMillis, - intervalMillis = ONE_DAY_ASK_INTERVAL_MILLIS, - additionalCondition = belowMaxWarnings - ) - } - - private fun checkTimeout( - lastIgnoredMillis: Long, - intervalMillis: Long, - additionalCondition: Boolean = true, - ): Boolean { - if (!additionalCondition) return false - - val currentTime = Clock.System.now().toEpochMilliseconds() - val isTimeOutOver = lastIgnoredMillis == 0L || - (currentTime - lastIgnoredMillis > intervalMillis) - return isTimeOutOver - } - - private fun satsToUsd(sats: ULong): BigDecimal? { - val converted = currencyRepo.convertSatsToFiat(sats = sats.toLong(), currency = "USD").getOrNull() - return converted?.value - } + fun dismissTimedSheet(skipQueue: Boolean = false) = + timedSheetManager.dismissCurrentSheet(skipQueue) companion object { private const val TAG = "AppViewModel" @@ -2001,19 +1815,6 @@ class AppViewModel @Inject constructor( private const val MAX_BALANCE_FRACTION = 0.5 private const val MAX_FEE_AMOUNT_RATIO = 0.5 private const val SCREEN_TRANSITION_DELAY_MS = 300L - - /**How high the balance must be to show this warning to the user (in USD)*/ - private const val BALANCE_THRESHOLD_USD = 500L - private const val MAX_WARNINGS = 3 - - /** how long this prompt will be hidden if user taps Later*/ - private const val ONE_DAY_ASK_INTERVAL_MILLIS = 1000 * 60 * 60 * 24L - - /** how long this prompt will be hidden if user taps Later*/ - private const val ONE_WEEK_ASK_INTERVAL_MILLIS = ONE_DAY_ASK_INTERVAL_MILLIS * 7L - - /**How long user needs to stay on the home screen before he see this prompt*/ - private const val CHECK_DELAY_MILLIS = 2000L } } From 601f0b6ed126f14a0519fa91a029faceaa5e4af3 Mon Sep 17 00:00:00 2001 From: jvsena42 Date: Tue, 30 Dec 2025 15:09:33 -0300 Subject: [PATCH 4/6] chore: auto-fix detekt lint issues --- .../bitkit/ui/onboarding/TermsOfUseScreen.kt | 1 - .../timedsheets/TimedSheetManagerTest.kt | 1 - .../sheets/AppUpdateTimedSheetTest.kt | 2 +- .../sheets/BackupTimedSheetTest.kt | 2 +- .../sheets/HighBalanceTimedSheetTest.kt | 144 ++++++++++-------- .../sheets/NotificationsTimedSheetTest.kt | 2 +- .../sheets/QuickPayTimedSheetTest.kt | 2 +- 7 files changed, 83 insertions(+), 71 deletions(-) diff --git a/app/src/main/java/to/bitkit/ui/onboarding/TermsOfUseScreen.kt b/app/src/main/java/to/bitkit/ui/onboarding/TermsOfUseScreen.kt index 30b60c475..920a82d54 100644 --- a/app/src/main/java/to/bitkit/ui/onboarding/TermsOfUseScreen.kt +++ b/app/src/main/java/to/bitkit/ui/onboarding/TermsOfUseScreen.kt @@ -135,7 +135,6 @@ private fun TermsText( } } - @Preview(showSystemUi = true) @Composable private fun TermsPreview() { diff --git a/app/src/test/java/to/bitkit/utils/timedsheets/TimedSheetManagerTest.kt b/app/src/test/java/to/bitkit/utils/timedsheets/TimedSheetManagerTest.kt index a56d98c4e..2e698039f 100644 --- a/app/src/test/java/to/bitkit/utils/timedsheets/TimedSheetManagerTest.kt +++ b/app/src/test/java/to/bitkit/utils/timedsheets/TimedSheetManagerTest.kt @@ -3,7 +3,6 @@ package to.bitkit.utils.timedsheets import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.test.TestScope import kotlinx.coroutines.test.advanceTimeBy -import kotlinx.coroutines.test.runTest import org.junit.Assert.assertEquals import org.junit.Assert.assertNull import org.junit.Before diff --git a/app/src/test/java/to/bitkit/utils/timedsheets/sheets/AppUpdateTimedSheetTest.kt b/app/src/test/java/to/bitkit/utils/timedsheets/sheets/AppUpdateTimedSheetTest.kt index dc245821d..6828399e9 100644 --- a/app/src/test/java/to/bitkit/utils/timedsheets/sheets/AppUpdateTimedSheetTest.kt +++ b/app/src/test/java/to/bitkit/utils/timedsheets/sheets/AppUpdateTimedSheetTest.kt @@ -8,12 +8,12 @@ import org.junit.Before import org.junit.Test import org.mockito.kotlin.mock import org.mockito.kotlin.whenever -import to.bitkit.test.BaseUnitTest import to.bitkit.BuildConfig import to.bitkit.data.dto.PlatformDetails import to.bitkit.data.dto.Platforms import to.bitkit.data.dto.ReleaseInfoDTO import to.bitkit.services.AppUpdaterService +import to.bitkit.test.BaseUnitTest import to.bitkit.ui.components.TimedSheetType class AppUpdateTimedSheetTest : BaseUnitTest() { diff --git a/app/src/test/java/to/bitkit/utils/timedsheets/sheets/BackupTimedSheetTest.kt b/app/src/test/java/to/bitkit/utils/timedsheets/sheets/BackupTimedSheetTest.kt index f3acf6a1f..3157e2a8c 100644 --- a/app/src/test/java/to/bitkit/utils/timedsheets/sheets/BackupTimedSheetTest.kt +++ b/app/src/test/java/to/bitkit/utils/timedsheets/sheets/BackupTimedSheetTest.kt @@ -11,11 +11,11 @@ import org.mockito.kotlin.any import org.mockito.kotlin.mock import org.mockito.kotlin.verify import org.mockito.kotlin.whenever -import to.bitkit.test.BaseUnitTest import to.bitkit.data.SettingsData import to.bitkit.data.SettingsStore import to.bitkit.models.BalanceState import to.bitkit.repositories.WalletRepo +import to.bitkit.test.BaseUnitTest import to.bitkit.ui.components.TimedSheetType class BackupTimedSheetTest : BaseUnitTest() { diff --git a/app/src/test/java/to/bitkit/utils/timedsheets/sheets/HighBalanceTimedSheetTest.kt b/app/src/test/java/to/bitkit/utils/timedsheets/sheets/HighBalanceTimedSheetTest.kt index 8f8bffd9d..bff275a3d 100644 --- a/app/src/test/java/to/bitkit/utils/timedsheets/sheets/HighBalanceTimedSheetTest.kt +++ b/app/src/test/java/to/bitkit/utils/timedsheets/sheets/HighBalanceTimedSheetTest.kt @@ -12,16 +12,16 @@ import org.mockito.kotlin.any import org.mockito.kotlin.mock import org.mockito.kotlin.verify import org.mockito.kotlin.whenever -import to.bitkit.test.BaseUnitTest import to.bitkit.data.SettingsData import to.bitkit.data.SettingsStore import to.bitkit.models.BalanceState import to.bitkit.models.ConvertedAmount -import java.util.Locale import to.bitkit.repositories.CurrencyRepo import to.bitkit.repositories.WalletRepo +import to.bitkit.test.BaseUnitTest import to.bitkit.ui.components.TimedSheetType import java.math.BigDecimal +import java.util.Locale class HighBalanceTimedSheetTest : BaseUnitTest() { private lateinit var settingsStore: SettingsStore @@ -64,15 +64,17 @@ class HighBalanceTimedSheetTest : BaseUnitTest() { MutableStateFlow(BalanceState(totalOnchainSats = 10000U)) ) whenever(currencyRepo.convertSatsToFiat(10000L, "USD")).thenReturn( - Result.success(ConvertedAmount( - value = BigDecimal("100"), - formatted = "100.00", - symbol = "$", - currency = "USD", - flag = "", - sats = 10000L, - locale = Locale.US - )) + Result.success( + ConvertedAmount( + value = BigDecimal("100"), + formatted = "100.00", + symbol = "$", + currency = "USD", + flag = "", + sats = 10000L, + locale = Locale.US + ) + ) ) val result = sut.shouldShow() @@ -100,15 +102,17 @@ class HighBalanceTimedSheetTest : BaseUnitTest() { MutableStateFlow(BalanceState(totalOnchainSats = 100000U)) ) whenever(currencyRepo.convertSatsToFiat(100000L, "USD")).thenReturn( - Result.success(ConvertedAmount( - value = BigDecimal("600"), - formatted = "600.00", - symbol = "$", - currency = "USD", - flag = "", - sats = 100000L, - locale = Locale.US - )) + Result.success( + ConvertedAmount( + value = BigDecimal("600"), + formatted = "600.00", + symbol = "$", + currency = "USD", + flag = "", + sats = 100000L, + locale = Locale.US + ) + ) ) settingsFlow.value = defaultSettings.copy(balanceWarningTimes = 3) @@ -124,15 +128,17 @@ class HighBalanceTimedSheetTest : BaseUnitTest() { MutableStateFlow(BalanceState(totalOnchainSats = 100000U)) ) whenever(currencyRepo.convertSatsToFiat(100000L, "USD")).thenReturn( - Result.success(ConvertedAmount( - value = BigDecimal("600"), - formatted = "600.00", - symbol = "$", - currency = "USD", - flag = "", - sats = 100000L, - locale = Locale.US - )) + Result.success( + ConvertedAmount( + value = BigDecimal("600"), + formatted = "600.00", + symbol = "$", + currency = "USD", + flag = "", + sats = 100000L, + locale = Locale.US + ) + ) ) settingsFlow.value = defaultSettings.copy( balanceWarningTimes = 0, @@ -151,15 +157,17 @@ class HighBalanceTimedSheetTest : BaseUnitTest() { MutableStateFlow(BalanceState(totalOnchainSats = 100000U)) ) whenever(currencyRepo.convertSatsToFiat(100000L, "USD")).thenReturn( - Result.success(ConvertedAmount( - value = BigDecimal("600"), - formatted = "600.00", - symbol = "$", - currency = "USD", - flag = "", - sats = 100000L, - locale = Locale.US - )) + Result.success( + ConvertedAmount( + value = BigDecimal("600"), + formatted = "600.00", + symbol = "$", + currency = "USD", + flag = "", + sats = 100000L, + locale = Locale.US + ) + ) ) settingsFlow.value = defaultSettings.copy( balanceWarningTimes = 0, @@ -177,15 +185,17 @@ class HighBalanceTimedSheetTest : BaseUnitTest() { MutableStateFlow(BalanceState(totalOnchainSats = 100000U)) ) whenever(currencyRepo.convertSatsToFiat(100000L, "USD")).thenReturn( - Result.success(ConvertedAmount( - value = BigDecimal("600"), - formatted = "600.00", - symbol = "$", - currency = "USD", - flag = "", - sats = 100000L, - locale = Locale.US - )) + Result.success( + ConvertedAmount( + value = BigDecimal("600"), + formatted = "600.00", + symbol = "$", + currency = "USD", + flag = "", + sats = 100000L, + locale = Locale.US + ) + ) ) settingsFlow.value = defaultSettings.copy( balanceWarningTimes = 0, @@ -203,15 +213,17 @@ class HighBalanceTimedSheetTest : BaseUnitTest() { MutableStateFlow(BalanceState(totalOnchainSats = 10000U)) ) whenever(currencyRepo.convertSatsToFiat(10000L, "USD")).thenReturn( - Result.success(ConvertedAmount( - value = BigDecimal("100"), - formatted = "100.00", - symbol = "$", - currency = "USD", - flag = "", - sats = 10000L, - locale = Locale.US - )) + Result.success( + ConvertedAmount( + value = BigDecimal("100"), + formatted = "100.00", + symbol = "$", + currency = "USD", + flag = "", + sats = 10000L, + locale = Locale.US + ) + ) ) settingsFlow.value = defaultSettings.copy(balanceWarningTimes = 2) @@ -239,15 +251,17 @@ class HighBalanceTimedSheetTest : BaseUnitTest() { MutableStateFlow(BalanceState(totalOnchainSats = 100000U)) ) whenever(currencyRepo.convertSatsToFiat(100000L, "USD")).thenReturn( - Result.success(ConvertedAmount( - value = BigDecimal("600"), - formatted = "600.00", - symbol = "$", - currency = "USD", - flag = "", - sats = 100000L, - locale = Locale.US - )) + Result.success( + ConvertedAmount( + value = BigDecimal("600"), + formatted = "600.00", + symbol = "$", + currency = "USD", + flag = "", + sats = 100000L, + locale = Locale.US + ) + ) ) settingsFlow.value = defaultSettings.copy( diff --git a/app/src/test/java/to/bitkit/utils/timedsheets/sheets/NotificationsTimedSheetTest.kt b/app/src/test/java/to/bitkit/utils/timedsheets/sheets/NotificationsTimedSheetTest.kt index 947aaa19f..a62bcba3e 100644 --- a/app/src/test/java/to/bitkit/utils/timedsheets/sheets/NotificationsTimedSheetTest.kt +++ b/app/src/test/java/to/bitkit/utils/timedsheets/sheets/NotificationsTimedSheetTest.kt @@ -11,11 +11,11 @@ import org.mockito.kotlin.any import org.mockito.kotlin.mock import org.mockito.kotlin.verify import org.mockito.kotlin.whenever -import to.bitkit.test.BaseUnitTest import to.bitkit.data.SettingsData import to.bitkit.data.SettingsStore import to.bitkit.models.BalanceState import to.bitkit.repositories.WalletRepo +import to.bitkit.test.BaseUnitTest import to.bitkit.ui.components.TimedSheetType class NotificationsTimedSheetTest : BaseUnitTest() { diff --git a/app/src/test/java/to/bitkit/utils/timedsheets/sheets/QuickPayTimedSheetTest.kt b/app/src/test/java/to/bitkit/utils/timedsheets/sheets/QuickPayTimedSheetTest.kt index 3e3c2d995..0790dcc25 100644 --- a/app/src/test/java/to/bitkit/utils/timedsheets/sheets/QuickPayTimedSheetTest.kt +++ b/app/src/test/java/to/bitkit/utils/timedsheets/sheets/QuickPayTimedSheetTest.kt @@ -11,11 +11,11 @@ import org.mockito.kotlin.any import org.mockito.kotlin.mock import org.mockito.kotlin.verify import org.mockito.kotlin.whenever -import to.bitkit.test.BaseUnitTest import to.bitkit.data.SettingsData import to.bitkit.data.SettingsStore import to.bitkit.models.BalanceState import to.bitkit.repositories.WalletRepo +import to.bitkit.test.BaseUnitTest import to.bitkit.ui.components.TimedSheetType class QuickPayTimedSheetTest : BaseUnitTest() { From ad7d313fb08e7f8f275f9028ca8ef853c47105cb Mon Sep 17 00:00:00 2001 From: jvsena42 Date: Tue, 30 Dec 2025 15:27:39 -0300 Subject: [PATCH 5/6] fix: clean queue on timed sheet dismiss and reapply critical navigation --- app/src/main/java/to/bitkit/ui/ContentView.kt | 6 +-- .../utils/timedsheets/TimedSheetManager.kt | 15 ++++--- .../java/to/bitkit/viewmodels/AppViewModel.kt | 40 ++++++++++++++----- .../timedsheets/TimedSheetManagerTest.kt | 4 +- 4 files changed, 43 insertions(+), 22 deletions(-) diff --git a/app/src/main/java/to/bitkit/ui/ContentView.kt b/app/src/main/java/to/bitkit/ui/ContentView.kt index 0220b40ed..3a838797a 100644 --- a/app/src/main/java/to/bitkit/ui/ContentView.kt +++ b/app/src/main/java/to/bitkit/ui/ContentView.kt @@ -409,7 +409,7 @@ fun ContentView( TimedSheetType.NOTIFICATIONS -> { BackgroundPaymentsIntroSheet( onContinue = { - appViewModel.dismissTimedSheet(skipQueue = true) + appViewModel.dismissTimedSheet() navController.navigate(Routes.BackgroundPaymentsSettings) settingsViewModel.setBgPaymentsIntroSeen(true) }, @@ -419,7 +419,7 @@ fun ContentView( TimedSheetType.QUICK_PAY -> { QuickPayIntroSheet( onContinue = { - appViewModel.dismissTimedSheet(skipQueue = true) + appViewModel.dismissTimedSheet() navController.navigate(Routes.QuickPaySettings) }, ) @@ -432,7 +432,7 @@ fun ContentView( val intent = Intent(Intent.ACTION_VIEW, Env.STORING_BITCOINS_URL.toUri()) context.startActivity(intent) - appViewModel.dismissTimedSheet(skipQueue = true) + appViewModel.dismissTimedSheet() } ) } diff --git a/app/src/main/java/to/bitkit/utils/timedsheets/TimedSheetManager.kt b/app/src/main/java/to/bitkit/utils/timedsheets/TimedSheetManager.kt index f2f7dc07c..71f34852e 100644 --- a/app/src/main/java/to/bitkit/utils/timedsheets/TimedSheetManager.kt +++ b/app/src/main/java/to/bitkit/utils/timedsheets/TimedSheetManager.kt @@ -42,21 +42,20 @@ class TimedSheetManager(private val scope: CoroutineScope) { checkJob = null } - fun dismissCurrentSheet(skipQueue: Boolean = false) { + fun dismissCurrentSheet() { + if (currentTimedSheet == null) return + scope.launch { currentTimedSheet?.onDismissed() + _currentSheet.value = null + currentTimedSheet = null - if (skipQueue) { - Logger.debug("Clearing timed sheet queue", context = TAG) - _currentSheet.value = null - currentTimedSheet = null - } else { - checkAndShowNextSheet() - } + Logger.debug("Clearing timed sheet queue", context = TAG) } } private suspend fun checkAndShowNextSheet() { + Logger.debug("Registered sheets: ${registeredSheets.map { it.type.name }}") for (sheet in registeredSheets.toList()) { if (sheet.shouldShow()) { Logger.debug( diff --git a/app/src/main/java/to/bitkit/viewmodels/AppViewModel.kt b/app/src/main/java/to/bitkit/viewmodels/AppViewModel.kt index b4478c038..a0725dc06 100644 --- a/app/src/main/java/to/bitkit/viewmodels/AppViewModel.kt +++ b/app/src/main/java/to/bitkit/viewmodels/AppViewModel.kt @@ -48,6 +48,7 @@ import org.lightningdevkit.ldknode.Event import org.lightningdevkit.ldknode.PaymentId import org.lightningdevkit.ldknode.SpendableUtxo import org.lightningdevkit.ldknode.Txid +import to.bitkit.BuildConfig import to.bitkit.R import to.bitkit.data.CacheStore import to.bitkit.data.SettingsStore @@ -93,6 +94,7 @@ import to.bitkit.repositories.LightningRepo import to.bitkit.repositories.PreActivityMetadataRepo import to.bitkit.repositories.TransferRepo import to.bitkit.repositories.WalletRepo +import to.bitkit.services.AppUpdaterService import to.bitkit.ui.Routes import to.bitkit.ui.components.Sheet import to.bitkit.ui.shared.toast.ToastEventBus @@ -131,6 +133,7 @@ class AppViewModel @Inject constructor( private val activityRepo: ActivityRepo, private val preActivityMetadataRepo: PreActivityMetadataRepo, private val blocktankRepo: BlocktankRepo, + private val appUpdaterService: AppUpdaterService, private val notifyPaymentReceivedHandler: NotifyPaymentReceivedHandler, private val cacheStore: CacheStore, private val transferRepo: TransferRepo, @@ -238,15 +241,11 @@ class AppViewModel @Inject constructor( } } } - - observeLdkNodeEvents() - observeSendEvents() - viewModelScope.launch { - walletRepo.balanceState.collect { - checkTimedSheets() - } + checkCriticalAppUpdate() } + observeLdkNodeEvents() + observeSendEvents() } private fun observeLdkNodeEvents() { @@ -1805,8 +1804,31 @@ class AppViewModel @Inject constructor( fun onLeftHome() = timedSheetManager.onHomeScreenExited() - fun dismissTimedSheet(skipQueue: Boolean = false) = - timedSheetManager.dismissCurrentSheet(skipQueue) + fun dismissTimedSheet() = timedSheetManager.dismissCurrentSheet() + + private suspend fun checkCriticalAppUpdate() = withContext(bgDispatcher) { + delay(SCREEN_TRANSITION_DELAY_MS) + + runCatching { + val androidReleaseInfo = appUpdaterService.getReleaseInfo().platforms.android + val currentBuildNumber = BuildConfig.VERSION_CODE + + if (androidReleaseInfo.buildNumber <= currentBuildNumber) return@withContext + + if (androidReleaseInfo.isCritical) { + mainScreenEffect( + MainScreenEffect.Navigate( + route = Routes.CriticalUpdate, + navOptions = navOptions { + popUpTo(0) { inclusive = true } + } + ) + ) + } + }.onFailure { e -> + Logger.warn("Failure fetching new releases", e = e, context = TAG) + } + } companion object { private const val TAG = "AppViewModel" diff --git a/app/src/test/java/to/bitkit/utils/timedsheets/TimedSheetManagerTest.kt b/app/src/test/java/to/bitkit/utils/timedsheets/TimedSheetManagerTest.kt index 2e698039f..24228de27 100644 --- a/app/src/test/java/to/bitkit/utils/timedsheets/TimedSheetManagerTest.kt +++ b/app/src/test/java/to/bitkit/utils/timedsheets/TimedSheetManagerTest.kt @@ -103,7 +103,7 @@ class TimedSheetManagerTest : BaseUnitTest() { assertEquals(TimedSheetType.APP_UPDATE, sut.currentSheet.value) - sut.dismissCurrentSheet(skipQueue = true) + sut.dismissCurrentSheet() testScope.advanceTimeBy(100) assertNull(sut.currentSheet.value) @@ -139,7 +139,7 @@ class TimedSheetManagerTest : BaseUnitTest() { assertEquals(TimedSheetType.BACKUP, sut.currentSheet.value) - sut.dismissCurrentSheet(skipQueue = false) + sut.dismissCurrentSheet() testScope.advanceTimeBy(100) assertNull(sut.currentSheet.value) From d58d46a757513615c08159eb9d4e9f7a524d344a Mon Sep 17 00:00:00 2001 From: jvsena42 Date: Wed, 31 Dec 2025 07:59:52 -0300 Subject: [PATCH 6/6] fix: handle null sheet state --- .../java/to/bitkit/viewmodels/AppViewModel.kt | 28 +++++++++++++------ 1 file changed, 20 insertions(+), 8 deletions(-) diff --git a/app/src/main/java/to/bitkit/viewmodels/AppViewModel.kt b/app/src/main/java/to/bitkit/viewmodels/AppViewModel.kt index a0725dc06..1ff2f65c0 100644 --- a/app/src/main/java/to/bitkit/viewmodels/AppViewModel.kt +++ b/app/src/main/java/to/bitkit/viewmodels/AppViewModel.kt @@ -177,6 +177,9 @@ class AppViewModel @Inject constructor( private val _showForgotPinSheet = MutableStateFlow(false) val showForgotPinSheet = _showForgotPinSheet.asStateFlow() + private val _currentSheet: MutableStateFlow = MutableStateFlow(null) + val currentSheet = _currentSheet.asStateFlow() + private val processedPayments = mutableSetOf() private val timedSheetManager = timedSheetManagerProvider(viewModelScope).apply { @@ -237,7 +240,12 @@ class AppViewModel @Inject constructor( viewModelScope.launch { timedSheetManager.currentSheet.collect { sheetType -> if (sheetType != null) { - _currentSheet.update { Sheet.TimedSheet(sheetType) } + showSheet(Sheet.TimedSheet(sheetType)) + } else { + // Clear the timed sheet when manager sets it to null + _currentSheet.update { current -> + if (current is Sheet.TimedSheet) null else current + } } } } @@ -1583,9 +1591,6 @@ class AppViewModel @Inject constructor( // endregion // region Sheets - private val _currentSheet: MutableStateFlow = MutableStateFlow(null) - val currentSheet = _currentSheet.asStateFlow() - fun showSheet(sheetType: Sheet) { viewModelScope.launch { _currentSheet.value?.let { @@ -1597,10 +1602,17 @@ class AppViewModel @Inject constructor( } fun hideSheet() { - if (currentSheet.value is Sheet.TimedSheet && timedSheetManager.currentSheet.value != null) { - dismissTimedSheet() - } else { - _currentSheet.update { null } + when { + currentSheet.value is Sheet.TimedSheet -> { + // Only dismiss if manager still has a sheet (user initiated) + // If manager already cleared it, just update our state + if (timedSheetManager.currentSheet.value != null) { + dismissTimedSheet() + } else { + _currentSheet.update { null } + } + } + else -> _currentSheet.update { null } } }