Skip to content

Why Does CameraX Work on Flagship Phones But Fail on Budget Android Devices?

The Problem

Our document scanning feature worked flawlessly on my Samsung Galaxy S23. Then I tested it on a Redmi 12.

Expected: Clear, focused document scan
Got: Laggy preview, hunting autofocus, blurry 640x480 images
Success rate: 34% (vs 91% on iOS)

I thought CameraX was supposed to handle device fragmentation. That’s the whole point, right? One API that works everywhere?

Wrong.

What I Tried First

I started with the basic CameraX setup that worked perfectly on my flagship:

CameraManager.kt
private fun startCamera() {
val cameraProviderFuture = ProcessCameraProvider.getInstance(context)
cameraProviderFuture.addListener({
val cameraProvider = cameraProviderFuture.get()
val preview = Preview.Builder()
.build()
.also { it.setSurfaceProvider(previewView.surfaceProvider) }
val imageAnalyzer = ImageAnalysis.Builder()
.setBackpressureStrategy(ImageAnalysis.STRATEGY_KEEP_ONLY_LATEST)
.build()
.also { it.setAnalyzer(executor, documentAnalyzer) }
cameraProvider.unbindAll()
cameraProvider.bindToLifecycle(
lifecycleOwner,
CameraSelector.DEFAULT_BACK_CAMERA,
preview,
imageAnalyzer
)
}, ContextCompat.getMainExecutor(context))
}

On the Redmi 12, the preview was laggy. The autofocus kept hunting. And when I finally captured an image, it was 640x480—barely readable for OCR.

The Realization

A senior dev on my team dropped a truth bomb:

“On flagship phones our code didn’t need to be smart because the hardware did all the heavy lifting. On a $150 phone the hardware gives you barely adequate raw data and your code needs to work much harder.”

I wasn’t testing my code. I was testing Samsung’s post-processing algorithms.

The Reddit thread I found later confirmed this:

“You weren’t testing your code; you were testing Samsung’s post-processing algorithms.”

Understanding Budget Device Limitations

I ran diagnostics on three budget devices:

DeviceDefault ResolutionFocus ModeFrame RateIssues
Redmi 12640x480Continuous only15-20 fpsNo manual focus, low resolution default
Samsung Galaxy A151280x720Continuous + Auto20-25 fpsFocus hunting, no quality scoring
Realme C55640x480Continuous only15 fpsAggressive noise reduction, no RAW

The pattern was clear: budget devices default to low resolutions, lack manual focus control, and provide no frame quality metadata.

Fix 1: Explicit Resolution Selection

CameraX’s default resolution selection is optimistic. On budget devices, it often picks the lowest common denominator.

CameraManager.kt
private fun createImageAnalyzer(): ImageAnalysis {
val resolutionSelector = ResolutionSelector.Builder()
.setResolutionFilter { supportedSizes, rotation ->
// Filter for resolutions >= 1080p
supportedSizes.filter { size ->
size.width >= 1920 && size.height >= 1080
}.ifEmpty {
// Fallback to highest available
supportedSizes.sortedByDescending { it.width * it.height }
}
}
.build()
return ImageAnalysis.Builder()
.setResolutionSelector(resolutionSelector)
.setBackpressureStrategy(ImageAnalysis.STRATEGY_KEEP_ONLY_LATEST)
.setOutputImageFormat(ImageAnalysis.OUTPUT_IMAGE_FORMAT_RGBA_8888)
.build()
}

But this alone wasn’t enough. Some budget devices reported 1080p support but delivered noisy, compressed frames.

Fix 2: Focus Control for Document Scanning

The biggest issue was autofocus hunting. Budget cameras lack the sophisticated focus systems of flagships. I needed to lock focus at the optimal distance for documents.

