Migrate to CameraX

Since the app is on minSDK 21 now, and because some camera
drivers apparently don't support the torch feature through
the legacy API.
This commit is contained in:
Markus Fisch
2026-03-20 10:52:21 +01:00
parent bc69f99f8b
commit 4423ee62c8
3 changed files with 310 additions and 223 deletions
+4 -1
View File
@@ -76,7 +76,10 @@ dependencies {
implementation "androidx.preference:preference:1.2.1"
// External
implementation 'com.github.markusfisch:CameraView:1.10.0'
implementation 'androidx.camera:camera-core:1.4.2'
implementation 'androidx.camera:camera-camera2:1.4.2'
implementation 'androidx.camera:camera-lifecycle:1.4.2'
implementation 'androidx.camera:camera-view:1.4.2'
implementation 'com.github.markusfisch:ScalingImageView:1.4.3'
implementation 'com.github.markusfisch:zxing-cpp:v3.0.2.1'
@@ -8,20 +8,31 @@ import android.content.Intent
import android.content.pm.PackageManager
import android.graphics.Matrix
import android.graphics.Rect
import android.hardware.Camera
import android.net.Uri
import android.os.Build
import android.os.Bundle
import androidx.appcompat.app.AppCompatActivity
import androidx.core.content.edit
import androidx.core.net.toUri
import com.google.android.material.floatingactionbutton.FloatingActionButton
import android.view.Menu
import android.view.MenuItem
import android.view.MotionEvent
import android.view.View
import android.widget.EditText
import android.widget.SeekBar
import androidx.appcompat.app.AppCompatActivity
import androidx.camera.core.Camera
import androidx.camera.core.CameraSelector
import androidx.camera.core.FocusMeteringAction
import androidx.camera.core.ImageAnalysis
import androidx.camera.core.ImageProxy
import androidx.camera.core.Preview
import androidx.camera.core.TorchState
import androidx.camera.core.resolutionselector.AspectRatioStrategy
import androidx.camera.core.resolutionselector.ResolutionSelector
import androidx.camera.lifecycle.ProcessCameraProvider
import androidx.camera.view.PreviewView
import androidx.core.content.ContextCompat
import androidx.core.content.edit
import androidx.core.net.toUri
import com.google.android.material.floatingactionbutton.FloatingActionButton
import de.markusfisch.android.binaryeye.R
import de.markusfisch.android.binaryeye.adapter.prettifyFormatName
import de.markusfisch.android.binaryeye.app.PERMISSION_CAMERA
@@ -52,7 +63,6 @@ import de.markusfisch.android.binaryeye.view.setPaddingFromWindowInsets
import de.markusfisch.android.binaryeye.widget.DetectorView
import de.markusfisch.android.binaryeye.widget.toast
import de.markusfisch.android.binaryeye.zxingcpp.migrateBarcodeFormatName
import de.markusfisch.android.cameraview.widget.CameraView
import de.markusfisch.android.zxingcpp.ZxingCpp
import de.markusfisch.android.zxingcpp.ZxingCpp.BarcodeFormat
import de.markusfisch.android.zxingcpp.ZxingCpp.Binarizer
@@ -61,19 +71,35 @@ import de.markusfisch.android.zxingcpp.ZxingCpp.Result
import de.markusfisch.android.zxingcpp.ZxingCpp.TextMode
import java.io.FileInputStream
import java.util.Scanner
import java.util.concurrent.ExecutorService
import java.util.concurrent.Executors
import kotlin.math.max
import kotlin.math.min
import kotlin.math.roundToInt
class CameraActivity : AppCompatActivity() {
private val frameRoi = Rect()
private val previewRect = Rect()
private val viewBounds = Rect()
private val matrix = Matrix()
private val analyzerExecutor: ExecutorService =
Executors.newSingleThreadExecutor()
private val readerOptions = ReaderOptions(
tryHarder = prefs.tryHarder,
tryRotate = prefs.autoRotate,
tryInvert = true,
tryDownscale = true,
maxNumberOfSymbols = 1,
textMode = TextMode.PLAIN
)
private lateinit var cameraView: CameraView
private lateinit var cameraView: PreviewView
private lateinit var detectorView: DetectorView
private lateinit var zoomBar: SeekBar
private lateinit var flashFab: FloatingActionButton
private var cameraProvider: ProcessCameraProvider? = null
private var camera: Camera? = null
private var currentProfile = prefs.profile
private var shouldStoreSettings = true
private var formatsToRead = setOf<BarcodeFormat>()
@@ -87,8 +113,8 @@ class CameraActivity : AppCompatActivity() {
private var restrictFormat: String? = null
private var searchTerm: Regex? = null
private var ignoreNext: String? = null
private var fallbackBuffer: IntArray? = null
private var requestCameraPermission = true
private var useLocalAverage = false
override fun onRequestPermissionsResult(
requestCode: Int,
@@ -101,10 +127,14 @@ class CameraActivity : AppCompatActivity() {
grantResults
)
when (requestCode) {
PERMISSION_CAMERA -> if (grantResults.isNotEmpty() &&
grantResults[0] != PackageManager.PERMISSION_GRANTED
) {
toast(R.string.camera_error)
PERMISSION_CAMERA -> {
if (grantResults.isNotEmpty() &&
grantResults[0] != PackageManager.PERMISSION_GRANTED
) {
toast(R.string.camera_error)
} else if (grantResults.isNotEmpty()) {
bindCameraUseCases()
}
}
}
}
@@ -142,12 +172,12 @@ class CameraActivity : AppCompatActivity() {
initBars()
cameraView = findViewById(R.id.camera_view) as CameraView
detectorView = findViewById(R.id.detector_view) as DetectorView
zoomBar = findViewById(R.id.zoom) as SeekBar
flashFab = findViewById(R.id.flash) as FloatingActionButton
cameraView = findViewById(R.id.camera_view)
detectorView = findViewById(R.id.detector_view)
zoomBar = findViewById(R.id.zoom)
flashFab = findViewById(R.id.flash)
initCameraView()
initPreviewView()
initZoomBar()
initDetectorView()
@@ -160,7 +190,7 @@ class CameraActivity : AppCompatActivity() {
override fun onDestroy() {
super.onDestroy()
fallbackBuffer = null
analyzerExecutor.shutdown()
releaseToneGenerators()
}
@@ -180,7 +210,7 @@ class CameraActivity : AppCompatActivity() {
// for this session. Otherwise ActivityCompat.requestPermissions()
// will trigger onResume() again and again.
if (hasCameraPermission(requestCameraPermission)) {
openCamera()
bindCameraUseCases()
}
requestCameraPermission = false
}
@@ -195,7 +225,7 @@ class CameraActivity : AppCompatActivity() {
}
val packageInfo = packageManager.getPackageInfo(packageName, 0)
val installedSince = System.currentTimeMillis() -
packageInfo.firstInstallTime
packageInfo.firstInstallTime
return installedSince < 86400000
}
@@ -204,7 +234,7 @@ class CameraActivity : AppCompatActivity() {
return false
}
shouldStoreSettings = false
zoomBar.max = 0 // Reset zoom when there are no preferences yet.
zoomBar.progress = 10
currentProfile = prefs.profile
recreate()
return true
@@ -257,30 +287,19 @@ class CameraActivity : AppCompatActivity() {
}
}
private fun openCamera() {
cameraView.openAsync(
CameraView.findCameraId(
@Suppress("DEPRECATION")
if (frontFacing) {
Camera.CameraInfo.CAMERA_FACING_FRONT
} else {
Camera.CameraInfo.CAMERA_FACING_BACK
}
)
)
}
override fun onPause() {
super.onPause()
closeCamera()
unbindCameraUseCases()
if (shouldStoreSettings) {
storeSettings()
}
shouldStoreSettings = true
}
private fun closeCamera() {
cameraView.close()
private fun unbindCameraUseCases() {
decoding = false
cameraProvider?.unbindAll()
camera = null
}
private fun storeSettings() {
@@ -290,7 +309,6 @@ class CameraActivity : AppCompatActivity() {
override fun onRestoreInstanceState(savedState: Bundle) {
super.onRestoreInstanceState(savedState)
zoomBar.max = savedState.getInt(ZOOM_MAX)
zoomBar.progress = savedState.getInt(ZOOM_LEVEL)
frontFacing = savedState.getBoolean(FRONT_FACING)
bulkMode = savedState.getBoolean(BULK_MODE)
@@ -299,7 +317,6 @@ class CameraActivity : AppCompatActivity() {
}
override fun onSaveInstanceState(outState: Bundle) {
outState.putInt(ZOOM_MAX, zoomBar.max)
outState.putInt(ZOOM_LEVEL, zoomBar.progress)
outState.putBoolean(FRONT_FACING, frontFacing)
outState.putBoolean(BULK_MODE, bulkMode)
@@ -389,7 +406,9 @@ class CameraActivity : AppCompatActivity() {
true
}
else -> super.onOptionsItemSelected(item)
else -> {
super.onOptionsItemSelected(item)
}
}
}
@@ -398,9 +417,8 @@ class CameraActivity : AppCompatActivity() {
}
private fun switchCamera() {
closeCamera()
frontFacing = frontFacing xor true
openCamera()
bindCameraUseCases()
}
private fun showRestrictionDialog() {
@@ -466,8 +484,13 @@ class CameraActivity : AppCompatActivity() {
.setTitle(R.string.profile)
.setItems(profiles) { _, which ->
val newProfile = when (which) {
0 -> null
else -> profiles[which]
0 -> {
null
}
else -> {
profiles[which]
}
}
if (currentProfile != newProfile) {
storeSettings()
@@ -486,8 +509,8 @@ class CameraActivity : AppCompatActivity() {
startActivity(EncodeActivity.newIntent(this, text).apply {
addFlags(
Intent.FLAG_ACTIVITY_NO_HISTORY or
Intent.FLAG_ACTIVITY_CLEAR_TASK or
Intent.FLAG_ACTIVITY_NEW_TASK
Intent.FLAG_ACTIVITY_CLEAR_TASK or
Intent.FLAG_ACTIVITY_NEW_TASK
)
})
finish()
@@ -505,28 +528,29 @@ class CameraActivity : AppCompatActivity() {
if (file != null) {
val fs = FileInputStream(file.fileDescriptor)
val scn = Scanner(fs).useDelimiter("\\A")
if (scn.hasNext()) {
startActivity(
EncodeActivity.newIntent(this, scn.next()).apply {
addFlags(
Intent.FLAG_ACTIVITY_NO_HISTORY or
Intent.FLAG_ACTIVITY_CLEAR_TASK or
Intent.FLAG_ACTIVITY_NEW_TASK
)
}
)
finish()
}
if (scn.hasNext()) {
startActivity(
EncodeActivity.newIntent(this, scn.next()).apply {
addFlags(
Intent.FLAG_ACTIVITY_NO_HISTORY or
Intent.FLAG_ACTIVITY_CLEAR_TASK or
Intent.FLAG_ACTIVITY_NEW_TASK
)
}
)
finish()
}
file.close()
}
}
}
private fun initCameraView() {
cameraView.setUseOrientationListener(true)
private fun initPreviewView() {
cameraView.addOnLayoutChangeListener { _, _, _, _, _, _, _, _, _ ->
updateFrameRoiAndMappingMatrix()
}
@Suppress("ClickableViewAccessibility")
cameraView.setOnTouchListener(object : View.OnTouchListener {
var focus = true
var offset = -1f
var progress = 0
@@ -546,7 +570,7 @@ class CameraActivity : AppCompatActivity() {
val dist = offset - pos
val maxValue = zoomBar.max
val change = maxValue /
v.height.toFloat() * 2f * dist
v.height.toFloat() * 2f * dist
zoomBar.progress = min(
maxValue,
max(progress + change.roundToInt(), 0)
@@ -556,131 +580,25 @@ class CameraActivity : AppCompatActivity() {
}
MotionEvent.ACTION_UP -> {
// Stop calling focusTo() as soon as it returns false
// to avoid throwing and catching future exceptions.
if (focus) {
focus = cameraView.focusTo(v, event.x, event.y)
if (focus) {
v?.performClick()
return true
}
val pointFactory = cameraView.meteringPointFactory
val point = pointFactory.createPoint(event.x, event.y)
val action = FocusMeteringAction.Builder(point).build()
camera?.cameraControl?.startFocusAndMetering(action)
if (v != null) {
v.performClick()
}
return true
}
}
return false
}
})
@Suppress("DEPRECATION")
cameraView.setOnCameraListener(object : CameraView.OnCameraListener {
override fun onConfigureParameters(
parameters: Camera.Parameters
) {
zoomBar.visibility = if (parameters.isZoomSupported) {
restoreZoomBarSettings()
val max = parameters.maxZoom
if (zoomBar.max != max) {
zoomBar.max = max
zoomBar.progress = max / 10
storeZoomBarSettings()
}
parameters.zoom = zoomBar.progress
View.VISIBLE
} else {
View.GONE
}
val sceneModes = parameters.supportedSceneModes
sceneModes?.let {
for (mode in sceneModes) {
if (mode == Camera.Parameters.SCENE_MODE_BARCODE) {
parameters.sceneMode = mode
break
}
}
}
CameraView.setAutoFocus(parameters)
updateFlashFab(parameters.flashMode == null)
}
override fun onCameraError() {
this@CameraActivity.toast(R.string.camera_error)
}
override fun onCameraReady(camera: Camera) {
frameMetrics = FrameMetrics(
cameraView.frameWidth,
cameraView.frameHeight,
cameraView.frameOrientation
)
updateFrameRoiAndMappingMatrix()
ignoreNext = null
decoding = true
// These settings can't change while the camera is open.
val options = ReaderOptions(
tryHarder = prefs.tryHarder,
tryRotate = prefs.autoRotate,
tryInvert = true,
tryDownscale = true,
maxNumberOfSymbols = 1,
textMode = TextMode.PLAIN
)
var useLocalAverage = false
camera.setPreviewCallback { frameData, _ ->
if (!decoding ||
frameData == null ||
frameData.isEmpty()
) {
return@setPreviewCallback
}
useLocalAverage = useLocalAverage xor true
ZxingCpp.readByteArray(
frameData,
frameMetrics.width,
frameRoi.left, frameRoi.top,
frameRoi.width(), frameRoi.height(),
frameMetrics.orientation,
options.apply {
// By default, ZXing uses LOCAL_AVERAGE, but
// this does not work well with inverted
// barcodes on low-contrast backgrounds.
binarizer = if (useLocalAverage) {
Binarizer.LOCAL_AVERAGE
} else {
Binarizer.GLOBAL_HISTOGRAM
}
formats = formatsToRead
}
)?.let { results ->
val result = results.first()
val text = result.text
if (text == ignoreNext) {
return@let
}
val term = searchTerm
if (term != null &&
!text.matches(term) &&
!text.contains(term)
) {
ignoreNext = text
errorFeedback()
toast(R.string.does_not_match_search_term)
return@let
}
postResult(result)
decoding = false
}
}
}
override fun onPreviewStarted(camera: Camera) {
}
override fun onCameraStopping(camera: Camera) {
camera.setPreviewCallback(null)
}
})
}
private fun initZoomBar() {
zoomBar.max = 100
zoomBar.progress = 10
restoreZoomBarSettings()
zoomBar.setOnSeekBarChangeListener(object :
SeekBar.OnSeekBarChangeListener {
override fun onProgressChanged(
@@ -688,35 +606,25 @@ class CameraActivity : AppCompatActivity() {
progress: Int,
fromUser: Boolean
) {
cameraView.camera?.setZoom(progress)
camera?.cameraControl?.setLinearZoom(progress / 100f)
}
override fun onStartTrackingTouch(seekBar: SeekBar) {}
override fun onStartTrackingTouch(seekBar: SeekBar) {
}
override fun onStopTrackingTouch(seekBar: SeekBar) {}
override fun onStopTrackingTouch(seekBar: SeekBar) {
storeZoomBarSettings()
}
})
}
@Suppress("DEPRECATION")
private fun Camera.setZoom(zoom: Int) {
try {
val params = parameters
params.zoom = zoom
parameters = params
} catch (_: RuntimeException) {
// Ignore. There's nothing we can do.
}
}
private fun storeZoomBarSettings() {
prefs.preferences.edit {
putInt(ZOOM_MAX, zoomBar.max)
putInt(ZOOM_LEVEL, zoomBar.progress)
}
}
private fun restoreZoomBarSettings() {
zoomBar.max = prefs.preferences.getInt(ZOOM_MAX, zoomBar.max)
zoomBar.progress = prefs.preferences.getInt(
ZOOM_LEVEL,
zoomBar.progress
@@ -735,24 +643,160 @@ class CameraActivity : AppCompatActivity() {
detectorView.cropHandleName = "camera_crop_handle"
}
private fun bindCameraUseCases() {
val cameraProviderFuture = ProcessCameraProvider.getInstance(this)
cameraProviderFuture.addListener({
if (isDestroyed) {
return@addListener
}
val provider = try {
cameraProviderFuture.get()
} catch (_: Exception) {
toast(R.string.camera_error)
return@addListener
}
cameraProvider = provider
val resolutionSelector = ResolutionSelector.Builder()
.setAspectRatioStrategy(
AspectRatioStrategy.RATIO_16_9_FALLBACK_AUTO_STRATEGY
)
.build()
val preview = Preview.Builder()
.setResolutionSelector(resolutionSelector)
.build()
val imageAnalysis = ImageAnalysis.Builder()
.setResolutionSelector(resolutionSelector)
.setBackpressureStrategy(
ImageAnalysis.STRATEGY_KEEP_ONLY_LATEST
)
.build()
imageAnalysis.setAnalyzer(analyzerExecutor) { image ->
analyzeImage(image)
}
val cameraSelector = CameraSelector.Builder()
.requireLensFacing(
if (frontFacing) {
CameraSelector.LENS_FACING_FRONT
} else {
CameraSelector.LENS_FACING_BACK
}
)
.build()
try {
provider.unbindAll()
camera = provider.bindToLifecycle(
this,
cameraSelector,
preview,
imageAnalysis
)
preview.surfaceProvider = cameraView.surfaceProvider
ignoreNext = null
decoding = true
useLocalAverage = false
updateZoomState()
updateFlashFab(!(camera?.cameraInfo?.hasFlashUnit() ?: false))
updateFrameRoiAndMappingMatrix()
} catch (_: Exception) {
camera = null
toast(R.string.camera_error)
}
}, ContextCompat.getMainExecutor(this))
}
private fun analyzeImage(image: ImageProxy) {
try {
if (!decoding) {
return
}
frameMetrics = FrameMetrics(
image.width,
image.height,
image.imageInfo.rotationDegrees
)
updateFrameRoiAndMappingMatrix()
if (frameRoi.width() < 1 || frameRoi.height() < 1) {
return
}
useLocalAverage = useLocalAverage xor true
val yPlane = image.planes[0]
ZxingCpp.readYBuffer(
yPlane.buffer,
yPlane.rowStride,
frameRoi,
frameMetrics.orientation,
readerOptions.apply {
binarizer = if (useLocalAverage) {
Binarizer.LOCAL_AVERAGE
} else {
Binarizer.GLOBAL_HISTOGRAM
}
formats = formatsToRead
tryHarder = prefs.tryHarder
tryRotate = prefs.autoRotate
}
)?.firstOrNull()?.let { result ->
handleDecodeResult(result)
}
} finally {
image.close()
}
}
private fun handleDecodeResult(result: Result) {
val text = result.text
if (text == ignoreNext) {
return
}
val term = searchTerm
if (term != null &&
!text.matches(term) &&
!text.contains(term)
) {
ignoreNext = text
cameraView.post {
errorFeedback()
toast(R.string.does_not_match_search_term)
}
return
}
decoding = false
postResult(result)
}
private fun updateZoomState() {
zoomBar.visibility = if (
camera?.cameraInfo?.zoomState?.value != null
) {
View.VISIBLE
} else {
View.GONE
}
}
private fun updateFrameRoiAndMappingMatrix() {
val viewRect = cameraView.previewRect
if (viewRect.width() < 1 ||
viewRect.height() < 1 ||
viewBounds.set(0, 0, cameraView.width, cameraView.height)
if (viewBounds.width() < 1 ||
viewBounds.height() < 1 ||
!frameMetrics.isValid()
) {
return
}
previewRect.setCovered(
viewBounds.width(),
viewBounds.height(),
frameMetrics
)
val viewRoi = if (
detectorView.roi.width() < 1 ||
detectorView.roi.height() < 1
) {
viewRect
viewBounds
} else {
detectorView.roi
}
frameRoi.setFrameRoi(frameMetrics, viewRect, viewRoi)
matrix.setFrameToView(frameMetrics, viewRect, viewRoi)
frameRoi.setFrameRoi(frameMetrics, previewRect, viewRoi)
matrix.setFrameToView(frameMetrics, previewRect, viewRoi)
}
private fun updateFlashFab(unavailable: Boolean) {
@@ -765,22 +809,54 @@ class CameraActivity : AppCompatActivity() {
}
}
@Suppress("DEPRECATION")
private fun toggleTorchMode() {
val camera = cameraView.camera ?: return
val parameters = camera.parameters ?: return
parameters.flashMode = if (
parameters.flashMode != Camera.Parameters.FLASH_MODE_OFF
val currentState = camera?.cameraInfo?.torchState?.value ?: return
camera?.cameraControl?.enableTorch(
currentState != TorchState.ON
)
}
private fun Rect.setCovered(
viewWidth: Int,
viewHeight: Int,
frameMetrics: FrameMetrics
) {
val frameWidth: Int
val frameHeight: Int
when (frameMetrics.orientation) {
90, 270 -> {
frameWidth = frameMetrics.height
frameHeight = frameMetrics.width
}
else -> {
frameWidth = frameMetrics.width
frameHeight = frameMetrics.height
}
}
if (frameWidth < 1 || frameHeight < 1) {
set(0, 0, 0, 0)
return
}
var coveredWidth = frameWidth
var coveredHeight = frameHeight
if (viewWidth.toLong() * coveredWidth <
viewHeight.toLong() * coveredHeight
) {
Camera.Parameters.FLASH_MODE_OFF
coveredWidth = coveredWidth * viewHeight / coveredHeight
coveredHeight = viewHeight
} else {
Camera.Parameters.FLASH_MODE_TORCH
}
try {
camera.parameters = parameters
} catch (e: RuntimeException) {
toast(e.message ?: getString(R.string.error_flash))
coveredHeight = coveredHeight * viewWidth / coveredWidth
coveredWidth = viewWidth
}
val left = (viewWidth - coveredWidth) / 2
val top = (viewHeight - coveredHeight) / 2
set(
left,
top,
left + coveredWidth,
top + coveredHeight
)
}
private fun postResult(result: Result) {
@@ -808,9 +884,11 @@ class CameraActivity : AppCompatActivity() {
finish()
}
returnUri != null -> execShareIntent(
Intent(Intent.ACTION_VIEW, returnUri)
)
returnUri != null -> {
execShareIntent(
Intent(Intent.ACTION_VIEW, returnUri)
)
}
else -> {
showResult(result, bulkMode)
@@ -825,10 +903,12 @@ class CameraActivity : AppCompatActivity() {
if (bulkMode) {
when (prefs.ignoreDuplicates()) {
Preferences.Companion.IgnoreDuplicates.Consecutive,
Preferences.Companion.IgnoreDuplicates.Any ->
Preferences.Companion.IgnoreDuplicates.Any -> {
ignoreNext = result.text
}
else -> Unit
else -> {
}
}
if (prefs.showToastInBulkMode) {
toast(result.text)
@@ -842,7 +922,6 @@ class CameraActivity : AppCompatActivity() {
companion object {
private const val PICK_FILE_RESULT_CODE = 1
private const val ZOOM_MAX = "zoom_max"
private const val ZOOM_LEVEL = "zoom_level"
private const val FRONT_FACING = "front_facing"
private const val BULK_MODE = "bulk_mode"
@@ -906,7 +985,9 @@ fun Activity.showResult(
R.string.bluetooth_send_fail
}
else -> R.string.bluetooth_send_success
else -> {
R.string.bluetooth_send_success
}
}
)
}
+6 -3
View File
@@ -1,8 +1,11 @@
<merge xmlns:android="http://schemas.android.com/apk/res/android">
<de.markusfisch.android.cameraview.widget.CameraView
<merge
xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto">
<androidx.camera.view.PreviewView
android:id="@+id/camera_view"
android:layout_width="match_parent"
android:layout_height="match_parent"/>
android:layout_height="match_parent"
app:scaleType="fillCenter"/>
<de.markusfisch.android.binaryeye.widget.DetectorView
android:id="@+id/detector_view"
android:layout_width="match_parent"