diff options
| author | David Göransson <david.goransson@mullvad.net> | 2025-10-02 11:27:53 +0200 |
|---|---|---|
| committer | Jonatan Rhodin <jonatan.rhodin@mullvad.net> | 2025-10-06 11:43:15 +0200 |
| commit | d229d24b508f97c24fcc0a4a2db4f845141fd931 (patch) | |
| tree | 19241200a1830c7c5b8703714d2055b8d87619e7 /android | |
| parent | f82099962e5160b4577038b794170fa2f70ed546 (diff) | |
| download | mullvadvpn-d229d24b508f97c24fcc0a4a2db4f845141fd931.tar.xz mullvadvpn-d229d24b508f97c24fcc0a4a2db4f845141fd931.zip | |
Warn users about android 16 upgrade issue
Diffstat (limited to 'android')
41 files changed, 473 insertions, 145 deletions
diff --git a/android/app/build.gradle.kts b/android/app/build.gradle.kts index 20a56ef1e9..3e3634c178 100644 --- a/android/app/build.gradle.kts +++ b/android/app/build.gradle.kts @@ -147,7 +147,8 @@ android { freeCompilerArgs = listOf( // Opt-in option for Koin annotation of KoinComponent. - "-opt-in=kotlin.RequiresOptIn" + "-opt-in=kotlin.RequiresOptIn", + "-XXLanguage:+WhenGuards", ) } } diff --git a/android/app/src/androidTest/kotlin/net/mullvad/mullvadvpn/compose/screen/ConnectScreenTest.kt b/android/app/src/androidTest/kotlin/net/mullvad/mullvadvpn/compose/screen/ConnectScreenTest.kt index 1bed735bb0..6846b6cf0c 100644 --- a/android/app/src/androidTest/kotlin/net/mullvad/mullvadvpn/compose/screen/ConnectScreenTest.kt +++ b/android/app/src/androidTest/kotlin/net/mullvad/mullvadvpn/compose/screen/ConnectScreenTest.kt @@ -73,6 +73,8 @@ class ConnectScreenTest { onDismissChangelogClick: () -> Unit = {}, onNavigateToFeature: (FeatureIndicator) -> Unit = {}, onClickShowWireguardPortSettings: () -> Unit = {}, + onClickShowAndroid16UpgradeInfo: () -> Unit = {}, + onClickDismissAndroid16UpgradeWarning: () -> Unit = {}, ) { setContentWithTheme { ConnectScreen( @@ -91,6 +93,8 @@ class ConnectScreenTest { onDismissChangelogClick = onDismissChangelogClick, onNavigateToFeature = onNavigateToFeature, onClickShowWireguardPortSettings = onClickShowWireguardPortSettings, + onClickShowAndroid16UpgradeInfo = onClickShowAndroid16UpgradeInfo, + onClickDismissAndroid16UpgradeWarning = onClickDismissAndroid16UpgradeWarning, ) } } diff --git a/android/app/src/main/AndroidManifest.xml b/android/app/src/main/AndroidManifest.xml index 6242d494a5..b32ad3acf4 100644 --- a/android/app/src/main/AndroidManifest.xml +++ b/android/app/src/main/AndroidManifest.xml @@ -149,5 +149,17 @@ <action android:name="android.intent.action.BOOT_COMPLETED" /> </intent-filter> </receiver> + <!-- + Receiver to warn Android 16 users about VPN upgrade issue + https://issuetracker.google.com/issues/441315112 + --> + <receiver + android:name=".receiver.Android16UpdateWarningReceiver" + android:enabled="true" + android:exported="false"> + <intent-filter> + <action android:name="android.intent.action.MY_PACKAGE_REPLACED" /> + </intent-filter> + </receiver> </application> </manifest> diff --git a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/component/connectioninfo/ConnectionDetailPanel.kt b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/component/connectioninfo/ConnectionDetailPanel.kt index e8493b97cf..92b782895b 100644 --- a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/component/connectioninfo/ConnectionDetailPanel.kt +++ b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/component/connectioninfo/ConnectionDetailPanel.kt @@ -16,10 +16,10 @@ import androidx.constraintlayout.compose.ConstraintLayout import androidx.constraintlayout.compose.Dimension import net.mullvad.mullvadvpn.R import net.mullvad.mullvadvpn.compose.screen.ConnectionDetails -import net.mullvad.mullvadvpn.constant.SPACE_CHAR import net.mullvad.mullvadvpn.lib.model.TransportProtocol import net.mullvad.mullvadvpn.lib.model.TunnelEndpoint import net.mullvad.mullvadvpn.lib.theme.Dimens +import net.mullvad.mullvadvpn.lib.ui.component.SPACE_CHAR import net.mullvad.mullvadvpn.lib.ui.tag.LOCATION_INFO_CONNECTION_IN_TEST_TAG import net.mullvad.mullvadvpn.lib.ui.tag.LOCATION_INFO_CONNECTION_OUT_TEST_TAG diff --git a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/component/notificationbanner/NotificationBanner.kt b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/component/notificationbanner/NotificationBanner.kt index 2279e47407..75558e5a6a 100644 --- a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/component/notificationbanner/NotificationBanner.kt +++ b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/component/notificationbanner/NotificationBanner.kt @@ -54,9 +54,11 @@ private fun PreviewNotificationBanner() { openAppListing = {}, onClickShowAccount = {}, onClickShowChangelog = {}, + onClickShowAndroid16UpgradeInfo = {}, onClickDismissChangelog = {}, onClickDismissNewDevice = {}, onClickShowWireguardPortSettings = {}, + onClickDismissAndroid16UpgradeWarning = {}, ) Spacer(modifier = Modifier.size(16.dp)) } @@ -73,9 +75,11 @@ fun NotificationBanner( openAppListing: () -> Unit, onClickShowAccount: () -> Unit, onClickShowChangelog: () -> Unit, + onClickShowAndroid16UpgradeInfo: () -> Unit, onClickDismissChangelog: () -> Unit, onClickDismissNewDevice: () -> Unit, onClickShowWireguardPortSettings: () -> Unit, + onClickDismissAndroid16UpgradeWarning: () -> Unit, ) { if (isTv()) { NotificationBannerTv( @@ -86,9 +90,11 @@ fun NotificationBanner( contentFocusRequester = contentFocusRequester, onClickShowAccount = onClickShowAccount, onClickShowChangelog = onClickShowChangelog, + onClickShowAndroid16UpgradeInfo = onClickShowAndroid16UpgradeInfo, onClickDismissChangelog = onClickDismissChangelog, onClickDismissNewDevice = onClickDismissNewDevice, onClickShowWireguardPortSettings = onClickShowWireguardPortSettings, + onClickDismissAndroid16UpgradeWarning = onClickDismissAndroid16UpgradeWarning, ) } else { AnimatedNotificationBanner( @@ -100,9 +106,11 @@ fun NotificationBanner( contentFocusRequester = contentFocusRequester, onClickShowAccount = onClickShowAccount, onClickShowChangelog = onClickShowChangelog, + onClickShowAndroid16UpgradeInfo = onClickShowAndroid16UpgradeInfo, onClickDismissChangelog = onClickDismissChangelog, onClickDismissNewDevice = onClickDismissNewDevice, onClickShowWireguardPortSettings = onClickShowWireguardPortSettings, + onClickDismissAndroid16UpgradeWarning = onClickDismissAndroid16UpgradeWarning, ) } } diff --git a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/dialog/info/Android16UpgradeWarningDialog.kt b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/dialog/info/Android16UpgradeWarningDialog.kt new file mode 100644 index 0000000000..a70bb62d09 --- /dev/null +++ b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/dialog/info/Android16UpgradeWarningDialog.kt @@ -0,0 +1,53 @@ +package net.mullvad.mullvadvpn.compose.dialog.info + +import androidx.compose.material3.MaterialTheme +import androidx.compose.runtime.Composable +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.SpanStyle +import androidx.compose.ui.text.style.TextDecoration +import androidx.compose.ui.tooling.preview.Preview +import com.ramcosta.composedestinations.annotation.Destination +import com.ramcosta.composedestinations.annotation.RootGraph +import com.ramcosta.composedestinations.navigation.DestinationsNavigator +import com.ramcosta.composedestinations.spec.DestinationStyle +import net.mullvad.mullvadvpn.R +import net.mullvad.mullvadvpn.compose.util.clickableAnnotatedString +import net.mullvad.mullvadvpn.compose.util.createCopyToClipboardHandle +import net.mullvad.mullvadvpn.lib.theme.AppTheme + +@Preview +@Composable +private fun PreviewAndroid16UpgradeWarningInfoDialog() { + AppTheme { Android16UpgradeWarningInfoDialog(onDismiss = {}, onClickEmail = {}) } +} + +@Destination<RootGraph>(style = DestinationStyle.Dialog::class) +@Composable +fun Android16UpgradeWarningInfo(navigator: DestinationsNavigator) { + val copyToClipboard = createCopyToClipboardHandle(isSensitive = false) + Android16UpgradeWarningInfoDialog( + onDismiss = navigator::navigateUp, + onClickEmail = { email -> copyToClipboard(email, null) }, + ) +} + +@Composable +fun Android16UpgradeWarningInfoDialog(onDismiss: () -> Unit, onClickEmail: (String) -> Unit) { + InfoDialog( + title = stringResource(id = R.string.android_16_upgrade_warning_title), + message = stringResource(id = R.string.android_16_upgrade_warning_dialog_first_message), + additionalInfo = + clickableAnnotatedString( + text = stringResource(R.string.android_16_upgrade_warning_dialog_second_message), + linkStyle = + SpanStyle( + color = MaterialTheme.colorScheme.onSurface, + textDecoration = TextDecoration.Underline, + ), + argument = stringResource(R.string.support_email), + onClick = onClickEmail, + ), + showIcon = false, + onDismiss = onDismiss, + ) +} diff --git a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/dialog/info/InfoDialog.kt b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/dialog/info/InfoDialog.kt index cf6cb1c240..bb5c587002 100644 --- a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/dialog/info/InfoDialog.kt +++ b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/dialog/info/InfoDialog.kt @@ -17,6 +17,7 @@ import androidx.compose.runtime.Composable import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.AnnotatedString import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.window.DialogProperties @@ -44,8 +45,10 @@ private fun PreviewChangelogDialogWithTwoLongItems() { @Composable fun InfoDialog( + title: String? = null, message: String, - additionalInfo: String? = null, + additionalInfo: CharSequence? = null, + showIcon: Boolean = true, onDismiss: () -> Unit, confirmButton: @Composable () -> Unit = { PrimaryButton( @@ -58,14 +61,23 @@ fun InfoDialog( ) { AlertDialog( onDismissRequest = { onDismiss() }, - icon = { - Icon( - modifier = Modifier.fillMaxWidth().height(Dimens.dialogIconHeight), - imageVector = Icons.Default.Info, - contentDescription = "", - tint = MaterialTheme.colorScheme.onSurface, - ) - }, + icon = + if (showIcon) { + { + Icon( + modifier = Modifier.fillMaxWidth().height(Dimens.dialogIconHeight), + imageVector = Icons.Default.Info, + contentDescription = "", + tint = MaterialTheme.colorScheme.onSurface, + ) + } + } else null, + title = + if (title != null) { + @Composable { Text(title) } + } else { + null + }, text = { val scrollState = rememberScrollState() Column( @@ -84,13 +96,27 @@ fun InfoDialog( ) if (additionalInfo != null) { Spacer(modifier = Modifier.height(Dimens.verticalSpace)) - val htmlFormattedString = - HtmlCompat.fromHtml(additionalInfo, HtmlCompat.FROM_HTML_MODE_COMPACT) - val annotated = htmlFormattedString.toAnnotatedString(FontWeight.Bold) - // fromHtml may add a trailing newline when using HTML tags, so we remove it - val trimmed = annotated.substring(0, annotated.trimEnd().length) + val annotated: AnnotatedString = + when (additionalInfo) { + is AnnotatedString -> additionalInfo + is String -> { + val htmlAnnotated = + HtmlCompat.fromHtml( + additionalInfo, + HtmlCompat.FROM_HTML_MODE_COMPACT, + ) + .toAnnotatedString(FontWeight.Bold) + // fromHtml may add a trailing newline when using HTML tags, so we + // remove it + AnnotatedString( + htmlAnnotated.substring(0, htmlAnnotated.trimEnd().length) + ) + } + else -> + error("Unsupported additionalInfo type ${additionalInfo::class}") + } Text( - text = trimmed, + text = annotated, color = MaterialTheme.colorScheme.onSurfaceVariant, style = MaterialTheme.typography.labelLarge, modifier = Modifier.fillMaxWidth(), diff --git a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/dialog/info/LocalNetworkSharingInfoDialog.kt b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/dialog/info/LocalNetworkSharingInfoDialog.kt index 0cd5c5ff43..f69a218584 100644 --- a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/dialog/info/LocalNetworkSharingInfoDialog.kt +++ b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/dialog/info/LocalNetworkSharingInfoDialog.kt @@ -11,8 +11,8 @@ import com.ramcosta.composedestinations.navigation.EmptyDestinationsNavigator import com.ramcosta.composedestinations.spec.DestinationStyle import net.mullvad.mullvadvpn.R import net.mullvad.mullvadvpn.compose.component.textResource -import net.mullvad.mullvadvpn.constant.HTML_NEWLINE_STRING import net.mullvad.mullvadvpn.lib.theme.AppTheme +import net.mullvad.mullvadvpn.lib.ui.component.HTML_NEWLINE_STRING @Preview @Composable diff --git a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/dialog/info/ObfuscationInfoDialog.kt b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/dialog/info/ObfuscationInfoDialog.kt index 95f180bdaf..19f442de3a 100644 --- a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/dialog/info/ObfuscationInfoDialog.kt +++ b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/dialog/info/ObfuscationInfoDialog.kt @@ -10,7 +10,7 @@ import com.ramcosta.composedestinations.navigation.DestinationsNavigator import com.ramcosta.composedestinations.navigation.EmptyDestinationsNavigator import com.ramcosta.composedestinations.spec.DestinationStyle import net.mullvad.mullvadvpn.R -import net.mullvad.mullvadvpn.constant.NEWLINE_STRING +import net.mullvad.mullvadvpn.lib.ui.component.NEWLINE_STRING @Preview @Composable diff --git a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/screen/ConnectScreen.kt b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/screen/ConnectScreen.kt index 8cb0bde801..13b6a518cf 100644 --- a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/screen/ConnectScreen.kt +++ b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/screen/ConnectScreen.kt @@ -66,6 +66,7 @@ import com.ramcosta.composedestinations.annotation.Destination import com.ramcosta.composedestinations.annotation.RootGraph import com.ramcosta.composedestinations.generated.NavGraphs import com.ramcosta.composedestinations.generated.destinations.AccountDestination +import com.ramcosta.composedestinations.generated.destinations.Android16UpgradeWarningInfoDestination import com.ramcosta.composedestinations.generated.destinations.ChangelogDestination import com.ramcosta.composedestinations.generated.destinations.DaitaDestination import com.ramcosta.composedestinations.generated.destinations.DeviceRevokedDestination @@ -164,6 +165,8 @@ private fun PreviewAccountScreen( onDismissNewDeviceClick = {}, onNavigateToFeature = {}, onClickShowWireguardPortSettings = {}, + onClickDismissAndroid16UpgradeWarning = {}, + onClickShowAndroid16UpgradeInfo = {}, ) } } @@ -306,6 +309,10 @@ fun Connect( }, onClickShowWireguardPortSettings = dropUnlessResumed { navigator.navigate(VpnSettingsDestination()) }, + onClickDismissAndroid16UpgradeWarning = + connectViewModel::dismissAndroid16UpgradeWarning, + onClickShowAndroid16UpgradeInfo = + dropUnlessResumed { navigator.navigate(Android16UpgradeWarningInfoDestination()) }, ) } } @@ -330,6 +337,8 @@ fun ConnectScreen( onDismissNewDeviceClick: () -> Unit, onNavigateToFeature: (FeatureIndicator) -> Unit, onClickShowWireguardPortSettings: () -> Unit, + onClickDismissAndroid16UpgradeWarning: () -> Unit, + onClickShowAndroid16UpgradeInfo: () -> Unit, ) { val contentFocusRequester = remember { FocusRequester() } @@ -351,6 +360,8 @@ fun ConnectScreen( onDismissNewDeviceClick, onNavigateToFeature, onClickShowWireguardPortSettings, + onClickDismissAndroid16UpgradeWarning, + onClickShowAndroid16UpgradeInfo, ) } @@ -406,6 +417,8 @@ private fun Content( onDismissNewDeviceClick: () -> Unit, onNavigateToFeature: (FeatureIndicator) -> Unit, onClickShowWireguardPortSettings: () -> Unit, + onClickDismissAndroid16UpgradeWarning: () -> Unit, + onClickShowAndroid16UpgradeInfo: () -> Unit, ) { val screenHeight = with(LocalDensity.current) { LocalWindowInfo.current.containerSize.height.toDp() } @@ -454,9 +467,11 @@ private fun Content( openAppListing = onOpenAppListing, onClickShowAccount = onManageAccountClick, onClickShowChangelog = onChangelogClick, + onClickShowAndroid16UpgradeInfo = onClickShowAndroid16UpgradeInfo, onClickDismissChangelog = onDismissChangelogClick, onClickDismissNewDevice = onDismissNewDeviceClick, onClickShowWireguardPortSettings = onClickShowWireguardPortSettings, + onClickDismissAndroid16UpgradeWarning = onClickDismissAndroid16UpgradeWarning, ) ConnectionCard( state = state, diff --git a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/textfield/CustomTextField.kt b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/textfield/CustomTextField.kt index 4f165a5303..b030093cce 100644 --- a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/textfield/CustomTextField.kt +++ b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/textfield/CustomTextField.kt @@ -30,9 +30,9 @@ import androidx.compose.ui.text.input.KeyboardType import androidx.compose.ui.text.input.TextFieldValue import androidx.compose.ui.text.input.VisualTransformation import androidx.core.text.isDigitsOnly -import net.mullvad.mullvadvpn.constant.EMPTY_STRING -import net.mullvad.mullvadvpn.constant.NEWLINE_STRING import net.mullvad.mullvadvpn.lib.theme.Dimens +import net.mullvad.mullvadvpn.lib.ui.component.EMPTY_STRING +import net.mullvad.mullvadvpn.lib.ui.component.NEWLINE_STRING @Suppress("ComposableLambdaParameterNaming") @Composable diff --git a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/util/ClickableString.kt b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/util/ClickableString.kt new file mode 100644 index 0000000000..bcf4e1b76c --- /dev/null +++ b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/util/ClickableString.kt @@ -0,0 +1,37 @@ +package net.mullvad.mullvadvpn.compose.util + +import androidx.compose.ui.text.LinkAnnotation +import androidx.compose.ui.text.LinkInteractionListener +import androidx.compose.ui.text.SpanStyle +import androidx.compose.ui.text.buildAnnotatedString +import androidx.compose.ui.text.withLink +import androidx.compose.ui.text.withStyle + +/** + * Creates an [AnnotatedString] from a localized string with a clickable part. The [text] parameter + * should contain a single "%s" placeholder where the [argument] will be inserted. + */ +fun clickableAnnotatedString( + text: String, + argument: String, + linkStyle: SpanStyle, + onClick: (String) -> Unit, +) = buildAnnotatedString { + val firstString = text.substringBefore("%s") + val secondString = text.substringAfter("%s") + append(firstString) + withLink( + link = + LinkAnnotation.Clickable( + tag = argument, + linkInteractionListener = + object : LinkInteractionListener { + override fun onClick(link: LinkAnnotation) { + onClick(argument) + } + }, + ), + block = { withStyle(style = linkStyle) { append(argument) } }, + ) + append(secondString) +} diff --git a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/util/Clipboard.kt b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/util/Clipboard.kt index 3dbe8e7565..61da23dd5c 100644 --- a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/util/Clipboard.kt +++ b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/util/Clipboard.kt @@ -17,7 +17,7 @@ private const val IS_SENSITIVE_FLAG = "android.content.extra.IS_SENSITIVE" @Composable fun createCopyToClipboardHandle( - snackbarHostState: SnackbarHostState, + snackbarHostState: SnackbarHostState = SnackbarHostState(), isSensitive: Boolean, ): CopyToClipboardHandle { val scope = rememberCoroutineScope() 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 c05502a11a..a70e6f93db 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 @@ -2,6 +2,7 @@ package net.mullvad.mullvadvpn.di import android.content.ComponentName import android.content.pm.PackageManager +import android.os.Build import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.MainScope import net.mullvad.mullvadvpn.BuildConfig @@ -30,7 +31,6 @@ import net.mullvad.mullvadvpn.repository.WireguardConstraintsRepository import net.mullvad.mullvadvpn.ui.MainActivity import net.mullvad.mullvadvpn.ui.serviceconnection.AppVersionInfoRepository import net.mullvad.mullvadvpn.ui.serviceconnection.ServiceConnectionManager -import net.mullvad.mullvadvpn.usecase.AccountExpiryInAppNotificationUseCase import net.mullvad.mullvadvpn.usecase.DeleteCustomDnsUseCase import net.mullvad.mullvadvpn.usecase.EmptyPaymentUseCase import net.mullvad.mullvadvpn.usecase.FilterChipUseCase @@ -38,8 +38,6 @@ import net.mullvad.mullvadvpn.usecase.FilteredRelayListUseCase import net.mullvad.mullvadvpn.usecase.InternetAvailableUseCase import net.mullvad.mullvadvpn.usecase.LastKnownLocationUseCase import net.mullvad.mullvadvpn.usecase.ModifyMultihopUseCase -import net.mullvad.mullvadvpn.usecase.NewChangelogNotificationUseCase -import net.mullvad.mullvadvpn.usecase.NewDeviceNotificationUseCase import net.mullvad.mullvadvpn.usecase.OutOfTimeUseCase import net.mullvad.mullvadvpn.usecase.PaymentUseCase import net.mullvad.mullvadvpn.usecase.PlayPaymentUseCase @@ -49,12 +47,17 @@ import net.mullvad.mullvadvpn.usecase.SelectHopUseCase import net.mullvad.mullvadvpn.usecase.SelectedLocationTitleUseCase import net.mullvad.mullvadvpn.usecase.SelectedLocationUseCase import net.mullvad.mullvadvpn.usecase.SystemVpnSettingsAvailableUseCase -import net.mullvad.mullvadvpn.usecase.TunnelStateNotificationUseCase -import net.mullvad.mullvadvpn.usecase.VersionNotificationUseCase import net.mullvad.mullvadvpn.usecase.customlists.CustomListActionUseCase import net.mullvad.mullvadvpn.usecase.customlists.CustomListRelayItemsUseCase import net.mullvad.mullvadvpn.usecase.customlists.CustomListsRelayItemUseCase import net.mullvad.mullvadvpn.usecase.customlists.FilterCustomListsRelayItemUseCase +import net.mullvad.mullvadvpn.usecase.inappnotification.AccountExpiryInAppNotificationUseCase +import net.mullvad.mullvadvpn.usecase.inappnotification.Android16UpdateWarningUseCase +import net.mullvad.mullvadvpn.usecase.inappnotification.InAppNotificationUseCase +import net.mullvad.mullvadvpn.usecase.inappnotification.NewChangelogNotificationUseCase +import net.mullvad.mullvadvpn.usecase.inappnotification.NewDeviceNotificationUseCase +import net.mullvad.mullvadvpn.usecase.inappnotification.TunnelStateNotificationUseCase +import net.mullvad.mullvadvpn.usecase.inappnotification.VersionNotificationUseCase import net.mullvad.mullvadvpn.util.ChangelogDataProvider import net.mullvad.mullvadvpn.util.IChangelogDataProvider import net.mullvad.mullvadvpn.viewmodel.AccountViewModel @@ -106,6 +109,7 @@ import org.apache.commons.validator.routines.InetAddressValidator import org.koin.android.ext.koin.androidContext import org.koin.core.module.dsl.viewModel import org.koin.core.qualifier.named +import org.koin.dsl.bind import org.koin.dsl.module val uiModule = module { @@ -150,11 +154,18 @@ val uiModule = module { } single { WireguardConstraintsRepository(get()) } - single { AccountExpiryInAppNotificationUseCase(get()) } - single { TunnelStateNotificationUseCase(get(), get(), get()) } - single { VersionNotificationUseCase(get(), BuildConfig.ENABLE_IN_APP_VERSION_NOTIFICATIONS) } - single { NewDeviceNotificationUseCase(get(), get()) } - single { NewChangelogNotificationUseCase(get()) } + single { AccountExpiryInAppNotificationUseCase(get()) } bind InAppNotificationUseCase::class + single { TunnelStateNotificationUseCase(get(), get(), get()) } bind + InAppNotificationUseCase::class + single { + VersionNotificationUseCase(get(), BuildConfig.ENABLE_IN_APP_VERSION_NOTIFICATIONS) + } bind InAppNotificationUseCase::class + single { NewDeviceNotificationUseCase(get(), get()) } bind InAppNotificationUseCase::class + single { NewChangelogNotificationUseCase(get()) } bind InAppNotificationUseCase::class + if (Build.VERSION.SDK_INT == Build.VERSION_CODES.BAKLAVA) { + single { Android16UpdateWarningUseCase(get(), get()) } bind InAppNotificationUseCase::class + } + single { OutOfTimeUseCase(get(), get(), MainScope()) } single { InternetAvailableUseCase(get()) } single { SystemVpnSettingsAvailableUseCase(androidContext()) } @@ -180,7 +191,7 @@ val uiModule = module { ) } - single { InAppNotificationController(get(), get(), get(), get(), get(), MainScope()) } + single { InAppNotificationController(getAll(), MainScope()) } single<IChangelogDataProvider> { ChangelogDataProvider(get()) } @@ -219,6 +230,7 @@ val uiModule = module { changelogRepository = get(), inAppNotificationController = get(), newDeviceRepository = get(), + userPreferencesRepository = get(), selectedLocationTitleUseCase = get(), outOfTimeUseCase = get(), paymentUseCase = get(), diff --git a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/receiver/Android16UpdateWarningReceiver.kt b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/receiver/Android16UpdateWarningReceiver.kt new file mode 100644 index 0000000000..f24a73e8e9 --- /dev/null +++ b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/receiver/Android16UpdateWarningReceiver.kt @@ -0,0 +1,25 @@ +package net.mullvad.mullvadvpn.receiver + +import android.content.BroadcastReceiver +import android.content.Context +import android.content.Intent +import kotlin.getValue +import net.mullvad.mullvadvpn.repository.UserPreferencesRepository +import net.mullvad.mullvadvpn.util.goAsync +import org.koin.core.component.KoinComponent +import org.koin.core.component.inject + +class Android16UpdateWarningReceiver : BroadcastReceiver(), KoinComponent { + private val userPreferencesRepository by inject<UserPreferencesRepository>() + + override fun onReceive(context: Context?, intent: Intent?) { + if (intent?.action == Intent.ACTION_MY_PACKAGE_REPLACED) { + // Check that we run Android 16 (Baklava) + goAsync { + userPreferencesRepository.setShowAndroid16ConnectWarning( + android.os.Build.VERSION.SDK_INT == android.os.Build.VERSION_CODES.BAKLAVA + ) + } + } + } +} diff --git a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/repository/InAppNotificationController.kt b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/repository/InAppNotificationController.kt index a792d2347a..2efb394f26 100644 --- a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/repository/InAppNotificationController.kt +++ b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/repository/InAppNotificationController.kt @@ -5,30 +5,18 @@ import kotlinx.coroutines.flow.SharingStarted import kotlinx.coroutines.flow.combine import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.stateIn -import net.mullvad.mullvadvpn.usecase.AccountExpiryInAppNotificationUseCase -import net.mullvad.mullvadvpn.usecase.NewChangelogNotificationUseCase -import net.mullvad.mullvadvpn.usecase.NewDeviceNotificationUseCase -import net.mullvad.mullvadvpn.usecase.TunnelStateNotificationUseCase -import net.mullvad.mullvadvpn.usecase.VersionNotificationUseCase +import net.mullvad.mullvadvpn.lib.model.InAppNotification +import net.mullvad.mullvadvpn.usecase.inappnotification.InAppNotificationUseCase class InAppNotificationController( - accountExpiryInAppNotificationUseCase: AccountExpiryInAppNotificationUseCase, - newDeviceNotificationUseCase: NewDeviceNotificationUseCase, - newChangelogNotificationUseCase: NewChangelogNotificationUseCase, - versionNotificationUseCase: VersionNotificationUseCase, - tunnelStateNotificationUseCase: TunnelStateNotificationUseCase, + inAppNotificationUseCases: List<InAppNotificationUseCase>, scope: CoroutineScope, ) { val notifications = - combine( - tunnelStateNotificationUseCase(), - versionNotificationUseCase(), - accountExpiryInAppNotificationUseCase(), - newDeviceNotificationUseCase(), - newChangelogNotificationUseCase(), - ) { a, b, c, d, e -> - a + b + c + d + e + combine(inAppNotificationUseCases.map { it.invoke() }) { + notifications: Array<InAppNotification?> -> + notifications.filterNotNull() } .map { it.sortedWith( 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 ad83484dc1..e262cba161 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 @@ -48,4 +48,9 @@ class UserPreferencesRepository( if (expiryTime == 0L) return null Instant.ofEpochSecond(expiryTime).atZone(ZoneId.systemDefault()) } + + suspend fun setShowAndroid16ConnectWarning(show: Boolean) = + userPreferencesStore.updateData { prefs -> + prefs.toBuilder().setShowAndroid16ConnectWarning(show).build() + } } diff --git a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/usecase/AccountExpiryInAppNotificationUseCase.kt b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/usecase/inappnotification/AccountExpiryInAppNotificationUseCase.kt index 9d78b47902..dfae0f17c0 100644 --- a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/usecase/AccountExpiryInAppNotificationUseCase.kt +++ b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/usecase/inappnotification/AccountExpiryInAppNotificationUseCase.kt @@ -1,5 +1,6 @@ -package net.mullvad.mullvadvpn.usecase +package net.mullvad.mullvadvpn.usecase.inappnotification +import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.distinctUntilChanged import kotlinx.coroutines.flow.flatMapLatest @@ -11,10 +12,11 @@ import net.mullvad.mullvadvpn.service.notifications.accountexpiry.ACCOUNT_EXPIRY 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) { +class AccountExpiryInAppNotificationUseCase(private val accountRepository: AccountRepository) : + InAppNotificationUseCase { - @OptIn(kotlinx.coroutines.ExperimentalCoroutinesApi::class) - operator fun invoke(): Flow<List<InAppNotification>> = + @OptIn(ExperimentalCoroutinesApi::class) + override operator fun invoke(): Flow<InAppNotification?> = accountRepository.accountData .flatMapLatest { accountData -> if (accountData != null) { @@ -25,13 +27,13 @@ class AccountExpiryInAppNotificationUseCase(private val accountRepository: Accou ) .map { tick -> when (tick) { - InAppAccountExpiryTicker.NotWithinThreshold -> emptyList() + InAppAccountExpiryTicker.NotWithinThreshold -> null is InAppAccountExpiryTicker.Tick -> - listOf(InAppNotification.AccountExpiry(tick.expiresIn)) + InAppNotification.AccountExpiry(tick.expiresIn) } } } else { - flowOf(emptyList()) + flowOf(null) } } .distinctUntilChanged() diff --git a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/usecase/inappnotification/Android16UpdateWarningUseCase.kt b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/usecase/inappnotification/Android16UpdateWarningUseCase.kt new file mode 100644 index 0000000000..8f131b2bfd --- /dev/null +++ b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/usecase/inappnotification/Android16UpdateWarningUseCase.kt @@ -0,0 +1,68 @@ +package net.mullvad.mullvadvpn.usecase.inappnotification + +import kotlin.time.Duration.Companion.seconds +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.delay +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.flow.distinctUntilChanged +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.flow.transformLatest +import net.mullvad.mullvadvpn.lib.daemon.grpc.ManagementService +import net.mullvad.mullvadvpn.lib.model.ActionAfterDisconnect +import net.mullvad.mullvadvpn.lib.model.InAppNotification +import net.mullvad.mullvadvpn.lib.model.TunnelState +import net.mullvad.mullvadvpn.repository.UserPreferencesRepository + +class Android16UpdateWarningUseCase( + private val userPreferencesRepository: UserPreferencesRepository, + private val managementService: ManagementService, +) : InAppNotificationUseCase { + @OptIn(ExperimentalCoroutinesApi::class) + override operator fun invoke(): Flow<InAppNotification?> = + combine( + userPreferencesRepository + .preferencesFlow() + .map { it.showAndroid16ConnectWarning } + .distinctUntilChanged(), + managementService.tunnelState.map { it.toTunState() }.distinctUntilChanged(), + ) { showWarning, tunState -> + showWarning to tunState + } + .transformLatest { (showWarning, tunState) -> + when { + showWarning && tunState == SimpleTunState.Connecting -> { + emit(null) + delay(SHOW_WARNING_DELAY) + emit(InAppNotification.Android16UpgradeWarning) + } + showWarning && tunState == SimpleTunState.Connected -> { + // User is connected, we know this warning is not relevant so we remove it + // and don't show the warning again. + userPreferencesRepository.setShowAndroid16ConnectWarning(false) + emit(null) + } + else -> emit(null) + } + } + + private fun TunnelState.toTunState(): SimpleTunState = + when (this) { + is TunnelState.Connecting -> SimpleTunState.Connecting + is TunnelState.Disconnecting if + actionAfterDisconnect == ActionAfterDisconnect.Reconnect + -> SimpleTunState.Connecting + is TunnelState.Connected -> SimpleTunState.Connected + else -> SimpleTunState.Other + } + + private enum class SimpleTunState { + Connecting, + Connected, + Other, + } + + companion object { + private val SHOW_WARNING_DELAY = 2.seconds + } +} diff --git a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/usecase/inappnotification/InAppNotificationUseCase.kt b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/usecase/inappnotification/InAppNotificationUseCase.kt new file mode 100644 index 0000000000..5d67ff7d53 --- /dev/null +++ b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/usecase/inappnotification/InAppNotificationUseCase.kt @@ -0,0 +1,8 @@ +package net.mullvad.mullvadvpn.usecase.inappnotification + +import kotlinx.coroutines.flow.Flow +import net.mullvad.mullvadvpn.lib.model.InAppNotification + +interface InAppNotificationUseCase { + operator fun invoke(): Flow<InAppNotification?> +} diff --git a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/usecase/NewChangelogNotificationUseCase.kt b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/usecase/inappnotification/NewChangelogNotificationUseCase.kt index 5936d7b3a6..284364a6f7 100644 --- a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/usecase/NewChangelogNotificationUseCase.kt +++ b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/usecase/inappnotification/NewChangelogNotificationUseCase.kt @@ -1,19 +1,14 @@ -package net.mullvad.mullvadvpn.usecase +package net.mullvad.mullvadvpn.usecase.inappnotification import kotlinx.coroutines.flow.distinctUntilChanged import kotlinx.coroutines.flow.map import net.mullvad.mullvadvpn.lib.model.InAppNotification import net.mullvad.mullvadvpn.repository.ChangelogRepository -class NewChangelogNotificationUseCase(private val changelogRepository: ChangelogRepository) { - operator fun invoke() = +class NewChangelogNotificationUseCase(private val changelogRepository: ChangelogRepository) : + InAppNotificationUseCase { + override operator fun invoke() = changelogRepository.hasUnreadChangelog - .map { - buildList { - if (it) { - add(InAppNotification.NewVersionChangelog) - } - } - } + .map { if (it) InAppNotification.NewVersionChangelog else null } .distinctUntilChanged() } diff --git a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/usecase/NewDeviceNotificationUseCase.kt b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/usecase/inappnotification/NewDeviceNotificationUseCase.kt index 4374ca6037..54480a6dc0 100644 --- a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/usecase/NewDeviceNotificationUseCase.kt +++ b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/usecase/inappnotification/NewDeviceNotificationUseCase.kt @@ -1,4 +1,4 @@ -package net.mullvad.mullvadvpn.usecase +package net.mullvad.mullvadvpn.usecase.inappnotification import kotlinx.coroutines.flow.combine import kotlinx.coroutines.flow.distinctUntilChanged @@ -10,8 +10,8 @@ import net.mullvad.mullvadvpn.repository.NewDeviceRepository class NewDeviceNotificationUseCase( private val newDeviceRepository: NewDeviceRepository, private val deviceRepository: DeviceRepository, -) { - operator fun invoke() = +) : InAppNotificationUseCase { + override operator fun invoke() = combine( deviceRepository.deviceState.map { it?.displayName() }, newDeviceRepository.isNewDevice, @@ -20,6 +20,5 @@ class NewDeviceNotificationUseCase( InAppNotification.NewDevice(deviceName) } else null } - .map(::listOfNotNull) .distinctUntilChanged() } diff --git a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/usecase/TunnelStateNotificationUseCase.kt b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/usecase/inappnotification/TunnelStateNotificationUseCase.kt index 753f8c1eef..8e58c58a36 100644 --- a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/usecase/TunnelStateNotificationUseCase.kt +++ b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/usecase/inappnotification/TunnelStateNotificationUseCase.kt @@ -1,4 +1,4 @@ -package net.mullvad.mullvadvpn.usecase +package net.mullvad.mullvadvpn.usecase.inappnotification import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.flow.Flow @@ -25,9 +25,9 @@ class TunnelStateNotificationUseCase( private val connectionProxy: ConnectionProxy, private val relayListRepository: RelayListRepository, private val settingsRepository: SettingsRepository, -) { +) : InAppNotificationUseCase { @OptIn(ExperimentalCoroutinesApi::class) - operator fun invoke(): Flow<List<InAppNotification>> = + override operator fun invoke(): Flow<InAppNotification?> = connectionProxy.tunnelState .distinctUntilChanged() .map(::tunnelStateNotification) @@ -41,7 +41,6 @@ class TunnelStateNotificationUseCase( ) } } - .map(::listOfNotNull) .distinctUntilChanged() private fun tunnelStateNotification(tunnelUiState: TunnelState): InAppNotification? = diff --git a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/usecase/VersionNotificationUseCase.kt b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/usecase/inappnotification/VersionNotificationUseCase.kt index 6575871f21..d1faea4e4a 100644 --- a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/usecase/VersionNotificationUseCase.kt +++ b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/usecase/inappnotification/VersionNotificationUseCase.kt @@ -1,4 +1,4 @@ -package net.mullvad.mullvadvpn.usecase +package net.mullvad.mullvadvpn.usecase.inappnotification import kotlinx.coroutines.flow.distinctUntilChanged import kotlinx.coroutines.flow.map @@ -9,11 +9,11 @@ import net.mullvad.mullvadvpn.ui.serviceconnection.AppVersionInfoRepository class VersionNotificationUseCase( private val appVersionInfoRepository: AppVersionInfoRepository, private val isVersionInfoNotificationEnabled: Boolean, -) { +) : InAppNotificationUseCase { - operator fun invoke() = + override operator fun invoke() = appVersionInfoRepository.versionInfo - .map { versionInfo -> listOfNotNull(unsupportedVersionNotification(versionInfo)) } + .map { versionInfo -> unsupportedVersionNotification(versionInfo) } .distinctUntilChanged() private fun unsupportedVersionNotification(versionInfo: VersionInfo): InAppNotification? { diff --git a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/viewmodel/ConnectViewModel.kt b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/viewmodel/ConnectViewModel.kt index 4b26fde420..f4c460d880 100644 --- a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/viewmodel/ConnectViewModel.kt +++ b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/viewmodel/ConnectViewModel.kt @@ -34,6 +34,7 @@ import net.mullvad.mullvadvpn.lib.shared.DeviceRepository import net.mullvad.mullvadvpn.repository.ChangelogRepository import net.mullvad.mullvadvpn.repository.InAppNotificationController import net.mullvad.mullvadvpn.repository.NewDeviceRepository +import net.mullvad.mullvadvpn.repository.UserPreferencesRepository import net.mullvad.mullvadvpn.usecase.LastKnownLocationUseCase import net.mullvad.mullvadvpn.usecase.OutOfTimeUseCase import net.mullvad.mullvadvpn.usecase.PaymentUseCase @@ -50,6 +51,7 @@ class ConnectViewModel( private val changelogRepository: ChangelogRepository, inAppNotificationController: InAppNotificationController, private val newDeviceRepository: NewDeviceRepository, + private val userPreferencesRepository: UserPreferencesRepository, selectedLocationTitleUseCase: SelectedLocationTitleUseCase, private val outOfTimeUseCase: OutOfTimeUseCase, private val paymentUseCase: PaymentUseCase, @@ -213,6 +215,9 @@ class ConnectViewModel( newDeviceRepository.clearNewDeviceCreatedNotification() } + fun dismissAndroid16UpgradeWarning() = + viewModelScope.launch { userPreferencesRepository.setShowAndroid16ConnectWarning(false) } + fun dismissNewChangelogNotification() = viewModelScope.launch { changelogRepository.setDismissNewChangelogNotification() } diff --git a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/viewmodel/ViewLogsViewModel.kt b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/viewmodel/ViewLogsViewModel.kt index 27f1156540..1ebd27eab9 100644 --- a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/viewmodel/ViewLogsViewModel.kt +++ b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/viewmodel/ViewLogsViewModel.kt @@ -6,8 +6,8 @@ import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.update import kotlinx.coroutines.launch -import net.mullvad.mullvadvpn.constant.NEWLINE_STRING import net.mullvad.mullvadvpn.dataproxy.MullvadProblemReport +import net.mullvad.mullvadvpn.lib.ui.component.NEWLINE_STRING data class ViewLogsUiState( val allLines: List<String> = emptyList(), diff --git a/android/app/src/main/proto/user_prefs.proto b/android/app/src/main/proto/user_prefs.proto index ca8ba3e0ba..c61ac08acc 100644 --- a/android/app/src/main/proto/user_prefs.proto +++ b/android/app/src/main/proto/user_prefs.proto @@ -7,4 +7,5 @@ message UserPreferences { bool is_privacy_disclosure_accepted = 1; int32 last_shown_changelog_version_code = 2; int64 account_expiry_unix_time_seconds = 3; + bool show_android_16_connect_warning = 4; } diff --git a/android/app/src/test/kotlin/net/mullvad/mullvadvpn/InAppNotificationControllerTest.kt b/android/app/src/test/kotlin/net/mullvad/mullvadvpn/InAppNotificationControllerTest.kt index f9ee9f3d41..caeba3aa0a 100644 --- a/android/app/src/test/kotlin/net/mullvad/mullvadvpn/InAppNotificationControllerTest.kt +++ b/android/app/src/test/kotlin/net/mullvad/mullvadvpn/InAppNotificationControllerTest.kt @@ -17,11 +17,11 @@ import net.mullvad.mullvadvpn.lib.common.test.TestCoroutineRule import net.mullvad.mullvadvpn.lib.model.ErrorState import net.mullvad.mullvadvpn.lib.model.InAppNotification import net.mullvad.mullvadvpn.repository.InAppNotificationController -import net.mullvad.mullvadvpn.usecase.AccountExpiryInAppNotificationUseCase -import net.mullvad.mullvadvpn.usecase.NewChangelogNotificationUseCase -import net.mullvad.mullvadvpn.usecase.NewDeviceNotificationUseCase -import net.mullvad.mullvadvpn.usecase.TunnelStateNotificationUseCase -import net.mullvad.mullvadvpn.usecase.VersionNotificationUseCase +import net.mullvad.mullvadvpn.usecase.inappnotification.AccountExpiryInAppNotificationUseCase +import net.mullvad.mullvadvpn.usecase.inappnotification.NewChangelogNotificationUseCase +import net.mullvad.mullvadvpn.usecase.inappnotification.NewDeviceNotificationUseCase +import net.mullvad.mullvadvpn.usecase.inappnotification.TunnelStateNotificationUseCase +import net.mullvad.mullvadvpn.usecase.inappnotification.VersionNotificationUseCase import org.junit.jupiter.api.AfterEach import org.junit.jupiter.api.BeforeEach import org.junit.jupiter.api.Test @@ -32,12 +32,13 @@ import org.junit.jupiter.api.extension.ExtendWith class InAppNotificationControllerTest { private lateinit var inAppNotificationController: InAppNotificationController - private val accountExpiryNotifications = MutableStateFlow(emptyList<InAppNotification>()) - private val newDeviceNotifications = MutableStateFlow(emptyList<InAppNotification.NewDevice>()) + private val accountExpiryNotifications = + MutableStateFlow<InAppNotification.AccountExpiry?>(null) + private val newDeviceNotifications = MutableStateFlow<InAppNotification.NewDevice?>(null) private val newVersionChangelogNotifications = - MutableStateFlow(emptyList<InAppNotification.NewVersionChangelog>()) - private val versionNotifications = MutableStateFlow(emptyList<InAppNotification>()) - private val tunnelStateNotifications = MutableStateFlow(emptyList<InAppNotification>()) + MutableStateFlow<InAppNotification.NewVersionChangelog?>(null) + private val versionNotifications = MutableStateFlow<InAppNotification.UnsupportedVersion?>(null) + private val tunnelStateNotifications = MutableStateFlow<InAppNotification?>(null) private lateinit var job: Job @@ -60,11 +61,13 @@ class InAppNotificationControllerTest { inAppNotificationController = InAppNotificationController( - accountExpiryInAppNotificationUseCase, - newDeviceNotificationUseCase, - newVersionChangelogUseCase, - versionNotificationUseCase, - tunnelStateNotificationUseCase, + listOf( + accountExpiryInAppNotificationUseCase, + newDeviceNotificationUseCase, + newVersionChangelogUseCase, + versionNotificationUseCase, + tunnelStateNotificationUseCase, + ), CoroutineScope(job + UnconfinedTestDispatcher()), ) } @@ -78,29 +81,27 @@ class InAppNotificationControllerTest { @Test fun `ensure all notifications have the right priority`() = runTest { val newDevice = InAppNotification.NewDevice("") - newDeviceNotifications.value = listOf(newDevice) + newDeviceNotifications.value = newDevice val newVersionChangelog = InAppNotification.NewVersionChangelog - newVersionChangelogNotifications.value = listOf(newVersionChangelog) + newVersionChangelogNotifications.value = newVersionChangelog val errorState: ErrorState = mockk() every { errorState.cause } returns mockk() val tunnelStateBlocked = InAppNotification.TunnelStateBlocked - val tunnelStateError = InAppNotification.TunnelStateError(errorState) - tunnelStateNotifications.value = listOf(tunnelStateBlocked, tunnelStateError) + tunnelStateNotifications.value = tunnelStateBlocked val unsupportedVersion = InAppNotification.UnsupportedVersion(mockk()) - versionNotifications.value = listOf(unsupportedVersion) + versionNotifications.value = unsupportedVersion val accountExpiry = InAppNotification.AccountExpiry(Duration.ZERO) - accountExpiryNotifications.value = listOf(accountExpiry) + accountExpiryNotifications.value = accountExpiry inAppNotificationController.notifications.test { val notifications = awaitItem() assertEquals( listOf( - tunnelStateError, tunnelStateBlocked, unsupportedVersion, accountExpiry, 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 0557cc5786..4de9f38b53 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 @@ -22,9 +22,11 @@ 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_NOTIFICATION_UPDATE_INTERVAL +import net.mullvad.mullvadvpn.usecase.inappnotification.AccountExpiryInAppNotificationUseCase import org.junit.jupiter.api.AfterEach import org.junit.jupiter.api.BeforeEach import org.junit.jupiter.api.Test +import org.junit.jupiter.api.assertNull import org.junit.jupiter.api.extension.ExtendWith @ExtendWith(TestCoroutineRule::class) @@ -55,14 +57,14 @@ class AccountExpiryInAppNotificationUseCaseTest { } @Test - fun `initial state should be empty`() = runTest { - accountExpiryInAppNotificationUseCase().test { assertTrue { awaitItem().isEmpty() } } + fun `initial state should be null`() = runTest { + accountExpiryInAppNotificationUseCase().test { assertNull(awaitItem()) } } @Test fun `account that expires within the threshold should emit a notification`() = runTest { accountExpiryInAppNotificationUseCase().test { - assertTrue { awaitItem().isEmpty() } + assertNull(awaitItem()) val expiry = setExpiry(notificationThreshold.minusHours(1)) assertExpiryNotificationDuration(expiry, expectMostRecentItem()) expectNoEvents() @@ -72,7 +74,7 @@ class AccountExpiryInAppNotificationUseCaseTest { @Test fun `account that expires after the threshold should not emit a notification`() = runTest { accountExpiryInAppNotificationUseCase().test { - assertTrue { awaitItem().isEmpty() } + assertNull(awaitItem()) setExpiry(notificationThreshold.plusDays(1)) expectNoEvents() } @@ -81,7 +83,7 @@ class AccountExpiryInAppNotificationUseCaseTest { @Test fun `should emit when the threshold is passed`() = runTest { accountExpiryInAppNotificationUseCase().test { - assertTrue { awaitItem().isEmpty() } + assertNull(awaitItem()) val expiry = setExpiry(notificationThreshold.plusMinutes(1)) expectNoEvents() @@ -99,7 +101,7 @@ class AccountExpiryInAppNotificationUseCaseTest { @Test fun `should emit zero duration when the time expires`() = runTest { accountExpiryInAppNotificationUseCase().test { - assertTrue { awaitItem().isEmpty() } + assertNull(awaitItem()) // Set expiry to to be in the final update interval. val inLastUpdate = @@ -124,7 +126,7 @@ class AccountExpiryInAppNotificationUseCaseTest { setExpiry( ZonedDateTime.now().plus(ACCOUNT_EXPIRY_CLOSE_TO_EXPIRY_THRESHOLD).plusDays(1) ) - assertEquals(emptyList(), expectMostRecentItem()) + assertNull(expectMostRecentItem()) } } @@ -139,20 +141,18 @@ class AccountExpiryInAppNotificationUseCaseTest { // ZonedDateTime.now) private fun assertExpiryNotificationDuration( expiry: ZonedDateTime, - notifications: List<InAppNotification>, + notification: InAppNotification?, ) { - val notificationDuration = getExpiryNotificationDuration(notifications) + val notificationDuration = getExpiryNotificationDuration(notification) val expiresFromNow = Duration.between(ZonedDateTime.now(), expiry) assertTrue(expiresFromNow <= notificationDuration) assertTrue(expiresFromNow.plus(Duration.ofSeconds(5)) > notificationDuration) } - private fun getExpiryNotificationDuration(notifications: List<InAppNotification>): Duration { - assertTrue(notifications.size == 1, "Expected a single notification") - val n = notifications[0] - if (n !is InAppNotification.AccountExpiry) { + private fun getExpiryNotificationDuration(notification: InAppNotification?): Duration { + if (notification !is InAppNotification.AccountExpiry) { error("Expected an AccountExpiry notification") } - return n.expiry + return notification.expiry } } diff --git a/android/app/src/test/kotlin/net/mullvad/mullvadvpn/usecase/NewDeviceNotificationUseCaseTest.kt b/android/app/src/test/kotlin/net/mullvad/mullvadvpn/usecase/NewDeviceNotificationUseCaseTest.kt index 414c7c1e08..71ee5253fa 100644 --- a/android/app/src/test/kotlin/net/mullvad/mullvadvpn/usecase/NewDeviceNotificationUseCaseTest.kt +++ b/android/app/src/test/kotlin/net/mullvad/mullvadvpn/usecase/NewDeviceNotificationUseCaseTest.kt @@ -7,7 +7,6 @@ import io.mockk.impl.annotations.MockK import io.mockk.unmockkAll import java.time.ZonedDateTime import kotlin.test.assertEquals -import kotlin.test.assertTrue import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.test.runTest import net.mullvad.mullvadvpn.data.UUID @@ -19,9 +18,11 @@ import net.mullvad.mullvadvpn.lib.model.DeviceState import net.mullvad.mullvadvpn.lib.model.InAppNotification import net.mullvad.mullvadvpn.lib.shared.DeviceRepository import net.mullvad.mullvadvpn.repository.NewDeviceRepository +import net.mullvad.mullvadvpn.usecase.inappnotification.NewDeviceNotificationUseCase import org.junit.jupiter.api.AfterEach import org.junit.jupiter.api.BeforeEach import org.junit.jupiter.api.Test +import org.junit.jupiter.api.assertNull import org.junit.jupiter.api.extension.ExtendWith @ExtendWith(TestCoroutineRule::class) @@ -69,7 +70,7 @@ class NewDeviceNotificationUseCaseTest { @Test fun `initial state should be empty`() = runTest { // Arrange, Act, Assert - newDeviceNotificationUseCase().test { assertTrue { awaitItem().isEmpty() } } + newDeviceNotificationUseCase().test { assertNull(awaitItem()) } } @Test @@ -81,7 +82,7 @@ class NewDeviceNotificationUseCaseTest { isNewDeviceState.value = true // Assert - assertEquals(awaitItem(), listOf(InAppNotification.NewDevice(deviceName))) + assertEquals(awaitItem(), InAppNotification.NewDevice(deviceName)) } } @@ -96,7 +97,7 @@ class NewDeviceNotificationUseCaseTest { isNewDeviceState.value = false // Assert - assertEquals(awaitItem(), emptyList()) + assertNull(awaitItem()) } } } diff --git a/android/app/src/test/kotlin/net/mullvad/mullvadvpn/usecase/TunnelStateNotificationUseCaseTest.kt b/android/app/src/test/kotlin/net/mullvad/mullvadvpn/usecase/TunnelStateNotificationUseCaseTest.kt index 6544913748..62d3ed0877 100644 --- a/android/app/src/test/kotlin/net/mullvad/mullvadvpn/usecase/TunnelStateNotificationUseCaseTest.kt +++ b/android/app/src/test/kotlin/net/mullvad/mullvadvpn/usecase/TunnelStateNotificationUseCaseTest.kt @@ -8,7 +8,6 @@ import io.mockk.unmockkAll import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.test.runTest import net.mullvad.mullvadvpn.lib.common.test.TestCoroutineRule -import net.mullvad.mullvadvpn.lib.common.test.assertLists import net.mullvad.mullvadvpn.lib.model.ActionAfterDisconnect import net.mullvad.mullvadvpn.lib.model.Constraint import net.mullvad.mullvadvpn.lib.model.ErrorState @@ -23,11 +22,13 @@ import net.mullvad.mullvadvpn.lib.model.TunnelState import net.mullvad.mullvadvpn.lib.shared.ConnectionProxy import net.mullvad.mullvadvpn.repository.RelayListRepository import net.mullvad.mullvadvpn.repository.SettingsRepository +import net.mullvad.mullvadvpn.usecase.inappnotification.TunnelStateNotificationUseCase import org.junit.jupiter.api.AfterEach import org.junit.jupiter.api.Assertions.assertEquals import org.junit.jupiter.api.Assertions.assertTrue import org.junit.jupiter.api.BeforeEach import org.junit.jupiter.api.Test +import org.junit.jupiter.api.assertNull import org.junit.jupiter.api.extension.ExtendWith @ExtendWith(TestCoroutineRule::class) @@ -66,20 +67,20 @@ class TunnelStateNotificationUseCaseTest { @Test fun `initial state should be empty`() = runTest { // Arrange, Act, Assert - tunnelStateNotificationUseCase().test { assertTrue(awaitItem().isEmpty()) } + tunnelStateNotificationUseCase().test { assertNull(awaitItem()) } } @Test fun `when TunnelState is error use case should emit TunnelStateError notification`() = runTest { tunnelStateNotificationUseCase().test { // Arrange, Act - assertLists(emptyList(), awaitItem()) + assertNull(awaitItem()) val errorState: ErrorState = mockk() every { errorState.cause } returns mockk() tunnelState.emit(TunnelState.Error(errorState)) // Assert - assertEquals(listOf(InAppNotification.TunnelStateError(errorState)), awaitItem()) + assertEquals(InAppNotification.TunnelStateError(errorState), awaitItem()) } } @@ -88,11 +89,11 @@ class TunnelStateNotificationUseCaseTest { runTest { tunnelStateNotificationUseCase().test { // Arrange, Act - assertLists(emptyList(), awaitItem()) + assertNull(awaitItem()) tunnelState.emit(TunnelState.Disconnecting(ActionAfterDisconnect.Block)) // Assert - assertEquals(listOf(InAppNotification.TunnelStateBlocked), awaitItem()) + assertEquals(InAppNotification.TunnelStateBlocked, awaitItem()) } } @@ -101,7 +102,7 @@ class TunnelStateNotificationUseCaseTest { runTest { tunnelStateNotificationUseCase().test { // Arrange, Act - assertLists(emptyList(), awaitItem()) + assertNull(awaitItem()) val errorState: ErrorState = mockk() every { errorState.isBlocking } returns true every { errorState.cause } returns @@ -117,7 +118,7 @@ class TunnelStateNotificationUseCaseTest { // Assert val item = awaitItem() assertTrue { - (item.first() as InAppNotification.TunnelStateError).error.cause is + (item as InAppNotification.TunnelStateError).error.cause is NoRelaysMatchSelectedPort } } @@ -128,7 +129,7 @@ class TunnelStateNotificationUseCaseTest { runTest { tunnelStateNotificationUseCase().test { // Arrange, Act - assertLists(emptyList(), awaitItem()) + assertNull(awaitItem()) val errorState: ErrorState = mockk() every { errorState.isBlocking } returns true every { errorState.cause } returns @@ -143,10 +144,9 @@ class TunnelStateNotificationUseCaseTest { // Assert val item = awaitItem() - assertEquals(listOf(InAppNotification.TunnelStateError(errorState)), item) + assertEquals(InAppNotification.TunnelStateError(errorState), item) assertTrue { - (item.first() as InAppNotification.TunnelStateError).error.cause is - TunnelParameterError + (item as InAppNotification.TunnelStateError).error.cause is TunnelParameterError } } } diff --git a/android/app/src/test/kotlin/net/mullvad/mullvadvpn/usecase/VersionNotificationUseCaseTest.kt b/android/app/src/test/kotlin/net/mullvad/mullvadvpn/usecase/VersionNotificationUseCaseTest.kt index 78f2fb72df..9b053e6887 100644 --- a/android/app/src/test/kotlin/net/mullvad/mullvadvpn/usecase/VersionNotificationUseCaseTest.kt +++ b/android/app/src/test/kotlin/net/mullvad/mullvadvpn/usecase/VersionNotificationUseCaseTest.kt @@ -6,16 +6,17 @@ import io.mockk.every import io.mockk.mockk import io.mockk.unmockkAll import kotlin.test.assertEquals -import kotlin.test.assertTrue import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.test.runTest import net.mullvad.mullvadvpn.lib.common.test.TestCoroutineRule import net.mullvad.mullvadvpn.lib.model.InAppNotification import net.mullvad.mullvadvpn.lib.model.VersionInfo import net.mullvad.mullvadvpn.ui.serviceconnection.AppVersionInfoRepository +import net.mullvad.mullvadvpn.usecase.inappnotification.VersionNotificationUseCase import org.junit.jupiter.api.AfterEach import org.junit.jupiter.api.BeforeEach import org.junit.jupiter.api.Test +import org.junit.jupiter.api.assertNull import org.junit.jupiter.api.extension.ExtendWith @ExtendWith(TestCoroutineRule::class) @@ -46,7 +47,7 @@ class VersionNotificationUseCaseTest { @Test fun `initial state should be empty`() = runTest { // Arrange, Act, Assert - versionNotificationUseCase().test { assertTrue { awaitItem().isEmpty() } } + versionNotificationUseCase().test { assertNull(awaitItem()) } } @Test @@ -59,10 +60,7 @@ class VersionNotificationUseCaseTest { versionInfo.value = upgradeVersionInfo // Assert - assertEquals( - awaitItem(), - listOf(InAppNotification.UnsupportedVersion(upgradeVersionInfo)), - ) + assertEquals(awaitItem(), InAppNotification.UnsupportedVersion(upgradeVersionInfo)) } } } diff --git a/android/app/src/test/kotlin/net/mullvad/mullvadvpn/viewmodel/ConnectViewModelTest.kt b/android/app/src/test/kotlin/net/mullvad/mullvadvpn/viewmodel/ConnectViewModelTest.kt index 44f46b2778..519b182caa 100644 --- a/android/app/src/test/kotlin/net/mullvad/mullvadvpn/viewmodel/ConnectViewModelTest.kt +++ b/android/app/src/test/kotlin/net/mullvad/mullvadvpn/viewmodel/ConnectViewModelTest.kt @@ -124,6 +124,7 @@ class ConnectViewModelTest { changelogRepository = mockChangelogRepository, inAppNotificationController = mockInAppNotificationController, newDeviceRepository = mockk(), + userPreferencesRepository = mockk(), outOfTimeUseCase = outOfTimeUseCase, paymentUseCase = mockPaymentUseCase, selectedLocationTitleUseCase = mockSelectedLocationTitleUseCase, diff --git a/android/lib/model/src/main/kotlin/net/mullvad/mullvadvpn/lib/model/InAppNotification.kt b/android/lib/model/src/main/kotlin/net/mullvad/mullvadvpn/lib/model/InAppNotification.kt index 7a681da66c..77cfd97e5c 100644 --- a/android/lib/model/src/main/kotlin/net/mullvad/mullvadvpn/lib/model/InAppNotification.kt +++ b/android/lib/model/src/main/kotlin/net/mullvad/mullvadvpn/lib/model/InAppNotification.kt @@ -20,12 +20,17 @@ sealed class InAppNotification { } else { StatusLevel.Error } - override val priority: Long = 1004 + override val priority: Long = 1005 + } + + data object Android16UpgradeWarning : InAppNotification() { + override val statusLevel = StatusLevel.Warning + override val priority: Long = 1005 } data object TunnelStateBlocked : InAppNotification() { override val statusLevel = StatusLevel.None - override val priority: Long = 1003 + override val priority: Long = 1004 } data class UnsupportedVersion(val versionInfo: VersionInfo) : InAppNotification() { diff --git a/android/lib/resource/src/main/res/values/strings.xml b/android/lib/resource/src/main/res/values/strings.xml index 03379ec5b8..bc07a73fd7 100644 --- a/android/lib/resource/src/main/res/values/strings.xml +++ b/android/lib/resource/src/main/res/values/strings.xml @@ -440,4 +440,9 @@ <string name="no_matching_servers_found_second_line">Please try changing your filters.</string> <string name="refresh_server_list">Update server list</string> <string name="updating_server_list_in_the_background">Updating server list in the background...</string> + <string name="android_16_upgrade_warning_title">Can\'t connect?</string> + <string name="android_16_upgrade_warning_message">Android 16 has a known issue. Please restart your device and try again. To learn more, </string> + <string name="android_16_upgrade_warning_dialog_first_message">After updating a VPN app on Android 16, devices might end up in a state where VPN apps are no longer able to reach the internet.</string> + <string name="android_16_upgrade_warning_dialog_second_message">Please restart your device and try connecting again. If this does not work, please write an email to %s in Swedish or English.</string> + <string name="click_here">click here</string> </resources> diff --git a/android/lib/resource/src/main/res/values/strings_non_translatable.xml b/android/lib/resource/src/main/res/values/strings_non_translatable.xml index aab7a4117a..3595fe7096 100644 --- a/android/lib/resource/src/main/res/values/strings_non_translatable.xml +++ b/android/lib/resource/src/main/res/values/strings_non_translatable.xml @@ -17,4 +17,5 @@ </string> <string name="daita" translatable="false">DAITA</string> <string name="daita_full" translatable="false">Defence against AI-guided Traffic Analysis</string> + <string name="support_email">support@mullvadvpn.net</string> </resources> diff --git a/android/lib/tv/src/main/kotlin/net/mullvad/mullvadvpn/lib/tv/NotificationBannerTv.kt b/android/lib/tv/src/main/kotlin/net/mullvad/mullvadvpn/lib/tv/NotificationBannerTv.kt index 8208e5f6ec..e375c43c08 100644 --- a/android/lib/tv/src/main/kotlin/net/mullvad/mullvadvpn/lib/tv/NotificationBannerTv.kt +++ b/android/lib/tv/src/main/kotlin/net/mullvad/mullvadvpn/lib/tv/NotificationBannerTv.kt @@ -24,9 +24,11 @@ fun PreviewNotificationBannerTv() { openAppListing = {}, onClickShowAccount = {}, onClickShowChangelog = {}, + onClickShowAndroid16UpgradeInfo = {}, onClickDismissChangelog = {}, onClickDismissNewDevice = {}, onClickShowWireguardPortSettings = {}, + onClickDismissAndroid16UpgradeWarning = {}, ) } } @@ -40,9 +42,11 @@ fun NotificationBannerTv( contentFocusRequester: FocusRequester = FocusRequester(), onClickShowAccount: () -> Unit, onClickShowChangelog: () -> Unit, + onClickShowAndroid16UpgradeInfo: () -> Unit, onClickDismissChangelog: () -> Unit, onClickDismissNewDevice: () -> Unit, onClickShowWireguardPortSettings: () -> Unit, + onClickDismissAndroid16UpgradeWarning: () -> Unit, ) { AnimatedNotificationBanner( modifier = modifier, @@ -63,8 +67,10 @@ fun NotificationBannerTv( contentFocusRequester = contentFocusRequester, onClickShowAccount = onClickShowAccount, onClickShowChangelog = onClickShowChangelog, + onClickShowAndroid16UpgradeInfo = onClickShowAndroid16UpgradeInfo, onClickDismissChangelog = onClickDismissChangelog, onClickDismissNewDevice = onClickDismissNewDevice, onClickShowWireguardPortSettings = onClickShowWireguardPortSettings, + onClickDismissAndroid16UpgradeWarning = onClickDismissAndroid16UpgradeWarning, ) } diff --git a/android/lib/ui/component/src/main/kotlin/net/mullvad/mullvadvpn/lib/ui/component/AnimatedNotificationBanner.kt b/android/lib/ui/component/src/main/kotlin/net/mullvad/mullvadvpn/lib/ui/component/AnimatedNotificationBanner.kt index 94a935db74..b9799fab33 100644 --- a/android/lib/ui/component/src/main/kotlin/net/mullvad/mullvadvpn/lib/ui/component/AnimatedNotificationBanner.kt +++ b/android/lib/ui/component/src/main/kotlin/net/mullvad/mullvadvpn/lib/ui/component/AnimatedNotificationBanner.kt @@ -49,9 +49,11 @@ fun AnimatedNotificationBanner( contentFocusRequester: FocusRequester, onClickShowAccount: () -> Unit, onClickShowChangelog: () -> Unit, + onClickShowAndroid16UpgradeInfo: () -> Unit, onClickDismissChangelog: () -> Unit, onClickDismissNewDevice: () -> Unit, onClickShowWireguardPortSettings: () -> Unit, + onClickDismissAndroid16UpgradeWarning: () -> Unit, ) { // Fix for animating to invisible state val previous = rememberPrevious(current = notification, shouldUpdate = { _, _ -> true }) @@ -81,9 +83,11 @@ fun AnimatedNotificationBanner( openAppListing, onClickShowAccount, onClickShowChangelog, + onClickShowAndroid16UpgradeInfo, onClickDismissChangelog, onClickDismissNewDevice, onClickShowWireguardPortSettings, + onClickDismissAndroid16UpgradeWarning, ), ) } diff --git a/android/lib/ui/component/src/main/kotlin/net/mullvad/mullvadvpn/lib/ui/component/NotificationData.kt b/android/lib/ui/component/src/main/kotlin/net/mullvad/mullvadvpn/lib/ui/component/NotificationData.kt index 3af3b8b4ae..9f4c17dbd9 100644 --- a/android/lib/ui/component/src/main/kotlin/net/mullvad/mullvadvpn/lib/ui/component/NotificationData.kt +++ b/android/lib/ui/component/src/main/kotlin/net/mullvad/mullvadvpn/lib/ui/component/NotificationData.kt @@ -25,6 +25,8 @@ import net.mullvad.mullvadvpn.lib.model.ErrorStateCause import net.mullvad.mullvadvpn.lib.model.InAppNotification import net.mullvad.mullvadvpn.lib.model.ParameterGenerationError import net.mullvad.mullvadvpn.lib.model.StatusLevel +import net.mullvad.mullvadvpn.lib.ui.component.NotificationMessage.ClickableText +import net.mullvad.mullvadvpn.lib.ui.component.NotificationMessage.Text data class NotificationData( val title: AnnotatedString, @@ -70,15 +72,18 @@ data class NotificationAction( val contentDescription: String, ) +@Suppress("LongMethod") @Composable fun InAppNotification.toNotificationData( isPlayBuild: Boolean, openAppListing: () -> Unit, onClickShowAccount: () -> Unit, onClickShowChangelog: () -> Unit, + onClickShowAndroid16UpgradeInfo: () -> Unit, onClickDismissChangelog: () -> Unit, onClickDismissNewDevice: () -> Unit, onClickShowWireguardPortSettings: () -> Unit, + onClickDismissAndroid16UpgradeWarning: () -> Unit, ) = when (this) { is InAppNotification.NewDevice -> @@ -86,7 +91,7 @@ fun InAppNotification.toNotificationData( title = AnnotatedString(stringResource(id = R.string.new_device_notification_title)), message = - NotificationMessage.Text( + Text( stringResource(id = R.string.new_device_notification_message, deviceName) .formatWithHtml() ), @@ -135,7 +140,7 @@ fun InAppNotification.toNotificationData( NotificationData( title = stringResource(id = R.string.new_changelog_notification_title), message = - NotificationMessage.ClickableText( + ClickableText( text = buildAnnotatedString { withStyle(SpanStyle(textDecoration = TextDecoration.Underline)) { @@ -158,6 +163,40 @@ fun InAppNotification.toNotificationData( stringResource(id = R.string.dismiss), ), ) + + InAppNotification.Android16UpgradeWarning -> + NotificationData( + title = stringResource(id = R.string.android_16_upgrade_warning_title), + message = + ClickableText( + text = + buildAnnotatedString { + append( + stringResource(id = R.string.android_16_upgrade_warning_message) + ) + append(SPACE_CHAR) + withStyle( + SpanStyle( + textDecoration = TextDecoration.Underline, + color = MaterialTheme.colorScheme.onSurface, + ) + ) { + append(stringResource(R.string.click_here)) + } + append(DOT_CHAR) + }, + onClick = onClickShowAndroid16UpgradeInfo, + contentDescription = + stringResource(id = R.string.new_changelog_notification_message), + ), + statusLevel = statusLevel, + action = + NotificationAction( + Icons.Default.Clear, + onClickDismissAndroid16UpgradeWarning, + stringResource(id = R.string.dismiss), + ), + ) } @Composable diff --git a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/constant/StringConstant.kt b/android/lib/ui/component/src/main/kotlin/net/mullvad/mullvadvpn/lib/ui/component/StringConstant.kt index 90e6897be0..3198dc8101 100644 --- a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/constant/StringConstant.kt +++ b/android/lib/ui/component/src/main/kotlin/net/mullvad/mullvadvpn/lib/ui/component/StringConstant.kt @@ -1,6 +1,7 @@ -package net.mullvad.mullvadvpn.constant +package net.mullvad.mullvadvpn.lib.ui.component const val EMPTY_STRING = "" const val SPACE_CHAR = ' ' const val NEWLINE_STRING = "\n" const val HTML_NEWLINE_STRING = "<br/>" +const val DOT_CHAR = '.' diff --git a/android/test/arch/src/test/kotlin/net/mullvad/mullvadvpn/test/arch/UseCaseTest.kt b/android/test/arch/src/test/kotlin/net/mullvad/mullvadvpn/test/arch/UseCaseTest.kt index 305ba7127b..283e051638 100644 --- a/android/test/arch/src/test/kotlin/net/mullvad/mullvadvpn/test/arch/UseCaseTest.kt +++ b/android/test/arch/src/test/kotlin/net/mullvad/mullvadvpn/test/arch/UseCaseTest.kt @@ -25,5 +25,8 @@ class UseCaseTest { Konsist.scopeFromProduction().files.filter { it.resideInPath("..usecase..") } private fun allUseCases() = - Konsist.scopeFromProduction().classes().filter { it.resideInPackage("..usecase..") } + Konsist.scopeFromProduction() + .classes() + .filter { it.resideInPackage("..usecase..") } + .filter { !it.hasPrivateModifier } } |
