diff options
| author | Kalle Lindström <karl.lindstrom@mullvad.net> | 2025-06-09 17:16:45 +0200 |
|---|---|---|
| committer | Kalle Lindström <karl.lindstrom@mullvad.net> | 2025-06-19 09:36:26 +0200 |
| commit | cdc2c4d700cd41f37cf4b1607a0396019b1fbee5 (patch) | |
| tree | 10fbfcebf826159d515b5599634ce5b7be256f54 /android/app/src | |
| parent | 1748d8a7b3e8044f6b2718d25903bf1b38afad4e (diff) | |
| download | mullvadvpn-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/app/src')
22 files changed, 431 insertions, 276 deletions
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 { |
