From 75eb6c5a9dcb423872485bb134c8e53b380295bb Mon Sep 17 00:00:00 2001 From: Ka-Ping Yee Date: Sun, 17 Mar 2024 14:36:25 +0545 Subject: [PATCH 01/11] Make the FormFillingActivity launchable from a link of the form "odkcollect://form/", allowing an entity to be preselected with a query parameter. --- collect_app/src/main/AndroidManifest.xml | 10 ++++- .../collect/android/tasks/FormLoaderTask.java | 39 +++++++++++++++++++ 2 files changed, 48 insertions(+), 1 deletion(-) diff --git a/collect_app/src/main/AndroidManifest.xml b/collect_app/src/main/AndroidManifest.xml index d7fd1e1e23d..b3d7f71cfb1 100644 --- a/collect_app/src/main/AndroidManifest.xml +++ b/collect_app/src/main/AndroidManifest.xml @@ -121,7 +121,15 @@ the specific language governing permissions and limitations under the License. + android:windowSoftInputMode="adjustResize" + android:exported="true"> + + + + + + + diff --git a/collect_app/src/main/java/org/odk/collect/android/tasks/FormLoaderTask.java b/collect_app/src/main/java/org/odk/collect/android/tasks/FormLoaderTask.java index e1248a546a8..65235bf289c 100644 --- a/collect_app/src/main/java/org/odk/collect/android/tasks/FormLoaderTask.java +++ b/collect_app/src/main/java/org/odk/collect/android/tasks/FormLoaderTask.java @@ -29,6 +29,8 @@ import org.javarosa.core.model.FormDef; import org.javarosa.core.model.FormIndex; +import org.javarosa.core.model.data.SelectOneData; +import org.javarosa.core.model.data.helper.Selection; import org.javarosa.core.model.instance.InstanceInitializationFactory; import org.javarosa.core.model.instance.TreeElement; import org.javarosa.core.model.instance.TreeReference; @@ -42,6 +44,7 @@ import org.odk.collect.android.dynamicpreload.ExternalAnswerResolver; import org.odk.collect.android.dynamicpreload.ExternalDataManager; import org.odk.collect.android.dynamicpreload.ExternalDataUseCases; +import org.odk.collect.android.exception.JavaRosaException; import org.odk.collect.android.external.FormsContract; import org.odk.collect.android.external.InstancesContract; import org.odk.collect.android.fastexternalitemset.ItemsetDbAdapter; @@ -178,6 +181,15 @@ protected FECWrapper doInBackground(Void... ignored) { * explicitly saved instance is edited via edit-saved-form. */ instancePath = loadSavePoint(); + } else if (uri.getScheme().equals("odkcollect") && uri.getHost().equals("form")) { + // Launch a form from a browsable web link in the format: odkcollect://form/ + String formId = uri.getPathSegments().get(0); + List
forms = new FormsRepositoryProvider(Collect.getInstance()).get().getAllByFormId(formId); + if (forms.size() == 0) { + Timber.e(new Error("Form not found for URL: " + uri)); + return null; + } + form = forms.get(0); } if (form.getFormFilePath() == null) { @@ -270,10 +282,37 @@ protected FECWrapper doInBackground(Void... ignored) { fc.setIndexWaitingForData(idx); } } + + preselectEntity(fc, uri); + data = new FECWrapper(fc, usedSavepoint); return data; } + private void preselectEntity(FormController fc, Uri uri) { + FormIndex saved = fc.getFormIndex(); + try { + for (int event = fc.jumpToIndex(FormIndex.createBeginningOfFormIndex()); + event != FormEntryController.EVENT_END_OF_FORM; + event = fc.stepToNextEvent(false)) { + FormIndex index = fc.getFormIndex(); + TreeReference ref = fc.getFormDef().getChildInstanceRef(index); + if (ref != null) { + String value = uri.getQueryParameter(ref.getNameLast()); + if (value != null) { + try { + fc.answerQuestion(index, new SelectOneData(new Selection(value))); + } catch (JavaRosaException e) { + Timber.w("Could not preselect answer " + value + " for question " + ref); + } + } + } + } + } finally { + fc.jumpToIndex(saved); + } + } + private static void unzipMediaFiles(File formMediaDir) { File[] zipFiles = formMediaDir.listFiles(new FileFilter() { @Override From 4af83e754a53eed15a8dd1e7ecc40588b0b23b80 Mon Sep 17 00:00:00 2001 From: Ka-Ping Yee Date: Mon, 18 Mar 2024 17:12:09 +0545 Subject: [PATCH 02/11] Add a few explanatory comments. --- collect_app/src/main/AndroidManifest.xml | 5 +++++ .../collect/android/tasks/FormLoaderTask.java | 16 +++++++++++++++- 2 files changed, 20 insertions(+), 1 deletion(-) diff --git a/collect_app/src/main/AndroidManifest.xml b/collect_app/src/main/AndroidManifest.xml index b3d7f71cfb1..b08bc61b0a5 100644 --- a/collect_app/src/main/AndroidManifest.xml +++ b/collect_app/src/main/AndroidManifest.xml @@ -123,6 +123,11 @@ the specific language governing permissions and limitations under the License. android:theme="@style/Theme.Collect.FormEntry" android:windowSoftInputMode="adjustResize" android:exported="true"> + diff --git a/collect_app/src/main/java/org/odk/collect/android/tasks/FormLoaderTask.java b/collect_app/src/main/java/org/odk/collect/android/tasks/FormLoaderTask.java index 65235bf289c..b17ea6d3cf6 100644 --- a/collect_app/src/main/java/org/odk/collect/android/tasks/FormLoaderTask.java +++ b/collect_app/src/main/java/org/odk/collect/android/tasks/FormLoaderTask.java @@ -182,7 +182,10 @@ protected FECWrapper doInBackground(Void... ignored) { */ instancePath = loadSavePoint(); } else if (uri.getScheme().equals("odkcollect") && uri.getHost().equals("form")) { - // Launch a form from a browsable web link in the format: odkcollect://form/ + // When the FormFillingActivity is started via a browsable link in + // the format "odkcollect://form/", we want to launch and + // load the form with the specified Form ID. ( is the + // form ID in the form definition, not the local form ID.) String formId = uri.getPathSegments().get(0); List forms = new FormsRepositoryProvider(Collect.getInstance()).get().getAllByFormId(formId); if (forms.size() == 0) { @@ -283,21 +286,32 @@ protected FECWrapper doInBackground(Void... ignored) { } } + // Preselect the entity specified in a URI query parameter, if any. preselectEntity(fc, uri); data = new FECWrapper(fc, usedSavepoint); return data; } + /** + * Prefills top-level select-one fields in the form, according to query + * parameters given in the intent URI. + */ private void preselectEntity(FormController fc, Uri uri) { + // We need to save the current form index in order to restore it + // after iterating through the form. FormIndex saved = fc.getFormIndex(); try { + // We assume that entity selection happens in a top-level question, + // so no need to step into groups, i.e. stepToNextEvent(false). for (int event = fc.jumpToIndex(FormIndex.createBeginningOfFormIndex()); event != FormEntryController.EVENT_END_OF_FORM; event = fc.stepToNextEvent(false)) { FormIndex index = fc.getFormIndex(); TreeReference ref = fc.getFormDef().getChildInstanceRef(index); if (ref != null) { + // If there's a query parameter matching this question, + // we prefill the question using the parameter value. String value = uri.getQueryParameter(ref.getNameLast()); if (value != null) { try { From 3bb7521f146519603ef8c29fab0120549e819b9f Mon Sep 17 00:00:00 2001 From: spwoodcock Date: Fri, 22 Mar 2024 13:48:27 +0000 Subject: [PATCH 03/11] build: hardcode directories for robolectric download over $_ syntax --- download-robolectric-deps.sh | 27 ++++++++++++++------------- 1 file changed, 14 insertions(+), 13 deletions(-) diff --git a/download-robolectric-deps.sh b/download-robolectric-deps.sh index 592f1d0a212..7012d25620f 100755 --- a/download-robolectric-deps.sh +++ b/download-robolectric-deps.sh @@ -11,16 +11,17 @@ wget -nc https://repo1.maven.org/maven2/org/robolectric/android-all-instrumented wget -nc https://repo1.maven.org/maven2/org/robolectric/android-all-instrumented/13-robolectric-9030017-i4/android-all-instrumented-13-robolectric-9030017-i4.jar -P robolectric-deps wget -nc https://repo1.maven.org/maven2/org/robolectric/android-all-instrumented/14-robolectric-10818077-i4/android-all-instrumented-14-robolectric-10818077-i4.jar -P robolectric-deps -mkdir -p collect_app/src/test/resources && cp robolectric-deps.properties "$_" -mkdir -p audiorecorder/src/test/resources && cp robolectric-deps.properties "$_" -mkdir -p projects/src/test/resources && cp robolectric-deps.properties "$_" -mkdir -p location/src/test/resources && cp robolectric-deps.properties "$_" -mkdir -p androidshared/src/test/resources && cp robolectric-deps.properties "$_" -mkdir -p geo/src/test/resources && cp robolectric-deps.properties "$_" -mkdir -p permissions/src/test/resources && cp robolectric-deps.properties "$_" -mkdir -p settings/src/test/resources && cp robolectric-deps.properties "$_" -mkdir -p maps/src/test/resources && cp robolectric-deps.properties "$_" -mkdir -p errors/src/test/resources && cp robolectric-deps.properties "$_" -mkdir -p selfie-camera/src/test/resources && cp robolectric-deps.properties "$_" -mkdir -p qr-code/src/test/resources && cp robolectric-deps.properties "$_" -mkdir -p draw/src/test/resources && cp robolectric-deps.properties "$_" +dest_dir="src/test/resources" +mkdir -p collect_app/$dest_dir && cp robolectric-deps.properties collect_app/$dest_dir +mkdir -p audiorecorder/$dest_dir && cp robolectric-deps.properties audiorecorder/$dest_dir +mkdir -p projects/$dest_dir && cp robolectric-deps.properties projects/$dest_dir +mkdir -p location/$dest_dir && cp robolectric-deps.properties location/$dest_dir +mkdir -p androidshared/$dest_dir && cp robolectric-deps.properties androidshared/$dest_dir +mkdir -p geo/$dest_dir && cp robolectric-deps.properties geo/$dest_dir +mkdir -p permissions/$dest_dir && cp robolectric-deps.properties permissions/$dest_dir +mkdir -p settings/$dest_dir && cp robolectric-deps.properties settings/$dest_dir +mkdir -p maps/$dest_dir && cp robolectric-deps.properties maps/$dest_dir +mkdir -p errors/$dest_dir && cp robolectric-deps.properties errors/$dest_dir +mkdir -p selfie-camera/$dest_dir && cp robolectric-deps.properties selfie-camera/$dest_dir +mkdir -p qr-code/$dest_dir && cp robolectric-deps.properties qr-code/$dest_dir +mkdir -p draw/$dest_dir && cp robolectric-deps.properties draw/$dest_dir From d0843cb627dfb3fd7e3fd0e60ffc6c151d880a9f Mon Sep 17 00:00:00 2001 From: spwoodcock Date: Fri, 22 Mar 2024 16:08:17 +0000 Subject: [PATCH 04/11] build: add github workflow for custom release builds --- .github/workflows/custom_release.yml | 44 ++++++++++++++++++++++++++++ 1 file changed, 44 insertions(+) create mode 100644 .github/workflows/custom_release.yml diff --git a/.github/workflows/custom_release.yml b/.github/workflows/custom_release.yml new file mode 100644 index 00000000000..bd82471afdd --- /dev/null +++ b/.github/workflows/custom_release.yml @@ -0,0 +1,44 @@ +# Workflow to deploy custom builds on ODK Collect + +name: 🔧 Build and Release + +on: + release: + types: [published] + # Allow manual trigger (workflow_dispatch) + workflow_dispatch: + +jobs: + build_upload_apk: + runs-on: ubuntu-latest + permissions: + contents: write + + container: + image: docker.io/cimg/android:2023.10.1 + + steps: + - name: Checkout repository + uses: actions/checkout@v4 + + - name: Add Robolectric Deps + run: ./download-robolectric-deps.sh + + - name: Compile Code + run: ./gradlew assembleDebug + + - name: Install Github CLI + run: | + sudo apt update + sudo apt install --no-install-recommends -y gh + + - name: Build & Upload APK + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + run: | + ./gradlew assembleSelfSignedRelease + + apk_path=$(find ./collect_app/build/outputs/apk/selfSignedRelease -name '*.apk' -type f) + echo "Generated APK file: ${apk_path}" + + gh release upload ${{ github.event.release.tag_name }} "${apk_path}" From 94765bcb5426b232174f30f8ab28e06a8a5aaa83 Mon Sep 17 00:00:00 2001 From: Samir Dangal Date: Fri, 7 Jun 2024 18:08:05 +0545 Subject: [PATCH 05/11] fixes form not saving issue after launcing ODK from url --- .../collect/android/utilities/ContentUriHelper.kt | 12 +++++++++++- .../collect/android/version/VersionInformation.java | 13 +++++++++++-- 2 files changed, 22 insertions(+), 3 deletions(-) diff --git a/collect_app/src/main/java/org/odk/collect/android/utilities/ContentUriHelper.kt b/collect_app/src/main/java/org/odk/collect/android/utilities/ContentUriHelper.kt index 8fe820d7100..7d21278e9cc 100644 --- a/collect_app/src/main/java/org/odk/collect/android/utilities/ContentUriHelper.kt +++ b/collect_app/src/main/java/org/odk/collect/android/utilities/ContentUriHelper.kt @@ -18,6 +18,7 @@ import android.net.Uri import android.provider.MediaStore import android.webkit.MimeTypeMap import org.odk.collect.android.application.Collect +import org.odk.collect.forms.Form object ContentUriHelper { @@ -25,7 +26,16 @@ object ContentUriHelper { fun getIdFromUri(contentUri: Uri): Long { val lastSegmentIndex = contentUri.pathSegments.size - 1 val idSegment = contentUri.pathSegments[lastSegmentIndex] - return idSegment.toLong() + + var idSegmentNew = idSegment + /// get form dBId from form's jrFormId + if(idSegmentNew.contains("-")){ + val forms: List = + FormsRepositoryProvider(Collect.getInstance()).get().getAllByFormId(idSegment) + idSegmentNew = "${forms.first().dbId}" + } + + return idSegmentNew.toLong() } @JvmStatic diff --git a/collect_app/src/main/java/org/odk/collect/android/version/VersionInformation.java b/collect_app/src/main/java/org/odk/collect/android/version/VersionInformation.java index ad7d4d1d9cc..bc2178c662e 100644 --- a/collect_app/src/main/java/org/odk/collect/android/version/VersionInformation.java +++ b/collect_app/src/main/java/org/odk/collect/android/version/VersionInformation.java @@ -45,9 +45,18 @@ public Integer getCommitCount() { String[] components = getVersionDescriptionComponents(); if (isBeta() && components.length > 3) { - return Integer.parseInt(components[2]); + try { + return Integer.parseInt(components[2]); + }catch (NumberFormatException numberFormatException){ + return null; + } } else if (!isBeta() && components.length > 2) { - return Integer.parseInt(components[1]); + try { + return Integer.parseInt(components[1]); + }catch (NumberFormatException numberFormatException){ + return null; + } + } else { return null; } From 6a69caee9c5899bfd0f1294c115776b2e1402078 Mon Sep 17 00:00:00 2001 From: Amit Chaudhary Date: Fri, 5 Jul 2024 11:04:10 +0545 Subject: [PATCH 06/11] Added analytics event to track project creation with deeplink --- .../org/odk/collect/android/analytics/AnalyticsEvents.kt | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/collect_app/src/main/java/org/odk/collect/android/analytics/AnalyticsEvents.kt b/collect_app/src/main/java/org/odk/collect/android/analytics/AnalyticsEvents.kt index 508881957c3..a45bdfb7971 100644 --- a/collect_app/src/main/java/org/odk/collect/android/analytics/AnalyticsEvents.kt +++ b/collect_app/src/main/java/org/odk/collect/android/analytics/AnalyticsEvents.kt @@ -73,6 +73,11 @@ object AnalyticsEvents { */ const val QR_CREATE_PROJECT = "ProjectCreateQR" + /** + * Tracks how often projects are created using deeplink. + */ + const val DEEPLINK_CREATE_PROJECT = "ProjectCreateDeeplink" + /** * Tracks how often projects are created by manually entering details. */ From 4f6fbc2f0e250e597caafa1a23c6bb316907f0b9 Mon Sep 17 00:00:00 2001 From: Amit Chaudhary Date: Fri, 5 Jul 2024 11:08:17 +0545 Subject: [PATCH 07/11] Added intent filter for project creation deeplink in FirstLaunchActivity --- collect_app/src/main/AndroidManifest.xml | 15 ++++++++++++++- 1 file changed, 14 insertions(+), 1 deletion(-) diff --git a/collect_app/src/main/AndroidManifest.xml b/collect_app/src/main/AndroidManifest.xml index b08bc61b0a5..f4a179e45b3 100644 --- a/collect_app/src/main/AndroidManifest.xml +++ b/collect_app/src/main/AndroidManifest.xml @@ -97,7 +97,20 @@ the specific language governing permissions and limitations under the License. + android:name=".activities.FirstLaunchActivity" + android:exported="true"> + + + + + + + + Date: Fri, 5 Jul 2024 11:09:04 +0545 Subject: [PATCH 08/11] Added helper class to setup project by deeplink --- .../android/utilities/ProjectSetupHelper.kt | 152 ++++++++++++++++++ 1 file changed, 152 insertions(+) create mode 100644 collect_app/src/main/java/org/odk/collect/android/utilities/ProjectSetupHelper.kt diff --git a/collect_app/src/main/java/org/odk/collect/android/utilities/ProjectSetupHelper.kt b/collect_app/src/main/java/org/odk/collect/android/utilities/ProjectSetupHelper.kt new file mode 100644 index 00000000000..a2bf44f6fc3 --- /dev/null +++ b/collect_app/src/main/java/org/odk/collect/android/utilities/ProjectSetupHelper.kt @@ -0,0 +1,152 @@ +package org.odk.collect.android.utilities + +import android.net.Uri +import androidx.appcompat.app.AppCompatActivity +import org.odk.collect.analytics.Analytics +import org.odk.collect.android.activities.ActivityUtils +import org.odk.collect.android.analytics.AnalyticsEvents +import org.odk.collect.android.mainmenu.MainMenuActivity +import org.odk.collect.android.projects.DuplicateProjectConfirmationDialog +import org.odk.collect.android.projects.ProjectCreator +import org.odk.collect.android.projects.ProjectsDataService +import org.odk.collect.android.projects.SettingsConnectionMatcher +import org.odk.collect.androidshared.ui.ToastUtils +import org.odk.collect.androidshared.ui.ToastUtils.showShortToast +import org.odk.collect.androidshared.utils.CompressionUtils +import org.odk.collect.settings.importing.SettingsImportingResult +import timber.log.Timber + +/** + * Helper class to set up a project from a deeplink URI. + * It takes following parameters: + * - activity: It is the AppCompatActivity instance. It will be used to start the MainMenuActivity. + * - settingsConnectionMatcher: It is the SettingsConnectionMatcher instance. It is used to check for duplicate project + * - projectCreator: It is the ProjectCreator instance. It is used to create a new project. + * - projectsDataService: It is the ProjectsDataService instance. It is used to check if the project is already a current project. + */ +class ProjectSetupHelper( + private val activity: AppCompatActivity, + private val settingsConnectionMatcher: SettingsConnectionMatcher, + private val projectCreator: ProjectCreator, + private val projectsDataService: ProjectsDataService, +) : + DuplicateProjectConfirmationDialog.DuplicateProjectConfirmationListener { + + /** + * Method to set up a project from a deeplink URI. + */ + fun initiateSetup(uri: Uri) { + // Get the project settings from the URI + // Here we are replacing the "space" with "+" because the + // "+" char is replaced by "space" while coming through the deep link + val projectSettingJsonStr = uri.getQueryParameter("data")?.replace(" ", "+") + + // If the URI doesn't contain a settings parameter, return + if (projectSettingJsonStr == null) { + showShortToast( + activity.applicationContext, + "Uri doesn't contain a settings data" + ) + return + } + + // Decompress the project settings configuration string if valid + // It will try to decompress the settings data from the query parameter + // If the settings data is invalid, show a toast and return + // If the settings data is valid, create the project + val settingsJson = try { + CompressionUtils.decompress(projectSettingJsonStr) + } catch (e: Exception) { + showShortToast( + activity.applicationContext, + "Invalid project configuration data" + ) + null + } ?: return + + // Create the project + createProjectOrError(settingsJson) + } + + /** + * Method to check the duplicate project and create a new project if needed. + */ + private fun createProjectOrError(settingsJson: String) { + settingsConnectionMatcher.getProjectWithMatchingConnection(settingsJson)?.let { uuid -> + // Switch to project if it is not already a current project + try { + // Checking if project is already a current project + val alreadyCurrentProject = projectsDataService.getCurrentProject().uuid == uuid + + // If not current project, switch to it + if (!alreadyCurrentProject) switchToProject(uuid) + + // Else just navigate to the project + else navigateToProject() + + } catch (e: Exception) { + Timber.e("Error occurred while getting current project: %s", e.message) + } + + } ?: run { + createProject(settingsJson) + } + } + + /** + * Method to create a new project. + */ + override fun createProject(settingsJson: String) { + when (projectCreator.createNewProject(settingsJson)) { + SettingsImportingResult.SUCCESS -> { + Analytics.log(AnalyticsEvents.DEEPLINK_CREATE_PROJECT) + + ActivityUtils.startActivityAndCloseAllOthers( + activity, + MainMenuActivity::class.java + ) + ToastUtils.showLongToast( + activity.applicationContext, + activity.getString( + org.odk.collect.strings.R.string.switched_project, + projectsDataService.getCurrentProject().name + ) + ) + } + + SettingsImportingResult.INVALID_SETTINGS -> ToastUtils.showLongToast( + activity.applicationContext, + "Invalid project configuration data" + ) + + SettingsImportingResult.GD_PROJECT -> ToastUtils.showLongToast( + activity.applicationContext, + activity.getString( + org.odk.collect.strings.R.string.settings_with_gd_protocol + ) + ) + } + } + + /** + * Method to switch to an existing project. + */ + override fun switchToProject(uuid: String) { + projectsDataService.setCurrentProject(uuid) + ActivityUtils.startActivityAndCloseAllOthers(activity, MainMenuActivity::class.java) + ToastUtils.showLongToast( + activity.applicationContext, + activity.getString( + org.odk.collect.strings.R.string.switched_project, + projectsDataService.getCurrentProject().name + ) + ) + } + + /** + * Method to handle navigation to the project. + */ + private fun navigateToProject() { + ActivityUtils.startActivityAndCloseAllOthers(activity, MainMenuActivity::class.java) + } +} \ No newline at end of file From a8c8cde244f07dae82b4d1003ae2ae03a290c536 Mon Sep 17 00:00:00 2001 From: Amit Chaudhary Date: Fri, 5 Jul 2024 11:18:52 +0545 Subject: [PATCH 09/11] Added functionality to handle project creation deeplink --- .../android/activities/FirstLaunchActivity.kt | 49 ++++++++++++++++++- 1 file changed, 48 insertions(+), 1 deletion(-) diff --git a/collect_app/src/main/java/org/odk/collect/android/activities/FirstLaunchActivity.kt b/collect_app/src/main/java/org/odk/collect/android/activities/FirstLaunchActivity.kt index b98eadad962..d2e39783d22 100644 --- a/collect_app/src/main/java/org/odk/collect/android/activities/FirstLaunchActivity.kt +++ b/collect_app/src/main/java/org/odk/collect/android/activities/FirstLaunchActivity.kt @@ -14,8 +14,11 @@ import org.odk.collect.android.databinding.FirstLaunchLayoutBinding import org.odk.collect.android.injection.DaggerUtils import org.odk.collect.android.mainmenu.MainMenuActivity import org.odk.collect.android.projects.ManualProjectCreatorDialog +import org.odk.collect.android.projects.ProjectCreator import org.odk.collect.android.projects.ProjectsDataService import org.odk.collect.android.projects.QrCodeProjectCreatorDialog +import org.odk.collect.android.projects.SettingsConnectionMatcher +import org.odk.collect.android.utilities.ProjectSetupHelper import org.odk.collect.android.version.VersionInformation import org.odk.collect.androidshared.system.ContextUtils.getThemeAttributeValue import org.odk.collect.androidshared.ui.DialogFragmentUtils @@ -25,6 +28,7 @@ import org.odk.collect.projects.Project import org.odk.collect.projects.ProjectsRepository import org.odk.collect.settings.SettingsProvider import org.odk.collect.strings.localization.LocalizedActivity +import timber.log.Timber import javax.inject.Inject class FirstLaunchActivity : LocalizedActivity() { @@ -41,6 +45,9 @@ class FirstLaunchActivity : LocalizedActivity() { @Inject lateinit var settingsProvider: SettingsProvider + @Inject + lateinit var projectCreator: ProjectCreator + @Inject lateinit var scheduler: Scheduler @@ -102,7 +109,12 @@ class FirstLaunchActivity : LocalizedActivity() { text = SpannableStringBuilder() .append(getString(org.odk.collect.strings.R.string.dont_have_project)) .append(" ") - .color(getThemeAttributeValue(context, com.google.android.material.R.attr.colorAccent)) { + .color( + getThemeAttributeValue( + context, + com.google.android.material.R.attr.colorAccent + ) + ) { append(getString(org.odk.collect.strings.R.string.try_demo)) } @@ -110,6 +122,41 @@ class FirstLaunchActivity : LocalizedActivity() { viewModel.tryDemo() } } + + // When the FirstLaunchActivity is started via a browsable link in + // the format "odkcollect://project/configuration?data=", + // setup project automatically if data is valid + initiateProjectSetUp() + } + } + + /** + * Method to initiate project setup from a URI. + * When the FirstLaunchActivity is started via a browsable link in + * the format "odkcollect://project/configuration?data=", + * setup project automatically if data is valid + */ + private fun initiateProjectSetUp() { + try { + val uri = intent.data + + if (uri?.scheme != "odkcollect" || uri.host != "project") return + + val segment = uri.pathSegments.firstOrNull() + + if (segment != "configuration") return + + val helper = ProjectSetupHelper( + this, + SettingsConnectionMatcher(projectsRepository, settingsProvider), + projectCreator, + projectsDataService + ) + + helper.initiateSetup(uri) + + } catch (e: Exception) { + Timber.e(e) } } } From b2012babd3fe4cd15cad5b6520c83bf787c36c25 Mon Sep 17 00:00:00 2001 From: Amit Chaudhary Date: Fri, 5 Jul 2024 11:18:58 +0545 Subject: [PATCH 10/11] Workaround for prefilling form field with deeplink --- .../collect/android/tasks/FormLoaderTask.java | 99 ++++++++++++++----- .../android/utilities/ContentUriHelper.kt | 33 ++++--- 2 files changed, 91 insertions(+), 41 deletions(-) diff --git a/collect_app/src/main/java/org/odk/collect/android/tasks/FormLoaderTask.java b/collect_app/src/main/java/org/odk/collect/android/tasks/FormLoaderTask.java index b17ea6d3cf6..403edb418e3 100644 --- a/collect_app/src/main/java/org/odk/collect/android/tasks/FormLoaderTask.java +++ b/collect_app/src/main/java/org/odk/collect/android/tasks/FormLoaderTask.java @@ -29,14 +29,14 @@ import org.javarosa.core.model.FormDef; import org.javarosa.core.model.FormIndex; -import org.javarosa.core.model.data.SelectOneData; -import org.javarosa.core.model.data.helper.Selection; +import org.javarosa.core.model.data.IAnswerData; import org.javarosa.core.model.instance.InstanceInitializationFactory; import org.javarosa.core.model.instance.TreeElement; import org.javarosa.core.model.instance.TreeReference; import org.javarosa.core.model.instance.utils.DefaultAnswerResolver; import org.javarosa.core.reference.ReferenceManager; import org.javarosa.form.api.FormEntryController; +import org.javarosa.form.api.FormEntryPrompt; import org.javarosa.xform.parse.XFormParser; import org.javarosa.xform.util.XFormUtils; import org.javarosa.xpath.XPathTypeMismatchException; @@ -44,7 +44,6 @@ import org.odk.collect.android.dynamicpreload.ExternalAnswerResolver; import org.odk.collect.android.dynamicpreload.ExternalDataManager; import org.odk.collect.android.dynamicpreload.ExternalDataUseCases; -import org.odk.collect.android.exception.JavaRosaException; import org.odk.collect.android.external.FormsContract; import org.odk.collect.android.external.InstancesContract; import org.odk.collect.android.fastexternalitemset.ItemsetDbAdapter; @@ -71,6 +70,8 @@ import java.io.IOException; import java.util.List; import java.util.Locale; +import java.util.Objects; +import java.util.Set; import timber.log.Timber; @@ -181,18 +182,17 @@ protected FECWrapper doInBackground(Void... ignored) { * explicitly saved instance is edited via edit-saved-form. */ instancePath = loadSavePoint(); - } else if (uri.getScheme().equals("odkcollect") && uri.getHost().equals("form")) { + } else if (Objects.equals(uri.getScheme(), "odkcollect") && Objects.equals(uri.getHost(), "form")) { // When the FormFillingActivity is started via a browsable link in // the format "odkcollect://form/", we want to launch and // load the form with the specified Form ID. ( is the // form ID in the form definition, not the local form ID.) - String formId = uri.getPathSegments().get(0); - List forms = new FormsRepositoryProvider(Collect.getInstance()).get().getAllByFormId(formId); - if (forms.size() == 0) { - Timber.e(new Error("Form not found for URL: " + uri)); + form = new FormsRepositoryProvider(Collect.getInstance()).get().get(ContentUriHelper.getIdFromUri(uri)); + if (form == null) { + Timber.e(new Error("form is null")); + errorMsg = "This form no longer exists, please email support@getodk.org with a description of what you were doing when this happened."; return null; } - form = forms.get(0); } if (form.getFormFilePath() == null) { @@ -298,30 +298,79 @@ protected FECWrapper doInBackground(Void... ignored) { * parameters given in the intent URI. */ private void preselectEntity(FormController fc, Uri uri) { + // Getting set of query parameters name from the uri + Set queryParameterNames = uri.getQueryParameterNames(); + + // Checking if parameters name is null or empty + // If query parameters name is null or empty, then we don't need to preselect + // So we return from here + if (queryParameterNames == null || queryParameterNames.isEmpty()) return; + + // Odk by default pass projectId as a query parameter. + // If query parameters name only contains projectId, then we don't need to preselect + // So we return from here + if (queryParameterNames.size() == 1 && queryParameterNames.contains("projectId")) return; + // We need to save the current form index in order to restore it // after iterating through the form. FormIndex saved = fc.getFormIndex(); try { - // We assume that entity selection happens in a top-level question, - // so no need to step into groups, i.e. stepToNextEvent(false). + // We assume that entity selection might happens in a top-level question and groups + // so we also need to step into groups, i.e. stepToNextEvent(true). for (int event = fc.jumpToIndex(FormIndex.createBeginningOfFormIndex()); event != FormEntryController.EVENT_END_OF_FORM; - event = fc.stepToNextEvent(false)) { + event = fc.stepToNextEvent(true)) { + + // Getting current form index + // It is an immutable index which is structured + // to provide quick access to a specific node in a FormDef FormIndex index = fc.getFormIndex(); - TreeReference ref = fc.getFormDef().getChildInstanceRef(index); - if (ref != null) { - // If there's a query parameter matching this question, - // we prefill the question using the parameter value. - String value = uri.getQueryParameter(ref.getNameLast()); - if (value != null) { - try { - fc.answerQuestion(index, new SelectOneData(new Selection(value))); - } catch (JavaRosaException e) { - Timber.w("Could not preselect answer " + value + " for question " + ref); - } - } + + // If index is null, then we don't need to preselect + if (index == null) continue; + + // Getting form definition + // It contains the metadata about the form definition + // and a collection of groups together with question branching or skipping rules + FormDef def = fc.getFormDef(); + + // Id def is null continue + if (def == null) continue; + + // Now getting tree reference from the current form index + TreeReference ref = def.getChildInstanceRef(index); + + // If ref is null continue + if (ref == null) continue; + + // Else getting value from the query parameters as per the ref name + String value = uri.getQueryParameter(ref.getNameLast()); + + // If value is null continue + if (value == null) continue; + + // Else preselect the answer + try { + // Getting form entry prompt for the form index + // It provides all the information regarding question + FormEntryPrompt prompt = fc.getQuestionPrompt(index); + + // If form entry prompt is null continue + if (prompt == null) continue; + + // Else creating answer data as per the prompt data type + // It will automatically create required IAnswerData as per the prompt data type + IAnswerData answer = IAnswerData.wrapData(value, prompt.getDataType()); + + // Finally we are preselecting the answer + fc.answerQuestion(index, answer); + + } catch (Exception e) { + Timber.w("Could not preselect answer %s for index %s", value, index.getLocalIndex()); } } + } catch (Exception e) { + Timber.w("Could not preselect answer due to: %s", e.getMessage()); } finally { fc.jumpToIndex(saved); } @@ -454,7 +503,7 @@ private boolean initializeForm(FormDef formDef, FormEntryController fec) throws // The saved instance is corrupted. Timber.e(e, "Corrupt saved instance"); throw new RuntimeException("An unknown error has occurred. Please ask your project leadership to email support@getodk.org with information about this form." - + "\n\n" + e.getMessage()); + + "\n\n" + e.getMessage()); } } } else { diff --git a/collect_app/src/main/java/org/odk/collect/android/utilities/ContentUriHelper.kt b/collect_app/src/main/java/org/odk/collect/android/utilities/ContentUriHelper.kt index 7d21278e9cc..7a562f12ab6 100644 --- a/collect_app/src/main/java/org/odk/collect/android/utilities/ContentUriHelper.kt +++ b/collect_app/src/main/java/org/odk/collect/android/utilities/ContentUriHelper.kt @@ -24,32 +24,33 @@ object ContentUriHelper { @JvmStatic fun getIdFromUri(contentUri: Uri): Long { - val lastSegmentIndex = contentUri.pathSegments.size - 1 - val idSegment = contentUri.pathSegments[lastSegmentIndex] - - var idSegmentNew = idSegment - /// get form dBId from form's jrFormId - if(idSegmentNew.contains("-")){ + val idSegment = contentUri.pathSegments.last() + if (idSegment.toLongOrNull() == null) { val forms: List = FormsRepositoryProvider(Collect.getInstance()).get().getAllByFormId(idSegment) - idSegmentNew = "${forms.first().dbId}" + + return forms.firstOrNull()?.dbId ?: -1 } - return idSegmentNew.toLong() + return idSegment.toLong() } @JvmStatic fun getFileExtensionFromUri(fileUri: Uri): String? { val mimeType = Collect.getInstance().contentResolver.getType(fileUri) - var extension = if (fileUri.scheme != null && fileUri.scheme == ContentResolver.SCHEME_CONTENT) MimeTypeMap.getSingleton().getExtensionFromMimeType(mimeType) else MimeTypeMap.getFileExtensionFromUrl(fileUri.toString()) - if (extension == null || extension.isEmpty()) { - Collect.getInstance().contentResolver.query(fileUri, null, null, null, null).use { cursor -> - var name: String? = null - if (cursor != null && cursor.moveToFirst()) { - name = cursor.getString(cursor.getColumnIndex(MediaStore.MediaColumns.DISPLAY_NAME)) + var extension = + if (fileUri.scheme != null && fileUri.scheme == ContentResolver.SCHEME_CONTENT) MimeTypeMap.getSingleton() + .getExtensionFromMimeType(mimeType) else MimeTypeMap.getFileExtensionFromUrl(fileUri.toString()) + if (extension.isNullOrEmpty()) { + Collect.getInstance().contentResolver.query(fileUri, null, null, null, null) + .use { cursor -> + var name: String? = null + if (cursor != null && cursor.moveToFirst()) { + name = + cursor.getString(cursor.getColumnIndex(MediaStore.MediaColumns.DISPLAY_NAME)) + } + extension = name?.substring(name.lastIndexOf('.') + 1) ?: "" } - extension = name?.substring(name.lastIndexOf('.') + 1) ?: "" - } } if (extension!!.isEmpty() && mimeType != null && mimeType.contains("/")) { From 335fd6cadbcc012258175875ec6acce96796216d Mon Sep 17 00:00:00 2001 From: Amit Chaudhary Date: Fri, 5 Jul 2024 17:39:25 +0545 Subject: [PATCH 11/11] handled deeplink for opening form with preselected values --- collect_app/src/main/AndroidManifest.xml | 29 ++-- .../android/external/FormUriActivity.kt | 152 ++++++++++++++---- .../collect/android/tasks/FormLoaderTask.java | 12 -- .../android/utilities/ContentUriHelper.kt | 12 +- 4 files changed, 147 insertions(+), 58 deletions(-) diff --git a/collect_app/src/main/AndroidManifest.xml b/collect_app/src/main/AndroidManifest.xml index f4a179e45b3..f4a7843220e 100644 --- a/collect_app/src/main/AndroidManifest.xml +++ b/collect_app/src/main/AndroidManifest.xml @@ -104,6 +104,7 @@ the specific language governing permissions and limitations under the License. This intent-filter enables the launching of FirstLaunchActivity via in-browser links in the format "odkcollect://project/configuration?data=" + to set up the project. --> @@ -134,20 +135,7 @@ the specific language governing permissions and limitations under the License. - - - - - - - - + android:windowSoftInputMode="adjustResize" /> @@ -350,6 +338,19 @@ the specific language governing permissions and limitations under the License. + + + + + + + + diff --git a/collect_app/src/main/java/org/odk/collect/android/external/FormUriActivity.kt b/collect_app/src/main/java/org/odk/collect/android/external/FormUriActivity.kt index 57ea84a4d61..57a25a13247 100644 --- a/collect_app/src/main/java/org/odk/collect/android/external/FormUriActivity.kt +++ b/collect_app/src/main/java/org/odk/collect/android/external/FormUriActivity.kt @@ -87,23 +87,29 @@ class FormUriActivity : ComponentActivity() { DaggerUtils.getComponent(this).inject(this) setContentView(R.layout.circular_progress_indicator) - formUriViewModel.error.observe(this) { - if (it != null) { - displayErrorDialog(it) - } else if (savedInstanceState?.getBoolean(FORM_FILLING_ALREADY_STARTED) != true) { - startForm() + formUriViewModel.formUriValidationResult.observe(this) { + when (it) { + is Valid -> { + if (savedInstanceState?.getBoolean(FORM_FILLING_ALREADY_STARTED) != true) { + startForm(it.uri) + } + } + + is Invalid -> { + displayErrorDialog(it.message) + } } } } - private fun startForm() { + private fun startForm(uri: Uri) { formFillingAlreadyStarted = true openForm.launch( Intent(this, FormFillingActivity::class.java).apply { action = intent.action - data = intent.data + data = uri intent.extras?.let { sourceExtras -> putExtras(sourceExtras) } - if (!canFormBeEdited()) { + if (!canFormBeEdited(uri)) { putExtra( ApplicationConstants.BundleKeys.FORM_MODE, ApplicationConstants.FormModes.VIEW_SENT @@ -122,8 +128,7 @@ class FormUriActivity : ComponentActivity() { .show() } - private fun canFormBeEdited(): Boolean { - val uri = intent.data!! + private fun canFormBeEdited(uri: Uri): Boolean { val uriMimeType = contentResolver.getType(uri) val formEditingEnabled = if (uriMimeType == InstancesContract.CONTENT_ITEM_TYPE) { @@ -157,23 +162,79 @@ private class FormUriViewModel( private val resources: Resources ) : ViewModel() { - private val _error = MutableLiveData() - val error: LiveData = _error + + // This is just for handling and reassigning uri + // When the FormUriActivity is started via a browsable link in + // the format "odkcollect://form/" + private var _uri: Uri? = uri + + private val _formUriValidationResult = MutableLiveData() + val formUriValidationResult: LiveData = _formUriValidationResult init { scheduler.immediate( background = { + // If from the browsable link in the format "odkcollect://form/", + // We will modify the uri to make it valid for the FormFillingActivity + if (uri?.scheme == "odkcollect" && uri.host == "form") { + + // Get the form id from the uri + val formId = ContentUriHelper.getIdFromUri(uri) + + // If the form id is not valid i.e. -1, return with error message + if (formId == -1L) { + return@immediate "${resources.getString(string.wrong_project_selected_for_form)} And conform that it is downloaded." + } + + // Getting currently active project id + val currentProjectId = try { + projectsDataService.getCurrentProject().uuid + } catch (e: Exception) { + null + } + + // Else build the new uri + // We will modify the uri to make it valid for the FormFillingActivity + val tempUri = FormsContract.getUri(null) + val builder = Uri.Builder() + .scheme(tempUri.scheme) + .authority(tempUri.authority) + .appendPath(tempUri.lastPathSegment) + .appendPath(ContentUriHelper.getIdFromUri(uri).toString()) + + // If projectId is not present in the uri, add it + if (!uri.queryParameterNames.contains("projectId")) { + builder.appendQueryParameter("projectId", currentProjectId) + } + + // add all the query parameters from the uri + uri.queryParameterNames?.forEach { key -> + builder.appendQueryParameter(key, uri.getQueryParameter(key)) + } + + // Build the new uri + _uri = builder.build() + } + assertProjectListNotEmpty() ?: assertCurrentProjectUsed() ?: assertValidUri() - ?: assertFormExists() ?: assertFormNotEncrypted() + ?: assertFormExists() ?: assertFormNotEncrypted() }, foreground = { - _error.value = it + _formUriValidationResult.value = if (it == null) { + Valid(_uri!!) + } else { + Invalid(it) + } } ) } private fun assertProjectListNotEmpty(): String? { - val projects = projectsRepository.getAll() + val projects = try { + projectsRepository.getAll() + } catch (e: Exception) { + emptyList() + } return if (projects.isEmpty()) { resources.getString(string.app_not_configured) } else { @@ -182,12 +243,20 @@ private class FormUriViewModel( } private fun assertCurrentProjectUsed(): String? { - val projects = projectsRepository.getAll() - val firstProject = projects.first() - val uriProjectId = uri?.getQueryParameter("projectId") - val projectId = uriProjectId ?: firstProject.uuid + val uriProjectId = _uri?.getQueryParameter("projectId") + val projectId = if (uriProjectId != null) uriProjectId else { + val projects = projectsRepository.getAll() + val firstProject = projects.first() + firstProject.uuid + } - return if (projectId != projectsDataService.getCurrentProject().uuid) { + val currentProjectId = try { + projectsDataService.getCurrentProject().uuid + } catch (e: Exception) { + null + } + + return if (projectId != currentProjectId) { resources.getString(string.wrong_project_selected_for_form) } else { null @@ -195,7 +264,7 @@ private class FormUriViewModel( } private fun assertValidUri(): String? { - val isUriValid = uri?.let { + val isUriValid = _uri?.let { val uriMimeType = contentResolver.getType(it) if (uriMimeType == null) { false @@ -212,13 +281,14 @@ private class FormUriViewModel( } private fun assertFormExists(): String? { - val uriMimeType = contentResolver.getType(uri!!) + val uriMimeType = contentResolver.getType(_uri!!) return if (uriMimeType == FormsContract.CONTENT_ITEM_TYPE) { val formExists = - formsRepositoryProvider.get().get(ContentUriHelper.getIdFromUri(uri))?.let { - File(it.formFilePath).exists() - } ?: false + formsRepositoryProvider.get().get(ContentUriHelper.getIdFromUri(_uri!!)) + ?.let { + File(it.formFilePath).exists() + } ?: false if (formExists) { null @@ -226,7 +296,8 @@ private class FormUriViewModel( resources.getString(string.bad_uri) } } else { - val instance = instancesRepositoryProvider.get().get(ContentUriHelper.getIdFromUri(uri)) + val instance = + instancesRepositoryProvider.get().get(ContentUriHelper.getIdFromUri(_uri!!)) if (instance == null) { resources.getString(string.bad_uri) } else if (!File(instance.instanceFilePath).exists()) { @@ -247,7 +318,10 @@ private class FormUriViewModel( "\n${resources.getString(string.version)} ${instance.formVersion}" } - resources.getString(string.parent_form_not_present, "${instance.formId}$version") + resources.getString( + string.parent_form_not_present, + "${instance.formId}$version" + ) } else if (candidateForms.filter { !it.isDeleted }.size > 1) { resources.getString(string.survey_multiple_forms_error) } else { @@ -258,10 +332,11 @@ private class FormUriViewModel( } private fun assertFormNotEncrypted(): String? { - val uriMimeType = contentResolver.getType(uri!!) + val uriMimeType = contentResolver.getType(_uri!!) return if (uriMimeType == InstancesContract.CONTENT_ITEM_TYPE) { - val instance = instancesRepositoryProvider.get().get(ContentUriHelper.getIdFromUri(uri)) + val instance = + instancesRepositoryProvider.get().get(ContentUriHelper.getIdFromUri(_uri!!)) if (instance!!.canEditWhenComplete()) { null } else { @@ -272,3 +347,22 @@ private class FormUriViewModel( } } } + +/** + * Represents the result of validating a form URI. + *It can either be a [Valid] containing the valid URI, + * or an [Invalid] with an error message. + */ +private sealed class FormUriValidationResult + +/** + * Represents a successfully validated form URI. + * @property uri The valid URI. + */ +private data class Valid(val uri: Uri) : FormUriValidationResult() + +/** + * Represents an invalid form URI with an associated error message. + * @property message The error message explaining why the URI is invalid. + */ +private data class Invalid(val message: String) : FormUriValidationResult() diff --git a/collect_app/src/main/java/org/odk/collect/android/tasks/FormLoaderTask.java b/collect_app/src/main/java/org/odk/collect/android/tasks/FormLoaderTask.java index 403edb418e3..bd0e94e6ee0 100644 --- a/collect_app/src/main/java/org/odk/collect/android/tasks/FormLoaderTask.java +++ b/collect_app/src/main/java/org/odk/collect/android/tasks/FormLoaderTask.java @@ -70,7 +70,6 @@ import java.io.IOException; import java.util.List; import java.util.Locale; -import java.util.Objects; import java.util.Set; import timber.log.Timber; @@ -182,17 +181,6 @@ protected FECWrapper doInBackground(Void... ignored) { * explicitly saved instance is edited via edit-saved-form. */ instancePath = loadSavePoint(); - } else if (Objects.equals(uri.getScheme(), "odkcollect") && Objects.equals(uri.getHost(), "form")) { - // When the FormFillingActivity is started via a browsable link in - // the format "odkcollect://form/", we want to launch and - // load the form with the specified Form ID. ( is the - // form ID in the form definition, not the local form ID.) - form = new FormsRepositoryProvider(Collect.getInstance()).get().get(ContentUriHelper.getIdFromUri(uri)); - if (form == null) { - Timber.e(new Error("form is null")); - errorMsg = "This form no longer exists, please email support@getodk.org with a description of what you were doing when this happened."; - return null; - } } if (form.getFormFilePath() == null) { diff --git a/collect_app/src/main/java/org/odk/collect/android/utilities/ContentUriHelper.kt b/collect_app/src/main/java/org/odk/collect/android/utilities/ContentUriHelper.kt index 7a562f12ab6..5a673ea0b57 100644 --- a/collect_app/src/main/java/org/odk/collect/android/utilities/ContentUriHelper.kt +++ b/collect_app/src/main/java/org/odk/collect/android/utilities/ContentUriHelper.kt @@ -19,6 +19,7 @@ import android.provider.MediaStore import android.webkit.MimeTypeMap import org.odk.collect.android.application.Collect import org.odk.collect.forms.Form +import timber.log.Timber object ContentUriHelper { @@ -26,10 +27,15 @@ object ContentUriHelper { fun getIdFromUri(contentUri: Uri): Long { val idSegment = contentUri.pathSegments.last() if (idSegment.toLongOrNull() == null) { - val forms: List = - FormsRepositoryProvider(Collect.getInstance()).get().getAllByFormId(idSegment) + try { + val forms: List = + FormsRepositoryProvider(Collect.getInstance()).get().getAllByFormId(idSegment) + return forms.firstOrNull()?.dbId ?: -1 + } catch (e: Exception) { + Timber.e("Error getting form id: %s", e.message) + } - return forms.firstOrNull()?.dbId ?: -1 + return -1 } return idSegment.toLong()