Add in-app qr code scanner

This commit is contained in:
世界
2024-03-16 16:38:42 +08:00
parent df51284af0
commit 29e02d2696
15 changed files with 471 additions and 2 deletions

View File

@@ -128,7 +128,7 @@ class MainActivity : AbstractActivity<ActivityMainBinding>(),
}
}
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") {

View File

@@ -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()

View File

@@ -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<ActivityQrScanBinding>() {
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<Nothing?, Intent?>() {
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
}
}
}
}

View File

@@ -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()
}
}
}

View File

@@ -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?
}