mirror of
https://github.com/SagerNet/sing-box-for-android.git
synced 2025-04-03 20:07:38 +03:00
Add in-app qr code scanner
This commit is contained in:
parent
df51284af0
commit
29e02d2696
15 changed files with 471 additions and 2 deletions
|
@ -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")
|
||||
|
|
|
@ -2,6 +2,11 @@
|
|||
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
xmlns:tools="http://schemas.android.com/tools">
|
||||
|
||||
|
||||
<uses-feature
|
||||
android:name="android.hardware.camera"
|
||||
android:required="false" />
|
||||
|
||||
<uses-permission android:name="android.permission.INTERNET" />
|
||||
<uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
|
||||
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_SPECIAL_USE" />
|
||||
|
@ -13,6 +18,7 @@
|
|||
<uses-permission android:name="android.permission.ACCESS_COARSE_LOCATION" />
|
||||
<uses-permission android:name="android.permission.ACCESS_FINE_LOCATION" />
|
||||
<uses-permission android:name="android.permission.ACCESS_BACKGROUND_LOCATION" />
|
||||
<uses-permission android:name="android.permission.CAMERA" />
|
||||
|
||||
<uses-permission
|
||||
android:name="android.permission.QUERY_ALL_PACKAGES"
|
||||
|
@ -126,6 +132,10 @@
|
|||
android:name="io.nekohasekai.sfa.ui.debug.VPNScanActivity"
|
||||
android:exported="false" />
|
||||
|
||||
<activity
|
||||
android:name="io.nekohasekai.sfa.ui.profile.QRScanActivity"
|
||||
android:exported="false" />
|
||||
|
||||
<service
|
||||
android:name=".bg.TileService"
|
||||
android:directBootAware="true"
|
||||
|
|
|
@ -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?
|
||||
}
|
|
@ -0,0 +1,12 @@
|
|||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:height="24dp"
|
||||
android:tint="#000000"
|
||||
android:viewportHeight="24"
|
||||
android:viewportWidth="24"
|
||||
android:width="24dp">
|
||||
|
||||
<path
|
||||
android:fillColor="@android:color/white"
|
||||
android:pathData="M20,6h-8l-2,-2L4,4c-1.11,0 -1.99,0.89 -1.99,2L2,18c0,1.11 0.89,2 2,2h16c1.11,0 2,-0.89 2,-2L22,8c0,-1.11 -0.89,-2 -2,-2zM19,14h-3v3h-2v-3h-3v-2h3L14,9h2v3h3v2z" />
|
||||
|
||||
</vector>
|
13
app/src/main/res/layout/activity_qr_scan.xml
Normal file
13
app/src/main/res/layout/activity_qr_scan.xml
Normal file
|
@ -0,0 +1,13 @@
|
|||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<FrameLayout xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="match_parent">
|
||||
|
||||
<androidx.camera.view.PreviewView
|
||||
android:id="@+id/preview_view"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="match_parent" />
|
||||
|
||||
<include layout="@layout/view_appbar" />
|
||||
|
||||
</FrameLayout>
|
70
app/src/main/res/layout/sheet_add_profile.xml
Normal file
70
app/src/main/res/layout/sheet_add_profile.xml
Normal file
|
@ -0,0 +1,70 @@
|
|||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<androidx.constraintlayout.widget.ConstraintLayout xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="match_parent"
|
||||
xmlns:app="http://schemas.android.com/apk/res-auto">
|
||||
|
||||
<LinearLayout
|
||||
style="@style/Widget.Material3.BottomSheet"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="match_parent"
|
||||
android:orientation="vertical"
|
||||
android:background="?selectableItemBackground"
|
||||
app:layout_behavior="com.google.android.material.bottomsheet.BottomSheetBehavior">
|
||||
|
||||
<com.google.android.material.bottomsheet.BottomSheetDragHandleView
|
||||
android:id="@+id/drag_handle"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content" />
|
||||
|
||||
<LinearLayout
|
||||
android:id="@+id/scan_qr_code"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="64dp"
|
||||
android:background="?selectableItemBackground"
|
||||
android:orientation="horizontal">
|
||||
|
||||
<ImageView
|
||||
android:layout_width="64dp"
|
||||
android:padding="18dp"
|
||||
app:tint="?colorControlNormal"
|
||||
android:layout_height="match_parent"
|
||||
android:src="@drawable/ic_qr_code_2_24" />
|
||||
|
||||
<TextView
|
||||
android:layout_width="0dp"
|
||||
android:layout_weight="1"
|
||||
android:text="@string/profile_add_scan_qr_code"
|
||||
android:gravity="center_vertical"
|
||||
android:layout_height="match_parent">
|
||||
|
||||
</TextView>
|
||||
</LinearLayout>
|
||||
|
||||
<LinearLayout
|
||||
android:id="@+id/create_manually"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="64dp"
|
||||
android:background="?selectableItemBackground"
|
||||
android:orientation="horizontal">
|
||||
|
||||
<ImageView
|
||||
android:layout_width="64dp"
|
||||
android:padding="18dp"
|
||||
app:tint="?colorControlNormal"
|
||||
android:layout_height="match_parent"
|
||||
android:src="@drawable/ic_baseline_create_new_folder_24" />
|
||||
|
||||
<TextView
|
||||
android:layout_width="0dp"
|
||||
android:layout_weight="1"
|
||||
android:text="@string/profile_add_create_manually"
|
||||
android:gravity="center_vertical"
|
||||
android:layout_height="match_parent">
|
||||
|
||||
</TextView>
|
||||
</LinearLayout>
|
||||
|
||||
</LinearLayout>
|
||||
|
||||
</androidx.constraintlayout.widget.ConstraintLayout>
|
8
app/src/main/res/menu/qr_scan_menu.xml
Normal file
8
app/src/main/res/menu/qr_scan_menu.xml
Normal file
|
@ -0,0 +1,8 @@
|
|||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<menu xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
|
||||
<item
|
||||
android:id="@+id/action_disable_vendor_analyzer"
|
||||
android:title="@string/disable_vendor_analyzer" />
|
||||
|
||||
</menu>
|
|
@ -41,6 +41,10 @@
|
|||
<string name="profile_source_create_new">Create New</string>
|
||||
<string name="profile_source_import">Import</string>
|
||||
|
||||
<string name="profile_add_scan_qr_code">Scan QR code</string>
|
||||
<string name="profile_add_import_from_clipboard">Import remote profile from clipboard</string>
|
||||
<string name="profile_add_create_manually">Create Manually</string>
|
||||
|
||||
<string name="menu_undo">Undo</string>
|
||||
<string name="menu_redo">Redo</string>
|
||||
<string name="menu_format">Format</string>
|
||||
|
@ -185,5 +189,6 @@
|
|||
<string name="open_settings">Open Settings</string>
|
||||
|
||||
<string name="settings_title_core">Core</string>
|
||||
<string name="disable_vendor_analyzer">Disable Google MLKit</string>
|
||||
|
||||
</resources>
|
|
@ -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
|
||||
}
|
||||
}
|
63
app/src/play/java/io/nekohasekai/sfa/vendor/MLKitQRCodeAnalyzer.kt
vendored
Normal file
63
app/src/play/java/io/nekohasekai/sfa/vendor/MLKitQRCodeAnalyzer.kt
vendored
Normal file
|
@ -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)
|
||||
}
|
|
@ -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
|
||||
}
|
||||
}
|
||||
|
||||
}
|
Loading…
Add table
Add a link
Reference in a new issue