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/ui/ContentView.kt b/app/src/main/java/to/bitkit/ui/ContentView.kt index f5da38ac0..ba26ae1a3 100644 --- a/app/src/main/java/to/bitkit/ui/ContentView.kt +++ b/app/src/main/java/to/bitkit/ui/ContentView.kt @@ -127,6 +127,7 @@ fun ContentView( appViewModel.mainScreenEffect.collect { when (it) { is MainScreenEffect.Navigate -> navigator.navigate(it.route) + is MainScreenEffect.NavigateAndClearBackstack -> navigator.navigateAndClearBackstack(it.route) is MainScreenEffect.ProcessClipboardAutoRead -> { if (!navigator.isAtHome()) { navigator.navigateToHome() diff --git a/app/src/main/java/to/bitkit/ui/nav/Navigator.kt b/app/src/main/java/to/bitkit/ui/nav/Navigator.kt index 9ec4513e8..be9ff047f 100644 --- a/app/src/main/java/to/bitkit/ui/nav/Navigator.kt +++ b/app/src/main/java/to/bitkit/ui/nav/Navigator.kt @@ -50,6 +50,11 @@ class Navigator(@PublishedApi internal val backStack: NavBackStack) { fun isAtHome(): Boolean = backStack.lastOrNull() is Routes.Home + fun navigateAndClearBackstack(route: Routes) { + backStack.clear() + backStack.add(route) + } + fun shouldShowTabBar(): Boolean = when (backStack.lastOrNull()) { is Routes.Home, is Routes.Savings, is Routes.Spending, is Routes.Activity.All -> true else -> false diff --git a/app/src/main/java/to/bitkit/ui/nav/entries/SheetEntries.kt b/app/src/main/java/to/bitkit/ui/nav/entries/SheetEntries.kt index 4a98f092d..93e5026d5 100644 --- a/app/src/main/java/to/bitkit/ui/nav/entries/SheetEntries.kt +++ b/app/src/main/java/to/bitkit/ui/nav/entries/SheetEntries.kt @@ -3,6 +3,7 @@ package to.bitkit.ui.nav.entries import android.content.Intent import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.navigationBarsPadding +import androidx.compose.runtime.DisposableEffect import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue import androidx.compose.runtime.remember @@ -799,23 +800,23 @@ private fun EntryProviderScope.sheetFlowEntries( entry( metadata = SheetSceneStrategy.sheet() ) { + DisposableEffect(Unit) { + onDispose { appViewModel.dismissTimedSheet() } + } UpdateSheet( - onCancel = { - appViewModel.dismissTimedSheet() - navigator.goBack() - }, + onCancel = { navigator.goBack() }, ) } entry( metadata = SheetSceneStrategy.sheet() ) { + DisposableEffect(Unit) { + onDispose { appViewModel.dismissTimedSheet() } + } BackupIntroScreen( hasFunds = LocalBalances.current.totalSats > 0u, - onClose = { - appViewModel.dismissTimedSheet() - navigator.goBack() - }, + onClose = { navigator.goBack() }, onConfirm = { navigator.navigate(Routes.Backup.ShowMnemonic) }, ) } @@ -823,6 +824,9 @@ private fun EntryProviderScope.sheetFlowEntries( entry( metadata = SheetSceneStrategy.sheet() ) { + DisposableEffect(Unit) { + onDispose { appViewModel.dismissTimedSheet() } + } BackgroundPaymentsIntroSheet( onContinue = { appViewModel.dismissTimedSheet(skipQueue = true) @@ -834,6 +838,9 @@ private fun EntryProviderScope.sheetFlowEntries( entry( metadata = SheetSceneStrategy.sheet() ) { + DisposableEffect(Unit) { + onDispose { appViewModel.dismissTimedSheet() } + } QuickPayIntroSheet( onContinue = { appViewModel.dismissTimedSheet(skipQueue = true) @@ -845,12 +852,12 @@ private fun EntryProviderScope.sheetFlowEntries( entry( metadata = SheetSceneStrategy.sheet() ) { + DisposableEffect(Unit) { + onDispose { appViewModel.dismissTimedSheet() } + } val context = LocalContext.current HighBalanceWarningSheet( - understoodClick = { - appViewModel.dismissTimedSheet() - navigator.goBack() - }, + understoodClick = { navigator.goBack() }, learnMoreClick = { val intent = Intent(Intent.ACTION_VIEW, Env.STORING_BITCOINS_URL.toUri()) context.startActivity(intent) 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..3260e5e1b --- /dev/null +++ b/app/src/main/java/to/bitkit/utils/timedsheets/TimedSheetManager.kt @@ -0,0 +1,87 @@ +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) { + if (currentTimedSheet == null) return + + scope.launch { + currentTimedSheet?.onDismissed() + _currentSheet.value = null + currentTimedSheet = null + + if (skipQueue) { + Logger.debug("Clearing timed sheet queue", context = TAG) + } else { + delay(CHECK_DELAY_MILLIS) + checkAndShowNextSheet() + } + } + } + + private suspend fun checkAndShowNextSheet() { + Logger.debug("Registered sheets: ${registeredSheets.map { it.type.name }}") + 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..1d6aa5476 --- /dev/null +++ b/app/src/main/java/to/bitkit/utils/timedsheets/sheets/AppUpdateTimedSheet.kt @@ -0,0 +1,47 @@ +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) { + return@withContext runCatching { + val androidReleaseInfo = appUpdaterService.getReleaseInfo().platforms.android + val currentBuildNumber = BuildConfig.VERSION_CODE + + if (androidReleaseInfo.buildNumber <= currentBuildNumber) return@withContext false + + if (androidReleaseInfo.isCritical) { + return@runCatching false + } + return@runCatching true + }.onFailure { e -> + Logger.warn("Failure fetching new releases", e = e, context = TAG) + }.getOrDefault(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" + } +} diff --git a/app/src/main/java/to/bitkit/viewmodels/AppViewModel.kt b/app/src/main/java/to/bitkit/viewmodels/AppViewModel.kt index e0a5989e4..6ab035d66 100644 --- a/app/src/main/java/to/bitkit/viewmodels/AppViewModel.kt +++ b/app/src/main/java/to/bitkit/viewmodels/AppViewModel.kt @@ -26,10 +26,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 @@ -102,10 +100,15 @@ import to.bitkit.ui.shared.toast.ToastEventBus import to.bitkit.ui.shared.toast.ToastQueueManager 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) @@ -115,6 +118,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, @@ -130,6 +134,11 @@ class AppViewModel @Inject constructor( 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 @@ -167,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 @@ -218,15 +231,18 @@ class AppViewModel @Inject constructor( viewModelScope.launch { lightningRepo.updateGeoBlockState() } - - observeLdkNodeEvents() - observeSendEvents() - viewModelScope.launch { - walletRepo.balanceState.collect { - checkTimedSheets() + timedSheetManager.currentSheet.collect { sheetType -> + sheetType?.let { + mainScreenEffect(MainScreenEffect.Navigate(it.toRoute())) + } } } + viewModelScope.launch { + checkCriticalAppUpdate() + } + observeLdkNodeEvents() + observeSendEvents() } private fun observeLdkNodeEvents() { @@ -1489,7 +1505,7 @@ class AppViewModel @Inject constructor( // region Sheets fun hideSheet() { - if (currentTimedSheet != null) { + if (timedSheetManager.currentSheet.value != null) { dismissTimedSheet() } } @@ -1677,205 +1693,28 @@ class AppViewModel @Inject constructor( handleScan(data.removeLightningSchemes()) } - 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() - mainScreenEffect(MainScreenEffect.Navigate(eligibleSheets.first().toRoute())) - } 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] - mainScreenEffect(MainScreenEffect.Navigate(currentQueue[nextIndex].toRoute())) - } else { - Logger.debug("Timed sheet queue exhausted") - clearTimedSheets() - } - } - - private fun clearTimedSheets() { - currentTimedSheet = null - timedSheetQueue = emptyList() - hideSheet() - } + fun checkTimedSheets() = timedSheetManager.onHomeScreenEntered() - 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() - } + fun onLeftHome() = timedSheetManager.onHomeScreenExited() - 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 - } + fun dismissTimedSheet(skipQueue: Boolean = false) = + timedSheetManager.dismissCurrentSheet(skipQueue) - 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 checkCriticalAppUpdate() = withContext(bgDispatcher) { + delay(SCREEN_TRANSITION_DELAY_MS) - private suspend fun checkAppUpdate(): Boolean = withContext(bgDispatcher) { - try { + runCatching { val androidReleaseInfo = appUpdaterService.getReleaseInfo().platforms.android val currentBuildNumber = BuildConfig.VERSION_CODE - if (androidReleaseInfo.buildNumber <= currentBuildNumber) return@withContext false + if (androidReleaseInfo.buildNumber <= currentBuildNumber) return@withContext if (androidReleaseInfo.isCritical) { - mainScreenEffect(MainScreenEffect.Navigate(Routes.CriticalUpdate)) - return@withContext false + mainScreenEffect(MainScreenEffect.NavigateAndClearBackstack(Routes.CriticalUpdate)) } - - 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 + }.onFailure { e -> + Logger.warn("Failure fetching new releases", e = e, context = TAG) } - - 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 } companion object { @@ -1885,19 +1724,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 } } @@ -1958,6 +1784,7 @@ sealed class SendEffect { sealed class MainScreenEffect { data class Navigate(val route: Routes) : MainScreenEffect() + data class NavigateAndClearBackstack(val route: Routes) : MainScreenEffect() data object WipeWallet : MainScreenEffect() data class ProcessClipboardAutoRead(val data: String) : MainScreenEffect() } 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..2e698039f --- /dev/null +++ b/app/src/test/java/to/bitkit/utils/timedsheets/TimedSheetManagerTest.kt @@ -0,0 +1,178 @@ +package to.bitkit.utils.timedsheets + +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.test.TestScope +import kotlinx.coroutines.test.advanceTimeBy +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..6828399e9 --- /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.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() { + 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..3157e2a8c --- /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.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() { + 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..bff275a3d --- /dev/null +++ b/app/src/test/java/to/bitkit/utils/timedsheets/sheets/HighBalanceTimedSheetTest.kt @@ -0,0 +1,279 @@ +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.data.SettingsData +import to.bitkit.data.SettingsStore +import to.bitkit.models.BalanceState +import to.bitkit.models.ConvertedAmount +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 + 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..a62bcba3e --- /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.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() { + 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..0790dcc25 --- /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.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() { + 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) + } +}