FocusController.kt
class FocusController(
private val camera: Camera,
private val cameraControl: CameraControl
) {
private var isFocusLocked = false
fun lockFocusForDocument() {
if (isFocusLocked) return
camera.cameraInfo.cameraState.observeForever { state ->
if (state?.type == CameraState.Type.OPEN) {
cameraControl.cancelFocusAndMetering()
// Set focus distance for typical document scanning (30-50cm)
val focusDistance = 0.3f // meters
cameraControl.setLinearZoom(0f) // Reset zoom first
// Some budget devices don't support manual focus
// Try-catch is essential here
try {
cameraControl.startFocusAndMetering(
FocusMeteringAction.Builder(
MeteringPointFactory
.createPoint(0.5f, 0.5f) // Center focus
)
.setAutoCancelDuration(0, TimeUnit.MILLISECONDS)
.build()
)
isFocusLocked = true
} catch (e: Exception) {
// Fallback: continuous focus on budget devices
Log.w(TAG, "Manual focus not supported, using continuous")
}
}
}
}
fun unlockFocus() {
if (isFocusLocked) {
cameraControl.cancelFocusAndMetering()
isFocusLocked = false
}
}
}

Fix 3: Frame Quality Scoring

The breakthrough came when I realized I needed to evaluate frame quality myself. Flagship phones do this in hardware. Budget phones don’t.

FrameQualityAnalyzer.kt
class FrameQualityAnalyzer(
private val onQualityFrame: (ImageProxy) -> Unit,
private val minQualityThreshold: Float = 0.6f
) : ImageAnalysis.Analyzer {
private val frameBuffer = mutableListOf<FrameScore>()
private val maxBufferSize = 5
data class FrameScore(
val image: ImageProxy,
val sharpnessScore: Float,
val brightnessScore: Float,
val overallScore: Float
)
override fun analyze(image: ImageProxy) {
val sharpness = calculateSharpness(image)
val brightness = calculateBrightness(image)
val overallScore = (sharpness * 0.7f + brightness * 0.3f)
val frameScore = FrameScore(image, sharpness, brightness, overallScore)
synchronized(frameBuffer) {
frameBuffer.add(frameScore)
if (frameBuffer.size > maxBufferSize) {
frameBuffer.removeAt(0)
}
// Only emit if this is the best frame in buffer
val bestFrame = frameBuffer.maxByOrNull { it.overallScore }
if (bestFrame?.image == image && overallScore >= minQualityThreshold) {
onQualityFrame(image)
frameBuffer.clear()
} else {
image.close()
}
}
}
private fun calculateSharpness(image: ImageProxy): Float {
val buffer = image.planes[0].buffer
val bytes = ByteArray(buffer.remaining())
buffer.get(bytes)
buffer.rewind()
// Laplacian variance for sharpness
var sum = 0.0
var sumSq = 0.0
val width = image.width
val height = image.height
for (y in 1 until height - 1) {
for (x in 1 until width - 1) {
val idx = y * width + x
val laplacian = Math.abs(
4 * (bytes[idx].toInt() and 0xFF) -
(bytes[idx - 1].toInt() and 0xFF) -
(bytes[idx + 1].toInt() and 0xFF) -
(bytes[idx - width].toInt() and 0xFF) -
(bytes[idx + width].toInt() and 0xFF)
)
sum += laplacian
sumSq += laplacian * laplacian
}
}
val mean = sum / ((width - 2) * (height - 2))
val variance = sumSq / ((width - 2) * (height - 2)) - mean * mean
// Normalize to 0-1 range
return (variance / 10000.0).coerceIn(0.0, 1.0).toFloat()
}
private fun calculateBrightness(image: ImageProxy): Float {
val buffer = image.planes[0].buffer
val bytes = ByteArray(buffer.remaining())
buffer.get(bytes)
buffer.rewind()
val avgBrightness = bytes.sumOf { (it.toInt() and 0xFF) } / bytes.size.toDouble()
// Optimal brightness around 128 (middle of 0-255)
val deviation = Math.abs(avgBrightness - 128.0)
return (1.0 - deviation / 128.0).coerceIn(0.0, 1.0).toFloat()
}
}

This approach buffers recent frames and only emits the highest quality one. It’s a software implementation of what flagship phones do in hardware.

Fix 4: Device-Aware Configuration

I created a device capability detector to adjust expectations:

