summaryrefslogtreecommitdiffhomepage
path: root/android
diff options
context:
space:
mode:
authorDavid Göransson <david.goransson@mullvad.net>2025-10-02 11:27:53 +0200
committerJonatan Rhodin <jonatan.rhodin@mullvad.net>2025-10-06 11:43:15 +0200
commitd229d24b508f97c24fcc0a4a2db4f845141fd931 (patch)
tree19241200a1830c7c5b8703714d2055b8d87619e7 /android
parentf82099962e5160b4577038b794170fa2f70ed546 (diff)
downloadmullvadvpn-d229d24b508f97c24fcc0a4a2db4f845141fd931.tar.xz
mullvadvpn-d229d24b508f97c24fcc0a4a2db4f845141fd931.zip
Warn users about android 16 upgrade issue
Diffstat (limited to 'android')
-rw-r--r--android/app/build.gradle.kts3
-rw-r--r--android/app/src/androidTest/kotlin/net/mullvad/mullvadvpn/compose/screen/ConnectScreenTest.kt4
-rw-r--r--android/app/src/main/AndroidManifest.xml12
-rw-r--r--android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/component/connectioninfo/ConnectionDetailPanel.kt2
-rw-r--r--android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/component/notificationbanner/NotificationBanner.kt8
-rw-r--r--android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/dialog/info/Android16UpgradeWarningDialog.kt53
-rw-r--r--android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/dialog/info/InfoDialog.kt56
-rw-r--r--android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/dialog/info/LocalNetworkSharingInfoDialog.kt2
-rw-r--r--android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/dialog/info/ObfuscationInfoDialog.kt2
-rw-r--r--android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/screen/ConnectScreen.kt15
-rw-r--r--android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/textfield/CustomTextField.kt4
-rw-r--r--android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/util/ClickableString.kt37
-rw-r--r--android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/util/Clipboard.kt2
-rw-r--r--android/app/src/main/kotlin/net/mullvad/mullvadvpn/di/UiModule.kt34
-rw-r--r--android/app/src/main/kotlin/net/mullvad/mullvadvpn/receiver/Android16UpdateWarningReceiver.kt25
-rw-r--r--android/app/src/main/kotlin/net/mullvad/mullvadvpn/repository/InAppNotificationController.kt24
-rw-r--r--android/app/src/main/kotlin/net/mullvad/mullvadvpn/repository/UserPreferencesRepository.kt5
-rw-r--r--android/app/src/main/kotlin/net/mullvad/mullvadvpn/usecase/inappnotification/AccountExpiryInAppNotificationUseCase.kt (renamed from android/app/src/main/kotlin/net/mullvad/mullvadvpn/usecase/AccountExpiryInAppNotificationUseCase.kt)16
-rw-r--r--android/app/src/main/kotlin/net/mullvad/mullvadvpn/usecase/inappnotification/Android16UpdateWarningUseCase.kt68
-rw-r--r--android/app/src/main/kotlin/net/mullvad/mullvadvpn/usecase/inappnotification/InAppNotificationUseCase.kt8
-rw-r--r--android/app/src/main/kotlin/net/mullvad/mullvadvpn/usecase/inappnotification/NewChangelogNotificationUseCase.kt (renamed from android/app/src/main/kotlin/net/mullvad/mullvadvpn/usecase/NewChangelogNotificationUseCase.kt)15
-rw-r--r--android/app/src/main/kotlin/net/mullvad/mullvadvpn/usecase/inappnotification/NewDeviceNotificationUseCase.kt (renamed from android/app/src/main/kotlin/net/mullvad/mullvadvpn/usecase/NewDeviceNotificationUseCase.kt)7
-rw-r--r--android/app/src/main/kotlin/net/mullvad/mullvadvpn/usecase/inappnotification/TunnelStateNotificationUseCase.kt (renamed from android/app/src/main/kotlin/net/mullvad/mullvadvpn/usecase/TunnelStateNotificationUseCase.kt)7
-rw-r--r--android/app/src/main/kotlin/net/mullvad/mullvadvpn/usecase/inappnotification/VersionNotificationUseCase.kt (renamed from android/app/src/main/kotlin/net/mullvad/mullvadvpn/usecase/VersionNotificationUseCase.kt)8
-rw-r--r--android/app/src/main/kotlin/net/mullvad/mullvadvpn/viewmodel/ConnectViewModel.kt5
-rw-r--r--android/app/src/main/kotlin/net/mullvad/mullvadvpn/viewmodel/ViewLogsViewModel.kt2
-rw-r--r--android/app/src/main/proto/user_prefs.proto1
-rw-r--r--android/app/src/test/kotlin/net/mullvad/mullvadvpn/InAppNotificationControllerTest.kt45
-rw-r--r--android/app/src/test/kotlin/net/mullvad/mullvadvpn/usecase/AccountExpiryInAppNotificationUseCaseTest.kt28
-rw-r--r--android/app/src/test/kotlin/net/mullvad/mullvadvpn/usecase/NewDeviceNotificationUseCaseTest.kt9
-rw-r--r--android/app/src/test/kotlin/net/mullvad/mullvadvpn/usecase/TunnelStateNotificationUseCaseTest.kt24
-rw-r--r--android/app/src/test/kotlin/net/mullvad/mullvadvpn/usecase/VersionNotificationUseCaseTest.kt10
-rw-r--r--android/app/src/test/kotlin/net/mullvad/mullvadvpn/viewmodel/ConnectViewModelTest.kt1
-rw-r--r--android/lib/model/src/main/kotlin/net/mullvad/mullvadvpn/lib/model/InAppNotification.kt9
-rw-r--r--android/lib/resource/src/main/res/values/strings.xml5
-rw-r--r--android/lib/resource/src/main/res/values/strings_non_translatable.xml1
-rw-r--r--android/lib/tv/src/main/kotlin/net/mullvad/mullvadvpn/lib/tv/NotificationBannerTv.kt6
-rw-r--r--android/lib/ui/component/src/main/kotlin/net/mullvad/mullvadvpn/lib/ui/component/AnimatedNotificationBanner.kt4
-rw-r--r--android/lib/ui/component/src/main/kotlin/net/mullvad/mullvadvpn/lib/ui/component/NotificationData.kt43
-rw-r--r--android/lib/ui/component/src/main/kotlin/net/mullvad/mullvadvpn/lib/ui/component/StringConstant.kt (renamed from android/app/src/main/kotlin/net/mullvad/mullvadvpn/constant/StringConstant.kt)3
-rw-r--r--android/test/arch/src/test/kotlin/net/mullvad/mullvadvpn/test/arch/UseCaseTest.kt5
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 }
}