summaryrefslogtreecommitdiffhomepage
path: root/android
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
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')
-rw-r--r--android/CHANGELOG.md1
-rw-r--r--android/app/src/main/AndroidManifest.xml36
-rw-r--r--android/app/src/main/kotlin/net/mullvad/mullvadvpn/MullvadApplication.kt7
-rw-r--r--android/app/src/main/kotlin/net/mullvad/mullvadvpn/di/AppModule.kt52
-rw-r--r--android/app/src/main/kotlin/net/mullvad/mullvadvpn/di/UiModule.kt21
-rw-r--r--android/app/src/main/kotlin/net/mullvad/mullvadvpn/receiver/AutoStartVpnBootCompletedReceiver.kt (renamed from android/app/src/main/kotlin/net/mullvad/mullvadvpn/receiver/BootCompletedReceiver.kt)3
-rw-r--r--android/app/src/main/kotlin/net/mullvad/mullvadvpn/receiver/NotificationAlarmReceiver.kt43
-rw-r--r--android/app/src/main/kotlin/net/mullvad/mullvadvpn/receiver/ScheduleNotificationBootCompletedReceiver.kt32
-rw-r--r--android/app/src/main/kotlin/net/mullvad/mullvadvpn/receiver/TimeChangedReceiver.kt31
-rw-r--r--android/app/src/main/kotlin/net/mullvad/mullvadvpn/repository/UserPreferencesRepository.kt17
-rw-r--r--android/app/src/main/kotlin/net/mullvad/mullvadvpn/ui/MainActivity.kt30
-rw-r--r--android/app/src/main/kotlin/net/mullvad/mullvadvpn/usecase/AccountExpiryInAppNotificationUseCase.kt12
-rw-r--r--android/app/src/main/kotlin/net/mullvad/mullvadvpn/usecase/AccountExpiryNotificationActionUseCase.kt61
-rw-r--r--android/app/src/main/kotlin/net/mullvad/mullvadvpn/usecase/ScheduleNotificationAlarmUseCase.kt67
-rw-r--r--android/app/src/main/kotlin/net/mullvad/mullvadvpn/util/ContextExtensions.kt18
-rw-r--r--android/app/src/main/kotlin/net/mullvad/mullvadvpn/viewmodel/WelcomeViewModel.kt5
-rw-r--r--android/app/src/main/proto/user_prefs.proto1
-rw-r--r--android/app/src/test/kotlin/net/mullvad/mullvadvpn/AccountExpiryNotificationProviderTest.kt213
-rw-r--r--android/app/src/test/kotlin/net/mullvad/mullvadvpn/data/Extensions.kt12
-rw-r--r--android/app/src/test/kotlin/net/mullvad/mullvadvpn/usecase/AccountExpiryInAppNotificationUseCaseTest.kt11
-rw-r--r--android/app/src/test/kotlin/net/mullvad/mullvadvpn/usecase/OutOfTimeUseCaseTest.kt25
-rw-r--r--android/app/src/test/kotlin/net/mullvad/mullvadvpn/viewmodel/LoginViewModelTest.kt5
-rw-r--r--android/app/src/test/kotlin/net/mullvad/mullvadvpn/viewmodel/WelcomeViewModelTest.kt5
-rw-r--r--android/lib/daemon-grpc/src/main/kotlin/net/mullvad/mullvadvpn/lib/daemon/grpc/ManagementService.kt4
-rw-r--r--android/lib/daemon-grpc/src/main/kotlin/net/mullvad/mullvadvpn/lib/daemon/grpc/mapper/ToDomain.kt5
-rw-r--r--android/lib/model/src/main/kotlin/net/mullvad/mullvadvpn/lib/model/AccountData.kt8
-rw-r--r--android/lib/model/src/main/kotlin/net/mullvad/mullvadvpn/lib/model/Notification.kt1
-rw-r--r--android/lib/shared/src/main/kotlin/net/mullvad/mullvadvpn/lib/shared/AccountRepository.kt7
-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
-rw-r--r--android/test/mockapi/src/main/kotlin/net/mullvad/mullvadvpn/test/mockapi/AccountExpiryMockApiTest.kt61
37 files changed, 645 insertions, 424 deletions
diff --git a/android/CHANGELOG.md b/android/CHANGELOG.md
index 317feef805..28bf85252c 100644
--- a/android/CHANGELOG.md
+++ b/android/CHANGELOG.md
@@ -35,6 +35,7 @@ Line wrap the file at 100 chars. Th
is drastically smaller.
- Update the UI and flow for adding time.
- Change so that search no longer require at least 2 letters.
+- Use AlarmManger to schedule account out of time notifications.
### Removed
- Remove logging from Google in-app purchase component in an experimental and non-supported way.
diff --git a/android/app/src/main/AndroidManifest.xml b/android/app/src/main/AndroidManifest.xml
index 10d1123e3f..4e28e48c82 100644
--- a/android/app/src/main/AndroidManifest.xml
+++ b/android/app/src/main/AndroidManifest.xml
@@ -122,13 +122,33 @@
<action android:name="android.intent.action.LOCALE_CHANGED" />
</intent-filter>
</receiver>
- <receiver android:name=".receiver.BootCompletedReceiver"
- android:enabled="false"
- android:exported="false">
- <intent-filter>
- <action android:name="android.intent.action.QUICKBOOT_POWERON" />
- <action android:name="android.intent.action.BOOT_COMPLETED" />
- </intent-filter>
- </receiver>
+ <receiver
+ android:name=".receiver.NotificationAlarmReceiver"
+ android:exported="false" />
+ <receiver
+ android:name=".receiver.TimeChangedReceiver"
+ android:exported="false">
+ <intent-filter>
+ <action android:name="android.intent.action.TIME_SET" />
+ <action android:name="android.intent.action.TIMEZONE_CHANGED" />
+ </intent-filter>
+ </receiver>
+ <receiver
+ android:name=".receiver.AutoStartVpnBootCompletedReceiver"
+ android:enabled="false"
+ android:exported="false">
+ <intent-filter>
+ <action android:name="android.intent.action.QUICKBOOT_POWERON" />
+ <action android:name="android.intent.action.BOOT_COMPLETED" />
+ </intent-filter>
+ </receiver>
+ <receiver
+ android:name=".receiver.ScheduleNotificationBootCompletedReceiver"
+ android:exported="false">
+ <intent-filter>
+ <action android:name="android.intent.action.QUICKBOOT_POWERON" />
+ <action android:name="android.intent.action.BOOT_COMPLETED" />
+ </intent-filter>
+ </receiver>
</application>
</manifest>
diff --git a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/MullvadApplication.kt b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/MullvadApplication.kt
index 617f538e87..3af04a9b6c 100644
--- a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/MullvadApplication.kt
+++ b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/MullvadApplication.kt
@@ -4,6 +4,9 @@ import android.app.Application
import co.touchlab.kermit.Logger
import co.touchlab.kermit.Severity
import net.mullvad.mullvadvpn.di.appModule
+import net.mullvad.mullvadvpn.service.notifications.NotificationChannelFactory
+import net.mullvad.mullvadvpn.service.notifications.NotificationManager
+import org.koin.android.ext.android.getKoin
import org.koin.android.ext.koin.androidContext
import org.koin.core.context.loadKoinModules
import org.koin.core.context.startKoin
@@ -19,5 +22,9 @@ class MullvadApplication : Application() {
}
startKoin { androidContext(this@MullvadApplication) }
loadKoinModules(listOf(appModule))
+ with(getKoin()) {
+ get<NotificationChannelFactory>()
+ get<NotificationManager>()
+ }
}
}
diff --git a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/di/AppModule.kt b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/di/AppModule.kt
index d7a1bfc8cb..f9627cdcae 100644
--- a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/di/AppModule.kt
+++ b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/di/AppModule.kt
@@ -1,5 +1,9 @@
package net.mullvad.mullvadvpn.di
+import android.content.Context
+import androidx.core.app.NotificationManagerCompat
+import androidx.datastore.core.DataStore
+import androidx.datastore.dataStore
import java.io.File
import kotlinx.coroutines.MainScope
import net.mullvad.mullvadvpn.BuildConfig
@@ -8,14 +12,29 @@ import net.mullvad.mullvadvpn.lib.common.constant.GRPC_SOCKET_FILE_NAMED_ARGUMEN
import net.mullvad.mullvadvpn.lib.daemon.grpc.ManagementService
import net.mullvad.mullvadvpn.lib.endpoint.ApiEndpointFromIntentHolder
import net.mullvad.mullvadvpn.lib.model.BuildVersion
+import net.mullvad.mullvadvpn.lib.model.NotificationChannel
import net.mullvad.mullvadvpn.lib.shared.AccountRepository
import net.mullvad.mullvadvpn.lib.shared.ConnectionProxy
import net.mullvad.mullvadvpn.lib.shared.DeviceRepository
import net.mullvad.mullvadvpn.lib.shared.LocaleRepository
import net.mullvad.mullvadvpn.lib.shared.PrepareVpnUseCase
import net.mullvad.mullvadvpn.lib.shared.RelayLocationTranslationRepository
+import net.mullvad.mullvadvpn.repository.UserPreferences
+import net.mullvad.mullvadvpn.repository.UserPreferencesMigration
+import net.mullvad.mullvadvpn.repository.UserPreferencesRepository
+import net.mullvad.mullvadvpn.repository.UserPreferencesSerializer
+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 net.mullvad.mullvadvpn.usecase.AccountExpiryNotificationActionUseCase
+import net.mullvad.mullvadvpn.usecase.ScheduleNotificationAlarmUseCase
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 appModule = module {
@@ -32,12 +51,43 @@ val appModule = module {
single { PrepareVpnUseCase(androidContext()) }
+ single { androidContext().resources }
+ single { androidContext().userPreferencesStore }
single { BuildVersion(BuildConfig.VERSION_NAME, BuildConfig.VERSION_CODE) }
single { ApiEndpointFromIntentHolder() }
single { AccountRepository(get(), get(), MainScope()) }
single { DeviceRepository(get()) }
+ single { UserPreferencesRepository(get(), get()) }
single { ConnectionProxy(get(), get(), get()) }
single { LocaleRepository(get()) }
single { RelayLocationTranslationRepository(get(), get(), MainScope()) }
- single { androidContext().resources }
+ single { ScheduleNotificationAlarmUseCase(get()) }
+ single { AccountExpiryNotificationActionUseCase(get(), get()) }
+
+ single { NotificationChannel.TunnelUpdates } bind NotificationChannel::class
+ single { NotificationChannel.AccountUpdates } bind NotificationChannel::class
+ single { NotificationChannelFactory(get(), get(), getAll()) } withOptions { createdAtStart() }
+ single { NotificationManagerCompat.from(androidContext()) }
+ single { NotificationManager(get(), getAll(), get(), MainScope()) } withOptions
+ {
+ createdAtStart()
+ }
+ single {
+ TunnelStateNotificationProvider(
+ get(),
+ get(),
+ get(),
+ get<NotificationChannel.TunnelUpdates>().id,
+ MainScope(),
+ )
+ } bind NotificationProvider::class
+ single { AccountExpiryNotificationProvider(get<NotificationChannel.AccountUpdates>().id) } bind
+ NotificationProvider::class
}
+
+private val Context.userPreferencesStore: DataStore<UserPreferences> by
+ dataStore(
+ fileName = APP_PREFERENCES_NAME,
+ serializer = UserPreferencesSerializer,
+ produceMigrations = UserPreferencesMigration::migrations,
+ )
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 7e798a69c8..a75aaef553 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
@@ -1,10 +1,7 @@
package net.mullvad.mullvadvpn.di
import android.content.ComponentName
-import android.content.Context
import android.content.pm.PackageManager
-import androidx.datastore.core.DataStore
-import androidx.datastore.dataStore
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.MainScope
import net.mullvad.mullvadvpn.BuildConfig
@@ -15,7 +12,7 @@ import net.mullvad.mullvadvpn.constant.IS_PLAY_BUILD
import net.mullvad.mullvadvpn.dataproxy.MullvadProblemReport
import net.mullvad.mullvadvpn.lib.payment.PaymentProvider
import net.mullvad.mullvadvpn.lib.shared.VoucherRepository
-import net.mullvad.mullvadvpn.receiver.BootCompletedReceiver
+import net.mullvad.mullvadvpn.receiver.AutoStartVpnBootCompletedReceiver
import net.mullvad.mullvadvpn.repository.ApiAccessRepository
import net.mullvad.mullvadvpn.repository.AutoStartAndConnectOnBootRepository
import net.mullvad.mullvadvpn.repository.ChangelogRepository
@@ -29,10 +26,6 @@ import net.mullvad.mullvadvpn.repository.RelayOverridesRepository
import net.mullvad.mullvadvpn.repository.SettingsRepository
import net.mullvad.mullvadvpn.repository.SplashCompleteRepository
import net.mullvad.mullvadvpn.repository.SplitTunnelingRepository
-import net.mullvad.mullvadvpn.repository.UserPreferences
-import net.mullvad.mullvadvpn.repository.UserPreferencesMigration
-import net.mullvad.mullvadvpn.repository.UserPreferencesRepository
-import net.mullvad.mullvadvpn.repository.UserPreferencesSerializer
import net.mullvad.mullvadvpn.repository.WireguardConstraintsRepository
import net.mullvad.mullvadvpn.service.DaemonConfig
import net.mullvad.mullvadvpn.ui.MainActivity
@@ -114,13 +107,11 @@ import org.koin.core.qualifier.named
import org.koin.dsl.module
val uiModule = module {
- single<DataStore<UserPreferences>> { androidContext().userPreferencesStore }
-
single<PackageManager> { androidContext().packageManager }
single<String>(named(SELF_PACKAGE_NAME)) { androidContext().packageName }
single<ComponentName>(named(BOOT_COMPLETED_RECEIVER_COMPONENT_NAME)) {
- ComponentName(androidContext(), BootCompletedReceiver::class.java)
+ ComponentName(androidContext(), AutoStartVpnBootCompletedReceiver::class.java)
}
viewModel { SplitTunnelingViewModel(get(), get(), get(), Dispatchers.Default) }
@@ -132,7 +123,6 @@ val uiModule = module {
single { androidContext().contentResolver }
single { ChangelogRepository(get(), get(), get()) }
- single { UserPreferencesRepository(get(), get()) }
single { SettingsRepository(get()) }
single { MullvadProblemReport(get(), get<DaemonConfig>().apiEndpointOverride, get()) }
single { RelayOverridesRepository(get()) }
@@ -294,10 +284,3 @@ val uiModule = module {
const val SELF_PACKAGE_NAME = "SELF_PACKAGE_NAME"
const val APP_PREFERENCES_NAME = "${BuildConfig.APPLICATION_ID}.app_preferences"
const val BOOT_COMPLETED_RECEIVER_COMPONENT_NAME = "BOOT_COMPLETED_RECEIVER_COMPONENT_NAME"
-
-private val Context.userPreferencesStore: DataStore<UserPreferences> by
- dataStore(
- fileName = APP_PREFERENCES_NAME,
- serializer = UserPreferencesSerializer,
- produceMigrations = UserPreferencesMigration::migrations,
- )
diff --git a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/receiver/BootCompletedReceiver.kt b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/receiver/AutoStartVpnBootCompletedReceiver.kt
index 3ab3750c5e..5c3a0a714e 100644
--- a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/receiver/BootCompletedReceiver.kt
+++ b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/receiver/AutoStartVpnBootCompletedReceiver.kt
@@ -7,8 +7,9 @@ import co.touchlab.kermit.Logger
import net.mullvad.mullvadvpn.lib.common.constant.KEY_CONNECT_ACTION
import net.mullvad.mullvadvpn.lib.common.constant.VPN_SERVICE_CLASS
import net.mullvad.mullvadvpn.lib.common.util.prepareVpnSafe
+import org.koin.core.component.KoinComponent
-class BootCompletedReceiver : BroadcastReceiver() {
+class AutoStartVpnBootCompletedReceiver : BroadcastReceiver(), KoinComponent {
override fun onReceive(context: Context?, intent: Intent?) {
if (intent?.action == Intent.ACTION_BOOT_COMPLETED) {
context?.let { startAndConnectTunnel(context) }
diff --git a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/receiver/NotificationAlarmReceiver.kt b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/receiver/NotificationAlarmReceiver.kt
new file mode 100644
index 0000000000..7b8ab4e04a
--- /dev/null
+++ b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/receiver/NotificationAlarmReceiver.kt
@@ -0,0 +1,43 @@
+package net.mullvad.mullvadvpn.receiver
+
+import android.content.BroadcastReceiver
+import android.content.Context
+import android.content.Intent
+import co.touchlab.kermit.Logger
+import java.time.Duration
+import java.time.ZonedDateTime
+import kotlinx.coroutines.runBlocking
+import net.mullvad.mullvadvpn.service.notifications.accountexpiry.AccountExpiryNotificationProvider
+import net.mullvad.mullvadvpn.usecase.ScheduleNotificationAlarmUseCase
+import net.mullvad.mullvadvpn.util.serializable
+import org.koin.core.component.KoinComponent
+import org.koin.core.component.inject
+
+class NotificationAlarmReceiver : BroadcastReceiver(), KoinComponent {
+ private val notificationProvider by inject<AccountExpiryNotificationProvider>()
+ private val scheduleNotificationAlarmUseCase by inject<ScheduleNotificationAlarmUseCase>()
+
+ override fun onReceive(context: Context?, intent: Intent?) {
+
+ val expiry: ZonedDateTime? = intent?.serializable(ACCOUNT_EXPIRY_KEY)
+ if (expiry == null) {
+ Logger.e("NotificationAlarmReceiver: Intent missing account expiry")
+ return
+ }
+
+ Logger.d("Account expiry alarm triggered")
+ val untilExpiry = Duration.between(ZonedDateTime.now(), expiry)
+
+ runBlocking {
+ notificationProvider.showNotification(untilExpiry)
+ // Only schedule the next alarm if we still have time left on the account.
+ if (context != null && expiry > ZonedDateTime.now()) {
+ scheduleNotificationAlarmUseCase(context = context, accountExpiry = expiry)
+ }
+ }
+ }
+
+ companion object {
+ const val ACCOUNT_EXPIRY_KEY: String = "account_expiry_key"
+ }
+}
diff --git a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/receiver/ScheduleNotificationBootCompletedReceiver.kt b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/receiver/ScheduleNotificationBootCompletedReceiver.kt
new file mode 100644
index 0000000000..3bd8c9be79
--- /dev/null
+++ b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/receiver/ScheduleNotificationBootCompletedReceiver.kt
@@ -0,0 +1,32 @@
+package net.mullvad.mullvadvpn.receiver
+
+import android.content.BroadcastReceiver
+import android.content.Context
+import android.content.Intent
+import co.touchlab.kermit.Logger
+import kotlinx.coroutines.runBlocking
+import net.mullvad.mullvadvpn.repository.UserPreferencesRepository
+import net.mullvad.mullvadvpn.usecase.ScheduleNotificationAlarmUseCase
+import org.koin.core.component.KoinComponent
+import org.koin.core.component.inject
+
+class ScheduleNotificationBootCompletedReceiver : BroadcastReceiver(), KoinComponent {
+ private val userPreferencesRepository by inject<UserPreferencesRepository>()
+ private val scheduleNotificationAlarmUseCase by inject<ScheduleNotificationAlarmUseCase>()
+
+ override fun onReceive(context: Context?, intent: Intent?) {
+ if (intent?.action == Intent.ACTION_BOOT_COMPLETED) {
+ context?.let {
+ Logger.d(
+ "Scheduling notification alarm from ScheduleNotificationBootCompletedReceiver"
+ )
+ runBlocking { scheduleAccountExpiryNotification(context) }
+ }
+ }
+ }
+
+ private suspend fun scheduleAccountExpiryNotification(context: Context) {
+ val expiry = userPreferencesRepository.accountExpiry() ?: return
+ scheduleNotificationAlarmUseCase(context, expiry)
+ }
+}
diff --git a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/receiver/TimeChangedReceiver.kt b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/receiver/TimeChangedReceiver.kt
new file mode 100644
index 0000000000..3b37aaa1db
--- /dev/null
+++ b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/receiver/TimeChangedReceiver.kt
@@ -0,0 +1,31 @@
+package net.mullvad.mullvadvpn.receiver
+
+import android.content.BroadcastReceiver
+import android.content.Context
+import android.content.Intent
+import co.touchlab.kermit.Logger
+import kotlinx.coroutines.runBlocking
+import net.mullvad.mullvadvpn.repository.UserPreferencesRepository
+import net.mullvad.mullvadvpn.usecase.ScheduleNotificationAlarmUseCase
+import org.koin.core.component.KoinComponent
+import org.koin.core.component.inject
+
+class TimeChangedReceiver : BroadcastReceiver(), KoinComponent {
+ private val userPreferencesRepository by inject<UserPreferencesRepository>()
+ private val scheduleNotificationAlarmUseCase by inject<ScheduleNotificationAlarmUseCase>()
+
+ override fun onReceive(context: Context?, intent: Intent?) {
+ if (
+ intent?.action == Intent.ACTION_TIME_CHANGED ||
+ intent?.action == Intent.ACTION_TIMEZONE_CHANGED
+ ) {
+ runBlocking {
+ val expiry = userPreferencesRepository.accountExpiry()
+ if (context != null && expiry != null) {
+ Logger.d("Scheduling notification alarm from TimeChangedReceiver")
+ scheduleNotificationAlarmUseCase(context, expiry)
+ }
+ }
+ }
+ }
+}
diff --git a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/repository/UserPreferencesRepository.kt b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/repository/UserPreferencesRepository.kt
index 8a6dfd59a6..85d70c6109 100644
--- a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/repository/UserPreferencesRepository.kt
+++ b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/repository/UserPreferencesRepository.kt
@@ -3,6 +3,9 @@ package net.mullvad.mullvadvpn.repository
import androidx.datastore.core.DataStore
import co.touchlab.kermit.Logger
import java.io.IOException
+import java.time.Instant
+import java.time.ZoneId
+import java.time.ZonedDateTime
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.catch
import kotlinx.coroutines.flow.first
@@ -39,4 +42,18 @@ class UserPreferencesRepository(
prefs.toBuilder().setLastShownChangelogVersionCode(buildVersion.code).build()
}
}
+
+ suspend fun setAccountExpiry(expiry: ZonedDateTime) {
+ userPreferencesStore.updateData { prefs ->
+ prefs.toBuilder().setAccountExpiryUnixTimeSeconds(expiry.toEpochSecond()).build()
+ }
+ }
+
+ // Returns the account expiry time or null if the account expiry has not been set yet.
+ suspend fun accountExpiry(): ZonedDateTime? =
+ preferences().let { prefs ->
+ val expiryTime = prefs.accountExpiryUnixTimeSeconds
+ if (expiryTime == 0L) return null
+ Instant.ofEpochSecond(expiryTime).atZone(ZoneId.systemDefault())
+ }
}
diff --git a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/ui/MainActivity.kt b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/ui/MainActivity.kt
index de4fc2d046..e10f71af95 100644
--- a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/ui/MainActivity.kt
+++ b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/ui/MainActivity.kt
@@ -36,8 +36,12 @@ import net.mullvad.mullvadvpn.lib.model.Prepared
import net.mullvad.mullvadvpn.lib.theme.AppTheme
import net.mullvad.mullvadvpn.repository.SplashCompleteRepository
import net.mullvad.mullvadvpn.repository.UserPreferencesRepository
+import net.mullvad.mullvadvpn.service.notifications.accountexpiry.AccountExpiryNotificationProvider
import net.mullvad.mullvadvpn.ui.serviceconnection.ServiceConnectionManager
import net.mullvad.mullvadvpn.ui.serviceconnection.ServiceConnectionState
+import net.mullvad.mullvadvpn.usecase.AccountExpiryNotificationActionUseCase
+import net.mullvad.mullvadvpn.usecase.NotificationAction
+import net.mullvad.mullvadvpn.usecase.ScheduleNotificationAlarmUseCase
import net.mullvad.mullvadvpn.viewmodel.MullvadAppViewModel
import org.koin.android.ext.android.inject
import org.koin.android.scope.AndroidScopeComponent
@@ -61,6 +65,10 @@ class MainActivity : ComponentActivity(), AndroidScopeComponent {
private val serviceConnectionManager by inject<ServiceConnectionManager>()
private val splashCompleteRepository by inject<SplashCompleteRepository>()
private val managementService by inject<ManagementService>()
+ private val scheduleNotificationAlarmUseCase by inject<ScheduleNotificationAlarmUseCase>()
+ private val accountExpiryNotificationActionUseCase by
+ inject<AccountExpiryNotificationActionUseCase>()
+ private val accountExpiryNotificationProvider by inject<AccountExpiryNotificationProvider>()
private var isReadyNextDraw: Boolean = false
@@ -100,6 +108,28 @@ class MainActivity : ComponentActivity(), AndroidScopeComponent {
}
}
}
+
+ lifecycleScope.launch {
+ repeatOnLifecycle(Lifecycle.State.STARTED) {
+ accountExpiryNotificationActionUseCase().collect { action ->
+ when (action) {
+ NotificationAction.CancelExisting -> {
+ accountExpiryNotificationProvider.cancelNotification()
+ scheduleNotificationAlarmUseCase(
+ context = this@MainActivity,
+ accountExpiry = null,
+ )
+ }
+
+ is NotificationAction.ScheduleAlarm ->
+ scheduleNotificationAlarmUseCase(
+ context = this@MainActivity,
+ accountExpiry = action.alarmTime,
+ )
+ }
+ }
+ }
+ }
}
override fun onRestoreInstanceState(savedInstanceState: Bundle) {
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 a39afe9c39..9d78b47902 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
@@ -8,8 +8,8 @@ import kotlinx.coroutines.flow.map
import net.mullvad.mullvadvpn.lib.model.InAppNotification
import net.mullvad.mullvadvpn.lib.shared.AccountRepository
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.AccountExpiryTicker
+import net.mullvad.mullvadvpn.service.notifications.accountexpiry.ACCOUNT_EXPIRY_NOTIFICATION_UPDATE_INTERVAL
+import net.mullvad.mullvadvpn.service.notifications.accountexpiry.InAppAccountExpiryTicker
class AccountExpiryInAppNotificationUseCase(private val accountRepository: AccountRepository) {
@@ -18,15 +18,15 @@ class AccountExpiryInAppNotificationUseCase(private val accountRepository: Accou
accountRepository.accountData
.flatMapLatest { accountData ->
if (accountData != null) {
- AccountExpiryTicker.tickerFlow(
+ InAppAccountExpiryTicker.tickerFlow(
expiry = accountData.expiryDate,
tickStart = ACCOUNT_EXPIRY_CLOSE_TO_EXPIRY_THRESHOLD,
- updateInterval = { ACCOUNT_EXPIRY_IN_APP_NOTIFICATION_UPDATE_INTERVAL },
+ updateInterval = { ACCOUNT_EXPIRY_NOTIFICATION_UPDATE_INTERVAL },
)
.map { tick ->
when (tick) {
- AccountExpiryTicker.NotWithinThreshold -> emptyList()
- is AccountExpiryTicker.Tick ->
+ InAppAccountExpiryTicker.NotWithinThreshold -> emptyList()
+ is InAppAccountExpiryTicker.Tick ->
listOf(InAppNotification.AccountExpiry(tick.expiresIn))
}
}
diff --git a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/usecase/AccountExpiryNotificationActionUseCase.kt b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/usecase/AccountExpiryNotificationActionUseCase.kt
new file mode 100644
index 0000000000..4e8ed620bf
--- /dev/null
+++ b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/usecase/AccountExpiryNotificationActionUseCase.kt
@@ -0,0 +1,61 @@
+package net.mullvad.mullvadvpn.usecase
+
+import java.time.ZonedDateTime
+import kotlinx.coroutines.ExperimentalCoroutinesApi
+import kotlinx.coroutines.flow.Flow
+import kotlinx.coroutines.flow.combine
+import kotlinx.coroutines.flow.distinctUntilChanged
+import kotlinx.coroutines.flow.filter
+import kotlinx.coroutines.flow.flattenConcat
+import kotlinx.coroutines.flow.flow
+import net.mullvad.mullvadvpn.lib.daemon.grpc.ManagementService
+import net.mullvad.mullvadvpn.lib.model.DeviceState
+import net.mullvad.mullvadvpn.lib.shared.AccountRepository
+import net.mullvad.mullvadvpn.service.notifications.accountexpiry.ACCOUNT_EXPIRY_CLOSE_TO_EXPIRY_THRESHOLD
+
+sealed interface NotificationAction {
+ data object CancelExisting : NotificationAction
+
+ data class ScheduleAlarm(val alarmTime: ZonedDateTime) : NotificationAction
+}
+
+class AccountExpiryNotificationActionUseCase(
+ private val accountRepository: AccountRepository,
+ private val managementService: ManagementService,
+) {
+ @OptIn(ExperimentalCoroutinesApi::class)
+ operator fun invoke(): Flow<NotificationAction> =
+ combine(managementService.deviceState, accountRepository.accountData) {
+ deviceState,
+ accountData ->
+ flow {
+ when (deviceState) {
+ is DeviceState.LoggedIn -> {
+ // There are cases where the current device's account number isn't the
+ // same as the account data device number. This can happen when logging
+ // out of one account and logging in to another (the deviceState will
+ // update before the new accountData is available).
+ if (deviceState.accountNumber == accountData?.accountNumber) {
+ if (shouldCancelExisting(accountData.expiryDate)) {
+ emit(NotificationAction.CancelExisting)
+ }
+ emit(NotificationAction.ScheduleAlarm(accountData.expiryDate))
+ }
+ }
+
+ DeviceState.LoggedOut,
+ DeviceState.Revoked -> emit(NotificationAction.CancelExisting)
+ }
+ }
+ }
+ .flattenConcat()
+ .filter { !accountRepository.isNewAccount.value }
+ .distinctUntilChanged()
+
+ private fun shouldCancelExisting(expiry: ZonedDateTime): Boolean {
+ val expiryTimeIsAfterThreshold =
+ expiry.isAfter(ZonedDateTime.now().plus(ACCOUNT_EXPIRY_CLOSE_TO_EXPIRY_THRESHOLD))
+
+ return expiryTimeIsAfterThreshold || accountRepository.isNewAccount.value
+ }
+}
diff --git a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/usecase/ScheduleNotificationAlarmUseCase.kt b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/usecase/ScheduleNotificationAlarmUseCase.kt
new file mode 100644
index 0000000000..57ed5c5dd2
--- /dev/null
+++ b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/usecase/ScheduleNotificationAlarmUseCase.kt
@@ -0,0 +1,67 @@
+package net.mullvad.mullvadvpn.usecase
+
+import android.app.AlarmManager
+import android.app.PendingIntent
+import android.content.Context
+import android.content.Intent
+import co.touchlab.kermit.Logger
+import java.time.ZoneOffset
+import java.time.ZonedDateTime
+import net.mullvad.mullvadvpn.receiver.NotificationAlarmReceiver
+import net.mullvad.mullvadvpn.repository.UserPreferencesRepository
+import net.mullvad.mullvadvpn.service.notifications.accountexpiry.accountExpiryNotificationTriggerAt
+
+class ScheduleNotificationAlarmUseCase(
+ private val userPreferencesRepository: UserPreferencesRepository
+) {
+ suspend operator fun invoke(context: Context, accountExpiry: ZonedDateTime?) {
+ val appContext = context.applicationContext
+ val alarmManager = appContext.getSystemService(AlarmManager::class.java) ?: return
+ cancelExisting(appContext, alarmManager)
+
+ if (accountExpiry == null) return
+
+ val triggerAt =
+ accountExpiryNotificationTriggerAt(now = ZonedDateTime.now(), expiry = accountExpiry)
+ val triggerAtMillis = triggerAt.toInstant().toEpochMilli()
+
+ val intent = alarmIntent(appContext, accountExpiry)
+ alarmManager.set(AlarmManager.RTC, triggerAtMillis, intent)
+
+ // Change to UTC to avoid leaking the user's time zone in the logs
+ Logger.d(
+ "Scheduling next account expiry alarm for ${triggerAt.withZoneSameInstant(ZoneOffset.UTC)}"
+ )
+ userPreferencesRepository.setAccountExpiry(accountExpiry)
+ }
+
+ private fun alarmIntent(context: Context, accountExpiry: ZonedDateTime): PendingIntent =
+ Intent(context, NotificationAlarmReceiver::class.java).let { intent ->
+ intent.putExtra(NotificationAlarmReceiver.ACCOUNT_EXPIRY_KEY, accountExpiry)
+ PendingIntent.getBroadcast(
+ context,
+ ALARM_INTENT_REQUEST_CODE,
+ intent,
+ PendingIntent.FLAG_UPDATE_CURRENT + PendingIntent.FLAG_IMMUTABLE,
+ )
+ }
+
+ private fun cancelExisting(context: Context, alarmManager: AlarmManager) {
+ existingAlarmIntent(context)?.let { pendingIntent ->
+ alarmManager.cancel(pendingIntent)
+ Logger.d("Cancelled existing account expiry alarm")
+ }
+ }
+
+ private fun existingAlarmIntent(context: Context): PendingIntent? =
+ PendingIntent.getBroadcast(
+ context,
+ ALARM_INTENT_REQUEST_CODE,
+ Intent(context, NotificationAlarmReceiver::class.java),
+ PendingIntent.FLAG_UPDATE_CURRENT +
+ PendingIntent.FLAG_IMMUTABLE +
+ PendingIntent.FLAG_NO_CREATE,
+ )
+}
+
+private const val ALARM_INTENT_REQUEST_CODE = 0
diff --git a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/util/ContextExtensions.kt b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/util/ContextExtensions.kt
index fd004562a3..e8a5986dac 100644
--- a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/util/ContextExtensions.kt
+++ b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/util/ContextExtensions.kt
@@ -3,6 +3,10 @@ package net.mullvad.mullvadvpn.util
import android.app.Activity
import android.content.Context
import android.content.ContextWrapper
+import android.content.Intent
+import android.os.Build
+import android.os.Bundle
+import java.io.Serializable
fun Context.getActivity(): Activity? {
return when (this) {
@@ -11,3 +15,17 @@ fun Context.getActivity(): Activity? {
else -> null
}
}
+
+inline fun <reified T : Serializable> Bundle.serializable(key: String): T? =
+ when {
+ Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU -> getSerializable(key, T::class.java)
+ else -> @Suppress("DEPRECATION") getSerializable(key) as? T
+ }
+
+inline fun <reified T : Serializable> Intent.serializable(key: String): T? =
+ when {
+ Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU ->
+ getSerializableExtra(key, T::class.java)
+
+ else -> @Suppress("DEPRECATION") getSerializableExtra(key) as? T
+ }
diff --git a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/viewmodel/WelcomeViewModel.kt b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/viewmodel/WelcomeViewModel.kt
index dcda3ee917..334a360cfd 100644
--- a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/viewmodel/WelcomeViewModel.kt
+++ b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/viewmodel/WelcomeViewModel.kt
@@ -77,7 +77,10 @@ class WelcomeViewModel(
accountRepository.accountData
.filterNotNull()
.filter { it.expiryDate.minusHours(MIN_HOURS_PAST_ACCOUNT_EXPIRY).isAfterNowInstant() }
- .onEach { paymentUseCase.resetPurchaseResult() }
+ .onEach {
+ paymentUseCase.resetPurchaseResult()
+ accountRepository.resetIsNewAccount()
+ }
.map { UiSideEffect.OpenConnectScreen }
fun onSitePaymentClick() {
diff --git a/android/app/src/main/proto/user_prefs.proto b/android/app/src/main/proto/user_prefs.proto
index 6f9661970f..ca8ba3e0ba 100644
--- a/android/app/src/main/proto/user_prefs.proto
+++ b/android/app/src/main/proto/user_prefs.proto
@@ -6,4 +6,5 @@ option java_multiple_files = true;
message UserPreferences {
bool is_privacy_disclosure_accepted = 1;
int32 last_shown_changelog_version_code = 2;
+ int64 account_expiry_unix_time_seconds = 3;
}
diff --git a/android/app/src/test/kotlin/net/mullvad/mullvadvpn/AccountExpiryNotificationProviderTest.kt b/android/app/src/test/kotlin/net/mullvad/mullvadvpn/AccountExpiryNotificationProviderTest.kt
deleted file mode 100644
index 9a3672515d..0000000000
--- a/android/app/src/test/kotlin/net/mullvad/mullvadvpn/AccountExpiryNotificationProviderTest.kt
+++ /dev/null
@@ -1,213 +0,0 @@
-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 java.time.Duration
-import java.time.ZonedDateTime
-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.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(
- ZonedDateTime.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(ZonedDateTime.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(ZonedDateTime.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(ZonedDateTime.now().plus(Duration.ofSeconds(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(
- ZonedDateTime.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(ZonedDateTime.now().plus(ACCOUNT_EXPIRY_CLOSE_TO_EXPIRY_THRESHOLD).minusDays(1))
- provider.notifications.test {
- assertTrue(awaitItem() is Notify)
- expectNoEvents()
-
- setExpiry(
- ZonedDateTime.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(ZonedDateTime.now().plus(ACCOUNT_EXPIRY_CLOSE_TO_EXPIRY_THRESHOLD).minusDays(1))
- provider.notifications.test {
- assertTrue(awaitItem() is Notify)
- expectNoEvents()
-
- setExpiry(
- ZonedDateTime.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: ZonedDateTime): ZonedDateTime {
- 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/data/Extensions.kt b/android/app/src/test/kotlin/net/mullvad/mullvadvpn/data/Extensions.kt
new file mode 100644
index 0000000000..eb7a98ed8b
--- /dev/null
+++ b/android/app/src/test/kotlin/net/mullvad/mullvadvpn/data/Extensions.kt
@@ -0,0 +1,12 @@
+package net.mullvad.mullvadvpn.data
+
+import io.mockk.mockk
+import java.time.ZonedDateTime
+import net.mullvad.mullvadvpn.lib.model.AccountData
+
+fun AccountData.Companion.mock(expiry: ZonedDateTime): AccountData =
+ AccountData(
+ id = mockk(relaxed = true),
+ accountNumber = mockk(relaxed = true),
+ expiryDate = expiry,
+ )
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 df7d561f84..0557cc5786 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
@@ -15,12 +15,13 @@ 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.data.mock
import net.mullvad.mullvadvpn.lib.common.test.TestCoroutineRule
import net.mullvad.mullvadvpn.lib.model.AccountData
import net.mullvad.mullvadvpn.lib.model.InAppNotification
import net.mullvad.mullvadvpn.lib.shared.AccountRepository
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.ACCOUNT_EXPIRY_NOTIFICATION_UPDATE_INTERVAL
import org.junit.jupiter.api.AfterEach
import org.junit.jupiter.api.BeforeEach
import org.junit.jupiter.api.Test
@@ -103,7 +104,7 @@ class AccountExpiryInAppNotificationUseCaseTest {
// Set expiry to to be in the final update interval.
val inLastUpdate =
ZonedDateTime.now()
- .plus(ACCOUNT_EXPIRY_IN_APP_NOTIFICATION_UPDATE_INTERVAL)
+ .plus(ACCOUNT_EXPIRY_NOTIFICATION_UPDATE_INTERVAL)
.minusSeconds(1)
val expiry = setExpiry(inLastUpdate)
@@ -113,9 +114,9 @@ class AccountExpiryInAppNotificationUseCaseTest {
expectNoEvents()
// Advance past the delay before the while loop:
- advanceTimeBy(ACCOUNT_EXPIRY_IN_APP_NOTIFICATION_UPDATE_INTERVAL.toMillis())
+ advanceTimeBy(ACCOUNT_EXPIRY_NOTIFICATION_UPDATE_INTERVAL.toMillis())
// Advance past the delay after the while loop:
- advanceTimeBy(ACCOUNT_EXPIRY_IN_APP_NOTIFICATION_UPDATE_INTERVAL.toMillis())
+ advanceTimeBy(ACCOUNT_EXPIRY_NOTIFICATION_UPDATE_INTERVAL.toMillis())
assertEquals(Duration.ZERO, getExpiryNotificationDuration(expectMostRecentItem()))
expectNoEvents()
@@ -128,7 +129,7 @@ class AccountExpiryInAppNotificationUseCaseTest {
}
private fun setExpiry(expiryDateTime: ZonedDateTime): ZonedDateTime {
- val expiry = AccountData(mockk(relaxed = true), expiryDateTime)
+ val expiry = AccountData.mock(expiryDateTime)
accountExpiry.value = expiry
return expiryDateTime
}
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 4fd3ba08d1..0fd1563169 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
@@ -20,6 +20,7 @@ import kotlinx.coroutines.test.advanceTimeBy
import kotlinx.coroutines.test.resetMain
import kotlinx.coroutines.test.runTest
import kotlinx.coroutines.test.setMain
+import net.mullvad.mullvadvpn.data.mock
import net.mullvad.mullvadvpn.lib.model.AccountData
import net.mullvad.mullvadvpn.lib.model.AuthFailedError
import net.mullvad.mullvadvpn.lib.model.ErrorState
@@ -90,8 +91,7 @@ class OutOfTimeUseCaseTest {
fun `tunnel is connected should emit false`() =
scope.runTest {
// Arrange
- val expiredAccountExpiry =
- AccountData(mockk(relaxed = true), ZonedDateTime.now().plusDays(1))
+ val expiredAccountExpiry = AccountData.mock(ZonedDateTime.now().plusDays(1))
val tunnelStateChanges =
listOf(
TunnelState.Disconnected(),
@@ -119,8 +119,7 @@ class OutOfTimeUseCaseTest {
fun `account expiry that has expired should emit true`() =
scope.runTest {
// Arrange
- val expiredAccountExpiry =
- AccountData(mockk(relaxed = true), ZonedDateTime.now().minusDays(1))
+ val expiredAccountExpiry = AccountData.mock(ZonedDateTime.now().minusDays(1))
// Act, Assert
outOfTimeUseCase.isOutOfTime.test {
assertEquals(null, awaitItem())
@@ -133,8 +132,7 @@ class OutOfTimeUseCaseTest {
fun `account expiry that has not expired should emit false`() =
scope.runTest {
// Arrange
- val notExpiredAccountExpiry =
- AccountData(mockk(relaxed = true), ZonedDateTime.now().plusDays(1))
+ val notExpiredAccountExpiry = AccountData.mock(ZonedDateTime.now().plusDays(1))
// Act, Assert
outOfTimeUseCase.isOutOfTime.test {
@@ -148,8 +146,7 @@ class OutOfTimeUseCaseTest {
fun `account that expires without new expiry event should emit true`() =
scope.runTest {
// Arrange
- val expiredAccountExpiry =
- AccountData(mockk(relaxed = true), ZonedDateTime.now().plusSeconds(100))
+ val expiredAccountExpiry = AccountData.mock(ZonedDateTime.now().plusSeconds(100))
// Act, Assert
outOfTimeUseCase.isOutOfTime.test {
// Initial event
@@ -172,10 +169,8 @@ class OutOfTimeUseCaseTest {
fun `account that is about to expire but is refilled should emit false`() =
scope.runTest {
// Arrange
- val initialAccountExpiry =
- AccountData(mockk(relaxed = true), ZonedDateTime.now().plusSeconds(100))
- val updatedExpiry =
- AccountData(mockk(relaxed = true), initialAccountExpiry.expiryDate.plusDays(30))
+ val initialAccountExpiry = AccountData.mock(ZonedDateTime.now().plusSeconds(100))
+ val updatedExpiry = AccountData.mock(initialAccountExpiry.expiryDate.plusDays(30))
// Act, Assert
outOfTimeUseCase.isOutOfTime.test {
@@ -202,10 +197,8 @@ class OutOfTimeUseCaseTest {
fun `expired account that is refilled should emit false`() =
scope.runTest {
// Arrange
- val initialAccountExpiry =
- AccountData(mockk(relaxed = true), ZonedDateTime.now().plusSeconds(100))
- val updatedExpiry =
- AccountData(mockk(relaxed = true), initialAccountExpiry.expiryDate.plusDays(30))
+ val initialAccountExpiry = AccountData.mock(ZonedDateTime.now().plusSeconds(100))
+ val updatedExpiry = AccountData.mock(initialAccountExpiry.expiryDate.plusDays(30))
// Act, Assert
outOfTimeUseCase.isOutOfTime.test {
// Initial event
diff --git a/android/app/src/test/kotlin/net/mullvad/mullvadvpn/viewmodel/LoginViewModelTest.kt b/android/app/src/test/kotlin/net/mullvad/mullvadvpn/viewmodel/LoginViewModelTest.kt
index a2be07e0a6..eeb848dc14 100644
--- a/android/app/src/test/kotlin/net/mullvad/mullvadvpn/viewmodel/LoginViewModelTest.kt
+++ b/android/app/src/test/kotlin/net/mullvad/mullvadvpn/viewmodel/LoginViewModelTest.kt
@@ -24,6 +24,7 @@ import net.mullvad.mullvadvpn.compose.state.LoginState.Idle
import net.mullvad.mullvadvpn.compose.state.LoginState.Loading
import net.mullvad.mullvadvpn.compose.state.LoginState.Success
import net.mullvad.mullvadvpn.compose.state.LoginUiState
+import net.mullvad.mullvadvpn.data.mock
import net.mullvad.mullvadvpn.lib.common.test.TestCoroutineRule
import net.mullvad.mullvadvpn.lib.model.AccountData
import net.mullvad.mullvadvpn.lib.model.AccountNumber
@@ -133,9 +134,7 @@ class LoginViewModelTest {
val sideEffects = loginViewModel.uiSideEffect.testIn(backgroundScope)
coEvery { mockedAccountRepository.login(any()) } returns Unit.right()
coEvery { mockedAccountRepository.accountData } returns
- MutableStateFlow(
- AccountData(mockk(relaxed = true), ZonedDateTime.now().plusDays(3))
- )
+ MutableStateFlow(AccountData.mock(ZonedDateTime.now().plusDays(3)))
// Act, Assert
uiStates.skipDefaultItem()
diff --git a/android/app/src/test/kotlin/net/mullvad/mullvadvpn/viewmodel/WelcomeViewModelTest.kt b/android/app/src/test/kotlin/net/mullvad/mullvadvpn/viewmodel/WelcomeViewModelTest.kt
index a96d59361a..f753a2e613 100644
--- a/android/app/src/test/kotlin/net/mullvad/mullvadvpn/viewmodel/WelcomeViewModelTest.kt
+++ b/android/app/src/test/kotlin/net/mullvad/mullvadvpn/viewmodel/WelcomeViewModelTest.kt
@@ -15,6 +15,7 @@ import kotlinx.coroutines.cancel
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.test.runTest
import net.mullvad.mullvadvpn.compose.state.WelcomeUiState
+import net.mullvad.mullvadvpn.data.mock
import net.mullvad.mullvadvpn.lib.common.test.TestCoroutineRule
import net.mullvad.mullvadvpn.lib.model.AccountData
import net.mullvad.mullvadvpn.lib.model.AccountNumber
@@ -149,9 +150,7 @@ class WelcomeViewModelTest {
@Test
fun `when user has added time then uiSideEffect should emit OpenConnectScreen`() = runTest {
// Arrange
- accountExpiryStateFlow.emit(
- AccountData(mockk(relaxed = true), ZonedDateTime.now().plusDays(1))
- )
+ accountExpiryStateFlow.emit(AccountData.mock(ZonedDateTime.now().plusDays(1)))
// Act, Assert
viewModel.uiSideEffect.test {
diff --git a/android/lib/daemon-grpc/src/main/kotlin/net/mullvad/mullvadvpn/lib/daemon/grpc/ManagementService.kt b/android/lib/daemon-grpc/src/main/kotlin/net/mullvad/mullvadvpn/lib/daemon/grpc/ManagementService.kt
index bac6e22277..f306c9f833 100644
--- a/android/lib/daemon-grpc/src/main/kotlin/net/mullvad/mullvadvpn/lib/daemon/grpc/ManagementService.kt
+++ b/android/lib/daemon-grpc/src/main/kotlin/net/mullvad/mullvadvpn/lib/daemon/grpc/ManagementService.kt
@@ -389,7 +389,9 @@ class ManagementService(
suspend fun getAccountData(
accountNumber: AccountNumber
): Either<GetAccountDataError, AccountData> =
- Either.catch { grpc.getAccountData(StringValue.of(accountNumber.value)).toDomain() }
+ Either.catch {
+ grpc.getAccountData(StringValue.of(accountNumber.value)).toDomain(accountNumber)
+ }
.onLeft { Logger.e("Get account data error") }
.mapLeft(GetAccountDataError::Unknown)
diff --git a/android/lib/daemon-grpc/src/main/kotlin/net/mullvad/mullvadvpn/lib/daemon/grpc/mapper/ToDomain.kt b/android/lib/daemon-grpc/src/main/kotlin/net/mullvad/mullvadvpn/lib/daemon/grpc/mapper/ToDomain.kt
index feb181f01d..f3037a8c1a 100644
--- a/android/lib/daemon-grpc/src/main/kotlin/net/mullvad/mullvadvpn/lib/daemon/grpc/mapper/ToDomain.kt
+++ b/android/lib/daemon-grpc/src/main/kotlin/net/mullvad/mullvadvpn/lib/daemon/grpc/mapper/ToDomain.kt
@@ -613,9 +613,10 @@ internal fun ManagementInterface.DeviceState.toDomain(): DeviceState =
else -> throw NullPointerException("Device state is null")
}
-internal fun ManagementInterface.AccountData.toDomain(): AccountData =
+internal fun ManagementInterface.AccountData.toDomain(accountNumber: AccountNumber): AccountData =
AccountData(
- AccountId(UUID.fromString(id)),
+ id = AccountId(UUID.fromString(id)),
+ accountNumber = accountNumber,
expiryDate = Instant.ofEpochSecond(expiry.seconds).atDefaultZone(),
)
diff --git a/android/lib/model/src/main/kotlin/net/mullvad/mullvadvpn/lib/model/AccountData.kt b/android/lib/model/src/main/kotlin/net/mullvad/mullvadvpn/lib/model/AccountData.kt
index b1746e1bcc..3c869ad3e0 100644
--- a/android/lib/model/src/main/kotlin/net/mullvad/mullvadvpn/lib/model/AccountData.kt
+++ b/android/lib/model/src/main/kotlin/net/mullvad/mullvadvpn/lib/model/AccountData.kt
@@ -2,4 +2,10 @@ package net.mullvad.mullvadvpn.lib.model
import java.time.ZonedDateTime
-data class AccountData(val id: AccountId, val expiryDate: ZonedDateTime)
+data class AccountData(
+ val id: AccountId,
+ val accountNumber: AccountNumber,
+ val expiryDate: ZonedDateTime,
+) {
+ companion object
+}
diff --git a/android/lib/model/src/main/kotlin/net/mullvad/mullvadvpn/lib/model/Notification.kt b/android/lib/model/src/main/kotlin/net/mullvad/mullvadvpn/lib/model/Notification.kt
index 6b073988a3..65805bb74e 100644
--- a/android/lib/model/src/main/kotlin/net/mullvad/mullvadvpn/lib/model/Notification.kt
+++ b/android/lib/model/src/main/kotlin/net/mullvad/mullvadvpn/lib/model/Notification.kt
@@ -17,7 +17,6 @@ sealed interface Notification {
data class AccountExpiry(
override val channelId: NotificationChannelId,
override val actions: List<NotificationAction.AccountExpiry>,
- val websiteAuthToken: WebsiteAuthToken?,
val durationUntilExpiry: Duration,
) : Notification {
override val ongoing: Boolean = false
diff --git a/android/lib/shared/src/main/kotlin/net/mullvad/mullvadvpn/lib/shared/AccountRepository.kt b/android/lib/shared/src/main/kotlin/net/mullvad/mullvadvpn/lib/shared/AccountRepository.kt
index a0edc2faa6..2e922a0895 100644
--- a/android/lib/shared/src/main/kotlin/net/mullvad/mullvadvpn/lib/shared/AccountRepository.kt
+++ b/android/lib/shared/src/main/kotlin/net/mullvad/mullvadvpn/lib/shared/AccountRepository.kt
@@ -9,7 +9,6 @@ import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.distinctUntilChanged
-import kotlinx.coroutines.flow.filterNotNull
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.merge
import kotlinx.coroutines.flow.stateIn
@@ -41,7 +40,7 @@ class AccountRepository(
val accountData: StateFlow<AccountData?> =
merge(
- managementService.deviceState.filterNotNull().map { deviceState ->
+ managementService.deviceState.map { deviceState ->
when (deviceState) {
is DeviceState.LoggedIn -> {
managementService.getAccountData(deviceState.accountNumber).getOrNull()
@@ -90,4 +89,8 @@ class AccountRepository(
internal suspend fun onVoucherRedeemed(newExpiry: ZonedDateTime) {
accountData.value?.copy(expiryDate = newExpiry)?.let { _mutableAccountDataCache.emit(it) }
}
+
+ fun resetIsNewAccount() {
+ _isNewAccount.value = false
+ }
}
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)
+ }
+}
diff --git a/android/test/mockapi/src/main/kotlin/net/mullvad/mullvadvpn/test/mockapi/AccountExpiryMockApiTest.kt b/android/test/mockapi/src/main/kotlin/net/mullvad/mullvadvpn/test/mockapi/AccountExpiryMockApiTest.kt
index 7aec8f4725..43020435df 100644
--- a/android/test/mockapi/src/main/kotlin/net/mullvad/mullvadvpn/test/mockapi/AccountExpiryMockApiTest.kt
+++ b/android/test/mockapi/src/main/kotlin/net/mullvad/mullvadvpn/test/mockapi/AccountExpiryMockApiTest.kt
@@ -2,6 +2,7 @@ package net.mullvad.mullvadvpn.test.mockapi
import androidx.test.uiautomator.By
import java.time.ZonedDateTime
+import net.mullvad.mullvadvpn.test.common.constant.VERY_LONG_TIMEOUT
import net.mullvad.mullvadvpn.test.common.extension.findObjectWithTimeout
import net.mullvad.mullvadvpn.test.common.page.AccountPage
import net.mullvad.mullvadvpn.test.common.page.ConnectPage
@@ -18,14 +19,7 @@ class AccountExpiryMockApiTest : MockApiTest() {
@Test
fun testAccountExpiryDateUpdated() {
// Arrange
- val validAccountNumber = "1234123412341234"
- val oldAccountExpiry = ZonedDateTime.now().plusMonths(1)
- apiDispatcher.apply {
- expectedAccountNumber = validAccountNumber
- accountExpiry = oldAccountExpiry
- devices = DEFAULT_DEVICE_LIST.toMutableMap()
- devicePendingToGetCreated = DUMMY_ID_2 to DUMMY_DEVICE_NAME_2
- }
+ val (validAccountNumber, oldAccountExpiry) = configureAccount()
// Act
app.launchAndLogIn(validAccountNumber)
@@ -45,14 +39,7 @@ class AccountExpiryMockApiTest : MockApiTest() {
@Test
fun testAccountTimeExpiredWhileUsingTheAppShouldShowOutOfTimeScreen() {
// Arrange
- val validAccountNumber = "1234123412341234"
- val oldAccountExpiry = ZonedDateTime.now().plusMonths(1)
- apiDispatcher.apply {
- expectedAccountNumber = validAccountNumber
- accountExpiry = oldAccountExpiry
- devices = DEFAULT_DEVICE_LIST.toMutableMap()
- devicePendingToGetCreated = DUMMY_ID_2 to DUMMY_DEVICE_NAME_2
- }
+ val (validAccountNumber, oldAccountExpiry) = configureAccount()
// Act
app.launchAndLogIn(validAccountNumber)
@@ -75,4 +62,46 @@ class AccountExpiryMockApiTest : MockApiTest() {
// Assert that we show the out of time screen
on<OutOfTimePage>()
}
+
+ @Test
+ fun testAccountTimeExpiryNotificationIsShown() {
+ // Arrange
+ val (validAccountNumber, _) = configureAccount()
+
+ // Act
+ app.launchAndLogIn(validAccountNumber)
+
+ // Wait for us to be on connect page before changing expiry
+ on<ConnectPage>()
+
+ // Set account time as expired
+ val newAccountExpiry = ZonedDateTime.now().plusDays(3).plusSeconds(5)
+ apiDispatcher.accountExpiry = newAccountExpiry
+
+ on<ConnectPage> { clickAccount() }
+
+ // Go to account page to update the account expiry
+ on<AccountPage>()
+
+ // Go back to the main screen
+ device.openNotification()
+
+ // Make sure the notification is shown
+ device.findObjectWithTimeout(
+ By.text("Account credit expires in 2 days"),
+ timeout = VERY_LONG_TIMEOUT,
+ )
+ }
+
+ private fun configureAccount(): Pair<String, ZonedDateTime> {
+ val validAccountNumber = "1234123412341234"
+ val oldAccountExpiry = ZonedDateTime.now().plusMonths(1)
+ apiDispatcher.apply {
+ expectedAccountNumber = validAccountNumber
+ accountExpiry = oldAccountExpiry
+ devices = DEFAULT_DEVICE_LIST.toMutableMap()
+ devicePendingToGetCreated = DUMMY_ID_2 to DUMMY_DEVICE_NAME_2
+ }
+ return Pair(validAccountNumber, oldAccountExpiry)
+ }
}