DeviceCapabilityDetector.kt
object DeviceCapabilityDetector {
data class CameraCapabilities(
val supportsManualFocus: Boolean,
val supportsHighResolution: Boolean,
val recommendedResolution: Size,
val estimatedPerformance: PerformanceTier
)
enum class PerformanceTier {
HIGH, // Flagship devices
MEDIUM, // Mid-range devices
LOW // Budget devices
}
fun detectCapabilities(context: Context): CameraCapabilities {
val cameraManager = context.getSystemService(Context.CAMERA_SERVICE) as CameraManager
val backCameraId = cameraManager.cameraIdList.firstOrNull { id ->
cameraManager.getCameraCharacteristics(id)
.get(CameraCharacteristics.LENS_FACING) == CameraCharacteristics.LENS_FACING_BACK
} ?: return getDefaultCapabilities()
val characteristics = cameraManager.getCameraCharacteristics(backCameraId)
val supportsManualFocus = try {
val focusModes = characteristics.get(CameraCharacteristics.CONTROL_AF_AVAILABLE_MODES)
focusModes?.contains(CameraCharacteristics.CONTROL_AF_MODE_OFF) == true
} catch (e: Exception) {
false
}
val streamConfigMap = characteristics.get(CameraCharacteristics.SCALER_STREAM_CONFIGURATION_MAP)
val supportedSizes = streamConfigMap?.getOutputSizes(ImageFormat.YUV_420_888) ?: emptyArray()
val hasHighRes = supportedSizes.any { it.width >= 1920 && it.height >= 1080 }
val performanceTier = estimatePerformanceTier(
supportsManualFocus = supportsManualFocus,
hasHighResolution = hasHighRes,
supportedSizes = supportedSizes
)
val recommendedResolution = when (performanceTier) {
PerformanceTier.HIGH -> Size(1920, 1080)
PerformanceTier.MEDIUM -> Size(1280, 720)
PerformanceTier.LOW -> Size(1280, 720) // Force higher than default 640x480
}
return CameraCapabilities(
supportsManualFocus = supportsManualFocus,
supportsHighResolution = hasHighRes,
recommendedResolution = recommendedResolution,
estimatedPerformance = performanceTier
)
}
private fun estimatePerformanceTier(
supportsManualFocus: Boolean,
hasHighResolution: Boolean,
supportedSizes: Array<Size>
): PerformanceTier {
// Heuristic: devices with manual focus and high res are likely flagships
return when {
supportsManualFocus && hasHighResolution -> PerformanceTier.HIGH
hasHighResolution -> PerformanceTier.MEDIUM
else -> PerformanceTier.LOW
}
}
private fun getDefaultCapabilities() = CameraCapabilities(
supportsManualFocus = false,
supportsHighResolution = false,
recommendedResolution = Size(640, 480),
estimatedPerformance = PerformanceTier.LOW
)
}

The Complete Solution

Putting it all together:

DocumentScanner.kt
class DocumentScanner(
private val context: Context,
private val lifecycleOwner: LifecycleOwner,
private val previewView: PreviewView,
private val onDocumentScanned: (Bitmap) -> Unit
) {
private lateinit var camera: Camera
private lateinit var cameraControl: CameraControl
private lateinit var focusController: FocusController
private val capabilities = DeviceCapabilityDetector.detectCapabilities(context)
fun startScanning() {
val cameraProviderFuture = ProcessCameraProvider.getInstance(context)
cameraProviderFuture.addListener({
val cameraProvider = cameraProviderFuture.get()
val preview = Preview.Builder()
.setResolutionSelector(
ResolutionSelector.Builder()
.setResolutionFilter { sizes, _ ->
sizes.filter {
it.width >= capabilities.recommendedResolution.width
}.ifEmpty { sizes }
}.build()
)
.build()
.also { it.setSurfaceProvider(previewView.surfaceProvider) }
val imageAnalysis = ImageAnalysis.Builder()
.setResolutionSelector(
ResolutionSelector.Builder()
.setResolutionFilter { sizes, _ ->
sizes.filter {
it.width >= capabilities.recommendedResolution.width
}.ifEmpty { sizes.sortedByDescending { it.width * it.height } }
}.build()
)
.setBackpressureStrategy(ImageAnalysis.STRATEGY_KEEP_ONLY_LATEST)
.build()
val qualityAnalyzer = FrameQualityAnalyzer(
onQualityFrame = { imageProxy ->
processDocument(imageProxy, onDocumentScanned)
},
minQualityThreshold = when (capabilities.estimatedPerformance) {
DeviceCapabilityDetector.PerformanceTier.HIGH -> 0.7f
DeviceCapabilityDetector.PerformanceTier.MEDIUM -> 0.6f
DeviceCapabilityDetector.PerformanceTier.LOW -> 0.5f
}
)
imageAnalysis.setAnalyzer(cameraExecutor, qualityAnalyzer)
camera = cameraProvider.bindToLifecycle(
lifecycleOwner,
CameraSelector.DEFAULT_BACK_CAMERA,
preview,
imageAnalysis
)
cameraControl = camera.cameraControl
focusController = FocusController(camera, cameraControl)
// Lock focus after camera is ready
Handler(Looper.getMainLooper()).postDelayed({
if (capabilities.supportsManualFocus) {
focusController.lockFocusForDocument()
}
}, 1000)
}, ContextCompat.getMainExecutor(context))
}
private fun processDocument(
imageProxy: ImageProxy,
callback: (Bitmap) -> Unit
) {
// Convert to bitmap and process
val bitmap = imageProxyToBitmap(imageProxy)
imageProxy.close()
callback(bitmap)
}
private val cameraExecutor = Executors.newSingleThreadExecutor()
}

