diff options
| author | Kalle Lindström <karl.lindstrom@mullvad.net> | 2026-03-27 11:43:57 +0100 |
|---|---|---|
| committer | Markus Pettersson <markus.pettersson@mullvad.net> | 2026-04-17 09:32:31 +0200 |
| commit | 071ebf1a4d27020837e1570ea1e7a5891ff3bd5d (patch) | |
| tree | d2e7553c865717b4fd826d2d5feb6efd751309a6 | |
| parent | 4e1709a26938cf5d52225f2f26c9407f66a20ba0 (diff) | |
| download | mullvadvpn-hackday-log-me-in.tar.xz mullvadvpn-hackday-log-me-in.zip | |
Add Android QR code gen and scanhackday-log-me-in
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 } |
