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 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/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/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..71f34852e --- /dev/null +++ b/app/src/main/java/to/bitkit/utils/timedsheets/TimedSheetManager.kt @@ -0,0 +1,82 @@ +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() { + if (currentTimedSheet == null) return + + scope.launch { + currentTimedSheet?.onDismissed() + _currentSheet.value = null + currentTimedSheet = null + + 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( + "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" + } +} diff --git a/app/src/main/java/to/bitkit/viewmodels/AppViewModel.kt b/app/src/main/java/to/bitkit/viewmodels/AppViewModel.kt index 59ce94a52..1ff2f65c0 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 @@ -69,7 +67,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 @@ -100,17 +97,21 @@ 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 +121,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, @@ -135,6 +137,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 @@ -170,11 +177,18 @@ 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 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,15 +237,23 @@ class AppViewModel @Inject constructor( viewModelScope.launch { lightningRepo.updateGeoBlockState() } - - observeLdkNodeEvents() - observeSendEvents() - viewModelScope.launch { - walletRepo.balanceState.collect { - checkTimedSheets() + timedSheetManager.currentSheet.collect { sheetType -> + if (sheetType != null) { + 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 + } + } } } + viewModelScope.launch { + checkCriticalAppUpdate() + } + observeLdkNodeEvents() + observeSendEvents() } private fun observeLdkNodeEvents() { @@ -1569,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 { @@ -1583,10 +1602,17 @@ class AppViewModel @Inject constructor( } fun hideSheet() { - if (currentSheet.value is Sheet.TimedSheet && currentTimedSheet != 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 } } } @@ -1786,155 +1812,20 @@ class AppViewModel @Inject constructor( .replace("lnurlp:", "") } - fun checkTimedSheets() { - if (backupRepo.isRestoring.value) return + fun checkTimedSheets() = timedSheetManager.onHomeScreenEntered() - 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 - } + fun onLeftHome() = timedSheetManager.onHomeScreenExited() - val eligibleSheets = TimedSheetType.entries - .filter { shouldDisplaySheet(it) } - .sortedByDescending { it.priority } + fun dismissTimedSheet() = timedSheetManager.dismissCurrentSheet() - 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 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( @@ -1945,55 +1836,12 @@ class AppViewModel @Inject constructor( } ) ) - return@withContext false } - - return@withContext true - } catch (e: Exception) { - Logger.warn("Failure fetching new releases", e = e) - return@withContext false + }.onFailure { e -> + Logger.warn("Failure fetching new releases", e = e, context = TAG) } } - 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 - } - companion object { private const val TAG = "AppViewModel" private const val SEND_AMOUNT_WARNING_THRESHOLD = 100.0 @@ -2001,19 +1849,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 } } 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..24228de27 --- /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() + 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() + 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) + } +}