Skip to content

Commit

Permalink
Update in app expiry notifications over time
Browse files Browse the repository at this point in the history
  • Loading branch information
kl committed Sep 26, 2024
1 parent d75f793 commit 10d59ed
Show file tree
Hide file tree
Showing 8 changed files with 179 additions and 62 deletions.
Original file line number Diff line number Diff line change
Expand Up @@ -37,7 +37,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.Period

@Preview
@Composable
Expand All @@ -49,7 +49,7 @@ private fun PreviewNotificationBanner() {
InAppNotification.UnsupportedVersion(
versionInfo = VersionInfo(currentVersion = "1.0", isSupported = false)
),
InAppNotification.AccountExpiry(expiry = DateTime.now()),
InAppNotification.AccountExpiry(expiry = Period.ZERO),
InAppNotification.TunnelStateBlocked,
InAppNotification.NewDevice("Courageous Turtle"),
InAppNotification.TunnelStateError(
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2,36 +2,19 @@ package net.mullvad.mullvadvpn.compose.extensions

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: Period): String {
return if (accountExpiry == Period.ZERO) {
getString(R.string.out_of_time)
} else if (accountExpiry.years > 0) {
getRemainingText(this, R.plurals.years_left, accountExpiry.years)
} else if (accountExpiry.months >= 3) {
getRemainingText(this, R.plurals.months_left, accountExpiry.months)
} else if (accountExpiry.months > 0 || accountExpiry.days >= 1) {
getRemainingText(this, R.plurals.days_left, accountExpiry.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)
}
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@ import net.mullvad.mullvadvpn.usecase.AccountExpiryNotificationUseCase
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.Period

enum class StatusLevel {
Error,
Expand All @@ -38,7 +38,7 @@ sealed class InAppNotification {
override val priority: Long = 999
}

data class AccountExpiry(val expiry: DateTime) : InAppNotification() {
data class AccountExpiry(val expiry: Period) : InAppNotification() {
override val statusLevel = StatusLevel.Warning
override val priority: Long = 1001
}
Expand Down
Original file line number Diff line number Diff line change
@@ -1,29 +1,69 @@
package net.mullvad.mullvadvpn.usecase

import kotlin.math.roundToInt
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.distinctUntilChanged
import kotlinx.coroutines.flow.flatMapLatest
import kotlinx.coroutines.flow.flow
import kotlinx.coroutines.flow.flowOf
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 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

class AccountExpiryNotificationUseCase(private val accountRepository: AccountRepository) {

operator fun invoke(): Flow<List<InAppNotification>> =
accountRepository.accountData
.map(::accountExpiryNotification)
.flatMapLatest { accountData ->
if (accountData != null) {
flow {
val expiry = accountData.expiryDate

expiresInMillis(expiry).let { millis ->
if (millis <= 0) {
// has expired
emit(InAppNotification.AccountExpiry(Period.ZERO))
return@flow
}
delayUntilNotificationThreshold(millis)
}

emit(InAppNotification.AccountExpiry(Period(DateTime.now(), expiry)))

val updateMillis = ACCOUNT_EXPIRY_IN_APP_NOTIFICATION_UPDATE_INTERVAL.millis

// Delay until the next update interval
delay(expiresInMillis(expiry) % updateMillis)

// Calculate how many times we need to delay and show the notification
// until the expiry time is reached.
val delayCount =
(expiresInMillis(expiry).toDouble() / updateMillis).roundToInt()

repeat(delayCount) {
emit(InAppNotification.AccountExpiry(Period(DateTime.now(), expiry)))
delay(updateMillis)
}

emit(InAppNotification.AccountExpiry(Period.ZERO))
}
} else {
flowOf<InAppNotification?>(null)
}
}
.map(::listOfNotNull)
.distinctUntilChanged()

private fun accountExpiryNotification(accountData: AccountData?) =
if (accountData != null && accountData.expiryDate.isCloseToExpiring()) {
InAppNotification.AccountExpiry(accountData.expiryDate)
} else null
private fun expiresInMillis(expiry: DateTime): Long = Duration(DateTime.now(), expiry).millis

private fun DateTime.isCloseToExpiring(): Boolean {
val threeDaysFromNow =
DateTime.now().plusDays(ACCOUNT_EXPIRY_CLOSE_TO_EXPIRY_THRESHOLD_DAYS)
return isBefore(threeDaysFromNow)
private suspend fun delayUntilNotificationThreshold(expiresInMillis: Long) {
val thresholdMillis = ACCOUNT_EXPIRY_CLOSE_TO_EXPIRY_THRESHOLD.millis
if (expiresInMillis > thresholdMillis) {
delay(expiresInMillis - thresholdMillis)
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,7 @@ import net.mullvad.mullvadvpn.usecase.AccountExpiryNotificationUseCase
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.Period
import org.junit.jupiter.api.AfterEach
import org.junit.jupiter.api.BeforeEach
import org.junit.jupiter.api.Test
Expand Down Expand Up @@ -79,7 +79,7 @@ class InAppNotificationControllerTest {
val unsupportedVersion = InAppNotification.UnsupportedVersion(mockk())
versionNotifications.value = listOf(unsupportedVersion)

val accountExpiry = InAppNotification.AccountExpiry(DateTime.now())
val accountExpiry = InAppNotification.AccountExpiry(Period.ZERO)
accountExpiryNotifications.value = listOf(accountExpiry)

inAppNotificationController.notifications.test {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -5,15 +5,23 @@ import io.mockk.MockKAnnotations
import io.mockk.every
import io.mockk.mockk
import io.mockk.unmockkAll
import kotlin.test.assertEquals
import kotlin.math.roundToInt
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.constant.ACCOUNT_EXPIRY_CLOSE_TO_EXPIRY_THRESHOLD
import net.mullvad.mullvadvpn.constant.ACCOUNT_EXPIRY_IN_APP_NOTIFICATION_UPDATE_INTERVAL
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
Expand All @@ -25,6 +33,8 @@ class AccountExpiryNotificationUseCaseTest {
private val accountExpiry = MutableStateFlow<AccountData?>(null)
private lateinit var accountExpiryNotificationUseCase: AccountExpiryNotificationUseCase

private lateinit var notificationThreshold: DateTime

@BeforeEach
fun setup() {
MockKAnnotations.init(this)
Expand All @@ -33,6 +43,8 @@ class AccountExpiryNotificationUseCaseTest {
every { accountRepository.accountData } returns accountExpiry

accountExpiryNotificationUseCase = AccountExpiryNotificationUseCase(accountRepository)

notificationThreshold = DateTime.now().plus(ACCOUNT_EXPIRY_CLOSE_TO_EXPIRY_THRESHOLD)
}

@AfterEach
Expand All @@ -47,27 +59,106 @@ class AccountExpiryNotificationUseCaseTest {
}

@Test
fun `account that expires within 3 days should emit a notification`() = runTest {
fun `account that expires within the threshold 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
val expiry = setExpiry(notificationThreshold.minusHours(1))
assertExpiryNotificationAndPeriod(expiry, expectMostRecentItem())
expectNoEvents()
}
}

assertEquals(
listOf(InAppNotification.AccountExpiry(closeToExpiry.expiryDate)),
awaitItem(),
)
@Test
fun `account that expires after the threshold should not emit a notification`() = runTest {
// Arrange, Act, Assert
accountExpiryNotificationUseCase().test {
assertTrue { awaitItem().isEmpty() }
setExpiry(notificationThreshold.plusDays(1))
expectNoEvents()
}
}

@Test
fun `account that expires in 4 days should not emit a notification`() = runTest {
fun `should emit when the threshold is passed`() = runTest {
// Arrange, Act, Assert
accountExpiryNotificationUseCase().test {
assertTrue { awaitItem().isEmpty() }
accountExpiry.value = AccountData(mockk(relaxed = true), DateTime.now().plusDays(4))
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 remaining time divided by update interval time times`() = runTest {
// Arrange, Act, Assert
accountExpiryNotificationUseCase().test {
assertTrue { awaitItem().isEmpty() }

// Set expiry to to be 1 second before the end of the first update interval
val beforeUpdate =
notificationThreshold
.minus(ACCOUNT_EXPIRY_IN_APP_NOTIFICATION_UPDATE_INTERVAL)
.plusSeconds(1)
val expiry = setExpiry(beforeUpdate)

// The expiry time is within the notification threshold so we should have an item
// immediately.
assertExpiryNotificationAndPeriod(expiry, expectMostRecentItem())
expectNoEvents()

// + 1 because we emit one final time then the account has expired
val expectedEmits =
(Duration(DateTime.now(), beforeUpdate).millis.toDouble() /
ACCOUNT_EXPIRY_IN_APP_NOTIFICATION_UPDATE_INTERVAL.millis)
.roundToInt() + 1

advanceTimeBy(2.seconds)
repeat(expectedEmits) {
expectMostRecentItem()
advanceTimeBy(
ACCOUNT_EXPIRY_IN_APP_NOTIFICATION_UPDATE_INTERVAL.plus(
Duration.standardSeconds(1)
)
.millis
)
}

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>,
) {
assertTrue(notifications.size == 1, "Expected a single notification")
val n = notifications[0]
if (n !is InAppNotification.AccountExpiry) {
error("Expected an AccountExpiry notification")
}
val periodNow = Period(DateTime.now(), expiry)
assertTrue(periodNow.toStandardDuration() <= n.expiry.toStandardDuration())
assertTrue(
periodNow.toStandardDuration().plus(Duration.standardSeconds(5)) >
n.expiry.toStandardDuration()
)
}
}
Original file line number Diff line number Diff line change
@@ -1,4 +1,9 @@
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)
@Suppress("MagicNumber")
val ACCOUNT_EXPIRY_CLOSE_TO_EXPIRY_THRESHOLD: Duration = Duration.standardDays(3)
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}
}

0 comments on commit 10d59ed

Please sign in to comment.