Results After Fixes

After implementing all four fixes:

DeviceBeforeAfterImprovement
Redmi 1234% success78% success+44%
Samsung Galaxy A1552% success85% success+33%
Realme C5541% success81% success+40%

The key metrics improved dramatically:

Before fixes:
- Average resolution: 640x480
- Focus time: 3-5 seconds (hunting)
- Frame quality: Inconsistent
- OCR accuracy: 45%
After fixes:
- Average resolution: 1280x720
- Focus time: <1 second (locked)
- Frame quality: Consistent (quality scoring)
- OCR accuracy: 82%

The Root Cause

I think the fundamental issue is that CameraX’s promise of “works everywhere” is technically true but practically misleading. It works everywhere in the sense that it doesn’t crash. But it doesn’t work well everywhere without device-specific tuning.

Flagship Device Flow:
┌─────────────────────────────────────────────────────────────┐
│ CameraX Request → Hardware ISP → Post-Processing → Result │
│ (powerful) (sophisticated) │
│ │
│ Your code: Generic │
│ Hardware: Compensates for everything │
│ Result: Great │
└─────────────────────────────────────────────────────────────┘
Budget Device Flow:
┌─────────────────────────────────────────────────────────────┐
│ CameraX Request → Hardware ISP → Post-Processing → Result │
│ (minimal) (basic) │
│ │
│ Your code: Generic │
│ Hardware: Can't compensate │
│ Result: Poor │
└─────────────────────────────────────────────────────────────┘
Fixed Budget Device Flow:
┌─────────────────────────────────────────────────────────────┐
│ CameraX Request → Hardware ISP → Post-Processing → Result │
│ ↓ (minimal) (basic) │
│ Device Detection │
│ ↓ │
│ Resolution Override │
│ ↓ │
│ Focus Locking │
│ ↓ │
│ Quality Scoring → Best Frame Selection → Result │
│ │
│ Your code: Device-aware, defensive │
│ Hardware: Still minimal, but guided │
│ Result: Good │
└─────────────────────────────────────────────────────────────┘

Lessons Learned

  1. Test on budget devices early. Your $1000 phone hides your code’s flaws.

  2. CameraX is a starting point, not a solution. It provides the API, but you need to implement device-aware logic.

  3. Budget devices need explicit configuration. Default settings are optimized for compatibility, not quality.

  4. Frame quality scoring is essential. What flagship phones do in hardware, budget phones need in software.

  5. Focus control matters more than you think. Document scanning needs locked focus, not continuous hunting.

Summary

CameraX works on flagship phones because powerful hardware compensates for generic code. Budget devices expose the gaps in your implementation: missing resolution selection, no focus control, and absent quality scoring.

The fix requires device-aware camera configuration with:

  • Explicit resolution selection (force higher than defaults)
  • Focus locking for document scanning
  • Frame quality scoring in software
  • Device capability detection for adaptive thresholds

After implementing these fixes, our document scanning success rate improved from 34% to 78% on budget devices. The code is more complex, but it actually works everywhere—not just on flagships.

Final Words + More Resources

My intention with this article was to help others share my knowledge and experience. If you want to contact me, you can contact by email: Email me

Here are also the most important links from this article along with some further resources that will help you in this scope:

Oh, and if you found these resources useful, don’t forget to support me by starring the repo on GitHub!

Comments