diff --git a/android/gradle/libs.versions.toml b/android/gradle/libs.versions.toml index fceec7641e..101fe3833f 100644 --- a/android/gradle/libs.versions.toml +++ b/android/gradle/libs.versions.toml @@ -2,8 +2,9 @@ accompanist = "0.23.1" activity-compose = "1.8.2" androidJunit5 = "1.8.2.1" +androidx-camera = "1.4.0-rc01" androidx-paging = "3.3.0" -androidx-test= "1.6.1" +androidx-test = "1.6.1" appcompat = "1.7.0" benchmark-junit = "1.2.4" cardview = "1.0.0" @@ -56,6 +57,7 @@ leakcanary-android = "2.10" lifecycle= "2.8.3" mapbox-sdk-turf = "4.8.0" material = "1.12.0" +mlkit-barcode-scanning = "17.3.0" mockk = "1.13.8" mockk-android = "1.13.8" msg-simple = "1.2" @@ -88,6 +90,11 @@ xercesImpl = "2.12.2" accompanist-flowlayout = { group = "com.google.accompanist", name = "accompanist-flowlayout", version.ref = "accompanist" } accompanist-placeholder = { group = "com.google.accompanist", name = "accompanist-placeholder", version.ref = "accompanist" } activity-compose = { group = "androidx.activity", name = "activity-compose", version.ref = "activity-compose" } +androidx-camera-camera2 = { group = "androidx.camera", name = "camera-camera2", version.ref = "androidx-camera"} +androidx-camera-extensions = { group = "androidx.camera", name = "camera-extensions", version.ref = "androidx-camera"} +androidx-camera-lifecycle = { group = "androidx.camera", name = "camera-lifecycle", version.ref = "androidx-camera"} +androidx-camera-mlkit-vision = {group = "androidx.camera", name = "camera-mlkit-vision", version.ref = "androidx-camera"} +androidx-camera-view = {group = "androidx.camera", name = "camera-view", version.ref = "androidx-camera"} activity-ktx = { group = "androidx.activity", name = "activity-ktx", version.ref = "activity-compose" } appcompat = { group = "androidx.appcompat", name = "appcompat", version.ref = "appcompat" } benchmark-junit = { group = "androidx.benchmark", name = "benchmark-junit4", version.ref = "benchmark-junit" } @@ -154,6 +161,7 @@ lifecycle-viewmodel-compose = { group = "androidx.lifecycle", name = "lifecycle- lifecycle-viewmodel-ktx = { group = "androidx.lifecycle", name = "lifecycle-viewmodel-ktx", version.ref = "lifecycle" } mapbox-sdk-turf = { group = "com.mapbox.mapboxsdk", name = "mapbox-sdk-turf", version.ref = "mapbox-sdk-turf" } material = { group = "com.google.android.material", name = "material", version.ref = "material" } +mlkit-barcode-scanning = { group = "com.google.mlkit", name = "barcode-scanning", version.ref = "mlkit-barcode-scanning"} mockk = { group = "io.mockk", name = "mockk", version.ref = "mockk" } mockk-android = { group = "io.mockk", name = "mockk-android", version.ref = "mockk-android" } msg-simple = { group = "com.github.java-json-tools", name = "msg-simple", version.ref = "msg-simple" } @@ -206,6 +214,7 @@ org-owasp-dependencycheck = { id = "org.owasp.dependencycheck", version.ref = "o [bundles] accompanist = ["accompanist-placeholder", "accompanist-flowlayout"] +cameraX = ["androidx-camera-camera2", "androidx-camera-extensions", "androidx-camera-lifecycle", "androidx-camera-mlkit-vision", "androidx-camera-view"] compose = ["activity-compose","activity-ktx", "ui", "ui-tooling-preview", "constraintlayout-compose", "foundation","runtime-livedata"] compose-ui-test = ["ui-test-junit4","ui-test-manifest"] coroutines = ["kotlinx-coroutines-core", "kotlinx-coroutines-android"] diff --git a/android/quest/build.gradle.kts b/android/quest/build.gradle.kts index 6e64030d03..bf7ca9de70 100644 --- a/android/quest/build.gradle.kts +++ b/android/quest/build.gradle.kts @@ -11,7 +11,7 @@ import org.json.JSONObject plugins { `jacoco-report` `project-properties` - `ktlint` + ktlint id("com.android.application") id("kotlin-android") id("kotlin-kapt") @@ -27,7 +27,7 @@ plugins { sonar { properties { property("sonar.projectKey", "fhircore") - property("sonar.kotlin.source.version", libs.kotlin) + property("sonar.kotlin.source.version", libs.versions.kotlin) property( "sonar.androidLint.reportPaths", "${project.layout.buildDirectory.get()}/reports/lint-results-opensrpDebug.xml", @@ -429,6 +429,9 @@ dependencies { implementation(libs.dagger.hilt.android) implementation(libs.hilt.work) implementation(libs.gms.play.services.location) + implementation(libs.mlkit.barcode.scanning) + + implementation(libs.bundles.cameraX) // Annotation processors kapt(libs.hilt.compiler) diff --git a/android/quest/src/main/java/org/smartregister/fhircore/quest/ui/sdc/qrCode/QrCodeCameraDialogFragment.kt b/android/quest/src/main/java/org/smartregister/fhircore/quest/ui/sdc/qrCode/CameraPermissionsDialogFragment.kt similarity index 53% rename from android/quest/src/main/java/org/smartregister/fhircore/quest/ui/sdc/qrCode/QrCodeCameraDialogFragment.kt rename to android/quest/src/main/java/org/smartregister/fhircore/quest/ui/sdc/qrCode/CameraPermissionsDialogFragment.kt index 5a08e6c1cf..0202c11c72 100644 --- a/android/quest/src/main/java/org/smartregister/fhircore/quest/ui/sdc/qrCode/QrCodeCameraDialogFragment.kt +++ b/android/quest/src/main/java/org/smartregister/fhircore/quest/ui/sdc/qrCode/CameraPermissionsDialogFragment.kt @@ -17,29 +17,26 @@ package org.smartregister.fhircore.quest.ui.sdc.qrCode import android.Manifest -import android.widget.Toast import androidx.activity.result.contract.ActivityResultContracts import androidx.annotation.VisibleForTesting import androidx.core.os.bundleOf import androidx.fragment.app.DialogFragment -import com.google.android.fhir.datacapture.contrib.views.barcode.mlkit.md.LiveBarcodeScanningFragment import org.smartregister.fhircore.engine.util.location.PermissionUtils import org.smartregister.fhircore.quest.R -class QrCodeCameraDialogFragment : DialogFragment(R.layout.fragment_camera_permission) { +class CameraPermissionsDialogFragment : DialogFragment(R.layout.fragment_camera_permission) { @VisibleForTesting val cameraPermissionRequest = registerForActivityResult(ActivityResultContracts.RequestPermission()) { permissionGranted -> + parentFragmentManager.setFragmentResult( + CAMERA_PERMISSION_REQUEST_RESULT_KEY, + bundleOf(CAMERA_PERMISSION_REQUEST_RESULT_KEY to permissionGranted), + ) + if (permissionGranted) { - showQrCodeScanner() + dismiss() } else { - Toast.makeText( - requireContext(), - requireContext().getString(R.string.barcode_camera_permission_denied), - Toast.LENGTH_SHORT, - ) - .show() dismiss() } } @@ -49,7 +46,7 @@ class QrCodeCameraDialogFragment : DialogFragment(R.layout.fragment_camera_permi when { PermissionUtils.checkPermissions(requireContext(), listOf(Manifest.permission.CAMERA)) -> { - showQrCodeScanner() + dismiss() } else -> { cameraPermissionRequest.launch(Manifest.permission.CAMERA) @@ -57,33 +54,8 @@ class QrCodeCameraDialogFragment : DialogFragment(R.layout.fragment_camera_permi } } - private fun showQrCodeScanner() = - parentFragmentManager.apply { - setFragmentResultListener( - LiveBarcodeScanningFragment.RESULT_REQUEST_KEY, - requireActivity(), - ) { _, result -> - val qrCode = result.getString(LiveBarcodeScanningFragment.RESULT_REQUEST_KEY)?.trim() - this.setFragmentResult( - RESULT_REQUEST_KEY, - bundleOf(RESULT_REQUEST_KEY to qrCode), - ) - } - - val qrCodeScannerFragment = this.findFragmentByTag(QR_CODE_SCANNER_FRAGMENT_TAG) - if (qrCodeScannerFragment == null) { - LiveBarcodeScanningFragment() - .show( - this@apply, - QR_CODE_SCANNER_FRAGMENT_TAG, - ) - } - - dismiss() - } - companion object { - const val RESULT_REQUEST_KEY = "qr-code-result" - const val QR_CODE_SCANNER_FRAGMENT_TAG = "QrCodeCameraDialogFragment" + const val CAMERA_PERMISSION_REQUEST_RESULT_KEY = + "quest.ui.sdc.qrCode.CameraPermissionsDialogFragment" } } diff --git a/android/quest/src/main/java/org/smartregister/fhircore/quest/ui/sdc/qrCode/scan/QRCodeScannerDialogFragment.kt b/android/quest/src/main/java/org/smartregister/fhircore/quest/ui/sdc/qrCode/scan/QRCodeScannerDialogFragment.kt new file mode 100644 index 0000000000..1f675c1a52 --- /dev/null +++ b/android/quest/src/main/java/org/smartregister/fhircore/quest/ui/sdc/qrCode/scan/QRCodeScannerDialogFragment.kt @@ -0,0 +1,188 @@ +/* + * Copyright 2021-2024 Ona Systems, Inc + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.smartregister.fhircore.quest.ui.sdc.qrCode.scan + +import android.content.res.Resources +import android.graphics.RectF +import android.os.Bundle +import android.view.View +import android.view.ViewGroup +import android.widget.ImageButton +import android.widget.ImageView +import android.widget.TextView +import android.widget.Toast +import androidx.annotation.VisibleForTesting +import androidx.camera.core.ImageAnalysis.COORDINATE_SYSTEM_VIEW_REFERENCED +import androidx.camera.mlkit.vision.MlKitAnalyzer +import androidx.camera.view.LifecycleCameraController +import androidx.camera.view.PreviewView +import androidx.core.content.ContextCompat +import androidx.core.graphics.toRect +import androidx.core.os.bundleOf +import com.google.android.material.bottomsheet.BottomSheetBehavior +import com.google.android.material.bottomsheet.BottomSheetDialogFragment +import com.google.mlkit.vision.barcode.BarcodeScanner +import com.google.mlkit.vision.barcode.BarcodeScannerOptions +import com.google.mlkit.vision.barcode.BarcodeScanning +import com.google.mlkit.vision.barcode.common.Barcode +import java.util.concurrent.ExecutorService +import java.util.concurrent.Executors +import org.smartregister.fhircore.engine.util.location.PermissionUtils +import org.smartregister.fhircore.quest.R +import org.smartregister.fhircore.quest.ui.sdc.qrCode.CameraPermissionsDialogFragment + +internal class QRCodeScannerDialogFragment : + BottomSheetDialogFragment(R.layout.fragment_qr_code_scan) { + + private lateinit var cameraExecutor: ExecutorService + private lateinit var cameraController: LifecycleCameraController + private lateinit var mlKitImageAnalyzer: MlKitAnalyzer + private val barcodeScanner: BarcodeScanner by lazy { + val options = BarcodeScannerOptions.Builder().setBarcodeFormats(Barcode.FORMAT_QR_CODE).build() + BarcodeScanning.getClient(options) + } + + private lateinit var previewView: PreviewView + private lateinit var cancelScanButton: ImageButton + private lateinit var viewFinderImageView: ImageView + private lateinit var placeQrCodeScanTextView: TextView + + @VisibleForTesting + val viewFinderBounds: RectF + get() { + val viewFinderImageViewHeight = viewFinderImageView.height + val viewFinderImageViewWidth = viewFinderImageView.width + return RectF( + viewFinderImageView.x, + viewFinderImageView.y, + viewFinderImageView.x + viewFinderImageViewWidth + 10, + viewFinderImageView.y + viewFinderImageViewHeight + 10, + ) + } + + override fun onViewCreated(view: View, savedInstanceState: Bundle?) { + super.onViewCreated(view, savedInstanceState) + + previewView = view.findViewById(R.id.previewView) + viewFinderImageView = view.findViewById(R.id.viewFinderImageView) + placeQrCodeScanTextView = view.findViewById(R.id.placeQrCodeScanTextView) + cancelScanButton = view.findViewById(R.id.cancelImageButton) + cancelScanButton.setOnClickListener { dismiss() } + + val parent = view.parent as View + val behavior = BottomSheetBehavior.from(parent) + val layoutParams = parent.layoutParams + layoutParams.height = ViewGroup.LayoutParams.MATCH_PARENT + parent.layoutParams = layoutParams + behavior.maxHeight = (0.8 * Resources.getSystem().displayMetrics.heightPixels).toInt() + behavior.peekHeight = (0.8 * Resources.getSystem().displayMetrics.heightPixels).toInt() + behavior.state = BottomSheetBehavior.STATE_EXPANDED + behavior.isDraggable = false + + cameraExecutor = Executors.newSingleThreadExecutor() + cameraController = LifecycleCameraController(requireContext()) + mlKitImageAnalyzer = + MlKitAnalyzer( + listOf(barcodeScanner), + COORDINATE_SYSTEM_VIEW_REFERENCED, + ContextCompat.getMainExecutor(requireActivity()), + ) { result: MlKitAnalyzer.Result? -> + val barcodeResults = result?.getValue(barcodeScanner) + if ( + (barcodeResults == null) || (barcodeResults.size == 0) || (barcodeResults.first() == null) + ) { + return@MlKitAnalyzer + } + + onQrCodeDetected(barcodeResults[0]) + } + + cameraController.setImageAnalysisAnalyzer( + ContextCompat.getMainExecutor(requireActivity()), + mlKitImageAnalyzer, + ) + + parentFragmentManager.setFragmentResultListener( + CameraPermissionsDialogFragment.CAMERA_PERMISSION_REQUEST_RESULT_KEY, + this, + ) { _, result -> + val permissionGranted = + result.getBoolean(CameraPermissionsDialogFragment.CAMERA_PERMISSION_REQUEST_RESULT_KEY) + if (!permissionGranted) { + Toast.makeText( + requireActivity(), + requireContext().getString(R.string.barcode_camera_permission_denied), + Toast.LENGTH_SHORT, + ) + .show() + dismiss() + } + } + } + + override fun onResume() { + super.onResume() + + if ( + !PermissionUtils.checkPermissions( + requireContext(), + listOf(android.Manifest.permission.CAMERA), + ) + ) { + requestCameraPermissions() + } else { + bindCamera() + } + } + + private fun requestCameraPermissions() { + CameraPermissionsDialogFragment().show(parentFragmentManager, TAG) + } + + private fun bindCamera() { + cameraController.bindToLifecycle(this) + previewView.controller = cameraController + } + + private fun isBarcodeWithinExpectedBounds(barcode: Barcode): Boolean { + return barcode.boundingBox?.let { viewFinderBounds.toRect().contains(it) } ?: false + } + + @VisibleForTesting + fun onQrCodeDetected(barcode: Barcode) { + if (isBarcodeWithinExpectedBounds(barcode)) { + parentFragmentManager.setFragmentResult( + RESULT_REQUEST_KEY, + bundleOf(RESULT_REQUEST_KEY to barcode.rawValue), + ) + dismiss() + } else { + placeQrCodeScanTextView.visibility = View.VISIBLE + } + } + + override fun onDestroyView() { + super.onDestroyView() + cameraExecutor.shutdown() + barcodeScanner.close() + } + + companion object { + private const val TAG = "QRCodeScannerDialogFragment" + const val RESULT_REQUEST_KEY = "quest.ui.sdc.qrCode.scan.QRCodeScannerDialogFragment" + } +} diff --git a/android/quest/src/main/java/org/smartregister/fhircore/quest/util/QrCodeScanUtils.kt b/android/quest/src/main/java/org/smartregister/fhircore/quest/util/QrCodeScanUtils.kt index 2ad380b4e6..62de1fb396 100644 --- a/android/quest/src/main/java/org/smartregister/fhircore/quest/util/QrCodeScanUtils.kt +++ b/android/quest/src/main/java/org/smartregister/fhircore/quest/util/QrCodeScanUtils.kt @@ -17,7 +17,7 @@ package org.smartregister.fhircore.quest.util import androidx.fragment.app.FragmentActivity -import org.smartregister.fhircore.quest.ui.sdc.qrCode.QrCodeCameraDialogFragment +import org.smartregister.fhircore.quest.ui.sdc.qrCode.scan.QRCodeScannerDialogFragment object QrCodeScanUtils { @@ -26,14 +26,14 @@ object QrCodeScanUtils { fun scanQrCode(lifecycleOwner: FragmentActivity, onQrCodeScanResult: (String?) -> Unit) { lifecycleOwner.supportFragmentManager.apply { setFragmentResultListener( - QrCodeCameraDialogFragment.RESULT_REQUEST_KEY, + QRCodeScannerDialogFragment.RESULT_REQUEST_KEY, lifecycleOwner, ) { _, result -> - val barcode = result.getString(QrCodeCameraDialogFragment.RESULT_REQUEST_KEY) + val barcode = result.getString(QRCodeScannerDialogFragment.RESULT_REQUEST_KEY) onQrCodeScanResult.invoke(barcode) } - QrCodeCameraDialogFragment().show(this@apply, QR_CODE_SCAN_UTILS_TAG) + QRCodeScannerDialogFragment().show(this@apply, QR_CODE_SCAN_UTILS_TAG) } } } diff --git a/android/quest/src/main/res/drawable-hdpi/ic_scan_qr_viewfinder.png b/android/quest/src/main/res/drawable-hdpi/ic_scan_qr_viewfinder.png new file mode 100644 index 0000000000..063c1f5f48 Binary files /dev/null and b/android/quest/src/main/res/drawable-hdpi/ic_scan_qr_viewfinder.png differ diff --git a/android/quest/src/main/res/drawable-mdpi/ic_scan_qr_viewfinder.png b/android/quest/src/main/res/drawable-mdpi/ic_scan_qr_viewfinder.png new file mode 100644 index 0000000000..d76359263b Binary files /dev/null and b/android/quest/src/main/res/drawable-mdpi/ic_scan_qr_viewfinder.png differ diff --git a/android/quest/src/main/res/drawable-xhdpi/ic_scan_qr_viewfinder.png b/android/quest/src/main/res/drawable-xhdpi/ic_scan_qr_viewfinder.png new file mode 100644 index 0000000000..2537f0b952 Binary files /dev/null and b/android/quest/src/main/res/drawable-xhdpi/ic_scan_qr_viewfinder.png differ diff --git a/android/quest/src/main/res/drawable-xxhdpi/ic_scan_qr_viewfinder.png b/android/quest/src/main/res/drawable-xxhdpi/ic_scan_qr_viewfinder.png new file mode 100644 index 0000000000..da085ae8cb Binary files /dev/null and b/android/quest/src/main/res/drawable-xxhdpi/ic_scan_qr_viewfinder.png differ diff --git a/android/quest/src/main/res/drawable-xxxhdpi/ic_scan_qr_viewfinder.png b/android/quest/src/main/res/drawable-xxxhdpi/ic_scan_qr_viewfinder.png new file mode 100644 index 0000000000..b3fc69c15b Binary files /dev/null and b/android/quest/src/main/res/drawable-xxxhdpi/ic_scan_qr_viewfinder.png differ diff --git a/android/quest/src/main/res/layout/fragment_qr_code_scan.xml b/android/quest/src/main/res/layout/fragment_qr_code_scan.xml new file mode 100644 index 0000000000..1e5d2e3ded --- /dev/null +++ b/android/quest/src/main/res/layout/fragment_qr_code_scan.xml @@ -0,0 +1,80 @@ + + + + + + + + + + + + + + + + + + + + diff --git a/android/quest/src/main/res/values/strings.xml b/android/quest/src/main/res/values/strings.xml index 91402fb480..50fde9a582 100644 --- a/android/quest/src/main/res/values/strings.xml +++ b/android/quest/src/main/res/values/strings.xml @@ -129,4 +129,6 @@ Camera permission request denied. Barcode may not work as expected Place your camera over the QR Code to start scanning Add QR code + Scan QR Code + Place your camera over the entire QR Code to start scanning diff --git a/android/quest/src/test/java/org/smartregister/fhircore/quest/ui/sdc/qrCode/CameraPermissionsDialogFragmentTest.kt b/android/quest/src/test/java/org/smartregister/fhircore/quest/ui/sdc/qrCode/CameraPermissionsDialogFragmentTest.kt new file mode 100644 index 0000000000..d56dfa7494 --- /dev/null +++ b/android/quest/src/test/java/org/smartregister/fhircore/quest/ui/sdc/qrCode/CameraPermissionsDialogFragmentTest.kt @@ -0,0 +1,87 @@ +/* + * Copyright 2021-2024 Ona Systems, Inc + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.smartregister.fhircore.quest.ui.sdc.qrCode + +import android.Manifest +import android.app.Application +import androidx.fragment.app.commitNow +import androidx.test.core.app.ApplicationProvider +import dagger.hilt.android.testing.HiltAndroidRule +import dagger.hilt.android.testing.HiltAndroidTest +import io.mockk.spyk +import io.mockk.verify +import org.junit.Before +import org.junit.Rule +import org.junit.Test +import org.robolectric.Shadows.shadowOf +import org.robolectric.util.ReflectionHelpers +import org.smartregister.fhircore.quest.hiltActivityForTestScenario +import org.smartregister.fhircore.quest.robolectric.RobolectricTest + +@HiltAndroidTest +class CameraPermissionsDialogFragmentTest : RobolectricTest() { + @get:Rule(order = 0) var hiltAndroidRule = HiltAndroidRule(this) + + private val applicationContext = ApplicationProvider.getApplicationContext() + + @Before + fun setUp() { + hiltAndroidRule.inject() + } + + @Test + fun onResumeShouldNotLaunchCameraPermissionRequestWhenCameraPermissionGranted() { + shadowOf(applicationContext).grantPermissions(Manifest.permission.CAMERA) + hiltActivityForTestScenario().use { scenario -> + scenario.onActivity { activity -> + val qrCodeFragment = CameraPermissionsDialogFragment() + val cameraPermissionRequestSpy = spyk(qrCodeFragment.cameraPermissionRequest) + ReflectionHelpers.setField( + qrCodeFragment, + "cameraPermissionRequest", + cameraPermissionRequestSpy, + ) + + activity.supportFragmentManager.commitNow { + add(qrCodeFragment, CameraPermissionsDialogFragmentTest::class.java.simpleName) + } + verify(exactly = 0) { cameraPermissionRequestSpy.launch(Manifest.permission.CAMERA) } + } + } + } + + @Test + fun onResumeShouldLaunchCameraPermissionRequestWhenPermissionDenied() { + shadowOf(applicationContext).denyPermissions(Manifest.permission.CAMERA) + hiltActivityForTestScenario().use { scenario -> + scenario.onActivity { activity -> + val qrCodeFragment = CameraPermissionsDialogFragment() + val cameraPermissionRequestSpy = spyk(qrCodeFragment.cameraPermissionRequest) + ReflectionHelpers.setField( + qrCodeFragment, + "cameraPermissionRequest", + cameraPermissionRequestSpy, + ) + + activity.supportFragmentManager.commitNow { + add(qrCodeFragment, CameraPermissionsDialogFragmentTest::class.java.simpleName) + } + verify { cameraPermissionRequestSpy.launch(Manifest.permission.CAMERA) } + } + } + } +} diff --git a/android/quest/src/test/java/org/smartregister/fhircore/quest/ui/sdc/qrCode/EditTextQrCodeItemViewHolderFactoryTest.kt b/android/quest/src/test/java/org/smartregister/fhircore/quest/ui/sdc/qrCode/EditTextQrCodeItemViewHolderFactoryTest.kt index d04814f58a..7edddb6eca 100644 --- a/android/quest/src/test/java/org/smartregister/fhircore/quest/ui/sdc/qrCode/EditTextQrCodeItemViewHolderFactoryTest.kt +++ b/android/quest/src/test/java/org/smartregister/fhircore/quest/ui/sdc/qrCode/EditTextQrCodeItemViewHolderFactoryTest.kt @@ -45,6 +45,7 @@ import org.robolectric.Robolectric import org.smartregister.fhircore.quest.R import org.smartregister.fhircore.quest.hiltActivityForTestScenario import org.smartregister.fhircore.quest.robolectric.RobolectricTest +import org.smartregister.fhircore.quest.ui.sdc.qrCode.scan.QRCodeScannerDialogFragment import org.smartregister.fhircore.quest.util.QrCodeScanUtils @HiltAndroidTest @@ -71,7 +72,7 @@ class EditTextQrCodeItemViewHolderFactoryTest : RobolectricTest() { @Test fun shouldUpdateTextCorrectlyWhenScanQrCodeReceived() { - mockkConstructor(QrCodeCameraDialogFragment::class) + mockkConstructor(QRCodeScannerDialogFragment::class) val sampleQrCode = "d84fbd12-4f22-423a-8645-5525504e1bcb" /** * Using style 'com.google.android.material.R.style.Theme_Material3_DayNight' to prevent @@ -90,13 +91,13 @@ class EditTextQrCodeItemViewHolderFactoryTest : RobolectricTest() { textInputLayout.findViewById(R.id.text_input_edit_text) Assert.assertNotNull(textInputEditText) every { - anyConstructed() + anyConstructed() .show(any(), QrCodeScanUtils.QR_CODE_SCAN_UTILS_TAG) } answers { activity.supportFragmentManager.setFragmentResult( - QrCodeCameraDialogFragment.RESULT_REQUEST_KEY, - bundleOf(QrCodeCameraDialogFragment.RESULT_REQUEST_KEY to sampleQrCode), + QRCodeScannerDialogFragment.RESULT_REQUEST_KEY, + bundleOf(QRCodeScannerDialogFragment.RESULT_REQUEST_KEY to sampleQrCode), ) } @@ -106,7 +107,7 @@ class EditTextQrCodeItemViewHolderFactoryTest : RobolectricTest() { Assert.assertEquals(sampleQrCode, textInputEditText.text.toString()) } } - unmockkConstructor(QrCodeCameraDialogFragment::class) + unmockkConstructor(QRCodeScannerDialogFragment::class) } @Test diff --git a/android/quest/src/test/java/org/smartregister/fhircore/quest/ui/sdc/qrCode/QrCodeCameraDialogFragmentTest.kt b/android/quest/src/test/java/org/smartregister/fhircore/quest/ui/sdc/qrCode/QrCodeCameraDialogFragmentTest.kt deleted file mode 100644 index c8c891eb9e..0000000000 --- a/android/quest/src/test/java/org/smartregister/fhircore/quest/ui/sdc/qrCode/QrCodeCameraDialogFragmentTest.kt +++ /dev/null @@ -1,139 +0,0 @@ -/* - * Copyright 2021-2024 Ona Systems, Inc - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -package org.smartregister.fhircore.quest.ui.sdc.qrCode - -import android.Manifest -import android.app.Application -import android.widget.Toast -import androidx.core.os.bundleOf -import androidx.fragment.app.FragmentManager -import androidx.fragment.app.commitNow -import androidx.test.core.app.ApplicationProvider -import com.google.android.fhir.datacapture.contrib.views.barcode.mlkit.md.LiveBarcodeScanningFragment -import dagger.hilt.android.testing.HiltAndroidRule -import dagger.hilt.android.testing.HiltAndroidTest -import io.mockk.every -import io.mockk.just -import io.mockk.mockkConstructor -import io.mockk.runs -import io.mockk.spyk -import io.mockk.unmockkConstructor -import io.mockk.unmockkStatic -import io.mockk.verify -import org.junit.Assert -import org.junit.Before -import org.junit.Rule -import org.junit.Test -import org.robolectric.Shadows.shadowOf -import org.robolectric.util.ReflectionHelpers -import org.smartregister.fhircore.quest.hiltActivityForTestScenario -import org.smartregister.fhircore.quest.launchFragmentInHiltContainer -import org.smartregister.fhircore.quest.robolectric.RobolectricTest -import org.smartregister.fhircore.quest.ui.sdc.qrCode.QrCodeCameraDialogFragment.Companion.QR_CODE_SCANNER_FRAGMENT_TAG - -@HiltAndroidTest -class QrCodeCameraDialogFragmentTest : RobolectricTest() { - @get:Rule(order = 0) var hiltAndroidRule = HiltAndroidRule(this) - - private val applicationContext = ApplicationProvider.getApplicationContext() - - @Before - fun setUp() { - hiltAndroidRule.inject() - } - - @Test - fun onResumeShouldShowQrCodeScannerWhenCameraPermissionGranted() { - shadowOf(applicationContext).grantPermissions(Manifest.permission.CAMERA) - mockkConstructor(LiveBarcodeScanningFragment::class) - every { - anyConstructed().show(any(), any()) - } just runs - launchFragmentInHiltContainer { - verify { - anyConstructed() - .show( - this@launchFragmentInHiltContainer.parentFragmentManager, - QR_CODE_SCANNER_FRAGMENT_TAG, - ) - } - } - unmockkConstructor(LiveBarcodeScanningFragment::class) - } - - @Test - fun onResumeShouldReturnCorrectCodeReceivedFromQrCodeScanningWhenPermissionGranted() { - shadowOf(applicationContext).grantPermissions(Manifest.permission.CAMERA) - mockkConstructor(LiveBarcodeScanningFragment::class) - val sampleBarcodeResult = "13462889" - var receivedCode: String? = null - - hiltActivityForTestScenario().use { scenario -> - scenario.onActivity { activity -> - val activityFragmentManager = activity.supportFragmentManager - every { - anyConstructed().show(any(), any()) - } answers - { - activityFragmentManager.setFragmentResult( - LiveBarcodeScanningFragment.RESULT_REQUEST_KEY, - bundleOf(LiveBarcodeScanningFragment.RESULT_REQUEST_KEY to sampleBarcodeResult), - ) - } - activityFragmentManager.setFragmentResultListener( - QrCodeCameraDialogFragment.RESULT_REQUEST_KEY, - activity, - ) { _, result -> - val code = result.getString(QrCodeCameraDialogFragment.RESULT_REQUEST_KEY) - Assert.assertEquals(sampleBarcodeResult, code) - receivedCode = code - } - - activityFragmentManager.commitNow { - add(QrCodeCameraDialogFragment(), QrCodeCameraDialogFragmentTest::class.java.simpleName) - } - - Assert.assertNotNull(receivedCode) - Assert.assertEquals(sampleBarcodeResult, receivedCode) - } - } - - unmockkConstructor(LiveBarcodeScanningFragment::class) - } - - @Test - fun onResumeShouldLaunchCameraPermissionRequestWhenPermissionDenied() { - shadowOf(applicationContext).denyPermissions(Manifest.permission.CAMERA) - hiltActivityForTestScenario().use { scenario -> - scenario.onActivity { activity -> - val qrCodeFragment = QrCodeCameraDialogFragment() - val cameraPermissionRequestSpy = spyk(qrCodeFragment.cameraPermissionRequest) - ReflectionHelpers.setField( - qrCodeFragment, - "cameraPermissionRequest", - cameraPermissionRequestSpy, - ) - - activity.supportFragmentManager.commitNow { - add(qrCodeFragment, QrCodeCameraDialogFragmentTest::class.java.simpleName) - } - verify { cameraPermissionRequestSpy.launch(Manifest.permission.CAMERA) } - } - } - unmockkStatic(Toast::class) - } -} diff --git a/android/quest/src/test/java/org/smartregister/fhircore/quest/ui/sdc/qrCode/scan/QRCodeScannerDialogFragmentTest.kt b/android/quest/src/test/java/org/smartregister/fhircore/quest/ui/sdc/qrCode/scan/QRCodeScannerDialogFragmentTest.kt new file mode 100644 index 0000000000..efc3203659 --- /dev/null +++ b/android/quest/src/test/java/org/smartregister/fhircore/quest/ui/sdc/qrCode/scan/QRCodeScannerDialogFragmentTest.kt @@ -0,0 +1,146 @@ +/* + * Copyright 2021-2024 Ona Systems, Inc + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.smartregister.fhircore.quest.ui.sdc.qrCode.scan + +import android.Manifest +import android.app.Application +import androidx.core.graphics.toRect +import androidx.fragment.app.Fragment +import androidx.fragment.app.FragmentManager +import androidx.fragment.app.FragmentManager.FragmentLifecycleCallbacks +import androidx.test.core.app.ApplicationProvider +import com.google.mlkit.vision.barcode.BarcodeScanner +import com.google.mlkit.vision.barcode.BarcodeScannerOptions +import com.google.mlkit.vision.barcode.BarcodeScanning +import com.google.mlkit.vision.barcode.common.Barcode +import com.google.mlkit.vision.interfaces.Detector +import dagger.hilt.android.testing.HiltAndroidRule +import dagger.hilt.android.testing.HiltAndroidTest +import io.mockk.every +import io.mockk.just +import io.mockk.mockk +import io.mockk.mockkConstructor +import io.mockk.mockkStatic +import io.mockk.runs +import io.mockk.unmockkConstructor +import io.mockk.unmockkStatic +import io.mockk.verify +import org.junit.After +import org.junit.Assert +import org.junit.Before +import org.junit.Rule +import org.junit.Test +import org.robolectric.Shadows.shadowOf +import org.smartregister.fhircore.quest.hiltActivityForTestScenario +import org.smartregister.fhircore.quest.robolectric.RobolectricTest +import org.smartregister.fhircore.quest.ui.sdc.qrCode.CameraPermissionsDialogFragment + +@HiltAndroidTest +class QRCodeScannerDialogFragmentTest : RobolectricTest() { + @get:Rule(order = 0) var hiltAndroidRule = HiltAndroidRule(this) + + private val application = ApplicationProvider.getApplicationContext() + + @Before + fun setUp() { + hiltAndroidRule.inject() + } + + @Before + fun setUpQRCodeScannerDialogFragmentMocks() { + val barcodeScanner = + mockk() { + every { detectorType } returns Detector.TYPE_BARCODE_SCANNING + every { close() } just runs + } + mockkStatic(BarcodeScanning::class) + every { BarcodeScanning.getClient(any()) } returns barcodeScanner + mockkConstructor(CameraPermissionsDialogFragment::class) + every { + anyConstructed().show(any(), any()) + } just runs + } + + @After + fun unSetUpQRCodeScannerDialogFragmentMocks() { + unmockkConstructor(CameraPermissionsDialogFragment::class) + unmockkStatic(BarcodeScanning::class) + } + + @Test + fun shouldShowQrCodeCameraPermissionsDialogWhenNoPermission() { + shadowOf(application).denyPermissions(Manifest.permission.CAMERA) + + hiltActivityForTestScenario().use { scenario -> + scenario.onActivity { activity -> + QRCodeScannerDialogFragment() + .show( + activity.supportFragmentManager, + "shouldShowQrCodeCameraPermissionsDialogWhenNoPermission", + ) + } + } + + verify { + anyConstructed().show(any(), any()) + } + } + + @Test + fun shouldSetFragmentWhenBarCodeDetectedWithinBounds() { + shadowOf(application).grantPermissions(Manifest.permission.CAMERA) + + var barcodeValueResult: String? = null + hiltActivityForTestScenario().use { scenario -> + scenario.onActivity { activity -> + QRCodeScannerDialogFragment() + .show( + activity.supportFragmentManager, + "shouldShowQrCodeCameraPermissionsDialogWhenNoPermission", + ) + activity.supportFragmentManager.setFragmentResultListener( + QRCodeScannerDialogFragment.RESULT_REQUEST_KEY, + activity, + ) { _, result -> + barcodeValueResult = result.getString(QRCodeScannerDialogFragment.RESULT_REQUEST_KEY)!! + } + + activity.supportFragmentManager.registerFragmentLifecycleCallbacks( + object : FragmentLifecycleCallbacks() { + override fun onFragmentResumed(fm: FragmentManager, f: Fragment) { + super.onFragmentResumed(fm, f) + if (f is QRCodeScannerDialogFragment) { + val sampleBarcodeWithinBounds = mockk() + every { sampleBarcodeWithinBounds.boundingBox } returns + f.viewFinderBounds.toRect().apply { + bottom -= 20 + right -= 20 + } + every { sampleBarcodeWithinBounds.rawValue } returns + "ad2ae0df-01dd-4e65-a3ee-01e3174b5744" + + f.onQrCodeDetected(sampleBarcodeWithinBounds) + } + } + }, + false, + ) + } + } + Assert.assertEquals("ad2ae0df-01dd-4e65-a3ee-01e3174b5744", barcodeValueResult) + } +} diff --git a/android/quest/src/test/java/org/smartregister/fhircore/quest/util/QrCodeScanUtilsTest.kt b/android/quest/src/test/java/org/smartregister/fhircore/quest/util/QrCodeScanUtilsTest.kt index 1787e9d8aa..38a7e6eb74 100644 --- a/android/quest/src/test/java/org/smartregister/fhircore/quest/util/QrCodeScanUtilsTest.kt +++ b/android/quest/src/test/java/org/smartregister/fhircore/quest/util/QrCodeScanUtilsTest.kt @@ -30,7 +30,7 @@ import org.junit.Rule import org.junit.Test import org.smartregister.fhircore.quest.hiltActivityForTestScenario import org.smartregister.fhircore.quest.robolectric.RobolectricTest -import org.smartregister.fhircore.quest.ui.sdc.qrCode.QrCodeCameraDialogFragment +import org.smartregister.fhircore.quest.ui.sdc.qrCode.scan.QRCodeScannerDialogFragment @HiltAndroidTest class QrCodeScanUtilsTest : RobolectricTest() { @@ -39,13 +39,13 @@ class QrCodeScanUtilsTest : RobolectricTest() { @Before fun setUp() { hiltAndroidRule.inject() - mockkConstructor(QrCodeCameraDialogFragment::class) + mockkConstructor(QRCodeScannerDialogFragment::class) } @After override fun tearDown() { super.tearDown() - unmockkConstructor(QrCodeCameraDialogFragment::class) + unmockkConstructor(QRCodeScannerDialogFragment::class) } @Test @@ -57,13 +57,13 @@ class QrCodeScanUtilsTest : RobolectricTest() { scenario.onActivity { activity -> val sampleQrCode = "d84fbd12-4f22-423a-8645-5525504e1bcb" every { - anyConstructed() + anyConstructed() .show(any(), QrCodeScanUtils.QR_CODE_SCAN_UTILS_TAG) } answers { activity.supportFragmentManager.setFragmentResult( - QrCodeCameraDialogFragment.RESULT_REQUEST_KEY, - bundleOf(QrCodeCameraDialogFragment.RESULT_REQUEST_KEY to sampleQrCode), + QRCodeScannerDialogFragment.RESULT_REQUEST_KEY, + bundleOf(QRCodeScannerDialogFragment.RESULT_REQUEST_KEY to sampleQrCode), ) } QrCodeScanUtils.scanQrCode(activity, onQrCodeScanListener) diff --git a/docs/engineering/app/configuring/config-types/forms/questionnaire.mdx b/docs/engineering/app/configuring/config-types/forms/questionnaire.mdx index c71464ab21..225be946ae 100644 --- a/docs/engineering/app/configuring/config-types/forms/questionnaire.mdx +++ b/docs/engineering/app/configuring/config-types/forms/questionnaire.mdx @@ -552,7 +552,7 @@ Below is the specific extension for this. The extension is validated in this cla ## QR Code Support -QR Code widget can be set up for a Questionnaire by adding `qr_code-widget` extension to a QuestionnaireItem +QR Code widget can be set up for a Questionnaire by adding `qr_code-widget` extension, with url `https://github.com/opensrp/android-fhir/StructureDefinition/qr-code-widget`, to a QuestionnaireItem ```json { @@ -560,22 +560,22 @@ QR Code widget can be set up for a Questionnaire by adding `qr_code-widget` exte "extension": [] } ``` -The QR Code widget can also be configured to allow only setting the QR code and then have the field as readOnly by using `qr-code-entry-mode` extension +The QR Code widget extension can be configured to take another extension with url `set-only-readonly` that takes in a value `Boolean`, which when set to `true`, the QR code widget only allows set QR code once and thereafter the field would behave as `readOnly` ```json { "url": "https://github.com/opensrp/android-fhir/StructureDefinition/qr-code-widget", "extension": [ { - "url": "qr-code-entry-mode", - "valueString": "set-only-readonly" + "url": "set-only-readonly", + "valueBoolean": true } ] } ``` -Other option for the `qr-code-entry-mode` extension is `"normal"` whereby the widget would allow a new scan to repopulate the field +Normal behaviour of the `qr_code-widget` extension or if the `set-only-readonly` extension has a value of `false`, would be to allow setting QR code multiple times whereby subsequent QR codes would replace current -The QR code widget supports adding an arbitrary number of QR codes by setting QuestionnaireItem to `"repeats": true` +The QR code widget supports adding an arbitrary number of QR codes, implemented by showing `+Add QR Code` button. This can be configured by setting QuestionnaireItem with the `qr_code-widget` extension to `"repeats": true` ```json "repeats": true, @@ -584,11 +584,12 @@ The QR code widget supports adding an arbitrary number of QR codes by setting Qu "url": "https://github.com/opensrp/android-fhir/StructureDefinition/qr-code-widget", "extension": [ { - "url": "qr-code-entry-mode", - "valueString": "set-only-readonly" + "url": "set-only-readonly", + "valueBoolean": true } ] } ] } ``` +The extension's implementation can be found [here](https://github.com/opensrp/fhircore/blob/main/android/quest/src/main/java/org/smartregister/fhircore/quest/ui/sdc/qrCode/EditTextQrCodeViewHolderFactory.kt) \ No newline at end of file