Skip to content

Commit 5000c01

Browse files
committed
Closes mozilla-mobile#7893: Prevent crash in QrFragment on devices without camera
1 parent 8441622 commit 5000c01

File tree

4 files changed

+101
-4
lines changed

4 files changed

+101
-4
lines changed

components/feature/qr/src/main/java/mozilla/components/feature/qr/QrFragment.kt

Lines changed: 32 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -41,6 +41,7 @@ import android.view.Surface
4141
import android.view.TextureView
4242
import android.view.View
4343
import android.view.ViewGroup
44+
import android.widget.TextView
4445
import androidx.annotation.StringRes
4546
import androidx.core.content.ContextCompat.getColor
4647
import androidx.fragment.app.Fragment
@@ -52,6 +53,7 @@ import com.google.zxing.common.HybridBinarizer
5253
import mozilla.components.feature.qr.views.AutoFitTextureView
5354
import mozilla.components.feature.qr.views.CustomViewFinder
5455
import mozilla.components.support.base.log.logger.Logger
56+
import mozilla.components.support.ktx.android.content.hasCamera
5557
import java.io.Serializable
5658
import java.util.ArrayList
5759
import java.util.Arrays
@@ -80,7 +82,7 @@ class QrFragment : Fragment() {
8082
private val surfaceTextureListener = object : TextureView.SurfaceTextureListener {
8183

8284
override fun onSurfaceTextureAvailable(texture: SurfaceTexture, width: Int, height: Int) {
83-
openCamera(width, height)
85+
tryOpenCamera(width, height)
8486
}
8587

8688
override fun onSurfaceTextureSizeChanged(texture: SurfaceTexture, width: Int, height: Int) {
@@ -97,6 +99,8 @@ class QrFragment : Fragment() {
9799

98100
internal lateinit var textureView: AutoFitTextureView
99101
internal lateinit var customViewFinder: CustomViewFinder
102+
internal lateinit var cameraErrorView: TextView
103+
100104
@StringRes
101105
private var scanMessage: Int? = null
102106
internal var cameraId: String? = null
@@ -209,6 +213,8 @@ class QrFragment : Fragment() {
209213
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
210214
textureView = view.findViewById<View>(R.id.texture) as AutoFitTextureView
211215
customViewFinder = view.findViewById<View>(R.id.view_finder) as CustomViewFinder
216+
cameraErrorView = view.findViewById<View>(R.id.camera_error) as TextView
217+
212218
scanMessage?.let {
213219
CustomViewFinder.setMessage(it)
214220
}
@@ -223,7 +229,7 @@ class QrFragment : Fragment() {
223229
// a camera and start preview from here (otherwise, we wait until the surface is ready in
224230
// the SurfaceTextureListener).
225231
if (textureView.isAvailable) {
226-
openCamera(textureView.width, textureView.height)
232+
tryOpenCamera(textureView.width, textureView.height)
227233
} else {
228234
textureView.surfaceTextureListener = surfaceTextureListener
229235
}
@@ -324,6 +330,30 @@ class QrFragment : Fragment() {
324330
this.previewSize = Size(length, length)
325331
}
326332

333+
/**
334+
* Tries to open the camera and displays an error message in case
335+
* there's no camera available or we fail to open it. Applications
336+
* should ideally check for camera availability, but we use this
337+
* as a fallback in case they don't.
338+
*/
339+
@Suppress("TooGenericExceptionCaught")
340+
internal fun tryOpenCamera(width: Int, height: Int, skipCheck: Boolean = false) {
341+
try {
342+
if (context?.hasCamera() == true || skipCheck) {
343+
openCamera(width, height)
344+
} else {
345+
showNoCameraAvailableError()
346+
}
347+
} catch (e: Exception) {
348+
showNoCameraAvailableError()
349+
}
350+
}
351+
352+
private fun showNoCameraAvailableError() {
353+
cameraErrorView.visibility = View.VISIBLE
354+
customViewFinder.visibility = View.GONE
355+
}
356+
327357
/**
328358
* Opens the camera specified by [QrFragment.cameraId].
329359
*/

components/feature/qr/src/main/res/layout/fragment_layout.xml

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,15 @@
1111
android:focusable="true"
1212
tools:ignore="Overdraw">
1313

14+
<TextView
15+
android:id="@+id/camera_error"
16+
android:layout_width="wrap_content"
17+
android:layout_height="wrap_content"
18+
android:layout_centerInParent="true"
19+
android:text="@string/mozac_feature_qr_scanner_no_camera"
20+
android:textColor="@android:color/white"
21+
android:visibility="gone" />
22+
1423
<mozilla.components.feature.qr.views.AutoFitTextureView
1524
android:id="@+id/texture"
1625
android:layout_width="match_parent"
@@ -22,4 +31,5 @@
2231
android:layout_width="match_parent"
2332
android:layout_height="match_parent"
2433
android:layout_centerInParent="true" />
34+
2535
</RelativeLayout>

components/feature/qr/src/main/res/values/strings.xml

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,4 +7,7 @@
77
<!-- Content description (not visible, for screen readers etc.): Description of an image view. -->
88
<string name="mozac_feature_qr_scanner">QR scanner</string>
99

10+
<!-- Text shown if no camera is available on the device and the QR scanner cannot be displayed. -->
11+
<string name="mozac_feature_qr_scanner_no_camera">No camera available on device</string>
12+
1013
</resources>

components/feature/qr/src/test/java/mozilla/components/feature/qr/QrFragmentTest.kt

Lines changed: 56 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ import android.hardware.camera2.CameraManager
1111
import android.media.Image
1212
import android.util.Size
1313
import android.view.View
14+
import android.widget.TextView
1415
import androidx.fragment.app.FragmentActivity
1516
import androidx.test.ext.junit.runners.AndroidJUnit4
1617
import com.google.zxing.BarcodeFormat
@@ -32,6 +33,7 @@ import org.junit.Assert.assertSame
3233
import org.junit.Assert.fail
3334
import org.junit.Test
3435
import org.junit.runner.RunWith
36+
import org.mockito.ArgumentMatchers.anyBoolean
3537
import org.mockito.ArgumentMatchers.anyInt
3638
import org.mockito.ArgumentMatchers.anyString
3739
import org.mockito.Mockito.`when`
@@ -68,14 +70,16 @@ class QrFragmentTest {
6870
whenever(qrFragment.setUpCameraOutputs(anyInt(), anyInt())).then { }
6971

7072
qrFragment.textureView = mock()
73+
qrFragment.cameraErrorView = mock()
74+
qrFragment.customViewFinder = mock()
7175
qrFragment.onResume()
72-
verify(qrFragment, never()).openCamera(anyInt(), anyInt())
76+
verify(qrFragment, never()).tryOpenCamera(anyInt(), anyInt(), anyBoolean())
7377

7478
whenever(qrFragment.textureView.isAvailable).thenReturn(true)
7579
qrFragment.cameraId = "mockCamera"
7680
qrFragment.onResume()
7781
verify(qrFragment, times(2)).startBackgroundThread()
78-
verify(qrFragment).openCamera(anyInt(), anyInt())
82+
verify(qrFragment).tryOpenCamera(anyInt(), anyInt(), anyBoolean())
7983
}
8084

8185
@Test
@@ -84,9 +88,11 @@ class QrFragmentTest {
8488
val view: View = mock()
8589
val textureView: AutoFitTextureView = mock()
8690
val viewFinder: CustomViewFinder = mock()
91+
val cameraErrorView: TextView = mock()
8792

8893
whenever(view.findViewById<AutoFitTextureView>(R.id.texture)).thenReturn(textureView)
8994
whenever(view.findViewById<CustomViewFinder>(R.id.view_finder)).thenReturn(viewFinder)
95+
whenever(view.findViewById<TextView>(R.id.camera_error)).thenReturn(cameraErrorView)
9096

9197
qrFragment.onViewCreated(view, mock())
9298
assertEquals(QrFragment.STATE_FIND_QRCODE, QrFragment.qrState)
@@ -369,4 +375,52 @@ class QrFragmentTest {
369375
assertEquals(768, qrFragment.previewSize?.width)
370376
assertEquals(768, qrFragment.previewSize?.height)
371377
}
378+
379+
@Test
380+
fun `tryOpenCamera displays error message if no camera is available`() {
381+
val qrFragment = spy(QrFragment.newInstance(mock()))
382+
383+
qrFragment.textureView = mock()
384+
qrFragment.cameraErrorView = mock()
385+
qrFragment.customViewFinder = mock()
386+
387+
qrFragment.tryOpenCamera(0, 0)
388+
verify(qrFragment.cameraErrorView).visibility = View.VISIBLE
389+
verify(qrFragment.customViewFinder).visibility = View.GONE
390+
}
391+
392+
@Test
393+
fun `tryOpenCamera opens camera if available`() {
394+
val qrFragment = spy(QrFragment.newInstance(mock()))
395+
396+
qrFragment.textureView = mock()
397+
qrFragment.cameraErrorView = mock()
398+
qrFragment.customViewFinder = mock()
399+
400+
qrFragment.tryOpenCamera(0, 0, skipCheck = true)
401+
verify(qrFragment).openCamera(0, 0)
402+
}
403+
404+
@Test
405+
fun `tryOpenCamera displays error message if camera throws exception`() {
406+
val qrFragment = spy(QrFragment.newInstance(mock()))
407+
whenever(qrFragment.setUpCameraOutputs(anyInt(), anyInt())).then { }
408+
409+
qrFragment.textureView = mock()
410+
qrFragment.cameraErrorView = mock()
411+
qrFragment.customViewFinder = mock()
412+
413+
val cameraManager: CameraManager = mock()
414+
whenever(cameraManager.openCamera(anyString(), any<CameraDevice.StateCallback>(), any()))
415+
.thenThrow(IllegalStateException("no camera"))
416+
417+
val activity: FragmentActivity = mock()
418+
whenever(activity.getSystemService(Context.CAMERA_SERVICE)).thenReturn(cameraManager)
419+
whenever(qrFragment.activity).thenReturn(activity)
420+
qrFragment.cameraId = "mockCamera"
421+
422+
qrFragment.tryOpenCamera(0, 0, skipCheck = true)
423+
verify(qrFragment.cameraErrorView).visibility = View.VISIBLE
424+
verify(qrFragment.customViewFinder).visibility = View.GONE
425+
}
372426
}

0 commit comments

Comments
 (0)