summaryrefslogtreecommitdiffhomepage
path: root/android
diff options
context:
space:
mode:
authorDavid Göransson <david.goransson@mullvad.net>2024-10-07 08:35:21 +0200
committerDavid Göransson <david.goransson@mullvad.net>2024-10-07 08:35:21 +0200
commitefbefb1b7df99fd6192e5031c6b2a0c57a9d0675 (patch)
tree5b5d00686dd2e4241d99aefb1d80ab2035b8ff81 /android
parent6462dd1d00ea924d99deb27b1741e76e88045ec2 (diff)
parentcd084f800055a344262e5100d24ebeb500860e83 (diff)
downloadmullvadvpn-efbefb1b7df99fd6192e5031c6b2a0c57a9d0675.tar.xz
mullvadvpn-efbefb1b7df99fd6192e5031c6b2a0c57a9d0675.zip
Merge branch 'ensure-we-update-inappaccountexpiry-notification-droid-1348'
Diffstat (limited to 'android')
-rw-r--r--android/app/src/androidTest/kotlin/net/mullvad/mullvadvpn/compose/screen/ConnectScreenTest.kt11
-rw-r--r--android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/component/notificationbanner/NotificationBanner.kt4
-rw-r--r--android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/extensions/ResourcesExtensions.kt38
-rw-r--r--android/app/src/main/kotlin/net/mullvad/mullvadvpn/di/UiModule.kt4
-rw-r--r--android/app/src/main/kotlin/net/mullvad/mullvadvpn/repository/InAppNotificationController.kt10
-rw-r--r--android/app/src/main/kotlin/net/mullvad/mullvadvpn/usecase/AccountExpiryInAppNotificationUseCase.kt31
-rw-r--r--android/app/src/main/kotlin/net/mullvad/mullvadvpn/usecase/AccountExpiryNotificationUseCase.kt29
-rw-r--r--android/app/src/test/kotlin/net/mullvad/mullvadvpn/InAppNotificationControllerTest.kt12
-rw-r--r--android/app/src/test/kotlin/net/mullvad/mullvadvpn/usecase/AccountExpiryInAppNotificationUseCaseTest.kt153
-rw-r--r--android/app/src/test/kotlin/net/mullvad/mullvadvpn/usecase/AccountExpiryNotificationUseCaseTest.kt73
-rw-r--r--android/app/src/test/kotlin/net/mullvad/mullvadvpn/usecase/OutOfTimeUseCaseTest.kt8
-rw-r--r--android/service/src/main/kotlin/net/mullvad/mullvadvpn/service/notifications/accountexpiry/AccountExpiryAndroidNotification.kt2
-rw-r--r--android/service/src/main/kotlin/net/mullvad/mullvadvpn/service/notifications/accountexpiry/AccountExpiryConstant.kt10
-rw-r--r--android/service/src/main/kotlin/net/mullvad/mullvadvpn/service/notifications/accountexpiry/AccountExpiryNotificationProvider.kt4
-rw-r--r--android/service/src/main/kotlin/net/mullvad/mullvadvpn/service/notifications/accountexpiry/AccountExpiryTickerFlow.kt60
15 files changed, 293 insertions, 156 deletions
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 1ae19c534a..f55e07c1cf 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 ebed6ea462..a2485f2e99 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 367c1b54af..11b41dd27a 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 48dada2d61..89c4e1e68d 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 821fe769c2..1608e3689e 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 0000000000..004dd44351
--- /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<List<InAppNotification>> =
+ 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<InAppNotification?>(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 d3490692f0..0000000000
--- 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<List<InAppNotification>> =
- 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 84ec047f06..74b599da97 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 0000000000..0749cc62b4
--- /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<AccountData?>(null)
+ private lateinit var accountExpiryInAppNotificationUseCase:
+ AccountExpiryInAppNotificationUseCase
+
+ private lateinit var notificationThreshold: DateTime
+
+ @BeforeEach
+ fun setup() {
+ MockKAnnotations.init(this)
+
+ val accountRepository = mockk<AccountRepository>()
+ 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<InAppNotification>,
+ ) {
+ 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<InAppNotification>): 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 f8a0e52a3e..0000000000
--- 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<AccountData?>(null)
- private lateinit var accountExpiryNotificationUseCase: AccountExpiryNotificationUseCase
-
- @BeforeEach
- fun setup() {
- MockKAnnotations.init(this)
-
- val accountRepository = mockk<AccountRepository>()
- 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 d8438cafe0..e12c2a1d88 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 0e8a1a528f..d6d18fd58a 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 7ca2b33a22..d5ba20e30d 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 3250054459..a6a2f80d06 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 0000000000..c3a444f5ed
--- /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<Duration> = 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