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) { 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, 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, imageWidth: Int, imageHeight: Int, cropRect: Rect, rotationDegrees: Int ): List { 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() } }