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:
parent
2a3d18cd9c
commit
63c110f5fd
10 changed files with 195 additions and 18 deletions
|
|
@ -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>
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
// ========================================================================
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
},
|
||||
|
|
|
|||
|
|
@ -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
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue