Repository: opendatakit/collect Branch: master Commit: 77b1124ad9a2 Files: 2434 Total size: 11.5 MB Directory structure: gitextract_2lkczetw/ ├── .circleci/ │ ├── config.yml │ ├── generate-app-test-list.sh │ ├── gradle-large.properties │ ├── gradle.properties │ └── test_modules.txt ├── .editorconfig ├── .gitattributes ├── .github/ │ ├── CODE_OF_CONDUCT.md │ ├── ISSUE_TEMPLATE/ │ │ └── config.yml │ ├── ISSUE_TEMPLATE.md │ ├── PULL_REQUEST_TEMPLATE.md │ └── TESTING_RESULT_TEMPLATES.md ├── .gitignore ├── .hgtags ├── LICENSE.md ├── README.md ├── SECURITY.md ├── analytics/ │ ├── .gitignore │ ├── build.gradle.kts │ ├── proguard-rules.pro │ └── src/ │ └── main/ │ ├── AndroidManifest.xml │ └── java/ │ └── org/ │ └── odk/ │ └── collect/ │ └── analytics/ │ ├── Analytics.kt │ ├── BlockableFirebaseAnalytics.kt │ └── NoopAnalytics.kt ├── androidshared/ │ ├── .gitignore │ ├── build.gradle.kts │ └── src/ │ ├── androidTest/ │ │ └── java/ │ │ └── org/ │ │ └── odk/ │ │ └── collect/ │ │ └── androidshared/ │ │ └── bitmap/ │ │ ├── ImageCompressorTest.kt │ │ └── ImageFileUtilsTest.kt │ ├── main/ │ │ ├── AndroidManifest.xml │ │ ├── java/ │ │ │ └── org/ │ │ │ └── odk/ │ │ │ └── collect/ │ │ │ └── androidshared/ │ │ │ ├── async/ │ │ │ │ └── TrackableWorker.kt │ │ │ ├── bitmap/ │ │ │ │ ├── ImageCompressor.kt │ │ │ │ └── ImageFileUtils.kt │ │ │ ├── data/ │ │ │ │ ├── AppState.kt │ │ │ │ ├── Consumable.kt │ │ │ │ └── Data.kt │ │ │ ├── livedata/ │ │ │ │ ├── LiveDataExt.kt │ │ │ │ ├── LiveDataUtils.java │ │ │ │ └── NonNullLiveData.kt │ │ │ ├── system/ │ │ │ │ ├── BroadcastReceiverRegister.kt │ │ │ │ ├── CameraUtils.java │ │ │ │ ├── ContextExt.kt │ │ │ │ ├── ExternalFilesUtils.kt │ │ │ │ ├── IntentLauncher.kt │ │ │ │ ├── OpenGLVersionChecker.kt │ │ │ │ ├── PlayServicesChecker.java │ │ │ │ ├── ProcessRestoreDetector.kt │ │ │ │ └── UriExt.kt │ │ │ ├── ui/ │ │ │ │ ├── AlertStore.kt │ │ │ │ ├── Animations.kt │ │ │ │ ├── ColorPickerDialog.kt │ │ │ │ ├── ComposeThemeProvider.kt │ │ │ │ ├── DialogFragmentUtils.kt │ │ │ │ ├── DialogUtils.kt │ │ │ │ ├── DisplayString.kt │ │ │ │ ├── EdgeToEdge.kt │ │ │ │ ├── FragmentFactoryBuilder.kt │ │ │ │ ├── GroupClickListener.kt │ │ │ │ ├── ListFragmentStateAdapter.kt │ │ │ │ ├── MenuExt.kt │ │ │ │ ├── ObviousProgressBar.kt │ │ │ │ ├── OneSignTextWatcher.kt │ │ │ │ ├── PrefUtils.kt │ │ │ │ ├── ReturnToAppActivity.kt │ │ │ │ ├── SnackbarUtils.kt │ │ │ │ ├── ToastUtils.kt │ │ │ │ ├── compose/ │ │ │ │ │ └── Margins.kt │ │ │ │ └── multiclicksafe/ │ │ │ │ ├── DoubleClickSafeMaterialButton.kt │ │ │ │ ├── MultiClickGuard.kt │ │ │ │ ├── MultiClickSafeMaterialButton.kt │ │ │ │ ├── MultiClickSafeTextInputEditText.kt │ │ │ │ └── MultiClickSaveOnClickListener.kt │ │ │ └── utils/ │ │ │ ├── AppBarUtils.kt │ │ │ ├── ColorUtils.kt │ │ │ ├── CompressionUtils.kt │ │ │ ├── FileExt.kt │ │ │ ├── InMemUniqueIdGenerator.kt │ │ │ ├── PathUtils.kt │ │ │ ├── PreferenceFragmentCompatUtils.kt │ │ │ ├── ScreenUtils.java │ │ │ ├── SettingsUniqueIdGenerator.kt │ │ │ ├── UniqueIdGenerator.kt │ │ │ └── Validator.kt │ │ └── res/ │ │ ├── color/ │ │ │ ├── color_error_button_icon.xml │ │ │ ├── color_on_primary_low_emphasis.xml │ │ │ ├── color_on_surface_high_emphasis.xml │ │ │ ├── color_on_surface_low_emphasis.xml │ │ │ ├── color_on_surface_medium_emphasis.xml │ │ │ └── color_primary_low_emphasis.xml │ │ ├── drawable/ │ │ │ ├── color_circle.xml │ │ │ ├── ic_close_24.xml │ │ │ ├── list_item_divider.xml │ │ │ ├── radio_button_inset.xml │ │ │ └── shadow_up.xml │ │ ├── layout/ │ │ │ ├── app_bar_layout.xml │ │ │ └── color_picker_dialog_layout.xml │ │ └── values/ │ │ ├── attrs.xml │ │ ├── color_picker_dialog_colors.xml │ │ ├── dimens.xml │ │ └── styles.xml │ └── test/ │ ├── java/ │ │ └── org/ │ │ └── odk/ │ │ └── collect/ │ │ └── androidshared/ │ │ ├── async/ │ │ │ └── TrackableWorkerTest.kt │ │ ├── livedata/ │ │ │ └── LiveDataUtilsTest.kt │ │ ├── system/ │ │ │ └── UriExtTest.kt │ │ ├── ui/ │ │ │ ├── ColorPickerDialogTest.kt │ │ │ ├── OneSignTextWatcherTest.kt │ │ │ └── ReturnToAppActivityTest.kt │ │ └── utils/ │ │ ├── ColorUtilsTest.kt │ │ ├── CompressionUtilsTest.kt │ │ ├── DialogFragmentUtilsTest.java │ │ ├── InMemUniqueIdGeneratorTest.kt │ │ ├── IntentLauncherImplTest.kt │ │ ├── PathUtilsTest.kt │ │ ├── SettingsUniqueIdGeneratorTest.kt │ │ ├── UniqueIdGeneratorTest.kt │ │ └── ValidatorTest.kt │ └── resources/ │ └── robolectric.properties ├── androidtest/ │ ├── .gitignore │ ├── README.md │ ├── build.gradle.kts │ └── src/ │ └── main/ │ ├── AndroidManifest.xml │ └── java/ │ └── org/ │ └── odk/ │ └── collect/ │ └── androidtest/ │ ├── ActivityScenarioExtensions.kt │ ├── ActivityScenarioLauncherRule.kt │ ├── DrawableMatcher.kt │ ├── FakeLifecycleOwner.kt │ ├── FragmentScenarioExtensions.kt │ ├── LiveDataTestUtils.kt │ ├── MainDispatcherRule.kt │ ├── NodeInteractionExtensions.kt │ └── RecordedIntentsRule.kt ├── async/ │ ├── .gitignore │ ├── build.gradle.kts │ ├── proguard-rules.pro │ └── src/ │ ├── main/ │ │ ├── AndroidManifest.xml │ │ └── java/ │ │ └── org/ │ │ └── odk/ │ │ └── collect/ │ │ └── async/ │ │ ├── Cancellable.kt │ │ ├── OngoingWorkListener.kt │ │ ├── Scheduler.kt │ │ ├── SchedulerAsyncTaskMimic.kt │ │ ├── SchedulerBuilder.kt │ │ ├── ScopeCancellable.kt │ │ ├── TaskRunner.kt │ │ ├── TaskSpec.kt │ │ ├── TaskSpecRunner.kt │ │ ├── TaskSpecScheduler.kt │ │ ├── coroutines/ │ │ │ └── CoroutineTaskRunner.kt │ │ ├── network/ │ │ │ ├── ConnectivityProvider.kt │ │ │ └── NetworkStateProvider.kt │ │ ├── services/ │ │ │ └── ForegroundServiceTaskSpecRunner.kt │ │ └── workmanager/ │ │ ├── TaskSpecWorker.kt │ │ └── WorkManagerTaskSpecScheduler.kt │ └── test/ │ └── java/ │ └── org/ │ └── odk/ │ └── collect/ │ └── async/ │ ├── TaskSpecTest.kt │ └── workmanager/ │ └── TaskSpecWorkerTest.kt ├── audio-clips/ │ ├── .gitignore │ ├── build.gradle.kts │ └── src/ │ ├── main/ │ │ ├── AndroidManifest.xml │ │ └── java/ │ │ └── org/ │ │ └── odk/ │ │ └── collect/ │ │ └── audioclips/ │ │ ├── AudioClipViewModel.kt │ │ ├── AudioPlayer.kt │ │ ├── AudioPlayerFactory.kt │ │ ├── Clip.kt │ │ ├── PlaybackFailedException.kt │ │ └── ThreadSafeMediaPlayerWrapper.kt │ └── test/ │ └── java/ │ └── org/ │ └── odk/ │ └── collect/ │ └── audioclips/ │ └── AudioClipViewModelTest.kt ├── audio-recorder/ │ ├── .gitignore │ ├── build.gradle.kts │ └── src/ │ ├── main/ │ │ ├── AndroidManifest.xml │ │ └── java/ │ │ └── org/ │ │ └── odk/ │ │ └── collect/ │ │ └── audiorecorder/ │ │ ├── DaggerSetup.kt │ │ ├── mediarecorder/ │ │ │ └── MediaRecorderRecordingResource.kt │ │ ├── recorder/ │ │ │ ├── Recorder.kt │ │ │ ├── RecordingResource.kt │ │ │ └── RecordingResourceRecorder.kt │ │ ├── recording/ │ │ │ ├── AudioRecorder.kt │ │ │ ├── AudioRecorderFactory.kt │ │ │ ├── AudioRecorderService.kt │ │ │ └── internal/ │ │ │ ├── ForegroundServiceAudioRecorder.kt │ │ │ ├── RecordingForegroundServiceNotification.kt │ │ │ └── RecordingRepository.kt │ │ └── testsupport/ │ │ └── StubAudioRecorder.kt │ └── test/ │ ├── java/ │ │ └── org/ │ │ └── odk/ │ │ └── collect/ │ │ └── audiorecorder/ │ │ ├── mediarecorder/ │ │ │ └── AMRRecordingResourceTest.kt │ │ ├── recorder/ │ │ │ └── RecordingResourceRecorderTest.kt │ │ ├── recording/ │ │ │ ├── AudioRecorderTest.kt │ │ │ └── internal/ │ │ │ ├── AudioRecorderServiceTest.kt │ │ │ ├── ForegroundServiceAudioRecorderTest.kt │ │ │ └── RecordingForegroundServiceNotificationTest.kt │ │ ├── support/ │ │ │ └── FakeRecorder.kt │ │ └── testsupport/ │ │ ├── RobolectricApplication.kt │ │ └── StubAudioRecorderTest.kt │ └── resources/ │ └── robolectric.properties ├── benchmark.sh ├── build.gradle ├── check-size.sh ├── codecov.yml ├── collect_app/ │ ├── build.gradle │ ├── google-services.json │ ├── libs/ │ │ └── bikram-sambat-1.8.0.jar │ ├── proguard-rules.txt │ └── src/ │ ├── androidTest/ │ │ ├── assets/ │ │ │ └── instances/ │ │ │ ├── One Question_2021-06-22_15-55-50.xml │ │ │ └── one-question-google_2023-08-08_14-51-00.xml │ │ └── java/ │ │ └── org/ │ │ └── odk/ │ │ └── collect/ │ │ └── android/ │ │ ├── benchmark/ │ │ │ ├── EntitiesBenchmarkTest.kt │ │ │ ├── FormsUpdateBenchmarkTest.kt │ │ │ ├── SearchBenchmarkTest.kt │ │ │ └── support/ │ │ │ └── Benchmarker.kt │ │ ├── feature/ │ │ │ ├── entitymanagement/ │ │ │ │ └── ViewEntitiesTest.kt │ │ │ ├── external/ │ │ │ │ ├── AndroidShortcutsTest.kt │ │ │ │ ├── FormDownloadActionTest.kt │ │ │ │ ├── FormEditActionTest.kt │ │ │ │ ├── FormPickActionTest.kt │ │ │ │ ├── InstanceEditActionTest.kt │ │ │ │ ├── InstancePickActionTest.kt │ │ │ │ └── InstanceUploadActionTest.kt │ │ │ ├── formentry/ │ │ │ │ ├── AddRepeatTest.kt │ │ │ │ ├── AudioAutoplayTest.kt │ │ │ │ ├── AudioRecordingTest.java │ │ │ │ ├── BackgroundAudioRecordingTest.java │ │ │ │ ├── CascadingSelectTest.kt │ │ │ │ ├── CatchFormDesignExceptionsTest.kt │ │ │ │ ├── ContextMenuTest.java │ │ │ │ ├── DeletingRepeatGroupsTest.java │ │ │ │ ├── EncryptedFormTest.kt │ │ │ │ ├── ExternalAudioRecordingTest.java │ │ │ │ ├── ExternalSecondaryInstanceTest.java │ │ │ │ ├── ExternalSelectsTest.kt │ │ │ │ ├── FieldListUpdateTest.kt │ │ │ │ ├── FillBlankFormWithRepeatGroupTest.kt │ │ │ │ ├── FormEndTest.kt │ │ │ │ ├── FormHierarchyTest.java │ │ │ │ ├── FormLanguageTest.java │ │ │ │ ├── FormMediaTest.kt │ │ │ │ ├── FormMetadataTest.kt │ │ │ │ ├── FormNavigationTest.kt │ │ │ │ ├── FormSaveTest.kt │ │ │ │ ├── FormStylingTest.kt │ │ │ │ ├── GuidanceTest.kt │ │ │ │ ├── ImageLoadingTest.kt │ │ │ │ ├── IntentGroupTest.kt │ │ │ │ ├── InvalidFormTest.kt │ │ │ │ ├── LikertTest.java │ │ │ │ ├── NestedIntentGroupTest.kt │ │ │ │ ├── QuickSaveTest.java │ │ │ │ ├── QuittingFormTest.java │ │ │ │ ├── RankingWidgetWithCSVTest.java │ │ │ │ ├── RequiredAndConstraintQuestionTest.kt │ │ │ │ ├── SaveIncompleteTest.kt │ │ │ │ ├── SavePointTest.kt │ │ │ │ ├── SearchAppearancesTest.kt │ │ │ │ ├── audit/ │ │ │ │ │ ├── AuditTest.kt │ │ │ │ │ ├── IdentifyUserTest.kt │ │ │ │ │ └── TrackChangesReasonTest.kt │ │ │ │ ├── backgroundlocation/ │ │ │ │ │ ├── LocationTrackingAuditTest.java │ │ │ │ │ └── SetGeopointActionTest.java │ │ │ │ ├── dynamicpreload/ │ │ │ │ │ ├── DynamicPreLoadedDataPullTest.kt │ │ │ │ │ └── DynamicPreLoadedDataSelects.java │ │ │ │ └── entities/ │ │ │ │ ├── EntityFormApprovalTest.kt │ │ │ │ ├── EntityFormCreateUpdateTest.kt │ │ │ │ ├── EntityFormEditTest.kt │ │ │ │ ├── EntityFormLockingTest.kt │ │ │ │ ├── EntityFormSpecVersionTest.kt │ │ │ │ └── EntityListSyncTest.kt │ │ │ ├── formmanagement/ │ │ │ │ ├── BulkFinalizationTest.kt │ │ │ │ ├── DeleteBlankFormTest.java │ │ │ │ ├── FormUpdateTest.kt │ │ │ │ ├── FormsAdbTest.java │ │ │ │ ├── GetBlankFormsTest.java │ │ │ │ ├── HideOldVersionsTest.java │ │ │ │ ├── ManualUpdatesTest.java │ │ │ │ ├── MatchExactlyTest.kt │ │ │ │ └── PreviouslyDownloadedOnlyTest.kt │ │ │ ├── instancemanagement/ │ │ │ │ ├── AutoSendTest.kt │ │ │ │ ├── DeleteSavedFormTest.kt │ │ │ │ ├── EditSavedFormTest.kt │ │ │ │ ├── InstancesAdbTest.kt │ │ │ │ ├── PartialSubmissionTest.kt │ │ │ │ └── SendFinalizedFormTest.kt │ │ │ ├── maps/ │ │ │ │ └── FormMapTest.kt │ │ │ ├── projects/ │ │ │ │ ├── AddNewProjectTest.kt │ │ │ │ ├── DeleteProjectTest.kt │ │ │ │ ├── GoogleDriveDeprecationTest.kt │ │ │ │ ├── LaunchScreenTest.kt │ │ │ │ ├── MobileDeviceManagementTest.kt │ │ │ │ ├── ProjectsAdbTest.kt │ │ │ │ ├── SwitchProjectTest.kt │ │ │ │ └── UpdateProjectTest.kt │ │ │ ├── settings/ │ │ │ │ ├── ConfigureWithQRCodeTest.java │ │ │ │ ├── FormEntrySettingsTest.kt │ │ │ │ ├── FormManagementSettingsTest.kt │ │ │ │ ├── FormMetadataSettingsTest.kt │ │ │ │ ├── MovingBackwardsTest.kt │ │ │ │ ├── ResetProjectTest.kt │ │ │ │ ├── ServerSettingsTest.java │ │ │ │ └── SettingLanguageTest.kt │ │ │ └── smoke/ │ │ │ ├── AllWidgetsFormTest.kt │ │ │ ├── BadServerTest.java │ │ │ └── GetAndSubmitFormTest.java │ │ ├── instrumented/ │ │ │ ├── forms/ │ │ │ │ └── FormUtilsTest.java │ │ │ ├── tasks/ │ │ │ │ └── FormLoaderTaskTest.java │ │ │ └── utilities/ │ │ │ ├── CustomSQLiteQueryExecutionTest.java │ │ │ └── DateTimeUtilsTest.java │ │ └── support/ │ │ ├── ActivityHelpers.java │ │ ├── CollectHelpers.kt │ │ ├── ContentProviderUtils.kt │ │ ├── CountingTaskExecutorIdlingResource.java │ │ ├── DummyActivityLauncher.kt │ │ ├── FakeClickableMapFragment.kt │ │ ├── FakeLocationClient.java │ │ ├── FakeNetworkStateProvider.kt │ │ ├── StorageUtils.kt │ │ ├── StubOpenRosaServer.kt │ │ ├── SubmissionParser.kt │ │ ├── TestDependencies.kt │ │ ├── TranslatedStringBuilder.kt │ │ ├── actions/ │ │ │ └── RotateAction.java │ │ ├── async/ │ │ │ ├── AsyncWorkTracker.kt │ │ │ ├── AsyncWorkTrackerIdlingResource.kt │ │ │ ├── TrackingCoroutineAndWorkManagerScheduler.kt │ │ │ └── TrackingCoroutineDispatcher.kt │ │ ├── matchers/ │ │ │ ├── CustomMatchers.kt │ │ │ └── ToastMatcher.java │ │ ├── pages/ │ │ │ ├── AboutPage.java │ │ │ ├── AccessControlPage.kt │ │ │ ├── AddNewRepeatDialog.kt │ │ │ ├── AppClosedPage.kt │ │ │ ├── AsyncPage.kt │ │ │ ├── BlankFormSearchPage.java │ │ │ ├── BulkFinalizationConfirmationDialogPage.kt │ │ │ ├── CancelRecordingDialog.java │ │ │ ├── ChangesReasonPromptPage.java │ │ │ ├── DeleteSavedFormPage.java │ │ │ ├── DeleteSelectedDialog.java │ │ │ ├── EditSavedFormPage.java │ │ │ ├── EntitiesPage.kt │ │ │ ├── EntityListPage.kt │ │ │ ├── ErrorDialog.kt │ │ │ ├── ErrorPage.kt │ │ │ ├── ExperimentalPage.java │ │ │ ├── FillBlankFormPage.java │ │ │ ├── FirstLaunchPage.kt │ │ │ ├── FormEndPage.java │ │ │ ├── FormEntryPage.java │ │ │ ├── FormHierarchyPage.kt │ │ │ ├── FormManagementPage.java │ │ │ ├── FormMapPage.java │ │ │ ├── FormMetadataPage.java │ │ │ ├── FormsDownloadResultPage.kt │ │ │ ├── GetBlankFormPage.java │ │ │ ├── IdentifyUserPromptPage.java │ │ │ ├── ListPreferenceDialog.java │ │ │ ├── MainMenuPage.java │ │ │ ├── MainMenuSettingsPage.kt │ │ │ ├── ManualProjectCreatorDialogPage.kt │ │ │ ├── MapsSettingsPage.java │ │ │ ├── NotificationDrawer.kt │ │ │ ├── OkDialog.java │ │ │ ├── OpenSourceLicensesPage.java │ │ │ ├── Page.kt │ │ │ ├── PreferencePage.java │ │ │ ├── ProjectDisplayPage.kt │ │ │ ├── ProjectManagementPage.kt │ │ │ ├── ProjectSettingsDialogPage.kt │ │ │ ├── ProjectSettingsPage.java │ │ │ ├── QRCodePage.java │ │ │ ├── QrCodeProjectCreatorDialogPage.kt │ │ │ ├── ResetApplicationDialog.java │ │ │ ├── SaveOrDiscardFormDialog.kt │ │ │ ├── SaveOrIgnoreDrawingDialog.kt │ │ │ ├── SavepointRecoveryDialogPage.kt │ │ │ ├── SelectMinimalDialogPage.kt │ │ │ ├── SendFinalizedFormPage.kt │ │ │ ├── ServerAuthDialog.java │ │ │ ├── ServerSettingsPage.java │ │ │ ├── ShortcutsPage.java │ │ │ ├── UserAndDeviceIdentitySettingsPage.java │ │ │ ├── UserInterfacePage.java │ │ │ ├── ViewFormPage.kt │ │ │ └── ViewSentFormPage.java │ │ └── rules/ │ │ ├── BlankFormTestRule.kt │ │ ├── CollectTestRule.kt │ │ ├── FormEntryActivityTestRule.kt │ │ ├── IdlingResourceRule.kt │ │ ├── NotificationDrawerRule.kt │ │ ├── PageComposeRule.kt │ │ ├── PrepDeviceForTestsRule.kt │ │ ├── RecentAppsRule.kt │ │ ├── ResetRotationRule.kt │ │ ├── ResetStateRule.kt │ │ ├── RetryOnDeviceErrorRule.kt │ │ ├── RunnableRule.kt │ │ └── TestRuleChain.kt │ ├── debug/ │ │ ├── AndroidManifest.xml │ │ └── google-services.json │ ├── main/ │ │ ├── AndroidManifest.xml │ │ ├── assets/ │ │ │ └── svg_map_helper.js │ │ ├── java/ │ │ │ └── org/ │ │ │ └── odk/ │ │ │ └── collect/ │ │ │ ├── android/ │ │ │ │ ├── activities/ │ │ │ │ │ ├── AboutActivity.kt │ │ │ │ │ ├── ActivityUtils.java │ │ │ │ │ ├── AppListActivity.java │ │ │ │ │ ├── BearingActivity.java │ │ │ │ │ ├── CrashHandlerActivity.kt │ │ │ │ │ ├── DeleteFormsActivity.kt │ │ │ │ │ ├── FirstLaunchActivity.kt │ │ │ │ │ ├── FormDownloadListActivity.java │ │ │ │ │ ├── FormEntryViewModelFactory.kt │ │ │ │ │ ├── FormFillingActivity.java │ │ │ │ │ ├── FormListActivity.java │ │ │ │ │ ├── FormMapActivity.kt │ │ │ │ │ ├── InstanceChooserList.java │ │ │ │ │ ├── ScannerWithFlashlightActivity.kt │ │ │ │ │ └── viewmodels/ │ │ │ │ │ └── FormDownloadListViewModel.java │ │ │ │ ├── adapters/ │ │ │ │ │ ├── AboutListAdapter.kt │ │ │ │ │ ├── AbstractSelectListAdapter.java │ │ │ │ │ ├── FormDownloadListAdapter.java │ │ │ │ │ ├── InstanceListCursorAdapter.java │ │ │ │ │ ├── InstanceUploaderAdapter.java │ │ │ │ │ ├── RankingListAdapter.java │ │ │ │ │ ├── SelectMultipleListAdapter.java │ │ │ │ │ └── SelectOneListAdapter.java │ │ │ │ ├── analytics/ │ │ │ │ │ ├── AnalyticsEvents.kt │ │ │ │ │ └── AnalyticsUtils.kt │ │ │ │ ├── application/ │ │ │ │ │ ├── Collect.java │ │ │ │ │ ├── CollectSettingsChangeHandler.kt │ │ │ │ │ ├── ComposeTheme.kt │ │ │ │ │ ├── FeatureFlags.kt │ │ │ │ │ ├── MapboxClassInstanceCreator.kt │ │ │ │ │ └── initialization/ │ │ │ │ │ ├── AnalyticsInitializer.kt │ │ │ │ │ ├── ApplicationInitializer.kt │ │ │ │ │ ├── CachedFormsCleaner.kt │ │ │ │ │ ├── CrashReportingTree.kt │ │ │ │ │ ├── ExistingProjectMigrator.kt │ │ │ │ │ ├── ExistingSettingsMigrator.kt │ │ │ │ │ ├── GoogleDriveProjectsDeleter.kt │ │ │ │ │ ├── JavaRosaInitializer.kt │ │ │ │ │ ├── MapsInitializer.kt │ │ │ │ │ ├── SavepointsImporter.kt │ │ │ │ │ ├── ScheduledWorkUpgrade.kt │ │ │ │ │ ├── UserPropertiesInitializer.kt │ │ │ │ │ └── upgrade/ │ │ │ │ │ ├── BeforeProjectsInstallDetector.kt │ │ │ │ │ └── UpgradeInitializer.kt │ │ │ │ ├── audio/ │ │ │ │ │ ├── AMRAppender.java │ │ │ │ │ ├── AudioButton.java │ │ │ │ │ ├── AudioControllerView.java │ │ │ │ │ ├── AudioFileAppender.java │ │ │ │ │ ├── AudioRecordingControllerFragment.java │ │ │ │ │ ├── AudioRecordingErrorDialogFragment.java │ │ │ │ │ ├── BackgroundAudioHelpDialogFragment.java │ │ │ │ │ ├── M4AAppender.java │ │ │ │ │ ├── VolumeBar.java │ │ │ │ │ └── Waveform.java │ │ │ │ ├── backgroundwork/ │ │ │ │ │ ├── AutoUpdateTaskSpec.kt │ │ │ │ │ ├── BackgroundWorkUtils.java │ │ │ │ │ ├── FormUpdateAndInstanceSubmitScheduler.java │ │ │ │ │ ├── FormUpdateScheduler.java │ │ │ │ │ ├── InstanceSubmitScheduler.java │ │ │ │ │ ├── SendFormsTaskSpec.kt │ │ │ │ │ ├── SyncFormsTaskSpec.kt │ │ │ │ │ └── TaskData.kt │ │ │ │ ├── configure/ │ │ │ │ │ └── qr/ │ │ │ │ │ ├── AppConfigurationGenerator.kt │ │ │ │ │ ├── CachingQRCodeGenerator.kt │ │ │ │ │ ├── QRCodeActivityResultDelegate.kt │ │ │ │ │ ├── QRCodeMenuProvider.kt │ │ │ │ │ ├── QRCodeScannerFragment.kt │ │ │ │ │ ├── QRCodeTabsActivity.kt │ │ │ │ │ ├── QRCodeViewModel.kt │ │ │ │ │ ├── SettingsBarcodeScannerViewFactory.kt │ │ │ │ │ └── ShowQRCodeFragment.kt │ │ │ │ ├── dao/ │ │ │ │ │ ├── CursorLoaderFactory.java │ │ │ │ │ └── helpers/ │ │ │ │ │ └── InstancesDaoHelper.java │ │ │ │ ├── database/ │ │ │ │ │ ├── DatabaseConstants.java │ │ │ │ │ ├── DatabaseObjectMapper.kt │ │ │ │ │ ├── entities/ │ │ │ │ │ │ └── DatabaseEntitiesRepository.kt │ │ │ │ │ ├── forms/ │ │ │ │ │ │ ├── DatabaseFormColumns.kt │ │ │ │ │ │ ├── DatabaseFormsRepository.java │ │ │ │ │ │ └── FormDatabaseMigrator.java │ │ │ │ │ ├── instances/ │ │ │ │ │ │ ├── DatabaseInstanceColumns.kt │ │ │ │ │ │ ├── DatabaseInstancesRepository.java │ │ │ │ │ │ └── InstanceDatabaseMigrator.java │ │ │ │ │ ├── itemsets/ │ │ │ │ │ │ └── DatabaseFastExternalItemsetsRepository.kt │ │ │ │ │ └── savepoints/ │ │ │ │ │ ├── DatabaseSavepointsColumns.kt │ │ │ │ │ ├── DatabaseSavepointsRepository.kt │ │ │ │ │ └── SavepointsDatabaseMigrator.kt │ │ │ │ ├── dynamicpreload/ │ │ │ │ │ ├── DynamicPreloadXFormParserFactory.kt │ │ │ │ │ ├── ExternalAnswerResolver.java │ │ │ │ │ ├── ExternalAppsUtils.java │ │ │ │ │ ├── ExternalDataHandler.java │ │ │ │ │ ├── ExternalDataManager.java │ │ │ │ │ ├── ExternalDataManagerImpl.java │ │ │ │ │ ├── ExternalDataReader.java │ │ │ │ │ ├── ExternalDataReaderImpl.java │ │ │ │ │ ├── ExternalDataUseCases.kt │ │ │ │ │ ├── ExternalDataUtil.java │ │ │ │ │ ├── ExternalSQLiteOpenHelper.java │ │ │ │ │ ├── ExternalSelectChoice.java │ │ │ │ │ └── handler/ │ │ │ │ │ ├── ExternalDataHandlerBase.java │ │ │ │ │ ├── ExternalDataHandlerPull.java │ │ │ │ │ ├── ExternalDataHandlerSearch.java │ │ │ │ │ └── ExternalDataSearchType.java │ │ │ │ ├── entities/ │ │ │ │ │ └── EntitiesRepositoryProvider.kt │ │ │ │ ├── exception/ │ │ │ │ │ ├── EncryptionException.java │ │ │ │ │ ├── ExternalDataException.java │ │ │ │ │ ├── ExternalParamsException.java │ │ │ │ │ └── JavaRosaException.java │ │ │ │ ├── external/ │ │ │ │ │ ├── AndroidShortcutsActivity.kt │ │ │ │ │ ├── FormUriActivity.kt │ │ │ │ │ ├── FormsContract.java │ │ │ │ │ ├── FormsProvider.java │ │ │ │ │ ├── InstanceProvider.java │ │ │ │ │ └── InstancesContract.java │ │ │ │ ├── fastexternalitemset/ │ │ │ │ │ ├── ItemsetDao.java │ │ │ │ │ ├── ItemsetDbAdapter.java │ │ │ │ │ └── XPathParseTool.java │ │ │ │ ├── formentry/ │ │ │ │ │ ├── BackgroundAudioPermissionDialogFragment.java │ │ │ │ │ ├── BackgroundAudioViewModel.java │ │ │ │ │ ├── CurrentFormIndex.kt │ │ │ │ │ ├── FormAnimation.kt │ │ │ │ │ ├── FormDefCache.kt │ │ │ │ │ ├── FormEnd.kt │ │ │ │ │ ├── FormEndView.kt │ │ │ │ │ ├── FormEndViewModel.kt │ │ │ │ │ ├── FormEntryMenuProvider.kt │ │ │ │ │ ├── FormEntryUseCases.kt │ │ │ │ │ ├── FormEntryViewModel.java │ │ │ │ │ ├── FormError.kt │ │ │ │ │ ├── FormIndexAnimationHandler.kt │ │ │ │ │ ├── FormLoadingDialogFragment.java │ │ │ │ │ ├── FormOpeningMode.kt │ │ │ │ │ ├── FormSessionRepository.kt │ │ │ │ │ ├── ODKView.java │ │ │ │ │ ├── PrinterWidgetViewModel.kt │ │ │ │ │ ├── QuitFormDialog.kt │ │ │ │ │ ├── RecordingHandler.java │ │ │ │ │ ├── RecordingWarningDialogFragment.java │ │ │ │ │ ├── RefreshFormListDialogFragment.java │ │ │ │ │ ├── SwipeHandler.kt │ │ │ │ │ ├── audit/ │ │ │ │ │ │ ├── AsyncTaskAuditEventWriter.java │ │ │ │ │ │ ├── AuditConfig.java │ │ │ │ │ │ ├── AuditEvent.java │ │ │ │ │ │ ├── AuditEventCSVLine.java │ │ │ │ │ │ ├── AuditEventLogger.java │ │ │ │ │ │ ├── AuditEventSaveTask.java │ │ │ │ │ │ ├── AuditUtils.kt │ │ │ │ │ │ ├── ChangesReasonPromptDialogFragment.java │ │ │ │ │ │ ├── IdentifyUserPromptDialogFragment.java │ │ │ │ │ │ └── IdentityPromptViewModel.java │ │ │ │ │ ├── backgroundlocation/ │ │ │ │ │ │ ├── BackgroundLocationHelper.java │ │ │ │ │ │ ├── BackgroundLocationManager.java │ │ │ │ │ │ └── BackgroundLocationViewModel.java │ │ │ │ │ ├── media/ │ │ │ │ │ │ ├── FormMediaUtils.java │ │ │ │ │ │ └── PromptAutoplayer.java │ │ │ │ │ ├── questions/ │ │ │ │ │ │ ├── AnswersProvider.java │ │ │ │ │ │ ├── AudioVideoImageTextLabel.java │ │ │ │ │ │ ├── NoButtonsItem.java │ │ │ │ │ │ ├── QuestionDetails.java │ │ │ │ │ │ └── SelectChoiceUtils.kt │ │ │ │ │ ├── repeats/ │ │ │ │ │ │ ├── AddRepeatDialog.kt │ │ │ │ │ │ └── DeleteRepeatDialogFragment.java │ │ │ │ │ └── saving/ │ │ │ │ │ ├── DiskFormSaver.java │ │ │ │ │ ├── FormSaveViewModel.java │ │ │ │ │ ├── FormSaver.java │ │ │ │ │ ├── SaveAnswerFileErrorDialogFragment.java │ │ │ │ │ ├── SaveAnswerFileProgressDialogFragment.java │ │ │ │ │ └── SaveFormProgressDialogFragment.java │ │ │ │ ├── formhierarchy/ │ │ │ │ │ ├── FormHierarchyFragment.java │ │ │ │ │ ├── FormHierarchyFragmentHostActivity.kt │ │ │ │ │ ├── FormHierarchyViewModel.kt │ │ │ │ │ ├── HierarchyItem.kt │ │ │ │ │ ├── HierarchyListAdapter.kt │ │ │ │ │ ├── HierarchyListItemView.kt │ │ │ │ │ └── QuestionAnswerProcessor.kt │ │ │ │ ├── formlists/ │ │ │ │ │ ├── blankformlist/ │ │ │ │ │ │ ├── BlankFormListActivity.kt │ │ │ │ │ │ ├── BlankFormListAdapter.kt │ │ │ │ │ │ ├── BlankFormListItem.kt │ │ │ │ │ │ ├── BlankFormListItemView.kt │ │ │ │ │ │ ├── BlankFormListMenuProvider.kt │ │ │ │ │ │ ├── BlankFormListViewModel.kt │ │ │ │ │ │ └── DeleteBlankFormFragment.kt │ │ │ │ │ ├── savedformlist/ │ │ │ │ │ │ ├── DeleteSavedFormFragment.kt │ │ │ │ │ │ ├── SavedFormListItemView.kt │ │ │ │ │ │ ├── SavedFormListListMenuProvider.kt │ │ │ │ │ │ ├── SavedFormListViewModel.kt │ │ │ │ │ │ └── SelectableSavedFormListItemViewHolder.kt │ │ │ │ │ └── sorting/ │ │ │ │ │ ├── FormListSortingAdapter.kt │ │ │ │ │ ├── FormListSortingBottomSheetDialog.kt │ │ │ │ │ └── FormListSortingOption.kt │ │ │ │ ├── formmanagement/ │ │ │ │ │ ├── CollectFormEntryControllerFactory.kt │ │ │ │ │ ├── FormFillingIntentFactory.kt │ │ │ │ │ ├── FormSourceExceptionMapper.kt │ │ │ │ │ ├── FormsDataService.kt │ │ │ │ │ ├── LocalFormUseCases.kt │ │ │ │ │ ├── OpenRosaClientProvider.kt │ │ │ │ │ ├── ServerFormDetails.kt │ │ │ │ │ ├── ServerFormUseCases.kt │ │ │ │ │ ├── download/ │ │ │ │ │ │ ├── FormDownloadException.kt │ │ │ │ │ │ ├── FormDownloadExceptionMapper.kt │ │ │ │ │ │ ├── FormDownloader.kt │ │ │ │ │ │ └── ServerFormDownloader.java │ │ │ │ │ ├── drafts/ │ │ │ │ │ │ ├── BulkFinalizationViewModel.kt │ │ │ │ │ │ └── DraftsMenuProvider.kt │ │ │ │ │ ├── finalization/ │ │ │ │ │ │ └── EditedFormFinalizationProcessor.kt │ │ │ │ │ ├── formmap/ │ │ │ │ │ │ └── FormMapViewModel.kt │ │ │ │ │ ├── matchexactly/ │ │ │ │ │ │ └── ServerFormsSynchronizer.java │ │ │ │ │ └── metadata/ │ │ │ │ │ ├── FormMetadata.kt │ │ │ │ │ └── FormMetadataParser.kt │ │ │ │ ├── fragments/ │ │ │ │ │ ├── BarCodeScannerFragment.kt │ │ │ │ │ ├── BarcodeWidgetScannerFragment.kt │ │ │ │ │ ├── MediaLoadingFragment.java │ │ │ │ │ ├── dialogs/ │ │ │ │ │ │ ├── FormsDownloadResultDialog.kt │ │ │ │ │ │ ├── LocationProvidersDisabledDialog.java │ │ │ │ │ │ ├── MovingBackwardsDialog.java │ │ │ │ │ │ ├── RangePickerDialogFragment.kt │ │ │ │ │ │ ├── RankingWidgetDialog.java │ │ │ │ │ │ ├── ResetSettingsResultDialog.java │ │ │ │ │ │ ├── SelectMinimalDialog.java │ │ │ │ │ │ ├── SelectMultiMinimalDialog.java │ │ │ │ │ │ ├── SelectOneMinimalDialog.java │ │ │ │ │ │ └── SimpleDialog.java │ │ │ │ │ └── viewmodels/ │ │ │ │ │ ├── RankingViewModel.java │ │ │ │ │ └── SelectMinimalViewModel.java │ │ │ │ ├── geo/ │ │ │ │ │ ├── MapConfiguratorProvider.java │ │ │ │ │ └── MapFragmentFactoryImpl.kt │ │ │ │ ├── injection/ │ │ │ │ │ ├── DaggerUtils.kt │ │ │ │ │ └── config/ │ │ │ │ │ ├── AppDependencyComponent.kt │ │ │ │ │ ├── AppDependencyModule.java │ │ │ │ │ ├── CollectDrawDependencyModule.kt │ │ │ │ │ ├── CollectEntitiesDependencyModule.kt │ │ │ │ │ ├── CollectGeoDependencyModule.kt │ │ │ │ │ ├── CollectGoogleMapsDependencyModule.kt │ │ │ │ │ ├── CollectOsmDroidDependencyModule.kt │ │ │ │ │ ├── CollectProjectsDependencyModule.kt │ │ │ │ │ ├── CollectSelfieCameraDependencyModule.kt │ │ │ │ │ └── ProjectDependencyModuleFactory.kt │ │ │ │ ├── instancemanagement/ │ │ │ │ │ ├── FinalizeAllSnackbarPresenter.kt │ │ │ │ │ ├── InstanceDeleter.kt │ │ │ │ │ ├── InstanceDiskSynchronizer.java │ │ │ │ │ ├── InstanceExt.kt │ │ │ │ │ ├── InstanceListItemView.kt │ │ │ │ │ ├── InstanceSubmitter.kt │ │ │ │ │ ├── InstancesDataService.kt │ │ │ │ │ ├── LocalInstancesUseCases.kt │ │ │ │ │ ├── autosend/ │ │ │ │ │ │ ├── AutoSendSettingsProvider.kt │ │ │ │ │ │ ├── FormExt.kt │ │ │ │ │ │ └── InstanceAutoSendFetcher.kt │ │ │ │ │ └── send/ │ │ │ │ │ ├── InstanceUploaderActivity.java │ │ │ │ │ ├── InstanceUploaderListActivity.java │ │ │ │ │ ├── ReadyToSendBanner.kt │ │ │ │ │ └── ReadyToSendViewModel.kt │ │ │ │ ├── itemsets/ │ │ │ │ │ └── FastExternalItemsetsRepository.kt │ │ │ │ ├── javarosawrapper/ │ │ │ │ │ ├── FormController.kt │ │ │ │ │ ├── FormControllerExt.kt │ │ │ │ │ ├── FormDesignException.kt │ │ │ │ │ ├── FormIndexUtils.java │ │ │ │ │ ├── InstanceMetadata.java │ │ │ │ │ ├── JavaRosaFormController.java │ │ │ │ │ └── ValidationResult.kt │ │ │ │ ├── listeners/ │ │ │ │ │ ├── AdvanceToNextListener.java │ │ │ │ │ ├── DeleteInstancesListener.java │ │ │ │ │ ├── DownloadFormsTaskListener.java │ │ │ │ │ ├── FormListDownloaderListener.java │ │ │ │ │ ├── FormLoaderListener.java │ │ │ │ │ ├── InstanceUploaderListener.java │ │ │ │ │ ├── Result.java │ │ │ │ │ ├── SelectItemClickListener.java │ │ │ │ │ ├── ThousandsSeparatorTextWatcher.java │ │ │ │ │ └── WidgetValueChangedListener.java │ │ │ │ ├── location/ │ │ │ │ │ └── client/ │ │ │ │ │ └── MaxAccuracyWithinTimeoutLocationClientWrapper.java │ │ │ │ ├── logic/ │ │ │ │ │ ├── FileReference.java │ │ │ │ │ ├── FileReferenceFactory.java │ │ │ │ │ ├── ImmutableDisplayableQuestion.java │ │ │ │ │ └── actions/ │ │ │ │ │ └── setgeopoint/ │ │ │ │ │ ├── CollectSetGeopointAction.java │ │ │ │ │ └── CollectSetGeopointActionHandler.java │ │ │ │ ├── mainmenu/ │ │ │ │ │ ├── CurrentProjectViewModel.kt │ │ │ │ │ ├── MainMenuActivity.kt │ │ │ │ │ ├── MainMenuButton.kt │ │ │ │ │ ├── MainMenuFragment.kt │ │ │ │ │ ├── MainMenuViewModel.kt │ │ │ │ │ ├── MainMenuViewModelFactory.kt │ │ │ │ │ ├── PermissionsDialogFragment.kt │ │ │ │ │ ├── RequestPermissionsViewModel.kt │ │ │ │ │ └── StartNewFormButton.kt │ │ │ │ ├── notifications/ │ │ │ │ │ ├── NotificationManagerNotifier.kt │ │ │ │ │ ├── NotificationUtils.kt │ │ │ │ │ ├── Notifier.kt │ │ │ │ │ └── builders/ │ │ │ │ │ ├── FormUpdatesAvailableNotificationBuilder.kt │ │ │ │ │ ├── FormUpdatesDownloadedNotificationBuilder.kt │ │ │ │ │ ├── FormsSubmissionNotificationBuilder.kt │ │ │ │ │ ├── FormsSyncFailedNotificationBuilder.kt │ │ │ │ │ └── FormsSyncStoppedNotificationBuilder.kt │ │ │ │ ├── preferences/ │ │ │ │ │ ├── Defaults.kt │ │ │ │ │ ├── PreferenceVisibilityHandler.kt │ │ │ │ │ ├── ProjectPreferencesViewModel.kt │ │ │ │ │ ├── ServerPreferencesAdder.java │ │ │ │ │ ├── SettingsExt.kt │ │ │ │ │ ├── dialogs/ │ │ │ │ │ │ ├── AdminPasswordDialogFragment.kt │ │ │ │ │ │ ├── ChangeAdminPasswordDialog.kt │ │ │ │ │ │ ├── DeleteProjectDialog.kt │ │ │ │ │ │ ├── ResetDialogPreference.java │ │ │ │ │ │ ├── ResetDialogPreferenceFragmentCompat.java │ │ │ │ │ │ ├── ResetProgressDialog.kt │ │ │ │ │ │ └── ServerAuthDialogFragment.java │ │ │ │ │ ├── filters/ │ │ │ │ │ │ └── ControlCharacterFilter.java │ │ │ │ │ ├── screens/ │ │ │ │ │ │ ├── AccessControlPreferencesFragment.kt │ │ │ │ │ │ ├── BaseAdminPreferencesFragment.java │ │ │ │ │ │ ├── BasePreferencesFragment.kt │ │ │ │ │ │ ├── BaseProjectPreferencesFragment.java │ │ │ │ │ │ ├── DevToolsPreferencesFragment.kt │ │ │ │ │ │ ├── ExperimentalPreferencesFragment.java │ │ │ │ │ │ ├── FormEntryAccessPreferencesFragment.kt │ │ │ │ │ │ ├── FormManagementPreferencesFragment.java │ │ │ │ │ │ ├── FormMetadataPreferencesFragment.java │ │ │ │ │ │ ├── IdentityPreferencesFragment.kt │ │ │ │ │ │ ├── MainMenuAccessPreferencesFragment.kt │ │ │ │ │ │ ├── MapsPreferencesFragment.kt │ │ │ │ │ │ ├── ProjectDisplayPreferencesFragment.kt │ │ │ │ │ │ ├── ProjectManagementPreferencesFragment.kt │ │ │ │ │ │ ├── ProjectPreferencesActivity.kt │ │ │ │ │ │ ├── ProjectPreferencesFragment.kt │ │ │ │ │ │ ├── ServerPreferencesFragment.java │ │ │ │ │ │ ├── UserInterfacePreferencesFragment.java │ │ │ │ │ │ └── UserSettingsAccessPreferencesFragment.java │ │ │ │ │ ├── source/ │ │ │ │ │ │ ├── SettingsStore.kt │ │ │ │ │ │ ├── SharedPreferencesSettings.kt │ │ │ │ │ │ └── SharedPreferencesSettingsProvider.kt │ │ │ │ │ └── utilities/ │ │ │ │ │ └── PreferencesUtils.java │ │ │ │ ├── projects/ │ │ │ │ │ ├── DuplicateProjectConfirmationDialog.kt │ │ │ │ │ ├── FileDebugLogger.kt │ │ │ │ │ ├── ManualProjectCreatorDialog.kt │ │ │ │ │ ├── ProjectCreatorImpl.kt │ │ │ │ │ ├── ProjectDeleter.kt │ │ │ │ │ ├── ProjectDependencyModule.kt │ │ │ │ │ ├── ProjectIconView.kt │ │ │ │ │ ├── ProjectListItemView.kt │ │ │ │ │ ├── ProjectResetter.kt │ │ │ │ │ ├── ProjectSettingsDialog.kt │ │ │ │ │ ├── ProjectsDataService.kt │ │ │ │ │ ├── QrCodeProjectCreatorDialog.kt │ │ │ │ │ └── SettingsConnectionMatcherImpl.kt │ │ │ │ ├── savepoints/ │ │ │ │ │ ├── SavepointTask.kt │ │ │ │ │ └── SavepointUseCases.kt │ │ │ │ ├── state/ │ │ │ │ │ └── DataKeys.kt │ │ │ │ ├── storage/ │ │ │ │ │ ├── StoragePathProvider.kt │ │ │ │ │ ├── StoragePaths.kt │ │ │ │ │ └── StorageSubdirectory.java │ │ │ │ ├── tasks/ │ │ │ │ │ ├── DownloadFormListTask.java │ │ │ │ │ ├── DownloadFormsTask.java │ │ │ │ │ ├── FormLoaderTask.java │ │ │ │ │ ├── InstanceUploaderTask.java │ │ │ │ │ ├── MediaLoadingTask.java │ │ │ │ │ ├── ProgressNotifier.java │ │ │ │ │ ├── SaveFormIndexTask.java │ │ │ │ │ ├── SaveFormToDisk.java │ │ │ │ │ └── SaveToDiskResult.java │ │ │ │ ├── upload/ │ │ │ │ │ ├── FormUploadException.kt │ │ │ │ │ ├── InstanceServerUploader.java │ │ │ │ │ └── InstanceUploader.java │ │ │ │ ├── utilities/ │ │ │ │ │ ├── ActionRegister.kt │ │ │ │ │ ├── AdminPasswordProvider.java │ │ │ │ │ ├── AndroidUserAgent.java │ │ │ │ │ ├── AnimationUtils.java │ │ │ │ │ ├── Appearances.kt │ │ │ │ │ ├── ApplicationConstants.java │ │ │ │ │ ├── ArrayUtils.java │ │ │ │ │ ├── AuthDialogUtility.java │ │ │ │ │ ├── CSVUtils.java │ │ │ │ │ ├── ChangeLockProvider.kt │ │ │ │ │ ├── CollectStrictMode.kt │ │ │ │ │ ├── ContentUriHelper.kt │ │ │ │ │ ├── ContentUriProvider.java │ │ │ │ │ ├── ControllableLifecyleOwner.kt │ │ │ │ │ ├── DialogUtils.java │ │ │ │ │ ├── EncryptionUtils.java │ │ │ │ │ ├── ExternalAppIntentProvider.java │ │ │ │ │ ├── ExternalizableFormDefCache.java │ │ │ │ │ ├── FileProvider.java │ │ │ │ │ ├── FileUtils.java │ │ │ │ │ ├── FormEntryPromptUtils.java │ │ │ │ │ ├── FormNameUtils.java │ │ │ │ │ ├── FormUtils.java │ │ │ │ │ ├── FormsDownloadResultInterpreter.kt │ │ │ │ │ ├── FormsRepositoryProvider.kt │ │ │ │ │ ├── FormsUploadResultInterpreter.kt │ │ │ │ │ ├── HtmlUtils.java │ │ │ │ │ ├── ImageCompressionController.kt │ │ │ │ │ ├── InstanceAutoDeleteChecker.kt │ │ │ │ │ ├── InstanceUploaderUtils.java │ │ │ │ │ ├── InstancesRepositoryProvider.kt │ │ │ │ │ ├── LocaleHelper.kt │ │ │ │ │ ├── MediaUtils.kt │ │ │ │ │ ├── MyanmarDateUtils.java │ │ │ │ │ ├── QuestionMediaManager.java │ │ │ │ │ ├── RankingItemTouchHelperCallback.java │ │ │ │ │ ├── ReplaceCallback.java │ │ │ │ │ ├── ResponseMessageParser.java │ │ │ │ │ ├── SavepointsRepositoryProvider.kt │ │ │ │ │ ├── SelectOneWidgetUtils.java │ │ │ │ │ ├── SoftKeyboardController.kt │ │ │ │ │ ├── ThemeUtils.java │ │ │ │ │ ├── UnderlyingValuesConcat.java │ │ │ │ │ ├── ViewUtils.kt │ │ │ │ │ ├── WebCredentialsUtils.java │ │ │ │ │ └── ZipUtils.java │ │ │ │ ├── version/ │ │ │ │ │ ├── VersionDescriptionProvider.java │ │ │ │ │ └── VersionInformation.java │ │ │ │ ├── views/ │ │ │ │ │ ├── ChoicesRecyclerView.java │ │ │ │ │ ├── CustomNumberPicker.kt │ │ │ │ │ ├── CustomWebView.java │ │ │ │ │ ├── DayNightProgressDialog.java │ │ │ │ │ ├── DecoratedBarcodeView.kt │ │ │ │ │ ├── SlidingTabLayout.java │ │ │ │ │ ├── SlidingTabStrip.java │ │ │ │ │ ├── TrackingTouchSlider.kt │ │ │ │ │ ├── TransparentProgressScreen.kt │ │ │ │ │ ├── TwoItemMultipleChoiceView.java │ │ │ │ │ └── WidgetAnswerText.kt │ │ │ │ └── widgets/ │ │ │ │ ├── AnnotateWidget.java │ │ │ │ ├── AudioWidget.java │ │ │ │ ├── BaseImageWidget.java │ │ │ │ ├── BearingWidget.java │ │ │ │ ├── CounterWidget.kt │ │ │ │ ├── DecimalWidget.java │ │ │ │ ├── DrawWidget.java │ │ │ │ ├── ExAudioWidget.java │ │ │ │ ├── ExDecimalWidget.java │ │ │ │ ├── ExImageWidget.java │ │ │ │ ├── ExIntegerWidget.java │ │ │ │ ├── ExStringWidget.java │ │ │ │ ├── GeoPointMapWidget.java │ │ │ │ ├── GeoPointWidget.java │ │ │ │ ├── GeoShapeWidget.java │ │ │ │ ├── GeoTraceWidget.java │ │ │ │ ├── ImageWidget.java │ │ │ │ ├── IntegerWidget.java │ │ │ │ ├── MediaWidgetAnswerViewModel.kt │ │ │ │ ├── OSMWidget.java │ │ │ │ ├── PrinterWidget.kt │ │ │ │ ├── QuestionWidget.java │ │ │ │ ├── RatingWidget.java │ │ │ │ ├── SignatureWidget.java │ │ │ │ ├── StringNumberWidget.java │ │ │ │ ├── StringWidget.java │ │ │ │ ├── TextWidgetAnswer.kt │ │ │ │ ├── TimedGridWidget.kt │ │ │ │ ├── TriggerWidget.java │ │ │ │ ├── UrlWidget.java │ │ │ │ ├── WidgetAnswer.kt │ │ │ │ ├── WidgetAnswerView.kt │ │ │ │ ├── WidgetFactory.java │ │ │ │ ├── WidgetIconButton.kt │ │ │ │ ├── arbitraryfile/ │ │ │ │ │ ├── ArbitraryFileWidget.kt │ │ │ │ │ ├── ArbitraryFileWidgetContent.kt │ │ │ │ │ ├── ArbitraryFileWidgetDelegate.kt │ │ │ │ │ ├── ExArbitraryFileWidget.kt │ │ │ │ │ └── ExArbitraryFileWidgetContent.kt │ │ │ │ ├── barcode/ │ │ │ │ │ ├── BarcodeWidget.kt │ │ │ │ │ └── BarcodeWidgetContent.kt │ │ │ │ ├── datetime/ │ │ │ │ │ ├── DatePickerDetails.java │ │ │ │ │ ├── DateTimeUtils.java │ │ │ │ │ ├── DateTimeWidget.java │ │ │ │ │ ├── DateWidget.java │ │ │ │ │ ├── TimeWidget.java │ │ │ │ │ └── pickers/ │ │ │ │ │ ├── BikramSambatDatePickerDialog.java │ │ │ │ │ ├── BuddhistDatePickerDialog.kt │ │ │ │ │ ├── CopticDatePickerDialog.java │ │ │ │ │ ├── CustomDatePickerDialog.java │ │ │ │ │ ├── CustomTimePickerDialog.java │ │ │ │ │ ├── EthiopianDatePickerDialog.java │ │ │ │ │ ├── FixedDatePickerDialog.java │ │ │ │ │ ├── IslamicDatePickerDialog.java │ │ │ │ │ ├── MyanmarDatePickerDialog.java │ │ │ │ │ └── PersianDatePickerDialog.java │ │ │ │ ├── interfaces/ │ │ │ │ │ ├── FileWidget.java │ │ │ │ │ ├── GeoDataRequester.kt │ │ │ │ │ ├── MultiChoiceWidget.java │ │ │ │ │ ├── Printer.kt │ │ │ │ │ ├── SelectChoiceLoader.kt │ │ │ │ │ ├── Widget.java │ │ │ │ │ └── WidgetDataReceiver.kt │ │ │ │ ├── items/ │ │ │ │ │ ├── BaseSelectListWidget.java │ │ │ │ │ ├── ItemsWidgetUtils.kt │ │ │ │ │ ├── LabelWidget.java │ │ │ │ │ ├── LikertWidget.java │ │ │ │ │ ├── ListMultiWidget.java │ │ │ │ │ ├── ListWidget.java │ │ │ │ │ ├── RankingWidget.java │ │ │ │ │ ├── SelectImageMapWidget.java │ │ │ │ │ ├── SelectMinimalWidget.java │ │ │ │ │ ├── SelectMultiImageMapWidget.java │ │ │ │ │ ├── SelectMultiMinimalWidget.java │ │ │ │ │ ├── SelectMultiWidget.java │ │ │ │ │ ├── SelectOneFromMapDialogFragment.kt │ │ │ │ │ ├── SelectOneFromMapWidget.kt │ │ │ │ │ ├── SelectOneImageMapWidget.java │ │ │ │ │ ├── SelectOneMinimalWidget.java │ │ │ │ │ └── SelectOneWidget.java │ │ │ │ ├── range/ │ │ │ │ │ ├── RangeDecimalWidget.java │ │ │ │ │ ├── RangeIntegerWidget.java │ │ │ │ │ ├── RangePickerWidget.java │ │ │ │ │ └── RangePickerWidgetUtils.kt │ │ │ │ ├── utilities/ │ │ │ │ │ ├── ActivityGeoDataRequester.kt │ │ │ │ │ ├── AdditionalAttributes.kt │ │ │ │ │ ├── AudioFileRequester.java │ │ │ │ │ ├── AudioRecorderRecordingStatusHandler.java │ │ │ │ │ ├── BindAttributes.kt │ │ │ │ │ ├── DateTimeWidgetUtils.java │ │ │ │ │ ├── ExternalAppRecordingRequester.kt │ │ │ │ │ ├── FileRequester.kt │ │ │ │ │ ├── FormControllerWaitingForDataRegistry.java │ │ │ │ │ ├── GeoPolyDialogFragment.kt │ │ │ │ │ ├── GeoWidgetUtils.kt │ │ │ │ │ ├── GetContentAudioFileRequester.kt │ │ │ │ │ ├── ImageCaptureIntentCreator.kt │ │ │ │ │ ├── InternalRecordingRequester.java │ │ │ │ │ ├── QuestionFontSizeUtils.kt │ │ │ │ │ ├── RangeWidgetUtils.java │ │ │ │ │ ├── RecordingRequester.java │ │ │ │ │ ├── RecordingRequesterProvider.java │ │ │ │ │ ├── RecordingStatusHandler.java │ │ │ │ │ ├── SearchQueryViewModel.java │ │ │ │ │ ├── StringRequester.kt │ │ │ │ │ ├── StringWidgetUtils.java │ │ │ │ │ ├── ViewModelAudioPlayer.kt │ │ │ │ │ ├── WaitingForDataRegistry.java │ │ │ │ │ └── WidgetAnswerDialogFragment.kt │ │ │ │ ├── video/ │ │ │ │ │ ├── ExVideoWidget.kt │ │ │ │ │ ├── ExVideoWidgetContent.kt │ │ │ │ │ ├── VideoWidget.kt │ │ │ │ │ ├── VideoWidgetAnswer.kt │ │ │ │ │ └── VideoWidgetContent.kt │ │ │ │ ├── viewmodels/ │ │ │ │ │ ├── DateTimeViewModel.java │ │ │ │ │ └── QuestionViewModel.kt │ │ │ │ └── warnings/ │ │ │ │ └── SpacesInUnderlyingValuesWarning.java │ │ │ └── utilities/ │ │ │ ├── Result.java │ │ │ └── UserAgentProvider.java │ │ └── res/ │ │ ├── anim/ │ │ │ ├── fade_in.xml │ │ │ ├── fade_out.xml │ │ │ ├── push_left_in.xml │ │ │ ├── push_left_out.xml │ │ │ ├── push_right_in.xml │ │ │ └── push_right_out.xml │ │ ├── drawable/ │ │ │ ├── counter_minus_button_background.xml │ │ │ ├── counter_plus_button_background.xml │ │ │ ├── counter_value_background.xml │ │ │ ├── ic_access_time.xml │ │ │ ├── ic_add_circle_24.xml │ │ │ ├── ic_arrow_back.xml │ │ │ ├── ic_arrow_drop_down.xml │ │ │ ├── ic_arrow_up.xml │ │ │ ├── ic_baseline_delete_72.xml │ │ │ ├── ic_baseline_delete_outline_24.xml │ │ │ ├── ic_baseline_download_72.xml │ │ │ ├── ic_baseline_edit_72.xml │ │ │ ├── ic_baseline_error_24.xml │ │ │ ├── ic_baseline_help_outline_24.xml │ │ │ ├── ic_baseline_inbox_72.xml │ │ │ ├── ic_baseline_note_72.xml │ │ │ ├── ic_baseline_notifications_24.xml │ │ │ ├── ic_baseline_qr_code_scanner_24.xml │ │ │ ├── ic_baseline_refresh_24.xml │ │ │ ├── ic_baseline_refresh_error_24.xml │ │ │ ├── ic_baseline_search_24.xml │ │ │ ├── ic_baseline_send_72.xml │ │ │ ├── ic_baseline_sort_24.xml │ │ │ ├── ic_cancel.xml │ │ │ ├── ic_chat_bubble_24dp.xml │ │ │ ├── ic_check_circle_24.xml │ │ │ ├── ic_download_24.xml │ │ │ ├── ic_edit_24.xml │ │ │ ├── ic_folder_open.xml │ │ │ ├── ic_form_state_blank.xml │ │ │ ├── ic_form_state_finalized.xml │ │ │ ├── ic_form_state_saved.xml │ │ │ ├── ic_form_state_submission_failed.xml │ │ │ ├── ic_form_state_submitted.xml │ │ │ ├── ic_information_outline.xml │ │ │ ├── ic_map.xml │ │ │ ├── ic_menu_share.xml │ │ │ ├── ic_navigate_back.xml │ │ │ ├── ic_navigate_forward.xml │ │ │ ├── ic_ondemand_video_black_24dp.xml │ │ │ ├── ic_outline_assignment_accent_24.xml │ │ │ ├── ic_outline_cloud_accent_24.xml │ │ │ ├── ic_outline_color_lens_accent_24.xml │ │ │ ├── ic_outline_delete_accent_24.xml │ │ │ ├── ic_outline_edit_24.xml │ │ │ ├── ic_outline_face_accent_24.xml │ │ │ ├── ic_outline_forum_24.xml │ │ │ ├── ic_outline_info_small.xml │ │ │ ├── ic_outline_lock_24.xml │ │ │ ├── ic_outline_lock_accent_24.xml │ │ │ ├── ic_outline_lock_open_24.xml │ │ │ ├── ic_outline_map_accent_24.xml │ │ │ ├── ic_outline_menu_accent_24.xml │ │ │ ├── ic_outline_phonelink_setup_accent_24.xml │ │ │ ├── ic_outline_qr_code_scanner_accent_24.xml │ │ │ ├── ic_outline_rate_review_24.xml │ │ │ ├── ic_outline_refresh_accent_24.xml │ │ │ ├── ic_outline_settings_accent_24.xml │ │ │ ├── ic_outline_settings_applications_accent_24.xml │ │ │ ├── ic_outline_share_24.xml │ │ │ ├── ic_outline_stars_24.xml │ │ │ ├── ic_outline_vpn_key_accent_24.xml │ │ │ ├── ic_outline_warning_accent_24.xml │ │ │ ├── ic_outline_website_24.xml │ │ │ ├── ic_pause_24dp.xml │ │ │ ├── ic_person_24dp.xml │ │ │ ├── ic_play_arrow_24dp.xml │ │ │ ├── ic_repeat.xml │ │ │ ├── ic_room_form_state_complete_24dp.xml │ │ │ ├── ic_room_form_state_complete_48dp.xml │ │ │ ├── ic_room_form_state_incomplete_24dp.xml │ │ │ ├── ic_room_form_state_incomplete_48dp.xml │ │ │ ├── ic_room_form_state_submission_failed_24dp.xml │ │ │ ├── ic_room_form_state_submission_failed_48dp.xml │ │ │ ├── ic_room_form_state_submitted_24dp.xml │ │ │ ├── ic_room_form_state_submitted_48dp.xml │ │ │ ├── ic_save_menu_24.xml │ │ │ ├── ic_send_24.xml │ │ │ ├── ic_sort.xml │ │ │ ├── ic_sort_by_alpha.xml │ │ │ ├── ic_sort_by_last_saved.xml │ │ │ ├── ic_splash_screen_icon.xml │ │ │ ├── ic_stop_black_24dp.xml │ │ │ ├── ic_visibility.xml │ │ │ ├── ic_volume_up_black_24dp.xml │ │ │ ├── inset_divider_64dp.xml │ │ │ ├── main_menu_button_background.xml │ │ │ ├── odk_logo.xml │ │ │ ├── pill_filled.xml │ │ │ ├── pill_unfilled.xml │ │ │ ├── project_icon_background.xml │ │ │ ├── question_with_error_border.xml │ │ │ ├── select_item_border.xml │ │ │ └── start_new_form_button_background.xml │ │ ├── drawable-ldrtl/ │ │ │ ├── counter_minus_button_background.xml │ │ │ └── counter_plus_button_background.xml │ │ ├── layout/ │ │ │ ├── about_item_layout.xml │ │ │ ├── about_layout.xml │ │ │ ├── activity_blank_form_list.xml │ │ │ ├── activity_custom_scanner.xml │ │ │ ├── activity_preferences_layout.xml │ │ │ ├── add_repeat_dialog_layout.xml │ │ │ ├── admin_password_dialog_layout.xml │ │ │ ├── annotate_widget.xml │ │ │ ├── audio_controller_layout.xml │ │ │ ├── audio_player_layout.xml │ │ │ ├── audio_recording_controller_fragment.xml │ │ │ ├── audio_video_image_text_label.xml │ │ │ ├── audio_widget_answer.xml │ │ │ ├── background_audio_help_fragment_layout.xml │ │ │ ├── bearing_widget_answer.xml │ │ │ ├── blank_form_list_item.xml │ │ │ ├── bottom_sheet.xml │ │ │ ├── captioned_item.xml │ │ │ ├── captioned_list_dialog.xml │ │ │ ├── changes_reason_dialog.xml │ │ │ ├── checkbox.xml │ │ │ ├── circular_progress_indicator.xml │ │ │ ├── counter_widget.xml │ │ │ ├── current_project_menu_icon.xml │ │ │ ├── custom_date_picker_dialog.xml │ │ │ ├── date_time_widget_answer.xml │ │ │ ├── date_widget_answer.xml │ │ │ ├── delete_form_layout.xml │ │ │ ├── delete_project_dialog_layout.xml │ │ │ ├── draw_widget.xml │ │ │ ├── ex_audio_widget_answer.xml │ │ │ ├── ex_image_widget_answer.xml │ │ │ ├── ex_string_question_type.xml │ │ │ ├── first_launch_layout.xml │ │ │ ├── form_chooser_list.xml │ │ │ ├── form_chooser_list_item.xml │ │ │ ├── form_chooser_list_item_icon.xml │ │ │ ├── form_chooser_list_item_map_button.xml │ │ │ ├── form_chooser_list_item_multiple_choice.xml │ │ │ ├── form_chooser_list_item_text.xml │ │ │ ├── form_download_list.xml │ │ │ ├── form_entry.xml │ │ │ ├── form_entry_end.xml │ │ │ ├── form_hierarchy_layout.xml │ │ │ ├── form_map_activity.xml │ │ │ ├── fragment_scan.xml │ │ │ ├── geopoint_question.xml │ │ │ ├── geoshape_question.xml │ │ │ ├── geotrace_question.xml │ │ │ ├── google_drive_deprecation_banner.xml │ │ │ ├── help_layout.xml │ │ │ ├── hierarchy_group_item.xml │ │ │ ├── hierarchy_host_layout.xml │ │ │ ├── hierarchy_question_item.xml │ │ │ ├── hierarchy_repeatable_group_instance_item.xml │ │ │ ├── hierarchy_repeatable_group_item.xml │ │ │ ├── identify_user_dialog.xml │ │ │ ├── image_widget.xml │ │ │ ├── instance_uploader_list.xml │ │ │ ├── label_widget.xml │ │ │ ├── launch_intent_button_layout.xml │ │ │ ├── main_menu.xml │ │ │ ├── main_menu_activity.xml │ │ │ ├── main_menu_button.xml │ │ │ ├── manual_project_creator_dialog_layout.xml │ │ │ ├── map_button.xml │ │ │ ├── no_buttons_item_layout.xml │ │ │ ├── number_picker_dialog.xml │ │ │ ├── odk_view.xml │ │ │ ├── osm_widget_answer.xml │ │ │ ├── password_dialog_layout.xml │ │ │ ├── permissions_dialog_layout.xml │ │ │ ├── printer_widget.xml │ │ │ ├── progress_bar_view.xml │ │ │ ├── project_icon_view.xml │ │ │ ├── project_list_item.xml │ │ │ ├── project_settings_dialog_layout.xml │ │ │ ├── qr_code_project_creator_dialog_layout.xml │ │ │ ├── question_error_layout.xml │ │ │ ├── question_widget.xml │ │ │ ├── quit_form_dialog_layout.xml │ │ │ ├── range_picker_widget_answer.xml │ │ │ ├── range_widget_horizontal.xml │ │ │ ├── range_widget_vertical.xml │ │ │ ├── ranking_item.xml │ │ │ ├── ranking_widget.xml │ │ │ ├── rating_widget_answer.xml │ │ │ ├── ready_to_send_banner.xml │ │ │ ├── reset_dialog_layout.xml │ │ │ ├── select_image_map_widget_answer.xml │ │ │ ├── select_list_widget_answer.xml │ │ │ ├── select_minimal_dialog_layout.xml │ │ │ ├── select_minimal_widget_answer.xml │ │ │ ├── select_multi_item.xml │ │ │ ├── select_one_from_map_widget_answer.xml │ │ │ ├── select_one_item.xml │ │ │ ├── server_auth_dialog.xml │ │ │ ├── show_qrcode_fragment.xml │ │ │ ├── signature_widget.xml │ │ │ ├── sort_item_layout.xml │ │ │ ├── start_new_from_button.xml │ │ │ ├── tabs_layout.xml │ │ │ ├── time_widget_answer.xml │ │ │ ├── transparent_progress_screen.xml │ │ │ ├── trigger_widget_answer.xml │ │ │ ├── url_widget_answer.xml │ │ │ ├── waveform_layout.xml │ │ │ ├── widget_answer_dialog_layout.xml │ │ │ └── widget_answer_text.xml │ │ ├── menu/ │ │ │ ├── blank_form_list_menu.xml │ │ │ ├── changes_reason_dialog.xml │ │ │ ├── drafts.xml │ │ │ ├── form_hierarchy_menu.xml │ │ │ ├── form_menu.xml │ │ │ ├── instance_uploader_menu.xml │ │ │ ├── main_menu.xml │ │ │ ├── project_preferences_menu.xml │ │ │ ├── qr_code_scan_menu.xml │ │ │ ├── saved_form_list_menu.xml │ │ │ └── select_minimal_dialog_menu.xml │ │ ├── navigation/ │ │ │ └── form_entry.xml │ │ ├── raw/ │ │ │ ├── isrgrootx1.pem │ │ │ └── keep.xml │ │ ├── values/ │ │ │ ├── arrays.xml │ │ │ ├── attrs.xml │ │ │ ├── buttons.xml │ │ │ ├── colors.xml │ │ │ ├── date_time_pickers.xml │ │ │ ├── dimens.xml │ │ │ ├── form_entry_activity_theme.xml │ │ │ ├── form_state_colors.xml │ │ │ ├── leak_canary_config.xml │ │ │ ├── material_colors.xml │ │ │ ├── screen_names.xml │ │ │ ├── settings_theme.xml │ │ │ ├── shape.xml │ │ │ ├── styles.xml │ │ │ ├── theme.xml │ │ │ └── typography.xml │ │ ├── values-night/ │ │ │ └── material_colors.xml │ │ └── xml/ │ │ ├── access_control_preferences.xml │ │ ├── dev_tools_preferences.xml │ │ ├── experimental_preferences.xml │ │ ├── form_entry_access_preferences.xml │ │ ├── form_management_preferences.xml │ │ ├── form_metadata_preferences.xml │ │ ├── identity_preferences.xml │ │ ├── main_menu_access_preferences.xml │ │ ├── maps_preferences.xml │ │ ├── network_security_config.xml │ │ ├── odk_server_preferences.xml │ │ ├── project_display_preferences.xml │ │ ├── project_management_preferences.xml │ │ ├── project_preferences.xml │ │ ├── provider_paths.xml │ │ ├── server_preferences.xml │ │ ├── user_interface_preferences.xml │ │ └── user_settings_access_preferences.xml │ ├── release/ │ │ └── google-services.json │ └── test/ │ ├── java/ │ │ └── org/ │ │ └── odk/ │ │ └── collect/ │ │ ├── android/ │ │ │ ├── TestSettingsProvider.kt │ │ │ ├── activities/ │ │ │ │ ├── CrashHandlerActivityTest.kt │ │ │ │ ├── FirstLaunchActivityTest.kt │ │ │ │ ├── FormFillingActivityTest.kt │ │ │ │ └── FormHierarchyFragmentHostActivityTest.kt │ │ │ ├── application/ │ │ │ │ ├── CollectSettingsChangeHandlerTest.kt │ │ │ │ ├── RobolectricApplication.java │ │ │ │ └── initialization/ │ │ │ │ ├── AnalyticsInitializerTest.kt │ │ │ │ ├── CachedFormsCleanerTest.kt │ │ │ │ ├── ExistingSettingsMigratorTest.kt │ │ │ │ ├── GoogleDriveProjectsDeleterTest.kt │ │ │ │ ├── SavepointsImporterTest.kt │ │ │ │ ├── ScheduledWorkUpgradeTest.kt │ │ │ │ └── upgrade/ │ │ │ │ └── BeforeProjectsInstallDetectorTest.kt │ │ │ ├── audio/ │ │ │ │ ├── AudioButtonTest.java │ │ │ │ ├── AudioControllerViewTest.java │ │ │ │ ├── AudioRecordingControllerFragmentTest.java │ │ │ │ ├── AudioRecordingFormErrorDialogFragmentTest.java │ │ │ │ └── BackgroundAudioHelpDialogFragmentTest.java │ │ │ ├── backgroundwork/ │ │ │ │ ├── AutoUpdateTaskSpecTest.kt │ │ │ │ ├── FormUpdateAndInstanceSubmitSchedulerTest.kt │ │ │ │ ├── SendFormsTaskSpecTest.kt │ │ │ │ └── SyncFormsTaskSpecTest.kt │ │ │ ├── configure/ │ │ │ │ └── qr/ │ │ │ │ ├── QRCodeActivityResultDelegateTest.kt │ │ │ │ ├── QRCodeMenuProviderTest.kt │ │ │ │ ├── QRCodeScannerFragmentTest.kt │ │ │ │ └── QRCodeViewModelTest.kt │ │ │ ├── database/ │ │ │ │ ├── DatabaseFormsRepositoryTest.java │ │ │ │ ├── DatabaseInstancesRepositoryTest.kt │ │ │ │ ├── DatabaseSavepointsRepositoryTest.kt │ │ │ │ ├── FormDatabaseMigratorTest.java │ │ │ │ ├── InstanceDatabaseMigratorTest.kt │ │ │ │ └── entities/ │ │ │ │ └── EntitiesDatabaseMigratorTest.kt │ │ │ ├── dependencies/ │ │ │ │ ├── BikramSambatTest.java │ │ │ │ └── PersianCalendarTest.java │ │ │ ├── dynamicpreload/ │ │ │ │ ├── DynamicPreloadExtraTest.kt │ │ │ │ ├── DynamicPreloadParseProcessorTest.kt │ │ │ │ ├── ExternalDataReaderTest.java │ │ │ │ ├── ExternalDataUseCasesTest.kt │ │ │ │ └── ExternalDataUtilTest.java │ │ │ ├── entities/ │ │ │ │ ├── DatabaseEntitiesRepositoryTest.kt │ │ │ │ ├── EntitiesRepositoryTest.kt │ │ │ │ ├── InMemEntitiesRepositoryTest.kt │ │ │ │ └── support/ │ │ │ │ └── EntitySameAsMatcher.kt │ │ │ ├── external/ │ │ │ │ ├── FormUriActivityTest.kt │ │ │ │ ├── FormsProviderTest.java │ │ │ │ └── InstanceProviderTest.java │ │ │ ├── fakes/ │ │ │ │ └── FakePermissionsProvider.kt │ │ │ ├── formentry/ │ │ │ │ ├── AppStateFormSessionRepositoryTest.kt │ │ │ │ ├── AudioVideoImageTextLabelTest.java │ │ │ │ ├── AudioVideoImageTextLabelVisibilityTest.kt │ │ │ │ ├── BackgroundAudioPermissionDialogFragmentTest.java │ │ │ │ ├── BackgroundAudioViewModelTest.java │ │ │ │ ├── FormEndTest.kt │ │ │ │ ├── FormEndViewModelTest.kt │ │ │ │ ├── FormEntryMenuProviderTest.kt │ │ │ │ ├── FormEntryUseCasesTest.kt │ │ │ │ ├── FormEntryViewModelTest.java │ │ │ │ ├── FormLoadingDialogFragmentTest.java │ │ │ │ ├── FormSessionRepositoryTest.kt │ │ │ │ ├── InMemoryFormSessionRepositoryTest.kt │ │ │ │ ├── PrinterWidgetViewModelTest.kt │ │ │ │ ├── QuitFormDialogTest.kt │ │ │ │ ├── RecordingHandlerTest.java │ │ │ │ ├── RefreshFormListDialogFragmentTest.java │ │ │ │ ├── SaveFormProgressDialogFragmentTest.java │ │ │ │ ├── audit/ │ │ │ │ │ ├── AsyncTaskAuditEventWriterTest.java │ │ │ │ │ ├── AuditConfigTest.java │ │ │ │ │ ├── AuditEventCSVLineTest.java │ │ │ │ │ ├── AuditEventLoggerTest.java │ │ │ │ │ ├── AuditEventTest.java │ │ │ │ │ ├── FormSaveViewModelTest.java │ │ │ │ │ └── IdentityPromptViewModelTest.java │ │ │ │ ├── backgroundlocation/ │ │ │ │ │ └── BackgroundLocationManagerTest.java │ │ │ │ ├── repeats/ │ │ │ │ │ └── DeleteRepeatDialogFragmentTest.java │ │ │ │ └── support/ │ │ │ │ └── InMemFormSessionRepository.kt │ │ │ ├── formhierarchy/ │ │ │ │ ├── HierarchyListItemViewTest.kt │ │ │ │ └── QuestionAnswerProcessorTest.kt │ │ │ ├── formlists/ │ │ │ │ ├── DeleteBlankFormFragmentTest.kt │ │ │ │ ├── blankformlist/ │ │ │ │ │ ├── BlankFormListItemTest.kt │ │ │ │ │ ├── BlankFormListItemViewTest.kt │ │ │ │ │ ├── BlankFormListMenuProviderTest.kt │ │ │ │ │ └── BlankFormListViewModelTest.kt │ │ │ │ └── savedformlist/ │ │ │ │ ├── DeleteSavedFormFragmentTest.kt │ │ │ │ ├── SavedFormListListMenuProviderTest.kt │ │ │ │ └── SavedFormListViewModelTest.kt │ │ │ ├── formmanagement/ │ │ │ │ ├── DownloadMediaFilesServerFormUseCasesTest.kt │ │ │ │ ├── DownloadUpdatesServerFormUseCasesTest.kt │ │ │ │ ├── FetchFormDetailsServerFormUseCasesTest.kt │ │ │ │ ├── FilterFormsToAddTest.kt │ │ │ │ ├── FormFillingIntentFactoryTest.kt │ │ │ │ ├── FormSourceExceptionMapperTest.kt │ │ │ │ ├── FormsDataServiceTest.kt │ │ │ │ ├── LocalFormUseCasesTest.java │ │ │ │ ├── ServerFormsSynchronizerTest.java │ │ │ │ ├── ShouldAddFormFileTest.java │ │ │ │ ├── download/ │ │ │ │ │ ├── FormDownloadExceptionMapperTest.kt │ │ │ │ │ └── ServerFormDownloaderTest.java │ │ │ │ ├── drafts/ │ │ │ │ │ └── DraftsMenuProviderTest.kt │ │ │ │ ├── formmap/ │ │ │ │ │ └── FormMapViewModelTest.kt │ │ │ │ └── metadata/ │ │ │ │ └── FormMetadataParserTest.kt │ │ │ ├── fragments/ │ │ │ │ ├── dialogs/ │ │ │ │ │ ├── FormsDownloadResultDialogTest.kt │ │ │ │ │ ├── RangePickerDialogFragmentTest.kt │ │ │ │ │ ├── SelectMinimalDialogTest.java │ │ │ │ │ ├── SelectMultiMinimalDialogTest.java │ │ │ │ │ └── SelectOneMinimalDialogTest.java │ │ │ │ └── support/ │ │ │ │ └── DialogFragmentHelpers.java │ │ │ ├── geo/ │ │ │ │ └── MapFragmentFactoryImplTest.kt │ │ │ ├── instancemanagement/ │ │ │ │ ├── InstanceDeleterTest.kt │ │ │ │ ├── InstanceExtKtTest.kt │ │ │ │ ├── InstanceListItemViewTest.kt │ │ │ │ ├── InstancesDataServiceTest.kt │ │ │ │ ├── LocalInstancesUseCasesTest.kt │ │ │ │ ├── autosend/ │ │ │ │ │ ├── AutoSendSettingsProviderTest.kt │ │ │ │ │ ├── FormExtTest.kt │ │ │ │ │ └── InstanceAutoSendFetcherTest.kt │ │ │ │ └── send/ │ │ │ │ ├── ReadyToSendBannerTest.kt │ │ │ │ └── ReadyToSendViewModelTest.kt │ │ │ ├── javarosawrapper/ │ │ │ │ ├── FakeFormController.java │ │ │ │ └── FormControllerTest.java │ │ │ ├── location/ │ │ │ │ └── client/ │ │ │ │ ├── FakeLocationClient.java │ │ │ │ └── MaxAccuracyWithinTimeoutLocationClientWrapperTest.java │ │ │ ├── mainmenu/ │ │ │ │ ├── CurrentProjectViewModelTest.kt │ │ │ │ ├── MainMenuActivityTest.kt │ │ │ │ ├── MainMenuButtonTest.kt │ │ │ │ ├── MainMenuViewModelTest.kt │ │ │ │ ├── PermissionsDialogFragmentTest.kt │ │ │ │ └── RequestPermissionsViewModelTest.kt │ │ │ ├── notifications/ │ │ │ │ └── NotificationManagerNotifierTest.kt │ │ │ ├── preferences/ │ │ │ │ ├── AppConfigurationGeneratorTest.kt │ │ │ │ ├── ProjectPreferencesViewModelTest.kt │ │ │ │ ├── ServerPreferencesAdderTest.java │ │ │ │ ├── dialogs/ │ │ │ │ │ ├── AdminPasswordDialogFragmentTest.kt │ │ │ │ │ ├── ChangeAdminPasswordDialogTest.kt │ │ │ │ │ ├── DeleteProjectDialogTest.kt │ │ │ │ │ ├── ResetProgressDialogTest.kt │ │ │ │ │ └── ServerAuthDialogFragmentTest.java │ │ │ │ ├── screens/ │ │ │ │ │ ├── FormEntryAccessPreferencesFragmentTest.kt │ │ │ │ │ ├── FormManagementPreferencesFragmentTest.kt │ │ │ │ │ ├── FormMetadataPreferencesFragmentTest.kt │ │ │ │ │ ├── IdentityPreferencesFragmentTest.kt │ │ │ │ │ ├── MainMenuAccessPreferencesTest.kt │ │ │ │ │ ├── MapsPreferencesFragmentTest.kt │ │ │ │ │ ├── ProjectDisplayPreferencesFragmentTest.kt │ │ │ │ │ ├── ProjectPreferencesFragmentTest.kt │ │ │ │ │ └── UserInterfacePreferencesFragmentTest.kt │ │ │ │ └── source/ │ │ │ │ ├── SettingsStoreTest.kt │ │ │ │ ├── SharedPreferencesSettingsProviderTest.kt │ │ │ │ └── SharedPreferencesSettingsTest.kt │ │ │ ├── projects/ │ │ │ │ ├── ExistingProjectMigratorTest.kt │ │ │ │ ├── ManualProjectCreatorDialogTest.kt │ │ │ │ ├── ProjectCreatorImplTest.kt │ │ │ │ ├── ProjectDeleterTest.kt │ │ │ │ ├── ProjectIconViewTest.kt │ │ │ │ ├── ProjectListItemViewTest.kt │ │ │ │ ├── ProjectResetterTest.kt │ │ │ │ ├── ProjectSettingsDialogTest.kt │ │ │ │ ├── ProjectsDataServiceTest.kt │ │ │ │ ├── QrCodeProjectCreatorDialogTest.kt │ │ │ │ └── SettingsConnectionMatcherImplTest.kt │ │ │ ├── savepoints/ │ │ │ │ └── SavepointUseCasesTest.kt │ │ │ ├── storage/ │ │ │ │ └── StoragePathProviderTest.kt │ │ │ ├── support/ │ │ │ │ ├── CollectHelpers.java │ │ │ │ ├── Matchers.kt │ │ │ │ ├── MockFormEntryPromptBuilder.java │ │ │ │ ├── SwipableParentActivity.kt │ │ │ │ └── WidgetTestActivity.kt │ │ │ ├── tasks/ │ │ │ │ └── SaveFormIndexTaskTest.java │ │ │ ├── utilities/ │ │ │ │ ├── AdminPasswordProviderTest.java │ │ │ │ ├── AppearancesTest.kt │ │ │ │ ├── ArrayUtilsTest.java │ │ │ │ ├── CSVUtilsTest.java │ │ │ │ ├── ChangeLockProviderTest.kt │ │ │ │ ├── ExternalAppIntentProviderTest.kt │ │ │ │ ├── ExternalAppUtilsTest.java │ │ │ │ ├── FileUtilsTest.java │ │ │ │ ├── FormNameUtilsTest.java │ │ │ │ ├── FormsDownloadResultInterpreterTest.kt │ │ │ │ ├── FormsRepositoryProviderTest.kt │ │ │ │ ├── FormsUploadResultInterpreterTest.kt │ │ │ │ ├── HtmlUtilsTest.kt │ │ │ │ ├── ImageCompressionControllerTest.kt │ │ │ │ ├── InstanceAutoDeleteCheckerTest.kt │ │ │ │ ├── InstanceUploaderUtilsTest.java │ │ │ │ ├── InstancesRepositoryProviderTest.kt │ │ │ │ ├── MediaUtilsTest.kt │ │ │ │ ├── MyanmarDateUtilsTest.java │ │ │ │ ├── QuestionFontSizeUtilsTest.java │ │ │ │ ├── StubFormController.kt │ │ │ │ └── WebCredentialsUtilsTest.java │ │ │ ├── version/ │ │ │ │ └── VersionInformationTest.java │ │ │ ├── views/ │ │ │ │ ├── ChoicesRecyclerViewTest.java │ │ │ │ ├── TrackingTouchSliderTest.java │ │ │ │ └── helpers/ │ │ │ │ └── PromptAutoplayerTest.java │ │ │ └── widgets/ │ │ │ ├── AnnotateWidgetTest.java │ │ │ ├── ArbitraryFileWidgetTest.kt │ │ │ ├── AudioWidgetTest.java │ │ │ ├── BarcodeWidgetTest.kt │ │ │ ├── BearingWidgetTest.java │ │ │ ├── CounterWidgetTest.kt │ │ │ ├── DecimalWidgetTest.java │ │ │ ├── DrawWidgetTest.java │ │ │ ├── ExArbitraryFileWidgetTest.kt │ │ │ ├── ExAudioWidgetTest.java │ │ │ ├── ExDecimalWidgetTest.java │ │ │ ├── ExImageWidgetTest.java │ │ │ ├── ExIntegerWidgetTest.java │ │ │ ├── ExStringWidgetTest.kt │ │ │ ├── ExVideoWidgetTest.kt │ │ │ ├── GeoPointMapWidgetTest.java │ │ │ ├── GeoPointWidgetTest.java │ │ │ ├── GeoShapeWidgetTest.java │ │ │ ├── GeoTraceWidgetTest.java │ │ │ ├── ImageWidgetTest.java │ │ │ ├── IntegerWidgetTest.java │ │ │ ├── OSMWidgetTest.java │ │ │ ├── PrinterWidgetTest.kt │ │ │ ├── QuestionWidgetTest.java │ │ │ ├── RatingWidgetTest.java │ │ │ ├── SignatureWidgetTest.java │ │ │ ├── StringNumberWidgetTest.java │ │ │ ├── StringWidgetTest.kt │ │ │ ├── TriggerWidgetTest.java │ │ │ ├── UrlWidgetTest.java │ │ │ ├── VideoWidgetTest.kt │ │ │ ├── WidgetFactoryTest.kt │ │ │ ├── base/ │ │ │ │ ├── BinaryWidgetTest.java │ │ │ │ ├── FileWidgetTest.java │ │ │ │ ├── GeneralExStringWidgetTest.java │ │ │ │ ├── GeneralSelectMultiWidgetTest.java │ │ │ │ ├── GeneralSelectOneWidgetTest.java │ │ │ │ ├── GeneralStringWidgetTest.java │ │ │ │ ├── QuestionWidgetTest.java │ │ │ │ ├── SelectWidgetTest.java │ │ │ │ └── WidgetTest.java │ │ │ ├── datetime/ │ │ │ │ ├── DateTimeUtilsTest.java │ │ │ │ ├── DateTimeWidgetTest.java │ │ │ │ ├── DateTimeWidgetUtilsTest.java │ │ │ │ ├── DateWidgetTest.java │ │ │ │ ├── DaylightSavingTest.java │ │ │ │ ├── TimeWidgetTest.java │ │ │ │ └── pickers/ │ │ │ │ ├── BikramSambatDatePickerDialogTest.java │ │ │ │ ├── BuddhistDatePickerDialogTest.kt │ │ │ │ ├── CopticDatePickerDialogTest.java │ │ │ │ ├── EthiopianDatePickerDialogTest.java │ │ │ │ ├── IslamicDatePickerDialogTest.java │ │ │ │ ├── MyanmarDatePickerDialogTest.java │ │ │ │ └── PersianDatePickerDialogTest.java │ │ │ ├── items/ │ │ │ │ ├── LikertWidgetTest.java │ │ │ │ ├── ListMultiWidgetTest.java │ │ │ │ ├── ListWidgetTest.java │ │ │ │ ├── RankingWidgetTest.java │ │ │ │ ├── SelectChoicesMapDataTest.kt │ │ │ │ ├── SelectImageMapWidgetTest.java │ │ │ │ ├── SelectMultiImageMapWidgetTest.java │ │ │ │ ├── SelectMultiMinimalWidgetTest.java │ │ │ │ ├── SelectMultiWidgetTest.java │ │ │ │ ├── SelectOneFromMapDialogFragmentTest.kt │ │ │ │ ├── SelectOneFromMapWidgetTest.kt │ │ │ │ ├── SelectOneImageMapWidgetTest.java │ │ │ │ ├── SelectOneMinimalWidgetTest.java │ │ │ │ └── SelectOneWidgetTest.java │ │ │ ├── range/ │ │ │ │ ├── RangeDecimalWidgetTest.java │ │ │ │ ├── RangeIntegerWidgetTest.java │ │ │ │ ├── RangePickerWidgetTest.kt │ │ │ │ └── RangePickerWidgetUtilsTest.kt │ │ │ ├── support/ │ │ │ │ ├── FakeQuestionMediaManager.java │ │ │ │ ├── FakeWaitingForDataRegistry.java │ │ │ │ ├── FormElementFixtures.kt │ │ │ │ ├── FormEntryPromptSelectChoiceLoader.kt │ │ │ │ ├── GeoWidgetHelpers.java │ │ │ │ ├── NoOpMapFragment.kt │ │ │ │ ├── QuestionWidgetHelpers.java │ │ │ │ └── SynchronousImageLoader.kt │ │ │ ├── utilities/ │ │ │ │ ├── ActivityGeoDataRequesterTest.java │ │ │ │ ├── AudioRecorderRecordingStatusHandlerTest.java │ │ │ │ ├── ExternalAppRecordingRequesterTest.kt │ │ │ │ ├── FileRequesterImplTest.kt │ │ │ │ ├── GeoPolyDialogFragmentTest.kt │ │ │ │ ├── GeoWidgetUtilsTest.kt │ │ │ │ ├── GetContentAudioFileRequesterTest.kt │ │ │ │ ├── InternalRecordingRequesterTest.java │ │ │ │ ├── RangeWidgetUtilsTest.java │ │ │ │ ├── RecordingRequesterProviderTest.java │ │ │ │ ├── StringRequesterImplTest.kt │ │ │ │ └── StringWidgetUtilsTest.java │ │ │ ├── viewmodels/ │ │ │ │ ├── DateTimeViewModelTest.java │ │ │ │ └── QuestionViewModelTest.kt │ │ │ └── warnings/ │ │ │ ├── SpacesInUnderlyingValuesTest.java │ │ │ └── SpacesInUnderlyingValuesWarningTest.java │ │ └── geo/ │ │ └── javarosa/ │ │ └── IntersectsFunctionHandlerTest.kt │ └── resources/ │ ├── forms/ │ │ └── simple-search-external-csv.xml │ ├── media/ │ │ └── simple-search-external-csv-fruits.csv │ └── robolectric.properties ├── config/ │ ├── checkstyle.xml │ ├── lint.xml │ ├── pmd-ruleset.xml │ └── quality.gradle ├── crash-handler/ │ ├── .gitignore │ ├── build.gradle.kts │ └── src/ │ ├── main/ │ │ ├── AndroidManifest.xml │ │ ├── java/ │ │ │ └── org/ │ │ │ └── odk/ │ │ │ └── collect/ │ │ │ └── crashhandler/ │ │ │ ├── CrashHandler.kt │ │ │ ├── CrashView.kt │ │ │ └── MockCrashView.kt │ │ └── res/ │ │ └── layout/ │ │ └── crash_layout.xml │ └── test/ │ └── java/ │ └── org/ │ └── odk/ │ └── collect/ │ └── crashhandler/ │ └── CrashHandlerTest.kt ├── create-release.sh ├── db/ │ ├── .gitignore │ ├── build.gradle.kts │ └── src/ │ ├── main/ │ │ ├── AndroidManifest.xml │ │ └── java/ │ │ └── org/ │ │ └── odk/ │ │ └── collect/ │ │ └── db/ │ │ └── sqlite/ │ │ ├── AltDatabasePathContext.kt │ │ ├── CursorExt.kt │ │ ├── CustomSQLiteQueryBuilder.java │ │ ├── CustomSQLiteQueryExecutor.java │ │ ├── DatabaseConnection.kt │ │ ├── DatabaseMigrator.java │ │ ├── MigrationListDatabaseMigrator.kt │ │ ├── RowNumbers.kt │ │ ├── SQLiteColumns.kt │ │ ├── SQLiteDatabaseExt.kt │ │ ├── SQLiteUtils.java │ │ ├── SqlQuery.kt │ │ └── SynchronizedDatabaseConnection.kt │ └── test/ │ └── java/ │ └── org/ │ └── odk/ │ └── collect/ │ └── db/ │ └── sqlite/ │ ├── CustomSQLiteQueryBuilderTest.java │ ├── DatabaseConnectionTest.kt │ ├── RowNumbersTest.kt │ ├── SQLiteUtilsTest.java │ ├── SqlQueryTest.kt │ ├── SqliteDatabaseExtTest.kt │ └── support/ │ └── NoopMigrator.kt ├── debug.keystore ├── docs/ │ ├── ANALYTICS-QUESTIONS.md │ ├── CODE-GUIDELINES.md │ ├── CONTRIBUTING.md │ ├── STATE.md │ ├── TEST-GUIDELINES.md │ ├── WIDGETS.md │ └── WINDOWS-DEV-SETUP.md ├── download-robolectric-deps.sh ├── draw/ │ ├── .gitignore │ ├── build.gradle.kts │ └── src/ │ ├── main/ │ │ ├── AndroidManifest.xml │ │ ├── java/ │ │ │ └── org/ │ │ │ └── odk/ │ │ │ └── collect/ │ │ │ └── draw/ │ │ │ ├── DaggerSetup.kt │ │ │ ├── DrawActivity.java │ │ │ ├── DrawView.kt │ │ │ ├── DrawViewModel.kt │ │ │ ├── PenColorPickerDialog.kt │ │ │ ├── PenColorPickerViewModel.kt │ │ │ ├── QuitDrawingDialog.kt │ │ │ └── RobolectricApplication.kt │ │ └── res/ │ │ └── layout/ │ │ ├── draw_layout.xml │ │ └── quit_drawing_dialog_layout.xml │ └── test/ │ ├── java/ │ │ └── org/ │ │ └── odk/ │ │ └── collect/ │ │ └── draw/ │ │ ├── DrawActivityTest.kt │ │ ├── PenColorPickerDialogTest.kt │ │ └── PenColorPickerViewModelTest.kt │ └── resources/ │ └── robolectric.properties ├── entities/ │ ├── .gitignore │ ├── build.gradle.kts │ └── src/ │ ├── main/ │ │ ├── AndroidManifest.xml │ │ ├── java/ │ │ │ └── org/ │ │ │ └── odk/ │ │ │ └── collect/ │ │ │ └── entities/ │ │ │ ├── DaggerSetup.kt │ │ │ ├── LocalEntityUseCases.kt │ │ │ ├── browser/ │ │ │ │ ├── EntitiesFragment.kt │ │ │ │ ├── EntitiesViewModel.kt │ │ │ │ ├── EntityBrowserActivity.kt │ │ │ │ ├── EntityItem.kt │ │ │ │ └── EntityListsFragment.kt │ │ │ ├── javarosa/ │ │ │ │ ├── filter/ │ │ │ │ │ ├── LocalEntitiesFilterStrategy.kt │ │ │ │ │ └── PullDataFunctionHandler.kt │ │ │ │ ├── finalization/ │ │ │ │ │ ├── EntitiesExtra.kt │ │ │ │ │ ├── EntityFormFinalizationProcessor.kt │ │ │ │ │ ├── FormEntity.kt │ │ │ │ │ └── InvalidEntity.kt │ │ │ │ ├── intance/ │ │ │ │ │ ├── LocalEntitiesExternalInstanceParserFactory.kt │ │ │ │ │ ├── LocalEntitiesInstanceAdapter.kt │ │ │ │ │ └── LocalEntitiesInstanceProvider.kt │ │ │ │ ├── parse/ │ │ │ │ │ ├── EntityFormExtra.kt │ │ │ │ │ ├── EntityFormParseProcessor.kt │ │ │ │ │ ├── EntitySchema.kt │ │ │ │ │ ├── EntityXFormParserFactory.kt │ │ │ │ │ ├── SaveTo.kt │ │ │ │ │ ├── StringExt.kt │ │ │ │ │ └── XPathExpressionExt.kt │ │ │ │ └── spec/ │ │ │ │ ├── EntityAction.kt │ │ │ │ ├── EntityFormParser.kt │ │ │ │ ├── FormEntityElement.kt │ │ │ │ └── UnrecognizedEntityVersionException.kt │ │ │ ├── server/ │ │ │ │ └── EntitySource.kt │ │ │ └── storage/ │ │ │ ├── EntitiesRepository.kt │ │ │ ├── Entity.kt │ │ │ ├── EntityList.kt │ │ │ ├── InMemEntitiesRepository.kt │ │ │ └── QueryException.kt │ │ └── res/ │ │ ├── layout/ │ │ │ ├── entities_layout.xml │ │ │ ├── entity_list_item_layout.xml │ │ │ └── list_layout.xml │ │ └── navigation/ │ │ └── entities_nav.xml │ └── test/ │ └── java/ │ └── org/ │ └── odk/ │ └── collect/ │ └── entities/ │ ├── LocalEntityUseCasesTest.kt │ ├── browser/ │ │ └── EntityItemTest.kt │ └── javarosa/ │ ├── EntitiesTest.kt │ ├── EntityFormFinalizationProcessorTest.kt │ ├── EntityFormParseProcessorTest.kt │ ├── EntityFormParserTest.kt │ ├── LocalEntitiesInstanceProviderTest.kt │ ├── filter/ │ │ ├── LocalEntitiesFilterStrategyTest.kt │ │ └── PullDataFunctionHandlerTest.kt │ ├── parse/ │ │ ├── StringExtTest.kt │ │ └── XPathExpressionExtTest.kt │ └── support/ │ └── EntityXFormsElement.kt ├── errors/ │ ├── .gitignore │ ├── build.gradle.kts │ └── src/ │ ├── main/ │ │ ├── AndroidManifest.xml │ │ ├── java/ │ │ │ └── org/ │ │ │ └── odk/ │ │ │ └── collect/ │ │ │ └── errors/ │ │ │ ├── ErrorActivity.kt │ │ │ ├── ErrorAdapter.kt │ │ │ └── ErrorItem.kt │ │ └── res/ │ │ └── layout/ │ │ ├── activity_error.xml │ │ └── error_item.xml │ └── test/ │ ├── java/ │ │ └── org/ │ │ └── odk/ │ │ └── collect/ │ │ └── errors/ │ │ └── ErrorActivityTest.kt │ └── resources/ │ └── robolectric.properties ├── external-app/ │ ├── .gitignore │ ├── build.gradle.kts │ └── src/ │ ├── main/ │ │ ├── AndroidManifest.xml │ │ └── java/ │ │ └── org/ │ │ └── odk/ │ │ └── collect/ │ │ └── externalapp/ │ │ └── ExternalAppUtils.kt │ └── test/ │ └── java/ │ └── org/ │ └── odk/ │ └── collect/ │ └── externalapp/ │ └── ExternalAppUtilsTest.kt ├── forms/ │ ├── .gitignore │ ├── build.gradle.kts │ └── src/ │ ├── main/ │ │ └── java/ │ │ └── org/ │ │ └── odk/ │ │ └── collect/ │ │ └── forms/ │ │ ├── Form.java │ │ ├── FormListItem.kt │ │ ├── FormSource.java │ │ ├── FormSourceException.kt │ │ ├── FormsRepository.java │ │ ├── ManifestFile.kt │ │ ├── MediaFile.kt │ │ ├── instances/ │ │ │ ├── Instance.java │ │ │ └── InstancesRepository.java │ │ └── savepoints/ │ │ ├── Savepoint.kt │ │ └── SavepointsRepository.kt │ └── test/ │ └── java/ │ └── org/ │ └── odk/ │ └── collect/ │ └── forms/ │ └── instances/ │ └── InstanceTest.kt ├── forms-test/ │ ├── .gitignore │ ├── README.md │ ├── build.gradle.kts │ └── src/ │ ├── main/ │ │ └── java/ │ │ └── org/ │ │ └── odk/ │ │ └── collect/ │ │ └── formstest/ │ │ ├── FormFixtures.kt │ │ ├── FormUtils.kt │ │ ├── FormsRepositoryTest.java │ │ ├── InMemFormsRepository.java │ │ ├── InMemInstancesRepository.java │ │ ├── InMemSavepointsRepository.kt │ │ ├── InstanceFixtures.kt │ │ ├── InstanceUtils.kt │ │ ├── InstancesRepositoryTest.kt │ │ └── SavepointsRepositoryTest.kt │ └── test/ │ └── java/ │ └── org/ │ └── odk/ │ └── collect/ │ └── formstest/ │ ├── InMemFormsRepositoryTest.java │ ├── InMemInstancesRepositoryTest.kt │ └── InMemSavepointsRepositoryTest.kt ├── fragments-test/ │ ├── .gitignore │ ├── README.md │ ├── build.gradle.kts │ └── src/ │ └── main/ │ ├── AndroidManifest.xml │ └── java/ │ └── org/ │ └── odk/ │ └── collect/ │ └── fragmentstest/ │ └── FragmentScenarioLauncherRule.kt ├── geo/ │ ├── .gitignore │ ├── build.gradle.kts │ └── src/ │ ├── main/ │ │ ├── AndroidManifest.xml │ │ ├── java/ │ │ │ └── org/ │ │ │ └── odk/ │ │ │ └── collect/ │ │ │ └── geo/ │ │ │ ├── Constants.kt │ │ │ ├── DaggerSetup.kt │ │ │ ├── GeoActivityUtils.kt │ │ │ ├── GeoUtils.kt │ │ │ ├── analytics/ │ │ │ │ └── AnalyticsEvents.kt │ │ │ ├── geopoint/ │ │ │ │ ├── AccuracyProgressView.kt │ │ │ │ ├── AccuracyStatusView.kt │ │ │ │ ├── GeoPointActivity.kt │ │ │ │ ├── GeoPointDialogFragment.kt │ │ │ │ ├── GeoPointMapActivity.java │ │ │ │ ├── GeoPointViewModel.kt │ │ │ │ └── LocationAccuracy.kt │ │ │ ├── geopoly/ │ │ │ │ ├── GeoPolyFragment.kt │ │ │ │ ├── GeoPolySettingsDialogFragment.java │ │ │ │ ├── GeoPolyUtils.kt │ │ │ │ ├── GeoPolyViewModel.kt │ │ │ │ └── InfoDialog.kt │ │ │ ├── javarosa/ │ │ │ │ └── IntersectsFunctionHandler.kt │ │ │ └── selection/ │ │ │ ├── MappableSelectItem.kt │ │ │ ├── SelectionMapFragment.kt │ │ │ └── SelectionSummarySheet.kt │ │ └── res/ │ │ ├── color/ │ │ │ └── fab_surface_background_color_less_transparent_disabled.xml │ │ ├── drawable/ │ │ │ ├── ic_add_location.xml │ │ │ ├── ic_backspace.xml │ │ │ ├── ic_crop_frame.xml │ │ │ ├── ic_distance.xml │ │ │ ├── ic_info.xml │ │ │ ├── ic_layers.xml │ │ │ ├── ic_my_location.xml │ │ │ ├── ic_note_add.xml │ │ │ ├── ic_pause_36.xml │ │ │ └── property_divider.xml │ │ ├── layout/ │ │ │ ├── accuracy_progress_layout.xml │ │ │ ├── accuracy_status_layout.xml │ │ │ ├── geopoint_dialog.xml │ │ │ ├── geopoint_layout.xml │ │ │ ├── geopoly_dialog.xml │ │ │ ├── geopoly_layout.xml │ │ │ ├── property.xml │ │ │ ├── selection_map_layout.xml │ │ │ ├── selection_summary_sheet_layout.xml │ │ │ └── simple_spinner_dropdown_item.xml │ │ ├── layout-land/ │ │ │ ├── geopoint_layout.xml │ │ │ └── geopoly_layout.xml │ │ └── values/ │ │ ├── attrs.xml │ │ ├── fab_surface.xml │ │ └── force_light_surface_overlay.xml │ └── test/ │ ├── java/ │ │ └── org/ │ │ └── odk/ │ │ └── collect/ │ │ └── geo/ │ │ ├── GeoUtilsTest.kt │ │ ├── geopoint/ │ │ │ ├── AccuracyProgressViewTest.kt │ │ │ ├── AccuracyStatusViewTest.kt │ │ │ ├── GeoPointActivityTest.kt │ │ │ ├── GeoPointDialogFragmentTest.kt │ │ │ ├── GeoPointMapActivityTest.java │ │ │ └── LocationTrackerGeoPointViewModelTest.kt │ │ ├── geopoly/ │ │ │ ├── GeoPolyFragmentTest.kt │ │ │ ├── GeoPolySettingsDialogFragmentTest.java │ │ │ ├── GeoPolyUtilsTest.kt │ │ │ ├── GeoPolyViewModelTest.kt │ │ │ └── InfoContentTest.kt │ │ ├── selection/ │ │ │ ├── SelectionMapFragmentTest.kt │ │ │ └── SelectionSummarySheetTest.kt │ │ └── support/ │ │ ├── AccuracyStatusViewMatcher.kt │ │ ├── FakeLocationTracker.kt │ │ ├── FakeMapFragment.kt │ │ ├── Fixtures.kt │ │ └── RobolectricApplication.kt │ └── resources/ │ └── robolectric.properties ├── google-maps/ │ ├── .gitignore │ ├── build.gradle.kts │ └── src/ │ └── main/ │ ├── AndroidManifest.xml │ ├── java/ │ │ └── org/ │ │ └── odk/ │ │ └── collect/ │ │ └── googlemaps/ │ │ ├── BitmapDescriptorCache.kt │ │ ├── DaggerSetup.kt │ │ ├── GoogleMapConfigurator.java │ │ ├── GoogleMapFragment.java │ │ ├── GoogleMapsMapBoxOfflineTileProvider.java │ │ ├── MapPointExt.kt │ │ ├── circles/ │ │ │ └── CircleFeature.kt │ │ └── scaleview/ │ │ ├── Drawer.java │ │ ├── MapScaleModel.java │ │ ├── MapScaleView.java │ │ ├── Scale.java │ │ ├── Scales.java │ │ └── ViewConfig.java │ └── res/ │ ├── layout/ │ │ └── map_layout.xml │ └── values/ │ └── attrs.xml ├── gradle/ │ ├── libs.versions.toml │ └── wrapper/ │ ├── gradle-wrapper.jar │ └── gradle-wrapper.properties ├── gradle.properties ├── gradlew ├── gradlew.bat ├── icons/ │ ├── .gitignore │ ├── build.gradle.kts │ └── src/ │ └── main/ │ ├── AndroidManifest.xml │ └── res/ │ └── drawable/ │ ├── ic_add_white_24.xml │ ├── ic_baseline_add_24.xml │ ├── ic_baseline_barcode_scanner_white_24.xml │ ├── ic_baseline_calendar_today_white_24.xml │ ├── ic_baseline_check_24.xml │ ├── ic_baseline_collapse_24.xml │ ├── ic_baseline_done_all_24.xml │ ├── ic_baseline_draw_white_24.xml │ ├── ic_baseline_expand_24.xml │ ├── ic_baseline_explore_white_24.xml │ ├── ic_baseline_format_list_bulleted_white_24.xml │ ├── ic_baseline_format_list_numbered_white_24.xml │ ├── ic_baseline_language_24.xml │ ├── ic_baseline_layers_24.xml │ ├── ic_baseline_library_music_white_24.xml │ ├── ic_baseline_list_24.xml │ ├── ic_baseline_location_off_24.xml │ ├── ic_baseline_location_on_24.xml │ ├── ic_baseline_location_on_white_24.xml │ ├── ic_baseline_markup_white_24.xml │ ├── ic_baseline_mic_24.xml │ ├── ic_baseline_mic_off_24.xml │ ├── ic_baseline_mic_white_24.xml │ ├── ic_baseline_my_location_white_24.xml │ ├── ic_baseline_open_in_new_white_24.xml │ ├── ic_baseline_photo_camera_white_24.xml │ ├── ic_baseline_photo_library_white_24.xml │ ├── ic_baseline_print_white_24.xml │ ├── ic_baseline_qr_code_2_add_24.xml │ ├── ic_baseline_remove_24.xml │ ├── ic_baseline_rule_24.xml │ ├── ic_baseline_settings_24.xml │ ├── ic_baseline_signature_white_24.xml │ ├── ic_baseline_time_filled_white_24.xml │ ├── ic_baseline_visibility_24.xml │ ├── ic_baseline_warning_24.xml │ ├── ic_baseline_wifi_off_24.xml │ ├── ic_clear_white.xml │ ├── ic_close.xml │ ├── ic_color_lens_white.xml │ ├── ic_delete.xml │ ├── ic_delete_24.xml │ ├── ic_edit.xml │ ├── ic_map_marker_big.xml │ ├── ic_map_marker_small.xml │ ├── ic_map_marker_with_hole_big.xml │ ├── ic_map_marker_with_hole_small.xml │ ├── ic_map_point.xml │ ├── ic_notification_small.xml │ ├── ic_outline_info_24.xml │ ├── ic_outline_polygon_white_24.xml │ ├── ic_outline_polyline_white_24.xml │ ├── ic_save.xml │ └── ic_save_white.xml ├── image-loader/ │ ├── .gitignore │ ├── build.gradle.kts │ └── src/ │ └── main/ │ ├── AndroidManifest.xml │ └── java/ │ └── org/ │ └── odk/ │ └── collect/ │ └── imageloader/ │ ├── GlideImageLoader.kt │ └── svg/ │ ├── SvgDecoder.kt │ ├── SvgDrawableTranscoder.kt │ ├── SvgModule.kt │ └── SvgSoftwareLayerSetter.kt ├── lists/ │ ├── .gitignore │ ├── build.gradle.kts │ ├── proguard-rules.pro │ └── src/ │ ├── main/ │ │ ├── AndroidManifest.xml │ │ ├── java/ │ │ │ └── org/ │ │ │ └── odk/ │ │ │ └── collect/ │ │ │ └── lists/ │ │ │ ├── EmptyListView.kt │ │ │ ├── RecyclerViewUtils.kt │ │ │ └── selects/ │ │ │ ├── MultiSelectAdapter.kt │ │ │ ├── MultiSelectControlsFragment.kt │ │ │ ├── MultiSelectListFragment.kt │ │ │ ├── MultiSelectViewModel.kt │ │ │ ├── SelectItem.kt │ │ │ └── SingleSelectViewModel.kt │ │ └── res/ │ │ ├── layout/ │ │ │ ├── empty_list_view.xml │ │ │ ├── multi_select_controls_layout.xml │ │ │ └── multi_select_list.xml │ │ └── values/ │ │ └── attrs.xml │ └── test/ │ ├── java/ │ │ └── org/ │ │ └── odk/ │ │ └── collect/ │ │ └── lists/ │ │ ├── EmptyListViewTest.kt │ │ ├── RobolectricApplication.kt │ │ └── selects/ │ │ ├── MultiSelectAdapterTest.kt │ │ ├── MultiSelectControlsFragmentTest.kt │ │ ├── MultiSelectListFragmentTest.kt │ │ ├── MultiSelectViewModelTest.kt │ │ ├── SingleSelectViewModelTest.kt │ │ └── support/ │ │ └── TextAndCheckboxViewHolder.kt │ └── resources/ │ └── robolectric.properties ├── location/ │ ├── .gitignore │ ├── build.gradle.kts │ └── src/ │ ├── main/ │ │ ├── AndroidManifest.xml │ │ └── java/ │ │ └── org/ │ │ └── odk/ │ │ └── collect/ │ │ └── location/ │ │ ├── AndroidLocationClient.java │ │ ├── BaseLocationClient.kt │ │ ├── DaggerSetup.kt │ │ ├── GoogleFusedLocationClient.kt │ │ ├── Location.kt │ │ ├── LocationClient.java │ │ ├── LocationClientProvider.kt │ │ ├── LocationUtils.kt │ │ ├── satellites/ │ │ │ ├── GpsStatusSatelliteInfoClient.kt │ │ │ └── SatelliteInfoClient.kt │ │ └── tracker/ │ │ ├── ForegroundServiceLocationTracker.kt │ │ └── LocationTracker.kt │ └── test/ │ ├── java/ │ │ └── org/ │ │ └── odk/ │ │ └── collect/ │ │ └── location/ │ │ ├── AndroidLocationClientTest.java │ │ ├── GoogleFusedLocationClientTest.kt │ │ ├── LocationClientProviderTest.kt │ │ ├── LocationUtilsTest.kt │ │ ├── RobolectricApplication.kt │ │ ├── TestClientListener.java │ │ ├── TestLocationListener.java │ │ └── tracker/ │ │ ├── ForegroundServiceLocationTrackerTest.kt │ │ ├── LocationTrackerServiceTest.kt │ │ └── LocationTrackerTest.kt │ └── resources/ │ └── robolectric.properties ├── mapbox/ │ ├── .gitignore │ ├── build.gradle.kts │ └── src/ │ └── main/ │ ├── AndroidManifest.xml │ ├── java/ │ │ └── org/ │ │ └── odk/ │ │ └── collect/ │ │ └── mapbox/ │ │ ├── DynamicPolyLineFeature.kt │ │ ├── DynamicPolygonFeature.kt │ │ ├── LineFeature.kt │ │ ├── MapBoxInitializationFragment.kt │ │ ├── MapFeature.kt │ │ ├── MapUtils.kt │ │ ├── MapboxMapConfigurator.java │ │ ├── MapboxMapFragment.kt │ │ ├── MarkerFeature.kt │ │ ├── StaticPolyLineFeature.kt │ │ ├── StaticPolygonFeature.kt │ │ └── TileHttpServer.java │ └── res/ │ └── layout/ │ └── mapbox_fragment_layout.xml ├── maps/ │ ├── .gitignore │ ├── build.gradle.kts │ └── src/ │ ├── main/ │ │ ├── AndroidManifest.xml │ │ ├── java/ │ │ │ └── org/ │ │ │ └── odk/ │ │ │ └── collect/ │ │ │ └── maps/ │ │ │ ├── AnalyticsEvents.kt │ │ │ ├── MapConfigurator.kt │ │ │ ├── MapConsts.kt │ │ │ ├── MapFragment.kt │ │ │ ├── MapFragmentFactory.kt │ │ │ ├── MapPoint.kt │ │ │ ├── MapViewModel.kt │ │ │ ├── MapViewModelMapFragment.kt │ │ │ ├── ZoomObserver.kt │ │ │ ├── circles/ │ │ │ │ ├── CircleDescription.kt │ │ │ │ └── CurrentLocationDelegate.kt │ │ │ ├── layers/ │ │ │ │ ├── DirectoryReferenceLayerRepository.kt │ │ │ │ ├── MapFragmentReferenceLayerUtils.kt │ │ │ │ ├── MbtilesFile.java │ │ │ │ ├── OfflineMapLayersImporterAdapter.kt │ │ │ │ ├── OfflineMapLayersImporterDialogFragment.kt │ │ │ │ ├── OfflineMapLayersPickerAdapter.kt │ │ │ │ ├── OfflineMapLayersPickerBottomSheetDialogFragment.kt │ │ │ │ ├── OfflineMapLayersViewModel.kt │ │ │ │ ├── ReferenceLayerRepository.kt │ │ │ │ └── TileSource.java │ │ │ ├── markers/ │ │ │ │ ├── MarkerDescription.kt │ │ │ │ ├── MarkerIconCreator.kt │ │ │ │ └── MarkerIconDescription.kt │ │ │ └── traces/ │ │ │ ├── LineDescription.kt │ │ │ ├── PolygonDescription.kt │ │ │ └── TraceDescription.kt │ │ └── res/ │ │ ├── drawable/ │ │ │ └── ic_crosshairs.xml │ │ └── layout/ │ │ ├── offline_map_layers_importer.xml │ │ ├── offline_map_layers_importer_item.xml │ │ ├── offline_map_layers_picker.xml │ │ └── offline_map_layers_picker_item.xml │ └── test/ │ ├── java/ │ │ └── org/ │ │ └── odk/ │ │ └── collect/ │ │ └── maps/ │ │ ├── LineDescriptionTest.kt │ │ ├── MarkerIconDescriptionTest.kt │ │ ├── PolygonDescriptionTest.kt │ │ ├── RobolectricApplication.kt │ │ ├── TraceDescriptionTest.kt │ │ └── layers/ │ │ ├── DirectoryReferenceLayerRepositoryTest.kt │ │ ├── InMemReferenceLayerRepository.kt │ │ ├── MapFragmentReferenceLayerUtilsTest.kt │ │ ├── OfflineMapLayersImporterDialogFragmentTest.kt │ │ └── OfflineMapLayersPickerBottomSheetDialogFragmentTest.kt │ └── resources/ │ └── robolectric.properties ├── material/ │ ├── .gitignore │ ├── README.md │ ├── build.gradle.kts │ └── src/ │ ├── main/ │ │ ├── AndroidManifest.xml │ │ ├── java/ │ │ │ └── org/ │ │ │ └── odk/ │ │ │ └── collect/ │ │ │ └── material/ │ │ │ ├── BottomSheetBehavior.kt │ │ │ ├── ErrorsPill.kt │ │ │ ├── MaterialFullScreenDialogFragment.kt │ │ │ ├── MaterialPill.kt │ │ │ ├── MaterialProgressDialogFragment.java │ │ │ └── Pill.kt │ │ └── res/ │ │ ├── layout/ │ │ │ ├── pill.xml │ │ │ └── progress_dialog.xml │ │ └── values/ │ │ ├── attrs.xml │ │ ├── material_3_button_icon_end_style.xml │ │ └── material_full_screen_dialog_theme.xml │ └── test/ │ └── java/ │ └── org/ │ └── odk/ │ └── collect/ │ └── material/ │ ├── ErrorsPillTest.kt │ └── MaterialProgressDialogFragmentTest.java ├── metadata/ │ ├── .gitignore │ ├── build.gradle.kts │ └── src/ │ ├── main/ │ │ └── java/ │ │ └── org/ │ │ └── odk/ │ │ └── collect/ │ │ └── metadata/ │ │ ├── InstallIDProvider.kt │ │ └── PropertyManager.kt │ └── test/ │ └── java/ │ └── org/ │ └── odk/ │ └── collect/ │ └── metadata/ │ ├── PropertyManagerTest.kt │ └── SettingsInstallIDProviderTest.kt ├── mobile-device-management/ │ ├── .gitignore │ ├── build.gradle.kts │ └── src/ │ ├── main/ │ │ ├── AndroidManifest.xml │ │ ├── java/ │ │ │ └── org/ │ │ │ └── odk/ │ │ │ └── collect/ │ │ │ └── mobiledevicemanagement/ │ │ │ ├── MDMConfigHandler.kt │ │ │ └── MDMConfigObserver.kt │ │ └── res/ │ │ └── xml/ │ │ └── managed_configuration.xml │ └── test/ │ └── java/ │ └── org/ │ └── odk/ │ └── collect/ │ └── mobiledevicemanagement/ │ ├── MDMConfigHandlerTest.kt │ └── MDMConfigObserverTest.kt ├── nbistubs/ │ ├── .gitignore │ ├── README.md │ ├── build.gradle.kts │ └── src/ │ └── main/ │ └── AndroidManifest.xml ├── open-rosa/ │ ├── .gitignore │ ├── build.gradle.kts │ └── src/ │ ├── main/ │ │ └── java/ │ │ └── org/ │ │ └── odk/ │ │ └── collect/ │ │ └── openrosa/ │ │ ├── forms/ │ │ │ ├── DocumentFetchResult.java │ │ │ ├── EntityIntegrity.kt │ │ │ ├── OpenRosaClient.kt │ │ │ └── OpenRosaXmlFetcher.java │ │ ├── http/ │ │ │ ├── CaseInsensitiveEmptyHeaders.java │ │ │ ├── CaseInsensitiveHeaders.java │ │ │ ├── CollectThenSystemContentTypeMapper.java │ │ │ ├── HttpCredentials.java │ │ │ ├── HttpCredentialsInterface.java │ │ │ ├── HttpGetResult.java │ │ │ ├── HttpHeadResult.java │ │ │ ├── HttpPostResult.java │ │ │ ├── OpenRosaConstants.kt │ │ │ ├── OpenRosaHttpInterface.java │ │ │ └── okhttp/ │ │ │ ├── OkHttpCaseInsensitiveHeaders.java │ │ │ ├── OkHttpConnection.java │ │ │ ├── OkHttpOpenRosaServerClientProvider.java │ │ │ ├── OpenRosaServerClient.java │ │ │ └── OpenRosaServerClientProvider.java │ │ └── parse/ │ │ ├── Kxml2OpenRosaResponseParser.kt │ │ └── OpenRosaResponseParser.kt │ └── test/ │ └── java/ │ └── org/ │ └── odk/ │ └── collect/ │ └── openrosa/ │ ├── forms/ │ │ ├── OpenRosaClientTest.kt │ │ └── OpenRosaXmlFetcherTest.java │ ├── http/ │ │ ├── CaseInsensitiveEmptyHeadersTest.java │ │ ├── CollectThenSystemContentTypeMapperTest.java │ │ ├── OpenRosaGetRequestTest.java │ │ ├── OpenRosaHeadRequestTest.java │ │ ├── OpenRosaPostRequestTest.java │ │ └── okhttp/ │ │ ├── OkHttpCaseInsensitiveHeadersTest.java │ │ ├── OkHttpConnectionGetRequestTest.java │ │ ├── OkHttpConnectionHeadRequestTest.java │ │ ├── OkHttpConnectionPostRequestTest.java │ │ ├── OkHttpOpenRosaServerClientProviderTest.java │ │ └── OpenRosaServerClientProviderTest.java │ ├── parse/ │ │ └── Kxml2OpenRosaResponseParserTest.kt │ └── support/ │ ├── MockWebServerHelper.java │ ├── MockWebServerRule.kt │ └── StubWebCredentialsProvider.java ├── osmdroid/ │ ├── .gitignore │ ├── build.gradle.kts │ └── src/ │ └── main/ │ ├── AndroidManifest.xml │ ├── java/ │ │ └── org/ │ │ └── odk/ │ │ └── collect/ │ │ └── osmdroid/ │ │ ├── DaggerSetup.kt │ │ ├── OsmDroidInitializer.kt │ │ ├── OsmDroidMapConfigurator.java │ │ ├── OsmDroidMapFragment.java │ │ ├── OsmMBTileModuleProvider.java │ │ ├── OsmMBTileProvider.java │ │ ├── OsmMBTileSource.java │ │ └── WebMapService.java │ └── res/ │ └── layout/ │ └── osm_map_layout.xml ├── permissions/ │ ├── .gitignore │ ├── build.gradle.kts │ └── src/ │ ├── main/ │ │ ├── AndroidManifest.xml │ │ ├── java/ │ │ │ └── org/ │ │ │ └── odk/ │ │ │ └── collect/ │ │ │ └── permissions/ │ │ │ ├── LocationAccessibilityChecker.kt │ │ │ ├── PermissionListener.kt │ │ │ ├── PermissionsChecker.kt │ │ │ ├── PermissionsDialogCreator.kt │ │ │ ├── PermissionsProvider.kt │ │ │ └── RequestPermissionsAPI.kt │ │ └── res/ │ │ └── drawable/ │ │ ├── ic_photo_camera.xml │ │ ├── ic_room_24dp.xml │ │ └── ic_storage.xml │ └── test/ │ ├── java/ │ │ └── org/ │ │ └── odk/ │ │ └── collect/ │ │ └── permissions/ │ │ ├── PermissionsDialogCreatorTest.kt │ │ └── PermissionsProviderTest.kt │ └── resources/ │ └── robolectric.properties ├── printer/ │ ├── .gitignore │ ├── build.gradle.kts │ └── src/ │ └── main/ │ ├── AndroidManifest.xml │ └── java/ │ └── org/ │ └── odk/ │ └── collect/ │ └── printer/ │ └── HtmlPrinter.kt ├── projects/ │ ├── .gitignore │ ├── build.gradle.kts │ └── src/ │ ├── main/ │ │ ├── AndroidManifest.xml │ │ └── java/ │ │ └── org/ │ │ └── odk/ │ │ └── collect/ │ │ └── projects/ │ │ ├── DaggerSetup.kt │ │ ├── InMemProjectsRepository.kt │ │ ├── Project.kt │ │ ├── ProjectConfigurationResult.kt │ │ ├── ProjectCreator.kt │ │ ├── ProjectDependencyFactory.kt │ │ ├── ProjectsRepository.kt │ │ ├── SettingsConnectionMatcher.kt │ │ └── SharedPreferencesProjectsRepository.kt │ └── test/ │ ├── java/ │ │ └── org/ │ │ └── odk/ │ │ └── collect/ │ │ └── projects/ │ │ ├── InMemProjectsRepositoryTest.kt │ │ ├── ProjectsRepositoryTest.kt │ │ ├── SharedPreferencesProjectsRepositoryTest.kt │ │ └── support/ │ │ └── RobolectricApplication.kt │ └── resources/ │ └── robolectric.properties ├── qr-code/ │ ├── .gitignore │ ├── build.gradle.kts │ └── src/ │ ├── main/ │ │ ├── AndroidManifest.xml │ │ ├── java/ │ │ │ └── org/ │ │ │ └── odk/ │ │ │ └── collect/ │ │ │ └── qrcode/ │ │ │ ├── BarcodeFilter.kt │ │ │ ├── BarcodeScannerViewContainer.kt │ │ │ ├── DetectedState.kt │ │ │ ├── FlashlightToggle.kt │ │ │ ├── ScannerControls.kt │ │ │ ├── ScannerOverlay.kt │ │ │ ├── mlkit/ │ │ │ │ ├── MlKitBarcodeScannerViewFactory.kt │ │ │ │ └── PlayServicesFallbackBarcodeScannerViewFactory.kt │ │ │ └── zxing/ │ │ │ ├── QRCodeCreator.kt │ │ │ ├── QRCodeDecoder.kt │ │ │ └── ZxingBarcodeScannerViewFactory.kt │ │ └── res/ │ │ └── layout/ │ │ ├── mlkit_barcode_scanner_layout.xml │ │ └── zxing_barcode_scanner_layout.xml │ └── test/ │ ├── java/ │ │ └── org/ │ │ └── odk/ │ │ └── collect/ │ │ └── qrcode/ │ │ ├── BarcodeFilterTest.kt │ │ └── QRCodeEncodeDecodeTest.kt │ └── resources/ │ └── robolectric.properties ├── secrets.gradle ├── selfie-camera/ │ ├── .gitignore │ ├── build.gradle.kts │ └── src/ │ ├── main/ │ │ ├── AndroidManifest.xml │ │ ├── java/ │ │ │ └── org/ │ │ │ └── odk/ │ │ │ └── collect/ │ │ │ └── selfiecamera/ │ │ │ ├── Camera.kt │ │ │ ├── CameraXCamera.kt │ │ │ ├── CaptureSelfieActivity.kt │ │ │ └── DaggerSetup.kt │ │ └── res/ │ │ └── layout/ │ │ └── activity_capture_selfie.xml │ └── test/ │ ├── java/ │ │ └── org/ │ │ └── odk/ │ │ └── collect/ │ │ └── selfiecamera/ │ │ ├── CaptureSelfieActivityTest.kt │ │ └── support/ │ │ └── RobolectricApplication.kt │ └── resources/ │ └── robolectric.properties ├── service-test/ │ ├── .gitignore │ ├── README.md │ ├── build.gradle.kts │ └── src/ │ └── main/ │ ├── AndroidManifest.xml │ └── java/ │ └── org/ │ └── odk/ │ └── collect/ │ └── servicetest/ │ ├── NotificationDetails.kt │ └── ServiceScenario.kt ├── settings/ │ ├── .gitignore │ ├── README.md │ ├── build.gradle.kts │ ├── consumer-rules.pro │ └── src/ │ ├── main/ │ │ ├── AndroidManifest.xml │ │ ├── java/ │ │ │ └── org/ │ │ │ └── odk/ │ │ │ └── collect/ │ │ │ └── settings/ │ │ │ ├── InMemSettingsProvider.kt │ │ │ ├── ODKAppSettingsImporter.kt │ │ │ ├── ODKAppSettingsMigrator.java │ │ │ ├── SettingsProvider.kt │ │ │ ├── enums/ │ │ │ │ ├── AutoSend.kt │ │ │ │ ├── FormUpdateMode.java │ │ │ │ ├── GuidanceHintMode.kt │ │ │ │ ├── StringIdEnum.kt │ │ │ │ └── StringIdEnumUtils.kt │ │ │ ├── importing/ │ │ │ │ ├── ProjectDetailsCreatorImpl.kt │ │ │ │ └── SettingsImporter.kt │ │ │ ├── keys/ │ │ │ │ ├── AppConfigurationKeys.kt │ │ │ │ ├── MetaKeys.kt │ │ │ │ ├── ProjectKeys.kt │ │ │ │ └── ProtectedProjectKeys.kt │ │ │ ├── migration/ │ │ │ │ ├── KeyCombiner.java │ │ │ │ ├── KeyExtractor.java │ │ │ │ ├── KeyMover.java │ │ │ │ ├── KeyRenamer.java │ │ │ │ ├── KeyTranslator.java │ │ │ │ ├── KeyUpdater.java │ │ │ │ ├── KeyValuePair.java │ │ │ │ ├── Migration.java │ │ │ │ ├── MigrationUtils.java │ │ │ │ └── ValueTranslator.java │ │ │ └── validation/ │ │ │ └── JsonSchemaSettingsValidator.kt │ │ ├── res/ │ │ │ └── values/ │ │ │ └── strings.xml │ │ └── resources/ │ │ └── client-settings.schema.json │ └── test/ │ └── java/ │ └── org/ │ └── odk/ │ └── collect/ │ └── settings/ │ ├── ODKAppSettingsImporterTest.kt │ ├── ODKAppSettingsMigratorTest.java │ ├── importing/ │ │ ├── ProjectDetailsCreatorImplTest.kt │ │ └── SettingsImporterTest.kt │ ├── migration/ │ │ ├── KeyCombinerTest.java │ │ ├── KeyExtractorTest.java │ │ ├── KeyMoverTest.java │ │ ├── KeyRemoverTest.java │ │ ├── KeyRenamerTest.java │ │ ├── KeyTranslatorTest.java │ │ └── ValueTranslatorTest.java │ ├── support/ │ │ └── SettingsUtils.kt │ └── validation/ │ ├── JsonSchemaSettingsValidatorTest.kt │ └── OriginalJsonSchemaSettingsValidatorTest.kt ├── settings.gradle ├── shadows/ │ ├── .gitignore │ ├── README.md │ ├── build.gradle.kts │ └── src/ │ ├── main/ │ │ ├── AndroidManifest.xml │ │ └── java/ │ │ └── org/ │ │ └── odk/ │ │ └── collect/ │ │ └── shadows/ │ │ └── ShadowAndroidXAlertDialog.kt │ └── test/ │ └── java/ │ └── org/ │ └── odk/ │ └── collect/ │ └── shadows/ │ └── ShadowAndroidXAlertDialogTest.kt ├── shared/ │ ├── .gitignore │ ├── build.gradle.kts │ └── src/ │ ├── main/ │ │ └── java/ │ │ └── org/ │ │ └── odk/ │ │ └── collect/ │ │ └── shared/ │ │ ├── DebugLogger.kt │ │ ├── PathUtils.kt │ │ ├── Query.kt │ │ ├── TempFiles.kt │ │ ├── TimeInMs.kt │ │ ├── collections/ │ │ │ └── CollectionExtensions.kt │ │ ├── files/ │ │ │ └── FileExt.kt │ │ ├── geometry/ │ │ │ └── Geometry.kt │ │ ├── injection/ │ │ │ └── ObjectProvider.kt │ │ ├── locks/ │ │ │ ├── BooleanChangeLock.kt │ │ │ ├── ChangeLock.kt │ │ │ └── ThreadSafeBooleanChangeLock.kt │ │ ├── result/ │ │ │ └── Result.kt │ │ ├── settings/ │ │ │ ├── InMemSettings.kt │ │ │ └── Settings.kt │ │ └── strings/ │ │ ├── Md5.kt │ │ ├── RandomString.java │ │ ├── StringUtils.kt │ │ └── UUIDGenerator.kt │ └── test/ │ └── java/ │ └── org/ │ └── odk/ │ └── collect/ │ └── shared/ │ ├── Md5Test.kt │ ├── PathUtilsTest.kt │ ├── QuickCheck.kt │ ├── collections/ │ │ └── CollectionExtensionsTest.kt │ ├── files/ │ │ └── FileExtTest.kt │ ├── geometry/ │ │ ├── GeometryTest.kt │ │ └── support/ │ │ ├── GeometryTestUtils.kt │ │ └── GeometryTestUtilsTest.kt │ ├── locks/ │ │ ├── BooleanChangeLockTest.kt │ │ ├── ChangeLockTest.kt │ │ └── ThreadSafeBooleanChangeLockTest.kt │ └── strings/ │ └── StringUtilsTest.kt ├── strings/ │ ├── .gitignore │ ├── build.gradle.kts │ └── src/ │ ├── main/ │ │ ├── AndroidManifest.xml │ │ ├── java/ │ │ │ └── org/ │ │ │ └── odk/ │ │ │ └── collect/ │ │ │ └── strings/ │ │ │ ├── format/ │ │ │ │ └── LengthFormatter.kt │ │ │ └── localization/ │ │ │ ├── LocalizedActivity.kt │ │ │ └── LocalizedApplication.kt │ │ └── res/ │ │ ├── values/ │ │ │ ├── strings.xml │ │ │ └── untranslated.xml │ │ ├── values-af/ │ │ │ └── strings.xml │ │ ├── values-am/ │ │ │ └── strings.xml │ │ ├── values-ar/ │ │ │ └── strings.xml │ │ ├── values-bg/ │ │ │ └── strings.xml │ │ ├── values-bn/ │ │ │ └── strings.xml │ │ ├── values-ca/ │ │ │ └── strings.xml │ │ ├── values-cs/ │ │ │ └── strings.xml │ │ ├── values-da/ │ │ │ └── strings.xml │ │ ├── values-de/ │ │ │ └── strings.xml │ │ ├── values-es/ │ │ │ └── strings.xml │ │ ├── values-es-rSV/ │ │ │ └── strings.xml │ │ ├── values-et/ │ │ │ └── strings.xml │ │ ├── values-fa/ │ │ │ └── strings.xml │ │ ├── values-fa-rAF/ │ │ │ └── strings.xml │ │ ├── values-fi/ │ │ │ └── strings.xml │ │ ├── values-fr/ │ │ │ └── strings.xml │ │ ├── values-hi/ │ │ │ └── strings.xml │ │ ├── values-ht/ │ │ │ └── strings.xml │ │ ├── values-in/ │ │ │ └── strings.xml │ │ ├── values-it/ │ │ │ └── strings.xml │ │ ├── values-ja/ │ │ │ └── strings.xml │ │ ├── values-ka/ │ │ │ └── strings.xml │ │ ├── values-km/ │ │ │ └── strings.xml │ │ ├── values-ln/ │ │ │ └── strings.xml │ │ ├── values-lo-rLA/ │ │ │ └── strings.xml │ │ ├── values-lt/ │ │ │ └── strings.xml │ │ ├── values-mg/ │ │ │ └── strings.xml │ │ ├── values-ml/ │ │ │ └── strings.xml │ │ ├── values-mr/ │ │ │ └── strings.xml │ │ ├── values-ms/ │ │ │ └── strings.xml │ │ ├── values-my/ │ │ │ └── strings.xml │ │ ├── values-ne-rNP/ │ │ │ └── strings.xml │ │ ├── values-nl/ │ │ │ └── strings.xml │ │ ├── values-no/ │ │ │ └── strings.xml │ │ ├── values-pl/ │ │ │ └── strings.xml │ │ ├── values-ps/ │ │ │ └── strings.xml │ │ ├── values-pt/ │ │ │ └── strings.xml │ │ ├── values-ro/ │ │ │ └── strings.xml │ │ ├── values-ru/ │ │ │ └── strings.xml │ │ ├── values-rw/ │ │ │ └── strings.xml │ │ ├── values-si/ │ │ │ └── strings.xml │ │ ├── values-sl/ │ │ │ └── strings.xml │ │ ├── values-so/ │ │ │ └── strings.xml │ │ ├── values-sq/ │ │ │ └── strings.xml │ │ ├── values-sr/ │ │ │ └── strings.xml │ │ ├── values-sv-rSE/ │ │ │ └── strings.xml │ │ ├── values-sw/ │ │ │ └── strings.xml │ │ ├── values-sw-rKE/ │ │ │ └── strings.xml │ │ ├── values-te/ │ │ │ └── strings.xml │ │ ├── values-th-rTH/ │ │ │ └── strings.xml │ │ ├── values-ti/ │ │ │ └── strings.xml │ │ ├── values-tl/ │ │ │ └── strings.xml │ │ ├── values-tl-rPH/ │ │ │ └── strings.xml │ │ ├── values-tr/ │ │ │ └── strings.xml │ │ ├── values-uk/ │ │ │ └── strings.xml │ │ ├── values-ur/ │ │ │ └── strings.xml │ │ ├── values-ur-rPK/ │ │ │ └── strings.xml │ │ ├── values-vi/ │ │ │ └── strings.xml │ │ ├── values-zh/ │ │ │ └── strings.xml │ │ ├── values-zh-rTW/ │ │ │ └── strings.xml │ │ └── values-zu/ │ │ └── strings.xml │ └── test/ │ ├── java/ │ │ └── org/ │ │ └── odk/ │ │ └── collect/ │ │ └── strings/ │ │ └── format/ │ │ ├── DateFormatsTest.kt │ │ └── LengthFormatterTest.kt │ └── resources/ │ └── robolectric.properties ├── test-forms/ │ ├── .gitignore │ ├── build.gradle.kts │ └── src/ │ └── main/ │ └── resources/ │ ├── forms/ │ │ ├── Empty First Repeat.xml │ │ ├── RepeatGroupAndGroup.xml │ │ ├── RepeatTitles_1648.xml │ │ ├── TestRepeat.xml │ │ ├── all-widgets.xml │ │ ├── audio-question.xml │ │ ├── basic.xml │ │ ├── different-search-appearances.xml │ │ ├── dynamic_and_static_choices.xml │ │ ├── dynamic_required_question.xml │ │ ├── emptyGroupFieldList.xml │ │ ├── encrypted-no-instanceID.xml │ │ ├── encrypted.xml │ │ ├── entity-update-pulldata.xml │ │ ├── external-audio-question.xml │ │ ├── external-csv-search-broken.xml │ │ ├── external-csv-search.xml │ │ ├── external_csv_form.xml │ │ ├── external_data_questions.xml │ │ ├── external_select.xml │ │ ├── external_select_10.xml │ │ ├── external_select_csv.xml │ │ ├── field-list-repeat.xml │ │ ├── fieldlist-updates.xml │ │ ├── fixed-count-repeat.xml │ │ ├── form1.xml │ │ ├── form2.xml │ │ ├── form3.xml │ │ ├── form4.xml │ │ ├── form5.xml │ │ ├── form6.xml │ │ ├── form7.xml │ │ ├── form8.xml │ │ ├── form9.xml │ │ ├── formHierarchy1.xml │ │ ├── formHierarchy2.xml │ │ ├── formHierarchy3.xml │ │ ├── form_design_error.xml │ │ ├── form_styling.xml │ │ ├── form_with_images.xml │ │ ├── g6Error.xml │ │ ├── hints_textq.xml │ │ ├── identify-user-audit-false.xml │ │ ├── identify-user-audit.xml │ │ ├── intent-group.xml │ │ ├── internal-audio-question.xml │ │ ├── invalid-form.xml │ │ ├── likert_test.xml │ │ ├── location-audit.xml │ │ ├── manyQ.xml │ │ ├── metadata.xml │ │ ├── nested-intent-group.xml │ │ ├── numberInCSV.xml │ │ ├── one-question-audit.xml │ │ ├── one-question-autoplay.xml │ │ ├── one-question-autosend-disabled.xml │ │ ├── one-question-autosend.xml │ │ ├── one-question-background-audio-audit.xml │ │ ├── one-question-background-audio-multiple.xml │ │ ├── one-question-background-audio.xml │ │ ├── one-question-editable.xml │ │ ├── one-question-encrypted-unicode.xml │ │ ├── one-question-entity-create-and-update.xml │ │ ├── one-question-entity-follow-up.xml │ │ ├── one-question-entity-registration-broken.xml │ │ ├── one-question-entity-registration-editable.xml │ │ ├── one-question-entity-registration-id.xml │ │ ├── one-question-entity-registration-v2020.1.xml │ │ ├── one-question-entity-registration-v2023.1.xml │ │ ├── one-question-entity-registration.xml │ │ ├── one-question-entity-update-and-create.xml │ │ ├── one-question-entity-update-editable.xml │ │ ├── one-question-entity-update.xml │ │ ├── one-question-last-saved-updated.xml │ │ ├── one-question-last-saved.xml │ │ ├── one-question-partial.xml │ │ ├── one-question-repeat.xml │ │ ├── one-question-translation.xml │ │ ├── one-question-updated.xml │ │ ├── one-question-uuid-instance-name.xml │ │ ├── one-question-with-constraint.xml │ │ ├── one-question.xml │ │ ├── pull_data.xml │ │ ├── random.xml │ │ ├── randomTest_broken.xml │ │ ├── ranking_widget.xml │ │ ├── repeat_group_form.xml │ │ ├── repeat_group_new.xml │ │ ├── repeat_group_wrapped_with_a_regular_group.xml │ │ ├── repeat_groups.xml │ │ ├── repeat_in_field_list.xml │ │ ├── repeat_without_label.xml │ │ ├── requiredQuestionInFieldList.xml │ │ ├── required_question_with_audio.xml │ │ ├── required_question_with_custom_error_message.xml │ │ ├── search_and_select.xml │ │ ├── selectOneExternal.xml │ │ ├── select_one_external.xml │ │ ├── setgeopoint-action.xml │ │ ├── simple-search-external-csv.xml │ │ ├── single-geopoint.xml │ │ ├── start-geopoint.xml │ │ ├── string_widgets_in_field_list.xml │ │ ├── track-changes-reason-on-edit.xml │ │ ├── two-question-audit-track-changes.xml │ │ ├── two-question-audit.xml │ │ ├── two-question-external.xml │ │ ├── two-question-required.xml │ │ ├── two-question-save-incomplete-required.xml │ │ ├── two-question-save-incomplete.xml │ │ ├── two-question-updated.xml │ │ ├── two-question.xml │ │ ├── two-questions-in-group.xml │ │ └── validate.xml │ └── media/ │ ├── external-csv-search-produce.csv │ ├── external_csv_cities.csv │ ├── external_csv_countries.csv │ ├── external_csv_neighbourhoods.csv │ ├── external_data.csv │ ├── external_data.xml │ ├── external_data_10.xml │ ├── external_data_broken.csv │ ├── fruits.csv │ ├── itemSets.csv │ ├── people.csv │ ├── selectOneExternal-media/ │ │ └── itemsets.csv │ ├── simple-search-external-csv-fruits.csv │ ├── test.m4a │ └── updated-people.csv ├── test-shared/ │ ├── .gitignore │ ├── build.gradle.kts │ └── src/ │ └── main/ │ ├── AndroidManifest.xml │ └── java/ │ └── org/ │ └── odk/ │ └── collect/ │ └── testshared/ │ ├── ActivityControllerRule.kt │ ├── ActivityExt.kt │ ├── AssertIntentsHelper.kt │ ├── AssertionFramework.kt │ ├── ComposeAssertions.kt │ ├── ComposeInteractions.kt │ ├── DummyActivity.kt │ ├── ErrorIntentLauncher.kt │ ├── EspressoAssertions.kt │ ├── EspressoInteractions.kt │ ├── FakeAudioPlayer.kt │ ├── FakeBarcodeScannerView.kt │ ├── FakeBroadcastReceiverRegister.kt │ ├── FakeScheduler.kt │ ├── FragmentResultRecorder.kt │ ├── LocationTestUtils.kt │ ├── MockFragmentFactory.kt │ ├── MockWebPageService.kt │ ├── RecyclerViewMatcher.kt │ ├── RobolectricHelpers.kt │ ├── SliderExt.kt │ ├── TimeZoneSetter.kt │ ├── ViewActions.kt │ ├── ViewMatchers.kt │ └── WaitFor.kt ├── timedgrid/ │ ├── .gitignore │ ├── build.gradle.kts │ └── src/ │ └── main/ │ ├── AndroidManifest.xml │ ├── java/ │ │ └── org/ │ │ └── odk/ │ │ └── collect/ │ │ └── timedgrid/ │ │ ├── AssessmentType.kt │ │ ├── CommonTimedGridRenderer.kt │ │ ├── FinishType.kt │ │ ├── NavigationAwareWidget.kt │ │ ├── PausableCountDownTimer.kt │ │ ├── TimedGridRenderer.kt │ │ ├── TimedGridState.kt │ │ ├── TimedGridSummary.kt │ │ ├── TimedGridSummaryAnswerCreator.kt │ │ ├── TimedGridViewModel.kt │ │ ├── TimedGridWidgetConfiguration.kt │ │ ├── TimedGridWidgetDelegate.kt │ │ └── TimedGridWidgetLayout.kt │ └── res/ │ ├── color/ │ │ └── timed_grid_button_tint_selector.xml │ ├── drawable/ │ │ └── row_number_background.xml │ ├── layout/ │ │ ├── timed_grid.xml │ │ ├── timed_grid_item_button.xml │ │ └── timed_grid_item_row.xml │ └── values/ │ ├── colors.xml │ ├── dimens.xml │ ├── strings.xml │ └── styles.xml ├── upgrade/ │ ├── .gitignore │ ├── README.md │ ├── build.gradle.kts │ └── src/ │ ├── main/ │ │ ├── AndroidManifest.xml │ │ └── java/ │ │ └── org/ │ │ └── odk/ │ │ └── collect/ │ │ └── upgrade/ │ │ ├── AppUpgrader.kt │ │ ├── LaunchState.kt │ │ └── Upgrade.kt │ └── test/ │ └── java/ │ └── org/ │ └── odk/ │ └── collect/ │ └── upgrade/ │ ├── AppUpgraderTest.kt │ └── VersionCodeLaunchStateTest.kt └── web-page/ ├── .gitignore ├── build.gradle.kts └── src/ ├── main/ │ ├── AndroidManifest.xml │ └── java/ │ └── org/ │ └── odk/ │ └── collect/ │ └── webpage/ │ ├── CustomTabsWebPageService.kt │ └── WebPageService.kt └── test/ └── java/ └── org/ └── odk/ └── collect/ └── webpage/ └── CustomTabsWebPageServiceTest.java ================================================ FILE CONTENTS ================================================ ================================================ FILE: .circleci/config.yml ================================================ # This config and the the Gradle flags/opts are based on: https://circleci.com/docs/2.0/language-android/ # and https://support.circleci.com/hc/en-us/articles/360021812453 version: 2 references: android_config_small: &android_config_small working_directory: ~/work docker: - image: cimg/android:2026.02 resource_class: small android_config: &android_config working_directory: ~/work docker: - image: cimg/android:2026.02 resource_class: large android_config_large: &android_config_large working_directory: ~/work docker: - image: cimg/android:2026.02 resource_class: xlarge gcloud_config: &gcloud_config working_directory: ~/work docker: - image: cimg/gcp:2025.01 resource_class: small jobs: compile: <<: *android_config steps: - checkout - restore_cache: keys: - compile-{{ checksum "gradle/libs.versions.toml" }}-{{ checksum "gradle/wrapper/gradle-wrapper.properties" }} - compile- - run: name: Copy gradle config command: mkdir -p ~/.gradle && cp .circleci/gradle.properties ~/.gradle/gradle.properties - run: name: Download Robolectric deps command: ./download-robolectric-deps.sh - run: name: Compile code command: ./gradlew assembleDebug - save_cache: paths: - ~/.gradle/caches - ~/.gradle/wrapper key: compile-{{ checksum "gradle/libs.versions.toml" }}-{{ checksum "gradle/wrapper/gradle-wrapper.properties" }} - save_cache: paths: - ~/work key: workflow-{{ .Environment.CIRCLE_WORKFLOW_ID }} - persist_to_workspace: root: ~/work paths: - collect_app/build/outputs/apk create_dependency_backup: <<: *android_config_small steps: - checkout - restore_cache: keys: - workflow-{{ .Environment.CIRCLE_WORKFLOW_ID }} - restore_cache: keys: - compile-{{ checksum "gradle/libs.versions.toml" }}-{{ checksum "gradle/wrapper/gradle-wrapper.properties" }} - compile- - run: name: Create Maven repo from dependencies command: ./gradlew cacheToMavenLocal - run: name: Compress Maven repo command: tar -cvzf maven.tar .local-m2 - store_artifacts: path: maven.tar check_quality: <<: *android_config steps: - checkout - restore_cache: keys: - workflow-{{ .Environment.CIRCLE_WORKFLOW_ID }} - restore_cache: keys: - compile-{{ checksum "gradle/libs.versions.toml" }}-{{ checksum "gradle/wrapper/gradle-wrapper.properties" }} - compile- - run: name: Copy gradle config command: mkdir -p ~/.gradle && cp .circleci/gradle.properties ~/.gradle/gradle.properties - run: name: Run code quality checks command: ./gradlew pmd ktlintCheck checkstyle - run: name: Run Android lint command: ./gradlew lintDebug test_modules: <<: *android_config parallelism: 4 steps: - checkout - restore_cache: keys: - workflow-{{ .Environment.CIRCLE_WORKFLOW_ID }} - restore_cache: keys: - test_modules-{{ checksum "gradle/libs.versions.toml" }}-{{ checksum "gradle/wrapper/gradle-wrapper.properties" }} - test_modules - compile-{{ checksum "gradle/libs.versions.toml" }}-{{ checksum "gradle/wrapper/gradle-wrapper.properties" }} - compile- - run: name: Copy gradle config command: mkdir -p ~/.gradle && cp .circleci/gradle.properties ~/.gradle/gradle.properties - run: name: Generate list of modules for this fork command: | cat .circleci/test_modules.txt | circleci tests split > .circleci/fork_test_modules.txt && \ echo "Modules for this fork:" && \ cat .circleci/fork_test_modules.txt - run: name: Run module unit tests command: | ./gradlew $(cat .circleci/fork_test_modules.txt | awk '{for (i=1; i<=NF; i++) printf "%s:testDebug ",$i}') - store_test_results: path: collect_app/build/test-results - save_cache: paths: - ~/.gradle/caches - ~/.gradle/wrapper key: test_modules-{{ checksum "gradle/libs.versions.toml" }}-{{ checksum "gradle/wrapper/gradle-wrapper.properties" }} test_app: <<: *android_config parallelism: 4 steps: - checkout - restore_cache: keys: - workflow-{{ .Environment.CIRCLE_WORKFLOW_ID }} - restore_cache: keys: - test_app-{{ checksum "gradle/libs.versions.toml" }}-{{ checksum "gradle/wrapper/gradle-wrapper.properties" }} - test_app- - compile-{{ checksum "gradle/libs.versions.toml" }}-{{ checksum "gradle/wrapper/gradle-wrapper.properties" }} - compile- - run: name: Copy gradle config command: mkdir -p ~/.gradle && cp .circleci/gradle.properties ~/.gradle/gradle.properties - run: name: Generate list of test classes command: .circleci/generate-app-test-list.sh - run: name: Generate list of tests for this fork command: | cat .circleci/collect_app_test_classes.txt | circleci tests split > .circleci/fork_test_classes.txt && \ echo "Tests for this fork:" && \ cat .circleci/fork_test_classes.txt && \ echo "" && \ echo "Will run command:" && \ echo "./gradlew collect_app:testDebug $(cat .circleci/fork_test_classes.txt | awk '{for (i=1; i<=NF; i++) printf "--tests %s ",$i}')" - run: name: Run app unit tests command: | ./gradlew collect_app:testDebug $(cat .circleci/fork_test_classes.txt | awk '{for (i=1; i<=NF; i++) printf "--tests %s ",$i}') - store_artifacts: path: collect_app/build/reports destination: reports - store_test_results: path: collect_app/build/test-results - save_cache: paths: - ~/.gradle/caches - ~/.gradle/wrapper key: test_app-{{ checksum "gradle/libs.versions.toml" }}-{{ checksum "gradle/wrapper/gradle-wrapper.properties" }} build_instrumented: <<: *android_config_large steps: - checkout - restore_cache: keys: - workflow-{{ .Environment.CIRCLE_WORKFLOW_ID }} - restore_cache: keys: - build_instrumented-{{ checksum "gradle/libs.versions.toml" }}-{{ checksum "gradle/wrapper/gradle-wrapper.properties" }} - build_instrumented- - compile-{{ checksum "gradle/libs.versions.toml" }}-{{ checksum "gradle/wrapper/gradle-wrapper.properties" }} - compile- - run: name: Copy gradle config command: mkdir -p ~/.gradle && cp .circleci/gradle-large.properties ~/.gradle/gradle.properties - run: name: Assemble connected test build command: ./gradlew assembleDebugAndroidTest - save_cache: paths: - ~/.gradle/caches - ~/.gradle/wrapper key: build_instrumented-{{ checksum "gradle/libs.versions.toml" }}-{{ checksum "gradle/wrapper/gradle-wrapper.properties" }} - persist_to_workspace: root: ~/work paths: - collect_app/build/outputs/apk build_release: <<: *android_config steps: - checkout - restore_cache: keys: - workflow-{{ .Environment.CIRCLE_WORKFLOW_ID }} - restore_cache: keys: - compile-{{ checksum "gradle/libs.versions.toml" }}-{{ checksum "gradle/wrapper/gradle-wrapper.properties" }} - compile- - run: name: Copy gradle config command: mkdir -p ~/.gradle && cp .circleci/gradle.properties ~/.gradle/gradle.properties - run: name: Set up secrets.properties for Maps frameworks command: | if [[ -n "$GOOGLE_MAPS_API_KEY" ]]; then \ echo "GOOGLE_MAPS_API_KEY=$GOOGLE_MAPS_API_KEY" >> secrets.properties echo "MAPBOX_ACCESS_TOKEN=$MAPBOX_ACCESS_TOKEN" >> secrets.properties echo "MAPBOX_DOWNLOADS_TOKEN=$MAPBOX_DOWNLOADS_TOKEN" >> secrets.properties fi - run: name: Assemble self signed release build command: ./gradlew assembleSelfSignedRelease - run: name: Check APK size hasn't increased command: | if [[ -n "$GOOGLE_MAPS_API_KEY" ]]; then \ ./check-size.sh 20447232 else ./check-size.sh 11010048 fi - run: name: Copy APK to predictable path for artifact storage command: cp collect_app/build/outputs/apk/selfSignedRelease/*.apk selfSignedRelease.apk - store_artifacts: path: selfSignedRelease.apk test_smoke_instrumented: <<: *gcloud_config steps: - attach_workspace: at: /tmp/workspace - run: name: Authorize gcloud command: | if [[ "$CIRCLE_PROJECT_USERNAME" == "getodk" ]]; then \ gcloud config set project api-project-322300403941 echo $GCLOUD_SERVICE_KEY | base64 --decode > client-secret.json gcloud auth activate-service-account --key-file client-secret.json fi - run: name: Run integration tests command: | if [[ "$CIRCLE_PROJECT_USERNAME" == "getodk" ]]; then \ echo "y" | gcloud beta firebase test android run \ --type instrumentation \ --app /tmp/workspace/collect_app/build/outputs/apk/debug/ODK-Collect-debug.apk \ --test /tmp/workspace/collect_app/build/outputs/apk/androidTest/debug/ODK-Collect-debug-androidTest.apk \ --device model=MediumPhone.arm,version=34,locale=en,orientation=portrait \ --results-bucket opendatakit-collect-test-results \ --directories-to-pull /sdcard --timeout 20m \ --test-targets "package org.odk.collect.android.feature.smoke" fi no_output_timeout: 25m test_instrumented: <<: *gcloud_config steps: - attach_workspace: at: /tmp/workspace - run: name: Authorize gcloud command: | if [[ -n "$GCLOUD_SERVICE_KEY" ]]; then \ gcloud config set project api-project-322300403941 echo $GCLOUD_SERVICE_KEY | base64 --decode > client-secret.json gcloud auth activate-service-account --key-file client-secret.json fi - run: name: Run integration tests command: | echo "y" | gcloud beta firebase test android run \ --type instrumentation \ --num-uniform-shards=25 \ --app /tmp/workspace/collect_app/build/outputs/apk/debug/ODK-Collect-debug.apk \ --test /tmp/workspace/collect_app/build/outputs/apk/androidTest/debug/ODK-Collect-debug-androidTest.apk \ --device model=MediumPhone.arm,version=34,locale=en,orientation=portrait \ --results-bucket opendatakit-collect-test-results \ --directories-to-pull /sdcard --timeout 20m \ --test-targets "notPackage org.odk.collect.android.regression" \ --test-targets "notPackage org.odk.collect.android.benchmark" no_output_timeout: 25m workflows: version: 2 pr: jobs: - hold_pr: type: approval filters: branches: ignore: - master - ^v((20)[0-9]{2})\.\d+\.x$ - compile: requires: - hold_pr - check_quality: requires: - compile - test_modules: requires: - compile - test_app: requires: - compile - build_release: requires: - compile - build_instrumented: requires: - compile master: jobs: - compile: filters: branches: only: - master - ^v((20)[0-9]{2})\.\d+\.x$ - check_quality: requires: - compile - test_modules: requires: - compile - test_app: requires: - compile - build_release: requires: - compile - build_instrumented: requires: - compile - test_smoke_instrumented: requires: - build_instrumented nightly: triggers: - schedule: cron: "0 0 * * 1-5" filters: branches: only: - master jobs: - compile - build_instrumented: requires: - compile - test_instrumented: requires: - build_instrumented release: jobs: - compile: filters: tags: only: /^v((20)[0-9]{2})\.\d+\.\d+$/ # matches semvers like v1.2.3 branches: ignore: /.*/ - create_dependency_backup: requires: - compile filters: tags: only: /^v((20)[0-9]{2})\.\d+\.\d+$/ # matches semvers like v1.2.3 branches: ignore: /.*/ ================================================ FILE: .circleci/generate-app-test-list.sh ================================================ ls -R collect_app/src/test/java/ | grep Test.java > .circleci/collect_app_test_files.txt ls -R collect_app/src/test/java/ | grep Test.kt >> .circleci/collect_app_test_files.txt cat .circleci/collect_app_test_files.txt | sed "s/\.java//" | sed "s/\.kt//" > .circleci/collect_app_test_classes.txt ================================================ FILE: .circleci/gradle-large.properties ================================================ # Gradle config for "X-Large" Circle CI resource (https://circleci.com/pricing/price-list/) org.gradle.jvmargs=-Xmx8g -XX:MaxMetaspaceSize=512m -Dkotlin.daemon.jvm.options=-Xmx2g org.gradle.daemon=false org.gradle.parallel=true org.gradle.workers.max=8 test.heap.max=1g ================================================ FILE: .circleci/gradle.properties ================================================ # Gradle config for "Large" Circle CI resource (https://circleci.com/pricing/price-list/) org.gradle.jvmargs=-Xmx2560m -XX:MaxMetaspaceSize=1g org.gradle.daemon=false org.gradle.parallel=true org.gradle.workers.max=4 test.heap.max=1g ================================================ FILE: .circleci/test_modules.txt ================================================ shared forms-test androidshared async strings audio-clips audio-recorder projects location geo upgrade permissions settings maps errors crash-handler entities qr-code shadows metadata selfie-camera draw printer lists web-page open-rosa mobile-device-management material ================================================ FILE: .editorconfig ================================================ root = true [*.{kt,kts}] ktlint_standard_no-blank-lines-in-chained-method-calls = disabled ktlint_standard_trailing-comma-on-call-site = disabled ktlint_standard_trailing-comma-on-declaration-site = disabled ktlint_standard_function-signature = disabled ktlint_standard_no-empty-first-line-in-class-body = disabled ktlint_standard_argument-list-wrapping = disabled ktlint_standard_parameter-list-wrapping = disabled ktlint_standard_multiline-expression-wrapping = disabled ktlint_standard_max-line-length = disabled ktlint_standard_string-template-indent = disabled ktlint_standard_annotation = disabled ktlint_standard_value-parameter-comment = disabled ktlint_standard_property-naming = disabled ktlint_standard_value-argument-comment = disabled ktlint_standard_blank-line-before-declaration = disabled ktlint_standard_no-consecutive-comments = disabled ktlint_standard_enum-wrapping = disabled ktlint_standard_statement-wrapping = disabled ktlint_standard_try-catch-finally-spacing = disabled ktlint_standard_wrapping = disabled ktlint_standard_chain-method-continuation = disabled ktlint_standard_function-expression-body = disabled ktlint_standard_class-signature = disabled ktlint_standard_binary-expression-wrapping = disabled ktlint_standard_condition-wrapping = disabled ktlint_standard_function-literal = disabled ktlint_standard_backing-property-naming = disabled ktlint_function_naming_ignore_when_annotated_with = Composable ktlint_standard_no-unused-imports=enabled ================================================ FILE: .gitattributes ================================================ * text=lf ================================================ FILE: .github/CODE_OF_CONDUCT.md ================================================ Please refer to the project-wide [ODK Code of Conduct](https://github.com/getodk/governance/blob/master/CODE-OF-CONDUCT.md). ================================================ FILE: .github/ISSUE_TEMPLATE/config.yml ================================================ blank_issues_enabled: true contact_links: - name: "Report an issue" about: "For when Collect is behaving in an unexpected way" url: "https://forum.getodk.org/c/support/6" - name: "Request a feature" about: "For when Collect is missing functionality" url: "https://forum.getodk.org/c/features/9" - name: "Everything else" about: "For everything else" url: "https://forum.getodk.org/c/support/6" ================================================ FILE: .github/ISSUE_TEMPLATE.md ================================================ #### ODK Collect version #### Android version #### Device used #### Problem description #### Steps to reproduce the problem #### Expected behavior #### Other information Things you tried, stack traces, related issues, suggestions on how to fix it... ================================================ FILE: .github/PULL_REQUEST_TEMPLATE.md ================================================ Closes # #### Why is this the best possible solution? Were any other approaches considered? #### How does this change affect users? Describe intentional changes to behavior and behavior that could have accidentally been affected by code changes. In other words, what are the regression risks? #### Do we need any specific form for testing your changes? If so, please attach one. #### Does this change require updates to documentation? If so, please file an issue [here]( https://github.com/getodk/docs/issues/new) and include the link below. #### Before submitting this PR, please make sure you have: - [ ] added or modified tests for any new or changed behavior - [ ] run `./gradlew connectedAndroidTest` (or `./gradlew testLab`) and confirmed all checks still pass - [ ] added a comment above any new strings describing it for translators - [ ] added any new strings with date formatting to `DateFormatsTest` - [ ] verified that any code or assets from external sources are properly credited in comments and/or in the [about file](https://github.com/getodk/collect/blob/master/collect_app/src/main/assets/open_source_licenses.html). - [ ] verified that any new UI elements use theme colors. [UI Components Style guidelines](https://github.com/getodk/collect/blob/master/docs/CODE-GUIDELINES.md#ui-components-style-guidelines) ================================================ FILE: .github/TESTING_RESULT_TEMPLATES.md ================================================ # Testing result templates ## Tested with success! #### Verified on: [List of devices/os versions] #### Verified cases: [optional] *** ## Bug[s] has[have] been found! / Regression[s] has[have] been found! #### Verified on: [List of devices/os versions] #### Problem visible on: [only if it's related to some specific devices otherwise it’s the same as above] #### Steps to reproduce: #### Current behavior: #### Expected behavior: #### Other information: [optional] ================================================ FILE: .gitignore ================================================ build .gradle .idea .kotlin local.properties *.iml .DS_Store *.sublime-project # emacs backup files *.*~ # gradle env props secrets.properties # built application files *.apk *.ap_ # files for the Dalvik VM *.dex # Java class files *.class # Generated files bin/ gen/ out/ # Android Studio files .externalNativeBuild/ .navigation/ captures/ # Visual Studio Code files .project .settings/ collect_app/.classpath collect_app/.project collect_app/.settings/ # Config for the official ODK Collect build collect_app/src/odkCollectRelease/ # Files generated during CI runs .circleci/collect_app_test_files.txt .circleci/collect_app_test_classes.txt .circleci/fork_test_classes.txt # Robolectric dependencies robolectric-deps/ **/robolectric-deps.properties .local-m2 apks ================================================ FILE: .hgtags ================================================ 5836a17a0e6f28b4e5b7a7abc9152446f4806ee2 v1.0.0 30d9314729db181177bb7209d1c16cd65a5095eb v1.1.0 827cf67bd902b80c37dce8d4ee8d2d29437408a5 v1.1.1 82798e410f5ab043721b3998e23861fd365a9801 v1.1.2 78fc6b2df0575ec79b94c0196babacbafcb7f958 v1.1.3 c6308d8be8cd99d060a06f3b10c87f5722776098 v1.1.4 f85cab690aa86dc86c2d53f12def22f16d9c0c5b v1.1.7-beta1 165ee1feda811cccefda77ae83896b7cb3209b6c v1.1.7-rc1 0c06023f4bae172a79a5aad27ca8d66798f2109e v1.1.7-rc2 caeb88584771e1e569c87eed7cca90738c867189 v1.1.7 96568099c2ec1408508178568fc5ca2503ec670b v1.2 RC1 96568099c2ec1408508178568fc5ca2503ec670b v1.2 RC1 15185704d55c220b02abcf5de6170a106f387f5b v1.2 RC1 88476e4dc9845a0a276bb57f93911d8aacc68912 v1.2.0 88476e4dc9845a0a276bb57f93911d8aacc68912 v1.2.0 ccb4a2556950af32ac153c26ca1f71faebbf8a9b v1.2.0 ccb4a2556950af32ac153c26ca1f71faebbf8a9b v1.2.0 2a9b44755f0a5af2558b7e22ac9948377b6e9597 v1.2.0 2a9b44755f0a5af2558b7e22ac9948377b6e9597 v1.2.0 a5faa6055d4bdbc5db786673c28d9872142120c7 v1.2.0 a5faa6055d4bdbc5db786673c28d9872142120c7 v1.2.0 0000000000000000000000000000000000000000 v1.2.0 b0ac9ab2b5bccea1b160e774b28a1f0c8af9a9e8 v1.2.0 rev 1012 3e7860308f3d7520ff91b8fb27bc5123450cfaa4 v1.2.0 rev 1013 3e7860308f3d7520ff91b8fb27bc5123450cfaa4 v1.2.0 rev 1013 b7d1fccb1a29ae62185bef1a123e80dbcdbc171c v1.2.0 rev 1013 29d29a54a208785e93d8ed2024e2222115ede489 v1.2.0 rev 1014 29d29a54a208785e93d8ed2024e2222115ede489 v1.2.0 rev 1014 0000000000000000000000000000000000000000 v1.2.0 rev 1014 abbf6ad98f25f0f33216ed11125a3a9cee8f90bc v1.2.1 rev 1014 6b017ddf90f21ab24a9b0f219657d48b1bdfc204 v1.2.1 rev 1015 fbc9d90492a631209d8587d299d08b0d005c681a v1.2.1 rev 1016 fd9ea977734c1bdbb37f40217becb4ec50d356d2 v1.2.1 rev 1017 b25021b3a495e039ef4a2a932a95df705648dc3c v1.2.0 rev 1010 prerelease 797ac8701d2c93b13c5cc585d39ff0a1a74d70d2 v1.2.0 rev 1011 prerelease 6e3dcf4780ac5e8a62b8d92f4111638cbc9a53e9 v1.2.0 rev 1009 prerelease dd3c2816eb879b2b922d2e4c52bc95c8996a8f25 v1.2.0 rev 1008 RC2 06063863a95919cd18dc36841febbf21ca0fa60e v1.2.0 rev 1008 RC1 7f9a9e162ff5de00b4384de5a6bd7b0132ad1e9c v1.2.0 rev 1007 beta a5a35a6eaf724a9c080a8ab85a2190f8fa6470fa v1.2.0 rev 1006 beta eca30a5719a2d603c364d470bedfbd7c90c89e35 v1.2.0 rev 1005 beta 820c7e389d341953d0158290f880c9226d144870 v1.2.0 rev 1004 alpha 5630fb8134d1e8169517b5586b05cc4183370e56 v1.2.0 rev 1003 alpha f628ef1bc45293e3d5fea77ac881a7b7652af554 v1.2.0 rev 1001 alpha 8c5544b1b0f4d469f35832c730d02a3bfbdb21ea v1.2.0 rev 1000 alpha 7f9a9e162ff5de00b4384de5a6bd7b0132ad1e9c v1.2.0 rev 1007 RC1 7f9a9e162ff5de00b4384de5a6bd7b0132ad1e9c v1.2.0 rev 1007 beta 0000000000000000000000000000000000000000 v1.2.0 rev 1007 beta a5a35a6eaf724a9c080a8ab85a2190f8fa6470fa v1.2.0 rev 1006 RC1 a5a35a6eaf724a9c080a8ab85a2190f8fa6470fa v1.2.0 rev 1006 beta 0000000000000000000000000000000000000000 v1.2.0 rev 1006 beta eca30a5719a2d603c364d470bedfbd7c90c89e35 v1.2.0 rev 1005 RC1 eca30a5719a2d603c364d470bedfbd7c90c89e35 v1.2.0 rev 1005 beta 0000000000000000000000000000000000000000 v1.2.0 rev 1005 beta 820c7e389d341953d0158290f880c9226d144870 v1.2.0 rev 1004 RC1 820c7e389d341953d0158290f880c9226d144870 v1.2.0 rev 1004 alpha 0000000000000000000000000000000000000000 v1.2.0 rev 1004 alpha 5630fb8134d1e8169517b5586b05cc4183370e56 v1.2.0 rev 1003 RC1 5630fb8134d1e8169517b5586b05cc4183370e56 v1.2.0 rev 1003 alpha 0000000000000000000000000000000000000000 v1.2.0 rev 1003 alpha f628ef1bc45293e3d5fea77ac881a7b7652af554 v1.2.0 rev 1001 RC1 f628ef1bc45293e3d5fea77ac881a7b7652af554 v1.2.0 rev 1001 alpha 0000000000000000000000000000000000000000 v1.2.0 rev 1001 alpha 8c5544b1b0f4d469f35832c730d02a3bfbdb21ea v1.2.0 rev 1000 RC1 8c5544b1b0f4d469f35832c730d02a3bfbdb21ea v1.2.0 rev 1000 alpha 0000000000000000000000000000000000000000 v1.2.0 rev 1000 alpha 7be1f7e0a0dc36b67f8a584c7f420c240eefd5c6 v1.2.1 rev 1018 (plus swahili) c11fe1aebaca2e213f71e61ad2337791d02723e0 v1.2.1 rev 1019 64e490522c2ef5ebc9af8a1a2c0dbf78bb213c06 v1.2.1 rev 1020 ea230f4ee12c303640c30090c30a9ac386324d5a v1.2.2 rev 1021 ea230f4ee12c303640c30090c30a9ac386324d5a v1.2.2 rev 1021 3b8a3847d1bea472176a27548d602b119d0e695d v1.2.2 rev 1021 3b8a3847d1bea472176a27548d602b119d0e695d v1.2.2 rev 1021 f714913194222fb579c5ef6e59092c788eeea300 v1.2.2 rev 1021 1759bdc402730ed7a16d26e7aba44aa43f822355 v1.2.2 rev 1023 cef8219f74248bd4d295e5c105b9f0ced3ac7f8d v1.3 rev 1025 a3d74ab96cdee3e972f7e507776a3834f497fbe0 v1.3 rev 1027 5c2ce96c94c6daf5277befaabf54ab74f088437b v1.3 rev 1029 1c8b890c69fbccc47de596c8592d21c7439646ba v1.3 rev 1030 e1e6c70eef9913497c65de897e229a6e0d871ad8 v1.4 rev 1033 68616e24e60d963334c762daba5784c7af763d76 v1.4 rev 1034 aa63d3a1f3295cf5a4a6bc74d8f1a4d4939fdd8b v1.4 rev 1035 db03f3497da8c76883e475a14a2b034e61e29c37 v1.4 rev 1036 db03f3497da8c76883e475a14a2b034e61e29c37 v1.4 rev 1036 8f77433258b376de5a6a7267df03ecc7cdecc1a4 v1.4 rev 1036 86afc4615411eaeee61271790b2bae5de7b94176 v1.4 rev 1037 3254b74a70d9c4f4088ba710fd6d7f542ffe166f v1.4 rev 1038 bc69898dc4ccb061079ee82f9b52da34de8400b7 v1.4.3 rev 1040 cd78ad21a3c8b1f106cdbb35a55ddfd1c2cf021f v1.4.3 rev 1041 7bf86c85d2dac5d6353bf2007a5c817654bbfdfc v1.4.3 rev 1042 753b33ca1fa800b517fcb6156d55370b986cedeb v1.4.4 rev 1044 753b33ca1fa800b517fcb6156d55370b986cedeb v1.4.4 rev 1044 c0d79cc86e2da93dfb03165a520de2c42b5cf061 v1.4.4 rev 1044 72d2e66877f36e0d3b607a6dbc4207c96dcc4c0c v1.4.4 rev 1045 712f8174b16bd95ece6bac433f871a19d59ea46c v1.4.4 rev 1046 5a92ad65ed775aa744b0abfe1aaa3ef756bd82f3 v1.4.5 rev 1048 5a92ad65ed775aa744b0abfe1aaa3ef756bd82f3 v1.4.5 rev 1048 1a6028ae2ce23b2eb50bd7ee3b2674effd2620af v1.4.5 rev 1048 aacc424db2a79c201abd6dcb481af830d017477f v1.4.6 rev 1049 for testing aacc424db2a79c201abd6dcb481af830d017477f v1.4.6 rev 1049 for testing 1fced08f31e9e4f10fcdf067debdbad3557899de v1.4.6 rev 1049 for testing ================================================ FILE: LICENSE.md ================================================ Apache License ============== _Version 2.0, January 2004_ _<>_ ### Terms and Conditions for use, reproduction, and distribution #### 1. Definitions “License” shall mean the terms and conditions for use, reproduction, and distribution as defined by Sections 1 through 9 of this document. “Licensor” shall mean the copyright owner or entity authorized by the copyright owner that is granting the License. “Legal Entity” shall mean the union of the acting entity and all other entities that control, are controlled by, or are under common control with that entity. For the purposes of this definition, “control” means **(i)** the power, direct or indirect, to cause the direction or management of such entity, whether by contract or otherwise, or **(ii)** ownership of fifty percent (50%) or more of the outstanding shares, or **(iii)** beneficial ownership of such entity. “You” (or “Your”) shall mean an individual or Legal Entity exercising permissions granted by this License. “Source” form shall mean the preferred form for making modifications, including but not limited to software source code, documentation source, and configuration files. “Object” form shall mean any form resulting from mechanical transformation or translation of a Source form, including but not limited to compiled object code, generated documentation, and conversions to other media types. “Work” shall mean the work of authorship, whether in Source or Object form, made available under the License, as indicated by a copyright notice that is included in or attached to the work (an example is provided in the Appendix below). “Derivative Works” shall mean any work, whether in Source or Object form, that is based on (or derived from) the Work and for which the editorial revisions, annotations, elaborations, or other modifications represent, as a whole, an original work of authorship. For the purposes of this License, Derivative Works shall not include works that remain separable from, or merely link (or bind by name) to the interfaces of, the Work and Derivative Works thereof. “Contribution” shall mean any work of authorship, including the original version of the Work and any modifications or additions to that Work or Derivative Works thereof, that is intentionally submitted to Licensor for inclusion in the Work by the copyright owner or by an individual or Legal Entity authorized to submit on behalf of the copyright owner. For the purposes of this definition, “submitted” means any form of electronic, verbal, or written communication sent to the Licensor or its representatives, including but not limited to communication on electronic mailing lists, source code control systems, and issue tracking systems that are managed by, or on behalf of, the Licensor for the purpose of discussing and improving the Work, but excluding communication that is conspicuously marked or otherwise designated in writing by the copyright owner as “Not a Contribution.” “Contributor” shall mean Licensor and any individual or Legal Entity on behalf of whom a Contribution has been received by Licensor and subsequently incorporated within the Work. #### 2. Grant of Copyright License Subject to the terms and conditions of this License, each Contributor hereby grants to You a perpetual, worldwide, non-exclusive, no-charge, royalty-free, irrevocable copyright license to reproduce, prepare Derivative Works of, publicly display, publicly perform, sublicense, and distribute the Work and such Derivative Works in Source or Object form. #### 3. Grant of Patent License Subject to the terms and conditions of this License, each Contributor hereby grants to You a perpetual, worldwide, non-exclusive, no-charge, royalty-free, irrevocable (except as stated in this section) patent license to make, have made, use, offer to sell, sell, import, and otherwise transfer the Work, where such license applies only to those patent claims licensable by such Contributor that are necessarily infringed by their Contribution(s) alone or by combination of their Contribution(s) with the Work to which such Contribution(s) was submitted. If You institute patent litigation against any entity (including a cross-claim or counterclaim in a lawsuit) alleging that the Work or a Contribution incorporated within the Work constitutes direct or contributory patent infringement, then any patent licenses granted to You under this License for that Work shall terminate as of the date such litigation is filed. #### 4. Redistribution You may reproduce and distribute copies of the Work or Derivative Works thereof in any medium, with or without modifications, and in Source or Object form, provided that You meet the following conditions: * **(a)** You must give any other recipients of the Work or Derivative Works a copy of this License; and * **(b)** You must cause any modified files to carry prominent notices stating that You changed the files; and * **(c)** You must retain, in the Source form of any Derivative Works that You distribute, all copyright, patent, trademark, and attribution notices from the Source form of the Work, excluding those notices that do not pertain to any part of the Derivative Works; and * **(d)** If the Work includes a “NOTICE” text file as part of its distribution, then any Derivative Works that You distribute must include a readable copy of the attribution notices contained within such NOTICE file, excluding those notices that do not pertain to any part of the Derivative Works, in at least one of the following places: within a NOTICE text file distributed as part of the Derivative Works; within the Source form or documentation, if provided along with the Derivative Works; or, within a display generated by the Derivative Works, if and wherever such third-party notices normally appear. The contents of the NOTICE file are for informational purposes only and do not modify the License. You may add Your own attribution notices within Derivative Works that You distribute, alongside or as an addendum to the NOTICE text from the Work, provided that such additional attribution notices cannot be construed as modifying the License. You may add Your own copyright statement to Your modifications and may provide additional or different license terms and conditions for use, reproduction, or distribution of Your modifications, or for any such Derivative Works as a whole, provided Your use, reproduction, and distribution of the Work otherwise complies with the conditions stated in this License. #### 5. Submission of Contributions Unless You explicitly state otherwise, any Contribution intentionally submitted for inclusion in the Work by You to the Licensor shall be under the terms and conditions of this License, without any additional terms or conditions. Notwithstanding the above, nothing herein shall supersede or modify the terms of any separate license agreement you may have executed with Licensor regarding such Contributions. #### 6. Trademarks This License does not grant permission to use the trade names, trademarks, service marks, or product names of the Licensor, except as required for reasonable and customary use in describing the origin of the Work and reproducing the content of the NOTICE file. #### 7. Disclaimer of Warranty Unless required by applicable law or agreed to in writing, Licensor provides the Work (and each Contributor provides its Contributions) on an “AS IS” BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied, including, without limitation, any warranties or conditions of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A PARTICULAR PURPOSE. You are solely responsible for determining the appropriateness of using or redistributing the Work and assume any risks associated with Your exercise of permissions under this License. #### 8. Limitation of Liability In no event and under no legal theory, whether in tort (including negligence), contract, or otherwise, unless required by applicable law (such as deliberate and grossly negligent acts) or agreed to in writing, shall any Contributor be liable to You for damages, including any direct, indirect, special, incidental, or consequential damages of any character arising as a result of this License or out of the use or inability to use the Work (including but not limited to damages for loss of goodwill, work stoppage, computer failure or malfunction, or any and all other commercial damages or losses), even if such Contributor has been advised of the possibility of such damages. #### 9. Accepting Warranty or Additional Liability While redistributing the Work or Derivative Works thereof, You may choose to offer, and charge a fee for, acceptance of support, warranty, indemnity, or other liability obligations and/or rights consistent with this License. However, in accepting such obligations, You may act only on Your own behalf and on Your sole responsibility, not on behalf of any other Contributor, and only if You agree to indemnify, defend, and hold each Contributor harmless for any liability incurred by, or claims asserted against, such Contributor by reason of your accepting any such warranty or additional liability. _END OF TERMS AND CONDITIONS_ ### APPENDIX: How to apply the Apache License to your work To apply the Apache License to your work, attach the following boilerplate notice, with the fields enclosed by brackets `[]` replaced with your own identifying information. (Don't include the brackets!) The text should be enclosed in the appropriate comment syntax for the file format. We also recommend that a file or class name and description of purpose be included on the same “printed page” as the copyright notice for easier identification within third-party archives. Copyright [yyyy] [name of copyright owner] 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. ================================================ FILE: README.md ================================================ # ODK Collect ![Platform](https://img.shields.io/badge/platform-Android-blue.svg) [![License](https://img.shields.io/badge/license-Apache%202.0-blue.svg)](https://opensource.org/licenses/Apache-2.0) [![Build status](https://circleci.com/gh/getodk/collect.svg?style=shield&circle-token=:circle-token)](https://circleci.com/gh/getodk/collect) [![Slack](https://img.shields.io/badge/chat-on%20slack-brightgreen)](https://slack.getodk.org) ODK Collect is an Android app for filling out forms. It is designed to be used in resource-constrained environments with challenges such as unreliable connectivity or power infrastructure. ODK Collect is part the ODK project, a free and open-source set of tools which help organizations author, field, and manage mobile data collection solutions. Learn more about ODK and its history [here](https://getodk.org/) and read about example ODK deployments [here](https://forum.getodk.org/c/showcase). ODK Collect renders forms that are compliant with the [ODK XForms standard](https://getodk.github.io/xforms-spec/), a subset of the [XForms 1.1 standard](https://www.w3.org/TR/xforms/) with some extensions. The form parsing is done by the [JavaRosa library](https://github.com/getodk/javarosa) which Collect includes as a dependency. Please note that the `master` branch reflects ongoing development and is not production-ready. ## Table of Contents * [Learn more about ODK Collect](#learn-more-about-odk-collect) * [Release cycle](#release-cycle) * [Downloading builds](#downloading-builds) * [Suggesting new features](#suggesting-new-features) * Contributing * [Contributing code](#contributing-code) * [Contributing translations](#contributing-translations) * Developing * [Setting up your development environment](#setting-up-your-development-environment) * [Testing a form without a server](#testing-a-form-without-a-server) * [Using APIs for local development](#using-apis-for-local-development) * [Debugging JavaRosa](#debugging-javarosa) * [Troubleshooting](#troubleshooting) * [Test devices](#test-devices) * [Creating signed releases for Google Play Store](#creating-signed-releases-for-google-play-store) ## Learn more about ODK Collect * ODK website: [https://getodk.org](https://getodk.org) * ODK Collect usage documentation: [https://docs.getodk.org/collect-intro/](https://docs.getodk.org/collect-intro/) * ODK forum: [https://forum.getodk.org](https://forum.getodk.org) * ODK developer Slack chat: [https://slack.getodk.org](https://slack.getodk.org) ## Release cycle The work to be done is continuously revised and prioritized in [the backlog](https://github.com/orgs/getodk/projects/9/views/8) by the Collect team. The majority of this is influenced by the priorities in [the ODK roadmap](https://getodk.org/roadmap). Releases are planned to happen every 2-3 months (resulting in ~4 releases a year). This goal is to balance the pace of delivery with keeping things stable for users while also minimizing the risk in each release. Sometimes issues will be assigned to core team members before they are actually started (moved to "in progress") to make it clear who's going to be working on what. Once the majority of high risk or visible work is done for a release, a new beta will then be released to the Play Store by [@lognaturel](https://github.com/lognaturel) and that will be used for regression testing by [@getodk/testers](https://github.com/orgs/getodk/teams/testers). If any problems are found, the release is blocked until we can merge fixes. Regression testing should continue on the original beta build (rather than a new one with fixes) unless problems block the rest of testing. Once the process is complete, [@lognaturel](https://github.com/lognaturel) pushes the releases to the Play Store following [these instructions](#creating-signed-releases-for-google-play-store). Fixes to a previous release should be merged to a "release" branch (`v2023.2.x` for example) so as to leave `master` available for the current release's work. If hotfix changes are needed in the current release as well then these can be merged in as a PR after hotfix releases (generally easiest as a single PR for the whole hotfix release). This approach can also be used if work for the next release starts before the current one is out - the next release continues on `master` while the release is on a release branch. At the beginning of each release cycle, [@grzesiek2010](https://github.com/grzesiek2010) updates all dependencies that have compatible upgrades available and ensures that the build targets the latest SDK. ## Downloading builds Per-commit debug builds can be found on [CircleCI](https://circleci.com/gh/getodk/collect). Login with your GitHub account, click the build you'd like, then find the APK in the Artifacts tab. If you are looking to use ODK Collect, we strongly recommend using the [Play Store build](https://play.google.com/store/apps/details?id=org.odk.collect.android). Current and previous production builds can be found in [Releases](https://github.com/getodk/collect/releases). ## Suggesting new features We try to make sure that all issues in the issue tracker are as close to fully specified as possible so that they can be closed by a pull request. Feature suggestions should be described [in the forum Features category](https://forum.getodk.org/c/features) and discussed by the broader user community. Once there is a clear way forward, issues should be filed on the relevant repositories. More controversial features will be discussed as part of the Technical Steering Committee's [roadmapping process](https://github.com/getodk/governance/blob/master/TSC-1/STANDARD-OPERATING-PROCEDURES.md#roadmap). ## Contributing code Any and all contributions to the project are welcome. ODK Collect is used across the world primarily by organizations with a social purpose so you can have real impact! Issues tagged as [good first issue](https://github.com/getodk/collect/labels/good%20first%20issue) should be a good place to start. There are also currently many issues tagged as [needs reproduction](https://github.com/getodk/collect/labels/needs%20reproduction) which need someone to try to reproduce them with the current version of ODK Collect and comment on the issue with their findings. If you're ready to contribute code, see [the contribution guide](docs/CONTRIBUTING.md). ## Contributing translations If you know a language other than English, consider contributing translations through [Transifex](https://explore.transifex.com/getodk/collect/). Translations are updated right before the first beta for a release and before the release itself. To update translations, download the zip from https://explore.transifex.com/getodk/collect/. The contents of each folder then need to be moved to the Android project folders. A quick script like [the one in this gist](https://gist.github.com/lognaturel/9974fab4e7579fac034511cd4944176b) can help. We currently copy everything from Transifex to minimize manual intervention. Sometimes translation files will only get comment changes. When new languages are updated in Transifex, they need to be added to the script above. Additionally, `ApplicationConstants.TRANSLATIONS_AVAILABLE` needs to be updated. This array provides the choices for the language preference in settings. Ideally the list could be dynamically generated. ## Setting up your development environment 1. Download and install [Git](https://git-scm.com/downloads) and add it to your PATH 1. Download and install [Android Studio](https://developer.android.com/studio/index.html) 1. Fork the collect project ([why and how to fork](https://help.github.com/articles/fork-a-repo/)) 1. Clone your fork of the project locally. At the command line: git clone https://github.com/YOUR-GITHUB-USERNAME/collect If you prefer not to use the command line, you can use Android Studio to create a new project from version control using `https://github.com/YOUR-GITHUB-USERNAME/collect`. 1. Use Android Studio to import the project from its Gradle settings. To run the project, click on the green arrow at the top of the screen. 1. Windows developers: continue configuring Android Studio with the steps in this document: [Developing ODK Collect on Windows](docs/WINDOWS-DEV-SETUP.md). 1. Make sure you can run unit tests by running everything under `collect_app/src/test/java` in Android Studio or on the command line: ``` ./gradlew testDebug ``` 1. Make sure you can run instrumented tests by running everything under `collect_app/src/androidTest/java` in Android Studio or on the command line: ``` ./gradlew connectedAndroidTest ``` **Note:** You can see the emulator setup used on CI in `.circleci/config.yml`. ## Customizing the development environment ### Changing JVM heap size You can customize the heap size that is used for compiling and running tests. Increasing these will most likely speed up compilation and tests on your local machine. The default values are specified in the project's `gradle.properties` and this can be overriden by your user level `gradle.properties` (found in your `GRADLE_USER_HOME` directory). An example `gradle.properties` that would give you a heap size of 4GB (rather than the default 1GB) would look like: ``` org.gradle.jvmargs=-Xmx4096m ``` ## Testing a form without a server When you first run Collect, it is set to download forms from [https://demo.getodk.org/](https://demo.getodk.org/), the demo server. You can sometimes verify your changes with those forms but it can also be helpful to put a specific test form on your device. Here are some options for that: 1. The `All question types` form from the default server is [here](https://docs.google.com/spreadsheets/d/1af_Sl8A_L8_EULbhRLHVl8OclCfco09Hq2tqb9CslwQ/edit#gid=0). You can also try [example forms](https://github.com/XLSForm/example-forms) and [test forms](https://github.com/XLSForm/test-forms) or [make your own](https://xlsform.org). 1. Convert the XLSForm (xlsx) to XForm (xml). Use the [ODK website](http://getodk.org/xlsform/) or [XLSForm Offline](https://gumroad.com/l/xlsform-offline) or [pyxform](https://github.com/XLSForm/pyxform). 1. Once you have the XForm, use [adb](https://developer.android.com/studio/command-line/adb.html) to push the form to your device (after [enabling USB debugging](https://www.kingoapp.com/root-tutorials/how-to-enable-usb-debugging-mode-on-android.htm)) or emulator. ``` adb push my_form.xml /sdcard/Android/data/org.odk.collect.android/files/projects/{project-id}/forms ``` If you are using the demo project, kindly replace `{project_id}` with `DEMO` 4. Launch ODK Collect and tap `+ Start new form`. The new form will be there. More information about using Android Debug Bridge with Collect can be found [here](https://docs.getodk.org/collect-adb/). ## Using APIs for local development Certain functions in ODK Collect depend on cloud services that require API keys or authorization steps to work. Here are the steps you need to take in order to use these functions in your development builds. **Google Maps API**: When the "Google Maps SDK" option is selected in the "User interface" settings, ODK Collect uses the Google Maps API for displaying maps in the geospatial question types (GeoPoint, GeoTrace, and GeoShape). To enable this API: 1. [Get a Google Maps API key](https://developers.google.com/maps/documentation/android-api/signup). Note that this requires a credit card number, though the card will not be charged immediately; some free API usage is permitted. You should carefully read the terms before providing a credit card number. 1. Edit or create `secrets.properties` and set the `GOOGLE_MAPS_API_KEY` property to your API key. You should end up with a line that looks like this: ``` GOOGLE_MAPS_API_KEY=AIbzvW8e0ub... ``` **Mapbox Maps SDK for Android**: When the "Mapbox SDK" option is selected in the "User interface" settings, ODK Collect uses the Mapbox SDK for displaying maps in the geospatial question types (GeoPoint, GeoTrace, and GeoShape). To enable this API: 1. [Create a Mapbox account](https://www.mapbox.com/signup/). Note that signing up with the "Pay-As-You-Go" plan does not require a credit card. Mapbox provides free API usage up to the monthly thresholds documented at [https://www.mapbox.com/pricing](https://www.mapbox.com/pricing). If your usage exceeds these thresholds, you will receive e-mail with instructions on how to add a credit card for payment; services will remain live until the end of the 30-day billing term, after which the account will be deactivated and will require a credit card to reactivate. 2. Find your access token on your [account page](https://account.mapbox.com/) - it should be in "Tokens" as "Default public token". 3. Edit or create `secrets.properties` and set the `MAPBOX_ACCESS_TOKEN` property to your access token. You should end up with a line that looks like this: ``` MAPBOX_ACCESS_TOKEN=pk.eyJk3bumVp4i... ``` 4. Create a new secret token with the "DOWNLOADS:READ" secret scope and then add it to `secrets.properties` as `MAPBOX_DOWNLOADS_TOKEN`. *Note: Mapbox will not be available as an option in compiled versions of Collect unless you follow the steps above. Mapbox will also not be available on x86 devices as the native libraries are excluded to reduce the APK size. If you need to use an x86 device, you can force the build to include x86 libs by include the `x86Libs` Gradle parameter. For example, to build a debug APK with x86 libs: `./gradlew assembleDebug -Px86Libs`.* ## Debugging JavaRosa JavaRosa is the form engine that powers Collect. If you want to debug or change that code while running Collect you can deploy it locally with Maven (you'll need `mvn` and `sed` installed): 1. Build and install your changes of JavaRosa (into your local Maven repo): ```bash ./gradlew publishToMavenLocal ``` 1. Change `const val javarosa = javarosa_online` in `Dependencies.kt` to `const val javarosa = javarosa_local` ## Troubleshooting #### Error when running Robolectric tests from Android Studio on macOS: `build/intermediates/bundles/debug/AndroidManifest.xml (No such file or directory)` > Configure the default JUnit test runner configuration in order to work around a bug where IntelliJ / Android Studio does not set the working directory to the module being tested. This can be accomplished by editing the run configurations, Defaults -> JUnit and changing the working directory value to $MODULE_DIR$. > Source: [Robolectric Wiki](https://github.com/robolectric/robolectric/wiki/Running-tests-in-Android-Studio#notes-for-mac). #### Android Studio Error: `SDK location not found. Define location with sdk.dir in the local.properties file or with an ANDROID_HOME environment variable.` When cloning the project from Android Studio, click "No" when prompted to open the `build.gradle` file and then open project. #### Execution failed for task ':collect_app:transformClassesWithInstantRunForDebug'. We have seen this problem happen in both IntelliJ IDEA and Android Studio, and believe it to be due to a bug in the IDE, which we can't fix. As a workaround, turning off [Instant Run](https://developer.android.com/studio/run/#set-up-ir) will usually avoid this problem. The problem is fixed in Android Studio 3.5 with the new [Apply Changes](https://medium.com/androiddevelopers/android-studio-project-marble-apply-changes-e3048662e8cd) feature. #### Moving to the main view if user minimizes the app If you build the app on your own using Android Studio `(Build -> Build APK)` and then install it (from an `.apk` file), you might notice this strange behaviour thoroughly described: [#1280](https://github.com/getodk/collect/issues/1280) and [#1142](https://github.com/getodk/collect/issues/1142). This problem occurs building other apps as well. #### gradlew Failure: `FAILURE: Build failed with an exception.` If you encounter an error similar to this when running `gradlew`: ``` FAILURE: Build failed with an exception What went wrong: A problem occurred configuring project ':collect_app'. > Failed to notify project evaluation listener. > Could not initialize class com.android.sdklib.repository.AndroidSdkHandler ``` You may have a mismatch between the embedded Android SDK Java and the JDK installed on your machine. You may wish to set your **JAVA_HOME** environment variable to that SDK. For example, on macOS: `export JAVA_HOME="/Applications/Android\ Studio.app/Contents/jre/Contents/Home/" ` Note that this change might cause problems with other Java-based applications (e.g., if you uninstall Android Studio). #### gradlew Failure: `java.lang.NullPointerException (no error message).` If you encounter the `java.lang.NullPointerException (no error message).` when running `gradlew`, please make sure your Java version for this project is Java 17. This can be configured under **File > Project Structure** in Android Studio, or by editing `$USER_HOME/.gradle/gradle.properties` to set `org.gradle.java.home=(path to JDK home)` for command-line use. #### `Unable to resolve artifact: Missing` while running tests This is encountered when Robolectric has problems downloading the jars it needs for different Android SDK levels. If you keep running into this you can download the JARs locally and point Robolectric to them by doing: ``` ./download-robolectric-deps.sh ``` ## Test devices Devices that @getodk/testers have available for testing are as follows: * Xiaomi Redmi 9T 4GB - Android 10 * Pixel 7a 8GB - Android 14 * LG Nexus 5X 2GB - Android 8.1 * Samsung Galaxy M12 4GB - Android 11 * Samsung Galaxy M23 4GB - Android 14 * Xiaomi Redmi 7 3GB - Android 10 * Pixel 6a 6GB - Android 13 * Pixel 3a 4GB - Android 12 * Huawei Y560-L01 1GB - Android 5.1 ## Creating signed releases for Google Play Store Maintainers keep a folder with a clean checkout of the code and use [jenv.be](https://www.jenv.be) in that folder to ensure compilation with Java 17. ### Release prerequisites: - a`local.properties` file in the root folder with the following: ``` sdk.dir=/path/to/android/sdk ``` - the keystore file and passwords - a `secrets.properties` file in the root project folder folder with the following: ``` // secrets.properties RELEASE_STORE_FILE=/path/to/collect.keystore RELEASE_STORE_PASSWORD=secure-store-password RELEASE_KEY_ALIAS=key-alias RELEASE_KEY_PASSWORD=secure-alias-password ``` - a `google-services.json` file in the `collect_app/src/odkCollectRelease` folder. The contents of the file are similar to the contents of `collect_app/src/google-services.json`. ### Release checklist: - update translations - make sure CI is green for the chosen commit - run `./gradlew releaseCheck`. If successful, a signed release will be at `collect_app/build/outputs/apk` (with an old version name) - verify a basic "happy path": scan a QR code to configure a new project, get a blank form, fill it, open the form map (confirms that the Google Maps key is correct), send form - run `./benchmark.sh` with a real device connected to verify performance - To run benchmarks a project will need to be set up in Central with the benchmark forms and app users. The forms and entities needed for that are available [here](https://drive.google.com/drive/folders/1dPLvDY0LhVX-5qTUEs6EDoraDnLpUS0g?usp=drive_link). - verify new APK can be installed as update to previous version and that above "happy path" works in that case also - create and publish scheduled forum post with release description - write Play Store release notes, include link to forum post - when creating a major release: - Tag the commit for the release (`vX.X.0`) - Run `./create-release.sh ` - when creating a patch release: - Tag the commit for the patch release (`vX.X.X`) - (If beta has started for next release) Tag the commit for the beta release (`vX.X.X-beta.X`) - Run `./create-release.sh ` - when creating a beta release: - Tag the commit for the beta release (`vX.X.X-beta.X`) - Run `./create-release.sh ` - add a release to Github [here](https://github.com/getodk/collect/releases), generate release notes and attach the APK - upload APK(s) to Play Store - When creating a hotfix, the beta APK should be uploaded second as it will have a higher version code - backup dependencies for the release by downloading the `vX.X.X.tar` artifact from the `create_dependency_backup` job on Circle CI (for the release commit) and then uploading it to [this folder](https://drive.google.com/drive/folders/1_tMKBFLdhzFZF9GKNeob4FbARjdfbtJu?usp=share_link) - backup a self signed release APK by downloading the `selfSignedRelease.apk` from the `build_release` job on Circle CI (for the release commit) and then upload to [this folder](https://drive.google.com/drive/folders/1pbbeNaMTziFhtZmedOs0If3BeYu3Ex5x?usp=share_link) ## Compiling a previous release using backed-up dependencies 1. Download the `.tar` for relevant release tag 2. Extract `.local-m2` into the project directory: ```bash tar -xf maven.tar -C ``` The project will now be able to fetch dependencies that are no longer available (but were used to compile the release) from the `.local-m2` Maven repo. ================================================ FILE: SECURITY.md ================================================ # Security Policy ## Reporting a Vulnerability See our [Vulnerability Disclosure Policy](https://getodk.org/vdp). ================================================ FILE: analytics/.gitignore ================================================ /build ================================================ FILE: analytics/build.gradle.kts ================================================ plugins { alias(libs.plugins.androidLibrary) } apply(from = "../config/quality.gradle") android { compileSdk = libs.versions.compileSdk.get().toInt() defaultConfig { minSdk = libs.versions.minSdk.get().toInt() testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" } buildTypes { release { isMinifyEnabled = false proguardFiles(getDefaultProguardFile("proguard-android-optimize.txt"), "proguard-rules.pro") } } compileOptions { sourceCompatibility = JavaVersion.VERSION_17 targetCompatibility = JavaVersion.VERSION_17 } namespace = "org.odk.collect.analytics" } dependencies { implementation(libs.kotlinStdlib) implementation(libs.androidxCoreKtx) implementation(libs.firebaseCrashlytics) implementation(libs.firebaseAnalytics) } ================================================ FILE: analytics/proguard-rules.pro ================================================ # Add project specific ProGuard rules here. # You can control the set of applied configuration files using the # proguardFiles setting in build.gradle. # # For more details, see # http://developer.android.com/guide/developing/tools/proguard.html # If your project uses WebView with JS, uncomment the following # and specify the fully qualified class name to the JavaScript interface # class: #-keepclassmembers class fqcn.of.javascript.interface.for.webview { # public *; #} # Uncomment this to preserve the line number information for # debugging stack traces. #-keepattributes SourceFile,LineNumberTable # If you keep the line number information, uncomment this to # hide the original source file name. #-renamesourcefileattribute SourceFile ================================================ FILE: analytics/src/main/AndroidManifest.xml ================================================ ================================================ FILE: analytics/src/main/java/org/odk/collect/analytics/Analytics.kt ================================================ package org.odk.collect.analytics interface Analytics { fun logEvent(event: String) fun logEventWithParam(event: String, key: String, value: String) fun logNonFatal(throwable: Throwable) fun logMessage(message: String) fun setAnalyticsCollectionEnabled(isAnalyticsEnabled: Boolean) fun setUserProperty(name: String, value: String) companion object { private var instance: Analytics = NoopAnalytics() private val params = mutableMapOf() fun setInstance(analytics: Analytics) { this.instance = analytics } @JvmStatic fun log(event: String) { instance.logEvent(event) } @JvmStatic fun log(event: String, key: String) { val paramValue = params[key] if (paramValue != null) { log(event, key, paramValue) } else { log(event) } } @JvmStatic fun log(event: String, key: String, value: String) { instance.logEventWithParam(event, key, value) } @JvmStatic fun setParam(key: String, value: String) { params[key] = value } @JvmStatic fun getParamValue(key: String): String? { return params[key] } fun setUserProperty(name: String, value: String) { instance.setUserProperty(name, value) } fun logNonFatal(throwable: Throwable) { instance.logNonFatal(throwable) } } } ================================================ FILE: analytics/src/main/java/org/odk/collect/analytics/BlockableFirebaseAnalytics.kt ================================================ package org.odk.collect.analytics import android.app.Application import android.os.Bundle import com.google.firebase.analytics.FirebaseAnalytics import com.google.firebase.crashlytics.FirebaseCrashlytics class BlockableFirebaseAnalytics(application: Application) : Analytics { private val firebaseAnalytics = FirebaseAnalytics.getInstance(application) private val crashlytics = FirebaseCrashlytics.getInstance() override fun logEvent(event: String) { firebaseAnalytics.logEvent(event, null) } override fun logEventWithParam(event: String, key: String, value: String) { val bundle = Bundle() bundle.putString(key, value) firebaseAnalytics.logEvent(event, bundle) } override fun logNonFatal(throwable: Throwable) { crashlytics.recordException(throwable) } override fun logMessage(message: String) { crashlytics.log(message) } override fun setAnalyticsCollectionEnabled(isAnalyticsEnabled: Boolean) { firebaseAnalytics.setAnalyticsCollectionEnabled(isAnalyticsEnabled) crashlytics.setCrashlyticsCollectionEnabled(isAnalyticsEnabled) } override fun setUserProperty(name: String, value: String) { firebaseAnalytics.setUserProperty(name, value) } } ================================================ FILE: analytics/src/main/java/org/odk/collect/analytics/NoopAnalytics.kt ================================================ package org.odk.collect.analytics class NoopAnalytics : Analytics { override fun logEvent(event: String) {} override fun logEventWithParam(event: String, key: String, value: String) {} override fun logNonFatal(throwable: Throwable) {} override fun logMessage(message: String) {} override fun setAnalyticsCollectionEnabled(isAnalyticsEnabled: Boolean) {} override fun setUserProperty(name: String, value: String) {} } ================================================ FILE: androidshared/.gitignore ================================================ /build ================================================ FILE: androidshared/build.gradle.kts ================================================ plugins { alias(libs.plugins.androidLibrary) alias(libs.plugins.kotlinKsp) alias(libs.plugins.composeCompiler) } apply(from = "../config/quality.gradle") android { compileSdk = libs.versions.compileSdk.get().toInt() buildFeatures { viewBinding = true compose = true } defaultConfig { minSdk = libs.versions.minSdk.get().toInt() testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" } buildTypes { release { isMinifyEnabled = false } } compileOptions { isCoreLibraryDesugaringEnabled = true sourceCompatibility = JavaVersion.VERSION_17 targetCompatibility = JavaVersion.VERSION_17 } testOptions { unitTests.isIncludeAndroidResources = true } namespace = "org.odk.collect.androidshared" } dependencies { coreLibraryDesugaring(libs.desugar) implementation(project(":icons")) implementation(project(":strings")) implementation(project(":shared")) implementation(project(":async")) implementation(libs.kotlinStdlib) implementation(libs.androidxCoreKtx) implementation(libs.androidxLifecycleLivedataKtx) implementation(libs.androidMaterial) implementation(libs.androidxFragmentKtx) implementation(libs.androidxPreferenceKtx) implementation(libs.timber) implementation(libs.androidxExinterface) implementation(libs.playServicesLocation) val composeBom = platform(libs.androidxComposeBom) implementation(composeBom) implementation(libs.androidXComposeMaterial) testImplementation(project(":test-shared")) testImplementation(project(":androidtest")) testImplementation(libs.junit) testImplementation(libs.androidxTestExtJunit) testImplementation(libs.androidxTestEspressoCore) testImplementation(libs.robolectric) testImplementation(libs.mockitoKotlin) testImplementation(libs.androidxArchCoreTesting) androidTestImplementation(libs.androidxTestExtJunit) androidTestImplementation(libs.junit) debugImplementation(project(":fragments-test")) } ================================================ FILE: androidshared/src/androidTest/java/org/odk/collect/androidshared/bitmap/ImageCompressorTest.kt ================================================ /* * Copyright 2017 Nafundi * * 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.odk.collect.androidshared.bitmap import android.graphics.Bitmap import android.graphics.BitmapFactory import androidx.exifinterface.media.ExifInterface import androidx.test.ext.junit.runners.AndroidJUnit4 import org.hamcrest.CoreMatchers.equalTo import org.hamcrest.MatcherAssert.assertThat import org.junit.Test import org.junit.runner.RunWith import java.io.File @RunWith(AndroidJUnit4::class) class ImageCompressorTest { private lateinit var testImagePath: String private val imageCompressor = ImageCompressor @Test fun imageShouldNotBeChangedIfMaxPixelsIsZero() { saveTestBitmap(3000, 2000) imageCompressor.execute(testImagePath, 0) val image = ImageFileUtils.getBitmap(testImagePath, BitmapFactory.Options())!! assertThat(3000, equalTo(image.width)) assertThat(2000, equalTo(image.height)) } @Test fun imageShouldNotBeChangedIfMaxPixelsIsSmallerThanZero() { saveTestBitmap(3000, 2000) imageCompressor.execute(testImagePath, -10) val image = ImageFileUtils.getBitmap(testImagePath, BitmapFactory.Options())!! assertThat(3000, equalTo(image.width)) assertThat(2000, equalTo(image.height)) } @Test fun imageShouldNotBeChangedIfMaxPixelsIsNotSmallerThanTheEdgeWhenWidthIsBiggerThanHeight() { saveTestBitmap(3000, 2000) imageCompressor.execute(testImagePath, 3000) val image = ImageFileUtils.getBitmap(testImagePath, BitmapFactory.Options())!! assertThat(3000, equalTo(image.width)) assertThat(2000, equalTo(image.height)) } @Test fun imageShouldNotBeChangedIfMaxPixelsIsNotSmallerThanTheLongEdgeWhenWidthIsSmallerThanHeight() { saveTestBitmap(2000, 3000) imageCompressor.execute(testImagePath, 4000) val image = ImageFileUtils.getBitmap(testImagePath, BitmapFactory.Options())!! assertThat(2000, equalTo(image.width)) assertThat(3000, equalTo(image.height)) } @Test fun imageShouldNotBeChangedIfMaxPixelsIsNotSmallerThanTheLongEdgeWhenWidthEqualsHeight() { saveTestBitmap(3000, 3000) imageCompressor.execute(testImagePath, 3000) val image = ImageFileUtils.getBitmap(testImagePath, BitmapFactory.Options())!! assertThat(3000, equalTo(image.width)) assertThat(3000, equalTo(image.height)) } @Test fun imageShouldBeCompressedIfMaxPixelsIsSmallerThanTheLongEdgeWhenWidthIsBiggerThanHeight() { saveTestBitmap(4000, 3000) imageCompressor.execute(testImagePath, 2000) val image = ImageFileUtils.getBitmap(testImagePath, BitmapFactory.Options())!! assertThat(2000, equalTo(image.width)) assertThat(1500, equalTo(image.height)) } @Test fun imageShouldBeCompressedIfMaxPixelsIsSmallerThanTheLongEdgeWhenWidthIsSmallerThanHeight() { saveTestBitmap(3000, 4000) imageCompressor.execute(testImagePath, 2000) val image = ImageFileUtils.getBitmap(testImagePath, BitmapFactory.Options())!! assertThat(1500, equalTo(image.width)) assertThat(2000, equalTo(image.height)) } @Test fun imageShouldBeCompressedIfMaxPixelsIsSmallerThanTheLongEdgeWhenWidthEqualsHeight() { saveTestBitmap(3000, 3000) imageCompressor.execute(testImagePath, 2000) val image = ImageFileUtils.getBitmap(testImagePath, BitmapFactory.Options())!! assertThat(2000, equalTo(image.width)) assertThat(2000, equalTo(image.height)) } @Test fun keepExifAfterScaling() { val attributes = mutableMapOf( // supported exif tags ExifInterface.TAG_DATETIME to "2014:01:23 14:57:18", ExifInterface.TAG_DATETIME_ORIGINAL to "2014:01:23 14:57:18", ExifInterface.TAG_DATETIME_DIGITIZED to "2014:01:23 14:57:18", ExifInterface.TAG_OFFSET_TIME to "+1:00", ExifInterface.TAG_OFFSET_TIME_ORIGINAL to "+1:00", ExifInterface.TAG_OFFSET_TIME_DIGITIZED to "+1:00", ExifInterface.TAG_SUBSEC_TIME to "First photo", ExifInterface.TAG_SUBSEC_TIME_ORIGINAL to "0", ExifInterface.TAG_SUBSEC_TIME_DIGITIZED to "0", ExifInterface.TAG_IMAGE_DESCRIPTION to "Photo from Poland", ExifInterface.TAG_MAKE to "OLYMPUS IMAGING CORP", ExifInterface.TAG_MODEL to "STYLUS1", ExifInterface.TAG_SOFTWARE to "Version 1.0", ExifInterface.TAG_ARTIST to "Grzegorz", ExifInterface.TAG_COPYRIGHT to "G", ExifInterface.TAG_MAKER_NOTE to "OLYMPUS", ExifInterface.TAG_USER_COMMENT to "First photo", ExifInterface.TAG_IMAGE_UNIQUE_ID to "123456789", ExifInterface.TAG_CAMERA_OWNER_NAME to "John", ExifInterface.TAG_BODY_SERIAL_NUMBER to "987654321", ExifInterface.TAG_GPS_ALTITUDE to "41/1", ExifInterface.TAG_GPS_ALTITUDE_REF to "0", ExifInterface.TAG_GPS_DATESTAMP to "2014:01:23", ExifInterface.TAG_GPS_TIMESTAMP to "14:57:18", ExifInterface.TAG_GPS_LATITUDE to "50/1,49/1,8592/1000", ExifInterface.TAG_GPS_LATITUDE_REF to "N", ExifInterface.TAG_GPS_LONGITUDE to "0/1,8/1,12450/1000", ExifInterface.TAG_GPS_LONGITUDE_REF to "W", ExifInterface.TAG_GPS_SATELLITES to "8", ExifInterface.TAG_GPS_STATUS to "A", ExifInterface.TAG_ORIENTATION to "1", // unsupported exif tags ExifInterface.TAG_THUMBNAIL_IMAGE_LENGTH to "5", ExifInterface.TAG_DNG_VERSION to "100" ) saveTestBitmap(3000, 4000, attributes) imageCompressor.execute(testImagePath, 2000) val exifData = ExifInterface(testImagePath) for (attributeName in attributes.keys) { if (attributeName == ExifInterface.TAG_THUMBNAIL_IMAGE_LENGTH || attributeName == ExifInterface.TAG_DNG_VERSION ) { assertThat(exifData.getAttribute(attributeName), equalTo(null)) } else { assertThat(exifData.getAttribute(attributeName), equalTo(attributes[attributeName])) } } } @Test fun verifyNoRotationAppliedForExifRotation() { val attributes = mapOf(ExifInterface.TAG_ORIENTATION to ExifInterface.ORIENTATION_ROTATE_90.toString()) saveTestBitmap(3000, 4000, attributes) imageCompressor.execute(testImagePath, 4000) val image = ImageFileUtils.getBitmap(testImagePath, BitmapFactory.Options())!! assertThat(3000, equalTo(image.width)) assertThat(4000, equalTo(image.height)) } private fun saveTestBitmap(width: Int, height: Int, attributes: Map = emptyMap()) { testImagePath = File.createTempFile("test", ".jpg").absolutePath val bitmap = Bitmap.createBitmap(width, height, Bitmap.Config.RGB_565) ImageFileUtils.saveBitmapToFile(bitmap, testImagePath) val exifInterface = ExifInterface(testImagePath) for ((key, value) in attributes) { exifInterface.setAttribute(key, value) } exifInterface.saveAttributes() } } ================================================ FILE: androidshared/src/androidTest/java/org/odk/collect/androidshared/bitmap/ImageFileUtilsTest.kt ================================================ /* * Copyright 2017 Nafundi * * 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.odk.collect.androidshared.bitmap import android.content.Context import android.graphics.Bitmap import android.graphics.BitmapFactory import android.graphics.Color import androidx.exifinterface.media.ExifInterface import androidx.test.core.app.ApplicationProvider import androidx.test.ext.junit.runners.AndroidJUnit4 import org.junit.Assert.assertEquals import org.junit.Before import org.junit.Test import org.junit.runner.RunWith import timber.log.Timber import java.io.File import java.io.IOException @RunWith(AndroidJUnit4::class) class ImageFileUtilsTest { private lateinit var sourceFile: File private lateinit var destinationFile: File private lateinit var attributes: MutableMap @Before fun setup() { sourceFile = createTempImageFile("source") destinationFile = createTempImageFile("destination") attributes = HashMap() } @Test fun copyAndRotateImageNinety() { attributes[ExifInterface.TAG_ORIENTATION] = ExifInterface.ORIENTATION_ROTATE_90.toString() saveTestBitmapToFile(sourceFile.absolutePath, attributes) ImageFileUtils.copyImageAndApplyExifRotation(sourceFile, destinationFile) val image = ImageFileUtils.getBitmap( destinationFile.absolutePath, BitmapFactory.Options() )!! assertEquals(2, image.width) assertEquals(1, image.height) assertEquals(Color.GREEN, image.getPixel(0, 0)) assertEquals(Color.RED, image.getPixel(1, 0)) verifyNoExifOrientationInDestinationFile(destinationFile.absolutePath) } @Test fun copyAndRotateImageTwoSeventy() { attributes[ExifInterface.TAG_ORIENTATION] = ExifInterface.ORIENTATION_ROTATE_270.toString() saveTestBitmapToFile(sourceFile.absolutePath, attributes) ImageFileUtils.copyImageAndApplyExifRotation(sourceFile, destinationFile) val image = ImageFileUtils.getBitmap( destinationFile.absolutePath, BitmapFactory.Options() )!! assertEquals(2, image.width) assertEquals(1, image.height) assertEquals(Color.RED, image.getPixel(0, 0)) assertEquals(Color.GREEN, image.getPixel(1, 0)) verifyNoExifOrientationInDestinationFile(destinationFile.absolutePath) } @Test fun copyAndRotateImageOneEighty() { attributes[ExifInterface.TAG_ORIENTATION] = ExifInterface.ORIENTATION_ROTATE_180.toString() saveTestBitmapToFile(sourceFile.absolutePath, attributes) ImageFileUtils.copyImageAndApplyExifRotation(sourceFile, destinationFile) val image = ImageFileUtils.getBitmap( destinationFile.absolutePath, BitmapFactory.Options() )!! assertEquals(1, image.width) assertEquals(2, image.height) assertEquals(Color.GREEN, image.getPixel(0, 0)) assertEquals(Color.RED, image.getPixel(0, 1)) verifyNoExifOrientationInDestinationFile(destinationFile.absolutePath) } @Test fun copyAndRotateImageUndefined() { attributes[ExifInterface.TAG_ORIENTATION] = ExifInterface.ORIENTATION_UNDEFINED.toString() saveTestBitmapToFile(sourceFile.absolutePath, attributes) ImageFileUtils.copyImageAndApplyExifRotation(sourceFile, destinationFile) val image = ImageFileUtils.getBitmap( destinationFile.absolutePath, BitmapFactory.Options() )!! assertEquals(1, image.width) assertEquals(2, image.height) assertEquals(Color.RED, image.getPixel(0, 0)) assertEquals(Color.GREEN, image.getPixel(0, 1)) verifyNoExifOrientationInDestinationFile(destinationFile.absolutePath) } @Test fun copyAndRotateImageNoExif() { saveTestBitmapToFile(sourceFile.absolutePath, null) ImageFileUtils.copyImageAndApplyExifRotation(sourceFile, destinationFile) val image = ImageFileUtils.getBitmap( destinationFile.absolutePath, BitmapFactory.Options() )!! assertEquals(1, image.width) assertEquals(2, image.height) assertEquals(Color.RED, image.getPixel(0, 0)) assertEquals(Color.GREEN, image.getPixel(0, 1)) verifyNoExifOrientationInDestinationFile(destinationFile.absolutePath) } /** * These cases all have a window smaller than the image so the image should be scaled down. * Note that the scaling isn't exact -- the factor is the closest power of 2 to the exact one. */ @Test fun scaleDownBitmapWhenNeeded() { runScaleTest(1000, 1000, 500, 500, 500, 500, false) runScaleTest(600, 800, 600, 200, 150, 200, false) runScaleTest(500, 400, 250, 200, 250, 200, false) } @Test fun doNotScaleDownBitmapWhenNotNeeded() { runScaleTest(1000, 1000, 2000, 2000, 1000, 1000, false) runScaleTest(600, 800, 600, 800, 600, 800, false) runScaleTest(500, 400, 600, 600, 500, 400, false) runScaleTest(2000, 800, 4000, 2000, 2000, 800, false) } @Test fun accuratelyScaleBitmapToDisplay() { runScaleTest(1000, 1000, 500, 500, 500, 500, true) runScaleTest(600, 800, 600, 200, 150, 200, true) runScaleTest(500, 400, 250, 200, 250, 200, true) runScaleTest(2000, 800, 300, 400, 300, 120, true) runScaleTest(1000, 1000, 2000, 2000, 2000, 2000, true) runScaleTest(600, 800, 600, 800, 600, 800, true) runScaleTest(500, 400, 600, 600, 600, 480, true) runScaleTest(2000, 800, 4000, 2000, 4000, 1600, true) } private fun runScaleTest( imageHeight: Int, imageWidth: Int, windowHeight: Int, windowWidth: Int, expectedHeight: Int, expectedWidth: Int, shouldScaleAccurately: Boolean ) { ScaleImageTest() .createBitmap(imageHeight, imageWidth) .scaleBitmapToDisplay(windowHeight, windowWidth, shouldScaleAccurately) .assertScaledBitmapDimensions(expectedHeight, expectedWidth) } private fun verifyNoExifOrientationInDestinationFile(destinationFilePath: String) { val exifData = getTestImageExif(destinationFilePath) if (exifData != null) { assertEquals( ExifInterface.ORIENTATION_UNDEFINED, exifData.getAttributeInt( ExifInterface.TAG_ORIENTATION, ExifInterface.ORIENTATION_UNDEFINED ) ) } } private fun saveTestBitmapToFile( filePath: String, attributes: Map? ) { val bitmap = Bitmap.createBitmap(1, 2, Bitmap.Config.ARGB_8888) bitmap.setPixel(0, 0, Color.RED) bitmap.setPixel(0, 1, Color.GREEN) ImageFileUtils.saveBitmapToFile(bitmap, filePath) if (attributes != null) { try { val exifInterface = ExifInterface(filePath) for (attributeName in attributes.keys) { exifInterface.setAttribute(attributeName, attributes[attributeName]) } exifInterface.saveAttributes() } catch (e: IOException) { Timber.w(e) } } } private fun getTestImageExif(imagePath: String): ExifInterface? { try { return ExifInterface(imagePath) } catch (e: Exception) { Timber.w(e) } return null } private fun createTempImageFile(imageName: String): File { val temp = File.createTempFile(imageName, ".png") temp.deleteOnExit() return temp } private class ScaleImageTest { private val cache = ApplicationProvider.getApplicationContext().externalCacheDir private val imageFile = File(cache, "testImage.jpeg") private var scaledBitmap: Bitmap? = null fun createBitmap(imageHeight: Int, imageWidth: Int): ScaleImageTest { val bitmap = Bitmap.createBitmap(imageWidth, imageHeight, Bitmap.Config.ARGB_8888) ImageFileUtils.saveBitmapToFile(bitmap, imageFile.absolutePath) return this } fun scaleBitmapToDisplay( windowHeight: Int, windowWidth: Int, shouldScaleAccurately: Boolean ): ScaleImageTest { scaledBitmap = ImageFileUtils.getBitmapScaledToDisplay( imageFile, windowHeight, windowWidth, shouldScaleAccurately ) return this } fun assertScaledBitmapDimensions(expectedHeight: Int, expectedWidth: Int) { assertEquals(expectedHeight.toLong(), scaledBitmap!!.height.toLong()) assertEquals(expectedWidth.toLong(), scaledBitmap!!.width.toLong()) } } } ================================================ FILE: androidshared/src/main/AndroidManifest.xml ================================================ ================================================ FILE: androidshared/src/main/java/org/odk/collect/androidshared/async/TrackableWorker.kt ================================================ package org.odk.collect.androidshared.async import org.odk.collect.androidshared.livedata.MutableNonNullLiveData import org.odk.collect.androidshared.livedata.NonNullLiveData import org.odk.collect.async.Scheduler import java.util.concurrent.atomic.AtomicInteger import java.util.function.Consumer import java.util.function.Supplier class TrackableWorker(private val scheduler: Scheduler) { private val _isWorking = MutableNonNullLiveData(false) val isWorking: NonNullLiveData = _isWorking private var activeBackgroundJobsCounter = AtomicInteger(0) fun immediate(background: Supplier, foreground: Consumer) { activeBackgroundJobsCounter.incrementAndGet() _isWorking.value = true scheduler.immediate(background) { result -> if (activeBackgroundJobsCounter.decrementAndGet() == 0) { _isWorking.value = false } foreground.accept(result) } } fun immediate(background: Runnable) { immediate( background = { background.run() }, foreground = {} ) } } ================================================ FILE: androidshared/src/main/java/org/odk/collect/androidshared/bitmap/ImageCompressor.kt ================================================ /* * Copyright 2017 Nafundi * * 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.odk.collect.androidshared.bitmap import android.graphics.Bitmap import android.graphics.BitmapFactory import androidx.exifinterface.media.ExifInterface import timber.log.Timber object ImageCompressor { /** * Before proceed with scaling or rotating, make sure existing exif information is stored/restored. * @author Khuong Ninh (khuong.ninh@it-development.com) */ fun execute(imagePath: String, maxPixels: Int) { backupExifData(imagePath) scaleDownImage(imagePath, maxPixels) restoreExifData(imagePath) } /** * This method is used to reduce an original picture size. * maxPixels refers to the max pixels of the long edge, the short edge is scaled proportionately. */ private fun scaleDownImage(imagePath: String, maxPixels: Int) { if (maxPixels <= 0) { return } var image = ImageFileUtils.getBitmap(imagePath, BitmapFactory.Options()) if (image != null) { val originalWidth = image.width.toDouble() val originalHeight = image.height.toDouble() if (originalWidth > originalHeight && originalWidth > maxPixels) { val newHeight = (originalHeight / (originalWidth / maxPixels)).toInt() image = Bitmap.createScaledBitmap(image, maxPixels, newHeight, false) ImageFileUtils.saveBitmapToFile(image, imagePath) } else if (originalHeight > maxPixels) { val newWidth = (originalWidth / (originalHeight / maxPixels)).toInt() image = Bitmap.createScaledBitmap(image, newWidth, maxPixels, false) ImageFileUtils.saveBitmapToFile(image, imagePath) } } } private fun backupExifData(imagePath: String) { try { val exif = ExifInterface(imagePath) for ((key, _) in exifDataBackup) { exifDataBackup[key] = exif.getAttribute(key) } } catch (e: Throwable) { Timber.w(e) } } private fun restoreExifData(imagePath: String) { try { val exif = ExifInterface(imagePath) for ((key, value) in exifDataBackup) { exif.setAttribute(key, value) } exif.saveAttributes() } catch (e: Throwable) { Timber.w(e) } } private val exifDataBackup = mutableMapOf( ExifInterface.TAG_DATETIME to null, ExifInterface.TAG_DATETIME_ORIGINAL to null, ExifInterface.TAG_DATETIME_DIGITIZED to null, ExifInterface.TAG_OFFSET_TIME to null, ExifInterface.TAG_OFFSET_TIME_ORIGINAL to null, ExifInterface.TAG_OFFSET_TIME_DIGITIZED to null, ExifInterface.TAG_SUBSEC_TIME to null, ExifInterface.TAG_SUBSEC_TIME_ORIGINAL to null, ExifInterface.TAG_SUBSEC_TIME_DIGITIZED to null, ExifInterface.TAG_IMAGE_DESCRIPTION to null, ExifInterface.TAG_MAKE to null, ExifInterface.TAG_MODEL to null, ExifInterface.TAG_SOFTWARE to null, ExifInterface.TAG_ARTIST to null, ExifInterface.TAG_COPYRIGHT to null, ExifInterface.TAG_MAKER_NOTE to null, ExifInterface.TAG_USER_COMMENT to null, ExifInterface.TAG_IMAGE_UNIQUE_ID to null, ExifInterface.TAG_CAMERA_OWNER_NAME to null, ExifInterface.TAG_BODY_SERIAL_NUMBER to null, ExifInterface.TAG_GPS_ALTITUDE to null, ExifInterface.TAG_GPS_ALTITUDE_REF to null, ExifInterface.TAG_GPS_DATESTAMP to null, ExifInterface.TAG_GPS_TIMESTAMP to null, ExifInterface.TAG_GPS_LATITUDE to null, ExifInterface.TAG_GPS_LATITUDE_REF to null, ExifInterface.TAG_GPS_LONGITUDE to null, ExifInterface.TAG_GPS_LONGITUDE_REF to null, ExifInterface.TAG_GPS_SATELLITES to null, ExifInterface.TAG_GPS_STATUS to null, ExifInterface.TAG_ORIENTATION to null ) } ================================================ FILE: androidshared/src/main/java/org/odk/collect/androidshared/bitmap/ImageFileUtils.kt ================================================ package org.odk.collect.androidshared.bitmap import android.graphics.Bitmap import android.graphics.Bitmap.CompressFormat import android.graphics.BitmapFactory import android.graphics.Matrix import androidx.exifinterface.media.ExifInterface import timber.log.Timber import java.io.File import java.io.FileOutputStream import java.io.IOException import java.lang.Exception import java.util.Locale import kotlin.math.ceil object ImageFileUtils { // 80% JPEG quality gives a greater file size reduction with almost no loss in quality private const val IMAGE_COMPRESS_QUALITY = 80 private val EXIF_ORIENTATION_ROTATIONS = arrayOf( ExifInterface.ORIENTATION_ROTATE_90, ExifInterface.ORIENTATION_ROTATE_180, ExifInterface.ORIENTATION_ROTATE_270 ) @JvmStatic fun saveBitmapToFile(bitmap: Bitmap?, path: String) { val compressFormat = if (path.lowercase(Locale.getDefault()).endsWith(".png")) { CompressFormat.PNG } else { CompressFormat.JPEG } try { if (bitmap != null) { FileOutputStream(path).use { out -> bitmap.compress(compressFormat, IMAGE_COMPRESS_QUALITY, out) } } } catch (e: Exception) { Timber.e(e) } } /* This method is used to avoid OutOfMemoryError exception during loading an image. If the exception occurs we catch it and try to load a smaller image. */ @JvmStatic fun getBitmap(path: String?, originalOptions: BitmapFactory.Options): Bitmap? { val newOptions = BitmapFactory.Options() newOptions.inSampleSize = originalOptions.inSampleSize if (newOptions.inSampleSize <= 0) { newOptions.inSampleSize = 1 } val bitmap: Bitmap? = try { BitmapFactory.decodeFile(path, originalOptions) } catch (e: OutOfMemoryError) { Timber.i(e) newOptions.inSampleSize++ return getBitmap(path, newOptions) } return bitmap } @JvmStatic fun getBitmapScaledToDisplay(file: File, screenHeight: Int, screenWidth: Int): Bitmap? { return getBitmapScaledToDisplay(file, screenHeight, screenWidth, false) } /** * Scales image according to the given display * * @param file containing the image * @param screenHeight height of the display * @param screenWidth width of the display * @param upscaleEnabled determines whether the image should be up-scaled or not * if the window size is greater than the image size * @return scaled bitmap */ @JvmStatic fun getBitmapScaledToDisplay( file: File, screenHeight: Int, screenWidth: Int, upscaleEnabled: Boolean ): Bitmap? { // Determine image size of file var options = BitmapFactory.Options() options.inJustDecodeBounds = true getBitmap(file.absolutePath, options) var bitmap: Bitmap? val scale: Double if (upscaleEnabled) { // Load full size bitmap image options = BitmapFactory.Options() options.inInputShareable = true options.inPurgeable = true bitmap = getBitmap(file.absolutePath, options) val heightScale = options.outHeight.toDouble() / screenHeight val widthScale = options.outWidth.toDouble() / screenWidth scale = widthScale.coerceAtLeast(heightScale) val newHeight = ceil(options.outHeight / scale) val newWidth = ceil(options.outWidth / scale) if (bitmap != null) { bitmap = Bitmap.createScaledBitmap( bitmap, newWidth.toInt(), newHeight.toInt(), false ) } } else { val heightScale = options.outHeight / screenHeight val widthScale = options.outWidth / screenWidth // Powers of 2 work faster, sometimes, according to the doc. // We're just doing closest size that still fills the screen. scale = widthScale.coerceAtLeast(heightScale).toDouble() // get bitmap with scale ( < 1 is the same as 1) options = BitmapFactory.Options() options.inInputShareable = true options.inPurgeable = true options.inSampleSize = scale.toInt() bitmap = getBitmap(file.absolutePath, options) } if (bitmap != null) { Timber.i( "Screen is %dx%d. Image has been scaled down by %f to %dx%d", screenHeight, screenWidth, scale, bitmap.height, bitmap.width ) } return bitmap } /** * While copying the file, apply the exif rotation of sourceFile to destinationFile * so that sourceFile with EXIF has same orientation as destinationFile without EXIF */ @JvmStatic fun copyImageAndApplyExifRotation(sourceFile: File, destFile: File) { var sourceFileExif: ExifInterface? = null try { sourceFileExif = ExifInterface(sourceFile) } catch (e: IOException) { Timber.w(e) } if (sourceFileExif == null || !EXIF_ORIENTATION_ROTATIONS.contains( sourceFileExif .getAttributeInt( ExifInterface.TAG_ORIENTATION, ExifInterface.ORIENTATION_UNDEFINED ) ) ) { // Source Image doesn't have any EXIF Rotations, so a normal file copy will suffice sourceFile.copyTo(destFile, true) } else { val sourceImage = getBitmap(sourceFile.absolutePath, BitmapFactory.Options()) val orientation = sourceFileExif.getAttributeInt( ExifInterface.TAG_ORIENTATION, ExifInterface.ORIENTATION_UNDEFINED ) when (orientation) { ExifInterface.ORIENTATION_ROTATE_90 -> rotateBitmapAndSaveToFile( sourceImage, 90, destFile.absolutePath ) ExifInterface.ORIENTATION_ROTATE_180 -> rotateBitmapAndSaveToFile( sourceImage, 180, destFile.absolutePath ) ExifInterface.ORIENTATION_ROTATE_270 -> rotateBitmapAndSaveToFile( sourceImage, 270, destFile.absolutePath ) } } } private fun rotateBitmapAndSaveToFile(image: Bitmap?, degrees: Int, filePath: String) { var imageToSave = image try { val matrix = Matrix() matrix.postRotate(degrees.toFloat()) if (image != null) { imageToSave = Bitmap.createBitmap(image, 0, 0, image.width, image.height, matrix, true) } } catch (e: OutOfMemoryError) { Timber.w(e) } saveBitmapToFile(imageToSave, filePath) } } ================================================ FILE: androidshared/src/main/java/org/odk/collect/androidshared/data/AppState.kt ================================================ package org.odk.collect.androidshared.data import android.app.Activity import android.app.Application import android.app.Service import android.content.Context import androidx.fragment.app.Fragment import androidx.lifecycle.ViewModel import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow /** * [AppState] can be used as a shared store of state that lives at an "app"/"in-memory" level * rather than being tied to a specific component. This could be shared state between different * [Activity] objects or a way of communicating between a [Service] and other components. * [AppState] can be used as an alternative to Dagger singleton objects or static fields. * * [AppState] should not be used to share state between views or components on the same screen or make * up part of the same flow. For this, using Jetpack's [ViewModel] at either a [Fragment] or [Activity] * level is more appropriate. * * The easiest way to use [AppState] is have an instance owned by your app's [Application] object * and implement the [StateStore] interface: * * ``` * class MyApplication : Application(), StateStore { * private val appState = AppState() * } * ``` * * The [AppState] instance can then be accessed anywhere the [Application] is available using the * [getState] extension function. * */ class AppState { private val map = mutableMapOf() @Suppress("UNCHECKED_CAST") fun get(key: String, default: T): T { return map.getOrPut(key) { default } as T } @Suppress("UNCHECKED_CAST") fun get(key: String): T? { return map[key] as T? } fun getFlow(key: String, default: T): StateFlow { return get(key, MutableStateFlow(default)) } fun set(key: String, value: Any?) { map[key] = value } fun setFlow(key: String, value: T) { get>(key).let { if (it != null) { it.value = value } else { map[key] = MutableStateFlow(value) } } } fun clear() { map.clear() } fun clear(key: String) { map.remove(key) } } interface StateStore { fun getState(): AppState } fun Application.getState(): AppState { try { val stateStore = this as StateStore return stateStore.getState() } catch (e: ClassCastException) { throw ClassCastException("${this.javaClass} cannot be cast to StateStore") } } fun Context.getState(): AppState { return (applicationContext as Application).getState() } ================================================ FILE: androidshared/src/main/java/org/odk/collect/androidshared/data/Consumable.kt ================================================ package org.odk.collect.androidshared.data import androidx.lifecycle.LifecycleOwner import androidx.lifecycle.LiveData /** * Useful for values that are read multiple times but only used * once (like an error that shows a dialog once). */ data class Consumable(val value: T) { private var consumed = false fun isConsumed(): Boolean { return consumed } fun consume() { consumed = true } } fun LiveData?>.consume(lifecycleOwner: LifecycleOwner, consumer: (T) -> Unit) { observe(lifecycleOwner) { consumable -> if (consumable != null && !consumable.isConsumed()) { consumable.consume() consumer(consumable.value) } } } ================================================ FILE: androidshared/src/main/java/org/odk/collect/androidshared/data/Data.kt ================================================ package org.odk.collect.androidshared.data import kotlinx.coroutines.flow.StateFlow import org.odk.collect.androidshared.data.Updatable.Data import org.odk.collect.androidshared.data.Updatable.QualifiedData import kotlin.reflect.KProperty sealed interface Updatable { class QualifiedData( private val appState: AppState, private val key: String, private val default: T ) : Updatable { fun flow(qualifier: String): StateFlow { return appState.getFlow("$qualifier:$key", default) } fun set(qualifier: String?, value: T) { appState.setFlow("$qualifier:$key", value) } } class Data(private val appState: AppState, private val key: String, private val default: T) : Updatable { fun flow(): StateFlow { return appState.getFlow(key, default) } fun set(value: T) { appState.setFlow(key, value) } } } abstract class DataService( private val appState: AppState, private val onUpdate: (() -> Unit)? = null ) { private val updaters = mutableListOf>() fun update(qualifier: String? = null) { updaters.forEach { it.update(qualifier) } onUpdate?.invoke() } protected fun data(key: String, default: T): DataDelegate { val data = Data(appState, key, default) return DataDelegate(data) } protected fun data(key: String, default: T, updater: () -> T): DataDelegate { val data = Data(appState, key, default) updaters.add(Updater(data) { updater() }) return DataDelegate(data) } protected fun qualifiedData( key: String, default: T ): QualifiedDataDelegate { val data = QualifiedData(appState, key, default) return QualifiedDataDelegate(data) } protected fun qualifiedData( key: String, default: T, updater: (String) -> T ): QualifiedDataDelegate { val data = QualifiedData(appState, key, default) updaters.add(Updater(data) { it: String? -> updater(it!!) }) return QualifiedDataDelegate(data) } class QualifiedDataDelegate(private val data: QualifiedData) { operator fun getValue(thisRef: Any?, property: KProperty<*>): QualifiedData { return data } } class DataDelegate(private val data: Data) { operator fun getValue(thisRef: Any?, property: KProperty<*>): Data { return data } } private class Updater( private val updatable: Updatable, private val updater: (String?) -> T ) { fun update(qualifier: String? = null) { when (updatable) { is Data -> updatable.set(updater(qualifier)) is QualifiedData -> updatable.set(qualifier, updater(qualifier)) } } } } ================================================ FILE: androidshared/src/main/java/org/odk/collect/androidshared/livedata/LiveDataExt.kt ================================================ package org.odk.collect.androidshared.livedata import androidx.lifecycle.LiveData import androidx.lifecycle.MediatorLiveData object LiveDataExt { fun LiveData.combine(other: LiveData): LiveData> { return LiveDataUtils.combine(this, other) } fun LiveData.runningFold(initial: U, operation: (U, T) -> U): LiveData { val mediator = MediatorLiveData() var accum = initial mediator.addSource(this) { accum = operation(accum, it) mediator.value = accum } return mediator } /** * Returns a [LiveData] where each value is a [Pair] made up of the latest value and the * previous value. */ fun LiveData.withLast(): LiveData> { return this.runningFold(Pair(null, null) as Pair) { last, current -> Pair(last.second, current) } } } ================================================ FILE: androidshared/src/main/java/org/odk/collect/androidshared/livedata/LiveDataUtils.java ================================================ package org.odk.collect.androidshared.livedata; import androidx.lifecycle.LiveData; import androidx.lifecycle.MediatorLiveData; import androidx.lifecycle.MutableLiveData; import androidx.lifecycle.Observer; import org.odk.collect.async.Cancellable; import java.util.function.Consumer; import java.util.function.Function; import kotlin.Pair; import kotlin.Triple; public class LiveDataUtils { private LiveDataUtils() { } public static Cancellable observe(LiveData liveData, Consumer consumer) { Observer observer = value -> { if (value != null) { consumer.accept(value); } }; liveData.observeForever(observer); return () -> { liveData.removeObserver(observer); return true; }; } public static LiveData liveDataOf(T value) { return new MutableLiveData<>(value); } public static LiveData> combine(LiveData one, LiveData two) { return new CombinedLiveData<>( new LiveData[]{one, two}, values -> new Pair<>((T) values[0], (U) values[1]) ); } public static LiveData> combine3(LiveData one, LiveData two, LiveData three) { return new CombinedLiveData<>( new LiveData[]{one, two, three}, values -> new Triple<>((T) values[0], (U) values[1], (V) values[2]) ); } public static LiveData> combine4(LiveData one, LiveData two, LiveData three, LiveData four) { return new CombinedLiveData<>( new LiveData[]{one, two, three, four}, values -> new Quad<>((T) values[0], (U) values[1], (V) values[2], (W) values[3]) ); } private static class CombinedLiveData extends MediatorLiveData { private final Object[] values; private final Function map; private T lastEmitted; CombinedLiveData(LiveData[] sources, Function map) { this.map = map; values = new Object[sources.length]; for (int i = 0; i < sources.length; i++) { int index = i; addSource(sources[i], value -> { values[index] = value; update(); }); } update(); } private void update() { T newValue = map.apply(values); if (lastEmitted == null || !lastEmitted.equals(newValue)) { lastEmitted = newValue; setValue(newValue); } } } public static class Quad { public final T first; public final U second; public final V third; public final W fourth; public Quad(T first, U second, V third, W fourth) { this.first = first; this.second = second; this.third = third; this.fourth = fourth; } } } ================================================ FILE: androidshared/src/main/java/org/odk/collect/androidshared/livedata/NonNullLiveData.kt ================================================ package org.odk.collect.androidshared.livedata import androidx.lifecycle.LiveData /** * Allows creating LiveData values that can be used without null checks */ abstract class NonNullLiveData(value: T) : LiveData(value) { override fun getValue(): T { return super.getValue() as T } } class MutableNonNullLiveData(value: T) : NonNullLiveData(value) { public override fun postValue(value: T) { super.postValue(value) } public override fun setValue(value: T) { super.setValue(value) } } ================================================ FILE: androidshared/src/main/java/org/odk/collect/androidshared/system/BroadcastReceiverRegister.kt ================================================ package org.odk.collect.androidshared.system import android.content.BroadcastReceiver import android.content.Context import android.content.IntentFilter interface BroadcastReceiverRegister { fun registerReceiver(receiver: BroadcastReceiver, filter: IntentFilter) fun unregisterReceiver(receiver: BroadcastReceiver) } class BroadcastReceiverRegisterImpl(private val context: Context) : BroadcastReceiverRegister { override fun registerReceiver(receiver: BroadcastReceiver, filter: IntentFilter) { context.registerReceiver(receiver, filter) } override fun unregisterReceiver(receiver: BroadcastReceiver) { context.unregisterReceiver(receiver) } } ================================================ FILE: androidshared/src/main/java/org/odk/collect/androidshared/system/CameraUtils.java ================================================ package org.odk.collect.androidshared.system; /* Copyright 2018 Theodoros Tyrovouzis 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. */ import android.content.Context; import android.hardware.Camera; import android.hardware.camera2.CameraAccessException; import android.hardware.camera2.CameraCharacteristics; import android.hardware.camera2.CameraManager; import timber.log.Timber; public class CameraUtils { public static int getFrontCameraId() { for (int camNo = 0; camNo < Camera.getNumberOfCameras(); camNo++) { Camera.CameraInfo camInfo = new Camera.CameraInfo(); Camera.getCameraInfo(camNo, camInfo); if (camInfo.facing == Camera.CameraInfo.CAMERA_FACING_FRONT) { return camNo; } } Timber.w("No Available front camera"); return -1; } public boolean isFrontCameraAvailable(Context context) { try { //https://developer.android.com/reference/android/hardware/camera2/CameraCharacteristics.html CameraManager cameraManager = (CameraManager) context.getSystemService(Context.CAMERA_SERVICE); if (cameraManager != null) { String[] cameraId = cameraManager.getCameraIdList(); for (String id : cameraId) { CameraCharacteristics characteristics = cameraManager.getCameraCharacteristics(id); Integer facing = characteristics.get(CameraCharacteristics.LENS_FACING); if (facing != null && facing == CameraCharacteristics.LENS_FACING_FRONT) { return true; } } } } catch (CameraAccessException | NullPointerException e) { Timber.e(e); } return false; // No front-facing camera found } } ================================================ FILE: androidshared/src/main/java/org/odk/collect/androidshared/system/ContextExt.kt ================================================ package org.odk.collect.androidshared.system import android.app.Activity import android.content.Context import android.content.res.Configuration import android.util.TypedValue import androidx.annotation.AttrRes object ContextExt { /** * Be careful when using this method to retrieve colors, especially for those defined * using selectors as it might not work well. * In such cases consider using [com.google.android.material.color.MaterialColors.getColor] instead. */ @JvmStatic fun getThemeAttributeValue(context: Context, @AttrRes resId: Int): Int { val outValue = TypedValue() context.theme.resolveAttribute(resId, outValue, true) return outValue.data } @JvmStatic fun Context.isDarkTheme(): Boolean { val uiMode: Int = this.resources.configuration.uiMode return (uiMode and Configuration.UI_MODE_NIGHT_MASK) == Configuration.UI_MODE_NIGHT_YES } } ================================================ FILE: androidshared/src/main/java/org/odk/collect/androidshared/system/ExternalFilesUtils.kt ================================================ package org.odk.collect.androidshared.system import android.content.Context import java.io.File import java.io.IOException object ExternalFilesUtils { @JvmStatic fun testExternalFilesAccess(context: Context) { val externalFilesDir = context.getExternalFilesDir(null) if (externalFilesDir == null) { throw IllegalStateException("External files dir is null!") } else { try { val testFile = File(externalFilesDir, ".test") testFile.createNewFile() testFile.delete() } catch (e: IOException) { if (!externalFilesDir.exists()) { throw IllegalStateException( "External files dir does not exist: ${externalFilesDir.absolutePath}" ) } else { throw IllegalStateException( "App can't write to external files dir: ${externalFilesDir.absolutePath}", e ) } } } } } ================================================ FILE: androidshared/src/main/java/org/odk/collect/androidshared/system/IntentLauncher.kt ================================================ package org.odk.collect.androidshared.system import android.app.Activity import android.content.Context import android.content.Intent import androidx.activity.result.ActivityResultLauncher object IntentLauncherImpl : IntentLauncher { override fun launch(context: Context, intent: Intent?, onError: () -> Unit) { try { context.startActivity(intent) } catch (e: Exception) { onError() } catch (e: Error) { onError() } } override fun launchForResult( activity: Activity, intent: Intent?, requestCode: Int, onError: () -> Unit ) { try { activity.startActivityForResult(intent, requestCode) } catch (e: Exception) { onError() } catch (e: Error) { onError() } } override fun launchForResult( resultLauncher: ActivityResultLauncher, intent: Intent?, onError: () -> Unit ) { try { resultLauncher.launch(intent) } catch (e: Exception) { onError() } catch (e: Error) { onError() } } } interface IntentLauncher { fun launch(context: Context, intent: Intent?, onError: () -> Unit) fun launchForResult(activity: Activity, intent: Intent?, requestCode: Int, onError: () -> Unit) fun launchForResult( resultLauncher: ActivityResultLauncher, intent: Intent?, onError: () -> Unit ) } ================================================ FILE: androidshared/src/main/java/org/odk/collect/androidshared/system/OpenGLVersionChecker.kt ================================================ package org.odk.collect.androidshared.system import android.app.ActivityManager import android.content.Context /** * Checks if the device supports the given OpenGL ES version. * * Note: This approach may not be 100% reliable because `reqGlEsVersion` indicates * the highest version of OpenGL ES that the device's hardware is guaranteed to support * at runtime. However, it might not always reflect the actual version available. * * For a more reliable method, refer to https://developer.android.com/develop/ui/views/graphics/opengl/about-opengl#version-check. * This recommended approach is more complex to implement but offers better accuracy. */ object OpenGLVersionChecker { @JvmStatic fun isOpenGLv2Supported(context: Context): Boolean { return (context.getSystemService(Context.ACTIVITY_SERVICE) as ActivityManager) .deviceConfigurationInfo.reqGlEsVersion >= 0x20000 } @JvmStatic fun isOpenGLv3Supported(context: Context): Boolean { return (context.getSystemService(Context.ACTIVITY_SERVICE) as ActivityManager) .deviceConfigurationInfo.reqGlEsVersion >= 0x30000 } } ================================================ FILE: androidshared/src/main/java/org/odk/collect/androidshared/system/PlayServicesChecker.java ================================================ package org.odk.collect.androidshared.system; import android.app.Activity; import android.content.Context; import com.google.android.gms.common.ConnectionResult; import com.google.android.gms.common.GoogleApiAvailability; /** Created by Divya on 3/2/2017. */ public class PlayServicesChecker { private static final int PLAY_SERVICE_ERROR_REQUEST_CODE = 1000; private static int lastResultCode = ConnectionResult.SUCCESS; /** Returns true if Google Play Services is installed and up to date. */ public boolean isGooglePlayServicesAvailable(Context context) { lastResultCode = GoogleApiAvailability.getInstance() .isGooglePlayServicesAvailable(context); return lastResultCode == ConnectionResult.SUCCESS; } /** Shows an error dialog for the last call to isGooglePlayServicesAvailable(). */ public void showGooglePlayServicesAvailabilityErrorDialog(Context context) { GoogleApiAvailability.getInstance().getErrorDialog( (Activity) context, lastResultCode, PLAY_SERVICE_ERROR_REQUEST_CODE).show(); } } ================================================ FILE: androidshared/src/main/java/org/odk/collect/androidshared/system/ProcessRestoreDetector.kt ================================================ package org.odk.collect.androidshared.system import android.content.Context import android.os.Bundle import org.odk.collect.androidshared.data.getState import org.odk.collect.shared.strings.UUIDGenerator object ProcessRestoreDetector { @JvmStatic fun registerOnSaveInstanceState(context: Context, outState: Bundle) { val uuid = UUIDGenerator().generateUUID() context.getState().set("${getKey()}:$uuid", Any()) outState.putString(getKey(), uuid) } @JvmStatic fun isProcessRestoring(context: Context, savedInstanceState: Bundle?): Boolean { return if (savedInstanceState != null) { val bundleUuid = savedInstanceState.getString(getKey()) context.getState().get("${getKey()}:$bundleUuid") == null } else { false } } private fun getKey() = this::class.qualifiedName } ================================================ FILE: androidshared/src/main/java/org/odk/collect/androidshared/system/UriExt.kt ================================================ package org.odk.collect.androidshared.system import android.content.ContentResolver import android.content.Context import android.net.Uri import android.provider.OpenableColumns import android.webkit.MimeTypeMap import androidx.core.net.toFile import java.io.File import java.io.FileOutputStream fun Uri.copyToFile(context: Context, dest: File) { try { context.contentResolver.openInputStream(this)?.use { inputStream -> FileOutputStream(dest).use { outputStream -> inputStream.copyTo(outputStream) } } } catch (e: Exception) { // ignore } } fun Uri.getFileExtension(context: Context): String? { var extension = getFileName(context)?.substringAfterLast(".", "") if (extension.isNullOrEmpty()) { val mimeType = context.contentResolver.getType(this) extension = if (scheme == ContentResolver.SCHEME_CONTENT) { MimeTypeMap.getSingleton().getExtensionFromMimeType(mimeType) } else { MimeTypeMap.getFileExtensionFromUrl(toString()) } if (extension.isNullOrEmpty()) { extension = mimeType?.substringAfterLast("/", "") } } return if (extension.isNullOrEmpty()) { null } else { ".$extension" } } fun Uri.getFileName(context: Context): String? { var fileName: String? = null try { when (scheme) { ContentResolver.SCHEME_FILE -> fileName = toFile().name ContentResolver.SCHEME_CONTENT -> { val cursor = context.contentResolver.query(this, null, null, null, null) cursor?.use { if (it.moveToFirst()) { val fileNameColumnIndex = it.getColumnIndex(OpenableColumns.DISPLAY_NAME) if (fileNameColumnIndex != -1) { fileName = it.getString(fileNameColumnIndex) } } } } ContentResolver.SCHEME_ANDROID_RESOURCE -> { // for uris like [android.resource://com.example.app/1234567890] val resourceId = lastPathSegment?.toIntOrNull() if (resourceId != null) { fileName = context.resources.getResourceName(resourceId) } else { // for uris like [android.resource://com.example.app/raw/sample] val packageName = authority if (pathSegments.size >= 2) { val resourceType = pathSegments[0] val resourceEntryName = pathSegments[1] val resId = context.resources.getIdentifier(resourceEntryName, resourceType, packageName) if (resId != 0) { fileName = context.resources.getResourceName(resId) } } } } } if (fileName == null) { fileName = path?.substringAfterLast("/") } } catch (e: Exception) { // ignore } return fileName } ================================================ FILE: androidshared/src/main/java/org/odk/collect/androidshared/ui/AlertStore.kt ================================================ package org.odk.collect.androidshared.ui /** * Component for recording "alerts". This is useful for testing transient UI elements like toasts, * flashes or snackbars that are susceptible to flakiness with assertions running after they have * disappeared. */ class AlertStore { var enabled = false private var recordedAlerts = mutableListOf() fun register(alert: String) { if (enabled) { recordedAlerts.add(alert) } } fun popAll(): List { val copy = recordedAlerts.toList() recordedAlerts.clear() return copy } } ================================================ FILE: androidshared/src/main/java/org/odk/collect/androidshared/ui/Animations.kt ================================================ package org.odk.collect.androidshared.ui import android.animation.Animator import android.animation.AnimatorSet import android.animation.ValueAnimator import android.view.View import org.odk.collect.androidshared.ui.Animations.DISABLE_ANIMATIONS /** * Helpers/extensions for running animations. These are "test safe" in that animations can be disabled * using [DISABLE_ANIMATIONS] - this should be set to `true` in Robolectric tests to avoid * infinite loops. */ object Animations { var DISABLE_ANIMATIONS = false @JvmStatic fun View.createAlphaAnimation( startValue: Float, endValue: Float, duration: Long ): DisableableAnimatorWrapper { val animation = ValueAnimator.ofFloat(startValue, endValue) animation.duration = duration animation.addUpdateListener { this.alpha = it.animatedValue as Float } return DisableableAnimatorWrapper(animation) } } class DisableableAnimatorWrapper(private val wrapped: Animator) { fun onEnd(onEnd: () -> Unit): DisableableAnimatorWrapper { wrapped.addListener(object : Animator.AnimatorListener { override fun onAnimationStart(animation: Animator) { } override fun onAnimationEnd(animation: Animator) { onEnd() } override fun onAnimationCancel(animation: Animator) { } override fun onAnimationRepeat(animation: Animator) { } }) return this } fun then(other: DisableableAnimatorWrapper): DisableableAnimatorWrapper { val set = AnimatorSet() set.playSequentially(this.wrapped, other.wrapped) return DisableableAnimatorWrapper(set) } fun start() { if (!DISABLE_ANIMATIONS) { wrapped.start() } else { // Just run listeners immediately if we're not running the actual animations if (wrapped is AnimatorSet) { (wrapped.childAnimations + wrapped).forEach { anim -> anim.listeners?.forEach { it.onAnimationStart(wrapped) it.onAnimationEnd(wrapped) } } } else { wrapped.listeners?.forEach { it.onAnimationStart(wrapped) it.onAnimationEnd(wrapped) } } } } } ================================================ FILE: androidshared/src/main/java/org/odk/collect/androidshared/ui/ColorPickerDialog.kt ================================================ package org.odk.collect.androidshared import android.app.Dialog import android.graphics.Color import android.graphics.drawable.GradientDrawable import android.os.Bundle import android.view.Gravity import android.view.LayoutInflater import android.view.ViewGroup import android.widget.LinearLayout import androidx.appcompat.app.AlertDialog import androidx.appcompat.widget.AppCompatTextView import androidx.core.content.ContextCompat import androidx.core.widget.doOnTextChanged import androidx.fragment.app.DialogFragment import androidx.fragment.app.activityViewModels import androidx.lifecycle.LiveData import androidx.lifecycle.MutableLiveData import androidx.lifecycle.ViewModel import com.google.android.material.dialog.MaterialAlertDialogBuilder import org.odk.collect.androidshared.databinding.ColorPickerDialogLayoutBinding import java.lang.Exception class ColorPickerDialog : DialogFragment() { lateinit var binding: ColorPickerDialogLayoutBinding val model: ColorPickerViewModel by activityViewModels() override fun onCreateDialog(savedInstanceState: Bundle?): Dialog { binding = ColorPickerDialogLayoutBinding.inflate(LayoutInflater.from(context)) binding.hexColor.doOnTextChanged { color, _, _, _ -> updateCurrentColorCircle("#$color") } binding.currentColor.text = requireArguments().getString(CURRENT_ICON)!! fixHexColorPrefix() setListeners() setCurrentColor(requireArguments().getString(CURRENT_COLOR)!!) return MaterialAlertDialogBuilder(requireContext()) .setView(binding.root) .setTitle(org.odk.collect.strings.R.string.project_color) .setNegativeButton(org.odk.collect.strings.R.string.cancel) { _, _ -> dismiss() } .setPositiveButton(org.odk.collect.strings.R.string.ok) { _, _ -> model.pickColor("#${binding.hexColor.text}") } .create() } private fun setListeners() { binding.color1.setOnClickListener { setCurrentColor(R.color.color1) } binding.color2.setOnClickListener { setCurrentColor(R.color.color2) } binding.color3.setOnClickListener { setCurrentColor(R.color.color3) } binding.color4.setOnClickListener { setCurrentColor(R.color.color4) } binding.color5.setOnClickListener { setCurrentColor(R.color.color5) } binding.color6.setOnClickListener { setCurrentColor(R.color.color6) } binding.color7.setOnClickListener { setCurrentColor(R.color.color7) } binding.color8.setOnClickListener { setCurrentColor(R.color.color8) } binding.color9.setOnClickListener { setCurrentColor(R.color.color9) } binding.color10.setOnClickListener { setCurrentColor(R.color.color10) } binding.color11.setOnClickListener { setCurrentColor(R.color.color11) } binding.color12.setOnClickListener { setCurrentColor(R.color.color12) } binding.color13.setOnClickListener { setCurrentColor(R.color.color13) } binding.color14.setOnClickListener { setCurrentColor(R.color.color14) } binding.color15.setOnClickListener { setCurrentColor(R.color.color15) } } private fun setCurrentColor(color: Int) { setCurrentColor("#${Integer.toHexString(ContextCompat.getColor(requireContext(), color)).substring(2)}") } private fun setCurrentColor(color: String) { binding.hexColor.setText(color.substring(1)) } private fun updateCurrentColorCircle(color: String) { try { (binding.currentColor.background as GradientDrawable).setColor(Color.parseColor(color)) binding.hexColor.error = null (dialog as? AlertDialog)?.also { it.getButton(AlertDialog.BUTTON_POSITIVE).alpha = 1f it.getButton(AlertDialog.BUTTON_POSITIVE).isEnabled = true } } catch (e: Exception) { binding.hexColor.error = getString(org.odk.collect.strings.R.string.invalid_hex_code) (dialog as? AlertDialog)?.also { it.getButton(AlertDialog.BUTTON_POSITIVE).alpha = 0.3f it.getButton(AlertDialog.BUTTON_POSITIVE).isEnabled = false } } } // https://github.com/material-components/material-components-android/issues/773#issuecomment-603759649 private fun fixHexColorPrefix() { val prefixView = binding.hexColorLayout.findViewById(com.google.android.material.R.id.textinput_prefix_text) prefixView.layoutParams = LinearLayout.LayoutParams(ViewGroup.LayoutParams.WRAP_CONTENT, ViewGroup.LayoutParams.MATCH_PARENT) prefixView.gravity = Gravity.CENTER } companion object { const val CURRENT_COLOR = "CURRENT_COLOR" const val CURRENT_ICON = "CURRENT_ICON" } } class ColorPickerViewModel : ViewModel() { private val _pickedColor = MutableLiveData() val pickedColor: LiveData = _pickedColor fun pickColor(color: String) { _pickedColor.value = color } } ================================================ FILE: androidshared/src/main/java/org/odk/collect/androidshared/ui/ComposeThemeProvider.kt ================================================ package org.odk.collect.androidshared.ui import androidx.compose.runtime.Composable import androidx.compose.ui.platform.ComposeView import androidx.compose.ui.platform.ViewCompositionStrategy /** * Interface that allows a [android.content.Context] (such as an [android.app.Activity]) to * provide a Compose Theme to any child [ComposeView] instances: * * ```kotlin * class MyActivity : AppCompatActivity(), ComposeThemeProvider { * * override fun onCreate(savedInstanceState: Bundle?) { * super.onCreate(savedInstanceState) * setContentView(R.layout.my_activity_layout) * findViewById(R.id.compose_view).setContextThemedContent { * Text("Hello, world!") * } * } * * @Composable * override fun Theme(content: @Composable (() -> Unit)) { * MyTheme { content() } * } * } */ interface ComposeThemeProvider { @Composable fun Theme(content: @Composable () -> Unit) companion object { fun ComposeView.setContextThemedContent( viewCompositionStrategy: ViewCompositionStrategy, content: @Composable () -> Unit ) { setViewCompositionStrategy(viewCompositionStrategy) setContent { val themeProvider = context as? ComposeThemeProvider if (themeProvider != null) { themeProvider.Theme { content() } } else { content() } } } @Deprecated("Use overload instead") fun ComposeView.setContextThemedContent(content: @Composable () -> Unit) { setContextThemedContent(ViewCompositionStrategy.Default, content) } } } ================================================ FILE: androidshared/src/main/java/org/odk/collect/androidshared/ui/DialogFragmentUtils.kt ================================================ package org.odk.collect.androidshared.ui import android.os.Bundle import androidx.fragment.app.DialogFragment import androidx.fragment.app.FragmentManager import timber.log.Timber import kotlin.reflect.KClass object DialogFragmentUtils { @JvmStatic fun showIfNotShowing( dialogClass: Class, fragmentManager: FragmentManager ) { showIfNotShowing(dialogClass, null, fragmentManager) } @JvmStatic fun showIfNotShowing( dialogClass: Class, args: Bundle?, fragmentManager: FragmentManager ) { if (fragmentManager.isDestroyed) { return } val fragmentFactory = fragmentManager.fragmentFactory val instance = fragmentFactory.instantiate(dialogClass.classLoader, dialogClass.name) as T instance.arguments = args showIfNotShowing(instance, dialogClass, fragmentManager) } @JvmStatic fun showIfNotShowing( newDialog: T, dialogClass: Class, fragmentManager: FragmentManager ) { showIfNotShowing(newDialog, dialogClass.name, fragmentManager) } @JvmStatic fun showIfNotShowing( newDialog: T, tag: String, fragmentManager: FragmentManager ) { if (fragmentManager.isStateSaved) { return } val existingDialog = fragmentManager.findFragmentByTag(tag) as T? if (existingDialog == null) { newDialog.show(fragmentManager.beginTransaction(), tag) // We need to execute this transaction. Otherwise a follow up call to this method // could happen before the Fragment exists in the Fragment Manager and so the // call to findFragmentByTag would return null and result in second dialog being show. try { fragmentManager.executePendingTransactions() } catch (e: IllegalStateException) { Timber.w(e) } } } @JvmStatic fun dismissDialog(dialogClazz: Class<*>, fragmentManager: FragmentManager) { dismissDialog(dialogClazz.name, fragmentManager) } @JvmStatic fun dismissDialog(tag: String, fragmentManager: FragmentManager) { val existingDialog = fragmentManager.findFragmentByTag(tag) as DialogFragment? if (existingDialog != null) { existingDialog.dismissAllowingStateLoss() // We need to execute this transaction. Otherwise a next attempt to display a dialog // could happen before the Fragment is dismissed in Fragment Manager and so the // call to findFragmentByTag would return something (not null) and as a result the // next dialog won't be displayed. try { fragmentManager.executePendingTransactions() } catch (e: IllegalStateException) { Timber.w(e) } } } fun FragmentManager.showIfNotShowing(dialogClass: KClass) { showIfNotShowing(dialogClass.java, this) } } ================================================ FILE: androidshared/src/main/java/org/odk/collect/androidshared/ui/DialogUtils.kt ================================================ package org.odk.collect.androidshared.ui import android.content.Context import androidx.annotation.StringRes import com.google.android.material.dialog.MaterialAlertDialogBuilder object DialogUtils { @JvmStatic fun show( context: Context, @StringRes titleRes: Int, @StringRes messageRes: Int, ) { MaterialAlertDialogBuilder(context) .setTitle(titleRes) .setMessage(messageRes) .setPositiveButton(org.odk.collect.strings.R.string.ok, null) .show() } } ================================================ FILE: androidshared/src/main/java/org/odk/collect/androidshared/ui/DisplayString.kt ================================================ package org.odk.collect.androidshared.ui import android.content.Context sealed class DisplayString { data class Raw(val value: String) : DisplayString() data class Resource(val resource: Int) : DisplayString() fun getString(context: Context): String { return when (this) { is Raw -> value is Resource -> context.getString(resource) } } } ================================================ FILE: androidshared/src/main/java/org/odk/collect/androidshared/ui/EdgeToEdge.kt ================================================ package org.odk.collect.androidshared.ui import android.app.Activity import android.content.Context import android.view.View import android.view.ViewGroup import android.view.Window import androidx.annotation.LayoutRes import androidx.core.view.ViewCompat import androidx.core.view.WindowCompat import androidx.core.view.WindowInsetsCompat import androidx.core.view.updateLayoutParams import androidx.core.view.updatePadding import org.odk.collect.androidshared.system.ContextExt.isDarkTheme object EdgeToEdge { @JvmStatic fun Activity.setView(@LayoutRes layout: Int, edgeToEdge: Boolean) { window.handleEdgeToEdge(this, edgeToEdge) setContentView(layout) } @JvmStatic fun Activity.setView(view: View, edgeToEdge: Boolean) { window.handleEdgeToEdge(this, edgeToEdge) setContentView(view) } fun Window.handleEdgeToEdge(context: Context, edgeToEdge: Boolean = false) { WindowCompat.enableEdgeToEdge(this) WindowCompat.getInsetsController(this, this.decorView).let { val darkTheme = context.isDarkTheme() it.isAppearanceLightStatusBars = !darkTheme it.isAppearanceLightNavigationBars = !darkTheme } if (!edgeToEdge) { avoidEdgeToEdge() } } fun View.applyBottomBarInsetMargins() { ViewCompat.setOnApplyWindowInsetsListener(this) { v, windowInsets -> val systemBarsInsets = windowInsets.getInsets(WindowInsetsCompat.Type.systemBars()) val keyboardInsets = windowInsets.getInsets(WindowInsetsCompat.Type.ime()) v.updatePadding( bottom = maxOf(0, keyboardInsets.bottom - systemBarsInsets.bottom) ) windowInsets } } private fun Window.avoidEdgeToEdge() { val contentView = decorView.findViewById(android.R.id.content) contentView.addSystemBarInsetMargins() } private fun View.addSystemBarInsetMargins() { ViewCompat.setOnApplyWindowInsetsListener(this) { v, windowInsets -> val insets = windowInsets.getInsets(WindowInsetsCompat.Type.systemBars()) v.updateLayoutParams { topMargin = insets.top bottomMargin = insets.bottom leftMargin = insets.left rightMargin = insets.right } windowInsets } } } ================================================ FILE: androidshared/src/main/java/org/odk/collect/androidshared/ui/FragmentFactoryBuilder.kt ================================================ package org.odk.collect.androidshared.ui import androidx.fragment.app.Fragment import androidx.fragment.app.FragmentFactory import kotlin.reflect.KClass /** * Convenience object for creating [FragmentFactory] instances without needing to use an inner, * private or anonymous class. */ class FragmentFactoryBuilder { private val classesAndFactories = mutableListOf, () -> Fragment>>() fun forClass(fragmentClass: KClass<*>, factory: () -> Fragment): FragmentFactoryBuilder { return forClass(fragmentClass.java, factory) } fun forClass(fragmentClass: Class<*>, factory: () -> Fragment): FragmentFactoryBuilder { classesAndFactories.add(Pair(fragmentClass, factory)) return this } fun build(): FragmentFactory { return object : androidx.fragment.app.FragmentFactory() { override fun instantiate(classLoader: ClassLoader, className: String): Fragment { val fragmentClass = loadFragmentClass(classLoader, className) val factory = classesAndFactories.find { it.first.isAssignableFrom(fragmentClass) }?.second return if (factory != null) { factory() } else { super.instantiate(classLoader, className) } } } } } ================================================ FILE: androidshared/src/main/java/org/odk/collect/androidshared/ui/GroupClickListener.kt ================================================ package org.odk.collect.androidshared.ui import android.view.View import androidx.constraintlayout.widget.Group // https://stackoverflow.com/questions/59020818/group-multiple-views-in-constraint-layout-to-set-only-one-click-listener fun Group.addOnClickListener(listener: (view: View) -> Unit) { referencedIds.forEach { id -> rootView.findViewById(id).setOnClickListener(listener) } } fun List.addOnClickListener(listener: (view: View) -> Unit) { forEach { it.setOnClickListener(listener) } } ================================================ FILE: androidshared/src/main/java/org/odk/collect/androidshared/ui/ListFragmentStateAdapter.kt ================================================ package org.odk.collect.androidshared.ui import androidx.fragment.app.Fragment import androidx.fragment.app.FragmentActivity import androidx.viewpager2.adapter.FragmentStateAdapter class ListFragmentStateAdapter( activity: FragmentActivity, private val fragments: List> ) : FragmentStateAdapter(activity) { private val fragmentFactory = activity.supportFragmentManager.fragmentFactory override fun createFragment(position: Int): Fragment { return fragmentFactory.instantiate( Thread.currentThread().contextClassLoader, fragments[position].name ) } override fun getItemCount(): Int { return fragments.size } } ================================================ FILE: androidshared/src/main/java/org/odk/collect/androidshared/ui/MenuExt.kt ================================================ package org.odk.collect.androidshared.ui import android.annotation.SuppressLint import android.view.Menu import androidx.appcompat.view.menu.MenuBuilder /** * Currently, there is no public API to add icons to popup menus. * The following workaround is for API 21+, and uses library-only APIs, * and is not guaranteed to work in future versions. */ @SuppressLint("RestrictedApi") fun Menu.enableIconsVisibility() { if (this is MenuBuilder) { this.setOptionalIconsVisible(true) } } ================================================ FILE: androidshared/src/main/java/org/odk/collect/androidshared/ui/ObviousProgressBar.kt ================================================ package org.odk.collect.androidshared.ui import android.content.Context import android.os.Handler import android.util.AttributeSet import com.google.android.material.progressindicator.LinearProgressIndicator /** * A progress bar that shows for a minimum amount fo time so it's obvious to the user that * something has happened. */ class ObviousProgressBar( context: Context, attrs: AttributeSet? ) : LinearProgressIndicator(context, attrs) { private val handler = Handler() private var shownAt: Long? = null init { super.setVisibility(GONE) super.setIndeterminate(true) } override fun show() { handler.removeCallbacksAndMessages(null) shownAt = System.currentTimeMillis() super.setVisibility(VISIBLE) } override fun hide() { if (shownAt != null) { val timeShown = System.currentTimeMillis() - shownAt!! if (timeShown < MINIMUM_SHOW_TIME) { val delay = MINIMUM_SHOW_TIME - timeShown handler.removeCallbacksAndMessages(null) handler.postDelayed({ this.makeGone() }, delay) } else { makeGone() } } else { makeGone() } } private fun makeGone() { super.setVisibility(GONE) shownAt = null } companion object { private const val MINIMUM_SHOW_TIME = 750 } } ================================================ FILE: androidshared/src/main/java/org/odk/collect/androidshared/ui/OneSignTextWatcher.kt ================================================ package org.odk.collect.androidshared.ui import android.text.Editable import android.text.TextWatcher import android.widget.EditText import org.odk.collect.shared.strings.StringUtils class OneSignTextWatcher(private val editText: EditText) : TextWatcher { lateinit var oldTextString: String override fun beforeTextChanged(s: CharSequence?, start: Int, count: Int, after: Int) { oldTextString = s.toString() } override fun onTextChanged(s: CharSequence?, start: Int, before: Int, count: Int) { } override fun afterTextChanged(editable: Editable?) { editable.toString().let { if (it != oldTextString) { val trimmedString = StringUtils.firstCharacterOrEmoji(it) editText.setText(trimmedString) editText.setSelection(trimmedString.length) } } } } ================================================ FILE: androidshared/src/main/java/org/odk/collect/androidshared/ui/PrefUtils.kt ================================================ package org.odk.collect.androidshared.ui import android.content.Context import androidx.preference.ListPreference import org.odk.collect.shared.settings.Settings object PrefUtils { @JvmStatic fun createListPref( context: Context, key: String, title: String, labelIds: IntArray, values: Array, settings: Settings ): ListPreference { val labels: Array = labelIds.map { context.getString(it) }.toTypedArray() return createListPref(context, key, title, labels, values, settings) } /** * Gets an integer value from the shared preferences. If the preference has * a string value, attempts to convert it to an integer. If the preference * is not found or is not a valid integer, returns the defaultValue. */ @JvmStatic fun getInt(key: String?, defaultValue: Int, settings: Settings): Int { val value: Any? = settings.getAll()[key] if (value is Int) { return value } if (value is String) { try { return Integer.parseInt(value) } catch (e: NumberFormatException) { // ignore } } return defaultValue } private fun createListPref( context: Context, key: String, title: String, labels: Array, values: Array, settings: Settings ): ListPreference { ensurePrefHasValidValue(key, values, settings) return ListPreference(context).also { it.key = key it.isPersistent = true it.title = title it.dialogTitle = title it.entries = labels it.entryValues = values it.summary = "%s" } } private fun ensurePrefHasValidValue( key: String, validValues: Array, settings: Settings ) { val value = settings.getString(key) if (validValues.indexOf(value) < 0) { if (validValues.isNotEmpty()) { settings.save(key, validValues[0]) } else { settings.remove(key) } } } } ================================================ FILE: androidshared/src/main/java/org/odk/collect/androidshared/ui/ReturnToAppActivity.kt ================================================ package org.odk.collect.androidshared.ui import android.app.Activity import android.os.Bundle /** * This Activity will close as soon as it is started. This means it can be used as the content * intent of a notification so clicking it will effectively return to the screen the user * was last on (knowing what that Activity was). */ class ReturnToAppActivity : Activity() { override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) finish() } } ================================================ FILE: androidshared/src/main/java/org/odk/collect/androidshared/ui/SnackbarUtils.kt ================================================ /* Copyright 2018 Shobhit 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.odk.collect.androidshared.ui import android.view.View import android.view.ViewGroup import android.widget.Button import android.widget.ImageView import android.widget.LinearLayout import android.widget.TextView import androidx.lifecycle.LifecycleOwner import androidx.lifecycle.LiveData import androidx.lifecycle.Observer import com.google.android.material.R import com.google.android.material.snackbar.BaseTransientBottomBar import com.google.android.material.snackbar.Snackbar import org.odk.collect.androidshared.data.Consumable /** * Convenience wrapper around Android's [Snackbar] API. */ object SnackbarUtils { @JvmStatic val alertStore = AlertStore() const val DURATION_SHORT = 3500 const val DURATION_LONG = 5500 private var lastSnackbar: Snackbar? = null /** * Displays snackbar with {@param message} and multi-line message enabled. * * @param parentView The view to find a parent from. * @param anchorView The view this snackbar should be anchored above. * @param message The text to show. Can be formatted text. * @param displayDismissButton True if the dismiss button should be displayed, false otherwise. */ @JvmStatic @JvmOverloads fun showSnackbar( parentView: View, message: String, duration: Int, anchorView: View? = null, action: Action? = null, displayDismissButton: Boolean = false, onDismiss: () -> Unit = {} ) { if (message.isBlank()) { return } val snackbar = make( parentView, message, duration, anchorView, action, displayDismissButton, onDismiss ) show(snackbar) } fun show(snackbar: Snackbar) { if (snackbar != lastSnackbar) { lastSnackbar?.dismiss() } snackbar.show() lastSnackbar = snackbar val message = snackbar.view.findViewById(R.id.snackbar_text).text.toString() alertStore.register(message) } fun make( parentView: View, message: String, duration: Int, anchorView: View? = null, action: Action? = null, displayDismissButton: Boolean = false, onDismiss: () -> Unit = {} ): Snackbar = Snackbar.make(parentView, message.trim(), duration).apply { val textView = view.findViewById(R.id.snackbar_text) textView.isSingleLine = false if (anchorView?.visibility != View.GONE) { this.anchorView = anchorView } if (displayDismissButton) { view.findViewById