ui: allow importing tunnel from an QR image stored on the device
Add a new feature to import a tunnel from a saved QR image, this feature integrates into 'import from file' flow, however adds a condition, if file is an image, attempt to parse it as QR image file. My use case for this feature, is to allow easier sharing of tunnels to family. Scanning QR code is ok when you have an external display to show it, but if you sent QR code to someone, there is no way to import it in the app. If you share a config file, that becomes way harder for a non-technical person to import as now they need to find a file with that name in the file picker etc etc, Where the images are very visible in the file picker, and user can easily recognize it for import. Testing: - Click "+" blue button, try to import a valid `.conf` file - the 'original' file flow should not be affected - Click "+" blue button, try to import a valid QR code image - if QR code was parsed, then a new tunnel will be added. - Click "+" blue button, try to import an invalid QR code image - Error message will be shown Signed-off-by: Nikita Pustovoi <deishelon@gmail.com> Signed-off-by: Harsh Shandilya <me@msfjarvis.dev>
This commit is contained in:
parent
751ce54fa5
commit
0bd39309c8
@ -21,6 +21,7 @@ import androidx.appcompat.app.AppCompatActivity
|
|||||||
import androidx.appcompat.view.ActionMode
|
import androidx.appcompat.view.ActionMode
|
||||||
import androidx.lifecycle.lifecycleScope
|
import androidx.lifecycle.lifecycleScope
|
||||||
import com.google.android.material.snackbar.Snackbar
|
import com.google.android.material.snackbar.Snackbar
|
||||||
|
import com.google.zxing.qrcode.QRCodeReader
|
||||||
import com.journeyapps.barcodescanner.ScanContract
|
import com.journeyapps.barcodescanner.ScanContract
|
||||||
import com.journeyapps.barcodescanner.ScanOptions
|
import com.journeyapps.barcodescanner.ScanOptions
|
||||||
import com.wireguard.android.Application
|
import com.wireguard.android.Application
|
||||||
@ -31,6 +32,7 @@ import com.wireguard.android.databinding.TunnelListFragmentBinding
|
|||||||
import com.wireguard.android.databinding.TunnelListItemBinding
|
import com.wireguard.android.databinding.TunnelListItemBinding
|
||||||
import com.wireguard.android.model.ObservableTunnel
|
import com.wireguard.android.model.ObservableTunnel
|
||||||
import com.wireguard.android.util.ErrorMessages
|
import com.wireguard.android.util.ErrorMessages
|
||||||
|
import com.wireguard.android.util.QrCodeFromFileScanner
|
||||||
import com.wireguard.android.util.TunnelImporter
|
import com.wireguard.android.util.TunnelImporter
|
||||||
import com.wireguard.android.widget.MultiselectableRelativeLayout
|
import com.wireguard.android.widget.MultiselectableRelativeLayout
|
||||||
import kotlinx.coroutines.SupervisorJob
|
import kotlinx.coroutines.SupervisorJob
|
||||||
@ -52,7 +54,20 @@ class TunnelListFragment : BaseFragment() {
|
|||||||
val activity = activity ?: return@registerForActivityResult
|
val activity = activity ?: return@registerForActivityResult
|
||||||
val contentResolver = activity.contentResolver ?: return@registerForActivityResult
|
val contentResolver = activity.contentResolver ?: return@registerForActivityResult
|
||||||
activity.lifecycleScope.launch {
|
activity.lifecycleScope.launch {
|
||||||
TunnelImporter.importTunnel(contentResolver, data) { showSnackbar(it) }
|
val qrCodeFromFileScanner = QrCodeFromFileScanner(contentResolver, QRCodeReader())
|
||||||
|
if (qrCodeFromFileScanner.validContentType(data)) {
|
||||||
|
try {
|
||||||
|
val result = qrCodeFromFileScanner.scan(data)
|
||||||
|
TunnelImporter.importTunnel(parentFragmentManager, result.text) { showSnackbar(it) }
|
||||||
|
} catch (e: Exception) {
|
||||||
|
val error = ErrorMessages[e]
|
||||||
|
val message = requireContext().getString(R.string.import_error, error)
|
||||||
|
Log.e(TAG, message, e)
|
||||||
|
showSnackbar(message)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
TunnelImporter.importTunnel(contentResolver, data) { showSnackbar(it) }
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -6,6 +6,8 @@ package com.wireguard.android.util
|
|||||||
|
|
||||||
import android.content.res.Resources
|
import android.content.res.Resources
|
||||||
import android.os.RemoteException
|
import android.os.RemoteException
|
||||||
|
import com.google.zxing.ChecksumException
|
||||||
|
import com.google.zxing.NotFoundException
|
||||||
import com.wireguard.android.Application
|
import com.wireguard.android.Application
|
||||||
import com.wireguard.android.R
|
import com.wireguard.android.R
|
||||||
import com.wireguard.android.backend.BackendException
|
import com.wireguard.android.backend.BackendException
|
||||||
@ -84,6 +86,12 @@ object ErrorMessages {
|
|||||||
rootCause is RootShellException -> {
|
rootCause is RootShellException -> {
|
||||||
resources.getString(RSE_REASON_MAP.getValue(rootCause.reason), *rootCause.format)
|
resources.getString(RSE_REASON_MAP.getValue(rootCause.reason), *rootCause.format)
|
||||||
}
|
}
|
||||||
|
rootCause is NotFoundException -> {
|
||||||
|
resources.getString(R.string.error_no_qr_found)
|
||||||
|
}
|
||||||
|
rootCause is ChecksumException -> {
|
||||||
|
resources.getString(R.string.error_qr_checksum)
|
||||||
|
}
|
||||||
rootCause.message != null -> {
|
rootCause.message != null -> {
|
||||||
rootCause.message!!
|
rootCause.message!!
|
||||||
}
|
}
|
||||||
|
@ -0,0 +1,110 @@
|
|||||||
|
/*
|
||||||
|
* Copyright © 2017-2022 WireGuard LLC. All Rights Reserved.
|
||||||
|
* SPDX-License-Identifier: Apache-2.0
|
||||||
|
*/
|
||||||
|
|
||||||
|
package com.wireguard.android.util
|
||||||
|
|
||||||
|
import android.content.ContentResolver
|
||||||
|
import android.graphics.Bitmap
|
||||||
|
import android.graphics.BitmapFactory
|
||||||
|
import android.net.Uri
|
||||||
|
import android.util.Log
|
||||||
|
import com.google.zxing.BinaryBitmap
|
||||||
|
import com.google.zxing.DecodeHintType
|
||||||
|
import com.google.zxing.NotFoundException
|
||||||
|
import com.google.zxing.RGBLuminanceSource
|
||||||
|
import com.google.zxing.Reader
|
||||||
|
import com.google.zxing.Result
|
||||||
|
import com.google.zxing.common.HybridBinarizer
|
||||||
|
import kotlinx.coroutines.Dispatchers
|
||||||
|
import kotlinx.coroutines.withContext
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Encapsulates the logic of scanning a barcode from a file,
|
||||||
|
* @property contentResolver - Resolver to read the incoming data
|
||||||
|
* @property reader - An instance of zxing's [Reader] class to parse the image
|
||||||
|
*/
|
||||||
|
class QrCodeFromFileScanner(
|
||||||
|
private val contentResolver: ContentResolver,
|
||||||
|
private val reader: Reader,
|
||||||
|
) {
|
||||||
|
|
||||||
|
private fun scanBitmapForResult(source: Bitmap): Result {
|
||||||
|
val width = source.width
|
||||||
|
val height = source.height
|
||||||
|
val pixels = IntArray(width * height)
|
||||||
|
source.getPixels(pixels, 0, width, 0, 0, width, height)
|
||||||
|
|
||||||
|
val bBitmap = BinaryBitmap(HybridBinarizer(RGBLuminanceSource(width, height, pixels)))
|
||||||
|
return reader.decode(bBitmap, mapOf(DecodeHintType.TRY_HARDER to true))
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun downscaleBitmap(source: Bitmap, scaledSize: Int): Bitmap {
|
||||||
|
|
||||||
|
val originalWidth = source.width
|
||||||
|
val originalHeight = source.height
|
||||||
|
|
||||||
|
var newWidth = -1
|
||||||
|
var newHeight = -1
|
||||||
|
val multFactor: Float
|
||||||
|
|
||||||
|
when {
|
||||||
|
originalHeight > originalWidth -> {
|
||||||
|
newHeight = scaledSize
|
||||||
|
multFactor = originalWidth.toFloat() / originalHeight.toFloat()
|
||||||
|
newWidth = (newHeight * multFactor).toInt()
|
||||||
|
}
|
||||||
|
originalWidth > originalHeight -> {
|
||||||
|
newWidth = scaledSize
|
||||||
|
multFactor = originalHeight.toFloat() / originalWidth.toFloat()
|
||||||
|
newHeight = (newWidth * multFactor).toInt()
|
||||||
|
}
|
||||||
|
originalHeight == originalWidth -> {
|
||||||
|
newHeight = scaledSize
|
||||||
|
newWidth = scaledSize
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return Bitmap.createScaledBitmap(source, newWidth, newHeight, false)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun doScan(data: Uri): Result {
|
||||||
|
Log.d(TAG, "Starting to scan an image: $data")
|
||||||
|
contentResolver.openInputStream(data).use { inputStream ->
|
||||||
|
val originalBitmap = BitmapFactory.decodeStream(inputStream)
|
||||||
|
?: throw IllegalArgumentException("Can't decode stream to Bitmap")
|
||||||
|
|
||||||
|
return try {
|
||||||
|
scanBitmapForResult(originalBitmap).also {
|
||||||
|
Log.d(TAG, "Found result in original image")
|
||||||
|
}
|
||||||
|
} catch (e: Exception) {
|
||||||
|
Log.e(TAG, "Original image scan finished with error: $e, will try downscaled image")
|
||||||
|
val scaleBitmap = downscaleBitmap(originalBitmap, 500)
|
||||||
|
scanBitmapForResult(originalBitmap).also { scaleBitmap.recycle() }
|
||||||
|
} finally {
|
||||||
|
originalBitmap.recycle()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Attempts to parse incoming data
|
||||||
|
* @return result of the decoding operation
|
||||||
|
* @throws NotFoundException when parser didn't find QR code in the image
|
||||||
|
*/
|
||||||
|
suspend fun scan(data: Uri) = withContext(Dispatchers.Default) { doScan(data) }
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Given a reference to a file, check if this file could be parsed by this class
|
||||||
|
* @return true if the file can be parsed, false if not
|
||||||
|
*/
|
||||||
|
fun validContentType(data: Uri): Boolean {
|
||||||
|
return contentResolver.getType(data)?.startsWith("image/") == true
|
||||||
|
}
|
||||||
|
|
||||||
|
companion object {
|
||||||
|
private const val TAG = "QrCodeFromFileScanner"
|
||||||
|
}
|
||||||
|
}
|
@ -80,6 +80,8 @@
|
|||||||
<string name="bad_config_reason_unknown_section">Unknown section</string>
|
<string name="bad_config_reason_unknown_section">Unknown section</string>
|
||||||
<string name="bad_config_reason_value_out_of_range">Value out of range</string>
|
<string name="bad_config_reason_value_out_of_range">Value out of range</string>
|
||||||
<string name="bad_extension_error">File must be .conf or .zip</string>
|
<string name="bad_extension_error">File must be .conf or .zip</string>
|
||||||
|
<string name="error_no_qr_found">QR code not found in image</string>
|
||||||
|
<string name="error_qr_checksum">QR code checksum verification failed</string>
|
||||||
<string name="cancel">Cancel</string>
|
<string name="cancel">Cancel</string>
|
||||||
<string name="config_delete_error">Cannot delete configuration file %s</string>
|
<string name="config_delete_error">Cannot delete configuration file %s</string>
|
||||||
<string name="config_exists_error">Configuration for “%s” already exists</string>
|
<string name="config_exists_error">Configuration for “%s” already exists</string>
|
||||||
|
Loading…
Reference in New Issue
Block a user