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