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 scanGot: Laggy preview, hunting autofocus, blurry 640x480 imagesSuccess 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:
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:
| Device | Default Resolution | Focus Mode | Frame Rate | Issues |
|---|---|---|---|---|
| Redmi 12 | 640x480 | Continuous only | 15-20 fps | No manual focus, low resolution default |
| Samsung Galaxy A15 | 1280x720 | Continuous + Auto | 20-25 fps | Focus hunting, no quality scoring |
| Realme C55 | 640x480 | Continuous only | 15 fps | Aggressive 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.
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.
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.
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:
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:
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:
| Device | Before | After | Improvement |
|---|---|---|---|
| Redmi 12 | 34% success | 78% success | +44% |
| Samsung Galaxy A15 | 52% success | 85% success | +33% |
| Realme C55 | 41% success | 81% 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
-
Test on budget devices early. Your $1000 phone hides your code’s flaws.
-
CameraX is a starting point, not a solution. It provides the API, but you need to implement device-aware logic.
-
Budget devices need explicit configuration. Default settings are optimized for compatibility, not quality.
-
Frame quality scoring is essential. What flagship phones do in hardware, budget phones need in software.
-
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:
- 👨💻 CameraX Official Documentation
- 👨💻 Reddit r/Kotlin: CameraX Budget Device Discussion
- 👨💻 ML Kit Text Recognition
- 👨💻 Android Camera2 API Guide
Oh, and if you found these resources useful, don’t forget to support me by starring the repo on GitHub!
Comments