diff --git a/client/android/AndroidManifest.xml b/client/android/AndroidManifest.xml index 34b5afd8..18e31f43 100644 --- a/client/android/AndroidManifest.xml +++ b/client/android/AndroidManifest.xml @@ -63,6 +63,9 @@ - - - - - \ No newline at end of file diff --git a/client/android/res/layout/camera_preview.xml b/client/android/res/layout/camera_preview.xml new file mode 100644 index 00000000..003abbb6 --- /dev/null +++ b/client/android/res/layout/camera_preview.xml @@ -0,0 +1,11 @@ + + + + + + \ No newline at end of file diff --git a/client/android/src/org/amnezia/vpn/AmneziaActivity.kt b/client/android/src/org/amnezia/vpn/AmneziaActivity.kt index c65f4bee..f484b44b 100644 --- a/client/android/src/org/amnezia/vpn/AmneziaActivity.kt +++ b/client/android/src/org/amnezia/vpn/AmneziaActivity.kt @@ -9,18 +9,12 @@ import org.qtproject.qt.android.bindings.QtActivity private const val TAG = "AmneziaActivity" -private const val CAMERA_ACTION_CODE = 101 private const val CREATE_FILE_ACTION_CODE = 102 class AmneziaActivity : QtActivity() { private var tmpFileContentToSave: String = "" - private fun startQrCodeActivity() { - val intent = Intent(this, CameraActivity::class.java) - startActivityForResult(intent, CAMERA_ACTION_CODE) - } - override fun onActivityResult(requestCode: Int, resultCode: Int, data: Intent?) { if (requestCode == CREATE_FILE_ACTION_CODE && resultCode == RESULT_OK) { data?.data?.also { uri -> @@ -97,6 +91,8 @@ class AmneziaActivity : QtActivity() { @Suppress("unused") fun startQrCodeReader() { Log.v(TAG, "Start camera") - startQrCodeActivity() + Intent(this, CameraActivity::class.java).also { + startActivity(it) + } } } diff --git a/client/android/src/org/amnezia/vpn/AmneziaApplication.kt b/client/android/src/org/amnezia/vpn/AmneziaApplication.kt index e41b27c4..df8b02ae 100644 --- a/client/android/src/org/amnezia/vpn/AmneziaApplication.kt +++ b/client/android/src/org/amnezia/vpn/AmneziaApplication.kt @@ -1,22 +1,16 @@ package org.amnezia.vpn -import android.content.res.Configuration -// import org.amnezia.vpn.shadowsocks.core.Core -// import org.amnezia.vpn.shadowsocks.core.VpnManager -import org.qtproject.qt.android.bindings.QtActivity +import androidx.camera.camera2.Camera2Config +import androidx.camera.core.CameraSelector +import androidx.camera.core.CameraXConfig import org.qtproject.qt.android.bindings.QtApplication -import android.app.Application -class AmneziaApplication: org.qtproject.qt.android.bindings.QtApplication() { +class AmneziaApplication : QtApplication(), CameraXConfig.Provider { - override fun onCreate() { - super.onCreate() - /* Core.init(this, QtActivity::class) - VpnManager.getInstance().init(this) */ - } + override fun getCameraXConfig(): CameraXConfig = CameraXConfig.Builder + .fromConfig(Camera2Config.defaultConfig()) + .setMinimumLoggingLevel(android.util.Log.ERROR) + .setAvailableCamerasLimiter(CameraSelector.DEFAULT_BACK_CAMERA) + .build() - override fun onConfigurationChanged(newConfig: Configuration) { - super.onConfigurationChanged(newConfig) - // Core.updateNotificationChannels() - } } diff --git a/client/android/src/org/amnezia/vpn/CameraActivity.kt b/client/android/src/org/amnezia/vpn/CameraActivity.kt index 3eaec6b4..16f847d3 100644 --- a/client/android/src/org/amnezia/vpn/CameraActivity.kt +++ b/client/android/src/org/amnezia/vpn/CameraActivity.kt @@ -4,167 +4,151 @@ import android.Manifest import android.annotation.SuppressLint import android.content.pm.PackageManager import android.os.Bundle -import android.util.Log -import android.view.MotionEvent -import android.view.View +import android.view.MotionEvent.ACTION_DOWN +import android.view.MotionEvent.ACTION_UP import android.widget.Toast -import androidx.appcompat.app.AppCompatActivity -import androidx.camera.core.* +import androidx.activity.ComponentActivity +import androidx.activity.result.contract.ActivityResultContracts.RequestPermission +import androidx.camera.core.CameraSelector +import androidx.camera.core.ExperimentalGetImage +import androidx.camera.core.FocusMeteringAction +import androidx.camera.core.FocusMeteringAction.FLAG_AE +import androidx.camera.core.FocusMeteringAction.FLAG_AF +import androidx.camera.core.ImageAnalysis +import androidx.camera.core.Preview import androidx.camera.lifecycle.ProcessCameraProvider -import androidx.camera.view.PreviewView import androidx.core.content.ContextCompat -import com.google.mlkit.vision.barcode.BarcodeScannerOptions +import com.google.mlkit.vision.barcode.BarcodeScannerOptions.Builder import com.google.mlkit.vision.barcode.BarcodeScanning +import com.google.mlkit.vision.barcode.ZoomSuggestionOptions import com.google.mlkit.vision.barcode.common.Barcode import com.google.mlkit.vision.common.InputImage -import org.amnezia.vpn.R -import java.util.concurrent.ExecutorService -import java.util.concurrent.Executors +import org.amnezia.vpn.databinding.CameraPreviewBinding +import org.amnezia.vpn.qt.QtAndroidController +private const val TAG = "CameraActivity" -class CameraActivity : AppCompatActivity() { +class CameraActivity : ComponentActivity() { - private val CAMERA_REQUEST = 100 - - private lateinit var cameraExecutor: ExecutorService - private lateinit var analyzerExecutor: ExecutorService - - private lateinit var viewFinder: PreviewView - - companion object { - private lateinit var instance: CameraActivity - - @JvmStatic fun getInstance(): CameraActivity { - return instance - } - - @JvmStatic fun stopQrCodeReader() { - CameraActivity.getInstance().finish() - } - } - - external fun passDataToDecoder(data: String) + private lateinit var viewBinding: CameraPreviewBinding + private lateinit var cameraProvider: ProcessCameraProvider + @ExperimentalGetImage override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) - setContentView(R.layout.activity_camera) + viewBinding = CameraPreviewBinding.inflate(layoutInflater) + setContentView(viewBinding.root) - viewFinder = findViewById(R.id.viewFinder) - - cameraExecutor = Executors.newSingleThreadExecutor() - analyzerExecutor = Executors.newSingleThreadExecutor() - - instance = this - - checkPermissions() - - configureVideoPreview() + checkPermissions(onSuccess = ::startCamera, onFail = ::finish) } - private fun checkPermissions() { - if (checkSelfPermission(Manifest.permission.CAMERA) != PackageManager.PERMISSION_GRANTED) { - requestPermissions(arrayOf(Manifest.permission.CAMERA), CAMERA_REQUEST) + private fun checkPermissions(onSuccess: () -> Unit, onFail: () -> Unit) { + if (checkSelfPermission(Manifest.permission.CAMERA) == PackageManager.PERMISSION_GRANTED) { + onSuccess() + } else { + val requestPermissionLauncher = + registerForActivityResult(RequestPermission()) { isGranted -> + if (isGranted) { + Toast.makeText(this, "Camera permission granted", Toast.LENGTH_SHORT).show() + onSuccess() + } else { + Toast.makeText(this, "Camera permission denied", Toast.LENGTH_SHORT).show() + onFail() + } + } + requestPermissionLauncher.launch(Manifest.permission.CAMERA) } } - override fun onRequestPermissionsResult(requestCode: Int, permissions: Array, grantResults: IntArray) { - super.onRequestPermissionsResult(requestCode, permissions, grantResults) - if (requestCode == CAMERA_REQUEST) { - if (grantResults[0] == PackageManager.PERMISSION_GRANTED) { - Toast.makeText(this, "CameraX permission granted", Toast.LENGTH_SHORT).show(); - } else { - Toast.makeText(this, "CameraX permission denied", Toast.LENGTH_SHORT).show(); - } - } - } - - @SuppressLint("UnsafeOptInUsageError", "ClickableViewAccessibility") - private fun configureVideoPreview() { + @ExperimentalGetImage + private fun startCamera() { val cameraProviderFuture = ProcessCameraProvider.getInstance(this) - val imageCapture = ImageCapture.Builder().build() cameraProviderFuture.addListener({ - val cameraProvider: ProcessCameraProvider = cameraProviderFuture.get() - - val preview = Preview.Builder().build() - - val cameraSelector = CameraSelector.DEFAULT_BACK_CAMERA - - val imageAnalyzer = BarCodeAnalyzer() - - val analysisUseCase = ImageAnalysis.Builder() - .setBackpressureStrategy(ImageAnalysis.STRATEGY_KEEP_ONLY_LATEST) - .build() - - analysisUseCase.setAnalyzer(analyzerExecutor, imageAnalyzer) - - try { - preview.setSurfaceProvider(viewFinder.surfaceProvider) - cameraProvider.unbindAll() - val camera = cameraProvider.bindToLifecycle(this, cameraSelector, preview, imageCapture, analysisUseCase) - viewFinder.setOnTouchListener(View.OnTouchListener { view: View, motionEvent: MotionEvent -> - when (motionEvent.action) { - MotionEvent.ACTION_DOWN -> return@OnTouchListener true - MotionEvent.ACTION_UP -> { - val factory = viewFinder.meteringPointFactory - val point = factory.createPoint(motionEvent.x, motionEvent.y) - val action = FocusMeteringAction.Builder(point).build() - camera.cameraControl.startFocusAndMetering(action) - return@OnTouchListener true - } - else -> return@OnTouchListener false - } - }) - } catch(exc: Exception) { - Log.e("WUTT", "Use case binding failed", exc) - } + cameraProvider = cameraProviderFuture.get() + bindPreview() + bindImageAnalysis() }, ContextCompat.getMainExecutor(this)) } - override fun onDestroy() { - cameraExecutor.shutdown() - analyzerExecutor.shutdown() + @SuppressLint("ClickableViewAccessibility") + private fun bindPreview() { + val viewFinder = viewBinding.viewFinder + val preview = Preview.Builder().build().also { + it.setSurfaceProvider(viewFinder.surfaceProvider) + } - super.onDestroy() - } + val camera = cameraProvider.bindToLifecycle(this, CameraSelector.DEFAULT_BACK_CAMERA, preview) - val barcodesSet = mutableSetOf() + viewFinder.setOnTouchListener { _, motionEvent -> + when (motionEvent.action) { + ACTION_DOWN -> true + ACTION_UP -> { + val point = viewFinder + .meteringPointFactory.createPoint(motionEvent.x, motionEvent.x) - private inner class BarCodeAnalyzer(): ImageAnalysis.Analyzer { + val action = FocusMeteringAction + .Builder(point, FLAG_AF or FLAG_AE).build() - private val options = BarcodeScannerOptions.Builder() - .setBarcodeFormats(Barcode.FORMAT_QR_CODE) - .build() + camera.cameraControl.startFocusAndMetering(action) + true + } - private val scanner = BarcodeScanning.getClient(options) - - @SuppressLint("UnsafeOptInUsageError") - 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 -> - if (barcodes.isNotEmpty()) { - val barcode = barcodes[0] - if (barcode != null) { - val str = barcode?.displayValue ?: "" - if (str.isNotEmpty()) { - val isAdded = barcodesSet.add(str) - if (isAdded) { - passDataToDecoder(str) - } - } - } - } - imageProxy.close() - } - .addOnFailureListener { - imageProxy.close() - } + else -> false } } } -} \ No newline at end of file + + @ExperimentalGetImage + private fun bindImageAnalysis() { + val imageAnalysis = ImageAnalysis.Builder().build() + + val camera = cameraProvider.bindToLifecycle(this, CameraSelector.DEFAULT_BACK_CAMERA, imageAnalysis) + + val barcodeScanner = BarcodeScanning.getClient( + Builder() + .setBarcodeFormats(Barcode.FORMAT_QR_CODE) + .setZoomSuggestionOptions( + ZoomSuggestionOptions.Builder { zoomLevel -> + camera.cameraControl.setZoomRatio(zoomLevel) + true + }.apply { + camera.cameraInfo.zoomState.value?.maxZoomRatio?.let { maxZoomRation -> + setMaxSupportedZoomRatio(maxZoomRation) + } + }.build() + ).build() + ) + + // optimization + val checkedBarcodes = hashSetOf() + + imageAnalysis.setAnalyzer(ContextCompat.getMainExecutor(this)) { imageProxy -> + imageProxy.image?.let { InputImage.fromMediaImage(it, imageProxy.imageInfo.rotationDegrees) } + ?.let { image -> + barcodeScanner.process(image).addOnSuccessListener { barcodes -> + barcodes.firstOrNull()?.let { barcode -> + barcode.displayValue?.let { code -> + if (code.isNotEmpty() && code !in checkedBarcodes) { + if (QtAndroidController.decodeQrCode(code)) { + barcodeScanner.close() + stopCamera() + } + checkedBarcodes.add(code) + } + } + } + }.addOnFailureListener { + Log.e(TAG, "Processing QR-code image failed: ${it.message}") + }.addOnCompleteListener { + imageProxy.close() + } + } + } + } + + private fun stopCamera() { + cameraProvider.unbindAll() + finish() + } +} diff --git a/client/android/src/org/amnezia/vpn/qt/QtAndroidController.kt b/client/android/src/org/amnezia/vpn/qt/QtAndroidController.kt index ce509281..0f3c7b1f 100644 --- a/client/android/src/org/amnezia/vpn/qt/QtAndroidController.kt +++ b/client/android/src/org/amnezia/vpn/qt/QtAndroidController.kt @@ -15,4 +15,6 @@ object QtAndroidController { external fun onStatisticsUpdate(rxBytes: Long, txBytes: Long) external fun onConfigImported() + + external fun decodeQrCode(data: String): Boolean } \ No newline at end of file diff --git a/client/platforms/android/android_controller.cpp b/client/platforms/android/android_controller.cpp index 5bf0be58..c9888b4c 100644 --- a/client/platforms/android/android_controller.cpp +++ b/client/platforms/android/android_controller.cpp @@ -3,6 +3,7 @@ #include #include "android_controller.h" +#include "ui/controllers/importController.h" namespace { @@ -101,6 +102,7 @@ bool AndroidController::initialize() {"onVpnDisconnected", "()V", reinterpret_cast(onVpnDisconnected)}, {"onStatisticsUpdate", "(JJ)V", reinterpret_cast(onStatisticsUpdate)}, {"onConfigImported", "()V", reinterpret_cast(onConfigImported)}, + {"decodeQrCode", "(Ljava/lang/String;)Z", reinterpret_cast(decodeQrCode)} }; QJniEnvironment env; @@ -240,6 +242,7 @@ void AndroidController::onStatisticsUpdate(JNIEnv *env, jobject thiz, jlong rxBy emit AndroidController::instance()->statisticsUpdated((quint64) rxBytes, (quint64) txBytes); } +// static void AndroidController::onConfigImported(JNIEnv *env, jobject thiz) { Q_UNUSED(env); @@ -247,3 +250,18 @@ void AndroidController::onConfigImported(JNIEnv *env, jobject thiz) emit AndroidController::instance()->configImported(); } + +// static +bool AndroidController::decodeQrCode(JNIEnv *env, jobject thiz, jstring data) +{ + Q_UNUSED(thiz); + + const char *buffer = env->GetStringUTFChars(data, nullptr); + if (!buffer) { + return false; + } + + QString code(buffer); + env->ReleaseStringUTFChars(data, buffer); + return ImportController::decodeQrCode(code); +} diff --git a/client/platforms/android/android_controller.h b/client/platforms/android/android_controller.h index 92d1916d..e30dcc68 100644 --- a/client/platforms/android/android_controller.h +++ b/client/platforms/android/android_controller.h @@ -50,6 +50,7 @@ private: static void onVpnDisconnected(JNIEnv *env, jobject thiz); static void onStatisticsUpdate(JNIEnv *env, jobject thiz, jlong rxBytes, jlong txBytes); static void onConfigImported(JNIEnv *env, jobject thiz); + static bool decodeQrCode(JNIEnv *env, jobject thiz, jstring data); template static auto callActivityMethod(const char *methodName, const char *signature, diff --git a/client/ui/controllers/importController.cpp b/client/ui/controllers/importController.cpp index e128d1cf..b9ee0b68 100644 --- a/client/ui/controllers/importController.cpp +++ b/client/ui/controllers/importController.cpp @@ -7,9 +7,7 @@ #include "core/errorstrings.h" #ifdef Q_OS_ANDROID - #include "../../platforms/android/android_controller.h" - #include "../../platforms/android/androidutils.h" - #include + #include "platforms/android/android_controller.h" #endif #ifdef Q_OS_IOS #include @@ -48,10 +46,6 @@ namespace #if defined Q_OS_ANDROID ImportController *mInstance = nullptr; #endif - -#ifdef Q_OS_ANDROID - constexpr auto AndroidCameraActivity = "org.amnezia.vpn.CameraActivity"; -#endif } // namespace ImportController::ImportController(const QSharedPointer &serversModel, @@ -61,18 +55,6 @@ ImportController::ImportController(const QSharedPointer &serversMo { #ifdef Q_OS_ANDROID mInstance = this; - - AndroidUtils::runOnAndroidThreadAsync([]() { - JNINativeMethod methods[] { - { "passDataToDecoder", "(Ljava/lang/String;)V", reinterpret_cast(onNewQrCodeDataChunk) }, - }; - - QJniObject javaClass(AndroidCameraActivity); - QJniEnvironment env; - jclass objectClass = env->GetObjectClass(javaClass.object()); - env->RegisterNatives(objectClass, methods, sizeof(methods) / sizeof(methods[0])); - env->DeleteLocalRef(objectClass); - }); #endif } @@ -320,26 +302,20 @@ QJsonObject ImportController::extractWireGuardConfig(const QString &data) } #ifdef Q_OS_ANDROID -void ImportController::onNewQrCodeDataChunk(JNIEnv *env, jobject thiz, jstring data) +static QMutex qrDecodeMutex; + +// static +bool ImportController::decodeQrCode(const QString &code) { - Q_UNUSED(thiz); - const char *buffer = env->GetStringUTFChars(data, nullptr); - if (!buffer) { - return; - } + QMutexLocker lock(&qrDecodeMutex); - QString parcelBody(buffer); - env->ReleaseStringUTFChars(data, buffer); - - if (mInstance != nullptr) { - if (!mInstance->m_isQrCodeProcessed) { - mInstance->m_qrCodeChunks.clear(); - mInstance->m_isQrCodeProcessed = true; - mInstance->m_totalQrCodeChunksCount = 0; - mInstance->m_receivedQrCodeChunksCount = 0; - } - mInstance->parseQrCodeChunk(parcelBody); + if (!mInstance->m_isQrCodeProcessed) { + mInstance->m_qrCodeChunks.clear(); + mInstance->m_isQrCodeProcessed = true; + mInstance->m_totalQrCodeChunksCount = 0; + mInstance->m_receivedQrCodeChunksCount = 0; } + return mInstance->parseQrCodeChunk(code); } #endif @@ -360,17 +336,14 @@ void ImportController::startDecodingQr() void ImportController::stopDecodingQr() { - #if defined Q_OS_ANDROID - QJniObject::callStaticMethod(AndroidCameraActivity, "stopQrCodeReader", "()V"); - #endif emit qrDecodingFinished(); } -void ImportController::parseQrCodeChunk(const QString &code) +bool ImportController::parseQrCodeChunk(const QString &code) { // qDebug() << code; if (!m_isQrCodeProcessed) - return; + return false; // check if chunk received QByteArray ba = QByteArray::fromBase64(code.toUtf8(), QByteArray::Base64UrlEncoding | QByteArray::OmitTrailingEquals); @@ -404,6 +377,7 @@ void ImportController::parseQrCodeChunk(const QString &code) m_isQrCodeProcessed = false; qDebug() << "stopDecodingQr"; stopDecodingQr(); + return true; } else { qDebug() << "error while extracting data from qr"; m_qrCodeChunks.clear(); @@ -417,8 +391,10 @@ void ImportController::parseQrCodeChunk(const QString &code) m_isQrCodeProcessed = false; qDebug() << "stopDecodingQr"; stopDecodingQr(); + return true; } } + return false; } double ImportController::getQrCodeScanProgressBarValue() diff --git a/client/ui/controllers/importController.h b/client/ui/controllers/importController.h index 1f8f8bbb..c1d0b2ab 100644 --- a/client/ui/controllers/importController.h +++ b/client/ui/controllers/importController.h @@ -7,9 +7,6 @@ #include "core/defs.h" #include "ui/models/containers_model.h" #include "ui/models/servers_model.h" -#ifdef Q_OS_ANDROID - #include "jni.h" -#endif class ImportController : public QObject { @@ -30,12 +27,16 @@ public slots: #if defined Q_OS_ANDROID || defined Q_OS_IOS void startDecodingQr(); - void parseQrCodeChunk(const QString &code); + bool parseQrCodeChunk(const QString &code); double getQrCodeScanProgressBarValue(); QString getQrCodeScanProgressString(); #endif +#if defined Q_OS_ANDROID + static bool decodeQrCode(const QString &code); +#endif + signals: void importFinished(); void importErrorOccurred(const QString &errorMessage); @@ -50,9 +51,6 @@ private: #if defined Q_OS_ANDROID || defined Q_OS_IOS void stopDecodingQr(); #endif -#if defined Q_OS_ANDROID - static void onNewQrCodeDataChunk(JNIEnv *env, jobject thiz, jstring data); -#endif QSharedPointer m_serversModel; QSharedPointer m_containersModel;