1529 lines
63 KiB
Kotlin
1529 lines
63 KiB
Kotlin
package uts.sdk.modules.masCodeScan
|
||
|
||
import android.Manifest
|
||
import android.app.Dialog
|
||
import android.content.Intent
|
||
import android.content.pm.PackageManager
|
||
import android.graphics.Bitmap
|
||
import android.graphics.BitmapFactory
|
||
import android.graphics.Color
|
||
import android.graphics.ColorFilter
|
||
import android.graphics.Canvas
|
||
import android.graphics.Paint
|
||
import android.graphics.Path
|
||
import android.graphics.PixelFormat
|
||
import android.graphics.Rect
|
||
import android.graphics.drawable.Drawable
|
||
import android.graphics.drawable.GradientDrawable
|
||
import android.hardware.camera2.CaptureRequest
|
||
import android.graphics.ImageDecoder
|
||
import android.net.Uri
|
||
import android.provider.MediaStore
|
||
import android.os.ParcelFileDescriptor
|
||
import android.os.Build
|
||
import android.os.Bundle
|
||
import android.os.Handler
|
||
import android.os.Looper
|
||
import android.os.VibrationEffect
|
||
import android.os.Vibrator
|
||
import android.os.VibratorManager
|
||
import android.util.Size
|
||
import android.view.Gravity
|
||
import android.view.MotionEvent
|
||
import android.view.ScaleGestureDetector
|
||
import android.view.View
|
||
import android.widget.FrameLayout
|
||
import android.widget.ImageButton
|
||
import android.widget.ImageView
|
||
import android.widget.LinearLayout
|
||
import android.widget.TextView
|
||
import android.widget.Toast
|
||
import androidx.activity.ComponentActivity
|
||
import androidx.camera.camera2.interop.Camera2Interop
|
||
import androidx.camera.core.Camera
|
||
import androidx.camera.core.CameraSelector
|
||
import androidx.camera.core.CameraUnavailableException
|
||
import androidx.camera.core.ImageAnalysis
|
||
import androidx.camera.core.ImageProxy
|
||
import androidx.camera.core.Preview
|
||
import androidx.camera.core.UseCaseGroup
|
||
import androidx.camera.lifecycle.ProcessCameraProvider
|
||
import androidx.camera.view.PreviewView
|
||
import androidx.core.app.ActivityCompat
|
||
import androidx.core.content.ContextCompat
|
||
import com.google.mlkit.vision.barcode.BarcodeScanner
|
||
import com.google.mlkit.vision.barcode.BarcodeScanning
|
||
import com.google.mlkit.vision.barcode.BarcodeScannerOptions
|
||
import com.google.mlkit.vision.barcode.common.Barcode
|
||
import com.google.mlkit.vision.common.InputImage
|
||
import java.io.File
|
||
import java.util.concurrent.Executors
|
||
|
||
private const val CAPTURE_EXTRA_TITLE = "mas_title"
|
||
private const val CAPTURE_EXTRA_HINT = "mas_hint"
|
||
private const val CAPTURE_EXTRA_SUCCESS_HINT = "mas_success_hint"
|
||
private const val CAPTURE_EXTRA_EMPTY_HINT = "mas_empty_hint"
|
||
private const val CAPTURE_EXTRA_SMART_ZOOM = "mas_smart_zoom"
|
||
private const val CAPTURE_EXTRA_TORCH_MODE = "mas_torch_mode"
|
||
private const val CAPTURE_EXTRA_RESULT_MODE = "mas_result_mode"
|
||
private const val CAPTURE_EXTRA_SHOW_PAUSE_BUTTON = "mas_show_pause_button"
|
||
private const val CAPTURE_EXTRA_VIBRATE = "mas_vibrate"
|
||
private const val CAPTURE_EXTRA_CONTINUOUS = "mas_continuous"
|
||
private const val CAPTURE_EXTRA_FROM_ALBUM = "mas_from_album"
|
||
private const val CAPTURE_EXTRA_FORMATS = "mas_formats"
|
||
private const val CAPTURE_EXTRA_SCAN_REGION_ENABLED = "mas_scan_region_enabled"
|
||
private const val CAPTURE_EXTRA_SCAN_REGION = "mas_scan_region"
|
||
private const val CAPTURE_EXTRA_SCAN_STYLE_SHOW_MASK = "mas_scan_style_show_mask"
|
||
private const val CAPTURE_EXTRA_SCAN_STYLE_SHOW_BEAM = "mas_scan_style_show_beam"
|
||
private const val CAPTURE_EXTRA_SCAN_STYLE_BORDER_COLOR = "mas_scan_style_border_color"
|
||
private const val CAPTURE_EXTRA_SCAN_STYLE_MASK_COLOR = "mas_scan_style_mask_color"
|
||
private const val CAPTURE_EXTRA_SCAN_STYLE_BEAM_COLOR = "mas_scan_style_beam_color"
|
||
private const val CAPTURE_EXTRA_SCAN_STYLE_BEAM_DURATION = "mas_scan_style_beam_duration"
|
||
private const val CAPTURE_EXTRA_SCAN_STYLE_CORNER = "mas_scan_style_corner"
|
||
private const val CAPTURE_EXTRA_SCAN_STYLE_CORNER_RADIUS = "mas_scan_style_corner_radius"
|
||
private const val CAPTURE_EXTRA_SCAN_TIMEOUT = "mas_scan_timeout"
|
||
private const val CAPTURE_EXTRA_CONTINUOUS_SUCCESS_DURATION = "mas_continuous_success_duration"
|
||
private const val CAPTURE_EXTRA_CONTENT = "mas_content"
|
||
private const val CAPTURE_EXTRA_FORMAT = "mas_format"
|
||
private const val CAPTURE_EXTRA_ERR = "mas_err"
|
||
private const val CAPTURE_EXTRA_FROM_ALBUM_RESULT = "mas_result_from_album"
|
||
private const val CAPTURE_EXTRA_HAD_HITS = "mas_had_hits"
|
||
|
||
private val ANALYSIS_TARGET_RESOLUTION = Size(1920, 1080)
|
||
private const val CAMERA_PERM_REQ = 53012
|
||
private const val ALBUM_PERM_REQ = 53014
|
||
private const val PICK_IMAGE_REQ = 53013
|
||
private const val ERR_PERMISSION = 9021002
|
||
private const val ERR_NO_RESULT = 9021004
|
||
private const val ERR_CAMERA_IN_USE = 9021005
|
||
private const val ERR_NO_FLASH = 9021006
|
||
private const val ERR_CAMERA_INIT = 9021007
|
||
private const val ERR_ALBUM_READ = 9021008
|
||
private const val ERR_ALBUM_NO_CODE = 9021009
|
||
private const val ERR_UNSUPPORTED_FORMAT = 9021010
|
||
private const val ERR_TIMEOUT = 9021011
|
||
|
||
private data class AlbumBitmapLoadResult(
|
||
val bitmap: Bitmap?
|
||
)
|
||
|
||
/**
|
||
* CameraX + ML Kit 扫码页。
|
||
* 成功:RESULT_OK + mas_content / mas_format
|
||
* 权限拒绝:RESULT_CANCELED + mas_err=9021002
|
||
* 用户取消:RESULT_CANCELED(无 mas_err 或其它)
|
||
* 连续扫:页内不关,命中走 CodeScanHitBus;关闭时结束
|
||
*/
|
||
class CodeCaptureActivity : ComponentActivity() {
|
||
|
||
private val workerPool = Executors.newSingleThreadExecutor()
|
||
private var providerRef: ProcessCameraProvider? = null
|
||
private var deviceCam: Camera? = null
|
||
private var flashBtn: ImageButton? = null
|
||
private var albumBtn: ImageButton? = null
|
||
private var multiPickLayer: MultiCodePickView? = null
|
||
private var frameDecorLayer: ScanFrameDecorView? = null
|
||
private var barcodeScanner: BarcodeScanner? = null
|
||
|
||
@Volatile
|
||
private var captureDone = false
|
||
@Volatile
|
||
private var waitingUserPick = false
|
||
private var flashAvailable = false
|
||
private var torchOn = false
|
||
private var torchMode = "off"
|
||
private var autoTorchLowLightFrames = 0
|
||
private var didShowLowLightHint = false
|
||
private var resultMode = "immediate"
|
||
private var showPauseButton = false
|
||
private var scanPaused = false
|
||
private var pendingResultDialog: Dialog? = null
|
||
private var pageTitle = "扫码"
|
||
private var scanHint = "请将条码放入扫描框内"
|
||
private var successHint = "扫码成功"
|
||
private var emptyHint = "未识别到有效条码"
|
||
private var smartZoomOn = true
|
||
private var vibrateOn = true
|
||
private var continuousOn = false
|
||
private var openAlbumFirst = false
|
||
private var formatsCsv: String = ""
|
||
private var albumNoCodePending = false
|
||
private var cameraAccessGranted = false
|
||
private var cameraAccessResolved = false
|
||
private var scanTimeoutMs = 0L
|
||
private var continuousSuccessDurationMs = 1000L
|
||
private var scanTimeoutRunnable: Runnable? = null
|
||
private lateinit var scanRegion: ScanRegionConfig
|
||
private var hitCount = 0
|
||
private var lastPickFromAlbum = false
|
||
private var continuousSuccessOverlay: ContinuousSuccessOverlayView? = null
|
||
private var continuousSuccessRunnable: Runnable? = null
|
||
|
||
private lateinit var livePreview: PreviewView
|
||
private lateinit var rootLayout: FrameLayout
|
||
|
||
private var zoomCeiling = 1.0f
|
||
private var zoomNow = 1.0f
|
||
private var zoomAim = 1.0f
|
||
private var didSmartZoom = false
|
||
private var needFlatMultiFrame = false
|
||
private var flatMultiStableHits = 0
|
||
private var pinchActive = false
|
||
private val uiHandler = Handler(Looper.getMainLooper())
|
||
private var pinchDetector: ScaleGestureDetector? = null
|
||
private val idealFillRatio = 0.45f
|
||
private val zoomTriggerFill = 0.30f
|
||
/** 连续扫冷却复位,勿与变焦回调互相 clear */
|
||
private var resumeScanRunnable: Runnable? = null
|
||
private var zoomEaseRunnable: Runnable? = null
|
||
private var pinchEndRunnable: Runnable? = null
|
||
private var lastEmittedContent: String = ""
|
||
private var lastEmitAt: Long = 0L
|
||
@Volatile
|
||
private var continuousFrameBusy = false
|
||
@Volatile
|
||
private var destroyed = false
|
||
|
||
override fun onCreate(savedInstanceState: Bundle?) {
|
||
super.onCreate(savedInstanceState)
|
||
pageTitle = intent.getStringExtra(CAPTURE_EXTRA_TITLE)?.takeIf { it.isNotBlank() } ?: "扫码"
|
||
scanHint = intent.getStringExtra(CAPTURE_EXTRA_HINT)?.takeIf { it.isNotBlank() } ?: "请将条码放入扫描框内"
|
||
successHint = intent.getStringExtra(CAPTURE_EXTRA_SUCCESS_HINT)?.takeIf { it.isNotBlank() } ?: "扫码成功"
|
||
emptyHint = intent.getStringExtra(CAPTURE_EXTRA_EMPTY_HINT)?.takeIf { it.isNotBlank() } ?: "未识别到有效条码"
|
||
smartZoomOn = intent.getBooleanExtra(CAPTURE_EXTRA_SMART_ZOOM, true)
|
||
torchMode = intent.getStringExtra(CAPTURE_EXTRA_TORCH_MODE).takeIf { it == "on" || it == "auto" || it == "off" } ?: "off"
|
||
resultMode = intent.getStringExtra(CAPTURE_EXTRA_RESULT_MODE).takeIf { it == "confirm" || it == "pause" || it == "immediate" } ?: "immediate"
|
||
showPauseButton = intent.getBooleanExtra(CAPTURE_EXTRA_SHOW_PAUSE_BUTTON, false)
|
||
vibrateOn = intent.getBooleanExtra(CAPTURE_EXTRA_VIBRATE, true)
|
||
continuousOn = intent.getBooleanExtra(CAPTURE_EXTRA_CONTINUOUS, false)
|
||
openAlbumFirst = intent.getBooleanExtra(CAPTURE_EXTRA_FROM_ALBUM, false)
|
||
formatsCsv = intent.getStringExtra(CAPTURE_EXTRA_FORMATS) ?: ""
|
||
scanTimeoutMs = intent.getIntExtra(CAPTURE_EXTRA_SCAN_TIMEOUT, 0).toLong().coerceIn(0L, 600000L)
|
||
continuousSuccessDurationMs = intent.getIntExtra(CAPTURE_EXTRA_CONTINUOUS_SUCCESS_DURATION, 1000).toLong().coerceIn(0L, 600000L)
|
||
scanRegion = readScanRegion()
|
||
val scanStyle = readScanStyle()
|
||
|
||
livePreview = PreviewView(this).apply {
|
||
scaleType = PreviewView.ScaleType.FILL_CENTER
|
||
}
|
||
|
||
val overlayView = MultiCodePickView(this).apply {
|
||
visibility = View.GONE
|
||
setOnTouchListener { _, event ->
|
||
if (event.action == MotionEvent.ACTION_DOWN) {
|
||
val hit = pickHitAt(event.x, event.y)
|
||
if (hit != null) {
|
||
acceptHit(hit, fromAlbum = lastPickFromAlbum)
|
||
}
|
||
true
|
||
} else {
|
||
true
|
||
}
|
||
}
|
||
}
|
||
multiPickLayer = overlayView
|
||
|
||
val successOverlay = ContinuousSuccessOverlayView(this, successHint)
|
||
successOverlay.visibility = View.GONE
|
||
continuousSuccessOverlay = successOverlay
|
||
|
||
val decorationView = ScanFrameDecorView(this, scanRegion, scanStyle, scanHint)
|
||
frameDecorLayer = decorationView
|
||
|
||
pinchDetector = ScaleGestureDetector(this, object : ScaleGestureDetector.SimpleOnScaleGestureListener() {
|
||
override fun onScaleBegin(detector: ScaleGestureDetector): Boolean {
|
||
if (waitingUserPick) return false
|
||
pinchActive = true
|
||
return true
|
||
}
|
||
|
||
override fun onScale(detector: ScaleGestureDetector): Boolean {
|
||
val scaleFactor = detector.scaleFactor.coerceIn(0.8f, 1.25f)
|
||
val newZoom = (zoomNow * scaleFactor).coerceIn(1.0f, zoomCeiling)
|
||
zoomAim = newZoom
|
||
zoomNow = newZoom
|
||
deviceCam?.cameraControl?.setZoomRatio(newZoom)
|
||
return true
|
||
}
|
||
|
||
override fun onScaleEnd(detector: ScaleGestureDetector) {
|
||
pinchEndRunnable?.let { uiHandler.removeCallbacks(it) }
|
||
val end = Runnable { pinchActive = false }
|
||
pinchEndRunnable = end
|
||
uiHandler.postDelayed(end, 250)
|
||
}
|
||
})
|
||
|
||
livePreview.setOnTouchListener { _, event ->
|
||
if (waitingUserPick || scanPaused) return@setOnTouchListener true
|
||
pinchDetector?.onTouchEvent(event)
|
||
true
|
||
}
|
||
|
||
rootLayout = FrameLayout(this).apply {
|
||
layoutParams = FrameLayout.LayoutParams(
|
||
FrameLayout.LayoutParams.MATCH_PARENT,
|
||
FrameLayout.LayoutParams.MATCH_PARENT
|
||
)
|
||
addView(
|
||
livePreview, FrameLayout.LayoutParams(
|
||
FrameLayout.LayoutParams.MATCH_PARENT,
|
||
FrameLayout.LayoutParams.MATCH_PARENT
|
||
)
|
||
)
|
||
addView(
|
||
successOverlay, FrameLayout.LayoutParams(
|
||
FrameLayout.LayoutParams.MATCH_PARENT,
|
||
FrameLayout.LayoutParams.MATCH_PARENT
|
||
)
|
||
)
|
||
addView(
|
||
decorationView, FrameLayout.LayoutParams(
|
||
FrameLayout.LayoutParams.MATCH_PARENT,
|
||
FrameLayout.LayoutParams.MATCH_PARENT
|
||
)
|
||
)
|
||
addView(
|
||
overlayView, FrameLayout.LayoutParams(
|
||
FrameLayout.LayoutParams.MATCH_PARENT,
|
||
FrameLayout.LayoutParams.MATCH_PARENT
|
||
)
|
||
)
|
||
}
|
||
|
||
addToolbarButtons(rootLayout)
|
||
setContentView(rootLayout)
|
||
|
||
if (!SymbologyUtil.hasSupportedFormat(formatsCsv)) {
|
||
finishWithError(ERR_UNSUPPORTED_FORMAT)
|
||
return
|
||
}
|
||
barcodeScanner = buildScanner()
|
||
if (openAlbumFirst) {
|
||
openAlbumPicker()
|
||
}
|
||
guardCameraAccess()
|
||
scheduleScanTimeout()
|
||
}
|
||
|
||
private fun buildScanner(): BarcodeScanner {
|
||
val formats = SymbologyUtil.parseFormatList(formatsCsv)
|
||
val builder = BarcodeScannerOptions.Builder()
|
||
if (formats.size == 1) {
|
||
builder.setBarcodeFormats(formats[0])
|
||
} else {
|
||
builder.setBarcodeFormats(formats[0], *formats.copyOfRange(1, formats.size))
|
||
}
|
||
builder.enableAllPotentialBarcodes()
|
||
return BarcodeScanning.getClient(builder.build())
|
||
}
|
||
|
||
private fun addToolbarButtons(root: FrameLayout) {
|
||
val btnSize = dip(48)
|
||
val margin = dip(20)
|
||
val bg = GradientDrawable().apply {
|
||
shape = GradientDrawable.OVAL
|
||
setColor(Color.parseColor("#66000000"))
|
||
}
|
||
|
||
val closeBtn = ImageButton(this).apply {
|
||
layoutParams = FrameLayout.LayoutParams(btnSize, btnSize).apply {
|
||
gravity = Gravity.START or Gravity.TOP
|
||
setMargins(margin, margin + dip(12), margin, margin)
|
||
}
|
||
background = bg.constantState?.newDrawable()?.mutate() ?: bg
|
||
scaleType = ImageView.ScaleType.CENTER_INSIDE
|
||
setPadding(dip(12), dip(12), dip(12), dip(12))
|
||
contentDescription = "关闭"
|
||
setImageDrawable(loadDrawable("close") ?: makeCloseIcon())
|
||
setOnClickListener { finishAsCancel() }
|
||
}
|
||
root.addView(closeBtn)
|
||
|
||
val titleView = TextView(this).apply {
|
||
layoutParams = FrameLayout.LayoutParams(
|
||
FrameLayout.LayoutParams.WRAP_CONTENT,
|
||
FrameLayout.LayoutParams.WRAP_CONTENT
|
||
).apply {
|
||
gravity = Gravity.CENTER_HORIZONTAL or Gravity.TOP
|
||
topMargin = margin + dip(24)
|
||
}
|
||
text = pageTitle
|
||
setTextColor(Color.WHITE)
|
||
textSize = 18f
|
||
includeFontPadding = false
|
||
setTypeface(typeface, android.graphics.Typeface.BOLD)
|
||
setShadowLayer(dip(2).toFloat(), 0f, dip(1).toFloat(), Color.argb(180, 0, 0, 0))
|
||
}
|
||
root.addView(titleView)
|
||
|
||
val photoBtn = ImageButton(this).apply {
|
||
layoutParams = FrameLayout.LayoutParams(btnSize, btnSize).apply {
|
||
gravity = Gravity.START or Gravity.BOTTOM
|
||
setMargins(margin, margin, margin, margin)
|
||
}
|
||
background = bg.constantState?.newDrawable()?.mutate() ?: GradientDrawable().apply {
|
||
shape = GradientDrawable.OVAL
|
||
setColor(Color.parseColor("#66000000"))
|
||
}
|
||
scaleType = ImageView.ScaleType.CENTER_INSIDE
|
||
setPadding(dip(12), dip(12), dip(12), dip(12))
|
||
contentDescription = "相册"
|
||
setImageDrawable(loadDrawable("photo") ?: makeAlbumIcon())
|
||
setOnClickListener { openAlbumPicker() }
|
||
}
|
||
albumBtn = photoBtn
|
||
root.addView(photoBtn)
|
||
|
||
val flashButton = ImageButton(this).apply {
|
||
layoutParams = FrameLayout.LayoutParams(dip(56), dip(56)).apply {
|
||
gravity = Gravity.END or Gravity.BOTTOM
|
||
setMargins(margin, margin, margin, margin)
|
||
}
|
||
background = GradientDrawable().apply {
|
||
shape = GradientDrawable.OVAL
|
||
setColor(Color.parseColor("#66000000"))
|
||
}
|
||
scaleType = ImageView.ScaleType.CENTER_INSIDE
|
||
setPadding(dip(12), dip(12), dip(12), dip(12))
|
||
isEnabled = false
|
||
alpha = 0.5f
|
||
setOnClickListener { flipTorch() }
|
||
}
|
||
flashBtn = flashButton
|
||
refreshFlashBtn()
|
||
root.addView(flashButton)
|
||
|
||
if (continuousOn && showPauseButton) {
|
||
val pauseBtn = ImageButton(this).apply {
|
||
layoutParams = FrameLayout.LayoutParams(btnSize, btnSize).apply {
|
||
gravity = Gravity.END or Gravity.TOP
|
||
setMargins(margin, margin + dip(12), margin, margin)
|
||
}
|
||
background = bg.constantState?.newDrawable()?.mutate() ?: bg
|
||
scaleType = ImageView.ScaleType.CENTER_INSIDE
|
||
setPadding(dip(12), dip(12), dip(12), dip(12))
|
||
contentDescription = "暂停扫描"
|
||
setImageDrawable(makePauseIcon(false))
|
||
setOnClickListener {
|
||
scanPaused = !scanPaused
|
||
setImageDrawable(makePauseIcon(scanPaused))
|
||
contentDescription = if (scanPaused) "继续扫描" else "暂停扫描"
|
||
Toast.makeText(this@CodeCaptureActivity, if (scanPaused) "已暂停扫描" else "已继续扫描", Toast.LENGTH_SHORT).show()
|
||
}
|
||
}
|
||
root.addView(pauseBtn)
|
||
}
|
||
}
|
||
|
||
private fun loadDrawable(name: String): Drawable? {
|
||
return try {
|
||
// 宿主包名优先(资源合并后常见),再回退插件包名
|
||
val hostPkg = packageName
|
||
val pluginPkg = "uts.sdk.modules.masCodeScan"
|
||
val idHost = resources.getIdentifier(name, "drawable", hostPkg)
|
||
if (idHost != 0) return ContextCompat.getDrawable(this, idHost)
|
||
val idPlugin = resources.getIdentifier(name, "drawable", pluginPkg)
|
||
if (idPlugin != 0) return ContextCompat.getDrawable(this, idPlugin)
|
||
null
|
||
} catch (_: Exception) {
|
||
null
|
||
}
|
||
}
|
||
|
||
private fun openAlbumPicker() {
|
||
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.TIRAMISU &&
|
||
ContextCompat.checkSelfPermission(this, Manifest.permission.READ_EXTERNAL_STORAGE) != PackageManager.PERMISSION_GRANTED
|
||
) {
|
||
ActivityCompat.requestPermissions(this, arrayOf(Manifest.permission.READ_EXTERNAL_STORAGE), ALBUM_PERM_REQ)
|
||
return
|
||
}
|
||
|
||
// Android 13+ 使用系统 Photo Picker,只展示相册图片,不会跳转到文件管理器。
|
||
val intent = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
|
||
Intent("android.provider.action.PICK_IMAGES")
|
||
} else {
|
||
// Android 12 及以下使用 MediaStore 图片集合,限定为系统相册入口。
|
||
Intent(Intent.ACTION_PICK, MediaStore.Images.Media.EXTERNAL_CONTENT_URI)
|
||
}
|
||
intent.type = "image/*"
|
||
intent.addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION)
|
||
try {
|
||
startActivityForResult(intent, PICK_IMAGE_REQ)
|
||
} catch (_: Exception) {
|
||
Toast.makeText(this, "无法打开相册", Toast.LENGTH_SHORT).show()
|
||
if (openAlbumFirst) {
|
||
finishWithError(ERR_ALBUM_READ)
|
||
}
|
||
}
|
||
}
|
||
|
||
override fun onActivityResult(requestCode: Int, resultCode: Int, data: Intent?) {
|
||
super.onActivityResult(requestCode, resultCode, data)
|
||
if (requestCode != PICK_IMAGE_REQ) return
|
||
if (resultCode != RESULT_OK || data?.data == null) {
|
||
if (openAlbumFirst && hitCount == 0 && !hasCameraBound()) {
|
||
// 打开即相册且用户取消,允许继续用相机(若已授权)
|
||
}
|
||
return
|
||
}
|
||
decodeAlbumUri(data.data ?: return)
|
||
}
|
||
|
||
private fun hasCameraBound(): Boolean = deviceCam != null
|
||
|
||
private fun decodeAlbumUri(uri: Uri) {
|
||
if (workerPool.isShutdown || destroyed) return
|
||
frameDecorLayer?.visibility = View.GONE
|
||
albumBtn?.visibility = View.GONE
|
||
flashBtn?.visibility = View.GONE
|
||
try {
|
||
workerPool.execute {
|
||
try {
|
||
if (destroyed) return@execute
|
||
val loadResult = loadBitmapFromUri(uri)
|
||
val bitmap = loadResult.bitmap ?: run {
|
||
uiHandler.post {
|
||
if (!destroyed && !isFinishing) {
|
||
clearAlbumPreview()
|
||
Toast.makeText(this, "无法读取照片,请重新选择相册中的图片", Toast.LENGTH_SHORT).show()
|
||
finishWithError(ERR_ALBUM_READ)
|
||
}
|
||
}
|
||
return@execute
|
||
}
|
||
uiHandler.post {
|
||
if (destroyed || isFinishing) {
|
||
if (!bitmap.isRecycled) bitmap.recycle()
|
||
return@post
|
||
}
|
||
// 先绘制相册图片与加载态,再开始识别,让用户清楚感知处理过程。
|
||
multiPickLayer?.showRecognizing(bitmap)
|
||
uiHandler.postDelayed({
|
||
if (destroyed || isFinishing) return@postDelayed
|
||
val scanner = barcodeScanner ?: run {
|
||
clearAlbumPreview()
|
||
return@postDelayed
|
||
}
|
||
val image = InputImage.fromBitmap(bitmap, 0)
|
||
scanner.process(image)
|
||
.addOnSuccessListener { barcodes ->
|
||
handleAlbumBarcodes(bitmap, barcodes)
|
||
}
|
||
.addOnFailureListener {
|
||
uiHandler.post {
|
||
if (!destroyed && !isFinishing) {
|
||
clearAlbumPreview()
|
||
Toast.makeText(this, "识别失败", Toast.LENGTH_SHORT).show()
|
||
finishWithError(ERR_ALBUM_READ)
|
||
}
|
||
}
|
||
}
|
||
}, 120)
|
||
}
|
||
} catch (_: Exception) {
|
||
uiHandler.post {
|
||
if (!destroyed && !isFinishing) {
|
||
clearAlbumPreview()
|
||
Toast.makeText(this, "识别失败", Toast.LENGTH_SHORT).show()
|
||
finishWithError(ERR_ALBUM_READ)
|
||
}
|
||
}
|
||
}
|
||
}
|
||
} catch (_: Exception) {
|
||
clearAlbumPreview()
|
||
Toast.makeText(this, "识别失败", Toast.LENGTH_SHORT).show()
|
||
}
|
||
}
|
||
|
||
private fun loadBitmapFromUri(uri: Uri): AlbumBitmapLoadResult {
|
||
// 先复制到应用私有缓存,再从本地文件解码,兼容系统相册、厂商相册及云媒体 URI。
|
||
return try {
|
||
val cacheFile = File.createTempFile("mas_scan_", ".image", cacheDir)
|
||
try {
|
||
val copiedBytes = contentResolver.openInputStream(uri)?.use { input ->
|
||
cacheFile.outputStream().use { output -> input.copyTo(output) }
|
||
} ?: return AlbumBitmapLoadResult(null)
|
||
if (copiedBytes <= 0L || cacheFile.length() <= 0L) {
|
||
return AlbumBitmapLoadResult(null)
|
||
}
|
||
decodeBitmapFromFile(cacheFile)
|
||
} finally {
|
||
cacheFile.delete()
|
||
}
|
||
} catch (_: Exception) {
|
||
AlbumBitmapLoadResult(null)
|
||
}
|
||
}
|
||
|
||
private fun decodeBitmapFromFile(file: File): AlbumBitmapLoadResult {
|
||
return try {
|
||
val bounds = BitmapFactory.Options().apply { inJustDecodeBounds = true }
|
||
BitmapFactory.decodeFile(file.absolutePath, bounds)
|
||
if (bounds.outWidth <= 0 || bounds.outHeight <= 0) {
|
||
return AlbumBitmapLoadResult(null)
|
||
}
|
||
|
||
var sample = 1
|
||
val maxSide = 1920
|
||
while (bounds.outWidth / sample > maxSide || bounds.outHeight / sample > maxSide) {
|
||
sample *= 2
|
||
}
|
||
val opts = BitmapFactory.Options().apply {
|
||
inSampleSize = sample
|
||
inPreferredConfig = Bitmap.Config.ARGB_8888
|
||
}
|
||
val bitmap = BitmapFactory.decodeFile(file.absolutePath, opts)
|
||
AlbumBitmapLoadResult(bitmap)
|
||
} catch (_: Exception) {
|
||
AlbumBitmapLoadResult(null)
|
||
}
|
||
}
|
||
|
||
private fun handleAlbumBarcodes(bitmap: Bitmap, barcodes: List<Barcode>) {
|
||
if (destroyed || isFinishing) {
|
||
if (!bitmap.isRecycled) bitmap.recycle()
|
||
return
|
||
}
|
||
if (captureDone && !continuousOn) {
|
||
if (!bitmap.isRecycled) bitmap.recycle()
|
||
return
|
||
}
|
||
val decoded = barcodes.filter { SymbologyUtil.isDecodedText(it) }
|
||
val decodedBoxed = decoded.filter { it.boundingBox != null }
|
||
uiHandler.post {
|
||
if (destroyed || isFinishing) {
|
||
if (!bitmap.isRecycled) bitmap.recycle()
|
||
return@post
|
||
}
|
||
lastPickFromAlbum = true
|
||
when {
|
||
decodedBoxed.size >= 2 -> {
|
||
waitingUserPick = true
|
||
multiPickLayer?.presentStillFrame(bitmap, decodedBoxed, Rect(0, 0, bitmap.width, bitmap.height))
|
||
}
|
||
decoded.isNotEmpty() -> {
|
||
clearAlbumPreview()
|
||
captureDone = true
|
||
val one = decoded.first()
|
||
deliverResult(
|
||
content = one.rawValue ?: "",
|
||
format = SymbologyUtil.nameOf(one.format),
|
||
fromAlbum = true
|
||
)
|
||
}
|
||
else -> {
|
||
clearAlbumPreview()
|
||
if (!bitmap.isRecycled) bitmap.recycle()
|
||
Toast.makeText(this, emptyHint, Toast.LENGTH_SHORT).show()
|
||
if (openAlbumFirst && !cameraAccessGranted) {
|
||
albumNoCodePending = true
|
||
if (cameraAccessResolved) {
|
||
finishWithError(ERR_ALBUM_NO_CODE)
|
||
}
|
||
}
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
override fun onRequestPermissionsResult(
|
||
requestCode: Int,
|
||
permissions: Array<out String>,
|
||
grantResults: IntArray
|
||
) {
|
||
super.onRequestPermissionsResult(requestCode, permissions, grantResults)
|
||
if (requestCode == ALBUM_PERM_REQ) {
|
||
val granted = grantResults.isNotEmpty() && grantResults[0] == PackageManager.PERMISSION_GRANTED
|
||
if (granted) {
|
||
openAlbumPicker()
|
||
} else {
|
||
Toast.makeText(this, "未获得相册权限,无法选择图片", Toast.LENGTH_SHORT).show()
|
||
}
|
||
return
|
||
}
|
||
if (requestCode != CAMERA_PERM_REQ) return
|
||
val granted = grantResults.isNotEmpty() && grantResults[0] == PackageManager.PERMISSION_GRANTED
|
||
cameraAccessResolved = true
|
||
if (granted) {
|
||
cameraAccessGranted = true
|
||
albumNoCodePending = false
|
||
bindCameraPipeline(livePreview)
|
||
} else if (openAlbumFirst) {
|
||
if (albumNoCodePending) {
|
||
finishWithError(ERR_ALBUM_NO_CODE)
|
||
} else {
|
||
Toast.makeText(this, "无相机权限,可使用相册识图", Toast.LENGTH_SHORT).show()
|
||
}
|
||
} else {
|
||
finishWithError(ERR_PERMISSION)
|
||
}
|
||
}
|
||
|
||
private fun guardCameraAccess() {
|
||
val granted = ContextCompat.checkSelfPermission(this, Manifest.permission.CAMERA) ==
|
||
PackageManager.PERMISSION_GRANTED
|
||
if (granted) {
|
||
cameraAccessGranted = true
|
||
cameraAccessResolved = true
|
||
bindCameraPipeline(livePreview)
|
||
return
|
||
}
|
||
ActivityCompat.requestPermissions(this, arrayOf(Manifest.permission.CAMERA), CAMERA_PERM_REQ)
|
||
}
|
||
|
||
private fun bindCameraPipeline(previewView: PreviewView) {
|
||
if (destroyed || isFinishing) return
|
||
val future = ProcessCameraProvider.getInstance(this)
|
||
future.addListener({
|
||
if (destroyed || isFinishing) return@addListener
|
||
try {
|
||
val provider = future.get()
|
||
providerRef = provider
|
||
|
||
val previewBuilder = Preview.Builder()
|
||
Camera2Interop.Extender(previewBuilder).setCaptureRequestOption(
|
||
CaptureRequest.CONTROL_AF_MODE,
|
||
CaptureRequest.CONTROL_AF_MODE_CONTINUOUS_PICTURE
|
||
)
|
||
val preview = previewBuilder.build().also {
|
||
it.setSurfaceProvider(previewView.surfaceProvider)
|
||
}
|
||
|
||
val analysisBuilder = ImageAnalysis.Builder()
|
||
.setBackpressureStrategy(ImageAnalysis.STRATEGY_KEEP_ONLY_LATEST)
|
||
.setTargetResolution(ANALYSIS_TARGET_RESOLUTION)
|
||
Camera2Interop.Extender(analysisBuilder).setCaptureRequestOption(
|
||
CaptureRequest.CONTROL_AF_MODE,
|
||
CaptureRequest.CONTROL_AF_MODE_CONTINUOUS_PICTURE
|
||
)
|
||
val imageAnalysis = analysisBuilder.build()
|
||
imageAnalysis.setAnalyzer(workerPool, createAnalyzer())
|
||
|
||
provider.unbindAll()
|
||
val group = UseCaseGroup.Builder()
|
||
.addUseCase(preview)
|
||
.addUseCase(imageAnalysis)
|
||
previewView.viewPort?.let { group.setViewPort(it) }
|
||
deviceCam = provider.bindToLifecycle(
|
||
this,
|
||
CameraSelector.DEFAULT_BACK_CAMERA,
|
||
group.build()
|
||
)
|
||
zoomCeiling = deviceCam?.cameraInfo?.zoomState?.value?.maxZoomRatio ?: 1.0f
|
||
zoomNow = 1.0f
|
||
zoomAim = 1.0f
|
||
didSmartZoom = false
|
||
flashAvailable = deviceCam?.cameraInfo?.hasFlashUnit() == true &&
|
||
packageManager.hasSystemFeature(PackageManager.FEATURE_CAMERA_FLASH)
|
||
torchOn = false
|
||
applyInitialTorchMode()
|
||
refreshFlashBtn()
|
||
} catch (error: Exception) {
|
||
if (!destroyed && !isFinishing) {
|
||
finishWithError(if (isCameraUnavailable(error)) ERR_CAMERA_IN_USE else ERR_CAMERA_INIT)
|
||
}
|
||
}
|
||
}, ContextCompat.getMainExecutor(this))
|
||
}
|
||
|
||
private fun readScanRegion(): ScanRegionConfig {
|
||
val values = intent.getStringExtra(CAPTURE_EXTRA_SCAN_REGION)
|
||
?.split(',')
|
||
?.mapNotNull { it.toFloatOrNull() }
|
||
?: emptyList()
|
||
return ScanRegionConfig.safe(
|
||
enabled = intent.getBooleanExtra(CAPTURE_EXTRA_SCAN_REGION_ENABLED, false),
|
||
widthRatio = values.getOrElse(0) { ScanRegionConfig.DEFAULT_SIZE_RATIO },
|
||
heightRatio = values.getOrElse(1) { ScanRegionConfig.DEFAULT_SIZE_RATIO },
|
||
centerXRatio = values.getOrElse(2) { ScanRegionConfig.DEFAULT_CENTER_RATIO },
|
||
centerYRatio = values.getOrElse(3) { ScanRegionConfig.DEFAULT_CENTER_RATIO }
|
||
)
|
||
}
|
||
|
||
private fun readScanStyle(): ScanStyleConfig {
|
||
return ScanStyleConfig.safe(
|
||
showMask = intent.getBooleanExtra(CAPTURE_EXTRA_SCAN_STYLE_SHOW_MASK, true),
|
||
showBeam = intent.getBooleanExtra(CAPTURE_EXTRA_SCAN_STYLE_SHOW_BEAM, true),
|
||
borderColor = intent.getStringExtra(CAPTURE_EXTRA_SCAN_STYLE_BORDER_COLOR) ?: "#19be6b",
|
||
maskColor = intent.getStringExtra(CAPTURE_EXTRA_SCAN_STYLE_MASK_COLOR) ?: "rgba(0, 0, 0, 0.45)",
|
||
beamColor = intent.getStringExtra(CAPTURE_EXTRA_SCAN_STYLE_BEAM_COLOR) ?: "#12e880",
|
||
beamDuration = intent.getIntExtra(CAPTURE_EXTRA_SCAN_STYLE_BEAM_DURATION, 1800),
|
||
cornerStyle = intent.getStringExtra(CAPTURE_EXTRA_SCAN_STYLE_CORNER) ?: "corner",
|
||
cornerRadius = intent.getIntExtra(CAPTURE_EXTRA_SCAN_STYLE_CORNER_RADIUS, 20)
|
||
)
|
||
}
|
||
|
||
/**
|
||
* scanRegion 仅用于实时相机预览。未传配置时完整保留旧版整帧识别行为,
|
||
* 已传配置时只接受条码中心点位于用户可见扫描框内的结果。
|
||
*/
|
||
private fun filterBarcodesInScanRegion(
|
||
barcodes: List<Barcode>,
|
||
imageWidth: Int,
|
||
imageHeight: Int,
|
||
cropRect: Rect,
|
||
rotationDegrees: Int
|
||
): List<Barcode> {
|
||
if (!scanRegion.enabled) return barcodes
|
||
val frameRect = scanRegion.resolveViewRect(livePreview.width, livePreview.height)
|
||
val imageRegion = ScanRegionMapper.mapViewRectToImage(
|
||
viewRect = frameRect,
|
||
viewWidth = livePreview.width,
|
||
viewHeight = livePreview.height,
|
||
imageWidth = imageWidth,
|
||
imageHeight = imageHeight,
|
||
cropRect = cropRect,
|
||
rotationDegrees = rotationDegrees
|
||
) ?: return emptyList()
|
||
return barcodes.filter { barcode ->
|
||
val box = barcode.boundingBox ?: return@filter false
|
||
ScanRegionMapper.containsBarcodeCenter(imageRegion, box)
|
||
}
|
||
}
|
||
|
||
private fun createAnalyzer(): ImageAnalysis.Analyzer {
|
||
return object : ImageAnalysis.Analyzer {
|
||
private var zoomAttempts = 0
|
||
private val zoomAttemptCap = 6
|
||
|
||
override fun analyze(imageProxy: ImageProxy) {
|
||
if ((!continuousOn && captureDone) || waitingUserPick || scanPaused || (continuousOn && continuousFrameBusy)) {
|
||
imageProxy.close()
|
||
return
|
||
}
|
||
if (continuousOn) continuousFrameBusy = true
|
||
val mediaImage = imageProxy.image
|
||
if (mediaImage == null) {
|
||
if (continuousOn) continuousFrameBusy = false
|
||
imageProxy.close()
|
||
return
|
||
}
|
||
val rotationDegrees = imageProxy.imageInfo.rotationDegrees
|
||
updateAutoTorchForFrame(imageProxy)
|
||
val inputImage = InputImage.fromMediaImage(mediaImage, rotationDegrees)
|
||
val scanner = barcodeScanner
|
||
if (scanner == null) {
|
||
if (continuousOn) continuousFrameBusy = false
|
||
imageProxy.close()
|
||
return
|
||
}
|
||
scanner.process(inputImage)
|
||
.addOnSuccessListener { barcodes ->
|
||
if ((!continuousOn && captureDone) || waitingUserPick) return@addOnSuccessListener
|
||
if (barcodes.isEmpty()) {
|
||
needFlatMultiFrame = false
|
||
flatMultiStableHits = 0
|
||
return@addOnSuccessListener
|
||
}
|
||
|
||
val regionBarcodes = filterBarcodesInScanRegion(
|
||
barcodes,
|
||
imageProxy.width,
|
||
imageProxy.height,
|
||
imageProxy.cropRect,
|
||
rotationDegrees
|
||
)
|
||
if (regionBarcodes.isEmpty()) {
|
||
needFlatMultiFrame = false
|
||
flatMultiStableHits = 0
|
||
return@addOnSuccessListener
|
||
}
|
||
// 同一条物理条码可能被 ML Kit 返回多个重复结果;先按内容去重,避免单码误进多码点选。
|
||
val decodedAll = regionBarcodes
|
||
.filter { SymbologyUtil.isDecodedText(it) }
|
||
.groupBy { it.rawValue?.trim() ?: "" }
|
||
.values
|
||
.mapNotNull { group ->
|
||
group.firstOrNull { it.boundingBox != null } ?: group.firstOrNull()
|
||
}
|
||
val decodedBoxed = decodedAll.filter { it.boundingBox != null }
|
||
val decodedBare = decodedAll.filter { it.boundingBox == null }
|
||
val pendingBoxed = regionBarcodes.filter {
|
||
!SymbologyUtil.isDecodedText(it) && it.boundingBox != null
|
||
}
|
||
|
||
// 连续扫码单码立即上报;多码仍进入点选层,避免把多码结果静默取第一条。
|
||
if (continuousOn && decodedAll.size == 1) {
|
||
val successFrame = YuvFrames.imageToBitmap(
|
||
mediaImage,
|
||
imageProxy.width,
|
||
imageProxy.height,
|
||
imageProxy.cropRect,
|
||
rotationDegrees
|
||
)
|
||
completeWithCode(decodedAll.first(), successFrame)
|
||
return@addOnSuccessListener
|
||
}
|
||
|
||
// ML Kit 可能同时返回一条已解码条码和若干无内容候选框;两条及以上可定位结果进入多码点选。
|
||
if (decodedBoxed.size >= 2) {
|
||
zoomAttempts = 0
|
||
val actualZoom = readLiveZoom()
|
||
if (!continuousOn && (actualZoom > 1.02f || zoomAim > 1.02f)) {
|
||
needFlatMultiFrame = true
|
||
flatMultiStableHits = 0
|
||
zoomToOne()
|
||
return@addOnSuccessListener
|
||
}
|
||
if (!continuousOn && needFlatMultiFrame) {
|
||
flatMultiStableHits++
|
||
if (flatMultiStableHits < 2) return@addOnSuccessListener
|
||
needFlatMultiFrame = false
|
||
flatMultiStableHits = 0
|
||
}
|
||
if (decodedAll.size >= 2) {
|
||
lastPickFromAlbum = false
|
||
val snap = YuvFrames.imageToBitmap(
|
||
mediaImage,
|
||
imageProxy.width,
|
||
imageProxy.height,
|
||
imageProxy.cropRect,
|
||
rotationDegrees
|
||
)
|
||
val cropVisual = YuvFrames.rectToVisualSpace(
|
||
imageProxy.cropRect,
|
||
imageProxy.width,
|
||
imageProxy.height,
|
||
rotationDegrees
|
||
)
|
||
if (snap == null) {
|
||
// 定格失败则不进入点选锁,避免卡死
|
||
return@addOnSuccessListener
|
||
}
|
||
waitingUserPick = true
|
||
uiHandler.post {
|
||
if (destroyed || isFinishing) {
|
||
if (!snap.isRecycled) snap.recycle()
|
||
waitingUserPick = false
|
||
return@post
|
||
}
|
||
if (captureDone && !continuousOn) {
|
||
if (!snap.isRecycled) snap.recycle()
|
||
waitingUserPick = false
|
||
return@post
|
||
}
|
||
multiPickLayer?.presentStillFrame(snap, decodedBoxed, cropVisual)
|
||
}
|
||
}
|
||
return@addOnSuccessListener
|
||
}
|
||
|
||
needFlatMultiFrame = false
|
||
flatMultiStableHits = 0
|
||
val zoomSubject = decodedBoxed.firstOrNull() ?: pendingBoxed.firstOrNull()
|
||
if (zoomSubject?.boundingBox != null) {
|
||
val box = zoomSubject.boundingBox!!
|
||
val fillRatio = maxOf(
|
||
box.width().toFloat() / mediaImage.width,
|
||
box.height().toFloat() / mediaImage.height
|
||
)
|
||
if (!pinchActive && smartZoomOn && fillRatio < zoomTriggerFill && zoomAttempts < zoomAttemptCap) {
|
||
zoomAttempts++
|
||
val desiredZoom = (idealFillRatio / fillRatio).coerceAtMost(zoomCeiling)
|
||
zoomAim = (zoomNow * 0.6f + desiredZoom * 0.4f).coerceIn(1.0f, zoomCeiling)
|
||
didSmartZoom = true
|
||
easeZoomToward(zoomAim)
|
||
} else if (SymbologyUtil.isDecodedText(zoomSubject)) {
|
||
captureDone = true
|
||
lastPickFromAlbum = false
|
||
val successFrame = if (continuousOn) {
|
||
YuvFrames.imageToBitmap(
|
||
mediaImage,
|
||
imageProxy.width,
|
||
imageProxy.height,
|
||
imageProxy.cropRect,
|
||
rotationDegrees
|
||
)
|
||
} else {
|
||
null
|
||
}
|
||
completeWithCode(zoomSubject, successFrame)
|
||
} else {
|
||
zoomAttempts = zoomAttemptCap
|
||
}
|
||
} else if (decodedBare.isNotEmpty() && decodedAll.size == 1) {
|
||
captureDone = true
|
||
lastPickFromAlbum = false
|
||
completeWithCode(decodedBare[0], null)
|
||
}
|
||
}
|
||
.addOnCompleteListener {
|
||
if (continuousOn) continuousFrameBusy = false
|
||
imageProxy.close()
|
||
}
|
||
}
|
||
|
||
override fun getDefaultTargetResolution(): Size? = ANALYSIS_TARGET_RESOLUTION
|
||
}
|
||
}
|
||
|
||
private fun acceptHit(mark: HitMark, fromAlbum: Boolean) {
|
||
if (captureDone && !continuousOn) return
|
||
if (mark.rawValue.isBlank()) return
|
||
captureDone = true
|
||
waitingUserPick = false
|
||
multiPickLayer?.clear()
|
||
deliverResult(mark.rawValue, mark.formatName, fromAlbum)
|
||
}
|
||
|
||
private fun completeWithCode(barcode: Barcode, successFrame: Bitmap? = null) {
|
||
needFlatMultiFrame = false
|
||
flatMultiStableHits = 0
|
||
deliverResult(
|
||
content = barcode.rawValue ?: "",
|
||
format = SymbologyUtil.nameOf(barcode.format),
|
||
fromAlbum = false,
|
||
successFrame = successFrame
|
||
)
|
||
}
|
||
|
||
private fun clearAlbumPreview() {
|
||
multiPickLayer?.clear()
|
||
frameDecorLayer?.visibility = View.VISIBLE
|
||
albumBtn?.visibility = View.VISIBLE
|
||
refreshFlashBtn()
|
||
}
|
||
|
||
private fun deliverResult(content: String, format: String, fromAlbum: Boolean, successFrame: Bitmap? = null) {
|
||
if (destroyed || isFinishing) {
|
||
successFrame?.recycle()
|
||
return
|
||
}
|
||
if (content.isBlank()) {
|
||
successFrame?.recycle()
|
||
if (!continuousOn) {
|
||
finishAsCancel()
|
||
} else {
|
||
captureDone = false
|
||
waitingUserPick = false
|
||
}
|
||
return
|
||
}
|
||
// 连续扫短时去重,避免同一码狂刷 success
|
||
if (continuousOn) {
|
||
val now = System.currentTimeMillis()
|
||
if (content == lastEmittedContent && now - lastEmitAt < 1200L) {
|
||
successFrame?.recycle()
|
||
return
|
||
}
|
||
lastEmittedContent = content
|
||
lastEmitAt = now
|
||
}
|
||
vibrateIfNeeded()
|
||
hitCount++
|
||
if (continuousOn) {
|
||
CodeScanHitBus.emitHit(content, format, fromAlbum)
|
||
showContinuousSuccess(content, successFrame)
|
||
return
|
||
}
|
||
when (resultMode) {
|
||
"confirm" -> showResultConfirmation(content, format, fromAlbum)
|
||
"pause" -> showResultPaused(content, format, fromAlbum)
|
||
else -> finishWithResult(content, format, fromAlbum)
|
||
}
|
||
}
|
||
|
||
private fun showContinuousSuccess(content: String, successFrame: Bitmap? = null) {
|
||
if (destroyed || isFinishing) {
|
||
successFrame?.recycle()
|
||
return
|
||
}
|
||
continuousSuccessRunnable?.let { uiHandler.removeCallbacks(it) }
|
||
waitingUserPick = true
|
||
multiPickLayer?.clear()
|
||
continuousSuccessOverlay?.showSuccess(
|
||
scanRegion.resolveViewRect(rootLayout.width, rootLayout.height),
|
||
content,
|
||
successFrame
|
||
)
|
||
val resume = Runnable {
|
||
if (destroyed || isFinishing) return@Runnable
|
||
continuousSuccessOverlay?.hideSuccess()
|
||
waitingUserPick = false
|
||
captureDone = false
|
||
continuousSuccessRunnable = null
|
||
}
|
||
continuousSuccessRunnable = resume
|
||
uiHandler.postDelayed(resume, continuousSuccessDurationMs)
|
||
}
|
||
|
||
private fun finishWithResult(content: String, format: String, fromAlbum: Boolean) {
|
||
scanTimeoutRunnable?.let { uiHandler.removeCallbacks(it) }
|
||
scanTimeoutRunnable = null
|
||
val resultIntent = Intent().apply {
|
||
putExtra(CAPTURE_EXTRA_CONTENT, content)
|
||
putExtra(CAPTURE_EXTRA_FORMAT, format)
|
||
putExtra(CAPTURE_EXTRA_FROM_ALBUM_RESULT, fromAlbum)
|
||
}
|
||
setResult(RESULT_OK, resultIntent)
|
||
finish()
|
||
}
|
||
|
||
private fun showResultConfirmation(content: String, format: String, fromAlbum: Boolean) {
|
||
showResultDialog("确认扫描结果", content, "重新扫描", "确认", format, fromAlbum)
|
||
}
|
||
|
||
private fun showResultPaused(content: String, format: String, fromAlbum: Boolean) {
|
||
showResultDialog("已暂停扫描", content, "继续扫描", "确认", format, fromAlbum)
|
||
}
|
||
|
||
private fun showResultDialog(
|
||
title: String,
|
||
content: String,
|
||
negativeText: String,
|
||
positiveText: String,
|
||
format: String,
|
||
fromAlbum: Boolean
|
||
) {
|
||
scanTimeoutRunnable?.let { uiHandler.removeCallbacks(it) }
|
||
scanTimeoutRunnable = null
|
||
pendingResultDialog?.dismiss()
|
||
|
||
val dialog = Dialog(this)
|
||
dialog.setCanceledOnTouchOutside(false)
|
||
|
||
val card = LinearLayout(this).apply {
|
||
orientation = LinearLayout.VERTICAL
|
||
setPadding(dip(24), dip(22), dip(24), dip(22))
|
||
background = GradientDrawable().apply {
|
||
cornerRadius = dip(22).toFloat()
|
||
setColor(Color.parseColor("#F8FAFF"))
|
||
setStroke(dip(1), Color.parseColor("#DCE7F7"))
|
||
}
|
||
}
|
||
val titleView = TextView(this).apply {
|
||
text = title
|
||
setTextColor(Color.parseColor("#17233D"))
|
||
textSize = 20f
|
||
includeFontPadding = false
|
||
setTypeface(typeface, android.graphics.Typeface.BOLD)
|
||
}
|
||
card.addView(titleView, LinearLayout.LayoutParams(-1, -2))
|
||
|
||
val accent = View(this).apply {
|
||
setBackground(GradientDrawable().apply {
|
||
cornerRadius = dip(2).toFloat()
|
||
setColor(Color.parseColor("#1677FF"))
|
||
})
|
||
}
|
||
val accentParams = LinearLayout.LayoutParams(dip(46), dip(3)).apply {
|
||
topMargin = dip(12)
|
||
bottomMargin = dip(18)
|
||
}
|
||
card.addView(accent, accentParams)
|
||
|
||
val contentView = TextView(this).apply {
|
||
text = content
|
||
setTextColor(Color.parseColor("#17233D"))
|
||
textSize = 17f
|
||
gravity = Gravity.CENTER_VERTICAL
|
||
includeFontPadding = false
|
||
setPadding(dip(16), dip(15), dip(16), dip(15))
|
||
background = GradientDrawable().apply {
|
||
cornerRadius = dip(14).toFloat()
|
||
setColor(Color.WHITE)
|
||
setStroke(dip(1), Color.parseColor("#EEF2F8"))
|
||
}
|
||
}
|
||
card.addView(contentView, LinearLayout.LayoutParams(-1, dip(54)))
|
||
|
||
val actions = LinearLayout(this).apply {
|
||
gravity = Gravity.END or Gravity.CENTER_VERTICAL
|
||
orientation = LinearLayout.HORIZONTAL
|
||
}
|
||
val negative = TextView(this).apply {
|
||
text = negativeText
|
||
gravity = Gravity.CENTER
|
||
includeFontPadding = false
|
||
setTextColor(Color.parseColor("#7C8BA3"))
|
||
textSize = 14f
|
||
background = GradientDrawable().apply {
|
||
cornerRadius = dip(14).toFloat()
|
||
setColor(Color.parseColor("#EAF0F8"))
|
||
}
|
||
setOnClickListener { dialog.dismiss(); resumeSingleScan() }
|
||
}
|
||
val positive = TextView(this).apply {
|
||
text = positiveText
|
||
gravity = Gravity.CENTER
|
||
includeFontPadding = false
|
||
setTextColor(Color.WHITE)
|
||
textSize = 14f
|
||
background = GradientDrawable().apply {
|
||
cornerRadius = dip(14).toFloat()
|
||
setColor(Color.parseColor("#1677FF"))
|
||
}
|
||
setOnClickListener { dialog.dismiss(); finishWithResult(content, format, fromAlbum) }
|
||
}
|
||
val negativeParams = LinearLayout.LayoutParams(dip(78), dip(42)).apply {
|
||
marginStart = dip(8)
|
||
}
|
||
val positiveParams = LinearLayout.LayoutParams(dip(56), dip(42)).apply {
|
||
marginStart = dip(8)
|
||
}
|
||
actions.addView(negative, negativeParams)
|
||
actions.addView(positive, positiveParams)
|
||
card.addView(actions, LinearLayout.LayoutParams(-1, dip(62)))
|
||
|
||
dialog.setContentView(card)
|
||
dialog.setOnCancelListener { resumeSingleScan() }
|
||
pendingResultDialog = dialog
|
||
dialog.show()
|
||
dialog.window?.apply {
|
||
setBackgroundDrawableResource(android.R.color.transparent)
|
||
setDimAmount(0.72f)
|
||
addFlags(android.view.WindowManager.LayoutParams.FLAG_DIM_BEHIND)
|
||
setLayout(minOf(dip(360), resources.displayMetrics.widthPixels - dip(40)), -2)
|
||
}
|
||
}
|
||
|
||
private fun resumeSingleScan() {
|
||
if (destroyed || isFinishing) return
|
||
pendingResultDialog = null
|
||
clearAlbumPreview()
|
||
captureDone = false
|
||
waitingUserPick = false
|
||
lastPickFromAlbum = false
|
||
scheduleScanTimeout()
|
||
}
|
||
|
||
private fun scheduleResumeScan() {
|
||
resumeScanRunnable?.let { uiHandler.removeCallbacks(it) }
|
||
val resume = Runnable {
|
||
if (destroyed || isFinishing) return@Runnable
|
||
lastPickFromAlbum = false
|
||
}
|
||
resumeScanRunnable = resume
|
||
uiHandler.postDelayed(resume, 600)
|
||
}
|
||
|
||
private fun vibrateIfNeeded() {
|
||
if (!vibrateOn) return
|
||
try {
|
||
if (Build.VERSION.SDK_INT >= 31) {
|
||
val mgr = getSystemService(VibratorManager::class.java)
|
||
mgr?.defaultVibrator?.vibrate(
|
||
VibrationEffect.createOneShot(40, VibrationEffect.DEFAULT_AMPLITUDE)
|
||
)
|
||
} else {
|
||
@Suppress("DEPRECATION")
|
||
val vibrator = getSystemService(VIBRATOR_SERVICE) as? Vibrator
|
||
if (Build.VERSION.SDK_INT >= 26) {
|
||
vibrator?.vibrate(VibrationEffect.createOneShot(40, VibrationEffect.DEFAULT_AMPLITUDE))
|
||
} else {
|
||
@Suppress("DEPRECATION")
|
||
vibrator?.vibrate(40)
|
||
}
|
||
}
|
||
} catch (_: Exception) {
|
||
}
|
||
}
|
||
|
||
private fun scheduleScanTimeout() {
|
||
if (scanTimeoutMs < 1000L || continuousOn) return
|
||
val timeout = Runnable {
|
||
if (!destroyed && !isFinishing && !captureDone) {
|
||
finishWithError(ERR_TIMEOUT)
|
||
}
|
||
}
|
||
scanTimeoutRunnable = timeout
|
||
uiHandler.postDelayed(timeout, scanTimeoutMs)
|
||
}
|
||
|
||
private fun isCameraUnavailable(error: Exception): Boolean {
|
||
var current: Throwable? = error
|
||
while (current != null) {
|
||
if (current is CameraUnavailableException) return true
|
||
current = current.cause
|
||
}
|
||
val message = error.message?.lowercase() ?: ""
|
||
return message.contains("in use") || message.contains("camera unavailable") || message.contains("camera is closed")
|
||
}
|
||
|
||
private fun finishWithError(code: Int) {
|
||
scanTimeoutRunnable?.let { uiHandler.removeCallbacks(it) }
|
||
scanTimeoutRunnable = null
|
||
val intent = Intent().apply { putExtra(CAPTURE_EXTRA_ERR, code) }
|
||
setResult(RESULT_CANCELED, intent)
|
||
finish()
|
||
}
|
||
|
||
private fun finishAsCancel() {
|
||
scanTimeoutRunnable?.let { uiHandler.removeCallbacks(it) }
|
||
scanTimeoutRunnable = null
|
||
clearAlbumPreview()
|
||
val intent = Intent().apply {
|
||
if (hitCount > 0) putExtra(CAPTURE_EXTRA_HAD_HITS, true)
|
||
}
|
||
setResult(RESULT_CANCELED, intent)
|
||
finish()
|
||
}
|
||
|
||
override fun onBackPressed() {
|
||
finishAsCancel()
|
||
}
|
||
|
||
private fun applyInitialTorchMode() {
|
||
if (!flashAvailable || torchMode != "on") return
|
||
setTorchEnabled(true)
|
||
}
|
||
|
||
private fun updateAutoTorchForFrame(imageProxy: ImageProxy) {
|
||
if (torchMode != "auto" || !flashAvailable || torchOn) return
|
||
val plane = imageProxy.planes.firstOrNull() ?: return
|
||
val buffer = plane.buffer
|
||
val size = buffer.remaining()
|
||
if (size <= 0) return
|
||
val sampleCount = minOf(96, size)
|
||
val stride = maxOf(1, size / sampleCount)
|
||
var total = 0L
|
||
var sampled = 0
|
||
var index = buffer.position()
|
||
val limit = buffer.limit()
|
||
while (index < limit && sampled < sampleCount) {
|
||
total += (buffer.get(index).toInt() and 0xFF)
|
||
sampled++
|
||
index += stride
|
||
}
|
||
val averageLuma = if (sampled == 0) 255 else (total / sampled).toInt()
|
||
autoTorchLowLightFrames = if (averageLuma < 45) autoTorchLowLightFrames + 1 else 0
|
||
if (autoTorchLowLightFrames >= 8) {
|
||
autoTorchLowLightFrames = 0
|
||
setTorchEnabled(true)
|
||
if (!didShowLowLightHint) {
|
||
didShowLowLightHint = true
|
||
uiHandler.post {
|
||
if (!destroyed && !isFinishing) {
|
||
Toast.makeText(this, "光线不足,已自动打开手电筒", Toast.LENGTH_SHORT).show()
|
||
}
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
private fun flipTorch() {
|
||
if (!flashAvailable) {
|
||
Toast.makeText(this, "当前设备不支持手电筒($ERR_NO_FLASH)", Toast.LENGTH_SHORT).show()
|
||
return
|
||
}
|
||
setTorchEnabled(!torchOn)
|
||
}
|
||
|
||
private fun setTorchEnabled(enabled: Boolean) {
|
||
if (!flashAvailable) return
|
||
val cam = deviceCam ?: return
|
||
cam.cameraControl.enableTorch(enabled).addListener({
|
||
torchOn = enabled
|
||
refreshFlashBtn()
|
||
}, ContextCompat.getMainExecutor(this))
|
||
}
|
||
|
||
private fun easeZoomToward(target: Float) {
|
||
val cam = deviceCam ?: return
|
||
val clamped = target.coerceIn(1.0f, zoomCeiling)
|
||
zoomAim = clamped
|
||
zoomNow += (clamped - zoomNow) * 0.3f
|
||
if (Math.abs(zoomNow - clamped) < 0.02f) {
|
||
zoomNow = clamped
|
||
}
|
||
cam.cameraControl.setZoomRatio(zoomNow)
|
||
if (zoomNow != clamped) {
|
||
zoomEaseRunnable?.let { uiHandler.removeCallbacks(it) }
|
||
val next = Runnable { easeZoomToward(clamped) }
|
||
zoomEaseRunnable = next
|
||
uiHandler.postDelayed(next, 50)
|
||
}
|
||
}
|
||
|
||
private fun readLiveZoom(): Float {
|
||
return deviceCam?.cameraInfo?.zoomState?.value?.zoomRatio ?: zoomNow
|
||
}
|
||
|
||
private fun zoomToOne() {
|
||
zoomEaseRunnable?.let { uiHandler.removeCallbacks(it) }
|
||
zoomEaseRunnable = null
|
||
zoomNow = 1.0f
|
||
zoomAim = 1.0f
|
||
didSmartZoom = false
|
||
pinchActive = false
|
||
deviceCam?.cameraControl?.setZoomRatio(1.0f)
|
||
}
|
||
|
||
private fun refreshFlashBtn() {
|
||
val button = flashBtn ?: return
|
||
button.visibility = View.VISIBLE
|
||
button.isEnabled = flashAvailable
|
||
button.alpha = if (flashAvailable) 1.0f else 0.4f
|
||
button.setImageDrawable(makeTorchIcon(torchOn))
|
||
button.contentDescription = if (torchOn) "关闭手电筒" else "打开手电筒"
|
||
}
|
||
|
||
private fun makeTorchIcon(isOn: Boolean): Drawable {
|
||
return object : Drawable() {
|
||
private val boltPaint = Paint(Paint.ANTI_ALIAS_FLAG).apply {
|
||
style = Paint.Style.FILL
|
||
color = if (isOn) Color.argb(255, 255, 200, 50) else Color.WHITE
|
||
}
|
||
|
||
override fun draw(canvas: Canvas) {
|
||
val w = bounds.width().toFloat()
|
||
val h = bounds.height().toFloat()
|
||
val cx = w * 0.5f
|
||
val left = w * 0.22f
|
||
val right = w * 0.78f
|
||
val top = h * 0.08f
|
||
val bottom = h * 0.92f
|
||
val midY = h * 0.52f
|
||
val path = Path().apply {
|
||
moveTo(cx, top)
|
||
lineTo(left, midY)
|
||
lineTo(cx - w * 0.05f, midY)
|
||
lineTo(cx - w * 0.08f, bottom)
|
||
lineTo(right, midY - h * 0.12f)
|
||
lineTo(cx + w * 0.05f, midY - h * 0.04f)
|
||
lineTo(cx + w * 0.08f, top)
|
||
close()
|
||
}
|
||
canvas.drawPath(path, boltPaint)
|
||
}
|
||
|
||
override fun setAlpha(alpha: Int) {
|
||
boltPaint.alpha = alpha
|
||
invalidateSelf()
|
||
}
|
||
|
||
override fun setColorFilter(colorFilter: ColorFilter?) {
|
||
boltPaint.colorFilter = colorFilter
|
||
invalidateSelf()
|
||
}
|
||
|
||
@Deprecated("Deprecated in Java")
|
||
override fun getOpacity(): Int = PixelFormat.TRANSLUCENT
|
||
}
|
||
}
|
||
|
||
private fun makePauseIcon(isPaused: Boolean): Drawable {
|
||
return object : Drawable() {
|
||
private val p = Paint(Paint.ANTI_ALIAS_FLAG).apply {
|
||
color = Color.WHITE
|
||
style = Paint.Style.FILL
|
||
}
|
||
|
||
override fun draw(canvas: Canvas) {
|
||
val w = bounds.width().toFloat()
|
||
val h = bounds.height().toFloat()
|
||
if (isPaused) {
|
||
val path = Path().apply {
|
||
moveTo(w * 0.34f, h * 0.22f)
|
||
lineTo(w * 0.78f, h * 0.5f)
|
||
lineTo(w * 0.34f, h * 0.78f)
|
||
close()
|
||
}
|
||
canvas.drawPath(path, p)
|
||
} else {
|
||
canvas.drawRoundRect(w * 0.28f, h * 0.2f, w * 0.42f, h * 0.8f, 3f, 3f, p)
|
||
canvas.drawRoundRect(w * 0.58f, h * 0.2f, w * 0.72f, h * 0.8f, 3f, 3f, p)
|
||
}
|
||
}
|
||
|
||
override fun setAlpha(alpha: Int) { p.alpha = alpha }
|
||
override fun setColorFilter(colorFilter: ColorFilter?) { p.colorFilter = colorFilter }
|
||
@Deprecated("Deprecated in Java")
|
||
override fun getOpacity(): Int = PixelFormat.TRANSLUCENT
|
||
}
|
||
}
|
||
|
||
private fun makeCloseIcon(): Drawable {
|
||
return object : Drawable() {
|
||
private val p = Paint(Paint.ANTI_ALIAS_FLAG).apply {
|
||
color = Color.WHITE
|
||
strokeWidth = 4f
|
||
style = Paint.Style.STROKE
|
||
strokeCap = Paint.Cap.ROUND
|
||
}
|
||
|
||
override fun draw(canvas: Canvas) {
|
||
val pad = bounds.width() * 0.28f
|
||
canvas.drawLine(bounds.left + pad, bounds.top + pad, bounds.right - pad, bounds.bottom - pad, p)
|
||
canvas.drawLine(bounds.right - pad, bounds.top + pad, bounds.left + pad, bounds.bottom - pad, p)
|
||
}
|
||
|
||
override fun setAlpha(alpha: Int) { p.alpha = alpha }
|
||
override fun setColorFilter(colorFilter: ColorFilter?) { p.colorFilter = colorFilter }
|
||
@Deprecated("Deprecated in Java")
|
||
override fun getOpacity(): Int = PixelFormat.TRANSLUCENT
|
||
}
|
||
}
|
||
|
||
private fun makeAlbumIcon(): Drawable {
|
||
return object : Drawable() {
|
||
private val p = Paint(Paint.ANTI_ALIAS_FLAG).apply {
|
||
color = Color.WHITE
|
||
style = Paint.Style.STROKE
|
||
strokeWidth = 3.5f
|
||
}
|
||
|
||
override fun draw(canvas: Canvas) {
|
||
val pad = bounds.width() * 0.22f
|
||
canvas.drawRoundRect(
|
||
bounds.left + pad, bounds.top + pad,
|
||
bounds.right - pad, bounds.bottom - pad,
|
||
8f, 8f, p
|
||
)
|
||
canvas.drawCircle(bounds.centerX() - pad * 0.3f, bounds.centerY() - pad * 0.2f, pad * 0.35f, p)
|
||
}
|
||
|
||
override fun setAlpha(alpha: Int) { p.alpha = alpha }
|
||
override fun setColorFilter(colorFilter: ColorFilter?) { p.colorFilter = colorFilter }
|
||
@Deprecated("Deprecated in Java")
|
||
override fun getOpacity(): Int = PixelFormat.TRANSLUCENT
|
||
}
|
||
}
|
||
|
||
private fun dip(dp: Int): Int {
|
||
return (dp * resources.displayMetrics.density + 0.5f).toInt()
|
||
}
|
||
|
||
override fun onDestroy() {
|
||
destroyed = true
|
||
resumeScanRunnable?.let { uiHandler.removeCallbacks(it) }
|
||
scanTimeoutRunnable?.let { uiHandler.removeCallbacks(it) }
|
||
zoomEaseRunnable?.let { uiHandler.removeCallbacks(it) }
|
||
pinchEndRunnable?.let { uiHandler.removeCallbacks(it) }
|
||
continuousSuccessRunnable?.let { uiHandler.removeCallbacks(it) }
|
||
resumeScanRunnable = null
|
||
scanTimeoutRunnable = null
|
||
zoomEaseRunnable = null
|
||
pinchEndRunnable = null
|
||
continuousSuccessRunnable = null
|
||
continuousSuccessOverlay?.hideSuccess()
|
||
continuousSuccessOverlay = null
|
||
needFlatMultiFrame = false
|
||
flatMultiStableHits = 0
|
||
if (torchOn) {
|
||
try {
|
||
deviceCam?.cameraControl?.enableTorch(false)
|
||
} catch (_: Exception) {
|
||
}
|
||
torchOn = false
|
||
}
|
||
pendingResultDialog?.dismiss()
|
||
pendingResultDialog = null
|
||
frameDecorLayer?.haltBeamLoop()
|
||
multiPickLayer?.clear()
|
||
try {
|
||
barcodeScanner?.close()
|
||
} catch (_: Exception) {
|
||
}
|
||
barcodeScanner = null
|
||
try {
|
||
providerRef?.unbindAll()
|
||
} catch (_: Exception) {
|
||
}
|
||
super.onDestroy()
|
||
workerPool.shutdownNow()
|
||
}
|
||
}
|