diff --git a/android/app/src/androidTest/kotlin/net/mullvad/mullvadvpn/compose/screen/ConnectScreenTest.kt b/android/app/src/androidTest/kotlin/net/mullvad/mullvadvpn/compose/screen/ConnectScreenTest.kt index 1ae19c534a99..f55e07c1cf96 100644 --- a/android/app/src/androidTest/kotlin/net/mullvad/mullvadvpn/compose/screen/ConnectScreenTest.kt +++ b/android/app/src/androidTest/kotlin/net/mullvad/mullvadvpn/compose/screen/ConnectScreenTest.kt @@ -29,6 +29,7 @@ import net.mullvad.mullvadvpn.lib.model.TunnelState import net.mullvad.mullvadvpn.repository.InAppNotification import net.mullvad.mullvadvpn.ui.VersionInfo import org.joda.time.DateTime +import org.joda.time.Duration import org.junit.jupiter.api.AfterEach import org.junit.jupiter.api.BeforeEach import org.junit.jupiter.api.Test @@ -570,7 +571,10 @@ class ConnectScreenTest { showLocation = false, deviceName = "", daysLeftUntilExpiry = null, - inAppNotification = InAppNotification.AccountExpiry(expiryDate), + inAppNotification = + InAppNotification.AccountExpiry( + Duration(DateTime.now(), expiryDate) + ), isPlayBuild = false, ) ) @@ -630,7 +634,10 @@ class ConnectScreenTest { showLocation = false, deviceName = "", daysLeftUntilExpiry = null, - inAppNotification = InAppNotification.AccountExpiry(expiryDate), + inAppNotification = + InAppNotification.AccountExpiry( + Duration(DateTime.now(), expiryDate) + ), isPlayBuild = false, ), ) diff --git a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/component/notificationbanner/NotificationBanner.kt b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/component/notificationbanner/NotificationBanner.kt index ebed6ea462f2..a2485f2e999b 100644 --- a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/component/notificationbanner/NotificationBanner.kt +++ b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/component/notificationbanner/NotificationBanner.kt @@ -36,7 +36,7 @@ import net.mullvad.mullvadvpn.lib.theme.color.warning import net.mullvad.mullvadvpn.repository.InAppNotification import net.mullvad.mullvadvpn.ui.VersionInfo import net.mullvad.mullvadvpn.ui.notification.StatusLevel -import org.joda.time.DateTime +import org.joda.time.Duration @Preview @Composable @@ -48,7 +48,7 @@ private fun PreviewNotificationBanner() { InAppNotification.UnsupportedVersion( versionInfo = VersionInfo(currentVersion = "1.0", isSupported = false) ), - InAppNotification.AccountExpiry(expiry = DateTime.now()), + InAppNotification.AccountExpiry(expiry = Duration.ZERO), InAppNotification.TunnelStateBlocked, InAppNotification.NewDevice("Courageous Turtle"), InAppNotification.TunnelStateError( diff --git a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/extensions/ResourcesExtensions.kt b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/extensions/ResourcesExtensions.kt index 367c1b54af27..11b41dd27a61 100644 --- a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/extensions/ResourcesExtensions.kt +++ b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/extensions/ResourcesExtensions.kt @@ -4,34 +4,20 @@ import android.content.res.Resources import net.mullvad.mullvadvpn.R import org.joda.time.DateTime import org.joda.time.Duration -import org.joda.time.PeriodType +import org.joda.time.Period -fun Resources.getExpiryQuantityString(accountExpiry: DateTime): String { - val remainingTime = Duration(DateTime.now(), accountExpiry) - - return getExpiryQuantityString(this, accountExpiry, remainingTime) -} - -private fun getExpiryQuantityString( - resources: Resources, - accountExpiry: DateTime, - remainingTime: Duration, -): String { - if (remainingTime.isShorterThan(Duration.ZERO)) { - return resources.getString(R.string.out_of_time) +fun Resources.getExpiryQuantityString(accountExpiry: Duration): String { + val expiryPeriod = Period(DateTime.now(), accountExpiry) + return if (accountExpiry.millis <= 0) { + getString(R.string.out_of_time) + } else if (expiryPeriod.years > 0) { + getRemainingText(this, R.plurals.years_left, expiryPeriod.years) + } else if (expiryPeriod.months >= 3) { + getRemainingText(this, R.plurals.months_left, expiryPeriod.months) + } else if (expiryPeriod.months > 0 || expiryPeriod.days >= 1) { + getRemainingText(this, R.plurals.days_left, expiryPeriod.days) } else { - val remainingTimeInfo = - remainingTime.toPeriodTo(accountExpiry, PeriodType.yearMonthDayTime()) - - return if (remainingTimeInfo.years > 0) { - getRemainingText(resources, R.plurals.years_left, remainingTimeInfo.years) - } else if (remainingTimeInfo.months >= 3) { - getRemainingText(resources, R.plurals.months_left, remainingTimeInfo.months) - } else if (remainingTimeInfo.months > 0 || remainingTimeInfo.days >= 1) { - getRemainingText(resources, R.plurals.days_left, remainingTime.standardDays.toInt()) - } else { - resources.getString(R.string.less_than_a_day_left) - } + getString(R.string.less_than_a_day_left) } } diff --git a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/di/UiModule.kt b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/di/UiModule.kt index 48dada2d61db..89c4e1e68df7 100644 --- a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/di/UiModule.kt +++ b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/di/UiModule.kt @@ -30,7 +30,7 @@ import net.mullvad.mullvadvpn.repository.SplitTunnelingRepository import net.mullvad.mullvadvpn.ui.MainActivity import net.mullvad.mullvadvpn.ui.serviceconnection.AppVersionInfoRepository import net.mullvad.mullvadvpn.ui.serviceconnection.ServiceConnectionManager -import net.mullvad.mullvadvpn.usecase.AccountExpiryNotificationUseCase +import net.mullvad.mullvadvpn.usecase.AccountExpiryInAppNotificationUseCase import net.mullvad.mullvadvpn.usecase.AvailableProvidersUseCase import net.mullvad.mullvadvpn.usecase.EmptyPaymentUseCase import net.mullvad.mullvadvpn.usecase.FilteredRelayListUseCase @@ -141,7 +141,7 @@ val uiModule = module { ) } - single { AccountExpiryNotificationUseCase(get()) } + single { AccountExpiryInAppNotificationUseCase(get()) } single { TunnelStateNotificationUseCase(get()) } single { VersionNotificationUseCase(get(), BuildConfig.ENABLE_IN_APP_VERSION_NOTIFICATIONS) } single { NewDeviceNotificationUseCase(get(), get()) } diff --git a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/repository/InAppNotificationController.kt b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/repository/InAppNotificationController.kt index 821fe769c296..1608e3689ea9 100644 --- a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/repository/InAppNotificationController.kt +++ b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/repository/InAppNotificationController.kt @@ -7,11 +7,11 @@ import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.stateIn import net.mullvad.mullvadvpn.lib.model.ErrorState import net.mullvad.mullvadvpn.ui.VersionInfo -import net.mullvad.mullvadvpn.usecase.AccountExpiryNotificationUseCase +import net.mullvad.mullvadvpn.usecase.AccountExpiryInAppNotificationUseCase import net.mullvad.mullvadvpn.usecase.NewDeviceNotificationUseCase import net.mullvad.mullvadvpn.usecase.TunnelStateNotificationUseCase import net.mullvad.mullvadvpn.usecase.VersionNotificationUseCase -import org.joda.time.DateTime +import org.joda.time.Duration enum class StatusLevel { Error, @@ -38,7 +38,7 @@ sealed class InAppNotification { override val priority: Long = 999 } - data class AccountExpiry(val expiry: DateTime) : InAppNotification() { + data class AccountExpiry(val expiry: Duration) : InAppNotification() { override val statusLevel = StatusLevel.Warning override val priority: Long = 1001 } @@ -50,7 +50,7 @@ sealed class InAppNotification { } class InAppNotificationController( - accountExpiryNotificationUseCase: AccountExpiryNotificationUseCase, + accountExpiryInAppNotificationUseCase: AccountExpiryInAppNotificationUseCase, newDeviceNotificationUseCase: NewDeviceNotificationUseCase, versionNotificationUseCase: VersionNotificationUseCase, tunnelStateNotificationUseCase: TunnelStateNotificationUseCase, @@ -61,7 +61,7 @@ class InAppNotificationController( combine( tunnelStateNotificationUseCase(), versionNotificationUseCase(), - accountExpiryNotificationUseCase(), + accountExpiryInAppNotificationUseCase(), newDeviceNotificationUseCase(), ) { a, b, c, d -> a + b + c + d diff --git a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/usecase/AccountExpiryInAppNotificationUseCase.kt b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/usecase/AccountExpiryInAppNotificationUseCase.kt new file mode 100644 index 000000000000..004dd443513d --- /dev/null +++ b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/usecase/AccountExpiryInAppNotificationUseCase.kt @@ -0,0 +1,31 @@ +package net.mullvad.mullvadvpn.usecase + +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.flatMapLatest +import kotlinx.coroutines.flow.flowOf +import kotlinx.coroutines.flow.map +import net.mullvad.mullvadvpn.lib.shared.AccountRepository +import net.mullvad.mullvadvpn.repository.InAppNotification +import net.mullvad.mullvadvpn.service.notifications.accountexpiry.ACCOUNT_EXPIRY_CLOSE_TO_EXPIRY_THRESHOLD +import net.mullvad.mullvadvpn.service.notifications.accountexpiry.ACCOUNT_EXPIRY_IN_APP_NOTIFICATION_UPDATE_INTERVAL +import net.mullvad.mullvadvpn.service.notifications.accountexpiry.expiryTickerFlow + +class AccountExpiryInAppNotificationUseCase(private val accountRepository: AccountRepository) { + + @OptIn(kotlinx.coroutines.ExperimentalCoroutinesApi::class) + operator fun invoke(): Flow> = + accountRepository.accountData + .flatMapLatest { accountData -> + if (accountData != null) { + expiryTickerFlow( + expiry = accountData.expiryDate, + tickStart = ACCOUNT_EXPIRY_CLOSE_TO_EXPIRY_THRESHOLD, + updateInterval = { ACCOUNT_EXPIRY_IN_APP_NOTIFICATION_UPDATE_INTERVAL }, + ) + .map { expiresInPeriod -> InAppNotification.AccountExpiry(expiresInPeriod) } + } else { + flowOf(null) + } + } + .map(::listOfNotNull) +} diff --git a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/usecase/AccountExpiryNotificationUseCase.kt b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/usecase/AccountExpiryNotificationUseCase.kt deleted file mode 100644 index d3490692f0ed..000000000000 --- a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/usecase/AccountExpiryNotificationUseCase.kt +++ /dev/null @@ -1,29 +0,0 @@ -package net.mullvad.mullvadvpn.usecase - -import kotlinx.coroutines.flow.Flow -import kotlinx.coroutines.flow.distinctUntilChanged -import kotlinx.coroutines.flow.map -import net.mullvad.mullvadvpn.lib.model.AccountData -import net.mullvad.mullvadvpn.lib.shared.AccountRepository -import net.mullvad.mullvadvpn.repository.InAppNotification -import net.mullvad.mullvadvpn.service.notifications.accountexpiry.ACCOUNT_EXPIRY_CLOSE_TO_EXPIRY_THRESHOLD_DAYS -import org.joda.time.DateTime - -class AccountExpiryNotificationUseCase(private val accountRepository: AccountRepository) { - operator fun invoke(): Flow> = - accountRepository.accountData - .map(::accountExpiryNotification) - .map(::listOfNotNull) - .distinctUntilChanged() - - private fun accountExpiryNotification(accountData: AccountData?) = - if (accountData != null && accountData.expiryDate.isCloseToExpiring()) { - InAppNotification.AccountExpiry(accountData.expiryDate) - } else null - - private fun DateTime.isCloseToExpiring(): Boolean { - val threeDaysFromNow = - DateTime.now().plusDays(ACCOUNT_EXPIRY_CLOSE_TO_EXPIRY_THRESHOLD_DAYS) - return isBefore(threeDaysFromNow) - } -} diff --git a/android/app/src/test/kotlin/net/mullvad/mullvadvpn/InAppNotificationControllerTest.kt b/android/app/src/test/kotlin/net/mullvad/mullvadvpn/InAppNotificationControllerTest.kt index 84ec047f06c7..74b599da976f 100644 --- a/android/app/src/test/kotlin/net/mullvad/mullvadvpn/InAppNotificationControllerTest.kt +++ b/android/app/src/test/kotlin/net/mullvad/mullvadvpn/InAppNotificationControllerTest.kt @@ -16,11 +16,11 @@ import net.mullvad.mullvadvpn.lib.common.test.TestCoroutineRule import net.mullvad.mullvadvpn.lib.model.ErrorState import net.mullvad.mullvadvpn.repository.InAppNotification import net.mullvad.mullvadvpn.repository.InAppNotificationController -import net.mullvad.mullvadvpn.usecase.AccountExpiryNotificationUseCase +import net.mullvad.mullvadvpn.usecase.AccountExpiryInAppNotificationUseCase import net.mullvad.mullvadvpn.usecase.NewDeviceNotificationUseCase import net.mullvad.mullvadvpn.usecase.TunnelStateNotificationUseCase import net.mullvad.mullvadvpn.usecase.VersionNotificationUseCase -import org.joda.time.DateTime +import org.joda.time.Duration import org.junit.jupiter.api.AfterEach import org.junit.jupiter.api.BeforeEach import org.junit.jupiter.api.Test @@ -42,11 +42,11 @@ class InAppNotificationControllerTest { fun setup() { MockKAnnotations.init(this) - val accountExpiryNotificationUseCase: AccountExpiryNotificationUseCase = mockk() + val accountExpiryInAppNotificationUseCase: AccountExpiryInAppNotificationUseCase = mockk() val newDeviceNotificationUseCase: NewDeviceNotificationUseCase = mockk() val versionNotificationUseCase: VersionNotificationUseCase = mockk() val tunnelStateNotificationUseCase: TunnelStateNotificationUseCase = mockk() - every { accountExpiryNotificationUseCase.invoke() } returns accountExpiryNotifications + every { accountExpiryInAppNotificationUseCase.invoke() } returns accountExpiryNotifications every { newDeviceNotificationUseCase.invoke() } returns newDeviceNotifications every { versionNotificationUseCase.invoke() } returns versionNotifications every { tunnelStateNotificationUseCase.invoke() } returns tunnelStateNotifications @@ -54,7 +54,7 @@ class InAppNotificationControllerTest { inAppNotificationController = InAppNotificationController( - accountExpiryNotificationUseCase, + accountExpiryInAppNotificationUseCase, newDeviceNotificationUseCase, versionNotificationUseCase, tunnelStateNotificationUseCase, @@ -81,7 +81,7 @@ class InAppNotificationControllerTest { val unsupportedVersion = InAppNotification.UnsupportedVersion(mockk()) versionNotifications.value = listOf(unsupportedVersion) - val accountExpiry = InAppNotification.AccountExpiry(DateTime.now()) + val accountExpiry = InAppNotification.AccountExpiry(Duration.ZERO) accountExpiryNotifications.value = listOf(accountExpiry) inAppNotificationController.notifications.test { diff --git a/android/app/src/test/kotlin/net/mullvad/mullvadvpn/usecase/AccountExpiryInAppNotificationUseCaseTest.kt b/android/app/src/test/kotlin/net/mullvad/mullvadvpn/usecase/AccountExpiryInAppNotificationUseCaseTest.kt new file mode 100644 index 000000000000..0749cc62b41f --- /dev/null +++ b/android/app/src/test/kotlin/net/mullvad/mullvadvpn/usecase/AccountExpiryInAppNotificationUseCaseTest.kt @@ -0,0 +1,153 @@ +@file:OptIn(kotlinx.coroutines.ExperimentalCoroutinesApi::class) + +package net.mullvad.mullvadvpn.usecase + +import app.cash.turbine.test +import io.mockk.MockKAnnotations +import io.mockk.every +import io.mockk.mockk +import io.mockk.unmockkAll +import kotlin.test.assertEquals +import kotlin.test.assertTrue +import kotlin.time.Duration.Companion.seconds +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.test.advanceTimeBy +import kotlinx.coroutines.test.runTest +import net.mullvad.mullvadvpn.lib.common.test.TestCoroutineRule +import net.mullvad.mullvadvpn.lib.model.AccountData +import net.mullvad.mullvadvpn.lib.shared.AccountRepository +import net.mullvad.mullvadvpn.repository.InAppNotification +import net.mullvad.mullvadvpn.service.notifications.accountexpiry.ACCOUNT_EXPIRY_CLOSE_TO_EXPIRY_THRESHOLD +import net.mullvad.mullvadvpn.service.notifications.accountexpiry.ACCOUNT_EXPIRY_IN_APP_NOTIFICATION_UPDATE_INTERVAL +import org.joda.time.DateTime +import org.joda.time.Duration +import org.joda.time.Period +import org.junit.jupiter.api.AfterEach +import org.junit.jupiter.api.BeforeEach +import org.junit.jupiter.api.Test +import org.junit.jupiter.api.extension.ExtendWith + +@ExtendWith(TestCoroutineRule::class) +class AccountExpiryInAppNotificationUseCaseTest { + + private val accountExpiry = MutableStateFlow(null) + private lateinit var accountExpiryInAppNotificationUseCase: + AccountExpiryInAppNotificationUseCase + + private lateinit var notificationThreshold: DateTime + + @BeforeEach + fun setup() { + MockKAnnotations.init(this) + + val accountRepository = mockk() + every { accountRepository.accountData } returns accountExpiry + + accountExpiryInAppNotificationUseCase = + AccountExpiryInAppNotificationUseCase(accountRepository) + + notificationThreshold = DateTime.now().plus(ACCOUNT_EXPIRY_CLOSE_TO_EXPIRY_THRESHOLD) + } + + @AfterEach + fun teardown() { + unmockkAll() + } + + @Test + fun `initial state should be empty`() = runTest { + accountExpiryInAppNotificationUseCase().test { assertTrue { awaitItem().isEmpty() } } + } + + @Test + fun `account that expires within the threshold should emit a notification`() = runTest { + accountExpiryInAppNotificationUseCase().test { + assertTrue { awaitItem().isEmpty() } + val expiry = setExpiry(notificationThreshold.minusHours(1)) + assertExpiryNotificationAndPeriod(expiry, expectMostRecentItem()) + expectNoEvents() + } + } + + @Test + fun `account that expires after the threshold should not emit a notification`() = runTest { + accountExpiryInAppNotificationUseCase().test { + assertTrue { awaitItem().isEmpty() } + setExpiry(notificationThreshold.plusDays(1)) + expectNoEvents() + } + } + + @Test + fun `should emit when the threshold is passed`() = runTest { + accountExpiryInAppNotificationUseCase().test { + assertTrue { awaitItem().isEmpty() } + val expiry = setExpiry(notificationThreshold.plusMinutes(1)) + expectNoEvents() + + // Advance to before threshold + advanceTimeBy(59.seconds) + expectNoEvents() + + // Advance to after threshold + advanceTimeBy(2.seconds) + assertExpiryNotificationAndPeriod(expiry, expectMostRecentItem()) + expectNoEvents() + } + } + + @Test + fun `should emit zero period when the time expires`() = runTest { + accountExpiryInAppNotificationUseCase().test { + assertTrue { awaitItem().isEmpty() } + + // Set expiry to to be in the final update period. + val inLastUpdate = + DateTime.now() + .plus(ACCOUNT_EXPIRY_IN_APP_NOTIFICATION_UPDATE_INTERVAL) + .minusSeconds(1) + val expiry = setExpiry(inLastUpdate) + + // The expiry time is within the notification threshold so we should have an item + // immediately. + assertExpiryNotificationAndPeriod(expiry, expectMostRecentItem()) + expectNoEvents() + + // Advance past the delay before the while loop: + advanceTimeBy(ACCOUNT_EXPIRY_IN_APP_NOTIFICATION_UPDATE_INTERVAL.millis) + // Advance past the delay after the while loop: + advanceTimeBy(ACCOUNT_EXPIRY_IN_APP_NOTIFICATION_UPDATE_INTERVAL.millis) + assertEquals(Duration.ZERO, getExpiryNotificationDuration(expectMostRecentItem())) + expectNoEvents() + } + } + + private fun setExpiry(expiryDateTime: DateTime): DateTime { + val expiry = AccountData(mockk(relaxed = true), expiryDateTime) + accountExpiry.value = expiry + return expiryDateTime + } + + // Assert that we go a single AccountExpiry notification and that the period is within + // the expected range (checking exact period values is not possible since we use DateTime.now) + private fun assertExpiryNotificationAndPeriod( + expiry: DateTime, + notifications: List, + ) { + val notificationDuration = getExpiryNotificationDuration(notifications) + val periodNow = Period(DateTime.now(), expiry) + assertTrue(periodNow.toStandardDuration() <= notificationDuration) + assertTrue( + periodNow.toStandardDuration().plus(Duration.standardSeconds(5)) > notificationDuration + ) + } + + private fun getExpiryNotificationDuration(notifications: List): Duration { + assertTrue(notifications.size == 1, "Expected a single notification") + val n = notifications[0] + if (n !is InAppNotification.AccountExpiry) { + error("Expected an AccountExpiry notification") + } + return n.expiry + } +} diff --git a/android/app/src/test/kotlin/net/mullvad/mullvadvpn/usecase/AccountExpiryNotificationUseCaseTest.kt b/android/app/src/test/kotlin/net/mullvad/mullvadvpn/usecase/AccountExpiryNotificationUseCaseTest.kt deleted file mode 100644 index f8a0e52a3e83..000000000000 --- a/android/app/src/test/kotlin/net/mullvad/mullvadvpn/usecase/AccountExpiryNotificationUseCaseTest.kt +++ /dev/null @@ -1,73 +0,0 @@ -package net.mullvad.mullvadvpn.usecase - -import app.cash.turbine.test -import io.mockk.MockKAnnotations -import io.mockk.every -import io.mockk.mockk -import io.mockk.unmockkAll -import kotlin.test.assertEquals -import kotlin.test.assertTrue -import kotlinx.coroutines.flow.MutableStateFlow -import kotlinx.coroutines.test.runTest -import net.mullvad.mullvadvpn.lib.common.test.TestCoroutineRule -import net.mullvad.mullvadvpn.lib.model.AccountData -import net.mullvad.mullvadvpn.lib.shared.AccountRepository -import net.mullvad.mullvadvpn.repository.InAppNotification -import org.joda.time.DateTime -import org.junit.jupiter.api.AfterEach -import org.junit.jupiter.api.BeforeEach -import org.junit.jupiter.api.Test -import org.junit.jupiter.api.extension.ExtendWith - -@ExtendWith(TestCoroutineRule::class) -class AccountExpiryNotificationUseCaseTest { - - private val accountExpiry = MutableStateFlow(null) - private lateinit var accountExpiryNotificationUseCase: AccountExpiryNotificationUseCase - - @BeforeEach - fun setup() { - MockKAnnotations.init(this) - - val accountRepository = mockk() - every { accountRepository.accountData } returns accountExpiry - - accountExpiryNotificationUseCase = AccountExpiryNotificationUseCase(accountRepository) - } - - @AfterEach - fun teardown() { - unmockkAll() - } - - @Test - fun `initial state should be empty`() = runTest { - // Arrange, Act, Assert - accountExpiryNotificationUseCase().test { assertTrue { awaitItem().isEmpty() } } - } - - @Test - fun `account that expires within 3 days should emit a notification`() = runTest { - // Arrange, Act, Assert - accountExpiryNotificationUseCase().test { - assertTrue { awaitItem().isEmpty() } - val closeToExpiry = AccountData(mockk(relaxed = true), DateTime.now().plusDays(2)) - accountExpiry.value = closeToExpiry - - assertEquals( - listOf(InAppNotification.AccountExpiry(closeToExpiry.expiryDate)), - awaitItem(), - ) - } - } - - @Test - fun `account that expires in 4 days should not emit a notification`() = runTest { - // Arrange, Act, Assert - accountExpiryNotificationUseCase().test { - assertTrue { awaitItem().isEmpty() } - accountExpiry.value = AccountData(mockk(relaxed = true), DateTime.now().plusDays(4)) - expectNoEvents() - } - } -} diff --git a/android/app/src/test/kotlin/net/mullvad/mullvadvpn/usecase/OutOfTimeUseCaseTest.kt b/android/app/src/test/kotlin/net/mullvad/mullvadvpn/usecase/OutOfTimeUseCaseTest.kt index d8438cafe07e..e12c2a1d88bd 100644 --- a/android/app/src/test/kotlin/net/mullvad/mullvadvpn/usecase/OutOfTimeUseCaseTest.kt +++ b/android/app/src/test/kotlin/net/mullvad/mullvadvpn/usecase/OutOfTimeUseCaseTest.kt @@ -185,14 +185,12 @@ class OutOfTimeUseCaseTest { advanceTimeBy(90.seconds) expectNoEvents() - // User fills up with more time 10 seconds before expiry + // User fills up with more time 10 seconds before expiry. expiry.emit(updatedExpiry) - advanceTimeBy(1.days) + advanceTimeBy(29.days) expectNoEvents() - // Expect no more emissions while user has time. - advanceTimeBy(29.days + 2.minutes) - println(testScheduler.currentTime) + advanceTimeBy(2.days) assertEquals(true, expectMostRecentItem()) expectNoEvents() } diff --git a/android/service/src/main/kotlin/net/mullvad/mullvadvpn/service/notifications/accountexpiry/AccountExpiryAndroidNotification.kt b/android/service/src/main/kotlin/net/mullvad/mullvadvpn/service/notifications/accountexpiry/AccountExpiryAndroidNotification.kt index 0e8a1a528f28..d6d18fd58ab7 100644 --- a/android/service/src/main/kotlin/net/mullvad/mullvadvpn/service/notifications/accountexpiry/AccountExpiryAndroidNotification.kt +++ b/android/service/src/main/kotlin/net/mullvad/mullvadvpn/service/notifications/accountexpiry/AccountExpiryAndroidNotification.kt @@ -39,7 +39,7 @@ private fun Notification.AccountExpiry.contentIntent(context: Context): PendingI private fun Resources.contentTitle(remainingTime: Duration): String = when { - remainingTime.isShorterThan(Duration.ZERO) -> { + remainingTime.isShorterThan(Duration.ZERO) || remainingTime == Duration.ZERO -> { getString(R.string.account_credit_has_expired) } remainingTime.standardDays >= 1 -> { diff --git a/android/service/src/main/kotlin/net/mullvad/mullvadvpn/service/notifications/accountexpiry/AccountExpiryConstant.kt b/android/service/src/main/kotlin/net/mullvad/mullvadvpn/service/notifications/accountexpiry/AccountExpiryConstant.kt index 7ca2b33a22ae..d5ba20e30dc3 100644 --- a/android/service/src/main/kotlin/net/mullvad/mullvadvpn/service/notifications/accountexpiry/AccountExpiryConstant.kt +++ b/android/service/src/main/kotlin/net/mullvad/mullvadvpn/service/notifications/accountexpiry/AccountExpiryConstant.kt @@ -1,4 +1,10 @@ +@file:Suppress("MagicNumber") + package net.mullvad.mullvadvpn.service.notifications.accountexpiry -const val ACCOUNT_EXPIRY_POLL_INTERVAL: Long = 15 /* s */ * 1000 /* ms */ -const val ACCOUNT_EXPIRY_CLOSE_TO_EXPIRY_THRESHOLD_DAYS = 3 +import kotlin.time.Duration.Companion.seconds +import org.joda.time.Duration + +val ACCOUNT_EXPIRY_POLL_INTERVAL = 15.seconds +val ACCOUNT_EXPIRY_IN_APP_NOTIFICATION_UPDATE_INTERVAL: Duration = Duration.standardDays(1) +val ACCOUNT_EXPIRY_CLOSE_TO_EXPIRY_THRESHOLD: Duration = Duration.standardDays(3) diff --git a/android/service/src/main/kotlin/net/mullvad/mullvadvpn/service/notifications/accountexpiry/AccountExpiryNotificationProvider.kt b/android/service/src/main/kotlin/net/mullvad/mullvadvpn/service/notifications/accountexpiry/AccountExpiryNotificationProvider.kt index 325005445927..a6a2f80d0647 100644 --- a/android/service/src/main/kotlin/net/mullvad/mullvadvpn/service/notifications/accountexpiry/AccountExpiryNotificationProvider.kt +++ b/android/service/src/main/kotlin/net/mullvad/mullvadvpn/service/notifications/accountexpiry/AccountExpiryNotificationProvider.kt @@ -55,8 +55,6 @@ class AccountExpiryNotificationProvider( } private fun Duration.isCloseToExpiry(): Boolean { - return isShorterThan( - Duration.standardDays(ACCOUNT_EXPIRY_CLOSE_TO_EXPIRY_THRESHOLD_DAYS.toLong()) - ) + return isShorterThan(ACCOUNT_EXPIRY_CLOSE_TO_EXPIRY_THRESHOLD) } } diff --git a/android/service/src/main/kotlin/net/mullvad/mullvadvpn/service/notifications/accountexpiry/AccountExpiryTickerFlow.kt b/android/service/src/main/kotlin/net/mullvad/mullvadvpn/service/notifications/accountexpiry/AccountExpiryTickerFlow.kt new file mode 100644 index 000000000000..c3a444f5edc0 --- /dev/null +++ b/android/service/src/main/kotlin/net/mullvad/mullvadvpn/service/notifications/accountexpiry/AccountExpiryTickerFlow.kt @@ -0,0 +1,60 @@ +package net.mullvad.mullvadvpn.service.notifications.accountexpiry + +import kotlinx.coroutines.delay +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.flow +import org.joda.time.DateTime +import org.joda.time.Duration + +fun expiryTickerFlow( + expiry: DateTime, + tickStart: Duration, + updateInterval: (expiry: DateTime) -> Duration, +): Flow = flow { + expiry.millisFromNow().let { expiryMillis -> + if (expiryMillis <= 0) { + // Has expired. + emit(Duration.ZERO) + return@flow + } + if (expiryMillis > tickStart.millis) { + // Delay until the time we should start emitting. + delay(expiryMillis - tickStart.millis + 1) + } + } + + var currentUpdateInterval = updateInterval(expiry).millis + + do { + emit(Duration(DateTime.now(), expiry)) + delay(millisUntilNextUpdate(expiry.millisFromNow(), currentUpdateInterval)) + currentUpdateInterval = updateInterval(expiry).millis + } while (hasAnotherEmission(expiry.millisFromNow(), currentUpdateInterval)) + + // We may have remaining time if the update interval wasn't a multiple of the remaining time. + delay(expiry.millisFromNow()) + + // We have now expired. + emit(Duration.ZERO) +} + +private fun millisUntilNextUpdate( + millisUntilExpiry: Long, + currentUpdateIntervalMillis: Long, +): Long = + (millisUntilExpiry % currentUpdateIntervalMillis).let { + if (it == 0L) currentUpdateIntervalMillis else it + } + +private fun hasAnotherEmission(millisUntilExpiry: Long, updateIntervalMillis: Long) = + calculateDelaysNeeded(millisUntilExpiry, updateIntervalMillis) > 0 + +// Calculate how many times we need to delay and and emit until the expiry time is reached. +// Note that the returned delays may add upp to less than the remaining time, for example +// if we have 100ms remaining and currentUpdateIntervalMillis is 40ms this function will return 2. +private fun calculateDelaysNeeded( + millisUntilExpiry: Long, + currentUpdateIntervalMillis: Long, +): Long = millisUntilExpiry.coerceAtLeast(0) / currentUpdateIntervalMillis + +private fun DateTime.millisFromNow(): Long = Duration(DateTime.now(), this).millis