diff options
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 { |
