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