summaryrefslogtreecommitdiffhomepage
path: root/android/app/src
diff options
context:
space:
mode:
Diffstat (limited to 'android/app/src')
-rw-r--r--android/app/src/main/AndroidManifest.xml36
-rw-r--r--android/app/src/main/kotlin/net/mullvad/mullvadvpn/MullvadApplication.kt7
-rw-r--r--android/app/src/main/kotlin/net/mullvad/mullvadvpn/di/AppModule.kt52
-rw-r--r--android/app/src/main/kotlin/net/mullvad/mullvadvpn/di/UiModule.kt21
-rw-r--r--android/app/src/main/kotlin/net/mullvad/mullvadvpn/receiver/AutoStartVpnBootCompletedReceiver.kt (renamed from android/app/src/main/kotlin/net/mullvad/mullvadvpn/receiver/BootCompletedReceiver.kt)3
-rw-r--r--android/app/src/main/kotlin/net/mullvad/mullvadvpn/receiver/NotificationAlarmReceiver.kt43
-rw-r--r--android/app/src/main/kotlin/net/mullvad/mullvadvpn/receiver/ScheduleNotificationBootCompletedReceiver.kt32
-rw-r--r--android/app/src/main/kotlin/net/mullvad/mullvadvpn/receiver/TimeChangedReceiver.kt31
-rw-r--r--android/app/src/main/kotlin/net/mullvad/mullvadvpn/repository/UserPreferencesRepository.kt17
-rw-r--r--android/app/src/main/kotlin/net/mullvad/mullvadvpn/ui/MainActivity.kt30
-rw-r--r--android/app/src/main/kotlin/net/mullvad/mullvadvpn/usecase/AccountExpiryInAppNotificationUseCase.kt12
-rw-r--r--android/app/src/main/kotlin/net/mullvad/mullvadvpn/usecase/AccountExpiryNotificationActionUseCase.kt61
-rw-r--r--android/app/src/main/kotlin/net/mullvad/mullvadvpn/usecase/ScheduleNotificationAlarmUseCase.kt67
-rw-r--r--android/app/src/main/kotlin/net/mullvad/mullvadvpn/util/ContextExtensions.kt18
-rw-r--r--android/app/src/main/kotlin/net/mullvad/mullvadvpn/viewmodel/WelcomeViewModel.kt5
-rw-r--r--android/app/src/main/proto/user_prefs.proto1
-rw-r--r--android/app/src/test/kotlin/net/mullvad/mullvadvpn/AccountExpiryNotificationProviderTest.kt213
-rw-r--r--android/app/src/test/kotlin/net/mullvad/mullvadvpn/data/Extensions.kt12
-rw-r--r--android/app/src/test/kotlin/net/mullvad/mullvadvpn/usecase/AccountExpiryInAppNotificationUseCaseTest.kt11
-rw-r--r--android/app/src/test/kotlin/net/mullvad/mullvadvpn/usecase/OutOfTimeUseCaseTest.kt25
-rw-r--r--android/app/src/test/kotlin/net/mullvad/mullvadvpn/viewmodel/LoginViewModelTest.kt5
-rw-r--r--android/app/src/test/kotlin/net/mullvad/mullvadvpn/viewmodel/WelcomeViewModelTest.kt5
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 {