diff options
Diffstat (limited to 'android/service/src')
7 files changed, 140 insertions, 125 deletions
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) + } +} |
