Skip to content

Commit

Permalink
Use ticker flow for Android system notifications
Browse files Browse the repository at this point in the history
  • Loading branch information
kl committed Nov 14, 2024
1 parent e763bf5 commit 0ba6a40
Show file tree
Hide file tree
Showing 9 changed files with 342 additions and 113 deletions.
4 changes: 4 additions & 0 deletions android/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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) {

Expand All @@ -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()
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,203 @@
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 `initial state should not emit notification`() = runTest {
accountData.value = null
deviceState.value = null
isNewDevice.value = true
provider.notifications.test { expectNoEvents() }
}

@Test
fun `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 `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 `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 `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 `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 `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 `do 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
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -64,7 +63,7 @@ class AccountExpiryInAppNotificationUseCaseTest {
accountExpiryInAppNotificationUseCase().test {
assertTrue { awaitItem().isEmpty() }
val expiry = setExpiry(notificationThreshold.minusHours(1))
assertExpiryNotificationAndPeriod(expiry, expectMostRecentItem())
assertExpiryNotificationDuration(expiry, expectMostRecentItem())
expectNoEvents()
}
}
Expand All @@ -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)
Expand All @@ -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:
Expand All @@ -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 {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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()

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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_CLOSE_TO_EXPIRY_THRESHOLD: Duration = Duration.standardDays(3)
val ACCOUNT_EXPIRY_SYSTEM_NOTIFICATION_UPDATE_INTERVAL: Duration = Duration.standardDays(1)
val ACCOUNT_EXPIRY_CLOSE_TO_EXPIRY_THRESHOLD: Duration = Duration.standardDays(30)
Loading

0 comments on commit 0ba6a40

Please sign in to comment.