From 29e02d2696f8cfc94f6d2e95d80a3b91768a8d83 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E4=B8=96=E7=95=8C?= Date: Sat, 16 Mar 2024 16:38:42 +0800 Subject: [PATCH] Add in-app qr code scanner --- app/build.gradle | 4 + app/src/main/AndroidManifest.xml | 10 + .../io/nekohasekai/sfa/ui/MainActivity.kt | 2 +- .../sfa/ui/main/ConfigurationFragment.kt | 31 ++- .../sfa/ui/profile/QRScanActivity.kt | 177 ++++++++++++++++++ .../sfa/ui/profile/ZxingQRCodeAnalyzer.kt | 49 +++++ .../nekohasekai/sfa/vendor/VendorInterface.kt | 5 + .../ic_baseline_create_new_folder_24.xml | 12 ++ app/src/main/res/layout/activity_qr_scan.xml | 13 ++ app/src/main/res/layout/sheet_add_profile.xml | 70 +++++++ app/src/main/res/menu/qr_scan_menu.xml | 8 + app/src/main/res/values/strings.xml | 5 + .../java/io/nekohasekai/sfa/vendor/Vendor.kt | 8 + .../sfa/vendor/MLKitQRCodeAnalyzer.kt | 63 +++++++ .../java/io/nekohasekai/sfa/vendor/Vendor.kt | 16 ++ 15 files changed, 471 insertions(+), 2 deletions(-) create mode 100644 app/src/main/java/io/nekohasekai/sfa/ui/profile/QRScanActivity.kt create mode 100644 app/src/main/java/io/nekohasekai/sfa/ui/profile/ZxingQRCodeAnalyzer.kt create mode 100644 app/src/main/res/drawable/ic_baseline_create_new_folder_24.xml create mode 100644 app/src/main/res/layout/activity_qr_scan.xml create mode 100644 app/src/main/res/layout/sheet_add_profile.xml create mode 100644 app/src/main/res/menu/qr_scan_menu.xml create mode 100644 app/src/play/java/io/nekohasekai/sfa/vendor/MLKitQRCodeAnalyzer.kt diff --git a/app/build.gradle b/app/build.gradle index 6bc2f4c..45b12f0 100644 --- a/app/build.gradle +++ b/app/build.gradle @@ -101,6 +101,9 @@ dependencies { implementation "androidx.room:room-runtime:2.6.1" implementation "androidx.coordinatorlayout:coordinatorlayout:1.2.0" implementation "androidx.preference:preference-ktx:1.2.1" + implementation "androidx.camera:camera-view:1.3.2" + implementation "androidx.camera:camera-lifecycle:1.3.2" + implementation "androidx.camera:camera-camera2:1.3.2" ksp "androidx.room:room-compiler:2.6.1" implementation "androidx.work:work-runtime-ktx:2.9.0" implementation "androidx.browser:browser:1.8.0" @@ -115,6 +118,7 @@ dependencies { } implementation "com.google.guava:guava:33.0.0-android" playImplementation "com.google.android.play:app-update-ktx:2.1.0" + playImplementation "com.google.android.gms:play-services-mlkit-barcode-scanning:18.3.0" } def playCredentialsJSON = rootProject.file("service-account-credentials.json") diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index 915fffb..9ed1ef5 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -2,6 +2,11 @@ + + + @@ -13,6 +18,7 @@ + + + (), } } - override fun onNewIntent(intent: Intent) { + override public fun onNewIntent(intent: Intent) { super.onNewIntent(intent) val uri = intent.data ?: return if (uri.scheme == "sing-box" && uri.host == "import-remote-profile") { diff --git a/app/src/main/java/io/nekohasekai/sfa/ui/main/ConfigurationFragment.kt b/app/src/main/java/io/nekohasekai/sfa/ui/main/ConfigurationFragment.kt index 56bc3bd..13b09d4 100644 --- a/app/src/main/java/io/nekohasekai/sfa/ui/main/ConfigurationFragment.kt +++ b/app/src/main/java/io/nekohasekai/sfa/ui/main/ConfigurationFragment.kt @@ -1,5 +1,6 @@ package io.nekohasekai.sfa.ui.main +import android.annotation.SuppressLint import android.content.Intent import android.os.Bundle import android.view.LayoutInflater @@ -13,17 +14,21 @@ import androidx.lifecycle.lifecycleScope import androidx.recyclerview.widget.ItemTouchHelper import androidx.recyclerview.widget.LinearLayoutManager import androidx.recyclerview.widget.RecyclerView +import com.google.android.material.bottomsheet.BottomSheetDialogFragment import io.nekohasekai.sfa.R import io.nekohasekai.sfa.database.Profile import io.nekohasekai.sfa.database.ProfileManager import io.nekohasekai.sfa.database.TypedProfile import io.nekohasekai.sfa.databinding.FragmentConfigurationBinding +import io.nekohasekai.sfa.databinding.SheetAddProfileBinding import io.nekohasekai.sfa.databinding.ViewConfigutationItemBinding import io.nekohasekai.sfa.ktx.errorDialogBuilder import io.nekohasekai.sfa.ktx.shareProfile import io.nekohasekai.sfa.ktx.shareProfileURL +import io.nekohasekai.sfa.ui.MainActivity import io.nekohasekai.sfa.ui.profile.EditProfileActivity import io.nekohasekai.sfa.ui.profile.NewProfileActivity +import io.nekohasekai.sfa.ui.profile.QRScanActivity import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.GlobalScope import kotlinx.coroutines.launch @@ -70,12 +75,35 @@ class ConfigurationFragment : Fragment() { } adapter.reload() binding.fab.setOnClickListener { - startActivity(Intent(requireContext(), NewProfileActivity::class.java)) + AddProfileDialog().show(childFragmentManager, "add_profile") } ProfileManager.registerCallback(this::updateProfiles) return binding.root } + class AddProfileDialog : BottomSheetDialogFragment(R.layout.sheet_add_profile) { + + private val scanQrCode = + registerForActivityResult(QRScanActivity.Contract(), ::onScanResult) + + override fun onViewCreated(view: View, savedInstanceState: Bundle?) { + super.onViewCreated(view, savedInstanceState) + val binding = SheetAddProfileBinding.bind(view) + binding.scanQrCode.setOnClickListener { + scanQrCode.launch(null) + } + binding.createManually.setOnClickListener { + dismiss() + startActivity(Intent(requireContext(), NewProfileActivity::class.java)) + } + } + + private fun onScanResult(result: Intent?) { + dismiss() + (activity as? MainActivity ?: return).onNewIntent(result ?: return) + } + } + override fun onResume() { super.onResume() adapter?.reload() @@ -100,6 +128,7 @@ class ConfigurationFragment : Fragment() { internal val scope = lifecycleScope internal val fragmentActivity = requireActivity() as FragmentActivity + @SuppressLint("NotifyDataSetChanged") internal fun reload() { lifecycleScope.launch(Dispatchers.IO) { val newItems = ProfileManager.list().toMutableList() diff --git a/app/src/main/java/io/nekohasekai/sfa/ui/profile/QRScanActivity.kt b/app/src/main/java/io/nekohasekai/sfa/ui/profile/QRScanActivity.kt new file mode 100644 index 0000000..5907a93 --- /dev/null +++ b/app/src/main/java/io/nekohasekai/sfa/ui/profile/QRScanActivity.kt @@ -0,0 +1,177 @@ +package io.nekohasekai.sfa.ui.profile + +import android.Manifest +import android.content.Context +import android.content.Intent +import android.content.pm.PackageManager +import android.net.Uri +import android.os.Bundle +import android.view.Menu +import android.view.MenuItem +import androidx.activity.result.contract.ActivityResultContract +import androidx.activity.result.contract.ActivityResultContracts +import androidx.camera.core.CameraSelector +import androidx.camera.core.ImageAnalysis +import androidx.camera.core.Preview +import androidx.camera.lifecycle.ProcessCameraProvider +import androidx.core.content.ContextCompat +import androidx.lifecycle.lifecycleScope +import io.nekohasekai.libbox.Libbox +import io.nekohasekai.sfa.R +import io.nekohasekai.sfa.databinding.ActivityQrScanBinding +import io.nekohasekai.sfa.ktx.errorDialogBuilder +import io.nekohasekai.sfa.ui.shared.AbstractActivity +import io.nekohasekai.sfa.vendor.Vendor +import kotlinx.coroutines.launch +import java.util.concurrent.ExecutorService +import java.util.concurrent.Executors + +class QRScanActivity : AbstractActivity() { + + private lateinit var analysisExecutor: ExecutorService + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + + setTitle(R.string.profile_add_scan_qr_code) + + analysisExecutor = Executors.newSingleThreadExecutor() + if (ContextCompat.checkSelfPermission( + this, Manifest.permission.CAMERA + ) == PackageManager.PERMISSION_GRANTED + ) { + startCamera() + } else { + requestPermissionLauncher.launch(Manifest.permission.CAMERA) + } + } + + private val requestPermissionLauncher = + registerForActivityResult(ActivityResultContracts.RequestPermission()) { isGranted: Boolean -> + if (isGranted) { + startCamera() + } else { + setResult(RESULT_CANCELED) + finish() + } + } + + private lateinit var imageAnalysis: ImageAnalysis + private lateinit var imageAnalyzer: ImageAnalysis.Analyzer + private val onSuccess: (String) -> Unit = { rawValue: String -> + imageAnalysis.clearAnalyzer() + if (!onSuccess(rawValue)) { + imageAnalysis.setAnalyzer(analysisExecutor, imageAnalyzer) + } + } + private val onFailure: (Exception) -> Unit = { + lifecycleScope.launch { + errorDialogBuilder(it).show() + } + } + private val vendorAnalyzer = Vendor.createQRCodeAnalyzer(onSuccess, onFailure) + + private fun startCamera() { + val cameraProviderFuture = try { + ProcessCameraProvider.getInstance(this) + } catch (e: Exception) { + fatalError(e) + return + } + cameraProviderFuture.addListener({ + val cameraProvider = try { + cameraProviderFuture.get() + } catch (e: Exception) { + fatalError(e) + return@addListener + } + + val preview = Preview.Builder().build() + .also { it.setSurfaceProvider(binding.previewView.surfaceProvider) } + imageAnalysis = ImageAnalysis.Builder().build() + imageAnalyzer = vendorAnalyzer ?: ZxingQRCodeAnalyzer(onSuccess, onFailure) + imageAnalysis.setAnalyzer(analysisExecutor, imageAnalyzer) + cameraProvider.unbindAll() + + try { + cameraProvider.bindToLifecycle( + this, CameraSelector.DEFAULT_BACK_CAMERA, preview, imageAnalysis + ) + } catch (e: Exception) { + fatalError(e) + } + }, ContextCompat.getMainExecutor(this)) + } + + private fun fatalError(e: Exception) { + lifecycleScope.launch { + errorDialogBuilder(e).setOnDismissListener { + setResult(RESULT_CANCELED) + finish() + }.show() + } + } + + private fun onSuccess(value: String): Boolean { + try { + importRemoteProfileFromString(value) + return true + } catch (e: Exception) { + lifecycleScope.launch { + errorDialogBuilder(e).show() + } + } + return false + } + + private fun importRemoteProfileFromString(uriString: String) { + val uri = Uri.parse(uriString) + if (uri.scheme != "sing-box" || uri.host != "import-remote-profile") error("Not a valid sing-box remote profile URI") + Libbox.parseRemoteProfileImportLink(uri.toString()) + setResult(RESULT_OK, Intent().apply { + setData(uri) + }) + finish() + } + + override fun onCreateOptionsMenu(menu: Menu): Boolean { + if (vendorAnalyzer == null) { + return false + } + menuInflater.inflate(R.menu.qr_scan_menu, menu) + return true + } + + override fun onOptionsItemSelected(item: MenuItem): Boolean { + when (item.itemId) { + R.id.action_disable_vendor_analyzer -> { + imageAnalysis.clearAnalyzer() + imageAnalyzer = ZxingQRCodeAnalyzer(onSuccess, onFailure) + imageAnalysis.setAnalyzer(analysisExecutor, imageAnalyzer) + item.isVisible = false + } + + else -> return super.onOptionsItemSelected(item) + } + return true + } + + + override fun onDestroy() { + super.onDestroy() + analysisExecutor.shutdown() + } + + class Contract : ActivityResultContract() { + + override fun createIntent(context: Context, input: Nothing?): Intent = + Intent(context, QRScanActivity::class.java) + + override fun parseResult(resultCode: Int, intent: Intent?): Intent? { + return when (resultCode) { + RESULT_OK -> intent + else -> null + } + } + } + +} \ No newline at end of file diff --git a/app/src/main/java/io/nekohasekai/sfa/ui/profile/ZxingQRCodeAnalyzer.kt b/app/src/main/java/io/nekohasekai/sfa/ui/profile/ZxingQRCodeAnalyzer.kt new file mode 100644 index 0000000..5d35d27 --- /dev/null +++ b/app/src/main/java/io/nekohasekai/sfa/ui/profile/ZxingQRCodeAnalyzer.kt @@ -0,0 +1,49 @@ +package io.nekohasekai.sfa.ui.profile + +import android.util.Log +import androidx.camera.core.ImageAnalysis +import androidx.camera.core.ImageProxy +import com.google.zxing.BinaryBitmap +import com.google.zxing.NotFoundException +import com.google.zxing.RGBLuminanceSource +import com.google.zxing.common.GlobalHistogramBinarizer +import com.google.zxing.qrcode.QRCodeReader + +class ZxingQRCodeAnalyzer( + private val onSuccess: ((String) -> Unit), + private val onFailure: ((Exception) -> Unit), +) : ImageAnalysis.Analyzer { + + private val qrCodeReader = QRCodeReader() + override fun analyze(image: ImageProxy) { + try { + val bitmap = image.toBitmap() + val intArray = IntArray(bitmap.getWidth() * bitmap.getHeight()) + bitmap.getPixels( + intArray, + 0, + bitmap.getWidth(), + 0, + 0, + bitmap.getWidth(), + bitmap.getHeight() + ) + val source = RGBLuminanceSource(bitmap.getWidth(), bitmap.getHeight(), intArray) + val result = try { + qrCodeReader.decode(BinaryBitmap(GlobalHistogramBinarizer(source))) + } catch (e: NotFoundException) { + try { + qrCodeReader.decode(BinaryBitmap(GlobalHistogramBinarizer(source.invert()))) + } catch (ignore: NotFoundException) { + return + } + } + Log.d("ZxingQRCodeAnalyzer", "barcode decode success: ${result.text}") + onSuccess(result.text) + } catch (e: Exception) { + onFailure(e) + } finally { + image.close() + } + } +} \ No newline at end of file diff --git a/app/src/main/java/io/nekohasekai/sfa/vendor/VendorInterface.kt b/app/src/main/java/io/nekohasekai/sfa/vendor/VendorInterface.kt index 0bbc0b4..7188969 100644 --- a/app/src/main/java/io/nekohasekai/sfa/vendor/VendorInterface.kt +++ b/app/src/main/java/io/nekohasekai/sfa/vendor/VendorInterface.kt @@ -1,8 +1,13 @@ package io.nekohasekai.sfa.vendor import android.app.Activity +import androidx.camera.core.ImageAnalysis interface VendorInterface { fun checkUpdateAvailable(): Boolean fun checkUpdate(activity: Activity, byUser: Boolean) + fun createQRCodeAnalyzer( + onSuccess: (String) -> Unit, + onFailure: (Exception) -> Unit + ): ImageAnalysis.Analyzer? } \ No newline at end of file diff --git a/app/src/main/res/drawable/ic_baseline_create_new_folder_24.xml b/app/src/main/res/drawable/ic_baseline_create_new_folder_24.xml new file mode 100644 index 0000000..61d3f17 --- /dev/null +++ b/app/src/main/res/drawable/ic_baseline_create_new_folder_24.xml @@ -0,0 +1,12 @@ + + + + + diff --git a/app/src/main/res/layout/activity_qr_scan.xml b/app/src/main/res/layout/activity_qr_scan.xml new file mode 100644 index 0000000..aec26cd --- /dev/null +++ b/app/src/main/res/layout/activity_qr_scan.xml @@ -0,0 +1,13 @@ + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/layout/sheet_add_profile.xml b/app/src/main/res/layout/sheet_add_profile.xml new file mode 100644 index 0000000..2b71d33 --- /dev/null +++ b/app/src/main/res/layout/sheet_add_profile.xml @@ -0,0 +1,70 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/menu/qr_scan_menu.xml b/app/src/main/res/menu/qr_scan_menu.xml new file mode 100644 index 0000000..97307c3 --- /dev/null +++ b/app/src/main/res/menu/qr_scan_menu.xml @@ -0,0 +1,8 @@ + + + + + + \ No newline at end of file diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 09e78e6..574d851 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -41,6 +41,10 @@ Create New Import + Scan QR code + Import remote profile from clipboard + Create Manually + Undo Redo Format @@ -185,5 +189,6 @@ Open Settings Core + Disable Google MLKit \ No newline at end of file diff --git a/app/src/other/java/io/nekohasekai/sfa/vendor/Vendor.kt b/app/src/other/java/io/nekohasekai/sfa/vendor/Vendor.kt index 4181309..d9e06b9 100644 --- a/app/src/other/java/io/nekohasekai/sfa/vendor/Vendor.kt +++ b/app/src/other/java/io/nekohasekai/sfa/vendor/Vendor.kt @@ -1,6 +1,8 @@ package io.nekohasekai.sfa.vendor import android.app.Activity +import androidx.camera.core.ImageAnalysis +import java.lang.Exception object Vendor : VendorInterface { @@ -11,4 +13,10 @@ object Vendor : VendorInterface { override fun checkUpdate(activity: Activity, byUser: Boolean) { } + override fun createQRCodeAnalyzer( + onSuccess: (String) -> Unit, + onFailure: (Exception) -> Unit + ): ImageAnalysis.Analyzer? { + return null + } } \ No newline at end of file diff --git a/app/src/play/java/io/nekohasekai/sfa/vendor/MLKitQRCodeAnalyzer.kt b/app/src/play/java/io/nekohasekai/sfa/vendor/MLKitQRCodeAnalyzer.kt new file mode 100644 index 0000000..9e27c92 --- /dev/null +++ b/app/src/play/java/io/nekohasekai/sfa/vendor/MLKitQRCodeAnalyzer.kt @@ -0,0 +1,63 @@ +package io.nekohasekai.sfa.vendor + +import android.util.Log +import androidx.camera.core.ExperimentalGetImage +import androidx.camera.core.ImageAnalysis +import androidx.camera.core.ImageProxy +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 + +// kanged from: https://github.com/G00fY2/quickie/blob/main/quickie/src/main/kotlin/io/github/g00fy2/quickie/QRCodeAnalyzer.kt + +class MLKitQRCodeAnalyzer( + private val onSuccess: ((String) -> Unit), + private val onFailure: ((Exception) -> Unit), +) : ImageAnalysis.Analyzer { + + private val barcodeScanner = + BarcodeScanning.getClient( + BarcodeScannerOptions.Builder().setBarcodeFormats(Barcode.FORMAT_QR_CODE).build() + ) + + @Volatile + private var failureOccurred = false + private var failureTimestamp = 0L + + @ExperimentalGetImage + override fun analyze(image: ImageProxy) { + if (image.image == null) return + + if (failureOccurred && System.currentTimeMillis() - failureTimestamp < 1000L) { + Log.d("MLKitQRCodeAnalyzer", "throttled analysis since error occurred in previous pass") + image.close() + return + } + + failureOccurred = false + barcodeScanner.process(image.toInputImage()) + .addOnSuccessListener { codes -> + if (codes.isNotEmpty()) { + val rawValue = codes.firstOrNull()?.rawValue + if (rawValue != null) { + Log.d("MLKitQRCodeAnalyzer", "barcode decode success: $rawValue") + onSuccess(rawValue) + } + } + } + .addOnFailureListener { + failureOccurred = true + failureTimestamp = System.currentTimeMillis() + onFailure(it) + } + .addOnCompleteListener { + image.close() + } + } + + @ExperimentalGetImage + @Suppress("UnsafeCallOnNullableType") + private fun ImageProxy.toInputImage() = + InputImage.fromMediaImage(image!!, imageInfo.rotationDegrees) +} \ No newline at end of file diff --git a/app/src/play/java/io/nekohasekai/sfa/vendor/Vendor.kt b/app/src/play/java/io/nekohasekai/sfa/vendor/Vendor.kt index 6769cdd..a2b5a41 100644 --- a/app/src/play/java/io/nekohasekai/sfa/vendor/Vendor.kt +++ b/app/src/play/java/io/nekohasekai/sfa/vendor/Vendor.kt @@ -3,12 +3,14 @@ package io.nekohasekai.sfa.vendor import android.app.Activity import android.content.Context import android.util.Log +import androidx.camera.core.ImageAnalysis import com.google.android.material.dialog.MaterialAlertDialogBuilder import com.google.android.play.core.appupdate.AppUpdateManagerFactory import com.google.android.play.core.appupdate.AppUpdateOptions import com.google.android.play.core.install.model.AppUpdateType import com.google.android.play.core.install.model.InstallStatus import com.google.android.play.core.install.model.UpdateAvailability +import com.google.mlkit.common.MlKitException import io.nekohasekai.sfa.R object Vendor : VendorInterface { @@ -74,4 +76,18 @@ object Vendor : VendorInterface { .setMessage(R.string.no_updates_available).setPositiveButton(R.string.ok, null).show() } + override fun createQRCodeAnalyzer( + onSuccess: (String) -> Unit, + onFailure: (Exception) -> Unit + ): ImageAnalysis.Analyzer? { + try { + return MLKitQRCodeAnalyzer(onSuccess, onFailure) + } catch (exception: Exception) { + if (exception !is MlKitException || exception.errorCode != MlKitException.UNAVAILABLE) { + Log.e(TAG, "failed to create MLKitQRCodeAnalyzer", exception) + } + return null + } + } + } \ No newline at end of file