Files
MrleeAdmin/frontend/scanapp/uni_modules/mas-code-scan/utssdk/app-android/CodeCaptureActivity.kt
T
2026-08-31 11:33:59 +08:00

1529 lines
63 KiB
Kotlin
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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()
}
}