diff --git a/android/engine/build.gradle.kts b/android/engine/build.gradle.kts index ba0bd27d1b..311aaa1ac0 100644 --- a/android/engine/build.gradle.kts +++ b/android/engine/build.gradle.kts @@ -160,7 +160,6 @@ dependencies { api(libs.jjwt) api(libs.fhir.common.utils) { exclude(group = "org.slf4j", module = "jcl-over-slf4j") } api(libs.runtime.livedata) -// api(libs.material3) api(libs.foundation) api(libs.fhir.common.utils) api(libs.kotlinx.serialization.json) diff --git a/android/engine/src/main/java/org/smartregister/fhircore/engine/configuration/app/ApplicationConfiguration.kt b/android/engine/src/main/java/org/smartregister/fhircore/engine/configuration/app/ApplicationConfiguration.kt index e686b3156a..2906d1e715 100644 --- a/android/engine/src/main/java/org/smartregister/fhircore/engine/configuration/app/ApplicationConfiguration.kt +++ b/android/engine/src/main/java/org/smartregister/fhircore/engine/configuration/app/ApplicationConfiguration.kt @@ -41,4 +41,9 @@ data class ApplicationConfiguration( val showLogo: Boolean = true, val taskBackgroundWorkerBatchSize: Int = 500, val eventWorkflows: List = emptyList(), + val logGpsLocation: List = emptyList(), ) : Configuration() + +enum class LocationLogOptions { + QUESTIONNAIRE, +} diff --git a/android/gradle/libs.versions.toml b/android/gradle/libs.versions.toml index 0681acd4f1..a136b8bbb7 100644 --- a/android/gradle/libs.versions.toml +++ b/android/gradle/libs.versions.toml @@ -57,6 +57,9 @@ okhttp = "4.12.0" okhttp-logging-interceptor = "4.11.0" orchestrator = "1.4.2" p2p-lib = "0.6.9-SNAPSHOT" +paging-compose = "3.2.0" +paging-runtime-ktx = "3.2.0" +playServicesLocation = "21.0.1" paging = "3.2.1" preference-ktx = "1.2.1" prettytime = "5.0.2.Final" @@ -166,8 +169,9 @@ okhttp = { group = "com.squareup.okhttp3", name = "okhttp", version.ref = "okhtt okhttp-logging-interceptor = { group = "com.squareup.okhttp3", name = "logging-interceptor", version.ref = "okhttp-logging-interceptor" } orchestrator = { group = "androidx.test", name = "orchestrator", version.ref = "orchestrator" } p2p-lib = { group = "org.smartregister", name = "p2p-lib", version.ref = "p2p-lib" } -paging-compose = { group = "androidx.paging", name = "paging-compose", version.ref = "paging" } -paging-runtime-ktx = { group = "androidx.paging", name = "paging-runtime-ktx", version.ref = "paging" } +paging-compose = { group = "androidx.paging", name = "paging-compose", version.ref = "paging-compose" } +paging-runtime-ktx = { group = "androidx.paging", name = "paging-runtime-ktx", version.ref = "paging-runtime-ktx" } +play-services-location = { module = "com.google.android.gms:play-services-location", version.ref = "playServicesLocation" } preference-ktx = { group = "androidx.preference", name = "preference-ktx", version.ref = "preference-ktx" } prettytime = { group = "org.ocpsoft.prettytime", name = "prettytime", version.ref = "prettytime" } retrofit = { group = "com.squareup.retrofit2", name = "retrofit", version.ref = "retrofit" } diff --git a/android/quest/build.gradle.kts b/android/quest/build.gradle.kts index 000d28d9b6..e9d7a2411f 100644 --- a/android/quest/build.gradle.kts +++ b/android/quest/build.gradle.kts @@ -381,6 +381,7 @@ dependencies { implementation(libs.material) implementation(libs.dagger.hilt.android) implementation(libs.hilt.work) + implementation(libs.play.services.location) // Annotation processors kapt(libs.hilt.compiler) diff --git a/android/quest/src/androidTest/java/org/smartregister/fhircore/quest/integration/ui/register/components/RegisterCardListTest.kt b/android/quest/src/androidTest/java/org/smartregister/fhircore/quest/integration/ui/register/components/RegisterCardListTest.kt index 8696a0bcbe..42cab5ea82 100644 --- a/android/quest/src/androidTest/java/org/smartregister/fhircore/quest/integration/ui/register/components/RegisterCardListTest.kt +++ b/android/quest/src/androidTest/java/org/smartregister/fhircore/quest/integration/ui/register/components/RegisterCardListTest.kt @@ -87,7 +87,7 @@ class RegisterCardListTest { ) } - composeTestRule.onNodeWithTag(REGISTER_CARD_LIST_TEST_TAG).onChildren().assertCountEquals(2) + composeTestRule.onNodeWithTag(REGISTER_CARD_LIST_TEST_TAG).onChildren().assertCountEquals(3) composeTestRule .onNodeWithTag(REGISTER_CARD_LIST_TEST_TAG) @@ -119,7 +119,7 @@ class RegisterCardListTest { ) } - composeTestRule.onNodeWithTag(REGISTER_CARD_LIST_TEST_TAG).onChildren().assertCountEquals(3) + composeTestRule.onNodeWithTag(REGISTER_CARD_LIST_TEST_TAG).onChildren().assertCountEquals(4) composeTestRule .onNodeWithTag(REGISTER_CARD_LIST_TEST_TAG) diff --git a/android/quest/src/main/AndroidManifest.xml b/android/quest/src/main/AndroidManifest.xml index f078c2b0dd..3115ebf4d9 100644 --- a/android/quest/src/main/AndroidManifest.xml +++ b/android/quest/src/main/AndroidManifest.xml @@ -91,4 +91,7 @@ android:resource="@xml/file_paths" /> + + + diff --git a/android/quest/src/main/assets/configs/app/application_config.json b/android/quest/src/main/assets/configs/app/application_config.json index 646a8ea843..2e4a334048 100644 --- a/android/quest/src/main/assets/configs/app/application_config.json +++ b/android/quest/src/main/assets/configs/app/application_config.json @@ -81,5 +81,8 @@ } ] } + ], + "logGpsLocation": [ + "QUESTIONNAIRE" ] } diff --git a/android/quest/src/main/java/org/smartregister/fhircore/quest/ui/questionnaire/QuestionnaireActivity.kt b/android/quest/src/main/java/org/smartregister/fhircore/quest/ui/questionnaire/QuestionnaireActivity.kt index ce6abb06ad..f310d8612d 100644 --- a/android/quest/src/main/java/org/smartregister/fhircore/quest/ui/questionnaire/QuestionnaireActivity.kt +++ b/android/quest/src/main/java/org/smartregister/fhircore/quest/ui/questionnaire/QuestionnaireActivity.kt @@ -16,34 +16,44 @@ package org.smartregister.fhircore.quest.ui.questionnaire +import android.Manifest import android.app.Activity import android.app.AlertDialog import android.content.Intent +import android.location.Location import android.os.Bundle import android.os.Parcelable +import android.provider.Settings import android.view.View +import android.widget.Toast import androidx.activity.OnBackPressedCallback +import androidx.activity.result.ActivityResultLauncher import androidx.activity.viewModels import androidx.core.os.bundleOf import androidx.fragment.app.commit import androidx.lifecycle.lifecycleScope import com.google.android.fhir.datacapture.QuestionnaireFragment import com.google.android.fhir.logicalId +import com.google.android.gms.location.FusedLocationProviderClient +import com.google.android.gms.location.LocationServices import dagger.hilt.android.AndroidEntryPoint import java.io.Serializable import java.util.LinkedList +import javax.inject.Inject import kotlinx.coroutines.launch import org.hl7.fhir.r4.model.Questionnaire import org.hl7.fhir.r4.model.QuestionnaireResponse import org.hl7.fhir.r4.model.Resource import org.hl7.fhir.r4.model.ResourceType import org.smartregister.fhircore.engine.configuration.QuestionnaireConfig +import org.smartregister.fhircore.engine.configuration.app.LocationLogOptions import org.smartregister.fhircore.engine.domain.model.ActionParameter import org.smartregister.fhircore.engine.domain.model.ActionParameterType import org.smartregister.fhircore.engine.domain.model.isEditable import org.smartregister.fhircore.engine.domain.model.isReadOnly import org.smartregister.fhircore.engine.ui.base.AlertDialogue import org.smartregister.fhircore.engine.ui.base.BaseMultiLanguageActivity +import org.smartregister.fhircore.engine.util.DispatcherProvider import org.smartregister.fhircore.engine.util.extension.clearText import org.smartregister.fhircore.engine.util.extension.encodeResourceToString import org.smartregister.fhircore.engine.util.extension.parcelable @@ -51,20 +61,29 @@ import org.smartregister.fhircore.engine.util.extension.parcelableArrayList import org.smartregister.fhircore.engine.util.extension.showToast import org.smartregister.fhircore.quest.R import org.smartregister.fhircore.quest.databinding.QuestionnaireActivityBinding +import org.smartregister.fhircore.quest.util.LocationUtils +import org.smartregister.fhircore.quest.util.PermissionUtils +import org.smartregister.fhircore.quest.util.ResourceUtils import timber.log.Timber @AndroidEntryPoint class QuestionnaireActivity : BaseMultiLanguageActivity() { + @Inject lateinit var dispatcherProvider: DispatcherProvider val viewModel by viewModels() private lateinit var questionnaireConfig: QuestionnaireConfig private lateinit var actionParameters: ArrayList private lateinit var viewBinding: QuestionnaireActivityBinding private var questionnaire: Questionnaire? = null private var alertDialog: AlertDialog? = null + private lateinit var fusedLocationClient: FusedLocationProviderClient + var currentLocation: Location? = null + private lateinit var locationPermissionLauncher: ActivityResultLauncher> + private lateinit var activityResultLauncher: ActivityResultLauncher override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) + setTheme(org.smartregister.fhircore.engine.R.style.AppTheme_Questionnaire) viewBinding = QuestionnaireActivityBinding.inflate(layoutInflater) setContentView(viewBinding.root) @@ -97,6 +116,8 @@ class QuestionnaireActivity : BaseMultiLanguageActivity() { if (savedInstanceState == null) renderQuestionnaire() + setupLocationServices() + this.onBackPressedDispatcher.addCallback( this, object : OnBackPressedCallback(true) { @@ -107,6 +128,103 @@ class QuestionnaireActivity : BaseMultiLanguageActivity() { ) } + fun setupLocationServices() { + if ( + viewModel.applicationConfiguration.logGpsLocation.contains(LocationLogOptions.QUESTIONNAIRE) + ) { + fusedLocationClient = LocationServices.getFusedLocationProviderClient(this) + + if (!LocationUtils.isLocationEnabled(this)) { + openLocationServicesSettings() + } + + if (!hasLocationPermissions()) { + launchLocationPermissionsDialog() + } + + if (LocationUtils.isLocationEnabled(this) && hasLocationPermissions()) { + fetchLocation(true) + } + } + } + + fun hasLocationPermissions(): Boolean { + return PermissionUtils.checkPermissions( + this, + listOf( + Manifest.permission.ACCESS_COARSE_LOCATION, + Manifest.permission.ACCESS_FINE_LOCATION, + ), + ) + } + + fun openLocationServicesSettings() { + activityResultLauncher = + PermissionUtils.getStartActivityForResultLauncher(this) { resultCode, _ -> + if (resultCode == RESULT_OK || hasLocationPermissions()) { + fetchLocation() + } + } + + val intent = Intent(Settings.ACTION_LOCATION_SOURCE_SETTINGS) + showLocationSettingsDialog(intent) + } + + private fun showLocationSettingsDialog(intent: Intent) { + viewModel.setProgressState(QuestionnaireProgressState.QuestionnaireLaunch(false)) + AlertDialog.Builder(this) + .setMessage(getString(R.string.location_services_disabled)) + .setCancelable(true) + .setPositiveButton(getString(R.string.yes)) { _, _ -> activityResultLauncher.launch(intent) } + .setNegativeButton(getString(R.string.no)) { dialog, _ -> dialog.cancel() } + .show() + } + + fun launchLocationPermissionsDialog() { + locationPermissionLauncher = + PermissionUtils.getLocationPermissionLauncher( + this, + onFineLocationPermissionGranted = { fetchLocation(true) }, + onCoarseLocationPermissionGranted = { fetchLocation(false) }, + onLocationPermissionDenied = { + Toast.makeText( + this, + getString(R.string.location_permissions_denied), + Toast.LENGTH_SHORT, + ) + .show() + Timber.e("Location permissions denied") + }, + ) + + locationPermissionLauncher.launch( + arrayOf( + Manifest.permission.ACCESS_FINE_LOCATION, + Manifest.permission.ACCESS_COARSE_LOCATION, + ), + ) + } + + fun fetchLocation(highAccuracy: Boolean = true) { + lifecycleScope.launch { + try { + if (highAccuracy) { + currentLocation = + LocationUtils.getAccurateLocation(fusedLocationClient, dispatcherProvider.io()) + } else { + currentLocation = + LocationUtils.getApproximateLocation(fusedLocationClient, dispatcherProvider.io()) + } + } catch (e: Exception) { + Timber.e(e, "Failed to get GPS location for questionnaire: ${questionnaireConfig.id}") + } finally { + if (currentLocation == null) { + this@QuestionnaireActivity.showToast("Failed to get GPS location", Toast.LENGTH_LONG) + } + } + } + } + override fun onSaveInstanceState(outState: Bundle) { super.onSaveInstanceState(outState) outState.clear() @@ -241,6 +359,13 @@ class QuestionnaireActivity : BaseMultiLanguageActivity() { if (questionnaireResponse != null && questionnaire != null) { viewModel.run { setProgressState(QuestionnaireProgressState.ExtractionInProgress(true)) + + if (currentLocation != null) { + questionnaireResponse.contained.add( + ResourceUtils.createFhirLocationFromGpsLocation(gpsLocation = currentLocation!!), + ) + } + handleQuestionnaireSubmission( questionnaire = questionnaire!!, currentQuestionnaireResponse = questionnaireResponse, diff --git a/android/quest/src/main/java/org/smartregister/fhircore/quest/ui/questionnaire/QuestionnaireViewModel.kt b/android/quest/src/main/java/org/smartregister/fhircore/quest/ui/questionnaire/QuestionnaireViewModel.kt index c5fa0c1ee2..811bd04452 100644 --- a/android/quest/src/main/java/org/smartregister/fhircore/quest/ui/questionnaire/QuestionnaireViewModel.kt +++ b/android/quest/src/main/java/org/smartregister/fhircore/quest/ui/questionnaire/QuestionnaireViewModel.kt @@ -58,9 +58,12 @@ import org.hl7.fhir.r4.model.Resource import org.hl7.fhir.r4.model.ResourceType import org.hl7.fhir.r4.model.StringType import org.smartregister.fhircore.engine.BuildConfig +import org.smartregister.fhircore.engine.configuration.ConfigType +import org.smartregister.fhircore.engine.configuration.ConfigurationRegistry import org.smartregister.fhircore.engine.configuration.GroupResourceConfig import org.smartregister.fhircore.engine.configuration.LinkIdType import org.smartregister.fhircore.engine.configuration.QuestionnaireConfig +import org.smartregister.fhircore.engine.configuration.app.ApplicationConfiguration import org.smartregister.fhircore.engine.data.local.DefaultRepository import org.smartregister.fhircore.engine.domain.model.ActionParameter import org.smartregister.fhircore.engine.domain.model.ActionParameterType @@ -104,6 +107,7 @@ constructor( val sharedPreferencesHelper: SharedPreferencesHelper, val fhirOperator: FhirOperator, val fhirPathDataExtractor: FhirPathDataExtractor, + val configurationRegistry: ConfigurationRegistry, ) : ViewModel() { private val parser = FhirContext.forR4Cached().newJsonParser() @@ -121,6 +125,10 @@ constructor( val questionnaireProgressStateLiveData: LiveData get() = _questionnaireProgressStateLiveData + val applicationConfiguration: ApplicationConfiguration by lazy { + configurationRegistry.retrieveConfiguration(ConfigType.Application) + } + /** * This function retrieves the [Questionnaire] as configured via the [QuestionnaireConfig]. The * retrieved [Questionnaire] can be pre-populated with computed values from the Rules engine. diff --git a/android/quest/src/main/java/org/smartregister/fhircore/quest/util/LocationUtils.kt b/android/quest/src/main/java/org/smartregister/fhircore/quest/util/LocationUtils.kt new file mode 100644 index 0000000000..3632f7aea2 --- /dev/null +++ b/android/quest/src/main/java/org/smartregister/fhircore/quest/util/LocationUtils.kt @@ -0,0 +1,106 @@ +/* + * 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.util + +import android.annotation.SuppressLint +import android.content.Context +import android.location.Location +import android.location.LocationManager +import com.google.android.gms.location.FusedLocationProviderClient +import com.google.android.gms.location.Priority +import com.google.android.gms.tasks.CancellationToken +import com.google.android.gms.tasks.CancellationTokenSource +import com.google.android.gms.tasks.OnTokenCanceledListener +import kotlin.coroutines.CoroutineContext +import kotlin.coroutines.resume +import kotlin.coroutines.resumeWithException +import kotlin.coroutines.suspendCoroutine +import kotlinx.coroutines.withContext +import timber.log.Timber + +object LocationUtils { + + fun isLocationEnabled(context: Context): Boolean { + val locationManager = context.getSystemService(Context.LOCATION_SERVICE) as LocationManager + + return locationManager.isProviderEnabled(LocationManager.GPS_PROVIDER) || + locationManager.isProviderEnabled(LocationManager.NETWORK_PROVIDER) + } + + @SuppressLint("MissingPermission") + suspend fun getAccurateLocation( + fusedLocationClient: FusedLocationProviderClient, + coroutineContext: CoroutineContext, + ): Location? { + return withContext(coroutineContext) { + suspendCoroutine { continuation -> + fusedLocationClient + .getCurrentLocation( + Priority.PRIORITY_HIGH_ACCURACY, + object : CancellationToken() { + override fun onCanceledRequested(p0: OnTokenCanceledListener) = + CancellationTokenSource().token + + override fun isCancellationRequested() = false + }, + ) + .addOnSuccessListener { location: Location? -> + if (location != null) { + Timber.d( + "Accurate location - lat: ${location.latitude}; long: ${location.longitude}; alt: ${location.altitude}", + ) + continuation.resume(location) + } + } + .addOnFailureListener { e -> + Timber.e(e, "Failed to get accurate location") + continuation.resumeWithException(e) + } + } + } + } + + @SuppressLint("MissingPermission") + suspend fun getApproximateLocation( + fusedLocationClient: FusedLocationProviderClient, + coroutineContext: CoroutineContext, + ): Location? { + return withContext(coroutineContext) { + suspendCoroutine { continuation -> + fusedLocationClient + .getCurrentLocation( + Priority.PRIORITY_BALANCED_POWER_ACCURACY, + object : CancellationToken() { + override fun onCanceledRequested(p0: OnTokenCanceledListener) = + CancellationTokenSource().token + + override fun isCancellationRequested() = false + }, + ) + .addOnSuccessListener { location: Location? -> + if (location != null) { + Timber.d( + "Approximate location - lat: ${location.latitude}; long: ${location.longitude}; alt: ${location.altitude}", + ) + continuation.resume(location) + } + } + .addOnFailureListener { e -> continuation.resumeWithException(e) } + } + } + } +} diff --git a/android/quest/src/main/java/org/smartregister/fhircore/quest/util/PermissionUtils.kt b/android/quest/src/main/java/org/smartregister/fhircore/quest/util/PermissionUtils.kt new file mode 100644 index 0000000000..b179e0b1b7 --- /dev/null +++ b/android/quest/src/main/java/org/smartregister/fhircore/quest/util/PermissionUtils.kt @@ -0,0 +1,70 @@ +/* + * 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.util + +import android.Manifest +import android.content.Context +import android.content.Intent +import android.content.pm.PackageManager +import androidx.activity.result.ActivityResultLauncher +import androidx.activity.result.contract.ActivityResultContracts +import androidx.appcompat.app.AppCompatActivity +import androidx.core.content.ContextCompat + +object PermissionUtils { + + fun checkPermissions(context: Context, permissions: List): Boolean { + for (permission in permissions) { + if ( + ContextCompat.checkSelfPermission(context, permission) != PackageManager.PERMISSION_GRANTED + ) { + return false + } + } + return true + } + + fun getLocationPermissionLauncher( + activity: AppCompatActivity, + onFineLocationPermissionGranted: () -> Unit, + onCoarseLocationPermissionGranted: () -> Unit, + onLocationPermissionDenied: () -> Unit, + ): ActivityResultLauncher> { + return activity.registerForActivityResult( + ActivityResultContracts.RequestMultiplePermissions(), + ) { permissions -> + if (permissions[Manifest.permission.ACCESS_FINE_LOCATION] == true) { + onFineLocationPermissionGranted() + } else if (permissions[Manifest.permission.ACCESS_COARSE_LOCATION] == true) { + onCoarseLocationPermissionGranted() + } else { + onLocationPermissionDenied() + } + } + } + + fun getStartActivityForResultLauncher( + activity: AppCompatActivity, + onResult: (resultCode: Int, data: Intent?) -> Unit, + ): ActivityResultLauncher { + return activity.registerForActivityResult( + ActivityResultContracts.StartActivityForResult(), + ) { result -> + onResult(result.resultCode, result.data) + } + } +} diff --git a/android/quest/src/main/java/org/smartregister/fhircore/quest/util/ResourceUtils.kt b/android/quest/src/main/java/org/smartregister/fhircore/quest/util/ResourceUtils.kt new file mode 100644 index 0000000000..56e4166ef3 --- /dev/null +++ b/android/quest/src/main/java/org/smartregister/fhircore/quest/util/ResourceUtils.kt @@ -0,0 +1,39 @@ +/* + * 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.util + +import java.util.UUID +import org.hl7.fhir.r4.model.Location + +typealias GpsLocation = android.location.Location + +object ResourceUtils { + fun createFhirLocationFromGpsLocation( + gpsLocation: GpsLocation, + fhirLocation: Location? = null, + ): Location { + return (fhirLocation ?: Location()).apply { + id = UUID.randomUUID().toString() + position = + Location.LocationPositionComponent().apply { + latitude = gpsLocation.latitude.toBigDecimal() + longitude = gpsLocation.longitude.toBigDecimal() + altitude = gpsLocation.altitude.toBigDecimal() + } + } + } +} diff --git a/android/quest/src/main/res/values/strings.xml b/android/quest/src/main/res/values/strings.xml index 65c7366a0e..172380e715 100644 --- a/android/quest/src/main/res/values/strings.xml +++ b/android/quest/src/main/res/values/strings.xml @@ -109,6 +109,11 @@ Processing questionnaire data… Loading questionnaire… Clear All + Yes + No + + Location Access Denied: To capture GPS coordinates, please enable location permissions in your device settings. + Location services are disabled. Do you want to enable them? Link %1$s copied successfully diff --git a/android/quest/src/test/assets/configs/app/application_config.json b/android/quest/src/test/assets/configs/app/application_config.json index 9cbadf77c9..85a96d76b3 100644 --- a/android/quest/src/test/assets/configs/app/application_config.json +++ b/android/quest/src/test/assets/configs/app/application_config.json @@ -28,5 +28,8 @@ "Questionnaire", "QuestionnaireResponse" ] - } + }, + "logGpsLocation": [ + "QUESTIONNAIRE" + ] } diff --git a/android/quest/src/test/java/org/smartregister/fhircore/quest/ui/questionnaire/QuestionnaireActivityTest.kt b/android/quest/src/test/java/org/smartregister/fhircore/quest/ui/questionnaire/QuestionnaireActivityTest.kt index d62c52320b..ffc7ae4d51 100644 --- a/android/quest/src/test/java/org/smartregister/fhircore/quest/ui/questionnaire/QuestionnaireActivityTest.kt +++ b/android/quest/src/test/java/org/smartregister/fhircore/quest/ui/questionnaire/QuestionnaireActivityTest.kt @@ -19,11 +19,14 @@ package org.smartregister.fhircore.quest.ui.questionnaire import android.app.Application import android.content.Context import android.content.Intent +import android.location.LocationManager +import android.provider.Settings import android.widget.Toast import androidx.test.core.app.ApplicationProvider import com.google.android.fhir.FhirEngine import com.google.android.fhir.datacapture.QuestionnaireFragment import com.google.android.fhir.db.ResourceNotFoundException +import com.google.android.gms.location.LocationServices import dagger.hilt.android.testing.BindValue import dagger.hilt.android.testing.HiltAndroidRule import dagger.hilt.android.testing.HiltAndroidTest @@ -37,6 +40,10 @@ import io.mockk.spyk import io.mockk.unmockkStatic import io.mockk.verify import javax.inject.Inject +import junit.framework.TestCase.assertEquals +import junit.framework.TestCase.assertFalse +import junit.framework.TestCase.assertTrue +import kotlin.test.assertNotNull import kotlin.time.Duration.Companion.seconds import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.test.advanceUntilIdle @@ -50,11 +57,13 @@ import org.junit.Before import org.junit.Rule import org.junit.Test import org.robolectric.Robolectric -import org.robolectric.Shadows +import org.robolectric.Shadows.shadowOf import org.robolectric.android.controller.ActivityController import org.robolectric.shadows.ShadowAlertDialog import org.robolectric.shadows.ShadowToast +import org.smartregister.fhircore.engine.configuration.ConfigurationRegistry import org.smartregister.fhircore.engine.configuration.QuestionnaireConfig +import org.smartregister.fhircore.engine.configuration.app.LocationLogOptions import org.smartregister.fhircore.engine.data.local.DefaultRepository import org.smartregister.fhircore.engine.domain.model.ActionParameter import org.smartregister.fhircore.engine.domain.model.ActionParameterType @@ -62,7 +71,9 @@ import org.smartregister.fhircore.engine.domain.model.RuleConfig import org.smartregister.fhircore.engine.util.DispatcherProvider import org.smartregister.fhircore.engine.util.extension.decodeResourceFromString import org.smartregister.fhircore.quest.R +import org.smartregister.fhircore.quest.app.fakes.Faker import org.smartregister.fhircore.quest.robolectric.RobolectricTest +import org.smartregister.fhircore.quest.util.LocationUtils @OptIn(ExperimentalCoroutinesApi::class) @HiltAndroidTest @@ -77,11 +88,17 @@ class QuestionnaireActivityTest : RobolectricTest() { private lateinit var questionnaire: Questionnaire private lateinit var questionnaireActivityController: ActivityController private lateinit var questionnaireActivity: QuestionnaireActivity + private lateinit var locationUtil: LocationUtils + + private lateinit var locationManager: LocationManager @Inject lateinit var testDispatcherProvider: DispatcherProvider @BindValue lateinit var defaultRepository: DefaultRepository + @BindValue + val configurationRegistry: ConfigurationRegistry = Faker.buildTestConfigurationRegistry() + @Before fun setUp() { hiltRule.inject() @@ -193,10 +210,80 @@ class QuestionnaireActivityTest : RobolectricTest() { fun testThatOnBackPressShowsConfirmationAlertDialog() = runTest { setupActivity() questionnaireActivity.onBackPressedDispatcher.onBackPressed() - val dialog = Shadows.shadowOf(ShadowAlertDialog.getLatestAlertDialog()) + val dialog = shadowOf(ShadowAlertDialog.getLatestAlertDialog()) Assert.assertNotNull(dialog) } + @Test + fun `setupLocationServices should fetch location when location is enabled and permissions granted`() { + setupActivity() + assertTrue( + questionnaireActivity.viewModel.applicationConfiguration.logGpsLocation.contains( + LocationLogOptions.QUESTIONNAIRE, + ), + ) + + val fusedLocationProviderClient = + LocationServices.getFusedLocationProviderClient(questionnaireActivity) + assertNotNull(fusedLocationProviderClient) + shadowOf(questionnaireActivity) + .grantPermissions(android.Manifest.permission.ACCESS_FINE_LOCATION) + + assertTrue(LocationUtils.isLocationEnabled(questionnaireActivity)) + + questionnaireActivity.setupLocationServices() + assertTrue(questionnaireActivity.hasLocationPermissions()) + questionnaireActivity.fetchLocation() + assertNotNull(questionnaireActivity.currentLocation) + } + + @Test + fun `setupLocationServices should open location settings if location is disabled`() { + setupActivity() + assertTrue( + questionnaireActivity.viewModel.applicationConfiguration.logGpsLocation.contains( + LocationLogOptions.QUESTIONNAIRE, + ), + ) + + val fusedLocationProviderClient = + LocationServices.getFusedLocationProviderClient(questionnaireActivity) + assertNotNull(fusedLocationProviderClient) + + shadowOf(questionnaireActivity) + .grantPermissions(android.Manifest.permission.ACCESS_FINE_LOCATION) + val locationManager = + questionnaireActivity.getSystemService(Context.LOCATION_SERVICE) as LocationManager + locationManager.setTestProviderEnabled(LocationManager.GPS_PROVIDER, false) + locationManager.setTestProviderEnabled(LocationManager.NETWORK_PROVIDER, false) + + questionnaireActivity.fetchLocation() + val startedIntent = shadowOf(questionnaireActivity).nextStartedActivity + val expectedIntent = Intent(Settings.ACTION_LOCATION_SOURCE_SETTINGS) + + assertEquals(expectedIntent.component, startedIntent.component) + } + + @Test + fun `setupLocationServices should launch location permissions dialog if permissions are not granted`() { + setupActivity() + assertTrue( + questionnaireActivity.viewModel.applicationConfiguration.logGpsLocation.contains( + LocationLogOptions.QUESTIONNAIRE, + ), + ) + + val fusedLocationProviderClient = + LocationServices.getFusedLocationProviderClient(questionnaireActivity) + assertNotNull(fusedLocationProviderClient) + + assertTrue(LocationUtils.isLocationEnabled(questionnaireActivity)) + assertFalse(questionnaireActivity.hasLocationPermissions()) + + val dialog = questionnaireActivity.launchLocationPermissionsDialog() + assertNotNull(dialog) + } + private fun setupActivity() { val bundle = QuestionnaireActivity.intentBundle(questionnaireConfig, emptyList()) questionnaireActivityController = diff --git a/android/quest/src/test/java/org/smartregister/fhircore/quest/ui/questionnaire/QuestionnaireViewModelTest.kt b/android/quest/src/test/java/org/smartregister/fhircore/quest/ui/questionnaire/QuestionnaireViewModelTest.kt index b9ca0285b7..33a838fabf 100644 --- a/android/quest/src/test/java/org/smartregister/fhircore/quest/ui/questionnaire/QuestionnaireViewModelTest.kt +++ b/android/quest/src/test/java/org/smartregister/fhircore/quest/ui/questionnaire/QuestionnaireViewModelTest.kt @@ -188,6 +188,7 @@ class QuestionnaireViewModelTest : RobolectricTest() { sharedPreferencesHelper = sharedPreferencesHelper, fhirOperator = fhirOperator, fhirPathDataExtractor = fhirPathDataExtractor, + configurationRegistry = configurationRegistry, ), ) diff --git a/android/quest/src/test/java/org/smartregister/fhircore/quest/util/LocationUtilsTest.kt b/android/quest/src/test/java/org/smartregister/fhircore/quest/util/LocationUtilsTest.kt new file mode 100644 index 0000000000..a2643752ef --- /dev/null +++ b/android/quest/src/test/java/org/smartregister/fhircore/quest/util/LocationUtilsTest.kt @@ -0,0 +1,112 @@ +/* + * 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.util + +import android.content.Context +import android.location.Location +import android.location.LocationManager +import com.google.android.gms.location.FusedLocationProviderClient +import kotlin.coroutines.cancellation.CancellationException +import kotlin.test.assertEquals +import kotlinx.coroutines.cancel +import kotlinx.coroutines.delay +import kotlinx.coroutines.launch +import kotlinx.coroutines.runBlocking +import org.junit.Before +import org.junit.Test +import org.robolectric.Robolectric +import org.robolectric.android.controller.ActivityController +import org.smartregister.fhircore.engine.util.test.HiltActivityForTest +import org.smartregister.fhircore.quest.robolectric.RobolectricTest + +class LocationUtilsTest : RobolectricTest() { + private lateinit var fusedLocationProviderClient: FusedLocationProviderClient + private val activityController: ActivityController = + Robolectric.buildActivity(HiltActivityForTest::class.java) + private lateinit var context: HiltActivityForTest + + @Before + fun setUp() { + context = activityController.create().resume().get() + } + + @Test + fun `test isLocationEnabled when GPS provider is enabled`() { + val locationManager = context.getSystemService(Context.LOCATION_SERVICE) as LocationManager + locationManager.setTestProviderEnabled(LocationManager.GPS_PROVIDER, true) + + val result = LocationUtils.isLocationEnabled(context) + + assert(result) + } + + @Test + fun `test isLocationEnabled when Network provider is enabled`() { + val locationManager = context.getSystemService(Context.LOCATION_SERVICE) as LocationManager + locationManager.setTestProviderEnabled(LocationManager.NETWORK_PROVIDER, true) + + val result = LocationUtils.isLocationEnabled(context) + + assert(result) + } + + @Test + fun `test getAccurateLocation`() = runBlocking { + val location = + Location("").apply { + latitude = 36.0 + longitude = 1.0 + } + fusedLocationProviderClient.setMockLocation(location) + + val result = LocationUtils.getAccurateLocation(fusedLocationProviderClient, coroutineContext) + + assertEquals(location.latitude, result!!.latitude, 0.0) + assertEquals(location.longitude, result.longitude, 0.0) + } + + @Test + fun `test getApproximateLocation`() = runBlocking { + val location = + Location("").apply { + latitude = 36.0 + longitude = 1.0 + } + fusedLocationProviderClient.setMockLocation(location) + + val result = LocationUtils.getApproximateLocation(fusedLocationProviderClient, coroutineContext) + assertEquals(location.latitude, result!!.latitude, 0.0) + assertEquals(location.longitude, result.longitude, 0.0) + } + + @Test + fun `test getAccurateLocation with cancellation`() = runBlocking { + val job = launch { + delay(500) + coroutineContext.cancel() + } + + val result = runCatching { + LocationUtils.getAccurateLocation(fusedLocationProviderClient, coroutineContext) + } + + assertEquals(true, result.isFailure) + assertEquals(true, result.exceptionOrNull() is CancellationException) + + job.join() + } +} diff --git a/android/quest/src/test/java/org/smartregister/fhircore/quest/util/PermissionsUtilsTest.kt b/android/quest/src/test/java/org/smartregister/fhircore/quest/util/PermissionsUtilsTest.kt new file mode 100644 index 0000000000..f16986ead2 --- /dev/null +++ b/android/quest/src/test/java/org/smartregister/fhircore/quest/util/PermissionsUtilsTest.kt @@ -0,0 +1,62 @@ +/* + * 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.util + +import android.Manifest +import junit.framework.TestCase.assertFalse +import junit.framework.TestCase.assertTrue +import org.junit.Before +import org.junit.Test +import org.robolectric.Robolectric +import org.robolectric.Shadows.shadowOf +import org.robolectric.android.controller.ActivityController +import org.smartregister.fhircore.engine.util.test.HiltActivityForTest +import org.smartregister.fhircore.quest.robolectric.RobolectricTest + +class PermissionsUtilsTest : RobolectricTest() { + private val activityController: ActivityController = + Robolectric.buildActivity(HiltActivityForTest::class.java) + private lateinit var context: HiltActivityForTest + + @Before + fun setUp() { + context = activityController.create().resume().get() + } + + @Test + fun checkAllPermissionsGranted() { + val permissions = listOf(Manifest.permission.ACCESS_FINE_LOCATION) + + shadowOf(context).grantPermissions(Manifest.permission.ACCESS_FINE_LOCATION) + + val result = PermissionUtils.checkPermissions(context, permissions) + + assertTrue(result) + } + + @Test + fun `checkPermissions should return false when any permission is not granted`() { + val permissions = listOf(Manifest.permission.ACCESS_FINE_LOCATION, Manifest.permission.INTERNET) + + shadowOf(context).grantPermissions(Manifest.permission.INTERNET) + shadowOf(context).denyPermissions(Manifest.permission.ACCESS_FINE_LOCATION) + + val result = PermissionUtils.checkPermissions(context, permissions) + + assertFalse(result) + } +} diff --git a/android/quest/src/test/java/org/smartregister/fhircore/quest/util/ResourceUtilsTest.kt b/android/quest/src/test/java/org/smartregister/fhircore/quest/util/ResourceUtilsTest.kt new file mode 100644 index 0000000000..0ac8969583 --- /dev/null +++ b/android/quest/src/test/java/org/smartregister/fhircore/quest/util/ResourceUtilsTest.kt @@ -0,0 +1,42 @@ +/* + * 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.util + +import android.location.Location +import kotlin.test.assertEquals +import kotlin.test.assertNotNull +import org.junit.Test + +class ResourceUtilsTest { + + @Test + fun testLocationResourceIsCreated() { + val location = + Location("").apply { + longitude = 10.0 + latitude = 20.0 + altitude = 30.0 + } + + val locationResource = ResourceUtils.createFhirLocationFromGpsLocation(location) + + assertNotNull(locationResource.id) + assertEquals(location.longitude.toBigDecimal(), locationResource.position.longitude) + assertEquals(location.latitude.toBigDecimal(), locationResource.position.latitude) + assertEquals(location.altitude.toBigDecimal(), locationResource.position.altitude) + } +} diff --git a/docs/engineering/android-app/configuring/config-types/application.mdx b/docs/engineering/android-app/configuring/config-types/application.mdx index d92af76c0d..2a8a8e0c62 100644 --- a/docs/engineering/android-app/configuring/config-types/application.mdx +++ b/docs/engineering/android-app/configuring/config-types/application.mdx @@ -7,7 +7,8 @@ title: Application These are app wide configurations used to control the application behaviour globally e.g. application theme, app language etc. :::note -There can only be one instance of application configuration for the entire application. There are instances where the `Event Workflow` is added to the application config. See [here](https://docs.opensrp.io/engineering/android-app/configuring/event-management/resource-closure-by-background-worker) +There can only be one instance of application configuration for the entire application. There are instances where the `Event Workflow` is added to the application config. See [here](https://docs.opensrp.io/engineering/android-app/configuring/event-management/resource-closure-by-background-worker) +The `logGpsLocation` config takes in a list of `LocationLogOptions` to toggle whether to capture GPS coordinates in the application. We can currently define this to capture Location GPS on Questionnaire submission by passing the log option `QUESTIONNAIRE` as shown in the sample JSON. ::: ## Sample JSON @@ -23,7 +24,7 @@ There can only be one instance of application configuration for the entire appli ], "useDarkTheme": false, "syncInterval": 15, - "syncStrategy": [ + "syncStrategies": [ "Location", "Organization", "CareTeam", @@ -48,7 +49,10 @@ There can only be one instance of application configuration for the entire appli "Questionnaire", "QuestionnaireResponse" ] - } + }, + "logGpsLocations": [ + "QUESTIONNAIRE" + ] } ``` @@ -58,17 +62,26 @@ There can only be one instance of application configuration for the entire appli |Property | Description | Required | Default | |--|--|:--:|:--:| -appId | Unique identifier for the application | Yes | | -configType | Type of configuration | Yes | `application` | -appTitle | Name of the application displayed on side menu (drawer) | No | "" | -remoteSyncPageSize | Sync batch size | Yes | 100 | -languages | Supported languages | Yes | `['en']` | -useDarkTheme | Indicate whether to apply dark theme | Yes | `false` | -syncInterval | Configuration duration for periodic sync | Yes | `30` | -syncStrategy | Tag every resource with the values for the resource types indicated here | Yes | `emptyList()` | -loginConfig.showLogo | Display logo in login page | Yes | `true` | -loginConfig.enablePin | Request user for pin after login; to be used for subsequent logins | No | `false` | -loginConfig.logoHeight | Set the maximum height a logo can have | No | 120 | -loginConfig.logoWidth | Set the maximum width a logo can have | No | 140 | -loginConfig.showAppTitle | Toggle App title in LoginScreen visibility | No | true | -deviceToDeviceSync.resourcesToSync | Types of resource to be synced from one device to another during peer connection | No | `false` | +`appId` | Unique identifier for the application | Yes | | +`configType` | Type of configuration | Yes | `application` | +`appTitle` | Name of the application displayed on side menu (drawer) | No | `""` | +`remoteSyncPageSize` | Sync batch size | Yes | `100` | +`languages` | Supported languages | Yes | `['en']` | +`useDarkTheme` | Indicate whether to apply dark theme | Yes | `false` | +`syncInterval` | Configuration duration for periodic sync | Yes | `30` | +`syncStrategies` | Tag every resource with the values for the resource types indicated here | Yes | `emptyList()` | +`loginConfig.showLogo` | Display logo in login page | Yes | `true` | +`loginConfig.enablePin` | Request user for pin after login; to be used for subsequent logins | No | `false` | +`loginConfig.logoHeight` | Set the maximum height a logo can have | No | `120` | +`loginConfig.logoWidth` | Set the maximum width a logo can have | No | `140` | +`loginConfig.showAppTitle` | Toggle App title in LoginScreen visibility | No | `true` | +`deviceToDeviceSync.resourcesToSync` | Types of resource to be synced from one device to another during peer connection | No | `false` | +`snackBarTheme` | Color styling for the SnackBar | Yes | `SnackBarThemeConfig()` | +`reportRepeatTime` | Time when to re-run reporting | Yes | `""` | +`taskStatusUpdateJobDuration` | Interval of when to run status update service | Yes | `PT15M` | +`taskExpireJobDuration` | Interval of when to run task expiry service | Yes | `PT30M` | +`taskCompleteCarePlanJobDuration` | Interval of when to run task completion service | Yes | `PT60M` | +`showLogo` | Toggle whether to show the logo | Yes | `true` | +`taskBackgroundWorkerBatchSize` | Batch size of tasks to be fetched from the server | Yes | `500` | +`eventWorkflows` | A list of `EventWorkflow`s | Yes | `emptyList()` | +`logGpsLocation` | A list of `LocationLogOptions` to toggle whether to capture GPS coordinates | Yes | emptyList() | \ No newline at end of file