summaryrefslogtreecommitdiffhomepage
path: root/android
diff options
context:
space:
mode:
authorKalle Lindström <karl.lindstrom@mullvad.net>2024-11-13 16:32:11 +0100
committerDavid Göransson <david.goransson@mullvad.net>2024-11-18 09:07:15 +0100
commit9e463290801047a80bd68b337c33040ff99f1828 (patch)
tree5e54b56944242bfe7f5d90ee8d524a109733001c /android
parenta75e50e64701835f263b1ba66133891781843a35 (diff)
downloadmullvadvpn-9e463290801047a80bd68b337c33040ff99f1828.tar.xz
mullvadvpn-9e463290801047a80bd68b337c33040ff99f1828.zip
Use ticker flow for Android system notifications
Diffstat (limited to 'android')
-rw-r--r--android/CHANGELOG.md4
-rw-r--r--android/app/src/main/kotlin/net/mullvad/mullvadvpn/usecase/AccountExpiryInAppNotificationUseCase.kt15
-rw-r--r--android/app/src/test/kotlin/net/mullvad/mullvadvpn/AccountExpiryNotificationProviderTest.kt204
-rw-r--r--android/app/src/test/kotlin/net/mullvad/mullvadvpn/usecase/AccountExpiryInAppNotificationUseCaseTest.kt25
-rw-r--r--android/service/src/main/kotlin/net/mullvad/mullvadvpn/service/notifications/accountexpiry/AccountExpiryAndroidNotification.kt1
-rw-r--r--android/service/src/main/kotlin/net/mullvad/mullvadvpn/service/notifications/accountexpiry/AccountExpiryConstant.kt1
-rw-r--r--android/service/src/main/kotlin/net/mullvad/mullvadvpn/service/notifications/accountexpiry/AccountExpiryNotificationProvider.kt71
-rw-r--r--android/service/src/main/kotlin/net/mullvad/mullvadvpn/service/notifications/accountexpiry/AccountExpiryTicker.kt71
-rw-r--r--android/service/src/main/kotlin/net/mullvad/mullvadvpn/service/notifications/accountexpiry/AccountExpiryTickerFlow.kt62
9 files changed, 342 insertions, 112 deletions
diff --git a/android/CHANGELOG.md b/android/CHANGELOG.md
index ba6f2e9255..ac65d38672 100644
--- a/android/CHANGELOG.md
+++ b/android/CHANGELOG.md
@@ -29,6 +29,10 @@ Line wrap the file at 100 chars. Th
### Changed
- Animation has been changed to look better with predictive back.
+### Fixed
+- Fix a bug where the Android account expiry notifications would not be updated if the app was
+ running in the background for a long time.
+
## [android/2024.8] - 2024-11-01
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
index 014f07bf35..057494f762 100644
--- a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/usecase/AccountExpiryInAppNotificationUseCase.kt
+++ b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/usecase/AccountExpiryInAppNotificationUseCase.kt
@@ -9,7 +9,7 @@ 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
+import net.mullvad.mullvadvpn.service.notifications.accountexpiry.AccountExpiryTicker
class AccountExpiryInAppNotificationUseCase(private val accountRepository: AccountRepository) {
@@ -18,20 +18,21 @@ class AccountExpiryInAppNotificationUseCase(private val accountRepository: Accou
accountRepository.accountData
.flatMapLatest { accountData ->
if (accountData != null) {
- expiryTickerFlow(
+ AccountExpiryTicker.tickerFlow(
expiry = accountData.expiryDate,
tickStart = ACCOUNT_EXPIRY_CLOSE_TO_EXPIRY_THRESHOLD,
updateInterval = { ACCOUNT_EXPIRY_IN_APP_NOTIFICATION_UPDATE_INTERVAL },
)
- .map {
- it?.let { expiresInPeriod ->
- InAppNotification.AccountExpiry(expiresInPeriod)
+ .map { tick ->
+ when (tick) {
+ AccountExpiryTicker.NotWithinThreshold -> emptyList()
+ is AccountExpiryTicker.Tick ->
+ listOf(InAppNotification.AccountExpiry(tick.expiresIn))
}
}
} else {
- flowOf(null)
+ flowOf(emptyList())
}
}
- .map(::listOfNotNull)
.distinctUntilChanged()
}
diff --git a/android/app/src/test/kotlin/net/mullvad/mullvadvpn/AccountExpiryNotificationProviderTest.kt b/android/app/src/test/kotlin/net/mullvad/mullvadvpn/AccountExpiryNotificationProviderTest.kt
new file mode 100644
index 0000000000..d830f407ab
--- /dev/null
+++ b/android/app/src/test/kotlin/net/mullvad/mullvadvpn/AccountExpiryNotificationProviderTest.kt
@@ -0,0 +1,204 @@
+package net.mullvad.mullvadvpn
+
+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.minutes
+import kotlin.time.Duration.Companion.seconds
+import kotlinx.coroutines.ExperimentalCoroutinesApi
+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.model.DeviceState
+import net.mullvad.mullvadvpn.lib.model.Notification
+import net.mullvad.mullvadvpn.lib.model.NotificationChannelId
+import net.mullvad.mullvadvpn.lib.model.NotificationUpdate
+import net.mullvad.mullvadvpn.lib.model.NotificationUpdate.Cancel
+import net.mullvad.mullvadvpn.lib.model.NotificationUpdate.Notify
+import net.mullvad.mullvadvpn.lib.shared.AccountRepository
+import net.mullvad.mullvadvpn.lib.shared.DeviceRepository
+import net.mullvad.mullvadvpn.service.notifications.accountexpiry.ACCOUNT_EXPIRY_CLOSE_TO_EXPIRY_THRESHOLD
+import net.mullvad.mullvadvpn.service.notifications.accountexpiry.AccountExpiryNotificationProvider
+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
+import org.junit.jupiter.api.extension.ExtendWith
+
+@ExperimentalCoroutinesApi
+@ExtendWith(TestCoroutineRule::class)
+class AccountExpiryNotificationProviderTest {
+
+ private lateinit var provider: AccountExpiryNotificationProvider
+
+ private val accountData = MutableStateFlow<AccountData?>(null)
+ private val deviceState = MutableStateFlow<DeviceState?>(null)
+ private val isNewDevice = MutableStateFlow(true)
+
+ @BeforeEach
+ fun setup() {
+ MockKAnnotations.init(this)
+
+ val accountRepository = mockk<AccountRepository>(relaxed = true)
+ every { accountRepository.accountData } returns accountData
+ every { accountRepository.isNewAccount } returns isNewDevice
+
+ val deviceRepository = mockk<DeviceRepository>(relaxed = true)
+ every { deviceRepository.deviceState } returns deviceState
+
+ provider =
+ AccountExpiryNotificationProvider(
+ channelId = NotificationChannelId("channelId"),
+ accountRepository = accountRepository,
+ deviceRepository = deviceRepository,
+ )
+
+ deviceState.value = DeviceState.LoggedIn(mockk(relaxed = true), mockk(relaxed = true))
+ isNewDevice.value = false
+ }
+
+ @AfterEach
+ fun teardown() {
+ unmockkAll()
+ }
+
+ @Test
+ fun `should not emit notification in initial state`() = runTest {
+ accountData.value = null
+ deviceState.value = null
+ isNewDevice.value = true
+ provider.notifications.test { expectNoEvents() }
+ }
+
+ @Test
+ fun `should emit notification if expiry time is shorter than expiry warning threshold`() =
+ runTest {
+ setExpiry(DateTime.now().plus(ACCOUNT_EXPIRY_CLOSE_TO_EXPIRY_THRESHOLD).minusDays(1))
+ provider.notifications.test {
+ assertTrue(awaitItem() is Notify)
+ expectNoEvents()
+ }
+ }
+
+ @Test
+ fun `should emit cancel notification if user account is new`() = runTest {
+ isNewDevice.value = true
+ setExpiry(DateTime.now().plus(ACCOUNT_EXPIRY_CLOSE_TO_EXPIRY_THRESHOLD).minusDays(1))
+ provider.notifications.test {
+ assertTrue(awaitItem() is Cancel)
+ expectNoEvents()
+ }
+ }
+
+ @Test
+ fun `should emit cancel notification if user account is logged out`() = runTest {
+ setIsLoggedIn(false)
+ setExpiry(DateTime.now().plus(ACCOUNT_EXPIRY_CLOSE_TO_EXPIRY_THRESHOLD).minusDays(1))
+ provider.notifications.test {
+ assertTrue(awaitItem() is Cancel)
+ expectNoEvents()
+
+ setIsLoggedIn(true)
+ assertTrue(awaitItem() is Notify)
+ expectNoEvents()
+
+ setIsLoggedIn(false)
+ assertTrue(awaitItem() is Cancel)
+ expectNoEvents()
+ }
+ }
+
+ @Test
+ fun `should emit zero duration notification when remaining time runs out`() = runTest {
+ setExpiry(DateTime.now().plus(Duration.standardSeconds(60)))
+ provider.notifications.test {
+ assertTrue(awaitItem() is Notify)
+ expectNoEvents()
+
+ advanceTimeBy(59.seconds)
+ expectNoEvents()
+
+ advanceTimeBy(2.seconds)
+ val item = getAccountExpiry(awaitItem())
+ assertEquals(item.durationUntilExpiry, Duration.ZERO)
+ expectNoEvents()
+ }
+ }
+
+ @Test
+ fun `should emit notification when update interval is passed`() = runTest {
+ setExpiry(
+ DateTime.now().plus(ACCOUNT_EXPIRY_CLOSE_TO_EXPIRY_THRESHOLD).minusDays(1).plusHours(1)
+ )
+ provider.notifications.test {
+ assertTrue(awaitItem() is Notify)
+ expectNoEvents()
+
+ advanceTimeBy(59.minutes)
+ expectNoEvents()
+
+ advanceTimeBy(1.minutes + 1.seconds)
+ assertTrue(awaitItem() is Notify)
+ expectNoEvents()
+ }
+ }
+
+ @Test
+ fun `should cancel existing notification if more time is added to account`() = runTest {
+ setExpiry(DateTime.now().plus(ACCOUNT_EXPIRY_CLOSE_TO_EXPIRY_THRESHOLD).minusDays(1))
+ provider.notifications.test {
+ assertTrue(awaitItem() is Notify)
+ expectNoEvents()
+
+ setExpiry(DateTime.now().plus(ACCOUNT_EXPIRY_CLOSE_TO_EXPIRY_THRESHOLD).plusDays(1))
+ assertTrue(awaitItem() is Cancel)
+ expectNoEvents()
+ }
+ }
+
+ @Test
+ fun `should not cancel existing notification if too little time is added`() = runTest {
+ setExpiry(DateTime.now().plus(ACCOUNT_EXPIRY_CLOSE_TO_EXPIRY_THRESHOLD).minusDays(1))
+ provider.notifications.test {
+ assertTrue(awaitItem() is Notify)
+ expectNoEvents()
+
+ setExpiry(DateTime.now().plus(ACCOUNT_EXPIRY_CLOSE_TO_EXPIRY_THRESHOLD).minusHours(1))
+ assertTrue(awaitItem() is Notify)
+ expectNoEvents()
+ }
+ }
+
+ private fun getAccountExpiry(
+ awaitItem: NotificationUpdate<Notification.AccountExpiry>
+ ): Notification.AccountExpiry =
+ when (awaitItem) {
+ is Cancel -> error("expected AccountExpiry, was Cancel")
+ is Notify -> awaitItem.value
+ }
+
+ private fun setExpiry(expiryDateTime: DateTime): DateTime {
+ val expiry = AccountData(mockk(relaxed = true), expiryDateTime)
+ accountData.value = expiry
+ return expiryDateTime
+ }
+
+ private fun setIsLoggedIn(isLoggedIn: Boolean) {
+ deviceState.value =
+ if (isLoggedIn) {
+ DeviceState.LoggedIn(
+ accountNumber = mockk(relaxed = true),
+ device = mockk(relaxed = true),
+ )
+ } else {
+ DeviceState.LoggedOut
+ }
+ }
+}
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
index df337a5911..316d12addd 100644
--- a/android/app/src/test/kotlin/net/mullvad/mullvadvpn/usecase/AccountExpiryInAppNotificationUseCaseTest.kt
+++ b/android/app/src/test/kotlin/net/mullvad/mullvadvpn/usecase/AccountExpiryInAppNotificationUseCaseTest.kt
@@ -21,7 +21,6 @@ import net.mullvad.mullvadvpn.service.notifications.accountexpiry.ACCOUNT_EXPIRY
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
@@ -64,7 +63,7 @@ class AccountExpiryInAppNotificationUseCaseTest {
accountExpiryInAppNotificationUseCase().test {
assertTrue { awaitItem().isEmpty() }
val expiry = setExpiry(notificationThreshold.minusHours(1))
- assertExpiryNotificationAndPeriod(expiry, expectMostRecentItem())
+ assertExpiryNotificationDuration(expiry, expectMostRecentItem())
expectNoEvents()
}
}
@@ -91,17 +90,17 @@ class AccountExpiryInAppNotificationUseCaseTest {
// Advance to after threshold
advanceTimeBy(2.seconds)
- assertExpiryNotificationAndPeriod(expiry, expectMostRecentItem())
+ assertExpiryNotificationDuration(expiry, expectMostRecentItem())
expectNoEvents()
}
}
@Test
- fun `should emit zero period when the time expires`() = runTest {
+ fun `should emit zero duration when the time expires`() = runTest {
accountExpiryInAppNotificationUseCase().test {
assertTrue { awaitItem().isEmpty() }
- // Set expiry to to be in the final update period.
+ // Set expiry to to be in the final update interval.
val inLastUpdate =
DateTime.now()
.plus(ACCOUNT_EXPIRY_IN_APP_NOTIFICATION_UPDATE_INTERVAL)
@@ -110,7 +109,7 @@ class AccountExpiryInAppNotificationUseCaseTest {
// The expiry time is within the notification threshold so we should have an item
// immediately.
- assertExpiryNotificationAndPeriod(expiry, expectMostRecentItem())
+ assertExpiryNotificationDuration(expiry, expectMostRecentItem())
expectNoEvents()
// Advance past the delay before the while loop:
@@ -132,18 +131,16 @@ class AccountExpiryInAppNotificationUseCaseTest {
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(
+ // Assert that we got a single AccountExpiry notification and that the expiry duration is within
+ // the expected range (checking exact duration value is not possible since we use DateTime.now)
+ private fun assertExpiryNotificationDuration(
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
- )
+ val expiresFromNow = Duration(DateTime.now(), expiry)
+ assertTrue(expiresFromNow <= notificationDuration)
+ assertTrue(expiresFromNow.plus(Duration.standardSeconds(5)) > notificationDuration)
}
private fun getExpiryNotificationDuration(notifications: List<InAppNotification>): Duration {
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 d6d18fd58a..db9ee92ae7 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
@@ -18,6 +18,7 @@ internal fun Notification.AccountExpiry.toNotification(context: Context) =
.setContentTitle(context.resources.contentTitle(durationUntilExpiry))
.setSmallIcon(R.drawable.small_logo_white)
.setOngoing(ongoing)
+ .setOnlyAlertOnce(true)
.setVisibility(NotificationCompat.VISIBILITY_PRIVATE)
.build()
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 d5ba20e30d..32190968b9 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
@@ -7,4 +7,5 @@ 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_SYSTEM_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 a6a2f80d06..b513b490e0 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
@@ -1,8 +1,12 @@
package net.mullvad.mullvadvpn.service.notifications.accountexpiry
+import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.filterNotNull
+import kotlinx.coroutines.flow.flatMapLatest
+import kotlinx.coroutines.flow.flowOf
+import kotlinx.coroutines.flow.map
import net.mullvad.mullvadvpn.lib.model.DeviceState
import net.mullvad.mullvadvpn.lib.model.Notification
import net.mullvad.mullvadvpn.lib.model.NotificationChannelId
@@ -13,48 +17,57 @@ import net.mullvad.mullvadvpn.lib.shared.DeviceRepository
import net.mullvad.mullvadvpn.service.constant.IS_PLAY_BUILD
import net.mullvad.mullvadvpn.service.notifications.NotificationProvider
import org.joda.time.DateTime
-import org.joda.time.Duration
class AccountExpiryNotificationProvider(
- channelId: NotificationChannelId,
- accountRepository: AccountRepository,
+ private val channelId: NotificationChannelId,
+ private val accountRepository: AccountRepository,
deviceRepository: DeviceRepository,
) : NotificationProvider<Notification.AccountExpiry> {
- private val notificationId = NotificationId(3)
+ @Suppress("MagicNumber") private val notificationId = NotificationId(3)
+ @OptIn(ExperimentalCoroutinesApi::class)
override val notifications: Flow<NotificationUpdate<Notification.AccountExpiry>> =
combine(
- deviceRepository.deviceState,
+ deviceRepository.deviceState.filterNotNull(),
accountRepository.accountData.filterNotNull(),
accountRepository.isNewAccount,
) { deviceState, accountData, isNewAccount ->
- if (deviceState !is DeviceState.LoggedIn) {
- return@combine NotificationUpdate.Cancel(notificationId)
- }
-
- val durationUntilExpiry = accountData.expiryDate.remainingTime()
+ Triple(deviceState, accountData, isNewAccount)
+ }
+ .flatMapLatest { (deviceState, accountData, isNewAccount) ->
+ val expiry = accountData.expiryDate
- val notification =
- Notification.AccountExpiry(
- channelId = channelId,
- actions = emptyList(),
- websiteAuthToken =
- if (!IS_PLAY_BUILD) accountRepository.getWebsiteAuthToken() else null,
- durationUntilExpiry = durationUntilExpiry,
- )
- if (!isNewAccount && durationUntilExpiry.isCloseToExpiry()) {
- NotificationUpdate.Notify(notificationId, notification)
+ if (isNewAccount || deviceState !is DeviceState.LoggedIn) {
+ flowOf(NotificationUpdate.Cancel(notificationId))
} else {
- NotificationUpdate.Cancel(notificationId)
+ accountExpiryNotificationFlow(expiry)
}
}
- .filterNotNull()
- private fun DateTime.remainingTime(): Duration {
- return Duration(DateTime.now(), this)
- }
-
- private fun Duration.isCloseToExpiry(): Boolean {
- return isShorterThan(ACCOUNT_EXPIRY_CLOSE_TO_EXPIRY_THRESHOLD)
- }
+ private fun accountExpiryNotificationFlow(
+ expiryDate: DateTime
+ ): Flow<NotificationUpdate<Notification.AccountExpiry>> =
+ AccountExpiryTicker.tickerFlow(
+ expiry = expiryDate,
+ tickStart = ACCOUNT_EXPIRY_CLOSE_TO_EXPIRY_THRESHOLD,
+ updateInterval = { ACCOUNT_EXPIRY_SYSTEM_NOTIFICATION_UPDATE_INTERVAL },
+ )
+ .map { expiryTick ->
+ when (expiryTick) {
+ AccountExpiryTicker.NotWithinThreshold ->
+ NotificationUpdate.Cancel(notificationId)
+ is AccountExpiryTicker.Tick -> {
+ val notification =
+ Notification.AccountExpiry(
+ channelId = channelId,
+ actions = emptyList(),
+ websiteAuthToken =
+ if (!IS_PLAY_BUILD) accountRepository.getWebsiteAuthToken()
+ else null,
+ durationUntilExpiry = expiryTick.expiresIn,
+ )
+ NotificationUpdate.Notify(notificationId, notification)
+ }
+ }
+ }
}
diff --git a/android/service/src/main/kotlin/net/mullvad/mullvadvpn/service/notifications/accountexpiry/AccountExpiryTicker.kt b/android/service/src/main/kotlin/net/mullvad/mullvadvpn/service/notifications/accountexpiry/AccountExpiryTicker.kt
new file mode 100644
index 0000000000..6add0a372a
--- /dev/null
+++ b/android/service/src/main/kotlin/net/mullvad/mullvadvpn/service/notifications/accountexpiry/AccountExpiryTicker.kt
@@ -0,0 +1,71 @@
+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
+
+sealed interface AccountExpiryTicker {
+ data object NotWithinThreshold : AccountExpiryTicker
+
+ data class Tick(val expiresIn: Duration) : AccountExpiryTicker
+
+ companion object {
+ fun tickerFlow(
+ expiry: DateTime,
+ tickStart: Duration,
+ updateInterval: (expiry: DateTime) -> Duration,
+ ): Flow<AccountExpiryTicker> = flow {
+ expiry.millisFromNow().let { expiryMillis ->
+ if (expiryMillis <= 0) {
+ // Has expired.
+ emit(Tick(Duration.ZERO))
+ return@flow
+ }
+ if (expiryMillis > tickStart.millis) {
+ // Emit NotWithinThreshold if no expiry notification should be provided.
+ emit(NotWithinThreshold)
+ // Delay until the time we should start emitting.
+ delay(expiryMillis - tickStart.millis + 1)
+ }
+ }
+
+ var currentUpdateInterval = updateInterval(expiry).millis
+
+ do {
+ emit(Tick(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(Tick(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
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
deleted file mode 100644
index 3683096c80..0000000000
--- a/android/service/src/main/kotlin/net/mullvad/mullvadvpn/service/notifications/accountexpiry/AccountExpiryTickerFlow.kt
+++ /dev/null
@@ -1,62 +0,0 @@
-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) {
- // Emit null if no expiry notification should be provided.
- emit(null)
- // 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