Add in-app qr code scanner
This commit is contained in:
@@ -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") {
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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?
|
||||
}
|
||||
Reference in New Issue
Block a user