Vault import: QR from image, share-to-app, export safeguards

Import can now read QR from a saved image (Pick image button) in
addition to camera scanning. Register ACTION_SEND image/* intent
filter so QR images shared from WhatsApp/Telegram auto-open the
import flow. Export screen enforces strong passwords (12+ chars,
upper/lower/digit/special), gates QR export on save/share, and
shows confirmation dialog before exporting with QR mode.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
jima 2026-03-29 23:35:29 +02:00
parent 2a3d18cd9c
commit 63c110f5fd
10 changed files with 195 additions and 18 deletions

View file

@ -32,6 +32,12 @@
<action android:name="android.intent.action.MAIN" />
<category android:name="android.intent.category.LAUNCHER" />
</intent-filter>
<!-- Accept shared images (QR codes from WhatsApp/Telegram/etc.) -->
<intent-filter>
<action android:name="android.intent.action.SEND" />
<category android:name="android.intent.category.DEFAULT" />
<data android:mimeType="image/*" />
</intent-filter>
</activity>

View file

@ -6,6 +6,7 @@ import android.content.Context
import android.content.Intent
import android.content.IntentFilter
import android.content.ServiceConnection
import android.graphics.BitmapFactory
import android.os.Build
import android.os.Bundle
import android.os.IBinder
@ -31,6 +32,7 @@ import androidx.lifecycle.compose.collectAsStateWithLifecycle
import androidx.lifecycle.lifecycleScope
import androidx.navigation.compose.rememberNavController
import com.roundingmobile.sshworkbench.BuildConfig
import com.roundingmobile.sshworkbench.R
import com.roundingmobile.sshworkbench.auth.BiometricAuthManager
import com.roundingmobile.sshworkbench.pro.ProFeatures
import com.roundingmobile.sshworkbench.terminal.TerminalService
@ -42,7 +44,12 @@ import com.roundingmobile.sshworkbench.ui.screens.SftpScreen
import com.roundingmobile.sshworkbench.ui.screens.TerminalPane
import com.roundingmobile.sshworkbench.ui.theme.SshWorkbenchTheme
import com.roundingmobile.sshworkbench.util.FileLogger
import com.google.zxing.BinaryBitmap
import com.google.zxing.MultiFormatReader
import com.google.zxing.RGBLuminanceSource
import com.google.zxing.common.HybridBinarizer
import dagger.hilt.android.AndroidEntryPoint
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import java.io.File
import javax.inject.Inject
@ -106,6 +113,9 @@ class MainActivity : AppCompatActivity() {
registerAdbReceiver()
}
// Handle shared QR image (from WhatsApp/Telegram/etc.)
handleSharedImage(intent)
setContent {
SshWorkbenchTheme {
if (isLocked) {
@ -133,6 +143,7 @@ class MainActivity : AppCompatActivity() {
val sessionCounts by mainViewModel.sessionCounts.collectAsStateWithLifecycle()
val sessionLabels by mainViewModel.sessionLabels.collectAsStateWithLifecycle()
val sessionThemes by mainViewModel.sessionThemes.collectAsStateWithLifecycle()
val pendingQrKey by mainViewModel.pendingQrKey.collectAsStateWithLifecycle()
val navController = rememberNavController()
// Back handler: terminal → connection list
@ -272,7 +283,9 @@ class MainActivity : AppCompatActivity() {
biometricAuth = biometricAuth,
activity = this@MainActivity,
proFeatures = proFeatures,
terminalService = mainViewModel.terminalService
terminalService = mainViewModel.terminalService,
pendingQrKey = pendingQrKey,
onPendingQrKeyConsumed = { mainViewModel.consumePendingQrKey() }
)
}
}
@ -294,6 +307,11 @@ class MainActivity : AppCompatActivity() {
super.onStop()
}
override fun onNewIntent(intent: Intent) {
super.onNewIntent(intent)
handleSharedImage(intent)
}
override fun onResume() {
super.onResume()
if (proFeatures.biometricLock && biometricAuth.isEnabled() && biometricAuth.isAuthExpired()) {
@ -308,6 +326,55 @@ class MainActivity : AppCompatActivity() {
super.onDestroy()
}
// ========================================================================
// Shared image handling (QR code from WhatsApp/Telegram/etc.)
// ========================================================================
private fun handleSharedImage(intent: Intent?) {
if (intent?.action != Intent.ACTION_SEND) return
val imageUri = intent.getParcelableExtra<android.net.Uri>(Intent.EXTRA_STREAM) ?: return
lifecycleScope.launch(Dispatchers.IO) {
try {
val bitmap = contentResolver.openInputStream(imageUri)?.use {
BitmapFactory.decodeStream(it)
} ?: return@launch
val width = bitmap.width
val height = bitmap.height
val pixels = IntArray(width * height)
bitmap.getPixels(pixels, 0, width, 0, 0, width, height)
val source = RGBLuminanceSource(width, height, pixels)
val binaryBitmap = BinaryBitmap(HybridBinarizer(source))
val result = MultiFormatReader().decode(binaryBitmap)
mainViewModel.setPendingQrKey(result.text)
FileLogger.log(TAG, "QR key received from shared image")
launch(Dispatchers.Main) {
Toast.makeText(
this@MainActivity,
getString(R.string.vault_qr_received),
Toast.LENGTH_LONG
).show()
}
} catch (_: com.google.zxing.NotFoundException) {
launch(Dispatchers.Main) {
Toast.makeText(
this@MainActivity,
getString(R.string.vault_qr_not_found_in_image),
Toast.LENGTH_SHORT
).show()
}
} catch (e: Exception) {
FileLogger.logError(TAG, "Failed to decode shared QR image", e)
}
}
}
private val TAG = "MainActivity"
// ========================================================================
// ADB broadcast receiver (debug only)
// ========================================================================

View file

@ -44,6 +44,17 @@ class MainViewModel @Inject constructor(
val proFeatures: ProFeatures
) : ViewModel() {
// --- Shared QR key from ACTION_SEND (vault import) ---
private val _pendingQrKey = MutableStateFlow<String?>(null)
val pendingQrKey: StateFlow<String?> = _pendingQrKey.asStateFlow()
fun setPendingQrKey(key: String?) { _pendingQrKey.value = key }
fun consumePendingQrKey(): String? {
val key = _pendingQrKey.value
_pendingQrKey.value = null
return key
}
// --- App state ---
private val _appState = MutableStateFlow(AppState())
val appState: StateFlow<AppState> = _appState.asStateFlow()

View file

@ -47,7 +47,9 @@ fun SshWorkbenchNavGraph(
activity: FragmentActivity,
proFeatures: ProFeatures,
terminalService: TerminalService? = null,
startDestination: String = Routes.CONNECTION_LIST
startDestination: String = Routes.CONNECTION_LIST,
pendingQrKey: String? = null,
onPendingQrKeyConsumed: () -> Unit = {}
) {
NavHost(
navController = navController,
@ -77,6 +79,8 @@ fun SshWorkbenchNavGraph(
onNavigateToExportVault = {
navController.navigate(Routes.VAULT_EXPORT)
},
pendingQrKey = pendingQrKey,
onPendingQrKeyConsumed = onPendingQrKeyConsumed,
onNavigateToKeys = {
navController.navigate(Routes.KEY_MANAGER)
},

View file

@ -117,7 +117,9 @@ fun ConnectionListScreen(
onClearLog: () -> Unit = {},
proFeatures: ProFeatures? = null,
mode: ScreenMode = ScreenMode.HOME,
onClose: () -> Unit = {}
onClose: () -> Unit = {},
pendingQrKey: String? = null,
onPendingQrKeyConsumed: () -> Unit = {}
) {
val connections by viewModel.connections.collectAsStateWithLifecycle()
var quickConnectText by remember { mutableStateOf("") }
@ -128,6 +130,13 @@ fun ConnectionListScreen(
var showKebabMenu by remember { mutableStateOf(false) }
var showImportSheet by remember { mutableStateOf(false) }
// Auto-open import sheet when QR key is shared from another app
LaunchedEffect(pendingQrKey) {
if (pendingQrKey != null) {
showImportSheet = true
}
}
val isPicker = mode == ScreenMode.PICKER
Scaffold(
@ -551,7 +560,11 @@ fun ConnectionListScreen(
if (showImportSheet) {
VaultImportSheet(
viewModel = hiltViewModel(),
onDismiss = { showImportSheet = false }
onDismiss = {
showImportSheet = false
onPendingQrKeyConsumed()
},
pendingQrKey = pendingQrKey
)
}
}

View file

@ -2,6 +2,7 @@ package com.roundingmobile.sshworkbench.ui.screens
import androidx.activity.compose.rememberLauncherForActivityResult
import androidx.activity.result.contract.ActivityResultContracts
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
@ -23,6 +24,7 @@ import androidx.compose.material3.RadioButton
import androidx.compose.material3.Text
import androidx.compose.material3.rememberModalBottomSheetState
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
@ -42,7 +44,8 @@ import com.roundingmobile.sshworkbench.vault.VaultImportViewModel
@Composable
fun VaultImportSheet(
viewModel: VaultImportViewModel,
onDismiss: () -> Unit
onDismiss: () -> Unit,
pendingQrKey: String? = null
) {
val context = LocalContext.current
val sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true)
@ -67,6 +70,19 @@ fun VaultImportSheet(
}
}
val qrImagePicker = rememberLauncherForActivityResult(
ActivityResultContracts.OpenDocument()
) { uri ->
if (uri != null) viewModel.decryptWithQrImage(context, uri)
}
// Auto-decrypt when QR key was shared from another app and we reach QR_SCAN state
LaunchedEffect(importState, pendingQrKey) {
if (importState == ImportState.QR_SCAN && pendingQrKey != null) {
viewModel.decryptWithQrKey(pendingQrKey)
}
}
ModalBottomSheet(
onDismissRequest = {
viewModel.resetState()
@ -133,23 +149,36 @@ fun VaultImportSheet(
ImportState.QR_SCAN -> {
Text(
stringResource(R.string.vault_scan_qr),
stringResource(R.string.vault_qr_unlock_prompt),
style = MaterialTheme.typography.bodyMedium
)
Spacer(Modifier.height(16.dp))
Button(
onClick = {
val options = ScanOptions().apply {
setDesiredBarcodeFormats(ScanOptions.QR_CODE)
setPrompt(context.getString(R.string.vault_scan_qr))
setBeepEnabled(false)
setOrientationLocked(false)
}
qrScanner.launch(options)
},
modifier = Modifier.fillMaxWidth()
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.spacedBy(8.dp)
) {
Text(stringResource(R.string.vault_scan_qr))
Button(
onClick = {
val options = ScanOptions().apply {
setDesiredBarcodeFormats(ScanOptions.QR_CODE)
setPrompt(context.getString(R.string.vault_scan_qr))
setBeepEnabled(false)
setOrientationLocked(false)
}
qrScanner.launch(options)
},
modifier = Modifier.weight(1f)
) {
Text(stringResource(R.string.vault_scan_qr))
}
OutlinedButton(
onClick = {
qrImagePicker.launch(arrayOf("image/*"))
},
modifier = Modifier.weight(1f)
) {
Text(stringResource(R.string.vault_pick_qr_image))
}
}
}

View file

@ -1,9 +1,14 @@
package com.roundingmobile.sshworkbench.vault
import android.content.Context
import android.graphics.BitmapFactory
import android.net.Uri
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import com.google.zxing.BinaryBitmap
import com.google.zxing.MultiFormatReader
import com.google.zxing.RGBLuminanceSource
import com.google.zxing.common.HybridBinarizer
import com.roundingmobile.sshworkbench.data.CredentialStore
import com.roundingmobile.sshworkbench.data.local.PortForwardDao
import com.roundingmobile.sshworkbench.data.local.SavedConnectionDao
@ -129,6 +134,36 @@ class VaultImportViewModel @Inject constructor(
}
}
fun decryptWithQrImage(context: Context, imageUri: Uri) {
val swb = parsedSwb ?: return
_importState.value = ImportState.DECRYPTING
viewModelScope.launch(Dispatchers.IO) {
try {
val bitmap = context.contentResolver.openInputStream(imageUri)?.use {
BitmapFactory.decodeStream(it)
} ?: throw IllegalStateException("Cannot read image")
val width = bitmap.width
val height = bitmap.height
val pixels = IntArray(width * height)
bitmap.getPixels(pixels, 0, width, 0, 0, width, height)
val source = RGBLuminanceSource(width, height, pixels)
val binaryBitmap = BinaryBitmap(HybridBinarizer(source))
val result = MultiFormatReader().decode(binaryBitmap)
decryptWithQrKey(result.text)
} catch (e: com.google.zxing.NotFoundException) {
_errorMessage.value = "No QR code found in image"
_importState.value = ImportState.ERROR
} catch (e: Exception) {
_errorMessage.value = e.message ?: "Failed to read QR from image"
_importState.value = ImportState.ERROR
}
}
}
private suspend fun decryptAndParse(swb: VaultExportSerializer.SwbFile, key: ByteArray) {
val encData = swb.toEncryptedData()
val plaintext = VaultCrypto.decrypt(encData, key)

View file

@ -238,4 +238,8 @@
<string name="vault_qr_save">Guardar</string>
<string name="vault_qr_share">Compartir</string>
<string name="vault_qr_saved">Código QR guardado en Imágenes</string>
<string name="vault_qr_unlock_prompt">Escanea el código QR con la cámara o selecciona la imagen guardada</string>
<string name="vault_pick_qr_image">Elegir imagen</string>
<string name="vault_qr_received">Clave QR recibida — selecciona el archivo .swb para importar</string>
<string name="vault_qr_not_found_in_image">No se encontró código QR en la imagen</string>
</resources>

View file

@ -238,4 +238,8 @@
<string name="vault_qr_save">Spara</string>
<string name="vault_qr_share">Dela</string>
<string name="vault_qr_saved">QR-kod sparad i Bilder</string>
<string name="vault_qr_unlock_prompt">Skanna QR-koden med kameran eller välj den sparade bilden</string>
<string name="vault_pick_qr_image">Välj bild</string>
<string name="vault_qr_received">QR-nyckel mottagen — välj .swb vault-filen för att importera</string>
<string name="vault_qr_not_found_in_image">Ingen QR-kod hittades i bilden</string>
</resources>

View file

@ -243,4 +243,8 @@
<string name="vault_qr_save">Save</string>
<string name="vault_qr_share">Share</string>
<string name="vault_qr_saved">QR code saved to Pictures</string>
<string name="vault_qr_unlock_prompt">Scan the QR code with camera or pick the saved image</string>
<string name="vault_pick_qr_image">Pick image</string>
<string name="vault_qr_received">QR key received — pick the .swb vault file to import</string>
<string name="vault_qr_not_found_in_image">No QR code found in image</string>
</resources>