summaryrefslogtreecommitdiffhomepage
diff options
context:
space:
mode:
authorKalle Lindström <karl.lindstrom@mullvad.net>2026-03-27 11:43:57 +0100
committerMarkus Pettersson <markus.pettersson@mullvad.net>2026-04-17 09:32:31 +0200
commit071ebf1a4d27020837e1570ea1e7a5891ff3bd5d (patch)
treed2e7553c865717b4fd826d2d5feb6efd751309a6
parent4e1709a26938cf5d52225f2f26c9407f66a20ba0 (diff)
downloadmullvadvpn-hackday-log-me-in.tar.xz
mullvadvpn-hackday-log-me-in.zip
Add Android QR code gen and scanhackday-log-me-in
-rw-r--r--android/app/src/main/AndroidManifest.xml3
-rw-r--r--android/app/src/main/kotlin/net/mullvad/mullvadvpn/di/UiModule.kt2
-rw-r--r--android/gradle/libs.versions.toml2
-rw-r--r--android/lib/feature/account/impl/build.gradle.kts13
-rw-r--r--android/lib/feature/account/impl/src/main/AndroidManifest.xml1
-rw-r--r--android/lib/feature/account/impl/src/main/kotlin/net/mullvad/mullvadvpn/feature/account/impl/AccountScreen.kt36
-rw-r--r--android/lib/feature/account/impl/src/main/kotlin/net/mullvad/mullvadvpn/feature/account/impl/AccountViewModel.kt10
-rw-r--r--android/lib/feature/account/impl/src/main/kotlin/net/mullvad/mullvadvpn/feature/account/impl/scanqrcode/QrCodeAnalyzer.kt134
-rw-r--r--android/lib/feature/login/impl/build.gradle.kts4
-rw-r--r--android/lib/feature/login/impl/src/main/kotlin/net/mullvad/mullvadvpn/feature/login/impl/LoginScreen.kt12
-rw-r--r--android/lib/feature/login/impl/src/main/kotlin/net/mullvad/mullvadvpn/feature/login/impl/LoginUiState.kt2
-rw-r--r--android/lib/feature/login/impl/src/main/kotlin/net/mullvad/mullvadvpn/feature/login/impl/LoginViewModel.kt34
-rw-r--r--android/lib/feature/login/impl/src/main/kotlin/net/mullvad/mullvadvpn/feature/login/impl/qrcode/QrCodeImage.kt72
-rw-r--r--android/lib/grpc/src/main/kotlin/net/mullvad/mullvadvpn/lib/grpc/ManagementService.kt27
-rw-r--r--android/lib/model/src/main/kotlin/net/mullvad/mullvadvpn/lib/model/GetLoginTicketError.kt5
-rw-r--r--android/lib/model/src/main/kotlin/net/mullvad/mullvadvpn/lib/model/LoginTicket.kt6
-rw-r--r--android/lib/repository/src/main/kotlin/net/mullvad/mullvadvpn/lib/repository/AccountRepository.kt14
17 files changed, 370 insertions, 7 deletions
diff --git a/android/app/src/main/AndroidManifest.xml b/android/app/src/main/AndroidManifest.xml
index 7199c48616..f9f14cf372 100644
--- a/android/app/src/main/AndroidManifest.xml
+++ b/android/app/src/main/AndroidManifest.xml
@@ -16,6 +16,9 @@
<uses-permission
android:name="android.permission.WAKE_LOCK"
tools:node="remove" />
+ <!-- QR code stuff -->
+ <uses-permission android:name="android.permission.CAMERA"/>
+ <!-- end -->
<uses-feature android:name="android.hardware.touchscreen"
android:required="false" />
<uses-feature android:name="android.hardware.faketouch"
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 c86532f65f..532638a9d9 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
@@ -297,7 +297,7 @@ val uiModule = module {
viewModel { params -> MtuDialogViewModel(navArgs = params.get(), get()) }
viewModel { params -> DnsDialogViewModel(navArgs = params.get(), get(), get(), get()) }
viewModel { params -> CustomPortDialogViewModel(navArgs = params.get()) }
- viewModel { LoginViewModel(get(), get(), get(), get(), get()) }
+ viewModel { LoginViewModel(get(), get(), get(), get(), get(), get()) }
viewModel { PrivacyDisclaimerViewModel(get(), IS_PLAY_BUILD) }
viewModel {
SelectLocationViewModel(
diff --git a/android/gradle/libs.versions.toml b/android/gradle/libs.versions.toml
index 577a591c7b..eb44571712 100644
--- a/android/gradle/libs.versions.toml
+++ b/android/gradle/libs.versions.toml
@@ -4,6 +4,7 @@
# See Bump SDK or container image template in Linear for more details.
compile-sdk = "36"
build-tools = "36.1.0"
+zxing-core = "3.5.3"
min-sdk = "28"
target-sdk = "36"
jvm-toolchain = "21"
@@ -129,6 +130,7 @@ compose-ui-tooling = { module = "androidx.compose.ui:ui-tooling", version.ref =
compose-ui-tooling-android-preview = { module = "androidx.compose.ui:ui-tooling-preview-android", version.ref = "compose" }
compose-ui-tooling-preview = { module = "androidx.compose.ui:ui-tooling-preview", version.ref = "compose" }
compose-ui-util = { module = "androidx.compose.ui:ui-util", version.ref = "compose" }
+zxing = { module = "com.google.zxing:core", version.ref = "zxing-core" }
detekt-api = { module = "io.gitlab.arturbosch.detekt:detekt-api", version.ref = "detekt" }
detekt-test = { module = "io.gitlab.arturbosch.detekt:detekt-test", version.ref = "detekt" }
grpc-android = { module = "io.grpc:grpc-android", version.ref = "grpc" }
diff --git a/android/lib/feature/account/impl/build.gradle.kts b/android/lib/feature/account/impl/build.gradle.kts
index 9e2432c1a6..533b1eae64 100644
--- a/android/lib/feature/account/impl/build.gradle.kts
+++ b/android/lib/feature/account/impl/build.gradle.kts
@@ -22,4 +22,17 @@ dependencies {
implementation(libs.koin.compose)
implementation(libs.arrow)
+
+ // CameraX core library
+ val camerax_version = "1.6.0"
+ implementation("androidx.camera:camera-core:${camerax_version}")
+ implementation("androidx.camera:camera-camera2:${camerax_version}")
+ implementation("androidx.camera:camera-lifecycle:${camerax_version}")
+ implementation("androidx.camera:camera-view:${camerax_version}")
+
+// ML Kit Barcode Scanning (Bundled version - does NOT require Play Services)
+ implementation("com.google.mlkit:barcode-scanning:17.3.0")
+
+ implementation("androidx.concurrent:concurrent-futures-ktx:1.2.0")
+ implementation("com.google.guava:guava:33.0.0-android")
}
diff --git a/android/lib/feature/account/impl/src/main/AndroidManifest.xml b/android/lib/feature/account/impl/src/main/AndroidManifest.xml
index 8bdb7e14b3..1e754af75f 100644
--- a/android/lib/feature/account/impl/src/main/AndroidManifest.xml
+++ b/android/lib/feature/account/impl/src/main/AndroidManifest.xml
@@ -1,4 +1,5 @@
<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
+ <uses-permission android:name="android.permission.CAMERA" />
</manifest>
diff --git a/android/lib/feature/account/impl/src/main/kotlin/net/mullvad/mullvadvpn/feature/account/impl/AccountScreen.kt b/android/lib/feature/account/impl/src/main/kotlin/net/mullvad/mullvadvpn/feature/account/impl/AccountScreen.kt
index 409ceeae1c..f6ccd274ad 100644
--- a/android/lib/feature/account/impl/src/main/kotlin/net/mullvad/mullvadvpn/feature/account/impl/AccountScreen.kt
+++ b/android/lib/feature/account/impl/src/main/kotlin/net/mullvad/mullvadvpn/feature/account/impl/AccountScreen.kt
@@ -9,6 +9,8 @@ import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.heightIn
import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.layout.size
+import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.rounded.DeleteForever
import androidx.compose.material.icons.rounded.Info
@@ -30,6 +32,8 @@ import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
+import androidx.compose.ui.draw.clip
+import androidx.compose.ui.platform.LocalInspectionMode
import androidx.compose.ui.platform.LocalLayoutDirection
import androidx.compose.ui.platform.LocalUriHandler
import androidx.compose.ui.platform.testTag
@@ -38,6 +42,7 @@ import androidx.compose.ui.text.style.TextDecoration
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.tooling.preview.PreviewParameter
import androidx.compose.ui.unit.LayoutDirection
+import androidx.compose.ui.unit.dp
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import androidx.lifecycle.compose.dropUnlessResumed
import java.time.ZonedDateTime
@@ -49,6 +54,7 @@ import net.mullvad.mullvadvpn.common.compose.createOpenAccountPageHook
import net.mullvad.mullvadvpn.common.compose.showSnackbarImmediately
import net.mullvad.mullvadvpn.core.Navigator
import net.mullvad.mullvadvpn.feature.addtime.api.AddTimeNavKey
+import net.mullvad.mullvadvpn.feature.account.impl.scanqrcode.CameraXQrScanner
import net.mullvad.mullvadvpn.feature.addtime.api.VerificationPendingNavKey
import net.mullvad.mullvadvpn.feature.deleteaccount.api.DeleteAccountNavKey
import net.mullvad.mullvadvpn.feature.login.api.LoginNavKey
@@ -78,6 +84,8 @@ private fun PreviewAccountScreen(
onCopyAccountNumber = {},
onManageDevicesClick = {},
onLogoutClick = {},
+ onQrCodeScanned = {},
+ onRedeemVoucherClick = {},
onPlayPaymentInfoClick = {},
onBackClick = {},
navigateToDeleteAccount = {},
@@ -123,6 +131,7 @@ fun Account(navigator: Navigator) {
}
},
onLogoutClick = vm::onLogoutClick,
+ onQrCodeScanned = vm::onQrCodeScanned,
onCopyAccountNumber = vm::onCopyAccountNumber,
onPlayPaymentInfoClick =
dropUnlessResumed { navigator.navigate(VerificationPendingNavKey) },
@@ -141,6 +150,7 @@ fun AccountScreen(
onManageDevicesClick: () -> Unit,
onLogoutClick: () -> Unit,
onPlayPaymentInfoClick: () -> Unit,
+ onQrCodeScanned: (String) -> Unit,
onBackClick: () -> Unit,
navigateToDeleteAccount: () -> Unit,
navigateToAddTime: () -> Unit,
@@ -184,6 +194,32 @@ fun AccountScreen(
)
}
+ var showQrCodeScanner by remember { mutableStateOf(false) }
+ Row(
+ modifier = Modifier.heightIn(min = Dimens.accountRowMinHeight),
+ verticalAlignment = Alignment.CenterVertically,
+ ) {
+ Spacer(modifier = Modifier.weight(1f))
+ PrimaryTextButton(
+ onClick = { showQrCodeScanner = true },
+ text = "Log in another device",
+ textDecoration = TextDecoration.Underline,
+ )
+ }
+
+ if (showQrCodeScanner) {
+ CameraXQrScanner(
+ modifier =
+ Modifier.size(300.dp)
+ .clip(RoundedCornerShape(16.dp))
+ .align(Alignment.CenterHorizontally),
+ onQrCodeScanned = {
+ showQrCodeScanner = false
+ onQrCodeScanned(it)
+ },
+ )
+ }
+
Spacer(modifier = Modifier.weight(1f))
NegativeOutlinedButton(
diff --git a/android/lib/feature/account/impl/src/main/kotlin/net/mullvad/mullvadvpn/feature/account/impl/AccountViewModel.kt b/android/lib/feature/account/impl/src/main/kotlin/net/mullvad/mullvadvpn/feature/account/impl/AccountViewModel.kt
index ba7bb84c72..6b95448bdb 100644
--- a/android/lib/feature/account/impl/src/main/kotlin/net/mullvad/mullvadvpn/feature/account/impl/AccountViewModel.kt
+++ b/android/lib/feature/account/impl/src/main/kotlin/net/mullvad/mullvadvpn/feature/account/impl/AccountViewModel.kt
@@ -2,6 +2,7 @@ package net.mullvad.mullvadvpn.feature.account.impl
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
+import co.touchlab.kermit.Logger
import java.time.ZonedDateTime
import kotlinx.coroutines.channels.Channel
import kotlinx.coroutines.flow.Flow
@@ -90,6 +91,15 @@ class AccountViewModel(
}
}
+ fun onQrCodeScanned(code: String) {
+ Logger.d("QR code scanned: $code")
+ viewModelScope.launch {
+ accountRepository.loginAnotherWithTicket(code).onLeft {
+ _uiSideEffect.send(UiSideEffect.GenericError)
+ }
+ }
+ }
+
fun onCopyAccountNumber(accountNumber: String) {
viewModelScope.launch { _uiSideEffect.send(UiSideEffect.CopyAccountNumber(accountNumber)) }
}
diff --git a/android/lib/feature/account/impl/src/main/kotlin/net/mullvad/mullvadvpn/feature/account/impl/scanqrcode/QrCodeAnalyzer.kt b/android/lib/feature/account/impl/src/main/kotlin/net/mullvad/mullvadvpn/feature/account/impl/scanqrcode/QrCodeAnalyzer.kt
new file mode 100644
index 0000000000..9deccae23f
--- /dev/null
+++ b/android/lib/feature/account/impl/src/main/kotlin/net/mullvad/mullvadvpn/feature/account/impl/scanqrcode/QrCodeAnalyzer.kt
@@ -0,0 +1,134 @@
+package net.mullvad.mullvadvpn.feature.account.impl.scanqrcode
+
+import android.Manifest
+import androidx.activity.compose.rememberLauncherForActivityResult
+import androidx.activity.result.contract.ActivityResultContracts
+import androidx.annotation.OptIn
+import androidx.camera.core.CameraSelector
+import androidx.camera.core.ExperimentalGetImage
+import androidx.camera.core.ImageAnalysis
+import androidx.camera.core.ImageProxy
+import androidx.camera.core.Preview
+import androidx.camera.lifecycle.ProcessCameraProvider
+import androidx.camera.view.PreviewView
+import androidx.compose.material3.Text
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.LaunchedEffect
+import androidx.compose.runtime.getValue
+import androidx.compose.runtime.mutableStateOf
+import androidx.compose.runtime.remember
+import androidx.compose.runtime.setValue
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.viewinterop.AndroidView
+import androidx.core.content.ContextCompat
+import androidx.lifecycle.compose.LocalLifecycleOwner
+import com.google.mlkit.vision.barcode.BarcodeScannerOptions
+import com.google.mlkit.vision.barcode.BarcodeScanning
+import com.google.mlkit.vision.barcode.common.Barcode
+import com.google.mlkit.vision.common.InputImage
+
+class QrCodeAnalyzer(private val onQrCodeScanned: (String) -> Unit) : ImageAnalysis.Analyzer {
+
+ // Configure ML Kit to only look for QR codes (makes it faster)
+ private val scanner =
+ BarcodeScanning.getClient(
+ BarcodeScannerOptions.Builder().setBarcodeFormats(Barcode.FORMAT_QR_CODE).build()
+ )
+
+ @OptIn(ExperimentalGetImage::class)
+ override fun analyze(imageProxy: ImageProxy) {
+ val mediaImage = imageProxy.image
+ if (mediaImage != null) {
+ val image = InputImage.fromMediaImage(mediaImage, imageProxy.imageInfo.rotationDegrees)
+
+ scanner
+ .process(image)
+ .addOnSuccessListener { barcodes ->
+ for (barcode in barcodes) {
+ barcode.rawValue?.let { onQrCodeScanned(it) }
+ }
+ }
+ .addOnCompleteListener {
+ imageProxy.close() // CRITICAL: Always close the image frame!
+ }
+ } else {
+ imageProxy.close()
+ }
+ }
+}
+
+@Composable
+fun CameraXQrScanner(
+ modifier: Modifier = Modifier,
+ onQrCodeScanned: (String) -> Unit,
+) {
+ val lifecycleOwner = LocalLifecycleOwner.current
+
+ var hasCameraPermission by remember { mutableStateOf(false) }
+
+ // Permission Requester
+ val launcher =
+ rememberLauncherForActivityResult(
+ contract = ActivityResultContracts.RequestPermission(),
+ onResult = { granted -> hasCameraPermission = granted },
+ )
+
+ // Request permission on first composition
+ LaunchedEffect(Unit) { launcher.launch(Manifest.permission.CAMERA) }
+
+ if (hasCameraPermission) {
+ // if (scannedResult == null) {
+ // Camera Preview
+ AndroidView(
+ modifier = modifier,
+ factory = { ctx ->
+ val previewView =
+ PreviewView(ctx).apply { scaleType = PreviewView.ScaleType.FILL_CENTER }
+ val executor = ContextCompat.getMainExecutor(ctx)
+ val cameraProviderFuture = ProcessCameraProvider.getInstance(ctx)
+
+ cameraProviderFuture.addListener(
+ {
+ val cameraProvider = cameraProviderFuture.get()
+
+ val preview =
+ Preview.Builder().build().also {
+ it.surfaceProvider = previewView.surfaceProvider
+ }
+
+ // Set up the Analyzer from Step 2
+ val imageAnalysis =
+ ImageAnalysis.Builder()
+ .setBackpressureStrategy(ImageAnalysis.STRATEGY_KEEP_ONLY_LATEST)
+ .build()
+ .also {
+ it.setAnalyzer(
+ executor,
+ QrCodeAnalyzer(onQrCodeScanned = onQrCodeScanned),
+ )
+ }
+
+ val cameraSelector = CameraSelector.DEFAULT_BACK_CAMERA
+
+ try {
+ cameraProvider.unbindAll()
+ cameraProvider.bindToLifecycle(
+ lifecycleOwner,
+ cameraSelector,
+ preview,
+ imageAnalysis,
+ )
+ } catch (e: Exception) {
+ e.printStackTrace()
+ }
+ },
+ executor,
+ )
+
+ previewView
+ },
+ )
+ } else {
+ Text("Please grant camera permission to scan QR codes.")
+ }
+}
diff --git a/android/lib/feature/login/impl/build.gradle.kts b/android/lib/feature/login/impl/build.gradle.kts
index cc0fc25e71..365f98cbd5 100644
--- a/android/lib/feature/login/impl/build.gradle.kts
+++ b/android/lib/feature/login/impl/build.gradle.kts
@@ -20,4 +20,8 @@ dependencies {
implementation(libs.koin.compose)
implementation(libs.arrow)
+
+
+ // QR
+ implementation(libs.zxing)
}
diff --git a/android/lib/feature/login/impl/src/main/kotlin/net/mullvad/mullvadvpn/feature/login/impl/LoginScreen.kt b/android/lib/feature/login/impl/src/main/kotlin/net/mullvad/mullvadvpn/feature/login/impl/LoginScreen.kt
index 000faa6f16..1ae46c0adb 100644
--- a/android/lib/feature/login/impl/src/main/kotlin/net/mullvad/mullvadvpn/feature/login/impl/LoginScreen.kt
+++ b/android/lib/feature/login/impl/src/main/kotlin/net/mullvad/mullvadvpn/feature/login/impl/LoginScreen.kt
@@ -86,6 +86,7 @@ import net.mullvad.mullvadvpn.feature.login.api.ApiUnreachableNavKey
import net.mullvad.mullvadvpn.feature.login.api.CreateAccountConfirmationNavKey
import net.mullvad.mullvadvpn.feature.login.api.DeviceListNavKey
import net.mullvad.mullvadvpn.feature.login.api.LoginAction
+import net.mullvad.mullvadvpn.feature.login.impl.qrcode.QRCodeImage
import net.mullvad.mullvadvpn.feature.settings.api.SettingsNavKey
import net.mullvad.mullvadvpn.lib.ui.component.ScaffoldWithTopBar
import net.mullvad.mullvadvpn.lib.ui.component.textfield.mullvadWhiteTextFieldColors
@@ -257,7 +258,10 @@ private fun LoginContent(
onDeleteHistoryClick: () -> Unit,
onShowApiUnreachableDialog: (LoginUiStateError) -> Unit,
) {
- Column(modifier = Modifier.fillMaxWidth().padding(horizontal = Dimens.sideMargin)) {
+ Column(
+ modifier = Modifier.fillMaxWidth().padding(horizontal = Dimens.sideMargin),
+ horizontalAlignment = Alignment.CenterHorizontally,
+ ) {
Text(
text = state.loginState.title(),
style = MaterialTheme.typography.headlineLarge,
@@ -284,6 +288,12 @@ private fun LoginContent(
modifier =
Modifier.testTag(LOGIN_BUTTON_TEST_TAG).padding(bottom = Dimens.mediumPadding),
)
+
+ state.loginTicket?.let { ticket ->
+ Spacer(modifier = Modifier.size(Dimens.mediumPadding))
+ QRCodeImage(text = ticket.value)
+ Spacer(modifier = Modifier.size(Dimens.mediumPadding))
+ }
}
}
diff --git a/android/lib/feature/login/impl/src/main/kotlin/net/mullvad/mullvadvpn/feature/login/impl/LoginUiState.kt b/android/lib/feature/login/impl/src/main/kotlin/net/mullvad/mullvadvpn/feature/login/impl/LoginUiState.kt
index 7bc16301a6..7443da1d9c 100644
--- a/android/lib/feature/login/impl/src/main/kotlin/net/mullvad/mullvadvpn/feature/login/impl/LoginUiState.kt
+++ b/android/lib/feature/login/impl/src/main/kotlin/net/mullvad/mullvadvpn/feature/login/impl/LoginUiState.kt
@@ -1,12 +1,14 @@
package net.mullvad.mullvadvpn.feature.login.impl
import net.mullvad.mullvadvpn.lib.model.AccountNumber
+import net.mullvad.mullvadvpn.lib.model.LoginTicket
const val MIN_ACCOUNT_LOGIN_LENGTH = 8
data class LoginUiState(
val accountNumberInput: String = "",
val lastUsedAccount: AccountNumber? = null,
+ val loginTicket: LoginTicket? = null,
val loginState: LoginState = LoginState.Idle(null),
) {
val loginButtonEnabled =
diff --git a/android/lib/feature/login/impl/src/main/kotlin/net/mullvad/mullvadvpn/feature/login/impl/LoginViewModel.kt b/android/lib/feature/login/impl/src/main/kotlin/net/mullvad/mullvadvpn/feature/login/impl/LoginViewModel.kt
index 164068ff05..900de59151 100644
--- a/android/lib/feature/login/impl/src/main/kotlin/net/mullvad/mullvadvpn/feature/login/impl/LoginViewModel.kt
+++ b/android/lib/feature/login/impl/src/main/kotlin/net/mullvad/mullvadvpn/feature/login/impl/LoginViewModel.kt
@@ -12,6 +12,7 @@ import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.WhileSubscribed
+import kotlinx.coroutines.flow.collectLatest
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.flow.mapNotNull
@@ -28,10 +29,12 @@ import net.mullvad.mullvadvpn.lib.common.util.getOrDefault
import net.mullvad.mullvadvpn.lib.common.util.isBeforeNowInstant
import net.mullvad.mullvadvpn.lib.model.AccountNumber
import net.mullvad.mullvadvpn.lib.model.CreateAccountError
+import net.mullvad.mullvadvpn.lib.model.DeviceState
import net.mullvad.mullvadvpn.lib.model.LoginAccountError
import net.mullvad.mullvadvpn.lib.pushnotification.ScheduleNotificationAlarmUseCase
import net.mullvad.mullvadvpn.lib.pushnotification.accountexpiry.AccountExpiryNotificationProvider
import net.mullvad.mullvadvpn.lib.repository.AccountRepository
+import net.mullvad.mullvadvpn.lib.repository.DeviceRepository
import net.mullvad.mullvadvpn.lib.repository.NewDeviceRepository
import net.mullvad.mullvadvpn.lib.usecase.InternetAvailableUseCase
@@ -53,12 +56,24 @@ sealed interface LoginUiSideEffect {
class LoginViewModel(
private val accountRepository: AccountRepository,
+ private val deviceRepository: DeviceRepository,
private val newDeviceRepository: NewDeviceRepository,
private val internetAvailableUseCase: InternetAvailableUseCase,
private val scheduleNotificationAlarmUseCase: ScheduleNotificationAlarmUseCase,
private val accountExpiryNotificationProvider: AccountExpiryNotificationProvider,
private val dispatcher: CoroutineDispatcher = Dispatchers.IO,
) : ViewModel() {
+
+ init {
+ viewModelScope.launch {
+ deviceRepository.deviceState.collectLatest { state ->
+ if (state is DeviceState.LoggedIn) {
+ _uiSideEffect.send(LoginUiSideEffect.NavigateToConnect)
+ }
+ }
+ }
+ }
+
private val _loginState = MutableStateFlow(LoginUiState.INITIAL.loginState)
private val _loginInput = MutableStateFlow(LoginUiState.INITIAL.accountNumberInput)
@@ -66,11 +81,19 @@ class LoginViewModel(
val uiSideEffect = _uiSideEffect.receiveAsFlow()
private val _uiState =
- combine(_loginInput, accountRepository.accountHistory, _loginState) {
- loginInput,
- historyAccountNumber,
- loginState ->
- LoginUiState(loginInput, historyAccountNumber, loginState)
+ combine(
+ _loginInput,
+ accountRepository.accountHistory,
+ accountRepository.loginTicket,
+ _loginState,
+ ) { loginInput, historyAccountNumber, loginTicket, loginState ->
+
+ LoginUiState(
+ accountNumberInput = loginInput,
+ lastUsedAccount = historyAccountNumber,
+ loginTicket = loginTicket,
+ loginState = loginState,
+ )
}
val uiState: StateFlow<LoginUiState> =
@@ -78,6 +101,7 @@ class LoginViewModel(
.onStart {
viewModelScope.launch {
accountRepository.fetchAccountHistory()
+ accountRepository.fetchLoginTicket()
accountExpiryNotificationProvider.cancelNotification()
scheduleNotificationAlarmUseCase(accountExpiry = null)
}
diff --git a/android/lib/feature/login/impl/src/main/kotlin/net/mullvad/mullvadvpn/feature/login/impl/qrcode/QrCodeImage.kt b/android/lib/feature/login/impl/src/main/kotlin/net/mullvad/mullvadvpn/feature/login/impl/qrcode/QrCodeImage.kt
new file mode 100644
index 0000000000..b90a97f8f6
--- /dev/null
+++ b/android/lib/feature/login/impl/src/main/kotlin/net/mullvad/mullvadvpn/feature/login/impl/qrcode/QrCodeImage.kt
@@ -0,0 +1,72 @@
+package net.mullvad.mullvadvpn.feature.login.impl.qrcode
+
+import android.graphics.Bitmap
+import androidx.compose.foundation.Image
+import androidx.compose.runtime.*
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.graphics.asImageBitmap
+import com.google.zxing.BarcodeFormat
+import com.google.zxing.EncodeHintType
+import com.google.zxing.qrcode.QRCodeWriter
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.withContext
+import androidx.core.graphics.createBitmap
+
+@Composable
+fun QRCodeImage(
+ modifier: Modifier = Modifier,
+ text: String,
+ sizePx: Int = 512 // Default to a high-res 512x512 image
+) {
+ // Hold the generated bitmap in state
+ var bitmap by remember { mutableStateOf<Bitmap?>(null) }
+
+ // Re-generate the QR code if the text or size changes
+ LaunchedEffect(text, sizePx) {
+ if (text.isNotEmpty()) {
+ bitmap = withContext(Dispatchers.Default) {
+ generateQrBitmap(text, sizePx)
+ }
+ } else {
+ bitmap = null
+ }
+ }
+
+ // Display the generated image
+ bitmap?.let { b ->
+ Image(
+ modifier = modifier,
+ bitmap = b.asImageBitmap(),
+ contentDescription = "QR Code for $text",
+ )
+ }
+}
+
+// Background generator function
+private fun generateQrBitmap(text: String, sizePx: Int): Bitmap {
+ // Add a small blank margin around the QR code (1 block)
+ val hints = mapOf(EncodeHintType.MARGIN to 1)
+
+ val bitMatrix = QRCodeWriter().encode(text, BarcodeFormat.QR_CODE, sizePx, sizePx, hints)
+ val width = bitMatrix.width
+ val height = bitMatrix.height
+
+ // Allocate a pixel array for fast bitmap creation
+ val pixels = IntArray(width * height)
+
+ for (y in 0 until height) {
+ val offset = y * width
+ for (x in 0 until width) {
+ // Check if the current block is "true" (black) or "false" (white)
+ pixels[offset + x] = if (bitMatrix.get(x, y)) {
+ android.graphics.Color.BLACK
+ } else {
+ android.graphics.Color.WHITE // Transparent or WHITE
+ }
+ }
+ }
+
+ val bitmap = createBitmap(width, height)
+ bitmap.setPixels(pixels, 0, width, 0, 0, width, height)
+ return bitmap
+}
diff --git a/android/lib/grpc/src/main/kotlin/net/mullvad/mullvadvpn/lib/grpc/ManagementService.kt b/android/lib/grpc/src/main/kotlin/net/mullvad/mullvadvpn/lib/grpc/ManagementService.kt
index 11d106db76..a500e75d22 100644
--- a/android/lib/grpc/src/main/kotlin/net/mullvad/mullvadvpn/lib/grpc/ManagementService.kt
+++ b/android/lib/grpc/src/main/kotlin/net/mullvad/mullvadvpn/lib/grpc/ManagementService.kt
@@ -83,9 +83,11 @@ import net.mullvad.mullvadvpn.lib.model.GetAccountDataError
import net.mullvad.mullvadvpn.lib.model.GetAccountHistoryError
import net.mullvad.mullvadvpn.lib.model.GetDeviceListError
import net.mullvad.mullvadvpn.lib.model.GetDeviceStateError
+import net.mullvad.mullvadvpn.lib.model.GetLoginTicketError
import net.mullvad.mullvadvpn.lib.model.GetVersionInfoError
import net.mullvad.mullvadvpn.lib.model.IpVersion
import net.mullvad.mullvadvpn.lib.model.LoginAccountError
+import net.mullvad.mullvadvpn.lib.model.LoginTicket
import net.mullvad.mullvadvpn.lib.model.LogoutAccountError
import net.mullvad.mullvadvpn.lib.model.NameAlreadyExists
import net.mullvad.mullvadvpn.lib.model.NewAccessMethodSetting
@@ -391,6 +393,23 @@ class ManagementService(
}
.mapEmpty()
+ suspend fun loginAnotherWithTicket(ticket: String): Either<LoginAccountError, Unit> =
+ Either.catch { grpc.login(ManagementInterface.Ticket.newBuilder().setToken(ticket).build()) }
+ .mapLeftStatus {
+ when (it.status.code) {
+ Status.Code.UNAUTHENTICATED -> LoginAccountError.InvalidAccount
+ Status.Code.RESOURCE_EXHAUSTED if it.status.isTooManyRequests() ->
+ LoginAccountError.TooManyAttempts
+ Status.Code.DEADLINE_EXCEEDED -> LoginAccountError.Timeout
+ Status.Code.UNAVAILABLE -> LoginAccountError.ApiUnreachable
+ else -> {
+ Logger.e("Unknown login account error")
+ LoginAccountError.Unknown(it)
+ }
+ }
+ }
+ .mapEmpty()
+
suspend fun deleteAccount(): Either<DeleteAccountError, Unit> =
Either.catch { grpc.deleteAccount(Empty.getDefaultInstance()) }
.onLeft { Logger.e("Delete account error") }
@@ -425,6 +444,14 @@ class ManagementService(
.onLeft { Logger.e("Get account history error") }
.mapLeft(GetAccountHistoryError::Unknown)
+ suspend fun getLoginTicket(): Either<GetLoginTicketError, LoginTicket> =
+ Either.catch {
+ val ticket = grpc.initLogin(Empty.getDefaultInstance())
+ LoginTicket(ticket.token)
+ }
+ .onLeft { Logger.e("Get login ticket error") }
+ .mapLeft(GetLoginTicketError::Unknown)
+
private suspend fun getInitialServiceState() {
withContext(Dispatchers.IO) {
awaitAll(
diff --git a/android/lib/model/src/main/kotlin/net/mullvad/mullvadvpn/lib/model/GetLoginTicketError.kt b/android/lib/model/src/main/kotlin/net/mullvad/mullvadvpn/lib/model/GetLoginTicketError.kt
new file mode 100644
index 0000000000..6bec1e7c52
--- /dev/null
+++ b/android/lib/model/src/main/kotlin/net/mullvad/mullvadvpn/lib/model/GetLoginTicketError.kt
@@ -0,0 +1,5 @@
+package net.mullvad.mullvadvpn.lib.model
+
+sealed interface GetLoginTicketError {
+ data class Unknown(val error: Throwable) : GetLoginTicketError
+}
diff --git a/android/lib/model/src/main/kotlin/net/mullvad/mullvadvpn/lib/model/LoginTicket.kt b/android/lib/model/src/main/kotlin/net/mullvad/mullvadvpn/lib/model/LoginTicket.kt
new file mode 100644
index 0000000000..f23ece14f7
--- /dev/null
+++ b/android/lib/model/src/main/kotlin/net/mullvad/mullvadvpn/lib/model/LoginTicket.kt
@@ -0,0 +1,6 @@
+package net.mullvad.mullvadvpn.lib.model
+
+import android.os.Parcelable
+import kotlinx.parcelize.Parcelize
+
+@JvmInline @Parcelize value class LoginTicket(val value: String) : Parcelable
diff --git a/android/lib/repository/src/main/kotlin/net/mullvad/mullvadvpn/lib/repository/AccountRepository.kt b/android/lib/repository/src/main/kotlin/net/mullvad/mullvadvpn/lib/repository/AccountRepository.kt
index 3f3b0ca997..19ad8e4865 100644
--- a/android/lib/repository/src/main/kotlin/net/mullvad/mullvadvpn/lib/repository/AccountRepository.kt
+++ b/android/lib/repository/src/main/kotlin/net/mullvad/mullvadvpn/lib/repository/AccountRepository.kt
@@ -22,6 +22,7 @@ import net.mullvad.mullvadvpn.lib.model.CreateAccountError
import net.mullvad.mullvadvpn.lib.model.DeleteAccountError
import net.mullvad.mullvadvpn.lib.model.DeviceState
import net.mullvad.mullvadvpn.lib.model.LoginAccountError
+import net.mullvad.mullvadvpn.lib.model.LoginTicket
import net.mullvad.mullvadvpn.lib.model.WebsiteAuthToken
class AccountRepository(
@@ -37,10 +38,14 @@ class AccountRepository(
private val _mutableAccountHistory: MutableStateFlow<AccountNumber?> = MutableStateFlow(null)
+ private val _mutableLoginTicket: MutableStateFlow<LoginTicket?> = MutableStateFlow(null)
+
val isNewAccount: StateFlow<Boolean> = _isNewAccount
val accountHistory: StateFlow<AccountNumber?> = _mutableAccountHistory
+ val loginTicket: StateFlow<LoginTicket?> = _mutableLoginTicket
+
val accountData: StateFlow<AccountData?> =
merge(
managementService.deviceState.map { deviceState ->
@@ -69,12 +74,21 @@ class AccountRepository(
suspend fun logout() =
managementService.logoutAccount().onRight { _isNewAccount.update { false } }
+ suspend fun loginAnotherWithTicket(ticket: String) =
+ managementService.loginAnotherWithTicket(ticket)
+
suspend fun fetchAccountHistory(): AccountNumber? =
managementService
.getAccountHistory()
.onRight { _mutableAccountHistory.value = it }
.getOrNull()
+ suspend fun fetchLoginTicket(): LoginTicket? =
+ managementService
+ .getLoginTicket()
+ .onRight { _mutableLoginTicket.value = it }
+ .getOrNull()
+
suspend fun clearAccountHistory(): Either<ClearAccountHistoryError, Unit> =
managementService.clearAccountHistory().onRight { _mutableAccountHistory.value = null }