summaryrefslogtreecommitdiffhomepage
path: root/android/service
diff options
context:
space:
mode:
authorKalle Lindström <karl.lindstrom@mullvad.net>2025-06-09 17:16:45 +0200
committerKalle Lindström <karl.lindstrom@mullvad.net>2025-06-19 09:36:26 +0200
commitcdc2c4d700cd41f37cf4b1607a0396019b1fbee5 (patch)
tree10fbfcebf826159d515b5599634ce5b7be256f54 /android/service
parent1748d8a7b3e8044f6b2718d25903bf1b38afad4e (diff)
downloadmullvadvpn-cdc2c4d700cd41f37cf4b1607a0396019b1fbee5.tar.xz
mullvadvpn-cdc2c4d700cd41f37cf4b1607a0396019b1fbee5.zip
Use AlarmManager for notifications
Instead of scheduling system notifications from a flow we now schedule them independently from the app lifecycle via AlarmManager. This is done so that for example an expiry notification that the user dismissed won't get redisplayed if the app process gets killed and then restarted. When the account exiry time is fetched we schedule an alarm that will show a notification 3 days before the account time expires. This alarm then also schedules a new alarm to show the following notification and so on. To make this work this PR also introduces two new broadcast receivers; one on boot received listener and one on time time/timezone changed listener. Beause Android clears alarms when the devices is rebooted/the time is changed we need these listeners to re-trigger the alarm. To enable the broadcast receivers to re-trigger the alarm we also have to persist the expiry time in the DataStore preferences.
Diffstat (limited to 'android/service')
-rw-r--r--android/service/build.gradle.kts10
-rw-r--r--android/service/src/main/kotlin/net/mullvad/mullvadvpn/service/di/VpnServiceModule.kt39
-rw-r--r--android/service/src/main/kotlin/net/mullvad/mullvadvpn/service/notifications/accountexpiry/AccountExpiry.kt30
-rw-r--r--android/service/src/main/kotlin/net/mullvad/mullvadvpn/service/notifications/accountexpiry/AccountExpiryAndroidNotification.kt17
-rw-r--r--android/service/src/main/kotlin/net/mullvad/mullvadvpn/service/notifications/accountexpiry/AccountExpiryConstant.kt11
-rw-r--r--android/service/src/main/kotlin/net/mullvad/mullvadvpn/service/notifications/accountexpiry/AccountExpiryNotificationProvider.kt89
-rw-r--r--android/service/src/main/kotlin/net/mullvad/mullvadvpn/service/notifications/accountexpiry/InAppAccountExpiryTicker.kt (renamed from android/service/src/main/kotlin/net/mullvad/mullvadvpn/service/notifications/accountexpiry/AccountExpiryTicker.kt)8
-rw-r--r--android/service/src/test/kotlin/net/mullvad/mullvadvpn/service/notifications/accountexpiry/AccountExpiryNotificationTriggerTest.kt71
8 files changed, 150 insertions, 125 deletions
diff --git a/android/service/build.gradle.kts b/android/service/build.gradle.kts
index 5ec1fa7d84..f7d214570f 100644
--- a/android/service/build.gradle.kts
+++ b/android/service/build.gradle.kts
@@ -2,6 +2,7 @@ plugins {
alias(libs.plugins.android.library)
alias(libs.plugins.kotlin.android)
alias(libs.plugins.kotlin.parcelize)
+ alias(libs.plugins.junit5.android)
}
android {
@@ -79,4 +80,13 @@ dependencies {
implementation(libs.koin.android)
implementation(libs.kotlin.stdlib)
implementation(libs.kotlinx.coroutines.android)
+
+ testImplementation(libs.kotlin.test)
+ testImplementation(libs.kotlinx.coroutines.test)
+ testImplementation(libs.mockk)
+ testImplementation(libs.junit.jupiter.api)
+ testImplementation(libs.junit.jupiter.params)
+ testImplementation(libs.turbine)
+ testImplementation(projects.lib.commonTest)
+ testRuntimeOnly(libs.junit.jupiter.engine)
}
diff --git a/android/service/src/main/kotlin/net/mullvad/mullvadvpn/service/di/VpnServiceModule.kt b/android/service/src/main/kotlin/net/mullvad/mullvadvpn/service/di/VpnServiceModule.kt
index c33b8ef518..f5ddd24578 100644
--- a/android/service/src/main/kotlin/net/mullvad/mullvadvpn/service/di/VpnServiceModule.kt
+++ b/android/service/src/main/kotlin/net/mullvad/mullvadvpn/service/di/VpnServiceModule.kt
@@ -1,59 +1,20 @@
package net.mullvad.mullvadvpn.service.di
-import androidx.core.app.NotificationManagerCompat
-import kotlinx.coroutines.MainScope
import net.mullvad.mullvadvpn.lib.common.constant.CACHE_DIR_NAMED_ARGUMENT
import net.mullvad.mullvadvpn.lib.common.constant.FILES_DIR_NAMED_ARGUMENT
import net.mullvad.mullvadvpn.lib.common.constant.GRPC_SOCKET_FILE_NAMED_ARGUMENT
import net.mullvad.mullvadvpn.lib.endpoint.ApiEndpointOverride
-import net.mullvad.mullvadvpn.lib.model.NotificationChannel
import net.mullvad.mullvadvpn.service.BuildConfig
import net.mullvad.mullvadvpn.service.DaemonConfig
import net.mullvad.mullvadvpn.service.migration.MigrateSplitTunneling
-import net.mullvad.mullvadvpn.service.notifications.NotificationChannelFactory
-import net.mullvad.mullvadvpn.service.notifications.NotificationManager
-import net.mullvad.mullvadvpn.service.notifications.NotificationProvider
-import net.mullvad.mullvadvpn.service.notifications.accountexpiry.AccountExpiryNotificationProvider
-import net.mullvad.mullvadvpn.service.notifications.tunnelstate.TunnelStateNotificationProvider
import org.koin.android.ext.koin.androidContext
-import org.koin.core.module.dsl.createdAtStart
-import org.koin.core.module.dsl.withOptions
import org.koin.core.qualifier.named
-import org.koin.dsl.bind
import org.koin.dsl.module
val vpnServiceModule = module {
- single { NotificationManagerCompat.from(androidContext()) }
- single { androidContext().resources }
single(named(FILES_DIR_NAMED_ARGUMENT)) { androidContext().filesDir }
single(named(CACHE_DIR_NAMED_ARGUMENT)) { androidContext().cacheDir }
- single { NotificationChannel.TunnelUpdates } bind NotificationChannel::class
- single { NotificationChannel.AccountUpdates } bind NotificationChannel::class
- single { NotificationChannelFactory(get(), get(), getAll()) } withOptions { createdAtStart() }
-
- single {
- TunnelStateNotificationProvider(
- get(),
- get(),
- get(),
- get<NotificationChannel.TunnelUpdates>().id,
- MainScope(),
- )
- } bind NotificationProvider::class
- single {
- AccountExpiryNotificationProvider(
- get<NotificationChannel.AccountUpdates>().id,
- get(),
- get(),
- )
- } bind NotificationProvider::class
-
- single { NotificationManager(get(), getAll(), get(), MainScope()) } withOptions
- {
- createdAtStart()
- }
-
single { MigrateSplitTunneling(androidContext()) }
single {
diff --git a/android/service/src/main/kotlin/net/mullvad/mullvadvpn/service/notifications/accountexpiry/AccountExpiry.kt b/android/service/src/main/kotlin/net/mullvad/mullvadvpn/service/notifications/accountexpiry/AccountExpiry.kt
new file mode 100644
index 0000000000..d5303e3903
--- /dev/null
+++ b/android/service/src/main/kotlin/net/mullvad/mullvadvpn/service/notifications/accountexpiry/AccountExpiry.kt
@@ -0,0 +1,30 @@
+@file:Suppress("MagicNumber")
+
+package net.mullvad.mullvadvpn.service.notifications.accountexpiry
+
+import java.time.Duration
+import java.time.ZonedDateTime
+import kotlin.time.Duration.Companion.seconds
+
+val ACCOUNT_EXPIRY_POLL_INTERVAL = 15.seconds
+
+// When to start showing the account expiry in-app notification.
+val ACCOUNT_EXPIRY_CLOSE_TO_EXPIRY_THRESHOLD: Duration = Duration.ofDays(3)
+
+// How often to update the account expiry in-app notification.
+val ACCOUNT_EXPIRY_NOTIFICATION_UPDATE_INTERVAL: Duration = Duration.ofDays(1)
+
+// Calculate when the alarm that triggers the account expiry notification should be set.
+fun accountExpiryNotificationTriggerAt(now: ZonedDateTime, expiry: ZonedDateTime): ZonedDateTime {
+ val untilExpiry = Duration.between(now, expiry)
+
+ return if (untilExpiry > ACCOUNT_EXPIRY_CLOSE_TO_EXPIRY_THRESHOLD) {
+ val wait = untilExpiry - ACCOUNT_EXPIRY_CLOSE_TO_EXPIRY_THRESHOLD
+ now + wait
+ } else {
+ val wait = untilExpiry.toMillis() % ACCOUNT_EXPIRY_NOTIFICATION_UPDATE_INTERVAL.toMillis()
+
+ // If the expiry is in the past we just return it as it is.
+ if (wait >= 0) now + Duration.ofMillis(wait) else expiry
+ }
+}
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 a61561c8cc..97212797cd 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
@@ -8,7 +8,6 @@ import androidx.core.app.NotificationCompat
import java.time.Duration
import net.mullvad.mullvadvpn.lib.common.constant.MAIN_ACTIVITY_CLASS
import net.mullvad.mullvadvpn.lib.common.util.SdkUtils
-import net.mullvad.mullvadvpn.lib.common.util.createAccountUri
import net.mullvad.mullvadvpn.lib.model.Notification
import net.mullvad.mullvadvpn.service.R
@@ -22,18 +21,12 @@ internal fun Notification.AccountExpiry.toNotification(context: Context) =
.setVisibility(NotificationCompat.VISIBILITY_PRIVATE)
.build()
-private fun Notification.AccountExpiry.contentIntent(context: Context): PendingIntent {
-
+private fun contentIntent(context: Context): PendingIntent {
val intent =
- if (websiteAuthToken == null) {
- Intent().apply {
- setClassName(context.packageName, MAIN_ACTIVITY_CLASS)
- flags = Intent.FLAG_ACTIVITY_CLEAR_TOP or Intent.FLAG_ACTIVITY_SINGLE_TOP
- action = Intent.ACTION_MAIN
- }
- } else {
- val uri = createAccountUri(context.getString(R.string.account_url), websiteAuthToken)
- Intent(Intent.ACTION_VIEW, uri)
+ Intent().apply {
+ setClassName(context.packageName, MAIN_ACTIVITY_CLASS)
+ flags = Intent.FLAG_ACTIVITY_CLEAR_TOP or Intent.FLAG_ACTIVITY_SINGLE_TOP
+ action = Intent.ACTION_MAIN
}
return PendingIntent.getActivity(context, 1, intent, SdkUtils.getSupportedPendingIntentFlags())
}
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
deleted file mode 100644
index 4ee9c6a533..0000000000
--- a/android/service/src/main/kotlin/net/mullvad/mullvadvpn/service/notifications/accountexpiry/AccountExpiryConstant.kt
+++ /dev/null
@@ -1,11 +0,0 @@
-@file:Suppress("MagicNumber")
-
-package net.mullvad.mullvadvpn.service.notifications.accountexpiry
-
-import java.time.Duration
-import kotlin.time.Duration.Companion.seconds
-
-val ACCOUNT_EXPIRY_POLL_INTERVAL = 15.seconds
-val ACCOUNT_EXPIRY_IN_APP_NOTIFICATION_UPDATE_INTERVAL: Duration = Duration.ofDays(1)
-val ACCOUNT_EXPIRY_SYSTEM_NOTIFICATION_UPDATE_INTERVAL: Duration = Duration.ofDays(1)
-val ACCOUNT_EXPIRY_CLOSE_TO_EXPIRY_THRESHOLD: Duration = Duration.ofDays(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 be0141c3f4..776ce47960 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,73 +1,44 @@
package net.mullvad.mullvadvpn.service.notifications.accountexpiry
-import java.time.ZonedDateTime
-import kotlinx.coroutines.ExperimentalCoroutinesApi
+import co.touchlab.kermit.Logger
+import java.time.Duration
+import kotlinx.coroutines.channels.Channel
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 kotlinx.coroutines.flow.receiveAsFlow
import net.mullvad.mullvadvpn.lib.model.Notification
import net.mullvad.mullvadvpn.lib.model.NotificationChannelId
import net.mullvad.mullvadvpn.lib.model.NotificationId
import net.mullvad.mullvadvpn.lib.model.NotificationUpdate
-import net.mullvad.mullvadvpn.lib.shared.AccountRepository
-import net.mullvad.mullvadvpn.lib.shared.DeviceRepository
-import net.mullvad.mullvadvpn.service.constant.IS_PLAY_BUILD
import net.mullvad.mullvadvpn.service.notifications.NotificationProvider
-class AccountExpiryNotificationProvider(
- private val channelId: NotificationChannelId,
- private val accountRepository: AccountRepository,
- deviceRepository: DeviceRepository,
-) : NotificationProvider<Notification.AccountExpiry> {
- @Suppress("MagicNumber") private val notificationId = NotificationId(3)
+class AccountExpiryNotificationProvider(private val channelId: NotificationChannelId) :
+ NotificationProvider<Notification.AccountExpiry> {
- @OptIn(ExperimentalCoroutinesApi::class)
- override val notifications: Flow<NotificationUpdate<Notification.AccountExpiry>> =
- combine(
- deviceRepository.deviceState.filterNotNull(),
- accountRepository.accountData.filterNotNull(),
- accountRepository.isNewAccount,
- ) { deviceState, accountData, isNewAccount ->
- Triple(deviceState, accountData, isNewAccount)
- }
- .flatMapLatest { (deviceState, accountData, isNewAccount) ->
- val expiry = accountData.expiryDate
+ private val notificationChannel: Channel<NotificationUpdate<Notification.AccountExpiry>> =
+ Channel(Channel.CONFLATED)
- if (isNewAccount || deviceState !is DeviceState.LoggedIn) {
- flowOf(NotificationUpdate.Cancel(notificationId))
- } else {
- accountExpiryNotificationFlow(expiry)
- }
- }
+ override val notifications: Flow<NotificationUpdate<Notification.AccountExpiry>>
+ get() = notificationChannel.receiveAsFlow()
- private fun accountExpiryNotificationFlow(
- expiryDate: ZonedDateTime
- ): Flow<NotificationUpdate<Notification.AccountExpiry>> =
- AccountExpiryTicker.tickerFlow(
- expiry = expiryDate,
- tickStart = ACCOUNT_EXPIRY_CLOSE_TO_EXPIRY_THRESHOLD,
- updateInterval = { ACCOUNT_EXPIRY_SYSTEM_NOTIFICATION_UPDATE_INTERVAL },
+ suspend fun showNotification(durationUntilExpiry: Duration) {
+ val notification =
+ Notification.AccountExpiry(
+ channelId = channelId,
+ actions = emptyList(),
+ durationUntilExpiry = durationUntilExpiry,
)
- .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)
- }
- }
- }
+
+ val notificationUpdate = NotificationUpdate.Notify(NOTIFICATION_ID, notification)
+ notificationChannel.send(notificationUpdate)
+ }
+
+ suspend fun cancelNotification() {
+ Logger.d("Cancelling existing account expiry notification")
+ val notificationUpdate = NotificationUpdate.Cancel(NOTIFICATION_ID)
+ notificationChannel.send(notificationUpdate)
+ }
+
+ companion object {
+ private val NOTIFICATION_ID = NotificationId(3)
+ }
}
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/InAppAccountExpiryTicker.kt
index cd872eee18..b26263f416 100644
--- 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/InAppAccountExpiryTicker.kt
@@ -8,17 +8,17 @@ import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.flow
import net.mullvad.mullvadvpn.lib.common.util.millisFromNow
-sealed interface AccountExpiryTicker {
- data object NotWithinThreshold : AccountExpiryTicker
+sealed interface InAppAccountExpiryTicker {
+ data object NotWithinThreshold : InAppAccountExpiryTicker
- data class Tick(val expiresIn: Duration) : AccountExpiryTicker
+ data class Tick(val expiresIn: Duration) : InAppAccountExpiryTicker
companion object {
fun tickerFlow(
expiry: ZonedDateTime,
tickStart: Duration,
updateInterval: (expiry: ZonedDateTime) -> Duration,
- ): Flow<AccountExpiryTicker> = flow {
+ ): Flow<InAppAccountExpiryTicker> = flow {
expiry.millisFromNow().let { expiryMillis ->
if (expiryMillis <= 0) {
// Has expired.
diff --git a/android/service/src/test/kotlin/net/mullvad/mullvadvpn/service/notifications/accountexpiry/AccountExpiryNotificationTriggerTest.kt b/android/service/src/test/kotlin/net/mullvad/mullvadvpn/service/notifications/accountexpiry/AccountExpiryNotificationTriggerTest.kt
new file mode 100644
index 0000000000..ff4340f168
--- /dev/null
+++ b/android/service/src/test/kotlin/net/mullvad/mullvadvpn/service/notifications/accountexpiry/AccountExpiryNotificationTriggerTest.kt
@@ -0,0 +1,71 @@
+package net.mullvad.mullvadvpn.service.notifications.accountexpiry
+
+import java.time.Duration
+import java.time.ZonedDateTime
+import kotlin.test.assertEquals
+import org.junit.jupiter.api.Test
+
+class AccountExpiryNotificationTriggerTest {
+
+ @Test
+ fun `long account expiry should trigger 3 days before expiry`() {
+ val now = ZonedDateTime.now()
+
+ val threeMonthsExpiry = now.plusDays(90)
+ val trigger1 = accountExpiryNotificationTriggerAt(now, threeMonthsExpiry)
+ assertEquals(87, Duration.between(now, trigger1).toDays())
+
+ val fourAndHalfDaysExpiry = now.plusDays(4).plusHours(12)
+ val trigger2 = accountExpiryNotificationTriggerAt(now, fourAndHalfDaysExpiry)
+ assertEquals(Duration.ofDays(1).plusHours(12), Duration.between(now, trigger2))
+ }
+
+ @Test
+ fun `account expiry that more than 2 days but less than 3 days should trigger 2 days before expiry`() {
+ val now = ZonedDateTime.now()
+ val expiry = now.plusHours(50)
+ val trigger = accountExpiryNotificationTriggerAt(now, expiry)
+ // Because acc
+ assertEquals(2, Duration.between(now, trigger).toHours())
+ }
+
+ @Test
+ fun `account expiry that is more than 1 day but less than 2 days should trigger 1 day before expiry`() {
+ val now = ZonedDateTime.now()
+ val expiry = now.plusHours(36).plusMinutes(20).plusSeconds(7)
+ val trigger = accountExpiryNotificationTriggerAt(now, expiry)
+ assertEquals(
+ Duration.ofHours(12).plusMinutes(20).plusSeconds(7),
+ Duration.between(now, trigger),
+ )
+ }
+
+ @Test
+ fun `account expiry that is less than 24 hours should trigger when account expires`() {
+ val now = ZonedDateTime.now()
+ val expiry = now.plusHours(2).plusMinutes(1).plusSeconds(30)
+ val trigger = accountExpiryNotificationTriggerAt(now, expiry)
+
+ assertEquals(
+ Duration.ofHours(2).plusMinutes(1).plusSeconds(30),
+ Duration.between(now, trigger),
+ )
+ }
+
+ @Test
+ fun `account that expires now should return now`() {
+ val now = ZonedDateTime.now()
+ val trigger = accountExpiryNotificationTriggerAt(now, now)
+
+ assertEquals(Duration.ofMillis(0), Duration.between(now, trigger))
+ }
+
+ @Test
+ fun `account expiry that is in the past should return the account expiry date`() {
+ val now = ZonedDateTime.now()
+ val expiry = now.minusDays(1).minusHours(17).minusMinutes(3).minusSeconds(40)
+ val trigger = accountExpiryNotificationTriggerAt(now, expiry)
+
+ assertEquals(expiry, trigger)
+ }
+}